@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/package.json ADDED
@@ -0,0 +1,117 @@
1
+ {
2
+ "name": "@zakkster/lite-bake-stream",
3
+ "version": "1.0.0",
4
+ "description": "Streaming byte-level JSON to lite-bake binary compiler. Zero-GC, tree-shakeable, gigabyte-scale.",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "exports": {
8
+ ".": {
9
+ "types": "./types/index.d.ts",
10
+ "import": "./src/index.js",
11
+ "default": "./src/index.js"
12
+ },
13
+ "./tokenizer": {
14
+ "types": "./types/Tokenizer.d.ts",
15
+ "import": "./src/Tokenizer.js",
16
+ "default": "./src/Tokenizer.js"
17
+ },
18
+ "./writer": {
19
+ "types": "./types/Writer.d.ts",
20
+ "import": "./src/Writer.js",
21
+ "default": "./src/Writer.js"
22
+ },
23
+ "./reader": {
24
+ "types": "./types/Reader.d.ts",
25
+ "import": "./src/Reader.js",
26
+ "default": "./src/Reader.js"
27
+ },
28
+ "./string-table": {
29
+ "types": "./types/StringTable.d.ts",
30
+ "import": "./src/StringTable.js",
31
+ "default": "./src/StringTable.js"
32
+ },
33
+ "./file-ingest": {
34
+ "types": "./types/FileIngest.d.ts",
35
+ "import": "./src/FileIngest.js",
36
+ "default": "./src/FileIngest.js"
37
+ },
38
+ "./range-reader": {
39
+ "types": "./types/RangeReader.d.ts",
40
+ "import": "./src/RangeReader.js",
41
+ "default": "./src/RangeReader.js"
42
+ },
43
+ "./multi-reader": {
44
+ "types": "./types/MultiReader.d.ts",
45
+ "import": "./src/MultiReader.js",
46
+ "default": "./src/MultiReader.js"
47
+ },
48
+ "./split": {
49
+ "types": "./types/Split.d.ts",
50
+ "import": "./src/Split.js",
51
+ "default": "./src/Split.js"
52
+ },
53
+ "./preserve-tokenizer": {
54
+ "types": "./types/PreserveTokenizer.d.ts",
55
+ "import": "./src/PreserveTokenizer.js",
56
+ "default": "./src/PreserveTokenizer.js"
57
+ },
58
+ "./preserve-writer": {
59
+ "types": "./types/PreserveWriter.d.ts",
60
+ "import": "./src/PreserveWriter.js",
61
+ "default": "./src/PreserveWriter.js"
62
+ },
63
+ "./preserve-reader": {
64
+ "types": "./types/PreserveReader.d.ts",
65
+ "import": "./src/PreserveReader.js",
66
+ "default": "./src/PreserveReader.js"
67
+ }
68
+ },
69
+ "files": [
70
+ "src",
71
+ "types",
72
+ "SPEC.md",
73
+ "CHANGELOG.md",
74
+ "llms.txt",
75
+ "README.md",
76
+ "LICENSE"
77
+ ],
78
+ "scripts": {
79
+ "test": "node --test test/Tokenizer.test.js test/RoundTrip.test.js test/StringRoundTrip.test.js test/DataPreservation.test.js test/NumericBoundary.test.js test/RangeReader.test.js test/FileIngest.test.js test/ZoneMaps.test.js test/M4.test.js test/M6.test.js test/Conformance.test.js test/Fuzz.test.js test/OffsetOverflow.test.js test/Preserve.test.js",
80
+ "bench": "node --expose-gc bench/bench-tokenizer.js",
81
+ "torture": "node --expose-gc bench/torture.js --tier=fast",
82
+ "torture:full": "node --expose-gc bench/torture.js --tier=full",
83
+ "soak": "node --expose-gc bench/soak.js --mb=100 --verify",
84
+ "soak:500": "node --expose-gc bench/soak.js --mb=500 --verify",
85
+ "soak:1gb": "node --expose-gc bench/soak.js --gb=1 --verify",
86
+ "soak:overnight": "node --expose-gc bench/soak.js --gb=8 --verify",
87
+ "demo": "npx serve ."
88
+ },
89
+ "keywords": [
90
+ "json",
91
+ "streaming",
92
+ "sax",
93
+ "zero-gc",
94
+ "binary",
95
+ "lite-bake",
96
+ "ingestion"
97
+ ],
98
+ "author": "Zahary Shinikchiev",
99
+ "license": "MIT",
100
+ "repository": {
101
+ "type": "git",
102
+ "url": "git+https://github.com/PeshoVurtoleta/lite-bake-stream.git"
103
+ },
104
+ "bugs": {
105
+ "url": "https://github.com/PeshoVurtoleta/lite-bake-stream/issues"
106
+ },
107
+ "homepage": "https://github.com/PeshoVurtoleta/lite-bake-stream#readme",
108
+ "engines": {
109
+ "node": ">=18"
110
+ },
111
+ "publishConfig": {
112
+ "access": "public"
113
+ },
114
+ "devDependencies": {
115
+ "@zakkster/lite-gc-profiler": "^1.0.0"
116
+ }
117
+ }
@@ -0,0 +1,104 @@
1
+ // @zakkster/lite-bake-stream / FileIngest
2
+ // Browser-side helper: File.stream() -> Tokenizer -> Writer -> Reader.
3
+ // Copyright (c) 2026 Zahary Shinikchiev. MIT.
4
+ //
5
+ // The natural browser ingest path is:
6
+ //
7
+ // const file = fileInputEl.files[0];
8
+ // const reader = await ingestStream(file.stream(), file.size, {
9
+ // framing: 'ndjson',
10
+ // onProgress: ({bytesIngested, totalBytes, rowsWritten, shardsCommitted}) => {...}
11
+ // });
12
+ // // reader is a fully-materialized in-memory LBK1 Reader.
13
+ //
14
+ // The helper is stream-agnostic (works with any ReadableStream<Uint8Array>),
15
+ // so it's usable for fetch() bodies, WebSocket-fed inputs, and Node's
16
+ // ReadableStream too. Progress fires every chunk plus a final "done" event
17
+ // with the completed Reader.
18
+ //
19
+ // For containers larger than the browser's ArrayBuffer capacity (~2 GB in
20
+ // most engines), use server-side pre-compilation and RangeReader instead.
21
+
22
+ import { Tokenizer } from './Tokenizer.js';
23
+ import { Writer } from './Writer.js';
24
+ import { Reader } from './Reader.js';
25
+ import { PreserveTokenizer } from './PreserveTokenizer.js';
26
+ import { PreserveWriter } from './PreserveWriter.js';
27
+ import { PreserveReader } from './PreserveReader.js';
28
+
29
+ export const VERSION = '1.0.0';
30
+
31
+ // Ingest a ReadableStream<Uint8Array> and return a Reader over the resulting
32
+ // LBK1 container. Options:
33
+ // preserve: when true, use preserve mode (opaque byte blobs, any
34
+ // JSON shape allowed). Default false = schema mode.
35
+ // framing: 'ndjson' | 'array' | 'auto' (default 'ndjson').
36
+ // Preserve mode is NDJSON-only.
37
+ // writer: Writer options. Schema: { schema, targetShardBytes,
38
+ // sampleBytes }. Preserve: { targetShardBytes, maxRecordBytes }.
39
+ // onProgress(state): called after each chunk with { bytesIngested,
40
+ // totalBytes, rowsWritten, shardsCommitted, chunkBytes,
41
+ // chunkCount, elapsedMs }
42
+ // totalBytes: optional total for progress percentage. Pass file.size
43
+ // when ingesting a File; omit for indeterminate streams.
44
+ export async function ingestStream(readableStream, opts) {
45
+ opts = opts || {};
46
+ const preserve = opts.preserve === true;
47
+ const framing = opts.framing || 'ndjson';
48
+ const writerOpts = opts.writer || {};
49
+ const onProgress = typeof opts.onProgress === 'function' ? opts.onProgress : null;
50
+ const totalBytes = typeof opts.totalBytes === 'number' ? opts.totalBytes : -1;
51
+
52
+ const writer = preserve ? new PreserveWriter(writerOpts) : new Writer(writerOpts);
53
+ const tokenizer = preserve
54
+ ? new PreserveTokenizer(writer, { framing, maxRecordBytes: writerOpts.maxRecordBytes || 0 })
55
+ : new Tokenizer(writer, { framing });
56
+
57
+ const reader = readableStream.getReader();
58
+ const t0 = (globalThis.performance && performance.now) ? performance.now() : Date.now();
59
+ let bytesIngested = 0;
60
+ let chunkCount = 0;
61
+
62
+ try {
63
+ while (true) {
64
+ const { value, done } = await reader.read();
65
+ if (done) break;
66
+ if (!(value instanceof Uint8Array)) {
67
+ throw new TypeError('ingestStream: expected Uint8Array chunks, got ' + typeof value);
68
+ }
69
+ tokenizer.feed(value);
70
+ bytesIngested += value.length;
71
+ chunkCount++;
72
+ if (onProgress) {
73
+ const now = (globalThis.performance && performance.now) ? performance.now() : Date.now();
74
+ onProgress({
75
+ bytesIngested, totalBytes,
76
+ rowsWritten: writer.totalRows,
77
+ shardsCommitted: writer.shardCount,
78
+ chunkBytes: value.length,
79
+ chunkCount,
80
+ elapsedMs: now - t0,
81
+ });
82
+ }
83
+ }
84
+ } finally {
85
+ reader.releaseLock();
86
+ }
87
+
88
+ tokenizer.end();
89
+ const { buffer } = writer.finalize();
90
+ return preserve ? new PreserveReader(buffer) : new Reader(buffer);
91
+ }
92
+
93
+ // Convenience for File / Blob inputs -- pulls the stream and size from the
94
+ // blob. Rejects if the runtime doesn't support Blob.stream().
95
+ export async function ingestFile(file, opts) {
96
+ if (!file || typeof file.stream !== 'function') {
97
+ throw new TypeError('ingestFile: expected a Blob/File with .stream()');
98
+ }
99
+ const merged = Object.assign({}, opts || {});
100
+ if (typeof file.size === 'number' && merged.totalBytes === undefined) {
101
+ merged.totalBytes = file.size;
102
+ }
103
+ return ingestStream(file.stream(), merged);
104
+ }
@@ -0,0 +1,160 @@
1
+ // @zakkster/lite-bake-stream / MultiReader
2
+ // Union view over N LBK1 Readers as one logical stream.
3
+ // Copyright (c) 2026 Zahary Shinikchiev. MIT.
4
+ //
5
+ // Motivation:
6
+ // - Worker-parallel compilation produces one container per worker; a query
7
+ // layer wants to see them as a single dataset.
8
+ // - Checkpoint/resume ingest: append a new container per session, browse
9
+ // the whole history.
10
+ // - Split-and-merge (M6): the merge step is optional if a MultiReader can
11
+ // serve queries directly over N separate LBK1 containers.
12
+ //
13
+ // Requirements:
14
+ // - Schemas MUST match across all readers (field names, lane kinds, order).
15
+ // Enforced at construction; mismatches raise M_SCHEMA_MISMATCH.
16
+ // - Row indices are cumulative in reader order: rows 0..N_0 are in reader
17
+ // 0, rows N_0..N_0+N_1 in reader 1, etc.
18
+ // - Zone maps merge naturally: findShards returns global shard indices
19
+ // spanning all sub-readers.
20
+ //
21
+ // Error codes:
22
+ // M_EMPTY - no readers passed
23
+ // M_SCHEMA_MISMATCH - schemas differ across readers
24
+ // M_UNKNOWN_FIELD - .get() / .fieldIndex() called with unknown name
25
+ // M_ROW_OUT_OF_RANGE - rowIdx >= totalRows
26
+
27
+ export const VERSION = '1.0.0';
28
+
29
+ export class MultiReaderError extends Error {
30
+ constructor(code, msg) { super(msg); this.code = code; this.name = 'MultiReaderError'; }
31
+ }
32
+
33
+ function schemasEqual(a, b) {
34
+ if (a.fields.length !== b.fields.length) return false;
35
+ if (a.rowStride !== b.rowStride) return false;
36
+ for (let i = 0; i < a.fields.length; i++) {
37
+ const fa = a.fields[i], fb = b.fields[i];
38
+ if (fa.name !== fb.name) return false;
39
+ if (fa.laneKind !== fb.laneKind) return false;
40
+ if (fa.offsetInRow !== fb.offsetInRow) return false;
41
+ }
42
+ return true;
43
+ }
44
+
45
+ export class MultiReader {
46
+ constructor(readers) {
47
+ if (!Array.isArray(readers) || readers.length === 0) {
48
+ throw new MultiReaderError('M_EMPTY', 'MultiReader requires a non-empty array of Readers');
49
+ }
50
+ this._readers = readers.slice();
51
+ // All readers must share the same schema.
52
+ const schema0 = readers[0].schema;
53
+ for (let i = 1; i < readers.length; i++) {
54
+ if (!schemasEqual(schema0, readers[i].schema)) {
55
+ throw new MultiReaderError('M_SCHEMA_MISMATCH',
56
+ 'reader ' + i + ' schema differs from reader 0');
57
+ }
58
+ }
59
+ this._schema = schema0;
60
+
61
+ // Cumulative row offsets and shard offsets. Row rowIdx belongs to reader
62
+ // r iff _rowOffsets[r] <= rowIdx < _rowOffsets[r+1].
63
+ let cumRows = 0, cumShards = 0;
64
+ this._rowOffsets = new Uint32Array(readers.length + 1);
65
+ this._shardOffsets = new Uint32Array(readers.length + 1);
66
+ for (let i = 0; i < readers.length; i++) {
67
+ this._rowOffsets[i] = cumRows;
68
+ this._shardOffsets[i] = cumShards;
69
+ cumRows += readers[i].totalRows;
70
+ cumShards += readers[i].shardCount;
71
+ }
72
+ this._rowOffsets[readers.length] = cumRows;
73
+ this._shardOffsets[readers.length] = cumShards;
74
+ this._totalRows = cumRows;
75
+ this._shardCount = cumShards;
76
+
77
+ // Field-name -> index lookup
78
+ this._fieldIndex = new Map();
79
+ for (let i = 0; i < this._schema.fields.length; i++) {
80
+ this._fieldIndex.set(this._schema.fields[i].name, i);
81
+ }
82
+ }
83
+
84
+ get schema() { return this._schema; }
85
+ get totalRows() { return this._totalRows; }
86
+ get shardCount() { return this._shardCount; }
87
+ get readerCount() { return this._readers.length; }
88
+
89
+ // Which reader contains global row rowIdx? Returns { readerIdx, localRow }.
90
+ _locateRow(rowIdx) {
91
+ if (rowIdx < 0 || rowIdx >= this._totalRows) {
92
+ throw new MultiReaderError('M_ROW_OUT_OF_RANGE',
93
+ 'rowIdx ' + rowIdx + ' out of range [0, ' + this._totalRows + ')');
94
+ }
95
+ // Linear-scan the cumulative offsets. Reader counts are small (usually
96
+ // <= a few dozen); binary search's overhead isn't worth it.
97
+ for (let r = 0; r < this._readers.length; r++) {
98
+ if (rowIdx < this._rowOffsets[r + 1]) {
99
+ return { readerIdx: r, localRow: rowIdx - this._rowOffsets[r] };
100
+ }
101
+ }
102
+ return null; // unreachable given the range check above
103
+ }
104
+
105
+ // Which reader owns global shard globalShardIdx? Returns { readerIdx, localShard }.
106
+ _locateShard(globalShardIdx) {
107
+ for (let r = 0; r < this._readers.length; r++) {
108
+ if (globalShardIdx < this._shardOffsets[r + 1]) {
109
+ return { readerIdx: r, localShard: globalShardIdx - this._shardOffsets[r] };
110
+ }
111
+ }
112
+ return null;
113
+ }
114
+
115
+ fieldIndex(name) {
116
+ const i = this._fieldIndex.get(name);
117
+ if (i === undefined) throw new MultiReaderError('M_UNKNOWN_FIELD', 'no field named ' + JSON.stringify(name));
118
+ return i;
119
+ }
120
+
121
+ // Get value at global row rowIdx.
122
+ get(rowIdx, fieldName) {
123
+ const loc = this._locateRow(rowIdx);
124
+ return this._readers[loc.readerIdx].get(loc.localRow, fieldName);
125
+ }
126
+
127
+ // Zone-maps query: returns GLOBAL shard indices whose bounds overlap [min, max].
128
+ // For sub-readers that have zone maps, the shard-level pruning happens per
129
+ // reader; results are concatenated and re-numbered into the global shard
130
+ // index space.
131
+ findShards(fieldName, opts) {
132
+ const out = [];
133
+ for (let r = 0; r < this._readers.length; r++) {
134
+ const localShards = this._readers[r].findShards(fieldName, opts);
135
+ const shift = this._shardOffsets[r];
136
+ for (const s of localShards) out.push(shift + s);
137
+ }
138
+ return out;
139
+ }
140
+
141
+ shardBounds(globalShardIdx, fieldName) {
142
+ const loc = this._locateShard(globalShardIdx);
143
+ if (!loc) return null;
144
+ return this._readers[loc.readerIdx].shardBounds(loc.localShard, fieldName);
145
+ }
146
+
147
+ // Access the underlying sub-Reader and its local shard offset for advanced
148
+ // consumers (e.g. building a MultiRangeReader or worker RPC).
149
+ reader(readerIdx) { return this._readers[readerIdx]; }
150
+ readerForShard(globalShardIdx) { return this._locateShard(globalShardIdx); }
151
+ readerForRow(rowIdx) { return this._locateRow(rowIdx); }
152
+
153
+ get hasZoneMaps() {
154
+ // MultiReader reports zone maps only if EVERY sub-reader has them.
155
+ // Mixed availability would silently fall through to full-scan in
156
+ // findShards for the sub-readers without zone maps; better to be honest.
157
+ for (const r of this._readers) if (!r.hasZoneMaps) return false;
158
+ return true;
159
+ }
160
+ }
@@ -0,0 +1,180 @@
1
+ // @zakkster/lite-bake-stream / PreserveReader
2
+ // Copyright (c) 2026 Zahary Shinikchiev. MIT.
3
+ //
4
+ // Random-access reader for preserve-mode LBK1 containers. Same 48-byte header
5
+ // and 40-byte ShardEntry as schema mode, but interpreted differently:
6
+ // - flags byte at offset 7 has bit 0 set (validated at open)
7
+ // - schema_block_off is 0 (no schema block to parse)
8
+ // - metadata_off is 0 (no zone maps)
9
+ // - each shard payload ends with a u32 offset table sized rowCount * 4
10
+ //
11
+ // Public tri-API:
12
+ // getBytes(rowIdx) -> Uint8Array (view into container, zero-alloc)
13
+ // getString(rowIdx) -> string (allocates one string via TextDecoder)
14
+ // getJSON(rowIdx) -> parsed value (allocates via getString + JSON.parse)
15
+ //
16
+ // The Uint8Array returned by getBytes is a live subarray view of the container
17
+ // buffer. It is valid for as long as the Reader instance holds the container.
18
+ // Do NOT mutate it, and do NOT retain it past the Reader's lifetime.
19
+
20
+ export const VERSION = '1.0.0';
21
+
22
+ const CONTAINER_HEADER_BYTES = 48;
23
+ const SHARD_ENTRY_BYTES = 40;
24
+
25
+ export class PreserveReaderError extends Error {
26
+ constructor(code, msg) { super(msg); this.code = code; this.name = 'PreserveReaderError'; }
27
+ }
28
+
29
+ export class PreserveReader {
30
+ static fromBuffer(input) {
31
+ if (input instanceof ArrayBuffer) return new PreserveReader(input);
32
+ if (input instanceof Uint8Array) {
33
+ if (input.byteOffset === 0 && input.byteLength === input.buffer.byteLength) {
34
+ return new PreserveReader(input.buffer);
35
+ }
36
+ const copy = new Uint8Array(input.byteLength);
37
+ copy.set(input);
38
+ return new PreserveReader(copy.buffer);
39
+ }
40
+ throw new TypeError('PreserveReader.fromBuffer: expected ArrayBuffer or Uint8Array');
41
+ }
42
+
43
+ constructor(buffer) {
44
+ if (!(buffer instanceof ArrayBuffer)) {
45
+ throw new TypeError('PreserveReader: expected ArrayBuffer');
46
+ }
47
+ this._buffer = buffer;
48
+ this._dv = new DataView(buffer);
49
+ this._bytes = new Uint8Array(buffer);
50
+ this._decoder = new TextDecoder('utf-8', { fatal: false });
51
+ this._parseHeader();
52
+ this._parseShardDirectory();
53
+ }
54
+
55
+ _parseHeader() {
56
+ if (this._buffer.byteLength < CONTAINER_HEADER_BYTES) {
57
+ throw new PreserveReaderError('R_TRUNCATED', 'container smaller than 48-byte header');
58
+ }
59
+ const b = this._bytes;
60
+ if (b[0] !== 0x4C || b[1] !== 0x42 || b[2] !== 0x4B || b[3] !== 0x31) {
61
+ throw new PreserveReaderError('R_BAD_MAGIC', 'not an LBK1 container');
62
+ }
63
+ const version = this._dv.getUint16(4, true);
64
+ if (version > 1) throw new PreserveReaderError('R_UNSUPPORTED_VERSION', 'format_version=' + version);
65
+ const endian = b[6];
66
+ if (endian !== 1) throw new PreserveReaderError('R_UNSUPPORTED_ENDIAN', 'BE payloads not implemented in v1');
67
+ const flags = b[7];
68
+ if (!(flags & 0x01)) {
69
+ throw new PreserveReaderError('R_WRONG_MODE',
70
+ 'container is schema-mode; use Reader (from @zakkster/lite-bake-stream/reader) or deserialize() which auto-dispatches');
71
+ }
72
+ if (flags & ~0x01) {
73
+ throw new PreserveReaderError('R_BAD_FLAGS', 'unknown flag bits set: 0x' + flags.toString(16));
74
+ }
75
+
76
+ this._schemaBlockOff = Number(this._dv.getBigUint64(8, true));
77
+ this._metadataOff = Number(this._dv.getBigUint64(16, true));
78
+ this._shardDirOff = Number(this._dv.getBigUint64(24, true));
79
+ this._shardCount = this._dv.getUint32(32, true);
80
+ // 4 bytes reserved at 36
81
+ this._totalRows = Number(this._dv.getBigUint64(40, true));
82
+
83
+ if (this._schemaBlockOff !== 0) {
84
+ throw new PreserveReaderError('R_INVALID', 'preserve container has non-zero schema_block_off');
85
+ }
86
+ }
87
+
88
+ _parseShardDirectory() {
89
+ const need = this._shardCount * SHARD_ENTRY_BYTES;
90
+ if (this._shardDirOff + need > this._buffer.byteLength) {
91
+ throw new PreserveReaderError('R_TRUNCATED', 'shard directory truncated');
92
+ }
93
+ const shards = new Array(this._shardCount);
94
+ // Also compute cumulative row offsets so _locateShard can binary-search
95
+ // in O(log N) rather than linear-scanning entries with row bounds.
96
+ let cumulativeRow = 0;
97
+ for (let i = 0; i < this._shardCount; i++) {
98
+ const entryOff = this._shardDirOff + i * SHARD_ENTRY_BYTES;
99
+ const payloadOff = Number(this._dv.getBigUint64(entryOff + 0, true));
100
+ const payloadLen = this._dv.getUint32(entryOff + 8, true);
101
+ const rowCount = this._dv.getUint32(entryOff + 12, true);
102
+ const minReaderVer = this._dv.getUint16(entryOff + 16, true);
103
+ if (minReaderVer > 1) {
104
+ throw new PreserveReaderError('R_SHARD_VERSION_TOO_NEW',
105
+ 'shard ' + i + ' requires reader version ' + minReaderVer);
106
+ }
107
+ // Trailing u32 offset table.
108
+ const offsetTableOff = payloadOff + payloadLen - rowCount * 4;
109
+ if (offsetTableOff < payloadOff) {
110
+ throw new PreserveReaderError('R_INVALID',
111
+ 'shard ' + i + ' payload_len (' + payloadLen + ') too small for offset table of ' + rowCount + ' rows');
112
+ }
113
+ shards[i] = {
114
+ payloadOff,
115
+ payloadLen,
116
+ rowCount,
117
+ blobLen: offsetTableOff - payloadOff,
118
+ offsetTableOff,
119
+ firstRow: cumulativeRow,
120
+ endRow: cumulativeRow + rowCount,
121
+ };
122
+ cumulativeRow += rowCount;
123
+ }
124
+ if (cumulativeRow !== this._totalRows) {
125
+ throw new PreserveReaderError('R_INVALID',
126
+ 'shard row counts sum to ' + cumulativeRow + ' but header total_rows = ' + this._totalRows);
127
+ }
128
+ this._shards = shards;
129
+ }
130
+
131
+ get totalRows() { return this._totalRows; }
132
+ get shardCount() { return this._shardCount; }
133
+ get shards() { return this._shards; }
134
+ get buffer() { return this._buffer; }
135
+ get mode() { return 'preserve'; }
136
+ // Preserve mode has nothing to query on. hasZoneMaps=false so query planners
137
+ // fall back to full-scan gracefully -- same behavior as schema mode without
138
+ // zone maps.
139
+ get hasZoneMaps() { return false; }
140
+
141
+ _locateShard(rowIdx) {
142
+ if (rowIdx < 0 || rowIdx >= this._totalRows) {
143
+ throw new PreserveReaderError('R_ROW_OUT_OF_RANGE',
144
+ 'rowIdx ' + rowIdx + ' out of range [0, ' + this._totalRows + ')');
145
+ }
146
+ let lo = 0, hi = this._shards.length;
147
+ while (lo < hi) {
148
+ const mid = (lo + hi) >>> 1;
149
+ if (rowIdx < this._shards[mid].firstRow) hi = mid;
150
+ else if (rowIdx >= this._shards[mid].endRow) lo = mid + 1;
151
+ else return this._shards[mid];
152
+ }
153
+ return null; // unreachable given the range check
154
+ }
155
+
156
+ // Zero-alloc: returns a subarray view into the container buffer.
157
+ // Do NOT mutate. Do NOT retain past the reader's lifetime.
158
+ getBytes(rowIdx) {
159
+ const shard = this._locateShard(rowIdx);
160
+ const localRow = rowIdx - shard.firstRow;
161
+ // Read record start and end offsets relative to payload_off.
162
+ const startRel = this._dv.getUint32(shard.offsetTableOff + localRow * 4, true);
163
+ const endRel = (localRow === shard.rowCount - 1)
164
+ ? shard.blobLen
165
+ : this._dv.getUint32(shard.offsetTableOff + (localRow + 1) * 4, true);
166
+ const absStart = shard.payloadOff + startRel;
167
+ const absEnd = shard.payloadOff + endRel;
168
+ return this._bytes.subarray(absStart, absEnd);
169
+ }
170
+
171
+ // Decodes UTF-8 via TextDecoder (allocates one string).
172
+ getString(rowIdx) {
173
+ return this._decoder.decode(this.getBytes(rowIdx));
174
+ }
175
+
176
+ // Full JSON.parse (allocates the parsed value graph).
177
+ getJSON(rowIdx) {
178
+ return JSON.parse(this.getString(rowIdx));
179
+ }
180
+ }