@zakkster/lite-bake-stream 1.6.1 → 1.7.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 +17 -0
- package/README.md +7 -5
- package/SPEC.md +1 -1
- package/llms.txt +4 -3
- package/package.json +1 -1
- package/src/FileIngest.js +1 -1
- package/src/MultiReader.js +1 -1
- package/src/Opts.js +19 -0
- package/src/PreserveReader.js +1 -1
- package/src/PreserveTokenizer.js +1 -1
- package/src/PreserveWriter.js +1 -1
- package/src/RangeReader.js +109 -16
- package/src/Reader.js +1 -1
- package/src/Split.js +1 -1
- package/src/StringTable.js +1 -1
- package/src/Tokenizer.js +1 -1
- package/src/Writer.js +1 -1
- package/src/index.js +1 -1
- package/types/RangeReader.d.ts +20 -4
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
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.7.0] -- 2026-09-02
|
|
6
|
+
|
|
7
|
+
MQ1 -- abortable range reads (BS-28). RangeReader gains reader-level cancellation so a mount/unmount consumer (lite-query's abort-on-detach law) can cancel in-flight range I/O instead of orphaning it.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- `RangeReader` constructor / `RangeReader.open` accept `{ signal }` (an AbortSignal-shaped object); `HTTPRangeAdapter.open` accepts and forwards `{ signal }` into its HEAD + probe GET. One signal governs the reader's lifetime; once it fires the reader is permanently dead for new I/O (design A, decisions/0013-range-abort.md).
|
|
11
|
+
- Additive adapter contract: `fetch(byteOffset, byteLength, signal?)`. A 2-arg adapter degrades cancellation only, never correctness -- every shipped 1.6.1 adapter and call site stays conformant byte-for-byte.
|
|
12
|
+
- One new thrown code `R_ABORTED` (pinned same-diff in test/RangeAbort.test.js); inventory 60 -> 61 codes / 0 unpinned. The fail-closed triangle: a pre-aborted signal requests zero bytes; a hanging adapter still rejects within the tick of the abort; resolved-after-abort bytes are discarded and nothing is cached. A foreign `AbortError` rejection while aborted maps to `R_ABORTED` (never leaks); a non-abort rejection rethrows verbatim.
|
|
13
|
+
- The abort listener is removed on settle in both directions, so a long-lived (page-lifetime) signal retains nothing (t7-soak abort retention witness; the swallow lens is BREAK id `r`). New t9 in-process control `(q)` proves the pre-aborted-open assertion bites; named controls 16 -> 17.
|
|
14
|
+
- `MockRangeAdapter` gains observation-only `signals` (index-aligned with `log`) and `lastSignal`; the `log` shape stays byte-identical.
|
|
15
|
+
- decisions/0013-range-abort.md; test/RangeAbort.test.js.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
- Opts.js gains a `{ t: 'signal' }` descriptor: a malformed signal fails closed with the existing `E_OPTION_VALUE` (shape-check -- aborted boolean + addEventListener/removeEventListener -- not instanceof, so a cross-realm signal is accepted). No new validation code.
|
|
19
|
+
|
|
20
|
+
Suite: 519 -> 530 tests / 530 pass / 0 fail / 0 todo. Torture 44 fast / 47 full; t9 controls 16 -> 17. BREAK matrix adds `r` (BAKE_TORTURE_BREAK=r exits non-zero). All 16 version sites move to 1.7.0 at this release; the one grep skip is decisions/0013's historical "shipped 1.6.1 adapter" compat statement.
|
|
21
|
+
|
|
5
22
|
## [1.6.1] -- 2026-09-02
|
|
6
23
|
|
|
7
24
|
M7 -- docs, comment prose, and two new guards. No src logic, no API surface change (exports map, error codes, and types signatures all unchanged; inventory 60 codes / 0 unpinned).
|
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|

|
|
14
14
|
[](./LICENSE)
|
|
15
15
|
|
|
16
|
-
**Status:** v1.
|
|
16
|
+
**Status:** v1.7.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
|
## The gigabyte-scale JSON front door the ecosystem was missing
|
|
19
19
|
|
|
@@ -164,7 +164,8 @@ Every subpath also exports a `VERSION` const.
|
|
|
164
164
|
plus zone-map query APIs `shardBounds(shardIdx, field)` and
|
|
165
165
|
`findShards(field, range)`.
|
|
166
166
|
- `RangeReader` (`/range-reader`) -- HTTP Range lazy shard loading; synchronous
|
|
167
|
-
query pruning after open.
|
|
167
|
+
query pruning after open. Optional `{ signal }` cancels in-flight range I/O
|
|
168
|
+
(`R_ABORTED`); once it fires the reader is dead for new I/O.
|
|
168
169
|
- `MultiReader` (`/multi-reader`) -- logical union over N Readers sharing a
|
|
169
170
|
schema.
|
|
170
171
|
- `splitNDJSON`, `compilePart`, `compileInParts`, `mergeContainers` (`/split`) --
|
|
@@ -220,7 +221,7 @@ the torture gate):
|
|
|
220
221
|
| :----- | :----------------------------------------------------------------- |
|
|
221
222
|
| `E_` | `TokenizerError` (e.g. `E_NUMBER_OVERFLOW`) |
|
|
222
223
|
| `W_` | `WriterError` (`W_MIXED_LANE_TYPES`, `W_LANE_MISMATCH`, `W_BAD_SINK`, `W_FINALIZED`) |
|
|
223
|
-
| `R_` | `ReaderError` / `RangeReaderError` (`R_ROW_OUT_OF_RANGE`, `R_OFFSET_TOO_LARGE`, `R_BAD_CRC`, `R_CRC_ABSENT`, ...) |
|
|
224
|
+
| `R_` | `ReaderError` / `RangeReaderError` (`R_ROW_OUT_OF_RANGE`, `R_OFFSET_TOO_LARGE`, `R_BAD_CRC`, `R_CRC_ABSENT`, `R_ABORTED`, ...) |
|
|
224
225
|
| `M_` | `MultiReaderError` (`M_ROW_OUT_OF_RANGE`) |
|
|
225
226
|
| `S_` | `SplitError` |
|
|
226
227
|
| `ST_` | `StringTableError` (`ST_BLOB_OVERFLOW`) |
|
|
@@ -243,7 +244,8 @@ const containers = compileInParts(parts);
|
|
|
243
244
|
const merged = mergeContainers(containers);
|
|
244
245
|
|
|
245
246
|
// 3. open the merged container and prune shards before fetching them
|
|
246
|
-
|
|
247
|
+
// (pass { signal } to cancel in-flight range I/O when the consumer detaches)
|
|
248
|
+
const reader = await RangeReader.open(adapterOver(merged), { signal: controller.signal });
|
|
247
249
|
const shards = reader.findShards('id', { min: 500, max: 999 });
|
|
248
250
|
for (const s of shards) reader.get(s.firstRow, 'id');
|
|
249
251
|
```
|
|
@@ -316,7 +318,7 @@ The gates run at scales chosen to fit different hardware and time budgets:
|
|
|
316
318
|
|
|
317
319
|
| Command | Scale | Use |
|
|
318
320
|
| :-- | :-- | :-- |
|
|
319
|
-
| `npm test` |
|
|
321
|
+
| `npm test` | 530 tests across 37 files | Dev loop, every save |
|
|
320
322
|
| `npm run torture` | 44 fast scenarios (~1 MB each) | Before every commit |
|
|
321
323
|
| `npm run torture:full` | 47 scenarios incl. the 500 MB soak | Before every publish |
|
|
322
324
|
| `npm run soak` | 100 MB with preservation gate | Sanity check |
|
package/SPEC.md
CHANGED
|
@@ -297,7 +297,7 @@ Subpath entries carved so consumers pay only for what they import. Each entry is
|
|
|
297
297
|
| `@zakkster/lite-bake-stream/reader` | LBK1 parser over an in-memory `ArrayBuffer`. Synchronous. Zone-maps query APIs. |
|
|
298
298
|
| `@zakkster/lite-bake-stream/string-table` | Byte-level UTF-8 interning primitive. |
|
|
299
299
|
| `@zakkster/lite-bake-stream/file-ingest` | Browser helper: `File.stream()` -> Reader. Any `ReadableStream<Uint8Array>` works. |
|
|
300
|
-
| `@zakkster/lite-bake-stream/range-reader` | LBK1 parser with lazy shard loading via an IO adapter. HTTP Range fetch or in-memory mock. Synchronous zone-maps pruning. |
|
|
300
|
+
| `@zakkster/lite-bake-stream/range-reader` | LBK1 parser with lazy shard loading via an IO adapter. HTTP Range fetch or in-memory mock. Synchronous zone-maps pruning. Optional reader-level `{ signal }` cancels in-flight range I/O (`R_ABORTED`); the adapter `fetch(byteOffset, byteLength, signal?)` contract is additive (a 2-arg adapter degrades cancellation only). |
|
|
301
301
|
| `@zakkster/lite-bake-stream/multi-reader` | Logical union over N Reader instances. Rows and shards cumulatively addressed; findShards merges across sub-readers. |
|
|
302
302
|
| `@zakkster/lite-bake-stream/split` | NDJSON splitter + per-part compile + container merge. Executor-agnostic -- wire to any worker pool or run serially. |
|
|
303
303
|
|
package/llms.txt
CHANGED
|
@@ -8,13 +8,13 @@ Ingest gigabyte-scale JSON (top-level array or NDJSON) into the LBK1 binary cont
|
|
|
8
8
|
|
|
9
9
|
## Status
|
|
10
10
|
|
|
11
|
-
v1.
|
|
11
|
+
v1.7.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.7.0)
|
|
18
18
|
|
|
19
19
|
Two ingest modes share one top-level API:
|
|
20
20
|
|
|
@@ -27,7 +27,7 @@ Schema-mode classes:
|
|
|
27
27
|
- `Tokenizer` (`/tokenizer`): chunk-safe UTF-8 JSON SAX scanner.
|
|
28
28
|
- `Writer` (`/writer`): LBK1 shard emitter, plugs into Tokenizer. F64/U32 lanes, per-shard string tables and zone maps.
|
|
29
29
|
- `Reader` (`/reader`): container parser, sync, zero-alloc `get(row, field)` accessors + zone-maps query APIs (`shardBounds`, `findShards`).
|
|
30
|
-
- `RangeReader` (`/range-reader`): HTTP Range lazy shard loading, synchronous query pruning after open.
|
|
30
|
+
- `RangeReader` (`/range-reader`): HTTP Range lazy shard loading, synchronous query pruning after open. Optional `{ signal }` cancels in-flight range I/O; the reader is dead for new I/O once it fires (BS-28).
|
|
31
31
|
- `MultiReader` (`/multi-reader`): logical union over N Readers sharing a schema.
|
|
32
32
|
- `splitNDJSON`, `compilePart`, `compileInParts`, `mergeContainers` (`/split`): worker-agnostic split/compile/merge.
|
|
33
33
|
|
|
@@ -64,6 +64,7 @@ Every declared field of every row round-trips. F64 lanes: bit-exact for numeric
|
|
|
64
64
|
- 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.
|
|
65
65
|
- `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.
|
|
66
66
|
- `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.
|
|
67
|
+
- `R_ABORTED`: a reader-level `{ signal }` (constructor / `RangeReader.open`, forwarded into `HTTPRangeAdapter.open`) fired -- in-flight range I/O is cancelled. Fail-closed triangle: a pre-aborted signal requests zero bytes; a hanging adapter still rejects within the tick of the abort (the abort listener is removed on settle in both directions, so a long-lived signal retains nothing); resolved-after-abort bytes are discarded and nothing is cached. A foreign `AbortError` rejection while aborted maps here (never leaks); a non-abort rejection rethrows verbatim. The adapter contract goes additive `fetch(byteOffset, byteLength, signal?)` -- a 2-arg adapter degrades cancellation only, never correctness. See decisions/0013-range-abort.md.
|
|
67
68
|
- `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.
|
|
68
69
|
|
|
69
70
|
## Shard budget
|
package/package.json
CHANGED
package/src/FileIngest.js
CHANGED
|
@@ -31,7 +31,7 @@ import { PreserveWriter } from './PreserveWriter.js';
|
|
|
31
31
|
import { PreserveReader } from './PreserveReader.js';
|
|
32
32
|
import { checkOpts } from './Opts.js';
|
|
33
33
|
|
|
34
|
-
export const VERSION = '1.
|
|
34
|
+
export const VERSION = '1.7.0';
|
|
35
35
|
|
|
36
36
|
const U32_MAX = 4294967295;
|
|
37
37
|
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.7.0';
|
|
29
29
|
|
|
30
30
|
export class MultiReaderError extends Error {
|
|
31
31
|
constructor(code, msg) { super(msg); this.code = code; this.name = 'MultiReaderError'; }
|
package/src/Opts.js
CHANGED
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
// { t: 'int', min, max } - integer in [min, max] inclusive
|
|
19
19
|
// { t: 'bool' } - boolean
|
|
20
20
|
// { t: 'fn' } - function
|
|
21
|
+
// { t: 'signal' } - AbortSignal-shaped (aborted boolean +
|
|
22
|
+
// addEventListener/removeEventListener fns)
|
|
21
23
|
// { t: 'obj', nullable } - object (contents validated downstream)
|
|
22
24
|
// A value of `undefined` for any key means "use the default" and is skipped,
|
|
23
25
|
// matching each host's `opts.k !== undefined ? opts.k : DEFAULT` defaulting.
|
|
@@ -51,8 +53,10 @@
|
|
|
51
53
|
// totalBytes int [0, 9007199254740991] def -1 (ingest only)
|
|
52
54
|
// RangeReader(adapter,opts) / .open
|
|
53
55
|
// maxCachedShards int [0, 4294967295] def 8 (0 = retain no shard)
|
|
56
|
+
// signal AbortSignal-shaped def none (no cancellation)
|
|
54
57
|
// HTTPRangeAdapter.open(url,opts)
|
|
55
58
|
// fetch function def globalThis.fetch
|
|
59
|
+
// signal AbortSignal-shaped def none (no cancellation)
|
|
56
60
|
// splitNDJSON(bytes,opts) / compileInParts
|
|
57
61
|
// targetParts int [1, 4294967295] def 4 (0 forbidden)
|
|
58
62
|
// maxPartBytes int [1, 4294967295] or Infinity def Infinity (0 forbidden; Infinity = no cap)
|
|
@@ -119,6 +123,21 @@ function _checkValue(label, key, v, d, raise) {
|
|
|
119
123
|
}
|
|
120
124
|
return;
|
|
121
125
|
}
|
|
126
|
+
if (t === 'signal') {
|
|
127
|
+
// AbortSignal by DUCK shape, not instanceof (cross-realm false negatives
|
|
128
|
+
// against the exact scheduled consumer). removeEventListener is REQUIRED --
|
|
129
|
+
// a signal that cannot be un-listened cannot satisfy the settle-removal law
|
|
130
|
+
// (unverified state, refused at the door). See decisions/0013.
|
|
131
|
+
if (v === null || typeof v !== 'object' ||
|
|
132
|
+
typeof v.aborted !== 'boolean' ||
|
|
133
|
+
typeof v.addEventListener !== 'function' ||
|
|
134
|
+
typeof v.removeEventListener !== 'function') {
|
|
135
|
+
raise('E_OPTION_VALUE',
|
|
136
|
+
label + ": option '" + key + "' must be an AbortSignal (aborted boolean + " +
|
|
137
|
+
'addEventListener/removeEventListener); got ' + _show(v));
|
|
138
|
+
}
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
122
141
|
if (t === 'obj') {
|
|
123
142
|
if (v === null) {
|
|
124
143
|
if (!d.nullable) {
|
package/src/PreserveReader.js
CHANGED
|
@@ -37,7 +37,7 @@ import { toContainerBuffer } from './Views.js';
|
|
|
37
37
|
import { checkOpts } from './Opts.js';
|
|
38
38
|
import { crc32cInit, crc32cUpdate, crc32cFinal } from './Crc32c.js';
|
|
39
39
|
|
|
40
|
-
export const VERSION = '1.
|
|
40
|
+
export const VERSION = '1.7.0';
|
|
41
41
|
|
|
42
42
|
const CONTAINER_HEADER_BYTES = 48;
|
|
43
43
|
const SHARD_ENTRY_BYTES = 40;
|
package/src/PreserveTokenizer.js
CHANGED
package/src/PreserveWriter.js
CHANGED
|
@@ -26,7 +26,7 @@ import { checkOpts } from './Opts.js';
|
|
|
26
26
|
import { crc32cInit, crc32cUpdate, crc32cFinal, crc32cCombine } from './Crc32c.js';
|
|
27
27
|
import { validateSink, isThenable } from './Views.js';
|
|
28
28
|
|
|
29
|
-
export const VERSION = '1.
|
|
29
|
+
export const VERSION = '1.7.0';
|
|
30
30
|
|
|
31
31
|
export class PreserveWriterError extends Error {
|
|
32
32
|
constructor(code, msg) { super(msg); this.code = code; this.name = 'PreserveWriterError'; }
|
package/src/RangeReader.js
CHANGED
|
@@ -19,9 +19,13 @@
|
|
|
19
19
|
//
|
|
20
20
|
// IO adapter contract:
|
|
21
21
|
// interface IOAdapter {
|
|
22
|
-
// size: number;
|
|
23
|
-
// fetch(byteOffset, byteLength): Promise<Uint8Array>; // MUST return exactly that many bytes
|
|
22
|
+
// size: number; // container byte length
|
|
23
|
+
// fetch(byteOffset, byteLength, signal?): Promise<Uint8Array>; // MUST return exactly that many bytes
|
|
24
24
|
// }
|
|
25
|
+
// The signal parameter is ADDITIVE (BS-28): a 2-arg adapter that ignores it
|
|
26
|
+
// degrades CANCELLATION only, never correctness -- the reader-side fail-closed
|
|
27
|
+
// triangle (pre-check / race / post-settle) guarantees the outcome regardless.
|
|
28
|
+
// See decisions/0013-range-abort.md.
|
|
25
29
|
//
|
|
26
30
|
// Two adapters ship in-box:
|
|
27
31
|
// HTTPRangeAdapter -- uses fetch() with Range headers.
|
|
@@ -43,12 +47,15 @@
|
|
|
43
47
|
// R_BAD_FOOTER -- footer magic_end or footer_len is malformed
|
|
44
48
|
// R_BAD_METADATA -- metadata_off non-zero but the zone-map segment is unparseable
|
|
45
49
|
// R_INVALID -- a structure is internally inconsistent but in-bounds
|
|
50
|
+
// R_ABORTED -- a reader-level { signal } fired: in-flight range I/O is
|
|
51
|
+
// cancelled, resolved-after-abort bytes discarded, and a
|
|
52
|
+
// foreign AbortError rejection is mapped here (BS-28)
|
|
46
53
|
|
|
47
54
|
import { StringTable } from './StringTable.js';
|
|
48
55
|
import { checkOpts } from './Opts.js';
|
|
49
56
|
import { crc32cInit, crc32cUpdate, crc32cFinal } from './Crc32c.js';
|
|
50
57
|
|
|
51
|
-
export const VERSION = '1.
|
|
58
|
+
export const VERSION = '1.7.0';
|
|
52
59
|
|
|
53
60
|
const CONTAINER_HEADER_BYTES = 48;
|
|
54
61
|
const SHARD_ENTRY_BYTES = 40;
|
|
@@ -62,11 +69,13 @@ const U32_MAX = 4294967295;
|
|
|
62
69
|
const RANGE_READER_OPTS = {
|
|
63
70
|
maxCachedShards: { t: 'int', min: 0, max: U32_MAX },
|
|
64
71
|
verifyCrc: { t: 'bool' },
|
|
72
|
+
signal: { t: 'signal' },
|
|
65
73
|
};
|
|
66
|
-
const HTTP_ADAPTER_OPTS = { fetch: { t: 'fn' } };
|
|
74
|
+
const HTTP_ADAPTER_OPTS = { fetch: { t: 'fn' }, signal: { t: 'signal' } };
|
|
67
75
|
|
|
68
76
|
const CONTAINER_FOOTER_BYTES = 16;
|
|
69
77
|
const CRC_ABSENT = 0xFFFFFFFF;
|
|
78
|
+
const NOOP = function () {};
|
|
70
79
|
|
|
71
80
|
function laneBytesOf(k) { return k === LANE_F64 ? 8 : (k === LANE_U32 ? 4 : 0); }
|
|
72
81
|
|
|
@@ -75,6 +84,12 @@ export class RangeReaderError extends Error {
|
|
|
75
84
|
}
|
|
76
85
|
function raiseRange(code, msg) { throw new RangeReaderError(code, msg); }
|
|
77
86
|
|
|
87
|
+
// A reader-level signal fired: cancel in-flight range I/O. The one message for
|
|
88
|
+
// every R_ABORTED site (pre-check, post-settle, foreign-AbortError mapping).
|
|
89
|
+
function abortedError() {
|
|
90
|
+
return new RangeReaderError('R_ABORTED', 'range I/O aborted: the reader-level signal fired');
|
|
91
|
+
}
|
|
92
|
+
|
|
78
93
|
// Read a u64 field from `dv` (the header, schema, or directory view -- three
|
|
79
94
|
// different DataViews here) and narrow it to a JS number, failing closed past
|
|
80
95
|
// Number.MAX_SAFE_INTEGER. Past 2^53-1 a Number() cast loses precision and every
|
|
@@ -125,25 +140,42 @@ export class HTTPRangeAdapter {
|
|
|
125
140
|
if (typeof fetchImpl !== 'function') {
|
|
126
141
|
throw new RangeReaderError('R_TRUNCATED', 'no fetch() available in this environment');
|
|
127
142
|
}
|
|
143
|
+
// Reader-level cancellation (BS-28). The adapter itself stays signal-stateless
|
|
144
|
+
// (one adapter may serve N readers); this signal governs only its own open().
|
|
145
|
+
const signal = opts.signal !== undefined ? opts.signal : null;
|
|
146
|
+
if (signal !== null && signal.aborted) throw abortedError();
|
|
128
147
|
let size = -1;
|
|
129
|
-
// Try HEAD first.
|
|
148
|
+
// Try HEAD first (forward the signal so a real HEAD is cancellable).
|
|
130
149
|
try {
|
|
131
|
-
const head = await fetchImpl(url, { method: 'HEAD' });
|
|
150
|
+
const head = await fetchImpl(url, { method: 'HEAD', signal });
|
|
132
151
|
if (head.ok) {
|
|
133
152
|
const cl = head.headers.get('content-length');
|
|
134
153
|
if (cl) size = parseInt(cl, 10);
|
|
135
154
|
}
|
|
136
155
|
} catch { /* fall through */ }
|
|
156
|
+
// E1 (O-5): the HEAD's own rejection is swallowed by design. Re-check the
|
|
157
|
+
// signal after the catch and BEFORE the probe GET -- an abort during HEAD
|
|
158
|
+
// must not fire a second network request.
|
|
159
|
+
if (signal !== null && signal.aborted) throw abortedError();
|
|
137
160
|
// Fallback: single-byte range GET.
|
|
138
161
|
if (size < 0) {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
162
|
+
try {
|
|
163
|
+
const probe = await fetchImpl(url, { headers: { Range: 'bytes=0-0' }, signal });
|
|
164
|
+
const cr = probe.headers.get('content-range');
|
|
165
|
+
if (cr) {
|
|
166
|
+
const m = cr.match(/\/(\d+)$/);
|
|
167
|
+
if (m) size = parseInt(m[1], 10);
|
|
168
|
+
}
|
|
169
|
+
// consume body so the connection doesn't hang
|
|
170
|
+
await probe.arrayBuffer();
|
|
171
|
+
} catch (err) {
|
|
172
|
+
// O-10: an abort landing mid-probe (fetch or drain) maps to R_ABORTED,
|
|
173
|
+
// symmetric with the E1-mapped HEAD path -- a foreign DOMException
|
|
174
|
+
// AbortError must not leak out of open(). A non-abort failure rethrows
|
|
175
|
+
// verbatim. Cold path; allocation here is fine.
|
|
176
|
+
if (signal !== null && signal.aborted) throw abortedError();
|
|
177
|
+
throw err;
|
|
144
178
|
}
|
|
145
|
-
// consume body so the connection doesn't hang
|
|
146
|
-
await probe.arrayBuffer();
|
|
147
179
|
}
|
|
148
180
|
if (size < 0) throw new RangeReaderError('R_TRUNCATED', 'could not determine container size for ' + url);
|
|
149
181
|
return new HTTPRangeAdapter(url, size, fetchImpl);
|
|
@@ -156,10 +188,11 @@ export class HTTPRangeAdapter {
|
|
|
156
188
|
this.stats = { requests: 0, bytesFetched: 0 };
|
|
157
189
|
}
|
|
158
190
|
|
|
159
|
-
async fetch(byteOffset, byteLength) {
|
|
191
|
+
async fetch(byteOffset, byteLength, signal) {
|
|
160
192
|
const rangeEnd = byteOffset + byteLength - 1;
|
|
161
193
|
const res = await this._fetch(this.url, {
|
|
162
194
|
headers: { Range: 'bytes=' + byteOffset + '-' + rangeEnd },
|
|
195
|
+
signal,
|
|
163
196
|
});
|
|
164
197
|
if (!res.ok && res.status !== 206) {
|
|
165
198
|
throw new RangeReaderError('R_TRUNCATED',
|
|
@@ -184,16 +217,23 @@ export class MockRangeAdapter {
|
|
|
184
217
|
this.bytes = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
185
218
|
this.size = this.bytes.length;
|
|
186
219
|
this.log = []; // [[offset, length], ...] for test assertions
|
|
220
|
+
// OBSERVATION-ONLY (O-2, BS-28): `signals` is index-aligned with `log` (null
|
|
221
|
+
// when no signal was forwarded); `lastSignal` is the most recent one. Neither
|
|
222
|
+
// changes behaviour -- `log` stays byte-identical.
|
|
223
|
+
this.signals = [];
|
|
224
|
+
this.lastSignal = null;
|
|
187
225
|
this.stats = { requests: 0, bytesFetched: 0 };
|
|
188
226
|
}
|
|
189
227
|
|
|
190
|
-
async fetch(byteOffset, byteLength) {
|
|
228
|
+
async fetch(byteOffset, byteLength, signal) {
|
|
191
229
|
if (byteOffset + byteLength > this.size) {
|
|
192
230
|
throw new RangeReaderError('R_ADAPTER_SHORT_READ',
|
|
193
231
|
'mock adapter: request bytes=' + byteOffset + '-' + (byteOffset + byteLength - 1) +
|
|
194
232
|
' exceeds size ' + this.size);
|
|
195
233
|
}
|
|
196
234
|
this.log.push([byteOffset, byteLength]);
|
|
235
|
+
this.signals.push(signal !== undefined ? signal : null);
|
|
236
|
+
this.lastSignal = signal !== undefined ? signal : null;
|
|
197
237
|
this.stats.requests++;
|
|
198
238
|
this.stats.bytesFetched += byteLength;
|
|
199
239
|
// Return a COPY so mutations by the caller don't affect the "source of truth".
|
|
@@ -221,6 +261,10 @@ export class RangeReader {
|
|
|
221
261
|
opts = opts || {};
|
|
222
262
|
this.adapter = adapter;
|
|
223
263
|
this.maxCachedShards = opts.maxCachedShards !== undefined ? opts.maxCachedShards : 8;
|
|
264
|
+
// Reader-level cancellation (BS-28). null = no signal = the pre-MQ1 hot path
|
|
265
|
+
// (one property read in _fetchExact, zero race/listener allocation). A signal
|
|
266
|
+
// that has already fired makes this reader permanently dead for NEW I/O.
|
|
267
|
+
this._signal = opts.signal !== undefined ? opts.signal : null;
|
|
224
268
|
this._footerCrc = CRC_ABSENT;
|
|
225
269
|
// shard cache: shardIdx -> { payloadBytes, payloadDv, stringTable, lastAccess }
|
|
226
270
|
this._shardCache = new Map();
|
|
@@ -500,7 +544,56 @@ export class RangeReader {
|
|
|
500
544
|
// non-Uint8Array return would otherwise seed a DataView over the wrong extent
|
|
501
545
|
// and decode silent garbage. Fail closed with R_ADAPTER_SHORT_READ instead.
|
|
502
546
|
async _fetchExact(byteOffset, byteLength) {
|
|
503
|
-
|
|
547
|
+
// HOT PATH (no signal): one property read is the entire legacy-path cost.
|
|
548
|
+
// The abort triangle + race live in the cold _fetchAborting (O-8). Collapsing
|
|
549
|
+
// the two is forbidden -- a reader without a signal reaches no listener, no
|
|
550
|
+
// race, no extra allocation.
|
|
551
|
+
if (this._signal === null) {
|
|
552
|
+
const buf = await this.adapter.fetch(byteOffset, byteLength);
|
|
553
|
+
if (!(buf instanceof Uint8Array) || buf.byteLength !== byteLength) {
|
|
554
|
+
throw new RangeReaderError('R_ADAPTER_SHORT_READ',
|
|
555
|
+
'adapter returned ' +
|
|
556
|
+
(buf instanceof Uint8Array ? buf.byteLength + ' bytes' : Object.prototype.toString.call(buf)) +
|
|
557
|
+
' for a ' + byteLength + '-byte request at offset ' + byteOffset);
|
|
558
|
+
}
|
|
559
|
+
return buf;
|
|
560
|
+
}
|
|
561
|
+
return this._fetchAborting(byteOffset, byteLength, this._signal);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// COLD PATH (a signal is bound): the fail-closed triangle plus a race. See
|
|
565
|
+
// decisions/0013-range-abort.md and BS-28.
|
|
566
|
+
// (a) pre-check -- signal already fired -> R_ABORTED, ZERO adapter calls;
|
|
567
|
+
// (b) race -- adapter promise vs an abort promise, so a never-resolving
|
|
568
|
+
// fetch still rejects within the tick of the abort; the
|
|
569
|
+
// listener is removed on settle in BOTH directions;
|
|
570
|
+
// (c) post-settle -- a signal-ignoring adapter that resolved anyway still
|
|
571
|
+
// yields R_ABORTED (bytes discarded, nothing cached); an
|
|
572
|
+
// adapter rejection while aborted maps to R_ABORTED (a
|
|
573
|
+
// foreign DOMException AbortError never leaks); a non-abort
|
|
574
|
+
// rejection rethrows verbatim; the short-read shape check
|
|
575
|
+
// runs only on the clean-resolve path.
|
|
576
|
+
async _fetchAborting(byteOffset, byteLength, sig) {
|
|
577
|
+
if (sig.aborted) throw abortedError(); // (a)
|
|
578
|
+
const adapterP = this.adapter.fetch(byteOffset, byteLength, sig);
|
|
579
|
+
let onAbort = null;
|
|
580
|
+
const abortP = new Promise((_resolve, reject) => {
|
|
581
|
+
onAbort = function () { reject(abortedError()); };
|
|
582
|
+
sig.addEventListener('abort', onAbort, { once: true });
|
|
583
|
+
});
|
|
584
|
+
let buf;
|
|
585
|
+
try {
|
|
586
|
+
buf = await Promise.race([adapterP, abortP]); // (b)
|
|
587
|
+
} catch (err) {
|
|
588
|
+
if (sig.aborted) {
|
|
589
|
+
adapterP.catch(NOOP); // neutralize the loser: no late unhandledRejection
|
|
590
|
+
throw abortedError(); // foreign AbortError mapped; never leaks
|
|
591
|
+
}
|
|
592
|
+
throw err; // non-abort rejection rethrows verbatim
|
|
593
|
+
} finally {
|
|
594
|
+
sig.removeEventListener('abort', onAbort);
|
|
595
|
+
}
|
|
596
|
+
if (sig.aborted) throw abortedError(); // (c)
|
|
504
597
|
if (!(buf instanceof Uint8Array) || buf.byteLength !== byteLength) {
|
|
505
598
|
throw new RangeReaderError('R_ADAPTER_SHORT_READ',
|
|
506
599
|
'adapter returned ' +
|
package/src/Reader.js
CHANGED
|
@@ -38,7 +38,7 @@ import { toContainerBuffer } from './Views.js';
|
|
|
38
38
|
import { checkOpts } from './Opts.js';
|
|
39
39
|
import { crc32cInit, crc32cUpdate, crc32cFinal } from './Crc32c.js';
|
|
40
40
|
|
|
41
|
-
export const VERSION = '1.
|
|
41
|
+
export const VERSION = '1.7.0';
|
|
42
42
|
|
|
43
43
|
const CONTAINER_HEADER_BYTES = 48;
|
|
44
44
|
const SHARD_ENTRY_BYTES = 40;
|
package/src/Split.js
CHANGED
|
@@ -42,7 +42,7 @@ import { StringTable } from './StringTable.js';
|
|
|
42
42
|
import { checkOpts } from './Opts.js';
|
|
43
43
|
import { crc32cInit, crc32cUpdate, crc32cFinal } from './Crc32c.js';
|
|
44
44
|
|
|
45
|
-
export const VERSION = '1.
|
|
45
|
+
export const VERSION = '1.7.0';
|
|
46
46
|
|
|
47
47
|
const LF = 0x0A;
|
|
48
48
|
const CONTAINER_HEADER_BYTES = 48;
|
package/src/StringTable.js
CHANGED
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
// TEST-ONLY: not re-exported from index.js, absent from the .d.ts and docs,
|
|
42
42
|
// and carrying no semver guarantee.
|
|
43
43
|
|
|
44
|
-
export const VERSION = '1.
|
|
44
|
+
export const VERSION = '1.7.0';
|
|
45
45
|
|
|
46
46
|
const EMPTY_SLOT = 0xFFFFFFFF; // MUST be unsigned; typed-array reads are unsigned
|
|
47
47
|
const INITIAL_BLOB_BYTES = 64 * 1024;
|
package/src/Tokenizer.js
CHANGED
package/src/Writer.js
CHANGED
|
@@ -62,7 +62,7 @@ import { checkOpts } from './Opts.js';
|
|
|
62
62
|
import { crc32cInit, crc32cUpdate, crc32cFinal, crc32cCombine } from './Crc32c.js';
|
|
63
63
|
import { validateSink, isThenable } from './Views.js';
|
|
64
64
|
|
|
65
|
-
export const VERSION = '1.
|
|
65
|
+
export const VERSION = '1.7.0';
|
|
66
66
|
|
|
67
67
|
const U32_MAX = 4294967295;
|
|
68
68
|
// Post-finalize sentinel for _recordDepth. Chosen = 2 so every post-finalize
|
package/src/index.js
CHANGED
package/types/RangeReader.d.ts
CHANGED
|
@@ -8,7 +8,12 @@ export const VERSION: string;
|
|
|
8
8
|
|
|
9
9
|
export interface IOAdapter {
|
|
10
10
|
size: number;
|
|
11
|
-
|
|
11
|
+
/**
|
|
12
|
+
* `signal` is ADDITIVE (BS-28): a 2-arg adapter that ignores it degrades
|
|
13
|
+
* CANCELLATION only, never correctness -- the reader-side fail-closed triangle
|
|
14
|
+
* guarantees the outcome regardless of whether the adapter honoured the signal.
|
|
15
|
+
*/
|
|
16
|
+
fetch(byteOffset: number, byteLength: number, signal?: AbortSignal): Promise<Uint8Array>;
|
|
12
17
|
}
|
|
13
18
|
|
|
14
19
|
export interface AdapterStats {
|
|
@@ -17,11 +22,11 @@ export interface AdapterStats {
|
|
|
17
22
|
}
|
|
18
23
|
|
|
19
24
|
export class HTTPRangeAdapter implements IOAdapter {
|
|
20
|
-
static open(url: string, opts?: { fetch?: typeof fetch }): Promise<HTTPRangeAdapter>;
|
|
25
|
+
static open(url: string, opts?: { fetch?: typeof fetch; signal?: AbortSignal }): Promise<HTTPRangeAdapter>;
|
|
21
26
|
readonly url: string;
|
|
22
27
|
size: number;
|
|
23
28
|
stats: AdapterStats;
|
|
24
|
-
fetch(byteOffset: number, byteLength: number): Promise<Uint8Array>;
|
|
29
|
+
fetch(byteOffset: number, byteLength: number, signal?: AbortSignal): Promise<Uint8Array>;
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
export class MockRangeAdapter implements IOAdapter {
|
|
@@ -29,8 +34,12 @@ export class MockRangeAdapter implements IOAdapter {
|
|
|
29
34
|
size: number;
|
|
30
35
|
/** Fetch log: [[offset, length], ...] for test assertions. */
|
|
31
36
|
log: [number, number][];
|
|
37
|
+
/** Observation only (BS-28): index-aligned with `log`, null when no signal forwarded. */
|
|
38
|
+
signals: (AbortSignal | null)[];
|
|
39
|
+
/** Observation only (BS-28): the most recently forwarded signal (or null). */
|
|
40
|
+
lastSignal: AbortSignal | null;
|
|
32
41
|
stats: AdapterStats;
|
|
33
|
-
fetch(byteOffset: number, byteLength: number): Promise<Uint8Array>;
|
|
42
|
+
fetch(byteOffset: number, byteLength: number, signal?: AbortSignal): Promise<Uint8Array>;
|
|
34
43
|
}
|
|
35
44
|
|
|
36
45
|
export interface RangeReaderOptions {
|
|
@@ -38,6 +47,13 @@ export interface RangeReaderOptions {
|
|
|
38
47
|
maxCachedShards?: number;
|
|
39
48
|
/** Verify the footer CRC-32C at open; rejects R_BAD_CRC on mismatch, R_CRC_ABSENT if absent. */
|
|
40
49
|
verifyCrc?: boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Reader-level cancellation (BS-28). One signal governs the reader's whole
|
|
52
|
+
* lifetime. Once it fires the reader is permanently DEAD for NEW I/O -- every
|
|
53
|
+
* fetch throws R_ABORTED. Already-cached shards stay readable through
|
|
54
|
+
* syncRange. A remount makes a new reader with a new signal (design A).
|
|
55
|
+
*/
|
|
56
|
+
signal?: AbortSignal;
|
|
41
57
|
}
|
|
42
58
|
|
|
43
59
|
export interface RangeShardHandle {
|