Quickback Docs

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 insteadWhere
check a row's current state, flip a column, stamp who/whentransitions on the table — plus guard, stamp/clears (which makes execute optional), idempotent, undo, onEnter cascadesTransitions
write a parent row plus its owned children in one callchangesets — declare the owns boundary; each op still pays the child's own firewall, access, and guardsChangesets
fire a side-effect after a write — denormalize a counter, stamp a column, enqueue a jobtriggers — SQL triggers cover every write path (REST, actions, cron, raw SQL), not just the one route you rememberedTriggers
return the same rows with a different column set per roleread.views — its own route, access rule, pagination, and maskingViews

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:

AspectRecord-BasedStandalone
Route{METHOD} /:id/{actionName}Custom path (required)
Record fetchingAutomaticNone (record is undefined)
Firewall appliedYesNo
PreconditionsSupported via access.recordNot applicable
Response typesJSON onlyJSON, stream, file
Use caseAdvance application, reject candidateAI 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)
LayoutBinds to
actions/<name>.tsThe feature's primary table (table file matching the feature name), or standalone if path: is set.
actions/<table>/<name>.tsThe 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 identifiersendMessage.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.ts filename 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/messages runs send's execute. Status is 201.
  • OpenAPI / MCP keep createMessages (or the table's CRUD operation id). The request body is the action's input, 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 /batch is suppressed (the stock batch would skip send). Set batch: false explicitly if you also declare a batch config.

Composition

Fail-closed, one rule:

LayerWho owns it
URL, method, operation idThe CRUD verb
AccessBoth: create.access AND the action's access must pass
GuardsSkipped — the action's input is the body contract
FirewallTable 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 db handle, AUTH_DB vs ctx, 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.

On this page