Files
wanderbooks/crates/ledger-store/tests/schema_files.rs
T
swaitsandClaude Opus 5.5 6288ead747 P3 review: asset bases and the tax-basis K-1
Schema 13 is as shipped and the asset columns are schema 14 (shipped
migrations pinned by hash); the tax-basis K-1 leaves net income open (only
the return has it) and takes a contributed prepaid plan at its book amount
as its section 723 basis; a disposal of contributed property names its
704(c) gain or loss; aliases compare by meaning; fixture amounts made up.

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

405 lines
16 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}");
}
}
/// A shipped migration's text never changes (P3 review, 2026-10-21: the
/// assets' v13 columns were first added to the v13 already on main, so a
/// v13 file built before them would open without them). Each schema's
/// file is built once by these scripts; a change is a new migration. The
/// SHA-256 of each shipped one, v1 to v13 (append the next as it ships).
#[test]
fn a_shipped_migration_never_changes() {
use sha2::{Digest, Sha256};
const SHIPPED: [&str; 13] = [
"8b253dded247d8518a7454b1c894a147ac211ee32ea2671dca2c586c5184c046",
"9fc19f844ed33b8f08eee8437ee3eb038e854737d5febe79ce3ac80abeb18614",
"215af9a7a54cda382f721488fedb34f68eb36399730de1b23371d44eb16fd79d",
"7923eb31ab8a08f20b8d7df017814095861478cb796af14994dbb85dbe558c20",
"95caa91ee3327b5590ad1ddefa76bad189950091cfcd140121f978e0af258e8a",
"74181a819beb4349808b9a095fbcda0073890fa6f54bc3fa5535c5a6644e35c6",
"df008d2a7cf832173908d1e33f795b88d272eae9cd23a5ebebe7207871d7076f",
"d0cd600ef4aa7bc2301e682bace23f3ce311b5f991e5d7bff447a9520ca8e89f",
"b2e54e8cf47b7f55f42f3fd4876fce5183e229aea08059518a50cbbffdca2db2",
"8881527992c586f69644276db921e640fb6548ee1d2f7c313a46c53e1e63430e",
"cc18b31e929b5889c0d09190b3211ccefd9d73df8e1e05c93f9e460097e1553a",
"dd2a93d760350f2edd9462f4a52888c0645040582739d3a93b17a3195238d501",
"9aa8a0ea7ea15315a4e38281e3a6d4e14beb26333991ebb2824d9c8cc5a51941",
];
for (i, want) in SHIPPED.iter().enumerate() {
let got: String = Sha256::digest(MIGRATIONS[i].as_bytes())
.iter()
.map(|b| format!("{b:02x}"))
.collect();
assert_eq!(&got, want, "migration v{} changed after it shipped", i + 1);
}
assert_eq!(MIGRATIONS.len() as u32, SCHEMA_VERSION);
}