Quickback Docs

Defining Actions

The action file, its configuration options, protected fields, and scoped foreign-key inputs.

Defining Actions

// 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"]),
    notes: z.string().max(2000).optional(),
  }),
  output: z.object({
    id: z.string(),
    status: z.enum(["screening", "interview", "offer"]),
  }),
  access: { roles: ["owner", "admin"] },
  transition: {
    field: "status",
    via: "nextStatus",
    fromTo: {
      applied: ["screening"],
      screening: ["interview"],
      interview: ["offer"],
    },
  },
  async execute({ db, record, input, whereTransition }) {
    const [updated] = await db
      .update(applications)
      .set({
        status: input.nextStatus,
        notes: input.notes ?? record.notes,
      })
      .where(whereTransition!(applications))
      .returning();
    return updated;
  },
});

defineAction import. Import from "../.quickback/define-action" — the .quickback/ directory is generated alongside your feature with a typed helper. It infers record and Zod input, exposes the generated Services, types c as a Hono request context, binds whereRecord and whereTransition to the feature table, and checks the value returned by execute against output when you declare one. Scoped db and interactive tx are schema-aware on D1 and Neon Hyperdrive; HTTP/WebSocket Neon keeps its dynamic database escape hatch. AppContext also retains its open index for namespace-hydrated fields. Audit fields (createdAt, createdBy, modifiedAt, modifiedBy) are hard-stamped by the audit DB wrapper on every db.insert().values() and db.update().set() — handlers don't pass them and can't override them.

Your editor is typed too. quickback build writes the same typed helpers into quickback/features/<feature>/.quickback/ (plus a quickback/tsconfig.json and a quickback/.quickback/compiler/ snapshot of the @quickback/compiler types, so feature/q/defineTable imports in table files and quickback.config.ts resolve as well). The import above resolves — and typechecks — in the tree you actually edit, not just in the generated output. The helper also re-exports every table in the feature typed with the generated schema, so compiler-managed columns (createdAt, modifiedBy, deletedAt, organizationId) are visible on it without casts:

import { defineAction, applications } from "../.quickback/define-action";

Other tables in the same feature come from that same helper. The boundary is the feature, not the whole project: a table owned by another feature is not re-exported here.

Prefer this single import over the older two-import form with import { applications } from "../applications". That form still builds and typechecks — quickback check resolves a named import from a feature() file to the generated table, as the build does — it is just one import more. The helper files are regenerated on every compile; keep quickback/features/*/.quickback/ in .gitignore (new projects get this automatically).

For an action that joins or writes a table owned by another feature, keep defineAction from the action's own helper and import the other table separately from its feature's generated helper (or generated table module). Query operators come from the generated project lib/q, not from either feature helper:

// features/waitlist/actions/promote.ts
import {
  defineAction,
  waitlistEntries,
} from "../.quickback/define-action";
import { reservations } from "../../reservations/.quickback/define-action";
import { q } from "../../../lib/q";

// `defineAction` stays bound to waitlistEntries; reservations is only a table.
// For a direct generated-table import, use "../../reservations/reservations".

This waitlist/reservations shape is generic: for any cross-feature action, the own-feature helper supplies the action's record, whereRecord, and whereTransition context; each external feature supplies only its own tables. Importing defineAction from the external feature would bind the wrong context.

Local action-schema harvest

Before sending a compile request, the CLI loads each action in an isolated local bundle and converts action.input plus an optional action.output with Zod 4's z.toJSONSchema. It does not call execute; the hosted compiler never evaluates project code. The output contract feeds OpenAPI and Api.<feature>.actions.<action>.Output in quickback.gen.ts.

Every action input must produce a schema. A failed harvest fails the compile, naming the exact action, whether bundling or evaluation failed, and the underlying local error. Output remains optional. A successful harvest reports its denominator — ✔ Resolved 12 of 12 action input schema(s).

Evaluation errors carry a stack trace mapped back to your source files, so they name the file and line that threw — including a shared module the action imports. Actions are evaluated one after another in one local process; if one action's module-level code hangs or ends that process (a loop that never exits, a top-level await that never settles, process.exit), only that action fails, with a message ending — while loading THIS action, and every other action is still harvested.

The count covers the action files you wrote. Actions the compiler generates from a table's transitions: have no file in your tree, so the CLI never harvests them: they take no input, and the compiler emits their strict schema ({ "type": "object", "properties": {}, "additionalProperties": false }) itself.

Why it fails instead of degrading. The compiler's static fallback parser reads your Zod as text and can only produce type and properties. It never emits required or additionalProperties — so a fallback request schema looks fully typed in openapi.json and the MCP tool list while enforcing nothing: generated clients treat every required field as optional, and unknown keys pass. Constructs it cannot parse at all degrade further, to a bare {} with no properties. Neither is visible in a diff.

There is no opt-out. Fix the action so its input schema harvests — the error names each one, the phase that failed, and the underlying local error.

Bare imports are resolved from the generated runtime's configured build.outputDir/node_modules first. If definitions live in quickback/ and the generated Worker lives in src/, install runtime dependencies in src/ as usual; no project-root dependency symlink is required. Shared schema or DTO modules may also declare top-level Drizzle selection objects. Quickback replaces authored table exports with inert proxies during this schema-only evaluation, so reading records.id to build a projection does not execute application or database logic. A schema derived from a table enumerates nothing under that substitution — see Declaring action outputs.

Declaring action outputs

output: is optional, and omitting it degrades quietly: the operation's OpenAPI success response becomes {} and Api.<feature>.actions.<action>.Output becomes unknown. The compile still succeeds, but it warns, naming how many actions are affected. Every compile emits the warning, validation compiles included. It skips actions whose response the compiler owns: responseType: 'file' or 'stream', bundles, and transitions without an execute.

An action that returns a row declares it with row(<table>), imported from the same helper as defineAction. Any other shape is a literal Zod schema. Do not derive a schema from a table yourself:

import { defineAction, row, tickets } from "../.quickback/define-action";

// Returns one ticket: Output = Api.tickets.Row, OpenAPI = the Tickets component.
output: row(tickets)

// row() composes: nest it in an envelope, make it nullable, or put it in an array.
output: z.object({ ticket: row(tickets), previous: row(tickets).nullable(), related: z.array(row(tickets)) })

// Any other shape — write it out.
output: z.object({
  tickets: z.array(z.object({ id: z.string(), name: z.string(), priceCents: z.number() })),
})

// Silently produces an EMPTY contract — do not do this.
output: z.object({ tickets: z.array(createSelectSchema(ticketTypes)) })

row(table) is compiler-recognised: the harvester records which table it was handed, and the compiler resolves that to the table's Row type in quickback.gen.ts and to its resource component in OpenAPI — wherever it appears in the output: bare, as an object property, under .nullable(), or as array items (the table must be a defineTable() resource — anything else is a compile error). At runtime it is a real Zod object built from the table's columns.

The table can come from anywhere you would import it: the feature's .quickback/define-action helper, its own table module (the import an action in actions/<table>/ uses), a sibling table's module, or another feature's table module. The harvester records the table's owning feature and module, not the import spelling, so every form resolves to the same table — another feature's table becomes Api.<otherFeature>.<table>.Row and that feature's component:

quickback/features/banking/actions/bankStatements/review.ts
import { z } from "zod";
import { defineAction, row } from "../../.quickback/define-action.bankStatements";
import { bankStatements } from "../../bankStatements";
import { transfers } from "../../transfers";
import { payments } from "../../../payments/payments";

export default defineAction({
  description: "Review a statement with its matched transfer and payments",
  input: z.object({}),
  output: z.object({
    statement: row(bankStatements),
    transfer: row(transfers).nullable(),
    payments: z.array(row(payments)),
  }),
  access: { roles: ["member"] },
  execute: async ({ record }) => ({ statement: record, transfer: null, payments: [] }),
});

If row() is handed something that is not a table (a column such as row(bankStatements.id), or a schema you built), the compile warns, naming the action file and the position (output.transfer, output.payments[]). That position is {} in OpenAPI and unknown in quickback.gen.ts.

The harvest substitutes inert proxies for table modules, so anything that derives a schema by enumerating a table's columns — createSelectSchema and every equivalent — enumerates zero columns. It does not throw. It returns a valid, empty object schema, which is then published as the documented success response. The compiler warns when a declared output: resolves to an empty object, but the warning is the only signal; the compile passes and the spec looks fine. row() exists so you never need that derivation.

A declared output: is enforced. The generated route parses execute's result through it before responding, so the published contract is what actually leaves the action: undeclared keys are stripped, and a result that does not satisfy the schema is a 500. See Declaring action outputs.

output describes the success path only. Error responses never pass through it: throw ActionError and the route returns { error, code, details } with your status code — that error envelope is part of the generated contract already, so do not fold error variants into output. The one exception is an endpoint whose success body is deliberately a status report (a webhook receiver acknowledging { received: false, reason }, say) — that is a union of success shapes, and declaring it as z.discriminatedUnion(...) in output is correct. Do not return an error object (or a Response) from execute to signal failure — see Error Handling for what actually happens.

Parameterized standalone paths use Hono's :param syntax in authored source, for example path: "/events/:eventId/check-in". The harvest planner converts those segments to the OpenAPI {param} form before applying the schema, so completeness uses the same route key as the emitted OpenAPI and MCP surfaces. The same normalization applies when a record resource path contains namespace parameters.

Masking action output (outputMask)

An action returns whatever execute returns. CRUD reads run every row through the table's masking rules for the caller; an action's result does not get that for free, because the compiler cannot tell a row from any other object. Declare it:

// The action's own table: the record (or rows of it) is masked exactly like GET /guests/:id.
outputMask: 'source',

// Another table's rows, inside a wrapper: { pollId, ballots: [...] }.
outputMask: { table: 'votes', path: 'ballots' },

The compiler applies the target table's generated mask function — the same one the CRUD routes call — to the value at path (or to the whole result), with the caller's roles. It handles a single row and an array of rows, on the per-record route, the bulk variant (each result masked on its own), and standalone actions.

It fails closed in three places:

  • Compile time. An unknown table, a table that masks no columns (the declaration would do nothing), or 'source' on a standalone action with no record: binding is a compile error. outputMask on a stream or file action is too.
  • Runtime shape check. The target must be a row of that table, or an array of them, each carrying the table's primary key. A wrapper object, a missing path, or an element that is not a row throws (the route answers 500 and logs it) rather than returning the value unmasked — an object that is not a row would otherwise pass through the mask function untouched.
  • null / undefined carry no data and pass through.

outputMask and output: compose: the result is parsed through the declared contract first, then masked. Without outputMask, a record-based action keeps the older behaviour — its result is masked as one row of the bound table when that table has masking rules, and anything else is returned as-is.

Configuration Options

OptionRequiredDescription
descriptionYesHuman-readable description of the action
inputYesZod schema for request validation
queryNoNon-GET only. An inline z.object({...}) of query-string parameters, validated apart from the JSON body and passed to execute as query. See Query parameters on non-GET actions
outputNoPortable Zod schema for the JSON success response. Generates action output types and constrains execute's result.
accessYesAccess control (roles, record conditions, or function), or input-conditioned arms — see Arms conditioned on input
executeYesInline async arrow function (async (ctx) => { … })
pathStandalone onlyCustom route path. Presence of path makes the action standalone.
methodNoHTTP method: GET, POST, PUT, PATCH, DELETE (default: POST). GET actions parse input from query params; all other methods parse from the JSON body. See GET vs POST for input.
responseTypeNoResponse format: json, stream, file (default: json)
sideEffectsNoHint for AI tools: 'sync', 'async', or 'fire-and-forget'
allowRawSqlNoExplicit compile-time opt-in for raw SQL in execute code
unsafeNoUnsafe raw DB mode. Object config (reason, adminOnly, crossTenant, targetScope, authWrites). authWrites: true turns unsafe mode on, permits direct writes to Better Auth tables, and requires reason; pair it with crossTenant: false unless the action is sysadmin-only — see ACTION_AUTH_DB_WRITE.
transitionNoState-machine policy enforcing the from/to pair. Lives on a record-based action, or on a standalone action paired with a record: binding. See Transitions
refsNoScoped foreign-key inputs: load + firewall + 404 before execute, with many, where/unmet, and ownedVia. See Refs
recordStandalone onlyFeature-area transition adapter binding: { table, idFrom: "path.<param>", matchScope? }. Binds + firewalls the record a transition operates on from a proven route param. See Feature-area transition adapter
bulkVariantNoAuto-generate a POST /:resource/batch/{action} route alongside the per-record route (record-based actions). See Bulk Variant
webhookNoStandalone only. Compile-declared inbound signature verification (standard-webhooks, hmac-sha256, or aws-sns) that runs on the raw bytes before parse/access — see Inbound webhooks
rawBodyNoStandalone, non-GET only. Hand execute the request body as received (rawBody: string, bounded to 1 MiB) for signature schemes the compiler does not model — a per-organization secret stored in the database, a provider with its own canonicalisation. input still parses from the same bytes. Actions with webhook: receive rawBody automatically. The param is typed exactly that way: rawBody: string on those actions, absent on every other — see Per-organization secrets
idempotencyNo'dedupe' — PUBLIC-action opt-in for the Idempotency-Key header with dedupe-only semantics (duplicate ⇒ 409 reference; the cached body is never served to a second caller). Standalone actions with a static path only (a :param path is a compile error — the allowlist matches literal request paths) and the action's access must admit PUBLIC. Authenticated routes accept the header automatically — see API contract
cmsNoPass-through metadata blob for CMS surfaces
writesNoPlatform-tier JSON keys this action may change: { '<column>' | '<table>.<column>': ['<key>', …] }. See JSON key writes and requires
requiresNoA JSON key that must hold before execute runs, else 403 ENTITLEMENT_REQUIRED: { flag: '<key>' } or { json: { column, key, truthy: true | equals } }, optionally from: '<ref as>'. See JSON key writes and requires
operatorStandalone onlyOperator lane: { target: { table, by: 'input.<field>', includeDeleted? } } on a userRole-only action. Loads the target cross-tenant (404 when absent or soft-deleted), scopes db to its organization, passes the row as target, and audits every call against that tenant. Mutually exclusive with unsafe. See Operator lane
outputMaskNo'source' or { table, path? }. Masks the rows the action returns with that table's masking rules for the caller — see Masking action output

execute must be an async (ctx) => { … } arrow function. async function declarations and non-async forms are rejected at parse time.

JSON key writes and requires

A typed JSON column can declare per-key write tiers with q.json(schema, { keys }). Generic CRUD refuses its platform keys. Two action options work with those keys.

writes: grants platform keys. The scoped db lets an action change a platform key in one of two cases:

  • the action's access names a userRole lane, and the caller's user.role matched one of those values. Every platform key is then open to the action;
  • the action lists the key in writes:. Keys are '<column>' on the action's own table, or '<table>.<column>' for any table.
// Platform operators: a userRole lane, so every platform key is open.
export default defineAction({
  description: "Turn the beta key on or off",
  input: z.object({ enabled: z.boolean() }),
  access: { userRole: ["operator"] },
  async execute({ db, record, input }) {
    await db.update(projects)
      .set({ settings: { ...(record.settings ?? {}), beta: input.enabled } })
      .where(eq(projects.id, record.id));
    return { beta: input.enabled };
  },
});

// Org owners, granted exactly one platform key.
export default defineAction({
  description: "Owners may grant beta",
  input: z.object({ enabled: z.boolean() }),
  access: { roles: ["owner"] },
  writes: { settings: ["beta"] },
  async execute({ db, record, input }) { /* same write */ },
});

Any other action that changes or drops a platform key gets 422 JSON_KEY_RESTRICTED from the write. Read-modify-write that carries the key over unchanged passes. writes: may name only platform keys. An organizer key needs no grant, and a derivedFrom key can never be written, so listing either is a compile error.

requires: checks an entitlement before execute. The check reads the action's record. On a standalone action, from must name one of its refs. If the check fails, the route answers 403 ENTITLEMENT_REQUIRED (details: { column, key }) and execute does not run.

requires: { flag: "premiumExport" }                                    // short form: the bound table's one keyed column, value === true
requires: { json: { column: "settings", key: "beta", truthy: true } }  // any typed q.json column; `key` may be a path ("limits.sms")
requires: { json: { column: "settings", key: "tier", equals: "pro" } }
requires: { flag: "premiumExport", from: "project" }                   // reads the `project` ref row instead of the record
  • flag resolves against the one q.json(..., { keys }) column on the bound table. If there are none or several, use the json form.
  • truthy needs a z.boolean() at the key path. equals takes a string, number, boolean or null literal.
  • A derivedFrom key is read from its source table, not from the stored column.
  • requires is not supported with bulkVariant: true.

Protected Fields

Actions can modify fields that are protected from regular CRUD operations:

// In the table file (defineTable config)
guards: {
  protected: {
    status: ["advance", "reject", "hire", "withdraw"],  // Only these actions can modify status
  }
}

This allows the advance action to set status = "interview" even though the field is protected from regular PATCH requests. Direct PATCH /:id attempts to set status are rejected by the guards layer with 400 GUARD_FIELD_PROTECTED (its details.actions names the actions that may change it). The field is also omitted from the generated POST/PATCH body schemas, so OpenAPI and the client types never advertise it.

Refs (scoped foreign-key inputs)

The most common action preamble — load an input id, 404 if missing, assert it belongs to the caller's event/org — becomes declarative:

// features/agenda/actions/assignGuest.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";

export default defineAction({
  description: "Assign a guest to an agenda item, optionally at a specific location.",
  access: { roles: ["admin", "member"] },
  input: z.object({ agendaItemId: z.string(), guestId: z.string(), locationId: z.string().optional() }),
  refs: {
    agendaItemId: { table: "agendaItems", as: "agendaItem",
                    matchScope: "event",                       // row.eventId === ctx.event.id
                    notFound: { code: "ITEM_NOT_FOUND" } },    // status defaults to 404
    guestId:      { table: "guests", as: "guest",
                    matchWith: { ref: "agendaItem", on: "eventId" },  // cross-input parenthood
                    mismatch: { code: "EVENT_MISMATCH", status: 400 } },
    locationId:   { table: "locations", as: "location", optional: true, matchScope: "event" },
  },
  execute: async ({ input, agendaItem, guest, location }) => { /* … */ },
});
  • Loads run through the scoped db before execute — the org firewall and soft-delete filter apply by construction, so a cross-org id 404s with zero hand-written tenancy checks. All loads batch into one Promise.all.
  • matchScope: "<scope>" asserts row.<scope>Id === ctx.<scope>.id and fail-closes (500) if the scope object isn't hydrated. The explicit form { column, equals: "ctx.activeOrgId" } is also accepted.
  • matchWith compares a column against an earlier-declared ref — declaration order defines resolution order; forward references are compile errors.
  • Ref keys must exist on the inline z.object input with agreeing optionality; as names can't shadow reserved context keys. Injected rows are the unmasked DB rows (masking remains a read-projection concern).
  • Unknown keys on a ref are a compile error (a typo such as idFrom fails the build instead of being ignored). The input field is the id source; there is no idFrom.

Id arrays, parent state, and "mine through a junction"

Three more ref shapes cover what action bodies otherwise check by hand. Each is independent and composes with matchScope / matchWith:

// features/sites/actions/confirmBookings.ts
export default defineAction({
  description: "Confirm the caller's own bookings against a published notice.",
  path: "/site/:siteId/bookings/confirm",
  input: z.object({ bookingIds: z.array(z.string()).min(1).max(200), noticeId: z.string() }),
  access: { roles: ["AUTHENTICATED"] },
  refs: {
    bookingIds: {
      table: "bookings", as: "bookings",
      many: true,                                   // id array: every id must resolve
      matchScope: "site",                           // checked per row
      ownedVia: {                                   // a junction row must link each one to the caller
        junction: "booking_visitors",
        refColumn: "bookingId",                     // junction column holding the ref's id
        subjectColumn: "visitorId",                 // junction column holding the caller's identity
        subject: "ctx.self.visitor",                // which identity — see below
      },
    },
    noticeId: {
      table: "notices", as: "notice",
      where: { status: "published" },              // parent-state rule on the loaded row
      unmet: { code: "NOTICE_NOT_PUBLISHED", status: 409 },
    },
  },
  execute: async ({ bookings, notice }) => { /* bookings: rows[], notice: row */ },
});
KeyBehaviourFailure
many: trueThe input field must be a z.array(...) (and an array field must declare many: true). Ids are deduplicated, loaded through the scoped db in slices under the driver's bind cap, and injected as a row array in first-occurrence input order.404 REF_NOT_FOUND (or your notFound) with details.missing — the ids that did not resolve inside the firewall. Out-of-tenant and nonexistent ids are indistinguishable.
ownedViaOne junction lookup (through the scoped db, in the same Promise.all as the loads) must find a row with refColumn = the ref's id and subjectColumn = the subject identity, for every loaded row.403 REF_NOT_OWNED (or notOwned) with details.notOwned on a many ref. A null subject (e.g. no self row) owns nothing.
whereColumn → value on each loaded row: a literal is equality, null is IS NULL, an array is "one of". Keys are emitted as typed column references, so a misspelled column fails tsc in the generated project.409 REF_CONDITION_UNMET (or unmet), with details.unmet on a many ref.

ownedVia.subject is required and names exactly one identity, so the compiler never guesses between a session user id and a scope subject id:

subjectValue
"ctx.userId"The Better Auth session user
"ctx.self.<exposeAs>"The id of the caller's own row — one identity for a session user and a pass holder (see ctx.self)
"ctx.scope.<kind>.subjectId"The scope claim's subject (the conferring row's PK)

An undeclared ctx.self.<exposeAs> or scope kind is a compile error.

Check order per ref: absence (404) → matchScope / matchWith (400) → ownedVia (403) → where (409). Ownership is checked before state so a caller who does not own a row never learns its status.

On this page