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 v2.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 v2.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.query(entity, filter?, options?) | array of rows |
data.find_one(entity, filter) | one row or null |
data.find_by_id(entity, id) | one row or null |
data.count(entity, filter?) | integer — but see the caution below |
data.exists(entity, filter) | boolean |
data.aggregate(entity, ops) | object |
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 |
data.delete(entity, id) | boolean |
| 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 four keys: limit (int), offset (int), order (a single string), fields (array of
string). Anything else is ignored.
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.
The caller’s identity does not reach data.*
Section titled “The caller’s identity does not reach data.*”The dispatcher does not propagate the request context into the engine calls a script makes. Those calls run without a principal and without the auth-level marker the HTTP layer stamps, which has two consequences:
- Deny-by-default does not apply. The absence of an auth level is the engine’s “trusted internal
caller” signal, so an entity with no access rule for
an operation is not refused inside a scripted endpoint, the way the same operation would be over
/data/rest. - Rules that are declared still evaluate, but against the engine’s configured identity rather than
the caller’s. A rule reading
user.idoruser.rolesdoes not see the person who made the request, and the same is true of the bindings row-level security compiles intoWHERE.
ctx.user is the only place the caller’s identity exists inside a scripted endpoint. Check it in the
script, and scope every query you issue by ctx.user.tenant yourself.
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 v2.script_error. To signal a business failure, return a value your
client can read — the status will still be 200.
| Code | HTTP | Cause |
|---|---|---|
v2.endpoint_path_missing | 404 | The catch-all matched with an empty path. |
v2.endpoint_not_found | 404 | No endpoint at that path, the tenant’s schema declares none, or enabled: false. |
v2.method_not_allowed | 405 | Verb does not match. Allow header set. |
v2.unauthenticated | 401 | mode: required or mode: action and no resolved principal. |
v2.forbidden | 403 | The auth gate rejected the caller, including an unrecognised auth.mode. |
v2.no_tenant | 403 | No tenant key on the request context. |
v2.bad_body | 400 | Body unreadable, not JSON, or over 1 MiB. |
v2.rate_limited | 429 | See rate limits. |
v2.script_engine_missing | 500 | The tenant’s engine bundle carries no script runtime. |
v2.script_error | 500 | The script threw, or its body failed to compile. |
v2.engine_unavailable | 500 | The tenant’s engine bundle could not be resolved. |
Every error body is {"error": "<message>", "code": "<code>", "details": {…}}, with details present
only where the table above says so.
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 twice. The generic gate in front of every Data API route
consumes a token first — v2_query_default for a GET, v2_mutate_default for any other verb —
and the dispatcher then consumes a second token against the endpoint profile. A 429 from the first
gate names the generic 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 v2.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 gate fails open.
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 tenant check is not redundant. As described above,
data.find_by_id does not carry the caller’s identity, so the row it returns has not been filtered by
row-level security.
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"}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.