@zakkster/lite-bake-stream 1.4.1 → 1.5.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
@@ -2,6 +2,68 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
4
4
 
5
+ ## [1.5.0] -- 2026-09-02
6
+
7
+ M5 -- every ceiling gets a door; the shard budget counts all the bytes it ships; the floors are measured. BS-26 closed; BS-27's floor items closed (the writer-holds-all-shards ~2x container peak moves to M6's streaming emission); BS-32/BS-33/BS-34 closed. Suite: 461 -> 479 tests / 479 pass / 0 fail / 0 todo. Torture: 44/44 fast (~322 ms tier sum), 47/47 full (~5.6 s) incl. a 2100-shard directory-scale scenario (SPEC checker green, RangeReader LRU cap held at 8, `_maybeEvict` allocation-flat over 20000 ops); arrayBuffers growth 0.00 MB both tiers; t7 soak tracker size 0. Inventory gate: 54 -> 57 thrown codes / 0 unpinned. Falsifiability, measured item-by-item with baseline src stashed: all 15 new door/behavior assertions fail on baseline and pass on this tree, every stay-green net holds on both, and an all-F64-lane container is byte-identical to the 1.4.1 output (same sha256 and length). BREAK matrix {1, t6, l, m, j} all exit non-zero.
8
+
9
+ ### Added
10
+
11
+ - **Serialization ceilings (BS-26).** `StringTable` now fails closed with a new
12
+ `StringTableError` code `ST_BLOB_OVERFLOW` when a per-shard string table would
13
+ cross the u32 `blob_length` (4294967295) or `entry_count` (4294967294) ceiling,
14
+ instead of wrapping silently on `setUint32` and minting a corrupt-but-in-bounds
15
+ container. The doubling paths clamp their allocation at the ceiling so every
16
+ crossing write re-enters the guard, making placement provably complete.
17
+ `StringTableError` is re-exported from `index.js` and declared in the types.
18
+ `PreserveWriter.onRecord` raises the same `ST_BLOB_OVERFLOW` (shared code)
19
+ before a shard's `payload_len` (blob + trailing offset table) would exceed u32.
20
+ A test-only `__setStringTableLimits({blobBytes, entryCount})` seam (no semver
21
+ guarantee, absent from the public API/types/docs) lowers the ceilings for a
22
+ cheap gated crossing on the real grow path.
23
+ - **Row-index doors (BS-32).** `Reader.get`, `MultiReader.get`, `RangeReader.get`
24
+ and `PreserveReader.getBytes/getString/getJSON` now reject a negative,
25
+ fractional, `NaN`, or out-of-range `rowIdx` with `R_ROW_OUT_OF_RANGE`
26
+ (`M_ROW_OUT_OF_RANGE` for `MultiReader`) rather than returning `undefined` or a
27
+ garbage subarray from a fractional byte offset. `Reader.get` uses a `>>>0`
28
+ compare; the others use `Number.isInteger` (they legitimately address up to
29
+ 2^53). The SHARD-index escape hatches keep their unchecked raw-access contract.
30
+ - **Safe-integer offset guard (BS-26).** `Reader`, `RangeReader` and
31
+ `PreserveReader` narrow every u64 header/schema/directory offset through a cold
32
+ `u64()` helper that throws `R_OFFSET_TOO_LARGE` past 2^53-1, where a `Number()`
33
+ cast would lose precision and hand a corrupted offset to every bounds check.
34
+ - **`RangeReader` adapter validation (BS-34 + BS-33).** All seven `adapter.fetch` sites
35
+ funnel through `_fetchExact`, which rejects a non-`Uint8Array` or wrong-length
36
+ return with `R_ADAPTER_SHORT_READ`. `syncRange` over a not-prefetched shard now
37
+ throws its own `R_NOT_PREFETCHED`, distinct from the `R_TRUNCATED` truncation
38
+ door.
39
+
40
+ ### Changed
41
+
42
+ - **Shard-byte budget (D2).** The schema `Writer` now rolls a shard when its
43
+ payload plus string-table bytes reach `targetShardBytes`, not on the row
44
+ ceiling alone, so a string-heavy shard no longer overshoots the target. The
45
+ string-table byte length is tracked incrementally with zero allocation (one
46
+ compare per string value, updated only on a unique arrival) and is exact versus
47
+ `StringTable.serialize` at every finalize. The budget reads only
48
+ chunk-invariant state, so re-chunked input still yields byte-identical
49
+ containers (the t0 law), and a container whose schema has only F64 lanes keeps
50
+ a zero string budget and is **byte-identical** to the pre-D2 output.
51
+ - **Floors.** The schema `Writer` field-name lookup moves from an O(F) hash-scan
52
+ to an O(1) `Map` (byte-confirmed, so a real FNV collision such as
53
+ `gwzx`/`16cd` still resolves correctly): on an ad-hoc 64-/256-field
54
+ flat-record serialize microbench (`bench/bench-tokenizer.js` carries a fixed
55
+ 7-field fixture, so it cannot express this floor), throughput went from
56
+ 190.0 -> 216.7 MB/s at 64 fields and 113.3 -> 213.6 MB/s at 256 fields --
57
+ the O(F) per-key term no longer degrades with field count. qa's independent
58
+ microbench (different corpus) corroborates the shape: baseline fell
59
+ 128.0 -> 77.3 MB/s going 64 -> 256 fields; with the map it holds
60
+ 163.0 -> 167.9 MB/s. The sample-window key
61
+ decoder is now a shared module-scope `TextDecoder` instead of one per key.
62
+ `PreserveWriter` reuses its working buffers across shard rolls (they were
63
+ reallocated every roll) -- the reuse is byte-safe (every read is bounded by the
64
+ reset cursors) and holds the working store byte-flat under a steady-stream soak.
65
+ - `package.json` exports: all 12 entries (root + 11 subpaths) gain the `node` condition alongside `types`/`import`/`default`, matching the suite convention. Verified: 12/12 subpaths resolve via self-reference import, `require()` resolves through `node` on Node 26 (`require(esm)`), npm test 461/461, `npm pack --dry-run` unchanged at 32 files.
66
+
5
67
  ## [1.4.1] -- 2026-09-02
6
68
 
7
69
  M8 -- the four reserved torture cells become real; BS-30 and BS-31 close. No tier prints "reserved" any more. Suite: 461 tests / 461 pass / 0 fail / 0 todo (unchanged -- no named-suite edits). Torture: 44/44 fast (~1.3 s wall), 47/47 full, arrayBuffers growth 0. BREAK matrix: ten runs (`=1`, eight registry ids, one unknown id) all exit non-zero; the unknown id is refused at import. Inventory gate: 54 thrown codes / 0 unpinned. Falsifiability: with baseline src stashed, exactly one new assertion fails (the onProgress-identity pin), both tiers. Test-only except one hot-path reuse in `src/FileIngest.js` (below).
package/README.md CHANGED
@@ -13,7 +13,7 @@
13
13
 
14
14
  > Streaming byte-level JSON compiler for [`@zakkster/lite-bake`](https://github.com/PeshoVurtoleta/lite-bake). Zero-GC, tree-shakeable, gigabyte-scale.
15
15
 
16
- **Status:** v1.4.1. LBK1 format frozen at `format_version: 1`. Qualified on an 8 GB soak with zero GC and byte-exact preservation across 590 million cells.
16
+ **Status:** v1.5.0. LBK1 format frozen at `format_version: 1`. Qualified on an 8 GB soak with zero GC and byte-exact preservation across 590 million cells.
17
17
 
18
18
  ## Two modes, one API
19
19
 
package/llms.txt CHANGED
@@ -8,13 +8,13 @@ Ingest gigabyte-scale JSON (top-level array or NDJSON) into the `lite-bake` LBK1
8
8
 
9
9
  ## Status
10
10
 
11
- v1.4.1 — stable. LBK1 format frozen at `format_version: 1`. Qualified on an 8 GB soak (M1 MacBook Pro): 98.37M rows, 4.89 GB container, zero major GC, zero minor GC, 499 KB total heap allocation, 590.21M cells verified byte-exact, zero mismatches. Tokenizer benches at 222-237 MB/s (~55% of JSON.parse, with no object graph allocated).
11
+ v1.5.0 — stable. LBK1 format frozen at `format_version: 1`. Qualified on an 8 GB soak (M1 MacBook Pro): 98.37M rows, 4.89 GB container, zero major GC, zero minor GC, 499 KB total heap allocation, 590.21M cells verified byte-exact, zero mismatches. Tokenizer benches at 222-237 MB/s (~55% of JSON.parse, with no object graph allocated).
12
12
 
13
13
  Public API follows semver from 1.0.0. Future additions (I64 lane, columnar payload mode, container-level string table) land via the format's forward-compat seams -- `min_reader_version` on ShardEntry, reserved FieldDescriptor flags, the `metadata_off` block wrapper -- without a format_version bump.
14
14
 
15
15
  See SPEC.md for the LBK1 container format, section 3.6 for zone maps, section 4.3 for the reserved field flags.
16
16
 
17
- ## Public API (v1.4.1)
17
+ ## Public API (v1.5.0)
18
18
 
19
19
  Two ingest modes share one top-level API:
20
20
 
@@ -62,6 +62,17 @@ Error classes with stable `code`: `TokenizerError`, `WriterError`, `ReaderError`
62
62
 
63
63
  Every declared field of every row round-trips. F64 lanes: bit-exact for numeric literals with ≤15 significant digits and |exponent| ≤ 22 (Clinger's fast path); ≤1 ULP drift outside that domain (documented, pinned by NumericBoundary.test.js). U32 lanes: byte-exact UTF-8, unconditional. Missing fields → documented defaults. Unknown keys → silently dropped. Wrong-type value on post-freeze schema → W_LANE_MISMATCH error, no corrupt container produced. See SPEC section 7 for the full table. Asserted by tests, property-based fuzz, AND torture-scale verification (~73,500 rows per fast-tier run).
64
64
 
65
+ ## Row-index and refusal codes
66
+
67
+ - Random-access row surfaces fail closed on a bad `rowIdx` (negative, fractional, NaN, or >= totalRows): `Reader.get`, `RangeReader.get` and `PreserveReader.getBytes/getString/getJSON` throw `R_ROW_OUT_OF_RANGE`; `MultiReader.get` throws `M_ROW_OUT_OF_RANGE`. The SHARD-index escape hatches (`shardPayload`, `shardF64`, `shardStringTable`, `loadShard`) keep their unchecked raw-access contract; `shardBounds` returns null out-of-range.
68
+ - `R_OFFSET_TOO_LARGE`: a u64 header/schema/directory offset exceeds 2^53-1 (a Number() cast would lose precision). Raised by all three reader classes.
69
+ - `R_NOT_PREFETCHED`: `RangeReader.syncRange` over a shard `prefetchRange` has not cached (distinct from the `R_TRUNCATED` truncation door). `R_ADAPTER_SHORT_READ` also covers a non-Uint8Array or wrong-length adapter return.
70
+ - `ST_BLOB_OVERFLOW`: a per-shard string table would cross the u32 blob_length (4294967295) or entry_count (4294967294) ceiling; also raised by `PreserveWriter` before a shard payload_len would exceed u32. One shared code across `StringTable` and `PreserveWriter`. `StringTableError` is exported from the root and the `string-table` subpath.
71
+
72
+ ## Shard budget
73
+
74
+ Schema-mode shards roll when payload bytes plus string-table bytes reach `targetShardBytes`, not on the row ceiling alone. The string-table byte count is tracked with zero allocation and is chunk-invariant, so re-chunked input yields byte-identical containers and an all-F64 schema is byte-identical to the pre-budget output. `PreserveWriter` reuses its working buffers across shard rolls.
75
+
65
76
  ## Numbers
66
77
 
67
78
  F64 only. Values exceeding IEEE 754 double range are rejected as `E_NUMBER_OVERFLOW`. 64-bit integer IDs above 2^53 lose precision silently (documented in SPEC 5.4); v2 will introduce an opt-in bytes-preserved lane.
package/package.json CHANGED
@@ -1,67 +1,79 @@
1
1
  {
2
2
  "name": "@zakkster/lite-bake-stream",
3
- "version": "1.4.1",
3
+ "version": "1.5.0",
4
4
  "description": "Streaming byte-level JSON to lite-bake binary compiler. Zero-GC, tree-shakeable, gigabyte-scale.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
7
7
  "exports": {
8
8
  ".": {
9
9
  "types": "./types/index.d.ts",
10
+ "node": "./src/index.js",
10
11
  "import": "./src/index.js",
11
12
  "default": "./src/index.js"
12
13
  },
13
14
  "./tokenizer": {
14
15
  "types": "./types/Tokenizer.d.ts",
16
+ "node": "./src/Tokenizer.js",
15
17
  "import": "./src/Tokenizer.js",
16
18
  "default": "./src/Tokenizer.js"
17
19
  },
18
20
  "./writer": {
19
21
  "types": "./types/Writer.d.ts",
22
+ "node": "./src/Writer.js",
20
23
  "import": "./src/Writer.js",
21
24
  "default": "./src/Writer.js"
22
25
  },
23
26
  "./reader": {
24
27
  "types": "./types/Reader.d.ts",
28
+ "node": "./src/Reader.js",
25
29
  "import": "./src/Reader.js",
26
30
  "default": "./src/Reader.js"
27
31
  },
28
32
  "./string-table": {
29
33
  "types": "./types/StringTable.d.ts",
34
+ "node": "./src/StringTable.js",
30
35
  "import": "./src/StringTable.js",
31
36
  "default": "./src/StringTable.js"
32
37
  },
33
38
  "./file-ingest": {
34
39
  "types": "./types/FileIngest.d.ts",
40
+ "node": "./src/FileIngest.js",
35
41
  "import": "./src/FileIngest.js",
36
42
  "default": "./src/FileIngest.js"
37
43
  },
38
44
  "./range-reader": {
39
45
  "types": "./types/RangeReader.d.ts",
46
+ "node": "./src/RangeReader.js",
40
47
  "import": "./src/RangeReader.js",
41
48
  "default": "./src/RangeReader.js"
42
49
  },
43
50
  "./multi-reader": {
44
51
  "types": "./types/MultiReader.d.ts",
52
+ "node": "./src/MultiReader.js",
45
53
  "import": "./src/MultiReader.js",
46
54
  "default": "./src/MultiReader.js"
47
55
  },
48
56
  "./split": {
49
57
  "types": "./types/Split.d.ts",
58
+ "node": "./src/Split.js",
50
59
  "import": "./src/Split.js",
51
60
  "default": "./src/Split.js"
52
61
  },
53
62
  "./preserve-tokenizer": {
54
63
  "types": "./types/PreserveTokenizer.d.ts",
64
+ "node": "./src/PreserveTokenizer.js",
55
65
  "import": "./src/PreserveTokenizer.js",
56
66
  "default": "./src/PreserveTokenizer.js"
57
67
  },
58
68
  "./preserve-writer": {
59
69
  "types": "./types/PreserveWriter.d.ts",
70
+ "node": "./src/PreserveWriter.js",
60
71
  "import": "./src/PreserveWriter.js",
61
72
  "default": "./src/PreserveWriter.js"
62
73
  },
63
74
  "./preserve-reader": {
64
75
  "types": "./types/PreserveReader.d.ts",
76
+ "node": "./src/PreserveReader.js",
65
77
  "import": "./src/PreserveReader.js",
66
78
  "default": "./src/PreserveReader.js"
67
79
  }
package/src/FileIngest.js CHANGED
@@ -27,7 +27,7 @@ import { PreserveWriter } from './PreserveWriter.js';
27
27
  import { PreserveReader } from './PreserveReader.js';
28
28
  import { checkOpts } from './Opts.js';
29
29
 
30
- export const VERSION = '1.4.1';
30
+ export const VERSION = '1.5.0';
31
31
 
32
32
  const U32_MAX = 4294967295;
33
33
  const INGEST_OPTS = {
@@ -25,7 +25,7 @@
25
25
  // M_ROW_OUT_OF_RANGE - rowIdx >= totalRows
26
26
  // M_TOO_MANY_ROWS - cumulative row count exceeds Number.MAX_SAFE_INTEGER
27
27
 
28
- export const VERSION = '1.4.1';
28
+ export const VERSION = '1.5.0';
29
29
 
30
30
  export class MultiReaderError extends Error {
31
31
  constructor(code, msg) { super(msg); this.code = code; this.name = 'MultiReaderError'; }
@@ -100,7 +100,10 @@ export class MultiReader {
100
100
 
101
101
  // Which reader contains global row rowIdx? Returns { readerIdx, localRow }.
102
102
  _locateRow(rowIdx) {
103
- if (rowIdx < 0 || rowIdx >= this._totalRows) {
103
+ // BS-32: Number.isInteger (not >>>0) because MultiReader legitimately
104
+ // addresses up to 2^53 (see M_TOO_MANY_ROWS). Rejects fractional, NaN, and
105
+ // out-of-range in one guard; covers get() and readerForRow().
106
+ if (!Number.isInteger(rowIdx) || rowIdx < 0 || rowIdx >= this._totalRows) {
104
107
  throw new MultiReaderError('M_ROW_OUT_OF_RANGE',
105
108
  'rowIdx ' + rowIdx + ' out of range [0, ' + this._totalRows + ')');
106
109
  }
@@ -28,11 +28,12 @@
28
28
  // R_BAD_FOOTER - footer magic_end or footer_len is malformed
29
29
  // R_INVALID - a structure is internally inconsistent but in-bounds
30
30
  // R_SHARD_VERSION_TOO_NEW - a shard's min_reader_version > this reader
31
- // R_ROW_OUT_OF_RANGE - rowIdx >= totalRows
31
+ // R_ROW_OUT_OF_RANGE - rowIdx negative, fractional, NaN, or >= totalRows (BS-32)
32
+ // R_OFFSET_TOO_LARGE - a u64 header/directory offset exceeds 2^53-1
32
33
 
33
34
  import { toContainerBuffer } from './Views.js';
34
35
 
35
- export const VERSION = '1.4.1';
36
+ export const VERSION = '1.5.0';
36
37
 
37
38
  const CONTAINER_HEADER_BYTES = 48;
38
39
  const SHARD_ENTRY_BYTES = 40;
@@ -42,6 +43,17 @@ export class PreserveReaderError extends Error {
42
43
  constructor(code, msg) { super(msg); this.code = code; this.name = 'PreserveReaderError'; }
43
44
  }
44
45
 
46
+ // Read a u64 header/directory field and narrow it to a JS number, failing closed
47
+ // past Number.MAX_SAFE_INTEGER. Past 2^53-1 a Number() cast loses precision and
48
+ // every downstream bounds check reads a corrupted offset (BS-05). Cold: parse-only.
49
+ function u64(dv, off, what) {
50
+ const v = dv.getBigUint64(off, true);
51
+ if (v > 9007199254740991n)
52
+ throw new PreserveReaderError('R_OFFSET_TOO_LARGE',
53
+ what + ' value ' + v + ' exceeds the safe-integer ceiling 9007199254740991');
54
+ return Number(v);
55
+ }
56
+
45
57
  export class PreserveReader {
46
58
  static fromBuffer(input) {
47
59
  return new PreserveReader(toContainerBuffer(input, 'PreserveReader.fromBuffer'));
@@ -84,11 +96,11 @@ export class PreserveReader {
84
96
  if (reserved1 !== 0)
85
97
  throw new PreserveReaderError('R_RESERVED_NONZERO', 'header reserved1 at offset 36 must be 0, got ' + reserved1);
86
98
 
87
- this._schemaBlockOff = Number(this._dv.getBigUint64(8, true));
88
- this._metadataOff = Number(this._dv.getBigUint64(16, true));
89
- this._shardDirOff = Number(this._dv.getBigUint64(24, true));
99
+ this._schemaBlockOff = u64(this._dv, 8, 'schema_block_off');
100
+ this._metadataOff = u64(this._dv, 16, 'metadata_off');
101
+ this._shardDirOff = u64(this._dv, 24, 'shard_directory_off');
90
102
  this._shardCount = this._dv.getUint32(32, true);
91
- this._totalRows = Number(this._dv.getBigUint64(40, true));
103
+ this._totalRows = u64(this._dv, 40, 'total_rows');
92
104
 
93
105
  if (this._schemaBlockOff !== 0) {
94
106
  throw new PreserveReaderError('R_INVALID', 'preserve container has non-zero schema_block_off');
@@ -127,14 +139,14 @@ export class PreserveReader {
127
139
  let cumulativeRow = 0;
128
140
  for (let i = 0; i < this._shardCount; i++) {
129
141
  const entryOff = this._shardDirOff + i * SHARD_ENTRY_BYTES;
130
- const payloadOff = Number(this._dv.getBigUint64(entryOff + 0, true));
142
+ const payloadOff = u64(this._dv, entryOff + 0, 'shard ' + i + ' payload_off');
131
143
  const payloadLen = this._dv.getUint32(entryOff + 8, true);
132
144
  const rowCount = this._dv.getUint32(entryOff + 12, true);
133
145
  const minReaderVer = this._dv.getUint16(entryOff + 16, true);
134
146
  const shardFlags = this._dv.getUint16(entryOff + 18, true);
135
147
  const shardReserved = this._dv.getUint32(entryOff + 20, true);
136
- const localStrOff = Number(this._dv.getBigUint64(entryOff + 24, true));
137
- const localStrLen = Number(this._dv.getBigUint64(entryOff + 32, true));
148
+ const localStrOff = u64(this._dv, entryOff + 24, 'shard ' + i + ' local_string_off');
149
+ const localStrLen = u64(this._dv, entryOff + 32, 'shard ' + i + ' local_string_len');
138
150
  if (minReaderVer > 1) {
139
151
  throw new PreserveReaderError('R_SHARD_VERSION_TOO_NEW',
140
152
  'shard ' + i + ' requires reader version ' + minReaderVer);
@@ -195,7 +207,10 @@ export class PreserveReader {
195
207
  get hasZoneMaps() { return false; }
196
208
 
197
209
  _locateShard(rowIdx) {
198
- if (rowIdx < 0 || rowIdx >= this._totalRows) {
210
+ // BS-32: Number.isInteger rejects fractional/NaN too, so getBytes(1.5) fails
211
+ // closed instead of returning a garbage subarray. One site covers
212
+ // getBytes/getString/getJSON.
213
+ if (!Number.isInteger(rowIdx) || rowIdx < 0 || rowIdx >= this._totalRows) {
199
214
  throw new PreserveReaderError('R_ROW_OUT_OF_RANGE',
200
215
  'rowIdx ' + rowIdx + ' out of range [0, ' + this._totalRows + ')');
201
216
  }
@@ -32,7 +32,7 @@
32
32
 
33
33
  import { checkOpts } from './Opts.js';
34
34
 
35
- export const VERSION = '1.4.1';
35
+ export const VERSION = '1.5.0';
36
36
 
37
37
  const U32_MAX = 4294967295;
38
38
  const PRESERVE_TOKENIZER_OPTS = {
@@ -18,7 +18,7 @@
18
18
 
19
19
  import { checkOpts } from './Opts.js';
20
20
 
21
- export const VERSION = '1.4.1';
21
+ export const VERSION = '1.5.0';
22
22
 
23
23
  export class PreserveWriterError extends Error {
24
24
  constructor(code, msg) { super(msg); this.code = code; this.name = 'PreserveWriterError'; }
@@ -48,13 +48,19 @@ export class PreserveWriter {
48
48
  this._shards = [];
49
49
  this._totalRows = 0;
50
50
  this._finalized = false;
51
+ this._currentBlobs = null; // null => never allocated; _allocateShard allocates once
51
52
  this._allocateShard();
52
53
  }
53
54
 
54
55
  _allocateShard() {
55
- // Pre-allocate up to targetShardBytes for zero-GC record writes.
56
- this._currentBlobs = new Uint8Array(this._targetShardBytes);
57
- this._currentOffsets = new Uint32Array(INITIAL_OFFSETS_CAP);
56
+ // Allocate the working buffers ONCE (BS-27 floor). Every subsequent shard
57
+ // reuses them: _finalizeCurrentShard resets the cursors instead of nulling
58
+ // the buffers, so a shard roll allocates nothing. A grown buffer (oversized
59
+ // record) simply carries forward as a larger reusable buffer.
60
+ if (this._currentBlobs === null) {
61
+ this._currentBlobs = new Uint8Array(this._targetShardBytes);
62
+ this._currentOffsets = new Uint32Array(INITIAL_OFFSETS_CAP);
63
+ }
58
64
  this._currentBlobBytes = 0;
59
65
  this._currentRowCount = 0;
60
66
  }
@@ -74,7 +80,19 @@ export class PreserveWriter {
74
80
  this._allocateShard();
75
81
  }
76
82
  // Grow shard buffer if this single record exceeds the pre-allocated size.
83
+ // Fail closed (BS-31) on a BLOB-dimension crossing: this guard covers a
84
+ // record whose blob bytes push payload_len past the u32 ceiling (including
85
+ // the oversized-single-record path), and the _growBlobBuffer clamp keeps the
86
+ // buffer <= U32_MAX so such a write always re-enters this grow branch. The
87
+ // ORTHOGONAL crossing -- payload_len exceeding u32 via OFFSET-TABLE
88
+ // accumulation (many tiny records, blob under target, grow branch never
89
+ // firing) -- is caught in _finalizeCurrentShard. ST_BLOB_OVERFLOW is the
90
+ // shared cross-writer overflow code (R11).
77
91
  if (this._currentBlobBytes + len > this._currentBlobs.length) {
92
+ if (this._currentBlobBytes + len + (this._currentRowCount + 1) * 4 > U32_MAX)
93
+ throw new PreserveWriterError('ST_BLOB_OVERFLOW',
94
+ 'preserve shard payload ' + (this._currentBlobBytes + len) +
95
+ ' bytes + offset table would exceed the u32 payload_len ceiling ' + U32_MAX);
78
96
  this._growBlobBuffer(this._currentBlobBytes + len);
79
97
  }
80
98
  // Grow offsets array if we've hit the pre-allocated ceiling.
@@ -107,6 +125,9 @@ export class PreserveWriter {
107
125
  _growBlobBuffer(needed) {
108
126
  let cap = this._currentBlobs.length;
109
127
  while (cap < needed) cap *= 2;
128
+ // Clamp so the buffer never exceeds the u32 payload ceiling; this makes the
129
+ // onRecord grow-branch guard provably complete (see onRecord).
130
+ if (cap > U32_MAX) cap = U32_MAX;
110
131
  const next = new Uint8Array(cap);
111
132
  next.set(this._currentBlobs.subarray(0, this._currentBlobBytes));
112
133
  this._currentBlobs = next;
@@ -124,6 +145,15 @@ export class PreserveWriter {
124
145
  const blobLen = this._currentBlobBytes;
125
146
  const offsetTableBytes = rowCount * 4;
126
147
  const shardBytesLen = blobLen + offsetTableBytes;
148
+ // Fail closed (BS-31, cold) on an OFFSET-TABLE-driven crossing: payload_len =
149
+ // blob bytes + rowCount*4 is written into the shard directory as a u32, and
150
+ // enough tiny records can push it past the ceiling without the onRecord grow
151
+ // branch ever firing. This is the door that branch cannot cover. Shared code
152
+ // ST_BLOB_OVERFLOW (R11).
153
+ if (shardBytesLen > U32_MAX)
154
+ throw new PreserveWriterError('ST_BLOB_OVERFLOW',
155
+ 'preserve shard payload_len ' + shardBytesLen + ' (blob ' + blobLen + ' + offset table ' +
156
+ offsetTableBytes + ') exceeds the u32 ceiling ' + U32_MAX);
127
157
 
128
158
  const shardBytes = new Uint8Array(shardBytesLen);
129
159
  shardBytes.set(this._currentBlobs.subarray(0, blobLen), 0);
@@ -137,10 +167,10 @@ export class PreserveWriter {
137
167
  rowCount,
138
168
  blobLen,
139
169
  });
140
- // Zero out the working buffers so a stale ref can't leak old bytes into
141
- // the next shard by accident. The buffers are reused via _allocateShard.
142
- this._currentBlobs = null;
143
- this._currentOffsets = null;
170
+ // Reuse the working buffers for the next shard: reset the cursors only, keep
171
+ // the allocations (BS-27). No stale bytes can leak -- every read into these
172
+ // buffers is bounded by _currentBlobBytes / _currentRowCount (both zeroed
173
+ // here), and the shard copy above took only subarray(0, blobLen).
144
174
  this._currentBlobBytes = 0;
145
175
  this._currentRowCount = 0;
146
176
  }
@@ -30,8 +30,11 @@
30
30
  // Error codes (stable, share prefix with Reader.js where semantics overlap):
31
31
  // R_BAD_MAGIC / R_UNSUPPORTED_VERSION / R_UNSUPPORTED_ENDIAN / R_BAD_FIELD_FLAGS
32
32
  // R_UNSUPPORTED_LANE / R_SHARD_VERSION_TOO_NEW / R_TRUNCATED / R_UNKNOWN_FIELD
33
- // R_ADAPTER_SHORT_READ -- adapter returned fewer bytes than requested
34
- // R_ROW_OUT_OF_RANGE -- rowIdx >= totalRows
33
+ // R_ADAPTER_SHORT_READ -- adapter returned fewer bytes than requested, OR a
34
+ // non-Uint8Array / wrong-length adapter return (T7c)
35
+ // R_ROW_OUT_OF_RANGE -- rowIdx negative, fractional, NaN, or >= totalRows (BS-32)
36
+ // R_OFFSET_TOO_LARGE -- a u64 header/schema/directory offset exceeds 2^53-1
37
+ // R_NOT_PREFETCHED -- syncRange over a shard that prefetchRange has not cached
35
38
  // R_WRONG_MODE -- preserve-mode container fed to the schema RangeReader
36
39
  // R_BAD_FLAGS -- unknown header flag bits set
37
40
  // R_RESERVED_NONZERO -- a reserved header/descriptor/shard field is non-zero
@@ -42,7 +45,7 @@
42
45
  import { StringTable } from './StringTable.js';
43
46
  import { checkOpts } from './Opts.js';
44
47
 
45
- export const VERSION = '1.4.1';
48
+ export const VERSION = '1.5.0';
46
49
 
47
50
  const CONTAINER_HEADER_BYTES = 48;
48
51
  const SHARD_ENTRY_BYTES = 40;
@@ -65,6 +68,18 @@ export class RangeReaderError extends Error {
65
68
  }
66
69
  function raiseRange(code, msg) { throw new RangeReaderError(code, msg); }
67
70
 
71
+ // Read a u64 field from `dv` (the header, schema, or directory view -- three
72
+ // different DataViews here) and narrow it to a JS number, failing closed past
73
+ // Number.MAX_SAFE_INTEGER. Past 2^53-1 a Number() cast loses precision and every
74
+ // downstream bounds check reads a corrupted offset (BS-05). Cold: parse-only.
75
+ function u64(dv, off, what) {
76
+ const v = dv.getBigUint64(off, true);
77
+ if (v > 9007199254740991n)
78
+ throw new RangeReaderError('R_OFFSET_TOO_LARGE',
79
+ what + ' value ' + v + ' exceeds the safe-integer ceiling 9007199254740991');
80
+ return Number(v);
81
+ }
82
+
68
83
  // Validate a local string table's shape before StringTable.parse casts a
69
84
  // Uint32Array over it (T-1..T-4). `bytes` is the already-fetched shard slice;
70
85
  // `off` and `len` locate the table within it.
@@ -205,7 +220,7 @@ export class RangeReader {
205
220
  }
206
221
 
207
222
  // Step 1: header (48 bytes) tells us schema + shard-dir offsets.
208
- const headerBytes = await this.adapter.fetch(0, CONTAINER_HEADER_BYTES);
223
+ const headerBytes = await this._fetchExact(0, CONTAINER_HEADER_BYTES);
209
224
  const hdrDv = new DataView(headerBytes.buffer, headerBytes.byteOffset, headerBytes.byteLength);
210
225
  if (headerBytes[0] !== 0x4C || headerBytes[1] !== 0x42 || headerBytes[2] !== 0x4B || headerBytes[3] !== 0x31) {
211
226
  throw new RangeReaderError('R_BAD_MAGIC', 'header magic is not LBK1');
@@ -226,11 +241,11 @@ export class RangeReader {
226
241
  if (reserved1 !== 0)
227
242
  throw new RangeReaderError('R_RESERVED_NONZERO', 'header reserved1 at offset 36 must be 0, got ' + reserved1);
228
243
 
229
- this._schemaBlockOff = Number(hdrDv.getBigUint64(8, true));
230
- this._metadataOff = Number(hdrDv.getBigUint64(16, true));
231
- this._shardDirOff = Number(hdrDv.getBigUint64(24, true));
244
+ this._schemaBlockOff = u64(hdrDv, 8, 'schema_block_off');
245
+ this._metadataOff = u64(hdrDv, 16, 'metadata_off');
246
+ this._shardDirOff = u64(hdrDv, 24, 'shard_directory_off');
232
247
  this._shardCount = hdrDv.getUint32(32, true);
233
- this._totalRows = Number(hdrDv.getBigUint64(40, true));
248
+ this._totalRows = u64(hdrDv, 40, 'total_rows');
234
249
 
235
250
  const size = this.adapter.size;
236
251
  if (this._schemaBlockOff < CONTAINER_HEADER_BYTES || this._schemaBlockOff >= size)
@@ -252,11 +267,11 @@ export class RangeReader {
252
267
  if (this._shardDirOff <= this._schemaBlockOff)
253
268
  throw new RangeReaderError('R_TRUNCATED', 'shard_directory_off ' + this._shardDirOff + ' not after schema_block_off ' + this._schemaBlockOff);
254
269
  const schemaBlockLen = this._shardDirOff - this._schemaBlockOff;
255
- const schemaBytes = await this.adapter.fetch(this._schemaBlockOff, schemaBlockLen);
270
+ const schemaBytes = await this._fetchExact(this._schemaBlockOff, schemaBlockLen);
256
271
  this._parseSchema(schemaBytes);
257
272
 
258
273
  // Step 3: shard directory (fixed size = shardCount * 40).
259
- const dirBytes = await this.adapter.fetch(this._shardDirOff, this._shardCount * SHARD_ENTRY_BYTES);
274
+ const dirBytes = await this._fetchExact(this._shardDirOff, this._shardCount * SHARD_ENTRY_BYTES);
260
275
  this._parseShardDirectory(dirBytes);
261
276
 
262
277
  // Step 4 (M7): zone maps. Fetched ONCE at open, cached. Enables query
@@ -274,7 +289,7 @@ export class RangeReader {
274
289
 
275
290
  async _loadFooter() {
276
291
  const size = this.adapter.size;
277
- const footer = await this.adapter.fetch(size - CONTAINER_FOOTER_BYTES, CONTAINER_FOOTER_BYTES);
292
+ const footer = await this._fetchExact(size - CONTAINER_FOOTER_BYTES, CONTAINER_FOOTER_BYTES);
278
293
  if (footer[8] !== 0x31 || footer[9] !== 0x4B || footer[10] !== 0x42 || footer[11] !== 0x4C)
279
294
  throw new RangeReaderError('R_BAD_FOOTER', 'footer magic_end is not 1KBL');
280
295
  const fDv = new DataView(footer.buffer, footer.byteOffset, footer.byteLength);
@@ -291,7 +306,7 @@ export class RangeReader {
291
306
  // corruption (D1), not absence.
292
307
  if (this._metadataOff + 16 > this.adapter.size)
293
308
  throw new RangeReaderError('R_BAD_METADATA', 'zone maps segment header runs past the container');
294
- const hdr = await this.adapter.fetch(this._metadataOff, 16);
309
+ const hdr = await this._fetchExact(this._metadataOff, 16);
295
310
  if (hdr[0] !== 0x30 || hdr[1] !== 0x5A || hdr[2] !== 0x4D || hdr[3] !== 0x31)
296
311
  throw new RangeReaderError('R_BAD_METADATA', 'zone maps magic is not ZM01');
297
312
  const hdrDv = new DataView(hdr.buffer, hdr.byteOffset, hdr.byteLength);
@@ -309,7 +324,7 @@ export class RangeReader {
309
324
  const restLen = fieldTableLen + fieldTablePad + shardCount * T * 8 * 2;
310
325
  if (this._metadataOff + 16 + restLen > this.adapter.size)
311
326
  throw new RangeReaderError('R_BAD_METADATA', 'zone maps segment runs past the container');
312
- const rest = await this.adapter.fetch(this._metadataOff + 16, restLen);
327
+ const rest = await this._fetchExact(this._metadataOff + 16, restLen);
313
328
  const restDv = new DataView(rest.buffer, rest.byteOffset, rest.byteLength);
314
329
  const tracked = new Array(T);
315
330
  const fieldToPos = new Map();
@@ -357,7 +372,10 @@ export class RangeReader {
357
372
  const laneKind = bytes[off + 4];
358
373
  const flags = bytes[off + 5];
359
374
  const reserved2 = dv.getUint16(off + 6, true);
360
- const nameStrOff = Number(dv.getBigUint64(off + 8, true));
375
+ const nameStrOff = u64(dv, off + 8, 'field ' + i + ' name_str_off');
376
+ // reserved3 is BigInt-compared vs 0n below, never Number()-cast, so it
377
+ // needs no narrowing guard -- the reserved-must-be-zero check catches any
378
+ // out-of-range value (BS-05).
361
379
  const reserved3 = dv.getBigUint64(off + 16, true);
362
380
  if (flags !== 0) throw new RangeReaderError('R_BAD_FIELD_FLAGS', 'field ' + i + ' has non-zero flags');
363
381
  if (reserved2 !== 0) throw new RangeReaderError('R_RESERVED_NONZERO', 'field ' + i + ' reserved2 must be 0, got ' + reserved2);
@@ -383,14 +401,14 @@ export class RangeReader {
383
401
  let cumulativeRow = 0;
384
402
  for (let i = 0; i < this._shardCount; i++) {
385
403
  const off = i * SHARD_ENTRY_BYTES;
386
- const payloadOff = Number(dv.getBigUint64(off + 0, true));
404
+ const payloadOff = u64(dv, off + 0, 'shard ' + i + ' payload_off');
387
405
  const payloadLen = dv.getUint32(off + 8, true);
388
406
  const rowCount = dv.getUint32(off + 12, true);
389
407
  const minReaderVer = dv.getUint16(off + 16, true);
390
408
  const shardFlags = dv.getUint16(off + 18, true);
391
409
  const shardReserved = dv.getUint32(off + 20, true);
392
- const localStrOff = Number(dv.getBigUint64(off + 24, true));
393
- const localStrLen = Number(dv.getBigUint64(off + 32, true));
410
+ const localStrOff = u64(dv, off + 24, 'shard ' + i + ' local_string_off');
411
+ const localStrLen = u64(dv, off + 32, 'shard ' + i + ' local_string_len');
394
412
  if (minReaderVer > READER_VERSION)
395
413
  throw new RangeReaderError('R_SHARD_VERSION_TOO_NEW',
396
414
  'shard ' + i + ' requires reader version ' + minReaderVer);
@@ -433,7 +451,10 @@ export class RangeReader {
433
451
 
434
452
  // Locate the shard containing rowIdx via binary search.
435
453
  _findShardIndex(rowIdx) {
436
- if (rowIdx < 0 || rowIdx >= this._totalRows) {
454
+ // BS-32: Number.isInteger rejects fractional/NaN as well as out-of-range in
455
+ // one guard. This ONE site covers get(), prefetchRange, syncRange's bounds
456
+ // walk and the syncRange view's inner get.
457
+ if (!Number.isInteger(rowIdx) || rowIdx < 0 || rowIdx >= this._totalRows) {
437
458
  throw new RangeReaderError('R_ROW_OUT_OF_RANGE',
438
459
  'rowIdx ' + rowIdx + ' out of range [0, ' + this._totalRows + ')');
439
460
  }
@@ -447,6 +468,21 @@ export class RangeReader {
447
468
  return -1; // unreachable given the range check above
448
469
  }
449
470
 
471
+ // Every adapter read funnels through here (T7c). The IOAdapter contract says
472
+ // fetch() MUST return exactly byteLength bytes; a short, over-long, or
473
+ // non-Uint8Array return would otherwise seed a DataView over the wrong extent
474
+ // and decode silent garbage. Fail closed with R_ADAPTER_SHORT_READ instead.
475
+ async _fetchExact(byteOffset, byteLength) {
476
+ const buf = await this.adapter.fetch(byteOffset, byteLength);
477
+ if (!(buf instanceof Uint8Array) || buf.byteLength !== byteLength) {
478
+ throw new RangeReaderError('R_ADAPTER_SHORT_READ',
479
+ 'adapter returned ' +
480
+ (buf instanceof Uint8Array ? buf.byteLength + ' bytes' : Object.prototype.toString.call(buf)) +
481
+ ' for a ' + byteLength + '-byte request at offset ' + byteOffset);
482
+ }
483
+ return buf;
484
+ }
485
+
450
486
  // Load a shard's payload + local string table with ONE range request.
451
487
  // They are contiguous in the container by SPEC 3.4, so a single fetch covers
452
488
  // both. Returns the cached shard record.
@@ -464,7 +500,7 @@ export class RangeReader {
464
500
  throw new RangeReaderError('R_TRUNCATED',
465
501
  'shard ' + shardIdx + ' string table not contiguous with payload');
466
502
  }
467
- const combined = await this.adapter.fetch(combinedOff, combinedLen);
503
+ const combined = await this._fetchExact(combinedOff, combinedLen);
468
504
  const payloadBytes = combined.subarray(0, s.payloadLen);
469
505
  // DataView over the payload's underlying ArrayBuffer window.
470
506
  const payloadDv = new DataView(payloadBytes.buffer, payloadBytes.byteOffset, payloadBytes.byteLength);
@@ -532,7 +568,7 @@ export class RangeReader {
532
568
  const lastShard = this._findShardIndex(lastRow - 1);
533
569
  for (let s = firstShard; s <= lastShard; s++) {
534
570
  if (!this._shardCache.has(s)) {
535
- throw new RangeReaderError('R_TRUNCATED',
571
+ throw new RangeReaderError('R_NOT_PREFETCHED',
536
572
  'syncRange requires shard ' + s + ' to be prefetched (call prefetchRange first)');
537
573
  }
538
574
  }
package/src/Reader.js CHANGED
@@ -21,11 +21,20 @@
21
21
  // R_BAD_METADATA - metadata_off is non-zero but the zone-map segment is unparseable
22
22
  // R_INVALID - a structure is internally inconsistent but in-bounds
23
23
  // R_UNKNOWN_FIELD - get()/findShards()/shardBounds() called with an unknown field name
24
+ // R_OFFSET_TOO_LARGE - a u64 header/schema/directory offset exceeds 2^53-1
25
+ // (Number() would lose precision; fail closed, BS-05)
26
+ // R_ROW_OUT_OF_RANGE - get(rowIdx) with rowIdx negative, fractional, NaN,
27
+ // or >= totalRows (BS-32)
28
+ //
29
+ // Row-index policy (BS-32): get() range-checks rowIdx; the SHARD-index escape
30
+ // hatches (shardPayload, shardF64, shardStringTable) do NOT -- their contract is
31
+ // raw indexed access with no per-call checking. shardBounds returns null for an
32
+ // out-of-range shard by decided policy.
24
33
 
25
34
  import { StringTable } from './StringTable.js';
26
35
  import { toContainerBuffer } from './Views.js';
27
36
 
28
- export const VERSION = '1.4.1';
37
+ export const VERSION = '1.5.0';
29
38
 
30
39
  const CONTAINER_HEADER_BYTES = 48;
31
40
  const SHARD_ENTRY_BYTES = 40;
@@ -42,6 +51,19 @@ export class ReaderError extends Error {
42
51
  constructor(code, msg) { super(msg); this.code = code; this.name = 'ReaderError'; }
43
52
  }
44
53
 
54
+ // Read a u64 header/directory/schema field and narrow it to a JS number, failing
55
+ // closed if it exceeds Number.MAX_SAFE_INTEGER (2^53-1). Past that a Number()
56
+ // cast loses precision and every downstream bounds check reads a corrupted
57
+ // offset -- an in-bounds lie is worse than a loud refusal (BS-05). Cold: called
58
+ // only during header/schema/directory parse, never per row.
59
+ function u64(dv, off, what) {
60
+ const v = dv.getBigUint64(off, true);
61
+ if (v > 9007199254740991n)
62
+ throw new ReaderError('R_OFFSET_TOO_LARGE',
63
+ what + ' value ' + v + ' exceeds the safe-integer ceiling 9007199254740991');
64
+ return Number(v);
65
+ }
66
+
45
67
  // Validate a local string table's shape BEFORE StringTable.parse casts a
46
68
  // Uint32Array over it (T-1..T-4): the offsets array must be in bounds, the
47
69
  // count/blob must fit, offsets must be non-decreasing, and the trailing
@@ -118,11 +140,11 @@ export class Reader {
118
140
  if (reserved1 !== 0)
119
141
  throw new ReaderError('R_RESERVED_NONZERO', 'header reserved1 at offset 36 must be 0, got ' + reserved1);
120
142
 
121
- this._schemaBlockOff = Number(this._dv.getBigUint64(8, true));
122
- this._metadataOff = Number(this._dv.getBigUint64(16, true)); // 0 = no metadata block; M7+ zone maps
123
- this._shardDirOff = Number(this._dv.getBigUint64(24, true));
143
+ this._schemaBlockOff = u64(this._dv, 8, 'schema_block_off');
144
+ this._metadataOff = u64(this._dv, 16, 'metadata_off'); // 0 = no metadata block; M7+ zone maps
145
+ this._shardDirOff = u64(this._dv, 24, 'shard_directory_off');
124
146
  this._shardCount = this._dv.getUint32(32, true);
125
- this._totalRows = Number(this._dv.getBigUint64(40, true));
147
+ this._totalRows = u64(this._dv, 40, 'total_rows');
126
148
  this._formatVersion = version;
127
149
 
128
150
  const len = this._buffer.byteLength;
@@ -180,7 +202,10 @@ export class Reader {
180
202
  const laneKind = this._bytes[descOff + 4];
181
203
  const flags = this._bytes[descOff + 5];
182
204
  const reserved2 = this._dv.getUint16(descOff + 6, true);
183
- const nameStrOff = Number(this._dv.getBigUint64(descOff + 8, true));
205
+ const nameStrOff = u64(this._dv, descOff + 8, 'field ' + i + ' name_str_off');
206
+ // reserved3 is compared as BigInt vs 0n below and never Number()-cast, so
207
+ // it needs no u64() narrowing guard -- a value past 2^53 is caught by the
208
+ // reserved-must-be-zero check, not by precision loss (BS-05).
184
209
  const reserved3 = this._dv.getBigUint64(descOff + 16, true);
185
210
 
186
211
  if (flags !== 0) throw new ReaderError('R_BAD_FIELD_FLAGS', 'field ' + i + ' has non-zero flags (v2+ reserved)');
@@ -212,14 +237,14 @@ export class Reader {
212
237
  let rowSum = 0;
213
238
  for (let i = 0; i < this._shardCount; i++) {
214
239
  const entryOff = off + i * SHARD_ENTRY_BYTES;
215
- const payloadOff = Number(this._dv.getBigUint64(entryOff + 0, true));
240
+ const payloadOff = u64(this._dv, entryOff + 0, 'shard ' + i + ' payload_off');
216
241
  const payloadLen = this._dv.getUint32(entryOff + 8, true);
217
242
  const rowCount = this._dv.getUint32(entryOff + 12, true);
218
243
  const minReaderVer = this._dv.getUint16(entryOff + 16, true);
219
244
  const shardFlags = this._dv.getUint16(entryOff + 18, true);
220
245
  const shardReserved = this._dv.getUint32(entryOff + 20, true);
221
- const localStrOff = Number(this._dv.getBigUint64(entryOff + 24, true));
222
- const localStrLen = Number(this._dv.getBigUint64(entryOff + 32, true));
246
+ const localStrOff = u64(this._dv, entryOff + 24, 'shard ' + i + ' local_string_off');
247
+ const localStrLen = u64(this._dv, entryOff + 32, 'shard ' + i + ' local_string_len');
223
248
  if (minReaderVer > READER_VERSION) {
224
249
  throw new ReaderError('R_SHARD_VERSION_TOO_NEW',
225
250
  'shard ' + i + ' requires reader version ' + minReaderVer);
@@ -396,13 +421,24 @@ export class Reader {
396
421
  return i;
397
422
  }
398
423
 
424
+ // Cold: the BS-32 row-range refusal body, kept out of get()'s hot frame.
425
+ _badRow(rowIdx) {
426
+ throw new ReaderError('R_ROW_OUT_OF_RANGE',
427
+ 'rowIdx ' + rowIdx + ' out of range [0, ' + this._totalRows + ')');
428
+ }
429
+
399
430
  // Random-access get across all shards. Returns the field's decoded value:
400
431
  // F64 lanes -> number
401
432
  // U32 lanes -> JS string (resolved via the shard's local string table)
402
433
  // Not zero-alloc; intended for testing/introspection.
403
434
  get(rowIdx, fieldName) {
404
- let remaining = rowIdx;
405
435
  const fieldIdx = this.fieldIndex(fieldName);
436
+ // BS-32 row-range door. `(rowIdx >>> 0) !== rowIdx` rejects negative,
437
+ // fractional, NaN and >= 2^32 in one compare; a whole ArrayBuffer cannot hold
438
+ // 2^32 rows, so the totalRows compare fires first for every reachable
439
+ // container. Throw body hoisted to the cold _badRow helper.
440
+ if ((rowIdx >>> 0) !== rowIdx || rowIdx >= this._totalRows) this._badRow(rowIdx);
441
+ let remaining = rowIdx;
406
442
  const field = this._schema.fields[fieldIdx];
407
443
  for (let s = 0; s < this._shards.length; s++) {
408
444
  const shard = this._shards[s];
package/src/Split.js CHANGED
@@ -37,7 +37,7 @@ import { Reader, ReaderError } from './Reader.js';
37
37
  import { StringTable } from './StringTable.js';
38
38
  import { checkOpts } from './Opts.js';
39
39
 
40
- export const VERSION = '1.4.1';
40
+ export const VERSION = '1.5.0';
41
41
 
42
42
  const LF = 0x0A;
43
43
  const CONTAINER_HEADER_BYTES = 48;
@@ -219,6 +219,11 @@ export function mergeContainers(containers) {
219
219
  // Compute the target layout. Schema block is copied from container 0 verbatim.
220
220
  // Reader gives us schemaBlockOff and shardDirOff; the schema-block byte range
221
221
  // is [schemaBlockOff, shardDirOff). We reuse this slice.
222
+ // These Number(getBigUint64) reads carry NO u64 narrowing guard by design
223
+ // (BS-05 dominance): `new Reader(...)` at :193 above parsed every part first,
224
+ // and Reader's own guarded u64() over the same header bytes (schema_block_off,
225
+ // shard_directory_off) already threw R_OFFSET_TOO_LARGE for any over-2^53
226
+ // value. Split mints nothing here -- an untriggerable code would be dead.
222
227
  const src0 = parts[0];
223
228
  const dv0 = new DataView(src0.buffer, src0.byteOffset, src0.byteLength);
224
229
  const schemaBlockOff = Number(dv0.getBigUint64(8, true));
@@ -383,7 +388,10 @@ function _schemasEqual(a, b) {
383
388
  return true;
384
389
  }
385
390
 
386
- // Look up the source string-table byte length for a shard.
391
+ // Look up the source string-table byte length for a shard. No u64 narrowing
392
+ // guard here (BS-05 dominance): `reader` is a fully-parsed Reader, so Reader's
393
+ // guarded u64() over this same local_string_len field already threw
394
+ // R_OFFSET_TOO_LARGE for any over-2^53 value during construction.
387
395
  function _sourceStringTableLen(reader, shardIdx) {
388
396
  const dv = new DataView(reader.buffer);
389
397
  const entryOff = reader.shardDirectoryOffset + shardIdx * SHARD_ENTRY_BYTES;
@@ -19,8 +19,26 @@
19
19
  // Entry 0 is always the empty string: the table reserves it in the constructor
20
20
  // and at every reset(), so an absent U32 row cell (which is 0) decodes as ""
21
21
  // rather than aliasing the shard's first-interned string (SPEC 3.3, SPEC 7).
22
+ //
23
+ // Serialization ceilings (BS-31). serialize() writes entry_count and
24
+ // blob_length as u32; a table that grew past either would silently wrap on
25
+ // setUint32 and mint a corrupt-but-in-bounds container. Two cold guards in
26
+ // _insertNew fail closed instead:
27
+ // ST_BLOB_OVERFLOW - the blob would exceed the u32 blob_length ceiling, OR
28
+ // the entry count would exceed the entry_count ceiling
29
+ // (one code, two messages -- both name their ceiling).
30
+ // The doubling clamps in _growBlob / _growOffsets cap allocation at the
31
+ // ceiling so a crossing write ALWAYS re-enters the grow branch where the guard
32
+ // lives; guard placement is then provably complete (no path reaches serialize
33
+ // with an out-of-range count/blob). Zero hot cost: the guards read only inside
34
+ // the cold grow arms.
35
+ //
36
+ // __setStringTableLimits({blobBytes, entryCount}) lowers the ceilings for a
37
+ // cheap gated crossing on the real grow path and returns the previous pair.
38
+ // TEST-ONLY: not re-exported from index.js, absent from the .d.ts and docs,
39
+ // and carrying no semver guarantee.
22
40
 
23
- export const VERSION = '1.4.1';
41
+ export const VERSION = '1.5.0';
24
42
 
25
43
  const EMPTY_SLOT = 0xFFFFFFFF; // MUST be unsigned; typed-array reads are unsigned
26
44
  const INITIAL_BLOB_BYTES = 64 * 1024;
@@ -29,6 +47,26 @@ const INITIAL_HASH_CAP = 2048; // load factor target 50%
29
47
  const HASH_MAX_LOAD_NUM = 1; // 50% load factor: num/den = 1/2
30
48
  const HASH_MAX_LOAD_DEN = 2;
31
49
 
50
+ // u32 serialization ceilings. blob_length is a u32 (max 4294967295). entry_count
51
+ // is a u32, but the offsets array carries entry_count+1 slots (the sentinel), so
52
+ // the last addressable entry_count is 4294967294 to keep offsets_len a valid u32.
53
+ // `let`, not `const`: __setStringTableLimits lowers them for gated crossings.
54
+ let LIMIT_BLOB = 4294967295;
55
+ let LIMIT_ENTRIES = 4294967294;
56
+
57
+ // TEST-ONLY seam (BS-31). Assigns whichever of {blobBytes, entryCount} are
58
+ // present, returns the previous pair. Not part of the public API.
59
+ export function __setStringTableLimits(next) {
60
+ const prev = { blobBytes: LIMIT_BLOB, entryCount: LIMIT_ENTRIES };
61
+ if (next && next.blobBytes !== undefined) LIMIT_BLOB = next.blobBytes;
62
+ if (next && next.entryCount !== undefined) LIMIT_ENTRIES = next.entryCount;
63
+ return prev;
64
+ }
65
+
66
+ export class StringTableError extends Error {
67
+ constructor(code, msg) { super(msg); this.code = code; this.name = 'StringTableError'; }
68
+ }
69
+
32
70
  // Reserved entry 0. A zero-length range: no blob bytes, no growth, one hash
33
71
  // slot. Module-level so reset() reserves without allocating.
34
72
  const EMPTY = new Uint8Array(0);
@@ -94,10 +132,23 @@ export class StringTable {
94
132
 
95
133
  _insertNew(bytes, from, to, slot) {
96
134
  const n = to - from;
97
- // Grow blob if needed
98
- if (this._blobLen + n > this._blob.length) this._growBlob(n);
99
- // Grow offsets if needed (leave room for +1 sentinel)
100
- if (this._count + 1 >= this._offsets.length) this._growOffsets();
135
+ // Grow blob if needed. The clamp keeps _blob.length <= LIMIT_BLOB, so any
136
+ // write pushing past the ceiling necessarily re-enters this branch and is
137
+ // caught by the cold guard (fail closed -- serialize would wrap otherwise).
138
+ if (this._blobLen + n > this._blob.length) {
139
+ if (this._blobLen + n > LIMIT_BLOB)
140
+ throw new StringTableError('ST_BLOB_OVERFLOW',
141
+ 'string blob ' + this._blobLen + ' + ' + n + ' would exceed the u32 blob_length ceiling ' + LIMIT_BLOB);
142
+ this._growBlob(n);
143
+ }
144
+ // Grow offsets if needed (leave room for +1 sentinel). Same completeness
145
+ // argument via the _growOffsets clamp on the entry_count ceiling.
146
+ if (this._count + 1 >= this._offsets.length) {
147
+ if (this._count >= LIMIT_ENTRIES)
148
+ throw new StringTableError('ST_BLOB_OVERFLOW',
149
+ 'string table entry count ' + this._count + ' would exceed the entry_count ceiling ' + LIMIT_ENTRIES);
150
+ this._growOffsets();
151
+ }
101
152
 
102
153
  const idx = this._count;
103
154
  this._offsets[idx] = this._blobLen;
@@ -123,13 +174,18 @@ export class StringTable {
123
174
  _growBlob(needed) {
124
175
  let cap = this._blob.length;
125
176
  while (cap < this._blobLen + needed) cap *= 2;
177
+ // Clamp so allocated length never exceeds the u32 ceiling (see _insertNew).
178
+ if (cap > LIMIT_BLOB) cap = LIMIT_BLOB;
126
179
  const nb = new Uint8Array(cap);
127
180
  nb.set(this._blob);
128
181
  this._blob = nb;
129
182
  }
130
183
 
131
184
  _growOffsets() {
132
- const nb = new Uint32Array(this._offsets.length * 2);
185
+ let len = this._offsets.length * 2;
186
+ // Clamp so the offsets array (entry_count + 1 slots) never exceeds a valid u32.
187
+ if (len > LIMIT_ENTRIES + 1) len = LIMIT_ENTRIES + 1;
188
+ const nb = new Uint32Array(len);
133
189
  nb.set(this._offsets);
134
190
  this._offsets = nb;
135
191
  }
package/src/Tokenizer.js CHANGED
@@ -30,7 +30,7 @@
30
30
 
31
31
  import { checkOpts } from './Opts.js';
32
32
 
33
- export const VERSION = '1.4.1';
33
+ export const VERSION = '1.5.0';
34
34
 
35
35
  const U32_MAX = 4294967295;
36
36
  const TOKENIZER_OPTS = {
package/src/Writer.js CHANGED
@@ -23,6 +23,21 @@
23
23
  // String fields intern via the per-shard StringTable which allocates only on
24
24
  // unique inserts (bounded by cardinality, not row count).
25
25
  //
26
+ // Shard-byte budget (D2): a shard rolls when its row count reaches the maxRows
27
+ // ceiling OR when payload_bytes + string_table_bytes reaches targetShardBytes.
28
+ // The byte term is CHUNK-INDEPENDENT: it reads only _currentShardRowCount,
29
+ // rowStride, _targetShardBytes and _stringTableBytes -- the first three are
30
+ // chunk-invariant and _stringTableBytes is a pure function of the unique
31
+ // interned byte ranges in arrival order (= logical record order, which the
32
+ // Tokenizer delivers identically for any input chunking). No chunk-local state
33
+ // (absOffset, buffer boundaries) enters, so re-chunked input yields byte-
34
+ // identical containers (the t0 law). A single row or string that alone exceeds
35
+ // targetShardBytes still gets its own oversized shard (maxRows has a
36
+ // Math.max(1, ...) floor and the budget runs post-write); payload_len stays
37
+ // u32-honest via the targetShardBytes constructor guard. A schema with no U32
38
+ // lane keeps a zero string budget and rolls on rows only -- byte-identical to
39
+ // the pre-D2 baseline.
40
+ //
26
41
  // Error codes (stable):
27
42
  // W_TOP_LEVEL_NOT_OBJECT - top-level value is not an object
28
43
  // W_NESTED_UNSUPPORTED - nested object/array inside a record
@@ -43,7 +58,7 @@
43
58
  import { StringTable } from './StringTable.js';
44
59
  import { checkOpts } from './Opts.js';
45
60
 
46
- export const VERSION = '1.4.1';
61
+ export const VERSION = '1.5.0';
47
62
 
48
63
  const U32_MAX = 4294967295;
49
64
  // Post-finalize sentinel for _recordDepth. Chosen = 2 so every post-finalize
@@ -105,6 +120,12 @@ export class WriterError extends Error {
105
120
  }
106
121
  }
107
122
 
123
+ // Shared decoder for sample-window key names (BS-27 floor). onKey fires once per
124
+ // key per sample record; a fresh TextDecoder each time is pure garbage. One
125
+ // module-scope instance, reused -- decode() is stateless across calls. The other
126
+ // TextDecoders in the codebase are per-open/lazy and stay as they are.
127
+ const KEY_DECODER = new TextDecoder();
128
+
108
129
  function hashBytes(bytes, from, to) {
109
130
  let h = 0x811c9dc5 | 0;
110
131
  for (let i = from; i < to; i++) {
@@ -226,6 +247,15 @@ export class Writer {
226
247
  this._shards = []; // { bytes, rowCount, stringTableBytes }
227
248
  this._totalRows = 0;
228
249
 
250
+ // D2 shard-byte budget (zero-alloc, chunk-independent). _stringTableBytes is
251
+ // the serialized byte length the CURRENT shard's string table would emit RIGHT
252
+ // NOW; _maxInternedIdx is the highest interned index seen this shard, so a new
253
+ // unique arrival (strIdx > _maxInternedIdx) is detected in one compare. Both
254
+ // recomputed only in the cold unique-string arm (bounded by cardinality, not
255
+ // rows) and reset at each shard finalize. Real values are set at freeze.
256
+ this._stringTableBytes = 0;
257
+ this._maxInternedIdx = 0;
258
+
229
259
  // Per-record parse state
230
260
  this._recordDepth = 0;
231
261
  this._currentKeyBytes = null;
@@ -285,7 +315,7 @@ export class Writer {
285
315
  // buffer for the value bytes before onNumber/onString fires, so we cannot
286
316
  // defer decoding. String allocation here is expected — bounded to the
287
317
  // sample window; steady-state post-freeze remains zero-alloc.
288
- this._currentKeyName = new TextDecoder().decode(bytes.subarray(from, to));
318
+ this._currentKeyName = KEY_DECODER.decode(bytes.subarray(from, to));
289
319
  }
290
320
  }
291
321
 
@@ -343,8 +373,17 @@ export class Writer {
343
373
  const lane = this._fieldLaneKinds[idx];
344
374
  if (lane !== LANE_U32) throw new WriterError('W_LANE_MISMATCH',
345
375
  'field ' + this._schema.fields[idx].name + ' is not a U32 lane but got a string');
346
- const strIdx = this._currentShardStringTable().intern(bytes, from, to);
376
+ const st = this._currentShardStringTable();
377
+ const strIdx = st.intern(bytes, from, to);
347
378
  this._rowValueSlotsU32[idx] = strIdx;
379
+ // D2 (cold arm, unique arrivals only): recompute the serialized string-table
380
+ // byte length. serialize() emits (8 + 4*(count+1) + blobLen), 8-padded, and
381
+ // count = strIdx + 1 for a fresh entry, so the padded form is
382
+ // (16 + 4*strIdx + blobLen) rounded up to 8 -- exact vs StringTable.serialize.
383
+ if (strIdx > this._maxInternedIdx) {
384
+ this._maxInternedIdx = strIdx;
385
+ this._stringTableBytes = (16 + 4 * strIdx + st.blobLen + 7) & ~7;
386
+ }
348
387
  } else {
349
388
  if (!this._currentKeyName) return;
350
389
  this._sample.setString(this._currentKeyName, bytes, from, to);
@@ -452,6 +491,19 @@ export class Writer {
452
491
  this._fieldLaneKinds[i] = fields[i].laneKind;
453
492
  this._fieldOffsets[i] = fields[i].offsetInRow;
454
493
  }
494
+ // R7 floor: O(1) field-name resolution. hash -> field index, or an array of
495
+ // indices when two names collide (Array.isArray in the cold arm). Built once
496
+ // at freeze; _lookupFieldIdx replaces its O(F) scan with one Map.get plus the
497
+ // mandatory byte-equal confirm, so a hash collision still resolves correctly.
498
+ const fieldHashMap = new Map();
499
+ for (let i = 0; i < fields.length; i++) {
500
+ const h = this._fieldNameHashes[i];
501
+ const existing = fieldHashMap.get(h);
502
+ if (existing === undefined) fieldHashMap.set(h, i);
503
+ else if (Array.isArray(existing)) existing.push(i);
504
+ else fieldHashMap.set(h, [existing, i]);
505
+ }
506
+ this._fieldHashMap = fieldHashMap;
455
507
  this._rowValueSlotsF64 = new Float64Array(fields.length);
456
508
  this._rowValueSlotsU32 = new Uint32Array(fields.length);
457
509
  this._currentShardMaxRows = Math.max(1, Math.floor(this._targetShardBytes / rowStride));
@@ -467,14 +519,27 @@ export class Writer {
467
519
  this._trackedFieldToPos = new Int32Array(fields.length);
468
520
  for (let i = 0; i < fields.length; i++) this._trackedFieldToPos[i] = -1;
469
521
  for (let t = 0; t < tracked.length; t++) this._trackedFieldToPos[tracked[t]] = t;
522
+
523
+ // D2 budget baseline. A fresh string table always carries reserved entry 0,
524
+ // so a U32-lane shard with no real strings still serializes to 16 bytes; a
525
+ // schema with no U32 lane emits NO table (0 bytes, EMPTY_STRING_TABLE_BYTES).
526
+ // Seeding _stringTableBytes to 0 for the F64-only case keeps such containers
527
+ // byte-identical to the pre-D2 baseline (the roll then fires on rows only via
528
+ // the maxRows term, which is <= the byte term for a zero string budget).
529
+ this._maxInternedIdx = 0;
530
+ this._stringTableBytes = this._hasU32 ? 16 : 0;
470
531
  }
471
532
 
472
533
  _lookupFieldIdx(bytes, from, to) {
473
534
  const h = hashBytes(bytes, from, to);
474
- const hashes = this._fieldNameHashes;
535
+ const hit = this._fieldHashMap.get(h);
536
+ if (hit === undefined) return -1;
475
537
  const names = this._fieldNamesUtf8;
476
- for (let i = 0; i < hashes.length; i++) {
477
- if (hashes[i] === h && bytesEqual(bytes, from, to, names[i])) return i;
538
+ // Hit path (the common case): a single index. Byte-confirm and return.
539
+ if (typeof hit === 'number') return bytesEqual(bytes, from, to, names[hit]) ? hit : -1;
540
+ // Cold: >= 2 field names share this hash; byte-confirm each candidate.
541
+ for (let k = 0; k < hit.length; k++) {
542
+ if (bytesEqual(bytes, from, to, names[hit[k]])) return hit[k];
478
543
  }
479
544
  return -1;
480
545
  }
@@ -511,7 +576,15 @@ export class Writer {
511
576
  }
512
577
  this._currentShardRowCount++;
513
578
  this._totalRows++;
514
- if (this._currentShardRowCount >= this._currentShardMaxRows) this._finalizeCurrentShard();
579
+ // D2 roll: fire on the row ceiling OR when payload + string-table bytes
580
+ // reach the target. The row-ceiling term fires first for any F64-only shard
581
+ // (string budget 0, maxRows = floor(target/stride) <= target/stride), so
582
+ // such containers stay byte-identical to baseline; string shards can roll
583
+ // earlier. Zero allocation -- one add + compare per row.
584
+ const n = this._currentShardRowCount;
585
+ if (n >= this._currentShardMaxRows ||
586
+ n * this._schema.rowStride + this._stringTableBytes >= this._targetShardBytes)
587
+ this._finalizeCurrentShard();
515
588
  } else {
516
589
  const endOff = this._source !== null ? this._source.absOffset : this._absOffset;
517
590
  const consumed = Math.max(1, endOff - this._recordStartByteOffset);
@@ -564,6 +637,10 @@ export class Writer {
564
637
  bytes: copy,
565
638
  rowCount: this._currentShardRowCount,
566
639
  stringTableBytes: stBytes,
640
+ // D2 audit: the incrementally-tracked budget at this finalize. MUST equal
641
+ // stBytes.length (the emitted directory local_string_len). Cross-checked by
642
+ // Ceilings.test.js via __stringTableBudgetAudit; not part of the public API.
643
+ budgetTracked: this._stringTableBytes,
567
644
  mins: shardMins,
568
645
  maxes: shardMaxes,
569
646
  });
@@ -574,6 +651,9 @@ export class Writer {
574
651
  // Reset string table for next shard (per-shard independence)
575
652
  st.reset();
576
653
  this._perShardStringTable = st;
654
+ // Reset the D2 budget trackers alongside st.reset() (per-shard independence).
655
+ this._maxInternedIdx = 0;
656
+ this._stringTableBytes = this._hasU32 ? 16 : 0;
577
657
  }
578
658
 
579
659
  _drainSampleToShards() {
@@ -607,6 +687,11 @@ export class Writer {
607
687
  const trackedIdx = this._trackedFieldToPos;
608
688
  const mins = this._currentShardMins;
609
689
  const maxes = this._currentShardMaxes;
690
+ // D2: honour the same string-aware byte budget the post-freeze hot path
691
+ // uses, so a sample-drained container and a post-freeze one agree on shard
692
+ // boundaries for identical logical input. writtenRows may finish below
693
+ // chunkRows when the string table pushes the shard over target.
694
+ let writtenRows = chunkRows;
610
695
  for (let r = 0; r < chunkRows; r++) {
611
696
  const rowOff = r * stride;
612
697
  for (let f = 0; f < fields.length; f++) {
@@ -625,14 +710,24 @@ export class Writer {
625
710
  const strBytes = sampleTable.bytesAt(sampleIdx);
626
711
  const newIdx = strBytes ? dstTable.intern(strBytes, 0, strBytes.length) : 0;
627
712
  dv.setUint32(fo, newIdx, true);
713
+ if (newIdx > this._maxInternedIdx) {
714
+ this._maxInternedIdx = newIdx;
715
+ this._stringTableBytes = (16 + 4 * newIdx + dstTable.blobLen + 7) & ~7;
716
+ }
628
717
  }
629
718
  }
719
+ const done = r + 1;
720
+ if (done < chunkRows &&
721
+ done * stride + this._stringTableBytes >= this._targetShardBytes) {
722
+ writtenRows = done;
723
+ break;
724
+ }
630
725
  }
631
- this._currentShardRowCount = chunkRows;
632
- this._totalRows += chunkRows;
726
+ this._currentShardRowCount = writtenRows;
727
+ this._totalRows += writtenRows;
633
728
  this._finalizeCurrentShard(); // serializes dstTable, resets in-place for the next iter
634
- srcRow += chunkRows;
635
- rowsRemaining -= chunkRows;
729
+ srcRow += writtenRows;
730
+ rowsRemaining -= writtenRows;
636
731
  }
637
732
 
638
733
  this._sample = null;
@@ -836,4 +931,17 @@ export class Writer {
836
931
  get schema() { return this._schema; }
837
932
  get totalRows() { return this._totalRows; }
838
933
  get shardCount() { return this._shards.length + (this._currentShardRowCount > 0 ? 1 : 0); }
934
+
935
+ // @internal TEST-ONLY (D2): per-shard [{ tracked, emitted }] string-table byte
936
+ // lengths. tracked is the incrementally-maintained budget at each finalize;
937
+ // emitted is the serialized local_string_len that reached the container. They
938
+ // MUST be equal at every shard -- Ceilings.test.js proves it, closing the
939
+ // formula-drift bug class. No semver guarantee; absent from the public API.
940
+ __stringTableBudgetAudit() {
941
+ const out = new Array(this._shards.length);
942
+ for (let i = 0; i < this._shards.length; i++) {
943
+ out[i] = { tracked: this._shards[i].budgetTracked, emitted: this._shards[i].stringTableBytes.length };
944
+ }
945
+ return out;
946
+ }
839
947
  }
package/src/index.js CHANGED
@@ -12,7 +12,7 @@
12
12
  import { Tokenizer, TokenizerError } from './Tokenizer.js';
13
13
  import { Writer, WriterError } from './Writer.js';
14
14
  import { Reader, ReaderError } from './Reader.js';
15
- import { StringTable } from './StringTable.js';
15
+ import { StringTable, StringTableError } from './StringTable.js';
16
16
  import { PreserveTokenizer, PreserveTokenizerError } from './PreserveTokenizer.js';
17
17
  import { PreserveWriter, PreserveWriterError } from './PreserveWriter.js';
18
18
  import { PreserveReader, PreserveReaderError } from './PreserveReader.js';
@@ -23,12 +23,12 @@ export {
23
23
  Tokenizer, TokenizerError,
24
24
  Writer, WriterError,
25
25
  Reader, ReaderError,
26
- StringTable,
26
+ StringTable, StringTableError,
27
27
  PreserveTokenizer, PreserveTokenizerError,
28
28
  PreserveWriter, PreserveWriterError,
29
29
  PreserveReader, PreserveReaderError,
30
30
  };
31
- export const VERSION = '1.4.1';
31
+ export const VERSION = '1.5.0';
32
32
 
33
33
  const encoder = new TextEncoder();
34
34
 
@@ -3,6 +3,11 @@
3
3
 
4
4
  export const VERSION: string;
5
5
 
6
+ export class StringTableError extends Error {
7
+ readonly code: string;
8
+ readonly name: 'StringTableError';
9
+ }
10
+
6
11
  export class StringTable {
7
12
  constructor();
8
13
  /** Intern a byte range. Zero-alloc on the hit path. Returns u32 index. */
package/types/index.d.ts CHANGED
@@ -13,7 +13,7 @@ export { Reader, ReaderError } from './Reader.d.ts';
13
13
  export type {
14
14
  ShardHandle, StringTableView, Bounds, FindShardsOptions,
15
15
  } from './Reader.d.ts';
16
- export { StringTable } from './StringTable.d.ts';
16
+ export { StringTable, StringTableError } from './StringTable.d.ts';
17
17
  export { PreserveTokenizer, PreserveTokenizerError } from './PreserveTokenizer.d.ts';
18
18
  export type { PreserveSink, PreserveTokenizerOptions } from './PreserveTokenizer.d.ts';
19
19
  export { PreserveWriter, PreserveWriterError } from './PreserveWriter.d.ts';