Skip to content

Changelog ​

User-visible changes in each production release of the Primitive platform: new capabilities, additions and changes to the client APIs and the CLI, and fixes that change behavior you can observe. Internal work — refactoring, infrastructure, dependency bumps — is not listed. Entries are newest first; each date is a production release. An ## Unreleased section, when present, lists changes already merged since the last production release; it is date-stamped when that release ships.

2026-09-30 ​

New ​

  • Prompts: decisions models. A kind = "decisions" prompt runs OpenRouter's decisions endpoint (TypeSafe System One) inside the prompt system: a [configs.decisions] block names a state and typed questions (category, boolean and numeric), the answers come back typed with their probabilities and a confidence, ctx.prompts.run returns them as parsed with metrics.cost, and the admin routes, both test runners (expectedJsonSubset) and config push / pull / diff of a kind file carry it. The Swift client's ExecutePromptResult.Metrics.cost decodes a decisions execution.
  • Error analytics group server-function failures. A failed server-function invocation or task run writes a failure event fingerprinted and titled by its function key, so errors.groups (CLI analytics errors groups --source function_invocation|function_run) reports it beside workflow, step and integration failures; a completed, CPU-yield or reset outcome writes none.
  • Large documents: a refused write is reported the same way on both clients. Every local write the offline window refuses raises document:write-refused (payload DocumentWriteRefusedEvent: documentId, model, recordId, error) — on JavaScript through the client and through a js-bao format2OnWriteRefused listener, on Swift as an event delivered before the throw reaches the caller — and, wherever the call can throw, still throws that same DOCUMENT_OFFLINE_WINDOW_EXPIRED error, now with details.
  • Large documents: a bulk load's write cost per record is recorded. Every ingest tick reports the rows whose counters it committed as DOCUMENT_WRITE_COST analytics rows (size band, statements, rows read and written, with countersKnown saying whether the host could count), and the session ledger counts them once.
  • Admin CLI: create a collection on behalf of a user. primitive collections create --owner <user id or email> stamps that user as the collection's owner (both system groups, both group permissions and the writer enrolment), and an admin-token POST …/collections may name the owner the same way.
  • Collections: a document's collection listing pages. collections.listCollectionsForDocument (and GET /documents/{documentId}/collections) accept limit and cursor and answer the standard { items, hasMore, nextCursor } envelope; a server function walking it sees every collection.
  • Large documents: snapshot on demand. POST /documents/{documentId}/snapshots (CLI: primitive documents snapshots build) seals the open epoch and names the build a snapshots get then describes; an empty epoch answers 200 and seals nothing; a request inside the floor is 429 SNAPSHOT_TOO_SOON; an ordinary document is the typed 400.
  • JavaScript client: documents.create({ documentFormat: 2 }) creates a large document (and createWithAlias answers its format); omitted or 1 creates an ordinary document.
  • Large documents: a bulk load's CPU by stage. Every ingest tick carries per-stage SQL counters split by statement target, and the session view sums them, so where a hosted load spends its time is measured rather than read as 0.
  • CLI: config push reports a var changed only on the server as drift. DRIFT var: <KEY> under "Server drift — not pushed", nothing written, exit 0; --only var/<key> scopes it and --force overwrites with the local value — the same rule every other type already followed.
  • CLI: --json on the destructive collection and membership verbs. collections delete, collections unshare, documents remove and members remove take --json beside --yes and print one object on stdout ({ success, … }), with the stderr success line suppressed; a failure exits 1 with an empty stdout.
  • Admin CLI: a document's tags. The admin per-user document inventory reports each document's tags (present when it has at least one), and documents list shows them in its table and under --json; documents get prints a Tags line.
  • JavaScript client: a model can be registered on a running client. client.registerModel(...) (and registerModel on a running js-bao instance) adds a model after initialization: the mapping resolves on an already-connected document, a record saves against a legacy document connected before the model existed, pre-existing records are answered, and a format-2 document's derived query tables are hydrated for it.
  • Large documents: a bulk-load session reports where its time went. The session view (ingests.get, GET …/ingest/{sessionId}, primitive documents ingest --wait) carries per-state windows, per-tick totals and intervals, rawBytes and throughput, and the CLI renders them — so a slow load can be diagnosed.
  • Every API failure carries a machine-readable code. Every 4xx/5xx response with a body, on both /app/{appId}/api/* and /admin/api/*, is now JSON carrying a non-empty code your app can branch and localize on: the handler's own code where it has one (DOC_ACCESS_DENIED, WORKFLOW_ARCHIVED, VALIDATION_FAILED, …), otherwise a status-derived default (NOT_FOUND, ACCESS_DENIED, UNAUTHENTICATED, METHOD_NOT_ALLOWED, INTERNAL_ERROR, …). The error text beside it is written for a developer reading a log and may change without notice — choose your own copy by code. The generic 409 default is STATE_CONFLICT, deliberately distinct from the optimistic-concurrency CONFLICT that carries serverModifiedAt/expectedModifiedAt. A body that already carried errorCode keeps it and now carries the same value under code too, and no existing field on any body is removed or retyped. code is absent only when the response did not come from the platform at all — an older server, or an intermediary's HTML 502. JavaScript client: JsBaoApiError.code is populated on every failing path, including the raw-body path behind blob downloads and the terminal error after a refused token refresh (which keeps its Invalid credentials message and now also carries the original 401's body and code). Swift client: the same, as HttpError.serverCode. CLI: ApiError.code is populated for the blob upload and download methods, which previously surfaced an uncoded error built from statusText or the raw body. See the new Error Handling guide.
  • Locks: a caller can re-take its own lease. Name an owner when you acquire — JavaScript tryAcquire(key, { ttlMs, owner }) / acquire(key, { ttlMs, timeoutMs, owner }), Swift tryAcquire(key:ttl:owner:) / acquire(key:ttl:timeout:owner:), and ctx.api.locks.tryAcquire({ body: { key, ttlMs, owner } }) from a server function — a string of your choosing up to 256 characters. Presenting the owner that already holds the key, from the same caller and the same kind of caller, succeeds with a fresh handle and lease and fences the previous handle (release on it answers not_holder, renew answers lease_lost); a different owner, or none, is refused as before, and a hold made without an owner is never re-entered. status, the contention answer and the admin list carry owner. CLI: locks acquire --owner <owner>; locks status prints Owner, locks list gains an OWNER column, and the --json shapes carry it.
  • Server functions: ctx.runId, ctx.sliceId and ctx.trigger.runKey. A function reads the run it belongs to (null for an invoke) and the slice it is running in (null under the request runtime; the same id primitive functions runs and the status route's slice.sliceId report), so a task run can name itself as a lock's owner and tell a resume from a first pass. ctx.trigger.runKey carries the key a start was coalesced by on the http, function and manual arms, or null.
  • Server functions: run error codes. A failed run carries a code beside its message — run.errorCode on the row and failure.details.code on the status block both clients read — from a closed set: FUNCTION_THREW, FUNCTION_NO_HANDLER, FUNCTION_TIMEOUT, FUNCTION_INPUT_INVALID, FUNCTION_OUTPUT_INVALID, OUTPUT_SCHEMA_VIOLATION, OUTPUT_NOT_SERIALIZABLE, OUTPUT_TOO_LARGE, FUNCTION_DELETED, FUNCTION_RUNTIME_REFUSED, and six ENGINE_* codes (ENGINE_ISOLATE_EVICTED, ENGINE_CODE_UPDATED, ENGINE_STORAGE_ERROR, ENGINE_INTERNAL_ERROR, ENGINE_SLICE_DEADLINE, ENGINE_INSTANCE_LOST) for failures nothing in your code caused. An engine failure keeps the engine's own text as its message, so branch on the code, not the message. CLI: functions logs --run prints ended in the engine — no handler output (or logs unavailable / output suppressed) for an attempt that ended in the engine before the handler printed anything.
  • Swift client: large documents. A document created with CreateDocumentOptions(title:, documentFormat: 2) (or primitive documents create --large) opens on iOS and macOS through the same documents.open, model and query facade as an ordinary one, and DocumentInfo.documentFormat says which kind a document is. It needs the client's default on-disk store (storageConfig: .sqlite(directory:)); on .memory the open throws format2StorageUnavailable. The client reports a base load through DocumentSnapshotLoadEvent (document:snapshot-load: started, progress, model, loaded). A model with members in both an ordinary and a large document scopes its reads with QueryOptions(documents:) or is refused with format2QueryScope; a server that refuses the client's formats fails the open with clientUpgradeRequired and is not reconnected to; the document follows the room's epoch seals in place, with no reload and no download (the YDocument handle is replaced at each seal, so read records through the model facade), and reloads from the newest base only when the chain cannot be trusted or a replay would drop a delete (format2ReloadRequired refuses writes while that reload is pending; format2FoldBroken when the merged view is untrustworthy). Writes made offline are judged against the sealed chain when the client returns — older writes and writes onto deleted records are dropped, ambiguous ones stand — and each verdict reaches the app as DocumentOfflineWritesResolvedEvent (documentOfflineWritesResolved: per write an outcome of dropped or kept-ambiguous and a reason of outdated, record-deleted, in-window, unverifiable or bulkIngest); a relaunch adopts the previous instance's unacknowledged writes, and a bulk load is crossed without reopening. Past the app's offline write window (largeDocumentWindowDays, 7 by default, 1–14) the document is read-only: create, update, save and string-set writes throw JsBaoError(.documentOfflineWindowExpired) with lastSyncAt, windowDays and overdueMs, and delete and field setters emit DocumentWriteRefusedEvent (document:write-refused); reads keep answering and a sync restores writes. JsBaoClientOptions(largeDocumentStorage: LargeDocumentStorageOptions(capability:models:)) names the models a device that cannot hold the whole document should load: a capped load is durable, reads of a model left out throw .format2ModelNotHydrated, and a device that cannot hold even those is refused with .format2StorageUnavailable (reason over-quota) before any chunk is fetched. documents.evict and logout(wipeLocal: true) remove a large document's local data along with everything else.
  • CLI: documents ingest bulk-loads a large document. primitive documents ingest <document-id> --input <dir> cuts a directory of <model>.ndjson[.gz] files — or a documents export output — into an artifact, uploads it, waits for the swap and reports the result; rows merge-patch by id onto what the document holds. -y skips the confirmation, --no-wait exits once the session is committed, --timeout <seconds> bounds the wait, --json prints the session; the exit status is 0 on complete, 1 on failed or aborted, 124 on timeout, 130 on interrupt. An app admin may run it without a grant on the document.
  • JavaScript client: bulk-load sessions and snapshot builds. client.documents.ingests (create, uploadChunk, commit, abort, get, list) drives or watches a bulk-load session from a server-side job, and client.documents.snapshots (list, get) reads a document's base builds; a build carries source ("builder", "import" or "ingest") and ingestSessionId. A connected client does not reload when a bulk load lands: it re-fetches only the chunks the load touched, refolds its own recent writes and reports through document:snapshot-load with mode: "converge" and chunksReused; a write still unacknowledged when the load landed is surfaced through documentOfflineWritesResolved with reason: "bulkIngest" — dropped when the load deleted its record, applied and marked ambiguous when the load modified it. Client 3.3.0 needs js-bao 0.9.0 or newer.
  • Server functions: ctx.runtime and assertRuntime. ctx.runtime is "request" or "task" — the runtime the platform ran the code under, whichever door fired it — and assertRuntime("task") (or "request"), at module scope or inside the handler, refuses the other with a typed FunctionRuntimeError (name "FUNCTION_RUNTIME_REFUSED", with runtime and required); the envelope and the run row carry the same code, which is now also what a request-runtime step.sleep or waitForEvent fails with.
  • CLI: functions runs and functions logs show the runtime. Both tables gain a RUNTIME column (request or task), and functions runs --json items carry runtime.
  • CLI: webhooks test --deliver delivers the preview for real. primitive webhooks test <webhook-id> --payload '{…}' --deliver posts the signed body to the webhook's own receive endpoint, so signature verification, the body cap, the IP allowlist, the handshake rules and deduplication run exactly as they do for the provider, and the function (or workflow) actually runs. It is a real delivery — an event row is written and the signed body's dedup key is spent, permanently on the github scheme, so vary the payload between deliveries — and the command reports what happened rather than a status code: dispatched (the run it names was confirmed) exits 0; refused names a disabled webhook (410), an address outside the IP allowlist (403), a rejected signature (401) or a suppressed replay; unconfirmed means the delivery was sent but could not be proved, and primitive webhooks events <webhook-id> has the truth. Without the flag, webhooks test still only signs.
  • Swift codegen for server functions. primitive functions codegen --lang swift emits one <key>.generated.swift per functions/<key>.toml: <Key>Input / <Key>Output as Codable types from the declared schemas, plus a <Key>Function invoker reached through a <key>(client) factory and bound over the generic client.functions overloads. Every invoker carries both verb sets — invoke, and start, getStatus, waitFor, terminate — so a wrong input shape is a compile error instead of a 400. A function with no declared schema gets a JSONValue alias rather than an empty struct, and the Swift app template regenerates the invokers from scripts/codegen.sh on every build path.
  • Swift client: server functions, channels and direct messages. client.functions.invoke(key, input:) takes an Encodable input and answers a request function's envelope decoded into your Decodable output (FunctionResult<Output>), and client.functions.start / getStatus(runId:) / waitFor / terminate drive a task function's run the way the workflow API does. The surface is typed-only: there is no [String: Any] entry point, and a dynamic caller names the witness — invoke(key, input: nil as JSONValue?) bound as FunctionResult<JSONValue>. client.subscribeToChannel(channel, grant:) joins a channel a function authorized and returns the membership with its expiresAt and an unsubscribe closure, unsubscribeFromChannel(channel) leaves, and channelMessage, channelSubscribeFailed and directMessage arrive as typed events on client.stream(for:); a refused join throws JsBaoError(.channelSubscribeFailed).
  • Server functions: a function starts a task function, and iterates the app's users. ctx.functions.start(key, input, { runKey?, contextDocId? }) starts a task function from inside any running function and answers the same envelope an HTTP start does; a repeated runKey replays the existing run (existing: true), the callee's access gate is not evaluated, and every run in the tree belongs to the tree's root caller. Nesting is bounded at four levels below the root (409 FUNCTION_NEST_DEPTH_EXCEEDED); inside a child, ctx.trigger.kind is "function", carrying the parent function's key and run id. ctx.users.list({ limit, cursor }) pages the app's users and ctx.users.iterate() walks them in a single-slice loop. primitive functions runs lists a nested run under its own function, FIRED BY function, with a PARENT column naming the function that started it.
  • Server functions: an invocation log. Every invocation the platform dispatched — an HTTP call, a webhook delivery, a cron fire, a workflow.call, a task run's settling slice — leaves a record of what it printed, what it threw and how it correlates (app, function, config version, run id, trigger), kept for seven days and outliving an archive. Read it with primitive functions logs <function-id> (--json, --follow, --limit / --cursor), from a function with ctx.api.functions.logs({ functionKey, limit }), or from the admin API — owner and admin only. A gate refusal writes no record, a value read through ctx.secret() is redacted best-effort, and truncated, logsUnavailable and contentSuppressed mark a capped, missing or withheld record.
  • Analytics: who signed up on a day. Every user-attributed event the server writes carries the user's app-join time, and users.search answers by it: pass signupDay (one UTC day) or signupStartDay + signupEndDay (an inclusive range of at most 90 days), page with offset while the response reports truncated, and each row carries signedUpAt and signupDay; the users.search workflow step forwards the same parameters. CLI: primitive analytics user-search --signup-day / --signup-start-day / --signup-end-day / --offset. JavaScript client: AnalyticsEventInput.user_created_at_epoch_s is ignored — the server records the join time.
  • Server functions: schema-typed codegen, a typed document handle, list parameters. Every config push — and primitive functions codegen — now also renders functions/primitive-function-types.d.ts (<Key>Input/<Key>Output from each function's schemas, typing the keyed defineFunction("<key>", handler); a key the tree does not declare is a compile error and push refuses an entry naming another file's key) and one typed client invoker per function under functions/generated/ (-o <dir> moves them) carrying both verb sets; the JS client's functions.waitFor result types its output by the generic. ctx.doc(documentId).model("<Model>") gives a function the same typed handle over a document's records that ctx.db gives over a database, typed from the project's models/models.toml. defineQuery parameters may be arrays ({ type: "array", items: { type: "string" } }), run's params are typed from the declaration, and a declared default is coerced to its type at registration. ctx.trigger carries the fields each fire path really sets. Removed from the JS client: functions.claimApply, functions.releaseApply and functions.confirmApply (the apply protocol was never reachable on a function run), and the iterations namespace from the primitive-functions profile.
  • Server functions: channels. A function authorizes its caller for a named topic with ctx.channels.authorize(channel, { userId?, ttlSeconds? }) — a signed grant naming the app, the channel and one user, good for 300 seconds by default and at most 900 — and any function publishes to the topic with ctx.channels.publish(channel, payload), which delivers a channel.message frame to every live membership (up to 500 connections per channel; no listeners is a success). Expiry is the only revocation and ends the membership even while the socket stays open; renewing is authorizing and subscribing again. JavaScript client: subscribeToChannel(channel, grant) resolves with { channel, expiresAt, unsubscribe }, unsubscribeFromChannel(channel) leaves, frames arrive on the channelMessage event, and channelSubscribeFailed reports a grant refused on reconnect so the app can renew it.
  • Server functions: a workflow calls a function, and a function takes an archived workflow's key. A workflow.call step whose workflowKey names no live workflow resolves to the server function holding that key: its output becomes the step's output, a caller-mode parent has the function's own access gate evaluated against the parent run's caller (a denial fails the step with FUNCTION_ACCESS_DENIED), a system parent skips it, and inside the function ctx.trigger is { kind: "workflow", workflowKey, runId, stepId }. Archiving a workflow lets primitive config push give its key to a function, so a replacement keeps its URL; a live workflow still refuses the push, naming the holder. The bridge goes one way — a function never calls a workflow.
  • Server functions: outbound calls, prompts, secrets, config vars and sends. A function's TOML declares integration:<key> and secret:<NAME> capabilities — the two kinds that name an outside credential — and primitive config push refuses an integration: with no active integration before any request is sent. Inside the function, ctx.integrations.call goes through the egress gateway (allowlisted destinations, canonicalized URLs, redirects re-checked by origin, a deadline that covers the response body — an upstream that never finishes answers UPSTREAM_TIMEOUT), ctx.prompts.run runs any of the app's prompts, ctx.secret reads a declared secret, ctx.configVar reads any config var (once per config version), and ctx.users.send / ctx.connections.send deliver direct.message frames to a user's live WebSocket connections.
  • Server functions: defineQuery and typed operations. A function can export queries with defineQuery, bound to the caller through $caller; primitive functions get prints a version's queries. Every operation in the remaining API families now carries a dotted operationId and request/response schemas in the published OpenAPI document, so generated clients and agents see the same surface the server serves.
  • Task server functions. Any function can run as a task: POST /app/{appId}/api/functions/{key}/start starts a run instead of answering inline and returns 201 with a run id (JS client: functions.start, getStatus, waitFor), the handler gains a step argument, and step.sleep can pause for hours or days with nothing running or billed in between. A run polls through the same routes and envelopes as a workflow run, repeats a runKey idempotently, and pins the code version it started on. A hard delete is refused while any run is unsettled, and archiving lets in-flight runs finish. Every cron fire starts a run, and overlapPolicy = "skip" asks the engine whether the previous run is still live, a sleeping run included — while a webhook delivery runs inside the request and hands long work to a task with ctx.functions.start. A run's slices have their own budgets: each slice is minted with 10 minutes of wall clock, 5 minutes of CPU and 10 000 platform calls, and every step begins with at least 5 minutes of wall clock — the platform refreshes the slice's credential between steps, so a sequence of steps runs continuously for up to 12 hours before a fresh slice starts (a refused refresh costs one pause of a little over five minutes). CPU and platform calls do not refresh: a step that exhausts a slice's CPU fails the run, and a slice past half its platform-call ceiling ends at the next step boundary. The run's status answer carries a slice block — refreshCount, ceilingAt, lastRefreshAt, and when the slice started and settled — and primitive functions runs shows the count in a REFRESHES column. JavaScript and Swift clients: functions.getStatus answers it as slice. A run's row settles as the run ends — the same settlement that writes its invocation record writes its status, endedAt and output — so getStatus, waitFor, primitive functions runs, runs wait and functions logs agree without anybody polling; running means live or not yet known to have ended, a running row whose run really ended is repaired on the next read, and a run started in the last five minutes is treated as still starting. The value a task run returns is kept on the run row for the row's life (45 days) and answered whole whether or not the engine still holds the instance (over roughly 10 000 characters it is stored by reference; the runs table does not print it). See the Server Functions guide.
  • Server functions: a typed database handle. ctx.db(databaseId, "<type>") gives a function a database handle typed from the model declarations config push writes for each type, and ctx.api.databases.records.* reaches the same rows; a function reads and writes every database of the app on the app's authority, with nothing to declare — scope a read to its caller in code, or through a $caller registered query. Every paged read the SDK exposes — the database handle, the document handle, ctx.users.list and the raw ctx.api.databases.records.query — answers the one envelope { items, hasMore, nextCursor? } (only the raw operation adds prevCursor), so paging code is written once; the REST routes' own responses are unchanged.
  • Server functions. TypeScript you author in your config tree and push to the platform: a functions/<key>.toml file states the access gate and the entry point, primitive config push builds and ships the bundle, and POST /app/{appId}/api/functions/{key} runs it synchronously. The code acts as the system — every platform call carries the app's own authority — and the signed-in caller is ctx.user, a name for attribution and scoping; the access gate is what decides who may make the code run. capabilities configure rather than authorize: integration:<key>, secret:<NAME>, and one exact string per high-blast operation (databases:delete, users:setRole, …); primitive functions get prints a manifest of the models and API families a version touches — documentation, never consulted for authorization. A function may declare its own inbound webhook and cron schedules in the same file, calls the platform through the primitive-functions client, and is operated with primitive functions (list, get, enable, disable, archive). Versions are immutable; a run pins the code it started on. See the Server Functions guide.
  • Workflow usage report. GET /admin/api/apps/{appId}/workflows/usage reports which step kinds an app's workflows configure and how often each ran, with partial-failure and truncation reporting; the CLI reads it as primitive analytics workflow-usage. The workflow key usage is reserved so a workflow can never collide with the route.
  • Swift template: the iOS App Store build lanes sign entirely from the App Store Connect API key in fastlane/.env — they fetch or create the distribution certificate and provisioning profile themselves — so a CI runner needs no pre-installed signing identity.
  • Large documents. A document created with documentFormat: 2 (CLI: primitive documents create --large) holds up to 2 GB of records without loading the whole document into memory on the server or in the Node.js client, and export and import carry the entire document. The app setting largeDocumentWindowDays (1–14, default 7) bounds how long a client may keep writing offline before it becomes read-only until it syncs.
  • Passkey user-verification policy. The app setting passkeyUserVerification (preferred or required) names one policy that both passkey ceremonies enforce; an assertion that fails it reports the typed PASSKEY_USER_VERIFICATION_FAILED code in the JavaScript and Swift clients.
  • Swift app layer: PrimitiveAuthManager.authFailure carries the last failed sign-in with its typed AuthCode and message, so an app can branch on the code instead of matching message text.
  • CLI and Vue template: environments accept an iosAppId beside webUrl (primitive env add --ios-app-id), and pnpm cf-deploy generates public/.well-known/apple-app-site-association for the environment it is deploying. An app with several environments no longer has to hand-edit one static file between deploys, and an environment with no iOS client serves no association document instead of one naming another environment's app.
  • Prompts: strict output for chat prompts. strictOutput = true in a [configs.chat] block sends the prompt's [prompt.outputSchema] to OpenRouter as a strict json_schema, so the provider constrains the answer instead of it being checked afterwards. Push and the admin routes refuse it with a PROMPT_STRICT_OUTPUT_* code naming the rule — beside a config-level outputSchema, on a non-OpenRouter config, with outputFormat = "text", or when the prompt declares no outputSchema — and a model that does not support structured outputs is refused AGENT_MODEL_CAPABILITY_MISSING.
  • Prompts: a decisions question's options can be supplied per run. Every choice question in [configs.decisions] declares criteriaSource: "static" keeps its options in the config's criteria table, and "dynamic" takes them from each run as variables.criteria.<question>, passed to ctx.prompts.run beside state. A run whose options are missing, malformed, or supplied for a static question is refused before the provider call, with nothing billed, as success: false with errorCode: "PROMPT_CRITERIA_INVALID"; push and the admin routes refuse a choice question without criteriaSource, naming it.
  • Analytics: per-prompt cost. A prompt event records the cost the provider reported for the run, and a run with no reported cost is recorded as cost-unknown, never as zero. prompts.top rows carry executionsWithCost, totalCost and avgCost, the last two null when no run in the window reported a cost. CLI: analytics prompts shows COST and AVG COST, with an (n/m) marker when only some runs reported a price; the Admin Console's Prompts table shows the same.
  • Documents: ask what one user may do with a document. validateAccess takes a user — JavaScript documents.validateAccess(documentId, { userId }), Swift validateAccess(documentId:userId:), ctx.api.documents.validateAccess({ documentId, body: { userId } }) in a server function, CLI primitive documents access get <id> --user <uid> — and answers that user's effective access across direct, group, collection and link grants, with accessSource and their appRole beside it. An app owner or admin may name anyone and a member only themselves; anyone else gets 403 DOCUMENT_ACCESS_SUBJECT_FORBIDDEN.
  • Documents: create a tagged large document behind an alias in one call. createWithAlias and getOrCreateWithAlias take documentFormat, tags and metadata on both clients, applied exactly as on documents.create; when getOrCreateWithAlias finds a document of another format than the one you state it answers 409 DOCUMENT_FORMAT_MISMATCH and creates nothing.
  • Documents: a client that has a document's format wrong is refused, not answered in the wrong format. A client that opens a document as the other format gets DOCUMENT_FORMAT_MISMATCH (details { documentId, declared, actual }): a waiting documents.open rejects with it, and a document already open is closed with the document:format-mismatch event. Records requests take the format you expect — ?documentFormat=1|2, CLI --document-format <1|2> on every records verb, ctx.doc(id, { documentFormat }) in a server function — and a disagreement answers 409 without touching a record.
  • Large documents: one query or aggregation spans all of a user's large documents. Under the opfs engine one browser store holds every large document a signed-in user has open, so an unscoped query(), queryOne(), count() or aggregate() reads across them in one call, and aggregate takes documents on both clients (JavaScript AggregationOptions.documents, Swift AggregateOptions(documents:)) to narrow it.
  • Server functions: open another user's root document. ctx.api.users.getRootDocument({ userId }) answers { userId, rootDocId } (null for a member who never signed in, 404 for a non-member), and ctx.api.documents.aliases.resolve / .delete take userId to act on that member's user-scoped alias, so a function with no caller can reach a member's own documents.
  • CLI: collections move between apps. primitive collections export --output <dir> writes collections.json beside a document export, recording every collection with its owner and members by email, its documents and its group grants. primitive collections import <dir> recreates them in another app: owners and members are resolved by email, documents by their preserved ids, a same-named collection is skipped unless --overwrite merges into it, --owner assigns one owner to all, anything missing is reported per item with a non-zero exit, and --dry-run writes nothing.
  • CLI: an integration's calls, by the server-function run that made them. A call made from a server function records the function's key and the run it came from. primitive integrations logs <integration-id> shows a RUN column and each call's correlation, and --run <run-id> (the logs API's runId filter) narrows the log to that run's calls.
  • CLI: functions list shows each function's triggers. A TRIGGERS column names which functions carry a webhook or crons, and a table beneath lists each trigger's status, schedule, next and last fire. --status filters both, and --json items carry triggers.
  • Admin sessions: every sign-in is revocable, and logout ends it on the server. Each CLI, web-admin and bootstrap login is a session: GET /admin/api/auth/sessions lists yours (kind, status, created, last refreshed, expires, and which one is current) and DELETE /admin/api/auth/sessions/{sessionId} revokes one; a super-admin can list and revoke any admin's. A revoked session's refresh is refused and an open admin WebSocket on it is closed with SESSION_REVOKED; an access token it already issued lasts until it expires (at most an hour). CLI: primitive auth sessions list (--limit, --cursor, --admin-id, --json) and primitive auth sessions revoke <session-id> (--yes outside a terminal); primitive logout revokes its session on the server, and when the server can't be reached it keeps the local credentials and exits non-zero.
  • Starter templates: pnpm lint lists deprecated platform API use. The Vue template enables the @typescript-eslint/no-deprecated rule for src/, so each use of a deprecated client, primitive-app or js-bao member is reported with the note naming its replacement. An existing app can pull the template's eslint.config.ts forward to get it.

Breaking ​

  • A unique constraint on a stringset field is refused where it is declared. No writer could build a consistent key from a set of strings, so the constraint was never honored. defineModelSchema, loadSchemaFromTomlString, every generated model barrel and a JsBaoClient given schemaToml now throw UniqueStringsetError (js-bao 0.11.0); codegen refuses the schema; primitive config push refuses the tree in its preflight; and a function push and the database type routes answer 400 UNIQUE_ON_STRINGSET. The Swift client's loader and codegen refuse it the same way, and a schema built in code is refused on its first write. Remove unique = true from the field, or the stringset field from the compound constraint, and regenerate. A constraint a document already recorded is ignored by the server, so nothing stored is lost.
  • CLI: every <noun> list --json prints one envelope, { "items": [...], "hasMore": bool, "nextCursor"?: string }. Verbs that printed a bare array (among them apps list, groups list and documents list) now print the envelope: read .items instead of the top-level array; users list and collections list gain a uniform hasMore; an empty result is { "items": [], "hasMore": false } and nextCursor is omitted on the last page. There is no flag for the old output.
  • CLI: a list verb prints exactly one page. Verbs backed by a paged route take --limit and --cursor and no longer fetch every page for you — notably documents list, which used to walk the whole cursor chain and now returns the first page. Page with --cursor <nextCursor> until hasMore is false, or use documents export-all for every document; an invalid --limit is refused before any request.
  • Plain-text API error bodies are now JSON. The admin API's shared error helper, the app API's 401/403 permission wrappers, the admin authentication gate, the deprecated blob PUT route's 405, and every remaining per-handler text/plain refusal on both API surfaces now answer the shared JSON envelope instead of a bare string. The message text is preserved verbatim: a consumer that read the body with res.text() now reads body.error from the parsed JSON. The supported clients need no change — the JavaScript client, the Swift client and the CLI all parsed JSON first and fell back to text already — so this affects only code that called these endpoints with a raw fetch and consumed the body as text. The OpenAPI document is updated with the body each route now sends.
  • The direct LLM and Gemini routes are removed; all model access goes through prompts. The app API's llm/* and gemini/* routes answer as an unknown route, a server function's ctx.api has no llm or gemini namespace, and directLlmEnabled is no longer an app setting: config push refuses it in app.toml by name like any unknown key, and config pull never writes it. JavaScript client: client.llm and client.gemini are removed, with LlmAPI, GeminiAPI, LlmChatOptions, ReasoningOptions, the Gemini* types, the GEMINI_ERROR code, the llm / gemini keys of analyticsAutoEvents, and getLlmAnalyticsContext() / getGeminiAnalyticsContext(). Swift client: client.llm and client.gemini are removed, with LlmAPI, GeminiAPI, their types, JsBaoErrorCode.geminiError, and client.llmAnalyticsContext / client.geminiAnalyticsContext. Define a prompt and run it from a server function with ctx.prompts.run.
  • Document create routes refuse a body key they do not read. POST create, create-with-alias and get-or-create-with-alias answer 400 VALIDATION_FAILED with a details entry naming the key (for example name, parentId, or a flat scope/aliasKey) instead of ignoring it. The typed clients send only keys the routes read.
  • Admin API: listing every app on the server is super-admin only. GET /admin/api/apps answers any other console admin — including an account whose role uses the legacy superadmin spelling — with 403 ACCESS_DENIED and no app data; list the apps assigned to you with GET /admin/api/admins/me/apps, which primitive apps list already reads. CLI: analytics workflow-usage --all-apps accepts only the super-admin role and refuses anyone else before sending a request.
  • JavaScript client (js-bao 0.10.0): a grouped Model.aggregate with a single sum, avg, min or max returns the bare value per group. A lone operation collapses exactly as a lone count does, so the shape matches the document and database aggregate routes and the CLI: { work: 40 }, not { work: { sum_estimatedHours: 40 } }. Read result[group]; a read of result[group].sum_estimatedHours is undefined at run time, and nothing fails to compile. Two or more operations, and ungrouped results, are unchanged.

Changed ​

  • Deprecations marked on every surface — nothing is removed yet. The CEL context surfaces (GET/PATCH …/databases/{id}/metadata, the database metadata / celContext properties, a type config's metadataAccess, a collection's contextId, CLI databases cel-context and databases create --cel-context/--metadata, the web admin's CEL Context card) are deprecated in OpenAPI, both clients, the CLI (which warns at runtime) and the workflow step docs; the cursor alias of nextCursor in every list envelope is deprecated and DeferredGrantListResult answers items (with grants as the legacy alias); and the admin DELETE …/apps/{appId}/users/{userId}, the app API's DELETE …/users/{userId}, ctx.users.remove and primitive users remove are deprecated in favour of users disable. The Swift client carries the same marks as compiler warnings, with source-compatible overloads.
  • Model fields named type are refused. type maps to the engine's internal _type column, so a model declaring a field of that name saved the value but filtered on the model name instead. config push on a database type or a function version's document schema, primitive functions codegen, and the document records surface now refuse the declaration with 400 RESERVED_FIELD_NAME, naming the field; a schema already stored keeps working. _-prefixed field names are refused for the same reason.
  • Server functions: no mode in config — the caller picks the runtime at each call. mode and durable are no longer keys of a function, and a cron entry has no mode: a file that still carries one pushes with a warning naming the key as ignored, config pull removes the line, and a later release refuses it. The route is the selector: POST /app/{appId}/api/functions/{key} runs the function inside the request and answers the result, POST …/functions/{key}/start starts it as a task run and answers 201 with the run — both behind the same access gate, input schema and rate ceiling. mode in the body is deprecated ("task" still starts a run; anything else, "any" included, is 400 INVALID_MODE), and FUNCTION_MODE_MISMATCH is no longer answered. Every cron fire starts a task run and every webhook delivery runs inside the request. JavaScript and Swift clients: functions.invoke and functions.start work on every function. CLI: functions invoke and functions start work on every function, functions get prints no mode, functions configs has no MODE column, and functions codegen (TypeScript and --lang swift) gives every invoker both verb sets — invoke, start, getStatus, waitFor, terminate.
  • Server functions: database-change triggers are removed. [[function.triggers.database]] is refused at config push with a message naming what replaces it, config pull strips it from a tree pushed earlier, and a stored declaration no longer fires; the trigger kinds are webhook and cron, ctx.trigger.kind is never "database", and LOOP_DETECTED / DEPTH_EXCEEDED are gone. Do the follow-up in the handler that writes the record — write the row, then publish, notify or write again — or hand longer work to a task with ctx.functions.start. functions get no longer lists watched types.
  • Admin API: a user's document and database inventories are paged, and reach the end. GET /admin/api/apps/{appId}/documents?userId=… and GET /admin/api/apps/{appId}/databases?userId=… now answer the standard list shape — { items, hasMore, nextCursor? }, with limit (default 50, at most 100) and cursor to fetch the next page — and a client that follows the cursor sees every document or database the user holds; each used to answer a single 100-row page with nothing saying rows had been left behind. The old documents / databases array keys are still emitted for now as deprecated aliases of items. A document's admin metadata now lists every permission, group permission and alias, and a database's every permission, rather than the first 100 of each. CLI: documents list --user-id and documents export-all walk every page; the Admin Console's per-user document and database views do too, and report a walk that could not reach the end instead of showing a partial inventory as complete.
  • CLI: one way to resolve the configuration tree. The --dir / --sync-dir override, the ./config fallback used when no project could be found, the scan that guessed which environment to generate from, and --app on every command that reads or writes the tree are all removed. A config verb, either codegen family, databases schema generate and scripts tests all read and write the selected environment's primitive/<env>/, and --env <name> is the only way to point at another one. --app survives where it can redirect no file write: users, apps, admins and the database data verbs. Outside a Primitive project every app-scoped command now stops with an error naming the missing primitive/config.json; login, logout, init and bootstrap still work there, and the auth-free local commands (--help, guides, skill, config fields) work anywhere.
  • CLI: config push checks the whole tree before it applies anything. Before the first mutating call, push checks every file — unrecognized keys and declared types, the workflow lints, a server function's entry, capabilities and trigger rules, and a webhook trigger's signing-secret reference — with the server's own rules and messages; any error aborts the run with nothing applied, --dry-run reports the same refusal, and config diff shows the file under Invalid with the same message. Once the preflight passes, entities are applied one after another and nothing already applied is undone: a refusal only the app's state can answer (a {{secrets.KEY}} that does not exist yet, a per-app cap, a key collision) fails that entity while the rest of the tree lands, the exit status is non-zero, and re-running converges.
  • CLI: config push typechecks a server function before it ships it. The same push that writes functions/primitive-db-types.d.ts, primitive-functions.d.ts, primitive-function-types.d.ts and primitive-document-types.d.ts now compiles your function sources against them, using the tree's own functions/tsconfig.json, and refuses a function whose code does not typecheck — with the compiler's own diagnostics, per function, so the rest of the tree still lands. config push --dry-run reports the same diagnostics and ships nothing; reproduce a refusal by hand with tsc -p functions/tsconfig.json --noEmit, and skip the check with primitive config push --no-typecheck. An npm package that ships no type declarations is not a refusal: the bundler inlines it.
  • The deprecated document listing and per-document invitation APIs are removed (JavaScript client 3.0.0). documents.list() is gone: read me.ownedDocuments() for the documents a user owns and me.sharedDocuments() for the ones shared with them. ownedDocuments takes the same options and returns the same DocumentInfo[], so most call sites are a rename; sharedDocuments returns an { items, cursor } page. The per-document invitation surface goes with it — documents.createInvitation / updateInvitation / deleteInvitation / getInvitation / listInvitations, documents.acceptInvitation / declineInvitation, and me.pendingDocumentInvitations(). Share by email with documents.updatePermissions(documentId, { email, permission }), and the recipient redeems the resulting app invitation with invitations.accept(inviteToken); list a document's outstanding deferred grants with documents.listPendingInvitations(documentId) and withdraw one with documents.removePermission(documentId, { email }). The invitation event channel and the DocumentInvitation and InvitationEvent types are removed, SharedDocument.source is always "permission" and its invitationId is gone, and opening a document or calling documents.validateAccess() no longer accepts a pending invitation as a side effect — a user whose only access was such an invitation is denied. The routes are withdrawn from the server in the same release, so a server on this version needs a 3.0.0 client. Swift client: the same removals, plus documents.listPage and ListDocumentsOptions. CLI: documents export sources its pending-share rows from deferred grants.
  • Swift client: documents.create no longer opens the new document. documents.create / createDocument writes the new document's metadata and commits it to the server in the background, matching the JavaScript client; it does not open the document. Open it before you query or write it — a write to a document that is not open throws JsBaoError(.notFound), and getDoc(documentId) returns nil until the open resolves. Previously such a write was applied locally and read back while the client reported the document as synced, and it never reached the server. Opening a just-created document does not wait on the network: a pending create, like a localOnly document, counts as a local copy, so a default-options open resolves locally, offline included.
  • CLI: the configuration tree moved to primitive/. primitive/config.json is the project config and each environment's TOML lives at primitive/<env>/, with the sync-state baseline committed beside it as .sync-state.json; .primitive/ now holds only machine-local state (credentials, local.json, snapshot backups) and is the single .gitignore entry primitive init writes. A project that still has .primitive/config.json and no primitive/config.json beside it — one that never migrated — is refused by every command that reads the project, with a message naming the file it found and the one it expects; anything else left under .primitive/ beside a migrated tree is ignored, and a baseline still named .primitive-sync.json is read until the next push or pull writes .sync-state.json. guides get, guides list, skill status, --help and --version read nothing from the tree and still run. --app must name the current environment's app.
  • Queries: $ne and $nin match records that never wrote the field. { deleted: { $ne: true } } now returns "false or not set", on the server, in the browser replica and in the Swift local mirror; $ne: null still matches only records holding a value, and a null entry in $nin excludes missing rows. Equality, ranges, $in and $exists are unchanged. This is a behavior change for code that relied on $ne skipping absent fields.
  • App API responses are Cache-Control: no-store by default (the avatar route keeps its public, immutable caching; blob downloads keep their ETag and 304). Swift client: HTTP responses are never written to URLCache — the client's session runs with the cache disabled and .reloadIgnoringLocalCacheData, without touching the host app's default session configuration.
  • JavaScript client: js-bao-wss-client now requires js-bao 0.6.0 or newer as its peer (it imports exports older releases do not have), so a clean Vite start resolves instead of failing on missing exports.
  • Server functions: the SDK package is primitive-functions (renamed from its earlier alpha name; config push refuses a bundle that still imports the old one); functions import defineFunction from it.
  • Server functions hardening. Output is capped at the wire ceiling and a function that exceeds it settles as failed with OUTPUT_TOO_LARGE; the invoke rate slot is taken only after the access gate and input validation, and a rate limiter that cannot answer refuses with 503 FUNCTION_RATE_UNAVAILABLE instead of admitting; the gateway forwards only an allowlist of request headers; a caller whose user or membership disappears mid-invocation is refused as FUNCTION_CALLER_NOT_A_MEMBER. The platform ceilings for a request invocation are 5 000 ms of CPU, 128 outbound subrequests and 1 200 invocations per minute per function (a task slice's own CPU and subrequest ceilings are under "Task server functions"); [function.limits] may lower any of them and never raise one, cpuMs is enforced on the deployed runtime, and an envelope whose handler ran to an answer carries limits — the ceilings that invocation actually ran under.
  • Rhai script step: the per-run operation ceiling is 250,000 (was 100,000), as the default and as the enforced maximum for limits.maxOperations.
  • CLI: every environment names one app. .primitive/config.json environments carry a required appId; the per-machine current app is gone with primitive use and primitive context (whoami still reports the environment's app).
  • Analytics: cron and system workflow runs no longer count as user activity — the sys:<appId> principal and the cron creator are excluded from top users, DAU/WAU/MAU and cohorts — and where a system run does appear it is named, not shown as a raw id. appId and userId are reserved keys in analytics.write event context.
  • Templates: every generated source is committed and regenerated on each build, so a fresh checkout builds without a codegen step and a stale artifact shows up as a diff.
  • One key namespace per app. Workflows, server functions, scripts and webhooks now share one key namespace, enforced at config push: a webhook can no longer reuse a workflow's key, and a push that would collide names the holder instead of creating a duplicate. Existing workflow/script collisions are grandfathered; cron triggers are unaffected. One exception: a function may take the key of an archived workflow — archive the workflow, then push the function.
  • JavaScript client (js-bao 0.6.0): each call to initJsBao now creates its own ORM instance with its own engine, models, subscriptions and document bindings, and resetJsBao destroys every live instance. A model class passed to a second live instance stays bound to its first one and logs a warning; give each client its own model classes (the schemaToml path already does) when they must be isolated. Previously a second call silently shared the first instance's engine.
  • Large documents: reopening costs what changed. A reopen after a close or reload folds only the records that changed since the last open, and nothing when nothing did (a store written by an earlier client folds the whole overlay once).
  • JavaScript client (large documents): existing OPFS stores are replaced by the per-user store: local rows download again on each large document's next open, and unsent writes carry over. A tab running a newer app version refuses to open a large document through an older tab's worker with FORMAT2_STORE_OUTDATED until that tab is reloaded or closed.
  • Swift client: aggregate's documents narrows a model bound to one document rather than replacing its scope, as query does, so naming another document returns no rows.
  • Large documents: epochs seal at 1 MiB, and bases build on a cadence. The open epoch seals at 1 MiB of overlay or 32,768 Yjs items (overwritten values count), no sooner than 10 seconds after it opened unless it reaches three times either limit, or when it is a week old. A base is built every eight seals, an hour after a seal no base covers yet, or on request (primitive documents snapshots build), rather than after every seal. Connected clients follow seals more often, and each one is smaller and pauses writes for less. Load large datasets with a bulk load, which seals once; records/bulk writes through the overlay like any save.

Fixed ​

  • Large documents: a document whose format lookup missed because DynamoDB had not caught up is no longer pinned as an ordinary document forever — a miss is confirmed with one strongly consistent read before it is believed, a confirmed absence answers 404 NOT_FOUND (query, count, bulk and a typed frame on the socket) with no pin, and the next request recovers.
  • Server functions: a task run the platform settled failed for an engine teardown (ENGINE_ISOLATE_EVICTED) reads completed, with its output and a real endedAt, once the resumed run's own settle lands; a teardown sentence from the engine's own step handle settles the slice reset rather than failing the run, and a row failed for the handler's own failure is left exactly as it was.
  • Prompts: userPromptTemplate keeps its trailing newline on prompt create, config create and config update, so config push of a TOML multi-line template that ends in a newline is reported synced by config diff — it no longer reads modified forever.
  • JavaScript client (large documents): an ordinary closeDocument keeps a large document's local data — records, unacknowledged writes, projection marks and query rows — so the next open re-projects nothing (Swift already did); an unscoped query(), count() or aggregate() reads only the documents that are open, so a closed document's kept rows are never counted or returned.
  • JavaScript client (large documents): with the OPFS engine, documents.evict, closeDocument({ evictLocal: true }) and logout({ wipeLocal: true }) now remove the document's local store — its rows, per-document tables, bookkeeping and projected members — and the OPFS directory once it holds nothing else; a document evicted while open stays editable until its close, and a wipe reaches documents this session never opened.
  • JavaScript client (large documents): a restart in the middle of an epoch move no longer publishes an offline write the catch-up would have dropped — the replay debt a deferral owes is recorded durably in the store before a frame is discarded or an overlay swapped, and the next client over the same store resumes the judgement.
  • JavaScript client: openDocument's first metadata write merges its delta into the persisted row instead of replacing it, so the epoch a rotation wrote or a marker a listing wrote in another tab survives an open; a custom storage provider's merge is serialized per document.
  • Swift client: localOnly: true with documentFormat: 2 is refused at create with JsBaoError(code: .localOnlyUnsupportedOption) before any local state moves — the same rule JavaScript enforces as LOCAL_ONLY_UNSUPPORTED_OPTION — instead of creating a document that can never sync.
  • Documents: a records read or write that reaches a document while its delete is in flight, or after the delete cleared the object's storage, answers 404 NOT_FOUND instead of 500 INTERNAL_ERROR (and, on an ordinary document, instead of a silent success against the wiped object).
  • Documents: a records query whose $or carries fifty or more two-field branches no longer fails with 500 "too many SQL variables" — it compiles to one statement inside the platform's parameter bound and returns, sorts, pages and counts exactly the records the branches name.
  • Documents and databases: cursor pagination is null-aware — sorting on a field some records lack no longer throws (Cannot generate cursor: record missing sort field), truncates silently or skips rows; present, null and absent values page exactly once in both directions.
  • Documents: a unique-index entry no longer survives its record. A stale entry (its record gone, or holding another value) is an index miss for findByUnique and upsertByUnique and is healed by the server on the next write, so a key can be written again after its record is deleted.
  • Collections: group grants are recomputed on every mutation — raising or lowering a level propagates, removing a source removes the access it gave, and a direct grant no longer overwrites a collection grant; a member's level is the highest any live source grants.
  • JavaScript client (large documents): evicting a document that was never opened, or a crash in the middle of an epoch swap, no longer leaves a per-epoch overlay in IndexedDB that nothing reclaims; the open path sweeps stale overlays under the epoch lock.
  • JavaScript client: a custom yjsPersistenceFactory is handed the epoch-scoped store name at a swap (with documentId, documentFormat and epoch on its context), so the post-swap clear can no longer remove the fresh epoch's rows.
  • JavaScript client: a failed clearData() on one document's persistence removes only that document's entries; the shared js-bao:yjs IndexedDB database survives an evict, a close with evictLocal, an epoch rotation and a stale-clock reset.
  • Workflows: a workflow create whose row was written no longer answers 400 INVALID_REQUEST "Failed to fetch saved item" when the store's post-write read-back misses — the row is confirmed with a consistent read, and a run-sync's terminal stamp lands on it.
  • JavaScript client: functions.waitFor right after functions.start no longer rejects NOT_FOUND for a live run — a Run not found 404 is retried for a bounded stale-read grace and the wait resolves when the run settles.
  • Server functions: a task run whose handler throws settles with errorCode: "FUNCTION_THREW" (it was null) on the row, the admin runs list, the status route and the client's getStatus / waitFor, agreeing with its invocation record.
  • Large documents: records/bulk creates no longer scan every stringset-index row of the model per record — the index is (_type, _record_id, field) and an existing database converges on its next cold operation.
  • CLI: primitive functions codegen --help describes the invoker the CLI writes today (invoke/start/getStatus/waitFor/terminate, the caller picks the runtime) instead of calling the Swift invoker "mode-fixed".
  • Workflows: a start whose run row was written but whose post-write read-back missed no longer answers 500 — the row is confirmed with a consistent read and the start answers 201 (about 2% of paced starts hit this on the agent env).
  • CLI: documents export, documents import, documents transfer-owner, databases export and databases import run with only their required positionals and act on the app the environment names; a typed app id (leading positional or --app) still selects another app.
  • Large documents: one unintegrable delta from a connection no longer makes the room answer every later frame from that connection with epoch.resync until the object is evicted — pending state is attributed to the frame that produced it, and a second connection is never told to resync for a delta it did not send.
  • JavaScript client (UMD bundle): dist/browser.umd.js carries registerModel (it had shipped only in the ESM dist), and the committed bundle is checked byte for byte against what build:umd produces.
  • Large documents: a bulk write refused because the document object was momentarily unreachable answers 503 DOCUMENT_UNAVAILABLE naming the write, so a transient can be told from a real failure; an unclassified failure stays 500 INTERNAL_ERROR, and documents records bulk reports the code and status.
  • Large documents: a 2 GB bulk load no longer sticks in registering: the old-table removal is bounded in rows and bytes and paged across ticks, an interrupted removal is detected on the next tick, and a replacement session pages a failed session's leftover tables down before its own copy.
  • JavaScript client: openDocument() on a client constructed with new JsBaoClient(options) resolves instead of throwing "called before the js-bao runtime finished initializing".
  • JavaScript client: open() on a document that is registered locally but holds no data waits for the initial sync instead of resolving over an empty replica, so query() and subscribe() see the synced records without a reload; the Swift BaoDataLoader and the Vue useJsBaoDataLoader keep a change that arrives during their first load.
  • Unique indexes: a unique-constraint entry only fires for an index owner the query can read — a stale entry left by a row that is gone, or that now holds another value, is a miss and is rewritten instead of throwing UniqueConstraintViolation for a row nothing can see. On the server, in the JavaScript client and in the Swift client (upsert(on:), upsertByUnique, findByUnique).
  • Large inline document updates are durable. An update or syncStep2 frame carrying a payload over the inline size cap with no uploadId (the Swift client sends every update inline) is put in object storage before any row names it; a stored update whose object cannot be loaded fails the read, naming the key, instead of reconstructing a document silently missing it, and a write whose store fails is refused rather than acknowledged.
  • Swift client: a number field holding a finite Double at or above 2^63 saves and reads back instead of aborting the process inside the CRDT write.
  • Swift client: DocumentContext.close returns the CloseDocumentResult (evicted: false when the server has not confirmed this client's writes), so a skipped eviction is observable through the document handle; ignoring the result takes no warning.
  • Swift client: the code generation build-tool plugin resolves its tool under Swift 6.4's swiftbuild build system as well as the native one (the plugin's target and product now share a name).
  • Swift template: scripts/smoke-test.sh ui_signin taps again on Xcode 27, which moved SimulatorKit: the scenario starts idb_companion under a DEVELOPER_DIR from the new scripts/idb-developer-dir.sh (a symlink mirror of the Xcode bundle; nothing is written inside Xcode.app), and its preflight reports an Xcode it cannot resolve before the build. Start an ad-hoc companion the same way.
  • A document that stops syncing reports it, and the client recovers on its own. An open document whose sync handshake goes unanswered for the whole handshake budget (10 s by default) now reports documentSyncStateChanged with state: "error", repeated on each timeout while it stays behind, and "synced" once when it catches up, so an app can show and clear a warning. The retry re-arms after every attempt that could not start a sync cycle, at a backoff capped at 15 s, and after three consecutive timeouts on a connection that still reads as open the client rebuilds the connection itself — once per stalled document and once per connection. Two server-side causes of a document that never finished syncing are fixed in the same release: an abandoned earlier sync no longer silences every later sync attempt from that connection, and after a reconnect other users' writes reach the connection again instead of only its own sync completing. A connection error for a refused document now carries its documentId. JavaScript and Swift clients alike.
  • CLI: config diff no longer reports a database type as modified after an identical push — an operation with an empty params set or a blank access compares equal to one with none — and a Modified row names the field that differs.
  • CLI: prompts tests and workflows tests verbs take the prompt's or workflow's key as well as its id; an identifier that matches neither is refused instead of answering No test cases found.
  • Every test-case route for a prompt, integration, workflow or script — list, create, get, update, delete and attachments — answers 404 naming the id when it names no block of that type in the app, instead of listing empty or accepting new cases.
  • Swift client: a localOnly document no longer reports unsynced changes it can never clear, so documents.evict accepts it without force and evictAll(onlySynced:) no longer skips it every time.
  • Swift client: a transaction wrapped in transactAndSync or transactAndSyncAsync is sent once rather than twice, and a write made from a document-opened handler is forwarded like any other.
  • Workflows and operations: a model-typed operation parameter rendered from a {{ }} template is coerced with the same safe rules as workflow input (numeric string → number, "true"/"false" → boolean, number → string; coerce: false opts out); a value with no safe conversion still fails with FIELD_TYPE_MISMATCH.
  • Analytics: users.top reports each user's all-time firstSeen beside the window-scoped one, so a returning user no longer reads as new.
  • CLI: documents import restores a root document — a root-marked export is applied to the target user's existing root, and only a root the import itself created takes the exported state without --overwrite.
  • CLI: scripts tests run <case> passes the case's inputVariables, matching run-all; a script run whose input did not arrive fails instead of being verified.
  • Workflows: a switch step's output templates resolve outputs.* like step params do, and a skipped step's output is tolerated in the branch expression.
  • CLI: config pull no longer writes a prompt's server-owned status into its TOML, except a retired one.
  • CLI: documents export and export-all now write a document's tags, and documents import restores them unchanged; import also refuses to create a new document from a root document and drops the internal root marker, and import --dry-run previews the exact run it would make.
  • Swift client: a single-document syncMetadata is now authoritative for that document's row, so a revoked or deleted document is evicted from the local cache instead of lingering until a full sync.
  • Swift app layer: PrimitiveAuthManager forwards the passkey relying party (rpId) on signInWithPasskey and enrollPasskey, and initialize() derives the default from the environment's webUrl, so an app with several relying parties can name the right one per call.
  • JavaScript client: an awaited documents.open() now resolves only once the document is ready to query, even when another open of the same document is already in flight; adding a model mapping and querying right after the open no longer fails intermittently with "document is not connected".
  • Passkey sign-in from a native iOS app no longer fails with "User verification required" when the app asked only for preferred verification.
  • Swift client: a rejected Apple sign-in callback reports the server's error code and message instead of a generic "Invalid credentials", and no longer triggers a token refresh.
  • Swift app layer: the login view shows the waitlist state for a waitlisted sign-in, and "Invalid code" appears only when the server rejected the code.
  • Swift template: the default app icon is opaque and the Info.plist declares supported orientations, so a first App Store upload passes validation instead of being rejected twice.
  • Swift template: the first archive no longer fails the models guard — model codegen runs before xcodegen regenerates the project.
  • Prompts: a failed prompt run carries upstreamStatus, the provider's own HTTP status, whenever the provider answered, for Gemini configs as well as OpenRouter ones (a Gemini failure used to read only "Upstream error"), and an upstream timeout sets errorCode: "PROMPT_UPSTREAM_TIMEOUT". JavaScript client: ExecutePromptResult.upstreamStatus and errorCode. Swift client: the same fields on ExecutePromptResult.
  • Databases: save and batch save keep a field written as null as the value null, as patch always did, instead of removing the key, so $exists: true still matches the record; a unique index on a field a batch create stamps now refuses a duplicate in the same batch reliably.
  • Collections: a collection's access listing returns every group grant and member rather than the first 100, and a group granted access to a collection holding more than 100 documents can read every one of them.
  • Server functions: creating a function no longer fails with "Failed to fetch saved item" after the write succeeded — the server confirms the write before answering and returns 503 STORAGE_CONSISTENCY_PENDING only when it still cannot — and CLI: config push retries that code a bounded number of times instead of reporting the function as "could not be applied".
  • Large documents: an edit that races a delete no longer brings the deleted record back as a partial row, on the JavaScript client or on the server, whatever order the updates arrive in. A field removed by a late concurrent write now leaves the local query rows too. Saving 8,000 records one at a time now folds in about 0.2 s instead of about 32 s.

2026-08-28 ​

New ​

  • JavaScript client: passkey sign-in and registration start calls accept an optional rpId, so a native or multi-domain app can name the relying party it wants instead of relying on the request origin.
  • Swift client: AuthConfig(passkeyRpId:) — or a per-call rpId: — names the passkey relying party explicitly.
  • CLI: environments accept a webUrl (a normalized web origin), and primitive init scaffolds the universal-link setup for a combined iOS + web app: the association file and an https sign-in link that opens the app where it is installed.
  • Swift template: run-ios.sh boots a simulator dedicated to the app and waits for the boot it started, instead of sharing whatever simulator was already open.

Fixed ​

  • A passkey start request naming a relying party the app does not configure is rejected with a typed PASSKEY_RP_NOT_CONFIGURED error (both SDKs) instead of being silently redirected to another relying party.
  • Document shares granted to an email address before the recipient signs up now resolve reliably: every pending grant is found at signup rather than only the first batch, a just-granted share appears on the recipient's first listing, and the app's allowed-domains policy is applied when the grant resolves.
  • JavaScript client: a failed OAuth code exchange throws a typed auth error carrying the server's error code instead of a generic failure.
  • CLI: integrations tests verbs resolve the integration by key or ID and fail clearly when they cannot, instead of acting on the wrong integration.
  • CLI: scripts tests names the local case files it skips because they are not registered in the pushed config.
  • CLI: email-templates help no longer lists retired template types.
  • Swift template: distribution resolves the App Store Connect key path from the app root, so fastlane finds it regardless of the working directory.

2026-08-27 ​

New ​

  • Webhook verification schemes. Inbound webhooks can verify requests with a JWT scheme (fetching keys from a remote JWKS URL), a declarative custom detached-signature configuration, or the built-in Plaid preset — all configurable in the admin console.

  • Metadata reverse lookup. A resource can be found by a unique metadata value, in the JavaScript client, the Swift client, and the CLI.

  • Document record aggregation. Documents gained a records aggregate endpoint matching the databases one, and the CLI's documents records and databases records groups expose the same verb set.

  • Workflow step execution state. Templates can read whether an upstream step succeeded, failed, or was skipped, so runIf conditions no longer have to re-derive it.

  • Batch database writes in workflows. The database write step accepts multiple records per operation call.

  • Metadata-filtered user fan-out. The iterate-users step accepts a metadataFilter, limiting the fan-out to users whose metadata matches a value.

  • CLI: document management. primitive documents gained read and inspection verbs, permission grant and revoke, and create and delete — with the owner assignable by email.

  • CLI: database record commands. New records get, count, aggregate, save, and patch verbs, plus databases list --owner.

  • CLI: live log following. Log inspection commands gained a --watch mode that resumes from the last entry seen.

  • CLI: retiring an object. primitive <noun> archive <id> retires an integration, webhook, cron trigger, workflow or prompt from the terminal — the five types whose status can be archived. It is the soft delete the API and console already performed: the row is kept so its history still resolves, and it goes on holding its key (and, for webhooks and cron triggers, its slot against that type's per-app cap), so enable refuses it and there is no un-archive. Deleting the object's TOML file and running a confirmed primitive config push --prune remains the only way to destroy a row and get its key back. Like every destructive command it confirms first, takes --yes/-y, and refuses to act on a pipe without it.

  • Swift client: notifications, locks, and resource metadata. A notifications inbox with push-token registration, named locks, and the full resource-metadata value API (get, set, getBatch, list, delete) — each matching the JavaScript client.

  • Swift client: events as AsyncStream. Client events are available as AsyncStream sequences, with opt-in main-actor delivery and delivery metadata on each event.

  • Swift client: reliable workflow completion waiting. workflows.waitFor(runId:) resolves a run's completion even when the run finishes while the client is disconnected, reconciling on reconnect; a typed overload decodes the run output.

  • Swift client: typed subscriptions codegen. Database codegen emits a typed subscriptions factory, matching the JavaScript generator.

  • Admin console: error groups, connections, and run detail. Recurring errors are grouped in a dedicated analytics view, an app's live connections are inspectable, and the workflow runs table names the step a failed run failed on.

  • Named-lock access control. An app can install a single lock rule set governing which keys each member may acquire or renew; without one, locks stay open to any member as before. A denied call answers 403 with LOCK_ACCESS_DENIED; release is never rule-gated.

  • Swift client: workflow factory parity. Generated workflow factories gain the JavaScript extras — terminate, cron-trigger create/update, and inline define.

Changed ​

  • One emailed sign-in link for an app with both an iOS and a web client. An app whose Primitive environment names a webUrl — the origin its web client is served from — emails that origin's /oauth/callback as the sign-in link, from BOTH clients. Tapped on a device with the iOS app installed, iOS opens the app (a universal link); read anywhere else it is the web app's ordinary sign-in page, so the same email works wherever it is opened. The server sees an ordinary allow-listed redirect target; nothing about the request is per-platform.

    webUrl is a normalized origin per environment in .primitive/config.json (an entry in that file, or primitive env add <name> --api-url … --web-url <origin> when you create the environment) — https except localhost/127.0.0.1, no credentials, path, query or fragment; the CLI refuses anything else, and the resolvers that build an app read it as no web counterpart at all. The Swift library carries it into client.links.appBaseURL, so the outgoing link and the origins an incoming universal link is trusted from are one value; PrimitiveAuthManager sends the https target in preference to its custom scheme, and sendsEmailSignInLink still forces code-only when set to false. An app with no web counterpart is unchanged: code-only by default, the <scheme>://auth/magic-link opt-in as before. primitive init seeds the dev webUrl when an app ends up with both clients and the dev callback is on its allow-list (otherwise it prints the two steps rather than turning a working code-only email into a 400), and prints the production checklist; the Vue template ships a query-scoped apple-app-site-association example plus the _headers rule that serves it as JSON, and the Swift template ships the associated-domains entitlement as a documented commented block. Because the web domain is compiled into the app, changing it later means an app release.

  • Deleting a workflow or prompt keeps its history. A plain delete now archives the row instead of destroying it: runs, revisions, and test cases keep resolving, the key stays held, and enable refuses the archived row with WORKFLOW_ARCHIVED / PROMPT_ARCHIVED naming the remedy. Destroying the row and freeing its key is explicit — the API delete with hard=true, or deleting the TOML file and running a confirmed primitive config push --prune. Archived rows are listable with --status archived, and an archived workflow no longer blocks deleting a script or config it referenced.

  • Document permission entries type name as optional. The server omits name for a permittee who has none; the JavaScript and Swift clients type it as optional (a breaking type change for TypeScript consumers that read it as required), and the Swift client no longer fails to decode a permission listing containing such an entry.

  • Workflow runs stop slowing down with distance. The engine no longer pays a database round trip per step for its own bookkeeping: run setup is checkpointed across replays, the per-step user check is cached, and progress writes leave the step boundary — so multi-step runs keep sub-second step gaps even when the run is scheduled far from the database region.

  • Email sign-in is one flow, and one setting. A sign-in request now sends ONE email carrying both a 6-digit code and a sign-in link, and the user finishes with whichever suits them — typing the code on the device they started on, or opening the link on whichever device has their mail. Consuming either one retires both: one email signs a user in once. There is nothing to choose in advance, so magicLinkEnabled and otpEnabled collapse into a single [auth].emailSignInEnabled (false turns email sign-in off entirely, for an app that only wants Google, Apple or passkeys); both old keys are retired and rejected by name with the replacement, and PUT /settings and the admin API report them as compatibility fields always equal to the new one. The JavaScript client gains emailSignInRequest(email, { redirectUri }) and the Swift client auth.emailSignInRequest(email:redirectUri:); magicLinkRequest and otpRequest still work as deprecated aliases of the same issuance path, as do POST /auth/magic-link/request and POST /auth/otp/request. The verify calls are unchanged. PrimitiveLogin's emailAuthMethod prop is gone, and both the web and iOS login screens show one email field and one "check your email" state with the code entry and a reminder that the link works too.

    The email renders from a new email-sign-in template carrying {{code}}, {{expiryMinutes}} and an optional {{magicLink}} inside a {{#if magicLink}} block; both credentials share one 15-minute expiry. Link issuance is fail-closed: the email carries a link only when the request names a redirect target AND that target matches the app's non-empty [auth].emailRedirectUris; with no target or an empty list the same template renders code-only, and a target that misses a non-empty list is still rejected with Invalid redirect URI. Because that one template is the only one any sign-in endpoint renders, deleting the link block from your email-sign-in override means no endpoint can send a link email for your app.

    magic-link and otp are retired as email types. A stored override for either is kept and still listed — labelled retired, with migration guidance — but nothing renders it, and creating, updating, previewing or test-sending one is refused; deleting still works. Re-author what you customized into email-sign-in ({{code}} for the code, the {{#if magicLink}} block for the link) and delete the old override. Old clients calling /auth/otp/request now receive the new email; since that endpoint's published body carries no redirect target, it is code-only.

  • Google sign-in is configured per client, and app.toml states the whole auth surface. Google registers an OAuth client per platform, so [auth] now takes one entry per client type — [auth.google.clients.web], [auth.google.clients.ios], and so on — each with its own clientId, redirectUris and (for web and desktop, which are the types Google issues one for) clientSecret. A redirect URI belongs to the client that redirects and selects it at the callback, so an iOS custom scheme is no longer a valid redirect for the web client. The app-wide redirectUris is renamed emailRedirectUris and now serves the magic-link flow only; its matching is fail-closed, so an app with an empty list rejects every magic-link request — new apps are seeded with the localhost dev callback, and primitive init appends the dev-port callback for a non-default port. googleClientId, googleClientSecret, redirectUris, passkeyRpId and passkeyRpName are retired: config push, PUT /settings and the admin API each reject them by name, with the replacement. Existing apps migrate themselves and keep signing in throughout: until an app is migrated the server reads its old googleClientId/googleClientSecret as a single client — web when a secret is stored, ios when not — and its old redirectUris as the magic-link list, and the first time the app's settings are read or written those values are written into the new fields. A client secret still held as a raw value is moved into the app's secret store and the setting stores the {{secrets.KEY}} reference, so the secret is preserved rather than needing to be regenerated at Google. After migrating, Google sign-in also needs a client library new enough to read the per-client map; magic-link works on any version.

    On the read side, a stored clientSecret is echoed verbatim: it is a {{secrets.KEY}} pointer, not a credential, so the withheld-value machinery is gone and with it googleClientSecretSet, googleClientSecretStatus, hasGoogleAuth and the forced-migration gate that refused every settings write while an unmigrated value was stored. A settings write now succeeds whatever is stored; a literal simply cannot be written back.

    /oauth-config publishes the client map with a per-entry usable, and no longer publishes hasOAuth, hasWebOAuth, googleClientId, authorizationUrl or an app-wide redirectUris — a single flag could only ever be right for one platform. Client-version floor: read availability as "the provider is enabled AND my platform's entry is usable". The JavaScript client's checkOAuthAvailable() and exported googleWebClientAvailable(config) compute it over the web entry; the Swift client's AuthConfigInfo.googleSignInAvailable and AppConfigInfo.googleAvailable compute it over the ios entry. A client older than these reads the removed flags as absent and hides the Google button — it does not crash, but it will not offer Google sign-in until it is upgraded. Apps scaffolded from a template hold a copy of that code and do not auto-update; re-apply the login changes or re-scaffold.

  • Access rules are required on prompts, workflows, and integrations. Every create surface requires an accessRule, and execution fails closed — a resource with no rule refuses callers. Integration calls are checked against their own rule on every call.

  • Credentials are configured by reference, not by literal. Webhook signing secrets and Google client secrets accept only secret references on writes. Existing literal signing secrets keep verifying deliveries, but editing such a webhook requires migrating it to a reference; the same holds per Google client entry. The separate integration-secret store is retired — app secrets are the only credential store. Rotation grace periods are capped at 30 days.

  • Server-side configuration is authored in TOML only. The CLI's config-setting flags are removed: author changes in the sync directory and push them. Integration test cases move into the config tree the same way.

  • CLI command reorganization. sync now lives under config; settings get is folded into apps get; collections docs is renamed to documents; database-types is renamed to database-type-configs; metadata category configuration is split into its own noun; per-subject analytics is consolidated under analytics (the workflows analytics group is gone); and the deprecated llm group and workflows publish are removed. --dir is the sync-directory override everywhere, with --sync-dir kept as a hidden deprecated alias.

  • Unresolved workflow template references fail the step instead of substituting silently. config sync push also validates {{ }} expressions statically, so unknown roots and references to undeclared steps fail at push time rather than at run time.

  • Prompt steps declare their output type. prompt.execute takes expect = "text" (the default) or "json", and content is typed by expect alone rather than by the active prompt configuration. Workflows that relied on automatic parsing need expect = "json".

  • Workflow push always sends the full definition. A field omitted from your TOML now clears the server value instead of silently preserving whatever was there.

  • config diff and config push agree about what changed. Every resource type push handles — webhooks, integrations, cron triggers, blob buckets and test cases included — is compared for CONTENT against the state the server currently holds, by both commands: a clean diff means push has nothing to apply, a comment-only edit is a change to neither, and a difference diff reports is one push applies or names (server drift, a conflict, or an immutable field) rather than silently skipping. A file push would reject — an unrecognized or retired key, or a value whose spelling is not its declared type — is reported by diff with the identical message instead of reading as in sync. Breaking: a quoted number where a number is declared (timeoutMs = "300000") is now a validation error both commands report; write it unquoted. config pull has never emitted that spelling, so only hand-authored files are affected. The exceptions are the two genuinely dual-encoded cases, unchanged: a prompt's temperature/topP, and JSON fields, which may be a native table or JSON text.

  • app.toml is the whole truth, and the full config diff compares it. App settings were the one configuration surface the whole-tree config diff did not look at — an app.toml edit reported every count as zero, which read as "nothing to push" over a push that would change the running app. They are now compared per field like every other type, and shown as a Modified app-settings row (with config diff --only app keeping its per-field detail). Breaking: they were also the one surface where an omitted key preserved the server's value. Push now applies app.toml as the complete state: a key the file does not carry is cleared, or reset to its declared default where the server has one — magicLinkEnabled, otpEnabled and waitlistNotifyAdmins to true, [cors] mode to "universal", [invitations] limit to 5, the remaining booleans to false. Run config pull --only app before your next push and review the file: any setting configured outside the CLI that is missing from it will be cleared. [app].name and [app].mode are now required keys — their absence is a validation error from both commands rather than a silent reset. A config pull followed by a config push remains a no-op.

  • forEach default cap raised to 500. A 250-row provider page iterates without configuration, and an over-cap list fails with an error naming the limit and the maxItems override instead of being silently truncated.

  • Declarative locks apply to synchronous runs and to workflow.call, not just queued runs. A step that loses a lock under onContention: fail is now distinguishable from an application error in run status and error analytics.

  • Role-aware records API. Admin data routes are consolidated under records/* with role-aware authorization, databases.list requires a bounding filter, and bulk record operations carry their payload under data — the old fields key is rejected with a clear error instead of silently writing nothing.

  • Stricter analytics and webhook limits. A filter set over the cap is rejected with an error instead of having filters silently dropped, and an app is limited to 50 webhooks.

  • Swift client: one breaking API migration. Every deprecated symbol queued for the next major is removed in a single batch — get*() methods become properties, millisecond Int parameters become TimeInterval, event, awareness, and analytics payloads (and codegen row data) become [String: JSONValue], generated code moves behind a client.codegen facade, and databases.subscribe returns an EventSubscription. Internal transport types are no longer public, and BlobManager is an actor.

  • Swift client: the libraries build under the Swift 6 language mode with strict concurrency.

  • Swift client: connectivity and list options behave as described. Network-path monitoring works, with gating and reporting separated (the ineffective probe option is removed); includeSystem reaches the server; waitForLoad and serverTimeoutMs are implemented; cached documents are served immediately while sync refreshes them; and me.ownedDocuments returns cached rows right away with a background refresh — pass .network for the previous always-blocking behavior.

  • JavaScript client: typed openDocument errors. Fast-fail open errors carry error codes instead of being plain Errors, matching the Swift client.

  • The direct LLM/Gemini proxy is off by default. The deprecated raw proxy routes answer 403 until the app opts in with directLlmEnabled = true in app.toml; managed prompts are unaffected. The routes are removed in the next major server release.

  • Availability status is server-owned. status is no longer authored in TOML for prompts, integrations, workflows, or webhooks — use primitive <noun> enable|disable. Creation always yields an active object, and pull no longer exports status.

  • Workflow guards read the full app-vars map. vars.KEY in runIf and successWhen CEL resolves every app config var, including synced vars, matching template rendering. Secrets stay declared-only.

  • Workflow document writes enforce the document model's field types. A value that violates a declared type (a number into a boolean field) is rejected with a remediation message instead of being stored raw; the same typing applies to upsertOn matching.

Fixed ​

  • Integration test cases run the request they declare. A test run executed GET / regardless of the case's documented method, path, body and inputVariables; it now runs exactly what the case's TOML declares, and a case's fixtures are its only attachments.
  • CLI: pushing a prompt with several configs works on the first push. A fresh multi-config prompt no longer fails with "Config name already exists" and lands without re-runs; the seeded config takes its authored name, and a config validation failure is attributed to the entry that caused it, activation included.
  • JavaScript client: a truncated documents.list() response no longer evicts documents. Eviction now follows the full scope walk instead of trusting a short page, so documents a client still has access to stay available locally.
  • CLI: config push adopts what local sync state never recorded. Webhooks, integrations, blob buckets, and group/collection type configs that already exist on the server are adopted by key instead of failing the push as conflicts; a genuine mismatch is reported naming both values.
  • CLI: cross-app config push keeps the target app's name. Pushing configuration to a different app with --only app no longer carries the source app's name, and a push that applied nothing leaves sync state untouched.
  • CLI: Swift codegen's --verify-project checks only its own group. A second Xcode group named "Generated" no longer causes false verification failures.
  • Reopening a long-lived document after a cold start is fast: stored updates are compacted durably instead of being replayed one at a time, and concurrent reconstructions of the same document are deduplicated.
  • Edits to localOnly documents are no longer transmitted to the server, in both the JavaScript and Swift clients.
  • Workflow runs are debuggable while in flight — run steps are visible before the run reaches a terminal state, child runs started by workflow.call appear in the runs list, and workflows runs failures shows the actual error for each failure and honors --limit.
  • Workflow forEach corrections: sources written as {{ }} templates resolve, checkpoints are scoped per iteration so iterations no longer collide, classified conflict error codes survive durable error capture, and a runIf-gated step downstream of a skipped step is skipped instead of failing on source resolution.
  • Workflow templates express boolean fallbacks correctly — || false, default:, and the boolean filter all handle false.
  • Workflow run status is reconciled on the server, so every client reports one consistent terminal status, and runs spawned per user by an iterate-users step finalize correctly.
  • Webhook hardening: deduplication keys derive from signed material and are domain-separated, so unsigned fields cannot forge duplicate suppression; JWT verification rejects tokens whose key id cannot be matched to a resolvable key; the test endpoint refuses to send an unsignable preview and verifies caller-supplied headers; and a duplicate webhook key returns a conflict error instead of failing opaquely.
  • Error-group analytics no longer mint a separate group for messages that differ only by embedded identifiers or numbers, distinct step and run failures are no longer collapsed together, timestamps are epoch seconds, JSON key casing is preserved, and each group carries an exemplar.
  • Adding a user by email is reliable: addresses are normalized across every provisioning path, transient conflicts are retried, and failures are descriptive instead of an opaque server error.
  • Secret references containing invisible formatting characters are rejected up front instead of failing confusingly later, and setting a configuration variable no longer misreports an unrelated write conflict as a duplicate key.
  • Legacy admin app routes enforce per-app access checks, and admin sessions can refresh their tokens.
  • CLI: config sync round-trips correctly — pull preserves [[include]] fragments and no longer writes workflow files a later push would reject, push lands a schema change together with an operation rewrite that depends on it, change detection agrees with diff and consults live server state, and diff detects webhook workflowKey and prompt accessRule changes.
  • CLI: init honors the configured server as well as the skip_install and dev_port keys, query-shaped operations execute results print in the standard list envelope, the upgrade hint names the package manager the CLI was installed with, and a vulnerable archive-extraction dependency is patched.
  • JavaScript client: device reachability no longer overwrites the app-set networkMode, challenge-driven token refreshes are capped so a refresh loop can no longer occur, switching accounts re-authenticates the connection as the new user, five missing event names are present on JsBaoEvents, and the published package no longer ships unresolvable source maps.
  • Swift client: the connection lifecycle now matches the JavaScript client — re-authentication mid-connection keeps long-lived connections alive through token rotation, an authentication failure triggers a token refresh instead of failing the connection, server-pushed frames are no longer dropped, sync state and presence reset on disconnect with exactly one disconnected status per close, stalled syncs recover through a watchdog, queued updates flush on open, and forcing a reconnect no longer tears the connection down.
  • Swift client: authentication is durable — sessions restore on cold start with leeway on token expiry, magic-link, one-time-code, and passkey sign-in connect automatically, concurrent unauthorized responses coalesce into a single refresh, failed refreshes retry on a backoff, a network outage during a refresh is no longer reported as an authentication failure, and signing out fully clears the session.
  • Swift client: the document lifecycle matches the JavaScript client — initial sync, the close and evict cycle, metadata frames, eviction on authoritative listings, permission bootstrapping at open time, documentLoaded and documentOpened firing once per open, and typed errors on an open timeout.
  • Swift client: writes are correct and ordered — outbound flushes are serialized and drained on send confirmation, typed save(in:) writes only the fields that changed, a zero-debounce save can no longer report synced before flushing, and documents with large externally stored updates load on a cold start.
  • Swift client: transactAndSync on an unopened document throws instead of crashing, an un-encodable URL path segment throws, reserved characters in URL values are percent-encoded so a plus-addressed email resolves the right user, reconnect backoff no longer overflows, removing all event handlers no longer deadlocks the event emitter, and documents.validateAccess and getRoot call real endpoints.
  • Swift client: opening a database no longer misreads existing data as empty, find and findByUnique return string-set members, and the in-app debug inspector's storage panel finds the local database again.
  • Admin console: analytics filters on the same field merge with operator-aware deduplication instead of stacking or overwriting each other, invalid filters surface the server's error instead of failing silently, secret-reference validation matches the server's rules, and the custom detached-signature webhook editor enforces the rules it states.
  • Starter templates and libraries: the Swift template's Xcode project pin stays in sync with the app's resolved packages and its development auth bypass is removed, both starter templates carry the same platform instructions, newly scaffolded Vue projects ignore .eslintcache and .wrangler, the Vue library accepts newer peer dependency versions and no longer ships unresolvable declaration maps, and the Swift client package ships consistent resolved dependencies.
  • Workflow database reads return real booleans instead of SQLite-raw 0/1.
  • Workflow templates: a terminal || 0 fallback resolves to a typed 0 instead of an empty string.
  • Duplicate Server-Timing headers on a response are merged into one.
  • JavaScript and Swift clients: connecting reconciles the whole document scope, documents deleted server-side are evicted from the local cache (bounded by what the listing walk actually proved), a walk that returns zero documents no longer evicts everything, and the reconciliation re-arms when the signed-in user changes.
  • Swift client: a NULL in a strictly-decoded boolean field no longer makes generated models drop the whole row.
  • Swift client: signing out closes every open document and empties the local record cache, so the signed-out account's data is never visible after the next sign-in, and a pending offline create stops retrying at logout.
  • iOS template: every build regenerates typed workflow invokers alongside model types through one codegen entry point, and an offline scripts/codegen.sh --check gate fails when the committed invokers are stale.
  • CLI: config diff and push agree on comment-only TOML edits and quoted-number values, a prompt push that fails partway can be retried, and analytics --help lists each command once.
  • CLI: a failing workflows codegen --check names the exact command that regenerates the stale files.

2026-07-24 ​

New ​

  • Conditional document writes. Single-document save, patch, and delete accept upsertOn, precondition (compare-and-set), and ifNotExists; bulk updates accept a per-operation precondition. A failed guard rejects the write with CONDITION_NOT_MET and rolls back the whole batch. Preconditions are field-equality only.
  • Multiple sign-in providers per account. A user can link more than one OAuth provider (for example Google and Apple); signing in with either resolves the same account.
  • Error-event analytics. Failures are recorded as fingerprinted error events in a dedicated dataset, queryable with the errors.groups query type — via the HTTP API, the workflow analytics step, or primitive analytics errors-groups.
  • Parameterized workflow fragments. A workflow fragment include accepts parameters, so one fragment serves multiple call sites.
  • primitive blob-buckets head reads a blob's metadata without downloading its content.
  • primitive connections list inspects an app's active client connections.

Changed ​

  • Unified list pagination. Every list endpoint returns { items, hasMore, nextCursor }. The previous cursor field and per-endpoint array keys remain as deprecated aliases for one deprecation window — migrate readers to items and nextCursor.
  • Stricter list-input validation. Zero, negative, or non-integer limit values and malformed cursors now return 400 instead of being silently coerced.

Fixed ​

  • The JavaScript client advances through every page of a list result instead of stopping after the first.
  • Users who linked a second sign-in provider are no longer locked out of the first.
  • A just-created document appears in the creator's owned-documents list immediately.
  • Group-membership reads paginate fully instead of silently truncating for users with many memberships.
  • CLI: date-time field values are serialized correctly on primitive config push, and workflow lock configuration ([workflow.lock]) survives config push and pull.
  • Swift client: waitForWriteConfirmation and waitForInSync wait for the write to actually be confirmed, and reconnect backoff no longer overflows during long outages.
Documentation validated against js-bao-wss-client 3.4.0 · js-bao 0.11.0 · primitive-admin 1.0.62 · primitive-app 3.1.0 — 2026-09-30