Stores

MemoryStore, SQLite and PostgreSQL: one contract, one writer per ledger, reads that never block, a schema identifier checked on open.

On this page
  1. What to call
  2. Choosing a store
  3. One writer per ledger
  4. The Store contract
  5. Schema identifier, no migrations
  6. Integrity
  7. Tracing
  8. Limits

What to call

use doubleentry::prelude::*;
use doubleentry::store::{SqlOptions, SqlStore, Sqlite};
use std::time::Duration;

let dir = tempfile::tempdir()?;
let url = format!("sqlite://{}", dir.path().join("books.db").display());
let options = SqlOptions { busy_timeout: Duration::from_secs(2), ..SqlOptions::default() };
let store = SqlStore::<Sqlite>::sqlite_with(&url, options).await?;
let books = Ledger::new(store.clone(), LedgerId::new("example.com/acme")?).await?; // create or open
books.declare_currency(EUR, 2, date!("2026-01-01")).await?;
books.open("Assets:Cash", AccountKind::Asset, date!("2026-01-01")).await?;
store.close().await;

let reopened = Ledger::new(SqlStore::<Sqlite>::sqlite(&url).await?, LedgerId::new("example.com/acme")?).await?;
assert!(reopened.account("Assets:Cash").await?.is_some());

Choosing a store

MemoryStoreSqlStore<Sqlite>SqlStore<Postgres>
Durablenoyes, one fileyes
Ledgersone per store valueone per fileone per schema; one pool serves many
Writersone at a time (a lock)one at a time (BEGIN IMMEDIATE)one at a time per ledger (advisory lock)
Readersnever waitnever wait (WAL)never wait (REPEATABLE READ READ ONLY)
Immutabilityin-process onlytriggerstriggers, incl. TRUNCATE

Every store answers every operation with the same result and the same Rejection as the in-memory Journal; the conformance suite asserts it field for field. Switching stores changes the constructor, not the code. Ledger::to_journal turns durable books into an in-memory replica.

The constructors only connect; nothing is read or created until Ledger::new. A SqlStore is a cheap, cloneable handle around one pool, and so is a Ledger.

  • SQLite runs every connection with journal_mode=WAL, synchronous=FULL, foreign_keys=ON and recursive_triggers=ON (a connection without the last two is refused). Writers queue on the file lock for up to SqlOptions::busy_timeout (5 s by default), then fail as Error::Backend with is_transient(). sqlite::memory: uses one connection.
  • PostgreSQL: one ledger per schema (SqlOptions::schema, default doubleentry); see PostgreSQL.

One writer per ledger

record runs one transaction: take the ledger's single-writer lock, read the facts the rule core asks for, run the core, persist, commit. Validation happens inside the writing transaction, against state no other writer can change. Positions are assigned there, so log order is commit order. Readers take no lock: each read runs in one snapshot. record_batch amortises the per-write round trips.

The Store contract

Store<const P: u8 = 2> has eighteen async primitives: create, open, write, size, read_leaves, read_nodes, seals, accounts, account_records, entry, holds, clearings, projection, seal_nodes, balances, postings, memo, erase_memo. Proofs, statements, reports, export and audit are built once over these by the façade; a store decides nothing.

The methods return impl Future + Send, so the trait is not object safe and Ledger<S> is generic. To pick a store at run time, wrap the candidates in an enum and implement Store by delegation; the Store rustdoc has the complete enum.

A store of your own proves the contract with the public suite: an exact-rejection table per code, a seeded simulation with retries, and concurrent appends, batches, draws and erasures, each on a fresh store from your factory. A failure names the check, seed and step (DOUBLEENTRY_SEED=… DOUBLEENTRY_CHECK=…).

use doubleentry::store::MemoryStore;
use doubleentry::testing::{Config, conformance_with};

// Your store: `conformance(|| async { MyStore::fresh().await })` runs everything.
let config = Config { check: Some(String::from("tables/2")), ..Config::default() };
let report = conformance_with(|| async { MemoryStore::new() }, config).await?;
assert!(!report.checks.is_empty());

Schema identifier, no migrations

A SQL store's meta table holds one row: the schema identifier doubleentry/1 (SCHEMA_ID), the ledger id, the scale P and the genesis leaf hash. open reads it before anything else:

FoundResult
no ledgerNotCreated; Ledger::new then creates it
another schema identifierError::Config(SchemaMismatch { found }); nothing is read or changed
another ledger id, or another scale PError::Config(LedgerMismatch { .. })
a genesis record that does not hash to the meta row'sError::Integrity (GenesisMismatch)

Every write re-checks the row. There are no migrations: a database of another schema is refused, never converted. Books move to a new schema by recording them from their source documents into a new ledger with the same idempotency keys; keep the old database, and the crate version that wrote it, for its retention period.

use doubleentry::error::ConfigError;
use doubleentry::prelude::*;
use doubleentry::store::{SqlStore, Sqlite};

let dir = tempfile::tempdir()?;
let url = format!("sqlite://{}", dir.path().join("books.db").display());
Ledger::new(SqlStore::<Sqlite>::sqlite(&url).await?, LedgerId::new("example.com/acme")?).await?;

let other = Ledger::new(SqlStore::<Sqlite>::sqlite(&url).await?, LedgerId::new("example.com/other")?).await;
assert!(matches!(other, Err(Error::Config(ConfigError::LedgerMismatch { .. }))));
let finer = Ledger::<_, 4>::with_precision(SqlStore::<Sqlite, 4>::sqlite(&url).await?, LedgerId::new("example.com/acme")?).await;
assert!(matches!(finer, Err(Error::Config(_))));
assert_eq!(doubleentry::store::SCHEMA_ID, "doubleentry/1");

Integrity

Every stored record a read hands out is verified first: the entry id must re-derive from its key, the leaf bytes must hash to the stored leaf hash. A mismatch is Error::Integrity, never a wrong answer.

The databases refuse, by trigger, UPDATE and DELETE on the log and its indexes. The exceptions are column-scoped: memo erasure (with its fingerprint) and a hold's single resolution. PostgreSQL also refuses TRUNCATE; SQLite refuses a REPLACE. Maintained values (balances, open-hold totals, dimension slices, applied clearings) are updated by the append and cannot be deleted; audit() recomputes them and compares every other table too.

No store can prevent the database owner from dropping a trigger. It makes the edit detectable: the row fails verification or audit(), and a rewritten history no longer matches a witnessed checkpoint.

Tracing

With the tracing feature, the façades open one span per operation (target doubleentry): record, record_batch, seal_period, audit, checkpoint at INFO, prove at DEBUG. Fields: the ledger id, the first 64 bytes of the key (or the period id), outcome (new, replay, ok, rejected, failed), the rejection code, the position (for a batch, the failing index). Stores add a DEBUG span per write and the SQL store one per read. Spans never carry an amount, a memo, an account path or a document reference; the crate emits no events.

Limits

  • MemoryStore is neither durable nor shared between processes.
  • No store replicates, shards or fails over: that is the database's job.