Contents

Storage: the Store and engines

The Store gathers the ledger and its sub-stores behind one transaction boundary. Every adapter passes the same conformance suite, and the SQL engines enforce the invariants a second time, in the database.

Source src/ports.ts#L310Storesrc/ports.ts#L217Ledgersrc/adapters/memory.ts#L1262memoryStoresrc/engines/postgres.ts#L1685postgresStore

The seam between the core and the world

Everything the economy persists passes through one of a few narrow interfaces. The core never opens a connection and never knows whether it’s talking to memory or Postgres. It calls a method on a port, and an adapter does the rest.

These ports all live in src/ports.ts. This page covers the ones that hold state: the Store (the ledger and its sub-stores) and the read-through Cache. The ports that move messages — the outbox, the inbox, and the Dispatcher — write through the same Store and have their own page.

The rules that keep money conserved are written once, not re-implemented per backend. They’re verified against every adapter, and the SQL engines push them down into the database as well.

The Store

The Store is the one object that gathers everything the system reads and writes. It exposes the ledger plus a set of named sub-stores, and a transaction method that runs a block of work across all of them atomically.

The ledger is the append-only, double-entry book itself, see accounts and double-entry for what a Posting and its Legs are. The Ledger interface records a Posting with append, reads a balance, pages a statement, and streams history for the integrity prover to replay.

Everything else hangs off the Store as a purpose-built sub-store, one per concern:

Sub-storeWhat it holds
idempotencyA claim per idempotencyKey, so a repeated request runs at most once.
salesA summary of each completed sale, keyed by order id, so a refund can reverse it precisely.
outboxPending outgoing events, written with the money move and relayed later.
inboxVerified inbound provider events, awaiting apply.
sagasEach in-flight payout saga and its state.
subscriptionsRecurring subscriptions and when each is next due to bill.
entitlementsWhich users own which SKUs.
promosEach promo grant, so the worker can reverse the unspent remainder at expiry.
trustEach subject’s recent spending, the input to the risk gate.
checkpointsSigned ledger snapshots, written only by the worker.
replayRaw inbound webhook ids, deduped separately from the domain idempotency keys.

The outbox and inbox rows are storage; what flows through them is messaging.

Atomicity is the contract

The reason all of these live behind one Store is transaction. When an operation handler posts money, it doesn’t just write a ledger entry. It may also record the idempotency key, enqueue an outbox event, open a saga, and grant an entitlement. Those writes have to commit together or not at all.

So transaction hands the handler a Unit: the subset of sub-stores that may participate in one database transaction. Everything the handler writes through that Unit commits as a group:

await store.transaction(async (unit) => {
  await unit.ledger.append(posting);
  await unit.idempotency.record(key, transaction);
  await unit.outbox.enqueue(message);
});

The Unit is deliberately narrower than the full Store. The trust and checkpoints stores are missing on purpose. Risk velocity is recorded outside the money transaction (so even a denied attempt still counts toward the limit), and checkpoints are written only by the worker. The replay store is absent for the same reason: the webhook dedupe check runs before any money moves.

One contract, every adapter

A port is only useful if every adapter behaves the same way. economy-lab pins that down with a single conformance suite, runStoreConformance, that every Store adapter is run through. Memory, Postgres, MySQL, and the HTTP adapter all execute the identical test bodies in test/conformance/store.ts.

The same posting, the same overdraft rejection, and the same hash chain come back from each backend, so the choice of backend is an infrastructure decision, not a behavioral one.

The in-memory default

With no DATABASE_URL set, the system runs on memoryStore(): plain JavaScript Maps, no database, no setup. It’s the zero-infrastructure default for tests and local development.

It’s also the reference implementation. Its hash function and clock default to deterministic versions, so a bare memoryStore() is reproducible run to run, and the conformance suite treats it as the oracle the other adapters must match.

Postgres and MySQL

Point DATABASE_URL at a postgres:// or mysql:// DSN and the system loads that engine instead, and only then loads its driver. The SQL engines live in src/engines/postgres.ts and src/engines/mysql.ts; both pass the same conformance suite as memory.

The SQL engines do something the in-memory one can’t: they enforce the invariants a second time, in the database. Every CHECK constraint in the schema is also a rule in the TypeScript code, so a user account can’t go negative and a posting’s legs can’t fail to sum to zero, even if a raw INSERT tried to write around the application. The conservation half is a deferred constraint trigger that re-sums a posting’s legs at commit (see legs_conserve):

create or replace function check_conservation() returns trigger as $$
begin
  if (select coalesce(sum(amount), 0)
        from legs
       where posting_id = new.posting_id and currency = new.currency) <> 0 then
    raise exception 'conservation: posting % legs in % do not net to zero',
      new.posting_id, new.currency;
  end if;
  return null;
end;
$$ language plpgsql;

create constraint trigger legs_conserve
  after insert on legs
  deferrable initially deferred
  for each row execute function check_conservation();

That belongs to integrity, which owns the conservation and no-overdraft invariants. The storage layer is just where the database half of them is declared. Money is stored as whole minor units in BIGINT columns, returned as JavaScript BigInt, never as a float that could drift.

The Cache

The Cache is a read-through key/value port for hot reads, mainly balances. It’s strictly an accelerator: the core works without one, and it can only ever speed reads, never break them.

That guarantee is built into the contract. When no cache is injected, the read path skips it and goes straight to the ledger. When a cache is present but errors, the read degrades to a direct ledger read rather than failing the request.

Two adapters back it. memoryCache is an in-process Map: the zero-infrastructure reference, with its own conformance suite the way memoryStore is the reference for the Store. redisCacheFrom in src/adapters/redis.ts adapts an already-connected ioredis client; setting REDIS_URL wires it in. Either way the cache stores opaque strings and never parses them.

What the host owns

These ports define the seam; the infrastructure behind them is the host’s. The store and cache adapters are economy-lab’s own and live in this repo: the in-memory reference, the Postgres and MySQL engines, the Redis and HTTP adapters, and the conformance suite that holds them all to one contract. A fair amount of the work here is making each driver fit that contract cleanly. What the host owns is the live infrastructure on the far side of each port: it creates and tunes the database pool, opens the Redis connection, and applies the schema. economy-lab loads a driver only when a DSN selects it, and never reaches for a connection the host didn’t ask for.

The drivers themselves (pg, mysql2, and ioredis) are peer dependencies, so the host pins the versions it runs. Each adapter types its driver’s surface structurally, so the file compiles and unit-tests without the package installed; the real client is constructed only when the matching env var is set.

See also