zonai_schema 0.6.1
zonai_schema: ^0.6.1 copied to clipboard
Define a Zonai backend in Dart: tables, access rules, rate limits, extensions and the wire types the server and its clients agree on.
Changelog #
0.6.1 #
SMTP_USERNAME and SMTP_PASSWORD can be set at runtime. The SMTP
credentials were compiled into the config worker like every other .env
value, so strings on a release bundle recovered them. When AppConfig.email
is set, AppConfig.withSecretsFromEnvironment now reads both from the process
environment, and they win over the compiled values, the same way JWT_SECRET
does. An empty value is ignored. Without an email config they change
nothing. Host, port, sender and ssl are still compile-time only.
0.6.0 #
Re-run zonai compile after upgrading. Workers now send and parse new
vocabulary (a rule's viewScope, count_requires_view_scope,
precondition_failed, ServerManagedColumnWriteException), and a stale
.zonai/executables/*.exe keeps the old code. Serve with zonai CLI 0.10.0 or
later, which requires this version.
Anonymous auth. Mix AnonymousAuth into an auth table to create accounts
before their owner gives an address. The row's email stays NULL until the
account is upgraded, so the column must be a NullableEmailColumn
($.email<String?>(...)); anonymousSignUpColumns names the app columns an
anonymous sign-up may set. Jwt.isAnonymous tells the two kinds of session
apart. See the new "Anonymous auth" docs page.
Breaking: tables are now checked when they register. A nullable email column
on a table without AnonymousAuth is refused at boot, because NULL there
means "anonymous account". So is AnonymousAuth together with AsAdmin.
Auth emails are stored lowercased. authTable declares .lowercase() on
the email column, and your next zonai db migrate generate emits a migration
that lowercases existing rows. Ann@x.com and ann@x.com are one account,
matching how addresses were already compared everywhere else. See "Before you
apply that migration" below if a table might hold both.
A malformed request body is a 400 invalid_body, not a 500. A body the
server could not read -- a missing field, a wrong type, a where that is not
a where -- failed inside its fromJson with an ArgumentError or a
TypeError from a cast, and both reached the client as an internal error.
Every route body now parses through parseBody, which turns any of those
into an InvalidBodyException, and the server answers it with 400 and the
structured envelope {"error": {"code": "invalid_body", "message": ...}}. The
message names the body and the field. InvalidBodyException is an
ArgumentError, so code that already catches one while parsing a body keeps
working.
BaseRowRules.viewScope: say which rows a caller may see as a filter. The
server ANDs it into every read (GET /db, /db/list, /db/count and the
three streams), so a row outside it is invisible rather than a 403: a list
returns the caller's own rows, and a read of someone else's row is a 404.
canView still runs on every row inside it. Additive: the default is null,
which is today's behaviour. TableRulesResponse gains an optional scope;
a worker built before it sends none and is read as no scope.
Counts no longer reveal rows the caller cannot view. /db/count, the
total of /db/list and the count stream checked only the table rule, so on
a table whose rows are private to their owner, anyone the table rule admitted
could count every owner's rows under any filter. A count is now a single
COUNT over rows the caller may see, which needs one of: a viewScope, row
rules with requiresPerRowCheck => false, or an admin caller.
Behaviour change. For anyone else -- per-row checks, no scope, not an admin:
/db/countand the count stream refuse with400 count_requires_view_scope, naming the fix: declareviewScope./db/listreturns its page withtotalomitted.Paginated.totalis nowint?, which is a breaking change for code that reads it asint.- If your
AuthRowRuleswidenscanView, widenviewScopeto match (returnnull, or your own filter). The default below scopes a signed-in user to their own row whatevercanViewsays, so otherwise lists narrow to the caller's own row and aGETof another user's row answers404, with no error to say why. Row rules withrequiresPerRowCheck => falseare never scoped: they have already declared every row visible. - An update refused on its
expectreports back incurrentonly the rows a read by the caller could return, so rows outside the caller's scope are never included.
AuthRowRules has a default viewScope. A signed-in user is scoped to
their own row, and an admin or an anonymous caller gets none, so a user table
counts, and lists with a total, in one statement out of the box.
$.revision(...): a revision counter the server maintains. 0 on create
and one higher on every update, so a client can update only the version it
read (with UpdateBody.expect, where available). INTEGER NOT NULL DEFAULT 0, so adding it to an existing table migrates the rows to 0. A create or
update that sets it is refused with a 400 (ServerManagedColumnWriteException)
rather than silently ignored. Shown read-only in the dashboard and treated as
server-set by the generated client. Additive. Serve it with a zonai CLI from
the same release: an older one doesn't know
ServerManagedColumnWriteException, and answers the refused write with a 500
instead of a 400.
Rules and hooks see a row's stored created_at / updated_at (#40). Rows
handed to canView, canUpdate(before, after), canDelete, custom-operation
rules, and the update, delete, after-create and auth hooks were rebuilt with the
create-time safeCreate, which stamped created_at with the moment the worker
decoded the row and set a nullable updated_at to null. A rule like
"editable for 24h after creation" always passed, and one comparing
before.createdAt with after.createdAt failed whenever the clock ticked in
between. safeCreate(data, stored: true) now keeps stored timestamps; creates
and beforeCreate still stamp them, and never trust a client-sent value.
A stored NULL in a nullable created_at or updated_at stays null too,
rather than becoming now(): a row written before the column existed has no
creation time to report.
A user can no longer verify themselves or change their own email. The
default AuthRowRules.canUpdate allowed the row's owner any change. An app
that opens canUpdate at the table level, usually for profile edits, let a
user set their own is_verified (skipping the email verification it
records) or change email to an address they never proved. The owner is now
refused a change to either column; everything else on their row stays
editable, and an admin is unaffected.
Behaviour change. If your app relies on users PATCHing their own email,
override canUpdate in your AuthRowRules. A hook that sets is_verified
through mutate under the user's token is refused too.
Auth tables now get the unique email index the docs always claimed.
authTable meant to declare <table>.email_unique for every PasswordAuth
table, and the docs say the column is TEXT UNIQUE, but the check tested the
table's type rather than the table, never matched, and the index was never
declared. It is now declared on every auth table with an email: password,
OTP, magic link and anonymous alike. Without it, two sign-ups racing for one
address on a passwordless table both inserted. An anonymous row's email is
NULL until it upgrades, and NULLs never collide in a unique index, so any
number of anonymous rows still coexist. Your next zonai db migrate generate
emits CREATE UNIQUE INDEX "<table>.email_unique".
Before you apply that migration, look for addresses that differ only by
case. The same release lowercases stored emails, so Ann@x.com and
ann@x.com become one address, and a table holding both will fail the
migration:
SELECT lower(email), count(*) FROM users GROUP BY lower(email) HAVING count(*) > 1;
Resolve any rows it returns, then migrate.
Two kinds of table are unaffected:
- A table that also mixes in
OAuth. OAuth provisions a second row for an address it declines to link (OAuthLinking.never, orbyVerifiedEmailwith an unverified email), so there an email is not unique by design. - A table that passes its own
extracallback toauthTable. That callback replaces the default indexes, as before.
UpdateBody.expect: an update that only applies if the row is still what
you read. A Where every target row must meet; if any does not, the server
writes nothing and answers 412 with code precondition_failed and the
failing rows (as they are now, and only those the caller may view) in
details.current. Distinct from the 404 for a row that is gone. Additive:
omitted (or JSON null), an update behaves exactly as before; any other
non-object value is a 400 (invalid_expect), never read as "no
precondition". A server that predates this field ignores it, so a client
relying on it must know its server.
Behaviour change, with or without expect: an update's beforeUpdate
hook now runs after the password-column check. A request that is refused
with a 403 for writing a password column no longer reaches the hook, so a
hook that queued a write or sent an email for such a request no longer does.
0.5.0 #
One server, development and production iOS builds. An APNs token belongs
to one environment, decided by how the build was signed, and until now a
server spoke to exactly one — so a phone running a build from Xcode or
flutter run could not be reached by the server the TestFlight fleet uses.
Worse, its token came back BadDeviceToken, was read as a dead device, and
was pruned on the first send.
DevicePlatform.iosSandbox, stored asios-sandbox(ios_sandboxandiosSandboxare accepted too). A row carrying it is sent toapi.sandbox.push.apple.comwith the same auth key, whateverApnsConfig.useSandboxsays;useSandboxis now the default host for plainiosrows. With noPushConfig.apnssuch a row is reported as unroutable — never sent through FCM, never pruned.DevicePlatform.wireName, the stored value.toJson()returns it, and it differs fromnameforiosSandbox.ApnsConfig.sandbox,ApnsConfig.productionHost/sandboxHost, andPushConfig.withApns.
Breaking for exhaustive switches. Code that switches over
DevicePlatform without a default case must handle iosSandbox. Nothing
stored changes: existing ios and android rows route exactly as before.
0.4.2 #
Additive. No export was removed or renamed, and no behaviour that an existing app relies on changed.
API tokens. A credential for something that cannot sign in — a backup
script, a CI job, a partner integration. Issued from the CLI or
POST /admin/tokens, with no sign-in involved and optionally no expiry.
ApiTokenSecret,ApiTokenId,ApiTokenJwt,ApiTokenScope, and theApiTokenBodypayload for the create route.- The credential is an opaque
zonai_pat_…string, not a JWT. Only its SHA-256 is stored, so the plaintext exists exactly twice — in the process that generated it, and wherever the operator pasted it. Nothing can recover it from a row, which is what makes "shown once, at creation" a fact rather than a promise. ApiTokenJwtis aJwtbecauseJwtis the currency of the whole authorization layer — rules take one, row rules filter on one, and the worker IPC boundary rebuilds one from JSON. An API-token identity that were not aJwtwould need a parallel path through all of it.- A signed JWT carrying the API-token flag is refused as a bearer token,
with the same "rotate the secret" log the
CRONandPROVISIONINGsentinels get. Accepting one would let anyone who can sign a token mint themselves an unscoped admin key. The flag exists for the worker round trip and nothing else. ApiTokenScopeis a hard gate evaluated before rules, not an input to them: an out-of-scope(table, operation)is refused however permissive that table's rules are, and rules then run as usual and may deny further. Widening is only ever possible by editing the row."*"for--operationsis stored as"*", not expanded at creation. An expanded list would silently freeze the token to the operations that existed on the day it was minted.SecretColumn(src/column_types/secret_column.dart), the column typeis SecretTransformerchecks see.
Forced password reset. _password_reset_requirements, one
(table, user_id) marker meaning "this account must choose a new password
before a password sign-in will mint a session", with its rules and operations.
- Durable on purpose. The reset ticket a gated sign-in hands back is an
ordinary
_auth_challengesrow with a short expiry; this row is the requirement and has to outlive every ticket issued against it. A requirement that expired on its own would silently restore the old password — the exact failure the feature exists to prevent.
beforeSignUp. An AuthExtension hook that can decline a registration
before any row exists, answering 403 with the app's own reason.
SignUpCandidateandSignUpDeclinedException.- The candidate is deliberately not the typed row. The row does not exist
yet, so building one means
safeCreateinventing a value for every column the body did not supply — and it only knows how to invent for five transformers.is_verified, on everyAuthTable, is not one, sodecode(null)threw inside the extension worker and an ordinary allowed sign-up died before the hook was entered. WideningsafeCreatewould move that failure rather than remove it. - It runs on password, OTP and magic-link sign-up, at request time. On OTP and magic link it runs again at verify, because the account is created there and a challenge is valid for ten minutes — so the hook is at least once on those two flows and a body with a side effect must tolerate repeating.
- It does not run for a first-seen OAuth or external-IdP identity; those
decline by returning from
onExternalAuthFirstSeenwithout inserting.
Smaller things.
Jwt.isApiTokenPayload, andJwt.maybeFromJsonnow reconstructs anApiTokenJwtwhen it sees that payload.SignUpAuthBody.fromJsonvalidates rather than casts. A body whoseemail,passwordorobjecthad the wrong type produced a500from a failed cast; it now raisesArgumentErrorand the route answers400. Thetypeswitch no longer casts either, so a missing or non-stringtypeis a400rather than a crash.maintenance_actions.dartdocuments_api_tokensand_password_reset_requirementsas purge targets — and flags the second as the one entry whose purge is a weakening: every other table here becomes more restrictive when emptied, while this one quietly releases every account an operator forced to reset.internal_db_artifacts.dartregisters the two new internal tables.
0.4.1 #
Purely additive. Nothing was removed, renamed, or changed in behaviour.
-
Where.isNull(column)andWhere.isNotNull(column). Two const factories that redirect to the existingNullandNotNullclauses, so those clauses stay constructible without a caller ever naming those two classes. That matters becausezonai_client's barrel cannot export them: a library importingNullshadowsdart:core'sNull, since Dart resolves an explicit import ahead of the implicitdart:coreone. Confirmed by compiling rather than by reasoning about it — in a file importingpackage:zonai_schema/payloads.dart,Null x; print(x);fails analysis withnot_assigned_potentially_non_nullable_local_variable, which is legal againstdart:core's nullableNull, so the error is the shadowing itself. Additive:NullandNotNullare unchanged and still exported from this package. -
Email.preheader. The inbox preview line a mail client shows next to the subject. Rendered into the template as{{preheader}}inside a hidden block, so it never paints in the body; without one the client scrapes whatever visible text comes first, which is usually the greeting. Each built-in auth email (SendOtpEmail,SendMagicLinkEmail,SendVerifyEmailEmail,SendResetPasswordEmail,SendAdminInviteEmail) now ships a default. The OTP one deliberately leaves the code out: the preheader is what renders on a locked phone. -
PushPermanentlyRejected.detail. The provider's own words, carried as far as a human.PushRejectionReasonhas two values because two is all the fan-out needs, but APNs answersUnregisteredfor an uninstalled app andBadDeviceTokenfor a valid token issued by the other environment — both prune correctly, only the second is a deployment mistake somebody can fix. Nullable and never load-bearing: nothing branches on it. -
Push test-send payloads (
src/payloads/push_test_send.dart) and dashboard metrics payloads (src/payloads/dashboard_metrics.dart), backing the dashboard's test-send, push-queue and sessions panels.payloads.dartalso now exportspush_outcome.dartandDevicePlatform, which the dashboard needs to name and previously could not reach through that barrel.
0.4.0 #
Re-run
zonai compileafter upgrading, and use it with Zonai CLI v0.8.0 or newer. This release adds message vocabulary a worker dispatches, and your.zonai/executables/*.exekeep the old copy until they are rebuilt. The.protocolstamp will not catch it: that records the IPC framing version, not what the messages contain.
Push notifications. push(...) is now available inside rules, operations
and crons, and hands the fan-out to a checkpointed job table rather than doing it
inline — so a large send survives a restart and cannot block the request that
started it.
PushMessage,PushJobId, and thePushOutcomeresult types (PushDelivered,PushPermanentlyRejectedwith aPushRejectionReason,PushTransientlyFailed).PushConfigandApnsConfigonAppConfig.push, withPushCredentials/ApnsCredentialsin file or inline form, andOnPermanentRejectiondeciding what happens to a token Apple or Google has rejected for good.- A
deviceTokencolumn type (ColumnShapeKind.deviceToken), and a declared platform column (DevicePlatform) that routes each row to APNs or FCM — so iOS is reachable without Firebase in the middle. - The internal
_push_jobstable and its drain/cleanup crons.
OAuth, and admin invites. Sign-in through a provider is now first-class. Add the mixin and list your providers:
class Users extends AuthTable with PasswordAuth, OAuth, AsAdmin {
@override
List<OAuthProvider> get oauthProviders => [OAuthProvider.google(...)];
}
OAuthProvider, with built-in factories named byOAuthProviderKind(Google, Apple, GitHub, Microsoft, Facebook, Discord, GitLab, LinkedIn) andOAuthProvider.custom(...)for anything else.OAuthEndpoints,OAuthClaimMap,OAuthLinking,OAuthBrand,OAuthIconandOAuthProviderPublic— the last being what the dashboard is given, so a client secret never leaves the server.oauth_bodyandadmin_invite_bodypayloads, the internal_oauth_identitiesand_auth_challengestables and their rules, andAuthTable.supportsOAuth.
Breaking: two enums gained values. Before 1.0 that is breaking, and it is
also exactly why kMinSchemaVersion moved to 0.4.0 — both are decoded with
Enum.values.byName, which throws on a name it does not have, so a v0.8.0 CLI
sending one to an older schema fails at the worker rather than degrading.
AuthTypeis now{ password, otp, magicLink, oauth }.RateLimitOperationgainedadminInvite,oauthStartandoauthCallback.
An exhaustive switch over either in your own code will stop compiling until
you handle the new values.
Behaviour changes, no API change:
- The default JWT lifetime is now 24 hours, was 14 days. Zonai has no
refresh-token flow, so the lifetime is the idle timeout — the old default
meant a session could not be withdrawn for a fortnight. Set
jwtExpiresInonAppConfig, or per auth table, to choose your own. - Admin auth is throttled far below the generic limit:
adminAuthenticatePolicyandadminSignInPolicynow default toRateLimitPolicy.adminAuth(10 requests / 15 minutes) instead ofdefaultPolicy(100 / minute). The only honest traffic these endpoints see is a human typing a password. AppConfig.validaterejects weak and reused secrets: a short or placeholderjwtSecret/passwordSecret, the two being equal, and either appearing in its ownprevious*Secretslist. A project that was relying on a throwaway secret will now fail to start, which is the point.whereclauses are checked against the table: filtering on a column that is not on the table, or on a secret column such as a password, is refused rather than interpolated. This applies toupdateanddeletetoo.
Also added: storage and maintenance payloads behind the dashboard's
Maintenance screen (storage_metrics.dart, maintenance_actions.dart,
formatBytes), richer SchemaException variants, extension request/response
vocabulary, and detection of custom-operation name collisions.
0.3.1 #
In and NotIn where-clauses threw ArgumentError when sent between a
worker and its host. Anything that put one on the wire failed before the
request was dispatched — including any rule, operation or cron that calls
get.* with an In/NotIn filter.
Re-run
zonai compileafter upgrading. This changes code a worker dispatches, and your.zonai/executables/*.exekeep the old copy until they are rebuilt. The.protocolstamp will not catch it: that records the IPC framing version, not what the messages contain.
The cause: serializeWhereValues ended in .cast<Object>(), which returns a
CastList rather than a plain List. Zonai has two worker transports — a
process worker receives the message encoded as bytes, where any List works,
and an isolate worker receives the object graph itself. An isolate message
may only contain primitives plus plain List/Map instances, so a
CastList is rejected as "a regular instance". Released binaries always use
the isolate transport, so this only ever failed there.
Only In and NotIn were affected; they are the only clauses whose
serializer builds a collection.
0.3.0 #
Scheduled cron jobs could not write to the database at all. Every
mutate.create / mutate.update / mutate.delete queued from a scheduled
firing was silently discarded — no error, no entry in _cron_jobs.error, at
any row count. Manually-triggered runs were unaffected, which is why this
survived so long. If you have a scheduled job whose writes never appeared,
this was it, and it needs no change on your side beyond upgrading.
The cause: runWithParent bound its request-scoped providers with
includeIfAbsent, which scoped_deps skips when the zone chain already
defines the ref. A scheduled cron always nests — the scheduler starts inside
runWithParent(StartCronsRequest) and a Dart timer fires in the zone that
created it — so every firing tagged its mutations with the startup request's
id. The host keys pending mutations by parent id and flushes on the matching
response; CronsStarted was answered once, at boot, and everything filed
afterwards was parked forever.
Found via a production deployment whose _log table reached 4.6M rows and
filled a 1GB volume while its retention cron reported success 13 times.
Breaking #
-
revali_coremoves to^3.0.0(was^2.0.0). If your project pinsrevali_core2.x, or arevali_router4.x that requires it, resolution will fail until you move up. -
Re-run
zonai compileafter upgrading. This release changes the cron IPC vocabulary, and the.protocolstamp will not catch a stale worker: that stamp records the IPC framing version, not the message vocabulary. A stale.zonai/executables/*.exekeeps the old code.
Added #
-
mutate.purge— a bulkDELETEreturning the number of rows removed. Skips the read-back, the per-row rules dispatch and the extension hooks thatmutate.deleteperforms, none of which a retention sweep over millions of rows can survive. Restricted to the framework's own tables and to admin identities, enforced host-side rather than trusted from the caller. All five internal retention crons now use it and report real counts. -
ReclaimLogSpaceRequest/ReclaimLogSpaceResponse— a cron-to-host RPC asking for the log database to be rewritten when enough of it is dead space and the volume has room._cleanup_logscalls it after purging, and treats a host that does not recognise it as a degradation rather than a failure, so a newer schema against an older CLI still runs retention.
Fixed #
- The internal retention crons (
_cleanup_logs,_delete_expired_jwts,_delete_old_rate_limits,_cleanup_auth_challenges,_cleanup_cron_entries) now delete in bounded chunks and log what they actually removed rather than that they queued something.
0.2.0 #
Custom operations work for the first time. On 0.1.1 every request to
PATCH /db/custom/:operation and /db/custom/:operation/many returned 500,
crashing in the rate-limits worker before authorization ran (#27).
Breaking #
-
TableRateLimits.customPolicynow takesString?instead ofString.Overrides declared as
customPolicy(String operation)no longer compile — Dart does not allow an override to narrow a parameter type. Widen yours toString?and handle the null case:@override Future<RateLimitPolicy?> customPolicy(String? operation) async { return switch (operation) { 'fill' => const RateLimitPolicy(maxRequests: 20, window: Duration(minutes: 1)), // The host could not validate the name, so every custom operation on // this table shares one counter. null => const RateLimitPolicy(maxRequests: 60, window: Duration(minutes: 1)), _ => .defaultPolicy, }; }The compile error is the intended signal: there is no working behavior on 0.1.1 to preserve, since the path 500s.
Fixed #
DbRateLimitsno longer assertsrequest.customOperationnon-null. The host passesnullon purpose whenever it cannot validate a caller-supplied operation name in-process — rules running in a worker rather than linked into the binary — so that an unvalidated name never becomes a rate-limit bucket dimension a caller could rotate to land on a fresh counter. The handler threw on exactly that value, so the documented fallback to one coarse per-table bucket never degraded; it crashed.nullnow means what the caller always said it meant.
Upgrading #
Bump the dependency and rebuild your workers:
dart pub upgrade zonai_schema
zonai compile
The rate-limit check runs in db_rate_limit.exe, which is compiled from your
project against your resolved zonai_schema. A stale executable keeps the old
code, and the .protocol stamp will not catch it — that stamp records the IPC
framing version, which did not change here.
A note on the CLI you're running #
Custom operations need this release, but the CLI has to catch up too.
zonai CLI 0.6.2 and earlier check the resolved zonai_schema against the
CLI's own version, on the assumption that the two ship in lockstep. They
don't — so those CLIs refuse to start on any 0.x schema, including 0.1.1
and this release, with "zonai_schema … is too far behind this CLI". Until
the next CLI release lands, pass --no-schema-version-check.
The next CLI replaces that comparison with a declared floor, and this version is that floor.
0.1.1 #
Released 2026-08-10. No changelog was kept before 0.2.0; see the git history
under libs/zonai_schema/ for what changed.
0.1.0 #
Initial release.