@zakkster/lite-stream 1.1.1 → 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,169 @@ 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
+
91
+ ## 1.2.0 -- 2026-09-02
92
+
93
+ **The law's gate, able to fail.** A torture harness that proves the package's
94
+ own claims -- including the zero-steady-state-allocation sentence that has been
95
+ unverified since 1.0.0 -- plus the drift guards that keep docs, exports, and
96
+ ASCII honest, a `VERSION` export, and the LS-11 packaging cleanup. No behavior
97
+ change: the only `Stream.js` code change is the additive `VERSION` const.
98
+
99
+ ### Added
100
+
101
+ - **Torture harness** -- `test/torture.mjs` + `test/torture/s0-s8`, run by
102
+ `npm run torture` (`node --expose-gc test/torture.mjs`): metamorphic laws
103
+ (s0), degenerate values and hostile iterators (s1), async-iteration protocol
104
+ conformance (s2), API abuse with a decided policy per case (s3), seeded
105
+ fuzz-vs-oracle (s4), the allocation gate (s5, `@zakkster/lite-gc-profiler`,
106
+ `maxMajor` 0 with per-scenario byte budgets), a retention soak (s6,
107
+ `@zakkster/lite-leak` WeakRef census), the StreamQuery conformance corpus and
108
+ TimeoutError duck-pact (s7), and the controls tier (s8). Prints `ok`, exits
109
+ 0/1; `TORTURE_SEED` replays a failure; `STREAM_TORTURE_BREAK=<s0..s8|1>` arms
110
+ each tier's injected breakage so every tier's control exits non-zero.
111
+ - **Scripts** -- `torture`, `torture:control` (all-tiers break lane), and
112
+ `test:floor`. `verify` gains `torture`. **LS-09 amendment:** the floor lane
113
+ runs `npm test` ONLY against the pinned peer -- `@zakkster/lite-leak` peers
114
+ `@zakkster/lite-signal >= 1.5.0`, so pinning `1.2.2` alongside it would put
115
+ lite-leak outside its supported peer range; torture (which imports lite-leak)
116
+ runs against the resolved latest only. Fail closed: never run a tool outside
117
+ its peer range.
118
+ - **`VERSION` export** (LS-05) -- a string that always equals the installed
119
+ `package.json` version. Declared in `Stream.d.ts`; documented in llms.txt and
120
+ the README API reference with no embedded version literal. Never re-exported
121
+ by consumers (suite ruling). The `Stream.d.ts` `TimeoutError` type declaration
122
+ was also added (the shipped runtime export had no type; the surface-guard
123
+ requires three-way parity).
124
+ - **`test/09-guards.test.mjs`** (5 tests) -- ascii-guard (every `files[]` +
125
+ `test/`/`bench/`/`demo/` byte is printable ASCII or LF, empty allowlist) and
126
+ surface-guard (runtime exports == llms.txt import list == `Stream.d.ts` value
127
+ exports; `VERSION` string-equals `package.json` version; the declared peer is
128
+ present in both docs). Each family carries an inline failing control.
129
+ - **devDependencies** -- `@zakkster/lite-leak ^1.10.0` and
130
+ `@zakkster/lite-gc-profiler ^1.16.0` (torture only; not runtime deps).
131
+
132
+ ### Changed
133
+
134
+ - **`files[]` drops `ROADMAP.md`** (LS-11) -- the roadmap is a working doc, not
135
+ a shipped artifact. llms.txt's Files section and the CHANGELOG [1.1.0]
136
+ forward-reference were reworded so tarball readers are not pointed at an
137
+ unshipped file.
138
+ - **`test` / `test:gc` globs scoped to `test/*.test.mjs`** so the `node:test`
139
+ runner does not discover the torture files.
140
+
141
+ ### Fixed
142
+
143
+ - Docs and packaging only; no runtime behavior changed.
144
+
145
+ ### Known issues
146
+
147
+ - **LS-13** (S1, opened this release, fix targeted for 1.3.0) -- a `next()`
148
+ result object whose `done` or `value` is a throwing getter escapes the pump's
149
+ `.then` onFulfilled uncaught: it surfaces as an unhandledRejection and
150
+ `fromAsyncIterable` never reaches a terminal state (it should transition to
151
+ the error state). Torture tier s1 reproduces it with a bounded sentinel and a
152
+ scoped rejection capture and prints it as `TODO(LS3, LS-13)` in the gate
153
+ output; it does not fail the gate. This refutes the "iterator throws -> error
154
+ state" triplet leg for the throwing-getter shape specifically.
155
+
156
+ ### Testing
157
+
158
+ - **90 tests** across `test/01-*` through `test/09-*` (up from 85), 0 fail,
159
+ 0 skip under `npm test` and `npm run test:gc`.
160
+ - `npm run torture` -> `ok`, ~1.8s wall (node v26.3.1, macOS arm64,
161
+ 2026-09-02); zero steady-state majors in every s5 scenario.
162
+ - `npm run test:floor` -> FLOOR-PASS (`npm test` 90/0 against
163
+ `@zakkster/lite-signal@1.2.2`) and LATEST-PASS (`npm test` 90/0 + torture ok
164
+ against the resolved `@zakkster/lite-signal@1.5.0`). The `^1.2.0` peer floor
165
+ is now a tested fact, not a claim.
166
+ - s5 budget doctrine: per-scenario `maxBytesPerOp` budgets (32 B/op for the
167
+ caught-up toAsyncIterable and pipeToSignal steady pump; 16 B/op for the
168
+ by-design fromAsyncIterable state-object and waiter-heavy paths), each ~3x the
169
+ measured 3-pass peak; the global `maxMajor` rule never widens.
170
+
171
+ ---
172
+
10
173
  ## 1.1.1 -- 2026-09-01
11
174
 
12
175
  **Truth patch. No API change, no behavior change; the `Stream.js` diff is
@@ -65,9 +228,9 @@ corrected, and the previously dark GC tier now gates on the default test run.
65
228
  ## 1.1.0 -- 2026-07-10
66
229
 
67
230
  **Additive `toAsyncIterable` enrichment.** Six additions, no breaking
68
- changes. All 1.0.0 callers continue to work without modification. See
69
- ROADMAP.md's "Shipped in 1.1.0" section for the triggering signals that
70
- justified each addition.
231
+ changes. All 1.0.0 callers continue to work without modification. Each
232
+ addition below records the triggering signal that justified it; the
233
+ `Added` and `Changed` entries in this section stand on their own.
71
234
 
72
235
  ### Added
73
236
 
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
@@ -249,6 +286,16 @@ Treat this as the secondary API for classic "consume signal changes"
249
286
  patterns; for the streaming pipeline case (paginated APIs, SSE, pubsub)
250
287
  the forward direction is what most consumers want.
251
288
 
289
+ ### VERSION
290
+
291
+ ```ts
292
+ VERSION: string
293
+ ```
294
+
295
+ A string that always equals the installed `package.json` version. Consumers
296
+ do NOT re-export it (suite ruling); the surface-guard asserts the equality on
297
+ every test run.
298
+
252
299
  ## Modes: latest vs buffer
253
300
 
254
301
  Pick `"latest"` when you only care about the most recent value:
@@ -486,7 +533,7 @@ target.
486
533
 
487
534
  ### Tier 1 -- behavior (unit tests, fast)
488
535
 
489
- 85 tests across `test/01-*` through `test/08-*`:
536
+ 111 tests across `test/01-*` through `test/10-*`:
490
537
 
491
538
  - `01-from-async-iterable-latest.test.mjs` -- state shape, lifecycle,
492
539
  Iterable acceptance variants, pre-aborted, subscriber observability
@@ -505,7 +552,18 @@ target.
505
552
  - `08-dispose-behavior.test.mjs` -- pins the TRUE dispose semantics
506
553
  (LS-01: disposing a result/target signal does NOT stop the pump;
507
554
  LS-04: a pending `next()` on a disposed source never settles, `timeout`
508
- 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)
558
+ - `09-guards.test.mjs` -- drift guards: ascii-guard (every shipped +
559
+ test/bench/demo byte is printable ASCII or LF) and surface-guard (runtime
560
+ exports == llms.txt == Stream.d.ts, VERSION == package.json version, the
561
+ declared peer is present in both docs). Each family carries an inline
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
509
567
 
510
568
  Run via `npm test`.
511
569
 
@@ -525,10 +583,26 @@ retained as an alias.
525
583
  `bench/bench.mjs` -- six scenarios; throughput and B/op retained. Run via
526
584
  `npm run bench`.
527
585
 
586
+ ### Tier 4 -- torture (retention + alloc gate + controls)
587
+
588
+ `npm run torture` (`node --expose-gc test/torture.mjs`) runs tiers s0-s8
589
+ sequentially -- metamorphic laws, degenerate values, protocol conformance,
590
+ API abuse, seeded fuzz-vs-oracle, the s5 per-scenario allocation gate
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),
594
+ and the controls tier. It prints `ok` and exits 0 on success.
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
599
+ floor `@zakkster/lite-signal@1.2.2` and then `npm test` + torture against the
600
+ resolved latest, printing both verdicts.
601
+
528
602
  No `package-lock.json` is committed: devDependencies float within their
529
603
  stated ranges, and correctness against the peer floor is proven by running
530
- the suite against `@zakkster/lite-signal@1.2.2` rather than by pinning a
531
- lockfile.
604
+ `npm run test:floor` against `@zakkster/lite-signal@1.2.2` rather than by
605
+ pinning a lockfile.
532
606
 
533
607
  ## What this is not
534
608
 
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
@@ -135,3 +188,16 @@ export function toAsyncIterable<T>(
135
188
  sig: Signal<T> | Computed<T>,
136
189
  opts?: ToAsyncIterableOptions
137
190
  ): AsyncIterable<T> & { readonly droppedCount: number };
191
+
192
+ /**
193
+ * Thrown when the `timeout` option on `toAsyncIterable` elapses. Structurally
194
+ * identical to `@zakkster/lite-await`'s TimeoutError (`e.name === "TimeoutError"`
195
+ * duck-checks work across both packages); not imported -- lite-stream stays zero-dep.
196
+ */
197
+ export declare class TimeoutError extends Error {
198
+ readonly name: "TimeoutError";
199
+ readonly timeout: number;
200
+ }
201
+
202
+ /** Package version string; always equals the installed package.json version. */
203
+ export declare const VERSION: string;
package/Stream.js CHANGED
@@ -1,4 +1,4 @@
1
- // @zakkster/lite-stream 1.1.1
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
  // ---------------------------------------------------------------------------
@@ -873,3 +990,7 @@ export {
873
990
  toAsyncIterable,
874
991
  TimeoutError
875
992
  };
993
+
994
+ // Always equals the installed package.json version (single source of truth is
995
+ // package.json; the /release drill bumps both sites in the same commit).
996
+ export const VERSION = "1.3.0";