services.data
CRUD runtime helper API for records (`query`, `get`, `find`, `create`, `update`, `delete`).
services.data
- Stability:
stable - Canonical source:
packages/client/src/index.ts— theObjectStackClient.datasurface (see Canonical source for why this page names the SDK rather than acontracts/*-service.tsinterface)
Who holds this binding — a hook does not
This page documents the services.data contract surface: the signatures, not a
binding every runtime surface receives (see the
binding note). A data hook never gets one. The engine
builds a hook context key by key — object / event / input / session / provenance
/ user / api / transaction / ql — and sets no services key at any of its
construction sites, so services.data.get(…) written beside a ctx.input.… read throws
on services at the first call rather than reading anything
(#5720).
A hook's own cross-object channel is ctx.api: see
Examples §2 for a
ctx.api.object('crm_account').findOne(…) read inside a real beforeInsert /
beforeUpdate handler. The Example below is written the other way round — as
the code that holds the binding calls it, with plain arguments and no ctx in sight.
Methods
services.data.query<T = any>(object: string, query: Partial<QueryAST>): Promise<PaginatedResult<T>>
services.data.find<T = any>(object: string, options?: QueryOptions | QueryOptionsV2): Promise<PaginatedResult<T>>
services.data.get<T = any>(object: string, id: string): Promise<GetDataResult<T>>
services.data.create<T = any>(object: string, data: Partial<T>): Promise<CreateDataResult<T>>
services.data.update<T = any>(object: string, id: string, data: Partial<T>): Promise<UpdateDataResult<T>>
services.data.delete(object: string, id: string): Promise<DeleteDataResult>Two list entries, one preference
query and find both answer a list read with the same
{ records, total?, hasMore? } envelope, and the Canonical source declares which
of the two to prefer — the block above lists them in its declaration order.
data.query is "Advanced Query using ObjectStack Query Protocol"; data.find
carries an @deprecated tag in the same file: "Use data.query() with standard
QueryAST parameters instead. This method uses legacy parameter names." The tag
ships in the package's published type declarations, so an editor strikes find
through at every call site and points at query. As with the options vocabulary
below, the posture is the SDK's own, not this page's — it is recorded product
direction (#986:
deprecate the legacy-parameter query entries, promote data.query(AST)).
Deprecated means "prefer query", not "scheduled for removal in this version":
find remains fully functional, keeps both of its option vocabularies (see
below), and the Canonical source still
describes QueryOptionsV2 as "the vocabulary data.find() still accepts"
for callers that stay on it. The capability line between the two entries is
real, though. find rides GET query parameters, so it has no spelling for a
search term or for nested expand detail — it refuses a nested expand with an
error whose own text says to use data.query(). query POSTs the full
QueryAST as a JSON body (POST /data/:object/query) and carries all of it.
Canonical source
Every sibling page in this chapter names a contract interface
(packages/spec/src/contracts/sharing-service.ts, queue-service.ts, …). This one names
the client SDK instead, and the difference is real rather than an oversight: the spec
declares no IDataService. Its nearest neighbour, IDataEngine
(packages/spec/src/contracts/data-engine.ts), is a lower surface with a different
shape — find(objectName, query, options): Promise<any[]> straight at the engine — not
the object-name-plus-options protocol call documented above.
So the signatures come from ObjectStackClient.data (packages/client/src/index.ts), and
the payloads they resolve to are the spec's wire schemas — GetDataResponseSchema,
CreateDataResponseSchema, UpdateDataResponseSchema, DeleteDataResponseSchema in
packages/spec/src/api/protocol.zod.ts — which the SDK's *DataResult interfaces mirror
key for key. A managed runtime binds services.data to this same shape.
Parameters
object: short object name (for exampletask,account)id: record ID for single-record operationsdata: partial payload for create/updatequery(query): aPartial<QueryAST>— the spec's query protocol shape (packages/spec/src/data/query.zod.ts):where/fields/orderBy/limit/offset, plus the AST-only clauses (search,expandwith nested detail,aggregations,groupBy,having). The AST's ownobjectkey is not needed here: the server takes the target from theobjectargument (the URL path) and overwrites anything the body saysoptions(find): filtering, sorting and pagination — two vocabularies, one behaviour; see the table below
find options: canonical and legacy
The signature above accepts QueryOptions | QueryOptionsV2, and the Canonical source
declares which of the two to write. QueryOptionsV2 is "canonical query options using
Spec protocol field names … the vocabulary data.find() still accepts", while
QueryOptions carries an @deprecated tag in the same file describing "legacy parameter
names … that require translation to QueryAST", with the instruction to "prefer QueryAST
fields directly". Both interfaces are declared in packages/client/src/index.ts, so the
recommendation is the SDK's own, not this page's.
Canonical (QueryOptionsV2) | Legacy (QueryOptions) | Clause |
|---|---|---|
where | filter | filter conditions (WHERE) |
fields | select | field selection (SELECT) |
orderBy | sort | sort definition (ORDER BY) |
limit | top | maximum records (LIMIT) |
offset | skip | records skipped (OFFSET) |
Write the left column. Those are the QueryAST and protocol field names, so the same
words carry from here down through data.query() into the query layer — one translation
step fewer to hold in your head.
The right column still works. Nothing refuses it: find recognises a canonical
options object and normalizes it into exactly the transport parameters the legacy names
produce, so the two columns are equivalent, not merely similar. Deprecated here means
"prefer the other spelling", not "scheduled for removal in this version".
Use one column per call. find reads either the canonical names or the legacy ones
for a given options object, never a blend — a key from the other column is dropped
silently rather than refused, so migrate an options object as a whole.
Returns
get: single record payloadquery/find: list payload + pagination metadata — the samePaginatedResultenvelope for bothcreate/update: mutated record payloaddelete:{ object, id, success }— the spec'sDeleteDataResponse. The flag issuccess, notdeleted(#5638)
Typical Errors
RECORD_NOT_FOUNDVALIDATION_FAILEDPERMISSION_DENIED
Example
Call these methods from code that holds the binding — a managed runtime hands it in as
services.data — so the record id arrives as an ordinary argument. It is deliberately not
a hook body: a hook has no services key to reach through (see above), and reads other
objects via ctx.api.
import type { ObjectStackClient } from '@objectstack/client';
/** The `services.data` binding, exactly as this page's Canonical source declares it. */
type DataService = ObjectStackClient['data'];
export async function recentOrdersForContact(data: DataService, contactId: string) {
// `get` resolves the response envelope `{ object, id, record }` — the row is `record`.
const { record: contact } = await data.get<{ id: string; name: string }>('contact', contactId);
// `find` resolves `{ records, total?, hasMore? }`. The options are the canonical
// `QueryOptionsV2` vocabulary — `where` / `orderBy` / `limit`, not `filter` / `sort` / `top`.
const { records: orders } = await data.find<{ id: string; amount: number }>('sales_order', {
where: { contact_id: contact.id },
orderBy: [{ field: 'created_at', order: 'desc' }],
limit: 20,
});
// `query` — the Canonical source's preferred list entry — POSTs the same
// words as a Partial<QueryAST> body, and carries the clauses `find` has no
// GET spelling for. Here: per-relation `expand` detail, which `find`
// refuses with an error that itself points at `query`.
const { records: withContact } = await data.query<{ id: string; amount: number }>('sales_order', {
where: { contact_id: contact.id },
orderBy: [{ field: 'created_at', order: 'desc' }],
limit: 20,
expand: { contact_id: { object: 'contact', fields: ['name'] } },
});
return { contact, orders, withContact };
}The block carries no {/* os:check */} marker, and that is a measurement rather than an
omission: check:skill-examples compiles marked blocks against the built
@objectstack/spec declarations only — its paths map is derived from that package's own
exports, and @objectstack/spec does not depend on @objectstack/client. A marked
block here would therefore have to hand-declare DataService instead of importing it,
which pins the example to itself and nothing else. The marker becomes worth adding the day
this surface has a spec-side contract to import (see the
Canonical source note).