zonai_schema 0.4.2
zonai_schema: ^0.4.2 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.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.