@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.
@@ -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.4.1';
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,16 +53,29 @@ 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;
66
+ this._currentBlobs = null; // null => never allocated; _allocateShard allocates once
51
67
  this._allocateShard();
52
68
  }
53
69
 
54
70
  _allocateShard() {
55
- // Pre-allocate up to targetShardBytes for zero-GC record writes.
56
- this._currentBlobs = new Uint8Array(this._targetShardBytes);
57
- this._currentOffsets = new Uint32Array(INITIAL_OFFSETS_CAP);
71
+ // Allocate the working buffers ONCE (BS-27 floor). Every subsequent shard
72
+ // reuses them: _finalizeCurrentShard resets the cursors instead of nulling
73
+ // the buffers, so a shard roll allocates nothing. A grown buffer (oversized
74
+ // record) simply carries forward as a larger reusable buffer.
75
+ if (this._currentBlobs === null) {
76
+ this._currentBlobs = new Uint8Array(this._targetShardBytes);
77
+ this._currentOffsets = new Uint32Array(INITIAL_OFFSETS_CAP);
78
+ }
58
79
  this._currentBlobBytes = 0;
59
80
  this._currentRowCount = 0;
60
81
  }
@@ -74,7 +95,19 @@ export class PreserveWriter {
74
95
  this._allocateShard();
75
96
  }
76
97
  // Grow shard buffer if this single record exceeds the pre-allocated size.
98
+ // Fail closed (BS-31) on a BLOB-dimension crossing: this guard covers a
99
+ // record whose blob bytes push payload_len past the u32 ceiling (including
100
+ // the oversized-single-record path), and the _growBlobBuffer clamp keeps the
101
+ // buffer <= U32_MAX so such a write always re-enters this grow branch. The
102
+ // ORTHOGONAL crossing -- payload_len exceeding u32 via OFFSET-TABLE
103
+ // accumulation (many tiny records, blob under target, grow branch never
104
+ // firing) -- is caught in _finalizeCurrentShard. ST_BLOB_OVERFLOW is the
105
+ // shared cross-writer overflow code (R11).
77
106
  if (this._currentBlobBytes + len > this._currentBlobs.length) {
107
+ if (this._currentBlobBytes + len + (this._currentRowCount + 1) * 4 > U32_MAX)
108
+ throw new PreserveWriterError('ST_BLOB_OVERFLOW',
109
+ 'preserve shard payload ' + (this._currentBlobBytes + len) +
110
+ ' bytes + offset table would exceed the u32 payload_len ceiling ' + U32_MAX);
78
111
  this._growBlobBuffer(this._currentBlobBytes + len);
79
112
  }
80
113
  // Grow offsets array if we've hit the pre-allocated ceiling.
@@ -107,6 +140,9 @@ export class PreserveWriter {
107
140
  _growBlobBuffer(needed) {
108
141
  let cap = this._currentBlobs.length;
109
142
  while (cap < needed) cap *= 2;
143
+ // Clamp so the buffer never exceeds the u32 payload ceiling; this makes the
144
+ // onRecord grow-branch guard provably complete (see onRecord).
145
+ if (cap > U32_MAX) cap = U32_MAX;
110
146
  const next = new Uint8Array(cap);
111
147
  next.set(this._currentBlobs.subarray(0, this._currentBlobBytes));
112
148
  this._currentBlobs = next;
@@ -124,6 +160,15 @@ export class PreserveWriter {
124
160
  const blobLen = this._currentBlobBytes;
125
161
  const offsetTableBytes = rowCount * 4;
126
162
  const shardBytesLen = blobLen + offsetTableBytes;
163
+ // Fail closed (BS-31, cold) on an OFFSET-TABLE-driven crossing: payload_len =
164
+ // blob bytes + rowCount*4 is written into the shard directory as a u32, and
165
+ // enough tiny records can push it past the ceiling without the onRecord grow
166
+ // branch ever firing. This is the door that branch cannot cover. Shared code
167
+ // ST_BLOB_OVERFLOW (R11).
168
+ if (shardBytesLen > U32_MAX)
169
+ throw new PreserveWriterError('ST_BLOB_OVERFLOW',
170
+ 'preserve shard payload_len ' + shardBytesLen + ' (blob ' + blobLen + ' + offset table ' +
171
+ offsetTableBytes + ') exceeds the u32 ceiling ' + U32_MAX);
127
172
 
128
173
  const shardBytes = new Uint8Array(shardBytesLen);
129
174
  shardBytes.set(this._currentBlobs.subarray(0, blobLen), 0);
@@ -132,100 +177,236 @@ export class PreserveWriter {
132
177
  for (let i = 0; i < rowCount; i++) {
133
178
  dv.setUint32(blobLen + i * 4, this._currentOffsets[i], true);
134
179
  }
135
- this._shards.push({
136
- bytes: shardBytes,
137
- rowCount,
138
- blobLen,
139
- });
140
- // Zero out the working buffers so a stale ref can't leak old bytes into
141
- // the next shard by accident. The buffers are reused via _allocateShard.
142
- this._currentBlobs = null;
143
- this._currentOffsets = null;
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
+ }
189
+ // Reuse the working buffers for the next shard: reset the cursors only, keep
190
+ // the allocations (BS-27). No stale bytes can leak -- every read into these
191
+ // buffers is bounded by _currentBlobBytes / _currentRowCount (both zeroed
192
+ // here), and the shard copy above took only subarray(0, blobLen).
144
193
  this._currentBlobBytes = 0;
145
194
  this._currentRowCount = 0;
146
195
  }
147
196
 
148
- finalize() {
197
+ _completeInput() {
149
198
  if (this._finalized) throw new PreserveWriterError('W_FINALIZED', 'writer already finalized');
150
199
  if (this._currentRowCount > 0) this._finalizeCurrentShard();
151
200
  if (this._shards.length === 0) {
152
- // 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.
153
202
  throw new PreserveWriterError('W_EMPTY_INPUT', 'no records written; nothing to finalize');
154
203
  }
204
+ }
205
+
206
+ finalize() {
207
+ this._completeInput();
208
+ const bytes = this._buildPrefixBytes(this._crc);
155
209
  this._finalized = true;
156
- return this._assembleContainer();
210
+ return { buffer: bytes.buffer, totalRows: this._totalRows, shardCount: this._shards.length, mode: 'preserve' };
157
211
  }
158
212
 
159
213
  get totalRows() { return this._totalRows; }
160
214
  get shardCount() { return this._shards.length; }
161
215
 
162
- _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) {
163
258
  const shardCount = this._shards.length;
164
259
  const shardDirBytes = shardCount * SHARD_ENTRY_BYTES;
165
-
166
- // Layout: header (48) | shard directory | shard payloads | footer (16)
167
- // No schema block, no zone maps in preserve mode; schema_block_off = 0.
168
260
  const shardDirOff = CONTAINER_HEADER_BYTES;
169
261
  let cursor = shardDirOff + shardDirBytes;
170
- const shardPayloadOffsets = new Array(shardCount);
262
+ const payloadOffs = new Array(shardCount);
171
263
  for (let i = 0; i < shardCount; i++) {
172
- shardPayloadOffsets[i] = cursor;
264
+ payloadOffs[i] = cursor;
173
265
  cursor += this._shards[i].bytes.length;
174
266
  }
267
+ const footerOff = cursor;
175
268
  const totalBytes = cursor + FOOTER_BYTES;
269
+ const buffer = new ArrayBuffer(totalBytes);
270
+ const dv = new DataView(buffer);
271
+ const bytes = new Uint8Array(buffer);
176
272
 
177
- const container = new ArrayBuffer(totalBytes);
178
- const dv = new DataView(container);
179
- const bytes = new Uint8Array(container);
180
-
181
- // Header
182
- bytes[0] = 0x4C; bytes[1] = 0x42; bytes[2] = 0x4B; bytes[3] = 0x31; // 'LBK1'
183
- dv.setUint16(4, 1, true); // format_version
184
- bytes[6] = 1; // endian LE
185
- bytes[7] = 0x01; // flags: bit 0 = preserve mode
186
- dv.setBigUint64(8, 0n, true); // schema_block_off = 0 (no schema)
187
- dv.setBigUint64(16, 0n, true); // metadata_off = 0 (no zone maps)
188
- dv.setBigUint64(24, BigInt(shardDirOff), true);
189
- dv.setUint32(32, shardCount, true);
190
- dv.setUint32(36, 0, true); // reserved1
191
- dv.setBigUint64(40, BigInt(this._totalRows), true);
192
-
193
- // Shard directory (40 bytes per entry, u64 payload_off, u32 payload_len,
194
- // u32 row_count, u16 min_reader_version, u16 flags, u32 reserved,
195
- // u64 local_string_off=0, u64 local_string_len=0)
273
+ this._emitHeaderInto(dv, bytes, 0, shardDirOff, shardCount, this._totalRows);
196
274
  for (let i = 0; i < shardCount; i++) {
197
- const entryOff = shardDirOff + i * SHARD_ENTRY_BYTES;
198
275
  const s = this._shards[i];
199
- dv.setBigUint64(entryOff + 0, BigInt(shardPayloadOffsets[i]), true);
200
- dv.setUint32(entryOff + 8, s.bytes.length, true);
201
- dv.setUint32(entryOff + 12, s.rowCount, true);
202
- dv.setUint16(entryOff + 16, 1, true); // min_reader_version
203
- dv.setUint16(entryOff + 18, 0, true); // shard flags
204
- dv.setUint32(entryOff + 20, 0, true); // reserved
205
- dv.setBigUint64(entryOff + 24, 0n, true); // no string table
206
- dv.setBigUint64(entryOff + 32, 0n, true);
276
+ this._emitDirEntryInto(dv, shardDirOff + i * SHARD_ENTRY_BYTES, payloadOffs[i], s.bytes.length, s.rowCount);
207
277
  }
278
+ for (let i = 0; i < shardCount; i++) bytes.set(this._shards[i].bytes, payloadOffs[i]);
208
279
 
209
- // Shard payloads
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;
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;
378
+
379
+ const dir = new Uint8Array(shardDirBytes);
380
+ const ddv = new DataView(dir.buffer);
210
381
  for (let i = 0; i < shardCount; i++) {
211
- 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);
212
384
  }
385
+ this._sinkWrite(dir, true);
386
+ this._sinkPos += shardDirBytes;
213
387
 
214
- // Footer
215
- const footerOff = totalBytes - FOOTER_BYTES;
216
- dv.setUint32(footerOff + 0, 0xFFFFFFFF, true); // CRC absent
217
- dv.setUint32(footerOff + 4, 0, true);
218
- bytes[footerOff + 8] = 0x31; // '1'
219
- bytes[footerOff + 9] = 0x4B; // 'K'
220
- bytes[footerOff + 10] = 0x42; // 'B'
221
- bytes[footerOff + 11] = 0x4C; // 'L'
222
- 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);
223
391
 
224
- return {
225
- buffer: container,
226
- totalRows: this._totalRows,
227
- shardCount,
228
- mode: 'preserve',
229
- };
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' };
230
411
  }
231
412
  }
@@ -30,8 +30,11 @@
30
30
  // Error codes (stable, share prefix with Reader.js where semantics overlap):
31
31
  // R_BAD_MAGIC / R_UNSUPPORTED_VERSION / R_UNSUPPORTED_ENDIAN / R_BAD_FIELD_FLAGS
32
32
  // R_UNSUPPORTED_LANE / R_SHARD_VERSION_TOO_NEW / R_TRUNCATED / R_UNKNOWN_FIELD
33
- // R_ADAPTER_SHORT_READ -- adapter returned fewer bytes than requested
34
- // R_ROW_OUT_OF_RANGE -- rowIdx >= totalRows
33
+ // R_ADAPTER_SHORT_READ -- adapter returned fewer bytes than requested, OR a
34
+ // non-Uint8Array / wrong-length adapter return (T7c)
35
+ // R_ROW_OUT_OF_RANGE -- rowIdx negative, fractional, NaN, or >= totalRows (BS-32)
36
+ // R_OFFSET_TOO_LARGE -- a u64 header/schema/directory offset exceeds 2^53-1
37
+ // R_NOT_PREFETCHED -- syncRange over a shard that prefetchRange has not cached
35
38
  // R_WRONG_MODE -- preserve-mode container fed to the schema RangeReader
36
39
  // R_BAD_FLAGS -- unknown header flag bits set
37
40
  // R_RESERVED_NONZERO -- a reserved header/descriptor/shard field is non-zero
@@ -41,8 +44,9 @@
41
44
 
42
45
  import { StringTable } from './StringTable.js';
43
46
  import { checkOpts } from './Opts.js';
47
+ import { crc32cInit, crc32cUpdate, crc32cFinal } from './Crc32c.js';
44
48
 
45
- export const VERSION = '1.4.1';
49
+ export const VERSION = '1.6.0';
46
50
 
47
51
  const CONTAINER_HEADER_BYTES = 48;
48
52
  const SHARD_ENTRY_BYTES = 40;
@@ -53,10 +57,14 @@ const LANE_U32 = 3;
53
57
  const READER_VERSION = 1;
54
58
  const U32_MAX = 4294967295;
55
59
 
56
- const RANGE_READER_OPTS = { maxCachedShards: { t: 'int', min: 0, max: U32_MAX } };
60
+ const RANGE_READER_OPTS = {
61
+ maxCachedShards: { t: 'int', min: 0, max: U32_MAX },
62
+ verifyCrc: { t: 'bool' },
63
+ };
57
64
  const HTTP_ADAPTER_OPTS = { fetch: { t: 'fn' } };
58
65
 
59
66
  const CONTAINER_FOOTER_BYTES = 16;
67
+ const CRC_ABSENT = 0xFFFFFFFF;
60
68
 
61
69
  function laneBytesOf(k) { return k === LANE_F64 ? 8 : (k === LANE_U32 ? 4 : 0); }
62
70
 
@@ -65,6 +73,18 @@ export class RangeReaderError extends Error {
65
73
  }
66
74
  function raiseRange(code, msg) { throw new RangeReaderError(code, msg); }
67
75
 
76
+ // Read a u64 field from `dv` (the header, schema, or directory view -- three
77
+ // different DataViews here) and narrow it to a JS number, failing closed past
78
+ // Number.MAX_SAFE_INTEGER. Past 2^53-1 a Number() cast loses precision and every
79
+ // downstream bounds check reads a corrupted offset (BS-05). Cold: parse-only.
80
+ function u64(dv, off, what) {
81
+ const v = dv.getBigUint64(off, true);
82
+ if (v > 9007199254740991n)
83
+ throw new RangeReaderError('R_OFFSET_TOO_LARGE',
84
+ what + ' value ' + v + ' exceeds the safe-integer ceiling 9007199254740991');
85
+ return Number(v);
86
+ }
87
+
68
88
  // Validate a local string table's shape before StringTable.parse casts a
69
89
  // Uint32Array over it (T-1..T-4). `bytes` is the already-fetched shard slice;
70
90
  // `off` and `len` locate the table within it.
@@ -186,6 +206,11 @@ export class RangeReader {
186
206
  static async open(adapter, opts) {
187
207
  const r = new RangeReader(adapter, opts);
188
208
  await r._loadHeaderAndSchema();
209
+ if (opts && opts.verifyCrc === true) {
210
+ const status = await r.verifyCrc();
211
+ if (status === 'absent')
212
+ throw new RangeReaderError('R_CRC_ABSENT', 'verifyCrc:true but the container carries no CRC (footer CRC is absent, 0xFFFFFFFF)');
213
+ }
189
214
  return r;
190
215
  }
191
216
 
@@ -194,6 +219,7 @@ export class RangeReader {
194
219
  opts = opts || {};
195
220
  this.adapter = adapter;
196
221
  this.maxCachedShards = opts.maxCachedShards !== undefined ? opts.maxCachedShards : 8;
222
+ this._footerCrc = CRC_ABSENT;
197
223
  // shard cache: shardIdx -> { payloadBytes, payloadDv, stringTable, lastAccess }
198
224
  this._shardCache = new Map();
199
225
  this._accessCounter = 0;
@@ -205,7 +231,7 @@ export class RangeReader {
205
231
  }
206
232
 
207
233
  // Step 1: header (48 bytes) tells us schema + shard-dir offsets.
208
- const headerBytes = await this.adapter.fetch(0, CONTAINER_HEADER_BYTES);
234
+ const headerBytes = await this._fetchExact(0, CONTAINER_HEADER_BYTES);
209
235
  const hdrDv = new DataView(headerBytes.buffer, headerBytes.byteOffset, headerBytes.byteLength);
210
236
  if (headerBytes[0] !== 0x4C || headerBytes[1] !== 0x42 || headerBytes[2] !== 0x4B || headerBytes[3] !== 0x31) {
211
237
  throw new RangeReaderError('R_BAD_MAGIC', 'header magic is not LBK1');
@@ -226,11 +252,11 @@ export class RangeReader {
226
252
  if (reserved1 !== 0)
227
253
  throw new RangeReaderError('R_RESERVED_NONZERO', 'header reserved1 at offset 36 must be 0, got ' + reserved1);
228
254
 
229
- this._schemaBlockOff = Number(hdrDv.getBigUint64(8, true));
230
- this._metadataOff = Number(hdrDv.getBigUint64(16, true));
231
- this._shardDirOff = Number(hdrDv.getBigUint64(24, true));
255
+ this._schemaBlockOff = u64(hdrDv, 8, 'schema_block_off');
256
+ this._metadataOff = u64(hdrDv, 16, 'metadata_off');
257
+ this._shardDirOff = u64(hdrDv, 24, 'shard_directory_off');
232
258
  this._shardCount = hdrDv.getUint32(32, true);
233
- this._totalRows = Number(hdrDv.getBigUint64(40, true));
259
+ this._totalRows = u64(hdrDv, 40, 'total_rows');
234
260
 
235
261
  const size = this.adapter.size;
236
262
  if (this._schemaBlockOff < CONTAINER_HEADER_BYTES || this._schemaBlockOff >= size)
@@ -252,11 +278,11 @@ export class RangeReader {
252
278
  if (this._shardDirOff <= this._schemaBlockOff)
253
279
  throw new RangeReaderError('R_TRUNCATED', 'shard_directory_off ' + this._shardDirOff + ' not after schema_block_off ' + this._schemaBlockOff);
254
280
  const schemaBlockLen = this._shardDirOff - this._schemaBlockOff;
255
- const schemaBytes = await this.adapter.fetch(this._schemaBlockOff, schemaBlockLen);
281
+ const schemaBytes = await this._fetchExact(this._schemaBlockOff, schemaBlockLen);
256
282
  this._parseSchema(schemaBytes);
257
283
 
258
284
  // Step 3: shard directory (fixed size = shardCount * 40).
259
- const dirBytes = await this.adapter.fetch(this._shardDirOff, this._shardCount * SHARD_ENTRY_BYTES);
285
+ const dirBytes = await this._fetchExact(this._shardDirOff, this._shardCount * SHARD_ENTRY_BYTES);
260
286
  this._parseShardDirectory(dirBytes);
261
287
 
262
288
  // Step 4 (M7): zone maps. Fetched ONCE at open, cached. Enables query
@@ -274,7 +300,7 @@ export class RangeReader {
274
300
 
275
301
  async _loadFooter() {
276
302
  const size = this.adapter.size;
277
- const footer = await this.adapter.fetch(size - CONTAINER_FOOTER_BYTES, CONTAINER_FOOTER_BYTES);
303
+ const footer = await this._fetchExact(size - CONTAINER_FOOTER_BYTES, CONTAINER_FOOTER_BYTES);
278
304
  if (footer[8] !== 0x31 || footer[9] !== 0x4B || footer[10] !== 0x42 || footer[11] !== 0x4C)
279
305
  throw new RangeReaderError('R_BAD_FOOTER', 'footer magic_end is not 1KBL');
280
306
  const fDv = new DataView(footer.buffer, footer.byteOffset, footer.byteLength);
@@ -283,6 +309,20 @@ export class RangeReader {
283
309
  throw new RangeReaderError('R_BAD_FOOTER', 'footer_len ' + footerLen + ' is less than the minimum ' + CONTAINER_FOOTER_BYTES);
284
310
  if (footerLen > size - CONTAINER_HEADER_BYTES)
285
311
  throw new RangeReaderError('R_BAD_FOOTER', 'footer_len ' + footerLen + ' exceeds the container body size');
312
+ this._footerCrc = fDv.getUint32(0, true) >>> 0;
313
+ }
314
+
315
+ // SPEC 3.7 integrity over a ranged source: 'ok' | 'absent'; a mismatch throws
316
+ // R_BAD_CRC. Fetches the body [0, footer_off) in one range (a full-integrity
317
+ // check is inherently whole-body). The footer CRC was captured at open.
318
+ async verifyCrc() {
319
+ if (this._footerCrc === CRC_ABSENT) return 'absent';
320
+ const footerOff = this.adapter.size - CONTAINER_FOOTER_BYTES;
321
+ const body = await this._fetchExact(0, footerOff);
322
+ const actual = crc32cFinal(crc32cUpdate(crc32cInit(), body, 0, footerOff));
323
+ if (actual !== this._footerCrc)
324
+ throw new RangeReaderError('R_BAD_CRC', 'container CRC mismatch: stored 0x' + this._footerCrc.toString(16) + ' != computed 0x' + actual.toString(16));
325
+ return 'ok';
286
326
  }
287
327
 
288
328
  async _loadZoneMaps() {
@@ -291,7 +331,7 @@ export class RangeReader {
291
331
  // corruption (D1), not absence.
292
332
  if (this._metadataOff + 16 > this.adapter.size)
293
333
  throw new RangeReaderError('R_BAD_METADATA', 'zone maps segment header runs past the container');
294
- const hdr = await this.adapter.fetch(this._metadataOff, 16);
334
+ const hdr = await this._fetchExact(this._metadataOff, 16);
295
335
  if (hdr[0] !== 0x30 || hdr[1] !== 0x5A || hdr[2] !== 0x4D || hdr[3] !== 0x31)
296
336
  throw new RangeReaderError('R_BAD_METADATA', 'zone maps magic is not ZM01');
297
337
  const hdrDv = new DataView(hdr.buffer, hdr.byteOffset, hdr.byteLength);
@@ -309,7 +349,7 @@ export class RangeReader {
309
349
  const restLen = fieldTableLen + fieldTablePad + shardCount * T * 8 * 2;
310
350
  if (this._metadataOff + 16 + restLen > this.adapter.size)
311
351
  throw new RangeReaderError('R_BAD_METADATA', 'zone maps segment runs past the container');
312
- const rest = await this.adapter.fetch(this._metadataOff + 16, restLen);
352
+ const rest = await this._fetchExact(this._metadataOff + 16, restLen);
313
353
  const restDv = new DataView(rest.buffer, rest.byteOffset, rest.byteLength);
314
354
  const tracked = new Array(T);
315
355
  const fieldToPos = new Map();
@@ -357,7 +397,10 @@ export class RangeReader {
357
397
  const laneKind = bytes[off + 4];
358
398
  const flags = bytes[off + 5];
359
399
  const reserved2 = dv.getUint16(off + 6, true);
360
- const nameStrOff = Number(dv.getBigUint64(off + 8, true));
400
+ const nameStrOff = u64(dv, off + 8, 'field ' + i + ' name_str_off');
401
+ // reserved3 is BigInt-compared vs 0n below, never Number()-cast, so it
402
+ // needs no narrowing guard -- the reserved-must-be-zero check catches any
403
+ // out-of-range value (BS-05).
361
404
  const reserved3 = dv.getBigUint64(off + 16, true);
362
405
  if (flags !== 0) throw new RangeReaderError('R_BAD_FIELD_FLAGS', 'field ' + i + ' has non-zero flags');
363
406
  if (reserved2 !== 0) throw new RangeReaderError('R_RESERVED_NONZERO', 'field ' + i + ' reserved2 must be 0, got ' + reserved2);
@@ -383,14 +426,14 @@ export class RangeReader {
383
426
  let cumulativeRow = 0;
384
427
  for (let i = 0; i < this._shardCount; i++) {
385
428
  const off = i * SHARD_ENTRY_BYTES;
386
- const payloadOff = Number(dv.getBigUint64(off + 0, true));
429
+ const payloadOff = u64(dv, off + 0, 'shard ' + i + ' payload_off');
387
430
  const payloadLen = dv.getUint32(off + 8, true);
388
431
  const rowCount = dv.getUint32(off + 12, true);
389
432
  const minReaderVer = dv.getUint16(off + 16, true);
390
433
  const shardFlags = dv.getUint16(off + 18, true);
391
434
  const shardReserved = dv.getUint32(off + 20, true);
392
- const localStrOff = Number(dv.getBigUint64(off + 24, true));
393
- const localStrLen = Number(dv.getBigUint64(off + 32, true));
435
+ const localStrOff = u64(dv, off + 24, 'shard ' + i + ' local_string_off');
436
+ const localStrLen = u64(dv, off + 32, 'shard ' + i + ' local_string_len');
394
437
  if (minReaderVer > READER_VERSION)
395
438
  throw new RangeReaderError('R_SHARD_VERSION_TOO_NEW',
396
439
  'shard ' + i + ' requires reader version ' + minReaderVer);
@@ -433,7 +476,10 @@ export class RangeReader {
433
476
 
434
477
  // Locate the shard containing rowIdx via binary search.
435
478
  _findShardIndex(rowIdx) {
436
- if (rowIdx < 0 || rowIdx >= this._totalRows) {
479
+ // BS-32: Number.isInteger rejects fractional/NaN as well as out-of-range in
480
+ // one guard. This ONE site covers get(), prefetchRange, syncRange's bounds
481
+ // walk and the syncRange view's inner get.
482
+ if (!Number.isInteger(rowIdx) || rowIdx < 0 || rowIdx >= this._totalRows) {
437
483
  throw new RangeReaderError('R_ROW_OUT_OF_RANGE',
438
484
  'rowIdx ' + rowIdx + ' out of range [0, ' + this._totalRows + ')');
439
485
  }
@@ -447,6 +493,21 @@ export class RangeReader {
447
493
  return -1; // unreachable given the range check above
448
494
  }
449
495
 
496
+ // Every adapter read funnels through here (T7c). The IOAdapter contract says
497
+ // fetch() MUST return exactly byteLength bytes; a short, over-long, or
498
+ // non-Uint8Array return would otherwise seed a DataView over the wrong extent
499
+ // and decode silent garbage. Fail closed with R_ADAPTER_SHORT_READ instead.
500
+ async _fetchExact(byteOffset, byteLength) {
501
+ const buf = await this.adapter.fetch(byteOffset, byteLength);
502
+ if (!(buf instanceof Uint8Array) || buf.byteLength !== byteLength) {
503
+ throw new RangeReaderError('R_ADAPTER_SHORT_READ',
504
+ 'adapter returned ' +
505
+ (buf instanceof Uint8Array ? buf.byteLength + ' bytes' : Object.prototype.toString.call(buf)) +
506
+ ' for a ' + byteLength + '-byte request at offset ' + byteOffset);
507
+ }
508
+ return buf;
509
+ }
510
+
450
511
  // Load a shard's payload + local string table with ONE range request.
451
512
  // They are contiguous in the container by SPEC 3.4, so a single fetch covers
452
513
  // both. Returns the cached shard record.
@@ -464,7 +525,7 @@ export class RangeReader {
464
525
  throw new RangeReaderError('R_TRUNCATED',
465
526
  'shard ' + shardIdx + ' string table not contiguous with payload');
466
527
  }
467
- const combined = await this.adapter.fetch(combinedOff, combinedLen);
528
+ const combined = await this._fetchExact(combinedOff, combinedLen);
468
529
  const payloadBytes = combined.subarray(0, s.payloadLen);
469
530
  // DataView over the payload's underlying ArrayBuffer window.
470
531
  const payloadDv = new DataView(payloadBytes.buffer, payloadBytes.byteOffset, payloadBytes.byteLength);
@@ -532,7 +593,7 @@ export class RangeReader {
532
593
  const lastShard = this._findShardIndex(lastRow - 1);
533
594
  for (let s = firstShard; s <= lastShard; s++) {
534
595
  if (!this._shardCache.has(s)) {
535
- throw new RangeReaderError('R_TRUNCATED',
596
+ throw new RangeReaderError('R_NOT_PREFETCHED',
536
597
  'syncRange requires shard ' + s + ' to be prefetched (call prefetchRange first)');
537
598
  }
538
599
  }