@voltro/cli 0.56.0 → 0.58.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 +395 -0
- package/dist/apiBuild-CDE4NXNi.js +2 -0
- package/dist/{apiBuild-Bdaetr37.js → apiBuild-CkpsCqIu.js} +2 -2
- package/dist/bin.js +1 -1
- package/dist/build-51qBWfYY.js +981 -0
- package/dist/{checkCommand-D0QV_zM_.js → checkCommand-C1EbNmwl.js} +1 -1
- package/dist/{checkCommand-CAwFXrxA.js → checkCommand-C30J2F18.js} +4 -4
- package/dist/{codegenCommand-CidbQzbv.js → codegenCommand-kS3Twuc9.js} +12 -12
- package/dist/{codemodRunner-BlQPfjzA.js → codemodRunner-BKjCwGiN.js} +1127 -895
- package/dist/{commands-CWjfThXv.js → commands-DowWkYtd.js} +25 -25
- package/dist/{dashboardCommand-BekcY5Ls.js → dashboardCommand-gG1y4PFG.js} +3 -3
- package/dist/{dataCommand-2pccgbIy.js → dataCommand-hNOGKz4K.js} +7 -7
- package/dist/{dbCommand-DpK_vQET.js → dbCommand-CMAIz-Bf.js} +3 -3
- package/dist/dbCommand-DHi_RuDl.js +2 -0
- package/dist/{dev-Dw263KPu.js → dev-Ce2gEziv.js} +2411 -2391
- package/dist/dev-Dff_pdik.js +3 -0
- package/dist/{doctorCommand-aR_bFmIi.js → doctorCommand-5ULMpL10.js} +652 -289
- package/dist/doctorCommand-B8H2MYCE.js +2 -0
- package/dist/{dormancyCommand-eXTQMbHU.js → dormancyCommand-DYgI0Kf-.js} +3 -3
- package/dist/{embeddingsCommand-CTmiQvwa.js → embeddingsCommand-yJivQSrI.js} +2 -2
- package/dist/emptyResultHeadline-Csa5fZOF.js +18 -0
- package/dist/{envCommand-BDUgV7EM.js → envCommand-ORJUf8r_.js} +2 -2
- package/dist/{evolveCommand-YV8qW1LU.js → evolveCommand-DTawtTxf.js} +2 -2
- package/dist/frameworkTableAssembly-B96WCNJA.js +2 -0
- package/dist/{frameworkTableAssembly-DNOFXfEQ.js → frameworkTableAssembly-vfkzuzEo.js} +104 -85
- package/dist/index.js +1 -1
- package/dist/{infoCommand-BjVXpMlP.js → infoCommand-BFksHunl.js} +1 -1
- package/dist/inspect-CZvsmp8t.js +2 -0
- package/dist/{inspect-CNYvNXPU.js → inspect-PcC5J-Dl.js} +74 -59
- package/dist/{logsCmd-BU8uCdys.js → logsCmd-C5y9HyrG.js} +58 -53
- package/dist/manifestBuild-AUh31bFU.js +2 -0
- package/dist/{manifestBuild-CEkjfpwc.js → manifestBuild-BwiS4iYm.js} +1 -1
- package/dist/{migrate-SICulyz1.js → migrate-DtC3lu7H.js} +4 -4
- package/dist/{probeCommand-6HxEkNDG.js → probeCommand-BUYzc0CT.js} +1 -1
- package/dist/{runtimeTrace-DgYMc09E.js → runtimeTrace-BUk5zMXY.js} +1 -1
- package/dist/{sdkgen-PY-umd6O.js → sdkgen-CAhmmbjx.js} +1 -1
- package/dist/serveCommand-BUXUtSF5.js +2 -0
- package/dist/serveCommand-DhtC7WBq.js +2545 -0
- package/dist/serveEntry.js +1 -1
- package/dist/{start-BFQQkL1i.js → start-C75w6ftA.js} +1 -1
- package/dist/{start-B9NGB8gn.js → start-DNtIlBcn.js} +527 -502
- package/dist/startEntry.js +1 -1
- package/dist/{tracesCmd-BWYDqMy6.js → tracesCmd-C1qKa94g.js} +68 -63
- package/dist/{updateCommand-C9n_Z_oG.js → updateCommand-CtuD5r1t.js} +1 -1
- package/dist/updateCommand-eVKVHj3N.js +2 -0
- package/dist/webDev-Bzb8ZFus.js +2 -0
- package/dist/{webDev-C53hJdcL.js → webDev-CBN9KkyQ.js} +949 -834
- package/dist/{webhooksCommand-uuPu8qQX.js → webhooksCommand-BPJQOGTe.js} +2 -2
- package/package.json +61 -19
- package/templates/AGENTS.md +1 -1
- package/templates/agent-docs/_index.md +1 -1
- package/templates/agent-docs/_manifest.json +2 -2
- package/templates/agent-docs/ai.md +175 -1
- package/templates/agent-docs/authentication.md +68 -19
- package/templates/agent-docs/cli.md +48 -0
- package/templates/agent-docs/configuration.md +29 -4
- package/templates/agent-docs/data.md +30 -0
- package/templates/agent-docs/database/scaling.md +10 -1
- package/templates/agent-docs/database/transactions.md +48 -0
- package/templates/agent-docs/deployment.md +22 -0
- package/templates/agent-docs/observability.md +13 -2
- package/templates/agent-docs/plugins/audit.md +21 -5
- package/templates/agent-docs/plugins/auth-supabase.md +21 -0
- package/templates/agent-docs/plugins/billing.md +2 -0
- package/templates/agent-docs/plugins/sentry.md +91 -8
- package/templates/agent-docs/reference.md +1 -0
- package/templates/agent-docs/scheduling.md +23 -0
- package/templates/agent-docs/schema-driven-ui.md +110 -0
- package/templates/agent-docs/whats-new.md +103 -399
- package/templates/apps/api-ai/package.json +6 -6
- package/templates/apps/api-auth/package.json +8 -8
- package/templates/apps/api-backend/package.json +7 -7
- package/templates/apps/api-backend-deactivation/package.json +7 -7
- package/templates/apps/api-backend-mail/package.json +8 -8
- package/templates/apps/api-backend-mariadb/package.json +9 -9
- package/templates/apps/api-backend-sqlite/package.json +8 -8
- package/templates/apps/api-backend-storage/package.json +8 -8
- package/templates/apps/api-cms/package.json +9 -9
- package/templates/apps/api-collab/package.json +8 -8
- package/templates/apps/api-data-advanced/package.json +8 -8
- package/templates/apps/api-durable/package.json +8 -8
- package/templates/apps/api-feature-flags/package.json +9 -9
- package/templates/apps/api-governance/package.json +8 -8
- package/templates/apps/api-kv/package.json +8 -8
- package/templates/apps/api-moderation/package.json +8 -8
- package/templates/apps/api-observability/package.json +8 -8
- package/templates/apps/api-ratelimit/package.json +8 -8
- package/templates/apps/api-rbac/package.json +8 -8
- package/templates/apps/api-rest/package.json +7 -7
- package/templates/apps/api-row-history/package.json +8 -8
- package/templates/apps/api-saas/package.json +11 -11
- package/templates/apps/api-saas-starter/package.json +10 -10
- package/templates/apps/api-search/package.json +8 -8
- package/templates/apps/api-status/package.json +8 -8
- package/templates/apps/api-webhooks/package.json +9 -9
- package/templates/apps/changelog/package.json +7 -7
- package/templates/apps/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/package.json +7 -7
- package/templates/apps/frontend-app/package.json +8 -8
- package/templates/apps/frontend-auth/package.json +7 -7
- package/templates/apps/frontend-blank/package.json +6 -6
- package/templates/apps/frontend-cms/package.json +8 -8
- package/templates/apps/frontend-collab/package.json +9 -9
- package/templates/apps/frontend-contact/package.json +7 -7
- package/templates/apps/frontend-dashboard/package.json +6 -6
- package/templates/apps/frontend-docs/package.json +8 -8
- package/templates/apps/frontend-i18n/package.json +6 -6
- package/templates/apps/frontend-landing/package.json +7 -7
- package/templates/apps/frontend-portal/package.json +7 -7
- package/templates/apps/frontend-saas/package.json +7 -7
- package/templates/apps/frontend-spa/package.json +6 -6
- package/templates/apps/frontend-ssr/package.json +6 -6
- package/templates/apps/frontend-ssr-api/package.json +7 -7
- package/templates/apps/frontend-static-blog/package.json +8 -8
- package/templates/apps/frontend-status/package.json +7 -7
- package/templates/apps/mobile-app/package.json +4 -4
- package/dist/apiBuild-Vw1figjO.js +0 -2
- package/dist/build-CI36wL4R.js +0 -868
- package/dist/dbCommand-DrycGWWt.js +0 -2
- package/dist/dev-B9Gz0k85.js +0 -3
- package/dist/doctorCommand-BMWs6aVm.js +0 -2
- package/dist/frameworkTableAssembly-CGNC0qr7.js +0 -2
- package/dist/inspect-S2rWy1Ys.js +0 -2
- package/dist/manifestBuild-DIa_s6u0.js +0 -2
- package/dist/serveCommand-BITS8Hpj.js +0 -2
- package/dist/serveCommand-DIJ3ma76.js +0 -2485
- package/dist/updateCommand-DsXEAHbd.js +0 -2
- package/dist/webDev-C2dRz9s5.js +0 -2
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# What's new in 0.
|
|
1
|
+
# What's new in 0.58.0
|
|
2
2
|
|
|
3
3
|
Read this FIRST when a task touches an area you have not worked in recently.
|
|
4
4
|
It is the cheapest way to notice that the framework grew the thing you were
|
|
@@ -9,493 +9,197 @@ BREAKING entries name a codemod; run `voltro update` to apply it.
|
|
|
9
9
|
|
|
10
10
|
### ⚠ BREAKING
|
|
11
11
|
|
|
12
|
-
- **@voltro/
|
|
12
|
+
- **@voltro/ai, @voltro/client** — The gateway catalog names eight modalities, `ModelModality` has listed all eight since the union was widened, and the runtime could execute five. Four of the six gaps are closed.
|
|
13
13
|
|
|
14
|
-
**
|
|
14
|
+
**The catalog gives out what the response carries.** `getAvailableModels` asked `@ai-sdk/gateway`'s provider, whose entry type has six fields where the HTTP response has twenty-one — so nothing was discarded, the fifteen never arrived. `readGatewayCatalog()` reads the endpoint and adds `capabilities`, `modalities`, `contextWindow`, `maxOutputTokens`, `releasedAt`, `knowledgeCutoff` and `dataPolicy`.
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
`capabilities` is unioned from `tags`, `supported_parameters` and `modalities`, which overlap and regularly disagree: a model tagged `reasoning` does not always carry the parameter. Taking either alone produces a false negative, and a false "cannot" removes a model from a picker with no way to find out why — so the union is framework knowledge rather than forty lines every app writes differently. `capabilities` and `modalities` stay OPTIONAL: absent means the entry came from the injected test provider, and defaulting to all-false would turn "not known" into "this model has no vision".
|
|
17
17
|
|
|
18
|
-
**
|
|
18
|
+
**The runtime says what it can run.** `executableModalities()` / `canExecute()` replace the hand-kept list an app needs to stop a picker offering a model nothing can execute. It is tied to the exports that justify each entry, so shipping a primitive without adding it turns a test red — which it did, twice, during this change.
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
**`transcribe` exists.** Nine transcription models were unaddressable without pulling the raw SDK past the framework (and resolving gateway credentials twice in one process). `source` takes `{ storageRef }`, `{ url }` or `{ bytes }`, because audio is large and usually already stored — the storage read is INJECTED (`resolveRef`), so `@voltro/ai` never learns about buckets. `segments` is absent rather than `[]` when the provider returned none.
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
- **@voltro/runtime, @voltro/cli** — **`defineReaction`'s `dedupeKey` was enforced by a per-process set, so the same change acted once per replica.**
|
|
22
|
+
**`rerank` reaches the gateway.** It was the one primitive that did not take a `ProviderConfig`: it wanted the vendors' own keys and could not express the gateway at all, so five reranking models in the catalog were unreachable.
|
|
24
23
|
|
|
25
|
-
|
|
24
|
+
**A speech provider the gateway does not carry can be registered.** The gateway serves nine speech models; a real voice catalog runs to hundreds behind vendors it does not carry, so an app with its own adapter called its own synthesizer and skipped the primitive — losing the shared error type and cost path. `registerSpeechProvider` gives that adapter a socket. No adapters ship: the framework is not in the business of tracking somebody else's REST API, and the app already has them.
|
|
26
25
|
|
|
27
|
-
|
|
26
|
+
Not built: `realtime`. Not for want of a model surface — the gateway exposes one — but because every streaming kind in Voltro is server→client, so relaying a duplex session over the existing authenticated connection needs a transport capability that does not exist. `canExecute` answers `false` for it, which keeps it out of a picker until it does.
|
|
28
27
|
|
|
29
|
-
|
|
28
|
+
Migration: `rerank`'s `provider` widened from `'mock' | 'cohere' | 'voyage'` to include `ProviderConfig`. Passing a string is unchanged; reading the field off a typed options object is now a union. See the codemod note.
|
|
30
29
|
|
|
31
|
-
|
|
30
|
+
**And the ergonomy over them, because a primitive nobody can reach is half a feature.** `transcribeStep` puts transcription on the durable-step path with `offload: true` — the modality that runs longest was the one without it, and a ninety-minute recording held a worker for the whole call. The queue row carries the source, never the bytes. `retrieveReranked` is the second retrieval stage declared once instead of the twenty lines every app writes (fetch N, reorder, keep K — with its own N, its own K, and half of them forgetting the cost). `useTranscription` makes upload and transcription ONE state: `progress` is scoped to the phase, because a single number across both reaches 1 when the upload finishes and sits there for the whole transcription, and a bar that says finished is worse than no bar.
|
|
32
31
|
|
|
33
|
-
|
|
34
|
-
- **@voltro/
|
|
32
|
+
**`voltro update` carries you across this** — codemod `0.58.0/07_rerank_takes_a_provider_config`. If you pin versions by hand and never run it, print the notes without changing anything: `voltro update --codemods-only --from <your current version> --dry-run` (this one ships in 0.58.0).
|
|
33
|
+
- **@voltro/protocol, @voltro/plugin-auth-supabase, @voltro/cli, @voltro/voltro** — An auth strategy's shared secret may be a function, so `voltro build` no longer needs the app's production credentials.
|
|
35
34
|
|
|
36
|
-
`
|
|
35
|
+
`voltro build` imports `app.config.ts`, so anything the config constructs runs at build time. `supabaseStrategy({ jwtSecret: process.env.SUPABASE_JWT_SECRET })` therefore made an image build need a production secret for a bundle that will not contain one. A placeholder is not available — this framework ships no secret values anywhere, and an `ARG SUPABASE_JWT_SECRET` in a Dockerfile is exactly that — so a deployment stayed on the slower tsx path instead.
|
|
37
36
|
|
|
38
|
-
|
|
37
|
+
`jwtSecret` now accepts `string | (() => string | undefined)`, on `jwtBearerStrategy` and on the Supabase strategy that specialises it.
|
|
39
38
|
|
|
40
|
-
**
|
|
39
|
+
**The deferral is paired, and that pairing is the design.** A thunk alone would trade an image-build inconvenience for a runtime surprise: the failure of a MISSING secret would move from boot to the first request that happens to carry a token, and an auth strategy that cannot verify one accepts nobody — a process that started cleanly, serving a login that can never succeed. "Fail at boot" is what the env gate, the plugin env contracts and the migration guard all promise, and this is not the place to sell it.
|
|
41
40
|
|
|
42
|
-
|
|
41
|
+
So `AuthStrategy` gained an optional `verifyConfig()`, and the pass over the strategies that `voltro dev` and `voltro serve` already share calls it. A build never reaches that builder. Three moments, three answers: construction is silent, boot decides, verification uses the settled value — resolved once, because a credential re-read per request is a `process.env` hit on the hot path and a value that could change under a running process.
|
|
43
42
|
|
|
44
|
-
|
|
43
|
+
A thunk counts as PRESENT at construction: whether it resolves is a boot question, and calling it early to find out would undo the whole point. Passing no credential at all still refuses at construction, unchanged.
|
|
45
44
|
|
|
46
|
-
|
|
47
|
-
- **@voltro/cli, @voltro/devtools-ui** — **Every `/_voltro/inspect/*` answer is now an `Observation` — it says what it is about, who answered, and how complete it is.**
|
|
45
|
+
Migration: passing a value is unchanged; what can stop compiling is READING `jwtSecret` off a typed config, which is now the union. That is the mirror of "more precise is still breaking" — less precise breaks readers — and the population is small, because the interface is one users construct and a literal's own inferred type never widened. `@voltro/voltro` is listed because it re-exports the type.
|
|
48
46
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
"data": { "…": "what the route returned before" },
|
|
52
|
-
"scope": { "kind": "process" },
|
|
53
|
-
"origin": { "replicaId": "api-7d9f-x2k", "instanceId": "api-7d9f-x2k@1787…", "version": "0.56.0" },
|
|
54
|
-
"completeness": { "complete": false, "fleetSize": 3, "reason": "process-scoped: this is 1 of 3 replicas" },
|
|
55
|
-
"capturedAt": 1787892497073
|
|
56
|
-
}
|
|
57
|
-
```
|
|
58
|
-
|
|
59
|
-
**Why the payload alone was not enough.** `/subscriptions` answers with the subscriptions of the ONE process that received the request. `/schedules` answers with the whole fleet's, read from the shared store. Both were plain JSON with nothing to tell them apart, so on a multi-replica deployment the first is an unlabelled sample and reads exactly like the second. On ONE replica the difference is invisible — which is every development environment, every e2e and every template, so the environment in which they are indistinguishable is the one the framework is built and tested in.
|
|
60
|
-
|
|
61
|
-
Four scopes, and the fourth is the one an outside report would not have asked for: `process`, `shared-store`, `fleet`, and `declaration` — `/routes`, `/manifest`, `/env` describe the SOURCE TREE, identical on every replica of one version and different across a rolling deploy.
|
|
47
|
+
**`voltro update` carries you across this** — codemod `0.58.0/06_jwt_secret_may_be_a_function`. If you pin versions by hand and never run it, print the notes without changing anything: `voltro update --codemods-only --from <your current version> --dry-run` (this one ships in 0.58.0).
|
|
48
|
+
- **@voltro/runtime, @voltro/cli** — A fleet view no longer counts rows left behind by processes that are gone, and the rows of live replicas actually refresh on MariaDB.
|
|
62
49
|
|
|
63
|
-
|
|
50
|
+
Three symptoms, one root cause and two that outlived it.
|
|
64
51
|
|
|
65
|
-
**
|
|
52
|
+
**The rows were frozen.** `writeReplicaObservation` upserts on `UNIQUE (replicaId, kind)` — a conflict key that is not the primary key, with a fresh `id` on every write — which is exactly the shape the MySQL/MariaDB upsert guard was rejecting. Every republish after the first was rolled back, so a publisher writing every 30s produced a row whose age grew second for second.
|
|
66
53
|
|
|
67
|
-
**
|
|
54
|
+
**The failure said nothing.** It was logged at `debug`, on the reasoning that a failed write leaves the previous row and the reader reports it as stale, so the failure is visible in the answer. It is not: a stale row is what a slow, busy or departed replica looks like, and the answer never says a write was REFUSED. It was also invisible where it mattered — `voltro dev` prints debug and `voltro serve` does not, so the unattended deployment got no line at all. The first failure per kind now warns.
|
|
68
55
|
|
|
69
|
-
**
|
|
56
|
+
**Nothing ever removed a row.** The table is keyed `(replicaId, kind)`, so a restart overwrites its own row and a replica that never returns leaves one forever — counted as a responder, counted in the version tally, and old enough to hold `complete` at false for the life of the deployment. Membership is the roster of who is alive, so a row whose replica it no longer lists is now reported as `departed`: separately from `stale`, because a stale row belongs to a process that is running and has stopped refreshing (a fault) and a departed one belongs to a process that is gone (a scale-down). It is reported rather than dropped — a reader who cannot see them cannot tell a fleet that scaled down from a table being written by something nobody is tracking. The publisher also forgets its own rows on shutdown, so the ordinary case leaves nothing to classify.
|
|
70
57
|
|
|
71
|
-
|
|
58
|
+
The sharpest consequence of the old behaviour was not the incompleteness: `versions` counted the framework version of a process that had been dead for hours and reported a version SPLIT across a fleet that had none — a line an operator acts on.
|
|
72
59
|
|
|
73
|
-
|
|
74
|
-
- **@voltro/cli** — **`voltro serve` on a WEB app now refuses. It used to delete the deployment artefact.**
|
|
60
|
+
Migration: `FleetMergeResult.departed` is required, so code that CONSTRUCTS one — a test double for `fleetObservations` — needs the field. Reading one is unaffected. See the codemod note.
|
|
75
61
|
|
|
76
|
-
|
|
62
|
+
**`voltro update` carries you across this** — codemod `0.58.0/03_fleet_view_reports_departed_replicas`. If you pin versions by hand and never run it, print the notes without changing anything: `voltro update --codemods-only --from <your current version> --dry-run` (this one ships in 0.58.0).
|
|
63
|
+
- **@voltro/client, @voltro/web** — An rpc call that fails in the client now reaches the app's error REPORTERS, not only its rpc listeners.
|
|
77
64
|
|
|
78
|
-
|
|
65
|
+
There were two buses. `clientErrorBus` is the module-global seam a reporter subscribes to — the route ErrorBoundary publishes render and loader failures there, `reportClientError` publishes manual captures, and the Sentry browser integration listens. `RpcErrorBus` is a per-runtime bus with a different subscriber set, for cross-cutting POLICY: redirect on `Unauthenticated`, toast on a network failure. Nothing connected them, so a call that failed in the client reached policy and never reached reporting.
|
|
79
66
|
|
|
80
|
-
|
|
81
|
-
[voltro] FATAL: production `voltro serve` requires a precompiled serve bundle at
|
|
82
|
-
…/.framework/dist-api/serveBundle/serveEntry.js
|
|
83
|
-
Run `voltro build` before serving
|
|
84
|
-
```
|
|
67
|
+
The subscription case is the one that costs. A rejected subscription leaves `data: undefined`, `loading: false` and an empty list — which is also what "nothing matched" looks like — so there is nothing in the UI to notice and nothing in any reporter to find.
|
|
85
68
|
|
|
86
|
-
|
|
69
|
+
The bridge is in `RpcErrorBus.emit`, not at the six call sites that emit rpc errors, so a seventh is covered by existing. `ClientErrorEvent.source` gained `rpc.mutation` / `rpc.action` / `rpc.subscription` as three separate labels rather than one `rpc`: a failed mutation is a write the user watched fail, and a failed subscription is a screen that silently never filled. The rpc tag and the `traceId` — the same id the server logged — travel in `context`.
|
|
87
70
|
|
|
88
|
-
|
|
71
|
+
Everything is published. A reporter that must not be flooded by a reconnect storm applies its own ceiling: the bus's job is to make the failure observable, and which failures are worth an event is a policy it cannot hold for every subscriber.
|
|
89
72
|
|
|
90
|
-
|
|
73
|
+
Migration: only an EXHAUSTIVE switch over `event.source` stops compiling. If you bridged the two buses by hand, delete that bridge or you will report twice — and keep whatever throttling you put around it.
|
|
91
74
|
|
|
92
|
-
|
|
75
|
+
`@voltro/web` is listed because it re-exports the type: the union widens in its golden too, and a consumer importing `ClientErrorEvent` from there is affected identically.
|
|
93
76
|
|
|
94
|
-
|
|
77
|
+
**`voltro update` carries you across this** — codemod `0.58.0/04_rpc_errors_reach_reporters`. If you pin versions by hand and never run it, print the notes without changing anything: `voltro update --codemods-only --from <your current version> --dry-run` (this one ships in 0.58.0).
|
|
78
|
+
- **@voltro/plugin-sentry, @voltro/cli** — Sentry performance traces are on by default, and the web entry no longer needs a config block to initialise them.
|
|
95
79
|
|
|
96
|
-
|
|
97
|
-
- **@voltro/runtime, @voltro/cli** — **Every outbox effect was delivered once per replica.** `drainOutbox`'s summary line has always said "claim due rows". It did not: it read every `pending` row and ran its handler, and every replica runs the drain. Not on a crash, not on a retry — on the happy path, every time. Measured with two stores over one postgres: one enqueued row, one drain pass each, handler called twice.
|
|
80
|
+
`sentryPlugin({ traces })` defaulted to `false` while the browser half defaulted `tracesSampleRate: 1.0` with `browserTracing` on. That pairing is the worst of the three available defaults: every page load, navigation and rpc call became a browser span that was emitted, paid for, and belonged to a trace with no server half — a browser hop hanging off nothing. Both-off would at least be coherent; both-on is what the two halves were built for, and the docs already said to pass `traces: true` to get it, which made that sentence a workaround note rather than a configuration option.
|
|
98
81
|
|
|
99
|
-
The
|
|
82
|
+
The rate stays 1.0 rather than being quietly lowered — a number the framework picks on your behalf is one nobody can find again — and the boot line names it: `sentry active traces=true tracesSampleRate=1`. Lower it with `tracesSampleRate`, or `traces: false` for errors and breadcrumbs only.
|
|
100
83
|
|
|
101
|
-
|
|
84
|
+
The browser half is reached the same way. The generated entry called `initSentryBrowser` only when `app.config.ts` carried a `sentry:` block, which made that block load-bearing for something it does not decide: the api half needs no config object (`sentryPlugin()` reads `SENTRY_DSN`) and the browser half reads `VOLTRO_PUBLIC_SENTRY_DSN`, so an app that had set its variables still had to add an empty `sentry: {}`. The entry now initialises whenever `@voltro/plugin-sentry` is a dependency of the web app — the dependency is the intent — and the block is for overrides. Without a DSN from either source the init returns immediately, so this changes nothing for an app that set none.
|
|
102
85
|
|
|
103
|
-
|
|
86
|
+
Migration: decide once whether you want the transactions. See the codemod note.
|
|
104
87
|
|
|
105
|
-
`
|
|
106
|
-
|
|
107
|
-
**Breaking:** `DrainDeps.claimedBy` is REQUIRED. It was optional for one draft, which meant a caller who omitted it silently got the old every-replica behaviour — an exactly-once gate that switches off when you leave a field out is not a gate. `voltro dev` / `voltro serve` pass it for you; only a caller driving `drainOutbox` directly is affected. `claimLeaseMs` (default 5 min) is new and optional.
|
|
108
|
-
|
|
109
|
-
**`voltro update` carries you across this** — codemod `0.56.0/03_outbox_drain_needs_an_identity`. If you pin versions by hand and never run it, print the notes without changing anything: `voltro update --codemods-only --from <your current version> --dry-run` (this one ships in 0.56.0).
|
|
110
|
-
|
|
111
|
-
### Added
|
|
88
|
+
**`voltro update` carries you across this** — codemod `0.58.0/05_sentry_traces_on_by_default`. If you pin versions by hand and never run it, print the notes without changing anything: `voltro update --codemods-only --from <your current version> --dry-run` (this one ships in 0.58.0).
|
|
89
|
+
- **@voltro/runtime, @voltro/cli** — `SYSTEM_SUBJECT` — what a schedule, a subscriber, a resumed workflow and the webhook trigger context run as — is now a `system` subject. It was `{ type: 'anonymous', id: null }`, and every exemption for "the framework acting as itself" is keyed on `type === 'system'`: the row-filter bypass in `resolveRowFilterScopeFor`, the unfiltered-read refusal in `wrapStoreWithMixinBehaviour`, and `isSystemSubject`. It matched none of them.
|
|
112
90
|
|
|
113
|
-
-
|
|
91
|
+
An app that calls `setRowFilter` therefore had every schedule fire refused at context-build time, before its handler ran — on both boot paths, from the cron timer and from `voltro schedule run` alike. The quieter half is the one that produced no error: a path that RESOLVED a scope for this subject asked the app's own filter what an anonymous caller may see, and applied that answer to a cross-tenant sweep.
|
|
114
92
|
|
|
115
|
-
|
|
93
|
+
`ctx.storeForTenant(id)` is fixed alongside it, and needed saying separately. It narrows to a `serviceAccount` subject by design, so a subject whose null tenant means "every tenant" cannot widen back out of the tenant it was just confined to — which also means the exemption stops at the derivation. The row-filter bypass now travels across that narrowing; the tenant confinement does not.
|
|
116
94
|
|
|
117
|
-
|
|
95
|
+
Migration: `actingUserId` answers `null` for a system subject, so a `runAsSystem` write stamps no `createdBy` / `updatedBy` instead of `'system'`. `createdBy` is a reference into the app's own `actors` table and the framework seeds no row there, so an id it stamps is one nothing points at — an outright insert failure where that reference is declared. Schedules, subscribers and workflows already stamped nothing and are unchanged. See the codemod note.
|
|
118
96
|
|
|
119
|
-
**
|
|
97
|
+
**`voltro update` carries you across this** — codemod `0.58.0/01_system_writes_stamp_no_actor`. If you pin versions by hand and never run it, print the notes without changing anything: `voltro update --codemods-only --from <your current version> --dry-run` (this one ships in 0.58.0).
|
|
98
|
+
- **@voltro/runtime, @voltro/cli** — A workflow step now reads through its caller's row filter, resolved at execution time — and until now a workflow started by a user could not read at all.
|
|
120
99
|
|
|
121
|
-
|
|
100
|
+
The store refuses a context that carries neither a resolved row-filter scope nor a system subject. A BOOTSTRAP run has the system subject and passed; a run started by a user has neither, so in any app that calls `setRowFilter` every workflow step threw before its executor ran. The gap was invisible from the framework's own suites because the arm that works and the arm that does not differ only in who started the run.
|
|
122
101
|
|
|
123
|
-
|
|
102
|
+
Visibility is resolved in the same seam as authority, at the same moment, for the same reason: `resolveAuthority` re-reads what a three-day-old run may DO, and the scope resolved beside it re-reads what that run may SEE. Restoring either from the start-context row would be a fact frozen at start time and made durable. A failure to resolve fails the attempt rather than downgrading it — a run that silently reads fewer rows than it should is indistinguishable from one whose data is not there. An app that registered no filter pays nothing; the resolution short-circuits without touching a store.
|
|
124
103
|
|
|
125
|
-
|
|
104
|
+
`BuildAppContext`'s request parameter now declares `rowFilter`. It was read by the implementation and absent from the type, so the request arms were correct by the accident of passing a value the signature did not mention, while a non-request caller had no way to see there was anything to supply.
|
|
126
105
|
|
|
127
|
-
`
|
|
106
|
+
`ResolvedWorkflowCallerContext.rowFilter` is required, so code that CONSTRUCTS one — in practice a test double for a context builder — needs the field. Implementing `buildContext` is unaffected: it receives one more property it may ignore. Required rather than optional is deliberate: optional is what it effectively was, and it left a boot path able to omit the one value without which a user's workflow cannot run.
|
|
128
107
|
|
|
129
|
-
|
|
108
|
+
**`voltro update` carries you across this** — codemod `0.58.0/02_workflow_caller_context_carries_a_scope`. If you pin versions by hand and never run it, print the notes without changing anything: `voltro update --codemods-only --from <your current version> --dry-run` (this one ships in 0.58.0).
|
|
130
109
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
That test found two defects the single-process one could not:
|
|
110
|
+
### Added
|
|
134
111
|
|
|
135
|
-
|
|
136
|
-
- **@voltro/web, @voltro/client, @voltro/cli** — **`web.api.connect: 'lazy'` — a page that reads no data no longer opens a WebSocket.**
|
|
112
|
+
- **@voltro/cli** — `voltro doctor`'s `nullable-column-a-mutation-cannot-clear` rule now resolves FRAMEWORK and first-party PLUGIN tables.
|
|
137
113
|
|
|
138
|
-
The
|
|
114
|
+
The rule asks "is this column nullable?" by finding the `table('<name>', { … })` declaration among the sources it was handed — the app's. That is right for the app's own tables and cannot work for ours: `_voltro_webhook_targets` is declared inside `@voltro/plugin-webhooks`, so a mutation writing to it resolved to "I could not look". The rule keeps that answer distinct from "this table has no nullable columns", because the two lead to opposite conclusions — but for our tables it was a permanent unknown, leaving anyone auditing their mutations to carry them as a standing exception and count them by hand.
|
|
139
115
|
|
|
140
|
-
|
|
116
|
+
Doctor is a static analyser: it parses files and boots nothing, so it cannot import a plugin to ask a table about itself. The nullability of our own tables is therefore generated from our declarations — with the same extraction rule the doctor applies, so a manifest entry cannot mean something different from what the rule would have concluded — and shipped as data. `scripts/gen-framework-table-nullability.mjs --check` runs in CI, because a stale entry would answer with a value that is no longer declared, which is worse than the honest unknown it replaces.
|
|
141
117
|
|
|
142
|
-
|
|
118
|
+
An app's own declaration still wins where both have one: it is the more specific authority and the one a developer can actually edit.
|
|
119
|
+
- **@voltro/plugin-sentry, @voltro/cli** — The browser half of the Sentry integration can now resolve its own configuration — from the public env, and from a module that runs at boot.
|
|
143
120
|
|
|
144
|
-
|
|
121
|
+
**It reads `VOLTRO_PUBLIC_SENTRY_DSN` / `_ENVIRONMENT` / `_RELEASE`.** The api half already resolved `SENTRY_DSN`, `SENTRY_ENVIRONMENT` and `SENTRY_RELEASE` from the environment and declared all three, so `sentryPlugin()` takes no arguments. `initSentryBrowser` resolved nothing: without an explicit `dsn` it returned, and the only way to supply one was `sentry:` in `app.config.ts`. An explicit option still wins — `app.config.ts` is the more specific authority.
|
|
145
122
|
|
|
146
|
-
|
|
123
|
+
**`sentry.optionsFrom` names a module the generated entry calls.** Everything else in that block is a literal frozen into the bundle, which is right for a DSN and wrong for `environment` the moment one image serves more than one environment — an ordinary pipeline with one build job and several environment-bound deploy jobs. A baked `environment` is then true for at most one of them, and a wrong environment tag is worse than an absent one because Sentry defaults a missing one to `production` and a wrong tag gets acted on.
|
|
147
124
|
|
|
148
|
-
|
|
125
|
+
There is no runtime channel to read instead: public values are baked at build time by construction. So the value has to be computed where it is known, which is the browser — and the entry is generated anyway, so it can call something. The resolver's result is spread LAST and may be async.
|
|
149
126
|
|
|
150
|
-
|
|
151
|
-
- **@voltro/
|
|
127
|
+
**And `voltro start` now says when a `VOLTRO_PUBLIC_*` cannot reach the browser.** Setting one as a deployment variable looks right, deploys cleanly, and does nothing — no error, no warning, no failed request. The check is exact rather than a guess, because both halves are present at boot: the process environment, and the object the build wrote (`.framework/env.public.json`, emitted alongside the baked module from the same source). A variable set to the value that was baked stays silent; one that was never baked, or that disagrees with the built one, is named with the reason. An unreadable manifest reports every runtime variable rather than none — "we could not check" and "there is nothing to report" are opposite facts.
|
|
128
|
+
- **@voltro/cli, @voltro/plugin-sentry** — `voltro build` uploads source maps to Sentry and then removes them from the output.
|
|
152
129
|
|
|
153
|
-
|
|
130
|
+
`web.sourcemaps: 'hidden'` already produced `.map` files, and the note beside it said to upload them in a deploy step and delete them before the image is built. Every word of that was right and all three hard parts belong to the build:
|
|
154
131
|
|
|
155
|
-
|
|
132
|
+
- **the moment** — there is no seam in a Dockerfile between "the bundle exists" and "the image is built"; - **the release** — Sentry matches an artifact to an event by release, and the event's comes from `SENTRY_RELEASE` via `sentryPlugin`. Upload under a different value and no frame resolves, silently: an upload that matched nothing looks exactly like one that worked. A configured upload with no release now refuses the build rather than uploading under nothing; - **the deletion** — a `.map` left in `dist` is the app's source, downloadable by anyone, and "we delete it in the deploy step" is a promise a failing build breaks. It is a `finally` here: the maps go even when the upload fails, and a configured upload that did not happen fails the build. Verified against a real build — a failed upload left zero `.map` files and all five chunks.
|
|
156
133
|
|
|
157
134
|
```ts
|
|
158
|
-
|
|
159
|
-
table: 'absence_requests',
|
|
160
|
-
on: ['insert'],
|
|
161
|
-
once: (event) => String((event.new as { id?: string } | null)?.id ?? ''),
|
|
162
|
-
handler: notifyApprovers,
|
|
163
|
-
})
|
|
164
|
-
```
|
|
165
|
-
|
|
166
|
-
The key is claimed in the new `_voltro_change_claims` table — `insertIgnore` on a `UNIQUE(scope, key)`, the same INSERT-wins arbiter the cron scheduler uses for a (schedule, bucket) firing — and exactly one replica wins it. The scope is the subscriber's file id, so two subscribers watching one table never lock each other out.
|
|
167
|
-
|
|
168
|
-
Three things the type states rather than leaves to be discovered:
|
|
169
|
-
|
|
170
|
-
- **The key must tell two genuine changes apart.** A row id is enough for `insert` and `delete`. It is not enough for `update`: two edits to one row produce the same id, and the second is dropped as a duplicate. Use `` `${id}:${updatedAt}` ``. - **AT MOST once, not exactly once.** The claim is taken before the handler runs, so a replica that wins and dies takes the event with it. A claim that cannot be written at all is taken by nobody — fail-closed, because `once` promising "at most once" is what makes it worth having. Both are loud in the log; neither is retried. - **Which coordination each subscriber got is in the boot log**, because the two behaviours differ by a factor of the replica count and were otherwise indistinguishable:
|
|
171
|
-
|
|
172
|
-
```
|
|
173
|
-
subscriber: registered table=absence_requests on=["insert"] once=fleet
|
|
174
|
-
subscriber: registered table=posts on=["insert","update"] once=per-replica
|
|
135
|
+
web: { sourcemaps: { mode: 'hidden', upload: { org: 'acme', project: 'web' } } }
|
|
175
136
|
```
|
|
176
137
|
|
|
177
|
-
`
|
|
178
|
-
- **@voltro/cli** — **`voltro doctor` now asks the question a subscriber's author is the only one who can answer: should this effect repeat on every replica?**
|
|
179
|
-
|
|
180
|
-
`store.onChange` is a broadcast. That is CORRECT for a reader — a cache drop, an index refresh, a live query must run everywhere — and a multiplier for an effect, because there is nothing to make idempotent: the effect IS the write, so each run produces another one. One `INSERT` behind two replicas writes two notification rows; with a broadcast bus in front, four.
|
|
181
|
-
|
|
182
|
-
`subscriber-effect-without-once` fires when a `*.subscribe.ts` handler writes (`ctx.store.insert` / `update` / `upsert` / …), publishes (`ctx.publish`), or calls a `notify` / `sendWebhook` / `sendMail`-shaped helper, and the subscriber declares no `once:`. Advisory like every rule in that scan — it prints, it never fails a build, because `once:` on a READER would silence it on every replica but one, and only the handler's author knows which of the two they wrote.
|
|
183
|
-
|
|
184
|
-
It reads the handler through the AST, for two reasons at once. `once:` and the handler sit in ONE object literal, so "this file mentions `once`" is not the question. And the effect has to be found inside the handler under whatever name it gave its context parameter — `(event, ctx)`, `(event, { store })`, or a `handler: notifyApprovers` naming a function in the same file, which is the form the primitive's own doc comment teaches. A handler IMPORTED from another module is not judged: its body is not in the file being read, and a finding about code the rule never opened would be a guess wearing a file path.
|
|
185
|
-
|
|
186
|
-
**And `mutation-tail-effect` no longer fires on the file that took its advice.** That rule matches a `notify(...)`-shaped call in any file with an `export default` — which a `*.subscribe.ts` has. So a subscriber calling `notify(...)` was told to move its effect into a subscriber: our own two rules disagreeing on one file, which is how a whole section of the report gets skipped. Detected by the CALL (`defineSubscriber` / `defineReaction`) rather than the filename, for the reason the adoption check already exists — a file can adopt the primitive under any name.
|
|
187
|
-
- **@voltro/protocol, @voltro/database, @voltro/runtime, @voltro/cli** — **`?replica=<id>` — ask ONE named replica, through the one you can reach.** The answer comes back with that replica's `origin`, process-scoped: the proxy does not launder whose answer it is.
|
|
188
|
-
|
|
189
|
-
**The address comes from the shared store, not from membership** — and that is a correction to the obvious design. Membership rides the broadcast bus, so peer addressing built on it works only for an app that configured one, and the operator who most needs to reach a specific pod is not reliably the one who did. The row already exists, every replica already writes it, and the database is already the thing that must be up. `_voltro_replica_observations` gained a `reachableAt` column.
|
|
190
|
-
|
|
191
|
-
**An endpoint that fetches a URL on request is an SSRF primitive unless it is built not to be.** Four rules, each closing a distinct way this could become one, and each with a test:
|
|
192
|
-
|
|
193
|
-
- **The caller names an ID, never a URL.** The address is resolved from a set we wrote; an unknown id is a `404` and no request leaves the process. "Unknown replica" and "published no reachable address" give the same message, because distinguishing them would tell a caller which ids exist. - **A proxied request carries a hop header and is always answered locally**, so `?replica=a` on A pointing at B pointing at A cannot cycle. The forwarded URL also has `replica` stripped and `scope=process` forced. - **The peer call forwards the caller's token and adds nothing.** A replica is not a more privileged caller than the human who asked. - **Only reads are addressable.** `?replica=` on `/invoke`, `/seeds/run`, `/migrations/rollback` or `/agent/call` is refused before any lookup.
|
|
194
|
-
|
|
195
|
-
A peer that does not answer inside a short deadline becomes a `504` naming it: a diagnostic that hangs is worse than one that says no.
|
|
196
|
-
|
|
197
|
-
**A declared address and a fallen-back one are different facts.** An unset `POD_IP` falls back to `127.0.0.1`, which is a shrug — recorded as NOT reachable, and no peer tries it. `VOLTRO_INSPECT_ADVERTISE_HOST` declares one, including `127.0.0.1` when the peers really are on this machine. This is the same posture the framework takes everywhere: we do not second-guess a declaration, and we do not treat a fallback as one.
|
|
198
|
-
|
|
199
|
-
Measured between two real replicas (`scripts/fleet-observation-e2e.mjs`): A answers for B, B's identity survives the hop, an unknown id is refused with no outbound request, and a mutating endpoint is refused with `400`.
|
|
200
|
-
|
|
201
|
-
**And the consumer that nearly shipped broken.** Both dashboards unwrap the envelope in their single fetch helper; the CLI's `inspectFetch` — which thirty-odd subcommands read through — was missed. `voltro cluster status`, `voltro logs`, `voltro schedules` would each have read `undefined` off an envelope and printed an empty table. Found by asking what ELSE reads these routes, not by a failing test, which is why the seam now has one: the payload comes out, the envelope is kept so a command can say "1 of 3", an answer that predates the envelope passes through unchanged, and a payload that merely HAS a `data` key is not mistaken for one.
|
|
202
|
-
- **@voltro/cli** — **`/_voltro/inspect/stream` events carry `origin`.**
|
|
203
|
-
|
|
204
|
-
A live SSE stream is the same defect as an unlabelled `/logs` response, except it keeps producing it: a viewer watching a tail on a three-replica fleet sees one third of it, continuously, and the connection landed on whichever replica the load balancer chose.
|
|
205
|
-
|
|
206
|
-
On **every event**, not on a handshake — because a consumer that connects late never receives a handshake. A reconnect, a second browser tab, a `curl` piped into `jq` all read the buffer, and a handshake they never saw would leave every buffered line unattributed. That is the assertion the test pins.
|
|
207
|
-
|
|
208
|
-
`ts` is that replica's clock. Two origins must not be ordered by it, which is the same rule `events.origin` already states as "serials are only comparable within it".
|
|
209
|
-
- **@voltro/devtools-ui** — **A Fleet panel, in both dashboards.** `FleetPage` renders what every replica published about itself — the self-hosted DevTools wire it over HTTP, the hosted customer console over the cloud RPC proxy, from one shared component.
|
|
210
|
-
|
|
211
|
-
**It renders THREE populations, and the two after the first are the point.** A page that shows only the replicas which ANSWERED is worse than no page: the reader now believes they asked everybody. So the silent replicas get their own section rather than being omitted ("not shown" and "not there" look identical and mean opposite things), and the stale ones are shown WITH their age (filtering them makes a partial answer look complete; leaving them unmarked presents old numbers as current). A mixed-version note appears when the responders disagree, because a rolling deploy means the numbers span two shapes.
|
|
212
|
-
|
|
213
|
-
The completeness banner is a RATIO — "3 of 5 replicas answered" — not a count. A count invites the reader to believe that is the fleet.
|
|
214
|
-
|
|
215
|
-
**Two guards, one per dashboard, because neither repo can see the other.** Each fails when a page the shared package exports has no nav entry in that dashboard, with a declared exemption list whose stale entries also fail. The asymmetry is the argument: the self-hosted dashboard is opened daily and the customer one is opened when something is wrong, so a panel missing from the second fails in the direction nobody notices.
|
|
138
|
+
Selective by ordinary means: `app.config.ts` is TypeScript, so `sourcemaps: process.env.CI ? { … } : 'hidden'` emits-and-keeps locally and uploads in the pipeline. `keep` defaults to "keep them only if nothing consumed them" — false with an upload, true without.
|
|
216
139
|
|
|
217
|
-
**
|
|
140
|
+
**There is no `authToken` option**, on purpose. It is read from `SENTRY_AUTH_TOKEN`: `app.config.ts` is a committed file, and a token with `project:releases` scope can write to every project in the org. It belongs to the build, not the deployment.
|
|
218
141
|
|
|
219
|
-
The
|
|
220
|
-
- **@voltro/cli, @voltro/runtime** — **Four serving changes: pre-compressed assets, a CDN prefix, a keep-alive that survives a proxy, and a preconnect for a cross-origin api.**
|
|
142
|
+
The upload runs through `@sentry/cli` rather than a hand-written HTTP client, because Sentry's artifact model is debug-ID based — the ids are injected into the built JS and the map, and the matching happens on them. An uploader that skipped that injection would produce artifacts that upload cleanly and resolve nothing. **You install it: `pnpm add -D @sentry/cli` in the web app.** The framework does not depend on it anywhere, and that is a licence decision rather than a packaging one. `@sentry/cli` is FSL-1.1-MIT, which restricts competing commercial use — so unlike the MPL and LGPL dependencies we do carry, "the consumer's own install fetches it, and we neither distribute nor modify it" does not answer the question: FSL binds a user, not a distributor. An optional dependency of a package we publish is part of that package's licence surface, so it is not one.
|
|
221
143
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
| | time | bytes | |---|---:|---:| | compressed per request | 5.6 / 5.0 / 4.6 ms | 100 665 | | pre-compressed | 1.6 / 1.7 ms | **86 083** |
|
|
225
|
-
|
|
226
|
-
Faster and smaller at once, because a build can afford q11 where a per-request path cannot (the runtime uses q4 precisely so it never stalls a response). Only hashed assets get variants: a stale `.br` beside a changed original is a corrupted response rather than a slow one. The `ETag` is computed over the UNCOMPRESSED file and passed through, so brotli, gzip and identity share one tag — the invariant `httpResponseWrite.ts` states, which hashing the variant would have broken.
|
|
227
|
-
|
|
228
|
-
**`web.assetPrefix`.** Becomes vite's `base`, so every emitted URL is written with the prefix at build time. Without it an app with a single `ssr` route serves every byte of its bundle from the container — `voltro static`, the documented cost-offload, only applies to an app that is entirely static. Verified on a fixture: all ten asset URLs in the shell AND in every pre-rendered page carry the prefix.
|
|
229
|
-
|
|
230
|
-
**`http.keepAliveTimeoutMs`, default 72000.** Node hangs up an idle keep-alive connection after **5 seconds** — confirmed on a running server (`Keep-Alive: timeout=5`) — while nginx holds one for 75s and ALB/Envoy for 60s. The proxy then sends onto a socket the server is closing and answers 502. Rare per request, certain over time, and invisible in testing. `headersTimeout` is derived above it and a declared value at or below `keepAliveTimeout` is RAISED rather than applied: node measures it from connection start, so honouring it would destroy healthy connections. Applied on both serving surfaces from one resolver. Verified: `Keep-Alive: timeout=72`.
|
|
231
|
-
|
|
232
|
-
**`preconnect` for a cross-origin api.** When an api's `wsUrl` is on another host the shell carries `<link rel="preconnect" … crossorigin>`, so the handshake overlaps the bundle download instead of following it. Same-origin apis emit nothing — the browser already has that connection, and a redundant hint costs a wasted socket. `crossorigin` is required: without it the warmed connection is anonymous and the credentialed one the socket needs is a second handshake.
|
|
233
|
-
|
|
234
|
-
**`web.sourcemaps: 'hidden'`.** Emits `.map` files with no `//# sourceMappingURL` comment. `plugin-sentry` reads `SENTRY_RELEASE` explicitly "for release health AND source maps" and there was no way to produce any, so every production stack trace was minified — from an integration advertising the opposite. Off by default, and named for what it does rather than being a boolean.
|
|
235
|
-
|
|
236
|
-
**And `voltro start` now refuses to serve a `.map` at all**, whether or not one is on disk. The docs say to upload the maps and delete them before the image is built, and "say" is not a mechanism: a hidden map is undiscoverable but perfectly REACHABLE — its URL is the chunk's own name plus `.map`. One forgotten deploy step would publish the app's source. 404, not 403, because a 403 confirms the file exists. Red-verified: with the refusal removed, the same request returns **200 and the map's contents**.
|
|
237
|
-
- **@voltro/i18n, @voltro/cli** — **`<LocaleSwitcher>` now ships unstyled from `@voltro/i18n`, and `voltro doctor` reports the failure that made it necessary.**
|
|
238
|
-
|
|
239
|
-
Counted across the shipped templates: 16 declared `@voltro/ui-shadcn`, and **13 of them never imported `@voltro/ui-shadcn/tokens.css`**. The only kit component they used was `LocaleSwitcher`, which is `h-9 rounded-md border border-input bg-transparent px-2 text-sm shadow-xs …` and nothing else. Tailwind v4 emits a utility only when a CSS entry declares it, and the kit deliberately does not import its own stylesheet (the app owns its CSS entry) — so every one of those class names referred to a rule that did not exist. The control rendered as a bare `<select>` with dead `class` attributes, in templates whose own docs say they do not use the kit.
|
|
240
|
-
|
|
241
|
-
Nothing could have caught it: `voltro build` succeeds (a missing utility is absent bytes, not an error), `tsc` succeeds (the import is real), and the page renders.
|
|
242
|
-
|
|
243
|
-
Two changes:
|
|
244
|
-
|
|
245
|
-
- **`@voltro/i18n` exports `LocaleSwitcher`** — the same behaviour (writes the `voltro:locale` cookie, reloads, `onChange` can suppress the reload) as a native `<select>` you style yourself. It belongs here for the same reason `LOCALE_COOKIE` already moved here: an app on the framework's i18n and not on the kit had no way to reach it. `@voltro/ui-shadcn`'s styled version is unchanged. - **`voltro doctor` reports a kit rendered with no stylesheet** — advisory, and it names the offending files. Quiet when the app imports `tokens.css`, quiet when the app declares Tailwind itself, and quiet on a `import type` (which emits nothing and therefore cannot produce a class attribute).
|
|
246
|
-
|
|
247
|
-
The 13 templates now import the unstyled control and no longer declare `@voltro/ui-shadcn` at all — which also drops that package's `shiki` dependency from every one of them.
|
|
248
|
-
|
|
249
|
-
### Changed
|
|
250
|
-
|
|
251
|
-
- **@voltro/content, @voltro/ui-shadcn, @voltro/cli** — **The production bundles carried 308 syntax grammars and three cloud SDKs for apps that asked for none of them.**
|
|
252
|
-
|
|
253
|
-
Both shiki callers already restricted their languages correctly — `@voltro/content` to nineteen, `@voltro/ui-shadcn` to eighteen — at RUNTIME. A bundler cannot read a runtime list, and the `shiki` barrel maps all 700+ of its languages to their own dynamic import, so every one was emitted as a chunk. Measured, by intersecting the emitted filenames with `@shikijs/langs` + `@shikijs/themes`: **6.67 MB across 308 files, in BOTH the api serve bundle and the web start bundle**. In a browser bundle it is the same set: an app rendering one `<HighlightedCode>` made 308 chunks reachable.
|
|
254
|
-
|
|
255
|
-
Both now build from `shiki/core` with the grammars and themes imported by name. Every specifier stays dynamic and node-gated — `shiki` is an optional dependency and `@voltro/content` is isomorphic, so a static import would both break an app that never renders markdown and put the highlighter in the browser graph of anything importing `renderMarkdown`.
|
|
256
|
-
|
|
257
|
-
The same shape, one layer out: the api serve bundle also carried the Azure Blob SDK (604 KB) and `@react-email/render` + `react-dom/server` (972 KB) for a fixture that declares one plugin and neither storage nor mail — they arrive through `@voltro/cli`'s own dependencies on `plugin-storage` / `plugin-mail`. Each is already reached by a dynamic import inside its plugin and is an optional peer of it, so each joins the runtime-external list beside `ioredis` and `nodemailer`: an app that configured the provider resolves it from its own `node_modules` at boot, one that did not never ships it.
|
|
258
|
-
|
|
259
|
-
Measured end to end, on the reference fixtures:
|
|
260
|
-
|
|
261
|
-
| | before | after | |---|---:|---:| | api serve bundle | 26.9 MB / 535 files | **5.72 MB / 138** | | web start bundle | 13.59 MB / 427 files | **1.48 MB / 39** |
|
|
262
|
-
|
|
263
|
-
Both are pinned now (`bundle-budget.mjs --artifacts`), in bytes AND in file count — 427 is a number somebody notices, where "13.6 MB" reads as "it is a bundle".
|
|
264
|
-
|
|
265
|
-
Highlighting is unchanged and verified through a real build: the docs site's pre-rendered pages still carry `class="shiki shiki-themes github-light github-dark-dimmed"`.
|
|
266
|
-
- **@voltro/cli** — **The artefacts that exist to collapse a cold boot were shipped unminified — and nothing enabled node's code cache.**
|
|
267
|
-
|
|
268
|
-
`VOLTRO_BOOT_TIMING=1` says where a boot goes, and the answer is the phase the bundles were built for: `modules` (node init + loading the precompiled bundle) is **46 %** of a `voltro start` and **65 %** of a `voltro serve`. V8 parse time tracks bytes, and neither `webStartBundle.ts` nor `apiBuild.ts` set `minify`, while vite leaves an SSR build unminified by default.
|
|
269
|
-
|
|
270
|
-
Both are on now, plus `enableCompileCache()` in the launcher. Measured on the reference fixtures, five fresh processes each, median:
|
|
271
|
-
|
|
272
|
-
| | before | after | |---|---:|---:| | `voltro start` boot | 166 ms | **83 ms** | | `voltro serve` boot | 287 ms | **222 ms** | | start bundle | 13.59 MB | 11.75 MB | | serve bundle | 26.9 MB | 18.25 MB | | `dist/server/ssrEntry.js` | 1.91 MB | 0.85 MB |
|
|
273
|
-
|
|
274
|
-
`keepNames: true` is not optional and is the one thing to preserve if you touch this: Effect's tags, error `name`s and the boot-refusal marker (`bootRefusal.ts`) are compared as STRINGS across the bundle boundary, and a mangled class name turns a precise refusal into an anonymous one. It costs a few percent of the saving and buys back every diagnostic the bundle exists to keep.
|
|
275
|
-
|
|
276
|
-
The compile cache is only enabled when `NODE_COMPILE_CACHE` is unset, so an operator's directory always wins. In a scale-from-zero container the OS temp dir starts empty, which is why the shipped standalone Dockerfiles point the variable at a directory the image BAKES during its boot smoke — the run that was already happening.
|
|
144
|
+
A configured upload with the package absent refuses the build and names the command, rather than skipping the upload and leaving you to discover months later that no frame resolves.
|
|
277
145
|
|
|
278
146
|
### Fixed
|
|
279
147
|
|
|
280
|
-
- **@voltro/database** —
|
|
281
|
-
|
|
282
|
-
`applyPlan` takes the migration advisory lock, so two writers cannot execute at the same time. What it did not do was ask again once it held the lock. Both replicas plan against the same live state, then queue; the winner applies, and the loser wakes holding a plan for a database that no longer exists:
|
|
283
|
-
|
|
284
|
-
```
|
|
285
|
-
═══ applier: statement failed (op=add-check) ═══
|
|
286
|
-
statement: ALTER TABLE "actors" ADD CONSTRAINT "actors_kind_check" …
|
|
287
|
-
db.message: constraint "actors_kind_check" for relation "actors" already exists
|
|
288
|
-
dev server exited — supervisor stopping exitCode: 1
|
|
289
|
-
```
|
|
290
|
-
|
|
291
|
-
A classic time-of-check/time-of-use: the lock serialised the apply and did not protect what the apply rests on. It self-heals — the pod restarts and finds the schema applied — so what it looked like in production was one crash per replica on every deploy against a schema that had work to do, on a plan that was correct when it was made. A cold fleet start is exactly when every replica has work to do.
|
|
292
|
-
|
|
293
|
-
The first thing under the lock is now `ctx.replan` — the same hook the convergence proof uses at the other end, required for the same reason (only the caller knows the planner inputs). Everything downstream reads the re-planned set: the operations, the resume ledger's comparison, and the recorded fingerprint. Using the stale one for any of those would record that a replica applied work it did not.
|
|
294
|
-
|
|
295
|
-
Where the plan is already current — the ordinary case, nobody raced — the re-plan returns what was passed in and costs one introspection. `applyPlan` is not called at all for an empty plan, so that cost lands only where there was real work.
|
|
296
|
-
|
|
297
|
-
A plan that becomes blocked under the lock is refused as loudly as one that started blocked; the pre-lock check judged a different set.
|
|
298
|
-
- **@voltro/cli** — **A proven change-stream gap now drops the cache, not only the live queries.**
|
|
299
|
-
|
|
300
|
-
Re-running every live subscription repairs what a subscriber sees, and that is the half you look at. Cache invalidation rides `store.onChange` — so while the stream was down, nothing was invalidated — and the dispatcher's recompute re-seeds only the entries a LIVE subscription owns. Everything else keeps serving pre-gap rows until its TTL: a `ctx.cache` read, an ISR page, a cached query nobody is currently subscribed to. On a replica that has just announced, in its own log, that it knows it was behind.
|
|
301
|
-
|
|
302
|
-
The recovery now evicts every registered table before refreshing. Blunt for the same reason `refreshAll` is blunt — we do not know which tables the lost changes touched, and guessing narrower is how the silent staleness comes back. Cache first, then the queries: the recompute reads the store directly and writes its result back, so dropping afterwards would throw the fresh rows away again.
|
|
303
|
-
|
|
304
|
-
Both boot paths, through the shared builder, with the parity asserted.
|
|
305
|
-
- **@voltro/cli** — **`voltro start` sets `Cache-Control`. It sent none at all.**
|
|
306
|
-
|
|
307
|
-
Measured against a running production server, on a chunk whose FILENAME carries its content hash:
|
|
308
|
-
|
|
309
|
-
```
|
|
310
|
-
$ curl -D - http://localhost:5399/assets/index-7N08IhkU.js
|
|
311
|
-
HTTP/1.1 200 OK
|
|
312
|
-
content-type: application/javascript
|
|
313
|
-
vary: Accept-Encoding
|
|
314
|
-
content-encoding: br
|
|
315
|
-
etag: W/"4bff20f74ba2a65fde4443acd7ed80b6"
|
|
316
|
-
```
|
|
317
|
-
|
|
318
|
-
No `cache-control` and no `last-modified`. RFC 9111 derives heuristic freshness from `Last-Modified`, so with neither header a browser has nothing to reason about and revalidates. The reference fixture's first load is an entry plus nine `modulepreload`s — ten conditional round-trips before the page is interactive, on every visit, for files that by construction can never change. A CDN or reverse proxy in front of the container could cache nothing at all, for the same reason.
|
|
319
|
-
|
|
320
|
-
The framework already knew the rule: `plugin-storage` serves its public objects with `public, max-age=31536000, immutable` and `plugin-atlassian` its avatars. Only the arm serving our OWN chunks had no policy.
|
|
321
|
-
|
|
322
|
-
Now, from one place (`staticCachePolicy.ts`, so the arms cannot disagree): content-hashed assets get a year and `immutable`; anything else out of `public/` gets an hour; a pre-rendered page gets `max-age=0, must-revalidate`, which the existing ETag answers with a `304`.
|
|
323
|
-
|
|
324
|
-
Nothing gets an `s-maxage` by default — a shared cache holding HTML past a deploy serves the previous build's asset URLs, and there is no purge hook to fix that. An app that owns its CDN opts in through the new `http.cache` block (`htmlSMaxAgeSeconds`, `isrShared`, and both lifetimes; `immutableMaxAgeSeconds: 0` turns the immutable header off entirely).
|
|
325
|
-
|
|
326
|
-
The hash detector is the part with an edge: it requires `/assets/` AND a `-<6..12 chars>` suffix, so a hand-named `page-2.js` is never frozen for a year — a mistake that cannot be undone without renaming the file.
|
|
327
|
-
- **@voltro/plugin-row-history** — **`timing: 'post-commit'` recorded one history version per replica, with different version numbers.**
|
|
328
|
-
|
|
329
|
-
The in-transaction timing is safe by construction: the writing replica records the entry inside its own transaction and the tap returns early. Post-commit has no such writer — on a `changeScope: 'fleet'` store the injected event reaches EVERY replica and each one calls `recordChange`.
|
|
330
|
-
|
|
331
|
-
It did not surface as a conflict. `recordChange` numbers a version as `MAX(version) + 1` and derives the row id from it, so two replicas both computed version 1, one won the primary key, and the loser's RETRY re-read MAX, got 2, and appended a SECOND entry for the same change. That retry exists for a genuine concurrent write to the same row and cannot tell that case from this one.
|
|
332
|
-
|
|
333
|
-
Measured with the gate removed: three replicas, one change, versions `1, 2, 3`; two replicas, three changes, `1, 2, 3, 4, 5, 6`. Not merely doubled — mis-ordered, and `selectAsOf` / `sortHistory` / `diffVersionRows` all read `version`. A duplicate can be deduped; a wrong order cannot even be detected from the data.
|
|
334
|
-
|
|
335
|
-
The tap now claims each change fleet-wide before recording, through the same arbiter behind `defineSubscriber({ once })`. The key names the CHANGE, not the row: a row id would collapse two genuine edits to one row, and for a history trail a silently missing version is the worse direction.
|
|
336
|
-
|
|
337
|
-
Nothing to configure. `timing: 'in-transaction'` is unaffected — it never had this.
|
|
338
|
-
- **@voltro/sql-postgres, @voltro/database, @voltro/cli** — **A postgres replica no longer goes permanently deaf when its LISTEN connection drops.** Under `changeStrategy: 'cdc'` — the multi-replica default on postgres — the CDC consumer holds one dedicated connection. `@effect/sql-pg` registers a no-op `client.on('error')` on it, so when that connection dies the socket error is swallowed, the stream neither fails nor ends, and the fiber draining it stays alive forever. Nothing throws. Nothing is logged. No fiber dies.
|
|
339
|
-
|
|
340
|
-
Measured against a live server: kill the backend holding the LISTEN, write from another connection, wait twenty seconds — nothing arrives. Every subsequent change from every other replica is lost too, until the process restarts. A failover, a proxy recycling an idle socket, an admin `pg_terminate_backend`, or the database pod restarting all produce it, and none of them touch the app process, which is exactly why the app process did not notice.
|
|
341
|
-
|
|
342
|
-
The consumer now carries a watchdog. It cannot wait for an error — there is none — so it probes: after silence on the channel it sends a `pg_notify` through the pool and requires the echo back on the LISTEN stream. Any traffic counts as the answer, including another replica's probe, so a busy channel never pays for one and a fleet pays roughly one probe per idle window however many replicas it has. An unanswered probe means the connection is dead; the consumer re-opens it, retrying with backoff, and does not declare success until it has heard its own heartbeat come back.
|
|
343
|
-
|
|
344
|
-
**And the reconnect declares a gap**, which is the half that makes it a recovery rather than merely a pulse: postgres queues nothing for a listener that is not there, so every change written during the outage is gone. Stores expose that through a new optional `DataStore.onChangeStreamGap`, and both `voltro dev` and `voltro serve` wire it to the same refresh the broadcast bus's gap already used — re-run every live query, which is safe and complete because a query is idempotent.
|
|
345
|
-
|
|
346
|
-
`VOLTRO_CDC_HEARTBEAT_MS` (default 20000) and `VOLTRO_CDC_HEARTBEAT_TIMEOUT_MS` (default 10000) tune it.
|
|
347
|
-
|
|
348
|
-
`apiSurface: compatible`: `PostgresDataStore`'s constructor gains a TRAILING OPTIONAL parameter (`cdcLiveness`), which the golden renders as a changed line. Every existing call still compiles — the store is built through `makePostgresDataStore` in any case.
|
|
349
|
-
|
|
350
|
-
The mysql binlog reader has had an error-driven reconnect and a watchdog for a silently-dead stream for some time. This is the postgres half of the same idea, on the dialect the documentation steers people to.
|
|
351
|
-
- **@voltro/plugin-broadcast, @voltro/cli** — **A change delivered by the database was re-published onto the broadcast bus, and the amplification was quadratic.**
|
|
352
|
-
|
|
353
|
-
On a dialect with a native change transport — postgres LISTEN/NOTIFY, mysql binlog — the store injects every change on EVERY replica; that is what makes the transport cross-instance in the first place. `@voltro/plugin-broadcast`'s outgoing listener then published those injected events again, under its own replica's origin, which is not own-origin for any peer. So every peer injected the change a second time.
|
|
354
|
-
|
|
355
|
-
Per change, with N replicas: N deliveries from the transport, N publishes onto the broker, and N(N-1) more injections from the peers — **N² local deliveries**. Every consumer of the change stream paid it: every `*.subscribe.ts` handler, every live-query wake, every plugin tap, every cache invalidation. At two replicas a subscriber's handler ran four times for one `INSERT`, twice per instance — and the per-instance half needs no cluster to reproduce, which is why it does not read as a clustering problem.
|
|
356
|
-
|
|
357
|
-
The suppression required the bus to be mid-inject (`injecting && …`), which only ever covered the bus's own echo. It is provenance alone now: an event stamped `origin: 'injected'` reached this process through some transport, and forwarding a transport delivery to a second transport is the amplification. A local publish that states `origin: 'inline'` — `publishReactivity` does — is still published, so reactivity channels keep crossing replicas.
|
|
358
|
-
|
|
359
|
-
The boot banner said the same thing the code did. It used to read `both paths active (own-origin skip dedups)`; it now names what each path carries:
|
|
360
|
-
|
|
361
|
-
```
|
|
362
|
-
reactivity: native LISTEN/NOTIFY (postgres) carries table changes;
|
|
363
|
-
@voltro/plugin-broadcast (redis) carries reactivity channels
|
|
364
|
-
```
|
|
365
|
-
|
|
366
|
-
Nothing to change in an app. If you run postgres or mysql with a broadcast plugin, the duplicate deliveries stop on upgrade.
|
|
367
|
-
- **@voltro/cli** — **`voltro db plan --json` and `voltro db drift --json` were silently truncated when piped.** `fs.writeSync` is not "write this"; it is "write as much as the fd accepts right now, and return how much that was". On a file that is everything — which is why `> plan.json` worked and every manual check passed. On a pipe the kernel takes one pipe buffer, 64 KiB, and the rest is simply not written.
|
|
368
|
-
|
|
369
|
-
A plan crossing 64 KiB therefore reached `| jq`, or a CI step capturing the command, as exactly 65 536 bytes: valid JSON up to the cut and a parse error after it. It reads like a malformed plan and it is a malformed read — and the documented production route is to review that JSON and apply it.
|
|
370
|
-
|
|
371
|
-
The line it replaced carried a comment saying `writeSync` "always flushes", written as the fix for the previous version of this same bug (`console.log` to a non-TTY is block-buffered, and `process.exit` dropped the buffer, so `--json` emitted *nothing*). That fix was right about `console.log` and wrong about its replacement, turning "nothing on a pipe" into "the first 64 KiB on a pipe" — the more dangerous of the two, because an empty output is noticed at once and a truncated one is noticed by whoever parses it later.
|
|
372
|
-
|
|
373
|
-
`writeAllSync` loops over partial writes. Its test spawns a child with a real pipe, because on a file the defect does not exist.
|
|
374
|
-
- **@voltro/plugin-search** — **The search panel could over-count a fleet's sync stats, because the plugin derived its replica id instead of using the one it was handed.**
|
|
375
|
-
|
|
376
|
-
`_voltro_search_stats` keeps one row per replica and aggregates on READ: SUM under `changeScope: 'local'` (each replica counted a different slice) and MAX under `'fleet'` (every replica received the full stream, so each row is already a fleet-wide count — summing three of those is the 3× inflation the design exists to avoid).
|
|
377
|
-
|
|
378
|
-
That only works if the rows are actually per replica. `dataStoreStatsStore` takes the id as a parameter and the plugin never passed one, so it fell back to the process global — which is correct in production and made the identity undiscoverable to the plugin's own configuration. The id now comes from `PluginBindContext.instanceId`, the same value the event bus stamps and the membership registry announces under, and the reason the context carries it.
|
|
379
|
-
|
|
380
|
-
Found as a red test rather than by reading: the fleet-stats case simulates three replicas in one process, which it used to do by setting `VOLTRO_REPLICA_ID` around construction. Once process identity became memoised — one process, one identity — all three "replicas" shared an id, wrote to one row, and the panel reported 3 where the case asserts 1. The property was right and the simulation had become impossible to express; taking the id from the context makes it expressible again, through the same seam production uses.
|
|
381
|
-
- **@voltro/cli** — **`voltro build` could not produce a serve bundle for any app declaring `@voltro/plugin-sentry`** — 35 errors, all `No loader is configured for ".node" files`, and a `fatal: serve bundle build FAILED — refusing to ship a bundle-less image`.
|
|
382
|
-
|
|
383
|
-
`@sentry/profiling-node` reaches `@sentry/node-cpu-profiler`, which `require()`s a per-platform `.node` binary. It is the strongest possible case for the native-leaf list — BOTH of that list's reasons at once, a compiled binding that cannot be inlined AND a `await import(…)` behind a `profiling: true` flag that already degrades to a warn when absent — and it was simply never added.
|
|
384
|
-
|
|
385
|
-
**The second half is why adding the leaf alone would not have fixed it.** The runtime shim resolves a leaf from a chain of roots: the declared SQL drivers, then `@voltro/cli`, then the app root. An optional peer lives under the plugin that dynamic-imports it, and pnpm strict does not hoist it — so `@voltro/cli` covers the peers of the plugins the CLI itself depends on (mail, storage) and nothing else. `@sentry/profiling-node` is an optionalDependency of `@voltro/plugin-sentry`, which the CLI does not depend on, so no root in the chain could have found it at runtime.
|
|
386
|
-
|
|
387
|
-
The chain now includes **every `@voltro/plugin-*` the app declares**, derived from its `package.json` rather than listed. A hard-coded plugin list is the shape that produced the gap: correct until the next plugin ships an optional peer, and silent when it does.
|
|
388
|
-
|
|
389
|
-
Found by the first `--build` run of the template harness. `voltro test` transpiles and `tsc --noEmit` typechecks; neither runs a build, so an unbuildable template stays green in both.
|
|
390
|
-
- **@voltro/protocol, @voltro/cli, @voltro/plugin-ratelimit** — **Two defaults that are correct for one process and silently wrong for several now say so.** Neither default changes — a process-local store is right on one process, and demanding Redis to run a single instance would be worse. What was missing is the deployment noticing.
|
|
391
|
-
|
|
392
|
-
- **The rate limiter.** `rateLimitPlugin` defaults to a process-local counter, so `100/min` on five pods is 500/min. That is not a performance detail: a limiter is usually what stands between an endpoint and abuse, which makes this the one default whose silent multiplication has a security consequence. - **Read-your-writes.** The RYW position store defaults to a process-local Map. With `DB_REPLICA_URLS` set, read-your-writes then holds only when the next request happens to land on the same pod — a user saves, the load balancer sends them elsewhere, and that pod routes the read to a lagging replica and serves the row as it was before the write. The boot line said `ryw policy 'fallback'` as though the policy were in force.
|
|
393
|
-
|
|
394
|
-
Both warn on the same signal the reactivity audit already used (`replicaEvidence`: `POD_NAME`, `FLY_ALLOC_ID`, `K_REVISION`, … and an explicit `REPLICA_COUNT` as a declaration in both directions), and each names the way out.
|
|
395
|
-
|
|
396
|
-
`replicaEvidence` moves to `@voltro/protocol/identity` — it began in the CLI, a plugin cannot import the CLI, and copying twenty env names is how one question acquires three answers. It is still exported from its old place.
|
|
397
|
-
- **@voltro/cli** — **The outbox runner's shutdown could settle the wrong pass.** `close()` awaits the in-flight drain rather than truncating a delivery mid-flight — that is the half of the shutdown gap a `clearTimeout` cannot cover, and it was already tested. What it awaited was `inFlight`, which `kick()` overwrote on every call including one that immediately returned because a pass was already running. So `close()` could await a promise for a pass that never ran while the real one was still inside its handler, and the next step of the real shutdown sequence is `store.close()`.
|
|
398
|
-
|
|
399
|
-
The window only opened when a second kick landed inside the first pass, which is why it survived: it took adding one round trip to the drain to widen it enough for the existing settle test to catch. An early-returning pass now hands back the pass it deferred to, so `inFlight` always names real work.
|
|
400
|
-
|
|
401
|
-
Found by a test that was already asserting the right thing and had been passing for the wrong reason.
|
|
402
|
-
- **@voltro/protocol, @voltro/runtime, @voltro/cli, @voltro/voltro** — **A peer that restarted stopped being heard by reconnecting clients.** `EventEnvelope.origin` says what it is — *"Publishing instance. Serials are only comparable WITHIN one origin"* — and both boot paths handed it the replica NAME. A name survives a restart; a serial does not. A StatefulSet pod keeps its `POD_NAME` and `VOLTRO_REPLICA_ID` is stable by definition, so a restarted peer publishes serials 1, 2, 3… under an origin whose watermark still reads 500.
|
|
403
|
-
|
|
404
|
-
Two silent losses follow from that one stale number. The watermark never advances, so a reconnecting client is told it missed nothing; and the resume replay filters by `n > lastSeen`, so the new process's events are dropped from the replay entirely. The peer is publishing normally the whole time.
|
|
405
|
-
|
|
406
|
-
The event bus now keys on `instanceId()` — `<replicaId>@<startedAt>.<nonce>`, newly exported from `@voltro/protocol/identity`. It carries the replica name as its prefix, so correlating the subsystems across pods still works.
|
|
407
|
-
|
|
408
|
-
`apiSurface: compatible`, and it covers a second thing: regenerating the goldens caught up drift the fleet-observation work left in the `@voltro/voltro` AGGREGATES, which are re-exports and so do not regenerate when their source package does. The one changed line there is `checkFrameworkCompat`, whose `runningVersion` parameter WIDENED to `string | undefined` and whose success result gained an optional `unverified`. A widened parameter accepts every call that compiled before; an added optional field breaks no reader.
|
|
409
|
-
|
|
410
|
-
The membership registry deliberately keeps the NAME: it detects a restart by comparing `startedAt` under a stable id, and a per-process id would turn every restart into a join plus a silent leave.
|
|
411
|
-
- **@voltro/cli, @voltro/database** — **`_voltro_idempotency` grew without bound, and its own documentation said it did not.**
|
|
412
|
-
|
|
413
|
-
The table's doc comment claimed "a periodic sweep / lazy-TTL drops rows past their window". The lazy TTL is real and fires only when the SAME key is claimed again — and an idempotency key is used once by definition, so the row it leaves behind is never read and never deleted. There was no periodic sweep at all: the table was the one member of its family with no entry in the retention registry.
|
|
414
|
-
|
|
415
|
-
It has one now, and the TTL is a FLOOR rather than a setting that can be turned down. A record dropped while still inside the app's own dedup window would let the duplicate request it exists to stop execute a second time, so `VOLTRO_IDEMPOTENCY_TTL_HOURS` may lengthen the window and may not shorten it below the app's `idempotency.ttlMs` plus a clock-skew margin. When it asks for less, the floor wins and the boot says so — a silently-ignored setting is worse than a refused one.
|
|
416
|
-
|
|
417
|
-
The reaction rate-limiter's slot rows used to live in this table too and now use `_voltro_change_claims`, whose window is an hour rather than the idempotency window. Two row families with different lifetimes cannot share one retention policy: the registry is keyed by table, so one table carries exactly one TTL.
|
|
418
|
-
|
|
419
|
-
Also corrected: the table is created for EVERY sql app (`when: 'always'`), not "only when `idempotency` is set in `app.config.ts`" — that is what the config gates, not what the table registry does.
|
|
420
|
-
- **@voltro/plugin-presence** — **`usePresence` re-announced its membership on every render, which is a write loop.** Measured on a real page against a real api, ONE page load, a fresh browser context: **~9 300 uncaught `RateLimited: presence.heartbeat` pageerrors in 3.5 seconds** — about 2 700 per second, and the same rate of mutations arriving at the server. The 60/min rate limiter was the only thing standing between this and an unbounded write loop.
|
|
421
|
-
|
|
422
|
-
`meta` sat in the join effect's dependency array, and the documented way to call the hook is an inline object:
|
|
423
|
-
|
|
424
|
-
```tsx
|
|
425
|
-
usePresence('room', { key: me.key, meta: { name: me.name } })
|
|
426
|
-
```
|
|
427
|
-
|
|
428
|
-
which is a new identity every render. So: leave → join → roster push → re-render → leave → join → … The hook already refd its two mutations for exactly this reason and left the one caller-supplied value in.
|
|
429
|
-
|
|
430
|
-
`meta` is refd now, so the interval always sends the CURRENT value, and the join effect is keyed on the membership identity only — a metadata change publishes immediately through a separate effect instead of tearing the membership down and re-announcing it. A serialisation failure degrades to "publish at the next beat" rather than throwing: a roster is not worth a render crash.
|
|
431
|
-
|
|
432
|
-
**Nothing in this package could have seen it.** Closing the loop needs a REAL subscription pushing a real roster back; a mocked transport re-renders once and stops. So the regression test asserts the property that BREAKS the loop — an equal-but-new meta object does not re-announce — and was falsified first: with `meta` back in the deps, two re-renders produce three heartbeats instead of one.
|
|
433
|
-
- **@voltro/protocol, @voltro/runtime, @voltro/cli, @voltro/plugin-billing, @voltro/plugin-search** — **One process, one identity.** `replicaId()` / `processIdentity()` from `@voltro/protocol/identity` is now the only place the framework decides which replica it is running as. It was an expression, sixteen times, in three packages, in FOUR spellings — and the spellings disagreed.
|
|
434
|
-
|
|
435
|
-
Two ignored `POD_NAME` entirely and read `HOSTNAME` alone; one read `HOSTNAME` *before* `POD_NAME`; only one honoured the explicit `VOLTRO_REPLICA_ID` override. The variants landed in different subsystems: the `HOSTNAME`-only one stamps `_voltro_schedule_runs.replicaId` and `claimedBy`, while the `POD_NAME`-first one answers `/_voltro/inspect/cluster`.
|
|
436
|
-
|
|
437
|
-
**So a single inspect response could name the same pod twice, differently.** `instance.replicaId` came from one spelling; `coordinationState.recentReplicaIds` — read back out of the schedule-run rows — came from the other. A dashboard asking "which of these is me" found itself in neither list. The doc comment on that field asserted the two were the same id; it is now true rather than aspirational.
|
|
438
|
-
|
|
439
|
-
This matters beyond tidiness: every aggregation across replicas groups by this id, and grouping by an id that depends on which subsystem wrote it is worse than not aggregating at all.
|
|
440
|
-
|
|
441
|
-
The identity says four things, because two of them were missing:
|
|
442
|
-
|
|
443
|
-
- `replicaId` is the PLACE in the fleet and survives a restart; `instanceId` carries the GENERATION and does not. Conflating them made "one pod restarted forty times" and "there are forty pods" the same number. The generation carries a nonce below the clock's resolution — two generations starting in the same millisecond would otherwise collide, and an aggregation keyed on it would merge two processes into one. - `version` is what this process runs, for the window in which a rolling deploy makes the fleet genuinely mixed. `undefined` when it cannot be determined — never a plausible-looking `0.0.0`, which two of the old resolvers returned and which compares equal to another unknown. - `reachableAt` / `reachable` record where a peer could reach this process, with loopback recorded as NOT reachable — the same distinction `resolveRunnerIdentity` already draws as `localhostRisk`.
|
|
444
|
-
|
|
445
|
-
**The framework VERSION had the same disease, with sharper teeth.** Three resolvers: two hunted for `package.json` relative to their own module, and one read `npm_package_version` — the APP's version when started through an npm script, reported as the framework's. The manifest hunt cannot work inside a bundle, where the framework is inlined and `../package.json` belongs to whatever sits there, so a bundled `voltro serve` fell into its `catch` and reported `'0.0.0'`: right under `voltro dev`, wrong in production, which is the one place nobody can go and read it out of the source tree.
|
|
148
|
+
- **@voltro/database, @voltro/protocol, @voltro/runtime, @voltro/sql-mysql** — A write the FRAMEWORK refuses now reaches the caller as a typed `ConstraintViolation` instead of a defect.
|
|
446
149
|
|
|
447
|
-
|
|
150
|
+
Every failure the DATABASE reports already had this: `storeMiddleware` reads the driver's error and maps it. A refusal the framework decides had nothing to map from — there is no driver code for our own decision — so it stayed a plain `Error`, which Effect classifies as a defect. It arrived at the client as a `Defect` wrapping a stringified `InternalError`, untagged, for a condition an app can genuinely branch on. A form shows a support id over an input that saved nothing.
|
|
448
151
|
|
|
449
|
-
|
|
152
|
+
The MySQL-family upsert raises one: `ON DUPLICATE KEY UPDATE` fires on whichever unique key the incoming row violates, so the store checks afterwards that it reached the row the caller's `conflictColumns` name, and rolls back when it did not. `unique` is the kind — a row did collide on some unique key of that table; what the engine will not say is which, and that silence is the reason the check exists, so `constraint` stays absent rather than guessed at.
|
|
450
153
|
|
|
451
|
-
|
|
154
|
+
`ConstraintViolation` gained an optional `detail`, populated only by these refusals and never from a driver message or a row value. The generated sentence exists because the driver's own text carries row data on several dialects; that is a rule about the DRIVER's sentence, not about length, and a sentence the framework authored carries exactly what we put in it.
|
|
452
155
|
|
|
453
|
-
|
|
454
|
-
- **@voltro/cli, @voltro/devtools-ui** — A sweep of all 31 dashboard pages in a real browser, against a running api, found four defects nothing else was looking for. None threw where a test could see it; three of them blanked a whole page.
|
|
156
|
+
The mark is read BEFORE the driver-parser and never folded into it: giving our own decision the fields `classifyConstraintViolation` reads would make the two indistinguishable inside the one function whose contract is "this is what the database said".
|
|
455
157
|
|
|
456
|
-
|
|
158
|
+
Not everything the store throws becomes a failure. An upsert whose conflict key cannot be looked up at all — every column database-GENERATED — stays a defect, because it is a call that cannot be served as written rather than a condition in the data, and typing it would invite an app to swallow a wiring mistake as though it were a busy row.
|
|
159
|
+
- **@voltro/sql-mysql, @voltro/testing** — An upsert whose `conflictColumns` are NOT the primary key now works on MySQL and MariaDB. It threw, rolled back, and reported "Nothing was changed" — accurately, which is the only reason it was not worse.
|
|
457
160
|
|
|
458
|
-
|
|
161
|
+
Postgres names its target (`ON CONFLICT (a, b) DO UPDATE`); MySQL and MariaDB have no such form, so `ON DUPLICATE KEY UPDATE` fires on whichever unique key the incoming row violates. `id` is excluded from the update — it identifies the row and must not be silently rewritten — so the store checked afterwards that the returned row was the one it aimed at, by comparing the returned `id` with the one passed in.
|
|
459
162
|
|
|
460
|
-
|
|
163
|
+
That is the shape of the dangerous case, and it is also the shape of every correct update: an upsert that legitimately matches on the caller's own columns returns THAT row's id, never the freshly minted one. So the check fired on the case it existed to permit. The `op` classification one line below it (`id unchanged → insert, else update`) is written for a branch the throw made unreachable.
|
|
461
164
|
|
|
462
|
-
|
|
165
|
+
What decides it is the conflict-column VALUES, which are excluded from the update too and therefore still carry the matched row's own: equal to the input means the named key is what matched, and no other row can carry those values because the column set is unique. A difference means the statement was sent somewhere the named key could not have sent it — still refused, and the message now names the differing COLUMN instead of asserting a constraint it cannot identify. Not its values: that sentence reaches the client, and a conflict column is as likely to hold an email address as a team id — the same line `ConstraintViolation` draws when it refuses to carry the driver's own sentence. The values go to the server log, where the row already is. A conflict column the database GENERATES cannot be compared; when none of them can, that is said rather than resolved to "fine".
|
|
463
166
|
|
|
464
|
-
|
|
465
|
-
- **@voltro/cli** — Two defects that produced a console error on every app page of the DevTools dashboard, found by a browser check and by nothing else — neither threw, neither changed a status code any test was watching.
|
|
167
|
+
The parity suite gained the case that was missing: every upsert scenario in it conflicted on `id`, so the returned id always equalled the one sent and a store could not be caught being wrong about this. It now upserts on a composite unique key from a fresh id on every dialect, with a second row that must NOT be overwritten.
|
|
466
168
|
|
|
467
|
-
|
|
169
|
+
The LOOKUP half of the same operation — taken by every MySQL upsert, and on MariaDB by a partial row or a function `update` — now refuses clearly when a conflict column is database-GENERATED, instead of failing inside the driver.
|
|
468
170
|
|
|
469
|
-
|
|
171
|
+
It matches by value, and the value of a generated column is not in the row the caller passed: the classic partial-unique index on this engine IS such a column (a STORED expression plus NULL-distinct semantics, so the key constrains only the rows the expression marks). Binding the absent value reached mysql2 as `Bind parameters must not contain undefined` — a TypeError naming no column, no table and no cause, for a write that did nothing. Binding SQL NULL would make the predicate never true, so the lookup finds nothing and falls through to an INSERT the very key rejects; dropping the column would widen the lookup and can match a row the write would not have collided with, which is the one outcome that overwrites somebody else's data.
|
|
470
172
|
|
|
471
|
-
|
|
173
|
+
So it refuses, names the column, and points at the path that CAN answer: the single-statement form, where the database evaluates the expression itself. That path is reached on MariaDB with a complete insert row and a column-list `update`.
|
|
174
|
+
- **@voltro/kv** — Closing a RESP client can no longer take the process down.
|
|
472
175
|
|
|
473
|
-
|
|
176
|
+
A previous round added a silent `error` listener so an ioredis client without one could not crash the process. That is a different failure, and a deployment on `voltro dev` measured the difference: after the fix, roughly one reload in three still ended in `Error: Connection is closed.` thrown from `ioredis/built/redis/event_handler.js`, unhandled, with no framework frame in the stack.
|
|
474
177
|
|
|
475
|
-
|
|
476
|
-
- **@voltro/cli** — **`voltro update` said the same thing about two opposite outcomes.** An update that changed nothing printed `codemods: nothing to apply for this jump` whether the jump ships no codemods at all, or ships several and every one of them gated ITSELF out through its own `appliesTo`. A reader takes the first meaning, because that is what the sentence says.
|
|
178
|
+
That is not an `error` event. `event_handler.js`'s `close` is where ioredis REJECTS the promises of commands that were in flight when the socket went — and `quit()` is one of those commands. Calling it on a connection that is already closing rejects, and `close: () => c.quit().then(() => undefined)` had no `catch`. A reload closes the client while the previous instance is still shutting down, which is the race.
|
|
477
179
|
|
|
478
|
-
|
|
180
|
+
`close()` now cannot reject, and falls back to `disconnect()` so giving up on the error does not mean giving up on the teardown — a `quit` that failed has not necessarily closed anything, and a socket leaked per reload is its own slow failure. Swallowing is right here and nowhere else in that file: a failed COMMAND reaches its caller as a `KvError`, because that is where it can be acted on, and a failed close has no such place.
|
|
181
|
+
- **@voltro/plugin-broadcast** — A subscription's teardown can no longer kill the process.
|
|
479
182
|
|
|
480
|
-
|
|
183
|
+
`void sub.unsubscribe(channel)` in the dispose function returned by the redis broadcast provider was fire-and-forget with no `catch`. ioredis rejects the promises of every command in flight when the socket closes — `Connection is closed.`, thrown from its own close handler — so a `voltro dev` reload, which disposes subscriptions while the previous connection is going away, produced an unhandled rejection and node killed the process.
|
|
481
184
|
|
|
482
|
-
|
|
185
|
+
This is the THIRD round on one symptom with three different causes: a client with no `error` listener, `quit()`'s unguarded promise, and now a discarded command. Each fix was correct and none of them was the next one, so this round adds a rule instead of a fourth fix: `scripts/check-resp-fire-and-forget.mjs` (CI + gate, with a `--selftest`) fails when a discarded RESP command has no `.catch`.
|
|
483
186
|
|
|
484
|
-
|
|
187
|
+
The rule is deliberately narrow. The general property is `no-floating-promises`, which this repo has no eslint to run — and measured before writing it, a blanket `void <call>` rule matches **194** sites in `packages/*/src`, nearly all legitimate. The RESP subset matches 3, all correct. Widening the command list to "any method" costs the 194; the header says so, next to the number.
|
|
485
188
|
|
|
486
|
-
|
|
189
|
+
The local half of the teardown still runs unconditionally: detaching the message handler is synchronous and cannot fail, and it is what stops a reloaded module receiving the previous one's messages.
|
|
190
|
+
- **@voltro/runtime** — A schedule firing that is rejected before its handler runs no longer outlives itself. `runHandler` wrote the `running` row, armed the watchdog, armed the heartbeat, and only then built the app context — with the `try` whose `finally` releases all three starting after them. The context build is the one step in that window that can throw on input the scheduler does not control, and when it did, each acquisition survived in its own way:
|
|
487
191
|
|
|
488
|
-
|
|
192
|
+
- the watchdog rejected a promise `Promise.race` never received, `maxRuntimeMs` after a firing that never started. Nothing was listening, so the rejection went unhandled and ended the process — with no recorded failure in between to connect the two events. Lowering `maxRuntimeMs`, the obvious response to a run that appears stuck, only widened the gap; - the heartbeat kept stamping `heartbeatAt` on a run that had not begun. That beat is what tells a live run from a corpse, so it certified one: a peer's overlap guard reads a beating row as alive for the life of the process, turning a bounded stale window into a permanent skip for that schedule; - the `catch` never ran either, so the row stayed `running` — the only artefact the firing left behind, saying the opposite of what happened.
|
|
489
193
|
|
|
490
|
-
|
|
194
|
+
Everything that acquires now lives inside the `try`, so a throw from anywhere records `failed` with its reason and releases both timers. The watchdog promise additionally carries a discarded rejection handler, which makes the CONSEQUENCE of a future leak impossible rather than only this one: `race` still sees the rejection, and node can never see it as unhandled.
|
|
195
|
+
- **@voltro/runtime, @voltro/cli** — A `defineStream`'s declared `guards:` are enforced — before the executor runs.
|
|
491
196
|
|
|
492
|
-
|
|
493
|
-
- **@voltro/runtime** — The event-bus perf budgets are RATIOS now, not absolute microseconds.
|
|
197
|
+
Measured against a running api: two `POST /rpc` calls with no Authorization header, milliseconds apart, both descriptors declaring the same scope. The QUERY refused with `ScopeError`; the STREAM answered tokens. It had called a model and paid for it, so the missing check was not only a read permission — an open stream is an open wallet.
|
|
494
198
|
|
|
495
|
-
|
|
199
|
+
Two defects, and the second survives fixing the first.
|
|
496
200
|
|
|
497
|
-
|
|
201
|
+
**Nothing passed the guards.** `bindStream` has taken a `guards` argument since it was written, implements the subscribe-time gate and the per-element re-check, and documents both — and neither boot path passed it. Declared, documented, implemented, wired at zero call sites. The call's input goes with it, because a resource-scoped guard (`{ scope: 'x:read', from: 'id' }`) reads which resource was asked for out of it and otherwise fails closed.
|
|
498
202
|
|
|
499
|
-
|
|
203
|
+
**The gate ran after `buildStream`.** That is where the user's executor runs, so an unauthorized caller had already been served by the expensive half by the time authorization was decided. Passing the guards alone would have stopped the tokens reaching the client while still paying for them, and from the client side those two are identical. The doc comment said "before anything is registered"; the code registered first. It is first now.
|
|
500
204
|
|
|
501
|
-
|
|
205
|
+
This is the SECOND time this exact defect shipped: `defineEvent` accepted `guards:`, documented them, and nothing checked. That was fixed for events and `defineStream` kept the identical hole. So the test asserts the property over the SET — all five primitives that accept `guards:`, each at its named enforcement point — rather than over the one that was reported. Query, mutation, action and event were all already enforced; the stream was the only gap.
|