@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 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
  ![Dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)
14
14
  [![license](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](./LICENSE)
15
15
 
16
- **Status:** v1.6.1. LBK1 format frozen at `format_version: 1`. Qualified on an 8 GB soak with zero GC and byte-exact preservation across 590 million cells.
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
- const reader = await RangeReader.open(adapterOver(merged));
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` | 519 tests across 36 files | Dev loop, every save |
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.6.1 -- stable. LBK1 format frozen at `format_version: 1`. Qualified on an 8 GB soak (M1 MacBook Pro): 98.37M rows, 4.89 GB container, zero major GC, zero minor GC, 499 KB total heap allocation, 590.21M cells verified byte-exact, zero mismatches. Tokenizer benches at 222-237 MB/s (~55% of JSON.parse, with no object graph allocated).
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.6.1)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zakkster/lite-bake-stream",
3
- "version": "1.6.1",
3
+ "version": "1.7.0",
4
4
  "description": "Streaming byte-level JSON to LBK1 binary containers. Zero-GC, tree-shakeable, gigabyte-scale.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
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.6.1';
34
+ export const VERSION = '1.7.0';
35
35
 
36
36
  const U32_MAX = 4294967295;
37
37
  const INGEST_OPTS = {
@@ -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.6.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) {
@@ -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.6.1';
40
+ export const VERSION = '1.7.0';
41
41
 
42
42
  const CONTAINER_HEADER_BYTES = 48;
43
43
  const SHARD_ENTRY_BYTES = 40;
@@ -41,7 +41,7 @@
41
41
 
42
42
  import { checkOpts } from './Opts.js';
43
43
 
44
- export const VERSION = '1.6.1';
44
+ export const VERSION = '1.7.0';
45
45
 
46
46
  const U32_MAX = 4294967295;
47
47
  const PRESERVE_TOKENIZER_OPTS = {
@@ -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.6.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'; }
@@ -19,9 +19,13 @@
19
19
  //
20
20
  // IO adapter contract:
21
21
  // interface IOAdapter {
22
- // size: number; // container byte length
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.6.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
- const probe = await fetchImpl(url, { headers: { Range: 'bytes=0-0' } });
140
- const cr = probe.headers.get('content-range');
141
- if (cr) {
142
- const m = cr.match(/\/(\d+)$/);
143
- if (m) size = parseInt(m[1], 10);
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
- const buf = await this.adapter.fetch(byteOffset, byteLength);
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.6.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.6.1';
45
+ export const VERSION = '1.7.0';
46
46
 
47
47
  const LF = 0x0A;
48
48
  const CONTAINER_HEADER_BYTES = 48;
@@ -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.6.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
@@ -30,7 +30,7 @@
30
30
 
31
31
  import { checkOpts } from './Opts.js';
32
32
 
33
- export const VERSION = '1.6.1';
33
+ export const VERSION = '1.7.0';
34
34
 
35
35
  const U32_MAX = 4294967295;
36
36
  const TOKENIZER_OPTS = {
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.6.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
@@ -32,7 +32,7 @@ export {
32
32
  PreserveWriter, PreserveWriterError,
33
33
  PreserveReader, PreserveReaderError,
34
34
  };
35
- export const VERSION = '1.6.1';
35
+ export const VERSION = '1.7.0';
36
36
 
37
37
  const encoder = new TextEncoder();
38
38
 
@@ -8,7 +8,12 @@ export const VERSION: string;
8
8
 
9
9
  export interface IOAdapter {
10
10
  size: number;
11
- fetch(byteOffset: number, byteLength: number): Promise<Uint8Array>;
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 {