From 024c9896f2257bc1ba3d0a1c892340fe390cebb2 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Mon, 12 Oct 2026 19:09:08 -0600 Subject: [PATCH] fix(store): verify no longer chokes on a draft before a posted entry The SQLite cross-check rebuilt entries by id and assumed no gaps, but drafts aren't projected until posted: a draft #8 before a posted #9 made the server refuse to start. Gaps are now held with empty drafts (which no report counts), and a projection that can't be read back counts as drift to heal, not a reason to refuse to start. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu --- crates/ledger-store/src/store.rs | 11 ++++++++++- crates/ledger-store/tests/store.rs | 25 +++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/crates/ledger-store/src/store.rs b/crates/ledger-store/src/store.rs index 5f4d8e5..15bc4df 100644 --- a/crates/ledger-store/src/store.rs +++ b/crates/ledger-store/src/store.rs @@ -2217,7 +2217,10 @@ impl Store { "live ledger diverged from journal replay".into(), )); } - let c = self.load_ledger_from_sqlite()?; + // A projection that can't even be read back has drifted too. + let c = self.load_ledger_from_sqlite().map_err(|e| { + StoreError::VerifyMismatch(format!("sqlite projection can't be read back: {e}")) + })?; let c_snap = reports::snapshot(&c).map_err(StoreError::Ledger)?; if a != c_snap { return Err(StoreError::VerifyMismatch( @@ -2406,6 +2409,12 @@ impl Store { let (id, date, description, reversal_of) = e?; let date = chrono::NaiveDate::parse_from_str(&date, "%Y-%m-%d") .map_err(|_| StoreError::JournalCorrupt(format!("bad date {date}")))?; + // Drafts aren't projected until posted, so ids can skip: a draft + // #8 before a posted #9. Hold their ids with empty drafts, which + // no report counts. + while ledger.next_entry_id() < id as u64 { + ledger.create_entry(date, "(draft, not projected)"); + } match reversal_of { Some(orig) => { // Reversals allocate the next id; replayed in id order the diff --git a/crates/ledger-store/tests/store.rs b/crates/ledger-store/tests/store.rs index 25dda90..16b1356 100644 --- a/crates/ledger-store/tests/store.rs +++ b/crates/ledger-store/tests/store.rs @@ -727,3 +727,28 @@ fn the_whole_projection_is_checked_and_healed() { // A healthy store reports nothing to heal. assert_eq!(store.verify_and_heal().unwrap().healed, None); } + +#[test] +fn a_draft_before_a_posted_entry_verifies_and_reopens() { + // The real case: a reimbursement drafted (#8, waiting on the bank), + // then a domain posted (#9). Drafts aren't in the projection, so its + // entry ids skip. + let dir = test_dir("draft-gap"); + let mut store = build_books(&dir); + let cash = store.ledger().chart().find_by_code("1000").unwrap().id; + let equity = store.ledger().chart().find_by_code("3000").unwrap().id; + let d = NaiveDate::from_ymd_opt(2026, 10, 12).unwrap(); + let draft = store.create_entry(d, "waiting on the bank").unwrap(); + store.add_line(draft, cash, Side::Debit, usd(5)).unwrap(); + store.add_line(draft, equity, Side::Credit, usd(5)).unwrap(); + let posted = store.create_entry(d, "posted after the draft").unwrap(); + store.add_line(posted, cash, Side::Debit, usd(7)).unwrap(); + store + .add_line(posted, equity, Side::Credit, usd(7)) + .unwrap(); + store.post_entry(posted).unwrap(); + store.verify().unwrap(); + drop(store); + let mut store = Store::open(&dir).unwrap(); + assert_eq!(store.verify_and_heal().unwrap().healed, None); +}