@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/src/Reader.js CHANGED
@@ -10,15 +10,22 @@
10
10
  // R_BAD_MAGIC - header magic mismatch
11
11
  // R_UNSUPPORTED_VERSION - format_version > 1
12
12
  // R_UNSUPPORTED_ENDIAN - endian byte is neither LE nor BE
13
+ // R_WRONG_MODE - preserve-mode container fed to the schema Reader
14
+ // R_BAD_FLAGS - unknown header flag bits set
13
15
  // R_BAD_FIELD_FLAGS - a FieldDescriptor.flags is non-zero (v2+ reserved)
14
16
  // R_UNSUPPORTED_LANE - lane_kind is not F64 or U32
15
17
  // 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
+ // R_TRUNCATED - a structure claims bytes past the buffer end
19
+ // R_RESERVED_NONZERO - a reserved header/descriptor/shard field is non-zero
20
+ // R_BAD_FOOTER - footer magic_end or footer_len is malformed
21
+ // R_BAD_METADATA - metadata_off is non-zero but the zone-map segment is unparseable
22
+ // R_INVALID - a structure is internally inconsistent but in-bounds
23
+ // R_UNKNOWN_FIELD - get()/findShards()/shardBounds() called with an unknown field name
18
24
 
19
25
  import { StringTable } from './StringTable.js';
26
+ import { toContainerBuffer } from './Views.js';
20
27
 
21
- export const VERSION = '1.1.0';
28
+ export const VERSION = '1.3.0';
22
29
 
23
30
  const CONTAINER_HEADER_BYTES = 48;
24
31
  const SHARD_ENTRY_BYTES = 40;
@@ -29,16 +36,43 @@ const LANE_F64 = 1;
29
36
  const LANE_U32 = 3;
30
37
  const READER_VERSION = 1;
31
38
 
39
+ function laneBytesOf(k) { return k === LANE_F64 ? 8 : (k === LANE_U32 ? 4 : 0); }
40
+
32
41
  export class ReaderError extends Error {
33
42
  constructor(code, msg) { super(msg); this.code = code; this.name = 'ReaderError'; }
34
43
  }
35
44
 
45
+ // Validate a local string table's shape BEFORE StringTable.parse casts a
46
+ // Uint32Array over it (T-1..T-4): the offsets array must be in bounds, the
47
+ // count/blob must fit, offsets must be non-decreasing, and the trailing
48
+ // sentinel must equal blob_length. A lie here is otherwise a raw RangeError
49
+ // from `new Uint32Array(buffer, off, ...)` or a silent out-of-range read.
50
+ function validateStringTable(bytes, off, len, label) {
51
+ if (len < 8)
52
+ throw new ReaderError('R_TRUNCATED', label + ' string table shorter than its 8-byte header');
53
+ const dv = new DataView(bytes.buffer, bytes.byteOffset + off, len);
54
+ const entryCount = dv.getUint32(0, true);
55
+ const blobLen = dv.getUint32(4, true);
56
+ const offsetsBytes = (entryCount + 1) * 4;
57
+ if (8 + offsetsBytes + blobLen > len)
58
+ throw new ReaderError('R_TRUNCATED', label + ' string table claims ' + entryCount +
59
+ ' entries + ' + blobLen + ' blob bytes past its ' + len + '-byte extent');
60
+ let prev = dv.getUint32(8, true);
61
+ if (prev !== 0)
62
+ throw new ReaderError('R_INVALID', label + ' string table offsets[0] is ' + prev + ', must be 0');
63
+ for (let i = 1; i <= entryCount; i++) {
64
+ const cur = dv.getUint32(8 + i * 4, true);
65
+ if (cur < prev)
66
+ throw new ReaderError('R_INVALID', label + ' string table offsets not monotonic at index ' + i);
67
+ prev = cur;
68
+ }
69
+ if (prev !== blobLen)
70
+ throw new ReaderError('R_INVALID', label + ' string table sentinel ' + prev + ' != blob_length ' + blobLen);
71
+ }
72
+
36
73
  export class Reader {
37
- static fromBuffer(bufferOrArrayBuffer) {
38
- const buffer = bufferOrArrayBuffer instanceof ArrayBuffer
39
- ? bufferOrArrayBuffer
40
- : bufferOrArrayBuffer.buffer;
41
- return new Reader(buffer);
74
+ static fromBuffer(input) {
75
+ return new Reader(toContainerBuffer(input, 'Reader.fromBuffer'));
42
76
  }
43
77
 
44
78
  constructor(buffer) {
@@ -46,9 +80,10 @@ export class Reader {
46
80
  this._dv = new DataView(buffer);
47
81
  this._bytes = new Uint8Array(buffer);
48
82
  this._parseHeader();
83
+ this._parseFooter();
49
84
  this._parseSchema();
50
85
  this._parseShardDirectory();
51
- this._parseZoneMaps(); // M7 no-op if metadata_off is 0
86
+ this._parseZoneMaps(); // M7 -- no-op if metadata_off is 0
52
87
  this._buildFieldIndex();
53
88
  }
54
89
 
@@ -79,13 +114,42 @@ export class Reader {
79
114
  }
80
115
  this._flags = flags;
81
116
 
117
+ const reserved1 = this._dv.getUint32(36, true);
118
+ if (reserved1 !== 0)
119
+ throw new ReaderError('R_RESERVED_NONZERO', 'header reserved1 at offset 36 must be 0, got ' + reserved1);
120
+
82
121
  this._schemaBlockOff = Number(this._dv.getBigUint64(8, true));
83
122
  this._metadataOff = Number(this._dv.getBigUint64(16, true)); // 0 = no metadata block; M7+ zone maps
84
123
  this._shardDirOff = Number(this._dv.getBigUint64(24, true));
85
124
  this._shardCount = this._dv.getUint32(32, true);
86
- // 4 bytes reserved at 36
87
125
  this._totalRows = Number(this._dv.getBigUint64(40, true));
88
126
  this._formatVersion = version;
127
+
128
+ const len = this._buffer.byteLength;
129
+ if (this._schemaBlockOff < CONTAINER_HEADER_BYTES || this._schemaBlockOff >= len)
130
+ throw new ReaderError('R_TRUNCATED', 'schema_block_off ' + this._schemaBlockOff + ' out of range [48, ' + len + ')');
131
+ if (this._shardDirOff < CONTAINER_HEADER_BYTES || this._shardDirOff >= len)
132
+ throw new ReaderError('R_TRUNCATED', 'shard_directory_off ' + this._shardDirOff + ' out of range [48, ' + len + ')');
133
+ if (this._metadataOff !== 0 && (this._metadataOff < CONTAINER_HEADER_BYTES || this._metadataOff >= len))
134
+ throw new ReaderError('R_TRUNCATED', 'metadata_off ' + this._metadataOff + ' out of range {0} u [48, ' + len + ')');
135
+ if (this._shardCount * SHARD_ENTRY_BYTES > len - CONTAINER_HEADER_BYTES)
136
+ throw new ReaderError('R_TRUNCATED', 'shard_count ' + this._shardCount + ' exceeds the bytes available for a shard directory');
137
+ }
138
+
139
+ // Footer (SPEC 3.7): magic_end 'LBK1'-reversed = '1KBL' at footer_off+8,
140
+ // footer_len >= 16 at footer_off+12. Runs once, at construction.
141
+ _parseFooter() {
142
+ const len = this._buffer.byteLength;
143
+ const footerOff = len - FOOTER_BYTES;
144
+ const b = this._bytes;
145
+ if (b[footerOff + 8] !== 0x31 || b[footerOff + 9] !== 0x4B ||
146
+ b[footerOff + 10] !== 0x42 || b[footerOff + 11] !== 0x4C)
147
+ throw new ReaderError('R_BAD_FOOTER', 'footer magic_end at offset ' + (footerOff + 8) + ' is not 1KBL');
148
+ const footerLen = this._dv.getUint32(footerOff + 12, true);
149
+ if (footerLen < FOOTER_BYTES)
150
+ throw new ReaderError('R_BAD_FOOTER', 'footer_len ' + footerLen + ' is less than the minimum ' + FOOTER_BYTES);
151
+ if (footerLen > len - CONTAINER_HEADER_BYTES)
152
+ throw new ReaderError('R_BAD_FOOTER', 'footer_len ' + footerLen + ' exceeds the container body size');
89
153
  }
90
154
 
91
155
  _parseSchema() {
@@ -93,6 +157,7 @@ export class Reader {
93
157
  if (off + 8 > this._buffer.byteLength) throw new ReaderError('R_TRUNCATED', 'schema header truncated');
94
158
  const fieldCount = this._dv.getUint32(off + 0, true);
95
159
  const rowStride = this._dv.getUint32(off + 4, true);
160
+ if (rowStride < 1) throw new ReaderError('R_INVALID', 'schema row_stride is 0');
96
161
  const descriptorsOff = off + 8;
97
162
  const descriptorsBytes = fieldCount * FIELD_DESCRIPTOR_BYTES;
98
163
  if (descriptorsOff + descriptorsBytes + 4 > this._buffer.byteLength) {
@@ -114,11 +179,21 @@ export class Reader {
114
179
  const offsetInRow = this._dv.getUint16(descOff + 2, true);
115
180
  const laneKind = this._bytes[descOff + 4];
116
181
  const flags = this._bytes[descOff + 5];
182
+ const reserved2 = this._dv.getUint16(descOff + 6, true);
117
183
  const nameStrOff = Number(this._dv.getBigUint64(descOff + 8, true));
184
+ const reserved3 = this._dv.getBigUint64(descOff + 16, true);
118
185
 
119
186
  if (flags !== 0) throw new ReaderError('R_BAD_FIELD_FLAGS', 'field ' + i + ' has non-zero flags (v2+ reserved)');
187
+ if (reserved2 !== 0) throw new ReaderError('R_RESERVED_NONZERO', 'field ' + i + ' reserved2 must be 0, got ' + reserved2);
188
+ if (reserved3 !== 0n) throw new ReaderError('R_RESERVED_NONZERO', 'field ' + i + ' reserved3 must be 0, got ' + reserved3);
120
189
  if (laneKind !== LANE_F64 && laneKind !== LANE_U32)
121
190
  throw new ReaderError('R_UNSUPPORTED_LANE', 'field ' + i + ' lane_kind=' + laneKind);
191
+ if (nameLen > 255) throw new ReaderError('R_INVALID', 'field ' + i + ' name_len ' + nameLen + ' exceeds 255');
192
+ if (offsetInRow + laneBytesOf(laneKind) > rowStride)
193
+ throw new ReaderError('R_INVALID', 'field ' + i + ' offset_in_row ' + offsetInRow +
194
+ ' + lane bytes exceeds row_stride ' + rowStride);
195
+ if (nameStrOff + nameLen > nameBlobLen)
196
+ throw new ReaderError('R_TRUNCATED', 'field ' + i + ' name range past the name blob');
122
197
 
123
198
  const nameBytes = this._bytes.subarray(nameBlobOff + nameStrOff, nameBlobOff + nameStrOff + nameLen);
124
199
  const name = decoder.decode(nameBytes);
@@ -133,22 +208,35 @@ export class Reader {
133
208
  const need = this._shardCount * SHARD_ENTRY_BYTES;
134
209
  if (off + need > this._buffer.byteLength) throw new ReaderError('R_TRUNCATED', 'shard directory truncated');
135
210
  const shards = new Array(this._shardCount);
211
+ const rowStride = this._schema.rowStride;
212
+ let rowSum = 0;
136
213
  for (let i = 0; i < this._shardCount; i++) {
137
214
  const entryOff = off + i * SHARD_ENTRY_BYTES;
138
215
  const payloadOff = Number(this._dv.getBigUint64(entryOff + 0, true));
139
216
  const payloadLen = this._dv.getUint32(entryOff + 8, true);
140
217
  const rowCount = this._dv.getUint32(entryOff + 12, true);
141
218
  const minReaderVer = this._dv.getUint16(entryOff + 16, true);
142
- // 2 bytes flags at 18, 4 bytes reserved at 20
219
+ const shardFlags = this._dv.getUint16(entryOff + 18, true);
220
+ const shardReserved = this._dv.getUint32(entryOff + 20, true);
143
221
  const localStrOff = Number(this._dv.getBigUint64(entryOff + 24, true));
144
222
  const localStrLen = Number(this._dv.getBigUint64(entryOff + 32, true));
145
223
  if (minReaderVer > READER_VERSION) {
146
224
  throw new ReaderError('R_SHARD_VERSION_TOO_NEW',
147
225
  'shard ' + i + ' requires reader version ' + minReaderVer);
148
226
  }
227
+ if (shardFlags !== 0) throw new ReaderError('R_RESERVED_NONZERO', 'shard ' + i + ' flags must be 0, got ' + shardFlags);
228
+ if (shardReserved !== 0) throw new ReaderError('R_RESERVED_NONZERO', 'shard ' + i + ' reserved must be 0, got ' + shardReserved);
229
+ if (payloadOff < CONTAINER_HEADER_BYTES)
230
+ throw new ReaderError('R_INVALID', 'shard ' + i + ' payload_off ' + payloadOff + ' overlaps the header');
149
231
  if (payloadOff + payloadLen > this._buffer.byteLength) {
150
232
  throw new ReaderError('R_TRUNCATED', 'shard ' + i + ' payload truncated');
151
233
  }
234
+ if (rowCount * rowStride > payloadLen)
235
+ throw new ReaderError('R_INVALID', 'shard ' + i + ' row_count ' + rowCount +
236
+ ' * row_stride ' + rowStride + ' exceeds payload_len ' + payloadLen);
237
+ if (localStrLen === 0 && localStrOff !== 0)
238
+ throw new ReaderError('R_INVALID', 'shard ' + i + ' has local_string_len 0 but local_string_off ' + localStrOff);
239
+ rowSum += rowCount;
152
240
  // Byte view over payload (no F64 view; row stride can be non-multiple-of-8 with U32 fields)
153
241
  const payloadBytes = new Uint8Array(this._buffer, payloadOff, payloadLen);
154
242
  const payloadDv = new DataView(this._buffer, payloadOff, payloadLen);
@@ -158,10 +246,13 @@ export class Reader {
158
246
  if (localStrOff + localStrLen > this._buffer.byteLength) {
159
247
  throw new ReaderError('R_TRUNCATED', 'shard ' + i + ' string table truncated');
160
248
  }
249
+ validateStringTable(this._bytes, localStrOff, localStrLen, 'shard ' + i);
161
250
  stringTable = StringTable.parse(new Uint8Array(this._buffer, localStrOff, localStrLen), 0);
162
251
  }
163
252
  shards[i] = { payloadOff, payloadLen, rowCount, payloadBytes, payloadDv, stringTable };
164
253
  }
254
+ if (rowSum !== this._totalRows)
255
+ throw new ReaderError('R_INVALID', 'shard row_count sum ' + rowSum + ' != header total_rows ' + this._totalRows);
165
256
  this._shards = shards;
166
257
  }
167
258
 
@@ -178,33 +269,38 @@ export class Reader {
178
269
  // _zoneMapsMins/Maxes: Float64Array -- length shardCount * T, row-major
179
270
  _parseZoneMaps() {
180
271
  this._zoneMapsTrackedFields = null;
181
- if (this._metadataOff === 0) return;
182
- if (this._metadataOff + 16 > this._buffer.byteLength) return;
272
+ if (this._metadataOff === 0) return; // legal absence
183
273
  const off = this._metadataOff;
184
- // Check 'ZM01' magic
274
+ // D1: a non-zero metadata_off is a producer assertion that a parseable
275
+ // segment lives here. Any way it fails to parse is corruption, not absence.
276
+ if (off + 16 > this._buffer.byteLength)
277
+ throw new ReaderError('R_BAD_METADATA', 'zone maps segment header at ' + off + ' runs past the buffer');
185
278
  if (this._bytes[off] !== 0x30 || this._bytes[off + 1] !== 0x5A ||
186
- this._bytes[off + 2] !== 0x4D || this._bytes[off + 3] !== 0x31) return;
279
+ this._bytes[off + 2] !== 0x4D || this._bytes[off + 3] !== 0x31)
280
+ throw new ReaderError('R_BAD_METADATA', 'zone maps magic at ' + off + ' is not ZM01');
187
281
  const shardCount = this._dv.getUint32(off + 4, true);
188
282
  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;
283
+ const reserved0 = this._dv.getUint32(off + 12, true);
284
+ if (shardCount !== this._shardCount)
285
+ throw new ReaderError('R_BAD_METADATA', 'zone maps shard_count ' + shardCount + ' != header ' + this._shardCount);
286
+ if (reserved0 !== 0)
287
+ throw new ReaderError('R_BAD_METADATA', 'zone maps reserved0 must be 0, got ' + reserved0);
288
+ if (T === 0) return; // a well-formed segment tracking nothing: hasZoneMaps false
193
289
  const fieldTableOff = off + 16;
194
290
  const fieldTablePad = (8 - ((T * 2) & 7)) & 7;
195
291
  const minsOff = fieldTableOff + T * 2 + fieldTablePad;
196
292
  const maxesOff = minsOff + shardCount * T * 8;
197
293
  const requiredEnd = maxesOff + shardCount * T * 8;
198
- if (requiredEnd > this._buffer.byteLength) {
199
- throw new ReaderError('R_TRUNCATED', 'zone maps segment truncated');
200
- }
294
+ if (requiredEnd > this._buffer.byteLength)
295
+ throw new ReaderError('R_BAD_METADATA', 'zone maps segment runs past the buffer');
296
+ if ((minsOff & 7) !== 0)
297
+ throw new ReaderError('R_BAD_METADATA', 'zone maps mins offset ' + minsOff + ' is not 8-byte aligned');
201
298
  const tracked = new Array(T);
202
299
  const fieldToPos = new Map();
203
300
  for (let t = 0; t < T; t++) {
204
301
  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
- }
302
+ if (schemaFieldIdx >= this._schema.fields.length)
303
+ throw new ReaderError('R_BAD_METADATA', 'zone maps references field index ' + schemaFieldIdx + ' past the schema');
208
304
  tracked[t] = schemaFieldIdx;
209
305
  fieldToPos.set(this._schema.fields[schemaFieldIdx].name, t);
210
306
  }
@@ -259,6 +355,8 @@ export class Reader {
259
355
  // - the field is not tracked (currently: U32/string fields are never tracked in v1).
260
356
  // For empty ranges (a shard where the field defaulted to 0), returns {min:0, max:0}.
261
357
  shardBounds(shardIdx, fieldName) {
358
+ if (!this._fieldIndex.has(fieldName))
359
+ throw new ReaderError('R_UNKNOWN_FIELD', 'no field named ' + JSON.stringify(fieldName));
262
360
  if (!this._zoneMapsTrackedFields) return null;
263
361
  const t = this._zoneMapsFieldToPos.get(fieldName);
264
362
  if (t === undefined) return null;
@@ -273,6 +371,8 @@ export class Reader {
273
371
  // Semantics: a shard is INCLUDED iff its [smin, smax] overlaps [min, max].
274
372
  // A pass-through range ({min: -Inf, max: +Inf} or omitted) returns all shards.
275
373
  findShards(fieldName, opts) {
374
+ if (!this._fieldIndex.has(fieldName))
375
+ throw new ReaderError('R_UNKNOWN_FIELD', 'no field named ' + JSON.stringify(fieldName));
276
376
  const all = () => { const a = new Array(this._shardCount); for (let i = 0; i < this._shardCount; i++) a[i] = i; return a; };
277
377
  if (!this._zoneMapsTrackedFields) return all();
278
378
  const t = this._zoneMapsFieldToPos.get(fieldName);
package/src/Split.js CHANGED
@@ -35,8 +35,9 @@ import { Tokenizer, TokenizerError } from './Tokenizer.js';
35
35
  import { Writer, WriterError } from './Writer.js';
36
36
  import { Reader, ReaderError } from './Reader.js';
37
37
  import { StringTable } from './StringTable.js';
38
+ import { checkOpts } from './Opts.js';
38
39
 
39
- export const VERSION = '1.1.0';
40
+ export const VERSION = '1.3.0';
40
41
 
41
42
  const LF = 0x0A;
42
43
  const CONTAINER_HEADER_BYTES = 48;
@@ -44,10 +45,28 @@ const SHARD_ENTRY_BYTES = 40;
44
45
  const FIELD_DESCRIPTOR_BYTES = 24;
45
46
  const FOOTER_BYTES = 16;
46
47
  const LANE_F64 = 1;
48
+ const LANE_U32 = 3;
49
+ const U32_MAX = 4294967295;
50
+
51
+ const SPLIT_NDJSON_OPTS = {
52
+ targetParts: { t: 'int', min: 1, max: U32_MAX },
53
+ maxPartBytes: { t: 'int', min: 1, max: U32_MAX, inf: true },
54
+ };
55
+ const COMPILE_PART_OPTS = {
56
+ framing: { t: 'enum', values: ['auto', 'array', 'ndjson'] },
57
+ writer: { t: 'obj' },
58
+ };
59
+ const COMPILE_IN_PARTS_OPTS = {
60
+ targetParts: { t: 'int', min: 1, max: U32_MAX },
61
+ maxPartBytes: { t: 'int', min: 1, max: U32_MAX, inf: true },
62
+ framing: { t: 'enum', values: ['auto', 'array', 'ndjson'] },
63
+ writer: { t: 'obj' },
64
+ };
47
65
 
48
66
  export class SplitError extends Error {
49
67
  constructor(code, msg) { super(msg); this.code = code; this.name = 'SplitError'; }
50
68
  }
69
+ function raiseSplit(code, msg) { throw new SplitError(code, msg); }
51
70
 
52
71
  // ---------- splitNDJSON ----------
53
72
 
@@ -67,8 +86,9 @@ export function splitNDJSON(bytes, opts) {
67
86
  throw new TypeError('splitNDJSON: expected Uint8Array');
68
87
  }
69
88
  opts = opts || {};
70
- const targetParts = Math.max(1, opts.targetParts || 4);
71
- const maxPartBytes = opts.maxPartBytes || Infinity;
89
+ checkOpts('splitNDJSON', opts, SPLIT_NDJSON_OPTS, raiseSplit);
90
+ const targetParts = Math.max(1, opts.targetParts !== undefined ? opts.targetParts : 4);
91
+ const maxPartBytes = opts.maxPartBytes !== undefined ? opts.maxPartBytes : Infinity;
72
92
  const total = bytes.length;
73
93
  if (total === 0) return [];
74
94
 
@@ -110,8 +130,9 @@ export function compilePart(bytes, opts) {
110
130
  throw new TypeError('compilePart: expected Uint8Array');
111
131
  }
112
132
  opts = opts || {};
113
- const framing = opts.framing || 'ndjson';
114
- const w = new Writer(opts.writer || {});
133
+ checkOpts('compilePart', opts, COMPILE_PART_OPTS, raiseSplit);
134
+ const framing = opts.framing !== undefined ? opts.framing : 'ndjson';
135
+ const w = new Writer(opts.writer !== undefined ? opts.writer : {});
115
136
  const t = new Tokenizer(w, { framing });
116
137
  t.feed(bytes);
117
138
  t.end();
@@ -126,13 +147,14 @@ export function compilePart(bytes, opts) {
126
147
  // aren't available or for testing.
127
148
  export function compileInParts(bytes, opts) {
128
149
  opts = opts || {};
150
+ checkOpts('compileInParts', opts, COMPILE_IN_PARTS_OPTS, raiseSplit);
129
151
  const splitOpts = {
130
152
  targetParts: opts.targetParts,
131
153
  maxPartBytes: opts.maxPartBytes,
132
154
  };
133
155
  const partOpts = {
134
- framing: opts.framing || 'ndjson',
135
- writer: opts.writer || {},
156
+ framing: opts.framing !== undefined ? opts.framing : 'ndjson',
157
+ writer: opts.writer !== undefined ? opts.writer : {},
136
158
  };
137
159
  const ranges = splitNDJSON(bytes, splitOpts);
138
160
  const containers = new Array(ranges.length);
@@ -180,6 +202,16 @@ export function mergeContainers(containers) {
180
202
  }
181
203
  }
182
204
 
205
+ // BS-15 conformance: a merged container whose schema has no U32 lane must
206
+ // emit NO local string tables (local_string_off/len = 0), regardless of what
207
+ // the source containers carry. A pre-M2 F64-only input holds a stale 16-byte
208
+ // empty table; without this, merge would launder it into the output and the
209
+ // t8 spec-checker would reject the result. Computed once from the shared schema.
210
+ let mergedHasU32 = false;
211
+ for (let i = 0; i < schema0.fields.length; i++) {
212
+ if (schema0.fields[i].laneKind === LANE_U32) { mergedHasU32 = true; break; }
213
+ }
214
+
183
215
  // Total shards and rows
184
216
  let totalShards = 0, totalRows = 0;
185
217
  for (const r of readers) { totalShards += r.shardCount; totalRows += r.totalRows; }
@@ -225,7 +257,7 @@ export function mergeContainers(containers) {
225
257
  // Source shard payload lives at shard.payloadOff in the source container.
226
258
  const payloadLen = shard.payloadLen;
227
259
  const payloadPad = (8 - (payloadLen & 7)) & 7;
228
- const stringLen = shard.stringTable ? _sourceStringTableLen(readers[ci], si) : 0;
260
+ const stringLen = (mergedHasU32 && shard.stringTable) ? _sourceStringTableLen(readers[ci], si) : 0;
229
261
  const outPayloadOff = cursor;
230
262
  const outStringOff = outPayloadOff + payloadLen + payloadPad;
231
263
  shardOutMap.push({
@@ -20,7 +20,7 @@
20
20
  // and at every reset(), so an absent U32 row cell (which is 0) decodes as ""
21
21
  // rather than aliasing the shard's first-interned string (SPEC 3.3, SPEC 7).
22
22
 
23
- export const VERSION = '1.1.0';
23
+ export const VERSION = '1.3.0';
24
24
 
25
25
  const EMPTY_SLOT = 0xFFFFFFFF; // MUST be unsigned; typed-array reads are unsigned
26
26
  const INITIAL_BLOB_BYTES = 64 * 1024;
package/src/Tokenizer.js CHANGED
@@ -16,8 +16,22 @@
16
16
  // E_KEYWORD_MISMATCH - true/false/null spelled wrong
17
17
  // E_TRAILING_INPUT - non-whitespace bytes after top-level value(s) (array mode only)
18
18
  // E_DEPTH_LIMIT - nesting depth exceeded MAX_DEPTH
19
+ // E_STRING_TOO_LONG - string exceeds maxStringBytes cap
20
+ // E_ENDED - feed()/end() after end()
21
+ // E_POISONED - feed()/end() after a thrown error
22
+ // E_UNKNOWN_OPTION - unknown constructor option key
23
+ // E_OPTION_VALUE - constructor option value out of domain
19
24
 
20
- export const VERSION = '1.1.0';
25
+ import { checkOpts } from './Opts.js';
26
+
27
+ export const VERSION = '1.3.0';
28
+
29
+ const U32_MAX = 4294967295;
30
+ const TOKENIZER_OPTS = {
31
+ framing: { t: 'enum', values: ['auto', 'array', 'ndjson'] },
32
+ maxStringBytes: { t: 'int', min: 1, max: U32_MAX },
33
+ };
34
+ function raiseTok(code, msg) { throw new TokenizerError(code, 0, msg); }
21
35
 
22
36
  // ---------- byte constants ----------
23
37
  const B_SPACE = 0x20, B_TAB = 0x09, B_LF = 0x0A, B_CR = 0x0D;
@@ -104,24 +118,33 @@ const NOOP = () => {};
104
118
  export class Tokenizer {
105
119
  constructor(sink, opts) {
106
120
  if (!sink) throw new Error('sink required');
121
+ checkOpts('Tokenizer', opts, TOKENIZER_OPTS, raiseTok);
107
122
  this.sink = sink;
108
123
 
109
124
  // top-level framing: 'auto' (default), 'array', or 'ndjson'
110
- this._framing = (opts && opts.framing) || 'auto';
111
- this._maxStringBytes = (opts && opts.maxStringBytes) || (1 << 20); // 1 MB default cap
112
-
113
- // fill in missing sink methods to avoid per-token undefined checks
114
- if (!sink.onStartObject) sink.onStartObject = NOOP;
115
- if (!sink.onEndObject) sink.onEndObject = NOOP;
116
- if (!sink.onStartArray) sink.onStartArray = NOOP;
117
- if (!sink.onEndArray) sink.onEndArray = NOOP;
118
- if (!sink.onKey) sink.onKey = NOOP;
119
- if (!sink.onString) sink.onString = NOOP;
120
- if (!sink.onNumber) sink.onNumber = NOOP;
121
- if (!sink.onTrue) sink.onTrue = NOOP;
122
- if (!sink.onFalse) sink.onFalse = NOOP;
123
- if (!sink.onNull) sink.onNull = NOOP;
124
- if (!sink.onEnd) sink.onEnd = NOOP;
125
+ this._framing = (opts && opts.framing !== undefined) ? opts.framing : 'auto';
126
+ this._maxStringBytes = (opts && opts.maxStringBytes !== undefined) ? opts.maxStringBytes : (1 << 20); // 1 MB default cap
127
+
128
+ // Per-instance dispatch fields: bind each present sink method to the sink
129
+ // once at construction (cold path), defaulting to NOOP. The caller's sink
130
+ // object is never mutated (BS-19), so a frozen sink constructs and drives
131
+ // fine. Binding preserves the sink as receiver while the hot call sites read
132
+ // a single monomorphic field (this._onX) instead of this.sink.onX.
133
+ this._onStartObject = sink.onStartObject ? sink.onStartObject.bind(sink) : NOOP;
134
+ this._onEndObject = sink.onEndObject ? sink.onEndObject.bind(sink) : NOOP;
135
+ this._onStartArray = sink.onStartArray ? sink.onStartArray.bind(sink) : NOOP;
136
+ this._onEndArray = sink.onEndArray ? sink.onEndArray.bind(sink) : NOOP;
137
+ this._onKey = sink.onKey ? sink.onKey.bind(sink) : NOOP;
138
+ this._onString = sink.onString ? sink.onString.bind(sink) : NOOP;
139
+ this._onNumber = sink.onNumber ? sink.onNumber.bind(sink) : NOOP;
140
+ this._onTrue = sink.onTrue ? sink.onTrue.bind(sink) : NOOP;
141
+ this._onFalse = sink.onFalse ? sink.onFalse.bind(sink) : NOOP;
142
+ this._onNull = sink.onNull ? sink.onNull.bind(sink) : NOOP;
143
+ this._onEnd = sink.onEnd ? sink.onEnd.bind(sink) : NOOP;
144
+
145
+ // Terminal state (BS-14): once ended or poisoned, the instance is dead.
146
+ this._ended = false;
147
+ this._poisoned = false;
125
148
 
126
149
  // parser state
127
150
  this._state = S_TOP;
@@ -158,6 +181,12 @@ export class Tokenizer {
158
181
 
159
182
  // Feed a chunk. Bytes are consumed synchronously; sink events fire during this call.
160
183
  feed(chunk) {
184
+ if (this._poisoned) throw new TokenizerError('E_POISONED', this._absOffset, 'tokenizer poisoned by a previous error or an in-flight feed(); construct a new instance');
185
+ if (this._ended) throw new TokenizerError('E_ENDED', this._absOffset, 'tokenizer already ended; construct a new instance');
186
+ // D3 armed-flag: a sink throw mid-feed unwinds through here with the flag
187
+ // set, poisoning the instance. Cleared on the successful fall-through below.
188
+ // Two boolean stores per chunk; the byte loop is untouched.
189
+ this._poisoned = true;
161
190
  const len = chunk.length;
162
191
  let i = 0;
163
192
  while (i < len) {
@@ -306,9 +335,9 @@ export class Tokenizer {
306
335
  this._kwPos++;
307
336
  i++; this._absOffset++;
308
337
  if (this._kwPos === tmpl.length) {
309
- if (this._kwId === KW_ID_TRUE) this.sink.onTrue();
310
- else if (this._kwId === KW_ID_FALSE) this.sink.onFalse();
311
- else this.sink.onNull();
338
+ if (this._kwId === KW_ID_TRUE) this._onTrue();
339
+ else if (this._kwId === KW_ID_FALSE) this._onFalse();
340
+ else this._onNull();
312
341
  this._afterValue();
313
342
  }
314
343
  continue;
@@ -317,9 +346,13 @@ export class Tokenizer {
317
346
  // unreachable
318
347
  this._err('E_UNEXPECTED_BYTE', 'internal: unknown state ' + st);
319
348
  }
349
+ this._poisoned = false; // D3: clean fall-through un-arms the flag
320
350
  }
321
351
 
322
352
  end() {
353
+ if (this._poisoned) throw new TokenizerError('E_POISONED', this._absOffset, 'tokenizer poisoned by a previous error or an in-flight feed(); construct a new instance');
354
+ if (this._ended) throw new TokenizerError('E_ENDED', this._absOffset, 'tokenizer already ended; construct a new instance');
355
+ this._poisoned = true; // D3: armed until a clean exit below
323
356
  // A trailing number may still be pending
324
357
  if (this._state === S_NUMBER) {
325
358
  this._emitNumber();
@@ -328,7 +361,9 @@ export class Tokenizer {
328
361
  if (this._arrayMode && this._arrayModeOuterOpen) {
329
362
  this._err('E_UNEXPECTED_EOF', 'unclosed top-level array');
330
363
  }
331
- this.sink.onEnd();
364
+ this._ended = true;
365
+ this._onEnd();
366
+ this._poisoned = false; // clear AFTER _onEnd(): a throwing onEnd stays poisoned
332
367
  return;
333
368
  }
334
369
  this._err('E_UNEXPECTED_EOF', 'input ended mid-token (state=' + this._state + ')');
@@ -378,7 +413,7 @@ export class Tokenizer {
378
413
 
379
414
  _handleObjectStartByte(b) {
380
415
  if (b === B_RBRACE) {
381
- this.sink.onEndObject();
416
+ this._onEndObject();
382
417
  this._popContainer();
383
418
  return;
384
419
  }
@@ -389,7 +424,7 @@ export class Tokenizer {
389
424
  _handleObjectValueEndByte(b) {
390
425
  if (b === B_COMMA) { this._state = S_OBJECT_NEXT_KEY; }
391
426
  else if (b === B_RBRACE) {
392
- this.sink.onEndObject();
427
+ this._onEndObject();
393
428
  this._popContainer();
394
429
  } else {
395
430
  this._err('E_UNEXPECTED_BYTE', 'expected , or }');
@@ -404,7 +439,7 @@ export class Tokenizer {
404
439
  this._state = S_TOP;
405
440
  return;
406
441
  }
407
- this.sink.onEndArray();
442
+ this._onEndArray();
408
443
  this._popContainer();
409
444
  return;
410
445
  }
@@ -419,7 +454,7 @@ export class Tokenizer {
419
454
  this._state = S_TOP;
420
455
  return;
421
456
  }
422
- this.sink.onEndArray();
457
+ this._onEndArray();
423
458
  this._popContainer();
424
459
  } else {
425
460
  this._err('E_UNEXPECTED_BYTE', 'expected , or ]');
@@ -430,13 +465,13 @@ export class Tokenizer {
430
465
 
431
466
  _beginValue(b) {
432
467
  if (b === B_LBRACE) {
433
- this.sink.onStartObject();
468
+ this._onStartObject();
434
469
  this._pushContainer(C_OBJECT);
435
470
  this._state = S_OBJECT_START;
436
471
  return;
437
472
  }
438
473
  if (b === B_LBRACKET) {
439
- this.sink.onStartArray();
474
+ this._onStartArray();
440
475
  this._pushContainer(C_ARRAY);
441
476
  this._state = S_ARRAY_START;
442
477
  return;
@@ -578,16 +613,17 @@ export class Tokenizer {
578
613
  v = this._numSign * v;
579
614
  }
580
615
  if (!Number.isFinite(v)) this._err('E_NUMBER_OVERFLOW', 'number exceeds F64 range');
581
- this.sink.onNumber(v);
616
+ this._onNumber(v);
582
617
  this._afterValue();
583
618
  }
584
619
 
585
620
  _emitString() {
621
+ if (this._strLen > this._maxStringBytes) this._err('E_STRING_TOO_LONG', 'string exceeds maxStringBytes cap (' + this._strLen + ' > ' + this._maxStringBytes + ')');
586
622
  if (this._strIsKey) {
587
- this.sink.onKey(this._strBuf, 0, this._strLen);
623
+ this._onKey(this._strBuf, 0, this._strLen);
588
624
  this._state = S_OBJECT_KEY_END;
589
625
  } else {
590
- this.sink.onString(this._strBuf, 0, this._strLen);
626
+ this._onString(this._strBuf, 0, this._strLen);
591
627
  this._afterValue();
592
628
  }
593
629
  }
@@ -636,6 +672,7 @@ export class Tokenizer {
636
672
 
637
673
  _appendStrRange(chunk, from, to) {
638
674
  const need = to - from;
675
+ if (this._strLen + need > this._maxStringBytes) this._err('E_STRING_TOO_LONG', 'string exceeds maxStringBytes cap (' + (this._strLen + need) + ' > ' + this._maxStringBytes + ')');
639
676
  if (this._strLen + need > this._strBuf.length) this._growStrBuf(need);
640
677
  // Manual copy loop — Uint8Array.set(source) via subarray allocates a small
641
678
  // view header per call, which turns into MB-scale GC pressure across a
@@ -668,9 +705,9 @@ export class Tokenizer {
668
705
  }
669
706
 
670
707
  _growStrBuf(need) {
708
+ if (this._strLen + need > this._maxStringBytes) this._err('E_STRING_TOO_LONG', 'string exceeds maxStringBytes cap (' + (this._strLen + need) + ' > ' + this._maxStringBytes + ')');
671
709
  let cap = this._strBuf.length;
672
710
  while (cap < this._strLen + need) cap *= 2;
673
- if (cap > this._maxStringBytes) this._err('E_UNEXPECTED_BYTE', 'string exceeds maxStringBytes cap');
674
711
  const nb = new Uint8Array(cap);
675
712
  nb.set(this._strBuf);
676
713
  this._strBuf = nb;
@@ -684,6 +721,7 @@ export class Tokenizer {
684
721
  }
685
722
 
686
723
  _err(code, msg) {
724
+ this._poisoned = true;
687
725
  throw new TokenizerError(code, this._absOffset, msg);
688
726
  }
689
727
  }
package/src/Views.js ADDED
@@ -0,0 +1,25 @@
1
+ // @zakkster/lite-bake-stream / Views (internal)
2
+ // Copyright (c) 2026 Zahary Shinikchiev. MIT.
3
+ //
4
+ // One shared helper so every container entry point (deserialize,
5
+ // Reader.fromBuffer, PreserveReader.fromBuffer) resolves caller-supplied bytes
6
+ // to an ArrayBuffer by exactly the SAME rule (BS-09). A Uint8Array that is a
7
+ // full-buffer view is unwrapped in place (zero copy); a partial view (a pooled
8
+ // Buffer with byteOffset > 0, or a subarray) is COPIED so the reader never sees
9
+ // bytes outside the caller's window. Anything else fails closed.
10
+ //
11
+ // Not a public export. Internal to the package.
12
+
13
+ export function toContainerBuffer(input, label) {
14
+ if (input instanceof ArrayBuffer) return input;
15
+ if (input instanceof Uint8Array) {
16
+ if (input.byteOffset === 0 && input.byteLength === input.buffer.byteLength) {
17
+ return input.buffer;
18
+ }
19
+ const copy = new Uint8Array(input.byteLength);
20
+ copy.set(input);
21
+ return copy.buffer;
22
+ }
23
+ throw new TypeError(label + ': expected ArrayBuffer or Uint8Array, got ' +
24
+ (input === null ? 'null' : typeof input));
25
+ }