From 4f874f872c6792e92470d7a86013fdee6fe12271 Mon Sep 17 00:00:00 2001 From: franzer Date: Wed, 1 Jul 2026 07:52:24 -0600 Subject: [PATCH] feat: add global personal store (lore sync --global) --- src/cli/commands/sync.rs | 746 +++++++++++++++++++++++++++++++++++++-- src/config/mod.rs | 17 + src/storage/db.rs | 447 ++++++++++++++++++++++- 3 files changed, 1165 insertions(+), 45 deletions(-) diff --git a/src/cli/commands/sync.rs b/src/cli/commands/sync.rs index 3bc5a5c..5d02b14 100644 --- a/src/cli/commands/sync.rs +++ b/src/cli/commands/sync.rs @@ -44,6 +44,13 @@ use crate::sync::SyncError; /// Lives outside `refs/heads/*` so it never checks out into the working tree. const SESSIONS_REF: &str = "refs/lore/sessions"; +/// Remote name used inside the managed global store repo (`~/.lore/sync`). +/// +/// The global store is a dedicated repository whose single remote always points +/// at the user's configured `sync_global_remote` URL, so every gitref operation +/// on the global store uses this fixed remote name. +const GLOBAL_REMOTE: &str = "origin"; + /// Minimum passphrase length for a newly created store. const MIN_PASSPHRASE_LEN: usize = 8; @@ -65,9 +72,20 @@ pub struct Args { pub command: Option, /// Remote to sync the lore store with (default: origin). + /// + /// Ignored in the global path (`--global`), which always uses the global + /// store's own `origin` remote configured from `sync_global_remote`. #[arg(long, global = true, default_value = "origin")] pub remote: String, + /// Sync the global personal store instead of this repo's store. + /// + /// The global store is a managed git repo at `~/.lore/sync` holding the + /// user's cross-tool, cross-repo aggregate of encrypted sessions, synced to + /// a private remote the user owns (configured via `sync_global_remote`). + #[arg(long, global = true)] + pub global: bool, + /// Hook-friendly mode used by the pre-push hook. /// /// No-ops and exits 0 when this repo's store is not set up or no key is @@ -153,8 +171,45 @@ struct StatusOutput { remote: String, } +/// JSON output for `lore sync --global status`. +#[derive(Serialize)] +struct GlobalStatusOutput { + set_up: bool, + keyed: bool, + unsynced_sessions: i32, + last_sync_at: Option, + remote_store_exists: bool, + local_ref: Option, + tracking_ref: Option, + remote: Option, + store_path: String, +} + +/// Identifies which lore store a sync operates on. +/// +/// Both stores share the identical on-disk format and the same `refs/lore/sessions` +/// ref name; they differ only in which git repository holds the ref, which +/// sessions are pushed, and which sync-tracking column marks progress. Threading +/// this enum through [`perform_sync_in_store`] lets the per-repo and global paths +/// share all fetch, merge, orphan, carry-forward, CAS, and push machinery. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SyncStore { + /// This repository's store under `refs/lore/sessions` in the repo itself. + PerRepo, + /// The global personal store under `refs/lore/sessions` in `~/.lore/sync`. + Global, +} + /// Executes the sync command. pub fn run(args: Args) -> Result<()> { + if args.global { + return match args.command { + Some(SyncSubcommand::Setup) => run_global_setup(), + Some(SyncSubcommand::Status { format }) => run_global_status(format), + None => run_global_sync(), + }; + } + match args.command { Some(SyncSubcommand::Setup) => run_setup(&args.remote), Some(SyncSubcommand::Status { format }) => run_status(&args.remote, format), @@ -169,36 +224,48 @@ pub fn run(args: Args) -> Result<()> { fn run_setup(remote: &str) -> Result<()> { let repo = current_repo()?; let mut config = Config::load()?; - let machine = machine_identity(&mut config)?; + create_or_join_store(&repo, remote, &mut config)?; + println!("Run 'lore sync' to push your reasoning history."); + Ok(()) +} + +/// Creates a new store or joins an existing one at `repo`/`remote`. +/// +/// Shared by the per-repo ([`run_setup`]) and global ([`run_global_setup`]) +/// setup paths: it fetches the remote-tracking ref to detect an existing store, +/// then either joins it (single passphrase prompt, verified by decrypting an +/// existing session) or creates a new one (confirmed passphrase, minimum +/// length). The derived key is stored locally so later syncs do not prompt. +fn create_or_join_store(repo: &Path, remote: &str, config: &mut Config) -> Result<()> { + let machine = machine_identity(config)?; let keystore = KeyStore::with_keychain(config.use_keychain); // Populate the remote-tracking ref so we can tell whether a store already // exists on the remote (and read its salt) without touching the local ref. - gitref::fetch(&repo, remote, SESSIONS_REF) + gitref::fetch(repo, remote, SESSIONS_REF) .with_context(|| format!("Failed to reach remote '{remote}'"))?; - match read_store_salt(&repo, remote)? { + match read_store_salt(repo, remote)? { Some(salt) => { println!("{}", "An existing lore store was found. Joining it.".bold()); - println!("Enter the shared passphrase for this repo's lore store."); + println!("Enter the shared passphrase for this lore store."); let passphrase = prompt_passphrase()?; - join_store(&repo, remote, &keystore, &machine, &salt, &passphrase)?; + join_store(repo, remote, &keystore, &machine, &salt, &passphrase)?; println!("{} Joined the lore store.", "Success!".green().bold()); } None => { - println!("{}", "Setting up a new lore store for this repo.".bold()); + println!("{}", "Setting up a new lore store.".bold()); println!( - "Your reasoning history is encrypted with a passphrase only you and\n\ - your teammates know. Share it out of band; the git host never sees it." + "Your reasoning history is encrypted with a passphrase only you\n\ + (and any teammates) know. It is never sent to the git host." ); println!(); let passphrase = prompt_new_passphrase()?; - create_store(&repo, remote, &keystore, &machine, &passphrase)?; + create_store(repo, remote, &keystore, &machine, &passphrase)?; println!("{} Created the lore store.", "Success!".green().bold()); } } - println!("Run 'lore sync' to push your reasoning history."); Ok(()) } @@ -439,6 +506,44 @@ fn perform_sync( salt: &[u8], machine: &MachineIdentity, sessions: Vec, +) -> Result { + perform_sync_in_store( + SyncStore::PerRepo, + db, + repo, + remote, + key, + salt, + machine, + sessions, + ) +} + +/// Store-parameterized core of [`perform_sync`]. +/// +/// `store` selects the three store-specific behaviors while every other step is +/// shared verbatim between the per-repo and global paths: +/// +/// - Carry-forward scope: [`SyncStore::PerRepo`] uses this repo's session ids +/// ([`Database::get_session_ids_for_repo`]); [`SyncStore::Global`] uses every +/// session id ([`Database::get_all_session_ids`]). +/// - Merge import marking: per-repo marks the `synced_at` track; global marks the +/// `global_synced_at` track (see [`merge_remote_in_store`]). +/// - Post-push marking: per-repo calls [`Database::mark_sessions_synced`]; global +/// calls [`Database::mark_global_synced`]. +/// +/// The caller supplies `sessions`, the exact set to push (per-repo passes the +/// repo-scoped unsynced set; global passes all unsynced-global sessions). +#[allow(clippy::too_many_arguments)] +fn perform_sync_in_store( + store: SyncStore, + db: &mut Database, + repo: &Path, + remote: &str, + key: &[u8], + salt: &[u8], + machine: &MachineIdentity, + sessions: Vec, ) -> Result { let mut pulled_total = 0; @@ -457,8 +562,9 @@ fn perform_sync( }; // MERGE remote -> local database (full records, newer-wins). A wrong key - // surfaces here and aborts before anything is built or pushed. - pulled_total += merge_remote(db, repo, &tracking_entries, key)?; + // surfaces here and aborts before anything is built or pushed. The store + // selects which sync-tracking column an imported session is marked on. + pulled_total += merge_remote_in_store(store, db, repo, &tracking_entries, key)?; merge_machines(db, repo, &tracking_entries)?; // BUILD the outgoing tree. Separate the TREE BASE (which entries the new @@ -501,7 +607,12 @@ fn perform_sync( // session a candidate; `carry_forward_local_sessions` still re-adds only // in-scope ids, so out-of-scope artifacts are never carried forward. if let Some(local_commit) = &old_local { - let in_scope = db.get_session_ids_for_repo(repo)?; + // Carry-forward scope: the per-repo store is limited to this repo's + // sessions, while the global store spans every session. + let in_scope = match store { + SyncStore::PerRepo => db.get_session_ids_for_repo(repo)?, + SyncStore::Global => db.get_all_session_ids()?, + }; let carry_tracking: &[TreeEntry] = if tracking_commit.is_some() { &tracking_entries } else { @@ -541,7 +652,10 @@ fn perform_sync( } let ids: Vec = sessions.iter().map(|s| s.id).collect(); - db.mark_sessions_synced(&ids, Utc::now())?; + match store { + SyncStore::PerRepo => db.mark_sessions_synced(&ids, Utc::now())?, + SyncStore::Global => db.mark_global_synced(&ids, Utc::now())?, + }; return Ok(SyncSummary { pulled: pulled_total, @@ -552,21 +666,41 @@ fn perform_sync( bail!("Sync did not converge after {MAX_SYNC_ATTEMPTS} attempts due to concurrent updates") } +/// Merges every encrypted session in `entries` into the database (per-repo). +/// +/// Thin per-repo wrapper over [`merge_remote_in_store`], retained for tests. +#[cfg(test)] +fn merge_remote( + db: &mut Database, + repo: &Path, + entries: &[TreeEntry], + key: &[u8], +) -> Result { + merge_remote_in_store(SyncStore::PerRepo, db, repo, entries, key) +} + /// Merges every encrypted session in `entries` into the database. /// -/// Each record is applied atomically by [`Database::merge_remote_record`]: the -/// session row and messages follow newer-wins (by message_count then ended_at) -/// while links, tags, and annotations are always merged (additive, idempotent by -/// id) so a remote addition to an already-synced session is not lost, and the -/// summary is kept only when strictly newer. Returns the number of sessions -/// whose row was imported or updated (the newer-wins branch ran). +/// Each record is applied atomically by the merge writer: the session row and +/// messages follow newer-wins (by message_count then ended_at) while links, +/// tags, and annotations are always merged (additive, idempotent by id) so a +/// remote addition to an already-synced session is not lost, and the summary is +/// kept only when strictly newer. Returns the number of sessions whose row was +/// imported or updated (the newer-wins branch ran). +/// +/// `store` selects which sync-tracking column an imported session is marked on: +/// [`SyncStore::PerRepo`] marks `synced_at` (via [`Database::merge_remote_record`]) +/// and [`SyncStore::Global`] marks `global_synced_at` (via +/// [`Database::merge_remote_record_global`]). Marking only the merging store's +/// column keeps the two sync tracks independent. /// /// A blob that cannot be decrypted is normally skipped (corruption or a single /// stray entry). But if the store held session blobs and NONE of them decrypted, /// the stored key is wrong for this store, so this returns an error rather than /// letting the caller push locally re-encrypted sessions under the wrong key and /// mark them synced (which would poison the store). -fn merge_remote( +fn merge_remote_in_store( + store: SyncStore, db: &mut Database, repo: &Path, entries: &[TreeEntry], @@ -595,15 +729,27 @@ fn merge_remote( }; decrypted += 1; - let imported = db.merge_remote_record( - &record.session, - &record.messages, - &record.links, - &record.tags, - &record.annotations, - record.summary.as_ref(), - Utc::now(), - )?; + let now = Utc::now(); + let imported = match store { + SyncStore::PerRepo => db.merge_remote_record( + &record.session, + &record.messages, + &record.links, + &record.tags, + &record.annotations, + record.summary.as_ref(), + now, + )?, + SyncStore::Global => db.merge_remote_record_global( + &record.session, + &record.messages, + &record.links, + &record.tags, + &record.annotations, + record.summary.as_ref(), + now, + )?, + }; if imported { pulled += 1; } @@ -791,6 +937,269 @@ fn run_status(remote: &str, format: OutputFormat) -> Result<()> { Ok(()) } +// ==================== global store ==================== + +/// Sets up the global personal store. +/// +/// Prompts for and stores the remote URL when unset, initializes and configures +/// the managed repo at `~/.lore/sync`, then creates or joins the store there +/// using the same machinery as the per-repo path (against the store's `origin` +/// remote). +fn run_global_setup() -> Result<()> { + let mut config = Config::load()?; + + let remote_url = match config.sync_global_remote.clone() { + Some(url) => url, + None => { + let url = prompt_global_remote_url()?; + config.sync_global_remote = Some(url.clone()); + config.save()?; + url + } + }; + + let repo = global_store_path()?; + ensure_global_repo(&repo, &remote_url)?; + create_or_join_store(&repo, GLOBAL_REMOTE, &mut config)?; + + println!("Run 'lore sync --global' to push your reasoning history."); + Ok(()) +} + +/// Performs a full sync of the global personal store, pushing all unsynced-global +/// sessions regardless of their working directory. +fn run_global_sync() -> Result<()> { + let mut config = Config::load()?; + let remote_url = config.sync_global_remote.clone().ok_or_else(|| { + anyhow!("The global store is not set up. Run 'lore sync --global setup' first.") + })?; + let machine = machine_identity(&mut config)?; + let keystore = KeyStore::with_keychain(config.use_keychain); + + let repo = global_store_path()?; + ensure_global_repo(&repo, &remote_url)?; + let (key, salt) = load_store_credentials(&repo, GLOBAL_REMOTE, &keystore)?; + + let mut db = Database::open_default()?; + // The global store aggregates every session, so push all unsynced-global + // sessions rather than scoping to a repository. + let sessions = db.get_unsynced_global_sessions()?; + let summary = perform_sync_in_store( + SyncStore::Global, + &mut db, + &repo, + GLOBAL_REMOTE, + &key, + &salt, + &machine, + sessions, + )?; + + println!( + "{} Pulled {}, pushed {}.", + "Global sync complete.".green().bold(), + summary.pulled, + summary.pushed + ); + Ok(()) +} + +/// Shows sync status for the global personal store. +fn run_global_status(format: OutputFormat) -> Result<()> { + let config = Config::load()?; + let keystore = KeyStore::with_keychain(config.use_keychain); + let db = Database::open_default()?; + + let remote_url = config.sync_global_remote.clone(); + let repo = global_store_path()?; + // The managed repo may not exist yet if setup was never run; guard every + // git access so status never errors on an unconfigured global store. + let repo_ready = repo.join(".git").exists(); + + let salt = if repo_ready { + read_store_salt(&repo, GLOBAL_REMOTE)? + } else { + None + }; + let keyed = match &salt { + Some(salt) => keystore.load_key(&store_id_from_salt(salt))?.is_some(), + None => false, + }; + let set_up = salt.is_some(); + + let unsynced = db.unsynced_global_count()?; + let last_sync = db.last_global_sync_time()?; + let (remote_exists, local_ref, tracking_ref) = if repo_ready { + ( + gitref::remote_ref_exists(&repo, GLOBAL_REMOTE, SESSIONS_REF).unwrap_or(false), + gitref::resolve_ref(&repo, SESSIONS_REF)?, + gitref::resolve_ref( + &repo, + &gitref::tracking_ref_name(GLOBAL_REMOTE, SESSIONS_REF)?, + )?, + ) + } else { + (false, None, None) + }; + + match format { + OutputFormat::Json => { + let output = GlobalStatusOutput { + set_up, + keyed, + unsynced_sessions: unsynced, + last_sync_at: last_sync.map(|t| t.to_rfc3339()), + remote_store_exists: remote_exists, + local_ref: local_ref.clone(), + tracking_ref: tracking_ref.clone(), + remote: remote_url.clone(), + store_path: repo.display().to_string(), + }; + println!("{}", serde_json::to_string_pretty(&output)?); + } + OutputFormat::Text | OutputFormat::Markdown => { + println!("{}", "Lore Global Sync".bold()); + println!(); + if set_up && keyed { + println!(" Store: {}", "set up".green()); + } else if set_up { + println!( + " Store: {} (run 'lore sync --global setup')", + "no key on this machine".yellow() + ); + } else { + println!( + " Store: {} (run 'lore sync --global setup')", + "not set up".yellow() + ); + } + match &remote_url { + Some(url) => println!(" Remote: {url}"), + None => println!(" Remote: {}", "not configured".yellow()), + } + println!(" Store path: {}", repo.display()); + println!( + " Remote store: {}", + if remote_exists { "present" } else { "none" } + ); + println!(" Pending sync: {unsynced}"); + match last_sync { + Some(t) => println!(" Last sync: {}", t.to_rfc3339()), + None => println!(" Last sync: {}", "never".dimmed()), + } + println!( + " Local ref: {}", + local_ref.as_deref().unwrap_or("none") + ); + println!( + " Tracking ref: {}", + tracking_ref.as_deref().unwrap_or("none") + ); + } + } + + Ok(()) +} + +/// Returns the path to the managed global store repository (`~/.lore/sync`). +fn global_store_path() -> Result { + let home = dirs::home_dir().ok_or_else(|| anyhow!("Could not find home directory"))?; + Ok(home.join(".lore").join("sync")) +} + +/// Ensures the managed global store repo exists and is configured. +/// +/// Creates the directory and runs `git init` if absent, configures a local +/// committer identity so `commit-tree` works without relying on the user's +/// global git config, disables commit signing so it never blocks on a GPG +/// prompt, and adds or updates the `origin` remote to point at `remote_url`. +/// Idempotent: safe to call on every setup and sync. +fn ensure_global_repo(repo: &Path, remote_url: &str) -> Result<()> { + std::fs::create_dir_all(repo).with_context(|| { + format!( + "Failed to create the global store directory: {}", + repo.display() + ) + })?; + + if !repo.join(".git").exists() { + run_git_checked(repo, &["init", "-q"], "initialize the global store repo")?; + } + + run_git_checked( + repo, + &["config", "user.name", "lore"], + "configure the global store committer name", + )?; + run_git_checked( + repo, + &["config", "user.email", "lore@localhost"], + "configure the global store committer email", + )?; + run_git_checked( + repo, + &["config", "commit.gpgsign", "false"], + "disable commit signing for the global store", + )?; + + configure_origin_remote(repo, remote_url)?; + Ok(()) +} + +/// Adds the `origin` remote, or updates its URL if it already exists. +fn configure_origin_remote(repo: &Path, remote_url: &str) -> Result<()> { + let has_origin = Command::new("git") + .current_dir(repo) + .args(["remote", "get-url", GLOBAL_REMOTE]) + .output() + .context("Failed to run git remote get-url")? + .status + .success(); + + let args: [&str; 4] = if has_origin { + ["remote", "set-url", GLOBAL_REMOTE, remote_url] + } else { + ["remote", "add", GLOBAL_REMOTE, remote_url] + }; + run_git_checked(repo, &args, "configure the global store remote")?; + Ok(()) +} + +/// Runs a git command in `repo`, returning a contextual error on failure. +fn run_git_checked(repo: &Path, args: &[&str], action: &str) -> Result<()> { + let output = Command::new("git") + .current_dir(repo) + .args(args) + .output() + .with_context(|| format!("Failed to run git to {action}"))?; + if !output.status.success() { + bail!( + "Failed to {action}: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + Ok(()) +} + +/// Prompts for the git remote URL of the user's private global store repo. +fn prompt_global_remote_url() -> Result { + println!("{}", "Set up your global personal store.".bold()); + println!( + "Enter the git remote URL of a private repository only you can access.\n\ + It holds your cross-tool, cross-repo reasoning history for personal\n\ + multi-machine backup and search." + ); + print!("Remote URL: "); + io::stdout().flush()?; + let mut url = String::new(); + io::stdin().read_line(&mut url)?; + let url = url.trim().to_string(); + if url.is_empty() { + bail!("A remote URL is required to set up the global store."); + } + Ok(url) +} + // ==================== helpers ==================== /// Loads the store's key and salt, or errors pointing the user to setup. @@ -2101,4 +2510,281 @@ mod tests { ); } } + + // ==================== global store ==================== + + /// Initializes a managed global-store repo in a temp dir wired to `remote_url`. + /// + /// Mirrors the real `~/.lore/sync` setup ([`ensure_global_repo`]) but on an + /// injectable path so the user's real global store is never touched. + fn init_global_store(remote_url: &str) -> (TempDir, PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let store = dir.path().to_path_buf(); + ensure_global_repo(&store, remote_url).unwrap(); + (dir, store) + } + + #[test] + fn test_global_create_store_writes_salt_and_pushes() { + let (_remote_dir, remote_url) = init_bare_remote(); + let (_store_dir, store) = init_global_store(&remote_url); + + let (keystore, _kd) = test_keystore(); + let m = machine("machine-a", "Machine A"); + + create_store( + &store, + GLOBAL_REMOTE, + &keystore, + &m, + "correct horse battery", + ) + .unwrap(); + + // The local ref exists and holds the salt. + assert!(gitref::ref_exists(&store, SESSIONS_REF).unwrap()); + let salt = read_store_salt(&store, GLOBAL_REMOTE) + .unwrap() + .expect("salt written"); + // The derived key is stored under the salt-derived store id. + assert!(keystore + .load_key(&store_id_from_salt(&salt)) + .unwrap() + .is_some()); + // The store was pushed to the global remote. + assert!(gitref::remote_ref_exists(&store, GLOBAL_REMOTE, SESSIONS_REF).unwrap()); + } + + #[test] + fn test_ensure_global_repo_is_idempotent_and_updates_remote() { + let (_remote_dir, remote_url) = init_bare_remote(); + let dir = tempfile::tempdir().unwrap(); + let store = dir.path().join("sync"); + + // First call creates the repo and configures origin. + ensure_global_repo(&store, &remote_url).unwrap(); + assert!(store.join(".git").exists()); + assert_eq!( + git_out(&store, &["remote", "get-url", "origin"]), + remote_url + ); + assert_eq!(git_out(&store, &["config", "user.name"]), "lore"); + + // A second call with a new URL updates the remote without failing. + let (_remote_dir2, remote_url2) = init_bare_remote(); + ensure_global_repo(&store, &remote_url2).unwrap(); + assert_eq!( + git_out(&store, &["remote", "get-url", "origin"]), + remote_url2 + ); + } + + #[test] + fn test_global_sync_round_trip_between_machines() { + let (_remote_dir, remote_url) = init_bare_remote(); + let passphrase = "shared global passphrase"; + + // Machine A: set up the global store, seed sessions in UNRELATED + // directories (the global store aggregates across repos), and sync. + let (_store_a, store_a) = init_global_store(&remote_url); + let (keystore_a, _ka) = test_keystore(); + let ma = machine("machine-a", "Machine A"); + create_store(&store_a, GLOBAL_REMOTE, &keystore_a, &ma, passphrase).unwrap(); + + let (mut db_a, _da) = open_db(); + let id_one = seed_full_session(&mut db_a, "machine-a", "/projects/repo-one"); + let id_two = seed_full_session(&mut db_a, "machine-a", "/elsewhere/repo-two"); + let (key_a, salt_a) = load_store_credentials(&store_a, GLOBAL_REMOTE, &keystore_a).unwrap(); + let sessions_a = db_a.get_unsynced_global_sessions().unwrap(); + assert_eq!(sessions_a.len(), 2); + let summary_a = perform_sync_in_store( + SyncStore::Global, + &mut db_a, + &store_a, + GLOBAL_REMOTE, + &key_a, + &salt_a, + &ma, + sessions_a, + ) + .unwrap(); + assert_eq!(summary_a.pushed, 2, "global sync pushes all sessions"); + + // Both sessions are now marked synced on the global track. + assert!(db_a.get_unsynced_global_sessions().unwrap().is_empty()); + + // Machine B: join the global store and sync (pull). + let (_store_b, store_b) = init_global_store(&remote_url); + let (keystore_b, _kb) = test_keystore(); + let mb = machine("machine-b", "Machine B"); + gitref::fetch(&store_b, GLOBAL_REMOTE, SESSIONS_REF).unwrap(); + let salt_b = read_store_salt(&store_b, GLOBAL_REMOTE).unwrap().unwrap(); + join_store( + &store_b, + GLOBAL_REMOTE, + &keystore_b, + &mb, + &salt_b, + passphrase, + ) + .unwrap(); + + let (mut db_b, _db) = open_db(); + let (key_b, salt_b2) = + load_store_credentials(&store_b, GLOBAL_REMOTE, &keystore_b).unwrap(); + let sessions_b = db_b.get_unsynced_global_sessions().unwrap(); + let summary_b = perform_sync_in_store( + SyncStore::Global, + &mut db_b, + &store_b, + GLOBAL_REMOTE, + &key_b, + &salt_b2, + &mb, + sessions_b, + ) + .unwrap(); + assert_eq!(summary_b.pulled, 2, "both sessions must be pulled"); + + // Machine B has both sessions with their full reasoning records. + for id in [id_one, id_two] { + assert!( + db_b.get_session(&id).unwrap().is_some(), + "session {id} must be pulled" + ); + assert_eq!(db_b.get_messages(&id).unwrap().len(), 1); + assert_eq!(db_b.get_links_by_session(&id).unwrap().len(), 1); + } + } + + #[test] + fn test_global_sync_pushes_all_sessions_regardless_of_directory() { + // The global store pushes every unsynced-global session no matter its + // working directory, in contrast to the repo-scoped per-repo path which + // would push none of these (none live inside the store repo). + let (_remote_dir, remote_url) = init_bare_remote(); + let (_store_dir, store) = init_global_store(&remote_url); + + let (keystore, _kd) = test_keystore(); + let m = machine("machine-a", "Machine A"); + create_store(&store, GLOBAL_REMOTE, &keystore, &m, "passphrase abcdefgh").unwrap(); + + let (mut db, _dd) = open_db(); + let id_a = seed_full_session(&mut db, "machine-a", "/somewhere/project-a"); + let id_b = seed_full_session(&mut db, "machine-a", "/totally/other/project-b"); + + // The per-repo scope for the store repo captures neither session. + assert!( + db.get_unsynced_sessions_for_repo(&store) + .unwrap() + .is_empty(), + "no session lives inside the store repo, so per-repo scope is empty" + ); + + let (key, salt) = load_store_credentials(&store, GLOBAL_REMOTE, &keystore).unwrap(); + let sessions = db.get_unsynced_global_sessions().unwrap(); + let summary = perform_sync_in_store( + SyncStore::Global, + &mut db, + &store, + GLOBAL_REMOTE, + &key, + &salt, + &m, + sessions, + ) + .unwrap(); + assert_eq!(summary.pushed, 2, "global sync pushes both sessions"); + + // Both encrypted blobs are present in the store. + let entries = gitref::read_tree(&store, SESSIONS_REF).unwrap(); + for id in [id_a, id_b] { + assert!( + entries + .iter() + .any(|e| e.path == format!("sessions/{id}.enc")), + "session {id} must be stored in the global store" + ); + } + } + + #[test] + fn test_per_repo_sync_does_not_mark_global_track() { + // A per-repo sync marks only the synced_at track; the session stays + // pending for the global store (and vice versa is covered by the DB tests). + let (_remote_dir, remote_url) = init_bare_remote(); + let dir = tempfile::tempdir().unwrap(); + let repo = dir.path(); + init_repo(repo); + git(repo, &["remote", "add", "origin", &remote_url]); + + let (keystore, _kd) = test_keystore(); + let m = machine("machine-a", "Machine A"); + create_store(repo, "origin", &keystore, &m, "passphrase abcdefgh").unwrap(); + + let (mut db, _dd) = open_db(); + let id = seed_full_session(&mut db, "machine-a", &repo_dir(repo)); + let (key, salt) = load_store_credentials(repo, "origin", &keystore).unwrap(); + let sessions = scoped_unsynced(&db, repo); + perform_sync(&mut db, repo, "origin", &key, &salt, &m, sessions).unwrap(); + + // Per-repo track is synced, global track is still pending. + assert!( + !db.get_unsynced_sessions() + .unwrap() + .iter() + .any(|s| s.id == id), + "per-repo sync must mark the per-repo track" + ); + assert!( + db.get_unsynced_global_sessions() + .unwrap() + .iter() + .any(|s| s.id == id), + "per-repo sync must NOT mark the global track" + ); + } + + #[test] + fn test_global_sync_does_not_mark_per_repo_track() { + // The reverse direction at the sync level: a global sync marks only the + // global track, leaving the per-repo track pending. + let (_remote_dir, remote_url) = init_bare_remote(); + let (_store_dir, store) = init_global_store(&remote_url); + + let (keystore, _kd) = test_keystore(); + let m = machine("machine-a", "Machine A"); + create_store(&store, GLOBAL_REMOTE, &keystore, &m, "passphrase abcdefgh").unwrap(); + + let (mut db, _dd) = open_db(); + let id = seed_full_session(&mut db, "machine-a", "/some/project"); + let (key, salt) = load_store_credentials(&store, GLOBAL_REMOTE, &keystore).unwrap(); + let sessions = db.get_unsynced_global_sessions().unwrap(); + perform_sync_in_store( + SyncStore::Global, + &mut db, + &store, + GLOBAL_REMOTE, + &key, + &salt, + &m, + sessions, + ) + .unwrap(); + + assert!( + !db.get_unsynced_global_sessions() + .unwrap() + .iter() + .any(|s| s.id == id), + "global sync must mark the global track" + ); + assert!( + db.get_unsynced_sessions() + .unwrap() + .iter() + .any(|s| s.id == id), + "global sync must NOT mark the per-repo track" + ); + } } diff --git a/src/config/mod.rs b/src/config/mod.rs index f0ea36f..04269f5 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -99,6 +99,15 @@ pub struct Config { /// Minimum message count to trigger auto-summary generation. #[serde(default = "default_summary_auto_threshold")] pub summary_auto_threshold: usize, + + /// Remote URL of the user's private global personal store repository. + /// + /// The global store (`lore sync --global`) is a managed git repo at + /// `~/.lore/sync` whose `origin` remote points at this URL. It holds the + /// user's cross-tool, cross-repo aggregate of encrypted sessions for + /// personal multi-machine backup and search. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sync_global_remote: Option, } impl Default for Config { @@ -122,6 +131,7 @@ impl Default for Config { summary_model_openrouter: None, summary_auto: false, summary_auto_threshold: 4, + sync_global_remote: None, } } } @@ -282,6 +292,7 @@ impl Config { /// - `summary_model_openrouter` - OpenRouter model override /// - `summary_auto` - "true" or "false" /// - `summary_auto_threshold` - minimum messages for auto-summary + /// - `sync_global_remote` - remote URL of the global personal store repo /// /// Returns `None` if the key is not recognized. pub fn get(&self, key: &str) -> Option { @@ -304,6 +315,7 @@ impl Config { "summary_model_openrouter" => self.summary_model_openrouter.clone(), "summary_auto" => Some(self.summary_auto.to_string()), "summary_auto_threshold" => Some(self.summary_auto_threshold.to_string()), + "sync_global_remote" => self.sync_global_remote.clone(), _ => None, } } @@ -326,6 +338,7 @@ impl Config { /// - `summary_model_openrouter` - OpenRouter model override /// - `summary_auto` - "true" or "false" /// - `summary_auto_threshold` - positive integer + /// - `sync_global_remote` - remote URL of the global personal store repo /// /// Note: `machine_id` and `encryption_salt` cannot be set manually. /// @@ -418,6 +431,9 @@ impl Config { } self.summary_auto_threshold = threshold; } + "sync_global_remote" => { + self.sync_global_remote = Some(value.to_string()); + } _ => { bail!("Unknown configuration key: '{key}'"); } @@ -457,6 +473,7 @@ impl Config { "summary_model_openrouter", "summary_auto", "summary_auto_threshold", + "sync_global_remote", ] } diff --git a/src/storage/db.rs b/src/storage/db.rs index fc79cae..07dc70b 100644 --- a/src/storage/db.rs +++ b/src/storage/db.rs @@ -16,6 +16,31 @@ use super::models::{ Summary, Tag, }; +/// Which sync-tracking column a merge or import marks on write. +/// +/// A session carries two independent sync tracks: the per-repo store +/// (`synced_at`) and the global personal store (`global_synced_at`). A local +/// content change invalidates BOTH tracks, but each store syncs and marks its +/// own column so a push to one store never marks a session as synced for the +/// other. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SyncTrack { + /// The per-repo store's `synced_at` column. + PerRepo, + /// The global personal store's `global_synced_at` column. + Global, +} + +impl SyncTrack { + /// Returns the sessions column name this track marks. + fn column(self) -> &'static str { + match self { + SyncTrack::PerRepo => "synced_at", + SyncTrack::Global => "global_synced_at", + } + } +} + /// Builds the SQL parameters for a path-boundary directory match. /// /// Returns `(exact, trailing, like_pattern)` for matching a @@ -338,6 +363,9 @@ impl Database { // Migration: Add synced_at column for cloud sync tracking. self.migrate_add_synced_at()?; + // Migration: Add global_synced_at column for the global personal store. + self.migrate_add_global_synced_at()?; + Ok(()) } @@ -402,14 +430,37 @@ impl Database { Ok(()) } + /// Adds the global_synced_at column to the sessions table if it does not exist. + /// + /// This column tracks when each session was last synced to the global + /// personal store, independently of the per-repo `synced_at` column. A NULL + /// value indicates the session has never been synced to the global store. + fn migrate_add_global_synced_at(&self) -> Result<()> { + let columns: Vec = self + .conn + .prepare("PRAGMA table_info(sessions)")? + .query_map([], |row| row.get::<_, String>(1))? + .collect::, _>>()?; + + if !columns.iter().any(|c| c == "global_synced_at") { + self.conn + .execute("ALTER TABLE sessions ADD COLUMN global_synced_at TEXT", [])?; + } + + Ok(()) + } + // ==================== Sessions ==================== /// Inserts a new session or updates an existing one. /// /// If a session with the same ID already exists, updates the `ended_at` - /// and `message_count` fields. Resets `synced_at` to NULL if either the - /// message_count or ended_at has changed (indicating updates that need re-sync). - /// Also updates the sessions_fts index for full-text search on session metadata. + /// and `message_count` fields. Resets both `synced_at` and + /// `global_synced_at` to NULL if either the message_count or ended_at has + /// changed (a local content change invalidates both the per-repo and global + /// sync tracks, so the session is re-exported to each store on the next + /// sync). Also updates the sessions_fts index for full-text search on + /// session metadata. pub fn insert_session(&self, session: &Session) -> Result<()> { let rows_changed = self.conn.execute( r#" @@ -424,6 +475,13 @@ impl Database { WHEN (ended_at IS NOT NULL AND ?5 IS NULL) THEN NULL WHEN ended_at != ?5 THEN NULL ELSE synced_at + END, + global_synced_at = CASE + WHEN message_count != ?10 THEN NULL + WHEN (ended_at IS NULL AND ?5 IS NOT NULL) THEN NULL + WHEN (ended_at IS NOT NULL AND ?5 IS NULL) THEN NULL + WHEN ended_at != ?5 THEN NULL + ELSE global_synced_at END "#, params![ @@ -733,7 +791,7 @@ impl Database { synced_at: Option>, ) -> Result<()> { let tx = self.conn.transaction()?; - Self::write_session_with_messages(&tx, session, messages, synced_at)?; + Self::write_session_with_messages(&tx, session, messages, synced_at, SyncTrack::PerRepo)?; tx.commit()?; Ok(()) } @@ -744,22 +802,31 @@ impl Database { /// [`Self::merge_remote_record`] so both the plain import and the atomic /// remote-merge transaction apply identical session and message SQL. The /// caller owns the transaction boundary; this function never commits. + /// + /// `track` selects which sync-tracking column the supplied timestamp is + /// written into: the per-repo `synced_at` or the global `global_synced_at`. + /// Only that column is touched, so marking a session synced for one store + /// never affects the other store's track. fn write_session_with_messages( conn: &Connection, session: &Session, messages: &[Message], synced_at: Option>, + track: SyncTrack, ) -> Result<()> { - // Insert session - conn.execute( - r#" - INSERT INTO sessions (id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id, synced_at) + // Insert session. The tracking column is chosen by `track`; the SQL is + // otherwise identical for both stores. + let col = track.column(); + let insert_sql = format!( + "INSERT INTO sessions (id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id, {col}) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) ON CONFLICT(id) DO UPDATE SET ended_at = ?5, message_count = ?10, - synced_at = COALESCE(?12, synced_at) - "#, + {col} = COALESCE(?12, {col})" + ); + conn.execute( + &insert_sql, params![ session.id.to_string(), session.tool, @@ -833,18 +900,20 @@ impl Database { Ok(()) } - /// Resets a session's `synced_at` to NULL so a later sync re-exports it. + /// Resets a session's `synced_at` and `global_synced_at` to NULL so a later + /// sync re-exports it to both the per-repo and global stores. /// /// Called after any local edit to a session's child records (links, tags, /// annotations, summary). Auto-linking and auto-summarizing frequently happen - /// after a session has already synced, so without clearing `synced_at` the - /// added child would never be re-encrypted and pushed. The remote-merge path + /// after a session has already synced, so without clearing both columns the + /// added child would never be re-encrypted and pushed. A local content change + /// invalidates both sync tracks, so both are cleared. The remote-merge path /// (the `upsert_*` methods and [`Self::merge_remote_record`]) deliberately /// does NOT call this: marking a just-pulled session as needing a push would /// bounce the same record back to the remote in a sync loop. fn mark_session_unsynced(&self, session_id: &Uuid) -> Result<()> { self.conn.execute( - "UPDATE sessions SET synced_at = NULL WHERE id = ?1", + "UPDATE sessions SET synced_at = NULL, global_synced_at = NULL WHERE id = ?1", params![session_id.to_string()], )?; Ok(()) @@ -876,6 +945,10 @@ impl Database { /// /// Returns `true` when the session row and messages were written (the /// newer-wins branch ran), which callers use for the pulled count. + /// + /// This marks imported sessions on the per-repo `synced_at` track. The + /// global store uses [`Self::merge_remote_record_global`], which shares the + /// identical merge logic but marks the `global_synced_at` track. #[allow(clippy::too_many_arguments)] pub fn merge_remote_record( &mut self, @@ -886,6 +959,63 @@ impl Database { annotations: &[Annotation], summary: Option<&Summary>, synced_at: DateTime, + ) -> Result { + self.merge_remote_record_tracked( + session, + messages, + links, + tags, + annotations, + summary, + synced_at, + SyncTrack::PerRepo, + ) + } + + /// Global-store counterpart of [`Self::merge_remote_record`]. + /// + /// Applies the identical newer-wins and additive-child merge, but marks an + /// imported session on the global `global_synced_at` track so a global pull + /// does not affect the per-repo `synced_at` track (and vice versa). + #[allow(clippy::too_many_arguments)] + pub fn merge_remote_record_global( + &mut self, + session: &Session, + messages: &[Message], + links: &[SessionLink], + tags: &[Tag], + annotations: &[Annotation], + summary: Option<&Summary>, + synced_at: DateTime, + ) -> Result { + self.merge_remote_record_tracked( + session, + messages, + links, + tags, + annotations, + summary, + synced_at, + SyncTrack::Global, + ) + } + + /// Shared implementation for the per-repo and global merge paths. + /// + /// `track` selects which sync-tracking column an imported session row is + /// marked on. Everything else (newer-wins session/message import plus + /// additive, idempotent child merges) is identical across both stores. + #[allow(clippy::too_many_arguments)] + fn merge_remote_record_tracked( + &mut self, + session: &Session, + messages: &[Message], + links: &[SessionLink], + tags: &[Tag], + annotations: &[Annotation], + summary: Option<&Summary>, + synced_at: DateTime, + track: SyncTrack, ) -> Result { let tx = self.conn.transaction()?; @@ -907,7 +1037,7 @@ impl Database { }; if import_session { - Self::write_session_with_messages(&tx, session, messages, Some(synced_at))?; + Self::write_session_with_messages(&tx, session, messages, Some(synced_at), track)?; } // Child records are additive and idempotent by id: always merge them so a @@ -1715,6 +1845,101 @@ impl Database { .context("Failed to get session ids for repo") } + /// Returns the ids of ALL sessions in the database, regardless of sync state. + /// + /// The global personal store aggregates every session across all repos and + /// tools, so its carry-forward scope is the entire session set (the global + /// analogue of [`Database::get_session_ids_for_repo`], which scopes to one + /// repo). Used by the global sync to decide which already-stored, local-only + /// session artifacts to carry forward. + pub fn get_all_session_ids(&self) -> Result> { + let mut stmt = self.conn.prepare("SELECT id FROM sessions")?; + let rows = stmt.query_map([], |row| { + let id: String = row.get(0)?; + parse_uuid(&id) + })?; + + rows.collect::>>() + .context("Failed to get all session ids") + } + + /// Returns sessions that have not been synced to the global personal store. + /// + /// Unsynced-global sessions are those where `global_synced_at` is NULL. Unlike + /// [`Database::get_unsynced_sessions_for_repo`], this is not scoped to any + /// repository: the global store holds every session regardless of working + /// directory. Returns sessions ordered by start time (oldest first). + pub fn get_unsynced_global_sessions(&self) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id + FROM sessions + WHERE global_synced_at IS NULL + ORDER BY started_at ASC" + )?; + + let rows = stmt.query_map([], Self::row_to_session)?; + + rows.collect::, _>>() + .context("Failed to get unsynced global sessions") + } + + /// Returns the count of sessions not yet synced to the global personal store. + pub fn unsynced_global_count(&self) -> Result { + let count: i32 = self.conn.query_row( + "SELECT COUNT(*) FROM sessions WHERE global_synced_at IS NULL", + [], + |row| row.get(0), + )?; + Ok(count) + } + + /// Marks sessions as synced to the global personal store. + /// + /// Updates the `global_synced_at` column for all specified session IDs, + /// leaving the per-repo `synced_at` track untouched. + pub fn mark_global_synced( + &self, + session_ids: &[Uuid], + synced_at: DateTime, + ) -> Result { + if session_ids.is_empty() { + return Ok(0); + } + + let synced_at_str = synced_at.to_rfc3339(); + let mut total_updated = 0; + + for id in session_ids { + let updated = self.conn.execute( + "UPDATE sessions SET global_synced_at = ?1 WHERE id = ?2", + params![synced_at_str, id.to_string()], + )?; + total_updated += updated; + } + + Ok(total_updated) + } + + /// Returns the most recent global-store sync timestamp across all sessions. + /// + /// Returns None if no session has been synced to the global store yet. + pub fn last_global_sync_time(&self) -> Result>> { + let result: Option = self + .conn + .query_row( + "SELECT MAX(global_synced_at) FROM sessions WHERE global_synced_at IS NOT NULL", + [], + |row| row.get(0), + ) + .optional()? + .flatten(); + + match result { + Some(s) => Ok(Some(parse_datetime(&s)?)), + None => Ok(None), + } + } + /// Returns the count of sessions that have not been synced. pub fn unsynced_session_count(&self) -> Result { let count: i32 = self.conn.query_row( @@ -7121,4 +7346,196 @@ mod tests { "a merged session must be marked synced" ); } + + // ==================== Global Store Track Tests ==================== + + #[test] + fn test_new_session_is_unsynced_on_both_tracks() { + // A freshly imported session (synced_at = None) is pending on both the + // per-repo and global tracks. + let (mut db, _dir) = create_test_db(); + let session = create_test_session("claude-code", "/project", Utc::now(), None); + db.import_session_with_messages(&session, &[], None) + .unwrap(); + + assert_eq!(db.get_unsynced_sessions().unwrap().len(), 1); + let global = db.get_unsynced_global_sessions().unwrap(); + assert_eq!(global.len(), 1); + assert_eq!(global[0].id, session.id); + assert_eq!(db.unsynced_global_count().unwrap(), 1); + } + + #[test] + fn test_per_repo_and_global_tracks_are_independent() { + // Marking one track must not affect the other: a per-repo sync leaves the + // global track pending, and a global sync leaves the per-repo track + // pending. + let (mut db, _dir) = create_test_db(); + let session = create_test_session("claude-code", "/project", Utc::now(), None); + db.import_session_with_messages(&session, &[], None) + .unwrap(); + + // Mark only the per-repo track. + db.mark_sessions_synced(&[session.id], Utc::now()).unwrap(); + assert!( + db.get_unsynced_sessions().unwrap().is_empty(), + "per-repo track must be marked synced" + ); + assert_eq!( + db.get_unsynced_global_sessions().unwrap().len(), + 1, + "global track must stay pending after a per-repo sync" + ); + + // Now mark only the global track. + db.mark_global_synced(&[session.id], Utc::now()).unwrap(); + assert!( + db.get_unsynced_global_sessions().unwrap().is_empty(), + "global track must be marked synced" + ); + } + + #[test] + fn test_global_only_sync_leaves_per_repo_pending() { + // The reverse independence direction: marking the global track first must + // not touch the per-repo track. + let (mut db, _dir) = create_test_db(); + let session = create_test_session("claude-code", "/project", Utc::now(), None); + db.import_session_with_messages(&session, &[], None) + .unwrap(); + + db.mark_global_synced(&[session.id], Utc::now()).unwrap(); + assert!( + db.get_unsynced_global_sessions().unwrap().is_empty(), + "global track must be marked synced" + ); + assert_eq!( + db.get_unsynced_sessions().unwrap().len(), + 1, + "per-repo track must stay pending after a global sync" + ); + } + + #[test] + fn test_insert_link_clears_both_sync_tracks() { + // A local child change (adding a link) must re-open the session on BOTH + // the per-repo and global tracks. + let (mut db, _dir) = create_test_db(); + let mut session = create_test_session("claude-code", "/project", Utc::now(), None); + session.message_count = 0; + db.import_session_with_messages(&session, &[], Some(Utc::now())) + .unwrap(); + db.mark_global_synced(&[session.id], Utc::now()).unwrap(); + assert!(db.get_unsynced_sessions().unwrap().is_empty()); + assert!(db.get_unsynced_global_sessions().unwrap().is_empty()); + + db.insert_link(&create_test_link( + session.id, + Some("abcdef"), + LinkType::Commit, + )) + .unwrap(); + + assert_eq!( + db.get_unsynced_sessions().unwrap().len(), + 1, + "adding a link must re-open the per-repo track" + ); + assert_eq!( + db.get_unsynced_global_sessions().unwrap().len(), + 1, + "adding a link must re-open the global track" + ); + } + + #[test] + fn test_insert_session_content_change_clears_both_tracks() { + // A message-count change through insert_session must invalidate both the + // per-repo and global sync tracks. + let (mut db, _dir) = create_test_db(); + let mut session = create_test_session("claude-code", "/project", Utc::now(), None); + session.message_count = 1; + db.import_session_with_messages(&session, &[], Some(Utc::now())) + .unwrap(); + db.mark_global_synced(&[session.id], Utc::now()).unwrap(); + assert!(db.get_unsynced_sessions().unwrap().is_empty()); + assert!(db.get_unsynced_global_sessions().unwrap().is_empty()); + + // Re-insert with a higher message count (a content change). + session.message_count = 2; + db.insert_session(&session).unwrap(); + + assert_eq!( + db.get_unsynced_sessions().unwrap().len(), + 1, + "a content change must re-open the per-repo track" + ); + assert_eq!( + db.get_unsynced_global_sessions().unwrap().len(), + 1, + "a content change must re-open the global track" + ); + } + + #[test] + fn test_merge_remote_record_global_marks_only_global_track() { + // The global merge path must mark global_synced_at and leave synced_at + // NULL, so a pulled global session is still pending for the per-repo store. + let (mut db, _dir) = create_test_db(); + let mut session = create_test_session("claude-code", "/project", Utc::now(), None); + session.message_count = 1; + + let imported = db + .merge_remote_record_global(&session, &[], &[], &[], &[], None, Utc::now()) + .unwrap(); + assert!(imported, "a new session must count as pulled"); + + assert!( + db.get_unsynced_global_sessions().unwrap().is_empty(), + "global merge must mark the global track synced" + ); + assert_eq!( + db.get_unsynced_sessions().unwrap().len(), + 1, + "global merge must leave the per-repo track pending" + ); + } + + #[test] + fn test_get_all_session_ids_returns_every_session_regardless_of_sync() { + let (mut db, _dir) = create_test_db(); + let a = create_test_session("claude-code", "/a", Utc::now(), None); + let b = create_test_session("claude-code", "/b", Utc::now(), None); + db.import_session_with_messages(&a, &[], None).unwrap(); + db.import_session_with_messages(&b, &[], Some(Utc::now())) + .unwrap(); + db.mark_global_synced(&[b.id], Utc::now()).unwrap(); + + let ids = db.get_all_session_ids().unwrap(); + assert!(ids.contains(&a.id)); + assert!(ids.contains(&b.id)); + assert_eq!(ids.len(), 2); + } + + #[test] + fn test_last_global_sync_time_tracks_global_column() { + let (mut db, _dir) = create_test_db(); + let session = create_test_session("claude-code", "/project", Utc::now(), None); + db.import_session_with_messages(&session, &[], None) + .unwrap(); + + assert!( + db.last_global_sync_time().unwrap().is_none(), + "no global sync yet" + ); + + let when = Utc::now(); + db.mark_global_synced(&[session.id], when).unwrap(); + let last = db + .last_global_sync_time() + .unwrap() + .expect("global sync time recorded"); + // Compare at second granularity to avoid RFC3339 sub-second rounding. + assert_eq!(last.timestamp(), when.timestamp()); + } }