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,
136 Tuple(&'static [&'static str]),
139 AutoId,
143 None,
146}
147
148pub struct TableSpec {
150 pub name: &'static str,
151 pub columns: &'static [&'static str],
153 pub apply_columns: &'static [&'static str],
157 pub pk: &'static [&'static str],
159 pub cursor: Cursor,
160}
161
162pub const TABLES: &[TableSpec] = &[
165 TableSpec {
166 name: "sessions",
167 columns: &[
168 "id",
169 "title",
170 "model",
171 "slug",
172 "space_id",
173 "compact_summary",
174 "compact_through",
175 "web_mode",
176 "swarm_mode",
177 "kind",
178 "research_parent_id",
179 "created_at",
180 "updated_at",
181 ],
182 apply_columns: &[
183 "id",
184 "title",
185 "model",
186 "slug",
187 "space_id",
188 "compact_summary",
189 "compact_through",
190 "web_mode",
191 "swarm_mode",
192 "kind",
193 "research_parent_id",
194 "created_at",
195 "updated_at",
196 ],
197 pk: &["id"],
198 cursor: Cursor::UpdatedAt,
199 },
200 TableSpec {
201 name: "swarm_personas",
202 columns: &["session_id", "ord", "name", "model", "persona"],
203 apply_columns: &["session_id", "ord", "name", "model", "persona"],
204 pk: &["session_id", "ord"],
205 cursor: Cursor::None,
206 },
207 TableSpec {
208 name: "model_prefs",
209 columns: &["id", "favorite", "last_used", "reasoning", "updated_at"],
210 apply_columns: &["id", "favorite", "last_used", "reasoning", "updated_at"],
211 pk: &["id"],
212 cursor: Cursor::UpdatedAt,
213 },
214 TableSpec {
215 name: "spaces",
216 columns: &["id", "name", "created_at", "updated_at"],
217 apply_columns: &["id", "name", "created_at", "updated_at"],
218 pk: &["id"],
219 cursor: Cursor::UpdatedAt,
220 },
221 TableSpec {
222 name: "files",
223 columns: &[
224 "id",
225 "space_id",
226 "name",
227 "hash",
228 "size",
229 "created_at",
230 "updated_at",
231 ],
232 apply_columns: &[
233 "id",
234 "space_id",
235 "name",
236 "hash",
237 "size",
238 "created_at",
239 "updated_at",
240 ],
241 pk: &["id"],
242 cursor: Cursor::UpdatedAt,
243 },
244 TableSpec {
245 name: "watches",
246 columns: &[
247 "id",
248 "space_id",
249 "topic",
250 "interval_hours",
251 "session_id",
252 "last_run_at",
253 "updated_at",
254 ],
255 apply_columns: &[
256 "id",
257 "space_id",
258 "topic",
259 "interval_hours",
260 "session_id",
261 "last_run_at",
262 "updated_at",
263 ],
264 pk: &["id"],
265 cursor: Cursor::UpdatedAt,
266 },
267 TableSpec {
268 name: "app_settings",
269 columns: &["key", "value", "scope", "updated_at"],
270 apply_columns: &["key", "value", "scope", "updated_at"],
271 pk: &["key"],
272 cursor: Cursor::UpdatedAt,
273 },
274 TableSpec {
275 name: "session_sources",
276 columns: &["session_id", "url_norm", "flag", "updated_at"],
277 apply_columns: &["session_id", "url_norm", "flag", "updated_at"],
278 pk: &["session_id", "url_norm"],
279 cursor: Cursor::UpdatedAt,
280 },
281 TableSpec {
282 name: "messages",
283 columns: &[
284 "id",
285 "session_id",
286 "role",
287 "content",
288 "model",
289 "reasoning",
290 "tokens",
291 "secs",
292 "cost",
293 "phrase",
294 "persona",
295 "created_at",
296 ],
297 apply_columns: &[
298 "id",
299 "session_id",
300 "role",
301 "content",
302 "model",
303 "reasoning",
304 "tokens",
305 "secs",
306 "cost",
307 "phrase",
308 "persona",
309 "created_at",
310 ],
311 pk: &["id"],
312 cursor: Cursor::Tuple(&["created_at", "id"]),
313 },
314 TableSpec {
315 name: "usage_log",
316 columns: &[
317 "sync_id",
318 "created_at",
319 "session_id",
320 "space_id",
321 "backend",
322 "model",
323 "prompt_tokens",
324 "completion_tokens",
325 "cache_read_tokens",
326 "cache_creation_tokens",
327 "cost",
328 "cost_is_provider",
329 "updated_at",
330 ],
331 apply_columns: &[
332 "sync_id",
333 "created_at",
334 "session_id",
335 "space_id",
336 "backend",
337 "model",
338 "prompt_tokens",
339 "completion_tokens",
340 "cache_read_tokens",
341 "cache_creation_tokens",
342 "cost",
343 "cost_is_provider",
344 "updated_at",
345 ],
346 pk: &["sync_id"],
347 cursor: Cursor::Tuple(&["created_at", "sync_id"]),
348 },
349 TableSpec {
350 name: "citations",
351 columns: &["id", "sync_id", "space_id", "report_file", "url", "title"],
354 apply_columns: &["sync_id", "space_id", "report_file", "url", "title"],
355 pk: &["sync_id"],
356 cursor: Cursor::AutoId,
357 },
358];
359
360const SYNC_TOMBSTONES: &str = "sync_tombstones";
361
362fn spec_for(table: &str) -> Option<&'static TableSpec> {
363 TABLES.iter().find(|s| s.name == table)
364}
365
366fn known_table(table: &str) -> bool {
367 table == SYNC_TOMBSTONES || spec_for(table).is_some()
368}
369
370fn position_gt(table: &str, a: &str, b: &str) -> bool {
375 if table == "citations" || table == SYNC_TOMBSTONES {
376 let n = |s: &str| s.parse::<i64>().unwrap_or(0);
377 n(a) > n(b)
378 } else {
379 a > b
380 }
381}
382
383fn row_position(spec: &TableSpec, row: &serde_json::Value) -> String {
386 match spec.cursor {
387 Cursor::UpdatedAt => row
388 .get("updated_at")
389 .and_then(serde_json::Value::as_str)
390 .unwrap_or_default()
391 .to_string(),
392 Cursor::Tuple(cols) => cols
393 .iter()
394 .map(|c| {
395 row.get(*c)
396 .and_then(serde_json::Value::as_str)
397 .unwrap_or_default()
398 })
399 .collect::<Vec<_>>()
400 .join("|"),
401 Cursor::AutoId => row
402 .get("id")
403 .and_then(serde_json::Value::as_i64)
404 .map_or_else(|| "0".to_string(), |i| i.to_string()),
405 Cursor::None => String::new(),
406 }
407}
408
409fn valid_component(name: &str) -> bool {
413 !name.is_empty()
414 && name != "."
415 && name != ".."
416 && !name.contains('/')
417 && !name.contains('\\')
418 && !name.contains('\0')
419}
420
421pub fn device_name() -> String {
423 if let Ok(n) = std::env::var("NEXUS_DEVICE_NAME")
424 && !n.trim().is_empty()
425 {
426 return n;
427 }
428 std::fs::read_to_string("/etc/hostname")
429 .map_or_else(|_| "nexus-device".to_string(), |s| s.trim().to_string())
430}
431
432pub fn build_changeset(db: &Db, peer_id: Option<&str>, name: &str) -> Result<Changeset> {
439 let device_id = db.device_id()?;
440 let mut cs = Changeset {
441 device_id,
442 device_name: name.to_string(),
443 ack: None,
444 rows: Vec::new(),
445 tombstones: Vec::new(),
446 files: Vec::new(),
447 generated_at: chrono::Utc::now().to_rfc3339(),
448 };
449 let states = db.load_sync_state()?;
450 let push_cursor = |table: &str| {
451 peer_id.and_then(|p| {
452 states
453 .iter()
454 .find(|s| s.peer_id == p && s.table_name == table)
455 .and_then(|s| s.push_cursor.clone())
456 })
457 };
458 for spec in TABLES {
459 if spec.cursor == Cursor::None {
460 continue;
461 }
462 let rows = select_rows(db, spec, push_cursor(spec.name).as_deref())?;
463 for row in rows {
464 if spec.name == "sessions" {
465 for persona in select_personas(db, &row)? {
466 cs.rows.push(RowChange {
467 table: "swarm_personas".to_string(),
468 row: persona,
469 });
470 }
471 }
472 if spec.name == "files" {
473 cs.files.push(FileChange {
474 space_id: row
475 .get("space_id")
476 .and_then(serde_json::Value::as_str)
477 .unwrap_or_default()
478 .to_string(),
479 name: row
480 .get("name")
481 .and_then(serde_json::Value::as_str)
482 .unwrap_or_default()
483 .to_string(),
484 hash: row
485 .get("hash")
486 .and_then(serde_json::Value::as_str)
487 .unwrap_or_default()
488 .to_string(),
489 size: row
490 .get("size")
491 .and_then(serde_json::Value::as_i64)
492 .unwrap_or(0),
493 });
494 }
495 cs.rows.push(RowChange {
496 table: spec.name.to_string(),
497 row,
498 });
499 }
500 }
501 let cursor = push_cursor(SYNC_TOMBSTONES);
502 let c = cursor.as_deref().unwrap_or("0");
503 let mut stmt = db.conn().prepare(&format!(
504 "SELECT id, table_name, row_id, deleted_at FROM {SYNC_TOMBSTONES} WHERE id > ?1 ORDER BY id"
505 ))?;
506 let rows = stmt.query_map([c], |r| {
507 Ok(Tombstone {
508 origin_id: r.get(0)?,
509 table_name: r.get(1)?,
510 row_id: r.get(2)?,
511 deleted_at: r.get(3)?,
512 })
513 })?;
514 cs.tombstones = rows.collect::<rusqlite::Result<Vec<_>>>()?;
515 Ok(cs)
516}
517
518pub fn build_ack(db: &Db, peer_id: &str) -> Result<Vec<PeerCursor>> {
523 Ok(db
524 .load_sync_state()?
525 .iter()
526 .filter(|s| s.peer_id == peer_id)
527 .filter_map(|s| {
528 s.pull_cursor.clone().map(|cursor| PeerCursor {
529 peer_id: peer_id.to_string(),
530 table_name: s.table_name.clone(),
531 cursor,
532 })
533 })
534 .collect())
535}
536
537fn select_rows(db: &Db, spec: &TableSpec, cursor: Option<&str>) -> Result<Vec<serde_json::Value>> {
541 let cols = spec.columns.join(", ");
542 let (sql, params): (String, Vec<rusqlite::types::Value>) = match spec.cursor {
543 Cursor::UpdatedAt => {
544 let scope_filter = if spec.name == "app_settings" {
546 " AND scope = 'sync'"
547 } else {
548 ""
549 };
550 (
551 format!(
552 "SELECT {cols} FROM {} WHERE COALESCE(updated_at, '') > ?1{scope_filter} \
553 ORDER BY COALESCE(updated_at, ''), {}",
554 spec.name,
555 spec.pk.join(", ")
556 ),
557 vec![rusqlite::types::Value::Text(
558 cursor.unwrap_or("").to_string(),
559 )],
560 )
561 }
562 Cursor::Tuple(tuple_cols) => {
563 let (a, b) = (tuple_cols[0], tuple_cols[1]);
564 let (ca, cb) = cursor.and_then(|c| c.split_once('|')).unwrap_or(("", ""));
565 (
566 format!(
567 "SELECT {cols} FROM {} WHERE ({a}, {b}) > (?1, ?2) ORDER BY {a}, {b}",
568 spec.name
569 ),
570 vec![
571 rusqlite::types::Value::Text(ca.to_string()),
572 rusqlite::types::Value::Text(cb.to_string()),
573 ],
574 )
575 }
576 Cursor::AutoId => (
577 format!("SELECT {cols} FROM {} WHERE id > ?1 ORDER BY id", spec.name),
578 vec![rusqlite::types::Value::Text(
579 cursor.unwrap_or("0").to_string(),
580 )],
581 ),
582 Cursor::None => bail!("{} has no cursor", spec.name),
583 };
584 let mut stmt = db.conn().prepare(&sql)?;
585 let rows = stmt.query_map(params_from_iter(params.iter()), |r| {
586 row_to_json(r, spec.columns)
587 })?;
588 Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
589}
590
591fn select_personas(db: &Db, session: &serde_json::Value) -> Result<Vec<serde_json::Value>> {
594 let Some(session_id) = session.get("id").and_then(serde_json::Value::as_str) else {
595 return Ok(Vec::new());
596 };
597 let mut stmt = db.conn().prepare(
598 "SELECT session_id, ord, name, model, persona FROM swarm_personas WHERE session_id = ?1",
599 )?;
600 let rows = stmt.query_map([session_id], |r| {
601 row_to_json(r, &["session_id", "ord", "name", "model", "persona"])
602 })?;
603 Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
604}
605
606fn row_to_json(row: &rusqlite::Row, columns: &[&str]) -> rusqlite::Result<serde_json::Value> {
607 let mut obj = serde_json::Map::new();
608 for (i, column) in columns.iter().enumerate() {
609 let v: rusqlite::types::Value = row.get(i)?;
610 obj.insert((*column).to_string(), sqlite_to_json(v));
611 }
612 Ok(serde_json::Value::Object(obj))
613}
614
615fn sqlite_to_json(v: rusqlite::types::Value) -> serde_json::Value {
616 match v {
617 rusqlite::types::Value::Null | rusqlite::types::Value::Blob(_) => serde_json::Value::Null,
618 rusqlite::types::Value::Integer(i) => serde_json::Value::from(i),
619 rusqlite::types::Value::Real(f) => serde_json::Number::from_f64(f)
620 .map_or(serde_json::Value::Null, serde_json::Value::Number),
621 rusqlite::types::Value::Text(s) => serde_json::Value::from(s),
622 }
623}
624
625fn json_to_value(v: &serde_json::Value) -> rusqlite::types::Value {
626 match v {
627 serde_json::Value::Null | serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
628 rusqlite::types::Value::Null
629 }
630 serde_json::Value::Bool(b) => rusqlite::types::Value::Integer(i64::from(*b)),
631 serde_json::Value::Number(n) => n.as_i64().map_or_else(
632 || rusqlite::types::Value::Real(n.as_f64().unwrap_or(0.0)),
633 rusqlite::types::Value::Integer,
634 ),
635 serde_json::Value::String(s) => rusqlite::types::Value::Text(s.clone()),
636 }
637}
638
639fn short_id(id: &str) -> String {
642 id.chars().take(8).collect()
643}
644
645enum LwwOutcome {
648 Applied,
649 Skipped,
650 Warned(String),
651}
652
653fn lww_wins(
656 spec: &TableSpec,
657 row: &serde_json::Value,
658 local_updated: &str,
659 local_tie: &str,
660) -> bool {
661 let incoming_updated = row
662 .get("updated_at")
663 .and_then(serde_json::Value::as_str)
664 .unwrap_or_default();
665 let incoming_tie = spec
666 .pk
667 .iter()
668 .map(|c| {
669 row.get(*c)
670 .and_then(serde_json::Value::as_str)
671 .unwrap_or_default()
672 })
673 .collect::<Vec<_>>()
674 .join("\u{1f}");
675 (incoming_updated, incoming_tie.as_str()) > (local_updated, local_tie)
676}
677
678fn lww_wins_against(id: &str, updated: &str, local_id: &str, local_updated: &str) -> bool {
681 (updated, id) > (local_updated, local_id)
682}
683
684#[allow(clippy::too_many_lines)]
691pub fn apply_changeset(
692 db: &Db,
693 space: &Space,
694 cs: &Changeset,
695 blob_source: Option<&Path>,
696) -> Result<(ApplySummary, Vec<PeerCursor>)> {
697 let my_id = db.device_id()?;
698 let mut summary = ApplySummary::default();
699 let mut by_table: HashMap<&str, Vec<&serde_json::Value>> = HashMap::new();
700 for rc in &cs.rows {
701 by_table.entry(rc.table.as_str()).or_default().push(&rc.row);
702 }
703 for table in by_table.keys() {
704 if !known_table(table) {
705 summary
706 .warnings
707 .push(format!("changeset has rows for unknown table {table:?}"));
708 }
709 }
710
711 let mut max_pos: HashMap<&'static str, String> = HashMap::new();
716 let mut won_sessions: HashSet<String> = HashSet::new();
717 let mut won_files: HashSet<(String, String)> = HashSet::new();
718 for spec in TABLES {
719 if spec.cursor == Cursor::None {
720 continue; }
722 let Some(rows) = by_table.get(spec.name) else {
723 continue;
724 };
725 for row in rows {
726 let pos = row_position(spec, row);
727 max_pos
728 .entry(spec.name)
729 .and_modify(|p| {
730 if position_gt(spec.name, &pos, p) {
731 p.clone_from(&pos);
732 }
733 })
734 .or_insert(pos);
735 let outcome = match apply_row(db, space, spec, row) {
736 Ok(o) => o,
737 Err(e) => LwwOutcome::Warned(format!("{} row failed: {e:#}", spec.name)),
738 };
739 match outcome {
740 LwwOutcome::Applied => {
741 summary.rows_applied += 1;
742 if spec.name == "sessions"
743 && let Some(id) = row.get("id").and_then(serde_json::Value::as_str)
744 {
745 won_sessions.insert(id.to_string());
746 }
747 if spec.name == "files"
748 && let (Some(sid), Some(name)) = (
749 row.get("space_id").and_then(serde_json::Value::as_str),
750 row.get("name").and_then(serde_json::Value::as_str),
751 )
752 {
753 won_files.insert((sid.to_string(), name.to_string()));
754 }
755 }
756 LwwOutcome::Skipped => summary.rows_skipped += 1,
757 LwwOutcome::Warned(w) => {
758 summary.rows_skipped += 1;
759 summary.warnings.push(w);
760 }
761 }
762 }
763 }
764
765 if let Some(personas) = by_table.get("swarm_personas") {
768 let mut rosters: HashMap<&str, Vec<&serde_json::Value>> = HashMap::new();
769 for p in personas {
770 if let Some(sid) = p.get("session_id").and_then(serde_json::Value::as_str) {
771 rosters.entry(sid).or_default().push(p);
772 }
773 }
774 for (sid, roster) in rosters {
775 if won_sessions.contains(sid) {
776 let conn = db.conn();
777 conn.execute("DELETE FROM swarm_personas WHERE session_id = ?1", [sid])?;
778 for p in &roster {
779 let values: Vec<rusqlite::types::Value> =
780 ["session_id", "ord", "name", "model", "persona"]
781 .iter()
782 .map(|c| json_to_value(p.get(*c).unwrap_or(&serde_json::Value::Null)))
783 .collect();
784 conn.execute(
785 "INSERT INTO swarm_personas (session_id, ord, name, model, persona)
786 VALUES (?1, ?2, ?3, ?4, ?5)",
787 params_from_iter(values.iter()),
788 )?;
789 }
790 summary.rows_applied += roster.len();
791 } else {
792 summary.rows_skipped += roster.len();
793 summary.warnings.push(format!(
794 "swarm personas for session {sid} skipped — their session row lost LWW"
795 ));
796 }
797 }
798 }
799
800 let mut tombstone_max = 0i64;
804 for t in &cs.tombstones {
805 tombstone_max = tombstone_max.max(t.origin_id);
806 if apply_tombstone(db, space, t, &mut summary) {
807 summary.tombstones_applied += 1;
808 }
809 }
810 if !cs.tombstones.is_empty() {
811 max_pos.insert(SYNC_TOMBSTONES, tombstone_max.to_string());
812 }
813
814 for fc in &cs.files {
817 if !won_files.contains(&(fc.space_id.clone(), fc.name.clone())) {
818 continue;
819 }
820 if !valid_component(&fc.name) || !valid_component(&fc.space_id) {
821 summary.warnings.push(format!(
822 "skipping blob for unsafe file {:?}/{:?}",
823 fc.space_id, fc.name
824 ));
825 continue;
826 }
827 let Some(space_name) = space_name_for(db, &fc.space_id)? else {
828 summary.warnings.push(format!(
829 "skipping blob for {:?} — its space no longer exists",
830 fc.name
831 ));
832 continue;
833 };
834 let target = space.files_dir(&space_name).join(&fc.name);
835 if file_matches(&target, &fc.hash) {
836 summary.files_kept += 1;
837 continue;
838 }
839 let pulled = match blob_source {
840 Some(src) => match pull_blob(src, &fc.space_id, &fc.name, &fc.hash, &target) {
841 Ok(true) => {
842 summary.files_pulled += 1;
843 true
844 }
845 _ => false,
846 },
847 None => false,
848 };
849 if !pulled {
850 summary.files_missing.push(fc.clone());
851 summary.warnings.push(format!(
852 "blob for {:?} unavailable — fetch it with a dir or ssh transport",
853 fc.name
854 ));
855 }
856 }
857
858 if let Some(acks) = &cs.ack {
861 let states = db.load_sync_state()?;
862 for a in acks {
863 if a.peer_id != my_id {
864 continue;
865 }
866 if !known_table(&a.table_name) {
867 summary
868 .warnings
869 .push(format!("ack for unknown table {:?}", a.table_name));
870 continue;
871 }
872 let existing = states
873 .iter()
874 .find(|s| s.peer_id == cs.device_id && s.table_name == a.table_name)
875 .and_then(|s| s.push_cursor.clone());
876 if existing
877 .as_deref()
878 .is_none_or(|e| position_gt(&a.table_name, &a.cursor, e))
879 {
880 db.set_sync_state(&cs.device_id, &a.table_name, None, Some(&a.cursor))?;
881 summary.acks_applied += 1;
882 }
883 }
884 }
885
886 let states = db.load_sync_state()?;
888 let mut reply: Vec<PeerCursor> = Vec::new();
889 for (table, pos) in &max_pos {
890 let existing = states
891 .iter()
892 .find(|s| s.peer_id == cs.device_id && s.table_name == *table)
893 .and_then(|s| s.pull_cursor.clone());
894 let final_pos = match &existing {
895 Some(e) if position_gt(table, e, pos) => e.clone(),
896 _ => pos.clone(),
897 };
898 if existing.as_deref() != Some(final_pos.as_str()) {
899 db.set_sync_state(&cs.device_id, table, Some(&final_pos), None)?;
900 reply.push(PeerCursor {
901 peer_id: cs.device_id.clone(),
902 table_name: (*table).to_string(),
903 cursor: final_pos,
904 });
905 }
906 }
907 Ok((summary, reply))
908}
909
910fn apply_row(
911 db: &Db,
912 space: &Space,
913 spec: &TableSpec,
914 row: &serde_json::Value,
915) -> Result<LwwOutcome> {
916 match spec.cursor {
917 Cursor::UpdatedAt if spec.name == "spaces" => apply_space(db, space, row),
918 Cursor::UpdatedAt if spec.name == "files" => apply_file(db, row),
919 Cursor::UpdatedAt => apply_lww(db, spec, row),
920 Cursor::Tuple(_) | Cursor::AutoId => Ok(if apply_append(db, spec, row)? {
921 LwwOutcome::Applied
922 } else {
923 LwwOutcome::Skipped
924 }),
925 Cursor::None => Ok(LwwOutcome::Skipped),
926 }
927}
928
929fn apply_lww(db: &Db, spec: &TableSpec, row: &serde_json::Value) -> Result<LwwOutcome> {
932 if spec.name == "app_settings"
933 && row.get("scope").and_then(serde_json::Value::as_str) != Some("sync")
934 {
935 return Ok(LwwOutcome::Skipped);
936 }
937 let conn = db.conn();
938 let pk_values: Vec<String> = spec
939 .pk
940 .iter()
941 .map(|c| {
942 row.get(*c)
943 .and_then(serde_json::Value::as_str)
944 .unwrap_or_default()
945 .to_string()
946 })
947 .collect();
948 let where_sql = spec
949 .pk
950 .iter()
951 .enumerate()
952 .map(|(i, c)| format!("{c} = ?{}", i + 1))
953 .collect::<Vec<_>>()
954 .join(" AND ");
955 let existing: Option<String> = conn
956 .query_row(
957 &format!(
958 "SELECT COALESCE(updated_at, '') FROM {} WHERE {where_sql}",
959 spec.name
960 ),
961 params_from_iter(pk_values.iter()),
962 |r| r.get(0),
963 )
964 .optional()?;
965 let local_tie = pk_values.join("\u{1f}");
966 if !lww_wins(spec, row, existing.as_deref().unwrap_or(""), &local_tie) {
967 return Ok(LwwOutcome::Skipped);
968 }
969 let values: Vec<rusqlite::types::Value> = spec
970 .apply_columns
971 .iter()
972 .map(|c| json_to_value(row.get(*c).unwrap_or(&serde_json::Value::Null)))
973 .collect();
974 if existing.is_some() {
975 let set_sql = spec
976 .apply_columns
977 .iter()
978 .enumerate()
979 .map(|(i, c)| format!("{c} = ?{}", i + 1))
980 .collect::<Vec<_>>()
981 .join(", ");
982 let update_where = spec
985 .pk
986 .iter()
987 .enumerate()
988 .map(|(i, c)| format!("{c} = ?{}", spec.apply_columns.len() + i + 1))
989 .collect::<Vec<_>>()
990 .join(" AND ");
991 let mut all = values;
992 all.extend(
993 pk_values
994 .iter()
995 .map(|v| rusqlite::types::Value::Text(v.clone())),
996 );
997 conn.execute(
998 &format!("UPDATE {} SET {set_sql} WHERE {update_where}", spec.name),
999 params_from_iter(all.iter()),
1000 )?;
1001 } else {
1002 let cols = spec.apply_columns.join(", ");
1003 let marks = (1..=spec.apply_columns.len())
1004 .map(|i| format!("?{i}"))
1005 .collect::<Vec<_>>()
1006 .join(", ");
1007 conn.execute(
1008 &format!("INSERT INTO {} ({cols}) VALUES ({marks})", spec.name),
1009 params_from_iter(values.iter()),
1010 )?;
1011 }
1012 Ok(LwwOutcome::Applied)
1013}
1014
1015fn apply_append(db: &Db, spec: &TableSpec, row: &serde_json::Value) -> Result<bool> {
1018 let cols = spec.apply_columns.join(", ");
1019 let marks = (1..=spec.apply_columns.len())
1020 .map(|i| format!("?{i}"))
1021 .collect::<Vec<_>>()
1022 .join(", ");
1023 let values: Vec<rusqlite::types::Value> = spec
1024 .apply_columns
1025 .iter()
1026 .map(|c| json_to_value(row.get(*c).unwrap_or(&serde_json::Value::Null)))
1027 .collect();
1028 let n = db.conn().execute(
1029 &format!(
1030 "INSERT OR IGNORE INTO {} ({cols}) VALUES ({marks})",
1031 spec.name
1032 ),
1033 params_from_iter(values.iter()),
1034 )?;
1035 Ok(n > 0)
1036}
1037
1038fn apply_space(db: &Db, space: &Space, row: &serde_json::Value) -> Result<LwwOutcome> {
1045 let Some(id) = row.get("id").and_then(serde_json::Value::as_str) else {
1046 return Ok(LwwOutcome::Warned("space row without id".to_string()));
1047 };
1048 let Some(name) = row.get("name").and_then(serde_json::Value::as_str) else {
1049 return Ok(LwwOutcome::Warned(format!("space {id} without name")));
1050 };
1051 if !valid_component(name) {
1052 return Ok(LwwOutcome::Warned(format!(
1053 "skipping space {id}: unsafe name {name:?}"
1054 )));
1055 }
1056 let conn = db.conn();
1057 let incoming_updated = row
1058 .get("updated_at")
1059 .and_then(serde_json::Value::as_str)
1060 .unwrap_or_default();
1061 let local: Option<(String, String)> = conn
1062 .query_row(
1063 "SELECT name, COALESCE(updated_at, '') FROM spaces WHERE id = ?1",
1064 [id],
1065 |r| Ok((r.get(0)?, r.get(1)?)),
1066 )
1067 .optional()?;
1068 let Some((local_name, local_updated)) = local else {
1069 let colliding: Option<String> = conn
1072 .query_row(
1073 "SELECT id FROM spaces WHERE name = ?1 AND id != ?2",
1074 (name, id),
1075 |r| r.get(0),
1076 )
1077 .optional()?;
1078 let mut incoming_name = name.to_string();
1079 if let Some(other) = colliding {
1080 let other_updated: String = conn.query_row(
1081 "SELECT COALESCE(updated_at, '') FROM spaces WHERE id = ?1",
1082 [&other],
1083 |r| r.get(0),
1084 )?;
1085 if lww_wins_against(id, incoming_updated, &other, &other_updated) {
1086 let new_name = format!("{name}-{}", short_id(&other));
1087 conn.execute(
1088 "UPDATE spaces SET name = ?1 WHERE id = ?2",
1089 (new_name.as_str(), other.as_str()),
1090 )?;
1091 rename_dir(space, name, &new_name);
1092 } else {
1093 incoming_name = format!("{name}-{}", short_id(id));
1094 }
1095 }
1096 insert_space_row(conn, id, &incoming_name, row)?;
1097 if let Err(e) = space.ensure_space_dir(&incoming_name) {
1098 return Ok(LwwOutcome::Warned(format!(
1099 "space {id} applied but its dir failed: {e}"
1100 )));
1101 }
1102 return Ok(LwwOutcome::Applied);
1103 };
1104 if !lww_wins_against(id, incoming_updated, id, &local_updated) {
1105 return Ok(LwwOutcome::Skipped);
1106 }
1107 if name != local_name {
1108 rename_dir(space, &local_name, name);
1109 }
1110 insert_space_row(conn, id, name, row)?;
1111 Ok(LwwOutcome::Applied)
1112}
1113
1114fn insert_space_row(
1115 conn: &Connection,
1116 id: &str,
1117 name: &str,
1118 row: &serde_json::Value,
1119) -> Result<()> {
1120 conn.execute(
1121 "INSERT INTO spaces (id, name, created_at, updated_at)
1122 VALUES (?1, ?2, ?3, ?4)
1123 ON CONFLICT(id) DO UPDATE SET name = ?2, created_at = ?3, updated_at = ?4",
1124 (
1125 id,
1126 name,
1127 row.get("created_at")
1128 .and_then(serde_json::Value::as_str)
1129 .unwrap_or_default(),
1130 row.get("updated_at")
1131 .and_then(serde_json::Value::as_str)
1132 .unwrap_or_default(),
1133 ),
1134 )?;
1135 Ok(())
1136}
1137
1138fn rename_dir(space: &Space, old: &str, new: &str) {
1141 let from = space.space_dir(old);
1142 let to = space.space_dir(new);
1143 if from.exists() && !to.exists() {
1144 let _ = std::fs::rename(&from, &to);
1145 }
1146}
1147
1148fn apply_file(db: &Db, row: &serde_json::Value) -> Result<LwwOutcome> {
1153 let Some(id) = row.get("id").and_then(serde_json::Value::as_str) else {
1154 return Ok(LwwOutcome::Warned("file row without id".to_string()));
1155 };
1156 let (Some(space_id), Some(name)) = (
1157 row.get("space_id").and_then(serde_json::Value::as_str),
1158 row.get("name").and_then(serde_json::Value::as_str),
1159 ) else {
1160 return Ok(LwwOutcome::Warned(format!("file {id} without space/name")));
1161 };
1162 if !valid_component(name) || !valid_component(space_id) {
1163 return Ok(LwwOutcome::Warned(format!(
1164 "skipping file {id}: unsafe name {name:?}"
1165 )));
1166 }
1167 let conn = db.conn();
1168 let incoming_updated = row
1169 .get("updated_at")
1170 .and_then(serde_json::Value::as_str)
1171 .unwrap_or_default();
1172 let local: Option<(String, String)> = conn
1175 .query_row(
1176 "SELECT id, COALESCE(updated_at, '') FROM files WHERE id = ?1",
1177 [id],
1178 |r| Ok((r.get(0)?, r.get(1)?)),
1179 )
1180 .optional()?
1181 .or(conn
1182 .query_row(
1183 "SELECT id, COALESCE(updated_at, '') FROM files
1184 WHERE space_id = ?1 AND name = ?2",
1185 (space_id, name),
1186 |r| Ok((r.get(0)?, r.get(1)?)),
1187 )
1188 .optional()?);
1189 let Some((local_id, local_updated)) = local else {
1190 insert_file_row(conn, row)?;
1191 return Ok(LwwOutcome::Applied);
1192 };
1193 if !lww_wins_against(id, incoming_updated, &local_id, &local_updated) {
1194 return Ok(LwwOutcome::Skipped);
1195 }
1196 let values: Vec<rusqlite::types::Value> = [
1199 "id",
1200 "space_id",
1201 "name",
1202 "hash",
1203 "size",
1204 "created_at",
1205 "updated_at",
1206 ]
1207 .iter()
1208 .map(|c| json_to_value(row.get(*c).unwrap_or(&serde_json::Value::Null)))
1209 .collect();
1210 let mut all = values;
1211 all.push(rusqlite::types::Value::Text(local_id));
1212 conn.execute(
1213 "UPDATE files SET id = ?1, space_id = ?2, name = ?3, hash = ?4, size = ?5,
1214 created_at = ?6, updated_at = ?7
1215 WHERE id = ?8",
1216 params_from_iter(all.iter()),
1217 )?;
1218 Ok(LwwOutcome::Applied)
1219}
1220
1221fn insert_file_row(conn: &Connection, row: &serde_json::Value) -> Result<()> {
1222 let values: Vec<rusqlite::types::Value> = [
1223 "id",
1224 "space_id",
1225 "name",
1226 "hash",
1227 "size",
1228 "created_at",
1229 "updated_at",
1230 ]
1231 .iter()
1232 .map(|c| json_to_value(row.get(*c).unwrap_or(&serde_json::Value::Null)))
1233 .collect();
1234 conn.execute(
1235 "INSERT INTO files (id, space_id, name, hash, size, created_at, updated_at)
1236 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
1237 params_from_iter(values.iter()),
1238 )?;
1239 Ok(())
1240}
1241
1242#[allow(clippy::too_many_lines)]
1248fn apply_tombstone(db: &Db, space: &Space, t: &Tombstone, summary: &mut ApplySummary) -> bool {
1249 let conn = db.conn();
1250 let _ = conn.execute(
1253 "INSERT INTO sync_tombstones (table_name, row_id, deleted_at)
1254 SELECT ?1, ?2, ?3 WHERE NOT EXISTS (
1255 SELECT 1 FROM sync_tombstones WHERE table_name = ?1 AND row_id = ?2)",
1256 (&t.table_name, &t.row_id, &t.deleted_at),
1257 );
1258 match t.table_name.as_str() {
1259 "spaces" => {
1260 if t.row_id == DEFAULT_SPACE {
1261 summary
1262 .warnings
1263 .push("tombstone for the default space ignored".to_string());
1264 return false;
1265 }
1266 let name: Option<String> = conn
1267 .query_row("SELECT name FROM spaces WHERE id = ?1", [&t.row_id], |r| {
1268 r.get(0)
1269 })
1270 .optional()
1271 .ok()
1272 .flatten();
1273 if conn
1274 .execute("DELETE FROM spaces WHERE id = ?1", [&t.row_id])
1275 .is_err()
1276 {
1277 return false;
1278 }
1279 if let Some(name) = name
1280 && let Err(e) = space.remove_space_dir(&name)
1281 {
1282 summary
1283 .warnings
1284 .push(format!("removing space dir {name}: {e}"));
1285 }
1286 true
1287 }
1288 "sessions" => {
1289 let cascade = conn.execute("DELETE FROM messages WHERE session_id = ?1", [&t.row_id]);
1290 let sources = conn.execute(
1291 "DELETE FROM session_sources WHERE session_id = ?1",
1292 [&t.row_id],
1293 );
1294 let row = conn.execute("DELETE FROM sessions WHERE id = ?1", [&t.row_id]);
1295 cascade.is_ok() && sources.is_ok() && row.is_ok()
1296 }
1297 "messages" => conn
1298 .execute("DELETE FROM messages WHERE id = ?1", [&t.row_id])
1299 .is_ok(),
1300 "files" => {
1301 let row: Option<(String, String)> = conn
1302 .query_row(
1303 "SELECT space_id, name FROM files WHERE id = ?1",
1304 [&t.row_id],
1305 |r| Ok((r.get(0)?, r.get(1)?)),
1306 )
1307 .optional()
1308 .ok()
1309 .flatten();
1310 let _ = conn.execute(
1311 "DELETE FROM cache.file_chunks WHERE file_id = ?1",
1312 [&t.row_id],
1313 );
1314 let _ = conn.execute(
1315 "DELETE FROM cache.chunk_embeddings WHERE file_id = ?1",
1316 [&t.row_id],
1317 );
1318 let _ = conn.execute(
1319 "DELETE FROM cache.file_index_state WHERE file_id = ?1",
1320 [&t.row_id],
1321 );
1322 if conn
1323 .execute("DELETE FROM files WHERE id = ?1", [&t.row_id])
1324 .is_err()
1325 {
1326 return false;
1327 }
1328 if let Some((space_id, name)) = row
1329 && let Ok(Some(space_name)) = space_name_for(db, &space_id)
1330 && valid_component(&name)
1331 {
1332 let blob = space.files_dir(&space_name).join(&name);
1333 let _ = std::fs::remove_file(blob);
1334 }
1335 true
1336 }
1337 "watches" => conn
1338 .execute("DELETE FROM watches WHERE id = ?1", [&t.row_id])
1339 .is_ok(),
1340 "usage_log" => conn
1341 .execute("DELETE FROM usage_log WHERE sync_id = ?1", [&t.row_id])
1342 .is_ok(),
1343 "citations" => conn
1344 .execute("DELETE FROM citations WHERE sync_id = ?1", [&t.row_id])
1345 .is_ok(),
1346 "swarm_personas" => false,
1350 other => {
1351 summary
1352 .warnings
1353 .push(format!("tombstone for unknown table {other:?}"));
1354 false
1355 }
1356 }
1357}
1358
1359fn space_name_for(db: &Db, space_id: &str) -> Result<Option<String>> {
1361 Ok(db
1362 .conn()
1363 .query_row("SELECT name FROM spaces WHERE id = ?1", [space_id], |r| {
1364 r.get(0)
1365 })
1366 .optional()?)
1367}
1368
1369fn sha256_hex(bytes: &[u8]) -> String {
1372 let mut hasher = Sha256::new();
1373 hasher.update(bytes);
1374 hasher.finalize().iter().fold(String::new(), |mut h, b| {
1375 let _ = std::fmt::Write::write_fmt(&mut h, format_args!("{b:02x}"));
1376 h
1377 })
1378}
1379
1380pub fn put_blob(
1384 db: &Db,
1385 space: &Space,
1386 space_id: &str,
1387 name: &str,
1388 hash: &str,
1389 bytes: &[u8],
1390) -> Result<()> {
1391 if !valid_component(space_id) || !valid_component(name) {
1392 bail!("unsafe blob path");
1393 }
1394 let Some(space_name) = space_name_for(db, space_id)? else {
1395 bail!("unknown space");
1396 };
1397 let row = db
1398 .list_files(space_id)?
1399 .into_iter()
1400 .find(|file| file.name == name)
1401 .ok_or_else(|| anyhow!("unknown file manifest"))?;
1402 if row.hash != hash {
1403 bail!("blob hash does not match the current file manifest");
1404 }
1405 if row.size < 0 || row.size as usize != bytes.len() {
1406 bail!("blob size does not match the current file manifest");
1407 }
1408 if sha256_hex(bytes) != hash {
1409 bail!("blob content hash mismatch");
1410 }
1411 let target = space.files_dir(&space_name).join(name);
1412 if let Some(parent) = target.parent() {
1413 std::fs::create_dir_all(parent)
1414 .with_context(|| format!("creating {}", parent.display()))?;
1415 }
1416 let temporary = target.with_extension(format!("nexus-upload-{}", uuid::Uuid::new_v4()));
1417 std::fs::write(&temporary, bytes)
1418 .with_context(|| format!("writing blob {}", temporary.display()))?;
1419 if let Err(error) = std::fs::rename(&temporary, &target) {
1420 let _ = std::fs::remove_file(&temporary);
1421 return Err(error).with_context(|| format!("installing blob {}", target.display()));
1422 }
1423 Ok(())
1424}
1425
1426pub fn read_blob(db: &Db, space: &Space, space_id: &str, name: &str) -> Result<Option<Vec<u8>>> {
1429 if !valid_component(space_id) || !valid_component(name) {
1430 return Ok(None);
1431 }
1432 let Some(space_name) = space_name_for(db, space_id)? else {
1433 return Ok(None);
1434 };
1435 let Some(row) = db
1436 .list_files(space_id)?
1437 .into_iter()
1438 .find(|file| file.name == name)
1439 else {
1440 return Ok(None);
1441 };
1442 let path = space.files_dir(&space_name).join(name);
1443 let bytes = match std::fs::read(path) {
1444 Ok(bytes) => bytes,
1445 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1446 Err(error) => return Err(error.into()),
1447 };
1448 Ok(
1449 (row.size >= 0 && row.size as usize == bytes.len() && sha256_hex(&bytes) == row.hash)
1450 .then_some(bytes),
1451 )
1452}
1453
1454fn file_matches(path: &Path, hash: &str) -> bool {
1456 let Ok(bytes) = std::fs::read(path) else {
1457 return false;
1458 };
1459 sha256_hex(&bytes) == hash
1460}
1461
1462fn blob_source_path(db: &Db, space: &Space, fc: &FileChange) -> Result<Option<PathBuf>> {
1465 if !valid_component(&fc.name) || !valid_component(&fc.space_id) {
1466 return Ok(None);
1467 }
1468 let Some(space_name) = space_name_for(db, &fc.space_id)? else {
1469 return Ok(None);
1470 };
1471 let path = space.files_dir(&space_name).join(&fc.name);
1472 Ok(path.exists().then_some(path))
1473}
1474
1475pub fn export_blobs(db: &Db, space: &Space, cs: &Changeset, dest: &Path) -> Result<usize> {
1479 let mut n = 0usize;
1480 for fc in &cs.files {
1481 let Some(src) = blob_source_path(db, space, fc)? else {
1482 continue;
1483 };
1484 let target = dest.join("blobs").join(&fc.space_id).join(&fc.name);
1485 if let Some(parent) = target.parent() {
1486 std::fs::create_dir_all(parent)
1487 .with_context(|| format!("creating {}", parent.display()))?;
1488 }
1489 std::fs::copy(&src, &target)
1490 .with_context(|| format!("copying blob {} to {}", src.display(), target.display()))?;
1491 n += 1;
1492 }
1493 Ok(n)
1494}
1495
1496fn pull_blob(src: &Path, space_id: &str, name: &str, hash: &str, target: &Path) -> Result<bool> {
1500 let candidate = src.join("blobs").join(space_id).join(name);
1501 if !file_matches(&candidate, hash) {
1502 return Ok(false);
1503 }
1504 if let Some(parent) = target.parent() {
1505 std::fs::create_dir_all(parent)
1506 .with_context(|| format!("creating {}", parent.display()))?;
1507 }
1508 std::fs::copy(&candidate, target)
1509 .with_context(|| format!("copying blob to {}", target.display()))?;
1510 Ok(true)
1511}
1512
1513pub fn write_bundle(
1518 db: &Db,
1519 space: &Space,
1520 cs: &Changeset,
1521 writer: impl std::io::Write + std::io::Seek,
1522) -> Result<usize> {
1523 let mut zip = zip::ZipWriter::new(writer);
1524 let opts = zip::write::SimpleFileOptions::default()
1525 .compression_method(zip::CompressionMethod::Deflated);
1526 zip.start_file("changeset.json", opts)?;
1527 serde_json::to_writer(&mut zip, cs)?;
1528 let mut n = 0usize;
1529 for fc in &cs.files {
1530 let Some(src) = blob_source_path(db, space, fc)? else {
1531 continue;
1532 };
1533 zip.start_file(format!("blobs/{}/{}", fc.space_id, fc.name), opts)?;
1534 let mut f = std::fs::File::open(&src)?;
1535 std::io::copy(&mut f, &mut zip)?;
1536 n += 1;
1537 }
1538 zip.finish()?;
1539 Ok(n)
1540}
1541
1542pub fn unpack_bundle(src: &Path, dest_dir: &Path) -> Result<Changeset> {
1546 let file = std::fs::File::open(src).with_context(|| format!("opening {}", src.display()))?;
1547 let mut archive =
1548 zip::ZipArchive::new(file).with_context(|| format!("reading {}", src.display()))?;
1549 let mut changeset: Option<Changeset> = None;
1550 for i in 0..archive.len() {
1551 let mut entry = archive.by_index(i)?;
1552 let name = entry.name().to_string();
1553 if name == "changeset.json" {
1554 changeset = Some(serde_json::from_reader(&mut entry)?);
1555 continue;
1556 }
1557 let Some(rel) = name.strip_prefix("blobs/") else {
1558 continue;
1559 };
1560 let components: Vec<&str> = rel.split('/').collect();
1561 if components.len() != 2 || !components.iter().all(|c| valid_component(c)) {
1562 bail!("unsafe path in bundle: {name}");
1563 }
1564 let dest = dest_dir.join("blobs").join(rel);
1565 if let Some(parent) = dest.parent() {
1566 std::fs::create_dir_all(parent)
1567 .with_context(|| format!("creating {}", parent.display()))?;
1568 }
1569 let mut out =
1570 std::fs::File::create(&dest).with_context(|| format!("creating {}", dest.display()))?;
1571 std::io::copy(&mut entry, &mut out)?;
1572 }
1573 changeset.ok_or_else(|| anyhow!("bundle has no changeset.json"))
1574}
1575
1576#[cfg(test)]
1577mod tests {
1578 use super::*;
1579 use crate::db::Db;
1580 use std::io::Write as _;
1581
1582 struct Pair {
1585 a: Db,
1586 b: Db,
1587 a_root: PathBuf,
1588 b_root: PathBuf,
1589 dir: PathBuf,
1590 }
1591
1592 impl Pair {
1593 fn new() -> Self {
1594 let dir = std::env::temp_dir().join(format!("nexus-sync-{}", uuid::Uuid::new_v4()));
1595 std::fs::create_dir_all(&dir).unwrap();
1596 Self {
1597 a: Db::open_in_memory().unwrap(),
1598 b: Db::open_in_memory().unwrap(),
1599 a_root: dir.join("a"),
1600 b_root: dir.join("b"),
1601 dir,
1602 }
1603 }
1604
1605 fn space_a(&self) -> Space {
1606 Space {
1607 root: self.a_root.clone(),
1608 }
1609 }
1610
1611 fn space_b(&self) -> Space {
1612 Space {
1613 root: self.b_root.clone(),
1614 }
1615 }
1616
1617 fn exchange_ab(&self) -> (ApplySummary, ApplySummary) {
1621 let a_space = self.space_a();
1622 let b_space = self.space_b();
1623 let cs = build_changeset(&self.a, None, "a").unwrap();
1624 export_blobs(&self.a, &a_space, &cs, &self.dir).unwrap();
1625 let (sa, cursors) = apply_changeset(&self.b, &b_space, &cs, Some(&self.dir)).unwrap();
1626 let mut reply = build_changeset(&self.b, Some(&cs.device_id), "b").unwrap();
1627 reply.ack = Some(cursors);
1628 export_blobs(&self.b, &b_space, &reply, &self.dir).unwrap();
1629 let (sb, _) = apply_changeset(&self.a, &a_space, &reply, Some(&self.dir)).unwrap();
1630 (sa, sb)
1631 }
1632
1633 fn exchange_ba(&self) -> (ApplySummary, ApplySummary) {
1635 let a_space = self.space_a();
1636 let b_space = self.space_b();
1637 let cs = build_changeset(&self.b, None, "b").unwrap();
1638 export_blobs(&self.b, &b_space, &cs, &self.dir).unwrap();
1639 let (sb, cursors) = apply_changeset(&self.a, &a_space, &cs, Some(&self.dir)).unwrap();
1640 let mut reply = build_changeset(&self.a, Some(&cs.device_id), "a").unwrap();
1641 reply.ack = Some(cursors);
1642 export_blobs(&self.a, &a_space, &reply, &self.dir).unwrap();
1643 let (sa, _) = apply_changeset(&self.b, &b_space, &reply, Some(&self.dir)).unwrap();
1644 (sb, sa)
1645 }
1646
1647 fn exchange(&self) -> (ApplySummary, ApplySummary) {
1649 let (_, _) = self.exchange_ab();
1650 self.exchange_ba()
1651 }
1652 }
1653
1654 impl Drop for Pair {
1655 fn drop(&mut self) {
1656 let _ = std::fs::remove_dir_all(&self.dir);
1657 }
1658 }
1659
1660 fn dump(db: &Db) -> Vec<(String, String, String)> {
1666 let mut out = Vec::new();
1667 for spec in TABLES {
1668 if spec.cursor == Cursor::None {
1669 continue;
1670 }
1671 let rows = select_rows(db, spec, None).unwrap();
1672 for row in rows {
1673 let pk = spec
1674 .pk
1675 .iter()
1676 .map(|c| {
1677 row.get(*c)
1678 .and_then(serde_json::Value::as_str)
1679 .unwrap_or_default()
1680 .to_string()
1681 })
1682 .collect::<Vec<_>>()
1683 .join(":");
1684 out.push((spec.name.to_string(), pk, row.to_string()));
1685 }
1686 }
1687 let mut stmt = db
1688 .conn()
1689 .prepare("SELECT table_name, row_id, deleted_at FROM sync_tombstones ORDER BY row_id")
1690 .unwrap();
1691 let rows = stmt
1692 .query_map([], |r| {
1693 Ok((
1694 r.get::<_, String>(0)?,
1695 r.get::<_, String>(1)?,
1696 r.get::<_, String>(2)?,
1697 ))
1698 })
1699 .unwrap()
1700 .collect::<rusqlite::Result<Vec<_>>>()
1701 .unwrap();
1702 for (t, rid, _) in rows {
1703 out.push(("tombstone".to_string(), format!("{t}:{rid}"), String::new()));
1704 }
1705 out.sort();
1706 out
1707 }
1708
1709 fn space(db: &Db, name: &str) -> String {
1710 db.list_spaces()
1711 .unwrap()
1712 .into_iter()
1713 .find(|s| s.name == name)
1714 .unwrap()
1715 .id
1716 }
1717
1718 fn session(db: &Db, title: &str) -> String {
1719 let sid = space(db, "default");
1720 let s = db.create_session(title, "m", &sid, "chat").unwrap();
1721 s.id
1722 }
1723
1724 fn set_updated(db: &Db, table: &str, id: &str, at: &str) {
1725 db.conn()
1726 .execute(
1727 &format!("UPDATE {table} SET updated_at = ?1 WHERE id = ?2"),
1728 (at, id),
1729 )
1730 .unwrap();
1731 }
1732
1733 fn session_updated(db: &Db, id: &str) -> String {
1734 db.conn()
1735 .query_row("SELECT updated_at FROM sessions WHERE id = ?1", [id], |r| {
1736 r.get(0)
1737 })
1738 .unwrap()
1739 }
1740
1741 fn minus_one_second(rfc3339: &str) -> String {
1742 let dt = chrono::DateTime::parse_from_rfc3339(rfc3339).unwrap();
1743 (dt - chrono::Duration::seconds(1)).to_rfc3339()
1744 }
1745
1746 fn write_blob(space: &Space, space_name: &str, name: &str, bytes: &[u8]) {
1747 let dir = space.files_dir(space_name);
1748 std::fs::create_dir_all(&dir).unwrap();
1749 std::fs::write(dir.join(name), bytes).unwrap();
1750 }
1751
1752 fn add_session_sources(db: &Db, session_id: &str, url_norms: &[String]) {
1753 crate::db::add_session_sources(db.conn(), session_id, url_norms).unwrap();
1754 }
1755
1756 fn db_state(db: &Db) -> Vec<crate::db::SyncState> {
1757 db.load_sync_state().unwrap()
1758 }
1759
1760 #[test]
1763 fn changeset_serde_roundtrip() {
1764 let cs = Changeset {
1765 device_id: "dev-1".to_string(),
1766 device_name: "laptop".to_string(),
1767 ack: Some(vec![PeerCursor {
1768 peer_id: "dev-2".to_string(),
1769 table_name: "sessions".to_string(),
1770 cursor: "2026-01-01T00:00:00Z|id".to_string(),
1771 }]),
1772 rows: vec![RowChange {
1773 table: "sessions".to_string(),
1774 row: serde_json::json!({"id": "s1", "title": "t"}),
1775 }],
1776 tombstones: vec![Tombstone {
1777 origin_id: 3,
1778 table_name: "sessions".to_string(),
1779 row_id: "s1".to_string(),
1780 deleted_at: "2026-01-02T00:00:00Z".to_string(),
1781 }],
1782 files: vec![FileChange {
1783 space_id: "sp".to_string(),
1784 name: "f.txt".to_string(),
1785 hash: "abc".to_string(),
1786 size: 3,
1787 }],
1788 generated_at: "2026-01-03T00:00:00Z".to_string(),
1789 };
1790 let json = serde_json::to_string(&cs).unwrap();
1791 let back: Changeset = serde_json::from_str(&json).unwrap();
1792 assert_eq!(cs, back);
1793 }
1794
1795 #[test]
1796 fn cursor_positions_compare_table_aware() {
1797 assert!(position_gt("citations", "10", "9"));
1799 assert!(position_gt("sync_tombstones", "10", "9"));
1800 assert!(position_gt(
1802 "sessions",
1803 "2026-02-01T00:00:00Z",
1804 "2026-01-01T00:00:00Z"
1805 ));
1806 assert!(!position_gt(
1807 "sessions",
1808 "2026-01-01T00:00:00Z",
1809 "2026-01-01T00:00:00Z"
1810 ));
1811 }
1812
1813 #[test]
1814 fn unsafe_components_are_rejected() {
1815 assert!(valid_component("notes.txt"));
1816 assert!(!valid_component(""));
1817 assert!(!valid_component("."));
1818 assert!(!valid_component(".."));
1819 assert!(!valid_component("a/b"));
1820 assert!(!valid_component("a\\b"));
1821 assert!(!valid_component("a\0b"));
1822 }
1823
1824 #[test]
1827 fn cold_start_full_export_covers_every_table() {
1828 let pair = Pair::new();
1829 let a = &pair.a;
1830 let s = session(a, "hello");
1831 a.add_user_message(&s, "hi").unwrap();
1832 a.log_usage(
1833 "openrouter",
1834 "m",
1835 1,
1836 2,
1837 0,
1838 0,
1839 Some(0.1),
1840 true,
1841 Some(&s),
1842 None,
1843 )
1844 .unwrap();
1845 a.add_citations(&space(a, "default"), "r.md", &[("https://x".into(), None)])
1846 .unwrap();
1847 add_session_sources(a, &s, &["https://x".to_string()]);
1848 a.create_watch(&space(a, "default"), "topic", 24, &s)
1849 .unwrap();
1850 a.set_reasoning("m", Some("high")).unwrap();
1851 a.set_setting("theme", "dark").unwrap();
1852 a.create_space("work").unwrap();
1856 let persona = crate::db::Persona {
1857 name: "p".to_string(),
1858 model: "m".to_string(),
1859 blurb: "b".to_string(),
1860 };
1861 a.save_swarm_personas(&s, &[persona]).unwrap();
1862 a.upsert_file(&space(a, "default"), "f.txt", "deadbeef", 3, "ok")
1863 .unwrap();
1864
1865 let cs = build_changeset(a, None, "a").unwrap();
1866 let tables: HashSet<&str> = cs.rows.iter().map(|r| r.table.as_str()).collect();
1867 for expected in [
1868 "sessions",
1869 "swarm_personas",
1870 "model_prefs",
1871 "spaces",
1872 "files",
1873 "watches",
1874 "app_settings",
1875 "session_sources",
1876 "messages",
1877 "usage_log",
1878 "citations",
1879 ] {
1880 assert!(tables.contains(expected), "missing {expected}");
1881 }
1882 assert_eq!(cs.files.len(), 1);
1884 assert_eq!(cs.files[0].space_id, space(a, "default"));
1885 assert_eq!(cs.files[0].name, "f.txt");
1886 assert_eq!(cs.files[0].hash, "deadbeef");
1887 assert_eq!(cs.files[0].size, 3);
1888 let personas: Vec<_> = cs
1890 .rows
1891 .iter()
1892 .filter(|r| r.table == "swarm_personas")
1893 .collect();
1894 assert_eq!(personas.len(), 1);
1895 assert_eq!(personas[0].row["session_id"], s);
1896 assert_eq!(personas[0].row["name"], "p");
1897 assert!(cs.ack.is_none());
1898 assert!(
1900 !cs.rows
1901 .iter()
1902 .any(|r| r.table == "app_settings" && r.row["scope"] == "local")
1903 );
1904 }
1905
1906 #[test]
1907 fn export_resumes_past_acked_cursor_only() {
1908 let pair = Pair::new();
1909 let a = &pair.a;
1910 let s1 = session(a, "one");
1911 let cs1 = build_changeset(a, None, "a").unwrap();
1912 assert!(!cs1.rows.is_empty());
1913
1914 let cs_again = build_changeset(a, Some("peer-x"), "a").unwrap();
1916 assert_eq!(cs_again.rows.len(), cs1.rows.len());
1917
1918 let sess_pos = cs1
1920 .rows
1921 .iter()
1922 .find(|r| r.table == "sessions" && r.row["id"] == s1)
1923 .map(|r| r.row["updated_at"].as_str().unwrap().to_string())
1924 .unwrap();
1925 a.set_sync_state("peer-x", "sessions", None, Some(&sess_pos))
1926 .unwrap();
1927 let cs2 = build_changeset(a, Some("peer-x"), "a").unwrap();
1928 assert!(!cs2.rows.iter().any(|r| r.table == "sessions"));
1929 let _s2 = session(a, "two");
1931 let cs3 = build_changeset(a, Some("peer-x"), "a").unwrap();
1932 assert!(
1933 cs3.rows
1934 .iter()
1935 .any(|r| r.table == "sessions" && r.row["title"] == "two")
1936 );
1937 let _ = s1;
1938 }
1939
1940 #[test]
1943 fn lww_newer_wins_older_loses() {
1944 let pair = Pair::new();
1945 let a = &pair.a;
1946 let b = &pair.b;
1947 let sa = session(a, "from a");
1948 let sb = session(b, "from b");
1949 b.conn()
1952 .execute("UPDATE sessions SET id = ?1 WHERE id = ?2", (&sa, &sb))
1953 .unwrap();
1954 set_session_updated(a, &sa, "2026-02-01T00:00:00Z");
1955 set_session_updated(b, &sa, "2026-01-01T00:00:00Z");
1956 pair.exchange_ab();
1957 let title = |db: &Db| db.get_session(&sa).unwrap().unwrap().title.clone();
1958 assert_eq!(title(a), "from a");
1959 assert_eq!(title(b), "from a");
1960 let cs = build_changeset(b, None, "b").unwrap();
1962 let _ = apply_changeset(&pair.b, &pair.space_b(), &cs, None).unwrap();
1963 assert_eq!(title(a), "from a");
1964 }
1965
1966 fn set_session_updated(db: &Db, id: &str, at: &str) {
1967 set_updated(db, "sessions", id, at);
1968 }
1969
1970 #[test]
1971 fn equal_timestamp_keeps_local_and_is_stable() {
1972 let pair = Pair::new();
1973 let a = &pair.a;
1974 let b = &pair.b;
1975 let sa = session(a, "a's title");
1976 let sb = session(b, "b's title");
1977 let sid = sa.clone();
1978 b.conn()
1979 .execute("UPDATE sessions SET id = ?1 WHERE id = ?2", (&sid, &sb))
1980 .unwrap();
1981 set_session_updated(a, &sid, "2026-01-01T00:00:00Z");
1985 set_session_updated(b, &sid, "2026-01-01T00:00:00Z");
1986 let title_a = a.get_session(&sid).unwrap().unwrap().title.clone();
1987 let title_b = b.get_session(&sid).unwrap().unwrap().title.clone();
1988 pair.exchange();
1989 assert_eq!(a.get_session(&sid).unwrap().unwrap().title, title_a);
1990 assert_eq!(b.get_session(&sid).unwrap().unwrap().title, title_b);
1991 pair.exchange();
1992 assert_eq!(a.get_session(&sid).unwrap().unwrap().title, title_a);
1993 assert_eq!(b.get_session(&sid).unwrap().unwrap().title, title_b);
1994 }
1995
1996 #[test]
1997 fn clock_skew_loser_converges() {
1998 let pair = Pair::new();
1999 let a = &pair.a;
2000 let b = &pair.b;
2001 let sa = session(a, "fast clock");
2002 let sb = session(b, "slow clock");
2003 let sid = sa.clone();
2004 b.conn()
2005 .execute("UPDATE sessions SET id = ?1 WHERE id = ?2", (&sid, &sb))
2006 .unwrap();
2007 set_session_updated(a, &sid, "2026-02-01T00:00:00Z");
2009 set_session_updated(b, &sid, "2026-01-01T00:00:00Z");
2010 pair.exchange();
2011 assert_eq!(a.get_session(&sid).unwrap().unwrap().title, "fast clock");
2012 assert_eq!(b.get_session(&sid).unwrap().unwrap().title, "fast clock");
2013 }
2014
2015 #[test]
2016 fn scope_local_setting_never_syncs_or_applies() {
2017 let pair = Pair::new();
2018 let a = &pair.a;
2019 a.set_setting("searxng_url", "http://localhost:8888")
2020 .unwrap();
2021 a.set_setting("theme", "dark").unwrap();
2022 let cs = build_changeset(a, None, "a").unwrap();
2023 let keys: Vec<&str> = cs
2024 .rows
2025 .iter()
2026 .filter(|r| r.table == "app_settings")
2027 .map(|r| r.row["key"].as_str().unwrap())
2028 .collect();
2029 assert_eq!(keys, vec!["theme"]);
2030 let mut evil = cs.clone();
2032 evil.rows.push(RowChange {
2033 table: "app_settings".to_string(),
2034 row: serde_json::json!({
2035 "key": "searxng_url", "value": "http://evil", "scope": "local",
2036 "updated_at": "2099-01-01T00:00:00Z",
2037 }),
2038 });
2039 let (summary, _) = apply_changeset(&pair.b, &pair.space_b(), &evil, None).unwrap();
2040 assert!(summary.rows_skipped >= 1);
2041 let settings = pair.b.load_settings().unwrap();
2042 assert!(!settings.iter().any(|(k, _)| k == "searxng_url"));
2043 }
2044
2045 #[test]
2048 fn messages_union_dedupes_by_id() {
2049 let pair = Pair::new();
2050 let a = &pair.a;
2051 let b = &pair.b;
2052 let sa = session(a, "shared");
2053 let cs = build_changeset(a, None, "a").unwrap();
2055 let _ = apply_changeset(b, &pair.space_b(), &cs, None).unwrap();
2056 a.add_user_message(&sa, "from a").unwrap();
2057 let sb = b.get_session(&sa).unwrap().unwrap().id;
2058 b.add_user_message(&sb, "from b").unwrap();
2059 pair.exchange();
2060 let count = |db: &Db| db.load_messages(&sa).unwrap().len();
2061 assert_eq!(count(a), 2);
2062 assert_eq!(count(b), 2);
2063 let (sa2, _) = pair.exchange_ab();
2065 assert_eq!(count(a), 2);
2066 assert_eq!(count(b), 2);
2067 assert_eq!(sa2.rows_applied, 0);
2068 }
2069
2070 #[test]
2071 fn usage_and_citations_dedupe_by_sync_id() {
2072 let pair = Pair::new();
2073 let a = &pair.a;
2074 let b = &pair.b;
2075 let s = session(a, "shared");
2076 a.log_usage("openrouter", "m", 1, 2, 0, 0, None, false, Some(&s), None)
2077 .unwrap();
2078 let cs = build_changeset(a, None, "a").unwrap();
2079 let _ = apply_changeset(b, &pair.space_b(), &cs, None).unwrap();
2080 b.log_usage("openrouter", "m", 3, 4, 0, 0, None, false, Some(&s), None)
2081 .unwrap();
2082 a.add_citations(&space(a, "default"), "r.md", &[("https://a".into(), None)])
2083 .unwrap();
2084 let cs2 = build_changeset(a, None, "a").unwrap();
2085 let _ = apply_changeset(b, &pair.space_b(), &cs2, None).unwrap();
2086 b.add_citations(&space(b, "default"), "r2.md", &[("https://b".into(), None)])
2087 .unwrap();
2088 pair.exchange();
2089 let usage = |db: &Db| {
2090 db.conn()
2091 .query_row("SELECT COUNT(*) FROM usage_log", [], |r| r.get::<_, i64>(0))
2092 .unwrap()
2093 };
2094 let cites = |db: &Db| {
2095 db.conn()
2096 .query_row("SELECT COUNT(*) FROM citations", [], |r| r.get::<_, i64>(0))
2097 .unwrap()
2098 };
2099 assert_eq!(usage(a), 2);
2100 assert_eq!(usage(b), 2);
2101 assert_eq!(cites(a), 2);
2102 assert_eq!(cites(b), 2);
2103 for db in [a, b] {
2105 let dupes: i64 = db
2106 .conn()
2107 .query_row(
2108 "SELECT COUNT(*) FROM (SELECT sync_id FROM usage_log UNION ALL \
2109 SELECT sync_id FROM citations) GROUP BY sync_id HAVING COUNT(*) > 1",
2110 [],
2111 |r| r.get(0),
2112 )
2113 .unwrap_or(0);
2114 assert_eq!(dupes, 0);
2115 }
2116 }
2117
2118 #[test]
2121 fn two_way_convergence_and_then_silence() {
2122 let pair = Pair::new();
2123 let a = &pair.a;
2124 let b = &pair.b;
2125 let sa = session(a, "a's chat");
2127 a.add_user_message(&sa, "from a").unwrap();
2128 a.set_setting("theme", "dark").unwrap();
2129 a.set_reasoning("m1", Some("high")).unwrap();
2130 a.log_usage("openrouter", "m1", 1, 2, 0, 0, None, false, Some(&sa), None)
2131 .unwrap();
2132 let sb = session(b, "b's chat");
2133 b.add_user_message(&sb, "from b").unwrap();
2134 b.add_user_message(&sb, "and another").unwrap();
2135 b.create_watch(&space(b, "default"), "topic", 24, &sb)
2136 .unwrap();
2137
2138 pair.exchange();
2139 assert_eq!(dump(a), dump(b), "devices must converge");
2140
2141 let (sa2, sb2) = pair.exchange_ab();
2143 assert_eq!(sa2.rows_applied, 0);
2144 assert_eq!(sb2.rows_applied, 0);
2145 assert_eq!(dump(a), dump(b));
2146 }
2147
2148 #[test]
2149 fn idempotent_reimport_is_a_noop() {
2150 let pair = Pair::new();
2151 let a = &pair.a;
2152 let b = &pair.b;
2153 let s = session(a, "hello");
2154 a.add_user_message(&s, "hi").unwrap();
2155 a.log_usage("openrouter", "m", 1, 2, 0, 0, None, false, Some(&s), None)
2156 .unwrap();
2157 let cs = build_changeset(a, None, "a").unwrap();
2158 let (first, _) = apply_changeset(b, &pair.space_b(), &cs, None).unwrap();
2159 assert!(first.rows_applied > 0);
2160 let (again, _) = apply_changeset(b, &pair.space_b(), &cs, None).unwrap();
2161 assert_eq!(again.rows_applied, 0);
2162 assert_eq!(again.tombstones_applied, 0);
2163 assert!(again.warnings.is_empty());
2164 }
2165
2166 #[test]
2167 fn session_sources_lww_flag_propagates() {
2168 let pair = Pair::new();
2169 let a = &pair.a;
2170 let b = &pair.b;
2171 let s = session(a, "s");
2172 add_session_sources(a, &s, &["https://x".to_string()]);
2173 let cs = build_changeset(a, None, "a").unwrap();
2174 let _ = apply_changeset(b, &pair.space_b(), &cs, None).unwrap();
2175 a.set_source_flag(&s, "https://x", Some("pinned")).unwrap();
2176 pair.exchange_ab();
2177 let flags: Vec<(String, String)> = b
2178 .conn()
2179 .prepare("SELECT session_id, flag FROM session_sources")
2180 .unwrap()
2181 .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
2182 .unwrap()
2183 .collect::<rusqlite::Result<Vec<_>>>()
2184 .unwrap();
2185 assert_eq!(flags, vec![(s, "pinned".to_string())]);
2186 }
2187
2188 #[test]
2191 fn session_tombstone_cascades_messages_and_sources() {
2192 let pair = Pair::new();
2193 let a = &pair.a;
2194 let b = &pair.b;
2195 let s = session(a, "doomed");
2196 a.add_user_message(&s, "one").unwrap();
2197 add_session_sources(a, &s, &["https://x".to_string()]);
2198 pair.exchange_ab();
2199 assert_eq!(dump(a), dump(b));
2200 a.delete_session(&s).unwrap();
2201 pair.exchange_ab();
2202 assert_eq!(dump(a), dump(b));
2203 let sessions = b.list_sessions(&space(b, "default")).unwrap();
2204 assert!(sessions.iter().all(|s| s.title != "doomed"));
2205 let messages: i64 = b
2206 .conn()
2207 .query_row("SELECT COUNT(*) FROM messages", [], |r| r.get(0))
2208 .unwrap();
2209 assert_eq!(messages, 0);
2210 let sources: i64 = b
2211 .conn()
2212 .query_row("SELECT COUNT(*) FROM session_sources", [], |r| r.get(0))
2213 .unwrap();
2214 assert_eq!(sources, 0);
2215 }
2216
2217 #[test]
2218 fn space_tombstone_removes_row_and_dir() {
2219 let pair = Pair::new();
2220 let a = &pair.a;
2221 let b = &pair.b;
2222 let sp = a.create_space("work").unwrap();
2223 pair.space_a().ensure_space_dir("work").unwrap();
2224 pair.space_b().ensure_space_dir("work").unwrap();
2225 write_blob(&pair.space_b(), "work", "f.txt", b"content");
2226 pair.exchange_ab();
2227 assert!(pair.space_b().files_dir("work").join("f.txt").exists());
2228 a.delete_space(&sp.id).unwrap();
2229 pair.space_a().remove_space_dir("work").unwrap();
2230 pair.exchange_ab();
2231 assert!(b.list_spaces().unwrap().iter().all(|s| s.name != "work"));
2232 assert!(!pair.space_b().space_dir("work").exists());
2233 }
2234
2235 #[test]
2236 fn file_tombstone_removes_row_and_blob() {
2237 let pair = Pair::new();
2238 let a = &pair.a;
2239 let b = &pair.b;
2240 let sid = space(a, "default");
2241 write_blob(&pair.space_a(), "default", "f.txt", b"content");
2242 let hash = sha256_hex(b"content");
2243 let fid = a.upsert_file(&sid, "f.txt", &hash, 7, "ok").unwrap();
2244 pair.exchange_ab();
2245 assert!(pair.space_b().files_dir("default").join("f.txt").exists());
2246 a.delete_file(&fid).unwrap();
2247 std::fs::remove_file(pair.space_a().files_dir("default").join("f.txt")).unwrap();
2248 pair.exchange_ab();
2249 assert_eq!(dump(a), dump(b));
2250 let files = b.list_files(&sid).unwrap();
2251 assert!(files.is_empty());
2252 assert!(!pair.space_b().files_dir("default").join("f.txt").exists());
2253 }
2254
2255 #[test]
2256 fn default_space_tombstone_is_ignored() {
2257 let pair = Pair::new();
2258 let a = &pair.a;
2259 let mut evil = build_changeset(a, None, "a").unwrap();
2260 evil.tombstones = vec![Tombstone {
2261 origin_id: 1,
2262 table_name: "spaces".to_string(),
2263 row_id: DEFAULT_SPACE.to_string(),
2264 deleted_at: "2026-01-01T00:00:00Z".to_string(),
2265 }];
2266 let (summary, _) = apply_changeset(&pair.b, &pair.space_b(), &evil, None).unwrap();
2267 assert_eq!(summary.tombstones_applied, 0);
2268 assert!(pair.b.default_space_id().is_ok());
2269 }
2270
2271 #[test]
2272 fn tombstone_cursor_advances_and_no_resends() {
2273 let pair = Pair::new();
2274 let a = &pair.a;
2275 let b = &pair.b;
2276 let s = session(a, "doomed");
2277 pair.exchange_ab();
2278 a.delete_session(&s).unwrap();
2279 pair.exchange_ab();
2280 let cs = build_changeset(a, Some(&b.device_id().unwrap()), "a").unwrap();
2282 assert!(cs.tombstones.is_empty());
2283 let state = db_state(b);
2285 assert!(state.iter().any(|st| {
2286 st.peer_id == a.device_id().unwrap()
2287 && st.table_name == "sync_tombstones"
2288 && st.pull_cursor.is_some()
2289 }));
2290 }
2291
2292 #[test]
2295 fn swarm_roster_follows_winning_session() {
2296 let pair = Pair::new();
2297 let a = &pair.a;
2298 let b = &pair.b;
2299 let s = session(a, "roundtable");
2300 let p1 = crate::db::Persona {
2301 name: "p1".to_string(),
2302 model: "m".to_string(),
2303 blurb: "b".to_string(),
2304 };
2305 a.save_swarm_personas(&s, &[p1]).unwrap();
2306 let cs = build_changeset(a, None, "a").unwrap();
2308 let _ = apply_changeset(b, &pair.space_b(), &cs, None).unwrap();
2309 let p2 = crate::db::Persona {
2311 name: "p2".to_string(),
2312 model: "m".to_string(),
2313 blurb: "b".to_string(),
2314 };
2315 let p3 = crate::db::Persona {
2316 name: "p3".to_string(),
2317 model: "m".to_string(),
2318 blurb: "b".to_string(),
2319 };
2320 b.save_swarm_personas(&s, &[p2, p3]).unwrap();
2321 let t2 = session_updated(b, &s);
2322 let p1b = crate::db::Persona {
2324 name: "p1".to_string(),
2325 model: "m".to_string(),
2326 blurb: "b".to_string(),
2327 };
2328 a.save_swarm_personas(&s, &[p1b]).unwrap();
2329 set_session_updated(a, &s, &minus_one_second(&t2));
2330 let cs_a = build_changeset(a, None, "a").unwrap();
2333 let (_, cursors) = apply_changeset(b, &pair.space_b(), &cs_a, None).unwrap();
2334 let mut reply = build_changeset(b, Some(&cs_a.device_id), "b").unwrap();
2335 reply.ack = Some(cursors);
2336 let _ = apply_changeset(a, &pair.space_a(), &reply, None).unwrap();
2337 let names = |db: &Db| -> Vec<String> {
2338 db.list_swarm_personas(&s)
2339 .unwrap()
2340 .iter()
2341 .map(|p| p.name.clone())
2342 .collect()
2343 };
2344 assert_eq!(names(a), vec!["p2", "p3"]);
2345 assert_eq!(names(b), vec!["p2", "p3"]);
2346 }
2347
2348 #[test]
2351 fn file_blobs_transfer_keep_and_report_missing() {
2352 let pair = Pair::new();
2353 let a = &pair.a;
2354 let sid = space(a, "default");
2355 write_blob(&pair.space_a(), "default", "notes.txt", b"hello sync");
2356 let hash = sha256_hex(b"hello sync");
2357 a.upsert_file(&sid, "notes.txt", &hash, 11, "ok").unwrap();
2358
2359 let cs = build_changeset(a, None, "a").unwrap();
2362 let (summary, _) = apply_changeset(&pair.b, &pair.space_b(), &cs, None).unwrap();
2363 assert_eq!(summary.files_missing.len(), 1);
2364 assert_eq!(summary.files_missing[0].name, "notes.txt");
2365 assert!(
2366 !pair
2367 .space_b()
2368 .files_dir("default")
2369 .join("notes.txt")
2370 .exists()
2371 );
2372
2373 export_blobs(a, &pair.space_a(), &cs, &pair.dir).unwrap();
2376 let (summary2, _) =
2377 apply_changeset(&pair.b, &pair.space_b(), &cs, Some(&pair.dir)).unwrap();
2378 assert_eq!(summary2.files_pulled, 0);
2379 assert!(summary2.files_missing.is_empty());
2380 assert!(
2381 !pair
2382 .space_b()
2383 .files_dir("default")
2384 .join("notes.txt")
2385 .exists()
2386 );
2387
2388 write_blob(&pair.space_a(), "default", "notes.txt", b"hello sync v2");
2391 let hash2 = sha256_hex(b"hello sync v2");
2392 a.upsert_file(&sid, "notes.txt", &hash2, 13, "ok").unwrap();
2393 let cs2 = build_changeset(a, None, "a").unwrap();
2394 export_blobs(a, &pair.space_a(), &cs2, &pair.dir).unwrap();
2395 let (summary3, _) =
2396 apply_changeset(&pair.b, &pair.space_b(), &cs2, Some(&pair.dir)).unwrap();
2397 assert_eq!(summary3.files_pulled, 1);
2398 assert!(summary3.files_missing.is_empty());
2399 assert_eq!(
2400 std::fs::read(pair.space_b().files_dir("default").join("notes.txt")).unwrap(),
2401 b"hello sync v2"
2402 );
2403 }
2404
2405 #[test]
2406 fn stale_blob_in_channel_is_never_applied() {
2407 let pair = Pair::new();
2408 let a = &pair.a;
2409 let sid = space(a, "default");
2410 write_blob(&pair.space_a(), "default", "f.txt", b"real");
2411 let hash = sha256_hex(b"real");
2412 a.upsert_file(&sid, "f.txt", &hash, 4, "ok").unwrap();
2413 let cs = build_changeset(a, None, "a").unwrap();
2414 let blob_dir = pair.dir.join("blobs").join(&sid);
2416 std::fs::create_dir_all(&blob_dir).unwrap();
2417 std::fs::write(blob_dir.join("f.txt"), b"stale").unwrap();
2418 let (summary, _) = apply_changeset(&pair.b, &pair.space_b(), &cs, Some(&pair.dir)).unwrap();
2419 assert_eq!(summary.files_pulled, 0);
2420 assert_eq!(summary.files_missing.len(), 1);
2421 assert!(!pair.space_b().files_dir("default").join("f.txt").exists());
2422 }
2423
2424 #[test]
2425 fn unsafe_blob_names_cannot_escape() {
2426 let pair = Pair::new();
2427 let b = &pair.b;
2428 let mut cs = build_changeset(b, None, "b").unwrap();
2429 cs.rows.push(RowChange {
2430 table: "files".to_string(),
2431 row: serde_json::json!({
2432 "id": "f1", "space_id": "sp", "name": "../escape.txt",
2433 "hash": "x", "size": 1,
2434 "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z",
2435 }),
2436 });
2437 cs.files.push(FileChange {
2438 space_id: "sp".to_string(),
2439 name: "../escape.txt".to_string(),
2440 hash: "x".to_string(),
2441 size: 1,
2442 });
2443 let (summary, _) = apply_changeset(&pair.a, &pair.space_a(), &cs, Some(&pair.dir)).unwrap();
2444 assert!(summary.warnings.iter().any(|w| w.contains("unsafe")));
2445 assert!(!pair.dir.join("blobs").join("sp").join("..").exists());
2446 assert!(!pair.a_root.join("escape.txt").exists());
2447 }
2448
2449 #[test]
2452 fn default_space_is_the_same_row_on_both_devices() {
2453 let pair = Pair::new();
2454 assert_eq!(pair.a.default_space_id().unwrap(), DEFAULT_SPACE);
2455 assert_eq!(pair.b.default_space_id().unwrap(), DEFAULT_SPACE);
2456 pair.exchange_ab();
2457 let spaces = pair.b.list_spaces().unwrap();
2458 assert_eq!(spaces.len(), 1, "no name collision for the default space");
2459 }
2460
2461 #[test]
2462 fn space_name_collision_renames_loser_deterministically() {
2463 let pair = Pair::new();
2464 let a = &pair.a;
2465 let b = &pair.b;
2466 let wa = a.create_space("work").unwrap();
2467 let wb = b.create_space("work").unwrap();
2468 set_updated(a, "spaces", &wa.id, "2026-02-01T00:00:00Z");
2470 set_updated(b, "spaces", &wb.id, "2026-01-01T00:00:00Z");
2471 pair.exchange();
2472 let names_a: Vec<String> = a
2475 .list_spaces()
2476 .unwrap()
2477 .iter()
2478 .map(|s| s.name.clone())
2479 .collect();
2480 let names_b: Vec<String> = b
2481 .list_spaces()
2482 .unwrap()
2483 .iter()
2484 .map(|s| s.name.clone())
2485 .collect();
2486 let expected = format!("work-{}", short_id(&wb.id));
2487 assert!(names_a.contains(&"work".to_string()));
2488 assert!(names_a.contains(&expected));
2489 assert_eq!(names_a, names_b);
2490 assert_eq!(dump(a), dump(b));
2491 pair.exchange();
2493 let names_a2: Vec<String> = a
2494 .list_spaces()
2495 .unwrap()
2496 .iter()
2497 .map(|s| s.name.clone())
2498 .collect();
2499 assert_eq!(names_a2, names_a);
2500 }
2501
2502 #[test]
2503 fn space_rename_moves_the_dir() {
2504 let pair = Pair::new();
2505 let a = &pair.a;
2506 let sp = a.create_space("work").unwrap();
2507 pair.space_a().ensure_space_dir("work").unwrap();
2508 pair.space_b().ensure_space_dir("work").unwrap();
2509 write_blob(&pair.space_b(), "work", "f.txt", b"content");
2510 pair.exchange_ab();
2511 assert!(pair.space_b().files_dir("work").join("f.txt").exists());
2512 a.rename_space(&sp.id, "work-2").unwrap();
2513 pair.space_a().rename_space_dir("work", "work-2").unwrap();
2514 pair.exchange_ab();
2515 assert!(
2516 pair.b
2517 .list_spaces()
2518 .unwrap()
2519 .iter()
2520 .any(|s| s.name == "work-2")
2521 );
2522 assert!(pair.space_b().files_dir("work-2").join("f.txt").exists());
2523 assert!(!pair.space_b().space_dir("work").exists());
2524 }
2525
2526 #[test]
2529 fn bundle_roundtrip_carries_changeset_and_blobs() {
2530 let pair = Pair::new();
2531 let a = &pair.a;
2532 let sid = space(a, "default");
2533 write_blob(&pair.space_a(), "default", "f.txt", b"payload");
2534 let hash = sha256_hex(b"payload");
2535 a.upsert_file(&sid, "f.txt", &hash, 7, "ok").unwrap();
2536 let cs = build_changeset(a, None, "a").unwrap();
2537 let dest = pair.dir.join("out.bundle");
2538 let blobs = write_bundle(
2539 a,
2540 &pair.space_a(),
2541 &cs,
2542 std::fs::File::create(&dest).unwrap(),
2543 )
2544 .unwrap();
2545 assert_eq!(blobs, 1);
2546 let unpacked = pair.dir.join("unpacked");
2547 let back = unpack_bundle(&dest, &unpacked).unwrap();
2548 assert_eq!(back.device_id, cs.device_id);
2549 assert_eq!(back.rows.len(), cs.rows.len());
2550 assert_eq!(
2551 std::fs::read(unpacked.join("blobs").join(&sid).join("f.txt")).unwrap(),
2552 b"payload"
2553 );
2554 }
2555
2556 #[test]
2557 fn bundle_rejects_escaping_paths() {
2558 let pair = Pair::new();
2559 let dest = pair.dir.join("evil.bundle");
2560 let file = std::fs::File::create(&dest).unwrap();
2561 let mut zip = zip::ZipWriter::new(file);
2562 let opts = zip::write::SimpleFileOptions::default();
2563 zip.start_file("changeset.json", opts).unwrap();
2564 serde_json::to_writer(
2565 &mut zip,
2566 &Changeset {
2567 device_id: "x".to_string(),
2568 device_name: "x".to_string(),
2569 ack: None,
2570 rows: Vec::new(),
2571 tombstones: Vec::new(),
2572 files: Vec::new(),
2573 generated_at: "t".to_string(),
2574 },
2575 )
2576 .unwrap();
2577 zip.start_file("blobs/../escape.txt", opts).unwrap();
2578 zip.write_all(b"nope").unwrap();
2579 zip.finish().unwrap();
2580 let unpacked = pair.dir.join("evil-out");
2581 assert!(unpack_bundle(&dest, &unpacked).is_err());
2582 assert!(!pair.dir.join("escape.txt").exists());
2583 }
2584
2585 #[test]
2588 fn ack_built_from_pull_cursors_advances_peer_push() {
2589 let pair = Pair::new();
2590 let a = &pair.a;
2591 let b = &pair.b;
2592 let _s = session(a, "s");
2593 let cs = build_changeset(a, None, "a").unwrap();
2594 let (_, cursors) = apply_changeset(b, &pair.space_b(), &cs, None).unwrap();
2595 let mut reply = build_changeset(b, Some(&cs.device_id), "b").unwrap();
2597 reply.ack = Some(cursors);
2598 let _ = apply_changeset(a, &pair.space_a(), &reply, None).unwrap();
2599 let bid = b.device_id().unwrap();
2602 let mut next = build_changeset(a, Some(&bid), "a").unwrap();
2603 next.ack = Some(build_ack(a, &bid).unwrap());
2604 let (summary, _) = apply_changeset(b, &pair.space_b(), &next, None).unwrap();
2605 assert!(summary.acks_applied >= 1);
2606 let after = build_changeset(b, Some(&a.device_id().unwrap()), "b").unwrap();
2608 assert!(after.rows.is_empty());
2609 }
2610
2611 #[test]
2612 fn acks_are_only_honored_for_this_device() {
2613 let pair = Pair::new();
2614 let a = &pair.a;
2615 let b = &pair.b;
2616 let _s = session(a, "s");
2617 pair.exchange_ab();
2618 let state = db_state(a);
2620 assert!(state.iter().any(|st| {
2621 st.peer_id == b.device_id().unwrap()
2622 && st.table_name == "sessions"
2623 && st.push_cursor.is_some()
2624 }));
2625 let forged = Changeset {
2627 device_id: b.device_id().unwrap(),
2628 device_name: "b".to_string(),
2629 ack: Some(vec![PeerCursor {
2630 peer_id: "someone-else".to_string(),
2631 table_name: "sessions".to_string(),
2632 cursor: "2099-01-01T00:00:00Z".to_string(),
2633 }]),
2634 rows: Vec::new(),
2635 tombstones: Vec::new(),
2636 files: Vec::new(),
2637 generated_at: "t".to_string(),
2638 };
2639 let (summary, _) = apply_changeset(a, &pair.space_a(), &forged, None).unwrap();
2640 assert_eq!(summary.acks_applied, 0);
2641 }
2642
2643 #[test]
2644 fn push_cursor_advances_only_on_ack() {
2645 let pair = Pair::new();
2646 let a = &pair.a;
2647 let b = &pair.b;
2648 let _s = session(a, "s");
2649 let bid = b.device_id().unwrap();
2650 let _ = build_changeset(a, Some(&bid), "a").unwrap();
2652 let after_export = db_state(a);
2653 assert!(after_export.iter().all(|st| st.peer_id != bid));
2654 let cs = build_changeset(a, None, "a").unwrap();
2656 let (_, cursors) = apply_changeset(b, &pair.space_b(), &cs, None).unwrap();
2657 let mut reply = build_changeset(b, Some(&cs.device_id), "b").unwrap();
2658 reply.ack = Some(cursors);
2659 let _ = apply_changeset(a, &pair.space_a(), &reply, None).unwrap();
2660 let state = db_state(a);
2661 assert!(state.iter().any(|st| {
2662 st.peer_id == bid && st.table_name == "sessions" && st.push_cursor.is_some()
2663 }));
2664 }
2665}