Adapters

@stateledger/outbox — transactional outbox for side effects

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

bash
pnpm add @stateledger/outbox

Works with the @stateledger/prisma or @stateledger/drizzle adapters, or directly on top of pg. Postgres-only.

Set up the outbox table

typescript
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.

typescript
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,
        },
      });
    },
  },
});
If the transition rolls back, the outbox row rolls back too. If the transition commits, the row is there for the worker to pick up. You never end up with an intent for a state change that didn’t happen.

Batch inserts

Need to enqueue several notes at once? Use enqueueBatch — one SQL round-trip, same atomicity contract.

typescript
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.

typescript
// 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 by kind. Unknown kinds are dead-lettered immediately.
  • • region — optional; only claim rows whose region matches (or NULL).
  • • 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 for claimed / 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 after maxAttempts.

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.

typescript
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:

sql
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:

sql
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.