pure-effect 0.15.0 → 0.17.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 +44 -0
- package/README.md +352 -110
- package/examples/opentelemetry-example.js +173 -0
- package/examples/recording-example.js +170 -0
- package/index.d.ts +1067 -1089
- package/index.js +292 -130
- package/package.json +7 -4
package/index.js
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
|
+
// Contents. Each section is a `#region`, so an editor can fold it.
|
|
4
|
+
//
|
|
5
|
+
// 1. Types: the JSDoc shapes of the nodes a flow is made of.
|
|
6
|
+
// 2. Building flows: the constructors; the checks, error messages and kinds of failure the later sections
|
|
7
|
+
// share; and `effectPipe`, with the `chain` that joins its steps.
|
|
8
|
+
// 3. Configuration: the hook types and the library's defaults, `configureEffect` and its layers, and
|
|
9
|
+
// `chainHooks`, which merges them.
|
|
10
|
+
// 4. Running flows: the helpers `Retry` and `Parallel` run on, the interpreter, and `runEffect`.
|
|
11
|
+
// 5. Recording and replay: the trace format and replay errors, copying values and errors into a trace,
|
|
12
|
+
// `recorder`, built on `observeSteps`, and `recordEffect`, then `fromTrace`, `replayEffect` and `timeTravel`.
|
|
13
|
+
//
|
|
14
|
+
// A new definition goes in the section it serves, which is usually the one that calls it.
|
|
15
|
+
|
|
16
|
+
// #region Types
|
|
17
|
+
|
|
3
18
|
/** @typedef {{ type: 'Success', value: any, initialInput?: any }} SuccessState */
|
|
4
19
|
/** @typedef {{ type: 'Failure', error: any, initialInput?: any }} FailureState */
|
|
5
20
|
/**
|
|
@@ -55,6 +70,9 @@
|
|
|
55
70
|
* @typedef {SuccessState | FailureState | CommandState | AskState | RetryState | ParallelState} Effect
|
|
56
71
|
*/
|
|
57
72
|
|
|
73
|
+
// #endregion
|
|
74
|
+
// #region Building flows
|
|
75
|
+
|
|
58
76
|
/**
|
|
59
77
|
* Represents a successful computation
|
|
60
78
|
* @param {any} value - The result value
|
|
@@ -78,9 +96,9 @@ const Failure = (error, initialInput) => ({
|
|
|
78
96
|
* Represents a side effect to be executed later.
|
|
79
97
|
*
|
|
80
98
|
* @param {(signal?: AbortSignal) => Promise<any>|any} cmd - The side-effect function to execute. Inside a
|
|
81
|
-
* `Parallel` branch
|
|
82
|
-
* accepts one can be cancelled in flight.
|
|
83
|
-
*
|
|
99
|
+
* `Parallel` branch, a function that declares a parameter receives an `AbortSignal` in it that fires when a
|
|
100
|
+
* sibling branch fails, so I/O that accepts one can be cancelled in flight. A parameter with a default value
|
|
101
|
+
* does not count, so `nanoid(size = 21)` keeps its default. Outside a `Parallel` no argument is passed.
|
|
84
102
|
* @param {(result: any) => Effect} [next] - Receives the result of `cmd` and returns the next Effect.
|
|
85
103
|
* Defaults to `(result) => Success(result)`, which is what most Commands want; `null` counts as omitted.
|
|
86
104
|
* @param {CommandMeta} [meta] - Optional metadata, passed to `onBeforeCommand`. A string `meta.name`
|
|
@@ -111,11 +129,15 @@ const Command = (cmd, next, meta) => {
|
|
|
111
129
|
/**
|
|
112
130
|
* The name a Command is known by: what a trace records, what replay matches on, and what a
|
|
113
131
|
* telemetry span is called. A non-empty string `meta.name`, else `cmd.name`, else 'anonymous'.
|
|
132
|
+
* Exported so a test walking a flow checks each step by the same rule.
|
|
114
133
|
*
|
|
115
134
|
* @param {CommandState} eff
|
|
116
135
|
* @returns {string}
|
|
117
136
|
*/
|
|
118
137
|
const commandName = (eff) => {
|
|
138
|
+
if (eff?.type !== 'Command') {
|
|
139
|
+
throw malformed(`commandName expects a Command, got ${describeArgument(eff)}.`, eff);
|
|
140
|
+
}
|
|
119
141
|
const meta = eff.meta;
|
|
120
142
|
const named = isObject(meta) ? meta.name : undefined;
|
|
121
143
|
return typeof named === 'string' && named !== '' ? named : eff.cmd.name || 'anonymous';
|
|
@@ -160,6 +182,9 @@ const Retry = (effect, options) => {
|
|
|
160
182
|
const hint = typeof options === 'number' ? `: write Retry(effect, { attempts: ${options} })` : '';
|
|
161
183
|
throw malformed(`Retry's options must be an object, got ${describeArgument(options)}${hint}.`, options);
|
|
162
184
|
}
|
|
185
|
+
rejectUnknownOptions(options, 'Retry', ['attempts', 'delay', 'backoff', 'onExhausted'], (m) =>
|
|
186
|
+
malformed(m, options)
|
|
187
|
+
);
|
|
163
188
|
return { type: 'Retry', effect, options: options ?? {}, next: (value) => Success(value) };
|
|
164
189
|
};
|
|
165
190
|
|
|
@@ -198,14 +223,26 @@ const Parallel = (effects, nextOrOptions, maybeOptions) => {
|
|
|
198
223
|
const hasNext = typeof nextOrOptions === 'function';
|
|
199
224
|
// A caller forwarding an absent `next` still passes its options third, as the signature reads.
|
|
200
225
|
const nextSkipped = nextOrOptions == null;
|
|
201
|
-
|
|
226
|
+
const optionsSecond = !hasNext && !nextSkipped;
|
|
227
|
+
if (optionsSecond && !isOptionsObject(nextOrOptions)) {
|
|
202
228
|
const got = describeArgument(nextOrOptions);
|
|
203
229
|
throw malformed(`Parallel's second argument must be next or the options, got ${got}.`, nextOrOptions);
|
|
204
230
|
}
|
|
205
|
-
|
|
231
|
+
// Nothing reads a third argument after the options, so a `next` passed there would silently be skipped.
|
|
232
|
+
if (optionsSecond && maybeOptions != null) {
|
|
233
|
+
throw malformed(
|
|
234
|
+
typeof maybeOptions === 'function'
|
|
235
|
+
? "Parallel's next goes second and its options third: Parallel(effects, next, options)."
|
|
236
|
+
: `Parallel takes one options object, and with the options second its third argument, ` +
|
|
237
|
+
`${describeArgument(maybeOptions)}, would be ignored.`,
|
|
238
|
+
maybeOptions
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
const options = optionsSecond ? nextOrOptions : maybeOptions;
|
|
206
242
|
if (options != null && !isOptionsObject(options)) {
|
|
207
243
|
throw malformed(`Parallel's options must be an object, got ${describeArgument(options)}.`, options);
|
|
208
244
|
}
|
|
245
|
+
rejectUnknownOptions(options, 'Parallel', ['limit', 'settled'], (m) => malformed(m, options));
|
|
209
246
|
return {
|
|
210
247
|
type: 'Parallel',
|
|
211
248
|
effects,
|
|
@@ -240,6 +277,22 @@ const isFiniteNonNegative = (value) => Number.isFinite(value) && value >= 0;
|
|
|
240
277
|
*/
|
|
241
278
|
const isOptionsObject = (value) => isObject(value) && !Array.isArray(value) && !isEffect(value);
|
|
242
279
|
|
|
280
|
+
/**
|
|
281
|
+
* Refuses an option name a function does not read. A misspelt name, or one borrowed from another library, as
|
|
282
|
+
* `concurrency` is from p-limit, otherwise ran with the default and nothing to say so.
|
|
283
|
+
* @param {any} options
|
|
284
|
+
* @param {string} source - The function that takes them, as the message names it
|
|
285
|
+
* @param {string[]} known
|
|
286
|
+
* @param {(message: string) => Error} [raise] - Builds the error; a constructor's argument errors are EffectTypeErrors
|
|
287
|
+
*/
|
|
288
|
+
const rejectUnknownOptions = (options, source, known, raise = (message) => new TypeError(message)) => {
|
|
289
|
+
const name = isObject(options) ? Object.keys(options).find((key) => !known.includes(key)) : undefined;
|
|
290
|
+
if (name !== undefined) {
|
|
291
|
+
const list = `${known.slice(0, -1).join(', ')} and ${known[known.length - 1]}`;
|
|
292
|
+
throw raise(`${source} has no option named '${name}'; its options are ${list}.`);
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
|
|
243
296
|
/**
|
|
244
297
|
* Describes a value for an error message, leading with the mistake it most likely is.
|
|
245
298
|
* @param {any} value
|
|
@@ -343,6 +396,16 @@ const effectTypeError = (value, source) =>
|
|
|
343
396
|
value
|
|
344
397
|
);
|
|
345
398
|
|
|
399
|
+
/**
|
|
400
|
+
* Names the node whose `next` returned a value, for an error message: a Command by its name, any other by its type.
|
|
401
|
+
* @param {Effect} node
|
|
402
|
+
* @returns {string}
|
|
403
|
+
*/
|
|
404
|
+
const nextOf = (node) =>
|
|
405
|
+
node.type === 'Command'
|
|
406
|
+
? `The next of Command '${commandName(node)}'`
|
|
407
|
+
: `The next of ${node.type === 'Ask' ? 'an' : 'a'} ${node.type}`;
|
|
408
|
+
|
|
346
409
|
/**
|
|
347
410
|
* A Success or a Failure: a flow with nothing left to run.
|
|
348
411
|
* @param {any} value
|
|
@@ -380,16 +443,18 @@ const asEffect = (value, source) => {
|
|
|
380
443
|
* @param {Effect} effect - The current Effect object
|
|
381
444
|
* @param {(value: any) => Effect} fn - The next function to run if the current effect is a Success
|
|
382
445
|
* @param {any} [initialInput] - The pipeline's starting value, stamped on every node but a Success
|
|
446
|
+
* @param {Effect} [from] - The node whose `next` returned `effect`, which an error names
|
|
383
447
|
* @returns {Effect} The composed Effect
|
|
384
448
|
*/
|
|
385
|
-
const chain = (effect, fn, initialInput) => {
|
|
449
|
+
const chain = (effect, fn, initialInput, from) => {
|
|
450
|
+
const source = () => (from ? nextOf(from) : 'A continuation');
|
|
386
451
|
// Overwrites a sub-pipeline's own stamp, so the root and a Failure at any depth carry the input of the
|
|
387
452
|
// flow that was actually called. A Success stays bare, so `deepEqual(result, Success(v))` always holds.
|
|
388
453
|
const withII = (/** @type {Effect} */ e) =>
|
|
389
454
|
initialInput !== undefined && e.type !== 'Success' ? { ...e, initialInput } : e;
|
|
390
455
|
|
|
391
456
|
// Checked before `effect.type` is read, which would otherwise throw a bare TypeError naming no step.
|
|
392
|
-
if (effect == null) return asEffect(effect,
|
|
457
|
+
if (effect == null) return asEffect(effect, source());
|
|
393
458
|
|
|
394
459
|
switch (effect.type) {
|
|
395
460
|
case 'Success':
|
|
@@ -397,23 +462,23 @@ const chain = (effect, fn, initialInput) => {
|
|
|
397
462
|
case 'Failure':
|
|
398
463
|
return withII(effect);
|
|
399
464
|
case 'Command': {
|
|
400
|
-
const next = (/** @type {any} */ result) => chain(effect.next(result), fn, initialInput);
|
|
465
|
+
const next = (/** @type {any} */ result) => chain(effect.next(result), fn, initialInput, effect);
|
|
401
466
|
return withII(Command(effect.cmd, next, effect.meta));
|
|
402
467
|
}
|
|
403
468
|
case 'Ask': {
|
|
404
|
-
const next = (/** @type {any} */ ctx) => chain(effect.next(ctx), fn, initialInput);
|
|
469
|
+
const next = (/** @type {any} */ ctx) => chain(effect.next(ctx), fn, initialInput, effect);
|
|
405
470
|
return withII(Ask(next));
|
|
406
471
|
}
|
|
407
472
|
case 'Retry': {
|
|
408
|
-
const next = (/** @type {any} */ result) => chain(effect.next(result), fn, initialInput);
|
|
473
|
+
const next = (/** @type {any} */ result) => chain(effect.next(result), fn, initialInput, effect);
|
|
409
474
|
return withII({ ...effect, next });
|
|
410
475
|
}
|
|
411
476
|
case 'Parallel': {
|
|
412
|
-
const next = (/** @type {any} */ result) => chain(effect.next(result), fn, initialInput);
|
|
477
|
+
const next = (/** @type {any} */ result) => chain(effect.next(result), fn, initialInput, effect);
|
|
413
478
|
return withII({ ...effect, next });
|
|
414
479
|
}
|
|
415
480
|
default:
|
|
416
|
-
return asEffect(effect,
|
|
481
|
+
return asEffect(effect, source());
|
|
417
482
|
}
|
|
418
483
|
};
|
|
419
484
|
|
|
@@ -421,7 +486,8 @@ const chain = (effect, fn, initialInput) => {
|
|
|
421
486
|
* Composes a list of functions into a single Effect pipeline.
|
|
422
487
|
* Each function receives the output of the previous one.
|
|
423
488
|
*
|
|
424
|
-
* @param {...(input: any) => Effect} fns - Functions that return Success, Failure, Command,
|
|
489
|
+
* @param {...(input: any) => Effect} fns - Functions that each return an Effect: Success, Failure, Command, Ask,
|
|
490
|
+
* Retry, or Parallel.
|
|
425
491
|
* @returns {(start: any) => Effect} A function that accepts an initial input and returns the final Effect tree.
|
|
426
492
|
*/
|
|
427
493
|
const effectPipe = (...fns) => {
|
|
@@ -444,6 +510,9 @@ const effectPipe = (...fns) => {
|
|
|
444
510
|
};
|
|
445
511
|
};
|
|
446
512
|
|
|
513
|
+
// #endregion
|
|
514
|
+
// #region Configuration
|
|
515
|
+
|
|
447
516
|
/**
|
|
448
517
|
* Wraps one Command execution, or one Parallel: `type` is 'Parallel', and `op` runs the branches and
|
|
449
518
|
* returns the Parallel's decision, even when a branch threw, so a hook must call it. Only a replay passes
|
|
@@ -514,7 +583,8 @@ const applyLayers = () => {
|
|
|
514
583
|
* Each call adds one layer on top of those already installed and returns a function that removes that
|
|
515
584
|
* layer, wherever it sits by then. Layers merge the way several configurations passed to one call do:
|
|
516
585
|
* `onStep` and `onRun` are wrappers, so they nest with the earliest layer outermost and the latest
|
|
517
|
-
* closest to the Command; and `onBeforeCommand` interceptors
|
|
586
|
+
* closest to the Command; and `onBeforeCommand` interceptors run in the order installed, the first to throw
|
|
587
|
+
* vetoing the Command. So these
|
|
518
588
|
* are the same:
|
|
519
589
|
*
|
|
520
590
|
* configureEffect(telemetryHooks(), recordingHooks({ sink }));
|
|
@@ -547,65 +617,13 @@ const configureEffect = (...configs) => {
|
|
|
547
617
|
};
|
|
548
618
|
};
|
|
549
619
|
|
|
550
|
-
/**
|
|
551
|
-
* @typedef {Object} StepStart
|
|
552
|
-
* @property {string} name - The Command's identity: `meta.name`, else `cmd.name`, else 'anonymous'.
|
|
553
|
-
* @property {string} type - 'Command', or 'Parallel' for a Parallel's decision.
|
|
554
|
-
* @property {string} [path] - The Command's position in the Effect tree.
|
|
555
|
-
*/
|
|
556
|
-
|
|
557
|
-
/**
|
|
558
|
-
* @typedef {Object} StepEnd
|
|
559
|
-
* @property {string} name
|
|
560
|
-
* @property {string} type
|
|
561
|
-
* @property {string} [path]
|
|
562
|
-
* @property {any} [result] - What the Command returned, when it succeeded.
|
|
563
|
-
* @property {any} [error] - What it threw, when it did not.
|
|
564
|
-
* @property {number} durationMs
|
|
565
|
-
*/
|
|
566
|
-
|
|
567
|
-
const now = () => (typeof performance === 'object' ? performance.now() : Date.now());
|
|
568
|
-
|
|
569
|
-
/**
|
|
570
|
-
* Wraps a step observer into an `onStep` that cannot change the run: `op` always runs, its result is
|
|
571
|
-
* returned, its error propagates, and anything the observer throws is dropped.
|
|
572
|
-
*
|
|
573
|
-
* @param {(start: StepStart) => (end: StepEnd) => void} handler - Returns a finisher for the outcome
|
|
574
|
-
* @returns {StepRunner}
|
|
575
|
-
*/
|
|
576
|
-
const observeSteps = (handler) => async (name, type, op, path) => {
|
|
577
|
-
/** @type {((end: StepEnd) => void) | undefined} */
|
|
578
|
-
let finish;
|
|
579
|
-
try {
|
|
580
|
-
finish = handler({ name, type, path });
|
|
581
|
-
} catch {
|
|
582
|
-
finish = undefined;
|
|
583
|
-
}
|
|
584
|
-
const report = (/** @type {StepEnd} */ end) => {
|
|
585
|
-
try {
|
|
586
|
-
if (finish) finish(end);
|
|
587
|
-
} catch {
|
|
588
|
-
// Observation does not get to decide the outcome, so a broken observer is dropped.
|
|
589
|
-
}
|
|
590
|
-
};
|
|
591
|
-
|
|
592
|
-
const started = now();
|
|
593
|
-
try {
|
|
594
|
-
const result = await op();
|
|
595
|
-
report({ name, type, path, result, durationMs: now() - started });
|
|
596
|
-
return result;
|
|
597
|
-
} catch (error) {
|
|
598
|
-
report({ name, type, path, error, durationMs: now() - started });
|
|
599
|
-
throw error;
|
|
600
|
-
}
|
|
601
|
-
};
|
|
602
|
-
|
|
603
620
|
/**
|
|
604
621
|
* Merges several configurations into one, so independent concerns can share the hooks.
|
|
605
622
|
*
|
|
606
623
|
* `onStep` and `onRun` are wrappers around an `op`, so they nest: the first config given is the
|
|
607
624
|
* outermost and the last sits closest to the Command, so a thrown Command unwinds from the last back
|
|
608
|
-
* to the first. `onBeforeCommand`
|
|
625
|
+
* to the first. `onBeforeCommand` interceptors do not nest: they run in the order given, and the first to
|
|
626
|
+
* throw vetoes the Command, so the ones after it do not run. A hook
|
|
609
627
|
* no config defines is left unset, so the caller keeps its default for that slot.
|
|
610
628
|
*
|
|
611
629
|
* @param {...(EffectConfiguration | undefined)} configs - Configurations to merge, outermost first
|
|
@@ -642,6 +660,9 @@ const chainHooks = (...configs) => {
|
|
|
642
660
|
return merged;
|
|
643
661
|
};
|
|
644
662
|
|
|
663
|
+
// #endregion
|
|
664
|
+
// #region Running flows
|
|
665
|
+
|
|
645
666
|
/**
|
|
646
667
|
* The Failure a cancelled `Parallel` branch stops with. Its error is named so it is never mistaken for
|
|
647
668
|
* the failure that triggered the cancellation.
|
|
@@ -692,7 +713,8 @@ const asDecision = (value, branches) => {
|
|
|
692
713
|
|
|
693
714
|
/**
|
|
694
715
|
* Marks a step a replay reached that production never ran, in a branch a recorded decision cancelled.
|
|
695
|
-
*
|
|
716
|
+
* Only `fromTrace` raises it, and `runCommand` and `runParallel` catch it and stop the branch there, as
|
|
717
|
+
* production did.
|
|
696
718
|
*/
|
|
697
719
|
const replayCut = Symbol('pure-effect.replayCut');
|
|
698
720
|
|
|
@@ -817,10 +839,13 @@ const interpret =
|
|
|
817
839
|
*/
|
|
818
840
|
async function execute(eff, signal, path = '') {
|
|
819
841
|
let step = 0;
|
|
842
|
+
/** @type {Effect | undefined} The node whose `next` returned `eff`, which an error names. */
|
|
843
|
+
let from;
|
|
820
844
|
while (isPending(eff)) {
|
|
821
845
|
// Checked before every node: a Command already in flight cannot be stopped, but the next never starts.
|
|
822
846
|
if (signal?.aborted) return cancelledBranch(eff.initialInput);
|
|
823
847
|
if (eff.type === 'Ask') {
|
|
848
|
+
from = eff;
|
|
824
849
|
eff = eff.next(context);
|
|
825
850
|
continue;
|
|
826
851
|
}
|
|
@@ -834,10 +859,11 @@ const interpret =
|
|
|
834
859
|
if (outcome.type !== 'Success') return outcome;
|
|
835
860
|
// Outside every catch: `next` and the pure steps it reaches are code, not I/O, so a throw there
|
|
836
861
|
// rejects the run.
|
|
862
|
+
from = eff;
|
|
837
863
|
eff = eff.next(outcome.value);
|
|
838
864
|
}
|
|
839
865
|
if (isOutcome(eff)) return eff;
|
|
840
|
-
throw effectTypeError(eff, 'The flow');
|
|
866
|
+
throw effectTypeError(eff, from ? nextOf(from) : 'The flow');
|
|
841
867
|
}
|
|
842
868
|
|
|
843
869
|
/**
|
|
@@ -915,6 +941,7 @@ const interpret =
|
|
|
915
941
|
throw new TypeError(`Parallel 'limit' must be a positive integer, received ${describeValue(limit)}.`);
|
|
916
942
|
// Cast rather than annotated, since only `op` assigns it.
|
|
917
943
|
let run = /** @type {BranchRun | undefined} */ (undefined);
|
|
944
|
+
// A replay passes the recorded decision, from `replayEffect`'s onStep; a live run passes nothing.
|
|
918
945
|
const op = async (/** @type {any} */ recorded) => {
|
|
919
946
|
run = await runBranches(parallel.effects, options, signal, branchPath, recorded);
|
|
920
947
|
return run.decision;
|
|
@@ -922,7 +949,7 @@ const interpret =
|
|
|
922
949
|
try {
|
|
923
950
|
await localStepRunner('Parallel', 'Parallel', op, branchPath);
|
|
924
951
|
} catch (e) {
|
|
925
|
-
// A replay found this Parallel inside a branch production had already stopped.
|
|
952
|
+
// A cut from `fromTrace`: a replay found this Parallel inside a branch production had already stopped.
|
|
926
953
|
if (hasMark(e, replayCut)) return cancelledBranch(parallel.initialInput);
|
|
927
954
|
throw e;
|
|
928
955
|
}
|
|
@@ -957,7 +984,8 @@ const interpret =
|
|
|
957
984
|
* @param {ParallelOptions} options
|
|
958
985
|
* @param {AbortSignal | undefined} signal - The enclosing Parallel's cancellation, if any
|
|
959
986
|
* @param {string} branchPath
|
|
960
|
-
* @param {any} recorded - The recorded decision,
|
|
987
|
+
* @param {any} recorded - The recorded decision, which `replayEffect`'s onStep passes to the Parallel's
|
|
988
|
+
* `op`; undefined in a live run
|
|
961
989
|
* @returns {Promise<BranchRun>}
|
|
962
990
|
*/
|
|
963
991
|
async function runBranches(effects, options, signal, branchPath, recorded) {
|
|
@@ -968,10 +996,10 @@ const interpret =
|
|
|
968
996
|
const pastTheEnd = Number.isInteger(recordedBranch) && recordedBranch >= effects.length;
|
|
969
997
|
if (pastTheEnd) {
|
|
970
998
|
const count = effects.length === 1 ? '1 branch' : `${effects.length} branches`;
|
|
971
|
-
throw
|
|
972
|
-
`
|
|
973
|
-
|
|
974
|
-
{
|
|
999
|
+
throw timeParadoxAt(
|
|
1000
|
+
`path '${branchPath}'`,
|
|
1001
|
+
`the recorded run was cancelled by branch ${recordedBranch}, but this Parallel has ${count}.`,
|
|
1002
|
+
{ path: branchPath, branch: recordedBranch }
|
|
975
1003
|
);
|
|
976
1004
|
}
|
|
977
1005
|
const forced = asDecision(recorded, effects.length);
|
|
@@ -991,10 +1019,10 @@ const interpret =
|
|
|
991
1019
|
const recordedTriggerSucceeded =
|
|
992
1020
|
!thrown && forced.branch !== null && results[forced.branch].type === 'Success';
|
|
993
1021
|
if (recordedTriggerSucceeded) {
|
|
994
|
-
throw
|
|
995
|
-
`
|
|
996
|
-
|
|
997
|
-
{
|
|
1022
|
+
throw timeParadoxAt(
|
|
1023
|
+
`path '${branchPath}'`,
|
|
1024
|
+
`the recorded run was cancelled by branch ${forced.branch}, which did not fail in this replay.`,
|
|
1025
|
+
{ path: branchPath, branch: forced.branch }
|
|
998
1026
|
);
|
|
999
1027
|
}
|
|
1000
1028
|
return { results, decision: forced, thrown };
|
|
@@ -1084,15 +1112,28 @@ const interpret =
|
|
|
1084
1112
|
// What it returned, so a hook that loses it is caught.
|
|
1085
1113
|
/** @type {unknown} */
|
|
1086
1114
|
let value;
|
|
1087
|
-
// The signal
|
|
1088
|
-
//
|
|
1089
|
-
//
|
|
1090
|
-
|
|
1115
|
+
// The signal goes only to a function that declares a parameter for it, and only inside a Parallel. A
|
|
1116
|
+
// parameter with a default value does not count toward `length`, so `nanoid(size = 21)` passed by name
|
|
1117
|
+
// keeps its default rather than reading the signal as its size. A plain first parameter the function
|
|
1118
|
+
// treats as optional still takes the signal: a documented sharp edge.
|
|
1119
|
+
const takesSignal = signal !== undefined && cmd.length > 0;
|
|
1120
|
+
// Async, so a hook always gets a promise, even from a synchronous function. The latest call is kept,
|
|
1121
|
+
// with whether it is still running, so a hook that does not wait for it is caught.
|
|
1122
|
+
/** @type {Promise<unknown> | undefined} */
|
|
1123
|
+
let call;
|
|
1124
|
+
let running = false;
|
|
1125
|
+
const run = async () => {
|
|
1091
1126
|
succeeded = false;
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1127
|
+
running = true;
|
|
1128
|
+
try {
|
|
1129
|
+
value = await (takesSignal ? cmd(signal) : cmd());
|
|
1130
|
+
succeeded = true;
|
|
1131
|
+
return value;
|
|
1132
|
+
} finally {
|
|
1133
|
+
running = false;
|
|
1134
|
+
}
|
|
1095
1135
|
};
|
|
1136
|
+
const op = () => (call = run());
|
|
1096
1137
|
try {
|
|
1097
1138
|
await localCommandInterceptor(command, context);
|
|
1098
1139
|
} catch (e) {
|
|
@@ -1102,10 +1143,18 @@ const interpret =
|
|
|
1102
1143
|
// Again, since an interceptor can wait (a rate limiter, say) while a sibling fails.
|
|
1103
1144
|
if (signal?.aborted) return cancelledBranch(initialInput);
|
|
1104
1145
|
let returned;
|
|
1146
|
+
let unwaited = false;
|
|
1105
1147
|
try {
|
|
1106
1148
|
returned = await localStepRunner(cmdName, 'Command', op, cmdPath);
|
|
1149
|
+
// A hook that called `op` without waiting for it returned while the Command ran, so the check below
|
|
1150
|
+
// would have nothing to check yet. Waiting here judges the step as though the hook had awaited `op`.
|
|
1151
|
+
if (returned === undefined && running) {
|
|
1152
|
+
unwaited = true;
|
|
1153
|
+
await call;
|
|
1154
|
+
}
|
|
1107
1155
|
} catch (e) {
|
|
1108
|
-
// A step production never ran, in a branch a Parallel cancelled
|
|
1156
|
+
// A cut from `fromTrace`: a step production never ran, in a branch a Parallel cancelled. Stop here,
|
|
1157
|
+
// as production did.
|
|
1109
1158
|
if (hasMark(e, replayCut)) return cancelledBranch(initialInput);
|
|
1110
1159
|
if (hasMark(e, harnessError)) throw e;
|
|
1111
1160
|
// A hook threw after the function returned, which is a bug in the hook. A hook that throws
|
|
@@ -1118,8 +1167,9 @@ const interpret =
|
|
|
1118
1167
|
// refused, so a hook that returns a copy of the result still works.
|
|
1119
1168
|
if (returned === undefined && succeeded && value !== undefined) {
|
|
1120
1169
|
throw new TypeError(
|
|
1121
|
-
`An onStep hook called op for '${cmdName}' at path '${cmdPath}' and returned undefined
|
|
1122
|
-
'
|
|
1170
|
+
`An onStep hook called op for '${cmdName}' at path '${cmdPath}' and returned undefined` +
|
|
1171
|
+
`${unwaited ? ' before op had finished' : ''}, although the Command returned a value. ` +
|
|
1172
|
+
'A hook has to await op() and return what it returns.'
|
|
1123
1173
|
);
|
|
1124
1174
|
}
|
|
1125
1175
|
return Success(returned);
|
|
@@ -1148,6 +1198,9 @@ const interpret =
|
|
|
1148
1198
|
*/
|
|
1149
1199
|
const runEffect = (effect, context, callConfig) => interpret(effect, context, callConfig);
|
|
1150
1200
|
|
|
1201
|
+
// #endregion
|
|
1202
|
+
// #region Recording and replay
|
|
1203
|
+
|
|
1151
1204
|
/**
|
|
1152
1205
|
* The step a replay is asking about. `path` is the Command's position in the Effect tree and is stable
|
|
1153
1206
|
* across runs; `index` is its position in this run's completion order, which is not stable for a flow
|
|
@@ -1213,25 +1266,32 @@ const replayError = (message, props = {}) =>
|
|
|
1213
1266
|
);
|
|
1214
1267
|
|
|
1215
1268
|
/**
|
|
1216
|
-
*
|
|
1217
|
-
*
|
|
1269
|
+
* Builds a `TimeParadox`: the replay reached a point where the flow no longer matches its recording, which
|
|
1270
|
+
* means the code has diverged from the recorded run.
|
|
1271
|
+
* @param {string} at - Where, as the message names it
|
|
1272
|
+
* @param {string} detail - How the flow and the recording disagree
|
|
1273
|
+
* @param {Object} props - The fields that locate it, such as `path`
|
|
1274
|
+
* @returns {Error}
|
|
1275
|
+
*/
|
|
1276
|
+
const timeParadoxAt = (at, detail, props) =>
|
|
1277
|
+
replayError(`Time paradox at ${at}: ${detail}`, { name: 'TimeParadox', ...props });
|
|
1278
|
+
|
|
1279
|
+
/**
|
|
1280
|
+
* Signals that the flow being replayed asked for a different Command than the trace recorded.
|
|
1218
1281
|
* @param {ReplayStep} step - The step the flow asked for
|
|
1219
1282
|
* @param {string} recorded - The command name the trace holds at that position
|
|
1220
1283
|
* @returns {Error}
|
|
1221
1284
|
*/
|
|
1222
1285
|
const timeParadox = (step, recorded) =>
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
{
|
|
1227
|
-
name: 'TimeParadox',
|
|
1228
|
-
index: step.index,
|
|
1229
|
-
path: step.path,
|
|
1230
|
-
expected: recorded,
|
|
1231
|
-
actual: step.name
|
|
1232
|
-
}
|
|
1286
|
+
timeParadoxAt(
|
|
1287
|
+
step.path !== undefined ? `path '${step.path}'` : `step ${step.index}`,
|
|
1288
|
+
`flow asked for '${step.name}', trace recorded '${recorded}'`,
|
|
1289
|
+
{ index: step.index, path: step.path, expected: recorded, actual: step.name }
|
|
1233
1290
|
);
|
|
1234
1291
|
|
|
1292
|
+
/** The keys `serializeError` carries whatever their enumerability, which a native Error sets as hidden. */
|
|
1293
|
+
const carriedKeys = ['name', 'message', 'cause'];
|
|
1294
|
+
|
|
1235
1295
|
/**
|
|
1236
1296
|
* Converts a thrown value into something JSON can carry. `message` and `stack` are
|
|
1237
1297
|
* non-enumerable on Error, so a plain `JSON.stringify` would silently drop them.
|
|
@@ -1270,9 +1330,6 @@ const serializeError = (e, withStack, ancestors = new Set()) => {
|
|
|
1270
1330
|
return out;
|
|
1271
1331
|
};
|
|
1272
1332
|
|
|
1273
|
-
/** The keys `serializeError` carries whatever their enumerability, which a native Error sets as hidden. */
|
|
1274
|
-
const carriedKeys = ['name', 'message', 'cause'];
|
|
1275
|
-
|
|
1276
1333
|
/**
|
|
1277
1334
|
* Rebuilds an Error from `serializeError` output. Non-Error values pass through, so a
|
|
1278
1335
|
* Command that rejected with a string still replays as a string.
|
|
@@ -1378,15 +1435,70 @@ const snapshot = (value) => {
|
|
|
1378
1435
|
};
|
|
1379
1436
|
|
|
1380
1437
|
/**
|
|
1381
|
-
*
|
|
1382
|
-
*
|
|
1383
|
-
*
|
|
1384
|
-
*
|
|
1438
|
+
* @typedef {Object} StepStart
|
|
1439
|
+
* @property {string} name - The Command's identity: `meta.name`, else `cmd.name`, else 'anonymous'.
|
|
1440
|
+
* @property {string} type - 'Command', or 'Parallel' for a Parallel's decision.
|
|
1441
|
+
* @property {string} [path] - The Command's position in the Effect tree.
|
|
1442
|
+
*/
|
|
1443
|
+
|
|
1444
|
+
/**
|
|
1445
|
+
* @typedef {Object} StepEnd
|
|
1446
|
+
* @property {string} name
|
|
1447
|
+
* @property {string} type
|
|
1448
|
+
* @property {string} [path]
|
|
1449
|
+
* @property {any} [result] - What the Command returned, when it succeeded.
|
|
1450
|
+
* @property {any} [error] - What it threw, when it did not.
|
|
1451
|
+
* @property {number} durationMs
|
|
1452
|
+
*/
|
|
1453
|
+
|
|
1454
|
+
const now = () => (typeof performance === 'object' ? performance.now() : Date.now());
|
|
1455
|
+
|
|
1456
|
+
/**
|
|
1457
|
+
* Wraps a step observer into an `onStep` that cannot change the run: `op` always runs, its result is
|
|
1458
|
+
* returned, its error propagates, and anything the observer throws is dropped.
|
|
1459
|
+
*
|
|
1460
|
+
* @param {(start: StepStart) => (end: StepEnd) => void} handler - Returns a finisher for the outcome
|
|
1461
|
+
* @returns {StepRunner}
|
|
1462
|
+
*/
|
|
1463
|
+
const observeSteps = (handler) => async (name, type, op, path) => {
|
|
1464
|
+
/** @type {((end: StepEnd) => void) | undefined} */
|
|
1465
|
+
let finish;
|
|
1466
|
+
try {
|
|
1467
|
+
finish = handler({ name, type, path });
|
|
1468
|
+
} catch {
|
|
1469
|
+
finish = undefined;
|
|
1470
|
+
}
|
|
1471
|
+
const report = (/** @type {StepEnd} */ end) => {
|
|
1472
|
+
try {
|
|
1473
|
+
if (finish) finish(end);
|
|
1474
|
+
} catch {
|
|
1475
|
+
// Observation does not get to decide the outcome, so a broken observer is dropped.
|
|
1476
|
+
}
|
|
1477
|
+
};
|
|
1478
|
+
|
|
1479
|
+
const started = now();
|
|
1480
|
+
try {
|
|
1481
|
+
const result = await op();
|
|
1482
|
+
report({ name, type, path, result, durationMs: now() - started });
|
|
1483
|
+
return result;
|
|
1484
|
+
} catch (error) {
|
|
1485
|
+
report({ name, type, path, error, durationMs: now() - started });
|
|
1486
|
+
throw error;
|
|
1487
|
+
}
|
|
1488
|
+
};
|
|
1489
|
+
|
|
1490
|
+
/**
|
|
1491
|
+
* Builds an `onStep` hook that records every Command's result or error and every Parallel's decision, plus
|
|
1492
|
+
* a packager for the reference trace format. Pass `onStep` to `runEffect` as per-call config. A recorder holds
|
|
1493
|
+
* the steps of every run it sees, so installed with `configureEffect` for a whole application it mixes requests
|
|
1494
|
+
* into one trace with duplicate paths, which a replay refuses; examples/recording-example.js gives each run its
|
|
1495
|
+
* own.
|
|
1385
1496
|
*
|
|
1386
1497
|
* @param {RecorderOptions} [options] - Redaction and size limits
|
|
1387
1498
|
* @returns {{ onStep: StepRunner, entries: TraceEntry[], toTrace: (meta?: TraceMeta) => TraceLog }}
|
|
1388
1499
|
*/
|
|
1389
1500
|
const recorder = (options = {}) => {
|
|
1501
|
+
rejectUnknownOptions(options, 'recorder', ['redact', 'maxEntries', 'stack']);
|
|
1390
1502
|
const { redact = (/** @type {any} */ r) => r, maxEntries = Infinity, stack = false } = options;
|
|
1391
1503
|
/** @type {TraceEntry[]} */
|
|
1392
1504
|
const entries = [];
|
|
@@ -1462,6 +1574,7 @@ const recorder = (options = {}) => {
|
|
|
1462
1574
|
* @returns {Promise<{ result: SuccessState | FailureState, trace: TraceLog }>}
|
|
1463
1575
|
*/
|
|
1464
1576
|
const recordEffect = async (flowFn, initialInput, options = {}) => {
|
|
1577
|
+
rejectUnknownOptions(options, 'recordEffect', ['context', 'version', 'redact', 'maxEntries', 'stack']);
|
|
1465
1578
|
const { context = {}, version, ...recorderOptions } = options;
|
|
1466
1579
|
const rec = recorder(recorderOptions);
|
|
1467
1580
|
// Packaged before the run, so a Command that writes to the input or the context, as an ORM save
|
|
@@ -1552,7 +1665,8 @@ const fromTrace = (traceLog, options = {}) => {
|
|
|
1552
1665
|
/** Recorded Parallel decisions that cancelled branches, by the Parallel's path. */
|
|
1553
1666
|
const cancellations = entries.filter(isDecisionEntry).filter((e) => e.result?.cancelled === true);
|
|
1554
1667
|
// A missing step is where production stopped a branch when it lies in a branch a recorded decision
|
|
1555
|
-
// cancelled: any branch but the cancelling one, or every branch of a Parallel cancelled from outside.
|
|
1668
|
+
// cancelled: any branch but the cancelling one, or every branch of a Parallel cancelled from outside. Such a
|
|
1669
|
+
// step throws a cut, which `runCommand` or `runParallel` catches to stop the branch.
|
|
1556
1670
|
const stoppedInProduction = (/** @type {string | undefined} */ stepPath) =>
|
|
1557
1671
|
typeof stepPath === 'string' &&
|
|
1558
1672
|
cancellations.some((e) => {
|
|
@@ -1564,7 +1678,8 @@ const fromTrace = (traceLog, options = {}) => {
|
|
|
1564
1678
|
const resolve = (step) => {
|
|
1565
1679
|
const entry = byPath.get(step.path);
|
|
1566
1680
|
if (step.type === 'Parallel') {
|
|
1567
|
-
// A decision rather than I/O.
|
|
1681
|
+
// A decision rather than I/O. `replayEffect` hands a recorded one to the Parallel's `op`, and
|
|
1682
|
+
// `runBranches` reproduces it; with none recorded, the Parallel replays under timing.
|
|
1568
1683
|
if (entry && entry.command !== 'Parallel') throw timeParadox(step, entry.command);
|
|
1569
1684
|
if (entry) return resolveEntry(entry);
|
|
1570
1685
|
if (stoppedInProduction(step.path)) throw replayCutError(/** @type {string} */ (step.path));
|
|
@@ -1604,6 +1719,35 @@ const fromTrace = (traceLog, options = {}) => {
|
|
|
1604
1719
|
return { resolve, missing };
|
|
1605
1720
|
};
|
|
1606
1721
|
|
|
1722
|
+
/**
|
|
1723
|
+
* The error for a step a replay has no recorded outcome for, which it refuses to run live.
|
|
1724
|
+
* @param {ReplayStep} step
|
|
1725
|
+
* @param {((step: ReplayStep) => string) | undefined} describe - `fromTrace`'s `missing`; a Resolver has none
|
|
1726
|
+
* @param {number} droppedEntries - How many entries the trace dropped under `maxEntries`
|
|
1727
|
+
* @returns {Error}
|
|
1728
|
+
*/
|
|
1729
|
+
const missingStepError = (step, describe, droppedEntries) => {
|
|
1730
|
+
const what = describe ? describe(step) : `No recorded outcome for '${step.name}' at step ${step.index}`;
|
|
1731
|
+
// Why the step may be missing decides the fix. A capped trace needs a higher cap, since the step may be one
|
|
1732
|
+
// production ran; any other missing step may be one production never ran, and running it live against
|
|
1733
|
+
// production would do I/O production refused.
|
|
1734
|
+
const why =
|
|
1735
|
+
droppedEntries > 0
|
|
1736
|
+
? `The trace dropped ${droppedEntries} entries under maxEntries, so production may have run ` +
|
|
1737
|
+
'this step and the recorder not kept it; record the flow with a higher maxEntries to ' +
|
|
1738
|
+
'replay past it.'
|
|
1739
|
+
: 'Production may never have run it: a Command an onBeforeCommand hook vetoed leaves no ' +
|
|
1740
|
+
'entry, and neither does a step added since the recording or one the flow reaches now that ' +
|
|
1741
|
+
"it did not then. onMissing: 'execute' runs such a step for real, and after an added step " +
|
|
1742
|
+
'every step that follows it, since they all move to new paths, so pass it only where every ' +
|
|
1743
|
+
'Command the flow can still reach goes to a test double or only reads.';
|
|
1744
|
+
return replayError(`${what}; refusing to run the real Command. ${why}`, {
|
|
1745
|
+
command: step.name,
|
|
1746
|
+
index: step.index,
|
|
1747
|
+
path: step.path
|
|
1748
|
+
});
|
|
1749
|
+
};
|
|
1750
|
+
|
|
1607
1751
|
/**
|
|
1608
1752
|
* @typedef {Object} ReplayOptions
|
|
1609
1753
|
* @property {any} [context] - Context for `Ask`; defaults to the context a trace recorded.
|
|
@@ -1615,7 +1759,9 @@ const fromTrace = (traceLog, options = {}) => {
|
|
|
1615
1759
|
* reach a telemetry backend or a guardrail that performs I/O.
|
|
1616
1760
|
* @property {'throw' | 'execute'} [onMissing] - What to do when the resolver has no recording for a step.
|
|
1617
1761
|
* `'throw'` (default) fails the replay, which makes side effects impossible for the whole run.
|
|
1618
|
-
* `'execute'` runs the real Command,
|
|
1762
|
+
* `'execute'` runs the real Command, so pass it only where the Commands reach test doubles or only
|
|
1763
|
+
* read. A trace that dropped entries under `maxEntries` refuses it, since a step it lacks may be one
|
|
1764
|
+
* production ran.
|
|
1619
1765
|
* @property {(step: ReplayStep, outcome: ReplayOutcome | undefined) => void} [onResolved] - Observes each step.
|
|
1620
1766
|
*/
|
|
1621
1767
|
|
|
@@ -1652,6 +1798,7 @@ const fromTrace = (traceLog, options = {}) => {
|
|
|
1652
1798
|
*/
|
|
1653
1799
|
// `async` so a malformed trace arrives as a rejection rather than a synchronous throw.
|
|
1654
1800
|
const replayEffect = async (effect, traceOrResolver, options = {}) => {
|
|
1801
|
+
rejectUnknownOptions(options, 'replayEffect', ['context', 'fastRetry', 'hooks', 'onMissing', 'onResolved']);
|
|
1655
1802
|
const { fastRetry = true, hooks = false, onMissing = 'throw', onResolved } = options;
|
|
1656
1803
|
// A trace is data and a Resolver is a function, so nothing else is needed to tell them
|
|
1657
1804
|
// apart, including the bare entries array that `fromTrace` also accepts.
|
|
@@ -1661,10 +1808,22 @@ const replayEffect = async (effect, traceOrResolver, options = {}) => {
|
|
|
1661
1808
|
const { resolve, missing } = fromResolver
|
|
1662
1809
|
? { resolve: traceOrResolver, missing: undefined }
|
|
1663
1810
|
: fromTrace(traceOrResolver, { onEntry: (entry) => void reached.add(entry) });
|
|
1811
|
+
// Only a trace log carries metadata; a bare entries array and a Resolver carry none.
|
|
1812
|
+
const traceLog = fromResolver || Array.isArray(traceOrResolver) ? undefined : traceOrResolver;
|
|
1664
1813
|
// Defaults to the trace's context: an `Ask` gate replayed with another one takes another branch, and
|
|
1665
1814
|
// no paradox flags it.
|
|
1666
|
-
const
|
|
1667
|
-
|
|
1815
|
+
const context = options.context ?? traceLog?.context ?? {};
|
|
1816
|
+
// A trace capped by `maxEntries` lacks steps production ran, so running a missing step live repeats
|
|
1817
|
+
// production's I/O: a billing batch replayed that way charged and invoiced subscriptions again.
|
|
1818
|
+
const droppedEntries = Number(traceLog?.dropped) || 0;
|
|
1819
|
+
const capped = droppedEntries > 0;
|
|
1820
|
+
if (capped && onMissing === 'execute') {
|
|
1821
|
+
throw replayError(
|
|
1822
|
+
`The trace dropped ${droppedEntries} entries under maxEntries, so a step it lacks may be one ` +
|
|
1823
|
+
"production ran, and onMissing: 'execute' would run it again. Replay without it to stop at the " +
|
|
1824
|
+
'first missing step, or record the flow with a higher maxEntries.'
|
|
1825
|
+
);
|
|
1826
|
+
}
|
|
1668
1827
|
let index = 0;
|
|
1669
1828
|
// What `onResolved` threw. It stops the replay, which rejects with it, rather than counting as the
|
|
1670
1829
|
// Command failing, which would let a Retry ask for an attempt production never made. Cast rather than
|
|
@@ -1674,7 +1833,7 @@ const replayEffect = async (effect, traceOrResolver, options = {}) => {
|
|
|
1674
1833
|
/** @type {StepRunner} */
|
|
1675
1834
|
const onStep = async (name, type, op, path) => {
|
|
1676
1835
|
if (type === 'Parallel') {
|
|
1677
|
-
// A Parallel's step carries its recorded decision into `op`,
|
|
1836
|
+
// A Parallel's step carries its recorded decision into `op`, where `runBranches` reproduces it.
|
|
1678
1837
|
// It is not a Command: `index` still counts Commands, and `onResolved` still sees only them.
|
|
1679
1838
|
const outcome = resolve({ index, name, type, path });
|
|
1680
1839
|
return await op(outcome && 'result' in outcome ? outcome.result : undefined);
|
|
@@ -1690,18 +1849,7 @@ const replayEffect = async (effect, traceOrResolver, options = {}) => {
|
|
|
1690
1849
|
}
|
|
1691
1850
|
}
|
|
1692
1851
|
if (outcome === undefined) {
|
|
1693
|
-
if (onMissing !== 'execute')
|
|
1694
|
-
const what = missing ? missing(step) : `No recorded outcome for '${name}' at step ${step.index}`;
|
|
1695
|
-
// The option is named with its one safe use, since a step with no entry may be one production
|
|
1696
|
-
// never ran, and running it live would do I/O production refused.
|
|
1697
|
-
throw replayError(
|
|
1698
|
-
`${what}; refusing to run the real Command. Production may never have run it: a Command ` +
|
|
1699
|
-
'an onBeforeCommand hook vetoed leaves no entry, and neither does a step added since the ' +
|
|
1700
|
-
"recording. Pass onMissing: 'execute' to run unrecorded steps live only when the trace was " +
|
|
1701
|
-
'cut short, as by maxEntries.',
|
|
1702
|
-
{ command: name, index: step.index, path }
|
|
1703
|
-
);
|
|
1704
|
-
}
|
|
1852
|
+
if (onMissing !== 'execute') throw missingStepError(step, missing, droppedEntries);
|
|
1705
1853
|
return await op();
|
|
1706
1854
|
}
|
|
1707
1855
|
if ('error' in outcome) throw outcome.error;
|
|
@@ -1744,6 +1892,7 @@ const replayEffect = async (effect, traceOrResolver, options = {}) => {
|
|
|
1744
1892
|
* a caller who wants the unreached entries as data uses `replayEffect`.
|
|
1745
1893
|
*/
|
|
1746
1894
|
const timeTravel = async (flowFn, traceLog, options = {}) => {
|
|
1895
|
+
rejectUnknownOptions(options, 'timeTravel', ['log', 'context', 'version']);
|
|
1747
1896
|
const { log = console.log, context, version } = options;
|
|
1748
1897
|
const { initialInput, trace, flowName, version: traceVersion } = traceLog;
|
|
1749
1898
|
// `message` is non-enumerable on Error, so JSON.stringify alone would drop it. JSON.stringify throws on
|
|
@@ -1767,6 +1916,15 @@ const timeTravel = async (flowFn, traceLog, options = {}) => {
|
|
|
1767
1916
|
'installed as a hook finds the input only on a flow built with effectPipe.'
|
|
1768
1917
|
);
|
|
1769
1918
|
}
|
|
1919
|
+
// A replay tells steps apart by name, so two anonymous Commands that swapped places replay without complaint.
|
|
1920
|
+
const anonymous = trace.filter((e) => e.command === 'anonymous').length;
|
|
1921
|
+
if (anonymous > 0) {
|
|
1922
|
+
log(
|
|
1923
|
+
`Warning: ${anonymous} of the recorded steps are named 'anonymous', usually inline arrow Commands, so ` +
|
|
1924
|
+
'this replay cannot tell them apart and would not notice two of them trading places. Name them with ' +
|
|
1925
|
+
'a const or meta.name.'
|
|
1926
|
+
);
|
|
1927
|
+
}
|
|
1770
1928
|
// Parallel decisions are not narrated as steps, so the header counts Commands to match the lines below.
|
|
1771
1929
|
const commandCount = trace.filter((e) => !isDecisionEntry(e)).length;
|
|
1772
1930
|
const stepsText = commandCount === 1 ? 'step' : 'steps';
|
|
@@ -1797,8 +1955,9 @@ const timeTravel = async (flowFn, traceLog, options = {}) => {
|
|
|
1797
1955
|
log(`Replay finished with state: ${result.type}`);
|
|
1798
1956
|
log(result.type === 'Failure' ? `Error: ${format(result.error)}` : `Result: ${format(result.value)}`);
|
|
1799
1957
|
// After a replay error the flow never got past the divergence, and the error already names where it
|
|
1800
|
-
// split. Only a flow that ended on its own terms with steps left over is worth a warning.
|
|
1801
|
-
|
|
1958
|
+
// split. Only a flow that ended on its own terms with steps left over is worth a warning. Told by the
|
|
1959
|
+
// mark rather than the name, which a flow's own error can share.
|
|
1960
|
+
const haltedByReplay = result.type === 'Failure' && hasMark(result.error, replayFault);
|
|
1802
1961
|
if (unreached.length > 0 && !haltedByReplay) {
|
|
1803
1962
|
// Named, not just counted: the step a fix stopped issuing is usually the one under suspicion.
|
|
1804
1963
|
const names = unreached.map((e) => (hasPath(e) ? `${e.command} (path '${e.path}')` : e.command));
|
|
@@ -1808,6 +1967,8 @@ const timeTravel = async (flowFn, traceLog, options = {}) => {
|
|
|
1808
1967
|
return result;
|
|
1809
1968
|
};
|
|
1810
1969
|
|
|
1970
|
+
// #endregion
|
|
1971
|
+
|
|
1811
1972
|
export {
|
|
1812
1973
|
Success,
|
|
1813
1974
|
Failure,
|
|
@@ -1821,5 +1982,6 @@ export {
|
|
|
1821
1982
|
recorder,
|
|
1822
1983
|
recordEffect,
|
|
1823
1984
|
replayEffect,
|
|
1824
|
-
timeTravel
|
|
1985
|
+
timeTravel,
|
|
1986
|
+
commandName
|
|
1825
1987
|
};
|