Skip to content

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 ​

bash
pnpm add @atscript/db-client

Creating a Client ​

typescript
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
  • $sort keys are constrained to valid field names, and so are $nulls keys (since 0.1.153; sent as the URL suffix, $sort=-closedAt:last)
  • $with entries are constrained to declared navigation properties
  • Primary key type flows through one() and remove()
  • Insert/update data is checked against the model's field types

Options ​

typescript
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,
});
OptionTypeDescription
baseUrlstringPrepended to the path for every request
headersRecord<string, string> or () => Promise<Record<string, string>>Default headers for every request — factory is called before each request
fetchtypeof fetchCustom fetch implementation
navigate(url: string) => void | Promise<void>SPA-router hook for processor: 'navigate' actions (see Navigate dispatch)
metaStoreMetaStore | falseWhere /meta bodies are kept for revalidation — default: one store shared by all clients (see Revalidating /meta)
metaKeystringStore key for this client's /meta — default: the /meta URL (see Parametric mounts)
localestring or () => string | undefinedSent 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:

typescript
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:

typescript
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:

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

typescript
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():

typescript
// 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:

typescript
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:

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

typescript
const total = await users.count({ filter: { role: "admin" } });

aggregate ​

GET /query with $groupBy — typed aggregation. See Relations & Search — Aggregation.

typescript
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:

typescript
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 string

Offer 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):

typescript
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 number

Check 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:

typescript
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):

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

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

typescript
// 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:

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

typescript
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:

typescript
const ctrl = new AbortController();
const rows = users.query({ filter: { status: "active" } }, { signal: ctrl.signal });
ctrl.abort(); // rows rejects with a TransportError whose `aborted` is true

An 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 /.

typescript
// 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):

typescript
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 index

Pass { 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):

typescript
const { insertedId, row } = await users.insert({ name: "Alice" }, { returning: true });
row?.createdAt; // typed like a one() row

Payloads 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 /.

typescript
// 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.

typescript
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):

PayloadResult
{ 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 tableClientValidationError at $cas (no @db.column.version)
{ id, $cas: { v: 4 } } / non-integer valueClientValidationError at $cas.v / $cas.version (shared messages)
array bodiesper 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 /.

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

typescript
// 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.

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

typescript
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 Client for an already-loaded controller revalidates instead of downloading.
  • invalidateMeta() drops the client's own copy, and the next meta() revalidates. An unchanged /meta then costs a 304, 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:

typescript
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 reused

A 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:

typescript
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:

typescript
import { clearMetaStore } from "@atscript/db-client";

await signIn(otherUser);
clearMetaStore(); // the shared default store
users.invalidateMeta(); // each client you keep

Reusing 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:

typescript
client.invalidateMeta();
client.metaStore?.clear();

Own store or no store ​

typescript
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 });
ExportDescription
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.sizeNumber of stored bodies
client.metaStoreThe store a client revalidates through (undefined with metaStore: false); read-only, since 0.1.155
clearMetaStore()Clears the shared default store
defaultMetaStoreThe 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.

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

typescript
// 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; // typed

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

typescript
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():

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

typescript
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):

typescript
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? } with q built from filter / search / index.
  • The request goes to queryTarget.url when present (a delegated action: the view's route), else to value.
  • An action without queryTarget throws ActionUnsupportedError before any request.
  • Server refusals throw ActionTargetError with code (TARGET_INVALID, TARGET_TOO_LARGE, TARGET_CHANGED), cap and matched. On TARGET_CHANGED, re-confirm with the new matched and 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 and failed lists the rest. Show it as a partial result, not a success.
  • A delegated run passes on the source handler's message: messages per batch, message the 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) for id → TypeError.
  • 'rows' level + non-array (single object included — no auto-wrap) for id → TypeError.
  • input is unknown at the type level — the client does not validate it. The caller's responsibility is to match the action's inputForm schema; 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.

By default, navigate actions call window.location.assign(url). Inject a SPA router via the navigate option:

typescript
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".

typescript
// preferredId = ['tenantId', 'userId']
await users.action("edit", { userId: "jane", tenantId: "acme/co" });
// → navigate('/members/acme%2Fco/jane/edit') — order from preferredId, not object keys

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

typescript
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}'
HelperEncodingUse for
formatIdentifiernonePrompt text, error messages, log lines, dialog titles
encodeNavigateIdURL-encodeNavigate-URL templates (only when building deep links outside actions)
formatIdentifierFieldnoneSingle-value coercion with null/undefined → "" semantics

Error cases ​

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

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

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

typescript
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):

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

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

typescript
// Throws ClientValidationError before sending the request
await users.insert({ name: 123 }); // name must be string

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

typescript
// 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:

typescript
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 failure

validate 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).

typescript
// 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:

typescript
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 types
  • FilterExpr — filter expression type
  • AggregateQuery, AggregateResult — aggregation types
  • BucketExpr, BucketUnit, WeekStart, CalendarBucketLabel — calendar-bucket entry and label types (since 0.1.132)
  • ValidGroupBy — the $groupBy check aggregate() applies (since 0.1.132)
  • TypedWithRelation — relation loading type
typescript
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 from first through last (type BucketSeriesOptions)
  • nextBucketLabel(label, unit, { weekStart?, tz? }) — the label of the following bucket (type NextBucketOptions)
  • bucketStartInstant(label, tz) — the first instant (epoch ms) of a label's local date or hour in tz

See Calendar Buckets — Filling gaps.

Wire / shape types (from @atscript/db) ​

  • TDbActionInfo, TDbActionLevel, TDbActionIntent, TDbActionProcessor — /meta.actions[] entry shape
  • TDbAvailableActions — availableActions() response (since 0.1.145)
  • TDbActionTargetSummary — actionOnQuery() result of a summary-returning handler (since 0.1.147)
  • TDbQueryTarget<T> — the actionOnQuery() target (since 0.1.147)
  • TCrudOp, TCrudPermissions — /meta.crud shape (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 identifier
  • formatIdentifierField(value) — single-value coercion (null / undefined → "")
  • actionIdentifier(action, rowOrId, preferredId) — the ids an action takes for a row, through a delegated action's idMap (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, errors accessors.
  • ActionDisabledError extends ClientError — HTTP 409 from the server-side action gate. Typed action, id, ids, reason, reasons accessors.
  • ActionTargetError extends ClientError — a refused query target (400 / 409). Typed code, action, matched, cap accessors (since 0.1.147).
  • TransportError — no server verdict: fetch rejected, or a 2xx body was not JSON. method, url, cause. Does not extend ClientError. See TransportError.
  • ActionNotFoundError — Client.action(name) called with a name not present in /meta.
  • ActionUnsupportedError — processor: 'custom', or processor: 'navigate' with no browser env and no navigate option; also actionOnQuery() on an action without queryTarget.
  • ClientValidationError extends ClientError — thrown by client-side validation on insert / update / replace before sending: status 400, preflight: true (since 0.1.165 a runtime export of the main entry; before, type-only and not a ClientError).
typescript
import {
  ClientError,
  TransportError,
  ActionDisabledError,
  ActionNotFoundError,
  ActionUnsupportedError,
  ClientValidationError,
} from "@atscript/db-client";

Action error body shape ​

ActionDisabledError.body matches this wire envelope (HTTP 409):

ts
{
  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 ​

MethodHTTPEndpointReturns
query()GET/queryDataOf<T>[]
count()GET/query ($count)number
aggregate()GET/query ($groupBy)AggregateResult[]
pages()GET/pagesPageResult<DataOf<T>>
one()GET/one/:id or /one?k=vDataOf<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=vTDbDeleteResult
meta()GET/metaMetaResponse
getActionForm()GET/meta/form/:name or formUrlTAtscriptAnnotatedType | null
getActionFormTexts()—same response as getActionForm()Record<string, string> | undefined — the form's $i18n
getValidator()—client-side; uses /metaClientValidator (lazy, cached)
invalidateMeta()—client-sidevoid — drops the cached /meta, validator and action forms
action()POSTresolved from /metaunknown (server response, or void for 'navigate')
actionOnQuery()POSTqueryTarget.url or valueTDbActionTargetSummary (or the handler's response)
countActionTarget()POSTqueryTarget.url or value (dry run){ matched: number }

Released under the MIT License.