# Actions

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](/define/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](/define/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](/define/triggers) |
| return the same rows with a different column set per role | `read.views` — its own route, access rule, pagination, and masking | [Views](/define/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.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.

{/* doc-compile: skip — two files (table + action); the gate compiles each fence as an isolated project. */}
```ts
// 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"] } },
});
```

```ts
// 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:

| 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](/define/actions/defining)** — the action file, its configuration options, protected fields, and refs
- **[Record and standalone actions](/define/actions/record-and-standalone)** — the two shapes, and what `path:` changes
- **[Access](/define/actions/access)** — roles, record conditions, context substitution, relationship-roles
- **[Scoped database](/define/actions/scoped-db)** — the firewalled `db` handle, AUTH_DB vs `ctx`, unsafe mode, raw SQL policy, sharing code
- **[Examples](/define/actions/examples)** — end-to-end action files

Related: **[Transitions](/define/transitions)** for state-machine policy,
**[Changesets](/define/changesets)** for aggregate writes,
**[Triggers](/define/triggers)** for after-commit hooks, and the
**[Actions API](/api/actions)** for calling an action over HTTP.
