@zakkster/lite-bake-stream 1.5.0 → 1.6.1

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/llms.txt CHANGED
@@ -1,20 +1,20 @@
1
1
  # @zakkster/lite-bake-stream
2
2
 
3
- Streaming byte-level JSON to lite-bake binary compiler for JS runtimes.
3
+ Streaming byte-level JSON to LBK1 binary containers for JS runtimes.
4
4
 
5
5
  ## Purpose
6
6
 
7
- Ingest gigabyte-scale JSON (top-level array or NDJSON) into the `lite-bake` LBK1 binary format without materializing the intermediate object graph. Reference producer path for downstream consumers of the flat, interleaved, zero-GC `lite-bake` reader API.
7
+ Ingest gigabyte-scale JSON (top-level array or NDJSON) into the LBK1 binary container format without materializing the intermediate object graph. The container is flat, interleaved, and zero-GC to read; a Reader gives random access to any row without re-parsing JSON.
8
8
 
9
9
  ## Status
10
10
 
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).
11
+ v1.6.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).
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.5.0)
17
+ ## Public API (v1.6.1)
18
18
 
19
19
  Two ingest modes share one top-level API:
20
20
 
@@ -34,21 +34,18 @@ Schema-mode classes:
34
34
  Preserve-mode classes:
35
35
  - `PreserveTokenizer` (`/preserve-tokenizer`): NDJSON record-boundary scanner, JSON-aware depth tracking, chunk-safe.
36
36
  - `PreserveWriter` (`/preserve-writer`): opaque byte-blob sink, pre-allocated shard buffer, zero-GC record path.
37
- - `PreserveReader` (`/preserve-reader`): tri-API `getBytes(i)` (zero-alloc view), `getString(i)`, `getJSON(i)`.
37
+ - `PreserveReader` (`/preserve-reader`): tri-API -- `getBytes(i)` (zero-alloc view), `getString(i)`, `getJSON(i)`. A returned view pins the container's ArrayBuffer (plain subarray semantics); copy the bytes out if you need the container to be collectable.
38
38
 
39
39
  Shared:
40
40
  - `StringTable` (`/string-table`): byte-level UTF-8 interning primitive.
41
- - `ingestStream`, `ingestFile` (`/file-ingest`): browser helpers piping a `ReadableStream<Uint8Array>` through the pipeline. `preserve` option dispatches to the right writer.
41
+ - `ingestStream`, `ingestFile` (`/file-ingest`): browser helpers piping a `ReadableStream<Uint8Array>` (e.g. `File.stream()`) through the pipeline. `preserve` option dispatches to the right writer. Per-chunk `onProgress` callback; its state object is reused across calls (mutated in place, zero per-chunk allocation) -- copy it if retained past the callback.
42
42
 
43
43
  Error classes with stable `code`: `TokenizerError`, `WriterError`, `ReaderError`, `RangeReaderError`, `MultiReaderError`, `SplitError`, `PreserveTokenizerError`, `PreserveWriterError`, `PreserveReaderError`.
44
- - `ingestStream`, `ingestFile` (`/file-ingest`): browser helpers piping a `ReadableStream<Uint8Array>` (e.g. `File.stream()`) through the Tokenizer + Writer, returning a Reader. Per-chunk `onProgress` callback; its state object is reused across calls (mutated in place, zero per-chunk allocation) -- copy it if retained past the callback.
45
- - `TokenizerError`, `WriterError`, `ReaderError`, `RangeReaderError`: thrown on parse/write/read errors with stable `code`.
46
- - `VERSION` const per subpath.
47
44
 
48
45
  ## Schema forms
49
46
 
50
- - `{ fields: ['id', 'x', 'y'] }` all-F64 shorthand, back-compat with M2.
51
- - `{ fields: [{name:'id', laneKind:'f64'}, {name:'tag', laneKind:'u32'}] }` mixed lanes (M3).
47
+ - `{ fields: ['id', 'x', 'y'] }` -- all-F64 shorthand, back-compat with M2.
48
+ - `{ fields: [{name:'id', laneKind:'f64'}, {name:'tag', laneKind:'u32'}] }` -- mixed lanes (M3).
52
49
  - Sample-and-infer (default when no schema is passed): the first `sampleBytes` of INPUT are observed, byte-true (BS-07) -- the window ends at the first record boundary at or after `sampleBytes` input bytes, read from the Tokenizer's `absOffset`, independent of chunking and record count (a hand-driven Writer with no wired source samples by record count). Fields with only numbers become F64, only strings become U32. null is lane-neutral (BS-20): it sets no kind, so null+string infers U32 (no longer "mixed") and null+number infers F64; post-freeze null reads 0 on an F64 lane and "" on a U32 lane. A field that saw both a real number and a real string raises `W_MIXED_LANE_TYPES` at freeze. Sample memory is O(`sampleBytes`): columnar staging plus a shared string table, independent of total input size.
53
50
 
54
51
  ## Contract
@@ -60,7 +57,7 @@ Error classes with stable `code`: `TokenizerError`, `WriterError`, `ReaderError`
60
57
 
61
58
  ## Preservation contract
62
59
 
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).
60
+ 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 (~103,000 rows across 20 scenarios per fast-tier run).
64
61
 
65
62
  ## Row-index and refusal codes
66
63
 
@@ -85,7 +82,15 @@ F64 only. Values exceeding IEEE 754 double range are rejected as `E_NUMBER_OVERF
85
82
 
86
83
  ## Tree-shaking
87
84
 
88
- Subpath entries per SPEC section 6. `sideEffects: false`. Consumers import only the path they need; the browser reader never pulls the writer.
85
+ Subpath entries per SPEC section 6. `sideEffects: false`. Consumers import only the path they need; the browser reader never pulls the writer. Every subpath exports a `VERSION` const.
86
+
87
+ ## Streaming emission and integrity (M6)
88
+
89
+ `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`).
90
+
91
+ 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).
92
+
93
+ 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.
89
94
 
90
95
  ## Non-goals
91
96
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@zakkster/lite-bake-stream",
3
- "version": "1.5.0",
4
- "description": "Streaming byte-level JSON to lite-bake binary compiler. Zero-GC, tree-shakeable, gigabyte-scale.",
3
+ "version": "1.6.1",
4
+ "description": "Streaming byte-level JSON to LBK1 binary containers. Zero-GC, tree-shakeable, gigabyte-scale.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
7
7
  "exports": {
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
@@ -2,11 +2,15 @@
2
2
  // Browser-side helper: File.stream() -> Tokenizer -> Writer -> Reader.
3
3
  // Copyright (c) 2026 Zahary Shinikchiev. MIT.
4
4
  //
5
+ // Error codes:
6
+ // E_OPTION_CONFLICT - preserve mode requested with a non-ndjson framing
7
+ //
5
8
  // The natural browser ingest path is:
6
9
  //
7
10
  // const file = fileInputEl.files[0];
8
- // const reader = await ingestStream(file.stream(), file.size, {
11
+ // const reader = await ingestStream(file.stream(), {
9
12
  // framing: 'ndjson',
13
+ // totalBytes: file.size,
10
14
  // onProgress: ({bytesIngested, totalBytes, rowsWritten, shardsCommitted}) => {...}
11
15
  // });
12
16
  // // reader is a fully-materialized in-memory LBK1 Reader.
@@ -27,7 +31,7 @@ import { PreserveWriter } from './PreserveWriter.js';
27
31
  import { PreserveReader } from './PreserveReader.js';
28
32
  import { checkOpts } from './Opts.js';
29
33
 
30
- export const VERSION = '1.5.0';
34
+ export const VERSION = '1.6.1';
31
35
 
32
36
  const U32_MAX = 4294967295;
33
37
  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.5.0';
28
+ export const VERSION = '1.6.1';
29
29
 
30
30
  export class MultiReaderError extends Error {
31
31
  constructor(code, msg) { super(msg); this.code = code; this.name = 'MultiReaderError'; }
package/src/Opts.js CHANGED
@@ -1,6 +1,10 @@
1
1
  // @zakkster/lite-bake-stream / Opts (internal, not exported from index.js)
2
2
  // Copyright (c) 2026 Zahary Shinikchiev. MIT.
3
3
  //
4
+ // Error codes:
5
+ // E_UNKNOWN_OPTION - an unknown option key (fails closed with a did-you-mean hint)
6
+ // E_OPTION_VALUE - an option value falls outside its declared domain
7
+ //
4
8
  // Shared construction-time options validator. Every public constructor and
5
9
  // entry function that takes an opts object runs checkOpts once, at construction
6
10
  // or in a synchronous prologue -- never per byte, never per record. An unknown
@@ -29,15 +29,23 @@
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
31
  // R_ROW_OUT_OF_RANGE - rowIdx negative, fractional, NaN, or >= totalRows (BS-32)
32
+ // R_BAD_CRC - CRC-32C verify requested and the stored checksum mismatched
33
+ // R_CRC_ABSENT - CRC verify requested but the container carries no checksum
32
34
  // R_OFFSET_TOO_LARGE - a u64 header/directory offset exceeds 2^53-1
33
35
 
34
36
  import { toContainerBuffer } from './Views.js';
37
+ import { checkOpts } from './Opts.js';
38
+ import { crc32cInit, crc32cUpdate, crc32cFinal } from './Crc32c.js';
35
39
 
36
- export const VERSION = '1.5.0';
40
+ export const VERSION = '1.6.1';
37
41
 
38
42
  const CONTAINER_HEADER_BYTES = 48;
39
43
  const SHARD_ENTRY_BYTES = 40;
40
44
  const FOOTER_BYTES = 16;
45
+ const CRC_ABSENT = 0xFFFFFFFF;
46
+
47
+ const PRESERVE_READER_OPTS = { verifyCrc: { t: 'bool' } };
48
+ function raisePreserveReaderOpt(code, msg) { throw new PreserveReaderError(code, msg); }
41
49
 
42
50
  export class PreserveReaderError extends Error {
43
51
  constructor(code, msg) { super(msg); this.code = code; this.name = 'PreserveReaderError'; }
@@ -55,14 +63,15 @@ function u64(dv, off, what) {
55
63
  }
56
64
 
57
65
  export class PreserveReader {
58
- static fromBuffer(input) {
59
- return new PreserveReader(toContainerBuffer(input, 'PreserveReader.fromBuffer'));
66
+ static fromBuffer(input, opts) {
67
+ return new PreserveReader(toContainerBuffer(input, 'PreserveReader.fromBuffer'), opts);
60
68
  }
61
69
 
62
- constructor(buffer) {
70
+ constructor(buffer, opts) {
63
71
  if (!(buffer instanceof ArrayBuffer)) {
64
72
  throw new TypeError('PreserveReader: expected ArrayBuffer');
65
73
  }
74
+ checkOpts('PreserveReader', opts, PRESERVE_READER_OPTS, raisePreserveReaderOpt);
66
75
  this._buffer = buffer;
67
76
  this._dv = new DataView(buffer);
68
77
  this._bytes = new Uint8Array(buffer);
@@ -70,6 +79,22 @@ export class PreserveReader {
70
79
  this._parseHeader();
71
80
  this._parseFooter();
72
81
  this._parseShardDirectory();
82
+ if (opts && opts.verifyCrc === true) {
83
+ const status = this.verifyCrc();
84
+ if (status === 'absent')
85
+ throw new PreserveReaderError('R_CRC_ABSENT', 'verifyCrc:true but the container carries no CRC (footer CRC is absent, 0xFFFFFFFF)');
86
+ }
87
+ }
88
+
89
+ // SPEC 3.7 integrity: 'ok' | 'absent'; a mismatch throws R_BAD_CRC.
90
+ verifyCrc() {
91
+ const footerOff = this._buffer.byteLength - FOOTER_BYTES;
92
+ const stored = this._dv.getUint32(footerOff, true) >>> 0;
93
+ if (stored === CRC_ABSENT) return 'absent';
94
+ const actual = crc32cFinal(crc32cUpdate(crc32cInit(), this._bytes, 0, footerOff));
95
+ if (actual !== stored)
96
+ throw new PreserveReaderError('R_BAD_CRC', 'container CRC mismatch: stored 0x' + stored.toString(16) + ' != computed 0x' + actual.toString(16));
97
+ return 'ok';
73
98
  }
74
99
 
75
100
  _parseHeader() {
@@ -1,11 +1,20 @@
1
1
  // @zakkster/lite-bake-stream / PreserveTokenizer
2
2
  // NDJSON record-boundary scanner for preserve-mode. Copyright (c) 2026 Zahary Shinikchiev. MIT.
3
3
  //
4
+ // Error codes:
5
+ // E_UNSUPPORTED_FRAMING - preserve mode fed a framing other than ndjson
6
+ // E_RECORD_TOO_LARGE - a single record exceeds maxRecordBytes
7
+ // E_UNBALANCED - a record's JSON structure never closes
8
+ // E_TRUNCATED - input ends mid-record
9
+ // E_ENDED - feed() called after end()
10
+ // E_POISONED - reuse after a prior throw
11
+ //
4
12
  // The schema-mode Tokenizer walks the full JSON AST (numbers, strings, keys,
5
13
  // object/array structure) so the Writer can pack values into typed lanes.
6
- // Preserve-mode doesn't crack open records it just needs to find where one
14
+ // Preserve-mode doesn't crack open records -- it just needs to find where one
7
15
  // record ends and the next begins, so the record's original bytes can be
8
16
  // shoved into a shard intact.
17
+ // See decisions/0010-preserve-mode.md.
9
18
  //
10
19
  // Contract:
11
20
  // - Input: NDJSON bytes. One JSON value per line, delimited by 0x0A.
@@ -32,7 +41,7 @@
32
41
 
33
42
  import { checkOpts } from './Opts.js';
34
43
 
35
- export const VERSION = '1.5.0';
44
+ export const VERSION = '1.6.1';
36
45
 
37
46
  const U32_MAX = 4294967295;
38
47
  const PRESERVE_TOKENIZER_OPTS = {
@@ -57,7 +66,7 @@ const BYTE_RBRACK = 0x5D;
57
66
  const BYTE_SPACE = 0x20;
58
67
  const BYTE_TAB = 0x09;
59
68
 
60
- const INITIAL_BUF = 1 << 16; // 64 KiB grows on demand for larger records
69
+ const INITIAL_BUF = 1 << 16; // 64 KiB -- grows on demand for larger records
61
70
 
62
71
  export class PreserveTokenizer {
63
72
  constructor(sink, opts) {