@zakkster/lite-bake-stream 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +58 -0
- package/README.md +1 -1
- package/llms.txt +2 -2
- package/package.json +5 -1
- package/src/FileIngest.js +25 -7
- package/src/MultiReader.js +20 -4
- package/src/Opts.js +189 -0
- package/src/PreserveReader.js +69 -13
- package/src/PreserveTokenizer.js +35 -4
- package/src/PreserveWriter.js +15 -2
- package/src/RangeReader.js +146 -16
- package/src/Reader.js +125 -25
- package/src/Split.js +40 -8
- package/src/StringTable.js +1 -1
- package/src/Tokenizer.js +68 -30
- package/src/Views.js +25 -0
- package/src/Writer.js +87 -15
- package/src/index.js +30 -24
package/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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
48
|
-
const writerOpts = opts.writer
|
|
49
|
-
const onProgress =
|
|
50
|
-
const totalBytes =
|
|
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
|
|
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();
|
package/src/MultiReader.js
CHANGED
|
@@ -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()
|
|
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.
|
|
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
|
|
65
|
-
this._shardOffsets = new
|
|
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
|
+
}
|
package/src/PreserveReader.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
|
173
|
+
blobLen,
|
|
118
174
|
offsetTableOff,
|
|
119
175
|
firstRow: cumulativeRow,
|
|
120
176
|
endRow: cumulativeRow + rowCount,
|