ZigBase 0.12.0
[0.12.0] - 2026-07-26
Breaking
-
The three request-scoped allocator seams are now the typed
zigbase.RequestArenainstead of a barestd.mem.Allocator:RecordEvent.arena(ev.arenain hooks),Ctx.arena(ctx.arena/req.ctx.arenain custom routes and jobs), andRequestCtx.allocator(ctx.allocator). The allocator itself is the field.aon the wrapper, so every place you passed one of these seams to something that wants an allocator, append.a:ev.arena.alloc(...)→ev.arena.a.alloc(...),ev.record.object.put(ev.arena, …)→ev.record.object.put(ev.arena.a, …),std.fmt.allocPrint(req.ctx.arena, …)→std.fmt.allocPrint(req.ctx.arena.a, …),ac.ctx.allocator→ac.ctx.allocator.a. Passing a seam straight through to another ZigBase API that takes aRequestArenaneeds no change; the compiler flags every site that does.Why the break is worth it: these arenas die at the end of the request, and the old bare-
Allocatortype made the two easiest lifetime bugs invisible — handing an arena-scoped API a long-lived general-purpose allocator (a leak), or stashing a request arena somewhere that outlives the request (a dangling read).RequestArenais constructible only from a realstd.heap.ArenaAllocatorat the boundary that owns it, so the first mistake no longer compiles, and the deliberate.aescape hatch makes the second one greppable instead of the default path. -
The dev-only build option
-Ddev-clockis renamed-Ddev-mode(it already gated the frozen clock, seeded entropy, and test-capture; it now also gates the new fake field-crypto). Update any CI/e2e invocation of-Ddev-clock=…to-Ddev-mode=…. -
A file download URL built with a raw
.authsession token in?token=(rather than a.file
token fromPOST /api/files/token) is no longer authenticated by that token. No first-party
client did this — the SDKs and admin UI already use.filetokens or the auth cookie/header —
but a hand-built URL relying on the old behavior must switch to a.filetoken. -
jwt.signnow returnserror.TokenTooLargerather than minting a token that exceeds
jwt.max_token_len, so this module can never produce a token it would itself refuse.
An application putting more than ~3 KB into the caller-suppliedplclaim now fails at
sign time instead of at the next request. Applications within that budget are unaffected.
Features
zigbase.checkSql/checkedSql: comptime validation of raw-SQL table/column identifiers
against the.collectionsschema, failing the build on an unknown table or a mistyped
qualified column. Best-effort by design (tables strict, qualified columns checked, unqualified
columns/functions untouched) to guarantee zero false compile errors on valid SQL — including
upserts: theUPDATEinON CONFLICT ... DO UPDATE SETis recognized as a conflict clause
(no table operand), not anUPDATE <table>statement.zigbase.Query.select: a comptime, schema-checked single-table SELECT builder that emits
validated SQL + positional binds forqueryAs— an unknown table/column is a build error, and
binds are positional by construction. SELECT-only / single-table in v1 (joins, writes, andin
are noted as future work).- Official Dart client SDK (
clients/dart, pub packagezigbase_client): REST records API with offset + cursor pagination, injection-safe filters, per-collection auth (password, OAuth2/PKCE, sessions), pluggable auth stores, file uploads/URLs, accounts/analytics/senders services, and realtime subscriptions over WebSocket with auto-reconnect. Dart VM, Flutter, and Flutter web. - Dart codegen. The client generator now emits Dart alongside TypeScript. Pass
--lang darttozigbase typegen(runtime introspection) orzig build gen-client
(comptime, via thegenClientSteplangoption) to generate azbase.gen.dart— concrete
typed record classes, per-collection typed services (typed CRUD, a fluent where-builder that
compiles to server filter strings, int/fixed decimal-string coercion, typed expand, files, and
realtime) over the base@zigbase/clientDart SDK's newpackage:zigbase_client/typed.dart
runtime. Typedrpc.*, auth-method, and feature-flag surfaces remain TypeScript-only for now. zigbase.testingcan now boot apps that declare.encryptedfields (#260): passStartOptions.field_keyfor real AES-GCM, or let it default to a dev-only fake-encrypt mode that stores readablefake:<key>:<value>at rest (label defaults to@test@) so encrypted values are eyeball-able while debugging. Also selectable onzigbase serveviaZIGBASE_FIELD_CRYPTO=fake. Fake crypto is compiled out of release binaries (thedev_modegate) and its envelopes are mutually unreadable with real ciphertext, so a fake DB can never be served by a production binary.- Added
jwt.verifyIntoandjwt.peekClaimsInto, which decode and verify a token into a caller-provided scratch buffer with zero heap allocation. An over-large token fails closed witherror.TokenTooLarge.jwt.scratch_size(16384) sizes that buffer for the measured worst case — escape-heavy claims forcestd.jsonto copy rather than borrow, and the consumption-to-token-length ratio rises with size (>4x at ~5.7 KB), so it covers 14800 bytes for any token withinmax_token_len. The allocator-takingjwt.verify/jwt.peekClaimsremain for callers already holding a request arena. zigbase.jwt,zigbase.crypto, andzigbase.RequestArenaare now public exports of the framework module, for consumers that need to mint/verify tokens, derive keys, or take the compile-enforced request-arena contract type directly.- Kotlin client SDK (
clients/kotlin, Mavenio.github.valthon:zigbase-client0.1.0): coroutines-firstZigbaseClientcovering auth (password, refresh, OAuth2/PKCE, sessions), records CRUD with offset + cursor pagination and an injection-safe filter builder, multipart file uploads, file URLs/tokens, and accounts/analytics/senders services. Realtime and typed codegen tiers follow. - Kotlin SDK realtime tier (
zb.realtime, bundled — no extra dependency): ack-gatedsubscribe/subscribeTopicwith an unsubscribe-function return,stream()/streamTopic()coldFlows, custom broadcast topics (signal/message), automatic re-auth fromauthStoreon login/logout/refresh, and exponential-backoff reconnection with full resubscribe. - Kotlin SDK typed tier:
zigbase typegen --lang kotlingenerates@Serializablerecord data classes withfromRecordcoercion,Create/Updatepayloads withtoMapwire encoding, injection-safe fluent filter builders, and typed collection services (plus Flow-based typed realtime) over the newio.github.valthon.zigbase.typedruntime — golden-gated in CI against the dating fixture. zigbase typegen --lang kotlingains a--package <name>flag that sets the emittedpackagedeclaration, honored on both the CLI and the comptimegen-clientbuild step, so a consumer wiringgenClientStepwithlang: "kotlin"targets their own app's package instead of getting an unoverridable namespace in a file marked "do not edit". Unqualified invocations still default to the dating fixture'sio.github.valthon.zigbase.codegen.datingnamespace (keeping the committed golden andzig build gen-dating-kotlin-clientbyte-stable).captcha.Resultandoauth.discovery.Endpointsgain adeinit(allocator)that frees their
owned strings, so a result produced with a non-arena allocator can be released. Callers on the
request-arena path (the usualctx.verifyCaptcha,resolve/parseDocument) do not need it.- New
zigbase importsubcommand +zigbase.Importlibrary entrypoint: encryption-aware,
offline (no HTTP server) bulk NDJSON record import that streams and batches through the
record engine — validation, defaults,.encryptedfield envelope, and auth password
hashing all applied — with optional--upsert-keyidempotency and source-id preservation. - Python client SDK (
clients/python, PyPIzigbase0.1.0): syncZigBaseand asyncAsyncZigBaseclients covering auth (password, refresh, OAuth2/PKCE, sessions), records CRUD with offset + cursor pagination and an injection-safe filter builder, file URLs/tokens, and accounts/analytics/senders services. Realtime and typed codegen tiers follow. - Python SDK realtime tier (
zigbase[realtime]):AsyncZigBase.realtimewith ack-gatedsubscribe/unsubscribe,stream()async iteration, custom broadcast topics (signal/message), automatic re-auth on auth-store changes, and exponential-backoff reconnection with full resubscribe. - Python SDK typed tier (
zigbase[typed]):zigbase typegen --lang pythongenerates Pydantic v2 record models, injection-safe fluent filter builders, and typed sync/async collection services (plus async typed realtime) over the newzigbase.typedruntime, golden-gated in CI against the dating fixture. Aselect-typed field'seq/neq/in_listacceptNonefor null filtering; a generated record'sexpandattribute (and each relation on its<Rec>Expandsubmodel) defaults to an empty value, so manual instantiation (tests, mocks) never requires building an expand submodel by hand. - Vendored/native component versions (SQLite, sqlite-vec, zap, facil.io, zigbase) are now
discoverable viazig build versions, the enriched--versionoutput + a startup log line,
and aversionsobject onGET /api/health. - Building against an unsupported Zig version now fails at compile time with a clear
required-vs-actual message instead of an opaque deep-compilation error.
Fixes
- Many of the memory-leak fixes below were surfaced by the allocator-ownership migration described
under Internal. They share a shape: the leaked scratch was always reclaimed by the
per-request or per-job arena a deployed server passes, so running servers were unaffected — but
the leak was real for a framework consumer calling the same API with a general-purpose
allocator, and it blinded the leak detector on that path. Each entry says which case it is. - Static-file range handling:
normalizeRangeno longer leaks its scratchbytes=a-b
string on the already-canonical passthrough path (returnednullwithout freeing the
freshly allocated buffer). A no-op under the request arena every production caller
passes, but a genuine leak under any non-arena allocator. - Made several framework helpers self-freeing under any allocator (allocator ownership contracts 1
& 2), removing latent scratch/result leaks that were reclaimed only when the caller passed a
request/job arena — as every in-tree call site does, so deployed servers were unaffected, but the
leaks were real for a general-purpose-allocator caller:sms/twilio.zigTwilioSender.sendnow routes its URL/auth/body build and theHttpClient
response scratch (a fixedmax_response_bytesbuffer that has nodeinit) through a
function-local arena.analytics/analytics.zigrunRollupbuilds its summary-table/watermark/aggregation-SQL scratch
on a function-local arena.api/senders.ziglistBodybuilds its intermediate JSON envelope on a function-local arena
(only the stringified body escapes).queue/durable.zigclaimBatchself-frees its dynamicIN (…)SQL scratch and returns an
owned[]Claimedfreed via the newfreeClaimed(contract-2), with per-row error-path cleanup.authz/abilities.zigabilityPredicatefrees theallocPrint-built"<col>"."<via>" IN (
prefix it leaked on every non-empty ability predicate (it was passed straight into
appendSlice, which copies, without freeing the temporary), and adds error-path frees for
its predicate buffers.auth/challenge_store.zigtakeByIdentityfrees its intermediate challenge id (previously
leaked on every call), andputfrees the generated id on a mid-insert error.route_types.zigtyped-route dispatch thunk (makeThunk) now frees its params view and — the
real fix — keeps and deinits the JSONParsedhandle it previously discarded (parseFromSlice(…).value),
which leaked that parse arena; only the serialized response body escapes.
- Vector search (
-Dvector)build: on an allocation failure while composing theORDER BY
distance expression, the already-allocatedWHEREfragment is now freed (addederrdefer),
closing an out-of-memory-path leak. - A collection field's
hiddenflag is now persisted and round-trips through a reload. It was never written to the stored schema, so every collection load silently reset user fields tohidden = false(the API and admin then reported hidden fields as visible); it now survives create/update/get correctly. - Fixed a memory leak on the auth-collection load path:
get(and the create/update it now backs) leaked the inner fields array when prepending the auth system columns, on non-arena allocators. - Fixed a memory leak in the
Datafacade's typed record I/O:createAs/getAs/updateAsparsed the intermediatestd.json.Valuerecord returned bycreate/findById/updateintoTbut never freed that intermediate, leaking every owned string in the discarded record (~46 allocations per call) on non-arena allocators. The intermediate is now freed after the parse deep-copies intoT. dumpload.planCreateOrder(themigrate loaddependency-order planner) leaked its Kahn-algorithmplacedscratch buffer on every call. Always masked by the migration's request arena; now freed explicitly.schema_dump.pgColumnType's array-type branch (Postgres schema dump,_<udt>columns) leaked the base type string it formats into the final<base>[]result. Always masked by the per-dump scratch arena; now freed explicitly.- Fix a memory leak in stored-filename sanitization (
files/naming.zig):sanitizeBasefreed its
scratchArrayListonly viaerrdefer, so every successful call leaked the buffer, and
storedNamenever freed the sanitized intermediate. Latent behind the upload request-arena, a
real leak under any non-arena allocator. Now contract-1 (defer-freed; the return is always a
fresh copy). - JWT signing no longer leaks its intermediate buffers when handed a non-arena allocator:
jwt.signnow frees the payload JSON, both base64 encodings, and the signing input, leaving only the returned token allocated. - Fix a memory leak in captcha response parsing.
captcha.parseResponse(reached via
ctx.verifyCaptcha) parsed the provider's JSON with a leaky parser and never freed the
tree, and returnedResultfields that borrowed it — including anerrorsslice that was
an un-freeable sub-slice of a larger allocation. It now frees the parse tree and returns
independently-owned dupes. Requests served through a per-request arena were unaffected in
practice (arena teardown reclaimed the tree); the leak bit any caller using a
general-purpose allocator. - Fix several memory leaks in the mail subsystem, latent in the request/job-arena path but real
under any non-arena allocator:- Bulk email:
bulk.sendBulknever freed the per-recipientvars_json/durable-jobpayload
scratch (freed per iteration now that SQLite/enqueue copy it), andbulk.jobHandlernever
freed the ~9 fields it rendered per delivery (now routed through a function-scoped scratch
arena freed on every return path). - Inbound webhooks:
suppression.parseProvider/mapSes/mapPostmarknever freed the provider
JSON parse tree and returnedEvent.emailas a slice borrowed from it (now duped before the
tree is freed);inbound.ingestdiscarded the suppressionEventslice without freeing it;
andinbound.webhook_handlerleaked its responseObjectMap.
- Bulk email:
- Multipart form-data parsing no longer leaks its per-request delimiter scratch:
files/multipart.parseallocated two derived boundary-matching strings on every call and never freed them, leaking that memory for any caller that does not pass an arena allocator (the production HTTP upload path is arena-backed, so served requests were unaffected). - Creating or updating a record with file uploads no longer orphans the uploaded bytes in storage when the write is rolled back (a failed commit, a denied access-rule guard, or a validation error) — the just-written files are now always removed on any pre-commit failure.
- Failures while cleaning up files after a delete/update (e.g. a transient object-store error) are now logged instead of silently swallowed, so orphaned-file accumulation is diagnosable.
- A malformed or out-of-range numeric value in a
filter,sort, or cursor (e.g.price=99999999999999999999) now returns400 Invalid filter or sort.instead of500. - The request error path no longer risks panicking the server (or invoking undefined behavior in a
ReleaseFastembed) when the machine is out of memory: rendering a 500 that itself fails to allocate now falls back to a preallocated static error body. - Malformed
.cronschedule strings are now rejected at compile time (wrong field count, full day names likeMONDAY, a trailing/doubled space) instead of silently making a job fire once at boot and then retire without ever running on schedule. The same validation applies to.auth.session.gc_cron. - A cron/interval job whose schedule has no future fire (e.g. an impossible date like Feb 30) is now retired at startup and logged, instead of being treated like a reactive job and run at an arbitrary boot time; jobs that retire for having no next fire are now logged rather than vanishing silently.
- Cron expressions now support the full standard grammar:
<lo>-<hi>/<step>ranges (e.g.0-23/2for every other hour) and day-of-week7as a Sunday alias (0and7both mean Sunday). Out-of-range field values (minute > 59, hour > 23, month > 12, day-of-month0, day-of-week > 7, etc.) are now rejected at compile time rather than compiling into a job that silently never fires. - Typo'd keys in more config surfaces are now a loud
@compileErrorinstead of being silently ignored: route specs (.rate_limit/.rate_limit_key/etc.), background job specs,.auth.methodsand each built-in method's options (.magic_link/.otp/.password/.webauthn),.auth.oauth2and its provider literals (e.g..tokenURL), collection.indexesentries (.unique/.collation/.where), and the.poolstuning group. - Duplicate or empty
.migrationsids are now a compile error; previously a copy-pasted id silently skipped the second migration on every environment. - A failure to create the data directory at startup (permissions, read-only filesystem, out of space) is now logged with the path and cause instead of being swallowed and later surfacing only as an opaque database-open error.
- Realtime: a failed
subscribeis no longer acked to the client as success. Neither a facil.iosubscribefailure nor a failure to record the just-created transport subscription (under memory pressure) can now strand the client — previously the first left a silent dead subscription and the second left a live subscription a later unsubscribe could never cancel. Both now roll back the logical subscription, log, and return an error frame the client can retry, on both the WebSocket and SSE transports. - Realtime: a dropped broadcast/signal/message frame (allocation failure on an already-committed write) is now logged with the collection/topic and action, matching the cross-instance paths, so a client-reported "missed update" is diagnosable instead of vanishing silently.
- Realtime (Postgres): cross-instance delete-snapshot and broadcast side-table failures now distinguish a genuinely-absent row (a forged/expired token — a quiet fail-closed drop) from a real database/parse error, which is now logged instead of collapsed into the same silent null.
- Realtime (Postgres): the cross-instance
LISTENreconnect backoff now resets only after a connection has stayed healthy for several seconds, and sleeps before reconnecting after a short-lived session. A proxy or mid-failover node that accepts the connection andLISTENbut drops it on the first wait no longer drives a zero-delay connect/reconnect loop. - A write that violates a database integrity constraint — most commonly a duplicate value on a unique field, such as signing up with an email that is already registered — now returns 409 Conflict instead of 500 Internal Server Error. Clients, SDKs, and error monitoring can now tell a routine user conflict apart from a genuine server fault. This applies to record create/update, runtime collection create/update, and WebAuthn credential registration, on both the SQLite and Postgres backends. Underneath, the internal database error set gained a distinct
error.Constraint(SQLiteSQLITE_CONSTRAINT; Postgres SQLSTATE class 23), raised from the prepared-statementstep()path instead of collapsing every failure intoerror.StepFailed, so a custom route that lets actx.records()write propagate surfaces the 409 automatically. Framework consumers who matched onerror.StepFailedfor a unique-violation race should matcherror.Constraint. Theexec()/COMMIT path (including deferred-constraint failures) is unchanged and still reportserror.ExecFailed. - Additive
ADD COLUMNsteps in the system migrations (sessiontoken_epoch,_collections.options,_suppressions.updated) no longer swallow every error as if it were the benign "duplicate column" case. A genuine DDL failure (lock timeout, disk full, connection drop) now propagates and aborts the migration instead of being recorded as applied with the column still missing — which could permanently break token issue/verify for an auth collection with no migration-based repair. Idempotence now comes from a backend-catalog column-existence check. - On Postgres builds, a pathological placeholder count in developer-authored raw SQL now surfaces a prepare error instead of panicking the process. This covers both a numbered
?Nwith an out-of-range or overflowing index (for example?10000000or a 20-plus-digit run), which previously panicked at statement-execution time, and an extreme number of anonymous?placeholders, whose running counter is now bounded by the same param cap rather than overflowing the placeholder buffer during renumbering. - Outbound webhook delivery now bounds the total time one attempt sequence spends sleeping between retries, so a receiver returning a large
Retry-After(or a long configured backoff) can no longer keep a delivery running past the queue'svisibility_timeout_s— which previously let the job be re-dispatched as a concurrent duplicate and stalled other jobs on the worker for minutes. - Fixed a table-name string leak on the out-of-memory error path of the database dump-load (
migrate load) copy loop. - S3 storage: the spool cache-fill path no longer leaks the joined cache path for a non-arena caller when the spool directory is unwritable or full; a failed per-object remote delete (orphaning a billed object after its record is gone) and a cache directory that becomes unlistable at runtime (silently disabling spool eviction) are now logged instead of swallowed.
- SMTP mailer: a partially-built CA bundle is freed when a system-trust-store rescan fails mid-load, closing a leak on hosts with a malformed or unreadable certificate.
GET /api/features: an out-of-memory error while rendering the403 Forbiddenbody now propagates to the500backstop instead of hitting anunreachable(a panic in safe builds).- Comptime
.migrationsbare-tuple entries now reject an unknown key (a typo'd.transational/.donwwas silently dropped) with a loud@compileError, matching every other list-shaped config key. - Webhook deliveries: at startup, warn about any declared queue whose
visibility_timeout_sis too small to safely host a webhook delivery's in-handler retry backoff, which could otherwise let the queue re-dispatch an in-flight delivery as a concurrent duplicate. - The "per-route rate limit cannot identify the client" startup warning now fires once per distinct route pattern instead of once per process, so a second unprotected route is no longer silently skipped from the log.
- Fixed a rare crash where enqueuing a background job (for example an error report) at the moment the in-memory job pool was shutting down could dereference a just-cleared pool pointer and panic the process.
App.submitnow null-checks the pool rather than asserting it, so a submit that races shutdown fails cleanly (the job is dropped) instead of crashing. - Fix memory leaks in the shared AES-256-GCM envelope (
aead.seal/aead.open) that backs every
at-rest secret — OAuth client secrets and.encryptedrecord fields.sealnever freed its
ciphertext/raw/base64 scratch buffers (three allocations per call) andopennever freed its
decode buffer (plus the plaintext on a decrypt-verify failure). Served through a per-request
arena the buffers were reclaimed at request end, but any non-arena caller (e.g. a batch
re-encrypt) leaked on every encrypt/decrypt. Now contract-1: all scratch is freed, only the
result escapes. - Fix leaks on the OAuth login path:
oauth.client.fetchIdentitynever freed theBearer <token>
authorization header it built, andoauth.providers.extractIdentitynever freed the JSON parse
tree of the provider's userinfo response (leaked on every third-party login). provision.appliedConsumerMigrations/recentConsumerMigrations(themigrate status/migrate rollbackledger readers) leaked the lowered SQL scratchMigrator.preparebuilds on every call. Always masked by the CLI's request arena; both now lower onto a function-local scratch arena instead.provision.migrationStatus'sorphaned[i].namewas an un-freeable mid-buffer offset into an internal ledger-read array the caller never saw (freeing it directly would have been an invalid free of a non-base pointer) —MigrationStatusnow dupes every retained string fresh and ships adeinit, so the result is a normal owned graph instead of an implicit arena-only value.provision.resolveDiscoveryProvidersleaked its partially-built collection/provider arrays when an OIDC discovery fetch failed mid-batch. Inert in production (the caller aborts startup on this error), but a real leak on any other caller; now freed via a tracked rollback on error.- Fixed a memory leak when reading or writing
jsonand multi-value (select/relation/file) record fields on a non-arena allocator:readValueused to return astd.json.Valuesub-tree from a discardedstd.json.Parsedwrapper (freeable only under an arena), andbindValuenever freed theStringify(and encrypted-seal) scratch it allocated.readValuenow returns a fully-owned, individually-freeable tree, andbindValuefrees its bind scratch — sorecords.freeRecord/ListResult.deinitreclaim a whole record (nested json/array sub-trees included) off any allocator. - Fix memory leaks throughout the realtime subsystem, latent behind the per-connection/per-request
arena but real under any non-arena allocator:realtime/connection.removeSubusedHashMap.remove, silently dropping the entry without
freeing the duped subscription key/filter — a connection that subscribed/unsubscribed
repeatedly accumulated leaked topics until disconnect. NowfetchRemove+ explicit frees, with
a newConn.deinitfreeing all remaining subscriptions.- Realtime frame building (
protocol.zig,ws.zig,pg_bridge.zig,hub.zig) leaked scratch
ObjectMaps and JSON stringify/parse buffers on nearly every emitted event/signal/ack frame. realtime/pg_bridge.decode/decodeAny(the Postgres realtime bridge) parsed with a leaky
parser and returned struct fields aliased into the never-freed tree; now dupes each field fresh
withEvent/Signal/MessageRef/Payloaddeinitmethods. Also fixes an unfreed
delete-snapshot JSON buffer instoreDeleteSnapshotInner.
- Fixed a long-standing memory leak in the
Datafacade:findById/create/update/delete/listloaded the collection metadata viacollections.getand never freed it, leaking on every call when driven by a general-purpose (non-request-arena) allocator. RecordValues now own their top-level keys, so the facade can free the collection safely. - Fixed a cursor-mode
records.listresult that returned a capacity-padded item slice and left the pagination probe row unfreed — harmless under a request arena but a wrong-size free / leak on a non-arena allocator. The result is now an exact-length owned slice with adeinit. - Fixed a memory leak on the record read path: when an encrypted field failed to decrypt part-way through building a row (fail-closed
error.BadEnvelope),records.get/getAtRest/create/update/listleaked the partially-decoded record.rowToObject/rowToObjectAtRestnow free the partial record on a mid-row read error. The same functions also leaked a hidden field's decoded value (read but never stored); that value is now freed too. Both matter for a framework consumer that calls these APIs with a plain (non-arena) allocator. parseCollectionInput(the runtime collection create/update request parser) leaked its filtered field-array scratch on every call: the intermediate arrayfieldsFromJsonreturns was discarded without freeing its backing allocation, and a submitted field whose name collided with a reserved system name (e.g."email"on a base collection) leaked that field's own id/name/options dupe entirely. It also leaked the escapingname/fields/indexesthemselves on a trailing error path (any ofindexesFromJson, the rule-string dupes, oroptionsFromJsonfailing after they were built) — none of these were reachable viaerrdeferonce each value's own local guard went out of scope. All are always masked in production by the request arena; all are now freed explicitly.- Fixed memory leaks throughout the collection and record write/read paths (
collections.create/update/delete, record insert/update/delete, and record reads) when driven by a non-arena allocator. The HTTP request path reclaims this scratch through its per-request arena, but callers that pass a general-purpose allocator — the Postgres backend's collection-cache fallback and directData-facade use — leaked the DDL, SQL, column-list and$nplaceholder-rewrite scratch on every call. These operations now free that scratch internally on both success and error paths, and validation failures hand their error list back as an owned, freeable slice, so each operation is leak-correct under any allocator.
Changed
Data.createAs/getAs/updateAsnow reject aTthat (recursively) contains a raw-JSON field (std.json.Value/ObjectMap/Array) at compile time with an actionable message.parseFromValueLeaky(used by these methods) returns such a field as an alias into the intermediate record they now free — which would dangle a string field (use-after-free) — so typed I/O is restricted to concrete field types; use the untypedcreate/findById/updatefor raw JSON.
Performance
- Realtime: per-subscriber event delivery now parses only the three envelope fields it needs (
action,record.id, and the delete-authorization snapshot) with a typed, unknown-field-skipping parse, instead of materializing the entire record body into a throwaway JSON tree for every subscriber. A create/update of a large record fanned out to many subscribers no longer does O(subscribers × record-size) redundant allocation. - List-endpoint
?expand=now resolves each relation target's schema once per page and reuses each(collection, id)view-authorization decision across rows, instead of re-loading and re-parsing the target collection and re-authorizing on every returned record. This removes redundant_collectionsreads/parses and duplicate rule queries onGET …/records?expand=…, most impactful on the Postgres backend where each was a network round trip. - Realtime delete authorization: the per-subscriber in-memory authorization sandbox for a deleted record is now reused across every subscriber of the same delete event that is served on a given worker thread, instead of being rebuilt once per subscriber. Large delete fan-outs do far less redundant work; per-subscriber authorization decisions are unchanged.
- Pre-size the record-read hot path's growing collections so their backing is allocated once
instead of reallocating as they fill.records.rowToObject(and its at-rest sibling)
pre-sizes the per-recordstd.json.ObjectMapto id/created/updated + the collection's
fields;records.listpre-sizes its result-item list to the page limit. Measured with
zig build bench, a 30-record list read dropped from ~260 to ~226 allocations per page
(~13%) — the 512-byte size bucket fell from 38 to 6 — and a single-record read drops an
allocation. Output is byte-identical (insertion/append order is unchanged), verified by the
record + cursor-pagination browser tests.
Security
- Closed an account-enumeration oracle across every token-mail endpoint: OTP initiate,
magic-link initiate,request-verification, andrequest-password-reset. Each previously
sent its code/link synchronously and only for an existing (or auto-created) account, so both
the response timing and a propagated SMTP failure (500vs204) revealed whether an email
was registered — and a mailer outage turned the endpoint into a boolean existence oracle.
Delivery now goes through the non-blocking token-mail queue on all four, so each returns204
with identical timing and status regardless of whether the email matched a record. - File downloads via the
?token=query parameter now accept only purpose-built.file
tokens (minted byPOST /api/files/token), not full.authsession tokens. A session token
in a URL query travels into access logs,Refererheaders, and browser history, so permitting
it there invited long-lived credentials into those sinks. Session-authenticated downloads
continue to work via theAuthorization: Bearerheader or the auth cookie. - Bound JWT token length before any allocation.
jwt.verifyandjwt.peekClaimsnow
reject a token longer thanjwt.max_token_len(4096 bytes) aserror.TokenTooLarge
as their first statement. Previously nothing on the request path bounded token length,
while a request body may bemax_upload_size(50 MiB by default) and a realtime frame
256 KiB — and because a token is decoded before its signature is checked, an
unauthenticated request could drive allocation proportional to the token it supplied. - Realtime: a duplicate
subscribeto a topic a socket already holds now REPLACES its subscription in place instead of stacking a second facil.io subscription. The old behavior let an (even anonymous) client loop subscribes on any public collection to bypass the per-connectionMAX_SUBScap entirely, grow per-connection memory without bound, multiply every published event's server-side authorization/delivery work N×, and orphan all-but-the-last facil.io subscription until socket close — a connection-scoped denial-of-service. Applies to both the WebSocket and SSE transports. - Realtime: a connection can no longer be driven to unbounded memory growth by looping
auth
frames — closing an inbound-driven single-connection memory-exhaustion vector.authframes are
now verified on a throwaway scratch arena, with only a successful identity persisted, and that
verified identity is held in a dedicated arena reclaimed on each re-authentication. Previously
everyauthframe leaked permanently into the connection-durable arena (freed only at connection
close) — including a garbage token, which allocated during pre-validation claim parsing, so the
attack needed no credentials at all — and even valid repeated re-authentication grew the
connection without bound. - Client-supplied
?filter=and?sort=can no longer reference hidden fields (passwordHash,tokenKey,token_epoch, or any field markedhidden). Previously such a query on a non-locked collection turned row presence/absence into a boolean oracle, allowing character-by-character extraction of a per-user server secret the API never serializes. The query builder now rejects hidden fields in client input the same way it already rejects encrypted ones (closed, with a400), matching the read layer's visibility rule exactly so no serialized column is affected. Trusted, operator-authored access rules may still gate on a hidden field — a rule is a server-sideWHEREclause whose truth is never returned to the client, so it is no oracle. - Per-route rate limiting no longer collapses every client into a single shared bucket when the client cannot be identified (a
.customroute limit with norate_limit_key, on a directly-exposed server whereZIGBASE_TRUST_PROXYis off and the client IP is unknown). Previously one anonymous caller could exhaust the shared bucket and 429 the route for everyone. The bucket is now keyed per client — the app-supplied key function, else the trusted-proxy client IP, else the authenticated principal — and when none of those can distinguish the caller the limit is skipped (fail-open) with a one-time warning rather than enforced as a poisonable global bucket. - The WebAuthn challenge check now uses the shared constant-time
crypto.timingSafeEqlhelper instead of a private byte-for-byte copy, so future hardening of the constant-time primitive reaches the ceremony verification. - Hardened the fail-closed
deny_lockedauthorization floor for Postgres dialect-portability. It
hardcoded the SQLite-only0false-literal (WHERE 0), which Postgres rejects
(argument of WHERE must be type boolean); it now uses the dialect'sconstFalse()(falseon
Postgres) — the same constant the ability/tenant composition already emits — so the fail-closed
floor is guaranteed-valid SQL on both backends. (In current code this branch is short-circuited by
authorizesbefore it reaches a statement, so no live query was affected; the fix hardens the
path against any evaluator that runs the guard directly.) - Realtime (WebSocket/SSE) delivery now enforces relationship abilities, not just the
access rule and tenant scope. A collection that was@publicfor its view rule but
visibility-narrowed by aviewability previously delivered every record to every
subscriber, bypassing the ability on the realtime channel (REST reads were unaffected).
Effective realtime visibility is now(rule) AND (ability) AND (tenant), matching the
documented guarantee and the REST list/read paths. - Record validation now rejects an over-
maxSelectrelationorselectvalue on the
element count, before running the per-element existence checks. Previously an
over-limitrelationarray still ran one existenceSELECTper submitted id under the
writer lock, so an attacker-sized array (bounded only by the request body limit) could
drive a large number of queries from input already known to be invalid. - The runtime collections API now validates
tenant_field(andttl_field): it must be a valid
identifier that names an existing field, rejected with an actionable error otherwise. Previously
a superuser could set an invalid or danglingtenant_fieldvia the admin API; because
tenancy.scopeAppliestreats an invalid identifier as "scoping does not apply", the tenant-owned
collection would then be served un-scoped — a cross-tenant row leak. The comptime
.collectionspath already enforced this; the runtime API now mirrors it, keeping the fail-open
state unreachable. zig build auditcompares pinned dependency versions against a curated in-repo advisory
table (docs/security-advisories.md); documented update process for vendored C security fixes.
Internal
- Allocator-ownership contract migration. CI now ratchets against leak-masked tests
(scripts/allocator-allowlist.txt): a test may wrapstd.testing.allocatorin an arena only
where the code under test genuinely takes aRequestArena(contract 4), and every remaining
line carries a written justification. Driving that ratchet down took the bulk of this release —
from 121 files / 889 masked tests at introduction to 44 / 299 — by giving the framework's
internals explicit ownership contracts: a function either frees all of its own scratch and
returns one caller-owned value (contract 1), or returns an owned handle with adeinit
(contract 2). Converted subsystems includerecords/collections/schema/ddl, the whole
query/stack (lexer, parser, compiler, joiner, sort, keyset, params),policy/rules/authz,
realtime/,mail/,oauth/+aead,files/,push/,provision,import,codegen/,
and the leaf libraries. This is a correctness change, not a memory or performance
improvement: every in-tree caller already passes a request/job arena that reclaimed the scratch
and still does (an arena-backed scratch arena frees no capacity on deinit), so deployed servers
are unaffected. The value is that the leak detector can now see these paths, and that a framework
consumer driving them with a general-purpose allocator no longer leaks. The consumer-visible
leaks the conversion surfaced are listed under Fixes. - New ownership primitives added along the way, usable directly by framework consumers:
schema.Collection.deinit(plusfreeFieldsOwned/freeIndexesOwnedfor a standalone
[]Field/[]Index),records.ListResult.deinit,query.Joiner.deinit,Cursor.deinit,
Guard.own/Guard.deinit,auth.Verified/Authed.deinit,tenancy.Resolution.deinit,
features_resolver.Resolved.deinit,search.Vector.deinit,mail.unsubscribe.Parts.deinit,
provision.MigrationStatus.deinit/RollbackOutcome.deinit/freeAppliedMigrations,
queue.freeClaimed,values.freeValue, and anAcquiredhandle ({ arena, collections },
mirroringstd.json.Parsed) for the codegen acquire adapters.collections.create/updatenow
return a fully-owned reload of the just-written row rather than a mixed-ownership hand-assembled
value, and recordValues returned byget/create/updateown their top-level keys, while
records.listinterns one shared key set per query (borrowed by every row, freed once via
ListResult.deinit) so the list read path adds no per-row key allocation. - Un-masking
src/codegen/(86 tests) surfaced pervasive ownership bugs in the client generators
that the masking arena had hidden:identifiers.recordNamereturned a sub-slice of an internal
allocation (freeing it was an invalid free), the sharedemit.putfformat helper leaked at every
call site, every language generator leaked all of its scratch, andgen_client.generateleaked
the whole generated buffer on its reachableerror.RpcTypeNameCollisionpath. All are fixed —
generators own their scratch internally and return a single caller-owned slice, and the
ts/dart/python/kotlintype mappers return a uniform always-owned string. Generated client
output is byte-identical (golden snapshots unchanged). This is a build-time tool, so none of
these bugs affected the shipped server. - Documented the arena-scoped ownership contract (contract 4) on
http_client.HttpResponseand
DownloadResult: the response body is a sub-slice of the fixedmax_response_bytesbuffer and
request()/download()leave their scratch on the passed allocator, so there is intentionally
nodeinitand callers must pass a request-scoped/arena allocator. - Converted the two JWT verification call sites whose claims are consumed internally
(auth.authenticate,api/auth.carrySessionCreated) from the arena-scopedpeekClaimsto the
caller-bufferpeekClaimsInto, removing an allocation from the per-request auth path. The
remaining four sites return borrowed claims and stay on the arena (safely bounded by
jwt.max_token_len). Inauthenticatethe peek is scoped to a block so the stack-borrowed
claims cannot escape into the returnedAuthed— a compiler-enforced guard rather than a prose
one. - Added
NO_SLOP.md, a Zig code-review standard for AI reviewers distilled from Andrew Kelley's
positions and the official Zig docs, and referenced it fromCLAUDE.md. Its §4 also records the
outcome of a data-oriented-design audit of the four structures it names: none is currently
high-cardinality enough for §4 to apply, so the guidance is now "do not 'fix' these without a
profile" rather than an open invitation to reflexive DoD. - Pointed the benchmark harness (
zig build bench) at the real record read paths for the first
time (it had only measured the jwt exemplar), reached through adev_mode-gatedinternalseam
inroot.zigthat folds tostruct {}in any release build, so it adds nothing to the shipped
public surface.data/queryAs-50rowsmeasures the typed row-decode path (~3 small allocations
per row);records/findById-jsonandrecords/list-json-30measure the JSON path every REST
read returns — ~3x more allocations and ~6x more bytes per record than the typed path, and ~8.7
allocs/record for a batchedlistvs ~13 for individualfindById(the per-record prepare/SQL
that N+1 gets each pay). A newharness.runArenameasures an op under a request-style arena
reset between iterations: identical allocation count but ~15x faster than raw malloc — which is
the point, since the allocation count/size distribution is the real backing-independent signal
while the raw-malloc ns is overhead the arena erases. Aquery/filter-compilebenchmark of the
SQL-injection-critical filter path is a useful negative result: ~24 allocations / ~6us, ~40x
cheaper than a record list read, confirming filter compilation is not a hotspot. - Postgres full-text search now concatenates its text-search configuration into the emitted SQL at
comptime (++) instead of formatting it with{s}. The value lands inside a single-quoted
SQL literal — an escaping contextschema.isValidIdentifierdoes not cover — so making it
configurable later now fails to compile rather than silently opening an injection, and a
comptime guard additionally rejects a quote or backslash in the literal. The emitted SQL is
byte-identical (verified by running the new test against the previous implementation). Added
alongside it, the first unit coverage for the Postgres full-text lowering:buildPostgresis
pure, but nothing asserted its emitted SQL, so the read-side lowering had been exercised only by
the live-Postgres suites (skipped in a default build). jwt.peekClaimsnow rejects a token carrying a 4th segment, matchingjwt.verify. Not a
vulnerability (verifyis authoritative and always refused such a token), but the two parsers
read the same bytes and should not disagree about what a well-formed token is.- Added
release-dart-sdk.yml, adart-client-v*-tag-triggered workflow that verifies
(dart analyze, format check, unit tests, tag/pubspec.yamlversion consistency,
dart pub publish --dry-run) and then publisheszigbase_clientto pub.dev via the
official OIDC-based automated-publishing flow. The first publish still needs one-time
owner setup on pub.dev — seeclients/dart/RELEASING.md. - Pinned
ruff==0.15.21in thepython-sdkCI job and the Python SDK release workflow's
format/lint gates (matching the codegen job), so a floatingruff>=0.8release can no longer
turn a greenmainred on a later PR without any code change — as happened when post-0.15.21
markdown code-block formatting reflowedclients/python/README.md. - The live SASLprep/SCRAM PostgreSQL tests now skip when the suite role lacks the
CREATEROLE
privilege their throwaway login-role fixtures require, instead of failing with an opaque
ExecFailedout of the setup DDL. Running the suite against a plain dev PostgreSQL whose suite
role is not a superuser no longer reports two misleading SCRAM failures; CI (whose suite role is
a superuser) still runs them. The module header documents both preconditions and how to point
ZIGBASE_PG_TEST_URLat a privileged role to run them locally. - Deduplication sweep: consolidated three byte-identical RFC 3986 percent-encoders (captcha,
Twilio SMS, OAuth token exchange) into oneurl.percentEncode; the copy-pasted JSON body
plumbing (parseBody/strField/jsonResponse) shared across the auth/OAuth handlers into
api/common.zig; the near-identicalrequest-verification/request-password-resethandlers and
thefindByEmail/findByIdentitylookup loop into a singlefindByFieldhelper (preserving the
subtle nocase / guarded-free memory semantics);records.zig's privatecoerceCloneinto the
canonicalvalues.cloneValueit was a byte-for-byte copy of; and the record create/update
file-cleanup block that had been copy-pasted into four return branches into one commit-guarded
scope guard. - Hoisted the byte-identical, language-neutral schema-query helpers that the four client emitters
(emit.zig/emit_dart.zig/emit_kotlin.zig/emit_python.zig) each kept their own copy of into
a singlesrc/codegen/schema_query.zig. The visible auth fields are now derived from the
canonicalschema.authSystemFields()(filtered to its non-hidden subset) rather than a
hand-maintained triple in each emitter, so a new non-hidden auth system field flows into every
generated SDK automatically instead of silently diverging until all four copies are edited. Pure
refactor — generated client bytes are unchanged. Also corrected the Dart/Python/Kotlin generator
module docs, which claimed the shared identifier guard is "language-neutral": it is TS-derived
(TS identifier validity + the TS typed-core reserved-name set) and applied to every language as a
conservative lowest common denominator, with the actual per-language keyword/member sanitizing
living in each emitter. records.last_errors(the validation-detail threadlocal) is cleared once consumed, so it never
outlives the per-request arena it points into.gcExpiredRecords(the TTL sweep) allocates its
per-collection scratch from an internal arena, and system migration 0010 allocates its
collection-name scratch from the run-scoped migrator arena instead ofstd.heap.page_allocator—
restoringstd.testing.allocatorleak visibility for both paths and removing ~15 lines of manual
cleanup from the latter.