CRUD Endpoints
This page documents every endpoint generated by AsDbController and AsDbReadableController. All paths are relative to the controller prefix (e.g., /todos/).
See HTTP Setup for installation and wiring.
Reading Records
Read-response baseline
Every row-returning read endpoint (/query, /pages, /one, /one/:id, including $search and vector-search paths) silently widens the projection so each row carries the table's preferredId field set, regardless of $select.
$select shape | Behaviour |
|---|---|
absent / undefined | full projection — preferred-id fields already present |
string[] inclusion | dedupe + append missing preferred-id fields |
pure inclusion map ({ name: 1 }) | add missing preferred-id keys with value 1 |
pure exclusion map ({ id: 0 }) | rewritten to inclusion (all non-ignored own-table fields minus excluded) + every preferred-id field — exclusion CANNOT remove a preferred-id field. Excluding an object parent ({ address: 0 }) removes its whole subtree; excluding a leaf keeps the parent's other leaves (since 0.1.134 — before, the parent's leaves came back) |
mixed inclusion/exclusion ({ a: 1, b: 0 }) | rejected before the readable call (HTTP 400) |
Not widened: $groupBy aggregate (group keys are the only fields) and $count (returns a number).
preferredId defaults to primaryKeys. Declare @db.table.preferredId.uniqueIndex(name?) to use a specific @db.index.unique group as the preferred identifier — see Actions — Preferred row identifier. The widening happens AFTER any transformProjection() override resolves; preferred-id fields cannot be suppressed via projection. Hide identifiers at the network/authz layer instead.
GET /query
Returns an array of records. Supports filtering, sorting, pagination, projection, relation loading, and search.
curl "http://localhost:3000/todos/query?completed=false&\$sort=-createdAt&\$limit=10"Query parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
$sort | string | — | Sort expression (e.g., -createdAt for descending) |
$limit | number | 1000 | Maximum records to return |
$skip | number | 0 | Number of records to skip |
$select | string | — | Comma-separated field names for projection |
$count | boolean | — | Return count instead of records |
$search | string | — | Fulltext search term |
$index | string | — | Named search index |
$vector | string | — | Vector field name for similarity search |
$threshold | string | — | Similarity threshold for vector search |
$rank | boolean | true | false — unranked search ($sort or primary key) |
$with | string | — | Load relations (e.g., $with=author,comments) |
$groupBy | string | — | Group by fields for aggregation |
| (other) | any | — | Filter fields (e.g., status=active) |
Response (array):
[
{ "id": 1, "title": "Buy milk", "completed": false, "priority": "high" },
{ "id": 2, "title": "Write docs", "completed": false, "priority": "medium" }
]When $count is set, returns a number instead of an array:
curl "http://localhost:3000/todos/query?completed=true&\$count"5$count counts the same rows the query would return, including a $search term: with native search it counts the search matches (since 0.1.150 — it used to count the whole filtered table), with the @db.column.searchable fallback the fallback's matches, and with $vector the nearest neighbours (up to $limit, default 1000).
See URL Query Syntax for the full filter syntax and Relations & Search for $with, $search, $vector, and $groupBy.
GET /pages
Returns paginated results with metadata. Uses page-based pagination instead of offset-based.
curl "http://localhost:3000/todos/pages?\$page=2&\$size=10&status=active"Additional parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
$page | number | 1 | Page number (1-based) |
$size | number | 10 | Items per page |
All other query parameters from GET /query (filters, $sort, $select, $search, $with) are also supported, except $skip, $limit, and $count.
Response:
{
"data": [{ "id": 11, "title": "Task 11", "status": "active" }],
"page": 2,
"itemsPerPage": 10,
"pages": 5,
"count": 47
}| Field | Description |
|---|---|
data | Array of records for the current page |
page | Current page number |
itemsPerPage | Page size |
pages | Total number of pages |
count | Total number of matching records |
GET /one/:id
Retrieves a single record by primary key (or any single-field unique index — the path resolver walks every legitimate identification). Returns 404 if not found. The id (and the ?field=value form) goes through resolveRowIds first when the controller overrides it — a renamed key can still reach its row. Since 0.1.143 the id addresses exactly one row, primary key first, among the rows the transformOne overlay admits: when abc is one in-scope row's key and another row's unique value, /one/abc reads the key row; when the key row is out of scope, it reads the in-scope unique-value row — the same answer as if the out-of-scope row did not exist.
curl http://localhost:3000/todos/one/42
curl http://localhost:3000/users/one/admin # by username unique indexResponse:
{ "id": 42, "title": "Buy milk", "completed": false, "priority": "high" }Supports $select and $with in the query string:
curl "http://localhost:3000/todos/one/42?\$select=id,title&\$with=project"No filters
Filter parameters (like status=active) are not allowed on this endpoint. They return a 400 error. Use GET /query with filters instead.
When the table declares @db.table.preferredId.uniqueIndex (the canonical addressing identifier), scalar lookups via /one/:id resolve against the preferredId field first; PK and other unique indexes are not retried for the same scalar (the named form below covers them when needed).
Named-form (object) addressing — use query parameters when you want to be explicit about which identification you are addressing, or when the identifier is compound:
curl "http://localhost:3000/users/one?username=admin" # single-field unique index
curl "http://localhost:3000/task-tags/one?taskId=1&tagId=2" # composite primary keyThe controller walks every registered identification (primary key + every unique index, single-field and compound) in declaration order and picks the first whose fields are all present in the query.
GET /meta
Returns table or view metadata for use by UI tooling or client libraries.
curl http://localhost:3000/todos/metaResponse (abbreviated):
{
"searchable": true,
"vectorSearchable": false,
"searchIndexes": [{ "name": "DEFAULT", "description": "dynamic_text index" }],
"primaryKeys": ["id"],
"preferredId": ["id"],
"relations": [{ "name": "comments", "direction": "from", "isArray": true }],
"fields": {
"id": { "sortable": true, "filterable": true },
"title": { "sortable": true, "filterable": true }
},
"type": { "...": "serialized Atscript type definition" },
"actions": [],
"crud": {
"query": ["filter", "insights", "...", "actions", "groupBy"],
"pages": ["filter", "page", "size", "...", "actions"],
"one": ["select", "with", "actions"],
"insert": [],
"update": [],
"replace": [],
"remove": []
}
}| Field | Description |
|---|---|
searchable | Whether the table has fulltext search indexes |
vectorSearchable | Whether the table has vector search indexes |
geoSearchable | Whether the adapter supports geo search and the table declares a geo index (crud.geo then lists the /geo controls). See Geo Search |
searchIndexes | Array of available search index definitions |
primaryKeys | Primary key field names (logical, not column names) |
preferredId | Logical field names of the preferred identifier (PK or @db.table.preferredId.uniqueIndex group). See Preferred row identifier |
versionColumn | Logical field name of the @db.column.version field, if the table declares one — the name clients send and read back; a @db.column rename changes only the storage column. Absent (undefined) otherwise. See OCC over HTTP |
relations | Available navigation properties — { name, direction, isArray }, plus filterable: true on relations that accept a client relational predicate (@db.rel.filterable, since 0.1.147) |
fields | Per-field capability flags (sortable, filterable, advisory indexed, plus encrypted / geo / writeOnly markers, filterOps when only narrower operators such as $exists pass, bucketable on fields that accept a calendar bucket — both since 0.1.132 — and, since 0.1.148, groupable on fields that accept $groupBy and numeric on fields that may be an operand of aggregate arithmetic). Since 0.1.128 they are exact: sortable: true ⇔ $sort accepted, filterable: true ⇔ filter accepted; unlisted paths (nested-object parents, navigation paths, JSON descendants on SQL adapters) are rejected with 400. See Query Gate |
type | Full serialized Atscript type (field names, types, annotations, metadata). FK fields ship as shallow refs ({ id, metadata }) — enough to resolve the target's URL via db.http.path; deeper structure is reachable through the target's own /meta (see below). Since 0.1.128 a field declared through a reference chain carries the terminal ref (and inherits db.rel.FK), so value-help on view fields targets the dictionary. Annotations kept: meta.*, expect.*, db.rel.* and the write-relevant db.json, db.patch.strategy, db.default*, db.onUpdate.now (since 0.1.156), db.onUpdate.inc (since 0.1.162), db.http.path, db.writeOnly, db.column.version, db.column.derived (since 0.1.142); every other db.* (table, column, index, …) is stripped. Override getSerializeOptions() to change the list |
bucketUnits | Calendar-bucket units the adapter supports (["hour", "day", "week", "month", "quarter", "year"]). Absent when it has none. Since 0.1.132 — see Calendar Buckets |
aggregateFns | Aggregate functions the adapter renders in grouped queries — its aggregateFns(), in canonical order (["sum", "count", "avg", "min", "max", "countDistinct", "first", "last"] on every built-in adapter; first / last since 0.1.148). Since 0.1.136 — see Grouped Queries |
aggregateExpressions | Whether the adapter renders arithmetic in grouped queries — true on every built-in adapter (since 0.1.148). The operands are the fields flagged fields[path].numeric |
conditionalAggregates | Whether the endpoint takes conditional aggregates ($where, URL sum(amount|status=paid):paid) — true on every built-in adapter (since 0.1.168) |
distinctFieldLists | Whether the endpoint takes countDistinct over several fields — true on every built-in adapter (since 0.1.168) |
nestedAggregates | Whether /query takes two-level aggregates ($from) — the adapter's support, unless the controller turned them off with nestedAggregates() (since 0.1.168) |
nullsPlacement | Present (true) when the adapter honours NULL placement in sorts — the :first / :last suffix on $sort keys and the fields' @db.sort.nulls defaults (in type, metadata["db.sort.nulls"]). true on every built-in adapter (since 0.1.153) |
actions | Declared domain actions — see Actions |
crud | Built-in CRUD permissions / control whitelists — see Permissions |
A derived column is marked fields[path].derived: true (since 0.1.141) — read-only: a value sent for it on a write is dropped. Since 0.1.142 client preflight treats it as server-managed too.
A computed view column (@db.compute) is marked fields[path].computed: true (since 0.1.147) — advisory: its sortable / filterable flags follow the column type like any other number column. A hasField override that hides an operand hides the computed field from queries and read projections too.
type also keeps the annotations outside db.* that the table above does not list — normalize.* among them, which client preflight applies before it validates (since 0.1.170).
The actions[] entry shape is owned by Actions and the full crud whitelists by Permissions; from the browser, read this payload with client.meta().
The type.metadata["db.http.path"] carried in this payload follows the normalization contract — it is always the final public URL, prefixed with / and inclusive of the Moost globalPrefix, safe to use verbatim with fetch() or new Client(url). A reference carries the target model's canonical route in the serving app; the root of a controller's own /meta carries that controller's own mount (see Several controllers over one model). /meta is therefore resolved per app (async in the base controller since 0.1.150).
Caching and revalidation
Since 0.1.151 GET /meta (and GET /meta/form/:name) is cacheable by the browser, per user:
HTTP/1.1 200 OK
Cache-Control: private, no-cache
Vary: Authorization, Cookie
ETag: W/"oC1GGywzL4DrvNSrcTo23A"The browser revalidates on every use (no-cache). When the server would send the same bytes, it answers 304 Not Modified with no body:
curl -i http://localhost:3000/todos/meta -H 'If-None-Match: W/"oC1GGywzL4DrvNSrcTo23A"'
# → HTTP/1.1 304 Not Modified- The ETag is a hash of the response bytes, computed after every per-request hook (
applyMetaOverlay,hasFieldindex pruning, delegated actions). A user whose role sees a different/metagets a different ETag and a200, even when the browser sends the previous user's tag after a re-login. A304only confirms bytes the client already holds. - A refused request is never a
304. AprepareRequestthat throws answers with its error. - The JSON is serialized once per distinct payload, not on every request.
- An ETag is sent only when the payload object is reused across requests: the default pipeline reuses it; an
applyMetaOverlaythat builds a fresh object per request is served without an ETag (see applyMetaOverlay). - Opt out or change the headers with
metaHttpCaching(). - With a translator registered, each language has its own ETag and the response adds
Vary: Accept-Language(since 0.1.170) — see Translations.
Don't cache /meta by URL alone
/meta differs per user. A service worker or client cache keyed only by URL serves one user's /meta to the next. Cache per identity, or let the browser revalidate. client.invalidateMeta() resets a reused client.
FK ref shape in meta
/meta always serializes FK fields as shallow refs — ref.type is the { id, metadata } shape, independent of @db.depth.limit (which is a security guard on nested writes, not a serialization policy). The target's db.http.path is carried in metadata, so clients can:
- resolve the target endpoint for value-help pickers, and
- fetch the target's own
/metaon demand when they need the target's structural body.
Nav-prop trees (@db.rel.from / @db.rel.to / @db.rel.via) are fully expanded in meta regardless — they are not .ref nodes — so the write-payload shape clients need for nested inserts is always present. Only the FK pointer bodies are shallow, and those are recoverable via a cached per-target /meta fetch.
Creating Records
POST /
Insert one or many records. The request body determines single vs. batch mode.
Single insert:
curl -X POST http://localhost:3000/todos/ \
-H "Content-Type: application/json" \
-d '{"title": "Buy milk", "priority": "high"}'Response:
{ "insertedId": 1 }Batch insert:
curl -X POST http://localhost:3000/todos/ \
-H "Content-Type: application/json" \
-d '[{"title": "Buy milk"}, {"title": "Write docs"}]'Response:
{ "insertedCount": 2, "insertedIds": [1, 2] }Default values from @db.default and generated defaults (@db.default.increment, @db.default.uuid, @db.default.now) are applied automatically. Request bodies can contain nested relation data for deep insert operations.
On a table with a composite primary key, insertedId (and each insertedIds entry) is the primary-key object (since 0.1.169) — the fields GET /one?… takes:
{ "insertedId": { "orderId": 1, "productId": 2 } }A row whose key has a database-generated part the adapter cannot report answers without insertedId (null in insertedIds) instead of a wrong id — see Composite primary keys.
Batch edge cases
- Empty array
[]— behavior is adapter-dependent (may return 200, 201, 400, or 500) - Single-item array
[{...}]— treated as a batch insert, returnsinsertedCount/insertedIds - Large batches (100+ items) — supported; the entire batch runs in a single transaction
- Partial failure — if any item fails validation or violates a constraint, the entire batch is rolled back
Skip conflicting rows: ?$onConflict=ignore
Since 0.1.148. POST /?$onConflict=ignore skips rows that collide on the primary key or a unique index instead of answering 409. The status code is the normal insert status even when every row conflicted; the body reports each row by input index.
curl -X POST 'http://localhost:3000/todos/?$onConflict=ignore' \
-H "Content-Type: application/json" \
-d '[{"id": 1, "title": "Buy milk"}, {"id": 2, "title": "Write docs"}, {"id": 1, "title": "dup"}]'{ "insertedCount": 1, "insertedIds": [2], "inserted": [1], "conflicts": [0, 2] }A single-object body answers { "insertedId": 2, "conflict": false }, or { "conflict": true } when it was skipped.
$onConflicttakeserror(the default) orignore; any other value, and any other$control on POST, answers400.- Validation,
NOT NULLand foreign-key errors still fail the whole request and write nothing. A body that creates a nested related parent answers400. - A conflict reveals only the input index of the colliding row, never the stored row or the constraint name. Insert permission is all it needs;
guardWrite/checkWriterun exactly as for a plain insert, andguardWritesees every submitted row. GET /metalists the mode ascrud.insert: ["onConflict"]when the adapter supports it.prepareRequestreceivesonConflict: "ignore"on theinsertendpoint, so a controller can refuse it.- The client sends it as
client.insert(rows, { onConflict: "ignore" }).
Get the stored row back: Prefer: return=representation
Since 0.1.168. Opt-in per controller. On a controller whose returnRepresentation() returns true, a single-object POST / with the header Prefer: return=representation answers the stored row as row next to insertedId — server defaults, stamps and the version included — and the header Preference-Applied: return=representation. It saves the read-back request after an insert. By default (false) the header is ignored: the plain result, no Preference-Applied.
@TableController(todosTable)
export class TodosController extends AsDbController<typeof Todo> {
// Only when prepareRequest authorizes by ctx.endpoint (see below)
protected override returnRepresentation() {
return true;
}
}curl -X POST http://localhost:3000/todos/ \
-H "Content-Type: application/json" \
-H "Prefer: return=representation" \
-d '{"title": "Buy milk"}'{
"insertedId": 7,
"row": {
"id": 7,
"title": "Buy milk",
"status": "open",
"createdAt": 1762160000000,
"version": 0
}
}rowis exactly whatGET /one/7would answer the same caller:prepareRequestruns a second time withendpoint: "one", then the/oneread applies the read projection and the row overlay, andresolveRowIdsgets the id in its/one/:idtext form ("7"). A permission layer must authorize that call byctx.endpoint— the read grant, not the insert grant — before you opt in.- On a composite primary key the read-back addresses the row by its key object, exactly as
GET /one?orderId=1&productId=2does (since 0.1.169). An insert that reports noinsertedIdgets norow. - When that read fails — the caller has no read grant (
prepareRequestthrows), the new row is outside its read overlay, or a hook throws (logged as a warning) — the insert stands and the response is the plain{ insertedId }, withoutPreference-Applied. A denied read-back never turns the committed insert into an error. - An array body, and a single row skipped by
?$onConflict=ignore, ignore the preference. - The client sends it as
client.insert(row, { returning: true }).
Updating Records
PUT /
Full replace by primary key. The body must include all required fields and the primary key field(s). Optional fields you omit are cleared (NULL / absent) on every adapter — since 0.1.128 also on the SQL adapters, which previously kept the old value of an omitted column. Send the whole row you want stored. number.timestamp.updated fields (and any @db.onUpdate.now field) may be left out: PUT and PATCH set them to the server's time, ignoring a value in the body. Likewise a @db.onUpdate.inc counter may be left out; PUT and PATCH increment it whatever the body carries.
Single replace:
curl -X PUT http://localhost:3000/todos/ \
-H "Content-Type: application/json" \
-d '{"id": 1, "title": "Buy oat milk", "completed": true, "priority": "high"}'Response:
{ "matchedCount": 1, "modifiedCount": 1 }Bulk replace:
curl -X PUT http://localhost:3000/todos/ \
-H "Content-Type: application/json" \
-d '[
{"id": 1, "title": "Buy oat milk", "completed": true, "priority": "high"},
{"id": 2, "title": "Write tests", "completed": false, "priority": "medium"}
]'Response:
{ "matchedCount": 2, "modifiedCount": 2 }Nested relation data is supported per item — each record goes through the deep replace process.
PATCH /
Partial update by primary key. Only the provided fields are changed: send null to clear an optional column, omit the key to keep its value (an empty string is a value, not a clear). An optional field marked @meta.required rejects null with a 400 (since atscript 0.1.103) — such a field cannot be cleared; omit it, or type it string | null to accept null. Since 0.1.128 the read-side filter types admit null for optional columns as well, so ?description=null and { description: { $ne: null } } are typed like the runtime.
Single update:
curl -X PATCH http://localhost:3000/todos/ \
-H "Content-Type: application/json" \
-d '{"id": 1, "completed": true}'Response:
{ "matchedCount": 1, "modifiedCount": 1 }Bulk update:
curl -X PATCH http://localhost:3000/todos/ \
-H "Content-Type: application/json" \
-d '[{"id": 1, "completed": true}, {"id": 2, "priority": "high"}]'Response:
{ "matchedCount": 2, "modifiedCount": 2 }Field operations ($inc, $dec, $mul) work as plain JSON objects in the body:
curl -X PATCH http://localhost:3000/products/ \
-H "Content-Type: application/json" \
-d '{"id": 42, "views": {"$inc": 1}, "stock": {"$dec": 1}}'Also supports array patch operators. See Update & Patch for the full programmatic API.
OCC over HTTP
Tables that declare @db.column.version get optimistic concurrency control over HTTP automatically. Clients round-trip the version field — read the row, send it back as-is on PATCH or PUT, and the controller auto-lifts version from the body into $cas. No special headers, no special endpoints.
Auto-lift policy
The controller intercepts every write payload on a table whose /meta exposes a versionColumn. The examples on this page call the field version; the controller keys on whatever field versionColumn names — its logical name, which a @db.column rename does not change (since 0.1.141; earlier releases exposed the renamed storage column, so a renamed version field got no conflict detection over HTTP):
- If
versionis present in the body: it is stripped from the SET payload and lifted to$cas: { version: N }. The auto-bump still applies — the stored row ends up atN + 1, never at the value the client sent. - If
versionis absent: the write proceeds with no$cas— last-write-wins semantics (client opted out by stripping it). The version still bumps, unless the body writes only@db.column.version.exemptfields (since 0.1.150) — then it stays. Withversionpresent, an exempt-only body is still a$caswrite: checked and bumped. - A raw SDK-shaped
$cas: { version: N }is accepted as sent (since 0.1.128) and gets the same 404 / 409 disambiguation. Sending bothversionand$caswith different values is ambiguous →400witherrors: [{ path: "$cas", message: 'Ambiguous version: "version" and "$cas.version" differ' }]([i].$casin an array body); identical values are accepted.
Policy is presence-based, not enforced. There is no 428 Precondition Required gate.
A PK-only PATCH carrying a version is a write
PATCH { "id": 1, "version": 4 } with no other fields executes the conditional UPDATE — the versioned touch — and bumps the row to version 5 (since 0.1.128; it used to answer { 1, 0 } without touching the store). A stale version answers 409, a missing row 404. Re-submitting an unchanged form together with its version therefore invalidates every other open editor's version: strip version (or skip the request) when nothing changed. Without a version, PATCH { "id": 1 } writes nothing and reports { "matchedCount": 1, "modifiedCount": 0 } ({ 0, 0 } when the row does not exist).
| Request (row at version 4) | Response |
|---|---|
PATCH { id, version: 4 } | 200 { matchedCount: 1, modifiedCount: 1 }, row → version 5 |
PATCH { id, version: 3 } (stale) | 409 version_mismatch, currentVersion: 4 |
PATCH { id: 999, version: 4 } (missing) | 404 |
PATCH { id } / PATCH { id: 999 } | 200 { 1, 0 } / 200 { 0, 0 }, version unchanged |
PATCH { id, $cas: { version: 3 } } | 409 (raw $cas accepted) |
PATCH { id, version: 4, $cas: { version: 3 } } | 400 at path $cas |
# Read the row — version comes back.
curl http://localhost:3000/tasks/one/1
# → { "id": 1, "title": "Bake bread", "status": "open", "version": 4 }
# Send it back. version: 4 is auto-lifted to $cas: { version: 4 }.
curl -X PATCH http://localhost:3000/tasks/ \
-H "Content-Type: application/json" \
-d '{"id": 1, "status": "done", "version": 4}'
# → 200 OK { "matchedCount": 1, "modifiedCount": 1 }
# Row is now { …, status: "done", version: 5 }.409 Conflict — version mismatch
When the row's stored version no longer matches what the client submitted, the controller returns 409 Conflict:
{
"statusCode": 409,
"error": "Conflict",
"message": "version_mismatch",
"code": "VERSION_MISMATCH",
"params": { "currentVersion": 6 },
"kind": "version_mismatch",
"currentVersion": 6,
}code (since 0.1.175) is the discriminator to branch on, like every other moost-db error; kind stays for older clients.
Discriminate on code or kind, not error
The proposal called for { error: "version_mismatch", currentVersion: N }. The actual response carries error: "Conflict" — the Wooks framework that powers Moost overrides the error field with the standard HTTP reason phrase, and @atscript/moost-db cannot reclaim it. The OCC discriminator lives in kind instead, and currentVersion is preserved at the top level.
Clients should test code === "VERSION_MISMATCH" (or kind === "version_mismatch") and read currentVersion to refresh their view of the row, then retry.
Clients using @atscript/db-client get a typed VersionMismatchError subclass thrown automatically — no need to inspect body.kind. See the client doc for the pattern.
Clients on 409 typically:
- Re-read the row via
GET /one/:id(or usecurrentVersiondirectly if they have the rest of the row cached). - Re-apply their changes against the refreshed state.
- Retry the PATCH / PUT.
404 Not Found — missing row
A CAS-bearing write on a row that does not exist returns 404, not 409. The controller disambiguates by issuing a single findOne(id) after the adapter reports matchedCount === 0 — resolving the row primary key first like the write itself (since 0.1.143), so currentVersion is never read from a different row that merely shares a unique value with the payload:
- Row missing →
404 Not Found. - Row present →
409 Conflictwith the body above.
The extra findOne is paid only on the conflict path, never on the happy path.
PUT (replace) — same contract
PUT behaves identically to PATCH at this layer: round-trip version → $cas, mismatch → 409 with kind: "version_mismatch" + currentVersion, missing row → 404.
Bulk PATCH / PUT — per-item CAS
Each item in an array body carries its own optional version. Behavior:
- Items with
versionget per-row CAS. - Items without
versionare last-write-wins. - Mismatches are silently skipped, not failed — never "fail all on first conflict".
- Response is the aggregate shape:
{ matchedCount, modifiedCount }. Detect partial failure viamatchedCount < items.length— not viamodifiedCount, which can be lower thanmatchedCountfor unchanged values (a version-exempt write on MySQL / MongoDB).
curl -X PATCH http://localhost:3000/tasks/ \
-H "Content-Type: application/json" \
-d '[
{"id": 1, "title": "a", "version": 5},
{"id": 2, "title": "b", "version": 9},
{"id": 3, "title": "c"}
]'
# → 200 OK { "matchedCount": 2, "modifiedCount": 2 }
# Item 2 was silently skipped (its stored version was not 9).Per-item conflict status is deferred
v1 returns aggregate { matchedCount, modifiedCount } only. Per-item 207 Multi-Status responses — which would tell the client exactly which items conflicted — are not yet implemented. Clients that need to know which items lost the race must re-read affected rows after a partial result.
Non-versioned tables — no change
Tables without @db.column.version are unaffected. /meta omits the versionColumn field; the auto-lift short-circuits when versionColumn is undefined; PATCH / PUT behave exactly as before.
A version field on a non-versioned table's payload is treated like any other field — passed through to the validator, which accepts it if the field exists on the type and rejects it as unknown otherwise. The OCC path is never engaged.
See the Versioning reference for the SDK-level API, error codes, and the withOptimisticRetry helper.
Write-Only Fields (@db.writeOnly)
A field annotated @db.writeOnly is settable through insert/update/replace payloads but never appears in read responses — the classic case is a sealed secret (pair it with @db.encrypted):
@db.writeOnly
@db.encrypted
credentials?: stringThe controller enforces the seal, not just hides the value:
- Every read projection force-excludes the field (
$selecting it explicitly is silently stripped — even when it's the only selected field). Selecting a parent object ($select=secretoversecret: { @db.writeOnly hash, salt }) returns the parent's other leaves only (since 0.1.148). - Filtering, sorting, and aggregate
$groupBy/$selecton it are rejected — an equality probe or sort order would leak the sealed value. /metastill serves the field's type, flaggedwriteOnly: true, so generated forms render a set-only input and client preflight validation accepts the field in write payloads.- Server-side code reading through
AtscriptDbTablestill sees the value — the seal is an HTTP-layer contract.
The seal reaches joined rows too (since 0.1.143): every $with entry, at any depth, has its target table's @db.writeOnly fields stripped from its $select (the default projection included), and a $with sub-filter or sub-sort on one — $with=owner(password='x'), $with=owner($sort=password) — is rejected with the same 400 as the top-level form, naming the full path (owner.password). This applies to every controller, not only permission-aware ones; before 0.1.143 joined rows carried their write-only values. Permission overlays (e.g. @aooth/arbac-moost) can additionally stamp writeOnly per-caller for fields the caller may write but not read.
Deleting Records
DELETE /:id
Removes a single record by primary key (or unique key — resolved primary key first). Returns 404 if the record is not found. The id passes through resolveRowIds first (onRemove and guardRemove receive the resolved id). Since 0.1.143, when the controller overrides transformOne / transformFilter, that overlay is the delete's row scope: DELETE /:id and DELETE /?… only ever delete a row inside it, and an out-of-scope row answers 404 exactly like a missing one.
curl -X DELETE http://localhost:3000/todos/42Response:
{ "deletedCount": 1 }Composite keys — use query parameters:
curl -X DELETE "http://localhost:3000/task-tags/?taskId=1&tagId=2"Response:
{ "deletedCount": 1 }Error Handling
The controller automatically transforms errors into appropriate HTTP responses:
| Error | HTTP Status | Response Body |
|---|---|---|
| Body not an object / array of objects | 400 | { message: "Expected an object", statusCode, errors: [{ path: "" | "[i]", message: "Expected an object" }] } (since 0.1.128) |
ValidatorError | 400 | { message, statusCode, errors: [{ path, message }] } |
DbError (CONFLICT) | 409 | { message, statusCode, errors } |
DbError (TX_WAIT_TIMEOUT — SQLite gate) | 503 | { message, statusCode, errors } — see SQLite concurrency |
DbError (DEADLOCK / LOCK_TIMEOUT / SERIALIZATION_FAILURE) | 503 | { message, statusCode, errors } — transient; retrying the request may succeed (since 0.1.153, see Transactions) |
DbError (BUCKET_TZ_UNAVAILABLE) | 501 | { message, statusCode, errors: [{ path: "$select", message }] } — the database cannot convert to the bucket's time zone; see Calendar Buckets — Errors (since 0.1.132) |
DbError (LOCK_NOT_SUPPORTED / ISOLATION_NOT_SUPPORTED) | 501 | { message, statusCode, errors } — the adapter cannot lock rows on read or open the requested isolation level; see Transactions (since 0.1.162) |
DbError (CAS_MISMATCH — touchMany) | 409 | { message, statusCode, errors: [{ path: "$cas", message }] } — see Versioning — Batch touch |
DbError (other, incl. BUCKET_NOT_SUPPORTED, AGG_FN_NOT_SUPPORTED, AGG_EXPR_NOT_SUPPORTED) | 400 | { message, statusCode, errors } |
| Version mismatch (OCC) | 409 | { statusCode, error, message: "version_mismatch", code: "VERSION_MISMATCH", kind, currentVersion } — see OCC over HTTP |
| A request the gates refuse (unknown field, field not permitted, malformed query, …) | 400 / 403 | { message, statusCode, code, params?, errors: [{ path, message, code, params? }] } — every one coded since 0.1.175, see Request error codes |
version + differing $cas | 400 | { message, statusCode, errors: [{ path: "$cas", message }] } |
| Not found — a missing row, a row outside the caller's scope, an unknown form or action | 404 | code: "NOT_FOUND", the same body everywhere — see Not found |
Not found
Every 404 moost-db answers — /one/:id and /one?…, DELETE, a PATCH / PUT with a version on a missing row, a 'row' action, value-help /one, an unknown or refused /meta/form/:name, an unknown delegated action — has one body (since 0.1.173):
{ "message": "Not found", "statusCode": 404, "code": "NOT_FOUND", "error": "Not Found" }A missing row and a row outside the caller's scope answer the same bytes, so the answer never tells whether a row exists. NOT_FOUND is in moost-db's message catalog (MOOST_DB_MESSAGES, registered with moost), so a translator renders it per request. A permission layer that refuses a row itself should throw notFoundError() from @atscript/moost-db — the same body.
Built-in write failures are thrown as HttpError (since 0.1.128) rather than returned — the wire response is identical, and a throw also rolls back any transaction a subclass wrapped around super.update(). See Customization — thrown errors.
Validation error example:
{
"message": "Validation failed",
"statusCode": 400,
"errors": [
{ "path": "title", "message": "Required field" },
{ "path": "project.title", "message": "Expected string, got number" },
{ "path": "tasks.0.status", "message": "Required field" }
]
}Validation runs automatically on POST, PUT, and PATCH using the constraints defined in your .as schema.
Constraint error example. A DbError envelope carries the error's code and, on constraint violations, a code and params per entry (since 0.1.165) — one entry per field of the violated key; see Constraint violations. The engine's own text never reaches the client.
{
"message": "The combination of \"householdId\", \"name\" must be unique",
"statusCode": 409,
"code": "CONFLICT",
"errors": [
{
"path": "householdId",
"message": "The combination of \"householdId\", \"name\" must be unique",
"code": "UNIQUE",
"params": { "index": "household_name", "fields": ["householdId", "name"] }
},
{
"path": "name",
"message": "The combination of \"householdId\", \"name\" must be unique",
"code": "UNIQUE",
"params": { "index": "household_name", "fields": ["householdId", "name"] }
}
]
}Query Validation
Invalid query parameters return 400 errors with descriptive messages:
| Invalid query | Error reason |
|---|---|
$with=nonexistent | Navigation property does not exist |
$with=projectId | FK field, not a navigation property |
$with=tasks($with=nonexistent) | Nested relation does not exist |
$select=fakefield | Field does not exist on the type |
$sort=nonexistent | Cannot sort by unknown field |
GET /one/1?status=todo | Filters not allowed on getOne endpoint |
These validations apply to all endpoints that accept query controls — /query, /pages, and /one/:id.
Next Steps
- URL Query Syntax — Full filter, sort, and pagination syntax
- Relations & Search in URLs —
$with,$search,$vector,$groupByin query strings - Customization — Override hooks for access control and data transformation
- CRUD Operations — Programmatic
AtscriptDbTableAPI (non-HTTP) - HTTP Client — TypeScript client for consuming these endpoints