Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan

This commit is contained in:
swaits committed 2026-10-11 03:01:26 +00:00
1 parent 9ba4917899
commit 6313c50b11
1 file changed
+76 -15
+76 -15
View File
@@ -185,7 +185,23 @@ impl Store {
let lines = read_and_verify_journal(&journal_path)?; let lines = read_and_verify_journal(&journal_path)?;
let journal = JournalWriter::from_lines(&journal_path, &lines)?; let journal = JournalWriter::from_lines(&journal_path, &lines)?;
let db_path = data_dir.join(DB_FILE); let db_path = data_dir.join(DB_FILE);
let fresh_db = !db_path.exists(); let mut fresh_db = !db_path.exists();
if !fresh_db && !Self::db_file_healthy(&db_path) {
// The projection is corrupt. The journal is the source of truth,
// so quarantine the damaged file (for forensics) and rebuild.
let stamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
let quarantine = db_path.with_extension(format!("corrupt.{stamp}"));
std::fs::rename(&db_path, &quarantine).map_err(StoreError::Io)?;
// WAL/SHM are derived state; drop them so they can't contaminate
// the rebuild.
let _ = std::fs::remove_file(db_path.with_extension("db-wal"));
let _ = std::fs::remove_file(db_path.with_extension("db-shm"));
eprintln!(
"wanderbooks: sqlite projection failed integrity_check; quarantined to {} and rebuilding from journal",
quarantine.display()
);
fresh_db = true;
}
let conn = Self::open_conn(&db_path)?; let conn = Self::open_conn(&db_path)?;
Self::migrate(&conn)?; Self::migrate(&conn)?;
let base_currency = if fresh_db { let base_currency = if fresh_db {
@@ -240,15 +256,21 @@ impl Store {
conn.execute_batch( conn.execute_batch(
"PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA foreign_keys=ON;", "PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA foreign_keys=ON;",
)?; )?;
let check: String = conn.query_row("PRAGMA integrity_check", [], |r| r.get(0))?;
if check != "ok" {
return Err(StoreError::JournalCorrupt(format!(
"sqlite integrity_check failed: {check}"
)));
}
Ok(conn) Ok(conn)
} }
/// True when the SQLite file at `db_path` passes `integrity_check`.
/// Any failure (missing file, not a database, corruption) counts as
/// unhealthy. Opens a throwaway connection without touching pragmas.
fn db_file_healthy(db_path: &Path) -> bool {
let conn = match Connection::open(db_path) {
Ok(c) => c,
Err(_) => return false,
};
let check: Result<String, _> = conn.query_row("PRAGMA integrity_check", [], |r| r.get(0));
matches!(check, Ok(s) if s == "ok")
}
fn migrate(conn: &Connection) -> Result<(), StoreError> { fn migrate(conn: &Connection) -> Result<(), StoreError> {
let mut v = Self::current_version(conn)?; let mut v = Self::current_version(conn)?;
if v > schema::SCHEMA_VERSION { if v > schema::SCHEMA_VERSION {
@@ -414,8 +436,10 @@ impl Store {
Ok(live) Ok(live)
} }
/// Add a line to a draft entry. Draft state is ephemeral and is not /// Add a line to a draft entry. Drafts are durable: the line is
/// journaled; only the posted entry hits the journal. /// journaled as `entry.line_added`, so drafts (and their receipts)
/// survive restarts. Drafts never touch the SQLite projection until
/// posted.
pub fn add_line( pub fn add_line(
&mut self, &mut self,
entry: EntryId, entry: EntryId,
@@ -424,6 +448,20 @@ impl Store {
amount: Money, amount: Money,
) -> Result<(), StoreError> { ) -> Result<(), StoreError> {
self.ledger.add_line(entry, account, side, amount)?; self.ledger.add_line(entry, account, side, amount)?;
let line = self.append_journal(
"entry.line_added",
json!({
"id": entry,
"account": account,
"side": match side {
Side::Debit => "debit",
Side::Credit => "credit",
},
"amount": amount.amount.to_string(),
"currency": amount.currency.code(),
}),
)?;
apply_line_to_sqlite(&self.conn, &line)?;
Ok(()) Ok(())
} }
@@ -1484,14 +1522,33 @@ fn replay_line_into_ledger(
))); )));
} }
} }
"entry.line_added" => {
let id = get_u64(p, "id")?;
ledger.add_line(
id,
get_u64(p, "account")?,
parse_side(get_str(p, "side")?)?,
Money::new(
get_str(p, "amount")?
.parse()
.map_err(|_| StoreError::JournalCorrupt("bad amount".into()))?,
Currency::new(get_str(p, "currency")?)?,
),
)?;
}
"entry.posted" => { "entry.posted" => {
let id = get_u64(p, "id")?; let id = get_u64(p, "id")?;
// The draft was created by the entry.created line; add lines, post. // New journals build the draft via entry.line_added events; old
for l in p // journals (pre-durable-drafts) only have the lines in the
.get("lines") // posted payload. If the draft already has lines, just post.
.and_then(|v| v.as_array()) let has_lines = ledger
.ok_or_else(|| StoreError::JournalCorrupt("entry.posted without lines".into()))? .get_entry(id)
{ .map(|e| !e.lines().is_empty())
.unwrap_or(false);
if !has_lines {
for l in p.get("lines").and_then(|v| v.as_array()).ok_or_else(|| {
StoreError::JournalCorrupt("entry.posted without lines".into())
})? {
ledger.add_line( ledger.add_line(
id, id,
get_u64(l, "account")?, get_u64(l, "account")?,
@@ -1504,6 +1561,7 @@ fn replay_line_into_ledger(
), ),
)?; )?;
} }
}
ledger.post_entry(id)?; ledger.post_entry(id)?;
} }
"entry.reversed" => { "entry.reversed" => {
@@ -1584,6 +1642,9 @@ fn apply_line_to_sqlite(conn: &Connection, line: &JournalLine) -> Result<(), Sto
)?; )?;
} }
"entry.created" => {} "entry.created" => {}
// Draft lines are durable in the journal but never touch the
// projection until posted.
"entry.line_added" => {}
"entry.posted" => { "entry.posted" => {
conn.execute( conn.execute(
"INSERT INTO entries(id, date, description, status, reversal_of) VALUES (?,?,?,'posted',?)", "INSERT INTO entries(id, date, description, status, reversal_of) VALUES (?,?,?,'posted',?)",