Records
Read, filter, search and write the records of a synced object with a query that stays typed from your builder to the wire.
db.<object> is a typed query, where db is a client created with createStandards({ …, schema: source }). Every filter, sort and
pagination call returns a new query; the terminals send one request.
import { getStandards } from "./client";
const incidents = getStandards().incidents;
const open = await incidents
.eq("severity", "critical")
.isEmpty("resolvedAt")
.orderBy("startedAt", "desc")
.limit(10)
.fetch();
open.records[0]?.startedAt; // Date
open.total; // number | nullTyped or untyped
db.<object> reads the definition once. Attribute names are checked,
select and status values narrow to their options, and every date
attribute is revived to a Date on the way in.
from("incidents") is the untyped escape hatch. Records are
Record<string, unknown> plus metadata, and only the three system
timestamps (createdAt, updatedAt, deletedAt) are revived; your own
date attributes stay ISO strings.
Both refuse an empty, . or .. path segment (object name or id) with a
ValidationError before any request leaves: an id can never climb out of
/records/<object>/.
Filters
Queries are immutable: each method returns a new query, so a base query can be branched.
| Method | Sent as |
|---|---|
eq(attr, value) | is |
neq(attr, value) | is_not |
gt(attr, value) | greater_than |
gte(attr, value) | greater_or_equal |
lt(attr, value) | less_than |
lte(attr, value) | less_or_equal |
contains(attr, value) | contains |
notContains(attr, value) | not_contains |
startsWith(attr, text) | starts_with |
endsWith(attr, text) | ends_with |
in(attr, values) | any_of |
notIn(attr, values) | none_of |
isEmpty(attr) | is_empty |
isNotEmpty(attr) | is_not_empty |
Rules combine with and. or() flips the whole combinator; there is no
nesting.
const urgent = incidents.eq("severity", "critical").or().gte("impact", 8);
// filters: { combinator: "or", rules: [severity is critical, impact >= 8] }orderBy(attr, direction = "asc") is repeatable and appends. limit(n) and
offset(n) map to the page; the API answers 20 by default and never more
than 1000.
Search
search(q) is a modifier, not a terminal. It routes the next fetch() to
POST /records/<object>/search with q, and keeps the filters, sorts and
page you set.
const hits = await incidents.search("latency").eq("severity", "major").limit(5).fetch();If the search backend is unavailable, this route returns HTTP 503 with
code: "SEARCH_BACKEND_FAILED". The response carries x-request-id for
support and diagnostics. Retry after the backend recovers; the route does not
silently switch to a database text search.
Export
export(columns, options?) is a terminal that downloads the query as a CSV.
It posts to POST /records/<object>/export with the query's search, filters
and sorts, and the columns you name, in order. It ignores limit and
offset: an export always covers every matching record.
const response = await standards
.from("deals")
.eq("stage", "won")
.orderBy("name")
.export(["name", "stage", "amount"], { timezone: "Europe/Paris" });
await writeFile("won-deals.csv", Buffer.from(await response.arrayBuffer()));The call resolves to the unread Response, so a large export can be streamed
instead of held in memory. The body is text/csv: UTF-8 with a byte order
mark, , separators and \r\n line endings. Values read as they do in the app:
a select shows its label, a related record its name, a date is rendered in the
request's timezone. A text cell that starts with =, +, - or @ is
prefixed so a spreadsheet does not run it as a formula.
The reserved column $label exports each record's name, under the object's
label, for an object whose name is computed rather than held in one attribute.
Every record also carries four system columns: createdAt, updatedAt,
createdBy and lastUpdatedBy. Name them like any attribute. The two times
are written as YYYY-MM-DD HH:mm in the request's timezone. The two actors
are written by display name, prefixed like any other text when the name starts
with =, +, - or @. An actor the app cannot name (one no longer on
record, or an agent you cannot see) leaves its cell empty. Any other name is refused
with HTTP 400 and Unknown export columns.
The second argument takes ids to export only those records (at most
20 000), locale for numbers and dates, timezone (default: the
caller's zone) for dates, and filename: the tab name the server names the
download after (deals-won-deals-2026-10-13.csv), not a file name.
On the wire the body takes columns (required), the same q, filters,
userFilters and sorts as search, plus ids, filename, timezone and
locale. An export carries at most 100 000 records. A larger match answers HTTP 413 with code: "EXPORT_TOO_LARGE" and
details: { total, max } before any byte is written; narrow the filters and
call again. The call rejects with the same StandardsRequestError as every
other route.
Aggregate
aggregate(options) is a terminal that measures the query instead of reading
it. It posts the query with the options to POST /records/<object>/aggregate
and resolves to { groups, total, truncated, appliedBucket?, appliedBucket2?, currency?, previous? }. sorts and limit
of the query are ignored; a query with search(q) is refused with
AGGREGATE_SEARCH_UNSUPPORTED (narrow it with filters instead).
const byStage = await standards.deals
.aggregate({ measure: "sum", attribute: "amount", groupBy: "stage" });
byStage.groups; // [{ key: "won", value: 9000 }, ...]
byStage.currency; // "EUR": the code the amounts were summed inThe options are measure (count, sum, avg, min, max), attribute,
groupBy and groupBy2 (an attribute name or { attribute, bucket }; a date
named alone is cut into months),
compare, userFilters, timezone (default: the caller's zone) and
hostRecordId. The type is RecordsAggregateOptions. The wire contract, the
group limit and the refusals are on
Objects and records.
Terminals
fetch() resolves to FetchResult, whose shape is { records, total: number | null }: total is null unless the server counted, so never treat it as zero.
| Terminal | Request | Returns |
|---|---|---|
fetch() | POST /records/<object>/list (or /search) | { records, total: number | null } |
all() | fetch() page after page, 1000 at a time | Every matching record |
single() | fetch() with limit(1) | The first record or null |
aggregate(options) | POST /records/<object>/aggregate | { groups, total, truncated, appliedBucket?, appliedBucket2?, currency?, previous? } |
get(id) | GET /records/<object>/<id> | The record; throws on 404 |
create(input) | POST /records/<object> with { data } | The created record |
update(id, patch) | PUT /records/<object>/<id>, patch unwrapped | The updated record |
delete(id) | DELETE /records/<object>/<id> | void |
listAccess(id) | GET /records/<object>/<id>/access | The record's audience |
grantAccess(id, actorId, role) | PUT /records/<object>/<id>/access/<actorId> | The audience after the share (owner or editor) |
revokeAccess(id, actorId) | DELETE /records/<object>/<id>/access/<actorId> | The audience after the removal |
clearAccess(id) | DELETE /records/<object>/<id>/access | The audience after every manual share is removed |
const created = await incidents.create({
title: "API latency",
severity: "major",
startedAt: new Date(),
});
const same = await incidents.get(created.id);
await incidents.update(created.id, { severity: "minor" });
await incidents.delete(created.id);create wraps the input in { data }; update sends the patch as the body,
unwrapped, on a PUT. Only the keys you pass travel.
From the SDK's point of view delete is final: there is no restore, no purge, no hydration of relations and no field projection. Deleting a record other records point at is never refused, see Relations and documents.
Bulk writes
createMany, updateMany and deleteMany write many records through the
bulk routes. Inputs are typed exactly like create and update, and created
and updated records are revived like a single write, dates included.
| Method | Request | Returns |
|---|---|---|
createMany(inputs) | POST /records/<object>/bulk-create with { records } | { created, errors: { index, error }[] } |
updateMany(entries) | POST /records/<object>/bulk-update with { updates }, each entry { id, data } | { updated, errors: { id, error, code? }[] } |
deleteMany(ids) | POST /records/<object>/bulk-delete with { ids } | { deleted: string[], errors: { id, error, code? }[] } |
const { created, errors } = await incidents.createMany([
{ title: "API latency", severity: "major", startedAt: new Date() },
{ title: "Queue backlog", severity: "minor" },
]);
for (const { index, error } of errors) {
// index is the position in the array you passed
}
await incidents.updateMany(created.map((incident) => ({ id: incident.id, data: { severity: "minor" } })));
const { deleted } = await incidents.deleteMany(created.map((incident) => incident.id));The server takes at most 100 items per request, so the client splits a longer list into requests of 100, sends them one after another, and concatenates the results in order. An empty list sends no request.
A refused item never throws. It lands in errors while its siblings are
written. index in a createMany error is the position in the array you
passed, not in its chunk. A deleteMany error carries the server's code when
it has one, SCHEMA_FORBIDDEN for a record you can read but not delete and
SCHEMA_RECORD_NOT_FOUND for a missing or invisible one.
A failed request (a 500, a timeout, a refused key) rejects with the same
StandardsRequestError the other terminals throw, and the remaining chunks
are not sent. The chunks sent before it are already written, and the failing
chunk may be partly or fully written (a timeout in particular): nothing is
rolled back, so read back what landed before retrying.
Where the API's rate limiter is on, the bulk routes allow 10 calls a minute,
so past 1,000 items a minute a long list rejects mid-way with a 429
StandardsRequestError after the first 1,000 are written (see
Bulk). Split a very large list
yourself or pace it.
createMany takes no visibility: every record the bulk-create route writes is
workspace-visible. Create private records one by one with create.
Dates on the wire
Outbound, every Date becomes an ISO string. Inbound, the system timestamps
and every date attribute of the builder become Date objects; a
.endDate() range has both start and end revived. Statically a date is
string | Date, so pass either.
Walk every page
fetch() returns one page. all() returns every matching record: it walks
pages of 1000 (the most one call answers) with the query's filters, sorts and
search, and follows the server's hasMore until it is false.
const everyIncident = await incidents.orderBy("startedAt").all();limit(n) caps the total rather than the page: limit(1500).all() asks for
1000, then 500. offset(k) is where the walk starts. If any page fails, all()
rejects with the same error fetch() raises and returns nothing.
When a records route answers 404
The workspace answers, but has none of your objects: the schema was never
synced, or was released. Run the sync before reading, and treat this 404
as "never synced" rather than as a missing record. The isSchemaMissing predicate that sorts it out is on Errors.
Test against an in-memory workspace
@standards/client/testing gives a test a Standards workspace in memory. Its db is the real client, with the same queries, date revival, bulk chunking and all() your app ships. It answers the records API the way a workspace does, so the code under test cannot tell the two apart.
import { createTestStandards } from "@standards/client/testing";
import { expect, it } from "vitest";
import { listOpenIncidents } from "./incidents";
import { schema } from "./schema";
it("lists the open incidents, newest first", async () => {
const { db, seed } = createTestStandards({ schema });
seed("incidents", [
{ title: "API latency", status: "open" },
{ title: "Login fails", status: "resolved" },
]);
expect(await listOpenIncidents(db)).toEqual([expect.objectContaining({ title: "API latency" })]);
});Pass db wherever your code takes the client createStandards returns. It needs no URL, no key and no network.
What it answers like a workspace
- Pages: 20 records by default, 1000 at most. With no sort, the newest come first. A negative or fractional
limitoroffsetis a400. - Filters: every fluent operator:
eq,neq, the comparisons,contains,startsWith,endsWith,in,notIn,isEmpty,isNotEmpty, andor().- Text equality is exact;
contains,startsWithandendsWithignore case. neq,notContainsandnotInkeep a record with no value.- A multiselect matches
inwhen any listed option is set, andnotInwhen none is. It is a set, socontainson it is a400namingany_of. - A checkbox takes
eqalone;eq(false)also matches a record where it is unset. - An operator a workspace refuses for the attribute's type is refused here with the same
400and message: the workspace runs the server's own filter validation.
- Text equality is exact;
- Dates: a date compares by its day.
gt("due", "2026-10-04")means from the next day on, andltemeans up to and including that day. A comparand that names no date is a400. - Sorts: a record with no value sorts last in either direction. A select or status sorts by its options' labels.
search(q): matches any text value containingq, ignoring case, with no ranking.- Records: every create gets a generated id and the workspace's own timestamps, and every record answers
visibility: "workspace"andownedBy: null. A key the object does not declare is a400, andidis one of them: a body cannot choose a record's id. The in-memory workspace lets acreatedAtorupdatedAtin a body through and keeps its own. An empty update is a400too. - Bulk:
createMany,updateManyanddeleteManyreport a refused item inerrorsrather than throwing, and the others are written: an unknown id, or an item naming a key the object does not declare, with the same message and code a workspace returns. - Missing things: an unknown record answers
404withSCHEMA_RECORD_NOT_FOUND, and an object your schema does not declare answers404too.
Seed, inspect, fail
seed(objectName, records)stores records directly, without a request. It is typed by your schema: the object's name, and whatcreatetakes. Give anid,createdAtorupdatedAtto pin them; otherwise they are filled in, each record a millisecond after the last. Generated IDs avoid IDs already reserved by seeded or created records across the workspace.records(objectName)returns what the workspace holds, in creation order, flat and with dates revived.failNextRequestWith(status, { message, code })makes the next request fail with that status.401and403raiseStandardsAuthError; any other status raisesStandardsRequestError, exactly as the network client does.
const { db, failNextRequestWith } = createTestStandards({ schema });
failNextRequestWith(503, { code: "SERVICE_UNAVAILABLE" });
await expect(db.incidents.fetch()).rejects.toMatchObject({ status: 503 });What it does not serve
The workspace keeps records only. It throws UnsupportedFilterOperatorError, naming the operator and the attribute, instead of matching nothing:
- on a filter operator the fluent API does not offer (
is_within,on_day_month, …); - on a comparison against a date range (
.endDate()) or a date with a time (.includeTime()).
Files, documents, schema sync and record access answer 501. It runs no permissions, visibility, required-attribute validation, relations, formulas or full-text ranking, and % and _ in a contains needle match only themselves.
Test those against a real workspace.
Next steps
- Type safety: what
createandupdateaccept, and the six helpers - Errors:
status: 0, the auth errors, one handling pattern - Sync: push the schema before the first query
- Objects and records: system fields and metadata
- Book a demo