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

225 lines
11 KiB
Rust

//! File history over real HTTP: every applied write is a revision, listing and reading
//! old revisions, restore, and the retention rules the GC applies. Non-members are 404,
//! viewers read but never restore.
mod common;
use std::path::PathBuf;
use common::{ADMIN, Server, temp_dir};
use notescraft_sync_server::{AppState, GcReport, Store, TOMBSTONE_GRACE_MS, now_ms};
use serde_json::{Value, json};
/// A server over a fresh temp folder; the folder comes back for blob assertions.
fn server(name: &str) -> (Server, PathBuf) {
let data = temp_dir(name);
let state = AppState::new(Store::open(&data.join("sync.db")).expect("db"), data.join("blobs"), ADMIN);
(common::start_with_state(state), data)
}
/// A fresh vault with an owner token.
fn vault(server: &Server, name: &str) -> (String, String) {
server.paired(name)
}
/// Writes `count` successive versions of one file, the old sequence as the base each
/// time; `(seq, body, hash)` in write order.
fn edits(server: &Server, vault: &str, token: &str, count: u64) -> Vec<(u64, String, String)> {
let mut base_seq = 0;
(1..=count)
.map(|index| {
let body = format!("body-{index}");
let (status, record) = server.put(vault, token, "a.md", &body, base_seq);
assert_eq!(status, 201, "write {index}");
let seq = record["seq"].as_u64().expect("seq");
let hash = record["hash"].as_str().expect("hash").to_owned();
base_seq = seq;
(seq, body, hash)
})
.collect()
}
/// The vault's own row of `GET /v1/vaults`.
fn listing(server: &Server, token: &str, vault: &str) -> Value {
let (status, rows) = server.json("GET", "/v1/vaults", Some(token), &[], None);
assert_eq!(status, 200);
rows.as_array().expect("vaults").iter().find(|row| row["id"] == *vault).expect("vault row").clone()
}
/// The bytes and headers of a `GET` of one file with optional `?seq=`.
fn read(server: &Server, vault: &str, token: &str, query: &str) -> (u16, String, Vec<u8>) {
let (status, headers, body) = server.send_full("GET", &format!("/v1/vaults/{vault}/files/a.md{query}"), Some(token), &[], None);
let etag = headers.iter().find(|(name, _)| name == "etag").map(|(_, value)| value.clone()).unwrap_or_default();
(status, etag, body)
}
fn history(server: &Server, vault: &str, token: &str, query: &str) -> Vec<Value> {
let (status, rows) = server.json("GET", &format!("/v1/vaults/{vault}/files-history/a.md{query}"), Some(token), &[], None);
assert_eq!(status, 200);
rows.as_array().expect("history rows").clone()
}
fn restore(server: &Server, vault: &str, token: &str, seq: u64) -> (u16, Value) {
server.json("POST", &format!("/v1/vaults/{vault}/files-restore/a.md"), Some(token), &[], Some(&json!({"seq": seq})))
}
#[test]
fn forty_edits_list_forty_revisions_newest_first_with_a_limit() {
let (server, _) = server("history-list");
let (vault, token) = vault(&server, "hist");
let writes = edits(&server, &vault, &token, 40);
assert_eq!(writes.iter().map(|(seq, _, _)| *seq).max(), Some(40));
let rows = history(&server, &vault, &token, "");
assert_eq!(rows.len(), 40);
assert_eq!(rows[0]["seq"], 40, "newest first");
assert_eq!(rows[39]["seq"], 1);
assert_eq!(rows[0]["deleted"], false);
assert!(rows[0]["size"].as_u64().is_some_and(|n| n > 0), "sizes come along");
assert!(rows[0]["mtime_ms"].as_u64().is_some_and(|n| n > 0));
assert!(rows[0]["recorded_ms"].as_u64().is_some_and(|n| n > 0));
assert!(rows[0]["editor_user"].as_str().is_some(), "the editor is stamped");
assert!(rows[0]["editor_name"].as_str().is_some(), "the editor's name resolves");
let limited = history(&server, &vault, &token, "?limit=10");
assert_eq!(limited.len(), 10);
assert_eq!(limited.iter().map(|row| row["seq"].as_u64().expect("seq")).collect::<Vec<_>>(), (31..=40).rev().collect::<Vec<_>>());
assert_eq!(history(&server, &vault, &token, "?limit=0").len(), 1, "0 clamps up to one entry");
}
#[test]
fn get_by_seq_returns_that_revisions_bytes() {
let (server, _) = server("history-read");
let (vault, token) = vault(&server, "hist");
let writes = edits(&server, &vault, &token, 12);
let (_, body12, hash12) = writes.iter().find(|(seq, _, _)| *seq == 12).expect("revision 12").clone();
let (status, etag, bytes) = read(&server, &vault, &token, "?seq=12");
assert_eq!(status, 200);
assert_eq!(etag, hash12);
assert_eq!(bytes, body12.as_bytes());
assert_eq!(read(&server, &vault, &token, "").1, hash12, "without a seq it reads the live record");
assert_eq!(read(&server, &vault, &token, "?seq=999").0, 404, "unknown revision");
}
#[test]
fn restore_of_revision_12_creates_revision_41_with_those_bytes() {
let (server, _) = server("history-restore");
let (vault, token) = vault(&server, "hist");
let writes = edits(&server, &vault, &token, 40);
let (_, body12, hash12) = writes.iter().find(|(seq, _, _)| *seq == 12).expect("revision 12").clone();
let (status, record) = restore(&server, &vault, &token, 12);
assert_eq!(status, 201);
assert_eq!(record["seq"].as_u64(), Some(41));
assert_eq!(record["hash"].as_str(), Some(hash12.as_str()));
assert_eq!(record["size"].as_u64(), Some(u64::try_from(body12.len()).expect("small")));
let rows = history(&server, &vault, &token, "");
assert_eq!(rows.len(), 41, "history is never rewritten: 41 entries");
assert_eq!(rows[0]["seq"], 41);
let (status, etag, bytes) = read(&server, &vault, &token, "");
assert_eq!((status, etag, bytes), (200, hash12.clone(), body12.into_bytes()), "the file reads back as revision 12");
let audit = server.audit();
assert!(audit.iter().any(|row| row["kind"] == "file_restored"), "the restore is audited");
}
#[test]
fn restore_of_a_deleted_file_recreates_it() {
let (server, _) = server("history-recreate");
let (vault, token) = vault(&server, "hist");
assert_eq!(server.put(&vault, &token, "a.md", "body-1", 0).0, 201);
let (status, _) = server.json("DELETE", &format!("/v1/vaults/{vault}/files/a.md"), Some(&token), &[("X-Base-Seq", "1")], None);
assert_eq!(status, 201);
assert_eq!(read(&server, &vault, &token, "").0, 404, "the file is gone");
let (status, record) = restore(&server, &vault, &token, 1);
assert_eq!(status, 201, "the tombstone yields to an edit");
assert_eq!(record["seq"], 3, "one for the write, one for the delete");
assert_eq!(record["deleted"], false);
assert_eq!((read(&server, &vault, &token, "").0, read(&server, &vault, &token, "").2), (200, b"body-1".to_vec()));
assert_eq!(history(&server, &vault, &token, "").len(), 3, "both the tombstone and the recreate stay listed");
}
#[test]
fn unknown_seq_is_404_viewer_reads_but_cannot_restore_and_a_non_member_is_404() {
let (server, _) = server("history-roles");
let (vault, owner) = vault(&server, "hist");
edits(&server, &vault, &owner, 3);
let (_, viewer) = server.paired("hist-viewer");
let (status, code) = server.invite(&vault, &owner, "viewer");
assert_eq!(status, 200);
assert_eq!(server.accept(&viewer, &code).0, 200);
let (_, stranger) = server.paired("hist-stranger");
assert_eq!(restore(&server, &vault, &viewer, 1).0, 403, "a viewer cannot restore");
assert_eq!(restore(&server, &vault, &viewer, 999).0, 403, "role first, never existence");
assert_eq!(restore(&server, &vault, &owner, 999).0, 404, "unknown revision");
assert_eq!(read(&server, &vault, &owner, "?seq=999").0, 404, "unknown revision read");
assert_eq!(server.json("GET", &format!("/v1/vaults/{vault}/files-history/a.md"), Some(&stranger), &[], None).0, 404, "a non-member");
assert_eq!(restore_status(&server, &vault, &stranger, 1), 404);
}
/// The raw restore status, for assertions where the body does not parse as JSON.
fn restore_status(server: &Server, vault: &str, token: &str, seq: u64) -> u16 {
server
.send(
"POST",
&format!("/v1/vaults/{vault}/files-restore/a.md"),
Some(token),
&[("Content-Type", "application/json")],
Some(serde_json::to_vec(&json!({"seq": seq})).expect("body").as_slice()),
)
.0
}
#[test]
fn retention_days_0_and_366_are_refused() {
let (server, _) = server("history-bounds");
let (vault, token) = vault(&server, "hist");
for days in [0, 366] {
let (status, _) = server.json("PATCH", &format!("/v1/vaults/{vault}"), Some(&token), &[], Some(&json!({"retention_days": days})));
assert_eq!(status, 400, "retention_days = {days}");
}
assert_eq!(server.json("PATCH", &format!("/v1/vaults/{vault}"), Some(&token), &[], Some(&json!({"retention_days": 365}))).0, 204, "the range top is fine");
}
#[test]
fn retention_gc_removes_expired_unique_blobs_and_keeps_the_newest_20() {
let (server, data) = server("history-retention");
let (vault, token) = vault(&server, "hist");
// Revision 2 shares its content with revision 30: its blob must survive.
let bodies: Vec<String> = (1..=40).map(|index| if index == 2 { "body-30".to_owned() } else { format!("body-{index}") }).collect();
let mut writes = Vec::new();
let mut base_seq = 0;
for body in &bodies {
let (status, record) = server.put(&vault, &token, "a.md", body, base_seq);
assert_eq!(status, 201);
base_seq = record["seq"].as_u64().expect("seq");
writes.push((base_seq, body.clone(), record["hash"].as_str().expect("hash").to_owned()));
}
let dir = data.join("blobs").join(&vault);
let (hash2, hash30) = (writes[1].2.clone(), writes[29].2.clone());
assert_eq!(hash2, hash30, "the shared content is one blob");
assert_eq!(listing(&server, &token, &vault)["bytes_used"].as_u64(), Some(265), "39 distinct contents");
let (status, _) = server.json("PATCH", &format!("/v1/vaults/{vault}"), Some(&token), &[], Some(&json!({"retention_days": 1})));
assert_eq!(status, 204);
let aged = now_ms() + 2 * 24 * 60 * 60 * 1000;
let report = server.state.gc(&vault, TOMBSTONE_GRACE_MS, aged).expect("gc");
assert_eq!(report, GcReport { removed: 19, bytes: 125 }, "the expired unique blobs went, the shared one stayed");
let rows = history(&server, &vault, &token, "");
assert_eq!(rows.len(), 20, "the current one and the newest 20 stay");
assert_eq!(rows.first().and_then(|row| row["seq"].as_u64()), Some(40));
assert_eq!(rows.last().and_then(|row| row["seq"].as_u64()), Some(21));
assert!(dir.join(&hash2).exists(), "a blob shared by a kept revision survives");
assert!(dir.join(&writes[39].2).exists(), "the current content stays");
assert!(!dir.join(&writes[0].2).exists(), "an expired revision's unique blob is gone");
assert_eq!(listing(&server, &token, &vault)["bytes_used"].as_u64(), Some(140), "20 distinct contents of the kept revisions");
}