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