@colixsystems/datastore-client
v0.12.0
Published
Typed, scoped data-plane client for the AppStudio datastore API (tables, records, aggregates, record-level permissions, BankID e-signing, realtime subscribe). snake_case wire contract, no transform.
Readme
@colixsystems/datastore-client
Typed, scoped data-plane client for the AppStudio datastore API. It covers exactly three things:
- tables — table schema (id, name, columns) via
tables.{list,get}and theschema(tableId)alias. - records — record CRUD, querying, and aggregation via
records(tableId).{list,get,create,update,delete,aggregate}. - record-level permissions — RLS grants on a single record (REQ-ACL-06) via
records(tableId).permissions(recordId).{list,grant,update,revoke}. - realtime — subscribe to a table's live change stream (REQ-RT-07) via
records(tableId).subscribe({ onCreated, onUpdated, onDeleted, onStatus }), returning an unsubscribe function.
It does not cover users, groups, files, or payments — those live in sibling packages (assets-client, directory-client, payments-client). This is a standalone fetch-based client you instantiate yourself with createDatastoreClient({ baseUrl, getToken, getTenantId }).
Two surfaces, one package. This client serves two callers:
- External / server-side integrations instantiate it directly with an API key (
getTokenreturns the key,getTenantIdthe tenant) and call the methods below.- The widget runtime — the Player and exported Expo app instantiate the same package and inject it into
WidgetContext.datastore. Widgets never import this package; they call the SDK hooks from@colixsystems/widget-sdk(useDatastoreQuery,useDatastoreMutation,useDatastoreSchema,useRecordPermissions, …), which readctx.datastore. Both surfaces speak the identical snake_case REST contract.
Status
v0.12.0 — pre-publish. Not yet published to npm.
0.12.0 (additive):
records(tableId).list({ sort, filter })and.aggregate({ filter })now NORMALISE theirsortandfilterarguments, so the structured shapes authors (and the AI widget agent) reach for work alongside the raw wire shapes:
sortaccepts the<col>:<dir>wire string (e.g.created_at:desc), a{ field, dir }, or an array of those (the backend sorts on a single column — the first entry is used).filteraccepts the wire map ({ status: "eq:PAID" }) OR the structured condition array thefilterListauthor control emits ([{ column, operator, value, valueMode }]); arelativeDatevalue mode resolves to an ISO timestamp, andempty/nemptydrop their value.- An un-normalizable
sort/filternow throwsValidationErrorinstead of stringifying to[object Object]and producing a silent 400.- Doc fix: the descending-sort form is
created_at:desc(NOT-created_at, which the backend reads as a column literally named-created_at).
0.10.0 (breaking): removed
records(tableId).sign(recordId)and itsSignInitiate*/SignStatusResult/RecordSignatureNamespacetypes. Per-record/column BankID signing (the retired SIGNATURE column type) is replaced by a standalone Signature subject model — signing a file first — exposed through new methods as that backend ships. No other method changed.
0.6.0 (additive): added
records(tableId).subscribe({ onCreated, onUpdated, onDeleted, onStatus }, { fallbackAfterMs? })(REQ-RT-07) — a WebSocket subscription to the table's realtime change stream at<baseUrl>/datastore/ws, returning a synchronous unsubscribe function. Server-gated by the same read ACL as REST. New optionalwebSocketImplfactory option (defaults toglobalThis.WebSocket, present in browsers and React Native); when no impl is availableonStatusreports"fallback"so callers poll. No existing method changed.
0.5.0 (breaking): the client is now snake_case end to end with NO transform (REQ-GEN-09) and is scoped to the data plane only.
- The old camelCase↔snake_case permission mappers are deleted. Permission bodies are sent snake_case verbatim (
{ user_id, can_read, … }) and rows are returned snake_case verbatim ({ id, table_id, can_read, … }).records().list()returns the{ data, meta }envelope verbatim (no unwrap).tables.list()likewise.- Added
schema(tableId)as an alias oftables.getfor the column structure (the host callsctx.datastore.schema(t)).- Added an optional
getRequestHeaders({ namespace, operation })factory option for attaching per-request headers (e.g. per-widget scope tokens) without the SDK knowing about scope-token issuance.record listnow accepts asortparam (e.g.created_at:desc).- Removed the
users/groupsnamespaces — they belong in sibling packages.
Contract: snake_case, no transform
The wire format is snake_case in both directions and that is the client contract too. The SDK does no case mapping:
- Request bodies are sent snake_case verbatim — you pass
{ user_id, can_read }, not{ userId, canRead }. - Response objects are returned snake_case verbatim — you read
row.created_at,row.table_id,perm.can_read. - The only camelCase is JS method names (
filterMode,groupBy,sumField) and factory option names.
Public API
import {
createDatastoreClient,
DatastoreError,
NotFoundError,
ForbiddenError,
ValidationError,
RateLimitedError,
ServerError,
} from "@colixsystems/datastore-client";
const client = createDatastoreClient({
baseUrl: "https://api.appstudio.io",
getToken: () => "Bearer ...", // "Bearer " prefix added if missing
getTenantId: () => "tenant_abc",
getRequestHeaders: ({ namespace, operation }) => ({ "X-Widget-Scopes": "..." }), // optional
// fetchImpl defaults to globalThis.fetch
});
// Tables (schema)
const { data: tables, meta } = await client.tables.list(); // { data, meta } verbatim
const table = await client.tables.get("Tasks"); // { id, name, columns: [...] }
const sameTable = await client.schema("Tasks"); // alias of tables.get
// Records — filter values are `op:value` expressions (eq, neq, lt, gt, gte,
// lte, contains, empty, nempty). Sort is `<col>:<asc|desc>`. Pagination is
// limit + offset.
const { data: orders } = await client
.records("orders")
.list({ filter: { status: "eq:PAID" }, sort: "created_at:desc", limit: 50, offset: 0 });
// `sort` and `filter` also accept the structured shapes authors reach for —
// they normalise to the wire form above (an un-normalizable shape throws
// ValidationError rather than emitting `[object Object]`):
const { data: recent } = await client
.records("orders")
.list({
sort: { field: "created_at", dir: "desc" },
filter: [{ column: "status", operator: "eq", value: "PAID" }],
});
const rec = await client.records("orders").get("r1");
await client.records("orders").create({ status: "PAID", amount_cents: 1200 });
await client.records("orders").update("r1", { status: "REFUNDED" }); // PATCH
await client.records("orders").delete("r1");
// Aggregate: group by one column, optionally sum one numeric field.
const byStatus = await client
.records("orders")
.aggregate({ groupBy: "status", sumField: "amount_cents" });
// → [{ group: "PAID", count: 12, sum: 4200 }, ...]
// Row-level permissions (REQ-ACL-06): a grant to a user OR group with
// can_read is what membership means. Provide exactly one of user_id/group_id.
const perms = client.records("channels").permissions(channelId);
const { data: members } = await perms.list(); // { data, meta } verbatim
const grant = await perms.grant({ user_id: "user_123", can_read: true });
await perms.update(grant.id, { can_write: true });
await perms.revoke(grant.id);Surface
| Method | HTTP | Returns |
| --- | --- | --- |
| tables.list() | GET /tables | Page<Table> |
| tables.get(idOrName) | GET /tables/{id} | Table (with columns) |
| schema(tableId) | GET /tables/{id} | Table (alias of tables.get) |
| records(t).list(query?) | GET /tables/{t}/records | Page<Record> |
| records(t).get(id) | GET /tables/{t}/records/{id} | Record |
| records(t).create(values) | POST /tables/{t}/records | Record |
| records(t).update(id, values) | PATCH /tables/{t}/records/{id} | Record |
| records(t).delete(id) | DELETE /tables/{t}/records/{id} | void |
| records(t).aggregate(spec) | GET /tables/{t}/records/aggregate | AggregateResult |
| records(t).permissions(r).list() | GET /tables/{t}/records/{r}/permissions | Page<RecordPermission> |
| records(t).permissions(r).grant(body) | POST /tables/{t}/records/{r}/permissions | RecordPermission |
| records(t).permissions(r).update(pid, patch) | PUT /tables/{t}/records/{r}/permissions/{pid} | RecordPermission |
| records(t).permissions(r).revoke(pid) | DELETE /tables/{t}/records/{r}/permissions/{pid} | void |
| records(t).subscribe(handlers, opts?) | WS <baseUrl>/datastore/ws ({type:"subscribe",tableId}) | () => void (unsubscribe) |
Query params (snake_case on the wire)
| Caller key | Wire param |
| --- | --- |
| limit | limit |
| offset | offset |
| q | q |
| filterMode | filter_mode (and | or) |
| sort | sort — <col>:<asc\|desc> (e.g. created_at:desc); also accepts { field, dir } or an array |
| filter[col] | filter[col]=op:value (inner key is the author's column name, verbatim); also accepts [{ column, operator, value, valueMode }] |
| groupBy | group_by |
| sumField | sum_field |
Factory options
| Option | Type | Notes |
| --- | --- | --- |
| baseUrl | string | Required. |
| getToken | () => string \| Promise<string> | Required. Returns the Authorization value; Bearer prefix added if missing. |
| getTenantId | () => string \| Promise<string> | Required. Returns the x-tenant-id value. |
| getRequestHeaders | ({ namespace, operation }) => object \| Promise<object> | Optional. Extra headers merged per request (e.g. scope tokens). namespace is one of tables / records / permissions. |
| fetchImpl | typeof fetch | Optional. Defaults to globalThis.fetch. |
| webSocketImpl | typeof WebSocket | Optional. Used by records(t).subscribe(...). Defaults to globalThis.WebSocket (browser + React Native). When absent, subscribe reports "fallback" and opens no socket. |
Transport
| Concern | Behaviour |
| --- | --- |
| Auth header | From getToken (host-injected); Bearer prefix normalised. |
| Tenant header | x-tenant-id from getTenantId (host-injected). |
| Retries | Idempotent GETs retried 3× with exponential backoff (200/400/800 ms). |
| Timeouts | 10 s default, configurable per call via timeoutMs. |
| Error model | Typed DatastoreError hierarchy (NotFoundError, ForbiddenError, ValidationError, RateLimitedError, ServerError). |
| Platform | Browser and React Native (uses fetch + AbortController). |
Dependencies
None. The client uses only platform fetch and AbortController, both available in modern browsers, Node 18+, and React Native.
Tests
node --test srcFully self-contained — no npm install, no cross-package deps.
