ZigBase 0.11.0
·
534 commits
to main
since this release
Immutable
release. Only release title and notes can be modified.
[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 queue for durable retry/backoff. Provider is pluggable behind anSmsSendervtable (Twilio first, viaApp(.{ .sms_provider = … })or theZIGBASE_TWILIO_ACCOUNT_SID/_AUTH_TOKEN/_FROMenv 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 aCaptureSmsin-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-configuredApp(.{...})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 viar.json(T). Auth helpers cover both fidelities:mintSession(direct deterministic JWT) andloginPassword/loginSuperuser(the real auth-with-password endpoint);createSuperuser/createRecordseed rows andcaptureMailswaps 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_intervalApp config key (aschedule.Interval, default.{ .minutes = 5 }); expired rows are still hidden from reads immediately regardless of sweep cadence. ctx.txWith(T, payload, fn)(#237) — actx.txcompanion 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 athreadlocalglobal.- Typed record I/O on the records handle:
ctx.records().createAs(T, col, .{…}),getAs(T, col, id), andupdateAs(T, col, id, .{…})reflect a plain Zig struct into a write and parse the resulting record back intoT— no more hand-assemblingObjectMaps 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 onT(a typo is a build error). Thestd.json.ValueAPI 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.pushis configured). Enable withApp(.{ .push = .{ .subject = "mailto:ops@example.com" } })plus a VAPID keypair inZIGBASE_VAPID_PUBLIC_KEY/ZIGBASE_VAPID_PRIVATE_KEY; without the keysctx.push()is a network-free logging no-op. New CLI subcommandzigbase vapid-keygengenerates a keypair.
Fixes
- The standalone
WriterData/ReaderDataDB-access handles (ev.writer()/ev.reader()) no longer leak on the process allocator: theirdata()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'sdeinit()runs. Results are valid untildeinit(). (ctx.records()was never affected — it already uses the per-request arena.) - Setting
.auth.session.gc_cronwithout.auth.session.store = .tableis 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 (.epochstore) it was never analyzed and the misconfiguration silently compiled and did nothing; it now fails loudly at build time. .intand.fixed-mode.numberfields now accept a JSON number on write, not only a string — symmetric with reads, which return a string.price_cents = 500andprice = 5.0(scaled to afixedfield) bind correctly instead of failing validation; a fractional float on an.intfield is still rejected.
Changed
zigbase migratenow 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. Previouslymigrateapplied only the built-in system migrations. (Collection tables from.collectionsare still provisioned when the server starts, not bymigrate.) It remains idempotent (already-applied migrations are skipped via the_migrationsledger).- 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.
-Ds3builds only.
Performance
- Feature-state resolution (
ctx.flags().resolveAll/ the public/api/stateprojection) 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.stickyexperiments an app declares (previously 1 + N — one assignment read per sticky experiment). Variants and miss-persist behavior are byte-identical; the single-accessorApp.experimentpath is unchanged. - Steady-state feature-flag and experiment resolution now costs zero
_kvreads: an in-process cache serves the currentflag:*/exp:*:weightsoverride set to bothctx.flags().resolveAlland the per-flag/App.flag/App.experimentlookups. 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_authcookie).authenticatealready 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/.superuserroutes without credentials still 401/403.