pure-effect 0.10.0 → 0.12.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.d.ts CHANGED
@@ -27,14 +27,19 @@ export type CommandState<R, T, E = unknown, Ctx = unknown> = {
27
27
  * `Parallel` no argument is passed at all.
28
28
  */
29
29
  cmd: (signal?: AbortSignal) => Promise<R> | R;
30
- next: (result: R) => Effect<T, E, Ctx>;
30
+ /**
31
+ * Method syntax, not a function-typed property, on every state's `next`: `strictFunctionTypes`
32
+ * checks a property's parameter contravariantly, which made a `CommandState<never, ...>` (a `cmd`
33
+ * that only throws) unassignable to `Effect`. Method parameters are bivariant, so it is accepted.
34
+ */
35
+ next(result: R): Effect<T, E, Ctx>;
31
36
  meta?: CommandMeta;
32
37
  initialInput?: unknown;
33
38
  };
34
39
 
35
40
  export type AskState<T, E = unknown, Ctx = unknown> = {
36
41
  type: 'Ask';
37
- next: (context: Ctx) => Effect<T, E, Ctx>;
42
+ next(context: Ctx): Effect<T, E, Ctx>;
38
43
  initialInput?: unknown;
39
44
  };
40
45
 
@@ -44,11 +49,16 @@ export type RetryOptions = {
44
49
  backoff?: number;
45
50
  };
46
51
 
52
+ /**
53
+ * `E` is the error the node contributes to its pipeline, which for a Retry is the exhaustion failure:
54
+ * the interpreter never lets an inner attempt's error escape unwrapped, so the wrapped tree's own
55
+ * error type is consumed by the retry loop rather than exposed here.
56
+ */
47
57
  export type RetryState<T, E = unknown, Ctx = unknown> = {
48
58
  type: 'Retry';
49
- effect: Effect<T, E, Ctx>;
50
- options: RetryOptions;
51
- next: (value: T) => Effect<T, E, Ctx>;
59
+ effect: Effect<T, any, Ctx>;
60
+ options: RetryOptions & { onExhausted?: (error: RetryExhaustedError<any>) => Effect<T, any, Ctx> };
61
+ next(value: T): Effect<T, E, Ctx>;
52
62
  initialInput?: unknown;
53
63
  };
54
64
 
@@ -61,7 +71,7 @@ export type RetryExhaustedError<E = unknown> = {
61
71
  export type ParallelState<T extends readonly unknown[], R, E = unknown, Ctx = unknown> = {
62
72
  type: 'Parallel';
63
73
  effects: { [K in keyof T]: Effect<T[K], E, Ctx> };
64
- next: (values: [...T]) => Effect<R, E, Ctx>;
74
+ next(values: [...T]): Effect<R, E, Ctx>;
65
75
  initialInput?: unknown;
66
76
  };
67
77
 
@@ -93,10 +103,35 @@ export declare function Ask<T, E = unknown, Ctx = unknown>(
93
103
  next: (context: Ctx) => Effect<T, E, Ctx>
94
104
  ): AskState<T, E, Ctx>;
95
105
 
106
+ /**
107
+ * With `onExhausted`, the exhaustion failure never escapes: the fallback Effect runs instead, its
108
+ * success feeds `next`, and its failure propagates unwrapped, so the node's declared error is the
109
+ * fallback's own error type. `onExhausted` is a per-use option only; the global `retry` defaults in
110
+ * `EffectConfiguration` deliberately cannot carry one.
111
+ */
112
+ export declare function Retry<T, E = unknown, E2 = unknown, Ctx = unknown>(
113
+ effect: Effect<T, E, Ctx>,
114
+ options: RetryOptions & { onExhausted: (error: RetryExhaustedError<E>) => Effect<T, E2, Ctx> }
115
+ ): RetryState<T, E2, Ctx>;
116
+
117
+ /**
118
+ * Without `onExhausted`, the declared error type is `RetryExhaustedError<E>`, because that is what
119
+ * a Retry's Failure actually carries: on exhaustion the interpreter returns
120
+ * `Failure({ retryExhausted: true, lastError, attempts })` rather than the inner error itself,
121
+ * so `result.error.lastError` is where the wrapped tree's `E` survives.
122
+ */
96
123
  export declare function Retry<T, E = unknown, Ctx = unknown>(
97
124
  effect: Effect<T, E, Ctx>,
98
125
  options?: RetryOptions
99
- ): RetryState<T, E, Ctx>;
126
+ ): RetryState<T, RetryExhaustedError<E>, Ctx>;
127
+
128
+ /**
129
+ * `next` is optional and defaults to `(values) => Success(values)`, same as `Command`'s default,
130
+ * so a bare `Parallel(effects)` resolves to the ordered array of success values.
131
+ */
132
+ export declare function Parallel<T extends readonly unknown[], E = unknown, Ctx = unknown>(effects: {
133
+ [K in keyof T]: Effect<T[K], E, Ctx>;
134
+ }): ParallelState<[...T], [...T], E, Ctx>;
100
135
 
101
136
  export declare function Parallel<T extends readonly unknown[], R, E = unknown, Ctx = unknown>(
102
137
  effects: { [K in keyof T]: Effect<T[K], E, Ctx> },
@@ -376,29 +411,45 @@ export interface ReplayOptions<Ctx = unknown> {
376
411
  */
377
412
  onMissing?: 'throw' | 'execute';
378
413
  onResolved?: (step: ReplayStep, outcome: ReplayOutcome | undefined) => void;
379
- /**
380
- * Legacy matching mode, used only for a trace whose entries carry no `path`. `true` (default)
381
- * consumes entries positionally and raises a `TimeParadox` on divergence; `false` matches
382
- * per-Command FIFO queues, which used to be required for flows containing `Parallel`. Ignored
383
- * for a trace carrying paths, which is matched by path, and when a Resolver is supplied, since
384
- * that Resolver has already chosen how it matches.
385
- */
386
- strict?: boolean;
414
+ }
415
+
416
+ /**
417
+ * What a replay returns: the flow's own outcome and, for a trace, the recorded entries the flow
418
+ * never asked for. A flow that stops issuing Commands early mismatches nothing, so `result` can be a
419
+ * `Success` with steps left over; `unreached` is where that shows. It is absent for a Resolver,
420
+ * since only a trace knows what it holds.
421
+ */
422
+ export interface Replay<T, E = unknown> {
423
+ result: SuccessState<T> | FailureState<E>;
424
+ unreached: TraceEntry[];
387
425
  }
388
426
 
389
427
  /**
390
428
  * Pass a trace to replay it directly, or a Resolver when traces are stored in some other
391
429
  * shape. To observe a replay rather than change how it resolves, use `onResolved`.
392
430
  * A malformed trace rejects with a `ReplayError`.
431
+ *
432
+ * Three overloads: a trace yields `unreached`, a Resolver yields none, and a source only known
433
+ * as the union of the two (a wrapper forwarding whatever it was handed) yields it as optional.
393
434
  */
435
+ export declare function replayEffect<T, E = unknown, Ctx = unknown>(
436
+ effect: Effect<T, E, Ctx>,
437
+ trace: TraceLog | TraceEntry[],
438
+ options?: ReplayOptions<Ctx>
439
+ ): Promise<Replay<T, E>>;
440
+ export declare function replayEffect<T, E = unknown, Ctx = unknown>(
441
+ effect: Effect<T, E, Ctx>,
442
+ resolver: Resolver,
443
+ options?: ReplayOptions<Ctx>
444
+ ): Promise<Omit<Replay<T, E>, 'unreached'>>;
394
445
  export declare function replayEffect<T, E = unknown, Ctx = unknown>(
395
446
  effect: Effect<T, E, Ctx>,
396
447
  traceOrResolver: Resolver | TraceLog | TraceEntry[],
397
448
  options?: ReplayOptions<Ctx>
398
- ): Promise<SuccessState<T> | FailureState<E>>;
449
+ ): Promise<Omit<Replay<T, E>, 'unreached'> & { unreached?: TraceEntry[] }>;
399
450
 
400
451
  export declare function timeTravel<T, E = unknown, Ctx = unknown>(
401
452
  flowFn: (input: any) => Effect<T, E, Ctx>,
402
453
  traceLog: TraceLog,
403
- options?: { strict?: boolean; log?: (...args: any[]) => void; context?: Ctx; version?: string }
454
+ options?: { log?: (...args: any[]) => void; context?: Ctx; version?: string }
404
455
  ): Promise<SuccessState<T> | FailureState<E>>;
package/index.js CHANGED
@@ -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
@@ -89,7 +89,7 @@ const Command = (cmd, next = (/** @type {any} */ result) => Success(result), met
89
89
  });
90
90
 
91
91
  /**
92
- * The name a Command is known by: what a trace records, what strict replay matches on, and what a
92
+ * The name a Command is known by: what a trace records, what replay matches on, and what a
93
93
  * telemetry span is called.
94
94
  *
95
95
  * `meta.name` wins when it is a non-empty string. Otherwise the identity is the thunk's own `name`,
@@ -125,6 +125,10 @@ const Ask = (next) => ({ type: 'Ask', next });
125
125
  * @param {number} [options.attempts] - Max retries (not counting first try)
126
126
  * @param {number} [options.delay] - Ms before first retry
127
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.
128
132
  * @returns {RetryState}
129
133
  */
130
134
  const Retry = (effect, options = {}) => ({
@@ -145,10 +149,15 @@ const Retry = (effect, options = {}) => ({
145
149
  * signal runs to completion, so a branch's first Command can still write after a sibling has failed.
146
150
  *
147
151
  * @param {Effect[]} effects - Array of Effect trees to run concurrently
148
- * @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.
149
154
  * @returns {ParallelState}
150
155
  */
151
- 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
+ });
152
161
 
153
162
  /**
154
163
  * Describes a value for an error message, leading with the mistake it most likely is.
@@ -540,6 +549,9 @@ const runEffect =
540
549
  // Each attempt gets its own prefix, so the Commands of attempt 2 cannot be mistaken
541
550
  // for the Commands of attempt 1 when a trace is matched back up.
542
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;
543
555
 
544
556
  for (let attempt = 0; attempt <= attempts; attempt++) {
545
557
  if (attempt > 0) {
@@ -551,7 +563,7 @@ const runEffect =
551
563
  if (signal?.aborted) return Failure(parallelCancelled(), eff.initialInput);
552
564
  const result = await execute(eff.effect, signal, `${retryPath}${attempt}/`);
553
565
  if (result.type === 'Success') {
554
- eff = eff.next(result.value);
566
+ eff = retryNext(result.value);
555
567
  succeeded = true;
556
568
  break;
557
569
  }
@@ -559,7 +571,23 @@ const runEffect =
559
571
  }
560
572
 
561
573
  if (!succeeded) {
562
- 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);
563
591
  }
564
592
  continue;
565
593
  }
@@ -712,6 +740,8 @@ const serializeError = (e, withStack) => {
712
740
  /** @type {any} */
713
741
  const out = { __error: true, name: e.name, message: e.message };
714
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);
715
745
  for (const k of Object.keys(e)) out[k] = /** @type {any} */ (e)[k];
716
746
  return out;
717
747
  };
@@ -725,9 +755,17 @@ const serializeError = (e, withStack) => {
725
755
  const reviveError = (v) => {
726
756
  if (!v || typeof v !== 'object' || v.__error !== true) return v;
727
757
  const e = new Error(v.message);
728
- e.name = v.name;
758
+ // `name` and `cause` are non-enumerable on a native Error (`name` is inherited, `cause` is set by the
759
+ // constructor), so they are defined the same way here. Assigning them would make them own enumerable
760
+ // keys, and the revived error would then never compare deep-equal to the one the Command threw, which
761
+ // is what the record-then-replay determinism check relies on. Everything else was enumerable on the
762
+ // original, since `serializeError` read it through `Object.keys`, so plain assignment is faithful.
763
+ const hidden = { enumerable: false, configurable: true, writable: true };
764
+ Object.defineProperty(e, 'name', { ...hidden, value: v.name });
729
765
  for (const [k, val] of Object.entries(v)) {
730
- if (k !== '__error' && k !== 'name' && k !== 'message') /** @type {any} */ (e)[k] = val;
766
+ if (k === '__error' || k === 'name' || k === 'message') continue;
767
+ if (k === 'cause') Object.defineProperty(e, 'cause', { ...hidden, value: reviveError(val) });
768
+ else /** @type {any} */ (e)[k] = val;
731
769
  }
732
770
  return e;
733
771
  };
@@ -876,36 +914,36 @@ const entryToOutcome = (entry) => ('error' in entry ? { error: reviveError(entry
876
914
  *
877
915
  * Path matching is used whenever every entry carries a `path`, which is every trace this
878
916
  * version records. A path is the Command's position in the Effect tree, so it is order-independent
879
- * and still paradox-detecting: it works for `Parallel` without `strict: false`, and it tells two
880
- * branches that call the same Command apart by where they are rather than by which finished first.
881
- * `strict` does not apply, since path matching is neither positional nor lenient.
917
+ * and still paradox-detecting: it works for `Parallel` as it is, and it tells two branches that
918
+ * call the same Command apart by where they are rather than by which finished first.
882
919
  *
883
- * The two older modes remain for traces recorded before paths existed. Strict matching consumes
884
- * entries in recorded order and verifies the flow asks for the Command the trace expects. Name
885
- * matching resolves per-Command FIFO queues, which was the only option for `Parallel` and is the
886
- * mode that could pair a branch with a sibling's result when both called the same Command.
920
+ * A trace whose entries carry no path (recorded before paths existed, or written by hand) is
921
+ * matched positionally: entries are consumed in recorded order and each must name the Command the
922
+ * flow asks for. That is exact for sequential flows and cannot tell `Parallel` branches apart, which
923
+ * is what paths were added to fix.
887
924
  *
888
925
  * @param {TraceLog | TraceEntry[]} traceLog - A reference-format trace, or a bare array of entries
889
926
  * @param {Object} [options]
890
- * @param {boolean} [options.strict] - Positional matching with paradox detection (default `true`).
891
- * Ignored for a trace carrying paths.
927
+ * @param {(entry: TraceEntry) => void} [options.onEntry] - Observes each entry as it is handed to a step,
928
+ * which is how `replayEffect` learns which recorded entries the flow never asked for.
892
929
  * @returns {Resolver}
893
930
  */
894
931
  const fromTrace = (traceLog, options = {}) => {
895
- const { strict = true } = options;
932
+ const { onEntry } = options;
896
933
  const entries = Array.isArray(traceLog) ? traceLog : traceLog?.trace;
897
934
  if (!Array.isArray(entries)) throw replayError('Trace has no `trace` array.');
935
+ const resolveEntry = (/** @type {TraceEntry} */ entry) => {
936
+ if (onEntry) onEntry(entry);
937
+ return entryToOutcome(entry);
938
+ };
898
939
 
899
- // Paths win when the trace has them, whatever `strict` says: a caller who passed `strict: false`
900
- // to work around Parallel should get the correct matching once their traces carry paths, rather
901
- // than keeping the mode that option existed to work around.
902
940
  if (entries.length > 0 && entries.every((e) => typeof e.path === 'string')) {
903
941
  const byPath = new Map(entries.map((e) => [e.path, e]));
904
942
  // Paths are derived from tree position, so they are unique by construction. A collision means
905
943
  // either a hand-built trace or a bug in path derivation, and silently keeping the last entry
906
944
  // would reintroduce exactly the wrong-result-per-branch failure paths exist to prevent.
907
945
  if (byPath.size !== entries.length) {
908
- throw replayError('Trace has duplicate step paths, so it cannot be matched by path.');
946
+ throw replayError('Trace has duplicate step paths.');
909
947
  }
910
948
  return (step) => {
911
949
  const entry = byPath.get(step.path);
@@ -916,29 +954,24 @@ const fromTrace = (traceLog, options = {}) => {
916
954
  });
917
955
  }
918
956
  if (entry.command !== step.name) throw timeParadox(step, entry.command);
919
- return entryToOutcome(entry);
920
- };
921
- }
922
-
923
- if (strict) {
924
- return (step) => {
925
- const entry = entries[step.index];
926
- if (!entry) throw replayError(`Trace exhausted: no entry #${step.index} for '${step.name}'.`);
927
- if (entry.command !== step.name) throw timeParadox(step, entry.command);
928
- return entryToOutcome(entry);
957
+ return resolveEntry(entry);
929
958
  };
930
959
  }
931
960
 
932
- /** @type {Map<string, TraceEntry[]>} */
933
- const queues = new Map();
934
- for (const entry of entries) {
935
- if (!queues.has(entry.command)) queues.set(entry.command, []);
936
- /** @type {TraceEntry[]} */ (queues.get(entry.command)).push(entry);
937
- }
938
961
  return (step) => {
939
- const queue = queues.get(step.name);
940
- if (!queue || queue.length === 0) throw replayError(`No recorded result left for '${step.name}'.`);
941
- return entryToOutcome(/** @type {TraceEntry} */ (queue.shift()));
962
+ // Positional matching pairs steps by completion order, which is exactly what makes Parallel
963
+ // branches indistinguishable. Refusing is better than a result that is right only when the
964
+ // replay happens to finish in production's order.
965
+ if (typeof step.path === 'string' && /p\d+\//.test(step.path)) {
966
+ throw replayError(
967
+ `Trace carries no paths, so '${step.name}' inside a Parallel cannot be matched positionally.`,
968
+ { command: step.name, path: step.path }
969
+ );
970
+ }
971
+ const entry = entries[step.index];
972
+ if (!entry) throw replayError(`Trace exhausted: no entry #${step.index} for '${step.name}'.`);
973
+ if (entry.command !== step.name) throw timeParadox(step, entry.command);
974
+ return resolveEntry(entry);
942
975
  };
943
976
  };
944
977
 
@@ -956,13 +989,21 @@ const fromTrace = (traceLog, options = {}) => {
956
989
  const zeroRetryDelays = (eff) => {
957
990
  if (!eff || typeof eff.type !== 'string') return eff;
958
991
  switch (eff.type) {
959
- case 'Retry':
992
+ case 'Retry': {
993
+ const options = { ...eff.options, delay: 0, backoff: 1 };
994
+ // The fallback tree only exists once onExhausted runs, so it is rewritten lazily too;
995
+ // otherwise a Retry inside the fallback keeps its production backoff during replay.
996
+ if (typeof options.onExhausted === 'function') {
997
+ const original = options.onExhausted;
998
+ options.onExhausted = (/** @type {any} */ error) => zeroRetryDelays(original(error));
999
+ }
960
1000
  return {
961
1001
  ...eff,
962
- options: { ...eff.options, delay: 0, backoff: 1 },
1002
+ options,
963
1003
  effect: zeroRetryDelays(eff.effect),
964
1004
  next: (/** @type {any} */ value) => zeroRetryDelays(eff.next(value))
965
1005
  };
1006
+ }
966
1007
  case 'Command':
967
1008
  return { ...eff, next: (/** @type {any} */ result) => zeroRetryDelays(eff.next(result)) };
968
1009
  case 'Ask':
@@ -988,10 +1029,19 @@ const zeroRetryDelays = (eff) => {
988
1029
  * `'throw'` (default) fails the replay, which makes side effects impossible for the whole run.
989
1030
  * `'execute'` runs the real Command, giving partial replay: recorded prefix, live tail.
990
1031
  * @property {(step: ReplayStep, outcome: ReplayOutcome | undefined) => void} [onResolved] - Observes each step.
991
- * @property {boolean} [strict] - Matching mode used when a trace is passed instead of a Resolver.
992
- * `true` (default) consumes entries positionally and raises a `TimeParadox` on divergence;
993
- * `false` matches per-Command FIFO queues, which is required for flows containing `Parallel`.
994
- * Ignored when a Resolver is supplied, since that Resolver has already chosen how it matches.
1032
+ */
1033
+
1034
+ /**
1035
+ * What a replay returns: the flow's own outcome, and, when a trace was supplied, the recorded
1036
+ * entries the flow never asked for. `unreached` is absent for a Resolver, since only a trace
1037
+ * knows what it holds.
1038
+ *
1039
+ * A flow that stops issuing Commands before its recording ends mismatches nothing, so no
1040
+ * `TimeParadox` fires and `result` can be a `Success` with recorded steps left over. That is
1041
+ * sometimes the point of a fix and sometimes a fix that quietly dropped a step; `unreached` is
1042
+ * the only place the difference shows, which is why it travels with the result rather than
1043
+ * behind an option.
1044
+ * @typedef {{ result: SuccessState | FailureState, unreached?: TraceEntry[] }} Replay
995
1045
  */
996
1046
 
997
1047
  /**
@@ -1011,15 +1061,20 @@ const zeroRetryDelays = (eff) => {
1011
1061
  * Resolver supplying each Command's recorded outcome (`undefined` if it has none). Write a Resolver when
1012
1062
  * traces are stored in some other shape; to observe a replay without one, use `onResolved`.
1013
1063
  * @param {ReplayOptions} [options]
1014
- * @returns {Promise<SuccessState | FailureState>}
1064
+ * @returns {Promise<Replay>} `{ result, unreached }` for a trace, `{ result }` for a Resolver
1015
1065
  */
1016
1066
  // `async` so a malformed trace arrives as a rejection rather than a synchronous throw:
1017
1067
  // the function otherwise returns a promise, and callers should not have to handle both.
1018
1068
  const replayEffect = async (effect, traceOrResolver, options = {}) => {
1019
- const { context = {}, fastRetry = true, hooks = false, onMissing = 'throw', onResolved, strict = true } = options;
1069
+ const { context = {}, fastRetry = true, hooks = false, onMissing = 'throw', onResolved } = options;
1020
1070
  // A trace is data and a Resolver is a function, so nothing else is needed to tell them
1021
1071
  // apart, including the bare entries array that `fromTrace` also accepts.
1022
- const resolve = typeof traceOrResolver === 'function' ? traceOrResolver : fromTrace(traceOrResolver, { strict });
1072
+ const fromResolver = typeof traceOrResolver === 'function';
1073
+ /** @type {Set<TraceEntry>} */
1074
+ const reached = new Set();
1075
+ const resolve = fromResolver
1076
+ ? traceOrResolver
1077
+ : fromTrace(traceOrResolver, { onEntry: (entry) => void reached.add(entry) });
1023
1078
  let index = 0;
1024
1079
 
1025
1080
  /** @type {StepRunner} */
@@ -1048,25 +1103,29 @@ const replayEffect = async (effect, traceOrResolver, options = {}) => {
1048
1103
  callConfig.onBeforeCommand = async () => {};
1049
1104
  }
1050
1105
 
1051
- return runEffect(fastRetry ? zeroRetryDelays(effect) : effect, context, callConfig);
1106
+ const result = await runEffect(fastRetry ? zeroRetryDelays(effect) : effect, context, callConfig);
1107
+ if (fromResolver) return { result };
1108
+ // `fromTrace` has already validated the shape, so the entries are here in one form or the other.
1109
+ const entries = Array.isArray(traceOrResolver) ? traceOrResolver : traceOrResolver.trace;
1110
+ return { result, unreached: entries.filter((entry) => !reached.has(entry)) };
1052
1111
  };
1053
1112
 
1054
1113
  /**
1055
1114
  * Replays a reference-format trace and narrates each step. Rebuilds the flow from the
1056
- * recorded input, reports the outcome, and warns when recorded steps were never
1115
+ * recorded input, reports the outcome, and names any recorded steps that were never
1057
1116
  * reached, which means the current code issued fewer Commands than production did.
1058
1117
  *
1059
1118
  * @param {(input: any) => Effect} flowFn - The same flow function that produced the trace
1060
1119
  * @param {TraceLog} traceLog - A trace from `recordEffect` or a `recorder`
1061
1120
  * @param {Object} [options]
1062
- * @param {boolean} [options.strict] - Positional matching with paradox detection; set `false` for `Parallel`
1063
1121
  * @param {(...args: any[]) => void} [options.log] - Defaults to `console.log`
1064
1122
  * @param {any} [options.context] - Overrides the context stored on the trace
1065
1123
  * @param {string} [options.version] - Current build id; warns when it differs from the trace's
1066
- * @returns {Promise<SuccessState | FailureState>}
1124
+ * @returns {Promise<SuccessState | FailureState>} The flow's outcome. Narration is the point of this function;
1125
+ * a caller who wants the unreached entries as data uses `replayEffect`.
1067
1126
  */
1068
1127
  const timeTravel = async (flowFn, traceLog, options = {}) => {
1069
- const { strict = true, log = console.log, context, version } = options;
1128
+ const { log = console.log, context, version } = options;
1070
1129
  const { initialInput, trace, flowName, version: traceVersion } = traceLog;
1071
1130
  // `message` and `stack` are non-enumerable on Error, so JSON.stringify alone would
1072
1131
  // drop the most useful line of the report.
@@ -1084,22 +1143,19 @@ const timeTravel = async (flowFn, traceLog, options = {}) => {
1084
1143
 
1085
1144
  // Narration goes through `onResolved` rather than a wrapped Resolver: observing each
1086
1145
  // step is all this needs, and `replayEffect` already resolves the trace itself.
1087
- let consumed = 0;
1088
1146
  // A path maps a replayed step back to its recorded entry whatever order branches finished in, so
1089
- // timings are narrated for Parallel too. Positional lookup is the fallback for a legacy trace with
1090
- // no paths, and only under strict matching, since per-Command queues carry no position.
1147
+ // timings are narrated for Parallel too. Positional lookup is the fallback for a trace with no paths,
1148
+ // which is how such a trace is matched anyway.
1091
1149
  const byPath = new Map(trace.filter((e) => typeof e.path === 'string').map((e) => [e.path, e]));
1092
1150
  const timing = (/** @type {ReplayStep} */ step) => {
1093
- const recorded = byPath.get(step.path) ?? (strict ? trace[step.index] : undefined);
1151
+ const recorded = byPath.get(step.path) ?? trace[step.index];
1094
1152
  return typeof recorded?.durationMs === 'number' ? ` in ${recorded.durationMs}ms` : '';
1095
1153
  };
1096
- const result = await replayEffect(flowFn(initialInput), traceLog, {
1097
- strict,
1154
+ const replay = await replayEffect(flowFn(initialInput), traceLog, {
1098
1155
  context: context !== undefined ? context : traceLog.context || {},
1099
1156
  onResolved: (step, outcome) => {
1100
1157
  // Resolving from a trace either answers or throws, so this is never undefined.
1101
1158
  if (outcome === undefined) return;
1102
- consumed++;
1103
1159
  log(
1104
1160
  'error' in outcome
1105
1161
  ? `Step ${step.index + 1}: ${step.name} threw${timing(step)} ${format(outcome.error)}`
@@ -1107,11 +1163,21 @@ const timeTravel = async (flowFn, traceLog, options = {}) => {
1107
1163
  );
1108
1164
  }
1109
1165
  });
1166
+ const { result, unreached = [] } = replay;
1110
1167
 
1111
1168
  log(`Replay finished with state: ${result.type}`);
1112
1169
  log(result.type === 'Failure' ? `Error: ${format(result.error)}` : `Result: ${format(result.value)}`);
1113
- if (consumed < trace.length) {
1114
- log(`Warning: ${trace.length - consumed} recorded ${stepsText} were never reached. The flow diverged.`);
1170
+ // After a replay error the flow never got past the divergence, so the entries behind it are not
1171
+ // news; the error already names where the timeline split. Only a flow that ended on its own terms
1172
+ // with steps left over is worth a warning.
1173
+ const haltedByReplay = result.type === 'Failure' && /^(TimeParadox|ReplayError)$/.test(result.error?.name);
1174
+ if (unreached.length > 0 && !haltedByReplay) {
1175
+ // Named, not just counted: the step a fix stopped issuing is usually the one under suspicion.
1176
+ const names = unreached.map((e) =>
1177
+ typeof e.path === 'string' ? `${e.command} (path '${e.path}')` : e.command
1178
+ );
1179
+ const count = unreached.length === 1 ? '1 recorded step was' : `${unreached.length} recorded steps were`;
1180
+ log(`Warning: ${count} never reached: ${names.join(', ')}. The flow diverged.`);
1115
1181
  }
1116
1182
  return result;
1117
1183
  };
package/jsconfig.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "allowJs": true,
4
+ "checkJs": true,
5
+ "strict": true,
6
+ "noEmit": true,
7
+ "target": "es2022",
8
+ "module": "nodenext",
9
+ "moduleResolution": "nodenext",
10
+ "lib": ["es2022", "dom"]
11
+ },
12
+ "include": ["index.js", "test/all.js", "test/types.test-d.ts", "examples/*.js"]
13
+ }
package/package.json CHANGED
@@ -1,19 +1,19 @@
1
1
  {
2
2
  "name": "pure-effect",
3
- "version": "0.10.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.12.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",
10
- "format": "prettier --write index.js index.d.ts examples/*.js test/all.js README.md CLAUDE.md"
9
+ "test": "mocha && tsd && tsc -p jsconfig.json",
10
+ "format": "prettier --write index.js index.d.ts examples/*.js test/all.js test/types.test-d.ts 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",