@voltro/testing 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/client.d.ts CHANGED
@@ -9,6 +9,17 @@ import { RunForkOptions } from 'effect/Runtime';
9
9
  import { RuntimeFiber } from 'effect/Fiber';
10
10
  import { UseFormBindingOptions } from '@voltro/client';
11
11
 
12
+ /** The snapshot shape `useConnectionStatus` subscribes to. */
13
+ declare interface ConnectionFacts {
14
+ readonly rawTransport: 'connected' | 'disconnected';
15
+ readonly status: TestConnectionStatus;
16
+ readonly failureCount: number;
17
+ readonly lastFailureAt: number | undefined;
18
+ readonly failedSubscriptions: ReadonlyArray<string>;
19
+ readonly pendingMutationReplays: number;
20
+ readonly mutationReplay: 'unknown' | 'enabled' | 'disabled';
21
+ }
22
+
12
23
  /**
13
24
  * The runtime the hooks get. EXPORTED so it can be guarded.
14
25
  *
@@ -115,6 +126,11 @@ export declare interface SnapshotMeta {
115
126
  readonly emittedAt?: number;
116
127
  }
117
128
 
129
+ /** The coordinator's own three states. `'offline'` is not one of them: the
130
+ * hook derives it from `navigator.onLine`, so a test that wants it stubs the
131
+ * browser (`vi.spyOn(navigator, 'onLine', 'get')`), not the harness. */
132
+ export declare type TestConnectionStatus = 'connected' | 'recovering' | 'degraded';
133
+
118
134
  export declare interface VoltroTestClient {
119
135
  /** Wrap the component under test. */
120
136
  readonly Provider: (props: {
@@ -128,6 +144,32 @@ export declare interface VoltroTestClient {
128
144
  readonly resetSubscription: (tag: string) => void;
129
145
  /** Every mutation/action invoked so far, in order. */
130
146
  readonly calls: ReadonlyArray<RecordedCall>;
147
+ /**
148
+ * Drive what `useConnectionStatus` reports — the coordinator's presentation
149
+ * state plus, optionally, the facts beside it (`failureCount`,
150
+ * `failedSubscriptions`, `pendingMutationReplays`, `rawTransport`).
151
+ * Components re-render, like a real transport gap. Without this, a page's
152
+ * disconnect banner had a state no test could reach except by mocking the
153
+ * whole client module.
154
+ */
155
+ readonly setConnectionStatus: (status: TestConnectionStatus, facts?: Partial<Omit<ConnectionFacts, 'status'>>) => void;
156
+ /** How many times a component asked the cache to refresh (`useRefreshSubscriptions`). */
157
+ readonly refreshes: number;
158
+ /**
159
+ * Emit an rpc error onto the api's error bus — what `useOnRpcError` /
160
+ * `useRpcErrors` and the presentation layer subscribe to. `kind: 'transport'`
161
+ * with `outcome: 'unknown'` is the raw channel a socket cut produces;
162
+ * `kind: 'handler'` is a typed failure the presentation layer shows. A
163
+ * banner that reacts to either had no way to be handed one.
164
+ */
165
+ readonly emitRpcError: (event: {
166
+ readonly source: 'mutation' | 'action' | 'subscription';
167
+ readonly tag: string;
168
+ readonly error: unknown;
169
+ readonly kind: 'transport' | 'handler' | 'client' | 'unknown';
170
+ readonly outcome?: 'unknown' | 'known' | 'not-started';
171
+ readonly traceId?: string;
172
+ }) => void;
131
173
  }
132
174
 
133
175
  export declare interface VoltroTestClientConfig {
package/dist/client.js CHANGED
@@ -88,9 +88,25 @@ var o = async (e, r = {}) => {
88
88
  confirmByMutation: () => {},
89
89
  update: () => {},
90
90
  forTag: () => ({ patchedCount: 0 }),
91
- refreshAll: () => {}
92
- }, h = {}, g = (t, n, r) => {
93
- let i = t.split("."), a = h;
91
+ refreshAll: () => {
92
+ h += 1;
93
+ }
94
+ }, h = 0, g = {
95
+ rawTransport: "connected",
96
+ status: "connected",
97
+ failureCount: 0,
98
+ lastFailureAt: void 0,
99
+ failedSubscriptions: [],
100
+ pendingMutationReplays: 0,
101
+ mutationReplay: "unknown"
102
+ }, _ = /* @__PURE__ */ new Set(), v = {
103
+ subscribe: (e) => (_.add(e), () => {
104
+ _.delete(e);
105
+ }),
106
+ getSnapshot: () => g,
107
+ reportExternalSuccess: () => {}
108
+ }, y = {}, b = (t, n, r) => {
109
+ let i = t.split("."), a = y;
94
110
  for (let e of i.slice(0, -1)) a[e] = a[e] ?? {}, a = a[e];
95
111
  a[i[i.length - 1]] = (i) => e.tryPromise({
96
112
  try: async () => (u.push({
@@ -101,24 +117,25 @@ var o = async (e, r = {}) => {
101
117
  catch: (e) => e
102
118
  });
103
119
  };
104
- for (let [e, n] of Object.entries(t.mutations ?? {})) g(e, "mutation", n);
105
- for (let [e, n] of Object.entries(t.actions ?? {})) g(e, "action", n);
106
- let _ = {
120
+ for (let [e, n] of Object.entries(t.mutations ?? {})) b(e, "mutation", n);
121
+ for (let [e, n] of Object.entries(t.actions ?? {})) b(e, "action", n);
122
+ let x = {
107
123
  runtime: s,
108
124
  cache: m,
109
- client: h,
125
+ recovery: v,
126
+ client: y,
110
127
  descriptors: {},
111
128
  inspectBaseUrl: "http://test.local",
112
129
  errorBus: new i()
113
- }, v = {
130
+ }, S = {
114
131
  get: (e) => {
115
132
  if (e !== a) throw Error(`makeVoltroTestClient: component asked for api '${e}' but the harness was built for '${a}'. Pass { apiName: '${e}' }.`);
116
- return _;
133
+ return x;
117
134
  },
118
135
  names: [a]
119
136
  };
120
137
  return {
121
- Provider: ({ children: e }) => n(r.Provider, { value: v }, e),
138
+ Provider: ({ children: e }) => n(r.Provider, { value: S }, e),
122
139
  setSubscription: (e, t, n) => {
123
140
  let r = d(e);
124
141
  o.set(e, {
@@ -141,7 +158,22 @@ var o = async (e, r = {}) => {
141
158
  resetSubscription: (e) => {
142
159
  o.delete(e), f(e);
143
160
  },
144
- calls: u
161
+ calls: u,
162
+ setConnectionStatus: (e, t) => {
163
+ g = {
164
+ ...g,
165
+ ...t,
166
+ status: e,
167
+ rawTransport: t?.rawTransport ?? (e === "connected" ? "connected" : "disconnected")
168
+ };
169
+ for (let e of _) e();
170
+ },
171
+ get refreshes() {
172
+ return h;
173
+ },
174
+ emitRpcError: (e) => {
175
+ x.errorBus.emit(e);
176
+ }
145
177
  };
146
178
  };
147
179
  //#endregion
package/dist/index.d.ts CHANGED
@@ -864,7 +864,9 @@ export declare interface TestRequestOptions {
864
864
  export declare interface TestResponse {
865
865
  readonly status: number;
866
866
  /** Response headers, names lowercased. */
867
- readonly headers: Readonly<Record<string, string>>;
867
+ /** Response headers, lower-cased. A header the route repeated — two
868
+ * `set-cookie` lines — is an array, so a test can assert on both. */
869
+ readonly headers: Readonly<Record<string, string | ReadonlyArray<string>>>;
868
870
  /** The raw response body as text (empty string when there was none). */
869
871
  readonly text: string;
870
872
  /** The body parsed as JSON when the route answered with a JSON content-type,
package/dist/index.js CHANGED
@@ -97,15 +97,15 @@ var Ee = new Set(e), E = 0, De = () => ++E, Oe = (e, t) => {
97
97
  construct: (e, n, r) => Reflect.construct(e, n.length === 0 ? [t()] : n, r),
98
98
  apply: () => new e(t()).toString(),
99
99
  get: (e, n, r) => n === "now" ? () => t() : Reflect.get(e, n, r)
100
- }), Pe = (e) => {
100
+ }), M = (e) => {
101
101
  if (typeof e == "number") return e;
102
102
  let t = e instanceof Date ? e.getTime() : Date.parse(e);
103
103
  if (Number.isNaN(t)) throw Error(`mockClock: cannot read an instant from '${String(e)}'`);
104
104
  return t;
105
- }, M = class {
105
+ }, N = class {
106
106
  currentMs;
107
107
  constructor(e = "2026-01-01T00:00:00Z") {
108
- this.currentMs = Pe(e);
108
+ this.currentMs = M(e);
109
109
  }
110
110
  now() {
111
111
  return this.currentMs;
@@ -114,10 +114,10 @@ var Ee = new Set(e), E = 0, De = () => ++E, Oe = (e, t) => {
114
114
  return new Date(this.currentMs);
115
115
  }
116
116
  advance(e) {
117
- this.currentMs += typeof e == "number" ? e : Re(e);
117
+ this.currentMs += typeof e == "number" ? e : Le(e);
118
118
  }
119
119
  set(e) {
120
- this.currentMs = Pe(e);
120
+ this.currentMs = M(e);
121
121
  }
122
122
  get installed() {
123
123
  return j()[A]?.clock === this;
@@ -137,24 +137,24 @@ var Ee = new Set(e), E = 0, De = () => ++E, Oe = (e, t) => {
137
137
  let e = j(), t = e[A];
138
138
  t !== void 0 && t.clock === this && (globalThis.Date = t.realDate, delete e[A]);
139
139
  }
140
- }, Fe = (e) => typeof e == "object" && !!e && typeof e.then == "function", Ie = (e, t) => {
141
- let n = e instanceof M ? e : new M(e);
140
+ }, Pe = (e) => typeof e == "object" && !!e && typeof e.then == "function", Fe = (e, t) => {
141
+ let n = e instanceof N ? e : new N(e);
142
142
  n.install();
143
143
  let r = !1;
144
144
  try {
145
145
  let e = t(n);
146
- return Fe(e) ? (r = !0, e.then((e) => (n.uninstall(), e), (e) => {
146
+ return Pe(e) ? (r = !0, e.then((e) => (n.uninstall(), e), (e) => {
147
147
  throw n.uninstall(), e;
148
148
  })) : e;
149
149
  } finally {
150
150
  r || n.uninstall();
151
151
  }
152
- }, Le = (e) => u.acquireRelease(u.sync(() => {
153
- let t = e instanceof M ? e : new M(e);
152
+ }, Ie = (e) => u.acquireRelease(u.sync(() => {
153
+ let t = e instanceof N ? e : new N(e);
154
154
  return t.install(), t;
155
155
  }), (e) => u.sync(() => {
156
156
  e.uninstall();
157
- })), Re = (e) => {
157
+ })), Le = (e) => {
158
158
  let t = /^\s*(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)\s*$/.exec(e);
159
159
  if (!t) throw Error(`mockClock: cannot parse duration '${e}'`);
160
160
  let n = Number(t[1]), r = t[2];
@@ -166,7 +166,7 @@ var Ee = new Set(e), E = 0, De = () => ++E, Oe = (e, t) => {
166
166
  case "d": return n * 864e5;
167
167
  default: throw Error(`mockClock: unknown unit '${r}'`);
168
168
  }
169
- }, N = class {
169
+ }, P = class {
170
170
  emitted = [];
171
171
  async emit(e, t) {
172
172
  let n = typeof e == "string" ? e : e.id ?? e.event ?? "(unnamed event)";
@@ -185,7 +185,7 @@ var Ee = new Set(e), E = 0, De = () => ++E, Oe = (e, t) => {
185
185
  clear() {
186
186
  this.emitted.length = 0;
187
187
  }
188
- }, P = (e = {}) => {
188
+ }, F = (e = {}) => {
189
189
  let t = e.tenantId ?? null, n = new te({
190
190
  origin: e.origin ?? "test",
191
191
  ...e.ringSize === void 0 ? {} : { ringSize: e.ringSize }
@@ -255,7 +255,7 @@ var Ee = new Set(e), E = 0, De = () => ++E, Oe = (e, t) => {
255
255
  n.clear();
256
256
  }
257
257
  };
258
- }, F = class {
258
+ }, I = class {
259
259
  queue;
260
260
  calls = [];
261
261
  constructor(e) {
@@ -270,32 +270,32 @@ var Ee = new Set(e), E = 0, De = () => ++E, Oe = (e, t) => {
270
270
  remaining() {
271
271
  return this.queue.length;
272
272
  }
273
- }, ze = (e) => e, I = "00000000000000000000000000000000", L = /* @__PURE__ */ new WeakMap(), R = /* @__PURE__ */ new WeakMap(), z = /* @__PURE__ */ new WeakMap(), B = /* @__PURE__ */ new WeakMap(), V = /* @__PURE__ */ new WeakMap(), H = (e, t) => {
274
- let n = B.get(e) ?? [], r = t === "mutation" ? "interceptMutation" : t === "query" ? "interceptQuery" : "interceptAction", i = [];
273
+ }, Re = (e) => e, L = "00000000000000000000000000000000", R = /* @__PURE__ */ new WeakMap(), z = /* @__PURE__ */ new WeakMap(), B = /* @__PURE__ */ new WeakMap(), V = /* @__PURE__ */ new WeakMap(), H = /* @__PURE__ */ new WeakMap(), U = (e, t) => {
274
+ let n = V.get(e) ?? [], r = t === "mutation" ? "interceptMutation" : t === "query" ? "interceptQuery" : "interceptAction", i = [];
275
275
  for (let e of n) {
276
276
  let t = e[r];
277
277
  typeof t == "function" && i.push(t);
278
278
  }
279
279
  return v(i);
280
- }, U = (e) => {
281
- let t = (B.get(e) ?? []).map((e) => e.services).filter((e) => e !== void 0), n = V.get(e) ?? [], r = [...t, ...n];
282
- if (r.length !== 0) return f.mergeAll(...r);
283
280
  }, W = (e) => {
284
- let t = R.get(e);
281
+ let t = (V.get(e) ?? []).map((e) => e.services).filter((e) => e !== void 0), n = H.get(e) ?? [], r = [...t, ...n];
282
+ if (r.length !== 0) return f.mergeAll(...r);
283
+ }, G = (e) => {
284
+ let t = z.get(e);
285
285
  t !== void 0 && (t.length = 0);
286
- }, G = async (e) => {
287
- let t = R.get(e);
286
+ }, K = async (e) => {
287
+ let t = z.get(e);
288
288
  if (t === void 0 || t.length === 0) return;
289
289
  let n = [...t];
290
290
  t.length = 0;
291
291
  for (let e of n) await e();
292
- }, Be = (e) => z.get(e) ?? [], K = async (e, t) => {
293
- let n = L.get(e);
292
+ }, ze = (e) => B.get(e) ?? [], q = async (e, t) => {
293
+ let n = R.get(e);
294
294
  return n === void 0 ? e.store.transactional(async (n) => t({
295
295
  ...e,
296
296
  store: n
297
297
  })) : n(t);
298
- }, Ve = (e) => {
298
+ }, Be = (e) => {
299
299
  let t = /* @__PURE__ */ new Map(), n = /* @__PURE__ */ new Map(), r = (n) => {
300
300
  let r = t.get(n);
301
301
  return r === void 0 ? !1 : r.expiresAt !== null && r.expiresAt <= e() ? (t.delete(n), !1) : !0;
@@ -334,7 +334,7 @@ var Ee = new Set(e), E = 0, De = () => ++E, Oe = (e, t) => {
334
334
  return i(e, o, n.ttlMs, n.tags), o;
335
335
  }
336
336
  };
337
- }, He = (e) => {
337
+ }, Ve = (e) => {
338
338
  let t = /* @__PURE__ */ new Map(), n = (n) => {
339
339
  let r = t.get(n);
340
340
  return r === void 0 ? !1 : r.expiresAt !== null && r.expiresAt <= e() ? (t.delete(n), !1) : !0;
@@ -362,7 +362,7 @@ var Ee = new Set(e), E = 0, De = () => ++E, Oe = (e, t) => {
362
362
  t.clear();
363
363
  }
364
364
  };
365
- }, Ue = async (e, t) => {
365
+ }, He = async (e, t) => {
366
366
  let n = oe();
367
367
  w(e);
368
368
  try {
@@ -370,21 +370,21 @@ var Ee = new Set(e), E = 0, De = () => ++E, Oe = (e, t) => {
370
370
  } finally {
371
371
  n === void 0 ? ae() : w(n);
372
372
  }
373
- }, q = /* @__PURE__ */ new Set(), We = (e) => {
374
- q.has(e) || (q.add(e), console.warn(e));
375
- }, Ge = (e, t, n) => {
376
- let r = Ke, i;
373
+ }, J = /* @__PURE__ */ new Set(), Ue = (e) => {
374
+ J.has(e) || (J.add(e), console.warn(e));
375
+ }, We = (e, t, n) => {
376
+ let r = Ge, i;
377
377
  return () => {
378
378
  let a = t ?? S();
379
- return t !== void 0 && S() !== void 0 && t !== S() && We("[voltro:testing] this test passes `rowFilter:` while a DIFFERENT filter is registered via `setRowFilter`. The option wins, so this test does not exercise your app's registration. Drop `rowFilter:` to run the real process-global one."), (i === void 0 || a !== r) && (r = a, i = Ue(n, () => C(me(a, e, (e) => {
379
+ return t !== void 0 && S() !== void 0 && t !== S() && Ue("[voltro:testing] this test passes `rowFilter:` while a DIFFERENT filter is registered via `setRowFilter`. The option wins, so this test does not exercise your app's registration. Drop `rowFilter:` to run the real process-global one."), (i === void 0 || a !== r) && (r = a, i = He(n, () => C(me(a, e, (e) => {
380
380
  console.error("[voltro:testing] row filter failed to load — reads refused for this subject", e);
381
381
  })))), i;
382
382
  };
383
- }, Ke = Symbol("unresolved"), J = (e, t) => new Proxy(e, { get: (e, n) => {
383
+ }, Ge = Symbol("unresolved"), Y = (e, t) => new Proxy(e, { get: (e, n) => {
384
384
  if (n === "query") return async (n) => e.query(ie(await t(), n));
385
385
  let r = Reflect.get(e, n, e);
386
386
  return typeof r == "function" ? r.bind(e) : r;
387
- } }), qe = (e = {}) => {
387
+ } }), Ke = (e = {}) => {
388
388
  if (ge({
389
389
  ...process.env,
390
390
  ...e.env ?? {}
@@ -397,14 +397,14 @@ var Ee = new Set(e), E = 0, De = () => ++E, Oe = (e, t) => {
397
397
  schemaRegistry: r
398
398
  };
399
399
  w(a);
400
- let s = new M(e.clockStart), l = new N(), u = P(), d = new F(e.llmResponses ?? []), f = Ve(() => s.now()), p = He(() => s.now()), m = (t, n = i, o) => {
400
+ let s = new N(e.clockStart), l = new P(), u = F(), d = new I(e.llmResponses ?? []), f = Be(() => s.now()), p = Ve(() => s.now()), m = (t, n = i, o) => {
401
401
  let c = {
402
402
  subject: t,
403
- traceId: I
403
+ traceId: L
404
404
  }, h = ue({
405
405
  bus: u.bus,
406
406
  tenantId: t.tenantId ?? null
407
- }), g = o?.queued ?? [], _ = o?.nudges ?? [], v = ce({ store: n }), y = Ge(t, e.rowFilter, a), b = {
407
+ }), g = o?.queued ?? [], _ = o?.nudges ?? [], v = ce({ store: n }), y = We(t, e.rowFilter, a), b = {
408
408
  clock: s,
409
409
  webhooks: l,
410
410
  events: h,
@@ -416,12 +416,12 @@ var Ee = new Set(e), E = 0, De = () => ++E, Oe = (e, t) => {
416
416
  access: se(t),
417
417
  cache: f,
418
418
  kv: p,
419
- store: T(J(n, y), {
419
+ store: T(Y(n, y), {
420
420
  subject: t,
421
421
  schemaRegistry: r,
422
422
  rowFilter: x
423
423
  }),
424
- storeForTenant: (e) => T(J(n, y), {
424
+ storeForTenant: (e) => T(Y(n, y), {
425
425
  subject: ee(t, e),
426
426
  schemaRegistry: r,
427
427
  rowFilter: x
@@ -429,9 +429,9 @@ var Ee = new Set(e), E = 0, De = () => ++E, Oe = (e, t) => {
429
429
  outbox: de({
430
430
  store: n,
431
431
  subject: t,
432
- traceId: I,
432
+ traceId: L,
433
433
  nudge: () => {
434
- _.push(I);
434
+ _.push(L);
435
435
  },
436
436
  afterCommit: (e) => {
437
437
  g.push(e);
@@ -451,19 +451,19 @@ var Ee = new Set(e), E = 0, De = () => ++E, Oe = (e, t) => {
451
451
  nudges: _
452
452
  })))
453
453
  };
454
- return R.set(b, g), z.set(b, _), B.set(b, e.plugins ?? []), V.set(b, e.layers ?? []), L.set(b, (e) => n.transactional((n) => e(m(t, n, {
454
+ return z.set(b, g), B.set(b, _), V.set(b, e.plugins ?? []), H.set(b, e.layers ?? []), R.set(b, (e) => n.transactional((n) => e(m(t, n, {
455
455
  queued: g,
456
456
  nudges: _
457
457
  })))), b;
458
458
  };
459
459
  return m(e.subject ?? h(null));
460
- }, Je = (e, t) => {
461
- let n = U(t);
460
+ }, qe = (e, t) => {
461
+ let n = W(t);
462
462
  return (n === void 0 ? e : u.provide(e, n)).pipe(u.provide(le(t.store)), u.provideService(m, t.request.subject));
463
- }, Y = async (e, t, n, r) => {
463
+ }, X = async (e, t, n, r) => {
464
464
  let i = e.input, a = await p.decodeUnknownPromise(i)(n), o = e.kind, s = async (e) => {
465
465
  let n = t(a, e);
466
- return u.isEffect(n) ? C(Je(n, e)) : n;
466
+ return u.isEffect(n) ? C(qe(n, e)) : n;
467
467
  }, c = async () => {
468
468
  let t = e.guards;
469
469
  if (t !== void 0 && t.length > 0) {
@@ -471,9 +471,9 @@ var Ee = new Set(e), E = 0, De = () => ++E, Oe = (e, t) => {
471
471
  if (e !== null) throw e;
472
472
  }
473
473
  if (o !== "mutation") return s(r);
474
- let n = await he(async () => (W(r), K(r, async (e) => s(e))), { delay: () => Promise.resolve() });
475
- return await G(r), n;
476
- }, f = o === "mutation" || o === "query" || o === "action" ? o : void 0, m = f === void 0 ? void 0 : H(r, f);
474
+ let n = await he(async () => q(r, async (e) => (G(r), s(e))), { delay: () => Promise.resolve() });
475
+ return await K(r), n;
476
+ }, f = o === "mutation" || o === "query" || o === "action" ? o : void 0, m = f === void 0 ? void 0 : U(r, f);
477
477
  if (m === void 0 || f === void 0) return await c();
478
478
  let h = m(u.tryPromise({
479
479
  try: () => c(),
@@ -488,12 +488,12 @@ var Ee = new Set(e), E = 0, De = () => ++E, Oe = (e, t) => {
488
488
  }), _ = await u.runPromiseExit(h);
489
489
  if (d.isSuccess(_)) return _.value;
490
490
  throw l.squash(_.cause);
491
- }, X = (e) => new Promise((t) => {
491
+ }, Z = (e) => new Promise((t) => {
492
492
  let n = new _e.Socket(), r = (e) => {
493
493
  clearTimeout(i), n.destroy(), t(e);
494
494
  }, i = setTimeout(() => r(!1), e.timeoutMs ?? 750);
495
495
  n.once("connect", () => r(!0)), n.once("error", () => r(!1)), n.connect(e.port, e.host);
496
- }), Z = async (e, t, n, r) => {
496
+ }), Q = async (e, t, n, r) => {
497
497
  if (typeof t != "string" || typeof n != "function" || typeof r != "function") throw TypeError(`describeIfAvailable('${e}', ...): expected (label, dependency, probe, suite) — got dependency: ${typeof t}, probe: ${typeof n}, suite: ${typeof r}. Three arguments means the suite body is being used as the probe: it would run at file scope, unconditionally, and the skip would silently do nothing.`);
498
498
  let i = !1;
499
499
  try {
@@ -502,20 +502,20 @@ var Ee = new Set(e), E = 0, De = () => ++E, Oe = (e, t) => {
502
502
  i = !1;
503
503
  }
504
504
  ve.skipIf(!i)(i ? e : `${e} [SKIPPED: needs ${t}]`, r);
505
- }, Ye = async (e, t, n) => {
506
- await Z(e, `${t.name} at ${t.host}:${t.port}`, () => X(t), n);
507
- }, Xe = async (e, t) => {
505
+ }, Je = async (e, t, n) => {
506
+ await Q(e, `${t.name} at ${t.host}:${t.port}`, () => Z(t), n);
507
+ }, Ye = async (e, t) => {
508
508
  try {
509
509
  let n = await t();
510
510
  n !== void 0 && n !== "" && console.log(`[engine] ${e}: ${n}`);
511
511
  } catch {}
512
- }, Ze = "test-tenant", Q = (e, t, n) => ({
512
+ }, Xe = "test-tenant", $ = (e, t, n) => ({
513
513
  type: e,
514
514
  id: t,
515
515
  tenantId: n.tenantId ?? "test-tenant",
516
516
  scopes: n.scopes ?? [],
517
517
  ...n.metadata === void 0 ? {} : { metadata: n.metadata }
518
- }), Qe = (e, t = {}) => Q("user", e, t), $e = (e, t = {}) => Q("apiKey", e, t), et = (e, t = {}) => Q("serviceAccount", e, t), tt = (e = null) => h(e), nt = (e = "job:test", t) => t === void 0 ? b(e) : b(e, t), rt = (e) => {
518
+ }), Ze = (e, t = {}) => $("user", e, t), Qe = (e, t = {}) => $("apiKey", e, t), $e = (e, t = {}) => $("serviceAccount", e, t), et = (e = null) => h(e), tt = (e = "job:test", t) => t === void 0 ? b(e) : b(e, t), nt = (e) => {
519
519
  let t = e.indexOf("?");
520
520
  return t === -1 ? {
521
521
  path: e,
@@ -524,17 +524,17 @@ var Ee = new Set(e), E = 0, De = () => ++E, Oe = (e, t) => {
524
524
  path: e.slice(0, t),
525
525
  query: e.slice(t + 1)
526
526
  };
527
- }, it = (e, t) => {
527
+ }, rt = (e, t) => {
528
528
  let n = e.split("/").filter((e) => e.length > 0), r = t.split("/").filter((e) => e.length > 0);
529
529
  return n.length === r.length && n.every((e, t) => e.startsWith(":") || e === r[t]);
530
- }, $ = (e) => {
530
+ }, it = (e) => {
531
531
  let t = {};
532
532
  for (let [n, r] of Object.entries(e)) t[n.toLowerCase()] = r;
533
533
  return t;
534
534
  }, at = (e) => e === void 0 ? /* @__PURE__ */ new Uint8Array() : new TextEncoder().encode(JSON.stringify(e)), ot = (e) => e === void 0 ? "" : typeof e == "string" ? e : new TextDecoder().decode(e), st = (e) => {
535
535
  let { ctx: t } = e, n = ye((e.publicApi ?? []).map((e) => ({
536
536
  descriptor: e.descriptor,
537
- invoke: (n, r) => t.withSubject(r.subject, (t) => Y(e.descriptor, e.handler, n, t))
537
+ invoke: (n, r) => t.withSubject(r.subject, (t) => X(e.descriptor, e.handler, n, t))
538
538
  }))), r = [...e.restRoutes ?? [], ...n], i = _(e.strategies ?? [], {
539
539
  ...e.anonymousTenantRequired === void 0 ? {} : { anonymousTenantRequired: e.anonymousTenantRequired },
540
540
  getStore: () => t.store
@@ -550,7 +550,7 @@ var Ee = new Set(e), E = 0, De = () => ++E, Oe = (e, t) => {
550
550
  pattern: r[t].path,
551
551
  route: e
552
552
  })), l = async (e, n, i = {}) => {
553
- let { path: a, query: s } = rt(n), l = c.filter((e) => it(e.pattern, a)).map((e) => e.route);
553
+ let { path: a, query: s } = nt(n), l = c.filter((e) => rt(e.pattern, a)).map((e) => e.route);
554
554
  if (l.length === 0) return {
555
555
  status: 404,
556
556
  headers: {},
@@ -566,12 +566,13 @@ var Ee = new Set(e), E = 0, De = () => ++E, Oe = (e, t) => {
566
566
  path: a,
567
567
  query: s,
568
568
  headers: {
569
- ...$(o),
570
- ...$(i.headers ?? {})
569
+ ...it(o),
570
+ ...it(i.headers ?? {})
571
571
  },
572
572
  rawBody: at(i.body),
573
573
  store: t.store
574
- }, d = await be(l, u), f = $(d.headers ?? {});
574
+ }, d = await be(l, u), f = {};
575
+ for (let [e, t] of Object.entries(d.headers ?? {})) f[e.toLowerCase()] = t;
575
576
  d.contentType !== void 0 && (f["content-type"] = d.contentType);
576
577
  let p = ot(d.body), m = (d.contentType ?? "").includes("json");
577
578
  return {
@@ -691,4 +692,4 @@ var Ee = new Set(e), E = 0, De = () => ++E, Oe = (e, t) => {
691
692
  };
692
693
  }, dt = 1;
693
694
  //#endregion
694
- export { M as MockClock, F as MockLLM, N as MockWebhooks, dt as TESTING_PRESET_VERSION, Ze as TEST_TENANT_ID, tt as anonymous, $e as apiKey, n as changeDelete, r as changeInsert, i as changeSoftDelete, a as changeUpdate, Me as defineFactory, Z as describeIfAvailable, Ye as describeIfReachable, D as fixtureRow, Le as frozenTime, Y as invoke, X as isTcpReachable, ct as makeSubscribeContext, st as makeTestApp, qe as makeTestContext, ut as makeWorkflowRunner, ze as mockStore, De as nextSequence, Be as outboxNudgesOf, Xe as reportEngineVersion, W as resetAfterCommit, H as rpcInterceptorFor, G as runAfterCommit, K as runInStoreTransaction, et as serviceAccount, U as serviceLayerFor, nt as system, P as testEventBus, Qe as user, Ie as withFrozenTime };
695
+ export { N as MockClock, I as MockLLM, P as MockWebhooks, dt as TESTING_PRESET_VERSION, Xe as TEST_TENANT_ID, et as anonymous, Qe as apiKey, n as changeDelete, r as changeInsert, i as changeSoftDelete, a as changeUpdate, Me as defineFactory, Q as describeIfAvailable, Je as describeIfReachable, D as fixtureRow, Ie as frozenTime, X as invoke, Z as isTcpReachable, ct as makeSubscribeContext, st as makeTestApp, Ke as makeTestContext, ut as makeWorkflowRunner, Re as mockStore, De as nextSequence, ze as outboxNudgesOf, Ye as reportEngineVersion, G as resetAfterCommit, U as rpcInterceptorFor, K as runAfterCommit, q as runInStoreTransaction, $e as serviceAccount, W as serviceLayerFor, tt as system, F as testEventBus, Ze as user, Fe as withFrozenTime };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/testing",
3
- "version": "0.62.3",
3
+ "version": "0.64.0",
4
4
  "description": "Test utilities for Voltro apps — deterministic clock, subject and row factories, a handler-level invoke and a request-level app harness, queued LLM responses, scoped subject/tenant runners, and a cross-dialect parity harness.",
5
5
  "keywords": [
6
6
  "voltro",
@@ -43,15 +43,15 @@
43
43
  "node": ">=24.0.0"
44
44
  },
45
45
  "dependencies": {
46
- "@voltro/database": "0.62.3",
47
- "@voltro/env": "0.62.3",
48
- "@voltro/logger": "0.62.3",
49
- "@voltro/protocol": "0.62.3",
50
- "@voltro/runtime": "0.62.3",
51
- "@voltro/workflow": "0.62.3"
46
+ "@voltro/database": "0.64.0",
47
+ "@voltro/env": "0.64.0",
48
+ "@voltro/logger": "0.64.0",
49
+ "@voltro/protocol": "0.64.0",
50
+ "@voltro/runtime": "0.64.0",
51
+ "@voltro/workflow": "0.64.0"
52
52
  },
53
53
  "peerDependencies": {
54
- "@voltro/client": "0.62.3",
54
+ "@voltro/client": "0.64.0",
55
55
  "effect": "^3.22.0",
56
56
  "react": "^19.0.0",
57
57
  "@effect/sql": "^0.52.0"