standards
RecordsRecords

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 | null

Typed 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.

MethodSent 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(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 in

The 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.

TerminalRequestReturns
fetch()POST /records/<object>/list (or /search){ records, total: number | null }
all()fetch() page after page, 1000 at a timeEvery 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 unwrappedThe updated record
delete(id)DELETE /records/<object>/<id>void
listAccess(id)GET /records/<object>/<id>/accessThe 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>/accessThe 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.

MethodRequestReturns
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 limit or offset is a 400.
  • Filters: every fluent operator: eq, neq, the comparisons, contains, startsWith, endsWith, in, notIn, isEmpty, isNotEmpty, and or().
    • Text equality is exact; contains, startsWith and endsWith ignore case.
    • neq, notContains and notIn keep a record with no value.
    • A multiselect matches in when any listed option is set, and notIn when none is. It is a set, so contains on it is a 400 naming any_of.
    • A checkbox takes eq alone; 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 400 and message: the workspace runs the server's own filter validation.
  • Dates: a date compares by its day. gt("due", "2026-10-04") means from the next day on, and lte means up to and including that day. A comparand that names no date is a 400.
  • 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 containing q, ignoring case, with no ranking.
  • Records: every create gets a generated id and the workspace's own timestamps, and every record answers visibility: "workspace" and ownedBy: null. A key the object does not declare is a 400, and id is one of them: a body cannot choose a record's id. The in-memory workspace lets a createdAt or updatedAt in a body through and keeps its own. An empty update is a 400 too.
  • Bulk: createMany, updateMany and deleteMany report a refused item in errors rather 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 404 with SCHEMA_RECORD_NOT_FOUND, and an object your schema does not declare answers 404 too.

Seed, inspect, fail

  • seed(objectName, records) stores records directly, without a request. It is typed by your schema: the object's name, and what create takes. Give an id, createdAt or updatedAt to 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. 401 and 403 raise StandardsAuthError; any other status raises StandardsRequestError, 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