@zakkster/lite-bake-stream 1.4.1 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/Writer.js CHANGED
@@ -23,6 +23,21 @@
23
23
  // String fields intern via the per-shard StringTable which allocates only on
24
24
  // unique inserts (bounded by cardinality, not row count).
25
25
  //
26
+ // Shard-byte budget (D2): a shard rolls when its row count reaches the maxRows
27
+ // ceiling OR when payload_bytes + string_table_bytes reaches targetShardBytes.
28
+ // The byte term is CHUNK-INDEPENDENT: it reads only _currentShardRowCount,
29
+ // rowStride, _targetShardBytes and _stringTableBytes -- the first three are
30
+ // chunk-invariant and _stringTableBytes is a pure function of the unique
31
+ // interned byte ranges in arrival order (= logical record order, which the
32
+ // Tokenizer delivers identically for any input chunking). No chunk-local state
33
+ // (absOffset, buffer boundaries) enters, so re-chunked input yields byte-
34
+ // identical containers (the t0 law). A single row or string that alone exceeds
35
+ // targetShardBytes still gets its own oversized shard (maxRows has a
36
+ // Math.max(1, ...) floor and the budget runs post-write); payload_len stays
37
+ // u32-honest via the targetShardBytes constructor guard. A schema with no U32
38
+ // lane keeps a zero string budget and rolls on rows only -- byte-identical to
39
+ // the pre-D2 baseline.
40
+ //
26
41
  // Error codes (stable):
27
42
  // W_TOP_LEVEL_NOT_OBJECT - top-level value is not an object
28
43
  // W_NESTED_UNSUPPORTED - nested object/array inside a record
@@ -42,8 +57,10 @@
42
57
 
43
58
  import { StringTable } from './StringTable.js';
44
59
  import { checkOpts } from './Opts.js';
60
+ import { crc32cInit, crc32cUpdate, crc32cFinal, crc32cCombine } from './Crc32c.js';
61
+ import { validateSink, isThenable } from './Views.js';
45
62
 
46
- export const VERSION = '1.4.1';
63
+ export const VERSION = '1.6.0';
47
64
 
48
65
  const U32_MAX = 4294967295;
49
66
  // Post-finalize sentinel for _recordDepth. Chosen = 2 so every post-finalize
@@ -56,7 +73,13 @@ const WRITER_OPTS = {
56
73
  schema: { t: 'obj', nullable: true },
57
74
  targetShardBytes: { t: 'int', min: 1, max: U32_MAX },
58
75
  sampleBytes: { t: 'int', min: 0, max: U32_MAX },
76
+ crc: { t: 'bool' },
77
+ };
78
+ const FINALIZE_TO_SINK_OPTS = {
79
+ layout: { t: 'enum', values: ['prefix', 'stream'] },
80
+ crc: { t: 'bool' },
59
81
  };
82
+ const CRC_ABSENT = 0xFFFFFFFF;
60
83
  function raiseWriter(code, msg) { throw new WriterError(code, msg); }
61
84
 
62
85
  const CONTAINER_HEADER_BYTES = 48;
@@ -76,6 +99,10 @@ const SHARD_FLAGS_NONE = 0;
76
99
  // U32-lane fields). Read-only; never written into.
77
100
  const EMPTY_STRING_TABLE_BYTES = new Uint8Array(0);
78
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
+
79
106
  const FORMAT_VERSION = 1;
80
107
  const ENDIAN_LE = 1;
81
108
 
@@ -105,6 +132,12 @@ export class WriterError extends Error {
105
132
  }
106
133
  }
107
134
 
135
+ // Shared decoder for sample-window key names (BS-27 floor). onKey fires once per
136
+ // key per sample record; a fresh TextDecoder each time is pure garbage. One
137
+ // module-scope instance, reused -- decode() is stateless across calls. The other
138
+ // TextDecoders in the codebase are per-open/lazy and stay as they are.
139
+ const KEY_DECODER = new TextDecoder();
140
+
108
141
  function hashBytes(bytes, from, to) {
109
142
  let h = 0x811c9dc5 | 0;
110
143
  for (let i = from; i < to; i++) {
@@ -204,6 +237,17 @@ export class Writer {
204
237
  this._targetShardBytes = opts.targetShardBytes !== undefined ? opts.targetShardBytes : DEFAULT_SHARD_BYTES;
205
238
  this._sampleBytes = opts.sampleBytes !== undefined ? opts.sampleBytes : this._targetShardBytes;
206
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)
207
251
 
208
252
  // Frozen schema state
209
253
  this._schema = null; // { fields: [{name, laneKind, offsetInRow}], rowStride }
@@ -226,6 +270,15 @@ export class Writer {
226
270
  this._shards = []; // { bytes, rowCount, stringTableBytes }
227
271
  this._totalRows = 0;
228
272
 
273
+ // D2 shard-byte budget (zero-alloc, chunk-independent). _stringTableBytes is
274
+ // the serialized byte length the CURRENT shard's string table would emit RIGHT
275
+ // NOW; _maxInternedIdx is the highest interned index seen this shard, so a new
276
+ // unique arrival (strIdx > _maxInternedIdx) is detected in one compare. Both
277
+ // recomputed only in the cold unique-string arm (bounded by cardinality, not
278
+ // rows) and reset at each shard finalize. Real values are set at freeze.
279
+ this._stringTableBytes = 0;
280
+ this._maxInternedIdx = 0;
281
+
229
282
  // Per-record parse state
230
283
  this._recordDepth = 0;
231
284
  this._currentKeyBytes = null;
@@ -285,7 +338,7 @@ export class Writer {
285
338
  // buffer for the value bytes before onNumber/onString fires, so we cannot
286
339
  // defer decoding. String allocation here is expected — bounded to the
287
340
  // sample window; steady-state post-freeze remains zero-alloc.
288
- this._currentKeyName = new TextDecoder().decode(bytes.subarray(from, to));
341
+ this._currentKeyName = KEY_DECODER.decode(bytes.subarray(from, to));
289
342
  }
290
343
  }
291
344
 
@@ -343,8 +396,17 @@ export class Writer {
343
396
  const lane = this._fieldLaneKinds[idx];
344
397
  if (lane !== LANE_U32) throw new WriterError('W_LANE_MISMATCH',
345
398
  'field ' + this._schema.fields[idx].name + ' is not a U32 lane but got a string');
346
- const strIdx = this._currentShardStringTable().intern(bytes, from, to);
399
+ const st = this._currentShardStringTable();
400
+ const strIdx = st.intern(bytes, from, to);
347
401
  this._rowValueSlotsU32[idx] = strIdx;
402
+ // D2 (cold arm, unique arrivals only): recompute the serialized string-table
403
+ // byte length. serialize() emits (8 + 4*(count+1) + blobLen), 8-padded, and
404
+ // count = strIdx + 1 for a fresh entry, so the padded form is
405
+ // (16 + 4*strIdx + blobLen) rounded up to 8 -- exact vs StringTable.serialize.
406
+ if (strIdx > this._maxInternedIdx) {
407
+ this._maxInternedIdx = strIdx;
408
+ this._stringTableBytes = (16 + 4 * strIdx + st.blobLen + 7) & ~7;
409
+ }
348
410
  } else {
349
411
  if (!this._currentKeyName) return;
350
412
  this._sample.setString(this._currentKeyName, bytes, from, to);
@@ -452,6 +514,19 @@ export class Writer {
452
514
  this._fieldLaneKinds[i] = fields[i].laneKind;
453
515
  this._fieldOffsets[i] = fields[i].offsetInRow;
454
516
  }
517
+ // R7 floor: O(1) field-name resolution. hash -> field index, or an array of
518
+ // indices when two names collide (Array.isArray in the cold arm). Built once
519
+ // at freeze; _lookupFieldIdx replaces its O(F) scan with one Map.get plus the
520
+ // mandatory byte-equal confirm, so a hash collision still resolves correctly.
521
+ const fieldHashMap = new Map();
522
+ for (let i = 0; i < fields.length; i++) {
523
+ const h = this._fieldNameHashes[i];
524
+ const existing = fieldHashMap.get(h);
525
+ if (existing === undefined) fieldHashMap.set(h, i);
526
+ else if (Array.isArray(existing)) existing.push(i);
527
+ else fieldHashMap.set(h, [existing, i]);
528
+ }
529
+ this._fieldHashMap = fieldHashMap;
455
530
  this._rowValueSlotsF64 = new Float64Array(fields.length);
456
531
  this._rowValueSlotsU32 = new Uint32Array(fields.length);
457
532
  this._currentShardMaxRows = Math.max(1, Math.floor(this._targetShardBytes / rowStride));
@@ -467,14 +542,27 @@ export class Writer {
467
542
  this._trackedFieldToPos = new Int32Array(fields.length);
468
543
  for (let i = 0; i < fields.length; i++) this._trackedFieldToPos[i] = -1;
469
544
  for (let t = 0; t < tracked.length; t++) this._trackedFieldToPos[tracked[t]] = t;
545
+
546
+ // D2 budget baseline. A fresh string table always carries reserved entry 0,
547
+ // so a U32-lane shard with no real strings still serializes to 16 bytes; a
548
+ // schema with no U32 lane emits NO table (0 bytes, EMPTY_STRING_TABLE_BYTES).
549
+ // Seeding _stringTableBytes to 0 for the F64-only case keeps such containers
550
+ // byte-identical to the pre-D2 baseline (the roll then fires on rows only via
551
+ // the maxRows term, which is <= the byte term for a zero string budget).
552
+ this._maxInternedIdx = 0;
553
+ this._stringTableBytes = this._hasU32 ? 16 : 0;
470
554
  }
471
555
 
472
556
  _lookupFieldIdx(bytes, from, to) {
473
557
  const h = hashBytes(bytes, from, to);
474
- const hashes = this._fieldNameHashes;
558
+ const hit = this._fieldHashMap.get(h);
559
+ if (hit === undefined) return -1;
475
560
  const names = this._fieldNamesUtf8;
476
- for (let i = 0; i < hashes.length; i++) {
477
- if (hashes[i] === h && bytesEqual(bytes, from, to, names[i])) return i;
561
+ // Hit path (the common case): a single index. Byte-confirm and return.
562
+ if (typeof hit === 'number') return bytesEqual(bytes, from, to, names[hit]) ? hit : -1;
563
+ // Cold: >= 2 field names share this hash; byte-confirm each candidate.
564
+ for (let k = 0; k < hit.length; k++) {
565
+ if (bytesEqual(bytes, from, to, names[hit[k]])) return hit[k];
478
566
  }
479
567
  return -1;
480
568
  }
@@ -511,7 +599,15 @@ export class Writer {
511
599
  }
512
600
  this._currentShardRowCount++;
513
601
  this._totalRows++;
514
- if (this._currentShardRowCount >= this._currentShardMaxRows) this._finalizeCurrentShard();
602
+ // D2 roll: fire on the row ceiling OR when payload + string-table bytes
603
+ // reach the target. The row-ceiling term fires first for any F64-only shard
604
+ // (string budget 0, maxRows = floor(target/stride) <= target/stride), so
605
+ // such containers stay byte-identical to baseline; string shards can roll
606
+ // earlier. Zero allocation -- one add + compare per row.
607
+ const n = this._currentShardRowCount;
608
+ if (n >= this._currentShardMaxRows ||
609
+ n * this._schema.rowStride + this._stringTableBytes >= this._targetShardBytes)
610
+ this._finalizeCurrentShard();
515
611
  } else {
516
612
  const endOff = this._source !== null ? this._source.absOffset : this._absOffset;
517
613
  const consumed = Math.max(1, endOff - this._recordStartByteOffset);
@@ -560,13 +656,25 @@ export class Writer {
560
656
  const shardMaxes = new Float64Array(T);
561
657
  shardMins.set(this._currentShardMins);
562
658
  shardMaxes.set(this._currentShardMaxes);
563
- this._shards.push({
564
- bytes: copy,
565
- rowCount: this._currentShardRowCount,
566
- stringTableBytes: stBytes,
567
- mins: shardMins,
568
- maxes: shardMaxes,
569
- });
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
+ }
570
678
  this._currentShardBuffer = null;
571
679
  this._currentShardBytes = null;
572
680
  this._currentShardDv = null;
@@ -574,6 +682,9 @@ export class Writer {
574
682
  // Reset string table for next shard (per-shard independence)
575
683
  st.reset();
576
684
  this._perShardStringTable = st;
685
+ // Reset the D2 budget trackers alongside st.reset() (per-shard independence).
686
+ this._maxInternedIdx = 0;
687
+ this._stringTableBytes = this._hasU32 ? 16 : 0;
577
688
  }
578
689
 
579
690
  _drainSampleToShards() {
@@ -607,6 +718,11 @@ export class Writer {
607
718
  const trackedIdx = this._trackedFieldToPos;
608
719
  const mins = this._currentShardMins;
609
720
  const maxes = this._currentShardMaxes;
721
+ // D2: honour the same string-aware byte budget the post-freeze hot path
722
+ // uses, so a sample-drained container and a post-freeze one agree on shard
723
+ // boundaries for identical logical input. writtenRows may finish below
724
+ // chunkRows when the string table pushes the shard over target.
725
+ let writtenRows = chunkRows;
610
726
  for (let r = 0; r < chunkRows; r++) {
611
727
  const rowOff = r * stride;
612
728
  for (let f = 0; f < fields.length; f++) {
@@ -625,14 +741,24 @@ export class Writer {
625
741
  const strBytes = sampleTable.bytesAt(sampleIdx);
626
742
  const newIdx = strBytes ? dstTable.intern(strBytes, 0, strBytes.length) : 0;
627
743
  dv.setUint32(fo, newIdx, true);
744
+ if (newIdx > this._maxInternedIdx) {
745
+ this._maxInternedIdx = newIdx;
746
+ this._stringTableBytes = (16 + 4 * newIdx + dstTable.blobLen + 7) & ~7;
747
+ }
628
748
  }
629
749
  }
750
+ const done = r + 1;
751
+ if (done < chunkRows &&
752
+ done * stride + this._stringTableBytes >= this._targetShardBytes) {
753
+ writtenRows = done;
754
+ break;
755
+ }
630
756
  }
631
- this._currentShardRowCount = chunkRows;
632
- this._totalRows += chunkRows;
757
+ this._currentShardRowCount = writtenRows;
758
+ this._totalRows += writtenRows;
633
759
  this._finalizeCurrentShard(); // serializes dstTable, resets in-place for the next iter
634
- srcRow += chunkRows;
635
- rowsRemaining -= chunkRows;
760
+ srcRow += writtenRows;
761
+ rowsRemaining -= writtenRows;
636
762
  }
637
763
 
638
764
  this._sample = null;
@@ -640,7 +766,12 @@ export class Writer {
640
766
  // receive strings from post-drain records that fill subsequent shards.
641
767
  }
642
768
 
643
- 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() {
644
775
  if (this._finalized) throw new WriterError('W_FINALIZED', 'writer already finalized');
645
776
  if (!this._schema) {
646
777
  if (this._sample.rowCount === 0) throw new WriterError('W_EMPTY_INPUT', 'no records to write');
@@ -649,13 +780,94 @@ export class Writer {
649
780
  }
650
781
  if (this._currentShardRowCount > 0) this._finalizeCurrentShard();
651
782
  if (this._shards.length === 0) throw new WriterError('W_EMPTY_INPUT', 'no records to write');
652
- 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);
653
791
  this._finalized = true;
654
792
  // Post-finalize sentinel: route every later sink event into a cold arm.
655
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
+ };
656
800
  return this._container;
657
801
  }
658
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
+
659
871
  // Optional capability hook (BS-07): the Tokenizer calls this at construction
660
872
  // to hand over the live tokenizer. The Writer then reads src.absOffset at
661
873
  // record boundaries so the sample window is byte-true. A CALL only; the
@@ -667,100 +879,62 @@ export class Writer {
667
879
  // dance is a documented no-op retained for source compatibility until 2.0.
668
880
  setInputByteOffset() {}
669
881
 
670
- // -------- container assembly --------
671
-
672
- _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() {
673
895
  const enc = new TextEncoder();
674
- const nameBytes = new Array(this._schema.fields.length);
896
+ const fields = this._schema.fields;
897
+ const nameBytes = new Array(fields.length);
675
898
  let nameBlobLen = 0;
676
- for (let i = 0; i < this._schema.fields.length; i++) {
677
- 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);
678
901
  nameBlobLen += nameBytes[i].length;
679
902
  }
680
-
681
- const fieldCount = this._schema.fields.length;
682
- const descriptorBytes = fieldCount * FIELD_DESCRIPTOR_BYTES;
903
+ const descriptorBytes = fields.length * FIELD_DESCRIPTOR_BYTES;
683
904
  let schemaBlockBytes = 8 + descriptorBytes + 4 + nameBlobLen;
684
- const schemaPad = (8 - (schemaBlockBytes & 7)) & 7;
685
- schemaBlockBytes += schemaPad;
686
-
687
- const shardCount = this._shards.length;
688
- const shardDirBytes = shardCount * SHARD_ENTRY_BYTES;
689
-
690
- const schemaBlockOff = CONTAINER_HEADER_BYTES;
691
- const shardDirOff = schemaBlockOff + schemaBlockBytes;
905
+ schemaBlockBytes += (8 - (schemaBlockBytes & 7)) & 7;
906
+ return { nameBytes, nameBlobLen, descriptorBytes, schemaBlockBytes };
907
+ }
692
908
 
693
- // Zone maps segment (M7). Placed between shard directory and first shard
694
- // so a Reader loading header/schema/dir/zoneMaps up front does it in one
695
- // contiguous byte range.
696
- const T = this._trackedFieldIndices.length;
697
- const zoneMapsEnabled = T > 0 && shardCount > 0;
698
- let zoneMapsOff = 0;
699
- let zoneMapsBytes = 0;
700
- let zoneMapsFieldTableOff = 0;
701
- let zoneMapsMinsOff = 0;
702
- let zoneMapsMaxesOff = 0;
703
- if (zoneMapsEnabled) {
704
- zoneMapsOff = shardDirOff + shardDirBytes;
705
- // Layout: 16-byte header + T*u16 field indices, padded to 8, then
706
- // shardCount*T*8 mins, then shardCount*T*8 maxes.
707
- const headerLen = 16;
708
- const fieldTableLen = T * 2;
709
- const fieldTablePad = (8 - (fieldTableLen & 7)) & 7;
710
- zoneMapsFieldTableOff = zoneMapsOff + headerLen;
711
- const minsRel = headerLen + fieldTableLen + fieldTablePad;
712
- zoneMapsMinsOff = zoneMapsOff + minsRel;
713
- const minsMaxesLen = shardCount * T * 8 * 2;
714
- zoneMapsMaxesOff = zoneMapsMinsOff + shardCount * T * 8;
715
- zoneMapsBytes = minsRel + minsMaxesLen;
716
- }
717
- 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
+ }
718
914
 
719
- // Compute per-shard payload and string-table offsets
720
- let cursor = firstShardOff;
721
- const shardPayloadOffsets = new Array(shardCount);
722
- const shardPayloadPadded = new Array(shardCount);
723
- const shardStringTableOffsets = new Array(shardCount);
724
- const shardStringTableLens = new Array(shardCount);
725
- for (let i = 0; i < shardCount; i++) {
726
- const s = this._shards[i];
727
- shardPayloadOffsets[i] = cursor;
728
- const payloadLen = s.bytes.length;
729
- const payloadPad = (8 - (payloadLen & 7)) & 7;
730
- shardPayloadPadded[i] = payloadLen + payloadPad;
731
- cursor += shardPayloadPadded[i];
732
- // String table immediately follows the shard payload
733
- shardStringTableOffsets[i] = cursor;
734
- const stLen = s.stringTableBytes.length;
735
- shardStringTableLens[i] = stLen;
736
- cursor += stLen; // stringTable.serialize() already 8-byte padded
737
- }
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
+ }
738
927
 
739
- const totalBytes = cursor + FOOTER_BYTES;
740
- const container = new ArrayBuffer(totalBytes);
741
- const dv = new DataView(container);
742
- const bytes = new Uint8Array(container);
743
-
744
- // Header
745
- bytes[0] = 0x4C; bytes[1] = 0x42; bytes[2] = 0x4B; bytes[3] = 0x31;
746
- dv.setUint16(4, FORMAT_VERSION, true);
747
- bytes[6] = ENDIAN_LE;
748
- bytes[7] = 0;
749
- dv.setBigUint64(8, BigInt(schemaBlockOff), true);
750
- dv.setBigUint64(16, BigInt(zoneMapsOff), true); // metadata_off — 0 iff no zone maps
751
- dv.setBigUint64(24, BigInt(shardDirOff), true);
752
- dv.setUint32(32, shardCount, true);
753
- dv.setUint32(36, 0, true); // reserved1
754
- dv.setBigUint64(40, BigInt(this._totalRows), true);
755
-
756
- // Schema block
757
- dv.setUint32(schemaBlockOff, fieldCount, true);
758
- 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);
759
933
  let nameOff = 0;
760
934
  for (let i = 0; i < fieldCount; i++) {
761
- const descOff = schemaBlockOff + 8 + i * FIELD_DESCRIPTOR_BYTES;
762
- const f = this._schema.fields[i];
763
- const nb = nameBytes[i];
935
+ const descOff = off + 8 + i * FIELD_DESCRIPTOR_BYTES;
936
+ const f = fields[i];
937
+ const nb = sb.nameBytes[i];
764
938
  dv.setUint16(descOff + 0, nb.length, true);
765
939
  dv.setUint16(descOff + 2, f.offsetInRow, true);
766
940
  bytes[descOff + 4] = f.laneKind;
@@ -770,70 +944,243 @@ export class Writer {
770
944
  dv.setBigUint64(descOff + 16, 0n, true);
771
945
  nameOff += nb.length;
772
946
  }
773
- const nameBlobLenOff = schemaBlockOff + 8 + descriptorBytes;
774
- dv.setUint32(nameBlobLenOff, nameBlobLen, true);
947
+ const nameBlobLenOff = off + 8 + sb.descriptorBytes;
948
+ dv.setUint32(nameBlobLenOff, sb.nameBlobLen, true);
775
949
  let namePosOff = nameBlobLenOff + 4;
776
950
  for (let i = 0; i < fieldCount; i++) {
777
- bytes.set(nameBytes[i], namePosOff);
778
- namePosOff += nameBytes[i].length;
951
+ bytes.set(sb.nameBytes[i], namePosOff);
952
+ namePosOff += sb.nameBytes[i].length;
779
953
  }
954
+ }
780
955
 
781
- // Shard directory
782
- for (let i = 0; i < shardCount; i++) {
783
- const entryOff = shardDirOff + i * SHARD_ENTRY_BYTES;
784
- const s = this._shards[i];
785
- dv.setBigUint64(entryOff + 0, BigInt(shardPayloadOffsets[i]), true);
786
- dv.setUint32(entryOff + 8, s.bytes.length, true);
787
- dv.setUint32(entryOff + 12, s.rowCount, true);
788
- dv.setUint16(entryOff + 16, SHARD_MIN_READER_VERSION, true);
789
- dv.setUint16(entryOff + 18, SHARD_FLAGS_NONE, true);
790
- dv.setUint32(entryOff + 20, 0, true); // reserved
791
- dv.setBigUint64(entryOff + 24, BigInt(shardStringTableLens[i] > 0 ? shardStringTableOffsets[i] : 0), true);
792
- dv.setBigUint64(entryOff + 32, BigInt(shardStringTableLens[i]), true);
793
- }
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
+ }
794
966
 
795
- // Zone maps segment (M7)
796
- if (zoneMapsEnabled) {
797
- // Header: 'ZM01' magic + shard_count + tracked_field_count + reserved0
798
- bytes[zoneMapsOff + 0] = 0x30; bytes[zoneMapsOff + 1] = 0x5A;
799
- bytes[zoneMapsOff + 2] = 0x4D; bytes[zoneMapsOff + 3] = 0x31;
800
- dv.setUint32(zoneMapsOff + 4, shardCount, true);
801
- dv.setUint32(zoneMapsOff + 8, T, true);
802
- dv.setUint32(zoneMapsOff + 12, 0, true);
803
- // 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];
804
980
  for (let t = 0; t < T; t++) {
805
- dv.setUint16(zoneMapsFieldTableOff + t * 2, this._trackedFieldIndices[t], true);
981
+ dv.setFloat64(minsOff + (s * T + t) * 8, sh.mins[t], true);
982
+ dv.setFloat64(maxesOff + (s * T + t) * 8, sh.maxes[t], true);
806
983
  }
807
- // Mins / maxes: row-major, [shard * T + tracked]
808
- for (let s = 0; s < shardCount; s++) {
809
- const sh = this._shards[s];
810
- for (let t = 0; t < T; t++) {
811
- dv.setFloat64(zoneMapsMinsOff + (s * T + t) * 8, sh.mins[t], true);
812
- dv.setFloat64(zoneMapsMaxesOff + (s * T + t) * 8, sh.maxes[t], true);
813
- }
814
- }
815
- }
816
-
817
- // Payloads + local string tables
818
- for (let i = 0; i < shardCount; i++) {
819
- bytes.set(this._shards[i].bytes, shardPayloadOffsets[i]);
820
- bytes.set(this._shards[i].stringTableBytes, shardStringTableOffsets[i]);
821
984
  }
985
+ }
822
986
 
823
- // Footer
824
- const footerOff = totalBytes - FOOTER_BYTES;
825
- dv.setUint32(footerOff + 0, 0xFFFFFFFF, true);
987
+ _emitFooterInto(dv, bytes, footerOff, crcVal) {
988
+ dv.setUint32(footerOff + 0, crcVal >>> 0, true);
826
989
  dv.setUint32(footerOff + 4, 0, true);
827
990
  bytes[footerOff + 8] = 0x31;
828
991
  bytes[footerOff + 9] = 0x4B;
829
992
  bytes[footerOff + 10] = 0x42;
830
993
  bytes[footerOff + 11] = 0x4C;
831
994
  dv.setUint32(footerOff + 12, FOOTER_BYTES, true);
995
+ }
832
996
 
833
- return { buffer: container, totalRows: this._totalRows, shardCount, schema: this._schema };
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
+ }
1052
+
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' };
834
1168
  }
835
1169
 
836
1170
  get schema() { return this._schema; }
837
1171
  get totalRows() { return this._totalRows; }
838
1172
  get shardCount() { return this._shards.length + (this._currentShardRowCount > 0 ? 1 : 0); }
1173
+
1174
+ // @internal TEST-ONLY (D2): per-shard [{ tracked, emitted }] string-table byte
1175
+ // lengths. tracked is the incrementally-maintained budget at each finalize;
1176
+ // emitted is the serialized local_string_len that reached the container. They
1177
+ // MUST be equal at every shard -- Ceilings.test.js proves it, closing the
1178
+ // formula-drift bug class. No semver guarantee; absent from the public API.
1179
+ __stringTableBudgetAudit() {
1180
+ const out = new Array(this._shards.length);
1181
+ for (let i = 0; i < this._shards.length; i++) {
1182
+ out[i] = { tracked: this._shards[i].budgetTracked, emitted: this._shards[i].stringTableBytes.length };
1183
+ }
1184
+ return out;
1185
+ }
839
1186
  }