Skip to content

ZigBase 0.11.0

Choose a tag to compare

@github-actions github-actions released this 07 Jul 10:16
· 534 commits to main since this release
Immutable release. Only release title and notes can be modified.
4d50b55

[0.11.0] - 2026-07-07

Breaking

  • The onBootstrap/onBeforeServe/onBeforeTerminate lifecycle hooks now return anyerror!void (was void) — update existing hook signatures (a fn (...) void no longer coerces). A returned error from onBootstrap/onBeforeServe fails the boot; an onBeforeTerminate error is logged (it fires in a shutdown defer).

Features

  • Admin UI: editor fields now use a rich-text WYSIWYG editor (bold, italic, headings, lists, links, blockquote, inline code) that stores sanitized HTML, and json fields 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 in onBootstrap via ctx.setAppData(T, &value), and read it anywhere (handler/hook/job/cron) as a *T with ctx.appData(T) — one explicit, typed handle replacing module-level globals + bootstrap setter rituals. Declaring .app_context makes setting it a boot contract (the server refuses to start if onBootstrap never 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 = true to 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 same 401 as no token at all (no oracle) — and comptime-validated: the named collection must be declared in .collections and be of .type = .auth, else the build fails. Plain .authed still 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 could ALTER collections unseen — and the runtime collection create/update/delete endpoints return 403 (schema then evolves via .migrations + a redeploy). Default false leaves 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 picks SentryReporter when ZIGBASE_SENTRY_DSN is set (POSTs a Sentry envelope) and LogReporter otherwise (a structured backstop line [phase] err_name: message); a custom plugin implements create/interface/deinit and returns a Reporter whose report receives a Report{ .message, .err_name, .phase, .level } (the Reporter, Report, LogReporter, SentryReporter, and DefaultReporterPlugin types are re-exported). Consumers route their own swallowed-but-notable errors through the SAME backstop with ctx.reportError(err, "fmt", .{args}) — the onError handler then the reporter — tagged with the new .app error 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) within App(.{ .reporter_dedup = .{ .window_s = 60 } }) (the default) is suppressed so a hot error path reports once per window instead of flooding Sentry; .reporter_dedup = .off reports every swallowed error and compiles the dedup map out entirely.
  • Record read endpoints (GET list and get-one) accept a fields= 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 into expanded 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 in filter and pass a parallel filter_args slice (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 (so price = ? with .{ .float = 5.0 } matches the same rows as price = 5.00). Placeholders bind 0-based left-to-right; a placeholder-count vs. filter_args.len mismatch is a loud error.BadFilter (so a stray ? on the REST ?filter= path fails closed).
  • Mail: ctx.mail() messages can now carry file attachments (#219) — set MailMessage.attachments to a slice of { filename, content_type, data } (the canonical use is a .ics calendar invite). The message body is wrapped in multipart/mixed with 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 native Attachments array) and survive the durable queue round-trip. filename/content_type are CRLF/control-char checked, and a new .mail.max_message_bytes cap (default 10 MiB) rejects an over-sized send/enqueue at the call site with error.MailTooLarge. Only cid: inline images remain unsupported.
  • zigbase migrate dump [--out <file>] introspects the live database and writes a canonical, dialect-native structure.sql (stdout by default; --out writes a file). SQLite emits the exact stored DDL; Postgres reconstructs it from the system catalogs — no external pg_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 the migrate CLI trio alongside status and rollback.
  • zigbase migrate status reports your comptime .migrations as 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 concise N applied, M pending, K orphaned summary. It reads the _migrations ledger 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 is down orelse change (the mirror of the forward change orelse up): an explicit down runs as-is, otherwise the change re-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-up migration, a non-transactional change, or an orphaned ledger row is refused; a change that reverses into an irreversible op (raw/records()/a .was-less drop, or addForeignKey on SQLite) is rolled back by its transaction and named. N beyond the applied count rolls back all of them.
  • Migrations gain a dialect-aware schema DSL (m.createTable/addColumn/addIndex/renameColumn/addForeignKey, …) and auto-reversible change migrations: write the forward change once and it inverts for rollback. up/down remain for irreversible steps; a per-statement m.raw(.{ .sqlite, .postgres }) breakout and records-aware m.records() data transforms (#241) round it out. Migrations stay transactional by default with a per-migration .transactional = false opt-out. (A schema dump lands next.)
  • data.queryAs(T, conn, alloc, sql, args) (and the ctx.records().queryAs(T, sql, args) wrapper) decode raw-SQL result rows into a struct T by matching each field to the result column of the same name (respecting AS aliases) instead of by position — so a reordered or newly-inserted SELECT column can no longer silently misalign a hand-written columnText(n) mapping. Args bind positionally (?1..?N, rewritten to $n on Postgres); fields decode by Zig type ([]const u8, integers, floats, bool, and ?T over nullable columns, with SQL NULL → null); extra result columns are ignored; a non-optional field with no matching column errors with error.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 of ctx.mail(). ctx.sms().send(.{ .to, .body }) delivers synchronously and ctx.sms().enqueue(...) rides the background queue for durable retry/backoff. Provider is pluggable behind an SmsSender vtable (Twilio first, via App(.{ .sms_provider = … }) or the ZIGBASE_TWILIO_ACCOUNT_SID/_AUTH_TOKEN/_FROM env vars); unconfigured it is a network-free logging no-op, so dev/CI need no credentials. Framework-owned E.164 normalization/rejection runs before any byte reaches a provider or a queue row (.sms = .{ .default_region = .us } sets the country code prefixed onto national numbers). Ships a CaptureSms in-memory test double for asserting sent messages with no network.
  • Static file serving now percent-decodes the request path, so files whose names need encoding (e.g. my%20file.pdf) are servable. Decoding is single-pass and happens before the traversal checks, so encoded traversal (%2e%2e, %2f, %00, %5c) is decoded and then rejected fail-closed, and double-encoding is never recursively decoded; the symlink guard is unchanged.
  • In-process test harness (zigbase.testing): boot a comptime-configured App(.{...}) against a throwaway tempdir data dir and inject requests through the REAL pipeline — the same router, access rules, auth, hooks, and custom routes the socket server runs — with no socket, port, or background threads. testing.start(App, .{}) runs migrations + onBootstrap; t.request(method, path, .{ .json = .{...}, .auth = bearer }) returns a genuine response you assert on and parse via r.json(T). Auth helpers cover both fidelities: mintSession (direct deterministic JWT) and loginPassword/loginSuperuser (the real auth-with-password endpoint); createSuperuser/createRecord seed rows and captureMail swaps in an in-memory mailer to assert outbound mail. See docs/framework.md §15.
  • The TTL garbage-collection sweep cadence is now configurable via the comptime .ttl_gc_interval App config key (a schedule.Interval, default .{ .minutes = 5 }); expired rows are still hidden from reads immediately regardless of sweep cadence.
  • ctx.txWith(T, payload, fn) (#237) — a ctx.tx companion that threads a caller-supplied payload directly into the transaction callback, so a route/hook/job that needs request data inside a transaction no longer has to smuggle it through a threadlocal global.
  • Typed record I/O on the records handle: ctx.records().createAs(T, col, .{…}), getAs(T, col, id), and updateAs(T, col, id, .{…}) reflect a plain Zig struct into a write and parse the resulting record back into T — no more hand-assembling ObjectMaps or unwrapping union tags. Struct fields map to schema fields by name, optionals map to nullable columns, and every literal field is comptime-verified to exist on T (a typo is a build error). The std.json.Value API stays for dynamic callers.
  • Web Push notifications (ctx.push(), #223). Send browser push notifications with RFC 8291 (aes128gcm) payload encryption and RFC 8292 VAPID authentication. ctx.push().send(subscription, message) returns a tri-state (.delivered / .gone / .failed) so a dead subscription (HTTP 404/410) is pruned and never retried; ctx.push().enqueue(...) delivers durably in the background via the built-in "push" job kind (registered when .push is configured). Enable with App(.{ .push = .{ .subject = "mailto:ops@example.com" } }) plus a VAPID keypair in ZIGBASE_VAPID_PUBLIC_KEY / ZIGBASE_VAPID_PRIVATE_KEY; without the keys ctx.push() is a network-free logging no-op. New CLI subcommand zigbase vapid-keygen generates a keypair.

Fixes

  • The standalone WriterData/ReaderData DB-access handles (ev.writer()/ev.reader()) no longer leak on the process allocator: their data() accessor now allocates on an arena OWNED BY THE HANDLE, so a record op's collection metadata, SQL scratch, and returned records are all freed together when the handle's deinit() runs. Results are valid until deinit(). (ctx.records() was never affected — it already uses the per-request arena.)
  • Setting .auth.session.gc_cron without .auth.session.store = .table is now the compile error it was always meant to be. The guard lived in a lazy comptime value referenced only by the .table-mode session-GC job, so in the misuse case (.epoch store) it was never analyzed and the misconfiguration silently compiled and did nothing; it now fails loudly at build time.
  • .int and .fixed-mode .number fields now accept a JSON number on write, not only a string — symmetric with reads, which return a string. price_cents = 500 and price = 5.0 (scaled to a fixed field) bind correctly instead of failing validation; a fractional float on an .int field is still rejected.

Changed

  • zigbase migrate now applies the app's comptime .migrations (the consumer escape-hatch migrations) after the system migrations, so migrating from the CLI ahead of a deploy applies the same migration pass the server would. Previously migrate applied only the built-in system migrations. (Collection tables from .collections are still provisioned when the server starts, not by migrate.) It remains idempotent (already-applied migrations are skipped via the _migrations ledger).
  • The S3 spool cache now bumps an entry's mtime on a cache hit, so size-triggered eviction approximates last-access LRU (a frequently-read file survives over a rarely-read newer one) instead of being purely create-time ordered. -Ds3 builds only.

Performance

  • Feature-state resolution (ctx.flags().resolveAll / the public /api/state projection) now reads every sticky experiment's persisted assignment in a single batched query, so a resolve is a constant 2 queries regardless of how many .sticky experiments an app declares (previously 1 + N — one assignment read per sticky experiment). Variants and miss-persist behavior are byte-identical; the single-accessor App.experiment path is unchanged.
  • Steady-state feature-flag and experiment resolution now costs zero _kv reads: an in-process cache serves the current flag:* / exp:*:weights override set to both ctx.flags().resolveAll and the per-flag/App.flag/App.experiment lookups. A same-instance override write (App.setFlag, the admin settings verbs) invalidates it instantly, so a kill-switch flip still takes effect on the next request; on Postgres, another instance's write self-heals within a 5 s staleness bound (so it runs on both backends, unlike the SQLite-only collection cache).
  • Custom-route dispatch now skips authentication resolution — and its pooled reader acquire — on credential-less requests (no bearer header and no zb_auth cookie). authenticate already returns null in that case, so the reader round-trip was pure overhead on the highest-volume shape most apps serve (anonymous traffic on public routes); hoisting the credential check above the acquire lowers the per-request floor and cuts reader-pool contention for the requests that actually need a connection. Semantics are unchanged — .authed/.superuser routes without credentials still 401/403.