@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.
@@ -1,6 +1,12 @@
1
1
  // @zakkster/lite-bake-stream / PreserveWriter
2
2
  // Copyright (c) 2026 Zahary Shinikchiev. MIT.
3
3
  //
4
+ // Error codes:
5
+ // W_EMPTY_INPUT - finalize with no records written
6
+ // W_BAD_SINK - sink is not an object / missing write / async return
7
+ // W_FINALIZED - write after the writer was finalized
8
+ // ST_BLOB_OVERFLOW - a shard payload_len would exceed the u32 ceiling
9
+ //
4
10
  // Preserve-mode sink: opaque record blobs packed into shards, with a trailing
5
11
  // u32 offset table per shard. No schema, no lanes, no string table, no zone
6
12
  // maps. Bytes in, same bytes out.
@@ -17,8 +23,10 @@
17
23
  // container with R_WRONG_MODE.
18
24
 
19
25
  import { checkOpts } from './Opts.js';
26
+ import { crc32cInit, crc32cUpdate, crc32cFinal, crc32cCombine } from './Crc32c.js';
27
+ import { validateSink, isThenable } from './Views.js';
20
28
 
21
- export const VERSION = '1.5.0';
29
+ export const VERSION = '1.6.1';
22
30
 
23
31
  export class PreserveWriterError extends Error {
24
32
  constructor(code, msg) { super(msg); this.code = code; this.name = 'PreserveWriterError'; }
@@ -28,7 +36,12 @@ function raisePreserveWriter(code, msg) { throw new PreserveWriterError(code, ms
28
36
  const CONTAINER_HEADER_BYTES = 48;
29
37
  const SHARD_ENTRY_BYTES = 40;
30
38
  const FOOTER_BYTES = 16;
31
- const DEFAULT_TARGET_SHARD_BYTES = 8 * 1024 * 1024; // 8 MiB — smaller than schema mode
39
+ const CRC_ABSENT = 0xFFFFFFFF;
40
+ const FINALIZE_TO_SINK_OPTS = {
41
+ layout: { t: 'enum', values: ['prefix', 'stream'] },
42
+ crc: { t: 'bool' },
43
+ };
44
+ const DEFAULT_TARGET_SHARD_BYTES = 8 * 1024 * 1024; // 8 MiB -- smaller than schema mode
32
45
  const INITIAL_OFFSETS_CAP = 4096;
33
46
  const U32_MAX = 4294967295;
34
47
  // maxRecordBytes is a declared, validated, deliberately-unused key here: the
@@ -38,6 +51,7 @@ const U32_MAX = 4294967295;
38
51
  const PRESERVE_WRITER_OPTS = {
39
52
  targetShardBytes: { t: 'int', min: 1, max: U32_MAX },
40
53
  maxRecordBytes: { t: 'int', min: 0, max: U32_MAX },
54
+ crc: { t: 'bool' },
41
55
  };
42
56
 
43
57
  export class PreserveWriter {
@@ -45,9 +59,16 @@ export class PreserveWriter {
45
59
  checkOpts('PreserveWriter', opts, PRESERVE_WRITER_OPTS, raisePreserveWriter);
46
60
  opts = opts || {};
47
61
  this._targetShardBytes = opts.targetShardBytes !== undefined ? opts.targetShardBytes : DEFAULT_TARGET_SHARD_BYTES;
62
+ this._crc = opts.crc === true;
48
63
  this._shards = [];
49
64
  this._totalRows = 0;
50
65
  this._finalized = false;
66
+ // Streaming-emission state (M6); see Writer for the model. A bound sink makes
67
+ // _finalizeCurrentShard emit + drop each shard, retaining only a descriptor.
68
+ this._sink = null;
69
+ this._sinkCrcOn = false;
70
+ this._sinkPos = 0;
71
+ this._sinkCrc = 0;
51
72
  this._currentBlobs = null; // null => never allocated; _allocateShard allocates once
52
73
  this._allocateShard();
53
74
  }
@@ -162,11 +183,15 @@ export class PreserveWriter {
162
183
  for (let i = 0; i < rowCount; i++) {
163
184
  dv.setUint32(blobLen + i * 4, this._currentOffsets[i], true);
164
185
  }
165
- this._shards.push({
166
- bytes: shardBytes,
167
- rowCount,
168
- blobLen,
169
- });
186
+ if (this._sink !== null) {
187
+ this._streamEmitShard(shardBytes, rowCount);
188
+ } else {
189
+ this._shards.push({
190
+ bytes: shardBytes,
191
+ rowCount,
192
+ blobLen,
193
+ });
194
+ }
170
195
  // Reuse the working buffers for the next shard: reset the cursors only, keep
171
196
  // the allocations (BS-27). No stale bytes can leak -- every read into these
172
197
  // buffers is bounded by _currentBlobBytes / _currentRowCount (both zeroed
@@ -175,87 +200,219 @@ export class PreserveWriter {
175
200
  this._currentRowCount = 0;
176
201
  }
177
202
 
178
- finalize() {
203
+ _completeInput() {
179
204
  if (this._finalized) throw new PreserveWriterError('W_FINALIZED', 'writer already finalized');
180
205
  if (this._currentRowCount > 0) this._finalizeCurrentShard();
181
206
  if (this._shards.length === 0) {
182
- // Emit an empty container? Or error? Match schema-mode: throw.
207
+ // Match schema-mode: an empty container is an error, not a silent no-op.
183
208
  throw new PreserveWriterError('W_EMPTY_INPUT', 'no records written; nothing to finalize');
184
209
  }
210
+ }
211
+
212
+ finalize() {
213
+ this._completeInput();
214
+ const bytes = this._buildPrefixBytes(this._crc);
185
215
  this._finalized = true;
186
- return this._assembleContainer();
216
+ return { buffer: bytes.buffer, totalRows: this._totalRows, shardCount: this._shards.length, mode: 'preserve' };
187
217
  }
188
218
 
189
219
  get totalRows() { return this._totalRows; }
190
220
  get shardCount() { return this._shards.length; }
191
221
 
192
- _assembleContainer() {
222
+ // -------- container assembly (placement-free emitters) --------
223
+ // Preserve mode has no schema block and no zone maps: schema_block_off and
224
+ // metadata_off are 0. Classic prefix layout is header | directory | payloads |
225
+ // footer; the streaming layout (O9) is header | payloads | directory | footer.
226
+
227
+ _emitHeaderInto(dv, bytes, off, shardDirOff, shardCount, totalRows) {
228
+ bytes[off + 0] = 0x4C; bytes[off + 1] = 0x42; bytes[off + 2] = 0x4B; bytes[off + 3] = 0x31; // 'LBK1'
229
+ dv.setUint16(off + 4, 1, true); // format_version
230
+ bytes[off + 6] = 1; // endian LE
231
+ bytes[off + 7] = 0x01; // flags: bit 0 = preserve mode
232
+ dv.setBigUint64(off + 8, 0n, true); // schema_block_off = 0
233
+ dv.setBigUint64(off + 16, 0n, true); // metadata_off = 0
234
+ dv.setBigUint64(off + 24, BigInt(shardDirOff), true);
235
+ dv.setUint32(off + 32, shardCount, true);
236
+ dv.setUint32(off + 36, 0, true); // reserved1
237
+ dv.setBigUint64(off + 40, BigInt(totalRows), true);
238
+ }
239
+
240
+ _emitDirEntryInto(dv, entryOff, payloadOff, payloadLen, rowCount) {
241
+ dv.setBigUint64(entryOff + 0, BigInt(payloadOff), true);
242
+ dv.setUint32(entryOff + 8, payloadLen, true);
243
+ dv.setUint32(entryOff + 12, rowCount, true);
244
+ dv.setUint16(entryOff + 16, 1, true); // min_reader_version
245
+ dv.setUint16(entryOff + 18, 0, true); // shard flags
246
+ dv.setUint32(entryOff + 20, 0, true); // reserved
247
+ dv.setBigUint64(entryOff + 24, 0n, true); // no string table
248
+ dv.setBigUint64(entryOff + 32, 0n, true);
249
+ }
250
+
251
+ _emitFooterInto(dv, bytes, footerOff, crcVal) {
252
+ dv.setUint32(footerOff + 0, crcVal >>> 0, true);
253
+ dv.setUint32(footerOff + 4, 0, true);
254
+ bytes[footerOff + 8] = 0x31; // '1'
255
+ bytes[footerOff + 9] = 0x4B; // 'K'
256
+ bytes[footerOff + 10] = 0x42; // 'B'
257
+ bytes[footerOff + 11] = 0x4C; // 'L'
258
+ dv.setUint32(footerOff + 12, FOOTER_BYTES, true);
259
+ }
260
+
261
+ // Classic prefix layout, byte-for-byte identical to the pre-M6 assembler when
262
+ // crc is off; the optional CRC-32C covers [0, footer_off).
263
+ _buildPrefixBytes(crcOn) {
193
264
  const shardCount = this._shards.length;
194
265
  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
266
  const shardDirOff = CONTAINER_HEADER_BYTES;
199
267
  let cursor = shardDirOff + shardDirBytes;
200
- const shardPayloadOffsets = new Array(shardCount);
268
+ const payloadOffs = new Array(shardCount);
201
269
  for (let i = 0; i < shardCount; i++) {
202
- shardPayloadOffsets[i] = cursor;
270
+ payloadOffs[i] = cursor;
203
271
  cursor += this._shards[i].bytes.length;
204
272
  }
273
+ const footerOff = cursor;
205
274
  const totalBytes = cursor + FOOTER_BYTES;
275
+ const buffer = new ArrayBuffer(totalBytes);
276
+ const dv = new DataView(buffer);
277
+ const bytes = new Uint8Array(buffer);
206
278
 
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)
279
+ this._emitHeaderInto(dv, bytes, 0, shardDirOff, shardCount, this._totalRows);
226
280
  for (let i = 0; i < shardCount; i++) {
227
- const entryOff = shardDirOff + i * SHARD_ENTRY_BYTES;
228
281
  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);
282
+ this._emitDirEntryInto(dv, shardDirOff + i * SHARD_ENTRY_BYTES, payloadOffs[i], s.bytes.length, s.rowCount);
237
283
  }
284
+ for (let i = 0; i < shardCount; i++) bytes.set(this._shards[i].bytes, payloadOffs[i]);
238
285
 
239
- // Shard payloads
286
+ let crcVal = CRC_ABSENT;
287
+ if (crcOn) crcVal = crc32cFinal(crc32cUpdate(crc32cInit(), bytes, 0, footerOff));
288
+ this._emitFooterInto(dv, bytes, footerOff, crcVal);
289
+ return bytes;
290
+ }
291
+
292
+ // PUBLIC. Bind a sink and stream shards as they finalize (bounded RAM). Call
293
+ // before feeding, then finalizeToSink(sink, opts) writes the trailer. Calling
294
+ // finalizeToSink alone is BUFFERED mode (O(container) peak). opts.layout must
295
+ // be 'stream'; opts.crc defaults to the constructor crc.
296
+ beginStream(sink, opts) {
297
+ if (this._finalized) throw new PreserveWriterError('W_FINALIZED', 'writer already finalized');
298
+ if (this._sink !== null) throw new PreserveWriterError('W_FINALIZED', 'writer is already streaming to a sink');
299
+ if (this._shards.length > 0 || this._currentRowCount > 0)
300
+ throw new PreserveWriterError('W_FINALIZED', 'beginStream must be called before the first record');
301
+ checkOpts('PreserveWriter.beginStream', opts, FINALIZE_TO_SINK_OPTS, raisePreserveWriter);
302
+ opts = opts || {};
303
+ if (opts.layout !== undefined && opts.layout !== 'stream')
304
+ raisePreserveWriter('W_BAD_SINK', "beginStream requires layout:'stream'");
305
+ validateSink(sink, true, raisePreserveWriter);
306
+ this._sink = sink;
307
+ this._sinkCrcOn = opts.crc !== undefined ? opts.crc === true : this._crc;
308
+ this._sinkCrc = crc32cInit();
309
+ this._sinkWrite(new Uint8Array(CONTAINER_HEADER_BYTES), false);
310
+ this._sinkPos = CONTAINER_HEADER_BYTES;
311
+ }
312
+
313
+ _sinkWrite(bytes, fold) {
314
+ if (fold && this._sinkCrcOn) this._sinkCrc = crc32cUpdate(this._sinkCrc, bytes, 0, bytes.length);
315
+ let ret;
316
+ // A throwing sink fails the writer closed and rethrows verbatim; retry -> W_FINALIZED.
317
+ try { ret = this._sink.write(bytes); }
318
+ catch (e) { this._finalized = true; throw e; }
319
+ if (isThenable(ret)) { this._finalized = true; raisePreserveWriter('W_BAD_SINK', 'sink.write returned a thenable; sinks must be synchronous'); }
320
+ }
321
+
322
+ // Preserve payloads carry an internal offset table and are NOT 8-padded (the
323
+ // classic layout packs them back-to-back), so the streaming layout does the
324
+ // same: no inter-shard padding.
325
+ _streamEmitShard(shardBytes, rowCount) {
326
+ const payloadOff = this._sinkPos;
327
+ const payloadLen = shardBytes.length;
328
+ this._sinkWrite(shardBytes, true);
329
+ this._sinkPos += payloadLen;
330
+ this._shards.push({ rowCount, payloadOff, payloadLen });
331
+ }
332
+
333
+ // PUBLIC. Emit to a caller sink. Returns { totalRows, shardCount, mode,
334
+ // bytesWritten, layout }.
335
+ finalizeToSink(sink, opts) {
336
+ if (this._finalized) throw new PreserveWriterError('W_FINALIZED', 'writer already finalized');
337
+ checkOpts('PreserveWriter.finalizeToSink', opts, FINALIZE_TO_SINK_OPTS, raisePreserveWriter);
338
+ opts = opts || {};
339
+
340
+ if (this._sink !== null) {
341
+ if (sink !== this._sink) raisePreserveWriter('W_BAD_SINK', 'finalizeToSink sink differs from the streaming sink');
342
+ if (opts.layout !== undefined && opts.layout !== 'stream')
343
+ raisePreserveWriter('W_BAD_SINK', "a streaming writer must be finalized with layout:'stream'");
344
+ const crcOn = opts.crc !== undefined ? opts.crc === true : this._sinkCrcOn;
345
+ this._sinkCrcOn = crcOn;
346
+ this._completeInput();
347
+ return this._finishStream(sink, crcOn);
348
+ }
349
+
350
+ const layout = opts.layout !== undefined ? opts.layout : 'stream';
351
+ const crcOn = opts.crc !== undefined ? opts.crc === true : this._crc;
352
+ validateSink(sink, layout === 'stream', raisePreserveWriter);
353
+ this._completeInput();
354
+
355
+ if (layout === 'prefix') {
356
+ const bytes = this._buildPrefixBytes(crcOn);
357
+ let ret;
358
+ try { ret = sink.write(bytes); }
359
+ catch (e) { this._finalized = true; throw e; }
360
+ if (isThenable(ret)) { this._finalized = true; raisePreserveWriter('W_BAD_SINK', 'sink.write returned a thenable; sinks must be synchronous'); }
361
+ this._finalized = true;
362
+ return { totalRows: this._totalRows, shardCount: this._shards.length, mode: 'preserve', bytesWritten: bytes.length, layout: 'prefix' };
363
+ }
364
+
365
+ const buffered = this._shards;
366
+ this._shards = [];
367
+ this._sink = sink;
368
+ this._sinkCrcOn = crcOn;
369
+ this._sinkCrc = crc32cInit();
370
+ this._sinkWrite(new Uint8Array(CONTAINER_HEADER_BYTES), false);
371
+ this._sinkPos = CONTAINER_HEADER_BYTES;
372
+ for (let i = 0; i < buffered.length; i++) {
373
+ this._streamEmitShard(buffered[i].bytes, buffered[i].rowCount);
374
+ buffered[i] = null;
375
+ }
376
+ return this._finishStream(sink, crcOn);
377
+ }
378
+
379
+ _finishStream(sink, crcOn) {
380
+ const shardCount = this._shards.length;
381
+ const shardDirBytes = shardCount * SHARD_ENTRY_BYTES;
382
+ const shardDirOff = this._sinkPos; // directory follows the payloads (O9)
383
+ const footerOff = shardDirOff + shardDirBytes;
384
+
385
+ const dir = new Uint8Array(shardDirBytes);
386
+ const ddv = new DataView(dir.buffer);
240
387
  for (let i = 0; i < shardCount; i++) {
241
- bytes.set(this._shards[i].bytes, shardPayloadOffsets[i]);
388
+ const d = this._shards[i];
389
+ this._emitDirEntryInto(ddv, i * SHARD_ENTRY_BYTES, d.payloadOff, d.payloadLen, d.rowCount);
242
390
  }
391
+ this._sinkWrite(dir, true);
392
+ this._sinkPos += shardDirBytes;
243
393
 
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);
394
+ const header = new Uint8Array(CONTAINER_HEADER_BYTES);
395
+ const hdv = new DataView(header.buffer);
396
+ this._emitHeaderInto(hdv, header, 0, shardDirOff, shardCount, this._totalRows);
253
397
 
254
- return {
255
- buffer: container,
256
- totalRows: this._totalRows,
257
- shardCount,
258
- mode: 'preserve',
259
- };
398
+ let crcVal = CRC_ABSENT;
399
+ if (crcOn) {
400
+ const headerCrc = crc32cFinal(crc32cUpdate(crc32cInit(), header, 0, CONTAINER_HEADER_BYTES));
401
+ const suffixCrc = crc32cFinal(this._sinkCrc);
402
+ crcVal = crc32cCombine(headerCrc, suffixCrc, footerOff - CONTAINER_HEADER_BYTES);
403
+ }
404
+ const footer = new Uint8Array(FOOTER_BYTES);
405
+ const fdv = new DataView(footer.buffer);
406
+ this._emitFooterInto(fdv, footer, 0, crcVal);
407
+ this._sinkWrite(footer, false);
408
+ this._sinkPos += FOOTER_BYTES;
409
+
410
+ let ret;
411
+ try { ret = sink.writeAt(header, 0); }
412
+ catch (e) { this._finalized = true; throw e; }
413
+ if (isThenable(ret)) { this._finalized = true; raisePreserveWriter('W_BAD_SINK', 'sink.writeAt returned a thenable; sinks must be synchronous'); }
414
+
415
+ this._finalized = true;
416
+ return { totalRows: this._totalRows, shardCount, mode: 'preserve', bytesWritten: this._sinkPos, layout: 'stream' };
260
417
  }
261
418
  }
@@ -33,6 +33,8 @@
33
33
  // R_ADAPTER_SHORT_READ -- adapter returned fewer bytes than requested, OR a
34
34
  // non-Uint8Array / wrong-length adapter return (T7c)
35
35
  // R_ROW_OUT_OF_RANGE -- rowIdx negative, fractional, NaN, or >= totalRows (BS-32)
36
+ // R_BAD_CRC -- CRC-32C verify requested and the stored checksum mismatched
37
+ // R_CRC_ABSENT -- CRC verify requested but the container carries no checksum
36
38
  // R_OFFSET_TOO_LARGE -- a u64 header/schema/directory offset exceeds 2^53-1
37
39
  // R_NOT_PREFETCHED -- syncRange over a shard that prefetchRange has not cached
38
40
  // R_WRONG_MODE -- preserve-mode container fed to the schema RangeReader
@@ -44,8 +46,9 @@
44
46
 
45
47
  import { StringTable } from './StringTable.js';
46
48
  import { checkOpts } from './Opts.js';
49
+ import { crc32cInit, crc32cUpdate, crc32cFinal } from './Crc32c.js';
47
50
 
48
- export const VERSION = '1.5.0';
51
+ export const VERSION = '1.6.1';
49
52
 
50
53
  const CONTAINER_HEADER_BYTES = 48;
51
54
  const SHARD_ENTRY_BYTES = 40;
@@ -56,10 +59,14 @@ const LANE_U32 = 3;
56
59
  const READER_VERSION = 1;
57
60
  const U32_MAX = 4294967295;
58
61
 
59
- const RANGE_READER_OPTS = { maxCachedShards: { t: 'int', min: 0, max: U32_MAX } };
62
+ const RANGE_READER_OPTS = {
63
+ maxCachedShards: { t: 'int', min: 0, max: U32_MAX },
64
+ verifyCrc: { t: 'bool' },
65
+ };
60
66
  const HTTP_ADAPTER_OPTS = { fetch: { t: 'fn' } };
61
67
 
62
68
  const CONTAINER_FOOTER_BYTES = 16;
69
+ const CRC_ABSENT = 0xFFFFFFFF;
63
70
 
64
71
  function laneBytesOf(k) { return k === LANE_F64 ? 8 : (k === LANE_U32 ? 4 : 0); }
65
72
 
@@ -201,6 +208,11 @@ export class RangeReader {
201
208
  static async open(adapter, opts) {
202
209
  const r = new RangeReader(adapter, opts);
203
210
  await r._loadHeaderAndSchema();
211
+ if (opts && opts.verifyCrc === true) {
212
+ const status = await r.verifyCrc();
213
+ if (status === 'absent')
214
+ throw new RangeReaderError('R_CRC_ABSENT', 'verifyCrc:true but the container carries no CRC (footer CRC is absent, 0xFFFFFFFF)');
215
+ }
204
216
  return r;
205
217
  }
206
218
 
@@ -209,6 +221,7 @@ export class RangeReader {
209
221
  opts = opts || {};
210
222
  this.adapter = adapter;
211
223
  this.maxCachedShards = opts.maxCachedShards !== undefined ? opts.maxCachedShards : 8;
224
+ this._footerCrc = CRC_ABSENT;
212
225
  // shard cache: shardIdx -> { payloadBytes, payloadDv, stringTable, lastAccess }
213
226
  this._shardCache = new Map();
214
227
  this._accessCounter = 0;
@@ -298,6 +311,20 @@ export class RangeReader {
298
311
  throw new RangeReaderError('R_BAD_FOOTER', 'footer_len ' + footerLen + ' is less than the minimum ' + CONTAINER_FOOTER_BYTES);
299
312
  if (footerLen > size - CONTAINER_HEADER_BYTES)
300
313
  throw new RangeReaderError('R_BAD_FOOTER', 'footer_len ' + footerLen + ' exceeds the container body size');
314
+ this._footerCrc = fDv.getUint32(0, true) >>> 0;
315
+ }
316
+
317
+ // SPEC 3.7 integrity over a ranged source: 'ok' | 'absent'; a mismatch throws
318
+ // R_BAD_CRC. Fetches the body [0, footer_off) in one range (a full-integrity
319
+ // check is inherently whole-body). The footer CRC was captured at open.
320
+ async verifyCrc() {
321
+ if (this._footerCrc === CRC_ABSENT) return 'absent';
322
+ const footerOff = this.adapter.size - CONTAINER_FOOTER_BYTES;
323
+ const body = await this._fetchExact(0, footerOff);
324
+ const actual = crc32cFinal(crc32cUpdate(crc32cInit(), body, 0, footerOff));
325
+ if (actual !== this._footerCrc)
326
+ throw new RangeReaderError('R_BAD_CRC', 'container CRC mismatch: stored 0x' + this._footerCrc.toString(16) + ' != computed 0x' + actual.toString(16));
327
+ return 'ok';
301
328
  }
302
329
 
303
330
  async _loadZoneMaps() {
package/src/Reader.js CHANGED
@@ -25,6 +25,8 @@
25
25
  // (Number() would lose precision; fail closed, BS-05)
26
26
  // R_ROW_OUT_OF_RANGE - get(rowIdx) with rowIdx negative, fractional, NaN,
27
27
  // or >= totalRows (BS-32)
28
+ // R_BAD_CRC - CRC-32C verify requested and the stored checksum mismatched
29
+ // R_CRC_ABSENT - CRC verify requested but the container carries no checksum
28
30
  //
29
31
  // Row-index policy (BS-32): get() range-checks rowIdx; the SHARD-index escape
30
32
  // hatches (shardPayload, shardF64, shardStringTable) do NOT -- their contract is
@@ -33,13 +35,19 @@
33
35
 
34
36
  import { StringTable } from './StringTable.js';
35
37
  import { toContainerBuffer } from './Views.js';
38
+ import { checkOpts } from './Opts.js';
39
+ import { crc32cInit, crc32cUpdate, crc32cFinal } from './Crc32c.js';
36
40
 
37
- export const VERSION = '1.5.0';
41
+ export const VERSION = '1.6.1';
38
42
 
39
43
  const CONTAINER_HEADER_BYTES = 48;
40
44
  const SHARD_ENTRY_BYTES = 40;
41
45
  const FIELD_DESCRIPTOR_BYTES = 24;
42
46
  const FOOTER_BYTES = 16;
47
+ const CRC_ABSENT = 0xFFFFFFFF;
48
+
49
+ const READER_OPTS = { verifyCrc: { t: 'bool' } };
50
+ function raiseReaderOpt(code, msg) { throw new ReaderError(code, msg); }
43
51
 
44
52
  const LANE_F64 = 1;
45
53
  const LANE_U32 = 3;
@@ -93,11 +101,12 @@ function validateStringTable(bytes, off, len, label) {
93
101
  }
94
102
 
95
103
  export class Reader {
96
- static fromBuffer(input) {
97
- return new Reader(toContainerBuffer(input, 'Reader.fromBuffer'));
104
+ static fromBuffer(input, opts) {
105
+ return new Reader(toContainerBuffer(input, 'Reader.fromBuffer'), opts);
98
106
  }
99
107
 
100
- constructor(buffer) {
108
+ constructor(buffer, opts) {
109
+ checkOpts('Reader', opts, READER_OPTS, raiseReaderOpt);
101
110
  this._buffer = buffer;
102
111
  this._dv = new DataView(buffer);
103
112
  this._bytes = new Uint8Array(buffer);
@@ -107,6 +116,27 @@ export class Reader {
107
116
  this._parseShardDirectory();
108
117
  this._parseZoneMaps(); // M7 -- no-op if metadata_off is 0
109
118
  this._buildFieldIndex();
119
+ // Optional open-time verification (fail closed on BOTH mismatch AND absence:
120
+ // the caller demanded verification, so an unverifiable container is an
121
+ // unverified state -- null is not zero).
122
+ if (opts && opts.verifyCrc === true) {
123
+ const status = this.verifyCrc();
124
+ if (status === 'absent')
125
+ throw new ReaderError('R_CRC_ABSENT', 'verifyCrc:true but the container carries no CRC (footer CRC is absent, 0xFFFFFFFF)');
126
+ }
127
+ }
128
+
129
+ // SPEC 3.7 integrity: recompute CRC-32C over [0, footer_off) and compare to the
130
+ // stored footer CRC. Returns 'ok' when they match, 'absent' when the container
131
+ // carries no CRC (footer CRC == 0xFFFFFFFF). A MISMATCH throws R_BAD_CRC.
132
+ verifyCrc() {
133
+ const footerOff = this._buffer.byteLength - FOOTER_BYTES;
134
+ const stored = this._dv.getUint32(footerOff, true) >>> 0;
135
+ if (stored === CRC_ABSENT) return 'absent';
136
+ const actual = crc32cFinal(crc32cUpdate(crc32cInit(), this._bytes, 0, footerOff));
137
+ if (actual !== stored)
138
+ throw new ReaderError('R_BAD_CRC', 'container CRC mismatch: stored 0x' + stored.toString(16) + ' != computed 0x' + actual.toString(16));
139
+ return 'ok';
110
140
  }
111
141
 
112
142
  _parseHeader() {
package/src/Split.js CHANGED
@@ -2,6 +2,10 @@
2
2
  // Splitters + per-part compilation + container merge for parallel/checkpointed ingest.
3
3
  // Copyright (c) 2026 Zahary Shinikchiev. MIT.
4
4
  //
5
+ // Error codes:
6
+ // S_MERGE_EMPTY - mergeContainers called with no inputs
7
+ // S_SCHEMA_MISMATCH - parts carry incompatible schemas
8
+ //
5
9
  // M6 covers three primitives that together enable worker-parallel or checkpoint-
6
10
  // resumable ingest against LBK1:
7
11
  //
@@ -36,8 +40,9 @@ import { Writer, WriterError } from './Writer.js';
36
40
  import { Reader, ReaderError } from './Reader.js';
37
41
  import { StringTable } from './StringTable.js';
38
42
  import { checkOpts } from './Opts.js';
43
+ import { crc32cInit, crc32cUpdate, crc32cFinal } from './Crc32c.js';
39
44
 
40
- export const VERSION = '1.5.0';
45
+ export const VERSION = '1.6.1';
41
46
 
42
47
  const LF = 0x0A;
43
48
  const CONTAINER_HEADER_BYTES = 48;
@@ -142,7 +147,7 @@ export function compilePart(bytes, opts) {
142
147
  // ---------- compileInParts ----------
143
148
 
144
149
  // Sequential convenience: split then compile each part serially.
145
- // Returns Uint8Array[] one container per part. Equivalent output to running
150
+ // Returns Uint8Array[] -- one container per part. Equivalent output to running
146
151
  // each part through a worker (or any parallel executor); use this when workers
147
152
  // aren't available or for testing.
148
153
  export function compileInParts(bytes, opts) {
@@ -236,7 +241,7 @@ export function mergeContainers(containers) {
236
241
  const outShardDirBytes = totalShards * SHARD_ENTRY_BYTES;
237
242
 
238
243
  // Zone maps: emit iff container 0 has them AND all others do too.
239
- // (MultiReader uses the same "all-or-none" policy see MultiReader.d.ts.)
244
+ // (MultiReader uses the same "all-or-none" policy -- see MultiReader.d.ts.)
240
245
  const outHasZoneMaps = readers.every((r) => r.hasZoneMaps);
241
246
  const zoneMapsRaw = readers.map((r) => r.zoneMapsRaw());
242
247
  const T = outHasZoneMaps ? zoneMapsRaw[0].trackedFields.length : 0;
@@ -320,7 +325,7 @@ export function mergeContainers(containers) {
320
325
  outDv.setUint32(outMetadataOff + 4, totalShards, true);
321
326
  outDv.setUint32(outMetadataOff + 8, T, true);
322
327
  outDv.setUint32(outMetadataOff + 12, 0, true);
323
- // Field index table (copied from container 0 all containers share the schema)
328
+ // Field index table (copied from container 0 -- all containers share the schema)
324
329
  const fieldTableOut = outMetadataOff + zoneMapsHeaderLen;
325
330
  const tracked0 = zoneMapsRaw[0].trackedFields;
326
331
  for (let t = 0; t < T; t++) outDv.setUint16(fieldTableOut + t * 2, tracked0[t], true);
@@ -355,9 +360,20 @@ export function mergeContainers(containers) {
355
360
  }
356
361
  }
357
362
 
358
- // Footer
363
+ // Footer. CRC policy (D4): recompute a fresh CRC-32C over the merged body iff
364
+ // EVERY input carried one; if any input's CRC is absent the merged CRC is
365
+ // absent too (an integrity guarantee only the inputs all shared can be
366
+ // honestly re-asserted -- never fabricated over an unverified part).
359
367
  const footerOff = totalBytes - FOOTER_BYTES;
360
- outDv.setUint32(footerOff + 0, 0xFFFFFFFF, true); // CRC absent
368
+ let mergedCrc = 0xFFFFFFFF;
369
+ let allHaveCrc = true;
370
+ for (const p of parts) {
371
+ const pDv = new DataView(p.buffer, p.byteOffset, p.byteLength);
372
+ const pCrc = pDv.getUint32(p.byteLength - FOOTER_BYTES, true) >>> 0;
373
+ if (pCrc === 0xFFFFFFFF) { allHaveCrc = false; break; }
374
+ }
375
+ if (allHaveCrc) mergedCrc = crc32cFinal(crc32cUpdate(crc32cInit(), out, 0, footerOff));
376
+ outDv.setUint32(footerOff + 0, mergedCrc >>> 0, true);
361
377
  outDv.setUint32(footerOff + 4, 0, true);
362
378
  out[footerOff + 8] = 0x31;
363
379
  out[footerOff + 9] = 0x4B;
@@ -2,6 +2,9 @@
2
2
  // Byte-level UTF-8 string interning with zero-alloc hot path.
3
3
  // Copyright (c) 2026 Zahary Shinikchiev. MIT.
4
4
  //
5
+ // Error codes:
6
+ // ST_BLOB_OVERFLOW - the blob or entry count would cross its u32 ceiling
7
+ //
5
8
  // intern(bytes, from, to) -> u32 index, deduplicating identical byte ranges.
6
9
  // The hash path never allocates: bytes stay in the caller's buffer during
7
10
  // lookup, and internal storage grows in doubling steps.
@@ -14,7 +17,7 @@
14
17
  // u8 blob[blob_length_bytes]
15
18
  //
16
19
  // The trailing sentinel makes `len(i) = offsets[i+1] - offsets[i]` uniform for
17
- // all i including the last no branch in the reader hot path.
20
+ // all i including the last -- no branch in the reader hot path.
18
21
  //
19
22
  // Entry 0 is always the empty string: the table reserves it in the constructor
20
23
  // and at every reset(), so an absent U32 row cell (which is 0) decodes as ""
@@ -38,7 +41,7 @@
38
41
  // TEST-ONLY: not re-exported from index.js, absent from the .d.ts and docs,
39
42
  // and carrying no semver guarantee.
40
43
 
41
- export const VERSION = '1.5.0';
44
+ export const VERSION = '1.6.1';
42
45
 
43
46
  const EMPTY_SLOT = 0xFFFFFFFF; // MUST be unsigned; typed-array reads are unsigned
44
47
  const INITIAL_BLOB_BYTES = 64 * 1024;
@@ -256,7 +259,7 @@ export class StringTable {
256
259
  }
257
260
 
258
261
  // Static: parse a serialized table from a byte view. Returns a read-only
259
- // accessor object (not a live StringTable cheaper for the Reader path).
262
+ // accessor object (not a live StringTable -- cheaper for the Reader path).
260
263
  static parse(bytes, byteOffset) {
261
264
  const dv = new DataView(bytes.buffer, bytes.byteOffset + byteOffset, bytes.byteLength - byteOffset);
262
265
  const entryCount = dv.getUint32(0, true);
@@ -277,7 +280,7 @@ export class StringTableView {
277
280
  this._count = count;
278
281
  this._blob = blob;
279
282
  this._offsets = offsets;
280
- this._decoder = null; // lazy created on first get()
283
+ this._decoder = null; // lazy -- created on first get()
281
284
  }
282
285
 
283
286
  get count() { return this._count; }
package/src/Tokenizer.js CHANGED
@@ -30,7 +30,7 @@
30
30
 
31
31
  import { checkOpts } from './Opts.js';
32
32
 
33
- export const VERSION = '1.5.0';
33
+ export const VERSION = '1.6.1';
34
34
 
35
35
  const U32_MAX = 4294967295;
36
36
  const TOKENIZER_OPTS = {
@@ -88,6 +88,7 @@ const NS_EXP = 7; // in exponent digits
88
88
  // correctly-rounded F64 via a single multiplication (positive exp) or single
89
89
  // division (negative exp) using this table. Outside that domain, correctly
90
90
  // rounded parsing requires David Gay's strtod, deferred to M4+.
91
+ // See decisions/0012-clinger-fast-path.md.
91
92
  const POW10 = new Float64Array(23);
92
93
  POW10[0] = 1;
93
94
  for (let _pi = 1; _pi < 23; _pi++) POW10[_pi] = POW10[_pi - 1] * 10;
@@ -691,7 +692,7 @@ export class Tokenizer {
691
692
  const need = to - from;
692
693
  if (this._strLen + need > this._maxStringBytes) this._err('E_STRING_TOO_LONG', 'string exceeds maxStringBytes cap (' + (this._strLen + need) + ' > ' + this._maxStringBytes + ')');
693
694
  if (this._strLen + need > this._strBuf.length) this._growStrBuf(need);
694
- // Manual copy loop Uint8Array.set(source) via subarray allocates a small
695
+ // Manual copy loop -- Uint8Array.set(source) via subarray allocates a small
695
696
  // view header per call, which turns into MB-scale GC pressure across a
696
697
  // string-heavy fixture. The loop is boring but keeps the hot path allocation-free.
697
698
  const dst = this._strBuf;