REST data API — CRUD over every object
REST endpoints for CRUD, batch operations, record cloning, and analytics queries.
Record CRUD, batch operations, and analytics queries over REST. All paths are relative to the base URL (defaults to /api/v1) — see the API Overview for discovery and service availability.
Data Operations
CRUD operations on any object. Always available — provided by the kernel.
GET /data/:object
Query records with filtering, sorting, selection, and pagination.
| Parameter | Location | Description |
|---|---|---|
object | path | Object name |
select | query | Comma-separated field names. Every name must exist — an unknown one is 400 INVALID_FIELD, never dropped. |
filter | query | Filter expression (JSON). filters also accepted for backward compatibility. Malformed JSON is rejected with 400 INVALID_FILTER — never ignored; so is sending the parameter more than once (?filter=…&filter=…), which is refused as a repetition rather than diagnosed as malformed. |
sort | query | Sort expression (e.g. name asc or -created_at). Must name a real field on the object itself — an unknown name or a dotted path (account.company_name) is 400 INVALID_SORT. |
top | query | Max records to return. No default — omitting it returns all matching records. |
skip | query | Offset |
expand | query | Comma-separated list of relations to eager-load. Must name a reference field (lookup / master_detail / user / tree) — otherwise 400 INVALID_FIELD. |
search | query | Full-text search term, scanned case-insensitively across the object's searchable fields (its declared searchableFields, or, when none are declared, a text-like auto-default plus every field marked searchable: true except secret / password / vector). When that set is empty — no searchableFields entry names a stored field, and the auto-default finds none — a non-blank term is 400 INVALID_FIELD, never every row. |
searchFields | query | Comma-separated subset of the searchable fields for search to scan — narrows the scan, never widens it. A name outside the searchable set is 400 INVALID_FIELD. |
Note: OData-style
$-prefixed parameters ($filter,$select,$orderby,$top,$skip,$expand,$count,$search) are also accepted directly on this same endpoint as aliases — they're normalized internally to the parameter names above. There is no separate standalone OData endpoint.
Any other parameter is a field filter — and must name a real field
A query parameter the endpoint does not reserve is read as a field-level
equality filter, so ?status=done is shorthand for
?filter={"status":"done"}. When an explicit filter is also present, the
two compose by AND — ?filter={"amount":{"$gte":100}}&status=done applies
both predicates, the same way the search parameter composes with filter.
Because such a parameter is a predicate, one naming a field the object does
not have could only ever match zero records — so the endpoint rejects it
instead of returning an empty page:
GET /api/v1/data/showcase_task?pageSize=5{
"error": "Unknown field 'pageSize' on object 'showcase_task'. Query parameters that are not reserved are read as field filters, so an unknown name can only match zero records. Did you mean the 'top' query parameter (OData spelling '$top')?",
"code": "INVALID_FIELD",
"field": "pageSize",
"object": "showcase_task"
}This is the same 400 INVALID_FIELD the write path returns for an unknown
field name, and it applies whether or not an explicit filter rode along.
Page size is top / $top / limit — pageSize, page_size and perPage
are not accepted spellings.
Reserved names cannot double as implicit filters. An object with a field
literally called count, cursor, distinct, object, search or top
filters it through the explicit form (?filter={"count":3}).
A filter either applies or fails — it is never ignored
filter, filters, $filter and where are four spellings of one slot. A
value the server cannot turn into a filter is rejected with
400 INVALID_FILTER rather than dropped, because a dropped filter would
return the unfiltered result set — a response indistinguishable from a
successful query:
| Request | Result |
|---|---|
?filter={"status":"done"} | filter applies |
?filter={status:done (invalid JSON) | 400 — filter must be valid JSON |
?filter=5, ?filter="done", ?filter=null | 400 — parses, but is not a filter |
?filter= (blank) | treated as absent — no filter, no error |
where and filter sent with different values | 400 — aliases for one slot; send exactly one |
?filter={"a":1}&filter={"b":2} — one spelling sent twice | 400 — Repeated "filter" query parameter — send exactly one |
The same rule applies to orderby on GET /data/:object/export.
Sending one spelling twice — on GET /data/:object, in any of the four
spellings — is refused as its own named cause rather than reported as a
malformed filter: a repeat is neither merged nor resolved by precedence,
because either would apply a filter you did not express. Repetition is counted,
not compared, so two identical occurrences are still two occurrences, while a
single occurrence that a server adapter delivers as a one-element array is
still one.
Nor is a sort, a projection, or an expansion
filter is not the only parameter that names a field. sort, select and
expand do too, and each one used to be dropped in silence when the name was
wrong — three more responses that looked exactly like successful ones:
| Request | Result |
|---|---|
?sort=-created_at | sorts |
?sort=no_such_field | 400 INVALID_SORT |
?sort=account.company_name | 400 INVALID_SORT — sort reaches only the object's own columns; denormalise the related value (formula/rollup field) to sort by it |
?sort={oops / ?sort=title:desc | 400 INVALID_SORT — the list route spells a direction with a space (title desc) or a leading - |
?select=id,title | projects those two columns |
?select=no_such_field, ?select=title,no_such_field | 400 INVALID_FIELD |
?expand=owner_id | expands the reference |
?expand=no_such_rel | 400 INVALID_FIELD — no such field |
?expand=title | 400 INVALID_FIELD — real field, but it holds no reference |
Why each one matters, since none of them changes which rows match:
sort—sort+topis how you ask for "the latest N". A sort that is dropped turns that into an arbitrary N, and nothing in the response says so.select— an unknown column used to be dropped, and a projection left with no known column fell back to every column: a parameter that exists to return less failed by returning more.expand— an unexpanded relation is indistinguishable from one whose foreign keys are all null, so clients render raw ids where names belong.
Sort spellings are slot-specific, and the two routes take different sets — a
slot that does not take a shape answers 400 instead of sorting. Measured on
both routes:
| Sort value | On the querystring — GET /data/:object | In the body of POST /data/:object/query |
|---|---|---|
-created_at, or created_at desc — the shorthand string | ?sort=-created_at, ?$orderby=-created_at and ?orderBy=-created_at — each of them sorts; they normalize to one parameter | 400 VALIDATION_FAILED on every slot — orderBy, $orderby and sort alike |
["-created_at"] — an array of those shorthand strings | 400 INVALID_SORT — this route parses no JSON here, so the whole value is read as one field name | 400 VALIDATION_FAILED on every slot |
{"created_at": "desc"} — a field-to-direction map (1 / -1 are read as asc / desc) | 400 INVALID_SORT — same reason | sorts on $orderby and on sort; on orderBy it is 400 VALIDATION_FAILED |
[{"field": "created_at", "order": "desc"}] — SortNode[], the canonical shape | 400 INVALID_SORT — same reason | {"orderBy": …} sorts, and so do $orderby and sort |
So read the last column before copying a sort into a request body. The
shorthand string is a querystring spelling and reaches no body slot at all; the
map reaches $orderby and sort but never orderBy; SortNode[] is the one
shape the canonical slot and its two transport aliases all accept.
Naming something that is not a field on the object is 400 INVALID_SORT on
either route — ?sort=no_such_field and
{"orderBy": [{"field": "no_such_field", "order": "asc"}]} alike. A body whose
sort has the wrong shape never reaches that check: it is
400 VALIDATION_FAILED, and the offending path is named in fields
(query.orderBy.0 for an entry that names no field, query.orderBy.0.order
for a direction that is neither asc nor desc). The querystring has no body
to validate first, so it reports that direction as 400 INVALID_SORT —
?sort=name sideways.
GET /data/:object/:id applies the same select and expand rules, so the
list and single-record routes cannot disagree about one field map.
Neither is a search narrowing, a grouping, or an aggregation
The last three field-naming axes follow the same rule. Each of these used to
answer 200 with something that looked exactly like a served query — and each
corrupts something the earlier axes do not:
| Request | Result |
|---|---|
?search=alpha&searchFields=title | scans only title |
?search=alpha&searchFields=no_such_field | 400 INVALID_FIELD |
?search=alpha&searchFields= (or =,, or [] in a body) | 400 VALIDATION_ERROR — names no field, the answer $select= gets; leave the parameter out to scan the default set |
?search=alpha&searchFields=amount | 400 INVALID_FIELD — real field, but not searchable |
?search=alpha&searchFields=api_key (a secret or password field, even one searchableFields declares) | 400 INVALID_FIELD — a credential is never searched; remove it from searchableFields too |
?search=alpha&searchFields=project_id.name | 400 INVALID_FIELD — search scans this object's own columns; mirror the related title instead (see below) |
?search=alpha on an object with no searchable field (two lookups and a formula title, say) | 400 INVALID_FIELD — nothing to scan; declare a stored field searchable or mirror the title (see below) |
groupBy: ["status"] | one bucket per status value |
groupBy: ["no_such_field"] | 400 INVALID_FIELD |
aggregations: [{function:"sum", field:"amount", alias:"total"}] | the real total |
aggregations: [{function:"sum", field:"no_such_field", alias:"total"}] | 400 INVALID_FIELD |
aggregations: [{function:"count", alias:"n"}] | count(*) — the one legitimate field-less form |
-
searchandsearchFields— the only parameters whose failure changed which rows came back. Asearchover an object with nothing to scan added no predicate, so every row came back as if every row matched. An unknownsearchFieldsname used to be dropped, and an override left empty fell back to scanning every searchable column: a parameter that exists only to narrow a search failed by widening it. Three causes get three messages, because the fixes differ: a name that is no field (a typo in the request), a real field outside the searchable set (declare it insearchableFields), and asearchableFieldsentry that names no field (a stale declaration — the bug is on the object, and clients that echo the declaration verbatim are told so).A dotted path (
project_id.name) is the typo case with its own hint:searchscans this object's own columns, so a related record's column can never be a search target, and the search axis does not resolve traversal the way$select/$orderby/$filterdo. To search by a related record's title, mirror that title into a stored field on this object and declare that field searchable — a task list searched by project name carries aproject_nametext column ontask, maintained on write and listed intask.searchableFields. It must be a stored field: aformulafield is virtual, so no driver has a column for$containsto scan. Cross-object search paths are rejected by design, not pending — see Schema Design → Searching by a related record's title. -
groupBy— an unknown column projectednullfor every row, so all rows fell into one bucket whose count is the true row count: structurally perfect, indistinguishable from a column that really holds a single value. A chart draws one bar and nothing says the grouping never ran. -
aggregations—sumover an unknown column folded blanks to0, the exact number a genuinely empty quarter produces (avg/min/maxanswerednullthe same way), in reports whose whole job is to be believed.
A groupBy / aggregations value the spec cannot read at all — a bare string
instead of an array, an entry that names no field, a function or date
granularity outside the spec's enums, a missing alias — is
400 INVALID_QUERY: those shapes were silently ignored, returning ungrouped
raw rows with nothing to say the aggregation never happened.
Response:
{
"object": "account",
"records": [{ "id": "1", "name": "Acme Corp", ... }],
"total": 42,
"hasMore": true
}Read limits a deployment may set
A deployment can bound how many rows one read returns and walks. Three host environment variables, each a positive integer; with none of them set, no read is limited:
| Variable | Meaning |
|---|---|
OS_DATA_DEFAULT_PAGE_SIZE | the page a read gets when it names no top / limit (unset with a maximum set: the maximum) |
OS_DATA_MAX_PAGE_SIZE | the largest top / limit a read may name |
OS_DATA_QUERY_ROW_BUDGET | the most rows one query may walk: its skip / offset plus the rows it returns |
Over a limit is refused, never clamped, so a client paging by its own page
size never skips rows. A page size over the maximum answers
400 MAX_RECORDS_EXCEEDED; offset plus page size over the budget answers
400 QUERY_ROW_BUDGET_EXCEEDED. Both messages name the configured number, and
both are refused before the read runs. A list read that names no page size gets
the default page, and hasMore / total say whether more rows exist. With a
budget and no page size, a read that names no page size runs one row past what
the budget leaves and is refused with QUERY_ROW_BUDGET_EXCEEDED if its answer
holds more; that read does run first. The same limits apply to
POST /analytics/query and the dataset query that reports and dashboards read
through. An analytics answer carries no hasMore, so one that names no limit
and would answer more rows than the default page runs one row past the default
and is refused with MAX_RECORDS_EXCEEDED rather than cut.
GET /data/:object/:id
Get a single record by ID. Only select and expand query parameters are allowed; any other parameter is refused 400 VALIDATION_ERROR, and the message names it and lists the two accepted ones.
| Parameter | Location | Description |
|---|---|---|
object | path | Object name |
id | path | Record ID |
select | query | Comma-separated field names to include. Unknown name → 400 INVALID_FIELD. |
expand | query | Comma-separated list of relations to eager-load. Not a reference field → 400 INVALID_FIELD. |
Response: { object: "account", id: "1", record: { ... } }
POST /data/:object
Create a new record.
Body: { name: "Acme Corp", industry: "Technology" }
Response: { object: "account", id: "1", record: { ... } }
A sys_comment create that @mentions users the server will not notify (the
mention gate withheld them, most often because they cannot read the comment or
its record) also answers withheldMentions: those users' ids, with no reason, on
the requester's own answer only. The comment is saved
with every mention, and nothing about the withheld users is stored on it. With
nobody withheld, the key is absent.
When a hook on the write tells the person who saved what the save set in motion
("Sent to your manager for approval"), the answer also carries notice: that one
author-written message, info severity, separate from warnings. Show it with, or
in place of, your own success message. If more than one hook sets it, the last one
wins. A hook on a nested write never reaches it, nothing about it is stored, and
with no notice set the key is absent.
PATCH /data/:object/:id
Update an existing record (partial update).
Body: { industry: "Healthcare" }
Response: { object: "account", id: "1", record: { ... } }
A sys_comment update answers withheldMentions the same way, for the mentions
the update added. A mention the comment already carried is not judged again.
It answers notice the same way as a create, when a hook on the update set one.
DELETE /data/:object/:id
Delete a record.
Response: { object: "account", id: "1", success: true }
Every relation pointing at the deleted record honours its own deleteBehavior
(cascade / set_null / restrict) — with one substitution: on a
required: true lookup, set_null is escalated to restrict and the delete is
refused with 409 DELETE_RESTRICTED. That happens whether the set_null was
defaulted or written out explicitly (see
Required foreign keys). On a
multiple: true required lookup the substitution is judged per referencing
ROW: the delete is refused only over rows whose set the member removal would
empty, and dependentCount counts just those rows — a row that keeps another
member is updated and does not block the delete. On a
multiple: true reference where set_null does run, it
removes just the deleted id from the array and keeps the rest. On an optional field
a reference set emptied that way reads back as null, the same as one never set; a
row stored before that rule can still read back [], so a client treats null and
[] alike as "no link", as the is_empty filter ($empty) does (the multiple doc
block in packages/spec/src/data/field.zod.ts, rendered in the
Field reference).
A record that the same delete removes never blocks it. Before any refusal is
judged, the delete collects every record its cascade reaches, the deleted
record included. A restrict held by a record in that set is not a refusal,
and neither is the restrict that a required set_null becomes. A set_null
writes nothing to a record in that set. So when an account cascades to its
contacts and its contracts, and a contract's required lookup points at a
contact, deleting the account removes all three, whichever object was
registered first. A restrict from any record outside the set still refuses
with 409 DELETE_RESTRICTED, and dependentCount counts only those records.
Batch Operations
Efficient bulk operations. Always available.
POST /data/:object/batch
Execute a batch operation (create / update / upsert / delete) on multiple records.
Body:
{
"operation": "update",
"records": [
{ "id": "1", "data": { "status": "active" } },
{ "id": "2", "data": { "status": "active" } }
],
"options": {
"atomic": true,
"returnRecords": true,
"continueOnError": false
}
}Response: BatchUpdateResponse with succeeded, failed, total, and a per-record results array. Each entry in results has id, success, index (the row's position in the request array), an optional errors array (ApiError[] — read errors[0].message, branch on errors[0].code), and optional data (the full record, present when returnRecords is true).
options.atomic defaults to false: sequential best-effort that stops at the first failure. Records written before the failure stay written — nothing is rolled back on this arm — and every record after it is reported with errors[0].code NOT_ATTEMPTED rather than omitted, so results always covers all total records and succeeded + failed === total (#7539). Send continueOnError: true to process the remaining records instead of stopping. Set atomic to true and the whole batch runs inside one transaction: the first failure rolls back every prior write, and the response reports succeeded: 0 — each row's errors[0].code says what happened: ROLLED_BACK (written, then undone), the causal row's own error code, or NOT_ATTEMPTED (never reached). A deployment whose driver cannot roll back rejects an atomic request with 501 NOT_IMPLEMENTED instead of running it best-effort — probe capabilities.transactionalBatch on /discovery first. atomic takes precedence over continueOnError.
POST /data/:object/createMany
Batch create multiple records.
Body: a bare array of records — [{ name: "A" }, { name: "B" }]. The REST handler reads the request body directly as the records array, so do not wrap it in { records: [...] }.
Response: { object: "account", records: [...], count: 2 }
POST /data/:object/updateMany
Batch update multiple records.
Body:
{
"records": [
{ "id": "1", "data": { "status": "active" } },
{ "id": "2", "data": { "status": "closed" } }
],
"options": { "atomic": false }
}The body is validated against the contract and unknown keys are dropped. The
target object always comes from the URL — an object key in the body is
ignored, on this route and on deleteMany.
Response: BatchUpdateResponse, one results entry per record.
POST /data/:object/deleteMany
Batch delete records by ID list.
Body: { "ids": ["1", "2", "3"], "options": { "continueOnError": true } } — options is
the same BatchOptions bag /batch takes. The body is validated against the
contract and unknown keys are dropped: the id list is the only thing that
selects rows, so no body key can widen the delete into a filter.
Response: BatchUpdateResponse — one results entry per id. Records are
deleted one at a time by primary key, so each honours deleteBehavior
(cascade / set_null / restrict) on relations pointing at it. The run stops
at the first failure; continueOnError: true processes the remaining ids and
reports the failures instead. Either way every id gets a results entry — the
ids a stopped run never reached carry errors[0].code NOT_ATTEMPTED, so the
counters reconcile against total (#7539).
options.atomic: true is honoured here the same way as on /batch (#4620): the
whole id list runs inside one transaction, the first failure rolls back every
prior delete, and the response reports succeeded: 0 with each row's
errors[0].code set to ROLLED_BACK, the causal error code, or
NOT_ATTEMPTED. A runtime that cannot roll back refuses the request with
501 NOT_IMPLEMENTED rather than degrading to best-effort. The same applies to
/updateMany.
Batch size
Every bulk route above — batch, createMany, updateMany, deleteMany —
caps how many records one request may carry. The limit is
batch.maxBatchSize, and it is embedder-only (#15543): it is written only
by a host that constructs the RestServerConfig itself, never by os serve or
the dev plugin. A CLI-started deployment therefore always gets the default of
200, and no flag, config file or CLI option moves it; only the embedding
host can set it, anywhere in 1–1000. Over the cap the request is rejected with
400 BATCH_TOO_LARGE before anything is written:
{
"error": "Batch too large: 500 records (max 200)",
"code": "BATCH_TOO_LARGE",
"count": 500,
"max": 200,
"object": "account"
}An empty batch is not an error — it is a no-op that returns total: 0.
POST /data/:object/:id/clone
Clone a record. Reads the source, drops engine-owned columns (id, the audit
fields, autonumbers, and computed formula/summary values) so they are
re-derived, applies any caller overrides, and inserts the copy. Shallow by
design — it duplicates the record's own fields, not its child records.
Gated by the object's enable.clone capability (default true); an object with
enable.clone: false returns 403 CLONE_DISABLED.
Body (optional): { "overrides": { "name": "Acme (Copy)" } } — applied on
top of the copied values (a bare field map is also accepted). The natural place
to set a new name or clear a unique field.
Response 201: { object, id, sourceId, record, droppedFields? } — the
bare CloneDataResponseSchema body, no envelope. droppedFields appears only
when the engine stripped a static readonly column from the copy — one the
source row carried (a clone is the one create that holds a read-only value the
caller never typed) or one supplied through overrides — and names the dropped
keys; the clone still succeeded and the field re-derived its default. The
X-ObjectStack-Dropped-Fields response header names the same fields, as on a
single create.
Analytics
Semantic BI queries using a cube-style API. Provided by @objectstack/service-analytics —
on deployments without it these endpoints answer 404 ROUTE_NOT_FOUND and discovery
reports analytics: { enabled: false, status: "unavailable" }. (The former kernel-level
degraded fallback was retired — it served unscoped, unfiltered aggregates.)
POST /analytics/query
Execute an analytics query.
Body:
{
"cube": "account",
"measures": ["revenue_sum", "count"],
"dimensions": ["industry"],
"where": { "status": "active" },
"limit": 100
}How to spell a measure. A measures entry is either a measure the Cube declares,
or one of the inferred spellings: the bare count (COUNT(*)), or one of the object's
own field names plus an aggregation suffix — _sum, _avg, _min, _max,
_count_distinct. So "the sum of revenue" is revenue_sum.
A dot is legal only as the <cube>. qualifier: account.revenue_sum resolves to the
same measure as revenue_sum (the response column keeps whichever of the two you sent).
Any other dotted spelling — revenue.sum, owner.amount_sum — is refused with
400 INVALID_FIELD naming the spelling you sent, and nothing is executed: measures do not
traverse relationships, only dimensions do, so there is no related column for a dotted
measure to aggregate. To aggregate a related column, declare a Cube whose measure names it
in its own sql.
Filtering uses the canonical Query DSL where object (the same MongoDB-style FilterCondition accepted by find()), not a filters array.
An order key names a column the answer carries. Each key must be one of the query's own
dimensions or measures entries, or a timeDimensions entry that carries a granularity,
spelled exactly as it is selected. Any other key — a field the query does not select, a
relationship path it does not group by, a time dimension that only sets a dateRange window — is
refused with 400 INVALID_FIELD naming the key and the members the query does select, and nothing
is executed. POST /analytics/sql refuses the same keys. To order by a member, select it.
Response: the runtime dispatcher wraps the AnalyticsResult as { success: true, data: { rows, fields, sql?, totals? } }:
{
"success": true,
"data": {
"rows": [
{ "industry": "Technology", "revenue_sum": 150000, "count": 5 },
{ "industry": "Healthcare", "revenue_sum": 80000, "count": 3 }
],
"fields": [
{ "name": "industry", "type": "string" },
{ "name": "revenue_sum", "type": "number" },
{ "name": "count", "type": "number" }
],
"sql": "SELECT ..."
}
}fields[] is the resolved presentation surface — read it first. Each entry carries
name and type and, when the producer declares them, label, format, currency,
currencyField, percentScale, builtinAggregate, aggregate, nullLabel and polarity (which direction
of a measure's change is good news, relayed from the dataset measure): the optional members
AnalyticsResultResponseSchema declares (packages/spec/src/api/analytics.zod.ts),
mirrored member for member by IAnalyticsService.query's AnalyticsResult and bound to
it at compile time. Optional means a given column may omit them — read them defensively,
never as guaranteed present.
The cube's metric/dimension definition (MetricSchema.label / MetricSchema.format,
DimensionSchema.label) is the declaration surface, not a substitute for this one.
GET /analytics/meta below publishes a deliberately narrow projection of it — name,
type, title (the definition's label), description, and on a measure its declared
format; never sql or granularities
(#6442). So a client that
renders table headers or formats amounts takes label / format / currency /
percentScale off the query result's fields[]: those are resolved server-side, per
column (the currency chain below, the percent-scale chain), and currency and
percentScale are not reachable through the metadata endpoint at all.
The currency chain runs on the dataset query (POST /analytics/dataset/query). A
monetary measure column takes the measure's own currency; then, only when the source
field is currencyMode: 'fixed', that field's currencyConfig.defaultCurrency; then the
tenant default currency (the localization.currency setting), if one is set. A
dynamic field (the default mode) never lends its defaultCurrency — only fixed does —
so its column shows the tenant default. A derived sum or difference whose monetary
operands all resolve to one fixed code takes that code at the field's step; any other
derived measure (a ratio, a product, operands in the tenant's currency) is monetary
only by its own currency, else its column carries none. The cube query on this page
carries no column currency.
Over a fixed field the measure's currency may only restate the field's code. A
different code is refused when the dataset compiles, with DATASET_INVALID / 400: delete
the measure's currency, and the column takes the field's code. A derived sum or
difference whose operands resolve to one fixed code is held to the same rule: a
different currency is refused with DATASET_INVALID / 400 — delete the measure's
currency (the column takes the operands' code), or restate it. A derived measure whose
monetary operands resolve to two different fixed codes is refused the same way, whatever
the operation: derive it from measures in one currency, or drop the operand in the other.
Neither a fixed code against the tenant default nor a measure currency over a
dynamic field is judged.
A measure column over a currencyMode: 'record' amount has one currency per row, so it
carries currencyField instead of currency: the name of the dimension column, in the same
answer, that groups by the amount's code field (currencyConfig.currencyField). Each row's
currency is that row's value of that column. A sum, avg, min or max over such an
amount is refused INVALID_FIELD / 400 unless the query groups by the code field, on the
dataset query and the cube query alike; a count is served.
The dataset query also states each measure column's aggregate (count, sum, avg,
min, max, count_distinct), whether or not the author labelled the measure, so a
chart can tell a count from a sum. builtinAggregate is narrower: it is present only
when the measure has no label, to mark a header that is the server's default. Neither
is set on a dimension column or a derived measure, and the cube query on this page
carries neither.
A dimension column of a dataset answer carries nullLabel when its dataset dimension
declares one: the name of the bucket of rows whose value for that dimension is null,
resolved to the request's locale like label. A chart draws its null bucket under it
instead of its own default. It is never set on a measure column, nor on the cube query
on this page.
GET /analytics/meta
Get metadata for all registered cubes, except those declared public: false, which are
omitted (and refused by POST /analytics/query and POST /analytics/sql with the same
404 CUBE_NOT_FOUND an unknown cube name gets). Cubes are explicitly defined (via
defineCube or the analytics service's cubes config) — a cube referenced by a query
that isn't yet registered is lazily auto-inferred from that query's shape, but metadata
isn't proactively generated for every object.
Pass ?cube=<name> to filter the listing to a single cube (this is what
client.analytics.meta(cube) sends).
Response: { success: true, data: [...] } where data is an array of cube
definitions with measures and dimensions (time-based dimensions are dimensions
entries with type: "time"). That envelope is the wire shape only — the SDK
unwraps it, so client.analytics.meta(cube) resolves to the cube array itself
and a caller reads cubes[0].name. The same holds for the other analytics and
automation calls; see the Client SDK page.
POST /analytics/sql
Generate the SQL for a given analytics query without executing it (dry-run/debug).
Accepts the same body shape as /analytics/query; support depends on the underlying
driver/strategy.
Response: { success: true, data: { sql: string, params: unknown[] } }
See also
- API Overview — discovery, error handling, and protocol types
- Metadata & Package API — schemas, views, and package management