Files
wanderbooks/crates/ledger-store/src/lib.rs
T
swaitsandClaude Opus 5.5 8c8ba19471
check / check (push) Canceled after 0s
fix(store): one writer at a time; a second is refused, not allowed to corrupt the books
Max-testing pass on ledger-store, found during the survey: nothing
stopped two processes from opening the same books for writing. Two
agent sessions (two MCP servers) on one books dir would each extend the
hash chain from the same last line; one write from each broke the
journal ("seq gap: expected 3, found 2") and the books would not open.

The store now holds an exclusive lock on books-data/wanderbooks.lock
(std's File::try_lock, no new dependency) from before it reads the
journal until it is dropped; the OS releases it if the process dies. A
second writer gets StoreError::Locked saying another process has the
books open. The viewer reads through Snapshot and never takes the lock.
The lock file is git-ignored in the books scaffold.

Regression: a_second_writer_is_refused_while_the_first_has_the_books
(refused while open, allowed after close, books whole).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 16:05:59 -06:00

37 lines
1.5 KiB
Rust

//! `ledger-store`: durable persistence for the books.
//!
//! Three pieces, one job — keep the books safe:
//!
//! * [`journal`] — the source of truth. An append-only, hash-chained JSONL
//! journal. Every state change is an immutable fact, fsync'd on append.
//! * [`blob`] — the bytes. Content-addressed (SHA-256), immutable,
//! deduplicated receipt and document storage.
//! * [`Store`] — the SQLite projection plus the write path. SQLite is the
//! query engine and is disposable by design: [`Store::rebuild`] drops it
//! and replays the journal. [`Store::verify`] replays the journal into a
//! fresh engine and diffs its reports against both the live ledger and
//! the projection.
//!
//! The write path is singular: **validate → journal → mutate → apply**.
//! Journal first means a failed write leaves nothing behind; a failed
//! SQLite apply leaves the journal ahead, and the next open heals it via
//! `last_applied_seq`. There is exactly one writer: `Store` is `!Sync` by
//! construction (it owns a `rusqlite::Connection`).
pub mod blob;
pub mod error;
pub mod journal;
pub mod schema;
pub mod snapshot;
pub mod store;
pub mod viewer;
pub use blob::BlobStore;
pub use error::StoreError;
pub use journal::{GENESIS_PREV, JournalLine, JournalWriter};
pub use snapshot::{DiskCheck, Snapshot};
pub use store::{
Attachment, DB_FILE, DepreciationRun, DisposalPreview, EntryRow, EventRow, JOURNAL_FILE,
LOCK_FILE, PayrollLineInput, Store, VerifyReport,
};