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. Declare that shape on the table
and the compiler generates the action files — including undo inverses and
onEnter cascades.
Two declaration sites:
- Table-level
transitionsondefineTable— the compiler generates the actions. Start here. - Action-level
transitionondefineAction— the same guards, stamps, idempotency, and compiler-applied writes, for an action you are writing anyway.
Both work on any table in the feature — the primary and its siblings
alike. A sibling table's transitions synthesize to
actions/<table>/<name>.ts, bound to that table, importing the generated
per-table helper (.quickback/define-action.<table>), and mount at
POST /<table>/:id/<name> exactly like a primary's. So a status machine on a
child table (reservations inside features/sessions/) does not force the
table into its own feature — group related tables freely. Never replace
declared transitions with hand-written per-verb actions: you lose the
generated routes, the guards.protected integration, and the CMS
affordances.
Two lines per state change
// 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, {
guards: {
createable: ["title"],
updatable: ["title"],
protected: {
isPublished: ["publish", "revertToDraft"],
},
},
transitions: {
publish: { field: "isPublished", from: false, to: true, access: { roles: ["admin+"] } },
revertToDraft: { field: "isPublished", from: true, to: false, access: { roles: ["admin+"] } },
},
});isPublished has a schema default and is absent from both createable and
updatable. The transition names are its only writers. A workflow-owned field
cannot also be directly updatable; listing it in both updatable and
protected is a compile error and would undermine the state machine.
Table-level transitions need a fixed `to`
Every transitions.<name> entry must name a literal target: to with either
from (scalar or array) or a fromTo map. A target that varies per call
(via: "nextStatus" — read from the action's input) is not accepted at
table level; the compile fails with
[quickback:transitions] <feature>/<table> transitions.<name>: table-level fromTo transitions need a fixed to (via-style input targets require a hand-written action).
Write that one as actions/<name>.ts with transition: { field, via, fromTo }
(see Transition (state-machine policy)) —
or split it into one fixed-target entry per step (toScreening, toInterview, …).
The generated actions mount exactly where an authored file would
(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).
Generated transitions take no request input. Their OpenAPI request body and MCP tool schema are the compiler's own strict empty object, and they are outside the CLI's local action-schema harvest — which only covers action files that exist in your tree.
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(). It writes into aq.timestamp()column as-is, on every database and everydbhandle — see Column types.
Concurrency: the guard is in the UPDATE
D1 has no transaction across statements, so a transition that read the row and then wrote it by id would let two concurrent requests both succeed (two scanners checking in one guest). The compiler-applied write never does that: the UPDATE itself carries the state it checked.
-- field transition (close: open → closed)
UPDATE tickets SET status = 'closed', modified_at = ?, …
WHERE <firewall> AND id = ? AND status = 'open' -- the from-state it read
RETURNING *;
-- stamp-only transition (checkIn, guard: { checkedInAt: { null: true } })
UPDATE guests SET checked_in_at = ?, checked_in_by_id = ?, …
WHERE <firewall> AND id = ? AND status = 'confirmed' AND checked_in_at IS NULL
RETURNING *;The WHERE pins the exact value the pre-check saw (a compare-and-set), which is
stricter than status IN (<every from-state>): if a twin moved the row
between two legal source states, the stale request loses instead of acting on
a row it never checked. When two requests race:
- one UPDATE matches and wins (
200); - the other sees the new state in its pre-check (
409 ACCESS_ACTION_NOT_ALLOWED_FOR_STATE/onIllegal), or matches no row at the UPDATE (409 ACCESS_TRANSITION_LOST); - with
idempotent: "noop", a request that loses to a twin that already reached the target answers{ success: true, unchanged: true }, exactly as if it had arrived second.
This holds for every table-level transition (the compiler always applies the
write) and every action-level transition with stamp, clears,
applyBefore: true or no execute. A hand-written execute that does its own
write must use whereTransition(table) in its WHERE and throw
TransitionLostError on an empty RETURNING — a write by whereRecord alone
is the race.
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, applications } from "../.quickback/define-action";
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, applications } from "../.quickback/define-action";
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 as
application/problem+json (like every API error), with the
field, the current value, the requested target, and the allowed set:
{
"type": "https://quickback.dev/problems/access-action-not-allowed-for-state",
"title": "Access action not allowed for state",
"status": 409,
"code": "ACCESS_ACTION_NOT_ALLOWED_FOR_STATE",
"detail": "Action not allowed for current record state",
"instance": "/api/v1/applications/app_123/advance",
"layer": "access",
"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.
In the CMS
The schema registry carries each action's field, fromTo, to/via,
literal guard, and idempotent. The CMS evaluates them against the row and
hides any transition the record's current state cannot take — no
fromTo[current] entry admits the target, the row is already in the to
state, or a literal guard fails. Nothing extra to declare; see
CMS Actions → Transition State.
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.