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
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-21 14:04:11 -06:00
1 parent b0a9fd8ad4
commit 3a9aa92315
62 files changed
+5309 -628

No files matched your search

+4 -4
View File
@@ -33,7 +33,7 @@
use chrono::NaiveDate;
use ledger_core::{AccountType, AssetStatus, Currency, Money, NewAsset};
use ledger_store::{DB_FILE, JOURNAL_FILE, Store};
use ledger_store::{JOURNAL_FILE, Store, db_file};
use proptest::prelude::*;
use rust_decimal::Decimal;
use std::path::{Path, PathBuf};
@@ -309,10 +309,10 @@ fn check_completion(dir: &Path, asset: u64, ops: &[Op]) {
/// Copy the books (journal and projection) to `to`, closed.
fn copy_books(from: &Path, to: &Path) {
for f in [JOURNAL_FILE, DB_FILE] {
let src = from.join(f);
for f in [JOURNAL_FILE.to_string(), db_file()] {
let src = from.join(&f);
if src.exists() {
std::fs::copy(&src, to.join(f)).unwrap();
std::fs::copy(&src, to.join(&f)).unwrap();
}
}
}
+44 -21
View File
@@ -5,7 +5,9 @@
//! written before tags existed, refusals journaling nothing, and corrupt
//! `asset.serial_set` lines refused on open; and tax basis (B-25:
//! `asset.tax_basis_set` {asset_id, rule, contributor_basis | null}, the
//! v10 migration, corrupt payloads).
//! v10 schema, corrupt payloads). A database at an older schema under this
//! version's file name is rebuilt, never migrated (each schema keeps a file
//! of its own: rules.md B-27, tests/schema_files.rs).
//!
//! Expectations come from the doc comments on `Store::set_asset_tag`,
//! `Store::set_asset_serial`, `Store::open`, `Store::verify`,
@@ -14,7 +16,7 @@
use chrono::NaiveDate;
use ledger_core::{AccountType, AssetId, Currency, LedgerError, Money, NewAsset};
use ledger_store::journal::read_and_verify_journal;
use ledger_store::{DB_FILE, JOURNAL_FILE, JournalWriter, Store, StoreError};
use ledger_store::{JOURNAL_FILE, JournalWriter, Store, StoreError, db_file};
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
@@ -245,7 +247,7 @@ fn tags_and_serials_replay_on_reopen() {
for rebuild in [false, true] {
if rebuild {
std::fs::remove_file(dir.join(DB_FILE)).unwrap();
std::fs::remove_file(dir.join(db_file())).unwrap();
}
let s = Store::open(&dir).unwrap();
s.verify().unwrap();
@@ -327,7 +329,7 @@ fn verify_passes_after_many_retags_and_serial_changes() {
fn books_written_before_tags_open_and_verify() {
let (dir, s, _, x, y) = fixture("older");
drop(s);
let _ = std::fs::remove_file(dir.join(DB_FILE));
let _ = std::fs::remove_file(dir.join(db_file()));
let mut s = Store::open(&dir).expect("older books still open");
s.verify().unwrap();
assert_eq!(tag_of(&s, x), None);
@@ -356,7 +358,7 @@ fn an_older_asset_registered_line_still_replays() {
)
.unwrap();
drop(w);
let _ = std::fs::remove_file(dir.join(DB_FILE));
let _ = std::fs::remove_file(dir.join(db_file()));
let mut s = Store::open(&dir).expect("older books still open");
s.verify().unwrap();
let old = y + 1;
@@ -372,12 +374,23 @@ fn an_older_asset_registered_line_still_replays() {
assert_eq!(tag_of(&s, old).as_deref(), Some("FL-0001"));
}
/// Whether this version's database was kept aside (and rebuilt) as the
/// books opened: `wanderbooks.v{N}.corrupt.*` beside it.
fn kept_aside(dir: &Path) -> bool {
let stem = db_file().trim_end_matches(".db").to_string();
std::fs::read_dir(dir).unwrap().flatten().any(|e| {
e.file_name()
.to_string_lossy()
.starts_with(&format!("{stem}.corrupt."))
})
}
/// Downgrade the database to schema v8 (no assets.tag / assets.serial, no
/// asset_tags_log, and none of v10's tax basis columns), as an older
/// wanderbooks left it.
fn downgrade_db_to_v8(dir: &Path) {
downgrade_db_to_v9(dir);
let conn = rusqlite::Connection::open(dir.join(DB_FILE)).unwrap();
let conn = rusqlite::Connection::open(dir.join(db_file())).unwrap();
conn.execute_batch(
"ALTER TABLE assets DROP COLUMN tag;
ALTER TABLE assets DROP COLUMN serial;
@@ -388,13 +401,17 @@ fn downgrade_db_to_v8(dir: &Path) {
.unwrap();
}
/// An existing v8 database migrates to v9 on open and verifies.
/// A database at schema v8 under this version's own file name (what an
/// older wanderbooks once migrated in place) is never migrated now: each
/// schema keeps a file of its own (rules.md B-27), so this one is damaged,
/// kept aside and rebuilt from the journal as the books open, and verifies.
#[test]
fn a_v8_database_migrates_and_verifies() {
let (dir, s, _, x, y) = fixture("v8");
drop(s);
downgrade_db_to_v8(&dir);
let mut s = Store::open(&dir).expect("a v8 database opens");
assert!(kept_aside(&dir), "rebuilt, not migrated");
s.verify().unwrap();
s.set_asset_tag(x, "FL-0001").unwrap();
s.set_asset_serial(y, Some("SN-Y")).unwrap();
@@ -406,8 +423,8 @@ fn a_v8_database_migrates_and_verifies() {
assert_eq!(serial_of(&s, y).as_deref(), Some("SN-Y"));
}
/// A v8 database behind a journal that has tag lines (Store::open SCENARIO
/// 7: apply the rest) migrates, applies them, and verifies.
/// A v8 database behind a journal that has tag lines is rebuilt from the
/// whole journal, the tag lines with it (as above), and verifies.
#[test]
fn a_v8_database_behind_a_journal_with_tags_catches_up() {
let (dir, s, _, x, _) = fixture("v8-behind");
@@ -423,6 +440,7 @@ fn a_v8_database_behind_a_journal_with_tags_catches_up() {
.unwrap();
drop(w);
let s = Store::open(&dir).expect("opens and catches up");
assert!(kept_aside(&dir), "rebuilt, not migrated");
s.verify().unwrap();
assert_eq!(tag_of(&s, x).as_deref(), Some("FL-0001"));
assert_eq!(serial_of(&s, x).as_deref(), Some("SN-1"));
@@ -449,7 +467,7 @@ fn an_asset_serial_set_line_with_a_non_string_serial_is_corrupt() {
Ok(_) => panic!("serial {bad}: a corrupt asset.serial_set line opened"),
}
// Rebuilding from the journal alone refuses it too.
let _ = std::fs::remove_file(dir.join(DB_FILE));
let _ = std::fs::remove_file(dir.join(db_file()));
assert!(Store::open(&dir).is_err(), "serial {bad}: rebuild opened");
}
}
@@ -497,7 +515,7 @@ fn the_acquisition_link_replays_and_verifies() {
assert_eq!(s.ledger().get_asset(x).unwrap().acquired_by, Some(e));
s.verify().unwrap();
drop(s);
std::fs::remove_file(dir.join(DB_FILE)).unwrap();
std::fs::remove_file(dir.join(db_file())).unwrap();
let s = Store::open(&dir).unwrap();
assert_eq!(s.ledger().get_asset(x).unwrap().acquired_by, Some(e));
s.verify().unwrap();
@@ -542,7 +560,7 @@ fn a_description_change_replays_and_verifies() {
assert_eq!(p["description"], "Laptop, contributed jointly");
s.verify().unwrap();
drop(s);
std::fs::remove_file(dir.join(DB_FILE)).unwrap();
std::fs::remove_file(dir.join(db_file())).unwrap();
let s = Store::open(&dir).unwrap();
assert_eq!(
s.ledger().get_asset(x).unwrap().description,
@@ -673,7 +691,7 @@ fn tax_basis_replays_on_reopen() {
drop(s);
for rebuild in [false, true] {
if rebuild {
std::fs::remove_file(dir.join(DB_FILE)).unwrap();
std::fs::remove_file(dir.join(db_file())).unwrap();
}
let s = Store::open(&dir).unwrap();
s.verify().unwrap();
@@ -694,7 +712,7 @@ fn tax_basis_replays_on_reopen() {
/// Downgrade the database to schema v10 (none of v11's attachment links,
/// statements, reconciliations, or payroll_lines.employee_id).
fn downgrade_db_to_v10(dir: &Path) {
let conn = rusqlite::Connection::open(dir.join(DB_FILE)).unwrap();
let conn = rusqlite::Connection::open(dir.join(db_file())).unwrap();
conn.execute_batch(
"DROP TABLE attachment_links;
DROP TABLE statements;
@@ -706,7 +724,8 @@ fn downgrade_db_to_v10(dir: &Path) {
.unwrap();
}
/// An existing v10 database migrates on open and verifies.
/// A database at schema v10 under this version's own file name is rebuilt
/// from the journal as the books open (as the v8 one above), and verifies.
#[test]
fn a_v10_database_migrates_and_verifies() {
let (dir, mut s, _, x, _) = fixture("v10");
@@ -714,6 +733,7 @@ fn a_v10_database_migrates_and_verifies() {
drop(s);
downgrade_db_to_v10(&dir);
let s = Store::open(&dir).expect("a v10 database opens");
assert!(kept_aside(&dir), "rebuilt, not migrated");
s.verify().unwrap();
assert_eq!(tag_of(&s, x).as_deref(), Some("FL-0001"));
}
@@ -722,7 +742,7 @@ fn a_v10_database_migrates_and_verifies() {
/// assets.contributor_basis), as an older wanderbooks left it.
fn downgrade_db_to_v9(dir: &Path) {
downgrade_db_to_v10(dir);
let conn = rusqlite::Connection::open(dir.join(DB_FILE)).unwrap();
let conn = rusqlite::Connection::open(dir.join(db_file())).unwrap();
conn.execute_batch(
"ALTER TABLE assets DROP COLUMN tax_rule;
ALTER TABLE assets DROP COLUMN contributor_basis;
@@ -731,7 +751,8 @@ fn downgrade_db_to_v9(dir: &Path) {
.unwrap();
}
/// An existing v9 database migrates on open, verifies, and takes tax bases.
/// A database at schema v9 under this version's own file name is rebuilt
/// as the books open (as the v8 one above), verifies, and takes tax bases.
#[test]
fn a_v9_database_migrates_and_verifies() {
use ledger_core::TaxBasisRule::*;
@@ -740,6 +761,7 @@ fn a_v9_database_migrates_and_verifies() {
drop(s);
downgrade_db_to_v9(&dir);
let mut s = Store::open(&dir).expect("a v9 database opens");
assert!(kept_aside(&dir), "rebuilt, not migrated");
s.verify().unwrap();
assert_eq!(tag_of(&s, x).as_deref(), Some("FL-0001"));
assert_eq!(tax_of(&s, x), None);
@@ -752,8 +774,8 @@ fn a_v9_database_migrates_and_verifies() {
assert_eq!(tax_of(&s, y), Some(tax(Carryover, Some("640.00"))));
}
/// A v9 database behind a journal with a tax basis line migrates, applies
/// it (Store::open SCENARIO 7), and verifies.
/// A v9 database behind a journal with a tax basis line is rebuilt from
/// the whole journal, that line with it, and verifies.
#[test]
fn a_v9_database_behind_a_journal_with_tax_basis_catches_up() {
use ledger_core::TaxBasisRule::*;
@@ -768,6 +790,7 @@ fn a_v9_database_behind_a_journal_with_tax_basis_catches_up() {
.unwrap();
drop(w);
let s = Store::open(&dir).expect("opens and catches up");
assert!(kept_aside(&dir), "rebuilt, not migrated");
s.verify().unwrap();
assert_eq!(tax_of(&s, x), Some(tax(Carryover, Some("300.00"))));
}
@@ -799,7 +822,7 @@ fn an_asset_tax_basis_set_line_with_a_bad_payload_is_corrupt() {
Err(other) => panic!("{bad}: expected JournalCorrupt, got {other:?}"),
Ok(_) => panic!("{bad}: a corrupt asset.tax_basis_set line opened"),
}
let _ = std::fs::remove_file(dir.join(DB_FILE));
let _ = std::fs::remove_file(dir.join(db_file()));
match Store::open(&dir) {
Err(StoreError::JournalCorrupt(_)) => {}
Err(other) => panic!("{bad} rebuild: expected JournalCorrupt, got {other:?}"),
@@ -835,7 +858,7 @@ fn an_asset_is_corrected_until_depreciation_is_posted() {
assert_eq!(p["previous"]["cost"]["amount"], "1200.00");
s.verify().unwrap();
drop(s);
std::fs::remove_file(dir.join(DB_FILE)).unwrap();
std::fs::remove_file(dir.join(db_file())).unwrap();
let mut s = Store::open(&dir).unwrap();
assert_eq!(
s.ledger().get_asset(x).unwrap().cost.amount.to_string(),
+27 -10
View File
@@ -396,9 +396,12 @@ fn a_skipped_month_is_caught_up_in_the_month_run() {
}
/// P3 depreciation review R3: a run reversed within its month is
/// un-posted, so its month is due again; running it again declares journal
/// feature month_reposting first (an older version would ignore the second
/// line for the month).
/// un-posted, so its month is due again; running it again needs journal
/// feature month_reposting (an older version would ignore the second line
/// for the month), which new books declare as they are created, so it
/// journals no declaration of its own (books an older version wrote wait
/// for their format upgrade: format.rs
/// a_write_waiting_for_the_upgrade_journals_nothing).
#[test]
fn a_reversed_depreciation_month_can_be_run_again() {
let (dir, mut store) = open_store();
@@ -409,14 +412,28 @@ fn a_reversed_depreciation_month_can_be_run_again() {
store
.reverse_entry(jan_entry, date(2026, 1, 31), "posted to the wrong asset")
.unwrap();
assert!(store.journal_format().features.is_empty());
assert!(
store
.journal_format()
.has_feature(ledger_store::format::MONTH_REPOSTING)
);
let declarations = |dir: &std::path::Path| {
std::fs::read_to_string(dir.join(ledger_store::JOURNAL_FILE))
.unwrap()
.lines()
.filter(|l| l.contains("journal.format_set"))
.count()
};
let before = declarations(dir.path());
let again = store.run_depreciation(x, "2026-01").unwrap();
assert!(!again.already_posted);
assert_eq!(again.amount, dec("1000.00"));
assert_ne!(again.entry_id, Some(jan_entry));
assert_eq!(*store.journal_format(), ledger_store::format::current());
assert_eq!(
store.journal_format().features,
vec![ledger_store::format::MONTH_REPOSTING.to_string()]
declarations(dir.path()),
before,
"no declaration of its own"
);
// Idempotent again: the new run is the month's.
let twice = store.run_depreciation(x, "2026-01").unwrap();
@@ -430,7 +447,7 @@ fn a_reversed_depreciation_month_can_be_run_again() {
// Reopened (the projection rebuilt from the journal, then caught up),
// the same.
drop(store);
std::fs::remove_file(dir.path().join(ledger_store::DB_FILE)).unwrap();
std::fs::remove_file(dir.path().join(ledger_store::db_file())).unwrap();
let store = Store::open(dir.path()).unwrap();
store.verify().unwrap();
let mut store = store;
@@ -631,7 +648,7 @@ fn a_run_reversed_after_its_month_is_caught_up_in_the_reversals_month() {
);
assert_eq!(balance(&store, accum, date(2026, 1, 31)), dec("-1000.00"));
assert_eq!(balance(&store, accum, date(2026, 2, 28)), dec("-2000.00"));
assert!(store.journal_format().features.is_empty());
assert_eq!(*store.journal_format(), ledger_store::format::current());
store.verify().unwrap();
}
@@ -854,7 +871,7 @@ fn a_run_reinstated_after_its_rerun_was_reversed_still_counts() {
store.verify().unwrap();
// The same after the projection is rebuilt from the journal.
drop(store);
std::fs::remove_file(dir.path().join(ledger_store::DB_FILE)).unwrap();
std::fs::remove_file(dir.path().join(ledger_store::db_file())).unwrap();
Store::open(dir.path()).unwrap().verify().unwrap();
}
@@ -1069,7 +1086,7 @@ fn a_disposal_whose_month_has_run_charges_what_is_still_due_in_its_entry() {
.filter(|l| l.contains("asset.depreciation_posted") && l.contains("\"2026-06\""))
.count();
assert_eq!(junes, 1, "June keeps one run");
assert!(store.journal_format().features.is_empty());
assert_eq!(*store.journal_format(), ledger_store::format::current());
let on = date(2026, 6, 30);
assert_eq!(balance(&store, accum, on), Decimal::ZERO);
assert_eq!(balance(&store, expense, on), dec("5500.00"));
+2 -2
View File
@@ -17,7 +17,7 @@ use ledger_core::arap::DocumentLine;
use ledger_core::{AccountId, AccountType, Currency, EntryId, Money, NewAsset, Side};
use ledger_store::journal::read_and_verify_journal;
use ledger_store::store::{Reconciliation, Statement, Subject};
use ledger_store::{Attachment, DB_FILE, JOURNAL_FILE, Store, StoreError};
use ledger_store::{Attachment, JOURNAL_FILE, Store, StoreError, db_file};
use rust_decimal::Decimal;
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
@@ -137,7 +137,7 @@ fn refused_nothing_journaled<T: std::fmt::Debug>(
fn delete_db(dir: &Path) {
for suffix in ["", "-wal", "-shm", "-journal"] {
let _ = std::fs::remove_file(dir.join(format!("{DB_FILE}{suffix}")));
let _ = std::fs::remove_file(dir.join(format!("{}{suffix}", db_file())));
}
}
@@ -0,0 +1,7 @@
{"payload":{"base_currency":"USD"},"prev":"0000000000000000000000000000000000000000000000000000000000000000","seq":1,"ts":"2026-10-21T15:47:13Z","type":"store.created"}
{"payload":{"account_type":"Asset","code":"1000","currency":"USD","id":1,"name":"Cash","parent":null,"placeholder":false},"prev":"f7654c1569eb4937590b36306bc57c162d23109ec5e73cfe1eb2a25f2a56613e","seq":2,"ts":"2026-10-21T15:47:13Z","type":"account.added"}
{"payload":{"account_type":"Equity","code":"3000","currency":"USD","id":2,"name":"Capital","parent":null,"placeholder":false},"prev":"0365a319dcc4a38d8ea85a0aa18cffa6d3507c85f7999ec676e868e577f5c4a8","seq":3,"ts":"2026-10-21T15:47:13Z","type":"account.added"}
{"payload":{"date":"2026-03-01","description":"Capital in","id":1},"prev":"706e1ee9e5804f21858822f83e55cdb91e3b6f7b122929944adf78639239e963","seq":4,"ts":"2026-10-21T15:47:13Z","type":"entry.created"}
{"payload":{"account":1,"amount":"500","currency":"USD","id":1,"side":"debit"},"prev":"1a77b7d31e6394b71a2ea90ca1c059e82bffda1223ac009776ab94719a299b1b","seq":5,"ts":"2026-10-21T15:47:13Z","type":"entry.line_added"}
{"payload":{"account":2,"amount":"500","currency":"USD","id":1,"side":"credit"},"prev":"7b976b885597c115457545dfc2c1ba21df432796941acc8ecdf971254fa0c0e8","seq":6,"ts":"2026-10-21T15:47:13Z","type":"entry.line_added"}
{"payload":{"date":"2026-03-01","description":"Capital in","id":1,"lines":[{"account":1,"amount":"500","currency":"USD","side":"debit"},{"account":2,"amount":"500","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"0bf713aa4db27c948592d5e772ea49414d95d068891fa525874e3eddfcf82796","seq":7,"ts":"2026-10-21T15:47:13Z","type":"entry.posted"}
Binary file not shown.
+103 -41
View File
@@ -1,8 +1,9 @@
//! 1099 decisions and payment methods in the journal (plan P3, defects
//! 12, 18 and 23; D-40): declared as features before their first line,
//! replayed and projected exactly, refused without a trace, and books
//! written before them read as they always meant. Every name is
//! fictional.
//! 12, 18 and 23; D-40): written only in books that declare their
//! features (this version's from creation, an older version's after the
//! format upgrade), replayed and projected exactly, refused without a
//! trace, and books written before them read as they always meant. Every
//! name is fictional.
//!
//! ```text
//! GOAL: the decision ("no 1099" included) and how each payment was made
@@ -17,9 +18,11 @@ use ledger_core::{
AccountType, Currency, Dated1099, Decision1099, ExemptReason, Form1099, InstrumentKind, Money,
Side,
};
use ledger_store::format::{FORMAT_SET, PAYMENT_INSTRUMENTS, VENDOR_1099_DECISIONS};
use ledger_store::format::{FORMAT_SET, PAYMENT_INSTRUMENTS, VENDOR_1099_DECISIONS, current};
use ledger_store::journal::read_and_verify_journal;
use ledger_store::{DB_FILE, JOURNAL_FILE, JournalWriter, Snapshot, Store, StoreError};
use ledger_store::{
JOURNAL_FILE, JournalFormat, JournalWriter, Snapshot, Store, StoreError, db_file,
};
use rust_decimal::Decimal;
use serde_json::json;
use std::path::{Path, PathBuf};
@@ -76,7 +79,13 @@ struct Books {
}
fn books(dir: &Path) -> Books {
let mut store = Store::create(dir, Currency::USD).unwrap();
books_in(dir, &current())
}
/// [`books`] in the journal format `format`: format 1
/// ([`JournalFormat::default`]) is books an older version wrote.
fn books_in(dir: &Path, format: &JournalFormat) -> Books {
let mut store = Store::create_in_format(dir, Currency::USD, format).unwrap();
store
.add_account("1000", "Bank", AccountType::Asset, None, false)
.unwrap();
@@ -130,26 +139,57 @@ fn types(dir: &Path) -> Vec<(String, serde_json::Value)> {
.collect()
}
/// B-27: the first line that needs a feature comes after a declaration of
/// it (with every feature already in force), once. A version that doesn't
/// know the feature stops at the declaration (`tests/format.rs`
/// B-27: a line that needs a feature is written only in books that
/// declare it, and a command never declares one on the way. Books an
/// older version wrote (format 1) refuse a decision and a payment method
/// while they wait for their format upgrade, journaling nothing; the
/// upgrade is one declaration of every feature; after it, as in books this
/// version made (which declared them when created), each command journals
/// its own lines and nothing else. A version that doesn't know a feature
/// stops at the declaration (`tests/format.rs`
/// old_reader_gets_newer_journal, for any feature it doesn't know).
#[test]
fn each_feature_is_declared_once_before_its_first_line() {
fn each_feature_waits_for_the_upgrade_and_is_never_declared_on_the_way() {
let dir = test_dir("declare");
let mut b = books(&dir);
let before = types(&dir).len();
let mut b = books_in(&dir, &JournalFormat::default());
let was = std::fs::read(dir.join(JOURNAL_FILE)).unwrap();
assert!(
!types(&dir).iter().any(|(t, _)| t == FORMAT_SET),
"books without either feature declare nothing"
"books an older version wrote declare nothing"
);
b.store
.set_vendor_1099(
b.harbor,
&from_beginning(Decision1099::Files(Form1099::Nec)),
let nec = from_beginning(Decision1099::Files(Form1099::Nec));
fn waits<T: std::fmt::Debug>(r: Result<T, StoreError>, feature: &str) {
assert!(
matches!(&r, Err(StoreError::FormatUpgradePending { need }) if need.contains(feature)),
"{feature}: {r:?}"
);
}
waits(
b.store.set_vendor_1099(b.harbor, &nec, None),
VENDOR_1099_DECISIONS,
);
waits(
b.store.set_vendor_w9_flag(b.harbor, true),
VENDOR_1099_DECISIONS,
);
waits(
b.store.add_instrument(
InstrumentKind::MemberCard,
"Riley's Visa",
Some("4242"),
None,
)
.unwrap();
b.due,
),
PAYMENT_INSTRUMENTS,
);
assert_eq!(
std::fs::read(dir.join(JOURNAL_FILE)).unwrap(),
was,
"nothing journaled"
);
let before = types(&dir).len();
assert!(b.store.upgrade_format().unwrap().is_some());
b.store.set_vendor_1099(b.harbor, &nec, None).unwrap();
b.store.set_vendor_w9_flag(b.harbor, true).unwrap();
let card = b
.store
@@ -171,26 +211,48 @@ fn each_feature_is_declared_once_before_its_first_line() {
b.due,
)
.unwrap();
let added: Vec<String> = types(&dir)[before..]
.iter()
.map(|(t, p)| match t.as_str() {
FORMAT_SET => format!("{t} {}", p["features"]),
_ => t.clone(),
})
.collect();
let j = types(&dir);
let added: Vec<&str> = j[before..].iter().map(|(t, _)| t.as_str()).collect();
assert_eq!(
added,
[
format!("{FORMAT_SET} [\"{VENDOR_1099_DECISIONS}\"]"),
"vendor.1099_set".to_string(),
"vendor.w9_flag_set".to_string(),
format!("{FORMAT_SET} [\"{PAYMENT_INSTRUMENTS}\",\"{VENDOR_1099_DECISIONS}\"]"),
"instrument.added".to_string(),
"entry.instrument_set".to_string(),
"instrument.added".to_string(),
FORMAT_SET,
"vendor.1099_set",
"vendor.w9_flag_set",
"instrument.added",
"entry.instrument_set",
"instrument.added",
]
);
assert_eq!(b.store.journal_format().format, 2);
let declared = &j[before].1["features"];
for feature in [VENDOR_1099_DECISIONS, PAYMENT_INSTRUMENTS] {
assert!(
declared.as_array().unwrap().contains(&json!(feature)),
"the upgrade declares {feature}"
);
}
assert_eq!(b.store.journal_format(), &current());
b.store.verify().unwrap();
// Books this version made: declared at creation, so the same commands
// journal only their own lines.
let dir = test_dir("declare-new");
let mut b = books(&dir);
let before = types(&dir).len();
b.store.set_vendor_1099(b.harbor, &nec, None).unwrap();
b.store
.add_instrument(
InstrumentKind::MemberCashOrCheck,
"Riley's checks",
None,
None,
b.due,
)
.unwrap();
let j = types(&dir);
let added: Vec<&str> = j[before..].iter().map(|(t, _)| t.as_str()).collect();
assert_eq!(added, ["vendor.1099_set", "instrument.added"]);
assert_eq!(j.iter().filter(|(t, _)| t == FORMAT_SET).count(), 1);
}
/// Every record replays and projects exactly: reopening gives back the
@@ -252,14 +314,14 @@ fn decisions_and_methods_replay_and_project() {
assert!(live.differs_from(store.ledger()).is_empty());
store.verify().unwrap();
drop(store);
std::fs::remove_file(dir.join(DB_FILE)).unwrap();
std::fs::remove_file(dir.join(db_file())).unwrap();
let store = Store::open(&dir).unwrap();
store.verify().unwrap();
assert!(live.differs_from(store.ledger()).is_empty());
drop(store);
let snap = Snapshot::load(&dir).unwrap();
assert!(live.differs_from(snap.ledger()).is_empty());
let conn = rusqlite::Connection::open(dir.join(DB_FILE)).unwrap();
let conn = rusqlite::Connection::open(dir.join(db_file())).unwrap();
let decisions: Vec<DecisionRow> = conn
.prepare("SELECT effective, form_1099, reason_kind, source_json FROM vendor_1099_decisions ORDER BY seq")
.unwrap()
@@ -478,7 +540,7 @@ fn an_unknown_1099_value_is_newer_not_corrupt() {
.unwrap();
let seq = w.append(t, payload).unwrap().seq;
drop(w);
std::fs::remove_file(dir.join(DB_FILE)).unwrap();
std::fs::remove_file(dir.join(db_file())).unwrap();
match (Store::open(&dir), need) {
(
Err(StoreError::NewerJournal {
@@ -502,7 +564,7 @@ fn an_unknown_1099_value_is_newer_not_corrupt() {
#[test]
fn books_written_before_decisions_read_as_they_always_meant() {
let dir = test_dir("legacy");
let b = books(&dir);
let b = books_in(&dir, &JournalFormat::default());
let (harbor, paid) = (b.harbor, b.paid);
drop(b);
let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap();
@@ -519,7 +581,7 @@ fn books_written_before_decisions_read_as_they_always_meant() {
w.append("vendor.1099_set", p).unwrap();
}
drop(w);
std::fs::remove_file(dir.join(DB_FILE)).unwrap();
std::fs::remove_file(dir.join(db_file())).unwrap();
let journal = std::fs::read(dir.join(JOURNAL_FILE)).unwrap();
let store = Store::open(&dir).unwrap();
store.verify().unwrap();
@@ -543,7 +605,7 @@ fn books_written_before_decisions_read_as_they_always_meant() {
assert_eq!(store.journal_format().features, Vec::<String>::new());
drop(store);
assert_eq!(std::fs::read(dir.join(JOURNAL_FILE)).unwrap(), journal);
let conn = rusqlite::Connection::open(dir.join(DB_FILE)).unwrap();
let conn = rusqlite::Connection::open(dir.join(db_file())).unwrap();
let forms: Vec<(i64, Option<String>, bool)> = conn
.prepare("SELECT id, form_1099, tin_on_file FROM vendors ORDER BY id")
.unwrap()
+112 -85
View File
@@ -20,8 +20,8 @@ use ledger_core::{AccountType, Currency, Money, Side};
use ledger_store::format::{EVENT_TYPES, FORMAT_SET, KNOWN_FEATURES};
use ledger_store::journal::{canonical_json, sha256_hex};
use ledger_store::{
DB_FILE, JOURNAL_FILE, JournalFormat, JournalWriter, SUPPORTED_JOURNAL_FORMAT, Snapshot, Store,
StoreError,
JOURNAL_FILE, JournalFormat, JournalWriter, SUPPORTED_JOURNAL_FORMAT, Snapshot, Store,
StoreError, db_file,
};
use rust_decimal::Decimal;
use serde_json::json;
@@ -57,9 +57,11 @@ fn test_dir(name: &str) -> TestDir {
TestDir(dir)
}
/// Books with one posted entry, closed again.
/// Books with one posted entry, closed again: in journal format 1, as
/// every version before the format gate wrote them (this version's own
/// declare its format as they are created).
fn books(dir: &Path) {
let mut store = Store::create(dir, Currency::USD).unwrap();
let mut store = Store::create_in_format(dir, Currency::USD, &JournalFormat::default()).unwrap();
let cash = store
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
@@ -177,12 +179,12 @@ fn old_reader_gets_newer_journal() {
),
],
)[0];
std::fs::remove_file(dir.join(DB_FILE)).unwrap();
std::fs::remove_file(dir.join(db_file())).unwrap();
let journal = std::fs::read(dir.join(JOURNAL_FILE)).unwrap();
assert_newer(Store::open(&dir), seq, line_type, need, true);
assert!(
!dir.join(DB_FILE).exists(),
!dir.join(db_file()).exists(),
"a refused open rebuilt the database"
);
assert_eq!(std::fs::read(dir.join(JOURNAL_FILE)).unwrap(), journal);
@@ -746,7 +748,7 @@ fn a_bad_complete_line_before_a_torn_tail_leaves_the_journal_as_it_was() {
append_raw(dir, b"{\"seq\":");
let path = dir.join(JOURNAL_FILE);
let before = std::fs::read(&path).unwrap();
let had_db = dir.join(ledger_store::DB_FILE).exists();
let had_db = dir.join(ledger_store::db_file()).exists();
assert!(Store::open(dir).is_err(), "{}", dir.display());
assert_eq!(std::fs::read(&path).unwrap(), before, "{}", dir.display());
let set_aside: Vec<_> = std::fs::read_dir(dir)
@@ -756,7 +758,7 @@ fn a_bad_complete_line_before_a_torn_tail_leaves_the_journal_as_it_was() {
.collect();
assert!(set_aside.is_empty(), "{set_aside:?}");
assert_eq!(
dir.join(ledger_store::DB_FILE).exists(),
dir.join(ledger_store::db_file()).exists(),
had_db,
"no database made for books that can't open"
);
@@ -885,16 +887,16 @@ fn read_journal_and_format_gives_every_line_and_the_format() {
}
/// P3's journal extensions (rules.md B-27; plan D-39): each is announced
/// by a feature this version knows, declared once, just before the first
/// line that needs it, with the features in force kept: `account_renamed`
/// before the first `account.renamed`, `require_tax_category` before the
/// first `settings.set` naming `policy.require_tax_category`. A reader
/// whose features don't include it (every earlier version: none) stops at
/// the declaration as newer, never at the line, and never calls it
/// corrupt; books that never use either stay format 1 or 2 with no
/// feature.
/// by a feature this version knows, `account_renamed` for an
/// `account.renamed`, `require_tax_category` for a `settings.set` naming
/// `policy.require_tax_category`. Books an older version made (books():
/// format 1, nothing declared) refuse both, journaling nothing, until the
/// owner's format upgrade declares this version's whole format at once;
/// then both are taken, under that one declaration. A reader whose
/// features don't include them (every earlier version: none) stops at the
/// declaration as newer, never at the line, and never calls it corrupt.
#[test]
fn p3_features_are_declared_once_before_the_first_line_that_needs_them() {
fn p3_features_wait_for_the_upgrade_and_are_declared_once() {
for f in ["account_renamed", "require_tax_category"] {
assert!(KNOWN_FEATURES.contains(&f), "{f}");
}
@@ -902,26 +904,35 @@ fn p3_features_are_declared_once_before_the_first_line_that_needs_them() {
books(&dir);
let mut store = Store::open(&dir).unwrap();
assert!(store.journal_format().features.is_empty());
let policy = || ledger_core::settings::Change {
key: "policy.require_tax_category".into(),
value: Some(json!("refuse")),
};
let source = ledger_core::settings::Source {
decided_by: "Owner".into(),
decided_on: NaiveDate::from_ymd_opt(2026, 10, 20).unwrap(),
citation: None,
note: None,
};
let head = store.head();
let waits = |r: Result<(), StoreError>, need: &str| match r {
Err(StoreError::FormatUpgradePending { need: n }) => assert_eq!(n, need),
other => panic!("expected FormatUpgradePending, got {other:?}"),
};
waits(
store.rename_account(1, "Business Checking", None),
"the journal feature account_renamed",
);
waits(
store.change_settings(&[policy()], &source).map(|_| ()),
"the journal feature require_tax_category",
);
assert_eq!(store.head(), head, "refused whole");
assert!(store.upgrade_format().unwrap().is_some());
store.rename_account(1, "Business Checking", None).unwrap();
store.rename_account(1, "Checking", None).unwrap();
store
.change_settings(
&[ledger_core::settings::Change {
key: "policy.require_tax_category".into(),
value: Some(json!("refuse")),
}],
&ledger_core::settings::Source {
decided_by: "Owner".into(),
decided_on: NaiveDate::from_ymd_opt(2026, 10, 20).unwrap(),
citation: None,
note: None,
},
)
.unwrap();
assert_eq!(
store.journal_format().features,
["account_renamed", "require_tax_category"]
);
store.change_settings(&[policy()], &source).unwrap();
assert_eq!(*store.journal_format(), ledger_store::format::current());
drop(store);
let text = std::fs::read_to_string(dir.join(JOURNAL_FILE)).unwrap();
let lines: Vec<serde_json::Value> = text
@@ -933,23 +944,19 @@ fn p3_features_are_declared_once_before_the_first_line_that_needs_them() {
.map(|l| (l["type"].as_str().unwrap(), l["payload"].clone()))
.filter(|(t, _)| matches!(*t, FORMAT_SET | "account.renamed" | "settings.set"))
.collect();
let current = ledger_store::format::current();
assert_eq!(kinds[0].0, FORMAT_SET);
assert_eq!(
kinds[0].1,
json!({"format": 2, "features": ["account_renamed"]})
json!({"format": current.format, "features": current.features})
);
assert_eq!(kinds[1].0, "account.renamed");
assert_eq!(kinds[2].0, "account.renamed");
assert_eq!(kinds[3].0, FORMAT_SET);
assert_eq!(
kinds[3].1,
json!({"format": 2, "features": ["account_renamed", "require_tax_category"]})
);
assert_eq!(kinds[4].0, "settings.set");
assert_eq!(kinds.len(), 5);
assert_eq!(kinds[3].0, "settings.set");
assert_eq!(kinds.len(), 4);
// An earlier reader knows no feature (P2: KNOWN_FEATURES was empty),
// so the gate stops it at the first declaration, before the rename:
// the gate's refusal of a feature it doesn't know is pinned by
// so the gate stops it at the declaration, before the rename: the
// gate's refusal of a feature it doesn't know is pinned by
// an_unknown_feature_before_a_rename_is_newer_at_the_declaration.
// This version opens them, in the writer and the viewer.
let store = Store::open(&dir).unwrap();
@@ -980,7 +987,7 @@ fn an_unknown_feature_before_a_rename_is_newer_at_the_declaration() {
),
],
);
let _ = std::fs::remove_file(dir.join(DB_FILE));
let _ = std::fs::remove_file(dir.join(db_file()));
match Store::open(&dir) {
Err(StoreError::NewerJournal { seq, need, .. }) => {
assert_eq!(seq, seqs[0]);
@@ -1034,33 +1041,41 @@ fn lines(dir: &Path) -> Vec<serde_json::Value> {
.collect()
}
/// P3 (defect 9; rules.md B-27): the first daily schedule declares journal
/// feature schedule_convention, on the line before its schedule.registered,
/// which carries convention "daily". A version before P3 knew no features,
/// so it stops at that declaration (NewerJournal; format.rs's
/// an_older_reader_stops_at_p3s_features), never reading the schedule as
/// equal months. This version reads it back, posts by the day, and
/// verifies; a second daily schedule declares nothing more.
/// P3 (defect 9; rules.md B-27, D-39): a daily schedule's
/// schedule.registered, which carries convention "daily", needs journal
/// feature schedule_convention. Books an older version made (books():
/// format 1) refuse one, journaling nothing, until the owner's format
/// upgrade, whose one declaration is on the line before it. A version
/// before P3 knew no features, so it stops at that declaration
/// (NewerJournal; format.rs's an_older_reader_stops_at_p3s_features),
/// never reading the schedule as equal months. This version reads it back,
/// posts by the day, and verifies; a second daily schedule declares
/// nothing more.
#[test]
fn the_first_daily_schedule_declares_its_feature_and_an_older_reader_stops_there() {
fn a_daily_schedule_waits_for_its_feature_and_an_older_reader_stops_there() {
let dir = test_dir("daily-feature");
let accounts = schedule_books(&dir);
let mut store = Store::open(&dir).unwrap();
let id = store
.register_schedule(insurance(
accounts,
day(2026, 3, 15),
ledger_core::ScheduleConvention::Daily,
))
.unwrap();
let daily = |start| insurance(accounts, start, ledger_core::ScheduleConvention::Daily);
let head = store.head();
match store.register_schedule(daily(day(2026, 3, 15))) {
Err(StoreError::FormatUpgradePending { need }) => {
assert_eq!(need, "the journal feature schedule_convention")
}
other => panic!("expected FormatUpgradePending, got {other:?}"),
}
assert_eq!(store.head(), head, "refused whole");
store.upgrade_format().unwrap();
let id = store.register_schedule(daily(day(2026, 3, 15))).unwrap();
let journal = lines(&dir);
let n = journal.len();
assert_eq!(journal[n - 1]["type"], "schedule.registered");
assert_eq!(journal[n - 1]["payload"]["convention"], "daily");
assert_eq!(journal[n - 2]["type"], FORMAT_SET);
let current = ledger_store::format::current();
assert_eq!(
journal[n - 2]["payload"],
json!({"format": 2, "features": ["schedule_convention"]})
json!({"format": current.format, "features": current.features})
);
store.run_schedules(day(2026, 5, 31)).unwrap();
store
@@ -1152,7 +1167,7 @@ fn an_unknown_schedule_convention_is_newer_not_corrupt() {
&dir,
&[("schedule.registered", registered(json!("weekly")))],
)[0];
std::fs::remove_file(dir.join(DB_FILE)).unwrap();
std::fs::remove_file(dir.join(db_file())).unwrap();
for got in [Store::open(&dir).err(), Snapshot::load(&dir).err()] {
match got {
Some(StoreError::NewerJournal { seq: s, need, .. }) => {
@@ -1166,20 +1181,21 @@ fn an_unknown_schedule_convention_is_newer_not_corrupt() {
let accounts2 = schedule_books(&dir);
assert_eq!(accounts2, accounts);
append(&dir, &[("schedule.registered", registered(json!(7)))]);
std::fs::remove_file(dir.join(DB_FILE)).unwrap();
std::fs::remove_file(dir.join(db_file())).unwrap();
assert_corrupt(Store::open(&dir));
}
/// P3 (R3; rules.md B-27): a schedule month reversed within its month is
/// posted again only after journal feature month_reposting is declared
/// posted again only in books that declare journal feature month_reposting
/// (an older version would refuse the second schedule.posted as posted
/// twice), and the books replay to the same ledger. A month reversed after
/// it ended is left by the run, listed with why (it would count twice until
/// the reversal), and declares nothing. Hand check: equal months of 100.00
/// twice): books an older version made wait for the format upgrade, and
/// the books replay to the same ledger. A month reversed after it ended is
/// left by the run, listed with why (it would count twice until the
/// reversal), and journals nothing. Hand check: equal months of 100.00
/// from 2026-03-01; March posted, reversed on Mar 31, run again: one
/// March, 100.00; April posted, reversed on May 2: April held.
#[test]
fn a_schedule_month_posted_again_declares_month_reposting_and_replays() {
fn a_schedule_month_posted_again_waits_for_month_reposting_and_replays() {
let dir = test_dir("schedule-repost");
let accounts = schedule_books(&dir);
let mut store = Store::open(&dir).unwrap();
@@ -1194,12 +1210,22 @@ fn a_schedule_month_posted_again_declares_month_reposting_and_replays() {
store
.reverse_entry(mar, day(2026, 3, 31), "posted early")
.unwrap();
// Books an older version made (books(): format 1) wait for the
// upgrade before March posts again; the owner's go-ahead declares it.
assert!(store.journal_format().features.is_empty());
let head = store.head();
assert!(matches!(
store.run_schedules(day(2026, 3, 31)),
Err(StoreError::FormatUpgradePending { need }) if need == "the journal feature month_reposting"
));
assert_eq!(store.head(), head, "refused whole");
store.upgrade_format().unwrap();
let posted = store.run_schedules(day(2026, 3, 31)).unwrap();
assert_eq!(posted.len(), 1);
assert_eq!(
store.journal_format().features,
vec![ledger_store::format::MONTH_REPOSTING.to_string()]
assert!(
store
.journal_format()
.has_feature(ledger_store::format::MONTH_REPOSTING)
);
let apr = store.post_schedule_month(id, day(2026, 4, 30)).unwrap();
store
@@ -1228,18 +1254,19 @@ fn a_schedule_month_posted_again_declares_month_reposting_and_replays() {
);
}
/// P3 review round 2 (the format-upgrade gate refuses a feature until the
/// owner consents): every command that needs a journal feature decides it,
/// and is refused, before its first journaled line, so a refused feature
/// journals nothing: never a depreciation entry without the
/// P3 review round 2, and the format-upgrade gate (D-39): in books an
/// older version wrote, every command that needs a journal feature is
/// refused (FormatUpgradePending) before its first journaled line, so the
/// refusal journals nothing: never a depreciation entry without the
/// `asset.depreciation_posted` that makes it the period's run, nor a
/// disposal's final depreciation without its line. Each command: a period
/// run again after its run was reversed (month_reposting), a disposal whose
/// month's run was reversed (its final depreciation, month_reposting), a
/// daily schedule (schedule_convention), and a schedule month posted again
/// (month_reposting). Taken again, each journals and the books verify.
/// (month_reposting). After the owner's upgrade each journals, and the
/// books verify.
#[test]
fn a_refused_journal_feature_journals_nothing() {
fn a_write_waiting_for_the_upgrade_journals_nothing() {
let dir = test_dir("feature-refused");
let accounts = schedule_books(&dir);
let mut store = Store::open(&dir).unwrap();
@@ -1292,12 +1319,15 @@ fn a_refused_journal_feature_journals_nothing() {
store
.reverse_entry(jan, day(2026, 1, 31), "doubted")
.unwrap();
// Books as an older version made them (format 1, books()): nothing
// declared, and everything above needs nothing new.
assert!(store.journal_format().features.is_empty());
store.refuse_new_features(true);
let head = store.head();
let refused = |r: Result<(), StoreError>, what: &str| match r {
Err(StoreError::FormatRefused(why)) => assert!(why.contains("feature"), "{what}: {why}"),
Err(StoreError::FormatUpgradePending { need }) => {
assert!(need.starts_with("the journal feature "), "{what}: {need}")
}
other => panic!("{what}: {other:?}"),
};
refused(
@@ -1333,7 +1363,7 @@ fn a_refused_journal_feature_journals_nothing() {
});
store.verify().unwrap();
store.refuse_new_features(false);
assert!(store.upgrade_format().unwrap().is_some());
assert!(
store
.run_depreciation(asset, "2026-01")
@@ -1347,9 +1377,6 @@ fn a_refused_journal_feature_journals_nothing() {
store
.dispose_asset(sold, day(2026, 3, 15), usd(900_000), cash, gain)
.unwrap();
assert_eq!(
store.journal_format().features,
vec![ledger_store::format::MONTH_REPOSTING.to_string()]
);
assert_eq!(*store.journal_format(), ledger_store::format::current());
store.verify().unwrap();
}
+390
View File
@@ -0,0 +1,390 @@
//! The format upgrade (plan/served.md 3.9, D-39; rules.md B-27): books an
//! older wanderbooks wrote open on their old journal format, refuse only
//! what needs the new one, and move to this version's format in one
//! journaled step; books this version makes are in its format from their
//! first lines.
//!
//! ```text
//! GOAL: no book is moved past what its owner's older version reads
//! without the owner's go-ahead, and an upgrade changes nothing of the
//! books but what may be written from then on.
//! SOURCE: plan/served.md 3.9 ("Format changes are the server's gate, not
//! the updater's"); D-39; rules.md B-27.
//! INVARIANTS:
//! 1. New books declare this version's format, every feature it knows,
//! as they are created: they wait for no upgrade.
//! 2. Older books open, in the writer and the viewer, without a byte of
//! their journal changing, and say what upgrade they wait for.
//! 3. Before the upgrade a write that needs the new format is refused,
//! journaling nothing; a write that needs nothing new works.
//! 4. The upgrade is one journal line declaring the whole format; asked
//! again, it journals nothing.
//! 5. Every book an earlier version wrote (tests/fixtures/old-books)
//! upgrades, verifies as before and replays to exactly the figures
//! that version computed, in the writer and the viewer.
//! 6. Books declaring a feature this version doesn't know are still
//! refused as newer: the upgrade never weakens the gate.
//! ```
use chrono::NaiveDate;
use ledger_core::settings::{Change, Source};
use ledger_core::{AccountType, Currency, Money, Side};
use ledger_store::format::{FORMAT_SET, current};
use ledger_store::{JOURNAL_FILE, JournalFormat, JournalWriter, Snapshot, Store, StoreError};
use rust_decimal::Decimal;
use serde_json::json;
use std::path::{Path, PathBuf};
/// 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-format-upgrade-{name}-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
TestDir(dir)
}
fn journal(dir: &Path) -> Vec<u8> {
std::fs::read(dir.join(JOURNAL_FILE)).unwrap()
}
fn lines(dir: &Path) -> Vec<serde_json::Value> {
String::from_utf8(journal(dir))
.unwrap()
.lines()
.map(|l| serde_json::from_str(l).unwrap())
.collect()
}
/// The payload this version's format is declared with.
fn this_versions_declaration() -> serde_json::Value {
let c = current();
json!({"format": c.format, "features": c.features})
}
fn usd(n: i64) -> Money {
Money::new(Decimal::from(n), Currency::USD)
}
/// Books as an older version made them (journal format 1), with a chart
/// and one posted entry.
fn old_books(dir: &Path) -> Store {
let mut s = Store::create_in_format(dir, Currency::USD, &JournalFormat::default()).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();
s
}
fn a_setting() -> (Vec<Change>, Source) {
(
vec![Change {
key: "company.name".into(),
value: Some(json!("Fernhill Labs LLC")),
}],
Source {
decided_by: "Avery Quill".into(),
decided_on: NaiveDate::from_ymd_opt(2026, 10, 20).unwrap(),
citation: None,
note: None,
},
)
}
/// INVARIANT 1: new books are in this version's format from their second
/// line on, and wait for no upgrade, in the writer and the viewer.
#[test]
fn new_books_are_in_this_versions_format_from_their_creation() {
let dir = test_dir("new");
let s = Store::create(&dir, Currency::USD).unwrap();
assert_eq!(s.journal_format(), &current());
assert_eq!(s.journal_format().upgrade(), None);
drop(s);
let l = lines(&dir);
assert_eq!(l.len(), 2, "store.created, then the format");
assert_eq!(l[0]["type"], "store.created");
assert_eq!(l[1]["type"], FORMAT_SET);
assert_eq!(l[1]["payload"], this_versions_declaration());
let s = Store::open(&dir).unwrap();
assert_eq!(s.journal_format().upgrade(), None);
s.verify().unwrap();
let snap = Snapshot::load(&dir).unwrap();
assert_eq!(snap.journal_format(), &current());
}
/// INVARIANTS 2 to 4: older books open without a byte changing and say
/// what they wait for; a change that needs the new format is refused,
/// journaling nothing, while one that needs nothing new is made; the
/// upgrade is one line, the whole format at once, and asked again does
/// nothing; then the change is made.
#[test]
fn a_change_needing_the_new_format_waits_for_the_upgrade() {
let dir = test_dir("waits");
drop(old_books(&dir));
let before = journal(&dir);
let mut s = Store::open(&dir).unwrap();
assert_eq!(journal(&dir), before, "opening wrote nothing");
let waiting = s.journal_format().upgrade().expect("they wait");
assert_eq!(waiting.from, JournalFormat::default());
assert_eq!(waiting.to, current());
let snap = Snapshot::load(&dir).unwrap();
assert_eq!(snap.journal_format().upgrade(), Some(waiting.clone()));
// Refused, saying why, with nothing journaled or applied.
let (changes, source) = a_setting();
match s.change_settings(&changes, &source) {
Err(e @ StoreError::FormatUpgradePending { .. }) => {
assert!(e.to_string().contains("format_upgrade_pending"), "{e}");
}
other => panic!("expected FormatUpgradePending, got {other:?}"),
}
assert_eq!(journal(&dir), before);
assert!(!s.ledger().settings().is_journaled());
for refused in [
s.set_convention(&ledger_core::conventions::NewConvention {
id: "close-ask".into(),
kind: ledger_core::conventions::ConventionKind::CloseStep,
title: "Ask about cards".into(),
body_md: "Ask.".into(),
structured: None,
source: source.clone(),
})
.map(drop),
s.raise_open_item(&ledger_core::conventions::NewOpenItem {
title: "Find the lease".into(),
detail_md: None,
due: None,
source: source.clone(),
})
.map(drop),
] {
assert!(
matches!(refused, Err(StoreError::FormatUpgradePending { .. })),
"{refused:?}"
);
}
assert_eq!(journal(&dir), before);
// What needs nothing new is made as ever.
let supplies = s
.add_account("6000", "Supplies", AccountType::Expense, None, false)
.unwrap();
let cash = s.ledger().chart().find_by_code("1000").unwrap().id;
let e = s
.create_entry(NaiveDate::from_ymd_opt(2026, 3, 2).unwrap(), "Pens")
.unwrap();
s.add_line(e, supplies, Side::Debit, usd(12)).unwrap();
s.add_line(e, cash, Side::Credit, usd(12)).unwrap();
s.post_entry(e).unwrap();
assert_eq!(s.journal_format(), &JournalFormat::default());
let figures = ledger_core::reports::snapshot(s.ledger()).unwrap();
// The upgrade: one line, the whole format.
let mid = journal(&dir);
let n = lines(&dir).len();
let up = s.upgrade_format().unwrap().expect("upgraded");
assert_eq!(up, waiting);
assert_eq!(s.journal_format(), &current());
let after = lines(&dir);
assert_eq!(after.len(), n + 1, "one line");
assert_eq!(after[n]["type"], FORMAT_SET);
assert_eq!(after[n]["payload"], this_versions_declaration());
assert!(journal(&dir).starts_with(&mid), "nothing before it changed");
assert_eq!(
ledger_core::reports::snapshot(s.ledger()).unwrap(),
figures,
"the books read as before"
);
// Again: nothing.
let upgraded = journal(&dir);
assert_eq!(s.upgrade_format().unwrap(), None);
assert_eq!(journal(&dir), upgraded);
// Now the change is made.
s.change_settings(&changes, &source).unwrap();
assert!(s.ledger().settings().is_journaled());
s.verify().unwrap();
drop(s);
let s = Store::open(&dir).unwrap();
assert_eq!(s.journal_format(), &current());
assert_eq!(s.journal_format().upgrade(), None);
s.verify().unwrap();
let snap = Snapshot::load(&dir).unwrap();
assert_eq!(snap.journal_format(), &current());
assert_eq!(
lines(&dir)
.iter()
.filter(|l| l["type"] == FORMAT_SET)
.count(),
1,
"the format moved exactly once"
);
}
/// The books an earlier wanderbooks wrote (tests/fixtures/old-books; see
/// tests/store.rs `old_books`).
fn old_book_fixtures() -> Vec<PathBuf> {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/old-books");
let mut dirs: Vec<PathBuf> = std::fs::read_dir(&root)
.unwrap()
.map(|e| e.unwrap().path())
.filter(|p| p.is_dir())
.collect();
dirs.sort();
assert!(dirs.len() >= 29, "{} old books", dirs.len());
dirs
}
/// A fixture's journal and stored files in a fresh dir of their own.
fn fixture_copy(fixture: &Path) -> TestDir {
fn copy(from: &Path, to: &Path) {
std::fs::create_dir_all(to).unwrap();
for e in std::fs::read_dir(from).unwrap() {
let e = e.unwrap();
if e.file_name() == "expected.json" || e.file_name() == "record.json" {
continue;
}
if e.file_type().unwrap().is_dir() {
copy(&e.path(), &to.join(e.file_name()));
} else {
std::fs::copy(e.path(), to.join(e.file_name())).unwrap();
}
}
}
let name = fixture.file_name().unwrap().to_string_lossy();
let dir = test_dir(&format!("fixture-{name}"));
copy(fixture, &dir);
dir
}
/// What expected.json records of a ledger (tests/store.rs
/// `old_books_figures`, field for field).
fn figures(l: &ledger_core::Ledger) -> serde_json::Value {
let entries: Vec<_> = (1..l.next_entry_id())
.filter_map(|id| l.get_entry(id))
.collect();
let parties: Vec<_> = (1..l.next_entry_id())
.map(|id| (id, l.entry_parties(id)))
.filter(|(_, p)| !p.is_empty())
.collect();
json!({
"snapshot": ledger_core::reports::snapshot(l).map_err(|e| e.to_string()),
"entries": entries,
"assets": l.registered_assets().collect::<Vec<_>>(),
"periods": l.list_periods(),
"schedules": l.schedules().collect::<Vec<_>>(),
"recurring": l.recurring_entries().collect::<Vec<_>>(),
"invoices": l.invoices(),
"bills": l.bills(),
"payroll_runs": l.list_payroll_runs(),
"entry_parties": parties,
})
}
/// INVARIANTS 2, 4 and 5: every book an earlier version wrote opens
/// without a byte changing, upgrades in one line (or, already in this
/// version's format, waits for nothing), verifies as it did, and replays
/// to exactly the figures that version computed: in the writer, again
/// after reopening, and in the viewer.
#[test]
fn every_book_an_earlier_version_wrote_upgrades_and_still_verifies() {
let mut upgraded = 0;
for fixture in old_book_fixtures() {
let name = fixture.file_name().unwrap().to_string_lossy().to_string();
let expected: serde_json::Value =
serde_json::from_slice(&std::fs::read(fixture.join("expected.json")).unwrap()).unwrap();
let dir = fixture_copy(&fixture);
let before = journal(&dir);
let mut s = Store::open(&dir).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(journal(&dir), before, "{name}: opening wrote");
let waiting = s.journal_format().upgrade();
let declared = before
.split(|b| *b == b'\n')
.filter(|l| !l.is_empty())
.any(|l| serde_json::from_slice::<serde_json::Value>(l).unwrap()["type"] == FORMAT_SET);
if !declared {
assert!(waiting.is_some(), "{name}: format 1 books wait");
}
let up = s.upgrade_format().unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(up, waiting, "{name}");
let after = journal(&dir);
assert!(after.starts_with(&before), "{name}: the old lines changed");
let added = after[before.len()..]
.split(|b| *b == b'\n')
.filter(|l| !l.is_empty())
.count();
assert_eq!(added, usize::from(up.is_some()), "{name}: one line");
if up.is_some() {
upgraded += 1;
}
assert_eq!(s.journal_format(), &current(), "{name}");
let verify = s.verify().map(|_| ()).map_err(|e| e.to_string());
assert_eq!(json!(verify), expected["verify"], "{name}: verify");
let mut got = figures(s.ledger());
got["verify"] = expected["verify"].clone();
assert_eq!(got, expected, "{name}: the writer's figures");
drop(s);
let s = Store::open(&dir).unwrap_or_else(|e| panic!("{name}: reopen: {e}"));
assert_eq!(s.journal_format().upgrade(), None, "{name}");
let mut got = figures(s.ledger());
got["verify"] = expected["verify"].clone();
assert_eq!(got, expected, "{name}: reopened");
drop(s);
let snap = Snapshot::load(&dir).unwrap_or_else(|e| panic!("{name}: viewer: {e}"));
assert_eq!(snap.journal_format(), &current(), "{name}");
let mut got = figures(snap.ledger());
got["verify"] = expected["verify"].clone();
assert_eq!(got, expected, "{name}: the viewer's figures");
}
assert!(upgraded >= 28, "only {upgraded} fixtures were format 1");
}
/// INVARIANT 6: books that declare a feature this version doesn't know
/// (a newer version wrote them) are refused as newer, in the writer and
/// the viewer, and never touched: there is nothing to upgrade.
#[test]
fn books_declaring_a_feature_this_version_doesnt_know_stay_refused_as_newer() {
let dir = test_dir("newer");
drop(old_books(&dir));
{
let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap();
w.append(
FORMAT_SET,
json!({"format": 2, "features": ["from_a_newer_version"]}),
)
.unwrap();
w.append("account.added", json!({})).unwrap();
}
let before = journal(&dir);
for refused in [Store::open(&dir).map(drop), Snapshot::load(&dir).map(drop)] {
match refused {
Err(StoreError::NewerJournal { need, .. }) => {
assert_eq!(need, "journal features from_a_newer_version")
}
other => panic!("expected NewerJournal, got {other:?}"),
}
}
assert_eq!(journal(&dir), before);
}
+87 -18
View File
@@ -10,7 +10,7 @@ use ledger_core::{
members_equity,
};
use ledger_store::journal::read_and_verify_journal;
use ledger_store::{JOURNAL_FILE, JournalWriter, Snapshot, Store, StoreError};
use ledger_store::{JOURNAL_FILE, JournalFormat, JournalWriter, Snapshot, Store, StoreError};
use rust_decimal::Decimal;
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
@@ -82,6 +82,25 @@ fn books(dir: &Path) -> Books {
}
}
/// [`books`] as a version before the format gate made them: format 1,
/// nothing declared (D-39).
fn old_books(dir: &Path) -> Books {
let mut store = Store::create_in_format(dir, Currency::USD, &JournalFormat::default()).unwrap();
let mut add = |code: &str, t| store.add_account(code, code, t, None, false).unwrap();
let cash = add("1000", AccountType::Asset);
let caps = [
add("3000", AccountType::Equity),
add("3001", AccountType::Equity),
];
let sales = add("4000", AccountType::Revenue);
Books {
store,
cash,
caps,
sales,
}
}
/// [`books`] opened again.
fn reopen(dir: &Path) -> Books {
let store = Store::open(dir).unwrap();
@@ -147,11 +166,14 @@ fn three_cumulative_closes(dir: &Path) -> Books {
b
}
/// Contract (rules.md B-27; P3 defect 17): the first members close counted
/// from its regime's start declares the `members_cumulative` feature, on
/// the line just before it and once only, so an older version stops at the
/// declaration (it would replay the close under the run rule). Books that
/// never close to the members never declare it.
/// Contract (rules.md B-27; P3 defect 17; D-39): a members close counted
/// from its regime's start needs the `members_cumulative` feature, so an
/// older version stops at its declaration (it would replay the close under
/// the run rule). New books declare it on their second line, with the rest
/// of this version's format, and never again; books an older version made
/// (format 1) refuse a members close, journaling nothing, until the
/// owner's format upgrade declares it, and close to retained earnings as
/// ever.
#[test]
fn the_first_cumulative_close_declares_its_feature() {
let dir = test_dir("declares");
@@ -169,12 +191,13 @@ fn the_first_cumulative_close_declares_its_feature() {
.filter(|(_, (t, p))| t == "journal.format_set" && declares(p))
.map(|(i, _)| i)
.collect();
let first_close = lines
.iter()
.position(|(t, p)| t == "period.closed" && p["close_to"]["rounding"] == "cumulative_since")
.unwrap();
assert_eq!(declarations, [first_close - 1]);
assert_eq!(lines[first_close - 1].1["format"], 2);
// Line 2: this version's whole format, as the books were created.
assert_eq!(declarations, [1]);
assert_eq!(lines[1].1["format"], 2);
assert_eq!(
lines[1].1["features"],
json!(ledger_store::format::current().features)
);
assert_eq!(
lines
.iter()
@@ -193,17 +216,47 @@ fn the_first_cumulative_close_declares_its_feature() {
.count();
assert_eq!(again, 1);
// Books that close only to retained earnings never declare it.
let dir = test_dir("declares-not");
let mut b = books(&dir);
// Books an older version made: a members close waits for the upgrade,
// a close to retained earnings doesn't.
let dir = test_dir("declares-old");
let mut b = old_books(&dir);
earn_and_close(
&mut b,
1,
"0.01",
&CloseTo::RetainedEarnings { sweep: vec![] },
);
drop(b);
let e = b.store.create_entry(d(2, 10), "sale").unwrap();
let money = Money::new(dec("0.01"), Currency::USD);
b.store.add_line(e, b.cash, Side::Debit, money).unwrap();
b.store.add_line(e, b.sales, Side::Credit, money).unwrap();
b.store.post_entry(e).unwrap();
let p = b
.store
.create_period("2026-02", d(2, 1), month_end(2))
.unwrap();
let head = b.store.head();
let to = CloseTo::Members(half(&b.caps));
match b.store.close_period_to(p, &to) {
Err(StoreError::FormatUpgradePending { need }) => {
assert_eq!(need, "the journal feature members_cumulative")
}
other => panic!("expected FormatUpgradePending, got {other:?}"),
}
assert_eq!(b.store.head(), head, "refused whole");
assert!(!journal(&dir).iter().any(|(_, p)| declares(p)));
// The owner's go-ahead: the whole format at once, then the close.
assert!(b.store.upgrade_format().unwrap().is_some());
b.store.close_period_to(p, &to).unwrap().unwrap();
drop(b);
let lines = journal(&dir);
let n = lines.len();
assert_eq!(lines[n - 1].0, "period.closed");
assert_eq!(lines[n - 2].0, "journal.format_set");
assert_eq!(
lines[n - 2].1["features"],
json!(ledger_store::format::current().features)
);
}
/// A change to a journaled close's target.
@@ -457,6 +510,18 @@ fn the_first_close_after_the_upgrade_trues_up_the_old_rules() {
distributions: None,
},
];
// Books an earlier version wrote: the close counted from its regime's
// start waits for the owner's go-ahead (D-39), journaling nothing; the
// upgrade declares this version's whole format.
let head = store.head();
match store.close_period_to(dec_period, &CloseTo::Members(members.clone())) {
Err(StoreError::FormatUpgradePending { need }) => {
assert_eq!(need, "the journal feature members_cumulative")
}
other => panic!("expected FormatUpgradePending, got {other:?}"),
}
assert_eq!(store.head(), head, "refused whole");
assert!(store.upgrade_format().unwrap().is_some());
let closing = store
.close_period_to(dec_period, &CloseTo::Members(members))
.unwrap()
@@ -504,14 +569,18 @@ fn the_first_close_after_the_upgrade_trues_up_the_old_rules() {
capital_on(store.ledger(), d(12, 31)),
[dec("99.08"), dec("100.07")]
);
// One declaration, just before the close.
// One declaration, the upgrade's, just before the close: this
// version's whole format.
drop(store);
let lines = journal(&dir);
let n = lines.len();
assert_eq!(lines[n - 1].0, "period.closed");
assert_eq!(lines[n - 1].1["close_to"]["since_seq"], 1);
assert_eq!(lines[n - 2].0, "journal.format_set");
assert_eq!(lines[n - 2].1["features"], json!(["members_cumulative"]));
assert_eq!(
lines[n - 2].1["features"],
json!(ledger_store::format::current().features)
);
// Reopened, and in the viewer: the same.
let store = Store::open(&dir).unwrap();
store.verify().unwrap();
+2 -2
View File
@@ -16,7 +16,7 @@ use ledger_core::{
PayrollLine, PayrollRun, Role, Side, W4Facts, W9Facts,
};
use ledger_store::journal::read_and_verify_journal;
use ledger_store::{DB_FILE, JOURNAL_FILE, JournalWriter, Store};
use ledger_store::{JOURNAL_FILE, JournalWriter, Store, db_file};
use rust_decimal::Decimal;
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
@@ -192,7 +192,7 @@ fn view(l: &Ledger) -> View {
fn delete_db(dir: &Path) {
for suffix in ["", "-wal", "-shm", "-journal"] {
let _ = std::fs::remove_file(dir.join(format!("{DB_FILE}{suffix}")));
let _ = std::fs::remove_file(dir.join(format!("{}{suffix}", db_file())));
}
}
+99 -48
View File
@@ -1,18 +1,22 @@
//! A party named the creditor of a liability (rules.md B-35, P3 defect
//! 24), in the store: journaled as `party.creditor_set {id, account_id,
//! on}`, after the journal declares the feature it needs
//! (`party_creditors`) the first time, and never otherwise.
//! on}`, which needs the journal feature `party_creditors`: books this
//! version made declared it when they were created; books an older
//! version wrote refuse it until their format upgrade (rules.md B-27).
//!
//! Oracles: `Store::set_party_creditor`'s doc, `format::KNOWN_FEATURES`
//! and `PARTY_CREDITORS`, `Store::ensure_feature`'s doc (nothing journaled
//! when the feature is in force; books that never use it never change),
//! and `Ledger::set_party_creditor`'s SCENARIOS. Expected values by hand.
//! and `PARTY_CREDITORS`, `Store::ensure_feature`'s doc (refused,
//! journaling nothing, unless the books declared it; books that never use
//! it never change), and `Ledger::set_party_creditor`'s SCENARIOS.
//! Expected values by hand.
use chrono::NaiveDate;
use ledger_core::{AccountType, Currency, Money, Side};
use ledger_store::format::{FORMAT_SET, KNOWN_FEATURES, PARTY_CREDITORS};
use ledger_store::format::{FORMAT_SET, KNOWN_FEATURES, PARTY_CREDITORS, current};
use ledger_store::journal::read_and_verify_journal;
use ledger_store::{DB_FILE, JOURNAL_FILE, JournalWriter, Snapshot, Store};
use ledger_store::{
JOURNAL_FILE, JournalFormat, JournalWriter, Snapshot, Store, StoreError, db_file,
};
use rust_decimal::Decimal;
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
@@ -55,7 +59,13 @@ fn bytes(dir: &Path) -> Vec<u8> {
/// Books with a card, the members' financing account, a member, and a
/// posted entry.
fn books(dir: &Path) -> (Store, u64, u64, u64, u64) {
let mut s = Store::create(dir, Currency::USD).unwrap();
books_in(dir, &current())
}
/// [`books`] in the journal format `format`: format 1
/// ([`JournalFormat::default`]) is books an older version wrote.
fn books_in(dir: &Path, format: &JournalFormat) -> (Store, u64, u64, u64, u64) {
let mut s = Store::create_in_format(dir, Currency::USD, format).unwrap();
let cash = s
.add_account("1000", "Checking", AccountType::Asset, None, false)
.unwrap();
@@ -103,40 +113,48 @@ fn party_creditors_is_a_known_feature() {
assert!(KNOWN_FEATURES.contains(&PARTY_CREDITORS));
}
/// Store::set_party_creditor doc: the first one declares the feature (one
/// journal.format_set keeping the format, at least 2, and every feature
/// already declared), then journals party.creditor_set; later ones journal
/// one line each; the books replay to the same directory, through the
/// store and the viewer's snapshot alike.
/// Store::set_party_creditor doc: books this version made declared the
/// feature when they were created (their second line, the only
/// declaration), so each creditor named journals one line,
/// party.creditor_set, and nothing else; the books replay to the same
/// directory, through the store and the viewer's snapshot alike.
#[test]
fn naming_a_creditor_declares_its_feature_once() {
fn naming_a_creditor_journals_one_line_each_time() {
let dir = test_dir("declare");
let (mut s, card, due, issuer, tobin) = books(&dir);
let before = journal(&dir).len();
let j = journal(&dir);
assert_eq!(j[1].0, FORMAT_SET);
assert!(
j[1].1["features"]
.as_array()
.unwrap()
.contains(&json!("party_creditors"))
);
let before = j.len();
s.set_party_creditor(issuer, card, true).unwrap();
let j = journal(&dir);
assert_eq!(j.len(), before + 2);
assert_eq!(j[before].0, FORMAT_SET);
assert_eq!(j.len(), before + 1);
assert_eq!(
j[before].1,
json!({"format": 2, "features": ["party_creditors"]})
);
assert_eq!(
j[before + 1],
j[before],
(
"party.creditor_set".to_string(),
json!({"id": issuer, "account_id": card, "on": true})
)
);
assert_eq!(s.journal_format().features, vec!["party_creditors"]);
s.set_party_creditor(tobin, due, true).unwrap();
s.set_party_creditor(tobin, due, false).unwrap();
let j = journal(&dir);
assert_eq!(j.len(), before + 4, "the feature is declared once");
assert_eq!(j.len(), before + 3, "one line each");
assert_eq!(
j[before + 3].1,
j[before + 2].1,
json!({"id": tobin, "account_id": due, "on": false})
);
assert_eq!(
j.iter().filter(|(t, _)| t == FORMAT_SET).count(),
1,
"nothing declared on the way"
);
assert_eq!(s.journal_format(), &current());
s.set_party_creditor(tobin, due, true).unwrap();
let live = s.ledger().clone();
s.verify().unwrap();
@@ -148,15 +166,40 @@ fn naming_a_creditor_declares_its_feature_once() {
drop(s);
let snap = Snapshot::load(&dir).unwrap();
assert!(*snap.ledger() == live);
assert_eq!(snap.journal_format().features, vec!["party_creditors"]);
assert_eq!(snap.journal_format(), &current());
// Rebuilt from the journal alone.
for suffix in ["", "-wal", "-shm"] {
let _ = std::fs::remove_file(dir.join(format!("{DB_FILE}{suffix}")));
let _ = std::fs::remove_file(dir.join(format!("{}{suffix}", db_file())));
}
let s = Store::open(&dir).unwrap();
assert!(*s.ledger() == live);
}
/// Books an older version wrote (format 1, declaring nothing) refuse a
/// creditor while they wait for their format upgrade, journaling nothing,
/// not the line and not the feature; once the owner upgrades them, the
/// same command journals its one line.
#[test]
fn a_creditor_in_older_books_waits_for_the_format_upgrade() {
let dir = test_dir("older");
let (mut s, card, _, issuer, _) = books_in(&dir, &JournalFormat::default());
let was = bytes(&dir);
assert!(matches!(
s.set_party_creditor(issuer, card, true),
Err(StoreError::FormatUpgradePending { need }) if need.contains("party_creditors")
));
assert_eq!(bytes(&dir), was, "nothing journaled");
assert!(s.ledger().creditors_of(card).is_empty());
assert!(s.upgrade_format().unwrap().is_some());
let before = journal(&dir).len();
s.set_party_creditor(issuer, card, true).unwrap();
let j = journal(&dir);
assert_eq!(j.len(), before + 1);
assert_eq!(j[before].0, "party.creditor_set");
s.verify().unwrap();
assert_eq!(s.ledger().creditors_of(card), vec![issuer]);
}
/// A refusal journals nothing: not the line, and not the feature either.
#[test]
fn a_refused_creditor_journals_nothing() {
@@ -164,34 +207,42 @@ fn a_refused_creditor_journals_nothing() {
let (mut s, _, _, issuer, _) = books(&dir);
let cash = s.ledger().chart().find_by_code("1000").unwrap().id;
let was = bytes(&dir);
let format = s.journal_format().clone();
for (party, account) in [(issuer, cash), (999, cash), (issuer, 999)] {
assert!(s.set_party_creditor(party, account, true).is_err());
assert_eq!(bytes(&dir), was);
}
assert!(s.journal_format().features.is_empty());
assert_eq!(s.journal_format(), &format);
}
/// Books that never name a creditor never declare the feature: opening,
/// verifying and rebuilding them leaves the journal byte-identical, in the
/// format it was in.
/// Books that never name a creditor are never changed by the feature:
/// opening, verifying and rebuilding them leaves the journal
/// byte-identical, in the format it was in, this version's or an older
/// one's (which declares nothing and stays so).
#[test]
fn books_without_creditors_are_never_changed_by_the_feature() {
let dir = test_dir("untouched");
let (s, ..) = books(&dir);
let format = s.journal_format().clone();
drop(s);
let was = bytes(&dir);
let mut s = Store::open(&dir).unwrap();
s.verify().unwrap();
s.rebuild().unwrap();
assert_eq!(s.journal_format(), &format);
drop(s);
assert_eq!(bytes(&dir), was);
assert!(
journal(&dir)
.iter()
.all(|(t, p)| t != FORMAT_SET || p["features"] == json!([]))
);
for (name, format) in [
("untouched", current()),
("untouched-old", JournalFormat::default()),
] {
let dir = test_dir(name);
let (s, ..) = books_in(&dir, &format);
assert_eq!(s.journal_format(), &format);
drop(s);
let was = bytes(&dir);
let mut s = Store::open(&dir).unwrap();
s.verify().unwrap();
s.rebuild().unwrap();
assert_eq!(s.journal_format(), &format, "{name}");
drop(s);
assert_eq!(bytes(&dir), was, "{name}");
let j = journal(&dir);
assert!(j.iter().all(|(t, _)| t != "party.creditor_set"), "{name}");
assert!(
j.iter().filter(|(t, _)| t == FORMAT_SET).count() <= 1,
"{name}: nothing declared but at creation"
);
}
}
/// A line that can't replay is refused on open, with the database and
@@ -224,7 +275,7 @@ fn corrupt_creditor_lines_are_refused_on_open() {
drop(w);
assert!(Store::open(&dir).is_err(), "{name}: {payload} opened");
for suffix in ["", "-wal", "-shm"] {
let _ = std::fs::remove_file(dir.join(format!("{DB_FILE}{suffix}")));
let _ = std::fs::remove_file(dir.join(format!("{}{suffix}", db_file())));
}
assert!(
Store::open(&dir).is_err(),
+2 -2
View File
@@ -8,7 +8,7 @@
use chrono::NaiveDate;
use ledger_core::{Currency, Role, TinFacts, TinType, W9Facts};
use ledger_store::journal::read_and_verify_journal;
use ledger_store::{DB_FILE, JOURNAL_FILE, JournalWriter, Store};
use ledger_store::{JOURNAL_FILE, JournalWriter, Store, db_file};
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
@@ -72,7 +72,7 @@ fn w9(last4: &str) -> W9Facts {
fn delete_db(dir: &Path) {
for suffix in ["", "-wal", "-shm", "-journal"] {
let _ = std::fs::remove_file(dir.join(format!("{DB_FILE}{suffix}")));
let _ = std::fs::remove_file(dir.join(format!("{}{suffix}", db_file())));
}
}
+33 -4
View File
@@ -45,7 +45,14 @@ fn set(key: &str, value: serde_json::Value) -> Change {
/// Books with accounts, a posted entry with its receipt, a filed document,
/// and (with `settings`) settings of their own: what adoption must carry.
fn source_books(dir: &Path, settings: bool) -> Store {
let mut s = Store::create(dir, Currency::USD).unwrap();
// As an earlier version wrote them: journal format 1, or 2 once they
// keep their own settings (this version's own books declare their
// format as they are made).
let format = ledger_store::JournalFormat {
format: if settings { 2 } else { 1 },
features: Vec::new(),
};
let mut s = Store::create_in_format(dir, Currency::USD, &format).unwrap();
if settings {
s.adopt_settings(&[set("company.name", json!("Adopted LLC"))], &src("Owner"))
.unwrap();
@@ -299,6 +306,9 @@ fn adopted_books_are_the_same_books_and_the_source_is_untouched() {
let source = source_books(&data, true);
let source_head = source.head();
let source_ledger = source.ledger().clone();
// Format 2 with no feature, as P2 wrote them: this version knows
// features they haven't declared, so they wait for the upgrade.
assert!(source.journal_format().upgrade().is_some());
drop(source);
// A vault and a key beside them, as the old layout kept them.
let vault = old.path().join("vault");
@@ -322,9 +332,14 @@ fn adopted_books_are_the_same_books_and_the_source_is_untouched() {
)
.unwrap();
assert_eq!(report.source_head, source_head);
// Books with settings gain no event but the copy's move to this
// version's format (new books of this version; the source keeps its
// own): one journal.format_set.
assert!(report.format_upgraded);
assert_eq!(
report.head, source_head,
"books with settings gain no event"
report.head.0,
source_head.0 + 1,
"books with settings gain only the format"
);
assert_eq!(report.blob_files, 2);
assert_eq!(report.blobs_verified, 2);
@@ -405,6 +420,9 @@ fn books_without_settings_need_them_and_get_them_on_the_way_in() {
.unwrap();
assert_eq!(report.source_head, head);
assert_eq!(report.settings_imported, 1);
// The copy is new books of this version: in its journal format (the
// source, format 1, keeps its own).
assert!(report.format_upgraded);
// What was added: the journal format the settings need, declared
// first (older books), then the settings.
let journal = std::fs::read_to_string(root.book_dir(&id("acme")).join(JOURNAL_FILE)).unwrap();
@@ -437,6 +455,7 @@ fn books_without_settings_need_them_and_get_them_on_the_way_in() {
assert_eq!(added.last().map(String::as_str), Some("settings.set"));
assert_eq!(report.head.0, head.0 + added.len() as u64);
let adopted = Store::open(&root.book_dir(&id("acme"))).unwrap();
assert_eq!(adopted.journal_format(), &ledger_store::format::current());
assert!(adopted.ledger().settings().is_journaled());
assert_eq!(
adopted.ledger().settings().company("USD").name,
@@ -881,6 +900,9 @@ fn settings_given_for_books_that_keep_their_own_are_reported_unused() {
let old = tmp("adopt-ignored-src");
let s = source_books(old.path(), true);
let head = s.head();
// Format 2 already: moved to this version's only if it knows features
// these books haven't declared.
let waits = s.journal_format().upgrade().is_some();
drop(s);
let report = root
.adopt(
@@ -893,7 +915,14 @@ fn settings_given_for_books_that_keep_their_own_are_reported_unused() {
)
.unwrap();
assert!(report.settings_ignored);
assert_eq!((report.head, report.settings_imported), (head, 0));
assert_eq!(report.format_upgraded, waits);
assert_eq!(
(report.head.0, report.settings_imported),
(head.0 + u64::from(waits), 0)
);
if !waits {
assert_eq!(report.head, head);
}
}
#[test]
+371
View File
@@ -0,0 +1,371 @@
//! 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}");
}
}
+37 -12
View File
@@ -110,23 +110,26 @@ fn assert_every_reader_agrees(dir: &std::path::Path, s: &Store) {
assert!(snap.ledger().differs_from(s.ledger()).is_empty());
}
/// The first change declares journal format 2 before it (format gate), and
/// only once; a change to what is already set journals nothing.
/// Settings need journal format 2 (format gate): books this version made
/// declared it as they were created, so the first change journals only
/// itself; a change to what is already set journals nothing. Books an
/// older version wrote in format 1 refuse the change, journaling nothing,
/// until their format upgrade (the owner's go-ahead, D-39); then it is
/// made, after the one declaration.
#[test]
fn the_first_change_declares_format_2() {
fn a_change_needs_journal_format_2() {
let (dir, mut s) = books("format");
assert_eq!(s.journal_format().format, 1);
assert_eq!(s.journal_format().format, 2);
let before = s.head().0;
s.change_settings(&[set("company.name", json!("Acme LLC"))], &src("A"))
.unwrap();
assert_eq!(s.journal_format().format, 2);
assert_eq!(s.head().0, before + 2, "a declaration, then the change");
assert_eq!(s.head().0, before + 1, "the change alone");
s.change_settings(
&[set("company.timezone", json!("America/Denver"))],
&src("A"),
)
.unwrap();
assert_eq!(s.head().0, before + 3, "declared once");
assert_eq!(s.head().0, before + 2);
let at = s.head().0;
let made = s
.change_settings(&[set("company.name", json!(" Acme LLC "))], &src("A"))
@@ -134,7 +137,29 @@ fn the_first_change_declares_format_2() {
assert!(made.is_empty());
assert_eq!(s.head().0, at, "nothing to change, nothing journaled");
let r = s.ledger().settings().get("company.name").unwrap();
assert_eq!((r.seq, r.source.decided_by.as_str()), (before + 2, "A"));
assert_eq!((r.seq, r.source.decided_by.as_str()), (before + 1, "A"));
// Books in format 1, as an older version wrote them.
let old = test_dir("format-old");
let mut o =
Store::create_in_format(&old, Currency::USD, &ledger_store::JournalFormat::default())
.unwrap();
let at = o.head().0;
for refused in [
o.change_settings(&[set("company.name", json!("Acme LLC"))], &src("A")),
o.adopt_settings(&[], &src("A")),
] {
assert!(
matches!(refused, Err(StoreError::FormatUpgradePending { .. })),
"{refused:?}"
);
}
assert_eq!(o.head().0, at, "refused: nothing journaled");
assert!(!o.ledger().settings().is_journaled());
o.upgrade_format().unwrap().expect("upgraded");
o.change_settings(&[set("company.name", json!("Acme LLC"))], &src("A"))
.unwrap();
assert_eq!(o.head().0, at + 2, "the declaration, then the change");
assert_every_reader_agrees(&old, &o);
drop(s);
let s = Store::open(&dir).unwrap();
assert_eq!(s.journal_format().format, 2);
@@ -1070,19 +1095,19 @@ fn an_empty_import_still_moves_the_books_in() {
let at = s.head().0;
assert!(s.change_settings(&[], &src("A")).unwrap().is_empty());
assert_eq!(s.head().0, at, "an ordinary empty change: nothing");
assert_eq!(s.journal_format().format, 1);
assert_eq!(s.journal_format().format, 2, "declared at creation");
assert!(!s.ledger().settings().is_journaled());
assert!(s.adopt_settings(&[], &src("A")).unwrap().is_empty());
assert_eq!(s.head().0, at + 2, "a declaration, then the move");
assert_eq!(s.head().0, at + 1, "the move alone");
assert!(s.ledger().settings().is_journaled());
assert_eq!(s.ledger().settings().last_seq(), Some(at + 2));
assert_eq!(s.ledger().settings().last_seq(), Some(at + 1));
for again in [
s.adopt_settings(&[], &src("A")).unwrap(),
s.change_settings(&[], &src("A")).unwrap(),
] {
assert!(again.is_empty());
}
assert_eq!(s.head().0, at + 2, "moved in once");
assert_eq!(s.head().0, at + 1, "moved in once");
drop(s);
let s = Store::open(&dir).unwrap();
assert!(s.ledger().settings().is_journaled());
+1 -1
View File
@@ -186,7 +186,7 @@ fn a_snapshot_checks_the_writers_database_and_files() {
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();
let conn = rusqlite::Connection::open(dir.join(ledger_store::db_file())).unwrap();
conn.execute(
"UPDATE accounts SET name = 'Petty cash' WHERE code = '1000'",
[],
+11 -11
View File
@@ -10,7 +10,7 @@
use chrono::NaiveDate;
use ledger_core::{AccountId, AccountType, Currency, EntryId, Money, NewAsset, Side};
use ledger_store::journal::repair_torn_tail;
use ledger_store::{BlobStore, DB_FILE, JOURNAL_FILE, Snapshot, Store, StoreError};
use ledger_store::{BlobStore, JOURNAL_FILE, Snapshot, Store, StoreError, db_file};
use rust_decimal::Decimal;
use serde_json::Value;
use sha2::{Digest, Sha256};
@@ -156,7 +156,7 @@ fn file_names(dir: &Path) -> Vec<String> {
fn remove_db(dir: &Path) {
for suffix in ["", "-wal", "-shm"] {
let _ = std::fs::remove_file(dir.join(format!("{DB_FILE}{suffix}")));
let _ = std::fs::remove_file(dir.join(format!("{}{suffix}", db_file())));
}
}
@@ -764,7 +764,7 @@ fn open_rebuilds_a_missing_database() {
drop(b);
remove_db(&dir);
let s = Store::open(&dir).unwrap();
assert!(dir.join(DB_FILE).exists());
assert!(dir.join(db_file()).exists());
assert_eq!(s.ledger(), &live);
assert_eq!(
s.events_since(0, 1_000_000).unwrap().len(),
@@ -788,7 +788,7 @@ fn open_keeps_a_corrupt_database_aside_and_rebuilds() {
.take(8192)
.copied()
.collect();
std::fs::write(dir.join(DB_FILE), &garbage).unwrap();
std::fs::write(dir.join(db_file()), &garbage).unwrap();
let journal_before = jbytes(&dir);
let s = Store::open(&dir).unwrap();
assert_eq!(s.ledger(), &live);
@@ -796,7 +796,7 @@ fn open_keeps_a_corrupt_database_aside_and_rebuilds() {
// Kept aside: some other file in the directory holds the garbage.
let kept = tree(&dir)
.into_iter()
.any(|(p, bytes)| p != Path::new(DB_FILE) && bytes == garbage);
.any(|(p, bytes)| p != Path::new(&db_file()) && bytes == garbage);
assert!(
kept,
"corrupt db not kept aside; files: {:?}",
@@ -815,8 +815,8 @@ fn open_keeps_a_corrupt_database_aside_and_rebuilds() {
fn open_catches_up_a_database_behind_the_journal() {
let dir = test_dir("open-behind");
drop(books(&dir));
let old_db = std::fs::read(dir.join(DB_FILE)).unwrap();
let old_wal = std::fs::read(dir.join(format!("{DB_FILE}-wal"))).ok();
let old_db = std::fs::read(dir.join(db_file())).unwrap();
let old_wal = std::fs::read(dir.join(format!("{}-wal", db_file()))).ok();
let mut s = Store::open(&dir).unwrap();
s.add_account("6300", "Meals", AccountType::Expense, None, false)
.unwrap();
@@ -826,9 +826,9 @@ fn open_catches_up_a_database_behind_the_journal() {
let live = s.ledger().clone();
drop(s);
remove_db(&dir);
std::fs::write(dir.join(DB_FILE), &old_db).unwrap();
std::fs::write(dir.join(db_file()), &old_db).unwrap();
if let Some(w) = old_wal {
std::fs::write(dir.join(format!("{DB_FILE}-wal")), w).unwrap();
std::fs::write(dir.join(format!("{}-wal", db_file())), w).unwrap();
}
let s = Store::open(&dir).unwrap();
assert_eq!(s.ledger(), &live);
@@ -1395,7 +1395,7 @@ fn verify_detects_projection_divergence() {
.attach_receipt(Some(b.e1), "r.pdf", "application/pdf", b"d", None)
.unwrap();
{
let c = rusqlite::Connection::open(dir.join(DB_FILE)).unwrap();
let c = rusqlite::Connection::open(dir.join(db_file())).unwrap();
let n = c.execute("DELETE FROM attachments", []).unwrap();
assert_eq!(n, 1);
}
@@ -1448,7 +1448,7 @@ fn heal_rebuilds_a_diverged_projection() {
.attach_receipt(Some(b.e1), "r.pdf", "application/pdf", b"h", None)
.unwrap();
{
let c = rusqlite::Connection::open(dir.join(DB_FILE)).unwrap();
let c = rusqlite::Connection::open(dir.join(db_file())).unwrap();
c.execute("DELETE FROM attachments", []).unwrap();
}
let journal = jbytes(&dir);
+44 -44
View File
@@ -304,7 +304,7 @@ fn missing_database_heals_on_open() {
let dir = test_dir("healopen");
let store = build_books(&dir);
drop(store);
std::fs::remove_file(dir.join(ledger_store::DB_FILE)).unwrap();
std::fs::remove_file(dir.join(ledger_store::db_file())).unwrap();
// No database at all: open rebuilds the projection from the journal.
let store = Store::open(&dir).unwrap();
let tb = ledger_core::reports::trial_balance(store.ledger());
@@ -426,7 +426,7 @@ fn corrupt_projection_is_quarantined_and_rebuilt() {
let _store = build_books(&dir);
}
// Corrupt the SQLite file with garbage.
let db_path = dir.join(ledger_store::DB_FILE);
let db_path = dir.join(ledger_store::db_file());
std::fs::write(&db_path, b"this is not a sqlite database at all").unwrap();
// Reopen heals from the journal instead of failing.
let store = Store::open(&dir).unwrap();
@@ -435,9 +435,11 @@ fn corrupt_projection_is_quarantined_and_rebuilt() {
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| {
// Kept beside it under its own name: wanderbooks.v{N}.corrupt.*
let stem = ledger_store::db_file().trim_end_matches(".db").to_string();
e.file_name()
.to_string_lossy()
.starts_with("wanderbooks.corrupt.")
.starts_with(&format!("{stem}.corrupt."))
})
.collect();
assert_eq!(quarantined.len(), 1);
@@ -838,11 +840,11 @@ fn vendor_1099_tags_replay_and_project() {
// Reopen (replay), then rebuild the projection from scratch.
for rebuild in [false, true] {
if rebuild {
std::fs::remove_file(dir.join(ledger_store::DB_FILE)).unwrap();
std::fs::remove_file(dir.join(ledger_store::db_file())).unwrap();
}
let store = Store::open(&dir).unwrap();
assert_eq!(report(store.ledger()), before);
let conn = rusqlite::Connection::open(dir.join(ledger_store::DB_FILE)).unwrap();
let conn = rusqlite::Connection::open(dir.join(ledger_store::db_file())).unwrap();
let tagged: i64 = conn
.query_row(
"SELECT COUNT(*) FROM entry_vendors WHERE vendor_id = ?",
@@ -885,7 +887,7 @@ fn the_whole_projection_is_checked_and_healed() {
store.verify().unwrap();
drop(store);
// Drift no accounting report would notice: a receipt quietly unlinked.
let db = dir.join(ledger_store::DB_FILE);
let db = dir.join(ledger_store::db_file());
let conn = rusqlite::Connection::open(&db).unwrap();
conn.execute("UPDATE attachments SET entry_id = NULL", [])
.unwrap();
@@ -1781,7 +1783,7 @@ fn books_written_under_older_rules_still_open() {
.unwrap();
drop(w);
// Opening rebuilds from the journal; it must accept all of it.
let _ = std::fs::remove_file(dir.join(ledger_store::DB_FILE));
let _ = std::fs::remove_file(dir.join(ledger_store::db_file()));
let store = Store::open(&dir).expect("older books still open");
store.verify().unwrap();
assert_eq!(store.journal_format().format, 1);
@@ -2570,12 +2572,12 @@ fn the_viewer_tells_behind_from_ahead() {
// Behind: a database restored from before that write.
drop(store);
let mut store = Store::open(&dir).unwrap();
let before_write = std::fs::read(dir.join(ledger_store::DB_FILE)).unwrap();
let before_write = std::fs::read(dir.join(ledger_store::db_file())).unwrap();
store.add_vendor("Supplier").unwrap();
drop(store);
std::fs::write(dir.join(ledger_store::DB_FILE), &before_write).unwrap();
let _ = std::fs::remove_file(dir.join("wanderbooks.db-wal"));
let _ = std::fs::remove_file(dir.join("wanderbooks.db-shm"));
std::fs::write(dir.join(ledger_store::db_file()), &before_write).unwrap();
let _ = std::fs::remove_file(dir.join(format!("{}-wal", ledger_store::db_file())));
let _ = std::fs::remove_file(dir.join(format!("{}-shm", ledger_store::db_file())));
let snap = Snapshot::load(&dir).unwrap();
match snap.check_disk() {
DiskCheck::Behind { applied, journal } => assert!(applied < journal),
@@ -2590,7 +2592,7 @@ fn the_table_check_counts_missing_and_unexpected_rows() {
let dir = test_dir("table-counts");
let store = build_books(&dir);
drop(store);
let conn = rusqlite::Connection::open(dir.join(ledger_store::DB_FILE)).unwrap();
let conn = rusqlite::Connection::open(dir.join(ledger_store::db_file())).unwrap();
// The events table mirrors the journal and isn't read back into the
// ledger, so only the table check sees this.
conn.execute(
@@ -2636,7 +2638,7 @@ fn a_database_with_the_wrong_currency_is_rebuilt() {
let store = build_books(&dir);
let before = ledger_core::reports::snapshot(store.ledger()).unwrap();
drop(store);
let conn = rusqlite::Connection::open(dir.join(ledger_store::DB_FILE)).unwrap();
let conn = rusqlite::Connection::open(dir.join(ledger_store::db_file())).unwrap();
conn.execute(
"UPDATE meta SET value = 'EUR' WHERE key = 'base_currency'",
[],
@@ -2780,7 +2782,7 @@ fn the_database_tracks_what_an_invoice_has_been_paid() {
)
.unwrap();
let row = |dir: &std::path::Path| -> (String, String) {
let conn = rusqlite::Connection::open(dir.join(ledger_store::DB_FILE)).unwrap();
let conn = rusqlite::Connection::open(dir.join(ledger_store::db_file())).unwrap();
conn.query_row(
"SELECT amount_paid, status FROM invoices WHERE id = ?",
[inv as i64],
@@ -2807,7 +2809,7 @@ fn rechain(dir: &std::path::Path, change: impl Fn(&mut Vec<ledger_store::Journal
let mut lines = ledger_store::journal::read_and_verify_journal(&path).unwrap();
change(&mut lines);
std::fs::remove_file(&path).unwrap();
let _ = std::fs::remove_file(dir.join(ledger_store::DB_FILE));
let _ = std::fs::remove_file(dir.join(ledger_store::db_file()));
let mut w = JournalWriter::open(&path).unwrap();
for l in lines {
w.append(&l.line_type, l.payload).unwrap();
@@ -2850,7 +2852,7 @@ fn replay_refuses_a_journaled_entry_it_does_not_rederive() {
// A projection missing a table.
let dir = test_dir("missing-table");
drop(build_books(&dir));
let conn = rusqlite::Connection::open(dir.join(ledger_store::DB_FILE)).unwrap();
let conn = rusqlite::Connection::open(dir.join(ledger_store::db_file())).unwrap();
conn.execute("DROP TABLE events", []).unwrap();
drop(conn);
let store = Store::open(&dir);
@@ -2930,7 +2932,7 @@ fn a_database_with_damaged_pages_is_rebuilt() {
}
let before = ledger_core::reports::snapshot(store.ledger()).unwrap();
drop(store);
let db = dir.join(ledger_store::DB_FILE);
let db = dir.join(ledger_store::db_file());
let mut bytes = std::fs::read(&db).unwrap();
// Scramble only the last pages (the customers just added); the header,
// schema, and settings at the front still read.
@@ -2967,7 +2969,7 @@ fn a_database_with_damaged_pages_is_rebuilt() {
/// `meta`), as text, with `accounts.name` blanked: what a rename may
/// change is that one column.
fn projection_but_names(dir: &std::path::Path) -> Vec<(String, Vec<String>)> {
let conn = rusqlite::Connection::open(dir.join(ledger_store::DB_FILE)).unwrap();
let conn = rusqlite::Connection::open(dir.join(ledger_store::db_file())).unwrap();
let mut tables: Vec<String> = conn
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name")
.unwrap()
@@ -3065,28 +3067,29 @@ fn rename_account_changes_only_the_name_defect_15() {
.rename_account(rent, " Office Rent ", Some("owner asked 2026-10-21"))
.unwrap();
let after = std::fs::read_to_string(&journal).unwrap();
// Every earlier line kept, byte for byte; two lines appended: the
// feature's declaration, then the rename.
// Every earlier line kept, byte for byte; one line appended, the
// rename (its feature, account_renamed, the books declared as they
// were created, with the rest of this version's format).
assert!(after.starts_with(&text));
assert!(
store
.journal_format()
.has_feature(ledger_store::format::FEATURE_ACCOUNT_RENAMED)
);
let added: Vec<serde_json::Value> = after[text.len()..]
.lines()
.map(|l| serde_json::from_str(l).unwrap())
.collect();
assert_eq!(added.len(), 2);
assert_eq!(added[0]["type"], "journal.format_set");
assert_eq!(added.len(), 1);
assert_eq!(added[0]["type"], "account.renamed");
assert_eq!(
added[0]["payload"],
serde_json::json!({"format": 2, "features": ["account_renamed"]})
);
assert_eq!(added[1]["type"], "account.renamed");
assert_eq!(
added[1]["payload"],
serde_json::json!({
"account_id": rent, "code": "6000", "name": "Office Rent",
"previous": "Rent Expense", "reason": "owner asked 2026-10-21"
})
);
assert_eq!(store.head().0, seq + 2);
assert_eq!(store.head().0, seq + 1);
assert_eq!(
store.ledger().chart().get(rent).unwrap().name,
"Office Rent"
@@ -3096,7 +3099,7 @@ fn rename_account_changes_only_the_name_defect_15() {
// The one column a rename changes, read from the projection itself
// (projection_but_names blanks it).
let db_name = |dir: &std::path::Path| -> String {
rusqlite::Connection::open(dir.join(ledger_store::DB_FILE))
rusqlite::Connection::open(dir.join(ledger_store::db_file()))
.unwrap()
.query_row(
"SELECT name FROM accounts WHERE id = ?",
@@ -3120,7 +3123,7 @@ fn rename_account_changes_only_the_name_defect_15() {
assert_eq!(reopened.ledger().chart().get(rent).unwrap().name, "Rent");
assert_eq!(figures_but_names(reopened.ledger()), figures);
drop(reopened);
let _ = std::fs::remove_file(dir.join(ledger_store::DB_FILE));
let _ = std::fs::remove_file(dir.join(ledger_store::db_file()));
let rebuilt = Store::open(&dir).unwrap();
assert_eq!(rebuilt.ledger().chart().get(rent).unwrap().name, "Rent");
assert_eq!(projection_but_names(&dir), rows);
@@ -3159,7 +3162,7 @@ fn a_journaled_rename_replays_past_the_command_rules() {
)
.unwrap();
drop(w);
let _ = std::fs::remove_file(dir.join(ledger_store::DB_FILE));
let _ = std::fs::remove_file(dir.join(ledger_store::db_file()));
let store = Store::open(&dir);
assert_eq!(store.is_ok(), opens, "{name:?}");
let store = store.unwrap();
@@ -3181,7 +3184,7 @@ fn a_journaled_rename_replays_past_the_command_rules() {
)
.unwrap();
drop(w);
let _ = std::fs::remove_file(dir.join(ledger_store::DB_FILE));
let _ = std::fs::remove_file(dir.join(ledger_store::db_file()));
assert!(Store::open(&dir).is_err());
}
@@ -3207,8 +3210,9 @@ fn tax_policy(store: &mut Store, value: Option<&str>) {
/// expense account that isn't a header is refused without one, journaling
/// nothing, and created with one as account.added then account.tax_set;
/// a needed category can't be cleared; the accounts the engine creates
/// for a control role are never refused. The first setting of the key
/// declares the feature require_tax_category, once.
/// for a control role are never refused. Setting the key needs the feature
/// require_tax_category, which new books declared as they were created:
/// the setting journals only itself.
#[test]
fn the_tax_category_policy_refuses_only_what_it_says() {
let dir = test_dir("tax-policy");
@@ -3224,12 +3228,8 @@ fn the_tax_category_policy_refuses_only_what_it_says() {
.lines()
.map(|l| serde_json::from_str(l).unwrap())
.collect();
assert_eq!(new[0]["type"], "journal.format_set");
assert_eq!(
new[0]["payload"],
serde_json::json!({"format": 2, "features": ["require_tax_category"]})
);
assert_eq!(new.last().unwrap()["type"], "settings.set");
assert_eq!(new.len(), 1);
assert_eq!(new[0]["type"], "settings.set");
for (code, t, header, tax, refused) in [
("6160", AccountType::Expense, false, None, true),
("4100", AccountType::Revenue, false, None, true),
@@ -3375,7 +3375,7 @@ fn a_journaled_clear_replays_under_refuse() {
)
.unwrap();
drop(w);
let _ = std::fs::remove_file(dir.join(ledger_store::DB_FILE));
let _ = std::fs::remove_file(dir.join(ledger_store::db_file()));
let store = Store::open(&dir).unwrap();
store.verify().unwrap();
assert!(
@@ -3646,7 +3646,7 @@ fn books_written_under_p3_keep_their_names() {
]
);
drop(store);
let _ = std::fs::remove_file(dir.join(ledger_store::DB_FILE));
let _ = std::fs::remove_file(dir.join(ledger_store::db_file()));
let rebuilt = Store::open(&dir).unwrap();
assert_eq!(chart_of(rebuilt.ledger()), want);
drop(rebuilt);
@@ -3683,10 +3683,10 @@ fn a_new_accounts_name_is_journaled_trimmed() {
assert_eq!(store.ledger().chart().get(id).unwrap().name, "Hosting");
let live = store.ledger().clone();
drop(store);
let _ = std::fs::remove_file(dir.join(ledger_store::DB_FILE));
let _ = std::fs::remove_file(dir.join(ledger_store::db_file()));
let reopened = Store::open(&dir).unwrap();
assert!(live.differs_from(reopened.ledger()).is_empty());
let conn = rusqlite::Connection::open(dir.join(ledger_store::DB_FILE)).unwrap();
let conn = rusqlite::Connection::open(dir.join(ledger_store::db_file())).unwrap();
let name: String = conn
.query_row("SELECT name FROM accounts WHERE id = ?", [id as i64], |r| {
r.get(0)