Why this exists
Your state machine transitions inside a database transaction — safe. But real apps also need to do things outside the database: call Stripe, send an email, publish to Kafka, hit an SMS gateway.
Do the side effect before the transaction commits and you can end up with a phantom action when the tx rolls back. Do it after and you can end up with a state change that has no matching side effect when your process crashes at the wrong moment. Either way, state and side effects drift.
@stateledger/outbox closes the gap. Instead of doing the side effect directly, you write an intent to an outbox table inside the same transaction as the state change. A worker picks it up later and dispatches for real — with retries, exponential backoff, and a dead-letter queue after N failures.
Install
pnpm add @stateledger/outboxWorks with the @stateledger/prisma or @stateledger/drizzle adapters, or directly on top of pg. Postgres-only.
Set up the outbox table
import { OUTBOX_SCHEMA_STATEMENTS } from "@stateledger/outbox";
import { prisma } from "./prisma";
// One-off setup — idempotent, safe to re-run.
for (const stmt of OUTBOX_SCHEMA_STATEMENTS) {
await prisma.$executeRawUnsafe(stmt);
}The table is called stateledger_outbox. Every row has a kind, a JSON payload, and a status (pending → processing → delivered, or failed after dead-lettering).
Enqueue from a callback
The enqueue helper takes the same tx your after-callback receives — so the outbox row commits atomically with the transition write, or rolls back with it.
import { defineMachine } from "@stateledger/core";
import { enqueue } from "@stateledger/outbox";
const paymentMachine = defineMachine({
name: "payment",
states: ["pending", "authorized", "captured"] as const,
initialState: "pending",
transitions: [
{ from: "pending", to: "authorized" },
{ from: "authorized", to: "captured" },
] as const,
callbacks: {
"after:authorized->captured": async ({ tx, subject }) => {
await enqueue(tx, {
kind: "stripe.capture",
payload: {
paymentIntentId: (subject as any).stripeId,
amount: (subject as any).amount,
},
});
},
},
});Batch inserts
Need to enqueue several notes at once? Use enqueueBatch — one SQL round-trip, same atomicity contract.
import { enqueueBatch } from "@stateledger/outbox";
await enqueueBatch(tx, [
{ kind: "receipt.email", payload: { userId } },
{ kind: "analytics.event", payload: { event: "checkout_complete" } },
]);Run a worker
The worker is a plain Node process you run alongside your API — under pm2, systemd, Kubernetes, Fly, your choice. It polls the outbox table, claims one row at a time, dispatches it through your handler registry, and marks it delivered.
// worker.ts
import { createWorker } from "@stateledger/outbox";
import { PrismaClient } from "@prisma/client";
import { stripe, mailer } from "./services";
const prisma = new PrismaClient();
const worker = createWorker({
client: prisma,
handlers: {
"stripe.capture": async (payload: { paymentIntentId: string; amount: number }) => {
await stripe.paymentIntents.capture(payload.paymentIntentId, {
amount_to_capture: payload.amount,
});
},
"receipt.email": async (payload: { userId: string }) => {
await mailer.send({ template: "receipt", userId: payload.userId });
},
},
onEvent: (event) => {
if (event.type === "dead-lettered") {
console.error("[outbox] DLQ:", event.record.kind, event.record.id, event.error);
}
},
});
worker.start();
// Graceful shutdown | waits for the in-flight dispatch to finish first.
process.on("SIGTERM", async () => {
await worker.stop();
await prisma.$disconnect();
process.exit(0);
});Run multiple workers if you want throughput or redundancy — FOR UPDATE SKIP LOCKED in the claim query guarantees each row is dispatched by at most one worker at a time.
Config reference
- •
client— Postgres client (Prisma, Drizzle, pg shim). - •
handlers— registry of async functions keyed bykind. Unknown kinds are dead-lettered immediately. - •
region— optional; only claim rows whoseregionmatches (orNULL). - •
pollIntervalMs— wait between empty polls. Default 500ms. - •
maxAttempts— attempts before dead-letter. Default 5. - •
backoffMs(attempt)— delay before retry. Default: exponential 1s, 2s, 4s, 8s, 16s, capped at 5 min. - •
onEvent(event)— observer forclaimed/delivered/rescheduled/dead-lettered/unknown-kind/worker-error.
Handler contract — idempotency
Handlers mustbe idempotent. Delivery is at-least-once: if the worker crashes between “call Stripe” and “mark delivered,” another worker will retry the same row later. Rely on the receiver’s idempotency key (Stripe supports it; most others do too).
- • Return normally → the row is marked
delivered. - • Throw → the worker increments
attempts, reschedules with backoff, or dead-letters aftermaxAttempts.
Regional routing
If you’re calling regionally-hosted services (MTN Mobile Money in Cameroon, Twilio in the US, Stripe EU in Europe), tag each note with a region and run one worker per region. Rows with region = NULL are picked up by any worker.
await enqueue(tx, {
kind: "mtn.mobilemoney.charge",
payload: { phone: "+237...", amount: 5000 },
region: "af-central",
});
// Worker A — deployed in Africa:
createWorker({ client: prisma, region: "af-central", handlers: { /* ... */ } }).start();
// Worker B — deployed in EU:
createWorker({ client: prisma, region: "eu-west", handlers: { /* ... */ } }).start();Dead-letter queue recovery
The DLQ is just rows with status = 'failed'. Query them however you like:
SELECT id, kind, last_error, created_at
FROM stateledger_outbox
WHERE status = 'failed'
ORDER BY created_at DESC;Fix the underlying issue, then re-queue by resetting the row:
UPDATE stateledger_outbox
SET status = 'pending', attempts = 0, last_error = NULL, available_at = NOW()
WHERE id = '<uuid>';Failure-mode reference
- External API returns 500. Handler throws →
attempts++→ reschedule with backoff. - Worker crashes mid-dispatch. Row stays in
processing; the next worker reclaims it after 5 minutes of no activity. - Invalid payload / permanent failure. Dead-lettered after
maxAttempts. Kept for manual review. - No handler for
kind. Dead-lettered immediately — retrying can’t fix missing code. - Business tx rolls back. Outbox row rolls back too. No orphan intent.