feat: ledger-gui — a read-only, live desktop view of the books
egui/wgpu app for owners and their accountant: Overview, Cash, Profit, Transactions (with rendered receipts and journal history), Reports (statements with interval columns, comparisons, drill-down), Accounts, Documents, Company, Journal; Ctrl+K palette; back/forward; light/dark. Follows the journal live (~30 ms from write to screen). --demo opens a deterministic demo company, also used for screenshot tests. Engine: ledger-store Snapshot (read-only, incremental, tolerant of a half-written tail); ledger-core analysis (intervals, balance series, P&L by interval, cash flows); ledger-config extracted from ledger-mcp with the full company profile and cash_accounts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
This commit is contained in:
52 files changed
+14050
-113
No files matched your search
@@ -25,6 +25,14 @@ impl BlobStore {
|
||||
})
|
||||
}
|
||||
|
||||
/// Open an existing blob store for reading, creating nothing. A
|
||||
/// missing directory just means no blobs yet.
|
||||
pub fn open_read_only(root: &Path) -> Self {
|
||||
Self {
|
||||
root: root.to_path_buf(),
|
||||
}
|
||||
}
|
||||
|
||||
fn path_for(&self, sha_hex: &str) -> PathBuf {
|
||||
self.root
|
||||
.join(&sha_hex[0..2])
|
||||
|
||||
@@ -22,11 +22,13 @@ pub mod blob;
|
||||
pub mod error;
|
||||
pub mod journal;
|
||||
pub mod schema;
|
||||
pub mod snapshot;
|
||||
pub mod store;
|
||||
|
||||
pub use blob::BlobStore;
|
||||
pub use error::StoreError;
|
||||
pub use journal::{GENESIS_PREV, JournalLine, JournalWriter};
|
||||
pub use snapshot::Snapshot;
|
||||
pub use store::{
|
||||
Attachment, DB_FILE, DepreciationRun, DisposalPreview, EntryRow, EventRow, JOURNAL_FILE,
|
||||
PayrollLineInput, Store,
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
//! [`Snapshot`]: a read-only view of the books, for viewers.
|
||||
//!
|
||||
//! A snapshot never writes to the data directory. It reads the journal,
|
||||
//! verifies the hash chain, replays it into its own [`Ledger`], and projects
|
||||
//! it into a private in-memory SQLite database with exactly the code the
|
||||
//! writer uses, so attachments and events read the same as through
|
||||
//! [`crate::Store`]. Blobs are read (and verified) straight from the blob
|
||||
//! store.
|
||||
//!
|
||||
//! [`Snapshot::refresh`] follows a live journal: it reads only the bytes
|
||||
//! appended since the last read, verifies they chain onto what it has, and
|
||||
//! applies them. A line the writer is still in the middle of appending
|
||||
//! (no newline yet) is left for the next refresh, never treated as
|
||||
//! corruption. If the file shrank or no longer chains, the snapshot reloads
|
||||
//! from scratch.
|
||||
|
||||
use crate::blob::BlobStore;
|
||||
use crate::error::StoreError;
|
||||
use crate::journal::{GENESIS_PREV, JournalLine, sha256_hex};
|
||||
use crate::store::{
|
||||
Attachment, BLOBS_DIR, JOURNAL_FILE, Store, apply_line_to_sqlite, query_attachments,
|
||||
read_attachment_bytes, replay_line_into_ledger,
|
||||
};
|
||||
use ledger_core::{Currency, EntryId, Ledger};
|
||||
use rusqlite::Connection;
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// A read-only, live-refreshable view of a data directory.
|
||||
pub struct Snapshot {
|
||||
data_dir: PathBuf,
|
||||
conn: Connection,
|
||||
blobs: BlobStore,
|
||||
ledger: Ledger,
|
||||
base_currency: Currency,
|
||||
lines: Vec<JournalLine>,
|
||||
/// Bytes of the journal consumed (complete lines only).
|
||||
offset: u64,
|
||||
/// Hash of the last line read: what the next line must chain onto.
|
||||
head: String,
|
||||
}
|
||||
|
||||
impl Snapshot {
|
||||
/// Load the books in `data_dir`, read-only.
|
||||
pub fn load(data_dir: &Path) -> Result<Self, StoreError> {
|
||||
let journal_path = data_dir.join(JOURNAL_FILE);
|
||||
if !journal_path.exists() {
|
||||
return Err(StoreError::NotFound(data_dir.display().to_string()));
|
||||
}
|
||||
let conn = Connection::open_in_memory()?;
|
||||
Store::migrate(&conn)?;
|
||||
let mut snap = Self {
|
||||
data_dir: data_dir.to_path_buf(),
|
||||
conn,
|
||||
blobs: BlobStore::open_read_only(&data_dir.join(BLOBS_DIR)),
|
||||
ledger: Ledger::new(Currency::USD),
|
||||
base_currency: Currency::USD,
|
||||
lines: Vec::new(),
|
||||
offset: 0,
|
||||
head: GENESIS_PREV.to_string(),
|
||||
};
|
||||
snap.read_new()?;
|
||||
Ok(snap)
|
||||
}
|
||||
|
||||
/// Apply whatever the writer appended since the last read. Returns the
|
||||
/// number of new journal lines (0 when nothing changed).
|
||||
pub fn refresh(&mut self) -> Result<usize, StoreError> {
|
||||
let len = std::fs::metadata(self.data_dir.join(JOURNAL_FILE))?.len();
|
||||
if len < self.offset {
|
||||
// Rewritten underneath us (e.g. restored from a backup).
|
||||
*self = Self::load(&self.data_dir)?;
|
||||
return Ok(self.lines.len());
|
||||
}
|
||||
match self.read_new() {
|
||||
Ok(n) => Ok(n),
|
||||
Err(StoreError::JournalCorrupt(_)) => {
|
||||
// No longer chains onto what we have: start over, and let a
|
||||
// real corruption surface from the full load.
|
||||
*self = Self::load(&self.data_dir)?;
|
||||
Ok(self.lines.len())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_new(&mut self) -> Result<usize, StoreError> {
|
||||
let mut file = std::fs::File::open(self.data_dir.join(JOURNAL_FILE))?;
|
||||
file.seek(SeekFrom::Start(self.offset))?;
|
||||
let mut buf = Vec::new();
|
||||
file.read_to_end(&mut buf)?;
|
||||
// Complete lines only: a torn tail is a write still in progress.
|
||||
let Some(last_newline) = buf.iter().rposition(|&b| b == b'\n') else {
|
||||
return Ok(0);
|
||||
};
|
||||
let complete = &buf[..=last_newline];
|
||||
let mut added = 0;
|
||||
for raw in complete.split(|&b| b == b'\n').filter(|l| !l.is_empty()) {
|
||||
let line: JournalLine = serde_json::from_slice(raw).map_err(|e| {
|
||||
StoreError::JournalCorrupt(format!(
|
||||
"seq {}: unparseable line: {e}",
|
||||
self.lines.len() + 1
|
||||
))
|
||||
})?;
|
||||
let want_seq = self.lines.len() as u64 + 1;
|
||||
if line.seq != want_seq {
|
||||
return Err(StoreError::JournalCorrupt(format!(
|
||||
"seq gap: expected {want_seq}, found {}",
|
||||
line.seq
|
||||
)));
|
||||
}
|
||||
if line.prev != self.head {
|
||||
return Err(StoreError::JournalCorrupt(format!(
|
||||
"hash chain broken at seq {}",
|
||||
line.seq
|
||||
)));
|
||||
}
|
||||
if line.seq == 1 {
|
||||
let code = line.payload["base_currency"].as_str().unwrap_or("USD");
|
||||
self.base_currency = Currency::new(code)
|
||||
.map_err(|_| StoreError::JournalCorrupt(format!("bad base_currency {code}")))?;
|
||||
self.ledger = Ledger::new(self.base_currency);
|
||||
}
|
||||
replay_line_into_ledger(&mut self.ledger, &line, self.base_currency)?;
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
self.head = sha256_hex(line.to_canonical().as_bytes());
|
||||
self.lines.push(line);
|
||||
added += 1;
|
||||
}
|
||||
self.offset += complete.len() as u64;
|
||||
Ok(added)
|
||||
}
|
||||
|
||||
/// The replayed ledger: the source for every report.
|
||||
pub fn ledger(&self) -> &Ledger {
|
||||
&self.ledger
|
||||
}
|
||||
|
||||
/// The books' base currency.
|
||||
pub fn base_currency(&self) -> Currency {
|
||||
self.base_currency
|
||||
}
|
||||
|
||||
/// The data directory.
|
||||
pub fn data_dir(&self) -> &Path {
|
||||
&self.data_dir
|
||||
}
|
||||
|
||||
/// Every journal line read so far, in order: the complete history,
|
||||
/// with timestamps and hashes.
|
||||
pub fn journal(&self) -> &[JournalLine] {
|
||||
&self.lines
|
||||
}
|
||||
|
||||
/// Sequence number of the last journal line read.
|
||||
pub fn journal_seq(&self) -> u64 {
|
||||
self.lines.len() as u64
|
||||
}
|
||||
|
||||
/// Hash of the last journal line: the head of the verified chain.
|
||||
pub fn head_hash(&self) -> &str {
|
||||
&self.head
|
||||
}
|
||||
|
||||
/// Attachments (receipts and documents, withdrawn ones included),
|
||||
/// optionally only one entry's.
|
||||
pub fn attachments(&self, entry_id: Option<EntryId>) -> Result<Vec<Attachment>, StoreError> {
|
||||
query_attachments(&self.conn, entry_id)
|
||||
}
|
||||
|
||||
/// An attachment's bytes, verified against its hash.
|
||||
pub fn attachment_bytes(&self, attachment_id: u64) -> Result<Vec<u8>, StoreError> {
|
||||
read_attachment_bytes(&self.conn, &self.blobs, attachment_id)
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ use std::path::{Path, PathBuf};
|
||||
pub const JOURNAL_FILE: &str = "journal.jsonl";
|
||||
/// File names inside the data directory.
|
||||
pub const DB_FILE: &str = "wanderbooks.db";
|
||||
const BLOBS_DIR: &str = "blobs";
|
||||
pub(crate) const BLOBS_DIR: &str = "blobs";
|
||||
|
||||
/// One row of an entry listing.
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -283,7 +283,7 @@ impl Store {
|
||||
matches!(check, Ok(s) if s == "ok")
|
||||
}
|
||||
|
||||
fn migrate(conn: &Connection) -> Result<(), StoreError> {
|
||||
pub(crate) fn migrate(conn: &Connection) -> Result<(), StoreError> {
|
||||
let mut v = Self::current_version(conn)?;
|
||||
if v > schema::SCHEMA_VERSION {
|
||||
return Err(StoreError::SchemaTooNew(v, schema::SCHEMA_VERSION));
|
||||
@@ -2106,36 +2106,12 @@ impl Store {
|
||||
&self,
|
||||
entry_id: Option<EntryId>,
|
||||
) -> Result<Vec<Attachment>, StoreError> {
|
||||
let mut sql = String::from(
|
||||
"SELECT a.id, a.blob_sha, a.entry_id, a.category, b.original_name, b.mime_type, \
|
||||
b.size_bytes, a.description, a.created_at, a.withdrawn_at, a.withdrawn_reason \
|
||||
FROM attachments a \
|
||||
JOIN blobs b ON b.sha256 = a.blob_sha",
|
||||
);
|
||||
if entry_id.is_some() {
|
||||
sql.push_str(" WHERE a.entry_id = ?");
|
||||
}
|
||||
sql.push_str(" ORDER BY a.id");
|
||||
let mut stmt = self.conn.prepare(&sql)?;
|
||||
let rows: Vec<Attachment> = match entry_id {
|
||||
Some(eid) => stmt
|
||||
.query_map(params![eid as i64], row_to_attachment)?
|
||||
.collect::<Result<_, _>>()?,
|
||||
None => stmt
|
||||
.query_map([], row_to_attachment)?
|
||||
.collect::<Result<_, _>>()?,
|
||||
};
|
||||
Ok(rows)
|
||||
query_attachments(&self.conn, entry_id)
|
||||
}
|
||||
|
||||
/// Read attachment bytes, verifying the SHA-256.
|
||||
pub fn attachment_bytes(&self, attachment_id: u64) -> Result<Vec<u8>, StoreError> {
|
||||
let sha: String = self.conn.query_row(
|
||||
"SELECT blob_sha FROM attachments WHERE id = ?",
|
||||
params![attachment_id as i64],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
self.blobs.get(&sha)
|
||||
read_attachment_bytes(&self.conn, &self.blobs, attachment_id)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
@@ -2795,7 +2771,7 @@ fn get_str<'a>(p: &'a Value, key: &str) -> Result<&'a str, StoreError> {
|
||||
/// Replay one journal line into a ledger, asserting recorded ids. Used by
|
||||
/// hydration, `verify`, and `rebuild`: the journal is the script, the engine
|
||||
/// is the actor.
|
||||
fn replay_line_into_ledger(
|
||||
pub(crate) fn replay_line_into_ledger(
|
||||
ledger: &mut Ledger,
|
||||
line: &JournalLine,
|
||||
base_currency: Currency,
|
||||
@@ -3376,7 +3352,10 @@ fn check_replayed_entry(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_line_to_sqlite(conn: &Connection, line: &JournalLine) -> Result<(), StoreError> {
|
||||
pub(crate) fn apply_line_to_sqlite(
|
||||
conn: &Connection,
|
||||
line: &JournalLine,
|
||||
) -> Result<(), StoreError> {
|
||||
let p = &line.payload;
|
||||
match line.line_type.as_str() {
|
||||
"store.created" => {}
|
||||
@@ -3795,6 +3774,47 @@ fn apply_line_to_sqlite(conn: &Connection, line: &JournalLine) -> Result<(), Sto
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Attachments in the projection, optionally only one entry's, by id.
|
||||
pub(crate) fn query_attachments(
|
||||
conn: &Connection,
|
||||
entry_id: Option<EntryId>,
|
||||
) -> Result<Vec<Attachment>, StoreError> {
|
||||
let mut sql = String::from(
|
||||
"SELECT a.id, a.blob_sha, a.entry_id, a.category, b.original_name, b.mime_type, \
|
||||
b.size_bytes, a.description, a.created_at, a.withdrawn_at, a.withdrawn_reason \
|
||||
FROM attachments a \
|
||||
JOIN blobs b ON b.sha256 = a.blob_sha",
|
||||
);
|
||||
if entry_id.is_some() {
|
||||
sql.push_str(" WHERE a.entry_id = ?");
|
||||
}
|
||||
sql.push_str(" ORDER BY a.id");
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows: Vec<Attachment> = match entry_id {
|
||||
Some(eid) => stmt
|
||||
.query_map(params![eid as i64], row_to_attachment)?
|
||||
.collect::<Result<_, _>>()?,
|
||||
None => stmt
|
||||
.query_map([], row_to_attachment)?
|
||||
.collect::<Result<_, _>>()?,
|
||||
};
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// An attachment's bytes, verified against its SHA-256.
|
||||
pub(crate) fn read_attachment_bytes(
|
||||
conn: &Connection,
|
||||
blobs: &BlobStore,
|
||||
attachment_id: u64,
|
||||
) -> Result<Vec<u8>, StoreError> {
|
||||
let sha: String = conn.query_row(
|
||||
"SELECT blob_sha FROM attachments WHERE id = ?",
|
||||
params![attachment_id as i64],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
blobs.get(&sha)
|
||||
}
|
||||
|
||||
fn row_to_attachment(r: &rusqlite::Row) -> rusqlite::Result<Attachment> {
|
||||
Ok(Attachment {
|
||||
id: r.get::<_, i64>(0)? as u64,
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
//! Snapshot: a read-only view that follows a live writer.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::reports::snapshot;
|
||||
use ledger_core::{AccountType, Currency, Money, Side};
|
||||
use ledger_store::{JOURNAL_FILE, Snapshot, Store};
|
||||
use rust_decimal::Decimal;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn test_dir(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("wanderbooks-snap-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn usd(n: i64) -> Money {
|
||||
Money::new(Decimal::from(n), Currency::USD)
|
||||
}
|
||||
|
||||
fn post(store: &mut Store, day: u32, dr: u64, cr: u64, amount: i64) -> u64 {
|
||||
let d = NaiveDate::from_ymd_opt(2026, 10, day).unwrap();
|
||||
let e = store.create_entry(d, "x").unwrap();
|
||||
store.add_line(e, dr, Side::Debit, usd(amount)).unwrap();
|
||||
store.add_line(e, cr, Side::Credit, usd(amount)).unwrap();
|
||||
store.post_entry(e).unwrap();
|
||||
e
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_snapshot_follows_the_writer_and_never_writes() {
|
||||
let dir = test_dir("follow");
|
||||
let mut store = Store::create(&dir, Currency::USD).unwrap();
|
||||
let cash = store
|
||||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let equity = store
|
||||
.add_account("3000", "Equity", AccountType::Equity, None, false)
|
||||
.unwrap();
|
||||
let e = post(&mut store, 1, cash, equity, 1000);
|
||||
store
|
||||
.attach_receipt(Some(e), "deposit.png", "image/png", b"png-bytes", None)
|
||||
.unwrap();
|
||||
|
||||
let listing = |dir: &std::path::Path| {
|
||||
let mut v: Vec<_> = walk(dir);
|
||||
v.sort();
|
||||
v
|
||||
};
|
||||
let before = listing(&dir);
|
||||
let mut snap = Snapshot::load(&dir).unwrap();
|
||||
assert_eq!(listing(&dir), before, "loading writes nothing");
|
||||
assert_eq!(snap.journal_seq(), store.journal_seq());
|
||||
assert_eq!(
|
||||
snapshot(snap.ledger()).unwrap(),
|
||||
snapshot(store.ledger()).unwrap()
|
||||
);
|
||||
let atts = snap.attachments(Some(e)).unwrap();
|
||||
assert_eq!(atts.len(), 1);
|
||||
assert_eq!(snap.attachment_bytes(atts[0].id).unwrap(), b"png-bytes");
|
||||
|
||||
// The writer keeps going; refresh picks up exactly the new lines.
|
||||
assert_eq!(snap.refresh().unwrap(), 0);
|
||||
let seq = store.journal_seq();
|
||||
post(&mut store, 2, cash, equity, 250);
|
||||
let added = snap.refresh().unwrap();
|
||||
assert_eq!(added as u64, store.journal_seq() - seq);
|
||||
assert_eq!(
|
||||
snapshot(snap.ledger()).unwrap(),
|
||||
snapshot(store.ledger()).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_half_written_line_waits_for_the_next_refresh() {
|
||||
let dir = test_dir("torn");
|
||||
let mut store = Store::create(&dir, Currency::USD).unwrap();
|
||||
store
|
||||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
drop(store);
|
||||
let mut snap = Snapshot::load(&dir).unwrap();
|
||||
let seq = snap.journal_seq();
|
||||
// Simulate the writer mid-append: half a line, no newline yet.
|
||||
let journal = dir.join(JOURNAL_FILE);
|
||||
let full = std::fs::read(&journal).unwrap();
|
||||
let mut store = Store::open(&dir).unwrap();
|
||||
store
|
||||
.add_account("3000", "Equity", AccountType::Equity, None, false)
|
||||
.unwrap();
|
||||
drop(store);
|
||||
let grown = std::fs::read(&journal).unwrap();
|
||||
let new_line = &grown[full.len()..];
|
||||
std::fs::write(&journal, &full).unwrap();
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(&journal)
|
||||
.unwrap();
|
||||
f.write_all(&new_line[..new_line.len() / 2]).unwrap();
|
||||
f.flush().unwrap();
|
||||
assert_eq!(snap.refresh().unwrap(), 0, "torn tail is not corruption");
|
||||
f.write_all(&new_line[new_line.len() / 2..]).unwrap();
|
||||
f.flush().unwrap();
|
||||
assert_eq!(snap.refresh().unwrap(), 1);
|
||||
assert_eq!(snap.journal_seq(), seq + 1);
|
||||
assert!(snap.ledger().chart().find_by_code("3000").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rewritten_journal_reloads() {
|
||||
let dir = test_dir("rewrite");
|
||||
let mut store = Store::create(&dir, Currency::USD).unwrap();
|
||||
let cash = store
|
||||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let equity = store
|
||||
.add_account("3000", "Equity", AccountType::Equity, None, false)
|
||||
.unwrap();
|
||||
let journal = dir.join(JOURNAL_FILE);
|
||||
let early = std::fs::read(&journal).unwrap();
|
||||
post(&mut store, 1, cash, equity, 10);
|
||||
drop(store);
|
||||
let mut snap = Snapshot::load(&dir).unwrap();
|
||||
let later = snap.journal_seq();
|
||||
// Restored from an earlier backup: shorter file.
|
||||
std::fs::write(&journal, &early).unwrap();
|
||||
snap.refresh().unwrap();
|
||||
assert!(snap.journal_seq() < later);
|
||||
assert!(snap.ledger().posted_entries().next().is_none());
|
||||
}
|
||||
|
||||
fn walk(dir: &std::path::Path) -> Vec<(String, u64)> {
|
||||
let mut out = Vec::new();
|
||||
for e in std::fs::read_dir(dir).unwrap() {
|
||||
let e = e.unwrap();
|
||||
let m = e.metadata().unwrap();
|
||||
if m.is_dir() {
|
||||
out.extend(walk(&e.path()));
|
||||
} else {
|
||||
out.push((e.path().display().to_string(), m.len()));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
Reference in new issue
Block a user