ZigBase 0.9.0
·
756 commits
to main
since this release
Immutable
release. Only release title and notes can be modified.
[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 — case-insensitive identity stays opt-in via a.nocaseindex.
Security
- The shared one-time-code comparison was unified on the audited constant-time
crypto.timingSafeEqlprimitive (the OTP auth method now uses it too). - Webhook retry backoff (including a server-supplied
Retry-After) is capped at the queue's maximum, so a hostile or misconfigured receiver cannot park a worker thread and starve the background pool. - The new subsystems are fail-closed by design — tenant/ability/search scoping, the email verified-sender + suppression + CRLF-injection defenses, the realtime no-row-data-on-the-NOTIFY-wire guarantee, the
path_secretconstant-time gate, and per-route rate-limit IP keying are detailed under their features above.
Internal
- CI now runs a
-Ddev-clock=falseproduction-gate test pass, so the tests asserting thatZIGBASE_FAKE_NOW/ZIGBASE_FAKE_SEED/test-capture are compiled out of production builds actually execute (they were previously skipped in the only CI test run). - The e2e test harnesses now retry server startup on a port-bind race (fresh OS-assigned port + fast
ListenErrordetection + cleanup between attempts), fixing an intermittentListenError→ "server did not become healthy" flake in thets-sdk/browserjobs. - New
policy.zigauthorization-composition layer andsrc/sql/dialect.zigSQL-dialect layer are the architectural seams the abilities/tenancy and the Postgres backend compose through. - GitHub release descriptions now contain only the released version's changelog section (
scripts/extract-release-notes.sh), not the entireCHANGELOG.md.