Skip to content

Releases: valthon/zigbase

ZigBase 0.13.0

Choose a tag to compare

@github-actions github-actions released this 09 Aug 22:56
Immutable release. Only release title and notes can be modified.
1d200f7

[0.13.0] - 2026-08-09

Breaking

  • The API error envelope changed shape. It is now {"status": <int>, "code": "<string>", "message": "…", "data": {…}} — the top-level code used to repeat the integer HTTP status and is now a frozen machine string (the integer moved to status). The three divergent shapes are gone: typed (rpc.*) routes, auth-method endpoints, and ctx.jsonError all emit this one envelope instead of the old bare {"message": …} and {"error": …} bodies. Branch on code; message text is not contract. Every built-in error response now carries a code that matches its status (401 → unauthorized, 403 → forbidden, 404 → not_found, 409 → conflict, 429 → too_many_requests, etc.) instead of silently defaulting to internal at ~30 call sites across the auth, files, mail, accounts, senders, and analytics APIs. The official SDKs read message and data only and need no change.
  • ctx.jsonError(status, code) now takes a message: ctx.jsonError(status, code, message).
  • zigbase serve now takes an exclusive lock on its data dir and refuses to start when
    another serve process 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-lock for the old behavior — that
    instance is untracked and invisible to zigbase 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 --json and zigbase migrate status --json emit 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 verbs init, agents-md, and typegen (project scaffolding + schema-to-client codegen — src/scaffold*.zig and src/codegen/**, ~490 KiB in a ReleaseSafe, stripped binary). Every artifact we publish — the GitHub release tarballs, the Docker image, and the @zigbase/server npm packages — builds at this default and ships all three; -Ddev-tools=false is 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=true instead of UnknownCommand.
  • zigbase doctor runs nine preflight checks over a deployment — JWT-secret
    persistence, every @public access 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 exits 1
    when any of them is an error, 2 when it found warnings only, and 0 when the
    deployment is fully clean. --production judges 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. --json emits 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 report skipped instead — 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_large replaces a generic internal on 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_verified replaces a generic forbidden when 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) and ZigbaseException.code (Dart, Kotlin). This is the handle that makes the frozen registry usable from a client: branch on code, never on message. It is empty when the server sent no code, and a pre-unification integer code is 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-level validation_* 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 on code; message text 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 with explain-code alone. --json emits 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 import now supports migration-scale NDJSON loads: --dry-run executes 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-error isolates each row in its own SAVEPOINT and skips a failing one instead of aborting the whole run, logging its finding to --error-log as NDJSON ({"line":N,"code":…,"detail":…}); --progress N prints a heartbeat to stderr; --json prints the run summary as one JSON object on stdout (created/updated/failed/total). A lossy import (failed > 0) now exits 3, never 0, so an agent or script cannot mistake skipped rows for success.
  • zigbase import --manifest FILE loads 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"}]}), with file paths 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 own id). A required field on one of those relations has no legal two-pass row order, so it's refused up front instead of failing row by row. --manifest is 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/--json flags. See docs/migration-tools.md.
  • zigbase import --legacy-hashes bcrypt imports 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's verified flag 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's upsertKey) is refused, because an updated row would land with no credential installed. See docs/migration-tools.md.
  • --log-format text|json / ZIGBASE_LOG_FORMAT switches the whole log stream to one JSON object per line on stderr, and --log-level / ZIGBASE_LOG_LEVEL sets the minimum severity (debug, info, warn, error). The env vars apply to every subcommand; the flags are serve-only.
  • New guide: Observability & machine-readable output (docs/observability.md) — the log formats, the NDJSON consumption rule, the frozen error-code registry, and the --json CLI conventions.
  • The server is now distributed under the bare npm name zigbase in addition to @zigbase/server, so npx zigbase serve … works and require("zigbase") re-exports binaryPath(). The alias ships no binary and pins one exact @zigbase/server version, which stays the canonical package to depend on; installing both is harmless even though both provide a zigbase command. The unscoped name is claimed by a one-time manual publish — see clients/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...
Read more

ZigBase 0.12.0

Choose a tag to compare

@github-actions github-actions released this 26 Jul 09:41
Immutable release. Only release title and notes can be modified.
24e01e5

[0.12.0] - 2026-07-26

Breaking

  • The three request-scoped allocator seams are now the typed zigbase.RequestArena instead of a bare std.mem.Allocator: RecordEvent.arena (ev.arena in hooks), Ctx.arena (ctx.arena / req.ctx.arena in custom routes and jobs), and RequestCtx.allocator (ctx.allocator). The allocator itself is the field .a on 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 a RequestArena needs 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-Allocator type 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). RequestArena is constructible only from a real std.heap.ArenaAllocator at the boundary that owns it, so the first mistake no longer compiles, and the deliberate .a escape hatch makes the second one greppable instead of the default path.

  • The dev-only build option -Ddev-clock is 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 .auth session token in ?token= (rather than a .file
    token from POST /api/files/token) is no longer authenticated by that token. No first-party
    client did this — the SDKs and admin UI already use .file tokens or the auth cookie/header —
    but a hand-built URL relying on the old behavior must switch to a .file token.

  • jwt.sign now returns error.TokenTooLarge rather 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-supplied pl claim 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 .collections schema, 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: the UPDATE in ON CONFLICT ... DO UPDATE SET is recognized as a conflict clause
    (no table operand), not an UPDATE <table> statement.
  • zigbase.Query.select: a comptime, schema-checked single-table SELECT builder that emits
    validated SQL + positional binds for queryAs — an unknown table/column is a build error, and
    binds are positional by construction. SELECT-only / single-table in v1 (joins, writes, and in
    are noted as future work).
  • Official Dart client SDK (clients/dart, pub package zigbase_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 dart to zigbase typegen (runtime introspection) or zig build gen-client
    (comptime, via the genClientStep lang option) to generate a zbase.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/client Dart SDK's new package:zigbase_client/typed.dart
    runtime. Typed rpc.*, auth-method, and feature-flag surfaces remain TypeScript-only for now.
  • zigbase.testing can now boot apps that declare .encrypted fields (#260): pass StartOptions.field_key for real AES-GCM, or let it default to a dev-only fake-encrypt mode that stores readable fake:<key>:<value> at rest (label defaults to @test@) so encrypted values are eyeball-able while debugging. Also selectable on zigbase serve via ZIGBASE_FIELD_CRYPTO=fake. Fake crypto is compiled out of release binaries (the dev_mode gate) and its envelopes are mutually unreadable with real ciphertext, so a fake DB can never be served by a production binary.
  • Added jwt.verifyInto and jwt.peekClaimsInto, which decode and verify a token into a caller-provided scratch buffer with zero heap allocation. An over-large token fails closed with error.TokenTooLarge. jwt.scratch_size (16384) sizes that buffer for the measured worst case — escape-heavy claims force std.json to 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 within max_token_len. The allocator-taking jwt.verify/jwt.peekClaims remain for callers already holding a request arena.
  • zigbase.jwt, zigbase.crypto, and zigbase.RequestArena are 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, Maven io.github.valthon:zigbase-client 0.1.0): coroutines-first ZigbaseClient covering 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-gated subscribe/subscribeTopic with an unsubscribe-function return, stream()/streamTopic() cold Flows, custom broadcast topics (signal/message), automatic re-auth from authStore on login/logout/refresh, and exponential-backoff reconnection with full resubscribe.
  • Kotlin SDK typed tier: zigbase typegen --lang kotlin generates @Serializable record data classes with fromRecord coercion, Create/Update payloads with toMap wire encoding, injection-safe fluent filter builders, and typed collection services (plus Flow-based typed realtime) over the new io.github.valthon.zigbase.typed runtime — golden-gated in CI against the dating fixture.
  • zigbase typegen --lang kotlin gains a --package <name> flag that sets the emitted package declaration, honored on both the CLI and the comptime gen-client build step, so a consumer wiring genClientStep with lang: "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's io.github.valthon.zigbase.codegen.dating namespace (keeping the committed golden and zig build gen-dating-kotlin-client byte-stable).
  • captcha.Result and oauth.discovery.Endpoints gain a deinit(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 usual ctx.verifyCaptcha, resolve/parseDocument) do not need it.
  • New zigbase import subcommand + zigbase.Import library entrypoint: encryption-aware,
    offline (no HTTP server) bulk NDJSON record import that streams and batches through the
    record engine — validation, defaults, .encrypted field envelope, and auth password
    hashing all applied — with optional --upsert-key idempotency and source-id preservation.
  • Python client SDK (clients/python, PyPI zigbase 0.1.0): sync ZigBase and async AsyncZigBase clients 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.realtime with ack-gated subscribe/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 python generates Pydantic v2 record models, injection-safe fluent filter builders, and typed sync/async collection services (plus async typed realtime) over the new zigbase.typed runtime, golden-gated in CI against the dating fixture. A select-typed field's eq/neq/in_list accept None for null filtering; a generated record's expand attribute (and each relation on its <Rec>Expand submodel) 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 via zig build versions, the enriched --version output + a startup log line,
    and a versions object on GET /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...
Read more

ZigBase 0.11.0

Choose a tag to compare

@github-actions github-actions released this 07 Jul 10:16
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 q...
Read more

ZigBase 0.10.0

Choose a tag to compare

@github-actions github-actions released this 04 Jul 22:45
Immutable release. Only release title and notes can be modified.
54d152b

[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 @compileError naming 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 .auth group.
  • beforeAuthSuccess now fires on the legacy POST …/auth-with-password and POST …/auth-refresh routes — 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.AuthMethod gained a .refresh variant; exhaustive switches over the enum must add an arm (compile error).
  • Custom-route surface: http.Response.file_path is now Response.file (.file_path = p → .file = .{ .path = p }). Plain-path delegation behavior is unchanged; the new optional offset/len window enables handler-planned partial responses.
  • Postgres backend (-Dpostgres builds): the default sslmode for postgres:// URLs is now verify-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) to ZIGBASE_DB_URL. Explicitly configured modes below verify-full keep 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/client types updated to Promise<void>.
  • The magic-link consume URL is now dash-case: GET …/auth/magic-link/consume (was auth/magic_link/consume). Hard cutover — links emailed by pre-upgrade servers 404 (tokens are short-lived). The method slug (/auth/magic_link/initiate|complete, onAuth tag) is unchanged.
  • The built-in job kinds are now config-gated (embedded consumers): ctx.webhook requires .webhooks = true; ctx.mail().enqueue requires .mail (use .mail = .{} for defaults) or a .mailer plugin. 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 names mail/webhook remain 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.ctx is now RecordEvent.rctx (ctx always means *Ctx in a hook signature). Mechanical migration: ev.ctx. → ev.rctx..
  • RecordEvent.app was removed — it put the UB footgun (ev.app.allocator vs ev.arena) one dot from every hook. Use the hook's ctx.app; allocate record data with ev.arena. (JobEvent.app/ErrorEvent.app are unchanged.)
  • RouteEvent was deleted. It was never passed to a live route (handlers take *Ctx); it existed only in tests. Events carry data; ctx carries capabilities.
  • GET /api/collections and GET /api/settings now return {"items":[…]} instead of a bare JSON array (superuser endpoints; admin SPA + typegen updated). zigbase typegen --url requires a server from this release.
  • GET /api/collections/:col/auth/oauth2/providers returns {"items":[…]} (was {"providers":[…]}); @zigbase/client's listAuthProviders types updated.
  • zigbase.Server is now a generic pub fn Server(comptime gates: Gates) type instead of a concrete struct — the built-in route table is assembled per-app from Gates (R2-3). Framework consumers reach it exclusively through App(cfg).runCli/serve, which thread the new gates config automatically; only code that named zigbase.Server directly (bypassing App) needs an update, e.g. server.Server(.{}) for the historical all-on table.
  • Storage plugin vtable: localPath(ctx, alloc, col, record_id, filename) is now fetch(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 + the io parameter).
  • GET /api/senders now returns {"items":[…]} instead of a bare JSON array (unified with the analytics endpoints' envelope).
  • The __features realtime 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 stock zigbase serve binary 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 verifying oldPassword — a non-oracle check (wrong/missing values, unknown records, and passwordless targets all return the login-identical 400 "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 _sessions purge in table mode) while a self-change keeps the calling device signed in via fresh Set-Cookie headers. The beforePasswordChange/afterPasswordChange lifecycle hooks now fire on this path too. @zigbase/client gains collection(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/static base, non-root by default. The supported deployment path for Windows-hardware users, since ZigBase has no native Windows build. See docs/docker.md.
  • migrate-db now 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 initial CREATE TABLE and added back as DEFERRABLE INITIALLY IMMEDIATE constraints (Postgres cannot create tables with circular inline REFERENCES in any order), and the load transaction defers those constraints to COMMIT (SET CONSTRAINTS ALL DEFERRED on Postgres, PRAGMA defer_foreign_keys=ON on 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 at COMMIT, 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) plus batchStatus / cancelBatch.
  • Scheduled sends: ctx.mail().deliverAt(msg, .{ .at | .delay_s }) returns a cancellable job id, ctx.mail().cancel(id) calls a pending send off, and sendBulk accepts .at — the documented drip-sequence primitives.
  • One-click unsubscribe (RFC 8058): configure .mail.unsubscribe_base_url (or ZIGBASE_UNSUBSCRIBE_BASE_URL) and bulk mail automatically carries List-Unsubscribe / List-Unsubscribe-Post headers pointing at the new signed public POST/GET /api/mail/unsubscribe endpoint; one-click opt-outs are recorded as unsubscribe suppressions 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()...
Read more

ZigBase 0.9.0

Choose a tag to compare

@github-actions github-actions released this 30 Jun 11:38
Immutable release. Only release title and notes can be modified.
e71eac5

[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.Migrator instead of (alloc, io, w). Change each up to fn (m: *zigbase.Migrator) anyerror!void: the writer is m.db, the arena m.arena, the request std.Io is m.io. Migrator carries 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, and m.dialect.kind / m.rawFor(.postgres, …) branch per backend. SQLite-only consumers just swap w → m.db.
  • ErrorPhase gained a .webhook variant (additive). An onError handler that switches exhaustively over ErrorPhase must add a .webhook arm.

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_URL in a -Dpostgres build; 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 via std.crypto); the default SQLite build links zero new symbols. Transport is encrypted but the server certificate is not yet verified in any sslmode (verify-full is a tracked follow-up) — use the Postgres backend over a trusted network path until then.
    • migrate-db CLI: 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 -Dvector flag 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 backend field on GET /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 reads ZIGBASE_DB_URL and logs a prominent warning if it is a postgres:// 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 bound tenant_field = ? predicate; create stamps the owning account and update rejects cross-tenant moves. The active account resolves from an X-Account-Id header or a signed zb_account cookie, verified against an active _memberships row (fail-closed). Adds built-in _accounts/_memberships/_invitations collections, a configurable role order (viewer < editor < admin < owner), POST /api/accounts/:id/activate, and the @request.account.id/.role/.ids rule macros. Superusers bypass; zigbase.crossTenant(rctx) is the explicit admin override. Apps with no .tenancy are 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, and ctx.can(.action, "col", id) + GET …/records/:id/abilities expose them to custom routes. Collections with no .abilities are byte-identical to before.
  • Search on the list endpoint (#157).
    • Full-text search ships in the default build: mark a text/editor field .searchable = true and query with ?search=<terms> — ranked by relevance, with AND/OR/NOT/prefix operators, provisioned automatically (SQLite FTS5; Postgres tsvector + GIN). Search composes with the full authorization stack and structured filters: ?search=X&filter=Y returns the scoped intersection (never an unscoped query) and terms are always bound (no injection).
    • Vector / nearest-neighbor search behind an opt-in -Dvector flag: ?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.
  • Product analytics (#158). ctx.track("user.signup", .{ .plan = "pro" }) appends an immutable event — actor, tenant, and timestamp stamped server-side — to the new _events collection. 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) and GET /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 Mailer vtable (SMTP/Command unchanged), a per-message From override, and a CaptureMailer for 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.Email gains html_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 .jobs kind→handler registry, then enqueue from anywhere with ctx.enqueue(.queue, .kind, payload). Durable queues persist to _queue_jobs with 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 (honoring Retry-After, capped), optional HMAC-SHA256 signing, and a stable per-delivery Idempotency-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. New App(.{ .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 via App(.{ .captcha = … }) (dev-bypass when the secret is empty).
  • Custom-route ergonomics. Response builders (ctx.json / jsonError / html / redirect / notFound), deferred ctx.setCookie / addHeader (merged on both the success and error paths), lazy ctx.query(), ctx.randomToken / randomHex, and ctx.subjectCookie (an anonymous per-visitor id). A declarative route guard pipeline: .auth now also accepts a path_secret guard (constant-time shared-secret gate, bare-404 on mismatch) and .rate_limit adds per-route buckets keyed on the trust-proxy client IP. http.Cookie gains an optional domain.
  • Filter/rule grammar: a new in set-membership operator (field in ("a", "b"), compiled to a bound IN (?, …), empty set fail-closed) and the @request.account.id / .role / .ids macros that underpin tenancy and abilities.

Changed

  • A .nocase (case-insensitive) index now makes both uniqueness and lookups case-insensitive on SQLite. Previously a .nocase UNIQUE index treated Bob@x.com/bob@x.com as the same identity, but the lookup was case-sensitive — so a user registered as Bob@x.com could not log in as bob@x.com. Identity/email lookups and =/!=/in comparisons against a .nocase column are now case-insensitive, agreeing with the index (and matching the Postgres backend, which uses a lower() functional index). The built-in auth identity index remains case-sensitive ...
Read more

ZigBase 0.8.0

Choose a tag to compare

@github-actions github-actions released this 28 Jun 15:29
Immutable release. Only release title and notes can be modified.
c4d8cf3

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 API ctx.flag("arbitrary") (KV-or-false) has been removed — use the typed App.flag(ctx, .name) for known flags, or ctx.flagByName("name") (returns ?bool, null when undeclared) for dynamic names.
  • ctx.setFlag now writes a declared-flag override. It writes the flag:<name> override key for a DECLARED flag and errors error.UndeclaredFlag otherwise (the typed, compile-checked form is App.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 the App(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, and App.experiment(ctx, .name, subject) ![]const u8 — a typo'd flag/experiment name is a compile error (generated App.Flag / App.Experiment enums).
  • Runtime resolution. ctx.flagByName(name) ?bool (dynamic read), ctx.flags().resolveAll(subject) resolves every declared flag + experiment in a single batched _kv scan, and deterministic experiment bucketing (FNV1a-64(name ++ 0x00 ++ subject) over cumulative weights) gives a stable variant per (name, subject). Per-flag overrides live in _kv under flag:<name>; experiment weight overrides under exp:<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 the flag:<name> override, and each declared experiment's variants with editable weight sliders that write the exp:<name>:weights override; a "Reset to declared" action clears the override. Superuser-only; backed by the new GET /api/features endpoint.
  • New GET /api/features endpoint (superuser) returns the comptime-declared flag + experiment registry alongside each entry's current _kv override — useful for custom admin tooling.
  • Feature exposure events: register .onFeatureExposure to receive an ExposureEvent ({ 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.setFlag or an admin PUT/DELETE of a flag:<name> / exp:<name>:weights setting) broadcasts a signal-only {"type":"features.changed"} frame on the public __features channel. Clients may subscribe anonymously and re-GET /api/state on 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 _kv keys, defaults, weights, timestamps, or any superuser settings verb (those stay behind requireSuperuser). A .sticky experiment returns its persisted assignment here too (agreeing with App.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-client now emits a fully-typed feature-state surface from your App(.{ .flags, .experiments }): flags as named booleans and each experiment as a string-literal union of its declared variants (FeatureState). await zb.flags.resolveAll("user-42") calls GET /api/state and returns { flags: { … }, experiments: { … } } with no any. 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 = true to persist a subject's first variant in _experiment_assignments so it survives later weight changes (new subjects still follow the current weights; empty subjects are never persisted). A framework-internal _experiment_gc job — installed only when a .sticky experiment is declared — reaps assignments older than the new .experiment_assignment_ttl config (in days, default 90) hourly in bounded batches.

ZigBase 0.7.1

Choose a tag to compare

@github-actions github-actions released this 28 Jun 02:46
Immutable release. Only release title and notes can be modified.
adeb109

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 = … } } } — and zig build gen-client reflects the declared Zig types into zb.auth.<col>.<method>.{initiate,complete} interfaces (named by the Zig type, like the typed zb.rpc.* route surface). A void Input omits the input argument; a void Output maps to Promise<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 a ctx.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/.off enum-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 via ctx.tx(), a best-effort booking-confirmation webhook via ctx.http(), and KV write-side seeding from onBootstrap. Added a deterministic e2e suite that freezes time with ZIGBASE_FAKE_NOW and captures the outbound webhook. Fixed a latent date-formatting bug in golfsim's isoFromEpoch (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

Choose a tag to compare

@github-actions github-actions released this 28 Jun 00:29
Immutable release. Only release title and notes can be modified.
0a1c9ea

Breaking

  • Custom handler/hook/job signatures now receive a unified per-request *Ctx:
    • Untyped routes are fn(ctx: *zigbase.Ctx) anyerror!zigbase.http.Response (was fn(*RouteEvent)).
    • Record hooks are fn(ctx: *zigbase.Ctx, ev: *zigbase.RecordEvent) anyerror!void (was one-arg fn(*RecordEvent)).
    • Jobs are fn(ctx: *zigbase.Ctx, ev: *zigbase.events.JobEvent) anyerror!void (was one-arg fn(*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 via req.ctx (req.ctx.records(), req.ctx.http(), req.ctx.arena, req.ctx.app).
  • DB access is now uniform through the Ctx capability object: ctx.records() (list/get/create/update/delete), ctx.tx() (atomic writes), and ctx.http() (outbound client). In a before* 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 public zigbase.Data re-export. Migrate hook/job DB access to ctx.records(); for raw SQL on a migration-owned table use the pooled writer via ctx.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
    whose t.records() exposes the full Records API; 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/settings REST surface.
  • Auth lifecycle hooks (#98): a new .auth config group adds before/after hooks for
    register, logout, refresh, and password-change, extending the Theme D
    beforeAuthSuccess discipline into a uniform lifecycle. Before-hooks run with a *Ctx
    bound to the action's connection (in-transaction for register / refresh /
    password-change), so ctx.records() writes commit atomically with the action; returning
    an error aborts and fails closed (rolling back where a write transaction exists — e.g. an
    aborting beforePasswordChange leaves the password unchanged and the reset token
    un-consumed, an aborting beforeRegister creates no account). After-hooks are notify-only.
    Hooks fire on register (auth-collection record create), POST …/auth-logout,
    POST …/auth-refresh, and POST …/confirm-password-reset. A typo'd hook name or a
    wrong-typed handler is a compile error. The existing beforeAuthSuccess and onAuth
    hooks are unchanged.
  • Handler/hook/job capability object: handlers, hooks, and jobs now receive a
    *Ctx directly, exposing ctx.records() (filtered/sorted/paginated list +
    get/create/update/delete, with expand/relations), an outbound ctx.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_KEY at generation ZIGBASE_FIELD_KEY_GENERATION (default 1, = the v<N>: envelope version written); older generations are supplied via ZIGBASE_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 rewrap command — re-encrypts every .encrypted field across all collections under the primary key, and migrates legacy plaintext into ciphertext (the supported way to enable .encrypted on a column that already holds plaintext). Idempotent, transactional per collection, with --dry-run.
  • ZIGBASE_FAKE_NOW now also freezes CURRENT_TIMESTAMP and column DEFAULTs. The dev-only test clock previously froze the framework's own timestamps and a consumer's raw datetime('now') / unixepoch('now') / strftime(…, 'now'), but the SQL keywords CURRENT_TIMESTAMP / CURRENT_TIME / CURRENT_DATE and column DEFAULT CURRENT_TIMESTAMP still 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) — set ZIGBASE_FAKE_SEED to a decimal u64 on 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 same dev_clock build option as ZIGBASE_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 _sessions rows in bounded batches on the writer — no opt-in required. The default
    cadence is hourly; override it with App(.{ .session_store = .table, .session_gc_cron = "…" })
    (UTC, minute-granularity cron syntax). Nothing is installed in the default .epoch mode (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), and rotate()
    (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 single tokenKey SELECT 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 (.epoch default, or .table). The .table
    variant adds a server-side _sessions store for full per-device management:
    ctx.auth().listActiveSessions() (with is_current) and ctx.auth().revoke(sessionId)
    ("log out THIS device", owner-or-superuser authorized). In table mode each token carries an
    opaque sid and verification additionally requires a live (unexpired) session row — one
    extra indexed read per authenticated request. .epoch stays the default and is unchanged:
    zero extra DB work, and enabling .table does not alter the .epoch-mode token shape
    (the sid claim is simply omitted when absent). In .epoch mode the per-device verbs
    return error.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/json field .encrypted = true to 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 versioned v<N>: envelope.
  • TTL records. A collection may declare .ttl_field = "<field>" naming an existing date/autodate field 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_field are 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_gc sweep) alongside consumer .cron jobs. 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 curated data.kvGet/kvSet/kvDelete/kvList) over a new internal _kv table — 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) -> bool and ctx.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/:key for managing KV/settings values.
  • The ZIGBASE_FAKE_NOW dev test clock now also freezes a consumer's own raw SQL
    datetime('now') / unixepoch('now') / strftime(…, 'now') (and date/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', the strftime format 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...
Read more

ZigBase 0.6.0

Choose a tag to compare

@github-actions github-actions released this 23 Jun 01:06
Immutable release. Only release title and notes can be modified.
b77f9f3

Features

  • Auth-aware Data.create — Data.create on an auth collection now runs the same credential transforms as the HTTP records handler (generates the per-record tokenKey, forces verified=false, hashes password when supplied), so a programmatically-created record works with zigbase.auth.issueSession / mintLinkToken immediately. password is optional, enabling passwordless (magic-link) sign-up to provision an account without hand-writing credential columns. Non-auth collections are unaffected; the lower-level engine records.create still does a raw insert for imports/migrations.
  • magic_link and otp auth methods now honour auto_create: true — when an unknown identity calls initiate, 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 with verified = false; pair with require_verified only 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 -i or msmtp -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 new ZIGBASE_SENDMAIL_COMMAND env var (whitespace-split into argv; From: from ZIGBASE_SMTP_FROM), which takes precedence over SMTP in DefaultMailerPlugin. Re-exported as zigbase.CommandMailer.
  • Comptime .indexes on collection literals — a zigbase.App(.{ .collections = … }) collection may now declare .indexes = .{ .{ .name, .fields, .unique?, .collation?, .where? }, … }, lowered into the provisioned schema and emitted as CREATE INDEX DDL (case-insensitive via .collation = .nocase; conditional-unique via .where). Index .fields reference fields by their declared name.
  • ZIGBASE_PUBLIC_URL → clickable magic-link emails — set public_url (env ZIGBASE_PUBLIC_URL) and the built-in magic_link method 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 runtime clientId/clientSecret are sourced from ZIGBASE_OAUTH_<NAME>_CLIENT_ID / ZIGBASE_OAUTH_<NAME>_CLIENT_SECRET at 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 a dev_clock build (on in Debug, 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.
  • golfsim example: require_verified = true on the users auth collection — guests must verify their email before a session is minted (booking/payments justification).
  • golfsim example: OTP passwordless login (auto_create = false) for existing verified accounts; first-time onboarding remains password signup + email verification.
  • golfsim example: comptime indexes — NOCASE unique on users.email (prevents case-variant duplicate accounts) and a partial composite index on bookings(listing, starts_at) WHERE status != 'cancelled' (backs the double-booking overlap check and availability route).
  • golfsim example: OAuth2 "Sign in with Google" via comptime .auth.oauth2; client credentials sourced from ZIGBASE_OAUTH_GOOGLE_CLIENT_ID / ZIGBASE_OAUTH_GOOGLE_CLIENT_SECRET at provision time; Google-verified accounts are created verified=true.
  • golfsim frontend: multi-step Auth component covering password sign-in, OTP initiate/complete, signup, email-verification, and Google OAuth2 flows.
  • Blog example: adds built-in magic_link auth on users (passwordless login via
    emailed link, auto_create = true, 1 h TTL, server-redirects to /).
  • Blog example: NOCASE unique comptime index on users.email via .indexes = .{...}
    — prevents case-variant duplicate accounts.
  • examples/plugins showcases the full advanced auth surface: authors auth collection with WebAuthn (passkeys) + a custom ApiTokenMethod plugin; commenters auth collection with magic-link (auto_create=true); onAuth hook logging all three methods; comptime NOCASE collation index on authors.contact_email; frontend magic-link comment flow; beforeCreate hook auto-populating commenter from session.
  • Comptime index collation + partial predicates — schema.Index gains collation (.binary default / .nocase, applied per indexed column) and an optional where: ?[]const u8 partial-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 .collections schema and emitted in the generated CREATE INDEX DDL, instead of requiring an out-of-band raw-SQL bootstrap. Defaults preserve existing DDL and JSON round-trip behavior.
  • mintLinkToken opaque bound payload — zigbase.auth.mintLinkToken takes a trailing opts: MintOptions arg whose payload (default "") binds a small opaque string into the single-use token's signed pl claim, returned by verifyLinkToken as claims.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=/app verifies and consumes the single-use link token (same replay guard as complete), mints the session through the shared issueSession seam (so onAuth(.magic_link) fires and the zb_auth/zb_csrf cookies are set), honors the require_verified gate, and 302s to the redirect target. Two new per-method magic_link options shape the redirect: redirect_default (fallback path when ?redirect= is absent or rejected; defaults to /) and redirect_allow (allow-list of exact paths or /-suffixed prefixes; an empty list permits any safe relative path).

Fixes

  • Comptime .indexes is no longer silently ignored — the documented .indexes key on collection literals was never lowered by the provisioner; it is now applied.
  • Corrected false claim in examples/plugins migration 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_csrf cookie in the X-CSRF-Token header on unsafe methods (POST/PUT/PATCH/DELETE); GET/HEAD/OPTIONS are exempt. A failed CSRF check makes the request anonymous, so the response status follows the collection's access rules — 403 on a create denial, 404 on an update/delete denial against a protected record (existence-hiding) — not a flat 403. 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/%5c payloads are all rejected and fall back to redirect_default.

Internal

  • Changelog-fragments workflow — changes now add a changelog.d/<slug>.md fragment (with one or more ### <Section> headings) instead of editing CHANGELOG.md, so parallel PRs never conflict on the shared changelog. scripts/assemble-changelog.sh aggregates the fragments per section into a new version block in CHANGELOG.md (and its site/ mirror) at release time (run from scripts/release.sh) and deletes them. See changelog.d/README.md.
  • Corrected the "provisioned columns are named by a stable field id" claim in CLAUDE.md and docs/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 via getMe() on mount, and an
    AuthStatus nav island for logged-in display after consume redirect.
  • Blog example README: document ZIGBASE_PUBLIC_URL, the fake blog.test UR...
Read more

ZigBase 0.5.0

Choose a tag to compare

@github-actions github-actions released this 22 Jun 01:16
Immutable release. Only release title and notes can be modified.

Removed

  • BREAKING: legacy OAuth2 endpoints removed — GET .../oauth2-providers, POST .../oauth2-init, and POST .../auth-with-oauth2 no longer exist. OAuth2 is now exclusively the contract method: POST .../auth/oauth2/initiate, POST .../auth/oauth2/complete, and GET .../auth/oauth2/providers (discovery).

Added

  • OAuth2 as a first-class AuthMethod — exclusively at the contract endpoints POST /auth/oauth2/initiate, POST /auth/oauth2/complete, and GET /auth/oauth2/providers (discovery); all paths share one implementation and the single onAuth session seam. See docs/api.md for request/response shapes.
  • Pluggable auth-method system — the AuthMethod contract (initiate/complete + AuthCtx blessed 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.methods config — enable and configure built-in methods per auth collection: password (backward-compat default when .methods is absent), magic_link (TTL, auto-create flag), otp (code length, TTL), webauthn (rp_id, rp_name, origin, credentials_collection). Each method has a rate_limit knob (.default | .off | .{ .custom = .{ .max, .window_s } }).
  • App-level .auth_methods — register custom AuthMethod plugin TYPES at comptime (same pattern as .storage/.mailer); a type missing create/method/deinit is a compile error.
  • Auto-mounted auth endpoints — for every enabled method, the framework auto-mounts POST /api/collections/:col/auth/:method/initiate and .../complete; the dispatch enforces enablement (404 for disabled/unknown methods) and default rate-limits.
  • magic_link built-in — enumeration-safe initiate (always 204), single-use link token emailed via the configured mailer, complete verifies+consumes and mints the session.
  • otp built-in — enumeration-safe initiate emails a 6-digit code stored in the ChallengeStore, complete verifies the code.
  • webauthn built-in — passkey login via the two-phase contract (initiate returns PublicKeyCredentialRequestOptions; 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; attestation fmt:"none" (v1); signCount clone detection (fail-closed); credentials stored in _webauthnCredentials.
  • ChallengeStore (_authChallenges) — TTL'd, GC'd single-use server-side challenge storage used by otp and webauthn, and accessible to custom plugins via AuthCtx.challengeStore().
  • onAuth method tagging extended — AuthEvent.method is an enum: .password, .oauth2, .magic_link, .otp, .webauthn, or .custom for custom plugins.
  • RPC client generation for auth endpoints — the generated TypeScript client exposes non-password auth-method endpoints under an auth surface (initiate/complete stubs, currently untyped).
  • zigbase.auth consumer surface for custom auth flows — issueSession (and RouteEvent.issueSession), single-use magic-link tokens (mintLinkToken / verifyLinkToken / consumeLinkToken), deliverAuthMail, and rateLimit. All session minting now funnels through one seam that always fires onAuth.

Security

  • OAuth2 server-side CSRF state is now ON by default (ZIGBASE_OAUTH_STATE_SERVER defaults to true). The initiate endpoint issues a state value and complete requires and consumes it before contacting the provider. Behavior change: OAuth2 clients must use the initiate→complete flow; bare complete calls without a valid state are rejected with 400. Set ZIGBASE_OAUTH_STATE_SERVER=false to restore the previous client-driven mode.
  • New require_verified per-collection auth option (default false). When true, any login attempt for an unverified record is rejected with 403. This gate applies to all methods — including WebAuthn/passkey and OAuth2 accounts whose provider email was unverified (those are created verified=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=false and the email field 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_uv option (default false). When true, 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. otp and magic_link release 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). oauth2Providers uses 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 complete releases the writer during the provider HTTP exchange (no write-throughput stall); password complete uses a reader (argon2 is read-only). Neither method blocks writes during I/O.
  • Session issuance (password, refresh, OAuth2) routes through a single
    issueSession+emitAuth seam
    — custom routes can no longer mint a session that
    skips the onAuth hook.