pure-effect 0.19.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 +12 -0
- package/README.md +148 -119
- package/index.d.ts +26 -19
- package/index.js +119 -39
- package/package.json +1 -1
package/index.d.ts
CHANGED
|
@@ -35,7 +35,7 @@ export type AskState<T, E = unknown, Ctx = unknown> = {
|
|
|
35
35
|
// Every optional option and hook takes `undefined`, which keeps its default, under `exactOptionalPropertyTypes` too.
|
|
36
36
|
export type RetryOptions = {
|
|
37
37
|
/** Tries after the first, so `2` makes at most 3 calls. A positive integer, 3 by default. */
|
|
38
|
-
|
|
38
|
+
retries?: number | undefined;
|
|
39
39
|
/** Milliseconds before the first retry, 100 by default. */
|
|
40
40
|
delay?: number | undefined;
|
|
41
41
|
/** Multiplies the wait before each later retry, so `2` doubles it. 1 by default, the same wait each time. */
|
|
@@ -58,8 +58,8 @@ export type RetryState<T, E = unknown, Ctx = unknown, R = T> = {
|
|
|
58
58
|
export type RetryExhaustedError<Thrown = unknown> = {
|
|
59
59
|
retryExhausted: true;
|
|
60
60
|
lastError: Thrown;
|
|
61
|
-
/** The `
|
|
62
|
-
|
|
61
|
+
/** The `retries` option, so the retried steps ran one more time than this. */
|
|
62
|
+
retries: number;
|
|
63
63
|
};
|
|
64
64
|
|
|
65
65
|
export type ParallelOptions = {
|
|
@@ -82,7 +82,7 @@ export type ParallelState<
|
|
|
82
82
|
effects: { [K in keyof T]: Effect<T[K], BranchError, Ctx> };
|
|
83
83
|
next(values: [...V]): Effect<R, E, Ctx>;
|
|
84
84
|
/** A frozen copy of the options the `Parallel` was built with. */
|
|
85
|
-
options
|
|
85
|
+
options: Readonly<ParallelOptions>;
|
|
86
86
|
};
|
|
87
87
|
|
|
88
88
|
export type Effect<T, E = unknown, Ctx = unknown> =
|
|
@@ -1300,7 +1300,7 @@ export declare function effectGen<I, Y extends Step<any, any>, R>(
|
|
|
1300
1300
|
*/
|
|
1301
1301
|
export type StepRunner = (
|
|
1302
1302
|
name: string,
|
|
1303
|
-
type:
|
|
1303
|
+
type: 'Command' | 'Parallel',
|
|
1304
1304
|
op: (decision?: ParallelDecision) => Promise<unknown>,
|
|
1305
1305
|
path: string
|
|
1306
1306
|
) => Promise<unknown>;
|
|
@@ -1367,7 +1367,7 @@ export type ReplayStep = {
|
|
|
1367
1367
|
/** `meta.name`, `cmd.name`, or 'anonymous'; 'Parallel' for a `Parallel`'s step. */
|
|
1368
1368
|
name: string;
|
|
1369
1369
|
/** `'Command'`, or `'Parallel'` for a `Parallel`'s decision. */
|
|
1370
|
-
type:
|
|
1370
|
+
type: 'Command' | 'Parallel';
|
|
1371
1371
|
/** The step's position in the flow, the same in every run; a Resolver should key off it. */
|
|
1372
1372
|
path: string;
|
|
1373
1373
|
};
|
|
@@ -1442,16 +1442,15 @@ export type RecordOptions<Ctx = unknown> = RecorderOptions & {
|
|
|
1442
1442
|
};
|
|
1443
1443
|
|
|
1444
1444
|
/**
|
|
1445
|
-
* Runs a flow for real and returns its outcome with a trace
|
|
1446
|
-
*
|
|
1445
|
+
* Runs a flow for real and returns its outcome with a trace. The trace holds the flow's input when `effectPipe` or
|
|
1446
|
+
* `effectGen` built it.
|
|
1447
1447
|
*/
|
|
1448
|
-
export declare function recordEffect<
|
|
1449
|
-
|
|
1450
|
-
initialInput: I,
|
|
1448
|
+
export declare function recordEffect<T, E = unknown, Ctx = unknown>(
|
|
1449
|
+
effect: Effect<T, E, Ctx>,
|
|
1451
1450
|
...options: unknown extends Ctx
|
|
1452
1451
|
? [options?: RecordOptions<CheckedContext<Ctx>>]
|
|
1453
1452
|
: [options: RecordOptions<CheckedContext<Ctx>> & { context: RunContext<CheckedContext<Ctx>> }]
|
|
1454
|
-
): Promise<{ result: SuccessState<T> | FailureState<E>; trace: TraceLog<
|
|
1453
|
+
): Promise<{ result: SuccessState<T> | FailureState<E>; trace: TraceLog<unknown, Ctx> }>;
|
|
1455
1454
|
|
|
1456
1455
|
export interface ReplayOptions<Ctx = unknown> {
|
|
1457
1456
|
/** Context for `Ask`; defaults to the one the trace recorded. */
|
|
@@ -1498,16 +1497,24 @@ export declare function replayEffect<T, E = unknown, Ctx = unknown>(
|
|
|
1498
1497
|
: [options: ReplayOptions<CheckedContext<Ctx>> & { context: RunContext<CheckedContext<Ctx>> }]
|
|
1499
1498
|
): Promise<Omit<Replay<T, E>, 'unreached'> & { unreached?: TraceEntry[] }>;
|
|
1500
1499
|
|
|
1501
|
-
|
|
1500
|
+
export interface TimeTravelOptions<Ctx = unknown> {
|
|
1501
|
+
/** Receives each narrated line; `console.log` by default. */
|
|
1502
|
+
log?: ((...args: any[]) => void) | undefined;
|
|
1503
|
+
/** Context for `Ask`; defaults to the one the trace recorded. */
|
|
1504
|
+
context?: RunContext<Ctx> | undefined;
|
|
1505
|
+
/** The build replaying the trace; a trace recorded at another build gets a warning. */
|
|
1506
|
+
version?: string | undefined;
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
/**
|
|
1510
|
+
* Replays a trace and logs each step with its recorded timing, warning about what will not replay as recorded. Returns
|
|
1511
|
+
* what `replayEffect` returns for a trace.
|
|
1512
|
+
*/
|
|
1502
1513
|
export declare function timeTravel<T, E = unknown, Ctx = unknown>(
|
|
1503
1514
|
flowFn: (input: any) => Effect<T, E, Ctx>,
|
|
1504
1515
|
traceLog: TraceLog,
|
|
1505
|
-
options?:
|
|
1506
|
-
|
|
1507
|
-
context?: RunContext<CheckedContext<Ctx>> | undefined;
|
|
1508
|
-
version?: string | undefined;
|
|
1509
|
-
}
|
|
1510
|
-
): Promise<SuccessState<T> | FailureState<E>>;
|
|
1516
|
+
options?: TimeTravelOptions<CheckedContext<Ctx>>
|
|
1517
|
+
): Promise<Replay<T, E>>;
|
|
1511
1518
|
|
|
1512
1519
|
// Only the declarations marked `export` are public; without this, a declaration file exports every top-level one.
|
|
1513
1520
|
export {};
|
package/index.js
CHANGED
|
@@ -5,13 +5,14 @@
|
|
|
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
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
|
|
9
|
-
// and `step` and `effectGen`, which build a flow from a generator function.
|
|
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.
|
|
10
10
|
// 3. Configuration: the hook types and their defaults, `configureEffect` and its layers, and `chainHooks`, which
|
|
11
11
|
// merges them.
|
|
12
12
|
// 4. Running flows: the helpers `Retry`, `Parallel` and Commands run on; `interpret`, which builds a run's
|
|
13
|
-
// `Runtime`, then `stopAt`, which tells a waiting generator flow its step stopped, `
|
|
14
|
-
// for each
|
|
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`.
|
|
15
16
|
// 5. Recording and replay: the trace format and replay errors, copying values and errors into a trace,
|
|
16
17
|
// `recorder` and `recordEffect`, then `fromTrace`, `replayEffect` and `timeTravel`, each followed by the
|
|
17
18
|
// functions it hands its parts to.
|
|
@@ -47,7 +48,7 @@
|
|
|
47
48
|
* @typedef {{
|
|
48
49
|
* type: 'Retry',
|
|
49
50
|
* effect: Effect,
|
|
50
|
-
* options: Readonly<{
|
|
51
|
+
* options: Readonly<{ retries?: number, delay?: number, backoff?: number, onExhausted?: (error: any) => Effect }>,
|
|
51
52
|
* next: (value: any) => Effect
|
|
52
53
|
* }} RetryState
|
|
53
54
|
*/
|
|
@@ -162,12 +163,12 @@ const Ask = (...args) => {
|
|
|
162
163
|
*
|
|
163
164
|
* @param {Effect} effect - The inner Effect tree to retry
|
|
164
165
|
* @param {Object} [options] - Merged over the defaults when it runs; an option set to `undefined` keeps its default.
|
|
165
|
-
* @param {number} [options.
|
|
166
|
+
* @param {number} [options.retries] - Tries after the first, a positive integer
|
|
166
167
|
* @param {number} [options.delay] - Ms before the first retry, a finite number of 0 or more
|
|
167
168
|
* @param {number} [options.backoff] - Multiplier applied to the delay on each later retry, a finite number of 0 or
|
|
168
169
|
* more
|
|
169
170
|
* @param {(error: any) => Effect} [options.onExhausted] - Builds a fallback from `{ retryExhausted, lastError,
|
|
170
|
-
*
|
|
171
|
+
* retries }` once every attempt has failed. Its success feeds `next`; its failure propagates unwrapped.
|
|
171
172
|
* @returns {RetryState}
|
|
172
173
|
*/
|
|
173
174
|
const Retry = (effect, options) => {
|
|
@@ -175,7 +176,7 @@ const Retry = (effect, options) => {
|
|
|
175
176
|
if (typeof options === 'number') {
|
|
176
177
|
const got = describeArgument(options);
|
|
177
178
|
throw malformed(
|
|
178
|
-
`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} }).`,
|
|
179
180
|
options
|
|
180
181
|
);
|
|
181
182
|
}
|
|
@@ -317,7 +318,7 @@ const checkOptions = (options, source, rules, raise = (message) => new TypeError
|
|
|
317
318
|
};
|
|
318
319
|
|
|
319
320
|
/** What `Retry` uses for an option left out or set to `undefined`. */
|
|
320
|
-
const defaultRetryOptions = {
|
|
321
|
+
const defaultRetryOptions = { retries: 3, delay: 100, backoff: 1 };
|
|
321
322
|
|
|
322
323
|
/**
|
|
323
324
|
* Checked when the Retry is built, which keeps a frozen copy of the options, so they cannot change before it runs.
|
|
@@ -325,7 +326,7 @@ const defaultRetryOptions = { attempts: 3, delay: 100, backoff: 1 };
|
|
|
325
326
|
*/
|
|
326
327
|
const retryOptionRules = {
|
|
327
328
|
// Not 0, which would make `onExhausted` a catch.
|
|
328
|
-
|
|
329
|
+
retries: [
|
|
329
330
|
isPositiveInteger,
|
|
330
331
|
'a positive integer',
|
|
331
332
|
"To handle an outcome without retrying, branch on it as data in the Command's next, or isolate a failing " +
|
|
@@ -510,6 +511,10 @@ const chain = (effect, fn, from, onStop) => {
|
|
|
510
511
|
const waiting = stopListeners.get(effect);
|
|
511
512
|
if (onStop) stopListeners.set(copy, [...(waiting ?? []), onStop]);
|
|
512
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
|
+
}
|
|
513
518
|
return copy;
|
|
514
519
|
};
|
|
515
520
|
|
|
@@ -527,7 +532,22 @@ const chain = (effect, fn, from, onStop) => {
|
|
|
527
532
|
const stopListeners = new WeakMap();
|
|
528
533
|
|
|
529
534
|
/**
|
|
530
|
-
*
|
|
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.
|
|
531
551
|
* @param {Effect} tree
|
|
532
552
|
* @returns {Effect}
|
|
533
553
|
*/
|
|
@@ -535,12 +555,14 @@ const copyRoot = (tree) => {
|
|
|
535
555
|
const root = { ...tree };
|
|
536
556
|
const waiting = stopListeners.get(tree);
|
|
537
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()) });
|
|
538
560
|
return root;
|
|
539
561
|
};
|
|
540
562
|
|
|
541
563
|
/**
|
|
542
|
-
* The input each flow `effectPipe` built was called with, keyed on the flow's root, for `interpret` to
|
|
543
|
-
* Kept off the flow, so no node or outcome carries it.
|
|
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.
|
|
544
566
|
* @type {WeakMap<Effect, any>}
|
|
545
567
|
*/
|
|
546
568
|
const flowInputs = new WeakMap();
|
|
@@ -671,8 +693,9 @@ const asyncGeneratorFunction = Object.getPrototypeOf(async function* () {});
|
|
|
671
693
|
*/
|
|
672
694
|
|
|
673
695
|
/**
|
|
674
|
-
* One run of a body: its generator, the position of the step it is waiting at,
|
|
675
|
-
*
|
|
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
|
|
676
699
|
*/
|
|
677
700
|
|
|
678
701
|
/**
|
|
@@ -681,6 +704,13 @@ const asyncGeneratorFunction = Object.getPrototypeOf(async function* () {});
|
|
|
681
704
|
* @typedef {{ signature: string, answer: any, earlier: Trail } | null} Trail
|
|
682
705
|
*/
|
|
683
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
|
+
|
|
684
714
|
/**
|
|
685
715
|
* @typedef {(generator: Generator<any, any, any>) => IteratorResult<any, any>} Resume
|
|
686
716
|
*/
|
|
@@ -704,7 +734,7 @@ const startRun = (flow) => {
|
|
|
704
734
|
generator
|
|
705
735
|
);
|
|
706
736
|
}
|
|
707
|
-
return { generator, position: 0, state: 'suspended' };
|
|
737
|
+
return { generator, position: 0, state: 'suspended', owned: false };
|
|
708
738
|
};
|
|
709
739
|
|
|
710
740
|
/**
|
|
@@ -825,7 +855,17 @@ const rerun = (flow, trail, signature) => {
|
|
|
825
855
|
};
|
|
826
856
|
/** @type {{ signature: string, answer: any }[]} */
|
|
827
857
|
const answers = [];
|
|
828
|
-
for (let cell = trail; cell; cell = cell.earlier)
|
|
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
|
+
}
|
|
829
869
|
let result = resumeBody(run, (g) => g.next());
|
|
830
870
|
for (const { signature: asked, answer } of answers) {
|
|
831
871
|
const effect = expect(result, asked);
|
|
@@ -840,7 +880,7 @@ const rerun = (flow, trail, signature) => {
|
|
|
840
880
|
|
|
841
881
|
/**
|
|
842
882
|
* 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
|
|
883
|
+
* when a test walks a second branch.
|
|
844
884
|
* @param {GenFlow} flow
|
|
845
885
|
* @param {BodyRun} run
|
|
846
886
|
* @param {number} position
|
|
@@ -897,7 +937,8 @@ const advance = (flow, run, trail, resume) => {
|
|
|
897
937
|
const continueBody = (flow, run, position, trail, signature, answer) => {
|
|
898
938
|
const current = runAt(flow, run, position, trail, signature);
|
|
899
939
|
current.position = position + 1;
|
|
900
|
-
|
|
940
|
+
const after = current.owned ? droppedAnswers : { signature, answer, earlier: trail };
|
|
941
|
+
return advance(flow, current, after, (g) => g.next(answer));
|
|
901
942
|
};
|
|
902
943
|
|
|
903
944
|
/**
|
|
@@ -945,7 +986,8 @@ const stopBody = (flow, run, position, trail, signature, node) => {
|
|
|
945
986
|
/**
|
|
946
987
|
* Builds a flow function from a generator function, which writes `yield* step(effect)` where `async` code writes
|
|
947
988
|
* `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.
|
|
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.
|
|
949
991
|
*
|
|
950
992
|
* @param {(input: any) => Generator<any, any, any>} body
|
|
951
993
|
* @returns {(input: any) => Effect}
|
|
@@ -963,13 +1005,18 @@ const effectGen = (body) => {
|
|
|
963
1005
|
body
|
|
964
1006
|
);
|
|
965
1007
|
}
|
|
966
|
-
|
|
1008
|
+
/** @param {any} input */
|
|
1009
|
+
const build = (input) => {
|
|
967
1010
|
const flow = { body, input };
|
|
1011
|
+
const run = startRun(flow);
|
|
968
1012
|
// Keyed on a copy, as `effectPipe`'s root is, since a body can return a shared object.
|
|
969
|
-
const root = copyRoot(advance(flow,
|
|
1013
|
+
const root = copyRoot(advance(flow, run, null, (g) => g.next()));
|
|
970
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) });
|
|
971
1017
|
return root;
|
|
972
1018
|
};
|
|
1019
|
+
return build;
|
|
973
1020
|
};
|
|
974
1021
|
|
|
975
1022
|
// #endregion
|
|
@@ -978,7 +1025,7 @@ const effectGen = (body) => {
|
|
|
978
1025
|
/**
|
|
979
1026
|
* Wraps one Command, or one Parallel, whose `op` runs the branches and returns the decision, so a hook must call it.
|
|
980
1027
|
* Only a replay passes `op` an argument, the recorded decision. `path` is the step's position in the Effect tree.
|
|
981
|
-
* @typedef {(name: string, type:
|
|
1028
|
+
* @typedef {(name: string, type: 'Command' | 'Parallel', op: function, path?: string) => Promise<any>} StepRunner
|
|
982
1029
|
*/
|
|
983
1030
|
/** @type StepRunner */
|
|
984
1031
|
const defaultStepRunner = async (name, type, op) => await op();
|
|
@@ -1362,11 +1409,38 @@ const stopAt = (node, result, signal) => {
|
|
|
1362
1409
|
* @returns {SuccessState | FailureState | IoFaultState}
|
|
1363
1410
|
*/
|
|
1364
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;
|
|
1365
1415
|
if (node.type === 'Retry') stopSkipped(node.effect, result, signal);
|
|
1366
1416
|
if (node.type === 'Parallel') node.effects.forEach((branch) => stopSkipped(branch, result, signal));
|
|
1367
1417
|
return stopAt(node, result, signal);
|
|
1368
1418
|
};
|
|
1369
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
|
+
|
|
1370
1444
|
/**
|
|
1371
1445
|
* Walks a subtree until it reaches a Success, a Failure or an I/O fault. Every node but `Ask` runs in its
|
|
1372
1446
|
* own function, which returns a Success carrying the value for the node's `next`, or what stops the subtree.
|
|
@@ -1385,6 +1459,11 @@ const execute = async (runtime, eff, signal, path = '') => {
|
|
|
1385
1459
|
while (isPending(eff)) {
|
|
1386
1460
|
// A Command already in flight cannot be stopped, but the next one never starts.
|
|
1387
1461
|
if (signal?.aborted) return stopSkipped(eff, cancelledBranch(), signal);
|
|
1462
|
+
const own = ownRun(eff);
|
|
1463
|
+
if (own !== eff) {
|
|
1464
|
+
eff = own;
|
|
1465
|
+
continue;
|
|
1466
|
+
}
|
|
1388
1467
|
if (eff.type === 'Ask') {
|
|
1389
1468
|
from = eff;
|
|
1390
1469
|
eff = eff.next(runtime.context);
|
|
@@ -1423,9 +1502,9 @@ const runRetry = async (runtime, retry, signal, stepPath) => {
|
|
|
1423
1502
|
...defaultRetryOptions,
|
|
1424
1503
|
...Object.fromEntries(given)
|
|
1425
1504
|
});
|
|
1426
|
-
const {
|
|
1505
|
+
const { retries, onExhausted } = opts;
|
|
1427
1506
|
let lastError;
|
|
1428
|
-
for (let attempt = 0; attempt <=
|
|
1507
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
1429
1508
|
// `fastRetry` waits for no time rather than skipping the wait, so branches replayed by timing still
|
|
1430
1509
|
// interleave.
|
|
1431
1510
|
if (attempt > 0) {
|
|
@@ -1438,7 +1517,7 @@ const runRetry = async (runtime, retry, signal, stepPath) => {
|
|
|
1438
1517
|
if (result.type !== 'IoFault') return result;
|
|
1439
1518
|
lastError = result.error;
|
|
1440
1519
|
}
|
|
1441
|
-
const exhausted = { retryExhausted: true, lastError,
|
|
1520
|
+
const exhausted = { retryExhausted: true, lastError, retries };
|
|
1442
1521
|
// A fault too, so an enclosing Retry retries this one.
|
|
1443
1522
|
if (typeof onExhausted !== 'function') return IoFault(exhausted);
|
|
1444
1523
|
if (signal?.aborted) return cancelledBranch();
|
|
@@ -1748,7 +1827,7 @@ const runEffect = (effect, context, callConfig) => interpret(effect, context, ca
|
|
|
1748
1827
|
* position in completion order, which is not stable for a flow with a `Parallel`. A step whose `type` is 'Parallel'
|
|
1749
1828
|
* asks for the recorded decision and does not advance `index`; an answer that holds no decision replays that
|
|
1750
1829
|
* Parallel by timing.
|
|
1751
|
-
* @typedef {{ index: number, name: string, type:
|
|
1830
|
+
* @typedef {{ index: number, name: string, type: 'Command' | 'Parallel', path?: string }} ReplayStep
|
|
1752
1831
|
*/
|
|
1753
1832
|
|
|
1754
1833
|
/**
|
|
@@ -2123,24 +2202,26 @@ const packTrace = (settings, meta, entries, dropped) => {
|
|
|
2123
2202
|
};
|
|
2124
2203
|
|
|
2125
2204
|
/**
|
|
2126
|
-
* Runs a flow for real while recording it, and returns the outcome and a replayable trace.
|
|
2127
|
-
*
|
|
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.
|
|
2128
2208
|
*
|
|
2129
|
-
* @param {
|
|
2130
|
-
* @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
|
|
2131
2210
|
* @param {RecorderOptions & { context?: any, version?: string }} [options] - Recorder options, plus the
|
|
2132
2211
|
* `context` given to `runEffect` (stored so `Ask` can be replayed) and a build id
|
|
2133
2212
|
* @returns {Promise<{ result: SuccessState | FailureState, trace: TraceLog }>}
|
|
2134
2213
|
*/
|
|
2135
|
-
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');
|
|
2136
2217
|
checkOptions(options, 'recordEffect', { context: null, version: null, ...recorderOptionRules });
|
|
2137
|
-
checkFlowFn(flowFn, 'recordEffect');
|
|
2138
2218
|
const { context = {}, version, ...recorderOptions } = options ?? {};
|
|
2139
2219
|
const rec = recorder(recorderOptions);
|
|
2140
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);
|
|
2141
2222
|
const head = rec.toTrace({ initialInput, flowName: context?.flowName, context, version });
|
|
2142
2223
|
// Merged over the global wiring, so recording inside an instrumented application keeps its spans.
|
|
2143
|
-
const result = await runEffect(
|
|
2224
|
+
const result = await runEffect(effect, context, { onStep: rec.onStep });
|
|
2144
2225
|
const { dropped, trace } = rec.toTrace();
|
|
2145
2226
|
return { result, trace: { ...head, dropped, trace } };
|
|
2146
2227
|
};
|
|
@@ -2719,8 +2800,7 @@ const replayHook = ({ resolve, missing, onResolved, onMissing, droppedEntries })
|
|
|
2719
2800
|
* @param {(...args: any[]) => void} [options.log] - Defaults to `console.log`
|
|
2720
2801
|
* @param {any} [options.context] - Overrides the context stored on the trace
|
|
2721
2802
|
* @param {string} [options.version] - Current build id; warns when it differs from the trace's
|
|
2722
|
-
* @returns {Promise<
|
|
2723
|
-
* `replayEffect`.
|
|
2803
|
+
* @returns {Promise<Replay>} `{ result, unreached }`, as `replayEffect` returns for a trace
|
|
2724
2804
|
*/
|
|
2725
2805
|
const timeTravel = async (flowFn, traceLog, options = {}) => {
|
|
2726
2806
|
checkOptions(options, 'timeTravel', { log: [isFunction, 'a function'], context: null, version: null });
|
|
@@ -2757,7 +2837,7 @@ const timeTravel = async (flowFn, traceLog, options = {}) => {
|
|
|
2757
2837
|
log(result.type === 'Failure' ? `Error: ${formatForLog(result.error)}` : `Result: ${formatForLog(result.value)}`);
|
|
2758
2838
|
const warning = unreachedWarning(result, unreached);
|
|
2759
2839
|
if (warning !== undefined) log(warning);
|
|
2760
|
-
return result;
|
|
2840
|
+
return { result, unreached };
|
|
2761
2841
|
};
|
|
2762
2842
|
|
|
2763
2843
|
/**
|
|
@@ -2775,8 +2855,8 @@ const traceWarnings = (traceLog, trace, version) => {
|
|
|
2775
2855
|
}
|
|
2776
2856
|
if (traceLog.initialInput === undefined) {
|
|
2777
2857
|
warnings.push(
|
|
2778
|
-
'Warning: the trace holds no initial input, so the flow is rebuilt from undefined. A
|
|
2779
|
-
'
|
|
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.'
|
|
2780
2860
|
);
|
|
2781
2861
|
}
|
|
2782
2862
|
const anonymous = trace.filter((e) => e.command === 'anonymous').length;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pure-effect",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"description": "Records what your business logic did in production and replays it anywhere: time-travel debugging for JavaScript and TypeScript, with zero dependencies. Business logic is plain data you can test without mocks.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"effect",
|