pure-effect 0.18.0 → 0.19.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/index.js CHANGED
@@ -4,12 +4,14 @@
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 `chain` keeps for generator flows, and the `flowInputs` it records for `onRun`;
9
+ // 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, `execute` and a function
14
+ // for each kind of node, which each take it; and `runEffect`.
13
15
  // 5. Recording and replay: the trace format and replay errors, copying values and errors into a trace,
14
16
  // `recorder` and `recordEffect`, then `fromTrace`, `replayEffect` and `timeTravel`, each followed by the
15
17
  // functions it hands its parts to.
@@ -137,10 +139,14 @@ const commandName = (eff) => {
137
139
 
138
140
  /**
139
141
  * Reads the context object from the current `runEffect` call.
140
- * @param {(context: any) => Effect} next - Receives the context and returns the next Effect
142
+ * @param {...((context: any) => Effect)} args - `next`, which receives the context and returns the next Effect. With
143
+ * no argument, the Ask succeeds with the context itself, as `yield* step(Ask())` reads it.
141
144
  * @returns {AskState}
142
145
  */
143
- const Ask = (next) => {
146
+ const Ask = (...args) => {
147
+ // Only a call with no argument: an explicit `undefined` is usually a misspelt name.
148
+ if (args.length === 0) return { type: 'Ask', next: (/** @type {any} */ context) => Success(context) };
149
+ const [next] = args;
144
150
  if (typeof next !== 'function') {
145
151
  throw malformed(`Ask expects a function that receives the context, got ${describeArgument(next)}.`, next);
146
152
  }
@@ -486,16 +492,50 @@ const asEffect = (value, source) => {
486
492
  * @param {Effect} effect - The current Effect object
487
493
  * @param {(value: any) => Effect} fn - The next function to run if the current effect is a Success
488
494
  * @param {Effect} [from] - The node whose `next` returned `effect`, which an error names
495
+ * @param {StopListener} [onStop] - Set by `effectGen`: told when a Failure passes `fn`, and kept on each copy for
496
+ * `execute`'s `stopAt`, which calls it when the copy itself stops the flow
489
497
  * @returns {Effect} The composed Effect
490
498
  */
491
- const chain = (effect, fn, from) => {
499
+ const chain = (effect, fn, from, onStop) => {
492
500
  // Before reading `.type`, so a missing return is named rather than thrown as a bare TypeError.
493
501
  if (!isEffect(effect)) throw effectTypeError(effect, from ? nextOf(from) : 'A continuation');
494
502
  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);
503
+ if (effect.type === 'Failure') {
504
+ onStop?.(from);
505
+ return effect;
506
+ }
507
+ const next = (/** @type {any} */ value) => chain(effect.next(value), fn, effect, onStop);
497
508
  // 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 };
509
+ const copy = effect.type === 'Command' ? Command(effect.cmd, next, effect.meta) : { ...effect, next };
510
+ const waiting = stopListeners.get(effect);
511
+ if (onStop) stopListeners.set(copy, [...(waiting ?? []), onStop]);
512
+ else if (waiting) stopListeners.set(copy, waiting);
513
+ return copy;
514
+ };
515
+
516
+ /**
517
+ * Told that the flow stopped at `node`, or at a Failure when `node` is undefined, so a generator flow waiting on it
518
+ * runs its `catch` and `finally` blocks.
519
+ * @typedef {(node: Effect | undefined) => void} StopListener
520
+ */
521
+
522
+ /**
523
+ * The generator flows waiting on each node `chain` copied for them, innermost first. `chain` calls one when a Failure
524
+ * passes the continuation it guards, and `execute`'s `stopAt` calls them all when the node itself stops the flow.
525
+ * @type {WeakMap<Effect, StopListener[]>}
526
+ */
527
+ const stopListeners = new WeakMap();
528
+
529
+ /**
530
+ * Copies a flow's root, which `flowInputs` is keyed on, keeping the generator flows waiting on it.
531
+ * @param {Effect} tree
532
+ * @returns {Effect}
533
+ */
534
+ const copyRoot = (tree) => {
535
+ const root = { ...tree };
536
+ const waiting = stopListeners.get(tree);
537
+ if (waiting) stopListeners.set(root, waiting);
538
+ return root;
499
539
  };
500
540
 
501
541
  /**
@@ -527,12 +567,411 @@ const effectPipe = (...fns) => {
527
567
  return (start) => {
528
568
  const tree = fns.reduce((eff, fn) => chain(eff, fn), /** @type {Effect} */ (Success(start)));
529
569
  // Keyed on a copy, since the last step can return an object other flows share, such as a constant Failure.
530
- const root = { ...tree };
570
+ const root = copyRoot(tree);
531
571
  flowInputs.set(root, start);
532
572
  return root;
533
573
  };
534
574
  };
535
575
 
576
+ /**
577
+ * Every marker a step's iterator yielded, so `effectGen` can tell one from a value the body yielded itself.
578
+ * @type {WeakSet<StepMarker>}
579
+ */
580
+ const stepMarkers = new WeakSet();
581
+
582
+ /**
583
+ * The markers `effectGen` received, so a step's iterator can tell `yield*` from a spread or a `for...of`.
584
+ * @type {WeakSet<StepMarker>}
585
+ */
586
+ const claimedMarkers = new WeakSet();
587
+
588
+ /**
589
+ * The Effect inside each step wrapper, so `effectGen` can name a step yielded or returned without `yield*`.
590
+ * @type {WeakMap<object, Effect>}
591
+ */
592
+ const stepEffects = new WeakMap();
593
+
594
+ /** @typedef {Readonly<{ effect: Effect }>} StepMarker */
595
+
596
+ /**
597
+ * @param {any} value
598
+ * @returns {boolean}
599
+ */
600
+ const isThenable = (value) => isObject(value) && typeof value.then === 'function';
601
+
602
+ /**
603
+ * Names a step for a message, and for comparing one run of a body with the next.
604
+ * @param {Effect} effect
605
+ * @returns {string}
606
+ */
607
+ const describeStep = (effect) =>
608
+ effect.type === 'Command'
609
+ ? `Command '${commandName(effect)}'`
610
+ : `${effect.type === 'Ask' ? 'an' : 'a'} ${effect.type}`;
611
+
612
+ /**
613
+ * Wraps an Effect for `yield*` inside `effectGen`, which gives back the Effect's value. The wrapper is a proxy that throws
614
+ * when it is used as a value, and its iterator throws unless `effectGen` claimed what it yielded.
615
+ *
616
+ * @param {Effect} effect
617
+ * @returns {any}
618
+ */
619
+ const step = (effect) => {
620
+ if (!isEffect(effect)) {
621
+ throw malformed(
622
+ isThenable(effect)
623
+ ? 'step expects an Effect, got a Promise: put the call in a Command, as in ' +
624
+ 'yield* step(Command(() => db.findUser(email))).'
625
+ : `step expects an Effect, got ${describeArgument(effect)}.`,
626
+ effect
627
+ );
628
+ }
629
+ /** @returns {Generator<StepMarker, any, any>} */
630
+ const iterate = function* () {
631
+ const marker = Object.freeze({ effect });
632
+ stepMarkers.add(marker);
633
+ try {
634
+ return yield marker;
635
+ } finally {
636
+ // Only `effectGen` claims a marker, so an unclaimed one means a spread, `Array.from` or a `for...of`.
637
+ if (!claimedMarkers.has(marker)) {
638
+ throw malformed(
639
+ `step(${describeStep(effect)}) was iterated outside yield*; write yield* step(...).`,
640
+ effect
641
+ );
642
+ }
643
+ }
644
+ };
645
+ const refuse = () => {
646
+ throw malformed(`step(${describeStep(effect)}) was used as a value; write yield* step(...).`, effect);
647
+ };
648
+ const inspectCustom = Symbol.for('nodejs.util.inspect.custom');
649
+ const hint = () => `[step: ${describeStep(effect)}, use with yield*]`;
650
+ // On the target as well, since Node's inspect reads a proxy's target without running its traps.
651
+ const target = Object.freeze(Object.defineProperty({}, inspectCustom, { value: hint }));
652
+ const wrapper = new Proxy(target, {
653
+ get: (_, key) => (key === Symbol.iterator ? iterate : key === inspectCustom ? hint : refuse()),
654
+ has: refuse,
655
+ ownKeys: refuse,
656
+ getOwnPropertyDescriptor: refuse,
657
+ set: refuse,
658
+ defineProperty: refuse,
659
+ deleteProperty: refuse
660
+ });
661
+ stepEffects.set(wrapper, effect);
662
+ return wrapper;
663
+ };
664
+
665
+ /** What an `async function*` inherits from, which `effectGen` refuses before it builds anything. */
666
+ const asyncGeneratorFunction = Object.getPrototypeOf(async function* () {});
667
+
668
+ /**
669
+ * A generator flow called with its input, which a re-run starts its body from again.
670
+ * @typedef {{ body: (input: any) => any, input: any }} GenFlow
671
+ */
672
+
673
+ /**
674
+ * One run of a body: its generator, the position of the step it is waiting at, and whether it can still resume.
675
+ * @typedef {{ generator: Generator<any, any, any>, position: number, state: 'suspended' | 'done' }} BodyRun
676
+ */
677
+
678
+ /**
679
+ * The answers a body was given before a position, newest first, each with the step it answered, so a re-run can feed
680
+ * them again and check the body asks for the same steps. Shared between continuations, never copied.
681
+ * @typedef {{ signature: string, answer: any, earlier: Trail } | null} Trail
682
+ */
683
+
684
+ /**
685
+ * @typedef {(generator: Generator<any, any, any>) => IteratorResult<any, any>} Resume
686
+ */
687
+
688
+ /**
689
+ * Starts a body. A function that returns anything but a synchronous generator is refused here, which covers a bundled
690
+ * `async function*` the prototype check in `effectGen` missed.
691
+ * @param {GenFlow} flow
692
+ * @returns {BodyRun}
693
+ */
694
+ const startRun = (flow) => {
695
+ const generator = flow.body(flow.input);
696
+ const isGenerator =
697
+ isObject(generator) &&
698
+ ['next', 'throw', 'return'].every((name) => typeof generator[name] === 'function') &&
699
+ generator[Symbol.toStringTag] !== 'AsyncGenerator';
700
+ if (!isGenerator) {
701
+ throw malformed(
702
+ 'effectGen expects a generator function, function* (input) { ... }; ' +
703
+ `its function returned ${describeValue(generator)}.`,
704
+ generator
705
+ );
706
+ }
707
+ return { generator, position: 0, state: 'suspended' };
708
+ };
709
+
710
+ /**
711
+ * Adds a hint to V8's error for `yield*` on a value that is not iterable, keeping the original as `cause`.
712
+ * @param {unknown} e
713
+ * @returns {unknown}
714
+ */
715
+ const explainBodyThrow = (e) => {
716
+ // V8's two wordings; the second is not unique to yield*, so the hint is conditional.
717
+ const notIterable = [
718
+ /^yield\* .* is not iterable$/,
719
+ /^object is not iterable \(cannot read property Symbol\(Symbol\.iterator\)\)$/
720
+ ];
721
+ if (!(e instanceof TypeError) || hasMark(e, harnessError) || !notIterable.some((p) => p.test(e.message))) {
722
+ return e;
723
+ }
724
+ const error = malformed(
725
+ `A generator flow threw "${e.message}". If it came from yield* on an Effect, write yield* step(effect).`,
726
+ undefined
727
+ );
728
+ return Object.defineProperty(error, 'cause', { value: e, writable: true, configurable: true });
729
+ };
730
+
731
+ /**
732
+ * @param {BodyRun} run
733
+ * @param {Resume} resume
734
+ * @returns {IteratorResult<any, any>}
735
+ */
736
+ const resumeBody = (run, resume) => {
737
+ try {
738
+ const result = resume(run.generator);
739
+ if (result.done) run.state = 'done';
740
+ return result;
741
+ } catch (e) {
742
+ run.state = 'done';
743
+ throw explainBodyThrow(e);
744
+ }
745
+ };
746
+
747
+ /**
748
+ * Takes the Effect out of what a body yielded, claiming its marker, or refuses what is not a step.
749
+ * @param {any} value
750
+ * @returns {Effect}
751
+ */
752
+ const claimStep = (value) => {
753
+ // First, since reading anything from a step wrapper throws.
754
+ const unstarred = stepEffects.get(value);
755
+ if (unstarred) {
756
+ throw malformed(
757
+ `A generator flow yielded step(${describeStep(unstarred)}) without the star; write yield* step(...).`,
758
+ undefined
759
+ );
760
+ }
761
+ if (stepMarkers.has(value)) {
762
+ claimedMarkers.add(value);
763
+ return value.effect;
764
+ }
765
+ if (isEffect(value)) {
766
+ throw malformed(`A generator flow yielded ${describeArgument(value)}; write yield* step(effect).`, value);
767
+ }
768
+ if (isThenable(value)) {
769
+ throw malformed(
770
+ 'A generator flow yielded a Promise; put the call in a Command and yield* step(command).',
771
+ value
772
+ );
773
+ }
774
+ throw malformed(`A generator flow yielded ${describeValue(value)}; it can only yield* step(effect).`, value);
775
+ };
776
+
777
+ /**
778
+ * What a body's `return` means: an Effect runs as it is, a plain value is a `Success`.
779
+ * @param {any} value
780
+ * @returns {Effect}
781
+ */
782
+ const returnedEffect = (value) => {
783
+ if (stepEffects.has(value)) {
784
+ throw malformed(
785
+ 'A generator flow returned step(...); write return yield* step(...), or return the Effect itself.',
786
+ undefined
787
+ );
788
+ }
789
+ if (isEffect(value)) return value;
790
+ if (isThenable(value)) {
791
+ throw malformed(
792
+ 'A generator flow returned a Promise; put the call in a Command and return yield* step(command).',
793
+ value
794
+ );
795
+ }
796
+ return Success(value);
797
+ };
798
+
799
+ /**
800
+ * Runs a body again from the top, feeding it the answers in `trail`, and checks it asks for the same steps. A step it
801
+ * builds again but does not run, since its answer is known, is stopped as a skipped one is (`stopSkipped`), so a
802
+ * generator flow inside it that has already entered its `try` runs its `finally`.
803
+ * @param {GenFlow} flow
804
+ * @param {Trail} trail
805
+ * @param {string} signature - The step the body has to be waiting at once the answers are fed
806
+ * @returns {BodyRun} The new run, waiting at that step
807
+ */
808
+ const rerun = (flow, trail, signature) => {
809
+ const run = startRun(flow);
810
+ /** @returns {Effect} The step the body asked for */
811
+ const expect = (/** @type {IteratorResult<any, any>} */ result, /** @type {string} */ wanted) => {
812
+ const effect = result.done ? undefined : claimStep(result.value);
813
+ const asked = effect && describeStep(effect);
814
+ if (!effect || asked !== wanted) {
815
+ const did = asked === undefined ? 'returned' : `asked for ${asked}`;
816
+ throw malformed(
817
+ `A generator flow ${did} where its earlier run asked for ${wanted}. Something in its ` +
818
+ 'body changes between runs, such as Date.now(), Math.random(), or a variable outside the body; ' +
819
+ "make it a Command, or pass it in the flow's input.",
820
+ undefined
821
+ );
822
+ }
823
+ if (effect.type !== 'Success') stopSkipped(effect, Success(undefined), undefined);
824
+ return effect;
825
+ };
826
+ /** @type {{ signature: string, answer: any }[]} */
827
+ const answers = [];
828
+ for (let cell = trail; cell; cell = cell.earlier) answers.unshift(cell);
829
+ let result = resumeBody(run, (g) => g.next());
830
+ for (const { signature: asked, answer } of answers) {
831
+ const effect = expect(result, asked);
832
+ run.position++;
833
+ // A pure step's value is the one this run just computed, never an object the earlier run went on to change.
834
+ const value = effect.type === 'Success' ? effect.value : answer;
835
+ result = resumeBody(run, (g) => g.next(value));
836
+ }
837
+ expect(result, signature);
838
+ return run;
839
+ };
840
+
841
+ /**
842
+ * The run waiting at `position`: the live one if it is there, else a new one the earlier answers bring there, as
843
+ * when a test walks a second branch or a `Retry` repeats the flow.
844
+ * @param {GenFlow} flow
845
+ * @param {BodyRun} run
846
+ * @param {number} position
847
+ * @param {Trail} trail
848
+ * @param {string} signature
849
+ * @returns {BodyRun}
850
+ */
851
+ const runAt = (flow, run, position, trail, signature) =>
852
+ run.state === 'suspended' && run.position === position ? run : rerun(flow, trail, signature);
853
+
854
+ /**
855
+ * Resumes a body and turns what it does next into an Effect: the step it yields, chained back into the body, or what
856
+ * it returns.
857
+ * @param {GenFlow} flow
858
+ * @param {BodyRun} run
859
+ * @param {Trail} trail - The answers before the position the body resumes at
860
+ * @param {Resume} resume
861
+ * @returns {Effect}
862
+ */
863
+ const advance = (flow, run, trail, resume) => {
864
+ for (;;) {
865
+ const result = resumeBody(run, resume);
866
+ if (result.done) return returnedEffect(result.value);
867
+ const effect = claimStep(result.value);
868
+ const position = run.position;
869
+ const signature = describeStep(effect);
870
+ const before = trail;
871
+ if (effect.type === 'Success') {
872
+ // Fed straight back rather than through `chain`, so a long run of pure steps does not grow the stack.
873
+ trail = { signature, answer: effect.value, earlier: before };
874
+ run.position = position + 1;
875
+ resume = (g) => g.next(effect.value);
876
+ continue;
877
+ }
878
+ return chain(
879
+ effect,
880
+ (answer) => continueBody(flow, run, position, before, signature, answer),
881
+ undefined,
882
+ (node) => stopBody(flow, run, position, before, signature, node)
883
+ );
884
+ }
885
+ };
886
+
887
+ /**
888
+ * Hands a step's answer to the body waiting at it.
889
+ * @param {GenFlow} flow
890
+ * @param {BodyRun} run
891
+ * @param {number} position
892
+ * @param {Trail} trail
893
+ * @param {string} signature
894
+ * @param {any} answer
895
+ * @returns {Effect}
896
+ */
897
+ const continueBody = (flow, run, position, trail, signature, answer) => {
898
+ const current = runAt(flow, run, position, trail, signature);
899
+ current.position = position + 1;
900
+ return advance(flow, current, { signature, answer, earlier: trail }, (g) => g.next(answer));
901
+ };
902
+
903
+ /**
904
+ * Throws a stop into the body waiting at a step that stopped the flow, so its `catch` and `finally` blocks run. Only
905
+ * the stop coming back out is accepted: a body that swallows it, throws something else, or yields while stopping
906
+ * rejects the run.
907
+ * @param {GenFlow} flow
908
+ * @param {BodyRun} run
909
+ * @param {number} position
910
+ * @param {Trail} trail
911
+ * @param {string} signature
912
+ * @param {Effect | undefined} node - The node that stopped the flow; undefined for a Failure
913
+ */
914
+ const stopBody = (flow, run, position, trail, signature, node) => {
915
+ const current = runAt(flow, run, position, trail, signature);
916
+ current.state = 'done';
917
+ const where = node && isPending(node) ? describeStep(node) : 'a Failure';
918
+ const advice = "return the error as data inside the Command's function, or use Retry's onExhausted.";
919
+ // Carries neither the error nor what kind of stop it was, so user code cannot branch on either.
920
+ const stop = Object.assign(
921
+ new Error(`The flow stopped ${node ? 'after' : 'at'} ${where}. Nothing can be caught here: ${advice}`),
922
+ {
923
+ name: 'FlowStopped'
924
+ }
925
+ );
926
+ try {
927
+ current.generator.throw(stop);
928
+ } catch (e) {
929
+ if (e === stop) return;
930
+ if (hasMark(e, harnessError)) throw e;
931
+ const error = malformed(
932
+ `A catch in a generator flow threw another error after ${where} stopped it; a catch around yield* ` +
933
+ 'cannot recover, so let the stop pass.',
934
+ undefined
935
+ );
936
+ throw Object.defineProperty(error, 'cause', { value: e, writable: true, configurable: true });
937
+ }
938
+ throw malformed(
939
+ `A generator flow kept going after ${where} stopped it: a catch swallowed the stop, or a finally ran a ` +
940
+ `step. Nothing can be caught here: ${advice}`,
941
+ undefined
942
+ );
943
+ };
944
+
945
+ /**
946
+ * Builds a flow function from a generator function, which writes `yield* step(effect)` where `async` code writes
947
+ * `await`. Calling the flow function runs the body up to its first step, and the flow it returns is made of ordinary
948
+ * nodes, so it records, replays and tests as a pipeline does.
949
+ *
950
+ * @param {(input: any) => Generator<any, any, any>} body
951
+ * @returns {(input: any) => Effect}
952
+ */
953
+ const effectGen = (body) => {
954
+ if (typeof body !== 'function') {
955
+ throw malformed(
956
+ `effectGen expects a generator function, function* (input) { ... }, got ${describeArgument(body)}.`,
957
+ body
958
+ );
959
+ }
960
+ if (Object.getPrototypeOf(body) === asyncGeneratorFunction) {
961
+ throw malformed(
962
+ 'effectGen expects function*, not async function*: get each result with yield* step(command) instead of await.',
963
+ body
964
+ );
965
+ }
966
+ return (input) => {
967
+ const flow = { body, input };
968
+ // Keyed on a copy, as `effectPipe`'s root is, since a body can return a shared object.
969
+ const root = copyRoot(advance(flow, startRun(flow), null, (g) => g.next()));
970
+ flowInputs.set(root, input);
971
+ return root;
972
+ };
973
+ };
974
+
536
975
  // #endregion
537
976
  // #region Configuration
538
977
 
@@ -708,8 +1147,10 @@ const cancelledBranch = () =>
708
1147
  /**
709
1148
  * Which branch, if any, cancelled a Parallel, and how many branches it had; `branch: null` means an enclosing Parallel
710
1149
  * 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
1150
+ * replay see a branch added or removed. `threw` says a branch threw, which marked the scope, so a replay stops no
1151
+ * generator flow there, as production did not once the throw came.
1152
+ * @typedef {{ cancelled: false, branches: number }
1153
+ * | { cancelled: true, branch: number | null, branches: number, threw?: true }} ParallelDecision
713
1154
  */
714
1155
 
715
1156
  /**
@@ -747,8 +1188,10 @@ const asDecision = (value, branches) => {
747
1188
  if (value.cancelled === false) return { cancelled: false, branches };
748
1189
  if (value.cancelled !== true) return undefined;
749
1190
  const { branch } = value;
750
- if (branch === null) return { cancelled: true, branch: null, branches };
751
- return isBranchIndex(branch, branches) ? { cancelled: true, branch, branches } : undefined;
1191
+ if (branch !== null && !isBranchIndex(branch, branches)) return undefined;
1192
+ return value.threw === true
1193
+ ? { cancelled: true, branch, branches, threw: true }
1194
+ : { cancelled: true, branch, branches };
752
1195
  };
753
1196
 
754
1197
  /**
@@ -768,6 +1211,13 @@ const replayCutError = (path) =>
768
1211
  { value: true }
769
1212
  );
770
1213
 
1214
+ /**
1215
+ * Cancellation scopes a branch's throw aborted, and the scopes linked under them. `stopAt` stops no generator flow
1216
+ * under one, since the throw rejects the run.
1217
+ * @type {WeakSet<AbortSignal>}
1218
+ */
1219
+ const thrownScopes = new WeakSet();
1220
+
771
1221
  /**
772
1222
  * A cancellation scope for one Parallel, linked to the enclosing one so cancellation nests.
773
1223
  * @param {AbortSignal} [signal] - The enclosing Parallel's signal, if any
@@ -775,9 +1225,13 @@ const replayCutError = (path) =>
775
1225
  */
776
1226
  const linkedScope = (signal) => {
777
1227
  const scope = new AbortController();
778
- const relay = () => scope.abort();
1228
+ if (signal && thrownScopes.has(signal)) thrownScopes.add(scope.signal);
1229
+ const relay = () => {
1230
+ if (signal && thrownScopes.has(signal)) thrownScopes.add(scope.signal);
1231
+ scope.abort();
1232
+ };
779
1233
  if (signal) {
780
- if (signal.aborted) scope.abort();
1234
+ if (signal.aborted) relay();
781
1235
  else signal.addEventListener('abort', relay, { once: true });
782
1236
  }
783
1237
  const unlink = () => {
@@ -882,6 +1336,37 @@ const interpret = async (effect, context = {}, callConfig = {}, fastRetry = fals
882
1336
  return onRun(effect, op, context?.flowName || '', flowInputs.get(effect));
883
1337
  };
884
1338
 
1339
+ /**
1340
+ * Tells the generator flows waiting on `node` that it stopped the flow, innermost first. The other end of the
1341
+ * protocol is `chain`, which keeps the listeners on each node it copies. Returns `result`, for a `return` to pass on.
1342
+ * Under a scope a throw cancelled it tells none, since the run is about to reject and a `catch` could hide why.
1343
+ *
1344
+ * @param {Effect} node
1345
+ * @param {SuccessState | FailureState | IoFaultState} result
1346
+ * @param {AbortSignal | undefined} signal
1347
+ * @returns {SuccessState | FailureState | IoFaultState}
1348
+ */
1349
+ const stopAt = (node, result, signal) => {
1350
+ if (signal && thrownScopes.has(signal)) return result;
1351
+ stopListeners.get(node)?.forEach((listener) => listener(node));
1352
+ return result;
1353
+ };
1354
+
1355
+ /**
1356
+ * `stopAt` for a node a cancellation skipped, which also tells the generator flows built inside a skipped `Retry` or
1357
+ * `Parallel`, innermost first, since each body ran up to its first step as it was built.
1358
+ *
1359
+ * @param {Effect} node
1360
+ * @param {SuccessState | FailureState | IoFaultState} result
1361
+ * @param {AbortSignal | undefined} signal
1362
+ * @returns {SuccessState | FailureState | IoFaultState}
1363
+ */
1364
+ const stopSkipped = (node, result, signal) => {
1365
+ if (node.type === 'Retry') stopSkipped(node.effect, result, signal);
1366
+ if (node.type === 'Parallel') node.effects.forEach((branch) => stopSkipped(branch, result, signal));
1367
+ return stopAt(node, result, signal);
1368
+ };
1369
+
885
1370
  /**
886
1371
  * Walks a subtree until it reaches a Success, a Failure or an I/O fault. Every node but `Ask` runs in its
887
1372
  * own function, which returns a Success carrying the value for the node's `next`, or what stops the subtree.
@@ -899,7 +1384,7 @@ const execute = async (runtime, eff, signal, path = '') => {
899
1384
  let from;
900
1385
  while (isPending(eff)) {
901
1386
  // A Command already in flight cannot be stopped, but the next one never starts.
902
- if (signal?.aborted) return cancelledBranch();
1387
+ if (signal?.aborted) return stopSkipped(eff, cancelledBranch(), signal);
903
1388
  if (eff.type === 'Ask') {
904
1389
  from = eff;
905
1390
  eff = eff.next(runtime.context);
@@ -912,7 +1397,7 @@ const execute = async (runtime, eff, signal, path = '') => {
912
1397
  : eff.type === 'Parallel'
913
1398
  ? await runParallel(runtime, eff, signal, stepPath)
914
1399
  : await runCommand(runtime, eff, signal, stepPath);
915
- if (outcome.type !== 'Success') return outcome;
1400
+ if (outcome.type !== 'Success') return stopAt(eff, outcome, signal);
916
1401
  // Outside every catch: `next` and the pure steps it reaches are code, not I/O, so a throw there
917
1402
  // rejects the run.
918
1403
  from = eff;
@@ -985,8 +1470,12 @@ const runParallel = async (runtime, parallel, signal, stepPath) => {
985
1470
  try {
986
1471
  await runtime.onStep('Parallel', 'Parallel', op, branchPath);
987
1472
  } catch (e) {
988
- // A cut from `fromTrace`: production had already stopped the branch this Parallel is in.
989
- if (hasMark(e, replayCut)) return cancelledBranch();
1473
+ // A cut from `fromTrace`: production had already stopped the branch this Parallel is in, so it skipped this
1474
+ // Parallel, and `stopSkipped` tells the flows built in its branches, as production's cancellation did.
1475
+ if (hasMark(e, replayCut)) {
1476
+ parallel.effects.forEach((branch) => stopSkipped(branch, cancelledBranch(), signal));
1477
+ return cancelledBranch();
1478
+ }
990
1479
  throw e;
991
1480
  }
992
1481
  if (!branchRun) {
@@ -1057,7 +1546,8 @@ const runBranches = async (runtime, effects, options, signal, branchPath, record
1057
1546
  limit,
1058
1547
  signal,
1059
1548
  branchPath,
1060
- reproducing ? () => false : (threw, result) => threw || (!settled && result.type !== 'Success')
1549
+ reproducing ? () => false : (threw, result) => threw || (!settled && result.type !== 'Success'),
1550
+ reproducing && forced.threw === true
1061
1551
  );
1062
1552
  if (reproducing) {
1063
1553
  // A branch that threw has no result to check, and its throw is what is rethrown.
@@ -1079,6 +1569,8 @@ const runBranches = async (runtime, effects, options, signal, branchPath, record
1079
1569
  : cancelled
1080
1570
  ? { cancelled: true, branch: null, branches }
1081
1571
  : { cancelled: false, branches };
1572
+ // A throw from any branch marked the scope, whichever branch cancelled the others first.
1573
+ if (thrown !== undefined && decision.cancelled) decision.threw = true;
1082
1574
  return { results, decision, thrown };
1083
1575
  };
1084
1576
 
@@ -1093,6 +1585,7 @@ const runBranches = async (runtime, effects, options, signal, branchPath, record
1093
1585
  * @param {AbortSignal | undefined} signal
1094
1586
  * @param {string} branchPath
1095
1587
  * @param {(threw: boolean, result: SuccessState | FailureState | IoFaultState) => boolean} cancelsOthers
1588
+ * @param {boolean} [recordedThrow] - A replay's recorded decision says a branch threw, so no generator flow stops
1096
1589
  * @returns {Promise<{
1097
1590
  * results: (SuccessState | FailureState | IoFaultState)[],
1098
1591
  * thrown: { error: unknown } | undefined,
@@ -1100,8 +1593,9 @@ const runBranches = async (runtime, effects, options, signal, branchPath, record
1100
1593
  * cancelled: boolean
1101
1594
  * }>}
1102
1595
  */
1103
- const settleBranches = async (runtime, effects, limit, signal, branchPath, cancelsOthers) => {
1596
+ const settleBranches = async (runtime, effects, limit, signal, branchPath, cancelsOthers, recordedThrow = false) => {
1104
1597
  const { scope, unlink } = linkedScope(signal);
1598
+ if (recordedThrow) thrownScopes.add(scope.signal);
1105
1599
  /** @type {(SuccessState | FailureState | IoFaultState)[]} */
1106
1600
  const results = new Array(effects.length);
1107
1601
  // Held rather than rethrown, so every branch still settles and no `limit` worker stops early.
@@ -1118,6 +1612,7 @@ const settleBranches = async (runtime, effects, limit, signal, branchPath, cance
1118
1612
  // Read-then-abort is atomic here, so exactly one branch is the trigger.
1119
1613
  if (cancelsOthers(thrown[i] !== undefined, results[i])) {
1120
1614
  if (!scope.signal.aborted) triggered[i] = true;
1615
+ if (thrown[i] !== undefined) thrownScopes.add(scope.signal);
1121
1616
  scope.abort();
1122
1617
  }
1123
1618
  };
@@ -2353,5 +2848,7 @@ export {
2353
2848
  recordEffect,
2354
2849
  replayEffect,
2355
2850
  timeTravel,
2356
- commandName
2851
+ commandName,
2852
+ effectGen,
2853
+ step
2357
2854
  };