@zakkster/lite-bake-stream 1.5.0 → 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,14 @@
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
+
5
13
  ## [1.5.0] -- 2026-09-02
6
14
 
7
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.
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.5.0. LBK1 format frozen at `format_version: 1`. Qualified on an 8 GB soak with zero GC and byte-exact preservation across 590 million cells.
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.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.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.5.0)
17
+ ## Public API (v1.6.0)
18
18
 
19
19
  Two ingest modes share one top-level API:
20
20
 
@@ -87,6 +87,14 @@ F64 only. Values exceeding IEEE 754 double range are rejected as `E_NUMBER_OVERF
87
87
 
88
88
  Subpath entries per SPEC section 6. `sideEffects: false`. Consumers import only the path they need; the browser reader never pulls the writer.
89
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
+
90
98
  ## Non-goals
91
99
 
92
100
  JSON5, JSONC, comments, trailing commas, streaming field updates, compression, native (non-JS) producers.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zakkster/lite-bake-stream",
3
- "version": "1.5.0",
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,
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.5.0';
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.5.0';
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'; }
@@ -32,12 +32,18 @@
32
32
  // R_OFFSET_TOO_LARGE - a u64 header/directory offset exceeds 2^53-1
33
33
 
34
34
  import { toContainerBuffer } from './Views.js';
35
+ import { checkOpts } from './Opts.js';
36
+ import { crc32cInit, crc32cUpdate, crc32cFinal } from './Crc32c.js';
35
37
 
36
- export const VERSION = '1.5.0';
38
+ export const VERSION = '1.6.0';
37
39
 
38
40
  const CONTAINER_HEADER_BYTES = 48;
39
41
  const SHARD_ENTRY_BYTES = 40;
40
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); }
41
47
 
42
48
  export class PreserveReaderError extends Error {
43
49
  constructor(code, msg) { super(msg); this.code = code; this.name = 'PreserveReaderError'; }
@@ -55,14 +61,15 @@ function u64(dv, off, what) {
55
61
  }
56
62
 
57
63
  export class PreserveReader {
58
- static fromBuffer(input) {
59
- return new PreserveReader(toContainerBuffer(input, 'PreserveReader.fromBuffer'));
64
+ static fromBuffer(input, opts) {
65
+ return new PreserveReader(toContainerBuffer(input, 'PreserveReader.fromBuffer'), opts);
60
66
  }
61
67
 
62
- constructor(buffer) {
68
+ constructor(buffer, opts) {
63
69
  if (!(buffer instanceof ArrayBuffer)) {
64
70
  throw new TypeError('PreserveReader: expected ArrayBuffer');
65
71
  }
72
+ checkOpts('PreserveReader', opts, PRESERVE_READER_OPTS, raisePreserveReaderOpt);
66
73
  this._buffer = buffer;
67
74
  this._dv = new DataView(buffer);
68
75
  this._bytes = new Uint8Array(buffer);
@@ -70,6 +77,22 @@ export class PreserveReader {
70
77
  this._parseHeader();
71
78
  this._parseFooter();
72
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';
73
96
  }
74
97
 
75
98
  _parseHeader() {
@@ -32,7 +32,7 @@
32
32
 
33
33
  import { checkOpts } from './Opts.js';
34
34
 
35
- export const VERSION = '1.5.0';
35
+ export const VERSION = '1.6.0';
36
36
 
37
37
  const U32_MAX = 4294967295;
38
38
  const PRESERVE_TOKENIZER_OPTS = {
@@ -17,8 +17,10 @@
17
17
  // container with R_WRONG_MODE.
18
18
 
19
19
  import { checkOpts } from './Opts.js';
20
+ import { crc32cInit, crc32cUpdate, crc32cFinal, crc32cCombine } from './Crc32c.js';
21
+ import { validateSink, isThenable } from './Views.js';
20
22
 
21
- export const VERSION = '1.5.0';
23
+ export const VERSION = '1.6.0';
22
24
 
23
25
  export class PreserveWriterError extends Error {
24
26
  constructor(code, msg) { super(msg); this.code = code; this.name = 'PreserveWriterError'; }
@@ -28,6 +30,11 @@ function raisePreserveWriter(code, msg) { throw new PreserveWriterError(code, ms
28
30
  const CONTAINER_HEADER_BYTES = 48;
29
31
  const SHARD_ENTRY_BYTES = 40;
30
32
  const FOOTER_BYTES = 16;
33
+ const CRC_ABSENT = 0xFFFFFFFF;
34
+ const FINALIZE_TO_SINK_OPTS = {
35
+ layout: { t: 'enum', values: ['prefix', 'stream'] },
36
+ crc: { t: 'bool' },
37
+ };
31
38
  const DEFAULT_TARGET_SHARD_BYTES = 8 * 1024 * 1024; // 8 MiB — smaller than schema mode
32
39
  const INITIAL_OFFSETS_CAP = 4096;
33
40
  const U32_MAX = 4294967295;
@@ -38,6 +45,7 @@ const U32_MAX = 4294967295;
38
45
  const PRESERVE_WRITER_OPTS = {
39
46
  targetShardBytes: { t: 'int', min: 1, max: U32_MAX },
40
47
  maxRecordBytes: { t: 'int', min: 0, max: U32_MAX },
48
+ crc: { t: 'bool' },
41
49
  };
42
50
 
43
51
  export class PreserveWriter {
@@ -45,9 +53,16 @@ export class PreserveWriter {
45
53
  checkOpts('PreserveWriter', opts, PRESERVE_WRITER_OPTS, raisePreserveWriter);
46
54
  opts = opts || {};
47
55
  this._targetShardBytes = opts.targetShardBytes !== undefined ? opts.targetShardBytes : DEFAULT_TARGET_SHARD_BYTES;
56
+ this._crc = opts.crc === true;
48
57
  this._shards = [];
49
58
  this._totalRows = 0;
50
59
  this._finalized = false;
60
+ // Streaming-emission state (M6); see Writer for the model. A bound sink makes
61
+ // _finalizeCurrentShard emit + drop each shard, retaining only a descriptor.
62
+ this._sink = null;
63
+ this._sinkCrcOn = false;
64
+ this._sinkPos = 0;
65
+ this._sinkCrc = 0;
51
66
  this._currentBlobs = null; // null => never allocated; _allocateShard allocates once
52
67
  this._allocateShard();
53
68
  }
@@ -162,11 +177,15 @@ export class PreserveWriter {
162
177
  for (let i = 0; i < rowCount; i++) {
163
178
  dv.setUint32(blobLen + i * 4, this._currentOffsets[i], true);
164
179
  }
165
- this._shards.push({
166
- bytes: shardBytes,
167
- rowCount,
168
- blobLen,
169
- });
180
+ if (this._sink !== null) {
181
+ this._streamEmitShard(shardBytes, rowCount);
182
+ } else {
183
+ this._shards.push({
184
+ bytes: shardBytes,
185
+ rowCount,
186
+ blobLen,
187
+ });
188
+ }
170
189
  // Reuse the working buffers for the next shard: reset the cursors only, keep
171
190
  // the allocations (BS-27). No stale bytes can leak -- every read into these
172
191
  // buffers is bounded by _currentBlobBytes / _currentRowCount (both zeroed
@@ -175,87 +194,219 @@ export class PreserveWriter {
175
194
  this._currentRowCount = 0;
176
195
  }
177
196
 
178
- finalize() {
197
+ _completeInput() {
179
198
  if (this._finalized) throw new PreserveWriterError('W_FINALIZED', 'writer already finalized');
180
199
  if (this._currentRowCount > 0) this._finalizeCurrentShard();
181
200
  if (this._shards.length === 0) {
182
- // Emit an empty container? Or error? Match schema-mode: throw.
201
+ // Match schema-mode: an empty container is an error, not a silent no-op.
183
202
  throw new PreserveWriterError('W_EMPTY_INPUT', 'no records written; nothing to finalize');
184
203
  }
204
+ }
205
+
206
+ finalize() {
207
+ this._completeInput();
208
+ const bytes = this._buildPrefixBytes(this._crc);
185
209
  this._finalized = true;
186
- return this._assembleContainer();
210
+ return { buffer: bytes.buffer, totalRows: this._totalRows, shardCount: this._shards.length, mode: 'preserve' };
187
211
  }
188
212
 
189
213
  get totalRows() { return this._totalRows; }
190
214
  get shardCount() { return this._shards.length; }
191
215
 
192
- _assembleContainer() {
216
+ // -------- container assembly (placement-free emitters) --------
217
+ // Preserve mode has no schema block and no zone maps: schema_block_off and
218
+ // metadata_off are 0. Classic prefix layout is header | directory | payloads |
219
+ // footer; the streaming layout (O9) is header | payloads | directory | footer.
220
+
221
+ _emitHeaderInto(dv, bytes, off, shardDirOff, shardCount, totalRows) {
222
+ bytes[off + 0] = 0x4C; bytes[off + 1] = 0x42; bytes[off + 2] = 0x4B; bytes[off + 3] = 0x31; // 'LBK1'
223
+ dv.setUint16(off + 4, 1, true); // format_version
224
+ bytes[off + 6] = 1; // endian LE
225
+ bytes[off + 7] = 0x01; // flags: bit 0 = preserve mode
226
+ dv.setBigUint64(off + 8, 0n, true); // schema_block_off = 0
227
+ dv.setBigUint64(off + 16, 0n, true); // metadata_off = 0
228
+ dv.setBigUint64(off + 24, BigInt(shardDirOff), true);
229
+ dv.setUint32(off + 32, shardCount, true);
230
+ dv.setUint32(off + 36, 0, true); // reserved1
231
+ dv.setBigUint64(off + 40, BigInt(totalRows), true);
232
+ }
233
+
234
+ _emitDirEntryInto(dv, entryOff, payloadOff, payloadLen, rowCount) {
235
+ dv.setBigUint64(entryOff + 0, BigInt(payloadOff), true);
236
+ dv.setUint32(entryOff + 8, payloadLen, true);
237
+ dv.setUint32(entryOff + 12, rowCount, true);
238
+ dv.setUint16(entryOff + 16, 1, true); // min_reader_version
239
+ dv.setUint16(entryOff + 18, 0, true); // shard flags
240
+ dv.setUint32(entryOff + 20, 0, true); // reserved
241
+ dv.setBigUint64(entryOff + 24, 0n, true); // no string table
242
+ dv.setBigUint64(entryOff + 32, 0n, true);
243
+ }
244
+
245
+ _emitFooterInto(dv, bytes, footerOff, crcVal) {
246
+ dv.setUint32(footerOff + 0, crcVal >>> 0, true);
247
+ dv.setUint32(footerOff + 4, 0, true);
248
+ bytes[footerOff + 8] = 0x31; // '1'
249
+ bytes[footerOff + 9] = 0x4B; // 'K'
250
+ bytes[footerOff + 10] = 0x42; // 'B'
251
+ bytes[footerOff + 11] = 0x4C; // 'L'
252
+ dv.setUint32(footerOff + 12, FOOTER_BYTES, true);
253
+ }
254
+
255
+ // Classic prefix layout, byte-for-byte identical to the pre-M6 assembler when
256
+ // crc is off; the optional CRC-32C covers [0, footer_off).
257
+ _buildPrefixBytes(crcOn) {
193
258
  const shardCount = this._shards.length;
194
259
  const shardDirBytes = shardCount * SHARD_ENTRY_BYTES;
195
-
196
- // Layout: header (48) | shard directory | shard payloads | footer (16)
197
- // No schema block, no zone maps in preserve mode; schema_block_off = 0.
198
260
  const shardDirOff = CONTAINER_HEADER_BYTES;
199
261
  let cursor = shardDirOff + shardDirBytes;
200
- const shardPayloadOffsets = new Array(shardCount);
262
+ const payloadOffs = new Array(shardCount);
201
263
  for (let i = 0; i < shardCount; i++) {
202
- shardPayloadOffsets[i] = cursor;
264
+ payloadOffs[i] = cursor;
203
265
  cursor += this._shards[i].bytes.length;
204
266
  }
267
+ const footerOff = cursor;
205
268
  const totalBytes = cursor + FOOTER_BYTES;
269
+ const buffer = new ArrayBuffer(totalBytes);
270
+ const dv = new DataView(buffer);
271
+ const bytes = new Uint8Array(buffer);
206
272
 
207
- const container = new ArrayBuffer(totalBytes);
208
- const dv = new DataView(container);
209
- const bytes = new Uint8Array(container);
210
-
211
- // Header
212
- bytes[0] = 0x4C; bytes[1] = 0x42; bytes[2] = 0x4B; bytes[3] = 0x31; // 'LBK1'
213
- dv.setUint16(4, 1, true); // format_version
214
- bytes[6] = 1; // endian LE
215
- bytes[7] = 0x01; // flags: bit 0 = preserve mode
216
- dv.setBigUint64(8, 0n, true); // schema_block_off = 0 (no schema)
217
- dv.setBigUint64(16, 0n, true); // metadata_off = 0 (no zone maps)
218
- dv.setBigUint64(24, BigInt(shardDirOff), true);
219
- dv.setUint32(32, shardCount, true);
220
- dv.setUint32(36, 0, true); // reserved1
221
- dv.setBigUint64(40, BigInt(this._totalRows), true);
222
-
223
- // Shard directory (40 bytes per entry, u64 payload_off, u32 payload_len,
224
- // u32 row_count, u16 min_reader_version, u16 flags, u32 reserved,
225
- // u64 local_string_off=0, u64 local_string_len=0)
273
+ this._emitHeaderInto(dv, bytes, 0, shardDirOff, shardCount, this._totalRows);
226
274
  for (let i = 0; i < shardCount; i++) {
227
- const entryOff = shardDirOff + i * SHARD_ENTRY_BYTES;
228
275
  const s = this._shards[i];
229
- dv.setBigUint64(entryOff + 0, BigInt(shardPayloadOffsets[i]), true);
230
- dv.setUint32(entryOff + 8, s.bytes.length, true);
231
- dv.setUint32(entryOff + 12, s.rowCount, true);
232
- dv.setUint16(entryOff + 16, 1, true); // min_reader_version
233
- dv.setUint16(entryOff + 18, 0, true); // shard flags
234
- dv.setUint32(entryOff + 20, 0, true); // reserved
235
- dv.setBigUint64(entryOff + 24, 0n, true); // no string table
236
- dv.setBigUint64(entryOff + 32, 0n, true);
276
+ this._emitDirEntryInto(dv, shardDirOff + i * SHARD_ENTRY_BYTES, payloadOffs[i], s.bytes.length, s.rowCount);
277
+ }
278
+ for (let i = 0; i < shardCount; i++) bytes.set(this._shards[i].bytes, payloadOffs[i]);
279
+
280
+ let crcVal = CRC_ABSENT;
281
+ if (crcOn) crcVal = crc32cFinal(crc32cUpdate(crc32cInit(), bytes, 0, footerOff));
282
+ this._emitFooterInto(dv, bytes, footerOff, crcVal);
283
+ return bytes;
284
+ }
285
+
286
+ // PUBLIC. Bind a sink and stream shards as they finalize (bounded RAM). Call
287
+ // before feeding, then finalizeToSink(sink, opts) writes the trailer. Calling
288
+ // finalizeToSink alone is BUFFERED mode (O(container) peak). opts.layout must
289
+ // be 'stream'; opts.crc defaults to the constructor crc.
290
+ beginStream(sink, opts) {
291
+ if (this._finalized) throw new PreserveWriterError('W_FINALIZED', 'writer already finalized');
292
+ if (this._sink !== null) throw new PreserveWriterError('W_FINALIZED', 'writer is already streaming to a sink');
293
+ if (this._shards.length > 0 || this._currentRowCount > 0)
294
+ throw new PreserveWriterError('W_FINALIZED', 'beginStream must be called before the first record');
295
+ checkOpts('PreserveWriter.beginStream', opts, FINALIZE_TO_SINK_OPTS, raisePreserveWriter);
296
+ opts = opts || {};
297
+ if (opts.layout !== undefined && opts.layout !== 'stream')
298
+ raisePreserveWriter('W_BAD_SINK', "beginStream requires layout:'stream'");
299
+ validateSink(sink, true, raisePreserveWriter);
300
+ this._sink = sink;
301
+ this._sinkCrcOn = opts.crc !== undefined ? opts.crc === true : this._crc;
302
+ this._sinkCrc = crc32cInit();
303
+ this._sinkWrite(new Uint8Array(CONTAINER_HEADER_BYTES), false);
304
+ this._sinkPos = CONTAINER_HEADER_BYTES;
305
+ }
306
+
307
+ _sinkWrite(bytes, fold) {
308
+ if (fold && this._sinkCrcOn) this._sinkCrc = crc32cUpdate(this._sinkCrc, bytes, 0, bytes.length);
309
+ let ret;
310
+ // A throwing sink fails the writer closed and rethrows verbatim; retry -> W_FINALIZED.
311
+ try { ret = this._sink.write(bytes); }
312
+ catch (e) { this._finalized = true; throw e; }
313
+ if (isThenable(ret)) { this._finalized = true; raisePreserveWriter('W_BAD_SINK', 'sink.write returned a thenable; sinks must be synchronous'); }
314
+ }
315
+
316
+ // Preserve payloads carry an internal offset table and are NOT 8-padded (the
317
+ // classic layout packs them back-to-back), so the streaming layout does the
318
+ // same: no inter-shard padding.
319
+ _streamEmitShard(shardBytes, rowCount) {
320
+ const payloadOff = this._sinkPos;
321
+ const payloadLen = shardBytes.length;
322
+ this._sinkWrite(shardBytes, true);
323
+ this._sinkPos += payloadLen;
324
+ this._shards.push({ rowCount, payloadOff, payloadLen });
325
+ }
326
+
327
+ // PUBLIC. Emit to a caller sink. Returns { totalRows, shardCount, mode,
328
+ // bytesWritten, layout }.
329
+ finalizeToSink(sink, opts) {
330
+ if (this._finalized) throw new PreserveWriterError('W_FINALIZED', 'writer already finalized');
331
+ checkOpts('PreserveWriter.finalizeToSink', opts, FINALIZE_TO_SINK_OPTS, raisePreserveWriter);
332
+ opts = opts || {};
333
+
334
+ if (this._sink !== null) {
335
+ if (sink !== this._sink) raisePreserveWriter('W_BAD_SINK', 'finalizeToSink sink differs from the streaming sink');
336
+ if (opts.layout !== undefined && opts.layout !== 'stream')
337
+ raisePreserveWriter('W_BAD_SINK', "a streaming writer must be finalized with layout:'stream'");
338
+ const crcOn = opts.crc !== undefined ? opts.crc === true : this._sinkCrcOn;
339
+ this._sinkCrcOn = crcOn;
340
+ this._completeInput();
341
+ return this._finishStream(sink, crcOn);
342
+ }
343
+
344
+ const layout = opts.layout !== undefined ? opts.layout : 'stream';
345
+ const crcOn = opts.crc !== undefined ? opts.crc === true : this._crc;
346
+ validateSink(sink, layout === 'stream', raisePreserveWriter);
347
+ this._completeInput();
348
+
349
+ if (layout === 'prefix') {
350
+ const bytes = this._buildPrefixBytes(crcOn);
351
+ let ret;
352
+ try { ret = sink.write(bytes); }
353
+ catch (e) { this._finalized = true; throw e; }
354
+ if (isThenable(ret)) { this._finalized = true; raisePreserveWriter('W_BAD_SINK', 'sink.write returned a thenable; sinks must be synchronous'); }
355
+ this._finalized = true;
356
+ return { totalRows: this._totalRows, shardCount: this._shards.length, mode: 'preserve', bytesWritten: bytes.length, layout: 'prefix' };
357
+ }
358
+
359
+ const buffered = this._shards;
360
+ this._shards = [];
361
+ this._sink = sink;
362
+ this._sinkCrcOn = crcOn;
363
+ this._sinkCrc = crc32cInit();
364
+ this._sinkWrite(new Uint8Array(CONTAINER_HEADER_BYTES), false);
365
+ this._sinkPos = CONTAINER_HEADER_BYTES;
366
+ for (let i = 0; i < buffered.length; i++) {
367
+ this._streamEmitShard(buffered[i].bytes, buffered[i].rowCount);
368
+ buffered[i] = null;
237
369
  }
370
+ return this._finishStream(sink, crcOn);
371
+ }
372
+
373
+ _finishStream(sink, crcOn) {
374
+ const shardCount = this._shards.length;
375
+ const shardDirBytes = shardCount * SHARD_ENTRY_BYTES;
376
+ const shardDirOff = this._sinkPos; // directory follows the payloads (O9)
377
+ const footerOff = shardDirOff + shardDirBytes;
238
378
 
239
- // Shard payloads
379
+ const dir = new Uint8Array(shardDirBytes);
380
+ const ddv = new DataView(dir.buffer);
240
381
  for (let i = 0; i < shardCount; i++) {
241
- bytes.set(this._shards[i].bytes, shardPayloadOffsets[i]);
382
+ const d = this._shards[i];
383
+ this._emitDirEntryInto(ddv, i * SHARD_ENTRY_BYTES, d.payloadOff, d.payloadLen, d.rowCount);
242
384
  }
385
+ this._sinkWrite(dir, true);
386
+ this._sinkPos += shardDirBytes;
243
387
 
244
- // Footer
245
- const footerOff = totalBytes - FOOTER_BYTES;
246
- dv.setUint32(footerOff + 0, 0xFFFFFFFF, true); // CRC absent
247
- dv.setUint32(footerOff + 4, 0, true);
248
- bytes[footerOff + 8] = 0x31; // '1'
249
- bytes[footerOff + 9] = 0x4B; // 'K'
250
- bytes[footerOff + 10] = 0x42; // 'B'
251
- bytes[footerOff + 11] = 0x4C; // 'L'
252
- dv.setUint32(footerOff + 12, FOOTER_BYTES, true);
388
+ const header = new Uint8Array(CONTAINER_HEADER_BYTES);
389
+ const hdv = new DataView(header.buffer);
390
+ this._emitHeaderInto(hdv, header, 0, shardDirOff, shardCount, this._totalRows);
253
391
 
254
- return {
255
- buffer: container,
256
- totalRows: this._totalRows,
257
- shardCount,
258
- mode: 'preserve',
259
- };
392
+ let crcVal = CRC_ABSENT;
393
+ if (crcOn) {
394
+ const headerCrc = crc32cFinal(crc32cUpdate(crc32cInit(), header, 0, CONTAINER_HEADER_BYTES));
395
+ const suffixCrc = crc32cFinal(this._sinkCrc);
396
+ crcVal = crc32cCombine(headerCrc, suffixCrc, footerOff - CONTAINER_HEADER_BYTES);
397
+ }
398
+ const footer = new Uint8Array(FOOTER_BYTES);
399
+ const fdv = new DataView(footer.buffer);
400
+ this._emitFooterInto(fdv, footer, 0, crcVal);
401
+ this._sinkWrite(footer, false);
402
+ this._sinkPos += FOOTER_BYTES;
403
+
404
+ let ret;
405
+ try { ret = sink.writeAt(header, 0); }
406
+ catch (e) { this._finalized = true; throw e; }
407
+ if (isThenable(ret)) { this._finalized = true; raisePreserveWriter('W_BAD_SINK', 'sink.writeAt returned a thenable; sinks must be synchronous'); }
408
+
409
+ this._finalized = true;
410
+ return { totalRows: this._totalRows, shardCount, mode: 'preserve', bytesWritten: this._sinkPos, layout: 'stream' };
260
411
  }
261
412
  }