Record and Standalone Actions
The two action shapes. A path field makes an action standalone; without one it binds to :id and receives the record.
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
- Input Validation - Request body validated against Zod schema
- Role Gate - The access roles are checked before the record is
fetched (v0.46+), so a wrong-role caller gets a uniform
403 ACCESS_ROLE_REQUIREDwhether or not the record exists — the response can't be used to probe for record ids - Record Loading - The record is fetched by ID through the firewall (a cross-tenant or missing id is a standard firewall miss)
- Access Check - Record preconditions (
access.record) and transition policy are validated against the loaded record - 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, applications } from "../.quickback/define-action";
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
import { z } from "zod";
import { defineAction, applications, TransitionLostError } from "../.quickback/define-action";
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.
The generated OpenAPI document and MCP tools reuse that exact action schema
in both places: as the single-record request body and as the bulk wrapper's
nested input property. Under contract v2, Quickback considers the action's
schema harvest complete only after both operations are patched. This also
holds when the table filename and declared table name use different casing or
separators (for example, event-guests.ts exporting event_guests); the
operations follow the table's declared resource path.
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 |
|---|---|---|---|
| tx-supporting (postgres, neon over websocket) | true | true | db.transaction() wraps the loop; any throw rolls back all writes |
| (any) | false | false | Independent per-record outcomes, partial success |
| cloudflare-d1, neon over http (Cloudflare default) | true | false | Fail-fast loop without rollback — earlier records stay committed (the driver has no db.transaction; drizzle-orm/neon-http throws at runtime) |
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/stripePaths are absolute (/...). Inside a feature area
with a mounted prefix, two more forms apply:
- Relative —
path: './tasks/add'resolves against the area's prefix (/project/:projectId/tasks/add). Plain descending segments only;./..segments are rejected at compile time. - Self-gated — an absolute path outside the area's prefix opts the action out of the
area's admission gate and MUST carry
route: 'self'(fail-closed; the marker is rejected where it would be dead). The action is then gated solely by its ownaccess:and listed inquickback/authz-manifest.json.
An action's access: and anchor: may reference vocabulary declared by an ancestor area —
and never vocabulary of an area the feature is not inside (compile error).
Feature-area transition adapter
A record state transition normally lives on a
table-bound record action (POST /:id/{action}). When your clients call a nested
feature-area route instead, you can attach the same compiler-owned transition to
an explicit (path:) action by adding a record: binding. The binding is what lets a
transition ride a standalone action — without it, transition on a path: action is
a compile error.
// features/events/event_documents.ts
import { q, defineTable } from "@quickback/compiler";
export const event_documents = q.table("event_documents", {
id: q.id(),
eventId: q.text().required(),
status: q.text().default("draft").required(),
publishedAt: q.text().optional(),
organizationId: q.scope("organization"),
...q.audit(),
...q.softDelete(),
});
export default defineTable(event_documents, {
firewall: [{ field: "organizationId", equals: "ctx.activeOrgId" }],
});
// features/events/event_documents/actions/publishEventDocument.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";
export default defineAction({
path: "/events/:eventId/documents/:documentId/publish",
method: "POST",
description: "Publish one completed event document.",
input: z.object({ idempotencyKey: z.string().min(8) }),
access: { roles: ["member+"] },
// The compiler — not the action body — binds and firewalls the record.
record: {
table: "event_documents",
idFrom: "path.documentId", // MUST be a route param, never a body field
matchScope: "event", // row.eventId === ctx.event.id (area-proven)
},
transition: {
field: "status",
to: "published",
fromTo: { draft: ["published"] },
idempotent: "noop",
stamp: { at: "publishedAt" },
},
async execute({ record, invalidate }) {
// `record` is already bound, firewalled, area-matched, and transitioned.
// Own the response projection + invalidations here.
return { success: true as const, document: record };
},
});Security semantics (all enforced by the compiler, fail-closed):
- The record id is bound from the proven route param named in
idFrom(path.<param>); a request-body source is rejected at compile time. - The row is loaded through the scoped db, so the table's full firewall
(org / owner / team + soft-delete) applies — a cross-tenant id is a
404, never a leak. matchScopeasserts the row belongs to the area-proven scope (e.g.:eventIdhydrated ontoctx.event); an unhydrated scope fails closed (500), and a row under a different scope is a404(no cross-scope existence oracle).- The transition keeps its TOCTOU-safe guarded UPDATE — the from-state is re-validated
in the
WHERE, and a lost race returns409ACCESS_TRANSITION_LOST. - Area admission and the action's own
access:still AND-compose; the adapter widens neither. idempotent: "noop"short-circuits beforeexecute, so a no-op never fires the action's invalidations. Existing record actions and table-level transitions are unchanged.
Example: 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/
├── trialBalance.ts ← standalone (has path:)
└── profitLoss.ts ← standalone (has path:)Action filenames are the action's identifier and must be valid camelCase — a
hyphen is a compile error. The URL in path: is independent and may be kebab-case.
// features/reports/actions/trialBalance.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: [] };
},
});GET vs POST for input
GET actions read input from URL query parameters; every other method (POST,
PUT, PATCH, DELETE) reads from the JSON body. The Zod schema is the same in
both cases — the compiler picks the right parser based on method.
// features/search/actions/search.ts — input populated from ?q=…&limit=…
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";
export default defineAction({
description: "Full-text search across the workspace, newest match first.",
method: "GET",
path: "/search",
input: z.object({
q: z.string(),
limit: z.coerce.number().int().positive().max(100).default(20),
}),
access: { roles: ["AUTHENTICATED"] },
async execute({ input }) {
// input is { q: string, limit: number } — fully typed.
},
});Repeated query keys (?tag=a&tag=b) come through as arrays automatically;
single values stay as strings. Use Zod's .coerce.number() /
.coerce.boolean() when you want typed primitives from the parsed strings.
Use POST when input is structured. GET requests cannot carry a JSON body in any of the surfaces Quickback APIs are commonly called from:
- Browsers throw on
fetch(url, { method: 'GET', body }). - CDNs and caches key on URL+method; a GET with a body either gets the body stripped at the edge or cache-collides with semantically different requests.
- OpenAPI client generators (axios, openapi-fetch, openapi-typescript-codegen) refuse to emit GET-with-body, so the generated SDKs break.
- The MCP agent surface treats GET as parameter-only — agents calling a GET action will only see what the URL exposes.
If your action takes nested objects, arrays of objects, or large inputs that
don't serialize cleanly to a query string, set method: "POST". POST doesn't
imply "this writes data" — it's the right method whenever the request body is
load-bearing. Use the response shape (responseType: "json", or just the data
your handler returns) to convey read semantics; HTTP method choice is about
input transport, not action intent.