HTTP Client
@atscript/db-client is an HTTP client that maps 1:1 to moost-db controller endpoints. Each method corresponds to a specific HTTP request — query() is GET /query, insert() is POST /, and so on. Works in browsers, Node.js, and any runtime with fetch.
In SSR environments, Moost's fetch automatically routes local requests to handlers in-process, so the same Client instance works on both server and browser with zero configuration.
Installation
pnpm add @atscript/db-clientCreating a Client
import { Client } from "@atscript/db-client";
// Untyped — Record<string, unknown> generics
const users = new Client("/api/users");
// Type-safe — pass the Atscript model as generic
import type { User } from "./models/user.as";
const users = new Client<typeof User>("/api/users");When you provide <typeof User>, all methods become fully typed:
- Filters check field names against the model's own properties
$sortkeys are constrained to valid field names, and so are$nullskeys (since 0.1.153; sent as the URL suffix,$sort=-closedAt:last)$withentries are constrained to declared navigation properties- Primary key type flows through
one()andremove() - Insert/update data is checked against the model's field types
Options
const users = new Client<typeof User>("/api/users", {
// Base URL for all requests
baseUrl: "https://api.example.com",
// Static headers
headers: { Authorization: "Bearer token123" },
// Async header factory (e.g. token refresh)
headers: async () => ({
Authorization: `Bearer ${await getToken()}`,
}),
// Custom fetch (e.g. for testing or interceptors)
fetch: myCustomFetch,
});| Option | Type | Description |
|---|---|---|
baseUrl | string | Prepended to the path for every request |
headers | Record<string, string> or () => Promise<Record<string, string>> | Default headers for every request — factory is called before each request |
fetch | typeof fetch | Custom fetch implementation |
navigate | (url: string) => void | Promise<void> | SPA-router hook for processor: 'navigate' actions (see Navigate dispatch) |
metaStore | MetaStore | false | Where /meta bodies are kept for revalidation — default: one store shared by all clients (see Revalidating /meta) |
metaKey | string | Store key for this client's /meta — default: the /meta URL (see Parametric mounts) |
locale | string or () => string | undefined | Sent as Accept-Language; /meta, the validator and forms are cached per locale (see Translations) |
SSR and auth — async headers factory
The headers option accepts an async factory that the client awaits on every request — ideal for token refresh, SSR cookie forwarding, and Authorization rotation.
Per-request token refresh:
import { Client } from "@atscript/db-client";
const users = new Client<typeof User>("/api/users", {
headers: async () => {
const token = await getAccessToken(); // your refresh logic
return { Authorization: `Bearer ${token}` };
},
});getAccessToken() runs on every method call (query, insert, action, ...) — keep it cheap or memoize internally. The client never caches the resolved headers.
SSR cookie forwarding — on the server, forward the incoming request's cookies to the moost-db endpoint so authenticated session reuse works in-process:
import { Client } from "@atscript/db-client";
function makeServerClient(req: { headers: { cookie?: string } }) {
return new Client<typeof User>("/api/users", {
headers: async () => ({
cookie: req.headers.cookie ?? "",
}),
});
}Moost's fetch automatically routes local requests to handlers in-process — the same Client instance works on both server and browser; only the headers factory needs to change.
Refreshing on 401 — the client does not implement automatic retry; wrap with a thin helper:
async function withRefresh<R>(call: () => Promise<R>): Promise<R> {
try {
return await call();
} catch (e) {
if (e instanceof ClientError && e.status === 401) {
await refreshAccessToken(); // your refresh implementation
return call(); // headers factory re-runs and picks up the new token
}
throw e;
}
}
const rows = await withRefresh(() => users.query({ filter: { active: true } }));Because headers is awaited on every call, the retried request automatically picks up the new token — no need to recreate the Client.
Querying
All query methods accept a Uniquery object with filter and controls.
query
GET /query — returns all matching records. See CRUD — GET /query.
const active = await users.query({
filter: { status: "active" },
controls: { $sort: { createdAt: -1 }, $limit: 50 },
});The $search, $vector, $index, $threshold and $rank controls are also passed through query():
// Text search
const results = await users.query({
controls: { $search: "alice" },
});
// Text search without relevance ranking (since 0.1.175): `$sort`, else primary-key order
const unranked = await users.query({
controls: { $search: "alice", $rank: false },
});
// Vector search
const similar = await posts.query({
controls: { $vector: "embedding", $search: "machine learning" },
});Filters may select rows by their related rows with $some / $none (since 0.1.147). The client sends them as URL predicates; the relation must be @db.rel.filterable on the server:
const open = await issues.query({
filter: { ticket: { $some: { status: "open" } } },
});
const orphans = await issues.count({ filter: { ticket: { $none: {} } } });The filter type accepts a predicate on navigation keys only — { title: { $some: {} } } and unknown keys are compile errors; the operand itself is not typed field by field. query, count, pages and the geo methods all take it. meta().relations[i].filterable is true on the relations that accept one.
Decorations — Client<T, D>
Since 0.1.148. A controller can declare display-only columns with @DbDecorations. Pass the declaring interface as the second generic to type them:
import type { TicketDecorations } from "./tickets.decorations.as";
const tickets = new Client<typeof Ticket, TicketDecorations>("/api/tickets");
const rows = await tickets.query({ controls: { $select: ["title", "ownerName"] } });
rows[0].ownerName; // string | undefined$select of query, pages, one, geoSearch and geoPages accepts the decoration keys beside the own fields, and rows carry them as optional properties. Filters stay over the own fields — a decoration is display-only, and the server answers a filter on one with a 400. meta() returns the declaration in decorations and flags each key with fields[key].decoration. Without the second generic nothing changes.
count
GET /query with $count: true — returns the number of matching records.
const total = await users.count({ filter: { role: "admin" } });aggregate
GET /query with $groupBy — typed aggregation. See Relations & Search — Aggregation.
const stats = await orders.aggregate({
controls: {
$groupBy: ["status"],
$select: [
"status",
{ $fn: "count", $field: "*", $as: "total" },
{ $fn: "sum", $field: "amount", $as: "revenue" },
],
},
});When $groupBy fields and $select are typed, the result type is inferred — stats[0].total is number, stats[0].status preserves the original field type. A $groupBy entry that is neither a field nor a bucket alias from $select is a type error (ValidGroupBy).
Arithmetic and first / last (since 0.1.148) go in $select in object form too; the client serializes them to expr(…), sum(<arith>), first(…) and $rowOrder, and types an expression alias as number | null and a first / last alias as its field's type:
const queue = await issues.aggregate({
filter: { status: "open" },
controls: {
$groupBy: ["ticketId"],
$select: [
"ticketId",
{ $fn: "count", $field: "*", $as: "open" },
{ $fn: "sum", $expr: { $op: "*", $args: ["price", "qty"] }, $as: "revenue" },
{ $expr: { $op: "/", $args: ["revenue", "open"] }, $as: "avg" },
{ $fn: "first", $field: "title", $as: "oldestTitle" },
],
$rowOrder: { raisedAt: 1 },
},
});
// queue[0].avg is number | null, queue[0].oldestTitle is stringOffer expression operands from meta.fields[path].numeric (and check meta.aggregateExpressions).
Field lists, conditions and two-level aggregates (since 0.1.168) are typed too. A $where is a filter on the model's fields — or, at the outer level of a two-level query, on the inner columns; a query with controls.$from is checked with ValidNested — an outer name that is not an inner column is a type error — and its rows are typed from the inner columns (NestedAggregateResult):
const spend = await orders.aggregate({
filter: { status: "paid" },
controls: {
$from: {
$groupBy: ["region", "customerId"],
$select: [
{ $fn: "sum", $field: "amount", $as: "spent" },
{ $fn: "countDistinct", $field: ["productId", "variant"], $as: "items" },
],
},
$groupBy: ["region"],
$select: [
"region",
{ $fn: "avg", $field: "spent", $as: "avgSpent" },
{ $fn: "count", $field: "*", $where: { items: { $gt: 1 } }, $as: "multiItem" },
],
},
});
// spend[0].region is string, spend[0].avgSpent is numberCheck meta.distinctFieldLists, meta.conditionalAggregates and meta.nestedAggregates before offering them. NestedAggregateQuery, NestedAggregateResult, StageRow, AggregateStage and ValidNested are re-exported from @atscript/db-client.
$groupBy: [] is the ungrouped aggregate: one row over the whole filtered set (since 0.1.155 against moost-db — the request carries the aggregate $select and no $groupBy). Plain fields cannot be selected then:
const [totals] = await orders.aggregate({
filter: { status: "paid" },
controls: {
$groupBy: [],
$select: [
{ $fn: "sum", $field: "amount", $as: "revenue" },
{ $fn: "count", $field: "*", $as: "orders" },
],
},
});
// totals.revenue and totals.orders are number; over no matching row: { revenue: null, orders: 0 }Calendar buckets go in $select in object form; the client serializes them to bucket(…) (since 0.1.132):
const weekly = await tickets.aggregate({
filter: { openedAt: { $gte: from } },
controls: {
$select: [
{ $bucket: "week", $field: "openedAt", $tz: "Europe/Berlin", $as: "week" },
{ $fn: "count", $field: "*", $as: "n" },
],
$groupBy: ["week"],
$sort: { week: 1 },
},
});
weekly[0].week; // string — "YYYY-MM-DD", or "YYYY-MM-DDTHH:00" for an hour bucket (string | null when openedAt is optional)bucketSeries, nextBucketLabel and bucketStartInstant are re-exported for filling empty buckets and placing labels on a time axis — see Filling gaps. Check meta.bucketUnits and meta.fields[path].bucketable before offering time grouping in a UI.
pages
GET /pages — page-based pagination. See CRUD — GET /pages.
const page = await users.pages(
{ filter: { active: true } },
2, // page (default: 1)
25, // size (default: 10)
);
// → { data: [...], page: 2, itemsPerPage: 25, pages: 10, count: 243 }one
GET /one/:id (scalar) or GET /one?key=val (object) — fetch by any registered identification: primary key, single-field unique index, or compound unique index. Returns null on 404. See CRUD — GET /one.
// Scalar — resolves through every identification (PK + every unique index).
// When the table declares an explicit `@db.table.preferredId.uniqueIndex`,
// the scalar lookup is restricted to that field for determinism.
const user = await users.one("abc-123");
// Object — recommended for non-PK lookups; deterministic (avoids any ambiguity
// when the same scalar could match multiple unique fields). The client routes
// any object id to the named-form `/one?key=val` endpoint.
const userByName = await users.one({ username: "admin" });
const row = await users.one({ tenantId: "t1", userId: "u1" });Supports controls for projection and relation loading:
const user = await users.one("abc", {
controls: { $select: ["id", "name"], $with: ["posts"] },
});Relation Loading
Load relations using $with in controls. See Relations & Search for full syntax.
const orders = await client.query({
controls: { $with: ["customer", "items"] },
});The row type narrows by the $with you pass — no as const needed (since 0.1.165): orders[0].customer is typed, relations you did not list are absent from the type, and the row's own fields keep their declared types. (Before 0.1.165, on a table with navigation properties every field was typed unknown, and a $with needed as const.)
Cancelling a request
Every read and write method takes a last, optional { signal } (since 0.1.165) — query, count, aggregate, pages, geoSearch, geoPages, one, insert, update, replace, remove:
const ctrl = new AbortController();
const rows = users.query({ filter: { status: "active" } }, { signal: ctrl.signal });
ctrl.abort(); // rows rejects with a TransportError whose `aborted` is trueAn aborted request rejects with a TransportError whose aborted is true (also for AbortSignal.timeout()); a network failure has aborted: false. meta() is shared by every call of the client and takes no signal.
Write Operations
Write methods are available when the server uses AsDbController (not AsDbReadableController).
insert
POST / — insert one or many records. See CRUD — POST /.
// Single insert → { insertedId }
const { insertedId } = await users.insert({
name: "Alice",
email: "alice@example.com",
});
// Batch insert → { insertedCount, insertedIds }
const { insertedCount } = await users.insert([{ name: "Alice" }, { name: "Bob" }]);On a composite primary key insertedId is the key object (since 0.1.169) — pass it to one() as is: await orderLines.one(insertedId) reads GET /one?orderId=1&productId=2. See Composite primary keys.
Pass { onConflict: "ignore" } (since 0.1.148) to skip rows that collide on the primary key or a unique index — it sends POST /?$onConflict=ignore (details):
const one = await users.insert({ id: 1, name: "Alice" }, { onConflict: "ignore" });
// { insertedId: 1, conflict: false } or { conflict: true }
const many = await users.insert(
[
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
],
{
onConflict: "ignore",
},
);
// { insertedCount, insertedIds, inserted, conflicts } — one slot per input row, by indexPass { returning: true } (since 0.1.168) to get the stored row back — server defaults and stamps included, shaped as one() answers it — in the same request. It sends Prefer: return=representation (details); row is absent when the server did not apply it (the controller has not opted in, the caller may not read the row, a skipped conflict):
const { insertedId, row } = await users.insert({ name: "Alice" }, { returning: true });
row?.createdAt; // typed like a one() rowPayloads are typed as InsertOf<T> (since 0.1.168; PatchOf<T> before) — every key optional, optional columns also accept null (explicit NULL). A value of an interface type fits, the generated model type or your own (users.insert(user) with user: User); on an untyped Client any object does. On a versioned table the version column is server-managed: leave it out (preflight accepts its absence since 0.1.128) — a value passes through and is stored as sent. $cas is rejected on insert with a ClientValidationError at path $cas.
update
PATCH / — partial update. Include the primary key and changed fields only. See CRUD — PATCH /.
// Single or bulk → { matchedCount, modifiedCount }
await users.update({ id: "abc", name: "Updated" });
// Bulk
await users.update([
{ id: "a", status: "active" },
{ id: "b", status: "active" },
]);Supports field operations like $inc, $dec, $mul. Payloads are typed as PatchOf<T>: every key optional, and since 0.1.168 the operators are typed ({ id, attempts: { $inc: 1 } }, { id, tags: { $insert: ["x"] } }, $cas) and an interface-typed value fits.
For tables with @db.column.version, round-trip the version field — the server auto-lifts it to $cas and returns 409 on conflict. See OCC over HTTP and the versioning guide.
import { VersionMismatchError, ClientError } from "@atscript/db-client";
const row = await users.one("abc");
try {
await users.update({ id: "abc", name: "Updated", version: row.version });
} catch (err) {
if (err instanceof VersionMismatchError) {
// Row moved on; re-read and retry. err.currentVersion is the new version.
} else if (err instanceof ClientError && err.status === 404) {
// Row was deleted.
} else {
throw err;
}
}The client throws VersionMismatchError (a ClientError subclass) automatically whenever the server response carries kind: "version_mismatch" — instanceof is the recommended discriminator since @atscript/db-client 0.1.84. On older versions, inspect err.body?.kind === "version_mismatch" and err.body.currentVersion directly.
Since 0.1.128 the SDK shape works too: users.update({ id, $cas: { version: row.version } }) is lifted to the wire version field before preflight and sending, so code shared with the server SDK needs no rewrite. The lift is validated with the same rules as the server (ClientValidationError before any request is sent):
| Payload | Result |
|---|---|
{ id, $cas: { version: 4 } } | sent as { id, version: 4 } |
{ id, version: 4, $cas: { version: 3 } } | ClientValidationError at $cas (Ambiguous version: … differ) |
{ id, $cas: { version: 4 } } on a non-versioned table | ClientValidationError at $cas (no @db.column.version) |
{ id, $cas: { v: 4 } } / non-integer value | ClientValidationError at $cas.v / $cas.version (shared messages) |
| array bodies | per item; paths are prefixed [i]. |
A PK-only update({ id, version }) (or $cas) is a real write — the server bumps the version on a hit. See OCC over HTTP.
replace
PUT / — full document replace. All required fields must be present. See CRUD — PUT /.
await users.replace({
id: "abc",
name: "Alice",
email: "new@example.com",
role: "admin",
});
// Bulk
await users.replace([...]);replace accepts the same version round-trip as update — same 409 behavior, same $cas semantics (and the same $cas lift). Catch conflicts with instanceof VersionMismatchError exactly as shown above. Payloads are typed as RowOf<T>: required columns stay required, optional ones also accept null; the version column itself is optional in replace preflight.
remove
DELETE /:id — remove by primary key. See CRUD — DELETE.
// Scalar PK
const { deletedCount } = await users.remove("abc");
// Composite PK
await users.remove({ tenantId: "t1", userId: "u1" });Metadata
GET /meta — fetch table/view metadata. The result is cached after the first call; action(), getActionForm() and write validation reuse it.
const meta = await users.meta();/meta is projected for the requesting user. When the identity changes (login, logout, a role change) and you keep the same Client instance, call invalidateMeta() (since 0.1.151): it drops the cached /meta, the validator built from it and the loaded action forms, and the next call fetches them again.
await signIn(otherUser);
users.invalidateMeta();The payload is documented field by field in CRUD Endpoints — GET /meta. To tell whether the table is read-only, derive it from meta.crud — see Permissions — Read-only check.
Revalidating /meta — the meta store
Since 0.1.153 the client keeps every /meta response that carries an ETag in a meta store, keyed by that ETag. When a client loads /meta and the store already holds bodies for that URL, it sends their ETags as If-None-Match; the server answers 304 Not Modified when one of them is still current, and the client reuses the stored body instead of downloading it again. A changed or differently projected /meta simply comes back as a 200 with a new ETag. moost-db sends the ETags by default — see Caching and revalidation.
It is on by default, with one store shared by every Client in the page, so you get:
- A new
Clientfor an already-loaded controller revalidates instead of downloading. invalidateMeta()drops the client's own copy, and the nextmeta()revalidates. An unchanged/metathen costs a304, not the full body.
The store is a cache only. The server decides on every request whether a stored body is still valid, so a stale or foreign body is never used without its confirmation. Error responses and network failures are never stored. The store keeps at most 50 bodies (least recently used are dropped first).
Parametric mounts — metaKey
By default the store remembers ETags per /meta URL, so /api/tenant/acme/todos/meta and /api/tenant/globex/todos/meta are separate entries, and the first visit to each one downloads the full body. When one controller is mounted under a route param (see Multi-Tenant Route Recipe), its /meta is usually the same for every param value. Give those clients one metaKey, such as the route template, and every new param value revalidates against the body already loaded for another:
const todosFor = (tenantId: string) =>
new Client<typeof Todo>(`/api/tenant/${tenantId}/todos`, {
metaKey: "/api/tenant/:tenantId/todos",
});
await todosFor("acme").meta(); // 200, stored
await todosFor("globex").meta(); // If-None-Match → 304, body reusedA shared key is safe even when the server's /meta does depend on the param. The server answers 304 only when the bytes it would send match a stored ETag, and a different /meta comes back as a 200 and is stored next to the first one.
Keying derived data by ETag — metaEtag()
client.metaEtag() returns the ETag of the /meta body meta() resolved to (since 0.1.153). Equal ETags mean byte-identical bodies, so it is a safe cache key for anything you derive from /meta once — deserialized types, validators, column definitions — and share across clients and route params:
const meta = await todos.meta();
const key = todos.metaEtag(); // e.g. 'W/"5f2c…"', or undefined
const type = (key && typeCache.get(key)) ?? deserializeAnnotatedType(meta.type);
if (key) typeCache.set(key, type);It is undefined before meta() resolves, after invalidateMeta(), and when the response has no readable ETag (cross-origin without Access-Control-Expose-Headers: ETag); fall back to per-client caching then. It works with metaStore: false too.
Identity changes — clearing the store
On sign-out, or any change of the signed-in user, clear the store along with the clients' own copies:
import { clearMetaStore } from "@atscript/db-client";
await signIn(otherUser);
clearMetaStore(); // the shared default store
users.invalidateMeta(); // each client you keepReusing a body across users is not a correctness risk with moost-db: its ETag is computed from the response bytes, so a 304 for the new user means their /meta is byte-for-byte the stored one. Clearing frees the memory and avoids sending the previous user's ETags. Do it anyway if the server computes ETags some other way.
The client's store — metaStore
client.metaStore (since 0.1.155) is the store the client revalidates through: the shared default store (exported as defaultMetaStore), the one you passed as metaStore, or undefined for metaStore: false. It is read-only. Use it when a client was handed to you (for example by a factory) and its store must be cleared on an identity change:
client.invalidateMeta();
client.metaStore?.clear();Own store or no store
import { Client, MetaStore } from "@atscript/db-client";
// A separate store for a group of clients (e.g. per tenant workspace, or in SSR per request)
const metaStore = new MetaStore({ maxEntries: 20 });
const users = new Client<typeof User>("/api/users", { metaStore });
metaStore.clear(); // forget everything in this store
// Always download /meta, no If-None-Match
const audit = new Client<typeof Audit>("/api/audit", { metaStore: false });| Export | Description |
|---|---|
new MetaStore({ maxEntries?, unconditionalMs? }) | A store; maxEntries bounds the kept bodies (default 50); unconditionalMs is how long a store key skips If-None-Match after a refused conditional request (default 5 minutes) |
metaStore.clear() | Drops every stored body and remembered ETag |
metaStore.candidates(key) | The ETags that would be sent as If-None-Match for a store key (most recent first) |
metaStore.size | Number of stored bodies |
client.metaStore | The store a client revalidates through (undefined with metaStore: false); read-only, since 0.1.155 |
clearMetaStore() | Clears the shared default store |
defaultMetaStore | The shared default store, since 0.1.155 — client.metaStore === defaultMetaStore tells a client on the shared store from one with its own |
Cross-origin servers
When the API is on another origin, the browser hides the ETag header unless the server sends Access-Control-Expose-Headers: ETag, and If-None-Match needs the preflight to allow it (Access-Control-Allow-Headers: If-None-Match, plus any auth headers you already send). Without the exposed ETag the client stores nothing and behaves as before: one full /meta per client. If the preflight rejects If-None-Match, the client retries that request without it and stops sending it for that store key for 5 minutes (unconditionalMs), then tries a conditional request again. meta() does not fail because of the store.
Actions
action<R>() invokes any declared action on the controller by name. The client reads /meta (cached), looks up the action descriptor, then dispatches based on processor.
client.action<R>(name: string, id?: ..., input?: unknown): Promise<R>The identifier is object-only — single object for 'row' actions, array of objects for 'rows' actions, omitted for 'table' actions. Even single-field PK tables send { id: "abc" }, never bare "abc". The third input argument carries the action's @InputForm payload — see Form input. The wire body is the envelope { ids?, input? }; the client wraps your call's args into it.
// processor: 'backend', level: 'row' — POST { "ids": { "id": "abc123" } }
const result = await users.action("block", { id: "abc123" });
// → { message: "User abc123 blocked" }
// level: 'rows' — pass an array of identifier objects
await users.action("lock", [{ id: "a" }, { id: "b" }]);
// composite PK
await members.action("promote", { tenantId: "acme", userId: "u1" });
// unique-index addressing (same controller, different identification)
await users.action("promote", { email: "jane@example.com" });
// level: 'table' — no identifier, no input → no body sent
await users.action("refresh-cache");
// processor: 'navigate' — substitutes $1 with preferredId and navigates
await users.action("edit", { slug: "alpha" }); // → /users/alpha/edit
// @InputForm payload (third arg) — POST { "ids": ..., "input": ... }
await users.action("approve", { id: "o1" }, { note: "looks good" });
// Table-level + @InputForm (no id) — POST { "input": ... }
await users.action("broadcast", undefined, { message: "hi" });
// Typed return shape
const r = await users.action<{ message: string }>("block", { id: "abc" });
r.message; // typedThe <R> return-type generic asserts the server handler's response shape (commonly { message?: string, ... } per convention). Default R = unknown.
action() is always POST for processor: 'backend'. The path comes from the meta builder — method-decorator actions resolve to the bound HTTP path; class-level backend actions use the dev-supplied path verbatim.
Form-schema discovery — getActionForm()
When an action declares @InputForm(), /meta carries an inputForm field with the form's name. getActionForm(name) lazily fetches the schema from GET <controller>/meta/form/<inputForm>, deserializes it via deserializeAnnotatedType, and returns the TAtscriptAnnotatedType ready to hand to a form renderer.
const meta = await users.meta();
const action = meta.actions.find((a) => a.name === "approve");
if (action?.inputForm) {
const form = await users.getActionForm("approve");
// → TAtscriptAnnotatedType, ready for @atscript/ui form components
// Render the form, collect input, then:
await users.action("approve", { id: "o1" }, collectedInput);
}When the action also carries formUrl (a class-level form served by another controller), the schema is fetched from baseUrl + formUrl instead.
Returns null when the action has no inputForm, or the action name isn't on /meta. Cached per resolved URL on the client instance — repeated calls for the same form make only one HTTP request. Failed fetches are evicted from the cache so retries can re-fetch.
Since 0.1.170, getActionFormTexts(name) returns the texts the form was served with in the client's locale — its $i18n: the registered message templates and the translated literals of its expressions — or undefined when there are none. It reads the same cached response, so it costs no extra request. Merge them into your UI texts to show the form's expression results in the user's language (@atscript/ui action forms do this for you).
Available actions for one row — availableActions()
Since 0.1.145. availableActions(id) asks the server which row-level actions the caller may run on one row right now, including rows the caller cannot read. It calls GET /meta/actions/:id; an object id uses the composite form ?k1=v1&k2=v2. Id forms are the same as one():
const { actions, disabledReasons } = await orders.availableActions("o2");
// actions: ["edit"]; disabledReasons: { ship: "Order already shipped" }An unknown id and an id the caller may not act on both resolve to { actions: [] }. Use it for a detail view opened from a link or a notification. On lists, $actions gives the same answer per row, as part of the read.
Delegated actions — idMap
Since 0.1.147. An action listed by a view with @DbActionsFrom carries owner (the controller that runs it) and, when the ids are renamed, idMap. Pass the view's rows as usual — action() maps each one to the owner's identification ({ [ownerField]: row[path] }) before POSTing to value, and navigate $1 uses the mapped id in idMap key order. A dot path reads a nested value ({ org: { id } }) or a flat key spelled like the path ({ "org.id": … }, checked first). A row missing a mapped path throws TypeError naming the action and the path.
const board = new Client<typeof IssueBoard>("/api/issue-board");
const rows = await board.query({ controls: { $actions: true } as const });
await board.action("close", [rows[0], rows[1]]); // POST /api/issues/actions/close { ids: [{ id: … }, …] }actionIdentifier(action, rowOrId, preferredId) is the same mapping as a standalone export (with no idMap it picks the preferredId fields) — use it when a UI builds ids itself. availableActions(id) on the view answers delegated actions only when the source id is the view id renamed; otherwise ask the owner.
Query targets — actionOnQuery() / countActionTarget()
Since 0.1.147. Run a 'rows' action whose /meta entry carries queryTarget on every row matching a query (see Query targets):
const target = { filter: { teamId: "a" }, search: "login", exclude: [{ id: 7 }] };
const { matched } = await issues.countActionTarget("close", target); // dry run
const summary = await issues.actionOnQuery("close", { ...target, expectCount: matched });
// → { matched, processed, skipped: [{ id, reason? }], failed: [{ id, reason }], aborted?, messages?, message? }- The target is
{ filter?, search?, index?, exclude?, expectCount?, maxRows? }(TDbQueryTarget<T>); the client sends{ query: { q, … }, input? }withqbuilt fromfilter/search/index. - The request goes to
queryTarget.urlwhen present (a delegated action: the view's route), else tovalue. - An action without
queryTargetthrowsActionUnsupportedErrorbefore any request. - Server refusals throw
ActionTargetErrorwithcode(TARGET_INVALID,TARGET_TOO_LARGE,TARGET_CHANGED),capandmatched. OnTARGET_CHANGED, re-confirm with the newmatchedand retry. - The return type defaults to
TDbActionTargetSummary; pass a type argument when the handler returns something else. - A run that stopped part-way answers normally (no throw) with
aborted: { status, message }; the batches before it stayed applied andfailedlists the rest. Show it as a partial result, not a success. - A delegated run passes on the source handler's
message:messagesper batch,messagethe distinct ones joined by newlines.
Client-side validation
The client refuses obviously-wrong shapes BEFORE the network round-trip:
'row'level + non-object (scalar,null, array) forid→TypeError.'rows'level + non-array (single object included — no auto-wrap) forid→TypeError.inputisunknownat the type level — the client does not validate it. The caller's responsibility is to match the action'sinputFormschema; server-side validation depends on a Moost atscript validator pipe being installed (see Actions — Validation).
The TypeScript signature catches the id-shape cases at compile time when Client<typeof T> is used; untyped Client<> clients fall back to Partial<Record<string, unknown>> and get only the runtime guard.
When the server's disabled gate rejects, action() throws ActionDisabledError (HTTP 409) — see Error cases below.
Navigate dispatch
By default, navigate actions call window.location.assign(url). Inject a SPA router via the navigate option:
import { useRouter } from "vue-router";
const router = useRouter();
const users = new Client<typeof User>("/api/users", {
navigate: (url) => router.push(url),
});
await users.action("edit", { slug: "alpha" }); // → router.push('/users/alpha/edit')For 'row'-level navigate, the client substitutes $1 by walking meta.preferredId declaration order — NOT object-key insertion order. Each value is encodeURIComponent'd, compound preferred-ids are joined with /. Missing fields render as empty segments (e.g. acme//jane), not the literal "undefined".
// preferredId = ['tenantId', 'userId']
await users.action("edit", { userId: "jane", tenantId: "acme/co" });
// → navigate('/members/acme%2Fco/jane/edit') — order from preferredId, not object keysFor level: 'rows' and level: 'table' navigate actions, value is used verbatim — no $1 substitution.
Identifier rendering helpers
The same identifier-to-string logic the client uses internally for $1 substitution is exported as standalone helpers. Reach for these when you need to render a row identifier outside Client.action() — prompt text in a confirm dialog, log lines, deep-link copy, audit messages.
import { formatIdentifier, encodeNavigateId, formatIdentifierField } from "@atscript/db-client";
// Raw form (no URL encoding) — for prompt text, error messages, logs.
formatIdentifier({ tenantId: "acme/co", userId: "jane" }, ["tenantId", "userId"]);
// → "acme/co/jane"
// URL-encoded form — same logic Client.action() applies for navigate $1.
encodeNavigateId({ tenantId: "acme/co", userId: "jane" }, ["tenantId", "userId"]);
// → "acme%2Fco/jane"
// Single-value coercion (null / undefined → "", primitives via String,
// objects/arrays via JSON.stringify).
formatIdentifierField(undefined); // ""
formatIdentifierField(123n); // "123"
formatIdentifierField({ a: 1 }); // '{"a":1}'| Helper | Encoding | Use for |
|---|---|---|
formatIdentifier | none | Prompt text, error messages, log lines, dialog titles |
encodeNavigateId | URL-encode | Navigate-URL templates (only when building deep links outside actions) |
formatIdentifierField | none | Single-value coercion with null/undefined → "" semantics |
Error cases
import {
ActionNotFoundError,
ActionUnsupportedError,
ActionDisabledError,
ClientError,
} from "@atscript/db-client";
try {
await users.action("ship", { id: "abc" });
} catch (e) {
if (e instanceof ActionNotFoundError) {
/* action name not in /meta */
}
if (e instanceof ActionUnsupportedError) {
/* processor: 'custom' (handle the event yourself), or
processor: 'navigate' with no browser env and no navigate option */
}
if (e instanceof ActionDisabledError) {
/* HTTP 409 — server-side disabled gate rejected the row(s).
Typed accessors layered on top of ClientError: */
e.action; // "ship"
e.id; // { id: "abc" } (row-level rejection — submitted identifier object)
e.ids; // [...] (rows-level rejection — full list of failing identifier objects)
e.reason; // "Order already shipped" — shared reason, when the predicate returned one
e.reasons; // ["Locked", null] — rows-level, aligned with e.ids (null = no reason)
} else if (e instanceof ClientError) {
/* any other server non-2xx — same shape as other endpoints */
}
}ActionDisabledError extends ClientError, so a generic instanceof ClientError catch still handles gate rejections — use the typed branch when you want e.action / e.id / e.ids / e.reason / e.reasons without indexing into body. When the server's predicate returned a reason, e.message is already that reason — show it as is. See Actions — Server-side Gate for the server-side declaration.
processor: 'custom' actions cannot be invoked through the client — those describe UI events your application dispatches itself. The client throws ActionUnsupportedError in that case.
Success response convention
Backend action handlers may return any JSON. Convention: if the response has { message: string }, the UI toasts it; otherwise the UI uses a generic per-level message. See Actions — Success response for the server side.
const result = await users.action<{ message?: string }>("block", { id: "abc" });
if (result?.message) toast(result.message);
else toast("Action completed");Per-row action availability — $actions=true
Add $actions: true to any read-method controls to ask the server which row/rows-level actions each returned row qualifies for. The server runs every row/rows-level disabled predicate against the returned rows inside that action's scope and attaches $actions: string[] (action names that did NOT reject the row) to each row.
const r = await users.query({
filter: { active: true },
controls: { $actions: true } as const,
});
r[0].$actions; // string[] | undefined (typed via ClientResponse<T, Q>)
// Pages and one() too
const page = await users.pages({ controls: { $actions: true } as const }, 1, 25);
page.data[0].$actions;
const single = await users.one({ id: "abc" }, { controls: { $actions: true } as const });
single?.$actions;
// Actions disabled WITH a reason (predicate returned a string) — action name → reason
r[0].$disabledReasons?.ship; // Record<string, string> | undefined$disabledReasons is present only on rows where some action was disabled with a reason; its keys never appear in $actions. See Actions — $disabledReasons.
NOT augmented on count() and aggregate() — no row shape. 'table'-level actions never appear in $actions. Action ordering follows /meta.actions[] declaration order.
See Actions — $actions=true for the full server-side pipeline (overlay filtering, requiredFields-driven projection widening, length-mismatch handling).
Error Handling
Non-2xx responses throw a ClientError with the HTTP status and structured error body. The error shape matches the server's error response format.
import { Client, ClientError } from "@atscript/db-client";
try {
await users.insert({ name: "" });
} catch (e) {
if (e instanceof ClientError) {
e.status; // 400
e.message; // "Validation failed"
e.errors; // [{ path: "name", message: "required" }]
e.body; // full server error response
}
}one() is the exception — it returns null on 404 instead of throwing.
With a translator on the server, messages come in the requested locale, and preflight errors are translated with the served templates; code and params stay the same in every language.
A DbError reaches the client with its code (since 0.1.165): e.body.code is CONFLICT, FK_VIOLATION, …, and each entry of e.errors carries a code (UNIQUE, FK_MISSING, FK_RESTRICT) and params to build a translated message from — see Constraint violations. moost-db's own refusals are coded the same way (since 0.1.175): e.body.code / e.body.params and each entry's code / params — UNKNOWN_FIELD, FIELD_NOT_PERMITTED, INVALID_QUERY, VERSION_MISMATCH, … (see Request error codes). Branch on code, never on the message.
A handler may answer an error body with fields of its own (quota, tier, …). ServerError admits extra fields, and ClientError<B> types them (since 0.1.165):
interface QuotaBody extends ServerError {
code: "quota_reached";
limit: number;
}
if (e instanceof ClientError && e.body.code === "quota_reached") {
const { limit } = (e as ClientError<QuotaBody>).body;
}When the error response is not JSON (a proxy or gateway page), e.body is { message, statusCode } with message = the HTTP status text, or HTTP <status> when the response carries none (HTTP/2 has no status text) — e.body.message is never empty.
TransportError — no server verdict
A ClientError means the server answered and rejected. A TransportError (since 0.1.129) means there is no verdict at all: fetch itself rejected (network down, DNS, CORS, an AbortError from an AbortSignal), or a 2xx response carried a body that is not JSON. The request may or may not have reached the server, so a write may have committed — reload the row before retrying a non-idempotent write instead of blindly re-sending it.
import { Client, ClientError, TransportError } from "@atscript/db-client";
try {
await users.update({ id: 7, status: "active", version: 3 });
} catch (e) {
if (e instanceof ClientError) {
// the server rejected — e.status / e.errors are authoritative
} else if (e instanceof TransportError) {
e.method; // "PATCH"
e.url; // full request URL
e.cause; // the original error (e.g. cause.name === "AbortError")
// unknown outcome: re-read the row, then decide whether to retry
const current = await users.one(7);
if (current?.version === 3) await users.update({ id: 7, status: "active", version: 3 });
}
}TransportError does not extend ClientError; it exposes method, url, the standard cause, and aborted (since 0.1.165) — true when the request was cancelled through a signal rather than lost.
Client-Side Validation
Write methods (insert, update, replace) automatically validate data client-side against the Atscript type fetched from /meta. This catches type errors before they reach the server.
// Throws ClientValidationError before sending the request
await users.insert({ name: 123 }); // name must be stringClientValidationError is a ClientError (since 0.1.165): status 400, body shaped like the server's validation envelope ({ message, statusCode: 400, errors }), and preflight: true — so one instanceof ClientError branch shows field errors from the preflight and from the server alike. It is exported from @atscript/db-client (and, as the same class, from @atscript/db-client/validator).
Before it validates, the preflight applies the served type's @normalize.* annotations, the way the server's write does (since 0.1.170), and sends the normalized data: what the client checks is what the server stores. Patch operators and nested relation data are included; the caller's object is not changed.
// email: @normalize.trim @normalize.lowercase
await users.insert({ email: " Ann@Example.COM ", name: "Ann" });
// sends { email: "ann@example.com", name: "Ann" }Access the validator directly for form generation or custom validation:
const validator = await users.getValidator();
validator.flatMap; // Map of field paths → annotated types
validator.navFields; // Set of navigation field names
const data = validator.normalize(input, "insert"); // @normalize.* applied (since 0.1.170)
validator.validate(data, "insert"); // throws on failurevalidate checks data as given; call normalize first, as the write methods do.
Server-managed fields follow the server's rules, read from the annotations the /meta type keeps: insert and replace accept a missing @db.default*, @db.rel.FK or @db.column.version field (since 0.1.128) and a missing derived column (since 0.1.142), replace a missing @db.onUpdate.now field (since 0.1.156 — number.timestamp.updated is also @db.default*, so insert may omit it too), insert and replace a missing @db.onUpdate.inc counter (since 0.1.162), and $inc / $dec / $mul on a derived column is a ClientValidationError before any request is sent (since 0.1.142).
// customerId / amount are @db.column.derived (required in the type)
await orders.insert({ id: 1, status: "open", payload: { customer: { id: "c1" }, total: 3 } }); // ok
await orders.update({ id: 1, amount: { $inc: 1 } }); // ClientValidationError at "amount"For form and plugin code, @atscript/db/validator exports isServerStampedOnUpdate(metadata) and the SERVER_STAMPED_ON_UPDATE_KEYS set behind it (since 0.1.163): true when a field carries @db.onUpdate.now or @db.onUpdate.inc, i.e. the server overwrites it on every update — e.g. to show it read-only in an edit form (isServerStampedOnUpdate(validator.flatMap.get("updatedAt")!.metadata)).
Patch preflight is merge-aware (since 0.1.124), mirroring the server's update validation exactly: a nested @db.patch.strategy 'merge' block validates as a deep partial — absent required keys (e.g. server-stamped fields) pass, present keys are still type-checked — while non-merge nested objects keep full validation, since they are $set as a whole. Insert and replace always validate the full shape.
Lenient writes (projected metas)
When the served /meta type is a projection of the full server-side type (e.g. an ARBAC read overlay strips fields the caller may write but not read), strict preflight rejects legitimate writes carrying those fields. Opt into tolerance for unknown properties on writes — required fields and formats stay enforced, and the server remains authoritative:
const users = new Client<typeof User>("/api/users", { lenientWrites: true });
// or standalone:
const validator = createClientValidator(meta, { lenientWrites: true });Leave it off elsewhere — strict preflight catches typos. Note that servers on @aooth/arbac-moost ≥ 0.1.57 stamp write-granted fields into the served type as writeOnly instead of stripping them, which removes the need for this flag in most setups.
Re-exports
Query types (from @uniqu/core)
Uniquery,UniqueryControls— query and control typesFilterExpr— filter expression typeAggregateQuery,AggregateResult— aggregation typesBucketExpr,BucketUnit,WeekStart,CalendarBucketLabel— calendar-bucket entry and label types (since 0.1.132)ValidGroupBy— the$groupBycheckaggregate()applies (since 0.1.132)TypedWithRelation— relation loading type
import type { FilterExpr, Uniquery } from "@atscript/db-client";Calendar-bucket helpers (from @uniqu/core, since 0.1.132)
bucketSeries(first, last, unit, { weekStart?, tz?, maxLength? })— every label fromfirstthroughlast(typeBucketSeriesOptions)nextBucketLabel(label, unit, { weekStart?, tz? })— the label of the following bucket (typeNextBucketOptions)bucketStartInstant(label, tz)— the first instant (epoch ms) of a label's local date or hour intz
See Calendar Buckets — Filling gaps.
Wire / shape types (from @atscript/db)
TDbActionInfo,TDbActionLevel,TDbActionIntent,TDbActionProcessor—/meta.actions[]entry shapeTDbAvailableActions—availableActions()response (since 0.1.145)TDbActionTargetSummary—actionOnQuery()result of a summary-returning handler (since 0.1.147)TDbQueryTarget<T>— theactionOnQuery()target (since 0.1.147)TCrudOp,TCrudPermissions—/meta.crudshape (see Permissions)TDbInsertResult,TDbInsertManyResult,TDbUpdateResult,TDbDeleteResult— write-method return shapes
Identifier helpers
Standalone exports of the same logic used internally for navigate $1 substitution — handy when rendering identifiers outside Client.action():
formatIdentifier(id, preferredId)— raw/-joined identifier (no URL encoding)encodeNavigateId(id, preferredId)— URL-encoded/-joined identifierformatIdentifierField(value)— single-value coercion (null/undefined→"")actionIdentifier(action, rowOrId, preferredId)— the ids an action takes for a row, through a delegated action'sidMap(since 0.1.147)
See Identifier rendering helpers for usage.
Errors
All error classes — generic and action-specific — are exported as runtime values for instanceof discrimination:
ClientError— base class for every non-2xx response.status,body,errorsaccessors.ActionDisabledError extends ClientError— HTTP 409 from the server-side action gate. Typedaction,id,ids,reason,reasonsaccessors.ActionTargetError extends ClientError— a refused query target (400 / 409). Typedcode,action,matched,capaccessors (since 0.1.147).TransportError— no server verdict:fetchrejected, or a 2xx body was not JSON.method,url,cause. Does not extendClientError. See TransportError.ActionNotFoundError—Client.action(name)called with a name not present in/meta.ActionUnsupportedError—processor: 'custom', orprocessor: 'navigate'with no browser env and nonavigateoption; alsoactionOnQuery()on an action withoutqueryTarget.ClientValidationError extends ClientError— thrown by client-side validation oninsert/update/replacebefore sending:status400,preflight: true(since 0.1.165 a runtime export of the main entry; before, type-only and not aClientError).
import {
ClientError,
TransportError,
ActionDisabledError,
ActionNotFoundError,
ActionUnsupportedError,
ClientValidationError,
} from "@atscript/db-client";Action error body shape
ActionDisabledError.body matches this wire envelope (HTTP 409):
{
name: "ActionDisabledError";
statusCode: 409;
message: string;
action: string; // action name that rejected
id?: Record<string, unknown>; // 'row'-level rejections
ids?: Record<string, unknown>[]; // 'rows'-level rejections
reason?: string; // reason shared by every rejected row (since 0.1.141)
reasons?: (string | null)[]; // 'rows'-level, aligned with ids (since 0.1.141)
}Method ↔ Endpoint Reference
| Method | HTTP | Endpoint | Returns |
|---|---|---|---|
query() | GET | /query | DataOf<T>[] |
count() | GET | /query ($count) | number |
aggregate() | GET | /query ($groupBy) | AggregateResult[] |
pages() | GET | /pages | PageResult<DataOf<T>> |
one() | GET | /one/:id or /one?k=v | DataOf<T> | null |
insert() | POST | / (?$onConflict=ignore) | TDbInsertResult / TDbInsertManyResult, or the …IgnoreResult shapes with onConflict: "ignore" |
update() | PATCH | / | TDbUpdateResult |
replace() | PUT | / | TDbUpdateResult |
remove() | DELETE | /:id or /?k=v | TDbDeleteResult |
meta() | GET | /meta | MetaResponse |
getActionForm() | GET | /meta/form/:name or formUrl | TAtscriptAnnotatedType | null |
getActionFormTexts() | — | same response as getActionForm() | Record<string, string> | undefined — the form's $i18n |
getValidator() | — | client-side; uses /meta | ClientValidator (lazy, cached) |
invalidateMeta() | — | client-side | void — drops the cached /meta, validator and action forms |
action() | POST | resolved from /meta | unknown (server response, or void for 'navigate') |
actionOnQuery() | POST | queryTarget.url or value | TDbActionTargetSummary (or the handler's response) |
countActionTarget() | POST | queryTarget.url or value (dry run) | { matched: number } |