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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-12 19:09:08 -06:00
1 parent 475d08b28e
commit 024c9896f2
2 files changed
+35 -1

No files matched your search

+10 -1
View File
@@ -2217,7 +2217,10 @@ impl Store {
"live ledger diverged from journal replay".into(), "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)?; let c_snap = reports::snapshot(&c).map_err(StoreError::Ledger)?;
if a != c_snap { if a != c_snap {
return Err(StoreError::VerifyMismatch( return Err(StoreError::VerifyMismatch(
@@ -2406,6 +2409,12 @@ impl Store {
let (id, date, description, reversal_of) = e?; let (id, date, description, reversal_of) = e?;
let date = chrono::NaiveDate::parse_from_str(&date, "%Y-%m-%d") let date = chrono::NaiveDate::parse_from_str(&date, "%Y-%m-%d")
.map_err(|_| StoreError::JournalCorrupt(format!("bad date {date}")))?; .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 { match reversal_of {
Some(orig) => { Some(orig) => {
// Reversals allocate the next id; replayed in id order the // Reversals allocate the next id; replayed in id order the
+25
View File
@@ -727,3 +727,28 @@ fn the_whole_projection_is_checked_and_healed() {
// A healthy store reports nothing to heal. // A healthy store reports nothing to heal.
assert_eq!(store.verify_and_heal().unwrap().healed, None); 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);
}