@zakkster/lite-bake-stream 1.1.0 → 1.3.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 +58 -0
- package/README.md +1 -1
- package/llms.txt +2 -2
- package/package.json +5 -1
- package/src/FileIngest.js +25 -7
- package/src/MultiReader.js +20 -4
- package/src/Opts.js +189 -0
- package/src/PreserveReader.js +69 -13
- package/src/PreserveTokenizer.js +35 -4
- package/src/PreserveWriter.js +15 -2
- package/src/RangeReader.js +146 -16
- package/src/Reader.js +125 -25
- package/src/Split.js +40 -8
- package/src/StringTable.js +1 -1
- package/src/Tokenizer.js +68 -30
- package/src/Views.js +25 -0
- package/src/Writer.js +87 -15
- package/src/index.js +30 -24
package/src/PreserveTokenizer.js
CHANGED
|
@@ -25,12 +25,25 @@
|
|
|
25
25
|
// Errors (stable E_* codes):
|
|
26
26
|
// E_TRUNCATED - end of input reached mid-record (depth != 0 or in-string)
|
|
27
27
|
// E_RECORD_TOO_LARGE - single record exceeds maxRecordBytes (default: none)
|
|
28
|
+
// E_ENDED - feed()/end() after end()
|
|
29
|
+
// E_POISONED - feed()/end() after a thrown error
|
|
30
|
+
// E_UNKNOWN_OPTION - unknown constructor option key
|
|
31
|
+
// E_OPTION_VALUE - constructor option value out of domain
|
|
28
32
|
|
|
29
|
-
|
|
33
|
+
import { checkOpts } from './Opts.js';
|
|
34
|
+
|
|
35
|
+
export const VERSION = '1.3.0';
|
|
36
|
+
|
|
37
|
+
const U32_MAX = 4294967295;
|
|
38
|
+
const PRESERVE_TOKENIZER_OPTS = {
|
|
39
|
+
framing: { t: 'enum', values: ['ndjson', 'array', 'auto'] },
|
|
40
|
+
maxRecordBytes: { t: 'int', min: 0, max: U32_MAX },
|
|
41
|
+
};
|
|
30
42
|
|
|
31
43
|
export class PreserveTokenizerError extends Error {
|
|
32
44
|
constructor(code, msg) { super(msg); this.code = code; this.name = 'PreserveTokenizerError'; }
|
|
33
45
|
}
|
|
46
|
+
function raisePreserveTok(code, msg) { throw new PreserveTokenizerError(code, msg); }
|
|
34
47
|
|
|
35
48
|
// Byte values used by the scanner (named so the hot loop reads cleanly).
|
|
36
49
|
const BYTE_LF = 0x0A;
|
|
@@ -51,14 +64,18 @@ export class PreserveTokenizer {
|
|
|
51
64
|
if (!sink || typeof sink.onRecord !== 'function') {
|
|
52
65
|
throw new TypeError('PreserveTokenizer: sink must implement onRecord(bytes, from, to)');
|
|
53
66
|
}
|
|
67
|
+
checkOpts('PreserveTokenizer', opts, PRESERVE_TOKENIZER_OPTS, raisePreserveTok);
|
|
54
68
|
opts = opts || {};
|
|
55
|
-
if (opts.framing && opts.framing !== 'ndjson') {
|
|
69
|
+
if (opts.framing !== undefined && opts.framing !== 'ndjson') {
|
|
56
70
|
// 'array' framing (top-level `[a, b, c]`) is a future addition.
|
|
57
71
|
throw new PreserveTokenizerError('E_UNSUPPORTED_FRAMING',
|
|
58
72
|
'preserve-mode currently supports NDJSON only; got framing=' + opts.framing);
|
|
59
73
|
}
|
|
60
74
|
this._sink = sink;
|
|
61
|
-
this._maxRecordBytes =
|
|
75
|
+
this._maxRecordBytes = opts.maxRecordBytes !== undefined ? opts.maxRecordBytes : 0; // 0 = unlimited
|
|
76
|
+
// Terminal state (BS-14): once ended or poisoned, the instance is dead.
|
|
77
|
+
this._ended = false;
|
|
78
|
+
this._poisoned = false;
|
|
62
79
|
// Accumulator: bytes seen since the last emitted record's terminating LF.
|
|
63
80
|
this._buf = new Uint8Array(INITIAL_BUF);
|
|
64
81
|
this._bufLen = 0;
|
|
@@ -71,22 +88,32 @@ export class PreserveTokenizer {
|
|
|
71
88
|
}
|
|
72
89
|
|
|
73
90
|
feed(chunk) {
|
|
91
|
+
if (this._poisoned) throw new PreserveTokenizerError('E_POISONED', 'tokenizer poisoned by a previous error or an in-flight feed(); construct a new instance');
|
|
92
|
+
if (this._ended) throw new PreserveTokenizerError('E_ENDED', 'tokenizer already ended; construct a new instance');
|
|
74
93
|
if (!(chunk instanceof Uint8Array)) {
|
|
75
94
|
throw new TypeError('PreserveTokenizer.feed: expected Uint8Array');
|
|
76
95
|
}
|
|
77
|
-
|
|
96
|
+
// D3 armed-flag: a sink throw inside _scan unwinds with the flag set,
|
|
97
|
+
// poisoning the instance. Cleared at every clean exit (empty chunk and end).
|
|
98
|
+
this._poisoned = true;
|
|
99
|
+
if (chunk.length === 0) { this._poisoned = false; return; }
|
|
78
100
|
this._ensureCapacity(this._bufLen + chunk.length);
|
|
79
101
|
this._buf.set(chunk, this._bufLen);
|
|
80
102
|
const scanStart = this._bufLen;
|
|
81
103
|
this._bufLen += chunk.length;
|
|
82
104
|
this._scan(scanStart);
|
|
83
105
|
this._compact();
|
|
106
|
+
this._poisoned = false;
|
|
84
107
|
}
|
|
85
108
|
|
|
86
109
|
end() {
|
|
110
|
+
if (this._poisoned) throw new PreserveTokenizerError('E_POISONED', 'tokenizer poisoned by a previous error or an in-flight feed(); construct a new instance');
|
|
111
|
+
if (this._ended) throw new PreserveTokenizerError('E_ENDED', 'tokenizer already ended; construct a new instance');
|
|
112
|
+
this._poisoned = true; // D3: armed until the clean exit below
|
|
87
113
|
if (this._bufLen > this._recordStart) {
|
|
88
114
|
// There's pending content that never terminated.
|
|
89
115
|
if (this._depth !== 0 || this._inString || this._escape) {
|
|
116
|
+
this._poisoned = true;
|
|
90
117
|
throw new PreserveTokenizerError('E_TRUNCATED',
|
|
91
118
|
'input ended mid-record at byte ' + (this._absOffset + this._bufLen) +
|
|
92
119
|
' (depth=' + this._depth + ', inString=' + this._inString + ')');
|
|
@@ -96,6 +123,8 @@ export class PreserveTokenizer {
|
|
|
96
123
|
this._emitRecord(this._recordStart, this._bufLen);
|
|
97
124
|
this._recordStart = this._bufLen;
|
|
98
125
|
}
|
|
126
|
+
this._ended = true;
|
|
127
|
+
this._poisoned = false; // clean end; a throwing onRecord above stays poisoned
|
|
99
128
|
}
|
|
100
129
|
|
|
101
130
|
_scan(from) {
|
|
@@ -119,6 +148,7 @@ export class PreserveTokenizer {
|
|
|
119
148
|
depth--;
|
|
120
149
|
if (depth < 0) {
|
|
121
150
|
this._depth = depth; this._inString = inString; this._escape = escape;
|
|
151
|
+
this._poisoned = true;
|
|
122
152
|
throw new PreserveTokenizerError('E_UNBALANCED',
|
|
123
153
|
'closing bracket without matching open at byte ' + (this._absOffset + i));
|
|
124
154
|
}
|
|
@@ -130,6 +160,7 @@ export class PreserveTokenizer {
|
|
|
130
160
|
}
|
|
131
161
|
}
|
|
132
162
|
if (this._maxRecordBytes > 0 && (i - this._recordStart) > this._maxRecordBytes) {
|
|
163
|
+
this._poisoned = true;
|
|
133
164
|
throw new PreserveTokenizerError('E_RECORD_TOO_LARGE',
|
|
134
165
|
'record starting at byte ' + (this._absOffset + this._recordStart) +
|
|
135
166
|
' exceeded maxRecordBytes=' + this._maxRecordBytes);
|
package/src/PreserveWriter.js
CHANGED
|
@@ -16,22 +16,35 @@
|
|
|
16
16
|
// Reader dispatch happens via the flag bit; a schema-mode Reader refuses this
|
|
17
17
|
// container with R_WRONG_MODE.
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
import { checkOpts } from './Opts.js';
|
|
20
|
+
|
|
21
|
+
export const VERSION = '1.3.0';
|
|
20
22
|
|
|
21
23
|
export class PreserveWriterError extends Error {
|
|
22
24
|
constructor(code, msg) { super(msg); this.code = code; this.name = 'PreserveWriterError'; }
|
|
23
25
|
}
|
|
26
|
+
function raisePreserveWriter(code, msg) { throw new PreserveWriterError(code, msg); }
|
|
24
27
|
|
|
25
28
|
const CONTAINER_HEADER_BYTES = 48;
|
|
26
29
|
const SHARD_ENTRY_BYTES = 40;
|
|
27
30
|
const FOOTER_BYTES = 16;
|
|
28
31
|
const DEFAULT_TARGET_SHARD_BYTES = 8 * 1024 * 1024; // 8 MiB — smaller than schema mode
|
|
29
32
|
const INITIAL_OFFSETS_CAP = 4096;
|
|
33
|
+
const U32_MAX = 4294967295;
|
|
34
|
+
// maxRecordBytes is a declared, validated, deliberately-unused key here: the
|
|
35
|
+
// documented call serialize(x, {preserve:true, writer:{maxRecordBytes:N}}) reads
|
|
36
|
+
// it out of opts.writer and hands it to PreserveTokenizer, which consumes it.
|
|
37
|
+
// PreserveWriter accepts it so that call shape does not trip E_UNKNOWN_OPTION.
|
|
38
|
+
const PRESERVE_WRITER_OPTS = {
|
|
39
|
+
targetShardBytes: { t: 'int', min: 1, max: U32_MAX },
|
|
40
|
+
maxRecordBytes: { t: 'int', min: 0, max: U32_MAX },
|
|
41
|
+
};
|
|
30
42
|
|
|
31
43
|
export class PreserveWriter {
|
|
32
44
|
constructor(opts) {
|
|
45
|
+
checkOpts('PreserveWriter', opts, PRESERVE_WRITER_OPTS, raisePreserveWriter);
|
|
33
46
|
opts = opts || {};
|
|
34
|
-
this._targetShardBytes = opts.targetShardBytes
|
|
47
|
+
this._targetShardBytes = opts.targetShardBytes !== undefined ? opts.targetShardBytes : DEFAULT_TARGET_SHARD_BYTES;
|
|
35
48
|
this._shards = [];
|
|
36
49
|
this._totalRows = 0;
|
|
37
50
|
this._finalized = false;
|
package/src/RangeReader.js
CHANGED
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
// table offsets exist precisely so a client can fetch only what it needs.
|
|
10
10
|
//
|
|
11
11
|
// RangeReader implements that story:
|
|
12
|
-
// 1. On open, fetch the header (
|
|
13
|
-
// That gives us the whole schema and every shard's byte extents
|
|
14
|
-
// touching a single row.
|
|
12
|
+
// 1. On open, fetch the header (48 bytes) + footer + schema block + shard
|
|
13
|
+
// directory. That gives us the whole schema and every shard's byte extents
|
|
14
|
+
// without touching a single row.
|
|
15
15
|
// 2. On get(rowIdx, fieldName), locate the shard containing rowIdx, fetch
|
|
16
16
|
// its payload + local string table in ONE range request (they are
|
|
17
17
|
// contiguous by SPEC 3.4), cache it, decode.
|
|
@@ -32,10 +32,17 @@
|
|
|
32
32
|
// R_UNSUPPORTED_LANE / R_SHARD_VERSION_TOO_NEW / R_TRUNCATED / R_UNKNOWN_FIELD
|
|
33
33
|
// R_ADAPTER_SHORT_READ -- adapter returned fewer bytes than requested
|
|
34
34
|
// R_ROW_OUT_OF_RANGE -- rowIdx >= totalRows
|
|
35
|
+
// R_WRONG_MODE -- preserve-mode container fed to the schema RangeReader
|
|
36
|
+
// R_BAD_FLAGS -- unknown header flag bits set
|
|
37
|
+
// R_RESERVED_NONZERO -- a reserved header/descriptor/shard field is non-zero
|
|
38
|
+
// R_BAD_FOOTER -- footer magic_end or footer_len is malformed
|
|
39
|
+
// R_BAD_METADATA -- metadata_off non-zero but the zone-map segment is unparseable
|
|
40
|
+
// R_INVALID -- a structure is internally inconsistent but in-bounds
|
|
35
41
|
|
|
36
42
|
import { StringTable } from './StringTable.js';
|
|
43
|
+
import { checkOpts } from './Opts.js';
|
|
37
44
|
|
|
38
|
-
export const VERSION = '1.
|
|
45
|
+
export const VERSION = '1.3.0';
|
|
39
46
|
|
|
40
47
|
const CONTAINER_HEADER_BYTES = 48;
|
|
41
48
|
const SHARD_ENTRY_BYTES = 40;
|
|
@@ -44,10 +51,44 @@ const FIELD_DESCRIPTOR_BYTES = 24;
|
|
|
44
51
|
const LANE_F64 = 1;
|
|
45
52
|
const LANE_U32 = 3;
|
|
46
53
|
const READER_VERSION = 1;
|
|
54
|
+
const U32_MAX = 4294967295;
|
|
55
|
+
|
|
56
|
+
const RANGE_READER_OPTS = { maxCachedShards: { t: 'int', min: 0, max: U32_MAX } };
|
|
57
|
+
const HTTP_ADAPTER_OPTS = { fetch: { t: 'fn' } };
|
|
58
|
+
|
|
59
|
+
const CONTAINER_FOOTER_BYTES = 16;
|
|
60
|
+
|
|
61
|
+
function laneBytesOf(k) { return k === LANE_F64 ? 8 : (k === LANE_U32 ? 4 : 0); }
|
|
47
62
|
|
|
48
63
|
export class RangeReaderError extends Error {
|
|
49
64
|
constructor(code, msg) { super(msg); this.code = code; this.name = 'RangeReaderError'; }
|
|
50
65
|
}
|
|
66
|
+
function raiseRange(code, msg) { throw new RangeReaderError(code, msg); }
|
|
67
|
+
|
|
68
|
+
// Validate a local string table's shape before StringTable.parse casts a
|
|
69
|
+
// Uint32Array over it (T-1..T-4). `bytes` is the already-fetched shard slice;
|
|
70
|
+
// `off` and `len` locate the table within it.
|
|
71
|
+
function validateStringTable(bytes, off, len, label) {
|
|
72
|
+
if (len < 8)
|
|
73
|
+
throw new RangeReaderError('R_TRUNCATED', label + ' string table shorter than its 8-byte header');
|
|
74
|
+
const dv = new DataView(bytes.buffer, bytes.byteOffset + off, len);
|
|
75
|
+
const entryCount = dv.getUint32(0, true);
|
|
76
|
+
const blobLen = dv.getUint32(4, true);
|
|
77
|
+
if (8 + (entryCount + 1) * 4 + blobLen > len)
|
|
78
|
+
throw new RangeReaderError('R_TRUNCATED', label + ' string table claims ' + entryCount +
|
|
79
|
+
' entries + ' + blobLen + ' blob bytes past its ' + len + '-byte extent');
|
|
80
|
+
let prev = dv.getUint32(8, true);
|
|
81
|
+
if (prev !== 0)
|
|
82
|
+
throw new RangeReaderError('R_INVALID', label + ' string table offsets[0] is ' + prev + ', must be 0');
|
|
83
|
+
for (let i = 1; i <= entryCount; i++) {
|
|
84
|
+
const cur = dv.getUint32(8 + i * 4, true);
|
|
85
|
+
if (cur < prev)
|
|
86
|
+
throw new RangeReaderError('R_INVALID', label + ' string table offsets not monotonic at index ' + i);
|
|
87
|
+
prev = cur;
|
|
88
|
+
}
|
|
89
|
+
if (prev !== blobLen)
|
|
90
|
+
throw new RangeReaderError('R_INVALID', label + ' string table sentinel ' + prev + ' != blob_length ' + blobLen);
|
|
91
|
+
}
|
|
51
92
|
|
|
52
93
|
// ----- Adapters --------------------------------------------------------------
|
|
53
94
|
|
|
@@ -56,8 +97,9 @@ export class RangeReaderError extends Error {
|
|
|
56
97
|
// origin/CDN).
|
|
57
98
|
export class HTTPRangeAdapter {
|
|
58
99
|
static async open(url, opts) {
|
|
100
|
+
checkOpts('HTTPRangeAdapter', opts, HTTP_ADAPTER_OPTS, raiseRange);
|
|
59
101
|
opts = opts || {};
|
|
60
|
-
const fetchImpl = opts.fetch
|
|
102
|
+
const fetchImpl = opts.fetch !== undefined ? opts.fetch : globalThis.fetch;
|
|
61
103
|
if (typeof fetchImpl !== 'function') {
|
|
62
104
|
throw new RangeReaderError('R_TRUNCATED', 'no fetch() available in this environment');
|
|
63
105
|
}
|
|
@@ -148,9 +190,10 @@ export class RangeReader {
|
|
|
148
190
|
}
|
|
149
191
|
|
|
150
192
|
constructor(adapter, opts) {
|
|
193
|
+
checkOpts('RangeReader', opts, RANGE_READER_OPTS, raiseRange);
|
|
151
194
|
opts = opts || {};
|
|
152
195
|
this.adapter = adapter;
|
|
153
|
-
this.maxCachedShards = opts.maxCachedShards
|
|
196
|
+
this.maxCachedShards = opts.maxCachedShards !== undefined ? opts.maxCachedShards : 8;
|
|
154
197
|
// shard cache: shardIdx -> { payloadBytes, payloadDv, stringTable, lastAccess }
|
|
155
198
|
this._shardCache = new Map();
|
|
156
199
|
this._accessCounter = 0;
|
|
@@ -161,7 +204,7 @@ export class RangeReader {
|
|
|
161
204
|
throw new RangeReaderError('R_TRUNCATED', 'adapter size smaller than header');
|
|
162
205
|
}
|
|
163
206
|
|
|
164
|
-
// Step 1: header (
|
|
207
|
+
// Step 1: header (48 bytes) tells us schema + shard-dir offsets.
|
|
165
208
|
const headerBytes = await this.adapter.fetch(0, CONTAINER_HEADER_BYTES);
|
|
166
209
|
const hdrDv = new DataView(headerBytes.buffer, headerBytes.byteOffset, headerBytes.byteLength);
|
|
167
210
|
if (headerBytes[0] !== 0x4C || headerBytes[1] !== 0x42 || headerBytes[2] !== 0x4B || headerBytes[3] !== 0x31) {
|
|
@@ -171,21 +214,48 @@ export class RangeReader {
|
|
|
171
214
|
if (version > 1) throw new RangeReaderError('R_UNSUPPORTED_VERSION', 'format_version=' + version);
|
|
172
215
|
const endian = headerBytes[6];
|
|
173
216
|
if (endian !== 1) throw new RangeReaderError('R_UNSUPPORTED_ENDIAN', 'BE payloads not implemented in v1 reader');
|
|
217
|
+
const flags = headerBytes[7];
|
|
218
|
+
if (flags & 0x01) {
|
|
219
|
+
throw new RangeReaderError('R_WRONG_MODE',
|
|
220
|
+
'container is preserve-mode; RangeReader is unsupported in preserve mode (SPEC 3.8) -- use PreserveReader');
|
|
221
|
+
}
|
|
222
|
+
if (flags & ~0x01) {
|
|
223
|
+
throw new RangeReaderError('R_BAD_FLAGS', 'unknown flag bits set in header byte 7: 0x' + flags.toString(16));
|
|
224
|
+
}
|
|
225
|
+
const reserved1 = hdrDv.getUint32(36, true);
|
|
226
|
+
if (reserved1 !== 0)
|
|
227
|
+
throw new RangeReaderError('R_RESERVED_NONZERO', 'header reserved1 at offset 36 must be 0, got ' + reserved1);
|
|
174
228
|
|
|
175
229
|
this._schemaBlockOff = Number(hdrDv.getBigUint64(8, true));
|
|
176
230
|
this._metadataOff = Number(hdrDv.getBigUint64(16, true));
|
|
177
231
|
this._shardDirOff = Number(hdrDv.getBigUint64(24, true));
|
|
178
232
|
this._shardCount = hdrDv.getUint32(32, true);
|
|
179
|
-
// 4 bytes reserved at 36
|
|
180
233
|
this._totalRows = Number(hdrDv.getBigUint64(40, true));
|
|
181
234
|
|
|
235
|
+
const size = this.adapter.size;
|
|
236
|
+
if (this._schemaBlockOff < CONTAINER_HEADER_BYTES || this._schemaBlockOff >= size)
|
|
237
|
+
throw new RangeReaderError('R_TRUNCATED', 'schema_block_off ' + this._schemaBlockOff + ' out of range [48, ' + size + ')');
|
|
238
|
+
if (this._shardDirOff < CONTAINER_HEADER_BYTES || this._shardDirOff >= size)
|
|
239
|
+
throw new RangeReaderError('R_TRUNCATED', 'shard_directory_off ' + this._shardDirOff + ' out of range [48, ' + size + ')');
|
|
240
|
+
if (this._metadataOff !== 0 && (this._metadataOff < CONTAINER_HEADER_BYTES || this._metadataOff >= size))
|
|
241
|
+
throw new RangeReaderError('R_TRUNCATED', 'metadata_off ' + this._metadataOff + ' out of range {0} u [48, ' + size + ')');
|
|
242
|
+
if (this._shardCount * SHARD_ENTRY_BYTES > size - CONTAINER_HEADER_BYTES)
|
|
243
|
+
throw new RangeReaderError('R_TRUNCATED', 'shard_count ' + this._shardCount + ' exceeds the bytes available for a shard directory');
|
|
244
|
+
|
|
245
|
+
// Footer: one 16-byte suffix fetch. Refuse a wrong-footer container before
|
|
246
|
+
// spending fetches on the schema. Gated on a body big enough to hold a
|
|
247
|
+
// header and a footer that do not overlap.
|
|
248
|
+
if (size >= CONTAINER_HEADER_BYTES + CONTAINER_FOOTER_BYTES) await this._loadFooter();
|
|
249
|
+
|
|
182
250
|
// Step 2: schema block. We know its start but not its length; compute
|
|
183
251
|
// upper bound: schemaBlockOff .. shardDirOff.
|
|
252
|
+
if (this._shardDirOff <= this._schemaBlockOff)
|
|
253
|
+
throw new RangeReaderError('R_TRUNCATED', 'shard_directory_off ' + this._shardDirOff + ' not after schema_block_off ' + this._schemaBlockOff);
|
|
184
254
|
const schemaBlockLen = this._shardDirOff - this._schemaBlockOff;
|
|
185
255
|
const schemaBytes = await this.adapter.fetch(this._schemaBlockOff, schemaBlockLen);
|
|
186
256
|
this._parseSchema(schemaBytes);
|
|
187
257
|
|
|
188
|
-
// Step 3: shard directory (fixed size = shardCount *
|
|
258
|
+
// Step 3: shard directory (fixed size = shardCount * 40).
|
|
189
259
|
const dirBytes = await this.adapter.fetch(this._shardDirOff, this._shardCount * SHARD_ENTRY_BYTES);
|
|
190
260
|
this._parseShardDirectory(dirBytes);
|
|
191
261
|
|
|
@@ -202,21 +272,43 @@ export class RangeReader {
|
|
|
202
272
|
}
|
|
203
273
|
}
|
|
204
274
|
|
|
275
|
+
async _loadFooter() {
|
|
276
|
+
const size = this.adapter.size;
|
|
277
|
+
const footer = await this.adapter.fetch(size - CONTAINER_FOOTER_BYTES, CONTAINER_FOOTER_BYTES);
|
|
278
|
+
if (footer[8] !== 0x31 || footer[9] !== 0x4B || footer[10] !== 0x42 || footer[11] !== 0x4C)
|
|
279
|
+
throw new RangeReaderError('R_BAD_FOOTER', 'footer magic_end is not 1KBL');
|
|
280
|
+
const fDv = new DataView(footer.buffer, footer.byteOffset, footer.byteLength);
|
|
281
|
+
const footerLen = fDv.getUint32(12, true);
|
|
282
|
+
if (footerLen < CONTAINER_FOOTER_BYTES)
|
|
283
|
+
throw new RangeReaderError('R_BAD_FOOTER', 'footer_len ' + footerLen + ' is less than the minimum ' + CONTAINER_FOOTER_BYTES);
|
|
284
|
+
if (footerLen > size - CONTAINER_HEADER_BYTES)
|
|
285
|
+
throw new RangeReaderError('R_BAD_FOOTER', 'footer_len ' + footerLen + ' exceeds the container body size');
|
|
286
|
+
}
|
|
287
|
+
|
|
205
288
|
async _loadZoneMaps() {
|
|
206
|
-
// Fetch the segment header first (16 bytes) so we can size the rest.
|
|
289
|
+
// Fetch the segment header first (16 bytes) so we can size the rest. A
|
|
290
|
+
// non-zero metadata_off asserts a parseable segment; any failure is
|
|
291
|
+
// corruption (D1), not absence.
|
|
292
|
+
if (this._metadataOff + 16 > this.adapter.size)
|
|
293
|
+
throw new RangeReaderError('R_BAD_METADATA', 'zone maps segment header runs past the container');
|
|
207
294
|
const hdr = await this.adapter.fetch(this._metadataOff, 16);
|
|
208
|
-
if (hdr[0] !== 0x30 || hdr[1] !== 0x5A || hdr[2] !== 0x4D || hdr[3] !== 0x31)
|
|
295
|
+
if (hdr[0] !== 0x30 || hdr[1] !== 0x5A || hdr[2] !== 0x4D || hdr[3] !== 0x31)
|
|
296
|
+
throw new RangeReaderError('R_BAD_METADATA', 'zone maps magic is not ZM01');
|
|
209
297
|
const hdrDv = new DataView(hdr.buffer, hdr.byteOffset, hdr.byteLength);
|
|
210
298
|
const shardCount = hdrDv.getUint32(4, true);
|
|
211
299
|
const T = hdrDv.getUint32(8, true);
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
300
|
+
const reserved0 = hdrDv.getUint32(12, true);
|
|
301
|
+
if (shardCount !== this._shardCount)
|
|
302
|
+
throw new RangeReaderError('R_BAD_METADATA', 'zone maps shard_count ' + shardCount + ' != header ' + this._shardCount);
|
|
303
|
+
if (reserved0 !== 0)
|
|
304
|
+
throw new RangeReaderError('R_BAD_METADATA', 'zone maps reserved0 must be 0, got ' + reserved0);
|
|
215
305
|
if (T === 0) return;
|
|
216
306
|
// Fetch the rest in one range: field table + pad + mins + maxes.
|
|
217
307
|
const fieldTableLen = T * 2;
|
|
218
308
|
const fieldTablePad = (8 - (fieldTableLen & 7)) & 7;
|
|
219
309
|
const restLen = fieldTableLen + fieldTablePad + shardCount * T * 8 * 2;
|
|
310
|
+
if (this._metadataOff + 16 + restLen > this.adapter.size)
|
|
311
|
+
throw new RangeReaderError('R_BAD_METADATA', 'zone maps segment runs past the container');
|
|
220
312
|
const rest = await this.adapter.fetch(this._metadataOff + 16, restLen);
|
|
221
313
|
const restDv = new DataView(rest.buffer, rest.byteOffset, rest.byteLength);
|
|
222
314
|
const tracked = new Array(T);
|
|
@@ -224,7 +316,7 @@ export class RangeReader {
|
|
|
224
316
|
for (let t = 0; t < T; t++) {
|
|
225
317
|
const schemaFieldIdx = restDv.getUint16(t * 2, true);
|
|
226
318
|
if (schemaFieldIdx >= this._schema.fields.length) {
|
|
227
|
-
throw new RangeReaderError('
|
|
319
|
+
throw new RangeReaderError('R_BAD_METADATA', 'zone maps references field index ' + schemaFieldIdx + ' past the schema');
|
|
228
320
|
}
|
|
229
321
|
tracked[t] = schemaFieldIdx;
|
|
230
322
|
fieldToPos.set(this._schema.fields[schemaFieldIdx].name, t);
|
|
@@ -242,13 +334,20 @@ export class RangeReader {
|
|
|
242
334
|
|
|
243
335
|
_parseSchema(bytes) {
|
|
244
336
|
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
337
|
+
const total = bytes.byteLength;
|
|
338
|
+
if (total < 8) throw new RangeReaderError('R_TRUNCATED', 'schema block shorter than its 8-byte header');
|
|
245
339
|
const fieldCount = dv.getUint32(0, true);
|
|
246
340
|
const rowStride = dv.getUint32(4, true);
|
|
341
|
+
if (rowStride < 1) throw new RangeReaderError('R_INVALID', 'schema row_stride is 0');
|
|
247
342
|
const descOff = 8;
|
|
248
343
|
const descBytes = fieldCount * FIELD_DESCRIPTOR_BYTES;
|
|
344
|
+
if (descOff + descBytes + 4 > total)
|
|
345
|
+
throw new RangeReaderError('R_TRUNCATED', 'schema descriptors run past the fetched schema block');
|
|
249
346
|
const nameBlobLenOff = descOff + descBytes;
|
|
250
347
|
const nameBlobLen = dv.getUint32(nameBlobLenOff, true);
|
|
251
348
|
const nameBlobOff = nameBlobLenOff + 4;
|
|
349
|
+
if (nameBlobOff + nameBlobLen > total)
|
|
350
|
+
throw new RangeReaderError('R_TRUNCATED', 'schema name blob runs past the fetched schema block');
|
|
252
351
|
const decoder = new TextDecoder('utf-8');
|
|
253
352
|
const fields = new Array(fieldCount);
|
|
254
353
|
for (let i = 0; i < fieldCount; i++) {
|
|
@@ -257,10 +356,19 @@ export class RangeReader {
|
|
|
257
356
|
const offsetInRow = dv.getUint16(off + 2, true);
|
|
258
357
|
const laneKind = bytes[off + 4];
|
|
259
358
|
const flags = bytes[off + 5];
|
|
359
|
+
const reserved2 = dv.getUint16(off + 6, true);
|
|
260
360
|
const nameStrOff = Number(dv.getBigUint64(off + 8, true));
|
|
361
|
+
const reserved3 = dv.getBigUint64(off + 16, true);
|
|
261
362
|
if (flags !== 0) throw new RangeReaderError('R_BAD_FIELD_FLAGS', 'field ' + i + ' has non-zero flags');
|
|
363
|
+
if (reserved2 !== 0) throw new RangeReaderError('R_RESERVED_NONZERO', 'field ' + i + ' reserved2 must be 0, got ' + reserved2);
|
|
364
|
+
if (reserved3 !== 0n) throw new RangeReaderError('R_RESERVED_NONZERO', 'field ' + i + ' reserved3 must be 0, got ' + reserved3);
|
|
262
365
|
if (laneKind !== LANE_F64 && laneKind !== LANE_U32)
|
|
263
366
|
throw new RangeReaderError('R_UNSUPPORTED_LANE', 'field ' + i + ' lane_kind=' + laneKind);
|
|
367
|
+
if (nameLen > 255) throw new RangeReaderError('R_INVALID', 'field ' + i + ' name_len ' + nameLen + ' exceeds 255');
|
|
368
|
+
if (offsetInRow + laneBytesOf(laneKind) > rowStride)
|
|
369
|
+
throw new RangeReaderError('R_INVALID', 'field ' + i + ' offset_in_row ' + offsetInRow + ' + lane bytes exceeds row_stride ' + rowStride);
|
|
370
|
+
if (nameStrOff + nameLen > nameBlobLen)
|
|
371
|
+
throw new RangeReaderError('R_TRUNCATED', 'field ' + i + ' name range past the name blob');
|
|
264
372
|
const nb = bytes.subarray(nameBlobOff + nameStrOff, nameBlobOff + nameStrOff + nameLen);
|
|
265
373
|
fields[i] = { name: decoder.decode(nb), laneKind, offsetInRow };
|
|
266
374
|
}
|
|
@@ -270,6 +378,8 @@ export class RangeReader {
|
|
|
270
378
|
_parseShardDirectory(bytes) {
|
|
271
379
|
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
272
380
|
const shards = new Array(this._shardCount);
|
|
381
|
+
const size = this.adapter.size;
|
|
382
|
+
const rowStride = this._schema.rowStride;
|
|
273
383
|
let cumulativeRow = 0;
|
|
274
384
|
for (let i = 0; i < this._shardCount; i++) {
|
|
275
385
|
const off = i * SHARD_ENTRY_BYTES;
|
|
@@ -277,12 +387,25 @@ export class RangeReader {
|
|
|
277
387
|
const payloadLen = dv.getUint32(off + 8, true);
|
|
278
388
|
const rowCount = dv.getUint32(off + 12, true);
|
|
279
389
|
const minReaderVer = dv.getUint16(off + 16, true);
|
|
280
|
-
|
|
390
|
+
const shardFlags = dv.getUint16(off + 18, true);
|
|
391
|
+
const shardReserved = dv.getUint32(off + 20, true);
|
|
281
392
|
const localStrOff = Number(dv.getBigUint64(off + 24, true));
|
|
282
393
|
const localStrLen = Number(dv.getBigUint64(off + 32, true));
|
|
283
394
|
if (minReaderVer > READER_VERSION)
|
|
284
395
|
throw new RangeReaderError('R_SHARD_VERSION_TOO_NEW',
|
|
285
396
|
'shard ' + i + ' requires reader version ' + minReaderVer);
|
|
397
|
+
if (shardFlags !== 0) throw new RangeReaderError('R_RESERVED_NONZERO', 'shard ' + i + ' flags must be 0, got ' + shardFlags);
|
|
398
|
+
if (shardReserved !== 0) throw new RangeReaderError('R_RESERVED_NONZERO', 'shard ' + i + ' reserved must be 0, got ' + shardReserved);
|
|
399
|
+
if (payloadOff < CONTAINER_HEADER_BYTES)
|
|
400
|
+
throw new RangeReaderError('R_INVALID', 'shard ' + i + ' payload_off ' + payloadOff + ' overlaps the header');
|
|
401
|
+
if (payloadOff + payloadLen > size)
|
|
402
|
+
throw new RangeReaderError('R_TRUNCATED', 'shard ' + i + ' payload past the container size ' + size);
|
|
403
|
+
if (rowCount * rowStride > payloadLen)
|
|
404
|
+
throw new RangeReaderError('R_INVALID', 'shard ' + i + ' row_count ' + rowCount + ' * row_stride ' + rowStride + ' exceeds payload_len ' + payloadLen);
|
|
405
|
+
if (localStrLen === 0 && localStrOff !== 0)
|
|
406
|
+
throw new RangeReaderError('R_INVALID', 'shard ' + i + ' has local_string_len 0 but local_string_off ' + localStrOff);
|
|
407
|
+
if (localStrLen > 0 && localStrOff + localStrLen > size)
|
|
408
|
+
throw new RangeReaderError('R_TRUNCATED', 'shard ' + i + ' string table past the container size ' + size);
|
|
286
409
|
shards[i] = {
|
|
287
410
|
payloadOff, payloadLen, rowCount, localStrOff, localStrLen,
|
|
288
411
|
firstRow: cumulativeRow,
|
|
@@ -290,6 +413,8 @@ export class RangeReader {
|
|
|
290
413
|
};
|
|
291
414
|
cumulativeRow += rowCount;
|
|
292
415
|
}
|
|
416
|
+
if (cumulativeRow !== this._totalRows)
|
|
417
|
+
throw new RangeReaderError('R_INVALID', 'shard row_count sum ' + cumulativeRow + ' != header total_rows ' + this._totalRows);
|
|
293
418
|
this._shards = shards;
|
|
294
419
|
}
|
|
295
420
|
|
|
@@ -346,6 +471,7 @@ export class RangeReader {
|
|
|
346
471
|
let stringTable = null;
|
|
347
472
|
if (s.localStrLen > 0) {
|
|
348
473
|
const stBytes = combined.subarray(s.payloadLen, s.payloadLen + s.localStrLen);
|
|
474
|
+
validateStringTable(stBytes, 0, s.localStrLen, 'shard ' + shardIdx);
|
|
349
475
|
stringTable = StringTable.parse(stBytes, 0);
|
|
350
476
|
}
|
|
351
477
|
const record = {
|
|
@@ -440,6 +566,8 @@ export class RangeReader {
|
|
|
440
566
|
// the container has no zone maps or the field is not tracked. Synchronous
|
|
441
567
|
// -- zone maps were fetched during open().
|
|
442
568
|
shardBounds(shardIdx, fieldName) {
|
|
569
|
+
if (!this._fieldIndex.has(fieldName))
|
|
570
|
+
throw new RangeReaderError('R_UNKNOWN_FIELD', 'no field named ' + JSON.stringify(fieldName));
|
|
443
571
|
if (!this._zoneMapsTrackedFields) return null;
|
|
444
572
|
const t = this._zoneMapsFieldToPos.get(fieldName);
|
|
445
573
|
if (t === undefined) return null;
|
|
@@ -452,6 +580,8 @@ export class RangeReader {
|
|
|
452
580
|
// When no zone maps are present or the field is not tracked, returns every
|
|
453
581
|
// shard (query planner must fall back to full scan).
|
|
454
582
|
findShards(fieldName, opts) {
|
|
583
|
+
if (!this._fieldIndex.has(fieldName))
|
|
584
|
+
throw new RangeReaderError('R_UNKNOWN_FIELD', 'no field named ' + JSON.stringify(fieldName));
|
|
455
585
|
const all = () => { const a = new Array(this._shardCount); for (let i = 0; i < this._shardCount; i++) a[i] = i; return a; };
|
|
456
586
|
if (!this._zoneMapsTrackedFields) return all();
|
|
457
587
|
const t = this._zoneMapsFieldToPos.get(fieldName);
|