Guides

Migrating to stateledger

The starting point

Most codebases already have a state field, usually a plain string column:

typescript
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

typescript
// 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");
  });
}
Because both writes are in one transaction, they can never diverge. If either fails, both roll back.

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:

typescript
// 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());
Run backfill during low-traffic hours. The script writes ledger rows for every historical entity, which is one insert per row.

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.

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