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

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.

On this page