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

87 lines
4.3 KiB
Rust

//! Store level: idempotent migration of old databases, device lifecycle, audit row order.
use notescraft_sync_server::Store;
/// The legacy schema, before `last_seen_ms`, `revoked` and `updated_ms` existed.
const LEGACY: &str = "
CREATE TABLE vaults (id TEXT PRIMARY KEY, created_ms INTEGER NOT NULL);
CREATE TABLE pairings (code_hash TEXT PRIMARY KEY, vault TEXT NOT NULL, expires_ms INTEGER NOT NULL);
CREATE TABLE devices (id TEXT PRIMARY KEY, vault TEXT NOT NULL, name TEXT NOT NULL, token_hash TEXT NOT NULL UNIQUE, created_ms INTEGER NOT NULL);
CREATE TABLE files (vault TEXT NOT NULL, path TEXT NOT NULL, hash TEXT NOT NULL, size INTEGER NOT NULL,
mtime_ms INTEGER NOT NULL, seq INTEGER NOT NULL, deleted INTEGER NOT NULL, PRIMARY KEY (vault, path));
";
fn columns_of(path: &std::path::Path, table: &str) -> Vec<String> {
let conn = rusqlite::Connection::open(path).expect("open");
let mut statement = conn.prepare(&format!("PRAGMA table_info({table})")).expect("pragma");
statement.query_map([], |row| row.get::<_, String>(1)).expect("names").collect::<rusqlite::Result<Vec<String>>>().expect("rows")
}
#[test]
fn migration_adds_missing_columns_and_runs_more_than_once() {
let dir = std::env::temp_dir().join(format!("nc-sync-store-{}", std::process::id()));
std::fs::remove_dir_all(&dir).ok();
std::fs::create_dir_all(&dir).expect("temp");
let path = dir.join("old.db");
rusqlite::Connection::open(&path).expect("open").execute_batch(LEGACY).expect("legacy schema");
Store::open(&path).expect("first open migrates");
Store::open(&path).expect("second open is idempotent");
for (table, column) in [
("devices", "last_seen_ms"),
("devices", "revoked"),
("devices", "user_id"),
("files", "updated_ms"),
("files", "editor_user"),
("pairings", "user"),
("vaults", "name"),
("vaults", "deleted_ms"),
] {
assert!(columns_of(&path, table).iter().any(|name| name == column), "{table}.{column} missing");
}
let conn = rusqlite::Connection::open(&path).expect("open");
for table in ["users", "members", "invites"] {
// `sqlite_master` row proves the table exists after the migration.
let count: u32 = conn.query_row("SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1", [table], |row| row.get(0)).expect("query");
assert_eq!(count, 1, "{table} missing");
}
std::fs::remove_dir_all(&dir).expect("cleanup");
}
#[test]
fn rotation_and_revocation_take_the_old_token_out_of_lookup() {
let store = Store::in_memory().expect("db");
store.create_user("u-1", "phone user", "#89b4fa", 0).expect("user");
store.add_device("d", "v", "u-1", "phone", "old-hash", 1).expect("add");
assert_eq!(store.device_for("old-hash").expect("query"), Some(("d".to_owned(), "u-1".to_owned())));
assert!(store.rotate_device("d", "v", "new-hash").expect("rotate"));
assert_eq!(store.device_for("old-hash").expect("query"), None);
assert_eq!(store.device_for("new-hash").expect("query"), Some(("d".to_owned(), "u-1".to_owned())));
// `last_seen_ms` moves at most once a minute.
store.touch_device("new-hash", 10_000).expect("first touch");
store.touch_device("new-hash", 40_000).expect("too soon, ignored");
let (_, _, _, last_seen): (String, String, u64, Option<u64>) = store.list_devices("v").expect("rows").remove(0);
assert_eq!(last_seen, Some(10_000));
store.touch_device("new-hash", 70_000).expect("a minute later, applied");
let (_, _, _, last_seen): (String, String, u64, Option<u64>) = store.list_devices("v").expect("rows").remove(0);
assert_eq!(last_seen, Some(70_000));
assert!(store.revoke_device("d", "v").expect("revoke"));
assert_eq!(store.device_for("new-hash").expect("query"), None);
assert_eq!(store.list_devices("v").expect("rows").len(), 0);
}
#[test]
fn audit_rows_come_back_newest_first_and_capped() {
let store = Store::in_memory().expect("db");
for (ts, kind) in [(1_u64, "a"), (2, "b"), (3, "c")] {
store.audit(ts, kind, Some("v"), Some("d"), None).expect("audit");
}
let rows = store.audit_rows(2).expect("rows");
assert_eq!(rows.iter().map(|row| row.kind.as_str()).collect::<Vec<_>>(), vec!["c", "b"]);
assert_eq!(rows[0].ts_ms, 3);
assert_eq!(rows[0].vault.as_deref(), Some("v"));
}