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. Under contract v2, every action input must produce a schema; output remains optional. A failure names the exact action, whether bundling or evaluation failed, and the underlying local error. V1 can still fall back to the lower-fidelity static schema parser.

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.

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 contract-v2 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