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/CHANGELOG.md +9 -0
- package/README.md +584 -241
- package/index.d.ts +56 -3
- package/index.js +520 -23
- package/package.json +2 -1
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;
|
|
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 `
|
|
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}
|
|
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 = (
|
|
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')
|
|
496
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
*
|
|
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
|
|
751
|
-
return
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
};
|