Quickback Docs

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 transitions on defineTable — the compiler generates the actions. Start here.
  • Action-level transition on defineAction — 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 RETURNING maps to 409 ACCESS_TRANSITION_LOST). Guard failures return onIllegal (default 409 TRANSITION_GUARD_FAILED); on bulk twins they report per-id in the batch envelope.
  • Compiler-applied writes: declaring stamp/clears (or applyBefore: true) makes the compiler execute the UPDATE itself before execute — and execute becomes optional. An omitted execute returns { success: true, <field>: <to>, <stamp.at>: <now> }. Set applyBefore: false to keep the write in your own execute (today's behavior).
  • field: null declares a pure stamp transition (check-in/check-out style) — guards + stamps with no state column.
  • actx.now: every action's execute context now carries now — one ISO timestamp per invocation. Prefer it over new Date().toISOString(). It writes into a q.timestamp() column as-is, on every database and every db handle — 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, and from/to reverse for field transitions.
  • onEnter steps run in the generated execute, after the compiler-applied primary write (parent row first). update steps go through the scoped db — cross-feature targets resolve automatically and inherit the firewall. enqueue steps 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.
  • access is required on every transition (fail-closed, like defineAction).
  • Boolean from values match integer-boolean columns too (from: false matches both false and 0).

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 }) { /* … */ },
});
FieldRequiredDescription
fieldYesRecord column to validate (e.g. "status")
fromToYesMap of current value → permitted next values. Keys also enumerate the legal source states.
viaYes (or to)Input key that carries the target value. Use for variable-target actions like advance.
toYes (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.

On this page