@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/CHANGELOG.md +457 -0
- package/LICENSE +21 -0
- package/README.md +150 -0
- package/SPEC.md +364 -0
- package/llms.txt +81 -0
- package/package.json +117 -0
- package/src/FileIngest.js +104 -0
- package/src/MultiReader.js +160 -0
- package/src/PreserveReader.js +180 -0
- package/src/PreserveTokenizer.js +172 -0
- package/src/PreserveWriter.js +218 -0
- package/src/RangeReader.js +470 -0
- package/src/Reader.js +349 -0
- package/src/Split.js +359 -0
- package/src/StringTable.js +225 -0
- package/src/Tokenizer.js +691 -0
- package/src/Writer.js +713 -0
- package/src/index.js +193 -0
- package/types/FileIngest.d.ts +44 -0
- package/types/MultiReader.d.ts +36 -0
- package/types/PreserveReader.d.ts +44 -0
- package/types/PreserveTokenizer.d.ts +27 -0
- package/types/PreserveWriter.d.ts +35 -0
- package/types/RangeReader.d.ts +93 -0
- package/types/Reader.d.ts +85 -0
- package/types/Split.d.ts +66 -0
- package/types/StringTable.d.ts +23 -0
- package/types/Tokenizer.d.ts +42 -0
- package/types/Writer.d.ts +92 -0
- package/types/index.d.ts +58 -0
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
// @zakkster/lite-bake-stream / RangeReader
|
|
2
|
+
// LBK1 reader with lazy shard loading via an IO adapter.
|
|
3
|
+
// Copyright (c) 2026 Zahary Shinikchiev. MIT.
|
|
4
|
+
//
|
|
5
|
+
// The base Reader parses a fully-loaded ArrayBuffer synchronously. That's the
|
|
6
|
+
// right choice for small containers and post-ingest local use. For gigabyte-
|
|
7
|
+
// scale containers hosted over HTTP, downloading the whole file to read a few
|
|
8
|
+
// rows is wasteful; SPEC 3.4's shard directory + per-shard payload/string-
|
|
9
|
+
// table offsets exist precisely so a client can fetch only what it needs.
|
|
10
|
+
//
|
|
11
|
+
// RangeReader implements that story:
|
|
12
|
+
// 1. On open, fetch the header (32 bytes) + schema block + shard directory.
|
|
13
|
+
// That gives us the whole schema and every shard's byte extents without
|
|
14
|
+
// touching a single row.
|
|
15
|
+
// 2. On get(rowIdx, fieldName), locate the shard containing rowIdx, fetch
|
|
16
|
+
// its payload + local string table in ONE range request (they are
|
|
17
|
+
// contiguous by SPEC 3.4), cache it, decode.
|
|
18
|
+
// 3. LRU-evict cached shards when the cache exceeds `maxCachedShards`.
|
|
19
|
+
//
|
|
20
|
+
// IO adapter contract:
|
|
21
|
+
// interface IOAdapter {
|
|
22
|
+
// size: number; // container byte length
|
|
23
|
+
// fetch(byteOffset, byteLength): Promise<Uint8Array>; // MUST return exactly that many bytes
|
|
24
|
+
// }
|
|
25
|
+
//
|
|
26
|
+
// Two adapters ship in-box:
|
|
27
|
+
// HTTPRangeAdapter -- uses fetch() with Range headers.
|
|
28
|
+
// MockRangeAdapter -- backed by a Uint8Array; used by tests + local demos.
|
|
29
|
+
//
|
|
30
|
+
// Error codes (stable, share prefix with Reader.js where semantics overlap):
|
|
31
|
+
// R_BAD_MAGIC / R_UNSUPPORTED_VERSION / R_UNSUPPORTED_ENDIAN / R_BAD_FIELD_FLAGS
|
|
32
|
+
// R_UNSUPPORTED_LANE / R_SHARD_VERSION_TOO_NEW / R_TRUNCATED / R_UNKNOWN_FIELD
|
|
33
|
+
// R_ADAPTER_SHORT_READ -- adapter returned fewer bytes than requested
|
|
34
|
+
// R_ROW_OUT_OF_RANGE -- rowIdx >= totalRows
|
|
35
|
+
|
|
36
|
+
import { StringTable } from './StringTable.js';
|
|
37
|
+
|
|
38
|
+
export const VERSION = '1.0.0';
|
|
39
|
+
|
|
40
|
+
const CONTAINER_HEADER_BYTES = 48;
|
|
41
|
+
const SHARD_ENTRY_BYTES = 40;
|
|
42
|
+
const FIELD_DESCRIPTOR_BYTES = 24;
|
|
43
|
+
|
|
44
|
+
const LANE_F64 = 1;
|
|
45
|
+
const LANE_U32 = 3;
|
|
46
|
+
const READER_VERSION = 1;
|
|
47
|
+
|
|
48
|
+
export class RangeReaderError extends Error {
|
|
49
|
+
constructor(code, msg) { super(msg); this.code = code; this.name = 'RangeReaderError'; }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ----- Adapters --------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
// Uses fetch() with Range headers. Content-Length is discovered on open() via
|
|
55
|
+
// a HEAD (falls back to a Range: bytes=0-0 GET if HEAD is disallowed by the
|
|
56
|
+
// origin/CDN).
|
|
57
|
+
export class HTTPRangeAdapter {
|
|
58
|
+
static async open(url, opts) {
|
|
59
|
+
opts = opts || {};
|
|
60
|
+
const fetchImpl = opts.fetch || globalThis.fetch;
|
|
61
|
+
if (typeof fetchImpl !== 'function') {
|
|
62
|
+
throw new RangeReaderError('R_TRUNCATED', 'no fetch() available in this environment');
|
|
63
|
+
}
|
|
64
|
+
let size = -1;
|
|
65
|
+
// Try HEAD first.
|
|
66
|
+
try {
|
|
67
|
+
const head = await fetchImpl(url, { method: 'HEAD' });
|
|
68
|
+
if (head.ok) {
|
|
69
|
+
const cl = head.headers.get('content-length');
|
|
70
|
+
if (cl) size = parseInt(cl, 10);
|
|
71
|
+
}
|
|
72
|
+
} catch { /* fall through */ }
|
|
73
|
+
// Fallback: single-byte range GET.
|
|
74
|
+
if (size < 0) {
|
|
75
|
+
const probe = await fetchImpl(url, { headers: { Range: 'bytes=0-0' } });
|
|
76
|
+
const cr = probe.headers.get('content-range');
|
|
77
|
+
if (cr) {
|
|
78
|
+
const m = cr.match(/\/(\d+)$/);
|
|
79
|
+
if (m) size = parseInt(m[1], 10);
|
|
80
|
+
}
|
|
81
|
+
// consume body so the connection doesn't hang
|
|
82
|
+
await probe.arrayBuffer();
|
|
83
|
+
}
|
|
84
|
+
if (size < 0) throw new RangeReaderError('R_TRUNCATED', 'could not determine container size for ' + url);
|
|
85
|
+
return new HTTPRangeAdapter(url, size, fetchImpl);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
constructor(url, size, fetchImpl) {
|
|
89
|
+
this.url = url;
|
|
90
|
+
this.size = size;
|
|
91
|
+
this._fetch = fetchImpl;
|
|
92
|
+
this.stats = { requests: 0, bytesFetched: 0 };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async fetch(byteOffset, byteLength) {
|
|
96
|
+
const rangeEnd = byteOffset + byteLength - 1;
|
|
97
|
+
const res = await this._fetch(this.url, {
|
|
98
|
+
headers: { Range: 'bytes=' + byteOffset + '-' + rangeEnd },
|
|
99
|
+
});
|
|
100
|
+
if (!res.ok && res.status !== 206) {
|
|
101
|
+
throw new RangeReaderError('R_TRUNCATED',
|
|
102
|
+
'range fetch failed: HTTP ' + res.status + ' bytes=' + byteOffset + '-' + rangeEnd);
|
|
103
|
+
}
|
|
104
|
+
const buf = new Uint8Array(await res.arrayBuffer());
|
|
105
|
+
if (buf.length !== byteLength) {
|
|
106
|
+
throw new RangeReaderError('R_ADAPTER_SHORT_READ',
|
|
107
|
+
'expected ' + byteLength + ' bytes at offset ' + byteOffset + ', got ' + buf.length);
|
|
108
|
+
}
|
|
109
|
+
this.stats.requests++;
|
|
110
|
+
this.stats.bytesFetched += byteLength;
|
|
111
|
+
return buf;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Backed by an in-memory Uint8Array. Used by tests and the local demo (which
|
|
116
|
+
// wants to prove the RangeReader code path against a container it just built
|
|
117
|
+
// in the browser, without spinning up an HTTP server).
|
|
118
|
+
export class MockRangeAdapter {
|
|
119
|
+
constructor(bytes) {
|
|
120
|
+
this.bytes = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
121
|
+
this.size = this.bytes.length;
|
|
122
|
+
this.log = []; // [[offset, length], ...] for test assertions
|
|
123
|
+
this.stats = { requests: 0, bytesFetched: 0 };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async fetch(byteOffset, byteLength) {
|
|
127
|
+
if (byteOffset + byteLength > this.size) {
|
|
128
|
+
throw new RangeReaderError('R_ADAPTER_SHORT_READ',
|
|
129
|
+
'mock adapter: request bytes=' + byteOffset + '-' + (byteOffset + byteLength - 1) +
|
|
130
|
+
' exceeds size ' + this.size);
|
|
131
|
+
}
|
|
132
|
+
this.log.push([byteOffset, byteLength]);
|
|
133
|
+
this.stats.requests++;
|
|
134
|
+
this.stats.bytesFetched += byteLength;
|
|
135
|
+
// Return a COPY so mutations by the caller don't affect the "source of truth".
|
|
136
|
+
// This matches HTTPRangeAdapter's semantics (each fetch returns a fresh buffer).
|
|
137
|
+
return this.bytes.slice(byteOffset, byteOffset + byteLength);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ----- RangeReader ---------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
export class RangeReader {
|
|
144
|
+
static async open(adapter, opts) {
|
|
145
|
+
const r = new RangeReader(adapter, opts);
|
|
146
|
+
await r._loadHeaderAndSchema();
|
|
147
|
+
return r;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
constructor(adapter, opts) {
|
|
151
|
+
opts = opts || {};
|
|
152
|
+
this.adapter = adapter;
|
|
153
|
+
this.maxCachedShards = opts.maxCachedShards || 8;
|
|
154
|
+
// shard cache: shardIdx -> { payloadBytes, payloadDv, stringTable, lastAccess }
|
|
155
|
+
this._shardCache = new Map();
|
|
156
|
+
this._accessCounter = 0;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async _loadHeaderAndSchema() {
|
|
160
|
+
if (this.adapter.size < CONTAINER_HEADER_BYTES) {
|
|
161
|
+
throw new RangeReaderError('R_TRUNCATED', 'adapter size smaller than header');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Step 1: header (32 bytes) tells us schema + shard-dir offsets.
|
|
165
|
+
const headerBytes = await this.adapter.fetch(0, CONTAINER_HEADER_BYTES);
|
|
166
|
+
const hdrDv = new DataView(headerBytes.buffer, headerBytes.byteOffset, headerBytes.byteLength);
|
|
167
|
+
if (headerBytes[0] !== 0x4C || headerBytes[1] !== 0x42 || headerBytes[2] !== 0x4B || headerBytes[3] !== 0x31) {
|
|
168
|
+
throw new RangeReaderError('R_BAD_MAGIC', 'header magic is not LBK1');
|
|
169
|
+
}
|
|
170
|
+
const version = hdrDv.getUint16(4, true);
|
|
171
|
+
if (version > 1) throw new RangeReaderError('R_UNSUPPORTED_VERSION', 'format_version=' + version);
|
|
172
|
+
const endian = headerBytes[6];
|
|
173
|
+
if (endian !== 1) throw new RangeReaderError('R_UNSUPPORTED_ENDIAN', 'BE payloads not implemented in v1 reader');
|
|
174
|
+
|
|
175
|
+
this._schemaBlockOff = Number(hdrDv.getBigUint64(8, true));
|
|
176
|
+
this._metadataOff = Number(hdrDv.getBigUint64(16, true));
|
|
177
|
+
this._shardDirOff = Number(hdrDv.getBigUint64(24, true));
|
|
178
|
+
this._shardCount = hdrDv.getUint32(32, true);
|
|
179
|
+
// 4 bytes reserved at 36
|
|
180
|
+
this._totalRows = Number(hdrDv.getBigUint64(40, true));
|
|
181
|
+
|
|
182
|
+
// Step 2: schema block. We know its start but not its length; compute
|
|
183
|
+
// upper bound: schemaBlockOff .. shardDirOff.
|
|
184
|
+
const schemaBlockLen = this._shardDirOff - this._schemaBlockOff;
|
|
185
|
+
const schemaBytes = await this.adapter.fetch(this._schemaBlockOff, schemaBlockLen);
|
|
186
|
+
this._parseSchema(schemaBytes);
|
|
187
|
+
|
|
188
|
+
// Step 3: shard directory (fixed size = shardCount * 32).
|
|
189
|
+
const dirBytes = await this.adapter.fetch(this._shardDirOff, this._shardCount * SHARD_ENTRY_BYTES);
|
|
190
|
+
this._parseShardDirectory(dirBytes);
|
|
191
|
+
|
|
192
|
+
// Step 4 (M7): zone maps. Fetched ONCE at open, cached. Enables query
|
|
193
|
+
// pruning without touching shard payloads. When metadata_off is 0, skip
|
|
194
|
+
// entirely (older writers or all-U32 schemas).
|
|
195
|
+
this._zoneMapsTrackedFields = null;
|
|
196
|
+
if (this._metadataOff !== 0) await this._loadZoneMaps();
|
|
197
|
+
|
|
198
|
+
// Field-name -> index map for O(1) get()
|
|
199
|
+
this._fieldIndex = new Map();
|
|
200
|
+
for (let i = 0; i < this._schema.fields.length; i++) {
|
|
201
|
+
this._fieldIndex.set(this._schema.fields[i].name, i);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async _loadZoneMaps() {
|
|
206
|
+
// Fetch the segment header first (16 bytes) so we can size the rest.
|
|
207
|
+
const hdr = await this.adapter.fetch(this._metadataOff, 16);
|
|
208
|
+
if (hdr[0] !== 0x30 || hdr[1] !== 0x5A || hdr[2] !== 0x4D || hdr[3] !== 0x31) return;
|
|
209
|
+
const hdrDv = new DataView(hdr.buffer, hdr.byteOffset, hdr.byteLength);
|
|
210
|
+
const shardCount = hdrDv.getUint32(4, true);
|
|
211
|
+
const T = hdrDv.getUint32(8, true);
|
|
212
|
+
if (shardCount !== this._shardCount) {
|
|
213
|
+
throw new RangeReaderError('R_TRUNCATED', 'zone maps shard_count mismatch');
|
|
214
|
+
}
|
|
215
|
+
if (T === 0) return;
|
|
216
|
+
// Fetch the rest in one range: field table + pad + mins + maxes.
|
|
217
|
+
const fieldTableLen = T * 2;
|
|
218
|
+
const fieldTablePad = (8 - (fieldTableLen & 7)) & 7;
|
|
219
|
+
const restLen = fieldTableLen + fieldTablePad + shardCount * T * 8 * 2;
|
|
220
|
+
const rest = await this.adapter.fetch(this._metadataOff + 16, restLen);
|
|
221
|
+
const restDv = new DataView(rest.buffer, rest.byteOffset, rest.byteLength);
|
|
222
|
+
const tracked = new Array(T);
|
|
223
|
+
const fieldToPos = new Map();
|
|
224
|
+
for (let t = 0; t < T; t++) {
|
|
225
|
+
const schemaFieldIdx = restDv.getUint16(t * 2, true);
|
|
226
|
+
if (schemaFieldIdx >= this._schema.fields.length) {
|
|
227
|
+
throw new RangeReaderError('R_TRUNCATED', 'zone maps references field index ' + schemaFieldIdx);
|
|
228
|
+
}
|
|
229
|
+
tracked[t] = schemaFieldIdx;
|
|
230
|
+
fieldToPos.set(this._schema.fields[schemaFieldIdx].name, t);
|
|
231
|
+
}
|
|
232
|
+
const minsRel = fieldTableLen + fieldTablePad;
|
|
233
|
+
// Materialize as Float64Array. Zero-copy over rest.buffer, aligned reads.
|
|
234
|
+
this._zoneMapsMins = new Float64Array(
|
|
235
|
+
rest.buffer, rest.byteOffset + minsRel, shardCount * T);
|
|
236
|
+
this._zoneMapsMaxes = new Float64Array(
|
|
237
|
+
rest.buffer, rest.byteOffset + minsRel + shardCount * T * 8, shardCount * T);
|
|
238
|
+
this._zoneMapsTrackedFields = tracked;
|
|
239
|
+
this._zoneMapsFieldToPos = fieldToPos;
|
|
240
|
+
this._zoneMapsT = T;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
_parseSchema(bytes) {
|
|
244
|
+
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
245
|
+
const fieldCount = dv.getUint32(0, true);
|
|
246
|
+
const rowStride = dv.getUint32(4, true);
|
|
247
|
+
const descOff = 8;
|
|
248
|
+
const descBytes = fieldCount * FIELD_DESCRIPTOR_BYTES;
|
|
249
|
+
const nameBlobLenOff = descOff + descBytes;
|
|
250
|
+
const nameBlobLen = dv.getUint32(nameBlobLenOff, true);
|
|
251
|
+
const nameBlobOff = nameBlobLenOff + 4;
|
|
252
|
+
const decoder = new TextDecoder('utf-8');
|
|
253
|
+
const fields = new Array(fieldCount);
|
|
254
|
+
for (let i = 0; i < fieldCount; i++) {
|
|
255
|
+
const off = descOff + i * FIELD_DESCRIPTOR_BYTES;
|
|
256
|
+
const nameLen = dv.getUint16(off + 0, true);
|
|
257
|
+
const offsetInRow = dv.getUint16(off + 2, true);
|
|
258
|
+
const laneKind = bytes[off + 4];
|
|
259
|
+
const flags = bytes[off + 5];
|
|
260
|
+
const nameStrOff = Number(dv.getBigUint64(off + 8, true));
|
|
261
|
+
if (flags !== 0) throw new RangeReaderError('R_BAD_FIELD_FLAGS', 'field ' + i + ' has non-zero flags');
|
|
262
|
+
if (laneKind !== LANE_F64 && laneKind !== LANE_U32)
|
|
263
|
+
throw new RangeReaderError('R_UNSUPPORTED_LANE', 'field ' + i + ' lane_kind=' + laneKind);
|
|
264
|
+
const nb = bytes.subarray(nameBlobOff + nameStrOff, nameBlobOff + nameStrOff + nameLen);
|
|
265
|
+
fields[i] = { name: decoder.decode(nb), laneKind, offsetInRow };
|
|
266
|
+
}
|
|
267
|
+
this._schema = { fields, rowStride };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
_parseShardDirectory(bytes) {
|
|
271
|
+
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
272
|
+
const shards = new Array(this._shardCount);
|
|
273
|
+
let cumulativeRow = 0;
|
|
274
|
+
for (let i = 0; i < this._shardCount; i++) {
|
|
275
|
+
const off = i * SHARD_ENTRY_BYTES;
|
|
276
|
+
const payloadOff = Number(dv.getBigUint64(off + 0, true));
|
|
277
|
+
const payloadLen = dv.getUint32(off + 8, true);
|
|
278
|
+
const rowCount = dv.getUint32(off + 12, true);
|
|
279
|
+
const minReaderVer = dv.getUint16(off + 16, true);
|
|
280
|
+
// 2 bytes flags at 18, 4 bytes reserved at 20
|
|
281
|
+
const localStrOff = Number(dv.getBigUint64(off + 24, true));
|
|
282
|
+
const localStrLen = Number(dv.getBigUint64(off + 32, true));
|
|
283
|
+
if (minReaderVer > READER_VERSION)
|
|
284
|
+
throw new RangeReaderError('R_SHARD_VERSION_TOO_NEW',
|
|
285
|
+
'shard ' + i + ' requires reader version ' + minReaderVer);
|
|
286
|
+
shards[i] = {
|
|
287
|
+
payloadOff, payloadLen, rowCount, localStrOff, localStrLen,
|
|
288
|
+
firstRow: cumulativeRow,
|
|
289
|
+
endRow: cumulativeRow + rowCount,
|
|
290
|
+
};
|
|
291
|
+
cumulativeRow += rowCount;
|
|
292
|
+
}
|
|
293
|
+
this._shards = shards;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ---------- public API ----------
|
|
297
|
+
|
|
298
|
+
get schema() { return this._schema; }
|
|
299
|
+
get totalRows() { return this._totalRows; }
|
|
300
|
+
get shardCount() { return this._shardCount; }
|
|
301
|
+
get shards() { return this._shards; }
|
|
302
|
+
|
|
303
|
+
fieldIndex(name) {
|
|
304
|
+
const i = this._fieldIndex.get(name);
|
|
305
|
+
if (i === undefined) throw new RangeReaderError('R_UNKNOWN_FIELD', 'no field named ' + JSON.stringify(name));
|
|
306
|
+
return i;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Locate the shard containing rowIdx via binary search.
|
|
310
|
+
_findShardIndex(rowIdx) {
|
|
311
|
+
if (rowIdx < 0 || rowIdx >= this._totalRows) {
|
|
312
|
+
throw new RangeReaderError('R_ROW_OUT_OF_RANGE',
|
|
313
|
+
'rowIdx ' + rowIdx + ' out of range [0, ' + this._totalRows + ')');
|
|
314
|
+
}
|
|
315
|
+
let lo = 0, hi = this._shards.length;
|
|
316
|
+
while (lo < hi) {
|
|
317
|
+
const mid = (lo + hi) >>> 1;
|
|
318
|
+
if (rowIdx < this._shards[mid].firstRow) hi = mid;
|
|
319
|
+
else if (rowIdx >= this._shards[mid].endRow) lo = mid + 1;
|
|
320
|
+
else return mid;
|
|
321
|
+
}
|
|
322
|
+
return -1; // unreachable given the range check above
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Load a shard's payload + local string table with ONE range request.
|
|
326
|
+
// They are contiguous in the container by SPEC 3.4, so a single fetch covers
|
|
327
|
+
// both. Returns the cached shard record.
|
|
328
|
+
async loadShard(shardIdx) {
|
|
329
|
+
const cached = this._shardCache.get(shardIdx);
|
|
330
|
+
if (cached) {
|
|
331
|
+
cached.lastAccess = ++this._accessCounter;
|
|
332
|
+
return cached;
|
|
333
|
+
}
|
|
334
|
+
const s = this._shards[shardIdx];
|
|
335
|
+
const combinedLen = s.payloadLen + s.localStrLen;
|
|
336
|
+
const combinedOff = s.payloadOff;
|
|
337
|
+
// Sanity: string table (if any) MUST be contiguous with payload.
|
|
338
|
+
if (s.localStrLen > 0 && s.localStrOff !== combinedOff + s.payloadLen) {
|
|
339
|
+
throw new RangeReaderError('R_TRUNCATED',
|
|
340
|
+
'shard ' + shardIdx + ' string table not contiguous with payload');
|
|
341
|
+
}
|
|
342
|
+
const combined = await this.adapter.fetch(combinedOff, combinedLen);
|
|
343
|
+
const payloadBytes = combined.subarray(0, s.payloadLen);
|
|
344
|
+
// DataView over the payload's underlying ArrayBuffer window.
|
|
345
|
+
const payloadDv = new DataView(payloadBytes.buffer, payloadBytes.byteOffset, payloadBytes.byteLength);
|
|
346
|
+
let stringTable = null;
|
|
347
|
+
if (s.localStrLen > 0) {
|
|
348
|
+
const stBytes = combined.subarray(s.payloadLen, s.payloadLen + s.localStrLen);
|
|
349
|
+
stringTable = StringTable.parse(stBytes, 0);
|
|
350
|
+
}
|
|
351
|
+
const record = {
|
|
352
|
+
payloadBytes, payloadDv, stringTable,
|
|
353
|
+
firstRow: s.firstRow, rowCount: s.rowCount,
|
|
354
|
+
lastAccess: ++this._accessCounter,
|
|
355
|
+
};
|
|
356
|
+
this._shardCache.set(shardIdx, record);
|
|
357
|
+
this._maybeEvict();
|
|
358
|
+
return record;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
_maybeEvict() {
|
|
362
|
+
if (this._shardCache.size <= this.maxCachedShards) return;
|
|
363
|
+
// Evict LRU: the one with the smallest lastAccess.
|
|
364
|
+
let evictKey = -1, evictAccess = Infinity;
|
|
365
|
+
for (const [key, rec] of this._shardCache) {
|
|
366
|
+
if (rec.lastAccess < evictAccess) { evictAccess = rec.lastAccess; evictKey = key; }
|
|
367
|
+
}
|
|
368
|
+
if (evictKey >= 0) this._shardCache.delete(evictKey);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Random-access get: returns a number (F64 lane) or a string (U32 lane).
|
|
372
|
+
// Async because the containing shard may need to be fetched.
|
|
373
|
+
async get(rowIdx, fieldName) {
|
|
374
|
+
const fieldIdx = this.fieldIndex(fieldName);
|
|
375
|
+
const field = this._schema.fields[fieldIdx];
|
|
376
|
+
const shardIdx = this._findShardIndex(rowIdx);
|
|
377
|
+
const shard = await this.loadShard(shardIdx);
|
|
378
|
+
const localRow = rowIdx - shard.firstRow;
|
|
379
|
+
const rowOff = localRow * this._schema.rowStride;
|
|
380
|
+
if (field.laneKind === LANE_F64) {
|
|
381
|
+
return shard.payloadDv.getFloat64(rowOff + field.offsetInRow, true);
|
|
382
|
+
}
|
|
383
|
+
const strIdx = shard.payloadDv.getUint32(rowOff + field.offsetInRow, true);
|
|
384
|
+
return shard.stringTable ? shard.stringTable.get(strIdx) : undefined;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Ensure shards covering rows [firstRow, lastRow) are cached. Useful for
|
|
388
|
+
// prefetching before a viewport re-render, so the UI doesn't await per-row.
|
|
389
|
+
async prefetchRange(firstRow, lastRow) {
|
|
390
|
+
if (firstRow >= this._totalRows) return;
|
|
391
|
+
if (lastRow > this._totalRows) lastRow = this._totalRows;
|
|
392
|
+
const firstShard = this._findShardIndex(firstRow);
|
|
393
|
+
const lastShard = this._findShardIndex(lastRow - 1);
|
|
394
|
+
const requests = [];
|
|
395
|
+
for (let s = firstShard; s <= lastShard; s++) {
|
|
396
|
+
if (!this._shardCache.has(s)) requests.push(this.loadShard(s));
|
|
397
|
+
}
|
|
398
|
+
await Promise.all(requests);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Return a synchronous row accessor for a range that is already cached.
|
|
402
|
+
// Throws if any shard in the range hasn't been prefetched. Intended for
|
|
403
|
+
// hot render paths: the UI calls prefetchRange, then reads without await.
|
|
404
|
+
syncRange(firstRow, lastRow) {
|
|
405
|
+
const firstShard = this._findShardIndex(firstRow);
|
|
406
|
+
const lastShard = this._findShardIndex(lastRow - 1);
|
|
407
|
+
for (let s = firstShard; s <= lastShard; s++) {
|
|
408
|
+
if (!this._shardCache.has(s)) {
|
|
409
|
+
throw new RangeReaderError('R_TRUNCATED',
|
|
410
|
+
'syncRange requires shard ' + s + ' to be prefetched (call prefetchRange first)');
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
const stride = this._schema.rowStride;
|
|
414
|
+
const fields = this._schema.fields;
|
|
415
|
+
const self = this;
|
|
416
|
+
return {
|
|
417
|
+
get(rowIdx, fieldName) {
|
|
418
|
+
const fieldIdx = self._fieldIndex.get(fieldName);
|
|
419
|
+
if (fieldIdx === undefined) throw new RangeReaderError('R_UNKNOWN_FIELD', fieldName);
|
|
420
|
+
const field = fields[fieldIdx];
|
|
421
|
+
const shardIdx = self._findShardIndex(rowIdx);
|
|
422
|
+
const shard = self._shardCache.get(shardIdx);
|
|
423
|
+
const localRow = rowIdx - shard.firstRow;
|
|
424
|
+
const rowOff = localRow * stride;
|
|
425
|
+
if (field.laneKind === LANE_F64) {
|
|
426
|
+
return shard.payloadDv.getFloat64(rowOff + field.offsetInRow, true);
|
|
427
|
+
}
|
|
428
|
+
const strIdx = shard.payloadDv.getUint32(rowOff + field.offsetInRow, true);
|
|
429
|
+
return shard.stringTable ? shard.stringTable.get(strIdx) : undefined;
|
|
430
|
+
},
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
get cachedShardCount() { return this._shardCache.size; }
|
|
435
|
+
|
|
436
|
+
// Whether the container carries a zone maps segment (fetched once at open).
|
|
437
|
+
get hasZoneMaps() { return this._zoneMapsTrackedFields !== null; }
|
|
438
|
+
|
|
439
|
+
// {min, max} for the given field within the given shard, or null if
|
|
440
|
+
// the container has no zone maps or the field is not tracked. Synchronous
|
|
441
|
+
// -- zone maps were fetched during open().
|
|
442
|
+
shardBounds(shardIdx, fieldName) {
|
|
443
|
+
if (!this._zoneMapsTrackedFields) return null;
|
|
444
|
+
const t = this._zoneMapsFieldToPos.get(fieldName);
|
|
445
|
+
if (t === undefined) return null;
|
|
446
|
+
if (shardIdx < 0 || shardIdx >= this._shardCount) return null;
|
|
447
|
+
const pos = shardIdx * this._zoneMapsT + t;
|
|
448
|
+
return { min: this._zoneMapsMins[pos], max: this._zoneMapsMaxes[pos] };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Return the indices of shards whose bounds overlap [min, max]. Synchronous.
|
|
452
|
+
// When no zone maps are present or the field is not tracked, returns every
|
|
453
|
+
// shard (query planner must fall back to full scan).
|
|
454
|
+
findShards(fieldName, opts) {
|
|
455
|
+
const all = () => { const a = new Array(this._shardCount); for (let i = 0; i < this._shardCount; i++) a[i] = i; return a; };
|
|
456
|
+
if (!this._zoneMapsTrackedFields) return all();
|
|
457
|
+
const t = this._zoneMapsFieldToPos.get(fieldName);
|
|
458
|
+
if (t === undefined) return all();
|
|
459
|
+
const min = (opts && typeof opts.min === 'number') ? opts.min : Number.NEGATIVE_INFINITY;
|
|
460
|
+
const max = (opts && typeof opts.max === 'number') ? opts.max : Number.POSITIVE_INFINITY;
|
|
461
|
+
const T = this._zoneMapsT;
|
|
462
|
+
const out = [];
|
|
463
|
+
for (let s = 0; s < this._shardCount; s++) {
|
|
464
|
+
const smin = this._zoneMapsMins[s * T + t];
|
|
465
|
+
const smax = this._zoneMapsMaxes[s * T + t];
|
|
466
|
+
if (smin <= max && smax >= min) out.push(s);
|
|
467
|
+
}
|
|
468
|
+
return out;
|
|
469
|
+
}
|
|
470
|
+
}
|