1#![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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
60pub struct Changeset {
61 pub device_id: String,
63 pub device_name: String,
65 pub ack: Option<Vec<PeerCursor>>,
68 pub rows: Vec<RowChange>,
70 pub tombstones: Vec<Tombstone>,
72 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 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 pub peer_id: String,
108 pub table_name: String,
109 pub cursor: String,
110}
111
112#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum Cursor {
131 UpdatedAt,
137 Tuple(&'static [&'static str]),
140 AutoId,
144 None,
147}
148
149pub struct TableSpec {
151 pub name: &'static str,
152 pub columns: &'static [&'static str],
154 pub apply_columns: &'static [&'static str],
158 pub pk: &'static [&'static str],
160 pub cursor: Cursor,
161}
162
163pub 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 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
371fn 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
386fn 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
406fn 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
424fn 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 !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
461fn 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
499fn 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
511pub 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
522pub 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
608pub 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
630fn 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 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
698fn 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
746fn short_id(id: &str) -> String {
749 id.chars().take(8).collect()
750}
751
752enum LwwOutcome {
755 Applied,
756 Skipped,
757 Warned(String),
758}
759
760fn 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
785fn lww_wins_against(id: &str, updated: &str, local_id: &str, local_updated: &str) -> bool {
788 (updated, id) > (local_updated, local_id)
789}
790
791#[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 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; }
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 failed_tables.insert(spec.name);
874 }
875 }
876 }
877 }
878 for table in failed_tables {
879 max_pos.remove(table);
880 }
881
882 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 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 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 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 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
1056fn 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 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
1142fn 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
1165fn 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 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
1265fn 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
1275fn 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 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 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
1369fn 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 "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
1404fn 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#[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 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 "swarm_personas" => Ok(false),
1586 _ => unreachable!("validated tombstone table"),
1587 }
1588}
1589
1590fn 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
1608fn 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
1618fn 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
1629pub 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
1675pub 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
1703fn 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
1711fn 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 Ok((path.is_file() && file_matches(&path, &fc.hash)).then_some(path))
1725}
1726
1727fn 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
1764pub 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 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
1788fn 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
1805pub 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
1841pub 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 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 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 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 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 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 #[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 assert!(position_gt("citations", "10", "9"));
2098 assert!(position_gt("sync_tombstones", "10", "9"));
2099 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 #[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.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 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 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 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 let cs_again = build_changeset(a, Some("peer-x"), "a").unwrap();
2244 assert_eq!(cs_again.rows.len(), cs1.rows.len());
2245
2246 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 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 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 #[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 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 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 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 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 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 #[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 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 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 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 #[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 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 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 #[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 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 let cs = build_changeset(a, Some(&b.device_id().unwrap()), "a").unwrap();
2679 assert!(cs.tombstones.is_empty());
2680 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 #[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 let cs = build_changeset(a, None, "a").unwrap();
2705 let _ = apply_changeset(b, &pair.space_b(), &cs, None).unwrap();
2706 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 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 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 #[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 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 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 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 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 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 #[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 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 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 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 #[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 #[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 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 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 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 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 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 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 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}