@voltro/sql-mssql 0.62.3 → 0.64.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -39,6 +39,110 @@ _Changes staged for the next release accumulate here (rolled up from
39
39
 
40
40
  ---
41
41
 
42
+ ## [0.64.0] — 2026-09-04
43
+
44
+ ### ⚠ BREAKING
45
+
46
+ - **@voltro/ai, @voltro/cli** — `defineAgent` must now decide **who may talk to the agent** — `guards: [...]` or `openAccess: '<why>'` — and the decision applies to both synthesized routes (`<name>.send`, `<name>.messages`). An undecided agent refuses the boot under `security.defaultDeny` (kind `agent`, named with its file by `voltro doctor`). The routes used to carry a framework-written `openAccess` whose own wording admitted it served any session — anonymous included — so every agent was a free model-spend surface for anyone who could open the socket. Threads now have **owners**: the first `send` on a `threadId` opens the thread for the calling subject, and every later `send` / `messages` on it by another authenticated subject fails with the typed `AgentThreadAccessDenied` (in both routes' error unions). A thread opened anonymously has no owner and keeps its old reach. `_voltro_agent_threads` gains a nullable `subjectId` column, applied by the declarative differ on the next boot or `voltro db apply`; `runAgentTurn` and `loadAgentMessages` take the caller's `subjectId`.
47
+
48
+ **`voltro update` carries you across this** — codemod `0.64.0/02_agent-access-decision`. If you pin versions by hand and never run it, print the notes without changing anything: `voltro update --codemods-only --from <your current version> --dry-run` (this one ships in 0.64.0).
49
+ - **@voltro/plugin-atlassian** — `@voltro/plugin-atlassian`: `AtlassianCredentials` (and `connectionCredentials({ … })`) gain `confluenceBaseUrl`, and `ConfluenceService` is built on it — never on the Jira `baseUrl`, where `/rest/api/content` answers 404 on Data Center and on Cloud alike (Cloud serves Confluence under `/wiki`), so every Confluence call was reaching the wrong host. A credential without the field now fails every `ConfluenceService` method with a typed `ConfluenceError` (`code: 'not_configured'`). A **named** instance (`atlassianPlugin({ name })`) provides its services under per-name tags — `jiraServiceFor(name)` / `confluenceServiceFor(name)` — instead of the shared `JiraService` / `ConfluenceService`, which two named instances used to both provide with the last merged layer silently winning; the unnamed instance keeps the default tags. Migration: set `confluenceBaseUrl` where Confluence is used; reach a named site through `jiraServiceFor(name)`.
50
+
51
+ **`voltro update` carries you across this** — codemod `0.64.0/03_atlassian-confluence-root-and-named-instances`. If you pin versions by hand and never run it, print the notes without changing anything: `voltro update --codemods-only --from <your current version> --dry-run` (this one ships in 0.64.0).
52
+ - **@voltro/cli, @voltro/plugin-webhooks, @voltro/plugin-governance, @voltro/database** — The field cipher behind `.encrypted()` columns is registered by the framework, from `VOLTRO_FIELD_ENCRYPTION_KEY`, on both boot paths — `voltro dev` mints the key into `.env.local` like the inspect tokens, and `voltro serve` refuses to boot without it as soon as any `.encrypted()` column is declared, naming the columns. Until now the only registrar was `governancePlugin({ fieldEncryption })`, so a framework table could not encrypt a credential without making every app that installs its plugin also install governance — and `_voltro_webhook_targets.secret`, the key that signs every delivery, sat in plaintext for exactly that reason. It is `.encrypted().serverOnly()` now; rows written before still read, and `voltro db encrypt-column _voltro_webhook_targets.secret` converts them in place. `fieldEncryption: true` reads the same variable and changes nothing; `{ secretKey }` keeps its purpose (a key under another name) and still wins.
53
+
54
+ BREAKING for an app with `@voltro/plugin-webhooks` (or its own `.encrypted()` columns) and no cipher: production boot now requires the key.
55
+
56
+ **`voltro update` carries you across this** — codemod `0.64.0/05_field-encryption-key-framework-owned`. If you pin versions by hand and never run it, print the notes without changing anything: `voltro update --codemods-only --from <your current version> --dry-run` (this one ships in 0.64.0).
57
+ - **@voltro/protocol, @voltro/runtime, @voltro/testing, @voltro/plugin-auth-social, @voltro/plugin-sso-saml** — `PluginHttpRouteResult.headers` accepts an array per header: `set-cookie` arrays become separate `Set-Cookie` lines (a session and a CSRF cookie from one login), every other array joins with `, `. A record of single strings could not say it, so the second cookie overwrote the first.
58
+
59
+ `@voltro/plugin-auth-social` and `@voltro/plugin-sso-saml` — the two first-party routes that set two cookies from one login — answer with two lines now. sso-saml joined them with a comma, which a browser reads as ONE cookie, so the SLO companion never arrived; auth-social newline-joined them, an encoding no layer split.
60
+
61
+ BREAKING for tests: `@voltro/testing`'s route response `headers` values are `string | ReadonlyArray<string>` — a test that assigned one to a `string` narrows it (the codemod's note says how).
62
+
63
+ **`voltro update` carries you across this** — codemod `0.64.0/06_test-response-headers-arrays`. If you pin versions by hand and never run it, print the notes without changing anything: `voltro update --codemods-only --from <your current version> --dry-run` (this one ships in 0.64.0).
64
+ - **@voltro/protocol, @voltro/voltro, @voltro/workflow, @voltro/runtime, @voltro/cli, @voltro/plugin-audit, @voltro/plugin-rbac, @voltro/plugin-sentry, @voltro/plugin-ratelimit, @voltro/plugin-billing, @voltro/plugin-flags, @voltro/plugin-moderation** — A `workflow()` must now decide **who may start it** — `guards: [...]`, `openAccess: '<why>'`, or `internal: true` — and an app with an undecided workflow refuses to boot under `security.defaultDeny` (on by default), on `voltro dev`, `voltro serve` and `voltro doctor`. The start rpc a `*.workflow.tsx` becomes had no access decision at all: the definer had no `guards:` field, so nothing was enforced and every discovered workflow was startable by any socket, anonymous sessions included. Guards use the mutation vocabulary verbatim (a resource-scoped guard reads its id from the start payload); `internal: true` removes the start rpc from the client group and both boot paths, leaving server-side starts (`ctx.workflows.start`, schedules, event triggers) untouched. The start now runs inside a new **`interceptWorkflow`** plugin chain (`RpcKind` gained `'workflow'`, permission `'rpc:intercept:workflow'`): audit records starts, rate limiting counts them, billing's entitlement gate covers them, and rbac publishes role scopes so a role-derived guard can pass. The start lifter `workflowToRpc` moved from `@voltro/runtime` to `@voltro/protocol` (browser-safe) and is now the ONE lifter `voltro dev`, `voltro serve` and the generated client group all call — the start payload is `strictInput`-checked on every path and a guarded workflow advertises `ScopeError` / `Unauthenticated` in its wire error union, so a refused start decodes as a typed failure rather than an `ExitEncoded` defect. Migration: run `voltro doctor`, decide each listed workflow; an app plugin declaring `interceptAction` adds `interceptWorkflow` for the same coverage.
65
+
66
+ **`voltro update` carries you across this** — codemod `0.64.0/01_workflow-access-decision`. If you pin versions by hand and never run it, print the notes without changing anything: `voltro update --codemods-only --from <your current version> --dry-run` (this one ships in 0.64.0).
67
+ - **@voltro/protocol, @voltro/runtime, @voltro/voltro** — A workflow start whose idempotency key was already SPENT — a run under that key had finished — now returns `status: 'replayed'` on the handle (with the old run's id) and warns once per key, instead of `running` with the old run's id, which was indistinguishable from a fresh start. A schedule keyed per day ran once and reported every later firing as a start; the replay is now visible where it happens. `WorkflowRunHandle.status` gains the literal — an exhaustive switch over it needs the arm. The lookup is best-effort: a run store that cannot answer leaves the handle as before.
68
+
69
+ **`voltro update` carries you across this** — codemod `0.64.0/04_workflow-handle-replayed`. If you pin versions by hand and never run it, print the notes without changing anything: `voltro update --codemods-only --from <your current version> --dry-run` (this one ships in 0.64.0).
70
+
71
+ ### Added
72
+
73
+ - **@voltro/cli** — `voltro agents-md` reports a nested `AGENTS.md` / `CLAUDE.md` pair whose contents differ (both sizes, both ways out: add the keep marker to the one you maintain and copy it, or `--force` to re-seed both). The seeder writes one content under both names and never overwrites without `--force`, so a pair diverges only by hand — and then every tool reading the other name gets guidance nobody wrote. Reported rather than symlinked: a symlink is not a file to `COPY`, to Windows, or to a tool that refuses to follow one. `EnsureResult.diverged` carries it.
74
+ - **@voltro/ai** — `aiStep` / `aiObjectStep` take `tools`, `maxSteps` and `toolContext`: the whole LLM↔tool loop runs as ONE durable step — one journal entry, one retry policy, one usage row summing every round-trip (`totalUsage`, not the last step's). `generateText` gained the same tool loop for a text answer. A durable tool loop had to leave the step for the inline `generateObjectWithTools` before, and lost the journal and the per-step spend on the way out.
75
+ - **@voltro/cli** — Without `auth.anonymousTenantRequired`, an anonymous request resolves to the tenant its own `x-tenant` header names (or the development fallback tenant) — a convenience for smoke flows that is, under `NODE_ENV=production`, a client-chosen tenant on every unauthenticated read of a non-`tenant()` table. Both boot paths now say so once at boot and name the field; the request path is unchanged.
76
+ - **@voltro/plugin-atlassian** — `atlassianPlugin({ confluenceCredentialsResolver })` resolves the caller's Confluence credentials separately from Jira's. On Cloud one token serves both products; on Data Center a Jira PAT is not a Confluence PAT, and a Confluence service built on the Jira resolver cannot authenticate at all. Absent, the Jira resolver serves Confluence as before.
77
+ - **@voltro/plugin-atlassian, @voltro/protocol, @voltro/cli** — `atlassianPlugin({ cache: true })` memoises reference-data reads (board configuration, project, field and status catalogues) into the FRAMEWORK's cache — the backend `CACHE_BACKEND` selects, in-process or Redis across replicas — handed over at bind time; `{ ttlMs }` tunes the per-namespace TTLs on it, and `{ store }` still memoises into a store of your own. The option used to need a hand-built store object and the docs named no way to obtain one. Behind it, `PluginBindContext` gains `cache` (get/set on the framework cache), which both boot paths hand to every plugin's `bindDataStore`.
78
+ - **@voltro/plugin-atlassian** — `@voltro/plugin-atlassian` exports `jql` — the JQL escaping helpers (`str`, `field`, `key`, `keyList`, `strList`) — and the `JiraIssueKey` schema, so a value interpolated into `searchIssues(...)` cannot close the literal it sits in or smuggle an `OR` into the query; keys are validated at the call site, before anything is sent.
79
+ - **@voltro/plugin-audit** — `auditPlugin({ metadata })` — a per-call resolver for the `metadata` column, which existed and which nothing ever set. It runs AFTER the call with the tag, input, subject and the outcome (`ok` with the value, or `error`), so an app can write the sentence a diff cannot contain ("Anna removed Bernd from the Frontend team"); it may be async, and a throw or `undefined` records no note and never fails or delays the call.
80
+ - **@voltro/database, @voltro/plugin-audit, @voltro/plugin-row-history** — `_voltro_audit_log` and `_voltro_row_history` gain `scopeKey` — the app's `resolveScope` value as a bounded, indexed text (`scopeKeyOf`: a string scope is its own key, anything else canonical JSON with sorted keys) beside the opaque json `scope`, plus `auditByScope(store, scope, { status?, limit? })` and `historyByScope(store, scope, tenantId, limit)` (+ Effect twin) that address rows through it on the `byAuditScope` / `byRowHistoryScope` indexes. The json column was documented as indexed and was not — json is not an access path on any dialect — so "everything that happened to team X" was a full scan, and the row-history reader dropped `scope` and `actor` on the floor; both are returned now. `scopeKeyOf` is exported from `@voltro/database`.
81
+ - **@voltro/protocol, @voltro/cli, @voltro/plugin-deactivation** — `auth.subjectGuards` in `app.config.ts` — post-authentication vetoes at the framework level, where EVERY subject passes: after a strategy matches and `resolveScopes` has applied, on the request path and on a durable workflow's execution. A guard receives the subject as it will run plus the same context `resolveScopes` gets, returns `{ ok: true }` or `{ ok: false, code, message }`; the first rejection fails the request closed with `Unauthenticated` carrying both. The auth plugin's `subjectGuards` run at login only, so an app whose subjects arrive through its own strategy chain (a JWKS IdP, an API-key table, a self-minted session) had no place to veto a deactivated account — `users.deactivate` locked no one out. Vetoes are cached with the scope decision (same key, same window, same `invalidate` handle); `applyScopeDecision` throws on a vetoed decision so a caller that forgets the branch fails closed. `@voltro/plugin-deactivation/guard` ships `deactivationSubjectGuard({ table })` for this seam.
82
+ - **@voltro/plugin-broadcast** — The broadcast bus reports three Effect metrics, exported with the framework's other counters: `voltro_broadcast_connected{provider}` (a gauge — `1` while this replica holds a live subscription, `0` while it does not), `voltro_broadcast_gaps_total` (gaps recovered from) and `voltro_broadcast_missed_total` (messages serial accounting proves were missed). A broker outage used to be a silent UI — local reactivity kept working, only the other replicas' changes stopped arriving — and the gauge dropping to 0 is the alarm that search was missing.
83
+ - **@voltro/plugin-broadcast** — The broadcast bus counts transport reconnects (`voltro_broadcast_reconnects_total{provider}`, beside `_connected`, `_gaps_total`, `_missed_total`), logs each one with its replica identity and last-received timestamp, and exposes `health()` on the handle — provider, channel, replica id, connected, last received / published, reconnects, gaps, missed — for a readiness probe or a support dump. With two replicas and CDC off the bus is part of correctness, and an outage that loses cross-replica updates without closing a socket had no number to point at.
84
+ - **@voltro/cli** — `voltro db plan --check` exits 1 when ANY operation is pending — the CI schema gate a bare `db plan` cannot be, since a preview that failed would make "show me the diff" and "the diff is empty" the same exit code. The verdict names the count ("3 pending operations"); `--json` still emits the plan. A blocked plan keeps exit 2.
85
+ - **@voltro/integration-http** — The per-host retry budget reports itself: `voltro_http_retries_total{host}` counts retries that went out, `voltro_http_retry_budget_exhausted_total{host}` the ones the budget refused. A retry storm and a budget that has closed the door on a host were both invisible from a span — there is one span per logical request, whatever it took to answer.
86
+ - **@voltro/i18n** — `LOCALE_COOKIE`, `THEME_COOKIE` and `TIMEZONE_COOKIE` are exported from `@voltro/i18n/server` as well as from the root. A server that sets or clears one of those cookies names it, and the root entry carried the browser hooks along for two strings.
87
+ - **@voltro/data-transfer** — Masking action `{ json: { fields, rest? } }` masks INSIDE a json value by key: an array masks each item, an object applies `fields[key]` to the keys it names and `rest` to every other one — `rest` defaults to `'redact'`, so a key the policy did not think about is never copied through. Nested `{ json }` descends; a `custom` transform inside sees the path as its `column`. A json column was one scalar to the export before: `null`, `[redacted]`, or a verbatim copy of every person in it.
88
+ - **@voltro/runtime** — `ctx.store.one(query, onMissing?)` and `select(table)….one(onMissing?)`: the factory receives the `{ table, found }` that `NoRowFound` carries and whatever it returns is thrown instead. A contract that already declares its own not-found error keeps declaring it, without a `maybeOne` + null branch at every read that only existed to translate the framework's error into the app's.
89
+ - **@voltro/protocol, @voltro/runtime, @voltro/cli, @voltro/plugin-audit** — Plugins gain `onHttpRoute` — a fire-and-forget observer of every plugin HTTP route call after it answers (method, path, status, duration; permission `'http:intercept'`, the other end of the pipeline `onHttpRequest` opens). A login, a logout or an avatar fetch served by a plugin's `httpRoutes` answered outside every rpc interceptor, so a trail that recorded every mutation had no row for the one call an incident review asks about first. `@voltro/plugin-audit` implements it: `http.<METHOD> <path>` with the status, no subject (a plugin route is not part of the rpc pipeline and nothing upstream authenticated its caller). Both boot paths compose the hooks through one builder and hand them to the server.
90
+ - **@voltro/database, @voltro/runtime, @voltro/cli** — `reference(target, { onSoftDelete: 'cascade' | 'setNull' })`. A soft delete is an `UPDATE … SET deletedAt`, so `ON DELETE` never fires for it and the children of a soft-deleted parent stayed exactly where they were. The runtime enforces this one on the store's post-commit change channel through the same engine `pluginRef` orphan rules use — after the parent's update commits, once per replica, chaining through children that emit their own change. `'cascade'` is refused at boot when the referencing table lacks `softDelete()` (the engine would hard-delete children a restore could never bring back); `'setNull'` needs a nullable column. Restoring the parent does not restore the children.
91
+ - **@voltro/database, @voltro/runtime** — `.forUpdate()` on both query builders — `database.t.where(…).forUpdate()` and `ctx.store.select('t').where(…).forUpdate()` — marks the descriptor `lock: 'update'` and the shared SELECT compiler emits the dialect's row lock: `FOR UPDATE` on postgres / mysql / mariadb, `WITH (UPDLOCK, ROWLOCK)` on mssql, nothing on sqlite (its writers are serialised already). The matched rows stay locked until the enclosing transaction commits, so a read-modify-write — an append into a json column, a counter, a merge — keeps every writer's change instead of the last one's. `.version()` refuses the stale writer; this makes the second writer wait. A set operation ignores it.
92
+ - **@voltro/database** — `selfReference(options?)` — a reference column pointing at the table it is declared in. `reference(() => teams)` inside `teams`' own declaration is a TypeScript cycle (TS7022), so `parentId` columns ended up as bare `text()` with no foreign key the schema knew about. The new column carries no target type; `table()` resolves its placeholder to the table being built, so the FK emitter, the eager loader and the subject graph see it as the reference it is.
93
+ - **@voltro/plugin-sentry** — `captureFailures: 'infrastructure'` — the preset between `false` (the default: no declared failure reaches Sentry) and `true` (every one does). A failure whose tag says infrastructure broke — `StoreOperationFailed`, `SqlError`, `ResultLengthMismatch`, `FieldDecryptionError`, `RequestError`, `ResponseError`, `CacheError`, `WorkflowAuthorityUnavailable` — is reported even though a handler declared it, because a declared database outage is still a database outage; every other declared failure stays out. Both halves take it (`initSentryBrowser({ captureFailures })` resolves the same predicate), and the boot line names the preset.
94
+ - **@voltro/database, @voltro/cli** — A `voltro dev` boot and `voltro db plan` / `db apply` warn about soft-drop snapshots (`<name>__dropped_<ts>`) older than the review window, naming each one and printing the exact `voltro db gc-snapshots --before <date>` to run — computed from the stamp in the name, so it costs no query beyond the introspection that already ran. A snapshot is a full copy of the dropped data, PII included, and reclaiming it was a step an operator had to remember while the soft-drop advice promised "~7d". The window is `database.softDropRetentionDays` in `app.config.ts` (default 7).
95
+ - **@voltro/runtime, @voltro/cli** — The api now says when a subscription's consumer stops acknowledging chunks. `@effect/rpc` waits for the client's `Ack` of each `Chunk` before writing the next one, so a hand-rolled socket client that only listens receives the initial snapshot and then nothing, however many writes land — while the producer, the matcher and the registry all report a healthy subscription, because they are. A delivery the transport has not accepted within `reactive.socket.stallWarnAfterMs` (default 5 s, env `VOLTRO_REACTIVE_STALL_WARN_AFTER_MS`) is logged at WARN naming the subscription and the missing `Ack`, and counted in `voltro_subscription_stalled_total{tag}`; against `voltro_subscription_deliveries_total{kind="delta"}` that separates "the server delivered" from "the socket handed it over". The stream buffer between the outbox and the socket is one event deep now (was 16), so updates coalesce onto the newest state sooner and the pump can see the stall at all. Computed subscriptions ride the same outbox: they used to hand every recomputed snapshot to the stream fire-and-forget, so a stalled consumer retained each one. All three binders share one `pumpOutbox`. The wire-protocol page documents the flow control and a raw-client checklist. Golden churn: `ReactiveSocketTunables` gains a required `stallWarnAfterMs` (constructed only by the CLI's resolver and tests), `SubscriptionOutbox` is generic in its payload, and `pumpOutbox` / `bindComputedUntyped` / `recordStalled` are new exports.
96
+ - **@voltro/testing** — `makeVoltroTestClient` drives `useConnectionStatus`: `setConnectionStatus(status, facts?)` sets the coordinator's presentation state (`connected` / `recovering` / `degraded`) and the facts beside it — failure count, failed subscriptions, pending replays, raw transport — and components re-render like on a real gap. `refreshes` counts what `useRefreshSubscriptions` asked for. A disconnect banner and a retry button had states no test could reach except by mocking the whole client module.
97
+ - **@voltro/testing** — `makeVoltroTestClient().emitRpcError({ source, tag, error, kind, outcome? })` puts an rpc error onto the api's error bus — `kind: 'transport'` with `outcome: 'unknown'` is the raw channel a socket cut produces, `kind: 'handler'` a typed failure the presentation layer shows. A banner that subscribes through `useOnRpcError` had no way to be handed either without mocking the whole client module.
98
+
99
+ ### Changed
100
+
101
+ - **@voltro/cli, @voltro/ai** — The model-provider key is checked at BOOT: when `AI_PROVIDER` names a real provider and its key variable (`ANTHROPIC_API_KEY` / `OPENAI_API_KEY` / `AI_GATEWAY_API_KEY`) is unset or empty, `voltro serve` refuses to start and `voltro dev` warns. The provider SDK used to read the key at the first model call, so an unset or rotated key surfaced hours after a deploy that reported healthy, from inside the SDK. An app that supplies every key in code (`model: { apiKey }`) opts out with `ai: { requireProviderKey: false }` in `app.config.ts`. `@voltro/ai` exports `providerKeyEnvVar(provider)` — the one map the gate and the provider share.
102
+ - **@voltro/cli** — `voltro doctor`, corrected where its rules assumed the framework's own names:
103
+
104
+ - `subject-write-no-guard` reads the app's guard vocabulary (every exported guard, not a fixed list of the framework's names) and accepts an explicit null guard on `subject.id` or an alias of it — a fixed list reported every check an app writes under its own names as absent. - new `open-reason-unverified`: an `openAccess` reason that names a guard from the vocabulary is checked against the paired executor; a reason that convinced a reviewer and was false is the finding. - the authz scan reads an executor's local helper imports (two hops) as part of the executor, so a `database.<table>` read in `lib/` is attributed to the executor that calls it instead of to nobody. - new `input/uncapped-array`: `Schema.Array` in a mutation or action input with no `maxItems` — the body limit bounds bytes, not writes. - new `cron/bucket-on-wall-clock`: a cron that keys its period on `firedAt` / `Date.now()` / `new Date()` and never reads `scheduledAt`. - a sensitivity report: every text/json column carrying neither `.sensitive()` nor `.safe()`, named before a masked export refuses it; reviewed exceptions in `doctor.sensitivity.allow`. - `convention/missing-test` covers executors (`*.{query,mutation,action, stream}.server.ts`, `*.workflow.server.tsx`), with the test beside either half of the pair and a fix that points at `invoke` from `@voltro/testing`. - `hardcoded-framework-cookie` ignores comments; `ui/write-hook` follows a `.hook.ts` import one hop, so a UI component writing through its own hook is reported as the write it is.
105
+ - **@voltro/cli** — Three `voltro doctor` scans, each corrected where its premise was narrower than the code it read:
106
+
107
+ - the never-emitted-events scan resolves a DEFAULT-exported descriptor through its importers' names (`import updating from '…event'` → `ctx.events.publish(updating, …)`); every default-exported event read as never emitted before. - `n-plus-one` reports a read inside a loop or per-item callback outright, and otherwise counts only INDEPENDENT reads — a chain where each key comes from the previous row (registration → setup → access code) is an assembly `Effect.all` cannot run and is no longer called an N+1 you are writing. - `hand-serialized-date` runs on executors only — not on `lib/` helpers building predicate bounds, not on web components, where its fix is unfollowable — and skips a property the paired descriptor declares `Schema.String`: an ISO contract chosen on purpose is not a Date leaking out. - new `mutation/target-op-mismatch`: a mutation whose `target: { table, op }` promises one write verb while its executor performs another on that table — the descriptor drives the optimistic patch, the invalidation and the audit row, so the disagreement was silent and wrong three ways. - new `bulk-write-in-loop`: `insert` / `insertIgnore` / `upsert` inside a loop or per-item callback, where `insertMany` / `insertManyIgnore` / `upsertMany` is one statement per batch.
108
+ - **@voltro/cli** — `voltro doctor`'s `renderMode:'spa'` candidates carry a `reason` line each — what the page costs today (a server render and hydration of a leaf whose data arrives client-side anyway) and what `'spa'` changes (the layout shell stays server-rendered; only the leaf drops to the client) — so the decision can be made from the report rather than from re-reading every page.
109
+ - **@voltro/database, @voltro/runtime, @voltro/cli** — An `.encrypted()` value is bound to where it lives. AES-256-GCM now takes `table:column:rowId` — or `table:column` when the row's identity is not known at write time — as additional authenticated data, and the envelope records which (`enc:v2:r:` / `enc:v2:c:`). A ciphertext was a bearer value before: whoever could write the database could copy one subject's encrypted token into another subject's row and let the app, acting for the second, decrypt and use the first's credential — no key needed, the app is the deputy that holds it. Bound, the copied value fails authentication in its new home, in another row and in another column alike. Set-based `updateMany` writes that touch an encrypted column are expanded per row so each row gets its own binding; a projected read that omits `id` on such a table fetches it and strips it, so the binding can be verified. The connection vault binds its tokens and PKCE verifiers the same way, and workflow step payloads are column-bound. Rows written before (`enc:v1:`) still read; `voltro db encrypt-column` re-binds them in place, resumably, alongside the raw-encoding normalisation it already did.
110
+ - **@voltro/i18n** — `useFormatDate` and `useRelativeTime` accept `null` / `undefined` and render `options.fallback` (default `''`) for them and for a value that is not a date — a nullable column reaching a component as `null` used to render the Unix epoch (`new Date(null)`), so every unguarded call showed 1.1.1970 for an open row. `formatDateValue` is the pure half, exported. The golden churn is the two hooks' `value` parameter widening from `DateInput` to `MaybeDateInput` and `options` gaining `fallback` — every existing call still compiles.
111
+ - **@voltro/integration-http, @voltro/plugin-atlassian** — `@voltro/integration-http` retries transient failures for the idempotent methods only — `GET` / `PUT` / `DELETE` (`policy.retryMethods`); a `POST` is no longer retried by default, because a proxy answering 502 after the origin has committed the write turns a retry into a duplicate (a duplicated Jira `createIssue` is the case). Such a failure still surfaces `transient: true`, with "not retried: POST is not idempotent" in the message; a call the caller has made idempotent passes `retry: true` on `request` (`retry: false` disables retries for any call). Retries are also bounded per host by a retry budget (`policy.retryBudget`, default 20 % of the trailing 10 s window's requests plus a floor of 10, per process; `false` disables it): every first attempt still goes out, only the amplification is capped, which is why it is a budget rather than a circuit breaker. `retryBudgetSnapshot(host)` reads the ledger. The Atlassian docs also stop claiming a 401 carries `code: 'session_expired'` — it has been `'unauthorized'` since the rename. The golden churn is `RequestArgs.method` spelled through the new `HttpMethod` alias — the same four literals, no call-site effect.
112
+ - **@voltro/protocol, @voltro/runtime, @voltro/client** — A replay whose key is still being executed by the first call is the typed `MutationInFlight { traceId, retryAfterMs }` on every mutation's wire union — it was `Effect.die`, presented client-side as the handler having failed while the write was committing, for exactly the handler that dies mid-socket-cut: the long one. `useMutation` now waits the server's `retryAfterMs` inside the replay window and replays the same key once more; REST keeps answering 409.
113
+ - **@voltro/database, @voltro/runtime, @voltro/protocol, @voltro/cli, @voltro/testing, @voltro/sql-mysql, @voltro/sql-postgres, @voltro/sql-mssql, @voltro/sql-sqlite, @voltro/sql-turso** — A read-modify-write inside a mutation no longer loses the second writer's edit. "It runs in a transaction" protects it on no engine at the default isolation — a plain SELECT takes no row lock — and what the engine did about the stale write ranged from an opaque `Failed to execute statement` (MariaDB 11.6.2+, `ER_CHECKREAD`) to applying it in silence (MySQL, Postgres, SQL Server, SQLite). Five changes, one shape:
114
+
115
+ - **Inside a transaction, a read is an expectation.** A keyed `ctx.store.update` of a `.version()` row that the same transaction has read carries the version it read, without the caller sending one; a row that moved in between is a conflict the mutation runner replays (the handler re-reads, like a deadlock replay), and only after the bounded replays does the typed `VersionConflict` surface — now on every mutation's wire union, like `MutationInFlight`. A row the transaction never read stays last-write-wins; a caller's own expectation is still answered, never replayed. An insert that omits the version column starts the row at `INITIAL_VERSION` on every store. - **The transient-contention list lives once**, in `@voltro/database` (`transientContention.ts`), read by every dialect's `retryFilter` and by the mutation runner; MariaDB's `ER_CHECKREAD` (1020) is on it. The filters also expand a `FiberFailure` before looking for a code — a statement that failed inside the transactional view rejected with one, and the store's own replay never saw the deadlock it was written for. - **`ctx.store.raw` exists inside a mutation**, on the transaction's own connection (every SQL dialect's transactional view carries it), so a `SELECT … FOR UPDATE` spelled by hand locks for the rest of that transaction. The docs had promised it on every SQL store; the view a mutation receives had none. - **Set-based writes on a transaction are one statement.** The mysql/mariadb, mssql and sqlite transactional views decomposed `updateMany` / `deleteMany` into a SELECT of ids and keyed writes, so the predicate was evaluated by a snapshot read rather than by the write — the version compare-and-swap then matched a row another transaction had already moved. They now run the same predicate-carrying statement the pool path (and postgres) always did. - **The replay bound is the deployment's.** `mutations.replayAttempts` in `app.config.ts` (default 3, env `VOLTRO_MUTATION_REPLAY_ATTEMPTS`), one resolver on both boot paths and in `@voltro/testing`'s `invoke`. The runner resets its post-commit queue per run of the transaction body, inside the store's bracket — that bracket replays the body itself now, and a reset outside it would have queued the aborted body's `afterCommit` work twice. - **`voltro doctor` names the handlers** that read a row, rebuild it and write it back with a keyed `update` on a table without `.version()` (`mutation/read-modify-write-unversioned`), so the tables that need the column are a report. - The concurrency page carries the per-engine matrix, the three protections, what the read-as-expectation does not see, and when a row lock beats a replay.
116
+ - **@voltro/cli** — `voltro test <directory>` naming a tree that contains no tests now FAILS (exit 1, "No test files found") instead of passing — an explicit directory is the same statement of intent as an explicit filter, and a CI line pointing at the wrong tree was green forever. The bare `voltro test` (cwd as root, nothing named) still passes with nothing to run.
117
+ - **@voltro/workflow, @voltro/cli** — A queued workflow start that fails to drain is retried with backoff — one second, doubling, capped at five minutes, jittered — instead of on every tick, so one undrainable intent no longer produces a warning every two seconds on every pod. After `workflows.deadLetterAfterAttempts` failed attempts (default 50 — several hours under that backoff; `false` disables it) the intent is dead-lettered exactly like a permanently invalid payload: `onFailure` once, the row retained for `voltro workflows flow`, re-armable with `flow retry`. The backoff rides the intent's `dueAt`, the field the drain window already selects on.
118
+
119
+ ### Fixed
120
+
121
+ - **@voltro/ai** — `runAssistant` — and with it every synthesized `<agent>.send` turn — records its terminal token usage into `_voltro_ai_usage` (`operation: 'agent.turn'`, attributed to the agent's name). A tool loop is a model call like any other, and the agent turn was the one path that spent without recording: `aiStep` records, the turn wrote nothing, so an app whose model sites are mostly tool loops could show zero spend. `recordUsage: false` keeps the ledger untouched for a caller that records elsewhere; `agent` names the row.
122
+ - **@voltro/client** — An optimistic patch whose server echo arrived **before** the mutation's own response is now retired at confirmation instead of five seconds later. The server emits a change at commit and answers the mutation only after the handler returns, so on a shared socket the echo is routinely queued ahead of the `Exit`, and a handler that keeps working after its write (an event publication, an audit row) widens the gap. The cache credited an echo only when it landed AFTER `confirmByMutation`; an earlier one left the patch unconfirmed, the confirmation then armed the resync window, and every such write ended in the `no superseding server event` warning plus a full re-fetch of a subscription that was already showing the truth. Each entry now counts base movements (every delta; a snapshot only when it changed the rows), each patch carries the count it went on at, and confirmation retires a patch whose base moved since — through the same door a later echo uses. A movement that preceded the patch, or a snapshot that repeated the base, still does not count.
123
+ - **@voltro/cli** — `voltro doctor`'s `page-without-test` hint named the retired `users/[id].page.tsx` convention; it now shows `users/[id]/page.tsx → users/[id]/page.test.tsx`.
124
+ - **@voltro/cli, @voltro/database** — `expires()` tables are registered with the retention sweep on both boot paths — on `expiresAt`, on every dialect, after a grace of `VOLTRO_EXPIRED_ROWS_TTL_HOURS` (default 24) — so an expired row is physically deleted as the docs said. Nothing registered them before: the mixin's header said the storage half was "postgres only" and the docs said "every dialect, eventually", and both described a registration that did not exist. Reads filtered immediately throughout; this is the storage half of that promise.
125
+ - **@voltro/i18n** — `assertCatalogParity` reads ICU ARGUMENTS by walking the braces instead of matching every `{` — the first word of a plural or select BRANCH (`{count, plural, one {extended by 1 day} …}`) was reported as a placeholder, so two catalogs that agreed on every variable failed parity on their prose. Arguments declared inside a branch (`{gender, select, female {{name} …}}`) still count; ICU quoting is honoured. `placeholdersOf` is exported.
126
+ - **@voltro/cli** — The start bundle (`.framework/dist-web/startBundle/`) and the api bundle (`.framework/dist-api/`) carry a `package.json` with `"type": "module"`. Both are ESM in `.js` files; under an app whose package.json is CommonJS-typed or untyped, Node reparsed the entry on every start and warned (`MODULE_TYPELESS_PACKAGE_JSON`). The artefact describes itself now; the app keeps its own module mode.
127
+ - **@voltro/cli** — `voltro serve` now unions plugin-declared error schemas (`errorSchemas` on a plugin) into every lifted rpc's wire error, as `voltro dev` always did and as the generated client group already expected. Before, a plugin interceptor failing with its declared error (a rate-limit `RateLimited`, an entitlement refusal) reached a dev client typed and crossed the production wire as an untyped defect. One derivation (`pluginExtraErrors`) feeds both boot paths now.
128
+ - **@voltro/runtime** — A `.version()` update is now a real compare-and-swap: the UPDATE carries the version it read as a predicate (`WHERE id = ? AND version = <read>`) and the affected-row count decides, on every dialect and at any isolation level. It used to compare the version in memory and then write by key, so two writers that read the same version both landed and the first change was silently lost — inside a transaction too, since a plain SELECT takes no row lock under READ COMMITTED. A write with no expectation that loses its swap is re-read and re-applied (bounded) and surfaces `VersionConflict` only when the row stays contended; a set-based update (`ctx.store.updateMany`, the fluent `.set()`) on a versioned table now advances every matched row's version by one instead of leaving it still.
129
+
130
+ ---
131
+
132
+ ## [0.63.0] — 2026-09-03
133
+
134
+ ### ⚠ BREAKING
135
+
136
+ - **@voltro/client, @voltro/runtime, @voltro/cli, @voltro/web, @voltro/react-native** — Short transport gaps now have one bounded recovery contract. Raw diagnostics remain immediate, while `useConnectionStatus` exposes `recovering` during a configurable grace window and `useOnRpcError` emits exactly one visible transport failure only if the gap remains open. Same-subject runtime swaps keep subscription bases, revisions and optimistic patches; subject changes discard them. Interrupted mutations report `outcome: unknown` and replay once with the same key only after the deployed API proves that server idempotency is active. The codemod explains the new status member and the raw versus presentation error channels.
137
+
138
+ **`voltro update` carries you across this** — codemod `0.63.0/01_recovery-presentation`. If you pin versions by hand and never run it, print the notes without changing anything: `voltro update --codemods-only --from <your current version> --dry-run` (this one ships in 0.63.0).
139
+
140
+ ### Fixed
141
+
142
+ - **@voltro/database, @voltro/sql-turso, @voltro/sql-sqlite, @voltro/sql-mssql** — Framework-table evolution now converges under real SQLite and both local Turso transports, and has a live MSSQL fixture. The new Turso runs also fixed internal table protection, boolean/number introspection normalization, and case-insensitive SQLite-family index-name comparison. The MSSQL fixture exposed and fixes a second convergence bug: unbounded text that MSSQL auto-bounds for an index is now rendered and compared with the same indexed-column rule, so a second plan no longer tries to widen an indexed `NVARCHAR(450)` to `NVARCHAR(MAX)`.
143
+
144
+ ---
145
+
42
146
  ## [0.62.3] — 2026-09-03
43
147
 
44
148
  ### Added
package/dist/index.d.ts CHANGED
@@ -222,6 +222,7 @@ export declare class MssqlStore implements DataStore {
222
222
  raw<T extends object = Row>(fragment: RawSqlFragment, _opts?: {
223
223
  dependsOn?: ReadonlyArray<string>;
224
224
  }): Promise<ReadonlyArray<T>>;
225
+ /* Excluded from this release type: rawOn */
225
226
  private runWithEager;
226
227
  /* Excluded from this release type: getInternalRunWithEager */
227
228
  /**
@@ -254,6 +255,8 @@ export declare class MssqlStore implements DataStore {
254
255
  deleteMany(table: string, options: {
255
256
  where: Predicate;
256
257
  }): Promise<number>;
258
+ /* Excluded from this release type: executeUpdateMany */
259
+ /* Excluded from this release type: executeDeleteMany */
257
260
  upsert(t: string, r: Row, options: {
258
261
  conflictColumns: ReadonlyArray<string>;
259
262
  update?: ReadonlyArray<string> | ((existing: Row) => Readonly<Record<string, unknown>>);
@@ -288,6 +291,12 @@ export declare class MssqlStore implements DataStore {
288
291
  /* Excluded from this release type: getInternalExecuteInsertMany */
289
292
  /* Excluded from this release type: getInternalExecutePatchJson */
290
293
  /* Excluded from this release type: getInternalExecuteUpdate */
294
+ getInternalExecuteUpdateMany(): (table: string, patch: Readonly<Record<string, unknown>>, options: {
295
+ where: Predicate;
296
+ }, txn: TxnContext | null, events: Array<ChangeEvent> | null, attr?: WriteAttribution | undefined) => Promise<number>;
297
+ getInternalExecuteDeleteMany(): (table: string, options: {
298
+ where: Predicate;
299
+ }, txn: TxnContext | null, events: Array<ChangeEvent> | null, attr?: WriteAttribution | undefined) => Promise<number>;
291
300
  /* Excluded from this release type: getInternalExecuteDelete */
292
301
  /* Excluded from this release type: getInternalExecuteUpsert */
293
302
  /* Excluded from this release type: getInternalExecuteInsertIgnore */
package/dist/index.js CHANGED
@@ -1,21 +1,21 @@
1
1
  import { MssqlClient as e } from "@effect/sql-mssql";
2
2
  import { Config as t, Duration as n, Effect as r, Layer as i, ManagedRuntime as a, Option as o, Redacted as s } from "effect";
3
- import { CDC_OFFSETS_TABLE as c, DEFAULT_ACQUIRE_TIMEOUT_MS as l, EagerCardinalityError as u, _voltroMssqlCdcOffsetsTable as d, attachEagerLoads as f, attributionFields as p, bulkInsertLimitsFor as m, chunkRowsForInsert as h, compileEagerJson as g, compilePredicate as _, compileRawFragment as v, compileSelect as y, decodeRowsFromSchema as b, encodeRowForSchema as x, externalChangeEvent as S, hasEagerLoads as C, isTableReactive as w, makeEagerFallbackReporter as ee, observeDbOp as T, qualifyTable as E, quoteIdent as te, raiseChangeListenerCeiling as ne, recordsTable as D, requireTable as re, runStoreTransaction as ie, runWriteRecorders as O, stampGeneratedId as k, stampGeneratedIds as A, withCapturedAttribution as j } from "@voltro/database";
4
- import { EventEmitter as M } from "node:events";
3
+ import { CDC_OFFSETS_TABLE as c, DEFAULT_ACQUIRE_TIMEOUT_MS as l, EagerCardinalityError as u, MSSQL_TRANSIENT_NUMBERS as d, _voltroMssqlCdcOffsetsTable as f, attachEagerLoads as p, attributionFields as m, bulkInsertLimitsFor as h, causeBranches as g, chunkRowsForInsert as _, compileEagerJson as v, compilePredicate as y, compileRawFragment as ee, compileSelect as b, decodeRowsFromSchema as x, encodeRowForSchema as S, externalChangeEvent as te, hasEagerLoads as ne, isTableReactive as C, makeEagerFallbackReporter as re, observeDbOp as w, qualifyTable as T, quoteIdent as E, raiseChangeListenerCeiling as ie, recordsTable as D, requireTable as O, runStoreTransaction as k, runWriteRecorders as A, stampGeneratedId as j, stampGeneratedIds as M, withCapturedAttribution as ae } from "@voltro/database";
4
+ import { EventEmitter as oe } from "node:events";
5
5
  import { createLogger as N } from "@voltro/logger";
6
6
  import { SqlClient as P, TransactionConnection as F } from "@effect/sql/SqlClient";
7
7
  //#region src/sqlLayer.ts
8
- var ae = {
8
+ var se = {
9
9
  ...e.defaultParameterTypes,
10
10
  null: e.defaultParameterTypes.object
11
- }, oe = (e) => {
11
+ }, ce = (e) => {
12
12
  let t = e.acquireTimeoutMs ?? l;
13
13
  return t > 0 ? t : void 0;
14
14
  }, I = (r) => {
15
- let i = oe(r);
15
+ let i = ce(r);
16
16
  return e.layerConfig({
17
17
  server: t.succeed(r.server),
18
- parameterTypes: t.succeed(ae),
18
+ parameterTypes: t.succeed(se),
19
19
  ...i === void 0 ? {} : { connectTimeout: t.succeed(n.millis(i)) },
20
20
  ...r.port === void 0 ? {} : { port: t.succeed(r.port) },
21
21
  ...r.database === void 0 ? {} : { database: t.succeed(r.database) },
@@ -25,7 +25,7 @@ var ae = {
25
25
  ...r.trustServer === void 0 ? {} : { trustServer: t.succeed(r.trustServer) },
26
26
  ...r.maxConnections === void 0 ? {} : { maxConnections: t.succeed(r.maxConnections) }
27
27
  });
28
- }, se = (e) => {
28
+ }, L = (e) => {
29
29
  let t = e.get("sslmode");
30
30
  if (t !== null) {
31
31
  if (t === "require") return !0;
@@ -40,7 +40,7 @@ var ae = {
40
40
  throw Error(`DB_URL '?${t}=${n}' is not supported by the mssql dialect — use 'true'/'1' or 'false'/'0'.`);
41
41
  }
42
42
  }
43
- }, L = (e) => {
43
+ }, R = (e) => {
44
44
  let t = e.acquireTimeoutMs === void 0 ? {} : { acquireTimeoutMs: e.acquireTimeoutMs }, n = (e) => e === void 0 ? {} : {
45
45
  encrypt: e,
46
46
  ...e ? { trustServer: !0 } : {}
@@ -53,7 +53,7 @@ var ae = {
53
53
  username: decodeURIComponent(r.username || "sa"),
54
54
  password: decodeURIComponent(r.password || ""),
55
55
  database: r.pathname.replace(/^\//, "") || "master",
56
- ...n(e.ssl ?? se(r.searchParams)),
56
+ ...n(e.ssl ?? L(r.searchParams)),
57
57
  ...e.maxConnections === void 0 ? {} : { maxConnections: e.maxConnections },
58
58
  ...t
59
59
  };
@@ -68,7 +68,7 @@ var ae = {
68
68
  ...e.maxConnections === void 0 ? {} : { maxConnections: e.maxConnections },
69
69
  ...t
70
70
  };
71
- }, R = (e) => I(L(e)), z = /* @__PURE__ */ new Set(["1205"]), B = (e) => {
71
+ }, z = (e) => I(R(e)), B = d, V = (e) => {
72
72
  let t = e;
73
73
  for (let e = 0; e < 5 && typeof t == "object" && t; e++) {
74
74
  let e = t.number;
@@ -77,13 +77,13 @@ var ae = {
77
77
  if (typeof n == "string") return n;
78
78
  t = t.cause;
79
79
  }
80
- }, V = (e) => {
81
- let t = B(e);
82
- return t !== void 0 && z.has(t);
83
- }, H = /* @__PURE__ */ new Set(["2601", "2627"]), U = (e) => {
84
- let t = B(e);
85
- return t !== void 0 && H.has(t);
86
- }, W = (e) => V(e) ? "retry" : "noRetry", G = {
80
+ }, H = (e) => g(e).some((e) => {
81
+ let t = V(e);
82
+ return t !== void 0 && B.has(t);
83
+ }), U = /* @__PURE__ */ new Set(["2601", "2627"]), W = (e) => {
84
+ let t = V(e);
85
+ return t !== void 0 && U.has(t);
86
+ }, G = (e) => H(e) ? "retry" : "noRetry", le = {
87
87
  I: "insert",
88
88
  U: "update",
89
89
  D: "delete"
@@ -124,7 +124,7 @@ var ae = {
124
124
  return;
125
125
  }
126
126
  for (let t of s.rows) {
127
- let r = G[t.__ct_op];
127
+ let r = le[t.__ct_op];
128
128
  if (!r) continue;
129
129
  let { __ct_op: i, __ct_id: a, ...o } = t;
130
130
  if (r === "delete") e.onChange({
@@ -134,7 +134,7 @@ var ae = {
134
134
  new: null
135
135
  });
136
136
  else {
137
- let t = b([o], n, "mssql")[0];
137
+ let t = x([o], n, "mssql")[0];
138
138
  e.onChange({
139
139
  table: n,
140
140
  op: r,
@@ -178,23 +178,23 @@ var ae = {
178
178
  }, X = N({ scope: "voltro:mssql" }), Z = async (e) => {
179
179
  let t = e.changeStrategy ?? "inline", n = t;
180
180
  t === "cdc" && !e.cdcConfig && (X.warn("changeStrategy='cdc' requires cdcConfig (replicaId + includeTables); falling back to 'inline'."), n = "inline");
181
- let r = e.tracerLayer ? i.mergeAll(e.sqlLayer, e.tracerLayer) : e.sqlLayer, o = a.make(r), s = new ce(await o.runPromise(P), o, n);
181
+ let r = e.tracerLayer ? i.mergeAll(e.sqlLayer, e.tracerLayer) : e.sqlLayer, o = a.make(r), s = new Q(await o.runPromise(P), o, n);
182
182
  return n === "cdc" && e.cdcConfig && await s.startCdcConsumer(e.cdcConfig), s;
183
- }, ce = class e {
183
+ }, Q = class e {
184
184
  sql;
185
185
  runtime;
186
186
  changeStrategy;
187
187
  namespace;
188
188
  emitter;
189
189
  inflightTxns = 0;
190
- reportEagerFallback = ee(X);
190
+ reportEagerFallback = re(X);
191
191
  cdcHandle = null;
192
192
  cdcCheckpointTimer = null;
193
193
  cdcPendingVersion = null;
194
194
  cdcReplicaId = "";
195
195
  cdcStreamName = "default";
196
196
  constructor(e, t, n = "inline", r = null, i) {
197
- this.sql = e, this.runtime = t, this.changeStrategy = n, this.namespace = r, this.emitter = i ?? new M(), ne(this.emitter);
197
+ this.sql = e, this.runtime = t, this.changeStrategy = n, this.namespace = r, this.emitter = i ?? new oe(), ie(this.emitter);
198
198
  }
199
199
  get inlineEmit() {
200
200
  return this.changeStrategy === "inline";
@@ -203,19 +203,19 @@ var ae = {
203
203
  return t === this.namespace ? this : new e(this.sql, this.runtime, this.changeStrategy, t, this.emitter);
204
204
  }
205
205
  nsT(e) {
206
- return E(this.namespace, e);
206
+ return T(this.namespace, e);
207
207
  }
208
208
  __mssqlReplicationFriend = { runEffect: (e) => this.runtime.runPromise(e) };
209
209
  async executeQuery(e, t) {
210
- let n = y(e, this.sql, this.namespace), i = t ? r.provideService(n, F, t) : n, a = await T("mssql", "select", () => this.runtime.runPromise(i));
211
- return b(a, e.table, "mssql");
210
+ let n = b(e, this.sql, this.namespace), i = t ? r.provideService(n, F, t) : n, a = await w("mssql", "select", () => this.runtime.runPromise(i));
211
+ return x(a, e.table, "mssql");
212
212
  }
213
213
  decodeWritten(e, t) {
214
- return b([t], e, "mssql")[0] ?? t;
214
+ return x([t], e, "mssql")[0] ?? t;
215
215
  }
216
216
  async executeInsert(e, t, n, i, a) {
217
- t = k(e, t);
218
- let o = this.sql, s = o`INSERT INTO ${o(this.nsT(e))} ${o.insert(x(t, e)).returning("*")}`, c = n ? r.provideService(s, F, n) : s, l = (await this.runtime.runPromise(c))[0];
217
+ t = j(e, t);
218
+ let o = this.sql, s = o`INSERT INTO ${o(this.nsT(e))} ${o.insert(S(t, e)).returning("*")}`, c = n ? r.provideService(s, F, n) : s, l = (await this.runtime.runPromise(c))[0];
219
219
  if (!l) throw Error(`MssqlStore.insert: row not returned post-insert in '${e}'`);
220
220
  let u = this.decodeWritten(e, l);
221
221
  return await this.routeEvent({
@@ -226,7 +226,7 @@ var ae = {
226
226
  }, i, n, a), u;
227
227
  }
228
228
  async executeUpdate(e, t, n, i, a, o) {
229
- let s = this.sql, c = s`UPDATE ${s(this.nsT(e))} SET ${s.update(x(n, e)).returning("*")} WHERE ${s("id")} = ${t}`, l = i ? r.provideService(c, F, i) : c, u = (await this.runtime.runPromise(l))[0];
229
+ let s = this.sql, c = s`UPDATE ${s(this.nsT(e))} SET ${s.update(S(n, e)).returning("*")} WHERE ${s("id")} = ${t}`, l = i ? r.provideService(c, F, i) : c, u = (await this.runtime.runPromise(l))[0];
230
230
  if (!u) return null;
231
231
  let d = this.decodeWritten(e, u);
232
232
  return await this.routeEvent({
@@ -237,8 +237,8 @@ var ae = {
237
237
  }, a, i, o), d;
238
238
  }
239
239
  async executeInsertMany(e, t, n, i, a) {
240
- if (t = A(e, t), t.length === 0) return [];
241
- let o = this.sql, s = h(t, m("mssql")), c = (t) => o`INSERT INTO ${o(this.nsT(e))} ${o.insert(t.map((t) => x(t, e))).returning("*")}`, l;
240
+ if (t = M(e, t), t.length === 0) return [];
241
+ let o = this.sql, s = _(t, h("mssql")), c = (t) => o`INSERT INTO ${o(this.nsT(e))} ${o.insert(t.map((t) => S(t, e))).returning("*")}`, l;
242
242
  if (s.length === 1) {
243
243
  let e = c(s[0]), t = n ? r.provideService(e, F, n) : e;
244
244
  l = await this.runtime.runPromise(t);
@@ -247,7 +247,7 @@ var ae = {
247
247
  for (let t of s) e.push(...await this.runtime.runPromise(r.provideService(c(t), F, n)));
248
248
  l = e;
249
249
  } else l = await this.runtime.runPromise(o.withTransaction(r.map(r.forEach(s, c, { concurrency: 1 }), (e) => e.flat())));
250
- let u = t.map((e) => e.id), d = new Map(b(l, e, "mssql").map((e) => [e.id, e])), f = u.map((e) => d.get(e)).filter((e) => e !== void 0), p = f.length === l.length ? f : [...l];
250
+ let u = t.map((e) => e.id), d = new Map(x(l, e, "mssql").map((e) => [e.id, e])), f = u.map((e) => d.get(e)).filter((e) => e !== void 0), p = f.length === l.length ? f : [...l];
251
251
  for (let t of p) await this.routeEvent({
252
252
  table: e,
253
253
  op: "insert",
@@ -277,7 +277,7 @@ var ae = {
277
277
  }, i, n, a), !0;
278
278
  }
279
279
  async appendInTxn(e, t, n) {
280
- let i = this.sql, a = i`INSERT INTO ${i(this.nsT(e))} ${i.insert(x(t, e))}`;
280
+ let i = this.sql, a = i`INSERT INTO ${i(this.nsT(e))} ${i.insert(S(t, e))}`;
281
281
  await this.runtime.runPromise(n ? r.provideService(a, F, n) : a);
282
282
  }
283
283
  async maxInTxn(e, t, n, i) {
@@ -286,9 +286,9 @@ var ae = {
286
286
  }
287
287
  async routeEvent(e, t, n = null, r) {
288
288
  e = {
289
- ...p(r),
289
+ ...m(r),
290
290
  ...e
291
- }, D(e.table) && await O({
291
+ }, D(e.table) && await A({
292
292
  append: (e, t) => this.appendInTxn(e, t, n),
293
293
  maxOf: (e, t, r) => this.maxInTxn(e, t, r, n)
294
294
  }, {
@@ -298,20 +298,23 @@ var ae = {
298
298
  prev: e.old,
299
299
  traceId: e.traceId,
300
300
  subjectId: e.subjectId
301
- }), w(e.table) && (t === null ? this.inlineEmit && this.emitter.emit("change", e) : t.push(e));
301
+ }), C(e.table) && (t === null ? this.inlineEmit && this.emitter.emit("change", e) : t.push(e));
302
302
  }
303
303
  query(e) {
304
304
  return this.runWithEager(e, null);
305
305
  }
306
306
  raw(e, t) {
307
- let n = v(e, this.sql);
308
- return T("mssql", "raw", () => this.runtime.runPromise(n));
307
+ return this.rawOn(e, null);
308
+ }
309
+ rawOn(e, t) {
310
+ let n = ee(e, this.sql), i = t ? r.provideService(n, F, t) : n;
311
+ return w("mssql", "raw", () => this.runtime.runPromise(i));
309
312
  }
310
313
  async runWithEager(e, t) {
311
- if (!C(e)) return this.executeQuery(e, t);
312
- let n = this.namespace === null ? g(e, this.sql, "mssql") : null;
314
+ if (!ne(e)) return this.executeQuery(e, t);
315
+ let n = this.namespace === null ? v(e, this.sql, "mssql") : null;
313
316
  if (n !== null) try {
314
- let e = t ? r.provideService(n.fragment, F, t) : n.fragment, i = await T("mssql", "select", () => this.runtime.runPromise(e));
317
+ let e = t ? r.provideService(n.fragment, F, t) : n.fragment, i = await w("mssql", "select", () => this.runtime.runPromise(e));
315
318
  return n.decode(i);
316
319
  } catch (t) {
317
320
  if (t instanceof u) throw t;
@@ -328,14 +331,14 @@ var ae = {
328
331
  reason: "not-compilable"
329
332
  });
330
333
  let i = await this.executeQuery(e, t);
331
- return f(i, e.eager, e.sourceTable ?? re(e.table), (e) => this.executeQuery(e, t));
334
+ return p(i, e.eager, e.sourceTable ?? O(e.table), (e) => this.executeQuery(e, t));
332
335
  }
333
336
  getInternalRunWithEager() {
334
337
  return this.runWithEager.bind(this);
335
338
  }
336
339
  localWrite(e, t, n) {
337
340
  let r = (e) => D(t) ? this.writeWithRecorders(n, e) : n(e, null, null);
338
- return T("mssql", e, () => j(r));
341
+ return w("mssql", e, () => ae(r));
339
342
  }
340
343
  async writeWithRecorders(e, t) {
341
344
  let n = [], i = this.sql, a = await this.runtime.runPromise(i.withTransaction(r.flatMap(r.serviceOption(F), (i) => o.isNone(i) ? r.fail(/* @__PURE__ */ Error("MssqlStore.write: TransactionConnection missing.")) : r.tryPromise({
@@ -360,25 +363,31 @@ var ae = {
360
363
  delete(e, t) {
361
364
  return this.localWrite("delete", e, (n, r, i) => this.executeDelete(e, t, r, i, n));
362
365
  }
363
- async updateMany(e, t, n) {
364
- let r = this.sql, i = _(n.where, r, this.namespace), a = r`UPDATE ${r(this.nsT(e))} SET ${r.update(x(t, e))} OUTPUT INSERTED.* WHERE ${i}`, o = await T("mssql", "update", () => this.runtime.runPromise(a));
365
- for (let t of o) await this.routeEvent({
366
+ updateMany(e, t, n) {
367
+ return this.executeUpdateMany(e, t, n, null, null);
368
+ }
369
+ deleteMany(e, t) {
370
+ return this.executeDeleteMany(e, t, null, null);
371
+ }
372
+ async executeUpdateMany(e, t, n, i, a, o) {
373
+ let s = this.sql, c = y(n.where, s, this.namespace), l = s`UPDATE ${s(this.nsT(e))} SET ${s.update(S(t, e))} OUTPUT INSERTED.* WHERE ${c}`, u = i ? r.provideService(l, F, i) : l, d = await w("mssql", "update", () => this.runtime.runPromise(u));
374
+ for (let t of d) await this.routeEvent({
366
375
  table: e,
367
376
  op: "update",
368
377
  old: null,
369
378
  new: t
370
- }, null, null);
371
- return o.length;
379
+ }, a, i, o);
380
+ return d.length;
372
381
  }
373
- async deleteMany(e, t) {
374
- let n = this.sql, r = _(t.where, n, this.namespace), i = n`DELETE FROM ${n(this.nsT(e))} OUTPUT DELETED.* WHERE ${r}`, a = await T("mssql", "delete", () => this.runtime.runPromise(i));
375
- for (let t of a) await this.routeEvent({
382
+ async executeDeleteMany(e, t, n, i, a) {
383
+ let o = this.sql, s = y(t.where, o, this.namespace), c = o`DELETE FROM ${o(this.nsT(e))} OUTPUT DELETED.* WHERE ${s}`, l = n ? r.provideService(c, F, n) : c, u = await w("mssql", "delete", () => this.runtime.runPromise(l));
384
+ for (let t of u) await this.routeEvent({
376
385
  table: e,
377
386
  op: "delete",
378
387
  old: t,
379
388
  new: null
380
- }, null, null);
381
- return a.length;
389
+ }, i, n, a);
390
+ return u.length;
382
391
  }
383
392
  upsert(e, t, n) {
384
393
  return this.localWrite("upsert", e, (r, i, a) => this.executeUpsert(e, t, n, i, a, r));
@@ -398,7 +407,7 @@ var ae = {
398
407
  return r;
399
408
  }
400
409
  async executeUpsert(e, t, n, r, i, a) {
401
- if (t = k(e, t), typeof n.update != "function" && n.conflictColumns.length > 0) return this.mergeUpsert(e, t, {
410
+ if (t = j(e, t), typeof n.update != "function" && n.conflictColumns.length > 0) return this.mergeUpsert(e, t, {
402
411
  conflictColumns: n.conflictColumns,
403
412
  ...n.update ? { update: n.update } : {}
404
413
  }, r, i);
@@ -407,14 +416,14 @@ var ae = {
407
416
  try {
408
417
  return await this.executeInsert(e, t, r, i);
409
418
  } catch (a) {
410
- if (!U(a)) throw a;
419
+ if (!W(a)) throw a;
411
420
  let o = await this.findByConflict(e, t, n.conflictColumns, r);
412
421
  if (!o) throw a;
413
422
  return await this.executeUpdate(e, o.id, this.upsertPatch(t, o, n), r, i) ?? o;
414
423
  }
415
424
  }
416
425
  async mergeUpsert(e, t, n, i, a) {
417
- let o = this.sql, s = Object.keys(t), c = Array.isArray(n.update) ? n.update.filter((e) => s.includes(e)) : s.filter((e) => e !== "id" && !n.conflictColumns.includes(e)), l = x(t, e), u = s.map((e) => o`${l[e]}`), d = s.map((e) => o`${o(e)}`), f = n.conflictColumns.map((e) => o`tgt.${o(e)} = src.${o(e)}`), p = s.map((e) => o`${o(e)}`), m = s.map((e) => o`src.${o(e)}`), h = (c.length > 0 ? c : [n.conflictColumns[0]]).map((e) => o`tgt.${o(e)} = src.${o(e)}`), g = o`
426
+ let o = this.sql, s = Object.keys(t), c = Array.isArray(n.update) ? n.update.filter((e) => s.includes(e)) : s.filter((e) => e !== "id" && !n.conflictColumns.includes(e)), l = S(t, e), u = s.map((e) => o`${l[e]}`), d = s.map((e) => o`${o(e)}`), f = n.conflictColumns.map((e) => o`tgt.${o(e)} = src.${o(e)}`), p = s.map((e) => o`${o(e)}`), m = s.map((e) => o`src.${o(e)}`), h = (c.length > 0 ? c : [n.conflictColumns[0]]).map((e) => o`tgt.${o(e)} = src.${o(e)}`), g = o`
418
427
  MERGE ${o(this.nsT(e))} WITH (HOLDLOCK) AS tgt
419
428
  USING (VALUES (${o.csv(u)})) AS src (${o.csv(d)})
420
429
  ON ${o.and(f)}
@@ -422,22 +431,22 @@ var ae = {
422
431
  WHEN NOT MATCHED THEN INSERT (${o.csv(p)}) VALUES (${o.csv(m)})
423
432
  OUTPUT $action AS ${o("__action")}, INSERTED.*;`, _ = i ? r.provideService(g, F, i) : g, v = (await this.runtime.runPromise(_))[0];
424
433
  if (!v) throw Error(`MssqlStore.upsert: MERGE returned no row for '${e}'`);
425
- let y = v.__action, { __action: b, ...S } = v, C = this.decodeWritten(e, S);
434
+ let y = v.__action, { __action: ee, ...b } = v, x = this.decodeWritten(e, b);
426
435
  return this.routeEvent({
427
436
  table: e,
428
437
  op: y === "INSERT" ? "insert" : "update",
429
438
  old: null,
430
- new: C
431
- }, a), C;
439
+ new: x
440
+ }, a), x;
432
441
  }
433
442
  async executeInsertIgnore(e, t, n, r, i, a) {
434
- t = k(e, t);
443
+ t = j(e, t);
435
444
  let o = await this.findByConflict(e, t, n.conflictColumns, r);
436
445
  if (o) return o;
437
446
  try {
438
447
  return await this.executeInsert(e, t, r, i);
439
448
  } catch (i) {
440
- if (!U(i)) throw i;
449
+ if (!W(i)) throw i;
441
450
  let a = await this.findByConflict(e, t, n.conflictColumns, r);
442
451
  if (!a) throw i;
443
452
  return a;
@@ -522,6 +531,12 @@ var ae = {
522
531
  getInternalExecuteUpdate() {
523
532
  return this.executeUpdate.bind(this);
524
533
  }
534
+ getInternalExecuteUpdateMany() {
535
+ return this.executeUpdateMany.bind(this);
536
+ }
537
+ getInternalExecuteDeleteMany() {
538
+ return this.executeDeleteMany.bind(this);
539
+ }
525
540
  getInternalExecuteDelete() {
526
541
  return this.executeDelete.bind(this);
527
542
  }
@@ -552,16 +567,16 @@ var ae = {
552
567
  }
553
568
  async emptyTablesOn(e, t) {
554
569
  if (e.length === 0) return;
555
- let n = this.sql, i = (e) => this.nsT(e), a = (e, t) => n.unsafe(`ALTER TABLE ${te(i(e), "mssql")} ${t ? "WITH CHECK CHECK" : "NOCHECK"} CONSTRAINT ALL`), o = r.acquireUseRelease(r.forEach(e, (e) => a(e, !1), { discard: !0 }), () => r.forEach(e, (e) => n`DELETE FROM ${n(i(e))}`, { discard: !0 }), () => r.orDie(r.ignore(r.forEach(e, (e) => a(e, !0), { discard: !0 })))), s = t === null ? n.withTransaction(o) : r.provideService(o, F, t);
570
+ let n = this.sql, i = (e) => this.nsT(e), a = (e, t) => n.unsafe(`ALTER TABLE ${E(i(e), "mssql")} ${t ? "WITH CHECK CHECK" : "NOCHECK"} CONSTRAINT ALL`), o = r.acquireUseRelease(r.forEach(e, (e) => a(e, !1), { discard: !0 }), () => r.forEach(e, (e) => n`DELETE FROM ${n(i(e))}`, { discard: !0 }), () => r.orDie(r.ignore(r.forEach(e, (e) => a(e, !0), { discard: !0 })))), s = t === null ? n.withTransaction(o) : r.provideService(o, F, t);
556
571
  await this.runtime.runPromise(s);
557
572
  }
558
573
  async transactional(e) {
559
574
  this.inflightTxns++;
560
575
  try {
561
- return await ie({
576
+ return await k({
562
577
  ...this.txnSpec("MssqlStore.transactional"),
563
578
  work: e,
564
- makeView: (e, t) => new le(this, e, t)
579
+ makeView: (e, t) => new ue(this, e, t)
565
580
  });
566
581
  } finally {
567
582
  this.inflightTxns--;
@@ -573,7 +588,7 @@ var ae = {
573
588
  dialect: "mssql",
574
589
  withTransaction: (e) => this.sql.withTransaction(e),
575
590
  runPromiseExit: (e) => this.runtime.runPromiseExit(e),
576
- isRetryable: V,
591
+ isRetryable: H,
577
592
  span: {
578
593
  name: "store.transactional",
579
594
  attributes: {
@@ -592,7 +607,7 @@ var ae = {
592
607
  return this.changeStrategy === "cdc" ? "fleet" : "local";
593
608
  }
594
609
  injectExternalChange(e) {
595
- w(e.table) && this.emitter.emit("change", S(e));
610
+ C(e.table) && this.emitter.emit("change", te(e));
596
611
  }
597
612
  run(e) {
598
613
  return this.runtime.runPromise(e);
@@ -615,7 +630,7 @@ var ae = {
615
630
  async ping() {
616
631
  await this.runtime.runPromise(this.sql`SELECT 1`);
617
632
  }
618
- }, le = class {
633
+ }, ue = class {
619
634
  parent;
620
635
  txn;
621
636
  attr;
@@ -624,6 +639,9 @@ var ae = {
624
639
  constructor(e, t, n = void 0) {
625
640
  this.parent = e, this.txn = t, this.attr = n;
626
641
  }
642
+ raw(e, t) {
643
+ return this.parent.rawOn(e, this.txn);
644
+ }
627
645
  query(e) {
628
646
  return this.parent.getInternalRunWithEager()(e, this.txn);
629
647
  }
@@ -645,29 +663,11 @@ var ae = {
645
663
  delete(e, t) {
646
664
  return this.parent.getInternalExecuteDelete()(e, t, this.txn, this.events, this.attr);
647
665
  }
648
- async updateMany(e, t, n) {
649
- let r = await this.query({
650
- table: e,
651
- predicate: n.where,
652
- order: [],
653
- take: void 0,
654
- skip: void 0,
655
- projection: ["id"]
656
- }), i = 0;
657
- for (let n of r) await this.update(e, n.id, t) && i++;
658
- return i;
659
- }
660
- async deleteMany(e, t) {
661
- let n = await this.query({
662
- table: e,
663
- predicate: t.where,
664
- order: [],
665
- take: void 0,
666
- skip: void 0,
667
- projection: ["id"]
668
- }), r = 0;
669
- for (let t of n) await this.delete(e, t.id) && r++;
670
- return r;
666
+ updateMany(e, t, n) {
667
+ return this.parent.getInternalExecuteUpdateMany()(e, t, n, this.txn, this.events, this.attr);
668
+ }
669
+ deleteMany(e, t) {
670
+ return this.parent.getInternalExecuteDeleteMany()(e, t, this.txn, this.events, this.attr);
671
671
  }
672
672
  upsert(e, t, n) {
673
673
  return this.parent.getInternalExecuteUpsert()(e, t, n, this.txn, this.events, this.attr);
@@ -691,9 +691,9 @@ var ae = {
691
691
  this.events.length = 0;
692
692
  }
693
693
  }
694
- }, Q = (e) => e.__mssqlReplicationFriend ?? null, $ = (e, t) => e === t ? 0 : e < t ? -1 : 1, ue = () => ({
694
+ }, $ = (e) => e.__mssqlReplicationFriend ?? null, de = (e, t) => e === t ? 0 : e < t ? -1 : 1, fe = () => ({
695
695
  async capturePrimaryPosition(e) {
696
- let t = Q(e);
696
+ let t = $(e);
697
697
  if (t === null) throw Error("mssqlReplicationAdapter: primary is not an MssqlStore (missing __mssqlReplicationFriend).");
698
698
  return t.runEffect(r.gen(function* () {
699
699
  return (yield* (yield* P)`
@@ -706,7 +706,7 @@ var ae = {
706
706
  }));
707
707
  },
708
708
  async probeReplicaPosition(e) {
709
- let t = Q(e);
709
+ let t = $(e);
710
710
  if (t === null) throw Error("mssqlReplicationAdapter: replica is not an MssqlStore.");
711
711
  return t.runEffect(r.gen(function* () {
712
712
  return (yield* (yield* P)`
@@ -718,14 +718,14 @@ var ae = {
718
718
  }));
719
719
  },
720
720
  compare(e, t) {
721
- return $(t, e) >= 0 ? "caught-up" : "behind";
721
+ return de(t, e) >= 0 ? "caught-up" : "behind";
722
722
  }
723
- }), de = {
723
+ }), pe = {
724
724
  id: "mssql",
725
- makeSqlLayer: (e) => R(e),
725
+ makeSqlLayer: (e) => z(e),
726
726
  makeStore: (e) => Z(e),
727
727
  compileContains: (e, t, n) => n`${e} LIKE ${`%${t.replace(/[\\%_[\]]/g, (e) => `\\${e}`)}%`} ESCAPE '\\'`,
728
- retryFilter: W
728
+ retryFilter: G
729
729
  };
730
730
  //#endregion
731
- export { c as CDC_OFFSETS_TABLE, e as MssqlClient, d as _voltroMssqlCdcOffsetsTable, L as connectionFromConfig, K as ensureChangeTracking, I as makeMssqlSqlLayer, R as makeMssqlSqlLayerFromConfig, Z as makeMssqlStore, de as mssqlDialect, ue as mssqlReplicationAdapter, W as mssqlRetryFilter, Y as startChangeTrackingCdc };
731
+ export { c as CDC_OFFSETS_TABLE, e as MssqlClient, f as _voltroMssqlCdcOffsetsTable, R as connectionFromConfig, K as ensureChangeTracking, I as makeMssqlSqlLayer, z as makeMssqlSqlLayerFromConfig, Z as makeMssqlStore, pe as mssqlDialect, fe as mssqlReplicationAdapter, G as mssqlRetryFilter, Y as startChangeTrackingCdc };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/sql-mssql",
3
- "version": "0.62.3",
3
+ "version": "0.64.0",
4
4
  "description": "SQL Server (mssql) dialect adapter for Voltro's cross-dialect DataStore (OUTPUT-clause returning, OFFSET/FETCH paging, inline reactivity).",
5
5
  "keywords": [
6
6
  "voltro",
@@ -35,8 +35,8 @@
35
35
  "dependencies": {
36
36
  "@effect/sql": "^0.52.0",
37
37
  "@effect/sql-mssql": "^0.53.0",
38
- "@voltro/database": "0.62.3",
39
- "@voltro/logger": "0.62.3"
38
+ "@voltro/database": "0.64.0",
39
+ "@voltro/logger": "0.64.0"
40
40
  },
41
41
  "peerDependencies": {
42
42
  "effect": "^3.22.0"