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. 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 transitions on defineTable — the compiler generates the action files, including undo inverses and onEnter cascades.

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 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().

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 } 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 }) { /* … */ },
});
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 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.

On this page