With a database

The same calls on a Ledger over SQLite: create or open, retry safely, keep books durable.

On this page
  1. What to call
  2. Create or open
  3. Record, in batches, and retry
  4. Without an async runtime of your own
  5. PostgreSQL and memory
  6. Limits

What to call

Ledger — the Journal's methods, async and on &self, over a store: SqlStore with Sqlite (feature sqlite) or Postgres (feature postgres), or MemoryStore.

cargo add doubleentry --features sqlite
cargo add tokio --features rt-multi-thread,macros

The sqlite feature bundles SQLite; nothing is installed on the machine.

Create or open

SqlStore::sqlite(url) only connects (sqlite://path/to/books.db is created if missing). Ledger::new(store, id) creates the ledger when the database holds none and opens it otherwise; a database holding another ledger id, or this one at another scale, is refused as Error::Config without reading or changing anything. With idempotent setup, the first start and the hundredth run the same code.

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

let store = SqlStore::<Sqlite>::sqlite("sqlite::memory:").await?;
let books = Ledger::new(store.clone(), LedgerId::new("example.com/acme")?).await?;

for _start in 0..2 {
    // The setup a service runs on every start: the second run writes nothing.
    books.declare_currency(EUR, 2, date!("2026-01-01")).await?;
    books.define_months(2026, 1..=12).await?;
    books.open("Assets:Bank", AccountKind::Asset, date!("2026-01-01")).await?;
    books.open("Income:Sales", AccountKind::Income, date!("2026-01-01")).await?;
}
assert_eq!(books.head().await?.size, 16); // genesis, currency, 12 months, 2 accounts

let again = Ledger::new(store.clone(), LedgerId::new("example.com/acme")?).await?;
assert_eq!(again.head().await?, books.head().await?);
let other = Ledger::new(store, LedgerId::new("example.com/other")?).await;
assert!(matches!(other, Err(Error::Config(_))));

A Ledger is a cheap clone sharing its store; hand one to every task.

Record, in batches, and retry

The store runs the rule core inside its write transaction, under the ledger's single-writer lock, so the state an entry is checked against is the state it is appended to. record_batch writes many drafts in one transaction: all or nothing.

A backend failure says whether a retry may succeed (is_transient) and whether the write may have committed although it failed (may_have_applied). Either way, record the same draft again: a committed entry comes back with replayed == true, an uncommitted one is written once.

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

async fn book(books: &Ledger<SqlStore<Sqlite>>, entry: Entry) -> Result<Recorded> {
    let mut attempt = 0;
    loop {
        match books.record(entry.clone()).await {
            Err(e) if (e.is_transient() || e.may_have_applied()) && attempt < 3 => attempt += 1,
            outcome => return outcome,
        }
    }
}

let store = SqlStore::<Sqlite>::sqlite("sqlite::memory:").await?;
let books = Ledger::new(store, LedgerId::new("example.com/shop")?).await?;
books.declare_currency(EUR, 2, date!("2026-01-01")).await?;
books.define_months(2026, 3..=3).await?;
books.open("Assets:Bank", AccountKind::Asset, date!("2026-01-01")).await?;
books.open("Income:Sales", AccountKind::Income, date!("2026-01-01")).await?;

let sale = Entry::new("psp-0001", date!("2026-03-15"))
    .debit("Assets:Bank", amount!("25.00"), EUR)
    .credit("Income:Sales", amount!("25.00"), EUR);
assert!(!book(&books, sale.clone()).await?.replayed);
assert!(book(&books, sale).await?.replayed); // delivered twice, booked once

let day: Vec<Entry> = (2..=4)
    .map(|n| {
        Entry::new(format!("psp-000{n}"), date!("2026-03-16"))
            .debit("Assets:Bank", amount!("10.00"), EUR)
            .credit("Income:Sales", amount!("10.00"), EUR)
    })
    .collect();
assert_eq!(books.record_batch(day).await?.len(), 3);

let bank = books.balance("Assets:Bank", EUR, &BalanceQuery::all()).await?;
assert_eq!(bank.net(), amount!("55.00"));
books.audit().await?; // replay the log and compare every table

The full service is examples/sqlite_service.rs.

Without an async runtime of your own

The SQL stores run on tokio (through sqlx). A synchronous program builds a runtime and hands each call to block_on.

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

let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
let store = runtime.block_on(SqlStore::<Sqlite>::sqlite("sqlite::memory:"))?;
let books = runtime.block_on(Ledger::new(store, LedgerId::new("example.com/cli")?))?;
runtime.block_on(books.declare_currency(EUR, 2, date!("2026-01-01")))?;
assert_eq!(runtime.block_on(books.exponent(EUR))?, Some(2));

PostgreSQL and memory

SqlStore<Postgres> (feature postgres) keeps one ledger per PostgreSQL schema — doubleentry by default, another through SqlOptions or with_schema. The crate enables no TLS stack; see Features and MSRV. Roles and privileges are on PostgreSQL.

use doubleentry::prelude::*;
use doubleentry::store::{Postgres, SqlStore};

let store = SqlStore::<Postgres>::postgres("postgres://app:secret@db.internal/books").await?;
let books = Ledger::new(store, LedgerId::new("example.com/acme")?).await?;
books.audit().await?;

MemoryStore is the same store contract in memory, for tests and embedded programs; it persists nothing. Ledger::to_journal() replays a store's log into a Journal.

Limits

  • No migrations: a database of another schema version is refused when opened (Stores).
  • One writer per ledger: writes to one ledger are serialised. Reads do not wait for a writer.
  • Durability is the database's (Backup and restore).