@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/Writer.js CHANGED
@@ -26,10 +26,31 @@
26
26
  // W_MIXED_LANE_TYPES - a field saw both number and string values
27
27
  // W_LANE_MISMATCH - post-freeze: value type doesn't match schema lane
28
28
  // W_UNKNOWN_LANE_KIND - explicit schema declares an unknown laneKind
29
+ // W_DUPLICATE_FIELD - schema declares the same field name twice
30
+ // W_SCHEMA_TOO_WIDE - a field offset exceeds the u16 offset_in_row ceiling
31
+ // W_FIELD_NAME_INVALID - field name empty, non-string, or > 255 UTF-8 bytes
32
+ // W_FINALIZED - a sink event or finalize() after finalize()
33
+ // E_UNKNOWN_OPTION - unknown constructor option key
34
+ // E_OPTION_VALUE - constructor option value out of domain
29
35
 
30
36
  import { StringTable } from './StringTable.js';
31
-
32
- export const VERSION = '1.1.0';
37
+ import { checkOpts } from './Opts.js';
38
+
39
+ export const VERSION = '1.3.0';
40
+
41
+ const U32_MAX = 4294967295;
42
+ // Post-finalize sentinel for _recordDepth. Chosen = 2 so every post-finalize
43
+ // sink event lands in an existing cold arm (onNumber/onString depth != 1,
44
+ // onStartObject depth > 1) without adding a per-record compare on the hot path.
45
+ // It is NOT a real nesting depth: onEndObject is unreachable post-finalize
46
+ // because onStartObject throws W_FINALIZED first.
47
+ const FINALIZED_DEPTH = 2;
48
+ const WRITER_OPTS = {
49
+ schema: { t: 'obj', nullable: true },
50
+ targetShardBytes: { t: 'int', min: 1, max: U32_MAX },
51
+ sampleBytes: { t: 'int', min: 0, max: U32_MAX },
52
+ };
53
+ function raiseWriter(code, msg) { throw new WriterError(code, msg); }
33
54
 
34
55
  const CONTAINER_HEADER_BYTES = 48;
35
56
  const SHARD_ENTRY_BYTES = 40;
@@ -43,6 +64,11 @@ const FIELD_FLAGS_NONE = 0;
43
64
  const SHARD_MIN_READER_VERSION = 1;
44
65
  const SHARD_FLAGS_NONE = 0;
45
66
 
67
+ // Shared empty view for F64-only shards, which emit NO local string table
68
+ // (SPEC 3.4: local_string_off/local_string_len are 0 when a shard has no
69
+ // U32-lane fields). Read-only; never written into.
70
+ const EMPTY_STRING_TABLE_BYTES = new Uint8Array(0);
71
+
46
72
  const FORMAT_VERSION = 1;
47
73
  const ENDIAN_LE = 1;
48
74
 
@@ -157,10 +183,11 @@ class SampleBuffer {
157
183
 
158
184
  export class Writer {
159
185
  constructor(opts) {
186
+ checkOpts('Writer', opts, WRITER_OPTS, raiseWriter);
160
187
  opts = opts || {};
161
- this._targetShardBytes = opts.targetShardBytes || DEFAULT_SHARD_BYTES;
162
- this._sampleBytes = opts.sampleBytes || this._targetShardBytes;
163
- this._explicitSchema = opts.schema || null;
188
+ this._targetShardBytes = opts.targetShardBytes !== undefined ? opts.targetShardBytes : DEFAULT_SHARD_BYTES;
189
+ this._sampleBytes = opts.sampleBytes !== undefined ? opts.sampleBytes : this._targetShardBytes;
190
+ this._explicitSchema = opts.schema !== undefined ? opts.schema : null;
164
191
 
165
192
  // Frozen schema state
166
193
  this._schema = null; // { fields: [{name, laneKind, offsetInRow}], rowStride }
@@ -201,7 +228,10 @@ export class Writer {
201
228
 
202
229
  onStartObject() {
203
230
  this._recordDepth++;
204
- if (this._recordDepth > 1) throw new WriterError('W_NESTED_UNSUPPORTED', 'nested object inside record');
231
+ if (this._recordDepth > 1) {
232
+ if (this._finalized) throw new WriterError('W_FINALIZED', 'writer already finalized');
233
+ throw new WriterError('W_NESTED_UNSUPPORTED', 'nested object inside record');
234
+ }
205
235
  if (this._schema) {
206
236
  // clear scratch slots
207
237
  const f = this._rowValueSlotsF64, u = this._rowValueSlotsU32;
@@ -216,7 +246,8 @@ export class Writer {
216
246
  }
217
247
 
218
248
  onStartArray() {
219
- if (this._recordDepth === 0) throw new WriterError('W_TOP_LEVEL_NOT_OBJECT', 'top-level array (in-record) not supported');
249
+ if (this._finalized) throw new WriterError('W_FINALIZED', 'writer already finalized');
250
+ if (this._recordDepth === 0) throw new WriterError('W_TOP_LEVEL_NOT_OBJECT', 'top-level array is not an object; LBK1 schema mode stores objects only (SPEC 5.1)');
220
251
  throw new WriterError('W_NESTED_UNSUPPORTED', 'nested array inside record');
221
252
  }
222
253
 
@@ -236,7 +267,10 @@ export class Writer {
236
267
  }
237
268
 
238
269
  onNumber(v) {
239
- if (this._recordDepth !== 1) return;
270
+ if (this._recordDepth !== 1) {
271
+ if (this._finalized) throw new WriterError('W_FINALIZED', 'writer already finalized');
272
+ throw new WriterError('W_TOP_LEVEL_NOT_OBJECT', 'top-level number is not an object; LBK1 schema mode stores objects only (SPEC 5.1)');
273
+ }
240
274
  if (this._schema) {
241
275
  const idx = this._currentFieldIdx;
242
276
  if (idx < 0) return;
@@ -255,7 +289,10 @@ export class Writer {
255
289
  onNull() { this.onNumber(0); }
256
290
 
257
291
  onString(bytes, from, to) {
258
- if (this._recordDepth === 0) return;
292
+ if (this._recordDepth !== 1) {
293
+ if (this._finalized) throw new WriterError('W_FINALIZED', 'writer already finalized');
294
+ throw new WriterError('W_TOP_LEVEL_NOT_OBJECT', 'top-level string is not an object; LBK1 schema mode stores objects only (SPEC 5.1)');
295
+ }
259
296
  if (this._schema) {
260
297
  const idx = this._currentFieldIdx;
261
298
  if (idx < 0) return;
@@ -327,14 +364,45 @@ export class Writer {
327
364
 
328
365
  _finalizeSchema(fields, rowStride) {
329
366
  this._schema = { fields, rowStride };
367
+ // BS-15: a shard with no U32-lane field emits NO local string table.
368
+ // Computed once at freeze, consulted per shard at finalize / assembly.
369
+ this._hasU32 = false;
370
+ for (let i = 0; i < fields.length; i++) {
371
+ if (fields[i].laneKind === LANE_U32) { this._hasU32 = true; break; }
372
+ }
330
373
 
331
374
  const enc = new TextEncoder();
332
375
  this._fieldNamesUtf8 = new Array(fields.length);
333
376
  this._fieldNameHashes = new Uint32Array(fields.length);
334
377
  this._fieldLaneKinds = new Uint8Array(fields.length);
335
378
  this._fieldOffsets = new Uint16Array(fields.length);
379
+ // Freeze-time schema validation (BS-02/BS-03): runs once per schema on both
380
+ // freeze paths (explicit + sample-drain funnel here), never per record.
381
+ const seenNames = new Map();
336
382
  for (let i = 0; i < fields.length; i++) {
337
- const b = enc.encode(fields[i].name);
383
+ const name = fields[i].name;
384
+ if (typeof name !== 'string' || name.length === 0) {
385
+ throw new WriterError('W_FIELD_NAME_INVALID',
386
+ 'field name at index ' + i + ' must be a non-empty string of at most 255 UTF-8 bytes; got ' +
387
+ (typeof name === 'string' ? '0 bytes' : typeof name));
388
+ }
389
+ const b = enc.encode(name);
390
+ if (b.length > 255) {
391
+ throw new WriterError('W_FIELD_NAME_INVALID',
392
+ 'field name at index ' + i + ' must be a non-empty string of at most 255 UTF-8 bytes; got ' +
393
+ b.length + ' bytes');
394
+ }
395
+ const first = seenNames.get(name);
396
+ if (first !== undefined) {
397
+ throw new WriterError('W_DUPLICATE_FIELD',
398
+ "duplicate field name '" + name + "' at index " + i + ' (first declared at index ' + first + ')');
399
+ }
400
+ seenNames.set(name, i);
401
+ if (fields[i].offsetInRow > 65535) {
402
+ throw new WriterError('W_SCHEMA_TOO_WIDE',
403
+ "field '" + name + "' at offset " + fields[i].offsetInRow +
404
+ ' exceeds the u16 offset_in_row ceiling (max 65535)');
405
+ }
338
406
  this._fieldNamesUtf8[i] = b;
339
407
  this._fieldNameHashes[i] = hashBytes(b, 0, b.length);
340
408
  this._fieldLaneKinds[i] = fields[i].laneKind;
@@ -435,9 +503,11 @@ export class Writer {
435
503
  const usedBytes = this._currentShardRowCount * this._schema.rowStride;
436
504
  const copy = new Uint8Array(usedBytes);
437
505
  copy.set(this._currentShardBytes.subarray(0, usedBytes));
438
- // Serialize the current shard's string table (empty for F64-only shards).
506
+ // Serialize the current shard's string table. A shard whose schema has no
507
+ // U32 lane emits NO table at all (BS-15 / SPEC 3.4): zero bytes here means
508
+ // the ShardEntry carries local_string_off = 0 / local_string_len = 0.
439
509
  const st = this._perShardStringTable || this._stringTable;
440
- const stSer = st.serialize();
510
+ const stBytes = this._hasU32 ? st.serialize().bytes : EMPTY_STRING_TABLE_BYTES;
441
511
  // Snapshot zone-map bounds for this shard. Copy so subsequent shards can
442
512
  // reuse _currentShardMins/Maxes without clobbering the pushed record.
443
513
  const T = this._trackedFieldIndices.length;
@@ -448,7 +518,7 @@ export class Writer {
448
518
  this._shards.push({
449
519
  bytes: copy,
450
520
  rowCount: this._currentShardRowCount,
451
- stringTableBytes: stSer.bytes,
521
+ stringTableBytes: stBytes,
452
522
  mins: shardMins,
453
523
  maxes: shardMaxes,
454
524
  });
@@ -526,7 +596,7 @@ export class Writer {
526
596
  }
527
597
 
528
598
  finalize() {
529
- if (this._finalized) return this._container;
599
+ if (this._finalized) throw new WriterError('W_FINALIZED', 'writer already finalized');
530
600
  if (!this._schema) {
531
601
  if (this._sample.rowCount === 0) throw new WriterError('W_EMPTY_INPUT', 'no records to write');
532
602
  this._freezeSchemaFromSample();
@@ -536,6 +606,8 @@ export class Writer {
536
606
  if (this._shards.length === 0) throw new WriterError('W_EMPTY_INPUT', 'no records to write');
537
607
  this._container = this._assembleContainer();
538
608
  this._finalized = true;
609
+ // Post-finalize sentinel: route every later sink event into a cold arm.
610
+ this._recordDepth = FINALIZED_DEPTH;
539
611
  return this._container;
540
612
  }
541
613
 
@@ -662,7 +734,7 @@ export class Writer {
662
734
  dv.setUint16(entryOff + 16, SHARD_MIN_READER_VERSION, true);
663
735
  dv.setUint16(entryOff + 18, SHARD_FLAGS_NONE, true);
664
736
  dv.setUint32(entryOff + 20, 0, true); // reserved
665
- dv.setBigUint64(entryOff + 24, BigInt(shardStringTableOffsets[i]), true);
737
+ dv.setBigUint64(entryOff + 24, BigInt(shardStringTableLens[i] > 0 ? shardStringTableOffsets[i] : 0), true);
666
738
  dv.setBigUint64(entryOff + 32, BigInt(shardStringTableLens[i]), true);
667
739
  }
668
740
 
package/src/index.js CHANGED
@@ -16,6 +16,8 @@ import { StringTable } from './StringTable.js';
16
16
  import { PreserveTokenizer, PreserveTokenizerError } from './PreserveTokenizer.js';
17
17
  import { PreserveWriter, PreserveWriterError } from './PreserveWriter.js';
18
18
  import { PreserveReader, PreserveReaderError } from './PreserveReader.js';
19
+ import { checkOpts } from './Opts.js';
20
+ import { toContainerBuffer } from './Views.js';
19
21
 
20
22
  export {
21
23
  Tokenizer, TokenizerError,
@@ -26,10 +28,17 @@ export {
26
28
  PreserveWriter, PreserveWriterError,
27
29
  PreserveReader, PreserveReaderError,
28
30
  };
29
- export const VERSION = '1.1.0';
31
+ export const VERSION = '1.3.0';
30
32
 
31
33
  const encoder = new TextEncoder();
32
34
 
35
+ const SERIALIZE_OPTS = {
36
+ preserve: { t: 'bool' },
37
+ framing: { t: 'enum', values: ['auto', 'array', 'ndjson'] },
38
+ writer: { t: 'obj' },
39
+ };
40
+ function raiseSerialize(code, msg) { throw new WriterError(code, msg); }
41
+
33
42
  // Serialize input data into an LBK1 container. Input may be:
34
43
  // - Uint8Array (raw NDJSON bytes)
35
44
  // - string (NDJSON text)
@@ -43,22 +52,32 @@ const encoder = new TextEncoder();
43
52
  // writer: writer options. Schema mode: { schema, targetShardBytes, sampleBytes }.
44
53
  // Preserve mode: { targetShardBytes, maxRecordBytes }.
45
54
  export function serialize(input, opts) {
55
+ // Validate synchronously in the prologue so a typo throws before any async
56
+ // dispatch (R7): E_UNKNOWN_OPTION must surface as a throw, never a rejected
57
+ // promise, for ReadableStream / AsyncIterable inputs too.
58
+ checkOpts('serialize', opts, SERIALIZE_OPTS, raiseSerialize);
46
59
  opts = opts || {};
47
60
  const preserve = opts.preserve === true;
61
+ const framing = opts.framing !== undefined ? opts.framing : 'ndjson';
62
+ const writerOpts = opts.writer !== undefined ? opts.writer : {};
63
+ if (preserve && opts.framing !== undefined && opts.framing !== 'ndjson') {
64
+ throw new WriterError('E_OPTION_CONFLICT',
65
+ "serialize: preserve mode is NDJSON-only; remove framing:'" + opts.framing + "' or set preserve:false");
66
+ }
48
67
 
49
68
  if (input && typeof input.getReader === 'function') {
50
69
  return preserve
51
70
  ? _serializeReadableStreamPreserve(input, opts)
52
- : _serializeReadableStream(input, opts.writer || {}, opts.framing || 'ndjson');
71
+ : _serializeReadableStream(input, writerOpts, framing);
53
72
  }
54
73
  if (input && typeof input[Symbol.asyncIterator] === 'function') {
55
74
  return preserve
56
75
  ? _serializeAsyncIterablePreserve(input, opts)
57
- : _serializeAsyncIterable(input, opts.writer || {}, opts.framing || 'ndjson');
76
+ : _serializeAsyncIterable(input, writerOpts, framing);
58
77
  }
59
78
 
60
79
  if (preserve) return _serializeSyncPreserve(input, opts);
61
- return _serializeSyncSchema(input, opts.writer || {}, opts.framing || 'ndjson');
80
+ return _serializeSyncSchema(input, writerOpts, framing);
62
81
  }
63
82
 
64
83
  function _serializeSyncSchema(input, writerOpts, framing) {
@@ -83,10 +102,10 @@ function _serializeSyncSchema(input, writerOpts, framing) {
83
102
  }
84
103
 
85
104
  function _serializeSyncPreserve(input, opts) {
86
- const w = new PreserveWriter(opts.writer || {});
105
+ const w = new PreserveWriter(opts.writer !== undefined ? opts.writer : {});
87
106
  const t = new PreserveTokenizer(w, {
88
107
  framing: 'ndjson',
89
- maxRecordBytes: (opts.writer && opts.writer.maxRecordBytes) || 0,
108
+ maxRecordBytes: (opts.writer !== undefined && opts.writer.maxRecordBytes !== undefined) ? opts.writer.maxRecordBytes : 0,
90
109
  });
91
110
  if (input instanceof Uint8Array) {
92
111
  t.feed(input);
@@ -124,10 +143,10 @@ async function _serializeReadableStream(stream, writerOpts, framing) {
124
143
  }
125
144
 
126
145
  async function _serializeReadableStreamPreserve(stream, opts) {
127
- const w = new PreserveWriter(opts.writer || {});
146
+ const w = new PreserveWriter(opts.writer !== undefined ? opts.writer : {});
128
147
  const t = new PreserveTokenizer(w, {
129
148
  framing: 'ndjson',
130
- maxRecordBytes: (opts.writer && opts.writer.maxRecordBytes) || 0,
149
+ maxRecordBytes: (opts.writer !== undefined && opts.writer.maxRecordBytes !== undefined) ? opts.writer.maxRecordBytes : 0,
131
150
  });
132
151
  const reader = stream.getReader();
133
152
  try {
@@ -154,10 +173,10 @@ async function _serializeAsyncIterable(iterable, writerOpts, framing) {
154
173
  }
155
174
 
156
175
  async function _serializeAsyncIterablePreserve(iterable, opts) {
157
- const w = new PreserveWriter(opts.writer || {});
176
+ const w = new PreserveWriter(opts.writer !== undefined ? opts.writer : {});
158
177
  const t = new PreserveTokenizer(w, {
159
178
  framing: 'ndjson',
160
- maxRecordBytes: (opts.writer && opts.writer.maxRecordBytes) || 0,
179
+ maxRecordBytes: (opts.writer !== undefined && opts.writer.maxRecordBytes !== undefined) ? opts.writer.maxRecordBytes : 0,
161
180
  });
162
181
  for await (const chunk of iterable) {
163
182
  if (!(chunk instanceof Uint8Array)) throw new TypeError('serialize: async iterable must yield Uint8Array');
@@ -170,20 +189,7 @@ async function _serializeAsyncIterablePreserve(iterable, opts) {
170
189
  // Deserialize LBK1 bytes into the right Reader for the container's mode.
171
190
  // Auto-detects preserve vs schema via header flag byte at offset 7 bit 0.
172
191
  export function deserialize(bytes) {
173
- let buffer;
174
- if (bytes instanceof ArrayBuffer) {
175
- buffer = bytes;
176
- } else if (bytes instanceof Uint8Array) {
177
- if (bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength) {
178
- buffer = bytes.buffer;
179
- } else {
180
- const copy = new Uint8Array(bytes.byteLength);
181
- copy.set(bytes);
182
- buffer = copy.buffer;
183
- }
184
- } else {
185
- throw new TypeError('deserialize: expected Uint8Array or ArrayBuffer, got ' + typeof bytes);
186
- }
192
+ const buffer = toContainerBuffer(bytes, 'deserialize');
187
193
  if (buffer.byteLength < 8) {
188
194
  throw new ReaderError('R_TRUNCATED', 'container too small to inspect header flags');
189
195
  }