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

98 lines
4.8 KiB
Rust

//! Quota and garbage collection, over real HTTP with injectable clocks and limits.
mod common;
use std::path::PathBuf;
use common::{ADMIN, Server, temp_dir};
use notescraft_sync_server::{GcReport, Store, TOMBSTONE_GRACE_MS};
/// One megabyte of quota, small enough that a test can spend it by hand.
const QUOTA_BYTES: u64 = 1024 * 1024;
fn server_with_quota(name: &str, quota_bytes: Option<u64>) -> (Server, PathBuf) {
let data = temp_dir(name);
let state = notescraft_sync_server::AppState::with_quota(Store::open(&data.join("sync.db")).expect("db"), data.join("blobs"), ADMIN, quota_bytes);
(common::start_with_state(state), data)
}
fn blob_files(data: &std::path::Path, vault: &str) -> Vec<PathBuf> {
let mut files: Vec<PathBuf> = std::fs::read_dir(data.join("blobs").join(vault))
.expect("blob dir")
.filter_map(|entry| entry.expect("entry").file_name().into_string().ok())
.map(|name| data.join("blobs").join(vault).join(name))
.collect();
files.sort();
files
}
#[test]
fn quota_rejects_uploads_over_the_live_bytes_limit_before_storing() {
let (server, data) = server_with_quota("quota", Some(QUOTA_BYTES));
let (vault, token) = server.paired("pc");
let near = "x".repeat(usize::try_from(QUOTA_BYTES - 50).expect("small quota"));
let (status, record) = server.put(&vault, &token, "a.md", &near, 0);
assert_eq!((status, record["seq"].as_u64()), (201, Some(1)));
// A sibling file would exceed the quota: 413, audited, and no blob stored.
let before = blob_files(&data, &vault);
let (status, reply) = server.put(&vault, &token, "b.md", &"y".repeat(60), 0);
assert_eq!(status, 413);
assert_eq!(reply["error"], "vault storage quota exceeded");
assert_eq!(blob_files(&data, &vault), before);
assert!(server.audit().iter().any(|row| row["kind"] == "quota_rejected"));
server.assert_no_secrets(&serde_json::to_string(&server.audit()).expect("rows"));
}
#[test]
fn quota_replacement_counts_only_the_size_difference() {
let (server, _) = server_with_quota("quota-replace", Some(QUOTA_BYTES));
let (vault, token) = server.paired("pc");
let (status, record) = server.put(&vault, &token, "a.md", &"x".repeat(usize::try_from(QUOTA_BYTES - 50).expect("small quota")), 0);
assert_eq!(status, 201);
let seq = record["seq"].as_u64().expect("seq");
// As a new file, +60 bytes overflows; as a replacement, +10 bytes fits the slack.
assert_eq!(server.put(&vault, &token, "b.md", &"y".repeat(60), 0).0, 413);
let (status, replaced) = server.put(&vault, &token, "a.md", &"z".repeat(usize::try_from(QUOTA_BYTES - 40).expect("small quota")), seq);
assert_eq!(replaced["seq"].as_u64(), Some(seq + 1));
assert_eq!(status, 201);
// The next new file is still over.
assert_eq!(server.put(&vault, &token, "b.md", &"y".repeat(59), 0).0, 413);
}
#[test]
fn gc_removes_unreferenced_blobs_and_keeps_referenced_and_in_grace_ones() {
let (server, data) = server_with_quota("gc", None);
let (vault, token) = server.paired("pc");
let (other, other_token) = server.paired("other");
let (_, record_a) = server.put(&vault, &token, "a.md", "gone", 0);
let (_, record_b) = server.put(&vault, &token, "b.md", "stays", 0);
let (_, record_c) = server.put(&other, &other_token, "c.md", "untouched", 0);
let (hash_a, hash_b, hash_c): (&str, &str, &str) =
(record_a["hash"].as_str().expect("hash"), record_b["hash"].as_str().expect("hash"), record_c["hash"].as_str().expect("hash"));
// Tombstone a.md; its blob is unreferenced by live records but inside the grace.
let (status, _) = server.json("DELETE", &format!("/v1/vaults/{vault}/files/a.md"), Some(&token), &[("X-Base-Seq", "1")], None);
assert_eq!(status, 201);
let dir = data.join("blobs").join(&vault);
assert!(dir.join(hash_a).exists() && dir.join(hash_b).exists());
// Default 14-day grace keeps the fresh tombstone's blob.
let report = server.state.gc(&vault, TOMBSTONE_GRACE_MS, notescraft_sync_server::now_ms()).expect("gc");
assert_eq!(report, GcReport { removed: 0, bytes: 0 });
// Aged past the grace (injected clock), the tombstone's blob goes, the live one stays.
let aged = notescraft_sync_server::now_ms() + TOMBSTONE_GRACE_MS + 1;
let report = server.state.gc(&vault, TOMBSTONE_GRACE_MS, aged).expect("gc");
assert_eq!(report, GcReport { removed: 1, bytes: u64::try_from("gone".len()).expect("small") });
assert!(!dir.join(hash_a).exists());
assert!(dir.join(hash_b).exists());
// A zero grace takes even a live tombstone only through its own vault's files.
assert_eq!(server.state.gc(&vault, 0, aged).expect("gc").removed, 0);
assert!(data.join("blobs").join(&other).join(hash_c).exists());
// The run itself is audited.
assert!(server.audit().iter().any(|row| row["kind"] == "gc_run"));
}