@voltro/plugin-postgis 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/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -39,6 +39,401 @@ _Changes staged for the next release accumulate here (rolled up from
|
|
|
39
39
|
|
|
40
40
|
---
|
|
41
41
|
|
|
42
|
+
## [0.58.0] — 2026-08-30
|
|
43
|
+
|
|
44
|
+
### ⚠ BREAKING
|
|
45
|
+
|
|
46
|
+
- **@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.
|
|
47
|
+
|
|
48
|
+
**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`.
|
|
49
|
+
|
|
50
|
+
`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".
|
|
51
|
+
|
|
52
|
+
**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.
|
|
53
|
+
|
|
54
|
+
**`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.
|
|
55
|
+
|
|
56
|
+
**`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.
|
|
57
|
+
|
|
58
|
+
**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.
|
|
59
|
+
|
|
60
|
+
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.
|
|
61
|
+
|
|
62
|
+
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.
|
|
63
|
+
|
|
64
|
+
**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.
|
|
65
|
+
|
|
66
|
+
**`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).
|
|
67
|
+
- **@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.
|
|
68
|
+
|
|
69
|
+
`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.
|
|
70
|
+
|
|
71
|
+
`jwtSecret` now accepts `string | (() => string | undefined)`, on `jwtBearerStrategy` and on the Supabase strategy that specialises it.
|
|
72
|
+
|
|
73
|
+
**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.
|
|
74
|
+
|
|
75
|
+
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.
|
|
76
|
+
|
|
77
|
+
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.
|
|
78
|
+
|
|
79
|
+
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.
|
|
80
|
+
|
|
81
|
+
**`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).
|
|
82
|
+
- **@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.
|
|
83
|
+
|
|
84
|
+
Three symptoms, one root cause and two that outlived it.
|
|
85
|
+
|
|
86
|
+
**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.
|
|
87
|
+
|
|
88
|
+
**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.
|
|
89
|
+
|
|
90
|
+
**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.
|
|
91
|
+
|
|
92
|
+
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.
|
|
93
|
+
|
|
94
|
+
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.
|
|
95
|
+
|
|
96
|
+
**`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).
|
|
97
|
+
- **@voltro/client, @voltro/web** — An rpc call that fails in the client now reaches the app's error REPORTERS, not only its rpc listeners.
|
|
98
|
+
|
|
99
|
+
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.
|
|
100
|
+
|
|
101
|
+
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.
|
|
102
|
+
|
|
103
|
+
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`.
|
|
104
|
+
|
|
105
|
+
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.
|
|
106
|
+
|
|
107
|
+
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.
|
|
108
|
+
|
|
109
|
+
`@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.
|
|
110
|
+
|
|
111
|
+
**`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).
|
|
112
|
+
- **@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.
|
|
113
|
+
|
|
114
|
+
`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.
|
|
115
|
+
|
|
116
|
+
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.
|
|
117
|
+
|
|
118
|
+
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.
|
|
119
|
+
|
|
120
|
+
Migration: decide once whether you want the transactions. See the codemod note.
|
|
121
|
+
|
|
122
|
+
**`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).
|
|
123
|
+
- **@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.
|
|
124
|
+
|
|
125
|
+
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.
|
|
126
|
+
|
|
127
|
+
`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.
|
|
128
|
+
|
|
129
|
+
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.
|
|
130
|
+
|
|
131
|
+
**`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).
|
|
132
|
+
- **@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.
|
|
133
|
+
|
|
134
|
+
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.
|
|
135
|
+
|
|
136
|
+
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.
|
|
137
|
+
|
|
138
|
+
`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.
|
|
139
|
+
|
|
140
|
+
`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.
|
|
141
|
+
|
|
142
|
+
**`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).
|
|
143
|
+
|
|
144
|
+
### Added
|
|
145
|
+
|
|
146
|
+
- **@voltro/cli** — `voltro doctor`'s `nullable-column-a-mutation-cannot-clear` rule now resolves FRAMEWORK and first-party PLUGIN tables.
|
|
147
|
+
|
|
148
|
+
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.
|
|
149
|
+
|
|
150
|
+
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.
|
|
151
|
+
|
|
152
|
+
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.
|
|
153
|
+
- **@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.
|
|
154
|
+
|
|
155
|
+
**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.
|
|
156
|
+
|
|
157
|
+
**`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.
|
|
158
|
+
|
|
159
|
+
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.
|
|
160
|
+
|
|
161
|
+
**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.
|
|
162
|
+
- **@voltro/cli, @voltro/plugin-sentry** — `voltro build` uploads source maps to Sentry and then removes them from the output.
|
|
163
|
+
|
|
164
|
+
`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:
|
|
165
|
+
|
|
166
|
+
- **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.
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
web: { sourcemaps: { mode: 'hidden', upload: { org: 'acme', project: 'web' } } }
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
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.
|
|
173
|
+
|
|
174
|
+
**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.
|
|
175
|
+
|
|
176
|
+
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.
|
|
177
|
+
|
|
178
|
+
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.
|
|
179
|
+
|
|
180
|
+
### Fixed
|
|
181
|
+
|
|
182
|
+
- **@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.
|
|
183
|
+
|
|
184
|
+
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.
|
|
185
|
+
|
|
186
|
+
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.
|
|
187
|
+
|
|
188
|
+
`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.
|
|
189
|
+
|
|
190
|
+
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".
|
|
191
|
+
|
|
192
|
+
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.
|
|
193
|
+
- **@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.
|
|
194
|
+
|
|
195
|
+
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.
|
|
196
|
+
|
|
197
|
+
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.
|
|
198
|
+
|
|
199
|
+
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".
|
|
200
|
+
|
|
201
|
+
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.
|
|
202
|
+
|
|
203
|
+
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.
|
|
204
|
+
|
|
205
|
+
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.
|
|
206
|
+
|
|
207
|
+
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`.
|
|
208
|
+
- **@voltro/kv** — Closing a RESP client can no longer take the process down.
|
|
209
|
+
|
|
210
|
+
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.
|
|
211
|
+
|
|
212
|
+
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.
|
|
213
|
+
|
|
214
|
+
`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.
|
|
215
|
+
- **@voltro/plugin-broadcast** — A subscription's teardown can no longer kill the process.
|
|
216
|
+
|
|
217
|
+
`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.
|
|
218
|
+
|
|
219
|
+
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`.
|
|
220
|
+
|
|
221
|
+
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.
|
|
222
|
+
|
|
223
|
+
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.
|
|
224
|
+
- **@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:
|
|
225
|
+
|
|
226
|
+
- 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.
|
|
227
|
+
|
|
228
|
+
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.
|
|
229
|
+
- **@voltro/runtime, @voltro/cli** — A `defineStream`'s declared `guards:` are enforced — before the executor runs.
|
|
230
|
+
|
|
231
|
+
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.
|
|
232
|
+
|
|
233
|
+
Two defects, and the second survives fixing the first.
|
|
234
|
+
|
|
235
|
+
**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.
|
|
236
|
+
|
|
237
|
+
**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.
|
|
238
|
+
|
|
239
|
+
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.
|
|
240
|
+
|
|
241
|
+
---
|
|
242
|
+
|
|
243
|
+
## [0.57.0] — 2026-08-30
|
|
244
|
+
|
|
245
|
+
### Added
|
|
246
|
+
|
|
247
|
+
- **@voltro/client, @voltro/web** — <!-- `apiSurface: compatible` rather than `additive`: the golden line for `useFormBinding` CHANGED rather than being added, because the second parameter WIDENED from `string` to `string | ((values) => string)`. A widened parameter cannot turn a call site that compiled into one that does not — every existing `useFormBinding('app', 'todos.create', …)` still satisfies the union. The `@voltro/web` aggregate re-exports it, which is why it is listed: its golden describes a surface defined in `@voltro/client`, and regenerating the source package's golden does not regenerate the aggregate's. -->
|
|
248
|
+
|
|
249
|
+
**`useFormBinding`'s mutation may now be a FUNCTION of the current values.**
|
|
250
|
+
|
|
251
|
+
```tsx
|
|
252
|
+
useFormBinding('app', (v) => (v.repeats ? 'calendarRecurringEvent.create' : 'calendarEntries.create'), { defaults })
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Some forms only learn their target from what the user does: a calendar entry becomes a recurring series the moment "repeats" is ticked, and the series mutation takes eleven more fields. Neither way out worked. Deriving the tag outside the binding is not available — the values belong to the binding and do not exist before it — and re-mounting with a different tag resets the engine, throwing away everything the user typed.
|
|
256
|
+
|
|
257
|
+
The schema in force follows the tag, so `fields` and validation always match what will actually be submitted, and the values survive the switch because the engine is constructed once and never rebuilt.
|
|
258
|
+
|
|
259
|
+
Two details that are decisions, not accidents. On the first render there are no values yet, so the function is called with the raw `defaults` — not the SEEDED ones, because seeding reads the schema and the schema comes from the tag being resolved. And the accessibility ids are pinned to the first resolved tag: they are DOM ids, and letting them move on the keystroke that flips the branch would remount every field, taking the focus and the caret with it — the opposite of what "switch the tag without losing the values" is for.
|
|
260
|
+
|
|
261
|
+
**Also: every hand-made `errorBus` in the client's own tests now uses the real `RpcErrorBus`.** Seven of them were `{ emit: () => {} }`, and they all broke the moment the bus grew a second channel — which is the cheap version of the failure a paraphrasing fake produces. A fake standing in for a seam that has a shared implementation should import it.
|
|
262
|
+
- **@voltro/plugin-audit** — **`redact*: 'shape'` now buckets a string's length below a floor: `string(<16)` rather than `string(6)`.**
|
|
263
|
+
|
|
264
|
+
The mode exists so an audit row can end a diagnosis the payload itself cannot be shown for — the motivating case is a 113-character value where 44 were due, and the length IS the whole finding. A length is a small disclosure at that size and not a small one at six: a TOTP reported as `string(6)`, or a four-digit PIN as `string(4)`, tells a reader with log access exactly what shape to try.
|
|
265
|
+
|
|
266
|
+
The floor is where the two stop overlapping. Nothing this mode is FOR lives under it, so the default costs no diagnostic value. `auditPlugin({ redactionLengthFloor })` moves it — `0` for a deployment whose audited payloads are ids and tokens and every character of length is worth having, higher for one holding short human-entered secrets.
|
|
267
|
+
|
|
268
|
+
Two details that are decisions. **An empty string stays exact** (`string(0)`): "the field arrived empty" is a real diagnosis, an empty string is not a secret, and collapsing it would hide the one short length worth seeing. And the floor applies to described KEYS as well as values — a key that failed the declared-field test is a key carrying data, so its length is the same disclosure.
|
|
269
|
+
- **@voltro/cli** — **`voltro logs --url` and `voltro traces --url` can read a deployed app now — and when they cannot, they say so instead of blaming your filters.**
|
|
270
|
+
|
|
271
|
+
Two defects, and the second is the cheap one that costs the most time.
|
|
272
|
+
|
|
273
|
+
**The flag existed for the case it could not serve.** `--url` addresses a deployed app; a deployed app runs `voltro serve`, which mounted neither `/_voltro/inspect/logs` nor `/_voltro/inspect/traces`. Our own docs name both commands as the way to debug a running app. A reader with cluster access can route around it by reading pod stdout — a customer on a hosted Voltro cannot, because inspect *is* the access.
|
|
274
|
+
|
|
275
|
+
`inspect: { logs, traces }` in `app.config.ts` arms a bounded in-process ring (`VOLTRO_INSPECT_LOGS` / `VOLTRO_INSPECT_TRACES` override per deployment, taking a size or `on`/`off`). **Off by default**, because a ring is memory on every replica for data most deployments already collect from stdout — the argument against a second span sink was an argument for a default, not for an absence. The endpoint handlers are the ones `voltro dev` already calls, and the span FILTER that decides what reaches the ring is now shared rather than copied: `traceRingSink.ts`, because a copied filter is a second definition nothing keeps in step, and this one is what stops `_voltro_traces` from recording its own INSERTs.
|
|
276
|
+
|
|
277
|
+
**"Found nothing" and "looked nowhere" read the same.** An empty result printed `no records matched the given filters.` — a claim that your filters were too narrow — with the truth in a `#` comment line below it, which is the first thing a pipe or a `| grep` drops. The invited reaction is to widen `--tail`, drop `--level`, extend `--since`, and get the identical sentence every time. When every target failed, the headline now says `NOTHING WAS SEARCHED` and carries the reason the endpoint gave — which the CLI was collapsing to `HTTP 404` and throwing away.
|
|
278
|
+
|
|
279
|
+
The 404 for these two also stopped saying "this endpoint is served by `voltro dev`". That was true and is now false in the one direction that matters: it tells a reader to stop looking when one config field stands between them and the answer. It names the switch.
|
|
280
|
+
- **@voltro/cli** — **Two `voltro doctor` rules for defects that are completely decidable from the source — and were reported because nothing decided them.**
|
|
281
|
+
|
|
282
|
+
**`executor-builds-an-undeclared-field`.** Since 0.37 a descriptor's `output` IS the serializer, so a key the struct does not declare is stripped on the way out. Nothing errors and nothing warns: every reader downstream gets `undefined` and renders a blank. A deployment hit it twice in one session — a list executor built two fields, declared neither, and every picker showed empty options.
|
|
283
|
+
|
|
284
|
+
**`nullable-column-a-mutation-cannot-clear`.** A nullable column written through an input field that does not accept `null` can be set once and never emptied. The mutation succeeds, the column keeps its old value, and the user's second attempt looks like a UI bug. A deployment found five real cases only after a formatter happened to wrap one of their own line-based checks — which is the argument for doing it here: their rule was blind to exactly the inputs small enough to fit on one line, and stayed blind silently.
|
|
285
|
+
|
|
286
|
+
Both are ADVISORY, like everything in that scan, and both REFUSE rather than guess. The refusals are where the work went:
|
|
287
|
+
|
|
288
|
+
- A spread on either side of the output comparison means the key set is not knowable; reporting the visible half would name the field the author can already see and miss the ones they cannot. - An `output` that is a named schema rather than a literal struct is UNJUDGEABLE, not empty — the second reading makes every field a finding. - Declared names are flattened across nesting, because a literal inside `versions: Schema.Array(Schema.Struct({ version, op }))` builds names the contract declares one level in. - A literal that shares NO name with the declared output is not the output: a declarative query executor returns `{ descriptor: { table, order, take } }`, which the framework runs. - A table declaration the scan cannot find produces no finding, because "this table has no nullable columns" and "I could not look" lead to opposite conclusions.
|
|
289
|
+
|
|
290
|
+
The last two exist because the first version of the output rule was measured against four real apps and flagged two files, both wrong — one query descriptor and one nested row. Both are pinned as regression cases, each beside a falsification proving the fix did not turn into accepting anything. After them: 156 real files, zero findings; and injecting an undeclared field into a real fixture makes the rule name exactly that file.
|
|
291
|
+
|
|
292
|
+
### Fixed
|
|
293
|
+
|
|
294
|
+
- **@voltro/runtime, @voltro/cli** — **A schedule run whose process died suppressed every later firing of that schedule for six hours, on every replica, and logged `overlap skip` while doing it.**
|
|
295
|
+
|
|
296
|
+
The cross-instance overlap guard asks whether an occurrence is already running anywhere in the cluster. Its only evidence was the run row's `firedAt` against a constant, because that is all a row carried — and a constant that must never cut off a live run has to be longer than the longest one. So a pod killed mid-run left a `running` row that read as a live occurrence until the window expired. Against a half-hourly cron that is twelve missed occurrences from one restart.
|
|
297
|
+
|
|
298
|
+
`_voltro_schedule_runs` now carries **`heartbeatAt`**, bumped while the handler is in flight, and liveness is measured rather than assumed: silent for three beats is dead. A row with no beat — one written by a process still on the previous version during a rolling deploy — is dated by `firedAt` and bounded by the schedule's own `maxRuntimeMs`, because the watchdog records anything past it as `failed`. Same field and the same three cases as `_voltro_replace_in_progress.heartbeatAt`; it is deliberately not a lease, and nothing takes ownership of the run.
|
|
299
|
+
|
|
300
|
+
**The log line now carries what it measured.** `schedule: overlap skip` with `scope: 'cluster'` described two states — a peer genuinely working, and a corpse holding the schedule shut — while the word "overlap" asserted the first, and telling them apart meant reading the scheduler's source. It reports `heldBy` and `lastSeenMs` now.
|
|
301
|
+
|
|
302
|
+
**Two more defects found in the same function.** The query took the 200 most recent `running` rows across EVERY schedule and matched the name in JavaScript, so past 200 concurrent rows a live run falls out of the page and the guard silently stops guarding; the name is in the predicate now. And a `finishRun` whose update matched nothing returned in silence — the row stays `running`, which is exactly the state that suppresses the schedule, so the one write whose absence stops a cron had no failure signal at all.
|
|
303
|
+
|
|
304
|
+
`scheduling.scheduleHeartbeatMs` (env `VOLTRO_SCHEDULE_HEARTBEAT_MS`, default 30 s) is the cadence. Both boot paths pass the resolved value; a run shorter than one interval writes no beat and costs nothing.
|
|
305
|
+
- **@voltro/cli** — **`voltro dev` crashed on boot for every API-only app.**
|
|
306
|
+
|
|
307
|
+
`tryRunWebDev` asks `loadConfig` for the app's WEB config and gets `null` whenever there isn't one — which is every `type:'api'` project, and any config that fails to import. That null is expected and handled: the function returns `{ ran: false }` and the caller boots the api alone.
|
|
308
|
+
|
|
309
|
+
The `health` block's resolution was inserted ABOVE that guard, so the first thing the function did was read a field off the null:
|
|
310
|
+
|
|
311
|
+
```
|
|
312
|
+
TypeError: Cannot read properties of null (reading 'health')
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
Two things about how it stayed invisible are worth more than the one-line fix.
|
|
316
|
+
|
|
317
|
+
**`tsc` had no chance.** The read is written `(config as { health?: … }).health`, and a cast on a maybe-null value erases exactly the nullability the compiler would have refused. The guard has no type-level protection, so it needed a behavioural one — `webDevNoWebConfig.test.ts` asserts the CONTRACT (`{ ran: false, exitCode: 0 }`, no throw) rather than the line order, and fails with the production error when the guard is moved back.
|
|
318
|
+
|
|
319
|
+
**And every symptom pointed somewhere else.** Eight integration files went red together, and all of them reported a TIMEOUT — `both replicas must boot`, at 300 s — because what a fixture observes is a process that never starts listening. A crash at boot and a slow machine are the same observation from outside, and the second is the expensive diagnosis to start with.
|
|
320
|
+
- **@voltro/plugin-billing** — **A production boot with no `STRIPE_SECRET_KEY` silently selected the MOCK billing provider. It says so now.**
|
|
321
|
+
|
|
322
|
+
`resolveProvider` picks `stripe` when the key is present and `mock` when it is not. The zero-config default is right — add the plugin, see checkout flows, no account needed. What was wrong is how it failed later.
|
|
323
|
+
|
|
324
|
+
`STRIPE_SECRET_KEY` is a deployment variable, and one that silently stops being set is a routine event: a rotated secret, a typo in a values file, a CI variable nobody ever created. When that happens, an app that has been charging customers keeps answering every charge, subscription and invoice call SUCCESSFULLY, reaches nobody, and leaves nothing in the log to find afterwards.
|
|
325
|
+
|
|
326
|
+
A mock chosen by ABSENCE, under `NODE_ENV=production`, now warns — naming the consequence rather than the selection, and both ways out (set the key, or declare `provider: 'mock'` so the reader knows it was a decision). An explicitly declared mock stays silent: warning about a choice somebody made trains the reader to ignore the line that matters.
|
|
327
|
+
|
|
328
|
+
`NODE_ENV` decides only whether it is worth SAYING, never what is selected. Both environments resolve the same provider — an env-conditional behaviour is the shape a staging box sails past, and it is pinned as its own case.
|
|
329
|
+
- **@voltro/cli, @voltro/protocol** — **A plugin's `declaredEnv: [{ required: true }]` now ABORTS THE BOOT when the value does not resolve. Nothing checked it before.**
|
|
330
|
+
|
|
331
|
+
The field has existed for a long time and ten-odd plugins fill it in. Three consumers read it — the env manifest, `voltro env`, and the secret MINT (which only handles `generate`, a secret that is OURS to invent). None validated `required`. Its own doc said "declaration only — metadata, not a read path", while `PluginEnvVar.generate`'s said "in production a missing secret refuses the boot": true for the minted kind, false for the one that matters — a third-party credential nobody can invent.
|
|
332
|
+
|
|
333
|
+
Declared, documented, read by three consumers, enforced by none. Every surface read as wired.
|
|
334
|
+
|
|
335
|
+
Every required entry now resolves before any plugin activates, on BOTH api boot paths, and a failure names the PLUGIN — the variable name alone appears in no file the reader owns, so without the attribution the next step is a grep through `node_modules`.
|
|
336
|
+
|
|
337
|
+
**A `secret: true` variable resolves through the configured secrets backend**, not just `process.env`, because that is where its value lives when one exists. A backend that cannot answer therefore fails the boot too — reported as `unreadable` rather than as an absent variable, because those are different facts and one of them sends the reader to the wrong file. A non-secret variable never touches the backend, so a misconfigured vault cannot fail variables that never used it.
|
|
338
|
+
|
|
339
|
+
Merging, when two plugins declare the same variable: `required` takes the stricter (a plugin that can live without the value must not weaken one that cannot), `secret` takes `true` (over-classifying hides something that did not need hiding; under-classifying prints somebody's credential into a dashboard — it is the only direction with a failure mode on one side). An app that declares the variable in `app.config.ts` overrides the plugin entirely.
|
|
340
|
+
|
|
341
|
+
**Upgrade note.** One first-party declaration is affected: `plugin-notifications`' `VOLTRO_VAPID_PRIVATE_KEY`, declared `required: true` and reachable only when web push is enabled. `voltro dev` mints it, as before; a production `voltro serve` with web push and no key now refuses to start, which is what that declaration has always said.
|
|
342
|
+
- **@voltro/client** — **A consecutive-failure ceiling was counting a tab's LIFETIME, and the screen it stranded had nothing left to move it.**
|
|
343
|
+
|
|
344
|
+
`wireAuthRefresh` rebuilds the transport when a call comes back `Unauthenticated`, and `maxConsecutive` bounds it: after that many refreshes *with no successful call in between*, it stops, because at that point the credential is not stale — it is refused. "With no successful call in between" was carried by `AuthRefreshHandle.noteSuccess()`, a method the HOST had to call, and the only host never called it.
|
|
345
|
+
|
|
346
|
+
The consequence is not the loop the ceiling guards against. It is the opposite: three token rotations over an afternoon exhaust the budget, and from then on the tab never refreshes again. Every later expiry rejects every subscription the page opens, a rejected entry is terminal for its transport by design, so the screen waits on a spinner with nothing left to wait for.
|
|
347
|
+
|
|
348
|
+
`RpcErrorBus` has a SUCCESS channel now (`onSuccess` / `emitSuccess`), emitted by the same pipeline that emits the errors — a snapshot in the subscription cache, a settled mutation, a settled action. `wireAuthRefresh` subscribes to it itself, so no host has to remember anything; `noteSuccess()` stays for a host with a better signal of its own and is no longer what the policy depends on.
|
|
349
|
+
|
|
350
|
+
**`useSubscriptionHealth(apiName)` is new**, and it exists because the state it reports cannot be derived from any single hook. A refused subscription never retries, so there is no second error to react to, and `SubscriptionFailed` presents `data: undefined` — the value every reading layer derives `loading` from. A wrapper hook passing `{ data, loading }` through therefore turns a refusal into a permanent skeleton, and passing those two through is the natural shape to write.
|
|
351
|
+
|
|
352
|
+
const { healthy, failed } = useSubscriptionHealth('app') if (!healthy) return <Banner tags={failed.map((f) => f.tag)} />
|
|
353
|
+
|
|
354
|
+
Keyed by TAG, not counted — two failures of one call are one broken thing — and scoped to the api's runtime, reset when that runtime is rebuilt: carrying a failure across a transport swap would report a call as broken that has not been tried since.
|
|
355
|
+
- **@voltro/plugin-notifications, @voltro/cli** — **About one generated VAPID key in 256 was 31 bytes, and a 31-byte key can never send a push.**
|
|
356
|
+
|
|
357
|
+
`createECDH('prime256v1').getPrivateKey()` returns the private scalar the way OpenSSL stores a BIGNUM — minimal length, leading zero bytes stripped. A P-256 scalar is a fixed-width field element, so a draw whose high byte is zero comes back 31 bytes (measured: 82 in 20 000), and two zero bytes gives 30 (1 in 20 000). Both generators had it: the plugin's `generateVapidPrivateKey`, and the `p256` branch of `voltro dev`'s secret mint.
|
|
358
|
+
|
|
359
|
+
The consumers were already right, which is what made this survivable and also what hid it. `vapidPublicKeyFor` REFUSES a non-32-byte scalar — a wrong VAPID key must fail loudly rather than at the first delivery. But the refusal happens inside `sendWebPush`'s `try`, so it came back as `{ kind: 'failed', status: 0 }`: the same outcome as the push service being unreachable. Nothing named the key. And the minted value is written to `.env.local` and stays, so an affected project's push sending is broken permanently and looks like a network problem forever.
|
|
360
|
+
|
|
361
|
+
Both generators left-pad now, and both packages assert the width on their OWN generator — deliberately not one reading the other's source, because a cross-package guard does not run when the suite is filtered to a single package.
|
|
362
|
+
|
|
363
|
+
**Worth recording is how nearly it was written off.** It surfaced as a single test failing in a full run and passing alone, roughly 1.6 % of the time — the exact signature of a saturated machine. Isolation "cleared" it twice. What settled it was not another isolated run but generating 20 000 keys and counting the widths: 0.41 % at 31 bytes is not noise, it is 1/256, and that number names its own cause.
|
|
364
|
+
- **@voltro/runtime, @voltro/cli, @voltro/client, @voltro/kv, @voltro/plugin-broadcast** — **Seven reported points, all from one round. Every one of them is information the process already had, not reaching the person who needed it.**
|
|
365
|
+
|
|
366
|
+
**A `SqlError` reaching the client as a Defect carried no detail.** The log line said `Failed to execute statement` — no table, no column, no operation — while the trace exporter had written `db.query.text` for the same statement. The handler-failure logger has walked the driver cause for a long time; the DEFECT frame did not, so a failure that reaches the client without passing through that logger left an operator with nothing. It leads with the driver's own message now and points at `_voltro_traces`.
|
|
367
|
+
|
|
368
|
+
**A defect that repeats IDENTICALLY ends the resumable stream.** The reconnect loop is built for a transport drop, and a transport drop does not repeat itself verbatim; a broken statement does. So one broken query spent the whole `maxReconnects` budget, and the user watched a spinner and then read "connection lost" — describing the opposite of what happened. TWICE, not once: the first failure is genuinely ambiguous, the second identical one is not. Also documented: `reconnects` is the RUN TOTAL and `maxReconnects` bounds CONSECUTIVE reconnects without progress, so "attempt {reconnects} of {maxReconnects}" renders `5/2`.
|
|
369
|
+
|
|
370
|
+
**`voltro build` reported a config that failed to LOAD as "no web app found".** It used the wrapper that discards the import error, so an `app.config.ts` that IS an api and simply threw came out as a claim about the app's TYPE. The message now distinguishes the two, prints the error, and names both types.
|
|
371
|
+
|
|
372
|
+
**The `subscriber-effect-without-once` rule found one of four.** The helper name matched by EQUALITY, so `notifyAbsenceRequested` fell through while `notify` reported — a prefix now. And it read only the handler body, so a write one call level down inside the same file was invisible; the file is already parsed. What it still cannot see (a write in another module) is SAID: the report prints how many subscribers declare no `once:` against how many the rule found an effect in, because "1 file(s)" read as an inventory.
|
|
373
|
+
|
|
374
|
+
**`voltro dev` had no probe surface on a web app**, and `/internal/*` did not 404 — it fell into the page router. An SPA shell answered 200 with HTML, a loader redirect answered 303, an auth guard answered 303 to `/login`; a kubelet reads all three as PASS. One of those pages had a loader calling the api, so the WEB pod's readiness hung on the API's reachability once per probe interval. All three boot paths now answer through one matcher, before routing — and `health: { path, liveness, readiness }` in `app.config.ts` makes the paths and the answers the app's, for both app types. "Ready" is a dependency ping for an api and its opposite for a screen that must keep showing its last frame.
|
|
375
|
+
|
|
376
|
+
**Every hot reload ended as a crash.** ioredis emits `error` on an EventEmitter, and node turns an unhandled `error` event into an uncaught exception — so a client with no listener made every close with commands in flight a process crash. Functionally harmless (the supervisor restarts) and every reload read as a crash loop in Kubernetes. Both `@voltro/kv` and `@voltro/plugin-broadcast` register one on every client. The watcher also ignores a tool's scratch file (`vitest.config.ts.timestamp-…mjs`), which was restarting the app twice per test run.
|
|
377
|
+
|
|
378
|
+
**A row filter's resolution is now reported.** Between "refuses everything" (which an older version did, loudly) and "applies nothing" there was nothing to see: every read would widen and no log, test or `doctor` would say so. The framework says so the FIRST time a filter resolves on a live read path — a fact from the running system rather than an echo of the declaration. And a new advisory `doctor` rule reports a handler that hand-writes a predicate on a table the registered filter already covers: never a leak (two identical predicates ANDed are as tight as one), but an authorization rule maintained in places the framework already knew about.
|
|
379
|
+
- **@voltro/client** — **A mutation, workflow start or upload fired from a mount effect hit the client boot window and failed with the stub's message. Only `useAction` waited it out.**
|
|
380
|
+
|
|
381
|
+
Between the first client commit — children render against the loading stub so `hydrateRoot` can adopt the SSR DOM — and the moment the supervisor resolves the real rpc client, `handle.client` is a proxy that throws on any access. Measured in real chromium at ~15–30 ms, and wider in an app that mints a token in `authHeaders` before connecting. `useEffect(() => { mutate(...) }, [])` is the ordinary shape that lands inside it.
|
|
382
|
+
|
|
383
|
+
`useAction.run` was fixed for exactly this, with a unit test and a browser measurement. `useMutation.mutate`, `useWorkflow`'s five callbacks and `useUpload.upload` had the identical defect for as long, and the failure was worse than a delay: the stub's `not-yet-resolved api` DISPLACED whatever the real outcome would have been, so the message a developer read described our plumbing rather than their call.
|
|
384
|
+
|
|
385
|
+
All four seams go through one `awaitResolvedApi` now, and each reads the handle from a ref at CALL time. A `useCallback` dep list cannot solve this however correct it is — the callback a mount effect fires was built on the first render, before the re-render that would rebuild it.
|
|
386
|
+
|
|
387
|
+
`useMutation` waits BEFORE staging optimistic patches, not just before the network call: the stub carries a different `cache` instance, so patches staged against it would land in a cache no live subscription reads and then be reverted against the wrong one.
|
|
388
|
+
|
|
389
|
+
**The rule is asserted over the SET** (`bootWindowSeams.test.ts`), because a per-hook test can only demonstrate one member. A fifth imperative hook inherits it by being written rather than by being remembered — which is precisely what did not happen the first time.
|
|
390
|
+
- **@voltro/cli** — **The boot's `reactivity:` line reported what the dialect COULD run, not what was running — so the deployment with no cross-replica fan-out was the one told it had none missing.**
|
|
391
|
+
|
|
392
|
+
`wireBroadcastBus` derived it as `postgres || mysql || mariadb`. A dialect is capable of a native transport; that is not the same as one being started. With `CDC=0` — which any deployment whose database grants no REPLICATION privilege has to set — the change reader never runs, and the boot still announced
|
|
393
|
+
|
|
394
|
+
reactivity: cross-instance via native binlog CDC (mariadb)
|
|
395
|
+
|
|
396
|
+
The crooked sentence is the smaller half. Without a broadcast plugin this line is the ONLY thing said about cross-replica reactivity, and the branch it displaced is the WARNING that there is none. It is also the line our own guidance names as the check for whether the native-CDC amplification applies to you, and there it answered yes where the truth was no.
|
|
397
|
+
|
|
398
|
+
It reads `store.changeScope` now — `'fleet'` exactly when change capture is running, and the same value `remoteChangesVisible` takes two calls later, so the honest signal was already in scope. Derived rather than enumerated: a sixth dialect with a native transport joins by itself, and an app that turned capture off because every table is `.nonReactive()` is covered without anyone remembering that path exists.
|
|
399
|
+
|
|
400
|
+
The enumeration was also wrong in the other direction — **mssql (Change Tracking) was absent**, so a deployment running a real cross-instance transport was being warned it had none.
|
|
401
|
+
|
|
402
|
+
And the warning that fires in the CDC-off case names the cause instead of the dialect. The old fallback told a mariadb operator to "use ... mariadb (binlog CDC)", which reads as boilerplate to the one person who needed to act on it.
|
|
403
|
+
- **@voltro/runtime** — **A row-filtered table reached through `.with(...)` is narrowed now, where it used to be refused — and before that, served unfiltered.**
|
|
404
|
+
|
|
405
|
+
An eager load resolves BELOW the seam that AND-merges the filter onto a read's base table: the stores expand the eager tree themselves, the memory store by recursing through its own raw read and the SQL stores by folding the relation into one join or JSON aggregate. Measured when it was found: a filter restricting a relation target to the caller returned exactly the caller's row on a direct read and BOTH rows through `.with()` on the same data.
|
|
406
|
+
|
|
407
|
+
That shipped as a refusal, on the reasoning that the real fix meant applying the filter inside eager compilation across four dialect stores. **It is not that change.** Every eager branch already accepts a `where`, and both resolvers already honour it — the walker ANDs it into the relation's lookup descriptor, the JSON compiler emits it into the correlated subquery on all five dialects. So the middleware writes the filter where a caller could have written it, and every resolver applies it without knowing a row filter exists. One rewrite, six execution paths, no dialect emitter touched.
|
|
408
|
+
|
|
409
|
+
A caller's own `where` is kept and the filter goes UNDER it: a branch you narrowed stays narrower, and nothing a caller writes can widen the filter. Nested `.with(...)` is narrowed at every level.
|
|
410
|
+
|
|
411
|
+
**One case still refuses: a row-filtered `manyToMany` JUNCTION.** A branch `where` is a predicate on the relation's TARGET, so a filter narrowing the junction has nowhere to be expressed. Narrow and honest beats a rewrite that looks complete.
|
|
412
|
+
|
|
413
|
+
Verified on the walker (unit, in-memory store) AND on the JSON-aggregation path against a real postgres, because those are two separate implementations and the suites that cover them each mock the other's half.
|
|
414
|
+
- **@voltro/client** — **Two dev-time messages said something the code had not measured.**
|
|
415
|
+
|
|
416
|
+
**The store's equals-footgun warning blamed the selector.** It read *"the selector returns a NEW object each call"* — and the site cannot observe that: an identical state returns from the cache before the selector runs, so everything reaching the warning arrives with a CHANGED state. Shallow-equal-but-not- identical there has two producers, and the message asserted one of them:
|
|
417
|
+
|
|
418
|
+
- the selector BUILDS (`s => ({ a: s.a })`), or - the STORE wrote an equivalent new value — `set({ crumbs: [] })` on leaving a page and again on entering the next is two different empty arrays.
|
|
419
|
+
|
|
420
|
+
Both waste the render and both are fixed by `{ equals: shallow }`, so the advice was right while the diagnosis pointed at the wrong half — and a deployment reading the second case went looking at a selector that had been returning the same reference all along. The warning now MEASURES which one it is: it re-runs the selector on the previous state (dev only, at most once per store, on a selector required to be pure anyway) and says either "the selector builds" or "the store is writing an equal value".
|
|
421
|
+
|
|
422
|
+
**And a validation template rendered its own placeholder.** `interpolate` left an unfilled `{param}` in place, so a template whose parameter was missing put a literal `Mindestens {min}` into the interface. It reports the hole now and the caller falls through to the issue's own rendered sentence, which is complete by construction. The reported route is closed twice over: counting rules supply BOTH `count` (the name an i18n layer pluralises on) and `min` (the built-in template's), so a framework-derived issue cannot reach it; a hand-written annotation still can, and a raw placeholder is never the right answer.
|
|
423
|
+
- **@voltro/cli, @voltro/ai** — **`@voltro/ai`'s resumable-stream tables were declared, bounded, documented — and never added to the migration set.**
|
|
424
|
+
|
|
425
|
+
`dataStoreResumableStreamStore` writes to `_voltro_stream_events` and `_voltro_stream_state`. `frameworkTableAssembly.ts` imports six `@voltro/ai` tables and did not import these two, so `voltro db plan` answered *schema is up to date* while both were absent, and the first user's turn died inside the store rather than at boot.
|
|
426
|
+
|
|
427
|
+
They are assembled now for any app that DEPENDS on `@voltro/ai`, so all four schema-declaring paths plan them.
|
|
428
|
+
|
|
429
|
+
**The gate is the dependency, not a file convention, and that is the half worth knowing.** Every other flag in `FeatureMix` reads a filename — `*.agent.tsx`, `*.connection.ts` — and `dataStoreResumableStreamStore(ctx.store)` has none: it is an ordinary call, reachable from an action, a query, a workflow step. Gating on `agents` is the obvious guess and it is wrong, because an app can stream without declaring a single agent file. The manifest is the superset that CAN reach the store, it comes from the source tree, and every process in one deployment computes it identically — the constraint the declared set is under.
|
|
430
|
+
|
|
431
|
+
`loadDiscovered` now REQUIRES a `root` for the same reason: the flag is read from a manifest, so it cannot be derived from the file list, and a mix that quietly answered `false` because nobody passed a root would declare a smaller schema than the same tree declares elsewhere. An optional root would read exactly like one that was supplied.
|
|
432
|
+
|
|
433
|
+
**And the documentation said to do something that does not work.** The JSDoc and the docs page both instructed *"add them to the database barrel (or they ride along with a `*.agent.tsx` app)"* — both halves wrong, since discovery is file-based (`*.entity.ts`) and the store is not agent-only. That sentence had reached the shipped agent guide, so it was teaching every downstream coding agent a remedy with no effect on the plan. Corrected in the JSDoc, in both languages of the docs site, and regenerated.
|
|
434
|
+
|
|
435
|
+
---
|
|
436
|
+
|
|
42
437
|
## [0.56.0] — 2026-08-29
|
|
43
438
|
|
|
44
439
|
### ⚠ BREAKING
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voltro/plugin-postgis",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.58.0",
|
|
4
4
|
"description": "PostGIS plugin — postgres-native geometry/geography columns and spatial predicates (ST_DWithin, ST_Contains, ST_Intersects) for location-aware apps; declare GiST indexes via .expressionIndex(..., { kind: 'gist' }). Postgres-only; other dialects fail loud at schema emission.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"voltro",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"node": ">=24.0.0"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@voltro/database": "0.
|
|
36
|
+
"@voltro/database": "0.58.0"
|
|
37
37
|
},
|
|
38
38
|
"peerDependencies": {
|
|
39
39
|
"effect": "^3.22.0"
|