1. Install the packages
stateledger has one required package (@stateledger/core) and one per database adapter. For a Postgres app using Prisma:
npm install @stateledger/core @stateledger/prisma
# or
yarn add @stateledger/core @stateledger/prisma
# or
pnpm add @stateledger/core @stateledger/prismaUsing Drizzle instead? Replace @stateledger/prisma with @stateledger/drizzle. Writing tests without a real database? Use @stateledger/memory. Need atomic side effects (Stripe, email, Kafka)? Add @stateledger/outbox — see the outbox docs.
2. Create the transitions table
stateledger stores every transition in one append-only table. The Prisma adapter ships with a schema SQL snippet you can run once:
import { STATELEDGER_SCHEMA_SQL } from "@stateledger/prisma";
import { prisma } from "./prisma";
// Run once, e.g. in a migration script:
await prisma.$executeRawUnsafe(STATELEDGER_SCHEMA_SQL);prisma migrate dev) or Drizzle migration file so it lives in your migration history.3. Define your state machine
Machines are declared with defineMachine. Every state and legal transition is listed up front so TypeScript can narrow the union for you.
import { defineMachine } from "@stateledger/core";
export 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,
});The as const is important | it lets stateledger infer the exact state and transition literals so transitionTo autocompletes only reachable targets.
4. Bind an adapter and transition
A machine definition is inert. Bind it to a specific subject id (the entity you want to transition) plus an adapter, and you get a live machine instance.
import { createPrismaAdapter } from "@stateledger/prisma";
import { prisma } from "./prisma";
import { invoiceMachine } from "./machines";
const adapter = createPrismaAdapter(prisma);
// Bind to invoice inv_0142:
const invoice = invoiceMachine.for("inv_0142", {
adapter,
actor: "user_abc", // who's making the change (stored on every row)
});
// Move it from draft → sent:
await invoice.transitionTo("sent", {
metadata: { sentBy: "email", to: "client@example.com" },
});
// Read where it is now:
const current = await invoice.readCurrent();
// → { state: "sent", actor: "user_abc", at: <Date>, metadata: {...} }5. What happens under the hood
- • Advisory lock acquired for
subjectIdso no other process can transition this entity while you're inside the call. - • Read current state from the transitions table.
- • Validate the requested transition is legal from the current state.
- • Insert a new row in the transitions table with the new state, timestamp, actor, and metadata.
- • Release the lock.
Next steps
- • Full
@stateledger/prismausage → Prisma adapter guide - • Query historical state → Time-travel queries
- • Concurrency model → Advisory locks