vapor-chamber 1.22.0 → 1.24.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.
Files changed (105) hide show
  1. package/CHANGELOG.md +661 -0
  2. package/README.md +16 -16
  3. package/ROADMAP.md +39 -19
  4. package/dist/LICENSE.txt +3 -2
  5. package/dist/chamber-vapor.d.ts +1 -1
  6. package/dist/chamber-vapor.js.map +1 -1
  7. package/dist/command-bus.d.ts +24 -2
  8. package/dist/command-bus.d.ts.map +1 -1
  9. package/dist/command-bus.js +1 -1
  10. package/dist/command-bus.js.map +1 -1
  11. package/dist/directives.d.ts +1 -1
  12. package/dist/directives.d.ts.map +1 -1
  13. package/dist/directives2.js.map +1 -1
  14. package/dist/glyphs.d.ts +7 -3
  15. package/dist/glyphs.d.ts.map +1 -1
  16. package/dist/http-errors.js +11 -0
  17. package/dist/http-errors.js.map +1 -0
  18. package/dist/http.d.ts +57 -3
  19. package/dist/http.d.ts.map +1 -1
  20. package/dist/http.js +28 -30
  21. package/dist/http.js.map +1 -1
  22. package/dist/iife-core.d.ts +1 -1
  23. package/dist/iife-core.js +1 -1
  24. package/dist/iife-core.js.map +1 -1
  25. package/dist/iife-elements.d.ts +10 -2
  26. package/dist/iife-elements.d.ts.map +1 -1
  27. package/dist/iife-elements.js +1 -1
  28. package/dist/iife-elements.js.map +1 -1
  29. package/dist/iife.d.ts +2 -2
  30. package/dist/iife.d.ts.map +1 -1
  31. package/dist/iife.js +2 -2
  32. package/dist/iife.js.map +1 -1
  33. package/dist/index.d.ts +3 -3
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +52 -6
  36. package/dist/index.js.map +1 -1
  37. package/dist/mcp.d.ts +8 -8
  38. package/dist/mcp.d.ts.map +1 -1
  39. package/dist/mcp.js +2 -2
  40. package/dist/mcp.js.map +1 -1
  41. package/dist/observable.d.ts +1 -1
  42. package/dist/observable.js.map +1 -1
  43. package/dist/outbox.d.ts +39 -9
  44. package/dist/outbox.d.ts.map +1 -1
  45. package/dist/outbox.js +29 -6
  46. package/dist/outbox.js.map +1 -1
  47. package/dist/plugins-io.d.ts +19 -10
  48. package/dist/plugins-io.d.ts.map +1 -1
  49. package/dist/plugins-io.js +5 -6
  50. package/dist/plugins-io.js.map +1 -1
  51. package/dist/plugins.d.ts +2 -2
  52. package/dist/plugins.d.ts.map +1 -1
  53. package/dist/router/remote.js +1 -1
  54. package/dist/router-fetch/index.js +1 -1
  55. package/dist/schema.d.ts.map +1 -1
  56. package/dist/ssr.d.ts +1 -1
  57. package/dist/ssr2.js.map +1 -1
  58. package/dist/store.d.ts +1 -1
  59. package/dist/store.d.ts.map +1 -1
  60. package/dist/store.js.map +1 -1
  61. package/dist/transitions.d.ts +1 -1
  62. package/dist/transitions2.js.map +1 -1
  63. package/dist/transports.d.ts +47 -2
  64. package/dist/transports.d.ts.map +1 -1
  65. package/dist/transports.js +59 -35
  66. package/dist/transports.js.map +1 -1
  67. package/dist/vapor-chamber-core.iife.js +230 -45
  68. package/dist/vapor-chamber-core.iife.js.map +1 -1
  69. package/dist/vapor-chamber-core.iife.min.js +1 -1
  70. package/dist/vapor-chamber-elements.iife.js +239 -46
  71. package/dist/vapor-chamber-elements.iife.js.map +1 -1
  72. package/dist/vapor-chamber-elements.iife.min.js +1 -1
  73. package/dist/vapor-chamber.iife.js +258 -56
  74. package/dist/vapor-chamber.iife.js.map +1 -1
  75. package/dist/vapor-chamber.iife.min.js +1 -1
  76. package/dist/vitest-pure.d.ts +10 -0
  77. package/dist/vitest-pure.d.ts.map +1 -1
  78. package/dist/vitest-pure.js +10 -0
  79. package/dist/vitest-pure.js.map +1 -1
  80. package/package.json +3 -3
  81. package/scripts/check-ascii.mjs +19 -1
  82. package/scripts/check-size.mjs +132 -4
  83. package/scripts/gate.mjs +1 -1
  84. package/scripts/generate-api-docs.mjs +35 -7
  85. package/scripts/stamp-docs.mjs +14 -6
  86. package/src/chamber-vapor.ts +1 -1
  87. package/src/command-bus.ts +48 -4
  88. package/src/directives.ts +18 -2
  89. package/src/glyphs.ts +7 -3
  90. package/src/http.ts +98 -25
  91. package/src/iife-core.ts +1 -1
  92. package/src/iife-elements.ts +10 -2
  93. package/src/iife.ts +2 -2
  94. package/src/index.ts +4 -3
  95. package/src/mcp.ts +8 -8
  96. package/src/observable.ts +1 -1
  97. package/src/outbox.ts +73 -14
  98. package/src/plugins-io.ts +65 -22
  99. package/src/plugins.ts +3 -3
  100. package/src/schema.ts +11 -1
  101. package/src/ssr.ts +1 -1
  102. package/src/store.ts +5 -3
  103. package/src/transitions.ts +1 -1
  104. package/src/transports.ts +197 -43
  105. package/src/vitest-pure.ts +10 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,667 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## v1.24.0 - 2026-09-24
6
+
7
+ ### Added: RFC 9457 problem documents are read on every path
8
+
9
+ A backend that answers a failure as `application/problem+json` -
10
+ `{ type, title, status, detail, code }`, `code` as an extension member - lost it
11
+ three ways. Measured with only `fetch` stubbed, then against the reference
12
+ controller running under Laravel 13:
13
+
14
+ - `createHttpClient` parsed a body only when its content type contained
15
+ `application/json`, which `application/problem+json` does not. The problem
16
+ stayed a string, so `HttpError.code` read `undefined` - on reads, in
17
+ `router-fetch`, and in any bridge handed an `httpClient`. `postCommand` was
18
+ never affected: its fetch calls `raw.json()` whatever the type.
19
+ - Every bridge took its message from `error ?? message`, never `detail`, so the
20
+ backend's sentence arrived as `HTTP 404`.
21
+ - A batched result, or a WebSocket frame, carrying `problem` resolved as a
22
+ SUCCESS whose value was `undefined`.
23
+
24
+ Now:
25
+
26
+ - The client parses `application/json` and any `+json` structured suffix
27
+ (RFC 6839), parameters allowed. A type that only contains "json"
28
+ (`application/json-seq`) stays text - it used to be `JSON.parse`d, which
29
+ throws on a real JSON text sequence.
30
+ - `Accept` is `application/json, application/problem+json`.
31
+ - A problem's `detail` is the error's message, read once, in `HttpError`
32
+ itself - so the client, `router-fetch` and every bridge agree. A body without
33
+ `detail` keeps `HTTP <status>`, and a body with the older `error`/`message`
34
+ keeps what the bridges read from it.
35
+ - A result `{ id, problem }` is a failure, batched or over WebSocket: message
36
+ from `detail`, then `title`; code from `problem.code`, then the result's
37
+ `code`. Both paths now share one reading, `resultFailure()` in
38
+ `transports.ts`, where each had its own `ok === false` branch. The single
39
+ bridge keeps its own: there a problem is the error response and arrives as
40
+ an HttpError, and routing it through the helper cost core +128 B raw for a
41
+ shape its 2xx never carries.
42
+ - `ProblemDetails` is exported, and `BackendResponse` gains `detail` and
43
+ `problem`.
44
+
45
+ **What is deliberately not read.** `type` is an opaque URI: nothing derives a
46
+ code from it, so a backend sends `code` as an extension member. The `status`
47
+ inside a batched problem is data, not the response's status, and changes no
48
+ retry decision.
49
+
50
+ **The reference controller** (`examples/laravel-backend/VaporChamberController.php`)
51
+ now answers every failure as `application/problem+json`, with an absolute `type`
52
+ (`url('/problems/<code>')`), the status's reason phrase as `title`, and `code`.
53
+ On `batch()` a failed command's problem rides on its own result inside the 200,
54
+ beside `ok: false`. An exception's own `render()` contributes its `code` member
55
+ when it has one, the last segment of its `type` otherwise.
56
+
57
+ **Upgrade order: the client first.** A v1.23.0 client against the new
58
+ controller still sees every failure as a failure - `ok: false` beside `problem`
59
+ is there for exactly that - but reads `HTTP 422` and `Backend error` for
60
+ messages, and loses the code on a batched result. The previous
61
+ `{ ok: false, error, code }` shape is still read everywhere, so a backend can
62
+ move one endpoint at a time.
63
+
64
+ **Examples:** the two mock backends (`examples/static-server.mjs`,
65
+ `examples/sprinkled-blade/mock-server.mjs`) answer failures as problems, and
66
+ their 500 no longer echoes the exception's text to the client - it is logged.
67
+
68
+ **Fixture:** `tests/problem-details.test.ts` - 17 cases across the client,
69
+ `router-fetch`, `createHttpBridge` (through `postCommand` and through an
70
+ `httpClient`), `createBatchingHttpBridge` and `createWsBridge`, each with
71
+ today's shape as the control. 13 fail against v1.23.0; the 4 controls pass on
72
+ both. The stubbed backend it answers with - `reply`, `problemReply`,
73
+ `batchServer`, `singleServer`, `MockWebSocket` - is now one shared file,
74
+ `tests/backend-stubs.ts`, and `batch-redirect`, `outbox-refusal` and
75
+ `transports` use it instead of their own copies.
76
+
77
+ **Size:** +136 B raw / +62 B brotli on the full IIFE, which carries the batch
78
+ and WebSocket bridges; +21 B raw on core and elements, +3 / -1 B brotli; +7 B
79
+ brotli on the Blade consumer ESM bundle, under its unchanged ceiling. Squeezed
80
+ before landing - how is in `scripts/check-size.mjs`.
81
+
82
+ ### Fixed: the last uncovered branch, back to 100% on all four axes
83
+
84
+ `runFlush` falls back to `new Error('Unknown error')` when a replay fails with
85
+ no `error` - the shape a hand-written plugin can return - and no test reached
86
+ it since v1.23.0 added `isRetryable`. `tests/outbox-edges.test.ts` now does,
87
+ and pins that the predicate sees that Error rather than `undefined`.
88
+
89
+ ## v1.23.0 - 2026-09-23
90
+
91
+ Post-v1.22.0 work. The first four entries were written into the v1.22.0 section
92
+ while it was still open and are moved here unchanged - v1.22.0 shipped on
93
+ 2026-09-21 and its text is frozen at what was published.
94
+
95
+ ### Fixed: the backend's `code` survives an envelope failure
96
+
97
+ Both HTTP bridges enriched an error when the failure THREW and built a bare one
98
+ when the same failure arrived as DATA, twelve lines apart in the same function.
99
+ `postCommand` throws an `HttpError` on a non-2xx with the parsed body attached,
100
+ and the catch path has copied `status`, `code` and `response` off it since
101
+ v1.20.0. A 200 carrying `{ ok: false, code }` throws nothing, so the envelope
102
+ path built `new Error(body.error)` and the code was read by nobody.
103
+
104
+ Not a missing feature. `examples/laravel-backend/VaporChamberController.php`
105
+ sends the field and its `fail()` docblock promises it arrives as
106
+ `HttpError.code` - a promise that was false twice over on the batch path, since
107
+ `batch()` answers 200 with per-command status in the body, so nothing throws and
108
+ no `HttpError` is ever built. That docblock was corrected in `df6923d`; this is
109
+ the code half.
110
+
111
+ Four sites now carry it, through one small builder: the HTTP envelope
112
+ (`ok: false`), the non-2xx-that-did-not-throw branch, the batching bridge's
113
+ per-result branch, and the WebSocket frame. `BackendResponse` gains `code?`, and
114
+ because the WS frame is parsed as `{ id: string } & BackendResponse` that one
115
+ type covers all four. `BatchedResult` stops being a hand-written restatement of
116
+ `{ id } & BackendResponse` and composes it instead - the spelling the WS site
117
+ already used, and the reason `code` would otherwise have needed adding twice.
118
+
119
+ **What this does NOT fix**, stated plainly so nobody reads it as closed: the
120
+ retry hole. `defaultIsRetryable` reads `e.status ?? e.response?.status`, an
121
+ envelope error carries neither, so it still returns true and a batched 422 is
122
+ still re-sent. Closing that needs the `status` contract, which changes a
123
+ documented shape and is its own step with its own evidence.
124
+
125
+ **Cost, and the squeeze, MEASURED per piece:**
126
+
127
+ the fix (4 sites + one builder) raw +64 / +61 / +61 brotli +2 / +13 / +14
128
+ sharing the two catch blocks raw +30 / +33 / +33 NOT TAKEN
129
+
130
+ Two squeezes were attempted and both are in those numbers. Assigning `code`
131
+ unconditionally rather than behind a `!== undefined` guard is **-14 B raw** and
132
+ is also the shape-stable form `okResult` / `errResult` already use, so every
133
+ error the builder returns leaves with one hidden class.
134
+
135
+ The second is the interesting one. The plan for this change argued that
136
+ extracting the two byte-identical catch blocks into one helper would pay for the
137
+ fix, on the grounds that `transports.ts`'s note declining exactly that
138
+ ("inline rather than shared with createHttpBridge, whose bundle has two bytes of
139
+ headroom") had expired now that the bundle has 115. Measured: the extraction
140
+ **costs 30 B raw** rather than saving. The abstraction is more expensive than the
141
+ duplication, and the headroom was never what made it so. The extraction was
142
+ reverted, and that note now carries the number under a `MEASURED:` prefix so the
143
+ next reader does not re-derive it - which cost four builds here.
144
+
145
+ Budgets raised by the remainder, which is the feature itself, with the per-piece
146
+ table in `scripts/check-size.mjs`.
147
+
148
+ **Fixture:** `tests/envelope-code.test.ts`, real bridges with only `fetch`
149
+ stubbed, verified to FAIL against the pre-fix module on both defect cases while
150
+ the controls pass. It carries two controls - the throw path still delivering
151
+ `code`, and a 5xx delivering code and status - because a negative result is only
152
+ evidence if the same probe fires where it must. One test was relabelled after
153
+ the arming run: it was written to cover the `!res.ok` branch and turned out to
154
+ drive the catch path, since a 503 throws. It stays as a control and says so,
155
+ rather than claiming coverage it does not have. And `error.status` is
156
+ deliberately NOT asserted on the envelope cases: on that path the response
157
+ really is 200, so `status` is not a field being dropped but one that does not
158
+ exist.
159
+
160
+ ### Fixed: `.code` on a transport error had two owners
161
+
162
+ `defaultIsRetryable` read a `VC_` prefix on `.code` as proof the library minted
163
+ it, and its docblock said so: "a backend code is not ours to interpret, while a
164
+ `VC_` code is." The bridges write the BACKEND's body code into that same field,
165
+ so the claim was false and a backend chose which branch of the predicate ran.
166
+ Two directions, both counted in `tests/transport-code-owner.test.ts`:
167
+
168
+ code: 'VC_CORE_THROTTLED' on a 422 -> re-sent 3x (the HTTP layer had refused to re-send it once)
169
+ code: 'VC_VALIDATION_FAILED' on a 503 -> sent 1x (a retryable status, suppressed from the wire)
170
+
171
+ No hostile backend is needed. `VC_` is a convention nobody polices, and it has
172
+ three claimants inside this repo already: `BusError`, `VcTestError` in
173
+ `src/vitest-pure.ts` (a `VC_TEST_*` code family with no emitter), and whatever a
174
+ body carries.
175
+
176
+ **`.code` does not move, and that is the decision.** It stays the backend's on a
177
+ transport error. The whitepaper documents it (section 5.7), the example
178
+ controller's `fail()` promises it, and the catch path has delivered it since
179
+ v1.20.0 - a released contract. What moves is the QUESTION: provenance now comes
180
+ from `emitter`, which `BusError`'s constructor has always set and which no
181
+ transport ever copies a body field into. A field the backend can write cannot
182
+ answer "did the library write this".
183
+
184
+ - if (typeof code === 'string' && code.startsWith('VC_')) return RETRYABLE_CODES.has(code);
185
+ + if (e.emitter !== undefined) return RETRYABLE_CODES.has(e.code as string);
186
+
187
+ That is the whole provenance fix and it is **smaller**: -25 B raw and -8 / -2 / -5
188
+ brotli. `emitter` is on every `BusError` because the constructor defaults it to
189
+ `'core'`, so nothing had to be added to make it reliable.
190
+
191
+ **The other half: seven library-minted transport failures carried no code at
192
+ all.** An unhandled backend redirect, a batch result that never came back, a full
193
+ offline queue, a queued message that expired, a dead socket, a WS request
194
+ timeout. Each was indistinguishable from an unclassified error, so each was
195
+ retried to exhaustion - and for the redirect the retry cannot change the answer,
196
+ it just issues `maxAttempts` requests to a backend that will redirect every time.
197
+
198
+ They are `BusError`s now, through one `transportError` builder, with
199
+ `emitter: 'transport'` - a `BusEmitter` member declared since v1.0 and never
200
+ set by anything until now. Five new codes: `VC_TRANSPORT_REDIRECT`,
201
+ `VC_TRANSPORT_PROTOCOL`, `VC_TRANSPORT_QUEUE_FULL`, `VC_TRANSPORT_CLOSED` and
202
+ `VC_TRANSPORT_TIMEOUT`. The last one replaced reusing `VC_CORE_REQUEST_TIMEOUT`
203
+ at the two WS timeout sites: that row says emitter `core` and "request() timed
204
+ out waiting for a response", and from a transport both are false. A registry row
205
+ costs nothing in these bundles, so the reuse traded two true rows for one
206
+ half-true one. Each code carries `action` and a `context` holding the
207
+ value a reader would inspect rather than read - the dropped envelope, the
208
+ unmatched batch id, the redirect URL - so it travels beside the sentence instead
209
+ of inside it, the rule `scripts/check-console-shape.mjs` enforces for console
210
+ arguments.
211
+
212
+ **The retry hole IS closed, and the previous entry predicted otherwise.** It said
213
+ closing it "needs the `status` contract, which changes a documented shape". It
214
+ does not. A refusal that arrives as `{ ok: false }` inside a 2xx means the server
215
+ received, processed and refused, so it is not a transient transport failure and
216
+ the transport is the layer that knows this. It says so by tagging the refusal
217
+ `emitter: 'transport'`, and then an EXISTING rule answers: a transport error is
218
+ retried only when its code is one of RETRYABLE_CODES' six, and a backend code
219
+ is not one of those. No status is stamped on a response that carried 200, no
220
+ `response` is attached to make a predicate find one, and the "no status means
221
+ retry" fallback is untouched for every non-HTTP consumer that depends on it. The
222
+ three rejected approaches stay rejected; this is a fourth.
223
+
224
+ **No new field, and that is the second decision.** This shipped for one commit
225
+ as a `retryable: false` boolean on the error, which worked. It went for two
226
+ reasons. It measured 19 B raw / 17 B brotli DEARER on the full IIFE (22/6 and
227
+ 22/4 on core and elements) than letting `emitter` carry it. And it was a
228
+ string-keyed field on an error object built from a response body, which the
229
+ predicate then obeyed unconditionally - the exact precondition that broke
230
+ `.code`, which became a wire field additively over two releases. `emitter` is
231
+ not a plausible wire field: a backend has no reason to claim to be a subsystem
232
+ of this library, and it is written from string literals at every site in `src/`.
233
+ `src/transports.ts` carries the do-not.
234
+
235
+ **The residue, stated and pinned.** A 2xx refusal carries `emitter: 'transport'`
236
+ while its `.code` is the BACKEND's, so a backend sending one of RETRYABLE_CODES'
237
+ six exact strings gets that refusal retried. Bounded and one-directional, where
238
+ the `VC_` prefix was unbounded and went both ways - and before this every 2xx
239
+ refusal was retried, so it is strictly smaller than what it replaces. It has its
240
+ own tests rather than a comment, and they enumerate the colliding strings FROM
241
+ `RETRYABLE_CODES` so the size cannot drift from the set. Closing it completely is
242
+ what the boolean bought, and it was not worth the shape.
243
+
244
+ **Six, not seven, and that is the set getting smaller rather than the rule
245
+ getting cleverer.** `VC_CORE_HANDLER_THREW` was in `RETRYABLE_CODES` while being
246
+ minted by no site in `src/` - a dead code is still a live collision surface, and
247
+ that one could only ever have matched a backend's string or a code a consumer
248
+ built by hand. Removed with nothing able to regress, because nothing emitted it -
249
+ and it PAYS rather than costs: MEASURED -24 B raw in all three IIFEs and -2 / -8 /
250
+ -7 brotli, a set member being a string plus a comma. (An earlier draft of this
251
+ entry said "at zero bytes", which was a guess, and wrong in the flattering
252
+ direction.) Priced: a consumer throwing a `BusError` with that code from
253
+ a handler is no longer retried by default and must pass its own predicate, which
254
+ is what the corrected registry row now says. `RETRYABLE_CODES`' own docblock
255
+ records that adding a member widens the wire-facing surface.
256
+
257
+ **Cost, MEASURED per piece,** one build each, raw and brotli, full / core /
258
+ elements. The full table is in `scripts/check-size.mjs`:
259
+
260
+ 1 provenance from `emitter` raw -25 / -25 / -25 brotli -8 / -2 / -5
261
+ 2 the 7 codeless sites, 5 new codes raw +174 / +89 / +89 brotli +54 / +35 / +42
262
+ 3 `action` + `context` on those sites raw +150 / +57 / +57 brotli +52 / +16 / +15
263
+ 4 the 2xx refusal tagged `emitter` raw +55 / +49 / +49 brotli 0 / +10 / +12
264
+
265
+ Squeezed first; three arms measured and not taken. `transportError` without its
266
+ `context` parameter is -12 raw and -12 / -5 / -8 brotli, declined once the
267
+ parameter earned its place. Dropping the builder for one shared
268
+ `{ emitter: 'transport' }` const inlined at each site is a wash (full +19 raw /
269
+ 0 brotli, core -6 / -7, elements -6 / +5), so the named function stays as the one
270
+ place saying which kind of error the transports mint. And row 4 as a `retryable`
271
+ boolean is 19 / 22 / 22 raw and 17 / 6 / 4 brotli dearer than the tag.
272
+
273
+ The five registry rows cost **nothing in these bundles**, and that scope is the
274
+ whole claim - verified by grep, the `fix` strings appear 0 times in all three,
275
+ because `/* @__PURE__ */` on `ERROR_CODE_REGISTRY`'s freeze still shakes the whole
276
+ table out of a build that never reads it. A consumer who imports the registry does
277
+ pay: the root barrel row in `docs/BUNDLE-SIZES.md` moved 23.3 -> 23.8 KB brotli
278
+ across this work, rows and new code strings together. The fifth budget
279
+ did not need raising either: the Blade consumer ESM bundle measures 6,342 against
280
+ its 6,380 ceiling.
281
+
282
+ Step 1 pays for itself and is the piece that fixes the defect; steps 2-4 are
283
+ features, taken on the trade: a transport failure now carries a code a consumer
284
+ can switch on and `getErrorEntry(code).fix` can explain, an emitter a logger can
285
+ route on, and an action.
286
+
287
+ **Fixture:** `tests/transport-code-owner.test.ts`, real bridges with only `fetch`
288
+ stubbed, counting HTTP requests rather than inspecting fields, because an attempt
289
+ count is what the defect cost. Verified to FAIL on all three defect cases against
290
+ `git show HEAD:` copies of every touched module. Two controls, and both PASS
291
+ against the pre-fix module, which is what makes the three failures evidence: with
292
+ no `code` the status rule still decides 503 -> 3 and 422 -> 1, and a transport
293
+ failure with a retryable code is still retried - so `emitter: 'transport'` cannot
294
+ have become a blanket "never retry" that would pass the first three assertions
295
+ for the wrong reason.
296
+
297
+ **Not addressed, and not from a "0 consumers" reading.** `VcTestError`
298
+ (`src/vitest-pure.ts`) carries a `VC_TEST_*` code and no `emitter`, so it is the
299
+ one library error class the new provenance rule does not recognise. No path puts
300
+ one in a `CommandResult` today. `ERROR_CODE_REGISTRY` calls itself the complete
301
+ registry of all `BusError` codes and nothing asserts that; a code added without a
302
+ row would make it false silently, which is the same shape as the incomplete
303
+ plugin catalogue found in whitepaper section 8.
304
+
305
+ ### `HttpError` is a class, and four hand-assembled sites are one call
306
+
307
+ It was `Error & { ... }` since v0.4. A type cannot be called, so four places
308
+ built the shape by hand: the non-2xx and timeout constructors in `http.ts`, and
309
+ the two catch blocks in `transports.ts` that re-assemble it field by field to
310
+ swap in the backend's own message. Each spelled the same four assignments out and
311
+ each was free to forget one. That is not hypothetical - it is how the envelope
312
+ paths came to drop `code` while the catch path twelve lines away kept it, the
313
+ defect the entry above this one fixed.
314
+
315
+ Nothing about the shape moved: the same five fields under the same names, and
316
+ `HttpError` still names a type wherever it did, because a class declaration
317
+ declares one. `index.ts` re-exports it as a type only, so the constructor is not
318
+ new public surface and stays shakeable out of a barrel import.
319
+
320
+ **Three shapes were measured**, one build each, brotli, full / core / elements:
321
+
322
+ class (shipped) 12,011 / 8,125 / 8,638
323
+ plain factory function 12,030 / 8,128 / 8,645
324
+ one Object.assign expression 12,021 / 8,131 / 8,642
325
+
326
+ The class is smallest in all three. It is 34 B LARGER raw in all three, and raw
327
+ is the ceiling that absorbs toolchain drift rather than the headline number - the
328
+ budget comments have said brotli is the meaningful metric since the Vite 8 move.
329
+
330
+ It is also the only one of the three that can be EXTENDED: a factory returns an
331
+ intersection nobody can subclass, and `Object.assign` returns a shape with no
332
+ identity at all. That is the tie-break the bytes did not need to make here, and
333
+ it is the reason to prefer it when they are this close.
334
+
335
+ `instanceof` is deliberately not what this buys. Using it to tell a library error
336
+ from a backend one was considered and dropped - `emitter` answers provenance, and
337
+ answers it for a plain object too.
338
+
339
+ **Cost: +72 raw / +71 / +71 and +8 brotli / -9 / +2.** Not a saving, and it was
340
+ not taken as one. `core` gets smaller; `full` pays 8 bytes. What it buys is that
341
+ a site can no longer omit a field. The Blade consumer ESM bundle measures 6,344
342
+ against its 6,380 ceiling, so that budget still does not move.
343
+
344
+ The note in `transports.ts` that priced sharing the two catch blocks stays, with
345
+ its number intact, and now says why this is not the case it declined: that
346
+ measurement was for a new shared HELPER, +30 B raw. Both sites call a CONSTRUCTOR
347
+ the layer below had to have anyway, so the shared code is paid for once by
348
+ `http.ts` rather than added.
349
+
350
+ ### BREAKING: `sync` is now `createChannel`
351
+
352
+ `sync(opts)` -> `createChannel(opts)`, `SyncOptions` -> `ChannelOptions`,
353
+ `SyncLane` -> `ChannelLane`. Same module, same behaviour, same options. The
354
+ section below is the change this one finishes.
355
+
356
+ **The name promised the one thing the mechanism cannot do.** That section ends
357
+ with a paragraph headed "What it still does not do": facts mirror but seeds do
358
+ not, a context opened later does not catch up, and tabs converge only when the
359
+ facts are absolute. A name needing a defensive paragraph is the defect. And
360
+ `sync` was the only bare verb among the primitives - `createCommandBus`,
361
+ `createFastLane`, `createChamber`, `createWorkflow`, `createHttpClient`,
362
+ `createOutbox`, `createTestBus` - so it read as a verb you call rather than a
363
+ handle you hold and close.
364
+
365
+ It also kept being mis-filed. Seven documentation sites listed it among the bus
366
+ plugins after it stopped being one, in README, ROADMAP, both migration guides,
367
+ the whitepaper's catalogue and an example, and one claimed the plugin pipeline
368
+ "applies automatically" to it, which it cannot.
369
+
370
+ `createChannel` pairs with `createFastLane` on the axis that matters: a lane is
371
+ local, a channel crosses contexts. It also uses the word the API already uses
372
+ for its own option (`channel`).
373
+
374
+ **Names rejected, with the reason each failed:**
375
+
376
+ - `createTabBridge` - names one example. The implementation is a bare
377
+ `new BroadcastChannel(...)`, which reaches every same-origin context: tabs,
378
+ windows, iframes and workers. Encoding "tab" in the name of a general
379
+ mechanism is the defect it was meant to fix.
380
+ - `createBroadcastBridge` - "broadcast" is redundant with "channel", and
381
+ "bridge" is the wrong family. All five `create*Bridge` attach to the BUS
382
+ (`bus.use(createHttpBridge(...))`, `realtime.install(bus)`). This takes a
383
+ lane and never sees a dispatch, so filing it as a bridge repeats the plugin
384
+ mistake one level over.
385
+
386
+ **Migration** is the call site and the import:
387
+
388
+ - import { sync } from 'vapor-chamber'
389
+ + import { createChannel } from 'vapor-chamber'
390
+
391
+ - const tabSync = sync({ channel, lane, events })
392
+ + const tabSync = createChannel({ channel, lane, events })
393
+
394
+ **Cost: +9 B raw / +2 B brotli, on the full IIFE only.** That is the whole of
395
+ it, and it is irreducible: `createChannel` appears exactly ONCE in the built
396
+ bundle, as the export key, so 13 characters replace 4. Verified by grep - one
397
+ occurrence in `vapor-chamber.iife.min.js`, zero in core and elements, which
398
+ stay byte-identical and serve as the control. The budget was raised rather than
399
+ squeezed because there is nothing to squeeze: the cost is the public name, once,
400
+ with no dead weight beside it to trade. The one string the rename touched, the
401
+ non-cloneable-payload warning, is DEV-gated and folds out of that build - its
402
+ wording also dropped "to other tabs", which was inaccurate for the same reason
403
+ `createTabBridge` was.
404
+
405
+ Also corrected while renaming: README's plugin list had "cross-tab sync" among
406
+ the plugins, and ROADMAP's capability list had it under Transports as a
407
+ "cross-tab" bridge. Neither is a plugin and neither is tab-scoped.
408
+
409
+ ### `buildRunner` is `@internal`, and the reference stops advertising it
410
+
411
+ It sat in the root barrel with a full section in the generated reference, while
412
+ every mention of it in prose treats it as internals - `docs/performance.md` calls
413
+ it "buildRunner's PERF NOTE", the whitepaper twice describes it as the sync
414
+ runner. Nothing outside `src/` imports it: `src/testing.ts` takes it relatively
415
+ from `./command-bus`, and `command-bus.ts` uses it internally. No test, no
416
+ example, no documented consumer call.
417
+
418
+ **Not a removal.** The export stays exactly where it is, so anything that did
419
+ reach for it keeps working - the no-removal-on-zero-consumers rule respected
420
+ rather than argued around. One JSDoc tag, no code, and the `@internal` filter
421
+ `generate-api-docs.mjs` already applies does the rest; an existing mechanism, not
422
+ a new one. The reference goes from 459 to 458 exports, the root entry from 196 to
423
+ 195. The existing PERF NOTE above the function is untouched: the new block sits
424
+ between it and the declaration, where TypeScript reads it.
425
+
426
+ ### Fixed: the API reference published a name nobody can import
427
+
428
+ Two defects in the generated reference, both live in the output rather than stale
429
+ files - regenerating before the fix produced no diff.
430
+
431
+ **A name that does not compile.** `getExportsOfModule()` is mapped through
432
+ `resolve()` so that `@internal` and the type come from the declaration, and that
433
+ also renamed the symbol. A declaration's name is not always the public one:
434
+ `src/vitest.ts` does `export { expect }` on a binding Vitest declares as
435
+ `globalExpect`. So `docs/api/vitest.md` listed `globalExpect` under Variables,
436
+ documented its signature, and never mentioned `expect` - while that module's own
437
+ JSDoc tells you to write `import { expect, it } from 'vapor-chamber/vitest'`. The
438
+ same shape as the `v-vc:payload` drift: a documented spelling that nothing
439
+ compiles. Fixed by keeping the export name BESIDE the resolved symbol instead of
440
+ in place of it, in a map rebuilt per entry point, because one declaration can be
441
+ exported under different names by different entries and the program is shared.
442
+
443
+ **Every SCREAMING_SNAKE entry in a Contents list was a dead link.** `anchor()`
444
+ folded `_` into `-`, but GitHub's slugger keeps underscores, so
445
+ `ERROR_CODE_REGISTRY` anchored as `error_code_registry` while the link said
446
+ `error-code-registry`. Seven of them. The sections were present and correct; only
447
+ the navigation to them was broken.
448
+
449
+ Also here: `scripts/stamp-docs.mjs` claimed "a fresh checkout has
450
+ docs/metrics.json (committed)". It does not - `.gitignore` ignores it
451
+ deliberately, because the counts depend on what else has run and a committed copy
452
+ would churn. The ignore is right and the comment was its opposite, and the cost it
453
+ hid is worth one line: a marker whose source is absent renders exactly like a
454
+ current one, and `--check` reports it current having had nothing to compare
455
+ against. Twelve `vc:bench*` markers are that set.
456
+
457
+ ### Fixed: the ascii guard raced the A/B tests' generated tree
458
+
459
+ `npm run test:run` failed intermittently on a file that is not on disk -
460
+ `ENOENT: tests/__ref/before-cancel/before-cancel-pre.ts`, from
461
+ `tests/ascii-guard.test.ts` - while the same file passed 15/15 on its own. Each
462
+ `tests/*-ab.test.ts` writes `tests/__ref/<name>/` while it runs and removes it
463
+ afterwards, and eight of them do, so a vitest run has several writers going at
464
+ once. The guard's test collects paths into one list and reads them later, which
465
+ turns that race into an ENOENT on a path that existed at walk time.
466
+
467
+ `__ref` joins `SKIP_DIRS` in `scripts/check-ascii.mjs`, beside `dist` and
468
+ `coverage`, which is where a generated tree belongs. One directory name fixes both
469
+ readers - the guard's own walk and the test's unfiltered one, since the test
470
+ imports `SKIP_DIRS` rather than keeping its own list.
471
+
472
+ NOT fixed by catching ENOENT in the reader, which is the other obvious way: that
473
+ would make the guard able to skip a real file silently, and a guard that can do
474
+ that is worse than the flake. Verified both directions - the guard still scans its
475
+ full set and still REJECTS an em dash injected into `src/dev.ts`, because a skip
476
+ rule that quietly matched too much would be indistinguishable from a working
477
+ guard.
478
+
479
+ ### `gate.mjs` advertised a flag it no longer implements
480
+
481
+ Its usage block offered `node scripts/gate.mjs [--expect-stamp-drift]`. The flag
482
+ is gone - the docblock says so nine lines later ("it was a countdown, not a
483
+ setting, and a second use would have made it one") and the implementation went
484
+ with it. Only the usage line survived, documenting an option that does nothing,
485
+ and passing it is silently accepted as an unknown argument: the worst of the three
486
+ possible behaviours, because a reader who follows the usage line believes stamp
487
+ drift is being tolerated when the check is unconditional.
488
+
489
+ ### Documentation: fourteen false claims, and one drift direction worth naming
490
+
491
+ Every tracked document that had never been read end to end was read end to end,
492
+ each claim checked against source before reading for sense. What that found, by
493
+ file:
494
+
495
+ - **ROADMAP.md** - "we align on rc.8" while the pin reads rc.9, and a header line
496
+ that read "Last reviewed against Vue 3.6.0-rc.8". The file carried FIVE
497
+ `vc:vueAligned` markers: four were stamped and correct the whole time, and only
498
+ the one hand-typed mention was stale. It is a marker now. Also: the prose plugin
499
+ list omitted `optimisticUndo` and `supersede`, and the feature matrix - which
500
+ that file calls "the single source of truth for feature status" - omitted
501
+ `supersede` entirely, a public export since v1.9.0. And "Four pieces have landed
502
+ under it" was two short.
503
+ - **CONTRIBUTING.md** - `plugins-io.ts` still listed `sync` as a plugin;
504
+ `directives.ts` named one Vapor directive where there are three; and
505
+ `npm run gate` appeared ZERO times, in the file whose Workflow section lists
506
+ seven commands and says "the order is part of the gate". It is now the first
507
+ thing that section offers, with its two traps: it wants a committed tree, and
508
+ `| tail` masks its exit code.
509
+ - **docs/integrations/laravel.md** - it told backend authors `code` does not
510
+ survive a 200 body and to read it back only from a throw. Also added, because
511
+ nothing said it: a redirect is a FAILED dispatch whether or not `onRedirect` is
512
+ wired, it carries `VC_TRANSPORT_REDIRECT` and `error.context.url`, and `retry()`
513
+ will not re-send it.
514
+ - **docs/integrations/vitest.md** - the Diagnostics section enumerates what a
515
+ `VcTestError` carries, and it gained a field this cycle.
516
+ - **examples/vapor-island-cart/README.md** - called `createChannel` a plugin and
517
+ counted "four plugins", while its own `src/store.ts` says "the bridge is NOT a
518
+ bus plugin", uses `createFastLane()`, and has three `bus.use` calls. The rename
519
+ swept the name and left the semantics, which is what v1.22.0 actually changed.
520
+ - **examples/exo-astro/README.md** - "vapor-chamber itself still runs on Node
521
+ >= 20.19; only this example carries the higher bar". The root `engines.node` is
522
+ `>=22.12.0`, so there is no higher bar. True once; the root floor moved up.
523
+ - **examples/vapor-sfc/README.md** - a file tree that reads as complete, missing
524
+ one file.
525
+
526
+ Source comments in the same pass: four modules stopped calling the alignment log a
527
+ table, `src/glyphs.ts` and `src/store.ts` lost three citations to files that never
528
+ existed, `src/iife-elements.ts` and `src/mcp.ts` stopped carrying `@example`s that
529
+ instruct the reader into a no-op, and `src/plugins-io.ts` records what happens when
530
+ a backend uses the `VC_` prefix.
531
+
532
+ **The drift direction worth naming, because nothing watches for it.** A doc that
533
+ correctly describes a LIMITATION acquires an expiry date the moment someone sets
534
+ out to remove the limitation. It does not drift away from the code; the code moves
535
+ to meet it, and the sentence stays internally coherent, cites real behaviour, and
536
+ is exactly what a reader trusts. Two instances landed in one day, both about
537
+ `code` not surviving an envelope failure - the page and the PHP docblock each said
538
+ so accurately, and then the client was fixed. When a commit removes a limitation,
539
+ grep for the words that documented it.
540
+
541
+ Two checks earned their place and one did not. A document that carries the same
542
+ fact as BOTH a `vc:` marker and a hand-typed literal will drift at the hand-typed
543
+ one - that is how ROADMAP broke, and it is worth grepping a file for a marker
544
+ family and then for that value outside a marker. Where a runnable example ships
545
+ for a documented pattern, diff them; three of this pass's sharpest finds came from
546
+ an example contradicting its own README. And a scan for staleness BY VALUE cannot
547
+ see staleness by mismatch: it finds a current value typed by hand, never an old
548
+ one, which is why "we align on rc.8" survived the first sweep of the same file.
549
+
550
+ ### Upgrade note: your OWN Vapor directives, if they take an argument
551
+
552
+ v1.22.0's rc.9 notes cover this library's directives. The same Vue change
553
+ reaches any Vapor directive a consumer wrote: from rc.9 (#15490) the third
554
+ parameter is a getter, `() => Arg`, not the string. Code written against rc.8
555
+ that reads it as a string fails only in a COMPILED template, so tests that
556
+ hand-write the `withVaporDirectives` tuple stay green. Reported by
557
+ `vapor-chamber-router-wire`: `v-wire:title` threw `path.split is not a
558
+ function` on every compiled template while six hand-tuple tests passed.
559
+
560
+ - function vMine(el, value, argument, modifiers) { use(argument) }
561
+ + function vMine(el, value, argument, modifiers) { use(argument?.()) }
562
+
563
+ `tests/compile-vapor.ts` is the fixture that catches it; compile the template
564
+ on the installed Vue rather than writing its output by hand. If a vitest config
565
+ aliases `vue` to the with-vapor build, make it `{ find: /^vue$/ }` - a string
566
+ key also rewrites `vue/compiler-sfc`, and `vitest.vapor.config.ts` here now
567
+ does the same.
568
+
569
+ ### Fixed: one refused command no longer holds the outbox forever
570
+
571
+ `runFlush` stopped at the first failed replay of ANY kind and kept that record
572
+ at the head. That is right for a transient failure - order is preserved and the
573
+ next flush retries from the same spot - and wrong for a refusal: the server has
574
+ already answered, so every flush re-sent the same record, got the same answer,
575
+ and the records queued behind it never left. Measured with the reference
576
+ controller's batch shape, one refused record ahead of two good ones:
577
+
578
+ flush 1: { replayed: 0, failed: 1 } pending 3
579
+ flush 2: { replayed: 0, failed: 1 } pending 3
580
+ flush 3: { replayed: 0, failed: 1 } pending 3
581
+
582
+ The only way out was `clear()`, which drops the good records too, and
583
+ `'outboxFlushed'` did not say which record was stuck.
584
+
585
+ A failure the SERVER gave as its verdict on the command is now final: the record
586
+ is dropped, `'outboxRejected'` fires with `{ record, error }` so the app can tell
587
+ the user or re-queue it, and the flush moves on. "Verdict" by default means a
588
+ refusal inside a 2xx, or a 4xx other than 401, 408, 419 and 429. Everything else
589
+ still blocks exactly as before - a 5xx, a network failure, and every error the
590
+ library raised itself (a plugin that threw, a handler not registered yet, a
591
+ redirect) - because dropping a queued command over a client-side failure loses
592
+ the user's data. The two existing tests that pin "nothing was lost" for those
593
+ cases are unchanged and pass.
594
+
595
+ Which failures are final is the app's call, so it is an option:
596
+ `createOutbox({ isRetryable: (error, record) => boolean })`. It sees the record,
597
+ so the rule can be per action and in the backend's own codes; `() => true`
598
+ restores the old block-on-everything behaviour.
599
+
600
+ **Changed shape:** the flush summary gains `rejected` -
601
+ `{ replayed, failed, rejected }`, from `flush()` and on `'outboxFlushed'`.
602
+
603
+ **Fixture:** `tests/outbox-refusal.test.ts`, real bridges with only `fetch`
604
+ stubbed - a batched refusal and a single-endpoint 422 at the head are rejected
605
+ and the rest replay; 401, 429 and 500 are kept; the app's predicate overrides
606
+ both ways. All 8 cases fail against the pre-fix module.
607
+
608
+ ### Fixed: `onRedirect` never fired with the reference controller, and the batching bridge ignored it
609
+
610
+ Two halves of one feature, each broken on its own, measured by running the real
611
+ controller under Laravel and feeding its output to the real bridge.
612
+
613
+ **The controller.** `docs/integrations/laravel.md` tells an action to hand a
614
+ navigation back with `return ['redirect' => route('login')]`. The controller
615
+ wrapped every return value as `state`, so it answered
616
+ `{"ok":true,"state":{"redirect":"/login"}}` - and both bridges read `redirect`
617
+ at the TOP of the envelope. Fed that exact body, `createHttpBridge` with
618
+ `onRedirect` set: `onRedirect calls: 0 | result.ok: true | value: {"redirect":"/login"}`.
619
+ The documented recipe had never navigated. `dispatchOne()` now lifts exactly
620
+ that shape - an array whose only key is `redirect` - to `{ redirect }`, and per
621
+ result on `batch()`. A state that merely contains a `redirect` key is still
622
+ data.
623
+
624
+ **The batching bridge.** `BatchingHttpBridgeOptions` extends
625
+ `HttpBridgeOptions`, so it has always ACCEPTED `onRedirect`, and never read it:
626
+ a redirect result resolved as a success. It now fails that command with
627
+ `VC_TRANSPORT_REDIRECT` and `context.url`, as the single bridge does, and calls
628
+ `onRedirect` once per batch with the first URL - two navigations in one tick
629
+ race. `BackendResponse` gains the `redirect?: string` field the single bridge
630
+ was already reading untyped.
631
+
632
+ **Fixture:** `tests/batch-redirect.test.ts` - a redirect beside a normal
633
+ result, two redirects in one batch, no `onRedirect`, and the single bridge as
634
+ the control. The three batch cases fail against the pre-fix module; the
635
+ control passes on both.
636
+
637
+ ### Fixed: the ascii guard's test failed outside a git checkout
638
+
639
+ `tests/ascii-guard.test.ts` lists the tracked root files with `git ls-files`
640
+ at collection time. In a copy that is not a git checkout - a release folder, an
641
+ extracted tarball - that threw, and the whole file failed with 0 tests: a false
642
+ failure, and its other 14 checks never ran. It now falls back to every regular
643
+ file in the root, which is a SUPERSET of what git tracks, so the check is never
644
+ weaker there. Armed both ways: with the guard's `rootFiles()` stubbed to return
645
+ nothing, the fallback fails the assertion it exists for; restored, 15/15 pass,
646
+ in a checkout and out of one.
647
+
648
+ ### Documentation: three whitepaper claims and one integration-guide claim
649
+
650
+ - `whitepaper.md`, the plugin table: the batching bridge had "Same options and
651
+ backend contract". Its `retry` covers a failure of the whole POST only; a
652
+ command refused inside the batch arrives with the backend's `code`, and
653
+ re-sending it is the app's `retry({ isRetryable })`. Section 11.7 now shows
654
+ that, scoped to the bridged actions.
655
+ - `whitepaper.md`, the `retry` example: `isRetryable: (err) => err.message !==
656
+ 'Unauthorized'` matched nothing the library emits (`authGuard` says
657
+ "Unauthorized: X requires authentication"). It now shows the app's rule in
658
+ the backend's own codes, and says the predicate replaces the default.
659
+ - `whitepaper.md`: the module map still listed `sync` in `plugins-io.ts`, and
660
+ the store row still called it `sync`.
661
+ - `integrations/laravel.md`: "a failure inside a 2xx ... you refused in the
662
+ body" is false on the batch endpoint, where a crash (`internal_error`, 500 in
663
+ `dispatchOne()`) is delivered inside the same 200. It says so now, and shows
664
+ the app-owned rule.
665
+
5
666
  ## v1.22.0 - 2026-09-21 Vue 3.6.0-rc.9 alignment
6
667
 
7
668
  89 commits in `v3.6.0-rc.8..v3.6.0-rc.9` (the full log, not first-parent; the