@voltro/data-transfer 0.63.0 → 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 +90 -0
- package/dist/index.d.ts +21 -1
- package/dist/index.js +520 -460
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -39,6 +39,96 @@ _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
|
+
|
|
42
132
|
## [0.63.0] — 2026-09-03
|
|
43
133
|
|
|
44
134
|
### ⚠ BREAKING
|
package/dist/index.d.ts
CHANGED
|
@@ -993,6 +993,26 @@ declare const IntegrityError_base: Schema.TaggedErrorClass<IntegrityError, "Inte
|
|
|
993
993
|
/** Is this table one whose rows are about the deployment rather than the app? */
|
|
994
994
|
export declare const isEnvironmentLocal: (tableName: string) => boolean;
|
|
995
995
|
|
|
996
|
+
/**
|
|
997
|
+
* Mask a json column by KEY rather than as one scalar.
|
|
998
|
+
*
|
|
999
|
+
* A `json<Array<{ userName, email, … }>>` column is one value to the export
|
|
1000
|
+
* and many values to a person: the whole-column actions turn it into `null`
|
|
1001
|
+
* or `[redacted]` (useless to dev data) or copy it verbatim (a leak). This
|
|
1002
|
+
* action walks the value: an array masks each item; an object applies
|
|
1003
|
+
* `fields[key]` to each key it names and `rest` to every other key — and
|
|
1004
|
+
* `rest` defaults to `'redact'`, so a key the policy did not think about is
|
|
1005
|
+
* NOT copied through. A primitive at the top level gets `rest`. Nested
|
|
1006
|
+
* `{ json }` actions descend further; `MaskInput.column` inside is the path
|
|
1007
|
+
* (`profile.contacts.email`), so a `custom` transform knows where it is.
|
|
1008
|
+
*/
|
|
1009
|
+
declare interface JsonMaskAction {
|
|
1010
|
+
readonly json: {
|
|
1011
|
+
readonly fields: Readonly<Record<string, MaskAction>>;
|
|
1012
|
+
readonly rest?: MaskAction;
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
|
|
996
1016
|
export declare interface LeakWarning {
|
|
997
1017
|
readonly table: string;
|
|
998
1018
|
readonly column: string;
|
|
@@ -1095,7 +1115,7 @@ export declare type MaskAction = 'keep' | 'null' | 'redact' | 'hash' | 'dateShif
|
|
|
1095
1115
|
readonly fake: string;
|
|
1096
1116
|
} | {
|
|
1097
1117
|
readonly custom: (input: MaskInput) => unknown;
|
|
1098
|
-
};
|
|
1118
|
+
} | JsonMaskAction;
|
|
1099
1119
|
|
|
1100
1120
|
/** A masking export was refused because one or more exported columns are
|
|
1101
1121
|
* neither `.sensitive()` nor `.safe()` (fail-closed). Lists them so the author
|