@zakkster/lite-bake-stream 1.2.0 → 1.3.1

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 CHANGED
@@ -2,6 +2,46 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
4
4
 
5
+ ## [1.3.1] -- 2026-09-01
6
+
7
+ M3 remainder (BS-05) -- the UTF-8 door ends the promise-A/behavior-B split with a measured decision. Option A (validate at the tokenizer, refuse invalid UTF-8 with `E_INVALID_UTF8`) was built, proven correctness-green (460/460, torture fast 42/42), and rejected on its pre-committed budget: unicode-heavy throughput regressed 229.7 -> 213.1 MB/s (-7.2%, over the 3% ceiling), because the baseline scan bulk-copies `>= 0x80` bytes with zero inspection so validation is pure added cost, while ASCII stayed within noise. SPEC 5.5 is instead rewritten to match the long-standing behavior (documented-permissive), and the dead code is removed. No bytes move at runtime. See decisions/0006-utf8-door.md for the before/after numbers and the rejected option. Suite: 453 tests / 453 pass / 0 fail / 0 todo. Torture: 42/42 fast, 44/44 full, arrayBuffers growth 0. Inverted mutation check: all 49 battery and pin tests pass on baseline src as well -- zero behavior change, proven.
8
+
9
+ ### Changed
10
+
11
+ - SPEC 5.5 and SPEC 7's U32 row rewritten to documented-permissive: string bytes between the quotes are stored byte-exact through U32 lanes regardless of UTF-8 validity; invalid sequences are not a parse error. `get()`/`getString()` decode with replacement (U+FFFD); `shardStringTable(s).bytesAt(i)` returns the exact stored bytes, the schema-mode twin of `PreserveReader.getBytes(i)`. This is a documentation-and-dead-code change only -- no runtime behavior changed, no new public surface (the accessor already existed).
12
+
13
+ ### Fixed
14
+
15
+ - BS-05: the SPEC no longer promises a UTF-8 parse error the tokenizer never enforced, and `Tokenizer.js` no longer declares an unreachable `E_INVALID_UTF8` code. The byte-exact string contract is now stated truthfully: invalid bytes round-trip byte-exact and decode with U+FFFD, in schema mode exactly as in preserve mode. Lone-surrogate escapes still refuse with `E_UNPAIRED_SURROGATE` (an escape-grammar rule, unrelated to input-UTF-8 validity).
16
+
17
+ ## [1.3.0] -- 2026-09-01
18
+
19
+ M2 -- a container is verified at the door or refused at the door. The four readers (`Reader`, `PreserveReader`, `RangeReader`, `MultiReader`) now agree with each other and with the `Writer` about the bytes between them; every malformed container names its refusal with a code, and a standalone SPEC byte-checker (reads SPEC.md offsets only, imports nothing from `src/`) is the contract's executable form. All new validation runs at open/parse/construction time -- the `get`/`syncRange`/`findShards` row loops and both tokenizer byte loops are byte-identical. Suite: 432 tests / 431 pass / 0 fail / 1 todo. Torture: 37/37 fast, 39/39 full, arrayBuffers growth 0. Mutation check: all 52 door tests fail on source revert; all 23 behavior-unchanged pins hold.
20
+
21
+ ### Added
22
+
23
+ - New stable refusal codes: `R_RESERVED_NONZERO` (a reserved header/descriptor/shard field is non-zero), `R_BAD_FOOTER` (footer `magic_end` or `footer_len` malformed), `R_BAD_METADATA` (a non-zero `metadata_off` pointing at an unparseable zone-map segment), and `M_TOO_MANY_ROWS` (a `MultiReader` union whose cumulative row count exceeds 2^53).
24
+ - `src/Views.js` (internal, not exported): a shared `toContainerBuffer` helper so `deserialize`, `Reader.fromBuffer`, and `PreserveReader.fromBuffer` resolve caller bytes by one rule -- a full-buffer view is unwrapped in place, a partial view is copied.
25
+
26
+ ### Changed
27
+
28
+ - Malformed containers that previously opened, half-opened, or failed later with a raw `RangeError` now refuse at the door with a code. Reserved bytes, footer shape, structural bounds (`row_count * row_stride <= payload_len`, `offset_in_row + lane_bytes <= row_stride`, name-blob and payload extents, monotonic string-table offsets with a `blob_length` sentinel, `total_rows == sum(row_count)`) are all checked at parse time. Every byte-prefix of a valid container either opens fully or throws an `R_`/`M_` code -- no raw `RangeError`, no clamped subarray, no silent short read.
29
+ - `findShards`/`shardBounds` with a field name absent from the schema now throw `R_UNKNOWN_FIELD` (`M_UNKNOWN_FIELD` on `MultiReader`) instead of silently returning all shards / `null`. A field present in the schema but untracked by zone maps still returns all shards / `null` (decisions/0005).
30
+ - F64-only shards no longer emit a 16-byte empty local string table -- such containers are `16 * shardCount` bytes smaller and their ShardEntries carry `local_string_off = 0` / `local_string_len = 0` (SPEC 3.4). `mergeContainers` applies the same rule to its output even when fed a pre-M2 container that violated it.
31
+ - `RangeReader.open` performs one additional 16-byte footer fetch (open-phase fetch count 5 -> 6 for a container with zone maps), so a wrong-footer or short container is refused before the schema fetch.
32
+ - `MultiReader` cumulative row/shard offsets moved `Uint32Array` -> `Float64Array`, so a union totaling more than 2^32 rows locates rows correctly instead of wrapping to `null`.
33
+
34
+ ### Fixed
35
+
36
+ - BS-06: a truncated preserve container no longer returns a clamped short `subarray`; the shard directory is validated at open.
37
+ - BS-08: reserved header/descriptor/shard bytes and the footer are now enforced; a lying `metadata_off` throws `R_BAD_METADATA` instead of silently degrading or throwing a raw `RangeError`.
38
+ - BS-09: `Reader.fromBuffer` now respects a `Uint8Array`'s `byteOffset`/`byteLength`, copying a partial view instead of reading a sibling's bytes from a pooled buffer.
39
+ - BS-10: `RangeReader.open` on a preserve-mode container throws `R_WRONG_MODE` naming `PreserveReader`, instead of misreading it as schema mode.
40
+ - BS-15: F64-only shards emit no local string table, matching SPEC 3.4's 0/0 rule.
41
+ - BS-16: `MultiReader` over sub-readers totaling more than 2^32 rows addresses every row exactly; beyond 2^53 it refuses with `M_TOO_MANY_ROWS`.
42
+ - BS-17: one unknown-field policy across `get`/`findShards`/`shardBounds` on all readers (decisions/0005).
43
+ - Sink-poison rider (M1 deferred): a sink callback that throws mid-`feed()`/`end()` now leaves the tokenizer poisoned; the next `feed()`/`end()` throws `E_POISONED`. Both tokenizers, via an armed-flag pattern with zero `try`/`catch` and two boolean stores per chunk.
44
+
5
45
  ## [1.2.0] -- 2026-08-31
6
46
 
7
47
  M1 -- the write path refuses what it cannot store. Eight findings closed by a set of doors that all run at construction, freeze, or terminal transition; the byte and record hot paths are unchanged (proven by the torture t6 gate, numbers within noise of M0). Suite: 359 tests / 358 pass / 0 fail / 1 todo. Torture: 37/37 fast, 39/39 full, arrayBuffers growth 0. Mutation check: all 63 door tests fail on source revert; all 13 behavior-unchanged pins hold.
package/README.md CHANGED
@@ -13,7 +13,7 @@
13
13
 
14
14
  > Streaming byte-level JSON compiler for [`@zakkster/lite-bake`](https://github.com/PeshoVurtoleta/lite-bake). Zero-GC, tree-shakeable, gigabyte-scale.
15
15
 
16
- **Status:** v1.2.0. LBK1 format frozen at `format_version: 1`. Qualified on an 8 GB soak with zero GC and byte-exact preservation across 590 million cells.
16
+ **Status:** v1.3.1. LBK1 format frozen at `format_version: 1`. Qualified on an 8 GB soak with zero GC and byte-exact preservation across 590 million cells.
17
17
 
18
18
  ## Two modes, one API
19
19
 
package/SPEC.md CHANGED
@@ -274,7 +274,7 @@ The boundary is exercised explicitly by `test/NumericBoundary.test.js`, which pi
274
274
 
275
275
  ### 5.5 String semantics (v1)
276
276
 
277
- - Input MUST be valid UTF-8. Invalid sequences are a parse error.
277
+ - String bytes between the quotes are stored byte-exact through U32 lanes, regardless of UTF-8 validity. Invalid sequences are **not** a parse error; the prior rule declaring them one (and its `E_INVALID_UTF8` code) is withdrawn. `get()` / `getString()` decode with replacement (U+FFFD); `shardStringTable(s).bytesAt(i)` returns the exact stored bytes, as `PreserveReader.getBytes(i)` does in preserve mode.
278
278
  - All standard JSON escapes are supported: `\"`, `\\`, `\/`, `\b`, `\f`, `\n`, `\r`, `\t`, `\uXXXX`.
279
279
  - Surrogate pairs (`\uD800`..`\uDBFF` followed by `\uDC00`..`\uDFFF`) are combined and emitted as UTF-8.
280
280
  - Unpaired surrogates are a parse error.
@@ -334,7 +334,7 @@ Given source NDJSON `S` and a schema `Σ` (either explicit or inferred), the LBK
334
334
  | :-- | :-- | :-- |
335
335
  | JSON number in fast-path domain (≤15 sig digits, `|exp| ≤ 22`) | F64 | **bit-exact IEEE 754 double** |
336
336
  | JSON number in slow-path domain | F64 | within 1 ULP of the correctly-rounded value |
337
- | JSON string (any valid UTF-8) | U32 | **byte-exact UTF-8 sequence** |
337
+ | JSON string (any bytes, valid UTF-8 or not) | U32 | **byte-exact bytes; validity not asserted** |
338
338
  | JSON `true` / `false` / `null` | F64 | `1` / `0` / `0` (documented coercion) |
339
339
  | absent field (missing in source) | F64 | `0` (default) |
340
340
  | absent field | U32 | `""` (index 0 is the reserved empty-string entry) |
package/llms.txt CHANGED
@@ -8,13 +8,13 @@ Ingest gigabyte-scale JSON (top-level array or NDJSON) into the `lite-bake` LBK1
8
8
 
9
9
  ## Status
10
10
 
11
- v1.2.0 — stable. LBK1 format frozen at `format_version: 1`. Qualified on an 8 GB soak (M1 MacBook Pro): 98.37M rows, 4.89 GB container, zero major GC, zero minor GC, 499 KB total heap allocation, 590.21M cells verified byte-exact, zero mismatches. Tokenizer benches at 222-237 MB/s (~55% of JSON.parse, with no object graph allocated).
11
+ v1.3.1 — stable. LBK1 format frozen at `format_version: 1`. Qualified on an 8 GB soak (M1 MacBook Pro): 98.37M rows, 4.89 GB container, zero major GC, zero minor GC, 499 KB total heap allocation, 590.21M cells verified byte-exact, zero mismatches. Tokenizer benches at 222-237 MB/s (~55% of JSON.parse, with no object graph allocated).
12
12
 
13
13
  Public API follows semver from 1.0.0. Future additions (I64 lane, columnar payload mode, container-level string table) land via the format's forward-compat seams -- `min_reader_version` on ShardEntry, reserved FieldDescriptor flags, the `metadata_off` block wrapper -- without a format_version bump.
14
14
 
15
15
  See SPEC.md for the LBK1 container format, section 3.6 for zone maps, section 4.3 for the reserved field flags.
16
16
 
17
- ## Public API (v1.2.0)
17
+ ## Public API (v1.3.1)
18
18
 
19
19
  Two ingest modes share one top-level API:
20
20
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zakkster/lite-bake-stream",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
4
4
  "description": "Streaming byte-level JSON to lite-bake binary compiler. Zero-GC, tree-shakeable, gigabyte-scale.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -109,6 +109,10 @@
109
109
  "engines": {
110
110
  "node": ">=18"
111
111
  },
112
+ "funding": {
113
+ "type": "github",
114
+ "url": "https://github.com/sponsors/PeshoVurtoleta"
115
+ },
112
116
  "publishConfig": {
113
117
  "access": "public"
114
118
  },
package/src/FileIngest.js CHANGED
@@ -27,7 +27,7 @@ import { PreserveWriter } from './PreserveWriter.js';
27
27
  import { PreserveReader } from './PreserveReader.js';
28
28
  import { checkOpts } from './Opts.js';
29
29
 
30
- export const VERSION = '1.2.0';
30
+ export const VERSION = '1.3.1';
31
31
 
32
32
  const U32_MAX = 4294967295;
33
33
  const INGEST_OPTS = {
@@ -21,10 +21,11 @@
21
21
  // Error codes:
22
22
  // M_EMPTY - no readers passed
23
23
  // M_SCHEMA_MISMATCH - schemas differ across readers
24
- // M_UNKNOWN_FIELD - .get() / .fieldIndex() called with unknown name
24
+ // M_UNKNOWN_FIELD - .get() / .fieldIndex() / findShards / shardBounds with unknown name
25
25
  // M_ROW_OUT_OF_RANGE - rowIdx >= totalRows
26
+ // M_TOO_MANY_ROWS - cumulative row count exceeds Number.MAX_SAFE_INTEGER
26
27
 
27
- export const VERSION = '1.2.0';
28
+ export const VERSION = '1.3.1';
28
29
 
29
30
  export class MultiReaderError extends Error {
30
31
  constructor(code, msg) { super(msg); this.code = code; this.name = 'MultiReaderError'; }
@@ -60,15 +61,26 @@ export class MultiReader {
60
61
 
61
62
  // Cumulative row offsets and shard offsets. Row rowIdx belongs to reader
62
63
  // r iff _rowOffsets[r] <= rowIdx < _rowOffsets[r+1].
64
+ // Float64Array (not Uint32Array): cumulative row/shard offsets can exceed
65
+ // 2^32 across many large sub-readers. F64 is exact to 2^53 and stays flat
66
+ // and monotonic, so the linear scan in _locateRow/_locateShard is unchanged
67
+ // apart from the element type (BS-16).
63
68
  let cumRows = 0, cumShards = 0;
64
- this._rowOffsets = new Uint32Array(readers.length + 1);
65
- this._shardOffsets = new Uint32Array(readers.length + 1);
69
+ this._rowOffsets = new Float64Array(readers.length + 1);
70
+ this._shardOffsets = new Float64Array(readers.length + 1);
66
71
  for (let i = 0; i < readers.length; i++) {
67
72
  this._rowOffsets[i] = cumRows;
68
73
  this._shardOffsets[i] = cumShards;
69
74
  cumRows += readers[i].totalRows;
70
75
  cumShards += readers[i].shardCount;
71
76
  }
77
+ // Boundary is 2^53 (BRIEF T6 / A8): total_rows == 2^53 keeps the largest
78
+ // row index at 2^53-1 (= MAX_SAFE_INTEGER), still exactly representable;
79
+ // beyond 2^53 an index collides with its neighbour in F64.
80
+ if (cumRows > 2 ** 53) {
81
+ throw new MultiReaderError('M_TOO_MANY_ROWS',
82
+ 'cumulative row count ' + cumRows + ' exceeds 2^53; row indices past 2^53 are not exactly addressable in F64');
83
+ }
72
84
  this._rowOffsets[readers.length] = cumRows;
73
85
  this._shardOffsets[readers.length] = cumShards;
74
86
  this._totalRows = cumRows;
@@ -129,6 +141,8 @@ export class MultiReader {
129
141
  // reader; results are concatenated and re-numbered into the global shard
130
142
  // index space.
131
143
  findShards(fieldName, opts) {
144
+ if (!this._fieldIndex.has(fieldName))
145
+ throw new MultiReaderError('M_UNKNOWN_FIELD', 'no field named ' + JSON.stringify(fieldName));
132
146
  const out = [];
133
147
  for (let r = 0; r < this._readers.length; r++) {
134
148
  const localShards = this._readers[r].findShards(fieldName, opts);
@@ -139,6 +153,8 @@ export class MultiReader {
139
153
  }
140
154
 
141
155
  shardBounds(globalShardIdx, fieldName) {
156
+ if (!this._fieldIndex.has(fieldName))
157
+ throw new MultiReaderError('M_UNKNOWN_FIELD', 'no field named ' + JSON.stringify(fieldName));
142
158
  const loc = this._locateShard(globalShardIdx);
143
159
  if (!loc) return null;
144
160
  return this._readers[loc.readerIdx].shardBounds(loc.localShard, fieldName);
@@ -16,11 +16,27 @@
16
16
  // The Uint8Array returned by getBytes is a live subarray view of the container
17
17
  // buffer. It is valid for as long as the Reader instance holds the container.
18
18
  // Do NOT mutate it, and do NOT retain it past the Reader's lifetime.
19
+ //
20
+ // Error codes:
21
+ // R_BAD_MAGIC - header magic mismatch
22
+ // R_UNSUPPORTED_VERSION - format_version > 1
23
+ // R_UNSUPPORTED_ENDIAN - endian byte is not LE
24
+ // R_WRONG_MODE - schema-mode container fed to the PreserveReader
25
+ // R_BAD_FLAGS - unknown header flag bits set
26
+ // R_TRUNCATED - a structure claims bytes past the buffer end
27
+ // R_RESERVED_NONZERO - a reserved header/shard field is non-zero
28
+ // R_BAD_FOOTER - footer magic_end or footer_len is malformed
29
+ // R_INVALID - a structure is internally inconsistent but in-bounds
30
+ // R_SHARD_VERSION_TOO_NEW - a shard's min_reader_version > this reader
31
+ // R_ROW_OUT_OF_RANGE - rowIdx >= totalRows
32
+
33
+ import { toContainerBuffer } from './Views.js';
19
34
 
20
- export const VERSION = '1.2.0';
35
+ export const VERSION = '1.3.1';
21
36
 
22
37
  const CONTAINER_HEADER_BYTES = 48;
23
38
  const SHARD_ENTRY_BYTES = 40;
39
+ const FOOTER_BYTES = 16;
24
40
 
25
41
  export class PreserveReaderError extends Error {
26
42
  constructor(code, msg) { super(msg); this.code = code; this.name = 'PreserveReaderError'; }
@@ -28,16 +44,7 @@ export class PreserveReaderError extends Error {
28
44
 
29
45
  export class PreserveReader {
30
46
  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');
47
+ return new PreserveReader(toContainerBuffer(input, 'PreserveReader.fromBuffer'));
41
48
  }
42
49
 
43
50
  constructor(buffer) {
@@ -49,6 +56,7 @@ export class PreserveReader {
49
56
  this._bytes = new Uint8Array(buffer);
50
57
  this._decoder = new TextDecoder('utf-8', { fatal: false });
51
58
  this._parseHeader();
59
+ this._parseFooter();
52
60
  this._parseShardDirectory();
53
61
  }
54
62
 
@@ -72,17 +80,40 @@ export class PreserveReader {
72
80
  if (flags & ~0x01) {
73
81
  throw new PreserveReaderError('R_BAD_FLAGS', 'unknown flag bits set: 0x' + flags.toString(16));
74
82
  }
83
+ const reserved1 = this._dv.getUint32(36, true);
84
+ if (reserved1 !== 0)
85
+ throw new PreserveReaderError('R_RESERVED_NONZERO', 'header reserved1 at offset 36 must be 0, got ' + reserved1);
75
86
 
76
87
  this._schemaBlockOff = Number(this._dv.getBigUint64(8, true));
77
88
  this._metadataOff = Number(this._dv.getBigUint64(16, true));
78
89
  this._shardDirOff = Number(this._dv.getBigUint64(24, true));
79
90
  this._shardCount = this._dv.getUint32(32, true);
80
- // 4 bytes reserved at 36
81
91
  this._totalRows = Number(this._dv.getBigUint64(40, true));
82
92
 
83
93
  if (this._schemaBlockOff !== 0) {
84
94
  throw new PreserveReaderError('R_INVALID', 'preserve container has non-zero schema_block_off');
85
95
  }
96
+ if (this._metadataOff !== 0) {
97
+ throw new PreserveReaderError('R_INVALID', 'preserve container has non-zero metadata_off (SPEC 3.8)');
98
+ }
99
+ const len = this._buffer.byteLength;
100
+ if (this._shardDirOff < CONTAINER_HEADER_BYTES || this._shardDirOff >= len)
101
+ throw new PreserveReaderError('R_TRUNCATED', 'shard_directory_off ' + this._shardDirOff + ' out of range [48, ' + len + ')');
102
+ }
103
+
104
+ // Footer (SPEC 3.7): magic_end '1KBL' + footer_len >= 16.
105
+ _parseFooter() {
106
+ const len = this._buffer.byteLength;
107
+ const footerOff = len - FOOTER_BYTES;
108
+ const b = this._bytes;
109
+ if (b[footerOff + 8] !== 0x31 || b[footerOff + 9] !== 0x4B ||
110
+ b[footerOff + 10] !== 0x42 || b[footerOff + 11] !== 0x4C)
111
+ throw new PreserveReaderError('R_BAD_FOOTER', 'footer magic_end at offset ' + (footerOff + 8) + ' is not 1KBL');
112
+ const footerLen = this._dv.getUint32(footerOff + 12, true);
113
+ if (footerLen < FOOTER_BYTES)
114
+ throw new PreserveReaderError('R_BAD_FOOTER', 'footer_len ' + footerLen + ' is less than the minimum ' + FOOTER_BYTES);
115
+ if (footerLen > len - CONTAINER_HEADER_BYTES)
116
+ throw new PreserveReaderError('R_BAD_FOOTER', 'footer_len ' + footerLen + ' exceeds the container body size');
86
117
  }
87
118
 
88
119
  _parseShardDirectory() {
@@ -100,21 +131,46 @@ export class PreserveReader {
100
131
  const payloadLen = this._dv.getUint32(entryOff + 8, true);
101
132
  const rowCount = this._dv.getUint32(entryOff + 12, true);
102
133
  const minReaderVer = this._dv.getUint16(entryOff + 16, true);
134
+ const shardFlags = this._dv.getUint16(entryOff + 18, true);
135
+ const shardReserved = this._dv.getUint32(entryOff + 20, true);
136
+ const localStrOff = Number(this._dv.getBigUint64(entryOff + 24, true));
137
+ const localStrLen = Number(this._dv.getBigUint64(entryOff + 32, true));
103
138
  if (minReaderVer > 1) {
104
139
  throw new PreserveReaderError('R_SHARD_VERSION_TOO_NEW',
105
140
  'shard ' + i + ' requires reader version ' + minReaderVer);
106
141
  }
142
+ if (shardFlags !== 0) throw new PreserveReaderError('R_RESERVED_NONZERO', 'shard ' + i + ' flags must be 0, got ' + shardFlags);
143
+ if (shardReserved !== 0) throw new PreserveReaderError('R_RESERVED_NONZERO', 'shard ' + i + ' reserved must be 0, got ' + shardReserved);
144
+ if (localStrOff !== 0 || localStrLen !== 0)
145
+ throw new PreserveReaderError('R_INVALID', 'shard ' + i + ' local_string_* must be 0 in preserve mode (SPEC 3.8)');
146
+ if (payloadOff < CONTAINER_HEADER_BYTES || payloadOff + payloadLen > this._buffer.byteLength)
147
+ throw new PreserveReaderError('R_TRUNCATED', 'shard ' + i + ' payload [' + payloadOff + ', ' + (payloadOff + payloadLen) + ') out of range');
107
148
  // Trailing u32 offset table.
108
149
  const offsetTableOff = payloadOff + payloadLen - rowCount * 4;
109
150
  if (offsetTableOff < payloadOff) {
110
151
  throw new PreserveReaderError('R_INVALID',
111
152
  'shard ' + i + ' payload_len (' + payloadLen + ') too small for offset table of ' + rowCount + ' rows');
112
153
  }
154
+ const blobLen = offsetTableOff - payloadOff;
155
+ // Offset-table walk: offsets[0] must be 0, non-decreasing, last <= blobLen.
156
+ if (rowCount > 0) {
157
+ let prev = this._dv.getUint32(offsetTableOff, true);
158
+ if (prev !== 0)
159
+ throw new PreserveReaderError('R_INVALID', 'shard ' + i + ' offset table[0] is ' + prev + ', must be 0');
160
+ for (let r = 1; r < rowCount; r++) {
161
+ const cur = this._dv.getUint32(offsetTableOff + r * 4, true);
162
+ if (cur < prev)
163
+ throw new PreserveReaderError('R_INVALID', 'shard ' + i + ' offset table not monotonic at record ' + r);
164
+ prev = cur;
165
+ }
166
+ if (prev > blobLen)
167
+ throw new PreserveReaderError('R_INVALID', 'shard ' + i + ' offset table last entry ' + prev + ' exceeds blob length ' + blobLen);
168
+ }
113
169
  shards[i] = {
114
170
  payloadOff,
115
171
  payloadLen,
116
172
  rowCount,
117
- blobLen: offsetTableOff - payloadOff,
173
+ blobLen,
118
174
  offsetTableOff,
119
175
  firstRow: cumulativeRow,
120
176
  endRow: cumulativeRow + rowCount,
@@ -32,7 +32,7 @@
32
32
 
33
33
  import { checkOpts } from './Opts.js';
34
34
 
35
- export const VERSION = '1.2.0';
35
+ export const VERSION = '1.3.1';
36
36
 
37
37
  const U32_MAX = 4294967295;
38
38
  const PRESERVE_TOKENIZER_OPTS = {
@@ -88,23 +88,28 @@ export class PreserveTokenizer {
88
88
  }
89
89
 
90
90
  feed(chunk) {
91
- if (this._poisoned) throw new PreserveTokenizerError('E_POISONED', 'tokenizer poisoned by a previous error; construct a new instance');
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
92
  if (this._ended) throw new PreserveTokenizerError('E_ENDED', 'tokenizer already ended; construct a new instance');
93
93
  if (!(chunk instanceof Uint8Array)) {
94
94
  throw new TypeError('PreserveTokenizer.feed: expected Uint8Array');
95
95
  }
96
- if (chunk.length === 0) return;
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; }
97
100
  this._ensureCapacity(this._bufLen + chunk.length);
98
101
  this._buf.set(chunk, this._bufLen);
99
102
  const scanStart = this._bufLen;
100
103
  this._bufLen += chunk.length;
101
104
  this._scan(scanStart);
102
105
  this._compact();
106
+ this._poisoned = false;
103
107
  }
104
108
 
105
109
  end() {
106
- if (this._poisoned) throw new PreserveTokenizerError('E_POISONED', 'tokenizer poisoned by a previous error; construct a new instance');
110
+ if (this._poisoned) throw new PreserveTokenizerError('E_POISONED', 'tokenizer poisoned by a previous error or an in-flight feed(); construct a new instance');
107
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
108
113
  if (this._bufLen > this._recordStart) {
109
114
  // There's pending content that never terminated.
110
115
  if (this._depth !== 0 || this._inString || this._escape) {
@@ -119,6 +124,7 @@ export class PreserveTokenizer {
119
124
  this._recordStart = this._bufLen;
120
125
  }
121
126
  this._ended = true;
127
+ this._poisoned = false; // clean end; a throwing onRecord above stays poisoned
122
128
  }
123
129
 
124
130
  _scan(from) {
@@ -18,7 +18,7 @@
18
18
 
19
19
  import { checkOpts } from './Opts.js';
20
20
 
21
- export const VERSION = '1.2.0';
21
+ export const VERSION = '1.3.1';
22
22
 
23
23
  export class PreserveWriterError extends Error {
24
24
  constructor(code, msg) { super(msg); this.code = code; this.name = 'PreserveWriterError'; }
@@ -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 (32 bytes) + schema block + shard directory.
13
- // That gives us the whole schema and every shard's byte extents without
14
- // touching a single row.
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,11 +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';
37
43
  import { checkOpts } from './Opts.js';
38
44
 
39
- export const VERSION = '1.2.0';
45
+ export const VERSION = '1.3.1';
40
46
 
41
47
  const CONTAINER_HEADER_BYTES = 48;
42
48
  const SHARD_ENTRY_BYTES = 40;
@@ -50,11 +56,40 @@ const U32_MAX = 4294967295;
50
56
  const RANGE_READER_OPTS = { maxCachedShards: { t: 'int', min: 0, max: U32_MAX } };
51
57
  const HTTP_ADAPTER_OPTS = { fetch: { t: 'fn' } };
52
58
 
59
+ const CONTAINER_FOOTER_BYTES = 16;
60
+
61
+ function laneBytesOf(k) { return k === LANE_F64 ? 8 : (k === LANE_U32 ? 4 : 0); }
62
+
53
63
  export class RangeReaderError extends Error {
54
64
  constructor(code, msg) { super(msg); this.code = code; this.name = 'RangeReaderError'; }
55
65
  }
56
66
  function raiseRange(code, msg) { throw new RangeReaderError(code, msg); }
57
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
+ }
92
+
58
93
  // ----- Adapters --------------------------------------------------------------
59
94
 
60
95
  // Uses fetch() with Range headers. Content-Length is discovered on open() via
@@ -169,7 +204,7 @@ export class RangeReader {
169
204
  throw new RangeReaderError('R_TRUNCATED', 'adapter size smaller than header');
170
205
  }
171
206
 
172
- // Step 1: header (32 bytes) tells us schema + shard-dir offsets.
207
+ // Step 1: header (48 bytes) tells us schema + shard-dir offsets.
173
208
  const headerBytes = await this.adapter.fetch(0, CONTAINER_HEADER_BYTES);
174
209
  const hdrDv = new DataView(headerBytes.buffer, headerBytes.byteOffset, headerBytes.byteLength);
175
210
  if (headerBytes[0] !== 0x4C || headerBytes[1] !== 0x42 || headerBytes[2] !== 0x4B || headerBytes[3] !== 0x31) {
@@ -179,21 +214,48 @@ export class RangeReader {
179
214
  if (version > 1) throw new RangeReaderError('R_UNSUPPORTED_VERSION', 'format_version=' + version);
180
215
  const endian = headerBytes[6];
181
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);
182
228
 
183
229
  this._schemaBlockOff = Number(hdrDv.getBigUint64(8, true));
184
230
  this._metadataOff = Number(hdrDv.getBigUint64(16, true));
185
231
  this._shardDirOff = Number(hdrDv.getBigUint64(24, true));
186
232
  this._shardCount = hdrDv.getUint32(32, true);
187
- // 4 bytes reserved at 36
188
233
  this._totalRows = Number(hdrDv.getBigUint64(40, true));
189
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
+
190
250
  // Step 2: schema block. We know its start but not its length; compute
191
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);
192
254
  const schemaBlockLen = this._shardDirOff - this._schemaBlockOff;
193
255
  const schemaBytes = await this.adapter.fetch(this._schemaBlockOff, schemaBlockLen);
194
256
  this._parseSchema(schemaBytes);
195
257
 
196
- // Step 3: shard directory (fixed size = shardCount * 32).
258
+ // Step 3: shard directory (fixed size = shardCount * 40).
197
259
  const dirBytes = await this.adapter.fetch(this._shardDirOff, this._shardCount * SHARD_ENTRY_BYTES);
198
260
  this._parseShardDirectory(dirBytes);
199
261
 
@@ -210,21 +272,43 @@ export class RangeReader {
210
272
  }
211
273
  }
212
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
+
213
288
  async _loadZoneMaps() {
214
- // 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');
215
294
  const hdr = await this.adapter.fetch(this._metadataOff, 16);
216
- if (hdr[0] !== 0x30 || hdr[1] !== 0x5A || hdr[2] !== 0x4D || hdr[3] !== 0x31) return;
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');
217
297
  const hdrDv = new DataView(hdr.buffer, hdr.byteOffset, hdr.byteLength);
218
298
  const shardCount = hdrDv.getUint32(4, true);
219
299
  const T = hdrDv.getUint32(8, true);
220
- if (shardCount !== this._shardCount) {
221
- throw new RangeReaderError('R_TRUNCATED', 'zone maps shard_count mismatch');
222
- }
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);
223
305
  if (T === 0) return;
224
306
  // Fetch the rest in one range: field table + pad + mins + maxes.
225
307
  const fieldTableLen = T * 2;
226
308
  const fieldTablePad = (8 - (fieldTableLen & 7)) & 7;
227
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');
228
312
  const rest = await this.adapter.fetch(this._metadataOff + 16, restLen);
229
313
  const restDv = new DataView(rest.buffer, rest.byteOffset, rest.byteLength);
230
314
  const tracked = new Array(T);
@@ -232,7 +316,7 @@ export class RangeReader {
232
316
  for (let t = 0; t < T; t++) {
233
317
  const schemaFieldIdx = restDv.getUint16(t * 2, true);
234
318
  if (schemaFieldIdx >= this._schema.fields.length) {
235
- throw new RangeReaderError('R_TRUNCATED', 'zone maps references field index ' + schemaFieldIdx);
319
+ throw new RangeReaderError('R_BAD_METADATA', 'zone maps references field index ' + schemaFieldIdx + ' past the schema');
236
320
  }
237
321
  tracked[t] = schemaFieldIdx;
238
322
  fieldToPos.set(this._schema.fields[schemaFieldIdx].name, t);
@@ -250,13 +334,20 @@ export class RangeReader {
250
334
 
251
335
  _parseSchema(bytes) {
252
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');
253
339
  const fieldCount = dv.getUint32(0, true);
254
340
  const rowStride = dv.getUint32(4, true);
341
+ if (rowStride < 1) throw new RangeReaderError('R_INVALID', 'schema row_stride is 0');
255
342
  const descOff = 8;
256
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');
257
346
  const nameBlobLenOff = descOff + descBytes;
258
347
  const nameBlobLen = dv.getUint32(nameBlobLenOff, true);
259
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');
260
351
  const decoder = new TextDecoder('utf-8');
261
352
  const fields = new Array(fieldCount);
262
353
  for (let i = 0; i < fieldCount; i++) {
@@ -265,10 +356,19 @@ export class RangeReader {
265
356
  const offsetInRow = dv.getUint16(off + 2, true);
266
357
  const laneKind = bytes[off + 4];
267
358
  const flags = bytes[off + 5];
359
+ const reserved2 = dv.getUint16(off + 6, true);
268
360
  const nameStrOff = Number(dv.getBigUint64(off + 8, true));
361
+ const reserved3 = dv.getBigUint64(off + 16, true);
269
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);
270
365
  if (laneKind !== LANE_F64 && laneKind !== LANE_U32)
271
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');
272
372
  const nb = bytes.subarray(nameBlobOff + nameStrOff, nameBlobOff + nameStrOff + nameLen);
273
373
  fields[i] = { name: decoder.decode(nb), laneKind, offsetInRow };
274
374
  }
@@ -278,6 +378,8 @@ export class RangeReader {
278
378
  _parseShardDirectory(bytes) {
279
379
  const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
280
380
  const shards = new Array(this._shardCount);
381
+ const size = this.adapter.size;
382
+ const rowStride = this._schema.rowStride;
281
383
  let cumulativeRow = 0;
282
384
  for (let i = 0; i < this._shardCount; i++) {
283
385
  const off = i * SHARD_ENTRY_BYTES;
@@ -285,12 +387,25 @@ export class RangeReader {
285
387
  const payloadLen = dv.getUint32(off + 8, true);
286
388
  const rowCount = dv.getUint32(off + 12, true);
287
389
  const minReaderVer = dv.getUint16(off + 16, true);
288
- // 2 bytes flags at 18, 4 bytes reserved at 20
390
+ const shardFlags = dv.getUint16(off + 18, true);
391
+ const shardReserved = dv.getUint32(off + 20, true);
289
392
  const localStrOff = Number(dv.getBigUint64(off + 24, true));
290
393
  const localStrLen = Number(dv.getBigUint64(off + 32, true));
291
394
  if (minReaderVer > READER_VERSION)
292
395
  throw new RangeReaderError('R_SHARD_VERSION_TOO_NEW',
293
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);
294
409
  shards[i] = {
295
410
  payloadOff, payloadLen, rowCount, localStrOff, localStrLen,
296
411
  firstRow: cumulativeRow,
@@ -298,6 +413,8 @@ export class RangeReader {
298
413
  };
299
414
  cumulativeRow += rowCount;
300
415
  }
416
+ if (cumulativeRow !== this._totalRows)
417
+ throw new RangeReaderError('R_INVALID', 'shard row_count sum ' + cumulativeRow + ' != header total_rows ' + this._totalRows);
301
418
  this._shards = shards;
302
419
  }
303
420
 
@@ -354,6 +471,7 @@ export class RangeReader {
354
471
  let stringTable = null;
355
472
  if (s.localStrLen > 0) {
356
473
  const stBytes = combined.subarray(s.payloadLen, s.payloadLen + s.localStrLen);
474
+ validateStringTable(stBytes, 0, s.localStrLen, 'shard ' + shardIdx);
357
475
  stringTable = StringTable.parse(stBytes, 0);
358
476
  }
359
477
  const record = {
@@ -448,6 +566,8 @@ export class RangeReader {
448
566
  // the container has no zone maps or the field is not tracked. Synchronous
449
567
  // -- zone maps were fetched during open().
450
568
  shardBounds(shardIdx, fieldName) {
569
+ if (!this._fieldIndex.has(fieldName))
570
+ throw new RangeReaderError('R_UNKNOWN_FIELD', 'no field named ' + JSON.stringify(fieldName));
451
571
  if (!this._zoneMapsTrackedFields) return null;
452
572
  const t = this._zoneMapsFieldToPos.get(fieldName);
453
573
  if (t === undefined) return null;
@@ -460,6 +580,8 @@ export class RangeReader {
460
580
  // When no zone maps are present or the field is not tracked, returns every
461
581
  // shard (query planner must fall back to full scan).
462
582
  findShards(fieldName, opts) {
583
+ if (!this._fieldIndex.has(fieldName))
584
+ throw new RangeReaderError('R_UNKNOWN_FIELD', 'no field named ' + JSON.stringify(fieldName));
463
585
  const all = () => { const a = new Array(this._shardCount); for (let i = 0; i < this._shardCount; i++) a[i] = i; return a; };
464
586
  if (!this._zoneMapsTrackedFields) return all();
465
587
  const t = this._zoneMapsFieldToPos.get(fieldName);
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.2.0';
28
+ export const VERSION = '1.3.1';
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
@@ -37,7 +37,7 @@ import { Reader, ReaderError } from './Reader.js';
37
37
  import { StringTable } from './StringTable.js';
38
38
  import { checkOpts } from './Opts.js';
39
39
 
40
- export const VERSION = '1.2.0';
40
+ export const VERSION = '1.3.1';
41
41
 
42
42
  const LF = 0x0A;
43
43
  const CONTAINER_HEADER_BYTES = 48;
@@ -45,6 +45,7 @@ const SHARD_ENTRY_BYTES = 40;
45
45
  const FIELD_DESCRIPTOR_BYTES = 24;
46
46
  const FOOTER_BYTES = 16;
47
47
  const LANE_F64 = 1;
48
+ const LANE_U32 = 3;
48
49
  const U32_MAX = 4294967295;
49
50
 
50
51
  const SPLIT_NDJSON_OPTS = {
@@ -201,6 +202,16 @@ export function mergeContainers(containers) {
201
202
  }
202
203
  }
203
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
+
204
215
  // Total shards and rows
205
216
  let totalShards = 0, totalRows = 0;
206
217
  for (const r of readers) { totalShards += r.shardCount; totalRows += r.totalRows; }
@@ -246,7 +257,7 @@ export function mergeContainers(containers) {
246
257
  // Source shard payload lives at shard.payloadOff in the source container.
247
258
  const payloadLen = shard.payloadLen;
248
259
  const payloadPad = (8 - (payloadLen & 7)) & 7;
249
- const stringLen = shard.stringTable ? _sourceStringTableLen(readers[ci], si) : 0;
260
+ const stringLen = (mergedHasU32 && shard.stringTable) ? _sourceStringTableLen(readers[ci], si) : 0;
250
261
  const outPayloadOff = cursor;
251
262
  const outStringOff = outPayloadOff + payloadLen + payloadPad;
252
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.2.0';
23
+ export const VERSION = '1.3.1';
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
@@ -10,7 +10,6 @@
10
10
  // E_INVALID_ESCAPE - unknown \ sequence
11
11
  // E_INVALID_HEX - non-hex digit in \uXXXX
12
12
  // E_UNPAIRED_SURROGATE - lone high/low surrogate in \u escape
13
- // E_INVALID_UTF8 - malformed UTF-8 byte sequence
14
13
  // E_NUMBER_OVERFLOW - number magnitude exceeds F64 range
15
14
  // E_NUMBER_INVALID - malformed number literal
16
15
  // E_KEYWORD_MISMATCH - true/false/null spelled wrong
@@ -24,7 +23,7 @@
24
23
 
25
24
  import { checkOpts } from './Opts.js';
26
25
 
27
- export const VERSION = '1.2.0';
26
+ export const VERSION = '1.3.1';
28
27
 
29
28
  const U32_MAX = 4294967295;
30
29
  const TOKENIZER_OPTS = {
@@ -181,8 +180,12 @@ export class Tokenizer {
181
180
 
182
181
  // Feed a chunk. Bytes are consumed synchronously; sink events fire during this call.
183
182
  feed(chunk) {
184
- if (this._poisoned) throw new TokenizerError('E_POISONED', this._absOffset, 'tokenizer poisoned by a previous error; construct a new instance');
183
+ 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
184
  if (this._ended) throw new TokenizerError('E_ENDED', this._absOffset, 'tokenizer already ended; construct a new instance');
185
+ // D3 armed-flag: a sink throw mid-feed unwinds through here with the flag
186
+ // set, poisoning the instance. Cleared on the successful fall-through below.
187
+ // Two boolean stores per chunk; the byte loop is untouched.
188
+ this._poisoned = true;
186
189
  const len = chunk.length;
187
190
  let i = 0;
188
191
  while (i < len) {
@@ -342,11 +345,13 @@ export class Tokenizer {
342
345
  // unreachable
343
346
  this._err('E_UNEXPECTED_BYTE', 'internal: unknown state ' + st);
344
347
  }
348
+ this._poisoned = false; // D3: clean fall-through un-arms the flag
345
349
  }
346
350
 
347
351
  end() {
348
- if (this._poisoned) throw new TokenizerError('E_POISONED', this._absOffset, 'tokenizer poisoned by a previous error; construct a new instance');
352
+ 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');
349
353
  if (this._ended) throw new TokenizerError('E_ENDED', this._absOffset, 'tokenizer already ended; construct a new instance');
354
+ this._poisoned = true; // D3: armed until a clean exit below
350
355
  // A trailing number may still be pending
351
356
  if (this._state === S_NUMBER) {
352
357
  this._emitNumber();
@@ -357,6 +362,7 @@ export class Tokenizer {
357
362
  }
358
363
  this._ended = true;
359
364
  this._onEnd();
365
+ this._poisoned = false; // clear AFTER _onEnd(): a throwing onEnd stays poisoned
360
366
  return;
361
367
  }
362
368
  this._err('E_UNEXPECTED_EOF', 'input ended mid-token (state=' + this._state + ')');
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
+ }
package/src/Writer.js CHANGED
@@ -36,7 +36,7 @@
36
36
  import { StringTable } from './StringTable.js';
37
37
  import { checkOpts } from './Opts.js';
38
38
 
39
- export const VERSION = '1.2.0';
39
+ export const VERSION = '1.3.1';
40
40
 
41
41
  const U32_MAX = 4294967295;
42
42
  // Post-finalize sentinel for _recordDepth. Chosen = 2 so every post-finalize
@@ -64,6 +64,11 @@ const FIELD_FLAGS_NONE = 0;
64
64
  const SHARD_MIN_READER_VERSION = 1;
65
65
  const SHARD_FLAGS_NONE = 0;
66
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
+
67
72
  const FORMAT_VERSION = 1;
68
73
  const ENDIAN_LE = 1;
69
74
 
@@ -359,6 +364,12 @@ export class Writer {
359
364
 
360
365
  _finalizeSchema(fields, rowStride) {
361
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
+ }
362
373
 
363
374
  const enc = new TextEncoder();
364
375
  this._fieldNamesUtf8 = new Array(fields.length);
@@ -492,9 +503,11 @@ export class Writer {
492
503
  const usedBytes = this._currentShardRowCount * this._schema.rowStride;
493
504
  const copy = new Uint8Array(usedBytes);
494
505
  copy.set(this._currentShardBytes.subarray(0, usedBytes));
495
- // 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.
496
509
  const st = this._perShardStringTable || this._stringTable;
497
- const stSer = st.serialize();
510
+ const stBytes = this._hasU32 ? st.serialize().bytes : EMPTY_STRING_TABLE_BYTES;
498
511
  // Snapshot zone-map bounds for this shard. Copy so subsequent shards can
499
512
  // reuse _currentShardMins/Maxes without clobbering the pushed record.
500
513
  const T = this._trackedFieldIndices.length;
@@ -505,7 +518,7 @@ export class Writer {
505
518
  this._shards.push({
506
519
  bytes: copy,
507
520
  rowCount: this._currentShardRowCount,
508
- stringTableBytes: stSer.bytes,
521
+ stringTableBytes: stBytes,
509
522
  mins: shardMins,
510
523
  maxes: shardMaxes,
511
524
  });
@@ -721,7 +734,7 @@ export class Writer {
721
734
  dv.setUint16(entryOff + 16, SHARD_MIN_READER_VERSION, true);
722
735
  dv.setUint16(entryOff + 18, SHARD_FLAGS_NONE, true);
723
736
  dv.setUint32(entryOff + 20, 0, true); // reserved
724
- dv.setBigUint64(entryOff + 24, BigInt(shardStringTableOffsets[i]), true);
737
+ dv.setBigUint64(entryOff + 24, BigInt(shardStringTableLens[i] > 0 ? shardStringTableOffsets[i] : 0), true);
725
738
  dv.setBigUint64(entryOff + 32, BigInt(shardStringTableLens[i]), true);
726
739
  }
727
740
 
package/src/index.js CHANGED
@@ -17,6 +17,7 @@ import { PreserveTokenizer, PreserveTokenizerError } from './PreserveTokenizer.j
17
17
  import { PreserveWriter, PreserveWriterError } from './PreserveWriter.js';
18
18
  import { PreserveReader, PreserveReaderError } from './PreserveReader.js';
19
19
  import { checkOpts } from './Opts.js';
20
+ import { toContainerBuffer } from './Views.js';
20
21
 
21
22
  export {
22
23
  Tokenizer, TokenizerError,
@@ -27,7 +28,7 @@ export {
27
28
  PreserveWriter, PreserveWriterError,
28
29
  PreserveReader, PreserveReaderError,
29
30
  };
30
- export const VERSION = '1.2.0';
31
+ export const VERSION = '1.3.1';
31
32
 
32
33
  const encoder = new TextEncoder();
33
34
 
@@ -188,20 +189,7 @@ async function _serializeAsyncIterablePreserve(iterable, opts) {
188
189
  // Deserialize LBK1 bytes into the right Reader for the container's mode.
189
190
  // Auto-detects preserve vs schema via header flag byte at offset 7 bit 0.
190
191
  export function deserialize(bytes) {
191
- let buffer;
192
- if (bytes instanceof ArrayBuffer) {
193
- buffer = bytes;
194
- } else if (bytes instanceof Uint8Array) {
195
- if (bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength) {
196
- buffer = bytes.buffer;
197
- } else {
198
- const copy = new Uint8Array(bytes.byteLength);
199
- copy.set(bytes);
200
- buffer = copy.buffer;
201
- }
202
- } else {
203
- throw new TypeError('deserialize: expected Uint8Array or ArrayBuffer, got ' + typeof bytes);
204
- }
192
+ const buffer = toContainerBuffer(bytes, 'deserialize');
205
193
  if (buffer.byteLength < 8) {
206
194
  throw new ReaderError('R_TRUNCATED', 'container too small to inspect header flags');
207
195
  }