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/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 it receives an `AbortSignal` that fires when a sibling branch fails, so I/O that
82
- * accepts one can be cancelled in flight. Outside a `Parallel` no argument is passed. The signal is the
83
- * first argument, so wrap a function that takes an optional one, as in `() => nanoid()`.
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
- if (!hasNext && !nextSkipped && !isOptionsObject(nextOrOptions)) {
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
- const options = hasNext || nextSkipped ? maybeOptions : nextOrOptions;
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, 'A continuation');
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, 'A continuation');
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, or Ask.
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 all run, in the order installed. So these
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` is an observer, so every interceptor runs in the order given. A hook
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
- * The interpreter stops the branch there, as production did. Only `fromTrace` raises it.
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, when a replay supplies one
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 replayError(
972
- `Time paradox at path '${branchPath}': the recorded run was cancelled by branch ` +
973
- `${recordedBranch}, but this Parallel has ${count}.`,
974
- { name: 'TimeParadox', path: branchPath, branch: recordedBranch }
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 replayError(
995
- `Time paradox at path '${branchPath}': the recorded run was cancelled by ` +
996
- `branch ${forced.branch}, which did not fail in this replay.`,
997
- { name: 'TimeParadox', path: branchPath, branch: forced.branch }
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 is passed only inside a Parallel. There it is still the first argument, so a function
1088
- // passed by name with an optional first parameter, `nanoid(size = 21)` say, takes the signal for it:
1089
- // a documented sharp edge. Async, so a hook always gets a promise, even from a synchronous function.
1090
- const op = async () => {
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
- value = await (signal ? cmd(signal) : cmd());
1093
- succeeded = true;
1094
- return value;
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: stop here, as production did.
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, although ` +
1122
- 'the Command returned a value. A hook has to return what op returns.'
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
- * Signals that the flow being replayed asked for a different Command than the trace
1217
- * recorded, which means the code has diverged from the recorded run.
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
- replayError(
1224
- `Time paradox at ${step.path !== undefined ? `path '${step.path}'` : `step ${step.index}`}: ` +
1225
- `flow asked for '${step.name}', trace recorded '${recorded}'`,
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
- * Builds an `onStep` hook that records what every Command returned, plus a packager
1382
- * for the reference trace format. Pass `onStep` to `runEffect` as per-call config. A recorder holds the steps
1383
- * of every run it sees, so installed with `configureEffect` for a whole application it mixes requests into one
1384
- * trace with duplicate paths, which a replay refuses; examples/recording-example.js gives each run its own.
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. With none recorded, the Parallel replays under timing.
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, giving partial replay: recorded prefix, live tail.
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 recorded = fromResolver || Array.isArray(traceOrResolver) ? undefined : traceOrResolver.context;
1667
- const context = options.context ?? recorded ?? {};
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`, which runs the branches under it.
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
- const haltedByReplay = result.type === 'Failure' && /^(TimeParadox|ReplayError)$/.test(result.error?.name);
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
  };