Actions
Custom API endpoints for business logic beyond CRUD. One file per action, in a feature's actions/ directory.
Actions are custom API endpoints for business logic beyond CRUD operations. They enable workflows, integrations, and complex operations.
Before you write one — four primitives that may already do it
An action is a file you own and maintain. These are declarations the compiler owns, and each generates the same real endpoint, OpenAPI operation, and MCP tool, on the same firewall/access/guards pipeline. Check all four first — the most common needless action is one of these hand-rolled.
| If the action would… | Declare instead | Where |
|---|---|---|
| check a row's current state, flip a column, stamp who/when | transitions on the table — plus guard, stamp/clears (which makes execute optional), idempotent, undo, onEnter cascades | Transitions |
| write a parent row plus its owned children in one call | changesets — declare the owns boundary; each op still pays the child's own firewall, access, and guards | Changesets |
| fire a side-effect after a write — denormalize a counter, stamp a column, enqueue a job | triggers — SQL triggers cover every write path (REST, actions, cron, raw SQL), not just the one route you remembered | Triggers |
| return the same rows with a different column set per role | read.views — its own route, access rule, pagination, and masking | Views |
Write the action for the remainder: third-party calls, multi-step orchestration, AI/streaming responses, anything genuinely imperative.
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, and
it must be a valid camelCase identifier — sendMessage.ts, not
send-message.ts (hyphenated names are a compile error, since the name becomes
a generated identifier).
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.
Replace a CRUD verb
CRUD routes are compiler-owned. When a live POST /messages (or
PATCH / DELETE /:id) has to grow — side effects, a different body, a
protocol-shaped response — map that verb to a named action. The URL, OpenAPI
operation id, and MCP tool stay; execute becomes the handler.
// features/messages/messages.ts
export default feature("messages", {
columns: { /* … */ },
read: { access: { roles: ["member"] } },
create: {
access: { roles: ["member"] },
action: "send", // features/messages/actions/send.ts
},
update: { access: { roles: ["member"] } },
delete: { access: { roles: ["admin"] } },
});// features/messages/actions/send.ts
import { z } from "zod";
import { defineAction, messages } from "../.quickback/define-action";
export default defineAction({
description: "Send a message",
path: "/messages", // must equal the collection URL
method: "POST",
input: z.object({ body: z.string().min(1) }),
access: { roles: ["member"] },
async execute({ db, input, ctx }) {
const [row] = await db.insert(messages).values({
body: input.body,
organizationId: ctx.activeOrgId!,
}).returning();
return row;
},
});Expected:
POST /api/v1/messagesrunssend'sexecute. Status is201.- OpenAPI / MCP keep
createMessages(or the table's CRUD operation id). The request body is the action'sinput, not the table's createable fields. - Unmapped verbs keep the generated handler. Mapping is per-verb.
- The action's own route is not mounted — there is no second
POST /messages/send. - Auto-promoted
POST /batchis suppressed (the stock batch would skipsend). Setbatch: falseexplicitly if you also declare a batch config.
Composition
Fail-closed, one rule:
| Layer | Who owns it |
|---|---|
| URL, method, operation id | The CRUD verb |
| Access | Both: create.access AND the action's access must pass |
| Guards | Skipped — the action's input is the body contract |
| Firewall | Table firewall on update/delete record fetch; create uses scoped db in execute |
Compile errors
The compile fails if the named action is missing, bound to a different table,
the wrong shape (create needs a standalone path: equal to the collection URL;
update/delete need a record-based action with no path:), declares a
conflicting method, is already mapped to another verb, or the table also
declares owns (changeset dispatch lives on the stock handler). read.action
is not supported.
The rest of this section
- Defining actions — the action file, its configuration options, protected fields, and refs
- Record and standalone actions — the two shapes, and what
path:changes - Access — roles, record conditions, context substitution, relationship-roles
- Scoped database — the firewalled
dbhandle, AUTH_DB vsctx, unsafe mode, raw SQL policy, sharing code - Examples — end-to-end action files
Related: Transitions for state-machine policy, Changesets for aggregate writes, Triggers for after-commit hooks, and the Actions API for calling an action over HTTP.
Triggers - Postgres-Style Table Triggers
Run data rules and side-effects on writes. SQL triggers compile to real SQLite triggers on D1; handler triggers run as application hooks at the write chokepoint.
Defining Actions
The action file, its configuration options, protected fields, and scoped foreign-key inputs.