@voltro/protocol 0.9.0 → 0.11.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 +394 -7
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -39,6 +39,284 @@ _Changes staged for the next release accumulate here (rolled up from
|
|
|
39
39
|
|
|
40
40
|
---
|
|
41
41
|
|
|
42
|
+
## [0.11.0] — 2026-07-22
|
|
43
|
+
|
|
44
|
+
### ⚠ BREAKING
|
|
45
|
+
|
|
46
|
+
- **@voltro/client, @voltro/web** — **@voltro/client** — `useSequence` steps gain **`covers`** and **`when`**, and the undo contract that was implicit is now written down.
|
|
47
|
+
|
|
48
|
+
**`covers` — overlapping undos.** Every succeeded step's undo runs, and the runner has no idea whether two of them reverse the same thing. Reported from a Jira rollback: `deleteJiraDraftTicket` deletes the issue *and* discards the draft, so the earlier `discardDraft` undo ran on something already gone. It worked only because discarding is idempotent — and **nothing said that was load-bearing**. For a refund or a cancellation email the double-run is a defect, not a nuisance.
|
|
49
|
+
|
|
50
|
+
```tsx
|
|
51
|
+
.step('draft', createDraft, { undo: discardDraft })
|
|
52
|
+
.step('jira', createTicket, { undo: deleteJiraDraftTicket, covers: ['draft'] })
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`covers` is static rather than a runtime signal on purpose: "this reverse also reverses that one" is a property of the operation, legible where it is defined and checkable against the step names in scope.
|
|
56
|
+
|
|
57
|
+
**When the covering undo FAILS**, the covered steps are neither run nor claimed. Whether the cascade got that far is genuinely unknown — running the covered undo risks the double reverse, skipping it risks an orphan — so both guesses are refused and the steps come back in `compensationUncertain` with the step that was supposed to cover them. Same principle as never compensating the step that failed: surface the ambiguity, don't resolve it by assumption.
|
|
58
|
+
|
|
59
|
+
**`when` — one optional step.** 13 multi-await blocks in one app; only 5 could migrate. Several of the rest were linear *except* for one conditional step ("schedule the summary only if the set changed", `if (assigneeKey) assign else unassign`) and fell back to `try/catch` entirely, though 80% of the flow was a clean pipeline. An optional step is a different shape from a loop, and the scoping was treating them the same.
|
|
60
|
+
|
|
61
|
+
```tsx
|
|
62
|
+
.step('summary', (c) => scheduleSummary.run({ id: c.save.id }), { when: (c) => c.save.changed })
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
A skipped step contributes `undefined` to the context — the overload says so, so a later step has to acknowledge it — and gets no undo, since it had no effect to reverse. Loops and real branches still keep their `try/catch`; this deliberately does not widen to them.
|
|
66
|
+
|
|
67
|
+
**The break:** `StepOptions` gained a type parameter — `StepOptions<Result>` became `StepOptions<Ctx, Result>`, because `covers` and `when` both need to see the accumulated context (`covers` is checked against the step names in scope; `when` receives it). Call sites that pass an object literal to `.step()` are unaffected — the type is inferred — but anyone who *named* the type explicitly gets a compile error. Filed BREAKING rather than Added for the same reason a widened union is: the test is "can this turn code that compiled into code that does not", and it can. `@voltro/web` is listed because it re-exports the client surface — the third time in this round that coupling has decided where a change lands.
|
|
68
|
+
|
|
69
|
+
### Added
|
|
70
|
+
|
|
71
|
+
- **@voltro/cli** — **Repo gate** — a new `Claimed-wiring check` (`scripts/check-claimed-wirings.mjs`, wired into CI and therefore into `pnpm gate`): a doc comment that says something wires a symbol up must be telling the truth.
|
|
72
|
+
|
|
73
|
+
`setSystemStoreHandle`'s comment read *"Process-wide handle, registered by the runtime boot (dev.ts / start.ts)"*. Nothing registered it, in either path, through an entire release — so `runAsSystem` threw for every consumer, and the comment was the only evidence anyone had that it should work. The shape is not rare: a comment gets written when the wiring is planned, the wiring gets deferred, and the comment never finds out. It then reads as documentation of behaviour rather than of intention, and the more confidently it is phrased the less likely anyone is to check it.
|
|
74
|
+
|
|
75
|
+
Two things it does that the obvious version does not, both learned by watching it report the bug as clean:
|
|
76
|
+
|
|
77
|
+
- **it counts real call expressions, not text.** The first version matched regexes and found `setSystemStoreHandle({ … })` inside `runAsSystem`'s own error-message string — a text match cannot tell a call from a sentence about a call. (Precisely the defect fixed in the hand-roll detector one commit earlier, repeated one file later.) - **it checks the NAMED caller, not any caller.** The second version asked "does anything call this"; `@voltro/testing` calls it from a test harness, so the bug read clean again. A claim that the runtime boot registers something is not satisfied by a test helper registering it.
|
|
78
|
+
|
|
79
|
+
Verified the only way this kind of check can be: by removing the wiring and confirming it fails, with the diagnosis that would have saved the original investigation — `called by: packages/testing/src/testContext.ts ← none of these is the boot`.
|
|
80
|
+
|
|
81
|
+
### Fixed
|
|
82
|
+
|
|
83
|
+
- **@voltro/database, @voltro/runtime, @voltro/sql-postgres, @voltro/sql-mysql, @voltro/sql-sqlite, @voltro/sql-mssql** — **@voltro/database + every store** — a row inserted into a table that declares an `id()` scheme now gets one **at the store**, not only when the caller happened to go through `wrapStoreWithMixinBehaviour`.
|
|
84
|
+
|
|
85
|
+
Id generation was sitting one layer too high. It is the single stamped field that needs no subject — the scheme is a property of the declared table — yet it lived in the subject-aware wrapper. So every insert through an unwrapped store reached the database with `id: null`:
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
null value in column "id" of relation "_voltro_seeds" violates not-null constraint
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
That was a real shipped bug in the seed ledger (the runner holds the raw store), and a survey found the same shape waiting elsewhere: `plugin-rbac/userRoleStore.ts`, `plugin-governance/consent.ts`, and `plugin-sso-saml/saml-cache.ts` all insert without an id into tables that declare one. Whether any of them broke came down to how their caller happened to obtain its store — and *"depends on how the caller obtained its store"* is not a contract, it is a coin flip with a NOT NULL constraint on the other side.
|
|
92
|
+
|
|
93
|
+
`stampGeneratedId` now runs in every store's insert path (all four dialects plus the in-memory store, at the private `executeInsert` / `executeInsertMany` / `executeInsertIgnore` choke points that every public and namespace-view path funnels through). It preserves the semantics the wrapper had: an explicit id is never overwritten, a `numeric` scheme deletes the key so the dialect's SERIAL fires, and an unregistered table is passed through untouched rather than guessed at.
|
|
94
|
+
|
|
95
|
+
The wrapper still stamps — it holds the schema registry and does the subject-derived fields in the same pass — and now finds the id already set. That is a floor, not a second implementation of a rule: the invariant is "a row that reaches the database has an id when its table declares a generating scheme", and only the store can promise that for *every* caller, including a `DataStore` someone implemented themselves.
|
|
96
|
+
- **@voltro/cli** — **Repo tests** — the liveness backstop for real-listener / real-child-process suites goes from 60s to 120s, in `vitest.config.ts` and the CI flag that overrides it.
|
|
97
|
+
|
|
98
|
+
Worth stating plainly what this number is, because raising a timeout is the classic way to bury a problem: it asserts nothing about performance. A real `serveApi` boot is ~0.2s in isolation. The value is pure headroom against residual starvation that the four existing mitigations — the unit/integration project split, group ordering, `fileParallelism: false`, per-package mssql databases — cannot reach, because the remaining contention is turbo running two *packages* concurrently alongside the docker stack.
|
|
99
|
+
|
|
100
|
+
60s tripped twice in one session, on two different files (`connectionServe.test.ts`, then `serveApi.test.ts`), each passing in ~2s alone. Different file each time, always in the same family, never reproducible in isolation: that is the signature of starvation rather than of a slow test, and it cost two full gate runs.
|
|
101
|
+
|
|
102
|
+
What would not be honest is treating a green run afterwards as evidence the contention is gone. It is not. If a third file trips this, the answer is to stop running two heavy packages concurrently — not to raise it again.
|
|
103
|
+
- **@voltro/cli, @voltro/database** — **@voltro/cli, @voltro/database** — two fixes to the 0.10.0 seed ledger, both reported from its first real use, both mine.
|
|
104
|
+
|
|
105
|
+
**The ledger never wrote.** `store.insert('_voltro_seeds', …)` ran against the RAW store, and auto-id lives in `wrapStoreWithMixinBehaviour` — so the row reached postgres with `id: null` and died on the NOT NULL constraint. Every boot reported `ran=1 skipped=0` regardless of fingerprint: the feature shipped doing nothing. The ledger now stamps its own id, derived from `_voltroSeedsTable`'s declared scheme rather than a hardcoded prefix, so it no longer depends on how a caller happens to have wrapped its store.
|
|
106
|
+
|
|
107
|
+
**Worse than the bug was the logging.** I put the failure on `debug` and swallowed it, reasoning that "it degrades to re-running, which is only a performance regression". That reasoning is exactly what made it undiagnosable: a silently unwritten ledger looks identical to a working one whose seeds all changed — no symptom, nothing to grep, and the reporter had to read the error out of a debug stream to find it. Both the write and the read path now warn, naming the table and the consequence.
|
|
108
|
+
|
|
109
|
+
**And the reason it shipped:** the test fake *invented an id* when the row lacked one, making it more permissive than any database. A fake that supplies what the subject under test forgot is not a test — it is the subject testing itself. It now rejects an id-less insert with the real constraint's message; reintroducing the bug fails five cases.
|
|
110
|
+
|
|
111
|
+
**A seed step could not reach a usable store.** `SeedStore` exposed only `query`/`insert`/`update`/`delete`, and `query` took just `{ table, predicate }`. A restore of 1361 rows across 167 tables, multi-pass for FK order, needs an idempotent insert and a read it can page — and `upsertByUnique` is neither: a read per row, and it overwrites what it finds, which is wrong whenever the live row is newer than the snapshot. `SeedStore` now carries **`insertIgnore`** (one statement per row, native on every dialect) and a full descriptor read (`order` / `take` / `skip` / `projection`).
|
|
112
|
+
|
|
113
|
+
Seed reads are unscoped and include soft-deleted rows *by construction* — a seed runs at boot with no request and therefore no subject, so nothing applies a tenant filter or the `deletedAt IS NULL` predicate. That is now documented on the type rather than left to be discovered, since the absence of `.unscoped()` / `.withDeleted()` reads as a missing feature until you know why they cannot exist here.
|
|
114
|
+
|
|
115
|
+
*(`apiSurface: compatible`: the golden churn in `@voltro/database` is two `(undocumented)` markers disappearing because `SeedStore` and its new member gained TSDoc — a comment cannot break a caller. The added `insertIgnore` member is additive for consumers, which is everyone: a `SeedStore` is what `ctx.store` IS, handed to you by the runner. Nobody constructs one, so nobody can be missing a member.)*
|
|
116
|
+
- **@voltro/cli** — **@voltro/cli** — `runAsSystem` now works. The process-wide system store is registered at boot by **both** `voltro dev` and `voltro serve`; until now neither did, so every call threw `no data store available — register one at boot via setSystemStoreHandle`.
|
|
117
|
+
|
|
118
|
+
`setSystemStoreHandle`'s own doc comment reads "registered by the runtime boot (dev.ts / start.ts)". It describes wiring that was never written: the only callers in the repo were its unit test and a note in `@voltro/testing`. So the failure was not a lifecycle-ordering subtlety — the handle was never set at any point, in any command, and `runAsSystem` was unusable for every consumer.
|
|
119
|
+
|
|
120
|
+
Surfaced by someone reporting it as "not registered *yet* at seed time", which implied it worked later. Checking that framing rather than the symptom is what turned a scheduling question into a missing-wiring one. It is registered before the seed runner in dev, since seeds are the earliest thing that can plausibly want it.
|
|
121
|
+
|
|
122
|
+
Same class as the seed lifecycle table and the `@voltro/web` re-export: a documented behaviour with nothing behind it, where the doc is the only evidence anyone has.
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## [0.10.0] — 2026-07-22
|
|
127
|
+
|
|
128
|
+
### ⚠ BREAKING
|
|
129
|
+
|
|
130
|
+
- **@voltro/web, @voltro/cli** — **@voltro/web, @voltro/cli** — the SSR → hydration payload now carries **`path`**, the pathname the server rendered that document for, and the client refuses to hydrate a document whose payload names a different route. `mount()` used to decide between `hydrateRoot` and `createRoot` on the mere PRESENCE of the `__voltro_state__` tag; the payload contained no server pathname at all, so "the server rendered my route" and "someone handed me another route's document" were indistinguishable.
|
|
131
|
+
|
|
132
|
+
They are distinguishable in exactly one deploy shape, and it is a real one: a static host answers every URL it has no file for with `index.html`. Once the ROOT route is prerendered — which `renderMode:'spa'` pages under a layout became in 0.9.0 — that file is a genuine server render of the root: valid markup, valid payload, everything a hydrating client looks for. A deep link to any other URL therefore adopted the root's layout chain while the client rendered a different route: React hydration mismatch (#418), then a silent full client re-render of the page. Refusing to adopt is the only correct reading of "the server rendered something else", and the fresh render it falls back to is what the visitor would have got anyway — minus the error.
|
|
133
|
+
|
|
134
|
+
Host-shaped differences are normalised before the comparison (a trailing slash, a trailing `/index.html`, percent-encoding), because refusing those would send every legitimately prerendered page down the client-render path — correct output, slower, and completely invisible. That half is pinned by its own test for the same reason.
|
|
135
|
+
|
|
136
|
+
**The break:** `renderRouterStateScript` (and `encodeRouterState`) require `path`. No application code calls them — `voltro dev`, `voltro serve` and `voltro build` are the only emitters and all three now pass the pathname they hand the renderer, taken from the same value so the payload cannot disagree with the markup. It is filed BREAKING rather than Fixed because the docs point anyone building their own server at `@voltro/web/ssr`, and for them this is a compile error. The codemod is `manual`: the right value is the request pathname a custom server holds in a variable, and a transform could only guess — a plausible-looking wrong path would silently disable hydration for that route instead of failing.
|
|
137
|
+
|
|
138
|
+
Pass the request pathname, not the route pattern (`/posts/hello`, not `/posts/[slug]`): it is compared against `window.location.pathname`.
|
|
139
|
+
- **@voltro/cli, @voltro/web** — **@voltro/cli, @voltro/web** — `renderMode` is now a **closed set**: `'static' | 'spa' | 'ssr' | 'isr'`. Anything else fails `voltro dev` / `voltro build` / `voltro start` at CODEGEN — before a page module is loaded, before vite runs — with an error naming the page, the value it declared, and the valid set. Previously `resolveRenderMode` returned any string verbatim (its return type was bare `string`), so an invented value silently fell into whichever else-branch each gate happened to have.
|
|
140
|
+
|
|
141
|
+
**This is breaking because it rejects a value that used to be accepted.** The one that mattered in practice is `'client'`. It was never in the `RenderMode` type, yet it behaved *almost* like `'spa'` — `voltro build` skipped it in the prerender (`renderMode !== 'static'`), `voltro dev` and `voltro start` fell through to the SPA shell (`!== 'ssr' && !== 'isr'`), the SSR bundle listed it like every other page, the client router ignored the field, and `defer()` rejected it. Two places disagreed, which is the bug this closes: the new SSR-layout-shell gate matches the literal `'spa'`, so a semantically client-only page written as `'client'` silently did NOT get its layout server-rendered; and the static-deploy scan (`renderProfile`) used a value-restricted regex that could not match `'client'` at all and therefore counted those pages as `'static'` — which put every dynamic one into `dynamicMissing` and reported an app that is perfectly static-safe as `staticSafe: false`. A real 33-page app went from `{static: 33}` / `staticSafe: false` / 28 `dynamicMissing` to `{spa: 33}` / `staticSafe: true` / none.
|
|
142
|
+
|
|
143
|
+
**Migration: `'client'` → `'spa'`** — applied automatically by the `transform` codemod, which rewrites the literal wherever `renderMode` is assigned (the `export const`, the `as const` form, the annotated form, and an inline descriptor's `renderMode:` property) and leaves every unrelated `'client'` string alone. `'spa'` is the truthful target rather than a guess: it is the mode those pages already behaved as. `'static'` would be actively wrong — it would start pre-rendering pages that have never been pre-rendered. Nothing that was pre-rendered before is pre-rendered differently after; what those pages GAIN is exactly what every `'spa'` page gained in this release, the SSR layout shell, and `0.9.0/01_ssr-layout-shell-for-spa` prints that review step (its predicate matches `'client'` too, so it fires for precisely these projects).
|
|
144
|
+
|
|
145
|
+
Alongside: the valid set is single-sourced (`RENDER_MODES` in `@voltro/web`'s router, mirrored in the CLI's `routeMeta` and pinned against it by a test) so the type and the validator cannot drift; `resolveRenderMode` / `RouteMetadata.renderMode` are typed `RenderMode` instead of `string`; and `voltro dev`'s two inline `mod.renderMode as string ?? 'static'` reads now go through that one resolver. A `renderMode` exported from a `layout.tsx` / `error.tsx` / `loading.tsx` has never been read by the framework and still is not — the mode is a property of the page.
|
|
146
|
+
- **@voltro/runtime, @voltro/cli** — **@voltro/runtime, @voltro/cli** — a schedule and a bootstrap workflow run now execute as **`SYSTEM_SUBJECT`** (`tenantId: null`, i.e. unscoped) on **both** boot paths, and the ENGINE hands that subject to `buildContext` instead of each boot path inventing one.
|
|
147
|
+
|
|
148
|
+
**The bug:** `voltro dev` and `voltro serve` disagreed about who non-request work runs as, and neither errored.
|
|
149
|
+
|
|
150
|
+
| | `voltro dev` | `voltro serve` | |---|---|---| | schedule subject | `anonymousSubject($TENANT ?? 'acme')` | `{ tenantId: null }` | | workflow bootstrap subject | `anonymousSubject($TENANT ?? 'acme')` | `{ tenantId: null }` | | workflow run-row `subject` | that same anonymous subject | `null` | | dormancy wakeup key (schedule) | `$TENANT ?? 'acme'` | `'default'` | | dormancy wakeup key (workflow) | `$TENANT ?? 'acme'` | `'default'` |
|
|
151
|
+
|
|
152
|
+
`applyTenantScope` treats `tenantId == null` as "system, nothing to scope to" and any other value as a filter. So the SAME cron read exactly one tenant's rows under `voltro dev` and every tenant's rows under `voltro serve` — silently, with no error on either side. A nightly cross-tenant backfill worked in production and quietly did a fraction of its job in development; the reverse reading is worse, since a developer testing tenant isolation in dev saw an isolation that production does not have. The value dev used was the fallback for a login-less dev HTTP REQUEST, which a schedule never is: it leaked from the request path into a place that has no request.
|
|
153
|
+
|
|
154
|
+
A dev/prod difference is usually a nuisance. A dev/prod difference in what the tenant filter resolves to is a security surface, which is why the scoping RULE already lives in exactly one module (`tenantScope.ts`). This closes the caller side of the same hole: the INPUT to that rule now has one definition too.
|
|
155
|
+
|
|
156
|
+
**Why the engine owns it.** Each caller was internally consistent, so nothing at either call site could catch the drift — that is why it survived. `startScheduler`'s `buildContext` now receives `subject` in its input, and `makeWorkflowLayers` normalises the caller context before `buildContext` sees it. A `buildContext` that RECEIVES the subject cannot make this mistake; one that invents it can, and did twice.
|
|
157
|
+
|
|
158
|
+
The wakeup keys are bookkeeping rather than security, but they drift into the same failure: a dormant schedule or workflow wait registered under one boot path's key was invisible to a waker looking under the other's. All four now use `DORMANCY_WAKEUP_TENANT`.
|
|
159
|
+
|
|
160
|
+
**Behaviour change to expect:** under `voltro dev`, a schedule or resumed workflow that reads a `tenant()` table now sees **every** tenant, matching what it already did in production. If a cron of yours relied on the dev scoping, it was relying on a value derived from `$TENANT` — make the tenant explicit (`.unscoped().where('tenantId', id)`), which is what the multi-tenancy docs already recommend for cross-tenant reads. Conversely, `.unscoped()` workarounds added to survive the dev scoping are now no-ops rather than necessities, and can go.
|
|
161
|
+
|
|
162
|
+
**The break** is the type of `WorkflowLayerOptions.buildContext`'s first parameter (`WorkflowCallerContext | undefined` → `ResolvedWorkflowCallerContext`, whose `subject` and `traceId` are never absent) and, following from it, `serveApi`'s `buildContextForWorkflow` option. No application code touches either — they are framework wiring — but an embedder building a custom server on `serveApi` does, and for them it is a compile error plus a now-dead fallback branch. The codemod is `manual`: the correct edit is deleting a `??` fallback, and which of the two shapes an embedder wrote is not mechanically predictable. `SubscribeContext`, `SchedulerDeps.buildContext`'s input, and every user-facing primitive are additive.
|
|
163
|
+
- **@voltro/cli, @voltro/devtools-ui** — **@voltro/cli** — boot-lifecycle seeds now **skip themselves when nothing changed**. The runner reads and writes `_voltro_seeds`, comparing each seed's source fingerprint against the last successful run.
|
|
164
|
+
|
|
165
|
+
This closes a documentation gap rather than adding a new idea. The seeds page has always stated that a `boot` seed "only re-runs when the fingerprint changes", the `_voltro_seeds` table has always carried a `fingerprint` column, and the runner computed the fingerprint on every boot — and then only logged it. So every boot seed re-ran on every boot. Nothing was WRONG (a boot seed is idempotent by contract), but "idempotent" means every row is re-checked: a consumer restoring a reference catalogue re-scanned it on every `voltro dev` restart and reasonably concluded the feature had never shipped.
|
|
166
|
+
|
|
167
|
+
Two rules, because both failure modes are silent:
|
|
168
|
+
|
|
169
|
+
- **Only a succeeded run counts.** A failed run records its status but never satisfies the skip, so one bad boot cannot turn into a permanently skipped seed. - **An unreadable ledger means RUN.** A missing table, an unmigrated database, a memory store — anything that stops us from reading `_voltro_seeds` makes the runner execute every boot seed. Re-running idempotent work costs time; skipping data restoration on a database we could not inspect costs data.
|
|
170
|
+
|
|
171
|
+
A skipped seed is **reported**, not omitted: `runBootSeeds` returns it with `status: 'skipped'`, and the inspect payload / dashboards carry that through as its own state. Dropping it from the results would have made an applied seed render as never-run in the devtools and cloud seed panels — and the cloud proxy validates the status with `Schema.Literal`, so an unannounced value there is a decode failure, not a cosmetic one. Both dashboards render `skipped` distinctly from "never run".
|
|
172
|
+
|
|
173
|
+
`voltro db seed` is unchanged and still **forced** — it ignores the fingerprint, as documented. Someone typing the command is asking for the seed to run, and quietly doing nothing would be the wrong answer.
|
|
174
|
+
|
|
175
|
+
**The break is the status union**, and it is worth spelling out because it looks additive and is not. `SeedRunResult['status']` and `SeedSnapshot['lastRunStatus']` gain `'skipped'`. Nothing was removed — but a consumer that switched exhaustively over `'succeeded' | 'failed'`, or assigned the value to a variable of that narrower type, stops compiling. That is the test the changelog gate applies ("can this turn code that compiled into code that does not?"), and it is the same shape as the `store.query()` narrowing that cost one app 102 hand-fixes. Our own cloud proxy validates the value with `Schema.Literal`, so an unannounced third state would have been a decode FAILURE there, not a rendering oddity.
|
|
176
|
+
|
|
177
|
+
A `manual` codemod: adding the case is a two-line edit, but only the author knows whether a skipped seed should render as applied, as pending, or be filtered out of that particular view.
|
|
178
|
+
|
|
179
|
+
**Also corrected, because the same audit turned it up:** the lifecycle table claimed `onTenantCreate`, `onSchemaChange`, and `cron` seeds fire. They do not. All three validate at definition time and get registered so the dashboard can list them, and then nothing ever triggers them — a seed declared with one runs never, silently. The docs (both languages) and `seed.ts` now say so, and point at the primitives that do run. Wiring them is a feature, not a fix; promising them meanwhile is the failure mode this whole entry is about.
|
|
180
|
+
- **@voltro/client** — **@voltro/client** — a skipped subscription now reports **`idle: true`, `loading: false`**. It used to report `loading: true` forever, since nothing was ever going to arrive.
|
|
181
|
+
|
|
182
|
+
That collided head-on with the pattern this hook's own documentation blesses:
|
|
183
|
+
|
|
184
|
+
```tsx
|
|
185
|
+
if (loading) return <Skeleton/>
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
On a skipping call site that renders a skeleton for a query the app deliberately switched off. `skip: !currentUser?.id` and `skip: !open` are the two commonest forms, and they only survived because components happened to carry a redundant `if (!currentUser) return null` in front — the very check `loading` was introduced to replace. `loading` was answering two different questions with one boolean, and at exactly the feature where the difference matters.
|
|
189
|
+
|
|
190
|
+
We had it six times in our own `useWorkflow.ts`, every one a `skip: someId === undefined`: `useWorkflowRun(api, undefined)` reported `loading: true` indefinitely, so any consumer following the blessed pattern rendered a permanent skeleton whenever nothing was selected.
|
|
191
|
+
|
|
192
|
+
**The reported fix was not the right one.** A third state on every call site — `{ status: 'idle', loading: false, data: undefined }` — would have destroyed the property the union exists for: `!loading` would stop proving `data` is present, silently un-narrowing every call site and reintroducing the `?? []` / `!` this type was built to delete. So the idle state is **scoped by overload** instead:
|
|
193
|
+
|
|
194
|
+
| call | result | |---|---| | no `skip`, or a literal `{ skip: false }` | `SubscriptionState<T>` — two states, narrowing unchanged | | a dynamic `{ skip: <boolean> }` | `SubscriptionState<T> \| SubscriptionIdle` — must be handled | | any `fallback` | `SubscriptionStateWithFallback<T>` — `data` always present, `idle` reports why |
|
|
195
|
+
|
|
196
|
+
The cost lands precisely on the callers who use the feature; everyone else compiles untouched. A `.test-d.ts` pins all four cases, including a `@ts-expect-error` asserting that handling only `loading` on a skipping site still fails to compile — so a regression in the overload ordering fails the typecheck rather than quietly making every call site longer again.
|
|
197
|
+
|
|
198
|
+
**One behaviour change beyond the types:** a subscription that was live and is then skipped goes idle instead of continuing to serve the snapshot it still holds in cache. Otherwise `skip: !open` would show last time's data the instant a dialog reopens. If you relied on the stale value, hold it in your own state.
|
|
199
|
+
|
|
200
|
+
### Added
|
|
201
|
+
|
|
202
|
+
- **@voltro/cli** — **@voltro/cli** — `voltro build` now prerenders the SSR LAYOUT SHELL for a `renderMode:'spa'` page — the layout chain server-rendered around an empty page slot (`<div data-voltro-page-slot>`), with `pageClientOnly: true` in the inlined state, i.e. byte-for-byte the shell `voltro start` already produced on demand. It is written to `dist/<route>/index.html`, so a static host paints the layout immediately instead of an empty `#root` (and serves the route at all, instead of falling back to the raw shell). This closes the "build-time prerender of the spa layout shell" follow-up left open by 0.9.0.
|
|
203
|
+
|
|
204
|
+
**It is gated, and the gate is the point: the shell is prerendered ONLY when no layout in the page's chain exports a `loader`.** Baking layout-loader output into a file served to every visitor is safe only if that output is request-INDEPENDENT — a layout loader that resolves the signed-in user or the tenant would freeze ONE visitor's data into the artefact, which is a cross-user data leak, not a stale value. There is no way to prove request-independence by inspection, so a chain with any layout loader is left to `voltro start`, which runs the loader per request and is correct by construction; the build logs which route it skipped and which layout caused it. The gate reads the layout module's REAL `loader` export rather than the source-scan heuristic used elsewhere, because a false negative there is exactly the direction that leaks. Dynamic spa patterns are also skipped — `getStaticPaths` is a static-rendering contract, so there is no build-time path to write. A skipped spa page behaves exactly as before.
|
|
205
|
+
|
|
206
|
+
`voltro start` is unchanged: it still renders every spa-with-layout route on demand, so the prerendered file is a static-hosting artefact and adds no new serving path. One thing to know if your spa page IS the root route: its shell then becomes `dist/index.html`, which is also the SPA fallback a static host serves for unmatched URLs (the pristine template stays available at `dist/_voltro/shell.html`, which is what `voltro start` reads). Covered by a new `e2e-fixtures/web-spa-shell` + `buildSpaShellPrerender.test.ts`, whose load-bearing assertion is the NEGATIVE one: a spa page under a loader-bearing layout produces no file at all.
|
|
207
|
+
- **@voltro/cli** — **@voltro/cli** — `voltro doctor`'s hand-roll detector now parses the files it scans instead of matching text against them, and gains a rule that needs that: **`subscription-undefined-check`**, which flags `data === undefined` / `!data` on a `useSubscription` result and points at `loading` (and `idle`).
|
|
208
|
+
|
|
209
|
+
The rule is the reason for the parser. A consumer migrating exactly these call sites wrote a regex codemod for the job, and it rewrote a `summary === undefined` check inside a child component where `summary` was a **prop**. Their compiler caught it only because that particular name was out of scope there; with matching names it would have shipped a silent behaviour change. Text cannot tell you which declaration an identifier refers to, so a rule about identifiers cannot be written in text — the new rule resolves each candidate to its declaration, which makes a prop, a loop variable, and a same-named import simply not be the binding.
|
|
210
|
+
|
|
211
|
+
Two existing rules were imprecise for the same reason and are now counted on the AST: the form detector counted the *word* `useState` (including in comments, in strings, and inside `useStateMachine(`), and the N+1 detector counted mentions of `store.query(` rather than calls — a file documenting the N+1 pattern in a comment block was a false positive. Every other rule keeps its exact predicate; the migration was verified behaviour-neutral against the existing suite before anything was upgraded.
|
|
212
|
+
|
|
213
|
+
Deliberately NOT built: a full `ts.Program` over the app's tsconfig. Resolving a binding to its declaration is a per-file question, and keeping it per-file means the doctor stays fast and keeps working on a project that does not currently typecheck — which is precisely when someone runs it.
|
|
214
|
+
- **@voltro/runtime, @voltro/cli** — **@voltro/runtime, @voltro/cli** — a `*.subscribe.ts` handler's `ctx` now carries **`store`**, so a subscriber can read and write in reaction to the commit it just observed.
|
|
215
|
+
|
|
216
|
+
It could not before. `SubscribeContext` was `{ log, id }` while the runner held the store one scope up in the very same function — so a subscriber could observe a change and do nothing about it. The documented workaround was no workaround at all: people fell back to a `*.startup.tsx` that called `store.onChange` itself, i.e. re-implemented the runner in app code to get a store back, losing the file-convention discovery and the per-subscriber error scoping in the process.
|
|
217
|
+
|
|
218
|
+
**`ctx.store` runs as `SYSTEM_SUBJECT` — it is NOT tenant-scoped.** This is the part worth reading before using it. A subscriber fires from the change stream, not from a request, so there is no subject to scope to and no tenant to infer. Queries see every tenant's rows, and a write to a `tenant()` table without an explicit `tenantId` fails with `TenantScopeViolation` rather than silently landing in some arbitrary tenant. When the reaction is per-tenant, take the tenant from the row that changed:
|
|
219
|
+
|
|
220
|
+
```ts
|
|
221
|
+
export default defineSubscriber({
|
|
222
|
+
table: 'orders',
|
|
223
|
+
on: 'insert',
|
|
224
|
+
handler: async (event, ctx) => {
|
|
225
|
+
await ctx.store.insert('order_audit', {
|
|
226
|
+
orderId: String(event.new?.['id']),
|
|
227
|
+
tenantId: String(event.new?.['tenantId']), // explicit — nothing infers it
|
|
228
|
+
})
|
|
229
|
+
},
|
|
230
|
+
})
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
Writes are mixin-stamped exactly as a request-path write is (id scheme, timestamps, audit columns), so this is the same store semantics handlers have, minus the request.
|
|
234
|
+
|
|
235
|
+
Still absent, still deliberate: there is no workflow handle here. A subscriber is best-effort and non-durable, so "a row changed → start a workflow" still belongs in a `*.reaction.tsx`, whose `act` is crash-safe. That distinction is the reason subscribers exist as a separate primitive, and adding a store does not blur it.
|
|
236
|
+
- **@voltro/client** — **@voltro/client** — `useSequence` / `sequence()`: multi-step writes as one unit, with one `onError` and per-step compensation.
|
|
237
|
+
|
|
238
|
+
`useMutation` and `useAction` deleted the `try/catch` from single writes, and the docs correctly said sequences keep theirs. That left multi-step writes — `upload → createAttachment`, `createDraft → createTicket → startThread → linkThread` — as the only verbose thing on the write path, and they are the hardest case, not the easiest. One consumer counted them as the dominant remaining group.
|
|
239
|
+
|
|
240
|
+
```tsx
|
|
241
|
+
const seq = useSequence({ onError: (e) => toast.error(readError(e)) })
|
|
242
|
+
|
|
243
|
+
const result = await seq.run(
|
|
244
|
+
sequence()
|
|
245
|
+
.step('upload', () => upload.run({ file }), {
|
|
246
|
+
undo: (created) => removeObject.run({ id: created.id }),
|
|
247
|
+
})
|
|
248
|
+
.step('attach', (c) => createAttachment.run({ refId: c.upload.id })),
|
|
249
|
+
)
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
The step context is typed and accumulates, so a later step reads an earlier result by name. `run` resolves with a discriminated result instead of rejecting — the same "handled" semantics the other write hooks use. **`undo` receives its own step's result**, which is the whole requirement: the id you just created is what you need to delete it again.
|
|
253
|
+
|
|
254
|
+
**It is not a transaction, and the API is shaped so nobody can mistake it for one.** After `createTicket` returns, the ticket exists in Jira — nothing the browser does un-creates it, it can only issue a delete and hope. The compensation also runs *in the tab*: close it mid-rollback and the remaining undos never happen. Two rules follow, both enforced and both tested with the failure injected:
|
|
255
|
+
|
|
256
|
+
- the step that **failed** is never compensated. It may or may not have had an effect, so undoing it is a guess — and a wrong guess deletes something that was never created, or someone else's row if ids get reused; - a failing `undo` never replaces the original error and never aborts the remaining undos. Rethrowing there would tell the user the wrong thing went wrong AND turn one orphan into several. Cleanup failures come back in `compensationFailures`, because a silently swallowed one is how an orphan becomes permanent.
|
|
257
|
+
|
|
258
|
+
For rollback that must survive a closed tab, the docs point at a workflow rather than pretending this covers it. The engine (`runSequence`) is React-free and separately tested, so the ordering and precedence rules do not need a renderer to exercise.
|
|
259
|
+
|
|
260
|
+
### Fixed
|
|
261
|
+
|
|
262
|
+
- **@voltro/plugin-duckdb, @voltro/plugin-clickhouse, @voltro/plugin-analytics-postgres** — An analytics range with no `to` no longer hides events written in the same instant as the query. All three sinks built the predicate as `occurred_at < (range.to ?? new Date())` — a STRICT upper bound defaulted to the moment the query is built. Since `track()` stamps `occurred_at = now`, a read landing in that same millisecond compared `T < T` and excluded the event, so the freshest data — the data a dashboard actually shows — was the most likely to vanish.
|
|
263
|
+
|
|
264
|
+
Measured, not inferred: a 40-iteration track-then-immediately-aggregate loop against the duckdb sink lost **7 of 40** before the fix and 0 after.
|
|
265
|
+
|
|
266
|
+
An absent `to` now means what it says: no upper bound at all. A caller-SUPPLIED `to` keeps the strict `<`, so an explicit range stays half-open `[from, to)` and two adjacent windows cannot both count a boundary event — which is why the strict comparison exists in the first place.
|
|
267
|
+
|
|
268
|
+
It surfaced as an intermittent `expected 0 to be 1` in a duckdb dispose-lifecycle test. The clickhouse suite had an assertion pinning the defect (it required `occurred_at < {to:DateTime64(3)}` for a range with no `to`); that now asserts the bound is ABSENT, with a new case proving an explicit `to` still binds one.
|
|
269
|
+
|
|
270
|
+
**`@voltro/plugin-tinybird` has the identical construction and is deliberately unchanged.** It passes `to` to a user-authored Tinybird *pipe* whose SQL lives in your own account, so dropping the parameter would break every pipe that declares it required. If you author such a pipe, make its upper bound inclusive or omit it — the client can only fix its half.
|
|
271
|
+
- **@voltro/cli** — **@voltro/cli** — `voltro build`'s static prerender now runs LAYOUT loaders, not just the page loader. A CMS-backed nav, footer or site-settings `loader` in a `layout.tsx` previously never ran at build time, so every prerendered page shipped with that layout rendering its no-data fallback and only filled in after hydration — an empty shell in the HTML a crawler (or a JS-less first paint) sees. The justification in the code was that "layouts have no per-request data here", which confused per-REQUEST data with per-BUILD data: a build-time loader is exactly what fetches CMS content for a static page, and a layout loader is no different from the page loader that has always run there. The prerender now assembles the chain through the SAME shared `buildSegmentChain` that `voltro dev` and `voltro start` use — so the three SSR paths cannot drift on loader-argument shape, parallelism or index keying — and passes the resulting `segmentLoaderData` into both `renderPageToHtml` and the single-sourced `renderRouterStateScript`. The prerendered file therefore carries the layout's build-time data in its markup AND in its `__voltro_state__` payload, so the client's hydration render reproduces the same markup and the layout loader does not re-run. Build-time loaders get the build-time context only — `params`, `pathname`, `signal`; there is no `headers` and no `query`, because there is no request. A layout loader throwing `RedirectError` fails the build (a static artefact cannot redirect per-request — switch the page to `ssr`); `NotFoundError` skips that path's artifact; any other throw degrades exactly as a failing page loader does, with a warning and no layout data, rather than failing the whole build. `defer()` inside a layout loader is rejected on a static page for the same reason it already was for a page loader. Verified end-to-end: `webDevSsrLayoutLoader.test.ts` asserts the bytes of a REAL `voltro build`'s prerendered file, and `@voltro/web`'s `hydrateLoaderData.test.tsx` hydrates that document shape for real (jsdom `hydrateRoot`) with zero console errors and no layout-loader re-run.
|
|
272
|
+
- **@voltro/database, @voltro/runtime, @voltro/sql-postgres, @voltro/sql-mysql, @voltro/sql-sqlite, @voltro/sql-mssql** — **@voltro/database + `InMemoryDataStore` + the SQL dialects** — the store's change bus no longer prints `MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 change listeners added` on a healthy boot. Every dialect store raises its emitter's limit to `CHANGE_LISTENER_CEILING` (512) before any listener binds.
|
|
273
|
+
|
|
274
|
+
Node's default of 10 assumes listeners accumulate per request — the classic leak. This bus does not work that way: listeners are bound once at boot, one per declared artefact, and detached at shutdown. The framework itself takes about six; the rest is one per `*.subscribe.ts`, one per `*.reaction.tsx`, and one or two per aggregate. An app crosses 10 by declaring a normal number of things, and the warning it then gets points at nothing it can act on.
|
|
275
|
+
|
|
276
|
+
Note what does **not** drive the count, since the obvious guess is wrong: table count. Every listener filters by table inside its own callback, so 500 reactive tables still bind one listener per artefact. A ceiling derived from table count would scale with the wrong number.
|
|
277
|
+
|
|
278
|
+
The ceiling is finite on purpose. `setMaxListeners(0)` would silence the warning and also silence a real leak — an aggregate or reaction that re-registers without detaching — forever. 512 sits far above any plausible declaration count and far below a runaway loop, so the warning keeps the meaning it was designed to have.
|
|
279
|
+
|
|
280
|
+
`InMemoryDataStore` is included, and was very nearly not — the first pass fixed the four dialect stores and left it alone. That would have been backwards: memory is the DEFAULT store and the one `voltro dev` picks with no database configured, so the warning would have survived precisely where a new project meets it, while the packages that got the fix are ones a beginner has not reached yet.
|
|
281
|
+
- **@voltro/runtime** — **@voltro/runtime** — a COMPUTED subscription's delivery no longer reports a hardcoded `rowCount: 1`. It reports the real count: `0` for `null`/`undefined`, the length of an array, `1` for a single value.
|
|
282
|
+
|
|
283
|
+
That number is not decoration. It is the `rows` attribute on the `subscription.<tag>.snapshot` span and the Prometheus row histogram — the two places a human looks to answer "did the server actually produce data?". With a constant, a computed query that resolved to `null` traced `rows=1`, identical to one that returned a row.
|
|
284
|
+
|
|
285
|
+
This is filed as a fix rather than a chore because we know what it cost. A downstream consumer debugging an SSR loader that returned `null` for `employees.me` read `subscription.employees.me.snapshot rows=1` in their trace, correctly concluded from it that the server had produced a value, and reported a framework bug in the loader's response drain. The drain was fine; the trace was lying. They spent the investigation on the wrong layer, and so did we until the constant turned up. Observability that lies is worse than none, because it is trusted.
|
|
286
|
+
|
|
287
|
+
Row-set subscriptions were always correct (`rows.length`) — only the two computed paths, snapshot and recompute-delta, carried the constant.
|
|
288
|
+
- **@voltro/cli** — **@voltro/cli** — `voltro dev` no longer throws away client state when you edit a page-local value export. Route Fast Refresh forced a full page reload whenever ANY non-component export of a page/layout changed by value — so a `export const COLUMNS = [...]` edited in the same save as the JSX that renders it reloaded the page, losing form input, scroll position and every `useState`, even though the JSX edit alone would have hot-updated. That was broader than the reason for the reload: an edit can only defeat HMR when the export is one the SERVER already read to produce the HTML in front of you. The forced-reload set is now exactly those exports — `loader`, `renderMode`, `dynamic`, `meta`, `getStaticPaths`, `revalidate`, `staleWhileRevalidate`, `cacheInvalidatesOn`, `interactive`, `tenantAware` — derived from `computeRouteMetadata`'s own parameter type (`src/routeServerExports.ts`), so it cannot drift from the readers in `build.ts` (prerender), `webDev.ts` (dev SSR) and `start.ts`. Everything else a route module exports now hot-updates: the module re-evaluates, the component renders the new value, and any other importer is Vite's normal graph propagation to resolve. A `loader` edit still full-reloads — correct, and unchanged — and the console line now names the changed export plus the server step that consumed it instead of always claiming "a loader runs on the server too". Unchanged as well: every non-component export is still reported to `@vitejs/plugin-react`'s ignored-exports hook (narrowing THAT list would make react-refresh refuse the boundary outright), and the transform stays client-only and append-only. One residual caveat, documented: ADDING or REMOVING a non-component export still reloads once, because Fast Refresh sees a name that was not yet on the ignore list. Verified in a real browser (`scripts/browser-deferred-stream.mjs --only=hmr`): a constant edit, and a constant + JSX edit in one save, both apply as hot updates with a live `useState` counter surviving and the new constant value on screen, while a `loader` edit still resets it.
|
|
289
|
+
- **@voltro/cli** — **@voltro/cli** — the dev boot-health handle now reports the port it **actually** bound, not the one it was asked for, so `startDevHealthServer({ port: 0 })` is usable: bind ephemerally, read the real port back off `handle.port`.
|
|
290
|
+
|
|
291
|
+
Echoing the request was fine for `voltro dev` itself (it names a concrete port, so the two always matched) and wrong for anyone binding `0` — the handle would report `0`, leaving the caller unable to find its own listener. `port: null` still means the bind failed, unchanged.
|
|
292
|
+
|
|
293
|
+
Found via a flaky test rather than a report, and the flake is the more useful half of the story. `devHealthServer.test.ts` picked a free port by binding `0`, reading the port, closing, and re-binding it — a window in which another process can take it. Under a full `pnpm gate` (dozens of parallel test processes plus the docker stack) that window gets hit: the server correctly degraded to `port: null`, the test then fetched a port now owned by something that never answers HTTP, and the run hung to the 60-second timeout. Green locally, red in CI, for nothing. The tests now bind `0` and use the reported port, so there is no window at all.
|
|
294
|
+
- **@voltro/cli, @voltro/web** — **@voltro/cli, @voltro/web** — a `defer()` in a LAYOUT loader now works on the SSR-layout-shell path (a `renderMode:'spa'` page under an SSR layout chain). It used to be **silently ignored**: `voltro dev` / `voltro serve` skipped deferral preparation entirely for that path, so the loader's deferred bucket never reached the renderer, `<Await>` in the layout got a promise nobody was streaming, and no error said so. That silence was the defect — every other unsupported combination in this feature (`static`, `isr`, `interactive:'none'`, `interactive:'islands'`) fails loudly, by name, with the fix in the message.
|
|
295
|
+
|
|
296
|
+
The shell now STREAMS when a layout defers: the layout chain plus the EMPTY page slot flush immediately — byte-for-byte the shell a non-deferring page has always produced, which is what keeps the client's first render (`pageClientOnly` → the empty slot) hydrating without a mismatch — and the deferred layout value arrives in a later chunk behind its `<Await>` boundary, with the settle script that publishes it to the client registry. A shell whose layouts do NOT defer is untouched: still one buffered write, still no registry script. Both boot paths go through one shared preparer (`prepareSpaLayoutShell` in `ssrHelpers.ts`), so dev and serve cannot drift on the seam flags; `voltro serve` reports the streamed shell as `x-voltro-rendered-by: spa`, the same as the buffered one.
|
|
297
|
+
|
|
298
|
+
Two behaviour changes worth naming even though neither breaks code that compiled. (1) `assertDeferralSupported` no longer decides on `renderMode` alone — a `renderMode:'spa'` page is accepted when its shell is delivered as a streamed per-request response, and still rejected when it is not. (2) The build-time prerender of a spa shell passes `layoutShell: 'artefact'`, so a deferring layout there is now a hard error naming the page instead of an `<Await>` fallback frozen into a static file forever. In practice that error is unreachable through `voltro build`: the existing safety gate already sends any chain with a layout `loader` to the on-demand renderer, and a `defer()` can only come from a loader — it is the second line of defence if that gate is ever loosened.
|
|
299
|
+
|
|
300
|
+
Verified as ORDERING rather than final bytes (a buffered implementation passes any final-HTML diff): the shell chunk must arrive first and must NOT contain the deferred value, asserted against both a real `voltro dev` and a real `voltro serve` in `webDevSsrLayoutLoader.test.ts`, in a jsdom hydration harness that streams real chunks into a real `hydrateRoot` (`deferredHydration.test.tsx`, with a control that breaks the `pageClientOnly` seam and must mismatch), and in a real chromium (`scripts/browser-spa-layout-shell.mjs --only=defer`, which also runs the seam-broken control against the streamed shell).
|
|
301
|
+
- **@voltro/plugin-storage** — **@voltro/plugin-storage** — `storage.listRefs` no longer dies when one ref's `tags` cell is not an array of strings. An unreadable cell reads as `null`; every other row is unaffected.
|
|
302
|
+
|
|
303
|
+
The reported failure: a tenant whose `_voltro_storage_refs.tags` column held a jsonb `{}` lost the **entire** query. The row set encodes against `Schema.NullOr(Schema.Array(Schema.String))` as one value, so a single bad cell fails all of it — and it surfaced as an Effect defect (`Die`), not the typed `StorageError` a caller can handle. The plugin's own media-library read was unusable for that tenant.
|
|
304
|
+
|
|
305
|
+
`toRef` coerced every other column at the DB boundary (`Number(...)` for size, a ternary for visibility, Date-or-string for createdAt) and `tags` alone was a bare cast — a promise the database never made.
|
|
306
|
+
|
|
307
|
+
Where the `{}` came from, for anyone finding one: before json columns were JSON-encoded on write, postgres bound a JS array to a `jsonb` column as a native ARRAY literal, so `tags: []` persisted as `{}`. That write path was fixed in 0.9.0. This read stays total anyway — a built-in query must not die on one cell when the correct answer for that cell is "no readable tags", and the bytes remain untouched in the column either way.
|
|
308
|
+
- **@voltro/cli** — **@voltro/cli** — the outgoing-webhook trigger context is now built by one shared function for `voltro dev` and `voltro serve`. Production was missing `access` and `load`/`loadMany` — both **required** on `AppContext` — so `ctx.access.has(...)` and `ctx.load(...)` worked in development and were `undefined` under `voltro serve`.
|
|
309
|
+
|
|
310
|
+
It compiled because the literal was cast `as never`. That cast is the whole story: the type system had the answer the entire time and was told not to give it. The shared builder returns a real `AppContext`, so a field added to that interface now breaks at one place instead of silently existing in one boot path.
|
|
311
|
+
|
|
312
|
+
Worth stating why this context is hand-assembled at all, since "just use `makeAppContextBuilder`" is the obvious question: that builder takes the webhooks service as a dependency and the service is built FROM this context. Something has to go first, and the trigger context is the right one — it is the single context that must NOT carry `ctx.webhooks`, because a service reaching back into itself is a loop, not a feature. So the fix is a second shared builder, not a deletion.
|
|
313
|
+
|
|
314
|
+
The context also now runs as `SYSTEM_SUBJECT` rather than a locally-constructed anonymous subject, matching schedules and subscribers: a trigger fires outside any request, so there is no tenant to infer.
|
|
315
|
+
|
|
316
|
+
Reach: the context is handed to `buildDeliverWebhookExecute`, i.e. it is what the durable delivery workflow runs under — not only the service. Nothing in the plugin reads the missing fields today, which is why this was latent rather than a live crash; the `as never` guaranteed it would stay invisible until something did.
|
|
317
|
+
|
|
318
|
+
---
|
|
319
|
+
|
|
42
320
|
## [0.9.0] — 2026-07-21
|
|
43
321
|
|
|
44
322
|
### ⚠ BREAKING
|
|
@@ -62,7 +340,36 @@ _Changes staged for the next release accumulate here (rolled up from
|
|
|
62
340
|
|
|
63
341
|
### ⚠ BREAKING
|
|
64
342
|
|
|
65
|
-
- **@voltro/web, @voltro/cli** — **`defer()` + `<Await>`: stream one slow part of a page instead of blocking the whole response.**
|
|
343
|
+
- **@voltro/web, @voltro/cli** — **`defer()` + `<Await>`: stream one slow part of a page instead of blocking the whole response.**
|
|
344
|
+
|
|
345
|
+
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**.
|
|
346
|
+
|
|
347
|
+
```tsx
|
|
348
|
+
export const renderMode = 'ssr' as const
|
|
349
|
+
export const loader = async ({ query }) => defer(
|
|
350
|
+
{ user: await query('users.me') }, // in the shell
|
|
351
|
+
{ report: query('reports.quarterly') }, // streamed after it
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
const { user, report } = useLoaderData<Awaited<ReturnType<typeof loader>>>()
|
|
355
|
+
<Await value={report} fallback={<ReportSkeleton />}>{(r) => <ReportTable rows={r.rows} />}</Await>
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
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>`.
|
|
359
|
+
|
|
360
|
+
**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.
|
|
361
|
+
|
|
362
|
+
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.
|
|
363
|
+
|
|
364
|
+
**`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.
|
|
365
|
+
|
|
366
|
+
**Why BREAKING.** Two things can turn code that compiled into code that does not, or change deployed behaviour:
|
|
367
|
+
|
|
368
|
+
- `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.
|
|
369
|
+
|
|
370
|
+
`<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.
|
|
371
|
+
|
|
372
|
+
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.
|
|
66
373
|
- **@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.
|
|
67
374
|
|
|
68
375
|
### Added
|
|
@@ -89,7 +396,24 @@ _Changes staged for the next release accumulate here (rolled up from
|
|
|
89
396
|
### Added
|
|
90
397
|
|
|
91
398
|
- **@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.
|
|
92
|
-
- **@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).
|
|
399
|
+
- **@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).
|
|
400
|
+
|
|
401
|
+
```ts
|
|
402
|
+
output: Schema.Struct({
|
|
403
|
+
id: Schema.String,
|
|
404
|
+
addedAt: timestampMs, // Date in the handler, epoch ms on the wire
|
|
405
|
+
archivedAt: timestampMsOrNull,
|
|
406
|
+
seenAt: Schema.optional(timestampMs),
|
|
407
|
+
})
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
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`.
|
|
411
|
+
|
|
412
|
+
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.
|
|
413
|
+
|
|
414
|
+
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.
|
|
415
|
+
|
|
416
|
+
Additive: two new exports, no existing declaration changed. `packages/database/etc/database.api.md` gains two entries and changes none.
|
|
93
417
|
|
|
94
418
|
### Fixed
|
|
95
419
|
|
|
@@ -103,7 +427,27 @@ _Changes staged for the next release accumulate here (rolled up from
|
|
|
103
427
|
|
|
104
428
|
### ⚠ BREAKING
|
|
105
429
|
|
|
106
|
-
- **@voltro/plugin-atlassian, @voltro/cli** — The Atlassian avatar proxy gained a **per-user auth mode**, and its config is now a discriminated union on a required `mode`.
|
|
430
|
+
- **@voltro/plugin-atlassian, @voltro/cli** — The Atlassian avatar proxy gained a **per-user auth mode**, and its config is now a discriminated union on a required `mode`.
|
|
431
|
+
|
|
432
|
+
```ts
|
|
433
|
+
// service mode — one shared token, public route (what the old shape meant)
|
|
434
|
+
avatar: { mode: 'service', resolveCredentials: () => creds }
|
|
435
|
+
|
|
436
|
+
// per-user mode — the fetch runs with the VIEWING subject's own PAT
|
|
437
|
+
avatar: {
|
|
438
|
+
mode: 'perUser',
|
|
439
|
+
resolveSubject: (req) => verifySession(readCookie(req.headers['cookie'], 'voltro:session'), secret),
|
|
440
|
+
resolveCredentials: (subject) => credsFor(subject),
|
|
441
|
+
}
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
Why a required discriminant rather than overloading `resolveCredentials` on its arity: for a deployment whose hard constraint is that a service PAT must not exist, the difference between the two modes is the whole security posture, and it must be readable at the config site rather than inferred from a callback's signature. In `perUser` mode an unauthenticated caller is refused with 401 — never served from cache — a subject with no PAT on file gets 403, and there is **no fallback to a service credential**.
|
|
445
|
+
|
|
446
|
+
`PluginHttpRouteRequest` carries no subject (the framework does not authenticate plugin HTTP routes), so `resolveSubject` is app-supplied; it is the same seam `@voltro/plugin-storage`'s serve route uses.
|
|
447
|
+
|
|
448
|
+
An optional byte cache (`avatar.cache`) now serves resolved avatars without a second upstream call. Its key includes the **viewer** in per-user mode (`type:id:tenant:ref`) — keying by avatar owner alone would let one user's fetch populate an entry served to another whose PAT was never checked. Service mode keys by ref alone, which is correct there.
|
|
449
|
+
|
|
450
|
+
Migration: `voltro update` adds `mode: 'service'` to every existing `avatar` config (the old shape had exactly that meaning). Opting into `perUser` is a deliberate follow-up — it needs a `resolveSubject` only the app can write.
|
|
107
451
|
- **@voltro/plugin-billing** — Billing runs on the official Stripe SDK, and everything Stripe already does is now Stripe's. The change that matters most is not stylistic: **`changeSeats` and `changePlan` never reached Stripe.** They patched a local row and returned a proration figure nothing ever charged. A customer could be granted forty seats while Stripe billed for one. Both now call `subscriptions.update` with a `proration_behavior`, and the local row is written FROM Stripe's answer so the two cannot drift. Removed, because Stripe does them properly: the whole `proration.ts` module (Stripe computes AND invoices proration), and the entire dunning subsystem — `dunning.ts`, `DunningStore`, the `_voltro_billing_dunning` table, the `dunningSchedule` option, and `recordPaymentFailure` / `recordPaymentSuccess` / `runDunningCycle`. Stripe Smart Retries runs the schedule and reports the outcome as a subscription status; our fixed `[1,3,5,7]`-day machine could only ever drift from it. Three defects the SDK's types surfaced immediately: the hand-rolled client sent no `Stripe-Version`, so it read `subscription.current_period_start/end` — a field Stripe has moved onto the subscription ITEM — and stored `null`, which silently disabled every period-dependent behaviour. Seat quantity was read from `items.data[0]` only, undercounting the common base-plan-plus-seat-item layout. And a subscription created in the Stripe Dashboard was dropped entirely for lacking our own `metadata.plan`; it now resolves through the price id. New: `billing.previewChange` (Stripe's invoice preview — quote this, never a local estimate), `billing.invoices` (hosted + PDF links), `startCheckout({ quantity })`, and `billingPlugin({ checkout: { adjustableQuantity, trialDays } })` so Stripe renders the seat stepper and runs trials. Checkout now enables Stripe automatic tax and promotion codes. Webhook signatures verify through `stripe.webhooks.constructEvent`; usage goes to Stripe Billing Meters with an `identifier`, whose 24h dedupe is what makes a replayed flush free. Set `STRIPE_WEBHOOK_SECRET` and a `priceId` per paid plan before upgrading, and reconcile existing subscriptions against Stripe first — see the codemod.
|
|
108
452
|
- **@voltro/protocol, @voltro/plugin-auth, @voltro/env, @voltro/cli** — The framework no longer ships a session secret, and no longer invents one. `VOLTRO_DEV_SESSION_SECRET` is removed from `@voltro/protocol/session` and `@voltro/plugin-auth`. It was a fixed string in the framework source applied automatically whenever `VOLTRO_SESSION_SECRET` was unset — so any deployment that reached it signed cookies with a value published in the source, and anyone could forge any user's session. The guard against it ran only when `NODE_ENV` was exactly `production`, which meant staging boxes and a bare `voltro serve` skipped it entirely. `resolveSessionSecret()` now throws when the variable is unset, and `assertProductionSessionSecret()` checks on every `voltro serve` boot regardless of `NODE_ENV`. Development stays zero-config by MINTING instead of sharing: declare a secret with the new `envVar.secret({ generate })` and `voltro dev` writes a unique per-project value into a gitignored `.env.local` on first boot. `generate` is opt-in per variable so third-party credentials (a WorkOS API key) still fail the boot gate rather than being invented. Templates therefore ship no secret values at all, and every template `.gitignore` now covers `.env` / `.env.local`. Also in this change: `AuthConfig.secret` is now optional on `authRoutesPlugin` / `voltroPasswordStrategy` — omit it and the key is resolved at request time, which removes the config-time `process.env` read that pushed apps into writing hardcoded fallbacks. The CLI's default session strategy now reads the cookie before resolving the secret, so an app without sessions no longer depends on one. Migration: see the codemod. In short — drop any import of the removed constant, review (do not blindly delete) hardcoded `?? '…'` fallbacks in case one is signing live sessions, stop passing `secret:` explicitly, and set a real `VOLTRO_SESSION_SECRET` in every deployed environment (`voltro secret generate session`).
|
|
109
453
|
- **@voltro/database, @voltro/cli** — A relation whose key options cannot resolve is now refused at boot, and `one` enforces its own cardinality at query time. An app declared `parent: one(() => teams, { foreignKey: 'parentTeamId' })` meaning "the parent whose id my column holds". `foreignKey` names a column on the TARGET; the source-side option is `sourceKey`. Nothing checked the column existed, so for that self-referencing table the walker emitted valid SQL — `WHERE parentTeamId IN (<parent ids>)` — which loads the CHILDREN. It typechecked, it booted, it matched a hand-written SQL join the author wrote to double-check, and it returned `null`. A row that HAD children would have resolved to the wrong row entirely; the reporter's test row was a leaf, which is why it read as "no parent" rather than as a bug. `validateRegisteredRelations()` now runs from the shared discovery both `voltro dev` and `voltro serve` use, rejecting a `sourceKey`/`foreignKey`/junction key that names no such column — and naming the option that was meant whenever the column exists on the other side. Passing both keys is refused too, because a resolvable `sourceKey` selects the source-side shape and `foreignKey` is then never read: the declaration would silently mean less than it says. Static validation provably cannot decide the SELF-REFERENCING case, where the column exists on both sides by definition. That half is caught by the `one` contract: the walker used to keep the last of several matching rows, so a mis-declared relation handed back an arbitrary child instead of a parent. It now fails, and when the relation is self-referential the message explains which side each option names. Same defect class as a `.one()` that returned the first of several rows, one layer down.
|
|
@@ -115,17 +459,53 @@ _Changes staged for the next release accumulate here (rolled up from
|
|
|
115
459
|
- **@voltro/client** — `useAction().run(input, options)` takes the same per-call `onSuccess` / `onError` / `notify` bag as `useMutation`'s `mutate`, including the load-bearing semantic: supplying an error handler marks the failure HANDLED, so `run` resolves with `undefined` instead of rejecting — which is what actually deletes the try/catch. With no options it rejects exactly as before, so unhandled failures stay loud. The asymmetry was arbitrary and expensive: roughly half of a real app's write sites are actions (sends, captures, invites), and they were the half that could not use the ergonomics, so they kept the try/catch. Documented alongside it: the callback form is for SINGLE-SHOT writes. A `for`-loop or a multi-step sequence relies on the throw to stop; once the failure is handled the promise resolves and the loop keeps going. Those want the bare `run(input)` and a real try/catch.
|
|
116
460
|
- **@voltro/protocol, @voltro/cli** — Boot now reports procedures whose `guards:` declare a per-resource check that nothing will enforce. `guards: [{ scope: 'teams:write', resource: (i) => i.teamId }]` reads as "may you write THIS team", but without a registered `setResourceScopeResolver` the extractor is advisory and the check runs against the caller's GLOBAL scopes — so an app whose authorization is per-resource (roles held on a membership row, subjects carrying no global scopes) gets a guard that passes callers it should refuse. The declaration looking stricter than the enforcement is the shape of every security bug that survives review. A production app hit exactly this, concluded the declarative path could not express per-team authorization, and reached for the far heavier policy machinery instead — nothing had told it the guard was being downgraded. A report rather than a refusal: a single-tenant app declaring `resource` for documentation value is not broken, and a boot that dies over an authorization nuance the app may enforce elsewhere would be its own kind of wrong. Silent is the one thing it must not be. Relationship guards are never flagged — they resolve through the tuple source and deny when unanswerable, so they cannot degrade to a global check, and flagging them would be the cry-wolf failure that gets a warning ignored. Wired into `voltro dev` and `voltro serve` through one shared helper.
|
|
117
461
|
- **@voltro/ui** — `<ConnectAccountFor api connectionId />` resolves the connection handle through `useConnection` itself, so the common case no longer requires wiring the hook by hand. The prop form stays for a custom connection source, a fixture, or a test. The absence of a bound variant was defended as "@voltro/ui is prop-driven", which is only half the rule. The kit's actual convention is that it does not depend on a PLUGIN — which is why `<PresenceAvatars>` takes a roster rather than calling `usePresence` from `@voltro/plugin-presence/web`. Depending on `@voltro/client` is routine and already the norm here: `<AgentChat>` calls `useAgentChat`, `<AsyncSelect>` calls `useQueryField`, `<AutoForm>` calls `useFormBinding`. `useConnection` is core, so the prop-only shape made this one component the exception rather than the rule. A separate component rather than an overload, because a hook cannot be called conditionally: one component calling `useConnection` only when `api` was present would break the rules of hooks the moment a caller switched between forms.
|
|
118
|
-
- **@voltro/runtime, @voltro/protocol, @voltro/cli, @voltro/client, @voltro/ui, @voltro/plugin-atlassian** — **Connections — a per-user credentials vault (`defineConnection`).** One declaration in a `*.connection.ts` yields the whole connected-accounts layer an app previously hand-rolled: an encrypted per-subject token store, the OAuth 2.0 authorize/callback pair (PKCE + single-use anti-CSRF state), refresh-before-use, a resolver for handlers and plugins, and a connect UI.
|
|
462
|
+
- **@voltro/runtime, @voltro/protocol, @voltro/cli, @voltro/client, @voltro/ui, @voltro/plugin-atlassian** — **Connections — a per-user credentials vault (`defineConnection`).** One declaration in a `*.connection.ts` yields the whole connected-accounts layer an app previously hand-rolled: an encrypted per-subject token store, the OAuth 2.0 authorize/callback pair (PKCE + single-use anti-CSRF state), refresh-before-use, a resolver for handlers and plugins, and a connect UI.
|
|
463
|
+
|
|
464
|
+
```ts
|
|
465
|
+
// api/connections/jira.connection.ts
|
|
466
|
+
export default defineConnection({
|
|
467
|
+
id: 'jira', kind: 'oauth2', label: 'Jira',
|
|
468
|
+
authorizeUrl: '…', tokenUrl: '…',
|
|
469
|
+
clientId: serverEnv.JIRA_CLIENT_ID, clientSecret: serverEnv.JIRA_CLIENT_SECRET,
|
|
470
|
+
scopes: ['read:jira-work', 'offline_access'],
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
// in any handler
|
|
474
|
+
const jira = await ctx.connections.get('jira') // the CALLING subject's, refreshed
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
- **Per-subject by construction.** Every vault read and write folds `subjectId` into its predicate, and the built-in procedures take the subject from the resolved request — their input schemas have no subject field at all. There is no code path that returns a credential given only a connection id. - **Encrypted at rest via the existing cipher.** Tokens go through `encryptField` (the same `FieldCipher` `.encrypted()` columns use). An app that declares a connection with no cipher configured **refuses to boot**, naming the connections — there is no plaintext fallback. - **Refresh before use, without stampeding.** Renewal happens 60s ahead of expiry, guarded by an in-process single-flight AND a compare-and-set lease on the row, so two replicas cannot both spend a rotating refresh token. A 4xx from the token endpoint is classified `revoked` (tokens cleared, re-consent needed); a 5xx/network failure is `error` (tokens retained, next use retries). - **Client + UI.** `useConnection` / `useConnections` (`@voltro/client`) and `<ConnectAccount>` (`@voltro/ui`). The wire shape carries status, account and expiry — never a token. - **Plugin injection.** `@voltro/plugin-atlassian/connection` exports `connectionCredentials({ connectionId, baseUrl })`, a drop-in `credentialsResolver` backed by the vault. The plugin's existing `credentialsResolver` option is unchanged and keeps working.
|
|
478
|
+
|
|
479
|
+
Two framework tables (`_voltro_connections`, `_voltro_connection_grants`) are created only when the app declares a connection; they ride the declarative differ like every other `_voltro_*` table. The callback endpoint (`GET /_voltro/connections/<id>/callback`) is mounted in both `voltro dev` and `voltro serve` from one shared builder.
|
|
119
480
|
- **@voltro/cli** — `voltro doctor` flags a handler that converts a `Date` by hand on the way out — `.getTime()` / `.toISOString()` inside a result mapping — and names `rowSchema(table)` in the descriptor's `output` instead. The advice states the part that was actually misunderstood: `output` IS the serializer (it is the rpc success schema), so a Date column declared as a Date crosses as epoch ms on its own, while declaring `Schema.Number` describes the WIRE type and leaves nothing to convert. Deliberately narrow: a lone `.getTime()` is ordinary arithmetic — a duration, a comparison — and only the mapping shape says "this is being shaped for the wire". A test pins that a duration calculation does NOT fire, because a rule that cries wolf is the one people stop reading.
|
|
120
481
|
- **@voltro/cli** — `voltro doctor --json` prints the complete hand-roll scan — every file path per finding, plus the scanned file count and directories — as machine-readable output with no preflight prose interleaved. The human view shows three paths per finding, which is fine as a summary and useless as a work list: those paths ARE the actionable part of a finding, and the matching rule lives inside the CLI, so nobody could re-derive the remainder with their own grep. A finding worth printing at all has a file list worth retrieving. The human view now also states how many paths it withheld and where to get them — a cap that does not announce itself reads as completeness. This is the opposite of the narrowing applied to `voltro capabilities`: that one cut which findings are reported (fewer false positives); this one stops hiding detail of findings already judged worth reporting. Mirrors `voltro capabilities --json`.
|
|
121
482
|
- **@voltro/testing** — `invoke(descriptor, executor, rawInput, ctx)` now enforces the descriptor's `guards:` against `ctx.request.subject` before decoding the input, rejecting with the typed `ScopeError` exactly as the server does. Scope guards, resource-scoped guards (through a registered `setResourceScopeResolver`), and relationship guards are all covered — the last FAIL CLOSED when no tuple source is registered, matching production, because a harness that quietly allowed them would train tests to pass on precisely the configuration that denies in prod. The previous version declined this and explained why: authorization was rpc middleware wired at dispatch in the CLI, not carried on the descriptor, so no util here could reach it without a layering inversion. That was true when written and became obsolete when `guards:` moved onto the descriptor and `checkGuardsEffect` landed in `@voltro/protocol`. The blocker dissolved; the comment outlived it, and an app read it as "the framework cannot test this". Guards run BEFORE the decode, mirroring the dispatch spine — an unauthorized caller must not be able to distinguish a malformed payload from a well-formed one. Still not covered, deliberately: transport concerns (connection info, rate limiting, the tenant header) are properties of the HTTP hop rather than the procedure, and a mutation invoked this way does not open a transaction.
|
|
122
483
|
- **@voltro/database** — `manyToMany` eager loads can return the junction's own columns: `with({ teams: { junction: ['role', 'addedAt'] } })` puts them under `_junction` on each target row, and `junction: true` takes all of them. A junction carrying meaningful columns — a role, a joined-at stamp, a permission tier — is the rule rather than the exception, and until now those columns could be FILTERED on (`onJunction`) but never returned, so any relation with real membership data had to stay a hand-written join. The junction rows were already being fetched in full to resolve the target ids; this stops discarding them. Nested rather than merged onto the target row: a junction and its target routinely share column names (`createdAt` is the obvious one) and merging would silently overwrite real target data with membership data. Each link gets its own clone, because one target row object is shared across every parent linking to it — attaching junction data in place would hand every parent the last writer's membership row. Without `junction` the shared-reference path is unchanged, so no existing m2m load pays for this. A branch requesting junction columns is served by the walker rather than the single-query JSON path. Compiling it would mean hand-writing a nested JSON object per dialect to save one round trip — four chances to get a dialect subtly wrong against a documented correctness reference that already works. Worth revisiting as an optimisation; not worth leading with.
|
|
123
484
|
- **@voltro/runtime, @voltro/cli** — The transactional outbox now keeps a queryable per-attempt delivery history, and can be resent on demand. `ctx.outbox` was a fire-and-deliver driver: `_voltro_outbox` holds one row per intent and mutates it in place, so it answers "is this still owed" but not "what did the remote say on attempt 3", "how long did it take", "who resent it" — the questions a delivery-history UI exists to answer. Apps were keeping their own per-attempt audit table alongside it. **`_voltro_outbox_attempts`** is that table, framework-owned. One append-only row per delivery attempt: `outboxId`, `effect` (denormalised so the history survives the entry's purge), `attempt` (1-indexed, monotonic across the entry's whole life), `trigger` (`automatic` | `manual`), `triggeredBy` / `reason`, `outcome` (`delivered` | `failed` | `dead`), `startedAt` / `finishedAt` / `durationMs`, `error`, `response`, and the entry's `subjectId` / `tenantId` / `traceId`. `response` is a JSON snapshot of whatever the handler RETURNED — that is how an HTTP-shaped handler records `{ status, body }` without the framework pretending to model HTTP. Reading it is a normal store read (`ctx.store.query({ table: '_voltro_outbox_attempts', … })`); no new query API was added because none is needed. Attempt recording is best-effort: a failed log write never fails the delivery it observes. **`ctx.outbox.resend(outboxId, { reason?, attempts? })`** re-arms one entry for immediate delivery, including one that already reached `dead`. Legitimate because delivery is at-least-once and handlers are already required to be idempotent — but never invisible: the resulting attempt is recorded as `trigger: 'manual'` with the requesting subject and reason, and the entry carries a `resendCount`. It re-arms the existing row rather than enqueuing a copy (a copy would carry the same `idempotencyKey` and split one entry's history across two ids), grants a small fresh budget (default 1 attempt, since a dead row has already spent its allowance), and refuses an entry whose attempt is in flight. **Retention** is bounded twice: a per-entry cap of 50 attempts trimmed on write (dialect-neutral; the automatic path can never reach it, so it bounds the repeatedly-resent entry) and a 30-day sweep over `startedAt` (`VOLTRO_OUTBOX_ATTEMPTS_TTL_HOURS`). The boot sweep now also ages out **delivered** `_voltro_outbox` rows (`VOLTRO_OUTBOX_TTL_HOURS`) — never `dead` or `pending` ones, which are an unresolved incident and an outstanding debt respectively. No codemod: `_voltro_outbox_attempts` and the three new `_voltro_outbox` columns ride the declarative differ, so `voltro db apply` / `voltro dev` boot reconcile them, and every existing call site keeps compiling.
|
|
124
485
|
- **@voltro/runtime, @voltro/cli** — Row-level security: `setRowFilter({ load, predicate })` registers a subject-derived predicate that is AND-merged into every read, so "tickets on teams I hold a role on" stops being a filter each list handler and each subscription must remember to write. Two halves on purpose. `load(subject)` is async and runs ONCE per request — read your membership tables there. `predicate(ctx, table)` is pure and sync and runs per read. A single `(subject, table) => Promise<Predicate>` would be simpler to declare and much worse to run: the obvious implementation re-queries memberships once per query in a handler that touches five tables. The filter narrows, never replaces, so it cannot grant access. It applies to BOTH read paths — `ctx.store.query(descriptor)` and the fluent `select(...)` builder — because a filter present on one of them is a detour rather than a boundary; a test asserts the fluent path specifically, and it caught that path being unwired during development. Fail-closed where it counts. A `load` that fails denies every constrained read instead of degrading to "no filter", because a row filter that evaporates under load failure is worse than none: the system keeps serving and nothing looks wrong. The cause is reported rather than swallowed. `.unscoped()` / `crossTenant` do NOT bypass it — those opt out of tenant ISOLATION for admin reads, and letting them drop row visibility too would convert an isolation escape into an authorization one. Only a `system` subject bypasses, because that is the framework acting as itself. Subscriptions re-resolve it before every delivery, derived from the descriptor WITHOUT the filter so successive deliveries cannot accumulate stale predicates and so visibility GAINED mid-subscription actually appears. Same reasoning as the per-delivery guard re-check: a membership can end while the socket stays open, and a filter frozen at subscribe keeps serving rows the subject has lost.
|
|
125
|
-
- **@voltro/database** — `rowSchema(table)` builds an effect/Schema for a table's rows with the WIRE representation already correct — `timestamp()` columns cross as epoch ms and arrive back as `Date`, `bigint()` crosses as a decimal string.
|
|
486
|
+
- **@voltro/database** — `rowSchema(table)` builds an effect/Schema for a table's rows with the WIRE representation already correct — `timestamp()` columns cross as epoch ms and arrive back as `Date`, `bigint()` crosses as a decimal string.
|
|
487
|
+
|
|
488
|
+
```ts
|
|
489
|
+
export const listNotes = defineQuery({ output: Schema.Array(rowSchema(notes)) })
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
This was reported as "the `output` schema is not used as a serializer", after an app wrote ~228 hand `Date → epoch` converters at its handler tails. That diagnosis was wrong and the verification is worth recording: `output` is passed straight to `Rpc.make` as `success`, so a handler's result IS encoded through it and decoded on the client. Declaring `Schema.Number` for a `timestamp()` column describes the WIRE type rather than the domain type, which leaves the schema nothing to convert and the handler doing it by hand.
|
|
493
|
+
|
|
494
|
+
What was actually missing is the schema worth declaring — nobody wants to work out the right Encoded/Type split for thirty columns per table. `bigint` crosses as a string rather than a number because a number silently rounds past 2^53. Columns whose shape the declaration does not pin down (`json()`, `vector()`, `raw()`) map to `Schema.Unknown` rather than a guess: a wrong schema rejects valid rows at the wire boundary and reports it far from the column responsible.
|
|
495
|
+
|
|
496
|
+
`omit` keeps a column off the wire. It is a convenience, not a security boundary — an omitted column is simply absent from THIS schema.
|
|
126
497
|
- **@voltro/testing** — `makeTestContext` now provides `ctx.outbox`, and `invoke` drains the handler's `afterCommit` callbacks after a mutation's transaction COMMITS — never after a rollback. This was previously listed as "not covered", alongside plugin interceptors and the deadlock replay. That was the wrong company for it: those two are serve entrypoint wiring a unit harness has no equivalent for, while this was a hole. `ctx.outbox.enqueue` schedules its delivery nudge through `afterCommit`, and the harness had neither the facade nor the hook — so the framework shipped a transactional-outbox primitive whose test story was "you cannot". The facade is the same `makeOutboxFacade` production builds, over the same store, so an enqueue inside a mutation is atomic with the domain write here too and a test exercises the real idempotency path rather than a stand-in. A test asserts that a throwing handler loses BOTH the row and the outbox entry. The ordering is the property worth having, not the presence: a drain that ran on the way out regardless would pass every naive test and be exactly wrong. Building it surfaced a real defect on the first run — the transactional context is derived, so callbacks queued inside the transaction landed in an array nobody drained. Derived contexts (the transaction, `withSubject`, `withTenant`) now share one collector, which is what production does by passing a single `afterCommit` into `buildContext`.
|
|
127
498
|
- **@voltro/testing** — `invoke()` now runs a MUTATION inside a real store transaction, matching the serve pipeline: everything the handler writes through `ctx.store` commits together, and a handler that throws leaves nothing written. This makes "the mutation failed, therefore nothing was written" a testable assertion — before, a half-applied mutation looked correct under test and only came apart in production, where `makeMutationRunner`'s `store.transactional(...)` is real. The kind comes off the descriptor (`kind: 'mutation'`), so nothing is declared at the call site. Queries and actions are deliberately NOT wrapped — actions run outside a transaction in production because their external I/O cannot be rolled back, and the harness reproduces that rather than being uniform. The rollback is the store's own transactional view discarding its overlay, not a copy the harness restores. Also adds `runInStoreTransaction(ctx, work)` for tests that want the same guarantee around a block of their own: `work` receives a full `TestContext` re-derived over the transaction — its store, loader, and `withSubject` / `withTenant` re-scopers all read and write through it, so a handler cannot escape the transaction mid-mutation.
|
|
128
|
-
- **@voltro/runtime** — `ctx.store.one(...)` / `.first(...)` / `.maybeOne(...)` return the ROW TYPE. They take the typed builder itself or its `.descriptor`, and the row type is inferred — no explicit type argument, no cast:
|
|
499
|
+
- **@voltro/runtime** — `ctx.store.one(...)` / `.first(...)` / `.maybeOne(...)` return the ROW TYPE. They take the typed builder itself or its `.descriptor`, and the row type is inferred — no explicit type argument, no cast:
|
|
500
|
+
|
|
501
|
+
```ts
|
|
502
|
+
const user = await ctx.store.one(database.users.where(eq('id', id)))
|
|
503
|
+
user.name // string
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
The two things we recommend did not compose. The typed read path lives on the typed builder, while the single-row terminals lived only on the string-keyed `select('users')` builder, which yields untyped `Row` — so adopting `.one()` re-introduced exactly the casts the typed path had just removed. You could have typed rows or the terminal, not both. That is not a last-mile gap for sophisticated apps: both shipped in the same release and we never tried composing our own two recommendations.
|
|
507
|
+
|
|
508
|
+
`one()` probes with LIMIT 2 and fails with `NoRowFound` on zero AND on two or more, matching the existing terminal. Scoping is inherited from `query()` rather than re-implemented — a second scoping path is how one of them quietly stops filtering by tenant.
|
|
129
509
|
|
|
130
510
|
### Fixed
|
|
131
511
|
|
|
@@ -151,7 +531,14 @@ _Changes staged for the next release accumulate here (rolled up from
|
|
|
151
531
|
### ⚠ BREAKING
|
|
152
532
|
|
|
153
533
|
- **@voltro/runtime** — `.one()` now fails when a query matches MORE than one row, not only when it matches none. It previously probed with `LIMIT 1`, so a filter that quietly stopped being unique returned an arbitrary row while the thrown error still claimed "expected exactly one row". It now probes with `LIMIT 2` and fails with `NoRowFound({ found: 2 })`. Migration: a `.one()` whose filter is not unique and that meant "any match" becomes `.first()` / `.maybeOne()`; one that meant "the unique row" stays as-is and now fails loudly when that assumption breaks. `NoRowFound` and `OptimisticLockError` are now `Schema.TaggedError`s, so they can be declared directly in a descriptor's `error:` union and caught with `Effect.catchTag`. Previously they carried a `_tag` field that looked declarable but did not typecheck there, forcing every caller to catch and re-wrap them in a hand-written tagged error — that wrapper can be deleted. Constructing one directly now takes an object: `new NoRowFound({ table, found })`, `new OptimisticLockError({ table, expected })`. `instanceof` and `_tag` checks are unaffected.
|
|
154
|
-
- **@voltro/database, @voltro/runtime** — `ctx.store.query()` now returns the row type instead of `Readonly<Record<string, unknown>>`. `QueryDescriptor<R>` carries a phantom row type, so the shape the typed builder already knew survives `.descriptor` into the store:
|
|
534
|
+
- **@voltro/database, @voltro/runtime** — `ctx.store.query()` now returns the row type instead of `Readonly<Record<string, unknown>>`. `QueryDescriptor<R>` carries a phantom row type, so the shape the typed builder already knew survives `.descriptor` into the store:
|
|
535
|
+
|
|
536
|
+
```ts
|
|
537
|
+
const rows = await ctx.store.query(database.notes.where(eq('id', id)).descriptor)
|
|
538
|
+
rows[0].title // string — previously `rows[0]['title'] as string`
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
The type was always available; it was dropped at exactly this boundary, which is why reading a field meant casting. One downstream app accumulated 2,032 of those casts against a surface that could have typed them all along. `EffectStore.query` is typed the same way, so an Effect-form handler keeps both the row type and the `StoreError` channel. The driver-level `DataStore.query` stays untyped deliberately. It is the SPI every dialect store and transactional view implements, and those genuinely do return untyped rows off the wire; the type is re-applied one layer up, at the handler-facing `FluentStore`. **Corrected after release — this entry originally appeared under `Added` with the claim that it "never types less than before, so it is additive: existing code keeps compiling." The premise is true and the conclusion does not follow.** Making a type more precise is source-breaking for every cast that previously widened from `unknown`: `rows[0]['meta'] as AppMeta` was `unknown → AppMeta` and always legal, while `rows[0].meta as AppMeta` is a conversion between two known types and fails with TS2352 when they do not overlap. One app upgrading from 0.3.0 hit 102 of these by hand. Migration (now shipped as the `0.5.0/02_typed-store-rows` manual codemod): delete the cast — most are simply redundant now; where the types genuinely differ, fix the column type rather than the call site (a `json<T>()` whose runtime shape is not `T` is a modelling bug the old `unknown` was hiding); reach for `as unknown as T` only when the divergence is real and intended. A hand-built descriptor still resolves to `Row`, so untyped call sites are unaffected.
|
|
155
542
|
|
|
156
543
|
### Added
|
|
157
544
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voltro/protocol",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.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.
|
|
56
|
+
"@voltro/database": "0.11.0",
|
|
57
57
|
"jose": "^6.2.3"
|
|
58
58
|
},
|
|
59
59
|
"peerDependencies": {
|