Scripted endpoints
A scripted endpoint is a custom HTTP route served by data.svc and implemented by a script instead
of by the entity CRUD compiler. You declare it in the product schema under endpoints:; it is served
at /data/x/<path>.
The script is a function taking one argument. Whatever it returns becomes the response body.
Declaring an endpoint
Section titled “Declaring an endpoint”endpoints: is a schema top-level key, and only the product’s own definitions are read. A tenant
overlay may contain an endpoints: block — it parses, and the merge path drops it. Endpoints are
keyed by path, and across files the last declaration for a path wins.
| Key | Type | Required | Meaning |
|---|---|---|---|
path | string | yes | URL suffix under /data/x. Must start with /. |
kind | query | mutate | yes | Operational semantics. Sets the default verb and gates caching. |
script | mapping | yes | The handler. See the script reference. |
description | string | no | Surfaced in the generated OpenAPI document. |
verb | GET | POST | PUT | PATCH | DELETE | no | Overrides the verb derived from kind. Upper-cased at load. |
auth | mapping | no | Entry gate. Omitted means mode: required. |
auth.mode | required | none | action | no | Default required. |
auth.action | string | with mode: action | Action name. Load fails if mode: action and this is blank. |
tx-mode | "" | explicit | none | no | Parsed and validated; nothing reads it at runtime. |
tx-timeout | duration string | no | Script execution budget. Unset means 30s. |
rate-limit.per | minute | hour | day | "" | no | See rate limits. |
rate-limit.max | int ≥ 0 | no | Requests per window. |
rate-limit.per-tenant | int ≥ 0 | no | Per-tenant ceiling. |
cache.ttl | duration string ≥ 0 | no | Response cache lifetime. 0 disables. |
cache.vary-by | list of string | no | Extra cache-key dimensions. |
enabled | bool | no | Default true. false leaves the endpoint declared but every request returns 404. |
Any other value for kind, auth.mode, tx-mode or rate-limit.per is a load error naming the
offending path. A negative cache.ttl, a negative rate-limit count, or a path that does not start
with / are load errors too.
verb is the exception: it is upper-cased and stored without validation. A verb outside the five
listed above loads cleanly, then matches none of the registered routes, so every request to that
endpoint returns 405.
The script reference
Section titled “The script reference”script: uses the same five keys as every other script slot in the schema — access rules, computed
fields, validations, pointcuts and triggers.
| Key | Meaning |
|---|---|
language | Which runtime evaluates the body. |
expression | An inline expression — one line, no function declarations. |
script | An inline script body — a full program with functions and locals. |
script-file | A path to an external script file. |
function | Entry function to call. Defaults to main. |
language accepts expr (also the empty default), cel, js, javascript, js-v8,
javascript-v8, lua, starlark, go and wasm. Anything else is rejected when the endpoint is
registered at engine build: the endpoint is skipped with a log line while the rest of the surface
boots, and requests to its path get the ordinary 404 endpoint_not_found, not an error naming the
language.
Set the body with either expression: or script:. When both are present expression: wins and
script: is discarded.
There is no file:, func:, entry:, inline:, source: or runtime: key. Those spellings decode
to nothing, which produces an empty script reference and a missing script load error.
The route it produces
Section titled “The route it produces”All five verbs register the same catch-all handler at /data/x/*path. The endpoint’s own declaration
decides which one it answers.
| Declaration | Verb served |
|---|---|
kind: query, no verb | GET |
kind: mutate, no verb | POST |
any kind, verb: PUT | PUT |
A request with a different verb gets 405 with an Allow header naming the single accepted verb and
details.allowed carrying it as a string, not an array.
?datastore=<name> selects the datastore whose engine — and therefore whose endpoint registry — serves
the request. Omitted, it is the schema’s default datastore.
The ctx argument
Section titled “The ctx argument”The script’s single argument always has every key below present, zero-valued when unset. It is safe
to read ctx.user.id or iterate ctx.user.roles without a nil guard.
| Path | Type | Contents |
|---|---|---|
ctx.req.path | object | Path-parameter bindings. Always empty — see the caution above. |
ctx.req.query | object of string | Query-string parameters. |
ctx.req.body | any | The parsed JSON body, or null. |
ctx.req.headers | object of string | An allowlisted subset of request headers. |
ctx.req.method | string | The HTTP verb the route was hit with. |
ctx.user.id | string | The caller’s user id. Empty on an auth: none endpoint. |
ctx.user.tenant | string | The tenant the request is scoped to. Always populated. |
ctx.user.roles | array of string | The caller’s roles. Empty array when none. |
ctx.req.query is single-valued: ?tag=a&tag=b reaches the script as tag = "a". The second value
is discarded.
ctx.req.headers carries exactly five headers, and only when the request sent them:
Accept-Language, Content-Type, Idempotency-Key, X-Request-Id, User-Agent. Authorization,
Cookie and the X-Forwarded-* family are stripped — a script cannot read the caller’s credentials.
ctx.req.body is parsed as JSON before the script runs. A kind: query endpoint hit with GET never
reads a body. Otherwise an empty body yields null, and a body that is unreadable, not JSON, or larger
than 1 MiB returns 400 bad_body without invoking the script.
Reaching data
Section titled “Reaching data”Data access is not on ctx. The script calls the data.* and schema.* namespaces directly. Every
data.* call re-enters the engine, so it runs the full pipeline: validation, stateflow guards,
computed fields, audit columns, history.
| Function | Returns |
|---|---|
data.q(dsl) | array of rows — a pipe query |
data.query(entity, filter?, options?) | array of rows |
data.q(dsl) | array of rows — the pipe syntax as a string |
data.find_one(entity, filter) | one row or null |
data.find_by_id(entity, id) | one row or null |
data.count(entity, filter?) | integer — the number of matching rows |
data.exists(entity, filter) | boolean |
data.aggregate(entity, ops, filter?) | object with one key per entry of ops |
data.create(entity, record) | the created row |
data.create_many(entity, records) | array of created rows |
data.update(entity, id, changes) | the updated row |
data.update_many(entity, filter, changes) | affected-row count |
data.upsert(entity, record, conflict_key?) | the row — inserted, or updated when one already carries the same key |
data.delete(entity, id) | boolean |
data.query’s filter is a map, so it can only express equality. For a range, a negation, a set
membership or an ordering, use data.q — it takes the
pipe syntax as a string and goes through the same
parser, the same engine and the same access rules:
const recent = data.q(`orders | status = "paid", total > 100 | -created_at | 10`);A syntax error is reported as one, before anything reaches the database. Build the string from literals and validated values — not by concatenating request input, which is the injection this form otherwise invites.
data.query’s filter is a map, so it can express equality and nothing else. When you need a
range, a negation, a set membership or an ordering, data.q takes the pipe syntax instead:
const recent = data.q(`orders | status in ("paid","shipped"), total > 100 | -created_at | 20`);It is the same parser and the same engine path as every other read, so access rules and row-level security apply unchanged — this is a more expressive way to ask, not a way around the rules. A syntax error is reported as a syntax error before anything reaches the database, and input the parser cannot fully consume is refused rather than being run as a smaller query.
| Function | Returns |
|---|---|
schema.entity(name) | entity descriptor |
schema.fields(entity) | array of field descriptors |
schema.field(entity, name) | field descriptor or null |
schema.relations(entity) | array of relation descriptors |
schema.stateflow(entity) | stateflow descriptor or null |
schema.field_group(entity, group) | array of field descriptors |
The schema.* functions read the parsed schema and never touch the database.
filter is equality only. Every key becomes an = predicate joined with AND; there is no way to
express a range, a set or a negation through this namespace. The full operator vocabulary is available
over HTTP — see the Query DSL.
options reads five keys: limit (int), offset (int), order (a single string), fields (array of
string), and lock — a row lock as "update", "share", or {mode, wait} with wait one of
wait, nowait, skip_locked — which the engine honours only inside a transaction (below) and
refuses everywhere else. Anything else is ignored.
data.count runs COUNT(*) under the same equality filter as data.query and returns the
counted number. data.aggregate takes the aggregates to compute, keyed by the name each result
carries, and an optional filter:
// the function as the name; count takes true, "*" or a fielddata.aggregate("orders", { count: true, sum: "total", max: "total" }, { status: "paid" });// → { count: 12, sum: 1834.5, max: 402 }
// free names for "func(field)", including count(distinct field)data.aggregate("orders", { orders: "count(*)", revenue: "sum(total)", buyers: "count(distinct customer_id)" });// → { orders: 41, revenue: 6120.25, buyers: 17 }count, sum, avg, min and max are the functions; anything else, or a function without a
field, is refused before the query runs. An empty ops is {count: true}.
data.upsert inserts the record, or updates the existing row that carries the same
conflict_key — the entity’s primary key when omitted, a unique field, or a comma-separated list
for a compound key. On conflict every column in the record except the key is written. It runs as
a create through the full pipeline, with the ON CONFLICT clause compiled by the backend’s
dialect.
Bare-name functions are registered on the same runtime for older scripts: query, queryOne,
queryById, count, exists, aggregate, insert, insertMany, update, updateMany,
deleteRecord, upsert, the entity getters, and hash, hmac, randomUUID, randomBytes. Prefer
the namespaced form — a locally defined query helper shadows the bare name, and data.query cannot
be shadowed.
Transactions and locks
Section titled “Transactions and locks”Every data.* call above is its own transaction. To make several writes one unit, or to hold a
lock across them, open a transaction and use the object it returns — every data.* function is on
it, bound to the transaction, plus commit, rollback, lock, try_lock and active:
function main(ctx) { const tx = data.begin({ immediate: true, lock_timeout: "2s" }); try { tx.lock("stock:" + ctx.req.body.item); // named lock, held to the end const [row] = tx.query("stock", { item_id: ctx.req.body.item }, { lock: { mode: "update", wait: "nowait" } }); tx.update("stock", row.id, { qty: row.qty - ctx.req.body.qty }); tx.commit(); } catch (e) { tx.rollback(); throw e; }}data.transaction(fn, options) is the same with the bookkeeping done for you: fn(tx) runs inside
one transaction, a return commits and its value is returned, a throw rolls back and the error
propagates.
const n = data.transaction(tx => { tx.create("order_line", line); return tx.query("order_line", { order_id: line.order_id }).length;}, { immediate: true });| Option | Meaning |
|---|---|
isolation | read_uncommitted, read_committed, repeatable_read or serializable. |
read_only | A read-only transaction. |
lock_timeout | How long a row or named lock waits — a duration string ("2s") or milliseconds. A wait that runs out is the engine’s lock_timeout error. |
immediate | Open as a writer. Required for any lock on SQLite, where the whole database is the lock; ignored by PostgreSQL. |
Locks are the engine’s: tx.lock(name) blocks (subject to lock_timeout), tx.try_lock(name)
returns false when the name is held elsewhere, both scoped to the tenant and released at commit
or rollback; a row lock on a query takes FOR UPDATE / FOR SHARE. Neither works outside a
transaction, and neither exists on DuckDB or ClickHouse. Transactions do not nest — a tx object
has no begin — and one that is neither committed nor rolled back is rolled back when the request
ends. See Concurrency and locking for the model behind this.
A script runs as the caller
Section titled “A script runs as the caller”The data.* and schema.* namespaces are bound per request, carrying the calling principal. A
query issued from a scripted endpoint sees exactly the rows that caller may see:
- Access rules evaluate against the request’s
identity, so
user.idanduser.rolesare the person who made the call. - Row-level security compiles the same
WHEREpredicate it would for a direct/data/restread. - Deny-by-default applies. An operation with no rule declared for it is refused here exactly as it is over REST.
The practical consequence is the one to design around: a scripted endpoint cannot see more than its caller. Where an endpoint legitimately needs a wider view — a report aggregating across owners, say — that is a modelling decision to make explicitly in the access rules, not something the endpoint acquires by virtue of being a script.
ctx.user remains the place to read the caller’s identity when you want to branch on it —
refusing early, choosing a code path, writing an actor into an audit field — rather than to
re-implement filtering the engine has already applied.
The response
Section titled “The response”A script that returns normally produces 200 with Content-Type: application/json; charset=utf-8 and
the return value serialised as the entire body. There is no data/count envelope, and the script
cannot set the status code, add a header, or stream.
A script that throws produces 500 script_error. To signal a business failure, return a value your
client can read — the status will still be 200.
| Code | HTTP | Cause |
|---|---|---|
endpoint_path_missing | 404 | The catch-all matched with an empty path. |
endpoint_not_found | 404 | No endpoint at that path, the tenant’s schema declares none, or enabled: false. |
method_not_allowed | 405 | Verb does not match. Allow header set. |
unauthenticated | 401 | mode: required or mode: action and no resolved principal. |
forbidden | 403 | The auth gate rejected the caller, including an unrecognised auth.mode. |
no_tenant | 403 | No tenant key on the request context. |
bad_body | 400 | Body unreadable, not JSON, or over 1 MiB. |
rate_limited | 429 | See rate limits. |
script_engine_missing | 500 | The tenant’s engine bundle carries no script runtime. |
script_error | 500 | The script threw, or its body failed to compile. |
engine_unavailable | 500 | The tenant’s engine bundle could not be resolved. |
Every error body is {"errors": [{"code": "<code>", "message": "<message>", "details": {…}}]}, with
details present only where the table above says so. A script’s own return value is passed through
verbatim and is not wrapped in the envelope.
Compilation is lazy. The script body is compiled on the first request to that path, not at schema load or at boot, and the compile error is cached stickily. A syntax error therefore passes every load check and surfaces as a 500 to the first user who hits the route.
Auth modes
Section titled “Auth modes”| Mode | Behaviour |
|---|---|
required | A resolved user id must be present, else 401. The default. |
none | Skips the identity check. |
action | Identical to required today. |
Rate limits
Section titled “Rate limits”Tokens are consumed against a named limiter profile, keyed by tenant, user and profile. kind alone
picks the endpoint profile:
kind | Profile used |
|---|---|
query | endpoint_query_default |
mutate | endpoint_mutate_default |
A scripted-endpoint request is metered once, against the endpoint profile only. The generic gate
that fronts the entity routes is not applied to /x/* — the dispatcher meters itself after it has
resolved the endpoint — so a 429 here always names the endpoint profile in details.profile.
An allowed request carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (unix
seconds). A denied one is 429 rate_limited with those headers, a Retry-After in whole seconds
(minimum 1), and details of {profile, limit, remaining, retry_after}. When no limiter is installed
the request passes.
Response cache
Section titled “Response cache”A response is cached only when cache.ttl is greater than zero and kind is query. Mutate
endpoints never cache.
The lookup happens after the auth and rate-limit gates, so a cached body is only ever served to a request already proven entitled to it. The key is a hash of tenant, user id, endpoint path and the sorted query string.
Each cache.vary-by entry names a request header whose value is folded into the key. A header:
prefix is stripped when present but is not required — header:Accept-Language and Accept-Language
key identically, and any other non-blank string is also read as a header name. Blank entries are
skipped.
A hit sets X-Cache: HIT. Any other response from a cacheable endpoint sets X-Cache: MISS, whether
or not the store accepted the write — a write failure is logged and the response served anyway. A
non-cacheable endpoint sends no X-Cache header at all.
Execution budget
Section titled “Execution budget”tx-timeout is passed to the script runtime as the invocation’s timeout, defaulting to 30s.
A complete endpoint
Section titled “A complete endpoint”Cancel an order, guarded on the caller’s tenant and the order’s current state.
endpoints: - path: /orders/cancel kind: mutate description: Cancel an open order and record who cancelled it. verb: POST tx-timeout: 10s auth: mode: required enabled: true script: language: javascript function: main script: | function main(ctx) { var body = ctx.req.body || {}; if (!body.order_id) { return { ok: false, reason: "order_id is required" }; }
var order = data.find_by_id("orders", body.order_id); if (!order || order.tenant_id !== ctx.user.tenant) { return { ok: false, reason: "no such order" }; } if (order.status !== "open") { return { ok: false, reason: "order is " + order.status }; }
data.update("orders", order.id, { status: "cancelled", cancel_reason: body.reason || "", cancelled_by: ctx.user.id });
return { ok: true, order_id: order.id, status: "cancelled", cancelled_by: ctx.user.id }; }The explicit check earns its place even though data.find_by_id is already filtered by the
caller’s row-level security: it turns “no row came back” into a specific, readable refusal instead
of a generic not-found, and it states the endpoint’s intent where the next reader will see it.
Call it:
curl -X POST https://api.example.com/data/x/orders/cancel -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"order_id":"9f1c...","reason":"customer request"}'{ "ok": true, "order_id": "9f1c...", "status": "cancelled", "cancelled_by": "u_4471"}A rejected cancellation is still a 200:
{ "ok": false, "reason": "order is shipped"}Endpoint or hook?
Section titled “Endpoint or hook?”Both run a script; they differ in what triggers them.
| Use | When |
|---|---|
| A scripted endpoint | A caller needs an operation the generated REST and GraphQL surface does not express |
| A pointcut or hook | Behaviour must attach to an entity’s lifecycle no matter who triggers the change |
The test is who has to remember. An endpoint runs only when someone calls it, so logic that must apply to every write — including writes through the generated API — belongs in a hook. Put it in an endpoint and the generated surface bypasses it.
Discovery
Section titled “Discovery”Declared endpoints appear in the generated OpenAPI document at /x<path>, tagged scripted, with
description rendered as the operation summary. Two things the document gets wrong: enabled: false
endpoints are emitted alongside the live ones, so the document lists routes that answer 404; and on
POST, PUT and PATCH operations the request body carries a placeholder schema reference that does
not describe the script’s input. Supply the body shape to generated clients by hand. See
Schema export and discovery for what the document does
and does not describe. The endpoint-level HTTP reference for the rest of the surface is at
Data API reference.