Files
wanderbooks/crates/ledger-store/tests/schema_files.rs
T
swaitsandClaude Opus 5.5 3a9aa92315 P3: the books' format moves only with the owner's go-ahead (D-39)
Books an update could write a newer format for open on their old format
and are asked at every open; anything needing a new feature is refused
until the owner agrees with the passphrase, after a verified backup that
includes the keys. New books start on the current format. Each projection
schema has its own database file, so an older version still opens the
books until the owner agrees.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-21 14:04:11 -06:00

372 lines
14 KiB
Rust

//! Each projection schema in a file of its own (ledger_store::db_file_for;
//! plan/served.md 3.9: before the owner consents to a format upgrade, the
//! way back to an older version is always there; rules.md B-27).
//!
//! ```text
//! GOAL: an older version, gone back to, finds the books it can open,
//! however far a newer one moved the projection.
//! INVARIANTS:
//! 1. This version opens or creates only its own schema's file (the
//! legacy `wanderbooks.db` only when that already holds its schema);
//! its own missing, it is built from the journal.
//! 2. A file of any other schema, older or newer, is never migrated,
//! written, refused or deleted: it is left byte for byte, and still
//! whole for its own schema.
//! 3. A damaged file of this version's own schema is kept aside and
//! rebuilt, as ever.
//! 4. Which file is in use, and which others are there, is said.
//! ```
//!
//! The P2-shaped fixture (tests/fixtures/p2-books) is a books dir as P2
//! left it: a format 1 journal and its projection in `wanderbooks.db`, at
//! schema 12. While this version's schema is 12 too, it is used as it is;
//! once a schema of P3's moves past it, it must be left byte for byte. A
//! legacy file one schema older than this version's is made here too, so
//! the second case runs on every version.
use chrono::NaiveDate;
use ledger_core::{AccountType, Currency, Money, Side};
use ledger_store::schema::{MIGRATIONS, SCHEMA_VERSION};
use ledger_store::{
DiskCheck, JOURNAL_FILE, JournalFormat, LEGACY_DB_FILE, Snapshot, Store, db_file, db_file_for,
};
use rust_decimal::Decimal;
use std::path::{Path, PathBuf};
/// The schema P2 kept in `wanderbooks.db`, the last before each schema had
/// a file of its own.
const P2_SCHEMA: u32 = 12;
/// A test's dir, removed when the test ends, pass or panic.
struct TestDir(PathBuf);
impl std::ops::Deref for TestDir {
type Target = Path;
fn deref(&self) -> &Path {
&self.0
}
}
impl Drop for TestDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn test_dir(name: &str) -> TestDir {
let dir = std::env::temp_dir().join(format!(
"wanderbooks-schema-files-{name}-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
TestDir(dir)
}
fn usd(n: i64) -> Money {
Money::new(Decimal::from(n), Currency::USD)
}
/// Books with a chart and one posted entry (cash 500), closed again, in
/// journal format `format`.
fn books(dir: &Path, format: &JournalFormat) {
let mut s = Store::create_in_format(dir, Currency::USD, format).unwrap();
let cash = s
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let cap = s
.add_account("3000", "Capital", AccountType::Equity, None, false)
.unwrap();
let e = s
.create_entry(NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(), "Capital in")
.unwrap();
s.add_line(e, cash, Side::Debit, usd(500)).unwrap();
s.add_line(e, cap, Side::Credit, usd(500)).unwrap();
s.post_entry(e).unwrap();
}
/// The cash balance the books say.
fn cash(s: &Store) -> Decimal {
let id = s.ledger().chart().find_by_code("1000").unwrap().id;
s.ledger().account_balance(id).unwrap()
}
/// A file's bytes, or None.
fn bytes(path: &Path) -> Option<Vec<u8>> {
std::fs::read(path).ok()
}
/// What a projection file says of itself, read without writing it: its
/// schema, the journal lines it applied, and SQLite's integrity check.
fn holds(path: &Path) -> (u32, u64, String) {
let uri = format!("file:{}?mode=ro&immutable=1", path.display());
let conn = rusqlite::Connection::open_with_flags(
uri,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_URI,
)
.unwrap();
let meta = |k: &str| -> String {
conn.query_row("SELECT value FROM meta WHERE key=?1", [k], |r| r.get(0))
.unwrap()
};
let check: String = conn
.query_row("PRAGMA integrity_check", [], |r| r.get(0))
.unwrap();
(
meta("schema_version").parse().unwrap(),
meta("last_applied_seq").parse().unwrap(),
check,
)
}
/// A copy of tests/fixtures/p2-books.
fn p2_books() -> TestDir {
let from = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/p2-books");
let dir = test_dir("p2");
for name in [JOURNAL_FILE, LEGACY_DB_FILE] {
std::fs::copy(from.join(name), dir.join(name)).unwrap();
}
dir
}
/// How tests/fixtures/p2-books was written: by this code while its schema
/// was P2's (12, and every line applied as P2 applies it), its projection
/// then given P2's file name. Run with WB_WRITE_P2_FIXTURE=DIR.
#[test]
fn write_the_p2_fixture() {
let Ok(out) = std::env::var("WB_WRITE_P2_FIXTURE") else {
return;
};
assert_eq!(
SCHEMA_VERSION, P2_SCHEMA,
"only a P2-schema version writes it"
);
let out = PathBuf::from(out);
books(&out, &JournalFormat::default());
std::fs::rename(out.join(db_file()), out.join(LEGACY_DB_FILE)).unwrap();
let _ = std::fs::remove_file(out.join(ledger_store::LOCK_FILE));
let _ = std::fs::remove_dir(out.join("blobs"));
}
/// INVARIANTS 1, 2 and 4, on a books dir exactly as P2 left it: with this
/// version's schema P2's, its file is this version's and is used as it is
/// (no rebuild); past it, the file is left byte for byte and whole for its
/// schema, and this version builds its own from the journal. Either way the
/// books open to the same figures and verify.
#[test]
fn a_p2_books_dirs_database_is_used_at_its_schema_and_left_alone_past_it() {
let dir = p2_books();
let legacy = dir.join(LEGACY_DB_FILE);
let before = bytes(&legacy).unwrap();
let lines = std::fs::read_to_string(dir.join(JOURNAL_FILE))
.unwrap()
.lines()
.count() as u64;
assert_eq!(holds(&legacy), (P2_SCHEMA, lines, "ok".into()));
let s = Store::open(&dir).unwrap();
assert_eq!(cash(&s), Decimal::from(500));
s.verify().unwrap();
if SCHEMA_VERSION == P2_SCHEMA {
// Its schema: used as it is, nothing rebuilt or made beside it.
assert_eq!(s.database_file(), LEGACY_DB_FILE);
assert!(s.other_databases().is_empty());
drop(s);
assert!(!dir.join(db_file()).exists(), "no rebuild for nothing");
} else {
assert_eq!(s.database_file(), db_file());
assert_eq!(s.other_databases(), [LEGACY_DB_FILE]);
drop(s);
assert_eq!(bytes(&legacy).unwrap(), before, "P2's file, byte for byte");
assert_eq!(holds(&legacy), (P2_SCHEMA, lines, "ok".into()), "and whole");
let s = Store::open(&dir).unwrap();
assert_eq!(cash(&s), Decimal::from(500));
drop(s);
assert_eq!(bytes(&legacy).unwrap(), before);
}
}
/// INVARIANTS 1, 2 and 4, on every version: a legacy file one schema older
/// than this version's (as P2's is to P3's) is never migrated or written.
/// It stays byte for byte, and whole for its own schema; this version's
/// file is built from the journal and caught up as the books move on; the
/// report names the one in use and the one left alone; the viewer checks
/// the one in use.
#[test]
fn an_older_schemas_file_is_left_byte_for_byte() {
let dir = test_dir("older");
books(&dir, &JournalFormat::default());
let own = dir.join(db_file());
let legacy = dir.join(LEGACY_DB_FILE);
// The older version's file: its schema, its own copy of the books.
let older = SCHEMA_VERSION - 1;
{
let conn = rusqlite::Connection::open(&legacy).unwrap();
for m in &MIGRATIONS[..older as usize] {
conn.execute_batch(m).unwrap();
}
for (k, v) in [
("schema_version", older.to_string()),
("base_currency", "USD".into()),
("last_applied_seq", "0".into()),
] {
conn.execute(
"INSERT OR REPLACE INTO meta(key, value) VALUES (?1, ?2)",
[k, v.as_str()],
)
.unwrap();
}
}
std::fs::remove_file(&own).unwrap();
let before = bytes(&legacy).unwrap();
let mut s = Store::open(&dir).unwrap();
assert_eq!(s.database_file(), db_file());
assert_eq!(s.other_databases(), [LEGACY_DB_FILE]);
assert_eq!(cash(&s), Decimal::from(500));
let report = s.verify_and_heal().unwrap();
assert_eq!(report.healed, None);
assert_eq!(report.database, db_file());
assert_eq!(report.other_databases, [LEGACY_DB_FILE]);
// The books move on: this version's file follows, the other doesn't.
s.add_account("6000", "Fees", AccountType::Expense, None, false)
.unwrap();
let head = s.head().0;
drop(s);
assert_eq!(bytes(&legacy).unwrap(), before, "never migrated or written");
assert!(!dir.join(format!("{LEGACY_DB_FILE}-wal")).exists());
assert_eq!(
holds(&legacy),
(older, 0, "ok".into()),
"whole, at its schema"
);
assert_eq!(holds(&own), (SCHEMA_VERSION, head, "ok".into()));
let snap = Snapshot::load(&dir).unwrap();
assert_eq!(snap.check_disk(), DiskCheck::Matches);
let s = Store::open(&dir).unwrap();
s.verify().unwrap();
drop(s);
assert_eq!(bytes(&legacy).unwrap(), before);
}
/// INVARIANT 2: a newer schema's file, whether under its own name or the
/// legacy one, is left alone, never refused: the books open on this
/// version's own file (built from the journal if it isn't there).
#[test]
fn a_newer_schemas_file_is_left_alone_not_refused() {
let dir = test_dir("newer");
books(&dir, &JournalFormat::default());
let own = dir.join(db_file());
// A newer version's files: its own name, and (as no version writes, but
// one could find) the legacy name holding a newer schema.
let newer = dir.join(db_file_for(SCHEMA_VERSION + 1));
let legacy = dir.join(LEGACY_DB_FILE);
for f in [&newer, &legacy] {
std::fs::copy(&own, f).unwrap();
let conn = rusqlite::Connection::open(f).unwrap();
conn.execute(
"UPDATE meta SET value=?1 WHERE key='schema_version'",
[(SCHEMA_VERSION + 1).to_string()],
)
.unwrap();
conn.execute_batch("PRAGMA journal_mode=DELETE").unwrap();
}
let (newer_bytes, legacy_bytes) = (bytes(&newer).unwrap(), bytes(&legacy).unwrap());
// This version's own file there, then not.
for keep_own in [true, false] {
if !keep_own {
std::fs::remove_file(&own).unwrap();
}
let s = Store::open(&dir).unwrap();
assert_eq!(s.database_file(), db_file(), "{keep_own}");
let mut others = vec![LEGACY_DB_FILE.to_string(), db_file_for(SCHEMA_VERSION + 1)];
others.sort();
assert_eq!(s.other_databases(), others);
assert_eq!(cash(&s), Decimal::from(500));
s.verify().unwrap();
drop(s);
assert_eq!(bytes(&newer).unwrap(), newer_bytes, "{keep_own}");
assert_eq!(bytes(&legacy).unwrap(), legacy_bytes, "{keep_own}");
}
}
/// INVARIANT 3: this version's own file, damaged (not a database, or
/// holding another schema though named for this one), is kept aside and
/// rebuilt from the journal; a damaged legacy file is another version's to
/// mend, and left as it is.
#[test]
fn a_damaged_file_of_this_versions_schema_is_rebuilt() {
let dir = test_dir("damaged");
books(&dir, &JournalFormat::default());
let own = dir.join(db_file());
let kept_aside = |dir: &Path| {
std::fs::read_dir(dir)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().contains(".corrupt."))
.count()
};
// Not a database at all.
std::fs::write(&own, b"this is not a sqlite database at all").unwrap();
for wal in ["-wal", "-shm"] {
let _ = std::fs::remove_file(dir.join(format!("{}{wal}", db_file())));
}
let s = Store::open(&dir).unwrap();
assert_eq!(cash(&s), Decimal::from(500));
s.verify().unwrap();
drop(s);
assert_eq!(kept_aside(&dir), 1);
assert_eq!(holds(&own).0, SCHEMA_VERSION);
// Named for this schema, holding another.
{
let conn = rusqlite::Connection::open(&own).unwrap();
conn.execute(
"UPDATE meta SET value=?1 WHERE key='schema_version'",
[(SCHEMA_VERSION + 1).to_string()],
)
.unwrap();
}
std::thread::sleep(std::time::Duration::from_millis(1100));
let s = Store::open(&dir).unwrap();
s.verify().unwrap();
drop(s);
assert_eq!(kept_aside(&dir), 2);
assert_eq!(holds(&own).0, SCHEMA_VERSION);
// A damaged legacy file: not this version's to mend.
let legacy = dir.join(LEGACY_DB_FILE);
std::fs::write(&legacy, b"garbage of an older version's").unwrap();
let s = Store::open(&dir).unwrap();
assert_eq!(s.database_file(), db_file());
s.verify().unwrap();
drop(s);
assert_eq!(
bytes(&legacy).unwrap(),
b"garbage of an older version's".to_vec()
);
assert_eq!(kept_aside(&dir), 2);
}
/// New books get this version's own schema's file, named for its schema,
/// and nothing else.
#[test]
fn new_books_get_their_schemas_own_file() {
let dir = test_dir("new");
let s = Store::create(&dir, Currency::USD).unwrap();
assert_eq!(s.database_file(), db_file());
assert_eq!(db_file(), format!("wanderbooks.v{SCHEMA_VERSION}.db"));
assert!(s.other_databases().is_empty());
drop(s);
assert!(dir.join(db_file()).exists());
assert!(!dir.join(LEGACY_DB_FILE).exists());
for (name, is) in [
("wanderbooks.db", true),
("wanderbooks.v13.db", true),
("wanderbooks.v1.db", true),
("wanderbooks.v.db", false),
("wanderbooks.v13.db-wal", false),
("wanderbooks.v13a.db", false),
("wanderbooks.v13.corrupt.20261021T000000Z", false),
("journal.jsonl", false),
] {
assert_eq!(ledger_store::is_database_file(name), is, "{name}");
}
}