181aad91e6
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>
133 lines
6.1 KiB
Rust
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());
|
|
}
|