@voltro/i18n 0.62.3 → 0.64.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -39,6 +39,110 @@ _Changes staged for the next release accumulate here (rolled up from
39
39
 
40
40
  ---
41
41
 
42
+ ## [0.64.0] — 2026-09-04
43
+
44
+ ### ⚠ BREAKING
45
+
46
+ - **@voltro/ai, @voltro/cli** — `defineAgent` must now decide **who may talk to the agent** — `guards: [...]` or `openAccess: '<why>'` — and the decision applies to both synthesized routes (`<name>.send`, `<name>.messages`). An undecided agent refuses the boot under `security.defaultDeny` (kind `agent`, named with its file by `voltro doctor`). The routes used to carry a framework-written `openAccess` whose own wording admitted it served any session — anonymous included — so every agent was a free model-spend surface for anyone who could open the socket. Threads now have **owners**: the first `send` on a `threadId` opens the thread for the calling subject, and every later `send` / `messages` on it by another authenticated subject fails with the typed `AgentThreadAccessDenied` (in both routes' error unions). A thread opened anonymously has no owner and keeps its old reach. `_voltro_agent_threads` gains a nullable `subjectId` column, applied by the declarative differ on the next boot or `voltro db apply`; `runAgentTurn` and `loadAgentMessages` take the caller's `subjectId`.
47
+
48
+ **`voltro update` carries you across this** — codemod `0.64.0/02_agent-access-decision`. If you pin versions by hand and never run it, print the notes without changing anything: `voltro update --codemods-only --from <your current version> --dry-run` (this one ships in 0.64.0).
49
+ - **@voltro/plugin-atlassian** — `@voltro/plugin-atlassian`: `AtlassianCredentials` (and `connectionCredentials({ … })`) gain `confluenceBaseUrl`, and `ConfluenceService` is built on it — never on the Jira `baseUrl`, where `/rest/api/content` answers 404 on Data Center and on Cloud alike (Cloud serves Confluence under `/wiki`), so every Confluence call was reaching the wrong host. A credential without the field now fails every `ConfluenceService` method with a typed `ConfluenceError` (`code: 'not_configured'`). A **named** instance (`atlassianPlugin({ name })`) provides its services under per-name tags — `jiraServiceFor(name)` / `confluenceServiceFor(name)` — instead of the shared `JiraService` / `ConfluenceService`, which two named instances used to both provide with the last merged layer silently winning; the unnamed instance keeps the default tags. Migration: set `confluenceBaseUrl` where Confluence is used; reach a named site through `jiraServiceFor(name)`.
50
+
51
+ **`voltro update` carries you across this** — codemod `0.64.0/03_atlassian-confluence-root-and-named-instances`. If you pin versions by hand and never run it, print the notes without changing anything: `voltro update --codemods-only --from <your current version> --dry-run` (this one ships in 0.64.0).
52
+ - **@voltro/cli, @voltro/plugin-webhooks, @voltro/plugin-governance, @voltro/database** — The field cipher behind `.encrypted()` columns is registered by the framework, from `VOLTRO_FIELD_ENCRYPTION_KEY`, on both boot paths — `voltro dev` mints the key into `.env.local` like the inspect tokens, and `voltro serve` refuses to boot without it as soon as any `.encrypted()` column is declared, naming the columns. Until now the only registrar was `governancePlugin({ fieldEncryption })`, so a framework table could not encrypt a credential without making every app that installs its plugin also install governance — and `_voltro_webhook_targets.secret`, the key that signs every delivery, sat in plaintext for exactly that reason. It is `.encrypted().serverOnly()` now; rows written before still read, and `voltro db encrypt-column _voltro_webhook_targets.secret` converts them in place. `fieldEncryption: true` reads the same variable and changes nothing; `{ secretKey }` keeps its purpose (a key under another name) and still wins.
53
+
54
+ BREAKING for an app with `@voltro/plugin-webhooks` (or its own `.encrypted()` columns) and no cipher: production boot now requires the key.
55
+
56
+ **`voltro update` carries you across this** — codemod `0.64.0/05_field-encryption-key-framework-owned`. If you pin versions by hand and never run it, print the notes without changing anything: `voltro update --codemods-only --from <your current version> --dry-run` (this one ships in 0.64.0).
57
+ - **@voltro/protocol, @voltro/runtime, @voltro/testing, @voltro/plugin-auth-social, @voltro/plugin-sso-saml** — `PluginHttpRouteResult.headers` accepts an array per header: `set-cookie` arrays become separate `Set-Cookie` lines (a session and a CSRF cookie from one login), every other array joins with `, `. A record of single strings could not say it, so the second cookie overwrote the first.
58
+
59
+ `@voltro/plugin-auth-social` and `@voltro/plugin-sso-saml` — the two first-party routes that set two cookies from one login — answer with two lines now. sso-saml joined them with a comma, which a browser reads as ONE cookie, so the SLO companion never arrived; auth-social newline-joined them, an encoding no layer split.
60
+
61
+ BREAKING for tests: `@voltro/testing`'s route response `headers` values are `string | ReadonlyArray<string>` — a test that assigned one to a `string` narrows it (the codemod's note says how).
62
+
63
+ **`voltro update` carries you across this** — codemod `0.64.0/06_test-response-headers-arrays`. If you pin versions by hand and never run it, print the notes without changing anything: `voltro update --codemods-only --from <your current version> --dry-run` (this one ships in 0.64.0).
64
+ - **@voltro/protocol, @voltro/voltro, @voltro/workflow, @voltro/runtime, @voltro/cli, @voltro/plugin-audit, @voltro/plugin-rbac, @voltro/plugin-sentry, @voltro/plugin-ratelimit, @voltro/plugin-billing, @voltro/plugin-flags, @voltro/plugin-moderation** — A `workflow()` must now decide **who may start it** — `guards: [...]`, `openAccess: '<why>'`, or `internal: true` — and an app with an undecided workflow refuses to boot under `security.defaultDeny` (on by default), on `voltro dev`, `voltro serve` and `voltro doctor`. The start rpc a `*.workflow.tsx` becomes had no access decision at all: the definer had no `guards:` field, so nothing was enforced and every discovered workflow was startable by any socket, anonymous sessions included. Guards use the mutation vocabulary verbatim (a resource-scoped guard reads its id from the start payload); `internal: true` removes the start rpc from the client group and both boot paths, leaving server-side starts (`ctx.workflows.start`, schedules, event triggers) untouched. The start now runs inside a new **`interceptWorkflow`** plugin chain (`RpcKind` gained `'workflow'`, permission `'rpc:intercept:workflow'`): audit records starts, rate limiting counts them, billing's entitlement gate covers them, and rbac publishes role scopes so a role-derived guard can pass. The start lifter `workflowToRpc` moved from `@voltro/runtime` to `@voltro/protocol` (browser-safe) and is now the ONE lifter `voltro dev`, `voltro serve` and the generated client group all call — the start payload is `strictInput`-checked on every path and a guarded workflow advertises `ScopeError` / `Unauthenticated` in its wire error union, so a refused start decodes as a typed failure rather than an `ExitEncoded` defect. Migration: run `voltro doctor`, decide each listed workflow; an app plugin declaring `interceptAction` adds `interceptWorkflow` for the same coverage.
65
+
66
+ **`voltro update` carries you across this** — codemod `0.64.0/01_workflow-access-decision`. If you pin versions by hand and never run it, print the notes without changing anything: `voltro update --codemods-only --from <your current version> --dry-run` (this one ships in 0.64.0).
67
+ - **@voltro/protocol, @voltro/runtime, @voltro/voltro** — A workflow start whose idempotency key was already SPENT — a run under that key had finished — now returns `status: 'replayed'` on the handle (with the old run's id) and warns once per key, instead of `running` with the old run's id, which was indistinguishable from a fresh start. A schedule keyed per day ran once and reported every later firing as a start; the replay is now visible where it happens. `WorkflowRunHandle.status` gains the literal — an exhaustive switch over it needs the arm. The lookup is best-effort: a run store that cannot answer leaves the handle as before.
68
+
69
+ **`voltro update` carries you across this** — codemod `0.64.0/04_workflow-handle-replayed`. If you pin versions by hand and never run it, print the notes without changing anything: `voltro update --codemods-only --from <your current version> --dry-run` (this one ships in 0.64.0).
70
+
71
+ ### Added
72
+
73
+ - **@voltro/cli** — `voltro agents-md` reports a nested `AGENTS.md` / `CLAUDE.md` pair whose contents differ (both sizes, both ways out: add the keep marker to the one you maintain and copy it, or `--force` to re-seed both). The seeder writes one content under both names and never overwrites without `--force`, so a pair diverges only by hand — and then every tool reading the other name gets guidance nobody wrote. Reported rather than symlinked: a symlink is not a file to `COPY`, to Windows, or to a tool that refuses to follow one. `EnsureResult.diverged` carries it.
74
+ - **@voltro/ai** — `aiStep` / `aiObjectStep` take `tools`, `maxSteps` and `toolContext`: the whole LLM↔tool loop runs as ONE durable step — one journal entry, one retry policy, one usage row summing every round-trip (`totalUsage`, not the last step's). `generateText` gained the same tool loop for a text answer. A durable tool loop had to leave the step for the inline `generateObjectWithTools` before, and lost the journal and the per-step spend on the way out.
75
+ - **@voltro/cli** — Without `auth.anonymousTenantRequired`, an anonymous request resolves to the tenant its own `x-tenant` header names (or the development fallback tenant) — a convenience for smoke flows that is, under `NODE_ENV=production`, a client-chosen tenant on every unauthenticated read of a non-`tenant()` table. Both boot paths now say so once at boot and name the field; the request path is unchanged.
76
+ - **@voltro/plugin-atlassian** — `atlassianPlugin({ confluenceCredentialsResolver })` resolves the caller's Confluence credentials separately from Jira's. On Cloud one token serves both products; on Data Center a Jira PAT is not a Confluence PAT, and a Confluence service built on the Jira resolver cannot authenticate at all. Absent, the Jira resolver serves Confluence as before.
77
+ - **@voltro/plugin-atlassian, @voltro/protocol, @voltro/cli** — `atlassianPlugin({ cache: true })` memoises reference-data reads (board configuration, project, field and status catalogues) into the FRAMEWORK's cache — the backend `CACHE_BACKEND` selects, in-process or Redis across replicas — handed over at bind time; `{ ttlMs }` tunes the per-namespace TTLs on it, and `{ store }` still memoises into a store of your own. The option used to need a hand-built store object and the docs named no way to obtain one. Behind it, `PluginBindContext` gains `cache` (get/set on the framework cache), which both boot paths hand to every plugin's `bindDataStore`.
78
+ - **@voltro/plugin-atlassian** — `@voltro/plugin-atlassian` exports `jql` — the JQL escaping helpers (`str`, `field`, `key`, `keyList`, `strList`) — and the `JiraIssueKey` schema, so a value interpolated into `searchIssues(...)` cannot close the literal it sits in or smuggle an `OR` into the query; keys are validated at the call site, before anything is sent.
79
+ - **@voltro/plugin-audit** — `auditPlugin({ metadata })` — a per-call resolver for the `metadata` column, which existed and which nothing ever set. It runs AFTER the call with the tag, input, subject and the outcome (`ok` with the value, or `error`), so an app can write the sentence a diff cannot contain ("Anna removed Bernd from the Frontend team"); it may be async, and a throw or `undefined` records no note and never fails or delays the call.
80
+ - **@voltro/database, @voltro/plugin-audit, @voltro/plugin-row-history** — `_voltro_audit_log` and `_voltro_row_history` gain `scopeKey` — the app's `resolveScope` value as a bounded, indexed text (`scopeKeyOf`: a string scope is its own key, anything else canonical JSON with sorted keys) beside the opaque json `scope`, plus `auditByScope(store, scope, { status?, limit? })` and `historyByScope(store, scope, tenantId, limit)` (+ Effect twin) that address rows through it on the `byAuditScope` / `byRowHistoryScope` indexes. The json column was documented as indexed and was not — json is not an access path on any dialect — so "everything that happened to team X" was a full scan, and the row-history reader dropped `scope` and `actor` on the floor; both are returned now. `scopeKeyOf` is exported from `@voltro/database`.
81
+ - **@voltro/protocol, @voltro/cli, @voltro/plugin-deactivation** — `auth.subjectGuards` in `app.config.ts` — post-authentication vetoes at the framework level, where EVERY subject passes: after a strategy matches and `resolveScopes` has applied, on the request path and on a durable workflow's execution. A guard receives the subject as it will run plus the same context `resolveScopes` gets, returns `{ ok: true }` or `{ ok: false, code, message }`; the first rejection fails the request closed with `Unauthenticated` carrying both. The auth plugin's `subjectGuards` run at login only, so an app whose subjects arrive through its own strategy chain (a JWKS IdP, an API-key table, a self-minted session) had no place to veto a deactivated account — `users.deactivate` locked no one out. Vetoes are cached with the scope decision (same key, same window, same `invalidate` handle); `applyScopeDecision` throws on a vetoed decision so a caller that forgets the branch fails closed. `@voltro/plugin-deactivation/guard` ships `deactivationSubjectGuard({ table })` for this seam.
82
+ - **@voltro/plugin-broadcast** — The broadcast bus reports three Effect metrics, exported with the framework's other counters: `voltro_broadcast_connected{provider}` (a gauge — `1` while this replica holds a live subscription, `0` while it does not), `voltro_broadcast_gaps_total` (gaps recovered from) and `voltro_broadcast_missed_total` (messages serial accounting proves were missed). A broker outage used to be a silent UI — local reactivity kept working, only the other replicas' changes stopped arriving — and the gauge dropping to 0 is the alarm that search was missing.
83
+ - **@voltro/plugin-broadcast** — The broadcast bus counts transport reconnects (`voltro_broadcast_reconnects_total{provider}`, beside `_connected`, `_gaps_total`, `_missed_total`), logs each one with its replica identity and last-received timestamp, and exposes `health()` on the handle — provider, channel, replica id, connected, last received / published, reconnects, gaps, missed — for a readiness probe or a support dump. With two replicas and CDC off the bus is part of correctness, and an outage that loses cross-replica updates without closing a socket had no number to point at.
84
+ - **@voltro/cli** — `voltro db plan --check` exits 1 when ANY operation is pending — the CI schema gate a bare `db plan` cannot be, since a preview that failed would make "show me the diff" and "the diff is empty" the same exit code. The verdict names the count ("3 pending operations"); `--json` still emits the plan. A blocked plan keeps exit 2.
85
+ - **@voltro/integration-http** — The per-host retry budget reports itself: `voltro_http_retries_total{host}` counts retries that went out, `voltro_http_retry_budget_exhausted_total{host}` the ones the budget refused. A retry storm and a budget that has closed the door on a host were both invisible from a span — there is one span per logical request, whatever it took to answer.
86
+ - **@voltro/i18n** — `LOCALE_COOKIE`, `THEME_COOKIE` and `TIMEZONE_COOKIE` are exported from `@voltro/i18n/server` as well as from the root. A server that sets or clears one of those cookies names it, and the root entry carried the browser hooks along for two strings.
87
+ - **@voltro/data-transfer** — Masking action `{ json: { fields, rest? } }` masks INSIDE a json value by key: an array masks each item, an object applies `fields[key]` to the keys it names and `rest` to every other one — `rest` defaults to `'redact'`, so a key the policy did not think about is never copied through. Nested `{ json }` descends; a `custom` transform inside sees the path as its `column`. A json column was one scalar to the export before: `null`, `[redacted]`, or a verbatim copy of every person in it.
88
+ - **@voltro/runtime** — `ctx.store.one(query, onMissing?)` and `select(table)….one(onMissing?)`: the factory receives the `{ table, found }` that `NoRowFound` carries and whatever it returns is thrown instead. A contract that already declares its own not-found error keeps declaring it, without a `maybeOne` + null branch at every read that only existed to translate the framework's error into the app's.
89
+ - **@voltro/protocol, @voltro/runtime, @voltro/cli, @voltro/plugin-audit** — Plugins gain `onHttpRoute` — a fire-and-forget observer of every plugin HTTP route call after it answers (method, path, status, duration; permission `'http:intercept'`, the other end of the pipeline `onHttpRequest` opens). A login, a logout or an avatar fetch served by a plugin's `httpRoutes` answered outside every rpc interceptor, so a trail that recorded every mutation had no row for the one call an incident review asks about first. `@voltro/plugin-audit` implements it: `http.<METHOD> <path>` with the status, no subject (a plugin route is not part of the rpc pipeline and nothing upstream authenticated its caller). Both boot paths compose the hooks through one builder and hand them to the server.
90
+ - **@voltro/database, @voltro/runtime, @voltro/cli** — `reference(target, { onSoftDelete: 'cascade' | 'setNull' })`. A soft delete is an `UPDATE … SET deletedAt`, so `ON DELETE` never fires for it and the children of a soft-deleted parent stayed exactly where they were. The runtime enforces this one on the store's post-commit change channel through the same engine `pluginRef` orphan rules use — after the parent's update commits, once per replica, chaining through children that emit their own change. `'cascade'` is refused at boot when the referencing table lacks `softDelete()` (the engine would hard-delete children a restore could never bring back); `'setNull'` needs a nullable column. Restoring the parent does not restore the children.
91
+ - **@voltro/database, @voltro/runtime** — `.forUpdate()` on both query builders — `database.t.where(…).forUpdate()` and `ctx.store.select('t').where(…).forUpdate()` — marks the descriptor `lock: 'update'` and the shared SELECT compiler emits the dialect's row lock: `FOR UPDATE` on postgres / mysql / mariadb, `WITH (UPDLOCK, ROWLOCK)` on mssql, nothing on sqlite (its writers are serialised already). The matched rows stay locked until the enclosing transaction commits, so a read-modify-write — an append into a json column, a counter, a merge — keeps every writer's change instead of the last one's. `.version()` refuses the stale writer; this makes the second writer wait. A set operation ignores it.
92
+ - **@voltro/database** — `selfReference(options?)` — a reference column pointing at the table it is declared in. `reference(() => teams)` inside `teams`' own declaration is a TypeScript cycle (TS7022), so `parentId` columns ended up as bare `text()` with no foreign key the schema knew about. The new column carries no target type; `table()` resolves its placeholder to the table being built, so the FK emitter, the eager loader and the subject graph see it as the reference it is.
93
+ - **@voltro/plugin-sentry** — `captureFailures: 'infrastructure'` — the preset between `false` (the default: no declared failure reaches Sentry) and `true` (every one does). A failure whose tag says infrastructure broke — `StoreOperationFailed`, `SqlError`, `ResultLengthMismatch`, `FieldDecryptionError`, `RequestError`, `ResponseError`, `CacheError`, `WorkflowAuthorityUnavailable` — is reported even though a handler declared it, because a declared database outage is still a database outage; every other declared failure stays out. Both halves take it (`initSentryBrowser({ captureFailures })` resolves the same predicate), and the boot line names the preset.
94
+ - **@voltro/database, @voltro/cli** — A `voltro dev` boot and `voltro db plan` / `db apply` warn about soft-drop snapshots (`<name>__dropped_<ts>`) older than the review window, naming each one and printing the exact `voltro db gc-snapshots --before <date>` to run — computed from the stamp in the name, so it costs no query beyond the introspection that already ran. A snapshot is a full copy of the dropped data, PII included, and reclaiming it was a step an operator had to remember while the soft-drop advice promised "~7d". The window is `database.softDropRetentionDays` in `app.config.ts` (default 7).
95
+ - **@voltro/runtime, @voltro/cli** — The api now says when a subscription's consumer stops acknowledging chunks. `@effect/rpc` waits for the client's `Ack` of each `Chunk` before writing the next one, so a hand-rolled socket client that only listens receives the initial snapshot and then nothing, however many writes land — while the producer, the matcher and the registry all report a healthy subscription, because they are. A delivery the transport has not accepted within `reactive.socket.stallWarnAfterMs` (default 5 s, env `VOLTRO_REACTIVE_STALL_WARN_AFTER_MS`) is logged at WARN naming the subscription and the missing `Ack`, and counted in `voltro_subscription_stalled_total{tag}`; against `voltro_subscription_deliveries_total{kind="delta"}` that separates "the server delivered" from "the socket handed it over". The stream buffer between the outbox and the socket is one event deep now (was 16), so updates coalesce onto the newest state sooner and the pump can see the stall at all. Computed subscriptions ride the same outbox: they used to hand every recomputed snapshot to the stream fire-and-forget, so a stalled consumer retained each one. All three binders share one `pumpOutbox`. The wire-protocol page documents the flow control and a raw-client checklist. Golden churn: `ReactiveSocketTunables` gains a required `stallWarnAfterMs` (constructed only by the CLI's resolver and tests), `SubscriptionOutbox` is generic in its payload, and `pumpOutbox` / `bindComputedUntyped` / `recordStalled` are new exports.
96
+ - **@voltro/testing** — `makeVoltroTestClient` drives `useConnectionStatus`: `setConnectionStatus(status, facts?)` sets the coordinator's presentation state (`connected` / `recovering` / `degraded`) and the facts beside it — failure count, failed subscriptions, pending replays, raw transport — and components re-render like on a real gap. `refreshes` counts what `useRefreshSubscriptions` asked for. A disconnect banner and a retry button had states no test could reach except by mocking the whole client module.
97
+ - **@voltro/testing** — `makeVoltroTestClient().emitRpcError({ source, tag, error, kind, outcome? })` puts an rpc error onto the api's error bus — `kind: 'transport'` with `outcome: 'unknown'` is the raw channel a socket cut produces, `kind: 'handler'` a typed failure the presentation layer shows. A banner that subscribes through `useOnRpcError` had no way to be handed either without mocking the whole client module.
98
+
99
+ ### Changed
100
+
101
+ - **@voltro/cli, @voltro/ai** — The model-provider key is checked at BOOT: when `AI_PROVIDER` names a real provider and its key variable (`ANTHROPIC_API_KEY` / `OPENAI_API_KEY` / `AI_GATEWAY_API_KEY`) is unset or empty, `voltro serve` refuses to start and `voltro dev` warns. The provider SDK used to read the key at the first model call, so an unset or rotated key surfaced hours after a deploy that reported healthy, from inside the SDK. An app that supplies every key in code (`model: { apiKey }`) opts out with `ai: { requireProviderKey: false }` in `app.config.ts`. `@voltro/ai` exports `providerKeyEnvVar(provider)` — the one map the gate and the provider share.
102
+ - **@voltro/cli** — `voltro doctor`, corrected where its rules assumed the framework's own names:
103
+
104
+ - `subject-write-no-guard` reads the app's guard vocabulary (every exported guard, not a fixed list of the framework's names) and accepts an explicit null guard on `subject.id` or an alias of it — a fixed list reported every check an app writes under its own names as absent. - new `open-reason-unverified`: an `openAccess` reason that names a guard from the vocabulary is checked against the paired executor; a reason that convinced a reviewer and was false is the finding. - the authz scan reads an executor's local helper imports (two hops) as part of the executor, so a `database.<table>` read in `lib/` is attributed to the executor that calls it instead of to nobody. - new `input/uncapped-array`: `Schema.Array` in a mutation or action input with no `maxItems` — the body limit bounds bytes, not writes. - new `cron/bucket-on-wall-clock`: a cron that keys its period on `firedAt` / `Date.now()` / `new Date()` and never reads `scheduledAt`. - a sensitivity report: every text/json column carrying neither `.sensitive()` nor `.safe()`, named before a masked export refuses it; reviewed exceptions in `doctor.sensitivity.allow`. - `convention/missing-test` covers executors (`*.{query,mutation,action, stream}.server.ts`, `*.workflow.server.tsx`), with the test beside either half of the pair and a fix that points at `invoke` from `@voltro/testing`. - `hardcoded-framework-cookie` ignores comments; `ui/write-hook` follows a `.hook.ts` import one hop, so a UI component writing through its own hook is reported as the write it is.
105
+ - **@voltro/cli** — Three `voltro doctor` scans, each corrected where its premise was narrower than the code it read:
106
+
107
+ - the never-emitted-events scan resolves a DEFAULT-exported descriptor through its importers' names (`import updating from '…event'` → `ctx.events.publish(updating, …)`); every default-exported event read as never emitted before. - `n-plus-one` reports a read inside a loop or per-item callback outright, and otherwise counts only INDEPENDENT reads — a chain where each key comes from the previous row (registration → setup → access code) is an assembly `Effect.all` cannot run and is no longer called an N+1 you are writing. - `hand-serialized-date` runs on executors only — not on `lib/` helpers building predicate bounds, not on web components, where its fix is unfollowable — and skips a property the paired descriptor declares `Schema.String`: an ISO contract chosen on purpose is not a Date leaking out. - new `mutation/target-op-mismatch`: a mutation whose `target: { table, op }` promises one write verb while its executor performs another on that table — the descriptor drives the optimistic patch, the invalidation and the audit row, so the disagreement was silent and wrong three ways. - new `bulk-write-in-loop`: `insert` / `insertIgnore` / `upsert` inside a loop or per-item callback, where `insertMany` / `insertManyIgnore` / `upsertMany` is one statement per batch.
108
+ - **@voltro/cli** — `voltro doctor`'s `renderMode:'spa'` candidates carry a `reason` line each — what the page costs today (a server render and hydration of a leaf whose data arrives client-side anyway) and what `'spa'` changes (the layout shell stays server-rendered; only the leaf drops to the client) — so the decision can be made from the report rather than from re-reading every page.
109
+ - **@voltro/database, @voltro/runtime, @voltro/cli** — An `.encrypted()` value is bound to where it lives. AES-256-GCM now takes `table:column:rowId` — or `table:column` when the row's identity is not known at write time — as additional authenticated data, and the envelope records which (`enc:v2:r:` / `enc:v2:c:`). A ciphertext was a bearer value before: whoever could write the database could copy one subject's encrypted token into another subject's row and let the app, acting for the second, decrypt and use the first's credential — no key needed, the app is the deputy that holds it. Bound, the copied value fails authentication in its new home, in another row and in another column alike. Set-based `updateMany` writes that touch an encrypted column are expanded per row so each row gets its own binding; a projected read that omits `id` on such a table fetches it and strips it, so the binding can be verified. The connection vault binds its tokens and PKCE verifiers the same way, and workflow step payloads are column-bound. Rows written before (`enc:v1:`) still read; `voltro db encrypt-column` re-binds them in place, resumably, alongside the raw-encoding normalisation it already did.
110
+ - **@voltro/i18n** — `useFormatDate` and `useRelativeTime` accept `null` / `undefined` and render `options.fallback` (default `''`) for them and for a value that is not a date — a nullable column reaching a component as `null` used to render the Unix epoch (`new Date(null)`), so every unguarded call showed 1.1.1970 for an open row. `formatDateValue` is the pure half, exported. The golden churn is the two hooks' `value` parameter widening from `DateInput` to `MaybeDateInput` and `options` gaining `fallback` — every existing call still compiles.
111
+ - **@voltro/integration-http, @voltro/plugin-atlassian** — `@voltro/integration-http` retries transient failures for the idempotent methods only — `GET` / `PUT` / `DELETE` (`policy.retryMethods`); a `POST` is no longer retried by default, because a proxy answering 502 after the origin has committed the write turns a retry into a duplicate (a duplicated Jira `createIssue` is the case). Such a failure still surfaces `transient: true`, with "not retried: POST is not idempotent" in the message; a call the caller has made idempotent passes `retry: true` on `request` (`retry: false` disables retries for any call). Retries are also bounded per host by a retry budget (`policy.retryBudget`, default 20 % of the trailing 10 s window's requests plus a floor of 10, per process; `false` disables it): every first attempt still goes out, only the amplification is capped, which is why it is a budget rather than a circuit breaker. `retryBudgetSnapshot(host)` reads the ledger. The Atlassian docs also stop claiming a 401 carries `code: 'session_expired'` — it has been `'unauthorized'` since the rename. The golden churn is `RequestArgs.method` spelled through the new `HttpMethod` alias — the same four literals, no call-site effect.
112
+ - **@voltro/protocol, @voltro/runtime, @voltro/client** — A replay whose key is still being executed by the first call is the typed `MutationInFlight { traceId, retryAfterMs }` on every mutation's wire union — it was `Effect.die`, presented client-side as the handler having failed while the write was committing, for exactly the handler that dies mid-socket-cut: the long one. `useMutation` now waits the server's `retryAfterMs` inside the replay window and replays the same key once more; REST keeps answering 409.
113
+ - **@voltro/database, @voltro/runtime, @voltro/protocol, @voltro/cli, @voltro/testing, @voltro/sql-mysql, @voltro/sql-postgres, @voltro/sql-mssql, @voltro/sql-sqlite, @voltro/sql-turso** — A read-modify-write inside a mutation no longer loses the second writer's edit. "It runs in a transaction" protects it on no engine at the default isolation — a plain SELECT takes no row lock — and what the engine did about the stale write ranged from an opaque `Failed to execute statement` (MariaDB 11.6.2+, `ER_CHECKREAD`) to applying it in silence (MySQL, Postgres, SQL Server, SQLite). Five changes, one shape:
114
+
115
+ - **Inside a transaction, a read is an expectation.** A keyed `ctx.store.update` of a `.version()` row that the same transaction has read carries the version it read, without the caller sending one; a row that moved in between is a conflict the mutation runner replays (the handler re-reads, like a deadlock replay), and only after the bounded replays does the typed `VersionConflict` surface — now on every mutation's wire union, like `MutationInFlight`. A row the transaction never read stays last-write-wins; a caller's own expectation is still answered, never replayed. An insert that omits the version column starts the row at `INITIAL_VERSION` on every store. - **The transient-contention list lives once**, in `@voltro/database` (`transientContention.ts`), read by every dialect's `retryFilter` and by the mutation runner; MariaDB's `ER_CHECKREAD` (1020) is on it. The filters also expand a `FiberFailure` before looking for a code — a statement that failed inside the transactional view rejected with one, and the store's own replay never saw the deadlock it was written for. - **`ctx.store.raw` exists inside a mutation**, on the transaction's own connection (every SQL dialect's transactional view carries it), so a `SELECT … FOR UPDATE` spelled by hand locks for the rest of that transaction. The docs had promised it on every SQL store; the view a mutation receives had none. - **Set-based writes on a transaction are one statement.** The mysql/mariadb, mssql and sqlite transactional views decomposed `updateMany` / `deleteMany` into a SELECT of ids and keyed writes, so the predicate was evaluated by a snapshot read rather than by the write — the version compare-and-swap then matched a row another transaction had already moved. They now run the same predicate-carrying statement the pool path (and postgres) always did. - **The replay bound is the deployment's.** `mutations.replayAttempts` in `app.config.ts` (default 3, env `VOLTRO_MUTATION_REPLAY_ATTEMPTS`), one resolver on both boot paths and in `@voltro/testing`'s `invoke`. The runner resets its post-commit queue per run of the transaction body, inside the store's bracket — that bracket replays the body itself now, and a reset outside it would have queued the aborted body's `afterCommit` work twice. - **`voltro doctor` names the handlers** that read a row, rebuild it and write it back with a keyed `update` on a table without `.version()` (`mutation/read-modify-write-unversioned`), so the tables that need the column are a report. - The concurrency page carries the per-engine matrix, the three protections, what the read-as-expectation does not see, and when a row lock beats a replay.
116
+ - **@voltro/cli** — `voltro test <directory>` naming a tree that contains no tests now FAILS (exit 1, "No test files found") instead of passing — an explicit directory is the same statement of intent as an explicit filter, and a CI line pointing at the wrong tree was green forever. The bare `voltro test` (cwd as root, nothing named) still passes with nothing to run.
117
+ - **@voltro/workflow, @voltro/cli** — A queued workflow start that fails to drain is retried with backoff — one second, doubling, capped at five minutes, jittered — instead of on every tick, so one undrainable intent no longer produces a warning every two seconds on every pod. After `workflows.deadLetterAfterAttempts` failed attempts (default 50 — several hours under that backoff; `false` disables it) the intent is dead-lettered exactly like a permanently invalid payload: `onFailure` once, the row retained for `voltro workflows flow`, re-armable with `flow retry`. The backoff rides the intent's `dueAt`, the field the drain window already selects on.
118
+
119
+ ### Fixed
120
+
121
+ - **@voltro/ai** — `runAssistant` — and with it every synthesized `<agent>.send` turn — records its terminal token usage into `_voltro_ai_usage` (`operation: 'agent.turn'`, attributed to the agent's name). A tool loop is a model call like any other, and the agent turn was the one path that spent without recording: `aiStep` records, the turn wrote nothing, so an app whose model sites are mostly tool loops could show zero spend. `recordUsage: false` keeps the ledger untouched for a caller that records elsewhere; `agent` names the row.
122
+ - **@voltro/client** — An optimistic patch whose server echo arrived **before** the mutation's own response is now retired at confirmation instead of five seconds later. The server emits a change at commit and answers the mutation only after the handler returns, so on a shared socket the echo is routinely queued ahead of the `Exit`, and a handler that keeps working after its write (an event publication, an audit row) widens the gap. The cache credited an echo only when it landed AFTER `confirmByMutation`; an earlier one left the patch unconfirmed, the confirmation then armed the resync window, and every such write ended in the `no superseding server event` warning plus a full re-fetch of a subscription that was already showing the truth. Each entry now counts base movements (every delta; a snapshot only when it changed the rows), each patch carries the count it went on at, and confirmation retires a patch whose base moved since — through the same door a later echo uses. A movement that preceded the patch, or a snapshot that repeated the base, still does not count.
123
+ - **@voltro/cli** — `voltro doctor`'s `page-without-test` hint named the retired `users/[id].page.tsx` convention; it now shows `users/[id]/page.tsx → users/[id]/page.test.tsx`.
124
+ - **@voltro/cli, @voltro/database** — `expires()` tables are registered with the retention sweep on both boot paths — on `expiresAt`, on every dialect, after a grace of `VOLTRO_EXPIRED_ROWS_TTL_HOURS` (default 24) — so an expired row is physically deleted as the docs said. Nothing registered them before: the mixin's header said the storage half was "postgres only" and the docs said "every dialect, eventually", and both described a registration that did not exist. Reads filtered immediately throughout; this is the storage half of that promise.
125
+ - **@voltro/i18n** — `assertCatalogParity` reads ICU ARGUMENTS by walking the braces instead of matching every `{` — the first word of a plural or select BRANCH (`{count, plural, one {extended by 1 day} …}`) was reported as a placeholder, so two catalogs that agreed on every variable failed parity on their prose. Arguments declared inside a branch (`{gender, select, female {{name} …}}`) still count; ICU quoting is honoured. `placeholdersOf` is exported.
126
+ - **@voltro/cli** — The start bundle (`.framework/dist-web/startBundle/`) and the api bundle (`.framework/dist-api/`) carry a `package.json` with `"type": "module"`. Both are ESM in `.js` files; under an app whose package.json is CommonJS-typed or untyped, Node reparsed the entry on every start and warned (`MODULE_TYPELESS_PACKAGE_JSON`). The artefact describes itself now; the app keeps its own module mode.
127
+ - **@voltro/cli** — `voltro serve` now unions plugin-declared error schemas (`errorSchemas` on a plugin) into every lifted rpc's wire error, as `voltro dev` always did and as the generated client group already expected. Before, a plugin interceptor failing with its declared error (a rate-limit `RateLimited`, an entitlement refusal) reached a dev client typed and crossed the production wire as an untyped defect. One derivation (`pluginExtraErrors`) feeds both boot paths now.
128
+ - **@voltro/runtime** — A `.version()` update is now a real compare-and-swap: the UPDATE carries the version it read as a predicate (`WHERE id = ? AND version = <read>`) and the affected-row count decides, on every dialect and at any isolation level. It used to compare the version in memory and then write by key, so two writers that read the same version both landed and the first change was silently lost — inside a transaction too, since a plain SELECT takes no row lock under READ COMMITTED. A write with no expectation that loses its swap is re-read and re-applied (bounded) and surfaces `VersionConflict` only when the row stays contended; a set-based update (`ctx.store.updateMany`, the fluent `.set()`) on a versioned table now advances every matched row's version by one instead of leaving it still.
129
+
130
+ ---
131
+
132
+ ## [0.63.0] — 2026-09-03
133
+
134
+ ### ⚠ BREAKING
135
+
136
+ - **@voltro/client, @voltro/runtime, @voltro/cli, @voltro/web, @voltro/react-native** — Short transport gaps now have one bounded recovery contract. Raw diagnostics remain immediate, while `useConnectionStatus` exposes `recovering` during a configurable grace window and `useOnRpcError` emits exactly one visible transport failure only if the gap remains open. Same-subject runtime swaps keep subscription bases, revisions and optimistic patches; subject changes discard them. Interrupted mutations report `outcome: unknown` and replay once with the same key only after the deployed API proves that server idempotency is active. The codemod explains the new status member and the raw versus presentation error channels.
137
+
138
+ **`voltro update` carries you across this** — codemod `0.63.0/01_recovery-presentation`. If you pin versions by hand and never run it, print the notes without changing anything: `voltro update --codemods-only --from <your current version> --dry-run` (this one ships in 0.63.0).
139
+
140
+ ### Fixed
141
+
142
+ - **@voltro/database, @voltro/sql-turso, @voltro/sql-sqlite, @voltro/sql-mssql** — Framework-table evolution now converges under real SQLite and both local Turso transports, and has a live MSSQL fixture. The new Turso runs also fixed internal table protection, boolean/number introspection normalization, and case-insensitive SQLite-family index-name comparison. The MSSQL fixture exposed and fixes a second convergence bug: unbounded text that MSSQL auto-bounds for an index is now rendered and compared with the same indexed-column rule, so a second plan no longer tries to widen an indexed `NVARCHAR(450)` to `NVARCHAR(MAX)`.
143
+
144
+ ---
145
+
42
146
  ## [0.62.3] — 2026-09-03
43
147
 
44
148
  ### Added
package/dist/index.d.ts CHANGED
@@ -278,6 +278,15 @@ export declare type LooseTFunction = (id: string, values?: Readonly<Record<strin
278
278
 
279
279
  export declare const makeSsgWrap: (locale: string, messages: MessageCatalog, defaultLocale: string) => ((children: ReactNode, request?: SsgWrapRequest) => ReactNode);
280
280
 
281
+ /**
282
+ * What a date formatter accepts: a date, or NOTHING. A nullable column reaches
283
+ * a component as `null`, and `new Date(null)` is the Unix epoch — so a row
284
+ * without a `closedAt` rendered "1.1.1970" in every app that did not guard the
285
+ * call, and eighteen guards were counted in one. `null` / `undefined` render
286
+ * the `fallback` (default `''`), and so does a value that is not a date at all.
287
+ */
288
+ declare type MaybeDateInput = DateInput | null | undefined;
289
+
281
290
  /**
282
291
  * The positional `values` a message key requires: none for a plain message, a
283
292
  * required record for one with placeholders, and a loose optional bag when the
@@ -441,23 +450,9 @@ export declare type TypedTProps<C extends MessageCatalog> = Omit<ComponentProps<
441
450
  /** Convenience: currency formatting with the ISO code up front. */
442
451
  export declare const useFormatCurrency: (currency: string) => ((value: number, options?: Intl.NumberFormatOptions) => string);
443
452
 
444
- /**
445
- * Locale-aware date/time formatting bound to the active locale AND the active
446
- * zone.
447
- *
448
- * The zone comes from the provider (`useTimeZone`), which the framework fills
449
- * from `app.config.ts`'s `timeZone` — either a fixed IANA name for everyone, or
450
- * `'viewer'` to resolve each request against the signed-in user's own zone.
451
- * Passing `timeZone` in the options overrides it for one call, which is what a
452
- * value that genuinely belongs to a fixed zone wants (a store's opening hours,
453
- * a scheduled broadcast).
454
- *
455
- * **With no zone pinned anywhere, each runtime uses its own** — the pod's on the
456
- * server, the viewer's in the browser. That is fine for a client-rendered page
457
- * and is a hydration mismatch under SSR, because the two are not the same zone
458
- * and across midnight not even the same day.
459
- */
460
- export declare const useFormatDate: () => ((value: DateInput, options?: Intl.DateTimeFormatOptions) => string);
453
+ export declare const useFormatDate: () => ((value: MaybeDateInput, options?: Intl.DateTimeFormatOptions & {
454
+ readonly fallback?: string;
455
+ }) => string);
461
456
 
462
457
  /** Locale-aware number formatting (decimals, percent, compact, currency). */
463
458
  export declare const useFormatNumber: () => ((value: number, options?: Intl.NumberFormatOptions) => string);
@@ -502,8 +497,9 @@ export declare const usePlural: () => ((count: number, forms: PluralForms, optio
502
497
  * the HTML and the first client render, and does not stay frozen afterwards.
503
498
  * Pass `{ now }` to state the base yourself; that always wins.
504
499
  */
505
- export declare const useRelativeTime: () => ((value: DateInput, options?: Intl.RelativeTimeFormatOptions & {
500
+ export declare const useRelativeTime: () => ((value: MaybeDateInput, options?: Intl.RelativeTimeFormatOptions & {
506
501
  readonly now?: DateInput;
502
+ readonly fallback?: string;
507
503
  }) => string);
508
504
 
509
505
  /**
package/dist/index.js CHANGED
@@ -69,19 +69,23 @@ var w = ({ locale: e, messages: t, defaultLocale: n, silenceMissingTranslations:
69
69
  }, j = () => {
70
70
  let e = D();
71
71
  return c((t, n, r) => A(e, t, n, r), [e]);
72
- }, M = (e) => e instanceof Date ? e : new Date(e), N = () => h().timeZone, P = () => {
73
- let e = D(), t = N();
74
- return c((n, r) => {
75
- try {
76
- return new Intl.DateTimeFormat(e, {
77
- ...t ? { timeZone: t } : {},
78
- ...r
79
- }).format(M(n));
80
- } catch {
81
- return String(n);
82
- }
83
- }, [e, t]);
84
- }, F = [
72
+ }, M = (e) => e instanceof Date ? e : new Date(e), N = (e) => e != null, P = () => h().timeZone, F = (e, t, n, r) => {
73
+ let { fallback: i = "", ...a } = r ?? {};
74
+ if (!N(n)) return i;
75
+ let o = M(n);
76
+ if (Number.isNaN(o.getTime())) return i;
77
+ try {
78
+ return new Intl.DateTimeFormat(e, {
79
+ ...t ? { timeZone: t } : {},
80
+ ...a
81
+ }).format(o);
82
+ } catch {
83
+ return String(n);
84
+ }
85
+ }, I = () => {
86
+ let e = D(), t = P();
87
+ return c((n, r) => F(e, t, n, r), [e, t]);
88
+ }, L = [
85
89
  ["year", 31536e6],
86
90
  ["month", 2592e6],
87
91
  ["week", 6048e5],
@@ -89,22 +93,26 @@ var w = ({ locale: e, messages: t, defaultLocale: n, silenceMissingTranslations:
89
93
  ["hour", 36e5],
90
94
  ["minute", 6e4],
91
95
  ["second", 1e3]
92
- ], I = () => {
96
+ ], R = () => {
93
97
  let e = D(), t = C();
94
98
  return c((n, r) => {
95
- let { now: i, ...a } = r ?? {}, o = M(n).getTime() - (i === void 0 ? t ?? Date.now() : M(i).getTime());
99
+ let { now: i, fallback: a = "", ...o } = r ?? {};
100
+ if (!N(n)) return a;
101
+ let s = M(n).getTime();
102
+ if (Number.isNaN(s)) return a;
103
+ let c = s - (i === void 0 ? t ?? Date.now() : M(i).getTime());
96
104
  try {
97
105
  let t = new Intl.RelativeTimeFormat(e, {
98
106
  numeric: "auto",
99
- ...a
107
+ ...o
100
108
  });
101
- for (let [e, n] of F) if (Math.abs(o) >= n || e === "second") return t.format(Math.round(o / n), e);
109
+ for (let [e, n] of L) if (Math.abs(c) >= n || e === "second") return t.format(Math.round(c / n), e);
102
110
  return t.format(0, "second");
103
111
  } catch {
104
112
  return String(n);
105
113
  }
106
114
  }, [e, t]);
107
- }, L = () => {
115
+ }, z = () => {
108
116
  let e = D();
109
117
  return c((t, n) => {
110
118
  try {
@@ -113,15 +121,15 @@ var w = ({ locale: e, messages: t, defaultLocale: n, silenceMissingTranslations:
113
121
  return String(t);
114
122
  }
115
123
  }, [e]);
116
- }, R = (e) => {
117
- let t = L();
124
+ }, B = (e) => {
125
+ let t = z();
118
126
  return c((n, r) => t(n, {
119
127
  style: "currency",
120
128
  currency: e,
121
129
  ...r
122
130
  }), [t, e]);
123
- }, z = () => {
124
- let e = D(), t = N(), n = P(), r = I(), i = L(), a = j();
131
+ }, V = () => {
132
+ let e = D(), t = P(), n = I(), r = R(), i = z(), a = j();
125
133
  return d(() => ({
126
134
  locale: e,
127
135
  timeZone: t,
@@ -137,21 +145,59 @@ var w = ({ locale: e, messages: t, defaultLocale: n, silenceMissingTranslations:
137
145
  i,
138
146
  a
139
147
  ]);
140
- }, B = (e) => e, V = () => (e) => e, H = (e, t, n) => e[t ?? n] ?? e[n], U = /\{\s*([A-Za-z0-9_]+)/g, W = (e) => {
141
- let t = /* @__PURE__ */ new Set();
142
- for (let n of e.matchAll(U)) t.add(n[1]);
143
- return t;
144
- }, G = (e, t) => {
148
+ }, H = (e) => e, U = () => (e) => e, W = (e, t, n) => e[t ?? n] ?? e[n], G = (e) => {
149
+ let t = /* @__PURE__ */ new Set(), n = (r, i) => {
150
+ let a = r;
151
+ for (; a < i;) {
152
+ let r = e[a];
153
+ if (r === "'") {
154
+ let t = e.indexOf("'", a + 1);
155
+ a = t === -1 ? i : t + 1;
156
+ continue;
157
+ }
158
+ if (r !== "{") {
159
+ a += 1;
160
+ continue;
161
+ }
162
+ let o = K(e, a, i), s = e.slice(a + 1, o), c = /^\s*([A-Za-z0-9_]+)/.exec(s)?.[1];
163
+ c !== void 0 && t.add(c);
164
+ let l = s.indexOf(",");
165
+ if (l !== -1) {
166
+ let t = a + 1 + l;
167
+ for (; t < o;) if (e[t] === "{") {
168
+ let r = K(e, t, o);
169
+ n(t + 1, r), t = r + 1;
170
+ } else t += 1;
171
+ }
172
+ a = o + 1;
173
+ }
174
+ };
175
+ return n(0, e.length), t;
176
+ }, K = (e, t, n) => {
177
+ let r = 0;
178
+ for (let i = t; i < n; i += 1) {
179
+ let t = e[i];
180
+ if (t === "'") {
181
+ let t = e.indexOf("'", i + 1);
182
+ if (t === -1) return n;
183
+ i = t;
184
+ continue;
185
+ }
186
+ if (t === "{") r += 1;
187
+ else if (t === "}" && (--r, r === 0)) return i;
188
+ }
189
+ return n;
190
+ }, q = (e, t) => {
145
191
  let n = Object.keys(e);
146
192
  if (n.length < 2) return;
147
193
  let r = t?.baseLocale ?? n[0], i = e[r] ?? {}, a = [];
148
194
  for (let [t, o] of Object.entries(i)) {
149
- let i = W(o);
195
+ let i = G(o);
150
196
  for (let o of n) {
151
197
  if (o === r) continue;
152
198
  let n = e[o]?.[t];
153
199
  if (n === void 0) continue;
154
- let s = W(n), c = [...i].filter((e) => !s.has(e)), l = [...s].filter((e) => !i.has(e));
200
+ let s = G(n), c = [...i].filter((e) => !s.has(e)), l = [...s].filter((e) => !i.has(e));
155
201
  if (c.length > 0 || l.length > 0) {
156
202
  let e = [c.length > 0 ? `missing {${c.join("}, {")}}` : "", l.length > 0 ? `unexpected {${l.join("}, {")}}` : ""].filter(Boolean);
157
203
  a.push(`${o} '${t}': ${e.join("; ")} (base ${r} has {${[...i].join("}, {")}})`);
@@ -165,21 +211,21 @@ var w = ({ locale: e, messages: t, defaultLocale: n, silenceMissingTranslations:
165
211
  return;
166
212
  }
167
213
  throw Error(o);
168
- }, K = (e) => {
214
+ }, J = (e) => {
169
215
  let t = e;
170
216
  return t.dynamic === void 0 && (t.dynamic = (t, n) => e(t, n)), t;
171
- }, q = () => ({
172
- useT: K((e, t) => E(e, t)),
173
- useTFn: (() => K(k())),
217
+ }, Y = () => ({
218
+ useT: J((e, t) => E(e, t)),
219
+ useTFn: (() => J(k())),
174
220
  T
175
- }), J = (e) => "default" in e && typeof e.default == "object" ? e.default : e, Y = (e, t) => {
221
+ }), X = (e) => "default" in e && typeof e.default == "object" ? e.default : e, Z = (e, t) => {
176
222
  let n = /* @__PURE__ */ new Map(), r = /* @__PURE__ */ new Map(), i = (n) => n && n in e ? n : t, a = (t) => {
177
223
  let a = i(t), o = n.get(a);
178
224
  if (o) return Promise.resolve(o);
179
225
  let s = r.get(a);
180
226
  if (s) return s;
181
227
  let c = e[a], l = c().then((e) => {
182
- let t = J(e);
228
+ let t = X(e);
183
229
  return n.set(a, t), r.delete(a), t;
184
230
  }).catch((e) => {
185
231
  throw r.delete(a), e;
@@ -195,7 +241,7 @@ var w = ({ locale: e, messages: t, defaultLocale: n, silenceMissingTranslations:
195
241
  a(e).catch(() => {});
196
242
  }
197
243
  };
198
- }, X = ({ catalogs: e, locale: t, fallback: n = null, silenceMissingTranslations: r, children: i }) => {
244
+ }, Q = ({ catalogs: e, locale: t, fallback: n = null, silenceMissingTranslations: r, children: i }) => {
199
245
  let [a, o] = f(() => e.peek(t));
200
246
  return u(() => {
201
247
  let n = e.peek(t);
@@ -216,7 +262,7 @@ var w = ({ locale: e, messages: t, defaultLocale: n, silenceMissingTranslations:
216
262
  ...r === void 0 ? {} : { silenceMissingTranslations: r },
217
263
  children: i
218
264
  }) : n;
219
- }, Z = (e, t, n) => (r, i) => s(w, {
265
+ }, $ = (e, t, n) => (r, i) => s(w, {
220
266
  locale: e,
221
267
  messages: t,
222
268
  defaultLocale: n,
@@ -224,7 +270,7 @@ var w = ({ locale: e, messages: t, defaultLocale: n, silenceMissingTranslations:
224
270
  ...i?.timeZone === void 0 ? {} : { timeZone: i.timeZone },
225
271
  ...i?.renderedAt === void 0 ? {} : { renderedAt: i.renderedAt },
226
272
  children: r
227
- }), Q = 3456e4, $ = (e) => {
273
+ }), ee = 3456e4, te = (e) => {
228
274
  if (!(typeof document > "u")) for (let t of document.cookie.split(";")) {
229
275
  let n = t.indexOf("=");
230
276
  if (!(n < 0) && t.slice(0, n).trim() === e) try {
@@ -233,12 +279,12 @@ var w = ({ locale: e, messages: t, defaultLocale: n, silenceMissingTranslations:
233
279
  return;
234
280
  }
235
281
  }
236
- }, ee = (e, t) => {
237
- typeof document > "u" || (document.cookie = `${e}=${encodeURIComponent(t)}; path=/; max-age=${Q}; SameSite=Lax`);
238
- }, te = ({ locales: e, current: t, onChange: n, ariaLabel: r = "Language", className: i }) => {
282
+ }, ne = (e, t) => {
283
+ typeof document > "u" || (document.cookie = `${e}=${encodeURIComponent(t)}; path=/; max-age=${ee}; SameSite=Lax`);
284
+ }, re = ({ locales: e, current: t, onChange: n, ariaLabel: r = "Language", className: i }) => {
239
285
  let [o, s] = f(void 0);
240
286
  u(() => {
241
- s($(a));
287
+ s(te(a));
242
288
  }, []);
243
289
  let c = t ?? o ?? e[0]?.code ?? "";
244
290
  return /* @__PURE__ */ g("select", {
@@ -246,7 +292,7 @@ var w = ({ locale: e, messages: t, defaultLocale: n, silenceMissingTranslations:
246
292
  value: c,
247
293
  onChange: (e) => {
248
294
  let t = e.target.value;
249
- ee(a, t), s(t), n?.(t) !== !1 && typeof window < "u" && window.location.reload();
295
+ ne(a, t), s(t), n?.(t) !== !1 && typeof window < "u" && window.location.reload();
250
296
  },
251
297
  ...i === void 0 ? {} : { className: i },
252
298
  children: e.map((e) => /* @__PURE__ */ g("option", {
@@ -256,4 +302,4 @@ var w = ({ locale: e, messages: t, defaultLocale: n, silenceMissingTranslations:
256
302
  });
257
303
  };
258
304
  //#endregion
259
- export { t as DEFAULT_TIME_ZONE, w as I18nProvider, a as LOCALE_COOKIE, X as LazyI18nProvider, te as LocaleSwitcher, S as RenderClockContext, T, n as THEME_COOKIE, i as TIMEZONE_COOKIE, e as VIEWER_TIME_ZONE, G as assertCatalogParity, q as createTypedMessages, B as defineCatalog, Y as defineCatalogs, V as defineLocale, x as i18nInstanceReport, r as isSupportedTimeZone, Z as makeSsgWrap, H as pickCatalog, A as plural, R as useFormatCurrency, P as useFormatDate, L as useFormatNumber, z as useFormatters, D as useLocale, O as useMessages, j as usePlural, I as useRelativeTime, C as useRenderClock, E as useT, k as useTFn, N as useTimeZone };
305
+ export { t as DEFAULT_TIME_ZONE, w as I18nProvider, a as LOCALE_COOKIE, Q as LazyI18nProvider, re as LocaleSwitcher, S as RenderClockContext, T, n as THEME_COOKIE, i as TIMEZONE_COOKIE, e as VIEWER_TIME_ZONE, q as assertCatalogParity, Y as createTypedMessages, H as defineCatalog, Z as defineCatalogs, U as defineLocale, x as i18nInstanceReport, r as isSupportedTimeZone, $ as makeSsgWrap, W as pickCatalog, A as plural, B as useFormatCurrency, I as useFormatDate, z as useFormatNumber, V as useFormatters, D as useLocale, O as useMessages, j as usePlural, R as useRelativeTime, C as useRenderClock, E as useT, k as useTFn, P as useTimeZone };
package/dist/server.d.ts CHANGED
@@ -12,6 +12,15 @@ export declare const DEFAULT_TIME_ZONE = "UTC";
12
12
  */
13
13
  export declare const isSupportedTimeZone: (name: string | null | undefined) => name is string;
14
14
 
15
+ /**
16
+ * The cookie carrying the user's explicit locale choice.
17
+ *
18
+ * Read by `resolveLocale` (ahead of `Accept-Language`), by the SSR boot when
19
+ * it resolves `<html lang>`, and by the ISR cache key — so a user who switches
20
+ * language gets their own rendering rather than the previous visitor's.
21
+ */
22
+ export declare const LOCALE_COOKIE = "voltro:locale";
23
+
15
24
  /**
16
25
  * Build the per-request resolver: cookie > Accept-Language > default, then the
17
26
  * matching wrapper.
@@ -113,6 +122,38 @@ export declare interface SsrI18nResolverArgs {
113
122
  readonly now?: () => number;
114
123
  }
115
124
 
125
+ /**
126
+ * The cookie carrying the user's explicit theme choice (`light` / `dark`;
127
+ * ABSENT means "follow the system", which is why the control deletes it rather
128
+ * than writing `system`).
129
+ *
130
+ * Read by the inline pre-paint script the dev server and the build inject, so
131
+ * the first paint is already correct and there is no flash.
132
+ */
133
+ export declare const THEME_COOKIE = "voltro:theme";
134
+
135
+ /**
136
+ * The cookie carrying the IANA timezone to render in (`Europe/Berlin`).
137
+ *
138
+ * Read by `resolveTimeZone` when the app configures `timeZone: 'viewer'`, and
139
+ * therefore by every SSR path — it is what lets the SERVER render in the
140
+ * viewer's zone instead of the pod's. Two writers are expected and both are
141
+ * correct:
142
+ *
143
+ * - the framework's pre-paint script, which seeds it from
144
+ * `Intl.DateTimeFormat().resolvedOptions().timeZone` when it is absent, so
145
+ * the zone is known from the second request onward with no login; and
146
+ * - the APP, at login, overwriting it with the zone it holds for the signed-in
147
+ * user (a profile field, an identity provider's `timeZone` claim). That is
148
+ * the authoritative one — a user travelling with a laptop keeps the zone
149
+ * their account says, not the airport's.
150
+ *
151
+ * Absent means "no answer yet", never "UTC": the resolver falls through to the
152
+ * app's `defaultTimeZone` so the two sides still agree on a value neither of
153
+ * them guessed.
154
+ */
155
+ export declare const TIMEZONE_COOKIE = "voltro:tz";
156
+
116
157
  /** The `app.config.ts` sentinel meaning "resolve per request from the viewer". */
117
158
  export declare const VIEWER_TIME_ZONE = "viewer";
118
159
 
package/dist/server.js CHANGED
@@ -1,10 +1,10 @@
1
- import { a as e, i as t, o as n, r, t as i } from "./cookieNames-CqdYWVpp.js";
1
+ import { a as e, i as t, n, o as r, r as i, t as a } from "./cookieNames-CqdYWVpp.js";
2
2
  //#region src/server.ts
3
- var a = i, o = (e) => RegExp(`(?:^|;\\s*)${e.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}=([^;]*)`), s = (e, t) => {
3
+ var o = a, s = (e) => RegExp(`(?:^|;\\s*)${e.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}=([^;]*)`), c = (e, t) => {
4
4
  if (!e) return;
5
- let n = e.match(o(t));
5
+ let n = e.match(s(t));
6
6
  return n ? decodeURIComponent(n[1]) : void 0;
7
- }, c = (e, t) => {
7
+ }, l = (e, t) => {
8
8
  let n = e.toLowerCase();
9
9
  for (; n.length > 0;) {
10
10
  let e = t.find((e) => e.toLowerCase() === n);
@@ -13,7 +13,7 @@ var a = i, o = (e) => RegExp(`(?:^|;\\s*)${e.replace(/[.*+?^${}()|[\]\\]/g, "\\$
13
13
  if (r < 0) return;
14
14
  n = n.slice(0, r);
15
15
  }
16
- }, l = (e) => e ? e.split(",").map((e) => {
16
+ }, u = (e) => e ? e.split(",").map((e) => {
17
17
  let [t, ...n] = e.split(";").map((e) => e.trim());
18
18
  if (!t || t === "*") return null;
19
19
  let r = 1;
@@ -28,32 +28,32 @@ var a = i, o = (e) => RegExp(`(?:^|;\\s*)${e.replace(/[.*+?^${}()|[\]\\]/g, "\\$
28
28
  tag: t,
29
29
  q: r
30
30
  };
31
- }).filter((e) => e !== null).sort((e, t) => t.q - e.q) : [], u = (e) => {
32
- let { cookieHeader: t, acceptLanguageHeader: n, supported: r, defaultLocale: i } = e, o = s(t, a);
33
- if (o) {
34
- let e = c(o, r);
31
+ }).filter((e) => e !== null).sort((e, t) => t.q - e.q) : [], d = (e) => {
32
+ let { cookieHeader: t, acceptLanguageHeader: n, supported: r, defaultLocale: i } = e, a = c(t, o);
33
+ if (a) {
34
+ let e = l(a, r);
35
35
  if (e) return e;
36
36
  }
37
- for (let e of l(n)) {
38
- let t = c(e.tag, r);
37
+ for (let e of u(n)) {
38
+ let t = l(e.tag, r);
39
39
  if (t) return t;
40
40
  }
41
41
  return i;
42
- }, d = (e) => {
43
- let { cookieHeader: t, configured: i, defaultTimeZone: a } = e;
44
- if (!i) return;
45
- if (i !== "viewer") return n(i) ? i : n(a) ? a : "UTC";
46
- let o = s(t, r);
47
- return n(o) ? o : n(a) ? a : "UTC";
48
42
  }, f = (e) => {
43
+ let { cookieHeader: t, configured: n, defaultTimeZone: a } = e;
44
+ if (!n) return;
45
+ if (n !== "viewer") return r(n) ? n : r(a) ? a : "UTC";
46
+ let o = c(t, i);
47
+ return r(o) ? o : r(a) ? a : "UTC";
48
+ }, p = (e) => {
49
49
  let { wraps: t, supported: n, defaultLocale: r } = e;
50
50
  return Object.keys(t).length === 0 ? () => void 0 : (i, a) => {
51
- let o = u({
51
+ let o = d({
52
52
  cookieHeader: i ?? null,
53
53
  acceptLanguageHeader: a ?? null,
54
54
  supported: n,
55
55
  defaultLocale: r
56
- }), s = d({
56
+ }), s = f({
57
57
  cookieHeader: i ?? null,
58
58
  configured: e.timeZone ?? null,
59
59
  defaultTimeZone: e.defaultTimeZone ?? null
@@ -69,4 +69,4 @@ var a = i, o = (e) => RegExp(`(?:^|;\\s*)${e.replace(/[.*+?^${}()|[\]\\]/g, "\\$
69
69
  };
70
70
  };
71
71
  //#endregion
72
- export { t as DEFAULT_TIME_ZONE, e as VIEWER_TIME_ZONE, n as isSupportedTimeZone, f as makeSsrI18nResolver, u as resolveLocale, d as resolveTimeZone };
72
+ export { t as DEFAULT_TIME_ZONE, a as LOCALE_COOKIE, n as THEME_COOKIE, i as TIMEZONE_COOKIE, e as VIEWER_TIME_ZONE, r as isSupportedTimeZone, p as makeSsrI18nResolver, d as resolveLocale, f as resolveTimeZone };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/i18n",
3
- "version": "0.62.3",
3
+ "version": "0.64.0",
4
4
  "description": "Voltro's i18n layer — thin opinionated wrap over react-intl. Provides the framework's locale-resolution conventions (voltro:locale cookie + Accept-Language fallback), a typed message-catalog helper, and a slim provider component. Power users can import directly from react-intl for advanced APIs.",
5
5
  "keywords": [
6
6
  "voltro",