@zakkster/lite-bake-stream 1.5.0 → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +156 -128
- package/README.md +294 -95
- package/SPEC.md +32 -30
- package/llms.txt +18 -13
- package/package.json +2 -2
- package/src/Crc32c.js +96 -0
- package/src/FileIngest.js +6 -2
- package/src/MultiReader.js +1 -1
- package/src/Opts.js +4 -0
- package/src/PreserveReader.js +29 -4
- package/src/PreserveTokenizer.js +12 -3
- package/src/PreserveWriter.js +218 -61
- package/src/RangeReader.js +29 -2
- package/src/Reader.js +34 -4
- package/src/Split.js +22 -6
- package/src/StringTable.js +7 -4
- package/src/Tokenizer.js +3 -2
- package/src/Views.js +65 -0
- package/src/Writer.js +387 -146
- package/src/index.js +8 -4
- package/types/MultiReader.d.ts +1 -1
- package/types/PreserveReader.d.ts +10 -2
- package/types/PreserveWriter.d.ts +30 -0
- package/types/RangeReader.d.ts +6 -1
- package/types/Reader.d.ts +11 -3
- package/types/Split.d.ts +1 -1
- package/types/Tokenizer.d.ts +1 -1
- package/types/Writer.d.ts +39 -2
- package/types/index.d.ts +2 -1
package/src/PreserveWriter.js
CHANGED
|
@@ -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.
|
|
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
|
|
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.
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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.
|
|
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
|
-
|
|
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
|
|
268
|
+
const payloadOffs = new Array(shardCount);
|
|
201
269
|
for (let i = 0; i < shardCount; i++) {
|
|
202
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
245
|
-
const
|
|
246
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
}
|
package/src/RangeReader.js
CHANGED
|
@@ -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.
|
|
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 = {
|
|
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.
|
|
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.
|
|
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[]
|
|
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
|
|
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
|
|
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
|
-
|
|
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;
|
package/src/StringTable.js
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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;
|