StateLedger
StateLedger
Database-backed state machines
v1.0.0 · MIT · @stateledger/outbox included

The database-backed state machine for Node & TypeScript.

stateledger persists every transition, audits who / when / from / to, and prevents two processes from racing the same entity. Paired with the built-in transactional outbox for side effects (Stripe, email, Kafka) that ship atomically with the state change or not at all. Built for payments, orders, subscriptions, and any Node.js backend where you need to prove exactly what happened, in what order.

Same pattern GoCardless open-sourced as Statesman for Ruby — now purpose-built for Node.

Not XState — XState runs in memory for UI. stateledger persists to Postgres for backend transitions. Different jobs.

bash
npm install @stateledger/core @stateledger/prisma
# or
yarn add @stateledger/core @stateledger/prisma
# or
pnpm add @stateledger/core @stateledger/prisma
What it looks like

Model a workflow. Transition safely. Query history.

Declare your states and legal transitions once. Every .transition() call writes to your database inside the same transaction as your business change | so the ledger can never drift from reality.

Behind the scenes: advisory locks per entity, transactional after-callbacks, and a complete audit row for every hop. Nothing to configure.

typescript
import { defineMachine } from "@stateledger/core";
import { createPrismaAdapter } from "@stateledger/prisma";
import { prisma } from "./prisma";

const invoiceMachine = defineMachine({
  name: "invoice",
  states: ["draft", "sent", "paid", "cancelled"],
  initialState: "draft",
  transitions: [
    { from: "draft", to: "sent" },
    { from: "draft", to: "cancelled" },
    { from: "sent",  to: "paid" },
    { from: "sent",  to: "cancelled" },
  ] as const,
});

const adapter = createPrismaAdapter(prisma);

// Bind the machine to a specific subject (e.g. invoice id):
const invoice = invoiceMachine.for("inv_0142", {
  adapter,
  actor: "user_abc",
});

// Concurrency-safe transition. Audits who / when / from / to.
await invoice.transitionTo("paid", {
  metadata: { reason: "Stripe webhook confirmed" },
});

// Read current state:
const current = await invoice.readCurrent();

// Time-travel: what was the state at a given moment?
const past = await invoice.stateAt(new Date("2026-06-01"));
Why stateledger

The boring, audit-friendly kind of state machine.

Persisted, not in-memory

Every transition writes to your database in the same transaction as your business change. If the app crashes mid-flow, state stays consistent | no lost updates, no phantom retries.

Concurrency-safe by default

PostgreSQL advisory locks prevent two processes from racing the same entity. No double-transitions, no lost writes, no re-inventing pessimistic locking in every service.

Full audit trail

Who transitioned this entity, when, from which state, to which state, and why. Persisted forever, queryable without extra infrastructure. Compliance-ready out of the box.

Time-travel queries

Ask what state any entity was in at any past timestamp. Useful for compliance replays, dispute investigation, and reproducing exactly what a user saw when they filed a ticket.

ORM-agnostic core

Bring your own storage adapter. First-party support for Prisma, Drizzle, and an in-memory adapter for tests. Adapters are ~100 lines of code | rolling your own for TypeORM or Kysely is trivial.

Transactional outbox included

@stateledger/outbox writes side-effect intents (Stripe, email, Kafka) inside the same transaction as the state change. A worker delivers them at-least-once with backoff and a dead-letter queue. Ship your state and your side effects atomically.

Boring on purpose

The finite state machine kind, not the blockchain kind. Zero cryptography, zero consensus, zero P2P. Just correct, auditable transitions for payments, orders, and workflows.

Use cases

Built for the backends that keep score.

Wherever a database record moves between well-defined states and someone downstream will ask “when did that change, and who did it?”

Payment state machines

Model the lifecycle of a charge, invoice, or refund. stateledger keeps Stripe/webhook races from double-charging by holding an advisory lock while it transitions.

Order & fulfilment workflows

Track carts → paid → picked → packed → shipped → delivered → returned with a full ledger of who moved each order and when.

Subscription lifecycles

trialing → active → past_due → cancelled with recovery paths. Every renewal attempt writes an audit row, so failed dunning is debuggable months later.

KYC & onboarding

unverified → pending → approved → suspended. Regulators want to know who approved what and when. stateledger gives you that answer in one query.

Content moderation queues

submitted → in_review → published → flagged → removed. Concurrency-safe means two moderators never accidentally act on the same item.

Multi-step approval flows

Any business process where an entity moves between well-defined states and you need to prove the audit trail later. Contracts, expense reports, medical records, disbursements.

How stateledger compares

Where stateledger fits vs. the alternatives.

vs XState

XState is brilliant for UI state and orchestration in a single process. stateledger complements it: use XState in the client and stateledger for the server-side persisted history. The two are not competitors.

vs event sourcing

Event sourcing replays events to derive current state. stateledger stores current state directly plus an append-only transitions log. Much less machinery, no snapshot cadence to tune, but you lose the ability to reshape past events. Fair tradeoff for most CRUD-shaped businesses.

vs writing it yourself

You end up rebuilding advisory locks, audit rows, transition validation, and time-travel every time. stateledger is what you'd have written on your fifth attempt, distilled into 4 npm packages.

Packages

One core, pick your ORM adapter.

Examples

Runnable code, real Postgres, no hand-waving.

Two examples ship in the monorepo. Both spin up Postgres in Docker, apply the schema, and run scripted scenarios end-to-end.

Paid integration & consulting

Stuck integrating stateledger? We'll do it for you.

stateledger is open source and free forever. But if you'd rather have the team that wrote it embed it into your Node.js backend | schema design, migration planning, first machines, adapter for a bespoke ORM, or a Postgres tuning pass | we do fixed-scope consulting. Typical engagement: 1 to 3 weeks.

FAQ

Answers to the questions people ask before shipping.

What is stateledger?

stateledger is a database-backed state machine library for Node.js and TypeScript. It gives you a finite state machine engine that persists every transition to your database, provides advisory-lock concurrency safety, keeps a full audit trail, and lets you query historical state at any past timestamp.

Which databases does stateledger support?

stateledger has first-party adapters for PostgreSQL via Prisma and Drizzle. The core is ORM-agnostic, so any database your ORM supports works. An in-memory adapter is included for tests. Adapters for other ORMs (Kysely, TypeORM, MikroORM) are ~100 lines to build.

How is stateledger different from XState?

XState is a client-side and orchestration state-machine library. stateledger is a server-side, persisted, audit-friendly state machine designed for business entities like invoices, orders, and subscriptions that live in your database. Use both together if you need orchestration in the UI plus a persistent ledger in the backend.

Does stateledger use event sourcing?

No. stateledger stores current state directly on your entity, plus an append-only transitions log. This gives you audit history without the operational complexity of full event sourcing (snapshots, replays, projections).

Is stateledger safe under concurrent load?

Yes. Every transition acquires a PostgreSQL advisory lock scoped to the entity ID, ensuring only one process can move an entity between states at a time. Combined with transactional writes, this eliminates the classic double-transition race that most hand-rolled state machines suffer from.

How does stateledger handle side effects like sending an email or calling Stripe?

@stateledger/outbox implements the transactional outbox pattern. Instead of firing the side effect directly (which risks either a phantom action or state drift on rollback), you enqueue an intent into an outbox table inside the same transaction as the state change. A worker process claims rows with FOR UPDATE SKIP LOCKED, dispatches your handler, and marks the row delivered — with exponential backoff on failure and a dead-letter queue after N attempts. Ships with v1.0.

Is stateledger production-ready?

Yes. stateledger is at v1.0.0 as of the outbox helper release. The core state-machine engine and the Prisma / Drizzle adapters are stable and used in production. The outbox helper ships with 26 real-Postgres integration tests covering atomicity, retry, dead-letter, concurrency, and stuck-row reclaim. MIT licensed.

Ship state you can trust.

Install @stateledger/core and an adapter. Fifty lines of setup, one migration, and every business transition on your Node.js platform is persisted, audited, and race-safe.