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