pure-effect 0.9.0 → 0.11.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
@@ -10,7 +10,7 @@
10
10
  /**
11
11
  * @typedef {{
12
12
  * type: 'Command',
13
- * cmd: () => Promise<any>|any,
13
+ * cmd: (signal?: AbortSignal) => Promise<any>|any,
14
14
  * next: (result: any) => Effect,
15
15
  * meta?: any,
16
16
  * initialInput?: any
@@ -28,7 +28,7 @@
28
28
  * @typedef {{
29
29
  * type: 'Retry',
30
30
  * effect: Effect,
31
- * options: { attempts?: number, delay?: number, backoff?: number },
31
+ * options: { attempts?: number, delay?: number, backoff?: number, onExhausted?: (error: any) => Effect },
32
32
  * next: (value: any) => Effect,
33
33
  * initialInput?: any
34
34
  * }} RetryState
@@ -70,7 +70,10 @@ const Failure = (error, initialInput) => ({
70
70
  /**
71
71
  * Represents a side effect to be executed later.
72
72
  *
73
- * @param {() => Promise<any>|any} cmd - The side-effect function to execute
73
+ * @param {(signal?: AbortSignal) => Promise<any>|any} cmd - The side-effect function to execute. Inside a
74
+ * `Parallel` branch it receives an `AbortSignal` that fires when a sibling branch fails, so I/O that
75
+ * accepts one can be cancelled in flight. Ignoring it is fine: the interpreter still refuses to start
76
+ * any later Command in a cancelled branch. Outside a `Parallel` no argument is passed.
74
77
  * @param {(result: any) => Effect} [next] - Receives the result of `cmd` and returns the next Effect.
75
78
  * Defaults to `(result) => Success(result)`, which is what most Commands want.
76
79
  * @param {CommandMeta} [meta] - Optional metadata, passed to `onBeforeCommand`. A string `meta.name`
@@ -122,6 +125,10 @@ const Ask = (next) => ({ type: 'Ask', next });
122
125
  * @param {number} [options.attempts] - Max retries (not counting first try)
123
126
  * @param {number} [options.delay] - Ms before first retry
124
127
  * @param {number} [options.backoff] - Multiplier applied to delay on each subsequent retry
128
+ * @param {(error: any) => Effect} [options.onExhausted] - Runs a fallback Effect when every attempt has
129
+ * failed, receiving `{ retryExhausted, lastError, attempts }`. The fallback's success feeds `next`
130
+ * exactly as the primary's would have; its failure propagates unwrapped. Per-use only, never a
131
+ * global default, and a fallback never starts in a `Parallel` branch a sibling has cancelled.
125
132
  * @returns {RetryState}
126
133
  */
127
134
  const Retry = (effect, options = {}) => ({
@@ -132,12 +139,25 @@ const Retry = (effect, options = {}) => ({
132
139
  });
133
140
 
134
141
  /**
135
- * Runs multiple Effect trees concurrently. If any effect fails, returns the first Failure by array order and skips next.
142
+ * Runs multiple Effect trees concurrently. The first branch to fail cancels its siblings, and that
143
+ * branch's Failure is what the Parallel returns; `next` is skipped. When several branches fail in the
144
+ * same tick, the first by array order wins.
145
+ *
146
+ * Cancellation is cooperative and works at two levels. A cancelled branch starts no further Commands,
147
+ * which needs nothing from the caller. Stopping the Command already in flight needs its thunk to accept
148
+ * the `AbortSignal` it is passed and hand it to whatever performs the I/O; a thunk that ignores the
149
+ * signal runs to completion, so a branch's first Command can still write after a sibling has failed.
150
+ *
136
151
  * @param {Effect[]} effects - Array of Effect trees to run concurrently
137
- * @param {(values: any[]) => Effect} next - Receives array of success values in order, returns next Effect
152
+ * @param {(values: any[]) => Effect} [next] - Receives array of success values in order, returns next Effect.
153
+ * Defaults to `(values) => Success(values)`, same as `Command`'s default.
138
154
  * @returns {ParallelState}
139
155
  */
140
- const Parallel = (effects, next) => ({ type: 'Parallel', effects, next });
156
+ const Parallel = (effects, next = (/** @type {any[]} */ values) => Success(values)) => ({
157
+ type: 'Parallel',
158
+ effects,
159
+ next
160
+ });
141
161
 
142
162
  /**
143
163
  * Describes a value for an error message, leading with the mistake it most likely is.
@@ -244,7 +264,13 @@ const effectPipe = (...fns) => {
244
264
  };
245
265
  };
246
266
 
247
- /** @typedef {(name: string, type: string, op: function) => Promise<any>} StepRunner */
267
+ /**
268
+ * Wraps one Command execution. `path` identifies the Command's position in the Effect tree rather than
269
+ * its position in completion order, so it is the same in a replay as it was in the recorded run even
270
+ * when `Parallel` branches finish in a different order. Hooks written before `path` existed take three
271
+ * parameters and are unaffected.
272
+ * @typedef {(name: string, type: string, op: function, path?: string) => Promise<any>} StepRunner
273
+ */
248
274
  /** @type StepRunner */
249
275
  const defaultStepRunner = async (name, type, op) => await op();
250
276
 
@@ -336,12 +362,14 @@ const configureEffect = (...configs) => {
336
362
  * @typedef {Object} StepStart
337
363
  * @property {string} name - `cmd.name`, or 'anonymous'.
338
364
  * @property {string} type - Always 'Command' today.
365
+ * @property {string} [path] - The Command's position in the Effect tree.
339
366
  */
340
367
 
341
368
  /**
342
369
  * @typedef {Object} StepEnd
343
370
  * @property {string} name
344
371
  * @property {string} type
372
+ * @property {string} [path]
345
373
  * @property {any} [result] - What the Command returned, when it succeeded.
346
374
  * @property {any} [error] - What it threw, when it did not.
347
375
  * @property {number} durationMs
@@ -355,11 +383,11 @@ const now = () => (typeof performance === 'object' ? performance.now() : Date.no
355
383
  * @param {(start: StepStart) => (end: StepEnd) => void} handler - Returns a finisher for the outcome
356
384
  * @returns {StepRunner}
357
385
  */
358
- const observeSteps = (handler) => async (name, type, op) => {
386
+ const observeSteps = (handler) => async (name, type, op, path) => {
359
387
  /** @type {((end: StepEnd) => void) | undefined} */
360
388
  let finish;
361
389
  try {
362
- finish = handler({ name, type });
390
+ finish = handler({ name, type, path });
363
391
  } catch {
364
392
  finish = undefined;
365
393
  }
@@ -374,10 +402,10 @@ const observeSteps = (handler) => async (name, type, op) => {
374
402
  const started = now();
375
403
  try {
376
404
  const result = await op();
377
- report({ name, type, result, durationMs: now() - started });
405
+ report({ name, type, path, result, durationMs: now() - started });
378
406
  return result;
379
407
  } catch (error) {
380
- report({ name, type, error, durationMs: now() - started });
408
+ report({ name, type, path, error, durationMs: now() - started });
381
409
  throw error;
382
410
  }
383
411
  };
@@ -411,7 +439,7 @@ const chainHooks = (...configs) => {
411
439
 
412
440
  if (steps.length) {
413
441
  merged.onStep = steps.reduceRight(
414
- (inner, outer) => (name, type, op) => outer(name, type, () => inner(name, type, op))
442
+ (inner, outer) => (name, type, op, path) => outer(name, type, () => inner(name, type, op, path), path)
415
443
  );
416
444
  }
417
445
  if (runs.length) {
@@ -428,6 +456,45 @@ const chainHooks = (...configs) => {
428
456
  return merged;
429
457
  };
430
458
 
459
+ /**
460
+ * The Failure a `Parallel` branch resolves to when a sibling branch has already failed.
461
+ *
462
+ * Branches are cancelled, not merely ignored: this is what `Parallel` returns for a branch that was
463
+ * still in flight, and it is deliberately distinguishable so it can be told apart from the genuine
464
+ * failure that triggered the cancellation.
465
+ * @returns {Error}
466
+ */
467
+ const parallelCancelled = () =>
468
+ Object.assign(new Error('Parallel branch cancelled.'), {
469
+ name: 'ParallelCancelled'
470
+ });
471
+
472
+ /**
473
+ * Sleeps, but gives up early when the surrounding branch is cancelled, so a sibling's failure does not
474
+ * have to wait out a retry backoff that is now pointless. Resolves either way: the caller's abort check
475
+ * is what turns a cancelled wait into a Failure.
476
+ *
477
+ * @param {number} ms
478
+ * @param {AbortSignal} [signal]
479
+ * @returns {Promise<void>}
480
+ */
481
+ const delayFor = (ms, signal) =>
482
+ new Promise((resolve) => {
483
+ if (!signal) {
484
+ setTimeout(resolve, ms);
485
+ return;
486
+ }
487
+ const onAbort = () => {
488
+ clearTimeout(timer);
489
+ resolve();
490
+ };
491
+ const timer = setTimeout(() => {
492
+ signal.removeEventListener('abort', onAbort);
493
+ resolve();
494
+ }, ms);
495
+ signal.addEventListener('abort', onAbort, { once: true });
496
+ });
497
+
431
498
  const runEffect =
432
499
  /**
433
500
  * The Interpreter
@@ -451,13 +518,25 @@ const runEffect =
451
518
 
452
519
  /**
453
520
  * @param {Effect} eff
521
+ * @param {AbortSignal} [signal] - Cancellation for this subtree, set for `Parallel` branches.
522
+ * A branch stops starting new Commands once it is aborted, and the signal is handed to
523
+ * each Command's thunk so I/O that accepts one can be cancelled in flight.
524
+ * @param {string} [path] - Prefix identifying this subtree's position in the Effect tree.
525
+ * Steps are numbered sequentially within a subtree and each `Parallel` branch and `Retry`
526
+ * attempt opens its own prefix, so a Command's full path depends only on the shape of the
527
+ * tree and not on the order branches happen to finish in. That is what lets a replay line
528
+ * a recorded step up with the step that asked for it.
454
529
  * @returns {Promise<SuccessState | FailureState>}
455
530
  */
456
- async function execute(eff) {
531
+ async function execute(eff, signal, path = '') {
532
+ let step = 0;
457
533
  while (
458
534
  eff &&
459
535
  (eff.type === 'Command' || eff.type === 'Ask' || eff.type === 'Retry' || eff.type === 'Parallel')
460
536
  ) {
537
+ // Checked before every node, which is what short-circuits a branch whose Commands
538
+ // ignore the signal: the one in flight cannot be stopped, but the next never starts.
539
+ if (signal?.aborted) return Failure(parallelCancelled(), eff.initialInput);
461
540
  if (eff.type === 'Ask') {
462
541
  eff = eff.next(context);
463
542
  continue;
@@ -467,14 +546,24 @@ const runEffect =
467
546
  const { attempts } = opts;
468
547
  let lastError;
469
548
  let succeeded = false;
549
+ // Each attempt gets its own prefix, so the Commands of attempt 2 cannot be mistaken
550
+ // for the Commands of attempt 1 when a trace is matched back up.
551
+ const retryPath = `${path}${step++}r`;
552
+ // Captured while `eff` is still narrowed to a Retry node: the loop reassigns `eff`
553
+ // on success, after which the checker cannot prove the fallback path still holds it.
554
+ const retryNext = eff.next;
470
555
 
471
556
  for (let attempt = 0; attempt <= attempts; attempt++) {
472
557
  if (attempt > 0) {
473
- await new Promise((r) => setTimeout(r, opts.delay * Math.pow(opts.backoff, attempt - 1)));
558
+ await delayFor(opts.delay * Math.pow(opts.backoff, attempt - 1), signal);
474
559
  }
475
- const result = await execute(eff.effect);
560
+ // Checked after the wait, so a branch cancelled mid-backoff stops here rather
561
+ // than buying one more attempt, and a sibling's failure does not pay for the
562
+ // rest of this branch's retry schedule.
563
+ if (signal?.aborted) return Failure(parallelCancelled(), eff.initialInput);
564
+ const result = await execute(eff.effect, signal, `${retryPath}${attempt}/`);
476
565
  if (result.type === 'Success') {
477
- eff = eff.next(result.value);
566
+ eff = retryNext(result.value);
478
567
  succeeded = true;
479
568
  break;
480
569
  }
@@ -482,22 +571,81 @@ const runEffect =
482
571
  }
483
572
 
484
573
  if (!succeeded) {
485
- return Failure({ retryExhausted: true, lastError, attempts }, eff.initialInput);
574
+ const exhausted = { retryExhausted: true, lastError, attempts };
575
+ const { onExhausted } = opts;
576
+ if (typeof onExhausted !== 'function') return Failure(exhausted, eff.initialInput);
577
+ // A cancelled branch must not start its fallback, for the same reason it starts
578
+ // no further Commands: recovery must not resurrect work a sibling's failure ended.
579
+ if (signal?.aborted) return Failure(parallelCancelled(), eff.initialInput);
580
+ // The fallback gets its own path prefix, so a fallback-path trace can never be
581
+ // confused with a success-path one when it is matched back up on replay.
582
+ const fallback = await execute(
583
+ asEffect(onExhausted(exhausted), "Retry option 'onExhausted'"),
584
+ signal,
585
+ `${retryPath}f/`
586
+ );
587
+ // A failing fallback propagates as-is: the flow's last word was the fallback's
588
+ // error, not the exhaustion it was already told about.
589
+ if (fallback.type === 'Failure') return fallback;
590
+ eff = retryNext(fallback.value);
486
591
  }
487
592
  continue;
488
593
  }
489
594
  if (eff.type === 'Parallel') {
490
- const results = await Promise.all(eff.effects.map((e) => execute(e)));
491
- const failure = results.find((r) => r.type === 'Failure');
595
+ // Branch prefixes come from the branch's index in the array, never from the order
596
+ // branches complete in, which is the whole point: two branches calling the same
597
+ // Command are told apart by where they are rather than by who finished first.
598
+ const branchPath = `${path}${step++}p`;
599
+ // One scope per Parallel, linked to the enclosing one so cancellation nests.
600
+ // Absent AbortController (very old runtimes), `branchSignal` stays undefined and
601
+ // the old run-everything-to-completion behaviour is what happens.
602
+ const scope = typeof AbortController === 'function' ? new AbortController() : undefined;
603
+ const branchSignal = scope?.signal;
604
+ const relay = () => scope?.abort();
605
+ if (signal && scope) {
606
+ if (signal.aborted) scope.abort();
607
+ else signal.addEventListener('abort', relay, { once: true });
608
+ }
609
+
610
+ /** @type {(SuccessState | FailureState)[]} */
611
+ const results = new Array(eff.effects.length);
612
+ // Which branch failed on its own account rather than because it was cancelled.
613
+ const triggered = new Array(eff.effects.length).fill(false);
614
+ try {
615
+ // Still awaits every branch, so no cancelled work is left running unobserved
616
+ // after the Failure is returned. Cancelled branches settle promptly; a branch
617
+ // whose in-flight Command ignores the signal is the one case that does not.
618
+ await Promise.all(
619
+ eff.effects.map(async (e, i) => {
620
+ const result = await execute(e, branchSignal, `${branchPath}${i}/`);
621
+ results[i] = result;
622
+ // Read-then-abort is atomic here, so exactly one branch is the trigger.
623
+ if (result.type === 'Failure' && !branchSignal?.aborted) {
624
+ triggered[i] = true;
625
+ scope?.abort();
626
+ }
627
+ })
628
+ );
629
+ } finally {
630
+ if (signal && scope) signal.removeEventListener('abort', relay);
631
+ }
632
+
633
+ const trigger = triggered.indexOf(true);
634
+ const failure = trigger >= 0 ? results[trigger] : results.find((r) => r.type === 'Failure');
492
635
  if (failure) return failure;
493
636
  eff = eff.next(results.map((r) => /** @type {SuccessState} */ (r).value));
494
637
  continue;
495
638
  }
496
639
  const cmdName = commandName(eff);
497
640
  const initialInput = eff.initialInput;
641
+ const cmdPath = `${path}${step++}`;
642
+ const cmd = eff.cmd;
643
+ // The signal reaches the thunk only inside a Parallel, so a thunk written to take a
644
+ // parameter is not handed an argument it never expected anywhere else.
645
+ const op = signal ? () => cmd(signal) : cmd;
498
646
  try {
499
647
  await localCommandInterceptor(eff, context);
500
- const result = await localStepRunner(cmdName, 'Command', eff.cmd);
648
+ const result = await localStepRunner(cmdName, 'Command', op, cmdPath);
501
649
  eff = eff.next(result);
502
650
  } catch (e) {
503
651
  // A malformed flow is a bug, not a domain failure, so it must not masquerade as one.
@@ -512,7 +660,12 @@ const runEffect =
512
660
  return localRunWrapper(effect, () => execute(effect), context?.flowName || '');
513
661
  };
514
662
 
515
- /** @typedef {{ index: number, name: string, type: string }} ReplayStep */
663
+ /**
664
+ * The step a replay is asking about. `path` is the Command's position in the Effect tree and is stable
665
+ * across runs; `index` is its position in this run's completion order, which is not stable for a flow
666
+ * containing `Parallel`. Prefer `path` when writing a Resolver.
667
+ * @typedef {{ index: number, name: string, type: string, path?: string }} ReplayStep
668
+ */
516
669
 
517
670
  /**
518
671
  * What production observed for a step. `{ result }` is handed to the Command's
@@ -527,7 +680,9 @@ const runEffect =
527
680
  /**
528
681
  * A recorded step. `durationMs` is how long the Command took in production, rounded to microseconds,
529
682
  * which is the one question a trace could not answer before: which step was slow.
530
- * @typedef {{ command: string, result?: any, error?: any, durationMs?: number }} TraceEntry
683
+ * `path` locates the Command in the Effect tree, which is what a replay matches on: it does not move
684
+ * when `Parallel` branches finish in a different order than they did in production.
685
+ * @typedef {{ command: string, path?: string, result?: any, error?: any, durationMs?: number }} TraceEntry
531
686
  */
532
687
 
533
688
  /**
@@ -560,12 +715,17 @@ const replayError = (message, props = {}) => Object.assign(new Error(message), {
560
715
  * @returns {Error}
561
716
  */
562
717
  const timeParadox = (step, recorded) =>
563
- replayError(`Time paradox at step ${step.index}: flow asked for '${step.name}', trace recorded '${recorded}'`, {
564
- name: 'TimeParadox',
565
- index: step.index,
566
- expected: recorded,
567
- actual: step.name
568
- });
718
+ replayError(
719
+ `Time paradox at ${step.path !== undefined ? `path '${step.path}'` : `step ${step.index}`}: ` +
720
+ `flow asked for '${step.name}', trace recorded '${recorded}'`,
721
+ {
722
+ name: 'TimeParadox',
723
+ index: step.index,
724
+ path: step.path,
725
+ expected: recorded,
726
+ actual: step.name
727
+ }
728
+ );
569
729
 
570
730
  /**
571
731
  * Converts a thrown value into something JSON can carry. `message` and `stack` are
@@ -580,6 +740,8 @@ const serializeError = (e, withStack) => {
580
740
  /** @type {any} */
581
741
  const out = { __error: true, name: e.name, message: e.message };
582
742
  if (withStack) out.stack = e.stack;
743
+ // `cause` is non-enumerable too, and it can itself be an Error, so it is carried recursively.
744
+ if ('cause' in e) out.cause = serializeError(e.cause, withStack);
583
745
  for (const k of Object.keys(e)) out[k] = /** @type {any} */ (e)[k];
584
746
  return out;
585
747
  };
@@ -595,7 +757,9 @@ const reviveError = (v) => {
595
757
  const e = new Error(v.message);
596
758
  e.name = v.name;
597
759
  for (const [k, val] of Object.entries(v)) {
598
- if (k !== '__error' && k !== 'name' && k !== 'message') /** @type {any} */ (e)[k] = val;
760
+ if (k !== '__error' && k !== 'name' && k !== 'message') {
761
+ /** @type {any} */ (e)[k] = k === 'cause' ? reviveError(val) : val;
762
+ }
599
763
  }
600
764
  return e;
601
765
  };
@@ -678,16 +842,17 @@ const recorder = (options = {}) => {
678
842
 
679
843
  // Built on `observe` so the hook contract lives in one place: `op` always runs, its result is
680
844
  // always returned, and its error always propagates.
681
- const onStep = observeSteps(({ name }) => (end) => {
845
+ const onStep = observeSteps(({ name, path }) => (end) => {
682
846
  const durationMs = Math.round(end.durationMs * 1000) / 1000;
683
847
  push(
684
848
  'error' in end
685
849
  ? {
686
850
  command: name,
851
+ path,
687
852
  error: snapshot(safeRedact(serializeError(end.error, stack), name, 'error')),
688
853
  durationMs
689
854
  }
690
- : { command: name, result: snapshot(safeRedact(end.result, name, 'result')), durationMs }
855
+ : { command: name, path, result: snapshot(safeRedact(end.result, name, 'result')), durationMs }
691
856
  );
692
857
  });
693
858
 
@@ -741,18 +906,21 @@ const entryToOutcome = (entry) => ('error' in entry ? { error: reviveError(entry
741
906
  * when handed a trace instead of a Resolver, and is the only caller. Callers with traces
742
907
  * in some other shape write a Resolver instead, which is the extension point.
743
908
  *
744
- * Strict matching consumes entries in recorded order and verifies that the flow asks
745
- * for the Command the trace expects, which is what detects divergence between the
746
- * recorded run and the code being replayed.
909
+ * Path matching is used whenever every entry carries a `path`, which is every trace this
910
+ * version records. A path is the Command's position in the Effect tree, so it is order-independent
911
+ * and still paradox-detecting: it works for `Parallel` without `strict: false`, and it tells two
912
+ * branches that call the same Command apart by where they are rather than by which finished first.
913
+ * `strict` does not apply, since path matching is neither positional nor lenient.
747
914
  *
748
- * Name matching is required for flows containing `Parallel`: branches run through
749
- * `Promise.all` and therefore complete out of array order, so recorded positions are
750
- * not stable. It resolves per-Command FIFO queues instead, which is order-independent
751
- * and still keeps duplicate Command names apart.
915
+ * The two older modes remain for traces recorded before paths existed. Strict matching consumes
916
+ * entries in recorded order and verifies the flow asks for the Command the trace expects. Name
917
+ * matching resolves per-Command FIFO queues, which was the only option for `Parallel` and is the
918
+ * mode that could pair a branch with a sibling's result when both called the same Command.
752
919
  *
753
920
  * @param {TraceLog | TraceEntry[]} traceLog - A reference-format trace, or a bare array of entries
754
921
  * @param {Object} [options]
755
- * @param {boolean} [options.strict] - Positional matching with paradox detection (default `true`)
922
+ * @param {boolean} [options.strict] - Positional matching with paradox detection (default `true`).
923
+ * Ignored for a trace carrying paths.
756
924
  * @returns {Resolver}
757
925
  */
758
926
  const fromTrace = (traceLog, options = {}) => {
@@ -760,6 +928,30 @@ const fromTrace = (traceLog, options = {}) => {
760
928
  const entries = Array.isArray(traceLog) ? traceLog : traceLog?.trace;
761
929
  if (!Array.isArray(entries)) throw replayError('Trace has no `trace` array.');
762
930
 
931
+ // Paths win when the trace has them, whatever `strict` says: a caller who passed `strict: false`
932
+ // to work around Parallel should get the correct matching once their traces carry paths, rather
933
+ // than keeping the mode that option existed to work around.
934
+ if (entries.length > 0 && entries.every((e) => typeof e.path === 'string')) {
935
+ const byPath = new Map(entries.map((e) => [e.path, e]));
936
+ // Paths are derived from tree position, so they are unique by construction. A collision means
937
+ // either a hand-built trace or a bug in path derivation, and silently keeping the last entry
938
+ // would reintroduce exactly the wrong-result-per-branch failure paths exist to prevent.
939
+ if (byPath.size !== entries.length) {
940
+ throw replayError('Trace has duplicate step paths, so it cannot be matched by path.');
941
+ }
942
+ return (step) => {
943
+ const entry = byPath.get(step.path);
944
+ if (!entry) {
945
+ throw replayError(`Trace has no step at path '${step.path}' for '${step.name}'.`, {
946
+ command: step.name,
947
+ path: step.path
948
+ });
949
+ }
950
+ if (entry.command !== step.name) throw timeParadox(step, entry.command);
951
+ return entryToOutcome(entry);
952
+ };
953
+ }
954
+
763
955
  if (strict) {
764
956
  return (step) => {
765
957
  const entry = entries[step.index];
@@ -796,13 +988,21 @@ const fromTrace = (traceLog, options = {}) => {
796
988
  const zeroRetryDelays = (eff) => {
797
989
  if (!eff || typeof eff.type !== 'string') return eff;
798
990
  switch (eff.type) {
799
- case 'Retry':
991
+ case 'Retry': {
992
+ const options = { ...eff.options, delay: 0, backoff: 1 };
993
+ // The fallback tree only exists once onExhausted runs, so it is rewritten lazily too;
994
+ // otherwise a Retry inside the fallback keeps its production backoff during replay.
995
+ if (typeof options.onExhausted === 'function') {
996
+ const original = options.onExhausted;
997
+ options.onExhausted = (/** @type {any} */ error) => zeroRetryDelays(original(error));
998
+ }
800
999
  return {
801
1000
  ...eff,
802
- options: { ...eff.options, delay: 0, backoff: 1 },
1001
+ options,
803
1002
  effect: zeroRetryDelays(eff.effect),
804
1003
  next: (/** @type {any} */ value) => zeroRetryDelays(eff.next(value))
805
1004
  };
1005
+ }
806
1006
  case 'Command':
807
1007
  return { ...eff, next: (/** @type {any} */ result) => zeroRetryDelays(eff.next(result)) };
808
1008
  case 'Ask':
@@ -863,8 +1063,8 @@ const replayEffect = async (effect, traceOrResolver, options = {}) => {
863
1063
  let index = 0;
864
1064
 
865
1065
  /** @type {StepRunner} */
866
- const onStep = async (name, type, op) => {
867
- const step = { index: index++, name, type };
1066
+ const onStep = async (name, type, op, path) => {
1067
+ const step = { index: index++, name, type, path };
868
1068
  const outcome = resolve(step);
869
1069
  if (onResolved) onResolved(step, outcome);
870
1070
  if (outcome === undefined) {
@@ -918,17 +1118,19 @@ const timeTravel = async (flowFn, traceLog, options = {}) => {
918
1118
  if (version && traceVersion && version !== traceVersion) {
919
1119
  log(`Warning: trace was recorded at ${traceVersion}, replaying against ${version}.`);
920
1120
  }
921
- const stepsText = (trace.length === 0 || trace.length > 1) ? 'steps' : 'step';
1121
+ const stepsText = trace.length === 0 || trace.length > 1 ? 'steps' : 'step';
922
1122
  log(`Replaying '${flowName || 'flow'}' (${trace.length} recorded ${stepsText})`);
923
1123
  log(`Initial input: ${format(initialInput)}`);
924
1124
 
925
1125
  // Narration goes through `onResolved` rather than a wrapped Resolver: observing each
926
1126
  // step is all this needs, and `replayEffect` already resolves the trace itself.
927
1127
  let consumed = 0;
928
- // Only strict matching maps a replayed step back to its recorded entry by position, so timings
929
- // are narrated there and omitted when per-Command queues are matching by name.
1128
+ // A path maps a replayed step back to its recorded entry whatever order branches finished in, so
1129
+ // timings are narrated for Parallel too. Positional lookup is the fallback for a legacy trace with
1130
+ // no paths, and only under strict matching, since per-Command queues carry no position.
1131
+ const byPath = new Map(trace.filter((e) => typeof e.path === 'string').map((e) => [e.path, e]));
930
1132
  const timing = (/** @type {ReplayStep} */ step) => {
931
- const recorded = strict ? trace[step.index] : undefined;
1133
+ const recorded = byPath.get(step.path) ?? (strict ? trace[step.index] : undefined);
932
1134
  return typeof recorded?.durationMs === 'number' ? ` in ${recorded.durationMs}ms` : '';
933
1135
  };
934
1136
  const result = await replayEffect(flowFn(initialInput), traceLog, {
package/package.json CHANGED
@@ -1,19 +1,19 @@
1
1
  {
2
2
  "name": "pure-effect",
3
- "version": "0.9.0",
4
- "description": "A zero-dependency effect library for JavaScript and TypeScript with time-travel debugging, dependency injection, retry, and OpenTelemetry, where business logic is plain data you can test without mocks.",
3
+ "version": "0.11.0",
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
  "type": "module",
6
6
  "exports": "./index.js",
7
7
  "types": "./index.d.ts",
8
8
  "scripts": {
9
- "test": "mocha && tsd",
9
+ "test": "mocha && tsd && tsc -p tsconfig.check.json",
10
10
  "format": "prettier --write index.js index.d.ts examples/*.js test/all.js README.md CLAUDE.md"
11
11
  },
12
12
  "tsd": {
13
13
  "directory": "test"
14
14
  },
15
15
  "author": "Aycan Gulez",
16
- "homepage": "https://github.com/aycangulez/pure-effect",
16
+ "homepage": "https://pure-effect.org",
17
17
  "license": "MIT",
18
18
  "devDependencies": {
19
19
  "@opentelemetry/api": "^1.9.1",