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