@zakkster/lite-stream 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,87 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ---
9
9
 
10
+ ## 1.3.0 -- 2026-09-02
11
+
12
+ **The consumer contract.** The one real bug the torture harness found is fixed
13
+ (LS-13), and the three things the flagship consumer hand-rolled around
14
+ `pipeToSignal` -- a buffer window, a first-value tap, an abort filter -- are
15
+ offered natively as additive options. Every 1.1.0 call shape stays
16
+ byte-identical; the abort vocabulary is pinned in a decision doc.
17
+
18
+ ### Fixed
19
+
20
+ - **LS-13** -- a `next()` result object whose `done` or `value` is a THROWING
21
+ getter now routes to the terminal error state with `onError` fired exactly
22
+ once, in both pumps (`fromAsyncIterable` and `pipeToSignal`). Previously the
23
+ getter fired inside the pump's `.then` onFulfilled with no catch at that
24
+ site: the throw escaped as an `unhandledRejection` and the signal never
25
+ settled. Both pumps now read the two result properties once into locals inside
26
+ a dedicated try/catch that routes to the existing "iterator throws" leg
27
+ (triplet leg 2). The wrap covers ONLY the two property reads -- the LS-01
28
+ peer-defense catch around `set()` is separate and untouched.
29
+
30
+ ### Added
31
+
32
+ - **`pipeToSignal` enrichment** (all additive; the un-optioned path is
33
+ byte-identical to 1.1.0) -- `mode: "buffer"` with a REQUIRED `maxBuffer`
34
+ drives the target with a bounded newest-last snapshot array (a fresh array per
35
+ set), dropping the oldest on overflow; `mode: "latest"` (default) is the
36
+ 1.1.0 direct-set behavior. Validated once at construction: `TypeError` on an
37
+ unknown mode and on `mode: "latest"` + `maxBuffer`; `RangeError` on
38
+ missing/invalid `maxBuffer` in buffer mode (the `fromAsyncIterable` message
39
+ voice). The returned stop fn gains `droppedCount` and `overflowCount` getters
40
+ (aliases of one overflow counter, 0 in latest mode); the stop fn stays
41
+ callable and idempotent.
42
+ - **`onValue(v)` tap** on `pipeToSignal` -- fires exactly once per value BEFORE
43
+ each set (after `transform`). A throwing `onValue` routes through the same
44
+ `stop` + `onError` leg as a throwing transform (the test/04 precedent).
45
+ - **`onAbort(reason)`** on `pipeToSignal` -- when present, aborts route to it
46
+ INSTEAD of `onError` (both at the abort listener and the pre-aborted early
47
+ return), so consumers stop filtering intentional aborts out of their error
48
+ handler. Absent -> 1.1.0 behavior byte-identical.
49
+ - **`decisions/0001-abort-vocabulary.md`** (LS-12) -- a repo-only decision doc
50
+ (NOT in `files[]`, never in the tarball) pinning the from-side/to-side abort
51
+ asymmetry as deliberate and recording the `onAbort` addition and the rejected
52
+ alternatives. llms.txt carries the contract table citing it.
53
+ - **Torture extensions** -- the `s7e` enriched-surface control (10 controls
54
+ total: s0-s8 + s7e, each individually non-zero) and the `pipeBuffer64`
55
+ allocation scenario in s5 (~5 B/op, budget 32 B/op). The s1 LS-13 pin is now a
56
+ hard assertion; the `pipeSteady` default-path budget is unchanged (the
57
+ purity proof).
58
+ - **21 new tests** -- `test/10-pipe-enriched.test.mjs` (15: mode/maxBuffer/
59
+ onValue/onAbort matrix, validation throws, stop-fn observability, abort
60
+ routing with and without `onAbort`, and a StreamQuery-shaped rewrite parity
61
+ case) plus 6 LS-13 pins in `test/08-dispose-behavior.test.mjs` (throwing
62
+ getter -> error state, `onError` once, zero unhandledRejection via a scoped
63
+ process capture; includes the pinned decision that a `done: true` result
64
+ with a throwing `value` getter routes to the ERROR leg, not the done leg).
65
+
66
+ ### Changed
67
+
68
+ - `pipeToSignal`'s `Stream.d.ts` return type is now the callable
69
+ `PipeToSignalStop` (a stop fn with `readonly droppedCount` / `overflowCount`),
70
+ and its options are the discriminated `PipeToSignalOptions<T>` union. No new
71
+ value export -- the surface stays exactly five names.
72
+
73
+ ### Testing
74
+
75
+ - **111 tests** across `test/01-*` through `test/10-*` (up from 90), 0 fail,
76
+ 0 skip under `npm test` and `npm run test:gc`.
77
+ - `npm run torture` -> `ok`, ~1.77s wall (node v26.3.1, macOS arm64,
78
+ 2026-09-02); `maxMajorsPerKOp` 0.000 in every s5 scenario; `pipeSteady`
79
+ 1.38 B/op (budget 32, UNCHANGED) and the new `pipeBuffer64` ~5 B/op
80
+ (budget 32). Controls are 10/10 individually non-zero; `TORTURE_SEED` replay
81
+ ok; probes P0-P4 digit-normalized identical.
82
+ - s7 rewrite-parity case: N=12 values, `maxBuffer=4` -> `droppedCount` 8,
83
+ newest-last order, semantics identical to the hand-rolled StreamQuery shape.
84
+ - Floor-matrix doctrine unchanged from 1.2.0: `npm run test:floor` runs
85
+ `npm test` against the pinned `@zakkster/lite-signal@1.2.2` and against the
86
+ resolved latest; per-scenario s5 budgets, the global `maxMajor` rule never
87
+ widens.
88
+
89
+ ---
90
+
10
91
  ## 1.2.0 -- 2026-09-02
11
92
 
12
93
  **The law's gate, able to fail.** A torture harness that proves the package's
package/README.md CHANGED
@@ -165,16 +165,22 @@ pipeToSignal<T>(
165
165
  target: Signal<T>,
166
166
  opts?: {
167
167
  signal?: AbortSignal,
168
+ mode?: "latest" | "buffer", // default "latest"
169
+ maxBuffer?: number, // REQUIRED if mode = "buffer"
170
+ transform?: (value: T) => T, // per-value map, runs first
171
+ onValue?: (value: T) => void, // tap: fires once BEFORE set
172
+ onAbort?: (reason: unknown) => void, // aborts route here, not onError
168
173
  onError?: (err: unknown) => void,
169
- onDone?: () => void,
170
- transform?: (value: T) => T
174
+ onDone?: () => void
171
175
  }
172
- ): () => void // stop fn (idempotent)
176
+ ): (() => void) & { readonly droppedCount: number, readonly overflowCount: number }
173
177
  ```
174
178
 
175
179
  Lower-level companion: pump an existing writable signal from an async
176
- iterator. The signal's value is replaced directly with each yielded value
177
- (no `{ value, count, ... }` wrapper). Returns a `stop` function.
180
+ iterator. In the default `"latest"` mode the signal's value is replaced
181
+ directly with each yielded value (no `{ value, count, ... }` wrapper).
182
+ Returns an idempotent `stop` function that also carries `droppedCount` /
183
+ `overflowCount` getters.
178
184
 
179
185
  Use `pipeToSignal` when:
180
186
 
@@ -182,10 +188,41 @@ Use `pipeToSignal` when:
182
188
  - You don't need the lifecycle metadata wrapper
183
189
  - You want a `stop` fn instead of an `AbortController` for cleanup
184
190
 
191
+ **Enrichment (1.3.0, all additive -- the un-optioned path is byte-identical
192
+ to 1.1.0):**
193
+
194
+ - `mode: "buffer"` (with required `maxBuffer`) drives the target with a bounded
195
+ newest-last snapshot array instead of the raw value; overflow drops the
196
+ oldest and increments `stop.droppedCount`. `mode: "latest"` + `maxBuffer`
197
+ throws `TypeError`; missing/invalid `maxBuffer` in buffer mode throws
198
+ `RangeError`.
199
+ - `onValue(v)` is a per-value tap that fires once BEFORE each set (after
200
+ `transform`). A throwing `onValue` routes through the same `stop` + `onError`
201
+ leg as a throwing transform.
202
+ - `onAbort(reason)`, when present, receives aborts INSTEAD of `onError`, so a
203
+ consumer aborting on purpose (detach / restart) no longer filters
204
+ `signal.aborted` out of its error handler. See the abort-vocabulary contract
205
+ in `llms.txt` and `decisions/0001-abort-vocabulary.md`.
206
+
185
207
  **Does NOT dispose the target signal.** The caller owns its lifetime.
186
208
  Disposing the target signal does NOT stop the pump: call the returned stop
187
209
  fn or abort `opts.signal`.
188
210
 
211
+ ```js
212
+ // Enriched buffer-mode pipe -- replaces a hand-rolled ring + status tap +
213
+ // abort filter (the StreamQuery shape) with native options.
214
+ const stop = pipeToSignal(topicSource, entrySig, {
215
+ signal: ctrl.signal,
216
+ mode: "buffer",
217
+ maxBuffer: 64, // bounded, drop-oldest
218
+ onValue: (v) => { if (first) status.set("streaming"); first = false; },
219
+ onAbort: () => {}, // intentional abort, not an error
220
+ onError: (e) => status.set("error"), // real failures only
221
+ onDone: () => status.set("done")
222
+ });
223
+ // stop.droppedCount tells you how many values overflowed the window.
224
+ ```
225
+
189
226
  ### toAsyncIterable
190
227
 
191
228
  ```ts
@@ -496,7 +533,7 @@ target.
496
533
 
497
534
  ### Tier 1 -- behavior (unit tests, fast)
498
535
 
499
- 90 tests across `test/01-*` through `test/09-*`:
536
+ 111 tests across `test/01-*` through `test/10-*`:
500
537
 
501
538
  - `01-from-async-iterable-latest.test.mjs` -- state shape, lifecycle,
502
539
  Iterable acceptance variants, pre-aborted, subscriber observability
@@ -515,12 +552,18 @@ target.
515
552
  - `08-dispose-behavior.test.mjs` -- pins the TRUE dispose semantics
516
553
  (LS-01: disposing a result/target signal does NOT stop the pump;
517
554
  LS-04: a pending `next()` on a disposed source never settles, `timeout`
518
- is the only escape hatch)
555
+ is the only escape hatch) plus the LS-13 pins (a throwing `done`/`value`
556
+ result getter routes to the error state, `onError` once, no
557
+ unhandledRejection)
519
558
  - `09-guards.test.mjs` -- drift guards: ascii-guard (every shipped +
520
559
  test/bench/demo byte is printable ASCII or LF) and surface-guard (runtime
521
560
  exports == llms.txt == Stream.d.ts, VERSION == package.json version, the
522
561
  declared peer is present in both docs). Each family carries an inline
523
562
  failing control proving the guard can fail.
563
+ - `10-pipe-enriched.test.mjs` (1.3.0) -- the enriched `pipeToSignal`:
564
+ `mode`/`maxBuffer` validation, buffer-window snapshots and drop counting,
565
+ `onValue` ordering and throw policy, `onAbort` routing with and without the
566
+ hook, and the stop-fn `droppedCount`/`overflowCount` observability
524
567
 
525
568
  Run via `npm test`.
526
569
 
@@ -545,11 +588,14 @@ retained as an alias.
545
588
  `npm run torture` (`node --expose-gc test/torture.mjs`) runs tiers s0-s8
546
589
  sequentially -- metamorphic laws, degenerate values, protocol conformance,
547
590
  API abuse, seeded fuzz-vs-oracle, the s5 per-scenario allocation gate
548
- (maxMajor 0), a lite-leak retention soak, the StreamQuery conformance corpus,
591
+ (maxMajor 0, including the 1.3.0 `pipeBuffer64` scenario alongside the
592
+ unchanged `pipeSteady` default-path budget), a lite-leak retention soak, the
593
+ StreamQuery conformance corpus (with the enriched-shape rewrite parity case),
549
594
  and the controls tier. It prints `ok` and exits 0 on success.
550
- `STREAM_TORTURE_BREAK=<s0..s8|1>` arms one tier's (or every tier's) injected
551
- breakage so the control lane exits non-zero; `npm run torture:control` runs
552
- the all-tiers form. `npm run test:floor` runs `npm test` against the peer
595
+ `STREAM_TORTURE_BREAK=<s0..s8|s7e|1>` arms one tier's (or every tier's)
596
+ injected breakage so the control lane exits non-zero; `npm run
597
+ torture:control` runs the all-tiers form (10 controls: s0-s8 plus the s7e
598
+ enriched-surface control, each individually non-zero). `npm run test:floor` runs `npm test` against the peer
553
599
  floor `@zakkster/lite-signal@1.2.2` and then `npm test` + torture against the
554
600
  resolved latest, printing both verdicts.
555
601
 
package/Stream.d.ts CHANGED
@@ -64,11 +64,57 @@ export interface FromAsyncIterableBufferOptions<T> {
64
64
  onDone?: () => void;
65
65
  }
66
66
 
67
- export interface PipeToSignalOptions<T> {
67
+ /**
68
+ * Common `pipeToSignal` options, shared by both modes. `onValue` is a per-value
69
+ * tap that fires once BEFORE each `set` (after `transform`); a throw from it
70
+ * routes through the same stop + `onError` leg as a throwing transform.
71
+ * `onAbort`, when present, receives aborts INSTEAD of `onError` (see
72
+ * decisions/0001-abort-vocabulary.md) -- both at the abort listener and the
73
+ * pre-aborted early return.
74
+ */
75
+ export interface PipeToSignalCommonOptions<T> {
68
76
  signal?: AbortSignal;
69
77
  onError?: (err: unknown) => void;
70
78
  onDone?: () => void;
71
79
  transform?: (value: T) => T;
80
+ onValue?: (value: T) => void;
81
+ onAbort?: (reason: unknown) => void;
82
+ }
83
+
84
+ export interface PipeToSignalLatestOptions<T> extends PipeToSignalCommonOptions<T> {
85
+ /** Default. The target signal's value is replaced with the raw value. */
86
+ mode?: "latest";
87
+ }
88
+
89
+ export interface PipeToSignalBufferOptions<T> extends PipeToSignalCommonOptions<T> {
90
+ /**
91
+ * Buffer mode: the target signal's value is a bounded newest-last snapshot
92
+ * array (a fresh array per set); overflow drops the oldest and increments
93
+ * the stop fn's `droppedCount`.
94
+ */
95
+ mode: "buffer";
96
+ /**
97
+ * Required when mode === "buffer". Positive integer. Unbounded buffering is
98
+ * rejected as a memory bug pretending to be a feature (RangeError); combining
99
+ * it with mode "latest" throws TypeError.
100
+ */
101
+ maxBuffer: number;
102
+ }
103
+
104
+ export type PipeToSignalOptions<T> =
105
+ | PipeToSignalLatestOptions<T>
106
+ | PipeToSignalBufferOptions<T>;
107
+
108
+ /**
109
+ * The `pipeToSignal` return value: an idempotent stop function (calling it more
110
+ * than once is a no-op) that also carries two read-only overflow counters.
111
+ * `droppedCount` and `overflowCount` are aliases of the same buffer-mode
112
+ * overflow counter (0 in latest mode); reading them never allocates.
113
+ */
114
+ export interface PipeToSignalStop {
115
+ (): void;
116
+ readonly droppedCount: number;
117
+ readonly overflowCount: number;
72
118
  }
73
119
 
74
120
  export interface ToAsyncIterableOptions {
@@ -110,16 +156,23 @@ export function fromAsyncIterable<T>(
110
156
  /**
111
157
  * Lower-level companion to `fromAsyncIterable`: pump an existing writable
112
158
  * signal from an async iterator. The signal's value is replaced directly
113
- * with each yielded value (no wrapper state). Returns a stop function that
114
- * ends the pump and calls `iterator.return()`.
159
+ * with each yielded value (no wrapper state) in the default "latest" mode, or
160
+ * with a bounded newest-last snapshot array in "buffer" mode. Returns a stop
161
+ * function that ends the pump and calls `iterator.return()`; the stop fn also
162
+ * carries `droppedCount` / `overflowCount` getters.
115
163
  *
116
164
  * Does NOT dispose the target signal; the caller owns its lifetime.
165
+ *
166
+ * Enrichment (1.3.0, all ADDITIVE -- the un-optioned path is byte-identical to
167
+ * 1.1.0): `mode: "buffer"` + required `maxBuffer`, an `onValue` tap firing
168
+ * before each set, and `onAbort` routing aborts instead of `onError` (see
169
+ * decisions/0001-abort-vocabulary.md).
117
170
  */
118
171
  export function pipeToSignal<T>(
119
172
  source: AsyncIterable<T> | AsyncIterator<T>,
120
173
  target: Signal<T>,
121
174
  opts?: PipeToSignalOptions<T>
122
- ): () => void;
175
+ ): PipeToSignalStop;
123
176
 
124
177
  /**
125
178
  * Yield signal changes as an async iterable. Each change resolves a pending
package/Stream.js CHANGED
@@ -1,4 +1,4 @@
1
- // @zakkster/lite-stream 1.2.0
1
+ // @zakkster/lite-stream 1.3.0
2
2
  //
3
3
  // Zero-GC bridge between async iterators and @zakkster/lite-signal. The
4
4
  // multi-shot dual of lite-await's fromPromise: project an async source of N
@@ -297,11 +297,23 @@ function fromAsyncIterable(source, opts) {
297
297
  stop(new TypeError("lite-stream: iterator.next() returned non-object"));
298
298
  return;
299
299
  }
300
- if (result.done === true) {
300
+ // LS-13: a throwing done/value getter on the result routes to
301
+ // the "iterator throws" leg (stop(err)). Wrap ONLY the two
302
+ // property reads -- widening this try over sig.set() below would
303
+ // swallow the LS-01 peer-defense catch semantics. Each property
304
+ // is read exactly once into a local.
305
+ let rDone, v;
306
+ try {
307
+ rDone = result.done;
308
+ v = result.value;
309
+ } catch (err) {
310
+ stop(err);
311
+ return;
312
+ }
313
+ if (rDone === true) {
301
314
  stop(null);
302
315
  return;
303
316
  }
304
- const v = result.value;
305
317
  // Build the new state from lastState (never sig.peek), then
306
318
  // commit to both lastState and sig. The try/catch around set()
307
319
  // is defensive against a peer whose set() throws; on a throw we
@@ -433,6 +445,17 @@ function makeErrorState(prevState, err, mode) {
433
445
  * Disposing the target signal does NOT stop the pump: call the returned
434
446
  * stop fn or abort opts.signal. See ROADMAP LS-01.
435
447
  *
448
+ * Enrichment (1.3.0, all ADDITIVE -- the un-optioned path is byte-identical to
449
+ * 1.1.0): `mode: "buffer"` (with REQUIRED `maxBuffer`) drives the target with a
450
+ * bounded newest-last snapshot window instead of the raw value, dropping oldest
451
+ * on overflow and counting the drops; `onValue(v)` is a per-value tap that fires
452
+ * once BEFORE each set (a throw routes through the same leg as a throwing
453
+ * transform -- stop + onError); `onAbort(reason)` receives aborts INSTEAD of
454
+ * onError when present (so consumers stop filtering intentional aborts out of
455
+ * their error handler). The returned stop fn carries `droppedCount` and
456
+ * `overflowCount` getters (aliases of the same overflow counter); it stays
457
+ * callable and idempotent exactly as 1.1.0.
458
+ *
436
459
  * @template T
437
460
  * @param {AsyncIterable<T> | AsyncIterator<T>} source
438
461
  * @param {import("@zakkster/lite-signal").Signal<T>} target
@@ -440,9 +463,13 @@ function makeErrorState(prevState, err, mode) {
440
463
  * signal?: AbortSignal,
441
464
  * onError?: (err: unknown) => void,
442
465
  * onDone?: () => void,
443
- * transform?: (value: T) => T
466
+ * transform?: (value: T) => T,
467
+ * mode?: "latest" | "buffer",
468
+ * maxBuffer?: number,
469
+ * onValue?: (value: T) => void,
470
+ * onAbort?: (reason: unknown) => void
444
471
  * }} [opts]
445
- * @returns {() => void} stop fn (idempotent)
472
+ * @returns {(() => void) & { readonly droppedCount: number, readonly overflowCount: number }} stop fn (idempotent)
446
473
  */
447
474
  function pipeToSignal(source, target, opts) {
448
475
  if (target === null || target === undefined || typeof target.set !== "function") {
@@ -452,18 +479,68 @@ function pipeToSignal(source, target, opts) {
452
479
  const onError = (opts !== undefined && opts !== null) ? opts.onError : undefined;
453
480
  const onDone = (opts !== undefined && opts !== null) ? opts.onDone : undefined;
454
481
  const transform = (opts !== undefined && opts !== null) ? opts.transform : undefined;
482
+ const mode = (opts !== undefined && opts !== null) ? (opts.mode || "latest") : "latest";
483
+ const maxBuffer = (opts !== undefined && opts !== null) ? opts.maxBuffer : undefined;
484
+ const onValue = (opts !== undefined && opts !== null) ? opts.onValue : undefined;
485
+ const onAbort = (opts !== undefined && opts !== null) ? opts.onAbort : undefined;
486
+
487
+ // Validate ONCE at construction. Unknown mode / latest+maxBuffer are
488
+ // programmer bugs surfaced synchronously; buffer mode requires a deliberate
489
+ // ceiling (unbounded buffering is a memory bug pretending to be a feature).
490
+ if (mode !== "latest" && mode !== "buffer") {
491
+ throw new TypeError(
492
+ "lite-stream: opts.mode must be \"latest\" or \"buffer\" (got " + JSON.stringify(mode) + ")"
493
+ );
494
+ }
495
+ if (mode === "latest" && maxBuffer !== undefined) {
496
+ throw new TypeError(
497
+ "lite-stream: opts.maxBuffer is not allowed with mode: \"latest\" -- latest-wins sets the raw value"
498
+ );
499
+ }
500
+ if (mode === "buffer") {
501
+ if (typeof maxBuffer !== "number" || !Number.isFinite(maxBuffer) || maxBuffer < 1 || (maxBuffer | 0) !== maxBuffer) {
502
+ throw new RangeError(
503
+ "lite-stream: \"buffer\" mode requires opts.maxBuffer to be a positive integer "
504
+ + "(got " + JSON.stringify(maxBuffer) + "). Unbounded buffering is a memory "
505
+ + "bug pretending to be a feature; pick a deliberate ceiling."
506
+ );
507
+ }
508
+ }
509
+ const isBuffer = mode === "buffer";
510
+
511
+ // Overflow counter. droppedCount and overflowCount are aliases (a drop is
512
+ // an overflow), matching toAsyncIterable's vocabulary. Both getters on the
513
+ // returned stop fn read this one variable; no per-value allocation.
514
+ let dropped = 0;
515
+ // Attach the observability getters to whatever stop fn we return. Closes
516
+ // over `dropped`; defined once at construction, never on the hot path.
517
+ const attachCounters = function (fn) {
518
+ Object.defineProperty(fn, "droppedCount", { get() { return dropped; }, enumerable: true, configurable: true });
519
+ Object.defineProperty(fn, "overflowCount", { get() { return dropped; }, enumerable: true, configurable: true });
520
+ return fn;
521
+ };
455
522
 
456
523
  if (abortSig !== undefined && abortSig !== null && abortSig.aborted) {
457
- if (onError !== undefined) {
458
- try { onError(makeAbortError(abortSig)); } catch (_e) {}
524
+ const err = makeAbortError(abortSig);
525
+ if (onAbort !== undefined) {
526
+ try { onAbort(err); } catch (_e) {}
527
+ } else if (onError !== undefined) {
528
+ try { onError(err); } catch (_e) {}
459
529
  }
460
- return function noopStop() {};
530
+ return attachCounters(function noopStop() {});
461
531
  }
462
532
 
463
533
  const iter = toIterator(source);
464
534
  let stopped = false;
465
535
  let abortListener = null;
466
536
 
537
+ // Buffer-mode ring: fixed-size, head-pointer wrap, oldest dropped on
538
+ // overflow. Snapshot rebuilt newest-last per set (fromAsyncIterable pattern).
539
+ let ring = null;
540
+ let ringHead = 0;
541
+ let ringLen = 0;
542
+ if (isBuffer) ring = new Array(maxBuffer);
543
+
467
544
  const stop = function () {
468
545
  if (stopped) return;
469
546
  stopped = true;
@@ -478,7 +555,10 @@ function pipeToSignal(source, target, opts) {
478
555
  abortListener = function () {
479
556
  const err = makeAbortError(abortSig);
480
557
  stop();
481
- if (onError !== undefined) {
558
+ // onAbort, when present, receives aborts INSTEAD of onError.
559
+ if (onAbort !== undefined) {
560
+ try { onAbort(err); } catch (_e) {}
561
+ } else if (onError !== undefined) {
482
562
  try { onError(err); } catch (_e) {}
483
563
  }
484
564
  };
@@ -506,15 +586,52 @@ function pipeToSignal(source, target, opts) {
506
586
  }
507
587
  return;
508
588
  }
509
- if (result.done === true) {
589
+ // LS-13: a throwing done/value getter routes to the "iterator
590
+ // throws" leg (stop + onError). Wrap ONLY the two property reads
591
+ // -- widening this try over transform()/target.set() below would
592
+ // fold the transform-throw leg into this one. Each property is
593
+ // read exactly once into a local.
594
+ let rDone, rValue;
595
+ try {
596
+ rDone = result.done;
597
+ rValue = result.value;
598
+ } catch (err) {
599
+ stop();
600
+ if (onError !== undefined) {
601
+ try { onError(err); } catch (_e) {}
602
+ }
603
+ return;
604
+ }
605
+ if (rDone === true) {
510
606
  stop();
511
607
  if (onDone !== undefined) {
512
608
  try { onDone(); } catch (_e) {}
513
609
  }
514
610
  return;
515
611
  }
612
+ // transform -> onValue tap -> set. A throw anywhere here routes
613
+ // to the same stop + onError leg (the 1.1.0 transform-throw
614
+ // precedent; see test/04 "transform throwing stops the pump").
516
615
  try {
517
- target.set(transform === undefined ? result.value : transform(result.value));
616
+ const v = transform === undefined ? rValue : transform(rValue);
617
+ if (onValue !== undefined) onValue(v);
618
+ if (isBuffer) {
619
+ if (ringLen < maxBuffer) {
620
+ ring[(ringHead + ringLen) % maxBuffer] = v;
621
+ ringLen = (ringLen + 1) | 0;
622
+ } else {
623
+ ring[ringHead] = v;
624
+ ringHead = (ringHead + 1) % maxBuffer;
625
+ dropped = (dropped + 1) | 0;
626
+ }
627
+ const snapshot = new Array(ringLen);
628
+ for (let i = 0; i < ringLen; i = (i + 1) | 0) {
629
+ snapshot[i] = ring[(ringHead + i) % maxBuffer];
630
+ }
631
+ target.set(snapshot);
632
+ } else {
633
+ target.set(v);
634
+ }
518
635
  } catch (err) {
519
636
  stop();
520
637
  if (onError !== undefined) {
@@ -534,7 +651,7 @@ function pipeToSignal(source, target, opts) {
534
651
  }
535
652
 
536
653
  queueMicrotask(pump);
537
- return stop;
654
+ return attachCounters(stop);
538
655
  }
539
656
 
540
657
  // ---------------------------------------------------------------------------
@@ -876,4 +993,4 @@ export {
876
993
 
877
994
  // Always equals the installed package.json version (single source of truth is
878
995
  // package.json; the /release drill bumps both sites in the same commit).
879
- export const VERSION = "1.2.0";
996
+ export const VERSION = "1.3.0";
package/llms.txt CHANGED
@@ -5,7 +5,7 @@ multi-shot dual of lite-await's `fromPromise`. Project async streams
5
5
  (paginated APIs, SSE, network frames, pubsub topics) into signals with
6
6
  bounded buffering and structural cleanup.
7
7
 
8
- ESM-only. Node >= 18. ~5 KB minified. Single file `Stream.js`.
8
+ ESM-only. Node >= 18. Zero runtime dependencies. Single file `Stream.js`.
9
9
  Peer dep: `@zakkster/lite-signal ^1.2.0`.
10
10
 
11
11
  ## Install
@@ -49,22 +49,72 @@ Throws `TypeError` on null/undefined/wrong-shape source. Throws
49
49
 
50
50
  ### `pipeToSignal(source, target, opts?) -> stop`
51
51
 
52
- Pump an existing writable signal from an async iterator. The signal's
53
- value is replaced directly (no wrapper). Returns an idempotent stop fn.
52
+ Pump an existing writable signal from an async iterator. In the default
53
+ `"latest"` mode the signal's value is replaced directly (no wrapper). Returns
54
+ an idempotent stop fn that also carries `droppedCount` / `overflowCount`
55
+ getters.
54
56
 
55
57
  ```js
56
58
  const stop = pipeToSignal(source, targetSig, {
57
59
  signal: abortCtrl.signal,
60
+ mode: "latest" | "buffer", // default "latest"
61
+ maxBuffer: 64, // REQUIRED if mode = "buffer"
62
+ transform: (v) => transform(v), // per-value map, runs first
63
+ onValue: (v) => {}, // tap: fires ONCE per value BEFORE set
64
+ onAbort: (reason) => {}, // aborts route HERE instead of onError
58
65
  onError: (e) => {},
59
- onDone: () => {},
60
- transform: (v) => transform(v)
66
+ onDone: () => {}
61
67
  });
68
+
69
+ stop(); // idempotent -- calling again is a no-op
70
+ stop.droppedCount; // buffer-mode overflow count (0 in latest mode)
71
+ stop.overflowCount; // alias of droppedCount
62
72
  ```
63
73
 
74
+ Enrichment (1.3.0, all ADDITIVE -- the un-optioned path is byte-identical to
75
+ 1.1.0):
76
+
77
+ - `mode: "buffer"` drives the target with a bounded newest-last snapshot array
78
+ (a fresh array per set) instead of the raw value. REQUIRES `maxBuffer` (a
79
+ positive integer; missing/invalid throws `RangeError` -- same
80
+ "unbounded buffering is a memory bug pretending to be a feature" voice as
81
+ `fromAsyncIterable`). Overflow drops the oldest and increments the stop fn's
82
+ `droppedCount`. `mode: "latest"` + `maxBuffer` throws `TypeError`; an unknown
83
+ mode throws `TypeError`. All validated ONCE at construction.
84
+ - `onValue(v)` is a per-value tap that fires exactly once per value BEFORE the
85
+ `set` (and after `transform`). A THROWING `onValue` routes through the same
86
+ leg as a throwing transform -- `stop()` then `onError` (precedent: test/04
87
+ "transform throwing stops the pump cleanly").
88
+ - `onAbort(reason)`, when present, receives aborts INSTEAD of `onError` (both at
89
+ the abort listener and the pre-aborted early return), so consumers stop
90
+ filtering intentional aborts out of their error handler. Absent -> 1.1.0
91
+ behavior byte-identical (aborts flow through `onError`). See the abort
92
+ vocabulary contract table below and `decisions/0001-abort-vocabulary.md`.
93
+ - `stop.droppedCount` and `stop.overflowCount` are getters ON the returned stop
94
+ fn (aliases of one overflow counter); the stop fn stays callable and
95
+ idempotent exactly as 1.1.0.
96
+
64
97
  Does NOT dispose the target signal. The caller owns its lifetime. Disposing
65
98
  the target signal does NOT stop the pump: call the returned stop fn or abort
66
99
  opts.signal.
67
100
 
101
+ #### Abort vocabulary contract
102
+
103
+ The two directions frame an AbortSignal abort differently, by design. See
104
+ `decisions/0001-abort-vocabulary.md` (a repo-only doc, not shipped in the
105
+ tarball).
106
+
107
+ | Direction | On abort | Framing | Escape hatch |
108
+ | --- | --- | --- | --- |
109
+ | `fromAsyncIterable` (from-side) | interrupt production; `iter.return()` best-effort; terminal error state | error (`onError`, `state.error`) | -- |
110
+ | `pipeToSignal` (from-side) | interrupt production; `iter.return()` best-effort | error (`onError`) by default | `onAbort` routes aborts away from `onError` |
111
+ | `toAsyncIterable` (to-side) | stop listening; pending `next()` resolves `{ done: true }` | graceful done | already graceful; nothing to filter |
112
+
113
+ Aborting a producer is an interruption of work in flight (error framing);
114
+ aborting a consumer is a subscriber leaving (graceful done). `onAbort` is added
115
+ to `pipeToSignal` ONLY -- the sole direction that carried a shipped consumer
116
+ workaround.
117
+
68
118
  ### `toAsyncIterable(sig, opts?) -> AsyncIterable<T> & { droppedCount, overflowCount }`
69
119
 
70
120
  Reverse direction: yield signal changes as an async iterable.
@@ -131,7 +181,14 @@ every test run.
131
181
  `fromAsyncIterable` ends on exactly three paths, all structural:
132
182
 
133
183
  1. Iterator natural completion (`{ done: true }`) -> done state, onDone fires.
134
- 2. Iterator throws -> error state, onError fires.
184
+ 2. Iterator throws -> error state, onError fires. As of 1.3.0 (LS-13) this leg
185
+ also covers a `next()` result whose `done` or `value` is a THROWING getter:
186
+ the thrown value routes to the error state with onError fired exactly once,
187
+ instead of escaping as an unhandledRejection with the signal never settling.
188
+ `pipeToSignal` gets the same fix (stop + onError). Both pumps read the two
189
+ result properties once, inside a dedicated try/catch that routes to the
190
+ existing "iterator throws" leg; the peer-defense catch around `set()` (LS-01)
191
+ is separate and untouched.
135
192
  3. AbortSignal aborts -> `iter.return()` called, error state, onError fires.
136
193
 
137
194
  The abort listener is always removed on any of the three paths -- no
@@ -288,7 +345,7 @@ is caught up; `mode: "buffer"` also zero when no waiter is pending
288
345
 
289
346
  ## Files
290
347
 
291
- - `Stream.js` -- single-file ESM implementation (~875 lines)
348
+ - `Stream.js` -- single-file ESM implementation (~995 lines)
292
349
  - `Stream.d.ts` -- TypeScript types with discriminated state union
293
350
  - `README.md` -- full docs with integration recipes
294
351
  - `llms.txt` -- this file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zakkster/lite-stream",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Zero-GC bridge between async iterators and @zakkster/lite-signal. Project async streams (paginated APIs, SSE, network frames, pubsub topics) into signals. Bounded buffering with explicit overflow diagnostics, structural cleanup on three termination paths (iterator done, iterator throws, AbortSignal abort). The multi-shot dual of lite-await's fromPromise.",
5
5
  "author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
6
6
  "license": "MIT",
@@ -29,7 +29,7 @@
29
29
  "test:gc": "node --expose-gc --test --test-reporter=spec test/*.test.mjs",
30
30
  "bench": "node --expose-gc bench/bench.mjs",
31
31
  "torture": "node --expose-gc test/torture.mjs",
32
- "torture:control": "for t in s0 s1 s2 s3 s4 s5 s6 s7 s8; do if STREAM_TORTURE_BREAK=$t node --expose-gc test/torture.mjs >/dev/null 2>&1; then echo \"control $t FAILED-TO-FAIL\"; exit 1; else echo \"control $t failed-as-expected\"; fi; done",
32
+ "torture:control": "for t in s0 s1 s2 s3 s4 s5 s6 s7 s7e s8; do if STREAM_TORTURE_BREAK=$t node --expose-gc test/torture.mjs >/dev/null 2>&1; then echo \"control $t FAILED-TO-FAIL\"; exit 1; else echo \"control $t failed-as-expected\"; fi; done",
33
33
  "test:floor": "echo '== floor lane: pin @zakkster/lite-signal@1.2.2 (torture NOT run here -- lite-leak peers lite-signal>=1.5.0, LS-09 amendment) ==' && npm i --no-save --no-package-lock @zakkster/lite-signal@1.2.2 --legacy-peer-deps && npm test && echo 'FLOOR-PASS: npm test green @ lite-signal@1.2.2' && echo '== latest lane: restore @zakkster/lite-signal@^1.2.0 ==' && npm i --no-save --no-package-lock @zakkster/lite-signal@^1.2.0 && npm test && npm run torture && echo 'LATEST-PASS: npm test + torture green @ lite-signal latest'",
34
34
  "verify": "npm test && npm run test:gc && npm run bench && npm run torture"
35
35
  },