@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 +166 -3
- package/README.md +83 -9
- package/Stream.d.ts +70 -4
- package/Stream.js +133 -12
- package/llms.txt +72 -10
- package/package.json +10 -6
- package/ROADMAP.md +0 -711
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.
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
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.
|
|
177
|
-
(no `{ value, count, ... }` wrapper).
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
114
|
-
*
|
|
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
|
-
):
|
|
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
|
+
// @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
|
-
|
|
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
|
-
|
|
458
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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";
|