@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.
Files changed (50) hide show
  1. package/dist/async-channel.d.ts +24 -4
  2. package/dist/async-channel.d.ts.map +1 -1
  3. package/dist/async-channel.js +41 -38
  4. package/dist/async-channel.js.map +1 -1
  5. package/dist/combine.d.ts +146 -0
  6. package/dist/combine.d.ts.map +1 -0
  7. package/dist/combine.js +353 -0
  8. package/dist/combine.js.map +1 -0
  9. package/dist/index.d.ts +2 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +2 -0
  12. package/dist/index.js.map +1 -1
  13. package/dist/internal.d.ts +66 -0
  14. package/dist/internal.d.ts.map +1 -0
  15. package/dist/internal.js +134 -0
  16. package/dist/internal.js.map +1 -0
  17. package/dist/iterator-tools.d.ts +13 -0
  18. package/dist/iterator-tools.d.ts.map +1 -1
  19. package/dist/iterator-tools.js +38 -18
  20. package/dist/iterator-tools.js.map +1 -1
  21. package/dist/itertools.d.ts.map +1 -1
  22. package/dist/itertools.js +42 -24
  23. package/dist/itertools.js.map +1 -1
  24. package/dist/tee.d.ts +24 -2
  25. package/dist/tee.d.ts.map +1 -1
  26. package/dist/tee.js +175 -2
  27. package/dist/tee.js.map +1 -1
  28. package/dist/time.d.ts +69 -0
  29. package/dist/time.d.ts.map +1 -0
  30. package/dist/time.js +234 -0
  31. package/dist/time.js.map +1 -0
  32. package/dist/transduce.d.ts +65 -0
  33. package/dist/transduce.d.ts.map +1 -1
  34. package/dist/transduce.js +33 -1
  35. package/dist/transduce.js.map +1 -1
  36. package/dist/transducers.d.ts.map +1 -1
  37. package/dist/transducers.js +86 -16
  38. package/dist/transducers.js.map +1 -1
  39. package/package.json +10 -2
  40. package/readme.md +338 -14
  41. package/src/async-channel.ts +63 -47
  42. package/src/combine.ts +441 -0
  43. package/src/index.ts +2 -0
  44. package/src/internal.ts +164 -0
  45. package/src/iterator-tools.ts +48 -20
  46. package/src/itertools.ts +36 -23
  47. package/src/tee.ts +202 -2
  48. package/src/time.ts +262 -0
  49. package/src/transduce.ts +113 -1
  50. 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": ">=22.12.0",
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.0.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
- Publishing is release-gated in GitHub Actions, matching the deployment setup in
743
- [`johnhenry/math`](https://github.com/johnhenry/math):
744
-
745
- 1. Bump `version` in `package.json` (and run `npm install --package-lock-only`
746
- so the lockfile matches — `npm ci` fails if they diverge).
747
- 2. Merge to `main`; CI runs typecheck, tests, build and the dist/`require(esm)`
748
- smoke tests across Node 22.x and 24.x.
749
- 3. Cut a **GitHub Release**. `.github/workflows/publish.yml` re-runs the full
750
- verification, then publishes with npm provenance attestation.
751
-
752
- The publish step is idempotent (`scripts/npm-publish-if-new.mjs` exits 0 when
753
- the current version is already on the registry), so re-running a release or a
754
- manual `workflow_dispatch` is safe.
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:
@@ -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
- private promise?: Promise<T | typeof CHANNEL_END>;
53
- private resolve?: (value: T | typeof CHANNEL_END) => void;
54
- private reject?: (reason?: unknown) => void;
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({ cache = [], limit = Infinity, transform = ($: T) => $, debug }: AsyncChannelOptions<T> = {}) {
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
- if (this.promise) {
118
- this.resolve?.(value);
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
- const { promise, resolve, reject } = InvertedPromise<T | typeof CHANNEL_END>();
155
- this.promise = promise;
156
- this.resolve = resolve;
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
- if (this.promise) {
172
- await this.resolve?.(CHANNEL_END);
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
- if (this.promise) {
189
- await this.reject?.(new Error(message));
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: new Error(message), release: () => {} });
208
+ this.putters.push({ value: error, release: () => {} });
193
209
  } else {
194
- this.cache.push(new Error(message));
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 !!this.promise;
220
+ return this.takers.length > 0;
205
221
  }
206
222
  /**
207
223
  * Return string representation of Asynchronous Channel