Transitions
Generated state-machine actions — guards, stamps, undo pairs, and cascades declared on the table.
Overview
Most state changes follow the same shape: check the row is in a legal state,
flip a column, stamp who/when, maybe cascade. Before transitions v2 every one
of those was a hand-written action file — a podcast app had ten files that
were byte-identical except the table name (publish.ts / revertToDraft.ts
across five entities), and an events app had eight near-identical
check-in/check-out files.
Transitions v2 collapses both patterns:
- Action-level extensions on
defineAction({ transition })— guards, stamps, idempotency, compiler-applied writes. - Table-level
transitionsondefineTable— the compiler generates the action files, includingundoinverses andonEntercascades.
The marquee example: 10 files → 2 lines each
Before (×10 files across episodes/shows/seasons/collections/profiles):
// actions/episodes/publish.ts — and nine siblings like it
export default defineAction({
description: "Publish an episode",
input: z.object({}),
access: { roles: ["admin+"] },
async execute({ db, record }) {
const [updated] = await db.update(episodes)
.set({ isPublished: true })
.where(eq(episodes.id, record.id)).returning();
return updated;
},
});After — on each entity's defineTable:
export default defineTable(episodes, {
// ...
transitions: {
publish: { field: "isPublished", from: false, to: true, access: { roles: ["admin+"] } },
revertToDraft: { field: "isPublished", from: true, to: false, access: { roles: ["admin+"] } },
},
});The generated actions mount exactly where the hand-written files did
(POST /episodes/:id/publish, operationId episodesPublish, same MCP tool
name), ride the same OpenAPI/security-contract pipeline, and delete cleanly:
an explicit actions/<name>.ts file always wins (the compiler warns about
the shadowed transitions entry).
Action-level extensions
All new keys are optional — a plain { field, fromTo, to|via } transition
behaves exactly as before.
transition: {
field: "status",
to: "confirmed",
fromTo: { pending: ["confirmed"] },
// v2:
guard: { // extra row preconditions
status: "confirmed", // equality
checkedInAt: { null: true }, // or { notNull: true }
custom: (record) => record.eventId !== null, // escape hatch; may throw ActionError
},
onIllegal: { code: "GUEST_NOT_CONFIRMED", status: 409, message: "Only confirmed guests can be checked in" },
idempotent: "noop", // already-in-target → { success: true, unchanged: true }
stamp: { at: "checkedInAt", by: "checkedInById" }, // now-ISO / ctx.userId
clears: ["checkedOutAt", "checkedOutById"], // nulled on transition
}Semantics:
- Guards run against the fetched record and fold into the UPDATE's
WHERE, so preconditions re-validate atomically at the SQL layer (an empty
RETURNINGmaps to 409ACCESS_TRANSITION_LOST). Guard failures returnonIllegal(default 409TRANSITION_GUARD_FAILED); on bulk twins they report per-id in the batch envelope. - Compiler-applied writes: declaring
stamp/clears(orapplyBefore: true) makes the compiler execute the UPDATE itself beforeexecute— andexecutebecomes optional. An omittedexecutereturns{ success: true, <field>: <to>, <stamp.at>: <now> }. SetapplyBefore: falseto keep the write in your ownexecute(today's behavior). field: nulldeclares a pure stamp transition (check-in/check-out style) — guards + stamps with no state column.actx.now: every action's execute context now carriesnow— one ISO timestamp per invocation. Prefer it overnew Date().toISOString().
Table-level generation
export default defineTable(guests, {
// ...
transitions: {
checkIn: {
field: null, // pure stamp transition
guard: { status: "confirmed", checkedInAt: { null: true } },
stamp: { at: "checkedInAt", by: "checkedInById" },
undo: "cancelCheckIn", // generates the inverse
access: { roles: ["member+"] },
bulkVariant: true,
},
cancel: {
field: "status",
fromTo: { published: ["cancelled"] },
to: "cancelled",
stamp: { at: "cancelledAt" },
access: { roles: ["admin+"] },
onEnter: [
{ update: "eventGuests",
set: { status: "rescinded" },
where: { eventId: "$record.id", status: { in: ["invited", "confirmed"] } } },
{ enqueue: { queue: "COMMS_QUEUE",
message: { kind: "eventCancelled", eventId: "$record.id", actorId: "$ctx.userId" } } },
],
},
},
});undo: "<name>"generates the inverse action: the guard flips to{ notNull: true }on the stamp column, the SET nulls the stamp columns, andfrom/toreverse for field transitions.onEntersteps run in the generatedexecute, after the compiler-applied primary write (parent row first).updatesteps go through the scoped db — cross-feature targets resolve automatically and inherit the firewall.enqueuesteps ride the after-commit effects queue. Substitution values are a strict allowlist —$record.<col>,$ctx.userId,$ctx.activeOrgId,$ctx.<scope>.id— always parameterized, never interpolated into SQL.accessis required on every transition (fail-closed, likedefineAction).- Boolean
fromvalues match integer-boolean columns too (from: falsematches bothfalseand0).
Not yet supported (fail loudly)
guard.custom at table level, onEnter.enqueue with perRowOf or function
messages, and via-style (input-driven) targets all error with a pointer to a
hand-written action. For manual fan-out enqueues, sendQueueBatched in
lib/effects chunks at the Cloudflare Queues 100-message cap.