Releases: valthon/zigbase
Release list
ZigBase 0.13.0
[0.13.0] - 2026-08-09
Breaking
- The API error envelope changed shape. It is now
{"status": <int>, "code": "<string>", "message": "…", "data": {…}}— the top-levelcodeused to repeat the integer HTTP status and is now a frozen machine string (the integer moved tostatus). The three divergent shapes are gone: typed (rpc.*) routes, auth-method endpoints, andctx.jsonErrorall emit this one envelope instead of the old bare{"message": …}and{"error": …}bodies. Branch oncode;messagetext is not contract. Every built-in error response now carries acodethat matches itsstatus(401 →unauthorized, 403 →forbidden, 404 →not_found, 409 →conflict, 429 →too_many_requests, etc.) instead of silently defaulting tointernalat ~30 call sites across the auth, files, mail, accounts, senders, and analytics APIs. The official SDKs readmessageanddataonly and need no change. ctx.jsonError(status, code)now takes a message:ctx.jsonError(status, code, message).zigbase servenow takes an exclusive lock on its data dir and refuses to start when
anotherserveprocess already owns it. Two servers sharing one data dir silently
half-worked before (two JWT secrets, two schedulers running the same cron, two
provisioners racing the same DDL). Pass--ignore-lockfor the old behavior — that
instance is untracked and invisible tozigbase serve status/stop/logs.
Features
GET /api/meta— a public, unauthenticated capability probe reporting the running version and commit, which optional route groups this binary carries (admin, OAuth2, WebAuthn, magic link, tenancy, analytics, senders, mail webhook/unsubscribe), which build flags are on (Postgres, S3, vector, dev mode), whether collection metadata is frozen, where the public feature-state route is mounted, and the upload size limit. Tools no longer have to infer frozen mode by string-matching a 403. It exposes only facts an anonymous client could already establish by probing — never a config value, path, or credential — and it is deliberately separate from/api/health(liveness) and/api/state(per-subject feature flags).zigbase version --jsonandzigbase migrate status --jsonemit exactly one JSON object on stdout (prose and warnings go to stderr), for scripts and agents that would otherwise parse the human report.- Added
-Ddev-tools(default on): a build flag that comptime-gates the three pure development-time CLI verbsinit,agents-md, andtypegen(project scaffolding + schema-to-client codegen —src/scaffold*.zigandsrc/codegen/**, ~490 KiB in aReleaseSafe, stripped binary). Every artifact we publish — the GitHub release tarballs, the Docker image, and the@zigbase/servernpm packages — builds at this default and ships all three;-Ddev-tools=falseis an opt-out for a consumer compiling their own binary for their own deployment who has no use for scaffolding or codegen on a running server. A binary built that way still recognizes the verb names but exits non-zero with a message pointing back at-Ddev-tools=trueinstead ofUnknownCommand. zigbase doctorruns nine preflight checks over a deployment — JWT-secret
persistence, every@publicaccess rule enumerated by name, cookie security, bind
address, reverse-proxy coherence, mailer configuration, pending migrations, data-dir
writability, and auth records still carrying a legacy password hash — and exits1
when any of them is an error,2when it found warnings only, and0when the
deployment is fully clean.--productionjudges the
same facts as a production deployment, escalating the risky warnings (an anonymously
writable collection, insecure cookies, an unconfigured mailer, a spoofable
--trust-proxy) into errors.--jsonemits NDJSON findings plus one summary object, and
the check ids are frozen, so a script can match on them forever. It never opens or
creates anything under a data dir it already found unwritable — the two DB-backed
checks reportskippedinstead — so a read-only diagnostic never mutates a deployment
it can't safely reach.
The legacy-password-hash check stays a warning even under--production: a legacy
hash is a normal transitional state that re-hashes itself on the owner's next
successful login, so what a migrating operator wants is to watch the count fall to
zero, not to be blocked from deploying.- Two new frozen error codes.
payload_too_largereplaces a genericinternalon the 413 an over-size upload returns — a client fixes that by sending less data, so it must never be indistinguishable from a server fault.email_not_verifiedreplaces a genericforbiddenwhen login succeeds against an account whose email is unverified, so a client can route to its verify-email flow by matching a code instead of the message text. - The error code is now exposed on every SDK's error type —
ZigbaseError.code(TypeScript, Python) andZigbaseException.code(Dart, Kotlin). This is the handle that makes the frozen registry usable from a client: branch oncode, never onmessage. It is empty when the server sent no code, and a pre-unification integercodeis ignored rather than surfaced as one. - Error codes are now a frozen, documented registry (
src/error-codes.frozen): every code ZigBase emits — the ten top-level envelope codes and the 27 field-levelvalidation_*codes — is append-only and permanent, enforced by unit tests and by a CI guard that diffs the ledger against the base branch so a code can never be quietly deleted. Match oncode;messagetext is explicitly not part of the contract and may be reworded at any time. zigbase explain-code [CODE] [--json]— print the summary and long-form explanation for any frozen API error code, or list every code withexplain-codealone.--jsonemits exactly one JSON object on stdout (prose goes to stderr); the exit code is 0 for a registered code and 1 for an unknown one.zigbase importnow supports migration-scale NDJSON loads:--dry-runexecutes and validates every row through the full engine (defaults, encryption, auth transforms) then rolls back instead of committing, so a rehearsal writes nothing;--continue-on-errorisolates each row in its ownSAVEPOINTand skips a failing one instead of aborting the whole run, logging its finding to--error-logas NDJSON ({"line":N,"code":…,"detail":…});--progress Nprints a heartbeat to stderr;--jsonprints the run summary as one JSON object on stdout (created/updated/failed/total). A lossy import (failed > 0) now exits3, never0, so an agent or script cannot mistake skipped rows for success.zigbase import --manifest FILEloads a whole dataset — several NDJSON files, one per collection — in one command. Collections load in relation order (from a JSON manifest:{"zigbaseImportManifest":1,"collections":[{"collection":"authors","file":"authors.ndjson"},{"collection":"posts","file":"posts.ndjson","upsertKey":"slug"}]}), withfilepaths resolving against the manifest's own directory so a migration bundle is relocatable. Relation cycles and self-relations — which defeat any static load order — are loaded with the offending values stripped, then patched in by record id once every target row exists (a deferred row must carry its ownid). Arequiredfield on one of those relations has no legal two-pass row order, so it's refused up front instead of failing row by row.--manifestis mutually exclusive with--collection/--upsert-key/the positional file (the manifest supplies those per entry) and composes with the existing--dry-run/--continue-on-error/--error-log/--progress/--jsonflags. See docs/migration-tools.md.zigbase import --legacy-hashes bcryptimports users with their existing bcrypt password hashes, stored tagged as$zblegacy$bcrypt$<hash>(e.g.$zblegacy$bcrypt$$2b$10$...— the tag's
trailing$immediately precedes the bcrypt hash's own leading$). On the user's first successful login the credential is verified against the source hash and transparently rewritten as argon2id.$2a$,$2b$and$2y$hashes are accepted ($2x$is refused — its deliberately-buggy 8-bit handling is not reproduced here). Requires an auth collection, requires source ids to be preserved (the credential is matched to its row by id), refuses_superusers, and carries each row'sverifiedflag over so a cutover does not mail a verification demand to the whole user base. It is create-only: combining it with--upsert-key(or a manifest entry'supsertKey) is refused, because an updated row would land with no credential installed. See docs/migration-tools.md.--log-format text|json/ZIGBASE_LOG_FORMATswitches the whole log stream to one JSON object per line on stderr, and--log-level/ZIGBASE_LOG_LEVELsets the minimum severity (debug,info,warn,error). The env vars apply to every subcommand; the flags areserve-only.- New guide: Observability & machine-readable output (
docs/observability.md) — the log formats, the NDJSON consumption rule, the frozen error-code registry, and the--jsonCLI conventions. - The server is now distributed under the bare npm name
zigbasein addition to@zigbase/server, sonpx zigbase serve …works andrequire("zigbase")re-exportsbinaryPath(). The alias ships no binary and pins one exact@zigbase/serverversion, which stays the canonical package to depend on; installing both is harmless even though both provide azigbasecommand. The unscoped name is claimed by a one-time manual publish — seeclients/typescript/npm/RELEASING.md. - Added
tools/replay/zb_replay.py, a dependency-free parity-replay harness: record a backend's HTTP behaviour, replay it against its replacement, and diff. Matching is a recursive subset with volatile keys (ids, t...
ZigBase 0.12.0
[0.12.0] - 2026-07-26
Breaking
-
The three request-scoped allocator seams are now the typed
zigbase.RequestArenainstead of a barestd.mem.Allocator:RecordEvent.arena(ev.arenain hooks),Ctx.arena(ctx.arena/req.ctx.arenain custom routes and jobs), andRequestCtx.allocator(ctx.allocator). The allocator itself is the field.aon the wrapper, so every place you passed one of these seams to something that wants an allocator, append.a:ev.arena.alloc(...)→ev.arena.a.alloc(...),ev.record.object.put(ev.arena, …)→ev.record.object.put(ev.arena.a, …),std.fmt.allocPrint(req.ctx.arena, …)→std.fmt.allocPrint(req.ctx.arena.a, …),ac.ctx.allocator→ac.ctx.allocator.a. Passing a seam straight through to another ZigBase API that takes aRequestArenaneeds no change; the compiler flags every site that does.Why the break is worth it: these arenas die at the end of the request, and the old bare-
Allocatortype made the two easiest lifetime bugs invisible — handing an arena-scoped API a long-lived general-purpose allocator (a leak), or stashing a request arena somewhere that outlives the request (a dangling read).RequestArenais constructible only from a realstd.heap.ArenaAllocatorat the boundary that owns it, so the first mistake no longer compiles, and the deliberate.aescape hatch makes the second one greppable instead of the default path. -
The dev-only build option
-Ddev-clockis renamed-Ddev-mode(it already gated the frozen clock, seeded entropy, and test-capture; it now also gates the new fake field-crypto). Update any CI/e2e invocation of-Ddev-clock=…to-Ddev-mode=…. -
A file download URL built with a raw
.authsession token in?token=(rather than a.file
token fromPOST /api/files/token) is no longer authenticated by that token. No first-party
client did this — the SDKs and admin UI already use.filetokens or the auth cookie/header —
but a hand-built URL relying on the old behavior must switch to a.filetoken. -
jwt.signnow returnserror.TokenTooLargerather than minting a token that exceeds
jwt.max_token_len, so this module can never produce a token it would itself refuse.
An application putting more than ~3 KB into the caller-suppliedplclaim now fails at
sign time instead of at the next request. Applications within that budget are unaffected.
Features
zigbase.checkSql/checkedSql: comptime validation of raw-SQL table/column identifiers
against the.collectionsschema, failing the build on an unknown table or a mistyped
qualified column. Best-effort by design (tables strict, qualified columns checked, unqualified
columns/functions untouched) to guarantee zero false compile errors on valid SQL — including
upserts: theUPDATEinON CONFLICT ... DO UPDATE SETis recognized as a conflict clause
(no table operand), not anUPDATE <table>statement.zigbase.Query.select: a comptime, schema-checked single-table SELECT builder that emits
validated SQL + positional binds forqueryAs— an unknown table/column is a build error, and
binds are positional by construction. SELECT-only / single-table in v1 (joins, writes, andin
are noted as future work).- Official Dart client SDK (
clients/dart, pub packagezigbase_client): REST records API with offset + cursor pagination, injection-safe filters, per-collection auth (password, OAuth2/PKCE, sessions), pluggable auth stores, file uploads/URLs, accounts/analytics/senders services, and realtime subscriptions over WebSocket with auto-reconnect. Dart VM, Flutter, and Flutter web. - Dart codegen. The client generator now emits Dart alongside TypeScript. Pass
--lang darttozigbase typegen(runtime introspection) orzig build gen-client
(comptime, via thegenClientSteplangoption) to generate azbase.gen.dart— concrete
typed record classes, per-collection typed services (typed CRUD, a fluent where-builder that
compiles to server filter strings, int/fixed decimal-string coercion, typed expand, files, and
realtime) over the base@zigbase/clientDart SDK's newpackage:zigbase_client/typed.dart
runtime. Typedrpc.*, auth-method, and feature-flag surfaces remain TypeScript-only for now. zigbase.testingcan now boot apps that declare.encryptedfields (#260): passStartOptions.field_keyfor real AES-GCM, or let it default to a dev-only fake-encrypt mode that stores readablefake:<key>:<value>at rest (label defaults to@test@) so encrypted values are eyeball-able while debugging. Also selectable onzigbase serveviaZIGBASE_FIELD_CRYPTO=fake. Fake crypto is compiled out of release binaries (thedev_modegate) and its envelopes are mutually unreadable with real ciphertext, so a fake DB can never be served by a production binary.- Added
jwt.verifyIntoandjwt.peekClaimsInto, which decode and verify a token into a caller-provided scratch buffer with zero heap allocation. An over-large token fails closed witherror.TokenTooLarge.jwt.scratch_size(16384) sizes that buffer for the measured worst case — escape-heavy claims forcestd.jsonto copy rather than borrow, and the consumption-to-token-length ratio rises with size (>4x at ~5.7 KB), so it covers 14800 bytes for any token withinmax_token_len. The allocator-takingjwt.verify/jwt.peekClaimsremain for callers already holding a request arena. zigbase.jwt,zigbase.crypto, andzigbase.RequestArenaare now public exports of the framework module, for consumers that need to mint/verify tokens, derive keys, or take the compile-enforced request-arena contract type directly.- Kotlin client SDK (
clients/kotlin, Mavenio.github.valthon:zigbase-client0.1.0): coroutines-firstZigbaseClientcovering auth (password, refresh, OAuth2/PKCE, sessions), records CRUD with offset + cursor pagination and an injection-safe filter builder, multipart file uploads, file URLs/tokens, and accounts/analytics/senders services. Realtime and typed codegen tiers follow. - Kotlin SDK realtime tier (
zb.realtime, bundled — no extra dependency): ack-gatedsubscribe/subscribeTopicwith an unsubscribe-function return,stream()/streamTopic()coldFlows, custom broadcast topics (signal/message), automatic re-auth fromauthStoreon login/logout/refresh, and exponential-backoff reconnection with full resubscribe. - Kotlin SDK typed tier:
zigbase typegen --lang kotlingenerates@Serializablerecord data classes withfromRecordcoercion,Create/Updatepayloads withtoMapwire encoding, injection-safe fluent filter builders, and typed collection services (plus Flow-based typed realtime) over the newio.github.valthon.zigbase.typedruntime — golden-gated in CI against the dating fixture. zigbase typegen --lang kotlingains a--package <name>flag that sets the emittedpackagedeclaration, honored on both the CLI and the comptimegen-clientbuild step, so a consumer wiringgenClientStepwithlang: "kotlin"targets their own app's package instead of getting an unoverridable namespace in a file marked "do not edit". Unqualified invocations still default to the dating fixture'sio.github.valthon.zigbase.codegen.datingnamespace (keeping the committed golden andzig build gen-dating-kotlin-clientbyte-stable).captcha.Resultandoauth.discovery.Endpointsgain adeinit(allocator)that frees their
owned strings, so a result produced with a non-arena allocator can be released. Callers on the
request-arena path (the usualctx.verifyCaptcha,resolve/parseDocument) do not need it.- New
zigbase importsubcommand +zigbase.Importlibrary entrypoint: encryption-aware,
offline (no HTTP server) bulk NDJSON record import that streams and batches through the
record engine — validation, defaults,.encryptedfield envelope, and auth password
hashing all applied — with optional--upsert-keyidempotency and source-id preservation. - Python client SDK (
clients/python, PyPIzigbase0.1.0): syncZigBaseand asyncAsyncZigBaseclients covering auth (password, refresh, OAuth2/PKCE, sessions), records CRUD with offset + cursor pagination and an injection-safe filter builder, file URLs/tokens, and accounts/analytics/senders services. Realtime and typed codegen tiers follow. - Python SDK realtime tier (
zigbase[realtime]):AsyncZigBase.realtimewith ack-gatedsubscribe/unsubscribe,stream()async iteration, custom broadcast topics (signal/message), automatic re-auth on auth-store changes, and exponential-backoff reconnection with full resubscribe. - Python SDK typed tier (
zigbase[typed]):zigbase typegen --lang pythongenerates Pydantic v2 record models, injection-safe fluent filter builders, and typed sync/async collection services (plus async typed realtime) over the newzigbase.typedruntime, golden-gated in CI against the dating fixture. Aselect-typed field'seq/neq/in_listacceptNonefor null filtering; a generated record'sexpandattribute (and each relation on its<Rec>Expandsubmodel) defaults to an empty value, so manual instantiation (tests, mocks) never requires building an expand submodel by hand. - Vendored/native component versions (SQLite, sqlite-vec, zap, facil.io, zigbase) are now
discoverable viazig build versions, the enriched--versionoutput + a startup log line,
and aversionsobject onGET /api/health. - Building against an unsupported Zig version now fails at compile time with a clear
required-vs-actual message instead of an opaque deep-compilation error.
Fixes
- Many of the memory-leak fixes below were surfaced by the allocator-ownership migration described
under Internal. They share a shape: the leaked scratch was always reclaimed by the
per-request or per-job arena a deployed server passes, so running servers were unaffected — but
the leak was real for a framework consume...
ZigBase 0.11.0
[0.11.0] - 2026-07-07
Breaking
- The
onBootstrap/onBeforeServe/onBeforeTerminatelifecycle hooks now returnanyerror!void(wasvoid) — update existing hook signatures (afn (...) voidno longer coerces). A returned error fromonBootstrap/onBeforeServefails the boot; anonBeforeTerminateerror is logged (it fires in a shutdown defer).
Features
- Admin UI:
editorfields now use a rich-text WYSIWYG editor (bold, italic, headings, lists, links, blockquote, inline code) that stores sanitized HTML, andjsonfields use a code editor with live validation, a Format button, and Save disabled while the JSON is invalid. - App-scoped context: declare a context type at comptime with
App(.{ .app_context = T }), install it once inonBootstrapviactx.setAppData(T, &value), and read it anywhere (handler/hook/job/cron) as a*Twithctx.appData(T)— one explicit, typed handle replacing module-level globals + bootstrap setter rituals. Declaring.app_contextmakes setting it a boot contract (the server refuses to start ifonBootstrapnever installs the handle); apps that don't declare it pay nothing. - Route-level auth-collection gating:
.auth = .{ .authed = "<collection>" }requires a route's principal to belong to a specific auth collection (with an optional.allow_superuser = trueto additionally admit superusers). The gate is fail-closed — a token from any other collection, a superuser without opt-in, or an empty-id principal is rejected with the same401as no token at all (no oracle) — and comptime-validated: the named collection must be declared in.collectionsand be of.type = .auth, else the build fails. Plain.authedstill accepts any authenticated principal. - New
App(.{ .collections_frozen = true })config key asserts that collections do not change after boot + migrations. Frozen apps get the parsed-collection-metadata cache on every backend — including Postgres, where it is otherwise skipped because a concurrent instance couldALTERcollections unseen — and the runtime collection create/update/delete endpoints return403(schema then evolves via.migrations+ a redeploy). Defaultfalseleaves today's behavior unchanged (cache SQLite-only, DDL endpoints live). - Cron expressions now accept case-insensitive 3-letter month (
JAN..DEC) and day-of-week (SUN..SAT) names in the month and day-of-week fields (e.g."0 9 * * MON-FRI"), in addition to numbers. Steps (*/n) remain numeric. - Pluggable error reporter: the terminal backstop every framework-swallowed error routes through is now a swappable plugin selected via
App(.{ .reporter = MyReporterPlugin }), mirroring.storage/.mailer. The default picksSentryReporterwhenZIGBASE_SENTRY_DSNis set (POSTs a Sentry envelope) andLogReporterotherwise (a structured backstop line[phase] err_name: message); a custom plugin implementscreate/interface/deinitand returns aReporterwhosereportreceives aReport{ .message, .err_name, .phase, .level }(theReporter,Report,LogReporter,SentryReporter, andDefaultReporterPlugintypes are re-exported). Consumers route their own swallowed-but-notable errors through the SAME backstop withctx.reportError(err, "fmt", .{args})— theonErrorhandler then the reporter — tagged with the new.apperror phase; it is best-effort and non-failing (never blocks or fails the caller, swallows its own allocation failure) and works from a route handler, hook, job, or cron. - Error reports deliver non-blocking with TTL dedup: the Sentry POST is enqueued on the in-process memory queue and performed on a pool worker — never inline on the thread that swallowed the error and never on the DB writer, so reporting never blocks a request/job/cron path (a failed POST is logged and dropped, never retried into a loop). A repeat of the same
(message, phase)withinApp(.{ .reporter_dedup = .{ .window_s = 60 } })(the default) is suppressed so a hot error path reports once per window instead of flooding Sentry;.reporter_dedup = .offreports every swallowed error and compiles the dedup map out entirely. - Record read endpoints (
GETlist and get-one) accept afields=query param for response projection: a comma-separated list of dot-paths selects which keys are returned (e.g.fields=id,title,expand.author.name), with*for all keys at a level and a leading-to exclude. Projection descends intoexpanded relations (objects and arrays) and is a pure output filter applied after expand and access rules — it can only narrow a response, never reveal a field the record wouldn't otherwise return. - Programmatic list filters accept bound placeholder values: put
?tokens infilterand pass a parallelfilter_argsslice (ctx.records().list(...)). Each?binds its value (.string/.int/.float/.bool/.null) as a literal SQL parameter that is never re-parsed as filter grammar — the injection-safe way to splice a runtime value into a filter. A placeholder is coerced by the target field's type exactly as an inline literal would be (soprice = ?with.{ .float = 5.0 }matches the same rows asprice = 5.00). Placeholders bind 0-based left-to-right; a placeholder-count vs.filter_args.lenmismatch is a louderror.BadFilter(so a stray?on the REST?filter=path fails closed). - Mail:
ctx.mail()messages can now carry file attachments (#219) — setMailMessage.attachmentsto a slice of{ filename, content_type, data }(the canonical use is a.icscalendar invite). The message body is wrapped inmultipart/mixedwith one base64 part per attachment; the default (&.{}) leaves existing mail byte-for-byte unchanged. Attachments ride through every backend (SMTP/Command get the raw MIME, SES switches to Raw MIME, Postmark uses its nativeAttachmentsarray) and survive the durable queue round-trip.filename/content_typeare CRLF/control-char checked, and a new.mail.max_message_bytescap (default 10 MiB) rejects an over-sizedsend/enqueueat the call site witherror.MailTooLarge. Onlycid:inline images remain unsupported. zigbase migrate dump [--out <file>]introspects the live database and writes a canonical, dialect-nativestructure.sql(stdout by default;--outwrites a file). SQLite emits the exact stored DDL; Postgres reconstructs it from the system catalogs — no externalpg_dump. The output is deterministic (no timestamps) so it diffs cleanly and re-runs to recreate the schema for a fast test DB; it also emits the applied-migration ledger so a restore lands at the same migration state. It is a snapshot for inspection/diffing/test-setup, NOT a schema source (that is.collections), and is never loaded at boot. This completes themigrateCLI trio alongsidestatusandrollback.zigbase migrate statusreports your comptime.migrationsas applied (with the ledger timestamp) or pending in declared order, and separately flags orphaned ledger rows — applied migrations no longer present in the binary — with a conciseN applied, M pending, K orphanedsummary. It reads the_migrationsledger only and applies nothing.zigbase migrate rollback [N]reverses the N most-recently-applied consumer migrations, newest first (N is a positional integer, default 1); system migrations are never touched. The reverse of a migration isdown orelse change(the mirror of the forwardchange orelse up): an explicitdownruns as-is, otherwise thechangere-runs inverted. Each migration's reverse body and its ledger-row delete commit in one transaction (honoring.transactional), so re-applying afterward works. It fails loudly and changes nothing it cannot undo: a lone-upmigration, a non-transactionalchange, or an orphaned ledger row is refused; achangethat reverses into an irreversible op (raw/records()/a.was-less drop, oraddForeignKeyon SQLite) is rolled back by its transaction and named.Nbeyond the applied count rolls back all of them.- Migrations gain a dialect-aware schema DSL (
m.createTable/addColumn/addIndex/renameColumn/addForeignKey, …) and auto-reversiblechangemigrations: write the forward change once and it inverts for rollback.up/downremain for irreversible steps; a per-statementm.raw(.{ .sqlite, .postgres })breakout and records-awarem.records()data transforms (#241) round it out. Migrations stay transactional by default with a per-migration.transactional = falseopt-out. (A schema dump lands next.) data.queryAs(T, conn, alloc, sql, args)(and thectx.records().queryAs(T, sql, args)wrapper) decode raw-SQL result rows into a structTby matching each field to the result column of the same name (respectingASaliases) instead of by position — so a reordered or newly-insertedSELECTcolumn can no longer silently misalign a hand-writtencolumnText(n)mapping. Args bind positionally (?1..?N, rewritten to$non Postgres); fields decode by Zig type ([]const u8, integers, floats,bool, and?Tover nullable columns, with SQLNULL→null); extra result columns are ignored; a non-optional field with no matching column errors witherror.ColumnNotFound. Works on both the SQLite and Postgres backends.- Optional S3 presigned-URL serving: with the comptime
App(.{ .files = .{ .s3_presign_redirect = true } })option, authorized file downloads on the S3 backend are served as a 302 redirect to a time-limited presigned GET URL (s3_presign_ttl_s, default 900s) instead of proxying the bytes through the server — offloading bandwidth/CPU. Default is unchanged (proxy). Authorization still runs per-request before the redirect; the issued URL is a bearer capability valid until it expires. ctx.sms()— transactional SMS, the outbound-text analog ofctx.mail().ctx.sms().send(.{ .to, .body })delivers synchronously andctx.sms().enqueue(...)rides the background q...
ZigBase 0.10.0
[0.10.0] - 2026-07-04
Breaking
- Auth configuration is now grouped under one comptime
App(.{ .auth = .{ … } })key. The previously-scattered top-level auth keys moved under it:.auth = .{ .beforeRegister = fn, … }(the flat lifecycle-hook group) →.auth = .{ .hooks = .{ .beforeRegister = fn, … } }.auth_methods = .{ … }→.auth = .{ .methods = .{ … } }(both the bare-tuple and.{ .builtins, .custom }forms).captcha = .{ .provider, .secret }→.auth = .{ .captcha = .{ … } }.session_store = .epoch | .table→.auth = .{ .session = .{ .store = … } }.session_gc_cron = "…"→.auth = .{ .session = .{ .gc_cron = "…" } }
Each old spelling is now a pointed@compileErrornaming its new location, so consumers get an actionable migration message rather than a silent no-op. Runtime auth knobs (ZIGBASE_AUTH_TOKEN_TTL,ZIGBASE_OAUTH_STATE_*, cookie security,ZIGBASE_RATE_LIMIT_*) intentionally remain env-configured and are not part of the.authgroup.
beforeAuthSuccessnow fires on the legacyPOST …/auth-with-passwordandPOST …/auth-refreshroutes — including_superusers(the admin SPA login). A hook that errors unconditionally will lock superusers out of the admin UI (fail closed, by design); fix the hook and rebuild.events.AuthMethodgained a.refreshvariant; exhaustiveswitches over the enum must add an arm (compile error).- Custom-route surface:
http.Response.file_pathis nowResponse.file(.file_path = p→.file = .{ .path = p }). Plain-path delegation behavior is unchanged; the new optionaloffset/lenwindow enables handler-planned partial responses. - Postgres backend (
-Dpostgresbuilds): the defaultsslmodeforpostgres://URLs is nowverify-full(the server certificate chain and hostname are verified — see the TLS entry under Security). A server without TLS (e.g. a docker-compose dev database) now fails at startup with an error naming the one-parameter fix: append?sslmode=disable(plaintext) or?sslmode=require(encrypted, unverified) toZIGBASE_DB_URL. Explicitly configured modes belowverify-fullkeep working and log one startup warning. - Side-effect auth successes are now uniform 204 No Content:
confirm-verification(was{"verified":true}),confirm-password-reset(was{"success":true}),webauthn/register/finish(was{"registered":true}). Treat any 2xx as success;@zigbase/clienttypes updated toPromise<void>. - The magic-link consume URL is now dash-case:
GET …/auth/magic-link/consume(wasauth/magic_link/consume). Hard cutover — links emailed by pre-upgrade servers 404 (tokens are short-lived). The method slug (/auth/magic_link/initiate|complete,onAuthtag) is unchanged. - The built-in job kinds are now config-gated (embedded consumers):
ctx.webhookrequires.webhooks = true;ctx.mail().enqueuerequires.mail(use.mail = .{}for defaults) or a.mailerplugin. Without the key the kind is not compiled in and enqueue fails loudly with a hint. Direct mailer delivery (verification/password-reset emails) is unaffected. The kind namesmail/webhookremain reserved either way. - Removed the legacy
.jobs = .{ .pool_size = N }spelling; set.pools = .{ .jobs = N }. The old key is now a pointed compile error (N1). RecordEvent.ctxis nowRecordEvent.rctx(ctxalways means*Ctxin a hook signature). Mechanical migration:ev.ctx.→ev.rctx..RecordEvent.appwas removed — it put the UB footgun (ev.app.allocatorvsev.arena) one dot from every hook. Use the hook'sctx.app; allocate record data withev.arena. (JobEvent.app/ErrorEvent.appare unchanged.)RouteEventwas deleted. It was never passed to a live route (handlers take*Ctx); it existed only in tests. Events carry data;ctxcarries capabilities.GET /api/collectionsandGET /api/settingsnow return{"items":[…]}instead of a bare JSON array (superuser endpoints; admin SPA + typegen updated).zigbase typegen --urlrequires a server from this release.GET /api/collections/:col/auth/oauth2/providersreturns{"items":[…]}(was{"providers":[…]});@zigbase/client'slistAuthProviderstypes updated.zigbase.Serveris now a genericpub fn Server(comptime gates: Gates) typeinstead of a concrete struct — the built-in route table is assembled per-app fromGates(R2-3). Framework consumers reach it exclusively throughApp(cfg).runCli/serve, which thread the newgatesconfig automatically; only code that namedzigbase.Serverdirectly (bypassingApp) needs an update, e.g.server.Server(.{})for the historical all-on table.- Storage plugin vtable:
localPath(ctx, alloc, col, record_id, filename)is nowfetch(ctx, io, alloc, col, record_id, filename)— return a local filesystem path whose contents are the file, materializing it locally if necessary;null= the backend has no such object. Local-disk backends migrate mechanically (rename + theioparameter). GET /api/sendersnow returns{"items":[…]}instead of a bare JSON array (unified with the analytics endpoints' envelope).- The
__featuresrealtime channel now emits the standard{"type":"signal","topic":"__features"}frame instead of the bespoke{"type":"features.changed"}frame.
Features
- Admin UI: an Email view — manage verified sender identities (list / invite / delete), the suppression list (add / remove / filter by reason, incl. one-click-unsubscribe entries), and read-only bulk-send batch progress, with a read-only mail-policy strip. Backed by the existing mail APIs plus a new superuser
GET /api/mail/config(booleans only, no secrets). - Admin UI: a Files view — browse per-collection file fields with image previews, upload/replace files, and remove them, plus a read-only storage-backend strip (local disk vs S3). Backed by the existing records + file-serve APIs plus a new superuser
GET /api/files/config(non-secret backend info only — never the S3 credentials). - Admin UI: a Logs & realtime view — browse app analytics events with name/actor/since filters and cursor pagination, view an app-declared rollup's aggregated series, and a read-only realtime health strip (live connection count + caps). Backed by the existing analytics APIs plus a new superuser
GET /api/realtime/stats. The Logs tab is capability-gated: it only appears when the app enables.analytics(the stockzigbase servebinary doesn't, so the tab is hidden there). - Admin UI: a Users view for managing superusers and auth-collection users — list, search, create/edit/delete, admin password reset, and a read-only OAuth-providers panel. The admin SPA is now split into browser-native ES modules (no build step) and every asset is served with a CRC32
ETag. - Self-service password change via
PATCH /api/collections/:col/records/:id: non-superusers must include a verifyingoldPassword— a non-oracle check (wrong/missing values, unknown records, and passwordless targets all return the login-identical400 "Invalid credentials."with argon2 timing padding), rate-limited under a new"pwchange"scope before any argon2 work runs. On success every other session for the record is invalidated (tokenKey rotation, plus_sessionspurge in table mode) while a self-change keeps the calling device signed in via freshSet-Cookieheaders. ThebeforePasswordChange/afterPasswordChangelifecycle hooks now fire on this path too.@zigbase/clientgainscollection(col).changePassword(id, oldPassword, newPassword)(transparent re-auth in token mode). - Official multi-arch Docker image,
ghcr.io/valthon/zigbase— built from the existing static-musl release binaries (no in-image compilation),distroless/staticbase, non-root by default. The supported deployment path for Windows-hardware users, since ZigBase has no native Windows build. Seedocs/docker.md. migrate-dbnow fully supports circular relations (self-relations and mutual/N-node cycles) end-to-end, not just provisioning: cycle-edge foreign keys are omitted from the initialCREATE TABLEand added back asDEFERRABLE INITIALLY IMMEDIATEconstraints (Postgres cannot create tables with circular inlineREFERENCESin any order), and the load transaction defers those constraints toCOMMIT(SET CONSTRAINTS ALL DEFERREDon Postgres,PRAGMA defer_foreign_keys=ONon SQLite) so rows load in any order regardless of reference direction. Previously this schema shape failed outright during provisioning; SQLite targets were always cycle-capable (inline FK DDL tolerates cycles) but are now verified round-trip end to end. A dataset with a genuinely dangling reference fails clearly atCOMMIT, naming the affected collections, and rolls the whole load back.- Bulk list sends:
ctx.mail().sendBulk(...)fans one templated message out as per-recipient-rendered emails over the durable queue, with submit-time validation/dedup, per-recipient suppression checks, idempotent redelivery, and a durable send-report (_mail_batches/_mail_batch_recipients, readable as superuser via the records API) plusbatchStatus/cancelBatch. - Scheduled sends:
ctx.mail().deliverAt(msg, .{ .at | .delay_s })returns a cancellable job id,ctx.mail().cancel(id)calls a pending send off, andsendBulkaccepts.at— the documented drip-sequence primitives. - One-click unsubscribe (RFC 8058): configure
.mail.unsubscribe_base_url(orZIGBASE_UNSUBSCRIBE_BASE_URL) and bulk mail automatically carriesList-Unsubscribe/List-Unsubscribe-Postheaders pointing at the new signed publicPOST/GET /api/mail/unsubscribeendpoint; one-click opt-outs are recorded asunsubscribesuppressions that block list mail only (transactional mail is unaffected). - Per-queue rate throttling: durable queues accept
.rate = .{ .per_second = N }— a token-bucket ceiling enforced at claim time (e.g. match SES's 14 msg/s). - `ctx.mail()...
ZigBase 0.9.0
[0.9.0] - 2026-06-30
A large release: a PostgreSQL backend alongside the default embedded SQLite, plus multi-tenancy, relationship-based authorization, full-text & vector search, product analytics, a transactional email subsystem, background job queues, outbound webhooks, CAPTCHA verification, and a realtime broadcast API.
Breaking
- Consumer migrations (
.migrations) now receive a*zigbase.Migratorinstead of(alloc, io, w). Change eachuptofn (m: *zigbase.Migrator) anyerror!void: the writer ism.db, the arenam.arena, the requeststd.Ioism.io.Migratorcarries the active SQL dialect so one migration runs on either backend —m.execLowered(sql)lowers SQLite-flavored DDL/seeds to the active backend (byte-identical on SQLite),m.exec(sql)runs raw backend-specific SQL, andm.dialect.kind/m.rawFor(.postgres, …)branch per backend. SQLite-only consumers just swapw→m.db. ErrorPhasegained a.webhookvariant (additive). AnonErrorhandler that switches exhaustively overErrorPhasemust add a.webhookarm.
Features
- PostgreSQL backend (opt-in). ZigBase can now run on PostgreSQL instead of the default embedded SQLite, selected by configuration alone — a
postgres://ZIGBASE_DB_URLin a-Dpostgresbuild; application code and collection definitions are unchanged.- Full feature parity: record CRUD and the typed filter/sort/expand/search query engine, the access-rule + abilities + tenancy authorization stack, analytics rollups, the KV/TTL/rate-limit/feature-flag stores, field encryption + key rotation, the deterministic test-clock, and typed-client codegen all work identically on Postgres — verified against a live server in CI.
- Realtime across app instances: a Postgres deployment can run multiple stateless app instances against one database, and record-change events fan out to subscribers on every instance via
LISTEN/NOTIFY. The NOTIFY payload carries only an opaque token — never row data — so encrypted fields never leave the database in plaintext. - Pure-Zig wire driver: no libpq, C, or OpenSSL dependency (TLS via
std.crypto.tls.Client, SCRAM-SHA-256 viastd.crypto); the default SQLite build links zero new symbols. Transport is encrypted but the server certificate is not yet verified in any sslmode (verify-fullis a tracked follow-up) — use the Postgres backend over a trusted network path until then. migrate-dbCLI:zigbase migrate-db --from ./data.db --to "postgres://…"copies an existing SQLite instance (schema and data) into a fresh Postgres database — provisions the equivalent schema, bulk-loads every table in one atomic transaction, preserves ids/timestamps/metadata, and carries encrypted-field envelopes byte-for-byte (no key needed). FK suspension requires a superuser target; a managed non-superuser Postgres uses a lightly-tested topological-order fallback.- Vector search on Postgres via pgvector, behind the same
-Dvectorflag and?vector=API as SQLite's sqlite-vec — one flag enables KNN on both backends. - Admin backend badge: the admin UI shows a "SQLite"/"Postgres" badge, sourced from a new
backendfield onGET /api/health(the kind only — never the connection string or credentials). - The default SQLite single-file deployment is unchanged. One safeguard: a stock (non-
-Dpostgres) binary now readsZIGBASE_DB_URLand logs a prominent warning if it is apostgres://URL, rather than silently writing to local SQLite.
- Account-scoped multi-tenancy (#156).
App(.{ .tenancy = .{ .enabled = true, .auth_collection = "users" } })plus a collection's.tenant_field = "account"auto-scopes every read/write (and realtime delivery) of a tenant-owned collection to the request's active account via a boundtenant_field = ?predicate; create stamps the owning account and update rejects cross-tenant moves. The active account resolves from anX-Account-Idheader or a signedzb_accountcookie, verified against an active_membershipsrow (fail-closed). Adds built-in_accounts/_memberships/_invitationscollections, a configurable role order (viewer < editor < admin < owner),POST /api/accounts/:id/activate, and the@request.account.id/.role/.idsrule macros. Superusers bypass;zigbase.crossTenant(rctx)is the explicit admin override. Apps with no.tenancyare byte-identical to before. - Relationship-based row abilities (#155). Declare per-collection, per-action authorization by the principal's relationship to the row:
App(.{ .abilities = .{ .projects = .{ .update = .{ .relationship = .{ .via = "account", .min_role = .editor } } } } })authorizes a row when the principal holds a membership (role ≥.min_role) of the account it belongs to. Abilities compose into the existing guard stack, narrow the LIST endpoint, are fail-closed and comptime-validated, andctx.can(.action, "col", id)+GET …/records/:id/abilitiesexpose them to custom routes. Collections with no.abilitiesare byte-identical to before. - Search on the list endpoint (#157).
- Full-text search ships in the default build: mark a
text/editorfield.searchable = trueand query with?search=<terms>— ranked by relevance, withAND/OR/NOT/prefix operators, provisioned automatically (SQLite FTS5; Postgrestsvector+ GIN). Search composes with the full authorization stack and structured filters:?search=X&filter=Yreturns the scoped intersection (never an unscoped query) and terms are always bound (no injection). - Vector / nearest-neighbor search behind an opt-in
-Dvectorflag:?vector=<field>[:cosine|:l2]:<embedding>KNN ordering composed into the same scoped query (sqlite-vec on SQLite, pgvector on Postgres). Not compiled into the default build.
- Full-text search ships in the default build: mark a
- Product analytics (#158).
ctx.track("user.signup", .{ .plan = "pro" })appends an immutable event — actor, tenant, and timestamp stamped server-side — to the new_eventscollection. Declarative rollups (App(.{ .analytics = .{ .rollups = … } })) incrementally aggregate events into summary tables on the scheduler. Tenant-scoped, fail-closed read API:GET /api/analytics/events(raw feed) andGET /api/analytics/rollups/:name. Usable standalone with no config. - Email subsystem (#154) on
ctx.mail():- A safe multipart HTML + plain-text template engine (HTML-escaped by default, named partials + shared layout, no code evaluation).
- First-class SES and Postmark HTTP providers behind the
Mailervtable (SMTP/Command unchanged), a per-messageFromoverride, and aCaptureMailerfor asserting outbound mail in tests with no network. - Verified per-account sender identities and bounce/complaint suppression with an inbound provider webhook, all tenant-scoped. Enforcement (
.mail.require_verified_sender,.check_suppression) defaults off, so an app that only calls the existing mailer is unaffected. ctx.mail().send(...)/.enqueue(...)/.deliverLater(...);mail.Emailgainshtml_body/reply_to; the framework owns header-injection (CRLF) defense for every backend.
- Background jobs & queues. A generic multi-queue/worker/job engine: declare named
.queues(memory or durable, prioritized, per-queue retry),.workers(bound to queues, strict-priority drain, concurrency), and a.jobskind→handler registry, then enqueue from anywhere withctx.enqueue(.queue, .kind, payload). Durable queues persist to_queue_jobswith at-least-once delivery, crash-reclaim, and GC; memory queues need zero schema. Powers the built-in"mail"and"webhook"job kinds. - Outbound webhooks.
ctx.webhook(url, payload, .{…})delivers in the background on the queue engine with retry/backoff (honoringRetry-After, capped), optional HMAC-SHA256 signing, and a stable per-deliveryIdempotency-Key; TLS certificate verification stays on. - Realtime broadcast API for custom (non-record) channels, from a route or job:
ctx.realtime().signal(topic)(a payload-less re-fetch trigger, the default for private state) and.broadcast(topic, payload)(delivered verbatim), over the same WebSocket subscribe protocol clients already use. NewApp(.{ .realtime = .{ .canSubscribe = fn } })gates custom-topic subscriptions; a custom topic can never reach a real collection's record channel. - CAPTCHA verification (#140).
ctx.verifyCaptcha(provider, token)for reCAPTCHA v2/v3, hCaptcha, and Cloudflare Turnstile, configured viaApp(.{ .captcha = … })(dev-bypass when the secret is empty). - Custom-route ergonomics. Response builders (
ctx.json/jsonError/html/redirect/notFound), deferredctx.setCookie/addHeader(merged on both the success and error paths), lazyctx.query(),ctx.randomToken/randomHex, andctx.subjectCookie(an anonymous per-visitor id). A declarative route guard pipeline:.authnow also accepts apath_secretguard (constant-time shared-secret gate, bare-404 on mismatch) and.rate_limitadds per-route buckets keyed on the trust-proxy client IP.http.Cookiegains an optionaldomain. - Filter/rule grammar: a new
inset-membership operator (field in ("a", "b"), compiled to a boundIN (?, …), empty set fail-closed) and the@request.account.id/.role/.idsmacros that underpin tenancy and abilities.
Changed
- A
.nocase(case-insensitive) index now makes both uniqueness and lookups case-insensitive on SQLite. Previously a.nocaseUNIQUE index treatedBob@x.com/bob@x.comas the same identity, but the lookup was case-sensitive — so a user registered asBob@x.comcould not log in asbob@x.com. Identity/email lookups and=/!=/incomparisons against a.nocasecolumn are now case-insensitive, agreeing with the index (and matching the Postgres backend, which uses alower()functional index). The built-in auth identity index remains case-sensitive ...
ZigBase 0.8.0
Breaking
- Feature flags are now declared-only. Flags must be declared in the
App(.{ .flags = .{ … } })literal; only declared flags resolve. The v0.7 runtime-string APIctx.flag("arbitrary")(KV-or-false) has been removed — use the typedApp.flag(ctx, .name)for known flags, orctx.flagByName("name")(returns?bool, null when undeclared) for dynamic names. ctx.setFlagnow writes a declared-flag override. It writes theflag:<name>override key for a DECLARED flag and errorserror.UndeclaredFlagotherwise (the typed, compile-checked form isApp.setFlag(ctx, .name, enabled)). Previously it set an arbitrary<name>KV value.
Features
- Comptime feature-flag + experiment registry (#128/#129/#130). Declare
.flags(bare-bool default or.{ .default, .description }) and.experiments(.{ .variants, .weights, .sticky, .description }) in theApp(cfg)literal. Malformed declarations (unknown sub-key, non-bool flag, variants/weights length mismatch, empty/duplicate variants, all-zero weights) are loud@compileErrors. - Typed, compile-checked accessors.
App.flag(ctx, .name) bool,App.setFlag(ctx, .name, enabled) !void, andApp.experiment(ctx, .name, subject) ![]const u8— a typo'd flag/experiment name is a compile error (generatedApp.Flag/App.Experimentenums). - Runtime resolution.
ctx.flagByName(name) ?bool(dynamic read),ctx.flags().resolveAll(subject)resolves every declared flag + experiment in a single batched_kvscan, and deterministic experiment bucketing (FNV1a-64(name ++ 0x00 ++ subject)over cumulative weights) gives a stable variant per(name, subject). Per-flag overrides live in_kvunderflag:<name>; experiment weight overrides underexp:<name>:weights(JSON). - Admin UI gains a Feature Flags & Experiments screen (
/_/#/features) showing every declared flag (name, default, description, effective value) with a toggle to set/clear theflag:<name>override, and each declared experiment's variants with editable weight sliders that write theexp:<name>:weightsoverride; a "Reset to declared" action clears the override. Superuser-only; backed by the newGET /api/featuresendpoint. - New
GET /api/featuresendpoint (superuser) returns the comptime-declared flag + experiment registry alongside each entry's current_kvoverride — useful for custom admin tooling. - Feature exposure events: register
.onFeatureExposureto receive anExposureEvent({ kind: .flag | .experiment, name, subject, value, variant }) each time a declared flag or experiment is resolved. The hook is notify-only and zero-cost when unregistered (the resolver never builds the event without a handler). - Realtime feature signal: any flag/experiment override change (
ctx.setFlag/App.setFlagor an adminPUT/DELETEof aflag:<name>/exp:<name>:weightssetting) broadcasts a signal-only{"type":"features.changed"}frame on the public__featureschannel. Clients may subscribe anonymously and re-GET /api/stateon receipt; no per-subject state or experiment assignment is ever pushed over the socket. - Public feature-state endpoint (#130).
GET /api/state?subject=<id>is an unauthenticated, read-only projection of resolved flags + experiments:{ "flags": { "<name>": <bool>, … }, "experiments": { "<name>": "<variant>", … } }. It exposes resolved values ONLY — never the_kvkeys, defaults, weights, timestamps, or any superuser settings verb (those stay behindrequireSuperuser). A.stickyexperiment returns its persisted assignment here too (agreeing withApp.experiment), resolved reader-first so a caller-supplied subject can't storm the writer lock. Auto-mounts at/api/state; configure with.features = .{ .public_route = "/state" }to remap or.{ .public_route = .disabled }to turn off. - Typed
zb.flags.resolveAll(subject)in the TypeScript SDK.zig build gen-clientnow emits a fully-typed feature-state surface from yourApp(.{ .flags, .experiments }): flags as namedbooleans and each experiment as a string-literal union of its declared variants (FeatureState).await zb.flags.resolveAll("user-42")callsGET /api/stateand returns{ flags: { … }, experiments: { … } }with noany. Emitted only when flags/experiments are declared; the runtime-introspection tier omits it (no comptime metadata), matching typed routes and custom auth methods. - Sticky experiment assignments (#129): declare an experiment
.sticky = trueto persist a subject's first variant in_experiment_assignmentsso it survives later weight changes (new subjects still follow the current weights; empty subjects are never persisted). A framework-internal_experiment_gcjob — installed only when a.stickyexperiment is declared — reaps assignments older than the new.experiment_assignment_ttlconfig (in days, default90) hourly in bounded batches.
ZigBase 0.7.1
Features
- TypeScript client codegen now emits precise typed I/O for custom auth methods. Enable a custom method in the new struct form —
.custom = &.{ .{ .slug = "corp-sso", .Initiate = .{ .Input = …, .Output = … }, .Complete = .{ .Input = …, .Output = … } } }— andzig build gen-clientreflects the declared Zig types intozb.auth.<col>.<method>.{initiate,complete}interfaces (named by the Zig type, like the typedzb.rpc.*route surface). AvoidInput omits the input argument; avoidOutput maps toPromise<void>. Bare-string slugs (.custom = .{"slug"}) stay fully back-compatible and untyped. Typed customs are a build-time feature (the runtime-introspection typegen tier keeps them untyped, exactly like typed routes).
Fixes
- Exported
zigbase.Tx— the transaction scope passed to actx.tx(T, fn(*Tx) ...)callback. It was referenced in the docs but never re-exported from the public API, so consumers could not name the callback's parameter type. - The comptime per-auth-method
.rate_limit = .{ .custom = .{ .max = …, .window_s = … } }config form now compiles (it previously failed with a@tagName-on-a-struct error; only the.default/.offenum-literal forms worked). - The TypeScript client generator (
zig build gen-client) no longer hits the comptime branch-quota limit on apps with larger custom-route tables.
Internal
- golfsim example: added demos for per-device session management (
.session_store = .table+ctx.auth().revokeAllSessions/listActiveSessions/revoke), an atomic hold→booking convert viactx.tx(), a best-effort booking-confirmation webhook viactx.http(), and KV write-side seeding fromonBootstrap. Added a deterministic e2e suite that freezes time withZIGBASE_FAKE_NOWand captures the outbound webhook. Fixed a latent date-formatting bug in golfsim'sisoFromEpoch(signed-integer{d:0>N}emitted a+sign, breaking hold creation). - plugins example: demonstrates the comptime
.rate_limit = .{ .custom = … }per-method config, and documents field-key rotation (ZIGBASE_FIELD_KEY_V<n>+zigbase rewrap) in its README.
ZigBase 0.7.0
Breaking
- Custom handler/hook/job signatures now receive a unified per-request
*Ctx:- Untyped routes are
fn(ctx: *zigbase.Ctx) anyerror!zigbase.http.Response(wasfn(*RouteEvent)). - Record hooks are
fn(ctx: *zigbase.Ctx, ev: *zigbase.RecordEvent) anyerror!void(was one-argfn(*RecordEvent)). - Jobs are
fn(ctx: *zigbase.Ctx, ev: *zigbase.events.JobEvent) anyerror!void(was one-argfn(*JobEvent)). - Lifecycle hooks are
fn(ctx: *zigbase.Ctx, ev: *zigbase.events.LifecycleEvent) void. - Typed routes keep
fn(req: *zigbase.Req(In)) zigbase.RouteError!Out, but reach capabilities viareq.ctx(req.ctx.records(),req.ctx.http(),req.ctx.arena,req.ctx.app).
- Untyped routes are
- DB access is now uniform through the
Ctxcapability object:ctx.records()(list/get/create/update/delete),ctx.tx()(atomic writes), andctx.http()(outbound client). In abefore*hook,ctx.records()is bound to the triggering write's in-transaction connection, so a side-write commits/rolls back atomically with it. - Removed
RecordEvent.data,JobEvent.reader()/JobEvent.writer(),ev.caps(), and the publiczigbase.Datare-export. Migrate hook/job DB access toctx.records(); for raw SQL on a migration-owned table use the pooled writer viactx.app.pool.acquireWriter().
Features
ctx.tx(T, fn)runs several record writes in one atomic transaction — all
commit, or all roll back on any returned error. The callback receives a*Tx
whoset.records()exposes the fullRecordsAPI; all writes share the
in-transaction connection with no deadlock. Nesting is rejected immediately
(error.NestedTransaction).- Admin UI now includes a "Settings / Feature Flags" section (
#/settings) where
superusers can list, create, edit, and delete KV entries, and toggle boolean feature
flags with a checkbox — backed by the existing/api/settingsREST surface. - Auth lifecycle hooks (#98): a new
.authconfig group adds before/after hooks for
register,logout,refresh, andpassword-change, extending the Theme D
beforeAuthSuccessdiscipline into a uniform lifecycle. Before-hooks run with a*Ctx
bound to the action's connection (in-transaction for register / refresh /
password-change), soctx.records()writes commit atomically with the action; returning
an error aborts and fails closed (rolling back where a write transaction exists — e.g. an
abortingbeforePasswordChangeleaves the password unchanged and the reset token
un-consumed, an abortingbeforeRegistercreates no account). After-hooks are notify-only.
Hooks fire onregister(auth-collection record create),POST …/auth-logout,
POST …/auth-refresh, andPOST …/confirm-password-reset. A typo'd hook name or a
wrong-typed handler is a compile error. The existingbeforeAuthSuccessandonAuth
hooks are unchanged. - Handler/hook/job capability object: handlers, hooks, and jobs now receive a
*Ctxdirectly, exposingctx.records()(filtered/sorted/paginated list +
get/create/update/delete, withexpand/relations), an outboundctx.http()
client, and a standard error model (ctx.fail/ctx.invalid, error→status
mapping over the existing{code,message,data}envelope). Custom handlers no
longer need to drop to raw SQL or vendor an HTTP stack. - Encryption key rotation — at-rest field encryption now supports a primary (write) key plus older read-only key generations. The primary key is
ZIGBASE_FIELD_KEYat generationZIGBASE_FIELD_KEY_GENERATION(default 1, = thev<N>:envelope version written); older generations are supplied viaZIGBASE_FIELD_KEY_V<n>. Writes use the primary generation; reads dispatch on each value's envelope version. The single-key default is unchanged and fully backward compatible. zigbase rewrapcommand — re-encrypts every.encryptedfield across all collections under the primary key, and migrates legacy plaintext into ciphertext (the supported way to enable.encryptedon a column that already holds plaintext). Idempotent, transactional per collection, with--dry-run.ZIGBASE_FAKE_NOWnow also freezesCURRENT_TIMESTAMPand column DEFAULTs. The dev-only test clock previously froze the framework's own timestamps and a consumer's rawdatetime('now')/unixepoch('now')/strftime(…, 'now'), but the SQL keywordsCURRENT_TIMESTAMP/CURRENT_TIME/CURRENT_DATEand columnDEFAULT CURRENT_TIMESTAMPstill read the OS clock (they go through SQLite's VFS, not the SQL-function layer). On dev builds, connections now open against a wrapping VFS — a byte-for-byte copy of the default VFS with only its current-time hooks overridden — so those keywords and defaults honor the frozen instant too, making tables with timestamp defaults deterministically snapshot-testable. All file I/O still delegates to the genuine OS VFS unchanged, and the wrapper is compiled out entirely on a production build (-Ddev-clock=false).- Seeded entropy for deterministic IDs/tokens in test mode (
ZIGBASE_FAKE_SEED) — setZIGBASE_FAKE_SEEDto a decimalu64on a dev build to make record/field ID and token key generation reproducible across runs with the same seed, enabling stable snapshot tests. Gated by the samedev_clockbuild option asZIGBASE_FAKE_NOW: compiled out on production builds, so a production binary always uses the OS CSPRNG and cannot be seeded. Closes #95. - Expired-session garbage collection for
.session_store = .table(#114). Enabling the
table-mode session store now auto-installs a framework-internal recurring job that deletes
expired_sessionsrows in bounded batches on the writer — no opt-in required. The default
cadence is hourly; override it withApp(.{ .session_store = .table, .session_gc_cron = "…" })
(UTC, minute-granularity cron syntax). Nothing is installed in the default.epochmode (no
job, no timer — the zero-overhead guarantee is preserved). - Session management verbs on
ctx.auth()(#99):revokeAllSessions()("log out
everywhere"),refresh()(sliding re-mint, other sessions stay valid), androtate()
(bump + re-mint, keep this session and kill every other). Free-function forms
zigbase.auth.revokeAllSessions/refresh/rotate(ctx). Sessions remain stateless JWTs but
are now revocable via a per-auth-record token epoch (the default
App(.{ .session_store = .epoch })model) — no extra query on either the verify hot path
or login: the epoch is folded into the singletokenKeySELECT each already performs.
Existing valid tokens keep working: tokens minted before the epoch existed and freshly
created records both read as epoch 0. - New comptime config key
.session_store(.epochdefault, or.table). The.table
variant adds a server-side_sessionsstore for full per-device management:
ctx.auth().listActiveSessions()(withis_current) andctx.auth().revoke(sessionId)
("log out THIS device", owner-or-superuser authorized). In table mode each token carries an
opaquesidand verification additionally requires a live (unexpired) session row — one
extra indexed read per authenticated request..epochstays the default and is unchanged:
zero extra DB work, and enabling.tabledoes not alter the.epoch-mode token shape
(thesidclaim is simply omitted when absent). In.epochmode the per-device verbs
returnerror.SessionStoreNotEnabled. - Field/collection policy pipeline — a value-transform seam at the records read/write path, applied transparently with the field schema in hand. Its first behavior ships below.
- Transparent at-rest field encryption — mark a
text/editor/jsonfield.encrypted = trueto store it encrypted (AES-256-GCM) in SQLite while handlers, the records API, and HTTP responses see plaintext. Encrypted fields cannot be indexed, marked.unique, or used in a?filter/?sort(compile error / 400). Key rotation is designed into the versionedv<N>:envelope. - TTL records. A collection may declare
.ttl_field = "<field>"naming an existingdate/autodatefield as the row's expiry timestamp. A framework-internal GC reaps expired rows automatically — once at startup and then on a 5-minute interval — across every TTL-enabled collection. Opt-in and additive; collections without.ttl_fieldare untouched. - Framework-internal scheduled jobs. Added an internal scheduled-job mechanism (
scheduler.concatJobs) so the framework can run its own jobs (such as the new_ttl_gcsweep) alongside consumer.cronjobs. The scheduler now starts whenever a TTL collection is declared, even with no user cron configured. - Built-in key→value/settings store (#87):
ctx.kv().get/set/delete(and the curateddata.kvGet/kvSet/kvDelete/kvList) over a new internal_kvtable — small server-managed values with no collection, schema, or access rules. Superuser-managed and not public by default. - Typed feature flags (#88):
ctx.flag(name) -> boolandctx.setFlag(name, enabled), a typed boolean view over the same KV store ("true"/"1"truthy, unset = false). - Superuser-only settings HTTP API:
GET /api/settings,GET/PUT/DELETE /api/settings/:keyfor managing KV/settings values. - The
ZIGBASE_FAKE_NOWdev test clock now also freezes a consumer's own raw SQL
datetime('now')/unixepoch('now')/strftime(…, 'now')(anddate/time/
julianday, including their zero-argument implicit-'now'forms). SQLite's date/time
builtins are shadowed on every reader and writer connection so they resolve to the frozen
instant, while explicit datetimes and modifiers ('+1 day', thestrftimeformat string)
pass through to genuine SQLite. This makes e2e/snapshot tests of consumer routes that use
raw time SQL fully deterministic (#84). Like the rest of the test clock it is **compiled
out of producti...
ZigBase 0.6.0
Features
- Auth-aware
Data.create—Data.createon an auth collection now runs the same credential transforms as the HTTP records handler (generates the per-recordtokenKey, forcesverified=false, hashespasswordwhen supplied), so a programmatically-created record works withzigbase.auth.issueSession/mintLinkTokenimmediately.passwordis optional, enabling passwordless (magic-link) sign-up to provision an account without hand-writing credential columns. Non-auth collections are unaffected; the lower-level enginerecords.createstill does a raw insert for imports/migrations. magic_linkandotpauth methods now honourauto_create: true— when an unknown identity callsinitiate, a passwordless account is provisioned automatically (email set from the identity,verified = false) and the link or code is sent as usual. Enables "sign up or sign in" in one step. Accounts are created withverified = false; pair withrequire_verifiedonly when a verification flow is in place.CommandMailer(local-command / sendmail mailer) — a built-in mailer that pipes the serialized RFC822 message to a local MTA's stdin (e.g.sendmail -t -iormsmtp -t) and treats exit 0 as success. The standard "delegate delivery to a local relay, hold no SMTP credentials in the app" setup. Selected via the newZIGBASE_SENDMAIL_COMMANDenv var (whitespace-split into argv;From:fromZIGBASE_SMTP_FROM), which takes precedence over SMTP inDefaultMailerPlugin. Re-exported aszigbase.CommandMailer.- Comptime
.indexeson collection literals — azigbase.App(.{ .collections = … })collection may now declare.indexes = .{ .{ .name, .fields, .unique?, .collation?, .where? }, … }, lowered into the provisioned schema and emitted asCREATE INDEXDDL (case-insensitive via.collation = .nocase; conditional-unique via.where). Index.fieldsreference fields by their declared name. ZIGBASE_PUBLIC_URL→ clickable magic-link emails — setpublic_url(envZIGBASE_PUBLIC_URL) and the built-inmagic_linkmethod emails an absolute link to its consume endpoint (which sets the session cookie and redirects) instead of a bare token. Unset preserves the previous raw-token email. Lets a stock binary offer real magic-link login by configuration alone.- Comptime OAuth2 providers: declare
.auth.oauth2 = .{ .enabled = true, .providers = .{ .{ .name = "google", .redirectUrls = .{…} } } }on an auth collection in.collections. The runtimeclientId/clientSecretare sourced fromZIGBASE_OAUTH_<NAME>_CLIENT_ID/ZIGBASE_OAUTH_<NAME>_CLIENT_SECRETat provisioning time and the secret is encrypted (AES-256-GCM) before it is persisted — secrets never live in the binary. (Applied on first creation only; rotate via the admin API.) - Dev-only injectable test clock (
ZIGBASE_FAKE_NOW) — freeze the framework's "now" to an ISO-8601 UTC instant (e.g.2029-03-07T16:00:00Z) so time-boundary scenarios (token expiry, scheduling, challenge/cursor TTLs) are deterministic in e2e suites. Every framework-controlled timestamp routes through one clock seam (src/clock.zig) that honors the override. Gated off in production: compiled in only on adev_clockbuild (on inDebug, off in any release build / shipped binary), so a production binary never reads the env var and time can never be frozen. Scope and the production gate are documented in Known limitations → Testing. Closes #58. golfsimexample:require_verified = trueon theusersauth collection — guests must verify their email before a session is minted (booking/payments justification).golfsimexample: OTP passwordless login (auto_create = false) for existing verified accounts; first-time onboarding remains password signup + email verification.golfsimexample: comptime indexes —NOCASEunique onusers.email(prevents case-variant duplicate accounts) and a partial composite index onbookings(listing, starts_at) WHERE status != 'cancelled'(backs the double-booking overlap check and availability route).golfsimexample: OAuth2 "Sign in with Google" via comptime.auth.oauth2; client credentials sourced fromZIGBASE_OAUTH_GOOGLE_CLIENT_ID/ZIGBASE_OAUTH_GOOGLE_CLIENT_SECRETat provision time; Google-verified accounts are createdverified=true.golfsimfrontend: multi-stepAuthcomponent covering password sign-in, OTP initiate/complete, signup, email-verification, and Google OAuth2 flows.- Blog example: adds built-in
magic_linkauth onusers(passwordless login via
emailed link,auto_create = true, 1 h TTL, server-redirects to/). - Blog example:
NOCASEunique comptime index onusers.emailvia.indexes = .{...}
— prevents case-variant duplicate accounts. examples/pluginsshowcases the full advanced auth surface:authorsauth collection with WebAuthn (passkeys) + a customApiTokenMethodplugin;commentersauth collection with magic-link (auto_create=true);onAuthhook logging all three methods; comptimeNOCASEcollation index onauthors.contact_email; frontend magic-link comment flow;beforeCreatehook auto-populatingcommenterfrom session.- Comptime index collation + partial predicates —
schema.Indexgainscollation(.binarydefault /.nocase, applied per indexed column) and an optionalwhere: ?[]const u8partial-index predicate. Case-insensitive indexes (CREATE INDEX ... ("email" COLLATE NOCASE)) and conditional-unique indexes (... WHERE deleted_at IS NULL) are now expressible in the comptime.collectionsschema and emitted in the generatedCREATE INDEXDDL, instead of requiring an out-of-band raw-SQL bootstrap. Defaults preserve existing DDL and JSON round-trip behavior. mintLinkTokenopaque bound payload —zigbase.auth.mintLinkTokentakes a trailingopts: MintOptionsarg whosepayload(default"") binds a small opaque string into the single-use token's signedplclaim, returned byverifyLinkTokenasclaims.pl. Lets a magic-link flow carry tamper-proof bound state (e.g. a post-login redirect target) in the one token instead of an unsigned&next=URL param. Signed, not encrypted — readable-but-tamper-proof; keep it small. Existing call sites add.{}.GET .../auth/magic_link/consume— browser-friendly email-link login —GET /api/collections/:col/auth/magic_link/consume?token=…&redirect=/appverifies and consumes the single-use link token (same replay guard ascomplete), mints the session through the sharedissueSessionseam (soonAuth(.magic_link)fires and thezb_auth/zb_csrfcookies are set), honors therequire_verifiedgate, and302s to the redirect target. Two new per-methodmagic_linkoptions shape the redirect:redirect_default(fallback path when?redirect=is absent or rejected; defaults to/) andredirect_allow(allow-list of exact paths or/-suffixed prefixes; an empty list permits any safe relative path).
Fixes
- Comptime
.indexesis no longer silently ignored — the documented.indexeskey on collection literals was never lowered by the provisioner; it is now applied. - Corrected false claim in
examples/pluginsmigration 0002 comment: provisioned collection columns are human-named (field.name), not id-named. Raw migrations targeting migration-owned tables remain valid; the rationale is now accurate.
Changed
- Documented the CSRF double-submit contract for cookie sessions — the API reference now spells out that cookie-session clients must echo the readable
zb_csrfcookie in theX-CSRF-Tokenheader on unsafe methods (POST/PUT/PATCH/DELETE);GET/HEAD/OPTIONSare exempt. A failed CSRF check makes the request anonymous, so the response status follows the collection's access rules —403on a create denial,404on an update/delete denial against a protected record (existence-hiding) — not a flat403. Documentation only; no behavior change.
Performance
- Trim unused subsystems from the vendored SQLite amalgamation (
OMIT_UTF16,OMIT_DECLTYPE,OMIT_DEPRECATED,OMIT_PROGRESS_CALLBACK,OMIT_TRACE,OMIT_SHARED_CACHE,DEFAULT_MEMSTATUS=0). The framework uses only SQLite's UTF-8 prepare/step/bind/column/exec surface, so this is a pure build-cost/size win — a smaller shipped binary and ~10% faster SQLite C compile — with no behavior change. FTS5 is intentionally retained.
Security
- Server-side open-redirect guard on magic_link consume — the
?redirect=target is validated server-side so consumers never re-implement the guard: only same-origin relative paths are honored. Off-origin, protocol-relative (//host), scheme, CRLF/control-byte, backslash,./..path-traversal segments, and still-encoded%2e/%2f/%5cpayloads are all rejected and fall back toredirect_default.
Internal
- Changelog-fragments workflow — changes now add a
changelog.d/<slug>.mdfragment (with one or more### <Section>headings) instead of editingCHANGELOG.md, so parallel PRs never conflict on the shared changelog.scripts/assemble-changelog.shaggregates the fragments per section into a new version block inCHANGELOG.md(and itssite/mirror) at release time (run fromscripts/release.sh) and deletes them. Seechangelog.d/README.md. - Corrected the "provisioned columns are named by a stable field id" claim in
CLAUDE.mdanddocs/framework.md: physical SQLite columns use the human field name; the stable field id only matches columns across additive rebuilds. - Blog example frontend: new magic-link login form in
Editor.tsx(email → initiate
→ "Check your email" state), cookie-session detection viagetMe()on mount, and an
AuthStatusnav island for logged-in display after consume redirect. - Blog example README: document
ZIGBASE_PUBLIC_URL, the fakeblog.testUR...
ZigBase 0.5.0
Removed
- BREAKING: legacy OAuth2 endpoints removed —
GET .../oauth2-providers,POST .../oauth2-init, andPOST .../auth-with-oauth2no longer exist. OAuth2 is now exclusively the contract method:POST .../auth/oauth2/initiate,POST .../auth/oauth2/complete, andGET .../auth/oauth2/providers(discovery).
Added
- OAuth2 as a first-class
AuthMethod— exclusively at the contract endpointsPOST /auth/oauth2/initiate,POST /auth/oauth2/complete, andGET /auth/oauth2/providers(discovery); all paths share one implementation and the singleonAuthsession seam. See docs/api.md for request/response shapes. - Pluggable auth-method system — the
AuthMethodcontract (initiate/complete+AuthCtxblessed helpers +Resolution) lets the framework own session issuance while methods plug in verification logic. Built-ins implement the same contract with no privileged path. - Per-collection
.auth.methodsconfig — enable and configure built-in methods per auth collection:password(backward-compat default when.methodsis absent),magic_link(TTL, auto-create flag),otp(code length, TTL),webauthn(rp_id, rp_name, origin, credentials_collection). Each method has arate_limitknob (.default|.off|.{ .custom = .{ .max, .window_s } }). - App-level
.auth_methods— register customAuthMethodplugin TYPES at comptime (same pattern as.storage/.mailer); a type missingcreate/method/deinitis a compile error. - Auto-mounted auth endpoints — for every enabled method, the framework auto-mounts
POST /api/collections/:col/auth/:method/initiateand.../complete; the dispatch enforces enablement (404 for disabled/unknown methods) and default rate-limits. magic_linkbuilt-in — enumeration-safeinitiate(always 204), single-use link token emailed via the configured mailer,completeverifies+consumes and mints the session.otpbuilt-in — enumeration-safeinitiateemails a 6-digit code stored in theChallengeStore,completeverifies the code.webauthnbuilt-in — passkey login via the two-phase contract (initiate returnsPublicKeyCredentialRequestOptions; complete verifies the signed assertion). Passkey registration via two authed endpoints (register/begin/register/finish). ES256 (P-256, COSE -7) and Ed25519 (COSE -8) supported; attestationfmt:"none"(v1); signCount clone detection (fail-closed); credentials stored in_webauthnCredentials.ChallengeStore(_authChallenges) — TTL'd, GC'd single-use server-side challenge storage used byotpandwebauthn, and accessible to custom plugins viaAuthCtx.challengeStore().onAuthmethod tagging extended —AuthEvent.methodis an enum:.password,.oauth2,.magic_link,.otp,.webauthn, or.customfor custom plugins.- RPC client generation for auth endpoints — the generated TypeScript client exposes non-password auth-method endpoints under an
authsurface (initiate/complete stubs, currently untyped). zigbase.authconsumer surface for custom auth flows —issueSession(andRouteEvent.issueSession), single-use magic-link tokens (mintLinkToken/verifyLinkToken/consumeLinkToken),deliverAuthMail, andrateLimit. All session minting now funnels through one seam that always firesonAuth.
Security
- OAuth2 server-side CSRF
stateis now ON by default (ZIGBASE_OAUTH_STATE_SERVERdefaults totrue). Theinitiateendpoint issues astatevalue andcompleterequires and consumes it before contacting the provider. Behavior change: OAuth2 clients must use theinitiate→completeflow; barecompletecalls without a validstateare rejected with400. SetZIGBASE_OAUTH_STATE_SERVER=falseto restore the previous client-driven mode. - New
require_verifiedper-collection auth option (defaultfalse). Whentrue, any login attempt for an unverified record is rejected with403. This gate applies to all methods — including WebAuthn/passkey and OAuth2 accounts whose provider email was unverified (those are createdverified=false). Enabling it will lock out such users until they complete email verification. - OAuth2 no longer claims unverified provider emails — when a provider does not mark the email as verified, the new account is created with
verified=falseand theemailfield is left unpopulated. This prevents email-squatting via an OAuth2 provider that does not verify addresses. - WebAuthn credential binding — a passkey is now bound to the collection it was registered on; presenting it on a different collection returns
401. - WebAuthn
require_uvoption (defaultfalse). Whentrue, the server rejects assertions that do not set the user-verification bit (UV=1), requiring biometrics or PIN at the authenticator. - WebAuthn COSE key curve validation — ES256 credentials must use the P-256 curve; EdDSA credentials must use Ed25519. A mismatched algorithm/curve is rejected.
Performance
- Auth I/O off the write lock.
otpandmagic_linkrelease the DB connection before the SMTP send. WebAuthn signature verification runs before acquiring the write lock (only the signCount update and challenge consume hold it).oauth2Providersuses a reader connection. The authenticated-request fast path no longer does a redundant collection lookup. No auth method holds the single writer across blocking I/O or CPU-heavy verification.
Changed
- Auth methods now manage their own DB connections — each method holds one connection across its work; OAuth2
completereleases the writer during the provider HTTP exchange (no write-throughput stall); passwordcompleteuses a reader (argon2 is read-only). Neither method blocks writes during I/O. - Session issuance (password, refresh, OAuth2) routes through a single
issueSession+emitAuthseam — custom routes can no longer mint a session that
skips theonAuthhook.