Skip to content

Latest commit

 

History

History
395 lines (303 loc) · 17.2 KB

File metadata and controls

395 lines (303 loc) · 17.2 KB
title Data API
description REST endpoints for CRUD, batch operations, record cloning, and analytics queries.

Data API

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.
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 a text-like auto-default when none are declared).
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 / limitpageSize, 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) 400filter 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

The same rule applies to orderby on GET /data/:object/export.

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:

  • sortsort + top is 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.

Sorts accept any of these spellings, all equivalent: ?sort=-created_at, ?$orderby=-created_at, and — on POST /data/:object/query{"orderBy": [{"field": "created_at", "order": "desc"}]}, {"orderBy": ["-created_at"]} or {"orderBy": {"created_at": "desc"}}. A shape that is none of these (a number, an entry naming no field, a direction that is neither asc nor desc) is 400 INVALID_SORT.

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=amount 400 INVALID_FIELD — real field, but not searchable
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
  • searchFields — the only parameter whose failure changed which rows came back. An unknown name 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 in searchableFields), and a searchableFields entry that names no field (a stale declaration — the bug is on the object, and clients that echo the declaration verbatim are told so).
  • groupBy — an unknown column projected null for 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.
  • aggregationssum over an unknown column folded blanks to 0, the exact number a genuinely empty quarter produces (avg/min/max answered null the 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
}

GET /data/:object/:id

Get a single record by ID. Only select and expand query parameters are allowed; all other parameters are discarded.

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: { ... } }

PATCH /data/:object/:id

Update an existing record (partial update).

Body: { industry: "Healthcare" }
Response: { object: "account", id: "1", record: { ... } }

DELETE /data/:object/:id

Delete a record.

Response: { object: "account", id: "1", success: true }


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, stopping at the first failure). Set it 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.

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 the deployment's batch.maxBatchSize (default 200, configurable 1–1000); over it 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 }


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
}
Filtering uses the canonical Query DSL `where` object (the same MongoDB-style `FilterCondition` accepted by `find()`), not a `filters` array.

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", "label": "Industry" },
      { "name": "revenue.sum", "type": "number", "label": "Revenue Sum", "format": "$0,0" },
      { "name": "count", "type": "number", "label": "Count" }
    ],
    "sql": "SELECT ..."
  }
}

GET /analytics/meta

Get metadata for all registered cubes. 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").

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