Skip to main content

nexus_core/
sync.rs

1//! Phase 3 merge engine: changeset build/apply, per-table sync rules,
2//! cursor + ack bookkeeping, and the file blob channel.
3//!
4//! Every table that syncs declares itself in the registry below — its
5//! cursor rule (how exports resume) and its apply rule (how imports land).
6//! The rules are deliberately small:
7//!
8//! - **LWW** (`updated_at` cursor): the incoming row wins iff
9//!   `(updated_at, pk…) > (local updated_at, pk…)`. RFC3339 timestamps
10//!   compare lexically, so both devices compute the same winner with no
11//!   origin columns. A tie (same row written in the same nanosecond on two
12//!   clocks — the clock-skew residual) keeps the local row on both sides;
13//!   it is stable, never flip-flops, and is accepted per the Phase 1
14//!   decision.
15//! - **Append** (`(created_at, id)` tuple or AUTOINCREMENT cursor):
16//!   INSERT OR IGNORE by the row's sync identity (uuid id, or `sync_id`
17//!   for the AUTOINCREMENT tables). Idempotent union — the exactly-once
18//!   backstop when a changeset is re-imported after a crash.
19//! - **Tombstones** (AUTOINCREMENT cursor): applied destructively — a
20//!   session tombstone cascades its messages and sources, a space
21//!   tombstone removes the space row and its directory, a file tombstone
22//!   removes the row and the local blob.
23//! - **`swarm_personas`** has no cursor of its own: it is versioned by the
24//!   owning session (Phase 1 decision). Persona rows travel attached to
25//!   their session row and are applied only when that session row wins
26//!   LWW, as a wholesale roster replace.
27//!
28//! Cursor lifecycle (the ack design): an export does not advance
29//! `push_cursor` — the receiver imports, then replies with an ack
30//! carrying its new pull cursors, and only then does the sender advance
31//! `push_cursor`. Idempotent apply is the backstop for crashes mid-
32//! exchange.
33
34// Casts here are on bounded values: byte sizes, row counts, ordinals — the
35// same justification as db.rs.
36#![allow(
37    clippy::cast_possible_truncation,
38    clippy::cast_possible_wrap,
39    clippy::cast_precision_loss,
40    clippy::cast_sign_loss
41)]
42
43use std::collections::{HashMap, HashSet};
44use std::path::{Path, PathBuf};
45
46use anyhow::{Context as _, Result, anyhow, bail};
47use rusqlite::{Connection, OptionalExtension as _, params_from_iter};
48use serde::{Deserialize, Serialize};
49use sha2::{Digest as _, Sha256};
50
51use crate::db::DEFAULT_SPACE;
52use crate::db::Db;
53use crate::space::Space;
54
55// ── changeset types ──
56
57/// One device's export, possibly carrying an ack for the device it was
58/// sent to. Serde JSON over the wire (or inside a zip bundle with blobs).
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
60pub struct Changeset {
61    /// The sender (from `device_meta`, created on first sync).
62    pub device_id: String,
63    /// "laptop" — for the receiver's log/status.
64    pub device_name: String,
65    /// "I imported up to here" — the sender's reply acks the *receiver*'s
66    /// data, so entries are addressed by the receiver's device id.
67    pub ack: Option<Vec<PeerCursor>>,
68    /// Full rows as JSON objects keyed by column name.
69    pub rows: Vec<RowChange>,
70    /// Deletes that actually happened on the sender.
71    pub tombstones: Vec<Tombstone>,
72    /// File metadata — the blobs themselves follow via the transport's
73    /// blob channel (`blobs/<space_id>/<name>` under a sync dir, or inside
74    /// a zip bundle).
75    pub files: Vec<FileChange>,
76    pub generated_at: String,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
80pub struct RowChange {
81    pub table: String,
82    pub row: serde_json::Value,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
86pub struct Tombstone {
87    /// The sender's `sync_tombstones` AUTOINCREMENT id — cursor
88    /// bookkeeping only, never applied.
89    pub origin_id: i64,
90    pub table_name: String,
91    pub row_id: String,
92    pub deleted_at: String,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
96pub struct FileChange {
97    pub space_id: String,
98    pub name: String,
99    pub hash: String,
100    pub size: i64,
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
104pub struct PeerCursor {
105    /// The device whose data was imported up to `cursor` — the ack's
106    /// target, matched against the receiver's own device id.
107    pub peer_id: String,
108    pub table_name: String,
109    pub cursor: String,
110}
111
112/// What applying a changeset did — the receiver's log line, plus the
113/// blobs the transport still has to fetch.
114#[derive(Debug, Clone, Default, PartialEq)]
115pub struct ApplySummary {
116    pub rows_applied: usize,
117    pub rows_skipped: usize,
118    pub tombstones_applied: usize,
119    pub acks_applied: usize,
120    pub files_kept: usize,
121    pub files_pulled: usize,
122    pub files_missing: Vec<FileChange>,
123    pub warnings: Vec<String>,
124}
125
126// ── the registry ──
127
128/// How a table's rows are ordered and resumed across changesets.
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum Cursor {
131    /// Mutable rows: the cursor is the `updated_at` column (nullable — a
132    /// NULL version sorts as `''`, i.e. the epoch; rows that never got a
133    /// version are legacy and simply never export). Incoming row wins iff
134    /// `(updated_at, pk…) > (local updated_at, pk…)`.
135    UpdatedAt,
136    /// Append-only rows: cursor is a `(col1, col2)` row-value tuple
137    /// (e.g. messages `(created_at, id)`), encoded as `"a|b"`.
138    Tuple(&'static [&'static str]),
139    /// Append-only rows whose cursor is their device-local AUTOINCREMENT
140    /// id (citations, `sync_tombstones`). The id rides along in the row JSON
141    /// for cursor bookkeeping but is never applied.
142    AutoId,
143    /// No cursor of its own; rows travel attached to a parent row
144    /// (`swarm_personas` → sessions).
145    None,
146}
147
148/// One syncable table's cursor + apply rules.
149pub struct TableSpec {
150    pub name: &'static str,
151    /// Columns exported in the row JSON (order = the SELECT list).
152    pub columns: &'static [&'static str],
153    /// Columns applied on insert/update — a subset of `columns`: the
154    /// AUTOINCREMENT ids are excluded where they'd collide across devices
155    /// (citations) and kept where the id is the sync identity.
156    pub apply_columns: &'static [&'static str],
157    /// The sync identity: the INSERT OR IGNORE target and the LWW tiebreak.
158    pub pk: &'static [&'static str],
159    pub cursor: Cursor,
160}
161
162/// The engine's registry, in apply order (sessions before their children,
163/// spaces/files before messages so foreign rows land after their parents).
164pub const TABLES: &[TableSpec] = &[
165    TableSpec {
166        name: "sessions",
167        columns: &[
168            "id",
169            "title",
170            "model",
171            "slug",
172            "space_id",
173            "compact_summary",
174            "compact_through",
175            "web_mode",
176            "swarm_mode",
177            "kind",
178            "research_parent_id",
179            "created_at",
180            "updated_at",
181        ],
182        apply_columns: &[
183            "id",
184            "title",
185            "model",
186            "slug",
187            "space_id",
188            "compact_summary",
189            "compact_through",
190            "web_mode",
191            "swarm_mode",
192            "kind",
193            "research_parent_id",
194            "created_at",
195            "updated_at",
196        ],
197        pk: &["id"],
198        cursor: Cursor::UpdatedAt,
199    },
200    TableSpec {
201        name: "swarm_personas",
202        columns: &["session_id", "ord", "name", "model", "persona"],
203        apply_columns: &["session_id", "ord", "name", "model", "persona"],
204        pk: &["session_id", "ord"],
205        cursor: Cursor::None,
206    },
207    TableSpec {
208        name: "model_prefs",
209        columns: &["id", "favorite", "last_used", "reasoning", "updated_at"],
210        apply_columns: &["id", "favorite", "last_used", "reasoning", "updated_at"],
211        pk: &["id"],
212        cursor: Cursor::UpdatedAt,
213    },
214    TableSpec {
215        name: "spaces",
216        columns: &["id", "name", "created_at", "updated_at"],
217        apply_columns: &["id", "name", "created_at", "updated_at"],
218        pk: &["id"],
219        cursor: Cursor::UpdatedAt,
220    },
221    TableSpec {
222        name: "files",
223        columns: &[
224            "id",
225            "space_id",
226            "name",
227            "hash",
228            "size",
229            "created_at",
230            "updated_at",
231        ],
232        apply_columns: &[
233            "id",
234            "space_id",
235            "name",
236            "hash",
237            "size",
238            "created_at",
239            "updated_at",
240        ],
241        pk: &["id"],
242        cursor: Cursor::UpdatedAt,
243    },
244    TableSpec {
245        name: "watches",
246        columns: &[
247            "id",
248            "space_id",
249            "topic",
250            "interval_hours",
251            "session_id",
252            "last_run_at",
253            "updated_at",
254        ],
255        apply_columns: &[
256            "id",
257            "space_id",
258            "topic",
259            "interval_hours",
260            "session_id",
261            "last_run_at",
262            "updated_at",
263        ],
264        pk: &["id"],
265        cursor: Cursor::UpdatedAt,
266    },
267    TableSpec {
268        name: "app_settings",
269        columns: &["key", "value", "scope", "updated_at"],
270        apply_columns: &["key", "value", "scope", "updated_at"],
271        pk: &["key"],
272        cursor: Cursor::UpdatedAt,
273    },
274    TableSpec {
275        name: "session_sources",
276        columns: &["session_id", "url_norm", "flag", "updated_at"],
277        apply_columns: &["session_id", "url_norm", "flag", "updated_at"],
278        pk: &["session_id", "url_norm"],
279        cursor: Cursor::UpdatedAt,
280    },
281    TableSpec {
282        name: "messages",
283        columns: &[
284            "id",
285            "session_id",
286            "role",
287            "content",
288            "model",
289            "reasoning",
290            "tokens",
291            "secs",
292            "cost",
293            "phrase",
294            "persona",
295            "created_at",
296        ],
297        apply_columns: &[
298            "id",
299            "session_id",
300            "role",
301            "content",
302            "model",
303            "reasoning",
304            "tokens",
305            "secs",
306            "cost",
307            "phrase",
308            "persona",
309            "created_at",
310        ],
311        pk: &["id"],
312        cursor: Cursor::Tuple(&["created_at", "id"]),
313    },
314    TableSpec {
315        name: "usage_log",
316        columns: &[
317            "sync_id",
318            "created_at",
319            "session_id",
320            "space_id",
321            "backend",
322            "model",
323            "prompt_tokens",
324            "completion_tokens",
325            "cache_read_tokens",
326            "cache_creation_tokens",
327            "cost",
328            "cost_is_provider",
329            "updated_at",
330        ],
331        apply_columns: &[
332            "sync_id",
333            "created_at",
334            "session_id",
335            "space_id",
336            "backend",
337            "model",
338            "prompt_tokens",
339            "completion_tokens",
340            "cache_read_tokens",
341            "cache_creation_tokens",
342            "cost",
343            "cost_is_provider",
344            "updated_at",
345        ],
346        pk: &["sync_id"],
347        cursor: Cursor::Tuple(&["created_at", "sync_id"]),
348    },
349    TableSpec {
350        name: "citations",
351        // `id` rides along for the cursor but is never applied — it is a
352        // device-local AUTOINCREMENT id.
353        columns: &["id", "sync_id", "space_id", "report_file", "url", "title"],
354        apply_columns: &["sync_id", "space_id", "report_file", "url", "title"],
355        pk: &["sync_id"],
356        cursor: Cursor::AutoId,
357    },
358];
359
360const SYNC_TOMBSTONES: &str = "sync_tombstones";
361
362fn spec_for(table: &str) -> Option<&'static TableSpec> {
363    TABLES.iter().find(|s| s.name == table)
364}
365
366fn known_table(table: &str) -> bool {
367    table == SYNC_TOMBSTONES || spec_for(table).is_some()
368}
369
370/// Cursor comparisons are table-aware: the AUTOINCREMENT tables compare
371/// numerically (string comparison would order "9" > "10"), everything else
372/// lexically (RFC3339 sorts correctly, and the tuple cursors are
373/// `timestamp|id` whose leading timestamp dominates).
374fn position_gt(table: &str, a: &str, b: &str) -> bool {
375    if table == "citations" || table == SYNC_TOMBSTONES {
376        let n = |s: &str| s.parse::<i64>().unwrap_or(0);
377        n(a) > n(b)
378    } else {
379        a > b
380    }
381}
382
383/// A row's cursor position — the pull cursor advances to the max position
384/// among received rows, win or lose (a losing row was still imported).
385fn row_position(spec: &TableSpec, row: &serde_json::Value) -> String {
386    match spec.cursor {
387        Cursor::UpdatedAt => row
388            .get("updated_at")
389            .and_then(serde_json::Value::as_str)
390            .unwrap_or_default()
391            .to_string(),
392        Cursor::Tuple(cols) => cols
393            .iter()
394            .map(|c| {
395                row.get(*c)
396                    .and_then(serde_json::Value::as_str)
397                    .unwrap_or_default()
398            })
399            .collect::<Vec<_>>()
400            .join("|"),
401        Cursor::AutoId => row
402            .get("id")
403            .and_then(serde_json::Value::as_i64)
404            .map_or_else(|| "0".to_string(), |i| i.to_string()),
405        Cursor::None => String::new(),
406    }
407}
408
409/// A value that must be a single path component — names flow into
410/// `spaces/<name>/files/<name>` and `blobs/<space_id>/<name>`, so they
411/// must not smuggle separators or `..`.
412fn valid_component(name: &str) -> bool {
413    !name.is_empty()
414        && name != "."
415        && name != ".."
416        && !name.contains('/')
417        && !name.contains('\\')
418        && !name.contains('\0')
419}
420
421/// The human-readable device name riding in every changeset.
422pub fn device_name() -> String {
423    if let Ok(n) = std::env::var("NEXUS_DEVICE_NAME")
424        && !n.trim().is_empty()
425    {
426        return n;
427    }
428    std::fs::read_to_string("/etc/hostname")
429        .map_or_else(|_| "nexus-device".to_string(), |s| s.trim().to_string())
430}
431
432// ── export ──
433
434/// Build this device's export for `peer_id` — every row past the peer's
435/// acked cursor per table (no cursor → full export, the first-run
436/// bootstrap), the un-acked tombstones, and the file manifest. Does not
437/// advance any cursor: that happens only when the peer acks.
438pub fn build_changeset(db: &Db, peer_id: Option<&str>, name: &str) -> Result<Changeset> {
439    let device_id = db.device_id()?;
440    let mut cs = Changeset {
441        device_id,
442        device_name: name.to_string(),
443        ack: None,
444        rows: Vec::new(),
445        tombstones: Vec::new(),
446        files: Vec::new(),
447        generated_at: chrono::Utc::now().to_rfc3339(),
448    };
449    let states = db.load_sync_state()?;
450    let push_cursor = |table: &str| {
451        peer_id.and_then(|p| {
452            states
453                .iter()
454                .find(|s| s.peer_id == p && s.table_name == table)
455                .and_then(|s| s.push_cursor.clone())
456        })
457    };
458    for spec in TABLES {
459        if spec.cursor == Cursor::None {
460            continue;
461        }
462        let rows = select_rows(db, spec, push_cursor(spec.name).as_deref())?;
463        for row in rows {
464            if spec.name == "sessions" {
465                for persona in select_personas(db, &row)? {
466                    cs.rows.push(RowChange {
467                        table: "swarm_personas".to_string(),
468                        row: persona,
469                    });
470                }
471            }
472            if spec.name == "files" {
473                cs.files.push(FileChange {
474                    space_id: row
475                        .get("space_id")
476                        .and_then(serde_json::Value::as_str)
477                        .unwrap_or_default()
478                        .to_string(),
479                    name: row
480                        .get("name")
481                        .and_then(serde_json::Value::as_str)
482                        .unwrap_or_default()
483                        .to_string(),
484                    hash: row
485                        .get("hash")
486                        .and_then(serde_json::Value::as_str)
487                        .unwrap_or_default()
488                        .to_string(),
489                    size: row
490                        .get("size")
491                        .and_then(serde_json::Value::as_i64)
492                        .unwrap_or(0),
493                });
494            }
495            cs.rows.push(RowChange {
496                table: spec.name.to_string(),
497                row,
498            });
499        }
500    }
501    let cursor = push_cursor(SYNC_TOMBSTONES);
502    let c = cursor.as_deref().unwrap_or("0");
503    let mut stmt = db.conn().prepare(&format!(
504        "SELECT id, table_name, row_id, deleted_at FROM {SYNC_TOMBSTONES} WHERE id > ?1 ORDER BY id"
505    ))?;
506    let rows = stmt.query_map([c], |r| {
507        Ok(Tombstone {
508            origin_id: r.get(0)?,
509            table_name: r.get(1)?,
510            row_id: r.get(2)?,
511            deleted_at: r.get(3)?,
512        })
513    })?;
514    cs.tombstones = rows.collect::<rusqlite::Result<Vec<_>>>()?;
515    Ok(cs)
516}
517
518/// The ack for `peer_id`: this device's pull cursors for that peer's
519/// data — "I imported your rows up to here". The transports embed it in
520/// their next changeset so the peer can advance its push cursors; without
521/// it the peer would re-export everything forever.
522pub fn build_ack(db: &Db, peer_id: &str) -> Result<Vec<PeerCursor>> {
523    Ok(db
524        .load_sync_state()?
525        .iter()
526        .filter(|s| s.peer_id == peer_id)
527        .filter_map(|s| {
528            s.pull_cursor.clone().map(|cursor| PeerCursor {
529                peer_id: peer_id.to_string(),
530                table_name: s.table_name.clone(),
531                cursor,
532            })
533        })
534        .collect())
535}
536
537/// The rows of one table past a cursor, in cursor order. Rows that never
538/// got an `updated_at` (legacy, pre-version rows) stay home — they could
539/// never win LWW anyway.
540fn select_rows(db: &Db, spec: &TableSpec, cursor: Option<&str>) -> Result<Vec<serde_json::Value>> {
541    let cols = spec.columns.join(", ");
542    let (sql, params): (String, Vec<rusqlite::types::Value>) = match spec.cursor {
543        Cursor::UpdatedAt => {
544            // Device-local settings are structurally invisible to sync.
545            let scope_filter = if spec.name == "app_settings" {
546                " AND scope = 'sync'"
547            } else {
548                ""
549            };
550            (
551                format!(
552                    "SELECT {cols} FROM {} WHERE COALESCE(updated_at, '') > ?1{scope_filter} \
553                     ORDER BY COALESCE(updated_at, ''), {}",
554                    spec.name,
555                    spec.pk.join(", ")
556                ),
557                vec![rusqlite::types::Value::Text(
558                    cursor.unwrap_or("").to_string(),
559                )],
560            )
561        }
562        Cursor::Tuple(tuple_cols) => {
563            let (a, b) = (tuple_cols[0], tuple_cols[1]);
564            let (ca, cb) = cursor.and_then(|c| c.split_once('|')).unwrap_or(("", ""));
565            (
566                format!(
567                    "SELECT {cols} FROM {} WHERE ({a}, {b}) > (?1, ?2) ORDER BY {a}, {b}",
568                    spec.name
569                ),
570                vec![
571                    rusqlite::types::Value::Text(ca.to_string()),
572                    rusqlite::types::Value::Text(cb.to_string()),
573                ],
574            )
575        }
576        Cursor::AutoId => (
577            format!("SELECT {cols} FROM {} WHERE id > ?1 ORDER BY id", spec.name),
578            vec![rusqlite::types::Value::Text(
579                cursor.unwrap_or("0").to_string(),
580            )],
581        ),
582        Cursor::None => bail!("{} has no cursor", spec.name),
583    };
584    let mut stmt = db.conn().prepare(&sql)?;
585    let rows = stmt.query_map(params_from_iter(params.iter()), |r| {
586        row_to_json(r, spec.columns)
587    })?;
588    Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
589}
590
591/// A session row's persona roster — attached to the session row in the
592/// changeset (personas have no cursor of their own).
593fn select_personas(db: &Db, session: &serde_json::Value) -> Result<Vec<serde_json::Value>> {
594    let Some(session_id) = session.get("id").and_then(serde_json::Value::as_str) else {
595        return Ok(Vec::new());
596    };
597    let mut stmt = db.conn().prepare(
598        "SELECT session_id, ord, name, model, persona FROM swarm_personas WHERE session_id = ?1",
599    )?;
600    let rows = stmt.query_map([session_id], |r| {
601        row_to_json(r, &["session_id", "ord", "name", "model", "persona"])
602    })?;
603    Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
604}
605
606fn row_to_json(row: &rusqlite::Row, columns: &[&str]) -> rusqlite::Result<serde_json::Value> {
607    let mut obj = serde_json::Map::new();
608    for (i, column) in columns.iter().enumerate() {
609        let v: rusqlite::types::Value = row.get(i)?;
610        obj.insert((*column).to_string(), sqlite_to_json(v));
611    }
612    Ok(serde_json::Value::Object(obj))
613}
614
615fn sqlite_to_json(v: rusqlite::types::Value) -> serde_json::Value {
616    match v {
617        rusqlite::types::Value::Null | rusqlite::types::Value::Blob(_) => serde_json::Value::Null,
618        rusqlite::types::Value::Integer(i) => serde_json::Value::from(i),
619        rusqlite::types::Value::Real(f) => serde_json::Number::from_f64(f)
620            .map_or(serde_json::Value::Null, serde_json::Value::Number),
621        rusqlite::types::Value::Text(s) => serde_json::Value::from(s),
622    }
623}
624
625fn json_to_value(v: &serde_json::Value) -> rusqlite::types::Value {
626    match v {
627        serde_json::Value::Null | serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
628            rusqlite::types::Value::Null
629        }
630        serde_json::Value::Bool(b) => rusqlite::types::Value::Integer(i64::from(*b)),
631        serde_json::Value::Number(n) => n.as_i64().map_or_else(
632            || rusqlite::types::Value::Real(n.as_f64().unwrap_or(0.0)),
633            rusqlite::types::Value::Integer,
634        ),
635        serde_json::Value::String(s) => rusqlite::types::Value::Text(s.clone()),
636    }
637}
638
639/// The first 8 chars of a uuid — enough to disambiguate in log lines, and
640/// the suffix the space-name collision resolution renames with.
641fn short_id(id: &str) -> String {
642    id.chars().take(8).collect()
643}
644
645// ── apply ──
646
647enum LwwOutcome {
648    Applied,
649    Skipped,
650    Warned(String),
651}
652
653/// Whether an incoming row wins LWW against a local opponent.
654/// `local_updated`/`local_tie` are `''`/empty when no local row exists.
655fn lww_wins(
656    spec: &TableSpec,
657    row: &serde_json::Value,
658    local_updated: &str,
659    local_tie: &str,
660) -> bool {
661    let incoming_updated = row
662        .get("updated_at")
663        .and_then(serde_json::Value::as_str)
664        .unwrap_or_default();
665    let incoming_tie = spec
666        .pk
667        .iter()
668        .map(|c| {
669            row.get(*c)
670                .and_then(serde_json::Value::as_str)
671                .unwrap_or_default()
672        })
673        .collect::<Vec<_>>()
674        .join("\u{1f}");
675    (incoming_updated, incoming_tie.as_str()) > (local_updated, local_tie)
676}
677
678/// `(updated_at, id) > (local_updated_at, local_id)` — the spaces/files
679/// LWW comparison, id-tiebroken so equal timestamps stay deterministic.
680fn lww_wins_against(id: &str, updated: &str, local_id: &str, local_updated: &str) -> bool {
681    (updated, id) > (local_updated, local_id)
682}
683
684/// Apply one changeset: rows (per the registry), tombstones, file blobs,
685/// and the embedded ack. Returns the summary plus the reply cursors — the
686/// receiver's new pull cursors per table, to be acked back to the sender.
687/// `blob_source`, when given, is a directory whose `blobs/<space_id>/<name>`
688/// holds the sender's payloads; missing blobs are reported instead.
689// Long by design: one step per sync rule, in registry order.
690#[allow(clippy::too_many_lines)]
691pub fn apply_changeset(
692    db: &Db,
693    space: &Space,
694    cs: &Changeset,
695    blob_source: Option<&Path>,
696) -> Result<(ApplySummary, Vec<PeerCursor>)> {
697    let my_id = db.device_id()?;
698    let mut summary = ApplySummary::default();
699    let mut by_table: HashMap<&str, Vec<&serde_json::Value>> = HashMap::new();
700    for rc in &cs.rows {
701        by_table.entry(rc.table.as_str()).or_default().push(&rc.row);
702    }
703    for table in by_table.keys() {
704        if !known_table(table) {
705            summary
706                .warnings
707                .push(format!("changeset has rows for unknown table {table:?}"));
708        }
709    }
710
711    // Rows, in registry order. `max_pos` tracks the highest position
712    // received per table — the reply cursor. Positions advance even for
713    // rows that lost LWW or failed: a single bad row must not block the
714    // table forever (the warning names it).
715    let mut max_pos: HashMap<&'static str, String> = HashMap::new();
716    let mut won_sessions: HashSet<String> = HashSet::new();
717    let mut won_files: HashSet<(String, String)> = HashSet::new();
718    for spec in TABLES {
719        if spec.cursor == Cursor::None {
720            continue; // swarm_personas travel with their session row
721        }
722        let Some(rows) = by_table.get(spec.name) else {
723            continue;
724        };
725        for row in rows {
726            let pos = row_position(spec, row);
727            max_pos
728                .entry(spec.name)
729                .and_modify(|p| {
730                    if position_gt(spec.name, &pos, p) {
731                        p.clone_from(&pos);
732                    }
733                })
734                .or_insert(pos);
735            let outcome = match apply_row(db, space, spec, row) {
736                Ok(o) => o,
737                Err(e) => LwwOutcome::Warned(format!("{} row failed: {e:#}", spec.name)),
738            };
739            match outcome {
740                LwwOutcome::Applied => {
741                    summary.rows_applied += 1;
742                    if spec.name == "sessions"
743                        && let Some(id) = row.get("id").and_then(serde_json::Value::as_str)
744                    {
745                        won_sessions.insert(id.to_string());
746                    }
747                    if spec.name == "files"
748                        && let (Some(sid), Some(name)) = (
749                            row.get("space_id").and_then(serde_json::Value::as_str),
750                            row.get("name").and_then(serde_json::Value::as_str),
751                        )
752                    {
753                        won_files.insert((sid.to_string(), name.to_string()));
754                    }
755                }
756                LwwOutcome::Skipped => summary.rows_skipped += 1,
757                LwwOutcome::Warned(w) => {
758                    summary.rows_skipped += 1;
759                    summary.warnings.push(w);
760                }
761            }
762        }
763    }
764
765    // swarm_personas: applied only for sessions that won in this changeset,
766    // as a wholesale roster replace (the collection has no per-row LWW).
767    if let Some(personas) = by_table.get("swarm_personas") {
768        let mut rosters: HashMap<&str, Vec<&serde_json::Value>> = HashMap::new();
769        for p in personas {
770            if let Some(sid) = p.get("session_id").and_then(serde_json::Value::as_str) {
771                rosters.entry(sid).or_default().push(p);
772            }
773        }
774        for (sid, roster) in rosters {
775            if won_sessions.contains(sid) {
776                let conn = db.conn();
777                conn.execute("DELETE FROM swarm_personas WHERE session_id = ?1", [sid])?;
778                for p in &roster {
779                    let values: Vec<rusqlite::types::Value> =
780                        ["session_id", "ord", "name", "model", "persona"]
781                            .iter()
782                            .map(|c| json_to_value(p.get(*c).unwrap_or(&serde_json::Value::Null)))
783                            .collect();
784                    conn.execute(
785                        "INSERT INTO swarm_personas (session_id, ord, name, model, persona)
786                         VALUES (?1, ?2, ?3, ?4, ?5)",
787                        params_from_iter(values.iter()),
788                    )?;
789                }
790                summary.rows_applied += roster.len();
791            } else {
792                summary.rows_skipped += roster.len();
793                summary.warnings.push(format!(
794                    "swarm personas for session {sid} skipped — their session row lost LWW"
795                ));
796            }
797        }
798    }
799
800    // Tombstones, after rows: within one changeset a row and its tombstone
801    // never coexist (a deleted row is not re-exported), and across
802    // changesets the destructive delete is the newer intent.
803    let mut tombstone_max = 0i64;
804    for t in &cs.tombstones {
805        tombstone_max = tombstone_max.max(t.origin_id);
806        if apply_tombstone(db, space, t, &mut summary) {
807            summary.tombstones_applied += 1;
808        }
809    }
810    if !cs.tombstones.is_empty() {
811        max_pos.insert(SYNC_TOMBSTONES, tombstone_max.to_string());
812    }
813
814    // File blobs: for every file row that won, keep the local blob when it
815    // matches, else pull it from the transport's blob channel.
816    for fc in &cs.files {
817        if !won_files.contains(&(fc.space_id.clone(), fc.name.clone())) {
818            continue;
819        }
820        if !valid_component(&fc.name) || !valid_component(&fc.space_id) {
821            summary.warnings.push(format!(
822                "skipping blob for unsafe file {:?}/{:?}",
823                fc.space_id, fc.name
824            ));
825            continue;
826        }
827        let Some(space_name) = space_name_for(db, &fc.space_id)? else {
828            summary.warnings.push(format!(
829                "skipping blob for {:?} — its space no longer exists",
830                fc.name
831            ));
832            continue;
833        };
834        let target = space.files_dir(&space_name).join(&fc.name);
835        if file_matches(&target, &fc.hash) {
836            summary.files_kept += 1;
837            continue;
838        }
839        let pulled = match blob_source {
840            Some(src) => match pull_blob(src, &fc.space_id, &fc.name, &fc.hash, &target) {
841                Ok(true) => {
842                    summary.files_pulled += 1;
843                    true
844                }
845                _ => false,
846            },
847            None => false,
848        };
849        if !pulled {
850            summary.files_missing.push(fc.clone());
851            summary.warnings.push(format!(
852                "blob for {:?} unavailable — fetch it with a dir or ssh transport",
853                fc.name
854            ));
855        }
856    }
857
858    // The embedded ack: entries addressed to this device advance the
859    // sender's push cursors — the only thing that does.
860    if let Some(acks) = &cs.ack {
861        let states = db.load_sync_state()?;
862        for a in acks {
863            if a.peer_id != my_id {
864                continue;
865            }
866            if !known_table(&a.table_name) {
867                summary
868                    .warnings
869                    .push(format!("ack for unknown table {:?}", a.table_name));
870                continue;
871            }
872            let existing = states
873                .iter()
874                .find(|s| s.peer_id == cs.device_id && s.table_name == a.table_name)
875                .and_then(|s| s.push_cursor.clone());
876            if existing
877                .as_deref()
878                .is_none_or(|e| position_gt(&a.table_name, &a.cursor, e))
879            {
880                db.set_sync_state(&cs.device_id, &a.table_name, None, Some(&a.cursor))?;
881                summary.acks_applied += 1;
882            }
883        }
884    }
885
886    // Advance pull cursors (monotonically) and build the reply cursors.
887    let states = db.load_sync_state()?;
888    let mut reply: Vec<PeerCursor> = Vec::new();
889    for (table, pos) in &max_pos {
890        let existing = states
891            .iter()
892            .find(|s| s.peer_id == cs.device_id && s.table_name == *table)
893            .and_then(|s| s.pull_cursor.clone());
894        let final_pos = match &existing {
895            Some(e) if position_gt(table, e, pos) => e.clone(),
896            _ => pos.clone(),
897        };
898        if existing.as_deref() != Some(final_pos.as_str()) {
899            db.set_sync_state(&cs.device_id, table, Some(&final_pos), None)?;
900            reply.push(PeerCursor {
901                peer_id: cs.device_id.clone(),
902                table_name: (*table).to_string(),
903                cursor: final_pos,
904            });
905        }
906    }
907    Ok((summary, reply))
908}
909
910fn apply_row(
911    db: &Db,
912    space: &Space,
913    spec: &TableSpec,
914    row: &serde_json::Value,
915) -> Result<LwwOutcome> {
916    match spec.cursor {
917        Cursor::UpdatedAt if spec.name == "spaces" => apply_space(db, space, row),
918        Cursor::UpdatedAt if spec.name == "files" => apply_file(db, row),
919        Cursor::UpdatedAt => apply_lww(db, spec, row),
920        Cursor::Tuple(_) | Cursor::AutoId => Ok(if apply_append(db, spec, row)? {
921            LwwOutcome::Applied
922        } else {
923            LwwOutcome::Skipped
924        }),
925        Cursor::None => Ok(LwwOutcome::Skipped),
926    }
927}
928
929/// LWW upsert for a plain row (not spaces/files — those have their own
930/// name-merge rules). A `scope != 'sync'` `app_settings` row never lands.
931fn apply_lww(db: &Db, spec: &TableSpec, row: &serde_json::Value) -> Result<LwwOutcome> {
932    if spec.name == "app_settings"
933        && row.get("scope").and_then(serde_json::Value::as_str) != Some("sync")
934    {
935        return Ok(LwwOutcome::Skipped);
936    }
937    let conn = db.conn();
938    let pk_values: Vec<String> = spec
939        .pk
940        .iter()
941        .map(|c| {
942            row.get(*c)
943                .and_then(serde_json::Value::as_str)
944                .unwrap_or_default()
945                .to_string()
946        })
947        .collect();
948    let where_sql = spec
949        .pk
950        .iter()
951        .enumerate()
952        .map(|(i, c)| format!("{c} = ?{}", i + 1))
953        .collect::<Vec<_>>()
954        .join(" AND ");
955    let existing: Option<String> = conn
956        .query_row(
957            &format!(
958                "SELECT COALESCE(updated_at, '') FROM {} WHERE {where_sql}",
959                spec.name
960            ),
961            params_from_iter(pk_values.iter()),
962            |r| r.get(0),
963        )
964        .optional()?;
965    let local_tie = pk_values.join("\u{1f}");
966    if !lww_wins(spec, row, existing.as_deref().unwrap_or(""), &local_tie) {
967        return Ok(LwwOutcome::Skipped);
968    }
969    let values: Vec<rusqlite::types::Value> = spec
970        .apply_columns
971        .iter()
972        .map(|c| json_to_value(row.get(*c).unwrap_or(&serde_json::Value::Null)))
973        .collect();
974    if existing.is_some() {
975        let set_sql = spec
976            .apply_columns
977            .iter()
978            .enumerate()
979            .map(|(i, c)| format!("{c} = ?{}", i + 1))
980            .collect::<Vec<_>>()
981            .join(", ");
982        // The WHERE placeholders follow the SET's (the SELECT above used
983        // its own 1-based clause).
984        let update_where = spec
985            .pk
986            .iter()
987            .enumerate()
988            .map(|(i, c)| format!("{c} = ?{}", spec.apply_columns.len() + i + 1))
989            .collect::<Vec<_>>()
990            .join(" AND ");
991        let mut all = values;
992        all.extend(
993            pk_values
994                .iter()
995                .map(|v| rusqlite::types::Value::Text(v.clone())),
996        );
997        conn.execute(
998            &format!("UPDATE {} SET {set_sql} WHERE {update_where}", spec.name),
999            params_from_iter(all.iter()),
1000        )?;
1001    } else {
1002        let cols = spec.apply_columns.join(", ");
1003        let marks = (1..=spec.apply_columns.len())
1004            .map(|i| format!("?{i}"))
1005            .collect::<Vec<_>>()
1006            .join(", ");
1007        conn.execute(
1008            &format!("INSERT INTO {} ({cols}) VALUES ({marks})", spec.name),
1009            params_from_iter(values.iter()),
1010        )?;
1011    }
1012    Ok(LwwOutcome::Applied)
1013}
1014
1015/// INSERT OR IGNORE by the row's sync identity — the append-only union
1016/// rule. `changes()` tells an ignored duplicate apart from a real insert.
1017fn apply_append(db: &Db, spec: &TableSpec, row: &serde_json::Value) -> Result<bool> {
1018    let cols = spec.apply_columns.join(", ");
1019    let marks = (1..=spec.apply_columns.len())
1020        .map(|i| format!("?{i}"))
1021        .collect::<Vec<_>>()
1022        .join(", ");
1023    let values: Vec<rusqlite::types::Value> = spec
1024        .apply_columns
1025        .iter()
1026        .map(|c| json_to_value(row.get(*c).unwrap_or(&serde_json::Value::Null)))
1027        .collect();
1028    let n = db.conn().execute(
1029        &format!(
1030            "INSERT OR IGNORE INTO {} ({cols}) VALUES ({marks})",
1031            spec.name
1032        ),
1033        params_from_iter(values.iter()),
1034    )?;
1035    Ok(n > 0)
1036}
1037
1038/// A space row's apply: LWW like the others, plus the name-merge rules —
1039/// spaces are UNIQUE by name, and two devices may have independently
1040/// created different spaces with the same name. The LWW loser is renamed
1041/// to `<name>-<first8(loser_id)>` on both sides (the rename is a pure
1042/// function of the loser, so both devices compute the same name). Fresh
1043/// winners get their directory; renames move it.
1044fn apply_space(db: &Db, space: &Space, row: &serde_json::Value) -> Result<LwwOutcome> {
1045    let Some(id) = row.get("id").and_then(serde_json::Value::as_str) else {
1046        return Ok(LwwOutcome::Warned("space row without id".to_string()));
1047    };
1048    let Some(name) = row.get("name").and_then(serde_json::Value::as_str) else {
1049        return Ok(LwwOutcome::Warned(format!("space {id} without name")));
1050    };
1051    if !valid_component(name) {
1052        return Ok(LwwOutcome::Warned(format!(
1053            "skipping space {id}: unsafe name {name:?}"
1054        )));
1055    }
1056    let conn = db.conn();
1057    let incoming_updated = row
1058        .get("updated_at")
1059        .and_then(serde_json::Value::as_str)
1060        .unwrap_or_default();
1061    let local: Option<(String, String)> = conn
1062        .query_row(
1063            "SELECT name, COALESCE(updated_at, '') FROM spaces WHERE id = ?1",
1064            [id],
1065            |r| Ok((r.get(0)?, r.get(1)?)),
1066        )
1067        .optional()?;
1068    let Some((local_name, local_updated)) = local else {
1069        // New space. Its name may be taken by a different local space —
1070        // the loser of that pair is renamed deterministically.
1071        let colliding: Option<String> = conn
1072            .query_row(
1073                "SELECT id FROM spaces WHERE name = ?1 AND id != ?2",
1074                (name, id),
1075                |r| r.get(0),
1076            )
1077            .optional()?;
1078        let mut incoming_name = name.to_string();
1079        if let Some(other) = colliding {
1080            let other_updated: String = conn.query_row(
1081                "SELECT COALESCE(updated_at, '') FROM spaces WHERE id = ?1",
1082                [&other],
1083                |r| r.get(0),
1084            )?;
1085            if lww_wins_against(id, incoming_updated, &other, &other_updated) {
1086                let new_name = format!("{name}-{}", short_id(&other));
1087                conn.execute(
1088                    "UPDATE spaces SET name = ?1 WHERE id = ?2",
1089                    (new_name.as_str(), other.as_str()),
1090                )?;
1091                rename_dir(space, name, &new_name);
1092            } else {
1093                incoming_name = format!("{name}-{}", short_id(id));
1094            }
1095        }
1096        insert_space_row(conn, id, &incoming_name, row)?;
1097        if let Err(e) = space.ensure_space_dir(&incoming_name) {
1098            return Ok(LwwOutcome::Warned(format!(
1099                "space {id} applied but its dir failed: {e}"
1100            )));
1101        }
1102        return Ok(LwwOutcome::Applied);
1103    };
1104    if !lww_wins_against(id, incoming_updated, id, &local_updated) {
1105        return Ok(LwwOutcome::Skipped);
1106    }
1107    if name != local_name {
1108        rename_dir(space, &local_name, name);
1109    }
1110    insert_space_row(conn, id, name, row)?;
1111    Ok(LwwOutcome::Applied)
1112}
1113
1114fn insert_space_row(
1115    conn: &Connection,
1116    id: &str,
1117    name: &str,
1118    row: &serde_json::Value,
1119) -> Result<()> {
1120    conn.execute(
1121        "INSERT INTO spaces (id, name, created_at, updated_at)
1122         VALUES (?1, ?2, ?3, ?4)
1123         ON CONFLICT(id) DO UPDATE SET name = ?2, created_at = ?3, updated_at = ?4",
1124        (
1125            id,
1126            name,
1127            row.get("created_at")
1128                .and_then(serde_json::Value::as_str)
1129                .unwrap_or_default(),
1130            row.get("updated_at")
1131                .and_then(serde_json::Value::as_str)
1132                .unwrap_or_default(),
1133        ),
1134    )?;
1135    Ok(())
1136}
1137
1138/// Move a space's directory on a rename — best-effort: a missing old dir
1139/// is fine, a failing rename surfaces in the caller's warning path.
1140fn rename_dir(space: &Space, old: &str, new: &str) {
1141    let from = space.space_dir(old);
1142    let to = space.space_dir(new);
1143    if from.exists() && !to.exists() {
1144        let _ = std::fs::rename(&from, &to);
1145    }
1146}
1147
1148/// A file row's apply: LWW, plus the (`space_id`, name) uniqueness merge —
1149/// the app keeps one row per space+name, so a synced row colliding with a
1150/// different id is the LWW opponent; the loser is replaced wholesale (id
1151/// and all) so both devices end on the same row id and deletes propagate.
1152fn apply_file(db: &Db, row: &serde_json::Value) -> Result<LwwOutcome> {
1153    let Some(id) = row.get("id").and_then(serde_json::Value::as_str) else {
1154        return Ok(LwwOutcome::Warned("file row without id".to_string()));
1155    };
1156    let (Some(space_id), Some(name)) = (
1157        row.get("space_id").and_then(serde_json::Value::as_str),
1158        row.get("name").and_then(serde_json::Value::as_str),
1159    ) else {
1160        return Ok(LwwOutcome::Warned(format!("file {id} without space/name")));
1161    };
1162    if !valid_component(name) || !valid_component(space_id) {
1163        return Ok(LwwOutcome::Warned(format!(
1164            "skipping file {id}: unsafe name {name:?}"
1165        )));
1166    }
1167    let conn = db.conn();
1168    let incoming_updated = row
1169        .get("updated_at")
1170        .and_then(serde_json::Value::as_str)
1171        .unwrap_or_default();
1172    // The opponent: the row with this id, or the row owning this
1173    // space+name (the app's uniqueness rule).
1174    let local: Option<(String, String)> = conn
1175        .query_row(
1176            "SELECT id, COALESCE(updated_at, '') FROM files WHERE id = ?1",
1177            [id],
1178            |r| Ok((r.get(0)?, r.get(1)?)),
1179        )
1180        .optional()?
1181        .or(conn
1182            .query_row(
1183                "SELECT id, COALESCE(updated_at, '') FROM files
1184                 WHERE space_id = ?1 AND name = ?2",
1185                (space_id, name),
1186                |r| Ok((r.get(0)?, r.get(1)?)),
1187            )
1188            .optional()?);
1189    let Some((local_id, local_updated)) = local else {
1190        insert_file_row(conn, row)?;
1191        return Ok(LwwOutcome::Applied);
1192    };
1193    if !lww_wins_against(id, incoming_updated, &local_id, &local_updated) {
1194        return Ok(LwwOutcome::Skipped);
1195    }
1196    // Incoming wins — replace the local row wholesale, including its id:
1197    // the winner's id becomes the row's identity on both devices.
1198    let values: Vec<rusqlite::types::Value> = [
1199        "id",
1200        "space_id",
1201        "name",
1202        "hash",
1203        "size",
1204        "created_at",
1205        "updated_at",
1206    ]
1207    .iter()
1208    .map(|c| json_to_value(row.get(*c).unwrap_or(&serde_json::Value::Null)))
1209    .collect();
1210    let mut all = values;
1211    all.push(rusqlite::types::Value::Text(local_id));
1212    conn.execute(
1213        "UPDATE files SET id = ?1, space_id = ?2, name = ?3, hash = ?4, size = ?5,
1214            created_at = ?6, updated_at = ?7
1215         WHERE id = ?8",
1216        params_from_iter(all.iter()),
1217    )?;
1218    Ok(LwwOutcome::Applied)
1219}
1220
1221fn insert_file_row(conn: &Connection, row: &serde_json::Value) -> Result<()> {
1222    let values: Vec<rusqlite::types::Value> = [
1223        "id",
1224        "space_id",
1225        "name",
1226        "hash",
1227        "size",
1228        "created_at",
1229        "updated_at",
1230    ]
1231    .iter()
1232    .map(|c| json_to_value(row.get(*c).unwrap_or(&serde_json::Value::Null)))
1233    .collect();
1234    conn.execute(
1235        "INSERT INTO files (id, space_id, name, hash, size, created_at, updated_at)
1236         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
1237        params_from_iter(values.iter()),
1238    )?;
1239    Ok(())
1240}
1241
1242/// Apply one tombstone; returns whether it hit anything. The incoming
1243/// tombstone is also recorded locally (deduped by `(table_name, row_id)`)
1244/// — that is what makes a delete transitive across a mesh of devices
1245/// without every pair syncing directly. The ack prevents resends.
1246// Long by design: one arm per tombstonable table.
1247#[allow(clippy::too_many_lines)]
1248fn apply_tombstone(db: &Db, space: &Space, t: &Tombstone, summary: &mut ApplySummary) -> bool {
1249    let conn = db.conn();
1250    // Dedupe: the first tombstone for a (table, row) wins; later copies
1251    // are the same delete.
1252    let _ = conn.execute(
1253        "INSERT INTO sync_tombstones (table_name, row_id, deleted_at)
1254         SELECT ?1, ?2, ?3 WHERE NOT EXISTS (
1255             SELECT 1 FROM sync_tombstones WHERE table_name = ?1 AND row_id = ?2)",
1256        (&t.table_name, &t.row_id, &t.deleted_at),
1257    );
1258    match t.table_name.as_str() {
1259        "spaces" => {
1260            if t.row_id == DEFAULT_SPACE {
1261                summary
1262                    .warnings
1263                    .push("tombstone for the default space ignored".to_string());
1264                return false;
1265            }
1266            let name: Option<String> = conn
1267                .query_row("SELECT name FROM spaces WHERE id = ?1", [&t.row_id], |r| {
1268                    r.get(0)
1269                })
1270                .optional()
1271                .ok()
1272                .flatten();
1273            if conn
1274                .execute("DELETE FROM spaces WHERE id = ?1", [&t.row_id])
1275                .is_err()
1276            {
1277                return false;
1278            }
1279            if let Some(name) = name
1280                && let Err(e) = space.remove_space_dir(&name)
1281            {
1282                summary
1283                    .warnings
1284                    .push(format!("removing space dir {name}: {e}"));
1285            }
1286            true
1287        }
1288        "sessions" => {
1289            let cascade = conn.execute("DELETE FROM messages WHERE session_id = ?1", [&t.row_id]);
1290            let sources = conn.execute(
1291                "DELETE FROM session_sources WHERE session_id = ?1",
1292                [&t.row_id],
1293            );
1294            let row = conn.execute("DELETE FROM sessions WHERE id = ?1", [&t.row_id]);
1295            cascade.is_ok() && sources.is_ok() && row.is_ok()
1296        }
1297        "messages" => conn
1298            .execute("DELETE FROM messages WHERE id = ?1", [&t.row_id])
1299            .is_ok(),
1300        "files" => {
1301            let row: Option<(String, String)> = conn
1302                .query_row(
1303                    "SELECT space_id, name FROM files WHERE id = ?1",
1304                    [&t.row_id],
1305                    |r| Ok((r.get(0)?, r.get(1)?)),
1306                )
1307                .optional()
1308                .ok()
1309                .flatten();
1310            let _ = conn.execute(
1311                "DELETE FROM cache.file_chunks WHERE file_id = ?1",
1312                [&t.row_id],
1313            );
1314            let _ = conn.execute(
1315                "DELETE FROM cache.chunk_embeddings WHERE file_id = ?1",
1316                [&t.row_id],
1317            );
1318            let _ = conn.execute(
1319                "DELETE FROM cache.file_index_state WHERE file_id = ?1",
1320                [&t.row_id],
1321            );
1322            if conn
1323                .execute("DELETE FROM files WHERE id = ?1", [&t.row_id])
1324                .is_err()
1325            {
1326                return false;
1327            }
1328            if let Some((space_id, name)) = row
1329                && let Ok(Some(space_name)) = space_name_for(db, &space_id)
1330                && valid_component(&name)
1331            {
1332                let blob = space.files_dir(&space_name).join(&name);
1333                let _ = std::fs::remove_file(blob);
1334            }
1335            true
1336        }
1337        "watches" => conn
1338            .execute("DELETE FROM watches WHERE id = ?1", [&t.row_id])
1339            .is_ok(),
1340        "usage_log" => conn
1341            .execute("DELETE FROM usage_log WHERE sync_id = ?1", [&t.row_id])
1342            .is_ok(),
1343        "citations" => conn
1344            .execute("DELETE FROM citations WHERE sync_id = ?1", [&t.row_id])
1345            .is_ok(),
1346        // Persona tombstones are subsumed by the roster replace that
1347        // accompanies a winning session row — applying them alone could
1348        // delete slots of a roster the session's LWW winner restored.
1349        "swarm_personas" => false,
1350        other => {
1351            summary
1352                .warnings
1353                .push(format!("tombstone for unknown table {other:?}"));
1354            false
1355        }
1356    }
1357}
1358
1359/// A space row's name for a space id, when the space still exists.
1360fn space_name_for(db: &Db, space_id: &str) -> Result<Option<String>> {
1361    Ok(db
1362        .conn()
1363        .query_row("SELECT name FROM spaces WHERE id = ?1", [space_id], |r| {
1364            r.get(0)
1365        })
1366        .optional()?)
1367}
1368
1369// ── file blobs ──
1370
1371fn sha256_hex(bytes: &[u8]) -> String {
1372    let mut hasher = Sha256::new();
1373    hasher.update(bytes);
1374    hasher.finalize().iter().fold(String::new(), |mut h, b| {
1375        let _ = std::fmt::Write::write_fmt(&mut h, format_args!("{b:02x}"));
1376        h
1377    })
1378}
1379
1380/// Store one HTTP/transport blob after checking that its manifest row exists,
1381/// the declared size matches, and the content hash is exact. Metadata always
1382/// wins first; an upload can never create a new file row or escape its space.
1383pub fn put_blob(
1384    db: &Db,
1385    space: &Space,
1386    space_id: &str,
1387    name: &str,
1388    hash: &str,
1389    bytes: &[u8],
1390) -> Result<()> {
1391    if !valid_component(space_id) || !valid_component(name) {
1392        bail!("unsafe blob path");
1393    }
1394    let Some(space_name) = space_name_for(db, space_id)? else {
1395        bail!("unknown space");
1396    };
1397    let row = db
1398        .list_files(space_id)?
1399        .into_iter()
1400        .find(|file| file.name == name)
1401        .ok_or_else(|| anyhow!("unknown file manifest"))?;
1402    if row.hash != hash {
1403        bail!("blob hash does not match the current file manifest");
1404    }
1405    if row.size < 0 || row.size as usize != bytes.len() {
1406        bail!("blob size does not match the current file manifest");
1407    }
1408    if sha256_hex(bytes) != hash {
1409        bail!("blob content hash mismatch");
1410    }
1411    let target = space.files_dir(&space_name).join(name);
1412    if let Some(parent) = target.parent() {
1413        std::fs::create_dir_all(parent)
1414            .with_context(|| format!("creating {}", parent.display()))?;
1415    }
1416    let temporary = target.with_extension(format!("nexus-upload-{}", uuid::Uuid::new_v4()));
1417    std::fs::write(&temporary, bytes)
1418        .with_context(|| format!("writing blob {}", temporary.display()))?;
1419    if let Err(error) = std::fs::rename(&temporary, &target) {
1420        let _ = std::fs::remove_file(&temporary);
1421        return Err(error).with_context(|| format!("installing blob {}", target.display()));
1422    }
1423    Ok(())
1424}
1425
1426/// Read a manifest-backed blob for an HTTP download. A missing or stale local
1427/// file is `None`, never an unverified byte stream.
1428pub fn read_blob(db: &Db, space: &Space, space_id: &str, name: &str) -> Result<Option<Vec<u8>>> {
1429    if !valid_component(space_id) || !valid_component(name) {
1430        return Ok(None);
1431    }
1432    let Some(space_name) = space_name_for(db, space_id)? else {
1433        return Ok(None);
1434    };
1435    let Some(row) = db
1436        .list_files(space_id)?
1437        .into_iter()
1438        .find(|file| file.name == name)
1439    else {
1440        return Ok(None);
1441    };
1442    let path = space.files_dir(&space_name).join(name);
1443    let bytes = match std::fs::read(path) {
1444        Ok(bytes) => bytes,
1445        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1446        Err(error) => return Err(error.into()),
1447    };
1448    Ok(
1449        (row.size >= 0 && row.size as usize == bytes.len() && sha256_hex(&bytes) == row.hash)
1450            .then_some(bytes),
1451    )
1452}
1453
1454/// Whether the file at `path` exists and its sha256 matches `hash`.
1455fn file_matches(path: &Path, hash: &str) -> bool {
1456    let Ok(bytes) = std::fs::read(path) else {
1457        return false;
1458    };
1459    sha256_hex(&bytes) == hash
1460}
1461
1462/// The local disk path of a manifest entry's blob, when the space still
1463/// exists and the name is a valid path component.
1464fn blob_source_path(db: &Db, space: &Space, fc: &FileChange) -> Result<Option<PathBuf>> {
1465    if !valid_component(&fc.name) || !valid_component(&fc.space_id) {
1466        return Ok(None);
1467    }
1468    let Some(space_name) = space_name_for(db, &fc.space_id)? else {
1469        return Ok(None);
1470    };
1471    let path = space.files_dir(&space_name).join(&fc.name);
1472    Ok(path.exists().then_some(path))
1473}
1474
1475/// Copy the local blobs of a changeset's manifest into `dest/blobs/` —
1476/// the transport's blob channel. Never re-sends identical content: the
1477/// receiver hash-checks before pulling.
1478pub fn export_blobs(db: &Db, space: &Space, cs: &Changeset, dest: &Path) -> Result<usize> {
1479    let mut n = 0usize;
1480    for fc in &cs.files {
1481        let Some(src) = blob_source_path(db, space, fc)? else {
1482            continue;
1483        };
1484        let target = dest.join("blobs").join(&fc.space_id).join(&fc.name);
1485        if let Some(parent) = target.parent() {
1486            std::fs::create_dir_all(parent)
1487                .with_context(|| format!("creating {}", parent.display()))?;
1488        }
1489        std::fs::copy(&src, &target)
1490            .with_context(|| format!("copying blob {} to {}", src.display(), target.display()))?;
1491        n += 1;
1492    }
1493    Ok(n)
1494}
1495
1496/// Pull one blob from `src/blobs/<space_id>/<name>` into `target`, after
1497/// verifying the source's content hash matches the manifest (a stale or
1498/// mismatched payload is never applied).
1499fn pull_blob(src: &Path, space_id: &str, name: &str, hash: &str, target: &Path) -> Result<bool> {
1500    let candidate = src.join("blobs").join(space_id).join(name);
1501    if !file_matches(&candidate, hash) {
1502        return Ok(false);
1503    }
1504    if let Some(parent) = target.parent() {
1505        std::fs::create_dir_all(parent)
1506            .with_context(|| format!("creating {}", parent.display()))?;
1507    }
1508    std::fs::copy(&candidate, target)
1509        .with_context(|| format!("copying blob to {}", target.display()))?;
1510    Ok(true)
1511}
1512
1513// ── zip bundles (the ssh transport's blob channel) ──
1514
1515/// Write a changeset plus its blobs as a zip: `changeset.json` +
1516/// `blobs/<space_id>/<name>`. Returns how many blobs were included.
1517pub fn write_bundle(
1518    db: &Db,
1519    space: &Space,
1520    cs: &Changeset,
1521    writer: impl std::io::Write + std::io::Seek,
1522) -> Result<usize> {
1523    let mut zip = zip::ZipWriter::new(writer);
1524    let opts = zip::write::SimpleFileOptions::default()
1525        .compression_method(zip::CompressionMethod::Deflated);
1526    zip.start_file("changeset.json", opts)?;
1527    serde_json::to_writer(&mut zip, cs)?;
1528    let mut n = 0usize;
1529    for fc in &cs.files {
1530        let Some(src) = blob_source_path(db, space, fc)? else {
1531            continue;
1532        };
1533        zip.start_file(format!("blobs/{}/{}", fc.space_id, fc.name), opts)?;
1534        let mut f = std::fs::File::open(&src)?;
1535        std::io::copy(&mut f, &mut zip)?;
1536        n += 1;
1537    }
1538    zip.finish()?;
1539    Ok(n)
1540}
1541
1542/// Unpack a bundle written by `write_bundle` into `dest_dir` (as
1543/// `blobs/…`), returning the changeset. Entry paths are validated — a
1544/// bundle must never write outside `dest_dir`.
1545pub fn unpack_bundle(src: &Path, dest_dir: &Path) -> Result<Changeset> {
1546    let file = std::fs::File::open(src).with_context(|| format!("opening {}", src.display()))?;
1547    let mut archive =
1548        zip::ZipArchive::new(file).with_context(|| format!("reading {}", src.display()))?;
1549    let mut changeset: Option<Changeset> = None;
1550    for i in 0..archive.len() {
1551        let mut entry = archive.by_index(i)?;
1552        let name = entry.name().to_string();
1553        if name == "changeset.json" {
1554            changeset = Some(serde_json::from_reader(&mut entry)?);
1555            continue;
1556        }
1557        let Some(rel) = name.strip_prefix("blobs/") else {
1558            continue;
1559        };
1560        let components: Vec<&str> = rel.split('/').collect();
1561        if components.len() != 2 || !components.iter().all(|c| valid_component(c)) {
1562            bail!("unsafe path in bundle: {name}");
1563        }
1564        let dest = dest_dir.join("blobs").join(rel);
1565        if let Some(parent) = dest.parent() {
1566            std::fs::create_dir_all(parent)
1567                .with_context(|| format!("creating {}", parent.display()))?;
1568        }
1569        let mut out =
1570            std::fs::File::create(&dest).with_context(|| format!("creating {}", dest.display()))?;
1571        std::io::copy(&mut entry, &mut out)?;
1572    }
1573    changeset.ok_or_else(|| anyhow!("bundle has no changeset.json"))
1574}
1575
1576#[cfg(test)]
1577mod tests {
1578    use super::*;
1579    use crate::db::Db;
1580    use std::io::Write as _;
1581
1582    /// A fresh device pair: in-memory dbs (with their attached in-memory
1583    /// caches) plus temp roots standing in for the spaces layout.
1584    struct Pair {
1585        a: Db,
1586        b: Db,
1587        a_root: PathBuf,
1588        b_root: PathBuf,
1589        dir: PathBuf,
1590    }
1591
1592    impl Pair {
1593        fn new() -> Self {
1594            let dir = std::env::temp_dir().join(format!("nexus-sync-{}", uuid::Uuid::new_v4()));
1595            std::fs::create_dir_all(&dir).unwrap();
1596            Self {
1597                a: Db::open_in_memory().unwrap(),
1598                b: Db::open_in_memory().unwrap(),
1599                a_root: dir.join("a"),
1600                b_root: dir.join("b"),
1601                dir,
1602            }
1603        }
1604
1605        fn space_a(&self) -> Space {
1606            Space {
1607                root: self.a_root.clone(),
1608            }
1609        }
1610
1611        fn space_b(&self) -> Space {
1612            Space {
1613                root: self.b_root.clone(),
1614            }
1615        }
1616
1617        /// One full A→B exchange: A exports (+ blobs into the channel), B
1618        /// applies (pulling blobs), B replies with its own export + blobs
1619        /// + ack, A applies.
1620        fn exchange_ab(&self) -> (ApplySummary, ApplySummary) {
1621            let a_space = self.space_a();
1622            let b_space = self.space_b();
1623            let cs = build_changeset(&self.a, None, "a").unwrap();
1624            export_blobs(&self.a, &a_space, &cs, &self.dir).unwrap();
1625            let (sa, cursors) = apply_changeset(&self.b, &b_space, &cs, Some(&self.dir)).unwrap();
1626            let mut reply = build_changeset(&self.b, Some(&cs.device_id), "b").unwrap();
1627            reply.ack = Some(cursors);
1628            export_blobs(&self.b, &b_space, &reply, &self.dir).unwrap();
1629            let (sb, _) = apply_changeset(&self.a, &a_space, &reply, Some(&self.dir)).unwrap();
1630            (sa, sb)
1631        }
1632
1633        /// The mirror image: B→A.
1634        fn exchange_ba(&self) -> (ApplySummary, ApplySummary) {
1635            let a_space = self.space_a();
1636            let b_space = self.space_b();
1637            let cs = build_changeset(&self.b, None, "b").unwrap();
1638            export_blobs(&self.b, &b_space, &cs, &self.dir).unwrap();
1639            let (sb, cursors) = apply_changeset(&self.a, &a_space, &cs, Some(&self.dir)).unwrap();
1640            let mut reply = build_changeset(&self.a, Some(&cs.device_id), "a").unwrap();
1641            reply.ack = Some(cursors);
1642            export_blobs(&self.a, &a_space, &reply, &self.dir).unwrap();
1643            let (sa, _) = apply_changeset(&self.b, &b_space, &reply, Some(&self.dir)).unwrap();
1644            (sb, sa)
1645        }
1646
1647        /// Both directions.
1648        fn exchange(&self) -> (ApplySummary, ApplySummary) {
1649            let (_, _) = self.exchange_ab();
1650            self.exchange_ba()
1651        }
1652    }
1653
1654    impl Drop for Pair {
1655        fn drop(&mut self) {
1656            let _ = std::fs::remove_dir_all(&self.dir);
1657        }
1658    }
1659
1660    /// Every syncable row on a device, as (table, pk, json) — the
1661    /// convergence oracle: two devices are converged iff their dumps are
1662    /// equal (tombstones included). The default space is excluded: its
1663    /// `updated_at` is NULL so it never exports, and its `created_at` is
1664    /// device-local (cosmetic).
1665    fn dump(db: &Db) -> Vec<(String, String, String)> {
1666        let mut out = Vec::new();
1667        for spec in TABLES {
1668            if spec.cursor == Cursor::None {
1669                continue;
1670            }
1671            let rows = select_rows(db, spec, None).unwrap();
1672            for row in rows {
1673                let pk = spec
1674                    .pk
1675                    .iter()
1676                    .map(|c| {
1677                        row.get(*c)
1678                            .and_then(serde_json::Value::as_str)
1679                            .unwrap_or_default()
1680                            .to_string()
1681                    })
1682                    .collect::<Vec<_>>()
1683                    .join(":");
1684                out.push((spec.name.to_string(), pk, row.to_string()));
1685            }
1686        }
1687        let mut stmt = db
1688            .conn()
1689            .prepare("SELECT table_name, row_id, deleted_at FROM sync_tombstones ORDER BY row_id")
1690            .unwrap();
1691        let rows = stmt
1692            .query_map([], |r| {
1693                Ok((
1694                    r.get::<_, String>(0)?,
1695                    r.get::<_, String>(1)?,
1696                    r.get::<_, String>(2)?,
1697                ))
1698            })
1699            .unwrap()
1700            .collect::<rusqlite::Result<Vec<_>>>()
1701            .unwrap();
1702        for (t, rid, _) in rows {
1703            out.push(("tombstone".to_string(), format!("{t}:{rid}"), String::new()));
1704        }
1705        out.sort();
1706        out
1707    }
1708
1709    fn space(db: &Db, name: &str) -> String {
1710        db.list_spaces()
1711            .unwrap()
1712            .into_iter()
1713            .find(|s| s.name == name)
1714            .unwrap()
1715            .id
1716    }
1717
1718    fn session(db: &Db, title: &str) -> String {
1719        let sid = space(db, "default");
1720        let s = db.create_session(title, "m", &sid, "chat").unwrap();
1721        s.id
1722    }
1723
1724    fn set_updated(db: &Db, table: &str, id: &str, at: &str) {
1725        db.conn()
1726            .execute(
1727                &format!("UPDATE {table} SET updated_at = ?1 WHERE id = ?2"),
1728                (at, id),
1729            )
1730            .unwrap();
1731    }
1732
1733    fn session_updated(db: &Db, id: &str) -> String {
1734        db.conn()
1735            .query_row("SELECT updated_at FROM sessions WHERE id = ?1", [id], |r| {
1736                r.get(0)
1737            })
1738            .unwrap()
1739    }
1740
1741    fn minus_one_second(rfc3339: &str) -> String {
1742        let dt = chrono::DateTime::parse_from_rfc3339(rfc3339).unwrap();
1743        (dt - chrono::Duration::seconds(1)).to_rfc3339()
1744    }
1745
1746    fn write_blob(space: &Space, space_name: &str, name: &str, bytes: &[u8]) {
1747        let dir = space.files_dir(space_name);
1748        std::fs::create_dir_all(&dir).unwrap();
1749        std::fs::write(dir.join(name), bytes).unwrap();
1750    }
1751
1752    fn add_session_sources(db: &Db, session_id: &str, url_norms: &[String]) {
1753        crate::db::add_session_sources(db.conn(), session_id, url_norms).unwrap();
1754    }
1755
1756    fn db_state(db: &Db) -> Vec<crate::db::SyncState> {
1757        db.load_sync_state().unwrap()
1758    }
1759
1760    // ── types / registry ──
1761
1762    #[test]
1763    fn changeset_serde_roundtrip() {
1764        let cs = Changeset {
1765            device_id: "dev-1".to_string(),
1766            device_name: "laptop".to_string(),
1767            ack: Some(vec![PeerCursor {
1768                peer_id: "dev-2".to_string(),
1769                table_name: "sessions".to_string(),
1770                cursor: "2026-01-01T00:00:00Z|id".to_string(),
1771            }]),
1772            rows: vec![RowChange {
1773                table: "sessions".to_string(),
1774                row: serde_json::json!({"id": "s1", "title": "t"}),
1775            }],
1776            tombstones: vec![Tombstone {
1777                origin_id: 3,
1778                table_name: "sessions".to_string(),
1779                row_id: "s1".to_string(),
1780                deleted_at: "2026-01-02T00:00:00Z".to_string(),
1781            }],
1782            files: vec![FileChange {
1783                space_id: "sp".to_string(),
1784                name: "f.txt".to_string(),
1785                hash: "abc".to_string(),
1786                size: 3,
1787            }],
1788            generated_at: "2026-01-03T00:00:00Z".to_string(),
1789        };
1790        let json = serde_json::to_string(&cs).unwrap();
1791        let back: Changeset = serde_json::from_str(&json).unwrap();
1792        assert_eq!(cs, back);
1793    }
1794
1795    #[test]
1796    fn cursor_positions_compare_table_aware() {
1797        // AUTOINCREMENT cursors are numeric: "10" > "9" as ids.
1798        assert!(position_gt("citations", "10", "9"));
1799        assert!(position_gt("sync_tombstones", "10", "9"));
1800        // RFC3339 cursors compare lexically.
1801        assert!(position_gt(
1802            "sessions",
1803            "2026-02-01T00:00:00Z",
1804            "2026-01-01T00:00:00Z"
1805        ));
1806        assert!(!position_gt(
1807            "sessions",
1808            "2026-01-01T00:00:00Z",
1809            "2026-01-01T00:00:00Z"
1810        ));
1811    }
1812
1813    #[test]
1814    fn unsafe_components_are_rejected() {
1815        assert!(valid_component("notes.txt"));
1816        assert!(!valid_component(""));
1817        assert!(!valid_component("."));
1818        assert!(!valid_component(".."));
1819        assert!(!valid_component("a/b"));
1820        assert!(!valid_component("a\\b"));
1821        assert!(!valid_component("a\0b"));
1822    }
1823
1824    // ── export / cursor rules ──
1825
1826    #[test]
1827    fn cold_start_full_export_covers_every_table() {
1828        let pair = Pair::new();
1829        let a = &pair.a;
1830        let s = session(a, "hello");
1831        a.add_user_message(&s, "hi").unwrap();
1832        a.log_usage(
1833            "openrouter",
1834            "m",
1835            1,
1836            2,
1837            0,
1838            0,
1839            Some(0.1),
1840            true,
1841            Some(&s),
1842            None,
1843        )
1844        .unwrap();
1845        a.add_citations(&space(a, "default"), "r.md", &[("https://x".into(), None)])
1846            .unwrap();
1847        add_session_sources(a, &s, &["https://x".to_string()]);
1848        a.create_watch(&space(a, "default"), "topic", 24, &s)
1849            .unwrap();
1850        a.set_reasoning("m", Some("high")).unwrap();
1851        a.set_setting("theme", "dark").unwrap();
1852        // A real space (with a version) — the default space has no
1853        // `updated_at`, so it never exports; its id/name are deterministic
1854        // on every device instead.
1855        a.create_space("work").unwrap();
1856        let persona = crate::db::Persona {
1857            name: "p".to_string(),
1858            model: "m".to_string(),
1859            blurb: "b".to_string(),
1860        };
1861        a.save_swarm_personas(&s, &[persona]).unwrap();
1862        a.upsert_file(&space(a, "default"), "f.txt", "deadbeef", 3, "ok")
1863            .unwrap();
1864
1865        let cs = build_changeset(a, None, "a").unwrap();
1866        let tables: HashSet<&str> = cs.rows.iter().map(|r| r.table.as_str()).collect();
1867        for expected in [
1868            "sessions",
1869            "swarm_personas",
1870            "model_prefs",
1871            "spaces",
1872            "files",
1873            "watches",
1874            "app_settings",
1875            "session_sources",
1876            "messages",
1877            "usage_log",
1878            "citations",
1879        ] {
1880            assert!(tables.contains(expected), "missing {expected}");
1881        }
1882        // The file manifest matches the exported file row.
1883        assert_eq!(cs.files.len(), 1);
1884        assert_eq!(cs.files[0].space_id, space(a, "default"));
1885        assert_eq!(cs.files[0].name, "f.txt");
1886        assert_eq!(cs.files[0].hash, "deadbeef");
1887        assert_eq!(cs.files[0].size, 3);
1888        // The persona rides attached to its session.
1889        let personas: Vec<_> = cs
1890            .rows
1891            .iter()
1892            .filter(|r| r.table == "swarm_personas")
1893            .collect();
1894        assert_eq!(personas.len(), 1);
1895        assert_eq!(personas[0].row["session_id"], s);
1896        assert_eq!(personas[0].row["name"], "p");
1897        assert!(cs.ack.is_none());
1898        // Nothing local-scope leaks out.
1899        assert!(
1900            !cs.rows
1901                .iter()
1902                .any(|r| r.table == "app_settings" && r.row["scope"] == "local")
1903        );
1904    }
1905
1906    #[test]
1907    fn export_resumes_past_acked_cursor_only() {
1908        let pair = Pair::new();
1909        let a = &pair.a;
1910        let s1 = session(a, "one");
1911        let cs1 = build_changeset(a, None, "a").unwrap();
1912        assert!(!cs1.rows.is_empty());
1913
1914        // No ack yet: the same rows re-export (the idempotent backstop).
1915        let cs_again = build_changeset(a, Some("peer-x"), "a").unwrap();
1916        assert_eq!(cs_again.rows.len(), cs1.rows.len());
1917
1918        // A "peer-x" ack of the current position silences the resends.
1919        let sess_pos = cs1
1920            .rows
1921            .iter()
1922            .find(|r| r.table == "sessions" && r.row["id"] == s1)
1923            .map(|r| r.row["updated_at"].as_str().unwrap().to_string())
1924            .unwrap();
1925        a.set_sync_state("peer-x", "sessions", None, Some(&sess_pos))
1926            .unwrap();
1927        let cs2 = build_changeset(a, Some("peer-x"), "a").unwrap();
1928        assert!(!cs2.rows.iter().any(|r| r.table == "sessions"));
1929        // Newer rows still flow.
1930        let _s2 = session(a, "two");
1931        let cs3 = build_changeset(a, Some("peer-x"), "a").unwrap();
1932        assert!(
1933            cs3.rows
1934                .iter()
1935                .any(|r| r.table == "sessions" && r.row["title"] == "two")
1936        );
1937        let _ = s1;
1938    }
1939
1940    // ── LWW ──
1941
1942    #[test]
1943    fn lww_newer_wins_older_loses() {
1944        let pair = Pair::new();
1945        let a = &pair.a;
1946        let b = &pair.b;
1947        let sa = session(a, "from a");
1948        let sb = session(b, "from b");
1949        // Same logical session on both devices — same id, so the merge is
1950        // a same-row LWW; A's version is newer.
1951        b.conn()
1952            .execute("UPDATE sessions SET id = ?1 WHERE id = ?2", (&sa, &sb))
1953            .unwrap();
1954        set_session_updated(a, &sa, "2026-02-01T00:00:00Z");
1955        set_session_updated(b, &sa, "2026-01-01T00:00:00Z");
1956        pair.exchange_ab();
1957        let title = |db: &Db| db.get_session(&sa).unwrap().unwrap().title.clone();
1958        assert_eq!(title(a), "from a");
1959        assert_eq!(title(b), "from a");
1960        // The older row arriving later changes nothing.
1961        let cs = build_changeset(b, None, "b").unwrap();
1962        let _ = apply_changeset(&pair.b, &pair.space_b(), &cs, None).unwrap();
1963        assert_eq!(title(a), "from a");
1964    }
1965
1966    fn set_session_updated(db: &Db, id: &str, at: &str) {
1967        set_updated(db, "sessions", id, at);
1968    }
1969
1970    #[test]
1971    fn equal_timestamp_keeps_local_and_is_stable() {
1972        let pair = Pair::new();
1973        let a = &pair.a;
1974        let b = &pair.b;
1975        let sa = session(a, "a's title");
1976        let sb = session(b, "b's title");
1977        let sid = sa.clone();
1978        b.conn()
1979            .execute("UPDATE sessions SET id = ?1 WHERE id = ?2", (&sid, &sb))
1980            .unwrap();
1981        // Same row, same nanosecond, different content — the clock-skew
1982        // residual. Both sides keep their own copy, and re-imports never
1983        // flip-flop.
1984        set_session_updated(a, &sid, "2026-01-01T00:00:00Z");
1985        set_session_updated(b, &sid, "2026-01-01T00:00:00Z");
1986        let title_a = a.get_session(&sid).unwrap().unwrap().title.clone();
1987        let title_b = b.get_session(&sid).unwrap().unwrap().title.clone();
1988        pair.exchange();
1989        assert_eq!(a.get_session(&sid).unwrap().unwrap().title, title_a);
1990        assert_eq!(b.get_session(&sid).unwrap().unwrap().title, title_b);
1991        pair.exchange();
1992        assert_eq!(a.get_session(&sid).unwrap().unwrap().title, title_a);
1993        assert_eq!(b.get_session(&sid).unwrap().unwrap().title, title_b);
1994    }
1995
1996    #[test]
1997    fn clock_skew_loser_converges() {
1998        let pair = Pair::new();
1999        let a = &pair.a;
2000        let b = &pair.b;
2001        let sa = session(a, "fast clock");
2002        let sb = session(b, "slow clock");
2003        let sid = sa.clone();
2004        b.conn()
2005            .execute("UPDATE sessions SET id = ?1 WHERE id = ?2", (&sid, &sb))
2006            .unwrap();
2007        // B's clock is behind: its version of the row is the loser.
2008        set_session_updated(a, &sid, "2026-02-01T00:00:00Z");
2009        set_session_updated(b, &sid, "2026-01-01T00:00:00Z");
2010        pair.exchange();
2011        assert_eq!(a.get_session(&sid).unwrap().unwrap().title, "fast clock");
2012        assert_eq!(b.get_session(&sid).unwrap().unwrap().title, "fast clock");
2013    }
2014
2015    #[test]
2016    fn scope_local_setting_never_syncs_or_applies() {
2017        let pair = Pair::new();
2018        let a = &pair.a;
2019        a.set_setting("searxng_url", "http://localhost:8888")
2020            .unwrap();
2021        a.set_setting("theme", "dark").unwrap();
2022        let cs = build_changeset(a, None, "a").unwrap();
2023        let keys: Vec<&str> = cs
2024            .rows
2025            .iter()
2026            .filter(|r| r.table == "app_settings")
2027            .map(|r| r.row["key"].as_str().unwrap())
2028            .collect();
2029        assert_eq!(keys, vec!["theme"]);
2030        // A smuggled local-scope row is refused on apply.
2031        let mut evil = cs.clone();
2032        evil.rows.push(RowChange {
2033            table: "app_settings".to_string(),
2034            row: serde_json::json!({
2035                "key": "searxng_url", "value": "http://evil", "scope": "local",
2036                "updated_at": "2099-01-01T00:00:00Z",
2037            }),
2038        });
2039        let (summary, _) = apply_changeset(&pair.b, &pair.space_b(), &evil, None).unwrap();
2040        assert!(summary.rows_skipped >= 1);
2041        let settings = pair.b.load_settings().unwrap();
2042        assert!(!settings.iter().any(|(k, _)| k == "searxng_url"));
2043    }
2044
2045    // ── append-only union + dedupe ──
2046
2047    #[test]
2048    fn messages_union_dedupes_by_id() {
2049        let pair = Pair::new();
2050        let a = &pair.a;
2051        let b = &pair.b;
2052        let sa = session(a, "shared");
2053        // B gets the same session via sync.
2054        let cs = build_changeset(a, None, "a").unwrap();
2055        let _ = apply_changeset(b, &pair.space_b(), &cs, None).unwrap();
2056        a.add_user_message(&sa, "from a").unwrap();
2057        let sb = b.get_session(&sa).unwrap().unwrap().id;
2058        b.add_user_message(&sb, "from b").unwrap();
2059        pair.exchange();
2060        let count = |db: &Db| db.load_messages(&sa).unwrap().len();
2061        assert_eq!(count(a), 2);
2062        assert_eq!(count(b), 2);
2063        // Re-exchange: nothing new moves.
2064        let (sa2, _) = pair.exchange_ab();
2065        assert_eq!(count(a), 2);
2066        assert_eq!(count(b), 2);
2067        assert_eq!(sa2.rows_applied, 0);
2068    }
2069
2070    #[test]
2071    fn usage_and_citations_dedupe_by_sync_id() {
2072        let pair = Pair::new();
2073        let a = &pair.a;
2074        let b = &pair.b;
2075        let s = session(a, "shared");
2076        a.log_usage("openrouter", "m", 1, 2, 0, 0, None, false, Some(&s), None)
2077            .unwrap();
2078        let cs = build_changeset(a, None, "a").unwrap();
2079        let _ = apply_changeset(b, &pair.space_b(), &cs, None).unwrap();
2080        b.log_usage("openrouter", "m", 3, 4, 0, 0, None, false, Some(&s), None)
2081            .unwrap();
2082        a.add_citations(&space(a, "default"), "r.md", &[("https://a".into(), None)])
2083            .unwrap();
2084        let cs2 = build_changeset(a, None, "a").unwrap();
2085        let _ = apply_changeset(b, &pair.space_b(), &cs2, None).unwrap();
2086        b.add_citations(&space(b, "default"), "r2.md", &[("https://b".into(), None)])
2087            .unwrap();
2088        pair.exchange();
2089        let usage = |db: &Db| {
2090            db.conn()
2091                .query_row("SELECT COUNT(*) FROM usage_log", [], |r| r.get::<_, i64>(0))
2092                .unwrap()
2093        };
2094        let cites = |db: &Db| {
2095            db.conn()
2096                .query_row("SELECT COUNT(*) FROM citations", [], |r| r.get::<_, i64>(0))
2097                .unwrap()
2098        };
2099        assert_eq!(usage(a), 2);
2100        assert_eq!(usage(b), 2);
2101        assert_eq!(cites(a), 2);
2102        assert_eq!(cites(b), 2);
2103        // sync_ids are unique on both sides.
2104        for db in [a, b] {
2105            let dupes: i64 = db
2106                .conn()
2107                .query_row(
2108                    "SELECT COUNT(*) FROM (SELECT sync_id FROM usage_log UNION ALL \
2109                     SELECT sync_id FROM citations) GROUP BY sync_id HAVING COUNT(*) > 1",
2110                    [],
2111                    |r| r.get(0),
2112                )
2113                .unwrap_or(0);
2114            assert_eq!(dupes, 0);
2115        }
2116    }
2117
2118    // ── convergence ──
2119
2120    #[test]
2121    fn two_way_convergence_and_then_silence() {
2122        let pair = Pair::new();
2123        let a = &pair.a;
2124        let b = &pair.b;
2125        // Independent work on both devices.
2126        let sa = session(a, "a's chat");
2127        a.add_user_message(&sa, "from a").unwrap();
2128        a.set_setting("theme", "dark").unwrap();
2129        a.set_reasoning("m1", Some("high")).unwrap();
2130        a.log_usage("openrouter", "m1", 1, 2, 0, 0, None, false, Some(&sa), None)
2131            .unwrap();
2132        let sb = session(b, "b's chat");
2133        b.add_user_message(&sb, "from b").unwrap();
2134        b.add_user_message(&sb, "and another").unwrap();
2135        b.create_watch(&space(b, "default"), "topic", 24, &sb)
2136            .unwrap();
2137
2138        pair.exchange();
2139        assert_eq!(dump(a), dump(b), "devices must converge");
2140
2141        // A second round with no new work moves nothing.
2142        let (sa2, sb2) = pair.exchange_ab();
2143        assert_eq!(sa2.rows_applied, 0);
2144        assert_eq!(sb2.rows_applied, 0);
2145        assert_eq!(dump(a), dump(b));
2146    }
2147
2148    #[test]
2149    fn idempotent_reimport_is_a_noop() {
2150        let pair = Pair::new();
2151        let a = &pair.a;
2152        let b = &pair.b;
2153        let s = session(a, "hello");
2154        a.add_user_message(&s, "hi").unwrap();
2155        a.log_usage("openrouter", "m", 1, 2, 0, 0, None, false, Some(&s), None)
2156            .unwrap();
2157        let cs = build_changeset(a, None, "a").unwrap();
2158        let (first, _) = apply_changeset(b, &pair.space_b(), &cs, None).unwrap();
2159        assert!(first.rows_applied > 0);
2160        let (again, _) = apply_changeset(b, &pair.space_b(), &cs, None).unwrap();
2161        assert_eq!(again.rows_applied, 0);
2162        assert_eq!(again.tombstones_applied, 0);
2163        assert!(again.warnings.is_empty());
2164    }
2165
2166    #[test]
2167    fn session_sources_lww_flag_propagates() {
2168        let pair = Pair::new();
2169        let a = &pair.a;
2170        let b = &pair.b;
2171        let s = session(a, "s");
2172        add_session_sources(a, &s, &["https://x".to_string()]);
2173        let cs = build_changeset(a, None, "a").unwrap();
2174        let _ = apply_changeset(b, &pair.space_b(), &cs, None).unwrap();
2175        a.set_source_flag(&s, "https://x", Some("pinned")).unwrap();
2176        pair.exchange_ab();
2177        let flags: Vec<(String, String)> = b
2178            .conn()
2179            .prepare("SELECT session_id, flag FROM session_sources")
2180            .unwrap()
2181            .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
2182            .unwrap()
2183            .collect::<rusqlite::Result<Vec<_>>>()
2184            .unwrap();
2185        assert_eq!(flags, vec![(s, "pinned".to_string())]);
2186    }
2187
2188    // ── tombstones ──
2189
2190    #[test]
2191    fn session_tombstone_cascades_messages_and_sources() {
2192        let pair = Pair::new();
2193        let a = &pair.a;
2194        let b = &pair.b;
2195        let s = session(a, "doomed");
2196        a.add_user_message(&s, "one").unwrap();
2197        add_session_sources(a, &s, &["https://x".to_string()]);
2198        pair.exchange_ab();
2199        assert_eq!(dump(a), dump(b));
2200        a.delete_session(&s).unwrap();
2201        pair.exchange_ab();
2202        assert_eq!(dump(a), dump(b));
2203        let sessions = b.list_sessions(&space(b, "default")).unwrap();
2204        assert!(sessions.iter().all(|s| s.title != "doomed"));
2205        let messages: i64 = b
2206            .conn()
2207            .query_row("SELECT COUNT(*) FROM messages", [], |r| r.get(0))
2208            .unwrap();
2209        assert_eq!(messages, 0);
2210        let sources: i64 = b
2211            .conn()
2212            .query_row("SELECT COUNT(*) FROM session_sources", [], |r| r.get(0))
2213            .unwrap();
2214        assert_eq!(sources, 0);
2215    }
2216
2217    #[test]
2218    fn space_tombstone_removes_row_and_dir() {
2219        let pair = Pair::new();
2220        let a = &pair.a;
2221        let b = &pair.b;
2222        let sp = a.create_space("work").unwrap();
2223        pair.space_a().ensure_space_dir("work").unwrap();
2224        pair.space_b().ensure_space_dir("work").unwrap();
2225        write_blob(&pair.space_b(), "work", "f.txt", b"content");
2226        pair.exchange_ab();
2227        assert!(pair.space_b().files_dir("work").join("f.txt").exists());
2228        a.delete_space(&sp.id).unwrap();
2229        pair.space_a().remove_space_dir("work").unwrap();
2230        pair.exchange_ab();
2231        assert!(b.list_spaces().unwrap().iter().all(|s| s.name != "work"));
2232        assert!(!pair.space_b().space_dir("work").exists());
2233    }
2234
2235    #[test]
2236    fn file_tombstone_removes_row_and_blob() {
2237        let pair = Pair::new();
2238        let a = &pair.a;
2239        let b = &pair.b;
2240        let sid = space(a, "default");
2241        write_blob(&pair.space_a(), "default", "f.txt", b"content");
2242        let hash = sha256_hex(b"content");
2243        let fid = a.upsert_file(&sid, "f.txt", &hash, 7, "ok").unwrap();
2244        pair.exchange_ab();
2245        assert!(pair.space_b().files_dir("default").join("f.txt").exists());
2246        a.delete_file(&fid).unwrap();
2247        std::fs::remove_file(pair.space_a().files_dir("default").join("f.txt")).unwrap();
2248        pair.exchange_ab();
2249        assert_eq!(dump(a), dump(b));
2250        let files = b.list_files(&sid).unwrap();
2251        assert!(files.is_empty());
2252        assert!(!pair.space_b().files_dir("default").join("f.txt").exists());
2253    }
2254
2255    #[test]
2256    fn default_space_tombstone_is_ignored() {
2257        let pair = Pair::new();
2258        let a = &pair.a;
2259        let mut evil = build_changeset(a, None, "a").unwrap();
2260        evil.tombstones = vec![Tombstone {
2261            origin_id: 1,
2262            table_name: "spaces".to_string(),
2263            row_id: DEFAULT_SPACE.to_string(),
2264            deleted_at: "2026-01-01T00:00:00Z".to_string(),
2265        }];
2266        let (summary, _) = apply_changeset(&pair.b, &pair.space_b(), &evil, None).unwrap();
2267        assert_eq!(summary.tombstones_applied, 0);
2268        assert!(pair.b.default_space_id().is_ok());
2269    }
2270
2271    #[test]
2272    fn tombstone_cursor_advances_and_no_resends() {
2273        let pair = Pair::new();
2274        let a = &pair.a;
2275        let b = &pair.b;
2276        let s = session(a, "doomed");
2277        pair.exchange_ab();
2278        a.delete_session(&s).unwrap();
2279        pair.exchange_ab();
2280        // The tombstone is acked: nothing re-sends.
2281        let cs = build_changeset(a, Some(&b.device_id().unwrap()), "a").unwrap();
2282        assert!(cs.tombstones.is_empty());
2283        // And B's pull cursor for tombstones advanced — its ack included it.
2284        let state = db_state(b);
2285        assert!(state.iter().any(|st| {
2286            st.peer_id == a.device_id().unwrap()
2287                && st.table_name == "sync_tombstones"
2288                && st.pull_cursor.is_some()
2289        }));
2290    }
2291
2292    // ── swarm personas ──
2293
2294    #[test]
2295    fn swarm_roster_follows_winning_session() {
2296        let pair = Pair::new();
2297        let a = &pair.a;
2298        let b = &pair.b;
2299        let s = session(a, "roundtable");
2300        let p1 = crate::db::Persona {
2301            name: "p1".to_string(),
2302            model: "m".to_string(),
2303            blurb: "b".to_string(),
2304        };
2305        a.save_swarm_personas(&s, &[p1]).unwrap();
2306        // B gets everything (no acks yet — exports are full either way).
2307        let cs = build_changeset(a, None, "a").unwrap();
2308        let _ = apply_changeset(b, &pair.space_b(), &cs, None).unwrap();
2309        // B's roster v2 is newer (t2).
2310        let p2 = crate::db::Persona {
2311            name: "p2".to_string(),
2312            model: "m".to_string(),
2313            blurb: "b".to_string(),
2314        };
2315        let p3 = crate::db::Persona {
2316            name: "p3".to_string(),
2317            model: "m".to_string(),
2318            blurb: "b".to_string(),
2319        };
2320        b.save_swarm_personas(&s, &[p2, p3]).unwrap();
2321        let t2 = session_updated(b, &s);
2322        // A's stale roster v1b sits between: newer than v1, older than v2.
2323        let p1b = crate::db::Persona {
2324            name: "p1".to_string(),
2325            model: "m".to_string(),
2326            blurb: "b".to_string(),
2327        };
2328        a.save_swarm_personas(&s, &[p1b]).unwrap();
2329        set_session_updated(a, &s, &minus_one_second(&t2));
2330        // Full exchange: B's v2 wins everywhere; A's stale roster (and
2331        // both sides' persona tombstones) can't clobber it.
2332        let cs_a = build_changeset(a, None, "a").unwrap();
2333        let (_, cursors) = apply_changeset(b, &pair.space_b(), &cs_a, None).unwrap();
2334        let mut reply = build_changeset(b, Some(&cs_a.device_id), "b").unwrap();
2335        reply.ack = Some(cursors);
2336        let _ = apply_changeset(a, &pair.space_a(), &reply, None).unwrap();
2337        let names = |db: &Db| -> Vec<String> {
2338            db.list_swarm_personas(&s)
2339                .unwrap()
2340                .iter()
2341                .map(|p| p.name.clone())
2342                .collect()
2343        };
2344        assert_eq!(names(a), vec!["p2", "p3"]);
2345        assert_eq!(names(b), vec!["p2", "p3"]);
2346    }
2347
2348    // ── files / blobs ──
2349
2350    #[test]
2351    fn file_blobs_transfer_keep_and_report_missing() {
2352        let pair = Pair::new();
2353        let a = &pair.a;
2354        let sid = space(a, "default");
2355        write_blob(&pair.space_a(), "default", "notes.txt", b"hello sync");
2356        let hash = sha256_hex(b"hello sync");
2357        a.upsert_file(&sid, "notes.txt", &hash, 11, "ok").unwrap();
2358
2359        // Import with no blob channel: the row lands, the blob is reported
2360        // missing.
2361        let cs = build_changeset(a, None, "a").unwrap();
2362        let (summary, _) = apply_changeset(&pair.b, &pair.space_b(), &cs, None).unwrap();
2363        assert_eq!(summary.files_missing.len(), 1);
2364        assert_eq!(summary.files_missing[0].name, "notes.txt");
2365        assert!(
2366            !pair
2367                .space_b()
2368                .files_dir("default")
2369                .join("notes.txt")
2370                .exists()
2371        );
2372
2373        // The same changeset re-imported with a channel: the row loses LWW
2374        // (idempotent), so nothing is pulled or reported.
2375        export_blobs(a, &pair.space_a(), &cs, &pair.dir).unwrap();
2376        let (summary2, _) =
2377            apply_changeset(&pair.b, &pair.space_b(), &cs, Some(&pair.dir)).unwrap();
2378        assert_eq!(summary2.files_pulled, 0);
2379        assert!(summary2.files_missing.is_empty());
2380        assert!(
2381            !pair
2382                .space_b()
2383                .files_dir("default")
2384                .join("notes.txt")
2385                .exists()
2386        );
2387
2388        // A newer version of the file re-wins the row and pulls its blob,
2389        // hash-verified.
2390        write_blob(&pair.space_a(), "default", "notes.txt", b"hello sync v2");
2391        let hash2 = sha256_hex(b"hello sync v2");
2392        a.upsert_file(&sid, "notes.txt", &hash2, 13, "ok").unwrap();
2393        let cs2 = build_changeset(a, None, "a").unwrap();
2394        export_blobs(a, &pair.space_a(), &cs2, &pair.dir).unwrap();
2395        let (summary3, _) =
2396            apply_changeset(&pair.b, &pair.space_b(), &cs2, Some(&pair.dir)).unwrap();
2397        assert_eq!(summary3.files_pulled, 1);
2398        assert!(summary3.files_missing.is_empty());
2399        assert_eq!(
2400            std::fs::read(pair.space_b().files_dir("default").join("notes.txt")).unwrap(),
2401            b"hello sync v2"
2402        );
2403    }
2404
2405    #[test]
2406    fn stale_blob_in_channel_is_never_applied() {
2407        let pair = Pair::new();
2408        let a = &pair.a;
2409        let sid = space(a, "default");
2410        write_blob(&pair.space_a(), "default", "f.txt", b"real");
2411        let hash = sha256_hex(b"real");
2412        a.upsert_file(&sid, "f.txt", &hash, 4, "ok").unwrap();
2413        let cs = build_changeset(a, None, "a").unwrap();
2414        // A stale payload with the right name but wrong content.
2415        let blob_dir = pair.dir.join("blobs").join(&sid);
2416        std::fs::create_dir_all(&blob_dir).unwrap();
2417        std::fs::write(blob_dir.join("f.txt"), b"stale").unwrap();
2418        let (summary, _) = apply_changeset(&pair.b, &pair.space_b(), &cs, Some(&pair.dir)).unwrap();
2419        assert_eq!(summary.files_pulled, 0);
2420        assert_eq!(summary.files_missing.len(), 1);
2421        assert!(!pair.space_b().files_dir("default").join("f.txt").exists());
2422    }
2423
2424    #[test]
2425    fn unsafe_blob_names_cannot_escape() {
2426        let pair = Pair::new();
2427        let b = &pair.b;
2428        let mut cs = build_changeset(b, None, "b").unwrap();
2429        cs.rows.push(RowChange {
2430            table: "files".to_string(),
2431            row: serde_json::json!({
2432                "id": "f1", "space_id": "sp", "name": "../escape.txt",
2433                "hash": "x", "size": 1,
2434                "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z",
2435            }),
2436        });
2437        cs.files.push(FileChange {
2438            space_id: "sp".to_string(),
2439            name: "../escape.txt".to_string(),
2440            hash: "x".to_string(),
2441            size: 1,
2442        });
2443        let (summary, _) = apply_changeset(&pair.a, &pair.space_a(), &cs, Some(&pair.dir)).unwrap();
2444        assert!(summary.warnings.iter().any(|w| w.contains("unsafe")));
2445        assert!(!pair.dir.join("blobs").join("sp").join("..").exists());
2446        assert!(!pair.a_root.join("escape.txt").exists());
2447    }
2448
2449    // ── spaces ──
2450
2451    #[test]
2452    fn default_space_is_the_same_row_on_both_devices() {
2453        let pair = Pair::new();
2454        assert_eq!(pair.a.default_space_id().unwrap(), DEFAULT_SPACE);
2455        assert_eq!(pair.b.default_space_id().unwrap(), DEFAULT_SPACE);
2456        pair.exchange_ab();
2457        let spaces = pair.b.list_spaces().unwrap();
2458        assert_eq!(spaces.len(), 1, "no name collision for the default space");
2459    }
2460
2461    #[test]
2462    fn space_name_collision_renames_loser_deterministically() {
2463        let pair = Pair::new();
2464        let a = &pair.a;
2465        let b = &pair.b;
2466        let wa = a.create_space("work").unwrap();
2467        let wb = b.create_space("work").unwrap();
2468        // Make the loser deterministic: B's space is older.
2469        set_updated(a, "spaces", &wa.id, "2026-02-01T00:00:00Z");
2470        set_updated(b, "spaces", &wb.id, "2026-01-01T00:00:00Z");
2471        pair.exchange();
2472        // Both devices end with the same two rows: the winner keeps "work",
2473        // the loser is renamed from its own id — identically on both sides.
2474        let names_a: Vec<String> = a
2475            .list_spaces()
2476            .unwrap()
2477            .iter()
2478            .map(|s| s.name.clone())
2479            .collect();
2480        let names_b: Vec<String> = b
2481            .list_spaces()
2482            .unwrap()
2483            .iter()
2484            .map(|s| s.name.clone())
2485            .collect();
2486        let expected = format!("work-{}", short_id(&wb.id));
2487        assert!(names_a.contains(&"work".to_string()));
2488        assert!(names_a.contains(&expected));
2489        assert_eq!(names_a, names_b);
2490        assert_eq!(dump(a), dump(b));
2491        // A later sync doesn't churn the names.
2492        pair.exchange();
2493        let names_a2: Vec<String> = a
2494            .list_spaces()
2495            .unwrap()
2496            .iter()
2497            .map(|s| s.name.clone())
2498            .collect();
2499        assert_eq!(names_a2, names_a);
2500    }
2501
2502    #[test]
2503    fn space_rename_moves_the_dir() {
2504        let pair = Pair::new();
2505        let a = &pair.a;
2506        let sp = a.create_space("work").unwrap();
2507        pair.space_a().ensure_space_dir("work").unwrap();
2508        pair.space_b().ensure_space_dir("work").unwrap();
2509        write_blob(&pair.space_b(), "work", "f.txt", b"content");
2510        pair.exchange_ab();
2511        assert!(pair.space_b().files_dir("work").join("f.txt").exists());
2512        a.rename_space(&sp.id, "work-2").unwrap();
2513        pair.space_a().rename_space_dir("work", "work-2").unwrap();
2514        pair.exchange_ab();
2515        assert!(
2516            pair.b
2517                .list_spaces()
2518                .unwrap()
2519                .iter()
2520                .any(|s| s.name == "work-2")
2521        );
2522        assert!(pair.space_b().files_dir("work-2").join("f.txt").exists());
2523        assert!(!pair.space_b().space_dir("work").exists());
2524    }
2525
2526    // ── bundles ──
2527
2528    #[test]
2529    fn bundle_roundtrip_carries_changeset_and_blobs() {
2530        let pair = Pair::new();
2531        let a = &pair.a;
2532        let sid = space(a, "default");
2533        write_blob(&pair.space_a(), "default", "f.txt", b"payload");
2534        let hash = sha256_hex(b"payload");
2535        a.upsert_file(&sid, "f.txt", &hash, 7, "ok").unwrap();
2536        let cs = build_changeset(a, None, "a").unwrap();
2537        let dest = pair.dir.join("out.bundle");
2538        let blobs = write_bundle(
2539            a,
2540            &pair.space_a(),
2541            &cs,
2542            std::fs::File::create(&dest).unwrap(),
2543        )
2544        .unwrap();
2545        assert_eq!(blobs, 1);
2546        let unpacked = pair.dir.join("unpacked");
2547        let back = unpack_bundle(&dest, &unpacked).unwrap();
2548        assert_eq!(back.device_id, cs.device_id);
2549        assert_eq!(back.rows.len(), cs.rows.len());
2550        assert_eq!(
2551            std::fs::read(unpacked.join("blobs").join(&sid).join("f.txt")).unwrap(),
2552            b"payload"
2553        );
2554    }
2555
2556    #[test]
2557    fn bundle_rejects_escaping_paths() {
2558        let pair = Pair::new();
2559        let dest = pair.dir.join("evil.bundle");
2560        let file = std::fs::File::create(&dest).unwrap();
2561        let mut zip = zip::ZipWriter::new(file);
2562        let opts = zip::write::SimpleFileOptions::default();
2563        zip.start_file("changeset.json", opts).unwrap();
2564        serde_json::to_writer(
2565            &mut zip,
2566            &Changeset {
2567                device_id: "x".to_string(),
2568                device_name: "x".to_string(),
2569                ack: None,
2570                rows: Vec::new(),
2571                tombstones: Vec::new(),
2572                files: Vec::new(),
2573                generated_at: "t".to_string(),
2574            },
2575        )
2576        .unwrap();
2577        zip.start_file("blobs/../escape.txt", opts).unwrap();
2578        zip.write_all(b"nope").unwrap();
2579        zip.finish().unwrap();
2580        let unpacked = pair.dir.join("evil-out");
2581        assert!(unpack_bundle(&dest, &unpacked).is_err());
2582        assert!(!pair.dir.join("escape.txt").exists());
2583    }
2584
2585    // ── ack plumbing ──
2586
2587    #[test]
2588    fn ack_built_from_pull_cursors_advances_peer_push() {
2589        let pair = Pair::new();
2590        let a = &pair.a;
2591        let b = &pair.b;
2592        let _s = session(a, "s");
2593        let cs = build_changeset(a, None, "a").unwrap();
2594        let (_, cursors) = apply_changeset(b, &pair.space_b(), &cs, None).unwrap();
2595        // B's reply is its export + the ack from the apply.
2596        let mut reply = build_changeset(b, Some(&cs.device_id), "b").unwrap();
2597        reply.ack = Some(cursors);
2598        let _ = apply_changeset(a, &pair.space_a(), &reply, None).unwrap();
2599        // A's next export carries `build_ack` for B (the ssh transport's
2600        // shape) — B applies it and its push cursors advance.
2601        let bid = b.device_id().unwrap();
2602        let mut next = build_changeset(a, Some(&bid), "a").unwrap();
2603        next.ack = Some(build_ack(a, &bid).unwrap());
2604        let (summary, _) = apply_changeset(b, &pair.space_b(), &next, None).unwrap();
2605        assert!(summary.acks_applied >= 1);
2606        // B's export for A is now silent.
2607        let after = build_changeset(b, Some(&a.device_id().unwrap()), "b").unwrap();
2608        assert!(after.rows.is_empty());
2609    }
2610
2611    #[test]
2612    fn acks_are_only_honored_for_this_device() {
2613        let pair = Pair::new();
2614        let a = &pair.a;
2615        let b = &pair.b;
2616        let _s = session(a, "s");
2617        pair.exchange_ab();
2618        // B's reply acked A; A's push cursor for B is set.
2619        let state = db_state(a);
2620        assert!(state.iter().any(|st| {
2621            st.peer_id == b.device_id().unwrap()
2622                && st.table_name == "sessions"
2623                && st.push_cursor.is_some()
2624        }));
2625        // A forged ack addressed to someone else is ignored.
2626        let forged = Changeset {
2627            device_id: b.device_id().unwrap(),
2628            device_name: "b".to_string(),
2629            ack: Some(vec![PeerCursor {
2630                peer_id: "someone-else".to_string(),
2631                table_name: "sessions".to_string(),
2632                cursor: "2099-01-01T00:00:00Z".to_string(),
2633            }]),
2634            rows: Vec::new(),
2635            tombstones: Vec::new(),
2636            files: Vec::new(),
2637            generated_at: "t".to_string(),
2638        };
2639        let (summary, _) = apply_changeset(a, &pair.space_a(), &forged, None).unwrap();
2640        assert_eq!(summary.acks_applied, 0);
2641    }
2642
2643    #[test]
2644    fn push_cursor_advances_only_on_ack() {
2645        let pair = Pair::new();
2646        let a = &pair.a;
2647        let b = &pair.b;
2648        let _s = session(a, "s");
2649        let bid = b.device_id().unwrap();
2650        // Export alone advances nothing.
2651        let _ = build_changeset(a, Some(&bid), "a").unwrap();
2652        let after_export = db_state(a);
2653        assert!(after_export.iter().all(|st| st.peer_id != bid));
2654        // B imports and acks; only then does A's push cursor move.
2655        let cs = build_changeset(a, None, "a").unwrap();
2656        let (_, cursors) = apply_changeset(b, &pair.space_b(), &cs, None).unwrap();
2657        let mut reply = build_changeset(b, Some(&cs.device_id), "b").unwrap();
2658        reply.ack = Some(cursors);
2659        let _ = apply_changeset(a, &pair.space_a(), &reply, None).unwrap();
2660        let state = db_state(a);
2661        assert!(state.iter().any(|st| {
2662            st.peer_id == bid && st.table_name == "sessions" && st.push_cursor.is_some()
2663        }));
2664    }
2665}