@zakkster/lite-bake-stream 1.4.1 → 1.6.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,76 @@
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.6.0] -- 2026-09-02
6
+
7
+ M6 -- streaming emission + optional integrity. Public `beginStream(sink, { layout: 'stream', crc? })` binds a sink BEFORE feeding, so each shard payload streams to the sink as it finalizes and is dropped; `finalizeToSink(sink, { layout })` then writes the schema/directory/zone-map trailer + footer with one `writeAt(header, 0)` backpatch and returns `{ totalRows, shardCount, schema | mode, bytesWritten, layout }`. Called without a prior `beginStream`, `finalizeToSink` is the buffered convenience mode (`O(container)` peak, stated in the memory-model docs). `layout: 'prefix'` is byte-identical to `finalize()`. With the two-step contract, peak memory drops from `O(container)` to `O(targetShardBytes + directory)`. A default-emitted stream container is a legal v1 container (format_version stays 1) accepted by every shipped reader, `checkContainer`, and `mergeContainers`. `finalize()` is re-based over the same emitters, so its output is byte-for-byte identical to 1.5.0 (frozen sha256 goldens hold).
8
+
9
+ Optional CRC-32C (Castagnoli, table-driven, zero deps, `src/Crc32c.js`): writers opt in with `{ crc: true }`; readers get `verifyCrc()` -> `'ok' | 'absent'` (mismatch throws `R_BAD_CRC`) and an open option `{ verifyCrc: true }` that fails closed on mismatch (`R_BAD_CRC`) and absence (`R_CRC_ABSENT`). `mergeContainers` recomputes the CRC iff every input carried one, else absent. KATs: `CRC32C("")=0x00000000`, `CRC32C("123456789")=0xE3069283`; the stream layout closes the ring with a split-buffer combine (`crc32cCombine`, KAT-pinned and covered end-to-end by a streaming `beginStream` + flipped-byte case). New sink guard `W_BAD_SINK`. Three new thrown codes (`W_BAD_SINK`, `R_BAD_CRC`, `R_CRC_ABSENT`); inventory 57 -> 60 / 0 unpinned.
10
+
11
+ Measured peak RSS (full-tier gate, 500 MB streamed in 8 MiB chunks into a counting sink, container never materialized): `peakRss - baselineRss` = 32.1 MiB against the bound `4*targetShardBytes + 64*shardCount + 64 MiB` = 96.0 MiB at 41 shards -- versus the buffered `finalize()` path at ~704 MiB (baseline `soak --mb=500`: baselineRss 667 MiB, peakRss 1371 MiB). Suite: 479 -> 515 tests / 515 pass / 0 fail / 0 todo. Torture: 44/44 fast, 47/47 full, arrayBuffers growth 0.00 MB both tiers, t7 sink-release witness (retained shard slots <= 1; independently armed during qa -- retained shards fail 364/364, clean run 0/0). Measurement note: t7's final arrayBuffers read now settles and collects twice before reading (the gc-profiler contract) -- t5-fuzz's large-ArrayBuffer churn was polluting the single-gc read via V8's buffer pool; the 8 MB budget and all RULES numbers are unchanged, and the M6 paths retain ~0 in isolation. Pack 32 -> 33 (new `src/Crc32c.js`). See `decisions/0009-streaming-emission.md`.
12
+
13
+ ## [1.5.0] -- 2026-09-02
14
+
15
+ 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.
16
+
17
+ ### Added
18
+
19
+ - **Serialization ceilings (BS-26).** `StringTable` now fails closed with a new
20
+ `StringTableError` code `ST_BLOB_OVERFLOW` when a per-shard string table would
21
+ cross the u32 `blob_length` (4294967295) or `entry_count` (4294967294) ceiling,
22
+ instead of wrapping silently on `setUint32` and minting a corrupt-but-in-bounds
23
+ container. The doubling paths clamp their allocation at the ceiling so every
24
+ crossing write re-enters the guard, making placement provably complete.
25
+ `StringTableError` is re-exported from `index.js` and declared in the types.
26
+ `PreserveWriter.onRecord` raises the same `ST_BLOB_OVERFLOW` (shared code)
27
+ before a shard's `payload_len` (blob + trailing offset table) would exceed u32.
28
+ A test-only `__setStringTableLimits({blobBytes, entryCount})` seam (no semver
29
+ guarantee, absent from the public API/types/docs) lowers the ceilings for a
30
+ cheap gated crossing on the real grow path.
31
+ - **Row-index doors (BS-32).** `Reader.get`, `MultiReader.get`, `RangeReader.get`
32
+ and `PreserveReader.getBytes/getString/getJSON` now reject a negative,
33
+ fractional, `NaN`, or out-of-range `rowIdx` with `R_ROW_OUT_OF_RANGE`
34
+ (`M_ROW_OUT_OF_RANGE` for `MultiReader`) rather than returning `undefined` or a
35
+ garbage subarray from a fractional byte offset. `Reader.get` uses a `>>>0`
36
+ compare; the others use `Number.isInteger` (they legitimately address up to
37
+ 2^53). The SHARD-index escape hatches keep their unchecked raw-access contract.
38
+ - **Safe-integer offset guard (BS-26).** `Reader`, `RangeReader` and
39
+ `PreserveReader` narrow every u64 header/schema/directory offset through a cold
40
+ `u64()` helper that throws `R_OFFSET_TOO_LARGE` past 2^53-1, where a `Number()`
41
+ cast would lose precision and hand a corrupted offset to every bounds check.
42
+ - **`RangeReader` adapter validation (BS-34 + BS-33).** All seven `adapter.fetch` sites
43
+ funnel through `_fetchExact`, which rejects a non-`Uint8Array` or wrong-length
44
+ return with `R_ADAPTER_SHORT_READ`. `syncRange` over a not-prefetched shard now
45
+ throws its own `R_NOT_PREFETCHED`, distinct from the `R_TRUNCATED` truncation
46
+ door.
47
+
48
+ ### Changed
49
+
50
+ - **Shard-byte budget (D2).** The schema `Writer` now rolls a shard when its
51
+ payload plus string-table bytes reach `targetShardBytes`, not on the row
52
+ ceiling alone, so a string-heavy shard no longer overshoots the target. The
53
+ string-table byte length is tracked incrementally with zero allocation (one
54
+ compare per string value, updated only on a unique arrival) and is exact versus
55
+ `StringTable.serialize` at every finalize. The budget reads only
56
+ chunk-invariant state, so re-chunked input still yields byte-identical
57
+ containers (the t0 law), and a container whose schema has only F64 lanes keeps
58
+ a zero string budget and is **byte-identical** to the pre-D2 output.
59
+ - **Floors.** The schema `Writer` field-name lookup moves from an O(F) hash-scan
60
+ to an O(1) `Map` (byte-confirmed, so a real FNV collision such as
61
+ `gwzx`/`16cd` still resolves correctly): on an ad-hoc 64-/256-field
62
+ flat-record serialize microbench (`bench/bench-tokenizer.js` carries a fixed
63
+ 7-field fixture, so it cannot express this floor), throughput went from
64
+ 190.0 -> 216.7 MB/s at 64 fields and 113.3 -> 213.6 MB/s at 256 fields --
65
+ the O(F) per-key term no longer degrades with field count. qa's independent
66
+ microbench (different corpus) corroborates the shape: baseline fell
67
+ 128.0 -> 77.3 MB/s going 64 -> 256 fields; with the map it holds
68
+ 163.0 -> 167.9 MB/s. The sample-window key
69
+ decoder is now a shared module-scope `TextDecoder` instead of one per key.
70
+ `PreserveWriter` reuses its working buffers across shard rolls (they were
71
+ reallocated every roll) -- the reuse is byte-safe (every read is bounded by the
72
+ reset cursors) and holds the working store byte-flat under a steady-stream soak.
73
+ - `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.
74
+
5
75
  ## [1.4.1] -- 2026-09-02
6
76
 
7
77
  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.6.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
 
@@ -123,6 +123,23 @@ All of these fit the format's existing forward-compat seams — `format_version`
123
123
 
124
124
  Two release gates. Zero major GC AND every declared cell round-trips exactly. If either fails, no publish.
125
125
 
126
+ ## Streaming emission and integrity
127
+
128
+ `finalize()` returns the whole container as one `ArrayBuffer` -- peak memory is `O(container)`. For bounded-memory output, `writer.finalizeToSink(sink, { layout })` emits to a caller sink and returns `{ totalRows, shardCount, schema | mode, bytesWritten, layout }` (no buffer). A sink is any object with a synchronous `write(bytes)`; `layout: 'stream'` also needs `writeAt(bytes, position)` for one header backpatch -- satisfiable by `fs.write(fd, buf, 0, len, pos)`, the FS Access API's `createWritable().write({ type: 'write', position })`, and the in-memory `MemorySink`.
129
+
130
+ There are two ways to drive a stream, and they deliver different peaks:
131
+
132
+ - **Two-step, bounded RAM** -- call `writer.beginStream(sink, { layout: 'stream' })` BEFORE feeding, feed the tokenizer, then `writer.finalizeToSink(sink, { layout: 'stream' })`. Each shard is written to the sink as it finalizes and its bytes are dropped, so peak memory is `peak(stream) = 2*targetShardBytes + shardCount*(40 + 16*T) + schemaBlockBytes` -- `O(targetShardBytes + directory)`, never `O(container)`. This is the mode the 500 MB gate proves.
133
+ - **One-shot, buffered** -- call `writer.finalizeToSink(sink, opts)` alone (no `beginStream`). Correct and simplest, but the shards are buffered first, so peak is `peak(prefix) = containerBytes + sum(shard bytes)` -- `O(container)`, the same as `finalize()`.
134
+
135
+ `layout: 'prefix'` is the classic layout, **byte-identical** to `finalize()`. `layout: 'stream'` places the schema/directory/zone-map trailer and footer after the payloads with one header backpatch; a default-emitted stream container is a legal v1 container (format_version stays 1) that every shipped reader, `checkContainer`, and `mergeContainers` accept.
136
+
137
+ A malformed sink (non-object, missing `write`, missing `writeAt` for stream, or an async/thenable return) throws `W_BAD_SINK`; a sink that throws mid-emission fails the writer closed and rethrows the source error verbatim (a retry then hits `W_FINALIZED`).
138
+
139
+ **Memory model.** `peak(prefix) = containerBytes + sum(shard bytes)`; `peak(stream) = 2*targetShardBytes + shardCount*(40 + 16*T) + schemaBlockBytes`. The full-tier gate drives the two-step bounded path, streaming 500 MB in 8 MiB chunks into a counting sink, and asserts `peakRss - baselineRss <= 4*targetShardBytes + 64*shardCount + 64 MiB` (measured 32.1 MiB against the 96.0 MiB bound at 41 shards; the buffered path is ~704 MiB at 500 MB).
140
+
141
+ **Optional CRC-32C** (Castagnoli, table-driven, zero deps). Opt in with `{ crc: true }` on the writer (or serialize `writer` opts); coverage is `[0, footer_off)`, folded per emitted chunk. Readers expose `verifyCrc()` -> `'ok' | 'absent'` (a mismatch throws `R_BAD_CRC`) on `Reader`/`PreserveReader` (sync) and `RangeReader` (async). The open option `{ verifyCrc: true }` (also `deserialize(bytes, opts)`) fails closed on both mismatch (`R_BAD_CRC`) and absence (`R_CRC_ABSENT`) -- `null` is not zero. `0xFFFFFFFF` means absent and stays legal. `mergeContainers` recomputes the CRC iff every input carried one, else emits absent. See `decisions/0009-streaming-emission.md`.
142
+
126
143
  ## Release testing tiers
127
144
 
128
145
  The gates run at four scales, chosen to fit different hardware and time budgets:
package/SPEC.md CHANGED
@@ -165,7 +165,9 @@ reader.findShards(fieldName, {min, max}) → shardIdx[] // shards that MA
165
165
  | 8 | 4 | `magic_end` | ASCII `1 K B L` = `0x31 0x4B 0x42 0x4C`. |
166
166
  | 12 | 4 | `footer_len`| u32. Currently `16`. Lets future versions grow the footer without breaking magic detection. |
167
167
 
168
- CRC is optional in M4; producers that omit it write `0xFFFFFFFF`.
168
+ CRC is optional; producers that omit it write `0xFFFFFFFF` (absent). Producers opt in with the writer option `{ crc: true }`. The CRC-32C uses the Castagnoli polynomial (bit-reflected `0x82F63B78`); check value `CRC32C("123456789") = 0xE3069283`. Readers verify with `verifyCrc()` (returns `'ok' | 'absent'`, throws `R_BAD_CRC` on mismatch) or the open option `{ verifyCrc: true }`, which additionally throws `R_CRC_ABSENT` when the caller demanded verification but the CRC is absent.
169
+
170
+ Note (SPEC 3 / 3.6): container offsets are ABSOLUTE, so two conformant orderings exist for a v1 container. The classic PREFIX ordering places the schema block, shard directory, and zone maps before the shard payloads. The STREAMING ordering (`layout: 'stream'`) places the shard payloads first, then the schema block, shard directory, and zone maps as a trailer, then the footer; the schema block still immediately precedes the shard directory (adjacency preserved). Both are valid v1 containers; format_version is unchanged.
169
171
 
170
172
  ## 4. Schema and field descriptors
171
173
 
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.6.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.6.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.
@@ -76,6 +87,14 @@ F64 only. Values exceeding IEEE 754 double range are rejected as `E_NUMBER_OVERF
76
87
 
77
88
  Subpath entries per SPEC section 6. `sideEffects: false`. Consumers import only the path they need; the browser reader never pulls the writer.
78
89
 
90
+ ## Streaming emission and integrity (M6)
91
+
92
+ `finalize()` returns the whole container as one ArrayBuffer (peak memory O(container)). `writer.finalizeToSink(sink, { layout })` emits to a caller sink instead and returns `{ totalRows, shardCount, schema|mode, bytesWritten, layout }` (no buffer). A sink is any object with a synchronous `write(bytes)`; `layout: 'stream'` also needs `writeAt(bytes, position)` for one header backpatch. Two ways to drive a stream with different peaks: (1) TWO-STEP bounded RAM -- `writer.beginStream(sink, { layout: 'stream' })` BEFORE feeding, feed the tokenizer, then `writer.finalizeToSink(sink, { layout: 'stream' })`; each shard streams to the sink as it finalizes and its bytes drop, so peak is `peak(stream) = 2*targetShardBytes + shardCount*(40 + 16*T) + schemaBlockBytes` = O(targetShardBytes + directory). (2) ONE-SHOT buffered -- `finalizeToSink(sink, opts)` alone (no beginStream); correct but the shards are buffered first, so peak is `peak(prefix) = containerBytes + sum(shard bytes)` = O(container), same as `finalize()`. `layout: 'prefix'` is the classic layout, byte-identical to `finalize()`. A default-emitted stream container is a legal v1 container (format_version stays 1) that every shipped reader, `checkContainer`, and `mergeContainers` accept. A malformed sink (non-object, missing `write`, missing `writeAt` for stream, or an async/thenable return) throws `W_BAD_SINK`; a sink that throws mid-emission fails the writer closed and rethrows the source error verbatim (a retry then hits `W_FINALIZED`).
93
+
94
+ Memory model. `peak(prefix) = containerBytes + sum(shard bytes)`; `peak(stream) = 2*targetShardBytes + shardCount*(40 + 16*T) + schemaBlockBytes`. The full-tier gate streams 500 MB in 8 MiB chunks into a counting sink and asserts `peakRss - baselineRss <= 4*targetShardBytes + 64*shardCount + 64 MiB` (measured 32.1 MiB vs the 96.0 MiB bound at 41 shards; the buffered prefix path is ~704 MiB at 500 MB).
95
+
96
+ Optional CRC-32C (Castagnoli, table-driven, zero deps). Writers opt in with `{ crc: true }` (constructor or serialize `writer` opts); coverage is [0, footer_off), folded per emitted chunk. Readers expose `verifyCrc()` -> `'ok' | 'absent'` (a mismatch throws `R_BAD_CRC`) on `Reader`/`PreserveReader` (sync) and `RangeReader` (async). The open option `{ verifyCrc: true }` (also `deserialize(bytes, opts)`) fails closed on both mismatch (`R_BAD_CRC`) and absence (`R_CRC_ABSENT`). `0xFFFFFFFF` means absent and stays legal (SPEC 3.7). `mergeContainers` recomputes the CRC iff every input carried one, else emits absent. KAT: `CRC32C("123456789") = 0xE3069283`. See decisions/0009-streaming-emission.md.
97
+
79
98
  ## Non-goals
80
99
 
81
100
  JSON5, JSONC, comments, trailing commas, streaming field updates, compression, native (non-JS) producers.
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.6.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/Crc32c.js ADDED
@@ -0,0 +1,96 @@
1
+ // @zakkster/lite-bake-stream / Crc32c (internal)
2
+ // Copyright (c) 2026 Zahary Shinikchiev. MIT.
3
+ //
4
+ // CRC-32C (Castagnoli, bit-reflected polynomial 0x82F63B78) over container
5
+ // bodies. Backs the optional footer integrity field (SPEC 3.7). The 256-entry
6
+ // lookup table is built ONCE at import (cold); the update loop is scalar and
7
+ // allocation-free, folding one byte per iteration:
8
+ // crc = (crc >>> 8) ^ TABLE[(crc ^ b) & 0xFF]
9
+ // It is called per emitted CHUNK, never per record.
10
+ //
11
+ // The running CRC is carried as a signed int32 (init = 0xFFFFFFFF, i.e. -1);
12
+ // crc32cFinal applies the trailing one-complement and returns an unsigned u32.
13
+ //
14
+ // crc32cCombine folds the finalized CRC of a suffix onto the finalized CRC of a
15
+ // prefix (GF(2) matrix method, as in zlib's crc32_combine but with the CRC-32C
16
+ // polynomial). The streaming writer uses it ONCE at end-of-input to prepend the
17
+ // 48-byte header CRC onto the running body CRC without re-reading the dropped
18
+ // shard payloads. Cold path; not on any per-chunk loop.
19
+ //
20
+ // Not a public export. No subpath. Internal to the package.
21
+
22
+ const POLY = 0x82F63B78; // CRC-32C, bit-reflected
23
+ const TABLE = buildTable();
24
+
25
+ function buildTable() {
26
+ const t = new Int32Array(256);
27
+ for (let n = 0; n < 256; n++) {
28
+ let c = n;
29
+ for (let k = 0; k < 8; k++) {
30
+ c = (c & 1) ? (POLY ^ (c >>> 1)) : (c >>> 1);
31
+ }
32
+ t[n] = c | 0;
33
+ }
34
+ return t;
35
+ }
36
+
37
+ export function crc32cInit() { return 0xFFFFFFFF | 0; }
38
+
39
+ export function crc32cUpdate(crc, bytes, off, len) {
40
+ let c = crc | 0;
41
+ const end = off + len;
42
+ for (let i = off; i < end; i++) {
43
+ c = (c >>> 8) ^ TABLE[(c ^ bytes[i]) & 0xFF];
44
+ }
45
+ return c | 0;
46
+ }
47
+
48
+ export function crc32cFinal(crc) {
49
+ return (crc ^ 0xFFFFFFFF) >>> 0;
50
+ }
51
+
52
+ // ---- GF(2) matrix helpers for crc32cCombine (cold) --------------------------
53
+
54
+ const _even = new Int32Array(32);
55
+ const _odd = new Int32Array(32);
56
+
57
+ function gf2Times(mat, vec) {
58
+ let sum = 0;
59
+ let v = vec >>> 0;
60
+ let i = 0;
61
+ while (v !== 0) {
62
+ if (v & 1) sum ^= mat[i];
63
+ v >>>= 1;
64
+ i++;
65
+ }
66
+ return sum | 0;
67
+ }
68
+
69
+ function gf2Square(square, mat) {
70
+ for (let n = 0; n < 32; n++) square[n] = gf2Times(mat, mat[n]);
71
+ }
72
+
73
+ // crcA = finalized CRC of prefix A; crcB = finalized CRC of suffix B; len2 =
74
+ // byte length of B. Returns the finalized CRC of A || B. len2 may exceed 2^31,
75
+ // so it is halved by division and tested by remainder.
76
+ export function crc32cCombine(crcA, crcB, len2) {
77
+ if (len2 === 0) return crcA >>> 0;
78
+ const odd = _odd, even = _even;
79
+ odd[0] = POLY | 0; // operator for a single zero bit
80
+ let row = 1;
81
+ for (let n = 1; n < 32; n++) { odd[n] = row; row = (row << 1) | 0; }
82
+ gf2Square(even, odd); // 2 zero bits
83
+ gf2Square(odd, even); // 4 zero bits
84
+ let crc = crcA >>> 0;
85
+ let len = len2;
86
+ do {
87
+ gf2Square(even, odd);
88
+ if (len % 2) crc = gf2Times(even, crc) >>> 0;
89
+ len = Math.floor(len / 2);
90
+ if (len === 0) break;
91
+ gf2Square(odd, even);
92
+ if (len % 2) crc = gf2Times(odd, crc) >>> 0;
93
+ len = Math.floor(len / 2);
94
+ } while (len !== 0);
95
+ return (crc ^ (crcB >>> 0)) >>> 0;
96
+ }
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.6.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.6.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,29 +28,48 @@
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';
35
+ import { checkOpts } from './Opts.js';
36
+ import { crc32cInit, crc32cUpdate, crc32cFinal } from './Crc32c.js';
34
37
 
35
- export const VERSION = '1.4.1';
38
+ export const VERSION = '1.6.0';
36
39
 
37
40
  const CONTAINER_HEADER_BYTES = 48;
38
41
  const SHARD_ENTRY_BYTES = 40;
39
42
  const FOOTER_BYTES = 16;
43
+ const CRC_ABSENT = 0xFFFFFFFF;
44
+
45
+ const PRESERVE_READER_OPTS = { verifyCrc: { t: 'bool' } };
46
+ function raisePreserveReaderOpt(code, msg) { throw new PreserveReaderError(code, msg); }
40
47
 
41
48
  export class PreserveReaderError extends Error {
42
49
  constructor(code, msg) { super(msg); this.code = code; this.name = 'PreserveReaderError'; }
43
50
  }
44
51
 
52
+ // Read a u64 header/directory field and narrow it to a JS number, failing closed
53
+ // past Number.MAX_SAFE_INTEGER. Past 2^53-1 a Number() cast loses precision and
54
+ // every downstream bounds check reads a corrupted offset (BS-05). Cold: parse-only.
55
+ function u64(dv, off, what) {
56
+ const v = dv.getBigUint64(off, true);
57
+ if (v > 9007199254740991n)
58
+ throw new PreserveReaderError('R_OFFSET_TOO_LARGE',
59
+ what + ' value ' + v + ' exceeds the safe-integer ceiling 9007199254740991');
60
+ return Number(v);
61
+ }
62
+
45
63
  export class PreserveReader {
46
- static fromBuffer(input) {
47
- return new PreserveReader(toContainerBuffer(input, 'PreserveReader.fromBuffer'));
64
+ static fromBuffer(input, opts) {
65
+ return new PreserveReader(toContainerBuffer(input, 'PreserveReader.fromBuffer'), opts);
48
66
  }
49
67
 
50
- constructor(buffer) {
68
+ constructor(buffer, opts) {
51
69
  if (!(buffer instanceof ArrayBuffer)) {
52
70
  throw new TypeError('PreserveReader: expected ArrayBuffer');
53
71
  }
72
+ checkOpts('PreserveReader', opts, PRESERVE_READER_OPTS, raisePreserveReaderOpt);
54
73
  this._buffer = buffer;
55
74
  this._dv = new DataView(buffer);
56
75
  this._bytes = new Uint8Array(buffer);
@@ -58,6 +77,22 @@ export class PreserveReader {
58
77
  this._parseHeader();
59
78
  this._parseFooter();
60
79
  this._parseShardDirectory();
80
+ if (opts && opts.verifyCrc === true) {
81
+ const status = this.verifyCrc();
82
+ if (status === 'absent')
83
+ throw new PreserveReaderError('R_CRC_ABSENT', 'verifyCrc:true but the container carries no CRC (footer CRC is absent, 0xFFFFFFFF)');
84
+ }
85
+ }
86
+
87
+ // SPEC 3.7 integrity: 'ok' | 'absent'; a mismatch throws R_BAD_CRC.
88
+ verifyCrc() {
89
+ const footerOff = this._buffer.byteLength - FOOTER_BYTES;
90
+ const stored = this._dv.getUint32(footerOff, true) >>> 0;
91
+ if (stored === CRC_ABSENT) return 'absent';
92
+ const actual = crc32cFinal(crc32cUpdate(crc32cInit(), this._bytes, 0, footerOff));
93
+ if (actual !== stored)
94
+ throw new PreserveReaderError('R_BAD_CRC', 'container CRC mismatch: stored 0x' + stored.toString(16) + ' != computed 0x' + actual.toString(16));
95
+ return 'ok';
61
96
  }
62
97
 
63
98
  _parseHeader() {
@@ -84,11 +119,11 @@ export class PreserveReader {
84
119
  if (reserved1 !== 0)
85
120
  throw new PreserveReaderError('R_RESERVED_NONZERO', 'header reserved1 at offset 36 must be 0, got ' + reserved1);
86
121
 
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));
122
+ this._schemaBlockOff = u64(this._dv, 8, 'schema_block_off');
123
+ this._metadataOff = u64(this._dv, 16, 'metadata_off');
124
+ this._shardDirOff = u64(this._dv, 24, 'shard_directory_off');
90
125
  this._shardCount = this._dv.getUint32(32, true);
91
- this._totalRows = Number(this._dv.getBigUint64(40, true));
126
+ this._totalRows = u64(this._dv, 40, 'total_rows');
92
127
 
93
128
  if (this._schemaBlockOff !== 0) {
94
129
  throw new PreserveReaderError('R_INVALID', 'preserve container has non-zero schema_block_off');
@@ -127,14 +162,14 @@ export class PreserveReader {
127
162
  let cumulativeRow = 0;
128
163
  for (let i = 0; i < this._shardCount; i++) {
129
164
  const entryOff = this._shardDirOff + i * SHARD_ENTRY_BYTES;
130
- const payloadOff = Number(this._dv.getBigUint64(entryOff + 0, true));
165
+ const payloadOff = u64(this._dv, entryOff + 0, 'shard ' + i + ' payload_off');
131
166
  const payloadLen = this._dv.getUint32(entryOff + 8, true);
132
167
  const rowCount = this._dv.getUint32(entryOff + 12, true);
133
168
  const minReaderVer = this._dv.getUint16(entryOff + 16, true);
134
169
  const shardFlags = this._dv.getUint16(entryOff + 18, true);
135
170
  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));
171
+ const localStrOff = u64(this._dv, entryOff + 24, 'shard ' + i + ' local_string_off');
172
+ const localStrLen = u64(this._dv, entryOff + 32, 'shard ' + i + ' local_string_len');
138
173
  if (minReaderVer > 1) {
139
174
  throw new PreserveReaderError('R_SHARD_VERSION_TOO_NEW',
140
175
  'shard ' + i + ' requires reader version ' + minReaderVer);
@@ -195,7 +230,10 @@ export class PreserveReader {
195
230
  get hasZoneMaps() { return false; }
196
231
 
197
232
  _locateShard(rowIdx) {
198
- if (rowIdx < 0 || rowIdx >= this._totalRows) {
233
+ // BS-32: Number.isInteger rejects fractional/NaN too, so getBytes(1.5) fails
234
+ // closed instead of returning a garbage subarray. One site covers
235
+ // getBytes/getString/getJSON.
236
+ if (!Number.isInteger(rowIdx) || rowIdx < 0 || rowIdx >= this._totalRows) {
199
237
  throw new PreserveReaderError('R_ROW_OUT_OF_RANGE',
200
238
  'rowIdx ' + rowIdx + ' out of range [0, ' + this._totalRows + ')');
201
239
  }
@@ -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.6.0';
36
36
 
37
37
  const U32_MAX = 4294967295;
38
38
  const PRESERVE_TOKENIZER_OPTS = {