@zakkster/lite-bake-stream 1.0.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 ADDED
@@ -0,0 +1,713 @@
1
+ // @zakkster/lite-bake-stream / Writer
2
+ // LBK1 shard emitter. Plugs into Tokenizer as a sink.
3
+ // Copyright (c) 2026 Zahary Shinikchiev. MIT.
4
+ //
5
+ // Two modes:
6
+ // - Explicit schema: consumer passes { fields: [names...] } or
7
+ // { fields: [{name, laneKind}...] }; no sampling.
8
+ // - Sample-and-infer (default): first `sampleBytes` of input buffered in a
9
+ // columnar staging area, per-field lane kind inferred from observed
10
+ // value types (number->F64, string->U32-into-string-table). Fields that
11
+ // saw both raise W_MIXED_LANE_TYPES at freeze.
12
+ //
13
+ // v1 lane kinds: F64 (numeric) and U32 (string-table index). Per-shard string
14
+ // tables ride immediately after each shard's payload; the shard directory
15
+ // carries local_string_off/local_string_len so each shard is self-contained.
16
+ //
17
+ // Post-freeze allocation profile: ZERO per record on the numeric hot path.
18
+ // String fields intern via the per-shard StringTable which allocates only on
19
+ // unique inserts (bounded by cardinality, not row count).
20
+ //
21
+ // Error codes (stable):
22
+ // W_TOP_LEVEL_NOT_OBJECT - top-level value is not an object
23
+ // W_NESTED_UNSUPPORTED - nested object/array inside a record
24
+ // W_EMPTY_INPUT - finalize() with zero records seen
25
+ // W_ROW_STRIDE_ZERO - schema has zero fields (nothing to store)
26
+ // W_MIXED_LANE_TYPES - a field saw both number and string values
27
+ // W_LANE_MISMATCH - post-freeze: value type doesn't match schema lane
28
+ // W_UNKNOWN_LANE_KIND - explicit schema declares an unknown laneKind
29
+
30
+ import { StringTable } from './StringTable.js';
31
+
32
+ export const VERSION = '1.0.0';
33
+
34
+ const CONTAINER_HEADER_BYTES = 48;
35
+ const SHARD_ENTRY_BYTES = 40;
36
+ const FIELD_DESCRIPTOR_BYTES = 24;
37
+ const FOOTER_BYTES = 16;
38
+
39
+ const LANE_F64 = 1;
40
+ const LANE_U32 = 3;
41
+ const FIELD_FLAGS_NONE = 0;
42
+
43
+ const SHARD_MIN_READER_VERSION = 1;
44
+ const SHARD_FLAGS_NONE = 0;
45
+
46
+ const FORMAT_VERSION = 1;
47
+ const ENDIAN_LE = 1;
48
+
49
+ const DEFAULT_SHARD_BYTES = 32 * 1024 * 1024;
50
+ const DEFAULT_SAMPLE_ROWS_CAPACITY = 4096;
51
+
52
+ // Kind tags for sample-time bookkeeping
53
+ const K_UNKNOWN = 0;
54
+ const K_NUMBER = 1;
55
+ const K_STRING = 2;
56
+ const K_MIXED = 3;
57
+
58
+ function laneKindBytes(k) { return k === LANE_F64 ? 8 : (k === LANE_U32 ? 4 : 0); }
59
+ function normalizeLaneKindStr(s) {
60
+ if (typeof s !== 'string') return s;
61
+ const t = s.toLowerCase();
62
+ if (t === 'f64') return LANE_F64;
63
+ if (t === 'u32') return LANE_U32;
64
+ return -1;
65
+ }
66
+
67
+ export class WriterError extends Error {
68
+ constructor(code, msg) {
69
+ super(msg);
70
+ this.code = code;
71
+ this.name = 'WriterError';
72
+ }
73
+ }
74
+
75
+ function hashBytes(bytes, from, to) {
76
+ let h = 0x811c9dc5 | 0;
77
+ for (let i = from; i < to; i++) {
78
+ h = (h ^ bytes[i]) | 0;
79
+ h = Math.imul(h, 0x01000193) | 0;
80
+ }
81
+ return h >>> 0;
82
+ }
83
+
84
+ function bytesEqual(a, aFrom, aTo, b) {
85
+ const n = aTo - aFrom;
86
+ if (n !== b.length) return false;
87
+ for (let i = 0; i < n; i++) if (a[aFrom + i] !== b[i]) return false;
88
+ return true;
89
+ }
90
+
91
+ // SampleBuffer: columnar SoA during sample window. Each field independently
92
+ // records observed kind (numeric or string). Numeric values -> F64 column;
93
+ // string values -> U32 column, interned into a shared string table that is
94
+ // handed to shard 0 at drain time.
95
+ class SampleBuffer {
96
+ constructor(initialCapacity, stringTable) {
97
+ this._rowCount = 0;
98
+ this._capacity = initialCapacity;
99
+ this._fieldOrder = []; // Array<string>
100
+ this._fieldMeta = new Map(); // fieldName -> { kind, columnF64?, columnU32? }
101
+ this._byteEstimate = 0;
102
+ this._stringTable = stringTable;
103
+ }
104
+
105
+ setNumber(name, value) {
106
+ let meta = this._ensureField(name);
107
+ if (meta.kind === K_UNKNOWN) { meta.kind = K_NUMBER; meta.columnF64 = new Float64Array(this._capacity); }
108
+ else if (meta.kind === K_STRING) { meta.kind = K_MIXED; return; }
109
+ else if (meta.kind === K_MIXED) { return; }
110
+ meta.columnF64[this._rowCount] = value;
111
+ }
112
+
113
+ setString(name, bytes, from, to) {
114
+ let meta = this._ensureField(name);
115
+ if (meta.kind === K_UNKNOWN) { meta.kind = K_STRING; meta.columnU32 = new Uint32Array(this._capacity); }
116
+ else if (meta.kind === K_NUMBER) { meta.kind = K_MIXED; return; }
117
+ else if (meta.kind === K_MIXED) { return; }
118
+ const idx = this._stringTable.intern(bytes, from, to);
119
+ meta.columnU32[this._rowCount] = idx;
120
+ }
121
+
122
+ _ensureField(name) {
123
+ let meta = this._fieldMeta.get(name);
124
+ if (!meta) {
125
+ meta = { kind: K_UNKNOWN, columnF64: null, columnU32: null };
126
+ this._fieldMeta.set(name, meta);
127
+ this._fieldOrder.push(name);
128
+ }
129
+ return meta;
130
+ }
131
+
132
+ commitRow(inputBytesConsumed) {
133
+ this._rowCount++;
134
+ this._byteEstimate += inputBytesConsumed;
135
+ if (this._rowCount >= this._capacity) this._growCapacity();
136
+ }
137
+
138
+ _growCapacity() {
139
+ const newCap = this._capacity * 2;
140
+ for (const name of this._fieldOrder) {
141
+ const meta = this._fieldMeta.get(name);
142
+ if (meta.columnF64) {
143
+ const grown = new Float64Array(newCap); grown.set(meta.columnF64); meta.columnF64 = grown;
144
+ }
145
+ if (meta.columnU32) {
146
+ const grown = new Uint32Array(newCap); grown.set(meta.columnU32); meta.columnU32 = grown;
147
+ }
148
+ }
149
+ this._capacity = newCap;
150
+ }
151
+
152
+ get byteEstimate() { return this._byteEstimate; }
153
+ get rowCount() { return this._rowCount; }
154
+ get fieldOrder() { return this._fieldOrder; }
155
+ getFieldMeta(name) { return this._fieldMeta.get(name); }
156
+ }
157
+
158
+ export class Writer {
159
+ constructor(opts) {
160
+ opts = opts || {};
161
+ this._targetShardBytes = opts.targetShardBytes || DEFAULT_SHARD_BYTES;
162
+ this._sampleBytes = opts.sampleBytes || this._targetShardBytes;
163
+ this._explicitSchema = opts.schema || null;
164
+
165
+ // Frozen schema state
166
+ this._schema = null; // { fields: [{name, laneKind, offsetInRow}], rowStride }
167
+ this._fieldNamesUtf8 = null;
168
+ this._fieldNameHashes = null;
169
+ this._fieldLaneKinds = null; // Uint8Array — per-field LANE_* (fast dispatch)
170
+ this._fieldOffsets = null; // Uint16Array — per-field byte offset in row
171
+ this._rowValueSlotsF64 = null; // Float64Array<fieldCount> scratch
172
+ this._rowValueSlotsU32 = null; // Uint32Array<fieldCount> scratch
173
+
174
+ // Sample-window state
175
+ this._stringTable = new StringTable();
176
+ this._sample = new SampleBuffer(DEFAULT_SAMPLE_ROWS_CAPACITY, this._stringTable);
177
+
178
+ // Shard state
179
+ this._currentShardBuffer = null;
180
+ this._currentShardBytes = null; // Uint8Array view over buffer
181
+ this._currentShardRowCount = 0;
182
+ this._currentShardMaxRows = 0;
183
+ this._shards = []; // { bytes, rowCount, stringTableBytes }
184
+ this._totalRows = 0;
185
+
186
+ // Per-record parse state
187
+ this._recordDepth = 0;
188
+ this._currentKeyBytes = null;
189
+ this._currentKeyFrom = 0;
190
+ this._currentKeyTo = 0;
191
+ this._currentFieldIdx = -1;
192
+ this._recordStartByteOffset = 0;
193
+ this._absOffset = 0;
194
+
195
+ this._finalized = false;
196
+
197
+ if (this._explicitSchema) this._freezeSchemaExplicit(this._explicitSchema.fields);
198
+ }
199
+
200
+ // -------- sink protocol --------
201
+
202
+ onStartObject() {
203
+ this._recordDepth++;
204
+ if (this._recordDepth > 1) throw new WriterError('W_NESTED_UNSUPPORTED', 'nested object inside record');
205
+ if (this._schema) {
206
+ // clear scratch slots
207
+ const f = this._rowValueSlotsF64, u = this._rowValueSlotsU32;
208
+ for (let i = 0; i < f.length; i++) { f[i] = 0; u[i] = 0; }
209
+ }
210
+ this._recordStartByteOffset = this._absOffset;
211
+ }
212
+
213
+ onEndObject() {
214
+ this._recordDepth--;
215
+ if (this._recordDepth === 0) this._commitRecord();
216
+ }
217
+
218
+ onStartArray() {
219
+ if (this._recordDepth === 0) throw new WriterError('W_TOP_LEVEL_NOT_OBJECT', 'top-level array (in-record) not supported');
220
+ throw new WriterError('W_NESTED_UNSUPPORTED', 'nested array inside record');
221
+ }
222
+
223
+ onEndArray() {}
224
+
225
+ onKey(bytes, from, to) {
226
+ if (this._schema) {
227
+ // Post-freeze: resolve to field index NOW; the bytes are ephemeral (SPEC 5.3).
228
+ this._currentFieldIdx = this._lookupFieldIdx(bytes, from, to);
229
+ } else {
230
+ // Sample window: decode to a JS string NOW. The tokenizer will reuse this
231
+ // buffer for the value bytes before onNumber/onString fires, so we cannot
232
+ // defer decoding. String allocation here is expected — bounded to the
233
+ // sample window; steady-state post-freeze remains zero-alloc.
234
+ this._currentKeyName = new TextDecoder().decode(bytes.subarray(from, to));
235
+ }
236
+ }
237
+
238
+ onNumber(v) {
239
+ if (this._recordDepth !== 1) return;
240
+ if (this._schema) {
241
+ const idx = this._currentFieldIdx;
242
+ if (idx < 0) return;
243
+ const lane = this._fieldLaneKinds[idx];
244
+ if (lane !== LANE_F64) throw new WriterError('W_LANE_MISMATCH',
245
+ 'field ' + this._schema.fields[idx].name + ' is not an F64 lane but got a number');
246
+ this._rowValueSlotsF64[idx] = v;
247
+ } else {
248
+ if (!this._currentKeyName) return;
249
+ this._sample.setNumber(this._currentKeyName, v);
250
+ }
251
+ }
252
+
253
+ onTrue() { this.onNumber(1); }
254
+ onFalse() { this.onNumber(0); }
255
+ onNull() { this.onNumber(0); }
256
+
257
+ onString(bytes, from, to) {
258
+ if (this._recordDepth === 0) return;
259
+ if (this._schema) {
260
+ const idx = this._currentFieldIdx;
261
+ if (idx < 0) return;
262
+ const lane = this._fieldLaneKinds[idx];
263
+ if (lane !== LANE_U32) throw new WriterError('W_LANE_MISMATCH',
264
+ 'field ' + this._schema.fields[idx].name + ' is not a U32 lane but got a string');
265
+ const strIdx = this._currentShardStringTable().intern(bytes, from, to);
266
+ this._rowValueSlotsU32[idx] = strIdx;
267
+ } else {
268
+ if (!this._currentKeyName) return;
269
+ this._sample.setString(this._currentKeyName, bytes, from, to);
270
+ }
271
+ }
272
+
273
+ onEnd() {}
274
+
275
+ // The current shard's string table. On the first record post-freeze we
276
+ // hand over the sample's string table (which already contains the drained
277
+ // records' strings). Subsequent shards get a fresh reset table.
278
+ _currentShardStringTable() {
279
+ if (!this._perShardStringTable) this._perShardStringTable = this._stringTable;
280
+ return this._perShardStringTable;
281
+ }
282
+
283
+ // -------- schema management --------
284
+
285
+ _freezeSchemaExplicit(rawFields) {
286
+ if (!rawFields || rawFields.length === 0) throw new WriterError('W_ROW_STRIDE_ZERO', 'schema has zero fields');
287
+ const fields = new Array(rawFields.length);
288
+ let offset = 0;
289
+ for (let i = 0; i < rawFields.length; i++) {
290
+ const rf = rawFields[i];
291
+ let name, laneKind;
292
+ if (typeof rf === 'string') { name = rf; laneKind = LANE_F64; }
293
+ else {
294
+ name = rf.name;
295
+ const asStr = normalizeLaneKindStr(rf.laneKind);
296
+ laneKind = typeof rf.laneKind === 'number' ? rf.laneKind : asStr;
297
+ if (laneKind !== LANE_F64 && laneKind !== LANE_U32)
298
+ throw new WriterError('W_UNKNOWN_LANE_KIND', 'field ' + name + ' laneKind=' + rf.laneKind);
299
+ }
300
+ fields[i] = { name, laneKind, offsetInRow: offset };
301
+ offset += laneKindBytes(laneKind);
302
+ }
303
+ // Pad row stride to 8 (per SPEC 3.5)
304
+ const rowStride = (offset + 7) & ~7;
305
+ this._finalizeSchema(fields, rowStride);
306
+ }
307
+
308
+ _freezeSchemaFromSample() {
309
+ const names = this._sample.fieldOrder;
310
+ if (names.length === 0) throw new WriterError('W_ROW_STRIDE_ZERO', 'sample discovered no fields');
311
+ const fields = new Array(names.length);
312
+ let offset = 0;
313
+ for (let i = 0; i < names.length; i++) {
314
+ const name = names[i];
315
+ const meta = this._sample.getFieldMeta(name);
316
+ let laneKind;
317
+ if (meta.kind === K_MIXED) throw new WriterError('W_MIXED_LANE_TYPES',
318
+ 'field ' + name + ' had both numeric and string values in sample');
319
+ if (meta.kind === K_STRING) laneKind = LANE_U32;
320
+ else laneKind = LANE_F64; // K_NUMBER or K_UNKNOWN default
321
+ fields[i] = { name, laneKind, offsetInRow: offset };
322
+ offset += laneKindBytes(laneKind);
323
+ }
324
+ const rowStride = (offset + 7) & ~7;
325
+ this._finalizeSchema(fields, rowStride);
326
+ }
327
+
328
+ _finalizeSchema(fields, rowStride) {
329
+ this._schema = { fields, rowStride };
330
+
331
+ const enc = new TextEncoder();
332
+ this._fieldNamesUtf8 = new Array(fields.length);
333
+ this._fieldNameHashes = new Uint32Array(fields.length);
334
+ this._fieldLaneKinds = new Uint8Array(fields.length);
335
+ this._fieldOffsets = new Uint16Array(fields.length);
336
+ for (let i = 0; i < fields.length; i++) {
337
+ const b = enc.encode(fields[i].name);
338
+ this._fieldNamesUtf8[i] = b;
339
+ this._fieldNameHashes[i] = hashBytes(b, 0, b.length);
340
+ this._fieldLaneKinds[i] = fields[i].laneKind;
341
+ this._fieldOffsets[i] = fields[i].offsetInRow;
342
+ }
343
+ this._rowValueSlotsF64 = new Float64Array(fields.length);
344
+ this._rowValueSlotsU32 = new Uint32Array(fields.length);
345
+ this._currentShardMaxRows = Math.max(1, Math.floor(this._targetShardBytes / rowStride));
346
+
347
+ // Zone-map tracking (M7): every F64 field gets a min/max slot per shard.
348
+ // trackedFieldIndices[t] = schema field index; trackedFieldToPos[schemaFieldIdx]
349
+ // = t or -1. The row-commit hot path uses trackedFieldToPos for O(1) dispatch.
350
+ const tracked = [];
351
+ for (let i = 0; i < fields.length; i++) {
352
+ if (fields[i].laneKind === LANE_F64) tracked.push(i);
353
+ }
354
+ this._trackedFieldIndices = new Uint16Array(tracked);
355
+ this._trackedFieldToPos = new Int32Array(fields.length);
356
+ for (let i = 0; i < fields.length; i++) this._trackedFieldToPos[i] = -1;
357
+ for (let t = 0; t < tracked.length; t++) this._trackedFieldToPos[tracked[t]] = t;
358
+ }
359
+
360
+ _lookupFieldIdx(bytes, from, to) {
361
+ const h = hashBytes(bytes, from, to);
362
+ const hashes = this._fieldNameHashes;
363
+ const names = this._fieldNamesUtf8;
364
+ for (let i = 0; i < hashes.length; i++) {
365
+ if (hashes[i] === h && bytesEqual(bytes, from, to, names[i])) return i;
366
+ }
367
+ return -1;
368
+ }
369
+
370
+ // -------- record write path --------
371
+
372
+ _commitRecord() {
373
+ if (this._schema) {
374
+ if (!this._currentShardBuffer) this._allocateCurrentShard();
375
+ const row = this._currentShardRowCount;
376
+ const stride = this._schema.rowStride;
377
+ const rowOff = row * stride;
378
+ const dv = this._currentShardDv;
379
+ const kinds = this._fieldLaneKinds;
380
+ const offs = this._fieldOffsets;
381
+ const f = this._rowValueSlotsF64;
382
+ const u = this._rowValueSlotsU32;
383
+ const trackedIdx = this._trackedFieldToPos; // Int32Array: schemaFieldIdx -> trackingPos or -1
384
+ const mins = this._currentShardMins;
385
+ const maxes = this._currentShardMaxes;
386
+ for (let i = 0; i < kinds.length; i++) {
387
+ const fo = rowOff + offs[i];
388
+ if (kinds[i] === LANE_F64) {
389
+ const v = f[i];
390
+ dv.setFloat64(fo, v, true);
391
+ const t = trackedIdx[i];
392
+ if (t >= 0) {
393
+ if (v < mins[t]) mins[t] = v;
394
+ if (v > maxes[t]) maxes[t] = v;
395
+ }
396
+ } else {
397
+ dv.setUint32(fo, u[i], true);
398
+ }
399
+ }
400
+ this._currentShardRowCount++;
401
+ this._totalRows++;
402
+ if (this._currentShardRowCount >= this._currentShardMaxRows) this._finalizeCurrentShard();
403
+ } else {
404
+ const consumed = Math.max(1, this._absOffset - this._recordStartByteOffset);
405
+ this._sample.commitRow(consumed);
406
+ if (this._sample.byteEstimate >= this._sampleBytes) {
407
+ this._freezeSchemaFromSample();
408
+ this._drainSampleToShards();
409
+ }
410
+ }
411
+ }
412
+
413
+ _allocateCurrentShard() {
414
+ const stride = this._schema.rowStride;
415
+ const bytes = this._currentShardMaxRows * stride;
416
+ this._currentShardBuffer = new ArrayBuffer(bytes);
417
+ this._currentShardBytes = new Uint8Array(this._currentShardBuffer);
418
+ this._currentShardDv = new DataView(this._currentShardBuffer);
419
+ this._currentShardRowCount = 0;
420
+ // Zone-map tracking: per-F64-field min/max for this shard. Initialized to
421
+ // +Inf / -Inf sentinels; every row's write path updates them. Only F64
422
+ // fields participate (M7 scope); the trackedFieldIndices map converts a
423
+ // schema field index -> position in the tracking arrays.
424
+ const T = this._trackedFieldIndices.length;
425
+ this._currentShardMins = new Float64Array(T);
426
+ this._currentShardMaxes = new Float64Array(T);
427
+ for (let i = 0; i < T; i++) {
428
+ this._currentShardMins[i] = Number.POSITIVE_INFINITY;
429
+ this._currentShardMaxes[i] = Number.NEGATIVE_INFINITY;
430
+ }
431
+ }
432
+
433
+ _finalizeCurrentShard() {
434
+ if (this._currentShardRowCount === 0) return;
435
+ const usedBytes = this._currentShardRowCount * this._schema.rowStride;
436
+ const copy = new Uint8Array(usedBytes);
437
+ copy.set(this._currentShardBytes.subarray(0, usedBytes));
438
+ // Serialize the current shard's string table (empty for F64-only shards).
439
+ const st = this._perShardStringTable || this._stringTable;
440
+ const stSer = st.serialize();
441
+ // Snapshot zone-map bounds for this shard. Copy so subsequent shards can
442
+ // reuse _currentShardMins/Maxes without clobbering the pushed record.
443
+ const T = this._trackedFieldIndices.length;
444
+ const shardMins = new Float64Array(T);
445
+ const shardMaxes = new Float64Array(T);
446
+ shardMins.set(this._currentShardMins);
447
+ shardMaxes.set(this._currentShardMaxes);
448
+ this._shards.push({
449
+ bytes: copy,
450
+ rowCount: this._currentShardRowCount,
451
+ stringTableBytes: stSer.bytes,
452
+ mins: shardMins,
453
+ maxes: shardMaxes,
454
+ });
455
+ this._currentShardBuffer = null;
456
+ this._currentShardBytes = null;
457
+ this._currentShardDv = null;
458
+ this._currentShardRowCount = 0;
459
+ // Reset string table for next shard (per-shard independence)
460
+ st.reset();
461
+ this._perShardStringTable = st;
462
+ }
463
+
464
+ _drainSampleToShards() {
465
+ const rowCount = this._sample.rowCount;
466
+ const fields = this._schema.fields;
467
+ const cols = new Array(fields.length);
468
+ for (let i = 0; i < fields.length; i++) {
469
+ const meta = this._sample.getFieldMeta(fields[i].name);
470
+ cols[i] = fields[i].laneKind === LANE_F64 ? (meta ? meta.columnF64 : null)
471
+ : (meta ? meta.columnU32 : null);
472
+ }
473
+
474
+ // The sample interned all strings into a SHARED table. When the sample
475
+ // overflows multiple output shards, each shard needs its own self-contained
476
+ // string table (SPEC 3.3 per-shard independence for HTTP Range fetches).
477
+ // Solution: re-intern per shard during drain. Look up the original bytes
478
+ // in the sample table, intern into a per-shard fresh table, remap the U32
479
+ // index. This preserves per-shard independence at the cost of a modest
480
+ // string-table copy for tags that appear across shards.
481
+ const sampleTable = this._stringTable;
482
+ this._perShardStringTable = new StringTable();
483
+
484
+ let rowsRemaining = rowCount;
485
+ let srcRow = 0;
486
+ while (rowsRemaining > 0) {
487
+ this._allocateCurrentShard();
488
+ const chunkRows = Math.min(rowsRemaining, this._currentShardMaxRows);
489
+ const dv = this._currentShardDv;
490
+ const stride = this._schema.rowStride;
491
+ const dstTable = this._perShardStringTable;
492
+ const trackedIdx = this._trackedFieldToPos;
493
+ const mins = this._currentShardMins;
494
+ const maxes = this._currentShardMaxes;
495
+ for (let r = 0; r < chunkRows; r++) {
496
+ const rowOff = r * stride;
497
+ for (let f = 0; f < fields.length; f++) {
498
+ const fo = rowOff + fields[f].offsetInRow;
499
+ const col = cols[f];
500
+ if (fields[f].laneKind === LANE_F64) {
501
+ const v = col ? col[srcRow + r] : 0;
502
+ dv.setFloat64(fo, v, true);
503
+ const t = trackedIdx[f];
504
+ if (t >= 0) {
505
+ if (v < mins[t]) mins[t] = v;
506
+ if (v > maxes[t]) maxes[t] = v;
507
+ }
508
+ } else {
509
+ const sampleIdx = col ? col[srcRow + r] : 0;
510
+ const strBytes = sampleTable.bytesAt(sampleIdx);
511
+ const newIdx = strBytes ? dstTable.intern(strBytes, 0, strBytes.length) : 0;
512
+ dv.setUint32(fo, newIdx, true);
513
+ }
514
+ }
515
+ }
516
+ this._currentShardRowCount = chunkRows;
517
+ this._totalRows += chunkRows;
518
+ this._finalizeCurrentShard(); // serializes dstTable, resets in-place for the next iter
519
+ srcRow += chunkRows;
520
+ rowsRemaining -= chunkRows;
521
+ }
522
+
523
+ this._sample = null;
524
+ // _perShardStringTable now holds a reset (empty) StringTable ready to
525
+ // receive strings from post-drain records that fill subsequent shards.
526
+ }
527
+
528
+ finalize() {
529
+ if (this._finalized) return this._container;
530
+ if (!this._schema) {
531
+ if (this._sample.rowCount === 0) throw new WriterError('W_EMPTY_INPUT', 'no records to write');
532
+ this._freezeSchemaFromSample();
533
+ this._drainSampleToShards();
534
+ }
535
+ if (this._currentShardRowCount > 0) this._finalizeCurrentShard();
536
+ if (this._shards.length === 0) throw new WriterError('W_EMPTY_INPUT', 'no records to write');
537
+ this._container = this._assembleContainer();
538
+ this._finalized = true;
539
+ return this._container;
540
+ }
541
+
542
+ setInputByteOffset(n) { this._absOffset = n; }
543
+
544
+ // -------- container assembly --------
545
+
546
+ _assembleContainer() {
547
+ const enc = new TextEncoder();
548
+ const nameBytes = new Array(this._schema.fields.length);
549
+ let nameBlobLen = 0;
550
+ for (let i = 0; i < this._schema.fields.length; i++) {
551
+ nameBytes[i] = enc.encode(this._schema.fields[i].name);
552
+ nameBlobLen += nameBytes[i].length;
553
+ }
554
+
555
+ const fieldCount = this._schema.fields.length;
556
+ const descriptorBytes = fieldCount * FIELD_DESCRIPTOR_BYTES;
557
+ let schemaBlockBytes = 8 + descriptorBytes + 4 + nameBlobLen;
558
+ const schemaPad = (8 - (schemaBlockBytes & 7)) & 7;
559
+ schemaBlockBytes += schemaPad;
560
+
561
+ const shardCount = this._shards.length;
562
+ const shardDirBytes = shardCount * SHARD_ENTRY_BYTES;
563
+
564
+ const schemaBlockOff = CONTAINER_HEADER_BYTES;
565
+ const shardDirOff = schemaBlockOff + schemaBlockBytes;
566
+
567
+ // Zone maps segment (M7). Placed between shard directory and first shard
568
+ // so a Reader loading header/schema/dir/zoneMaps up front does it in one
569
+ // contiguous byte range.
570
+ const T = this._trackedFieldIndices.length;
571
+ const zoneMapsEnabled = T > 0 && shardCount > 0;
572
+ let zoneMapsOff = 0;
573
+ let zoneMapsBytes = 0;
574
+ let zoneMapsFieldTableOff = 0;
575
+ let zoneMapsMinsOff = 0;
576
+ let zoneMapsMaxesOff = 0;
577
+ if (zoneMapsEnabled) {
578
+ zoneMapsOff = shardDirOff + shardDirBytes;
579
+ // Layout: 16-byte header + T*u16 field indices, padded to 8, then
580
+ // shardCount*T*8 mins, then shardCount*T*8 maxes.
581
+ const headerLen = 16;
582
+ const fieldTableLen = T * 2;
583
+ const fieldTablePad = (8 - (fieldTableLen & 7)) & 7;
584
+ zoneMapsFieldTableOff = zoneMapsOff + headerLen;
585
+ const minsRel = headerLen + fieldTableLen + fieldTablePad;
586
+ zoneMapsMinsOff = zoneMapsOff + minsRel;
587
+ const minsMaxesLen = shardCount * T * 8 * 2;
588
+ zoneMapsMaxesOff = zoneMapsMinsOff + shardCount * T * 8;
589
+ zoneMapsBytes = minsRel + minsMaxesLen;
590
+ }
591
+ const firstShardOff = shardDirOff + shardDirBytes + zoneMapsBytes;
592
+
593
+ // Compute per-shard payload and string-table offsets
594
+ let cursor = firstShardOff;
595
+ const shardPayloadOffsets = new Array(shardCount);
596
+ const shardPayloadPadded = new Array(shardCount);
597
+ const shardStringTableOffsets = new Array(shardCount);
598
+ const shardStringTableLens = new Array(shardCount);
599
+ for (let i = 0; i < shardCount; i++) {
600
+ const s = this._shards[i];
601
+ shardPayloadOffsets[i] = cursor;
602
+ const payloadLen = s.bytes.length;
603
+ const payloadPad = (8 - (payloadLen & 7)) & 7;
604
+ shardPayloadPadded[i] = payloadLen + payloadPad;
605
+ cursor += shardPayloadPadded[i];
606
+ // String table immediately follows the shard payload
607
+ shardStringTableOffsets[i] = cursor;
608
+ const stLen = s.stringTableBytes.length;
609
+ shardStringTableLens[i] = stLen;
610
+ cursor += stLen; // stringTable.serialize() already 8-byte padded
611
+ }
612
+
613
+ const totalBytes = cursor + FOOTER_BYTES;
614
+ const container = new ArrayBuffer(totalBytes);
615
+ const dv = new DataView(container);
616
+ const bytes = new Uint8Array(container);
617
+
618
+ // Header
619
+ bytes[0] = 0x4C; bytes[1] = 0x42; bytes[2] = 0x4B; bytes[3] = 0x31;
620
+ dv.setUint16(4, FORMAT_VERSION, true);
621
+ bytes[6] = ENDIAN_LE;
622
+ bytes[7] = 0;
623
+ dv.setBigUint64(8, BigInt(schemaBlockOff), true);
624
+ dv.setBigUint64(16, BigInt(zoneMapsOff), true); // metadata_off — 0 iff no zone maps
625
+ dv.setBigUint64(24, BigInt(shardDirOff), true);
626
+ dv.setUint32(32, shardCount, true);
627
+ dv.setUint32(36, 0, true); // reserved1
628
+ dv.setBigUint64(40, BigInt(this._totalRows), true);
629
+
630
+ // Schema block
631
+ dv.setUint32(schemaBlockOff, fieldCount, true);
632
+ dv.setUint32(schemaBlockOff + 4, this._schema.rowStride, true);
633
+ let nameOff = 0;
634
+ for (let i = 0; i < fieldCount; i++) {
635
+ const descOff = schemaBlockOff + 8 + i * FIELD_DESCRIPTOR_BYTES;
636
+ const f = this._schema.fields[i];
637
+ const nb = nameBytes[i];
638
+ dv.setUint16(descOff + 0, nb.length, true);
639
+ dv.setUint16(descOff + 2, f.offsetInRow, true);
640
+ bytes[descOff + 4] = f.laneKind;
641
+ bytes[descOff + 5] = FIELD_FLAGS_NONE;
642
+ dv.setUint16(descOff + 6, 0, true);
643
+ dv.setBigUint64(descOff + 8, BigInt(nameOff), true);
644
+ dv.setBigUint64(descOff + 16, 0n, true);
645
+ nameOff += nb.length;
646
+ }
647
+ const nameBlobLenOff = schemaBlockOff + 8 + descriptorBytes;
648
+ dv.setUint32(nameBlobLenOff, nameBlobLen, true);
649
+ let namePosOff = nameBlobLenOff + 4;
650
+ for (let i = 0; i < fieldCount; i++) {
651
+ bytes.set(nameBytes[i], namePosOff);
652
+ namePosOff += nameBytes[i].length;
653
+ }
654
+
655
+ // Shard directory
656
+ for (let i = 0; i < shardCount; i++) {
657
+ const entryOff = shardDirOff + i * SHARD_ENTRY_BYTES;
658
+ const s = this._shards[i];
659
+ dv.setBigUint64(entryOff + 0, BigInt(shardPayloadOffsets[i]), true);
660
+ dv.setUint32(entryOff + 8, s.bytes.length, true);
661
+ dv.setUint32(entryOff + 12, s.rowCount, true);
662
+ dv.setUint16(entryOff + 16, SHARD_MIN_READER_VERSION, true);
663
+ dv.setUint16(entryOff + 18, SHARD_FLAGS_NONE, true);
664
+ dv.setUint32(entryOff + 20, 0, true); // reserved
665
+ dv.setBigUint64(entryOff + 24, BigInt(shardStringTableOffsets[i]), true);
666
+ dv.setBigUint64(entryOff + 32, BigInt(shardStringTableLens[i]), true);
667
+ }
668
+
669
+ // Zone maps segment (M7)
670
+ if (zoneMapsEnabled) {
671
+ // Header: 'ZM01' magic + shard_count + tracked_field_count + reserved0
672
+ bytes[zoneMapsOff + 0] = 0x30; bytes[zoneMapsOff + 1] = 0x5A;
673
+ bytes[zoneMapsOff + 2] = 0x4D; bytes[zoneMapsOff + 3] = 0x31;
674
+ dv.setUint32(zoneMapsOff + 4, shardCount, true);
675
+ dv.setUint32(zoneMapsOff + 8, T, true);
676
+ dv.setUint32(zoneMapsOff + 12, 0, true);
677
+ // Field index table
678
+ for (let t = 0; t < T; t++) {
679
+ dv.setUint16(zoneMapsFieldTableOff + t * 2, this._trackedFieldIndices[t], true);
680
+ }
681
+ // Mins / maxes: row-major, [shard * T + tracked]
682
+ for (let s = 0; s < shardCount; s++) {
683
+ const sh = this._shards[s];
684
+ for (let t = 0; t < T; t++) {
685
+ dv.setFloat64(zoneMapsMinsOff + (s * T + t) * 8, sh.mins[t], true);
686
+ dv.setFloat64(zoneMapsMaxesOff + (s * T + t) * 8, sh.maxes[t], true);
687
+ }
688
+ }
689
+ }
690
+
691
+ // Payloads + local string tables
692
+ for (let i = 0; i < shardCount; i++) {
693
+ bytes.set(this._shards[i].bytes, shardPayloadOffsets[i]);
694
+ bytes.set(this._shards[i].stringTableBytes, shardStringTableOffsets[i]);
695
+ }
696
+
697
+ // Footer
698
+ const footerOff = totalBytes - FOOTER_BYTES;
699
+ dv.setUint32(footerOff + 0, 0xFFFFFFFF, true);
700
+ dv.setUint32(footerOff + 4, 0, true);
701
+ bytes[footerOff + 8] = 0x31;
702
+ bytes[footerOff + 9] = 0x4B;
703
+ bytes[footerOff + 10] = 0x42;
704
+ bytes[footerOff + 11] = 0x4C;
705
+ dv.setUint32(footerOff + 12, FOOTER_BYTES, true);
706
+
707
+ return { buffer: container, totalRows: this._totalRows, shardCount, schema: this._schema };
708
+ }
709
+
710
+ get schema() { return this._schema; }
711
+ get totalRows() { return this._totalRows; }
712
+ get shardCount() { return this._shards.length + (this._currentShardRowCount > 0 ? 1 : 0); }
713
+ }