@johnhenry/iteration 0.0.0 → 0.1.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/dist/async-channel.d.ts +24 -4
- package/dist/async-channel.d.ts.map +1 -1
- package/dist/async-channel.js +41 -38
- package/dist/async-channel.js.map +1 -1
- package/dist/combine.d.ts +146 -0
- package/dist/combine.d.ts.map +1 -0
- package/dist/combine.js +353 -0
- package/dist/combine.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +66 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +134 -0
- package/dist/internal.js.map +1 -0
- package/dist/iterator-tools.d.ts +13 -0
- package/dist/iterator-tools.d.ts.map +1 -1
- package/dist/iterator-tools.js +38 -18
- package/dist/iterator-tools.js.map +1 -1
- package/dist/itertools.d.ts.map +1 -1
- package/dist/itertools.js +42 -24
- package/dist/itertools.js.map +1 -1
- package/dist/tee.d.ts +24 -2
- package/dist/tee.d.ts.map +1 -1
- package/dist/tee.js +175 -2
- package/dist/tee.js.map +1 -1
- package/dist/time.d.ts +69 -0
- package/dist/time.d.ts.map +1 -0
- package/dist/time.js +234 -0
- package/dist/time.js.map +1 -0
- package/dist/transduce.d.ts +65 -0
- package/dist/transduce.d.ts.map +1 -1
- package/dist/transduce.js +33 -1
- package/dist/transduce.js.map +1 -1
- package/dist/transducers.d.ts.map +1 -1
- package/dist/transducers.js +86 -16
- package/dist/transducers.js.map +1 -1
- package/package.json +10 -2
- package/readme.md +338 -14
- package/src/async-channel.ts +63 -47
- package/src/combine.ts +441 -0
- package/src/index.ts +2 -0
- package/src/internal.ts +164 -0
- package/src/iterator-tools.ts +48 -20
- package/src/itertools.ts +36 -23
- package/src/tee.ts +202 -2
- package/src/time.ts +262 -0
- package/src/transduce.ts +113 -1
- package/src/transducers.ts +121 -30
package/package.json
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"provenance": true
|
|
7
7
|
},
|
|
8
8
|
"engines": {
|
|
9
|
-
"node": ">=
|
|
9
|
+
"node": ">=26.0.0",
|
|
10
10
|
"npm": ">=8.19.2"
|
|
11
11
|
},
|
|
12
12
|
"exports": {
|
|
@@ -54,6 +54,10 @@
|
|
|
54
54
|
"types": "./dist/async-channel.d.ts",
|
|
55
55
|
"import": "./dist/async-channel.js"
|
|
56
56
|
},
|
|
57
|
+
"./combine": {
|
|
58
|
+
"types": "./dist/combine.d.ts",
|
|
59
|
+
"import": "./dist/combine.js"
|
|
60
|
+
},
|
|
57
61
|
"./abort": {
|
|
58
62
|
"types": "./dist/abort.d.ts",
|
|
59
63
|
"import": "./dist/abort.js"
|
|
@@ -73,6 +77,10 @@
|
|
|
73
77
|
"./is-iterator": {
|
|
74
78
|
"types": "./dist/is-iterator.d.ts",
|
|
75
79
|
"import": "./dist/is-iterator.js"
|
|
80
|
+
},
|
|
81
|
+
"./time": {
|
|
82
|
+
"types": "./dist/time.d.ts",
|
|
83
|
+
"import": "./dist/time.js"
|
|
76
84
|
}
|
|
77
85
|
},
|
|
78
86
|
"repository": {
|
|
@@ -80,7 +88,7 @@
|
|
|
80
88
|
"url": "git+https://github.com/johnhenry/math.git",
|
|
81
89
|
"directory": "packages/iteration"
|
|
82
90
|
},
|
|
83
|
-
"version": "0.
|
|
91
|
+
"version": "0.1.0",
|
|
84
92
|
"description": "Sync + async iterator algebra for TypeScript — transducers, Python-itertools parity, terminal consumers, bounded concurrency, cancellation, and backpressure-aware channels. Zero runtime dependencies.",
|
|
85
93
|
"main": "./dist/index.js",
|
|
86
94
|
"types": "./dist/index.d.ts",
|
package/readme.md
CHANGED
|
@@ -21,6 +21,23 @@ Requires **Node.js 22.12+** (or an equivalent Iterator-Helpers-capable engine).
|
|
|
21
21
|
> [`johnhenry/math`](https://github.com/johnhenry/math) monorepo, restarted at `0.0.0` under the
|
|
22
22
|
> new scoped name — the version number is fresh, the code and its lineage are not.
|
|
23
23
|
|
|
24
|
+
## What's new in 0.0.1
|
|
25
|
+
|
|
26
|
+
Additions for carrying **live values and events**. Every existing export keeps
|
|
27
|
+
its signature and default behaviour.
|
|
28
|
+
|
|
29
|
+
- **Combinators** (`@johnhenry/iteration/combine`): `mergeAsync`,
|
|
30
|
+
`combineLatestAsync`, `withLatestFromAsync`, `latestAsync`, `sampleAsync`.
|
|
31
|
+
- **Time operators** (`@johnhenry/iteration/time`): `debounceAsync`,
|
|
32
|
+
`throttleAsync`, with an injectable `clock`.
|
|
33
|
+
- **`transducePush`**: drive transducers one value at a time, with no iterator.
|
|
34
|
+
- **Options:** `teeSync`/`teeAsync` take `{ limit, overflow }`, and
|
|
35
|
+
`AsyncChannel` takes `errors: "value" | "throw"`.
|
|
36
|
+
- **Fixes:** `zipSync`/`zipAsync`/`zipLongestSync`/`zipLongestAsync` now close
|
|
37
|
+
every input when the zip ends, errors or is exited early, and concurrent
|
|
38
|
+
`AsyncChannel#take()` calls are now served FIFO (a second waiting `take()`
|
|
39
|
+
used to replace the first, which then never resolved).
|
|
40
|
+
|
|
24
41
|
## What's new in 2.0
|
|
25
42
|
|
|
26
43
|
- **Transducer protocol rewritten (memory-leak fix).** In the 1.x line the
|
|
@@ -161,6 +178,13 @@ for await (const pair of zipAsync([1, 2, 3], ["a", "b"])) {
|
|
|
161
178
|
} // logs [1,'a'], [2,'b'] -- stops at the shorter input
|
|
162
179
|
```
|
|
163
180
|
|
|
181
|
+
When the zip ends (the shortest input runs out), when an input throws, or
|
|
182
|
+
when the consumer exits early, every input that has not ended is closed
|
|
183
|
+
with `return()`, so generator `finally` blocks and the resources they guard
|
|
184
|
+
are released. The same holds for `zipLongestSync`/`zipLongestAsync`. An input
|
|
185
|
+
that ended or threw is not closed again. `zipAsync` does not take a
|
|
186
|
+
`{ signal }`; wrap it in `abortable` for cancellation.
|
|
187
|
+
|
|
164
188
|
## Transformation: transducers
|
|
165
189
|
|
|
166
190
|
This library employs transducers,
|
|
@@ -245,7 +269,10 @@ for (const x of transduceSync(sum)([1, 2, 3, 4])) {
|
|
|
245
269
|
|
|
246
270
|
Place items into groups of size N. A trailing, under-sized group is flushed
|
|
247
271
|
once the source is exhausted (via the transducer completion protocol — see
|
|
248
|
-
`transduceSync`/`transduceAsync`)
|
|
272
|
+
`transduceSync`/`transduceAsync`), wherever `group` sits in the pipeline:
|
|
273
|
+
every built-in transducer forwards the flush to the transducers after it.
|
|
274
|
+
(Before 0.0.1, the stateless built-ins did not, so `map(f)` followed by
|
|
275
|
+
`group(2)` silently dropped the trailing group.)
|
|
249
276
|
|
|
250
277
|
```javascript
|
|
251
278
|
import { group } from "@johnhenry/iteration/transducers";
|
|
@@ -376,6 +403,68 @@ for (const x of transformation([
|
|
|
376
403
|
// logs 3, 5, 7, 9
|
|
377
404
|
```
|
|
378
405
|
|
|
406
|
+
### `transducePush`: push values in, one at a time
|
|
407
|
+
|
|
408
|
+
`transducePush(...transducers)` runs the same transducers without an
|
|
409
|
+
iterator, for code that receives values by callback (an event handler, a
|
|
410
|
+
wire in a dataflow graph). It returns `{ step(value), complete(), halted }`:
|
|
411
|
+
`step` returns the items that value emitted, `complete()` flushes stateful
|
|
412
|
+
transducers (such as a trailing partial `group`), and `halted` turns true
|
|
413
|
+
once a transducer returns `HALT`.
|
|
414
|
+
|
|
415
|
+
```javascript
|
|
416
|
+
import { transducePush, transducers } from "@johnhenry/iteration";
|
|
417
|
+
const { group, map } = transducers;
|
|
418
|
+
const pipe = transducePush(group(2), map((pair) => pair.join("+")));
|
|
419
|
+
socket.onmessage = (event) => pipe.step(event.data).forEach(send); // "a+b", "c+d", ...
|
|
420
|
+
socket.onclose = () => pipe.complete().forEach(send); // a trailing "e", if any
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
#### `held()` and `drop(predicate)`: see and let go of what a pipeline keeps
|
|
424
|
+
|
|
425
|
+
Stateful transducers keep items between steps: a half-full `group`,
|
|
426
|
+
`partitionBy`'s open run, `accumulate`'s running value, `dedupe`'s last key.
|
|
427
|
+
`pipe.held()` returns a copy of what is held (upstream stages first), and
|
|
428
|
+
`pipe.drop(predicate)` lets go of the held items `predicate` matches and returns
|
|
429
|
+
how many. Use it when something held must go (a value removed elsewhere, a
|
|
430
|
+
buffer that must not keep a large object alive).
|
|
431
|
+
|
|
432
|
+
```javascript
|
|
433
|
+
const pipe = transducePush(group(3));
|
|
434
|
+
pipe.step(a);
|
|
435
|
+
pipe.step(b);
|
|
436
|
+
pipe.held(); // [a, b]
|
|
437
|
+
pipe.drop((x) => x === a); // 1
|
|
438
|
+
pipe.step(c); // [] -- still waiting for a full group of 3
|
|
439
|
+
pipe.step(d); // [[b, c, d]]
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
Dropping never emits and never changes what is later emitted beyond leaving the
|
|
443
|
+
dropped items out:
|
|
444
|
+
|
|
445
|
+
- `group` keeps waiting for its full size (the batch stays that long), and
|
|
446
|
+
`complete()` flushes what is left.
|
|
447
|
+
- `partitionBy` removes the items from its open run; a run left empty is closed
|
|
448
|
+
without being emitted.
|
|
449
|
+
- `accumulate` can't un-fold one item, so when the predicate matches its running
|
|
450
|
+
value it starts again from its initial value.
|
|
451
|
+
- `dedupe` forgets its last key when the predicate matches it, so the next item
|
|
452
|
+
passes even if equal to it.
|
|
453
|
+
- Stateless transducers (`map`, `filter`, ...) hold nothing and pass the hooks
|
|
454
|
+
through. A custom stateful transducer takes part by setting `held` and `drop`
|
|
455
|
+
on its step (as it does `complete`) and calling the inner step's.
|
|
456
|
+
|
|
457
|
+
It uses the same composition order, `.complete` flush and `HALT` as
|
|
458
|
+
`transduceSync`: pushing every item of `xs` and then calling `complete()`
|
|
459
|
+
returns exactly what `transduceSync(...)(xs)` yields. What it does **not**
|
|
460
|
+
do:
|
|
461
|
+
|
|
462
|
+
- It does not buffer or schedule anything: each `step` runs synchronously
|
|
463
|
+
and returns its output. There is no async variant; a transducer is pure.
|
|
464
|
+
- After `HALT`, or after `complete()`, `step` ignores its value and returns
|
|
465
|
+
`[]`; it does not throw. `complete()` is idempotent.
|
|
466
|
+
- Like `transduceSync`, it drops what the halting step emitted.
|
|
467
|
+
|
|
379
468
|
### Early termination: `HALT`
|
|
380
469
|
|
|
381
470
|
A step may return the `HALT` sentinel to stop consumption of the source —
|
|
@@ -405,6 +494,11 @@ iterator completes by attaching a `.complete(init)` method to the step
|
|
|
405
494
|
function they return (see `group`, above, for a worked example, and note
|
|
406
495
|
that `.complete` must cascade to the inner step's own `.complete`) — see
|
|
407
496
|
`reduceSync`/`reduceAsync` in `src/iterator-tools.ts` for the protocol.
|
|
497
|
+
A custom *stateless* transducer has nothing to flush, but must still pass
|
|
498
|
+
the flush on, or a stateful transducer after it never sees it: copy the
|
|
499
|
+
inner step's `.complete` onto the step you return
|
|
500
|
+
(`if (next.complete) step.complete = next.complete;`), as every built-in
|
|
501
|
+
does.
|
|
408
502
|
|
|
409
503
|
> **Migrating from 2.0:** the old protocol threaded an *iterable*
|
|
410
504
|
> accumulator that each step wrapped in a new generator via
|
|
@@ -609,6 +703,185 @@ for await (const record of prefetchAsync(8, slowDatabaseCursor())) {
|
|
|
609
703
|
}
|
|
610
704
|
```
|
|
611
705
|
|
|
706
|
+
## Live values and events (`@johnhenry/iteration/combine`, new in 0.0.1)
|
|
707
|
+
|
|
708
|
+
Combinators for sources that produce values over time: UI events, sockets,
|
|
709
|
+
channels, sensors. Shared rules:
|
|
710
|
+
|
|
711
|
+
- Inputs may be async or sync iterables.
|
|
712
|
+
- Each takes an optional `{ signal }`. On abort it rejects with the signal's
|
|
713
|
+
reason (an `"AbortError"` `DOMException` by default).
|
|
714
|
+
- Each closes (`return()`) every input that has not ended when it ends, when
|
|
715
|
+
an input errors (the error is rethrown), when the signal aborts, and when
|
|
716
|
+
the consumer exits early. An input whose `next()` is still pending is
|
|
717
|
+
closed without waiting for that `next()` to settle, so a stalled source
|
|
718
|
+
cannot block cleanup.
|
|
719
|
+
- Like every generator here, nothing starts until the first `next()`.
|
|
720
|
+
|
|
721
|
+
The "latest value" combinators (`combineLatestAsync`, `withLatestFromAsync`,
|
|
722
|
+
`latestAsync`, `sampleAsync`) drain some inputs **eagerly**: they keep
|
|
723
|
+
pulling whether or not the consumer is reading, and keep only the newest
|
|
724
|
+
value. That is the point for live values, but it means they do **not**
|
|
725
|
+
apply backpressure to those inputs, and a synchronous infinite iterable
|
|
726
|
+
given as one of those inputs would be drained forever. Give them event
|
|
727
|
+
sources, not generators of unbounded data.
|
|
728
|
+
|
|
729
|
+
### `mergeAsync`
|
|
730
|
+
|
|
731
|
+
`mergeAsync(...iterables, { signal })` yields each value as it arrives from
|
|
732
|
+
any input, and ends when every input has ended.
|
|
733
|
+
|
|
734
|
+
```javascript
|
|
735
|
+
import { mergeAsync } from "@johnhenry/iteration";
|
|
736
|
+
// or: import { mergeAsync } from "@johnhenry/iteration/combine";
|
|
737
|
+
for await (const event of mergeAsync(clicks, keypresses, { signal })) {
|
|
738
|
+
handle(event);
|
|
739
|
+
}
|
|
740
|
+
```
|
|
741
|
+
|
|
742
|
+
It does **not** read ahead: each input has at most one `next()` pending, and
|
|
743
|
+
an input is asked for its next value only after its previous one has been
|
|
744
|
+
consumed. Inputs that are ready at the same moment are served in rotation,
|
|
745
|
+
so one fast (or synchronous) input cannot starve the rest. It does not tag
|
|
746
|
+
values with their input; map each input first if you need that.
|
|
747
|
+
|
|
748
|
+
### `combineLatestAsync`
|
|
749
|
+
|
|
750
|
+
`combineLatestAsync(inputs, { signal, initial, endOn })`: `inputs` is an
|
|
751
|
+
array or a record of iterables, and each output is a snapshot of the same
|
|
752
|
+
shape holding every input's latest value. A snapshot is emitted whenever an
|
|
753
|
+
input yields, once every input has a value.
|
|
754
|
+
|
|
755
|
+
```javascript
|
|
756
|
+
import { combineLatestAsync } from "@johnhenry/iteration";
|
|
757
|
+
for await (const { width, theme } of combineLatestAsync(
|
|
758
|
+
{ width: widths, theme: themes },
|
|
759
|
+
{ initial: { theme: "light" } },
|
|
760
|
+
)) {
|
|
761
|
+
render(width, theme);
|
|
762
|
+
}
|
|
763
|
+
```
|
|
764
|
+
|
|
765
|
+
- **Latest wins:** while the consumer is busy, the snapshots it would have
|
|
766
|
+
received coalesce into one, holding the newest values.
|
|
767
|
+
- `initial` supplies starting values for some or all inputs. It does **not**
|
|
768
|
+
emit on its own: the first snapshot still waits for an input to yield.
|
|
769
|
+
- `endOn: "all"` (default) ends once every input has ended; `"any"` ends as
|
|
770
|
+
soon as one does. A snapshot still pending is delivered first. Either way,
|
|
771
|
+
an input that ends without ever having a value (and no `initial`) ends the
|
|
772
|
+
output at once, since no complete snapshot could follow.
|
|
773
|
+
- It does **not** emit every intermediate combination, and it does not
|
|
774
|
+
compare values: an input that yields the same value again still produces
|
|
775
|
+
a snapshot. Each snapshot is a fresh object. An input error drops a
|
|
776
|
+
snapshot that was still pending.
|
|
777
|
+
|
|
778
|
+
### `withLatestFromAsync`
|
|
779
|
+
|
|
780
|
+
`withLatestFromAsync(source, others, { signal })` pairs each value of
|
|
781
|
+
`source` with the latest value of each of `others` (an array or a record),
|
|
782
|
+
yielding `[value, snapshot]`. This is the "an event samples values" pattern.
|
|
783
|
+
|
|
784
|
+
```javascript
|
|
785
|
+
import { withLatestFromAsync } from "@johnhenry/iteration";
|
|
786
|
+
for await (const [click, { price, qty }] of withLatestFromAsync(buyClicks, { price: prices, qty: quantities })) {
|
|
787
|
+
placeOrder(price, qty);
|
|
788
|
+
}
|
|
789
|
+
```
|
|
790
|
+
|
|
791
|
+
`source` is pulled as the consumer reads; `others` are drained eagerly.
|
|
792
|
+
Values of `others` do **not** produce output by themselves. Source values that
|
|
793
|
+
arrive before every one of `others` has a value are **dropped**, not
|
|
794
|
+
buffered. An input in `others` that ends keeps its last value. The output
|
|
795
|
+
ends when `source` ends.
|
|
796
|
+
|
|
797
|
+
### `latestAsync`
|
|
798
|
+
|
|
799
|
+
`latestAsync(iterable, { signal })` conflates a source: it drains the source
|
|
800
|
+
eagerly and keeps only the newest value not yet read. `next()` returns that
|
|
801
|
+
value, or waits for the next one.
|
|
802
|
+
|
|
803
|
+
```javascript
|
|
804
|
+
import { latestAsync } from "@johnhenry/iteration";
|
|
805
|
+
for await (const position of latestAsync(pointerMoves)) {
|
|
806
|
+
await expensiveRender(position); // never falls behind: stale positions are skipped
|
|
807
|
+
}
|
|
808
|
+
```
|
|
809
|
+
|
|
810
|
+
It does **not** repeat a value: each value is returned at most once, so a
|
|
811
|
+
consumer faster than the source waits. It ends after the source ends and
|
|
812
|
+
its last value has been read. A source error is rethrown on the next read,
|
|
813
|
+
and an unread value is dropped. Closing it closes the source.
|
|
814
|
+
|
|
815
|
+
### `sampleAsync`
|
|
816
|
+
|
|
817
|
+
`sampleAsync(source, trigger, { signal })`: on each `trigger` value, yields
|
|
818
|
+
the latest `source` value, if the source has produced one yet.
|
|
819
|
+
|
|
820
|
+
```javascript
|
|
821
|
+
import { sampleAsync } from "@johnhenry/iteration";
|
|
822
|
+
for await (const position of sampleAsync(pointerMoves, animationFrames)) {
|
|
823
|
+
draw(position);
|
|
824
|
+
}
|
|
825
|
+
```
|
|
826
|
+
|
|
827
|
+
Unlike RxJS `sample`, it does **not** skip a trigger when the source has not
|
|
828
|
+
changed since the last one: the same value is yielded again. Triggers that
|
|
829
|
+
fire before the source has a value yield nothing. The source is drained
|
|
830
|
+
eagerly and the trigger is pulled as the consumer reads. It ends when the
|
|
831
|
+
trigger ends; a source that ends keeps its last value.
|
|
832
|
+
|
|
833
|
+
## Time operators (`@johnhenry/iteration/time`, new in 0.0.1)
|
|
834
|
+
|
|
835
|
+
`debounceAsync` and `throttleAsync` drain their source eagerly (timing
|
|
836
|
+
depends on when values arrive, not on when you read them) and queue what
|
|
837
|
+
they emit until it is read. Both take `{ clock, signal }`. On abort, a source
|
|
838
|
+
error or consumer exit they clear their timer and close the source.
|
|
839
|
+
|
|
840
|
+
`clock` is `{ setTimeout(callback, ms), clearTimeout(handle), now() }`, and
|
|
841
|
+
defaults to `systemClock` (the host's timers and `performance.now()`). Pass
|
|
842
|
+
your own to drive them from a test's fake clock or from a host scheduler;
|
|
843
|
+
`now()` must share the time base of the delays given to `setTimeout`.
|
|
844
|
+
|
|
845
|
+
### `debounceAsync`
|
|
846
|
+
|
|
847
|
+
`debounceAsync(ms, iterable, { clock, signal })` emits a value only after
|
|
848
|
+
`ms` has passed with no newer value: a burst becomes its last value, `ms`
|
|
849
|
+
after the burst ends. When the source ends, a value still waiting is
|
|
850
|
+
emitted at once.
|
|
851
|
+
|
|
852
|
+
```javascript
|
|
853
|
+
import { debounceAsync } from "@johnhenry/iteration";
|
|
854
|
+
// or: import { debounceAsync } from "@johnhenry/iteration/time";
|
|
855
|
+
for await (const query of debounceAsync(250, searchInputs)) {
|
|
856
|
+
search(query);
|
|
857
|
+
}
|
|
858
|
+
```
|
|
859
|
+
|
|
860
|
+
It has no `leading` or `maxWait` option: nothing is emitted during a burst
|
|
861
|
+
that never pauses. A source error is rethrown after the values already
|
|
862
|
+
emitted, and a value still waiting for its quiet period is dropped.
|
|
863
|
+
|
|
864
|
+
### `throttleAsync`
|
|
865
|
+
|
|
866
|
+
`throttleAsync(ms, iterable, { leading, trailing, clock, signal })` emits at
|
|
867
|
+
most one value per `ms` window. A value arriving while no window is open
|
|
868
|
+
opens one, and with `leading` (default `true`) is emitted at once. With
|
|
869
|
+
`trailing` (default `true`), the newest value that arrived during the
|
|
870
|
+
window is emitted when it closes, which opens the next window. When the
|
|
871
|
+
source ends, a trailing value still waiting is emitted at once.
|
|
872
|
+
|
|
873
|
+
```javascript
|
|
874
|
+
import { throttleAsync } from "@johnhenry/iteration";
|
|
875
|
+
for await (const position of throttleAsync(16, pointerMoves)) {
|
|
876
|
+
draw(position); // at most one per 16 ms, and always the final position
|
|
877
|
+
}
|
|
878
|
+
```
|
|
879
|
+
|
|
880
|
+
`leading` and `trailing` cannot both be `false` (a `RangeError`). The
|
|
881
|
+
defaults match lodash's `throttle`, not RxJS `throttleTime` (whose
|
|
882
|
+
`trailing` defaults to `false`). It does **not** align windows to wall-clock
|
|
883
|
+
boundaries, and a source error drops a trailing value still waiting.
|
|
884
|
+
|
|
612
885
|
## Utilities
|
|
613
886
|
|
|
614
887
|
This library provides a number of iterator related utilities.
|
|
@@ -682,6 +955,28 @@ for (const x of transduce(filter((x) => x > 2))(i2)) {
|
|
|
682
955
|
} // logs 3
|
|
683
956
|
```
|
|
684
957
|
|
|
958
|
+
By default a branch that falls behind buffers without limit. Pass
|
|
959
|
+
`{ limit, overflow }` as a second argument to bound each branch's unread
|
|
960
|
+
items (new in 0.0.1):
|
|
961
|
+
|
|
962
|
+
```javascript
|
|
963
|
+
const [ui, log] = teeAsync(2, { limit: 100, overflow: "wait" })(events);
|
|
964
|
+
```
|
|
965
|
+
|
|
966
|
+
- `"wait"` (the `teeAsync` default when a `limit` is given): a branch that
|
|
967
|
+
wants a new item waits until the slowest branch has room, which is true
|
|
968
|
+
backpressure. Read the branches concurrently: draining one to the end
|
|
969
|
+
while another sits unread stalls once that one holds `limit` items.
|
|
970
|
+
- `"drop-oldest"`: the item is delivered, and a full branch discards its
|
|
971
|
+
oldest unread item, so each branch keeps its newest `limit`.
|
|
972
|
+
- `"error"` (the `teeSync` default when a `limit` is given): the pull throws
|
|
973
|
+
a `RangeError` instead of growing a buffer past `limit`.
|
|
974
|
+
|
|
975
|
+
`teeSync` cannot block, so it does **not** support `"wait"` (a `TypeError`).
|
|
976
|
+
`limit` must be an integer `>= 1`. A branch you are not going to read should
|
|
977
|
+
be closed with `return()`: a closed branch stops receiving items and no
|
|
978
|
+
longer counts toward the limit.
|
|
979
|
+
|
|
685
980
|
### `AsyncChannel`
|
|
686
981
|
|
|
687
982
|
AsyncChannel is an experimental primative object.
|
|
@@ -699,6 +994,30 @@ const bounded = new AsyncChannel({ limit: 100 });
|
|
|
699
994
|
await bounded.put(item); // suspends the producer while the channel is full
|
|
700
995
|
```
|
|
701
996
|
|
|
997
|
+
**Concurrent takers (fixed in 0.0.1):** several `take()` calls waiting at
|
|
998
|
+
once are served in call order, one item each. Before 0.0.1 the channel kept
|
|
999
|
+
a single waiting-taker slot, so a second `take()` replaced the first, which
|
|
1000
|
+
then never resolved. `break()` and `throw()` likewise reach only the
|
|
1001
|
+
longest-waiting taker; the others keep waiting.
|
|
1002
|
+
|
|
1003
|
+
**Errors (`errors` option, new in 0.0.1):** `throw(message)` queues an error.
|
|
1004
|
+
A `take()` already waiting when it is called always rejects. For a later
|
|
1005
|
+
`take()`, the `errors` option decides:
|
|
1006
|
+
|
|
1007
|
+
```javascript
|
|
1008
|
+
const lenient = new AsyncChannel(); // errors: "value" (default, unchanged)
|
|
1009
|
+
await lenient.throw("bad");
|
|
1010
|
+
await lenient.take(); // resolves with an Error object; iteration yields it
|
|
1011
|
+
|
|
1012
|
+
const strict = new AsyncChannel({ errors: "throw" });
|
|
1013
|
+
await strict.throw("bad");
|
|
1014
|
+
await strict.take(); // rejects with the Error; `for await` throws
|
|
1015
|
+
```
|
|
1016
|
+
|
|
1017
|
+
With `"throw"`, only errors queued by `throw()` reject. An `Error` passed to
|
|
1018
|
+
`put()` is still delivered as a value. The error does **not** close the
|
|
1019
|
+
channel: items queued after it are still delivered.
|
|
1020
|
+
|
|
702
1021
|
```javascript
|
|
703
1022
|
// file://declare.mjs
|
|
704
1023
|
import { AsyncChannel } from "@johnhenry/iteration";
|
|
@@ -739,19 +1058,24 @@ withEmitter(c, source);
|
|
|
739
1058
|
|
|
740
1059
|
## Releasing
|
|
741
1060
|
|
|
742
|
-
|
|
743
|
-
[
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
1061
|
+
`@johnhenry/iteration` publishes as part of the `johnhenry/math` monorepo, via
|
|
1062
|
+
that repo's root [Changesets](https://github.com/changesets/changesets) flow
|
|
1063
|
+
(`.github/workflows/release.yml`), not a standalone per-package workflow:
|
|
1064
|
+
|
|
1065
|
+
1. Bump `version` in `package.json` and describe the change with
|
|
1066
|
+
`npm run changeset` (run from the `math` repo root), or bump it directly
|
|
1067
|
+
in a PR that includes a changeset.
|
|
1068
|
+
2. Merge to `main`; the root release workflow opens or updates a
|
|
1069
|
+
"chore: version packages" PR.
|
|
1070
|
+
3. Merge that PR. The workflow verifies (typecheck, check, test, build,
|
|
1071
|
+
test:build) and publishes every workspace package whose version isn't yet
|
|
1072
|
+
on npm — idempotent, so re-running (including manual `workflow_dispatch`)
|
|
1073
|
+
is safe.
|
|
1074
|
+
|
|
1075
|
+
See the root [README](../../README.md#releasing) for the full flow. The
|
|
1076
|
+
`scripts/npm-publish-if-new.mjs` script in this package's own directory
|
|
1077
|
+
predates the monorepo merge and is not what the current release workflow
|
|
1078
|
+
uses.
|
|
755
1079
|
|
|
756
1080
|
Requires the **`NPM_TOKEN`** repository secret — a granular automation token
|
|
757
1081
|
with write access to this package:
|
package/src/async-channel.ts
CHANGED
|
@@ -11,32 +11,25 @@
|
|
|
11
11
|
*/
|
|
12
12
|
export const CHANNEL_END: unique symbol = Symbol("CHANNEL_END");
|
|
13
13
|
|
|
14
|
-
interface InvertedPromiseParts<T> {
|
|
15
|
-
promise: Promise<T>;
|
|
16
|
-
resolve: (value: T) => void;
|
|
17
|
-
reject: (reason?: unknown) => void;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Creates a promise that can be resolved/rejected outside of initial closure
|
|
22
|
-
* @kind function
|
|
23
|
-
* @name InvertedPromise
|
|
24
|
-
* @ignore
|
|
25
|
-
*/
|
|
26
|
-
const InvertedPromise = <T>(): InvertedPromiseParts<T> => {
|
|
27
|
-
const out = {} as InvertedPromiseParts<T>;
|
|
28
|
-
out.promise = new Promise<T>((resolve, reject) => {
|
|
29
|
-
out.resolve = resolve;
|
|
30
|
-
out.reject = reject;
|
|
31
|
-
});
|
|
32
|
-
return out;
|
|
33
|
-
};
|
|
34
|
-
|
|
35
14
|
export interface AsyncChannelOptions<T> {
|
|
36
15
|
cache?: Array<T | typeof CHANNEL_END | Error>;
|
|
37
16
|
limit?: number;
|
|
38
17
|
transform?: (item: T) => T | Promise<T>;
|
|
39
18
|
debug?: (...args: unknown[]) => unknown;
|
|
19
|
+
/**
|
|
20
|
+
* How an error queued by `throw()` reaches a `take()` that arrives after
|
|
21
|
+
* it (a `take()` already waiting when `throw()` is called always rejects).
|
|
22
|
+
* - `"value"` (default, the pre-0.0.1 behaviour): `take()` resolves with
|
|
23
|
+
* the `Error` object, and async iteration yields it as an item.
|
|
24
|
+
* - `"throw"`: `take()` rejects with it, so async iteration throws.
|
|
25
|
+
*/
|
|
26
|
+
errors?: "value" | "throw";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A pending take(), waiting for an item. */
|
|
30
|
+
interface Taker<T> {
|
|
31
|
+
resolve: (value: T | typeof CHANNEL_END) => void;
|
|
32
|
+
reject: (reason?: unknown) => void;
|
|
40
33
|
}
|
|
41
34
|
|
|
42
35
|
/**
|
|
@@ -49,9 +42,15 @@ export class AsyncChannel<T = unknown> {
|
|
|
49
42
|
cache: Array<T | typeof CHANNEL_END | Error>;
|
|
50
43
|
transform: (item: T) => T | Promise<T>;
|
|
51
44
|
debug?: (...args: unknown[]) => unknown;
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
45
|
+
errors: "value" | "throw";
|
|
46
|
+
/**
|
|
47
|
+
* Consumers waiting in take(), served FIFO. Before 0.0.1 this was a
|
|
48
|
+
* single slot, so a second concurrent take() replaced the first, which
|
|
49
|
+
* then never resolved.
|
|
50
|
+
*/
|
|
51
|
+
private takers: Array<Taker<T>> = [];
|
|
52
|
+
/** Errors queued by throw() (as opposed to Error objects put() as values). */
|
|
53
|
+
private thrown = new WeakSet<Error>();
|
|
55
54
|
/** Producers awaiting capacity, FIFO (backpressure -- see put()). */
|
|
56
55
|
private putters: Array<{
|
|
57
56
|
value: T | typeof CHANNEL_END | Error;
|
|
@@ -71,11 +70,29 @@ export class AsyncChannel<T = unknown> {
|
|
|
71
70
|
* @kind function
|
|
72
71
|
* @name constructor
|
|
73
72
|
*/
|
|
74
|
-
constructor({
|
|
73
|
+
constructor({
|
|
74
|
+
cache = [],
|
|
75
|
+
limit = Infinity,
|
|
76
|
+
transform = ($: T) => $,
|
|
77
|
+
debug,
|
|
78
|
+
errors = "value",
|
|
79
|
+
}: AsyncChannelOptions<T> = {}) {
|
|
75
80
|
this.limit = limit;
|
|
76
81
|
this.cache = cache.slice(0, limit);
|
|
77
82
|
this.transform = transform;
|
|
78
83
|
this.debug = debug;
|
|
84
|
+
this.errors = errors;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Hand an item that came off the cache or a producer to a take() caller:
|
|
88
|
+
* with `errors: "throw"`, an error queued by throw() rejects.
|
|
89
|
+
* @ignore
|
|
90
|
+
*/
|
|
91
|
+
private deliver(value: T | typeof CHANNEL_END | Error): T | typeof CHANNEL_END {
|
|
92
|
+
if (this.errors === "throw" && value instanceof Error && this.thrown.has(value)) {
|
|
93
|
+
throw value;
|
|
94
|
+
}
|
|
95
|
+
return value as T | typeof CHANNEL_END;
|
|
79
96
|
}
|
|
80
97
|
/**
|
|
81
98
|
* Move waiting producers' items into the cache while there is capacity,
|
|
@@ -114,8 +131,9 @@ export class AsyncChannel<T = unknown> {
|
|
|
114
131
|
try {
|
|
115
132
|
const value = await this.transform(item);
|
|
116
133
|
await myTurn;
|
|
117
|
-
|
|
118
|
-
|
|
134
|
+
const taker = this.takers.shift();
|
|
135
|
+
if (taker) {
|
|
136
|
+
taker.resolve(value);
|
|
119
137
|
return;
|
|
120
138
|
}
|
|
121
139
|
if (this.cache.length < this.limit) {
|
|
@@ -137,29 +155,23 @@ export class AsyncChannel<T = unknown> {
|
|
|
137
155
|
async take(...debug: unknown[]): Promise<T | typeof CHANNEL_END> {
|
|
138
156
|
this.debug?.("take", ...debug);
|
|
139
157
|
if (this.cache.length) {
|
|
140
|
-
const value = this.cache.shift() as T | typeof CHANNEL_END;
|
|
158
|
+
const value = this.cache.shift() as T | typeof CHANNEL_END | Error;
|
|
141
159
|
this.drainPutters();
|
|
142
|
-
return value;
|
|
160
|
+
return this.deliver(value);
|
|
143
161
|
}
|
|
144
162
|
if (this.putters.length) {
|
|
145
163
|
// limit of 0 (rendezvous) or drained cache with waiting producers:
|
|
146
164
|
// hand off directly.
|
|
147
165
|
const { value, release } = this.putters.shift() as {
|
|
148
|
-
value: T | typeof CHANNEL_END;
|
|
166
|
+
value: T | typeof CHANNEL_END | Error;
|
|
149
167
|
release: () => void;
|
|
150
168
|
};
|
|
151
169
|
release();
|
|
152
|
-
return value;
|
|
170
|
+
return this.deliver(value);
|
|
153
171
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
this.reject = reject;
|
|
158
|
-
const value = await this.promise;
|
|
159
|
-
delete this.promise;
|
|
160
|
-
delete this.resolve;
|
|
161
|
-
delete this.reject;
|
|
162
|
-
return value;
|
|
172
|
+
return new Promise<T | typeof CHANNEL_END>((resolve, reject) => {
|
|
173
|
+
this.takers.push({ resolve, reject });
|
|
174
|
+
});
|
|
163
175
|
}
|
|
164
176
|
/**
|
|
165
177
|
* Pause Asynchronous Channel
|
|
@@ -168,8 +180,9 @@ export class AsyncChannel<T = unknown> {
|
|
|
168
180
|
*/
|
|
169
181
|
async break(...debug: unknown[]): Promise<void> {
|
|
170
182
|
this.debug?.("break", ...debug);
|
|
171
|
-
|
|
172
|
-
|
|
183
|
+
const taker = this.takers.shift();
|
|
184
|
+
if (taker) {
|
|
185
|
+
taker.resolve(CHANNEL_END);
|
|
173
186
|
} else if (this.putters.length > 0) {
|
|
174
187
|
// Keep FIFO order: the end marker must not overtake producers
|
|
175
188
|
// already waiting for capacity.
|
|
@@ -185,13 +198,16 @@ export class AsyncChannel<T = unknown> {
|
|
|
185
198
|
*/
|
|
186
199
|
async throw(message?: string, ...debug: unknown[]): Promise<void> {
|
|
187
200
|
this.debug?.("throw", ...debug);
|
|
188
|
-
|
|
189
|
-
|
|
201
|
+
const error = new Error(message);
|
|
202
|
+
this.thrown.add(error);
|
|
203
|
+
const taker = this.takers.shift();
|
|
204
|
+
if (taker) {
|
|
205
|
+
taker.reject(error);
|
|
190
206
|
} else if (this.putters.length > 0) {
|
|
191
207
|
// Keep FIFO order behind producers already waiting for capacity.
|
|
192
|
-
this.putters.push({ value:
|
|
208
|
+
this.putters.push({ value: error, release: () => {} });
|
|
193
209
|
} else {
|
|
194
|
-
this.cache.push(
|
|
210
|
+
this.cache.push(error);
|
|
195
211
|
}
|
|
196
212
|
}
|
|
197
213
|
/**
|
|
@@ -201,7 +217,7 @@ export class AsyncChannel<T = unknown> {
|
|
|
201
217
|
* Note: should this be a getter?
|
|
202
218
|
*/
|
|
203
219
|
pending(): boolean {
|
|
204
|
-
return
|
|
220
|
+
return this.takers.length > 0;
|
|
205
221
|
}
|
|
206
222
|
/**
|
|
207
223
|
* Return string representation of Asynchronous Channel
|