@zakkster/lite-stream 1.0.0 → 1.1.1

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.
Files changed (6) hide show
  1. package/CHANGELOG.md +155 -0
  2. package/README.md +119 -63
  3. package/ROADMAP.md +679 -159
  4. package/Stream.js +316 -80
  5. package/llms.txt +101 -18
  6. package/package.json +3 -3
package/CHANGELOG.md CHANGED
@@ -7,6 +7,159 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ---
9
9
 
10
+ ## 1.1.1 -- 2026-09-01
11
+
12
+ **Truth patch. No API change, no behavior change; the `Stream.js` diff is
13
+ comment-only.** Every shipped sentence the runnable probes could refute is
14
+ corrected, and the previously dark GC tier now gates on the default test run.
15
+
16
+ ### Fixed
17
+
18
+ - **dispose-teardown claim (LS-01)** -- llms.txt, README, the `Stream.js`
19
+ `fromAsyncIterable` docstring, and the `package.json` description all
20
+ claimed the pump detects a disposed result signal on the next `sig.set()`
21
+ and cleans up silently. It never did: `set()` after `dispose()` is a
22
+ silent no-op in lite-signal 1.2.2 and 1.5.0, so the pump keeps pulling and
23
+ neither `iterator.return()` nor any callback fires. Corrected to the true
24
+ behavior -- AbortSignal or natural completion are the only stop mechanisms.
25
+ The defensive `catch` around `set()` and its comment were re-framed
26
+ accordingly.
27
+ - **phantom contract doc + session narrative (LS-06)** -- three references
28
+ to a non-existent `CONTRACT-toAsyncIterable-1.1.md` (in `Stream.js` and
29
+ `test/07-to-async-iterable-rich.test.mjs`) and the "Session A/Session B"
30
+ development narrative were removed; the locked 1.1 semantics now point at
31
+ CHANGELOG.md [1.1.0] and llms.txt.
32
+ - **pump deferral comment (LS-07)** -- the comment claimed a microtask
33
+ deferral that is not in the code. `pump()` is called directly; the stack
34
+ stays bounded because the continuation runs from a `.then()` handler.
35
+ - **perf numbers (LS-08)** -- the shipped ops/s figures named no node
36
+ version, date, or machine and no longer matched a current run. Re-measured
37
+ once with provenance; llms.txt (Performance) is now the single source of
38
+ truth and README cites it. Four 1.1 `toAsyncIterable` bench scenarios
39
+ (`latest-1to1-x10`, `latest-churn-100`, `filter-timeout`, `multi-waiter-x3`)
40
+ were removed from the docs as unreproducible -- no runnable source for them
41
+ exists in the repo; only the six scenarios `bench/bench.mjs` runs are cited.
42
+
43
+ ### Added
44
+
45
+ - **`test/08-dispose-behavior.test.mjs`** -- 6 tests pinning the TRUE dispose
46
+ semantics (LS-01: disposing a result/target signal does not stop the pump;
47
+ LS-04: a pending `next()` on a disposed source never settles, `timeout` is
48
+ the escape hatch).
49
+ - **`--expose-gc` on the default `npm test`** (LS-02) -- the 8 GC-tier tests
50
+ were dark under the default gate (71 run, 8 skipped); they now run on every
51
+ `npm test` (85 total, 0 skipped on Node 20+). `npm run test:gc` is retained
52
+ as an alias.
53
+ - **no-lockfile policy** (LS-10) -- one sentence in the README Testing
54
+ strategy documents why no `package-lock.json` is committed and how the peer
55
+ floor is verified.
56
+
57
+ ### Testing
58
+
59
+ - 85 tests across `test/01-*` through `test/08-*` (up from 79 in 1.1.0). All
60
+ pass under `npm test` and `npm run test:gc`; per-scenario bench numbers with
61
+ provenance live in llms.txt (no ops/s digits are pinned in this changelog).
62
+
63
+ ---
64
+
65
+ ## 1.1.0 -- 2026-07-10
66
+
67
+ **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.
71
+
72
+ ### Added
73
+
74
+ - **`mode: "latest" | "buffer"`** on `toAsyncIterable`. Default `"buffer"`
75
+ preserves 1.0.0 behavior. `mode: "latest"` uses a single mutable slot
76
+ (producer overwrites; consumer reads and clears; every overwrite bumps
77
+ `overflowCount`). Combining `mode: "latest"` with `maxBuffer` throws
78
+ `TypeError` at construction -- silently ignoring one would hide user
79
+ error.
80
+
81
+ - **`filter: (v) => unknown`** on `toAsyncIterable`. Skip values for which
82
+ the predicate returns falsy. Applied to the initial value when
83
+ `emitInitial` is `true`. A throwing filter rejects the current pending
84
+ `next()` with the thrown value and terminates the iterable; the throw
85
+ does NOT surface at the signal writer's `.set()` call site.
86
+
87
+ - **`timeout: number`** on `toAsyncIterable`. Overall deadline in ms from
88
+ the `toAsyncIterable(...)` call (not per-`next()`, not idle). On elapse
89
+ the pending `next()` rejects with `TimeoutError`; subsequent calls
90
+ return `{done: true}`. Negative or non-finite values throw `RangeError`
91
+ at construction.
92
+
93
+ - **`TimeoutError`** as a new named export. Structurally identical to
94
+ `@zakkster/lite-await`'s `TimeoutError` (`e.name === "TimeoutError"`
95
+ duck-checks work across both packages). NOT imported from lite-await --
96
+ zero runtime dependencies preserved.
97
+
98
+ - **`Symbol.asyncDispose`** on the `toAsyncIterable` return object (Node
99
+ 20+ only; absent on older runtimes). Delegates to `iter.return()`.
100
+ Enables `await using iter = toAsyncIterable(sig, { mode: "latest" })`.
101
+
102
+ - **`overflowCount`** getter on the `toAsyncIterable` return object.
103
+ Alias for `droppedCount` with mode-neutral vocabulary. In
104
+ `mode: "buffer"` reads the same value as `droppedCount` (drop-oldest
105
+ count); in `mode: "latest"` counts every slot overwrite.
106
+
107
+ ### Changed (invisible correctness fix)
108
+
109
+ - **Multi-waiter FIFO queue** replaces `toAsyncIterable`'s 1.0.0
110
+ single-slot `pendingResolve`. Concurrent `.next()` calls (e.g. from
111
+ `Promise.all([iter.next(), iter.next()])`) now resolve in FIFO order.
112
+ 1.0.0 silently overwrote the first resolver, causing the first
113
+ `.next()` to never settle. Invisible to correct callers; unblocks
114
+ `Promise.all`-style consumers.
115
+
116
+ ### Not changed
117
+
118
+ - `fromAsyncIterable`, `pipeToSignal` -- unchanged behavior, unchanged
119
+ types, unchanged bench numbers.
120
+ - `toAsyncIterable`'s default behavior (with no `mode` opt) exactly
121
+ matches 1.0.0. Every 1.0.0 test in `05-to-async-iterable.test.mjs`
122
+ continues to pass without modification.
123
+ - Peer dependency remains `@zakkster/lite-signal ^1.2.0`. Node engines
124
+ requirement unchanged (>=18); `Symbol.asyncDispose` is feature-detected
125
+ and gracefully absent on Node 18/19.
126
+
127
+ ### Performance (Node v22, --expose-gc, 500ms runs)
128
+
129
+ 1.1 additions:
130
+ - `to-async-iterable-latest-1to1-x10`: **119K ops/s** (~2.4M ops/s per
131
+ operation), 0.12 B/op retained
132
+ - `to-async-iterable-latest-churn-100`: **60K ops/s** (~6M writes/s
133
+ absorbed into single slot), 0.28 B/op retained
134
+ - `to-async-iterable-filter-timeout`: **141K ops/s**, NEGATIVE retained
135
+ heap (timer-clear discipline helps V8 reclaim more than baseline)
136
+ - `to-async-iterable-multi-waiter-x3`: **120K ops/s**, negative retained
137
+
138
+ 1.0.0 scenarios: unchanged from 1.0.0 numbers.
139
+
140
+ ### Testing
141
+
142
+ 74 tests across 7 files (up from 49 in 1.0.0):
143
+ - `01`-`04`: `fromAsyncIterable`, `pipeToSignal`, cleanup triplet
144
+ (unchanged)
145
+ - `05-to-async-iterable.test.mjs` -- 10 tests, unchanged 1.0.0 behavior
146
+ - `06-gc.test.mjs` -- 8 heap-budget tests (up from 3): 1.0.0 paths +
147
+ 5 new 1.1 paths at 2 MB budget
148
+ - `07-to-async-iterable-rich.test.mjs` (NEW) -- 25 tests covering the
149
+ six 1.1 additions
150
+
151
+ All 74 tests pass under both `npm test` and `npm run test:gc`.
152
+
153
+ ### Bundle
154
+
155
+ - ~7 KB minified (up from ~5 KB), ~3 KB gzipped
156
+ - ESM-only, Node >= 18 (with `Symbol.asyncDispose` on 20+)
157
+ - Single file `Stream.js`
158
+ - Zero runtime dependencies
159
+ - Peer dep: `@zakkster/lite-signal ^1.2.0` (unchanged)
160
+
161
+ ---
162
+
10
163
  ## 1.0.0 -- 2026-06-23
11
164
 
12
165
  **Initial public release.** API frozen; subsequent 1.x releases are purely
@@ -50,6 +203,8 @@ All three paths handled structurally; abort listeners always removed.
50
203
  Additionally: if the consumer disposes the result signal externally, the
51
204
  pump detects on next `sig.set()` and tears down silently (no `onError` --
52
205
  this is consumer-initiated cleanup).
206
+ (Superseded in 1.1.1: this claim was never true -- see [1.1.1] and ROADMAP
207
+ LS-01.)
53
208
 
54
209
  ### Performance (Node v22, --expose-gc, 500ms runs)
55
210
 
package/README.md CHANGED
@@ -116,10 +116,11 @@ The abort listener is registered with `addEventListener("abort", ...)` and
116
116
  **always removed** on any of the three paths -- no AbortSignal accumulates
117
117
  dangling listeners across stream lifecycles.
118
118
 
119
- If the consumer disposes the result signal via `lite-signal`'s `dispose()`,
120
- the pump detects the disposal on its next attempted `sig.set()`, tears down
121
- the iterator silently (no `onError` fired -- this is consumer-initiated
122
- cleanup, not a stream error), and exits.
119
+ Disposing the result signal does NOT stop the pump: the iterator keeps
120
+ pulling. AbortSignal or natural completion are the only stop mechanisms.
121
+ `iterator.return()` is never called and no callback fires, because
122
+ `lite-signal`'s `set()` after `dispose()` is a silent no-op -- the pump never
123
+ learns of the disposal. Abort `opts.signal` to stop early. See ROADMAP LS-01.
123
124
 
124
125
  ## API reference
125
126
 
@@ -182,6 +183,8 @@ Use `pipeToSignal` when:
182
183
  - You want a `stop` fn instead of an `AbortController` for cleanup
183
184
 
184
185
  **Does NOT dispose the target signal.** The caller owns its lifetime.
186
+ Disposing the target signal does NOT stop the pump: call the returned stop
187
+ fn or abort `opts.signal`.
185
188
 
186
189
  ### toAsyncIterable
187
190
 
@@ -190,10 +193,19 @@ toAsyncIterable<T>(
190
193
  sig: Signal<T> | Computed<T>,
191
194
  opts?: {
192
195
  signal?: AbortSignal,
193
- emitInitial?: boolean, // default true
194
- maxBuffer?: number // default 1024
196
+ emitInitial?: boolean, // default true
197
+ maxBuffer?: number, // default 1024 (buffer mode)
198
+
199
+ // Added in 1.1
200
+ mode?: "latest" | "buffer", // default "buffer"
201
+ filter?: (v: T) => unknown,
202
+ timeout?: number
195
203
  }
196
- ): AsyncIterable<T> & { readonly droppedCount: number }
204
+ ): AsyncIterable<T> & {
205
+ readonly droppedCount: number,
206
+ readonly overflowCount: number, // 1.1 alias for droppedCount
207
+ [Symbol.asyncDispose]?: () => Promise<IteratorResult<T, undefined>>
208
+ }
197
209
  ```
198
210
 
199
211
  The reverse direction -- yield signal changes as an async iterable. Useful
@@ -201,12 +213,41 @@ when you have signal-driven state and want to pipe its changes into a
201
213
  WebSocket, log sink, replay tool, or any consumer that wants for-await
202
214
  semantics.
203
215
 
204
- Internal queue is bounded (default 1024). On overflow, the OLDEST value is
205
- dropped and `iterable.droppedCount` increments. The iterator naturally
206
- completes when `opts.signal` aborts; consumer-side `break` triggers
207
- `iterator.return()`.
208
-
209
- Treat this as the secondary API. Most consumers want the forward direction.
216
+ **Two backpressure modes:**
217
+
218
+ - `"buffer"` (default; matches 1.0.0) -- FIFO ring buffer of `maxBuffer`
219
+ size. On overflow the OLDEST value is dropped and `droppedCount` (and
220
+ `overflowCount`) increments. Use when every value matters and must be
221
+ seen in order.
222
+ - `"latest"` (1.1) -- single mutable slot. Producer overwrites; consumer
223
+ reads and clears. Every overwrite bumps `overflowCount`. Use for
224
+ reactive-state consumers ("show the current frame / latest cursor /
225
+ live price") where intermediate values can be safely discarded.
226
+
227
+ **1.1 additions worth calling out:**
228
+
229
+ - `filter` -- skip values for which the predicate returns falsy. A
230
+ throwing filter rejects the current pending `next()` and terminates
231
+ the iterable; the throw does NOT surface at the signal writer's
232
+ `.set()` call site.
233
+ - `timeout` -- overall deadline in ms; on elapse the pending `next()`
234
+ rejects with `TimeoutError` (a new named export in 1.1). Subsequent
235
+ calls return `{done: true}`.
236
+ - `Symbol.asyncDispose` -- delegates to `iter.return()` on Node 20+;
237
+ enables `await using iter = toAsyncIterable(sig, { mode: "latest" })`.
238
+ - Multi-waiter queue -- concurrent `.next()` calls (e.g. from
239
+ `Promise.all([iter.next(), iter.next()])`) now resolve in FIFO order.
240
+ In 1.0.0 the second call silently overwrote the first resolver; the
241
+ 1.1 fix is invisible to correct callers.
242
+
243
+ The iterator naturally completes when `opts.signal` aborts; consumer-side
244
+ `break` triggers `iterator.return()`. `mode: "latest"` combined with
245
+ `maxBuffer` is rejected with `TypeError` at construction -- a single-slot
246
+ mode plus a buffer size is a user error worth surfacing loudly.
247
+
248
+ Treat this as the secondary API for classic "consume signal changes"
249
+ patterns; for the streaming pipeline case (paginated APIs, SSE, pubsub)
250
+ the forward direction is what most consumers want.
210
251
 
211
252
  ## Modes: latest vs buffer
212
253
 
@@ -216,6 +257,8 @@ Pick `"latest"` when you only care about the most recent value:
216
257
  - Current pubsub message
217
258
  - Latest network frame
218
259
  - Current SSE event
260
+ - Reactive state bridged into `@zakkster/lite-query`'s `streamQuery`
261
+ (matches its `mode: "latest"` on the forward direction)
219
262
 
220
263
  Pick `"buffer"` when every value matters and you want them in order:
221
264
 
@@ -223,29 +266,50 @@ Pick `"buffer"` when every value matters and you want them in order:
223
266
  - EBS SSE event log (must not miss an event)
224
267
  - Replay queue (every frame applies in order)
225
268
 
226
- **`"buffer"` mode requires `maxBuffer`.** There is no default, and missing
227
- the option throws a `RangeError` with this message:
269
+ **Availability:**
270
+ - `fromAsyncIterable` has both modes from 1.0.0.
271
+ - `toAsyncIterable` has both modes from 1.1 (was `"buffer"`-only in 1.0.0;
272
+ the option was added as `mode: "latest"` while preserving default
273
+ behavior).
274
+
275
+ **`"buffer"` mode requires `maxBuffer` on `fromAsyncIterable`.** There is
276
+ no default there, and missing the option throws a `RangeError` with this
277
+ message:
228
278
 
229
279
  > `lite-stream: "buffer" mode requires opts.maxBuffer to be a positive
230
280
  > integer. Unbounded buffering is a memory bug pretending to be a feature;
231
281
  > pick a deliberate ceiling.`
232
282
 
233
- On overflow, the OLDEST value is dropped and `droppedCount` increments.
234
- Watch `droppedCount` in your effect to know when your consumer is falling
235
- behind.
283
+ `toAsyncIterable`'s `"buffer"` mode defaults `maxBuffer` to 1024
284
+ (unchanged in 1.1). On overflow the OLDEST value is dropped and
285
+ `droppedCount` increments. Watch `droppedCount` in your effect to know
286
+ when your consumer is falling behind.
287
+
288
+ **streamQuery integration** (matching vocabulary on both directions):
289
+
290
+ ```js
291
+ import { toAsyncIterable } from "@zakkster/lite-stream";
292
+ import { streamQuery } from "@zakkster/lite-query/stream";
293
+
294
+ streamQuery(qc, {
295
+ key: ["price", ticker],
296
+ stream: ({ signal }) => toAsyncIterable(priceSig, {
297
+ signal,
298
+ mode: "latest" // matches streamQuery's mode
299
+ }),
300
+ mode: "latest"
301
+ });
302
+ ```
303
+
304
+ The `signal` from the stream context wires straight through: streamQuery
305
+ aborts on detach, `toAsyncIterable` catches the abort and calls
306
+ `return()`. Zero glue code.
236
307
 
237
308
  ## Zero-GC hot paths
238
309
 
239
310
  `lite-stream`'s hot paths -- per-yield state allocation and per-pull abort
240
- checks -- minimize per-op allocation. Measured retention is sub-byte per op
241
- across all scenarios:
242
-
243
- ```
244
- from-latest-10 178K ops/s 0.07 B/op
245
- from-buffer-20-drop 128K ops/s 0.13 B/op
246
- abort-cycle 84K ops/s 0.71 B/op
247
- pipe-to-signal 235K ops/s 0.10 B/op
248
- ```
311
+ checks -- minimize per-op allocation. Current numbers with provenance live in
312
+ `llms.txt` (Performance); re-run `npm run bench` on your target.
249
313
 
250
314
  Per-yield, `"latest"` mode allocates exactly one wrapper state object. The
251
315
  underlying signal node is reused via lite-signal's pool. `"buffer"` mode
@@ -403,8 +467,9 @@ const ws = new WebSocket("wss://logs.example.com/ingest");
403
467
  - **Sync `Iterable<T>`**: accepted via `Symbol.iterator`. Pulls happen
404
468
  microtask-asynchronously even for sync sources, so caller's `effect()`
405
469
  can wire up before the first yield.
406
- - **Consumer disposes the signal mid-stream**: pump detects on next
407
- `sig.set()`, tears down the iterator silently. No `onError` fired.
470
+ - **Consumer disposes the signal mid-stream**: the pump keeps pulling.
471
+ `iterator.return()` is not called and no callback fires; abort
472
+ `opts.signal` to stop.
408
473
  - **Buffer mode snapshot array**: a fresh array per yield. Past observers
409
474
  hold stable, non-mutated references.
410
475
  - **Disposing the signal multiple times**: lite-signal's `dispose` is
@@ -413,40 +478,15 @@ const ws = new WebSocket("wss://logs.example.com/ingest");
413
478
 
414
479
  ## Benchmarks
415
480
 
416
- Run via `npm run bench` (requires `--expose-gc`).
417
-
418
- ```
419
- [1] fromAsyncIterable latest mode -- 10-item async gen
420
- from-latest-10 178,554 ops/s retained: 0.07 B/op
421
-
422
- [2] fromAsyncIterable buffer mode -- 20-item gen, maxBuffer 5
423
- from-buffer-20-drop 128,630 ops/s retained: 0.13 B/op
424
-
425
- [3] Abort cycle -- infinite gen, abort after first yield
426
- abort-cycle 84,102 ops/s retained: 0.71 B/op
427
-
428
- [4] pipeToSignal -- pump existing signal directly
429
- pipe-to-signal 235,376 ops/s retained: 0.10 B/op
430
-
431
- [5] toAsyncIterable -- 10 signal updates, consumer drains in order
432
- to-async-iterable 42,480 ops/s retained: ~0 B/op
433
-
434
- [6] toAsyncIterable -- producer overflow, drop-oldest
435
- to-async-iterable-overflow 62,164 ops/s retained: ~25 B/op
436
- ```
437
-
438
- (Node v22, 150ms warmup, 500ms runs.)
439
-
440
- The full lifecycle (construct, pull, settle, dispose) of `fromAsyncIterable`
441
- in latest mode runs at 178K ops/s with sub-byte/op retained heap. Per-pull
442
- allocation is dominated by the iterator's own microtask cost, not by
443
- lite-stream's wrapper state.
481
+ Run via `npm run bench` (requires `--expose-gc`). Current numbers with
482
+ provenance live in `llms.txt` (Performance); re-run `npm run bench` on your
483
+ target.
444
484
 
445
485
  ## Testing strategy
446
486
 
447
487
  ### Tier 1 -- behavior (unit tests, fast)
448
488
 
449
- 49 tests across `test/01-*` through `test/06-*`:
489
+ 85 tests across `test/01-*` through `test/08-*`:
450
490
 
451
491
  - `01-from-async-iterable-latest.test.mjs` -- state shape, lifecycle,
452
492
  Iterable acceptance variants, pre-aborted, subscriber observability
@@ -456,24 +496,40 @@ lite-stream's wrapper state.
456
496
  return() invocation, abort reason propagation, 200-cycle leak smoke test
457
497
  - `04-pipe-to-signal.test.mjs` -- target write, stop fn idempotency,
458
498
  transform, abort behavior, target-not-disposed invariant
459
- - `05-to-async-iterable.test.mjs` -- emitInitial, ordering, overflow,
460
- consumer break triggers return(), pre-aborted path
499
+ - `05-to-async-iterable.test.mjs` -- 1.0.0 behavior: emitInitial, ordering,
500
+ overflow, consumer break triggers return(), pre-aborted path
501
+ - `07-to-async-iterable-rich.test.mjs` (1.1) -- `mode:"latest"` behavior,
502
+ filter (including throwing-filter no-writer-surface discipline), timeout
503
+ + TimeoutError, Symbol.asyncDispose (Node 20+ guarded), multi-waiter
504
+ queue, `overflowCount` alias, 4K structural cleanup cycles
505
+ - `08-dispose-behavior.test.mjs` -- pins the TRUE dispose semantics
506
+ (LS-01: disposing a result/target signal does NOT stop the pump;
507
+ LS-04: a pending `next()` on a disposed source never settles, `timeout`
508
+ is the only escape hatch)
461
509
 
462
510
  Run via `npm test`.
463
511
 
464
512
  ### Tier 2 -- memory (allocation-free verification)
465
513
 
466
514
  `test/06-gc.test.mjs` -- runs under `--expose-gc`. Asserts heap budget
467
- ceilings for 2K latest cycles (< 2 MB), 1K buffer cycles (< 2 MB), and
468
- 1K abort cycles (< 2 MB).
515
+ ceilings at 2 MB for the 1.0.0 paths (2K latest, 1K buffer, 1K abort) and
516
+ the 1.1 additions (5K `mode:"latest"` resolve, 3K timeout, 1K buffer
517
+ fill+drain, 2K `mode:"latest"` churn, 2K filter-throw).
469
518
 
470
- Run via `npm run test:gc`.
519
+ The GC tier now runs under the default `npm test` (the script carries
520
+ `--expose-gc`), so these budgets gate on every run; `npm run test:gc` is
521
+ retained as an alias.
471
522
 
472
523
  ### Tier 3 -- performance (measured throughput)
473
524
 
474
525
  `bench/bench.mjs` -- six scenarios; throughput and B/op retained. Run via
475
526
  `npm run bench`.
476
527
 
528
+ No `package-lock.json` is committed: devDependencies float within their
529
+ 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.
532
+
477
533
  ## What this is not
478
534
 
479
535
  - **Not a full reactive stream library.** No `map`, `filter`, `merge`, or