zonai_schema 0.6.1 copy "zonai_schema: ^0.6.1" to clipboard
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/count and the count stream refuse with 400 count_requires_view_scope, naming the fix: declare viewScope.
  • /db/list returns its page with total omitted. Paginated.total is now int?, which is a breaking change for code that reads it as int.
  • If your AuthRowRules widens canView, widen viewScope to match (return null, or your own filter). The default below scopes a signed-in user to their own row whatever canView says, so otherwise lists narrow to the caller's own row and a GET of another user's row answers 404, with no error to say why. Row rules with requiresPerRowCheck => false are never scoped: they have already declared every row visible.
  • An update refused on its expect reports back in current only 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, or byVerifiedEmail with an unverified email), so there an email is not unique by design.
  • A table that passes its own extra callback to authTable. 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 as ios-sandbox (ios_sandbox and iosSandbox are accepted too). A row carrying it is sent to api.sandbox.push.apple.com with the same auth key, whatever ApnsConfig.useSandbox says; useSandbox is now the default host for plain ios rows. With no PushConfig.apns such a row is reported as unroutable — never sent through FCM, never pruned.
  • DevicePlatform.wireName, the stored value. toJson() returns it, and it differs from name for iosSandbox.
  • ApnsConfig.sandbox, ApnsConfig.productionHost/sandboxHost, and PushConfig.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 the ApiTokenBody payload 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.
  • ApiTokenJwt is a Jwt because Jwt is 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 a Jwt would 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 CRON and PROVISIONING sentinels 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.
  • ApiTokenScope is 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 --operations is 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 type is SecretTransformer checks 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_challenges row 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.

  • SignUpCandidate and SignUpDeclinedException.
  • The candidate is deliberately not the typed row. The row does not exist yet, so building one means safeCreate inventing a value for every column the body did not supply — and it only knows how to invent for five transformers. is_verified, on every AuthTable, is not one, so decode(null) threw inside the extension worker and an ordinary allowed sign-up died before the hook was entered. Widening safeCreate would 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 onExternalAuthFirstSeen without inserting.

Smaller things.

  • Jwt.isApiTokenPayload, and Jwt.maybeFromJson now reconstructs an ApiTokenJwt when it sees that payload.
  • SignUpAuthBody.fromJson validates rather than casts. A body whose email, password or object had the wrong type produced a 500 from a failed cast; it now raises ArgumentError and the route answers 400. The type switch no longer casts either, so a missing or non-string type is a 400 rather than a crash.
  • maintenance_actions.dart documents _api_tokens and _password_reset_requirements as 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.dart registers the two new internal tables.

0.4.1 #

Purely additive. Nothing was removed, renamed, or changed in behaviour.

  • Where.isNull(column) and Where.isNotNull(column). Two const factories that redirect to the existing Null and NotNull clauses, so those clauses stay constructible without a caller ever naming those two classes. That matters because zonai_client's barrel cannot export them: a library importing Null shadows dart:core's Null, since Dart resolves an explicit import ahead of the implicit dart:core one. Confirmed by compiling rather than by reasoning about it — in a file importing package:zonai_schema/payloads.dart, Null x; print(x); fails analysis with not_assigned_potentially_non_nullable_local_variable, which is legal against dart:core's nullable Null, so the error is the shadowing itself. Additive: Null and NotNull are 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. PushRejectionReason has two values because two is all the fan-out needs, but APNs answers Unregistered for an uninstalled app and BadDeviceToken for 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.dart also now exports push_outcome.dart and DevicePlatform, which the dashboard needs to name and previously could not reach through that barrel.

0.4.0 #

Re-run zonai compile after upgrading, and use it with Zonai CLI v0.8.0 or newer. This release adds message vocabulary a worker dispatches, and your .zonai/executables/*.exe keep the old copy until they are rebuilt. The .protocol stamp 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 the PushOutcome result types (PushDelivered, PushPermanentlyRejected with a PushRejectionReason, PushTransientlyFailed).
  • PushConfig and ApnsConfig on AppConfig.push, with PushCredentials / ApnsCredentials in file or inline form, and OnPermanentRejection deciding what happens to a token Apple or Google has rejected for good.
  • A deviceToken column 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_jobs table 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 by OAuthProviderKind (Google, Apple, GitHub, Microsoft, Facebook, Discord, GitLab, LinkedIn) and OAuthProvider.custom(...) for anything else.
  • OAuthEndpoints, OAuthClaimMap, OAuthLinking, OAuthBrand, OAuthIcon and OAuthProviderPublic — the last being what the dashboard is given, so a client secret never leaves the server.
  • oauth_body and admin_invite_body payloads, the internal _oauth_identities and _auth_challenges tables and their rules, and AuthTable.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.

  • AuthType is now { password, otp, magicLink, oauth }.
  • RateLimitOperation gained adminInvite, oauthStart and oauthCallback.

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 jwtExpiresIn on AppConfig, or per auth table, to choose your own.
  • Admin auth is throttled far below the generic limit: adminAuthenticatePolicy and adminSignInPolicy now default to RateLimitPolicy.adminAuth (10 requests / 15 minutes) instead of defaultPolicy (100 / minute). The only honest traffic these endpoints see is a human typing a password.
  • AppConfig.validate rejects weak and reused secrets: a short or placeholder jwtSecret / passwordSecret, the two being equal, and either appearing in its own previous*Secrets list. A project that was relying on a throwaway secret will now fail to start, which is the point.
  • where clauses 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 to update and delete too.

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 compile after upgrading. This changes code a worker dispatches, and your .zonai/executables/*.exe keep the old copy until they are rebuilt. The .protocol stamp 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_core moves to ^3.0.0 (was ^2.0.0). If your project pins revali_core 2.x, or a revali_router 4.x that requires it, resolution will fail until you move up.

  • Re-run zonai compile after upgrading. This release changes the cron IPC vocabulary, and the .protocol stamp will not catch a stale worker: that stamp records the IPC framing version, not the message vocabulary. A stale .zonai/executables/*.exe keeps the old code.

Added #

  • mutate.purge — a bulk DELETE returning the number of rows removed. Skips the read-back, the per-row rules dispatch and the extension hooks that mutate.delete performs, 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_logs calls 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.customPolicy now takes String? instead of String.

    Overrides declared as customPolicy(String operation) no longer compile — Dart does not allow an override to narrow a parameter type. Widen yours to String? 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 #

  • DbRateLimits no longer asserts request.customOperation non-null. The host passes null on 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. null now 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.

0
likes
50
points
811
downloads

Documentation

Documentation

Publisher

verified publisherzonai.dev

Weekly Downloads

Define a Zonai backend in Dart: tables, access rules, rate limits, extensions and the wire types the server and its clients agree on.

Homepage
Repository (GitHub)
View/report issues
Contributing

Topics

#backend #sqlite #schema #orm #server

License

MIT (license)

Dependencies

clock, cron, crypto, meta, msgpack_dart, revali_core, scoped_deps

More

Packages that depend on zonai_schema