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 { eq } from "drizzle-orm";
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 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).

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

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.

Configuration Options

OptionRequiredDescription
descriptionYesHuman-readable description of the action
inputYesZod schema for request validation
outputNoPortable Zod schema for the JSON success response. Generates action output types and constrains execute's result.
accessYesAccess control (roles, record conditions, or function)
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).
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
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
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

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

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, and the field is omitted from the generated POST/PATCH body Zod schemas — clients cannot even send it past validation.

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

On this page