Documentation
TypeScript SDK — ZigBase
The official @zigbase/client TypeScript SDK — auth, records, offset + cursor pagination, files, realtime, and the live store.
The official TypeScript client (@zigbase/client) — zero dependencies; runs in browsers, Node 18+, Bun, Deno, and edge runtimes. It wraps the ZigBase HTTP REST + realtime WebSocket API (docs/api.md) in a typed, ergonomic surface: auth + stores, records, offset and cursor pagination, files, low-level realtime subscriptions, and a high-level live store.
Admission overload and retries
Normal SDK requests share one retry budget for HTTP 429 and ZigBase’s admission rejection: HTTP 503 with a JSON envelope whose top-level string code is "overloaded". Admission rejects before routing, so these recognized responses can be retried even for POST, PATCH, and DELETE without duplicating a write. Do not use this reserved code for application errors after side effects.
Retries honor a positive numeric Retry-After in seconds; absent or invalid values use exponential backoff starting at 200 ms, capped at 30 seconds. The configured budget counts retries, not the initial attempt; exhaustion raises the final normal SDK error. Generic 503s, malformed envelopes, and body-less responses (including overloaded HEAD requests) are not recognized and are not retried. Raw requests always make one attempt without retry or error mapping.
Install
npm install @zigbase/client
The SDK is published to npm as @zigbase/client. Its @zigbase/client/typed and @zigbase/client/realtime entry points ship in the same package. For the runtime generator, see Runtime introspection — no install needed beyond npx @zigbase/typegen (which pulls @zigbase/server).
Which tier should I use?
| Tier | Get it | Typing | Typed custom-route RPC | Needs Zig source |
|---|---|---|---|---|
| Base dynamic | npm install @zigbase/client | you pass <Type> | no | no |
| Runtime introspection | npx @zigbase/typegen | full (db/realtime/files) | no | no |
| Comptime-generated | zig build gen-client | full, from your schema | yes (zb.rpc.*) | yes |
All three share the same runtime (@zigbase/client); the generated tiers add fully-typed zb.db.* and zb.auth.* (the per-collection typed auth-method surface) on top — and, for comptime only, zb.rpc.* (typed custom routes).
Which one? Pick the tier by how much of the stack you own — they’re ordered above from least to most integrated:
- Base dynamic — reach for it when you just want to pull the client from a CDN (or a single
npm install) and hand-write your own types. Zero build wiring, zero codegen; you pass<Type>parameters yourself. Great for quick frontends, prototypes, and apps that consume a ZigBase backend you don’t control. - Runtime introspection — the middle ground when you’re not building a custom ZigBase backend (so there’s no Zig source to generate from) but still want a fully type-checked, optimized frontend.
npx @zigbase/typegenreads a running server (or its data dir) and emits typedzb.db.*/realtime/files. Norpc.*, since routes aren’t introspectable at runtime. - Comptime-generated — the biggest win if you want optimized, custom-tailored client code, especially when you’re already building a custom ZigBase backend in Zig.
zig build gen-clientreads your comptime schema and your registered routes, so you get fully-typedzb.db.*and typed custom-route RPC (zb.rpc.*) — generated straight from the source of truth, with no running server required.
Create a client
import { createClient, LocalAuthStore } from "@zigbase/client";
const zb = createClient("http://127.0.0.1:8090", {
authStore: new LocalAuthStore(), // browser-persistent; omit for SSR-safe in-memory
});
createClient(baseUrl, options?) returns a Client. Useful options:
| Option | Default | Purpose |
|---|---|---|
authStore | MemoryAuthStore | Where the token + auth record live. |
autoRefresh | false | Retry once on a 401 by refreshing the token (needs authCollection). |
authCollection | — | The auth collection used for automatic refresh (e.g. "users"). |
fetch | globalThis.fetch | Override the HTTP transport (exotic runtimes, tests). |
WebSocket | globalThis.WebSocket | Override the realtime transport. |
lang | — | Accept-Language for localized server errors. |
maxRetries | 3 | Shared retry budget for HTTP 429 and recognized admission overload (503 with JSON code: "overloaded"); 0 disables retries. |
Auth + stores
Three stores ship in the box:
MemoryAuthStore— the default. SSR-safe, never touches the DOM.LocalAuthStore— persists tolocalStorage; survives reloads in the browser.CookieAuthStore—exportToCookie()/loadFromCookie()for SSR handoff.
// password auth — saves { token, record } into the store on success
await zb.collection("users").authWithPassword("you@example.com", "secret");
zb.authStore.isValid; // decodes the JWT `exp` locally — UX hint only (see note)
zb.authStore.record; // the authenticated record
zb.authStore.token; // the raw JWT
// refresh + logout
await zb.collection("users").authRefresh();
await zb.collection("users").logout(); // clears the store
// react to login/logout/refresh anywhere
const off = zb.authStore.onChange((token, record) => {
console.log("auth changed", record?.id ?? "(signed out)");
});
OAuth2 (Authorization-Code + PKCE)
import { createPkceChallenge, randomState } from "@zigbase/client";
// Discover configured providers (name, authURL, clientId, scopes):
const { items: providers } = await zb.collection("users").listAuthProviders();
const { verifier, challenge } = await createPkceChallenge();
const state = randomState();
// 1. redirect the user to the provider authorize URL with `challenge` + `state`
// 2. on callback, exchange the code:
await zb.collection("users").authWithOAuth2({
provider: "github",
code,
codeVerifier: verifier,
redirectUrl: "https://app.example.com/callback",
state,
});
Verification + password reset
await zb.collection("users").requestVerification("you@example.com");
await zb.collection("users").confirmVerification(tokenFromEmail);
await zb.collection("users").requestPasswordReset("you@example.com");
await zb.collection("users").confirmPasswordReset(tokenFromEmail, "new-secret");
Changing a password (changePassword)
Requires ZigBase >= 0.10.0.
await zb.collection("users").changePassword(userId, "old-secret", "new-secret");
This is a self-service change while already logged in — distinct from the “forgot password” flow above. It rides PATCH /records/:id with { password, oldPassword }; the server verifies oldPassword against the target record (non-oracle: wrong/missing oldPassword both fail the same way) and rotates the record’s session epoch, so every other outstanding session dies. Behavior depends on your AuthStore:
- Cookie mode (
CookieAuthStore/ server setsSet-Cookie): the PATCH response itself re-issues fresh session cookies on a self-change — you stay logged in with no extra round trip. - Token mode (
MemoryAuthStore/LocalAuthStore): PATCH doesn’t hand back a new bearer token, so the helper transparently re-authenticates — it callsauthWithPasswordwith the store’s current identity and the new password right after the change, keeping the local store valid.
oldPassword also passes through plain update() for callers who want the raw updated record instead of changePassword’s void:
await zb.collection("users").update(userId, { password: "new-secret", oldPassword: "old-secret" });
Sessions (listSessions / revokeSession / revokeAllSessions)
Requires ZigBase >= 0.10.0. listSessions/revokeSession additionally require the server to run App(.{ .auth = .{ .session = .{ .store = .table } } }) — the default .epoch mode has no per-device state to list, and the call surfaces the server’s 404 as a standard ClientResponseError/ZigbaseError.
import type { SessionInfo } from "@zigbase/client";
const sessions: SessionInfo[] = await zb.collection("users").listSessions();
// newest first; sessions[i].is_current marks the one THIS request authenticated with
await zb.collection("users").revokeSession(sessions[1].id); // log out one other device
await zb.collection("users").revokeAllSessions(); // log out everywhere, incl. this device
SessionInfo:
interface SessionInfo {
id: string;
created: string;
last_seen: string;
user_agent: string;
ip: string;
is_current: boolean; // true for the session THIS request was authenticated with
}
revokeAllSessions() works in both session-store modes (it bumps the token epoch, and also wipes _sessions rows in table mode) and always clears the local AuthStore, even if the request fails — the same parity logout() has.
Security notes
isValidis not authorization. It decodes the JWTexpclaim client-side purely so the UI can pre-empt an expired session; it is never a security boundary. The server authorizes every request — never gate sensitive UI onisValidalone.localStoragetokens are exposed to XSS.LocalAuthStoreis the conventional SPA choice and accepts that tradeoff. For SSR or stricter setups preferCookieAuthStore, and when you write the cookie setsecure: true(HTTPS-only). A trueHttpOnlycookie (unreadable by JS) can only be set by the server on itsSet-Cookie— the client cannot.
import { CookieAuthStore } from "@zigbase/client";
// server: hand the token back to the browser as a hardened cookie
const store = new CookieAuthStore();
res.setHeader("Set-Cookie", store.exportToCookie({ secure: true, sameSite: "strict" }));
// SSR request handler: rehydrate from the incoming Cookie header
store.loadFromCookie(req.headers.cookie ?? "");
Account scoping (multi-tenancy)
Requires ZigBase >= 0.9.0 with .tenancy enabled.
Every request can carry an X-Account-Id header to scope reads/writes to one of the principal’s memberships. Two ways to set it:
// 1. Bake it into the client at creation time.
const zb = createClient(url, { accountId: "acc_123" });
// 2. withAccount(id) — a sibling client scoped to a (possibly different) account.
const scoped = zb.withAccount("acc_123");
await scoped.collection("notes").getList(); // every request carries X-Account-Id: acc_123
withAccount shares the same AuthStore as the client it was derived from — one principal, many scopes. Logging in or out on either view updates both. withAccount calls replace the account id rather than stacking: zb.withAccount("a").withAccount("b") is scoped to "b" only, not both.
// accounts.activate(id) — verify membership + set the zb_account cookie (browser apps).
const scope = await zb.accounts.activate("acc_123");
scope.account; // "acc_123"
scope.role; // the caller's role on that account
The
zb_accountcookie is a same-origin browser convenience only.activate()sets a signed cookie the server reads as a fallback when noX-Account-Idheader is present — handy for a same-origin SPA that just wants “switch active account” to persist across reloads. It does not work cross-origin, and the header always wins over the cookie when both are present. API clients and SSR code should preferwithAccount(id)/ theaccountIdoption instead — the SDK itself never reads the cookie.
Known limitation: no realtime tenant scoping. Browser WebSockets cannot carry custom headers, so
X-Account-Idhas no realtime equivalent — a subscribed connection’s delivery authorization is whatever the server’s connection-level resolution does, independent of anywithAccountscoping you’ve done on the REST side. This is a documented limitation, not a bug.
Typed tier. Tenant-owned collections carry a tenant?: string metadata field; the generated *Create/*Update payload types omit the tenant field entirely (the server stamps it from the active account and rejects cross-tenant moves — offering the key would only invite dead writes). It stays readable on the record interface and filterable in *Where/*Fields/sort. The generated client also gets withAccount(id) (rebuilding the typed facade over the base client’s withAccount) and a pass-through accounts service.
Records
The base SDK is dynamically typed: a plain ZbRecord has id: string and every other field typed unknown. To read fields safely, pass a type parameter on every read so the result is your shape — otherwise post.title won’t compile. (You can also cast, but the generic is cleaner.) Declare your row type by extending ZbRecord — it provides id plus the index signature the cursor methods (getPage/iterate/getFullList) require, so the same type works everywhere.
import type { ZbRecord } from "@zigbase/client";
interface Post extends ZbRecord {
title: string;
status: "draft" | "published";
author: string;
cover: string;
}
const posts = zb.collection("posts");
// list with filter + sort + relation expansion
const page = await posts.getList<Post>(1, 30, {
filter: "status = 'published'",
sort: "-created",
expand: "author",
});
page.items[0]?.title; // typed string
const one = await posts.getOne<Post>("REC123", { expand: "author" });
// create — multipart is auto-detected when the body contains a Blob/File
const made = await posts.create<Post>({ title: "Hi", status: "draft", author: "u1" });
const updated = await posts.update<Post>("REC123", { title: "Edited" });
await posts.delete("REC123");
// getFirstListItem — getList(1, 1) sugar; throws a 404 ZigbaseError when nothing matches
const draft = await posts.getFirstListItem<Post>("status = 'draft'");
For a server-enabled record mutation, pass an explicit idempotencyKey to create, update, or delete (including generated typed services):
const input = { title: "Hi", status: "draft", author: "u1" };
const key = crypto.randomUUID(); // preserve with the input until the outcome is known
const made = await posts.create<Post>(input, { idempotencyKey: key });
// After an uncertain network failure, retry the same call with the same key/input.
The SDK sends Idempotency-Key; it never generates a key. Supplying the key does not add automatic retries for network failures or receipt conflicts. Normal SDK retry behavior still applies: HTTP 429 and recognized admission-overload responses share the configured maxRetries budget, and an eligible 401 may trigger session refresh and a retry. The same key is preserved on those attempts. This differs from requestKey, which cancels duplicate in-flight client requests. Reuse a key only for the same operation and exact serialized body, within the server’s retention window. Authorization is checked again on replay; conflicts or revoked access must be handled by the application. Keyed mutations require the server’s explicit collection opt-in and supported JSON-only operation contract; files, auth records, and side-effect hooks are not covered. See REST record idempotency for configuration and replay restrictions.
Without a type parameter,
posts.getOne("REC123")returns aZbRecordwhose fields areunknown. That is the right shape for generic tooling, but to read.titleyou must supply<Post>(as above) or cast — TypeScript will reject a bare field access onunknown.
Safe filters
Build filter strings without injection using the filter tagged template. Interpolated values are always single-quoted and escaped against the server lexer (', \, and newline/tab/CR are backslash-escaped), numbers/booleans inline, and Date becomes an ISO string. Any string is representable, including values that mix both quote characters.
import { filter } from "@zigbase/client";
const q = userInput;
const f = filter`status = && author ~ `;
// => status = 'published' && author ~ '…'
const list = await posts.getList<Post>(1, 30, { filter: f });
// Mixed quotes are fine — single-quote the value and escape only the single quotes:
filter`title = `;
// => title = 'he said "hi" to O\'Brien'
Injection safety. The closing single quote can only appear escaped, so an interpolated value can never break out of its literal — even
' || 1=1 --becomes one inert token.
Search & vector
Requires ZigBase >= 0.9.0.
getList (and every list-ish read — getPage / iterate / getFullList) accepts search for full-text search (FTS5 / Postgres FTS) and vector for nearest-neighbor search on -Dvector server builds. Both work in offset mode; vector is offset-only (the server rejects it in cursor mode).
// Full-text search — ANDs with `filter`; works in offset AND cursor mode.
const hits = await posts.getList<Post>(1, 30, { search: "zig sqlite" });
// Vector search — structured query + the exported vectorSpec builder, offset mode only.
import { vectorSpec, type VectorQuery } from "@zigbase/client";
const q: VectorQuery = { field: "embedding", metric: "cosine", values: [0.12, 0.34, /* … */] };
const nearest = await posts.getList<Post>(1, 10, { vector: q });
vectorSpec(q) serializes a VectorQuery to the wire’s <field>[:metric]:<json-embedding> mini-grammar (used internally by getList({ vector })); it throws if any embedding value is non-finite (same posture as filter’s operand check). field is passed through verbatim — the server gates identifiers, not the client.
No client-side pre-flight. Whether vector search is compiled in, which fields are searchable, and embedding dimensions are all server-side facts the client can’t know in advance — it does not try to validate them. You’ll see the server’s 400s verbatim as
ZigbaseError{status: 400}:
"This collection has no searchable fields; \search` is not supported here.”`"Full-text search is not enabled in this build."(SQLite server built with-Dfts5=false; Postgres FTS is unaffected)"Vector search is not enabled in this build."(server not built with-Dvector)"Vector search does not support cursor pagination; use offset paging.""Invalid vector search query: the query embedding's dimension may not match the stored embeddings (or a stored embedding is malformed).""Vector search is unavailable: the pgvector extension is not installed on this database (run \CREATE EXTENSION vector`).”` (Postgres only)
Typed tier. The generated concrete service interfaces only expose search?: string on collections with at least one searchable field, and vector?: { field: <json-field-union>; … } on collections with at least one json field — a collection with neither simply lacks the key, so zb.db.tags.getList({ search: "x" }) is a compile error mirroring the server’s 400.
Pagination — offset + cursor
// Offset: random page access + exact totals.
const p = await posts.getList<Post>(2, 30);
p.totalItems; // total across all pages
p.totalPages;
// Cursor / keyset: stable under inserts; ideal for feeds and infinite scroll.
let c = await posts.getPage<Post>({ limit: 30, sort: "-created" });
c.items; c.nextCursor; c.hasNext;
while (c.hasNext && c.nextCursor) {
c = await posts.getPage<Post>({ limit: 30, sort: "-created", cursor: c.nextCursor });
}
// async-iterate every matching record over the stable cursor engine
for await (const post of posts.iterate<Post>({ sort: "-created" })) {
// ...
}
const all = await posts.getFullList<Post>({ filter: "status = 'published'" });
Which one to use? Reach for offset (getList) when you need jump-to-page-N navigation or an exact total count. Reach for cursor (getPage / iterate / getFullList) for stable feeds and infinite scroll: it is stable under concurrent inserts and avoids the cost of deep offsets.
Cursor pagination is native server-side keyset pagination. The server mints an opaque token (its internal format — stateless, signed, or stateful — is chosen server-side); the client treats nextCursor/prevCursor as opaque strings and simply forwards whatever the server returned on the next getPage call. There is no client-side keyset predicate or id tiebreaker — the server owns determinism. By default the server skips the total count in cursor mode (it’s the expensive part); pass withTotal: true to a getPage to include totalItems. getPage sends limit (defaulting to 30) — sending it with no cursor requests the first page.
Files
The legacy SDK thumb option is deprecated for ZigBase use: it only appends ?thumb=..., which does not invoke a server transform. Use the explicit GET /api/files/:collection/:record/:filename/thumbnail/:profile route for configured named thumbnails. Encode each path segment and put any scoped file token in the final URL’s query string. SDK signatures are unchanged; there is not yet a named-profile URL helper.
A create/update body containing a File/Blob (or an array of them) is sent as multipart automatically — no special method. Note that on a plain ZbRecord the file field is typed unknown, so either type the record (cover: string) or cast the filename argument.
// build an original-file URL — `record.cover` is typed string on Post
const url = zb.files.getUrl(record, record.cover);
// short-lived token for protected-file access (<img src>, emails)
const token = await zb.files.getToken();
const protectedUrl = zb.files.getUrl(record, record.cover, { token });
// you can also pass collection + id explicitly instead of a record object:
const url2 = zb.files.getUrl("posts", "REC123", "cover.png", { download: true });
Abilities
Requires ZigBase >= 0.9.0.
getAbilities(id) reports the actions the current principal may perform on a specific record — useful for conditionally rendering edit/delete UI without guessing at rule outcomes:
const abilities = await posts.getAbilities("REC123");
abilities.view; // always true on a 200 — you couldn't have fetched abilities otherwise
abilities.update; // boolean
abilities.delete; // boolean
getAbilities is available on the base CollectionService and on every generated collection service (it is emitted unconditionally — rules and tenancy answer even without an explicit .abilities config on the collection). 404, not 403, when the record isn’t viewable — the endpoint is a deliberate non-oracle: a ZigbaseError{status: 404} never tells you whether the record exists and you lack access, or it simply doesn’t exist.
Analytics
Requires ZigBase >= 0.9.0 (cursor pagination on events requires >= 0.10.0).
client.analytics exposes two read APIs over the tenant-scoped _events activity feed and any declared rollups:
// GET /api/analytics/events — the active account's activity feed. Paginates with the house
// cursor: feed.nextCursor/feed.hasNext, forwarded back as opts.cursor for the next page.
const feed = await zb.analytics.events({ name: "signup", since: new Date("2026-01-01"), limit: 50 });
feed.items[0]?.payload; // unknown (JSON value; null when unparseable/empty)
if (feed.hasNext) await zb.analytics.events({ cursor: feed.nextCursor! });
// GET /api/analytics/rollups/:name — a declared rollup's summary rows.
const rollup = await zb.analytics.rollup("daily_signups", { from: weekAgo, to: new Date() });
rollup.items[0]?.value;
Wire field names stay snake_case as the server sends them (actor_collection, occurred_at, computed_at, …) — the SDK does not rename server fields anywhere else and doesn’t start here. from/to/since accept either an ISO string or a Date; a Date is serialized with toISOString(), same as filter. Passing an invalid Date (e.g. new Date("not a date")) throws a RangeError client-side — toISOString() rejects it before a request is ever sent.
events() is 401 for an anonymous caller, returns empty items when there’s no active account, and a superuser sees every account’s events. rollup(name) is 404 for an undeclared rollup name and 403 when a non-superuser queries a rollup that isn’t grouped by account.
Senders
list requires ZigBase >= 0.10.0; create/verify require >= 0.9.0.
client.senders manages verified From-address identities for outbound mail, scoped to the active account exactly like the record API (withAccount / the zb_account cookie / a superuser’s explicit header):
// POST /api/senders — request verification. The token is EMAILED to the address, never
// returned in the response.
const pending = await zb.senders.create("orders@my-shop.example");
pending.status; // "pending" (201) or already-verified (200)
// POST /api/senders/:id/verify — confirm with the token from the email.
await zb.senders.verify(pending.id, tokenFromEmail);
// GET /api/senders — the active account's identities (requires server >= 0.10.0).
const { items } = await zb.senders.list();
items[0]?.verified_at;
A re-send of create() for the same (account, email) within the server’s throttle window rejects with a 429 ZigbaseError. verify() returns 404 — never a distinguishing error — for a wrong token, wrong account, or wrong id, so the endpoint can’t be used to probe for valid identities.
Breaking (0.10.0):
GET /api/sendersused to return a bare JSON array; as of server 0.10.0 it returns{ "items": [...] }, matching the analytics endpoints’ envelope. The SDK’slist()types only the fixed shape — against a 0.9.0-only server the response shape won’t match whatlist()expects, sosenders.list()requires >= 0.10.0.
Typed client — @zigbase/client/typed
@zigbase/client/typed is the generic typed core that a generated zbase.gen.ts file instantiates into a fully type-safe, schema-aware client. The generator reads your ZigBase schema (via the pub const App export in your main.zig) and emits a thin declarative wrapper. The examples/golfsim example ships a live generated client at examples/golfsim/clients/typescript/zbase.gen.ts — regenerate it with zig build gen-client from the examples/golfsim/ directory. The SDK’s own coverage fixture is fixtures/dating/schema.zig, validated by a type-level *.test-d.ts suite and a live e2e against a dating-server binary.
The subpath exports runtime factories and type utilities:
| Export | Kind | Purpose |
|---|---|---|
makeRecordService | factory | Build a typed CRUD service over SP1’s CollectionService. |
makeTypedRealtime | factory | Build a typed realtime/live surface. |
makeTypedFiles | factory | Build a typed file-URL helper. |
makeFilterBuilder | factory | Build a fluent filter builder (a Proxy over field names). |
compileWhere | function | Compile a where-DSL object into an SP1 filter string. |
compileIn | function | Compile an in-list to the native field in (...) filter operator. |
OP_MAP | object | Operator-to-filter-operator mapping (e.g. eq → =). |
fieldMeta | helper | Look up a FieldMeta by name from a CollectionMeta (fieldMeta(meta, name): FieldMeta | undefined). |
Expr, FieldExpr | classes | Fluent filter expression nodes. |
CollectionMeta, FieldMeta, FieldType | types | Runtime metadata descriptors. |
WithExpand | type | Narrow a record type to include one or more expanded relations. |
StringOps, NumberOps, BoolOps, DateOps, EnumOps, RelOps | types | Operator-object types for generated *Where interfaces. |
TypedFieldExpr | type | Per-field operand type for the fluent builder. |
A generated (or hand-authored) consumer file imports from @zigbase/client/typed, declares concrete record types and metadata, then assembles a typed client:
import { createClient as baseCreateClient } from "@zigbase/client";
import { withRealtime } from "@zigbase/client/realtime";
import {
makeRecordService,
makeTypedRealtime,
makeTypedFiles,
type CollectionMeta,
type WithExpand,
} from "@zigbase/client/typed";
// Declare per-collection metadata (the generator emits this from your schema):
const postsMeta: CollectionMeta = {
name: "posts",
fields: { title: { type: "text" }, status: { type: "select" } /* … */ },
fileFields: ["cover"],
expandable: ["author", "tags"],
isAuth: false,
};
// Build the typed service — compiles `where` → SP1 filter strings under the hood:
const base = withRealtime(baseCreateClient("http://127.0.0.1:8090"));
const posts = makeRecordService(base, postsMeta) as unknown as PostsService;
// Every call is narrowed by the generated concrete interface:
const page = await posts.getList({ where: { status: "published" }, sort: "-created" });
// page.items[0]?.title — typed string
// Expand-narrowed getOne (PostRelations = { author: User; tags: Tag[] }):
const post = await posts.getOne("REC123", { expand: ["author"] });
// post.expand?.author — User (not unknown)
The @zigbase/client/typed subpath tree-shakes independently of @zigbase/client/realtime — importing just the typed core adds only the where-compiler and factory code, not the realtime / live-store graph.
Typed sort & native in
Requires ZigBase >= 0.9.0.
In newly generated files, sort is narrowed per collection instead of a bare string:
export type ProfileSortField = "email" | "username" | "age" | "id" | "created" | "updated";
export type ProfileSort = SortExpr<ProfileSortField>; // ProfileSortField | `-${ProfileSortField}`
// every opts object that had `sort?: string` now has:
sort?: ProfileSort | ProfileSort[];
await zb.db.profiles.getList({ sort: "-age" }); // ok
await zb.db.profiles.getList({ sort: ["-age", "username"] }); // ok, multi-key
await zb.db.profiles.getList({ sort: "-nope" }); // compile error
The where-DSL’s in operator now compiles to the filter grammar’s native field in (...) operator instead of the old ||-chain desugar:
compileIn("status", ["draft", "published"]);
// => status in ('draft', 'published')
This is a behavior change against old servers, not just a client refactor. A
{ in: [...] }where-clause now 400s against a server older than 0.9.0 (its filter grammar doesn’t acceptin (...)) — there is no version sniff or client-side fallback. If you regenerate against a 0.9.0+ server you’re already on the floor this needs.
Runtime introspection (zigbase typegen)
The typegen subcommand generates the same typed TypeScript client as the comptime generator, but from the server’s actual schema (offline via its data directory, or live over HTTP) rather than from the Zig source. It is aimed at teams that consume a ZigBase backend as a black box — no Zig source, no build.zig wiring, and no custom routes. If you have the Zig source, prefer the comptime generator (zig build gen-client) because it also emits a typed rpc.* surface for custom routes; the runtime generator does not emit rpc.* (routes are not introspectable at runtime).
The typegen subcommand exists only in binaries built with .enable_typegen = true in the App(.{ … }) literal (default false, so production builds carry no codegen overhead). See the framework docs for how to enable it.
No Zig toolchain needed. The generator is also available as an npm package — npx @zigbase/typegen --data-dir ./zb_data --out src/zbase.gen.ts (or --url <origin> --admin-email <e> --admin-password <p> for the live mode). It bundles the codegen engine through @zigbase/server, which ships prebuilt platform binaries for Linux and macOS. Install @zigbase/client separately; the generated file imports it (and its /typed subpath export, which is included in the same package) at runtime.
Schema sources (exactly one required)
| Flag | Behavior |
|---|---|
--data-dir <path> | Offline. Reads the server’s data directory directly — no auth, no running server required. The directory must already have been provisioned by a prior serve run; a freshly-created or empty data directory has no collections to read. |
--url <origin> --admin-email <e> --admin-password <p> | Live. Calls GET /api/collections on the running instance using superuser credentials. |
Additional flags
| Flag | Default | Purpose |
|---|---|---|
--out <file> | (required) | Path to write the generated TypeScript file. |
--api-prefix <p> | /api | API path prefix used in the generated client. |
--client-name <name> | ZbClient | Name of the generated client class/factory. |
--lang <ts|dart|python|kotlin> | ts | Output language. dart emits a typed Dart client for the Dart SDK instead of TypeScript; python emits a typed Python client for the Python SDK; kotlin emits a typed Kotlin client for the Kotlin SDK. |
--check | — | Staleness gate: exits non-zero if the out file is out of date without writing it. |
Output
The generator emits the typed db / realtime / auth / files surface — the same collection-level typed client as the comptime generator (the typed auth-method surface is shared, since it derives from the schema’s enabled .auth.methods, which the runtime generator can see) — but without rpc.*. Custom routes are not visible at runtime.
Examples
# Offline — reads an already-provisioned data directory (no server needed):
myserver typegen --data-dir ./zb_data --out src/zbase.gen.ts
# Live — against a running instance (superuser credentials required):
myserver typegen --url https://api.example.com --admin-email admin@x.io --admin-password '…' --out src/zbase.gen.ts
Staleness gate (CI)
--check exits non-zero if --out does not match what typegen would generate. Use it in CI to enforce that the committed generated file stays in sync with the live schema:
myserver typegen --data-dir ./zb_data --out src/zbase.gen.ts --check
Typed RPC — zb.rpc.*
When a generated zbase.gen.ts declares typed routes (registered via the Zig server’s .routes config), the generated client exposes them under zb.rpc.<name>(params?, input?, opts?):
paramsobject is present IFF the route path contains:paramsegments (e.g.{ id: string }).inputargument is present IFF the route’sInputtype is non-void (POST/PUT/PATCH bodies).- GET/HEAD/DELETE routes pass non-param fields as query string parameters; POST/PUT/PATCH/OPTIONS routes serialize them as the request body.
- Throws a
ZigbaseErroron non-2xx — the same throw/parse behavior aszb.sendand the typed collection methods. - An optional final
optsargument acceptsSendOptions(signal,requestKey, custom headers).
The rpc namespace sits alongside db, realtime, auth, and files on the generated client — it is only present when the Zig app declares at least one typed route.
golfsim example — the golfsim server declares four typed routes; zig build gen-client (from examples/golfsim/) emits the following rpc interface:
// examples/golfsim/clients/typescript/zbase.gen.ts (excerpt)
rpc: {
bookingsConfirm(params: { id: string }, opts?: SendOptions): Promise<unknown>;
bookingsCancel(params: { id: string }, opts?: SendOptions): Promise<unknown>;
listingsAvailability(params: { id: string }, opts?: SendOptions): Promise<unknown>;
golfsimHealth(opts?: SendOptions): Promise<HealthOut>;
}
Usage in the golfsim e2e test:
// bookingsConfirm: POST /api/bookings/:id/confirm — params object; output is unknown
const confirmed = await zb.rpc.bookingsConfirm({ id: booking.id }) as Booking;
expect(confirmed.status).toBe("confirmed");
// golfsimHealth: GET /api/golfsim/health — no params; typed HealthOut output
const health = await zb.rpc.golfsimHealth();
// health.status === "ok"
unknown outputs correspond to Zig std.json.Value return types — cast to a concrete interface for type-safe field access.
Typed auth methods — zb.auth.*
When an auth collection enables a non-password method (magic_link, otp, webauthn, or a custom slug) in its .auth.methods config, the generator emits a typed two-phase surface under zb.auth.<collection>.<method> with initiate and complete. Each call posts to the auto-mounted endpoint POST /api/collections/<col>/auth/<slug>/{initiate|complete} (see api.md → Auth method endpoints):
// users enables OTP → zb.auth.users.otp.{initiate,complete}
await zb.auth.users.otp.initiate({ identity: "you@example.com" }); // 204, no body
const res = await zb.auth.users.otp.complete({
identity: "you@example.com",
code: "123456",
});
if ("pendingToken" in res) {
// Show enrollment or factor verification; do not save this as a session.
await zb.db.users.auth.completeSecondFactor(res.pendingToken, {
factor: "totp", code: "<authenticator code>",
});
} else {
res.token; // Session JWT; session cookies are also set.
}
The built-in methods carry precise I/O types generated by src/codegen/gen_client.zig; the complete of every built-in resolves to a shared AuthMethodResult:
| Type | Shape | Used by |
|---|---|---|
AuthMethodResult | { token: string } | PendingAuthentication | every built-in complete |
MagicLinkInitiateInput | { identity: string } | magic_link.initiate |
MagicLinkCompleteInput | { token: string } | magic_link.complete |
OtpInitiateInput | { identity: string } | otp.initiate |
OtpCompleteInput | { identity: string; code: string } | otp.complete |
WebAuthnInitiateInput | { identity?: string } | webauthn.initiate |
WebAuthnInitiateResult | { challenge: string; rpId: string; ceremonyId: string; timeout: number } | webauthn.initiate |
WebAuthnCompleteInput | { ceremonyId, credentialId, authenticatorData, clientDataJSON, signature: string } | webauthn.complete |
magic_link and otp initiate return Promise<void> (the endpoint replies 204, and is enumeration-safe); webauthn.initiate returns the typed WebAuthnInitiateResult challenge.
Custom methods can be typed too. A custom slug enabled as a bare string (.custom = .{ "api_token" }) carries no comptime I/O type info, so the generator falls back to initiate(input: Record<string, unknown>): Promise<unknown> / complete(...) stubs — the shape is yours to define. To get precise types, enable the slug in the struct form and declare the initiate/complete Zig I/O types:
.auth = .{ .methods = .{ .custom = &.{
// bare string → stays untyped (back-compat)
"legacy_slug",
// struct form → typed: the generator reflects these Zig types into TS
.{
.slug = "device_link",
.Initiate = .{ .Output = DeviceLinkInitiateResp }, // void Input omitted
.Complete = .{ .Input = DeviceLinkCompleteReq, .Output = DeviceLinkSession },
},
} } },
zig build gen-client then emits a precise surface:
auth: {
profiles: {
deviceLink: {
initiate(opts?: SendOptions): Promise<DeviceLinkInitiateResp>; // void Input → no input arg
complete(input: DeviceLinkCompleteReq, opts?: SendOptions): Promise<DeviceLinkSession>;
};
};
}
- Interfaces are named by the Zig type’s own (short) name —
DeviceLinkInitiateResp,DeviceLinkCompleteReq,DeviceLinkSession— exactly like the typedzb.rpc.*route surface. Two distinct Zig types that would collide on the same TS name are a build error. - A
voidInputomits theinputargument; avoidOutputmaps toPromise<void>. - The declared I/O types must be in the Zig→TS subset (scalars,
[]const u8, enums, optionals, slices,std.json.Value, and structs thereof) — same bound as typed routes. - Comptime-only: typed custom methods need the build-time generator (
zig build gen-client), which reads your Zig source. The runtime-introspection tier (typegen --data-dir/--url) cannot see Zig types, so it keeps custom methods untyped — identical to how typed routes behave.
// golfsim's users collection enables OTP; zig build gen-client emits:
auth: {
users: {
otp: {
initiate(input: OtpInitiateInput, opts?: SendOptions): Promise<void>;
complete(input: OtpCompleteInput, opts?: SendOptions): Promise<AuthMethodResult>;
};
};
}
Password auth and OAuth2 are not in the
zb.auth.*surface — use the base SDK’szb.collection(name).authWithPassword(...)/authWithOAuth2(...)(see Auth + stores). Thezb.auth.*namespace covers only the pluggable non-password methods.
Typed feature state — zb.flags
When the app declares feature flags or experiments (App(.{ .flags = …, .experiments = … })), the generator emits a typed zb.flags.resolveAll(subject) that calls the public, unauthenticated GET /api/state and returns a fully-typed FeatureState:
// App(.{ .flags = .{ .checkout_enabled = true, .new_dashboard = false },
// .experiments = .{ .checkout_layout = .{ .variants = .{ "control", "compact" }, … } } })
const state = await zb.flags.resolveAll("user-42");
state.flags.checkout_enabled; // boolean
state.flags.new_dashboard; // boolean
state.experiments.checkout_layout; // "control" | "compact"
The emitted shape is precise — flags become named booleans and each experiment a string-literal union of its declared variants:
export interface FeatureState {
flags: {
checkout_enabled: boolean;
new_dashboard: boolean;
};
experiments: {
checkout_layout: "control" | "compact";
};
}
// on the client:
flags: {
resolveAll(subject: string): Promise<FeatureState>;
};
subjectis the bucketing key for deterministic experiment assignment (a user/session id, or any stable string); the same subject always resolves to the same variant.- A flag with no
_kvoverride resolves to its declared default; anexp:<name>:weightsoverride (or the declared weights) drives the bucket. The endpoint returns resolved values only — never keys, defaults, or weights. - A
.stickyexperiment returns its persisted assignment (the same value the server’sApp.experimentresolves), so it survives later weight changes. Resolution is reader-first, so repeatedresolveAllcalls for a knownsubjectdon’t contend the writer. - A group with no declarations is typed
Record<string, never>(the server returns{}). - Comptime-only: the typed surface is emitted by
zig build gen-client, which reads your Zig.flags/.experiments. The runtime-introspection tier (typegen --data-dir/--url) cannot see them, so it omitszb.flags— exactly how typed routes and custom auth methods behave. (You can still callGET /api/statedirectly viazb.send("GET", "/api/state?subject=…").) - If you remap the route with
.features = .{ .public_route = "/state" }, the typed helper still targets the default/api/state; use the configured path viazb.sendinstead.
Realtime + live store
Realtime ships behind a dedicated entry point, @zigbase/client/realtime, so a REST-only app never bundles the realtime / live-store / filter-eval graph. Opt in with withRealtime:
import { createClient } from "@zigbase/client";
import { withRealtime } from "@zigbase/client/realtime";
const zb = withRealtime(createClient(url, { WebSocket }));
// `zb.realtime` is now available; everything else on `zb` is unchanged.
Tree-shaking. Because the realtime graph is reachable only through
@zigbase/client/realtime, an app that imports justcreateClientand uses.collection()dropstokenize/reconnect/LiveList/analyzeFilterentirely — roughly 13 KB (minified) of code a REST-only client never pays for.
Low-level subscriptions
const unsub = await zb.realtime.subscribe(
"posts",
(e) => {
e.action; // "create" | "update" | "delete"
e.record; // the record (a delete carries only { id })
},
{ filter: "status = 'published'" },
);
// single-record topic
await zb.realtime.subscribe("posts/REC123", (e) => {
/* fires on update/delete of one record */
});
unsub(); // stop this callback (the socket closes when the last topic goes away)
A single shared WebSocket to /api/realtime is created lazily on the first subscribe and multiplexes every topic. It:
- auto-reconnects with bounded exponential backoff after a drop,
- re-auths from the
AuthStoreon login / logout / refresh, - resubscribes every active topic after a reconnect,
- and coalesces multiple callbacks on the same
(topic, filter)onto one wire subscription.
Anonymous subscriptions are allowed only for collections with a @public view rule (server-enforced). The client does not pre-gate — it surfaces the server’s error frame (rejecting the pending subscribe() and/or calling your onError hook).
Custom topics — subscribeTopic
Requires ZigBase >= 0.9.0 for custom-route signal/message broadcasts; the built-in __features signal requires >= 0.10.0.
Beyond per-collection record events, the server lets custom routes push arbitrary topic broadcasts (ctx.realtime().signal(topic) / .broadcast(topic, payload)). Subscribe with subscribeTopic, which delivers the same enveloped frames as everything else on the socket — signal (no payload; a re-fetch hint) or message (payload-carrying):
const unsub = await zb.realtime.subscribeTopic("availability", (msg) => {
msg.topic; // "availability"
msg.kind; // "signal" | "message"
if (msg.kind === "message") msg.data; // the broadcast payload
});
unsub(); // or: zb.realtime.unsubscribeTopic("availability", cb);
kind mirrors the wire frame’s type field verbatim: {"type":"signal","topic":"…"} delivers kind: "signal" with no data, and {"type":"message","topic":"…","data":…} delivers kind: "message" with data set. Topic subscriptions reuse the same shared-socket machinery as record subscriptions (ack/pending/resubscribe/backoff) but take no filter; a topic that rejects the subscribe (not canSubscribeTopic-eligible) rejects the subscribeTopic() promise the same way an invalid collection subscribe does.
Feature-flag changes are just a topic. There’s no dedicated flags-changed API — the server’s built-in __features channel is an ordinary signal topic:
await zb.realtime.subscribeTopic("__features", () => {
// re-fetch zb.flags.resolveAll(subject) — the signal carries no data
});
Breaking (0.10.0): before 0.10.0 this channel emitted a bespoke, topic-less
{"type":"features.changed"}frame thatsubscribeTopiccannot deliver. As of server 0.10.0 it emits the standard{"type":"signal","topic":"__features"}frame like every other topic, sosubscribeTopic("__features", cb)requires >= 0.10.0.
High-level live store — “same API, now live”
zb.realtime.collection(name) mirrors the record read API but returns live objects that stay in sync as events arrive — backed by one shared per-collection record cache, so the same record id is one object across every view.
You must call
close()on a live record or list when you’re done with it.close()drops the realtime subscription and releases the cache ref(s); skipping it leaks the subscription and keeps records pinned in the cache. Bothclose()methods are idempotent.
const live = zb.realtime.collection("posts");
// A live record: looks exactly like the record, patched IN PLACE on update events.
const post = await live.getOne("REC123");
post.get(); // current backing data
post.subscribe(() => render(post.get())); // observable: subscribe() / get() / version
post.deleted; // flips to true on a delete event
post.close(); // REQUIRED when done — releases subscription + cache ref
// A live list: ordered items kept in sync as events arrive.
const list = await live.getList(1, 30, { sort: "-created" });
list.get(); // LiveRecord[] ordered by the query sort (alias: list.items)
const unbind = list.subscribe(() => render(list.get()));
// ... teardown:
unbind();
list.close(); // REQUIRED
// cursor-seeded live list
const feed = await live.getPage({ limit: 30, sort: "-created" });
// ... feed.close() when done
On a create/update/delete event the list surgically inserts (at the sorted position), patches in place, re-positions when a sort key changes, or removes — and notifies observers. LiveRecord and LiveList both implement the same observable contract:
interface Observable<T> {
subscribe(cb: () => void): () => void;
get(): T;
readonly version: number;
}
Correctness modes — list.mode
Membership of a record in a filtered live list is decided with a two-tier strategy that is always correct. Read list.mode ("precise" | "refetch") to see which tier a list is in:
"precise"(own-field filters). When the filter references only the record’s own scalar fields (status = 'published' && views > 10), the list evaluates membership client-side and applies surgical insert / remove / move on each event — zero extra requests."refetch"(relations / macros). When the filter traverses a relation (author.name = 'Ada') or uses a macro (@request.auth.id = owner), the client can’t evaluate it locally, so the list degrades to a debounced re-fetch of the query — still live, still correct, just coalesced to one request per burst of events.
React binding
list.get() / list.items is a stable, mutated reference — the array identity does not change when items move. Bind through useSyncExternalStore, keying your snapshot on list.version so React re-renders when the list mutates, and close() the list in cleanup:
import { useSyncExternalStore, useEffect, useState } from "react";
import type { LiveList } from "@zigbase/client";
function Feed({ zb }: { zb: ReturnType<typeof createClient> }) {
const [list, setList] = useState<LiveList | null>(null);
useEffect(() => {
let live: LiveList | undefined;
let cancelled = false;
zb.realtime.collection("posts").getList(1, 30, { sort: "-created" }).then((l) => {
if (cancelled) l.close();
else { live = l; setList(l); }
});
return () => { cancelled = true; live?.close(); }; // cleanup closes the list
}, [zb]);
// Re-render keyed on the list version (the items array is a stable mutated ref).
useSyncExternalStore(
(cb) => (list ? list.subscribe(cb) : () => {}),
() => list?.version ?? 0,
);
if (!list) return null;
return <ul>{list.get().map((r) => <li key={r.id}>{String(r.get().title)}</li>)}</ul>;
}
Runtime overrides
The SDK reads fetch and WebSocket from globals but lets you inject either — handy for SSR, tests, or runtimes without a global WebSocket:
createClient(url, { fetch: customFetch, WebSocket: customWS });
Error handling
Every non-2xx response rejects with a ZigbaseError carrying status, code, message, url, and per-field validation errors in data:
import { isZigbaseError } from "@zigbase/client";
try {
await posts.create<Post>({ title: "" } as Record<string, unknown>);
} catch (err) {
if (isZigbaseError(err) && err.status === 400) {
console.log(err.data); // { title: { code, message }, ... }
console.log(err.data.title?.message); // a single field's message
}
}
Branch on code, never on message. code is the frozen machine string from the error-code registry — it never changes meaning once shipped. message is human text and may be reworded in any release, so matching on it is a silent breakage waiting to happen:
try {
await zb.collection("users").authWithPassword(email, password);
} catch (err) {
if (isZigbaseError(err) && err.code === "email_not_verified") {
// A distinct, actionable state — not a flat denial. Send them to the verify flow.
showVerifyEmailStep(email);
}
}
code is "" when the server sent no code — a non-JSON body, or a response from something that isn’t ZigBase (a proxy’s own 502 page). Run zigbase explain-code to list every registered code, or zigbase explain-code <CODE> for the long form.
Field projection — fields
fields trims the server response to the listed fields and is honored on every read path: getList / getOne, the cursor engine (getPage / iterate / getFullList), and the live store (collection().getList / getPage / getOne seed fetches).
await posts.getFullList({ sort: "-created", fields: "id,title" });
for await (const p of posts.iterate({ fields: "id,slug" })) { /* ... */ }
const live = await zb.realtime.collection("posts").getList(1, 30, { fields: "id,title" });
Auto-cancellation — requestKey
Every read/mutation option bag (and send / fetch) accepts an optional requestKey for opt-in last-write-wins de-duplication: issuing a new request with a given key aborts any in-flight request sharing that key. Omitting the key disables auto-cancellation entirely (the default — concurrent requests never interfere). The key composes with a user-supplied signal (either one aborts the request), and an aborted request rejects with a DOMException whose name is "AbortError".
// Typeahead: only the most recent query survives.
const page = await posts.getList(1, 20, { filter, requestKey: "search" });
Escape hatches — send() and raw fetch()
zb.send(method, path, opts?) calls any endpoint the typed surface doesn’t cover, while still applying the auth header, retries, and ZigbaseError mapping (returning parsed JSON):
const stats = await zb.send<{ users: number }>("GET", "/api/custom/stats", {
query: { window: "7d" },
});
await zb.send("POST", "/api/custom/reindex", { body: { collection: "posts" } });
When you need the raw Response — binary or text bodies, response headers, or streaming — use zb.fetch(method, path, opts?). It passes through query / body / headers / signal / requestKey and the auth header, but does not JSON-parse and does not throw on a non-2xx status; you receive the Response as-is:
const res = await zb.fetch("GET", "/api/export.csv", { query: { format: "csv" } });
if (res.ok) {
const blob = await res.blob();
console.log(res.headers.get("content-type"));
}
Server compatibility
@zigbase/client 0.3.0’s new surfaces have two server floors — most of them land at 0.9.0, but two wire shapes were fixed for consistency and only exist as of 0.10.0:
| client 0.3.0 feature | server < 0.9.0 | 0.9.0 | >= 0.10.0 |
|---|---|---|---|
| existing (0.2.x) API surface | works | works | works |
search/vector, withAccount/activate, abilities, analytics, typed sort, subscribeTopic (signals + message broadcasts) | 404 / 400, loudly | works | works |
native in where-DSL | 400 parse error | works | works |
senders.* ({items} envelope) | 404 | shape mismatch (bare array) — do not use | works |
subscribeTopic("__features") feature-change signals | nothing delivered | nothing delivered (old features.changed frame is dropped) | works |
listSessions / revokeSession / revokeAllSessions | 404 | 404 | works (listSessions/revokeSession additionally require server .auth.session.store = .table, else 404) |
| client 0.2.x against >= 0.9.0 / 0.10.0 | — | works | works (it never consumed the two changed wire shapes) |
The behavior change against old servers is the where-DSL in operator switching to native emission: a { in: [...] } where-clause sent to a pre-0.9.0 server now 400s instead of working via the old || desugar. There’s no version sniff — the typed tier has always tracked the current server release, and adding a startup request just to detect this isn’t worth it. Per ZigBase’s pre-1.0 breaking-changes policy, the client also ships no shims for the pre-0.10.0 wire shapes — it speaks only the fixed formats, so senders.* and the __features signal simply require 0.10.0.
Generated-code ↔ core coupling. Files generated by the 0.10.0 codegen binary reference new typed-core exports and CollectionMeta keys that don’t exist on @zigbase/client 0.2.0 — against that old core they fail typecheck with opaque excess-property errors. To make the failure self-explaining, the typed core exports a marker type and every generated file imports it right under the header:
// @zigbase/client/typed
export type CoreSupports_0_3 = true;
// generated zbase.gen.ts, right under the header:
// requires @zigbase/client >= 0.3.0
type _RequiresCore = import("@zigbase/client/typed").CoreSupports_0_3;
On @zigbase/client 0.2.0 the resulting compile error literally names CoreSupports_0_3, pointing you at the fix (upgrade the client package). Old generated files keep working on the new core — every core change in 0.3.0 is additive, and new CollectionMeta keys are optional.
See also
- API reference — the underlying HTTP + WebSocket protocol.
- Recipes — schema provisioning, owner-scoped rules, signup flows.
- Tutorial — build an app on ZigBase end to end.