@zakkster/lite-bake-stream 1.4.1 → 1.5.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 +62 -0
- package/README.md +1 -1
- package/llms.txt +13 -2
- package/package.json +13 -1
- package/src/FileIngest.js +1 -1
- package/src/MultiReader.js +5 -2
- package/src/PreserveReader.js +25 -10
- package/src/PreserveTokenizer.js +1 -1
- package/src/PreserveWriter.js +38 -8
- package/src/RangeReader.js +56 -20
- package/src/Reader.js +46 -10
- package/src/Split.js +10 -2
- package/src/StringTable.js +62 -6
- package/src/Tokenizer.js +1 -1
- package/src/Writer.js +119 -11
- package/src/index.js +3 -3
- package/types/StringTable.d.ts +5 -0
- package/types/index.d.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,68 @@
|
|
|
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.5.0] -- 2026-09-02
|
|
6
|
+
|
|
7
|
+
M5 -- every ceiling gets a door; the shard budget counts all the bytes it ships; the floors are measured. BS-26 closed; BS-27's floor items closed (the writer-holds-all-shards ~2x container peak moves to M6's streaming emission); BS-32/BS-33/BS-34 closed. Suite: 461 -> 479 tests / 479 pass / 0 fail / 0 todo. Torture: 44/44 fast (~322 ms tier sum), 47/47 full (~5.6 s) incl. a 2100-shard directory-scale scenario (SPEC checker green, RangeReader LRU cap held at 8, `_maybeEvict` allocation-flat over 20000 ops); arrayBuffers growth 0.00 MB both tiers; t7 soak tracker size 0. Inventory gate: 54 -> 57 thrown codes / 0 unpinned. Falsifiability, measured item-by-item with baseline src stashed: all 15 new door/behavior assertions fail on baseline and pass on this tree, every stay-green net holds on both, and an all-F64-lane container is byte-identical to the 1.4.1 output (same sha256 and length). BREAK matrix {1, t6, l, m, j} all exit non-zero.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Serialization ceilings (BS-26).** `StringTable` now fails closed with a new
|
|
12
|
+
`StringTableError` code `ST_BLOB_OVERFLOW` when a per-shard string table would
|
|
13
|
+
cross the u32 `blob_length` (4294967295) or `entry_count` (4294967294) ceiling,
|
|
14
|
+
instead of wrapping silently on `setUint32` and minting a corrupt-but-in-bounds
|
|
15
|
+
container. The doubling paths clamp their allocation at the ceiling so every
|
|
16
|
+
crossing write re-enters the guard, making placement provably complete.
|
|
17
|
+
`StringTableError` is re-exported from `index.js` and declared in the types.
|
|
18
|
+
`PreserveWriter.onRecord` raises the same `ST_BLOB_OVERFLOW` (shared code)
|
|
19
|
+
before a shard's `payload_len` (blob + trailing offset table) would exceed u32.
|
|
20
|
+
A test-only `__setStringTableLimits({blobBytes, entryCount})` seam (no semver
|
|
21
|
+
guarantee, absent from the public API/types/docs) lowers the ceilings for a
|
|
22
|
+
cheap gated crossing on the real grow path.
|
|
23
|
+
- **Row-index doors (BS-32).** `Reader.get`, `MultiReader.get`, `RangeReader.get`
|
|
24
|
+
and `PreserveReader.getBytes/getString/getJSON` now reject a negative,
|
|
25
|
+
fractional, `NaN`, or out-of-range `rowIdx` with `R_ROW_OUT_OF_RANGE`
|
|
26
|
+
(`M_ROW_OUT_OF_RANGE` for `MultiReader`) rather than returning `undefined` or a
|
|
27
|
+
garbage subarray from a fractional byte offset. `Reader.get` uses a `>>>0`
|
|
28
|
+
compare; the others use `Number.isInteger` (they legitimately address up to
|
|
29
|
+
2^53). The SHARD-index escape hatches keep their unchecked raw-access contract.
|
|
30
|
+
- **Safe-integer offset guard (BS-26).** `Reader`, `RangeReader` and
|
|
31
|
+
`PreserveReader` narrow every u64 header/schema/directory offset through a cold
|
|
32
|
+
`u64()` helper that throws `R_OFFSET_TOO_LARGE` past 2^53-1, where a `Number()`
|
|
33
|
+
cast would lose precision and hand a corrupted offset to every bounds check.
|
|
34
|
+
- **`RangeReader` adapter validation (BS-34 + BS-33).** All seven `adapter.fetch` sites
|
|
35
|
+
funnel through `_fetchExact`, which rejects a non-`Uint8Array` or wrong-length
|
|
36
|
+
return with `R_ADAPTER_SHORT_READ`. `syncRange` over a not-prefetched shard now
|
|
37
|
+
throws its own `R_NOT_PREFETCHED`, distinct from the `R_TRUNCATED` truncation
|
|
38
|
+
door.
|
|
39
|
+
|
|
40
|
+
### Changed
|
|
41
|
+
|
|
42
|
+
- **Shard-byte budget (D2).** The schema `Writer` now rolls a shard when its
|
|
43
|
+
payload plus string-table bytes reach `targetShardBytes`, not on the row
|
|
44
|
+
ceiling alone, so a string-heavy shard no longer overshoots the target. The
|
|
45
|
+
string-table byte length is tracked incrementally with zero allocation (one
|
|
46
|
+
compare per string value, updated only on a unique arrival) and is exact versus
|
|
47
|
+
`StringTable.serialize` at every finalize. The budget reads only
|
|
48
|
+
chunk-invariant state, so re-chunked input still yields byte-identical
|
|
49
|
+
containers (the t0 law), and a container whose schema has only F64 lanes keeps
|
|
50
|
+
a zero string budget and is **byte-identical** to the pre-D2 output.
|
|
51
|
+
- **Floors.** The schema `Writer` field-name lookup moves from an O(F) hash-scan
|
|
52
|
+
to an O(1) `Map` (byte-confirmed, so a real FNV collision such as
|
|
53
|
+
`gwzx`/`16cd` still resolves correctly): on an ad-hoc 64-/256-field
|
|
54
|
+
flat-record serialize microbench (`bench/bench-tokenizer.js` carries a fixed
|
|
55
|
+
7-field fixture, so it cannot express this floor), throughput went from
|
|
56
|
+
190.0 -> 216.7 MB/s at 64 fields and 113.3 -> 213.6 MB/s at 256 fields --
|
|
57
|
+
the O(F) per-key term no longer degrades with field count. qa's independent
|
|
58
|
+
microbench (different corpus) corroborates the shape: baseline fell
|
|
59
|
+
128.0 -> 77.3 MB/s going 64 -> 256 fields; with the map it holds
|
|
60
|
+
163.0 -> 167.9 MB/s. The sample-window key
|
|
61
|
+
decoder is now a shared module-scope `TextDecoder` instead of one per key.
|
|
62
|
+
`PreserveWriter` reuses its working buffers across shard rolls (they were
|
|
63
|
+
reallocated every roll) -- the reuse is byte-safe (every read is bounded by the
|
|
64
|
+
reset cursors) and holds the working store byte-flat under a steady-stream soak.
|
|
65
|
+
- `package.json` exports: all 12 entries (root + 11 subpaths) gain the `node` condition alongside `types`/`import`/`default`, matching the suite convention. Verified: 12/12 subpaths resolve via self-reference import, `require()` resolves through `node` on Node 26 (`require(esm)`), npm test 461/461, `npm pack --dry-run` unchanged at 32 files.
|
|
66
|
+
|
|
5
67
|
## [1.4.1] -- 2026-09-02
|
|
6
68
|
|
|
7
69
|
M8 -- the four reserved torture cells become real; BS-30 and BS-31 close. No tier prints "reserved" any more. Suite: 461 tests / 461 pass / 0 fail / 0 todo (unchanged -- no named-suite edits). Torture: 44/44 fast (~1.3 s wall), 47/47 full, arrayBuffers growth 0. BREAK matrix: ten runs (`=1`, eight registry ids, one unknown id) all exit non-zero; the unknown id is refused at import. Inventory gate: 54 thrown codes / 0 unpinned. Falsifiability: with baseline src stashed, exactly one new assertion fails (the onProgress-identity pin), both tiers. Test-only except one hot-path reuse in `src/FileIngest.js` (below).
|
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.5.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.5.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.5.0)
|
|
18
18
|
|
|
19
19
|
Two ingest modes share one top-level API:
|
|
20
20
|
|
|
@@ -62,6 +62,17 @@ Error classes with stable `code`: `TokenizerError`, `WriterError`, `ReaderError`
|
|
|
62
62
|
|
|
63
63
|
Every declared field of every row round-trips. F64 lanes: bit-exact for numeric literals with ≤15 significant digits and |exponent| ≤ 22 (Clinger's fast path); ≤1 ULP drift outside that domain (documented, pinned by NumericBoundary.test.js). U32 lanes: byte-exact UTF-8, unconditional. Missing fields → documented defaults. Unknown keys → silently dropped. Wrong-type value on post-freeze schema → W_LANE_MISMATCH error, no corrupt container produced. See SPEC section 7 for the full table. Asserted by tests, property-based fuzz, AND torture-scale verification (~73,500 rows per fast-tier run).
|
|
64
64
|
|
|
65
|
+
## Row-index and refusal codes
|
|
66
|
+
|
|
67
|
+
- Random-access row surfaces fail closed on a bad `rowIdx` (negative, fractional, NaN, or >= totalRows): `Reader.get`, `RangeReader.get` and `PreserveReader.getBytes/getString/getJSON` throw `R_ROW_OUT_OF_RANGE`; `MultiReader.get` throws `M_ROW_OUT_OF_RANGE`. The SHARD-index escape hatches (`shardPayload`, `shardF64`, `shardStringTable`, `loadShard`) keep their unchecked raw-access contract; `shardBounds` returns null out-of-range.
|
|
68
|
+
- `R_OFFSET_TOO_LARGE`: a u64 header/schema/directory offset exceeds 2^53-1 (a Number() cast would lose precision). Raised by all three reader classes.
|
|
69
|
+
- `R_NOT_PREFETCHED`: `RangeReader.syncRange` over a shard `prefetchRange` has not cached (distinct from the `R_TRUNCATED` truncation door). `R_ADAPTER_SHORT_READ` also covers a non-Uint8Array or wrong-length adapter return.
|
|
70
|
+
- `ST_BLOB_OVERFLOW`: a per-shard string table would cross the u32 blob_length (4294967295) or entry_count (4294967294) ceiling; also raised by `PreserveWriter` before a shard payload_len would exceed u32. One shared code across `StringTable` and `PreserveWriter`. `StringTableError` is exported from the root and the `string-table` subpath.
|
|
71
|
+
|
|
72
|
+
## Shard budget
|
|
73
|
+
|
|
74
|
+
Schema-mode shards roll when payload bytes plus string-table bytes reach `targetShardBytes`, not on the row ceiling alone. The string-table byte count is tracked with zero allocation and is chunk-invariant, so re-chunked input yields byte-identical containers and an all-F64 schema is byte-identical to the pre-budget output. `PreserveWriter` reuses its working buffers across shard rolls.
|
|
75
|
+
|
|
65
76
|
## Numbers
|
|
66
77
|
|
|
67
78
|
F64 only. Values exceeding IEEE 754 double range are rejected as `E_NUMBER_OVERFLOW`. 64-bit integer IDs above 2^53 lose precision silently (documented in SPEC 5.4); v2 will introduce an opt-in bytes-preserved lane.
|
package/package.json
CHANGED
|
@@ -1,67 +1,79 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zakkster/lite-bake-stream",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.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,
|
|
7
7
|
"exports": {
|
|
8
8
|
".": {
|
|
9
9
|
"types": "./types/index.d.ts",
|
|
10
|
+
"node": "./src/index.js",
|
|
10
11
|
"import": "./src/index.js",
|
|
11
12
|
"default": "./src/index.js"
|
|
12
13
|
},
|
|
13
14
|
"./tokenizer": {
|
|
14
15
|
"types": "./types/Tokenizer.d.ts",
|
|
16
|
+
"node": "./src/Tokenizer.js",
|
|
15
17
|
"import": "./src/Tokenizer.js",
|
|
16
18
|
"default": "./src/Tokenizer.js"
|
|
17
19
|
},
|
|
18
20
|
"./writer": {
|
|
19
21
|
"types": "./types/Writer.d.ts",
|
|
22
|
+
"node": "./src/Writer.js",
|
|
20
23
|
"import": "./src/Writer.js",
|
|
21
24
|
"default": "./src/Writer.js"
|
|
22
25
|
},
|
|
23
26
|
"./reader": {
|
|
24
27
|
"types": "./types/Reader.d.ts",
|
|
28
|
+
"node": "./src/Reader.js",
|
|
25
29
|
"import": "./src/Reader.js",
|
|
26
30
|
"default": "./src/Reader.js"
|
|
27
31
|
},
|
|
28
32
|
"./string-table": {
|
|
29
33
|
"types": "./types/StringTable.d.ts",
|
|
34
|
+
"node": "./src/StringTable.js",
|
|
30
35
|
"import": "./src/StringTable.js",
|
|
31
36
|
"default": "./src/StringTable.js"
|
|
32
37
|
},
|
|
33
38
|
"./file-ingest": {
|
|
34
39
|
"types": "./types/FileIngest.d.ts",
|
|
40
|
+
"node": "./src/FileIngest.js",
|
|
35
41
|
"import": "./src/FileIngest.js",
|
|
36
42
|
"default": "./src/FileIngest.js"
|
|
37
43
|
},
|
|
38
44
|
"./range-reader": {
|
|
39
45
|
"types": "./types/RangeReader.d.ts",
|
|
46
|
+
"node": "./src/RangeReader.js",
|
|
40
47
|
"import": "./src/RangeReader.js",
|
|
41
48
|
"default": "./src/RangeReader.js"
|
|
42
49
|
},
|
|
43
50
|
"./multi-reader": {
|
|
44
51
|
"types": "./types/MultiReader.d.ts",
|
|
52
|
+
"node": "./src/MultiReader.js",
|
|
45
53
|
"import": "./src/MultiReader.js",
|
|
46
54
|
"default": "./src/MultiReader.js"
|
|
47
55
|
},
|
|
48
56
|
"./split": {
|
|
49
57
|
"types": "./types/Split.d.ts",
|
|
58
|
+
"node": "./src/Split.js",
|
|
50
59
|
"import": "./src/Split.js",
|
|
51
60
|
"default": "./src/Split.js"
|
|
52
61
|
},
|
|
53
62
|
"./preserve-tokenizer": {
|
|
54
63
|
"types": "./types/PreserveTokenizer.d.ts",
|
|
64
|
+
"node": "./src/PreserveTokenizer.js",
|
|
55
65
|
"import": "./src/PreserveTokenizer.js",
|
|
56
66
|
"default": "./src/PreserveTokenizer.js"
|
|
57
67
|
},
|
|
58
68
|
"./preserve-writer": {
|
|
59
69
|
"types": "./types/PreserveWriter.d.ts",
|
|
70
|
+
"node": "./src/PreserveWriter.js",
|
|
60
71
|
"import": "./src/PreserveWriter.js",
|
|
61
72
|
"default": "./src/PreserveWriter.js"
|
|
62
73
|
},
|
|
63
74
|
"./preserve-reader": {
|
|
64
75
|
"types": "./types/PreserveReader.d.ts",
|
|
76
|
+
"node": "./src/PreserveReader.js",
|
|
65
77
|
"import": "./src/PreserveReader.js",
|
|
66
78
|
"default": "./src/PreserveReader.js"
|
|
67
79
|
}
|
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.
|
|
30
|
+
export const VERSION = '1.5.0';
|
|
31
31
|
|
|
32
32
|
const U32_MAX = 4294967295;
|
|
33
33
|
const INGEST_OPTS = {
|
package/src/MultiReader.js
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
// M_ROW_OUT_OF_RANGE - rowIdx >= totalRows
|
|
26
26
|
// M_TOO_MANY_ROWS - cumulative row count exceeds Number.MAX_SAFE_INTEGER
|
|
27
27
|
|
|
28
|
-
export const VERSION = '1.
|
|
28
|
+
export const VERSION = '1.5.0';
|
|
29
29
|
|
|
30
30
|
export class MultiReaderError extends Error {
|
|
31
31
|
constructor(code, msg) { super(msg); this.code = code; this.name = 'MultiReaderError'; }
|
|
@@ -100,7 +100,10 @@ export class MultiReader {
|
|
|
100
100
|
|
|
101
101
|
// Which reader contains global row rowIdx? Returns { readerIdx, localRow }.
|
|
102
102
|
_locateRow(rowIdx) {
|
|
103
|
-
|
|
103
|
+
// BS-32: Number.isInteger (not >>>0) because MultiReader legitimately
|
|
104
|
+
// addresses up to 2^53 (see M_TOO_MANY_ROWS). Rejects fractional, NaN, and
|
|
105
|
+
// out-of-range in one guard; covers get() and readerForRow().
|
|
106
|
+
if (!Number.isInteger(rowIdx) || rowIdx < 0 || rowIdx >= this._totalRows) {
|
|
104
107
|
throw new MultiReaderError('M_ROW_OUT_OF_RANGE',
|
|
105
108
|
'rowIdx ' + rowIdx + ' out of range [0, ' + this._totalRows + ')');
|
|
106
109
|
}
|
package/src/PreserveReader.js
CHANGED
|
@@ -28,11 +28,12 @@
|
|
|
28
28
|
// R_BAD_FOOTER - footer magic_end or footer_len is malformed
|
|
29
29
|
// R_INVALID - a structure is internally inconsistent but in-bounds
|
|
30
30
|
// R_SHARD_VERSION_TOO_NEW - a shard's min_reader_version > this reader
|
|
31
|
-
// R_ROW_OUT_OF_RANGE - rowIdx >= totalRows
|
|
31
|
+
// R_ROW_OUT_OF_RANGE - rowIdx negative, fractional, NaN, or >= totalRows (BS-32)
|
|
32
|
+
// R_OFFSET_TOO_LARGE - a u64 header/directory offset exceeds 2^53-1
|
|
32
33
|
|
|
33
34
|
import { toContainerBuffer } from './Views.js';
|
|
34
35
|
|
|
35
|
-
export const VERSION = '1.
|
|
36
|
+
export const VERSION = '1.5.0';
|
|
36
37
|
|
|
37
38
|
const CONTAINER_HEADER_BYTES = 48;
|
|
38
39
|
const SHARD_ENTRY_BYTES = 40;
|
|
@@ -42,6 +43,17 @@ export class PreserveReaderError extends Error {
|
|
|
42
43
|
constructor(code, msg) { super(msg); this.code = code; this.name = 'PreserveReaderError'; }
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
// Read a u64 header/directory field and narrow it to a JS number, failing closed
|
|
47
|
+
// past Number.MAX_SAFE_INTEGER. Past 2^53-1 a Number() cast loses precision and
|
|
48
|
+
// every downstream bounds check reads a corrupted offset (BS-05). Cold: parse-only.
|
|
49
|
+
function u64(dv, off, what) {
|
|
50
|
+
const v = dv.getBigUint64(off, true);
|
|
51
|
+
if (v > 9007199254740991n)
|
|
52
|
+
throw new PreserveReaderError('R_OFFSET_TOO_LARGE',
|
|
53
|
+
what + ' value ' + v + ' exceeds the safe-integer ceiling 9007199254740991');
|
|
54
|
+
return Number(v);
|
|
55
|
+
}
|
|
56
|
+
|
|
45
57
|
export class PreserveReader {
|
|
46
58
|
static fromBuffer(input) {
|
|
47
59
|
return new PreserveReader(toContainerBuffer(input, 'PreserveReader.fromBuffer'));
|
|
@@ -84,11 +96,11 @@ export class PreserveReader {
|
|
|
84
96
|
if (reserved1 !== 0)
|
|
85
97
|
throw new PreserveReaderError('R_RESERVED_NONZERO', 'header reserved1 at offset 36 must be 0, got ' + reserved1);
|
|
86
98
|
|
|
87
|
-
this._schemaBlockOff =
|
|
88
|
-
this._metadataOff =
|
|
89
|
-
this._shardDirOff =
|
|
99
|
+
this._schemaBlockOff = u64(this._dv, 8, 'schema_block_off');
|
|
100
|
+
this._metadataOff = u64(this._dv, 16, 'metadata_off');
|
|
101
|
+
this._shardDirOff = u64(this._dv, 24, 'shard_directory_off');
|
|
90
102
|
this._shardCount = this._dv.getUint32(32, true);
|
|
91
|
-
this._totalRows =
|
|
103
|
+
this._totalRows = u64(this._dv, 40, 'total_rows');
|
|
92
104
|
|
|
93
105
|
if (this._schemaBlockOff !== 0) {
|
|
94
106
|
throw new PreserveReaderError('R_INVALID', 'preserve container has non-zero schema_block_off');
|
|
@@ -127,14 +139,14 @@ export class PreserveReader {
|
|
|
127
139
|
let cumulativeRow = 0;
|
|
128
140
|
for (let i = 0; i < this._shardCount; i++) {
|
|
129
141
|
const entryOff = this._shardDirOff + i * SHARD_ENTRY_BYTES;
|
|
130
|
-
const payloadOff =
|
|
142
|
+
const payloadOff = u64(this._dv, entryOff + 0, 'shard ' + i + ' payload_off');
|
|
131
143
|
const payloadLen = this._dv.getUint32(entryOff + 8, true);
|
|
132
144
|
const rowCount = this._dv.getUint32(entryOff + 12, true);
|
|
133
145
|
const minReaderVer = this._dv.getUint16(entryOff + 16, true);
|
|
134
146
|
const shardFlags = this._dv.getUint16(entryOff + 18, true);
|
|
135
147
|
const shardReserved = this._dv.getUint32(entryOff + 20, true);
|
|
136
|
-
const localStrOff =
|
|
137
|
-
const localStrLen =
|
|
148
|
+
const localStrOff = u64(this._dv, entryOff + 24, 'shard ' + i + ' local_string_off');
|
|
149
|
+
const localStrLen = u64(this._dv, entryOff + 32, 'shard ' + i + ' local_string_len');
|
|
138
150
|
if (minReaderVer > 1) {
|
|
139
151
|
throw new PreserveReaderError('R_SHARD_VERSION_TOO_NEW',
|
|
140
152
|
'shard ' + i + ' requires reader version ' + minReaderVer);
|
|
@@ -195,7 +207,10 @@ export class PreserveReader {
|
|
|
195
207
|
get hasZoneMaps() { return false; }
|
|
196
208
|
|
|
197
209
|
_locateShard(rowIdx) {
|
|
198
|
-
|
|
210
|
+
// BS-32: Number.isInteger rejects fractional/NaN too, so getBytes(1.5) fails
|
|
211
|
+
// closed instead of returning a garbage subarray. One site covers
|
|
212
|
+
// getBytes/getString/getJSON.
|
|
213
|
+
if (!Number.isInteger(rowIdx) || rowIdx < 0 || rowIdx >= this._totalRows) {
|
|
199
214
|
throw new PreserveReaderError('R_ROW_OUT_OF_RANGE',
|
|
200
215
|
'rowIdx ' + rowIdx + ' out of range [0, ' + this._totalRows + ')');
|
|
201
216
|
}
|
package/src/PreserveTokenizer.js
CHANGED
package/src/PreserveWriter.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
import { checkOpts } from './Opts.js';
|
|
20
20
|
|
|
21
|
-
export const VERSION = '1.
|
|
21
|
+
export const VERSION = '1.5.0';
|
|
22
22
|
|
|
23
23
|
export class PreserveWriterError extends Error {
|
|
24
24
|
constructor(code, msg) { super(msg); this.code = code; this.name = 'PreserveWriterError'; }
|
|
@@ -48,13 +48,19 @@ export class PreserveWriter {
|
|
|
48
48
|
this._shards = [];
|
|
49
49
|
this._totalRows = 0;
|
|
50
50
|
this._finalized = false;
|
|
51
|
+
this._currentBlobs = null; // null => never allocated; _allocateShard allocates once
|
|
51
52
|
this._allocateShard();
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
_allocateShard() {
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
// Allocate the working buffers ONCE (BS-27 floor). Every subsequent shard
|
|
57
|
+
// reuses them: _finalizeCurrentShard resets the cursors instead of nulling
|
|
58
|
+
// the buffers, so a shard roll allocates nothing. A grown buffer (oversized
|
|
59
|
+
// record) simply carries forward as a larger reusable buffer.
|
|
60
|
+
if (this._currentBlobs === null) {
|
|
61
|
+
this._currentBlobs = new Uint8Array(this._targetShardBytes);
|
|
62
|
+
this._currentOffsets = new Uint32Array(INITIAL_OFFSETS_CAP);
|
|
63
|
+
}
|
|
58
64
|
this._currentBlobBytes = 0;
|
|
59
65
|
this._currentRowCount = 0;
|
|
60
66
|
}
|
|
@@ -74,7 +80,19 @@ export class PreserveWriter {
|
|
|
74
80
|
this._allocateShard();
|
|
75
81
|
}
|
|
76
82
|
// Grow shard buffer if this single record exceeds the pre-allocated size.
|
|
83
|
+
// Fail closed (BS-31) on a BLOB-dimension crossing: this guard covers a
|
|
84
|
+
// record whose blob bytes push payload_len past the u32 ceiling (including
|
|
85
|
+
// the oversized-single-record path), and the _growBlobBuffer clamp keeps the
|
|
86
|
+
// buffer <= U32_MAX so such a write always re-enters this grow branch. The
|
|
87
|
+
// ORTHOGONAL crossing -- payload_len exceeding u32 via OFFSET-TABLE
|
|
88
|
+
// accumulation (many tiny records, blob under target, grow branch never
|
|
89
|
+
// firing) -- is caught in _finalizeCurrentShard. ST_BLOB_OVERFLOW is the
|
|
90
|
+
// shared cross-writer overflow code (R11).
|
|
77
91
|
if (this._currentBlobBytes + len > this._currentBlobs.length) {
|
|
92
|
+
if (this._currentBlobBytes + len + (this._currentRowCount + 1) * 4 > U32_MAX)
|
|
93
|
+
throw new PreserveWriterError('ST_BLOB_OVERFLOW',
|
|
94
|
+
'preserve shard payload ' + (this._currentBlobBytes + len) +
|
|
95
|
+
' bytes + offset table would exceed the u32 payload_len ceiling ' + U32_MAX);
|
|
78
96
|
this._growBlobBuffer(this._currentBlobBytes + len);
|
|
79
97
|
}
|
|
80
98
|
// Grow offsets array if we've hit the pre-allocated ceiling.
|
|
@@ -107,6 +125,9 @@ export class PreserveWriter {
|
|
|
107
125
|
_growBlobBuffer(needed) {
|
|
108
126
|
let cap = this._currentBlobs.length;
|
|
109
127
|
while (cap < needed) cap *= 2;
|
|
128
|
+
// Clamp so the buffer never exceeds the u32 payload ceiling; this makes the
|
|
129
|
+
// onRecord grow-branch guard provably complete (see onRecord).
|
|
130
|
+
if (cap > U32_MAX) cap = U32_MAX;
|
|
110
131
|
const next = new Uint8Array(cap);
|
|
111
132
|
next.set(this._currentBlobs.subarray(0, this._currentBlobBytes));
|
|
112
133
|
this._currentBlobs = next;
|
|
@@ -124,6 +145,15 @@ export class PreserveWriter {
|
|
|
124
145
|
const blobLen = this._currentBlobBytes;
|
|
125
146
|
const offsetTableBytes = rowCount * 4;
|
|
126
147
|
const shardBytesLen = blobLen + offsetTableBytes;
|
|
148
|
+
// Fail closed (BS-31, cold) on an OFFSET-TABLE-driven crossing: payload_len =
|
|
149
|
+
// blob bytes + rowCount*4 is written into the shard directory as a u32, and
|
|
150
|
+
// enough tiny records can push it past the ceiling without the onRecord grow
|
|
151
|
+
// branch ever firing. This is the door that branch cannot cover. Shared code
|
|
152
|
+
// ST_BLOB_OVERFLOW (R11).
|
|
153
|
+
if (shardBytesLen > U32_MAX)
|
|
154
|
+
throw new PreserveWriterError('ST_BLOB_OVERFLOW',
|
|
155
|
+
'preserve shard payload_len ' + shardBytesLen + ' (blob ' + blobLen + ' + offset table ' +
|
|
156
|
+
offsetTableBytes + ') exceeds the u32 ceiling ' + U32_MAX);
|
|
127
157
|
|
|
128
158
|
const shardBytes = new Uint8Array(shardBytesLen);
|
|
129
159
|
shardBytes.set(this._currentBlobs.subarray(0, blobLen), 0);
|
|
@@ -137,10 +167,10 @@ export class PreserveWriter {
|
|
|
137
167
|
rowCount,
|
|
138
168
|
blobLen,
|
|
139
169
|
});
|
|
140
|
-
//
|
|
141
|
-
// the
|
|
142
|
-
|
|
143
|
-
|
|
170
|
+
// Reuse the working buffers for the next shard: reset the cursors only, keep
|
|
171
|
+
// the allocations (BS-27). No stale bytes can leak -- every read into these
|
|
172
|
+
// buffers is bounded by _currentBlobBytes / _currentRowCount (both zeroed
|
|
173
|
+
// here), and the shard copy above took only subarray(0, blobLen).
|
|
144
174
|
this._currentBlobBytes = 0;
|
|
145
175
|
this._currentRowCount = 0;
|
|
146
176
|
}
|
package/src/RangeReader.js
CHANGED
|
@@ -30,8 +30,11 @@
|
|
|
30
30
|
// Error codes (stable, share prefix with Reader.js where semantics overlap):
|
|
31
31
|
// R_BAD_MAGIC / R_UNSUPPORTED_VERSION / R_UNSUPPORTED_ENDIAN / R_BAD_FIELD_FLAGS
|
|
32
32
|
// R_UNSUPPORTED_LANE / R_SHARD_VERSION_TOO_NEW / R_TRUNCATED / R_UNKNOWN_FIELD
|
|
33
|
-
// R_ADAPTER_SHORT_READ -- adapter returned fewer bytes than requested
|
|
34
|
-
//
|
|
33
|
+
// R_ADAPTER_SHORT_READ -- adapter returned fewer bytes than requested, OR a
|
|
34
|
+
// non-Uint8Array / wrong-length adapter return (T7c)
|
|
35
|
+
// R_ROW_OUT_OF_RANGE -- rowIdx negative, fractional, NaN, or >= totalRows (BS-32)
|
|
36
|
+
// R_OFFSET_TOO_LARGE -- a u64 header/schema/directory offset exceeds 2^53-1
|
|
37
|
+
// R_NOT_PREFETCHED -- syncRange over a shard that prefetchRange has not cached
|
|
35
38
|
// R_WRONG_MODE -- preserve-mode container fed to the schema RangeReader
|
|
36
39
|
// R_BAD_FLAGS -- unknown header flag bits set
|
|
37
40
|
// R_RESERVED_NONZERO -- a reserved header/descriptor/shard field is non-zero
|
|
@@ -42,7 +45,7 @@
|
|
|
42
45
|
import { StringTable } from './StringTable.js';
|
|
43
46
|
import { checkOpts } from './Opts.js';
|
|
44
47
|
|
|
45
|
-
export const VERSION = '1.
|
|
48
|
+
export const VERSION = '1.5.0';
|
|
46
49
|
|
|
47
50
|
const CONTAINER_HEADER_BYTES = 48;
|
|
48
51
|
const SHARD_ENTRY_BYTES = 40;
|
|
@@ -65,6 +68,18 @@ export class RangeReaderError extends Error {
|
|
|
65
68
|
}
|
|
66
69
|
function raiseRange(code, msg) { throw new RangeReaderError(code, msg); }
|
|
67
70
|
|
|
71
|
+
// Read a u64 field from `dv` (the header, schema, or directory view -- three
|
|
72
|
+
// different DataViews here) and narrow it to a JS number, failing closed past
|
|
73
|
+
// Number.MAX_SAFE_INTEGER. Past 2^53-1 a Number() cast loses precision and every
|
|
74
|
+
// downstream bounds check reads a corrupted offset (BS-05). Cold: parse-only.
|
|
75
|
+
function u64(dv, off, what) {
|
|
76
|
+
const v = dv.getBigUint64(off, true);
|
|
77
|
+
if (v > 9007199254740991n)
|
|
78
|
+
throw new RangeReaderError('R_OFFSET_TOO_LARGE',
|
|
79
|
+
what + ' value ' + v + ' exceeds the safe-integer ceiling 9007199254740991');
|
|
80
|
+
return Number(v);
|
|
81
|
+
}
|
|
82
|
+
|
|
68
83
|
// Validate a local string table's shape before StringTable.parse casts a
|
|
69
84
|
// Uint32Array over it (T-1..T-4). `bytes` is the already-fetched shard slice;
|
|
70
85
|
// `off` and `len` locate the table within it.
|
|
@@ -205,7 +220,7 @@ export class RangeReader {
|
|
|
205
220
|
}
|
|
206
221
|
|
|
207
222
|
// Step 1: header (48 bytes) tells us schema + shard-dir offsets.
|
|
208
|
-
const headerBytes = await this.
|
|
223
|
+
const headerBytes = await this._fetchExact(0, CONTAINER_HEADER_BYTES);
|
|
209
224
|
const hdrDv = new DataView(headerBytes.buffer, headerBytes.byteOffset, headerBytes.byteLength);
|
|
210
225
|
if (headerBytes[0] !== 0x4C || headerBytes[1] !== 0x42 || headerBytes[2] !== 0x4B || headerBytes[3] !== 0x31) {
|
|
211
226
|
throw new RangeReaderError('R_BAD_MAGIC', 'header magic is not LBK1');
|
|
@@ -226,11 +241,11 @@ export class RangeReader {
|
|
|
226
241
|
if (reserved1 !== 0)
|
|
227
242
|
throw new RangeReaderError('R_RESERVED_NONZERO', 'header reserved1 at offset 36 must be 0, got ' + reserved1);
|
|
228
243
|
|
|
229
|
-
this._schemaBlockOff =
|
|
230
|
-
this._metadataOff =
|
|
231
|
-
this._shardDirOff =
|
|
244
|
+
this._schemaBlockOff = u64(hdrDv, 8, 'schema_block_off');
|
|
245
|
+
this._metadataOff = u64(hdrDv, 16, 'metadata_off');
|
|
246
|
+
this._shardDirOff = u64(hdrDv, 24, 'shard_directory_off');
|
|
232
247
|
this._shardCount = hdrDv.getUint32(32, true);
|
|
233
|
-
this._totalRows =
|
|
248
|
+
this._totalRows = u64(hdrDv, 40, 'total_rows');
|
|
234
249
|
|
|
235
250
|
const size = this.adapter.size;
|
|
236
251
|
if (this._schemaBlockOff < CONTAINER_HEADER_BYTES || this._schemaBlockOff >= size)
|
|
@@ -252,11 +267,11 @@ export class RangeReader {
|
|
|
252
267
|
if (this._shardDirOff <= this._schemaBlockOff)
|
|
253
268
|
throw new RangeReaderError('R_TRUNCATED', 'shard_directory_off ' + this._shardDirOff + ' not after schema_block_off ' + this._schemaBlockOff);
|
|
254
269
|
const schemaBlockLen = this._shardDirOff - this._schemaBlockOff;
|
|
255
|
-
const schemaBytes = await this.
|
|
270
|
+
const schemaBytes = await this._fetchExact(this._schemaBlockOff, schemaBlockLen);
|
|
256
271
|
this._parseSchema(schemaBytes);
|
|
257
272
|
|
|
258
273
|
// Step 3: shard directory (fixed size = shardCount * 40).
|
|
259
|
-
const dirBytes = await this.
|
|
274
|
+
const dirBytes = await this._fetchExact(this._shardDirOff, this._shardCount * SHARD_ENTRY_BYTES);
|
|
260
275
|
this._parseShardDirectory(dirBytes);
|
|
261
276
|
|
|
262
277
|
// Step 4 (M7): zone maps. Fetched ONCE at open, cached. Enables query
|
|
@@ -274,7 +289,7 @@ export class RangeReader {
|
|
|
274
289
|
|
|
275
290
|
async _loadFooter() {
|
|
276
291
|
const size = this.adapter.size;
|
|
277
|
-
const footer = await this.
|
|
292
|
+
const footer = await this._fetchExact(size - CONTAINER_FOOTER_BYTES, CONTAINER_FOOTER_BYTES);
|
|
278
293
|
if (footer[8] !== 0x31 || footer[9] !== 0x4B || footer[10] !== 0x42 || footer[11] !== 0x4C)
|
|
279
294
|
throw new RangeReaderError('R_BAD_FOOTER', 'footer magic_end is not 1KBL');
|
|
280
295
|
const fDv = new DataView(footer.buffer, footer.byteOffset, footer.byteLength);
|
|
@@ -291,7 +306,7 @@ export class RangeReader {
|
|
|
291
306
|
// corruption (D1), not absence.
|
|
292
307
|
if (this._metadataOff + 16 > this.adapter.size)
|
|
293
308
|
throw new RangeReaderError('R_BAD_METADATA', 'zone maps segment header runs past the container');
|
|
294
|
-
const hdr = await this.
|
|
309
|
+
const hdr = await this._fetchExact(this._metadataOff, 16);
|
|
295
310
|
if (hdr[0] !== 0x30 || hdr[1] !== 0x5A || hdr[2] !== 0x4D || hdr[3] !== 0x31)
|
|
296
311
|
throw new RangeReaderError('R_BAD_METADATA', 'zone maps magic is not ZM01');
|
|
297
312
|
const hdrDv = new DataView(hdr.buffer, hdr.byteOffset, hdr.byteLength);
|
|
@@ -309,7 +324,7 @@ export class RangeReader {
|
|
|
309
324
|
const restLen = fieldTableLen + fieldTablePad + shardCount * T * 8 * 2;
|
|
310
325
|
if (this._metadataOff + 16 + restLen > this.adapter.size)
|
|
311
326
|
throw new RangeReaderError('R_BAD_METADATA', 'zone maps segment runs past the container');
|
|
312
|
-
const rest = await this.
|
|
327
|
+
const rest = await this._fetchExact(this._metadataOff + 16, restLen);
|
|
313
328
|
const restDv = new DataView(rest.buffer, rest.byteOffset, rest.byteLength);
|
|
314
329
|
const tracked = new Array(T);
|
|
315
330
|
const fieldToPos = new Map();
|
|
@@ -357,7 +372,10 @@ export class RangeReader {
|
|
|
357
372
|
const laneKind = bytes[off + 4];
|
|
358
373
|
const flags = bytes[off + 5];
|
|
359
374
|
const reserved2 = dv.getUint16(off + 6, true);
|
|
360
|
-
const nameStrOff =
|
|
375
|
+
const nameStrOff = u64(dv, off + 8, 'field ' + i + ' name_str_off');
|
|
376
|
+
// reserved3 is BigInt-compared vs 0n below, never Number()-cast, so it
|
|
377
|
+
// needs no narrowing guard -- the reserved-must-be-zero check catches any
|
|
378
|
+
// out-of-range value (BS-05).
|
|
361
379
|
const reserved3 = dv.getBigUint64(off + 16, true);
|
|
362
380
|
if (flags !== 0) throw new RangeReaderError('R_BAD_FIELD_FLAGS', 'field ' + i + ' has non-zero flags');
|
|
363
381
|
if (reserved2 !== 0) throw new RangeReaderError('R_RESERVED_NONZERO', 'field ' + i + ' reserved2 must be 0, got ' + reserved2);
|
|
@@ -383,14 +401,14 @@ export class RangeReader {
|
|
|
383
401
|
let cumulativeRow = 0;
|
|
384
402
|
for (let i = 0; i < this._shardCount; i++) {
|
|
385
403
|
const off = i * SHARD_ENTRY_BYTES;
|
|
386
|
-
const payloadOff =
|
|
404
|
+
const payloadOff = u64(dv, off + 0, 'shard ' + i + ' payload_off');
|
|
387
405
|
const payloadLen = dv.getUint32(off + 8, true);
|
|
388
406
|
const rowCount = dv.getUint32(off + 12, true);
|
|
389
407
|
const minReaderVer = dv.getUint16(off + 16, true);
|
|
390
408
|
const shardFlags = dv.getUint16(off + 18, true);
|
|
391
409
|
const shardReserved = dv.getUint32(off + 20, true);
|
|
392
|
-
const localStrOff =
|
|
393
|
-
const localStrLen =
|
|
410
|
+
const localStrOff = u64(dv, off + 24, 'shard ' + i + ' local_string_off');
|
|
411
|
+
const localStrLen = u64(dv, off + 32, 'shard ' + i + ' local_string_len');
|
|
394
412
|
if (minReaderVer > READER_VERSION)
|
|
395
413
|
throw new RangeReaderError('R_SHARD_VERSION_TOO_NEW',
|
|
396
414
|
'shard ' + i + ' requires reader version ' + minReaderVer);
|
|
@@ -433,7 +451,10 @@ export class RangeReader {
|
|
|
433
451
|
|
|
434
452
|
// Locate the shard containing rowIdx via binary search.
|
|
435
453
|
_findShardIndex(rowIdx) {
|
|
436
|
-
|
|
454
|
+
// BS-32: Number.isInteger rejects fractional/NaN as well as out-of-range in
|
|
455
|
+
// one guard. This ONE site covers get(), prefetchRange, syncRange's bounds
|
|
456
|
+
// walk and the syncRange view's inner get.
|
|
457
|
+
if (!Number.isInteger(rowIdx) || rowIdx < 0 || rowIdx >= this._totalRows) {
|
|
437
458
|
throw new RangeReaderError('R_ROW_OUT_OF_RANGE',
|
|
438
459
|
'rowIdx ' + rowIdx + ' out of range [0, ' + this._totalRows + ')');
|
|
439
460
|
}
|
|
@@ -447,6 +468,21 @@ export class RangeReader {
|
|
|
447
468
|
return -1; // unreachable given the range check above
|
|
448
469
|
}
|
|
449
470
|
|
|
471
|
+
// Every adapter read funnels through here (T7c). The IOAdapter contract says
|
|
472
|
+
// fetch() MUST return exactly byteLength bytes; a short, over-long, or
|
|
473
|
+
// non-Uint8Array return would otherwise seed a DataView over the wrong extent
|
|
474
|
+
// and decode silent garbage. Fail closed with R_ADAPTER_SHORT_READ instead.
|
|
475
|
+
async _fetchExact(byteOffset, byteLength) {
|
|
476
|
+
const buf = await this.adapter.fetch(byteOffset, byteLength);
|
|
477
|
+
if (!(buf instanceof Uint8Array) || buf.byteLength !== byteLength) {
|
|
478
|
+
throw new RangeReaderError('R_ADAPTER_SHORT_READ',
|
|
479
|
+
'adapter returned ' +
|
|
480
|
+
(buf instanceof Uint8Array ? buf.byteLength + ' bytes' : Object.prototype.toString.call(buf)) +
|
|
481
|
+
' for a ' + byteLength + '-byte request at offset ' + byteOffset);
|
|
482
|
+
}
|
|
483
|
+
return buf;
|
|
484
|
+
}
|
|
485
|
+
|
|
450
486
|
// Load a shard's payload + local string table with ONE range request.
|
|
451
487
|
// They are contiguous in the container by SPEC 3.4, so a single fetch covers
|
|
452
488
|
// both. Returns the cached shard record.
|
|
@@ -464,7 +500,7 @@ export class RangeReader {
|
|
|
464
500
|
throw new RangeReaderError('R_TRUNCATED',
|
|
465
501
|
'shard ' + shardIdx + ' string table not contiguous with payload');
|
|
466
502
|
}
|
|
467
|
-
const combined = await this.
|
|
503
|
+
const combined = await this._fetchExact(combinedOff, combinedLen);
|
|
468
504
|
const payloadBytes = combined.subarray(0, s.payloadLen);
|
|
469
505
|
// DataView over the payload's underlying ArrayBuffer window.
|
|
470
506
|
const payloadDv = new DataView(payloadBytes.buffer, payloadBytes.byteOffset, payloadBytes.byteLength);
|
|
@@ -532,7 +568,7 @@ export class RangeReader {
|
|
|
532
568
|
const lastShard = this._findShardIndex(lastRow - 1);
|
|
533
569
|
for (let s = firstShard; s <= lastShard; s++) {
|
|
534
570
|
if (!this._shardCache.has(s)) {
|
|
535
|
-
throw new RangeReaderError('
|
|
571
|
+
throw new RangeReaderError('R_NOT_PREFETCHED',
|
|
536
572
|
'syncRange requires shard ' + s + ' to be prefetched (call prefetchRange first)');
|
|
537
573
|
}
|
|
538
574
|
}
|
package/src/Reader.js
CHANGED
|
@@ -21,11 +21,20 @@
|
|
|
21
21
|
// R_BAD_METADATA - metadata_off is non-zero but the zone-map segment is unparseable
|
|
22
22
|
// R_INVALID - a structure is internally inconsistent but in-bounds
|
|
23
23
|
// R_UNKNOWN_FIELD - get()/findShards()/shardBounds() called with an unknown field name
|
|
24
|
+
// R_OFFSET_TOO_LARGE - a u64 header/schema/directory offset exceeds 2^53-1
|
|
25
|
+
// (Number() would lose precision; fail closed, BS-05)
|
|
26
|
+
// R_ROW_OUT_OF_RANGE - get(rowIdx) with rowIdx negative, fractional, NaN,
|
|
27
|
+
// or >= totalRows (BS-32)
|
|
28
|
+
//
|
|
29
|
+
// Row-index policy (BS-32): get() range-checks rowIdx; the SHARD-index escape
|
|
30
|
+
// hatches (shardPayload, shardF64, shardStringTable) do NOT -- their contract is
|
|
31
|
+
// raw indexed access with no per-call checking. shardBounds returns null for an
|
|
32
|
+
// out-of-range shard by decided policy.
|
|
24
33
|
|
|
25
34
|
import { StringTable } from './StringTable.js';
|
|
26
35
|
import { toContainerBuffer } from './Views.js';
|
|
27
36
|
|
|
28
|
-
export const VERSION = '1.
|
|
37
|
+
export const VERSION = '1.5.0';
|
|
29
38
|
|
|
30
39
|
const CONTAINER_HEADER_BYTES = 48;
|
|
31
40
|
const SHARD_ENTRY_BYTES = 40;
|
|
@@ -42,6 +51,19 @@ export class ReaderError extends Error {
|
|
|
42
51
|
constructor(code, msg) { super(msg); this.code = code; this.name = 'ReaderError'; }
|
|
43
52
|
}
|
|
44
53
|
|
|
54
|
+
// Read a u64 header/directory/schema field and narrow it to a JS number, failing
|
|
55
|
+
// closed if it exceeds Number.MAX_SAFE_INTEGER (2^53-1). Past that a Number()
|
|
56
|
+
// cast loses precision and every downstream bounds check reads a corrupted
|
|
57
|
+
// offset -- an in-bounds lie is worse than a loud refusal (BS-05). Cold: called
|
|
58
|
+
// only during header/schema/directory parse, never per row.
|
|
59
|
+
function u64(dv, off, what) {
|
|
60
|
+
const v = dv.getBigUint64(off, true);
|
|
61
|
+
if (v > 9007199254740991n)
|
|
62
|
+
throw new ReaderError('R_OFFSET_TOO_LARGE',
|
|
63
|
+
what + ' value ' + v + ' exceeds the safe-integer ceiling 9007199254740991');
|
|
64
|
+
return Number(v);
|
|
65
|
+
}
|
|
66
|
+
|
|
45
67
|
// Validate a local string table's shape BEFORE StringTable.parse casts a
|
|
46
68
|
// Uint32Array over it (T-1..T-4): the offsets array must be in bounds, the
|
|
47
69
|
// count/blob must fit, offsets must be non-decreasing, and the trailing
|
|
@@ -118,11 +140,11 @@ export class Reader {
|
|
|
118
140
|
if (reserved1 !== 0)
|
|
119
141
|
throw new ReaderError('R_RESERVED_NONZERO', 'header reserved1 at offset 36 must be 0, got ' + reserved1);
|
|
120
142
|
|
|
121
|
-
this._schemaBlockOff =
|
|
122
|
-
this._metadataOff =
|
|
123
|
-
this._shardDirOff =
|
|
143
|
+
this._schemaBlockOff = u64(this._dv, 8, 'schema_block_off');
|
|
144
|
+
this._metadataOff = u64(this._dv, 16, 'metadata_off'); // 0 = no metadata block; M7+ zone maps
|
|
145
|
+
this._shardDirOff = u64(this._dv, 24, 'shard_directory_off');
|
|
124
146
|
this._shardCount = this._dv.getUint32(32, true);
|
|
125
|
-
this._totalRows =
|
|
147
|
+
this._totalRows = u64(this._dv, 40, 'total_rows');
|
|
126
148
|
this._formatVersion = version;
|
|
127
149
|
|
|
128
150
|
const len = this._buffer.byteLength;
|
|
@@ -180,7 +202,10 @@ export class Reader {
|
|
|
180
202
|
const laneKind = this._bytes[descOff + 4];
|
|
181
203
|
const flags = this._bytes[descOff + 5];
|
|
182
204
|
const reserved2 = this._dv.getUint16(descOff + 6, true);
|
|
183
|
-
const nameStrOff =
|
|
205
|
+
const nameStrOff = u64(this._dv, descOff + 8, 'field ' + i + ' name_str_off');
|
|
206
|
+
// reserved3 is compared as BigInt vs 0n below and never Number()-cast, so
|
|
207
|
+
// it needs no u64() narrowing guard -- a value past 2^53 is caught by the
|
|
208
|
+
// reserved-must-be-zero check, not by precision loss (BS-05).
|
|
184
209
|
const reserved3 = this._dv.getBigUint64(descOff + 16, true);
|
|
185
210
|
|
|
186
211
|
if (flags !== 0) throw new ReaderError('R_BAD_FIELD_FLAGS', 'field ' + i + ' has non-zero flags (v2+ reserved)');
|
|
@@ -212,14 +237,14 @@ export class Reader {
|
|
|
212
237
|
let rowSum = 0;
|
|
213
238
|
for (let i = 0; i < this._shardCount; i++) {
|
|
214
239
|
const entryOff = off + i * SHARD_ENTRY_BYTES;
|
|
215
|
-
const payloadOff =
|
|
240
|
+
const payloadOff = u64(this._dv, entryOff + 0, 'shard ' + i + ' payload_off');
|
|
216
241
|
const payloadLen = this._dv.getUint32(entryOff + 8, true);
|
|
217
242
|
const rowCount = this._dv.getUint32(entryOff + 12, true);
|
|
218
243
|
const minReaderVer = this._dv.getUint16(entryOff + 16, true);
|
|
219
244
|
const shardFlags = this._dv.getUint16(entryOff + 18, true);
|
|
220
245
|
const shardReserved = this._dv.getUint32(entryOff + 20, true);
|
|
221
|
-
const localStrOff =
|
|
222
|
-
const localStrLen =
|
|
246
|
+
const localStrOff = u64(this._dv, entryOff + 24, 'shard ' + i + ' local_string_off');
|
|
247
|
+
const localStrLen = u64(this._dv, entryOff + 32, 'shard ' + i + ' local_string_len');
|
|
223
248
|
if (minReaderVer > READER_VERSION) {
|
|
224
249
|
throw new ReaderError('R_SHARD_VERSION_TOO_NEW',
|
|
225
250
|
'shard ' + i + ' requires reader version ' + minReaderVer);
|
|
@@ -396,13 +421,24 @@ export class Reader {
|
|
|
396
421
|
return i;
|
|
397
422
|
}
|
|
398
423
|
|
|
424
|
+
// Cold: the BS-32 row-range refusal body, kept out of get()'s hot frame.
|
|
425
|
+
_badRow(rowIdx) {
|
|
426
|
+
throw new ReaderError('R_ROW_OUT_OF_RANGE',
|
|
427
|
+
'rowIdx ' + rowIdx + ' out of range [0, ' + this._totalRows + ')');
|
|
428
|
+
}
|
|
429
|
+
|
|
399
430
|
// Random-access get across all shards. Returns the field's decoded value:
|
|
400
431
|
// F64 lanes -> number
|
|
401
432
|
// U32 lanes -> JS string (resolved via the shard's local string table)
|
|
402
433
|
// Not zero-alloc; intended for testing/introspection.
|
|
403
434
|
get(rowIdx, fieldName) {
|
|
404
|
-
let remaining = rowIdx;
|
|
405
435
|
const fieldIdx = this.fieldIndex(fieldName);
|
|
436
|
+
// BS-32 row-range door. `(rowIdx >>> 0) !== rowIdx` rejects negative,
|
|
437
|
+
// fractional, NaN and >= 2^32 in one compare; a whole ArrayBuffer cannot hold
|
|
438
|
+
// 2^32 rows, so the totalRows compare fires first for every reachable
|
|
439
|
+
// container. Throw body hoisted to the cold _badRow helper.
|
|
440
|
+
if ((rowIdx >>> 0) !== rowIdx || rowIdx >= this._totalRows) this._badRow(rowIdx);
|
|
441
|
+
let remaining = rowIdx;
|
|
406
442
|
const field = this._schema.fields[fieldIdx];
|
|
407
443
|
for (let s = 0; s < this._shards.length; s++) {
|
|
408
444
|
const shard = this._shards[s];
|
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.
|
|
40
|
+
export const VERSION = '1.5.0';
|
|
41
41
|
|
|
42
42
|
const LF = 0x0A;
|
|
43
43
|
const CONTAINER_HEADER_BYTES = 48;
|
|
@@ -219,6 +219,11 @@ export function mergeContainers(containers) {
|
|
|
219
219
|
// Compute the target layout. Schema block is copied from container 0 verbatim.
|
|
220
220
|
// Reader gives us schemaBlockOff and shardDirOff; the schema-block byte range
|
|
221
221
|
// is [schemaBlockOff, shardDirOff). We reuse this slice.
|
|
222
|
+
// These Number(getBigUint64) reads carry NO u64 narrowing guard by design
|
|
223
|
+
// (BS-05 dominance): `new Reader(...)` at :193 above parsed every part first,
|
|
224
|
+
// and Reader's own guarded u64() over the same header bytes (schema_block_off,
|
|
225
|
+
// shard_directory_off) already threw R_OFFSET_TOO_LARGE for any over-2^53
|
|
226
|
+
// value. Split mints nothing here -- an untriggerable code would be dead.
|
|
222
227
|
const src0 = parts[0];
|
|
223
228
|
const dv0 = new DataView(src0.buffer, src0.byteOffset, src0.byteLength);
|
|
224
229
|
const schemaBlockOff = Number(dv0.getBigUint64(8, true));
|
|
@@ -383,7 +388,10 @@ function _schemasEqual(a, b) {
|
|
|
383
388
|
return true;
|
|
384
389
|
}
|
|
385
390
|
|
|
386
|
-
// Look up the source string-table byte length for a shard.
|
|
391
|
+
// Look up the source string-table byte length for a shard. No u64 narrowing
|
|
392
|
+
// guard here (BS-05 dominance): `reader` is a fully-parsed Reader, so Reader's
|
|
393
|
+
// guarded u64() over this same local_string_len field already threw
|
|
394
|
+
// R_OFFSET_TOO_LARGE for any over-2^53 value during construction.
|
|
387
395
|
function _sourceStringTableLen(reader, shardIdx) {
|
|
388
396
|
const dv = new DataView(reader.buffer);
|
|
389
397
|
const entryOff = reader.shardDirectoryOffset + shardIdx * SHARD_ENTRY_BYTES;
|
package/src/StringTable.js
CHANGED
|
@@ -19,8 +19,26 @@
|
|
|
19
19
|
// Entry 0 is always the empty string: the table reserves it in the constructor
|
|
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
|
+
//
|
|
23
|
+
// Serialization ceilings (BS-31). serialize() writes entry_count and
|
|
24
|
+
// blob_length as u32; a table that grew past either would silently wrap on
|
|
25
|
+
// setUint32 and mint a corrupt-but-in-bounds container. Two cold guards in
|
|
26
|
+
// _insertNew fail closed instead:
|
|
27
|
+
// ST_BLOB_OVERFLOW - the blob would exceed the u32 blob_length ceiling, OR
|
|
28
|
+
// the entry count would exceed the entry_count ceiling
|
|
29
|
+
// (one code, two messages -- both name their ceiling).
|
|
30
|
+
// The doubling clamps in _growBlob / _growOffsets cap allocation at the
|
|
31
|
+
// ceiling so a crossing write ALWAYS re-enters the grow branch where the guard
|
|
32
|
+
// lives; guard placement is then provably complete (no path reaches serialize
|
|
33
|
+
// with an out-of-range count/blob). Zero hot cost: the guards read only inside
|
|
34
|
+
// the cold grow arms.
|
|
35
|
+
//
|
|
36
|
+
// __setStringTableLimits({blobBytes, entryCount}) lowers the ceilings for a
|
|
37
|
+
// cheap gated crossing on the real grow path and returns the previous pair.
|
|
38
|
+
// TEST-ONLY: not re-exported from index.js, absent from the .d.ts and docs,
|
|
39
|
+
// and carrying no semver guarantee.
|
|
22
40
|
|
|
23
|
-
export const VERSION = '1.
|
|
41
|
+
export const VERSION = '1.5.0';
|
|
24
42
|
|
|
25
43
|
const EMPTY_SLOT = 0xFFFFFFFF; // MUST be unsigned; typed-array reads are unsigned
|
|
26
44
|
const INITIAL_BLOB_BYTES = 64 * 1024;
|
|
@@ -29,6 +47,26 @@ const INITIAL_HASH_CAP = 2048; // load factor target 50%
|
|
|
29
47
|
const HASH_MAX_LOAD_NUM = 1; // 50% load factor: num/den = 1/2
|
|
30
48
|
const HASH_MAX_LOAD_DEN = 2;
|
|
31
49
|
|
|
50
|
+
// u32 serialization ceilings. blob_length is a u32 (max 4294967295). entry_count
|
|
51
|
+
// is a u32, but the offsets array carries entry_count+1 slots (the sentinel), so
|
|
52
|
+
// the last addressable entry_count is 4294967294 to keep offsets_len a valid u32.
|
|
53
|
+
// `let`, not `const`: __setStringTableLimits lowers them for gated crossings.
|
|
54
|
+
let LIMIT_BLOB = 4294967295;
|
|
55
|
+
let LIMIT_ENTRIES = 4294967294;
|
|
56
|
+
|
|
57
|
+
// TEST-ONLY seam (BS-31). Assigns whichever of {blobBytes, entryCount} are
|
|
58
|
+
// present, returns the previous pair. Not part of the public API.
|
|
59
|
+
export function __setStringTableLimits(next) {
|
|
60
|
+
const prev = { blobBytes: LIMIT_BLOB, entryCount: LIMIT_ENTRIES };
|
|
61
|
+
if (next && next.blobBytes !== undefined) LIMIT_BLOB = next.blobBytes;
|
|
62
|
+
if (next && next.entryCount !== undefined) LIMIT_ENTRIES = next.entryCount;
|
|
63
|
+
return prev;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export class StringTableError extends Error {
|
|
67
|
+
constructor(code, msg) { super(msg); this.code = code; this.name = 'StringTableError'; }
|
|
68
|
+
}
|
|
69
|
+
|
|
32
70
|
// Reserved entry 0. A zero-length range: no blob bytes, no growth, one hash
|
|
33
71
|
// slot. Module-level so reset() reserves without allocating.
|
|
34
72
|
const EMPTY = new Uint8Array(0);
|
|
@@ -94,10 +132,23 @@ export class StringTable {
|
|
|
94
132
|
|
|
95
133
|
_insertNew(bytes, from, to, slot) {
|
|
96
134
|
const n = to - from;
|
|
97
|
-
// Grow blob if needed
|
|
98
|
-
|
|
99
|
-
//
|
|
100
|
-
if (this.
|
|
135
|
+
// Grow blob if needed. The clamp keeps _blob.length <= LIMIT_BLOB, so any
|
|
136
|
+
// write pushing past the ceiling necessarily re-enters this branch and is
|
|
137
|
+
// caught by the cold guard (fail closed -- serialize would wrap otherwise).
|
|
138
|
+
if (this._blobLen + n > this._blob.length) {
|
|
139
|
+
if (this._blobLen + n > LIMIT_BLOB)
|
|
140
|
+
throw new StringTableError('ST_BLOB_OVERFLOW',
|
|
141
|
+
'string blob ' + this._blobLen + ' + ' + n + ' would exceed the u32 blob_length ceiling ' + LIMIT_BLOB);
|
|
142
|
+
this._growBlob(n);
|
|
143
|
+
}
|
|
144
|
+
// Grow offsets if needed (leave room for +1 sentinel). Same completeness
|
|
145
|
+
// argument via the _growOffsets clamp on the entry_count ceiling.
|
|
146
|
+
if (this._count + 1 >= this._offsets.length) {
|
|
147
|
+
if (this._count >= LIMIT_ENTRIES)
|
|
148
|
+
throw new StringTableError('ST_BLOB_OVERFLOW',
|
|
149
|
+
'string table entry count ' + this._count + ' would exceed the entry_count ceiling ' + LIMIT_ENTRIES);
|
|
150
|
+
this._growOffsets();
|
|
151
|
+
}
|
|
101
152
|
|
|
102
153
|
const idx = this._count;
|
|
103
154
|
this._offsets[idx] = this._blobLen;
|
|
@@ -123,13 +174,18 @@ export class StringTable {
|
|
|
123
174
|
_growBlob(needed) {
|
|
124
175
|
let cap = this._blob.length;
|
|
125
176
|
while (cap < this._blobLen + needed) cap *= 2;
|
|
177
|
+
// Clamp so allocated length never exceeds the u32 ceiling (see _insertNew).
|
|
178
|
+
if (cap > LIMIT_BLOB) cap = LIMIT_BLOB;
|
|
126
179
|
const nb = new Uint8Array(cap);
|
|
127
180
|
nb.set(this._blob);
|
|
128
181
|
this._blob = nb;
|
|
129
182
|
}
|
|
130
183
|
|
|
131
184
|
_growOffsets() {
|
|
132
|
-
|
|
185
|
+
let len = this._offsets.length * 2;
|
|
186
|
+
// Clamp so the offsets array (entry_count + 1 slots) never exceeds a valid u32.
|
|
187
|
+
if (len > LIMIT_ENTRIES + 1) len = LIMIT_ENTRIES + 1;
|
|
188
|
+
const nb = new Uint32Array(len);
|
|
133
189
|
nb.set(this._offsets);
|
|
134
190
|
this._offsets = nb;
|
|
135
191
|
}
|
package/src/Tokenizer.js
CHANGED
package/src/Writer.js
CHANGED
|
@@ -23,6 +23,21 @@
|
|
|
23
23
|
// String fields intern via the per-shard StringTable which allocates only on
|
|
24
24
|
// unique inserts (bounded by cardinality, not row count).
|
|
25
25
|
//
|
|
26
|
+
// Shard-byte budget (D2): a shard rolls when its row count reaches the maxRows
|
|
27
|
+
// ceiling OR when payload_bytes + string_table_bytes reaches targetShardBytes.
|
|
28
|
+
// The byte term is CHUNK-INDEPENDENT: it reads only _currentShardRowCount,
|
|
29
|
+
// rowStride, _targetShardBytes and _stringTableBytes -- the first three are
|
|
30
|
+
// chunk-invariant and _stringTableBytes is a pure function of the unique
|
|
31
|
+
// interned byte ranges in arrival order (= logical record order, which the
|
|
32
|
+
// Tokenizer delivers identically for any input chunking). No chunk-local state
|
|
33
|
+
// (absOffset, buffer boundaries) enters, so re-chunked input yields byte-
|
|
34
|
+
// identical containers (the t0 law). A single row or string that alone exceeds
|
|
35
|
+
// targetShardBytes still gets its own oversized shard (maxRows has a
|
|
36
|
+
// Math.max(1, ...) floor and the budget runs post-write); payload_len stays
|
|
37
|
+
// u32-honest via the targetShardBytes constructor guard. A schema with no U32
|
|
38
|
+
// lane keeps a zero string budget and rolls on rows only -- byte-identical to
|
|
39
|
+
// the pre-D2 baseline.
|
|
40
|
+
//
|
|
26
41
|
// Error codes (stable):
|
|
27
42
|
// W_TOP_LEVEL_NOT_OBJECT - top-level value is not an object
|
|
28
43
|
// W_NESTED_UNSUPPORTED - nested object/array inside a record
|
|
@@ -43,7 +58,7 @@
|
|
|
43
58
|
import { StringTable } from './StringTable.js';
|
|
44
59
|
import { checkOpts } from './Opts.js';
|
|
45
60
|
|
|
46
|
-
export const VERSION = '1.
|
|
61
|
+
export const VERSION = '1.5.0';
|
|
47
62
|
|
|
48
63
|
const U32_MAX = 4294967295;
|
|
49
64
|
// Post-finalize sentinel for _recordDepth. Chosen = 2 so every post-finalize
|
|
@@ -105,6 +120,12 @@ export class WriterError extends Error {
|
|
|
105
120
|
}
|
|
106
121
|
}
|
|
107
122
|
|
|
123
|
+
// Shared decoder for sample-window key names (BS-27 floor). onKey fires once per
|
|
124
|
+
// key per sample record; a fresh TextDecoder each time is pure garbage. One
|
|
125
|
+
// module-scope instance, reused -- decode() is stateless across calls. The other
|
|
126
|
+
// TextDecoders in the codebase are per-open/lazy and stay as they are.
|
|
127
|
+
const KEY_DECODER = new TextDecoder();
|
|
128
|
+
|
|
108
129
|
function hashBytes(bytes, from, to) {
|
|
109
130
|
let h = 0x811c9dc5 | 0;
|
|
110
131
|
for (let i = from; i < to; i++) {
|
|
@@ -226,6 +247,15 @@ export class Writer {
|
|
|
226
247
|
this._shards = []; // { bytes, rowCount, stringTableBytes }
|
|
227
248
|
this._totalRows = 0;
|
|
228
249
|
|
|
250
|
+
// D2 shard-byte budget (zero-alloc, chunk-independent). _stringTableBytes is
|
|
251
|
+
// the serialized byte length the CURRENT shard's string table would emit RIGHT
|
|
252
|
+
// NOW; _maxInternedIdx is the highest interned index seen this shard, so a new
|
|
253
|
+
// unique arrival (strIdx > _maxInternedIdx) is detected in one compare. Both
|
|
254
|
+
// recomputed only in the cold unique-string arm (bounded by cardinality, not
|
|
255
|
+
// rows) and reset at each shard finalize. Real values are set at freeze.
|
|
256
|
+
this._stringTableBytes = 0;
|
|
257
|
+
this._maxInternedIdx = 0;
|
|
258
|
+
|
|
229
259
|
// Per-record parse state
|
|
230
260
|
this._recordDepth = 0;
|
|
231
261
|
this._currentKeyBytes = null;
|
|
@@ -285,7 +315,7 @@ export class Writer {
|
|
|
285
315
|
// buffer for the value bytes before onNumber/onString fires, so we cannot
|
|
286
316
|
// defer decoding. String allocation here is expected — bounded to the
|
|
287
317
|
// sample window; steady-state post-freeze remains zero-alloc.
|
|
288
|
-
this._currentKeyName =
|
|
318
|
+
this._currentKeyName = KEY_DECODER.decode(bytes.subarray(from, to));
|
|
289
319
|
}
|
|
290
320
|
}
|
|
291
321
|
|
|
@@ -343,8 +373,17 @@ export class Writer {
|
|
|
343
373
|
const lane = this._fieldLaneKinds[idx];
|
|
344
374
|
if (lane !== LANE_U32) throw new WriterError('W_LANE_MISMATCH',
|
|
345
375
|
'field ' + this._schema.fields[idx].name + ' is not a U32 lane but got a string');
|
|
346
|
-
const
|
|
376
|
+
const st = this._currentShardStringTable();
|
|
377
|
+
const strIdx = st.intern(bytes, from, to);
|
|
347
378
|
this._rowValueSlotsU32[idx] = strIdx;
|
|
379
|
+
// D2 (cold arm, unique arrivals only): recompute the serialized string-table
|
|
380
|
+
// byte length. serialize() emits (8 + 4*(count+1) + blobLen), 8-padded, and
|
|
381
|
+
// count = strIdx + 1 for a fresh entry, so the padded form is
|
|
382
|
+
// (16 + 4*strIdx + blobLen) rounded up to 8 -- exact vs StringTable.serialize.
|
|
383
|
+
if (strIdx > this._maxInternedIdx) {
|
|
384
|
+
this._maxInternedIdx = strIdx;
|
|
385
|
+
this._stringTableBytes = (16 + 4 * strIdx + st.blobLen + 7) & ~7;
|
|
386
|
+
}
|
|
348
387
|
} else {
|
|
349
388
|
if (!this._currentKeyName) return;
|
|
350
389
|
this._sample.setString(this._currentKeyName, bytes, from, to);
|
|
@@ -452,6 +491,19 @@ export class Writer {
|
|
|
452
491
|
this._fieldLaneKinds[i] = fields[i].laneKind;
|
|
453
492
|
this._fieldOffsets[i] = fields[i].offsetInRow;
|
|
454
493
|
}
|
|
494
|
+
// R7 floor: O(1) field-name resolution. hash -> field index, or an array of
|
|
495
|
+
// indices when two names collide (Array.isArray in the cold arm). Built once
|
|
496
|
+
// at freeze; _lookupFieldIdx replaces its O(F) scan with one Map.get plus the
|
|
497
|
+
// mandatory byte-equal confirm, so a hash collision still resolves correctly.
|
|
498
|
+
const fieldHashMap = new Map();
|
|
499
|
+
for (let i = 0; i < fields.length; i++) {
|
|
500
|
+
const h = this._fieldNameHashes[i];
|
|
501
|
+
const existing = fieldHashMap.get(h);
|
|
502
|
+
if (existing === undefined) fieldHashMap.set(h, i);
|
|
503
|
+
else if (Array.isArray(existing)) existing.push(i);
|
|
504
|
+
else fieldHashMap.set(h, [existing, i]);
|
|
505
|
+
}
|
|
506
|
+
this._fieldHashMap = fieldHashMap;
|
|
455
507
|
this._rowValueSlotsF64 = new Float64Array(fields.length);
|
|
456
508
|
this._rowValueSlotsU32 = new Uint32Array(fields.length);
|
|
457
509
|
this._currentShardMaxRows = Math.max(1, Math.floor(this._targetShardBytes / rowStride));
|
|
@@ -467,14 +519,27 @@ export class Writer {
|
|
|
467
519
|
this._trackedFieldToPos = new Int32Array(fields.length);
|
|
468
520
|
for (let i = 0; i < fields.length; i++) this._trackedFieldToPos[i] = -1;
|
|
469
521
|
for (let t = 0; t < tracked.length; t++) this._trackedFieldToPos[tracked[t]] = t;
|
|
522
|
+
|
|
523
|
+
// D2 budget baseline. A fresh string table always carries reserved entry 0,
|
|
524
|
+
// so a U32-lane shard with no real strings still serializes to 16 bytes; a
|
|
525
|
+
// schema with no U32 lane emits NO table (0 bytes, EMPTY_STRING_TABLE_BYTES).
|
|
526
|
+
// Seeding _stringTableBytes to 0 for the F64-only case keeps such containers
|
|
527
|
+
// byte-identical to the pre-D2 baseline (the roll then fires on rows only via
|
|
528
|
+
// the maxRows term, which is <= the byte term for a zero string budget).
|
|
529
|
+
this._maxInternedIdx = 0;
|
|
530
|
+
this._stringTableBytes = this._hasU32 ? 16 : 0;
|
|
470
531
|
}
|
|
471
532
|
|
|
472
533
|
_lookupFieldIdx(bytes, from, to) {
|
|
473
534
|
const h = hashBytes(bytes, from, to);
|
|
474
|
-
const
|
|
535
|
+
const hit = this._fieldHashMap.get(h);
|
|
536
|
+
if (hit === undefined) return -1;
|
|
475
537
|
const names = this._fieldNamesUtf8;
|
|
476
|
-
|
|
477
|
-
|
|
538
|
+
// Hit path (the common case): a single index. Byte-confirm and return.
|
|
539
|
+
if (typeof hit === 'number') return bytesEqual(bytes, from, to, names[hit]) ? hit : -1;
|
|
540
|
+
// Cold: >= 2 field names share this hash; byte-confirm each candidate.
|
|
541
|
+
for (let k = 0; k < hit.length; k++) {
|
|
542
|
+
if (bytesEqual(bytes, from, to, names[hit[k]])) return hit[k];
|
|
478
543
|
}
|
|
479
544
|
return -1;
|
|
480
545
|
}
|
|
@@ -511,7 +576,15 @@ export class Writer {
|
|
|
511
576
|
}
|
|
512
577
|
this._currentShardRowCount++;
|
|
513
578
|
this._totalRows++;
|
|
514
|
-
|
|
579
|
+
// D2 roll: fire on the row ceiling OR when payload + string-table bytes
|
|
580
|
+
// reach the target. The row-ceiling term fires first for any F64-only shard
|
|
581
|
+
// (string budget 0, maxRows = floor(target/stride) <= target/stride), so
|
|
582
|
+
// such containers stay byte-identical to baseline; string shards can roll
|
|
583
|
+
// earlier. Zero allocation -- one add + compare per row.
|
|
584
|
+
const n = this._currentShardRowCount;
|
|
585
|
+
if (n >= this._currentShardMaxRows ||
|
|
586
|
+
n * this._schema.rowStride + this._stringTableBytes >= this._targetShardBytes)
|
|
587
|
+
this._finalizeCurrentShard();
|
|
515
588
|
} else {
|
|
516
589
|
const endOff = this._source !== null ? this._source.absOffset : this._absOffset;
|
|
517
590
|
const consumed = Math.max(1, endOff - this._recordStartByteOffset);
|
|
@@ -564,6 +637,10 @@ export class Writer {
|
|
|
564
637
|
bytes: copy,
|
|
565
638
|
rowCount: this._currentShardRowCount,
|
|
566
639
|
stringTableBytes: stBytes,
|
|
640
|
+
// D2 audit: the incrementally-tracked budget at this finalize. MUST equal
|
|
641
|
+
// stBytes.length (the emitted directory local_string_len). Cross-checked by
|
|
642
|
+
// Ceilings.test.js via __stringTableBudgetAudit; not part of the public API.
|
|
643
|
+
budgetTracked: this._stringTableBytes,
|
|
567
644
|
mins: shardMins,
|
|
568
645
|
maxes: shardMaxes,
|
|
569
646
|
});
|
|
@@ -574,6 +651,9 @@ export class Writer {
|
|
|
574
651
|
// Reset string table for next shard (per-shard independence)
|
|
575
652
|
st.reset();
|
|
576
653
|
this._perShardStringTable = st;
|
|
654
|
+
// Reset the D2 budget trackers alongside st.reset() (per-shard independence).
|
|
655
|
+
this._maxInternedIdx = 0;
|
|
656
|
+
this._stringTableBytes = this._hasU32 ? 16 : 0;
|
|
577
657
|
}
|
|
578
658
|
|
|
579
659
|
_drainSampleToShards() {
|
|
@@ -607,6 +687,11 @@ export class Writer {
|
|
|
607
687
|
const trackedIdx = this._trackedFieldToPos;
|
|
608
688
|
const mins = this._currentShardMins;
|
|
609
689
|
const maxes = this._currentShardMaxes;
|
|
690
|
+
// D2: honour the same string-aware byte budget the post-freeze hot path
|
|
691
|
+
// uses, so a sample-drained container and a post-freeze one agree on shard
|
|
692
|
+
// boundaries for identical logical input. writtenRows may finish below
|
|
693
|
+
// chunkRows when the string table pushes the shard over target.
|
|
694
|
+
let writtenRows = chunkRows;
|
|
610
695
|
for (let r = 0; r < chunkRows; r++) {
|
|
611
696
|
const rowOff = r * stride;
|
|
612
697
|
for (let f = 0; f < fields.length; f++) {
|
|
@@ -625,14 +710,24 @@ export class Writer {
|
|
|
625
710
|
const strBytes = sampleTable.bytesAt(sampleIdx);
|
|
626
711
|
const newIdx = strBytes ? dstTable.intern(strBytes, 0, strBytes.length) : 0;
|
|
627
712
|
dv.setUint32(fo, newIdx, true);
|
|
713
|
+
if (newIdx > this._maxInternedIdx) {
|
|
714
|
+
this._maxInternedIdx = newIdx;
|
|
715
|
+
this._stringTableBytes = (16 + 4 * newIdx + dstTable.blobLen + 7) & ~7;
|
|
716
|
+
}
|
|
628
717
|
}
|
|
629
718
|
}
|
|
719
|
+
const done = r + 1;
|
|
720
|
+
if (done < chunkRows &&
|
|
721
|
+
done * stride + this._stringTableBytes >= this._targetShardBytes) {
|
|
722
|
+
writtenRows = done;
|
|
723
|
+
break;
|
|
724
|
+
}
|
|
630
725
|
}
|
|
631
|
-
this._currentShardRowCount =
|
|
632
|
-
this._totalRows +=
|
|
726
|
+
this._currentShardRowCount = writtenRows;
|
|
727
|
+
this._totalRows += writtenRows;
|
|
633
728
|
this._finalizeCurrentShard(); // serializes dstTable, resets in-place for the next iter
|
|
634
|
-
srcRow +=
|
|
635
|
-
rowsRemaining -=
|
|
729
|
+
srcRow += writtenRows;
|
|
730
|
+
rowsRemaining -= writtenRows;
|
|
636
731
|
}
|
|
637
732
|
|
|
638
733
|
this._sample = null;
|
|
@@ -836,4 +931,17 @@ export class Writer {
|
|
|
836
931
|
get schema() { return this._schema; }
|
|
837
932
|
get totalRows() { return this._totalRows; }
|
|
838
933
|
get shardCount() { return this._shards.length + (this._currentShardRowCount > 0 ? 1 : 0); }
|
|
934
|
+
|
|
935
|
+
// @internal TEST-ONLY (D2): per-shard [{ tracked, emitted }] string-table byte
|
|
936
|
+
// lengths. tracked is the incrementally-maintained budget at each finalize;
|
|
937
|
+
// emitted is the serialized local_string_len that reached the container. They
|
|
938
|
+
// MUST be equal at every shard -- Ceilings.test.js proves it, closing the
|
|
939
|
+
// formula-drift bug class. No semver guarantee; absent from the public API.
|
|
940
|
+
__stringTableBudgetAudit() {
|
|
941
|
+
const out = new Array(this._shards.length);
|
|
942
|
+
for (let i = 0; i < this._shards.length; i++) {
|
|
943
|
+
out[i] = { tracked: this._shards[i].budgetTracked, emitted: this._shards[i].stringTableBytes.length };
|
|
944
|
+
}
|
|
945
|
+
return out;
|
|
946
|
+
}
|
|
839
947
|
}
|
package/src/index.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { Tokenizer, TokenizerError } from './Tokenizer.js';
|
|
13
13
|
import { Writer, WriterError } from './Writer.js';
|
|
14
14
|
import { Reader, ReaderError } from './Reader.js';
|
|
15
|
-
import { StringTable } from './StringTable.js';
|
|
15
|
+
import { StringTable, StringTableError } from './StringTable.js';
|
|
16
16
|
import { PreserveTokenizer, PreserveTokenizerError } from './PreserveTokenizer.js';
|
|
17
17
|
import { PreserveWriter, PreserveWriterError } from './PreserveWriter.js';
|
|
18
18
|
import { PreserveReader, PreserveReaderError } from './PreserveReader.js';
|
|
@@ -23,12 +23,12 @@ export {
|
|
|
23
23
|
Tokenizer, TokenizerError,
|
|
24
24
|
Writer, WriterError,
|
|
25
25
|
Reader, ReaderError,
|
|
26
|
-
StringTable,
|
|
26
|
+
StringTable, StringTableError,
|
|
27
27
|
PreserveTokenizer, PreserveTokenizerError,
|
|
28
28
|
PreserveWriter, PreserveWriterError,
|
|
29
29
|
PreserveReader, PreserveReaderError,
|
|
30
30
|
};
|
|
31
|
-
export const VERSION = '1.
|
|
31
|
+
export const VERSION = '1.5.0';
|
|
32
32
|
|
|
33
33
|
const encoder = new TextEncoder();
|
|
34
34
|
|
package/types/StringTable.d.ts
CHANGED
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
|
|
4
4
|
export const VERSION: string;
|
|
5
5
|
|
|
6
|
+
export class StringTableError extends Error {
|
|
7
|
+
readonly code: string;
|
|
8
|
+
readonly name: 'StringTableError';
|
|
9
|
+
}
|
|
10
|
+
|
|
6
11
|
export class StringTable {
|
|
7
12
|
constructor();
|
|
8
13
|
/** Intern a byte range. Zero-alloc on the hit path. Returns u32 index. */
|
package/types/index.d.ts
CHANGED
|
@@ -13,7 +13,7 @@ export { Reader, ReaderError } from './Reader.d.ts';
|
|
|
13
13
|
export type {
|
|
14
14
|
ShardHandle, StringTableView, Bounds, FindShardsOptions,
|
|
15
15
|
} from './Reader.d.ts';
|
|
16
|
-
export { StringTable } from './StringTable.d.ts';
|
|
16
|
+
export { StringTable, StringTableError } from './StringTable.d.ts';
|
|
17
17
|
export { PreserveTokenizer, PreserveTokenizerError } from './PreserveTokenizer.d.ts';
|
|
18
18
|
export type { PreserveSink, PreserveTokenizerOptions } from './PreserveTokenizer.d.ts';
|
|
19
19
|
export { PreserveWriter, PreserveWriterError } from './PreserveWriter.d.ts';
|