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.tsimport { 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.
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.
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:
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.
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.
Non-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
output
No
Portable Zod schema for the JSON success response. Generates action output types and constrains execute's result.
access
Yes
Access control (roles, record conditions, or function), or input-conditioned arms — see Arms conditioned on input
execute
Yes
Inline async arrow function (async (ctx) => { … })
path
Standalone only
Custom route path. Presence of path makes the action standalone.
method
No
HTTP 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.
Hint for AI tools: 'sync', 'async', or 'fire-and-forget'
allowRawSql
No
Explicit compile-time opt-in for raw SQL in execute code
unsafe
No
Unsafe 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.
transition
No
State-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
refs
No
Scoped foreign-key inputs: load + firewall + 404 before execute, with many, where/unmet, and ownedVia. See Refs
record
Standalone only
Feature-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
bulkVariant
No
Auto-generate a POST /:resource/batch/{action} route alongside the per-record route (record-based actions). See Bulk Variant
webhook
No
Standalone 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
rawBody
No
Standalone, 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
idempotency
No
'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
cms
No
Pass-through metadata blob for CMS surfaces
writes
No
Platform-tier JSON keys this action may change: { '<column>' | '<table>.<column>': ['<key>', …] }. See JSON key writes and requires
requires
No
A 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
operator
Standalone only
Operator 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
outputMask
No
'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.
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 === truerequires: { 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.
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.
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.tsimport { 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.
Three more ref shapes cover what action bodies otherwise check by hand. Each is
independent and composes with matchScope / matchWith:
// features/sites/actions/confirmBookings.tsexport 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 */ },});
Key
Behaviour
Failure
many: true
The 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.
404REF_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.
ownedVia
One 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.
403REF_NOT_OWNED (or notOwned) with details.notOwned on a many ref. A null subject (e.g. no self row) owns nothing.
where
Column → 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.
409REF_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:
subject
Value
"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.