@voltro/plugin-audit 0.29.0 → 0.30.1
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 +448 -0
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -39,6 +39,454 @@ _Changes staged for the next release accumulate here (rolled up from
|
|
|
39
39
|
|
|
40
40
|
---
|
|
41
41
|
|
|
42
|
+
## [0.30.1] — 2026-08-09
|
|
43
|
+
|
|
44
|
+
### Fixed
|
|
45
|
+
|
|
46
|
+
- **@voltro/testing, @voltro/sql-mysql** — **One integration suite was never skip-guarded, and the guard it called silently ran it anyway.** `describeIfAvailable(label, dependency, probe, suite)` was called with three arguments in `sql-mysql`'s `fileMigrationLedger.mariadb` suite, so `probe` bound to the SUITE body: `await probe()` executed it at file scope, its `beforeAll` and `test`s registered outside any `describe` and ran unconditionally, and the `describe.skipIf` underneath registered an empty shell. The file therefore passed when MariaDB happened to be up and hard-failed with `SqlError: MysqlClient: Failed to connect` when it was not — the exact opposite of the clean skip it was written to have, and a red `pnpm test` for anyone without the docker stack.
|
|
47
|
+
|
|
48
|
+
`tsc` could not catch it: every dialect package's tsconfig `include` lists the src glob only, so `__tests__/` is not typechecked at all — the same gap that lets an incomplete parity fixture compile. So the arity is checked at runtime now, and `describeIfAvailable` throws a `TypeError` naming what it got instead of quietly running the suite. One of roughly twenty call sites was wrong; nineteen were right, which is why nothing looked off.
|
|
49
|
+
- **@voltro/cli** — **Four things `voltro doctor` knew and would not tell you.** All reported by a consumer, all measured rather than guessed.
|
|
50
|
+
|
|
51
|
+
**The authz list was reachable by no route at all.** The human view truncated at 20 (`… and 14 more`) and `--json` had no `authz` section — measured, its keys were `root · scannedFiles · … · serverOnly`. Reading findings 21..n meant allowlisting the first 20, re-running, and resetting the file: a loop to read a list the tool already had. `--json` carries `authz` now (`counts`, `guardVocabulary`, `allowlist`, and every `unchecked` finding, never truncated), and the elision line names both the command and the field. The same defect, one section over, is recorded in `serverOnly`'s own comment — "the field was MISSING from `--json` entirely" — so this is that lesson applied rather than re-learned. Both surfaces read ONE scan (`scanAuthzForRoot`), because two derivations of one scan is how two views come to disagree about what was found.
|
|
52
|
+
|
|
53
|
+
**The allowlist could not tell "reviewed and safe" from "debt".** Its header says `This is DEBT, not approval` — which is right, and which made it the wrong place for the other thing people legitimately need to record: an executor a human has read and found genuinely open, constrained by something the scanner cannot see. It was also the ONLY place, so the reporter resorted to comment blocks around groups of lines — a convention inside a file parsed line by line, which the next `--write-authz-allowlist` would have flattened without a word. A line is now either `<tag>` (debt, unchanged) or `<tag> reviewed=<why>`, the reason REQUIRED — `reviewed=` with no why is the claim without the evidence and is refused, since a bare tag is the honest alternative and always available. Doctor counts and prints the two apart, and `--write-authz-allowlist` preserves reviewed lines instead of downgrading them.
|
|
54
|
+
|
|
55
|
+
**A hint that named 41% of the files was not a hint.** One hand-roll finding listed 2 641 of 6 374 files, and the reporter skipped the whole section because of it — including the lines pointing at 5 and 13 files, which were worth acting on that day. Findings now print FEWEST files first, and a finding above both a share (20%) and a floor (50 files) prints its ADVICE without the enumeration, marked as a codebase-wide pattern. Both bounds matter: the share is what makes it a pattern, the floor keeps a small app — where "3 of 8 files" is a large share and a perfectly readable list — out of it. The paths stay in `--json`.
|
|
56
|
+
|
|
57
|
+
**A translation catalog that is never loaded said nothing.** `src/locales/{code}.ts` is imported by the web codegen only when the app declares `locales:`. Without that line the files are inert — no import, no provider, no error — and from the inside a catalog that is never loaded looks exactly like one that works. The reporter carried `de.ts` + `en.ts` in TWO apps for months, never wired, and measured that neither boot nor doctor mentioned it. Doctor now names the orphaned codes and offers both ways out: the exact `locales: [...]` line to paste, or delete the files (which is what they did). It deliberately does NOT report the reverse — a declared locale with no file already fails loudly at codegen, and a second, weaker voice for a problem that has a loud one is noise.
|
|
58
|
+
- **@voltro/cli** — **`encryptSteps` was derived twice, once per boot path.** Six hand-mirrored lines in `dev.ts` and in `serveApi.ts` — read the flow control off the definition, compare `=== true`, build the cipher, spread the result or nothing. They agreed today and nothing kept them agreeing, which is the shape that produced the `_voltro_outbox` error loop and every dev/serve scar in `packages/cli/CLAUDE.md`. The asymmetry a drift would produce here is the bad direction: step payloads encrypted under `voltro dev` and plaintext under `voltro serve`, with the declaration reading as protection in both.
|
|
59
|
+
|
|
60
|
+
`stepPayloadCipherOptions(definition)` is the one derivation now, and it is slightly better than either copy it replaced: the workflow NAME in the boot-refusal message comes from the resolved control rather than from a second argument, so the flag and the name it is reported under are the same object. `flowControlParity.test.ts` pins that both paths call it AND that neither re-derives `encryptSteps === true` inline.
|
|
61
|
+
- **@voltro/runtime, @voltro/cli, @voltro/workflow** — **`debounce` never ran. Neither did a `batch` that flushed on its timeout — and `batch` could not be started at all.** Three defects, one boundary, all found by a consumer who adopted flow control against a live API and measured `attempts: 13, collapsed: 14, runs: 0` on a debounced workflow that never produced a run.
|
|
62
|
+
|
|
63
|
+
**1 — the drainer re-entered the admission boundary it had just cleared.** A deferred start is judged twice on purpose: once on arrival, once when the drainer reconsiders the pending row. The second judgement is the one that ADMITS, and the drainer then started the workflow *through the facade* — deliberately, so a queued run takes exactly the code path an immediate one does. But the facade's start IS the arrival path, and arrival is where a deferring control defers. So the admitted start was deferred straight back into the row it came from: `collapsed` up by one, the row still pending, the engine never reached, one wasted pass per second, forever. `debounce` was 100% broken; `batch` was broken whenever it flushed on the timeout rather than by filling. `throttle` and `concurrency` survived only by an ordering accident — the re-entrant arrival happened to re-admit because the ledger row and lease are written *after* the start returns. The drainer's start now carries an internal `admitted` marker that skips the gate: it is the APPLICATION of a decision already made, and everything the arrival path would have done (pause, singleton eviction, the ledger row and lease, consuming the intents) the drainer does around it.
|
|
64
|
+
|
|
65
|
+
**2 — a `batch:` workflow rejected every caller's start.** The declaration contract is explicit and enforced: the workflow's own `payload` is `{ items: Schema.Array(Item) }` while callers `start()` it with a SINGLE item, declared as `batch.item`. `batch.item` was required, asserted at declaration time — and then dropped during resolution and read by nothing. So the facade validated the caller's single item against the batch shape and threw `WorkflowPayloadError: missing required field(s): items` before the gate was ever reached. `batch:` was unusable end to end. The item schema is now carried through and is what an arriving start is judged by; a drained batch is judged by the workflow's own schema.
|
|
66
|
+
|
|
67
|
+
**3 — the two halves of that boundary could be wired half-right, in both boot paths.** `startPayloadSchema` is pinned beside `admitStart` in `flowControlParity.test.ts` as a separate assertion, because passing one and not the other fails silently and differently.
|
|
68
|
+
|
|
69
|
+
**Why no unit suite could see any of this.** `admissionDrainer.test.ts` fakes `startAdmitted`; `workflowRuntime.test.ts` fakes `admitStart`. Each is a complete test of its own half, and the defect lived strictly between them — the same shape as this repo's dev/serve parity scars, one level down. `flowControlDrainRoundTrip.test.ts` wires a real gate to a real facade over a real in-memory store and drives all four deferring controls from arrival to run. It was written red: debounce and batch-timeout failed, throttle and concurrency passed, which is exactly the diagnosis. It asserts the pending row is CONSUMED rather than merely that a run eventually happened — a debounce that re-collapses twice on the way is still broken, and `admitted: 1` alone would not say so.
|
|
70
|
+
- **@voltro/cli** — **`_voltro_outbox` was polled every five seconds by apps that never had it created.** Reported by a consumer as a permanent `Table doesn't exist` loop — and, they noted, "a permanent error loop that buries the real ones". The table appeared zero times in `voltro db plan`, which was correct for the gate as written and wrong for what the boot actually does.
|
|
71
|
+
|
|
72
|
+
The two predicates had drifted. The table was created when the app declared at least one `*.outbox.ts` handler; the delivery worker was STARTED when at least one handler existed *including the framework's own* `voltro.webhook.emit`, which is registered whenever the app has a webhook surface. So an app with webhooks and no handler file got the worker, got `ctx.outbox`, and got a `ctx.webhooks.emit` inside a mutation writing through a table that was never planned. The 0.30.0 note claiming such an app "keeps the in-memory callback" described the intent, not the code.
|
|
73
|
+
|
|
74
|
+
Widening the table's gate to match could not work: the webhook surface includes outgoing webhooks declared on EVENTS, and the migration path detects features by walking filenames, so it cannot see them without loading the app's modules. **`_voltro_outbox` and `_voltro_outbox_attempts` are therefore created for every sql app now** — small, dialect-neutral, empty unless something enqueues, the same trade `_voltro_wakeups` and the storage tables already take. Two empty tables against a class of divergence that has no symptom until production.
|
|
75
|
+
|
|
76
|
+
It also fixes `voltro migrate`, which never passed the flag at all — and, in the same sweep, `voltro migrate` never detected `*.connection.ts` either, so the credential-vault tables were created by `voltro dev` / `voltro db apply` and silently not by `voltro migrate`. Migrate carried its own copy of the feature-detection walk; it calls the shared `detectFeatureMix` now, so there is one walk and one answer.
|
|
77
|
+
|
|
78
|
+
**And the delivery worker no longer prints a wall.** An identical drain failure is reported once at `warn`, escalated ONCE to `error` after ~a minute of consecutive identical failures ("this is not transient. Enqueued effects are NOT being delivered"), and then suppressed until the cause CHANGES or it recovers — recovery says so, with how many passes it was broken for, because a failure that stopped being logged and one that got fixed must not read alike.
|
|
79
|
+
|
|
80
|
+
**The 0.30.0 codemod note said the opposite, and it is corrected in place.** It told users that an app declaring no `*.outbox.ts` "falls back to the in-memory callback" — the intent, not the code. Normally a note under a published version cannot be revised (`selectCodemods` filters `from < version <= to`, so anyone who has already crossed 0.30.0 will never see a correction, which is why corrections are re-issued under a version nobody has reached). That rule is about a changed *instruction*, where someone who acted on the old one has to hear the new one. This is a false statement of fact with nothing attached for a reader to undo — the table is created by the declarative differ on the next `voltro dev` boot or `voltro db apply` — so the alternative was leaving every future 0.29 → 0.31 upgrader a sentence that is simply untrue.
|
|
81
|
+
- **@voltro/web** — **The second half of the SSR `useId` divergence: the client boot rendered a sibling to the app that the server did not.** `VoltroRuntimeProvider` renders `{children}` alongside a chrome slot (`chromeMounted ? <>…overlays…</> : null`), while the server rendered the page tree with no boot wrapper at all. A parent with two children forks React's tree-id path; a parent with one does not — so this shifted every `useId` in the app exactly as the router provider did, one level further up.
|
|
82
|
+
|
|
83
|
+
It is filed separately from the router fix because the two are independent and **each is independently fatal**: measured on a pristine tree, fixing only the router still fails and fixing only this still fails. Both paths now render `RootChromeSlot`, one component owning the arity, with `chrome: null` on the server.
|
|
84
|
+
|
|
85
|
+
The irony is worth keeping, because it is what made the defect invisible: the `chromeMounted` gate was added so the first client render matches the server DOM. It does — and that is exactly why hydration SUCCEEDS, React keeps the server markup, nothing throws, and the only casualty is the ids. The gate did not cause the fork; the slot forks whether or not it renders anything.
|
|
86
|
+
|
|
87
|
+
**What must not change without re-measuring:** the number of forks above the page on each side. Nesting DEPTH is free — measured, any number of single-child providers above the router keeps ids aligned — but adding a sibling to the app on one path only (an overlay, a portal host, a second root element) reintroduces this. `ssrTreeIdParity.test.tsx` holds it in jsdom; `scripts/browser-ssr-hydration-ids.mjs` holds it in a real chromium against a real `voltro dev`.
|
|
88
|
+
- **@voltro/web** — **Every `useId` in an SSR app mismatched on hydration, on every page, since the route announcer was added.** Reported by a consumer against 0.30.0 and 0.29.0 with the two `dist` bundles read side by side — not a regression, and not something any of our tests could see.
|
|
89
|
+
|
|
90
|
+
The router provider took ONE child on the server (`createElement(RouterContext.Provider, { value }, tree)`) and TWO on the client (JSX with `{content}` and the announcer, which compiles to `jsxs` with a 2-element array). React derives `useId` from the path of ARRAY SLOTS down to a fiber: a single child does not fork, a 2-element array forks and places the subtree at index 0. So the entire tree below the router sat at a different tree id on the two sides, and every id generated beneath it differed.
|
|
91
|
+
|
|
92
|
+
**The failure is unusually quiet, which is why it lasted.** The second child is `announcerReady ? <RouteAnnouncer/> : null`, and `announcerReady` starts `false` — so the first client pass renders `null`, the DOM matches, hydration SUCCEEDS, and React keeps the server markup and merely warns about the attributes. Nothing breaks visibly; the console fills with `A tree hydrated but some attributes … didn't match` for every component that calls `useId`. With Radix that is every tooltip, dialog, accordion, collapsible, select and label.
|
|
93
|
+
|
|
94
|
+
**There were TWO such divergences, not one, and each is independently fatal.** The router provider is the one the reporter found by reading the bundles; one level further up, the client's `VoltroRuntimeProvider` rendered the app ALONGSIDE a chrome slot (`{children}{chromeMounted ? … : null}`) while the server rendered no boot wrapper at all. Measured on a pristine tree: fixing only the router still fails, fixing only the chrome slot still fails, fixing both passes. So a report that names one of them is not a partial diagnosis to be discounted — it is half of the answer, and the half nobody had.
|
|
95
|
+
|
|
96
|
+
Both paths now render ONE shared component at each level — `RouterProviderTree` for the router, `RootChromeSlot` for the boot — whose second slot is always present and `null` where there is nothing to put in it. The arity is identical by construction rather than by two call sites agreeing. `ssrTreeIdParity.test.tsx` renders one page through both paths and compares a `useId`, so a future change to the shape fails at the point of change instead of in a consumer's browser. It was written red first. Its FIRST version was the cautionary tale, though: it compared the server render against a bare `<Router>` and passed while the app was still broken, because the boot-level fork it did not model is the one that was left. It hydrates through the real `VoltroRuntimeProvider` now — every hydrating path in `mount.tsx` goes through it, so a bare router is not a shape that exists. A parity test that models less than the real boot proves only that the part it models agrees.
|
|
97
|
+
|
|
98
|
+
**The methodological trap is carried in the test, because it cost the reporter an hour and would cost the next person one:** reading the id back from the DOM shows the SERVER's value on both sides — hydration deliberately does not patch ids, which is the very thing the warning says. A harness built that way reports the bug as absent. The id has to be captured from the render that computed it, and a second test proves the harness would still catch a fork.
|
|
99
|
+
|
|
100
|
+
**Verified in a real browser, not only in jsdom.** `scripts/browser-ssr-hydration-ids.mjs` boots `voltro dev` on the SSR fixture from SOURCE, loads a `renderMode: 'ssr'` page, and asserts the client computes the same `useId` the server wrote AND that react-dom logs no mismatch. Removing either half of the fix makes it print the reporter's exact string — `A tree hydrated but some attributes of the server rendered HTML didn't match the client properties` — which is the only place that message can be observed at all: the DOM is identical, hydration succeeds, nothing throws, and there is no server-side signal.
|
|
101
|
+
- **@voltro/web, @voltro/cli** — **The server render discarded the request's query string.** `RenderPageOptions` had no `search`, and the SSR router context hardcoded `search: ''` with a comment noting that the client reads `window.location.search` on hydration. That is true, and it is precisely why the hardcoding was wrong: the client reading the real value is what turns a discarded query string into a divergence in an exported context value. The value was already computed in both per-request boot paths — the loaders receive it — and simply never reached the renderer.
|
|
102
|
+
|
|
103
|
+
`renderPageToHtml` / `renderPageToStream` take `search` now, and `voltro start` and `voltro dev` both pass it. `build.ts` deliberately does not: a static prerender has no request and one artefact serves every visitor, so `''` is the truthful value there rather than a missing wire — and `ssrI18nParity.test.ts` encodes that difference, asserting the two per-request renderers pass it while leaving the prerender out on purpose.
|
|
104
|
+
|
|
105
|
+
**Scope, stated rather than assumed:** `useSearchParams()` was ALREADY correct on the server. It reads `requestContext.url`, which both per-request paths populate with the full request url including the query. So this fixes `RouterContext.search` — exported, and readable by an app directly — and does not on its own explain a mismatch in a page that reaches the query through that hook. Reported alongside the `useId` defect by the same consumer.
|
|
106
|
+
|
|
107
|
+
### Internal (no consumer-facing effect)
|
|
108
|
+
|
|
109
|
+
- **@voltro/database** — `pendingAttribution`'s boundedness test no longer scores its property on the wall clock. It asserts that 12 000 registrations leave at most 10 000 entries and says nothing about how long 12 000 iterations take — but under the default 5 s timeout it had quietly become an assertion about the machine as well, and went red inside a 24-task parallel run while the whole file finishes in 480 ms on its own. That is the "a test that measures the machine" producer recorded in `packages/cli/CLAUDE.md`, and the fix is to decouple the property from the clock (an explicit generous timeout) rather than to shrink the loop — 12 000 is chosen to overrun the 10 000 cap, so a smaller burst would weaken the only thing under test. A non-vacuity assertion came with it: a cap of zero satisfies `<= 10 000` while proving nothing.
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## [0.30.0] — 2026-08-08
|
|
114
|
+
|
|
115
|
+
### ⚠ BREAKING
|
|
116
|
+
|
|
117
|
+
- **@voltro/ui-shadcn, @voltro/i18n, @voltro/web, @voltro/cli** — The language-preference cookie is **`voltro:locale`**. It was `voltro:lang`. The exported constant is `LOCALE_COOKIE` (was `LANG_COOKIE`), and `parsePreferenceCookies()` returns `{ theme, locale }` (was `{ theme, lang }`).
|
|
118
|
+
|
|
119
|
+
Every other name in the framework says `locale` — `resolveLocale`, `defaultLocale`, `config.locales`, `[locale]/…` routes, `meta({ locale })`, `RouteContext.locale`. The cookie was the one place the vocabulary broke, while holding a full IETF tag (`fr-CA`) — which is a locale, not a language.
|
|
120
|
+
|
|
121
|
+
That inconsistency was not cosmetic. `voltro dev` shipped for eleven weeks reading `voltro:locale` while every writer wrote `voltro:lang`, so `<html lang>` was the literal `"en"` on every page of a German-default app. A reader and a writer that disagree on a string are invisible to `tsc`; a name nobody types the same way twice is what produced the disagreement.
|
|
122
|
+
|
|
123
|
+
**Your source is migrated by `voltro update`. Your users' browsers are not.** The old cookie in an already-visited browser is no longer read, so each user falls through to `Accept-Language` and then `defaultLocale` once and re-picks their language. Nothing errors and nothing else is lost. There is deliberately no dual-read fallback: a framework that keeps reading the old name forever is one that never finished the rename, which is the exact condition this change removes. If the one-time reset is unacceptable for your users, copy the value forward at your own boot and delete the bridge once they have cycled through:
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
import { LOCALE_COOKIE, getCookie, setCookie } from '@voltro/ui-shadcn'
|
|
127
|
+
|
|
128
|
+
const legacy = getCookie('voltro:lang')
|
|
129
|
+
if (legacy && !getCookie(LOCALE_COOKIE)) setCookie(LOCALE_COOKIE, legacy)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
`voltro:theme` is unchanged.
|
|
133
|
+
|
|
134
|
+
**`voltro update` carries you across this** — codemod `0.30.0/01_locale-cookie-rename`.
|
|
135
|
+
- **@voltro/plugin-webhooks, @voltro/cli** — A deferred `ctx.webhooks.emit(...)` is a **transactional outbox row** now, not an in-memory after-commit callback. And `EmitOptions` gains **`immediate: true`** as the named way to opt out.
|
|
136
|
+
|
|
137
|
+
The commit-ordering half shipped in 0.29.0: an emit inside a mutation rides the commit, so a mutation that emits and then throws no longer tells a subscriber about a change that did not happen. That fix was correct about ORDER and silent about DURABILITY — a process dying between COMMIT and the callback dropped the delivery with nothing recorded as owed, which is the at-least-once-FROM-ENQUEUE weakness `@voltro/plugin-cdc-out` documents about itself, arrived at by accident.
|
|
138
|
+
|
|
139
|
+
The enqueue writes through `ctx.store` — inside a mutation, the transactional view — so the intent to deliver commits with the domain write or not at all. A crash is a retry instead of a loss. Delivery stays at-least-once, which is the strongest guarantee available without distributed transactions into the receiver.
|
|
140
|
+
|
|
141
|
+
**What changes for you:** an emit inside a mutation returns `{ event, deliveries: [], deferred: true }` and its delivery rows appear after commit — as it already did in 0.29.0. New is that the deferral survives a crash, and that `{ immediate: true }` exists for the cases that genuinely want the POST now. `immediate` does not make the emit safe; it makes the trade visible at the call site, which the old un-transactional behaviour never did.
|
|
142
|
+
|
|
143
|
+
The framework registers its own `voltro.webhook.emit` outbox handler in BOTH boot paths, gated by one shared `hasWebhookSurface` predicate — a deferral that is durable under `voltro dev` and not under `voltro serve` is exactly the drift the parity guard exists for. An app with no outbox wiring keeps the in-memory callback: ordered, not durable, and it says so.
|
|
144
|
+
|
|
145
|
+
**`voltro update` carries you across this** — codemod `0.30.0/03_webhook-emit-durable-deferral`.
|
|
146
|
+
- **@voltro/protocol, @voltro/runtime, @voltro/cli, @voltro/plugin-webhooks** — **`WorkflowRunHandle.executionId` is nullable and `status` has three more members, because a start no longer always becomes a run.**
|
|
147
|
+
|
|
148
|
+
With declarative flow control a start can be QUEUED (debounce / batch / throttle / concurrency / paused), DROPPED (over a `rateLimit` cap) or SKIPPED (a `singleton: { mode: 'skip' }` key was held). None of those has an execution id, and two of them may never have one.
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
status: 'running' | 'queued' | 'dropped' | 'skipped' // was: 'running'
|
|
152
|
+
executionId: string | null // was: string
|
|
153
|
+
deferral?: { mode, dueAt, retryAfterMs, intentId } // new
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Keeping `executionId` a required string was considered and rejected. It would have meant inventing a value — an empty string, or the id the run WOULD have had — and both produce a handle that polls `status: 'unknown'` forever: a wait that never resolves and never errors, which is the worst of the three answers. For a `skipped` singleton it carries the INCUMBENT's execution id, which is a real, pollable run and the entire point of that mode.
|
|
157
|
+
|
|
158
|
+
`ctx.workflows.wait(...)` on a handle with no execution id now throws with a message naming the status and, for `queued`, its `dueAt` — instead of polling forever.
|
|
159
|
+
|
|
160
|
+
Two structural copies of the old shape went stale and are now the protocol type itself rather than hand-copies: `@voltro/plugin-webhooks`' `IncomingWorkflowFacade` and the CLI's `inspectStartWorkflow`. An incoming webhook that starts a debounced workflow gets a `queued` handle, which both copies said could not happen.
|
|
161
|
+
|
|
162
|
+
**`voltro update` carries you across this** — codemod `0.30.0/04_workflow-run-handle-nullable-execution`.
|
|
163
|
+
|
|
164
|
+
### Added
|
|
165
|
+
|
|
166
|
+
- **@voltro/ai, @voltro/workflow** — **`@voltro/ai/workflow` — `aiStep` / `aiObjectStep`, a model call as a durable step that records what it cost.**
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
import { aiStep } from '@voltro/ai/workflow'
|
|
170
|
+
|
|
171
|
+
const summary = yield* aiStep({
|
|
172
|
+
name: 'summarise-thread',
|
|
173
|
+
prompt: `Summarise:\n${thread}`,
|
|
174
|
+
store: ctx.store,
|
|
175
|
+
tenantId: payload.tenantId,
|
|
176
|
+
offload: true,
|
|
177
|
+
})
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Journaling is NOT the difference, and saying otherwise would be selling something the framework already gives away: every `step()` is journaled, so a replay of a plain wrapped `generateText` already returns the recorded completion rather than re-calling the model. Four things are genuinely new:
|
|
181
|
+
|
|
182
|
+
1. **What did this run cost?** A model call inside a workflow was invisible to `_voltro_ai_usage` unless the app remembered to call `recordAiUsage` by hand — so the spend ledger was systematically missing exactly the calls that run unattended. `aiStep` records it, attributed to the workflow and the step. 2. **The prompt is not silently copied into a second table.** `step({ input })` is written to `_voltro_workflow_run_steps` and rendered in the dashboard; for a prompt built from customer data that is a plaintext copy outside whatever boundary the app established for the source. The default records a DIGEST plus the length; `recordPrompt: 'full'` exists and has to be typed out. 3. **Provider failures retry like provider failures.** The default policy handles a 429 with its `Retry-After` and a 5xx, rather than every app rediscovering that a bare call fails the whole durable run on a rate limit.
|
|
183
|
+
|
|
184
|
+
4. **`offload: true` frees the worker while the model thinks.** The run SUSPENDS on a durable deferred, the wait lives as a row in `_voltro_ai_inferences`, and a dispatcher owns the socket. Two hundred waiting runs become two hundred rows and four in-flight requests instead of two hundred parked fibers.
|
|
185
|
+
|
|
186
|
+
Nothing here needs a third party to operate an inference tier — it needs something to own the socket while the run sleeps, and a server process is something. Both pieces already existed: durable suspend/resume (`awaitSignalSuspending`, built for human-in-the-loop waits) and a leased work queue with a coordinated drainer (the admission queue's own shape).
|
|
187
|
+
|
|
188
|
+
The cost is stated rather than buried: a suspend/resume round trip adds the dispatcher's poll interval plus one engine wake, so it is a MODE. Under 5% on a six-second call; a doubling on a 200 ms one.
|
|
189
|
+
|
|
190
|
+
Four guarantees, each ruling out a specific way this goes wrong:
|
|
191
|
+
|
|
192
|
+
- the enqueue is idempotent (the row id derives from execution + step, so a replay cannot queue — and pay for — the same call twice); - the claim is a conditional update, so two dispatchers cannot both bill one call; - the order is perform → RESUME the run → mark the row, because a crash the other way round leaves a run waiting for a signal nobody will send again; - a give-up resumes the run WITH the failure — an abandoned queued call that never told its run is the one unrecoverable outcome here.
|
|
193
|
+
|
|
194
|
+
`aiObjectStep({ offload: true })` renders the schema to JSON Schema for the dispatcher (a JavaScript Schema cannot be journaled) and still decodes on the awaiting side, where the real schema exists.
|
|
195
|
+
|
|
196
|
+
The dispatcher rides the ONE shared builder both boot paths call, with a red-verified parity guard: the gap it prevents is the worst variant this repo catalogues — in production every offloaded call would suspend its run and never resume it, with no error and no log line.
|
|
197
|
+
|
|
198
|
+
The Flow tab renders the queue: what is waiting and for how long, calls waiting past two minutes, claims whose dispatcher died, and the dispatcher's own last tick. A run parked on an offloaded call reads `suspended` with no step row yet, so this is the only view of the wait while it is happening.
|
|
199
|
+
|
|
200
|
+
`StepRetryPolicy` is now re-exported from `@voltro/workflow/define` (type-only, so the browser bundle is unaffected): it is the type of a `step()` option, and anything defining a step has to be able to name it.
|
|
201
|
+
- **@voltro/cli** — **`voltro db scan-credentials`** — the credential scan as a command instead of a SQL snippet in an upgrade note.
|
|
202
|
+
|
|
203
|
+
It counts rows whose Subject carries a credential-shaped key (`token` / `secret` / `password` / `apikey` / `credential` / `privatekey`) in `_voltro_audit_log` and `_voltro_row_history`, plus any `--table <name>[:<column>]` you add. Exit `1` on a hit so CI can gate on it.
|
|
204
|
+
|
|
205
|
+
Why it is a command: the same check shipped as documented SQL (`subject::text ILIKE '%token%'`), which is postgres-only. Readers on MySQL/MariaDB translated it to a bare `LIKE` — case-SENSITIVE against the `utf8mb4_bin` collation our own migrator emits for a `json()` column, so `'%token%'` does not match `jiraToken`. A team ran it over 141 rows, got `0`, and nearly filed themselves clean; 117 held a working credential. Every dialect now casts to its own text type before `LOWER`, in code.
|
|
206
|
+
|
|
207
|
+
**And a `0` can no longer mean two things.** Every line prints the number of rows SCANNED beside the number of hits; an empty table says "EMPTY … this is not a clean bill of health"; a missing table reports as missing rather than as zero; and a run that examined nothing exits `2`, not `0`.
|
|
208
|
+
- **@voltro/workflow, @voltro/runtime, @voltro/cli, @voltro/plugin-ai-flows** — **`apiSurface: compatible`, and the reason.** Making `workflow()` a SINGLE call signature (see below — it is what lets a `key` lambda receive the payload type) also means every result is now intersected with its message carrier, including the empty one. `@voltro/plugin-ai-flows`' golden therefore reads
|
|
209
|
+
|
|
210
|
+
Workflow<"flow.run", …, typeof Schema.Never> & WorkflowMessagesCarrier<{ signals: {}, updates: {}, queries: {} }>
|
|
211
|
+
|
|
212
|
+
where it used to stop at the first line. That is an ADDED intersection member, not a narrowing: a value of `T & M` is usable everywhere a `T` was, and nothing outside the package produces a value of that type. The gate flags it because one golden LINE was rewritten, which is the right thing for it to be blunt about — it cannot tell an addition spelled as a rewrite from a removal.
|
|
213
|
+
|
|
214
|
+
It is also an improvement worth naming: before this, a workflow declaring `messages` fell through to the second overload and its payload/success/error types erased to `any`. That erasure is gone.
|
|
215
|
+
|
|
216
|
+
**Flow control is a declaration now — `debounce`, `singleton`, `concurrency`, `throttle`, `rateLimit`, `batch`, `priority`, `timeouts`, `onFailure`, `encryptSteps` on `workflow({...})`.**
|
|
217
|
+
|
|
218
|
+
Every one of these could already be hand-rolled, and that was the problem. A downstream app shipped "fifteen minutes after the last edit, narrate what settled" as ~120 lines: an idempotency key carrying the edit timestamp so every edit minted its own durable run, a re-check loop asking "what is due now and when should I wake next", a round cap so a run could not live forever, and an idempotent round so the superseded runs cost a diff instead of a model call. It works. It costs **twenty sleeping cluster entities to express "one job, latest deadline"**. It is now one line:
|
|
219
|
+
|
|
220
|
+
```ts
|
|
221
|
+
debounce: { key: (p) => `tour:${p.rowId}`, period: '15 minutes' }
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
The reason the obvious version is wrong is the same for all of them: **the decision has to be made before the run exists.** Once a run is enqueued the only tools left are cancel and sleep, and neither un-spends the entity. So this is not a primitive you call inside the body — it is a property of the declaration, evaluated at the ONE boundary every start funnels through (`start`, `child`, `run`, a trigger, a reaction, a cron).
|
|
225
|
+
|
|
226
|
+
**One decision function, two callers.** `decideAdmission` is pure — no store, no clock, no service. The arrival path and the drainer call it with state read by the same two queries, so they cannot disagree; a disagreement would surface as a workflow running twice under a limit of one, on a replica nobody is attached to, under load.
|
|
227
|
+
|
|
228
|
+
**Nothing is silent.** Every decision is a row in `_voltro_workflow_admissions` with its key, reason, `collapsed` count and `waitedMs`. A debounce that collapses nineteen starts into one is indisputably correct AND indistinguishable from nineteen starts vanishing unless something writes it down. `voltro workflows flow` and `GET /_voltro/inspect/workflows/flow-control` show it.
|
|
229
|
+
|
|
230
|
+
Also in this change set:
|
|
231
|
+
|
|
232
|
+
- **`awaitEvent({ event, schema, match })`** — wait on a CORRELATION rather than on an execution id. `awaitSignal` requires the sender to already know which run to wake, so a workflow waiting on a webhook that carries an issue key needed an app-maintained lookup table. The predicate is ordinary JavaScript over the decoded event, and the compiler checks it. - **`sleepUntil({ name, until })`** — the instant is journaled first, so a run that suspends and replays does not recompute the delta against a now that is already past the target and sleep the whole period again. - **`onFailure`** fires for every way a run fails to deliver, including the two that produce no run row at all (`timeouts.start` expiring a queued start; the workflow renamed away while starts were queued) — which is exactly why polling `listRuns({ status: 'failed' })` could never see them. - **`encryptSteps: true`** encrypts the journaled step `input` / `output` / `errorCause` with the cipher `governancePlugin({ fieldEncryption })` already registers. Declaring it without that plugin is a boot refusal, not a warning: a plaintext fallback would leave the declaration reading as protection. - **`voltro workflows pause|unpause <name>`** — a paused workflow COLLECTS. Never discards.
|
|
233
|
+
|
|
234
|
+
A workflow that declares no control takes exactly the path it took before this existed, and an undeclared control costs zero round trips.
|
|
235
|
+
- **@voltro/cli** — A failed `voltro dev` SSR render now reports how many hot updates the process has absorbed since boot, and every failure carries `x-voltro-ssr-generation`.
|
|
236
|
+
|
|
237
|
+
Not telemetry — PROVENANCE for a measurement. A consumer filed and unfiled the same item twice in one afternoon, in both directions, because the same route on the same code answered 200 and 500 depending only on which edits the watcher had processed since boot. Their conclusion is the right one and it belongs to both sides: otherwise two parties judge one item against two different module graphs and each concludes the other was careless.
|
|
238
|
+
|
|
239
|
+
A hard restart on every edit would trade one broken feedback loop for a slower one — a 224-page app is not free to reboot. What costs nothing is letting every failing response say which graph produced it. `0` means nothing has changed since this process started, which is the only state in which a dev-SSR measurement is worth reporting; anything else prints an explicit instruction to restart and measure once from a fresh boot.
|
|
240
|
+
|
|
241
|
+
Counted for suppressed hot updates too: a module we chose not to reload is still one whose bytes on disk no longer match what this process serves, which is precisely the divergence the number exists to expose.
|
|
242
|
+
- **@voltro/cli** — `voltro doctor` flags a framework cookie name written as a string literal (`voltro:locale`, `voltro:theme`, or the pre-0.30.0 `voltro:lang`) and names the constant to import instead.
|
|
243
|
+
|
|
244
|
+
A cookie name the FRAMEWORK reads and the APP writes is a public API — and the only kind where both sides can disagree with nothing failing. Nothing throws, no page breaks: the resolver finds nothing and falls back to `Accept-Language`, so the symptom is a language preference that quietly stops working for the subset of users whose browser language differs from their choice. The least likely thing anyone tests.
|
|
245
|
+
|
|
246
|
+
Raised by a consumer ahead of the `voltro:lang` → `voltro:locale` rename, in their words: *"your codemod will presumably rewrite the literal. Ours were two bare strings in two components, which is exactly the shape a codemod misses one of."* The codemod does rewrite every literal it can see. This rule covers what a codemod structurally cannot — and, more usefully, the NEXT rename, for which no codemod has been written yet.
|
|
247
|
+
- **@voltro/cli** — New `mobile` template kind + scaffolder support for Expo (React Native) apps. `voltro create-project <name> --mobile` (defaults to the `mobile-app` template) and `voltro add-app <name> --template=mobile-app` scaffold an Expo app that consumes your api with the same typed hooks. A `mobile` app deliberately gets NO port and is NOT part of `voltro dev`'s orchestration — Expo owns Metro (`expo start` / `expo run:ios`); the app connects to the sibling api over the network. `list-templates` shows the new kind; the template validation harness (`test-templates.mjs`) skips `kind: mobile` from its default sweep LOUDLY (the Expo/RN toolchain is heavy and simulator-bound — the template's pure logic is covered by its own tests). codemod: none — additive, no user-authored code changes. (The forward-looking design + the M0 gap — no RN-safe client boot yet — are in `plans/open/mobile/`.)
|
|
248
|
+
- **@voltro/client, @voltro/web** — `@voltro/client` now exports `buildApiRuntime` — the transport-level construction of one api's client stack (an rpc-client-over-WebSocket, its ManagedRuntime, a SubscriptionCache, an error bus, per-connection auth-header seeding). The WebSocket constructor is an INJECTED dependency, so React Native can build the SAME `ApiHandle` pieces the web client uses without pulling in `@voltro/web` — the keystone for mobile support (plans/open/mobile M0). `@voltro/web`'s `buildRuntimeAndClient` now DELEGATES to it (one implementation, no duplicate path; the web client-builder test suite stays green), and its `ResolvableHeaders` type is re-exported from `@voltro/client` (the owning lower layer) rather than defined locally. Also exported: `BuildApiRuntimeOptions`, `BuiltApiRuntime`, `ResolvableHeaders`. Additive — no consumer migration.
|
|
249
|
+
- **@voltro/cli** — `voltro dev` now tells you WHICH of two causes produced *"[React Intl] Could not find required `intl` object"*.
|
|
250
|
+
|
|
251
|
+
That error is byte-identical whether there is no `<I18nProvider>` above the consumer or a provider built from a SECOND physical `react-intl` copy — React contexts are identified by object identity, so a duplicate library has a duplicate context and the provider is present and invisible. The two causes have opposite fixes, and no red/green experiment in the app can separate them: the app's own provider comes from the app's own import, i.e. the instance its `useT()` already uses.
|
|
252
|
+
|
|
253
|
+
The dev server knows something the error does not — whether it supplied an `outerWrap` for that request. When it did, the 500 body and the log line now carry the duplicate-copy diagnosis and the one command that confirms it (`pnpm ls -r --depth 10 @voltro/i18n react-intl`), plus an explicit statement that the diagnosis is wrong if both resolve to a single version. It stays silent for an app that configures no locales, where "no provider" is the correct state.
|
|
254
|
+
- **@voltro/cli** — `voltro update --dry-run` now lists the codemods the target version puts **in range**, without installing anything and without touching your tree.
|
|
255
|
+
|
|
256
|
+
The obstacle was not the one we thought. The codemods for a jump ship INSIDE the target `@voltro/cli`, which is not installed when the preview runs — so the target VERSION is known before installing and the target REGISTRY is not. A preview that confused the two would list the codemods of the version you are leaving.
|
|
257
|
+
|
|
258
|
+
The registry is therefore republished as package METADATA (`voltro.codemods` in the published `package.json`, generated by `scripts/gen-codemod-manifest.mjs`, drift-checked in CI) and read with the SAME registry query that already resolves the latest version — project package manager first, `npm view` last. No tarball fetch, no temp install, no second package-manager surface. yarn and bun fall straight through to npm on purpose: `yarn npm info …` parses as `yarn run npm` on yarn classic and executes a same-named script, and that risk is not worth taking for a preview.
|
|
259
|
+
|
|
260
|
+
Two honesty properties, both load-bearing:
|
|
261
|
+
|
|
262
|
+
- **"In range" is not "will apply".** `appliesTo` is a function and cannot cross a registry query, so the list is the upper bound on what a run can touch. The output says so. - **"Could not look" never prints as "nothing to do".** A target published before this field existed, or an unreachable registry, produces an explicit *"This is NOT the same as no codemods"* — because the whole reason to preview is to decide whether to stash a dirty tree.
|
|
263
|
+
|
|
264
|
+
Asked for twice by a consumer who established the answer by grepping their own call sites instead.
|
|
265
|
+
- **@voltro/plugin-webhooks, @voltro/cli, @voltro/devtools-ui** — The Webhooks panel's Events tab shows **two** facts side by side: whether the event was ever DELIVERED, and whether `emit(...)` ever RAN.
|
|
266
|
+
|
|
267
|
+
`everDelivered: false` conflates three different things — no emit call site, a call site that ran before anyone subscribed, and one whose payload every target's filter excluded (or every target was paused). Only the first is a defect, and it is the one a consumer spent a week finding by hand: seven of eleven advertised events had no emit call site anywhere. Delivery history also ages out at 90 days, so a quiet-but-working event decays into looking dead.
|
|
268
|
+
|
|
269
|
+
`_voltro_webhook_event_stats` carries one row per event, stamped on every emit **regardless of whether any target matched** — the axis delivery history structurally cannot see. Not tenant-scoped (the question is whether the CODE has a live call site, not whether a tenant has triggered it) and not retention-swept (a quarterly event must not read as dead). The write is best-effort and silent on failure: this is telemetry for a dashboard column and must never be the reason a delivery does not go out.
|
|
270
|
+
|
|
271
|
+
**An unreadable stats table reports as UNKNOWN, never as "never".** "We did not look" and "it never fired" are different answers and only one is a finding.
|
|
272
|
+
|
|
273
|
+
Two corrections rode along:
|
|
274
|
+
|
|
275
|
+
- The existing activity label read *"{n} subscribed · NEVER emitted"* while being derived from delivery history. It says *never DELIVERED* now — it may only claim what it actually knows. - **The cloud dashboard never received `eventActivity` at all.** The proxy's output schema did not name the field, so Effect's decode dropped it silently and the column rendered locally but not in the cloud — the four-layer drift the maintainer rule exists to prevent, shipped since 0.29.0. Both dashboards now get it.
|
|
276
|
+
- **@voltro/cli, @voltro/devtools-ui** — **Bulk cancel and bulk replay — `voltro workflows cancel-many` / `replay-many`, plus a dashboard panel.**
|
|
277
|
+
|
|
278
|
+
A bad deploy leaves four thousand runs that must all stop, or four thousand that must all be re-driven once the downstream is fixed. Doing that one run at a time through a dashboard is not a workflow, and doing it with raw SQL is how a `_voltro_workflow_runs` row ends up marked `cancelled` while the engine keeps executing it.
|
|
279
|
+
|
|
280
|
+
```
|
|
281
|
+
voltro workflows cancel-many --workflow tourNarration --reason "bad deploy"
|
|
282
|
+
voltro workflows cancel-many --workflow tourNarration --reason "bad deploy" --commit
|
|
283
|
+
voltro workflows replay-many --status failed --mode redrive --limit 200 --commit
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
Three decisions are deliberately stricter than the obvious design:
|
|
287
|
+
|
|
288
|
+
- **`--limit` is required and there is no "all".** The cap IS the blast radius, and it costs one number. `truncated` in the result says whether more matched, so "did I get all of them" stays answerable without an unbounded verb ever existing. A result of "1000 cancelled" reads as "all of them" otherwise, at the exact moment that mistake is most expensive. - **It is a DRY RUN unless `--commit` is passed.** That is the opposite of the usual `--dry-run` flag, and deliberate: the default for a verb that can stop a thousand runs should be the one that stops none. The dashboard panel enforces the same order — the apply button does not exist until a preview has returned a number, because "this will cancel 412 runs" is a different sentence from "412 runs were cancelled". - **`--reason` is required for a cancel.** It lands on every affected run's `run-cancelled` event, so "why did four thousand runs stop on the 8th" has an answer in the same table an operator is already reading.
|
|
289
|
+
|
|
290
|
+
The result is per-run, not a count: `succeeded`, `failed` (with the reason for each) and `skipped` (with what made each ineligible) are three different outcomes. A bulk op that reports "4000 cancelled" while forty failed is how people learn not to trust bulk ops.
|
|
291
|
+
|
|
292
|
+
Eligibility follows the verb rather than a flag: a cancel acts on `running` and `suspended`; `replay --mode redrive` on `failed` only (redrive resumes from the step that died, which only exists for a failure); `replay --mode retry` on `failed` and `cancelled`. `--mode` has no default because the two cost very different amounts.
|
|
293
|
+
|
|
294
|
+
Each verb delegates to the SINGLE-run operation beside it — the shared canceller, `retry`, `redrive` — so a bulk path cannot end up performing a different set of side effects from the button next to it.
|
|
295
|
+
|
|
296
|
+
The dashboard panel is gated on a NEW capability, `canBulkOperateRuns`, rather than on `canPauseWorkflow`. The argument that made pause safe to expose is exactly why: a pause COLLECTS starts and never discards one, so its worst outcome is a backlog. A bulk cancel destroys work already in flight. In the cloud dashboard it is `owner`-only.
|
|
297
|
+
- **@voltro/workflow, @voltro/cli, @voltro/devtools-ui** — **`cancelOn` — stop a workflow's live work when a correlated event arrives.**
|
|
298
|
+
|
|
299
|
+
```ts
|
|
300
|
+
cancelOn: [{
|
|
301
|
+
event: 'jira.issue.deleted',
|
|
302
|
+
schema: JiraIssueDeleted,
|
|
303
|
+
match: (event, payload) => event.issueKey === payload.issueKey,
|
|
304
|
+
}]
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
Both sides are typed: the event from the entry's own `schema`, the payload from the workflow's.
|
|
308
|
+
|
|
309
|
+
**Why a declaration rather than a race inside the body.** "Stop when the issue is deleted" is expressible with `awaitEvent` and an interrupt, and that works while the body is RUNNING. It does not work while the run is sleeping for six hours, suspended on a signal, or still sitting in the admission queue — which is the case cancellation was wanted for. The event has to reach a run whose fiber is not executing anything, and only something outside the body can do that. So it is swept: a coordinated tick reads events published since a durable watermark (`_voltro_workflow_watermarks`), resolves each declaring workflow's live runs, and cancels the ones that correlate.
|
|
310
|
+
|
|
311
|
+
**It also discards QUEUED starts of the same workflow.** Cancelling only the running one leaves a debounced or concurrency-queued duplicate to start seconds later against the row that was just deleted — the exact outcome the declaration was meant to prevent, arriving late enough that nobody connects the two.
|
|
312
|
+
|
|
313
|
+
Three rules that are stricter than they look, each protecting against a way this would otherwise be silently wrong:
|
|
314
|
+
|
|
315
|
+
- **`match` is required.** The omitted case would mean "cancel every live run of this workflow", which is a legitimate thing to want and a catastrophic thing to acquire by forgetting a line. `match: () => true` says it out loud. - **A run that started AFTER the event is never cancelled.** A sweep catching up after a deployment gap reads an hour of history; without this it kills runs that started in the meantime, and the symptom looks nothing like the cause. - **An event that fails to decode is REPORTED and never matched.** Cancelling on an event you could not read is cancelling blind.
|
|
316
|
+
|
|
317
|
+
The cancel itself goes through the same code the operator's cancel button uses — engine interrupt, row flipped, `run-cancelled` recorded with the event name, children closed — because a second implementation would inevitably have done three of those four.
|
|
318
|
+
|
|
319
|
+
Wired through the one shared builder both `voltro dev` and `voltro serve` call, and shown in the dashboard as a `cancelOn:<event>` badge on the declaring workflow, so "which event stops this" is answerable without reading the source.
|
|
320
|
+
|
|
321
|
+
Also in this change set, from a review of the above:
|
|
322
|
+
|
|
323
|
+
**A DISCARDED queued start now writes a ledger row.** Both paths that drop one — an operator's discard button and a `cancelOn` event — deleted the pending row and recorded nothing. That is precisely the failure `_voltro_workflow_admissions` exists to prevent, committed by the feature that argues against it: from the outside, a start deliberately discarded and one that silently vanished are the same observation, a row that is no longer there. `outcome: 'discarded'` is a new member of the ledger's enum (a `_voltro_*` column change, so it rides the declarative differ on `voltro db apply` and on a `voltro dev` boot, on every dialect — no codemod).
|
|
324
|
+
|
|
325
|
+
**The `cancelOn` sweep reports its own health**, in the Flow tab rather than only in a log line. `problems` is the field that matters: an event whose SHAPE changed makes cancellation silently stop firing — the run keeps going, which is the safe direction, and nothing about the run says a cancellation was attempted and could not be evaluated.
|
|
326
|
+
|
|
327
|
+
Two bounds the first version was missing: the live-run read is paged (oldest-first, so a bounded sweep makes progress instead of re-reading the same page) and reports when it filled up; and a LISTING failure now HOLDS the watermark, because a tick that never evaluated those events must not advance past them. A cancel that was attempted and refused still advances — those are different failures and only one of them is worth retrying.
|
|
328
|
+
- **@voltro/workflow, @voltro/cli, @voltro/devtools-ui** — **`concurrency.pool` — one budget shared across workflows.** Without it, a concurrency limit bounds one workflow's runs; five workflows that each call a rate-limited provider hold five separate budgets nobody meant to multiply. Declaring the same pool name makes them compete for ONE:
|
|
329
|
+
|
|
330
|
+
```ts
|
|
331
|
+
// embeddings.workflow.tsx AND summarize.workflow.tsx
|
|
332
|
+
concurrency: { limit: 10, pool: 'openai' }
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
`key` still partitions WITHIN the pool (`(p) => p.tenantId` in each member → a per-tenant shared budget). Every member must declare the SAME `limit` — the boot fails on a disagreement, naming every workflow involved, because two numbers for one budget is a contradiction and silently picking either would enforce a limit somebody did not write.
|
|
336
|
+
|
|
337
|
+
Mechanically, the pool is spelled into the stored concurrency key (`pool<NUL><name><NUL><key>` — NUL separators so an app key function cannot collide with it by accident), so the pending row, the ledger row and the drainer all group pool-wide without any of them knowing pools exist. The count query drops its per-workflow filter exactly when a pool is declared; two UN-pooled workflows with a coincidentally-equal key stay separate budgets, and a test pins that boundary in both directions. The dashboard renders the pooled spelling as `pool:<name> · <key>`.
|
|
338
|
+
|
|
339
|
+
Also in this change: the unreleased `concurrency.scope: 'replica'` option is GONE before ever shipping. It was resolved and then read by nothing — a knob that did nothing distinguishable — and it cannot be coherent in this model: deferred starts queue in the SHARED pending table and are drained by whichever replica has capacity, so a per-process count has no meaning. The limit is deployment-wide, enforced through the shared admissions ledger, full stop.
|
|
340
|
+
|
|
341
|
+
codemod: none — `pool` is additive and `scope` never appeared in a published release.
|
|
342
|
+
- **@voltro/runtime, @voltro/cli, @voltro/devtools-ui** — **Server-side run filtering + a throughput/failure chart, across both dashboards.**
|
|
343
|
+
|
|
344
|
+
The runs surface used to fetch the newest N rows and filter in the browser — fine at a hundred runs, useless at a hundred thousand, where the five failed runs you are hunting have long scrolled out of the fetched page.
|
|
345
|
+
|
|
346
|
+
- **`ctx.workflows.listRuns(...)` and `GET /_voltro/inspect/workflows/runs`** gain composable server-side filters: `statuses` (several at once), `source`, `tagContains` (`q=` — the search-box semantic, where `tag` stays exact), `idPrefix` (matches the run id OR the execution id, so an operator never has to know which kind their log line carried), and a `startedAfter`/ `startedBefore` time range. The dashboards' filter bars send exactly these; the shared `WorkflowsPage` keeps its client-side filtering as a second layer, so an older api that ignores the params still renders a correctly-filtered page — just off a larger fetch.
|
|
347
|
+
|
|
348
|
+
- **`GET /_voltro/inspect/workflows/stats`** returns ~48 buckets of run activity over a trailing window (`hours` up to 168, optional `tag`), each with started/succeeded/failed/cancelled counts plus per-workflow totals. Computed by the app itself and PROXIED to the cloud dashboard, so both dashboards render the same aggregation instead of two derivations that drift. When the window exceeded the scan cap the response says `truncated: true`, and the chart renders that as a warning — a silently-truncated chart shows throughput dropping at exactly the moment it spiked.
|
|
349
|
+
|
|
350
|
+
- **`WorkflowThroughputChart`** (devtools-ui) — a dependency-free SVG stacked-bar chart (green delivered / red failed / grey cancelled / blue in-flight), rendered on the Runs tab and in per-workflow detail mode in the local AND cloud dashboards.
|
|
351
|
+
|
|
352
|
+
The cloud runs subscription (`apps.inspectWorkflowRuns`) accepts the same filters — time range included — and applies them inside the reactive predicate, so deltas for filtered-out runs never reach the browser.
|
|
353
|
+
|
|
354
|
+
- **The filter bar grows a TIME RANGE** (two `datetime-local` inputs), URL-persisted like the other filters. Deliberately NOT part of saved views: an absolute range goes stale the moment it is saved — "last Tuesday" is a moment, not a view — and silently re-applying it later filters to an empty page that reads as "no runs".
|
|
355
|
+
|
|
356
|
+
- **The overview chart lists the busiest workflows** in the window (per-tag started/ok/failed), each linking into that workflow's detail view.
|
|
357
|
+
|
|
358
|
+
- **`voltro workflows list`** gains the same triage flags (`--statuses a,b`, `--q`, `--source`, `--id-prefix`, `--since`/`--until` — an unparseable instant fails loudly at the flag rather than returning an empty page), and **`voltro workflows stats`** renders the chart in the terminal: a unicode sparkline for started/failed plus per-workflow totals, with the same never-silent truncation warning.
|
|
359
|
+
|
|
360
|
+
codemod: none — all additive.
|
|
361
|
+
|
|
362
|
+
### Fixed
|
|
363
|
+
|
|
364
|
+
- **@voltro/cli** — **Re-issued the credential-purge query, because the correction to it could not reach the people who ran the wrong one.**
|
|
365
|
+
|
|
366
|
+
`0.28.0/04_audit-redacts-subject-metadata` originally printed `subject::text ILIKE '%token%'` — postgres-only, and its natural MySQL/MariaDB translation (`LIKE`) is case-SENSITIVE against the `utf8mb4_bin` collation our own migrator emits for a `json()` column. `'%token%'` therefore does not match `jiraToken`. A team ran it over 141 rows, got `0`, and nearly filed themselves clean; 117 of those rows held a working credential.
|
|
367
|
+
|
|
368
|
+
The 0.28.0 note was corrected — and that correction is unreachable for everyone it concerns. `selectCodemods` picks `from < version <= to`, so a project that has already crossed 0.28.0 never runs a 0.28.0 codemod again, however wrong its note turned out to be. **A codemod note is delivered once, at a version boundary, and is not a document you can revise.** When one is found wrong after its version ships, the correction has to be re-issued under a version users have not yet landed on. `0.30.0/02_audit-purge-query-recheck` is that re-issue.
|
|
369
|
+
- **@voltro/database** — A `bytes()` / `crdtText()` column read over a reactive subscription or query threw on the CLIENT: `rowSchema`'s wire mapping used `Schema.Uint8ArrayFromSelf`, whose encode leaves a raw `Uint8Array` — `JSON.stringify` turns that into a numeric-keyed object (`{"0":1,…}`) the decoder then rejects. Every other column type in that module already crosses in a JSON-safe form (timestamp → epoch-ms number, bigint → decimal string); bytes was the outlier. It now crosses as a base64 string (Uint8Array in the handler, string on the wire), regression-covered by a full JSON round-trip for both `bytes()` and nullable `crdtText()`. codemod: none — the prior behaviour threw, so there is no working consumer to migrate. (Surfaced while building the api-collab/frontend-collab CRDT templates.)
|
|
370
|
+
- **@voltro/cli** — The `@effect/cluster@0.60.0` patch cast a message's `deliver_at` to `BigInt` for EVERY dialect (the fix was for mssql's tedious driver, which infers INT and overflows post-2001 epochs). But `@effect/sql-sqlite-node` runs `safeIntegers(true)`, where a bigint `deliver_at` breaks the due-message comparison — the cluster workflow engine polls forever, never delivers the message, and the workflow HANGS. This silently broke every cluster/workflow integration path on sqlite since the 0.60.0 bump (the whole sql-sqlite cluster suite timed out at ~95s and read as "flaky under load"). The cast is now dialect-conditional — `BigInt` only for mssql, plain number elsewhere (the pre-0.60.0 behaviour pg/mysql/sqlite always accepted). sql-sqlite: 86/86 in 12s (was 7 hanging at 96s); mssql's overflow fix preserved.
|
|
371
|
+
- **@voltro/cli** — `voltro dev`'s console capture no longer destroys the error it is passing through.
|
|
372
|
+
|
|
373
|
+
Node's `console.error` formats every argument with `util.inspect`. A React SSR failure carries the element/props graph, inspecting it can exceed V8's string cap, and `inspect` then throws `RangeError: Invalid string length` from `markNodeModules` — which REPLACES the error being reported.
|
|
374
|
+
|
|
375
|
+
**The framework is what made that fatal rather than merely ugly.** `voltro dev` installs a console wrapper on every boot and its first act was an unguarded pass-through, so the RangeError propagated out of `console.error` itself. A consumer chased a one-line dev-SSR i18n bug across two rounds through this mask and only recovered the real message by neutralising `console.error` from their own app code.
|
|
376
|
+
|
|
377
|
+
The pass-through now retries with bounded arguments and says that it did. Truncation that announces itself is the point: a message that silently stops looks like a short message, and the reader draws conclusions from it. Ordinary console output is untouched — the guard is a fallback, not a filter, and a wrapper that reshaped every line would be the mask with extra steps.
|
|
378
|
+
|
|
379
|
+
Red-verified: restoring the unguarded call turns two of the three new tests red.
|
|
380
|
+
- **@voltro/runtime** — A malformed CRDT update written to a `crdtText()` column no longer crashes the mutation with a cryptic `Unexpected end of array` from deep inside Yjs, and can no longer be stored raw to poison later reads. The server merge now validates every incoming update — folding it against the stored state, or an EMPTY state on a first write (previously a first write stored the bytes unchecked) — and a non-decodable update throws a clear, column-named error naming what a client must send. Surfaced while exercising the api-collab CRDT template.
|
|
381
|
+
- **@voltro/cli** — The `events: declared but not wired` check no longer calls every webhook event dead when an app emits through a shared helper.
|
|
382
|
+
|
|
383
|
+
Two independent defects produced that, both fixed:
|
|
384
|
+
|
|
385
|
+
- **The emitter test required the webhooks service within 400 CHARACTERS of the `emit(`.** That is a claim about file layout, not about code. An app that funnels every emit through one helper has `import { useWebhooks as webhooks }` at the top and the call a hundred lines below. A consumer's only `.emit(` in their entire api reads `webhooks(ctx).emit(descriptor, payload)` and matched neither alternative. The qualifier now has to appear anywhere in the file, the same shape the bare `publish(` rule already used, plus the package specifier for the aliased-import case where no service identifier survives into the body.
|
|
386
|
+
|
|
387
|
+
- **A funnel names no event, because the descriptor arrives as a VALUE.** A text scan cannot follow a value across a call boundary. That is not weak evidence of a dead event — it is no evidence, in either direction, and the check reported it as the strongest kind: 29 of 29 events flagged "never published" on every boot, for an app where all 29 were live.
|
|
388
|
+
|
|
389
|
+
The producer half now **abstains** for webhook events once an indirect emitter is found, and says so: `N webhook event(s) NOT verified … Not a warning, and not a pass either.` Abstaining silently would be its own defect — a check that stops reporting is indistinguishable from a codebase that got fixed.
|
|
390
|
+
|
|
391
|
+
The abstention is scoped to the webhook audience. An in-app event still has `publish(` to find, and a genuinely dead webhook event is still reported in a project whose emit sites name their events.
|
|
392
|
+
- **@voltro/cli** — `react-intl` joins `react` / `react-dom` in Vite's `resolve.dedupe`, in `voltro dev` and in every `voltro build` SSR config.
|
|
393
|
+
|
|
394
|
+
It carries a React CONTEXT whose two ends resolve from different roots: the framework builds `<I18nProvider>` by loading `@voltro/i18n` through Vite's SSR loader from its own dir, while the app's `useT()` imports it from the app root. Two physical copies means the provider is present and INVISIBLE — `useIntl` reads the other instance's context and throws *"[React Intl] Could not find required `intl` object"*, byte for byte the error you get when there is no provider at all.
|
|
395
|
+
|
|
396
|
+
A single-app fixture cannot surface this (only one copy ever exists), which is why the guard is the config rather than a test. Dev and build dedupe the same set on purpose — an app that renders in one and not the other is the boot-path divergence class.
|
|
397
|
+
- **@voltro/runtime** — **`column(...)` in an `.aggregate({})` spec crashed the memory store.** The documented way to project a grouped key (`groupBy(['status']).aggregate({ status: column('status'), n: count() })`) has always compiled on every SQL dialect — and threw `computeAggregates: unknown op 'column'` on `store: 'memory'`. Worse, only once the table held a row: an empty table never reaches the evaluator, so the aggregate looked healthy exactly until it had data. Found live against the reference app's `orderStats` aggregate; the memory evaluator now answers the op from the group's key (every row in the bucket shares it by construction), pinned by a parity test.
|
|
398
|
+
|
|
399
|
+
codemod: none.
|
|
400
|
+
- **@voltro/database** — **In-memory Date predicates compared by REFERENCE, so every range boundary was off by one row.** `evaluatePredicate`'s comparator checked `lhs === rhs` before `>` — reference equality for objects — so two Date objects holding the SAME instant compared as "less than". `gte(startedAt, T)` EXCLUDED a row whose value was exactly T, `lt(startedAt, T)` INCLUDED it, and `eq`/`neq`/`in`/`notIn` never matched a Date at all unless it was literally the same object. SQL never had the bug (the compiler emits `>=`/`<`), which is what kept it invisible: the same query returned different rows on the memory store than on postgres, only at the boundary millisecond.
|
|
401
|
+
|
|
402
|
+
Same defect class as the analytics sink that lost same-millisecond events — an instant-boundary comparison whose failure is one row, at one millisecond, in one store. All comparators now normalise Dates to their instant (`equalsValue` / `compareNumeric`), and `datePredicateBoundary.test.ts` pins every operator on both sides of the boundary.
|
|
403
|
+
|
|
404
|
+
Affects everything the in-memory evaluator serves: the `store: 'memory'` store, the reactive engine's pre-filter, and unit-test fixtures — which also means a test that "passed" against a memory fixture and failed against SQL at a time boundary was this, not your code.
|
|
405
|
+
|
|
406
|
+
codemod: none.
|
|
407
|
+
- **@voltro/cli** — **An event published from a MUTATION never reached its durable audience — no event-log row, no triggered workflow, while the mutation reported success.** Two independent defects, one symptom, both boot paths:
|
|
408
|
+
|
|
409
|
+
1. **The events facade wrote through the mutation's TRANSACTION.** `publish` correctly defers the durable half to `lifecycle.afterCommit` — but by then the transaction is closed, so the `_voltro_workflow_events` insert failed (or vanished into a discarded overlay) and the deliberate `.catch(() => {})` on the emit hid it. The facade writes through the BASE store now: post-commit facts do not belong to a closed transaction. (The OUTBOX intent stays on the transactional view on purpose — it is written DURING the handler and must die with a rollback.)
|
|
410
|
+
|
|
411
|
+
2. **The trigger's workflow start was deferred TWICE.** The start closure wrapped itself in the post-commit facade even though it is only ever reached post-commit — so it pushed its real `start` onto an afterCommit drain that had already finished. The delivery row optimistically said `started` with a minted execution id, and the engine never saw the run: no run row, no admission entry, no error.
|
|
412
|
+
|
|
413
|
+
Found LIVE, not by a test: the reference durable app's advertised chain (mutation → `order.placed` → trigger → `orders.fulfill`) placed orders that never fulfilled. The action-shaped bridge tests stayed green throughout, because outside a transaction both stores are the same object and nothing defers — which is exactly the shape the new regression test builds: a mutation-formed context with a lifecycle and a tx store that refuses writes after commit, asserting the event row exists AND the workflow really started. Both halves red-verified.
|
|
414
|
+
|
|
415
|
+
Publishes from actions, schedules, startup hooks and workflow bodies were never affected.
|
|
416
|
+
|
|
417
|
+
codemod: none — no user-authored code changes; the fix restores the documented behavior.
|
|
418
|
+
- **@voltro/runtime, @voltro/cli** — **`guards.rateLimit` on a reaction was neither per-key nor a limit — two defects, both reported from production.**
|
|
419
|
+
|
|
420
|
+
It reads as a per-key cap. The runner keyed the limiter on the **reaction name**, so one cap covered every row and every tenant that reaction watched: an app with a hundred tenants got a hundredth of the throughput it declared, and the busiest tenant starved the rest.
|
|
421
|
+
|
|
422
|
+
And the limiter was **in-memory, per process**. With three replicas the effective cap was 3×, and nothing in the declaration said so — the same config produced a different limit depending on how many pods happened to be running.
|
|
423
|
+
|
|
424
|
+
```ts
|
|
425
|
+
rateLimit: { limit: 10, windowMs: 60_000, key: (e) => e.new.tenantId }
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
`key` partitions the cap; omitting it keeps the GLOBAL meaning, which is a legitimate thing to want (a cap on a scarce downstream) — just not what the field appeared to offer. The limiter is now a claim in the shared store, using the same INSERT-wins arbiter the cron scheduler relies on, so the cap holds across replicas. A read-then-write would not: two replicas both read N-1, both fire, and the cap is exceeded by exactly the number of concurrent replicas.
|
|
429
|
+
|
|
430
|
+
That forces a FIXED window (a sliding one needs prior timestamps, i.e. a read), with the standard artefact: up to 2× the limit can fire across a bucket boundary. Stated rather than hidden, and a far smaller error than the N× it replaces — 2× transiently at a boundary versus N× permanently.
|
|
431
|
+
|
|
432
|
+
Where no durable claimer is wired (dev on the memory store) the per-process fallback remains, and `attachReactions` now says so ONCE at boot rather than leaving it to be discovered. The partition key applies there too, so the per-entity half of the fix survives.
|
|
433
|
+
|
|
434
|
+
Also: **`act` can shape the workflow's payload.**
|
|
435
|
+
|
|
436
|
+
```ts
|
|
437
|
+
act: { kind: 'workflow', workflow: 'tourNarration', payload: (e) => ({ rowId: e.new.id }) }
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
Without it the workflow's payload schema was dictated by the watched TABLE's row shape — every column travelling whether the workflow wanted it or not, and a `timestamp()` column arriving as a `Date` on MariaDB and a number elsewhere, so apps were normalising on both sides of an idempotency key. Omitting `payload` keeps the changed row, exactly as before.
|
|
441
|
+
- **@voltro/runtime, @voltro/voltro, @voltro/cli** — **`apiSurface: compatible`, and the reason:** `bindMutation` gained a seventh parameter and it is OPTIONAL. Every existing call site compiles and behaves exactly as before — omitting it skips the new check entirely, which is the deliberate default for a caller that cannot name a schema. `@voltro/voltro`'s golden churns only because it re-exports runtime. Nothing was removed, narrowed, or renamed.
|
|
442
|
+
|
|
443
|
+
A TAGGED error a procedure does not DECLARE no longer reaches the browser as the full `ExitEncoded<…>` decode tree.
|
|
444
|
+
|
|
445
|
+
It was a third category neither guard could see: the untagged-failure catch skips it (it has a `_tag`), `INFRA_ERROR_TAGS` skips it (it is not on a curated list), and the rpc encoder then cannot match it against the descriptor's `error:` union and ships the whole tree — ~2 KB for a one-line cause, with the message at the END so every tool that truncates shows the useless half. A consumer met it with `TenantScopeViolation`.
|
|
446
|
+
|
|
447
|
+
**Adding that tag to the infra list would have been wrong**, and that is the interesting part. `effectStore.ts` documents `error: Schema.Union(TenantScopeViolation, StoreOperationFailed, MyDomainError)` as a supported declaration, so an app that DECLARES it must still receive it typed. Collapsing unconditionally would break that app to fix the other one.
|
|
448
|
+
|
|
449
|
+
So the rule is a predicate, not a longer list: **tagged AND not representable by THIS descriptor's declared union** — `Schema.is(descriptor.error)`. The union IS the contract, so asking it directly cannot drift from what the encoder accepts. A call site that supplies no schema keeps the old behaviour exactly, rather than collapsing errors it cannot classify. Wired in dev AND serve: a sanitiser active on one boot path only is the drift class the parity guard exists for.
|
|
450
|
+
|
|
451
|
+
`defectMessage` now prefixes the `_tag` when there is one. A `Schema.TaggedError` with no `message` field rendered as an empty string, so the collapsed `InternalError` arrived correct, small AND useless — half a fix for the tree it replaces.
|
|
452
|
+
- **@voltro/plugin-webhooks, @voltro/cli** — **`webhooks.subscribe(...)` could not write its own table.** From any authenticated executor it died with:
|
|
453
|
+
|
|
454
|
+
```
|
|
455
|
+
TenantScopeViolation: cannot insert into tenant-scoped table without an
|
|
456
|
+
authenticated tenant — subject.tenantId is null. Either authenticate first or
|
|
457
|
+
pass tenantId explicitly in the row (admin tooling).
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
`_voltro_webhook_targets` carries `.with(tenant())`; the mixin scopes by the REQUEST subject; the service is built once at boot with the app-level store and no subject. The READ path got its binding in 0.29.0 (`EmitOptions.tenantId`, from the acting subject). The WRITE path had the identical gap and no equivalent — and **both escapes the error message named were unreachable**: you cannot "authenticate first" against a subject-less service, and `SubscribeInput` had no `tenantId` to pass.
|
|
461
|
+
|
|
462
|
+
`ctx.webhooks.subscribe(...)` now binds the acting subject's tenant, exactly as `emit` does, and `SubscribeInput.tenantId` exists for the admin tooling the message mentions. An explicit value at the call site wins; an explicit `null` survives (a deliberate system-wide subscription) rather than being replaced.
|
|
463
|
+
|
|
464
|
+
**What this invalidates, and it cuts both ways:** a `count(*) FROM _voltro_webhook_targets` of `0` did not mean "unused". It meant "never worked". A consumer read their zero as "the feature is unannounced"; we read it as "not exposed". Neither was true, and the empty table looked like evidence to both of us. Anything downstream that rested on that zero — an exposure assessment, a "nothing to purge" — has to be re-asked now that a subscription can exist.
|
|
465
|
+
|
|
466
|
+
### Internal (no consumer-facing effect)
|
|
467
|
+
|
|
468
|
+
- **@voltro/cli** — Guard test (`clusterPatchDialectGuard.test.ts`) that fails fast if a dependency bump re-vendors the `@effect/cluster` patch with an unconditional `BigInt(deliver_at_in)` cast — the exact shape that hung the sql-sqlite cluster/workflow suite for a week (safeIntegers(true) + a bigint deliver_at → message never delivers → workflow hangs, misread as flakiness). Self-tested: the negative matcher catches the buggy line and passes the mssql-only conditional. Test-only, no consumer effect.
|
|
469
|
+
- **@voltro/cli** — One shared `walkSourceFiles`, and a guard that makes source-tree guards use it.
|
|
470
|
+
|
|
471
|
+
Our codegen and agent suites create scratch fixtures INSIDE `packages/cli/src` (`mkdtemp(join(here, '.agent-fixtures-…'))`) because the codegen imports them through vite's module graph, which is rooted at the package. A guard that walks `src/` concurrently races them, and the failure is always the same shape: the whole FILE dies at COLLECTION time with `ENOENT` on a path nobody recognises, and it is green when re-run alone — the signature people write off as flake.
|
|
472
|
+
|
|
473
|
+
**Third occurrence, and that is why this is a function rather than another paragraph.** `ledgerReadPortability` hit it with `readdirSync` + `statSync` (two syscalls, one gap) and `packages/cli/CLAUDE.md` gained "any new guard that walks a source tree must do both". `broadcastNamespaceCoverage` then hit it while that rule was written down and current: it had `withFileTypes` — half the rule — and descended into a `.scan-fixtures-…` directory another suite had just removed.
|
|
474
|
+
|
|
475
|
+
`walkSourceFiles` has three properties, each load-bearing: one syscall per entry, dot-directories skipped (a scratch dir is never source, so this is right on its own terms), and a directory that vanishes mid-walk is skipped rather than fatal.
|
|
476
|
+
|
|
477
|
+
Six guards migrated — one of which still carried the ORIGINAL `readdirSync` + `statSync` shape. `sourceWalkDiscipline.test.ts` fails if a file reads a package `src/` without importing the shared walker; its first version flagged four files that had just been migrated correctly (a single-level `readdirSync` enumerating package directories is the shape we WANT), so the rule is "import the walker", not "never call readdirSync".
|
|
478
|
+
- **@voltro/cli** — The cross-replica latency test can now tell a dropped MESSAGE from a dropped CONNECTION.
|
|
479
|
+
|
|
480
|
+
It asserted zero loss over a raw subscribe — a stronger claim than the transport makes. Redis pub/sub has no retention, so when a subscriber's broker connection blips, everything published during the blip is gone by design. The shortfall looks identical to real loss, and the assertion reported the first as the second: `expected 163 to be 200` inside a full gate run (80 packages plus an 11-service docker stack on 12 cores), while the same test passed 8/8 in isolation — including under 12 busy loops.
|
|
481
|
+
|
|
482
|
+
**A loss check that a contended machine can trip cannot be trusted about loss, which is the only thing it exists to say.**
|
|
483
|
+
|
|
484
|
+
The bus already publishes the fact needed to separate them: `kind: 'gap'` with a PROVEN `missed` count. The test now records gaps and, when any occurred, skips the loss assertion LOUDLY with the count — a run that could not measure must not read like a run that measured nothing wrong. With no gap, a shortfall IS loss and still fails; red-verified by dropping every fifth delivery. The latency budget applies either way, guarded by a floor so the percentiles are never computed over a sample too small to mean anything.
|
|
485
|
+
|
|
486
|
+
Production recovery for a real gap is unchanged and covered elsewhere (`busGapDetection.test.ts`): a live query is idempotent, so the bus detects the gap and re-runs.
|
|
487
|
+
|
|
488
|
+
---
|
|
489
|
+
|
|
42
490
|
## [0.29.0] — 2026-08-07
|
|
43
491
|
|
|
44
492
|
### ⚠ BREAKING
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voltro/plugin-audit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.1",
|
|
4
4
|
"description": "Audit plugin — ships the `audit()` schema mixin (createdAt/updatedAt/createdBy/updatedBy → Actor) plus an optional mutation interceptor that records every call to a configurable sink (console / memory / custom function).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"voltro",
|
|
@@ -37,9 +37,9 @@
|
|
|
37
37
|
"node": ">=24.0.0"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@voltro/database": "0.
|
|
41
|
-
"@voltro/logger": "0.
|
|
42
|
-
"@voltro/protocol": "0.
|
|
40
|
+
"@voltro/database": "0.30.1",
|
|
41
|
+
"@voltro/logger": "0.30.1",
|
|
42
|
+
"@voltro/protocol": "0.30.1"
|
|
43
43
|
},
|
|
44
44
|
"peerDependencies": {
|
|
45
45
|
"effect": "^3.22.0"
|