@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 +70 -0
- package/README.md +18 -1
- package/SPEC.md +3 -1
- package/llms.txt +21 -2
- package/package.json +13 -1
- package/src/Crc32c.js +96 -0
- package/src/FileIngest.js +1 -1
- package/src/MultiReader.js +5 -2
- package/src/PreserveReader.js +51 -13
- package/src/PreserveTokenizer.js +1 -1
- package/src/PreserveWriter.js +248 -67
- package/src/RangeReader.js +82 -21
- package/src/Reader.js +77 -13
- package/src/Split.js +24 -4
- package/src/StringTable.js +62 -6
- package/src/Tokenizer.js +1 -1
- package/src/Views.js +62 -0
- package/src/Writer.js +496 -149
- package/src/index.js +6 -6
- package/types/PreserveReader.d.ts +10 -2
- package/types/PreserveWriter.d.ts +30 -0
- package/types/RangeReader.d.ts +5 -0
- package/types/Reader.d.ts +10 -2
- package/types/StringTable.d.ts +5 -0
- package/types/Writer.d.ts +37 -0
- package/types/index.d.ts +3 -2
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.
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
30
|
+
export const VERSION = '1.6.0';
|
|
31
31
|
|
|
32
32
|
const U32_MAX = 4294967295;
|
|
33
33
|
const INGEST_OPTS = {
|
package/src/MultiReader.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
}
|
package/src/PreserveReader.js
CHANGED
|
@@ -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.
|
|
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 =
|
|
88
|
-
this._metadataOff =
|
|
89
|
-
this._shardDirOff =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
137
|
-
const localStrLen =
|
|
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
|
-
|
|
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
|
}
|