@zakkster/lite-bake-stream 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/Writer.js CHANGED
@@ -57,8 +57,10 @@
57
57
 
58
58
  import { StringTable } from './StringTable.js';
59
59
  import { checkOpts } from './Opts.js';
60
+ import { crc32cInit, crc32cUpdate, crc32cFinal, crc32cCombine } from './Crc32c.js';
61
+ import { validateSink, isThenable } from './Views.js';
60
62
 
61
- export const VERSION = '1.5.0';
63
+ export const VERSION = '1.6.0';
62
64
 
63
65
  const U32_MAX = 4294967295;
64
66
  // Post-finalize sentinel for _recordDepth. Chosen = 2 so every post-finalize
@@ -71,7 +73,13 @@ const WRITER_OPTS = {
71
73
  schema: { t: 'obj', nullable: true },
72
74
  targetShardBytes: { t: 'int', min: 1, max: U32_MAX },
73
75
  sampleBytes: { t: 'int', min: 0, max: U32_MAX },
76
+ crc: { t: 'bool' },
74
77
  };
78
+ const FINALIZE_TO_SINK_OPTS = {
79
+ layout: { t: 'enum', values: ['prefix', 'stream'] },
80
+ crc: { t: 'bool' },
81
+ };
82
+ const CRC_ABSENT = 0xFFFFFFFF;
75
83
  function raiseWriter(code, msg) { throw new WriterError(code, msg); }
76
84
 
77
85
  const CONTAINER_HEADER_BYTES = 48;
@@ -91,6 +99,10 @@ const SHARD_FLAGS_NONE = 0;
91
99
  // U32-lane fields). Read-only; never written into.
92
100
  const EMPTY_STRING_TABLE_BYTES = new Uint8Array(0);
93
101
 
102
+ // Shared read-only zero span for 8-byte shard-payload alignment padding in the
103
+ // streaming path. Never written into.
104
+ const ZERO_PAD = new Uint8Array(8);
105
+
94
106
  const FORMAT_VERSION = 1;
95
107
  const ENDIAN_LE = 1;
96
108
 
@@ -225,6 +237,17 @@ export class Writer {
225
237
  this._targetShardBytes = opts.targetShardBytes !== undefined ? opts.targetShardBytes : DEFAULT_SHARD_BYTES;
226
238
  this._sampleBytes = opts.sampleBytes !== undefined ? opts.sampleBytes : this._targetShardBytes;
227
239
  this._explicitSchema = opts.schema !== undefined ? opts.schema : null;
240
+ this._crc = opts.crc === true;
241
+
242
+ // Streaming-emission state (M6). Null sink => classic buffered writer:
243
+ // shards accumulate in _shards until finalize()/finalizeToSink drains them.
244
+ // A bound sink (beginStream) switches _finalizeCurrentShard to emit each
245
+ // shard as it finalizes and retain only a scalar directory descriptor, so
246
+ // peak RAM stays O(targetShardBytes + directory) instead of O(container).
247
+ this._sink = null;
248
+ this._sinkCrcOn = false;
249
+ this._sinkPos = 0; // current absolute write position in the sink
250
+ this._sinkCrc = 0; // running int32 CRC of the body suffix [48, pos)
228
251
 
229
252
  // Frozen schema state
230
253
  this._schema = null; // { fields: [{name, laneKind, offsetInRow}], rowStride }
@@ -633,17 +656,25 @@ export class Writer {
633
656
  const shardMaxes = new Float64Array(T);
634
657
  shardMins.set(this._currentShardMins);
635
658
  shardMaxes.set(this._currentShardMaxes);
636
- this._shards.push({
637
- bytes: copy,
638
- rowCount: this._currentShardRowCount,
639
- stringTableBytes: stBytes,
640
- // D2 audit: the incrementally-tracked budget at this finalize. MUST equal
641
- // stBytes.length (the emitted directory local_string_len). Cross-checked by
642
- // Ceilings.test.js via __stringTableBudgetAudit; not part of the public API.
643
- budgetTracked: this._stringTableBytes,
644
- mins: shardMins,
645
- maxes: shardMaxes,
646
- });
659
+ if (this._sink !== null) {
660
+ // Streaming mode: emit this shard to the bound sink now and retain only a
661
+ // scalar directory descriptor (payload/string offsets, row count, zone
662
+ // bounds). The heavy payload + string-table byte arrays are dropped, so
663
+ // peak RAM does not grow with shard count.
664
+ this._streamEmitShard(copy, stBytes, this._currentShardRowCount, shardMins, shardMaxes);
665
+ } else {
666
+ this._shards.push({
667
+ bytes: copy,
668
+ rowCount: this._currentShardRowCount,
669
+ stringTableBytes: stBytes,
670
+ // D2 audit: the incrementally-tracked budget at this finalize. MUST equal
671
+ // stBytes.length (the emitted directory local_string_len). Cross-checked by
672
+ // Ceilings.test.js via __stringTableBudgetAudit; not part of the public API.
673
+ budgetTracked: this._stringTableBytes,
674
+ mins: shardMins,
675
+ maxes: shardMaxes,
676
+ });
677
+ }
647
678
  this._currentShardBuffer = null;
648
679
  this._currentShardBytes = null;
649
680
  this._currentShardDv = null;
@@ -735,7 +766,12 @@ export class Writer {
735
766
  // receive strings from post-drain records that fill subsequent shards.
736
767
  }
737
768
 
738
- finalize() {
769
+ // Shared input-completion prologue for finalize() and finalizeToSink(): freeze
770
+ // an inferred schema, drain the sample, finalize the trailing partial shard,
771
+ // and fail closed on empty input. After this returns, _shards holds one entry
772
+ // per shard (a buffered {bytes,...} in the classic path, or a scalar directory
773
+ // descriptor when a sink was bound via beginStream).
774
+ _completeInput() {
739
775
  if (this._finalized) throw new WriterError('W_FINALIZED', 'writer already finalized');
740
776
  if (!this._schema) {
741
777
  if (this._sample.rowCount === 0) throw new WriterError('W_EMPTY_INPUT', 'no records to write');
@@ -744,13 +780,94 @@ export class Writer {
744
780
  }
745
781
  if (this._currentShardRowCount > 0) this._finalizeCurrentShard();
746
782
  if (this._shards.length === 0) throw new WriterError('W_EMPTY_INPUT', 'no records to write');
747
- this._container = this._assembleContainer();
783
+ }
784
+
785
+ finalize() {
786
+ this._completeInput();
787
+ // finalize() IS layout:'prefix' over an implicit in-memory buffer: it builds
788
+ // the identical classic container the pre-M6 assembler produced (byte-for-
789
+ // byte when crc is off) and returns the frozen struct.
790
+ const bytes = this._buildPrefixBytes(this._crc);
748
791
  this._finalized = true;
749
792
  // Post-finalize sentinel: route every later sink event into a cold arm.
750
793
  this._recordDepth = FINALIZED_DEPTH;
794
+ this._container = {
795
+ buffer: bytes.buffer,
796
+ totalRows: this._totalRows,
797
+ shardCount: this._shards.length,
798
+ schema: this._schema,
799
+ };
751
800
  return this._container;
752
801
  }
753
802
 
803
+ // PUBLIC. Bind a sink and switch to streaming emission BEFORE the first record
804
+ // is fed (the two-step bounded-RAM contract): call beginStream(sink, opts),
805
+ // then feed the tokenizer -- each shard is written to the sink as it finalizes
806
+ // and its bytes are dropped, so peak RAM is O(targetShardBytes + directory) --
807
+ // then call finalizeToSink(sink, opts), which appends the schema/directory/
808
+ // zone-map trailer and footer and performs the single header backpatch. Calling
809
+ // finalizeToSink alone (without beginStream) is BUFFERED mode: correct, but
810
+ // peak RAM is O(container). opts.layout must be 'stream'; opts.crc (default:
811
+ // the constructor crc) toggles CRC-32C.
812
+ beginStream(sink, opts) {
813
+ if (this._finalized) throw new WriterError('W_FINALIZED', 'writer already finalized');
814
+ if (this._sink !== null) throw new WriterError('W_FINALIZED', 'writer is already streaming to a sink');
815
+ if (this._shards.length > 0 || this._currentShardRowCount > 0)
816
+ throw new WriterError('W_FINALIZED', 'beginStream must be called before the first record');
817
+ checkOpts('Writer.beginStream', opts, FINALIZE_TO_SINK_OPTS, raiseWriter);
818
+ opts = opts || {};
819
+ if (opts.layout !== undefined && opts.layout !== 'stream')
820
+ raiseWriter('W_BAD_SINK', "beginStream requires layout:'stream'");
821
+ validateSink(sink, true, raiseWriter);
822
+ this._sink = sink;
823
+ this._sinkCrcOn = opts.crc !== undefined ? opts.crc === true : this._crc;
824
+ this._sinkPos = 0;
825
+ this._sinkCrc = crc32cInit();
826
+ // Reserve the 48-byte header up front; it is backpatched by ONE writeAt at
827
+ // the end once shardCount and the trailer offsets are known. Excluded from
828
+ // the running suffix CRC (folded in via crc32cCombine at the end).
829
+ const placeholder = new Uint8Array(CONTAINER_HEADER_BYTES);
830
+ this._sinkWrite(placeholder, false);
831
+ this._sinkPos = CONTAINER_HEADER_BYTES;
832
+ }
833
+
834
+ // Write bytes to the bound sink, optionally folding them into the running body
835
+ // CRC. A thenable return means an async sink -- the contract is synchronous, so
836
+ // that is a malformed sink (W_BAD_SINK). NOTE: _sinkPos is advanced by callers.
837
+ _sinkWrite(bytes, fold) {
838
+ if (fold && this._sinkCrcOn) this._sinkCrc = crc32cUpdate(this._sinkCrc, bytes, 0, bytes.length);
839
+ let ret;
840
+ // A sink that throws mid-emission fails the writer closed and its own error
841
+ // is rethrown verbatim -- never laundered into a package code (the FileIngest
842
+ // mid-stream precedent). A retry then hits W_FINALIZED.
843
+ try { ret = this._sink.write(bytes); }
844
+ catch (e) { this._finalized = true; throw e; }
845
+ if (isThenable(ret)) { this._finalized = true; raiseWriter('W_BAD_SINK', 'sink.write returned a thenable; sinks must be synchronous'); }
846
+ }
847
+
848
+ // Streaming emit of one finalized shard: payload, 8-alignment pad, local string
849
+ // table -- each folded into the body CRC. Retains only a scalar descriptor.
850
+ _streamEmitShard(payloadBytes, stBytes, rowCount, mins, maxes) {
851
+ const payloadOff = this._sinkPos;
852
+ const payloadLen = payloadBytes.length;
853
+ this._sinkWrite(payloadBytes, true);
854
+ this._sinkPos += payloadLen;
855
+ const pad = (8 - (payloadLen & 7)) & 7;
856
+ if (pad > 0) { this._sinkWrite(ZERO_PAD.subarray(0, pad), true); this._sinkPos += pad; }
857
+ const stOff = this._sinkPos;
858
+ const stLen = stBytes.length;
859
+ if (stLen > 0) { this._sinkWrite(stBytes, true); this._sinkPos += stLen; }
860
+ this._shards.push({
861
+ rowCount,
862
+ payloadOff,
863
+ payloadLen,
864
+ stringOff: stLen > 0 ? stOff : 0,
865
+ stringLen: stLen,
866
+ mins,
867
+ maxes,
868
+ });
869
+ }
870
+
754
871
  // Optional capability hook (BS-07): the Tokenizer calls this at construction
755
872
  // to hand over the live tokenizer. The Writer then reads src.absOffset at
756
873
  // record boundaries so the sample window is byte-true. A CALL only; the
@@ -762,100 +879,62 @@ export class Writer {
762
879
  // dance is a documented no-op retained for source compatibility until 2.0.
763
880
  setInputByteOffset() {}
764
881
 
765
- // -------- container assembly --------
766
-
767
- _assembleContainer() {
882
+ // -------- container assembly (placement-free emitters) --------
883
+ //
884
+ // Both finalize() (layout:'prefix') and finalizeToSink(layout:'stream') drive
885
+ // the SAME byte-producing emitters below. The classic prefix layout builds one
886
+ // contiguous buffer; the streaming layout writes payloads first, accumulates a
887
+ // scalar directory, and emits the schema/directory/zone-map trailer + footer at
888
+ // the end with a single header backpatch. The emitters take an explicit target
889
+ // offset so they are reusable across both a full-container buffer and a small
890
+ // trailer buffer.
891
+
892
+ // Encode field names once and size the schema block (8-byte padded). Shared by
893
+ // every layout.
894
+ _computeSchemaBlock() {
768
895
  const enc = new TextEncoder();
769
- const nameBytes = new Array(this._schema.fields.length);
896
+ const fields = this._schema.fields;
897
+ const nameBytes = new Array(fields.length);
770
898
  let nameBlobLen = 0;
771
- for (let i = 0; i < this._schema.fields.length; i++) {
772
- nameBytes[i] = enc.encode(this._schema.fields[i].name);
899
+ for (let i = 0; i < fields.length; i++) {
900
+ nameBytes[i] = enc.encode(fields[i].name);
773
901
  nameBlobLen += nameBytes[i].length;
774
902
  }
775
-
776
- const fieldCount = this._schema.fields.length;
777
- const descriptorBytes = fieldCount * FIELD_DESCRIPTOR_BYTES;
903
+ const descriptorBytes = fields.length * FIELD_DESCRIPTOR_BYTES;
778
904
  let schemaBlockBytes = 8 + descriptorBytes + 4 + nameBlobLen;
779
- const schemaPad = (8 - (schemaBlockBytes & 7)) & 7;
780
- schemaBlockBytes += schemaPad;
781
-
782
- const shardCount = this._shards.length;
783
- const shardDirBytes = shardCount * SHARD_ENTRY_BYTES;
784
-
785
- const schemaBlockOff = CONTAINER_HEADER_BYTES;
786
- const shardDirOff = schemaBlockOff + schemaBlockBytes;
905
+ schemaBlockBytes += (8 - (schemaBlockBytes & 7)) & 7;
906
+ return { nameBytes, nameBlobLen, descriptorBytes, schemaBlockBytes };
907
+ }
787
908
 
788
- // Zone maps segment (M7). Placed between shard directory and first shard
789
- // so a Reader loading header/schema/dir/zoneMaps up front does it in one
790
- // contiguous byte range.
791
- const T = this._trackedFieldIndices.length;
792
- const zoneMapsEnabled = T > 0 && shardCount > 0;
793
- let zoneMapsOff = 0;
794
- let zoneMapsBytes = 0;
795
- let zoneMapsFieldTableOff = 0;
796
- let zoneMapsMinsOff = 0;
797
- let zoneMapsMaxesOff = 0;
798
- if (zoneMapsEnabled) {
799
- zoneMapsOff = shardDirOff + shardDirBytes;
800
- // Layout: 16-byte header + T*u16 field indices, padded to 8, then
801
- // shardCount*T*8 mins, then shardCount*T*8 maxes.
802
- const headerLen = 16;
803
- const fieldTableLen = T * 2;
804
- const fieldTablePad = (8 - (fieldTableLen & 7)) & 7;
805
- zoneMapsFieldTableOff = zoneMapsOff + headerLen;
806
- const minsRel = headerLen + fieldTableLen + fieldTablePad;
807
- zoneMapsMinsOff = zoneMapsOff + minsRel;
808
- const minsMaxesLen = shardCount * T * 8 * 2;
809
- zoneMapsMaxesOff = zoneMapsMinsOff + shardCount * T * 8;
810
- zoneMapsBytes = minsRel + minsMaxesLen;
811
- }
812
- const firstShardOff = shardDirOff + shardDirBytes + zoneMapsBytes;
909
+ _zoneMapsByteLen(T, shardCount) {
910
+ const fieldTableLen = T * 2;
911
+ const fieldTablePad = (8 - (fieldTableLen & 7)) & 7;
912
+ return 16 + fieldTableLen + fieldTablePad + shardCount * T * 8 * 2;
913
+ }
813
914
 
814
- // Compute per-shard payload and string-table offsets
815
- let cursor = firstShardOff;
816
- const shardPayloadOffsets = new Array(shardCount);
817
- const shardPayloadPadded = new Array(shardCount);
818
- const shardStringTableOffsets = new Array(shardCount);
819
- const shardStringTableLens = new Array(shardCount);
820
- for (let i = 0; i < shardCount; i++) {
821
- const s = this._shards[i];
822
- shardPayloadOffsets[i] = cursor;
823
- const payloadLen = s.bytes.length;
824
- const payloadPad = (8 - (payloadLen & 7)) & 7;
825
- shardPayloadPadded[i] = payloadLen + payloadPad;
826
- cursor += shardPayloadPadded[i];
827
- // String table immediately follows the shard payload
828
- shardStringTableOffsets[i] = cursor;
829
- const stLen = s.stringTableBytes.length;
830
- shardStringTableLens[i] = stLen;
831
- cursor += stLen; // stringTable.serialize() already 8-byte padded
832
- }
915
+ _emitHeaderInto(dv, bytes, off, h) {
916
+ bytes[off + 0] = 0x4C; bytes[off + 1] = 0x42; bytes[off + 2] = 0x4B; bytes[off + 3] = 0x31;
917
+ dv.setUint16(off + 4, FORMAT_VERSION, true);
918
+ bytes[off + 6] = ENDIAN_LE;
919
+ bytes[off + 7] = 0;
920
+ dv.setBigUint64(off + 8, BigInt(h.schemaBlockOff), true);
921
+ dv.setBigUint64(off + 16, BigInt(h.metadataOff), true); // metadata_off -- 0 iff no zone maps
922
+ dv.setBigUint64(off + 24, BigInt(h.shardDirOff), true);
923
+ dv.setUint32(off + 32, h.shardCount, true);
924
+ dv.setUint32(off + 36, 0, true); // reserved1
925
+ dv.setBigUint64(off + 40, BigInt(h.totalRows), true);
926
+ }
833
927
 
834
- const totalBytes = cursor + FOOTER_BYTES;
835
- const container = new ArrayBuffer(totalBytes);
836
- const dv = new DataView(container);
837
- const bytes = new Uint8Array(container);
838
-
839
- // Header
840
- bytes[0] = 0x4C; bytes[1] = 0x42; bytes[2] = 0x4B; bytes[3] = 0x31;
841
- dv.setUint16(4, FORMAT_VERSION, true);
842
- bytes[6] = ENDIAN_LE;
843
- bytes[7] = 0;
844
- dv.setBigUint64(8, BigInt(schemaBlockOff), true);
845
- dv.setBigUint64(16, BigInt(zoneMapsOff), true); // metadata_off — 0 iff no zone maps
846
- dv.setBigUint64(24, BigInt(shardDirOff), true);
847
- dv.setUint32(32, shardCount, true);
848
- dv.setUint32(36, 0, true); // reserved1
849
- dv.setBigUint64(40, BigInt(this._totalRows), true);
850
-
851
- // Schema block
852
- dv.setUint32(schemaBlockOff, fieldCount, true);
853
- dv.setUint32(schemaBlockOff + 4, this._schema.rowStride, true);
928
+ _emitSchemaInto(dv, bytes, off, sb) {
929
+ const fields = this._schema.fields;
930
+ const fieldCount = fields.length;
931
+ dv.setUint32(off, fieldCount, true);
932
+ dv.setUint32(off + 4, this._schema.rowStride, true);
854
933
  let nameOff = 0;
855
934
  for (let i = 0; i < fieldCount; i++) {
856
- const descOff = schemaBlockOff + 8 + i * FIELD_DESCRIPTOR_BYTES;
857
- const f = this._schema.fields[i];
858
- const nb = nameBytes[i];
935
+ const descOff = off + 8 + i * FIELD_DESCRIPTOR_BYTES;
936
+ const f = fields[i];
937
+ const nb = sb.nameBytes[i];
859
938
  dv.setUint16(descOff + 0, nb.length, true);
860
939
  dv.setUint16(descOff + 2, f.offsetInRow, true);
861
940
  bytes[descOff + 4] = f.laneKind;
@@ -865,67 +944,227 @@ export class Writer {
865
944
  dv.setBigUint64(descOff + 16, 0n, true);
866
945
  nameOff += nb.length;
867
946
  }
868
- const nameBlobLenOff = schemaBlockOff + 8 + descriptorBytes;
869
- dv.setUint32(nameBlobLenOff, nameBlobLen, true);
947
+ const nameBlobLenOff = off + 8 + sb.descriptorBytes;
948
+ dv.setUint32(nameBlobLenOff, sb.nameBlobLen, true);
870
949
  let namePosOff = nameBlobLenOff + 4;
871
950
  for (let i = 0; i < fieldCount; i++) {
872
- bytes.set(nameBytes[i], namePosOff);
873
- namePosOff += nameBytes[i].length;
951
+ bytes.set(sb.nameBytes[i], namePosOff);
952
+ namePosOff += sb.nameBytes[i].length;
874
953
  }
954
+ }
875
955
 
876
- // Shard directory
877
- for (let i = 0; i < shardCount; i++) {
878
- const entryOff = shardDirOff + i * SHARD_ENTRY_BYTES;
879
- const s = this._shards[i];
880
- dv.setBigUint64(entryOff + 0, BigInt(shardPayloadOffsets[i]), true);
881
- dv.setUint32(entryOff + 8, s.bytes.length, true);
882
- dv.setUint32(entryOff + 12, s.rowCount, true);
883
- dv.setUint16(entryOff + 16, SHARD_MIN_READER_VERSION, true);
884
- dv.setUint16(entryOff + 18, SHARD_FLAGS_NONE, true);
885
- dv.setUint32(entryOff + 20, 0, true); // reserved
886
- dv.setBigUint64(entryOff + 24, BigInt(shardStringTableLens[i] > 0 ? shardStringTableOffsets[i] : 0), true);
887
- dv.setBigUint64(entryOff + 32, BigInt(shardStringTableLens[i]), true);
888
- }
956
+ _emitDirEntryInto(dv, entryOff, payloadOff, payloadLen, rowCount, stOff, stLen) {
957
+ dv.setBigUint64(entryOff + 0, BigInt(payloadOff), true);
958
+ dv.setUint32(entryOff + 8, payloadLen, true);
959
+ dv.setUint32(entryOff + 12, rowCount, true);
960
+ dv.setUint16(entryOff + 16, SHARD_MIN_READER_VERSION, true);
961
+ dv.setUint16(entryOff + 18, SHARD_FLAGS_NONE, true);
962
+ dv.setUint32(entryOff + 20, 0, true); // reserved
963
+ dv.setBigUint64(entryOff + 24, BigInt(stLen > 0 ? stOff : 0), true);
964
+ dv.setBigUint64(entryOff + 32, BigInt(stLen), true);
965
+ }
889
966
 
890
- // Zone maps segment (M7)
891
- if (zoneMapsEnabled) {
892
- // Header: 'ZM01' magic + shard_count + tracked_field_count + reserved0
893
- bytes[zoneMapsOff + 0] = 0x30; bytes[zoneMapsOff + 1] = 0x5A;
894
- bytes[zoneMapsOff + 2] = 0x4D; bytes[zoneMapsOff + 3] = 0x31;
895
- dv.setUint32(zoneMapsOff + 4, shardCount, true);
896
- dv.setUint32(zoneMapsOff + 8, T, true);
897
- dv.setUint32(zoneMapsOff + 12, 0, true);
898
- // Field index table
967
+ _emitZoneMapsInto(dv, bytes, off, T, shardCount, src) {
968
+ bytes[off + 0] = 0x30; bytes[off + 1] = 0x5A; bytes[off + 2] = 0x4D; bytes[off + 3] = 0x31; // 'ZM01'
969
+ dv.setUint32(off + 4, shardCount, true);
970
+ dv.setUint32(off + 8, T, true);
971
+ dv.setUint32(off + 12, 0, true);
972
+ const fieldTableOff = off + 16;
973
+ for (let t = 0; t < T; t++) dv.setUint16(fieldTableOff + t * 2, this._trackedFieldIndices[t], true);
974
+ const fieldTableLen = T * 2;
975
+ const fieldTablePad = (8 - (fieldTableLen & 7)) & 7;
976
+ const minsOff = fieldTableOff + fieldTableLen + fieldTablePad;
977
+ const maxesOff = minsOff + shardCount * T * 8;
978
+ for (let s = 0; s < shardCount; s++) {
979
+ const sh = src[s];
899
980
  for (let t = 0; t < T; t++) {
900
- dv.setUint16(zoneMapsFieldTableOff + t * 2, this._trackedFieldIndices[t], true);
901
- }
902
- // Mins / maxes: row-major, [shard * T + tracked]
903
- for (let s = 0; s < shardCount; s++) {
904
- const sh = this._shards[s];
905
- for (let t = 0; t < T; t++) {
906
- dv.setFloat64(zoneMapsMinsOff + (s * T + t) * 8, sh.mins[t], true);
907
- dv.setFloat64(zoneMapsMaxesOff + (s * T + t) * 8, sh.maxes[t], true);
908
- }
981
+ dv.setFloat64(minsOff + (s * T + t) * 8, sh.mins[t], true);
982
+ dv.setFloat64(maxesOff + (s * T + t) * 8, sh.maxes[t], true);
909
983
  }
910
984
  }
985
+ }
911
986
 
912
- // Payloads + local string tables
913
- for (let i = 0; i < shardCount; i++) {
914
- bytes.set(this._shards[i].bytes, shardPayloadOffsets[i]);
915
- bytes.set(this._shards[i].stringTableBytes, shardStringTableOffsets[i]);
916
- }
917
-
918
- // Footer
919
- const footerOff = totalBytes - FOOTER_BYTES;
920
- dv.setUint32(footerOff + 0, 0xFFFFFFFF, true);
987
+ _emitFooterInto(dv, bytes, footerOff, crcVal) {
988
+ dv.setUint32(footerOff + 0, crcVal >>> 0, true);
921
989
  dv.setUint32(footerOff + 4, 0, true);
922
990
  bytes[footerOff + 8] = 0x31;
923
991
  bytes[footerOff + 9] = 0x4B;
924
992
  bytes[footerOff + 10] = 0x42;
925
993
  bytes[footerOff + 11] = 0x4C;
926
994
  dv.setUint32(footerOff + 12, FOOTER_BYTES, true);
995
+ }
996
+
997
+ // Classic prefix layout: header | schema | directory | zone maps | payloads |
998
+ // footer, in ONE contiguous buffer. Byte-for-byte identical to the pre-M6
999
+ // assembler when crc is off; the optional CRC-32C covers [0, footer_off).
1000
+ _buildPrefixBytes(crcOn) {
1001
+ const sb = this._computeSchemaBlock();
1002
+ const shardCount = this._shards.length;
1003
+ const shardDirBytes = shardCount * SHARD_ENTRY_BYTES;
1004
+ const schemaBlockOff = CONTAINER_HEADER_BYTES;
1005
+ const shardDirOff = schemaBlockOff + sb.schemaBlockBytes;
1006
+ const T = this._trackedFieldIndices.length;
1007
+ const zoneMapsEnabled = T > 0 && shardCount > 0;
1008
+ let zoneMapsOff = 0, zoneMapsBytes = 0;
1009
+ if (zoneMapsEnabled) {
1010
+ zoneMapsOff = shardDirOff + shardDirBytes;
1011
+ zoneMapsBytes = this._zoneMapsByteLen(T, shardCount);
1012
+ }
1013
+ const firstShardOff = shardDirOff + shardDirBytes + zoneMapsBytes;
1014
+ let cursor = firstShardOff;
1015
+ const payloadOffs = new Array(shardCount);
1016
+ const stOffs = new Array(shardCount);
1017
+ for (let i = 0; i < shardCount; i++) {
1018
+ const s = this._shards[i];
1019
+ payloadOffs[i] = cursor;
1020
+ const payloadLen = s.bytes.length;
1021
+ const payloadPad = (8 - (payloadLen & 7)) & 7;
1022
+ cursor += payloadLen + payloadPad;
1023
+ stOffs[i] = cursor;
1024
+ cursor += s.stringTableBytes.length; // stringTable.serialize() is already 8-padded
1025
+ }
1026
+ const footerOff = cursor;
1027
+ const totalBytes = cursor + FOOTER_BYTES;
1028
+ const buffer = new ArrayBuffer(totalBytes);
1029
+ const dv = new DataView(buffer);
1030
+ const bytes = new Uint8Array(buffer);
1031
+
1032
+ this._emitHeaderInto(dv, bytes, 0, {
1033
+ schemaBlockOff, metadataOff: zoneMapsEnabled ? zoneMapsOff : 0,
1034
+ shardDirOff, shardCount, totalRows: this._totalRows,
1035
+ });
1036
+ this._emitSchemaInto(dv, bytes, schemaBlockOff, sb);
1037
+ for (let i = 0; i < shardCount; i++) {
1038
+ const s = this._shards[i];
1039
+ this._emitDirEntryInto(dv, shardDirOff + i * SHARD_ENTRY_BYTES,
1040
+ payloadOffs[i], s.bytes.length, s.rowCount, stOffs[i], s.stringTableBytes.length);
1041
+ }
1042
+ if (zoneMapsEnabled) this._emitZoneMapsInto(dv, bytes, zoneMapsOff, T, shardCount, this._shards);
1043
+ for (let i = 0; i < shardCount; i++) {
1044
+ bytes.set(this._shards[i].bytes, payloadOffs[i]);
1045
+ bytes.set(this._shards[i].stringTableBytes, stOffs[i]);
1046
+ }
1047
+ let crcVal = CRC_ABSENT;
1048
+ if (crcOn) crcVal = crc32cFinal(crc32cUpdate(crc32cInit(), bytes, 0, footerOff));
1049
+ this._emitFooterInto(dv, bytes, footerOff, crcVal);
1050
+ return bytes;
1051
+ }
927
1052
 
928
- return { buffer: container, totalRows: this._totalRows, shardCount, schema: this._schema };
1053
+ // PUBLIC. Emit the container to a caller sink instead of returning a buffer.
1054
+ // layout:'prefix' -- the classic layout, written in one sink.write().
1055
+ // layout:'stream' -- payloads stream first, then the trailer + footer, with
1056
+ // one sink.writeAt(header, 0) backpatch (needs writeAt).
1057
+ // Returns { totalRows, shardCount, schema|mode, bytesWritten, layout }. When
1058
+ // the writer was pre-bound to this sink via beginStream (bounded-RAM ingest),
1059
+ // the shards are already emitted and this only writes the trailer.
1060
+ finalizeToSink(sink, opts) {
1061
+ if (this._finalized) throw new WriterError('W_FINALIZED', 'writer already finalized');
1062
+ checkOpts('Writer.finalizeToSink', opts, FINALIZE_TO_SINK_OPTS, raiseWriter);
1063
+ opts = opts || {};
1064
+
1065
+ if (this._sink !== null) {
1066
+ // Pre-bound streaming: shards were emitted during ingest. Finish the input
1067
+ // (streams the trailing shard), then write the trailer.
1068
+ if (sink !== this._sink) raiseWriter('W_BAD_SINK', 'finalizeToSink sink differs from the streaming sink');
1069
+ if (opts.layout !== undefined && opts.layout !== 'stream')
1070
+ raiseWriter('W_BAD_SINK', "a streaming writer must be finalized with layout:'stream'");
1071
+ const crcOn = opts.crc !== undefined ? opts.crc === true : this._sinkCrcOn;
1072
+ this._sinkCrcOn = crcOn;
1073
+ this._completeInput();
1074
+ return this._finishStream(sink, crcOn);
1075
+ }
1076
+
1077
+ const layout = opts.layout !== undefined ? opts.layout : 'stream';
1078
+ const crcOn = opts.crc !== undefined ? opts.crc === true : this._crc;
1079
+ validateSink(sink, layout === 'stream', raiseWriter);
1080
+ this._completeInput();
1081
+
1082
+ if (layout === 'prefix') {
1083
+ const bytes = this._buildPrefixBytes(crcOn);
1084
+ let ret;
1085
+ try { ret = sink.write(bytes); }
1086
+ catch (e) { this._finalized = true; throw e; }
1087
+ if (isThenable(ret)) { this._finalized = true; raiseWriter('W_BAD_SINK', 'sink.write returned a thenable; sinks must be synchronous'); }
1088
+ this._finalized = true;
1089
+ this._recordDepth = FINALIZED_DEPTH;
1090
+ return { totalRows: this._totalRows, shardCount: this._shards.length, schema: this._schema, bytesWritten: bytes.length, layout: 'prefix' };
1091
+ }
1092
+
1093
+ // layout 'stream', buffered shards: stream them out now, dropping each.
1094
+ const buffered = this._shards;
1095
+ this._shards = [];
1096
+ this._sink = sink;
1097
+ this._sinkCrcOn = crcOn;
1098
+ this._sinkPos = 0;
1099
+ this._sinkCrc = crc32cInit();
1100
+ this._sinkWrite(new Uint8Array(CONTAINER_HEADER_BYTES), false);
1101
+ this._sinkPos = CONTAINER_HEADER_BYTES;
1102
+ for (let i = 0; i < buffered.length; i++) {
1103
+ const s = buffered[i];
1104
+ this._streamEmitShard(s.bytes, s.stringTableBytes, s.rowCount, s.mins, s.maxes);
1105
+ buffered[i] = null;
1106
+ }
1107
+ return this._finishStream(sink, crcOn);
1108
+ }
1109
+
1110
+ // Write the schema/directory/zone-map trailer + footer and backpatch the header.
1111
+ // _shards holds one scalar descriptor per emitted shard; _sinkPos/_sinkCrc are
1112
+ // the current sink position and running body CRC over [48, pos).
1113
+ _finishStream(sink, crcOn) {
1114
+ const shardCount = this._shards.length;
1115
+ const sb = this._computeSchemaBlock();
1116
+ const schemaBlockOff = this._sinkPos; // 8-aligned: every payload region is 8-padded
1117
+ const shardDirBytes = shardCount * SHARD_ENTRY_BYTES;
1118
+ const shardDirOff = schemaBlockOff + sb.schemaBlockBytes;
1119
+ const T = this._trackedFieldIndices.length;
1120
+ const zoneMapsEnabled = T > 0 && shardCount > 0;
1121
+ let zoneMapsOff = 0, zoneMapsBytes = 0;
1122
+ if (zoneMapsEnabled) {
1123
+ zoneMapsOff = shardDirOff + shardDirBytes;
1124
+ zoneMapsBytes = this._zoneMapsByteLen(T, shardCount);
1125
+ }
1126
+ const trailerLen = sb.schemaBlockBytes + shardDirBytes + zoneMapsBytes;
1127
+ const footerOff = schemaBlockOff + trailerLen;
1128
+
1129
+ const trailer = new Uint8Array(trailerLen);
1130
+ const tdv = new DataView(trailer.buffer);
1131
+ this._emitSchemaInto(tdv, trailer, 0, sb);
1132
+ for (let i = 0; i < shardCount; i++) {
1133
+ const d = this._shards[i];
1134
+ this._emitDirEntryInto(tdv, sb.schemaBlockBytes + i * SHARD_ENTRY_BYTES,
1135
+ d.payloadOff, d.payloadLen, d.rowCount, d.stringOff, d.stringLen);
1136
+ }
1137
+ if (zoneMapsEnabled) this._emitZoneMapsInto(tdv, trailer, sb.schemaBlockBytes + shardDirBytes, T, shardCount, this._shards);
1138
+ this._sinkWrite(trailer, true);
1139
+ this._sinkPos += trailerLen;
1140
+
1141
+ const header = new Uint8Array(CONTAINER_HEADER_BYTES);
1142
+ const hdv = new DataView(header.buffer);
1143
+ this._emitHeaderInto(hdv, header, 0, {
1144
+ schemaBlockOff, metadataOff: zoneMapsEnabled ? zoneMapsOff : 0,
1145
+ shardDirOff, shardCount, totalRows: this._totalRows,
1146
+ });
1147
+
1148
+ let crcVal = CRC_ABSENT;
1149
+ if (crcOn) {
1150
+ const headerCrc = crc32cFinal(crc32cUpdate(crc32cInit(), header, 0, CONTAINER_HEADER_BYTES));
1151
+ const suffixCrc = crc32cFinal(this._sinkCrc);
1152
+ crcVal = crc32cCombine(headerCrc, suffixCrc, footerOff - CONTAINER_HEADER_BYTES);
1153
+ }
1154
+ const footer = new Uint8Array(FOOTER_BYTES);
1155
+ const fdv = new DataView(footer.buffer);
1156
+ this._emitFooterInto(fdv, footer, 0, crcVal);
1157
+ this._sinkWrite(footer, false);
1158
+ this._sinkPos += FOOTER_BYTES;
1159
+
1160
+ let ret;
1161
+ try { ret = sink.writeAt(header, 0); }
1162
+ catch (e) { this._finalized = true; throw e; }
1163
+ if (isThenable(ret)) { this._finalized = true; raiseWriter('W_BAD_SINK', 'sink.writeAt returned a thenable; sinks must be synchronous'); }
1164
+
1165
+ this._finalized = true;
1166
+ this._recordDepth = FINALIZED_DEPTH;
1167
+ return { totalRows: this._totalRows, shardCount, schema: this._schema, bytesWritten: this._sinkPos, layout: 'stream' };
929
1168
  }
930
1169
 
931
1170
  get schema() { return this._schema; }
package/src/index.js CHANGED
@@ -28,7 +28,7 @@ export {
28
28
  PreserveWriter, PreserveWriterError,
29
29
  PreserveReader, PreserveReaderError,
30
30
  };
31
- export const VERSION = '1.5.0';
31
+ export const VERSION = '1.6.0';
32
32
 
33
33
  const encoder = new TextEncoder();
34
34
 
@@ -188,12 +188,12 @@ async function _serializeAsyncIterablePreserve(iterable, opts) {
188
188
 
189
189
  // Deserialize LBK1 bytes into the right Reader for the container's mode.
190
190
  // Auto-detects preserve vs schema via header flag byte at offset 7 bit 0.
191
- export function deserialize(bytes) {
191
+ export function deserialize(bytes, opts) {
192
192
  const buffer = toContainerBuffer(bytes, 'deserialize');
193
193
  if (buffer.byteLength < 8) {
194
194
  throw new ReaderError('R_TRUNCATED', 'container too small to inspect header flags');
195
195
  }
196
196
  const flags = new Uint8Array(buffer, 7, 1)[0];
197
- if (flags & 0x01) return new PreserveReader(buffer);
198
- return new Reader(buffer);
197
+ if (flags & 0x01) return new PreserveReader(buffer, opts);
198
+ return new Reader(buffer, opts);
199
199
  }
@@ -13,9 +13,17 @@ export interface PreserveShardHandle {
13
13
  endRow: number;
14
14
  }
15
15
 
16
+ export interface PreserveReaderOptions {
17
+ /** Verify the footer CRC-32C at open; throws R_BAD_CRC on mismatch, R_CRC_ABSENT if absent. */
18
+ verifyCrc?: boolean;
19
+ }
20
+
16
21
  export class PreserveReader {
17
- static fromBuffer(input: ArrayBuffer | Uint8Array): PreserveReader;
18
- constructor(buffer: ArrayBuffer);
22
+ static fromBuffer(input: ArrayBuffer | Uint8Array, opts?: PreserveReaderOptions): PreserveReader;
23
+ constructor(buffer: ArrayBuffer, opts?: PreserveReaderOptions);
24
+
25
+ /** Recompute the footer CRC-32C over [0, footer_off): 'ok' | 'absent'; throws R_BAD_CRC on mismatch. */
26
+ verifyCrc(): 'ok' | 'absent';
19
27
 
20
28
  readonly totalRows: number;
21
29
  readonly shardCount: number;