@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/Reader.js ADDED
@@ -0,0 +1,349 @@
1
+ // @zakkster/lite-bake-stream / Reader
2
+ // Parses LBK1 container and exposes typed-array views over shard payloads.
3
+ // Copyright (c) 2026 Zahary Shinikchiev. MIT.
4
+ //
5
+ // v1 lane kinds: F64 (1) and U32 (3, string-table index).
6
+ // Per-shard string tables live immediately after each shard's payload; the
7
+ // shard directory carries their offset+length. Reader parses each on load.
8
+ //
9
+ // Error codes:
10
+ // R_BAD_MAGIC - header magic mismatch
11
+ // R_UNSUPPORTED_VERSION - format_version > 1
12
+ // R_UNSUPPORTED_ENDIAN - endian byte is neither LE nor BE
13
+ // R_BAD_FIELD_FLAGS - a FieldDescriptor.flags is non-zero (v2+ reserved)
14
+ // R_UNSUPPORTED_LANE - lane_kind is not F64 or U32
15
+ // R_SHARD_VERSION_TOO_NEW - a shard's min_reader_version > this reader
16
+ // R_TRUNCATED - container ends before an expected structure
17
+ // R_UNKNOWN_FIELD - get() called with an unknown field name
18
+
19
+ import { StringTable } from './StringTable.js';
20
+
21
+ export const VERSION = '1.0.0';
22
+
23
+ const CONTAINER_HEADER_BYTES = 48;
24
+ const SHARD_ENTRY_BYTES = 40;
25
+ const FIELD_DESCRIPTOR_BYTES = 24;
26
+ const FOOTER_BYTES = 16;
27
+
28
+ const LANE_F64 = 1;
29
+ const LANE_U32 = 3;
30
+ const READER_VERSION = 1;
31
+
32
+ export class ReaderError extends Error {
33
+ constructor(code, msg) { super(msg); this.code = code; this.name = 'ReaderError'; }
34
+ }
35
+
36
+ export class Reader {
37
+ static fromBuffer(bufferOrArrayBuffer) {
38
+ const buffer = bufferOrArrayBuffer instanceof ArrayBuffer
39
+ ? bufferOrArrayBuffer
40
+ : bufferOrArrayBuffer.buffer;
41
+ return new Reader(buffer);
42
+ }
43
+
44
+ constructor(buffer) {
45
+ this._buffer = buffer;
46
+ this._dv = new DataView(buffer);
47
+ this._bytes = new Uint8Array(buffer);
48
+ this._parseHeader();
49
+ this._parseSchema();
50
+ this._parseShardDirectory();
51
+ this._parseZoneMaps(); // M7 — no-op if metadata_off is 0
52
+ this._buildFieldIndex();
53
+ }
54
+
55
+ _parseHeader() {
56
+ if (this._buffer.byteLength < CONTAINER_HEADER_BYTES) {
57
+ throw new ReaderError('R_TRUNCATED', 'container smaller than header');
58
+ }
59
+ const b = this._bytes;
60
+ if (b[0] !== 0x4C || b[1] !== 0x42 || b[2] !== 0x4B || b[3] !== 0x31) {
61
+ throw new ReaderError('R_BAD_MAGIC', 'header magic is not LBK1');
62
+ }
63
+ const version = this._dv.getUint16(4, true);
64
+ if (version > 1) throw new ReaderError('R_UNSUPPORTED_VERSION', 'format_version=' + version);
65
+ const endian = b[6];
66
+ if (endian !== 1 && endian !== 2) throw new ReaderError('R_UNSUPPORTED_ENDIAN', 'endian=' + endian);
67
+ if (endian !== 1) throw new ReaderError('R_UNSUPPORTED_ENDIAN', 'BE payloads not implemented in v1 reader');
68
+
69
+ // Header flags byte (offset 7). Bit 0 = preserve mode. This Reader parses
70
+ // the schema-mode variant only; preserve containers have no schema block
71
+ // and would return garbage if we tried, so refuse loudly.
72
+ const flags = this._bytes[7];
73
+ if (flags & 0x01) {
74
+ throw new ReaderError('R_WRONG_MODE',
75
+ 'container is preserve-mode; use PreserveReader (from @zakkster/lite-bake-stream/preserve-reader) or deserialize() which auto-dispatches');
76
+ }
77
+ if (flags & ~0x01) {
78
+ throw new ReaderError('R_BAD_FLAGS', 'unknown flag bits set in header byte 7: 0x' + flags.toString(16));
79
+ }
80
+ this._flags = flags;
81
+
82
+ this._schemaBlockOff = Number(this._dv.getBigUint64(8, true));
83
+ this._metadataOff = Number(this._dv.getBigUint64(16, true)); // 0 = no metadata block; M7+ zone maps
84
+ this._shardDirOff = Number(this._dv.getBigUint64(24, true));
85
+ this._shardCount = this._dv.getUint32(32, true);
86
+ // 4 bytes reserved at 36
87
+ this._totalRows = Number(this._dv.getBigUint64(40, true));
88
+ this._formatVersion = version;
89
+ }
90
+
91
+ _parseSchema() {
92
+ const off = this._schemaBlockOff;
93
+ if (off + 8 > this._buffer.byteLength) throw new ReaderError('R_TRUNCATED', 'schema header truncated');
94
+ const fieldCount = this._dv.getUint32(off + 0, true);
95
+ const rowStride = this._dv.getUint32(off + 4, true);
96
+ const descriptorsOff = off + 8;
97
+ const descriptorsBytes = fieldCount * FIELD_DESCRIPTOR_BYTES;
98
+ if (descriptorsOff + descriptorsBytes + 4 > this._buffer.byteLength) {
99
+ throw new ReaderError('R_TRUNCATED', 'schema descriptors truncated');
100
+ }
101
+
102
+ const fields = new Array(fieldCount);
103
+ const nameBlobLenOff = descriptorsOff + descriptorsBytes;
104
+ const nameBlobLen = this._dv.getUint32(nameBlobLenOff, true);
105
+ const nameBlobOff = nameBlobLenOff + 4;
106
+ if (nameBlobOff + nameBlobLen > this._buffer.byteLength) {
107
+ throw new ReaderError('R_TRUNCATED', 'schema name blob truncated');
108
+ }
109
+ const decoder = new TextDecoder('utf-8');
110
+
111
+ for (let i = 0; i < fieldCount; i++) {
112
+ const descOff = descriptorsOff + i * FIELD_DESCRIPTOR_BYTES;
113
+ const nameLen = this._dv.getUint16(descOff + 0, true);
114
+ const offsetInRow = this._dv.getUint16(descOff + 2, true);
115
+ const laneKind = this._bytes[descOff + 4];
116
+ const flags = this._bytes[descOff + 5];
117
+ const nameStrOff = Number(this._dv.getBigUint64(descOff + 8, true));
118
+
119
+ if (flags !== 0) throw new ReaderError('R_BAD_FIELD_FLAGS', 'field ' + i + ' has non-zero flags (v2+ reserved)');
120
+ if (laneKind !== LANE_F64 && laneKind !== LANE_U32)
121
+ throw new ReaderError('R_UNSUPPORTED_LANE', 'field ' + i + ' lane_kind=' + laneKind);
122
+
123
+ const nameBytes = this._bytes.subarray(nameBlobOff + nameStrOff, nameBlobOff + nameStrOff + nameLen);
124
+ const name = decoder.decode(nameBytes);
125
+ fields[i] = { name, laneKind, offsetInRow };
126
+ }
127
+
128
+ this._schema = { fields, rowStride };
129
+ }
130
+
131
+ _parseShardDirectory() {
132
+ const off = this._shardDirOff;
133
+ const need = this._shardCount * SHARD_ENTRY_BYTES;
134
+ if (off + need > this._buffer.byteLength) throw new ReaderError('R_TRUNCATED', 'shard directory truncated');
135
+ const shards = new Array(this._shardCount);
136
+ for (let i = 0; i < this._shardCount; i++) {
137
+ const entryOff = off + i * SHARD_ENTRY_BYTES;
138
+ const payloadOff = Number(this._dv.getBigUint64(entryOff + 0, true));
139
+ const payloadLen = this._dv.getUint32(entryOff + 8, true);
140
+ const rowCount = this._dv.getUint32(entryOff + 12, true);
141
+ const minReaderVer = this._dv.getUint16(entryOff + 16, true);
142
+ // 2 bytes flags at 18, 4 bytes reserved at 20
143
+ const localStrOff = Number(this._dv.getBigUint64(entryOff + 24, true));
144
+ const localStrLen = Number(this._dv.getBigUint64(entryOff + 32, true));
145
+ if (minReaderVer > READER_VERSION) {
146
+ throw new ReaderError('R_SHARD_VERSION_TOO_NEW',
147
+ 'shard ' + i + ' requires reader version ' + minReaderVer);
148
+ }
149
+ if (payloadOff + payloadLen > this._buffer.byteLength) {
150
+ throw new ReaderError('R_TRUNCATED', 'shard ' + i + ' payload truncated');
151
+ }
152
+ // Byte view over payload (no F64 view; row stride can be non-multiple-of-8 with U32 fields)
153
+ const payloadBytes = new Uint8Array(this._buffer, payloadOff, payloadLen);
154
+ const payloadDv = new DataView(this._buffer, payloadOff, payloadLen);
155
+ // String table view (present iff localStrLen > 0)
156
+ let stringTable = null;
157
+ if (localStrLen > 0) {
158
+ if (localStrOff + localStrLen > this._buffer.byteLength) {
159
+ throw new ReaderError('R_TRUNCATED', 'shard ' + i + ' string table truncated');
160
+ }
161
+ stringTable = StringTable.parse(new Uint8Array(this._buffer, localStrOff, localStrLen), 0);
162
+ }
163
+ shards[i] = { payloadOff, payloadLen, rowCount, payloadBytes, payloadDv, stringTable };
164
+ }
165
+ this._shards = shards;
166
+ }
167
+
168
+ _buildFieldIndex() {
169
+ const idx = new Map();
170
+ for (let i = 0; i < this._schema.fields.length; i++) idx.set(this._schema.fields[i].name, i);
171
+ this._fieldIndex = idx;
172
+ }
173
+
174
+ // M7: parse the zone maps segment if present. No-op if _metadataOff is 0
175
+ // or the segment magic doesn't match. Populates:
176
+ // _zoneMapsTrackedFields: number[] -- schema field indices with bounds
177
+ // _zoneMapsFieldToPos: Map<name, t> -- fast field-name -> tracking pos
178
+ // _zoneMapsMins/Maxes: Float64Array -- length shardCount * T, row-major
179
+ _parseZoneMaps() {
180
+ this._zoneMapsTrackedFields = null;
181
+ if (this._metadataOff === 0) return;
182
+ if (this._metadataOff + 16 > this._buffer.byteLength) return;
183
+ const off = this._metadataOff;
184
+ // Check 'ZM01' magic
185
+ if (this._bytes[off] !== 0x30 || this._bytes[off + 1] !== 0x5A ||
186
+ this._bytes[off + 2] !== 0x4D || this._bytes[off + 3] !== 0x31) return;
187
+ const shardCount = this._dv.getUint32(off + 4, true);
188
+ const T = this._dv.getUint32(off + 8, true);
189
+ if (shardCount !== this._shardCount) {
190
+ throw new ReaderError('R_TRUNCATED', 'zone maps shard_count mismatch');
191
+ }
192
+ if (T === 0) return;
193
+ const fieldTableOff = off + 16;
194
+ const fieldTablePad = (8 - ((T * 2) & 7)) & 7;
195
+ const minsOff = fieldTableOff + T * 2 + fieldTablePad;
196
+ const maxesOff = minsOff + shardCount * T * 8;
197
+ const requiredEnd = maxesOff + shardCount * T * 8;
198
+ if (requiredEnd > this._buffer.byteLength) {
199
+ throw new ReaderError('R_TRUNCATED', 'zone maps segment truncated');
200
+ }
201
+ const tracked = new Array(T);
202
+ const fieldToPos = new Map();
203
+ for (let t = 0; t < T; t++) {
204
+ const schemaFieldIdx = this._dv.getUint16(fieldTableOff + t * 2, true);
205
+ if (schemaFieldIdx >= this._schema.fields.length) {
206
+ throw new ReaderError('R_TRUNCATED', 'zone maps references field index ' + schemaFieldIdx);
207
+ }
208
+ tracked[t] = schemaFieldIdx;
209
+ fieldToPos.set(this._schema.fields[schemaFieldIdx].name, t);
210
+ }
211
+ // Zero-copy views over the container's underlying buffer.
212
+ this._zoneMapsTrackedFields = tracked;
213
+ this._zoneMapsFieldToPos = fieldToPos;
214
+ this._zoneMapsMins = new Float64Array(this._buffer, minsOff, shardCount * T);
215
+ this._zoneMapsMaxes = new Float64Array(this._buffer, maxesOff, shardCount * T);
216
+ this._zoneMapsT = T;
217
+ }
218
+
219
+ // ---------- public API ----------
220
+
221
+ get schema() { return this._schema; }
222
+ get totalRows() { return this._totalRows; }
223
+ get shardCount() { return this._shardCount; }
224
+ get shards() { return this._shards; }
225
+ // ---------- internal accessors ----------
226
+ //
227
+ // These exist so sibling modules (Split.js) can do container surgery without
228
+ // reaching into underscore-prefixed fields. They are NOT part of the public
229
+ // API surface and carry no semver guarantee beyond "Split.js can rely on
230
+ // them"; consumers should use the documented public API instead.
231
+
232
+ /** @internal Byte offset of the shard directory. */
233
+ get shardDirectoryOffset() { return this._shardDirOff; }
234
+
235
+ /**
236
+ * @internal Raw zone-maps state, or null when the container has none.
237
+ * Returns { trackedFields: number[], laneCount: number,
238
+ * mins: Float64Array, maxes: Float64Array }
239
+ * where mins/maxes are row-major [shardIdx * laneCount + trackedPos].
240
+ */
241
+ zoneMapsRaw() {
242
+ if (!this._zoneMapsTrackedFields) return null;
243
+ return {
244
+ trackedFields: this._zoneMapsTrackedFields,
245
+ laneCount: this._zoneMapsT,
246
+ mins: this._zoneMapsMins,
247
+ maxes: this._zoneMapsMaxes,
248
+ };
249
+ }
250
+
251
+ get buffer() { return this._buffer; }
252
+
253
+ // Whether the container carries a zone maps segment. Consumers can pivot
254
+ // to a full-scan strategy when this is false.
255
+ get hasZoneMaps() { return this._zoneMapsTrackedFields !== null; }
256
+
257
+ // {min, max} for the given field within the given shard, or null if:
258
+ // - the container has no zone maps, or
259
+ // - the field is not tracked (currently: U32/string fields are never tracked in v1).
260
+ // For empty ranges (a shard where the field defaulted to 0), returns {min:0, max:0}.
261
+ shardBounds(shardIdx, fieldName) {
262
+ if (!this._zoneMapsTrackedFields) return null;
263
+ const t = this._zoneMapsFieldToPos.get(fieldName);
264
+ if (t === undefined) return null;
265
+ if (shardIdx < 0 || shardIdx >= this._shardCount) return null;
266
+ const pos = shardIdx * this._zoneMapsT + t;
267
+ return { min: this._zoneMapsMins[pos], max: this._zoneMapsMaxes[pos] };
268
+ }
269
+
270
+ // Return the indices of every shard whose bounds overlap [min, max].
271
+ // When there are no zone maps or the field is not tracked, returns every
272
+ // shard (i.e. the query planner must fall back to full scan).
273
+ // Semantics: a shard is INCLUDED iff its [smin, smax] overlaps [min, max].
274
+ // A pass-through range ({min: -Inf, max: +Inf} or omitted) returns all shards.
275
+ findShards(fieldName, opts) {
276
+ const all = () => { const a = new Array(this._shardCount); for (let i = 0; i < this._shardCount; i++) a[i] = i; return a; };
277
+ if (!this._zoneMapsTrackedFields) return all();
278
+ const t = this._zoneMapsFieldToPos.get(fieldName);
279
+ if (t === undefined) return all();
280
+ const min = (opts && typeof opts.min === 'number') ? opts.min : Number.NEGATIVE_INFINITY;
281
+ const max = (opts && typeof opts.max === 'number') ? opts.max : Number.POSITIVE_INFINITY;
282
+ const T = this._zoneMapsT;
283
+ const out = [];
284
+ for (let s = 0; s < this._shardCount; s++) {
285
+ const smin = this._zoneMapsMins[s * T + t];
286
+ const smax = this._zoneMapsMaxes[s * T + t];
287
+ // Overlap iff smin <= max AND smax >= min
288
+ if (smin <= max && smax >= min) out.push(s);
289
+ }
290
+ return out;
291
+ }
292
+
293
+ fieldIndex(name) {
294
+ const i = this._fieldIndex.get(name);
295
+ if (i === undefined) throw new ReaderError('R_UNKNOWN_FIELD', 'no field named ' + JSON.stringify(name));
296
+ return i;
297
+ }
298
+
299
+ // Random-access get across all shards. Returns the field's decoded value:
300
+ // F64 lanes -> number
301
+ // U32 lanes -> JS string (resolved via the shard's local string table)
302
+ // Not zero-alloc; intended for testing/introspection.
303
+ get(rowIdx, fieldName) {
304
+ let remaining = rowIdx;
305
+ const fieldIdx = this.fieldIndex(fieldName);
306
+ const field = this._schema.fields[fieldIdx];
307
+ for (let s = 0; s < this._shards.length; s++) {
308
+ const shard = this._shards[s];
309
+ if (remaining < shard.rowCount) {
310
+ const rowOff = remaining * this._schema.rowStride;
311
+ if (field.laneKind === LANE_F64) {
312
+ return shard.payloadDv.getFloat64(rowOff + field.offsetInRow, true);
313
+ } else {
314
+ const strIdx = shard.payloadDv.getUint32(rowOff + field.offsetInRow, true);
315
+ return shard.stringTable ? shard.stringTable.get(strIdx) : undefined;
316
+ }
317
+ }
318
+ remaining -= shard.rowCount;
319
+ }
320
+ return undefined;
321
+ }
322
+
323
+ // Hot-loop friendly: shard payload as DataView + stride + per-field byte offset.
324
+ shardPayload(shardIdx) { return this._shards[shardIdx].payloadDv; }
325
+ strideBytes() { return this._schema.rowStride; }
326
+ offsetBytes(fieldName) { return this._schema.fields[this.fieldIndex(fieldName)].offsetInRow; }
327
+ laneKind(fieldName) { return this._schema.fields[this.fieldIndex(fieldName)].laneKind; }
328
+ shardStringTable(shardIdx) { return this._shards[shardIdx].stringTable; }
329
+
330
+ // Back-compat with M2: F64-only reader helpers still work when the schema is
331
+ // all-F64 (rowStride is a multiple of 8). For mixed schemas these throw.
332
+ shardF64(shardIdx) {
333
+ const shard = this._shards[shardIdx];
334
+ if ((this._schema.rowStride & 7) !== 0)
335
+ throw new ReaderError('R_UNSUPPORTED_LANE', 'shardF64 not usable on mixed-lane schema');
336
+ return new Float64Array(this._buffer, shard.payloadOff, shard.payloadLen / 8);
337
+ }
338
+ strideF64() {
339
+ if ((this._schema.rowStride & 7) !== 0)
340
+ throw new ReaderError('R_UNSUPPORTED_LANE', 'strideF64 not usable on mixed-lane schema');
341
+ return this._schema.rowStride / 8;
342
+ }
343
+ offsetF64(fieldName) {
344
+ const f = this._schema.fields[this.fieldIndex(fieldName)];
345
+ if (f.laneKind !== LANE_F64) throw new ReaderError('R_UNSUPPORTED_LANE', fieldName + ' is not F64');
346
+ if ((f.offsetInRow & 7) !== 0) throw new ReaderError('R_UNSUPPORTED_LANE', fieldName + ' offset not F64-aligned');
347
+ return f.offsetInRow / 8;
348
+ }
349
+ }