pure-effect 0.19.0 → 0.20.1
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 +27 -1
- package/README.md +959 -657
- package/RULES.md +43 -0
- package/examples/recording-example.js +6 -3
- package/index.d.ts +29 -20
- package/index.js +153 -58
- package/package.json +4 -3
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
|
}
|
|
@@ -311,13 +312,13 @@ const checkOptions = (options, source, rules, raise = (message) => new TypeError
|
|
|
311
312
|
const value = options[name];
|
|
312
313
|
if (rule === null || value === undefined || rule[0](value)) continue;
|
|
313
314
|
const [, takes, advice] = rule;
|
|
314
|
-
const message = `${source} '${name}' must be ${takes}, received ${
|
|
315
|
+
const message = `${source} '${name}' must be ${takes}, received ${describeOption(value)}.`;
|
|
315
316
|
throw raise(advice ? `${message} ${advice}` : message, value);
|
|
316
317
|
}
|
|
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,13 +326,17 @@ 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 " +
|
|
332
333
|
"branch with Parallel's settled option."
|
|
333
334
|
],
|
|
334
|
-
delay: [
|
|
335
|
+
delay: [
|
|
336
|
+
isFiniteNonNegative,
|
|
337
|
+
'a finite number of 0 or more',
|
|
338
|
+
'To wait as long as a server asks, return its answer as data and wait in a Command.'
|
|
339
|
+
],
|
|
335
340
|
backoff: [isFiniteNonNegative, 'a finite number of 0 or more'],
|
|
336
341
|
onExhausted: [isFunction, 'a function that returns the fallback']
|
|
337
342
|
};
|
|
@@ -374,6 +379,14 @@ const describeValue = (value) => {
|
|
|
374
379
|
const describeArgument = (value) =>
|
|
375
380
|
value === undefined ? 'undefined' : isEffect(value) ? `an Effect of type '${value.type}'` : describeValue(value);
|
|
376
381
|
|
|
382
|
+
/**
|
|
383
|
+
* Describes an option's value for an error message. A function there is a callback in the wrong place, not a flow
|
|
384
|
+
* missing its input.
|
|
385
|
+
* @param {any} value
|
|
386
|
+
* @returns {string}
|
|
387
|
+
*/
|
|
388
|
+
const describeOption = (value) => (typeof value === 'function' ? 'a function' : describeArgument(value));
|
|
389
|
+
|
|
377
390
|
/**
|
|
378
391
|
* Marks an error as the harness failing rather than the flow: a malformed flow, or a trace that cannot answer a
|
|
379
392
|
* step. The interpreter rethrows it rather than folding it into a `Failure`, so nothing that handles a domain
|
|
@@ -510,6 +523,10 @@ const chain = (effect, fn, from, onStop) => {
|
|
|
510
523
|
const waiting = stopListeners.get(effect);
|
|
511
524
|
if (onStop) stopListeners.set(copy, [...(waiting ?? []), onStop]);
|
|
512
525
|
else if (waiting) stopListeners.set(copy, waiting);
|
|
526
|
+
const restart = restarts.get(effect);
|
|
527
|
+
if (restart) {
|
|
528
|
+
restarts.set(copy, { built: restart.built, rebuild: () => chain(restart.rebuild(), fn, from, onStop) });
|
|
529
|
+
}
|
|
513
530
|
return copy;
|
|
514
531
|
};
|
|
515
532
|
|
|
@@ -527,7 +544,22 @@ const chain = (effect, fn, from, onStop) => {
|
|
|
527
544
|
const stopListeners = new WeakMap();
|
|
528
545
|
|
|
529
546
|
/**
|
|
530
|
-
*
|
|
547
|
+
* The body run a generator flow made as it was built, which the first execution of the flow takes over if nothing
|
|
548
|
+
* has used it. `claimed` is shared by every copy of the flow's root, so only one execution takes it over.
|
|
549
|
+
* @typedef {{ run: BodyRun, position: number, state: BodyRun['state'], claimed: boolean }} BuiltRun
|
|
550
|
+
*/
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* How to build each generator flow's root again from its input, and the run it was built with. `effectGen` sets one,
|
|
554
|
+
* `chain` and `copyRoot` keep it on their copies, and `execute`'s `ownRun` builds the flow again for every execution
|
|
555
|
+
* after the first, so a `Retry` or a reused flow value runs the body afresh, as a retried `async` function does.
|
|
556
|
+
* @type {WeakMap<Effect, { built: BuiltRun, rebuild: () => Effect }>}
|
|
557
|
+
*/
|
|
558
|
+
const restarts = new WeakMap();
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Copies a flow's root, which `flowInputs` is keyed on, keeping the generator flows waiting on it and how to build it
|
|
562
|
+
* again.
|
|
531
563
|
* @param {Effect} tree
|
|
532
564
|
* @returns {Effect}
|
|
533
565
|
*/
|
|
@@ -535,12 +567,14 @@ const copyRoot = (tree) => {
|
|
|
535
567
|
const root = { ...tree };
|
|
536
568
|
const waiting = stopListeners.get(tree);
|
|
537
569
|
if (waiting) stopListeners.set(root, waiting);
|
|
570
|
+
const restart = restarts.get(tree);
|
|
571
|
+
if (restart) restarts.set(root, { built: restart.built, rebuild: () => copyRoot(restart.rebuild()) });
|
|
538
572
|
return root;
|
|
539
573
|
};
|
|
540
574
|
|
|
541
575
|
/**
|
|
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.
|
|
576
|
+
* The input each flow `effectPipe` or `effectGen` built was called with, keyed on the flow's root, for `interpret` to
|
|
577
|
+
* hand `onRun` and for `recordEffect` to store in its trace. Kept off the flow, so no node or outcome carries it.
|
|
544
578
|
* @type {WeakMap<Effect, any>}
|
|
545
579
|
*/
|
|
546
580
|
const flowInputs = new WeakMap();
|
|
@@ -671,8 +705,9 @@ const asyncGeneratorFunction = Object.getPrototypeOf(async function* () {});
|
|
|
671
705
|
*/
|
|
672
706
|
|
|
673
707
|
/**
|
|
674
|
-
* One run of a body: its generator, the position of the step it is waiting at,
|
|
675
|
-
*
|
|
708
|
+
* One run of a body: its generator, the position of the step it is waiting at, whether it can still resume, and
|
|
709
|
+
* whether an execution took it over (`takeBuiltRun`), which only ever moves it forward.
|
|
710
|
+
* @typedef {{ generator: Generator<any, any, any>, position: number, state: 'suspended' | 'done', owned: boolean }} BodyRun
|
|
676
711
|
*/
|
|
677
712
|
|
|
678
713
|
/**
|
|
@@ -681,6 +716,13 @@ const asyncGeneratorFunction = Object.getPrototypeOf(async function* () {});
|
|
|
681
716
|
* @typedef {{ signature: string, answer: any, earlier: Trail } | null} Trail
|
|
682
717
|
*/
|
|
683
718
|
|
|
719
|
+
/**
|
|
720
|
+
* Where a run an execution owns keeps its earlier answers: nowhere, since running it again builds the flow again from
|
|
721
|
+
* its input, so a long loop holds only the answer it is on. `rerun` refuses a trail that reaches it.
|
|
722
|
+
* @type {NonNullable<Trail>}
|
|
723
|
+
*/
|
|
724
|
+
const droppedAnswers = Object.freeze({ signature: '', answer: undefined, earlier: null });
|
|
725
|
+
|
|
684
726
|
/**
|
|
685
727
|
* @typedef {(generator: Generator<any, any, any>) => IteratorResult<any, any>} Resume
|
|
686
728
|
*/
|
|
@@ -704,7 +746,7 @@ const startRun = (flow) => {
|
|
|
704
746
|
generator
|
|
705
747
|
);
|
|
706
748
|
}
|
|
707
|
-
return { generator, position: 0, state: 'suspended' };
|
|
749
|
+
return { generator, position: 0, state: 'suspended', owned: false };
|
|
708
750
|
};
|
|
709
751
|
|
|
710
752
|
/**
|
|
@@ -825,7 +867,17 @@ const rerun = (flow, trail, signature) => {
|
|
|
825
867
|
};
|
|
826
868
|
/** @type {{ signature: string, answer: any }[]} */
|
|
827
869
|
const answers = [];
|
|
828
|
-
for (let cell = trail; cell; cell = cell.earlier)
|
|
870
|
+
for (let cell = trail; cell; cell = cell.earlier) {
|
|
871
|
+
if (cell === droppedAnswers) {
|
|
872
|
+
throw malformed(
|
|
873
|
+
`A step of a generator flow was answered again after a run had gone past it, at ${signature}, and ` +
|
|
874
|
+
'that run keeps no earlier answers to run the body again with. Walk a flow before running it, ' +
|
|
875
|
+
'not a step a run handed a hook.',
|
|
876
|
+
undefined
|
|
877
|
+
);
|
|
878
|
+
}
|
|
879
|
+
answers.unshift(cell);
|
|
880
|
+
}
|
|
829
881
|
let result = resumeBody(run, (g) => g.next());
|
|
830
882
|
for (const { signature: asked, answer } of answers) {
|
|
831
883
|
const effect = expect(result, asked);
|
|
@@ -840,7 +892,7 @@ const rerun = (flow, trail, signature) => {
|
|
|
840
892
|
|
|
841
893
|
/**
|
|
842
894
|
* 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
|
|
895
|
+
* when a test walks a second branch.
|
|
844
896
|
* @param {GenFlow} flow
|
|
845
897
|
* @param {BodyRun} run
|
|
846
898
|
* @param {number} position
|
|
@@ -897,7 +949,8 @@ const advance = (flow, run, trail, resume) => {
|
|
|
897
949
|
const continueBody = (flow, run, position, trail, signature, answer) => {
|
|
898
950
|
const current = runAt(flow, run, position, trail, signature);
|
|
899
951
|
current.position = position + 1;
|
|
900
|
-
|
|
952
|
+
const after = current.owned ? droppedAnswers : { signature, answer, earlier: trail };
|
|
953
|
+
return advance(flow, current, after, (g) => g.next(answer));
|
|
901
954
|
};
|
|
902
955
|
|
|
903
956
|
/**
|
|
@@ -945,7 +998,8 @@ const stopBody = (flow, run, position, trail, signature, node) => {
|
|
|
945
998
|
/**
|
|
946
999
|
* Builds a flow function from a generator function, which writes `yield* step(effect)` where `async` code writes
|
|
947
1000
|
* `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.
|
|
1001
|
+
* nodes, so it records, replays and tests as a pipeline does. The first execution of the flow takes over that run of
|
|
1002
|
+
* the body, and every later one builds the flow again from its input, as calling an `async` function again does.
|
|
949
1003
|
*
|
|
950
1004
|
* @param {(input: any) => Generator<any, any, any>} body
|
|
951
1005
|
* @returns {(input: any) => Effect}
|
|
@@ -963,13 +1017,18 @@ const effectGen = (body) => {
|
|
|
963
1017
|
body
|
|
964
1018
|
);
|
|
965
1019
|
}
|
|
966
|
-
|
|
1020
|
+
/** @param {any} input */
|
|
1021
|
+
const build = (input) => {
|
|
967
1022
|
const flow = { body, input };
|
|
1023
|
+
const run = startRun(flow);
|
|
968
1024
|
// Keyed on a copy, as `effectPipe`'s root is, since a body can return a shared object.
|
|
969
|
-
const root = copyRoot(advance(flow,
|
|
1025
|
+
const root = copyRoot(advance(flow, run, null, (g) => g.next()));
|
|
970
1026
|
flowInputs.set(root, input);
|
|
1027
|
+
const built = { run, position: run.position, state: run.state, claimed: false };
|
|
1028
|
+
restarts.set(root, { built, rebuild: () => build(input) });
|
|
971
1029
|
return root;
|
|
972
1030
|
};
|
|
1031
|
+
return build;
|
|
973
1032
|
};
|
|
974
1033
|
|
|
975
1034
|
// #endregion
|
|
@@ -978,7 +1037,7 @@ const effectGen = (body) => {
|
|
|
978
1037
|
/**
|
|
979
1038
|
* Wraps one Command, or one Parallel, whose `op` runs the branches and returns the decision, so a hook must call it.
|
|
980
1039
|
* 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:
|
|
1040
|
+
* @typedef {(name: string, type: 'Command' | 'Parallel', op: function, path?: string) => Promise<any>} StepRunner
|
|
982
1041
|
*/
|
|
983
1042
|
/** @type StepRunner */
|
|
984
1043
|
const defaultStepRunner = async (name, type, op) => await op();
|
|
@@ -1362,11 +1421,38 @@ const stopAt = (node, result, signal) => {
|
|
|
1362
1421
|
* @returns {SuccessState | FailureState | IoFaultState}
|
|
1363
1422
|
*/
|
|
1364
1423
|
const stopSkipped = (node, result, signal) => {
|
|
1424
|
+
// A generator flow another execution took over, or a test walked, is not this one's to stop.
|
|
1425
|
+
const restart = restarts.get(node);
|
|
1426
|
+
if (restart && !takeBuiltRun(restart.built)) return result;
|
|
1365
1427
|
if (node.type === 'Retry') stopSkipped(node.effect, result, signal);
|
|
1366
1428
|
if (node.type === 'Parallel') node.effects.forEach((branch) => stopSkipped(branch, result, signal));
|
|
1367
1429
|
return stopAt(node, result, signal);
|
|
1368
1430
|
};
|
|
1369
1431
|
|
|
1432
|
+
/**
|
|
1433
|
+
* Takes over the run a generator flow made as it was built, if nothing has: no execution took it over, and no test
|
|
1434
|
+
* walked it on from where it was built.
|
|
1435
|
+
* @param {BuiltRun} built
|
|
1436
|
+
* @returns {boolean} Whether the run was free
|
|
1437
|
+
*/
|
|
1438
|
+
const takeBuiltRun = (built) => {
|
|
1439
|
+
const free = !built.claimed && built.run.position === built.position && built.run.state === built.state;
|
|
1440
|
+
built.claimed = true;
|
|
1441
|
+
if (free) built.run.owned = true;
|
|
1442
|
+
return free;
|
|
1443
|
+
};
|
|
1444
|
+
|
|
1445
|
+
/**
|
|
1446
|
+
* The node to run in place of `eff`: `eff` itself, unless it is a generator flow's root whose built run is taken, when
|
|
1447
|
+
* it is the flow built again from its input. The other end is `restarts`, which `effectGen` fills.
|
|
1448
|
+
* @param {Effect} eff
|
|
1449
|
+
* @returns {Effect}
|
|
1450
|
+
*/
|
|
1451
|
+
const ownRun = (eff) => {
|
|
1452
|
+
const restart = restarts.get(eff);
|
|
1453
|
+
return restart && !takeBuiltRun(restart.built) ? restart.rebuild() : eff;
|
|
1454
|
+
};
|
|
1455
|
+
|
|
1370
1456
|
/**
|
|
1371
1457
|
* Walks a subtree until it reaches a Success, a Failure or an I/O fault. Every node but `Ask` runs in its
|
|
1372
1458
|
* own function, which returns a Success carrying the value for the node's `next`, or what stops the subtree.
|
|
@@ -1385,6 +1471,11 @@ const execute = async (runtime, eff, signal, path = '') => {
|
|
|
1385
1471
|
while (isPending(eff)) {
|
|
1386
1472
|
// A Command already in flight cannot be stopped, but the next one never starts.
|
|
1387
1473
|
if (signal?.aborted) return stopSkipped(eff, cancelledBranch(), signal);
|
|
1474
|
+
const own = ownRun(eff);
|
|
1475
|
+
if (own !== eff) {
|
|
1476
|
+
eff = own;
|
|
1477
|
+
continue;
|
|
1478
|
+
}
|
|
1388
1479
|
if (eff.type === 'Ask') {
|
|
1389
1480
|
from = eff;
|
|
1390
1481
|
eff = eff.next(runtime.context);
|
|
@@ -1423,9 +1514,9 @@ const runRetry = async (runtime, retry, signal, stepPath) => {
|
|
|
1423
1514
|
...defaultRetryOptions,
|
|
1424
1515
|
...Object.fromEntries(given)
|
|
1425
1516
|
});
|
|
1426
|
-
const {
|
|
1517
|
+
const { retries, onExhausted } = opts;
|
|
1427
1518
|
let lastError;
|
|
1428
|
-
for (let attempt = 0; attempt <=
|
|
1519
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
1429
1520
|
// `fastRetry` waits for no time rather than skipping the wait, so branches replayed by timing still
|
|
1430
1521
|
// interleave.
|
|
1431
1522
|
if (attempt > 0) {
|
|
@@ -1438,7 +1529,7 @@ const runRetry = async (runtime, retry, signal, stepPath) => {
|
|
|
1438
1529
|
if (result.type !== 'IoFault') return result;
|
|
1439
1530
|
lastError = result.error;
|
|
1440
1531
|
}
|
|
1441
|
-
const exhausted = { retryExhausted: true, lastError,
|
|
1532
|
+
const exhausted = { retryExhausted: true, lastError, retries };
|
|
1442
1533
|
// A fault too, so an enclosing Retry retries this one.
|
|
1443
1534
|
if (typeof onExhausted !== 'function') return IoFault(exhausted);
|
|
1444
1535
|
if (signal?.aborted) return cancelledBranch();
|
|
@@ -1748,7 +1839,7 @@ const runEffect = (effect, context, callConfig) => interpret(effect, context, ca
|
|
|
1748
1839
|
* position in completion order, which is not stable for a flow with a `Parallel`. A step whose `type` is 'Parallel'
|
|
1749
1840
|
* asks for the recorded decision and does not advance `index`; an answer that holds no decision replays that
|
|
1750
1841
|
* Parallel by timing.
|
|
1751
|
-
* @typedef {{ index: number, name: string, type:
|
|
1842
|
+
* @typedef {{ index: number, name: string, type: 'Command' | 'Parallel', path?: string }} ReplayStep
|
|
1752
1843
|
*/
|
|
1753
1844
|
|
|
1754
1845
|
/**
|
|
@@ -2123,24 +2214,26 @@ const packTrace = (settings, meta, entries, dropped) => {
|
|
|
2123
2214
|
};
|
|
2124
2215
|
|
|
2125
2216
|
/**
|
|
2126
|
-
* Runs a flow for real while recording it, and returns the outcome and a replayable trace.
|
|
2127
|
-
*
|
|
2217
|
+
* Runs a flow for real while recording it, and returns the outcome and a replayable trace. The trace holds the input
|
|
2218
|
+
* of a flow `effectPipe` or `effectGen` built, read from `flowInputs`. Convenient in tests and scripts; to record an
|
|
2219
|
+
* application, give each run its own recorder, as examples/recording-example.js does.
|
|
2128
2220
|
*
|
|
2129
|
-
* @param {
|
|
2130
|
-
* @param {any} initialInput - The value the flow is called with; stored so a replay can rebuild it
|
|
2221
|
+
* @param {Effect} effect - The flow, called with its input
|
|
2131
2222
|
* @param {RecorderOptions & { context?: any, version?: string }} [options] - Recorder options, plus the
|
|
2132
2223
|
* `context` given to `runEffect` (stored so `Ask` can be replayed) and a build id
|
|
2133
2224
|
* @returns {Promise<{ result: SuccessState | FailureState, trace: TraceLog }>}
|
|
2134
2225
|
*/
|
|
2135
|
-
const recordEffect = async (
|
|
2226
|
+
const recordEffect = async (effect, options = {}) => {
|
|
2227
|
+
// Before the options, so a call written the old way, with the flow function and its input, is named as that.
|
|
2228
|
+
asEffect(effect, 'The flow');
|
|
2136
2229
|
checkOptions(options, 'recordEffect', { context: null, version: null, ...recorderOptionRules });
|
|
2137
|
-
checkFlowFn(flowFn, 'recordEffect');
|
|
2138
2230
|
const { context = {}, version, ...recorderOptions } = options ?? {};
|
|
2139
2231
|
const rec = recorder(recorderOptions);
|
|
2140
2232
|
// Before the run, so a Command that writes to the input or the context cannot rewrite what the trace received.
|
|
2233
|
+
const initialInput = flowInputs.get(effect);
|
|
2141
2234
|
const head = rec.toTrace({ initialInput, flowName: context?.flowName, context, version });
|
|
2142
2235
|
// Merged over the global wiring, so recording inside an instrumented application keeps its spans.
|
|
2143
|
-
const result = await runEffect(
|
|
2236
|
+
const result = await runEffect(effect, context, { onStep: rec.onStep });
|
|
2144
2237
|
const { dropped, trace } = rec.toTrace();
|
|
2145
2238
|
return { result, trace: { ...head, dropped, trace } };
|
|
2146
2239
|
};
|
|
@@ -2294,7 +2387,7 @@ const fromTrace = (traceLog, options = {}) => {
|
|
|
2294
2387
|
|
|
2295
2388
|
/**
|
|
2296
2389
|
* The outcome a recorded entry hands its step. An entry the recorder could not record stops the replay rather than
|
|
2297
|
-
* run live under `onMissing`, since
|
|
2390
|
+
* run live under `onMissing`, since the recorded run ran the step.
|
|
2298
2391
|
* @param {TraceEntry} entry
|
|
2299
2392
|
* @param {ReplayStep} step
|
|
2300
2393
|
* @returns {ReplayOutcome}
|
|
@@ -2304,7 +2397,7 @@ const recordedOutcome = (entry, step) => {
|
|
|
2304
2397
|
throw unrecordedError(
|
|
2305
2398
|
`outcome for '${step.name}' at path '${step.path}'`,
|
|
2306
2399
|
entry.unrecorded,
|
|
2307
|
-
"
|
|
2400
|
+
"The recorded run ran the step, so onMissing: 'execute' does not run it again. To replay past it,",
|
|
2308
2401
|
{ command: step.name, index: step.index, path: step.path }
|
|
2309
2402
|
);
|
|
2310
2403
|
}
|
|
@@ -2459,25 +2552,28 @@ const resolveInOrder = (entries, answerFrom) => {
|
|
|
2459
2552
|
};
|
|
2460
2553
|
|
|
2461
2554
|
/**
|
|
2462
|
-
* The error for a step a replay has no
|
|
2463
|
-
* may have dropped the step or
|
|
2555
|
+
* The error for a step a replay has no outcome for, which it refuses to run live. For a trace it says whether a cap
|
|
2556
|
+
* may have dropped the step or the recorded run may never have reached it, since the fix differs. A Resolver is
|
|
2557
|
+
* usually a test's answers, so for one it names the step to answer.
|
|
2464
2558
|
* @param {ReplayStep} step
|
|
2465
2559
|
* @param {((step: ReplayStep) => string) | undefined} describe - `fromTrace`'s `missing`; a Resolver has none
|
|
2466
2560
|
* @param {number} droppedEntries - How many entries the trace dropped under `maxEntries`
|
|
2467
2561
|
* @returns {Error}
|
|
2468
2562
|
*/
|
|
2469
2563
|
const missingStepError = (step, describe, droppedEntries) => {
|
|
2470
|
-
const what = describe ? describe(step) : `
|
|
2471
|
-
const
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2564
|
+
const what = describe ? describe(step) : `The Resolver has no answer for '${step.name}' at path '${step.path}'`;
|
|
2565
|
+
const lacking = describe ? 'the trace also lacks' : 'the Resolver has no answer for either';
|
|
2566
|
+
const runsLive =
|
|
2567
|
+
`onMissing: 'execute' runs such a step for real, and every step after it that ${lacking}, so pass it ` +
|
|
2568
|
+
'only where every Command the flow can still reach goes to a test double or only reads.';
|
|
2569
|
+
const why = !describe
|
|
2570
|
+
? `Give the Resolver an answer for this step. ${runsLive}`
|
|
2571
|
+
: droppedEntries > 0
|
|
2572
|
+
? `The trace dropped ${droppedEntries} entries under maxEntries, so the recorded run may have reached ` +
|
|
2573
|
+
'this step and the recorder not kept it; record the flow with a higher maxEntries to replay past it.'
|
|
2574
|
+
: 'The recorded run may never have reached it: a Command an onBeforeCommand hook vetoed leaves no ' +
|
|
2575
|
+
'entry, and neither does a step the flow reaches now that it did not then, such as one added ' +
|
|
2576
|
+
`past the end of the recording. ${runsLive}`;
|
|
2481
2577
|
return replayError(`${what}; refusing to run the real Command. ${why}`, {
|
|
2482
2578
|
command: step.name,
|
|
2483
2579
|
index: step.index,
|
|
@@ -2610,7 +2706,7 @@ const replayContext = (traceLog, given) => {
|
|
|
2610
2706
|
};
|
|
2611
2707
|
|
|
2612
2708
|
/**
|
|
2613
|
-
* How many entries a trace dropped under `maxEntries`. A capped trace lacks steps
|
|
2709
|
+
* How many entries a trace dropped under `maxEntries`. A capped trace lacks steps the recorded run ran, so it refuses
|
|
2614
2710
|
* `onMissing: 'execute'`, which could run one of them again.
|
|
2615
2711
|
* @param {TraceLog | undefined} traceLog
|
|
2616
2712
|
* @param {'throw' | 'execute'} onMissing
|
|
@@ -2622,7 +2718,7 @@ const droppedEntriesOf = (traceLog, onMissing) => {
|
|
|
2622
2718
|
if (capped && onMissing === 'execute') {
|
|
2623
2719
|
throw replayError(
|
|
2624
2720
|
`The trace dropped ${dropped} entries under maxEntries, so a step it lacks may be one ` +
|
|
2625
|
-
"
|
|
2721
|
+
"the recorded run reached, and onMissing: 'execute' would run it again. Replay without it to stop at the " +
|
|
2626
2722
|
'first missing step, or record the flow with a higher maxEntries.'
|
|
2627
2723
|
);
|
|
2628
2724
|
}
|
|
@@ -2719,8 +2815,7 @@ const replayHook = ({ resolve, missing, onResolved, onMissing, droppedEntries })
|
|
|
2719
2815
|
* @param {(...args: any[]) => void} [options.log] - Defaults to `console.log`
|
|
2720
2816
|
* @param {any} [options.context] - Overrides the context stored on the trace
|
|
2721
2817
|
* @param {string} [options.version] - Current build id; warns when it differs from the trace's
|
|
2722
|
-
* @returns {Promise<
|
|
2723
|
-
* `replayEffect`.
|
|
2818
|
+
* @returns {Promise<Replay>} `{ result, unreached }`, as `replayEffect` returns for a trace
|
|
2724
2819
|
*/
|
|
2725
2820
|
const timeTravel = async (flowFn, traceLog, options = {}) => {
|
|
2726
2821
|
checkOptions(options, 'timeTravel', { log: [isFunction, 'a function'], context: null, version: null });
|
|
@@ -2757,7 +2852,7 @@ const timeTravel = async (flowFn, traceLog, options = {}) => {
|
|
|
2757
2852
|
log(result.type === 'Failure' ? `Error: ${formatForLog(result.error)}` : `Result: ${formatForLog(result.value)}`);
|
|
2758
2853
|
const warning = unreachedWarning(result, unreached);
|
|
2759
2854
|
if (warning !== undefined) log(warning);
|
|
2760
|
-
return result;
|
|
2855
|
+
return { result, unreached };
|
|
2761
2856
|
};
|
|
2762
2857
|
|
|
2763
2858
|
/**
|
|
@@ -2775,8 +2870,8 @@ const traceWarnings = (traceLog, trace, version) => {
|
|
|
2775
2870
|
}
|
|
2776
2871
|
if (traceLog.initialInput === undefined) {
|
|
2777
2872
|
warnings.push(
|
|
2778
|
-
'Warning: the trace holds no initial input, so the flow is rebuilt from undefined. A
|
|
2779
|
-
'
|
|
2873
|
+
'Warning: the trace holds no initial input, so the flow is rebuilt from undefined. A trace holds ' +
|
|
2874
|
+
'the input only of a flow built with effectPipe or effectGen.'
|
|
2780
2875
|
);
|
|
2781
2876
|
}
|
|
2782
2877
|
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.1",
|
|
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",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"files": [
|
|
21
21
|
"index.js",
|
|
22
22
|
"index.d.ts",
|
|
23
|
+
"RULES.md",
|
|
23
24
|
"CHANGELOG.md",
|
|
24
25
|
"examples"
|
|
25
26
|
],
|
|
@@ -31,8 +32,8 @@
|
|
|
31
32
|
"test": "mocha && tsd && tsc -p jsconfig.json",
|
|
32
33
|
"test:ts-minimum": "npx --yes -p typescript@5.1 tsc -p test/tsconfig.minimum.json",
|
|
33
34
|
"test:fuzz": "FUZZ_SCALE=20 mocha test/fuzz.js",
|
|
34
|
-
"generate": "node scripts/effect-pipe-overloads.js",
|
|
35
|
-
"format": "prettier --write index.js index.d.ts examples/*.js scripts/*.js test/*.js test/types.test-d.ts README.md CLAUDE.md DESIGN.md CHANGELOG.md CONTRIBUTING.md"
|
|
35
|
+
"generate": "node scripts/effect-pipe-overloads.js && node scripts/rules-file.js",
|
|
36
|
+
"format": "prettier --write index.js index.d.ts examples/*.js scripts/*.js test/*.js test/types.test-d.ts README.md CLAUDE.md DESIGN.md CHANGELOG.md CONTRIBUTING.md RULES.md"
|
|
36
37
|
},
|
|
37
38
|
"tsd": {
|
|
38
39
|
"directory": "test",
|