Ledger overview

A player has 100 chips and places two 80-chip bets at the same moment. Two requests, both read a balance of 100, both see enough, both deduct. The player now has −60 chips, and you have a bug that only appears under load.

@pithy-sh/ledger gives every user a balance — chips, gold, gems, credits, tokens — in your own D1. Three properties make it safe, and the third is the one people leave out.

Atomic

A movement is one operation. There is no read-then-write window for a second request to slip into, so the scenario above resolves the way you would want: one bet succeeds, one is refused.

Idempotent

A payout delivered twice pays once. Every movement carries a reference, and a repeat of that reference is a no-op rather than a second credit.

That matters more than it sounds. A network timeout on a payout call leaves the caller unable to tell did not happen from happened and I did not hear, and the only safe response to that ambiguity is to retry — which is only safe if the retry is free.

Overdraft-safe by a database constraint

Not by a check in a handler. A handler is one merge away from a second writer, and the balance check is in the other code path is a sentence people say after the incident.

The constraint is on the table, so a movement that would take a balance below zero fails in the database. There is no route around it, including the one somebody adds next year.

Holds are what make wagering safe

A bet is not a debit. A bet is a hold: the stake is reserved the moment it is placed, and then either released — the bet was canceled — or captured, when the hand resolves.

Without holds, a player can place a bet, spend the same chips elsewhere while the hand is running, and be unable to pay when they lose. With them, the chips are unavailable from the moment they are staked, and the settlement is a state change rather than a race.

That is why this pairs with multiplayer: an authoritative game session resolves a hand, and a ledger settles it.

What it deliberately does not do

It is currency-agnostic, and that is a boundary rather than a feature. Whether your units map to money — and any regulation that implies — is entirely yours. This package does not know, does not ask, and will not tell you what your jurisdiction requires.

It is not a payment rail. Nobody buys anything here. payments sells a coin pack; this holds the coins. A product’s catalog entry can declare a balance grant, which is the seam between the two, and most products never touch one.

It is not double-entry accounting. There is no chart of accounts and no journal that balances across parties. It is a per-user balance with an auditable movement history.

It does not do exchange rates. Several currencies means several independent balances, and converting between them is a movement you author.

Balance-moving writes are server-authoritative

Reads scope to the caller: you can see your own balance. Moving another player’s balance needs an admin scope, which you mint for your own server-side code.

The default inverts the usual vendor posture, deliberately. A client that can credit itself is not a ledger.

When you would reach for it

An in-game currency. A credits system for an AI product where a request costs tokens. A rewards balance. A wagering table. Anything where a number attached to a user goes up and down, and being wrong about it is expensive.

Not for storing a fiat balance you owe somebody. That is a regulated activity in most places, and the constraint that keeps this correct is not the constraint that keeps that legal.

What it needs

No required peers.

auth is optional and effectively required: reads scope to the caller, so with no identity composed there is no caller to scope to and every route denies.

controlplane exposes the admin surface to a management client, and audit records movements when composed.

ESC