Files
Marcos Paulo 181aad91e6 feat: sync, accounts, live editing, vault launcher, compact layout
Sync and the server
- crates/sync: revision ledger (stale writers conflict, edit beats delete), client planner, three-way merge, conflict names, path and hash gates
- apps/notescraft-sync-server: pairing, device tokens, change log with long poll, blobs, accounts with many vaults, roles, invites, audit log, quota, GC, readiness, request ids
- server history (revisions, restore, retention), chunked resumable uploads, live editing endpoints (SSE rooms, presence, flush through the ledger)
- engine: HttpRemote, sync_once, SyncWorker, sync.* commands, share.* and server.* commands, live session client, note.capture, vault catalog and vaults.* commands

Editing together
- crates/collab: operational transformation core (TextOp, transform, client state machine, room, presence)
- ui-egui: Settings > Sync, sync pill, conflicts, Share dialog, live pill and remote carets, launcher Connect and Join flows

App shell
- vault launcher (desktop and phone layouts) wired into the app host (--launcher, File > Vaults...)
- compact (phone) layout with a bottom tab bar

Verified with cargo xtask ci (fmt, clippy -D warnings, workspace tests, layers, tree, i18n). Android and iOS shells are not built: the build machine has no NDK or Xcode.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 07:46:24 -03:00

248 lines
9.6 KiB
Rust

//! Ledger rules, client planning, merge and naming.
use std::collections::BTreeMap;
use notescraft_sync::{
Action, Base, Changes, Error, FileRecord, Ledger, Merge, Outcome, Write, check_path, check_syncable, conflict_path, hash_hex, merge3, plan,
};
fn put(path: &str, body: &str, base_seq: u64) -> Write {
Write { path: path.into(), hash: Some(hash_hex(body.as_bytes())), size: body.len() as u64, mtime_ms: 1, base_seq }
}
fn del(path: &str, base_seq: u64) -> Write {
Write { path: path.into(), hash: None, size: 0, mtime_ms: 1, base_seq }
}
fn rec(path: &str, body: &str, seq: u64, deleted: bool) -> FileRecord {
FileRecord { path: path.into(), hash: hash_hex(body.as_bytes()), size: body.len() as u64, mtime_ms: 1, seq, deleted }
}
#[test]
fn hash_matches_the_standard_vector() {
assert_eq!(hash_hex(b"abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
}
#[test]
fn hostile_paths_are_refused() {
for bad in ["", "/a.md", "../x.md", "a/../b.md", "a//b.md", "a\\b.md", "./a.md", "a/\u{0}b", "a/"] {
assert!(matches!(check_path(bad), Err(Error::InvalidPath(_))), "{bad:?}");
}
assert!(check_path(&"a".repeat(1025)).is_err());
assert!(check_path("notes/a b.md").is_ok());
}
#[test]
fn private_folders_never_sync() {
for path in [".notescraft/history/x.md", ".trash/a.md", ".git/config", ".obsidian/app.json"] {
assert!(matches!(check_syncable(path), Err(Error::Excluded(_))), "{path}");
}
assert!(check_syncable("obsidian/a.md").is_ok());
}
#[test]
fn ledger_hands_out_increasing_revisions() {
let mut ledger = Ledger::new();
assert!(matches!(ledger.apply(put("a.md", "one", 0)), Ok(Outcome::Applied(r)) if r.seq == 1));
assert!(matches!(ledger.apply(put("b.md", "two", 0)), Ok(Outcome::Applied(r)) if r.seq == 2));
assert!(matches!(ledger.apply(put("a.md", "three", 1)), Ok(Outcome::Applied(r)) if r.seq == 3));
assert_eq!(ledger.head(), 3);
}
#[test]
fn identical_write_spends_no_revision() {
let mut ledger = Ledger::new();
ledger.apply(put("a.md", "one", 0)).ok();
assert!(matches!(ledger.apply(put("a.md", "one", 0)), Ok(Outcome::Unchanged(_))));
assert_eq!(ledger.head(), 1);
}
#[test]
fn stale_writer_conflicts_and_changes_nothing() {
let mut ledger = Ledger::new();
ledger.apply(put("a.md", "one", 0)).ok();
ledger.apply(put("a.md", "two", 1)).ok();
let outcome = ledger.apply(put("a.md", "mine", 1));
assert!(matches!(&outcome, Ok(Outcome::Conflict(r)) if r.seq == 2 && r.hash == hash_hex(b"two")));
assert_eq!(ledger.head(), 2);
}
#[test]
fn edit_beats_delete_both_ways() {
let mut ledger = Ledger::new();
ledger.apply(put("a.md", "one", 0)).ok();
ledger.apply(del("a.md", 1)).ok();
// a stale edit over a newer delete wins
assert!(matches!(ledger.apply(put("a.md", "edited", 1)), Ok(Outcome::Applied(r)) if !r.deleted && r.seq == 3));
// a stale delete over a newer edit conflicts
assert!(matches!(ledger.apply(del("a.md", 1)), Ok(Outcome::Conflict(r)) if !r.deleted));
}
#[test]
fn delete_rules() {
let mut ledger = Ledger::new();
assert!(matches!(ledger.apply(del("none.md", 0)), Err(Error::NotFound(_))));
ledger.apply(put("a.md", "one", 0)).ok();
assert!(matches!(ledger.apply(del("a.md", 1)), Ok(Outcome::Applied(r)) if r.deleted && r.size == 0));
assert!(matches!(ledger.apply(del("a.md", 2)), Ok(Outcome::Unchanged(_))));
}
#[test]
fn ledger_refuses_bad_input() {
let mut ledger = Ledger::new();
assert!(matches!(ledger.apply(put("../a.md", "x", 0)), Err(Error::InvalidPath(_))));
assert!(matches!(ledger.apply(put(".obsidian/a.md", "x", 0)), Err(Error::Excluded(_))));
let mut write = put("a.md", "x", 0);
write.hash = Some("XYZ".into());
assert!(matches!(ledger.apply(write), Err(Error::InvalidHash(_))));
}
#[test]
fn changes_page_and_cursor() {
let mut ledger = Ledger::new();
for n in 0..5 {
ledger.apply(put(&format!("n{n}.md"), "x", 0)).ok();
}
let page: Changes = ledger.changes_since(1, 2);
assert_eq!(page.records.iter().map(|r| r.seq).collect::<Vec<_>>(), vec![2, 3]);
assert!(page.more);
assert_eq!(page.cursor(1), 3);
let rest = ledger.changes_since(3, 10);
assert!(!rest.more);
assert_eq!(rest.cursor(3), 5);
assert_eq!(ledger.changes_since(9, 10).cursor(9), 9);
}
#[test]
fn ledger_rebuilds_from_records() {
let ledger = Ledger::from_records(vec![rec("a.md", "old", 1, false), rec("a.md", "new", 4, false), rec("b.md", "b", 2, true)]);
assert_eq!(ledger.head(), 4);
assert_eq!(ledger.get("a.md").map(|r| r.seq), Some(4));
assert_eq!(ledger.len(), 2);
}
fn bases(items: &[(&str, &str, u64)]) -> BTreeMap<String, Base> {
items.iter().map(|(p, b, s)| ((*p).to_owned(), Base { hash: hash_hex(b.as_bytes()), seq: *s })).collect()
}
fn local(items: &[(&str, &str)]) -> BTreeMap<String, String> {
items.iter().map(|(p, b)| ((*p).to_owned(), hash_hex(b.as_bytes()))).collect()
}
#[test]
fn plan_quiet_when_nothing_changed() {
assert_eq!(plan(&local(&[("a.md", "x")]), &bases(&[("a.md", "x", 3)]), &[]), Vec::<Action>::new());
}
#[test]
fn plan_uploads_new_and_edited_and_deletes() {
let actions = plan(&local(&[("new.md", "n"), ("edit.md", "e2")]), &bases(&[("edit.md", "e1", 4), ("gone.md", "g", 5)]), &[]);
assert_eq!(
actions,
vec![
Action::Upload { path: "edit.md".into(), base_seq: 4 },
Action::DeleteRemote { path: "gone.md".into(), base_seq: 5 },
Action::Upload { path: "new.md".into(), base_seq: 0 },
]
);
}
#[test]
fn plan_applies_remote_changes_to_untouched_files() {
let remote = [rec("a.md", "A2", 7, false), rec("b.md", "b", 8, true), rec("c.md", "C", 9, false)];
let actions = plan(&local(&[("a.md", "A1"), ("b.md", "b")]), &bases(&[("a.md", "A1", 3), ("b.md", "b", 3)]), &remote);
assert_eq!(actions, vec![Action::Download(remote[0].clone()), Action::DeleteLocal(remote[1].clone()), Action::Download(remote[2].clone())]);
}
#[test]
fn plan_takes_the_newest_record_per_path() {
let remote = [rec("a.md", "mid", 5, false), rec("a.md", "new", 6, false)];
let actions = plan(&local(&[("a.md", "old")]), &bases(&[("a.md", "old", 1)]), &remote);
assert_eq!(actions, vec![Action::Download(remote[1].clone())]);
}
#[test]
fn plan_ignores_records_already_seen() {
let remote = [rec("a.md", "A", 3, false)];
assert_eq!(plan(&local(&[("a.md", "A")]), &bases(&[("a.md", "A", 3)]), &remote), Vec::<Action>::new());
}
#[test]
fn plan_both_changed_reconciles_and_converges() {
let remote = [rec("a.md", "theirs", 7, false), rec("same.md", "S", 8, false)];
let actions = plan(&local(&[("a.md", "mine"), ("same.md", "S")]), &bases(&[("a.md", "base", 3), ("same.md", "old", 3)]), &remote);
assert_eq!(actions, vec![Action::Reconcile { remote: remote[0].clone(), base_hash: Some(hash_hex(b"base")) }, Action::Adopt(remote[1].clone())]);
}
#[test]
fn plan_edit_wins_over_delete() {
let remote = [rec("a.md", "A", 7, true), rec("b.md", "B2", 8, false)];
let actions = plan(&local(&[("a.md", "A edited")]), &bases(&[("a.md", "A", 3), ("b.md", "B", 3)]), &remote);
assert_eq!(actions, vec![Action::Upload { path: "a.md".into(), base_seq: 7 }, Action::Download(remote[1].clone())]);
}
#[test]
fn plan_both_deleted_adopts() {
let remote = [rec("a.md", "A", 7, true)];
assert_eq!(plan(&local(&[]), &bases(&[("a.md", "A", 3)]), &remote), vec![Action::Adopt(remote[0].clone())]);
}
#[test]
fn merge_combines_separate_regions() {
let base = "one\ntwo\nthree\nfour\nfive\n";
let ours = "ONE\ntwo\nthree\nfour\nfive\n";
let theirs = "one\ntwo\nthree\nfour\nFIVE\n";
assert_eq!(merge3(base, ours, theirs), Merge::Clean("ONE\ntwo\nthree\nfour\nFIVE\n".into()));
}
#[test]
fn merge_handles_inserts_and_deletes_apart() {
let base = "a\nb\nc\nd\ne\n";
assert_eq!(merge3(base, "a\nNEW\nb\nc\nd\ne\n", "a\nb\nc\ne\n"), Merge::Clean("a\nNEW\nb\nc\ne\n".into()));
}
#[test]
fn merge_same_change_both_sides_is_clean() {
let base = "a\nb\nc\n";
assert_eq!(merge3(base, "a\nX\nc\n", "a\nX\nc\n"), Merge::Clean("a\nX\nc\n".into()));
assert_eq!(merge3("a\nb\nc\nd\ne\n", "a\nX\nc\nd\ne\nE\n", "a\nX\nc\nd\ne\n"), Merge::Clean("a\nX\nc\nd\ne\nE\n".into()));
}
#[test]
fn merge_same_line_differently_conflicts() {
assert_eq!(merge3("a\nb\nc\n", "a\nX\nc\n", "a\nY\nc\n"), Merge::Conflict);
assert_eq!(merge3("a\nb\nc\n", "a\nc\n", "a\nY\nc\n"), Merge::Conflict);
}
#[test]
fn merge_keeps_missing_trailing_newline() {
assert_eq!(merge3("a\nb", "A\nb", "a\nB"), Merge::Clean("A\nB".into()));
assert_eq!(merge3("a\nb\nc", "A\nb\nc", "a\nb\nC"), Merge::Clean("A\nb\nC".into()));
}
#[test]
fn merge_gives_up_on_huge_input() {
let lines = |first: &str, mid: &str, last: &str| -> String {
(0..3000)
.map(|n| match n {
0 => format!("{first}\n"),
1500 => format!("{mid}\n"),
2999 => format!("{last}\n"),
_ => format!("line {n}\n"),
})
.collect()
};
let base = lines("first", "mid", "last");
// Separate regions would merge, but trimming leaves a ~9M-cell table: refuse.
assert_eq!(merge3(&base, &lines("FIRST", "mid", "LAST"), &lines("first", "MID", "last")), Merge::Conflict);
// Same shape, small: clean.
assert!(matches!(merge3("a\nb\nc\nd\ne\n", "A\nb\nc\nd\ne\n", "a\nb\nC\nd\ne\n"), Merge::Clean(_)));
}
#[test]
fn conflict_names() {
assert_eq!(conflict_path("notes/a.md", "iPhone 15!", "2026-10-19"), "notes/a (conflict iPhone15 2026-10-19).md");
assert_eq!(conflict_path("README", "pc", "d"), "README (conflict pc d)");
assert_eq!(conflict_path(".hidden", "pc", "d"), ".hidden (conflict pc d)");
}