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.
- package/CHANGELOG.md +21 -0
- package/README.md +678 -306
- package/index.d.ts +82 -22
- package/index.js +625 -48
- 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;
|
|
8
|
-
// steps and the `flowInputs` it records
|
|
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 `
|
|
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<{
|
|
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}
|
|
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 = (
|
|
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.
|
|
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
|
-
*
|
|
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, {
|
|
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 = {
|
|
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
|
-
|
|
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')
|
|
496
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
503
|
-
*
|
|
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 =
|
|
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:
|
|
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
|
-
*
|
|
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
|
|
751
|
-
return
|
|
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
|
-
|
|
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)
|
|
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 {
|
|
1505
|
+
const { retries, onExhausted } = opts;
|
|
942
1506
|
let lastError;
|
|
943
|
-
for (let attempt = 0; 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,
|
|
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
|
-
|
|
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:
|
|
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.
|
|
1632
|
-
*
|
|
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 {
|
|
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 (
|
|
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(
|
|
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<
|
|
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
|
|
2284
|
-
'
|
|
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
|
};
|