@sofa-buffers/corelib 0.8.0 → 0.10.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,63 @@ 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.10.0] - 2026-08-01
10
+
11
+ > The breaking entries below make the next release a **minor** bump (the first
12
+ > since `0.2.0`), per the pre-`1.0.0` rule above — never a patch. The published
13
+ > package is `@sofa-buffers/corelib`; the git tag is the source of truth for the
14
+ > version number, and `package.json` stays at `0.0.0-dev`.
10
15
 
11
16
  ### Changed
12
17
 
18
+ - **BREAKING (encode API) — an all-default sequence is now *omitted*, not framed
19
+ empty (MESSAGE_SPEC §2, CORELIB_PLAN §6).** A sequence-typed **field** whose
20
+ value equals its declared default carries no information, so it no longer
21
+ reaches the wire at all, where it previously appeared as the two-byte empty
22
+ frame `0E 07`. An all-default message is now the **empty byte string**. A
23
+ wrapper-array **element** is the exception and keeps its frame: element
24
+ presence is what carries a dynamic array's length (§5.1), so dropping one
25
+ would change the decoded value, not just the bytes.
26
+
27
+ Deciding this without buffering the sub-message means the sequence header has
28
+ to be held back until the sequence proves it has content, which changes the
29
+ encoder's public sequence API:
30
+
31
+ | before | after |
32
+ |---|---|
33
+ | `writeSequenceBegin(id)` — **removed** | `writeSequenceBeginLazy(id)` — opens the scope and holds the header back; writes no byte |
34
+ | `writeSequenceEnd()` | `writeSequenceEnd()` — drops the frame (header *and* end marker) if the sequence got no content |
35
+ | — | `writeSequenceEndKeep()` — new; emits the held-back headers plus the end marker, so a contentless sequence still reaches the wire as `begin` + `end` |
36
+
37
+ **Migration.** Replace every `writeSequenceBegin` with
38
+ `writeSequenceBeginLazy`. Then pick the closer *statically*, by the position in
39
+ the schema — it is a property of the position, not of the value:
40
+ `writeSequenceEnd` for a `struct`/`union` field and for an array-field wrapper;
41
+ `writeSequenceEndKeep` for a wrapper-array element, and for an array field
42
+ already known to differ from a **non-empty** declared default. When in doubt
43
+ `writeSequenceEndKeep` is the safe choice: the failure directions are not
44
+ symmetric — a needless `endKeep` costs one non-canonical empty frame that a
45
+ decoder normalizes away, while a wrong `end` silently changes an array's
46
+ length. Code that transcodes or replays raw bytes (rather than encoding a
47
+ schema value) wants `writeSequenceEndKeep` throughout, so its output reproduces
48
+ its input frame for frame.
49
+
50
+ **Decoding is unaffected**, in both directions: an empty frame remains valid
51
+ input that the message layer normalizes to the default, and an omitted
52
+ sequence field was already reconstructed from the schema default. Old and new
53
+ encoders therefore interoperate; they disagree only about which encoding is
54
+ canonical. Every non-sequence byte is unchanged — the shared
55
+ `assets/test_vectors.json` is re-synced and every `serialized` hex is
56
+ byte-identical; the vectors' separate `serialized_sparse` column is the new
57
+ canonical form, and is exercised by the generator's conformance drivers (a
58
+ corelib has no message layer and cannot produce it).
59
+
60
+ The hold-back run is bounded only by `MAX_DEPTH`: this port can allocate, so
61
+ it holds back to the full nesting depth and is canonical at every depth
62
+ (CORELIB_PLAN §6, "How deep the hold-back reaches"). Held-back ids are encoder
63
+ state and never buffer content, so a flush cannot split a run and a buffer
64
+ smaller than the message still produces the one-shot bytes.
65
+
13
66
  - **Strict UTF-8 for `string` fields (corelib-ts#85, MESSAGE_SPEC §8,
14
67
  CORELIB_PLAN §6.4).** JavaScript strings are a Unicode string type, so the
15
68
  corelib transcodes `string` payloads at the boundary and is now **always
package/README.md CHANGED
@@ -104,6 +104,61 @@ for (let i = 0; i < 1000; i++) os.writeUnsigned(i, BigInt(i));
104
104
  os.flush(); // push the tail
105
105
  ```
106
106
 
107
+ ### Nested sequences
108
+
109
+ A nested message is a *sequence*: a fresh id scope between a begin header and the
110
+ `0x07` end marker. MESSAGE_SPEC §2 omits a sequence-typed **field** whose value
111
+ equals its declared default, so the encoder holds the begin header back until the
112
+ sequence proves it has content — no buffering of the sub-message, and nothing to
113
+ compare byte images against:
114
+
115
+ ```ts
116
+ const os = new OStream();
117
+ os.writeUnsigned(1, 42);
118
+ os.writeSequenceBeginLazy(2); // a nested field...
119
+ os.writeSequenceEnd(); // ...that got no content: header and end both vanish
120
+ os.writeSequenceBeginLazy(3);
121
+ os.writeString(1, "hi"); // content — commits the held-back header first
122
+ os.writeSequenceEnd();
123
+ os.bytes(); // 08 2a 1e 0a 12 68 69 07 (field 2 is not on the wire)
124
+ ```
125
+
126
+ Which closer to use is decided **statically**, by the position in the schema, not
127
+ by the value:
128
+
129
+ | position | closer |
130
+ |---|---|
131
+ | `struct` / `union` field, array-field wrapper | `writeSequenceEnd()` — drops a contentless frame |
132
+ | wrapper-array **element**, or an array field differing from a non-empty declared default | `writeSequenceEndKeep()` — always emits `begin` + `end` |
133
+
134
+ An element keeps its frame because element presence is what carries a dynamic
135
+ array's length (highest present id + 1, §5.1); dropping an all-default element
136
+ would change the decoded length, not just the bytes. The failure directions are
137
+ not symmetric, so `writeSequenceEndKeep()` is the safe choice when in doubt: a
138
+ needless one costs a non-canonical empty frame that a decoder normalizes away,
139
+ while a wrong `writeSequenceEnd()` shortens an array. Raw transcoding — replaying
140
+ bytes rather than encoding a schema value — should use `writeSequenceEndKeep()`
141
+ throughout, so the output reproduces the input frame for frame.
142
+
143
+ ```ts
144
+ const os = new OStream();
145
+ os.writeSequenceBeginLazy(4); // the wrapper array
146
+ os.writeSequenceBeginLazy(0); // element 0 — has content
147
+ os.writeUnsigned(0, 7);
148
+ os.writeSequenceEndKeep();
149
+ os.writeSequenceBeginLazy(1); // element 1 — all-default, but still present
150
+ os.writeSequenceEndKeep(); // ...so its frame stays: the array has length 2
151
+ os.writeSequenceEnd();
152
+ os.bytes(); // 26 06 00 07 07 0e 07 07
153
+ ```
154
+
155
+ Decoding is unaffected by the distinction: an empty frame is valid input that the
156
+ message layer normalizes to the default, and an absent sequence field is
157
+ reconstructed from the schema default. Nesting is capped at `MAX_DEPTH` (255) on
158
+ both sides; the encoder holds headers back to that full depth, so its output is
159
+ canonical however deep a message nests. A held-back header is encoder state, never
160
+ buffer content, so streaming through a small buffer produces the same bytes.
161
+
107
162
  ### Deserialize
108
163
 
109
164
  `decode()` walks a whole buffer and calls one optional `Visitor` method per field;
package/dist/index.cjs CHANGED
@@ -97,8 +97,6 @@ var DecodeStatus = {
97
97
  var SofabErrorCode = {
98
98
  /** A caller argument was invalid (e.g. id out of range, empty array). */
99
99
  Argument: "ARGUMENT",
100
- /** The API was used incorrectly (e.g. unbalanced sequence end). */
101
- Usage: "USAGE",
102
100
  /** The output buffer is full and no flush sink was provided. */
103
101
  BufferFull: "BUFFER_FULL",
104
102
  /** The input being decoded is malformed regardless of what follows (`INVALID`). */
@@ -133,9 +131,6 @@ var SofabError = class _SofabError extends Error {
133
131
  function argumentError(message) {
134
132
  return new SofabError(SofabErrorCode.Argument, message);
135
133
  }
136
- function usageError(message) {
137
- return new SofabError(SofabErrorCode.Usage, message);
138
- }
139
134
  function bufferFullError(message) {
140
135
  return new SofabError(SofabErrorCode.BufferFull, message);
141
136
  }
@@ -449,6 +444,26 @@ var SIGNED_FAST_MAX2 = 4503599627370496;
449
444
  var OStream = class {
450
445
  constructor(buffer, offset = 0, flush) {
451
446
  this.depth = 0;
447
+ /**
448
+ * Ids of the innermost open sequences whose header has not been written yet
449
+ * (MESSAGE_SPEC §2 lazy framing, {@link OStream.writeSequenceBeginLazy}).
450
+ * Always a contiguous suffix of the open sequences: writing any field commits
451
+ * the whole run at once, so {@link OStream.writeSequenceEnd} can drop the
452
+ * innermost one by dropping the last entry. Held-back ids are encoder state,
453
+ * never buffer content, so a flush can never split a run.
454
+ *
455
+ * Storage plus an explicit count rather than `push`/`pop`, so the slots are
456
+ * reused across messages (no allocation on a pooled encoder) and so
457
+ * {@link OStream.commitPending} can zero the count *before* it writes.
458
+ *
459
+ * The array grows on demand and is bounded only by `MAX_DEPTH` — there is no
460
+ * fixed hold-back window and hence no eager-framing fallback, which is what
461
+ * CORELIB_PLAN §6 ("How deep the hold-back reaches") demands of an
462
+ * implementation that can allocate: canonical output at *every* depth.
463
+ */
464
+ this.pending = [];
465
+ /** Valid entries in {@link OStream.pending}. */
466
+ this.nPending = 0;
452
467
  this.kernel = getKernel();
453
468
  if (buffer === void 0) {
454
469
  this.buf = new Uint8Array(DEFAULT_CAPACITY);
@@ -510,6 +525,7 @@ var OStream = class {
510
525
  reset() {
511
526
  this.pos = this.start;
512
527
  this.depth = 0;
528
+ this.nPending = 0;
513
529
  }
514
530
  // --- scalars ------------------------------------------------------------
515
531
  /** Write an unsigned integer field. */
@@ -660,6 +676,23 @@ var OStream = class {
660
676
  }
661
677
  }
662
678
  }
679
+ /**
680
+ * Write an fp32 array from its raw little-endian element payload. The bytes
681
+ * are emitted verbatim — no per-element `setFloat32` — so a signaling NaN
682
+ * survives bit-for-bit (§4.6), which {@link writeFp32Array} cannot guarantee
683
+ * because it re-quantizes each JS `number`. `payload.length` must be a
684
+ * multiple of 4; the element count is `payload.length / 4`.
685
+ */
686
+ writeFp32ArrayRaw(id, payload) {
687
+ if ((payload.length & 3) !== 0) {
688
+ throw argumentError(
689
+ `fp32 array payload length ${payload.length} is not a multiple of 4`
690
+ );
691
+ }
692
+ this.arrayHead(id, WireType.ArrayFixlen, payload.length >> 2);
693
+ this.putVarintNum(4 * 8 + FixlenSubtype.Fp32);
694
+ this.writeRaw(payload);
695
+ }
663
696
  /** Write an array of IEEE-754 64-bit doubles. */
664
697
  writeFp64Array(id, values) {
665
698
  this.arrayHead(id, WireType.ArrayFixlen, values.length);
@@ -675,20 +708,82 @@ var OStream = class {
675
708
  }
676
709
  }
677
710
  // --- sequences ----------------------------------------------------------
678
- /** Open a nested sequence (a fresh id scope). */
679
- writeSequenceBegin(id) {
711
+ /**
712
+ * Open a nested sequence (a fresh id scope) whose header is **held back**
713
+ * until the sequence turns out to have content.
714
+ *
715
+ * MESSAGE_SPEC §2 omits a sequence-typed field whose value equals its declared
716
+ * default, and "not one child was written" is exactly that condition —
717
+ * evaluated per child field, recursively, for free, because the message layer
718
+ * already omits every child equal to its default. A sequence closed with
719
+ * nothing in it therefore emits **nothing** instead of a two-byte empty frame,
720
+ * and an all-default message becomes the empty byte string. No byte image is
721
+ * ever compared, so in-memory layout never enters the decision.
722
+ *
723
+ * This is the only way to open a sequence. How it closes decides whether a
724
+ * contentless one survives: {@link OStream.writeSequenceEnd} drops it,
725
+ * {@link OStream.writeSequenceEndKeep} forces the frame out.
726
+ */
727
+ writeSequenceBeginLazy(id) {
680
728
  if (this.depth >= MAX_DEPTH) {
681
- throw usageError(`nesting exceeds MAX_DEPTH (${MAX_DEPTH})`);
729
+ throw argumentError(`nesting exceeds MAX_DEPTH (${MAX_DEPTH})`);
682
730
  }
683
- this.header(id, WireType.SequenceStart);
731
+ if (id < 0 || id > ID_MAX || !Number.isInteger(id)) {
732
+ throw argumentError(`field id ${id} out of range 0..${ID_MAX}`);
733
+ }
734
+ this.pending[this.nPending++] = id;
684
735
  this.depth++;
685
736
  }
686
- /** Close the current sequence. */
737
+ /**
738
+ * Close the current sequence, letting it **vanish** if it received no content.
739
+ *
740
+ * Use it wherever absence encodes the same value as an empty frame: a
741
+ * `struct`/`union` field, and an array field whose declared `default` is the
742
+ * empty collection (MESSAGE_SPEC §2). Where the frame must be visible, close
743
+ * with {@link OStream.writeSequenceEndKeep} instead.
744
+ *
745
+ * An end with no matching begin is not rejected: the encoder writes what it is
746
+ * told, and the resulting bytes are then malformed, which is the decoder's
747
+ * verdict to make. No other port refuses it. The depth counter stops at zero
748
+ * so the MAX_DEPTH check on begin cannot be fooled by an underflow.
749
+ */
687
750
  writeSequenceEnd() {
688
- if (this.depth <= 0) throw usageError("sequence end without matching begin");
751
+ if (this.nPending !== 0) {
752
+ this.nPending--;
753
+ if (this.depth > 0) this.depth--;
754
+ return;
755
+ }
689
756
  this.ensure(1);
690
757
  this.buf[this.pos++] = WireType.SequenceEnd;
691
- this.depth--;
758
+ if (this.depth > 0) this.depth--;
759
+ }
760
+ /**
761
+ * Close the current sequence, **keeping** its frame even when it received no
762
+ * content.
763
+ *
764
+ * Behaves like a write: it first emits any held-back headers — this frame's
765
+ * and every enclosing one's — and then the end marker, so an empty sequence
766
+ * reaches the wire as `begin` + `end`.
767
+ *
768
+ * Required wherever the frame carries information beyond its contents:
769
+ * - a **wrapper-array element** (`struct`/`union`/nested row): element
770
+ * presence is what carries a dynamic array's length — *highest present id +
771
+ * 1* (MESSAGE_SPEC §5.1) — so dropping an all-default element would change
772
+ * the decoded length, not just the bytes;
773
+ * - an array field already known to **differ from a non-empty declared
774
+ * `default`**: absence would reconstruct that default, so the empty frame is
775
+ * the only encoding of "explicitly empty" (§2, §3).
776
+ *
777
+ * The two failure directions are not symmetric, which is why this is the safe
778
+ * choice when in doubt: using it where {@link OStream.writeSequenceEnd} would
779
+ * do costs one non-canonical empty frame that a decoder normalizes away, while
780
+ * the reverse silently changes an array's length.
781
+ */
782
+ writeSequenceEndKeep() {
783
+ if (this.nPending !== 0) this.commitPending();
784
+ this.ensure(1);
785
+ this.buf[this.pos++] = WireType.SequenceEnd;
786
+ if (this.depth > 0) this.depth--;
692
787
  }
693
788
  // --- internals ----------------------------------------------------------
694
789
  /** Ensure exactly `value`'s varint size, then write it (bigint path). */
@@ -701,12 +796,44 @@ var OStream = class {
701
796
  this.ensure(varintSizeNum(value));
702
797
  this.pos = encodeVarintNum(value, this.buf, this.pos);
703
798
  }
799
+ /**
800
+ * Write a field header, the `(id << 3) | wireType` tag, as a varint.
801
+ *
802
+ * This is the single choke point every field write passes through — the
803
+ * scalar, fixlen, float, string, blob and both array writers all reach the
804
+ * wire through `header` / `fixlenHead` / `arrayHead`, and `fixlenHead` and
805
+ * `arrayHead` are themselves nothing but `header` plus a follow-up varint. So
806
+ * this is also where a held-back sequence run is committed: the field about to
807
+ * be written is content, which means every enclosing sequence is non-default
808
+ * and must be framed after all (MESSAGE_SPEC §2).
809
+ *
810
+ * The only writers that do *not* pass through here are the two sequence
811
+ * closers, which must not commit ({@link OStream.writeSequenceEnd}) or commit
812
+ * explicitly ({@link OStream.writeSequenceEndKeep}), and
813
+ * {@link OStream.writeSequenceBeginLazy}, which writes no byte at all.
814
+ */
704
815
  header(id, type) {
705
816
  if (id < 0 || id > ID_MAX || !Number.isInteger(id)) {
706
817
  throw argumentError(`field id ${id} out of range 0..${ID_MAX}`);
707
818
  }
819
+ if (this.nPending !== 0) this.commitPending();
708
820
  this.putVarintNum(id * 8 + type);
709
821
  }
822
+ /**
823
+ * Write out the held-back sequence headers, **outermost first**, and clear the
824
+ * run. Runs at most once per non-default sequence, never per field — the cost
825
+ * on the hot path is the single `nPending` test in {@link header}.
826
+ *
827
+ * The count is zeroed before the first byte goes out, so a write re-entered
828
+ * from a flush sink cannot emit the same run twice.
829
+ */
830
+ commitPending() {
831
+ const n = this.nPending;
832
+ this.nPending = 0;
833
+ for (let i = 0; i < n; i++) {
834
+ this.putVarintNum(this.pending[i] * 8 + WireType.SequenceStart);
835
+ }
836
+ }
710
837
  fixlenHead(id, length, subtype) {
711
838
  this.header(id, WireType.Fixlen);
712
839
  this.putVarintNum(length * 8 + subtype);
@@ -826,9 +953,14 @@ var FastDecoder = class {
826
953
  if (sub === FixlenSubtype.Fp32 || sub === FixlenSubtype.Fp64) {
827
954
  const want = sub === FixlenSubtype.Fp32 ? 4 : 8;
828
955
  if (len !== want) throw invalidMsgError("fixlen float length mismatch");
829
- const value = sub === FixlenSubtype.Fp32 ? this.readFp32() : this.readFp64();
830
- if (sub === FixlenSubtype.Fp32) top.fp32?.(id, value);
831
- else top.fp64?.(id, value);
956
+ if (sub === FixlenSubtype.Fp32) {
957
+ const p = this.p;
958
+ const value = this.readFp32();
959
+ top.fp32?.(id, value, top.fp32Raw ? this.buf.subarray(p, p + 4) : void 0);
960
+ } else {
961
+ const value = this.readFp64();
962
+ top.fp64?.(id, value);
963
+ }
832
964
  } else {
833
965
  const chunk = this.take(len);
834
966
  if (sub === FixlenSubtype.String) top.string?.(id, len, 0, chunk);
@@ -867,9 +999,11 @@ var FastDecoder = class {
867
999
  else throw invalidMsgError("invalid fixlen array element type");
868
1000
  top.arrayBegin?.(id, kind, count);
869
1001
  if (kind === ArrayKind.Fp32) {
1002
+ const wantRaw = top.fp32Raw === true;
870
1003
  for (let i = 0; i < count; i++) {
1004
+ const p = this.p;
871
1005
  const value = this.readFp32();
872
- top.arrayFp32?.(id, i, value);
1006
+ top.arrayFp32?.(id, i, value, wantRaw ? this.buf.subarray(p, p + 4) : void 0);
873
1007
  }
874
1008
  } else {
875
1009
  for (let i = 0; i < count; i++) {
@@ -1130,8 +1264,10 @@ var DecoderState = class {
1130
1264
  i = this.fpStep(input, i);
1131
1265
  if (this.have < this.need) return;
1132
1266
  const value = this.fixSub === FixlenSubtype.Fp32 ? unpackFp32(this.scratch, 0) : unpackFp64(this.scratch, 0);
1133
- if (this.fixSub === FixlenSubtype.Fp32) this.top().fp32?.(this.id, value);
1134
- else this.top().fp64?.(this.id, value);
1267
+ if (this.fixSub === FixlenSubtype.Fp32) {
1268
+ const top = this.top();
1269
+ top.fp32?.(this.id, value, top.fp32Raw ? this.scratch.subarray(0, 4) : void 0);
1270
+ } else this.top().fp64?.(this.id, value);
1135
1271
  this.state = 0 /* Header */;
1136
1272
  break;
1137
1273
  }
@@ -1213,8 +1349,10 @@ var DecoderState = class {
1213
1349
  i = this.fpStep(input, i);
1214
1350
  if (this.have < this.need) return;
1215
1351
  const value = this.arrKind === ArrayKind.Fp32 ? unpackFp32(this.scratch, 0) : unpackFp64(this.scratch, 0);
1216
- if (this.arrKind === ArrayKind.Fp32) this.top().arrayFp32?.(this.id, this.arrIndex, value);
1217
- else this.top().arrayFp64?.(this.id, this.arrIndex, value);
1352
+ if (this.arrKind === ArrayKind.Fp32) {
1353
+ const top = this.top();
1354
+ top.arrayFp32?.(this.id, this.arrIndex, value, top.fp32Raw ? this.scratch.subarray(0, 4) : void 0);
1355
+ } else this.top().arrayFp64?.(this.id, this.arrIndex, value);
1218
1356
  this.have = 0;
1219
1357
  this.advanceArray();
1220
1358
  break;
@@ -1509,7 +1647,12 @@ var Cursor = class {
1509
1647
  }
1510
1648
  const id = this.upper();
1511
1649
  if (id > ID_MAX) throw invalidMsgError(`field id ${id} out of range`);
1512
- if (wire === WireType.SequenceStart) this.depth++;
1650
+ if (wire === WireType.SequenceStart) {
1651
+ if (this.depth >= MAX_DEPTH) {
1652
+ throw invalidMsgError(`nesting exceeds MAX_DEPTH (${MAX_DEPTH})`);
1653
+ }
1654
+ this.depth++;
1655
+ }
1513
1656
  this.id = id;
1514
1657
  this.wire = wire;
1515
1658
  this.fixSub = this.peekFixSub(wire);
@@ -1530,14 +1673,40 @@ var Cursor = class {
1530
1673
  this.fixlenHeader(FixlenSubtype.Fp32, 4);
1531
1674
  return this.rawFp32();
1532
1675
  }
1676
+ /**
1677
+ * Read a 32-bit float scalar as its raw 4 wire bytes (little-endian), zero-copy
1678
+ * — the bit-preserving companion to {@link readFp32}.
1679
+ *
1680
+ * {@link readFp32} returns a JS `number` (a 64-bit double), and widening an
1681
+ * fp32 *signaling* NaN into a double quiets it (0x7F800001 → 0x7FC00001), so a
1682
+ * value consumer can never round-trip one bit-for-bit (§4.6). Generated
1683
+ * bit-exact decode reads the bytes here instead and re-emits them verbatim with
1684
+ * {@link OStream.writeFixlen} (subtype fp32) — mirroring the visitor `raw`
1685
+ * channel on the push paths (fast.ts / state.ts), which the pull path was
1686
+ * missing (corelib-ts#66).
1687
+ *
1688
+ * The header (subtype fp32, length 4) is validated exactly as in
1689
+ * {@link readFp32}; the returned view aliases the source buffer, valid only
1690
+ * until it is reused, like {@link readBlob}.
1691
+ */
1692
+ readFp32Raw() {
1693
+ this.fixlenHeader(FixlenSubtype.Fp32, 4);
1694
+ return this.take(4);
1695
+ }
1533
1696
  /** Read a 64-bit float scalar (wire {@link WireType.Fixlen}, subtype fp64). */
1534
1697
  readFp64() {
1535
1698
  this.fixlenHeader(FixlenSubtype.Fp64, 8);
1536
1699
  return this.rawFp64();
1537
1700
  }
1538
- /** Read a UTF-8 string scalar (wire {@link WireType.Fixlen}, subtype string). */
1539
- readString() {
1540
- const len = this.fixlenLen(FixlenSubtype.String);
1701
+ /**
1702
+ * Read a UTF-8 string scalar (wire {@link WireType.Fixlen}, subtype string).
1703
+ * Pass the schema `maxlen` (byte length) for a bounded string so an
1704
+ * over-length is rejected as `INVALID` at the header, before the payload is
1705
+ * taken (see {@link fixlenLen}); the wire length is exactly the UTF-8 byte
1706
+ * length, so the check is exact. Omit for an unbounded string.
1707
+ */
1708
+ readString(schemaMaxlen) {
1709
+ const len = this.fixlenLen(FixlenSubtype.String, schemaMaxlen);
1541
1710
  const bytes = this.take(len);
1542
1711
  try {
1543
1712
  return _utf8.decode(bytes);
@@ -1549,13 +1718,18 @@ var Cursor = class {
1549
1718
  * Read a blob scalar (wire {@link WireType.Fixlen}, subtype blob) as a
1550
1719
  * zero-copy {@link Uint8Array} view into the source buffer.
1551
1720
  */
1552
- readBlob() {
1553
- const len = this.fixlenLen(FixlenSubtype.Blob);
1721
+ readBlob(schemaMaxlen) {
1722
+ const len = this.fixlenLen(FixlenSubtype.Blob, schemaMaxlen);
1554
1723
  return this.take(len);
1555
1724
  }
1556
- /** Read an unsigned array (wire {@link WireType.ArrayUnsigned}), number-first per element. */
1557
- readUnsignedArray() {
1558
- const count = this.arrayCount();
1725
+ /**
1726
+ * Read an unsigned array (wire {@link WireType.ArrayUnsigned}), number-first
1727
+ * per element. Pass the schema `count` for a bounded array so an over-count is
1728
+ * rejected as `INVALID` at the header (see {@link arrayCount}); omit it for an
1729
+ * unbounded array (today's behavior).
1730
+ */
1731
+ readUnsignedArray(schemaCount) {
1732
+ const count = this.arrayCount(schemaCount);
1559
1733
  const out = new Array(count);
1560
1734
  for (let i = 0; i < count; i++) {
1561
1735
  this.readVarint();
@@ -1564,8 +1738,8 @@ var Cursor = class {
1564
1738
  return out;
1565
1739
  }
1566
1740
  /** Read a signed array (wire {@link WireType.ArraySigned}), zig-zag, number-first per element. */
1567
- readSignedArray() {
1568
- const count = this.arrayCount();
1741
+ readSignedArray(schemaCount) {
1742
+ const count = this.arrayCount(schemaCount);
1569
1743
  const out = new Array(count);
1570
1744
  for (let i = 0; i < count; i++) {
1571
1745
  this.readVarint();
@@ -1578,8 +1752,8 @@ var Cursor = class {
1578
1752
  * Each element keeps the raw lo/hi halves; call {@link Long.toBigInt} to
1579
1753
  * materialise only the values the caller actually needs.
1580
1754
  */
1581
- readUnsignedArrayLong() {
1582
- const count = this.arrayCount();
1755
+ readUnsignedArrayLong(schemaCount) {
1756
+ const count = this.arrayCount(schemaCount);
1583
1757
  const out = new Array(count);
1584
1758
  for (let i = 0; i < count; i++) {
1585
1759
  this.readVarint();
@@ -1588,8 +1762,8 @@ var Cursor = class {
1588
1762
  return out;
1589
1763
  }
1590
1764
  /** Read a signed 64-bit array (zig-zag) into {@link Long}[] — the `bigint`-free path. */
1591
- readSignedArrayLong() {
1592
- const count = this.arrayCount();
1765
+ readSignedArrayLong(schemaCount) {
1766
+ const count = this.arrayCount(schemaCount);
1593
1767
  const out = new Array(count);
1594
1768
  for (let i = 0; i < count; i++) {
1595
1769
  this.readVarint();
@@ -1601,15 +1775,29 @@ var Cursor = class {
1601
1775
  return out;
1602
1776
  }
1603
1777
  /** Read an fp32 array (wire {@link WireType.ArrayFixlen}, element subtype fp32). */
1604
- readFp32Array() {
1605
- const count = this.arrayFixlenHeader(FixlenSubtype.Fp32, 4);
1778
+ readFp32Array(schemaCount) {
1779
+ const count = this.arrayFixlenHeader(FixlenSubtype.Fp32, 4, schemaCount);
1606
1780
  const out = new Array(count);
1607
1781
  for (let i = 0; i < count; i++) out[i] = this.rawFp32();
1608
1782
  return out;
1609
1783
  }
1784
+ /**
1785
+ * Read an fp32 array as its raw little-endian element payload (`count * 4`
1786
+ * bytes), zero-copy — the bit-preserving companion to {@link readFp32Array}.
1787
+ * Widening each element to a JS `number` quiets an fp32 *signaling* NaN just as
1788
+ * on the scalar path (§4.6; see {@link readFp32Raw}), so bit-exact decode reads
1789
+ * the whole payload here and re-emits it with {@link OStream.writeFp32ArrayRaw}
1790
+ * (corelib-ts#66). The header (element subtype fp32, size 4) is validated
1791
+ * exactly as in {@link readFp32Array}; the returned view aliases the source
1792
+ * buffer, like {@link readBlob}.
1793
+ */
1794
+ readFp32ArrayRaw(schemaCount) {
1795
+ const count = this.arrayFixlenHeader(FixlenSubtype.Fp32, 4, schemaCount);
1796
+ return this.take(count * 4);
1797
+ }
1610
1798
  /** Read an fp64 array (wire {@link WireType.ArrayFixlen}, element subtype fp64). */
1611
- readFp64Array() {
1612
- const count = this.arrayFixlenHeader(FixlenSubtype.Fp64, 8);
1799
+ readFp64Array(schemaCount) {
1800
+ const count = this.arrayFixlenHeader(FixlenSubtype.Fp64, 8, schemaCount);
1613
1801
  const out = new Array(count);
1614
1802
  for (let i = 0; i < count; i++) out[i] = this.rawFp64();
1615
1803
  return out;
@@ -1689,8 +1877,14 @@ var Cursor = class {
1689
1877
  }
1690
1878
  const id = this.upper();
1691
1879
  if (id > ID_MAX) throw invalidMsgError(`field id ${id} out of range`);
1692
- if (wire === WireType.SequenceStart) depth++;
1693
- else this.skipValue(wire);
1880
+ if (wire === WireType.SequenceStart) {
1881
+ if (this.depth + depth - 1 >= MAX_DEPTH) {
1882
+ throw invalidMsgError(`nesting exceeds MAX_DEPTH (${MAX_DEPTH})`);
1883
+ }
1884
+ depth++;
1885
+ } else {
1886
+ this.skipValue(wire);
1887
+ }
1694
1888
  }
1695
1889
  }
1696
1890
  // --- field helpers ------------------------------------------------------
@@ -1717,11 +1911,18 @@ var Cursor = class {
1717
1911
  }
1718
1912
  return -1;
1719
1913
  }
1720
- /** Read and validate an array count word (0..ARRAY_MAX; §4.7/§4.8). */
1721
- arrayCount() {
1914
+ /**
1915
+ * Read and validate an array count word (0..ARRAY_MAX; §4.7/§4.8). When a
1916
+ * `schemaCount` is given, a count above it is a schema-bound violation and is
1917
+ * rejected as `INVALID` — see the check below.
1918
+ */
1919
+ arrayCount(schemaCount) {
1722
1920
  this.readVarint();
1723
1921
  const count = this.num();
1724
1922
  if (count > ARRAY_MAX) throw invalidMsgError("array count out of range");
1923
+ if (schemaCount !== void 0 && count > schemaCount) {
1924
+ throw invalidMsgError("array count above schema capacity");
1925
+ }
1725
1926
  if (count > this.maxArrayCount) {
1726
1927
  throw limitExceededError(
1727
1928
  `array count ${count} exceeds maxArrayCount ${this.maxArrayCount}`
@@ -1738,13 +1939,20 @@ var Cursor = class {
1738
1939
  if (sub !== wantSub) throw invalidMsgError(`invalid fixlen subtype ${sub}`);
1739
1940
  if (len !== wantLen) throw invalidMsgError("fixlen float length mismatch");
1740
1941
  }
1741
- /** Read a scalar fixlen sub-header for a string/blob, asserting subtype; returns byte length. */
1742
- fixlenLen(wantSub) {
1942
+ /**
1943
+ * Read a scalar fixlen sub-header for a string/blob, asserting subtype;
1944
+ * returns the byte length. When a `schemaMaxlen` is given, a length above it
1945
+ * is a schema-bound violation and is rejected as `INVALID` — see below.
1946
+ */
1947
+ fixlenLen(wantSub, schemaMaxlen) {
1743
1948
  this.readVarint();
1744
1949
  const sub = this.lo & 7;
1745
1950
  const len = this.upper();
1746
1951
  if (sub !== wantSub) throw invalidMsgError(`invalid fixlen subtype ${sub}`);
1747
1952
  if (len > FIXLEN_MAX) throw invalidMsgError("fixlen length out of range");
1953
+ if (schemaMaxlen !== void 0 && len > schemaMaxlen) {
1954
+ throw invalidMsgError("fixlen length above schema maxlen");
1955
+ }
1748
1956
  const limit = wantSub === FixlenSubtype.String ? this.maxStringLen : this.maxBlobLen;
1749
1957
  if (len > limit) {
1750
1958
  const what = wantSub === FixlenSubtype.String ? "string" : "blob";
@@ -1755,11 +1963,18 @@ var Cursor = class {
1755
1963
  }
1756
1964
  return len;
1757
1965
  }
1758
- /** Read an array fixlen element header (count + element type); returns the count. */
1759
- arrayFixlenHeader(wantSub, wantSize) {
1966
+ /**
1967
+ * Read an array fixlen element header (count + element type); returns the
1968
+ * count. When a `schemaCount` is given, a count above it is a schema-bound
1969
+ * violation and is rejected as `INVALID` — see below.
1970
+ */
1971
+ arrayFixlenHeader(wantSub, wantSize, schemaCount) {
1760
1972
  this.readVarint();
1761
1973
  const count = this.num();
1762
1974
  if (count > ARRAY_MAX) throw invalidMsgError("array count out of range");
1975
+ if (schemaCount !== void 0 && count > schemaCount) {
1976
+ throw invalidMsgError("array count above schema capacity");
1977
+ }
1763
1978
  if (count > this.maxArrayCount) {
1764
1979
  throw limitExceededError(
1765
1980
  `array count ${count} exceeds maxArrayCount ${this.maxArrayCount}`