Customization
AsDbController and AsDbReadableController expose protected hooks that let you extend default behavior without reimplementing endpoints. Override the hooks you need to add access control, data transformation, and business logic.
Looking for "Block User", "Approve", or "Edit" buttons?
This page is about intercepting existing CRUD. For exposing new domain operations alongside CRUD — and having them surface in /meta so any UI can render row buttons, batch toolbars, and navigation entries generically — see Actions.
Available Hooks
All hooks are protected methods with sensible defaults (pass-through or no-op). Override only the ones you need.
| Hook | Available On | Called When | Purpose |
|---|---|---|---|
prepareRequest(ctx) | Both | First, on every endpoint | Resolve per-request policy asynchronously (since 0.1.143) |
transformFilter(filter) | Both | Before /query / /pages reads | Modify filters (add tenant, soft-delete) |
transformOne(filter) | Both | Before /one / /one/:id reads | Filter overlay for id-based reads (defaults to transformFilter) |
transformRelationFilter(path, filter) | Both | Each client $some / $none | Row overlay of the related table (since 0.1.147) |
transformProjection(projection) | Both | Before every read | Restrict visible fields |
hasField(path) | Both | Every field a request references | Hide fields per request — answered as Unknown field |
validateInsights(insights) | Both | After query parsing | Field-level access control |
computeEmbedding(search, fieldName?) | Both | When $vector is present | Convert text to embedding vector |
decorateRows(rows, ctx) | Both | After every row read | Attach computed $-keys, or @DbDecorations columns, to rows |
resolveRowIds(ids, ctx) | Both ("remove" on AsDbController) | Before any id-addressed read, delete or action | Map stale / alias ids to the row's current id (since 0.1.148) |
actionRowScope(action, ctx) | Both | Action gate, $actions reads | Rows an action may run on (0.1.145; candidates in ctx 0.1.147) |
queryTargetScope(action) | Both | Resolving a query target | Read scope "all matching rows" resolve under (since 0.1.147) |
onWrite(action, data) | AsDbController | Before insert/replace/update | Transform or reject write data (untrusted body, outside any tx) |
onRemove(id) | AsDbController | Before delete | Allow or prevent deletion |
guardWrite(ctx) | AsDbController | Inside the table's tx, validated | Validated-stage checks / enrichment (since 0.1.128) |
guardRemove(ctx) | AsDbController | Inside the table's tx, id known | Validated-stage delete checks (since 0.1.128) |
checkWrite(ctx) | AsDbController | Inside the table's tx, after it | Post-write "WITH CHECK" on the written rows (since 0.1.143) |
withTransaction(fn) | AsDbController | Called by you | One transaction across several table ops in a custom route |
meta() | Both | On GET /meta request | Enrich the metadata response (cached) |
applyMetaOverlay(meta) | Both | Per request, after meta() | Per-principal crud / actions filtering (returns a clone) |
metaHttpCaching() | Both | On GET /meta, GET /meta/form/:name | ETag / Cache-Control / Vary of /meta, or false (0.1.151) |
metaTranslation() | Both | On GET /meta, GET /meta/form/:name | false: source texts (i18n, 0.1.170) |
allowedActions(names) | Both | $actions, /meta/actions | Row-level actions the caller may run (0.1.145) |
authorizeForm(name, actionNames) | Both | On GET /meta/form/:name | Refuse a form per request — answered as an unknown form (0.1.143) |
init() | Both | On controller construction | One-time setup |
Deprecated hook
checkGates(parsed) still runs after the field capability gate but is deprecated since 0.1.128: the gate derived from /meta.fields already rejects every unlisted or non-sortable / non-filterable path before it. Override the read hooks above, or the table-level guard options, instead.
Request Preparation
prepareRequest
Since 0.1.143. The entry point for a permission layer that must resolve the caller's policy asynchronously — load scopes from a session store, evaluate grants — before the synchronous hooks (hasField, validateControls, the capability gate) consult it. Not implemented by default; defining it switches it on, and it is awaited only then.
import { HttpError } from "@moostjs/event-http";
import type { TDbRequestContext } from "@atscript/moost-db";
@TableController(ordersTable)
export class OrdersController extends AsDbController<typeof Order> {
protected async prepareRequest(ctx: TDbRequestContext) {
const scopes = await loadScopes(ctx.endpoint); // your policy source
if (!scopes) throw new HttpError(403);
requestScopes.set(scopes); // read back by hasField / transformFilter
}
}It runs once per request, before anything else looks at the request — twice on one kind of request: a POST / with Prefer: return=representation on a controller that opts in runs it again with endpoint: "one" for the read-back:
| Endpoint | ctx.endpoint | When | ctx.controls |
|---|---|---|---|
/query, /pages, /geo, /one/:id, /one?… | query, pages, geo, one | Right after the URL is parsed, before validation and every other hook | The parsed controls |
POST / PUT / PATCH / DELETE /:id, DELETE /?… | insert, replace, update, remove | At handler start, before the shape gate, onWrite / onRemove | — |
/meta, /meta/form/:name | meta, metaForm | First | — |
Every @DbAction handler (row, rows and table level) | action (ctx.action = the name) | After the guards, before the action's ids are validated / rows loaded | — |
GET /meta/actions/:id, /meta/actions?… | availableActions (since 0.1.145) | First | — |
On query, pages and geo, ctx.filter (since 0.1.147) carries the parsed client filter when the URL has one — read it to resolve the relations its relational predicates touch, next to ctx.controls.$with. It holds only the client's part: server-side filters are not in it. It is a deep-frozen copy: the request gate judges the original, so the hook can read it but not change it. The copy is made on first access; ctx.hasRelationFilters (a plain boolean on every read endpoint) tells whether the filter holds a relational predicate at all, so a hook that only cares about predicates can skip the copy.
Value-help controllers call it too (query, pages, one). Actions run it before anything reads their ids, rows or row overlay, so a permission layer needs no separate action guard. Every built-in route enters through parseRequest(endpoint, url?): with a URL it parses the query string, coerces boolean controls ($actions=true) and hands the parsed controls to prepareRequest; without one (writes, meta) it only runs the hook. Custom routes you add to a subclass should do the same — const { parsed, controls } = await this.parseRequest("query", url), or await this.parseRequest("insert").
- Do throw to deny — an
HttpErrorfor a specific status; the request aborts with it. - Do authorize by
ctx.endpoint, not by the handler that runs. Under a write handler,endpoint: "one"is the representation read-back of an insert: it MUST apply the caller's read grant. A layer that keys its decision on the current handler (the insert) would hand an insert-only caller the row — keepreturnRepresentation()off for such a layer. - Do mutate
ctx.controlsif you must rewrite a read — the rest of the pipeline validates what you leave. - Don't do per-row work here — it runs once per request; row-level policy belongs in
transformFilter/guardWrite/checkWrite.
Handlers that delegate authorization — getDbEndpoint
Since 0.1.145. Some framework routes have no per-route permission of their own: prepareRequest authorizes them. Today that is GET /meta/actions/:id and /meta/actions?… (available actions). A permission layer whose interceptor denies every handler it has no rule for should let these through without naming moost-db's methods. getDbEndpoint(controller, method) returns the prepareRequest endpoint a handler delegates to, or undefined for a handler authorized like any other route:
import { getDbEndpoint } from "@atscript/moost-db";
// inside your authorization interceptor
const endpoint = getDbEndpoint(controllerInstanceOrClass, methodName);
if (endpoint) return; // prepareRequest({ endpoint }) authorizes this call — authorize it therecontroller may be the instance or the class. The tag is inherited, so it survives a subclass override of the handler. Every other built-in handler (query, pages, one, writes, meta) returns undefined and keeps its normal per-route authorization.
Read Hooks
transformFilter
Receives the parsed filter expression and returns a modified one. Every query passes through this hook, making it ideal for cross-cutting read concerns.
Multi-tenant filtering:
@TableController(todosTable)
export class TodoController extends AsDbController<typeof Todo> {
protected transformFilter(filter: FilterExpr): FilterExpr {
const tenantId = this.getCurrentTenantId();
return { $and: [filter, { tenantId }] };
}
}The returned filter replaces the original for all read endpoints (/query, /pages, /one/:id).
Soft deletes:
protected transformFilter(filter: FilterExpr): FilterExpr {
return { $and: [filter, { deletedAt: { $exists: false } }] }
}Async lookups: the hook may return a Promise — useful when the filter additions depend on session, permissions, or a remote lookup.
protected async transformFilter(filter: FilterExpr): Promise<FilterExpr> {
const tenantId = await this.resolveTenantFromSession()
return { $and: [filter, { tenantId }] }
}transformOne
Filter overlay applied to GET /one/:id and GET /one?.... Defaults to calling transformFilter so existence is not leaked through id-based reads. Override only when /one needs different scoping than /query (rare):
protected transformOne(filter: FilterExpr): FilterExpr {
// Allow self-lookup by id without the tenant scope `/query` enforces
if (this.isCurrentUser()) return filter
return this.transformFilter(filter)
}If you only need to scope BOTH /query and /one the same way (the common case), override transformFilter alone — transformOne will pick it up automatically.
Since 0.1.143 the overlay also scopes id resolution and deletes: /one/:id, /one?…, DELETE /:id and DELETE /?… resolve the id primary key first among in-scope rows only (an out-of-scope row never shadows an in-scope one), and a DELETE never removes an out-of-scope row — it answers 404 like a missing one.
Since 0.1.143, transformOne({}) also scopes the ids and rows of @DbAction handlers. An id outside it gets the same answer as a missing one. See Actions § Row scoping.
transformFilter and transformOne may return relational predicates on any relation ({ ticket: { $some: { teamId: currentTeam() } } }) — server-side filters need no @db.rel.filterable and are not passed to transformRelationFilter.
transformRelationFilter
Since 0.1.147. The row overlay of a related table inside a client relational predicate. It receives the operand of each $some / $none the client sent and returns the operand to run. Default: identity.
@TableController(IssueTable)
export class IssuesController extends AsDbReadableController<typeof IssueTable> {
protected transformRelationFilter(path: string, filter: FilterExpr): FilterExpr {
// A client sees only its team's tickets — through $with and through predicates alike
return path === "ticket" ? { $and: [{ teamId: { $in: currentTeams() } }, filter] } : filter;
}
}pathis the dotted navigation chain from this controller's table:"ticket"forticket=$some(…),"ticket.team"for a predicate nested in its operand,"tickets.issues"for$with=tickets(issues=$some(…)).- It runs after the request gate and before
transformFilter, on/query(incl.$countand$groupBy),/pages,/geo,$withsub-filters of/oneand the filter of a query target. It may be async. - Post-order: a nested operand is rewritten first, then its parent receives the already-rewritten operand. Your output is not walked again — a predicate you add is not passed back to the hook.
- Under an overlay,
$somematches and$noneexcludes on visible related rows only:ticket=$none(status=open)keeps an issue whose only open ticket is invisible to the caller, just as$with=ticketwould not show it. - Server-side filters (
transformFilter,transformOne,actionRowScope) never pass through it, nor do predicates avalidateControlsoverride conjoined into$withentries. - It costs nothing unless overridden.
transformProjection
Intercepts the projection before every read. If the client sends $select, projection contains it; otherwise it is undefined. Use this to enforce field exclusions:
protected transformProjection(projection?: UniqueryControls['$select']) {
return projection ?? { password: 0, apiKey: 0, secret: 0 }
}When projection is undefined (no $select from the client), the hook supplies a default exclusion list. When the client does send $select, you can merge or override as needed. This hook may also return a Promise for async decisions (e.g. per-user field visibility).
projection arrives in the wire shape: an inclusion is an array ($select=a,b → ['a', 'b']), an exclusion an object ($select=-a → { a: 0 }). When you intersect it with a role's allowed fields, normalize the array first ({ a: 1, b: 1 }). A helper that expects the object form reads an array as an exclusion keyed "0", "1", …, and the result is no longer narrowed to what the client asked for.
validateInsights
Runs after the URL query string is parsed. The insights map contains every field referenced in the query — whether in a filter, projection, or sort order. Return a string to reject with HTTP 400, or undefined to allow. The default refusal (Unknown field "<path>") — also when your override returns super's answer — is the validation envelope with errors: [{ path, message }]; a string of your own is the response's message alone.
const RESTRICTED_FIELDS = new Set(['salary', 'ssn', 'internalNotes'])
protected validateInsights(insights: Map<string, unknown>): string | undefined {
// Run the default validation first (rejects unknown fields)
const base = super.validateInsights(insights)
if (base) return base
const user = this.getCurrentUser()
if (!user?.isAdmin) {
for (const field of insights.keys()) {
if (RESTRICTED_FIELDS.has(field)) {
return `Access denied: cannot query field "${field}"`
}
}
}
return undefined
}This catches every reference to a restricted field — whether in a filter (salary>=100000), a projection ($select=ssn), or a sort order ($sort=salary).
validateControls
Validates the parsed controls of a read (type: query, pages, geo, getOne, nested) against the endpoint's controls DTO — the hook for per-control authorization. An aggregate /query is checked against the aggregate controls instead (an unknown one is a 400, since 0.1.168), and the inner level of a two-level aggregate is passed again on its own with type: "nested" — so a rule on $groupBy or $select sees the inner level's too (the root's name inner columns). Override it, call super, then add rules; return a string to reject with HTTP 400. It may also rewrite controls in place, for example to conjoin a related table's row scope into each $with entry:
protected validateControls(controls: Record<string, unknown>, type: TDbControlsType) {
const error = super.validateControls(controls, type)
if (error) return error
for (const entry of (controls.$with ?? []) as Array<{ name: string; filter?: FilterExpr }>) {
const scope = rowScopeOf(entry.name) // may itself use $some / $none
if (scope) entry.filter = entry.filter ? { $and: [scope, entry.filter] } : scope
}
return undefined
}Since 0.1.147 the relational-predicate gate judges the client's $with tree as recorded before this hook runs, so predicates you add here are not gated or counted as client input, and transformRelationFilter rewrites only the client's. Keep the client's filter object when you wrap it (as above): with transformRelationFilter overridden, a client $with predicate that was copied or dropped makes the request fail with 500 instead of running without its overlay.
hasField
The field-visibility hook. Return false for a path the current request must not see, and the server answers exactly as for a field that does not exist — Unknown field "x" (or Unknown relation "x" in $with) — so the reply reveals nothing about the hidden field.
protected hasField(path: string): boolean {
const hidden = useHiddenFields() // e.g. from the caller's read scopes
return super.hasField(path) && !hidden.has(path.split('.')[0])
}Since 0.1.133 every field reference is checked against it before any capability rule: filter keys (inside ^ / !( ) groups and $exists included), $sort, $select, $groupBy, $having, aggregate and calendar-bucket fields, $with relation names and sub-query fields, and the $search fallback fields. Since 0.1.143 nested $with relation names are checked too, at their full path (hasField("author.org") for $with=author($with=org)), with the same Unknown relation answer. From 0.1.128 to 0.1.132 a hidden stored column skipped it, so a filter or sort on it still ran — a value oracle. Upgrade if you hide fields this way. Since 0.1.147 relational predicates are checked the same way: the relation at its path (ticket) and every operand field at its full path (ticket.status).
Since 0.1.134 it also governs row identification: a unique index over a hidden field is not an identification, so it cannot be used to probe whether a row with a given value exists. It behaves as if the index did not exist:
| Request | With the unique key hidden |
|---|---|
GET /one/<value> | Not tried as a key → 404, same as a value that matches nothing |
GET /one?<key>=<value> | 400 Query params do not match any primary key or unique index, same as ?nope=x |
DELETE /<value>, DELETE /?<key>=<value> | Same as /one — nothing is deleted |
PATCH / without the PK, keyed by the hidden field | The payload identifies no row (Missing primary key field …) |
Action ids (@DbActionID / @DbActionIDs) | Rejected like an unknown shape; the "must exactly match one of" list names visible keys only |
The primary key and preferredId are always addressable — they are returned in every row anyway. Also since 0.1.134, the "a" is a nested object — filter or sort on one of its leaves (…) hint lists only visible leaves. When every leaf is hidden, the parent answers Unknown field "a".
It only rejects references. Pair it with:
transformProjectionto strip the hidden values from rows (a request without$selectreturns every column);applyMetaOverlayto prune/meta, which is cached and does not consulthasField.
Since 0.1.143 an overridden hasField also gates the database's indexes, which search inside the engine and would otherwise rank or match on a hidden column:
| Request | An index reading a hidden field is answered… |
|---|---|
$search + $index=<name> (native text search) | like a nonexistent index: 400 Search index "<name>" not found |
$search without $index (the default text index) | like no index at all: 400 No search index available (since 0.1.147 — it used to fall back to the substring search, see Upgrading) |
$search + $vector[=<name>] | like a nonexistent vector index: 400 Vector index "<name>" not found (No vector index available without a name), before computeEmbedding runs |
GET /geo (default geo index, or $index=<name>) | like a missing geo index — the same 400 with code: "GEO_INDEX_MISSING" the core answers for an unknown one (the code since 0.1.166) |
Unknown index names get the same answers on a controller that overrides hasField, so the reply reveals nothing. A @db.column.derived field follows its source: it is visible only while its source path is (a derived copy of settings.apiKey disappears with settings), and one whose source is hidden is sealed out of every read projection for that request, exactly like a @db.writeOnly field — in joined $with rows too (the source is checked as rel.<source>) — and is never loaded for an action's requiredFields.
/meta follows the same rule on its own (since 0.1.147): under an overridden hasField it leaves out of searchIndexes every index reading a hidden field, and turns searchable / vectorSearchable / geoSearchable off when the index a request naming none would use reads one (searchable stays on for the @db.column.searchable fallback of a table without native search, while any of its fields is visible). The crud.query / crud.pages control lists drop what the gate would refuse: index and fuzzy when no text index is visible (nor the fallback), vector and threshold when no vector index is, search when neither is; crud.geo goes when no geo index is visible. Tables without native search keep the substring fallback over visible fields. The protected indexFieldPaths() lists every text / vector / geo index with the logical field paths it reads and which one is the default — read it to describe an index differently. An integer member of an @db.index.fulltext is an index field like any other: an index reading a hidden integer member is refused exactly like one reading a hidden string. Text and vector entries come from the adapter's getSearchIndexes() (fields, isDefault); an index whose coverage the adapter cannot tell (a dynamic document-search mapping) lists every field.
computeEmbedding
Called when $vector is present in query controls. Receives the search text and an optional field name. Must return a number[] (the embedding vector).
The default implementation throws HTTP 501 Not Implemented.
@TableController(articlesTable)
export class ArticlesController extends AsDbController<typeof Article> {
protected async computeEmbedding(search: string, fieldName?: string): Promise<number[]> {
// Use any embedding provider (OpenAI, Cohere, local model, etc.)
const response = await openai.embeddings.create({
model: "text-embedding-3-small",
input: search,
});
return response.data[0].embedding;
}
}The fieldName parameter identifies which vector field was specified in $vector, allowing different embedding models per field if needed.
See Vector Search in URLs for how this hook integrates with the URL parameters.
decorateRows
Since 0.1.136. A post-read hook for data that is not a column: unread counters, a signed download URL, a flag computed from another service. It is not implemented by default — defining it in your subclass switches it on. It receives the final rows of one response and mutates them in place. It may be async, and one call covers the whole page, so batch your lookups:
import type { TDbDecorateContext } from "@atscript/moost-db";
@TableController(ticketsTable)
export class TicketsController extends AsDbController<typeof Ticket> {
protected async decorateRows(rows: Record<string, unknown>[], ctx: TDbDecorateContext) {
const unread = await countUnread(rows.map((r) => r.id)); // one query per response
for (const row of rows) row.$unread = unread.get(row.id) ?? 0;
}
}ctx carries:
| Field | Value |
|---|---|
endpoint | "query", "pages", "geo" or "one" (/one/:id and the composite /one?… both report "one") |
projection | The effective $select of the read, after transformProjection and the @db.writeOnly seal (undefined = all columns) |
controls | The request's parsed controls ($select, $with, $actions, …) — treat as read-only |
decorations | The declared decoration keys this response must carry; empty without @DbDecorations |
The keys the hook sets are of two kinds:
| Kind | Example | Listed in /meta, selectable | In the response |
|---|---|---|---|
Undeclared — a $-prefixed key | $unread | No | Always |
| Declared (a column) | unreadCount | Yes ($select=unreadCount) | Only when named in ctx.decorations |
When it runs:
- Once per response on
/query,/pages,/geoand/one, including$searchand$vectorreads. - After
$actionsaugmentation, so rows already carry$actions(and$disabledReasonswhere set). - Not for
$count,$groupByaggregates, a/one404, or value-help controllers. - Nested
$withrows are not passed on their own — reach them through the parent row.
Declared decorations — @DbDecorations
Since 0.1.148. A $ key is invisible to /meta and a client cannot select it. To make a computed value a column — shown in a table, selectable, described by its label and width — declare it. The declaration is a plain atscript interface (no @db.table / @db.view):
// tickets.decorations.as
export interface TicketDecorations {
@meta.label 'Unread'
unreadCount?: number.int
@meta.label 'Owner'
ownerName?: string
}import { DbDecorations, type TDbDecorateContext } from "@atscript/moost-db";
@TableController(TicketTable)
@DbDecorations(TicketDecorations, { requires: { ownerName: ["ownerId"] } })
export class TicketsController extends AsDbController<typeof TicketTable> {
protected async decorateRows(rows: Record<string, unknown>[], ctx: TDbDecorateContext) {
if (ctx.decorations.has("ownerName")) {
// read rows[i].ownerId, set rows[i].ownerName
}
}
}/metagainsdecorations— the declared interface, serialized with its@meta.*,@expect.*and@ui.*annotations — and afields[key]entry{ sortable: false, filterable: false, decoration: true }for each key. Neither is in/meta.type, so forms and client-side write validation never see them.- A client names a key in
$selectlike a field:$select=title,ownerName. Without a$selectevery visible decoration is served;$select=-ownerNameleaves one out. Anywhere else — a filter ($existsincluded),$sort,$groupBy,$having, an aggregate, a calendar bucket, a grouped$select— a decoration answers400 Field "x" is display-only and cannot be used in …. requireslists the readable's own fields the hook reads to compute a key. They are selected for you and stripped from the response again unless the client selected them, sotransformProjectionneeds no widening and nothing leaks. A path may name a nested object (requires: { digest: ["secret"] }): on SQL the object is stored as leaf columns, and the parent stands for all of them, so the hook gets the whole object on every adapter (a client-selected or excluded descendant still applies to the response). A parent with a@db.writeOnlydescendant is refused at boot on every adapter. WhentransformProjectionnarrows the parent to its leaves, the decoration is served only if every leaf survives, and a hidden leaf hides it, on every adapter. A@db.jsoncolumn is one field; on SQL its inner paths cannot be selected.- Visibility follows the sources. A decoration is served — and listed in
/meta— only while everyrequirespath is visible (hasField) and survivestransformProjection. A hidden source answersUnknown field "x"like any hidden field; a source thattransformProjectionstrips silently drops the decoration, as it drops a field. A decoration that reveals a field's data MUST list it inrequires; data from outside the table (another service) is yours to protect. Decoration keys never reachtransformProjection,hasFieldor filters, so a permission layer needs no change; anapplyMetaOverlaymay delete a prop frommeta.decorationsto hide a decoration per principal. - Compute
ctx.decorationsonly (the keys requested and served). A declared key you set that is not in it is removed after the hook;$keys are untouched.
Checked once per class, with a [moost-db] error: the type is an object interface without @db.table / @db.view; keys are plain top-level identifiers (no $ prefix, no dots) that collide with no field or relation of the readable; every requires key is declared and every path is an own, readable (not @db.writeOnly) field or the parent object of such fields (none of its descendants @db.writeOnly). A decoration cannot be declared on a value-help controller; declaring one without implementing decorateRows logs a warning. The declaration is inherited under @Inherit(). On the client, new Client<typeof Ticket, TicketDecorations>(…) types the keys (Decorations).
DO
- Name the keys you add with a
$prefix, like$actionsand$distance. Such a key can never collide with a field. The prefix is a convention; nothing enforces it. - Add any column the hook reads but the client might not select in
transformProjection. That column is then part of the response. (With@DbDecorationslist it inrequiresinstead — it is stripped again.)
DON'T
- Overwrite or remove
$actionsor$disabledReasons. - Rely on columns that only an action's
requiredFieldspulled in — they are stripped again before the hook runs.
Don't "correct" stored columns in decorateRows
Overwriting a field of the table (row.status = …) changes only what the response shows:
- The database still works on the stored value. It filters, sorts, groups and counts on what is stored, so the column shows one value and sorts or filters by another.
- The inputs may be missing.
requirespaths are read only for a requested decoration key. A$selectthat names no decoration ($select=title,status) reads none of them, so the hook runs without the inputs of its "correction".
Correcting stored columns
A declared decoration can never be named like a column, and a hook that overwrites a column is the overlay the warning above describes. Pick the tool by what the corrected value must do:
| The value… | Use |
|---|---|
| is only shown | A declared decoration under its own key (effectiveStatus, not status). A client selects it, and its requires are read for it. |
| must sort, filter or page | A view field: a @db.compute column (the database evaluates it) or a first-row join field (taken from one related row). The two combine. |
| is stored wrong | Normalize it at write time — onWrite / guardWrite, or a @db.default* / @db.onUpdate.now annotation — and migrate the existing rows. |
@db.compute is numeric only: arithmetic and coalesce over number operands. It has no string or conditional form, so a corrected string or status is a first-row join field, a decoration under its own key, or a write-time fix.
resolveRowIds
Since 0.1.148. A client may hold a stale key — a ticket key that was renamed, an alias. Override resolveRowIds(ids, ctx) to map such ids to the row's current id. One hook covers every id-addressed endpoint, so the alias policy lives in one place:
ctx.purpose | Endpoint | ids |
|---|---|---|
"one" | GET /one/:id, GET /one?… | One id: the path string (always a string), or the ?-form identification object |
"available" | GET /meta/actions/:id, ?… — and a view asking its source | One id, as above |
"remove" | DELETE /:id, DELETE /?… (AsDbController) | One id, as above |
"action" | An action route | The body's validated ids (one for a 'row' action); ctx.action, ctx.level set |
import type { TDbRowIdInput, TDbRowIdsContext } from "@atscript/moost-db";
@TableController(ticketsTable)
export class TicketsController extends AsDbController<typeof Ticket> {
protected async resolveRowIds(ids: readonly TDbRowIdInput[], ctx: TDbRowIdsContext) {
return Promise.all(
ids.map(async (id) => {
const code = typeof id === "object" ? id.code : id;
if (typeof code !== "string") return id;
// resolve INSIDE the row overlay: a row the caller cannot reach never wins
const inScope = (code: string) =>
ticketsTable.count({
filter: ctx.overlay ? { $and: [{ code }, ctx.overlay] } : { code },
});
// the current holder of a key wins; consult the alias table on a miss
if ((await inScope(code)) > 0) return id;
const alias = await aliases.findOne({ filter: { oldCode: code } });
if (!alias || (await inScope(alias.newCode)) === 0) return id;
return typeof id === "object" ? { code: alias.newCode } : alias.newCode;
}),
);
}
}It runs once per request, after prepareRequest and the request's own validation (controls, the ?-form id, the action body's shape and maxIds — a bad request answers 400 before the hook), and before anything reads the row; the order on the action route is written once there. It costs nothing when not overridden: no call, no allocation.
Contract
- Return one id per input id, in order (anything else is a
500). - An id that already names a row comes back unchanged — the current holder of a key wins over an alias, the same precedence a primary key has over a unique key. An id you cannot resolve also comes back unchanged: the endpoint then answers its normal miss.
- The output is validated, and a bad one is a server bug (
500, never a client400). A scalar (string | number | boolean) is resolved like a path scalar — primary key first, then the visible unique keys, inside the row overlay. An object must be one of the visible identifications, so the hook cannot route through a unique index over a fieldhasFieldhides. For"action"every entry must be such an object. ctx.overlayis the row overlay the endpoint will apply to the resolved id (undefinedwhen none) — server data, never sent to the client.- Handlers (
@DbActionID(),useDbActionId()),onRemove,guardRemove,actionRowScopeand the row reads receive the resolved id. Error bodies and target summaries keep the id the client sent. - Write bodies (
POST/PUT/PATCH) are not resolved: inPATCH { code: "T-OLD", … }, "address the row by its former key" and "set the key" cannot be told apart, and the write addresses its row inside the table's transaction. Rewrite a body inonWrite. - Value-help controllers do not have it: they implement
getOne(id)themselves, which is already the resolution seam.resolveRowFilteris not one either —/onereads throughfindOneByRowon every real table and view. - On a
@DbActionsFromview,GET /meta/actionsresolves the id by the view first, then the source's ownresolveRowIds("available") resolves the source id: the source decides. - 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.
Resolving safely — the resolved id is never trusted for access: every endpoint still reads or deletes it under the same row overlay and visible identifications. The remaining question is whether the answers can tell an alias of an unreachable row from a missing id:
- Return unchanged instead of throwing for an unknown id — a custom error for "no such alias" is an oracle.
- Always resolve inside
ctx.overlay(as the example does), at least when an alias could map to several rows (findOne({ filter: { $and: [aliasFilter, ctx.overlay] } })), so an unreachable row never wins over a reachable one. - Do not log or return the canonical id in custom errors.
- The hook runs before the overlay read: its own query timing is yours to manage.
With that, /one, DELETE, /meta/actions and a 'row' action answer an unreachable alias, a missing id and an unknown alias identically (404, or { actions: [] }), and a 'rows' refusal lists every id you sent, in the order you sent them, each with its own reasons entry, and matched counts the ids you sent (two aliases of one row look exactly like two distinct rows; the handler still receives the row once). A @DbActionsFrom source id is built from the resolved id, never from a raw alias. Cost: one hook call per request when overridden — check the id's current holder first (one indexed read, or an in-memory alias map) and consult the alias table only on a miss.
Write Hooks
Every built-in write endpoint runs the same pipeline (since 0.1.128):
prepareRequest (when implemented)
→ shape gate (400) → onWrite / onRemove (untrusted body, outside any transaction)
→ table op — the table's own transaction:
validate → guardWrite / guardRemove (only when overridden) → re-validate → write
→ nested-relation phases → checkWrite (only when overridden)
→ 404 / 409 disambiguation- Shape gate — the body must be a plain object (
POST/PUT/PATCHwith an object) or an array of plain objects (array bodies).null, primitives and[1, "x"]answer400witherrors: [{ path: "" | "[i]", message: "Expected an object" }]before any hook runs. The gate is applied again to whatonWritereturns: a hook that returns a non-object (or an object for a*Manyaction) aborts with500 "Not saved", like returningundefined. onWrite/onRemovekeep their contract and run outside any transaction (they coerce untrusted input; on MongoDB a transaction callback may re-run, so hooks with side effects stay out of it).onWritereceives the body with@normalize.*applied — see onWrite.guardWrite/guardRemoveare the table's write guards: the override is passed as theguardoption of the table call and runs inside the table's transaction, after defaults + validation. Overriding a guard is the switch — unmodified controllers pass no guard and pay nothing: the table is called exactly as before, no second validation.- Built-in failures are thrown as
HttpError— see Thrown errors.
onWrite
Intercepts all write operations before they reach the database. The body arrives as the write will store it: the type's @normalize.* applied — " Ann@X.io " is "ann@x.io" here, as in guardWrite and checkWrite (since 0.1.173) — but not yet given defaults or validated. The request's own body object is not modified, and what you return is normalized and validated by the table. Return the (possibly modified) data to proceed — in the shape received: an object for the single actions, an array of objects for the *Many actions — or undefined to abort (HTTP 500 "Not saved"; any other non-conforming return value aborts the same way). Return an Error instance (an HttpError for a chosen status) to respond with that error — since 0.1.128; it used to be passed on as data — or throw one.
The action parameter identifies the operation:
| Action | Triggered By |
|---|---|
insert | POST / with object body |
insertMany | POST / with array body |
replace | PUT / with object body |
replaceMany | PUT / with array body |
update | PATCH / with object body |
updateMany | PATCH / with array body |
Audit fields:
protected onWrite(
action: 'insert' | 'insertMany' | 'replace' | 'replaceMany' | 'update' | 'updateMany',
data: unknown
) {
const record = data as Record<string, unknown>
if (action === 'insert') {
return { ...record, createdBy: this.getCurrentUserId() }
}
if (action === 'update') {
return { ...record, updatedBy: this.getCurrentUserId() }
}
return data
}Authorization:
protected onWrite(action: string, data: unknown) {
if (!this.getCurrentUser()?.canWrite) {
return undefined // abort — returns HTTP 500
}
return data
}onWrite may also be async — every call site already awaits the result:
protected async onWrite(action: string, data: unknown) {
const approved = await this.approveWithAuditService(action, data)
return approved ? data : undefined
}onRemove
Intercepts DELETE requests. Receives the record ID (a string for single-key tables, or an object for composite keys) — the id resolveRowIds resolved, when it is overridden; guardRemove sees the resolved id too. Return the ID to proceed with deletion, or undefined to abort (returns HTTP 500); return or throw an Error to respond with it.
Delete guard:
protected async onRemove(id: unknown) {
const record = await this.table.findById(id as string)
if (record?.protected) {
return undefined // abort
}
return id
}Soft delete (replace DELETE with UPDATE):
Returning undefined from onRemove produces HTTP 500
The abort path always emits HTTP 500 "Not deleted" to the client — there is no "soft success" return slot. If you simply soft-delete in onRemove and return undefined, the row is marked deleted but the client sees a 500 and may retry. Prefer one of the patterns below.
Recommended pattern A — intercept DELETE with a custom route, hide the generated one behind a guard, and expose your own soft-delete handler:
import { Delete, Param } from "@moostjs/event-http";
@TableController(todosTable)
export class TodoController extends AsDbController<typeof Todo> {
// Returning undefined here aborts the built-in DELETE with HTTP 500;
// route DELETEs through a sibling endpoint instead.
protected onRemove() {
return undefined;
}
@Delete(":id/soft")
async softDelete(@Param("id") id: string) {
await this.table.updateOne({ id, deletedAt: Date.now() } as any);
return { deletedCount: 1 };
}
}Recommended pattern B — model soft-delete as a 'row'-level action, which gives you a typed client.action('archive', { id }), server-side disabled gating, and /meta-driven UI buttons:
@Post("actions/archive")
@DbAction<Todo, ["deletedAt"]>("archive", {
label: "Archive",
intent: "negative",
requiredFields: ["deletedAt"],
disabled: (rows) => rows.map((r) => r.deletedAt != null),
})
async archive(@DbActionID() id: { id: string }) {
await this.table.updateOne({ id: id.id, deletedAt: Date.now() } as any)
return { message: "Archived" }
}In either case, combine with transformFilter to exclude soft-deleted records from reads:
protected transformFilter(filter: FilterExpr): FilterExpr {
return { $and: [filter, { deletedAt: { $exists: false } }] }
}Why not just return undefined from onRemove?
The original "soft delete inside onRemove, return undefined" recipe still succeeds at the DB level — the row IS soft-deleted — but the HTTP response is 500 "Not deleted". Clients (including @atscript/db-client) treat that as a server error and may surface a generic failure toast. Use one of the patterns above to keep the wire response consistent with the actual outcome.
guardWrite / guardRemove
The validated stage (since 0.1.128). Override guardWrite(ctx) to check or enrich rows after defaults and validation and before the write, inside the table's own transaction: the override becomes the guard option of the table call (TWriteOptions.guard), so it runs exactly once per endpoint call, and the table validates the rows again after it. A throw rolls the table's transaction back — including any rows the guard itself wrote through tables that joined it — and the error propagates unchanged. The OCC 404/409 disambiguation runs after the table call.
import { HttpError } from "@moostjs/event-http";
import { AsDbController, TableController } from "@atscript/moost-db";
import type { TDbWriteGuardContext, TDbRemoveGuardContext } from "@atscript/moost-db";
@TableController(rulesTable)
export class RulesController extends AsDbController<typeof Rule> {
protected override async guardWrite(ctx: TDbWriteGuardContext<Rule>) {
for (let i = 0; i < ctx.rows.length; i++) {
const row = ctx.rows[i];
// Enrich in place — the table op re-validates what it receives.
row.updatedBy = this.currentUserId();
// Lazy, memoised pre-image (read inside the transaction; null for inserts without a key).
const before = await ctx.current(i);
if (before?.locked) throw new HttpError(409, "rule is locked");
// Same transaction: an audit row rolls back with the write.
await auditTable.insertOne({ ruleId: row.id, action: ctx.action });
}
}
protected override async guardRemove(ctx: TDbRemoveGuardContext<Rule>) {
const row = await ctx.current();
if (row?.isFallback) throw new HttpError(409, "cannot delete the fallback rule");
}
}ctx.action is the endpoint's action (insert … updateMany); ctx.rows are the validated rows — defaults applied on insert/replace (static @db.default 'x' values are filled on every adapter, so the guard sees the full row; native function defaults such as a SQL @db.default.now stay absent until the engine fills them), identifying fields present and $cas removed on update; ctx.expectedVersions[i] is the version lifted from version / raw $cas (or undefined). For deletes, ctx.id is the id after onRemove, ctx.filter its resolved filter and ctx.current() the row about to be deleted. A missing row reaches guardRemove with current() resolving to null; the 404 comes after the guard — unless the id itself is malformed (it cannot be resolved to a filter, e.g. a non-numeric value for a numeric key), which is a 404 before the guard.
Overriding a guard is the only switch: there is no separate flag, and a no-op override still passes a guard (the table then validates twice). For a transaction around several table operations in a custom route, use withTransaction.
DO / DON'T
- Do reject by throwing (
HttpErrorfor a chosen status) — a returned value is ignored. - Don't swallow
DbErrors inside a guard: on PostgreSQL the transaction is aborted after a failed statement, and the next statement fails with "current transaction is aborted". - Don't await external I/O on SQLite — the guard holds the only connection; a self-call to your own API waits forever by default. See SQLite concurrency.
- MongoDB replica sets may re-run the callback on transient errors — a guard may execute more than once; keep it idempotent. On standalone MongoDB and on the memory adapter there is no transaction, so guard side effects are not rolled back.
- A guard that writes the same row (e.g. a versioned touch as a fence) bumps the version twice — once for the touch, once for the main write.
- Guard rows are validated twice (once before the guard, once after it): that cost exists only when a guard is overridden.
Recipe — a unique index over a custom-normalised value
@normalize folds case and whitespace. When two values count as the same under a rule of your own (letters folded together, punctuation ignored, uniqueness per catalog), store the normalised value in a column of its own, put the unique index on it, and fill it in guardWrite:
@db.table 'items'
export interface Item {
@meta.id
id: number
name: string
catalog: string
@db.index.unique 'item_identity'
identity?: string
}function identityOf(row: { name: string; catalog: string }): string {
const name = row.name.toLowerCase().replaceAll("ё", "е").replace(/[^\p{L}\p{N}]+/gu, "");
return `${row.catalog}:${name}`;
}
protected override async guardWrite(ctx: TDbWriteGuardContext<Item>) {
for (let i = 0; i < ctx.rows.length; i++) {
const row = ctx.rows[i];
let full = row;
if (ctx.action.startsWith("update")) {
// A patch carries only the changed fields: merge it onto the stored row first.
const current = await ctx.current(i);
if (!current) continue; // no such row: the update matches nothing, as without the guard
full = { ...current, ...row };
}
row.identity = identityOf(full);
}
}- A twin is a
409. The database enforces the index, so a second row with the same identity fails withCONFLICT(entry codeUNIQUE,params.index: "item_identity"), concurrent writes included. - The value a client sends is overwritten: the guard sets it on every insert, replace and patch of an existing row.
ctx.current(i)isnullfor a row that does not exist: skip it, oridentityOffails on the missing fields and the request answers 500 instead of matching nothing. - When the normaliser changes, the stored identities are stale. Recompute them in a data step, or with
rewriteRowsStep(Item, { field: "identity", id: "item-identity-v2", map: (_, row) => identityOf(row) })— change itsidwith each normaliser version, since the step cannot see your function change. Rows that become twins under the new rule make the step fail on the index; merge them first, in a step withphase: "beforeConstraints"when the index itself is new. - Direct table writes skip the controller.
table.insertOne()in a script or another service does not runguardWrite; pass the same function as theguardwrite option there, or the column is left as the caller wrote it.
checkWrite
Since 0.1.143. A row-level WITH CHECK: verify the rows a write produced, with the database's own filter semantics. The override becomes the table's check write option — same switch as guardWrite — and runs once per insert / replace / update call (bulk forms included), inside the table's transaction, after the main write and every nested-relation phase. Deletes never call it.
import type { TDbWriteCheckContext } from "@atscript/moost-db";
protected override async checkWrite(ctx: TDbWriteCheckContext) {
// Every written row must still be in the caller's tenant.
const inScope = await ctx.count({ $and: [{ $or: [...ctx.filters] }, { tenant: currentTenant() }] });
if (inScope !== ctx.filters.length) throw new HttpError(403, "row leaves your scope");
}ctx field | Meaning |
|---|---|
action | The table method the check runs for (insert, insertMany, replace, update, …) |
filters | One exact primary-key filter per written row (inserted rows by their resulting key, generated ids included) |
count(filter) | Counts matching rows inside the same transaction |
transactional | false when the adapter's transaction is a pass-through — the write is already durable |
- Do throw to reject: the transaction rolls back and the error propagates unchanged.
- Don't rely on the rollback when
ctx.transactionalisfalse(standalone MongoDB, the memory adapter): validate before the write inguardWritethere. - Nested writes a deep payload performs on related tables do not re-run it.
Recipe — at most N rows per parent or scope
There is no built-in row-cap option. Count the rows in the write's own transaction, but count in a way concurrent writes cannot slip past.
Counting alone is not enough. A count after the write (in checkWrite) or before it (in guardWrite) sees the rows other transactions have committed, plus its own. Two inserts that run at the same time therefore each count only their own new row, and both pass a cap they break together. This happens under the default isolation of PostgreSQL (READ COMMITTED), MySQL (REPEATABLE READ) and MongoDB. SQLite runs one writer at a time, so a plain count holds there.
Lock the parent, then count. In guardWrite, take a row lock on the parent the cap belongs to (the household, tenant or project row) with a locking read, then count. A second insert for the same parent waits at the lock until the first commits, and then counts its row:
protected override async guardWrite(ctx: TDbWriteGuardContext<Item>) {
if (ctx.action !== "insert" && ctx.action !== "insertMany") return;
for (const householdId of new Set(ctx.rows.map((row) => row.householdId))) {
// Serialises every insert for this household until the transaction ends.
await householdsTable.findById(householdId, { lock: "update" });
const stored = await this.table.count({ filter: { householdId }, controls: {} });
const adding = ctx.rows.filter((row) => row.householdId === householdId).length;
if (stored + adding > MAX_ITEMS) {
throw new HttpError(409, { message: "Item limit reached", code: "ITEM_LIMIT", limit: MAX_ITEMS });
}
}
}The lock must be the first read of the transaction. On MySQL (REPEATABLE READ) the transaction's snapshot is fixed by its first plain read, and every later plain count reads from that snapshot, so it misses rows that other transactions committed while this one waited at the lock. A locking read is not a plain read: it takes the latest committed state and does not fix the snapshot, so lock first and count after. PostgreSQL under READ COMMITTED takes a new snapshot for every statement, so the order matters less there, but keep it: under REPEATABLE READ or SERIALIZABLE PostgreSQL has the same gap as MySQL. Do not read a row of the table (ctx.current(i), a findById without lock) before the parent is locked.
A patch that moves a row to another parent is the case that tempts a read first. It needs no read before the lock: the new parent is in the patch itself (row.householdId), so lock it, then read the row's current parent, then count:
if (ctx.action === "update" || ctx.action === "updateMany") {
for (const [i, row] of ctx.rows.entries()) {
if (row.householdId === undefined) continue; // the patch does not move the row
await householdsTable.findById(row.householdId, { lock: "update" }); // lock first
const current = await ctx.current(i); // the pre-image, read after the lock
if (current?.householdId === row.householdId) continue; // it stays where it is
const stored = await this.table.count({
filter: { householdId: row.householdId },
controls: {},
});
if (stored + 1 > MAX_ITEMS) {
throw new HttpError(409, {
message: "Item limit reached",
code: "ITEM_LIMIT",
limit: MAX_ITEMS,
});
}
}
}(Several rows moved to one parent in a single updateMany need their moves counted together, as adding is above.) Rows skipped by ?$onConflict=ignore are still counted in adding, so the guard may refuse a batch whose duplicates would have been dropped.
Isolation assumptions. The recipe is exact on PostgreSQL at its default READ COMMITTED, and on MySQL at its default REPEATABLE READ when the lock is taken first. If you raise PostgreSQL to REPEATABLE READ or SERIALIZABLE, retry the transaction on SERIALIZATION_FAILURE (see the serializable option below); on MySQL do not read before the lock. SQLite needs no lock.
MongoDB has no locking reads (findById(…, { lock }) throws LOCK_NOT_SUPPORTED). Write to the parent instead, e.g. bump a counter or a touchedAt field on it in the same transaction. Two transactions that write the same document conflict, and one of them is retried or fails, so the counts cannot both pass. Without a replica set MongoDB has no transactions, and the cap cannot be exact.
Or use serializable isolation and retry: run the count and the insert in withTransaction(fn, { isolation: "serializable" }) and repeat the whole transaction when it fails with a retryable error (SERIALIZATION_FAILURE). This needs no lock, but every conflicting insert costs a retry.
withTransaction
this.withTransaction(fn) runs fn inside the bound table's adapter transaction — the same nesting rules as adapter.withTransaction. Use it for custom routes and actions that must be atomic across several table operations:
@Post("actions/close")
@DbAction("close", { label: "Close" })
async close(@DbActionID() id: { id: number }) {
return this.withTransaction(async () => {
await this.table.updateOne({ id: id.id, status: "closed" });
await auditTable.insertOne({ orderId: id.id, action: "close" });
return { message: "Closed" };
});
}Thrown errors
Since 0.1.128 every built-in write failure is thrown as an HttpError — 500 "Not saved" / "Not deleted", 404, the OCC 409, the shape gate 400 — instead of being returned. The response is identical (Moost renders returned and thrown HttpErrors the same way), but a throw also rolls back a transaction a subclass opened around super.update(). One nuance: when a handler throws, the HTTP router may fall through to a later route that also matches the request before responding; a returned error responds immediately. This only matters when a catch-all route shadows a controller path.
| Case | Status | Table transaction |
|---|---|---|
| Body not an object / array of objects | 400 | not opened |
onWrite / onRemove returns undefined or a non-object | 500 | not opened |
onWrite / onRemove returns or throws an error | that | not opened |
version + differing $cas, malformed $cas | 400 | not opened |
| Validation fails before the guard | 400 | rolled back |
| Guard throws | that | rolled back — nothing written |
| Re-validation after the guard fails | 400 | rolled back |
| OCC mismatch / missing row | 409 / 404 | committed with no match; the findOne runs after |
DELETE of a missing row (guard ran, current() = null) | 404 | committed with no match |
| DELETE with an id that resolves to no filter | 404 | not opened, guard not called |
| SQLite transaction-gate wait timeout | 503 | n/a |
| Success (POST / PUT / PATCH / DELETE) | 201 / 200 / 200 / 200 | committed |
The success statuses are those of moost 0.7 / wooks 0.8 (since 0.1.165; before, PUT answered 201 and PATCH / DELETE 202).
Answering like the core — dbErrorToHttp
Since 0.1.166. A permission layer that should answer exactly as the core would — a hidden thing reported like a missing one — builds the same DbError and throws (or returns) dbErrorToHttp(error) from @atscript/moost-db. It produces the HttpError the validation interceptor sends for a thrown DbError: the status for its code (as in Error Handling), the message, the error's code, and each entry's path, message, code and params (never details).
import { DbError, geoIndexNotFoundMessage } from "@atscript/db";
import { dbErrorToHttp } from "@atscript/moost-db";
// e.g. in prepareRequest: the caller may not use the geo index `name`
throw dbErrorToHttp(
new DbError("GEO_INDEX_MISSING", [
{ path: name, message: geoIndexNotFoundMessage(this.readable.tableName, name) },
]),
);Metadata Hook
meta
Returns the payload emitted by GET /meta. Override to enrich the response with derived or remote data — the method may be sync or async.
protected async meta() {
const base = await super.meta()
return { ...base, featureFlags: await this.loadFlags() }
}The base implementation caches its own result. Subclasses overriding with async enrichment should cache their own computation if they need per-request dedup. Never mutate the object super.meta() returns — it is the shared cached envelope; build a new one as above. An overridden meta() is sent with the caching headers but serialized per request, without an ETag. Use meta() for static enrichment that is the same for every caller. For per-principal filtering of crud / actions, override applyMetaOverlay() instead — it runs after caching, on every request.
applyMetaOverlay
Runs on every GET /meta request, after meta() has resolved (and the result has been cached). Use it to prune crud keys, narrow crud[op] control whitelists, or filter the actions[] array based on the current request principal (user role, tenant, etc.).
protected applyMetaOverlay(meta: TMetaResponse): TMetaResponse {
const user = this.getCurrentUser()
if (user?.isAdmin) return meta
// Shallow clone — DO NOT mutate the cached envelope.
const out: TMetaResponse = { ...meta, crud: { ...meta.crud } }
// Hide write operations from non-admin users.
delete out.crud.insert
delete out.crud.update
delete out.crud.replace
delete out.crud.remove
// Drop the "Delete All" table-level action from this principal.
out.actions = meta.actions.filter((a) => a.name !== "deleteAll")
return out
}Always return a shallow clone — never mutate meta
The argument is the cached envelope shared across all requests. Mutating it leaks the per-request overlay to every subsequent caller. Spread ({ ...meta }) and re-spread nested objects you modify (crud: { ...meta.crud }, actions: meta.actions.filter(...)).
Discoverability, not security
applyMetaOverlay controls what the UI renders. It does NOT stop a client from hitting the underlying route — for real per-principal route enforcement, use Moost auth guards (@Authenticate) and the server-side action gate. See Permissions for the broader contract.
Keep /meta cacheable (since 0.1.151)
/meta is serialized once and sent with an ETag only while the same object comes back for the same inputs. Return meta itself when nothing changes for the caller. If you build variants, memoize them and return them through stableMeta(variant) (exported by @atscript/moost-db; it deep-freezes the object and marks it reusable), and never build a new object for the same inputs. Freezing alone (Object.freeze) is not enough. A fresh object on every request is still correct; it is just re-serialized and sent without an ETag. In tests (NODE_ENV=test), in development (NODE_ENV=development) or with ATSCRIPT_DB_FREEZE_META=1, served /meta objects are deep-frozen, so an in-place mutation throws.
May return a Promise. By default, the $actions augmentation and GET /meta/actions/:id take their action set from this overlay through allowedActions. Since 0.1.143 they read the cached envelope through applyMetaOverlay directly (resolveMeta()), not through an overridden meta(), and without re-running prepareRequest.
metaHttpCaching
Since 0.1.151. Controls the HTTP caching headers of GET /meta and GET /meta/form/:name (behavior: Caching and revalidation). The default {} sends Cache-Control: private, no-cache, merges Authorization and Cookie into Vary, and adds a weak ETag with 304 revalidation.
// No browser copy at all (shared machines, strict deployments):
protected override metaHttpCaching() {
return { cacheControl: "no-store" };
}
// Responses vary by a tenant header too:
protected override metaHttpCaching() {
return { vary: ["Authorization", "Cookie", "X-Tenant"] };
}
// The pre-0.1.151 response: no ETag, no 304, no caching headers.
protected override metaHttpCaching() {
return false as const;
}| Option | Default | Effect |
|---|---|---|
cacheControl | "private, no-cache" | Cache-Control value. A Cache-Control already set (e.g. by an interceptor) is kept. |
vary | ["Authorization", "Cookie"] | Merged into an existing Vary header (case-insensitive; * is left alone). |
A request with If-None-Match: * gets 304 whatever metaHttpCaching() returns (wooks 0.8 answers * for every GET 200, since 0.1.165); with false, a real ETag is never matched.
TDbMetaHttpCaching is exported from @atscript/moost-db. Keep Vary covering whatever identifies the caller (an API-key header, a tenant header). An after-interceptor that replaces the response body turns the ETag off for that response.
allowedActions
Since 0.1.145. allowedActions(names) returns the subset of the row-level action names the caller may run. $actions and GET /meta/actions/:id list only those. The default keeps the names that survive applyMetaOverlay, and returns every name without calling anything when applyMetaOverlay is not overridden. Override it when your permission layer can answer per action. $actions reads then skip building the whole /meta overlay (field pruning and so on) on every request:
protected override allowedActions(names: readonly string[]) {
return names.filter((action) => this.policy.mayRun(action)); // may be async
}It runs after prepareRequest. Names outside names are ignored. It controls what is listed. The action gate still enforces each call on its own.
authorizeForm
Since 0.1.143. GET /meta/form/:name serves the schema of an action input form. Override authorizeForm(name, actionNames) to refuse it per request: return false (or a promise of it) and the response is the same coded 404 an unknown form gets, so a refused form's existence does not leak. actionNames lists the discovered actions whose input form is name — allow the form when the caller may run at least one of them. Default: true.
protected override authorizeForm(name: string, actionNames: readonly string[]) {
return actionNames.some((action) => this.mayRun(action));
}Initialization
init
Runs once during controller construction. Use it for schema setup, seeding, or registering watchers. Can be async — errors are caught and logged automatically.
protected async init() {
await this.table.ensureTable()
await this.table.syncIndexes()
}A column type may depend on the other tables of the space (MySQL makes a string a foreign key references VARCHAR), so register every table with space.getTable() before the first ensureTable() — or create the schema with schema sync instead.
Accessing the Underlying Table
Inside any hook or custom method, you have access to the underlying table instance:
this.table; // AtscriptDbTable — full read/write access (table-bound controllers)
this.readable; // AtscriptDbReadable — read-only access (all controllers)Both controllers expose this.readable and the writable this.table — including AsDbReadableController, where named @DbAction handlers are the mutation surface. This is the canonical write access for the "generic reads + named actions" posture: no module-scope db.getTable(Model) needed. this.table throws for view-bound controllers.
// In any hook or custom method
const count = await this.readable.count({ filter: {}, controls: {} });
this.logger.info(`Primary keys: ${this.table.primaryKeys}`);
this.logger.info(`Indexes: ${this.table.indexes.size}`);Extending Controllers
Adding Custom Routes
Add methods with Moost decorators to create custom endpoints alongside the generated ones:
import { Get } from "@moostjs/event-http";
@TableController(todosTable)
export class TodoController extends AsDbController<typeof Todo> {
@Get("stats")
async getStats() {
const total = await this.readable.count({ filter: {}, controls: {} });
const completed = await this.readable.count({
filter: { completed: true },
controls: {},
});
return { total, completed, pending: total - completed };
}
}This adds GET /todos/stats alongside the generated CRUD endpoints.
To get another controller's rows matching a client's /query string inside your own route or command, with that controller's read policy applied, use resolveQuery.
Multiple Controllers
Mount multiple tables and views on different prefixes:
app.registerControllers(
["todos", TodoController],
["projects", ProjectController],
["active-tasks", ActiveTasksViewController], // read-only view
);Combined Example
A complete controller combining multi-tenancy, audit fields, soft deletes, and field restrictions:
const RESTRICTED = new Set(["internalNotes", "costPrice"]);
@TableController(productsTable, "api/products")
export class ProductController extends AsDbController<typeof Product> {
protected async init() {
await this.table.ensureTable();
}
protected transformFilter(filter: FilterExpr): FilterExpr {
return {
$and: [
filter,
{
tenantId: this.getTenantId(),
deletedAt: { $exists: false },
},
],
};
}
protected transformProjection(projection?: UniqueryControls["$select"]) {
return projection ?? { costPrice: 0 };
}
protected onWrite(action: string, data: unknown) {
const record = data as Record<string, unknown>;
if (action === "insert") {
return {
...record,
tenantId: this.getTenantId(),
createdBy: this.getUserId(),
};
}
return {
...record,
tenantId: this.getTenantId(),
updatedBy: this.getUserId(),
};
}
// Block the built-in DELETE — soft delete is exposed via a custom route
// below. Returning undefined here produces HTTP 500 if a client still hits
// DELETE /:id, which is the desired outcome.
protected onRemove() {
return undefined;
}
@Delete(":id/soft")
async softDelete(@Param("id") id: string) {
await this.table.updateOne({ id, deletedAt: Date.now() } as any);
return { deletedCount: 1 };
}
protected validateInsights(insights: Map<string, unknown>): string | undefined {
const base = super.validateInsights(insights);
if (base) return base;
for (const field of insights.keys()) {
if (RESTRICTED.has(field) && !this.isAdmin()) {
return `Access denied: cannot query field "${field}"`;
}
}
return undefined;
}
}Multi-Tenant Route Recipe
A common pattern: mount the same controller under a /tenant/:tenantId/... prefix and scope every read/write to the URL-supplied tenant. The route prefix is supplied at registration time; the tenant scope is enforced in transformFilter / transformOne / onWrite.
Read the tenant from the route param via Moost composables — the table API knows nothing about routing, so the hooks bridge them:
import { useRouteParams } from "@wooksjs/event-core";
import { HttpError } from "@moostjs/event-http";
import { AsDbController, TableController, type FilterExpr } from "@atscript/moost-db";
import { Todo } from "./schema/todo.as";
import { todosTable } from "./db";
@TableController(todosTable)
export class TenantTodoController extends AsDbController<typeof Todo> {
private getTenantId(): string {
const tenantId = useRouteParams<{ tenantId: string }>().get("tenantId");
if (!tenantId) throw new HttpError(400, "tenantId route param missing");
return tenantId;
}
protected transformFilter(filter: FilterExpr): FilterExpr {
return { $and: [filter, { tenantId: this.getTenantId() }] };
}
// No override needed for `/one` — `transformOne` falls through to `transformFilter`.
protected onWrite(_action: string, data: unknown) {
const record = data as Record<string, unknown>;
return { ...record, tenantId: this.getTenantId() };
}
}Register under a nested route prefix at app boot:
app.registerControllers(["tenant/:tenantId/todos", TenantTodoController]);
await app.init();The generated endpoints become:
GET /tenant/acme/todos/queryGET /tenant/acme/todos/one/42POST /tenant/acme/todos/PATCH /tenant/acme/todos/- ...
Every operation is automatically scoped to tenantId = "acme". Cross-tenant access through the API is not possible because the filter overlay is applied unconditionally — even direct PK lookups like GET /tenant/acme/todos/one/42 are rejected with 404 if record 42 belongs to a different tenant.
Resolved from another route, the hooks see no route params
Read through resolveQuery from another controller's command, getTenantId() above throws 400 — pass the tenant as scope.
Combine with transformOne for asymmetric scoping
If /one should bypass the tenant scope (e.g. when an admin can address any row by PK), override transformOne separately and leave transformFilter strict.
Next Steps
- HTTP Setup — Controller installation and wiring
- CRUD Endpoints — Endpoint reference
- CRUD Operations — Programmatic
AtscriptDbTableAPI - Transactions — Transaction integration