@sofa-buffers/corelib 0.8.1 → 0.11.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 CHANGED
@@ -6,10 +6,274 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
  While the version is below `1.0.0`, breaking changes bump the **minor** version.
8
8
 
9
- ## [Unreleased]
9
+ ## [0.11.0] - 2026-09-24
10
+
11
+ > The breaking entries below make this a **minor** bump, per the pre-`1.0.0` rule
12
+ > above — never a patch. The release rebuilds the decode surface against
13
+ > CORELIB_PLAN@`c837108`: the visitor is the only way to decode, it is flat, it
14
+ > allocates nothing after construction, and an array's elements arrive through a
15
+ > hand-off instead of per-element callbacks.
16
+
17
+ ### Changed
18
+
19
+ - **BREAKING (decode API) — one visitor surface, flat and heap-free, and no
20
+ views (CORELIB_PLAN §5.3.1, §6.6, §6.7).** `Cursor` is gone, and with it
21
+ `fast.ts` and the `reader.ts` they shared. A second decode surface is a second
22
+ implementation of every rule, which this port had already paid for three times
23
+ (chunk-boundary verdicts, fixlen-array word order, receiver caps). `decode()`
24
+ is now `IStream` fed once, over one resumable machine with a fast lane that
25
+ decodes a field inline whenever its words do not straddle the chunk.
26
+
27
+ | before | after |
28
+ |---|---|
29
+ | `Cursor`, `decode()` as a second implementation | `new IStream(visitor)` and `feed(chunk)`; `decode()` is that machine fed once |
30
+ | a visitor per nested message class | **one flat visitor per message**; nesting arrives as `sequenceBegin(id, depth)` / `sequenceEnd(id, depth)` |
31
+ | `sequenceBegin` returns `null` to decline a subtree | returns **`false`** |
32
+ | `Visitor.longs`, an opt-in `Long` channel | gone — `lo`/`hi` halves are passed beside every integer value |
33
+ | `Visitor.fp32` takes a byte view | takes **`bits`**; re-encode with `OStream.writeFp32Bits` |
34
+ | payloads reported as a view | `(id, total, offset, src, start, end)`, where `src` **is** the caller's fed chunk |
35
+ | `FlushSink(view)` | `FlushSink(buffer, start, end)` — the sink only ever sees the installed buffer |
36
+
37
+ A visitor per nested class made every dispatch site megamorphic on V8 and JSC
38
+ and put a per-scope object on the decode path; flat removes both, and the
39
+ visitor stack with them. No view is built and nothing is retained, and the
40
+ one-shot path has no exemption (§6.7.1). Pass-through of a payload run is
41
+ **forbidden** (§5.1.6), not merely off by default. After construction `write`,
42
+ `feed` and `flush` allocate nothing: fixed-size state is sized from `MAX_DEPTH`
43
+ at construction, and `decode()` pools one decoder so a one-shot caller pays no
44
+ construction per message.
45
+
46
+ One deviation is recorded rather than hidden: a payload split across flushes is
47
+ copied with `set(data.subarray(...))`, one view per piece, because the
48
+ allocation-free alternative measured 358 MB/s against 10,963 MB/s.
49
+
50
+ - **BREAKING (decode API) — an array's elements reach a visitor only through the
51
+ hand-off (§6.7.2).** `Visitor.arrayUnsigned`, `arraySigned`, `arrayFp32` and
52
+ `arrayFp64` are removed. `Visitor.arrayBulk` hands the decoder the destination
53
+ to fill — a `number[]`, a `Long[]`, a `Uint32Array` pair of halves, a
54
+ `Float32Array` / `Float64Array`, or the raw `fp32` words — together with the
55
+ schema's element bound as 32-bit halves. Returning `null`, or declaring no
56
+ hook, walks the elements over without decoding them: the `skip` half of
57
+ §6.7.2's two intents.
58
+
59
+ Two delivery routes for the same elements were two places for the element
60
+ bound to be compared, two resume paths to keep in step, and a standing
61
+ invitation for generated code to take the slower one (§5.3.1: a rule gets one
62
+ implementation).
63
+
64
+ **Migration.** Replace each `array*` callback with one `arrayBulk` that returns
65
+ the destination; fold at `arrayEnd` instead of inside the delivery. Measured
66
+ with `bench/run_callgrind.sh`, 1000-element arrays, Ir/op:
67
+
68
+ | array | destination | before | after | |
69
+ |---|---|---:|---:|---:|
70
+ | `array<u16>` | `number[]` | 235,433 | 184,023 | −21.8% |
71
+ | `array<u64>` | `Long[]` | 576,206 | 429,113 | −25.5% |
72
+ | `array<u64>` | `lo`/`hi` halves | 498,618 | 299,180 | −40.0% |
73
+ | `array<fp64>` | `Float64Array` | 93,520 | 36,893 | −60.5% |
74
+ | `array<fp32>` | `Float32Array` | 94,497 | 35,259 | −62.7% |
75
+
76
+ Declining is cheaper still, since nothing is decoded into existence:
77
+ `array<fp64>` 36,893 → 9,383, `array<u16>` 184,023 → 152,082. A short array is
78
+ not the exception — the ~600 Ir fixed cost is paid once per array, so four
79
+ elements at the end of a 37-byte message cost 578 Ir against the 563 the
80
+ callbacks cost. What did move is a *folding* consumer's side: the same four
81
+ elements cost 1,063 Ir summed at `arrayEnd`.
82
+
83
+ - **BREAKING (decode API) — the codec holds no receiver limit (§6.2.1).** §6.2.1
84
+ fixes the provenance of a `max_dyn_*` number and forbids the codec from
85
+ supplying one: it must not hold a limit of its own, supply a default for one it
86
+ was not given, read an omitted argument as unlimited, or clamp to one. A §6.2
87
+ format ceiling reached because no cap was stated is the **format's** bound, not
88
+ a receiver cap.
89
+
90
+ This port failed both. `src/decode/state.ts` held `maxArrayCount` /
91
+ `maxStringLen` / `maxBlobLen`, each defaulting to `ARRAY_MAX` / `FIXLEN_MAX`,
92
+ so an unconfigured decode reported `LimitExceeded` against a ceiling nobody had
93
+ configured; `src/decode/seq.ts` defaulted `receiverCap` and `receiverElemMax`
94
+ at five collector sites.
95
+
96
+ | before | after |
97
+ |---|---|
98
+ | `src/decode/limits.ts`, `DecodeLimits` | **deleted** |
99
+ | `decode(bytes, visitor, limits?)`, `new IStream(visitor, limits?)` | the `limits` argument is gone |
100
+ | `StringSeq` / `BlobSeq` / `ElementSeq` bounds optional | every bound is a **required** constructor argument |
101
+ | — | `UNBOUNDED` (`-1`) is exported as the explicit "the schema declared none" |
102
+
103
+ The comparison now lives in the layer that has the number: generated code
104
+ compares its cap inside `arrayBegin` / `fixlenBegin`, and the collectors compare
105
+ the two bounds they are handed for the one shape a visitor cannot see — a
106
+ wrapper array's element index and element byte length.
107
+
108
+ - **BREAKING (decode API) — a refused decode is terminal, and `feed` is the only
109
+ answer (§5.2.1, §6.3).** `IStream.status()` is **removed**. `feed` returns
110
+ `FeedStatus`, a type alias admitting only `Complete` and `Incomplete`, so the
111
+ compiler rejects a branch that can never be taken; refusals travel on the thrown
112
+ `SofabError` with their code.
113
+
114
+ Two terminal rejections are decided inside a visitor callback rather than by the
115
+ state machine — §6.4.5 puts the strict UTF-8 check where a string is
116
+ materialized, §6.2.1 puts the receiver caps in the layer holding the numbers —
117
+ and both arrived as a throw out of the parse loop, passing the latch entirely.
118
+ Reproduced before the fix: on wire `0a 12 ff fe`, `feed` threw `INVALID_MSG` and
119
+ `status()` then answered `COMPLETE`; at a receiver cap with chunk size 3, the
120
+ visitor was handed `unsigned(0, 42)` — a field that was never on the wire.
121
+ `push()` now routes both through the existing `fail()` latch, which holds the
122
+ raised `SofabError` so the two codes stay distinct.
123
+
124
+ ### Added
125
+
126
+ - **An exact-width typed array is an array destination.**
127
+ `IntegerArrayTarget.typed` takes a `Uint8Array`..`Uint32Array` /
128
+ `Int8Array`..`Int32Array` whose element width **is** the schema's declared
129
+ width, and the encoder's array writers read one without the guards a `number[]`
130
+ element needs: a `Uint16Array` element is an integer in `0..65535` by
131
+ construction. It stores unboxed and never changes element kind the way a
132
+ `number[]` does when a value leaves the small-integer range. The element bound
133
+ is still compared — a typed array masks on store (70000 into a `Uint16Array` is
134
+ 4464) and MESSAGE_SPEC §7.1 makes an out-of-width element INVALID, so a fill
135
+ that merely stored would turn a malformed message into an accepted one.
136
+
137
+ - **64-bit and boolean destinations.** `IntegerArrayTarget.typed` also takes a
138
+ `BigUint64Array` / `BigInt64Array`. It cannot be filled the way the 32-bit
139
+ widths are — `b[i] = 5` throws, and a `bigint` store is one allocation per
140
+ element, the cost a 64-bit array most wants to avoid — so the fill writes the
141
+ two 32-bit halves it already holds into a `Uint32Array` over the array's own
142
+ buffer and builds no `bigint` at all. The encoder reads them back the same way,
143
+ so `writeUnsignedArray(id, someBigUint64Array)` is `bigint`-free in both
144
+ directions and byte-identical to the `bigint[]` path. A boolean array gets its
145
+ own destination for the same reason.
146
+
147
+ - **An fp32 array written from a `Float32Array` keeps its bits.** `packFp32Array`
148
+ read `values[i]`, widening each element to a double — and widening a
149
+ **signaling** NaN quiets it (`0x7f800001` came back `0x7fc00001`). A
150
+ `Float32Array` source already holds the 32-bit wire words, so they are copied
151
+ through a `Uint32Array` view instead. Bit-exact for every value, not only NaNs.
152
+ With a `Float32Array` member and the `bits` destination on the way in, the array
153
+ **is** the payload in both directions, and there is nothing left to capture,
154
+ compare or re-attach. The words go out through `DataView.setUint32(..., true)`,
155
+ so the wire stays little-endian whatever the platform's order is.
156
+
157
+ - **The static helpers a generated package still carries.** ARCHITECTURE §8 puts
158
+ a helper here when its code has the same shape for every schema and its schema
159
+ dependence is carried entirely by arguments and type parameters. Three helpers
160
+ in the TypeScript backend's output failed that test only because this library
161
+ had nowhere to put them, so they were re-emitted, textually identical, into
162
+ every generated package. `FramedSeq<T>` is the one that is not a straight move:
163
+ `ElementSeq<T>` holds the index rules of MESSAGE_SPEC §5.1 and the two exclusive
164
+ bounds of §6.2.1 for any element type, but writes a single shared `def` into
165
+ every gap — right for `""` and a zero-length `Uint8Array`, wrong for a `struct`,
166
+ a `union`, a nested row or a `Long[]` row, whose defaults are fresh mutable
167
+ objects.
168
+
169
+ ### Fixed
170
+
171
+ - **A source's claimed element width is not a fact the encoder can check.** The
172
+ bulk kernel decided how much room to reserve by reading the source's own
173
+ `constructor` — an ordinary property. An `ArrayLike` claiming to be a
174
+ `Uint16Array` while holding `0xffffffff` got three bytes an element reserved and
175
+ needed five; the kernel then wrote past the buffer, where the writes are no-ops,
176
+ and the message came back short while `bytesUsed` reported a length that was
177
+ never written — CORELIB_PLAN §5.1's "partial output handed back as complete",
178
+ the one failure an encoder must never produce. The estimate is gone and nothing
179
+ replaces it: `out.length` is the kernel's bound.
180
+
181
+ - **A streamed `Float32Array` keeps its signaling NaNs.** `writeFp32Array` copied
182
+ the wire words only on the bulk path; when the array did not fit the buffer it
183
+ fell back to `putFp32(values[i])`, quieting signaling NaNs, so a streamed encode
184
+ differed from the one-shot one (§6.5, §5.1.4). A `Float32Array` now takes its own
185
+ route at the entry, streamed through one `Uint32Array` over the source with the
186
+ words stored by shifts (byte-order independent). Split points are unchanged.
187
+
188
+ - **"Exactly one destination" now means one out of all of them.** `resolveTarget`
189
+ refused a target naming two destinations, but the float branch counted only
190
+ `f32`/`bits`/`f64` and the `bool` branch only the integer fields, so a
191
+ combination straddling the two branches was accepted and the other destination
192
+ left untouched — `{ bool, f64 }` on an fp64 array filled `f64` and left `bool`
193
+ at `[0,0]`, which reads as data. Worse, the extra field *silenced* an error:
194
+ `{ bool }` alone on an fp64 array is an `Argument` refusal, `{ bool, f64 }` was
195
+ not.
196
+
197
+ - **One fp32 NaN word view per array, not per NaN**, and the typed-destination
198
+ check is kept polymorphic, so an `instanceof` slow path no longer forces the
199
+ streaming loop to re-check every element.
200
+
201
+ - **The `node_modules` symlink committed by mistake is untracked.**
202
+
203
+ ### Performance
204
+
205
+ Measured with `bench/run_callgrind.sh` (Callgrind Ir/op, Node 24). Wall-clock
206
+ numbers from a shared runner are not usable at this resolution.
207
+
208
+ - **Encode and decode instruction cost cut by up to 4.7×:** `encode: u64 array
209
+ (1000)` 1,229,088 → 259,659 (−78.9%), `decode: u64 array (1000)` 1,458,937 →
210
+ 721,953 (−50.5%), `encode: typical message` 13,360 → 10,408 (−22.1%).
211
+ - **The §6.6.2 float handle, taken where it pays and only there:** `encode fp32`
212
+ (1000) 195,320 → 27,970 (−85.7%), while `decode: typical message` moved 5324 →
213
+ 6415 Ir/op, and every other decode path paid ~0.4% for code it never runs.
214
+ - **A skipped scope is carried as a sentinel, not a null slot**, recovering the
215
+ `decode: typical message` regression the nullable slot had cost (5366 → 5210
216
+ Ir/op, +0.93% over baseline, was +3.95%).
217
+ - **A collector compares against one bound, picked at construction**, since the
218
+ two-bound compare could not be inlined and a CALL is about 200 Ir.
219
+
220
+ ## [0.10.0] - 2026-08-01
221
+
222
+ > The breaking entries below make the next release a **minor** bump (the first
223
+ > since `0.2.0`), per the pre-`1.0.0` rule above — never a patch. The published
224
+ > package is `@sofa-buffers/corelib`; the git tag is the source of truth for the
225
+ > version number, and `package.json` stays at `0.0.0-dev`.
10
226
 
11
227
  ### Changed
12
228
 
229
+ - **BREAKING (encode API) — an all-default sequence is now *omitted*, not framed
230
+ empty (MESSAGE_SPEC §2, CORELIB_PLAN §6).** A sequence-typed **field** whose
231
+ value equals its declared default carries no information, so it no longer
232
+ reaches the wire at all, where it previously appeared as the two-byte empty
233
+ frame `0E 07`. An all-default message is now the **empty byte string**. A
234
+ wrapper-array **element** is the exception and keeps its frame: element
235
+ presence is what carries a dynamic array's length (§5.1), so dropping one
236
+ would change the decoded value, not just the bytes.
237
+
238
+ Deciding this without buffering the sub-message means the sequence header has
239
+ to be held back until the sequence proves it has content, which changes the
240
+ encoder's public sequence API:
241
+
242
+ | before | after |
243
+ |---|---|
244
+ | `writeSequenceBegin(id)` — **removed** | `writeSequenceBeginLazy(id)` — opens the scope and holds the header back; writes no byte |
245
+ | `writeSequenceEnd()` | `writeSequenceEnd()` — drops the frame (header *and* end marker) if the sequence got no content |
246
+ | — | `writeSequenceEndKeep()` — new; emits the held-back headers plus the end marker, so a contentless sequence still reaches the wire as `begin` + `end` |
247
+
248
+ **Migration.** Replace every `writeSequenceBegin` with
249
+ `writeSequenceBeginLazy`. Then pick the closer *statically*, by the position in
250
+ the schema — it is a property of the position, not of the value:
251
+ `writeSequenceEnd` for a `struct`/`union` field and for an array-field wrapper;
252
+ `writeSequenceEndKeep` for a wrapper-array element, and for an array field
253
+ already known to differ from a **non-empty** declared default. When in doubt
254
+ `writeSequenceEndKeep` is the safe choice: the failure directions are not
255
+ symmetric — a needless `endKeep` costs one non-canonical empty frame that a
256
+ decoder normalizes away, while a wrong `end` silently changes an array's
257
+ length. Code that transcodes or replays raw bytes (rather than encoding a
258
+ schema value) wants `writeSequenceEndKeep` throughout, so its output reproduces
259
+ its input frame for frame.
260
+
261
+ **Decoding is unaffected**, in both directions: an empty frame remains valid
262
+ input that the message layer normalizes to the default, and an omitted
263
+ sequence field was already reconstructed from the schema default. Old and new
264
+ encoders therefore interoperate; they disagree only about which encoding is
265
+ canonical. Every non-sequence byte is unchanged — the shared
266
+ `assets/test_vectors.json` is re-synced and every `serialized` hex is
267
+ byte-identical; the vectors' separate `serialized_sparse` column is the new
268
+ canonical form, and is exercised by the generator's conformance drivers (a
269
+ corelib has no message layer and cannot produce it).
270
+
271
+ The hold-back run is bounded only by `MAX_DEPTH`: this port can allocate, so
272
+ it holds back to the full nesting depth and is canonical at every depth
273
+ (CORELIB_PLAN §6, "How deep the hold-back reaches"). Held-back ids are encoder
274
+ state and never buffer content, so a flush cannot split a run and a buffer
275
+ smaller than the message still produces the one-shot bytes.
276
+
13
277
  - **Strict UTF-8 for `string` fields (corelib-ts#85, MESSAGE_SPEC §8,
14
278
  CORELIB_PLAN §6.4).** JavaScript strings are a Unicode string type, so the
15
279
  corelib transcodes `string` payloads at the boundary and is now **always
@@ -151,5 +415,7 @@ decode-side win.
151
415
  (driving a `Visitor`) to decode, both chunkable, with a swappable acceleration
152
416
  `Kernel` seam.
153
417
 
418
+ [0.11.0]: https://github.com/sofa-buffers/corelib-ts/releases/tag/v0.11.0
419
+ [0.10.0]: https://github.com/sofa-buffers/corelib-ts/releases/tag/v0.10.0
154
420
  [0.2.0]: https://github.com/sofa-buffers/corelib-ts/releases/tag/v0.2.0
155
421
  [0.1.0]: https://github.com/sofa-buffers/corelib-ts/releases/tag/v0.1.0