Idempotency refs

The mechanism

Every operation takes a ref. It is written as a ledger row with a UNIQUE constraint across the entire ledger — the second of the three invariants.

A replay’s insert violates it, which aborts that transaction — so the movement applies exactly once and the operation returns the balance unchanged.

Not an error you have to handle. The right answer, arrived at by the shortest path.

Choosing one

Every call in Using Ledger takes one; the reference has the column it lands in.

A ref has to be the same string on every retry of one logical movement, and different from every other movement’s.

Which means it comes from the thing that happened, not from a clock or a random source — and a hold’s ref is also the handle you settle it by:

await ledger.credit("alice", "chips", 1000, "signup-bonus:alice");
await ledger.debit("alice", "chips", 50, "buyin:table-7:hand-3");
await ledger.transfer("alice", "bob", "chips", 30, "tip:xyz");
await ledger.hold("alice", "chips", 100, "bet:hand-9:alice");

Read those out loud and each one names an event. The signup bonus for alice. The buy-in for hand 3 at table 7. Two retries of that buy-in produce the same string; a different hand produces a different one.

Uniqueness is global, not per account

The constraint is on the ref column alone — not on the pair of ref and user.

So a ref has to be unique across every account and every currency. In practice that means prefixing it with what it is: buyin: and payout: and tip: are doing real work, not decoration.

A bare hand-3 used as both a buy-in and a payout ref is one movement, and the second one silently does nothing.

What a duplicate ref actually returns

The balance, unchanged. No throw, no partial write, and no second row.

Which is what lets a caller retry freely: a timeout with an unknown outcome is safe to repeat, because repeating it either performs the movement (it had not landed) or returns the balance (it had).

Holds are addressed by their ref

await ledger.hold("alice", "chips", 100, "bet:hand-9:alice");
await ledger.capture("bet:hand-9:alice");
// or
await ledger.release("bet:hand-9:alice");

The ref is the handle. There is no separate hold id to carry around, which means the string you chose when you placed the bet is the string that settles it — and a settlement path that can reconstruct the ref does not need to have stored anything.

Resolving a hold that is not open is ledger/hold_not_open, naming its current state. A hold captured twice is caught by the state, not by the unique constraint — the constraint already did its job when the hold was placed.

Every entry can carry a memo and a related ref

relatedRef is what ties the two halves of a transfer together, or a payout back to the wager it settles.

A ledger you can read is a ledger somebody can reconcile. The refs are what make it readable — an entry log whose rows say buyin:table-7:hand-3 answers questions that an entry log of amounts and timestamps cannot.

ESC