@zakkster/lite-bake-stream 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,64 @@
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.0] -- 2026-09-01
6
+
7
+ 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.
8
+
9
+ ### Added
10
+
11
+ - 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).
12
+ - `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.
13
+
14
+ ### Changed
15
+
16
+ - 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.
17
+ - `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).
18
+ - 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.
19
+ - `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.
20
+ - `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`.
21
+
22
+ ### Fixed
23
+
24
+ - BS-06: a truncated preserve container no longer returns a clamped short `subarray`; the shard directory is validated at open.
25
+ - 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`.
26
+ - 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.
27
+ - BS-10: `RangeReader.open` on a preserve-mode container throws `R_WRONG_MODE` naming `PreserveReader`, instead of misreading it as schema mode.
28
+ - BS-15: F64-only shards emit no local string table, matching SPEC 3.4's 0/0 rule.
29
+ - 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`.
30
+ - BS-17: one unknown-field policy across `get`/`findShards`/`shardBounds` on all readers (decisions/0005).
31
+ - 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.
32
+
33
+ ## [1.2.0] -- 2026-08-31
34
+
35
+ 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.
36
+
37
+ ### Added
38
+
39
+ - `src/Opts.js` (internal, not exported): a shared construction-time options validator wired into `Tokenizer`, `Writer`, `PreserveTokenizer`, `PreserveWriter`, `serialize`, `ingestStream`/`ingestFile`, `RangeReader`, `HTTPRangeAdapter.open`, and the `./split` subpath (`splitNDJSON`, `compilePart`, `compileInParts`). Unknown keys throw with a did-you-mean hint (Levenshtein distance <= 2); out-of-domain values throw naming the key, the received value, and the domain.
40
+ - New stable error codes: `E_UNKNOWN_OPTION`, `E_OPTION_VALUE`, `E_OPTION_CONFLICT` (options); `W_DUPLICATE_FIELD`, `W_SCHEMA_TOO_WIDE`, `W_FIELD_NAME_INVALID` (schema freeze); `W_FINALIZED` on the schema `Writer` (already present on `PreserveWriter`); `E_ENDED`, `E_POISONED` (tokenizer terminal state); `E_STRING_TOO_LONG` (maxStringBytes cap).
41
+
42
+ ### Changed
43
+
44
+ - Unknown or typo'd option keys now throw `E_UNKNOWN_OPTION` instead of being silently ignored.
45
+ - Explicit `0` now means 0 for `sampleBytes` (freeze from the first record), `maxCachedShards` (retain no shard), and `maxRecordBytes` (unlimited) instead of silently becoming the default. `null` is out-of-domain and throws (null is not zero).
46
+ - `Writer.finalize()` called twice now throws `W_FINALIZED` instead of returning the stale container.
47
+ - Over-cap strings now throw the dedicated `E_STRING_TOO_LONG` instead of the wrong `E_UNEXPECTED_BYTE`, and the cap is enforced exactly (no longer only when the 4096-byte buffer grows).
48
+ - `Tokenizer` no longer writes NOOP methods into the caller's sink object; a frozen sink now constructs and drives a full feed/end.
49
+ - `splitNDJSON`/`compileInParts`: an explicit `targetParts:0` or `maxPartBytes:0` now throws instead of silently becoming the default 4 / no-cap, and a negative `targetParts` now throws instead of clamping to 1.
50
+ - `compilePart`/`compileInParts`: `preserve` was never supported and was silently ignored; it is now an unknown-key error (use `serialize(..., {preserve:true})` for preserve mode).
51
+
52
+ ### Fixed
53
+
54
+ - **BS-02:** an over-wide schema (a field offset past the u16 `offset_in_row` ceiling of 65535) now throws `W_SCHEMA_TOO_WIDE` at freeze instead of wrapping the offset and cross-writing fields. 8192 all-F64 fields freeze; 8193 throw.
55
+ - **BS-03:** duplicate schema field names now throw `W_DUPLICATE_FIELD` at freeze instead of writing a container the Writer reads from one lane and the Reader from another. See `decisions/0003-duplicate-field-names.md`.
56
+ - **BS-04:** a depth-0 non-object record (number, string, true, false, null) now throws `W_TOP_LEVEL_NOT_OBJECT` in schema mode instead of silently vanishing. Preserve mode is unchanged. See `decisions/0002-record-shape-policy.md`.
57
+ - **BS-11:** every construction-time options object is validated at every entry point that takes one; unknown keys and out-of-domain values fail closed.
58
+ - **BS-12:** `maxStringBytes` is enforced exactly against the running string length, so a cap below the initial 4096-byte buffer is honored, and over-cap strings throw `E_STRING_TOO_LONG`.
59
+ - **BS-13:** the schema `Writer` refuses any sink event or a second `finalize()` after `finalize()` with `W_FINALIZED`, matching `PreserveWriter`.
60
+ - **BS-14:** `Tokenizer` and `PreserveTokenizer` refuse `feed()`/`end()` after `end()` (`E_ENDED`) and after any thrown error (`E_POISONED`); a terminal instance never resurrects.
61
+ - **BS-19:** `Tokenizer` reads each sink method once at construction into a per-instance dispatch field (bound to the sink), so the caller's sink object is never mutated and a frozen sink works.
62
+
5
63
  ## [1.1.0] -- 2026-08-31
6
64
 
7
65
  ### Added
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.1.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.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.
17
17
 
18
18
  ## Two modes, one API
19
19
 
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.1.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.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).
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.1.0)
17
+ ## Public API (v1.3.0)
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.1.0",
3
+ "version": "1.3.0",
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
@@ -20,13 +20,24 @@
20
20
  // most engines), use server-side pre-compilation and RangeReader instead.
21
21
 
22
22
  import { Tokenizer } from './Tokenizer.js';
23
- import { Writer } from './Writer.js';
23
+ import { Writer, WriterError } from './Writer.js';
24
24
  import { Reader } from './Reader.js';
25
25
  import { PreserveTokenizer } from './PreserveTokenizer.js';
26
26
  import { PreserveWriter } from './PreserveWriter.js';
27
27
  import { PreserveReader } from './PreserveReader.js';
28
+ import { checkOpts } from './Opts.js';
28
29
 
29
- export const VERSION = '1.1.0';
30
+ export const VERSION = '1.3.0';
31
+
32
+ const U32_MAX = 4294967295;
33
+ const INGEST_OPTS = {
34
+ preserve: { t: 'bool' },
35
+ framing: { t: 'enum', values: ['auto', 'array', 'ndjson'] },
36
+ writer: { t: 'obj' },
37
+ onProgress: { t: 'fn' },
38
+ totalBytes: { t: 'int', min: 0, max: Number.MAX_SAFE_INTEGER },
39
+ };
40
+ function raiseIngest(code, msg) { throw new WriterError(code, msg); }
30
41
 
31
42
  // Ingest a ReadableStream<Uint8Array> and return a Reader over the resulting
32
43
  // LBK1 container. Options:
@@ -42,16 +53,23 @@ export const VERSION = '1.1.0';
42
53
  // totalBytes: optional total for progress percentage. Pass file.size
43
54
  // when ingesting a File; omit for indeterminate streams.
44
55
  export async function ingestStream(readableStream, opts) {
56
+ // Validated once, here, after ingestFile's totalBytes merge (R6): a single
57
+ // label, no double report.
58
+ checkOpts('ingestStream', opts, INGEST_OPTS, raiseIngest);
45
59
  opts = opts || {};
46
60
  const preserve = opts.preserve === true;
47
- const framing = opts.framing || 'ndjson';
48
- const writerOpts = opts.writer || {};
49
- const onProgress = typeof opts.onProgress === 'function' ? opts.onProgress : null;
50
- const totalBytes = typeof opts.totalBytes === 'number' ? opts.totalBytes : -1;
61
+ const framing = opts.framing !== undefined ? opts.framing : 'ndjson';
62
+ const writerOpts = opts.writer !== undefined ? opts.writer : {};
63
+ const onProgress = opts.onProgress !== undefined ? opts.onProgress : null;
64
+ const totalBytes = opts.totalBytes !== undefined ? opts.totalBytes : -1;
65
+ if (preserve && opts.framing !== undefined && opts.framing !== 'ndjson') {
66
+ throw new WriterError('E_OPTION_CONFLICT',
67
+ "ingestStream: preserve mode is NDJSON-only; remove framing:'" + opts.framing + "' or set preserve:false");
68
+ }
51
69
 
52
70
  const writer = preserve ? new PreserveWriter(writerOpts) : new Writer(writerOpts);
53
71
  const tokenizer = preserve
54
- ? new PreserveTokenizer(writer, { framing, maxRecordBytes: writerOpts.maxRecordBytes || 0 })
72
+ ? new PreserveTokenizer(writer, { framing, maxRecordBytes: writerOpts.maxRecordBytes !== undefined ? writerOpts.maxRecordBytes : 0 })
55
73
  : new Tokenizer(writer, { framing });
56
74
 
57
75
  const reader = readableStream.getReader();
@@ -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.1.0';
28
+ export const VERSION = '1.3.0';
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);
package/src/Opts.js ADDED
@@ -0,0 +1,189 @@
1
+ // @zakkster/lite-bake-stream / Opts (internal, not exported from index.js)
2
+ // Copyright (c) 2026 Zahary Shinikchiev. MIT.
3
+ //
4
+ // Shared construction-time options validator. Every public constructor and
5
+ // entry function that takes an opts object runs checkOpts once, at construction
6
+ // or in a synchronous prologue -- never per byte, never per record. An unknown
7
+ // key is an error with a did-you-mean hint (suite law: no silent ignore). An
8
+ // out-of-domain value is an error (suite law: null is not zero; explicit 0 is 0
9
+ // where 0 is in-domain). This file allocates only on the throw path.
10
+ //
11
+ // Each host declares a module-level frozen spec object literal mapping every
12
+ // known key to a domain descriptor:
13
+ // { t: 'enum', values: [...] } - string, must be a listed member
14
+ // { t: 'int', min, max } - integer in [min, max] inclusive
15
+ // { t: 'bool' } - boolean
16
+ // { t: 'fn' } - function
17
+ // { t: 'obj', nullable } - object (contents validated downstream)
18
+ // A value of `undefined` for any key means "use the default" and is skipped,
19
+ // matching each host's `opts.k !== undefined ? opts.k : DEFAULT` defaulting.
20
+ //
21
+ // OPTS DOMAIN TABLE (ships with the package; the M1 plan is the source of truth)
22
+ //
23
+ // Unknown-key rule (all entry points): throw E_UNKNOWN_OPTION. Levenshtein
24
+ // distance from the offending key to every known key; if the minimum is <= 2,
25
+ // message is "unknown option 'framming' -- did you mean 'framing'?"; else
26
+ // "unknown option 'zzz' (known: framing, maxStringBytes)". Ties break by spec
27
+ // declaration order.
28
+ //
29
+ // Tokenizer(sink,opts)
30
+ // framing enum 'auto'|'array'|'ndjson' def 'auto'
31
+ // maxStringBytes int [1, 4294967295] def 1048576 (0 forbidden)
32
+ // Writer(opts)
33
+ // schema object|null with array fields def null
34
+ // targetShardBytes int [1, 4294967295] def 33554432 (0 forbidden)
35
+ // sampleBytes int [0, 4294967295] def targetShardBytes (0 = freeze on record 1)
36
+ // PreserveTokenizer(sink,opts)
37
+ // framing enum 'ndjson' def 'ndjson'
38
+ // maxRecordBytes int [0, 4294967295] def 0 (0 = unlimited)
39
+ // PreserveWriter(opts)
40
+ // targetShardBytes int [1, 4294967295] def 8388608 (0 forbidden)
41
+ // maxRecordBytes int [0, 4294967295] declared, consumed by PreserveTokenizer
42
+ // serialize(input,opts) / ingestStream / ingestFile
43
+ // preserve bool def false
44
+ // framing enum 'auto'|'array'|'ndjson' def 'ndjson' (conflict if preserve && != 'ndjson')
45
+ // writer object def {}
46
+ // onProgress function (ingest only)
47
+ // totalBytes int [0, 9007199254740991] def -1 (ingest only)
48
+ // RangeReader(adapter,opts) / .open
49
+ // maxCachedShards int [0, 4294967295] def 8 (0 = retain no shard)
50
+ // HTTPRangeAdapter.open(url,opts)
51
+ // fetch function def globalThis.fetch
52
+ // splitNDJSON(bytes,opts) / compileInParts
53
+ // targetParts int [1, 4294967295] def 4 (0 forbidden)
54
+ // maxPartBytes int [1, 4294967295] or Infinity def Infinity (0 forbidden; Infinity = no cap)
55
+ // compilePart(bytes,opts) / compileInParts
56
+ // framing enum 'auto'|'array'|'ndjson' def 'ndjson'
57
+ // writer object def {}
58
+ //
59
+ // Non-integer, NaN, Infinity, negative, null, and wrong-typeof all land on
60
+ // E_OPTION_VALUE with a message naming key, received value, and the domain.
61
+
62
+ export function checkOpts(label, opts, spec, raise) {
63
+ if (opts === undefined || opts === null || typeof opts !== 'object') return;
64
+ for (const key in opts) {
65
+ if (!Object.prototype.hasOwnProperty.call(opts, key)) continue;
66
+ const d = spec[key];
67
+ if (d === undefined) {
68
+ const keys = Object.keys(spec);
69
+ const near = nearestKey(key, keys);
70
+ if (near !== null) {
71
+ raise('E_UNKNOWN_OPTION',
72
+ label + ": unknown option '" + key + "' -- did you mean '" + near + "'?");
73
+ } else {
74
+ raise('E_UNKNOWN_OPTION',
75
+ label + ": unknown option '" + key + "' (known: " + keys.join(', ') + ')');
76
+ }
77
+ continue;
78
+ }
79
+ const v = opts[key];
80
+ if (v === undefined) continue; // explicit undefined == use default
81
+ _checkValue(label, key, v, d, raise);
82
+ }
83
+ }
84
+
85
+ function _checkValue(label, key, v, d, raise) {
86
+ const t = d.t;
87
+ if (t === 'int') {
88
+ if (d.inf && v === Infinity) return; // Infinity is a legal explicit value for this key
89
+ if (typeof v !== 'number' || !Number.isInteger(v) || v < d.min || v > d.max) {
90
+ raise('E_OPTION_VALUE',
91
+ label + ": option '" + key + "' must be an integer in [" +
92
+ d.min + ', ' + d.max + ']' + (d.inf ? ' or Infinity' : '') + '; got ' + _show(v));
93
+ }
94
+ return;
95
+ }
96
+ if (t === 'enum') {
97
+ if (typeof v !== 'string' || d.values.indexOf(v) < 0) {
98
+ raise('E_OPTION_VALUE',
99
+ label + ": option '" + key + "' must be one of " +
100
+ _quoteList(d.values) + '; got ' + _show(v));
101
+ }
102
+ return;
103
+ }
104
+ if (t === 'bool') {
105
+ if (typeof v !== 'boolean') {
106
+ raise('E_OPTION_VALUE',
107
+ label + ": option '" + key + "' must be a boolean; got " + _show(v));
108
+ }
109
+ return;
110
+ }
111
+ if (t === 'fn') {
112
+ if (typeof v !== 'function') {
113
+ raise('E_OPTION_VALUE',
114
+ label + ": option '" + key + "' must be a function; got " + _show(v));
115
+ }
116
+ return;
117
+ }
118
+ if (t === 'obj') {
119
+ if (v === null) {
120
+ if (!d.nullable) {
121
+ raise('E_OPTION_VALUE',
122
+ label + ": option '" + key + "' must be an object; got null");
123
+ }
124
+ return;
125
+ }
126
+ if (typeof v !== 'object' || Array.isArray(v)) {
127
+ raise('E_OPTION_VALUE',
128
+ label + ": option '" + key + "' must be an object; got " + _show(v));
129
+ }
130
+ }
131
+ }
132
+
133
+ function _show(v) {
134
+ if (typeof v === 'string') return "'" + v + "'";
135
+ if (v === null) return 'null';
136
+ if (typeof v === 'object') return Array.isArray(v) ? 'array' : 'object';
137
+ return String(v);
138
+ }
139
+
140
+ function _quoteList(values) {
141
+ let s = '';
142
+ for (let i = 0; i < values.length; i++) {
143
+ if (i > 0) s += ', ';
144
+ s += "'" + values[i] + "'";
145
+ }
146
+ return s;
147
+ }
148
+
149
+ // Nearest known key by Levenshtein distance, returning it only when the minimum
150
+ // distance is <= 2. Ties break by declaration order (strict < keeps the first).
151
+ export function nearestKey(key, knownKeys) {
152
+ let best = null;
153
+ let bestDist = 3;
154
+ const klen = key.length;
155
+ for (let k = 0; k < knownKeys.length; k++) {
156
+ const cand = knownKeys[k];
157
+ const diff = klen - cand.length;
158
+ if (diff > 2 || diff < -2) continue; // length delta alone exceeds the cap
159
+ const dist = _lev(key, cand);
160
+ if (dist < bestDist) { bestDist = dist; best = cand; }
161
+ }
162
+ return bestDist <= 2 ? best : null;
163
+ }
164
+
165
+ // Two-row Levenshtein. Allocates two small arrays; only reached on the throw
166
+ // path (nearestKey is called only when an unknown key is being reported).
167
+ function _lev(a, b) {
168
+ const n = a.length, m = b.length;
169
+ if (n === 0) return m;
170
+ if (m === 0) return n;
171
+ let prev = new Array(m + 1);
172
+ let curr = new Array(m + 1);
173
+ for (let j = 0; j <= m; j++) prev[j] = j;
174
+ for (let i = 1; i <= n; i++) {
175
+ curr[0] = i;
176
+ const ca = a.charCodeAt(i - 1);
177
+ for (let j = 1; j <= m; j++) {
178
+ const cost = ca === b.charCodeAt(j - 1) ? 0 : 1;
179
+ let v = prev[j] + 1;
180
+ const del = curr[j - 1] + 1;
181
+ if (del < v) v = del;
182
+ const sub = prev[j - 1] + cost;
183
+ if (sub < v) v = sub;
184
+ curr[j] = v;
185
+ }
186
+ const tmp = prev; prev = curr; curr = tmp;
187
+ }
188
+ return prev[m];
189
+ }
@@ -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.1.0';
35
+ export const VERSION = '1.3.0';
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,