Postgres
Keep authorizations, charges, their transition history, and replay claims in your own PostgreSQL database with @tollstile/postgres.
npm install tollstile @tollstile/postgres pg- Bring your own client. You pass two functions,
queryandtransaction.pg, postgres.js, Neon, and PGlite all work. No runtime dependencies. - Same behavior as
memoryLedger(). Both run one conformance suite, including reservation accounting acrossreserved → settling → settled → refundedandunknown. - Concurrency-safe. Every write to a charge locks its authorization row first, so concurrent requests cannot over-reserve an authorization. Of several requests racing for a single-use authorization, one wins.
Quick start with pg
import pg from "pg";
import { createTollstile, testRail } from "tollstile";
import { postgresLedger, postgresSchema } from "@tollstile/postgres";
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
await pool.query(postgresSchema); // once, or through your migration tool
const ledger = postgresLedger({
query: (sql, params) => pool.query(sql, params),
transaction: async (work) => {
const client = await pool.connect();
try {
await client.query("BEGIN");
const result = await work((sql, params) => client.query(sql, params));
await client.query("COMMIT");
return result;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
},
});
export const toll = createTollstile({ rails: [testRail()], ledger });Applying the schema
postgresSchema is a string of plain DDL: four tables, their indexes, and a header comment. Every statement uses IF NOT EXISTS, so applying it again is harmless.
-
At startup:
await pool.query(postgresSchema). Fine for small deployments. -
With a migration tool (node-pg-migrate, Drizzle, Prisma, Flyway, sqitch, Supabase): print the DDL once and commit it as a migration.
node --input-type=module -e "import('@tollstile/postgres').then((m) => console.log(m.postgresSchema))" > migrations/001_tollstile.sql -
Another table prefix: every identifier starts with
tollstile_, sopostgresSchema.replaceAll("tollstile_", "billing_")is the DDL fortablePrefix: "billing_".
Schema changes in later releases ship as separate, additive migrations. The ledger never alters tables itself.
Options
| Option | Type | Default | |
|---|---|---|---|
query | (sql, params) => Promise<{ rows }> | required | Runs one statement outside a transaction. |
transaction | (work) => Promise<T> | required | Runs work(query) in one interactive transaction on one connection. |
tablePrefix | string | "tollstile_" | Lowercase letters, digits, underscores. Must match the schema you applied. |
clock | { now(): Date } | system clock | Decides when claims have expired. Use the same clock as createTollstile. |
Driver adapters
query(sql, params) runs one statement with $1, $2, … parameters and resolves with { rows }, rows keyed by column name. Parameters are always strings or null; the ledger casts them in SQL and reads every bigint, jsonb, and timestamp back as text, so driver type parsers and session time zones never matter.
transaction(work) runs work inside one interactive transaction on one connection, committing when it resolves and rolling back when it rejects. The ledger takes SELECT … FOR UPDATE locks inside it, so it needs the default READ COMMITTED isolation. Under SERIALIZABLE, concurrent requests fail with serialization errors instead of waiting.
postgres.js
import postgres from "postgres";
const sql = postgres(process.env.DATABASE_URL);
const ledger = postgresLedger({
query: async (text, params) => ({ rows: await sql.unsafe(text, params) }),
transaction: (work) => sql.begin((tx) => work(async (text, params) => ({ rows: await tx.unsafe(text, params) }))),
});Behind PgBouncer in transaction mode, create the client with prepare: false.
Neon
Neon's HTTP driver (neon()) only runs non-interactive transactions, which cannot hold a row lock while the ledger decides. Use the WebSocket Pool, which is pg-compatible:
import { Pool } from "@neondatabase/serverless";
const pool = new Pool({ connectionString: env.DATABASE_URL }); // on Workers: per request
// then the pg adapter from the quick startPGlite
import { PGlite } from "@electric-sql/pglite";
const db = new PGlite("./ledger");
await db.exec(postgresSchema);
const ledger = postgresLedger({
query: (sql, params) => db.query(sql, params),
transaction: (work) => db.transaction((tx) => work((sql, params) => tx.query(sql, params))),
});How each operation stays correct
| Operation | Statements | Why it is safe |
|---|---|---|
openAuthorization | INSERT … ON CONFLICT (id) DO NOTHING, then SELECT | The id is derived from rail and proof, so a replayed proof finds the stored row. |
createCharge | One transaction: lock the authorization, check it, insert the charge and its first history row, update reserved | Checks and reservation happen under the row lock, so concurrent charges on one authorization are serialized. |
transitionCharge | One transaction: lock the authorization, compare-and-set on both axes, append history, update reserved / consumed | The compare-and-set also runs in SQL, so even a writer that skipped the lock cannot overwrite a transition. |
replaceAuthorizationData | One UPDATE of data | Core calls it with the rail's redact output when a single-use charge becomes final, to drop evidence such as payer signatures. |
pendingCharges | SELECT on a partial index | The index condition is generated from core's terminal states, so finished charges are never scanned. |
spendSince | SELECT … GROUP BY currency on (payer, created_at) | Excludes released, failed, and refunded. |
claim | INSERT … ON CONFLICT DO UPDATE … WHERE expires_at <= now RETURNING | One statement: a live claim is untouched, an expired one is taken over. |
Tables
| Table | Holds |
|---|---|
tollstile_authorizations | What a payer authorized: rail, kind, limit, and the reserved / consumed totals of its charges. |
tollstile_charges | Each economic effect: amount, payment × fulfillment state, pending operation, settlement and refund references. version counts its history rows. |
tollstile_charge_transitions | Append-only history. Version 1 is the creation; every transition adds a row in the same transaction. |
tollstile_claims | Single-use keys (nonces, replay windows) until expires_at. |
Money is integer micros (1 USD = 1,000,000) in bigint with a currency code, never floating point. Amounts outside 0 … 2^63 − 1 are refused with INVALID_AMOUNT. An authorization holds one currency: a charge or patched amount in another is refused with CURRENCY_MISMATCH. Timestamps are timestamptz; JSON is jsonb.
-- Revenue settled today, per currency
SELECT currency, sum(amount_micros)::numeric / 1000000 AS amount
FROM tollstile_charges WHERE payment = 'settled' AND updated_at >= current_date GROUP BY currency;
-- Everything that happened to one charge
SELECT * FROM tollstile_charge_transitions WHERE charge_id = $1 ORDER BY version;
-- Expired claims can be deleted at any time
DELETE FROM tollstile_claims WHERE expires_at < now() - interval '1 day';Verification status
Tested:
- The shared ledger conformance suite against PGlite 0.5.8 (PostgreSQL 17 compiled to WASM): every
createChargestatus, single-use busy versus a released retry, reusable capacity, compare-and-set conflicts, accounting through every payment state, currency and amount refusals,replaceAuthorizationData, history,pendingCharges,spendSince, claim expiry, and amounts up to 2^63 − 1. - End-to-end flows through
createTollstile: quote round-trip, replay refusal, retry after a failed handler, redaction after settlement, settlement timeout reconciled, crash recovery, and credits. - Rollback when a statement fails mid-transaction.
Not verified:
- Real lock contention. PGlite has one connection, so the concurrency tests confirm the outcome but not
FOR UPDATEwaiting between connections. To verify on a real server, run the conformance suite with apg.Poolof 10+ connections and fire 50 concurrentcreateChargecalls at one authorization: onecreatedfor a single-use authorization, andreserved_microsnever abovelimit_micros. - The postgres.js and Neon adapters above, which were written from their documentation. Run the conformance file (
test/ledger-conformance.tsin the repository) against them before production use. - PostgreSQL versions other than 17. The schema uses only features available since 9.5.
Idempotency records
The charge ID encodes the payer, idempotency key, and attempt number when a key is present. Repeated creation of that ID returns the existing charge. All instances serving the same payer must share the ledger; a per-process memory ledger cannot deduplicate across instances or restarts.
| Charge field | SQL column | Meaning |
|---|---|---|
requestHash | request_hash (nullable text) | Request commitment for keyed charges; null without a key. Core compares it before admitting a keyed retry. |
resultRef | result_ref (nullable text) | Reference supplied through payment.fulfill({ resultRef }), returned as result in already_paid; no handler response is stored. |
Ledgers persist both fields; transitions can update resultRef. Keep charge records for the period in which you need retry protection. See Idempotency.
Existing databases
The exported schema creates new tables; CREATE TABLE IF NOT EXISTS does not add columns to existing tables. If your database predates request_hash and result_ref, add the missing nullable text columns through a reviewed migration before running the current ledger. Do not invent request hashes for historical charges: their original requests were not recorded.