From 4270029db529fec96a959eb9524c458f45e3b8b6 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:54:15 +0200 Subject: [PATCH 1/8] refactor(sync): keep linked devices state per device --- src-tauri/src/commands/profiles.rs | 3 +- src-tauri/src/lib.rs | 8 +-- src-tauri/src/sync2/actions.rs | 34 +++++++---- src-tauri/src/sync2/batches.rs | 4 +- src-tauri/src/sync2/live_hub.rs | 13 ++-- src-tauri/src/sync2/service.rs | 62 +++++++++++++------- src-tauri/tests/profiles/store_inventory.tsv | 30 +++++----- src-tauri/tests/sync2_device_state.rs | 58 ++++++++++++++++++ 8 files changed, 153 insertions(+), 59 deletions(-) create mode 100644 src-tauri/tests/sync2_device_state.rs diff --git a/src-tauri/src/commands/profiles.rs b/src-tauri/src/commands/profiles.rs index d6c1ea12..be9bd7be 100644 --- a/src-tauri/src/commands/profiles.rs +++ b/src-tauri/src/commands/profiles.rs @@ -75,10 +75,11 @@ pub async fn profiles_delete( #[tauri::command] pub async fn profiles_prepare_switch( id: String, + app: AppHandle, service: State<'_, ProfileService>, sync: State<'_, Arc>, ) -> Result<(), ErrorResponse> { - let sync_busy = sync.is_busy().await; + let sync_busy = sync.is_busy().await || crate::sync2::service::is_syncing(&app); service .prepare_switch(&id, sync_busy) .map_err(ErrorResponse::from) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4b082204..1401a9a8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -497,11 +497,9 @@ pub fn run() { app.manage(sync2::link_task::LinkTask::default()); app.manage(sync2::lan_peers::LanPeers::default()); app.manage(sync2::live_hub::LiveHub::default()); - if active_profile.is_default { - sync2::service::spawn(app.handle().clone()); - sync2::lan_runtime::spawn(app.handle().clone()); - sync2::live::spawn(app.handle().clone()); - } + sync2::service::spawn(app.handle().clone()); + sync2::lan_runtime::spawn(app.handle().clone()); + sync2::live::spawn(app.handle().clone()); // Manage the Shorts feed service (prefetch buffer + session de-dup) app.manage(ShortsService::new()); diff --git a/src-tauri/src/sync2/actions.rs b/src-tauri/src/sync2/actions.rs index a1e6f4a9..6611c550 100644 --- a/src-tauri/src/sync2/actions.rs +++ b/src-tauri/src/sync2/actions.rs @@ -42,18 +42,22 @@ const NAME_LIMIT: usize = 64; /// This installation as the group sees it. #[derive(Clone)] pub struct Local { + /// The group's state on this device, in Default's database whichever profile runs. pub pool: SqlitePool, + /// The running profile's database, which batches are exported from and applied to. + pub data: SqlitePool, + pub profile: ActiveProfile, pub device: MemberSecrets, pub meta: DeviceMeta, } -/// Like FLOW-SYNC/1, only the Default profile syncs until profiles get their own links. +/// The database holding the group's state: Default's, whichever profile runs. +pub fn state_pool(app: &AppHandle) -> SqlitePool { + crate::db::device::pool(app.state::().inner()).clone() +} + pub async fn local(app: &AppHandle) -> Result { - let profile = app.state::(); - if !profile.is_default { - return Err(SyncError::NotDefaultProfile(profile.default_name.clone()).into()); - } - let pool = app.state::().inner().clone(); + let pool = state_pool(app); let meta = DeviceMeta { app: ledger::get_or_create_device_id(&pool).await?, name: ledger::device_name(&pool).await?, @@ -61,6 +65,8 @@ pub async fn local(app: &AppHandle) -> Result { }; Ok(Local { device: DEVICE_KEYS.get().await?, + data: app.state::().inner().clone(), + profile: app.state::().inner().clone(), pool, meta, }) @@ -97,8 +103,12 @@ pub async fn open_session<'a>( } /// Checks that this device is in no group, and gives it fresh keys for the one it is about to -/// create or join. +/// create or join. Like FLOW-SYNC/1, only the Default profile links until profiles choose links. pub async fn start_membership(local: &mut Local) -> Result<()> { + if !local.profile.is_default { + let name = local.profile.default_name.clone(); + return Err(SyncError::NotDefaultProfile(name).into()); + } if load_group(&local.pool).await?.is_some() { return Err(EngineError::AlreadyLinked); } @@ -168,7 +178,7 @@ pub struct LinkedStatus { /// What can be shown without reaching the server. pub async fn status(app: &AppHandle) -> Result { - let pool = app.state::().inner().clone(); + let pool = state_pool(app); let saved = get_setting(&pool, RECOVERY_SAVED).await?; let record = load_group(&pool).await?; Ok(LinkedStatus { @@ -355,7 +365,7 @@ pub async fn retry_keychain(app: &AppHandle) -> Result<()> { } pub async fn recovery_saved(app: &AppHandle) -> Result<()> { - set_recovery_saved(app.state::().inner(), true).await + set_recovery_saved(&state_pool(app), true).await } pub async fn rename(app: &AppHandle, id: &str, name: &str) -> Result<()> { @@ -417,7 +427,7 @@ pub async fn leave(app: &AppHandle) -> Result<()> { /// [`leave`], when this device is in a group. pub async fn leave_if_linked(app: &AppHandle) -> Result<()> { - match load_group(app.state::().inner()).await? { + match load_group(&state_pool(app)).await? { Some(_) => leave(app).await, None => Ok(()), } @@ -447,14 +457,14 @@ pub async fn depart( /// Forgets the group and this device's keys without telling the group, for a device it already /// shut out. pub async fn forget(app: &AppHandle) -> Result<()> { - forget_with_keys(app.state::().inner()).await + forget_with_keys(&state_pool(app)).await } /// Clears history, subscriptions and playlists. When this profile syncs, the caller first leaves /// the group ([`leave_if_linked`]), and the deletions recorded here are dropped, so a reset never /// reaches the other devices; linking again brings their data back. pub async fn reset_local_data(pool: &SqlitePool, syncs: bool) -> Result<()> { - if syncs && load_group(pool).await?.is_some() { + if syncs && load_group(crate::db::device::pool(pool)).await?.is_some() { return Err(EngineError::AlreadyLinked); } crate::db::watch_history::clear_watch_history(pool).await?; diff --git a/src-tauri/src/sync2/batches.rs b/src-tauri/src/sync2/batches.rs index 1ae8229a..15c4baa2 100644 --- a/src-tauri/src/sync2/batches.rs +++ b/src-tauri/src/sync2/batches.rs @@ -165,12 +165,14 @@ pub async fn mark_sent(pool: &SqlitePool, outgoing: &Outgoing) -> Result<(), Syn remember(pool, &outgoing.sent, outgoing.now_ms).await } +/// The record of what was sent is the device's, in Default's database like the rest of the group's +/// state, while `pool` may be the running profile's. async fn remember( pool: &SqlitePool, records: &[(Collection, String, String)], now_ms: i64, ) -> Result<(), SyncError> { - let mut tx = pool.begin().await?; + let mut tx = crate::db::device::pool(pool).begin().await?; for (collection, key, hash) in records { sqlx::query("INSERT INTO sync2_sent (collection, record_key, hash, sent_at) VALUES (?, ?, ?, ?) ON CONFLICT (collection, record_key) DO UPDATE SET hash = excluded.hash, sent_at = excluded.sent_at") .bind(collection.key()) diff --git a/src-tauri/src/sync2/live_hub.rs b/src-tauri/src/sync2/live_hub.rs index b38cedfc..37afe78a 100644 --- a/src-tauri/src/sync2/live_hub.rs +++ b/src-tauri/src/sync2/live_hub.rs @@ -10,13 +10,14 @@ use sqlx::SqlitePool; use tauri::{AppHandle, Emitter, Manager}; use tokio::sync::broadcast; -use super::actions::{local, open_session}; +use super::actions::{local, open_session, state_pool}; use super::engine::{EngineError, Result, now_ms}; use super::lan_peers::nearby; use super::live_message::{Ability, Answer, DeviceKind, LiveMessage, Now}; use super::live_seal::{LiveKeys, Received}; use super::live_state::{accept_live_seq, consents, next_live_seq}; use super::state::load_group; +use crate::profiles::ActiveProfile; /// The devices' presence and what they play, whenever either changes. pub const EVENT_LIVE: &str = "sync2://live"; @@ -98,7 +99,7 @@ pub async fn forget_keys(app: &AppHandle) { async fn keys(app: &AppHandle) -> Result<(LiveKeys, SqlitePool)> { let mut cached = hub(app).keys.lock().await; - let pool = app.state::().inner().clone(); + let pool = state_pool(app); if let Some(keys) = cached.as_ref() { return Ok((keys.clone(), pool)); } @@ -138,10 +139,12 @@ pub async fn announce(app: &AppHandle) -> Result<()> { Ok(()) } -/// The profile link this device syncs, which `now` and `play` name. +/// The profile link the running profile syncs, which `now` and `play` name. async fn own_link(app: &AppHandle) -> Result> { - let pool = app.state::().inner().clone(); - Ok(load_group(&pool) + if !app.state::().is_default { + return Ok(None); + } + Ok(load_group(&state_pool(app)) .await? .map(|record| record.link) .filter(|link| !link.is_empty())) diff --git a/src-tauri/src/sync2/service.rs b/src-tauri/src/sync2/service.rs index a5c05d0f..2e324282 100644 --- a/src-tauri/src/sync2/service.rs +++ b/src-tauri/src/sync2/service.rs @@ -12,10 +12,12 @@ use tauri::{AppHandle, Emitter, Manager}; use tokio::sync::Mutex; use super::actions::{ - default_link, local, login_refused, open_session, set_login_refused, stop_if_shut_out, stopped, + Local, default_link, local, login_refused, open_session, set_login_refused, state_pool, + stop_if_shut_out, stopped, }; use super::batches::{IncomingBatch, apply_batches}; -use super::engine::{EngineError, Result, now_ms}; +use super::engine::{EngineError, Result, Session, now_ms}; +use super::group_store::GroupStore; use super::live_hub::forget_keys; use super::names::device_names; use super::server::StoreError; @@ -78,7 +80,7 @@ pub fn last_sync(app: &AppHandle) -> Option { pub async fn sync_now(app: &AppHandle) -> Result> { let state = app.state::(); let _running = state.running.lock().await; - let pool = app.state::().inner().clone(); + let pool = state_pool(app); if load_group(&pool).await?.is_none() || stopped(&pool).await?.is_some() || login_refused(&pool).await? @@ -112,14 +114,42 @@ async fn run(app: &AppHandle) -> Result> { let Some(record) = load_group(&local.pool).await? else { return Ok(Vec::new()); }; - let pool = &local.pool; - let mut session = open_session(pool, &record, &local.device).await?; + let mut session = open_session(&local.pool, &record, &local.device).await?; + // Only the linked profile's data is exchanged; other profiles keep the group going. + let received = if local.profile.is_default { + let Some(received) = exchange(app, &local, &mut session).await? else { + return Ok(Vec::new()); + }; + received + } else { + session.store().upload_pending().await?; + Vec::new() + }; + session.collect_garbage(&local.pool).await?; + // A server without join requests (older than flow-sync#41) shouldn't fail the sync. + if let Some(server) = session.store().server() + && let Ok(requests) = server.join_requests().await + && !requests.is_empty() + { + let _ = app.emit(EVENT_JOIN_REQUESTS, requests); + } + Ok(received) +} + +/// Pushes the running profile's changes and applies everyone else's. `None` while a restored +/// device can't choose its link yet. +async fn exchange( + app: &AppHandle, + local: &Local, + session: &mut Session<'_, GroupStore>, +) -> Result>> { + let (pool, data) = (&local.pool, &local.data); if !session.adopt_link(pool, &default_link(app)).await? { - return Ok(Vec::new()); + return Ok(None); } flush_brains(app).await; - let mut exported = export_collections(pool, &local.meta.app, &Collection::ALL).await?; + let mut exported = export_collections(data, &local.meta.app, &Collection::ALL).await?; enrich_outgoing_albums(app, &mut exported).await; session.store().upload_pending().await?; session.push(pool, exported).await?; @@ -128,7 +158,7 @@ async fn run(app: &AppHandle) -> Result> { let (changed, received) = if pulled.batches.is_empty() { (Vec::new(), BTreeMap::new()) } else { - apply(app, pool, &local.meta.app, &pulled.batches).await? + apply(app, data, &local.meta.app, &pulled.batches).await? }; if !changed.is_empty() { let _ = app.emit(EVENT_REFRESH, &changed); @@ -137,9 +167,9 @@ async fn run(app: &AppHandle) -> Result> { let names = if received.is_empty() { BTreeMap::new() } else { - device_names(&mut session).await.unwrap_or_default() + device_names(session).await.unwrap_or_default() }; - let received: Vec = received + let received = received .into_iter() .map(|(sender, count)| Received { name: names @@ -150,18 +180,10 @@ async fn run(app: &AppHandle) -> Result> { }) .collect(); if snapshot_due(pool, &session.record().link).await? { - let exported = export_collections(pool, &local.meta.app, &Collection::ALL).await?; + let exported = export_collections(data, &local.meta.app, &Collection::ALL).await?; session.write_snapshot(pool, exported).await?; } - session.collect_garbage(pool).await?; - // A server without join requests (older than flow-sync#41) shouldn't fail the sync. - if let Some(server) = session.store().server() - && let Ok(requests) = server.join_requests().await - && !requests.is_empty() - { - let _ = app.emit(EVENT_JOIN_REQUESTS, requests); - } - Ok(received) + Ok(Some(received)) } /// Applies each sender's batches, and returns the collections they touched and how many records diff --git a/src-tauri/tests/profiles/store_inventory.tsv b/src-tauri/tests/profiles/store_inventory.tsv index 7496c0e8..69b913c7 100644 --- a/src-tauri/tests/profiles/store_inventory.tsv +++ b/src-tauri/tests/profiles/store_inventory.tsv @@ -9,21 +9,21 @@ table sync_identity DEVICE - The sync device id; stays in Default's file where o table sync_peers DEFAULT_ONLY sync Only Default syncs table sync_log DEFAULT_ONLY sync Only Default syncs table sync_changes DEFAULT_ONLY sync Only Default syncs; local edits are not recorded elsewhere -table sync2_sent DEFAULT_ONLY sync Only Default syncs; the FLOW-SYNC/2 upload ledger -table sync2_group DEFAULT_ONLY sync Only Default syncs; the FLOW-SYNC/2 group and log positions -table sync2_roster DEFAULT_ONLY sync Only Default syncs; the verified FLOW-SYNC/2 roster -table sync2_streams DEFAULT_ONLY sync Only Default syncs; how far each device's log was read -table sync2_snapshots DEFAULT_ONLY sync Only Default syncs; when each link last got a snapshot -table sync2_acks DEFAULT_ONLY sync Only Default syncs; each device's latest FLOW-SYNC/2 ack -table sync2_manifests DEFAULT_ONLY sync Only Default syncs; snapshot manifests seen, for garbage collection -table sync2_links DEFAULT_ONLY sync Only Default syncs; links that batches name, for garbage collection -table sync2_collected DEFAULT_ONLY sync Only Default syncs; how far each log was deleted -table sync2_held_roster DEFAULT_ONLY sync Only Default syncs; roster entries a LAN group keeps on its devices -table sync2_held_keys DEFAULT_ONLY sync Only Default syncs; wraps and chain boxes a LAN group keeps -table sync2_held_objects DEFAULT_ONLY sync Only Default syncs; log objects and blobs a LAN group keeps -table sync2_live_seen DEFAULT_ONLY sync Only Default syncs; the highest live counter accepted from each device -table sync2_consent DEFAULT_ONLY sync Only Default syncs; which devices may control playback here -table sync2_pending DEFAULT_ONLY sync Only Default syncs; objects written while the server was out of reach +table sync2_sent DEVICE - The group's state, in Default's file whichever profile runs: the FLOW-SYNC/2 upload ledger +table sync2_group DEVICE - The group's state, in Default's file whichever profile runs: the FLOW-SYNC/2 group and log positions +table sync2_roster DEVICE - The group's state, in Default's file whichever profile runs: the verified FLOW-SYNC/2 roster +table sync2_streams DEVICE - The group's state, in Default's file whichever profile runs: how far each device's log was read +table sync2_snapshots DEVICE - The group's state, in Default's file whichever profile runs: when each link last got a snapshot +table sync2_acks DEVICE - The group's state, in Default's file whichever profile runs: each device's latest FLOW-SYNC/2 ack +table sync2_manifests DEVICE - The group's state, in Default's file whichever profile runs: snapshot manifests seen, for garbage collection +table sync2_links DEVICE - The group's state, in Default's file whichever profile runs: links that batches name, for garbage collection +table sync2_collected DEVICE - The group's state, in Default's file whichever profile runs: how far each log was deleted +table sync2_held_roster DEVICE - The group's state, in Default's file whichever profile runs: roster entries a LAN group keeps on its devices +table sync2_held_keys DEVICE - The group's state, in Default's file whichever profile runs: wraps and chain boxes a LAN group keeps +table sync2_held_objects DEVICE - The group's state, in Default's file whichever profile runs: log objects and blobs a LAN group keeps +table sync2_live_seen DEVICE - The group's state, in Default's file whichever profile runs: the highest live counter accepted from each device +table sync2_consent DEVICE - The group's state, in Default's file whichever profile runs: which devices may control playback here +table sync2_pending DEVICE - The group's state, in Default's file whichever profile runs: objects written while the server was out of reach table watch_history PROFILE library Video and music history (is_music) table notifications PROFILE library New-upload inbox written by the poll loop for this profile's subscriptions table recommendation_events PROFILE private FlowNeuro event log diff --git a/src-tauri/tests/sync2_device_state.rs b/src-tauri/tests/sync2_device_state.rs new file mode 100644 index 00000000..c0e792e5 --- /dev/null +++ b/src-tauri/tests/sync2_device_state.rs @@ -0,0 +1,58 @@ +//! A profile other than Default applies batches into its own database, while the group's record +//! of what was sent stays in Default's. Runs in its own test binary because the device pool is +//! process-wide. + +mod sync2_common; + +use flow_desktop_lib::profiles::{ActiveProfile, open_database}; +use flow_desktop_lib::sync::canonical::Collection; +use flow_desktop_lib::sync2::batches::{apply_batches, batch_content, parse_batch}; +use sqlx::SqlitePool; +use sync2_common::{empty_pool, exported, watch, watched}; + +async fn sent_rows(path: &std::path::Path) -> i64 { + let pool = SqlitePool::connect(&format!("sqlite://{}", path.display())) + .await + .unwrap(); + let rows = sqlx::query_scalar("SELECT COUNT(*) FROM sync2_sent") + .fetch_one(&pool) + .await + .unwrap(); + pool.close().await; + rows +} + +#[tokio::test] +async fn applied_records_count_as_sent_in_defaults_database() { + let dir = std::env::temp_dir().join(format!("flow-sync2-state-{}", uuid::Uuid::new_v4())); + let study = ActiveProfile { + id: "study".into(), + is_default: false, + after_switch: false, + default_name: String::new(), + }; + let pool = open_database(&dir, &study).await.unwrap(); + + let sender = empty_pool().await; + watch(&sender, "aaaaaaaaaaa").await; + let history = exported(&sender, "app-a") + .await + .into_iter() + .find(|c| c.collection == Collection::WatchHistory) + .unwrap(); + let records: Vec<&[u8]> = history.ndjson.split(|&b| b == b'\n').collect(); + let content = batch_content(Collection::WatchHistory, "head", "link", &records); + let batch = parse_batch(&content).unwrap(); + apply_batches(&pool, "app-b", "sender", &[batch], 0) + .await + .unwrap(); + assert_eq!(watched(&pool).await, ["aaaaaaaaaaa"]); + pool.close().await; + + assert_eq!(sent_rows(&dir.join("flow-desktop.sqlite")).await, 1); + assert_eq!( + sent_rows(&dir.join("profiles/study/flow-desktop.sqlite")).await, + 0 + ); + std::fs::remove_dir_all(dir).unwrap(); +} From c21e2cf84f7e0f04789a547e3888f79a2c4300a0 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:57:54 +0200 Subject: [PATCH 2/8] feat(sync): store profile links, kept batches and profile names --- src-tauri/migrations/0036_sync2_profiles.sql | 57 +++++++++++++++ src-tauri/src/sync2/batches.rs | 55 +++++++++------ src-tauri/src/sync2/state.rs | 6 ++ src-tauri/tests/profiles/store_inventory.tsv | 8 ++- src-tauri/tests/sync2_device_state.rs | 2 +- src-tauri/tests/sync2_link_storage.rs | 74 ++++++++++++++++++++ 6 files changed, 178 insertions(+), 24 deletions(-) create mode 100644 src-tauri/migrations/0036_sync2_profiles.sql create mode 100644 src-tauri/tests/sync2_link_storage.rs diff --git a/src-tauri/migrations/0036_sync2_profiles.sql b/src-tauri/migrations/0036_sync2_profiles.sql new file mode 100644 index 00000000..05fbb350 --- /dev/null +++ b/src-tauri/migrations/0036_sync2_profiles.sql @@ -0,0 +1,57 @@ +-- FLOW-SYNC/2 profiles (spec/protocol.md, Profiles): each synced profile has a link, and devices +-- keep what other profiles need until they run. Kept in Default's database, like the group. + +-- Which local profile syncs which link. A group joined before this starts with the link Default +-- joined (`sync2_group.link`), filled in by the app, which knows Default's id. +CREATE TABLE IF NOT EXISTS sync2_profile_links ( + profile TEXT PRIMARY KEY, + link TEXT NOT NULL UNIQUE +); + +-- What was sent, now per link. `link` is empty for the profiles collection. +CREATE TABLE IF NOT EXISTS sync2_sent_links ( + link TEXT NOT NULL, + collection TEXT NOT NULL, + record_key TEXT NOT NULL, + hash TEXT NOT NULL, + sent_at INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (link, collection, record_key) +); +INSERT OR IGNORE INTO sync2_sent_links (link, collection, record_key, hash, sent_at) + SELECT g.link, s.collection, s.record_key, s.hash, s.sent_at + FROM sync2_sent s, sync2_group g WHERE g.link != ''; + +-- Batches for a linked profile that isn't running, applied in `id` order once it runs. `part` +-- numbers the blobs of a snapshot read for that link; it is 0 for a batch. +CREATE TABLE IF NOT EXISTS sync2_kept ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + link TEXT NOT NULL, + sender TEXT NOT NULL, + seq INTEGER NOT NULL, + part INTEGER NOT NULL DEFAULT 0, + collection TEXT NOT NULL, + head TEXT NOT NULL, + records BLOB NOT NULL, + UNIQUE (link, sender, seq, part) +); + +-- The profiles collection: each link's name, merged last-writer-wins by `hlc`. +CREATE TABLE IF NOT EXISTS sync2_profiles ( + link TEXT PRIMARY KEY, + name TEXT NOT NULL, + hlc TEXT NOT NULL, + deleted INTEGER NOT NULL DEFAULT 0 +); + +-- A link read from a snapshot already includes its batches up to `seq` of each sender. +CREATE TABLE IF NOT EXISTS sync2_floors ( + link TEXT NOT NULL, + did TEXT NOT NULL, + seq INTEGER NOT NULL, + PRIMARY KEY (link, did) +); + +-- Links a profile joined that are still to be read from their snapshot. +CREATE TABLE IF NOT EXISTS sync2_reading ( + link TEXT PRIMARY KEY +); diff --git a/src-tauri/src/sync2/batches.rs b/src-tauri/src/sync2/batches.rs index 15c4baa2..b81f18e3 100644 --- a/src-tauri/src/sync2/batches.rs +++ b/src-tauri/src/sync2/batches.rs @@ -40,10 +40,13 @@ pub struct Batch { #[derive(Debug, Default)] pub struct Outgoing { pub batches: Vec, - sent: Vec<(Collection, String, String)>, + sent: Vec, now_ms: i64, } +/// A record as uploaded or received: its link, collection, key and hash. +type Sent = (String, Collection, String, String); + fn record_key(collection: Collection, record: &Value) -> Option { let field = |name: &str| record.get(name)?.as_str().map(str::to_owned); match collection { @@ -81,13 +84,16 @@ fn record_hash(collection: Collection, record: &Value, line: &[u8]) -> String { async fn sent_hashes( pool: &SqlitePool, + link: &str, collection: Collection, ) -> Result, SyncError> { - let rows: Vec<(String, String, i64)> = - sqlx::query_as("SELECT record_key, hash, sent_at FROM sync2_sent WHERE collection = ?") - .bind(collection.key()) - .fetch_all(pool) - .await?; + let rows: Vec<(String, String, i64)> = sqlx::query_as( + "SELECT record_key, hash, sent_at FROM sync2_sent_links WHERE link = ? AND collection = ?", + ) + .bind(link) + .bind(collection.key()) + .fetch_all(pool) + .await?; Ok(rows .into_iter() .map(|(key, hash, at)| (key, (hash, at))) @@ -130,7 +136,7 @@ pub async fn outgoing_batches( }; for exported in exported { let collection = exported.collection; - let sent = sent_hashes(pool, collection).await?; + let sent = sent_hashes(pool, link, collection).await?; let mut changed = Vec::new(); for line in exported .ndjson @@ -147,7 +153,7 @@ pub async fn outgoing_batches( && previous.is_some_and(|(_, at)| now_ms - at < BRAIN_INTERVAL_MS); if previous.map(|(h, _)| h) != Some(&hash) && !too_soon { changed.push(line.to_vec()); - outgoing.sent.push((collection, key, hash)); + outgoing.sent.push((link.to_owned(), collection, key, hash)); } } for records in changed.chunks(RECORDS_PER_BATCH) { @@ -167,14 +173,11 @@ pub async fn mark_sent(pool: &SqlitePool, outgoing: &Outgoing) -> Result<(), Syn /// The record of what was sent is the device's, in Default's database like the rest of the group's /// state, while `pool` may be the running profile's. -async fn remember( - pool: &SqlitePool, - records: &[(Collection, String, String)], - now_ms: i64, -) -> Result<(), SyncError> { +async fn remember(pool: &SqlitePool, records: &[Sent], now_ms: i64) -> Result<(), SyncError> { let mut tx = crate::db::device::pool(pool).begin().await?; - for (collection, key, hash) in records { - sqlx::query("INSERT INTO sync2_sent (collection, record_key, hash, sent_at) VALUES (?, ?, ?, ?) ON CONFLICT (collection, record_key) DO UPDATE SET hash = excluded.hash, sent_at = excluded.sent_at") + for (link, collection, key, hash) in records { + sqlx::query("INSERT INTO sync2_sent_links (link, collection, record_key, hash, sent_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT (link, collection, record_key) DO UPDATE SET hash = excluded.hash, sent_at = excluded.sent_at") + .bind(link) .bind(collection.key()) .bind(key) .bind(hash) @@ -224,9 +227,9 @@ pub async fn apply_batches( now_ms: i64, ) -> Result { let mut staged = Vec::new(); - let mut received = BTreeSet::new(); + let mut received: BTreeMap<(&str, String), BTreeSet<&str>> = BTreeMap::new(); for batch in batches { - let Some(collection) = batch.collection else { + let (Some(collection), Some(link)) = (batch.collection, batch.link.as_deref()) else { continue; }; let lines: Vec<&[u8]> = batch @@ -237,7 +240,10 @@ pub async fn apply_batches( for line in &lines { let record: Value = serde_json::from_slice(line)?; if let Some(key) = record_key(collection, &record) { - received.insert((collection.key(), key)); + received + .entry((collection.key(), key)) + .or_default() + .insert(link); } } let ndjson = lines.join(&b'\n'); @@ -267,10 +273,15 @@ pub async fn apply_batches( .filter(|l| !l.is_empty()) { let record: Value = serde_json::from_slice(line)?; - if let Some(key) = record_key(collection, &record) - && received.contains(&(collection.key(), key.clone())) - { - known.push((collection, key, record_hash(collection, &record, line))); + let Some(key) = record_key(collection, &record) else { + continue; + }; + let Some(links) = received.get(&(collection.key(), key.clone())) else { + continue; + }; + let hash = record_hash(collection, &record, line); + for link in links { + known.push(((*link).to_owned(), collection, key.clone(), hash.clone())); } } } diff --git a/src-tauri/src/sync2/state.rs b/src-tauri/src/sync2/state.rs index 065d88ed..f2f344fa 100644 --- a/src-tauri/src/sync2/state.rs +++ b/src-tauri/src/sync2/state.rs @@ -179,6 +179,12 @@ pub async fn forget_group(pool: &SqlitePool) -> Result<(), SyncError> { "DELETE FROM sync2_roster", "DELETE FROM sync2_streams", "DELETE FROM sync2_sent", + "DELETE FROM sync2_sent_links", + "DELETE FROM sync2_profile_links", + "DELETE FROM sync2_kept", + "DELETE FROM sync2_profiles", + "DELETE FROM sync2_floors", + "DELETE FROM sync2_reading", "DELETE FROM sync2_snapshots", "DELETE FROM sync2_acks", "DELETE FROM sync2_manifests", diff --git a/src-tauri/tests/profiles/store_inventory.tsv b/src-tauri/tests/profiles/store_inventory.tsv index 69b913c7..54b218da 100644 --- a/src-tauri/tests/profiles/store_inventory.tsv +++ b/src-tauri/tests/profiles/store_inventory.tsv @@ -9,7 +9,7 @@ table sync_identity DEVICE - The sync device id; stays in Default's file where o table sync_peers DEFAULT_ONLY sync Only Default syncs table sync_log DEFAULT_ONLY sync Only Default syncs table sync_changes DEFAULT_ONLY sync Only Default syncs; local edits are not recorded elsewhere -table sync2_sent DEVICE - The group's state, in Default's file whichever profile runs: the FLOW-SYNC/2 upload ledger +table sync2_sent DEAD - Replaced by sync2_sent_links; still cleared when a group is forgotten table sync2_group DEVICE - The group's state, in Default's file whichever profile runs: the FLOW-SYNC/2 group and log positions table sync2_roster DEVICE - The group's state, in Default's file whichever profile runs: the verified FLOW-SYNC/2 roster table sync2_streams DEVICE - The group's state, in Default's file whichever profile runs: how far each device's log was read @@ -24,6 +24,12 @@ table sync2_held_objects DEVICE - The group's state, in Default's file whichever table sync2_live_seen DEVICE - The group's state, in Default's file whichever profile runs: the highest live counter accepted from each device table sync2_consent DEVICE - The group's state, in Default's file whichever profile runs: which devices may control playback here table sync2_pending DEVICE - The group's state, in Default's file whichever profile runs: objects written while the server was out of reach +table sync2_sent_links DEVICE - The group's state, in Default's file whichever profile runs: the FLOW-SYNC/2 upload ledger, per link +table sync2_profile_links DEVICE - The group's state, in Default's file whichever profile runs: which local profile syncs which link +table sync2_kept DEVICE - The group's state, in Default's file whichever profile runs: batches kept for a linked profile that isn't running +table sync2_profiles DEVICE - The group's state, in Default's file whichever profile runs: the profiles collection, each link's name +table sync2_floors DEVICE - The group's state, in Default's file whichever profile runs: how far a link read from a snapshot already goes +table sync2_reading DEVICE - The group's state, in Default's file whichever profile runs: links still to be read from their snapshot table watch_history PROFILE library Video and music history (is_music) table notifications PROFILE library New-upload inbox written by the poll loop for this profile's subscriptions table recommendation_events PROFILE private FlowNeuro event log diff --git a/src-tauri/tests/sync2_device_state.rs b/src-tauri/tests/sync2_device_state.rs index c0e792e5..c1c1aef8 100644 --- a/src-tauri/tests/sync2_device_state.rs +++ b/src-tauri/tests/sync2_device_state.rs @@ -14,7 +14,7 @@ async fn sent_rows(path: &std::path::Path) -> i64 { let pool = SqlitePool::connect(&format!("sqlite://{}", path.display())) .await .unwrap(); - let rows = sqlx::query_scalar("SELECT COUNT(*) FROM sync2_sent") + let rows = sqlx::query_scalar("SELECT COUNT(*) FROM sync2_sent_links") .fetch_one(&pool) .await .unwrap(); diff --git a/src-tauri/tests/sync2_link_storage.rs b/src-tauri/tests/sync2_link_storage.rs new file mode 100644 index 00000000..8dbb0c3f --- /dev/null +++ b/src-tauri/tests/sync2_link_storage.rs @@ -0,0 +1,74 @@ +//! Migration 0036 carries a group's upload ledger over to the ledger kept per link. + +use sqlx::SqlitePool; +use sqlx::migrate::Migrator; +use sqlx::sqlite::SqlitePoolOptions; + +const BEFORE_PROFILES: usize = 35; + +async fn migrated_to(count: usize) -> SqlitePool { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + let mut migrator: Migrator = sqlx::migrate!("./migrations"); + migrator.migrations = migrator.migrations[..count].to_vec().into(); + migrator.run(&pool).await.unwrap(); + pool +} + +async fn group_with_link(pool: &SqlitePool, link: &str) { + sqlx::query( + "INSERT INTO sync2_group (id, group_id, kind, store_url, link, own_seq, own_prev, cursor) + VALUES (1, 'g', 'server', 'https://s', ?, 0, '', 0)", + ) + .bind(link) + .execute(pool) + .await + .unwrap(); + sqlx::query("INSERT INTO sync2_sent (collection, record_key, hash, sent_at) VALUES ('likes', 'video:a', 'h', 7)") + .execute(pool) + .await + .unwrap(); +} + +async fn finish(pool: &SqlitePool) { + sqlx::migrate!("./migrations").run(pool).await.unwrap(); +} + +#[tokio::test] +async fn a_linked_group_keeps_what_it_sent_under_its_link() { + let pool = migrated_to(BEFORE_PROFILES).await; + group_with_link(&pool, "link-a").await; + finish(&pool).await; + + let sent: Vec<(String, String, String, String, i64)> = + sqlx::query_as("SELECT link, collection, record_key, hash, sent_at FROM sync2_sent_links") + .fetch_all(&pool) + .await + .unwrap(); + assert_eq!( + sent, + [( + "link-a".to_owned(), + "likes".to_owned(), + "video:a".to_owned(), + "h".to_owned(), + 7 + )] + ); +} + +#[tokio::test] +async fn a_restored_group_without_a_link_carries_nothing_over() { + let pool = migrated_to(BEFORE_PROFILES).await; + group_with_link(&pool, "").await; + finish(&pool).await; + + let sent: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sync2_sent_links") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(sent, 0); +} From d2be0bf5ddb0a423a6e6932a6d721aa9126425de Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Sat, 31 Oct 2026 19:12:11 +0200 Subject: [PATCH 3/8] feat(sync): apply batches per profile --- src-tauri/src/services/profile_service.rs | 4 + src-tauri/src/sync2/actions.rs | 10 + src-tauri/src/sync2/batches.rs | 17 ++ src-tauri/src/sync2/bootstrap.rs | 141 +++++++++---- src-tauri/src/sync2/engine.rs | 151 +++++++++++--- src-tauri/src/sync2/gc.rs | 17 +- src-tauri/src/sync2/kept.rs | 90 ++++++++ src-tauri/src/sync2/link_read.rs | 109 ++++++++++ src-tauri/src/sync2/live_hub.rs | 13 +- src-tauri/src/sync2/mod.rs | 3 + src-tauri/src/sync2/profile_links.rs | 74 +++++++ src-tauri/src/sync2/service.rs | 203 ++++++++++++------ src-tauri/src/sync2/snapshot.rs | 9 +- src-tauri/src/sync2/state.rs | 55 ++++- src-tauri/tests/sync2_profiles.rs | 243 ++++++++++++++++++++++ 15 files changed, 995 insertions(+), 144 deletions(-) create mode 100644 src-tauri/src/sync2/kept.rs create mode 100644 src-tauri/src/sync2/link_read.rs create mode 100644 src-tauri/src/sync2/profile_links.rs create mode 100644 src-tauri/tests/sync2_profiles.rs diff --git a/src-tauri/src/services/profile_service.rs b/src-tauri/src/services/profile_service.rs index 5735d540..f68bbfee 100644 --- a/src-tauri/src/services/profile_service.rs +++ b/src-tauri/src/services/profile_service.rs @@ -376,6 +376,10 @@ impl ProfileService { Ok(self.running_id(&self.load()?)) } + pub fn default_profile_id(&self) -> AppResult { + Ok(self.load()?.default_id().to_string()) + } + /// Opens a profile that isn't running. Default's file is the app data folder's own. pub async fn open_closed(&self, id: &str) -> AppResult<(SqlitePool, bool)> { let state = self.load()?; diff --git a/src-tauri/src/sync2/actions.rs b/src-tauri/src/sync2/actions.rs index 6611c550..8d6738c2 100644 --- a/src-tauri/src/sync2/actions.rs +++ b/src-tauri/src/sync2/actions.rs @@ -29,6 +29,7 @@ use super::webdav::{WebDavLogin, WebDavStore}; use super::webdav_login::{forget_login, load_login, save_login}; use crate::db::settings::{get_setting, set_setting}; use crate::profiles::ActiveProfile; +use crate::services::profile_service::ProfileService; use crate::sync::error::SyncError; use crate::sync::{changes, ledger}; @@ -47,6 +48,8 @@ pub struct Local { /// The running profile's database, which batches are exported from and applied to. pub data: SqlitePool, pub profile: ActiveProfile, + pub running_profile: String, + pub default_profile: String, pub device: MemberSecrets, pub meta: DeviceMeta, } @@ -57,6 +60,11 @@ pub fn state_pool(app: &AppHandle) -> SqlitePool { } pub async fn local(app: &AppHandle) -> Result { + let profiles = app.state::(); + let (running_profile, default_profile) = ( + profiles.running_profile_id()?, + profiles.default_profile_id()?, + ); let pool = state_pool(app); let meta = DeviceMeta { app: ledger::get_or_create_device_id(&pool).await?, @@ -67,6 +75,8 @@ pub async fn local(app: &AppHandle) -> Result { device: DEVICE_KEYS.get().await?, data: app.state::().inner().clone(), profile: app.state::().inner().clone(), + running_profile, + default_profile, pool, meta, }) diff --git a/src-tauri/src/sync2/batches.rs b/src-tauri/src/sync2/batches.rs index b81f18e3..132a1081 100644 --- a/src-tauri/src/sync2/batches.rs +++ b/src-tauri/src/sync2/batches.rs @@ -199,6 +199,23 @@ pub struct IncomingBatch { records: Vec, } +impl IncomingBatch { + /// A batch this device kept for a profile that wasn't running, read back. + pub(super) fn kept(collection: &str, link: String, head: String, records: Vec) -> Self { + Self { + collection: collection_from_key(collection), + link: Some(link), + head, + size: records.len(), + records, + } + } + + pub(super) fn records(&self) -> &[u8] { + &self.records + } +} + pub fn parse_batch(content: &[u8]) -> Result { let (header, records) = content.split_at( content diff --git a/src-tauri/src/sync2/bootstrap.rs b/src-tauri/src/sync2/bootstrap.rs index 78758d67..06010a87 100644 --- a/src-tauri/src/sync2/bootstrap.rs +++ b/src-tauri/src/sync2/bootstrap.rs @@ -1,8 +1,8 @@ -//! Starting a device that has read nothing from a snapshot (spec/protocol.md, Snapshots), once -//! garbage collection has removed the beginning of some logs. Each log goes on from the object the -//! snapshot names, chained to its hash. +//! Starting a device that has read nothing from snapshots (spec/protocol.md, Reading a link from +//! its snapshot), once garbage collection has removed the beginning of some logs. Each log goes on +//! from the lowest object the snapshots name, chained to its hash. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use super::batches::{IncomingBatch, parse_batch}; use super::blob::open_blob; @@ -11,23 +11,57 @@ use super::envelope::{ObjectType, StreamPosition}; use super::epoch::purpose_key; use super::paths::{blob_path, log_path, parse_log_path}; use super::payload::decode_payload; -use super::snapshot::Manifest; +use super::snapshot::{Manifest, Tip}; use super::store::Store; use super::{Error, array}; -/// The snapshot a device starts from: its writer, its records, and where each log goes on. -pub struct Start { +/// A snapshot a device starts from: its writer, where it sits in the writer's log, and its +/// records. +pub struct Snapshot { pub writer: String, + pub seq: u64, pub manifest: Manifest, pub batches: Vec, +} + +/// The snapshots a device starts from, one per link, and where each log goes on. +pub struct Start { + pub snapshots: Vec, pub positions: BTreeMap, } /// How many objects of all logs the snapshot includes. -fn merged(manifest: &Manifest) -> u64 { +pub(super) fn merged(manifest: &Manifest) -> u64 { manifest.vv.values().map(|tip| tip.seq).sum() } +/// Where each log goes on: right after the lowest object the snapshots include. A log one of them +/// doesn't name is read from its first object. +fn lowest_tips(manifests: &[&Manifest], me: &str) -> Result> { + let mut positions = BTreeMap::new(); + let dids: BTreeSet<&String> = manifests.iter().flat_map(|m| m.vv.keys()).collect(); + for did in dids.into_iter().filter(|did| *did != me) { + let tips: Option> = manifests.iter().map(|m| m.vv.get(did)).collect(); + let Some(tip) = tips.and_then(|tips| tips.into_iter().min_by_key(|tip| tip.seq)) else { + continue; + }; + positions.insert(did.clone(), position(tip)?); + } + Ok(positions) +} + +/// The place in a log right after the object a snapshot names. +pub(super) fn position(tip: &Tip) -> Result { + let hash = hex::decode(&tip.hash) + .ok() + .filter(|h| h.len() == 32) + .ok_or(Error::Prev)?; + Ok(StreamPosition { + seq: tip.seq, + hash: array(&hash), + }) +} + /// Whether `manifest` covers every log that no longer starts at its first object. fn covers(manifest: &Manifest, first: &BTreeMap) -> bool { first @@ -37,9 +71,13 @@ fn covers(manifest: &Manifest, first: &BTreeMap) -> bool { } impl Session<'_, S> { - /// The snapshot to start from, or `None` when every log still starts at its first object, or - /// when no snapshot covers the logs that don't. - pub(super) async fn bootstrap(&mut self) -> Result> { + /// The snapshots to start from, the newest covering one of each of `links` (of any link when + /// `None`), or `None` when every log still starts at its first object, or when no snapshot + /// covers the logs that don't. + pub(super) async fn bootstrap( + &mut self, + links: Option<&BTreeSet>, + ) -> Result> { let me = hex::encode(self.device().did); let mut logs: BTreeMap> = BTreeMap::new(); let mut cursor = 0; @@ -62,50 +100,63 @@ impl Session<'_, S> { return Ok(None); } - let mut best: Option<(String, u32, Manifest)> = None; + // Per link (`None` standing for any), the best covering snapshot: writer, seq, epoch. + let mut best: BTreeMap, (String, u64, u32, Manifest)> = BTreeMap::new(); for (did, seqs) in &mut logs { seqs.sort_unstable(); - if let Some((epoch, manifest)) = self.newest_manifest(did, seqs).await? - && covers(&manifest, &first) - && best - .as_ref() - .is_none_or(|(_, _, b)| merged(b) < merged(&manifest)) - { - best = Some((did.clone(), epoch, manifest)); + for (seq, epoch, manifest) in self.manifests_from_newest(did, seqs, links).await? { + let Some(link) = manifest.link.clone() else { + continue; + }; + let key = match links { + Some(links) if links.contains(&link) => Some(link), + Some(_) => continue, + None => None, + }; + if covers(&manifest, &first) + && best + .get(&key) + .is_none_or(|(_, _, _, b)| merged(b) < merged(&manifest)) + { + best.insert(key, (did.clone(), seq, epoch, manifest)); + } } } - let Some((writer, epoch, manifest)) = best else { + if best.is_empty() { return Ok(None); - }; - let batches = self.snapshot_batches(&writer, epoch, &manifest).await?; - let mut positions = BTreeMap::new(); - for (did, tip) in manifest.vv.iter().filter(|(did, _)| **did != me) { - let hash = hex::decode(&tip.hash) - .ok() - .filter(|h| h.len() == 32) - .ok_or(Error::Prev)?; - let position = StreamPosition { - seq: tip.seq, - hash: array(&hash), - }; - positions.insert(did.clone(), position); + } + let chosen: Vec<&Manifest> = best.values().map(|(_, _, _, m)| m).collect(); + let positions = lowest_tips(&chosen, &me)?; + let mut snapshots = Vec::new(); + for (writer, seq, epoch, manifest) in best.into_values() { + let batches = self.snapshot_batches(&writer, epoch, &manifest).await?; + snapshots.push(Snapshot { + writer, + seq, + manifest, + batches, + }); } Ok(Some(Start { - writer, - manifest, - batches, + snapshots, positions, })) } - /// The newest snapshot manifest in `did`'s log, searched from its last object back, and the - /// epoch it was written in. - async fn newest_manifest( + /// The newest snapshot manifest of each of `links` in `did`'s log (only the newest of any link + /// when `None`): where it sits and the epoch it was written in. + async fn manifests_from_newest( &mut self, did: &str, seqs: &[u64], - ) -> Result> { + links: Option<&BTreeSet>, + ) -> Result> { + let wanted = links.map_or(1, BTreeSet::len); + let mut found: Vec<(u64, u32, Manifest)> = Vec::new(); for &seq in seqs.iter().rev() { + if found.len() >= wanted { + break; + } let Some(bytes) = self.store().get_object(&log_path(did, seq)).await? else { continue; }; @@ -113,14 +164,16 @@ impl Session<'_, S> { if object.header.object_type != ObjectType::Snapshot { continue; } - if let Content::Snapshot(manifest) = self.open_content(&object).await? { - return Ok(Some((object.header.epoch, manifest))); + if let Content::Snapshot(manifest) = self.open_content(&object).await? + && found.iter().all(|(_, _, m)| m.link != manifest.link) + { + found.push((seq, object.header.epoch, manifest)); } } - Ok(None) + Ok(found) } - async fn snapshot_batches( + pub(super) async fn snapshot_batches( &mut self, writer: &str, epoch: u32, diff --git a/src-tauri/src/sync2/engine.rs b/src-tauri/src/sync2/engine.rs index 833c475b..6e48a664 100644 --- a/src-tauri/src/sync2/engine.rs +++ b/src-tauri/src/sync2/engine.rs @@ -1,27 +1,31 @@ //! One sync with a store: verify the roster, push this device's changes, pull everyone else's. //! It holds no secrets beyond the session: epoch keys are unwrapped from the store each time. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use base64::Engine as _; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use sqlx::SqlitePool; use super::batches::{IncomingBatch, mark_sent, outgoing_batches, parse_batch}; +use super::bootstrap::Snapshot; use super::envelope::{ Header, ObjectType, ParsedObject, StreamPosition, next_in_stream, open_object, parse_object, seal_object, verify_object, }; use super::epoch::{check_epoch_key, open_chain, purpose_key}; use super::gc::{Ack, Logged, VersionVector, record_ack, record_links, record_manifest}; +use super::kept::{Kept, keep}; use super::keys::MemberSecrets; use super::paths::{log_path, parse_log_path}; use super::payload::{decode_payload, encode_payload}; +use super::profile_links::{Reading, link_profile}; use super::roster::{Entry, RosterState, apply_entry, replay_roster}; use super::server::StoreError; use super::snapshot::{Manifest, note_merged}; use super::state::{ - GroupRecord, add_roster_entries, load_group, roster_entries, save_group, save_streams, streams, + Floors, GroupRecord, add_roster_entries, floors, load_group, roster_entries, save_floors, + save_group, save_streams, streams, }; use super::store::Store; use super::wrap::unwrap_epoch_key; @@ -108,7 +112,12 @@ pub fn now_ms() -> i64 { /// Batches read from other devices, not applied yet, and how far reading got. #[derive(Default)] pub struct Pulled { + /// The running profile's batches, by sender, to apply before [`Session::finish`]. pub batches: BTreeMap>, + /// Batches of linked profiles that aren't running, kept by [`Session::finish`]. + pub kept: Vec, + /// Where the snapshots started from stop including each link's batches. + floors: Floors, positions: BTreeMap, cursor: i64, /// Batches and snapshot manifests read. Acks aren't acknowledged, so two idle devices don't @@ -122,6 +131,73 @@ pub struct Pulled { manifests: Vec, } +impl Pulled { + /// Takes the records of a snapshot this device starts from, as a batch of its writer's. + fn start_from(&mut self, me: &str, snapshot: Snapshot, reading: Option<&Reading>) { + self.links.push(snapshot.manifest.link.clone()); + let Some(link) = snapshot.manifest.link else { + return; + }; + for (did, tip) in snapshot.manifest.vv.iter().filter(|(did, _)| *did != me) { + self.floors.insert((link.clone(), did.clone()), tip.seq); + } + let writer = snapshot.writer; + match reading { + Some(reading) if reading.active.as_deref() != Some(link.as_str()) => { + for (part, batch) in (1..).zip(snapshot.batches) { + self.kept.push(Kept { + link: link.clone(), + sender: writer.clone(), + seq: snapshot.seq, + part, + batch, + }); + } + } + _ => self + .batches + .entry(writer) + .or_default() + .extend(snapshot.batches), + } + } + + /// Takes a batch `did` wrote at `seq`: to apply, to keep, or to skip, as `reading` asks. + fn take( + &mut self, + did: String, + seq: u64, + batch: IncomingBatch, + reading: Option<&Reading>, + floors: &Floors, + ) { + let Some(reading) = reading else { + self.batches.entry(did).or_default().push(batch); + return; + }; + let Some(link) = batch.link.clone() else { + return; + }; + if floors + .get(&(link.clone(), did.clone())) + .is_some_and(|floor| seq <= *floor) + { + return; + } + if reading.active.as_deref() == Some(link.as_str()) { + self.batches.entry(did).or_default().push(batch); + } else if reading.kept.contains(&link) { + self.kept.push(Kept { + link, + sender: did, + seq, + part: 0, + batch, + }); + } + } +} + /// What a log object carries, once checked and opened. pub(super) enum Content { Batch(IncomingBatch), @@ -139,6 +215,7 @@ pub struct Session<'a, S> { signing_keys: BTreeMap, commitments: BTreeMap>, keys: BTreeMap>, + reading: Reading, } fn decode_b64(value: &str) -> Result> { @@ -195,6 +272,7 @@ impl<'a, S: Store> Session<'a, S> { let mut session = Self { store, device, + reading: Reading::single(&record.link), record, group, state: replay_roster(&entries)?, @@ -252,6 +330,15 @@ impl<'a, S: Store> Session<'a, S> { &self.record } + /// The links this session pushes, applies and keeps; at first only the link Default joined. + pub fn reading(&self) -> &Reading { + &self.reading + } + + pub fn set_reading(&mut self, reading: Reading) { + self.reading = reading; + } + pub fn device(&self) -> &MemberSecrets { self.device } @@ -342,40 +429,36 @@ impl<'a, S: Store> Session<'a, S> { Ok(()) } - /// Uploads what changed in `exported` since the last upload. + /// Uploads what changed in `exported`, the running profile's data, since the last upload. pub async fn push( &mut self, pool: &SqlitePool, exported: Vec, ) -> Result { - let outgoing = outgoing_batches( - pool, - exported, - &self.state.head, - &self.record.link, - now_ms(), - ) - .await?; + let Some(link) = self.reading.active.clone() else { + return Ok(0); + }; + let outgoing = outgoing_batches(pool, exported, &self.state.head, &link, now_ms()).await?; for batch in &outgoing.batches { self.write(pool, ObjectType::Changes, &batch.content) .await?; } mark_sent(pool, &outgoing).await?; let bytes = outgoing.batches.iter().map(|b| b.content.len()).sum(); - note_merged(pool, &self.record.link, bytes).await?; + note_merged(pool, &link, bytes).await?; Ok(outgoing.batches.len()) } /// Reads other devices' new objects in stream order, checking each one. Nothing is applied: /// the caller applies [`Pulled::batches`] and then calls [`Session::finish`]. pub async fn pull(&mut self, pool: &SqlitePool) -> Result { - let link = self.record.link.clone(); - self.read_new(pool, Some(&link)).await + let reading = self.reading.clone(); + self.read_new(pool, Some(&reading)).await } - /// Links a restored device's profile (spec/protocol.md, Recovery): to the link other devices' - /// batches name, or to `own` when they name none. False while the logs can't be read to the - /// end yet; nothing may be pushed or pulled until this returns true. + /// Links a restored device's profile `own` (spec/protocol.md, Recovery): to the link other + /// devices' batches name, or to its own id when they name none. False while the logs can't be + /// read to the end yet; nothing may be pushed or pulled until this returns true. pub async fn adopt_link(&mut self, pool: &SqlitePool, own: &str) -> Result { if !self.record.link.is_empty() { return Ok(true); @@ -384,34 +467,39 @@ impl<'a, S: Store> Session<'a, S> { if read.stalled { return Ok(false); } - self.record.link = read + let link = read .links .into_iter() .flatten() .next() .unwrap_or_else(|| own.to_owned()); + link_profile(pool, own, &link).await?; + self.record.link.clone_from(&link); save_group(pool, &self.record).await?; + self.reading.active = Some(link); Ok(true) } - /// Batches of `link` only, or every batch when `link` is `None`. - async fn read_new(&mut self, pool: &SqlitePool, link: Option<&str>) -> Result { + /// The batches `reading` asks for, or every batch when it is `None`. + async fn read_new(&mut self, pool: &SqlitePool, reading: Option<&Reading>) -> Result { let me = hex::encode(self.device.did); let mut pulled = Pulled { positions: streams(pool).await?, cursor: self.record.cursor, ..Pulled::default() }; + let links = reading.map(|r| r.links().map(str::to_owned).collect::>()); if pulled.positions.is_empty() && pulled.cursor == 0 - && let Some(start) = self.bootstrap().await? + && let Some(start) = self.bootstrap(links.as_ref()).await? { - pulled.links.push(start.manifest.link.clone()); - if link.is_none_or(|l| start.manifest.link.as_deref() == Some(l)) { - pulled.batches.insert(start.writer, start.batches); + for snapshot in start.snapshots { + pulled.start_from(&me, snapshot, reading); } pulled.positions = start.positions; } + let mut floors = floors(pool).await?; + floors.extend(pulled.floors.clone()); loop { let cursor = u64::try_from(pulled.cursor).unwrap_or(0); let read: BTreeMap = pulled @@ -450,9 +538,7 @@ impl<'a, S: Store> Session<'a, S> { match content { Content::Batch(batch) => { pulled.links.push(batch.link.clone()); - if link.is_none_or(|l| batch.link.as_deref() == Some(l)) { - pulled.batches.entry(did).or_default().push(batch); - } + pulled.take(did, seq, batch, reading, &floors); } Content::Snapshot(manifest) => { pulled.links.push(manifest.link.clone()); @@ -542,11 +628,16 @@ impl<'a, S: Store> Session<'a, S> { }) } - /// Saves how far reading got, after the caller applied the batches, and acknowledges it. + /// Keeps the batches of profiles that aren't running, saves how far reading got, after the + /// caller applied the running profile's, and acknowledges it. pub async fn finish(&mut self, pool: &SqlitePool, pulled: Pulled) -> Result<()> { + keep(pool, &pulled.kept).await?; + save_floors(pool, &pulled.floors).await?; save_streams(pool, &pulled.positions, pulled.cursor).await?; - let merged = pulled.batches.values().flatten().map(|b| b.size).sum(); - note_merged(pool, &self.record.link, merged).await?; + if let Some(link) = &self.reading.active { + let merged = pulled.batches.values().flatten().map(|b| b.size).sum(); + note_merged(pool, link, merged).await?; + } self.record.cursor = pulled.cursor; for (did, vv) in &pulled.acks { record_ack(pool, did, vv).await?; diff --git a/src-tauri/src/sync2/gc.rs b/src-tauri/src/sync2/gc.rs index 4df30004..3cf2016a 100644 --- a/src-tauri/src/sync2/gc.rs +++ b/src-tauri/src/sync2/gc.rs @@ -87,6 +87,16 @@ async fn links(pool: &SqlitePool) -> Result>> { .collect() } +/// Links whose `profiles` record is deleted: their batches no longer need a snapshot. +async fn deleted_links(pool: &SqlitePool) -> Result> { + Ok( + sqlx::query_scalar("SELECT link FROM sync2_profiles WHERE deleted = 1") + .fetch_all(pool) + .await + .map_err(SyncError::from)?, + ) +} + async fn acks(pool: &SqlitePool) -> Result> { let rows: Vec<(String, String)> = sqlx::query_as("SELECT did, vv FROM sync2_acks") .fetch_all(pool) @@ -97,7 +107,7 @@ async fn acks(pool: &SqlitePool) -> Result> { .collect() } -async fn manifests(pool: &SqlitePool) -> Result> { +pub(super) async fn manifests(pool: &SqlitePool) -> Result> { let rows: Vec<(String, i64, String)> = sqlx::query_as("SELECT did, seq, manifest FROM sync2_manifests") .fetch_all(pool) @@ -301,7 +311,10 @@ impl Session<'_, S> { collected(pool).await?, ); let mut links = links(pool).await?; - links.insert(Some(self.record().link.clone())); + links.extend(self.reading().links().map(|link| Some(link.to_owned()))); + for link in deleted_links(pool).await? { + links.remove(&Some(link)); + } let plan = Facts { current: ¤t, collectable: &collectable, diff --git a/src-tauri/src/sync2/kept.rs b/src-tauri/src/sync2/kept.rs new file mode 100644 index 00000000..91e7f1f4 --- /dev/null +++ b/src-tauri/src/sync2/kept.rs @@ -0,0 +1,90 @@ +//! Batches kept for a linked profile that isn't running (spec/protocol.md, Profiles), applied in +//! the order they were read once it runs. + +use sqlx::SqlitePool; + +use super::batches::IncomingBatch; +use crate::sync::error::SyncError; + +/// A batch of `link` read from `sender`'s log at `seq`; `part` numbers a snapshot's blobs from 1, +/// and is 0 for a change batch. +pub struct Kept { + pub link: String, + pub sender: String, + pub seq: u64, + pub part: u32, + pub batch: IncomingBatch, +} + +fn to_i64(value: u64) -> Result { + i64::try_from(value).map_err(|_| SyncError::Protocol("log too long".into())) +} + +/// Keeps batches of collections this build knows; a batch read again is kept once. +pub async fn keep(pool: &SqlitePool, kept: &[Kept]) -> Result<(), SyncError> { + let mut tx = pool.begin().await?; + for item in kept { + let Some(collection) = item.batch.collection else { + continue; + }; + sqlx::query( + "INSERT OR IGNORE INTO sync2_kept (link, sender, seq, part, collection, head, records) + VALUES (?, ?, ?, ?, ?, ?, ?)", + ) + .bind(&item.link) + .bind(&item.sender) + .bind(to_i64(item.seq)?) + .bind(item.part) + .bind(collection.key()) + .bind(&item.batch.head) + .bind(item.batch.records()) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + Ok(()) +} + +/// The batches kept for `link`, oldest first, by row id and sender. +pub async fn kept( + pool: &SqlitePool, + link: &str, +) -> Result, SyncError> { + let rows: Vec<(i64, String, String, String, Vec)> = sqlx::query_as( + "SELECT id, sender, collection, head, records FROM sync2_kept WHERE link = ? ORDER BY id", + ) + .bind(link) + .fetch_all(pool) + .await?; + Ok(rows + .into_iter() + .map(|(id, sender, collection, head, records)| { + let batch = IncomingBatch::kept(&collection, link.to_owned(), head, records); + (id, sender, batch) + }) + .collect()) +} + +/// Forgets kept batches once applied. +pub async fn applied(pool: &SqlitePool, ids: &[i64]) -> Result<(), SyncError> { + let mut tx = pool.begin().await?; + for id in ids { + sqlx::query("DELETE FROM sync2_kept WHERE id = ?") + .bind(id) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + Ok(()) +} + +/// Whether batches of `link` wait to be applied. +pub async fn has_kept(pool: &SqlitePool, link: &str) -> Result { + Ok( + sqlx::query_scalar::<_, i64>("SELECT 1 FROM sync2_kept WHERE link = ? LIMIT 1") + .bind(link) + .fetch_optional(pool) + .await? + .is_some(), + ) +} diff --git a/src-tauri/src/sync2/link_read.rs b/src-tauri/src/sync2/link_read.rs new file mode 100644 index 00000000..c5acfcc5 --- /dev/null +++ b/src-tauri/src/sync2/link_read.rs @@ -0,0 +1,109 @@ +//! Reading a link from its snapshot (spec/protocol.md), for a profile that joins a link this +//! device hasn't been reading: the link's newest snapshot, then its batches in each log up to +//! where this device had read. + +use sqlx::SqlitePool; + +use super::bootstrap::{merged, position}; +use super::engine::{Content, Result, Session}; +use super::envelope::{StreamPosition, next_in_stream}; +use super::gc::{Logged, manifests}; +use super::kept::Kept; +use super::paths::log_path; +use super::state::{Floors, streams}; +use super::store::Store; + +/// What was read of a link: its batches, oldest first, and where its snapshot stops including +/// each sender's. +pub struct LinkRead { + pub batches: Vec, + pub floors: Floors, +} + +impl Session<'_, S> { + /// Reads `link` up to where this device has read the other logs; `None` while an object is + /// still missing from the store. + pub async fn read_link(&mut self, pool: &SqlitePool, link: &str) -> Result> { + let me = hex::encode(self.device().did); + let mut read = LinkRead { + batches: Vec::new(), + floors: Floors::new(), + }; + let start = self.newest_snapshot(pool, link).await?; + let tips = start + .as_ref() + .map(|(logged, _)| logged.manifest.vv.clone()) + .unwrap_or_default(); + if let Some((logged, epoch)) = start { + let batches = self + .snapshot_batches(&logged.did, epoch, &logged.manifest) + .await?; + for (part, batch) in (1..).zip(batches) { + read.batches + .push(kept(link, &logged.did, logged.seq, part, batch)); + } + } + for (did, reached) in streams(pool).await?.iter().filter(|(did, _)| **did != me) { + let mut last: Option = tips.get(did).map(position).transpose()?; + if let Some(tip) = &last { + read.floors.insert((link.to_owned(), did.clone()), tip.seq); + } + let from = last.as_ref().map_or(1, |tip| tip.seq + 1); + for seq in from..=reached.seq { + let Some(bytes) = self.store().get_object(&log_path(did, seq)).await? else { + return Ok(None); + }; + let object = self.verified(&bytes, did, seq)?; + last = Some(next_in_stream(last.as_ref(), &object)?); + if let Content::Batch(batch) = self.open_content(&object).await? + && batch.link.as_deref() == Some(link) + { + read.batches.push(kept(link, did, seq, 0, batch)); + } + } + } + Ok(Some(read)) + } + + /// The newest snapshot of `link` seen that is still in the store, and its epoch. + async fn newest_snapshot( + &mut self, + pool: &SqlitePool, + link: &str, + ) -> Result> { + let mut candidates: Vec = manifests(pool) + .await? + .into_iter() + .filter(|logged| logged.manifest.link.as_deref() == Some(link)) + .collect(); + candidates.sort_by_key(|logged| std::cmp::Reverse(merged(&logged.manifest))); + for logged in candidates { + let Some(bytes) = self + .store() + .get_object(&log_path(&logged.did, logged.seq)) + .await? + else { + continue; + }; + let epoch = self.verified(&bytes, &logged.did, logged.seq)?.header.epoch; + return Ok(Some((logged, epoch))); + } + Ok(None) + } +} + +fn kept( + link: &str, + sender: &str, + seq: u64, + part: u32, + batch: super::batches::IncomingBatch, +) -> Kept { + Kept { + link: link.to_owned(), + sender: sender.to_owned(), + seq, + part, + batch, + } +} diff --git a/src-tauri/src/sync2/live_hub.rs b/src-tauri/src/sync2/live_hub.rs index 37afe78a..309ccf5f 100644 --- a/src-tauri/src/sync2/live_hub.rs +++ b/src-tauri/src/sync2/live_hub.rs @@ -16,8 +16,8 @@ use super::lan_peers::nearby; use super::live_message::{Ability, Answer, DeviceKind, LiveMessage, Now}; use super::live_seal::{LiveKeys, Received}; use super::live_state::{accept_live_seq, consents, next_live_seq}; +use super::profile_links::reading; use super::state::load_group; -use crate::profiles::ActiveProfile; /// The devices' presence and what they play, whenever either changes. pub const EVENT_LIVE: &str = "sync2://live"; @@ -141,13 +141,14 @@ pub async fn announce(app: &AppHandle) -> Result<()> { /// The profile link the running profile syncs, which `now` and `play` name. async fn own_link(app: &AppHandle) -> Result> { - if !app.state::().is_default { + let local = local(app).await?; + let Some(record) = load_group(&local.pool).await? else { return Ok(None); - } - Ok(load_group(&state_pool(app)) + }; + let (running, default) = (&local.running_profile, &local.default_profile); + Ok(reading(&local.pool, &record.link, running, default) .await? - .map(|record| record.link) - .filter(|link| !link.is_empty())) + .active) } /// Sends a `play`, `cmd` or `result` from the player, and returns its counter. diff --git a/src-tauri/src/sync2/mod.rs b/src-tauri/src/sync2/mod.rs index d9b186bb..e9275717 100644 --- a/src-tauri/src/sync2/mod.rs +++ b/src-tauri/src/sync2/mod.rs @@ -22,12 +22,14 @@ pub mod http; pub mod hybrid; pub mod join_requests; pub mod kdf; +pub mod kept; pub mod keys; pub mod lan; pub mod lan_link; pub mod lan_peers; pub mod lan_runtime; pub mod lan_sync; +pub mod link_read; pub mod link_task; pub mod linking; pub mod live; @@ -43,6 +45,7 @@ pub mod pairing; pub mod paths; pub mod payload; pub mod pipe; +pub mod profile_links; pub mod recovery; pub mod recovery_flow; pub mod roster; diff --git a/src-tauri/src/sync2/profile_links.rs b/src-tauri/src/sync2/profile_links.rs new file mode 100644 index 00000000..a73c9111 --- /dev/null +++ b/src-tauri/src/sync2/profile_links.rs @@ -0,0 +1,74 @@ +//! Which local profile syncs which link (spec/protocol.md, Profiles), and what a run does with +//! each link: the running profile's batches apply now, other linked profiles' are kept. + +use std::collections::BTreeSet; + +use sqlx::SqlitePool; + +use crate::sync::error::SyncError; + +/// The links a run reads: the running profile's, and those of linked profiles that aren't running. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Reading { + pub active: Option, + pub kept: BTreeSet, +} + +impl Reading { + /// Only `link`, for the running profile; nothing when it is empty (a restored device that + /// hasn't chosen yet). + pub fn single(link: &str) -> Self { + Self { + active: (!link.is_empty()).then(|| link.to_owned()), + kept: BTreeSet::new(), + } + } + + pub fn links(&self) -> impl Iterator { + self.active.iter().chain(&self.kept).map(String::as_str) + } + + pub fn reads(&self, link: &str) -> bool { + self.active.as_deref() == Some(link) || self.kept.contains(link) + } +} + +/// Links `profile` to `link`. +pub async fn link_profile(pool: &SqlitePool, profile: &str, link: &str) -> Result<(), SyncError> { + sqlx::query( + "INSERT INTO sync2_profile_links (profile, link) VALUES (?, ?) + ON CONFLICT (profile) DO UPDATE SET link = excluded.link", + ) + .bind(profile) + .bind(link) + .execute(pool) + .await?; + Ok(()) +} + +/// The links of every linked profile, from the point of view of the running profile. A group +/// joined before profiles chose links has only the link Default joined, which `joined` names. +pub async fn reading( + pool: &SqlitePool, + joined: &str, + running: &str, + default: &str, +) -> Result { + let mut rows: Vec<(String, String)> = + sqlx::query_as("SELECT profile, link FROM sync2_profile_links") + .fetch_all(pool) + .await?; + if rows.is_empty() && !joined.is_empty() { + link_profile(pool, default, joined).await?; + rows.push((default.to_owned(), joined.to_owned())); + } + let mut reading = Reading::default(); + for (profile, link) in rows { + if profile == running { + reading.active = Some(link); + } else { + reading.kept.insert(link); + } + } + Ok(reading) +} diff --git a/src-tauri/src/sync2/service.rs b/src-tauri/src/sync2/service.rs index 2e324282..8b1e7174 100644 --- a/src-tauri/src/sync2/service.rs +++ b/src-tauri/src/sync2/service.rs @@ -7,22 +7,23 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Duration; use serde::Serialize; -use sqlx::SqlitePool; use tauri::{AppHandle, Emitter, Manager}; use tokio::sync::Mutex; use super::actions::{ - Local, default_link, local, login_refused, open_session, set_login_refused, state_pool, - stop_if_shut_out, stopped, + Local, local, login_refused, open_session, set_login_refused, state_pool, stop_if_shut_out, + stopped, }; use super::batches::{IncomingBatch, apply_batches}; use super::engine::{EngineError, Result, Session, now_ms}; use super::group_store::GroupStore; +use super::kept::{applied as applied_kept, has_kept, keep, kept}; use super::live_hub::forget_keys; use super::names::device_names; +use super::profile_links::reading; use super::server::StoreError; -use super::snapshot::snapshot_due; -use super::state::load_group; +use super::snapshot::{note_merged, snapshot_due}; +use super::state::{link_read, links_to_read, load_group, save_floors}; use crate::sync::canonical::Collection; use crate::sync::export::export_collections; use crate::sync::runtime::{ @@ -115,15 +116,8 @@ async fn run(app: &AppHandle) -> Result> { return Ok(Vec::new()); }; let mut session = open_session(&local.pool, &record, &local.device).await?; - // Only the linked profile's data is exchanged; other profiles keep the group going. - let received = if local.profile.is_default { - let Some(received) = exchange(app, &local, &mut session).await? else { - return Ok(Vec::new()); - }; - received - } else { - session.store().upload_pending().await?; - Vec::new() + let Some(received) = exchange(app, &local, &mut session).await? else { + return Ok(Vec::new()); }; session.collect_garbage(&local.pool).await?; // A server without join requests (older than flow-sync#41) shouldn't fail the sync. @@ -136,70 +130,162 @@ async fn run(app: &AppHandle) -> Result> { Ok(received) } -/// Pushes the running profile's changes and applies everyone else's. `None` while a restored -/// device can't choose its link yet. +/// Pushes the running profile's changes, applies everyone else's to it, and keeps the batches of +/// linked profiles that aren't running. `None` while a restored device can't choose its link yet. async fn exchange( app: &AppHandle, local: &Local, session: &mut Session<'_, GroupStore>, ) -> Result>> { let (pool, data) = (&local.pool, &local.data); - if !session.adopt_link(pool, &default_link(app)).await? { + if !session.adopt_link(pool, &local.default_profile).await? { return Ok(None); } + let reading = reading( + pool, + &session.record().link, + &local.running_profile, + &local.default_profile, + ) + .await?; + session.set_reading(reading.clone()); + let mut applied = Applied::default(); + if let Some(link) = &reading.active { + apply_kept(app, local, link, &mut applied).await?; + } + read_joined_links(app, local, session, &mut applied).await?; - flush_brains(app).await; - let mut exported = export_collections(data, &local.meta.app, &Collection::ALL).await?; - enrich_outgoing_albums(app, &mut exported).await; + let exported = if reading.active.is_some() { + flush_brains(app).await; + let mut exported = export_collections(data, &local.meta.app, &Collection::ALL).await?; + enrich_outgoing_albums(app, &mut exported).await; + exported + } else { + Vec::new() + }; session.store().upload_pending().await?; session.push(pool, exported).await?; let pulled = session.pull(pool).await?; - let (changed, received) = if pulled.batches.is_empty() { - (Vec::new(), BTreeMap::new()) - } else { - apply(app, data, &local.meta.app, &pulled.batches).await? - }; - if !changed.is_empty() { - let _ = app.emit(EVENT_REFRESH, &changed); + let groups: Vec<_> = pulled + .batches + .iter() + .map(|(sender, batches)| (sender.as_str(), batches.as_slice())) + .collect(); + apply(app, local, &groups, &mut applied).await?; + if !applied.changed.is_empty() { + let _ = app.emit(EVENT_REFRESH, &applied.changed); } session.finish(pool, pulled).await?; - let names = if received.is_empty() { - BTreeMap::new() - } else { - device_names(session).await.unwrap_or_default() - }; - let received = received - .into_iter() - .map(|(sender, count)| Received { - name: names - .get(&sender) - .map(|m| m.name.clone()) - .unwrap_or_default(), - count, - }) - .collect(); - if snapshot_due(pool, &session.record().link).await? { + let received = applied.received(session).await; + if let Some(link) = &reading.active + && snapshot_due(pool, link).await? + && !has_kept(pool, link).await? + && !links_to_read(pool).await?.contains(link) + { let exported = export_collections(data, &local.meta.app, &Collection::ALL).await?; session.write_snapshot(pool, exported).await?; } Ok(Some(received)) } -/// Applies each sender's batches, and returns the collections they touched and how many records -/// each sender changed here. +/// The collections a run changed, and how many records each sender changed here. +#[derive(Default)] +struct Applied { + changed: BTreeSet, + counts: BTreeMap, +} + +impl Applied { + async fn received(self, session: &mut Session<'_, GroupStore>) -> Vec { + let names = if self.counts.is_empty() { + BTreeMap::new() + } else { + device_names(session).await.unwrap_or_default() + }; + self.counts + .into_iter() + .map(|(sender, count)| Received { + name: names + .get(&sender) + .map(|m| m.name.clone()) + .unwrap_or_default(), + count, + }) + .collect() + } +} + +/// Applies the batches kept for `link` while its profile wasn't running, in the order they were +/// read, one transaction each. +async fn apply_kept( + app: &AppHandle, + local: &Local, + link: &str, + applied: &mut Applied, +) -> Result<()> { + let kept = kept(&local.pool, link).await?; + if kept.is_empty() { + return Ok(()); + } + let groups: Vec<_> = kept + .iter() + .map(|(_, sender, batch)| (sender.as_str(), std::slice::from_ref(batch))) + .collect(); + apply(app, local, &groups, applied).await?; + let ids: Vec = kept.iter().map(|(id, _, _)| *id).collect(); + applied_kept(&local.pool, &ids).await?; + let bytes = kept.iter().map(|(_, _, batch)| batch.size).sum(); + note_merged(&local.pool, link, bytes).await +} + +/// Reads the links profiles joined from their snapshots: applied for the running profile, kept +/// for the others. A link the store can't give in full yet is tried again next run. +async fn read_joined_links( + app: &AppHandle, + local: &Local, + session: &mut Session<'_, GroupStore>, + applied: &mut Applied, +) -> Result<()> { + let pool = &local.pool; + for link in links_to_read(pool).await? { + if !session.reading().reads(&link) { + link_read(pool, &link).await?; + continue; + } + let Some(read) = session.read_link(pool, &link).await? else { + continue; + }; + if session.reading().active.as_deref() == Some(link.as_str()) { + let groups: Vec<_> = read + .batches + .iter() + .map(|k| (k.sender.as_str(), std::slice::from_ref(&k.batch))) + .collect(); + apply(app, local, &groups, applied).await?; + } else { + keep(pool, &read.batches).await?; + } + save_floors(pool, &read.floors).await?; + link_read(pool, &link).await?; + } + Ok(()) +} + +/// Applies batches to the running profile, each group in one transaction. async fn apply( app: &AppHandle, - pool: &SqlitePool, - app_id: &str, - batches: &BTreeMap>, -) -> Result<(Vec, BTreeMap)> { + local: &Local, + groups: &[(&str, &[IncomingBatch])], + applied: &mut Applied, +) -> Result<()> { + if groups.is_empty() { + return Ok(()); + } flush_and_hold_brains(app).await; - let mut changed = BTreeSet::new(); - let mut received = BTreeMap::new(); let mut result = Ok(()); - for (sender, batches) in batches { - match apply_batches(pool, app_id, sender, batches, now_ms()).await { + for &(sender, batches) in groups { + match apply_batches(&local.data, &local.meta.app, sender, batches, now_ms()).await { Ok(report) => { let count = report .stats @@ -207,20 +293,21 @@ async fn apply( .map(|s| s.added + s.updated + s.tombstoned) .sum::(); if count > 0 { - received.insert(sender.clone(), count); + *applied.counts.entry((*sender).to_owned()).or_default() += count; } - changed.extend(report.stats.into_iter().map(|s| s.collection_key)); + applied + .changed + .extend(report.stats.into_iter().map(|s| s.collection_key)); } Err(error) => { - result = Err(error); + result = Err(error.into()); break; } } } // Also after a failed apply, which releases the held brain. reload_brains(app).await; - result?; - Ok((changed.into_iter().collect(), received)) + result } /// Syncs once without waiting, for example right after this device joined a group. diff --git a/src-tauri/src/sync2/snapshot.rs b/src-tauri/src/sync2/snapshot.rs index d92f1d62..df68b324 100644 --- a/src-tauri/src/sync2/snapshot.rs +++ b/src-tauri/src/sync2/snapshot.rs @@ -115,17 +115,20 @@ fn chunks(ndjson: &[u8]) -> Vec> { } impl Session<'_, S> { - /// Writes `exported`, the whole state of this link, as blobs and then a manifest naming the - /// last object of every log merged into it. + /// Writes `exported`, the whole state of the running profile's link, as blobs and then a + /// manifest naming the last object of every log merged into it. The caller first applies + /// every batch kept for that link. pub async fn write_snapshot( &mut self, pool: &SqlitePool, exported: Vec, ) -> Result<()> { + let Some(link) = self.reading().active.clone() else { + return Ok(()); + }; let group = *self.group(); let epoch = self.roster().epoch; let did = self.device().did; - let link = self.record().link.clone(); let head = self.roster().head.clone(); let key = purpose_key( self.current_key(), diff --git a/src-tauri/src/sync2/state.rs b/src-tauri/src/sync2/state.rs index f2f344fa..f334d25b 100644 --- a/src-tauri/src/sync2/state.rs +++ b/src-tauri/src/sync2/state.rs @@ -1,4 +1,4 @@ -//! The group this device belongs to, kept in Default's database (only Default syncs). +//! The group this device belongs to, kept in Default's database whichever profile runs. use std::collections::BTreeMap; @@ -202,3 +202,56 @@ pub async fn forget_group(pool: &SqlitePool) -> Result<(), SyncError> { tx.commit().await?; Ok(()) } + +/// Where each link read from a snapshot stops being included in it, by link and sender. +pub type Floors = BTreeMap<(String, String), u64>; + +pub async fn floors(pool: &SqlitePool) -> Result { + let rows: Vec<(String, String, i64)> = + sqlx::query_as("SELECT link, did, seq FROM sync2_floors") + .fetch_all(pool) + .await?; + Ok(rows + .into_iter() + .map(|(link, did, seq)| ((link, did), u64::try_from(seq).unwrap_or(0))) + .collect()) +} + +pub async fn save_floors(pool: &SqlitePool, floors: &Floors) -> Result<(), SyncError> { + let mut tx = pool.begin().await?; + for ((link, did), seq) in floors { + let seq = i64::try_from(*seq).map_err(|_| SyncError::Protocol("log too long".into()))?; + sqlx::query("INSERT INTO sync2_floors (link, did, seq) VALUES (?, ?, ?) ON CONFLICT (link, did) DO UPDATE SET seq = excluded.seq") + .bind(link) + .bind(did) + .bind(seq) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + Ok(()) +} + +/// Asks the next run to read `link` from its snapshot, for a profile that just joined it. +pub async fn read_from_snapshot(pool: &SqlitePool, link: &str) -> Result<(), SyncError> { + sqlx::query("INSERT OR IGNORE INTO sync2_reading (link) VALUES (?)") + .bind(link) + .execute(pool) + .await?; + Ok(()) +} + +/// Links still to be read from their snapshot. +pub async fn links_to_read(pool: &SqlitePool) -> Result, SyncError> { + Ok(sqlx::query_scalar("SELECT link FROM sync2_reading") + .fetch_all(pool) + .await?) +} + +pub async fn link_read(pool: &SqlitePool, link: &str) -> Result<(), SyncError> { + sqlx::query("DELETE FROM sync2_reading WHERE link = ?") + .bind(link) + .execute(pool) + .await?; + Ok(()) +} diff --git a/src-tauri/tests/sync2_profiles.rs b/src-tauri/tests/sync2_profiles.rs new file mode 100644 index 00000000..6fd08e70 --- /dev/null +++ b/src-tauri/tests/sync2_profiles.rs @@ -0,0 +1,243 @@ +//! FLOW-SYNC/2 with several linked profiles (spec/protocol.md, Profiles): the running profile's +//! batches apply, other linked profiles' are kept, each link starts from its own snapshot, and a +//! deleted link no longer holds back garbage collection. + +mod sync2_common; + +use std::collections::BTreeSet; + +use flow_desktop_lib::sync2::batches::{IncomingBatch, apply_batches}; +use flow_desktop_lib::sync2::engine::{Session, now_ms}; +use flow_desktop_lib::sync2::kept::kept; +use flow_desktop_lib::sync2::paths::log_path; +use flow_desktop_lib::sync2::profile_links::Reading; +use sqlx::SqlitePool; +use sync2_common::{Group, MemoryStore, device_pool, empty_pool, exported, group, watch, watched}; + +const HOME: &str = "link-home"; +const KIDS: &str = "link-kids"; + +fn running(active: &str, kept: &[&str]) -> Reading { + Reading { + active: Some(active.to_owned()), + kept: kept + .iter() + .map(|l| (*l).to_owned()) + .collect::>(), + } +} + +/// Pushes `data`, the data of the profile linked to `link`, from device A. +async fn push_as( + session: &mut Session<'_, &MemoryStore>, + state: &SqlitePool, + link: &str, + data: &SqlitePool, +) { + session.set_reading(running(link, &[])); + session + .push(state, exported(data, "app-a").await) + .await + .unwrap(); +} + +async fn snapshot_as( + session: &mut Session<'_, &MemoryStore>, + state: &SqlitePool, + link: &str, + data: &SqlitePool, +) { + session.set_reading(running(link, &[])); + session + .write_snapshot(state, exported(data, "app-a").await) + .await + .unwrap(); +} + +async fn apply_each(data: &SqlitePool, batches: &[(String, IncomingBatch)]) { + for (sender, batch) in batches { + apply_batches(data, "app-b", sender, std::slice::from_ref(batch), now_ms()) + .await + .unwrap(); + } +} + +async fn apply_kept(state: &SqlitePool, link: &str, data: &SqlitePool) { + let kept: Vec<(String, IncomingBatch)> = kept(state, link) + .await + .unwrap() + .into_iter() + .map(|(_, sender, batch)| (sender, batch)) + .collect(); + apply_each(data, &kept).await; +} + +/// The last object device A wrote. +fn last(session: &Session<'_, &MemoryStore>) -> u64 { + u64::try_from(session.record().own_seq).unwrap() +} + +fn has(g: &Group, seq: u64) -> bool { + g.store + .0 + .lock() + .unwrap() + .objects + .contains_key(&log_path(&hex::encode(g.a.did), seq)) +} + +#[tokio::test] +async fn batches_of_a_profile_that_isnt_running_are_kept_until_it_runs() { + let g = group().await; + let a_state = device_pool(&g).await; + let (a_home, a_kids) = (empty_pool().await, empty_pool().await); + watch(&a_home, "hhhhhhhhhhh").await; + watch(&a_kids, "kkkkkkkkkkk").await; + let mut a = Session::open(&a_state, &g.store, &g.a).await.unwrap(); + push_as(&mut a, &a_state, HOME, &a_home).await; + push_as(&mut a, &a_state, KIDS, &a_kids).await; + + let b_state = device_pool(&g).await; + let (b_home, b_kids) = (empty_pool().await, empty_pool().await); + let mut b = Session::open(&b_state, &g.store, &g.b).await.unwrap(); + b.set_reading(running(HOME, &[KIDS])); + let pulled = b.pull(&b_state).await.unwrap(); + for (sender, batches) in &pulled.batches { + assert!(batches.iter().all(|b| b.link.as_deref() == Some(HOME))); + apply_batches(&b_home, "app-b", sender, batches, now_ms()) + .await + .unwrap(); + } + b.finish(&b_state, pulled).await.unwrap(); + assert_eq!(watched(&b_home).await, ["hhhhhhhhhhh"]); + assert!(watched(&b_kids).await.is_empty()); + + apply_kept(&b_state, KIDS, &b_kids).await; + assert_eq!(watched(&b_kids).await, ["kkkkkkkkkkk"]); + assert_eq!( + watched(&b_home).await, + ["hhhhhhhhhhh"], + "nothing leaks across profiles" + ); + assert!(b.pull(&b_state).await.unwrap().kept.is_empty(), "kept once"); +} + +#[tokio::test] +async fn a_new_device_starts_each_profile_from_its_own_snapshot() { + let g = group().await; + let a_state = device_pool(&g).await; + let (a_home, a_kids) = (empty_pool().await, empty_pool().await); + watch(&a_home, "h1h1h1h1h1h").await; + watch(&a_kids, "k1k1k1k1k1k").await; + let mut a = Session::open(&a_state, &g.store, &g.a).await.unwrap(); + push_as(&mut a, &a_state, HOME, &a_home).await; + let home_pushed = last(&a); + snapshot_as(&mut a, &a_state, HOME, &a_home).await; + push_as(&mut a, &a_state, KIDS, &a_kids).await; + snapshot_as(&mut a, &a_state, KIDS, &a_kids).await; + watch(&a_home, "h2h2h2h2h2h").await; + push_as(&mut a, &a_state, HOME, &a_home).await; + // Garbage collection removed what both snapshots include. + for seq in 1..=home_pushed { + g.store.take(&log_path(&hex::encode(g.a.did), seq)); + } + + let b_state = device_pool(&g).await; + let (b_home, b_kids) = (empty_pool().await, empty_pool().await); + let mut b = Session::open(&b_state, &g.store, &g.b).await.unwrap(); + b.set_reading(running(HOME, &[KIDS])); + let pulled = b.pull(&b_state).await.unwrap(); + for (sender, batches) in &pulled.batches { + apply_batches(&b_home, "app-b", sender, batches, now_ms()) + .await + .unwrap(); + } + b.finish(&b_state, pulled).await.unwrap(); + assert_eq!(watched(&b_home).await, ["h1h1h1h1h1h", "h2h2h2h2h2h"]); + + apply_kept(&b_state, KIDS, &b_kids).await; + assert_eq!(watched(&b_kids).await, ["k1k1k1k1k1k"]); + let changes: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sync2_kept WHERE part = 0") + .fetch_one(&b_state) + .await + .unwrap(); + assert_eq!( + changes, 0, + "the kids batches are in its snapshot, read once" + ); +} + +#[tokio::test] +async fn a_profile_that_joins_a_link_reads_it_from_its_snapshot() { + let g = group().await; + let a_state = device_pool(&g).await; + let (a_home, a_kids) = (empty_pool().await, empty_pool().await); + watch(&a_home, "hhhhhhhhhhh").await; + watch(&a_kids, "k1k1k1k1k1k").await; + let mut a = Session::open(&a_state, &g.store, &g.a).await.unwrap(); + push_as(&mut a, &a_state, HOME, &a_home).await; + push_as(&mut a, &a_state, KIDS, &a_kids).await; + let included = last(&a); + snapshot_as(&mut a, &a_state, KIDS, &a_kids).await; + watch(&a_kids, "k2k2k2k2k2k").await; + push_as(&mut a, &a_state, KIDS, &a_kids).await; + + let b_state = device_pool(&g).await; + let b_kids = empty_pool().await; + let mut b = Session::open(&b_state, &g.store, &g.b).await.unwrap(); + b.set_reading(running(HOME, &[])); + let pulled = b.pull(&b_state).await.unwrap(); + assert!(pulled.kept.is_empty(), "kids isn't linked here yet"); + b.finish(&b_state, pulled).await.unwrap(); + + let read = b.read_link(&b_state, KIDS).await.unwrap().unwrap(); + assert!( + read.batches.iter().all(|k| k.part > 0 || k.seq > included), + "the batch the snapshot includes isn't read again" + ); + let batches: Vec<(String, IncomingBatch)> = read + .batches + .into_iter() + .map(|k| (k.sender, k.batch)) + .collect(); + apply_each(&b_kids, &batches).await; + assert_eq!(watched(&b_kids).await, ["k1k1k1k1k1k", "k2k2k2k2k2k"]); + assert_eq!( + read.floors.get(&(KIDS.to_owned(), hex::encode(g.a.did))), + Some(&included) + ); +} + +#[tokio::test] +async fn a_deleted_profile_no_longer_holds_back_garbage_collection() { + let g = group().await; + let a_state = device_pool(&g).await; + let (a_home, a_kids) = (empty_pool().await, empty_pool().await); + watch(&a_home, "hhhhhhhhhhh").await; + watch(&a_kids, "kkkkkkkkkkk").await; + let mut a = Session::open(&a_state, &g.store, &g.a).await.unwrap(); + push_as(&mut a, &a_state, HOME, &a_home).await; + push_as(&mut a, &a_state, KIDS, &a_kids).await; + let pushed = last(&a); + snapshot_as(&mut a, &a_state, HOME, &a_home).await; + + let b_state = device_pool(&g).await; + let mut b = Session::open(&b_state, &g.store, &g.b).await.unwrap(); + b.set_reading(running(HOME, &[KIDS])); + let pulled = b.pull(&b_state).await.unwrap(); + b.finish(&b_state, pulled).await.unwrap(); + a.set_reading(running(HOME, &[KIDS])); + let pulled = a.pull(&a_state).await.unwrap(); + a.finish(&a_state, pulled).await.unwrap(); + + a.collect_garbage(&a_state).await.unwrap(); + assert!(has(&g, 1), "kids has no snapshot yet"); + + sqlx::query("INSERT INTO sync2_profiles (link, name, hlc, deleted) VALUES (?, 'Kids', '1:0:aaaaaaaa', 1)") + .bind(KIDS) + .execute(&a_state) + .await + .unwrap(); + a.collect_garbage(&a_state).await.unwrap(); + assert!((1..=pushed).all(|seq| !has(&g, seq))); +} From aba3e370559294e1f5bb4160275c92cb21c4fe66 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Sat, 31 Oct 2026 19:35:06 +0200 Subject: [PATCH 4/8] feat(profiles): choose which profiles sync and link them --- changelog/v0.1.0-beta4.txt | 2 + src-tauri/src/commands/db.rs | 24 +- src-tauri/src/commands/linked.rs | 43 +++ src-tauri/src/commands/music_brain.rs | 5 +- src-tauri/src/commands/profiles.rs | 16 +- src-tauri/src/commands/recommendation.rs | 5 +- src-tauri/src/lib.rs | 14 +- src-tauri/src/services/profile_service.rs | 5 + src-tauri/src/sync2/actions.rs | 62 +++-- src-tauri/src/sync2/batches.rs | 12 +- src-tauri/src/sync2/bootstrap.rs | 25 +- src-tauri/src/sync2/engine.rs | 61 ++++- src-tauri/src/sync2/gc.rs | 4 + src-tauri/src/sync2/link_task.rs | 4 +- src-tauri/src/sync2/linking.rs | 6 +- src-tauri/src/sync2/live_hub.rs | 20 +- src-tauri/src/sync2/mod.rs | 2 + src-tauri/src/sync2/profile_actions.rs | 245 ++++++++++++++++++ src-tauri/src/sync2/profile_links.rs | 46 +++- src-tauri/src/sync2/profile_names.rs | 174 +++++++++++++ src-tauri/src/sync2/service.rs | 10 +- src-tauri/src/sync2/snapshot.rs | 35 ++- src-tauri/tests/profiles/store_inventory.tsv | 2 +- src-tauri/tests/sync2_device_state.rs | 7 +- src-tauri/tests/sync2_profile_names.rs | 76 ++++++ src-tauri/tests/sync2_profiles.rs | 23 +- src-tauri/tests/sync2_recovery.rs | 23 ++ src/components/sync/linked/LinkedOverview.tsx | 2 + src/components/sync/linked/ProfilesCard.tsx | 177 +++++++++++++ src/lib/api/linked.ts | 29 +++ src/lib/i18n/strings.json | 22 +- src/lib/linked/useLinkedProfiles.test.ts | 13 + src/lib/linked/useLinkedProfiles.ts | 97 +++++++ src/lib/syncErrors.ts | 2 + src/pages/Sync.tsx | 5 +- 35 files changed, 1160 insertions(+), 138 deletions(-) create mode 100644 src-tauri/src/sync2/profile_actions.rs create mode 100644 src-tauri/src/sync2/profile_names.rs create mode 100644 src-tauri/tests/sync2_profile_names.rs create mode 100644 src/components/sync/linked/ProfilesCard.tsx create mode 100644 src/lib/linked/useLinkedProfiles.test.ts create mode 100644 src/lib/linked/useLinkedProfiles.ts diff --git a/changelog/v0.1.0-beta4.txt b/changelog/v0.1.0-beta4.txt index bf674df9..75d102f7 100644 --- a/changelog/v0.1.0-beta4.txt +++ b/changelog/v0.1.0-beta4.txt @@ -20,6 +20,8 @@ SYNC - Data synced before comes back if this computer lost it. Music playlists from your phone no longer return under a new name, history sent to the phone has thumbnails, and subscription groups are sent when Flow Desktop scans the phone's code. - Play on: moving playback between Flow Desktop and your phone works again. A phone that paused or stopped no longer stays listed as playing, and a phone at full volume no longer shows its volume at 0. - Play on: picking a device now works as on Android. This computer takes playback back from the device you control, picking another device moves that device's session there, and a device that stops answering stops showing as playing. +- Linked devices: choose which profiles sync. A profile starts its own synced profile or joins one from your other devices, and Profiles under Linked devices adds one that only your other devices have. +- A synced profile that isn't open keeps what arrives and catches up when you switch to it. Stopping a profile on every device keeps its data on each one. FIXES - Restarting Flow after an update no longer opens the link Flow was first started with a second time. diff --git a/src-tauri/src/commands/db.rs b/src-tauri/src/commands/db.rs index 7309250d..43e0a803 100644 --- a/src-tauri/src/commands/db.rs +++ b/src-tauri/src/commands/db.rs @@ -4,10 +4,10 @@ use crate::commands::linked::err as linked_err; use crate::db::settings; use crate::db::watch_history::{self, WatchHistoryRecord}; use crate::errors::ErrorResponse; -use crate::profiles::ActiveProfile; use crate::sync::error::SyncError; use crate::sync::local_edits; use crate::sync2::actions::{leave_if_linked, reset_local_data}; +use crate::sync2::profile_actions::{edits_sync, syncs_link}; use crate::sync2::service::push_soon; use sqlx::SqlitePool; use tauri::{AppHandle, State}; @@ -39,12 +39,11 @@ pub async fn add_watch_record( app: AppHandle, record: WatchHistoryRecord, pool: State<'_, SqlitePool>, - profile: State<'_, ActiveProfile>, ) -> Result<(), ErrorResponse> { watch_history::upsert_watch_record(&pool, &record) .await .map_err(ErrorResponse::from)?; - if profile.is_default { + if edits_sync(&app).await { push_soon(&app); } Ok(()) @@ -55,12 +54,11 @@ pub async fn add_watch_records_bulk( app: AppHandle, records: Vec, pool: State<'_, SqlitePool>, - profile: State<'_, ActiveProfile>, ) -> Result<(), ErrorResponse> { watch_history::upsert_watch_records_bulk(&pool, &records) .await .map_err(ErrorResponse::from)?; - if profile.is_default { + if edits_sync(&app).await { push_soon(&app); } Ok(()) @@ -71,9 +69,8 @@ pub async fn delete_watch_record( app: AppHandle, video_id: String, pool: State<'_, SqlitePool>, - profile: State<'_, ActiveProfile>, ) -> Result<(), ErrorResponse> { - if profile.is_default { + if edits_sync(&app).await { note_sync_failure(local_edits::record_watch_deletion(&pool, Some(&video_id)).await); push_soon(&app); } @@ -86,9 +83,8 @@ pub async fn delete_watch_record( pub async fn clear_watch_history( app: AppHandle, pool: State<'_, SqlitePool>, - profile: State<'_, ActiveProfile>, ) -> Result<(), ErrorResponse> { - if profile.is_default { + if edits_sync(&app).await { note_sync_failure(local_edits::record_watch_deletion(&pool, None).await); push_soon(&app); } @@ -101,12 +97,11 @@ pub async fn clear_watch_history( pub async fn reset_app_data( app: AppHandle, pool: State<'_, SqlitePool>, - profile: State<'_, ActiveProfile>, ) -> Result<(), ErrorResponse> { - if profile.is_default { + if syncs_link(&app).await { leave_if_linked(&app).await.map_err(|e| linked_err(&e))?; } - reset_local_data(&pool, profile.is_default) + reset_local_data(&pool, edits_sync(&app).await) .await .map_err(|e| linked_err(&e)) } @@ -127,9 +122,8 @@ pub async fn set_setting( key: String, value: String, pool: State<'_, SqlitePool>, - profile: State<'_, ActiveProfile>, ) -> Result<(), ErrorResponse> { - if profile.is_default { + if edits_sync(&app).await { note_sync_failure(local_edits::record_blob_write(&pool, &key, &value).await); if local_edits::is_synced_setting(&key) { push_soon(&app); @@ -160,7 +154,7 @@ pub(crate) fn apply_backend_setting(key: &str, value: Option<&str>) { } } -/// Only Default syncs, so only Default records its edits for sync. Sync bookkeeping never blocks the user's own change; the worst case is that this one edit +/// Only a profile that syncs records its edits for sync. Sync bookkeeping never blocks the user's own change; the worst case is that this one edit /// syncs the way it did before this was recorded (a deletion a peer may undo). fn note_sync_failure(result: Result<(), SyncError>) { if let Err(e) = result { diff --git a/src-tauri/src/commands/linked.rs b/src-tauri/src/commands/linked.rs index fe2ce1ca..ece6562c 100644 --- a/src-tauri/src/commands/linked.rs +++ b/src-tauri/src/commands/linked.rs @@ -8,6 +8,7 @@ use crate::sync2::actions::{self, LinkedDevice, LinkedStatus}; use crate::sync2::engine::EngineError; use crate::sync2::join_requests::JoinRequest; use crate::sync2::link_task::{self, LinkPhase}; +use crate::sync2::profile_actions::{self, LinkedProfiles}; use crate::sync2::service::{self, Received}; use crate::sync2::webdav::WebDavLogin; @@ -181,3 +182,45 @@ pub async fn linked_answer(app: AppHandle, accept: bool) { pub async fn linked_cancel(app: AppHandle) { link_task::cancel(&app); } + +#[tauri::command] +pub async fn linked_profiles(app: AppHandle) -> Result { + profile_actions::profiles(&app).await.map_err(|e| err(&e)) +} + +/// Syncs `profile` under its own link, or joining `link`. +#[tauri::command] +pub async fn linked_sync_profile( + app: AppHandle, + profile: String, + link: Option, +) -> Result<(), ErrorResponse> { + profile_actions::sync_profile(&app, &profile, link.as_deref()) + .await + .map_err(|e| err(&e)) +} + +#[tauri::command] +pub async fn linked_stop_profile(app: AppHandle, profile: String) -> Result<(), ErrorResponse> { + profile_actions::stop_profile(&app, &profile) + .await + .map_err(|e| err(&e)) +} + +#[tauri::command] +pub async fn linked_delete_profile(app: AppHandle, link: String) -> Result<(), ErrorResponse> { + profile_actions::delete_everywhere(&app, &link) + .await + .map_err(|e| err(&e)) +} + +/// A restored device's Default joins `link`, or starts its own when `None`. +#[tauri::command] +pub async fn linked_choose_profile( + app: AppHandle, + link: Option, +) -> Result<(), ErrorResponse> { + profile_actions::choose_for_default(&app, link.as_deref()) + .await + .map_err(|e| err(&e)) +} diff --git a/src-tauri/src/commands/music_brain.rs b/src-tauri/src/commands/music_brain.rs index 66d5d634..651581e4 100644 --- a/src-tauri/src/commands/music_brain.rs +++ b/src-tauri/src/commands/music_brain.rs @@ -13,8 +13,8 @@ use crate::music_brain::rank::{ RankInput, day_slot, heavy_rotation, rank, rediscover, time_of_day_rotation, }; use crate::music_brain::store::MusicBrainStore; -use crate::profiles::ActiveProfile; use crate::sync2::actions::leave_if_linked; +use crate::sync2::profile_actions::syncs_link; type CmdResult = Result; @@ -280,9 +280,8 @@ pub async fn get_music_taste_profile( pub async fn reset_music_brain( app: AppHandle, music_brain: State<'_, Arc>, - profile: State<'_, ActiveProfile>, ) -> CmdResult<()> { - if profile.is_default { + if syncs_link(&app).await { leave_if_linked(&app).await.map_err(|e| linked_err(&e))?; } music_brain.reset().await.map_err(ErrorResponse::from) diff --git a/src-tauri/src/commands/profiles.rs b/src-tauri/src/commands/profiles.rs index be9bd7be..60dff3ce 100644 --- a/src-tauri/src/commands/profiles.rs +++ b/src-tauri/src/commands/profiles.rs @@ -9,6 +9,7 @@ use crate::profiles::registry::{Profile, ProfileAvatar}; use crate::services::profile_service::{NewProfile, ProfileService, ProfilesView}; use crate::services::recommendation_service::RecommendationService; use crate::sync::session::SyncManager; +use crate::sync2::profile_actions; #[tauri::command] pub fn profiles_list(service: State<'_, ProfileService>) -> Result { @@ -37,14 +38,19 @@ pub async fn profiles_create( #[tauri::command] pub async fn profiles_rename( + app: AppHandle, id: String, name: String, service: State<'_, ProfileService>, ) -> Result { - service + let profile = service .rename(&id, &name) .await - .map_err(ErrorResponse::from) + .map_err(ErrorResponse::from)?; + if let Err(error) = profile_actions::renamed(&app, &id, &profile.name).await { + tracing::warn!(target: "flow::sync2", kind = error.kind(), "synced profile name not updated"); + } + Ok(profile) } #[tauri::command] @@ -62,6 +68,7 @@ pub async fn profiles_set_look( #[tauri::command] pub async fn profiles_delete( + app: AppHandle, id: String, pool: State<'_, SqlitePool>, service: State<'_, ProfileService>, @@ -69,7 +76,10 @@ pub async fn profiles_delete( service .delete(&pool, &id) .await - .map_err(ErrorResponse::from) + .map_err(ErrorResponse::from)?; + profile_actions::deleted(&app, &id) + .await + .map_err(|e| crate::commands::linked::err(&e)) } #[tauri::command] diff --git a/src-tauri/src/commands/recommendation.rs b/src-tauri/src/commands/recommendation.rs index 79f83d4a..fce5629c 100644 --- a/src-tauri/src/commands/recommendation.rs +++ b/src-tauri/src/commands/recommendation.rs @@ -11,10 +11,10 @@ use crate::flow_neuro::bootstrap::ImportedVideo; use crate::flow_neuro::signals::InteractionType; use crate::flow_neuro::watch_session::{END_GRACE_SECONDS, WatchReport}; use crate::models::video::VideoSummary; -use crate::profiles::ActiveProfile; use crate::services::blocked_channel_names::{self, BlockedChannelNames}; use crate::services::recommendation_service::RecommendationService; use crate::sync2::actions::leave_if_linked; +use crate::sync2::profile_actions::syncs_link; #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] @@ -436,9 +436,8 @@ pub async fn import_brain( pub async fn reset_brain( app: AppHandle, recommendation_service: State<'_, RecommendationService>, - profile: State<'_, ActiveProfile>, ) -> Result<(), ErrorResponse> { - if profile.is_default { + if syncs_link(&app).await { leave_if_linked(&app).await.map_err(|e| linked_err(&e))?; } recommendation_service diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1401a9a8..5d21875a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -62,10 +62,11 @@ use commands::external::open_external; use commands::files::{write_backup_file, write_lyrics_file}; use commands::linked::{ linked_accept_join, linked_add_device, linked_answer, linked_ask_to_join, linked_cancel, - linked_create, linked_create_lan, linked_create_webdav, linked_decline_join, linked_devices, - linked_enter_code, linked_forget, linked_join_requests, linked_leave, linked_link_phase, - linked_recovery_saved, linked_remove, linked_rename, linked_restore, linked_retry_keychain, - linked_show_code, linked_status, linked_sync_now, linked_update_login, + linked_choose_profile, linked_create, linked_create_lan, linked_create_webdav, + linked_decline_join, linked_delete_profile, linked_devices, linked_enter_code, linked_forget, + linked_join_requests, linked_leave, linked_link_phase, linked_profiles, linked_recovery_saved, + linked_remove, linked_rename, linked_restore, linked_retry_keychain, linked_show_code, + linked_status, linked_stop_profile, linked_sync_now, linked_sync_profile, linked_update_login, }; use commands::live::{live_consents, live_now, live_send, live_set_consent, live_status}; use commands::lyrics::{ @@ -817,6 +818,11 @@ pub fn run() { live_set_consent, live_status, linked_sync_now, + linked_profiles, + linked_sync_profile, + linked_stop_profile, + linked_delete_profile, + linked_choose_profile, linked_link_phase, linked_add_device, linked_show_code, diff --git a/src-tauri/src/services/profile_service.rs b/src-tauri/src/services/profile_service.rs index f68bbfee..6875f05c 100644 --- a/src-tauri/src/services/profile_service.rs +++ b/src-tauri/src/services/profile_service.rs @@ -380,6 +380,11 @@ impl ProfileService { Ok(self.load()?.default_id().to_string()) } + /// Every profile, without the photos `list` reads. + pub fn profiles(&self) -> AppResult> { + Ok(self.load()?.profiles) + } + /// Opens a profile that isn't running. Default's file is the app data folder's own. pub async fn open_closed(&self, id: &str) -> AppResult<(SqlitePool, bool)> { let state = self.load()?; diff --git a/src-tauri/src/sync2/actions.rs b/src-tauri/src/sync2/actions.rs index 8d6738c2..aaba42dd 100644 --- a/src-tauri/src/sync2/actions.rs +++ b/src-tauri/src/sync2/actions.rs @@ -16,6 +16,7 @@ use super::lan_runtime::spread_leave; use super::linking::DeviceMeta; use super::membership::{leave_group, remove_device, rename_device}; use super::names::device_names; +use super::profile_links::{link_profile, reading}; use super::roster::Role; use super::server::{ServerStore, StoreError}; use super::service::{LastSync, is_syncing, last_sync, sync_soon}; @@ -28,9 +29,7 @@ use super::store::Store; use super::webdav::{WebDavLogin, WebDavStore}; use super::webdav_login::{forget_login, load_login, save_login}; use crate::db::settings::{get_setting, set_setting}; -use crate::profiles::ActiveProfile; use crate::services::profile_service::ProfileService; -use crate::sync::error::SyncError; use crate::sync::{changes, ledger}; const RECOVERY_SAVED: &str = "sync2_recovery_saved"; @@ -47,7 +46,6 @@ pub struct Local { pub pool: SqlitePool, /// The running profile's database, which batches are exported from and applied to. pub data: SqlitePool, - pub profile: ActiveProfile, pub running_profile: String, pub default_profile: String, pub device: MemberSecrets, @@ -74,7 +72,6 @@ pub async fn local(app: &AppHandle) -> Result { Ok(Local { device: DEVICE_KEYS.get().await?, data: app.state::().inner().clone(), - profile: app.state::().inner().clone(), running_profile, default_profile, pool, @@ -82,11 +79,12 @@ pub async fn local(app: &AppHandle) -> Result { }) } +/// A session for this device, reading the links its profiles sync. pub async fn open_session<'a>( - pool: &SqlitePool, + local: &'a Local, record: &GroupRecord, - device: &'a MemberSecrets, ) -> Result> { + let (pool, device) = (&local.pool, &local.device); let store = match record.kind { StoreKind::Server => { let server = ServerStore::connect( @@ -109,16 +107,15 @@ pub async fn open_session<'a>( )), StoreKind::Lan => GroupStore::Held(HeldStore::new(pool.clone())), }; - Session::open(pool, store, device).await + let mut session = Session::open(pool, store, device).await?; + let (running, default) = (&local.running_profile, &local.default_profile); + session.set_reading(reading(pool, &record.link, running, default).await?); + Ok(session) } /// Checks that this device is in no group, and gives it fresh keys for the one it is about to -/// create or join. Like FLOW-SYNC/1, only the Default profile links until profiles choose links. +/// create or join. pub async fn start_membership(local: &mut Local) -> Result<()> { - if !local.profile.is_default { - let name = local.profile.default_name.clone(); - return Err(SyncError::NotDefaultProfile(name).into()); - } if load_group(&local.pool).await?.is_some() { return Err(EngineError::AlreadyLinked); } @@ -149,9 +146,13 @@ async fn linked(pool: &SqlitePool) -> Result { load_group(pool).await?.ok_or(EngineError::NotLinked) } -/// The profile link this device's Default profile publishes under when it starts one: its own id. -pub fn default_link(app: &AppHandle) -> String { - app.state::().id.clone() +/// A group this device just started syncs the running profile under its own id, and its +/// recovery words are still to be confirmed. +async fn started(local: &Local, words: String) -> Result { + let profile = &local.running_profile; + link_profile(&local.pool, profile, profile).await?; + set_recovery_saved(&local.pool, false).await?; + Ok(words) } async fn set_recovery_saved(pool: &SqlitePool, saved: bool) -> Result<()> { @@ -223,7 +224,7 @@ pub struct LinkedDevice { pub async fn devices(app: &AppHandle) -> Result> { let local = local(app).await?; let record = linked(&local.pool).await?; - let mut session = open_session(&local.pool, &record, &local.device).await?; + let mut session = open_session(&local, &record).await?; let names = device_names(&mut session).await?; let me = hex::encode(local.device.did); let nearby = nearby(app); @@ -276,11 +277,10 @@ pub async fn create( admin_token.trim(), &local.device, &local.meta, - &default_link(app), + &local.running_profile, ) .await?; - set_recovery_saved(&local.pool, false).await?; - Ok(words) + started(&local, words).await } /// Starts a group in a `WebDAV` folder and returns the recovery words to show once. @@ -299,11 +299,10 @@ pub async fn create_on_webdav( }; let store = WebDavStore::connect(url, login.clone(), hex::encode(local.device.did)).await?; save_login(&login).await?; - let link = default_link(app); + let link = &local.running_profile; let words = - create_webdav_group(&local.pool, &store, url, &local.device, &local.meta, &link).await?; - set_recovery_saved(&local.pool, false).await?; - Ok(words) + create_webdav_group(&local.pool, &store, url, &local.device, &local.meta, link).await?; + started(&local, words).await } /// Replaces a `WebDAV` login the folder refused, once the folder takes the new one and still @@ -336,10 +335,9 @@ pub async fn update_login(app: &AppHandle, user: &str, password: &str) -> Result pub async fn create_on_lan(app: &AppHandle) -> Result { let mut local = local(app).await?; start_membership(&mut local).await?; - let words = - create_lan_group(&local.pool, &local.device, &local.meta, &default_link(app)).await?; - set_recovery_saved(&local.pool, false).await?; - Ok(words) + let link = &local.running_profile; + let words = create_lan_group(&local.pool, &local.device, &local.meta, link).await?; + started(&local, words).await } /// Restores the group from its recovery words, on a Flow server or, with `login`, in a `WebDAV` @@ -382,7 +380,7 @@ pub async fn rename(app: &AppHandle, id: &str, name: &str) -> Result<()> { let name: String = name.trim().chars().take(NAME_LIMIT).collect(); let local = local(app).await?; let record = linked(&local.pool).await?; - let mut session = open_session(&local.pool, &record, &local.device).await?; + let mut session = open_session(&local, &record).await?; let current = device_names(&mut session) .await? .remove(id) @@ -394,7 +392,7 @@ pub async fn rename(app: &AppHandle, id: &str, name: &str) -> Result<()> { pub async fn remove(app: &AppHandle, id: &str) -> Result<()> { let local = local(app).await?; let record = linked(&local.pool).await?; - let session = open_session(&local.pool, &record, &local.device).await?; + let session = open_session(&local, &record).await?; remove_device(&session, &local.device, id).await?; snapshot_soon(&local.pool).await?; sync_soon(app); @@ -405,7 +403,7 @@ pub async fn remove(app: &AppHandle, id: &str) -> Result<()> { pub async fn join_requests(app: &AppHandle) -> Result> { let local = local(app).await?; let record = linked(&local.pool).await?; - let session = open_session(&local.pool, &record, &local.device).await?; + let session = open_session(&local, &record).await?; match session.store().server() { Some(server) => Ok(server.join_requests().await?), None => Ok(Vec::new()), @@ -415,7 +413,7 @@ pub async fn join_requests(app: &AppHandle) -> Result> { pub async fn decline_join(app: &AppHandle, id: &str) -> Result<()> { let local = local(app).await?; let record = linked(&local.pool).await?; - let session = open_session(&local.pool, &record, &local.device).await?; + let session = open_session(&local, &record).await?; let server = session.store().server().ok_or(EngineError::Unexpected)?; Ok(server.decline_join(id).await?) } @@ -425,7 +423,7 @@ pub async fn decline_join(app: &AppHandle, id: &str) -> Result<()> { pub async fn leave(app: &AppHandle) -> Result<()> { let local = local(app).await?; let record = linked(&local.pool).await?; - let session = open_session(&local.pool, &record, &local.device).await?; + let session = open_session(&local, &record).await?; let spread = async { if record.kind == StoreKind::Lan { spread_leave(app, &session, &local).await; diff --git a/src-tauri/src/sync2/batches.rs b/src-tauri/src/sync2/batches.rs index 132a1081..2c71d3ad 100644 --- a/src-tauri/src/sync2/batches.rs +++ b/src-tauri/src/sync2/batches.rs @@ -101,17 +101,17 @@ async fn sent_hashes( } /// A header line and then the records, one per line: the format of change batches and of -/// snapshot blobs. +/// snapshot blobs. `link` is `None` for the `profiles` collection. pub fn batch_content( - collection: Collection, + collection: &str, head: &str, - link: &str, + link: Option<&str>, records: &[impl AsRef<[u8]>], ) -> Vec { let header = BatchHeader { - collection: collection.key().to_owned(), + collection: collection.to_owned(), head: head.to_owned(), - link: Some(link.to_owned()), + link: link.map(str::to_owned), }; let mut content = to_canonical_json(&header); for record in records { @@ -159,7 +159,7 @@ pub async fn outgoing_batches( for records in changed.chunks(RECORDS_PER_BATCH) { outgoing.batches.push(Batch { collection, - content: batch_content(collection, head, link, records), + content: batch_content(collection.key(), head, Some(link), records), }); } } diff --git a/src-tauri/src/sync2/bootstrap.rs b/src-tauri/src/sync2/bootstrap.rs index 06010a87..2121824e 100644 --- a/src-tauri/src/sync2/bootstrap.rs +++ b/src-tauri/src/sync2/bootstrap.rs @@ -30,6 +30,10 @@ pub struct Start { pub positions: BTreeMap, } +/// Per link, and `profiles` as `Some(None)`, the best covering snapshot: writer, seq and epoch. +/// Without links to look for, the one best snapshot of any link, as `None`. +type Best = BTreeMap>, (String, u64, u32, Manifest)>; + /// How many objects of all logs the snapshot includes. pub(super) fn merged(manifest: &Manifest) -> u64 { manifest.vv.values().map(|tip| tip.seq).sum() @@ -100,18 +104,15 @@ impl Session<'_, S> { return Ok(None); } - // Per link (`None` standing for any), the best covering snapshot: writer, seq, epoch. - let mut best: BTreeMap, (String, u64, u32, Manifest)> = BTreeMap::new(); + let mut best: Best = BTreeMap::new(); for (did, seqs) in &mut logs { seqs.sort_unstable(); for (seq, epoch, manifest) in self.manifests_from_newest(did, seqs, links).await? { - let Some(link) = manifest.link.clone() else { - continue; - }; - let key = match links { - Some(links) if links.contains(&link) => Some(link), - Some(_) => continue, - None => None, + let key = match (links, &manifest.link) { + (None, Some(_)) => None, + (Some(links), Some(link)) if !links.contains(link) => continue, + (Some(_), link) => Some(link.clone()), + (None, None) => continue, }; if covers(&manifest, &first) && best @@ -143,15 +144,15 @@ impl Session<'_, S> { })) } - /// The newest snapshot manifest of each of `links` in `did`'s log (only the newest of any link - /// when `None`): where it sits and the epoch it was written in. + /// The newest snapshot manifest of each of `links` and of `profiles` in `did`'s log (only the + /// newest of any link when `None`): where it sits and the epoch it was written in. async fn manifests_from_newest( &mut self, did: &str, seqs: &[u64], links: Option<&BTreeSet>, ) -> Result> { - let wanted = links.map_or(1, BTreeSet::len); + let wanted = links.map_or(1, |links| links.len() + 1); let mut found: Vec<(u64, u32, Manifest)> = Vec::new(); for &seq in seqs.iter().rev() { if found.len() >= wanted { diff --git a/src-tauri/src/sync2/engine.rs b/src-tauri/src/sync2/engine.rs index 6e48a664..c69ff266 100644 --- a/src-tauri/src/sync2/engine.rs +++ b/src-tauri/src/sync2/engine.rs @@ -7,7 +7,7 @@ use base64::Engine as _; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use sqlx::SqlitePool; -use super::batches::{IncomingBatch, mark_sent, outgoing_batches, parse_batch}; +use super::batches::{IncomingBatch, batch_content, mark_sent, outgoing_batches, parse_batch}; use super::bootstrap::Snapshot; use super::envelope::{ Header, ObjectType, ParsedObject, StreamPosition, next_in_stream, open_object, parse_object, @@ -19,10 +19,11 @@ use super::kept::{Kept, keep}; use super::keys::MemberSecrets; use super::paths::{log_path, parse_log_path}; use super::payload::{decode_payload, encode_payload}; -use super::profile_links::{Reading, link_profile}; +use super::profile_links::{Reading, forget_link, link_profile}; +use super::profile_names::{self, PROFILES}; use super::roster::{Entry, RosterState, apply_entry, replay_roster}; use super::server::StoreError; -use super::snapshot::{Manifest, note_merged}; +use super::snapshot::{Manifest, PROFILES_KEY, note_merged}; use super::state::{ Floors, GroupRecord, add_roster_entries, floors, load_group, roster_entries, save_floors, save_group, save_streams, streams, @@ -66,6 +67,10 @@ pub enum EngineError { LanUnreachable, #[error("the code names another WebDAV folder")] OtherFolder, + #[error("a device in a group keeps at least one profile synced")] + LastProfile, + #[error("that profile already syncs on this device")] + ProfileTaken, } impl EngineError { @@ -99,6 +104,8 @@ impl EngineError { Self::AlreadyLinked => "linkedAlreadyLinked", Self::LanUnreachable => "linkedLanUnreachable", Self::OtherFolder => "linkedOtherFolder", + Self::LastProfile => "linkedLastProfile", + Self::ProfileTaken => "linkedProfileTaken", } } } @@ -116,6 +123,8 @@ pub struct Pulled { pub batches: BTreeMap>, /// Batches of linked profiles that aren't running, kept by [`Session::finish`]. pub kept: Vec, + /// `profiles` batches, merged by [`Session::finish`]. + pub profiles: Vec, /// Where the snapshots started from stop including each link's batches. floors: Floors, positions: BTreeMap, @@ -136,6 +145,7 @@ impl Pulled { fn start_from(&mut self, me: &str, snapshot: Snapshot, reading: Option<&Reading>) { self.links.push(snapshot.manifest.link.clone()); let Some(link) = snapshot.manifest.link else { + self.profiles.extend(snapshot.batches); return; }; for (did, tip) in snapshot.manifest.vv.iter().filter(|(did, _)| *did != me) { @@ -171,11 +181,12 @@ impl Pulled { reading: Option<&Reading>, floors: &Floors, ) { - let Some(reading) = reading else { - self.batches.entry(did).or_default().push(batch); + let Some(link) = batch.link.clone() else { + self.profiles.push(batch); return; }; - let Some(link) = batch.link.clone() else { + let Some(reading) = reading else { + self.batches.entry(did).or_default().push(batch); return; }; if floors @@ -449,6 +460,19 @@ impl<'a, S: Store> Session<'a, S> { Ok(outgoing.batches.len()) } + /// Uploads the `profiles` records this device changed since it last sent them. + pub async fn push_profiles(&mut self, pool: &SqlitePool) -> Result<()> { + let unsent = profile_names::unsent(pool).await?; + if unsent.is_empty() { + return Ok(()); + } + let lines: Vec> = unsent.iter().map(to_canonical_json).collect(); + let content = batch_content(PROFILES, &self.state.head, None, &lines); + self.write(pool, ObjectType::Changes, &content).await?; + profile_names::mark_sent(pool, &unsent).await?; + note_merged(pool, PROFILES_KEY, content.len()).await + } + /// Reads other devices' new objects in stream order, checking each one. Nothing is applied: /// the caller applies [`Pulled::batches`] and then calls [`Session::finish`]. pub async fn pull(&mut self, pool: &SqlitePool) -> Result { @@ -458,7 +482,8 @@ impl<'a, S: Store> Session<'a, S> { /// Links a restored device's profile `own` (spec/protocol.md, Recovery): to the link other /// devices' batches name, or to its own id when they name none. False while the logs can't be - /// read to the end yet; nothing may be pushed or pulled until this returns true. + /// read to the end yet, or while the user has to choose among several links (whose names it + /// keeps); nothing may be pushed or pulled until this returns true. pub async fn adopt_link(&mut self, pool: &SqlitePool, own: &str) -> Result { if !self.record.link.is_empty() { return Ok(true); @@ -467,12 +492,18 @@ impl<'a, S: Store> Session<'a, S> { if read.stalled { return Ok(false); } - let link = read - .links - .into_iter() - .flatten() - .next() - .unwrap_or_else(|| own.to_owned()); + let deleted = profile_names::merge(pool, &profile_names::parse(&read.profiles)).await?; + let mut links: Vec = Vec::new(); + for link in read.links.iter().flatten() { + if !links.contains(link) && !deleted.contains(link) { + links.push(link.clone()); + } + } + if links.len() > 1 { + record_links(pool, &read.links).await?; + return Ok(false); + } + let link = links.pop().unwrap_or_else(|| own.to_owned()); link_profile(pool, own, &link).await?; self.record.link.clone_from(&link); save_group(pool, &self.record).await?; @@ -633,6 +664,10 @@ impl<'a, S: Store> Session<'a, S> { pub async fn finish(&mut self, pool: &SqlitePool, pulled: Pulled) -> Result<()> { keep(pool, &pulled.kept).await?; save_floors(pool, &pulled.floors).await?; + let records = profile_names::parse(&pulled.profiles); + for link in profile_names::merge(pool, &records).await? { + forget_link(pool, &link).await?; + } save_streams(pool, &pulled.positions, pulled.cursor).await?; if let Some(link) = &self.reading.active { let merged = pulled.batches.values().flatten().map(|b| b.size).sum(); diff --git a/src-tauri/src/sync2/gc.rs b/src-tauri/src/sync2/gc.rs index 3cf2016a..a79a9286 100644 --- a/src-tauri/src/sync2/gc.rs +++ b/src-tauri/src/sync2/gc.rs @@ -9,6 +9,7 @@ use sqlx::SqlitePool; use super::engine::{Result, Session}; use super::paths::{blob_path, log_path}; +use super::profile_names; use super::roster::Role; use super::snapshot::Manifest; use super::store::Store; @@ -315,6 +316,9 @@ impl Session<'_, S> { for link in deleted_links(pool).await? { links.remove(&Some(link)); } + if !profile_names::records(pool).await?.is_empty() { + links.insert(None); + } let plan = Facts { current: ¤t, collectable: &collectable, diff --git a/src-tauri/src/sync2/link_task.rs b/src-tauri/src/sync2/link_task.rs index f8becb69..3cb5c99c 100644 --- a/src-tauri/src/sync2/link_task.rs +++ b/src-tauri/src/sync2/link_task.rs @@ -181,7 +181,7 @@ async fn add(app: &AppHandle, request: Option) -> Result<()> { let welcome = welcome_store(&record).await?; let task = app.clone(); start(app, async move { - let session = open_session(&local.pool, &record, &local.device).await?; + let session = open_session(&local, &record).await?; let offer = new_offer(Some(store), &local.meta.name, listening); match &request { Some(id) => { @@ -351,7 +351,7 @@ pub async fn start_with_code( .ok_or(EngineError::NotLinked)?; let welcome = welcome_store(&record).await?; start(app, async move { - let session = open_session(&local.pool, &record, &local.device).await?; + let session = open_session(&local, &record).await?; let (_, reach) = add_scanned(&session, &code, &local.device, welcome, |sas| { confirm(task.clone(), sas) }) diff --git a/src-tauri/src/sync2/linking.rs b/src-tauri/src/sync2/linking.rs index d18310fb..1f7232d5 100644 --- a/src-tauri/src/sync2/linking.rs +++ b/src-tauri/src/sync2/linking.rs @@ -309,7 +309,11 @@ where group: record.group_id.clone(), head: after.head, seq: after.seq, - link: record.link.clone(), + link: session + .reading() + .welcome_link() + .ok_or(EngineError::LastProfile)? + .to_owned(), store, entries, wrap, diff --git a/src-tauri/src/sync2/live_hub.rs b/src-tauri/src/sync2/live_hub.rs index 309ccf5f..fe2006df 100644 --- a/src-tauri/src/sync2/live_hub.rs +++ b/src-tauri/src/sync2/live_hub.rs @@ -16,7 +16,7 @@ use super::lan_peers::nearby; use super::live_message::{Ability, Answer, DeviceKind, LiveMessage, Now}; use super::live_seal::{LiveKeys, Received}; use super::live_state::{accept_live_seq, consents, next_live_seq}; -use super::profile_links::reading; +use super::profile_actions::running_link; use super::state::load_group; /// The devices' presence and what they play, whenever either changes. @@ -105,7 +105,7 @@ async fn keys(app: &AppHandle) -> Result<(LiveKeys, SqlitePool)> { } let local = local(app).await?; let record = load_group(&pool).await?.ok_or(EngineError::NotLinked)?; - let session = open_session(&pool, &record, &local.device).await?; + let session = open_session(&local, &record).await?; let keys = LiveKeys::of(&session); *cached = Some(keys.clone()); Ok((keys, pool)) @@ -139,22 +139,10 @@ pub async fn announce(app: &AppHandle) -> Result<()> { Ok(()) } -/// The profile link the running profile syncs, which `now` and `play` name. -async fn own_link(app: &AppHandle) -> Result> { - let local = local(app).await?; - let Some(record) = load_group(&local.pool).await? else { - return Ok(None); - }; - let (running, default) = (&local.running_profile, &local.default_profile); - Ok(reading(&local.pool, &record.link, running, default) - .await? - .active) -} - /// Sends a `play`, `cmd` or `result` from the player, and returns its counter. pub async fn send_request(app: &AppHandle, mut message: LiveMessage) -> Result { match &mut message { - LiveMessage::Play(play) => play.link = own_link(app).await?, + LiveMessage::Play(play) => play.link = running_link(app).await?, LiveMessage::Cmd(_) | LiveMessage::Result(_) => {} LiveMessage::Presence { .. } | LiveMessage::Now(_) => return Err(EngineError::Unexpected), } @@ -167,7 +155,7 @@ pub async fn send_request(app: &AppHandle, mut message: LiveMessage) -> Result Result<()> { if now.video.is_some() { - now.link = own_link(app).await?; + now.link = running_link(app).await?; } if let Ok(mut mine) = hub(app).mine.lock() { *mine = Some(now.clone()); diff --git a/src-tauri/src/sync2/mod.rs b/src-tauri/src/sync2/mod.rs index e9275717..ae50373d 100644 --- a/src-tauri/src/sync2/mod.rs +++ b/src-tauri/src/sync2/mod.rs @@ -45,7 +45,9 @@ pub mod pairing; pub mod paths; pub mod payload; pub mod pipe; +pub mod profile_actions; pub mod profile_links; +pub mod profile_names; pub mod recovery; pub mod recovery_flow; pub mod roster; diff --git a/src-tauri/src/sync2/profile_actions.rs b/src-tauri/src/sync2/profile_actions.rs new file mode 100644 index 00000000..c8f9f8d2 --- /dev/null +++ b/src-tauri/src/sync2/profile_actions.rs @@ -0,0 +1,245 @@ +//! What the Profiles card under Linked devices shows and changes (spec/protocol.md, Profiles): +//! which local profiles sync which link, and the profiles other devices sync. + +use std::collections::BTreeSet; + +use serde::Serialize; +use tauri::{AppHandle, Manager}; + +use super::actions::{local, state_pool}; +use super::engine::{EngineError, Result}; +use super::profile_links::{Reading, forget_link, link_profile, linked, reading, unlink}; +use super::profile_names::{name_missing, records, set}; +use super::service::{push_soon, sync_soon}; +use super::state::{load_group, read_from_snapshot, save_group}; +use crate::services::profile_service::ProfileService; +use crate::sync::error::SyncError; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalProfile { + pub id: String, + /// The link it syncs, if any. + pub link: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncedProfile { + pub link: String, + /// Blank when no device named it yet. + pub name: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LinkedProfiles { + pub local: Vec, + /// Profiles other devices sync that no local profile syncs. + pub synced: Vec, + /// A restored device found several synced profiles, and Default joins the one the user picks. + pub choose_for_default: bool, +} + +/// The link the running profile syncs. Reads no keys, so local edits can ask it. +pub async fn running_link(app: &AppHandle) -> Result> { + Ok(current(app).await?.and_then(|(reading, _)| reading.active)) +} + +/// The running profile's view of the links, and Default's id; `None` outside a group. +async fn current(app: &AppHandle) -> Result> { + let pool = state_pool(app); + let Some(record) = load_group(&pool).await? else { + return Ok(None); + }; + let profiles = app.state::(); + let default = profiles.default_profile_id()?; + let running = profiles.running_profile_id()?; + let reading = reading(&pool, &record.link, &running, &default).await?; + Ok(Some((reading, default))) +} + +/// The linked profiles, once a group joined before profiles chose links has Default's. +async fn links_now(app: &AppHandle, pool: &sqlx::SqlitePool) -> Result> { + current(app).await?; + Ok(linked(pool).await?) +} + +async fn known_links(pool: &sqlx::SqlitePool) -> Result> { + let rows: Vec = sqlx::query_scalar("SELECT link FROM sync2_links") + .fetch_all(pool) + .await + .map_err(SyncError::from)?; + Ok(rows + .iter() + .filter_map(|json| serde_json::from_str::>(json).ok().flatten()) + .collect()) +} + +pub async fn profiles(app: &AppHandle) -> Result { + let pool = state_pool(app); + if current(app).await?.is_none() { + return Err(EngineError::NotLinked); + } + let links = linked(&pool).await?; + let local = app + .state::() + .profiles()? + .into_iter() + .map(|profile| LocalProfile { + link: links + .iter() + .find(|(id, _)| *id == profile.id) + .map(|(_, link)| link.clone()), + id: profile.id, + }) + .collect(); + let named = records(&pool).await?; + let deleted: BTreeSet<&str> = named + .iter() + .filter(|r| r.deleted) + .map(|r| r.link.as_str()) + .collect(); + let mut synced: Vec = named + .iter() + .filter(|r| !r.deleted) + .map(|r| SyncedProfile { + link: r.link.clone(), + name: r.name.clone(), + }) + .collect(); + for link in known_links(&pool).await? { + if !deleted.contains(link.as_str()) && synced.iter().all(|s| s.link != link) { + synced.push(SyncedProfile { + link, + name: String::new(), + }); + } + } + synced.retain(|s| links.iter().all(|(_, link)| *link != s.link)); + let restored = load_group(&pool) + .await? + .is_some_and(|record| record.link.is_empty()); + Ok(LinkedProfiles { + choose_for_default: restored && links.is_empty() && synced.len() > 1, + local, + synced, + }) +} + +fn name_of(app: &AppHandle, profile: &str) -> Result { + Ok(app + .state::() + .profiles()? + .into_iter() + .find(|p| p.id == profile) + .map(|p| p.name) + .unwrap_or_default()) +} + +/// Syncs `profile`: under a link of its own, or joining `link`, another device's profile. +pub async fn sync_profile(app: &AppHandle, profile: &str, link: Option<&str>) -> Result<()> { + let local = local(app).await?; + let pool = &local.pool; + let links = links_now(app, pool).await?; + let link = link.unwrap_or(profile); + if links.iter().any(|(id, l)| id == profile || l == link) { + return Err(EngineError::ProfileTaken); + } + link_profile(pool, profile, link).await?; + if link == profile { + set(pool, &local.meta.app, link, &name_of(app, profile)?, false).await?; + } else { + read_from_snapshot(pool, link).await?; + } + sync_soon(app); + Ok(()) +} + +/// Stops syncing `profile` on this device; its data stays. +pub async fn stop_profile(app: &AppHandle, profile: &str) -> Result<()> { + let pool = state_pool(app); + let links = links_now(app, &pool).await?; + if links.iter().all(|(id, _)| id == profile) { + return Err(EngineError::LastProfile); + } + unlink(&pool, profile).await?; + Ok(()) +} + +/// Stops syncing `link` on every device; each keeps its data. +pub async fn delete_everywhere(app: &AppHandle, link: &str) -> Result<()> { + let local = local(app).await?; + let pool = &local.pool; + let links = links_now(app, pool).await?; + if links.iter().all(|(_, l)| l == link) { + return Err(EngineError::LastProfile); + } + let name = records(pool) + .await? + .into_iter() + .find(|r| r.link == link) + .map(|r| r.name) + .unwrap_or_default(); + set(pool, &local.meta.app, link, &name, true).await?; + forget_link(pool, link).await?; + sync_soon(app); + Ok(()) +} + +/// A restored device's Default joins `link`, or starts a link of its own when `None`. +pub async fn choose_for_default(app: &AppHandle, link: Option<&str>) -> Result<()> { + let pool = state_pool(app); + let Some(mut record) = load_group(&pool).await? else { + return Err(EngineError::NotLinked); + }; + let default = app.state::().default_profile_id()?; + let link = link.unwrap_or(&default); + link_profile(&pool, &default, link).await?; + record.link = link.to_owned(); + save_group(&pool, &record).await?; + sync_soon(app); + Ok(()) +} + +/// A linked profile was renamed: its name goes to the other devices. +pub async fn renamed(app: &AppHandle, profile: &str, name: &str) -> Result<()> { + let local = local(app).await?; + let links = links_now(app, &local.pool).await?; + let Some((_, link)) = links.iter().find(|(id, _)| id == profile) else { + return Ok(()); + }; + set(&local.pool, &local.meta.app, link, name, false).await?; + push_soon(app); + Ok(()) +} + +/// A deleted profile stops syncing here. +pub async fn deleted(app: &AppHandle, profile: &str) -> Result<()> { + Ok(unlink(&state_pool(app), profile).await?) +} + +/// Names every link a local profile syncs that has no `profiles` record yet. +pub async fn name_linked(app: &AppHandle, pool: &sqlx::SqlitePool, device_id: &str) -> Result<()> { + let profiles = app.state::().profiles()?; + let named: Vec<(String, String)> = linked(pool) + .await? + .into_iter() + .filter_map(|(id, link)| { + let profile = profiles.iter().find(|p| p.id == id)?; + Some((link, profile.name.clone())) + }) + .collect(); + Ok(name_missing(pool, device_id, &named).await?) +} + +/// Whether the running profile's edits are recorded for sync: Default's always (FLOW-SYNC/1 +/// syncs it), another profile's while it syncs a link. +pub async fn edits_sync(app: &AppHandle) -> bool { + app.state::().is_default || syncs_link(app).await +} + +/// Whether the running profile syncs a link; an unreadable group counts as no. +pub async fn syncs_link(app: &AppHandle) -> bool { + matches!(running_link(app).await, Ok(Some(_))) +} diff --git a/src-tauri/src/sync2/profile_links.rs b/src-tauri/src/sync2/profile_links.rs index a73c9111..87754e53 100644 --- a/src-tauri/src/sync2/profile_links.rs +++ b/src-tauri/src/sync2/profile_links.rs @@ -31,6 +31,11 @@ impl Reading { pub fn reads(&self, link: &str) -> bool { self.active.as_deref() == Some(link) || self.kept.contains(link) } + + /// The link a new device's Default joins: the running profile's, else the first one synced. + pub fn welcome_link(&self) -> Option<&str> { + self.links().next() + } } /// Links `profile` to `link`. @@ -46,6 +51,42 @@ pub async fn link_profile(pool: &SqlitePool, profile: &str, link: &str) -> Resul Ok(()) } +/// Stops syncing `profile` here: its link's kept batches and pending read go too. +pub async fn unlink(pool: &SqlitePool, profile: &str) -> Result<(), SyncError> { + let link: Option = + sqlx::query_scalar("SELECT link FROM sync2_profile_links WHERE profile = ?") + .bind(profile) + .fetch_optional(pool) + .await?; + match link { + Some(link) => forget_link(pool, &link).await, + None => Ok(()), + } +} + +/// Stops syncing `link` here, whichever profile synced it. +pub async fn forget_link(pool: &SqlitePool, link: &str) -> Result<(), SyncError> { + let mut tx = pool.begin().await?; + for statement in [ + "DELETE FROM sync2_profile_links WHERE link = ?", + "DELETE FROM sync2_kept WHERE link = ?", + "DELETE FROM sync2_reading WHERE link = ?", + ] { + sqlx::query(statement).bind(link).execute(&mut *tx).await?; + } + tx.commit().await?; + Ok(()) +} + +/// Each linked profile and its link. +pub async fn linked(pool: &SqlitePool) -> Result, SyncError> { + Ok( + sqlx::query_as("SELECT profile, link FROM sync2_profile_links ORDER BY profile") + .fetch_all(pool) + .await?, + ) +} + /// The links of every linked profile, from the point of view of the running profile. A group /// joined before profiles chose links has only the link Default joined, which `joined` names. pub async fn reading( @@ -54,10 +95,7 @@ pub async fn reading( running: &str, default: &str, ) -> Result { - let mut rows: Vec<(String, String)> = - sqlx::query_as("SELECT profile, link FROM sync2_profile_links") - .fetch_all(pool) - .await?; + let mut rows = linked(pool).await?; if rows.is_empty() && !joined.is_empty() { link_profile(pool, default, joined).await?; rows.push((default.to_owned(), joined.to_owned())); diff --git a/src-tauri/src/sync2/profile_names.rs b/src-tauri/src/sync2/profile_names.rs new file mode 100644 index 00000000..74777c16 --- /dev/null +++ b/src-tauri/src/sync2/profile_names.rs @@ -0,0 +1,174 @@ +//! The `profiles` collection (spec/protocol.md, Profiles): each link's name, merged +//! last-writer-wins by `hlc`, kept in Default's database. A record goes out when this device's copy +//! differs from what it last sent or received. + +use serde::{Deserialize, Serialize}; +use sqlx::SqlitePool; + +use super::batches::IncomingBatch; +use crate::sync::canonical::{Hlc, to_canonical_json}; +use crate::sync::clock; +use crate::sync::error::SyncError; + +pub const PROFILES: &str = "profiles"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProfileRecord { + pub deleted: bool, + pub hlc: Hlc, + pub link: String, + pub name: String, +} + +pub async fn records(pool: &SqlitePool) -> Result, SyncError> { + let rows: Vec<(String, String, String, bool)> = + sqlx::query_as("SELECT link, name, hlc, deleted FROM sync2_profiles ORDER BY link") + .fetch_all(pool) + .await?; + rows.into_iter() + .map(|(link, name, hlc, deleted)| { + Ok(ProfileRecord { + deleted, + hlc: hlc.parse().map_err(SyncError::Protocol)?, + link, + name, + }) + }) + .collect() +} + +/// Every record as canonical lines, the content of a `profiles` snapshot. +pub async fn ndjson(pool: &SqlitePool) -> Result, SyncError> { + let lines: Vec> = records(pool).await?.iter().map(to_canonical_json).collect(); + Ok(lines.join(&b'\n')) +} + +/// The records of `profiles` batches; a line this build can't read is skipped. +pub fn parse(batches: &[IncomingBatch]) -> Vec { + batches + .iter() + .flat_map(|batch| batch.records().split(|&b| b == b'\n')) + .filter_map(|line| serde_json::from_slice(line).ok()) + .collect() +} + +async fn stored(pool: &SqlitePool, link: &str) -> Result, SyncError> { + let hlc: Option = sqlx::query_scalar("SELECT hlc FROM sync2_profiles WHERE link = ?") + .bind(link) + .fetch_optional(pool) + .await?; + hlc.map(|hlc| hlc.parse().map_err(SyncError::Protocol)) + .transpose() +} + +async fn save(pool: &SqlitePool, record: &ProfileRecord) -> Result<(), SyncError> { + sqlx::query( + "INSERT INTO sync2_profiles (link, name, hlc, deleted) VALUES (?, ?, ?, ?) + ON CONFLICT (link) DO UPDATE SET name = excluded.name, hlc = excluded.hlc, + deleted = excluded.deleted", + ) + .bind(&record.link) + .bind(&record.name) + .bind(record.hlc.to_string()) + .bind(record.deleted) + .execute(pool) + .await?; + Ok(()) +} + +/// Merges records read from other devices, and returns the links they newly deleted. +pub async fn merge( + pool: &SqlitePool, + incoming: &[ProfileRecord], +) -> Result, SyncError> { + let mut deleted = Vec::new(); + for record in incoming { + if stored(pool, &record.link) + .await? + .is_some_and(|hlc| hlc >= record.hlc) + { + continue; + } + save(pool, record).await?; + mark_sent(pool, std::slice::from_ref(record)).await?; + if record.deleted { + deleted.push(record.link.clone()); + } + } + let highest = incoming + .iter() + .map(|r| r.hlc.physical_ms) + .max() + .unwrap_or(0); + clock::observe(&mut *pool.acquire().await?, highest).await?; + Ok(deleted) +} + +/// Names `link` (or deletes it) as this device's newest edit. +pub async fn set( + pool: &SqlitePool, + device_id: &str, + link: &str, + name: &str, + deleted: bool, +) -> Result<(), SyncError> { + let hlc = Hlc::new(clock::edit_ms(pool).await?, 0, device_id); + let record = ProfileRecord { + deleted, + hlc, + link: link.to_owned(), + name: name.to_owned(), + }; + save(pool, &record).await +} + +/// Writes a record for each of `named` (link and local name) that has none yet. +pub async fn name_missing( + pool: &SqlitePool, + device_id: &str, + named: &[(String, String)], +) -> Result<(), SyncError> { + for (link, name) in named { + if stored(pool, link).await?.is_none() { + set(pool, device_id, link, name, false).await?; + } + } + Ok(()) +} + +/// Records this device hasn't sent in their current form. +pub async fn unsent(pool: &SqlitePool) -> Result, SyncError> { + let sent: Vec<(String, String)> = sqlx::query_as( + "SELECT record_key, hash FROM sync2_sent_links WHERE link = '' AND collection = ?", + ) + .bind(PROFILES) + .fetch_all(pool) + .await?; + Ok(records(pool) + .await? + .into_iter() + .filter(|r| { + !sent + .iter() + .any(|(link, hlc)| *link == r.link && *hlc == r.hlc.to_string()) + }) + .collect()) +} + +/// Records sent or received, so they don't go out again unchanged. +pub async fn mark_sent(pool: &SqlitePool, records: &[ProfileRecord]) -> Result<(), SyncError> { + for record in records { + sqlx::query( + "INSERT INTO sync2_sent_links (link, collection, record_key, hash, sent_at) + VALUES ('', ?, ?, ?, 0) + ON CONFLICT (link, collection, record_key) DO UPDATE SET hash = excluded.hash", + ) + .bind(PROFILES) + .bind(&record.link) + .bind(record.hlc.to_string()) + .execute(pool) + .await?; + } + Ok(()) +} diff --git a/src-tauri/src/sync2/service.rs b/src-tauri/src/sync2/service.rs index 8b1e7174..26b69936 100644 --- a/src-tauri/src/sync2/service.rs +++ b/src-tauri/src/sync2/service.rs @@ -20,9 +20,10 @@ use super::group_store::GroupStore; use super::kept::{applied as applied_kept, has_kept, keep, kept}; use super::live_hub::forget_keys; use super::names::device_names; +use super::profile_actions::name_linked; use super::profile_links::reading; use super::server::StoreError; -use super::snapshot::{note_merged, snapshot_due}; +use super::snapshot::{PROFILES_KEY, note_merged, snapshot_due}; use super::state::{link_read, links_to_read, load_group, save_floors}; use crate::sync::canonical::Collection; use crate::sync::export::export_collections; @@ -115,7 +116,7 @@ async fn run(app: &AppHandle) -> Result> { let Some(record) = load_group(&local.pool).await? else { return Ok(Vec::new()); }; - let mut session = open_session(&local.pool, &record, &local.device).await?; + let mut session = open_session(&local, &record).await?; let Some(received) = exchange(app, &local, &mut session).await? else { return Ok(Vec::new()); }; @@ -165,6 +166,8 @@ async fn exchange( }; session.store().upload_pending().await?; session.push(pool, exported).await?; + name_linked(app, pool, &local.meta.app).await?; + session.push_profiles(pool).await?; let pulled = session.pull(pool).await?; let groups: Vec<_> = pulled @@ -186,6 +189,9 @@ async fn exchange( let exported = export_collections(data, &local.meta.app, &Collection::ALL).await?; session.write_snapshot(pool, exported).await?; } + if snapshot_due(pool, PROFILES_KEY).await? { + session.write_profiles_snapshot(pool).await?; + } Ok(Some(received)) } diff --git a/src-tauri/src/sync2/snapshot.rs b/src-tauri/src/sync2/snapshot.rs index df68b324..7d8cbead 100644 --- a/src-tauri/src/sync2/snapshot.rs +++ b/src-tauri/src/sync2/snapshot.rs @@ -15,6 +15,7 @@ use super::gc::{Logged, record_manifest}; use super::kdf::sha256; use super::paths::blob_path; use super::payload::encode_payload; +use super::profile_names::{self, PROFILES}; use super::state::streams; use super::store::Store; use crate::sync::canonical::to_canonical_json; @@ -22,6 +23,8 @@ use crate::sync::error::SyncError; use crate::sync::protocol::OutgoingCollection; const MERGED_LIMIT: i64 = 1024 * 1024; +/// How the `profiles` collection is named among links in this device's snapshot records. +pub const PROFILES_KEY: &str = ""; const MAX_AGE_MS: i64 = 7 * 24 * 60 * 60 * 1000; /// spec/store.md asks for at most 2 MiB of payload per blob. const BLOB_CONTENT_LIMIT: usize = 2 * 1024 * 1024; @@ -126,6 +129,26 @@ impl Session<'_, S> { let Some(link) = self.reading().active.clone() else { return Ok(()); }; + let collections = exported + .iter() + .map(|c| (c.collection.key(), c.ndjson.as_slice())) + .collect(); + self.write_snapshot_of(pool, Some(link), collections).await + } + + /// Writes the `profiles` collection as a snapshot of its own. + pub async fn write_profiles_snapshot(&mut self, pool: &SqlitePool) -> Result<()> { + let ndjson = profile_names::ndjson(pool).await?; + self.write_snapshot_of(pool, None, vec![(PROFILES, ndjson.as_slice())]) + .await + } + + async fn write_snapshot_of( + &mut self, + pool: &SqlitePool, + link: Option, + collections: Vec<(&str, &[u8])>, + ) -> Result<()> { let group = *self.group(); let epoch = self.roster().epoch; let did = self.device().did; @@ -137,14 +160,14 @@ impl Session<'_, S> { ObjectType::Snapshot.purpose(), ); let mut parts = Vec::new(); - for collection in exported { - for records in chunks(&collection.ndjson) { - let content = batch_content(collection.collection, &head, &link, &records); + for (collection, ndjson) in collections { + for records in chunks(ndjson) { + let content = batch_content(collection, &head, link.as_deref(), &records); let blob = seal_blob(&key, &group, epoch, &did, &encode_payload(&content)?); let name = hex::encode(sha256(&blob)); parts.push(Part { blob: name.clone(), - collection: collection.collection.key().to_owned(), + collection: collection.to_owned(), size: blob.len() as u64, }); self.store() @@ -172,7 +195,7 @@ impl Session<'_, S> { ); let manifest = Manifest { head, - link: Some(link.clone()), + link: link.clone(), parts, vv, }; @@ -184,7 +207,7 @@ impl Session<'_, S> { manifest, }; record_manifest(pool, &logged).await?; - snapshot_written(pool, &link).await + snapshot_written(pool, link.as_deref().unwrap_or(PROFILES_KEY)).await } } diff --git a/src-tauri/tests/profiles/store_inventory.tsv b/src-tauri/tests/profiles/store_inventory.tsv index 54b218da..c34e4bd8 100644 --- a/src-tauri/tests/profiles/store_inventory.tsv +++ b/src-tauri/tests/profiles/store_inventory.tsv @@ -8,7 +8,7 @@ table download_jobs DEVICE - The orphan-partial sweep deletes .flowpart files no table sync_identity DEVICE - The sync device id; stays in Default's file where older builds read it table sync_peers DEFAULT_ONLY sync Only Default syncs table sync_log DEFAULT_ONLY sync Only Default syncs -table sync_changes DEFAULT_ONLY sync Only Default syncs; local edits are not recorded elsewhere +table sync_changes PROFILE sync Each profile that syncs records its own local edits table sync2_sent DEAD - Replaced by sync2_sent_links; still cleared when a group is forgotten table sync2_group DEVICE - The group's state, in Default's file whichever profile runs: the FLOW-SYNC/2 group and log positions table sync2_roster DEVICE - The group's state, in Default's file whichever profile runs: the verified FLOW-SYNC/2 roster diff --git a/src-tauri/tests/sync2_device_state.rs b/src-tauri/tests/sync2_device_state.rs index c1c1aef8..c3da29ba 100644 --- a/src-tauri/tests/sync2_device_state.rs +++ b/src-tauri/tests/sync2_device_state.rs @@ -41,7 +41,12 @@ async fn applied_records_count_as_sent_in_defaults_database() { .find(|c| c.collection == Collection::WatchHistory) .unwrap(); let records: Vec<&[u8]> = history.ndjson.split(|&b| b == b'\n').collect(); - let content = batch_content(Collection::WatchHistory, "head", "link", &records); + let content = batch_content( + Collection::WatchHistory.key(), + "head", + Some("link"), + &records, + ); let batch = parse_batch(&content).unwrap(); apply_batches(&pool, "app-b", "sender", &[batch], 0) .await diff --git a/src-tauri/tests/sync2_profile_names.rs b/src-tauri/tests/sync2_profile_names.rs new file mode 100644 index 00000000..a0255196 --- /dev/null +++ b/src-tauri/tests/sync2_profile_names.rs @@ -0,0 +1,76 @@ +//! The FLOW-SYNC/2 `profiles` collection (spec/protocol.md, Profiles): names merge last-writer-wins +//! across devices, and a link deleted on one device stops syncing on the others. + +mod sync2_common; + +use flow_desktop_lib::sync::canonical::Hlc; +use flow_desktop_lib::sync2::engine::Session; +use flow_desktop_lib::sync2::profile_links::{link_profile, linked}; +use flow_desktop_lib::sync2::profile_names::{ProfileRecord, merge, records, set}; +use sync2_common::{device_pool, empty_pool, group}; + +const HOME: &str = "link-home"; +const KIDS: &str = "link-kids"; + +fn record(link: &str, name: &str, ms: u64, deleted: bool) -> ProfileRecord { + ProfileRecord { + deleted, + hlc: Hlc::new(ms, 0, "bbbbbbbb"), + link: link.to_owned(), + name: name.to_owned(), + } +} + +#[tokio::test] +async fn the_newest_name_wins() { + let pool = empty_pool().await; + merge(&pool, &[record(HOME, "Home", 200, false)]) + .await + .unwrap(); + merge(&pool, &[record(HOME, "Older", 100, false)]) + .await + .unwrap(); + assert_eq!(records(&pool).await.unwrap()[0].name, "Home"); + + let deleted = merge(&pool, &[record(HOME, "Home", 300, true)]) + .await + .unwrap(); + assert_eq!(deleted, [HOME]); + assert!(records(&pool).await.unwrap()[0].deleted); +} + +#[tokio::test] +async fn names_reach_other_devices_and_a_deleted_link_stops_syncing_there() { + let g = group().await; + let a_state = device_pool(&g).await; + let mut a = Session::open(&a_state, &g.store, &g.a).await.unwrap(); + set(&a_state, "device-a", HOME, "Home", false) + .await + .unwrap(); + set(&a_state, "device-a", KIDS, "Kids", true).await.unwrap(); + a.push_profiles(&a_state).await.unwrap(); + let pushed = a.record().own_seq; + a.push_profiles(&a_state).await.unwrap(); + assert_eq!(a.record().own_seq, pushed, "nothing new goes out again"); + + let b_state = device_pool(&g).await; + link_profile(&b_state, "kids-here", KIDS).await.unwrap(); + let mut b = Session::open(&b_state, &g.store, &g.b).await.unwrap(); + let pulled = b.pull(&b_state).await.unwrap(); + b.finish(&b_state, pulled).await.unwrap(); + + let names: Vec<(String, String, bool)> = records(&b_state) + .await + .unwrap() + .into_iter() + .map(|r| (r.link, r.name, r.deleted)) + .collect(); + assert_eq!( + names, + [ + (HOME.to_owned(), "Home".to_owned(), false), + (KIDS.to_owned(), "Kids".to_owned(), true) + ] + ); + assert!(linked(&b_state).await.unwrap().is_empty()); +} diff --git a/src-tauri/tests/sync2_profiles.rs b/src-tauri/tests/sync2_profiles.rs index 6fd08e70..f46f3f91 100644 --- a/src-tauri/tests/sync2_profiles.rs +++ b/src-tauri/tests/sync2_profiles.rs @@ -11,6 +11,7 @@ use flow_desktop_lib::sync2::engine::{Session, now_ms}; use flow_desktop_lib::sync2::kept::kept; use flow_desktop_lib::sync2::paths::log_path; use flow_desktop_lib::sync2::profile_links::Reading; +use flow_desktop_lib::sync2::profile_names::set; use sqlx::SqlitePool; use sync2_common::{Group, MemoryStore, device_pool, empty_pool, exported, group, watch, watched}; @@ -224,20 +225,22 @@ async fn a_deleted_profile_no_longer_holds_back_garbage_collection() { let b_state = device_pool(&g).await; let mut b = Session::open(&b_state, &g.store, &g.b).await.unwrap(); b.set_reading(running(HOME, &[KIDS])); - let pulled = b.pull(&b_state).await.unwrap(); - b.finish(&b_state, pulled).await.unwrap(); a.set_reading(running(HOME, &[KIDS])); - let pulled = a.pull(&a_state).await.unwrap(); - a.finish(&a_state, pulled).await.unwrap(); - + let read_each_other = async |a: &mut Session<'_, &MemoryStore>, + b: &mut Session<'_, &MemoryStore>| { + let pulled = b.pull(&b_state).await.unwrap(); + b.finish(&b_state, pulled).await.unwrap(); + let pulled = a.pull(&a_state).await.unwrap(); + a.finish(&a_state, pulled).await.unwrap(); + }; + read_each_other(&mut a, &mut b).await; a.collect_garbage(&a_state).await.unwrap(); assert!(has(&g, 1), "kids has no snapshot yet"); - sqlx::query("INSERT INTO sync2_profiles (link, name, hlc, deleted) VALUES (?, 'Kids', '1:0:aaaaaaaa', 1)") - .bind(KIDS) - .execute(&a_state) - .await - .unwrap(); + set(&a_state, "device-a", KIDS, "Kids", true).await.unwrap(); + a.push_profiles(&a_state).await.unwrap(); + a.write_profiles_snapshot(&a_state).await.unwrap(); + read_each_other(&mut a, &mut b).await; a.collect_garbage(&a_state).await.unwrap(); assert!((1..=pushed).all(|seq| !has(&g, seq))); } diff --git a/src-tauri/tests/sync2_recovery.rs b/src-tauri/tests/sync2_recovery.rs index 6a6c487a..29a1093d 100644 --- a/src-tauri/tests/sync2_recovery.rs +++ b/src-tauri/tests/sync2_recovery.rs @@ -137,3 +137,26 @@ async fn a_restored_device_finds_the_link_in_a_snapshot() { assert!(session.adopt_link(&pool, "own-profile").await.unwrap()); assert_eq!(load_group(&pool).await.unwrap().unwrap().link, LINK); } + +#[tokio::test] +async fn a_restored_device_waits_for_a_choice_when_several_links_sync() { + let g = group().await; + let pool_a = device_pool(&g).await; + let mut session_a = Session::open(&pool_a, &g.store, &g.a).await.unwrap(); + session_a + .write_snapshot(&pool_a, exported(&pool_a, "app-a").await) + .await + .unwrap(); + let mut kids = session_a.reading().clone(); + kids.active = Some("link-kids".into()); + session_a.set_reading(kids); + session_a + .write_snapshot(&pool_a, exported(&pool_a, "app-a").await) + .await + .unwrap(); + + let (device, pool) = restored_device(&g).await; + let mut session = Session::open(&pool, &g.store, &device).await.unwrap(); + assert!(!session.adopt_link(&pool, "own-profile").await.unwrap()); + assert!(load_group(&pool).await.unwrap().unwrap().link.is_empty()); +} diff --git a/src/components/sync/linked/LinkedOverview.tsx b/src/components/sync/linked/LinkedOverview.tsx index 4b8bc6bf..ac290fb4 100644 --- a/src/components/sync/linked/LinkedOverview.tsx +++ b/src/components/sync/linked/LinkedOverview.tsx @@ -12,6 +12,7 @@ import { Card } from "./Card"; import { DeviceList } from "./DeviceList"; import { JoinRequests } from "./JoinRequests"; import { LeaveGroup } from "./LeaveGroup"; +import { ProfilesCard } from "./ProfilesCard"; function lastSyncText(status: LinkedStatus, syncing: boolean): string { if (syncing) return getString("linked_syncing"); @@ -66,6 +67,7 @@ export function LinkedOverview({ status }: { status: LinkedStatus }) { {getString("linked_add_device")} + ); } diff --git a/src/components/sync/linked/ProfilesCard.tsx b/src/components/sync/linked/ProfilesCard.tsx new file mode 100644 index 00000000..7ae241bc --- /dev/null +++ b/src/components/sync/linked/ProfilesCard.tsx @@ -0,0 +1,177 @@ +import { useState } from "react"; + +import type { SyncedProfile } from "../../../lib/api/linked"; +import type { Profile } from "../../../lib/api/profiles"; +import { getString } from "../../../lib/i18n/index"; +import { useLinkedProfiles } from "../../../lib/linked/useLinkedProfiles"; +import { profileDisplayName } from "../../../lib/profiles/display"; +import { useProfilesStore } from "../../../store/useProfilesStore"; +import { Button } from "../../ui/Button"; +import { ProfileAvatar } from "../../ui/ProfileAvatar"; +import { Select } from "../../ui/Select"; +import { ToggleSwitch } from "../../ui/ToggleSwitch"; +import { Spinner } from "../SyncParts"; +import { Card } from "./Card"; + +const NEW_LINK = ""; + +function syncedName(profile: SyncedProfile): string { + return profile.name.trim() || getString("linked_profile_unnamed"); +} + +type Controls = ReturnType; + +/** One profile here: whether it syncs, how it starts, and how it stops. */ +function ProfileRow({ + profile, + link, + synced, + controls, +}: { + profile: Profile; + link: string | null; + synced: SyncedProfile[]; + controls: Controls; +}) { + const photo = useProfilesStore((state) => state.view?.photos[profile.id]); + const [choosing, setChoosing] = useState<"start" | "stop" | null>(null); + const [joining, setJoining] = useState(NEW_LINK); + const name = profileDisplayName(profile); + + const onToggle = (on: boolean) => { + if (on && synced.length === 0) void controls.start(profile.id, null); + else setChoosing(on ? "start" : "stop"); + }; + const finish = (action: Promise) => void action.then(() => setChoosing(null)); + + return ( +
+
+ +
+
{name}
+
+ {getString(link ? "linked_profile_syncs" : "linked_profile_local")} +
+
+ +
+ {choosing === "start" && ( +
+ {getString("linked_profile_as")} +