@voltro/cli 0.26.0 → 0.28.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 +532 -0
- package/dist/apiBuild-DgBS9ayv.js +2 -0
- package/dist/{apiBuild-BtrTyKnF.js → apiBuild-eUM32r1u.js} +2 -2
- package/dist/bin.js +2 -2
- package/dist/{commands-D-9iYF2D.js → commands-CSkrUI1h.js} +2430 -2238
- package/dist/{dbCommand-uuNCrFAb.js → dbCommand-CpYgmSw4.js} +1 -1
- package/dist/dbCommand-DvguqlzF.js +2 -0
- package/dist/{dev-Dbm6SWtn.js → dev-CEZwJhmb.js} +2825 -2540
- package/dist/dev-DlBWWnJQ.js +3 -0
- package/dist/index.js +1 -1
- package/dist/{seedRunner-D6eu-u5U.js → seedRunner-Bqxgp7HZ.js} +60 -59
- package/dist/serveCommand-ZTn-dPFa.js +1425 -0
- package/dist/serveEntry.js +2 -2
- package/package.json +17 -17
- package/templates/AGENTS.md +1 -1
- package/templates/agent-docs/_index.md +1 -1
- package/templates/agent-docs/data.md +219 -0
- package/templates/agent-docs/deployment.md +56 -0
- package/templates/agent-docs/plugins.md +28 -0
- package/templates/agent-docs/testing.md +42 -0
- package/templates/agent-docs/whats-new.md +157 -286
- package/templates/apps/api-ai/package.json +7 -7
- package/templates/apps/api-auth/package.json +8 -8
- package/templates/apps/api-backend/package.json +7 -7
- package/templates/apps/api-backend-deactivation/package.json +7 -7
- package/templates/apps/api-backend-mail/package.json +8 -8
- package/templates/apps/api-backend-mariadb/package.json +9 -9
- package/templates/apps/api-backend-storage/package.json +8 -8
- package/templates/apps/api-data-advanced/package.json +8 -8
- package/templates/apps/api-durable/package.json +8 -8
- package/templates/apps/api-feature-flags/package.json +9 -9
- package/templates/apps/api-governance/package.json +8 -8
- package/templates/apps/api-kv/package.json +8 -8
- package/templates/apps/api-moderation/package.json +8 -8
- package/templates/apps/api-observability/package.json +8 -8
- package/templates/apps/api-ratelimit/package.json +8 -8
- package/templates/apps/api-rbac/package.json +8 -8
- package/templates/apps/api-rest/package.json +7 -7
- package/templates/apps/api-saas/package.json +11 -11
- package/templates/apps/api-search/package.json +8 -8
- package/templates/apps/api-versioning/package.json +8 -8
- package/templates/apps/api-webhooks/package.json +9 -9
- package/templates/apps/changelog/package.json +6 -6
- package/templates/apps/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/package.json +8 -8
- package/templates/apps/frontend-app/package.json +8 -8
- package/templates/apps/frontend-blank/package.json +7 -7
- package/templates/apps/frontend-contact/package.json +7 -7
- package/templates/apps/frontend-dashboard/package.json +7 -7
- package/templates/apps/frontend-docs/package.json +7 -7
- package/templates/apps/frontend-i18n/package.json +6 -6
- package/templates/apps/frontend-landing/package.json +7 -7
- package/templates/apps/frontend-spa/package.json +7 -7
- package/templates/apps/frontend-ssr/package.json +7 -7
- package/templates/apps/frontend-ssr-api/package.json +8 -8
- package/templates/apps/frontend-static-blog/package.json +6 -6
- package/dist/apiBuild-DDJ0It4j.js +0 -2
- package/dist/dbCommand-DrzXimKf.js +0 -2
- package/dist/dev-BnWq4jeA.js +0 -3
- package/dist/serveCommand-XBXuwJty.js +0 -1294
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# What's new in 0.
|
|
1
|
+
# What's new in 0.27.0
|
|
2
2
|
|
|
3
3
|
Read this FIRST when a task touches an area you have not worked in recently.
|
|
4
4
|
It is the cheapest way to notice that the framework grew the thing you were
|
|
@@ -7,424 +7,295 @@ workaround for something that shipped two versions ago.
|
|
|
7
7
|
|
|
8
8
|
BREAKING entries name a codemod; run `voltro update` to apply it.
|
|
9
9
|
|
|
10
|
-
### ⚠ BREAKING
|
|
11
|
-
|
|
12
|
-
- **@voltro/plugin-broadcast, @voltro/plugin-presence, @voltro/cli** — **Two Voltro apps pointed at one Redis or NATS were publishing into each other's channels. The option documented as the fix for that was never read.**
|
|
13
|
-
|
|
14
|
-
Every framework channel was a flat constant with no per-app component — `voltro:changes`, `voltro:events`, `voltro:members`, `voltro:presence` — and the providers pass channel names to the broker verbatim. So a shared broker made one app's change events wake another app's matchers, one app's presence deltas land in another app's roster (adding members that can never leave: there is no owner for membership to time out), and, since events were unified, one app's events arrive at another app's clients.
|
|
15
|
-
|
|
16
|
-
`BroadcastPluginOptions.channel` existed for this. Its own doc comment named it as the answer for several deployments sharing one broker. It was declared, it was documented, and **nothing ever forwarded it out of the options object** — proven by test before it was replaced. Setting it did nothing, silently, while looking like a solution.
|
|
17
|
-
|
|
18
|
-
It is now **one namespace for all four channels**:
|
|
19
|
-
|
|
20
|
-
```ts
|
|
21
|
-
broadcast({ provider: 'redis', namespace: 'shop-prod' })
|
|
22
|
-
```
|
|
23
|
-
|
|
24
|
-
A per-channel override would have been the wrong shape even working: escaping cross-talk means changing four names, three of which had no option at all, and fixing one of four is a half-fix that reads as a whole one.
|
|
25
|
-
|
|
26
|
-
**The default derives from your app's name**, so two different apps separate without anyone configuring anything. That ordering is deliberate — a namespace you must remember to set is one two apps forget to set, and the failure is silent in the worst direction.
|
|
27
|
-
|
|
28
|
-
**The one case derivation cannot see**, stated plainly rather than papered over: staging and production of the SAME app share a name, the code and every fingerprint. Nothing derivable tells them apart. If one broker serves several deployments of one app, `namespace` or `VOLTRO_BROADCAST_NAMESPACE` is not optional — it is the only thing that can work.
|
|
29
|
-
|
|
30
|
-
Resolution: `broadcast({ namespace })` → `VOLTRO_BROADCAST_NAMESPACE` → app name. Values are lowercased and reduced to `a-z0-9_-`. The reason, measured against nats:2 rather than assumed — the first version of this note had it wrong:
|
|
31
|
-
|
|
32
|
-
| In a name | What NATS does | | --- | --- | | a `.` beside a `>` (`shop.>`) | matches `shop.other` — wildcards are token-level, tokens are dot-separated | | a name that IS `>` or `*` | matches EVERY subject on the server | | whitespace | rejects the subject outright — the app receives nothing at all |
|
|
33
|
-
|
|
34
|
-
A wildcard inside a token is inert (`shop>:changes` does not match `other:changes`), so the dangerous inputs are narrower — and different in kind: the whitespace case is not a leak but a silent hard failure. A name reducing to nothing falls through to the next candidate rather than becoming an empty prefix. Redis is indifferent to all three; the sanitiser is the strict intersection.
|
|
35
|
-
|
|
36
|
-
The codemod rewrites `channel` → `namespace` and strips a trailing `:changes` (the framework appends the channel kind itself, so carrying the old value verbatim would produce `myapp:prod:changes:changes` — a channel nobody publishes to, and silent). A non-literal value is carried verbatim and flagged for review rather than guessed at. It also tells you the old option never took effect, which is the part a rename would otherwise hide.
|
|
37
|
-
|
|
38
|
-
Namespaces are resolved ONCE per boot and threaded to all four wirings; `dev`, `serve` and the plugin bind context call the same helper, because four independent derivations of one value is four chances to produce a replica that publishes where nobody listens.
|
|
39
|
-
- **@voltro/runtime, @voltro/cli** — **Each declared event now travels on its own cross-instance channel (`voltro:events:<name>`), and a replica subscribes only while it has a local subscriber for that event.**
|
|
40
|
-
|
|
41
|
-
No user-authored code is affected — hence `codemod: none`. The channel name is internal to the transport; `defineEvent`, `ctx.events.publish` and `useEvent` are unchanged.
|
|
42
|
-
|
|
43
|
-
Previously every event shared one channel, so every replica received, JSON-decoded and materialised a route for every event of every peer — including the ones it served no clients for. With five replicas and one high-rate event whose subscribers all sat on one of them, four replicas did that work and threw the result away.
|
|
44
|
-
|
|
45
|
-
**The operational consequence to plan for:** during a rolling deploy, replicas on different framework versions use different channel names, so cross-replica delivery is degraded for the length of the rollout. Local delivery on each replica is unaffected throughout, and the two sets converge when the rollout completes.
|
|
46
|
-
|
|
47
|
-
Interest is tracked per EVENT (not per route) and the transport re-reads the desired state when its async `subscribe` resolves — a subscriber that arrives and leaves inside that window would otherwise leave a live subscription behind, a leak that grows with reconnect churn and never reports itself. Registering the interest listener replays what is already subscribed, so a client that attached between the bus being built and the transport being wired is not left unwired.
|
|
48
|
-
- **@voltro/runtime, @voltro/cli** — **`ctx.events.emit('name', data)` is gone. `ctx.events.publish(descriptor, key, payload)` is the only emitter, and it drives BOTH audiences.**
|
|
49
|
-
|
|
50
|
-
The string emitter and the declared event were two ways to say the same thing, and only one of them can be checked. `emit` matched a workflow trigger BY NAME: rename the event on one side and the trigger silently stops matching, the workflow never runs again, and nothing errors. That is the exact defect a consumer reported having with their own string channels — two spellings of one event, both subscribed, one dead since the day it was written — so shipping the typed event while keeping the untyped emitter would have shipped the fix and the defect together.
|
|
51
|
-
|
|
52
|
-
```ts
|
|
53
|
-
// before
|
|
54
|
-
await ctx.events.emit('orders.paid', { orderId, total })
|
|
55
|
-
triggerWorkflow({ event: 'orders.paid', workflow: 'fulfil' })
|
|
56
|
-
|
|
57
|
-
// after
|
|
58
|
-
yield* ctx.events.publish(orderPaid, { orderId }, { total })
|
|
59
|
-
triggerWorkflow({ on: orderPaid, workflow: 'fulfil' })
|
|
60
|
-
```
|
|
61
|
-
|
|
62
|
-
**Nothing was lost with it.** `publish` still writes `_voltro_workflow_events`, still starts every matching trigger, and still records a delivery row per trigger — it does that from ONE call, on the SAME commit boundary as the client fan-out. Two emitters could disagree about whether the thing happened; one cannot. A trigger failure still cannot fail the mutation that published, for the same reason a broker outage cannot.
|
|
63
|
-
|
|
64
|
-
The codemod is `manual`, and the reason is the actual guidance: the rewrite needs a routing `key` and nothing can derive one. The key decides WHO receives the event, so a guessed `{}` compiles and fans every event out to every listener, while a guessed field fans it out to none. Both fail silently, which is what this change is about. The printed steps say how to choose one.
|
|
65
|
-
|
|
66
|
-
**Also: a subscriber can now PUBLISH a declared event** (`ctx.publish` in `*.subscribe.ts`, present only when the app declares any). A row changing and a thing happening are different statements, and usually only the second is what a client cares about — nobody watches `attendance` rows, they watch "attendance changed". Without the bridge, a table-derived event has to be published from every mutation that touches the table, and from the next one somebody adds: fail-open by omission, which is the shape a declaration exists to remove. Best-effort by nature — it fires after the commit, so there is no transaction left to couple to. When the event must not be lost, publish it from the mutation.
|
|
67
|
-
- **@voltro/plugin-webhooks, @voltro/cli** — **`defineOutgoingEvent` is gone. An outbound webhook event is an AUDIENCE of a declared event.**
|
|
68
|
-
|
|
69
|
-
```ts
|
|
70
|
-
// before — events/order.completed.webhook.tsx
|
|
71
|
-
export default defineOutgoingEvent({ id: 'order.completed', payload: P, version: 1 })
|
|
72
|
-
|
|
73
|
-
// after — events/orders.event.ts
|
|
74
|
-
export const orderCompleted = defineEvent({
|
|
75
|
-
name: 'order.completed',
|
|
76
|
-
key: Schema.Struct({}),
|
|
77
|
-
payload: P,
|
|
78
|
-
webhook: { version: 1 },
|
|
79
|
-
})
|
|
80
|
-
```
|
|
81
|
-
|
|
82
|
-
This completes the unification. One declaration, and `ctx.events.publish` reaches connected clients, workflow triggers AND subscribed HTTP targets from the same call, on the same commit boundary. Two declarations of one thing drift — the defect the event primitive exists to remove — and keeping both forms would have shipped the fix beside it.
|
|
83
|
-
|
|
84
|
-
**The codemod is a `transform`, and the contrast with its sibling is the useful part.** The string-emitter codemod had to be `manual` because the rewrite needs a routing key and nothing can derive one: only the author knows who should receive an event. This one needs no key. A webhook event is delivered to subscribed TARGETS, not to a key, so `key: Schema.Struct({})` is the correct answer rather than a guess — and everything else maps 1:1.
|
|
85
|
-
|
|
86
|
-
`defaultRetry` and `defaultSigning` are deliberately NOT carried across. The plugin's shapes are richer than a browser-safe descriptor can hold; dropping them silently would remove a policy the author wrote, and inventing the missing fields would install one they did not. The transform leaves them as a compile error and says so — configure them at subscribe time, where the full shape is typed. `globalRateLimit` becomes `rateLimit`: "global" only ever meant "not per-target", and beside three audiences that word would read as "across all of them".
|
|
87
|
-
|
|
88
|
-
**Nothing downstream changed shape.** `OutgoingEventDescriptor` survives as the internal form the delivery workflow, the JSON-Schema export and the dashboard's event list all read; a declared event is PROJECTED onto it. Giving declared events a parallel path would mean each of those consumers handles two shapes, which is how two shapes drift apart.
|
|
89
|
-
|
|
90
|
-
Webhook DISCOVERY now merges declared events into the same `outgoing` bucket it always produced, in both boot paths — so the six consumers of that bucket are untouched.
|
|
91
|
-
|
|
92
10
|
### Added
|
|
93
11
|
|
|
94
|
-
- **@voltro/plugin-
|
|
12
|
+
- **@voltro/plugin-audit, @voltro/plugin-versioning** — `scope` — the app's own scoping dimension on `_voltro_audit_log` **and** `_voltro_row_history`, supplied by a `resolveScope` option on each plugin.
|
|
95
13
|
|
|
96
|
-
|
|
14
|
+
The last thing between a consumer and deleting a 2900-row, 300-call-site hand-rolled audit trail. Their trail and its retention are per-TEAM; a tenant has many teams, so `.with(tenant())` is one level too coarse and every view they render filters by team first. It is the same column `_voltro_webhook_targets.scope` already carries: opaque json in, opaque json out, equality filtering.
|
|
97
15
|
|
|
98
|
-
|
|
16
|
+
**Deliberately not `metadata`.** They offered to carry `teamId` there and filter in memory, and were right to dislike it: `metadata` is documented as the app's free-form note — the noun a diff cannot contain — so filtering on it builds a read path against a column whose contract says it is not one. Two columns, two jobs.
|
|
99
17
|
|
|
100
|
-
|
|
18
|
+
The app supplies the value, because the framework does not know what a team is — which is the whole reason the column is opaque. `auditPlugin` derives it from the call (`(ctx) => ({ teamId: ctx.subject.metadata?.teamId })`); `versioningPlugin` from the changed ROW (`(row) => ({ teamId: row.teamId })`), because that is what that plugin has and where a per-table dimension lives. Configure both or half of every view is unfiltered.
|
|
101
19
|
|
|
102
|
-
|
|
20
|
+
Neither resolver can fail the write it annotates: an underivable scope is `null`, the same answer as not configuring one.
|
|
103
21
|
|
|
104
|
-
|
|
22
|
+
codemod: none
|
|
23
|
+
- **@voltro/plugin-notifications** — `notificationsPlugin({ resolveSubjectId })` — the app names its own addressing unit.
|
|
105
24
|
|
|
106
|
-
|
|
25
|
+
An inbox belonged to `subject.id`. That is the framework's answer and not always the app's: a shift change, an absence request or a task reminder is addressed to a PERSON, and a person does not necessarily have an auth user. A reporter measured it on 14 670 rows — 4 677 addressable through a user, and **668 live, read rows belonging to four people who have none**.
|
|
107
26
|
|
|
108
|
-
|
|
109
|
-
- **@voltro/protocol, @voltro/runtime, @voltro/client, @voltro/cli, @voltro/testing, @voltro/voltro** — **`defineEvent` — the axis the framework did not have.** Voltro modelled "what IS" (a table, watched by a reactive query) extremely well and had exactly ONE server→client fan-out path: a query re-runs because a table changed. Anything that is not row state — a game starting, a door opening, a payment terminal confirming — had to invent a table, and two independent consumers built the same three bugs on top of a reactive list: a `seen` set, an `initialized` flag so page load does not replay the history into a live system, and a `limit` that silently truncates. Our own `plugin-presence` does it too.
|
|
27
|
+
The worse half is what follows a migration without it: every producer resolves person → user and **silently delivers nothing** for anyone missing one. That is the failure this plugin's own docstring warns about, one level up and structural rather than accidental.
|
|
110
28
|
|
|
111
|
-
|
|
112
|
-
// events/gameLifecycle.event.ts — browser-safe, may hold several
|
|
113
|
-
export const gameStarted = defineEvent({
|
|
114
|
-
name: 'games.started',
|
|
115
|
-
key: Schema.Struct({ arenaId: Schema.String }),
|
|
116
|
-
payload: Schema.Struct({ gameId: Schema.String, startedAt: Schema.Number }),
|
|
117
|
-
guards: [{ scope: 'display:read' }],
|
|
118
|
-
})
|
|
119
|
-
|
|
120
|
-
// any handler with a ctx — action, mutation, workflow, cron, subscriber
|
|
121
|
-
yield* ctx.events.publish(gameStarted, { arenaId }, { gameId, startedAt })
|
|
122
|
-
|
|
123
|
-
// the client
|
|
124
|
-
const { missed } = useEvent(gameStarted, arenaId ? { arenaId } : null, (payload) => {
|
|
125
|
-
scene.switchTo('running', payload.gameId) // payload is typed from the descriptor
|
|
126
|
-
}, { onMissed: ({ count }) => resync(count) })
|
|
127
|
-
```
|
|
29
|
+
One function, thirteen call sites — the same seam `auth.resolveScopes` already offers for this shape. Absent keeps `subject.id`, so nothing changes for an app whose units line up. A resolver that returns `undefined`, an empty string, or throws falls back to the subject rather than failing the read: an inbox must not go down because one caller has no employee record, or because a lookup hit a database that was briefly unavailable.
|
|
128
30
|
|
|
129
|
-
**`
|
|
31
|
+
**`useEvent` already returns what the same report asked for.** `{ status, missed, lastMiss }`, where `status === 'live'` is the connected flag — the ask was for discoverability, not an API, so the docs now name the case that motivated it: a wall display nobody is standing at keeps rendering the last thing it received, and from across the room stale and current look identical.
|
|
130
32
|
|
|
131
|
-
|
|
33
|
+
codemod: none
|
|
34
|
+
- **@voltro/plugin-presence** — The presence tracker gets a perf suite — the last of the four realtime surfaces without pinned numbers — and the four are now documented side by side.
|
|
132
35
|
|
|
133
|
-
|
|
36
|
+
The presence figures existed in the docs (a heartbeat, a 10 k roster) and were measured once by hand, which cannot fail. Same gap the event bus had, closed the same way: a `*.perf.test.ts` that prints what it measured and asserts the SHAPE rather than the microseconds.
|
|
134
37
|
|
|
135
|
-
|
|
38
|
+
Measured across all four, each asserted by a test:
|
|
136
39
|
|
|
137
|
-
|
|
40
|
+
| primitive | operation | cost | scales with | | --- | --- | --- | --- | | Events | `ctx.events.publish` | 4.3 µs (~232 k/s) | nothing | | Events | delivery to a subscriber | 0.027 µs | subscribers, cheaply | | Presence | a heartbeat | 0.16 µs | nothing | | Presence | a roster read, 10 k members | 547 µs | the ROOM | | Records | a live-query re-diff, 5 000 rows | 3 062 µs | the RESULT SET | | Broadcast | cross-replica over real Redis | p50 1.1 ms · p99 11.2 ms | the network |
|
|
138
41
|
|
|
139
|
-
|
|
42
|
+
**The comparison is what was missing, not the numbers.** Publishing an event costs about a thousandth of re-diffing a large live query, and that ratio is what should decide between them — a 60 Hz value belongs in an event, because the same value written to a table wakes every subscriber of every query reading it and each pays the full walk.
|
|
140
43
|
|
|
141
|
-
|
|
44
|
+
Two of the four are flat and two are not. That is the property the tests assert: the per-member cost of a roster read must not grow with the room, and the per-row cost of a diff must not grow with the result set. Either one growing is the difference between expensive and unusable.
|
|
142
45
|
|
|
143
|
-
|
|
144
|
-
- **@voltro/
|
|
46
|
+
codemod: none
|
|
47
|
+
- **@voltro/cli** — A capability matrix for the realtime surface — fifteen things people build, each mapped to the primitive that carries it, each asserted by a test.
|
|
145
48
|
|
|
146
|
-
|
|
49
|
+
"Nothing is missing" is not a checkable sentence. This turns it into one: `realtimeCapabilities.test.ts` asserts every row's primitive is still exported, so a capability that loses its primitive to a rename goes red in CI rather than being discovered by whoever tries to build it.
|
|
147
50
|
|
|
148
|
-
|
|
51
|
+
It caught one on its first run — the matrix claimed `useUpload` lived in `@voltro/plugin-storage` and it is in `@voltro/client`. A row pointing at the wrong package is exactly what a table in a document does silently.
|
|
149
52
|
|
|
150
|
-
|
|
151
|
-
export default defineEvent({
|
|
152
|
-
name: 'player.moved',
|
|
153
|
-
key: Schema.Struct({ arenaId: Schema.String }),
|
|
154
|
-
payload: Schema.Struct({ playerId: Schema.String, x: Schema.Number, y: Schema.Number }),
|
|
155
|
-
access: 'authenticated',
|
|
156
|
-
delivery: 'latest',
|
|
157
|
-
})
|
|
158
|
-
```
|
|
53
|
+
It asserts EXPORTS rather than behaviour on purpose. Behaviour is what the other suites are for, and duplicating them here would make this a slower copy of them. What it catches is the gap between "we support that" and "the thing that supports it still exists".
|
|
159
54
|
|
|
160
|
-
The
|
|
55
|
+
The same table is in the docs, with the three capabilities people usually reach for wrongly called out: a value changing many times a second is an EVENT and not a row (writing it to a table wakes every subscriber of every query reading that table, each paying a full re-diff); "who is online" is presence rather than a table; and "did anything get lost" has a computed answer in `missed`, so nobody needs to build a heartbeat of their own to find out.
|
|
161
56
|
|
|
162
|
-
|
|
57
|
+
codemod: none
|
|
58
|
+
- **@voltro/cli** — The ten hard questions a realtime system is judged on, with this framework's answer and — enforced by a test — the proof behind each.
|
|
163
59
|
|
|
164
|
-
|
|
165
|
-
- **@voltro/protocol, @voltro/plugin-webhooks, @voltro/cli** — **A declared event can now reach subscribed HTTP targets too — one declaration, three audiences.**
|
|
60
|
+
`realtimeProperties.test.ts` fails if a row's proof disappears: a property may not be CLAIMED without something in the repository that demonstrates it. Red-verified by re-pointing one row at a test that does not exist.
|
|
166
61
|
|
|
167
|
-
|
|
168
|
-
export const orderPaid = defineEvent({
|
|
169
|
-
name: 'orders.paid',
|
|
170
|
-
key: Schema.Struct({ orderId: Schema.String }),
|
|
171
|
-
payload: Schema.Struct({ total: Schema.Number }),
|
|
172
|
-
webhook: { description: 'An order was paid', version: 2 },
|
|
173
|
-
})
|
|
174
|
-
|
|
175
|
-
yield* ctx.events.publish(orderPaid, { orderId }, { total })
|
|
176
|
-
// → connected clients (useEvent) + workflow triggers + subscribed HTTP targets
|
|
177
|
-
```
|
|
62
|
+
The questions, because they are the deliverable rather than the mechanism: is a missed delivery reported or silently dropped; can a late arrival tell "nothing happened" from "I was not listening"; is a SUBSCRIPTION authorized or only the connection; does a subscription outlive its credential; does the link heal itself after an outage; does a degraded network lose messages or only slow them; does fan-out cost grow with subscribers; are channels typed or strings; is a declared event nobody publishes reported; is cross-replica traffic separated per app by default.
|
|
178
63
|
|
|
179
|
-
|
|
64
|
+
**Why this replaces a benchmark against hosted competitors.** A table of our measured numbers beside someone else's published ones is not a comparison, it is two things in a row. Measuring a hosted product honestly needs its accounts, regions, tiers and retry policies, and a wrong number about someone else's product is worse than no number. What decides a choice is not the microseconds anyway — it is whether the system answers these questions at all, and every answer above is checkable against this repository by anyone.
|
|
180
65
|
|
|
181
|
-
|
|
66
|
+
codemod: none
|
|
67
|
+
- **@voltro/cli** — A real competitive measurement — against socket.io, on this machine, in the same topology.
|
|
182
68
|
|
|
183
|
-
|
|
69
|
+
This was declined twice on the grounds that a benchmark needs the competitor's accounts and regions. That reasoning holds for hosted products and **does not hold for socket.io**, which is an npm package: it can be installed, run and measured here with the same method. Declining it was over-broad.
|
|
184
70
|
|
|
185
|
-
|
|
71
|
+
Back to back, two server instances sharing one Redis, client on B, emits on A:
|
|
186
72
|
|
|
187
|
-
|
|
188
|
-
- **@voltro/runtime, @voltro/cli** — **Instance membership — which replicas are alive, and when one stops being.**
|
|
73
|
+
| | p50 | p99 | delivered | | --- | --- | --- | --- | | Voltro cross-replica | **1.29 ms** | 6.80 ms | 200/200 | | socket.io + redis-adapter | 1.89 ms | **3.81 ms** | 200/200 |
|
|
189
74
|
|
|
190
|
-
|
|
75
|
+
**~32% faster at the median, ~44% worse at the tail.** Both lossless. The p99 is ours to improve and is published rather than omitted, because a benchmark you only show when you win is advertising.
|
|
191
76
|
|
|
192
|
-
**
|
|
77
|
+
**The topology is what makes it a comparison.** The first attempt measured socket.io on a plain localhost websocket with no adapter and came out 3x faster — which proved nothing: that is one hop, ours is two through a broker. It would have flattered socket.io and been dishonest in their favour, which is the same defect as flattering ourselves.
|
|
193
78
|
|
|
194
|
-
|
|
79
|
+
Also measured and NOT published as a headline: socket.io's `emit` to 100 subscribers costs 13.7 µs against our 2.7 µs, but at that point ours has already run every listener while socket.io has only enqueued to 100 sockets — zero had arrived when the measurement ended. Two different quantities; comparing them would have been the same mistake in the other direction.
|
|
195
80
|
|
|
196
|
-
`
|
|
81
|
+
`scripts/bench/socketio-cross-replica.mjs` carries the method and the numbers so they can be re-taken. Deliberately a script, not a test: keeping a competitor in the dependency tree to hold a number green is the wrong trade.
|
|
197
82
|
|
|
198
|
-
|
|
83
|
+
codemod: none
|
|
84
|
+
- **@voltro/plugin-webhooks** — **A subscription is a SET of events, and the service now has a word for it.**
|
|
199
85
|
|
|
200
|
-
|
|
86
|
+
`subscribe({ events: [...] })` creates the rows in one call; a `{ scope }` selector addresses them as a group wherever a target id is accepted — `pauseTarget`, `resumeTarget`, `updateTarget`, `deleteTarget`, `rotateSecret`, `listDeliveries`.
|
|
201
87
|
|
|
202
|
-
|
|
88
|
+
A row is one event, but a subscription — as every webhook UI models it, ours included — is one URL with a list of event checkboxes. Without a name for the group, five checkboxes are five rows and every operation a user thinks of as single becomes a fan-out the app writes by hand: N pauses, N updates, N delivery reads merged and re-sorted, and a rotate that is delete + re-subscribe.
|
|
203
89
|
|
|
204
|
-
**
|
|
90
|
+
**The shared secret is why this is correctness and not ergonomics.** The receiver verifies ONE signature for ONE url, so N rows for one endpoint must sign identically — and there was no way to say so. `subscribe` mints a secret per call, `SubscribeResult` surfaces it once, `TargetPatch` cannot set it. So ticking a sixth event meant reading the secret column back out of `_voltro_webhook_targets` through the app's own database handle. That is exactly the coupling `listDeliveries` was added to remove, re-entered through a different door one release later.
|
|
205
91
|
|
|
206
|
-
|
|
207
|
-
- **@voltro/cli** — Restore drill — `voltro data restore <dir> --drill [--drill-url <url>]`. "A backup you have never restored is a hypothesis"; the drill turns it into a fact by restoring the artifact into a THROWAWAY database (from `--drill-url` / `DRILL_DB_URL`) and verifying it, WITHOUT ever touching the live DB. It refuses a drill target that resolves to the live connection (a drill that `--clean`s production is the disaster it exists to rehearse against). After the restore it introspects the throwaway DB and compares its schema fingerprint to the backup's stamp: zero tables → FAIL (empty / unreadable dump), fingerprint disagrees with the stamp → FAIL (the restore didn't reproduce what was backed up), tables + matching fingerprint → PASS. Exits non-zero on any FAIL, so a scheduled CI job turns a silently-broken backup into a red build. The verify is schema-level (introspect + fingerprint); a full app boot against the restored DB is a heavier follow-up. Pure decision logic (`resolveDrillTarget` / `assessDrillResult` / `connKey`) covered by 14 unit tests; the native round-trip is integration-tested where a matching `pg_dump` is available. `codemod: none` — a new opt-in flag; no user-authored code is affected.
|
|
208
|
-
- **@voltro/database, @voltro/cli** — Opt-in rolling-deploy refuse gate — `VOLTRO_ROLLING_DEPLOY=1`. The rolling-deploy safety classifier shipped as a `voltro db plan` advisory (a `⚠`, never a block), because the framework can't know the deploy strategy and a maintenance-window / scale-to-zero deploy has no overlap window. Operators who ALWAYS rolling-deploy can now opt into a hard gate: with `VOLTRO_ROLLING_DEPLOY=1` set, `voltro db apply` (both the auto-diff and the reviewed `--plan` path) REFUSES (exit 2) a plan containing a rolling-unsafe operation — a dropped/renamed column, a narrowed type, an added constraint — instead of warning, so an un-split breaking change fails the deploy rather than breaking pods at runtime. Override a specific apply with `--force`. Unset (the default) leaves the advisory behaviour untouched. The decision is a pure `assessRollingDeployGate` in `@voltro/database` (testable without a CLI, reusable by the cloud migration wall). `codemod: none` — a new opt-in env var; no user-authored code is affected.
|
|
209
|
-
- **@voltro/runtime** — Schedule (cron) observability metrics. The framework scheduler now emits three registry series on every firing — `voltro_schedule_runs_total{schedule,status}` (firings by name + `succeeded`/`failed`), `voltro_schedule_duration_seconds{schedule}` (histogram), and `voltro_schedule_last_success_timestamp_seconds{schedule}` (a gauge holding the UNIX time of the last SUCCESS). Emitted from the single scheduler seam, so EVERY app's crons get them with no per-handler wiring, scrapeable via `@voltro/plugin-prometheus` (`GET /metrics`), `GET /_voltro/inspect/metrics`, or the OTLP export — the same registry as the RPC/HTTP/subscription metrics. A cron fires unattended, so its failure mode is silent; the last-success gauge is the series to alert on (`time() - voltro_schedule_last_success_timestamp_seconds > interval × N`), because a failure counter alone can't catch a job that stopped firing at all. A failure moves the counter but deliberately NOT the gauge. `codemod: none` — additive metric emission; no user-authored code is affected.
|
|
210
|
-
- **@voltro/database, @voltro/runtime** — **`.version()` — optimistic locking, and the answer to "which write is newest".**
|
|
92
|
+
`subscribe` now mints one secret for the whole set, and `rotateSecret({ scope })` rotates every row to the same new value — which also replaces the delete-and-re-subscribe that minted new target ids and orphaned the delivery history.
|
|
211
93
|
|
|
212
|
-
|
|
94
|
+
**`secret` is deliberately still not patchable.** Adding it to `TargetPatch` would close the same gap by making a live credential app-writable, trading a coupling for a weaker invariant. The reporter proposed the constraint and declined that shortcut themselves.
|
|
213
95
|
|
|
214
|
-
|
|
215
|
-
table('documents', { id: id(), title: text(), version: integer().version() })
|
|
216
|
-
|
|
217
|
-
yield* ctx.store.update('documents', id, { title, version }) // the version the client READ
|
|
218
|
-
// → VersionConflict { expected: 3, actual: 7 }
|
|
219
|
-
```
|
|
96
|
+
A scope matching no row is an error rather than a no-op: "pause the endpoint" that pauses nothing and reports success is the silent shape this selector exists to avoid.
|
|
220
97
|
|
|
221
|
-
|
|
98
|
+
codemod: none
|
|
222
99
|
|
|
223
|
-
|
|
100
|
+
### Fixed
|
|
224
101
|
|
|
225
|
-
|
|
102
|
+
- **@voltro/cli** — Cross-replica delivery is now tested over a network that is not loopback.
|
|
226
103
|
|
|
227
|
-
|
|
104
|
+
This closes the one item repeatedly written off as needing external infrastructure — "two real pods over a real network". That was the wrong variable. What a loopback number cannot show is a path with LATENCY, JITTER and a bandwidth ceiling, and injecting those is not only possible in the test stack, it is BETTER than a real network for a test: reproducible, and degradable on purpose.
|
|
228
105
|
|
|
229
|
-
|
|
106
|
+
`toxiproxy-test` joins `test/docker-compose.yml` as a degradable path to `redis-test`. Measured through it:
|
|
230
107
|
|
|
231
|
-
|
|
232
|
-
table('inviteLinks', { id: id(), email: text() }).with(expires())
|
|
233
|
-
```
|
|
108
|
+
| condition | p50 | p99 | delivered | | --- | --- | --- | --- | | 20 ms ± 10 jitter | 26 ms | 89 ms | 200/200 | | + a 50 KB/s ceiling | 188 ms | 354 ms | 200/200 |
|
|
234
109
|
|
|
235
|
-
|
|
110
|
+
Seven times slower at the median under the second, and not one envelope lost. That is the property the new suite asserts: **degradation costs latency, never messages.**
|
|
236
111
|
|
|
237
|
-
|
|
238
|
-
- **@voltro/runtime, @voltro/workflow, @voltro/cli** — Workflow (durable-execution) observability metrics. The workflow run-recording seam now emits three registry series on every terminal outcome — `voltro_workflow_runs_total{workflow,status}` (`succeeded`/`failed`), `voltro_workflow_duration_seconds{workflow}` (histogram), and `voltro_workflow_last_success_timestamp_seconds{workflow}` (last-success gauge). Because the framework applies no retry of its own, a `failed` run is TERMINAL — it is the dead-letter state — so the failed counter IS the dead-letter rate, and the last-success gauge going stale is the "this workflow stopped completing" alert (`time() - voltro_workflow_last_success_timestamp_seconds > N`), mirroring the schedule metrics. A failure moves the counter but not the gauge. Same registry as the RPC/HTTP/subscription/schedule metrics → scrapeable via `@voltro/plugin-prometheus`, `/_voltro/inspect/metrics`, or OTLP. `@voltro/workflow` stays free of a `@voltro/runtime` dependency: the recorder is injected as an optional `recordRun` hook on the recording options (mirroring `emit`/`wakeups`), supplied by the CLI in BOTH boot paths. `codemod: none` — additive metric emission + a new optional hook; no user-authored code is affected.
|
|
112
|
+
The latency BUDGET is deliberately left in the healthy-path suite. Asserting it here would produce a test that goes red when the network is bad rather than when the code is — and the second row above is exactly that case.
|
|
239
113
|
|
|
240
|
-
|
|
114
|
+
codemod: none
|
|
115
|
+
- **@voltro/plugin-audit, @voltro/cli, @voltro/runtime** — **`@voltro/plugin-audit` could not boot — a release blocker, reported within a day.** 0.26.0 attached `interceptAction` and `interceptQuery` and declared neither scope, so the boot permission audit (`level: fatal`) refused to start EVERY app carrying the plugin, whether or not it had opted into query auditing. The audit inspects the presence of a hook, not what it does, so the identity passthrough counted.
|
|
241
116
|
|
|
242
|
-
|
|
117
|
+
The manifest declares both now — and `interceptQuery` is **attached** only when `recordQueries` is on, with its scope declared conditionally the way `store:write` already is. That is the reporter's suggestion and it is the better half of the fix: listing the scope unconditionally clears the boot while making every deployment DECLARE that it intercepts queries when almost none do, and a permission manifest is worth reading only if it describes what the plugin actually touches.
|
|
243
118
|
|
|
244
|
-
|
|
119
|
+
Their diagnosis of why it escaped is what the guard is built from: *a plugin's own test suite exercises the plugin, not a boot with the plugin installed*. The same shape as the `gc-snapshots` dialect bug one round earlier — the check that would have caught it is the one nobody ran on the affected path. There is now a test over the WHOLE `packages/plugin-*` set asserting that every hook a plugin ships has its scope named in its source, red-verified by reproducing 0.26.0.
|
|
245
120
|
|
|
246
|
-
**
|
|
121
|
+
**The stale-`source:` warning fired on the framework's own tables.** It resolved against the app's discovered entities, so every table the framework contributes conditionally — `_voltro_agent_messages` / `_voltro_agent_threads` behind a `*.agent.tsx`, and every plugin's `extendSchema.tables` — read as missing. The reporter got two warnings on every boot, for two sources that were correct, about a table the framework itself had created.
|
|
247
122
|
|
|
248
|
-
|
|
123
|
+
Their argument for why that is worse than cosmetic is the one that shaped the fix: this warning exists because a stale `source` is otherwise silent, so its entire value is being trusted. Firing on correct rows teaches the reader it is noise, and the next real one arrives into a warning nobody reads.
|
|
249
124
|
|
|
250
|
-
|
|
125
|
+
It resolves against the full live set now — app entities + plugin `extendSchema.tables` + framework tables, the same set auto-migrate emits DDL for — which means it runs after that set is assembled rather than inside `loadDiscovered`. Both boot paths do it, pinned by an ordering test.
|
|
251
126
|
|
|
252
|
-
|
|
127
|
+
codemod: none
|
|
128
|
+
- **@voltro/plugin-broadcast, @voltro/cli** — The broadcast namespace is normalised silently, and the silence reintroduces the hazard the namespace removes.
|
|
253
129
|
|
|
254
|
-
|
|
130
|
+
Found by probing the broadcast surface the way the events and records surfaces were probed. `broadcastPlugin` accepted all nine bad shapes tried — whitespace, a bare `>`, a trailing dot, an empty string, no options at all — and the sanitiser handles every one of them correctly. **No declaration-time refusal is warranted, and that is the finding**, not a gap.
|
|
255
131
|
|
|
256
|
-
|
|
257
|
-
- **@voltro/voltro** — **The umbrella package re-exports the event surface, and two trigger signatures widened.**
|
|
132
|
+
What the probe surfaced is one step on: `my app` and `my.app` BOTH resolve to `my-app`. Two deployments configured DIFFERENTLY therefore share a channel, which is precisely what this option exists to prevent — arrived at by way of the option itself. The docs already say that staging and production of one app share a name and only this variable separates them, which is exactly the case where someone types two values believing they differ.
|
|
258
133
|
|
|
259
|
-
|
|
134
|
+
Nothing refuses: the resolved value is broker-safe either way, and failing a boot over a dot would be worse than the collapse. Both boot paths log the substitution when it changes what was written, and the message names the COLLAPSE rather than only the substitution — the substitution alone reads as cosmetic. Silent when the value survives unchanged, and silent for the derived app-name default, which is not something an operator can act on.
|
|
260
135
|
|
|
261
|
-
|
|
136
|
+
codemod: none
|
|
137
|
+
- **@voltro/plugin-broadcast, @voltro/cli** — **`broadcastPlugin()` with `REDIS_URL` set no longer stays silently on `memory`.** `REDIS_URL` counted for RESOLUTION but not for INFERENCE — it took an explicit `connection` option to be considered — so the plugin fell through to the in-process bus while the branch that would have read the variable sat directly below. Two doc strings promised the fallback ("inferred from … `REDIS_URL`", "falls back to `REDIS_URL`").
|
|
262
138
|
|
|
263
|
-
|
|
139
|
+
The asymmetry is what made it expensive rather than merely wrong: cache, kv and ratelimit all follow `<NAME>_REDIS_URL` → `REDIS_URL`, so an operator sets one variable, reads `cache backend resolved: redis` in the boot log, and concludes the bus did the same. A reporter did exactly that, on a single-replica deployment where the difference is unobservable — it appears on scale-up, as "some screens miss some events".
|
|
264
140
|
|
|
265
|
-
|
|
141
|
+
The caution the opt-in encoded is obsolete: every channel now carries the app-derived namespace, so attaching to a shared server no longer means two apps read each other's traffic. The test that pinned the old decision is reversed with that reasoning in it rather than deleted.
|
|
266
142
|
|
|
267
|
-
|
|
143
|
+
**The producer scan sees a locally bound publisher.** `\.publish\s*\(` misses
|
|
268
144
|
|
|
269
|
-
|
|
145
|
+
const publish = ctx.publish if (publish === undefined) return await publish(descriptor, {}, payload)
|
|
270
146
|
|
|
271
|
-
|
|
147
|
+
— which is not a corner case but the shape a handler writes when it guards the optional publisher. A reporter spent a quarter hour hunting for a missing publish they had just written, because the warning said their working event was dead. A false negative here is a missed warning; a false POSITIVE is a warning that lies about working code, and that is the expensive direction.
|
|
272
148
|
|
|
273
|
-
|
|
149
|
+
A bare `publish(` now counts, but only in a file that mentions `ctx.publish` or `ctx.events` — `publish` is too common a name to accept unqualified, and the qualifier also covers the `async ({ publish })` destructuring the dotted form misses for the same reason. Both directions tested.
|
|
274
150
|
|
|
275
|
-
|
|
276
|
-
- **@voltro/
|
|
151
|
+
codemod: none
|
|
152
|
+
- **@voltro/plugin-broadcast** — `broadcastPlugin` refuses a request it cannot honour instead of downgrading it silently.
|
|
277
153
|
|
|
278
|
-
|
|
154
|
+
Probed the way the events, records and presence surfaces were: five plausible mistakes, **five accepted**, and every one produced the same outcome — the in-process memory bus with a successful boot.
|
|
279
155
|
|
|
280
|
-
|
|
156
|
+
| written | got | said | | --- | --- | --- | | `provider: 'redes'` (typo) | memory | nothing | | `url: 'http://x'` | memory | nothing | | `url: ''` | memory | nothing | | `provider: 'redis'`, no url anywhere | memory | nothing |
|
|
281
157
|
|
|
282
|
-
|
|
158
|
+
On one replica each of these is indistinguishable from working. They appear on the second, as "some screens miss some events" — which is the report that led here, and it cost a consumer a deployment.
|
|
283
159
|
|
|
284
|
-
|
|
285
|
-
- **@voltro/cli** — **A declared event never reached the generated rpcGroup, so `useEvent` could not work in a real app.**
|
|
160
|
+
The asymmetry that decides it: **an app that configures nothing has taken a default, and memory is the honest answer. An app that writes `provider: 'redis'` has stated a requirement**, and answering a requirement with a downgrade is the shape removed everywhere else in this codebase.
|
|
286
161
|
|
|
287
|
-
|
|
162
|
+
So configuring nothing still takes memory, an explicit `provider: 'memory'` is still honoured — saying it out loud must not be worse than saying nothing — and a bare redis url still resolves without naming the provider. What throws is only the case where the request cannot be met: an unknown name (listing the valid ones, so the fix does not need the docs), a url whose scheme names no provider, and a named provider with no url anywhere (naming the variables that would satisfy it).
|
|
288
163
|
|
|
289
|
-
|
|
164
|
+
Red-verified: with the refusal removed, the two tests that assert it go red.
|
|
290
165
|
|
|
291
|
-
|
|
166
|
+
codemod: none
|
|
167
|
+
- **@voltro/cli** — Cross-replica delivery is now tested across a broker OUTAGE, not only a healthy or a degraded link.
|
|
292
168
|
|
|
293
|
-
|
|
294
|
-
- **@voltro/runtime** — **A freshly-started replica no longer tells every client it missed thousands of messages.**
|
|
169
|
+
The suites here proved delivery on a working link, and one proved it on a throttled one. None broke the link. That is the failure an operator actually meets — a redis restart, a failover, a partition that heals — and it was the last untested shape in the realtime stack.
|
|
295
170
|
|
|
296
|
-
|
|
171
|
+
**The property asserted is recovery, not delivery.** A broker that is down cannot carry messages, and claiming otherwise would be exactly the sort of guarantee this repo keeps removing. What must hold is that the link heals BY ITSELF: after the outage, delivery resumes with no process restart, no app-side retry and no resubscribe. A subscriber that silently stays dead after a blip is the worst realtime failure there is, because the screen keeps rendering and nothing reports it.
|
|
297
172
|
|
|
298
|
-
The
|
|
173
|
+
The test proves the link worked BEFORE it breaks it, so a zero at the end cannot be blamed on a link that never worked. Red-verified: leaving the proxy disabled gives 0 recovered deliveries instead of 10.
|
|
299
174
|
|
|
300
|
-
|
|
175
|
+
Also probed, and correct as found: a throwing listener does not kill the publish, does not stop its healthy siblings receiving, and does not leave the bus unusable afterwards. The 5 MB payload the bus accepts is fine — the size gate sits at the public seam (`ctx.events.publish`) and measures the ENCODED wire form, which is the representation that can actually be rejected downstream.
|
|
301
176
|
|
|
302
|
-
|
|
303
|
-
- **@voltro/runtime, @voltro/cli** — **
|
|
177
|
+
codemod: none
|
|
178
|
+
- **@voltro/protocol, @voltro/runtime, @voltro/cli** — **The credential bound covered one auth shape and the sentence did not say so.**
|
|
304
179
|
|
|
305
|
-
|
|
180
|
+
We wrote that "an event subscription can no longer outlive the credential that authorized it" and, a release later, that "the bound now covers EVERY realtime primitive". Both were true only for the `voltro:session` cookie: `sessionExpiryFromHeaders` read that cookie and nothing else, so for an app authenticating with Bearer JWTs the bound was always `undefined` — a no-op that reads as a guarantee.
|
|
306
181
|
|
|
307
|
-
|
|
182
|
+
A reporter found it by expecting black screens an hour after a deploy and getting none. Their framing is the one to keep: **the guarantee was not false, it was scoped to an auth shape the sentence did not name** — and we had corrected a different sentence in the same release for exactly that reason.
|
|
308
183
|
|
|
309
|
-
|
|
184
|
+
There was also no seam to close it with. `StrategyResolution` was `{ matched, subject }`, so the strategy — the only place in the system that verified the token and holds its `exp` — could not report it.
|
|
310
185
|
|
|
311
|
-
|
|
186
|
+
It can now: `{ kind: 'matched', subject, credentialExpiresAt? }`, optional, with absent still meaning no bound. The shared JWT strategy reports its verified `exp`, which covers all six catalog providers (auth0, clerk, kinde, oidc, supabase, workos) in one place rather than six near-identical lines that drift.
|
|
312
187
|
|
|
313
|
-
The
|
|
188
|
+
The expiry rides WITH the subject through the chain and is recorded on the per-connection channel that already carries subject overrides, so `ConnectionInfo` reads it instead of re-deriving from headers. Two sites deriving one fact is what let the cookie path and the bearer path disagree. Both boot paths do it, in the same change.
|
|
314
189
|
|
|
315
|
-
|
|
316
|
-
- **@voltro/runtime** — **A `latest` event re-sent its current value to a client that already had it.**
|
|
190
|
+
Tested for both shapes — including that `resolveScopes`, which rebuilds the subject, does not drop it. That would have reopened the hole for every app using the seam we point people at for this kind of augmentation.
|
|
317
191
|
|
|
318
|
-
|
|
192
|
+
codemod: none
|
|
193
|
+
- **@voltro/cli** — **Records and presence are now PROVEN cross-replica, not asserted.**
|
|
319
194
|
|
|
320
|
-
|
|
195
|
+
Asking one question across the whole surface — *which primitive is proven cross-replica against a real broker?* — gave an answer no amount of bug-fixing had:
|
|
321
196
|
|
|
322
|
-
|
|
323
|
-
- **@voltro/runtime** — **`await ctx.events.publish(...)` in an async handler published NOTHING, silently.**
|
|
197
|
+
| primitive | before | | --- | --- | | events | seven suites: partition, broker outage, degraded network | | records | **none** | | presence | **none** — zero broker use in all three of its suites |
|
|
324
198
|
|
|
325
|
-
|
|
199
|
+
"Multi-replica works" was proven for events and asserted for the other two, and they run through different code: events go bus → bridge → subscriber, records go `store.onChange` → broadcast → the peer's `injectExternalChange` → dispatcher → subscription. Only one had been driven end to end.
|
|
326
200
|
|
|
327
|
-
|
|
201
|
+
**Presence** now proves what a consumer had to measure by hand with `redis-cli PUBSUB NUMSUB` because the framework was telling them the opposite: a member tracked on A appears in B's roster, opaque `meta` survives the hop, and a leave on A removes it from B. A roster that only ever GROWS across instances is the failure that looks like success.
|
|
328
202
|
|
|
329
|
-
|
|
203
|
+
**Records** cost three wrong attempts, and the reason is worth more than the test. Two independent in-memory stores cannot model this: `injectExternalChange` NOTIFIES without persisting — deliberately, because replicas share a DATABASE and the peer re-reads storage they have in common. With separate stores the notification arrives (measured: called exactly once) and the re-read finds nothing, so no delta is emitted. Correct behaviour against an incorrect topology — and reported as a defect it would have sent someone hunting the bus for a bug that is not there. The suite runs one postgres, two stores, two dispatchers.
|
|
330
204
|
|
|
331
|
-
|
|
205
|
+
Two harness errors along the way are recorded in the files rather than quietly fixed: `tracker.track()` alone is a LOCAL write (the route calls `announce(track(...))`), and a predicate literal is `{ column, op, value }` — using `kind` instead of `op` matched nothing, so the missing delta was correct. Both would have been reported as framework defects.
|
|
332
206
|
|
|
333
|
-
|
|
334
|
-
- **@voltro/
|
|
207
|
+
codemod: none
|
|
208
|
+
- **@voltro/cli** — The `no-consumer` half of the event audit sees sibling apps.
|
|
335
209
|
|
|
336
|
-
|
|
210
|
+
It read the API app's own tree, and in a monorepo the `useEvent` calls are not there — they are in the web apps beside it. A reporter had ten declared events, all ten consumed, all ten calls in ONE file in a sibling app, and got ten `no-consumer` warnings. A check that is wrong ten times out of ten carries no signal, and they ranked the two halves themselves: the producer half found them a dead trigger node that had not fired since a migration; the consumer half found nothing and spent the attention the producer half needed.
|
|
337
211
|
|
|
338
|
-
|
|
212
|
+
The siblings are not guessed from directory layout. `pnpm-workspace.yaml` declares them, so this reads what the workspace already says — a project outside a workspace costs nothing, which is the common single-app case.
|
|
339
213
|
|
|
340
|
-
|
|
214
|
+
Bounded at 4000 files, and LOUDLY: hitting the bound logs that a `no-consumer` line below may mean "we stopped looking" rather than "nothing consumes it". A silently truncated scan is the same false confidence one layer down, which is the defect this whole audit exists to remove.
|
|
341
215
|
|
|
342
|
-
|
|
216
|
+
codemod: none
|
|
217
|
+
- **@voltro/protocol** — `defineEvent` refuses four authoring mistakes it used to accept.
|
|
343
218
|
|
|
344
|
-
|
|
219
|
+
Found by probing what it lets through rather than by reading it: nine plausible mistakes were tried, nine were accepted. The surface had exactly two refusals, one of which (`latest` + `webhook`) is a model for the rest.
|
|
345
220
|
|
|
346
|
-
|
|
221
|
+
**Whitespace in a name is the severe one — a production-only silence.** The name becomes a broker SUBJECT segment, and NATS refuses a subject containing whitespace and delivers nothing, with no error on the publishing side. An app that works on Redis stops working when the transport changes: silently, on one broker only. Refused at declaration, where the author can still see the string, and the message names the dot form to use instead.
|
|
347
222
|
|
|
348
|
-
|
|
349
|
-
- **@voltro/cli** — **A source-tree guard failed the whole test FILE when a fixture directory vanished mid-walk.**
|
|
223
|
+
**`guards: []`** is refused because the enforcement in `bindEvent` runs only for a non-empty list — so it reads at the call site as if the event were protected and secures nothing. That is the declared-and-inert shape this codebase keeps finding; an omitted field is the honest spelling for unguarded.
|
|
350
224
|
|
|
351
|
-
|
|
225
|
+
**`webhook.rateLimit.perMinute: 0`** defers every delivery forever, and there is no "unlimited" spelling for the field, so 0 is almost always someone reaching for one. **`webhook.version: 0`** would make a subscriber pinned to 1 read the event as *behind* — the opposite of what a version bump means.
|
|
352
226
|
|
|
353
|
-
|
|
227
|
+
Each message says what is wrong, why, and what to write instead; a test asserts that every refusal is more than one line, because a message that only names the rule leaves the reader guessing at the reason, and the reason is usually what they needed.
|
|
354
228
|
|
|
355
|
-
|
|
356
|
-
- **@voltro/plugin-presence** —
|
|
229
|
+
codemod: none
|
|
230
|
+
- **@voltro/database, @voltro/runtime, @voltro/cli, @voltro/plugin-presence** — **`pluginRef` declarations survive `table()` and are readable as `table.appliedPluginRefs`.** They did not, and the consequence reached further than the reporter could see.
|
|
357
231
|
|
|
358
|
-
|
|
232
|
+
`pluginRefSpecOf` reads a column BUILDER; `table()` materialises builders into plain field descriptors. So the declaration vanished the instant the table existed, and the column read as an ordinary `text()`.
|
|
359
233
|
|
|
360
|
-
|
|
234
|
+
A consumer's CRUD generator and their contract test both derive "which column carries the tenant" from the schema, both asked `type === 'reference'`, and a `pluginRef` column answered no — so generated junction handlers dropped the tenant sub-query and a favourite could point at another tenant's row. Their test missed it for the same reason the generator did: **a checker sharing the assumption of the thing it checks.** They caught it only because they happened to teach discovery about `pluginRef` before the generator; the other order ships the regression.
|
|
361
235
|
|
|
362
|
-
|
|
236
|
+
**On our side it was worse and they could not have known.** The framework's own orphan-rule collector walked the column bag asking `pluginRefSpecOf`, got `undefined` every time, and produced ZERO rules on every real schema — so `orphanPolicy: 'delete'` did nothing, for the second release running. Its wiring test stayed green because it asserted the collector was CALLED, never that it returned anything.
|
|
363
237
|
|
|
364
|
-
`
|
|
238
|
+
A test against a real `table()` then found a THIRD defect immediately: the collector read `spec.target().name`, and a table's property is `tableName`, so every target resolved to undefined and the boot refusal fired for every `pluginRef`. The fixtures returned `{ name }` — confirming the wrong assumption rather than testing it.
|
|
365
239
|
|
|
366
|
-
|
|
240
|
+
That confusion had spread. The stale-`source:` resolver in BOTH boot paths built its table set the same way, producing an empty set — and `unresolvedSources` returns nothing for an empty set by design, so the warning silently stopped firing. **The fix for one false positive had turned the other into silence.** A narrow source guard now catches the shape; its own first run flagged a correct workflow read, which is recorded in the file, because a guard that opens with a false positive gets muted.
|
|
367
241
|
|
|
368
|
-
|
|
369
|
-
- **@voltro/cli** — **`voltro build` could delete output it had just written.** The post-build orphan prune compared each file's mtime against `Date.now()` taken at build start — two different clocks. Linux stamps inode times from a COARSE clock (`ktime_get_coarse_real_ts64`) that advances once per timer tick, so a file written microseconds AFTER the cutoff can carry an mtime a tick BEFORE it, and the strict comparison then removed it.
|
|
242
|
+
**The presence broker warning fires after the bus attaches.** It ran at plugin activation, and the broadcast bus attaches later — the reporter measured 634 ms, then confirmed with `PUBSUB NUMSUB` that presence was cross-instance while the log said otherwise. The check is deferred and re-reads the transport at fire time. This exact warning had just found them a real misconfiguration and then kept reporting the fault after the repair, which is how a warning spends the credibility it earned.
|
|
370
243
|
|
|
371
|
-
|
|
244
|
+
codemod: none
|
|
245
|
+
- **@voltro/plugin-presence** — `presencePlugin` refuses a `timeoutMs` that expires members between heartbeats.
|
|
372
246
|
|
|
373
|
-
The
|
|
247
|
+
The presence surface, probed the way events, records and broadcast were: five plausible mistakes tried, five accepted. Zero and a negative are the obvious two; the one worth the rule is a value SMALLER than the client's heartbeat, because that is the mistake with a plausible motive ("expire people quickly") and a silent failure.
|
|
374
248
|
|
|
375
|
-
|
|
376
|
-
- **@voltro/cli** — **A `source:` that names no table is now reported at boot.** It was silent, and the silence is the defect: `source` is matched BY NAME against change events, so one naming a table that does not exist matches nothing — the query returns its first result and never updates again. Not a broken subscription, a permanently silent one, which from the outside is indistinguishable from "nothing has changed".
|
|
249
|
+
`timeoutMs` is one half of a contract whose other half lives in the client. A member is online for `timeoutMs` after its last heartbeat, and `usePresence` beats every 15s by default. Below that, every member expires between beats — the roster flaps empty and nothing reports it, because an empty roster is also what "nobody is here" looks like.
|
|
377
250
|
|
|
378
|
-
|
|
379
|
-
1 query declares a `source` that names no table:
|
|
380
|
-
agent.messages: source 'agent_messages' is not a declared table — did you mean '_voltro_agent_messages'?
|
|
381
|
-
```
|
|
251
|
+
A consumer wrote that pairing down themselves ("our 10s heartbeat is the other half of the contract"), which is evidence the rule is real AND that it was left to the reader to work out. It is stated in both languages now, and the message names the CLIENT side, since a message naming only the server value sends the reader looking for the number in another package.
|
|
382
252
|
|
|
383
|
-
|
|
253
|
+
A long window is still fine — a signage terminal beating once a minute is a real deployment. The rule is a floor, not a range.
|
|
384
254
|
|
|
385
|
-
|
|
255
|
+
codemod: none
|
|
256
|
+
- **@voltro/protocol** — `defineQuery` refuses three contradictions it used to accept — the same probe that found four on `defineEvent`, run against the records surface.
|
|
386
257
|
|
|
387
|
-
|
|
258
|
+
That symmetry is the point rather than a coincidence. `guards: []` was refused on events an hour after it was accepted on queries, and a rule that holds for one primitive and not another is worse than no rule: the framework's answer then depends on which file the author happened to open.
|
|
388
259
|
|
|
389
|
-
|
|
390
|
-
- **@voltro/cli** — **`voltro serve` built the instance-membership registry TWICE per process.**
|
|
260
|
+
- **`guards: []`** reads at the call site as if the procedure were protected and enforces nothing — the check runs only for a non-empty list. - **An empty `source`** (`''`, `[]`, or a blank entry) declares reactivity and subscribes to nothing: one snapshot, never an update, indistinguishable from "nothing changed". It is worse than a STALE source, which the boot warning can at least name — this one names no table at all, so nothing can report it. - **`internal: true` + `overridesPlugin`** removes the plugin's route and puts something not wire-reachable in its place, so callers get a 404 for something that used to work with no diff that says so. It extends the existing `assertWireSurfaceConsistent` contract rather than adding a second rule beside it.
|
|
391
261
|
|
|
392
|
-
|
|
262
|
+
Also settled, by reading the runtime rather than declining again: **`rewind` needs no rule.** It replays the pruned ring on attach — with `each` that is "catch up on what you missed", with `latest` it is "here is the current value". Both are meaningful, so the combination that looked suspicious is fine, and a test now pins that decision so the next reader does not re-open it.
|
|
393
263
|
|
|
394
|
-
|
|
264
|
+
codemod: none
|
|
265
|
+
- **@voltro/cli** — The one multi-replica scenario with no test: a replica that goes away, misses traffic, and comes back. The existing suites prove two replicas REACH each other, not what happens when one stops being able to.
|
|
395
266
|
|
|
396
|
-
`
|
|
267
|
+
It covers both directions of the claim that `missed` is COMPUTED and never estimated. **Under-reporting** is the silence this primitive exists to remove. **Over-reporting** is the freshly-started replica announcing a loss for messages it was never owed — measured once at 5000, and the reason every delivery carries `prior`.
|
|
397
268
|
|
|
398
|
-
|
|
399
|
-
- **@voltro/protocol, @voltro/cli** — **Two guards for the two defect shapes this area kept producing.**
|
|
269
|
+
The accounting identity is the assertion: every envelope owed after the resume point is either replayed or reported, and the two must sum to what was owed.
|
|
400
270
|
|
|
401
|
-
|
|
271
|
+
**The first version of that test was vacuous, and the reason is worth recording because it is the fourth instance this session.** It published six envelopes into the default ring of 64, so the ring held everything, `missed` was always 0, and the identity was true by arithmetic for any implementation at all — sabotaging the computation to under-report by one left it green. The ring is now deliberately SMALLER than the traffic (`ringSize: 3`, ten publishes), the non-vacuity assertions come FIRST, and the same sabotage now fails it 8-to-9.
|
|
402
272
|
|
|
403
|
-
|
|
273
|
+
`describeIfReachable`, verified both ways: with `REDIS_PORT=1` it reports two named skips rather than returning green having tested nothing.
|
|
404
274
|
|
|
405
|
-
|
|
275
|
+
codemod: none
|
|
276
|
+
- **@voltro/runtime** — A resume replay could be **overtaken** by live traffic, delivering serials out of order.
|
|
406
277
|
|
|
407
|
-
|
|
278
|
+
Found by testing the case a busy app produces and the reconnect tests do not: a backlog being replayed at the same moment new envelopes are accepted, because a real reconnect does not pause the publisher. Measured — resuming from `n=3` over a ring of 8, with an ordinary re-entrant publish from the listener at `n=6`, delivered `[4, 5, 6, 9, 7, 8]`.
|
|
408
279
|
|
|
409
|
-
|
|
280
|
+
The cause is an ordering that is right for a different reason. The listener is registered BEFORE the replay on purpose: it closes the window between reading the ring and going live, so nothing published in between is lost. What it does not do on its own is keep the two streams in sequence — a live delivery reaches the listener immediately and jumps ahead of the entries still queued behind it.
|
|
410
281
|
|
|
411
|
-
|
|
282
|
+
Out-of-order is worse than loss for anything that folds state: a display applying an older frame after a newer one shows the past and stays there. And `n` arriving non-monotonically undermines the serial every gap number is computed from.
|
|
412
283
|
|
|
413
|
-
|
|
284
|
+
Live deliveries are now buffered for the duration of the replay and flushed after it, in arrival order, synchronously before `subscribe` returns — an async flush would reopen the window the early registration exists to close. Both properties hold: nothing is missed, and nothing overtakes.
|
|
414
285
|
|
|
415
|
-
|
|
286
|
+
The new tests also pin two things the quiet reconnect case cannot see: no serial is delivered twice when an envelope is in the ring at the moment of attach, and traffic arriving during a replay is not reported as a gap.
|
|
416
287
|
|
|
417
|
-
|
|
288
|
+
codemod: none
|
|
289
|
+
- **@voltro/cli** — The socket.io comparison published one run per side. Both its numbers were noise, and it is corrected here with five runs each.
|
|
418
290
|
|
|
419
|
-
|
|
291
|
+
| | p50 median | p50 range | p99 median | p99 range | | --- | --- | --- | --- | --- | | Voltro cross-replica | **0.68 ms** | 0.58–0.86 | 7.01 ms | 4.50–13.36 | | socket.io + redis-adapter | 1.31 ms | 1.16–1.73 | **4.52 ms** | 4.33–7.83 |
|
|
420
292
|
|
|
421
|
-
|
|
293
|
+
The earlier table claimed "32% faster at the median, 44% worse at the tail". The median advantage is nearer **2x** — the p50 ranges do not overlap at all — and the tail gap sits INSIDE the overlap, so it is weaker evidence than a single pair of numbers made it look.
|
|
422
294
|
|
|
423
|
-
|
|
424
|
-
- **@voltro/cli** — The dev-SSR streaming tests defined "the shell" as *every chunk that arrived before 0.6 × the deferral delay* — an assertion about the machine wearing the shape of an assertion about the renderer. Under a loaded CI runner the shell lands after that deadline, the derived `shell` string comes out EMPTY, and the failure reads `Expected SHELL_LAYOUT_EAGER_OK`, as though the renderer had dropped a field. Green on every developer machine.
|
|
295
|
+
**A single measurement presented as a fact is the defect this framework spends its time removing, and it was committed in its own benchmark.** The correction is the finding.
|
|
425
296
|
|
|
426
|
-
|
|
297
|
+
**Where the tail comes from, measured rather than guessed.** Splitting the publish path: our own code — building the envelope, the Effect fiber per message, the handoff — costs p50 **0.056 ms** / p99 **0.444 ms**. Waiting for Redis to acknowledge costs p50 1.17 ms / p99 6.43 ms.
|
|
427
298
|
|
|
428
|
-
|
|
299
|
+
So roughly 0.4 ms of a 7 ms tail is ours and the rest is the broker round-trip, which socket.io pays too. The `Effect.runPromise` per message was the leading hypothesis and the measurement cleared it. There is no code-level tail defect to fix — on this machine the number is dominated by Docker's network stack.
|
|
429
300
|
|
|
430
|
-
|
|
301
|
+
codemod: none
|