Documentation
Dart SDK — ZigBase
The official zigbase_client Dart SDK — auth, records, offset + cursor pagination, files, and realtime, for the Dart VM, Flutter, and Flutter web.
The official Dart client (zigbase_client) wraps the ZigBase HTTP REST + realtime WebSocket API (docs/api.md) in an ergonomic surface: auth + stores, records, offset and cursor pagination, files, realtime subscriptions, and a high-level live store. It runs on the Dart VM, Flutter (iOS, Android, desktop), and Flutter web.
Like the TypeScript SDK, it comes in two tiers: the hand-written dynamic client documented below, and a generated typed tier — concrete record classes and typed services emitted from your schema by zigbase typegen --lang dart / zig build gen-client.
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
Not yet published to pub.dev. The publishing workflow (release-dart-sdk.yml, OIDC automated publishing) is wired up, but the first publish still needs one-time owner setup on pub.dev — see clients/dart/RELEASING.md. Until then, add it as a git dependency:
dependencies:
zigbase_client:
git:
url: https://github.com/valthon/zigbase
path: clients/dart
Once published:
dart pub add zigbase_client
Runtime dependencies: http (transport), web_socket_channel + stream_channel (realtime), crypto (PKCE), http_parser (multipart). SDK version: zigbaseClientVersion (currently 0.1.0, exported from package:zigbase_client/zigbase_client.dart).
Create a client
import 'package:zigbase_client/zigbase_client.dart';
final zb = ZigbaseClient(
'http://127.0.0.1:8090',
authStore: AsyncAuthStore(save: persist, initial: cached), // omit for in-memory
);
ZigbaseClient(baseUrl, {...}) constructor 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"). |
accountId | — | Bakes X-Account-Id into every request from this client (multi-tenancy). |
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. |
httpClient | http.Client() | Override the HTTP transport (tests, custom http.Client). |
webSocketConnector | WebSocketChannel.connect | Override the realtime transport. |
onRealtimeError | logs via dart:developer | Realtime error callback — see Realtime. |
Ownership & close()
A ZigbaseClient constructed with the public constructor owns its http.Client (whether it built the default one or was handed one) and, when the caller did not supply an AuthStore, its authStore too. close() is idempotent and tears down exactly what this instance owns: the RealtimeService (if realtime was ever accessed), the underlying http.Client, and (only for the default MemoryAuthStore) the authStore. A closed client is terminal — every accessor (collection, the service getters, send, rawRequest, withAccount) throws StateError afterwards.
withAccount(accountId) returns a sibling: a second ZigbaseClient sharing this client’s authStore and http.Client (a login/logout on either is visible to both), scoped to send X-Account-Id: <accountId> on every request. A sibling’s close() only tears down its own RealtimeService; closing the parent invalidates every sibling.
Auth + stores
Two stores ship in the box:
MemoryAuthStore— the default. In-memory only, gone on process exit.AsyncAuthStore— persists via caller-supplied async callbacks, so a Flutter app can plug inshared_preferences/flutter_secure_storage/anything else without the SDK depending on Flutter.save/clearcalls are queued and applied in order (never interleaved); a throwing callback is swallowed (the write is dropped, later writes still run) — callbacks that need failure visibility should log internally.
final store = AsyncAuthStore(
save: (data) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('zb_auth', data);
},
clear: () async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove('zb_auth');
},
initial: cachedAuthJson, // must be read synchronously BEFORE constructing the store
);
// 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 security note)
zb.authStore.record; // the authenticated record (Map<String, dynamic>?)
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
zb.authStore.onChange.listen((event) {
print('auth changed: ${event.record?['id'] ?? '(signed out)'}');
});
OAuth2 (Authorization-Code + PKCE)
// Discover configured providers (name, authUrl, clientId, scopes):
final providers = await zb.collection('users').listAuthProviders();
final pkce = createPkceChallenge(); // { verifier, challenge }
final state = randomState();
// 1. redirect the user to the provider authorize URL with pkce.challenge + state
// 2. on callback, exchange the code:
final token = await zb.collection('users').authWithOAuth2(
provider: 'github',
code: code,
codeVerifier: pkce.verifier,
redirectUrl: 'https://app.example.com/callback',
state: state,
);
authWithOAuth2 returns the raw token String and saves it into the auth store with a null record — the endpoint sets zb_auth/zb_csrf cookies directly and never returns a record.
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');
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, dropping every other outstanding session. When the auth store’s current principal is the target record, changePassword transparently re-authenticates with the stored identity (email, falling back to username) and the new password, so a bearer-token client stays logged in.
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 ZigbaseException.
final sessions = await zb.collection('users').listSessions(); // List<SessionInfo>, newest first
// sessions[i].isCurrent 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 fields: id, created, lastSeen, userAgent, ip, isCurrent (mapped from the server’s snake_case last_seen/user_agent/is_current). revokeAllSessions() works in both session-store modes and always clears the local AuthStore, even if the request fails.
Security notes
isValidis not authorization. It decodes the JWTexpclaim client-side purely so the UI can pre-empt an expired session; the server authorizes every request — never gate sensitive UI onisValidalone.- There is no cookie-based store in this SDK (no browser
document.cookie, unlike the TypeScript SDK’sCookieAuthStore). For a persisted store, backAsyncAuthStorewith device-appropriate secure storage (e.g.flutter_secure_storageon mobile).
Account scoping (multi-tenancy)
Requires ZigBase >= 0.9.0 with .tenancy enabled.
// 1. Bake it into the client at creation time.
final zb = ZigbaseClient(url, accountId: 'acc_123');
// 2. withAccount(id) — a sibling client scoped to a (possibly different) account.
final scoped = zb.withAccount('acc_123');
await scoped.collection('notes').getList(); // every request carries X-Account-Id: acc_123
// accounts.activate(id) — verify membership + set the zb_account cookie (browser/webview apps).
final scope = await zb.accounts.activate('acc_123');
scope.account; // "acc_123"
scope.role; // the caller's role on that account
withAccount shares the same AuthStore as the client it was derived from and replaces the account id rather than stacking. Per-request X-Account-Id (via accountId/ withAccount) always wins over the zb_account cookie server-side.
Records
final posts = zb.collection('posts');
final page = await posts.getList(
page: 1,
perPage: 30,
filter: "status = 'published'",
sort: '-created',
expand: 'author',
);
page.items.first.getString('title');
final one = await posts.getOne('REC123', expand: 'author');
// create — multipart is auto-detected when a body value is an http.MultipartFile
final made = await posts.create({'title': 'Hi', 'status': 'draft'});
final updated = await posts.update('REC123', {'title': 'Edited'});
await posts.delete('REC123');
// getFirstListItem — getList(perPage: 1) sugar; throws a 404 ZigbaseException when nothing matches
final draft = await posts.getFirstListItem("status = 'draft'");
ZbRecord wraps the raw JSON Map<String, dynamic> with typed accessors: id, operator [](key), getString/getInt/getDouble/getBool/getList, and toJson().
Safe filters — zbFilter
Build filter strings without injection risk using zbFilter(expr, params), which interpolates named {:name} placeholders via filterValue. String values are always single-quoted and escaped against the server lexer (', \, and newline/tab/CR are backslash-escaped); int/double/bool render bare; DateTime becomes a millisecond- clamped UTC ISO-8601 string. Any string is representable — including values containing both ' and ".
final q = userInput; // even `' || 1=1 --` or `he said "hi" to O'Brien` is safely quoted
final filter = zbFilter('status = {:s} && author ~ {:q}', {'s': 'published', 'q': q});
final list = await posts.getList(filter: filter);
filterValue(value) is the single-operand primitive zbFilter calls internally — reach for it directly to build one operand at a time. Both throw ArgumentError on a non-finite double or an unsupported type (List/Map operands are ambiguous — expand them yourself, e.g. into an || chain or a native in (...) clause).
Vector search
Requires ZigBase >= 0.9.0 built with -Dvector=true.
final nearest = await posts.getList(
perPage: 10,
vector: VectorQuery(field: 'embedding', metric: 'cosine', values: [0.12, 0.34 /* … */]),
);
VectorQuery(field, metric, values).spec() serializes to the wire’s <field>[:metric]:<json-embedding> mini-grammar; it throws ArgumentError on a non-finite embedding value. Vector search is offset-only — the server rejects it in cursor mode. search (full-text) is a plain String param on getList/getPage/iterate/getFullList and works in both modes.
Pagination — offset + cursor
// Offset: random page access + exact totals.
final p = await posts.getList(page: 2, perPage: 30);
p.totalItems; // total across all pages
p.totalPages;
// Cursor / keyset: stable under inserts; ideal for feeds and infinite scroll.
var c = await posts.getPage(limit: 30, sort: '-created');
c.items; c.nextCursor; c.hasNext;
while (c.hasNext && c.nextCursor != null) {
c = await posts.getPage(limit: 30, sort: '-created', cursor: c.nextCursor);
}
// async-iterate every matching record over the stable cursor engine
await for (final post in posts.iterate(sort: '-created')) {
// ...
}
final all = await posts.getFullList(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; the client treats nextCursor/prevCursor as opaque strings and forwards whatever it received on the next getPage call — it never decodes or synthesizes one. The server skips the total count by default (the expensive part); pass withTotal: true to a getPage call to include totalItems. getPage sends limit (defaulting to 30); omitting 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 value that is an http.MultipartFile (or a List containing one) is sent as multipart automatically — no special method. The outer map key, not whatever field name the MultipartFile was constructed with, becomes the form field name.
import 'package:http/http.dart' as http;
final bytes = await File('cover.png').readAsBytes();
final rec = await posts.create({
'title': 'Hi',
'status': 'draft',
'cover': http.MultipartFile.fromBytes('cover', bytes, filename: 'cover.png'),
});
// build an original-file URL from the record:
final url = zb.files.getUrl(rec, rec.getString('cover')!);
// short-lived token for protected-file access (<img>, emails):
final token = await zb.files.getToken();
final protectedUrl = zb.files.getUrl(rec, rec.getString('cover')!, token: token);
// or pass collection + id explicitly instead of a record object:
final url2 = zb.files.getUrlFor('posts', 'REC123', 'cover.png', download: true);
An http.MultipartFile is single-use (package:http finalizes its byte stream once); construct a fresh instance for each create/update call — reusing one across two calls throws a StateError.
zb.files.getUrl(record, filename, {...}) reads the collection from record.data['collectionId'], falling back to collectionName; it throws ArgumentError when neither is present (e.g. a hand-built map that never round-tripped through the server).
Abilities
Requires ZigBase >= 0.9.0.
final abilities = await posts.getAbilities('REC123');
abilities.view; // always true on success — you couldn't have fetched abilities otherwise
abilities.update; // bool
abilities.delete; // bool
404, not 403, when the record isn’t viewable — a deliberate non-oracle: a ZigbaseException(status: 404) never distinguishes “exists but you lack access” from “doesn’t exist.”
Analytics
Requires ZigBase >= 0.9.0 (cursor pagination on events requires >= 0.10.0).
final feed = await zb.analytics.events(name: 'signup', since: '2026-01-01T00:00:00Z', limit: 50);
feed.items.first['payload']; // dynamic (JSON value; null when unparseable/empty)
if (feed.hasNext) await zb.analytics.events(cursor: feed.nextCursor);
final rollup = await zb.analytics.rollup('daily_signups', from: weekAgoIso, to: nowIso);
rollup.first['value'];
Wire field names stay snake_case as the server sends them (actor_collection, occurred_at, computed_at, …) — rows are returned as raw Map<String, dynamic> rather than a typed class. since/from/to are plain ISO-8601 Strings (format them yourself; there is no DateTime-accepting overload). 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.
// request verification — the token is EMAILED to the address, never returned
final pending = await zb.senders.create('orders@my-shop.example');
pending.status; // "pending" (201) or already-verified (200)
// confirm with the token from the email
await zb.senders.verify(pending.id, tokenFromEmail);
// the active account's identities (requires server >= 0.10.0)
final items = await zb.senders.list();
items.first.verifiedAt;
A re-send of create() for the same (account, email) within the server’s throttle window throws a 429 ZigbaseException. verify() returns false/404 — never a distinguishing error — for a wrong token, wrong account, or wrong id.
Realtime
final unsub = await zb.realtime.subscribe(
'posts',
(e) {
e.action; // 'create' | 'update' | 'delete'
e.record; // the record (a delete carries only {id})
},
filter: "status = 'published'",
);
await unsub(); // stop this callback; the socket closes when the last topic goes away
// single-record topic
await zb.realtime.subscribe('posts/REC123', (e) {
// fires on update/delete of one record
});
A single shared WebSocket to <baseUrl>/api/realtime (http/https mapped to ws/wss) is created lazily on the first subscribe/subscribeTopic call and multiplexes every topic. It:
- auto-reconnects with bounded exponential backoff (250ms–10s by default),
- re-auths from the
AuthStoreon login/logout/refresh, - resubscribes every active topic after a reconnect.
Anonymous subscriptions are allowed only for collections with a @public view rule (server-enforced); the client does not pre-gate — a rejected subscribe rejects the returned Future and/or calls the error callback. Known limitation: the server keys a subscription per topic per connection, so subscribing to the same topic with two different filters on one client makes the second filter overwrite the first server-side — both callbacks then receive the second filter’s events.
Error handling — onRealtimeError
final zb = ZigbaseClient(
'http://127.0.0.1:8090',
onRealtimeError: (error) => myLogger.warn('realtime: $error'),
);
Pass onRealtimeError to the client constructor to observe server-side realtime errors — e.g. a server rejection of an anonymous subscribe to a non-public collection, or a socket-level error with no in-flight subscribe at all. The callback fires for every server error frame, including errors also delivered to (and rejecting) a pending subscribe/subscribeTopic call — treat it as a logging/telemetry hook, not a replacement for handling a rejected subscribe Future. A realtime error is never silently dropped: when onRealtimeError is omitted, the client falls back to logging the error visibly via dart:developer (shows up in IDE/DevTools consoles). Constructing a RealtimeService directly (bypassing the client) still defaults its own onError to null (a real no-op) if you don’t pass one.
As a Stream
final sub = zb.realtime.stream('posts', filter: "status = 'published'").listen((e) {
print('${e.action}: ${e.record.id}');
});
// later:
await sub.cancel(); // unsubscribes, including mid-flight ack round-trips
Custom topics — subscribeTopic
Requires ZigBase >= 0.9.0 for custom-route signal/message broadcasts; the built-in __features signal requires >= 0.10.0.
final unsub = await zb.realtime.subscribeTopic('availability', (msg) {
msg.topic; // 'availability'
msg.kind; // 'signal' | 'message'
if (msg.kind == 'message') msg.data; // the broadcast payload
});
await zb.realtime.unsubscribeTopic('availability', callback);
kind mirrors the wire frame’s type field verbatim. Topic subscriptions reuse the same shared-socket machinery as record subscriptions (ack/pending/resubscribe/backoff) but take no filter.
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 a single object across every view (a list and a getOne share the same LiveRecord; one event patches both).
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, and post-close use throwsStateError(matching the SDK’s other close contracts).
final live = zb.realtime.collection('posts');
// A live record: patched IN PLACE on update events, flipped deleted on delete.
final post = await live.getOne('REC123');
post.get(); // current backing ZbRecord
post['title']; // convenience read-through of a backing field
final sub = post.changes.listen((_) => render(post.get()));
post.deleted; // true after a delete event
// ... teardown:
await sub.cancel();
post.close(); // REQUIRED
// A live list: ordered items kept in sync as events arrive.
final list = await live.getList(sort: '-created');
list.items; // read-only List<LiveRecord> ordered by the query sort (alias: list.get())
list.getById('REC123'); // O(1) membership lookup
list.changes.listen((_) => render(list.items));
list.close(); // REQUIRED
// cursor-seeded live list
final feed = await live.getPage(limit: 30, sort: '-created');
// ... feed.close() when done
The observable contract
LiveRecord and LiveList are pure-Dart observables (no Flutter dependency) — a synchronous snapshot + version plus a broadcast change stream:
abstract class Observable<T> {
T get(); // synchronous current snapshot
int get version; // monotonic counter, bumped before each notification
Stream<void> get changes; // broadcast; one event per mutation
}
Read state synchronously via get()/items/version; subscribe to changes (and cancel the StreamSubscription to unsubscribe) to know when to re-read. This adapts trivially to a Flutter ValueListenable in a future companion package — version → notifyListeners, get() → value.
A LiveList‘s get()/items return an unmodifiable view with a stable identity: the same list object across events, mutated internally as items move (key re-reads on version, as the TS SDK’s React binding does) — external mutation (.clear(), .sort(), …) throws UnsupportedError instead of silently desyncing the list’s internal index.
Unlike the TypeScript SDK, a
LiveRecorddoes not expose dynamic same-named getters (post.title) — Dart can’t synthesize them at runtime. Usepost.get()(aZbRecord, with its owngetString/getInt/… accessors) or the conveniencepost['title'].
Correctness modes — list.mode
Membership of a record in a filtered live list is decided with a two-tier strategy. Read list.mode (LiveListMode.precise | LiveListMode.refetch):
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. The sort always appends anid-asc tiebreaker for a deterministic order.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 single-flight re-fetch of the query (default 200ms; at most one request in flight, re-run once if events arrive mid-fetch) — still live, coalesced to one request per burst. A failing refetch keeps the previous items (stale until the next event schedules another attempt) rather than surfacing an error.
Caveat: precise mode is exact for the events the subscription delivers, but the subscription itself is server-side filtered against a record’s new state — so a record that is updated to stop matching the filter emits no event, and its stale row is only dropped on the next refetch/reload. (This matches the TypeScript SDK; a cross-SDK fix — subscribing unfiltered in precise mode — is a tracked follow-up.)
Error handling
Every non-2xx response throws a ZigbaseException carrying status, code, message, url, and per-field validation errors in data (Map<String, FieldError>):
try {
await posts.create({'title': ''});
} on ZigbaseException catch (e) {
if (e.status == 400) {
print(e.data['title']?.message); // field-level error
}
}
Branch on code, never on message. code is the frozen machine string from the error-code registry — it never changes meaning once shipped, whereas message is human text that may be reworded in any release:
try {
await users.authWithPassword(email, password);
} on ZigbaseException catch (e) {
if (e.code == 'email_not_verified') {
// A distinct, actionable state — not a flat denial.
showVerifyEmailStep(email);
}
}
code is empty when the server sent no code — a non-JSON body, or a response from something that isn’t ZigBase (a proxy’s own error page).
Auto-cancellation — requestKey
Pass requestKey on any read/mutation (or send) for opt-in last-write-wins de-duplication: issuing a new request with a key supersedes any in-flight request sharing that key. Without a key, nothing is auto-cancelled.
This is discard, not abort. package:http has no way to cancel a live socket, so cancellation is cooperative: the superseded call’s Future completes with a ZigbaseCancelledException while its underlying HTTP request keeps running in the background — its eventual response (success or error) is silently discarded on arrival, never leaking as an unhandled async error. This is a deliberate divergence from the TypeScript SDK, whose fetch-based transport can truly AbortController.abort() the in-flight request.
// As the user types, only the latest search's Future resolves; earlier ones reject.
try {
final results = await posts.getList(filter: filter, requestKey: 'search');
} on ZigbaseCancelledException {
// a newer keyed request superseded this one — safe to ignore
}
Escape hatch — send() and rawRequest()
zb.send(method, path, {...}) calls any endpoint the typed surface doesn’t cover, returning parsed JSON (or null for 204/empty); the auth header, 401 auto-refresh, 429 backoff, and ZigbaseException mapping still apply:
final stats = await zb.send('GET', '/api/custom/stats', query: {'window': '7d'})
as Map<String, dynamic>;
await zb.send('POST', '/api/custom/reindex', body: {'collection': 'posts'});
For a non-GET request, body is always JSON-encoded (Content-Type: application/json) unless it contains an http.MultipartFile, matching the TypeScript SDK’s JSON.stringify-everything behavior byte-for-byte — this applies even to a bare scalar, so body: 'hi' is sent as the quoted JSON string literal "hi", never as raw unquoted text. A DateTime nested anywhere in the body serializes as a millisecond-clamped UTC ISO-8601 string (as JS JSON.stringify does for a Date); any other non-encodable value throws an ArgumentError.
When you need the raw http.Response (binary/text bodies, response headers, custom status handling), use zb.rawRequest(method, path, {...}). It passes through query/body/headers and the auth header, but does not JSON-parse and does not throw on a non-2xx status:
final res = await zb.rawRequest('GET', '/api/export.csv', query: {'format': 'csv'});
if (res.statusCode == 200) {
print(res.headers['content-type']);
print(res.body);
}
Integration-test recipe
The SDK’s own end-to-end suite (clients/dart/test/integration/) drives the public API against a real zigbase serve process — the same pattern works for consumer apps that want a live-server smoke test:
# 1. Build the server binary once.
mise exec zig@0.16.0 -- zig build
# 2. Point ZIGBASE_TEST_BINARY at it and run the tagged suite.
ZIGBASE_TEST_BINARY="/zig-out/bin/zigbase" \
mise exec dart@3.12 -- dart test --tags integration
The suite is a clean no-op (a printed skip, never a failure) when ZIGBASE_TEST_BINARY is unset, so plain dart test stays green without the Zig toolchain. The harness (test/integration/harness.dart) launches the binary on a free loopback port with a fresh tempdir data directory and --insecure-cookies, seeds a superuser via the superuser create CLI subcommand, polls /api/health for readiness (retrying on a fresh port if a bind race kills the child), and SIGTERMs + removes the tempdir on teardown.
Typed tier
Beyond the dynamic base client, ZigBase’s code generator can emit a typed Dart client from your schema: concrete record classes with typed fields, one typed service per collection, a fluent filter builder, typed expand, and int/fixed numeric coercion. It is the Dart counterpart of the TypeScript typed client.
Generate
The same generator that emits the TypeScript client emits Dart — pass --lang dart:
# Runtime introspection (no Zig source; reads a provisioned data dir or a live server):
myserver typegen --data-dir ./zb_data --out lib/zbase.gen.dart --lang dart
myserver typegen --url https://api.example.com --admin-email admin@x.io --admin-password '…' \
--out lib/zbase.gen.dart --lang dart
# Comptime (reads your Zig schema) via a build step wired with genClientStep's `lang: "dart"`:
zig build gen-client # when the consumer's step passes .lang = "dart"
The generated file imports the base SDK barrel and the typed runtime (package:zigbase_client/typed.dart, shipped in zigbase_client), so the emitted code stays thin. Regenerate and re-run dart format on the output (it is committed dart format-clean).
Create a typed client
import 'zbase.gen.dart' as api;
final zb = api.createClient('http://127.0.0.1:8090');
// authCollection defaults to your auth collection; pass an AuthStore for persistence.
The client exposes one accessor per collection (zb.posts, zb.users, …) and a matching realtime accessor (zb.postsRealtime). zb.raw is the underlying ZigbaseClient for anything the typed surface doesn’t wrap; zb.close() tears it down.
Typed records + CRUD
Every read returns a concrete class with typed fields; writes take a typed Create/Update:
final post = await zb.posts.getOne('REC123');
post.title; // String
post.status; // PostStatus? (a generated enum from the select field)
final created = await zb.posts.create(api.PostCreate(title: 'Hello', status: api.PostStatus.draft));
final page = await zb.posts.getList(page: 1, perPage: 20); // TypedList<Post>
final cursorPage = await zb.posts.getPage(limit: 20); // TypedCursorPage<Post> (nextCursor/hasNext)
await for (final p in zb.posts.iterate()) { /* … */ } // Stream<Post>
Identifier mapping. A schema name that is a Dart reserved word (default, class, in, …) or would shadow a generated/Object member (expand, toMap, toString, a collection named raw, …) gets a trailing _ appended on the Dart side only — field default becomes member default_; the wire key, filter path, and toMap() key stay default. Two schema names that would sanitize to the same Dart identifier are a generation-time error naming both.
Typed filters — the fluent builder
where: takes a callback over a generated <Rec>Fields builder and compiles to a server filter string (injection-safe, via the same escaping the base SDK uses):
// scalar + enum + and/or:
await zb.posts.getList(where: (p) => p.status.eq(api.PostStatus.published).and(p.price.gte(10)));
// native `in (...)`:
await zb.posts.getList(where: (p) => p.status.inList([api.PostStatus.draft, api.PostStatus.published]));
// one level of nested-relation filtering (author.name ~ 'A'):
await zb.posts.getList(where: (p) => p.author.rel((a) => a.name.like('A')));
Operators: eq/neq (all fields), gt/gte/lt/lte (numbers, dates, strings), like/nlike (strings), inList. sort: accepts a field string or a list ('-created', ['-age', 'name']).
Typed expand
Every generated record carries a typed, nullable expand accessor; request it with expand: and read the related record(s) off it:
final withAuthor = await zb.posts.getOne('REC123', expand: ['author']);
withAuthor.expand.author; // User? (populated when requested)
final withTags = await zb.posts.getOne('REC123', expand: ['tags']);
withTags.expand.tags; // List<Tag>
Dart has no way to statically prove “this call requested author”, so expand members are nullable/empty by design.
Typed realtime + files
final off = await zb.postsRealtime.subscribe((e) {
e.action; // 'create' | 'update' | 'delete'
e.record.title; // typed Post
}, where: (p) => p.status.eq(api.PostStatus.published));
await off();
// File URLs: the field is a generated enum of the collection's single-value file fields.
final url = zb.posts.fileUrl(post, field: api.PostFileField.cover, token: token);
int/fixed numbers
ZigBase number fields can be integer or fixed-point. To preserve full i64 precision they travel as decimal strings on the wire; the typed layer coerces both directions — int fields surface as Dart int, fixed fields as double, and Create/Update.toMap() serializes them back to decimal strings. Plain float fields are double and pass through untouched. An int-mode field receiving a value with a fractional part (schema drift) throws a FormatException rather than silently truncating.
Scope
The typed rpc.* (custom routes), auth-method, and feature-flag surfaces are TypeScript-only for now — in Dart, call custom routes through zb.raw.send(...) and non-password auth through the base zb.raw.collection(name) methods. These are planned Dart follow-ups.
Not yet
The Dart SDK is a base client plus the typed tier above — a few surfaces the TypeScript SDK has do not exist here yet:
- Typed
rpc.*/ auth-method / feature-flag surfaces. The Dart typed tier covers the collection/record/where/expand/realtime/files surface; typed custom routes, pluggable auth methods, and feature flags are TypeScript-only for now (usezb.raw.send(...)/zb.raw.collection(...)). - SSE transport. The server exposes realtime over both WebSocket and SSE (
EventSource); this SDK speaks WebSocket only. - Cookie-based auth store. No
CookieAuthStoreequivalent — persist viaAsyncAuthStorebacked by your platform’s secure storage instead.
These are planned follow-ups, not permanent gaps — track them alongside the TypeScript SDK, which reached them first.
See also
- API reference — the underlying HTTP + WebSocket protocol.
- TypeScript SDK — the more mature sibling client, including the typed
rpc.*/auth-method/flags surfaces this SDK’s typed tier doesn’t have yet. - Recipes — schema provisioning, owner-scoped rules, signup flows.
- Tutorial — build an app on ZigBase end to end.