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
import { z } from "zod";
import { eq } from "drizzle-orm";
import { defineAction, episodes } from "../../.quickback/define-action.episodes";
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:
// features/podcast/episodes.ts
import { q, defineTable } from '@quickback/compiler';
export const episodes = q.table('episodes', {
id: q.id(),
title: q.text().required(),
isPublished: q.bool().default(false).required(),
organizationId: q.scope('organization'),
...q.audit(),
...q.softDelete(),
});
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().
On a feature-area route
A transition normally lives on a table-bound record action (POST /:id/{action}).
To attach the same compiler-owned transition to an explicit (path:) action mounted
under a feature area — a nested route like
POST /events/:eventId/documents/:documentId/publish — pair the transition with a
record: binding. The compiler binds the record id from the proven route param,
firewalls + area-matches the row, then runs the exact machinery above (guarded UPDATE,
noop short-circuit, guards, stamps) before the action's own execute. See
Feature-area transition adapter.
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.
Transition (state-machine policy)
For protected fields that follow a state machine — application status, invoice
lifecycle, order fulfilment — use transition on the action so the compiler
generates a runtime guard that enforces the from/to pair, not just the
current state. Without it, an advance action whose input enum allows
["screening", "interview", "offer"] and whose access.record allows
status in ["applied", "screening", "interview"] will happily run
applied → offer (a skip) or interview → screening (a regression).
// features/applications/actions/advance.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";
import { applications } from "../applications";
export default defineAction({
description: "Move an application forward in the pipeline.",
input: z.object({ nextStatus: z.enum(["screening", "interview", "offer"]) }),
access: { roles: ["owner", "admin"] },
transition: {
field: "status",
via: "nextStatus", // read target from input.nextStatus
fromTo: {
applied: ["screening"],
screening: ["interview"],
interview: ["offer"],
},
},
async execute({ db, record, input, whereTransition }) { /* … */ },
});// features/applications/actions/hire.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";
import { applications } from "../applications";
export default defineAction({
description: "Finalize an offer.",
input: z.object({}),
access: { roles: ["owner"] },
transition: {
field: "status",
to: "hired", // fixed target — same value every call
fromTo: { offer: ["hired"] },
},
async execute({ db, record, whereTransition }) { /* … */ },
});| Field | Required | Description |
|---|---|---|
field | Yes | Record column to validate (e.g. "status") |
fromTo | Yes | Map of current value → permitted next values. Keys also enumerate the legal source states. |
via | Yes (or to) | Input key that carries the target value. Use for variable-target actions like advance. |
to | Yes (or via) | Literal target value the action always writes. Use for fixed-target actions like hire/reject. |
Exactly one of via or to must be provided. The compiler rejects both at
validate time. When to is set, it must appear in some fromTo[*] array —
otherwise the action would be unreachable, which is almost certainly a bug.
Wire-level errors
The compiled guard returns 409 ACCESS_ACTION_NOT_ALLOWED_FOR_STATE with
the field, the current value, the requested target, and the allowed set:
{
"error": "Action not allowed for current record state",
"layer": "access",
"code": "ACCESS_ACTION_NOT_ALLOWED_FOR_STATE",
"details": {
"field": "status",
"current": "interview",
"target": "screening",
"allowedTargets": ["offer"]
},
"hint": "From \"interview\", status can transition to: offer"
}This is distinct from a role failure: if the caller has the wrong role they
get 403 ACCESS_ROLE_REQUIRED as before. Splitting the two lets clients
tell "you can't do this" from "you can't do this yet" — the latter is
recoverable by the user, the former isn't.
The same 409 code is used when an access.record predicate (without
transition) fails — for example record: { status: { equals: "pending" } }
called against a paid invoice now returns 409 ACCESS_ACTION_NOT_ALLOWED_FOR_STATE,
not a misleading 403.
Guards - Field Modification Rules
Control which fields can be modified during CREATE vs UPDATE operations. Protect sensitive fields and enforce data integrity rules.
Changesets — aggregate writes
Declare an owns boundary on a root table and write the parent plus its owned relations in one atomic request — every op paying the child's own firewall, access, and guards.