Store::verify compares the whole SQLite projection, every table row for row, against a fresh projection of the journal; verify_and_heal rebuilds a drifted database (keeping the old file) at every server start and via the new books.verify tool, which also re-hashes every blob. The GUI checks the server's database read-only and every stored file, and shows the result. rebuild() also drops stale WAL/SHM files. site/: the wanderbooks website (just site, just publish). install.sh: OS detection (Windows points to WSL2), C compiler and git checks, WANDERBOOKS_REPO. Windows: no directory fsync off Unix; .exe-aware lookup. Repo justfile: check, build, install, demo, site. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
188 lines
6.4 KiB
Rust
188 lines
6.4 KiB
Rust
//! Snapshot: a read-only view that follows a live writer.
|
|
|
|
use chrono::NaiveDate;
|
|
use ledger_core::reports::snapshot;
|
|
use ledger_core::{AccountType, Currency, Money, Side};
|
|
use ledger_store::{JOURNAL_FILE, Snapshot, Store};
|
|
use rust_decimal::Decimal;
|
|
use std::io::Write;
|
|
use std::path::PathBuf;
|
|
|
|
fn test_dir(name: &str) -> PathBuf {
|
|
let dir = std::env::temp_dir().join(format!("wanderbooks-snap-{name}-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
dir
|
|
}
|
|
|
|
fn usd(n: i64) -> Money {
|
|
Money::new(Decimal::from(n), Currency::USD)
|
|
}
|
|
|
|
fn post(store: &mut Store, day: u32, dr: u64, cr: u64, amount: i64) -> u64 {
|
|
let d = NaiveDate::from_ymd_opt(2026, 10, day).unwrap();
|
|
let e = store.create_entry(d, "x").unwrap();
|
|
store.add_line(e, dr, Side::Debit, usd(amount)).unwrap();
|
|
store.add_line(e, cr, Side::Credit, usd(amount)).unwrap();
|
|
store.post_entry(e).unwrap();
|
|
e
|
|
}
|
|
|
|
#[test]
|
|
fn a_snapshot_follows_the_writer_and_never_writes() {
|
|
let dir = test_dir("follow");
|
|
let mut store = Store::create(&dir, Currency::USD).unwrap();
|
|
let cash = store
|
|
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
|
.unwrap();
|
|
let equity = store
|
|
.add_account("3000", "Equity", AccountType::Equity, None, false)
|
|
.unwrap();
|
|
let e = post(&mut store, 1, cash, equity, 1000);
|
|
store
|
|
.attach_receipt(Some(e), "deposit.png", "image/png", b"png-bytes", None)
|
|
.unwrap();
|
|
|
|
let listing = |dir: &std::path::Path| {
|
|
let mut v: Vec<_> = walk(dir);
|
|
v.sort();
|
|
v
|
|
};
|
|
let before = listing(&dir);
|
|
let mut snap = Snapshot::load(&dir).unwrap();
|
|
assert_eq!(listing(&dir), before, "loading writes nothing");
|
|
assert_eq!(snap.journal_seq(), store.journal_seq());
|
|
assert_eq!(
|
|
snapshot(snap.ledger()).unwrap(),
|
|
snapshot(store.ledger()).unwrap()
|
|
);
|
|
let atts = snap.attachments(Some(e)).unwrap();
|
|
assert_eq!(atts.len(), 1);
|
|
assert_eq!(snap.attachment_bytes(atts[0].id).unwrap(), b"png-bytes");
|
|
|
|
// The writer keeps going; refresh picks up exactly the new lines.
|
|
assert_eq!(snap.refresh().unwrap(), 0);
|
|
let seq = store.journal_seq();
|
|
post(&mut store, 2, cash, equity, 250);
|
|
let added = snap.refresh().unwrap();
|
|
assert_eq!(added as u64, store.journal_seq() - seq);
|
|
assert_eq!(
|
|
snapshot(snap.ledger()).unwrap(),
|
|
snapshot(store.ledger()).unwrap()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_half_written_line_waits_for_the_next_refresh() {
|
|
let dir = test_dir("torn");
|
|
let mut store = Store::create(&dir, Currency::USD).unwrap();
|
|
store
|
|
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
|
.unwrap();
|
|
drop(store);
|
|
let mut snap = Snapshot::load(&dir).unwrap();
|
|
let seq = snap.journal_seq();
|
|
// Simulate the writer mid-append: half a line, no newline yet.
|
|
let journal = dir.join(JOURNAL_FILE);
|
|
let full = std::fs::read(&journal).unwrap();
|
|
let mut store = Store::open(&dir).unwrap();
|
|
store
|
|
.add_account("3000", "Equity", AccountType::Equity, None, false)
|
|
.unwrap();
|
|
drop(store);
|
|
let grown = std::fs::read(&journal).unwrap();
|
|
let new_line = &grown[full.len()..];
|
|
std::fs::write(&journal, &full).unwrap();
|
|
let mut f = std::fs::OpenOptions::new()
|
|
.append(true)
|
|
.open(&journal)
|
|
.unwrap();
|
|
f.write_all(&new_line[..new_line.len() / 2]).unwrap();
|
|
f.flush().unwrap();
|
|
assert_eq!(snap.refresh().unwrap(), 0, "torn tail is not corruption");
|
|
f.write_all(&new_line[new_line.len() / 2..]).unwrap();
|
|
f.flush().unwrap();
|
|
assert_eq!(snap.refresh().unwrap(), 1);
|
|
assert_eq!(snap.journal_seq(), seq + 1);
|
|
assert!(snap.ledger().chart().find_by_code("3000").is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn a_rewritten_journal_reloads() {
|
|
let dir = test_dir("rewrite");
|
|
let mut store = Store::create(&dir, Currency::USD).unwrap();
|
|
let cash = store
|
|
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
|
.unwrap();
|
|
let equity = store
|
|
.add_account("3000", "Equity", AccountType::Equity, None, false)
|
|
.unwrap();
|
|
let journal = dir.join(JOURNAL_FILE);
|
|
let early = std::fs::read(&journal).unwrap();
|
|
post(&mut store, 1, cash, equity, 10);
|
|
drop(store);
|
|
let mut snap = Snapshot::load(&dir).unwrap();
|
|
let later = snap.journal_seq();
|
|
// Restored from an earlier backup: shorter file.
|
|
std::fs::write(&journal, &early).unwrap();
|
|
snap.refresh().unwrap();
|
|
assert!(snap.journal_seq() < later);
|
|
assert!(snap.ledger().posted_entries().next().is_none());
|
|
}
|
|
|
|
fn walk(dir: &std::path::Path) -> Vec<(String, u64)> {
|
|
let mut out = Vec::new();
|
|
for e in std::fs::read_dir(dir).unwrap() {
|
|
let e = e.unwrap();
|
|
let m = e.metadata().unwrap();
|
|
if m.is_dir() {
|
|
out.extend(walk(&e.path()));
|
|
} else {
|
|
out.push((e.path().display().to_string(), m.len()));
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
#[test]
|
|
fn a_snapshot_checks_the_writers_database_and_files() {
|
|
use ledger_store::DiskCheck;
|
|
let dir = test_dir("disk");
|
|
let mut store = Store::create(&dir, Currency::USD).unwrap();
|
|
let cash = store
|
|
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
|
.unwrap();
|
|
let equity = store
|
|
.add_account("3000", "Equity", AccountType::Equity, None, false)
|
|
.unwrap();
|
|
let e = post(&mut store, 1, cash, equity, 100);
|
|
store
|
|
.attach_receipt(Some(e), "r.png", "image/png", b"receipt-bytes", None)
|
|
.unwrap();
|
|
drop(store);
|
|
let snap = Snapshot::load(&dir).unwrap();
|
|
assert_eq!(snap.check_disk(), DiskCheck::Matches);
|
|
assert_eq!(snap.verify_blobs(), (1, vec![]));
|
|
// Someone edits the database behind the journal's back.
|
|
let conn = rusqlite::Connection::open(dir.join(ledger_store::DB_FILE)).unwrap();
|
|
conn.execute(
|
|
"UPDATE accounts SET name = 'Petty cash' WHERE code = '1000'",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
drop(conn);
|
|
match snap.check_disk() {
|
|
DiskCheck::Differs(why) => assert!(why.contains("accounts"), "{why}"),
|
|
other => panic!("drift not seen: {other:?}"),
|
|
}
|
|
// A damaged file is caught too.
|
|
let blob = walk(&dir.join("blobs"))
|
|
.into_iter()
|
|
.map(|(p, _)| p)
|
|
.find(|p| !p.ends_with(".tmp"))
|
|
.unwrap();
|
|
std::fs::write(&blob, b"tampered").unwrap();
|
|
let (ok, bad) = snap.verify_blobs();
|
|
assert_eq!((ok, bad.len()), (0, 1));
|
|
}
|