The starting point
Most codebases already have a state field, usually a plain string column:
model Invoice {
id String @id @default(cuid())
status String @default("draft") // <- this is what we're replacing
total Decimal
...
}Every place in the code that mutates invoice.statusis a potential race. There’s no audit trail (who moved it? when?). No way to answer “what was the state on Feb 3rd?” The migration to stateledger fixes all of this while keeping your existing column so nothing that reads it breaks.
Migration strategy: dual-write, then read from ledger
You never have to do a big-bang cutover. Roll out in three safe stages:
- • Stage 1: add stateledger, keep the status column. Every write updates both.
- • Stage 2: backfill history for existing rows. Read from stateledger, keep writing both.
- • Stage 3:drop the column (optional). Only when you’re confident nothing outside the code you control reads it.
Stage 1 — install and dual-write
// Install + define the machine matching your existing statuses.
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,
});
// Wherever code used to do: invoice.status = "sent"; await invoice.save();
// Do this instead:
export async function markSent(invoiceId: string, actor: string) {
return prisma.$transaction(async (tx) => {
// 1. Update the legacy column (keeps every read-path working)
await tx.invoice.update({
where: { id: invoiceId },
data: { status: "sent" },
});
// 2. Write to the ledger in the SAME transaction
const invoice = invoiceMachine.for(invoiceId, {
adapter: createPrismaAdapter(tx),
actor,
});
await invoice.transitionTo("sent");
});
}Stage 2 — backfill existing rows
Rows that already existed before Stage 1 have a legacy status but no ledger row. Seed them with one initial transition so the ledger’s readCurrent() matches reality:
// Backfill script | run once
import { prisma } from "./prisma";
import { createPrismaAdapter } from "@stateledger/prisma";
import { invoiceMachine } from "./machines";
async function backfill() {
const adapter = createPrismaAdapter(prisma);
const invoices = await prisma.invoice.findMany({
where: {
// Only rows that don't already have a ledger entry
NOT: { stateTransitions: { some: {} } },
},
select: { id: true, status: true },
});
for (const inv of invoices) {
// Seed the ledger with a "migration" actor
const machine = invoiceMachine.for(inv.id, {
adapter,
actor: "migration:backfill",
});
// The initialState is "draft" | if the row is already past draft,
// walk it forward through the transitions.
if (inv.status !== "draft") {
await machine.transitionTo(inv.status as never);
}
}
console.log(`Backfilled ${invoices.length} invoices`);
}
backfill().finally(() => prisma.$disconnect());Stage 3 — read from the ledger
Once backfill is complete, start reading the ledger as the source of truth. The legacy column becomes a mirror.
// Before:
const status = invoice.status;
// After:
const machine = invoiceMachine.for(invoice.id, { adapter });
const current = await machine.readCurrent();
const status = current?.state ?? "draft";Once no code reads invoice.statusdirectly anymore, drop the column in a follow-up migration | or keep it as a denormalized cache for cheap indexed queries (e.g. “count of active subscriptions”).
What to watch for
- • Illegal historical values. If your legacy
statuscolumn contains values that aren’t in your new machine’s state list, the backfill will fail on those rows. Clean them up before backfilling. - • Skipped states.If historical data shows an entity jumped through a transition that isn’t declared, you either need to add that transition to your machine or seed the entity at its current state without walking history.
- • Reads outside your codebase. Reports, admin dashboards, third-party integrations may all read the legacy column. Audit them before dropping.