pure-effect 0.18.0 → 0.20.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 (5) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +678 -306
  3. package/index.d.ts +82 -22
  4. package/index.js +625 -48
  5. package/package.json +2 -1
package/index.js CHANGED
@@ -4,12 +4,15 @@
4
4
  //
5
5
  // 1. Types: the JSDoc shapes of the nodes a flow is made of.
6
6
  // 2. Building flows: the constructors; the checks, error messages and kinds of failure the later sections
7
- // share, `checkOptions` and `Retry`'s defaults among them; and `effectPipe`, with the `chain` that joins its
8
- // steps and the `flowInputs` it records for `onRun`.
7
+ // share, `checkOptions` and `Retry`'s defaults among them; `effectPipe`, with the `chain` that joins its
8
+ // steps, the stop listeners and `restarts` `chain` keeps for generator flows, and the `flowInputs` it records
9
+ // for `onRun` and `recordEffect`; and `step` and `effectGen`, which build a flow from a generator function.
9
10
  // 3. Configuration: the hook types and their defaults, `configureEffect` and its layers, and `chainHooks`, which
10
11
  // merges them.
11
12
  // 4. Running flows: the helpers `Retry`, `Parallel` and Commands run on; `interpret`, which builds a run's
12
- // `Runtime`, then `execute` and a function for each kind of node, which each take it; and `runEffect`.
13
+ // `Runtime`, then `stopAt`, which tells a waiting generator flow its step stopped, `ownRun`, which builds a
14
+ // generator flow again for each execution after its first, `execute` and a function for each kind of node,
15
+ // which each take it; and `runEffect`.
13
16
  // 5. Recording and replay: the trace format and replay errors, copying values and errors into a trace,
14
17
  // `recorder` and `recordEffect`, then `fromTrace`, `replayEffect` and `timeTravel`, each followed by the
15
18
  // functions it hands its parts to.
@@ -45,7 +48,7 @@
45
48
  * @typedef {{
46
49
  * type: 'Retry',
47
50
  * effect: Effect,
48
- * options: Readonly<{ attempts?: number, delay?: number, backoff?: number, onExhausted?: (error: any) => Effect }>,
51
+ * options: Readonly<{ retries?: number, delay?: number, backoff?: number, onExhausted?: (error: any) => Effect }>,
49
52
  * next: (value: any) => Effect
50
53
  * }} RetryState
51
54
  */
@@ -137,10 +140,14 @@ const commandName = (eff) => {
137
140
 
138
141
  /**
139
142
  * Reads the context object from the current `runEffect` call.
140
- * @param {(context: any) => Effect} next - Receives the context and returns the next Effect
143
+ * @param {...((context: any) => Effect)} args - `next`, which receives the context and returns the next Effect. With
144
+ * no argument, the Ask succeeds with the context itself, as `yield* step(Ask())` reads it.
141
145
  * @returns {AskState}
142
146
  */
143
- const Ask = (next) => {
147
+ const Ask = (...args) => {
148
+ // Only a call with no argument: an explicit `undefined` is usually a misspelt name.
149
+ if (args.length === 0) return { type: 'Ask', next: (/** @type {any} */ context) => Success(context) };
150
+ const [next] = args;
144
151
  if (typeof next !== 'function') {
145
152
  throw malformed(`Ask expects a function that receives the context, got ${describeArgument(next)}.`, next);
146
153
  }
@@ -156,12 +163,12 @@ const Ask = (next) => {
156
163
  *
157
164
  * @param {Effect} effect - The inner Effect tree to retry
158
165
  * @param {Object} [options] - Merged over the defaults when it runs; an option set to `undefined` keeps its default.
159
- * @param {number} [options.attempts] - Retries after the first try, a positive integer
166
+ * @param {number} [options.retries] - Tries after the first, a positive integer
160
167
  * @param {number} [options.delay] - Ms before the first retry, a finite number of 0 or more
161
168
  * @param {number} [options.backoff] - Multiplier applied to the delay on each later retry, a finite number of 0 or
162
169
  * more
163
170
  * @param {(error: any) => Effect} [options.onExhausted] - Builds a fallback from `{ retryExhausted, lastError,
164
- * attempts }` once every attempt has failed. Its success feeds `next`; its failure propagates unwrapped.
171
+ * retries }` once every attempt has failed. Its success feeds `next`; its failure propagates unwrapped.
165
172
  * @returns {RetryState}
166
173
  */
167
174
  const Retry = (effect, options) => {
@@ -169,7 +176,7 @@ const Retry = (effect, options) => {
169
176
  if (typeof options === 'number') {
170
177
  const got = describeArgument(options);
171
178
  throw malformed(
172
- `Retry's options must be an object, got ${got}: write Retry(effect, { attempts: ${options} }).`,
179
+ `Retry's options must be an object, got ${got}: write Retry(effect, { retries: ${options} }).`,
173
180
  options
174
181
  );
175
182
  }
@@ -311,7 +318,7 @@ const checkOptions = (options, source, rules, raise = (message) => new TypeError
311
318
  };
312
319
 
313
320
  /** What `Retry` uses for an option left out or set to `undefined`. */
314
- const defaultRetryOptions = { attempts: 3, delay: 100, backoff: 1 };
321
+ const defaultRetryOptions = { retries: 3, delay: 100, backoff: 1 };
315
322
 
316
323
  /**
317
324
  * Checked when the Retry is built, which keeps a frozen copy of the options, so they cannot change before it runs.
@@ -319,7 +326,7 @@ const defaultRetryOptions = { attempts: 3, delay: 100, backoff: 1 };
319
326
  */
320
327
  const retryOptionRules = {
321
328
  // Not 0, which would make `onExhausted` a catch.
322
- attempts: [
329
+ retries: [
323
330
  isPositiveInteger,
324
331
  'a positive integer',
325
332
  "To handle an outcome without retrying, branch on it as data in the Command's next, or isolate a failing " +
@@ -486,21 +493,76 @@ const asEffect = (value, source) => {
486
493
  * @param {Effect} effect - The current Effect object
487
494
  * @param {(value: any) => Effect} fn - The next function to run if the current effect is a Success
488
495
  * @param {Effect} [from] - The node whose `next` returned `effect`, which an error names
496
+ * @param {StopListener} [onStop] - Set by `effectGen`: told when a Failure passes `fn`, and kept on each copy for
497
+ * `execute`'s `stopAt`, which calls it when the copy itself stops the flow
489
498
  * @returns {Effect} The composed Effect
490
499
  */
491
- const chain = (effect, fn, from) => {
500
+ const chain = (effect, fn, from, onStop) => {
492
501
  // Before reading `.type`, so a missing return is named rather than thrown as a bare TypeError.
493
502
  if (!isEffect(effect)) throw effectTypeError(effect, from ? nextOf(from) : 'A continuation');
494
503
  if (effect.type === 'Success') return asEffect(fn(effect.value), `Step '${fn.name || 'anonymous'}'`);
495
- if (effect.type === 'Failure') return effect;
496
- const next = (/** @type {any} */ value) => chain(effect.next(value), fn, effect);
504
+ if (effect.type === 'Failure') {
505
+ onStop?.(from);
506
+ return effect;
507
+ }
508
+ const next = (/** @type {any} */ value) => chain(effect.next(value), fn, effect, onStop);
497
509
  // A Command goes back through its constructor, which checks the function of one built by hand.
498
- return effect.type === 'Command' ? Command(effect.cmd, next, effect.meta) : { ...effect, next };
510
+ const copy = effect.type === 'Command' ? Command(effect.cmd, next, effect.meta) : { ...effect, next };
511
+ const waiting = stopListeners.get(effect);
512
+ if (onStop) stopListeners.set(copy, [...(waiting ?? []), onStop]);
513
+ else if (waiting) stopListeners.set(copy, waiting);
514
+ const restart = restarts.get(effect);
515
+ if (restart) {
516
+ restarts.set(copy, { built: restart.built, rebuild: () => chain(restart.rebuild(), fn, from, onStop) });
517
+ }
518
+ return copy;
499
519
  };
500
520
 
501
521
  /**
502
- * The input each flow `effectPipe` built was called with, keyed on the flow's root, for `interpret` to hand `onRun`.
503
- * Kept off the flow, so no node or outcome carries it.
522
+ * Told that the flow stopped at `node`, or at a Failure when `node` is undefined, so a generator flow waiting on it
523
+ * runs its `catch` and `finally` blocks.
524
+ * @typedef {(node: Effect | undefined) => void} StopListener
525
+ */
526
+
527
+ /**
528
+ * The generator flows waiting on each node `chain` copied for them, innermost first. `chain` calls one when a Failure
529
+ * passes the continuation it guards, and `execute`'s `stopAt` calls them all when the node itself stops the flow.
530
+ * @type {WeakMap<Effect, StopListener[]>}
531
+ */
532
+ const stopListeners = new WeakMap();
533
+
534
+ /**
535
+ * The body run a generator flow made as it was built, which the first execution of the flow takes over if nothing
536
+ * has used it. `claimed` is shared by every copy of the flow's root, so only one execution takes it over.
537
+ * @typedef {{ run: BodyRun, position: number, state: BodyRun['state'], claimed: boolean }} BuiltRun
538
+ */
539
+
540
+ /**
541
+ * How to build each generator flow's root again from its input, and the run it was built with. `effectGen` sets one,
542
+ * `chain` and `copyRoot` keep it on their copies, and `execute`'s `ownRun` builds the flow again for every execution
543
+ * after the first, so a `Retry` or a reused flow value runs the body afresh, as a retried `async` function does.
544
+ * @type {WeakMap<Effect, { built: BuiltRun, rebuild: () => Effect }>}
545
+ */
546
+ const restarts = new WeakMap();
547
+
548
+ /**
549
+ * Copies a flow's root, which `flowInputs` is keyed on, keeping the generator flows waiting on it and how to build it
550
+ * again.
551
+ * @param {Effect} tree
552
+ * @returns {Effect}
553
+ */
554
+ const copyRoot = (tree) => {
555
+ const root = { ...tree };
556
+ const waiting = stopListeners.get(tree);
557
+ if (waiting) stopListeners.set(root, waiting);
558
+ const restart = restarts.get(tree);
559
+ if (restart) restarts.set(root, { built: restart.built, rebuild: () => copyRoot(restart.rebuild()) });
560
+ return root;
561
+ };
562
+
563
+ /**
564
+ * The input each flow `effectPipe` or `effectGen` built was called with, keyed on the flow's root, for `interpret` to
565
+ * hand `onRun` and for `recordEffect` to store in its trace. Kept off the flow, so no node or outcome carries it.
504
566
  * @type {WeakMap<Effect, any>}
505
567
  */
506
568
  const flowInputs = new WeakMap();
@@ -527,19 +589,443 @@ const effectPipe = (...fns) => {
527
589
  return (start) => {
528
590
  const tree = fns.reduce((eff, fn) => chain(eff, fn), /** @type {Effect} */ (Success(start)));
529
591
  // Keyed on a copy, since the last step can return an object other flows share, such as a constant Failure.
530
- const root = { ...tree };
592
+ const root = copyRoot(tree);
531
593
  flowInputs.set(root, start);
532
594
  return root;
533
595
  };
534
596
  };
535
597
 
598
+ /**
599
+ * Every marker a step's iterator yielded, so `effectGen` can tell one from a value the body yielded itself.
600
+ * @type {WeakSet<StepMarker>}
601
+ */
602
+ const stepMarkers = new WeakSet();
603
+
604
+ /**
605
+ * The markers `effectGen` received, so a step's iterator can tell `yield*` from a spread or a `for...of`.
606
+ * @type {WeakSet<StepMarker>}
607
+ */
608
+ const claimedMarkers = new WeakSet();
609
+
610
+ /**
611
+ * The Effect inside each step wrapper, so `effectGen` can name a step yielded or returned without `yield*`.
612
+ * @type {WeakMap<object, Effect>}
613
+ */
614
+ const stepEffects = new WeakMap();
615
+
616
+ /** @typedef {Readonly<{ effect: Effect }>} StepMarker */
617
+
618
+ /**
619
+ * @param {any} value
620
+ * @returns {boolean}
621
+ */
622
+ const isThenable = (value) => isObject(value) && typeof value.then === 'function';
623
+
624
+ /**
625
+ * Names a step for a message, and for comparing one run of a body with the next.
626
+ * @param {Effect} effect
627
+ * @returns {string}
628
+ */
629
+ const describeStep = (effect) =>
630
+ effect.type === 'Command'
631
+ ? `Command '${commandName(effect)}'`
632
+ : `${effect.type === 'Ask' ? 'an' : 'a'} ${effect.type}`;
633
+
634
+ /**
635
+ * Wraps an Effect for `yield*` inside `effectGen`, which gives back the Effect's value. The wrapper is a proxy that throws
636
+ * when it is used as a value, and its iterator throws unless `effectGen` claimed what it yielded.
637
+ *
638
+ * @param {Effect} effect
639
+ * @returns {any}
640
+ */
641
+ const step = (effect) => {
642
+ if (!isEffect(effect)) {
643
+ throw malformed(
644
+ isThenable(effect)
645
+ ? 'step expects an Effect, got a Promise: put the call in a Command, as in ' +
646
+ 'yield* step(Command(() => db.findUser(email))).'
647
+ : `step expects an Effect, got ${describeArgument(effect)}.`,
648
+ effect
649
+ );
650
+ }
651
+ /** @returns {Generator<StepMarker, any, any>} */
652
+ const iterate = function* () {
653
+ const marker = Object.freeze({ effect });
654
+ stepMarkers.add(marker);
655
+ try {
656
+ return yield marker;
657
+ } finally {
658
+ // Only `effectGen` claims a marker, so an unclaimed one means a spread, `Array.from` or a `for...of`.
659
+ if (!claimedMarkers.has(marker)) {
660
+ throw malformed(
661
+ `step(${describeStep(effect)}) was iterated outside yield*; write yield* step(...).`,
662
+ effect
663
+ );
664
+ }
665
+ }
666
+ };
667
+ const refuse = () => {
668
+ throw malformed(`step(${describeStep(effect)}) was used as a value; write yield* step(...).`, effect);
669
+ };
670
+ const inspectCustom = Symbol.for('nodejs.util.inspect.custom');
671
+ const hint = () => `[step: ${describeStep(effect)}, use with yield*]`;
672
+ // On the target as well, since Node's inspect reads a proxy's target without running its traps.
673
+ const target = Object.freeze(Object.defineProperty({}, inspectCustom, { value: hint }));
674
+ const wrapper = new Proxy(target, {
675
+ get: (_, key) => (key === Symbol.iterator ? iterate : key === inspectCustom ? hint : refuse()),
676
+ has: refuse,
677
+ ownKeys: refuse,
678
+ getOwnPropertyDescriptor: refuse,
679
+ set: refuse,
680
+ defineProperty: refuse,
681
+ deleteProperty: refuse
682
+ });
683
+ stepEffects.set(wrapper, effect);
684
+ return wrapper;
685
+ };
686
+
687
+ /** What an `async function*` inherits from, which `effectGen` refuses before it builds anything. */
688
+ const asyncGeneratorFunction = Object.getPrototypeOf(async function* () {});
689
+
690
+ /**
691
+ * A generator flow called with its input, which a re-run starts its body from again.
692
+ * @typedef {{ body: (input: any) => any, input: any }} GenFlow
693
+ */
694
+
695
+ /**
696
+ * One run of a body: its generator, the position of the step it is waiting at, whether it can still resume, and
697
+ * whether an execution took it over (`takeBuiltRun`), which only ever moves it forward.
698
+ * @typedef {{ generator: Generator<any, any, any>, position: number, state: 'suspended' | 'done', owned: boolean }} BodyRun
699
+ */
700
+
701
+ /**
702
+ * The answers a body was given before a position, newest first, each with the step it answered, so a re-run can feed
703
+ * them again and check the body asks for the same steps. Shared between continuations, never copied.
704
+ * @typedef {{ signature: string, answer: any, earlier: Trail } | null} Trail
705
+ */
706
+
707
+ /**
708
+ * Where a run an execution owns keeps its earlier answers: nowhere, since running it again builds the flow again from
709
+ * its input, so a long loop holds only the answer it is on. `rerun` refuses a trail that reaches it.
710
+ * @type {NonNullable<Trail>}
711
+ */
712
+ const droppedAnswers = Object.freeze({ signature: '', answer: undefined, earlier: null });
713
+
714
+ /**
715
+ * @typedef {(generator: Generator<any, any, any>) => IteratorResult<any, any>} Resume
716
+ */
717
+
718
+ /**
719
+ * Starts a body. A function that returns anything but a synchronous generator is refused here, which covers a bundled
720
+ * `async function*` the prototype check in `effectGen` missed.
721
+ * @param {GenFlow} flow
722
+ * @returns {BodyRun}
723
+ */
724
+ const startRun = (flow) => {
725
+ const generator = flow.body(flow.input);
726
+ const isGenerator =
727
+ isObject(generator) &&
728
+ ['next', 'throw', 'return'].every((name) => typeof generator[name] === 'function') &&
729
+ generator[Symbol.toStringTag] !== 'AsyncGenerator';
730
+ if (!isGenerator) {
731
+ throw malformed(
732
+ 'effectGen expects a generator function, function* (input) { ... }; ' +
733
+ `its function returned ${describeValue(generator)}.`,
734
+ generator
735
+ );
736
+ }
737
+ return { generator, position: 0, state: 'suspended', owned: false };
738
+ };
739
+
740
+ /**
741
+ * Adds a hint to V8's error for `yield*` on a value that is not iterable, keeping the original as `cause`.
742
+ * @param {unknown} e
743
+ * @returns {unknown}
744
+ */
745
+ const explainBodyThrow = (e) => {
746
+ // V8's two wordings; the second is not unique to yield*, so the hint is conditional.
747
+ const notIterable = [
748
+ /^yield\* .* is not iterable$/,
749
+ /^object is not iterable \(cannot read property Symbol\(Symbol\.iterator\)\)$/
750
+ ];
751
+ if (!(e instanceof TypeError) || hasMark(e, harnessError) || !notIterable.some((p) => p.test(e.message))) {
752
+ return e;
753
+ }
754
+ const error = malformed(
755
+ `A generator flow threw "${e.message}". If it came from yield* on an Effect, write yield* step(effect).`,
756
+ undefined
757
+ );
758
+ return Object.defineProperty(error, 'cause', { value: e, writable: true, configurable: true });
759
+ };
760
+
761
+ /**
762
+ * @param {BodyRun} run
763
+ * @param {Resume} resume
764
+ * @returns {IteratorResult<any, any>}
765
+ */
766
+ const resumeBody = (run, resume) => {
767
+ try {
768
+ const result = resume(run.generator);
769
+ if (result.done) run.state = 'done';
770
+ return result;
771
+ } catch (e) {
772
+ run.state = 'done';
773
+ throw explainBodyThrow(e);
774
+ }
775
+ };
776
+
777
+ /**
778
+ * Takes the Effect out of what a body yielded, claiming its marker, or refuses what is not a step.
779
+ * @param {any} value
780
+ * @returns {Effect}
781
+ */
782
+ const claimStep = (value) => {
783
+ // First, since reading anything from a step wrapper throws.
784
+ const unstarred = stepEffects.get(value);
785
+ if (unstarred) {
786
+ throw malformed(
787
+ `A generator flow yielded step(${describeStep(unstarred)}) without the star; write yield* step(...).`,
788
+ undefined
789
+ );
790
+ }
791
+ if (stepMarkers.has(value)) {
792
+ claimedMarkers.add(value);
793
+ return value.effect;
794
+ }
795
+ if (isEffect(value)) {
796
+ throw malformed(`A generator flow yielded ${describeArgument(value)}; write yield* step(effect).`, value);
797
+ }
798
+ if (isThenable(value)) {
799
+ throw malformed(
800
+ 'A generator flow yielded a Promise; put the call in a Command and yield* step(command).',
801
+ value
802
+ );
803
+ }
804
+ throw malformed(`A generator flow yielded ${describeValue(value)}; it can only yield* step(effect).`, value);
805
+ };
806
+
807
+ /**
808
+ * What a body's `return` means: an Effect runs as it is, a plain value is a `Success`.
809
+ * @param {any} value
810
+ * @returns {Effect}
811
+ */
812
+ const returnedEffect = (value) => {
813
+ if (stepEffects.has(value)) {
814
+ throw malformed(
815
+ 'A generator flow returned step(...); write return yield* step(...), or return the Effect itself.',
816
+ undefined
817
+ );
818
+ }
819
+ if (isEffect(value)) return value;
820
+ if (isThenable(value)) {
821
+ throw malformed(
822
+ 'A generator flow returned a Promise; put the call in a Command and return yield* step(command).',
823
+ value
824
+ );
825
+ }
826
+ return Success(value);
827
+ };
828
+
829
+ /**
830
+ * Runs a body again from the top, feeding it the answers in `trail`, and checks it asks for the same steps. A step it
831
+ * builds again but does not run, since its answer is known, is stopped as a skipped one is (`stopSkipped`), so a
832
+ * generator flow inside it that has already entered its `try` runs its `finally`.
833
+ * @param {GenFlow} flow
834
+ * @param {Trail} trail
835
+ * @param {string} signature - The step the body has to be waiting at once the answers are fed
836
+ * @returns {BodyRun} The new run, waiting at that step
837
+ */
838
+ const rerun = (flow, trail, signature) => {
839
+ const run = startRun(flow);
840
+ /** @returns {Effect} The step the body asked for */
841
+ const expect = (/** @type {IteratorResult<any, any>} */ result, /** @type {string} */ wanted) => {
842
+ const effect = result.done ? undefined : claimStep(result.value);
843
+ const asked = effect && describeStep(effect);
844
+ if (!effect || asked !== wanted) {
845
+ const did = asked === undefined ? 'returned' : `asked for ${asked}`;
846
+ throw malformed(
847
+ `A generator flow ${did} where its earlier run asked for ${wanted}. Something in its ` +
848
+ 'body changes between runs, such as Date.now(), Math.random(), or a variable outside the body; ' +
849
+ "make it a Command, or pass it in the flow's input.",
850
+ undefined
851
+ );
852
+ }
853
+ if (effect.type !== 'Success') stopSkipped(effect, Success(undefined), undefined);
854
+ return effect;
855
+ };
856
+ /** @type {{ signature: string, answer: any }[]} */
857
+ const answers = [];
858
+ for (let cell = trail; cell; cell = cell.earlier) {
859
+ if (cell === droppedAnswers) {
860
+ throw malformed(
861
+ `A step of a generator flow was answered again after a run had gone past it, at ${signature}, and ` +
862
+ 'that run keeps no earlier answers to run the body again with. Walk a flow before running it, ' +
863
+ 'not a step a run handed a hook.',
864
+ undefined
865
+ );
866
+ }
867
+ answers.unshift(cell);
868
+ }
869
+ let result = resumeBody(run, (g) => g.next());
870
+ for (const { signature: asked, answer } of answers) {
871
+ const effect = expect(result, asked);
872
+ run.position++;
873
+ // A pure step's value is the one this run just computed, never an object the earlier run went on to change.
874
+ const value = effect.type === 'Success' ? effect.value : answer;
875
+ result = resumeBody(run, (g) => g.next(value));
876
+ }
877
+ expect(result, signature);
878
+ return run;
879
+ };
880
+
881
+ /**
882
+ * The run waiting at `position`: the live one if it is there, else a new one the earlier answers bring there, as
883
+ * when a test walks a second branch.
884
+ * @param {GenFlow} flow
885
+ * @param {BodyRun} run
886
+ * @param {number} position
887
+ * @param {Trail} trail
888
+ * @param {string} signature
889
+ * @returns {BodyRun}
890
+ */
891
+ const runAt = (flow, run, position, trail, signature) =>
892
+ run.state === 'suspended' && run.position === position ? run : rerun(flow, trail, signature);
893
+
894
+ /**
895
+ * Resumes a body and turns what it does next into an Effect: the step it yields, chained back into the body, or what
896
+ * it returns.
897
+ * @param {GenFlow} flow
898
+ * @param {BodyRun} run
899
+ * @param {Trail} trail - The answers before the position the body resumes at
900
+ * @param {Resume} resume
901
+ * @returns {Effect}
902
+ */
903
+ const advance = (flow, run, trail, resume) => {
904
+ for (;;) {
905
+ const result = resumeBody(run, resume);
906
+ if (result.done) return returnedEffect(result.value);
907
+ const effect = claimStep(result.value);
908
+ const position = run.position;
909
+ const signature = describeStep(effect);
910
+ const before = trail;
911
+ if (effect.type === 'Success') {
912
+ // Fed straight back rather than through `chain`, so a long run of pure steps does not grow the stack.
913
+ trail = { signature, answer: effect.value, earlier: before };
914
+ run.position = position + 1;
915
+ resume = (g) => g.next(effect.value);
916
+ continue;
917
+ }
918
+ return chain(
919
+ effect,
920
+ (answer) => continueBody(flow, run, position, before, signature, answer),
921
+ undefined,
922
+ (node) => stopBody(flow, run, position, before, signature, node)
923
+ );
924
+ }
925
+ };
926
+
927
+ /**
928
+ * Hands a step's answer to the body waiting at it.
929
+ * @param {GenFlow} flow
930
+ * @param {BodyRun} run
931
+ * @param {number} position
932
+ * @param {Trail} trail
933
+ * @param {string} signature
934
+ * @param {any} answer
935
+ * @returns {Effect}
936
+ */
937
+ const continueBody = (flow, run, position, trail, signature, answer) => {
938
+ const current = runAt(flow, run, position, trail, signature);
939
+ current.position = position + 1;
940
+ const after = current.owned ? droppedAnswers : { signature, answer, earlier: trail };
941
+ return advance(flow, current, after, (g) => g.next(answer));
942
+ };
943
+
944
+ /**
945
+ * Throws a stop into the body waiting at a step that stopped the flow, so its `catch` and `finally` blocks run. Only
946
+ * the stop coming back out is accepted: a body that swallows it, throws something else, or yields while stopping
947
+ * rejects the run.
948
+ * @param {GenFlow} flow
949
+ * @param {BodyRun} run
950
+ * @param {number} position
951
+ * @param {Trail} trail
952
+ * @param {string} signature
953
+ * @param {Effect | undefined} node - The node that stopped the flow; undefined for a Failure
954
+ */
955
+ const stopBody = (flow, run, position, trail, signature, node) => {
956
+ const current = runAt(flow, run, position, trail, signature);
957
+ current.state = 'done';
958
+ const where = node && isPending(node) ? describeStep(node) : 'a Failure';
959
+ const advice = "return the error as data inside the Command's function, or use Retry's onExhausted.";
960
+ // Carries neither the error nor what kind of stop it was, so user code cannot branch on either.
961
+ const stop = Object.assign(
962
+ new Error(`The flow stopped ${node ? 'after' : 'at'} ${where}. Nothing can be caught here: ${advice}`),
963
+ {
964
+ name: 'FlowStopped'
965
+ }
966
+ );
967
+ try {
968
+ current.generator.throw(stop);
969
+ } catch (e) {
970
+ if (e === stop) return;
971
+ if (hasMark(e, harnessError)) throw e;
972
+ const error = malformed(
973
+ `A catch in a generator flow threw another error after ${where} stopped it; a catch around yield* ` +
974
+ 'cannot recover, so let the stop pass.',
975
+ undefined
976
+ );
977
+ throw Object.defineProperty(error, 'cause', { value: e, writable: true, configurable: true });
978
+ }
979
+ throw malformed(
980
+ `A generator flow kept going after ${where} stopped it: a catch swallowed the stop, or a finally ran a ` +
981
+ `step. Nothing can be caught here: ${advice}`,
982
+ undefined
983
+ );
984
+ };
985
+
986
+ /**
987
+ * Builds a flow function from a generator function, which writes `yield* step(effect)` where `async` code writes
988
+ * `await`. Calling the flow function runs the body up to its first step, and the flow it returns is made of ordinary
989
+ * nodes, so it records, replays and tests as a pipeline does. The first execution of the flow takes over that run of
990
+ * the body, and every later one builds the flow again from its input, as calling an `async` function again does.
991
+ *
992
+ * @param {(input: any) => Generator<any, any, any>} body
993
+ * @returns {(input: any) => Effect}
994
+ */
995
+ const effectGen = (body) => {
996
+ if (typeof body !== 'function') {
997
+ throw malformed(
998
+ `effectGen expects a generator function, function* (input) { ... }, got ${describeArgument(body)}.`,
999
+ body
1000
+ );
1001
+ }
1002
+ if (Object.getPrototypeOf(body) === asyncGeneratorFunction) {
1003
+ throw malformed(
1004
+ 'effectGen expects function*, not async function*: get each result with yield* step(command) instead of await.',
1005
+ body
1006
+ );
1007
+ }
1008
+ /** @param {any} input */
1009
+ const build = (input) => {
1010
+ const flow = { body, input };
1011
+ const run = startRun(flow);
1012
+ // Keyed on a copy, as `effectPipe`'s root is, since a body can return a shared object.
1013
+ const root = copyRoot(advance(flow, run, null, (g) => g.next()));
1014
+ flowInputs.set(root, input);
1015
+ const built = { run, position: run.position, state: run.state, claimed: false };
1016
+ restarts.set(root, { built, rebuild: () => build(input) });
1017
+ return root;
1018
+ };
1019
+ return build;
1020
+ };
1021
+
536
1022
  // #endregion
537
1023
  // #region Configuration
538
1024
 
539
1025
  /**
540
1026
  * Wraps one Command, or one Parallel, whose `op` runs the branches and returns the decision, so a hook must call it.
541
1027
  * Only a replay passes `op` an argument, the recorded decision. `path` is the step's position in the Effect tree.
542
- * @typedef {(name: string, type: string, op: function, path?: string) => Promise<any>} StepRunner
1028
+ * @typedef {(name: string, type: 'Command' | 'Parallel', op: function, path?: string) => Promise<any>} StepRunner
543
1029
  */
544
1030
  /** @type StepRunner */
545
1031
  const defaultStepRunner = async (name, type, op) => await op();
@@ -708,8 +1194,10 @@ const cancelledBranch = () =>
708
1194
  /**
709
1195
  * Which branch, if any, cancelled a Parallel, and how many branches it had; `branch: null` means an enclosing Parallel
710
1196
  * did. Timing decides it, so it is recorded as the Parallel's own step for a replay to hand back, and the count lets a
711
- * replay see a branch added or removed.
712
- * @typedef {{ cancelled: false, branches: number } | { cancelled: true, branch: number | null, branches: number }} ParallelDecision
1197
+ * replay see a branch added or removed. `threw` says a branch threw, which marked the scope, so a replay stops no
1198
+ * generator flow there, as production did not once the throw came.
1199
+ * @typedef {{ cancelled: false, branches: number }
1200
+ * | { cancelled: true, branch: number | null, branches: number, threw?: true }} ParallelDecision
713
1201
  */
714
1202
 
715
1203
  /**
@@ -747,8 +1235,10 @@ const asDecision = (value, branches) => {
747
1235
  if (value.cancelled === false) return { cancelled: false, branches };
748
1236
  if (value.cancelled !== true) return undefined;
749
1237
  const { branch } = value;
750
- if (branch === null) return { cancelled: true, branch: null, branches };
751
- return isBranchIndex(branch, branches) ? { cancelled: true, branch, branches } : undefined;
1238
+ if (branch !== null && !isBranchIndex(branch, branches)) return undefined;
1239
+ return value.threw === true
1240
+ ? { cancelled: true, branch, branches, threw: true }
1241
+ : { cancelled: true, branch, branches };
752
1242
  };
753
1243
 
754
1244
  /**
@@ -768,6 +1258,13 @@ const replayCutError = (path) =>
768
1258
  { value: true }
769
1259
  );
770
1260
 
1261
+ /**
1262
+ * Cancellation scopes a branch's throw aborted, and the scopes linked under them. `stopAt` stops no generator flow
1263
+ * under one, since the throw rejects the run.
1264
+ * @type {WeakSet<AbortSignal>}
1265
+ */
1266
+ const thrownScopes = new WeakSet();
1267
+
771
1268
  /**
772
1269
  * A cancellation scope for one Parallel, linked to the enclosing one so cancellation nests.
773
1270
  * @param {AbortSignal} [signal] - The enclosing Parallel's signal, if any
@@ -775,9 +1272,13 @@ const replayCutError = (path) =>
775
1272
  */
776
1273
  const linkedScope = (signal) => {
777
1274
  const scope = new AbortController();
778
- const relay = () => scope.abort();
1275
+ if (signal && thrownScopes.has(signal)) thrownScopes.add(scope.signal);
1276
+ const relay = () => {
1277
+ if (signal && thrownScopes.has(signal)) thrownScopes.add(scope.signal);
1278
+ scope.abort();
1279
+ };
779
1280
  if (signal) {
780
- if (signal.aborted) scope.abort();
1281
+ if (signal.aborted) relay();
781
1282
  else signal.addEventListener('abort', relay, { once: true });
782
1283
  }
783
1284
  const unlink = () => {
@@ -882,6 +1383,64 @@ const interpret = async (effect, context = {}, callConfig = {}, fastRetry = fals
882
1383
  return onRun(effect, op, context?.flowName || '', flowInputs.get(effect));
883
1384
  };
884
1385
 
1386
+ /**
1387
+ * Tells the generator flows waiting on `node` that it stopped the flow, innermost first. The other end of the
1388
+ * protocol is `chain`, which keeps the listeners on each node it copies. Returns `result`, for a `return` to pass on.
1389
+ * Under a scope a throw cancelled it tells none, since the run is about to reject and a `catch` could hide why.
1390
+ *
1391
+ * @param {Effect} node
1392
+ * @param {SuccessState | FailureState | IoFaultState} result
1393
+ * @param {AbortSignal | undefined} signal
1394
+ * @returns {SuccessState | FailureState | IoFaultState}
1395
+ */
1396
+ const stopAt = (node, result, signal) => {
1397
+ if (signal && thrownScopes.has(signal)) return result;
1398
+ stopListeners.get(node)?.forEach((listener) => listener(node));
1399
+ return result;
1400
+ };
1401
+
1402
+ /**
1403
+ * `stopAt` for a node a cancellation skipped, which also tells the generator flows built inside a skipped `Retry` or
1404
+ * `Parallel`, innermost first, since each body ran up to its first step as it was built.
1405
+ *
1406
+ * @param {Effect} node
1407
+ * @param {SuccessState | FailureState | IoFaultState} result
1408
+ * @param {AbortSignal | undefined} signal
1409
+ * @returns {SuccessState | FailureState | IoFaultState}
1410
+ */
1411
+ const stopSkipped = (node, result, signal) => {
1412
+ // A generator flow another execution took over, or a test walked, is not this one's to stop.
1413
+ const restart = restarts.get(node);
1414
+ if (restart && !takeBuiltRun(restart.built)) return result;
1415
+ if (node.type === 'Retry') stopSkipped(node.effect, result, signal);
1416
+ if (node.type === 'Parallel') node.effects.forEach((branch) => stopSkipped(branch, result, signal));
1417
+ return stopAt(node, result, signal);
1418
+ };
1419
+
1420
+ /**
1421
+ * Takes over the run a generator flow made as it was built, if nothing has: no execution took it over, and no test
1422
+ * walked it on from where it was built.
1423
+ * @param {BuiltRun} built
1424
+ * @returns {boolean} Whether the run was free
1425
+ */
1426
+ const takeBuiltRun = (built) => {
1427
+ const free = !built.claimed && built.run.position === built.position && built.run.state === built.state;
1428
+ built.claimed = true;
1429
+ if (free) built.run.owned = true;
1430
+ return free;
1431
+ };
1432
+
1433
+ /**
1434
+ * The node to run in place of `eff`: `eff` itself, unless it is a generator flow's root whose built run is taken, when
1435
+ * it is the flow built again from its input. The other end is `restarts`, which `effectGen` fills.
1436
+ * @param {Effect} eff
1437
+ * @returns {Effect}
1438
+ */
1439
+ const ownRun = (eff) => {
1440
+ const restart = restarts.get(eff);
1441
+ return restart && !takeBuiltRun(restart.built) ? restart.rebuild() : eff;
1442
+ };
1443
+
885
1444
  /**
886
1445
  * Walks a subtree until it reaches a Success, a Failure or an I/O fault. Every node but `Ask` runs in its
887
1446
  * own function, which returns a Success carrying the value for the node's `next`, or what stops the subtree.
@@ -899,7 +1458,12 @@ const execute = async (runtime, eff, signal, path = '') => {
899
1458
  let from;
900
1459
  while (isPending(eff)) {
901
1460
  // A Command already in flight cannot be stopped, but the next one never starts.
902
- if (signal?.aborted) return cancelledBranch();
1461
+ if (signal?.aborted) return stopSkipped(eff, cancelledBranch(), signal);
1462
+ const own = ownRun(eff);
1463
+ if (own !== eff) {
1464
+ eff = own;
1465
+ continue;
1466
+ }
903
1467
  if (eff.type === 'Ask') {
904
1468
  from = eff;
905
1469
  eff = eff.next(runtime.context);
@@ -912,7 +1476,7 @@ const execute = async (runtime, eff, signal, path = '') => {
912
1476
  : eff.type === 'Parallel'
913
1477
  ? await runParallel(runtime, eff, signal, stepPath)
914
1478
  : await runCommand(runtime, eff, signal, stepPath);
915
- if (outcome.type !== 'Success') return outcome;
1479
+ if (outcome.type !== 'Success') return stopAt(eff, outcome, signal);
916
1480
  // Outside every catch: `next` and the pure steps it reaches are code, not I/O, so a throw there
917
1481
  // rejects the run.
918
1482
  from = eff;
@@ -938,9 +1502,9 @@ const runRetry = async (runtime, retry, signal, stepPath) => {
938
1502
  ...defaultRetryOptions,
939
1503
  ...Object.fromEntries(given)
940
1504
  });
941
- const { attempts, onExhausted } = opts;
1505
+ const { retries, onExhausted } = opts;
942
1506
  let lastError;
943
- for (let attempt = 0; attempt <= attempts; attempt++) {
1507
+ for (let attempt = 0; attempt <= retries; attempt++) {
944
1508
  // `fastRetry` waits for no time rather than skipping the wait, so branches replayed by timing still
945
1509
  // interleave.
946
1510
  if (attempt > 0) {
@@ -953,7 +1517,7 @@ const runRetry = async (runtime, retry, signal, stepPath) => {
953
1517
  if (result.type !== 'IoFault') return result;
954
1518
  lastError = result.error;
955
1519
  }
956
- const exhausted = { retryExhausted: true, lastError, attempts };
1520
+ const exhausted = { retryExhausted: true, lastError, retries };
957
1521
  // A fault too, so an enclosing Retry retries this one.
958
1522
  if (typeof onExhausted !== 'function') return IoFault(exhausted);
959
1523
  if (signal?.aborted) return cancelledBranch();
@@ -985,8 +1549,12 @@ const runParallel = async (runtime, parallel, signal, stepPath) => {
985
1549
  try {
986
1550
  await runtime.onStep('Parallel', 'Parallel', op, branchPath);
987
1551
  } catch (e) {
988
- // A cut from `fromTrace`: production had already stopped the branch this Parallel is in.
989
- if (hasMark(e, replayCut)) return cancelledBranch();
1552
+ // A cut from `fromTrace`: production had already stopped the branch this Parallel is in, so it skipped this
1553
+ // Parallel, and `stopSkipped` tells the flows built in its branches, as production's cancellation did.
1554
+ if (hasMark(e, replayCut)) {
1555
+ parallel.effects.forEach((branch) => stopSkipped(branch, cancelledBranch(), signal));
1556
+ return cancelledBranch();
1557
+ }
990
1558
  throw e;
991
1559
  }
992
1560
  if (!branchRun) {
@@ -1057,7 +1625,8 @@ const runBranches = async (runtime, effects, options, signal, branchPath, record
1057
1625
  limit,
1058
1626
  signal,
1059
1627
  branchPath,
1060
- reproducing ? () => false : (threw, result) => threw || (!settled && result.type !== 'Success')
1628
+ reproducing ? () => false : (threw, result) => threw || (!settled && result.type !== 'Success'),
1629
+ reproducing && forced.threw === true
1061
1630
  );
1062
1631
  if (reproducing) {
1063
1632
  // A branch that threw has no result to check, and its throw is what is rethrown.
@@ -1079,6 +1648,8 @@ const runBranches = async (runtime, effects, options, signal, branchPath, record
1079
1648
  : cancelled
1080
1649
  ? { cancelled: true, branch: null, branches }
1081
1650
  : { cancelled: false, branches };
1651
+ // A throw from any branch marked the scope, whichever branch cancelled the others first.
1652
+ if (thrown !== undefined && decision.cancelled) decision.threw = true;
1082
1653
  return { results, decision, thrown };
1083
1654
  };
1084
1655
 
@@ -1093,6 +1664,7 @@ const runBranches = async (runtime, effects, options, signal, branchPath, record
1093
1664
  * @param {AbortSignal | undefined} signal
1094
1665
  * @param {string} branchPath
1095
1666
  * @param {(threw: boolean, result: SuccessState | FailureState | IoFaultState) => boolean} cancelsOthers
1667
+ * @param {boolean} [recordedThrow] - A replay's recorded decision says a branch threw, so no generator flow stops
1096
1668
  * @returns {Promise<{
1097
1669
  * results: (SuccessState | FailureState | IoFaultState)[],
1098
1670
  * thrown: { error: unknown } | undefined,
@@ -1100,8 +1672,9 @@ const runBranches = async (runtime, effects, options, signal, branchPath, record
1100
1672
  * cancelled: boolean
1101
1673
  * }>}
1102
1674
  */
1103
- const settleBranches = async (runtime, effects, limit, signal, branchPath, cancelsOthers) => {
1675
+ const settleBranches = async (runtime, effects, limit, signal, branchPath, cancelsOthers, recordedThrow = false) => {
1104
1676
  const { scope, unlink } = linkedScope(signal);
1677
+ if (recordedThrow) thrownScopes.add(scope.signal);
1105
1678
  /** @type {(SuccessState | FailureState | IoFaultState)[]} */
1106
1679
  const results = new Array(effects.length);
1107
1680
  // Held rather than rethrown, so every branch still settles and no `limit` worker stops early.
@@ -1118,6 +1691,7 @@ const settleBranches = async (runtime, effects, limit, signal, branchPath, cance
1118
1691
  // Read-then-abort is atomic here, so exactly one branch is the trigger.
1119
1692
  if (cancelsOthers(thrown[i] !== undefined, results[i])) {
1120
1693
  if (!scope.signal.aborted) triggered[i] = true;
1694
+ if (thrown[i] !== undefined) thrownScopes.add(scope.signal);
1121
1695
  scope.abort();
1122
1696
  }
1123
1697
  };
@@ -1253,7 +1827,7 @@ const runEffect = (effect, context, callConfig) => interpret(effect, context, ca
1253
1827
  * position in completion order, which is not stable for a flow with a `Parallel`. A step whose `type` is 'Parallel'
1254
1828
  * asks for the recorded decision and does not advance `index`; an answer that holds no decision replays that
1255
1829
  * Parallel by timing.
1256
- * @typedef {{ index: number, name: string, type: string, path?: string }} ReplayStep
1830
+ * @typedef {{ index: number, name: string, type: 'Command' | 'Parallel', path?: string }} ReplayStep
1257
1831
  */
1258
1832
 
1259
1833
  /**
@@ -1628,24 +2202,26 @@ const packTrace = (settings, meta, entries, dropped) => {
1628
2202
  };
1629
2203
 
1630
2204
  /**
1631
- * Runs a flow for real while recording it, and returns the outcome and a replayable trace. Convenient in tests and
1632
- * scripts; to record an application, give each run its own recorder, as examples/recording-example.js does.
2205
+ * Runs a flow for real while recording it, and returns the outcome and a replayable trace. The trace holds the input
2206
+ * of a flow `effectPipe` or `effectGen` built, read from `flowInputs`. Convenient in tests and scripts; to record an
2207
+ * application, give each run its own recorder, as examples/recording-example.js does.
1633
2208
  *
1634
- * @param {(input: any) => Effect} flowFn - Builds the Effect tree from its input
1635
- * @param {any} initialInput - The value the flow is called with; stored so a replay can rebuild it
2209
+ * @param {Effect} effect - The flow, called with its input
1636
2210
  * @param {RecorderOptions & { context?: any, version?: string }} [options] - Recorder options, plus the
1637
2211
  * `context` given to `runEffect` (stored so `Ask` can be replayed) and a build id
1638
2212
  * @returns {Promise<{ result: SuccessState | FailureState, trace: TraceLog }>}
1639
2213
  */
1640
- const recordEffect = async (flowFn, initialInput, options = {}) => {
2214
+ const recordEffect = async (effect, options = {}) => {
2215
+ // Before the options, so a call written the old way, with the flow function and its input, is named as that.
2216
+ asEffect(effect, 'The flow');
1641
2217
  checkOptions(options, 'recordEffect', { context: null, version: null, ...recorderOptionRules });
1642
- checkFlowFn(flowFn, 'recordEffect');
1643
2218
  const { context = {}, version, ...recorderOptions } = options ?? {};
1644
2219
  const rec = recorder(recorderOptions);
1645
2220
  // Before the run, so a Command that writes to the input or the context cannot rewrite what the trace received.
2221
+ const initialInput = flowInputs.get(effect);
1646
2222
  const head = rec.toTrace({ initialInput, flowName: context?.flowName, context, version });
1647
2223
  // Merged over the global wiring, so recording inside an instrumented application keeps its spans.
1648
- const result = await runEffect(flowFn(initialInput), context, { onStep: rec.onStep });
2224
+ const result = await runEffect(effect, context, { onStep: rec.onStep });
1649
2225
  const { dropped, trace } = rec.toTrace();
1650
2226
  return { result, trace: { ...head, dropped, trace } };
1651
2227
  };
@@ -2224,8 +2800,7 @@ const replayHook = ({ resolve, missing, onResolved, onMissing, droppedEntries })
2224
2800
  * @param {(...args: any[]) => void} [options.log] - Defaults to `console.log`
2225
2801
  * @param {any} [options.context] - Overrides the context stored on the trace
2226
2802
  * @param {string} [options.version] - Current build id; warns when it differs from the trace's
2227
- * @returns {Promise<SuccessState | FailureState>} The flow's outcome. For the unreached entries as data, use
2228
- * `replayEffect`.
2803
+ * @returns {Promise<Replay>} `{ result, unreached }`, as `replayEffect` returns for a trace
2229
2804
  */
2230
2805
  const timeTravel = async (flowFn, traceLog, options = {}) => {
2231
2806
  checkOptions(options, 'timeTravel', { log: [isFunction, 'a function'], context: null, version: null });
@@ -2262,7 +2837,7 @@ const timeTravel = async (flowFn, traceLog, options = {}) => {
2262
2837
  log(result.type === 'Failure' ? `Error: ${formatForLog(result.error)}` : `Result: ${formatForLog(result.value)}`);
2263
2838
  const warning = unreachedWarning(result, unreached);
2264
2839
  if (warning !== undefined) log(warning);
2265
- return result;
2840
+ return { result, unreached };
2266
2841
  };
2267
2842
 
2268
2843
  /**
@@ -2280,8 +2855,8 @@ const traceWarnings = (traceLog, trace, version) => {
2280
2855
  }
2281
2856
  if (traceLog.initialInput === undefined) {
2282
2857
  warnings.push(
2283
- 'Warning: the trace holds no initial input, so the flow is rebuilt from undefined. A recorder ' +
2284
- 'installed as a hook finds the input only on a flow built with effectPipe.'
2858
+ 'Warning: the trace holds no initial input, so the flow is rebuilt from undefined. A trace holds ' +
2859
+ 'the input only of a flow built with effectPipe or effectGen.'
2285
2860
  );
2286
2861
  }
2287
2862
  const anonymous = trace.filter((e) => e.command === 'anonymous').length;
@@ -2353,5 +2928,7 @@ export {
2353
2928
  recordEffect,
2354
2929
  replayEffect,
2355
2930
  timeTravel,
2356
- commandName
2931
+ commandName,
2932
+ effectGen,
2933
+ step
2357
2934
  };