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

133 lines
6.1 KiB
Rust

//! The real engine sync client against the real server over HTTP: two vault folders converge.
use std::path::{Path, PathBuf};
use notescraft_engine::{HttpRemote, SyncState, pair, sync_once};
use notescraft_sync_server::{AppState, Store, router};
use notescraft_vault::Vault;
const ADMIN: &str = "test-admin-token-0123456789abcdef";
fn temp(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("nc-e2e-{name}-{}", std::process::id()));
std::fs::remove_dir_all(&dir).ok();
std::fs::create_dir_all(&dir).expect("dir");
dir
}
/// Starts the server on an ephemeral port and returns its base url.
fn start(data: &Path) -> String {
std::fs::create_dir_all(data.join("blobs")).expect("blobs");
let state = AppState::new(Store::open(&data.join("sync.db")).expect("db"), data.join("blobs"), ADMIN);
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
listener.set_nonblocking(true).expect("nonblocking");
let port = listener.local_addr().expect("addr").port();
std::thread::spawn(move || {
let runtime = tokio::runtime::Builder::new_multi_thread().enable_all().build().expect("runtime");
runtime.block_on(async {
let listener = tokio::net::TcpListener::from_std(listener).expect("listener");
axum::serve(listener, router(state)).await.ok();
});
});
format!("http://127.0.0.1:{port}")
}
fn admin(base: &str, path: &str) -> serde_json::Value {
let agent: ureq::Agent = ureq::Agent::config_builder().http_status_as_error(false).build().into();
let mut response = agent.post(format!("{base}{path}")).header("Authorization", format!("Bearer {ADMIN}")).send_empty().expect("admin call");
response.body_mut().read_json().expect("json")
}
struct Device {
vault: Vault,
remote: HttpRemote,
state: SyncState,
name: &'static str,
}
impl Device {
/// A local vault over an already-paired device token. `name` keeps temp folders apart.
fn new(base: &str, vault_id: &str, token: &str, name: &'static str) -> Self {
let root = temp(name);
let vault = Vault::open(&root).expect("vault");
Self { vault, remote: HttpRemote::new(base, vault_id, token), state: SyncState::default(), name }
}
fn write(&self, path: &str, text: &str) {
self.vault.write(path, text.as_bytes()).expect("write");
}
fn read(&self, path: &str) -> String {
String::from_utf8(self.vault.read(path).expect("read")).expect("utf8")
}
fn sync(&mut self) -> notescraft_engine::SyncReport {
let report = sync_once(&self.vault, &self.remote, &mut self.state, self.name, "2026-10-20").expect("sync");
self.state.save(&self.vault).expect("save state");
report
}
}
/// Issues a pairing code (admin) for the vault, bound to `user` when given; issuing
/// for an unknown vault id creates the vault (accounts have no other bootstrap).
fn issue(base: &str, vault_id: &str, user: Option<&str>) -> String {
let path = user.map_or_else(|| format!("/v1/vaults/{vault_id}/pairing"), |user| format!("/v1/vaults/{vault_id}/pairing?user={user}"));
admin(base, &path)["code"].as_str().expect("code").to_owned()
}
/// Redeems a code with raw HTTP to also learn the account the device joined.
fn pair_raw(base: &str, code: &str, name: &str) -> (String, String) {
let agent: ureq::Agent = ureq::Agent::config_builder().http_status_as_error(false).build().into();
let mut response = agent.post(format!("{base}/v1/devices")).send_json(serde_json::json!({"code": code, "name": name})).expect("pair");
let device: serde_json::Value = response.body_mut().read_json().expect("json");
(device["token"].as_str().expect("token").to_owned(), device["user_id"].as_str().expect("user id").to_owned())
}
#[test]
fn two_devices_converge_through_the_real_server() {
let base = start(&temp("server-data"));
// Accounts: the first pairing creates the vault and its owner account; the second
// device joins the same account through a `?user=`-bound pairing code for the
// same vault, so one token works for the account, not just one vault.
let vault_id = format!("v-e2e-{}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.subsec_nanos()));
let (phone_token, user_id) = pair_raw(&base, &issue(&base, &vault_id, None), "phone");
let laptop_pairing = pair(&base, &issue(&base, &vault_id, Some(&user_id)), "laptop").expect("pair");
assert_eq!(laptop_pairing.vault_id, vault_id);
let mut phone = Device::new(&base, &vault_id, &phone_token, "phone");
let mut laptop = Device::new(&base, &laptop_pairing.vault_id, &laptop_pairing.token, "laptop");
phone.write("notes/a.md", "one\ntwo\nthree\nfour\nfive\n");
phone.write("b.md", "bee\n");
assert_eq!(phone.sync().uploaded.len(), 2);
assert_eq!(laptop.sync().downloaded.len(), 2);
assert_eq!(laptop.read("notes/a.md"), "one\ntwo\nthree\nfour\nfive\n");
// Offline edits on different lines merge without a conflict copy.
phone.write("notes/a.md", "ONE\ntwo\nthree\nfour\nfive\n");
laptop.write("notes/a.md", "one\ntwo\nthree\nfour\nFIVE\n");
assert_eq!(phone.sync().uploaded, vec!["notes/a.md".to_owned()]);
let report = laptop.sync();
assert_eq!(report.merged, vec!["notes/a.md".to_owned()]);
assert_eq!(report.conflicts, Vec::<String>::new());
phone.sync();
assert_eq!(phone.read("notes/a.md"), "ONE\ntwo\nthree\nfour\nFIVE\n");
assert_eq!(laptop.read("notes/a.md"), "ONE\ntwo\nthree\nfour\nFIVE\n");
// The same line edited twice keeps both versions.
phone.write("b.md", "phone\n");
laptop.write("b.md", "laptop\n");
phone.sync();
let report = laptop.sync();
assert_eq!(report.conflicts.len(), 1);
phone.sync();
assert_eq!(phone.read("b.md"), "phone\n");
assert_eq!(phone.read(&report.conflicts[0]), "laptop\n");
// Delete on one device reaches the other; a second run is quiet.
phone.vault.delete_to_trash("b.md").expect("delete");
phone.sync();
laptop.sync();
assert!(laptop.vault.read("b.md").is_err());
assert_eq!(laptop.sync(), notescraft_engine::SyncReport::default());
}