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 compile writes the same typed
helpers into quickback/features/<feature>/.quickback/ (plus a
quickback/tsconfig.json), so the import above resolves — and typechecks —
in the tree you actually edit, not just in the generated output. The helper
also re-exports the feature's table 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";
Prefer this single import over the older
import { applications } from "../applications" — that path resolves to
your authored file at edit time, which has no named export and none of the
injected columns. The helper files are regenerated on every compile; keep
quickback/features/*/.quickback/ in .gitignore (new projects get this
automatically).
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. Since
v0.63.0 a failed harvest fails the compile, naming the exact action, whether
bundling or evaluation failed, and the underlying local error. Output remains
optional.
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.
--allow-degraded-schemas restores the old degrade-and-continue behavior for a
single compile. It still prints every action that fell back and why, and the
compiler warns again with the count and sampled paths.
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 — it warns, naming how many actions are affected.
Declare the output shape as a literal Zod schema. Do not derive it from a
table:
// Silently produces an EMPTY contract — do not do this.output: z.object({ tickets: z.array(createSelectSchema(ticketTypes)) })// Documents the response.output: z.object({ tickets: z.array(z.object({ id: z.string(), name: z.string(), priceCents: z.number() })),})
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.
The proxy substitution has one exception, and it is not a supported escape
hatch: a table module that also contains a z.object(...) is bundled and
evaluated for real, so a derivation in that file happens to work. Whether it
works depends on unrelated content in the table file — declare the shape
instead.
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.
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)
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).
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
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
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
execute must be an async (ctx) => { … } arrow function. async function
declarations and non-async forms are rejected at parse time.
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, and the field is omitted from the generated POST/PATCH body Zod schemas — clients cannot even send it past validation.
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).