Run the built binary with resources --json to inspect its compiled resource
envelope without opening deployment state. The example's four retained SQLite
readers and 512 KiB per-connection soft cache target yield a 2.5 MiB target for
writer plus full idle pool. Admission limits synchronous callbacks to three;
neither setting bounds transport buffering, overflow readers, or total RSS.
Use measured workloads to size a deployment; scheduler_stack_bytes
describes effective virtual stack space after the 1 MiB floor, not resident memory.
This is the advanced framework example. The three examples form a ladder:
| Example | What it proves |
|---|---|
examples/blog |
bare packaging proof (ZigBase as a dependency) |
examples/golfsim |
a realistic app built on ZigBase (hooks, routes, cron) |
examples/plugins |
the full comptime-config surface a framework integrator uses |
It is a standalone package with a path dependency on the repo root
(../..) and exercises — using only public zigbase.* exports, no reaching
into ZigBase internals — the comptime-config features a serious consumer
configures in code:
-
Custom storage plugin (
AuditStorage). The headline addition. Implements the plugin contractcreate(gpa, io, cfg) !Self/interface(*Self) zigbase.Storage/deinit(*Self) void, wrapping azigbase.LocalStoragebackend with a logging intercept layer. The wrapper holdsLocalStorageby value, obtainsinner = local.storage()ininterface()(whereselfis stable), then builds a staticzigbase.Storage.VTablewith all four methods:put(ctx, io, col, record_id, filename, bytes)fetch(ctx, io, alloc, col, record_id, filename) ?[]const u8delete(ctx, io, col, record_id, filename)deleteRecord(ctx, io, col, record_id)
Each wrapper function
@ptrCast/@alignCastsctx→*AuditStorage, logs the operation, then delegates toself.inner.vtable.<method>(self.inner.ctx, ...). Registered viaApp(.{ .storage = AuditStorage }). The callbackcolis an immutable physical namespace, so the wrapper forwards it unchanged even after a collection rename; it must not reconstruct prefixes from public route names. -
Custom mailer plugin (
AuditMailer). Implements the same plugin contract forzigbase.Mailer/zigbase.Email, logging every outbound email and counting sends. Registered viaApp(.{ .mailer = AuditMailer }). -
Comptime schema via
.collections. Four related collections:Collection Type Auth methods Relations Access rules authorsauth webauthn (passkey) + api_token — list/view: public authors.private_notesis an encrypted-at-rest field (.encrypted = true): stored AES-256-GCM-encrypted in SQLite, transparent plaintext to the API. It requires theZIGBASE_FIELD_KEYenv var (see Build & run) — the server refuses to start without it. Encrypted fields can't be indexed/unique/filtered/sorted. |commenters| auth | magic_link (auto_create=true) | — | list: public; view: authed | |posts| base | — |author → authors(cascade-delete) | list:status = "published"| |comments| base | — |post → posts,commenter → commenters| list/view:approved=true; create: authed | -
Explicit migrations via
.migrations. Twozigbase.Migrationentries run once each (recorded in_migrations):0001_create_audit_log— creates aplugin_audit_logside table outside the comptime-managed schema (the classic escape hatch).0002_index_audit_note— a more realistic multi-statement migration: createsidx_audit_noteonplugin_audit_logand seeds a metadata row in the same transaction. It targets the migration-owned table on purpose: the migration is demonstrating the escape-hatch pattern for non-additive DDL on tables the migration itself creates. For comptime-managed collections, use.indexes = .{ ... }in the collection spec instead — physical columns are named by their human field name (not an internal id), soCREATE INDEX ... ON posts (status)would work fine in a raw migration too. See theauthorscollection for a working.indexesexample.
-
onErrorhandler via.onError. Receives*zigbase.ErrorEventwith.phase(.request/.before_hook/.after_hook/.cron/.job/.file_serve) and.message. Logs a structured one-liner so operators can distinguish request errors from background-job failures. -
Cron job (
audit-sweep) via.cron, with comptime.distributedcoordination and a 120-second lease so replicas sharing a database share one schedule. Fires every minute ("* * * * *", UTC, 5-field numeric). Demonstrates the full ctx-first job DB-access pattern: collection reads usectx.records()(the pooled reader is managed for you); raw SQL on the migration-ownedplugin_audit_logtable uses the pooled writer viactx.app.pool(acquireWriter/releaseWriter), since it is not a comptime collection. Counts published posts, then INSERTs an audit row intoplugin_audit_log. It also captures two analytics events together withctx.trackBatch, demonstrating atomic multi-event capture without a background buffer. -
Pool levers via
.pools(.readers/.jobs/.memory_jobs/.cache_kib) to tune warm readers, scheduler workers, lazy memory-job/submit workers, and SQLite page-cache targets. This example overrides the minimal profile's one memory worker with two; inspect the requested configuration withresources. The report'sjob_stack_bytesapplies to both scheduler and memory workers. The envelope's separatememory_job_stack_bytesis up to 2 MiB of virtual stacks for those two lazy workers, not current allocation or RSS. -
Fully embedded static frontend via
embedStaticDir. The Zigapagos island build output infrontend/distis compiled into the binary at build time via.static_files = .{ .embedded = &@import("static_assets").files }— there is no runtime dependency on thefrontend/distdirectory. The frontend shows authors, published posts, approved comments, and a magic-link comment flow. -
onAuthhook via.onAuth— logscollection + methodfor every successful session mint. With two auth collections and three methods in use, the log shows the three distinct paths:[onAuth] collection=authors method=webauthn record=<id> [onAuth] collection=authors method=custom record=<id> [onAuth] collection=commenters method=magic_link record=<id> -
Custom
AuthMethodplugin (ApiTokenMethod) via.auth = .{ .methods = .{ApiTokenMethod} }. Enabled onauthorsvia.auth.methods.custom = .{"api_token"}. Implements the plugin contractcreate(gpa, io, cfg) !Self/method(*Self) zigbase.AuthMethod/deinit(*Self) void, usingzigbase.AuthCtxhelpers (findByIdentity,rateLimit,reader). Returns azigbase.Resolution(.recordor.fail).- Initiate:
POST /api/collections/authors/auth/api_token/initiate→{"flow":"direct"} - Complete:
POST /api/collections/authors/auth/api_token/completewith{ "identity": "[email protected]", "token": "<bio-value>" }
- Initiate:
-
Comptime
.indexesonauthors.contact_emailwith.collation = .nocase(case-insensitive lookup). This demonstrates the correct tool for indexing comptime-managed collections —.indexesin the collection spec, because physical columns ARE named by their human field name (contact_email), not an internal id. -
Tier-2 SPA fallback routing via
.static_routes(issue #183). A single.{ .match = "/app/**", .serve = "/index.html" }entry serves the embedded frontend shell for any GET/HEAD static miss below/app/, so client-side deep links (e.g./app/some/deep/route) resolve to the SPA shell instead of a 404. The serve target is validated against the embeddedstatic_assetsmanifest at compile time — a typo'd path is a build error, not a runtime surprise. Declaring.static_routesalso flips the Tier-1.spa-marker default off (set.enable_spa_marker = trueto run both tiers together). The/api/**namespace is never rewritten, so a missing API route still returns the JSON 404 envelope. Seetest/spa.e2e.test.ts.
Three collections use four auth methods, all disambiguated by onAuth:
| Collection | Method | How it works |
|---|---|---|
authors |
webauthn |
Passkey registration + authentication via browser WebAuthn API. See WebAuthn endpoints below. |
authors |
api_token |
Custom plugin: POST .../auth/api_token/complete with { "identity": "...", "token": "..." }. Token is verified against the record's bio field (demo only — use a dedicated hashed-token field in production). |
commenters |
magic_link |
POST .../auth/magic_link/initiate with { "identity": "..." }. Server emails a link. Set ZIGBASE_PUBLIC_URL for a clickable URL; otherwise look in the server log for the raw token. Account auto-created on first login (auto_create = true). |
POST /api/collections/authors/auth/webauthn/register/begin -- start registration
POST /api/collections/authors/auth/webauthn/register/finish -- complete registration
POST /api/collections/authors/auth/webauthn/initiate -- start login
POST /api/collections/authors/auth/webauthn/complete -- complete login, mint session
Building a full passkey UI requires navigator.credentials.create() / .get(), CBOR
encoding, and careful error handling — a substantial frontend project. The Zig/schema
wiring is complete; the UI implementation is left as a reader exercise. See the
WebAuthn spec and the src/auth/webauthn/ directory.
This example sets .enable_typegen = true in its App(.{…}) config, so its
binary carries the typegen subcommand.
End-user command (published tool, no Zig required):
# against a live URL (compiles a client from the running server's schema):
npx @zigbase/typegen --url http://localhost:8090 --out src/zbase.gen.ts
# offline form — reads the data directory directly (no server needed):
npx @zigbase/typegen --data-dir ./pb_data --out src/zbase.gen.tsRuntime introspection generates the db / realtime / files surface for the
four collections (authors, commenters, posts, comments). It does NOT
produce typed rpc.* methods — for that, use the comptime tier (see examples/golfsim).
How it is verified here: the e2e in test/typegen.e2e.test.ts starts the
plugins server to provision a data dir, then runs:
./zig-out/bin/plugins typegen --data-dir <provisioned dir> --out <tmp>/zbase.gen.ts…and asserts the generated file contains // generated by zigbase, createClient,
and all collection names. Run it locally with:
cd examples/plugins
mise exec node@24 -- npm install
mise exec node@24 -- npm run typecheck
mise exec node@24 -- npm run test:e2eSee the TypeScript SDK docs for the full three-tier strategy.
cd examples/plugins
./frontend/build.sh
(cd frontend && ../../../scripts/zigapagos.sh doctor dist --format=json)
mise exec [email protected] -- zig build
./zig-out/bin/plugins help
# --insecure-cookies: local dev over plain HTTP (auth cookies are Secure by default).
# ZIGBASE_PUBLIC_URL makes magic-link emails contain a real clickable URL.
# In local dev the token also appears in the server log (look for "magic_link token=").
# ZIGBASE_FIELD_KEY is REQUIRED here because authors.private_notes is .encrypted —
# the server refuses to start without it. Use a strong, persistent value in prod
# (losing it makes the encrypted data unrecoverable); it is never auto-generated.
ZIGBASE_FIELD_KEY=dev-only-field-key \
ZIGBASE_PUBLIC_URL=http://localhost:8090 ./zig-out/bin/plugins serve --insecure-cookies
# open http://127.0.0.1:8090/ (admin UI at /_/)The frontend has no local Node dependencies. The repository launcher fetches
the exact Zigapagos v0.5.0 package through npx; the build enables its strict
island-props gate and writes frontend/dist for embedStaticDir. The root
package remains only for the example's TypeScript E2E and runtime-typegen tests.
./zig-out/bin/plugins migrate preview --jsonThis reports 0001_create_audit_log and 0002_index_audit_note in declaration
order, without creating a database, invoking the storage/mailer plugins or executing
the migration callbacks. Both declare up without down (missing_reverse).
It does not tell you whether they have been applied, what SQL they will execute, or
whether their effects are safe to undo. Use migrate status --json --data-dir ./zb_data
for ledger state; unlike preview, status can create supporting database state.
ZIGBASE_FIELD_KEY is the primary key (generation 1 by default). To rotate it without
losing access to data already written under the old key, keep the old key available as a
read-only older generation and run rewrap to re-encrypt every cell under the new key:
# 1. Restart writing under the new key (generation 2); old v1: rows still decrypt via _V1.
ZIGBASE_FIELD_KEY=new-strong-key ZIGBASE_FIELD_KEY_GENERATION=2 ZIGBASE_FIELD_KEY_V1=dev-only-field-key \
./zig-out/bin/plugins serve --insecure-cookies
# 2. Re-encrypt every v1: cell as v2: (idempotent, fail-closed; --dry-run reports counts only).
ZIGBASE_FIELD_KEY=new-strong-key ZIGBASE_FIELD_KEY_GENERATION=2 ZIGBASE_FIELD_KEY_V1=dev-only-field-key \
./zig-out/bin/plugins rewrap --data-dir ./zb_dataZIGBASE_FIELD_KEY_GENERATION (1–64) is the generation stamped on new writes (the v<N>:
prefix); ZIGBASE_FIELD_KEY_V<M> supplies a read-only key for an older generation M. See
docs/recipes.md ("encrypt a field at rest + key rotation") and
docs/framework.md for the full rotation playbook.
This demonstrates the embedded static-files mode: the Zigapagos frontend is
compiled into the binary by embedStaticDir in build.zig. Delete
frontend/dist after building — the site still serves from the binary.
--serve-static is rejected as an unknown flag because the mode is
comptime-hardcoded. The other modes are shown by the blog (runtime flag) and
golfsim (hardcoded dir) examples.
The fact that this package compiles against the published zigbase module
is the proof that the documented plugin / schema / migration / cron / error
features are usable by an external consumer.