npm packages are not installable yet: [email protected] can be published after 2026-09-16 10:30 UTC. Details
Tollstile

Postgres

Keep authorizations, charges, their transition history, and replay claims in your own PostgreSQL database with @tollstile/postgres.

Public Beta · early access
npm install tollstile @tollstile/postgres pg
  • Bring your own client. You pass two functions, query and transaction. pg, postgres.js, Neon, and PGlite all work. No runtime dependencies.
  • Same behavior as memoryLedger(). Both run one conformance suite, including reservation accounting across reserved → settling → settled → refunded and unknown.
  • 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

toll.ts
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_, so postgresSchema.replaceAll("tollstile_", "billing_") is the DDL for tablePrefix: "billing_".

Schema changes in later releases ship as separate, additive migrations. The ledger never alters tables itself.

Options

OptionTypeDefault
query(sql, params) => Promise<{ rows }>requiredRuns one statement outside a transaction.
transaction(work) => Promise<T>requiredRuns work(query) in one interactive transaction on one connection.
tablePrefixstring"tollstile_"Lowercase letters, digits, underscores. Must match the schema you applied.
clock{ now(): Date }system clockDecides 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 start

PGlite

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

OperationStatementsWhy it is safe
openAuthorizationINSERT … ON CONFLICT (id) DO NOTHING, then SELECTThe id is derived from rail and proof, so a replayed proof finds the stored row.
createChargeOne transaction: lock the authorization, check it, insert the charge and its first history row, update reservedChecks and reservation happen under the row lock, so concurrent charges on one authorization are serialized.
transitionChargeOne transaction: lock the authorization, compare-and-set on both axes, append history, update reserved / consumedThe compare-and-set also runs in SQL, so even a writer that skipped the lock cannot overwrite a transition.
replaceAuthorizationDataOne UPDATE of dataCore calls it with the rail's redact output when a single-use charge becomes final, to drop evidence such as payer signatures.
pendingChargesSELECT on a partial indexThe index condition is generated from core's terminal states, so finished charges are never scanned.
spendSinceSELECT … GROUP BY currency on (payer, created_at)Excludes released, failed, and refunded.
claimINSERT … ON CONFLICT DO UPDATE … WHERE expires_at <= now RETURNINGOne statement: a live claim is untouched, an expired one is taken over.

Tables

TableHolds
tollstile_authorizationsWhat a payer authorized: rail, kind, limit, and the reserved / consumed totals of its charges.
tollstile_chargesEach economic effect: amount, payment × fulfillment state, pending operation, settlement and refund references. version counts its history rows.
tollstile_charge_transitionsAppend-only history. Version 1 is the creation; every transition adds a row in the same transaction.
tollstile_claimsSingle-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 createCharge status, 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 UPDATE waiting between connections. To verify on a real server, run the conformance suite with a pg.Pool of 10+ connections and fire 50 concurrent createCharge calls at one authorization: one created for a single-use authorization, and reserved_micros never above limit_micros.
  • The postgres.js and Neon adapters above, which were written from their documentation. Run the conformance file (test/ledger-conformance.ts in 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 fieldSQL columnMeaning
requestHashrequest_hash (nullable text)Request commitment for keyed charges; null without a key. Core compares it before admitting a keyed retry.
resultRefresult_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.

On this page