Skip to content

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 shapeBehaviour
absent / undefinedfull projection — preferred-id fields already present
string[] inclusiondedupe + 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
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.

bash
curl "http://localhost:3000/todos/query?completed=false&\$sort=-createdAt&\$limit=10"

Query parameters:

ParameterTypeDefaultDescription
$sortstringSort expression (e.g., -createdAt for descending)
$limitnumber1000Maximum records to return
$skipnumber0Number of records to skip
$selectstringComma-separated field names for projection
$countbooleanReturn count instead of records
$searchstringFulltext search term
$indexstringNamed search index
$vectorstringVector field name for similarity search
$thresholdstringSimilarity threshold for vector search
$withstringLoad relations (e.g., $with=author,comments)
$groupBystringGroup by fields for aggregation
(other)anyFilter fields (e.g., status=active)

Response (array):

json
[
  { "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:

bash
curl "http://localhost:3000/todos/query?completed=true&\$count"
json
5

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.

bash
curl "http://localhost:3000/todos/pages?\$page=2&\$size=10&status=active"

Additional parameters:

ParameterTypeDefaultDescription
$pagenumber1Page number (1-based)
$sizenumber10Items per page

All other query parameters from GET /query (filters, $sort, $select, $search, $with) are also supported, except $skip, $limit, and $count.

Response:

json
{
  "data": [{ "id": 11, "title": "Task 11", "status": "active" }],
  "page": 2,
  "itemsPerPage": 10,
  "pages": 5,
  "count": 47
}
FieldDescription
dataArray of records for the current page
pageCurrent page number
itemsPerPagePage size
pagesTotal number of pages
countTotal 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.

bash
curl http://localhost:3000/todos/one/42
curl http://localhost:3000/users/one/admin   # by username unique index

Response:

json
{ "id": 42, "title": "Buy milk", "completed": false, "priority": "high" }

Supports $select and $with in the query string:

bash
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:

bash
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 key

The 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.

bash
curl http://localhost:3000/todos/meta

Response (abbreviated):

json
{
  "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": []
  }
}
FieldDescription
searchableWhether the table has fulltext search indexes
vectorSearchableWhether the table has vector search indexes
searchIndexesArray of available search index definitions
primaryKeysPrimary key field names (logical, not column names)
preferredIdLogical field names of the preferred identifier (PK or @db.table.preferredId.uniqueIndex group). See Preferred row identifier
relationsAvailable navigation properties
fieldsPer-field capability flags (sortable, filterable)
typeFull 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)
actionsDeclared domain actions — see Actions
crudBuilt-in CRUD permissions / control whitelists — see Permissions

For the full payload shape including actions[] entries and complete crud whitelists, see HTTP Client — Metadata.

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).

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 /meta on 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:

bash
curl -X POST http://localhost:3000/todos/ \
  -H "Content-Type: application/json" \
  -d '{"title": "Buy milk", "priority": "high"}'

Response:

json
{ "insertedId": 1 }

Batch insert:

bash
curl -X POST http://localhost:3000/todos/ \
  -H "Content-Type: application/json" \
  -d '[{"title": "Buy milk"}, {"title": "Write docs"}]'

Response:

json
{ "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.

Batch edge cases
  • Empty array [] — behavior is adapter-dependent (may return 200, 201, 400, or 500)
  • Single-item array [{...}] — treated as a batch insert, returns insertedCount / 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

Updating Records

PUT /

Full replace by primary key. The body must include all required fields and the primary key field(s).

Single replace:

bash
curl -X PUT http://localhost:3000/todos/ \
  -H "Content-Type: application/json" \
  -d '{"id": 1, "title": "Buy oat milk", "completed": true, "priority": "high"}'

Response:

json
{ "matchedCount": 1, "modifiedCount": 1 }

Bulk replace:

bash
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:

json
{ "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.

Single update:

bash
curl -X PATCH http://localhost:3000/todos/ \
  -H "Content-Type: application/json" \
  -d '{"id": 1, "completed": true}'

Response:

json
{ "matchedCount": 1, "modifiedCount": 1 }

Bulk update:

bash
curl -X PATCH http://localhost:3000/todos/ \
  -H "Content-Type: application/json" \
  -d '[{"id": 1, "completed": true}, {"id": 2, "priority": "high"}]'

Response:

json
{ "matchedCount": 2, "modifiedCount": 2 }

Field operations ($inc, $dec, $mul) work as plain JSON objects in the body:

bash
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.

Deleting Records

DELETE /:id

Removes a single record by primary key. Returns 404 if the record is not found.

bash
curl -X DELETE http://localhost:3000/todos/42

Response:

json
{ "deletedCount": 1 }

Composite keys — use query parameters:

bash
curl -X DELETE "http://localhost:3000/task-tags/?taskId=1&tagId=2"

Response:

json
{ "deletedCount": 1 }

Error Handling

The controller automatically transforms errors into appropriate HTTP responses:

ErrorHTTP StatusResponse Body
ValidatorError400{ message, statusCode, errors: [{ path, message }] }
DbError (CONFLICT)409{ message, statusCode, errors }
DbError (other)400{ message, statusCode, errors }
Not found404Standard 404

Validation error example:

json
{
  "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.

Query Validation

Invalid query parameters return 400 errors with descriptive messages:

Invalid queryError reason
$with=nonexistentNavigation property does not exist
$with=projectIdFK field, not a navigation property
$with=tasks($with=nonexistent)Nested relation does not exist
$select=fakefieldField does not exist on the type
$sort=nonexistentCannot sort by unknown field
GET /one/1?status=todoFilters not allowed on getOne endpoint

These validations apply to all endpoints that accept query controls — /query, /pages, and /one/:id.

Next Steps

Released under the MIT License.