@voltro/protocol 0.6.0 → 0.8.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.
Files changed (2) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -39,6 +39,47 @@ _Changes staged for the next release accumulate here (rolled up from
39
39
 
40
40
  ---
41
41
 
42
+ ## [0.8.0] — 2026-07-20
43
+
44
+ ### ⚠ BREAKING
45
+
46
+ - **@voltro/web, @voltro/cli** — **`defer()` + `<Await>`: stream one slow part of a page instead of blocking the whole response.** A loader blocks the entire response, so one slow field costs every byte of the page. A loader may now return `defer(eager, deferred)` — two explicit buckets. The eager half is awaited and renders into the shell; the deferred half is handed to the renderer as promises, read through the new `<Await>` component, and **flushed into the same response as each promise settles**. ```tsx export const renderMode = 'ssr' as const export const loader = async ({ query }) => defer( { user: await query('users.me') }, // in the shell { report: query('reports.quarterly') }, // streamed after it ) const { user, report } = useLoaderData<Awaited<ReturnType<typeof loader>>>() <Await value={report} fallback={<ReportSkeleton />}>{(r) => <ReportTable rows={r.rows} />}</Await> ``` Two buckets rather than "any promise-valued field is deferred": deferral is then something the author wrote down, not something inferred from a value's runtime shape, and `useLoaderData()` can type it — eager fields come back as values, deferred fields as `Promise<T>`, so the compiler says which ones need an `<Await>`. **Streaming is now wired for `renderMode: 'ssr'` on BOTH boot paths.** `voltro dev` and `voltro start` share one shell splitter + stream driver (`cli/src/ssrShell.ts`) rather than hand-mirroring the sequence — the same reason `buildSegmentChain` is shared. `renderPageToStream` was previously present but deliberately unwired, on the (correct, measured) grounds that streaming a Suspense-FREE tree changes event-loop blocking by zero. `defer()` is what makes the tree no longer Suspense-free, so that reasoning no longer applies and the doc comment saying so has been rewritten instead of left contradicting the code. Measured against a real `voltro start` (`scripts/measure-deferred-stream.mjs`, one 400ms deferred field): first body byte at 7ms, deferred chunk at 408ms, and a probe firing every 10ms for the duration of the streamed request was served 30 times at a 3ms median / 7ms max. The event loop stays free while the deferred value is pending — that, not TTFB alone, is the point. **`defer()` is a hard error, naming the page, on every mode where it provably cannot work** — each verified against React 19.2.7 rather than assumed: `renderMode: 'static'` (`renderToString` does not support Suspense; it emits an errored boundary plus a "switched to client rendering" template with no warning, so the artefact would ship a permanent fallback), `renderMode: 'isr'` (caches a completed HTML string), `interactive: 'none'` (no JS to run React's reveal scripts), `interactive: 'islands'` (the root never hydrates, so nothing consumes the streamed value). Awaiting-and-inlining on the artefact modes was considered and rejected: it makes `defer()` a silent no-op that still reads like it streams, and it needs a second wire format and a second `<Await>` path for no user-visible gain. **Why BREAKING.** Two things can turn code that compiled into code that does not, or change deployed behaviour: - `useLoaderData<T>()` now returns `LoaderData<T>`. For every concrete `T` that IS `T`, so ordinary call sites are unaffected — but a helper that passes an unresolved generic through (`<D,>(): D => useLoaderData<D>()`) stops compiling and must propagate `LoaderData<D>` instead. A transform cannot tell those apart without typechecking, hence a `manual` codemod. - `ssr` responses are chunked and carry no `content-length`, and `voltro start` needs an SSR bundle built by this version — so a deploy must re-run `voltro build`, and a buffering reverse proxy in front of the app will absorb the win until buffering is disabled for those routes. `<Await>` owns the `<Suspense>` boundary, the `use()`, and the settle `<script>` that publishes the resolved value to the client, so the server render and the hydration render are identical by construction rather than by a `typeof document` guard or `suppressHydrationWarning`. A rejected deferred value travels as a resolved error envelope and renders `errorFallback` in place on both sides — Fizz errors the whole boundary on a rejected `use()`, which would otherwise blow past `errorFallback` entirely. Coverage: `deferredStream.test.tsx` asserts chunk ordering + the concurrent-probe event-loop claim, `deferredHydration.test.tsx` hydrates a real streamed document in jsdom with zero console errors, `deferred.test.ts` pins all four hard errors, `ssrShell.test.ts` covers the shell split and the three stream-driver ordering hazards, and `webDevSsrLayoutLoader.test.ts` runs the SAME streaming assertions against a real `voltro dev` and a real `voltro start`. Non-deferring pages emit a byte-identical `__voltro_state__` payload, asserted against the exact strings a real build and a real serve produce.
47
+ - **@voltro/plugin-auth** — `UserRecord.passwordHash` is now optional (`string | null | undefined`), and the shipped `users` table's `passwordHash` column is nullable to match. It was required, which asserted that every identity has a password. SSO-only, magic-link-only, passkey-only and provider-PAT apps have none, so the only way they could call `subjectFromUser(...)` was to invent a fake hash — strictly worse than storing nothing, because a fake hash is a real value sitting in the column a verifier compares against. The alternative several apps took was to hand-build the `Subject` and lose the helper. The built `Subject` never needed the hash in the first place. **Why this is BREAKING even though nothing was removed:** widening is additive for code that WRITES a `UserRecord` and breaking for code that READS one. `const h: string = user.passwordHash`, `user.passwordHash.length` and `verifyPassword(pw, user.passwordHash)` all compiled before and do not now. The codemod is `manual` because a transform cannot typecheck and therefore cannot tell those sites apart — and both mechanical edits available to it are dangerous. `!` re-asserts the invariant that just stopped holding; `?? ''` manufactures a hash-shaped value and feeds it to a comparison, which is precisely the "a user with no password signs in with any password" failure this change exists to make impossible. An absent hash must be a refusal decision, never a default value. **The security half, which is where the work went.** `handleSignIn` treats an absent hash exactly as it treats an unknown email: it still burns a decoy scrypt, then returns the identical 401 `{ error: 'invalid credentials' }` with no `Set-Cookie`. So a password-less account can never be signed into with the password strategy, and the response reveals neither that the account exists nor that it lacks a password — a divergence there would be an oracle for which accounts are worth attacking through a different strategy. `null`, `undefined` and `''` are all treated as "no password". `passwordlessSignIn.test.ts` pins this across every shape of absence against every shape of submitted password (arbitrary, empty, `undefined`), plus the two indistinguishability properties. It was the only verification site in the package; nothing else reads the field to make a decision. Password RESET is unaffected and still promotes a password-less user: `updatePassword` assigns a hash to a user that had none, after which sign-in works normally and an arbitrary password still fails. `createdAt` stays required — every store can supply it (`insert` takes `Omit<UserRecord, 'createdAt'>`, so callers never pass one), so there is nothing to relax. The nullable column needs no migration work from you — it rides the declarative differ on `voltro db apply` / next boot.
48
+
49
+ ### Added
50
+
51
+ - **@voltro/testing, @voltro/runtime** — `makeTestContext` now makes its seeded store available to `runAsSystem`, so a row filter over a SHARED resource can be tested. `RowFilter.load` is `Effect<Ctx, unknown>` with `R = never`, so it cannot `yield*` an EffectStore service. A filter over rows the user OWNS needs no store (the subject carries the id), but a filter over rows SHARED with the user — "you see a list you are a member of" — must read a membership table, and `runAsSystem` is its only route there. Under `makeTestContext` that threw `runAsSystem: no data store available`, so the one rule most worth a test ("user B cannot see the row shared with user A") could not be asserted in-repo at all. The reported consequence was an app declining to ship a process-global filter over shared data it had no way to verify. `makeTestContext` registers its RAW `dataStore` + `schemaRegistry` via `setSystemStoreHandle` — never `ctx.store`, since `runAsSystem` applies `wrapStoreWithMixinBehaviour` for the system subject itself and a wrapped store would be wrapped twice (the system subject also bypasses row filters in production, so raw is the semantically correct layer). It is the outer store, not a transactional view: a `runAsSystem` block inside a mutation reads outside that mutation's transaction in production, and does here too. **Isolation, since the handle is a process global.** Registration is last-wins and never auto-cleared, so a bare `await runAsSystem(...)` written directly in a test resolves. Last-wins alone is not enough: two `makeTestContext` calls in one test allocate two separate `InMemoryDataStore`s even from one seed object, so a filter `load` on the first context would otherwise read the second's rows. The harness therefore re-points the handle at its own context for the span of that context's `load` and restores the previous value, making filter resolution independent of build order. Cross-file leakage does not arise — vitest isolates each test file's module registry — and within a file a test that wants the refusal back calls `clearSystemStoreHandle()`. Production is untouched: only the harness registers, so an app that registered no handle still gets the throw. This scopes an exception to the harness rather than relaxing the refusal. `@voltro/runtime` gains `getSystemStoreHandle()` (read the current handle so a caller can save/restore around a bounded span) and exports the `SystemStoreHandle` type. Both are additive. One behavioural caveat worth knowing: a test that built a `makeTestContext` and then asserted `runAsSystem` refuses in the same file will now see it succeed. Call `clearSystemStoreHandle()` first.
52
+
53
+ ### Fixed
54
+
55
+ - **@voltro/database, @voltro/cli** — `timestampMs` / `timestampMsOrNull` are now importable from a descriptor. 0.6.0 shipped them — and `rowSchema(table)` — documented for a `*.query.ts` `output`, but the browser-safety guard rejects `@voltro/database` and every subpath under it, so the documented usage aborted `voltro dev` with an import-chain error. A downstream app hit exactly that. The field schemas now ship from a new browser-safe entry, **`@voltro/database/wire`**, which contains plain `effect/Schema` values and imports `effect` and nothing else; the guard permits that one subpath. It is not a blanket allowlist entry — the guard resolves a workspace package to its `./src/*.ts` source and keeps walking, so a server import added to that module is still caught and still aborts boot. Both directions are covered by tests (`browserSafetyGuard.test.ts`): the subpath passes, bare `@voltro/database` still fails even with the subpath present, and a deliberately regressed wire module is caught. The package root keeps exporting the same values, so server-side code that already imports the root needs no second import. `rowSchema(table)` / `columnSchema(def)` are corrected rather than changed: they take the table as a VALUE, and reaching a table means importing an app's `database/schema.ts`, which imports `@voltro/database` — so a descriptor can never use them, no matter where they are packaged. That is a consequence of the browser/server boundary, not a packaging accident, and it is now stated where it was previously mis-stated. They remain the row CODEC for server-only code (`*.server.ts`, `*.seed.ts`, jobs, scripts): encoding rows for a file export or queue payload, decoding seed / import data against the real table shape. The doc comments, the docs site (en + de), the seeded agent guide, and the `voltro doctor` `hand-serialized-date` rule — which recommended `rowSchema(table)` in a descriptor's `output` and was therefore actively steering users into the boot crash — all now point at the field schemas instead. No API was removed or narrowed, and no user code that compiled stops compiling: the previously-recommended usage never got as far as a boot, so there is nothing to migrate. Apps that worked around this with hand-written `Date → epoch` converters can delete them and declare `timestampMs` on the field.
56
+ - **@voltro/cli** — `voltro dev` now runs LAYOUT loaders during SSR, like `voltro start` always did. A `layout.tsx` exporting a `loader` had it skipped entirely on the dev server's SSR pass: `useLoaderData()` inside that layout was `undefined` on the server-rendered first paint and only populated after hydration, while the identical code rendered correctly in production. The dev log gave the one visible tell — the `loadChain` phase reported `0ms`, because the phase only imported layout modules and never awaited a loader. This is dev/production parity drift, so the fix is structural rather than a second copy of the loop. Chain assembly plus the layout-loader run now live in ONE `buildSegmentChain`, called by both SSR paths; only module loading differs between them (`voltro start` resolves through its SSR module provider, `voltro dev` through Vite's `ssrLoadModule`) and that is injected. Everything a user can observe is single-sourced: the loader argument shape (`params`, `pathname`, `signal`, `headers`, `query` — identical to what a page loader receives, including the server-side rpc `query()` bound to the request's session cookie), the parallel run across chain segments, and the keying of each result by CHAIN index so a layout can never be handed a sibling's data. Dev also now shares ONE `AbortController` across the page loader and every layout loader on a request, wired to the response `close` event — navigating away cancels all in-flight loader fetches, not just the page's. A throwing layout loader is surfaced exactly as a throwing page loader already was on each path: `voltro start` maps `NotFoundError` / `RedirectError` onto a 404 / 3xx and lets anything else propagate, `voltro dev` logs the failure and falls back to the SPA shell. It is never swallowed into a silently empty layout. No documented behavior changes — layout-level loaders were already specified to run server-side, and dev was the outlier. Apps that worked around this by refetching in the layout after hydration can drop the workaround; nothing needs to change to pick up the fix.
57
+ - **@voltro/web** — The router no longer discards loader data it already holds while a route's loaders are still in flight. Its `pending` branch rendered the page under `<LoaderDataContext.Provider value={undefined}>` unconditionally, so `useLoaderData()` returned `undefined` for that window even when the router was holding that exact route's data. Where it actually bit: a `renderMode: 'static'` page. The prerender runs the PAGE loader and inlines its result into `__voltro_state__`, but it runs no LAYOUT loaders — so after hydration the client re-runs them and the router sits in `pending` until they settle, with the page's own data committed the whole time. During that window the page re-rendered with `undefined`, and an unguarded page (`data.title`, exactly what the docs tell you to write) threw into its `RouteErrorBoundary`. A fast layout loader hides this — it settles in the same microtask checkpoint and React batches the bad render away — so it only surfaces for real when a layout loader is slow, which is the case a real network call produces. The reuse is GATED, because the inverse is a worse bug: on a client-side navigation the committed data belongs to the route being left, and handing it to the incoming page would typecheck (both sides are `unknown` at that seam) and often look plausible on screen. The committed chain data now carries the pathname it was produced for, and the page-level provider reuses it only when that pathname is the one being rendered. The two diverge in exactly one situation — a route that opts into a `Pending` skeleton is displayed before its loaders settle — and that is the situation the gate exists for. Per-layout data got the same treatment one level down, with a finer key: during that same skeleton window a layout's committed value is reused only if the SAME `Layout` component still occupies that chain position. A shared shell therefore stays populated behind the incoming route's skeleton (the point of opting into one), while a DIFFERENT layout at that chain index now gets nothing instead of inheriting its predecessor's value by index. No public API changed (the api-extractor golden is unchanged) and no code that compiled stops compiling; a page that previously flashed `undefined` mid-pending now simply keeps its data. Covered end-to-end in `hydrateLoaderData.test.tsx` — a real SSR render → real state script → real `mount()` → real `hydrateRoot`, with a deliberately slow layout loader, plus both navigation directions and empty-console assertions.
58
+ - **@voltro/cli** — React Fast Refresh now works for pages and layouts in `voltro dev`. Until now **nothing** in a Voltro web app hot-updated: every edit — a page component, a layout component, a stylesheet-adjacent TSX tweak — forced a FULL PAGE RELOAD, discarding form input, scroll position, open dialogs and all client state. Measured in a real browser across both the streamed and the buffered SSR paths, so it was boot-path-agnostic and long-standing. Editing a page component now applies as a hot update with `useState` intact; editing a `loader` still reloads, deliberately. Two causes, both fixed. **(1)** react-refresh only accepts a module whose exports are all components, and a page exports `loader` / `renderMode` / `meta` beside its component, so every route module rejected itself and invalidated upward. **(2)** The rejection then reached the generated `.framework/app.tsx`, which was ALSO ineligible because it exported `preloadCurrentRoute` beside `App`, so it bubbled on to `main.tsx` — which accepts nothing, i.e. a full reload. The obvious fix — strip server-only exports from the client bundle, the Remix approach — is not available here: Voltro's loaders are **isomorphic** (`router.tsx` runs a route's `loader` in the browser on client-side navigation), so the loader must stay in the client graph. Instead, `voltro dev` registers each route module's non-component export names with `@vitejs/plugin-react`'s ignored-exports hook, so react-refresh judges only the components, and the framework decides the reload itself by comparing those exports' VALUES across the update — a function by its source text, everything else by its JSON form. A JSX-only edit recreates the `loader` function object but not its text, so it correctly reads as unchanged and hot-updates; an actual loader edit reads as changed and forces a reload. That reload is the correct outcome, not a limitation: a `loader` also runs server-side, the visible page was rendered from the old one, and the router caches loader results per route + params, so a silent hot swap would leave stale data on screen. The transform is **client-only and append-only** — it never removes an export, and it bails on the SSR environment entirely, because the dev renderer imports each page/layout through `ssrLoadModule` and reads `loader` / `renderMode` straight off the namespace. The generated dev entry is now split in three so the boundaries are clean: `app.tsx` exports `App` and nothing else (a valid refresh boundary), the route table + the mutable module registry HMR patches move to a new `.framework/routeTable.ts` — a plain `.ts` module that is deliberately NOT a boundary, so refreshing `app.tsx` cannot drop already-loaded page modules — and `main.tsx` stays side-effect-only. These are generated files, rewritten on every `voltro dev` boot; nothing user-authored changes. Covered by `routeFastRefresh.test.ts` (client transform injects, SSR transform is a no-op, and the reload decision — including the "re-evaluated but unedited loader is not a change" case that the whole thing turns on) and `webDevEntrySplit.test.ts` (app.tsx has exactly one export). The behaviour itself is browser-only and is driven by `scripts/browser-deferred-stream.mjs`, which now bumps a live `useState` counter before each edit and asserts it survives a component edit and resets on a loader edit — the only assertion that distinguishes a hot update from a reload.
59
+ - **@voltro/web, @voltro/cli** — Server-rendered loader data now reaches the client's FIRST render. Every `renderMode: 'ssr'` page with a loader — and every `layout.tsx` with one — previously hydrated with `useLoaderData()` returning `undefined`, because the inlined `__voltro_state__` payload was written by the SSR pipeline and read by nobody: `mount()` used the script tag as a boolean to pick `hydrateRoot` over `createRoot` and never parsed its contents, and `<Router>` started with no committed loader data, re-running every loader in an effect. Layout (chain-segment) data was not inlined in any shape at all. The consequences were not cosmetic. An ordinary SSR page that dereferences its own loader data (`data.value`) threw `TypeError: Cannot read properties of undefined` on the hydration render and fell into `RouteErrorBoundary`; a layout rendering its loader's value produced a genuine React hydration mismatch (server `ROOT_LAYOUT_LOADER_RAN`, client `ROOT_LAYOUT_LOADER_MISSING`), after which the tree was regenerated from scratch. Both are now measured in a real browser against `e2e-fixtures/web-layout-loader`, with zero console errors and zero page errors, and reproduced in the unit suite through a real `hydrateRoot`. The payload is one coherent object — `{ page, segments: { <chainIndex>: … }, ran }` — defined once in `@voltro/web`'s `routerState` module and emitted through a single `renderRouterStateScript()` helper that `voltro dev`, `voltro start` and `voltro build` all call. That is deliberate: the dev and serve SSR paths are independent assemblies, and hand-mirroring the shape into each is exactly the drift that let layout loaders go missing in dev in the first place. `ran` exists because JSON cannot express `undefined` — without it a loader that resolved to `undefined` is indistinguishable from one that never ran, and the client would re-run it. Values are keyed by the same chain index the renderer wraps layouts with, so a layout can never be handed its neighbour's data; escaping is unchanged (`<` is escaped, so a `</script>` inside loader data cannot break out of the tag). **Two behaviour changes worth knowing about, neither of which stops any code compiling.** First, `useLoaderData()` now returns the server's value on the initial render of a server-rendered page instead of `undefined`; components that branched on `undefined` to show a skeleton will simply stop showing it on first paint. Second, loaders no longer re-run on initial hydration — that re-run was the source of the post-hydration flash. A loader whose client-side re-execution an app was relying on (to refresh data or to trigger a side effect after mount) will no longer fire on first load; move that work into an effect. Client-side navigation is unaffected and runs loaders exactly as before, as does a fresh client mount with no server markup. Static prerender (`voltro build`) inlines the page loader's result the same way. It does not run layout loaders — it never did — so a static page's layouts continue to resolve their data on the client after mount.
60
+
61
+ ---
62
+
63
+ ## [0.7.0] — 2026-07-20
64
+
65
+ ### ⚠ BREAKING
66
+
67
+ - **@voltro/runtime** — Row-level security: a `load` failure is now retried, and then surfaces as an **error** instead of an empty result. `setRowFilter({ load, predicate })` resolves `load` once per request; previously ANY failure was answered with a predicate matching zero rows. A downstream app reported the consequence correctly — for shared/team visibility `load` must read the store, so one transient DB blip denied every constrained table for that request and the whole UI rendered empty. Two distinct defects, fixed separately, because conflating them was the original mistake. **A transient failure should never reach the decision:** `load` had no retry at all, so a reaped connection was answered as if it were an authorization fact. It now runs under a bounded `retry` schedule — `DEFAULT_ROW_FILTER_RETRY`, three attempts backing off exponentially from 20ms, so ~60ms worst case — configurable per filter with your own `Schedule`, or `retry: false` for one attempt. **And refusal was expressed as an empty result, which is a lie:** "we could not determine your visibility" and "you may see nothing" are different facts, and only one of them is a fact. An empty list is byte-identical to legitimate emptiness, so the user reads "you have no tickets", the operator reads a healthy 200, and the outage is invisible to both — the most misleading outcome available. The module header used to defend this ("an empty result rather than a 500 on every page"); every constrained page IS broken, and saying so is the correct behavior. `onLoadError` now defaults to `'fail'`, raising the exported typed `RowFilterUnavailable`; `onLoadError: 'deny'` keeps the old degradation for apps that have looked at the screen and genuinely prefer an empty list to an error state, with the `onError` reporter still firing so the choice is not silent. The app asked for `onLoadError: 'fallthrough'` — fail OPEN to the handler's own check. Deliberately not implemented, and not planned. Serving unfiltered rows when the authorization filter is unavailable leaks data precisely when the system is under stress and nobody is reading dashboards, and it is only safe if every handler still carries the check that row filters exist to replace. Fail-closed is retained under both policies; a test asserts neither branch can ever yield an unfiltered read. **Migration.** `resolveRowFilterScope` gains an error channel — `Effect<RowFilterScope, RowFilterUnavailable>` instead of an infallible Effect — so direct callers (a custom entrypoint, a test harness) must handle it; TypeScript points at each one. Apps that want the previous behavior add `onLoadError: 'deny'`, but should decide that rather than default into it. Tests asserting "a broken load yields an empty result" now fail with `RowFilterUnavailable`, which is the fix working. Subscriptions changed shape too: a re-resolve that fails mid-delivery now REVOKES the subscription and emits a typed error frame — following the existing withdrawn-guard precedent — because an empty snapshot on a live subscription reads to a client as "every row you could see was just deleted". The same failure at subscribe time aborts the subscribe instead of opening a stream on a fabricated snapshot, and unwinds the matcher + dependent-table registration it had already made (previously leaked on any subscribe-time throw).
68
+ - **@voltro/testing, @voltro/runtime** — `@voltro/testing` now mirrors the runtime store's POLICY path, not only its DATA path. An adopting app found three places where the harness diverged, each of which made a class of rule untestable in-repo while leaving the suite green. **`invoke` runs Effect-mode handlers.** The framework's contract is "async OR Effect, your choice per handler", and every production runner honours it (`Effect.isEffect(result) ? … : …`). `invoke` only awaited the executor's return value, so an Effect-mode handler handed back its own un-run `EffectPrimitive`: nothing executed, nothing was written, and a test asserting on the "result" asserted on a description of work. The Effect now runs through `runProvidedEffect` — the same function the serve entrypoints use — so a handler failing with a typed error REJECTS WITH THAT ERROR rather than an opaque FiberFailure, and `EffectStore` + `SubjectService` are provided over the context the handler is actually given. Guards, the input decode, the transactional wrap, the deadlock replay, `afterCommit` and the plugin interceptor chain apply identically to both modes. **`makeTestContext({ relations: [spec] })`** registers `relations()` specs. `relations()` is pure — it returns a spec, it does not register one — and production registration is a boot step (`voltro dev` → `registerDiscoveredRelations`), so under the harness an eager load failed with "no relations registered" no matter what the test imported. The option REPLACES the process-global registry with exactly the specs given (the registry is a `Symbol.for` global; additive registration would throw `duplicate relation` on the second `makeTestContext` in a file and leak the first test's relations into the second). Omitting it touches the registry not at all. **The harness store applies row-level security.** After `setRowFilter(...)` a `makeTestContext` read of a constrained table returned the unfiltered set, so "user A cannot see user B's row" could not be asserted at all. `ctx.store` now resolves the filter for its subject and AND-merges it into every read — fluent builders and descriptor reads alike, not bypassed by `.unscoped()`, bypassed for a `system` subject. A new `rowFilter:` option passes a filter directly for tests that would rather not write to a process global. Resolution goes through the runtime's new `resolveRowFilterScopeFor` (`resolveRowFilterScope` is now that function applied to the registered filter), so the retry schedule, the system bypass and the `onLoadError` policy are the runtime's single definition rather than a second copy in the harness. **Breaking, and how to migrate** (`voltro update` prints this): delete any `Effect.isEffect(out) ? await Effect.runPromise(out) : out` shim around an `invoke` call — it is dead code that also destroyed your typed errors. `ProcedureExecutor<Input, Output>` gained an `Effect<Output, E>` arm and an optional third parameter `E` (default `never`); `invoke` now infers from the executor's whole return type instead of taking `Output` as its second type parameter, so an explicit `invoke<typeof d, Note>(…)` type-argument list must be dropped in favour of inference. Handlers themselves keep compiling — what changes is the type of a CALL.
69
+
70
+ ### Added
71
+
72
+ - **@voltro/plugin-auth** — `subjectFromUser(user, { metadata })` now carries arbitrary app metadata onto the Subject. Previously the helper set the metadata slot ONLY when `memberships` were supplied, so an app whose Subject must carry a provider credential — an Atlassian PAT captured at login and read back as `subject.metadata.jiraToken` by `@voltro/plugin-atlassian`'s `credentialsResolver`, say — could not adopt the helper at all: calling it silently dropped the credential, and building the Subject by hand was the only way to keep it. The framework shipped both halves of that gap itself. `metadata` MERGES with the memberships projection rather than replacing it. **Precedence when a `memberships` key appears in both:** the dedicated `memberships` option wins — it is the specific, typed input, and it is the one projected into the `{ tenantId, role }` shape `subjectMemberships()` and the switch-tenant menu read, so letting a free-form bag shadow it would break tenant switching in a way nothing type-checks. Without the option, a `memberships` key inside `metadata` passes through unchanged. A Subject built with neither option still has NO `metadata` key (not an empty object). The later stamps compose unchanged: the sign-in / sign-up / magic-link / passkey handlers spread the existing slot before adding `sessionId`, and the password strategy does the same for `provider`, so keys set through this option survive every login path — unless a caller names a key `sessionId` or `provider`, which those merges overwrite by design. **`handleSwitchTenant` now carries that metadata across a switch**, via a new optional `metadata` on `SwitchTenantInput` (the built-in `/switch-tenant` route passes the caller's `subject.metadata` for you). Without this the option above would have been a trap rather than a feature: a switch rebuilds the Subject from the user record, so an app parking a provider credential in the slot would lose it the first time a user changed tenant — staying authenticated while every call to the provider began failing, with no signal at the point that caused it. `memberships` is deliberately NOT carried: it is re-derived for the target tenant, and a carried copy would report a role the user does not hold there. Covered by a test that fails on the exact assertion when the carry is removed. `SwitchTenantInput` is now exported too — every sibling handler input (`SignInInput`, `MfaVerifyInput`, …) already was, and this one had simply been forgotten, so an app calling `handleSwitchTenant` directly could not name its argument type. Additive: the option is optional and callers that pass neither get byte-identical Subjects. `packages/plugin-auth/etc/plugin-auth.api.md` gains one line and changes none.
73
+ - **@voltro/database** — `timestampMs` and `timestampMsOrNull` — the timestamp wire mapping as STANDALONE field schemas, for hand-written `Schema.Struct` outputs. `Date` in the handler, epoch-ms `number` on the wire; `timestampMsOrNull` is the `.nullable()` column's variant, so a "never archived" row stays `null` instead of becoming `new Date(null)` (1970-01-01, which renders as a plausible date rather than as nothing). ```ts output: Schema.Struct({ id: Schema.String, addedAt: timestampMs, // Date in the handler, epoch ms on the wire archivedAt: timestampMsOrNull, seenAt: Schema.optional(timestampMs), }) ``` This closes the half of the problem `rowSchema(table)` left open. `rowSchema` only helps a handler returning a RAW FULL TABLE ROW, and real handlers overwhelmingly return a COMPUTED struct assembled across several tables — `{ id, name, slug, addedAt, jiraProjectKey }` — where there is no single table to derive from. That is exactly where the hand-written `Date → epoch` converters accumulate: the app that reported the original gap has ~228 of them, all in shaped outputs, and found zero clean applications for whole-row `rowSchema`. Single-sourced, not a parallel declaration: `columnSchema` now READS `timestampMs` for `timestamp()` / `date()` columns, so the derived-row and hand-written-struct paths are the same schema by identity and cannot drift into different wire representations. `rowSchema.test.ts` asserts that identity rather than asserting both merely produce a number. There is deliberately no `timestampMsOptional` — an absent field is `Schema.optional(timestampMs)`, which composes without a third export. Both exports live in the browser-safe `@voltro/database` main entry (pure `effect/Schema`, no driver, no `node:*`), which is what a descriptor's `output` needs. Additive: two new exports, no existing declaration changed. `packages/database/etc/database.api.md` gains two entries and changes none.
74
+
75
+ ### Fixed
76
+
77
+ - **@voltro/sql-turso** — Turso (local Rust engine): pooled connections now WAIT for a held lock instead of failing instantly with `database is locked`. The engine keeps SQLite's default of `PRAGMA busy_timeout = 0`, so any statement that met a lock held by another connection failed on the spot — and with the default pool of 4 connections on one file, two concurrent writers are enough to reach it. `makeConnection` now issues `busy_timeout` for every pooled connection, beside the mandatory MVCC and foreign-key pragmas. MVCC did not cover this and was the reason it was missed: `journal_mode=experimental_mvcc` resolves write-write conflicts BETWEEN transactions, while DDL and the schema lock stay exclusive, so the failure lands on statements the concurrency design appears to have handled. It also only reproduces under CPU contention — green on an idle machine, sporadic under load — which is the worst shape for a defect to have. It surfaced as a flaky `CREATE TABLE` in the MVCC keystone test during a full local gate run, where 78 packages build in parallel; a user would see it as an intermittent `database is locked` under production traffic with no obvious trigger. The default is 5000ms, matching better-sqlite3's own default — which is why the sibling `@voltro/sql-sqlite` never needed this: that driver sets the timeout for us, and the turso NAPI driver does not. Tunable via `busyTimeoutMs` on `makeTursoSqlLayer` / `TursoClientConfig`, beside `maxConnections`; `busyTimeoutMs: 0` explicitly restores the fail-immediately behavior (asserted by a test, so the default can never be implemented as a floor that silently ignores 0). It is deliberately NOT on the cross-dialect `ConnectionConfig` — that shape stays free of engine-specific knobs, the same reason the Turso auth token is env-sourced rather than threaded through it.
78
+ - **@voltro/cli** — `voltro update` now honors the project's actual package manager instead of defaulting to npm. It resolves the manager by walking from the app directory **up to the repo root**, preferring the corepack `packageManager` field over a lockfile (`pnpm-lock.yaml` / `yarn.lock` / `bun.lock` / `bun.lockb` / `package-lock.json`), and only falls back to npm when nothing declares one. Walking up fixes the workspace case: a scaffolded project keeps its lockfile at the monorepo root, so running `voltro update` from `apps/api` previously found no lockfile and ran `npm install` against a pnpm/yarn workspace — writing a stray lockfile and a nested `node_modules`. The resolved manager is also used for the latest-version registry lookup (`pnpm view` / `yarn` / `bun pm view`, with `npm view` as a last-resort fallback), so a private or scoped registry configured in `.npmrc` / `.yarnrc.yml` is honored. The yarn query dispatches on the installed yarn MAJOR version rather than probing berry syntax first: on yarn classic, `yarn npm info …` parses as `yarn run npm` and **executes a `npm` script from the project's package.json** if one exists — verified against yarn 1.22.22. Resolving a version number must never run user code, so classic gets `yarn info … --silent` and only berry (>=2) gets `yarn npm info`. All three managers are verified against real binaries in throwaway Docker containers — yarn classic 1.22.22, yarn berry 4.6.0, bun 1.3.14 — each asserting both that the query resolves a version and that it does not execute a same-named script. Re-run with `node scripts/smoke-package-managers.mjs`.
79
+ - **@voltro/cli** — Three fixes to `voltro update`, all reported by an app upgrading a pnpm workspace. **The bump is now LOCKSTEP across the whole workspace.** `voltro update` in `apps/api` bumped only that `package.json`, leaving the sibling web app and shared `packages/*` on the previous version — an api on 0.6.0 and a web client on 0.5.0 disagree about the generated rpcGroup types and the session cookie shape, and that disagreement surfaces as a runtime decode error, not a build error. When the app sits inside a workspace (`pnpm-workspace.yaml`, or a `workspaces` field in an ancestor `package.json`, found by the same bounded upward walk that resolves the package manager and stops at the first `.git`), every member `package.json` that declares `@voltro/*` is bumped to the target together, and the install runs **once at the workspace root** — running it inside `apps/api` corrupts a pnpm/yarn workspace's layout. Each file that will be bumped is listed in the plan output and in `--dry-run`. A standalone (non-workspace) project is unchanged: its own `package.json`, its own install, in place. The codemod re-exec now also looks for the installed `voltro` bin at the workspace root, since npm and yarn hoist it there. **A failed install now says that the codemods were skipped.** It previously printed only "install failed — package.json was bumped; fix the install and re-run", never mentioning codemods, so a user could boot on target-version code with source shaped for the old one and no signal as to why. The codemods for a jump ship *inside* the target version, which a failed install did not put on disk, so running them is impossible rather than merely undesirable — the fix is the message. It now states plainly that no codemods were applied, why, and prints the exact copy-pasteable recovery command with the concrete versions: `voltro update --codemods-only --from <from> --to <to>`. **`--help` / `-h` is answered before every guard.** `voltro update --help` on a dirty tree printed "working tree is not clean" — at precisely the moment the user was trying to discover `--dry-run` and `--codemods-only`. Help is documentation, not an operation, so it is now handled first, ahead of the `package.json` check, the `@voltro/*`-deps check and the clean-tree guard, and lists every flag (`--to`, `--from`, `--root`, `--dry-run`, `--force`, `--exact`, `--codemods-only`). `voltro doctor --help` had the same shape — it fell through to the preflight and reported on the tree instead — and gets the same treatment. `voltro help`'s `update` line now names `--from` and `--codemods-only` too.
80
+
81
+ ---
82
+
42
83
  ## [0.6.0] — 2026-07-19
43
84
 
44
85
  ### ⚠ BREAKING
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/protocol",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "The Voltro wire + plugin contract — defineQuery/Mutation/Action/Stream, definePlugin, sessions / JWT / API-keys, and the RPC protocol.",
5
5
  "keywords": [
6
6
  "voltro",
@@ -53,7 +53,7 @@
53
53
  },
54
54
  "dependencies": {
55
55
  "@effect/sql": "^0.51.1",
56
- "@voltro/database": "0.6.0",
56
+ "@voltro/database": "0.8.0",
57
57
  "jose": "^6.2.3"
58
58
  },
59
59
  "peerDependencies": {