Actions
Create custom API endpoints for business logic beyond CRUD. Build workflows, integrations, and complex operations with type-safe action handlers.
Actions are custom API endpoints for business logic beyond CRUD operations. They enable workflows, integrations, and complex operations.
Overview
Quickback supports two types of actions:
| Aspect | Record-Based | Standalone |
|---|---|---|
| Route | {METHOD} /:id/{actionName} | Custom path (required) |
| Record fetching | Automatic | None (record is undefined) |
| Firewall applied | Yes | No |
| Preconditions | Supported via access.record | Not applicable |
| Response types | JSON only | JSON, stream, file |
| Use case | Advance application, reject candidate | AI chat, bulk import from job board, webhooks |
Standalone actions are signalled by the presence of a path: field in the action config. Record-based actions omit path and bind to the feature's primary table.
Where do actions live?
One file per action. Each action is its own module under the feature's
actions/ directory:
features/applications/
├── applications.ts ← defineTable (primary table)
├── candidates.ts ← defineTable (sibling table)
├── actions/
│ ├── advance.ts ← defineAction, binds to applications
│ ├── reject.ts ← defineAction, binds to applications
│ ├── stats.ts ← defineAction, standalone (has `path:`)
│ └── candidates/
│ └── disqualify.ts ← defineAction, binds to candidates
└── lib/
└── inputs.ts ← shared schemas (optional, copied verbatim)| Layout | Binds to |
|---|---|
actions/<name>.ts | The feature's primary table (table file matching the feature name), or standalone if path: is set. |
actions/<table>/<name>.ts | The sibling table file <table>.ts at the feature root. Standalone actions inside a subdir ignore the binding. |
The action's filename (sans .ts) is the action name and the URL segment.
Uniqueness is enforced per (bound table, name) pair — sibling-bound
actions on different tables may share a verb (actions/episodes/publish.ts
and actions/shows/publish.ts mount at /episodes/:id/publish and
/shows/:id/publish and compile cleanly). The compiler errors only when
two action files would mount at the same URL — for example, a flat
actions/foo.ts (binds to primary) plus actions/<primary>/foo.ts, or
two standalones with the same (method, path). Standalone actions are
unique per (method, path).
Tableless features have no top-level *.ts files; every action under
actions/ must be standalone (have path:). Use this for utility endpoints
like webhooks, reports, or integrations that don't operate on a specific
record.
Retired layouts
These are rejected at load time with migration guidance:
actions.ts(bundled multi-action file)_feature.ts(shared schemas + non-table-bound actions)handlers/(separate handler-file directory)defineActions(table, {...})(the multi-action factory)defineActions(null, {...})(tableless variant)*-actions.ts/*.actions.tsfilename patterns
For shared Zod schemas or helpers, put them under <feature>/lib/ and import
from each action file. Files under lib/ are copied verbatim into the
generated output.
Defining Actions
// features/applications/actions/advance.ts
import { z } from "zod";
import { eq } from "drizzle-orm";
import { defineAction } from "../.quickback/define-action";
import { applications } from "../applications";
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(),
}),
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;
},
});
defineActionimport. Import from"../.quickback/define-action"— the.quickback/directory is generated alongside your feature with a typed helper that infersrecordandinputtypes from the rest of the action config. Audit fields (createdAt,createdBy,modifiedAt,modifiedBy) are hard-stamped by the audit DB wrapper on everydb.insert().values()anddb.update().set()— handlers don't pass them and can't override them.
Configuration Options
| Option | Required | Description |
|---|---|---|
description | Yes | Human-readable description of the action |
input | Yes | Zod schema for request validation |
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) |
responseType | No | Response format: json, stream, file (default: json) |
sideEffects | No | 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 (record-based actions). See Transitions |
bulkVariant | No | Auto-generate a POST /:resource/batch/{action} route alongside the per-record route (record-based actions). See Bulk Variant |
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.
Record-Based Actions
Record-based actions operate on an existing record. The record is automatically loaded and validated before your action executes.
Route pattern: {METHOD} /:id/{actionName}
POST /applications/:id/advance
POST /applications/:id/reject
POST /applications/:id/schedule-interviewRuntime Flow
- Authentication - User token is validated
- Record Loading - The record is fetched by ID
- Firewall Check - Ensures user can access this record
- Access Check - Validates roles and preconditions
- Input Validation - Request body validated against Zod schema
- Execution - Your action handler runs
- Response - Result is returned to client
Example: Advance Application Stage
// features/applications/actions/advance.ts
import { z } from "zod";
import { eq } from "drizzle-orm";
import { defineAction } from "../.quickback/define-action";
import { applications } from "../applications";
export default defineAction({
description: "Move application to the next pipeline stage",
input: z.object({
stage: z.enum(["screening", "interview", "offer", "hired"]),
notes: z.string().optional(),
}),
access: {
roles: ["owner", "hiring-manager"],
record: { stage: { notEquals: "rejected" } },
},
async execute({ db, record, input }) {
const [updated] = await db
.update(applications)
.set({
stage: input.stage,
notes: input.notes ?? record.notes,
})
.where(eq(applications.id, record.id))
.returning();
return updated;
},
});Request Example
POST /applications/app_123/advance
Content-Type: application/json
{
"stage": "interview",
"notes": "Strong technical background, moving to interview"
}Response Example
{
"data": {
"id": "app_123",
"stage": "interview",
"notes": "Strong technical background, moving to interview"
}
}Bulk variant
Set bulkVariant: true to auto-generate a bulk version of the action at
POST /:resource/batch/{action}. The compiler emits a second route alongside
the per-record one, looping the same access + transition + execute pipeline
over an array of ids. This is the answer to "I need to advance 50
applications at once" — you don't have to hand-roll a parallel standalone
action that duplicates the per-record logic.
// features/applications/actions/advance.ts
export default defineAction({
description: "Move an application forward in the pipeline.",
input: z.object({ nextStatus: z.enum(["screening", "interview", "offer"]) }),
access: { roles: ["owner", "admin"] },
bulkVariant: true,
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 })
.where(whereTransition!(applications))
.returning();
if (!updated) throw new TransitionLostError("status", record.status, input.nextStatus);
return updated;
},
});Request shape:
POST /applications/batch/advance
Content-Type: application/json
{
"ids": ["app_001", "app_002", "app_003"],
"input": { "nextStatus": "interview" },
"failFast": false
}The input is shared across the batch — the action's Zod schema is
validated once, not per id. For per-record input variation, use the
single-record endpoint (POST /:id/{action}) or write a standalone action.
Response (207 Multi-Status when any record fails, 200 when clean):
{
"success": [{ "id": "app_001", "status": "interview" }],
"errors": [
{
"index": 1,
"id": "app_002",
"error": {
"error": "Action not allowed for current state",
"code": "ACCESS_ACTION_NOT_ALLOWED_FOR_STATE",
"details": { "field": "status", "currentValue": "rejected" }
}
}
],
"meta": {
"total": 3,
"succeeded": 2,
"failed": 1,
"failFast": false,
"transactional": false
}
}Per-record pipeline mirrors the single-record route exactly — the same
firewall fetch, access reason split (403 vs 409), transition pre-check,
whereTransition factory, and TransitionLostError handling. Per-record
failures land in errors[] with their original index; successful records
land in success[] masked the same way the single-record response is.
Transactional matrix (same as PATCH /batch):
| Provider | failFast | transactional | Behavior |
|---|---|---|---|
| postgres / supabase / neon / libsql / sqlite drivers | true | true | db.transaction() wraps the loop; any throw rolls back all writes |
| (any) | false | false | Independent per-record outcomes, partial success |
| cloudflare-d1 | true | false | Fail-fast loop without rollback — earlier records stay committed |
Constraints:
- Record-based actions only. Standalone actions reject
bulkVariant: trueat compile time (no record context to loop over). unsafeactions rejectbulkVariant: truein v1 — the cross-tenant per-record audit story is not yet wired through.- Method is forced to POST regardless of the action's
methodsetting. The semantic asymmetry (one row → many rows) makes inheriting GET/DELETE confusing. - Max batch size is 100 ids per request.
Standalone Actions
Standalone actions are independent endpoints that don't require a record context. Set a path: to declare the action standalone.
Route pattern: Custom path
POST /chat
GET /reports/summary
POST /webhooks/stripeExample: AI Chat with Streaming
// features/sessions/actions/chat.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";
export default defineAction({
description: "Send a message to AI",
path: "/chat",
method: "POST",
responseType: "stream",
input: z.object({
message: z.string().min(1).max(2000),
}),
access: { roles: ["recruiter", "hiring-manager"] },
async execute({ input, c }) {
// Stream response back to client...
return c.newResponse(/* ReadableStream */);
},
});Streaming Response Example
For actions with responseType: 'stream':
Content-Type: text/event-stream
data: {"type": "start"}
data: {"type": "chunk", "content": "Hello"}
data: {"type": "chunk", "content": "! I'm"}
data: {"type": "chunk", "content": " here to help."}
data: {"type": "done"}Tableless Features
A feature directory with no top-level *.ts files is "tableless" — every
action under actions/ must be standalone. Use this for utility endpoints
like reports, integrations, or webhooks that don't map to a specific
resource.
quickback/features/
└── reports/
└── actions/
├── trial-balance.ts ← standalone (has path:)
└── profit-loss.ts ← standalone (has path:)// features/reports/actions/trial-balance.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";
export default defineAction({
description: "Generate trial balance report",
path: "/reports/trial-balance",
method: "GET",
input: z.object({
startDate: z.string(),
endDate: z.string(),
}),
access: { roles: ["admin", "accountant"] },
async execute({ db, input }) {
// ... build report
return { rows: [] };
},
});Access Configuration
Access controls who can execute an action and under what conditions.
Role-Based Access
access: {
roles: ["hiring-manager", "recruiter"] // OR logic: user needs any of these roles
}Public Access
Use roles: ["PUBLIC"] for unauthenticated endpoints (contact forms, webhooks, public APIs). Every invocation is mandatory audit logged with IP address, input, result, and timing.
// features/contact/actions/submit.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";
export default defineAction({
description: "Public contact form submission",
path: "/submit",
input: z.object({
name: z.string().min(1),
email: z.string().email(),
message: z.string().min(10),
}),
access: { roles: ["PUBLIC"] },
async execute({ db, input }) {
// ... persist submission
return { ok: true };
},
});The wildcard "*" is not supported and will throw a compile-time error. Use "PUBLIC" explicitly.
Record Conditions
For record-based actions, you can require the record to be in a specific state:
access: {
roles: ["hiring-manager"],
record: {
stage: { equals: "screening" } // Precondition
}
}Supported operators:
| Operator | Description |
|---|---|
equals | Field must equal value |
notEquals | Field must not equal value |
in | Field must be one of the values |
notIn | Field must not be one of the values |
Context Substitution
Use $ctx to reference the current user's context:
access: {
record: {
ownerId: { equals: "$ctx.userId" },
orgId: { equals: "$ctx.orgId" }
}
}OR/AND Combinations
access: {
or: [
{ roles: ["hiring-manager"] },
{
roles: ["recruiter"],
record: { ownerId: { equals: "$ctx.userId" } }
}
]
}Function Access
For complex logic, use an access function:
access: async (ctx, record) => {
return ctx.roles.includes('admin') || record.ownerId === ctx.userId;
}Relationship-roles in actions (v1 limitation)
Relationship-roles declared under authz.roles (e.g. guest: { via: 'guestOf' }) are not yet supported in action access blocks. The compiler refuses to build with a clear error message because the binding semantics — which input field or which loaded record's column to check the relationship against — differ between record-actions and standalone-actions and require explicit declaration not yet wired in v1.
The runtime evaluator fails closed on unrecognized relationship arms, so silent grants are impossible.
Workaround: gate the action on BA roles only and apply the relationship at the resource layer via a firewall via: predicate:
// quickback.config.ts
authz: {
relationships: {
guestOf: {
from: 'event_guests',
subject: { column: 'userId', equals: 'ctx.userId' },
resource: { column: 'eventId' },
where: { status: 'confirmed' },
},
},
}
// features/sessions/sessions.ts — firewall scopes rows; action gates on BA role
firewall: [
{ field: 'organizationId', equals: 'ctx.activeOrgId' },
{ field: 'eventId', via: 'guestOf' }, // ← row-level relationship check
],
actions: {
comment: defineAction({
access: { roles: ['member', 'admin'] }, // ← BA role only on the action
input: z.object({ text: z.string() }),
execute: async (input, ctx, db, record) => {
// record is already filtered by the firewall — guest membership
// is enforced before this handler runs.
return db.insert(comments).values({ ... });
},
}),
}This pattern composes cleanly: the firewall guarantees the caller has the relationship to the record being acted on; the action's access list keeps the role surface obvious.
A future actions.<name>.relationshipBindings declaration is on the roadmap to remove this limitation. See Access — relationship authorization for the full picture.
Scoped Database
All actions receive a scoped db instance that automatically enforces security:
| Operation | Org Scoping | Owner Scoping | Soft Delete Filter | Auto-inject on INSERT |
|---|---|---|---|---|
SELECT | WHERE organizationId = ? | WHERE ownerId = ? | WHERE deletedAt IS NULL | n/a |
INSERT | n/a | n/a | n/a | organizationId, ownerId from ctx |
UPDATE | WHERE organizationId = ? | WHERE ownerId = ? | WHERE deletedAt IS NULL | n/a |
DELETE | WHERE organizationId = ? | WHERE ownerId = ? | WHERE deletedAt IS NULL | n/a |
Scoping is duck-typed at runtime — tables with an organizationId column get org scoping, tables with ownerId get owner scoping, tables with deletedAt get soft delete filtering.
async execute({ db, ctx, input }) {
// This query automatically includes WHERE organizationId = ? AND deletedAt IS NULL
const items = await db.select().from(applications).where(eq(applications.stage, 'interview'));
// Inserts automatically include organizationId
await db.insert(applications).values({ candidateId: input.candidateId, jobId: input.jobId, stage: 'applied' });
return items;
}Not enforced in scoped DB (by design):
- Guards — actions ARE the authorized way to modify guarded fields
- Masking — actions are backend code that may need raw data
- Access — already checked before action execution
Unsafe Mode
Actions that need to bypass scoped DB filters (for example, platform-level support operations) can enable unsafe mode.
Use object form for explicit policy and mandatory audit metadata:
// features/admin/actions/cross-tenant-report.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";
import { applications } from "../../applications/applications";
export default defineAction({
description: "Generate cross-org hiring report",
path: "/admin/cross-tenant-report",
input: z.object({ startDate: z.string() }),
access: { roles: ["owner"] },
unsafe: {
reason: "Support investigation for enterprise customer",
adminOnly: true, // default true
crossTenant: true, // default true
targetScope: "all", // "all" | "organization"
},
async execute({ unsafeDb, input }) {
// unsafeDb bypasses scoped filters
const allOrgs = await unsafeDb.select().from(applications);
return allOrgs;
},
});Unsafe cross-tenant actions are generated with:
- Better Auth authentication required (no unauthenticated path)
- platform admin gate (
ctx.userRole === "admin") - mandatory audit logging on deny/success/error
PUBLIC actions also receive mandatory audit logging (same audit table), since unauthenticated endpoints are high-risk by nature.
Without unsafe mode, unsafeDb is undefined in the executor params.
Raw SQL Policy
By default, the compiler rejects raw SQL in action code. Use Drizzle query-builder syntax whenever possible.
If a specific action must use raw SQL, opt in explicitly:
// features/ledgers/actions/reconcile.ts
import { z } from "zod";
import { sql } from "drizzle-orm";
import { defineAction } from "../.quickback/define-action";
export default defineAction({
description: "Run custom reconciliation query",
input: z.object({}),
access: { roles: ["owner"] },
allowRawSql: true,
async execute({ db }) {
return db.execute(sql`select 1`);
},
});Without allowRawSql: true, compilation fails with a loud error pointing to the action and snippet.
The detector checks for SQL keywords in string arguments, so non-SQL method calls like headers.get("X-Forwarded-For") or map.get("key") will not trigger false positives.
Sharing Code Between Actions
For Zod schemas, helpers, or constants used across multiple actions in a
feature, put them under <feature>/lib/ and import from each action file.
Files under lib/ are copied verbatim into the generated output.
features/applications/
├── applications.ts
├── actions/
│ ├── advance.ts
│ └── reject.ts
└── lib/
├── inputs.ts ← shared Zod schemas
└── transitions.ts ← state-machine helpers// features/applications/lib/inputs.ts
import { z } from "zod";
export const advanceInput = z.object({
nextStatus: z.enum(["screening", "interview", "offer"]),
notes: z.string().max(2000).optional(),
});// features/applications/actions/advance.ts
import { defineAction } from "../.quickback/define-action";
import { advanceInput } from "../lib/inputs";
export default defineAction({
description: "Move forward",
input: advanceInput,
access: { roles: ["owner"] },
async execute({ db, record, input }) { /* … */ },
});Importing Tables
Action files import tables from their own feature or other features. The compiler generates alias files for each table, so you import by the table's file name:
// features/applications/actions/advance.ts
import { eq, and } from "drizzle-orm";
// Same feature — import from parent directory using the table's file name
import { applications } from "../applications";
import { interviewScores } from "../interview-scores";
// Cross-feature — go up to the features directory, then into the other feature
import { candidates } from "../../candidates/candidates";
import { jobs } from "../../jobs/jobs";Path pattern from features/{name}/actions/:
- Same feature table:
../{table-file-name}(e.g.,../applications) - Other feature table:
../../{other-feature}/{table-file-name}(e.g.,../../candidates/candidates) - Generated lib files:
../../../lib/{module}(e.g.,../../../lib/realtime,../../../lib/webhooks)
Executor Parameters
import type { Auth } from "better-auth";
interface ActionExecutorParams {
db: DrizzleDB; // Scoped database (auto-applies org/owner/soft-delete filters)
unsafeDb?: DrizzleDB; // Unscoped database (only available when unsafe mode is enabled)
ctx: AppContext; // User context (userId, roles, orgId)
record?: TRecord; // The record (record-based only, undefined for standalone)
input: TInput; // Validated input from Zod schema
services: TServices; // Configured integrations (billing, notifications, etc.)
c: HonoContext; // Raw Hono context for advanced use
env: Env; // Runtime bindings (Cloudflare env / process env). Alias for c.env.
auth: Auth; // Better Auth instance — call auth.api.getSession(...) etc. Lazy on Cloudflare.
whereRecord?: (table) => SQL; // Pre-built WHERE: firewall + soft-delete + eq(table.id, record.id). Record-based actions only.
whereTransition?: (table) => SQL; // whereRecord plus the transition's "from" precondition. Transition actions only.
}auth — Better Auth instance
auth is the Better Auth instance, ready to call from inside an action
handler. Use it for things like minting an OAuth access token, fetching the
current session in a custom way, or invoking a Better Auth plugin method
that isn't already exposed by the generated middleware.
// features/integrations/actions/connect-google-drive.ts
import { defineAction } from "../.quickback/define-action";
import { z } from "zod";
export default defineAction({
description: "Get a fresh Google Drive access token for the signed-in user",
input: z.object({}),
access: { roles: ["AUTHENTICATED"] },
async execute({ auth, ctx, c }) {
// genericOAuth plugin method — needs a cast since the default-generic
// `Auth` type doesn't carry plugin-specific API surface.
const token = await (auth.api as any).getAccessToken({
headers: c.req.raw.headers,
body: { providerId: "google", userId: ctx.userId },
});
return { accessToken: token.accessToken };
},
});Lazy on Cloudflare: the generated handler emits a Proxy so
createAuth(c.env, c.req.raw.cf) only runs the first time you read a
property on auth. Actions that never touch auth pay zero construction
cost. On Bun/Node auth is the imported singleton (an IIFE that lazy-loads
the database adapter) wrapped in the same proxy shape — call sites are
identical across runtimes.
Typing: auth is typed as Auth from better-auth. The base API
(getSession, signOut, signInEmail, etc.) is fully typed. Plugin-only
methods (getAccessToken from genericOAuth, admin-plugin methods, etc.)
need a cast at the call site — the default-generic Auth doesn't infer
the project's specific plugin set.
whereRecord — explicit, scope-safe WHERE
For protected-field writes inside record-based actions, prefer
whereRecord(table) over eq(table.id, record.id). It returns a Drizzle
expression that AND-merges the resource's firewall and soft-delete predicates
with the record id, so the WHERE is explicit even if a future refactor swaps
db back to an unscoped client:
// features/applications/actions/advance.ts
import { defineAction } from "../.quickback/define-action";
import { applications } from "../applications";
export default defineAction({
description: "Advance",
input: /* … */,
access: { roles: ["owner"] },
async execute({ db, record, input, whereRecord }) {
const [updated] = await db
.update(applications)
.set({
status: input.nextStatus,
notes: input.notes ?? record.notes,
})
.where(whereRecord!(applications)) // firewall + soft-delete + id, all in one
.returning();
return updated;
},
});The audit DB wrapper hard-stamps modifiedAt/modifiedBy on every
.set() call, so handlers don't write them. The same wrapper hard-stamps
createdAt/createdBy/modifiedAt/modifiedBy on every .values().
Caller-supplied audit fields are dropped on the floor — the audit log
always reflects the real actor and the real moment.
whereRecord is undefined for standalone actions (they have no record), so
the parameter is typed as optional. whereTransition extends whereRecord
with the transition's "from" precondition for transition actions.
HTTP API Reference
Request Format
| Method | Input Source | Use Case |
|---|---|---|
GET | Query parameters | Read-only operations, fetching data |
POST | JSON body | Default, state-changing operations |
PUT | JSON body | Full replacement operations |
PATCH | JSON body | Partial updates |
DELETE | JSON body | Deletion with optional payload |
Response Formats
| Type | Content-Type | Use Case |
|---|---|---|
json | application/json | Standard API responses (default) |
stream | text/event-stream | Real-time streaming (AI chat, live updates) |
file | Varies | File downloads (reports, exports) |
Error Codes
| Status | Description |
|---|---|
400 | Invalid input / validation error (also GUARD_FIELD_PROTECTED for direct protected-field writes) |
401 | Not authenticated |
403 | Access role check failed (ACCESS_ROLE_REQUIRED) |
404 | Record not found (record-based actions) |
409 | Record is in the wrong state for this action — access.record predicate or transition policy failed (ACCESS_ACTION_NOT_ALLOWED_FOR_STATE) |
500 | Handler execution error |
Validation Error Response
{
"error": "Invalid request data",
"layer": "validation",
"code": "VALIDATION_FAILED",
"details": {
"fields": {
"amount": "Expected positive number"
}
},
"hint": "Check the input schema for this action"
}Error Handling
Option 1: Return a JSON error response (recommended for most cases)
Since action handlers receive the Hono context (c), you can return error responses directly:
async execute({ ctx, record, input, c }) {
if (record.stage === 'hired') {
return c.json({
error: 'Cannot modify a hired application',
code: 'ALREADY_HIRED',
details: { currentStage: record.stage },
}, 400);
}
// ... continue
}Option 2: Throw an ActionError
import { ActionError } from "@quickback/types";
async execute({ ctx, record, input }) {
if (record.stage === 'hired') {
throw new ActionError('Cannot modify a hired application', 'ALREADY_HIRED', 400, {
currentStage: record.stage,
});
}
// ... continue
}The ActionError constructor signature is (message, code, statusCode, details?).
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.
Transition (state-machine policy)
For protected fields that follow a state machine — application status, invoice
lifecycle, order fulfilment — use transition on the action so the compiler
generates a runtime guard that enforces the from/to pair, not just the
current state. Without it, an advance action whose input enum allows
["screening", "interview", "offer"] and whose access.record allows
status in ["applied", "screening", "interview"] will happily run
applied → offer (a skip) or interview → screening (a regression).
// features/applications/actions/advance.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";
import { applications } from "../applications";
export default defineAction({
description: "Move an application forward in the pipeline.",
input: z.object({ nextStatus: z.enum(["screening", "interview", "offer"]) }),
access: { roles: ["owner", "admin"] },
transition: {
field: "status",
via: "nextStatus", // read target from input.nextStatus
fromTo: {
applied: ["screening"],
screening: ["interview"],
interview: ["offer"],
},
},
async execute({ db, record, input, whereTransition }) { /* … */ },
});// features/applications/actions/hire.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";
import { applications } from "../applications";
export default defineAction({
description: "Finalize an offer.",
input: z.object({}),
access: { roles: ["owner"] },
transition: {
field: "status",
to: "hired", // fixed target — same value every call
fromTo: { offer: ["hired"] },
},
async execute({ db, record, whereTransition }) { /* … */ },
});| Field | Required | Description |
|---|---|---|
field | Yes | Record column to validate (e.g. "status") |
fromTo | Yes | Map of current value → permitted next values. Keys also enumerate the legal source states. |
via | Yes (or to) | Input key that carries the target value. Use for variable-target actions like advance. |
to | Yes (or via) | Literal target value the action always writes. Use for fixed-target actions like hire/reject. |
Exactly one of via or to must be provided. The compiler rejects both at
validate time. When to is set, it must appear in some fromTo[*] array —
otherwise the action would be unreachable, which is almost certainly a bug.
Wire-level errors
The compiled guard returns 409 ACCESS_ACTION_NOT_ALLOWED_FOR_STATE with
the field, the current value, the requested target, and the allowed set:
{
"error": "Action not allowed for current record state",
"layer": "access",
"code": "ACCESS_ACTION_NOT_ALLOWED_FOR_STATE",
"details": {
"field": "status",
"current": "interview",
"target": "screening",
"allowedTargets": ["offer"]
},
"hint": "From \"interview\", status can transition to: offer"
}This is distinct from a role failure: if the caller has the wrong role they
get 403 ACCESS_ROLE_REQUIRED as before. Splitting the two lets clients
tell "you can't do this" from "you can't do this yet" — the latter is
recoverable by the user, the former isn't.
The same 409 code is used when an access.record predicate (without
transition) fails — for example record: { status: { equals: "pending" } }
called against a paid invoice now returns 409 ACCESS_ACTION_NOT_ALLOWED_FOR_STATE,
not a misleading 403.
Examples
Bulk Import (Standalone, No Record)
// features/candidates/actions/bulk-import.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";
import { candidates } from "../candidates";
export default defineAction({
description: "Import candidates from job board CSV",
path: "/candidates/import",
input: z.object({
data: z.array(z.object({
email: z.string().email(),
name: z.string(),
})),
}),
access: { roles: ["hiring-manager", "recruiter"] },
async execute({ db, input }) {
const inserted = await db
.insert(candidates)
.values(input.data)
.returning();
return { imported: inserted.length };
},
});