Actions
Actions are domain operations that live alongside CRUD — "Block User", "Approve Order", "Export CSV", "Edit (navigate)". @atscript/moost-db exposes a decorator family that lets you declare these once on a controller and have them surface in GET /meta so any UI client can render row buttons, batch toolbars, header buttons, and double-click gestures generically.
A declared action is one of three kinds (processor):
backend— server-side POST handler (your method).navigate— UI route push (URL template, no server call).custom— UI-dispatched event (no server call, no navigation).
Actions also carry a level — 'row', 'rows', or 'table' — telling the UI where the affordance belongs.
Backend actions can additionally declare a form input via @InputForm(): the controller advertises a .as interface as the action's payload schema, the UI fetches it from a per-controller endpoint, renders a form, and submits the user-filled data alongside the identifier — no manual modal plumbing.
See Permissions for the built-in CRUD surface (
/meta.crud). Actions and CRUD permissions are sibling fields on/metawith the same overlay strategy but distinct dispatch paths — typed client methods for CRUD,Client.action()for actions.
Quick Example
A row-level "Block" action that POSTs to a server handler:
// schema/user.as
@db.table 'users'
export interface User {
@meta.id
id: string
name: string
@db.default false
blocked: boolean
}import { AsDbController, TableController, DbAction, DbActionID } from "@atscript/moost-db";
import { Post } from "@moostjs/event-http";
import { User } from "./schema/user.as";
import { usersTable } from "./db";
@TableController(usersTable)
export class UsersController extends AsDbController<typeof User> {
@Post("actions/block")
@DbAction("block", { label: "Block", icon: "i-as-block", intent: "negative" })
async blockUser(@DbActionID() id: { id: string }) {
await this.table.updateOne({ id: id.id, blocked: true });
return { message: `User ${id.id} blocked` };
}
}Fetch GET /users/meta and the actions array now contains:
{
"actions": [
{
"name": "block",
"label": "Block",
"level": "row",
"processor": "backend",
"value": "/users/actions/block",
"icon": "i-as-block",
"intent": "negative"
}
]
}A UI consuming /meta renders a per-row "Block" button. When the user clicks it, the client POSTs the row's identifier wrapped in the action envelope:
curl -X POST http://localhost:3000/users/actions/block \
-H "Content-Type: application/json" \
-d '{"ids":{"id":"abc123"}}'
# → { "message": "User abc123 blocked" }The body is an envelope: { ids?, input? }
Every action request body is an object envelope. ids carries the identifier(s); input carries the optional @InputForm payload, validated server-side against the declared form (see Form input). Both fields are optional: a 'table'-level action with no form declares no ids, and an action without @InputForm carries no input. Even single-field PK tables send { "ids": { "id": "abc" } }, never the bare scalar. See Body envelope.
Action Levels
The level tells the UI where the action belongs. It is inferred from the parameter decorators of the handler — you never set it directly on @DbAction:
| Parameter decorator(s) | Inferred level | Body envelope (JSON) |
|---|---|---|
@DbActionID() or @DbActionRow() | row | { "ids": { ... } } — identifier object as the ids field |
@DbActionIDs() or @DbActionRows() | rows | { "ids": [ ... ] } — array of identifier objects |
| (none) | table | empty body (or { "input": ... } when paired with @InputForm) |
| Both row + rows cardinality | illegal | action dropped from /meta with a [moost-db actions] warning |
@InputForm() is orthogonal to level — it adds an input field to the envelope without affecting whether the action is row/rows/table.
@DbActionRow() / @DbActionRows() inject the actual row(s) (already loaded by the gate); they are described under Server-side Gate § Row injection.
For class-level actions (declared via @DbActions family), you set level on the dict entry — see Class-level actions below.
Body envelope
Every action POST body is a JSON object envelope with optional fields:
{
ids?: object | object[], // identifier(s) — see Identifier shape below
input?: unknown, // payload for @InputForm — see Form input below
query?: { q: string, … }, // since 0.1.147: "every row matching" — see Query targets
}query is accepted only by 'rows' actions declaring queryTarget, never together with ids — see Query targets. Since 0.1.147 a query key on any other 'rows' action is a 400 TARGET_INVALID (it used to be ignored), and every 'rows' action reads and validates ids in its gate interceptor — the same 400 as before, but ahead of interceptors of a lower priority and before the handler's arguments resolve.
The envelope shape is fixed: arrays or scalars at the body root are rejected with HTTP 400 ValidatorError. This is a breaking change from the pre-@InputForm() shape that placed identifiers at the root — older clients sending the bare identifier (e.g. {"id":"abc"} or [{"id":"a"}]) need to be updated to wrap them in ids.
Empty {} is always valid — it means "table-level action, no input, no identifier". A 'table'-level action with no @InputForm may be invoked with no body at all; the client SHOULD omit the body in that case (and @atscript/db-client does).
Identifier shape
The ids field is always an object (single) or array of objects (multi) — never a scalar. Each object's field set must EXACTLY match one legitimate identification on the table:
- the primary key (
primaryKeys), or - any declared
@db.index.uniquegroup (single-field or compound).
A unique group over a field the controller's hasField hides is not an identification (since 0.1.134). It is rejected like an unknown shape, and the error does not list it.
The validator is strict — unknown fields are rejected with HTTP 400. Precedence: PK first, then unique-index groups in declaration order. The same @DbActionIDs() array MAY mix shapes per-element (one element by PK, another by email, etc.).
{ "ids": { "id": "abc123" } } // row, single-field PK
{ "ids": { "tenantId": "acme", "userId": "u1" } } // row, composite PK
{ "ids": { "email": "jane@example.com" } } // row, unique-index addressing
{ "ids": [{ "id": "a" }, { "id": "b" }] } // rows, single-field PK
{ "ids": [{ "id": 1 }, { "email": "x@y" }] } // rows, mixed identifier shapesEven single-field PK tables MUST send { "ids": { "id": "abc" } }, never bare "abc". Content-Type: application/json only.
Field names are logical (the .as prop names) — never physical column names from @db.column "...". The matcher always operates in logical-name space.
Identifier values must be scalars (since 0.1.143). An object or array value, such as { "id": { "$ne": null } }, is rejected with 400 whatever the field's type.
A 'rows' request carries at most maxIds identifiers (default 1000, since 0.1.143). See Id-count cap.
Resolving ids — resolveRowIds
Since 0.1.148. A controller that overrides resolveRowIds(ids, ctx) maps each action id through it once per request (ctx.purpose === "action", with ctx.action and ctx.level). Handlers (@DbActionID() / @DbActionIDs(), useDbActionId(s), @DbActionRow(s)), actionRowScope and target.batches() receive the resolved ids; a 'rows' request whose ids resolve to the same row keeps the first. A literal id repeated in one request ([T-A, T-A]) is judged per request id, so it is echoed and counted twice (matched, ids); only the handler sees it once. The ActionDisabledError ids and a target summary's skipped / failed ids are the ones the client sent. A query target's ids come from rows and never go through the hook. The contract and the safety rules are on the resolveRowIds page.
Row scoping
Since 0.1.143, action ids and rows obey the controller's row overlay. The overlay is the one GET /one/:id uses: transformOne({}), which defaults to transformFilter. Since 0.1.145 the loaded rows must also lie inside the action's own actionRowScope. Whether the gate verifies a request's ids before the handler runs depends on what there is to verify (see the gate table below). This applies with or without disabled:
| Action | Id outside the overlay |
|---|---|
'row': @DbActionRow() or a disabled gate | The row load includes the overlay. The request gets the same 404 as a missing row (code: "NOT_FOUND"). |
'row': @DbActionID() only | With an overlay or a restricting actionRowScope, the row is loaded (id AND overlay AND scope) before the handler, like @DbActionRow(). The request gets the same 404. |
'rows': @DbActionIDs() / @DbActionRows(), gated or not | The id fails like a missing id: same position in ids, null reason. onDisabledRows then applies ('reject' → 409, 'skip' → dropped). |
An out-of-scope id gets exactly the answer a nonexistent id gets. Nothing in the status, body, ids or reasons tells them apart. A disabled reason is never computed for an out-of-scope row, so the reason cannot leak that row's state. The handler never runs for an out-of-scope id.
@TableController(OrderTable)
export class OrdersController extends AsDbController<typeof OrderTable> {
protected override transformFilter(filter: FilterExpr): FilterExpr {
return { $and: [filter, { ownerId: currentUserId() }] };
}
@Post("actions/cancel")
@DbAction("cancel", { label: "Cancel" })
async cancel(@DbActionID() id: { id: string }) {
// Reached only for the caller's own orders. Any other id → 404.
}
}Notes:
- The check costs nothing when there is nothing to verify (see the gate table). No query is added. Without an overlay and a restricting scope,
'rows'actions keep an unmatched id as anundefinedgap in@DbActionRows()(gated actions still fail it). - Under an overlay, an ungated
'rows'action rejects unmatched ids by default, missing and out-of-scope alike. SetonDisabledRows: 'skip'to drop them instead. - The checks run in an interceptor that
@DbActionregisters. A@DbActionID*param on a route without@DbActionis not checked.@DbActionRow*loads still include the overlay. - The overlay applies only to the controller's own table. An
opts.tablebinding on a plain controller has no overlay. - A
requiredFieldsentry that the controller'shasFieldhides is never loaded, nor is a@db.column.derivedfield whose source it hides. Thedisabledpredicate and@DbActionRow*see it asundefined, so a hidden column never drives a verdict.
What the gate verifies
Since 0.1.148 the gate asks the action's actionRowScope first, with the request's ids, and loads rows only when its answer restricts. An override that restricts nothing — it returns undefined, null or {} for this action, as a permission layer does for the actions a caller may run on any row — makes the gate load nothing (upgrading).
| Row overlay | actionRowScope for the action | Gate on 'row' / ungated 'rows' |
|---|---|---|
| none | not overridden, or unrestricted | Nothing. A missing id reaches the handler: @DbActionRow / useDbActionRow answer 404 when they load it; an id-only handler sees the miss itself (for example matchedCount: 0). |
| none | restricted, ids in preferredId shape | One query id AND scope. A miss or an out-of-scope id gets the same 404 / failing id. |
| none | restricted, ids in a unique-index shape | The row loads, then the scope is asked for the loaded candidate (the hook sees its preferredId). |
| present | any | The row loads as id AND overlay, then the scope is asked for the loaded candidate. |
The gate verifies existence only when it has something to verify: a row overlay, a non-empty action scope, a disabled rule or a query target. 403 means no grant (from prepareRequest); an out-of-scope id and a missing one stay indistinguishable (404 / failing id with a null reason). The hook's presence no longer changes behavior — only its answer does — and no new oracle appears, because the unrestricted case is exactly a controller without the hook. A disabled gate and handler-side @DbActionRow* loads fold a restricting scope into their one row query, too.
Per-action scope — actionRowScope
Since 0.1.145. A permission layer may let a caller read many rows but run an action on only some of them, for example approve only on their own orders. Override actionRowScope(actionName, ctx) to return the rows that action may run on, as a filter. undefined or {} means no restriction, which is the default:
@TableController(OrderTable)
export class OrdersController extends AsDbController<typeof OrderTable> {
protected override actionRowScope(action: string) {
return action === "approve" ? { ownerId: currentUserId() } : undefined; // may be async
}
}- Enforced by the action gate. The action's ids and rows load under the row overlay above and must match this filter (without an overlay the filter joins the load — see the gate table), with the same outcomes as the table: an out-of-scope id gets the 404 of a missing row on
'row'actions and fails like a missing id on'rows'actions. You need notransformOneoverride for this. - Reflected in reads.
$actionsandGET /meta/actions/:idlist the action only on rows inside its scope. - It runs after
prepareRequest, at most once per action per evaluation, and may be async. Equal filters share one query: the same object, or (since 0.1.147) structurally equal ones. - Give it no side effects a caller could observe (headers, logs they can read, external calls). It runs for an existing row outside the scope but not for a missing one, so a visible effect would reveal that the row exists.
- The filter may use fields
hasFieldhides. It is never exposed in a response. - It applies only to the controller's own table, like the overlay. Nothing extra runs when it is not overridden.
The scope may depend on related rows through a relational predicate (since 0.1.147). It is a server-side filter, so the relation needs no @db.rel.filterable and no transformRelationFilter applies. On an issue controller, resolve runs only on issues whose ticket is open and belongs to one of the caller's teams:
@TableController(IssueTable)
export class IssuesController extends AsDbController<typeof IssueTable> {
protected override actionRowScope(action: string) {
return action === "resolve"
? { ticket: { $some: { teamId: { $in: currentTeams() }, status: "open" } } }
: undefined;
}
}The same filter drives the action gate, $actions and GET /meta/actions/:id.
Scopes that depend on the candidate rows
Since 0.1.147 the hook receives the candidate rows as its second argument, a TDbActionScopeContext. Use it when the rule can't be written as a relational predicate — for example the rule's table has no declared relation to this one, or the check is not a filter at all. Here the issue table carries a ticketKey but declares no ticket relation:
import type { TDbActionScopeContext } from "@atscript/moost-db";
@TableController(IssueTable)
export class IssuesController extends AsDbController<typeof IssueTable> {
protected override async actionRowScope(action: string, ctx: TDbActionScopeContext) {
if (action !== "resolve") return undefined;
const issues = await ctx.loadRows(["ticketKey"]);
const tickets = await ticketTable.findMany({
filter: { key: { $in: issues.map((i) => i.ticketKey) }, teamId: currentTeamId() },
controls: { $select: ["key"] },
});
return { ticketKey: { $in: tickets.map((t) => t.key) } };
}
}ctx member | What it is |
|---|---|
purpose | "execute" — the action gate; "rows" — $actions on a read (also a view's delegated verdicts); "available" — GET /meta/actions/:id. |
ids | The candidates' identities (preferredId shape), deduped, never empty. The same array object for every action of one evaluation, so you can memoize on it with a WeakMap. At "execute" without a row overlay these are the request's ids (see below). |
loadRows(fields) | The candidates' fields plus their id fields, read from the bound table without any overlay. Memoized per evaluation and field set. Nothing it reads reaches the response. |
- Candidates are bounded: the read's rows (at most its
$limit), the action's ids (at mostmaxIds), one batch of a query target, or the single row. - Candidates are inside the row overlay: ids that fall outside it never reach the hook. On the action route without a row overlay (and ids in
preferredIdshape, not a query target) the hook is asked before any load:ctx.idsare then the request's ids and may name rows that don't exist —ctx.loadRowsomits them. With no candidate, the hook is not called and no scope query runs. - The result only restricts the candidates. A throw fails the request; it is never read as "allow".
- Order on the action route:
prepareRequest→ row overlay (before the body is read) → ids from the body →resolveRowIds→actionRowScope(before the load when there is no overlay) → row load →actionRowScopeon the loaded candidates otherwise. - One-parameter overrides keep compiling and behaving as before, and so does
super.actionRowScope(name)—ctxis optional in the signature only; moost-db always passes it. - An
actionRowScopeoverride on a controller over a table without a primary key (a view) is warned about only when the controller has row actions of its own — their$actionscan't be scoped without an identity.
prepareRequest on actions
Since 0.1.143, every @DbAction handler — 'row', 'rows' and 'table' level, gated or not — runs the controller's prepareRequest with { endpoint: "action", action: "<name>" }. It runs once per request, after the guards and before anything reads the request's ids, loads its rows or builds its row overlay: id validation (which consults hasField), @DbActionRow* loads, the disabled gate and the row scoping above all see the policy it resolved. A throw aborts the request with the thrown error, even when the body is malformed.
@TableController(OrderTable)
export class OrdersController extends AsDbController<typeof OrderTable> {
protected async prepareRequest(ctx: TDbRequestContext) {
// ctx.endpoint === "action", ctx.action === "cancel" for the route below
const scopes = await loadScopes(ctx.action ?? ctx.endpoint);
if (!scopes) throw new HttpError(403);
requestScopes.set(scopes); // read back by transformFilter / hasField
}
@Post("actions/cancel")
@DbAction("cancel", { label: "Cancel" })
async cancel(@DbActionID() id: { id: string }) {}
}A permission layer that resolves its policy in prepareRequest therefore needs no action guard of its own. Controllers that don't define prepareRequest pay nothing: the interceptor returns without awaiting. A plain controller (not an AsReadableController subclass) has no prepareRequest; its 'table'-level actions get no interceptor at all.
Id-count cap
@DbActionIDs() / @DbActionRows() accept at most maxIds identifiers per request. The default is 1000 (since 0.1.143). A longer ids array is rejected with 400 before any row is loaded:
{
"statusCode": 400,
"errors": [{ "path": "", "message": "Too many identifiers: 1500 (at most 1000 per request)" }]
}To raise or lower the cap for one action:
@DbAction("purge", { label: "Purge", maxIds: 5000 })Preferred row identifier
The interface-level annotation @db.table.preferredId.uniqueIndex(name?: string) picks a unique-index group as the row's display/addressing identifier. When omitted, preferredId defaults to primaryKeys.
@db.table 'users'
@db.table.preferredId.uniqueIndex 'by_slug'
interface User {
@meta.id
@db.default.uuid
id: string
@db.index.unique 'by_slug'
slug: string
name: string
}/meta.preferredId: string[] is always populated and always logical names. Used by:
- Navigate URLs —
$1substitution walkspreferredIdfield declaration order. - Backend action body — clients can POST the preferred-id shape (
{ slug: 'alpha' }) instead of the PK. - Reactive list keys — guaranteed present on every read response (see Read-response baseline).
The table API exposes the same fields via readable.preferredId: readonly string[] alongside readable.primaryKeys.
Three Processors
'backend' — server-side POST handler
The most common case. Decorate a method with @DbAction(name, opts) plus @Post(path) and Moost binds the route normally:
@Post("actions/approve")
@DbAction("approve", { label: "Approve", intent: "positive" })
async approve(@DbActionID() id: { id: string }) {
await this.table.updateOne({ id: id.id, approved: true })
return { message: 'Approved' }
}The value field in /meta is filled in by the meta builder with the bound HTTP path (controller prefix + method path). You don't compute it.
'navigate' — UI route push
For "Edit", "View Details", or any action that just routes to another page. Declared at the class level only:
import { DbRowActions } from "@atscript/moost-db";
@TableController(usersTable)
@DbRowActions({
edit: { label: "Edit", processor: "navigate", value: "/users/$1/edit" },
})
export class UsersController extends AsDbController<typeof User> {}The $1 placeholder is substituted client-side with the row's preferredId field values, walking meta.preferredId declaration order (NOT object-key insertion order). Each value is encodeURIComponent'd; compound preferred-ids are joined with /; missing fields render as empty segments (acme//jane), not the literal "undefined". The server emits value verbatim. See Preferred row identifier and the identifier rendering helpers (formatIdentifier / encodeNavigateId) the client exports for use outside Client.action().
'rows'- and 'table'-level navigate entries do NOT substitute $1 — value is sent verbatim.
'custom' — UI-dispatched event
For actions whose entire behaviour lives in the UI (open a modal, copy to clipboard, kick off a client-only export). No server call, no navigation:
@DbTableActions({
exportCsv: { label: "Export CSV", processor: "custom" },
})
export class OrdersController extends AsDbController<typeof Order> {}The UI receives processor: 'custom' and value: 'exportCsv' (the dict key). It dispatches an event with that name and your client code handles it. value is forbidden in 'custom' entries — the meta builder fills it.
Method Decorators
Use these when the action has a server-side handler.
@DbAction<TRow, const R>(name, opts?)
Marks a method as an action. Does not register an HTTP route — pair it with @Post(...). The name is the action's stable identifier surfaced to the UI.
The decorator is generic over TRow (the bound table's row type) and R (the literal requiredFields tuple). Annotate <TRow> at the call site — TS decorators can't infer it from the enclosing controller's class generic. R is captured via const R from the requiredFields literal.
| Option | Type | Description |
|---|---|---|
label | string | Human-readable label. Required (or use @Label('...')). |
icon | string | Icon name; UI maps to its own icon set. |
intent | 'positive' | 'negative' | 'warning' | 'primary' | 'secondary' | Semantic colour/prominence hint. Suggested ordering (most → least): negative (destructive) > warning (risky-but-not-destructive: retry, force-recompute) > primary > positive > secondary. |
description | string | Tooltip / longer description. |
order | number | Display order hint. |
default | boolean | Marks this as the level's default (e.g. row dblclick handler). |
promptText | string | [string, string] | Confirmation prompt. Tuple form is [singular, plural] — UI picks [0] when executing against a single ID, [1] otherwise. UI substitutes $1 (preferred-id values) and $N (count). |
shortcut | string | Single-character keyboard hint. Modifier prefix (Alt+, Ctrl+, bare key) and activation scope are UI/UX concerns; server forwards the character verbatim and does no conflict resolution. |
requiredFields | readonly FlatKey<TRow>[] (literal tuple) | Required when disabled is set. Dot-notation paths the predicate references. Server-internal — never on the wire. Type-narrows disabled's row argument and drives projection widening (@DbActionRow* fetch + $actions augmentation). Listing a relation field is a TS error. See requiredFields. |
disabled | (rows: Pick<FlatOf<TRow>, R[number]>[]) => (boolean | string)[] | Sync batch gate predicate. One verdict per input row, parallel by index: truthy = disabled, a string also gives the reason. Without requiredFields → action dropped at discovery. See Server-side Gate. |
onDisabledRows | 'reject' | 'skip' | 'rows'-level batch policy. Default 'reject'. See Batch mode. |
maxIds | number | 'rows' level: the most identifiers one request may carry. Default 1000. Above it → 400. Server-internal. Since 0.1.143. See Id-count cap. |
table | AtscriptDbTable<TRow> | Required when declaring disabled or any @DbActionRow* on a class that does not extend AsDbReadableController. Silently ignored on subclasses (the bound table wins). See Bound-table requirement. |
FlatKey<TRow> = keyof FlatOf<TRow> & string — dot-paths over scalars; relations excluded. When TRow = unknown (no <TRow> generic), all string keys are accepted at the type level and disabled's row arg falls back to any[]. The runtime still drops disabled without requiredFields.
Label resolution
The label resolves in this order: opts.label > @Label('...') decorator > drop-with-warning. Pick one — both with the same value is benign; mismatched values let opts.label win.
@DbActionDefault()
Sugar for default: true. Equivalent to passing opts.default = true on @DbAction. Decorator order does not matter:
@Post("actions/edit")
@DbAction("edit", { label: "Edit" })
@DbActionDefault()
async edit(@DbActionID() id: { id: string }) { /* ... */ }The default action is what UIs invoke on row double-click (or the default key in batch toolbars). At most one default per (controller × level) — extra defaults are demoted with a warning.
@DbActionID() / @DbActionIDs()
Parameter resolvers that read the identifier object(s) from the JSON request body and validate them against the table's legitimate identifications (PK or any @db.index.unique group):
// Single row, single-field PK
@Post("actions/block")
@DbAction("block", { label: "Block" })
async block(@DbActionID() id: { id: string }) {
// body: { "ids": { "id": "abc" } }
}
// Single row, composite PK
@Post("actions/promote")
@DbAction("promote", { label: "Promote" })
async promote(@DbActionID() id: { tenantId: string; userId: string }) {
// body: { "ids": { "tenantId": "acme", "userId": "u1" } }
}
// Single row, unique-index addressing (same controller, same endpoint)
@Post("actions/promote")
async promoteByEmail(@DbActionID() id: { email: string }) {
// body: { "ids": { "email": "jane@example.com" } } — works as long as `email` is `@db.index.unique`
}
// Multiple rows
@Post("actions/lock")
@DbAction("lock", { label: "Lock Selected" })
async lock(@DbActionIDs() ids: Array<{ id: string }>) {
// body: { "ids": [{ "id": "a" }, { "id": "b" }] } (mixed shapes per element are allowed)
}The id the handler receives is the resolved one when the controller overrides resolveRowIds.
Validation is strict — unknown fields are rejected, no coercion. The identifier object's field set must EXACTLY match one legitimate identification on the table. See Identifier shape for precedence rules and the full contract.
rows-level ids is always an array
A 'rows' action MUST receive a JSON array under ids, even when the client invokes it on a single row. Send { "ids": [{"id":"a"}] }, not { "ids": {"id":"a"} }. The @DbActionIDs() resolver rejects non-array ids with HTTP 400. An empty array [] is accepted — client.action(name, []) posts { "ids": [] }, and your handler runs with ids === []. An array longer than maxIds (default 1000) is rejected with 400.
@DbActionID* requires a bound table
@DbActionID() and @DbActionIDs() validate the body against the controller's bound table schema. The bound table is resolved in this order:
opts.table(any controller class) — declare it on@DbAction(name, { table }).- Subclass of
AsDbController/AsDbReadableController(wired with@TableController/@ReadableController) — bound table comes from the controller automatically. - Duck-type fallback — a
readableortableinstance property on the controller (legacy support).
If none resolves at request time, the resolver throws HTTP 500 — a server-misconfiguration signal, not a client error. For controllers that genuinely have no typed table, use Moost's @Body() and parse / validate the identifier yourself.
When you also declare disabled or any @DbActionRow* decorator on a non-AsDbReadableController class, the duck-type fallback is NOT sufficient — you must pass opts.table explicitly so discovery can validate at first /meta (see Bound-table requirement).
Validation errors flow through the existing validation interceptor and emit the same envelope as DTO failures:
{
"statusCode": 400,
"message": "...",
"errors": [{ "path": "userId", "message": "Missing field \"userId\"" }]
}No @Body() alongside @DbActionID*
Mixing @DbActionID() or @DbActionIDs() with @Body() on the same method drops the action with a warning. If your action needs additional input beyond the identifier, model it as processor: 'custom' and POST to a regular @Post-decorated handler from your UI client.
Form input — @InputForm()
Some actions need more than just an identifier — "Approve with comment", "Transfer amount", "Update status to one of N values". Without a declarative form contract, this falls back to a custom modal hand-rolled per action: the UI doesn't know what fields to render, the server doesn't know how to validate them, and the two drift over time.
@InputForm(FormType) collapses this into a single declaration. You point the parameter at a .as interface; the discoverer surfaces the form's name on /meta; a UI client fetches the schema from a per-controller GET /meta/form/:name endpoint and renders a form generically; the user-filled payload arrives at your handler in the action envelope's input field.
Quick example
// schema/comment.as
export interface CommentForm {
note: string
visibility?: 'public' | 'internal'
}import {
AsDbController,
TableController,
DbAction,
DbActionID,
InputForm,
} from "@atscript/moost-db";
import { Post } from "@moostjs/event-http";
import { Order } from "./schema/order.as";
import { CommentForm } from "./schema/comment.as";
import { ordersTable } from "./db";
@TableController(ordersTable)
export class OrdersController extends AsDbController<typeof Order> {
@Post("actions/approve")
@DbAction("approve", { label: "Approve", intent: "positive" })
async approve(@DbActionID() id: { id: string }, @InputForm(CommentForm) input: CommentForm) {
await this.table.updateOne({ id: id.id, status: "approved", note: input.note });
return { message: `Approved order ${id.id}` };
}
}/meta for OrdersController carries inputForm: "CommentForm" on the approve action:
{
"actions": [
{
"name": "approve",
"label": "Approve",
"level": "row",
"processor": "backend",
"value": "/orders/actions/approve",
"intent": "positive",
"inputForm": "CommentForm"
}
]
}The client (or your UI) fetches the form schema from GET /orders/meta/form/CommentForm, renders a form (the @atscript/ui form components consume TSerializedAnnotatedType directly), then submits the envelope:
curl -X POST http://localhost:3000/orders/actions/approve \
-H "Content-Type: application/json" \
-d '{"ids":{"id":"o1"},"input":{"note":"looks good","visibility":"internal"}}'Form name resolution
The form's wire name is FormType.name — the compiled .as class's identifier. Compiled atscript interfaces are real classes with stable names, so InputForm(CommentForm) is enough; the decorator never asks for an explicit string.
A single FormType may be reused across multiple actions on the same controller — this is fine, the registry maps name → type. If two actions on the same controller declare different type refs but share the same name (e.g. via two anonymous classes that compile to the same identifier), discovery emits a [moost-db actions] warning and drops the second action: the discovery endpoint can only serve one schema per name.
GET /meta/form/:name
Every AsReadableController subclass automatically exposes:
GET /<controller>/meta/form/:name
→ TSerializedAnnotatedTypeReturns the serialized form schema for the named @InputForm type. Annotation allowlist matches /meta.type (kept: meta.*, expect.*, db.rel.*, plus a small db.json / db.patch.strategy / db.default* / db.http.path whitelist). Schemas are serialized once and cached per (controller, name).
Discovery is lazy: hitting /meta/form/:name triggers discoverActions if /meta hasn't been called yet on this process. Unknown names → HTTP 404.
Inferring the form from the parameter type
The FormType argument may be omitted — @InputForm() reads the parameter's reflected design type and resolves the form from it:
async approve(@DbActionID() id: { id: string }, @InputForm() input: CommentForm) { ... }Inference relies on design:paramtypes, so it works only when the parameter is annotated with the compiled .as class through a value import — an import type { CommentForm } elides the class and reflection yields Object. When the reflected type is unusable (plain interface annotation, import type, circular import, metadata emission off), decoration throws at import time with a fix hint rather than silently serving an action without a form. Passing the form explicitly sidesteps reflection entirely and always wins over the parameter annotation.
Keep .as imports as value imports
Lint rules that force type-only imports (typescript/consistent-type-imports and friends) convert .as imports used only in type positions to import type, breaking inference and any other design:paramtypes consumer. Keep such rules off in projects importing .as files.
Validation — built-in
The input payload is validated before your handler fires: the decorator's resolver runs FormType.validator(validatorOpts).validate(input ?? {}). On mismatch it throws ValidatorError, which the controllers' built-in validationErrorTransform() shapes into a structured HTTP 400 — the same envelope as strict-ids failures and DTO validation errors:
{
"message": "...",
"statusCode": 400,
"errors": [{ "path": "note", "message": "Required" }]
}Semantics worth knowing:
- Absent
inputvalidates as{}— an all-optional form passes, required fields produce per-field errors. Your handler always receives an object, neverundefined. - Unknown properties are rejected by the atscript validator's strict default, matching the strict
idscontract. - Validator options pass through the second argument:
@InputForm(CommentForm, { partial: "deep" })forwards toFormType.validator(opts). - An app-level atscript validator pipe (e.g.
validatorPipe()from@atscript/moost-validator) re-validating the same parameter is harmless — same validator, same result.
The decorator also stamps two pieces of param metadata: atscript_db_action_input_form ({ type, name }, consumed by discovery for /meta and GET /meta/form/:name) and atscript_type (a generic hook for atscript-aware Moost pipes).
Constraints
- One
@InputFormper action. For multiple structured inputs, compose them into a single.asinterface (object with sub-objects, or an array field for repeated items). - Class-level entries declare a form, they don't validate it.
@DbActions/@DbRowActions/@DbRowsActionsentries takeinputForm— see Input forms on class-level entries. Validation still happens only in the handler that owns the route, through its own@InputForm(...)parameter. - Body shape stays the envelope. With or without
@InputForm, the body is still{ ids?, input? }. Mixing@InputFormwith@Body()on the same method is allowed but unusual:@Body()would receive the full envelope while@InputFormresolves tobody.input.
Composable
import { useDbActionInput } from "@atscript/moost-db";
const input = await useDbActionInput().load(); // body.input — `unknown`, no validationFor interceptors that need to inspect the form payload alongside the gate-cached identifier or row.
Class-level Actions
Use these for 'navigate' and 'custom' actions that never need a server method, and as an escape hatch for 'backend' actions that point to a shared or legacy endpoint.
@DbActions(dict)
Generic — every entry must specify level:
@DbActions({
edit: { level: "row", label: "Edit", processor: "navigate", value: "/users/$1/edit" },
refresh: { level: "table", label: "Refresh", processor: "custom" },
block: { level: "row", label: "Block", processor: "backend", value: "/admin/block" },
})Level-pinned shortcuts
@DbTableActions, @DbRowActions, @DbRowsActions inject level into every entry of the dict — purely a DX optimisation:
import { DbRowActions, DbTableActions, DbRowsActions } from "@atscript/moost-db";
@TableController(usersTable)
@DbRowActions({
edit: { label: "Edit", processor: "navigate", value: "/users/$1/edit" },
view: { label: "View", processor: "navigate", value: "/users/$1" },
})
@DbTableActions({
importCsv: { label: "Import CSV", processor: "custom" },
})
@DbRowsActions({
bulkBlock: { label: "Block Selected", processor: "backend", value: "/admin/users/bulk-block" },
})
export class UsersController extends AsDbController<typeof User> {}The dictionary key serves as the action name. Class-level entries do not bind any HTTP route — they are surfaced in /meta only.
value rules per processor
| Processor | value at definition time | value in /meta |
|---|---|---|
'navigate' | required, non-empty (URL template) | passes through unchanged |
'backend' | required, non-empty (full HTTP path) | passes through unchanged |
'custom' | forbidden at definition time | filled by builder with the dict key |
For 'navigate' and 'backend', undefined, null, and '' are all treated as missing — the entry is dropped with a [moost-db actions] warning. For 'custom', supplying any value drops the entry.
Class-level 'backend' is the escape hatch
Use processor: 'backend' at the class level to point an action at a shared or legacy path. The dev-supplied path must be served by a @Post-bound handler somewhere — typically a method using @DbActionID() / @DbActionIDs() so the identifier-shaped JSON body is parsed and validated. The meta builder does not validate that the path resolves; that's your contract.
Input forms on class-level entries
Since 0.1.136. A class-level entry can tell the UI which form to collect before the action runs. This is the case when a listing controller shows a 'backend' action whose handler lives on another controller. Pass the form in one of two ways:
import { ShipForm } from "./forms.as";
@TableController(ordersTable)
@DbRowActions({
// 1. The compiled .as type — served by THIS controller's /meta/form/ShipForm
ship: {
label: "Ship",
processor: "backend",
value: "/api/shipping/actions/ship",
inputForm: ShipForm,
},
// 2. The form's name + the path where another controller serves its schema
refund: {
label: "Refund",
processor: "backend",
value: "/api/payments/actions/refund",
inputForm: { name: "RefundForm", url: "/api/payments/meta/form/RefundForm" },
},
})
export class OrdersController extends AsDbController<typeof Order> {}inputForm | /meta entry | Schema served by |
|---|---|---|
compiled .as type | inputForm: Type.name | this controller's GET /meta/form/:name |
{ name, url } | inputForm: name, formUrl: url | whatever serves url |
Any other shape is a type error. JavaScript callers that pass one anyway, or set inputForm on a processor: 'navigate' entry, get the entry dropped with a warning.
url is a server-absolute path, the same convention as value for 'backend' actions: clients prefix their base URL. getActionForm() in @atscript/db-client handles both shapes.
The entry only describes the form. The handler behind value must still validate input, normally with its own @InputForm(ShipForm) parameter. A type registered here shares this controller's form-name registry with method-level @InputForm parameters, and a name clash with a different type drops the entry.
When to use class- vs. method-level
- Method decorator (
@DbAction): the action has a server handler living on this controller. The path, validation, and label all live in one place. - Class decorator (
@DbActionsfamily): the action is'navigate'or'custom'(no server handler at all), or it points at a shared/legacy'backend'endpoint that lives elsewhere. Also useful for declaring many actions compactly.
Server-side Gate
Many actions only apply under specific row state — "Ship" should run only on orders with status === 'processing', "Approve" only on pending requests. Without a declarative gate, the controller has to guard the handler manually and return { ok: false, message: 'Already shipped' } after the user clicks; the UI button stays live for every row because the UI has no machine-readable predicate. The same predicate ends up duplicated (or, today, missing) in the UI, and the two drift.
The gate collapses this to one declaration: the server enforces it via an interceptor, and the wire emits the predicate's source so a UI can grey-out / hide the button per row.
Basic recipe
A row-level "Ship" action gated on order status:
import { AsDbController, TableController, DbAction, DbActionID } from "@atscript/moost-db";
import { Post } from "@moostjs/event-http";
import { Order } from "./schema/order.as";
import { ordersTable } from "./db";
@TableController(ordersTable)
export class OrdersController extends AsDbController<typeof Order> {
@Post("actions/ship")
@DbAction<Order, ["status"]>("ship", {
label: "Ship",
intent: "primary",
requiredFields: ["status"],
disabled: (orders) => orders.map((o) => o.status !== "processing"),
})
async ship(@DbActionID() id: { id: string }) {
await this.table.updateOne({ id: id.id, status: "shipped" });
return { message: "Shipped" };
}
}The gate interceptor runs after auth guards and before the handler. When disabled[i] is truthy, the request is rejected with ActionDisabledError (HTTP 409) and the handler never runs. No guard code in the handler body — by the time ship() executes, the gate has already vetted the row.
The predicate signature is (rows: Pick<FlatOf<TRow>, R[number]>[]) => (boolean | string)[]:
- Truthy = disabled —
false(or"") enables the row,truedisables it, a non-empty string disables it with that reason. - Sync — a
Promisereturn is rejected by the type system. - Batched — for
'row'-level the gate callsdisabled([row])and readsverdicts[0]; for'rows'-level it callsdisabled(survivorRows)once. - Parallel by index — verdict array length MUST equal input length. Length mismatch (e.g.
() => [true]ignoring inputs, orrows.filter(...).map(...)) throws HTTP 500 — the gate cannot map verdicts back to rows. - Type-narrowed row arg — only fields listed in
requiredFieldsare visible. Reading another field is a TS error.
Annotate <TRow> and requiredFields at the call site
TypeScript decorators can't infer TRow from the enclosing class generic. Use the explicit form @DbAction<Order, ["status"]>("ship", { ... }) so the predicate's row arg is type-narrowed and requiredFields becomes a literal tuple. Without <TRow>, the row arg falls back to any[] and you lose the field-narrowing safety net.
requiredFields is mandatory when disabled is set
Setting disabled without a non-empty requiredFields tuple drops the action at discovery with a warning. Field-deps must be declared explicitly — the system uses them to widen @DbActionRow* projection AND to widen $select for the $actions=true augmentation. See requiredFields.
perRow() helper
Most predicates are per-row in spirit; perRow() lifts a per-row function into the batch shape required by disabled. Polarity is preserved — true from the inner function means "disabled for that row":
import { perRow } from "@atscript/moost-db";
@DbAction<Order, ["status"]>("archive", {
label: "Archive",
requiredFields: ["status"],
disabled: perRow((o) => o.status === "archived"),
})Equivalent to disabled: (rows) => rows.map(o => o.status === "archived"). Use the explicit batch form when the predicate genuinely needs the whole list (e.g. cross-row checks).
Disabled reasons
Return a string instead of true to say why the action is disabled for that row. The string is the human-readable reason; false still means enabled:
@DbAction<Order, ["status"]>("ship", {
label: "Ship",
requiredFields: ["status"],
disabled: perRow((o) => {
if (o.status === "shipped") return "Order already shipped";
if (o.status !== "processing") return "Only processing orders can be shipped";
return false;
}),
})The reason reaches clients in two places:
- POST rejected by the gate — the 409
messageis the reason, and the body carriesreason/reasons. SeeActionDisabledError. $actions=truereads — each row gets$disabledReasons: { ship: "Order already shipped" }next to$actions, so a UI can show a disabled button with a tooltip instead of hiding it. See$disabledReasons.
Boolean predicates are unaffected — true still disables without a reason, and nothing new appears on the wire. The verdict type is exported as TDbActionDisabledVerdict (boolean | string) from @atscript/moost-db.
With a translator registered (moost's setTranslator), the reason is a message id: moost-db sends it in the request's language in both places (since 0.1.170) — see Translations.
Reasons are user-facing text
Reasons go to the browser verbatim (or translated). Don't put data in them that the caller may not see (other rows' values, internal state) — the requiredFields projection strip does not apply to reason strings.
Row injection — @DbActionRow() / @DbActionRows()
The gate already loaded the row(s) to evaluate disabled. The same loaded row(s) can be injected into the handler — no second fetch:
import { DbAction, DbActionID, DbActionRow } from "@atscript/moost-db";
@Post("actions/ship")
@DbAction<Order, ["status"]>("ship", {
label: "Ship",
requiredFields: ["status"],
disabled: (orders) => orders.map((o) => o.status !== "processing"),
})
async ship(@DbActionID() id: { id: string }, @DbActionRow() order: Order) {
// `order` is the same row the gate evaluated. No re-fetch.
await this.table.updateOne({ id: id.id, status: "shipped", shippedAt: Date.now() });
return { message: `Shipped order ${id.id}` };
}@DbActionRow() / @DbActionRows() are also recognized as level signals (see Action Levels) — @DbActionRow() infers 'row', @DbActionRows() infers 'rows'. They are interchangeable with @DbActionID* for level inference; mixing row-cardinality and rows-cardinality decorators on the same method drops the action with a warning.
Row projection is narrowed
The injected row(s) are projected to identifier-shape ∪ preferredId ∪ requiredFields. Other table columns are absent. To access fields the gate doesn't read, add them to requiredFields (or re-fetch with findOne). There is no auto-deps tracker — the field set is exactly what you declare. Since 0.1.143, requiredFields that the controller's hasField hides are left out, and rows outside the controller's row overlay are not loaded. See Row scoping.
In 'rows' + 'skip' mode, @DbActionRows() resolves to filtered survivors only — the original request rows are not retrievable post-filter.
'rows'-level batch mode
For @DbActionIDs() / @DbActionRows() actions, onDisabledRows controls how the gate handles partial failures:
| Mode | Predicate evaluated | On any failure | Handler runs with |
|---|---|---|---|
'reject' (default) | every survivor row once | throws ActionDisabledError listing all failing IDs (+ their reasons) | n/a |
'skip' | every survivor row once | filters cached IDs + rows to passing-only; zero survivors → throw | only the survivors |
Identifiers whose row didn't resolve (no DB match, or outside the controller's row overlay) are treated as failing without invoking disabled against undefined. Their reasons entry is null. Surviving rows are passed in one batched disabled call.
@Post("actions/archive")
@DbAction<Order, ["archived"]>("archive", {
label: "Archive Selected",
requiredFields: ["archived"],
disabled: (orders) => orders.map((o) => o.archived === true),
onDisabledRows: "skip", // archive only un-archived rows; ignore the rest
})
async archive(@DbActionIDs() ids: Array<{ id: string }>) {
// `ids` contains only survivors when `onDisabledRows: 'skip'`.
await this.table.bulkUpdate(ids.map(({ id }) => ({ id, archived: true })));
return { message: `${ids.length} orders archived` };
}Two notes:
'reject'is the default because it preserves request-atomicity — partial success is opt-in.- The cached identifier slot stores the original submitted object references —
'skip'-mode filtering preserves reference equality;useDbActionIds().load()returns the filtered subset. 'skip'drops rows disabled with a reason exactly liketrueones. Since 0.1.147 the handler can read what was skipped, with the reasons, fromuseDbActionTarget().summary()— see Query targets.
Bound-table requirement
The gate / @DbActionRow* need a typed table at request time to load the row(s). Discovery enforces this at first /meta:
| Controller | What's required for disabled / @DbActionRow* |
|---|---|
AsDbController / AsDbReadableController subclass | nothing — bound table comes from @TableController / @ReadableController; opts.table is silently ignored |
Plain Moost controller (no extends, no readable/table field) | MUST declare opts.table on @DbAction(name, { table }) |
Plain Moost controller WITH readable / table instance field (legacy) | duck-type fallback covers plain @DbActionID* only — gates and @DbActionRow* still need explicit opts.table |
When the requirement isn't met, discovery emits a [moost-db actions] warning and drops the action from /meta. Plain @DbActionID() / @DbActionIDs() (no gate, no row injection) still works on any controller via the existing duck-type fallback — only gated / row-injecting actions need the explicit table opt.
// Plain controller — gated action MUST pass opts.table
@Controller()
export class AdminController {
@Post("orders/ship")
@DbAction<Order, ["status"]>("ship", {
label: "Ship",
table: ordersTable, // ← required
requiredFields: ["status"],
disabled: (orders) => orders.map((o) => o.status !== "processing"),
})
async ship(@DbActionID() id: { id: string }, @DbActionRow() row: Order) {
/* ... */
}
}requiredFields
requiredFields declares the dot-notation field paths the disabled predicate reads. It is server-internal only — the array never crosses the /meta wire. The system uses it for three things:
- Type narrowing —
disabled's row argument isPick<FlatOf<TRow>, R[number]>[]. Reading a field not listed inrequiredFieldsis a TS error. @DbActionRow*projection widening — the row(s) injected into the handler includerequiredFields(in addition to identifier-shape +preferredIdfields). Other columns are absent.$actions=trueaugmentation — when a read endpoint is asked to compute$actions, the server widens$selectto include allrequiredFieldsacross the controller's row/rows-level actions, runs the predicates, then strips fields the caller didn't request. See$actions=true.
@DbAction<Order, ["status", "tenantId"]>("ship", {
label: "Ship",
requiredFields: ["status", "tenantId"],
disabled: (orders) =>
orders.map((o) => o.status !== "processing" || o.tenantId !== currentTenant.value),
})Without (non-empty) requiredFields, disabled is dropped at discovery with a [moost-db actions] warning.
Since 0.1.143, a requiredFields entry that the controller's hasField hides for the current request is never loaded, nor is a @db.column.derived field whose source it hides. The predicate sees it as undefined, and the @DbActionRow* row does not carry it.
Closure-emission pitfall
The wire emits fn.toString() of the predicate verbatim — captured outer-scope identifiers come along. The server doesn't validate closure-cleanliness; it runs the original closure successfully. The UI, on the other hand, evaluates the stringified source in a different scope and throws ReferenceError on captured names.
Predicate body must reference only the rows arg
Outer-scope identifiers (constants, helpers, imports, this.*) work server-side but break UI mirroring. Keep predicates pure and self-contained.
// ✅ self-contained — works server + UI
disabled: (orders) => orders.map((o) => o.status !== "processing");
// ❌ captures outer-scope SHIPPED — server runs, UI throws ReferenceError
const SHIPPED = "shipped";
disabled: (orders) => orders.map((o) => o.status === SHIPPED);
// ❌ captures `this` — same problem
disabled: (orders) => orders.map((o) => o.tenantId === this.currentTenant);ActionDisabledError (HTTP 409)
When the gate rejects, the response is HTTP 409 with this body:
{
"name": "ActionDisabledError",
"message": "Action \"ship\" is disabled for this row",
"statusCode": 409,
"action": "ship",
"id": { "id": "abc" }
}For 'rows'-level rejections, ids: [...] replaces id — each entry is the originally-submitted identifier object (Record<string, unknown> in PK or unique-index form). id / ids are the ids you sent, even when resolveRowIds mapped them to others:
'reject'mode:idslists ALL failing identifiers in original request order (predicate-rejected + missing-row both included).'skip'mode with zero survivors:idslists ALL request identifiers.
When the predicate returned reasons (since 0.1.141):
| Field | Present when | Value |
|---|---|---|
reason | every rejected row has the same reason ('row': the row has one) | that reason |
reasons | 'rows' level, at least one rejected row has a reason | (string | null)[] aligned index-by-index with ids; null = no reason (true verdict or row not found) |
message | always | reason when set; otherwise the generic text, followed by up to three distinct reasons — Action "archive" is disabled for 3 of the selected rows: Locked; Already archived |
{
"name": "ActionDisabledError",
"message": "Order already shipped",
"statusCode": 409,
"action": "ship",
"id": { "id": "abc" },
"reason": "Order already shipped"
}The error class lives in @atscript/moost-db (ActionDisabledError extends HttpError). The discriminator name: 'ActionDisabledError' lets @atscript/db-client construct the typed ActionDisabledError subclass on the consumer side — see HTTP Client — Actions § Error cases.
Class-level dict entries
Class-level @DbActions / @DbRowActions / @DbRowsActions accept disabled (with required requiredFields) but they do not register a server interceptor — the dict entry's value may point at an endpoint in another controller (or a method that doesn't exist in this scope). The predicate still runs in two places:
$actions=trueaugmentation on the read endpoints of the controller declaring the dict — rows get$actionsreflecting the dict-level predicates.- UI mirror via the wire
disabledstring — the UI greys out the button.
POSTs to the dict's value endpoint are NOT blocked here. For symmetric server enforcement at the actual @Post-bound handler, also declare @DbAction(name, { requiredFields, disabled }) on that method.
Composables
Useful when composing custom interceptors that need access to the gate's cached identifiers / rows without re-parsing the body or re-fetching:
import { useDbActionId, useDbActionIds, useDbActionRow, useDbActionRows } from "@atscript/moost-db";
const id = await useDbActionId().load(); // cached, validated single identifier object
const ids = await useDbActionIds().load(); // cached, validated identifier-object array
const row = await useDbActionRow().load(); // cached single row (gate-loaded)
const rows = await useDbActionRows().load(); // cached row array (gate-loaded; filtered in skip mode)All four follow the Wooks defineWook pattern and return { load() }. The gate runs at AFTER_GUARD priority so reads are safe inside any INTERCEPTOR-priority custom interceptor.
In 'skip' mode, useDbActionIds().load() returns the filtered subset of original objects (reference-equal to the entries the client posted), and useDbActionRows().load() returns the parallel-aligned filtered rows — no undefined gaps.
Request and Response Contracts
Request body
All action requests use Content-Type: application/json. The body is always the envelope { ids?, input? } — see Body envelope.
| Level | Identification (ids field) | Form input (input field) | JSON body |
|---|---|---|---|
row | single-field PK | n/a | { "ids": { "id": "abc" } } |
row | composite PK | n/a | { "ids": { "tenantId": "acme", "userId": "u1" } } |
row | unique-index addr. | n/a | { "ids": { "email": "jane@example.com" } } |
row + @InputForm | single-field PK | form payload | { "ids": { "id": "abc" }, "input": { "note": "looks good" } } |
rows | single-field PK | n/a | { "ids": [{ "id": "a" }, { "id": "b" }] } |
rows | mixed identifications | n/a | { "ids": [{ "id": 1 }, { "email": "x@y" }] } |
table | none | n/a | empty body (or {}) |
table + @InputForm | none | form payload | { "input": { "msg": "hi" } } |
Strict typing on ids — no coercion, unknown fields rejected. Schema mismatches return HTTP 400 with the same envelope as DTO validation failures. rows-level ids is always an array even for a single identifier — send { "ids": [{"id":"a"}] }, never { "ids": {"id":"a"} }. Validation of input depends on a user-installed atscript validator pipe — see Form input — Validation.
Success response
Backend actions may return any JSON. There is one convention UI clients SHOULD honour:
If the response body has a top-level
"message": string, the UI displays it (toast, banner, etc.). Otherwise the UI uses a generic per-level message ("Action completed", "5 rows updated", …).
This is a documented convention, not a runtime contract — no TDbActionResult type, no server-side validation. You're free to return whatever shape your client expects:
@Post("actions/block")
@DbAction("block", { label: "Block" })
async block(@DbActionID() id: { id: string }) {
await this.table.updateOne({ id: id.id, blocked: true })
return { message: `User ${id.id} blocked` } // ← UI toasts this
}
@Post("actions/lock")
@DbAction("lock", { label: "Lock Selected" })
async lock(@DbActionIDs() ids: Array<{ id: string }>) {
await this.table.bulkUpdate(ids.map(({ id }) => ({ id, locked: true })))
return { message: `${ids.length} users locked`, locked: ids }
}
@Post("actions/refresh-cache")
@DbAction("refresh-cache", { label: "Refresh" })
async refreshCache() {
await this.warmCache()
// No "message" → UI falls back to a generic toast.
return { ok: true }
}Error response
Errors flow through the existing validation interceptor — same envelope as CRUD endpoints. PK validation failures, missing fields, and wrong types all emit HTTP 400 with structured errors[]. See CRUD — Error Handling.
Gate rejections are HTTP 409 with the ActionDisabledError body shape — see Server-side Gate § ActionDisabledError.
Composing with Auth and Interceptors
@DbAction does not interfere with any other Moost decorator. @Authenticate, @Intercept, pipe decorators, and parameter decorators all behave as if @DbAction were absent:
import { Authenticate } from "@moostjs/event-http";
@Post("actions/block")
@Authenticate(adminGuard)
@DbAction("block", { label: "Block" })
async block(@DbActionID() id: { id: string }) {
/* runs only if adminGuard passes */
}If the guard rejects the request, the handler body never runs and the auth-failure response is returned — exactly as for any other Moost handler.
The internal action interceptor (the disabled gate, the row scoping, and — since 0.1.143 — prepareRequest, which it runs first) is at AFTER_GUARD priority — so auth guards run first, then prepareRequest, then the gate, then any custom INTERCEPTOR-priority interceptors, then the handler.
Worked recipe — auth + gate + requiredFields
A full row-level action that combines an @Authenticate admin guard, a disabled predicate gated on row state, @DbActionRow() row injection, and audited fields:
import {
AsDbController,
TableController,
DbAction,
DbActionID,
DbActionRow,
} from "@atscript/moost-db";
import { Post, Authenticate } from "@moostjs/event-http";
import { Order } from "./schema/order.as";
import { ordersTable } from "./db";
@TableController(ordersTable)
export class OrdersController extends AsDbController<typeof Order> {
@Post("actions/refund")
@Authenticate(adminGuard)
@DbAction<Order, ["status", "paidAmount"]>("refund", {
label: "Refund",
intent: "negative",
requiredFields: ["status", "paidAmount"],
disabled: (orders) => orders.map((o) => o.status !== "paid" || o.paidAmount <= 0),
promptText: ["Refund order $1?", "Refund $N orders?"],
})
async refund(
@DbActionID() id: { id: string },
@DbActionRow() order: Pick<Order, "id" | "status" | "paidAmount">,
) {
// adminGuard passed, the gate already verified status === 'paid'
// and paidAmount > 0 against this exact row.
await this.processRefund(order);
await this.table.updateOne({ id: id.id, status: "refunded" });
return { message: `Refunded order ${id.id}` };
}
}What happens per request:
@Authenticate(adminGuard)— Moost runs the auth guard. On failure: standard auth-failure response, handler never runs. Then the controller'sprepareRequestruns, when it defines one.requiredFieldswidens the projection used to load the row.- The gate interceptor (
AFTER_GUARD) loads the row, evaluatesdisabled([row]). On failure: HTTP 409ActionDisabledError. @DbActionRow()injects the gate-loaded row into the handler — no second fetch.- Handler runs.
The same disabled predicate also drives:
/meta.actions[].disabled— emitted asfn.toString()for the UI to grey out the button.$actions=trueaugmentation on read endpoints — each row carries$actions: string[]listing the actions whose predicate did NOT reject it.
Keep disabled self-contained
The predicate's source string is shipped to UI clients verbatim. Outer-scope identifiers work server-side but break UI evaluation — see Closure-emission pitfall.
The /meta Surface
The actions field of the /meta response is an array of TDbActionInfo:
interface TDbActionInfo {
name: string;
label: string;
level: "table" | "row" | "rows";
processor: "backend" | "navigate" | "custom";
value: string;
icon?: string;
intent?: "positive" | "negative" | "warning" | "primary" | "secondary";
description?: string;
order?: number;
default?: boolean;
promptText?: string | [string, string]; // [singular, plural]
shortcut?: string; // single character; UI binds the modifier
disabled?: string; // fn.toString() — UI mirror only; truthy verdict = disabled, string = reason; server-evaluated availability is in row-level $actions / $disabledReasons
inputForm?: string; // form name (@InputForm param or class-level inputForm); client fetches GET /meta/form/<name>
formUrl?: string; // class-level inputForm { name, url }; client fetches baseUrl + formUrl instead
}requiredFields is server-internal only — it never crosses the wire. Predicate field-deps are declared by the dev, drive server-side projection widening, and reach UI clients implicitly via the row-level $actions overlay rather than as an explicit $select hint.
The server emits fn.toString() of disabled verbatim — closures and outer-scope references included. No parsing, no AST transform. The UI evaluates the source against a level-specific scope (the row for 'row'-level, each row for 'rows'-level) to grey-out / hide the button. Server enforcement is authoritative; this field is purely a UI hint.
Discovery is lazy — it runs on the first GET /meta request and the result is cached alongside the rest of the meta envelope. Startup is unaffected. Warnings (missing label, missing @Post, @Body co-occurrence, duplicate default, …) emit on that first call, not at app.init() time.
The @atscript/db-client consumer reads actions off the meta response — see HTTP Client — Metadata — and exposes a typed client.action<R>(name, id?) helper that resolves and dispatches actions for you. See HTTP Client — Actions for the consumer-side API.
$actions=true — server-evaluated row availability
Asking which actions a row qualifies for can be answered server-side as part of the read. Set $actions=true on any read endpoint and every returned row carries an additional $actions: string[] field — the names of 'row' and 'rows'-level actions whose disabled predicate did NOT reject this row:
GET /users/query?status=active&$actions=true
# → [{ "id": "u1", "status": "active", "$actions": ["edit", "block"] }, ...]Available on /query, /pages, /one, /one/:id (including $search and vector-search paths). NOT augmented on $count and $groupBy responses (no row shape).
Action ordering follows /meta.actions[] declaration order. 'table'-level actions never appear in $actions. Actions without a disabled predicate are unconditionally present in every row's array.
$disabledReasons
When a predicate returns a reason for a row, that row also carries $disabledReasons — action name → reason — for the actions it disabled with a reason:
GET /orders/query?$actions=true
# → [{ "id": "o1", "status": "processing", "$actions": ["ship", "edit"] },
# { "id": "o2", "status": "shipped", "$actions": ["edit"],
# "$disabledReasons": { "ship": "Order already shipped" } }]- Present only on rows with at least one reason — rows (and whole responses) from boolean-only predicates look exactly as before.
- Its keys are never in
$actions. An action missing from$actionsand from$disabledReasonswas disabled without a reason (true). - UI rule of thumb: in
$actions→ enabled; in$disabledReasons→ show disabled with the reason as tooltip; in neither → hide.
Per-action row scope
Since 0.1.145, a row lists an action only when it lies inside that action's actionRowScope, the same filter the action gate enforces. An action outside its scope gets no $disabledReasons entry either. For each distinct filter (since 0.1.147 structurally distinct), the server runs one extra query over the page's rows, { $and: [{ $or: <row ids> }, <filter AND the row overlay>] }. The row overlay is included because the gate applies it too, so a transformOne stricter than the read's transformFilter is honored for scoped actions. The query selects only the id columns (preferredId, which is the primary key unless re-pointed) and runs directly on the bound table or view. hasField does not apply to it, and the response keeps its shape. A readable without a primary key withholds every scoped action, and a warning is logged once per controller class.
Pipeline
For each request that sets $actions=true on a controller extending AsDbReadableController:
- Discover row/rows-level action envelopes (memoized per controller ctor).
- Filter through
allowedActions(names)(since 0.1.145), which defaults to the per-requestapplyMetaOverlay()hook — actions stripped by the overlay are absent from$actions. Themeta()call is skipped whenapplyMetaOverlayis the default no-op. WhenactionRowScopeis overridden, start resolving the row overlay alongside the read. - Pre-widen
$selectto union allrequiredFieldsacross the surviving envelopes (only when the caller restricted projection). - Run the underlying read (find / pages / search / vector / findById).
- Call
actionRowScopewith the page's rows as candidates (purpose: "rows"), run one id-only scope query per distinct filter (in parallel), then eachdisabledpredicate at most once, on the page's rows inside its action's scope, and not at all when there are none (length-mismatch verdict → HTTP 500, same contract as the gate). An action is listed only on rows inside its scope. Before 0.1.172 a predicate also saw the rows outside its scope. Since 0.1.145 each predicate sees the rows narrowed to the columns its gate loads (id columns plus visiblerequiredFields), so it gives the same verdict as the gate. A predicate that reads an undeclared column seesundefinedin both places. - Strip widened-only fields the caller didn't ask for, so the response shape matches the original
$select.
Available actions for one row — GET /meta/actions/:id
Since 0.1.145. $actions rides on a read, so it cannot answer for a row the caller may act on but not read. GET /meta/actions/:id answers for one row without a read grant and without returning row data:
GET /orders/meta/actions/o2
# → { "actions": ["edit"], "disabledReasons": { "ship": "Order already shipped" } }
GET /orders/meta/actions?tenantId=t1&orderNo=42 # composite key, the /one?… rules- The id forms are those of
/one/:idand/one?…; a query that matches no identification is a 400. - The answer is exactly what calling each
'row'/'rows'action would do. The id resolves once, like/one/:id, under the row overlay. An action is then listed when it is inallowedActions, which defaults to theapplyMetaOverlay()set, the row lies inside itsactionRowScope, and itsdisabledrule passes on the columns its gate loads. - The answer is per row and per caller. Unlike
/meta, a cache or CDN must not share it across rows or callers. Since 0.1.172 it is sent withCache-Control: private, no-store, unless your app already set aCache-Controlon the response. Its headers never depend on the row: with a translator registered, every answer carriesVary: Accept-Language. - An unknown id and an out-of-scope id both answer
200 { "actions": [] }, with the same headers, so the route never reveals whether a row exists. A row outside theactionRowScopeof every action counts as out of scope. An action'sdisabledrule runs only when the row is inside that action's scope. Before 0.1.172, with a translator registered, such a row got aVary: Accept-Languageheader that an unknown id did not. prepareRequestruns first withendpoint: "availableActions", so a permission layer can authorize the route.getDbEndpointreports that endpoint for both handlers, so an authorization interceptor can let them through without naming them.transformOne/transformFilterthen run in this request exactly as they do for the action itself.- The response keys match the
$actions/$disabledReasonsrules above; the type isTDbAvailableActionsfrom@atscript/db. On the client, useavailableActions(id).
Programmatic use from @atscript/db-client
const r = await users.query({
filter: { active: true },
controls: { $actions: true } as const,
});
r[0].$actions; // typed `string[] | undefined` via ClientResponse<T, Q>
r[0].$disabledReasons?.ship; // typed `Record<string, string> | undefined`The control is also accepted on the URL (?$actions=true or ?$actions=1); the server coerces the string back to a boolean before DTO validation.
Same predicate, three call sites
The disabled predicate runs in three places per request lifecycle:
$actions=trueaugmentation — against the rows of the result set inside the action's scope, on the read endpoints.- Server-side gate — against the loaded row(s) at POST time, blocking the handler with HTTP 409 on any rejection.
- UI mirror — the
fn.toString()source is evaluated client-side to grey out the button before invocation.
Server enforcement on POST is authoritative. $actions and the UI mirror are availability hints used to render correctly without an extra round-trip.
Validation Rules and Warnings
The meta builder enforces several rules. Every violation emits a console warning prefixed [moost-db actions] and drops the offending action from /meta rather than throwing — this keeps /meta deliverable even with misconfigurations.
| Rule | Outcome |
|---|---|
@DbAction method has no @Post(...) | warn + drop |
@DbAction method's only verb is non-POST (@Get, @Put, …) | warn + drop |
Both @DbActionID() and @DbActionIDs() on the same method | warn + drop |
@DbActionID* / @DbActionRow* co-occurs with @Body() | warn + drop |
Method has no label (no opts.label, no @Label) | warn + drop |
@DbActionDefault() applied without a corresponding @DbAction(name) | warn + drop |
Class-level 'navigate' or 'backend' entry has missing/empty value | warn + drop |
Class-level 'custom' entry supplies a value | warn + drop |
Two actions with default: true at the same level | first wins, second demoted, warn |
'table'-level action declares disabled | warn + drop |
Gated / row-injecting on a non-AsDbReadableController class without opts.table | warn + drop |
disabled set without (non-empty) requiredFields | warn + drop |
Mixing row + rows cardinality (@DbActionID* / @DbActionRow*) on the same method | warn + drop |
| Duplicate action name within the same controller | warn + drop second declaration |
Two actions sharing the same @InputForm form name with different type refs | warn + drop second declaration |
Class-level inputForm that is neither a compiled type nor { name, url } (JS) | warn + drop |
Class-level inputForm on a 'navigate' entry | warn + drop |
The single greppable prefix [moost-db actions] makes it easy to detect issues in CI logs.
One misconfiguration throws instead: an action on a value-help controller (since 0.1.143, see below).
Value-Help Controllers Are Excluded
AsValueHelpController and AsJsonValueHelpController (used for FK pickers and dictionary surfaces) do not support actions. /meta always emits actions: [] for shape uniformity.
Since 0.1.143, an action on a value-help controller is a hard error:
@DbActionon a value-help method, or@DbActions/@DbTableActions/@DbRowActions/@DbRowsActionson a value-help class, throws when the decorator is applied.- An action inherited from a non-value-help base throws when the controller is constructed.
Before 0.1.143 these decorators were silently ignored, and the @Post route still ran with no gate. Move the action to an AsDbReadableController / AsDbController.
Inspecting Action Metadata — getAtscriptDbMate()
@atscript/moost-db writes its action metadata to the standard Moost mate workspace, but with a typed accessor so consumers don't have to retype string keys or hand-cast results:
import { getAtscriptDbMate } from "@atscript/moost-db";
const mate = getAtscriptDbMate();
// Class- / method-level
const meta = mate.read(OrdersController.prototype, "approve");
meta?.atscript_db_action; // { name, opts } | undefined — written by @DbAction
meta?.atscript_db_actions; // class-level dict entries written by @DbActions / @DbRowActions / …
// Param-level
meta?.params?.[0]?.atscript_db_action_param; // 'id' | 'ids' — written by @DbActionID / @DbActionIDs
meta?.params?.[0]?.atscript_db_action_row; // true — written by @DbActionRow
meta?.params?.[0]?.atscript_db_action_rows; // true — written by @DbActionRows
meta?.params?.[0]?.atscript_db_action_input_form; // { type, name } — written by @InputForm
meta?.params?.[0]?.atscript_type; // FormType — also written by @InputFormThe returned Mate is the same singleton as getMoostMate() from moost, but narrowed via TypeScript declaration merging to every key @atscript/moost-db writes. Reach for it when:
- Writing a custom Moost pipe that reads
atscript_typeto validate@InputFormpayloads. - Building tooling that introspects the action surface without going through
/meta. - Composing your own decorator on top of
@DbAction/@InputFormand needing to read what they wrote.
Companion type exports
| Export | Use for |
|---|---|
AtscriptDbMate | Return type of getAtscriptDbMate() — the fully-typed Mate shape. |
AtscriptDbMeta | Class- / method-level keys (atscript_db_action, atscript_db_actions, …). |
AtscriptDbParamsMeta | Param-level keys (atscript_db_action_param, atscript_db_action_input_form, atscript_type, …). |
TDbActionMeta | { name, opts } payload written by @DbAction. |
TDbActionInputFormMeta | { type, name } payload written by @InputForm. |
TDbActionParamKind | 'id' | 'ids' written by @DbActionID / @DbActionIDs. |
TDbClassActionMeta | Class-level dict entry written by @DbActions and the level-pinned shortcuts. |
Next Steps
- Query targets — Run a
'rows'action on every row matching a query - Actions on a view — List and run a source table's actions on a view's rows
- HTTP Client — Consume the
actionsfield from@atscript/db-client - Customization — Hooks for intercepting CRUD (different concept; complements actions)
- HTTP Setup — Controller installation and wiring