pure-effect 0.11.0 → 0.13.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
@@ -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`,
@@ -221,8 +221,22 @@ const asEffect = (value, source) => {
221
221
  * @returns {Effect}
222
222
  */
223
223
  const chain = (effect, fn, initialInput) => {
224
+ // The outermost pipeline wins. A step can return a sub-pipeline whose nodes already carry that
225
+ // sub-pipeline's start, and an outer chain reaches every one of them through the wrapped `next`
226
+ // continuations, so overwriting here is what makes the whole tree, a Failure from any depth, and
227
+ // the root a hook-based recorder reads carry the input of the flow that was actually called. The
228
+ // previous rule, stamp only when unset, let a Retry- or Parallel-headed sub-pipeline leave its own
229
+ // input on the root, and a trace recorded from it rebuilt the flow from the wrong value. A Success
230
+ // is never stamped: nothing reads the input off a terminal value, and leaving it bare is what lets
231
+ // `assert.deepEqual(result, Success(v))` hold whatever shape the flow had.
224
232
  const withII = (/** @type {Effect} */ e) =>
225
- initialInput !== undefined && e.initialInput === undefined ? { ...e, initialInput } : e;
233
+ initialInput !== undefined && e.type !== 'Success' ? { ...e, initialInput } : e;
234
+
235
+ // A continuation that returned nothing has to be caught before `effect.type` is read. Reading it off
236
+ // `undefined` throws a bare TypeError, and since every continuation runs inside the interpreter's try
237
+ // block, that TypeError became a domain Failure: the flow bug took the business-error branch and no
238
+ // step was named. The `default` arm below never sees it, because the switch itself is what throws.
239
+ if (effect == null) return asEffect(effect, 'A continuation');
226
240
 
227
241
  switch (effect.type) {
228
242
  case 'Success':
@@ -260,7 +274,14 @@ const chain = (effect, fn, initialInput) => {
260
274
  const effectPipe = (...fns) => {
261
275
  return (start) => {
262
276
  const chainWithII = (/** @type {Effect} */ eff, /** @type {(v: any) => Effect} */ fn) => chain(eff, fn, start);
263
- return fns.reduce(chainWithII, /** @type {Effect} */ (Success(start)));
277
+ const tree = fns.reduce(chainWithII, /** @type {Effect} */ (Success(start)));
278
+ // One identity pass over the finished tree. `chain` re-stamps a sub-pipeline's nodes only as
279
+ // it wraps the continuations that lead to them, and the last step has no later step to do
280
+ // that wrapping, so a sub-pipeline returned by the last step would keep its own input on
281
+ // everything past its head. The pass wraps those continuations too, so every node reachable
282
+ // from here, a Failure at any depth included, carries this pipeline's `start`. `Success` is the
283
+ // identity step: it can never return a non-Effect, so no message is ever attributed to it.
284
+ return chain(tree, Success, start);
264
285
  };
265
286
  };
266
287
 
@@ -282,12 +303,7 @@ const defaultRunWrapper = async (effect, op, flowName) => await op();
282
303
  /** @type CommandInterceptor */
283
304
  const defaultCommandInterceptor = async (command, context) => {};
284
305
 
285
- let stepRunner = defaultStepRunner;
286
- let runWrapper = defaultRunWrapper;
287
- let commandInterceptor = defaultCommandInterceptor;
288
-
289
306
  const defaultRetryOptions = { attempts: 3, delay: 100, backoff: 1 };
290
- let retryDefaults = { ...defaultRetryOptions };
291
307
 
292
308
  /**
293
309
  * @typedef {Object} EffectConfiguration
@@ -298,63 +314,77 @@ let retryDefaults = { ...defaultRetryOptions };
298
314
  */
299
315
 
300
316
  /**
301
- * Configures the global behavior of the Effect runner, including the command interceptor and telemetry.
317
+ * A per-call configuration: an `EffectConfiguration` plus `inherit`. With `inherit: true` (the default)
318
+ * the call's hooks are added to the wiring `configureEffect` installed, by the merge `configureEffect`
319
+ * applies to several configurations: global outermost, interceptors in order, `retry` merging with the
320
+ * call winning. With `inherit: false` the global wiring is not consulted at all, so any slot the call
321
+ * leaves unset falls back to the library default. A per-call hook cannot replace a single global slot on
322
+ * its own; that was the previous default, and it is how a per-call recorder silently switched off an
323
+ * application's tracing.
302
324
  *
303
- * Several configurations can be passed and are merged, which is how independent concerns share the
304
- * one slot each hook has:
325
+ * @typedef {EffectConfiguration & { inherit?: boolean }} CallConfiguration
326
+ */
327
+
328
+ /** @type {EffectConfiguration[]} */
329
+ let layers = [];
330
+
331
+ /**
332
+ * The installed layers merged into one configuration, empty when nothing is configured. This is the
333
+ * only derived form of `layers`: `runEffect` reads it and merges the call's configuration over it,
334
+ * and a slot no layer defines is simply absent, so the library default is chosen at the one place the
335
+ * hook is used rather than held in a second set of variables kept in step by hand.
336
+ * @type {EffectConfiguration}
337
+ */
338
+ let globalConfig = {};
339
+
340
+ /** Recomputes the effective wiring from the installed layers, earlier layers outermost. */
341
+ const applyLayers = () => {
342
+ globalConfig = chainHooks(...layers);
343
+ };
344
+
345
+ /**
346
+ * Adds a configuration to the global wiring of the Effect runner: telemetry, the command interceptor,
347
+ * and retry defaults.
305
348
  *
306
- * configureEffect(telemetryHooks(), recordingHooks({ sink }))
349
+ * Each call adds one layer on top of those already installed and returns a function that removes that
350
+ * layer, wherever it sits by then. Layers merge the way several configurations passed to one call do:
351
+ * `onStep` and `onRun` are wrappers, so they nest with the earliest layer outermost and the latest
352
+ * closest to the Command; `onBeforeCommand` interceptors all run, in the order installed; and `retry`
353
+ * merges with later layers winning. So these are the same:
307
354
  *
308
- * `onStep` and `onRun` are wrappers, so they nest with the first configuration outermost and the last
309
- * closest to the Command. `onBeforeCommand` interceptors all run, in the order given, and `retry`
310
- * merges with later configurations winning.
355
+ * configureEffect(telemetryHooks(), recordingHooks({ sink }));
356
+ * configureEffect(telemetryHooks()); configureEffect(recordingHooks({ sink }));
311
357
  *
312
- * Merging happens per call and does not accumulate across calls: a later `configureEffect` still
313
- * replaces the previous wiring entirely, and calling it with nothing resets every slot to its default.
358
+ * Calling it with no arguments at all removes every layer. A call whose arguments are all `undefined`
359
+ * is a conditional install that installed nothing: it adds no layer and removes none.
314
360
  *
315
- * Returns a function that puts back whatever was installed when this call was made, so a caller can
316
- * install hooks without owning the wiring forever. That is what makes this usable from a test, a
317
- * request-scoped experiment, or a library that wraps this one: without it, installing anything means
318
- * clobbering the host's telemetry permanently, since there is no way to read the current wiring.
319
- * Restoring undoes this call only while its wiring is still in effect. If a later `configureEffect` has
320
- * run since, restoring does nothing rather than discarding that newer wiring, so two callers installing
321
- * and releasing in interleaved order cannot clobber each other.
361
+ * Layers replaced the previous one-slot rule, under which a later call displaced the earlier wiring and
362
+ * the returned function restored a snapshot, guarded so that interleaved installs could not clobber
363
+ * each other. Removing a layer needs no guard: A installs, B installs, A removes, and B is still there.
364
+ * It also means a library can install its own hooks without erasing its host's, which under the old rule
365
+ * was possible only if the host passed the library's configuration into its own call.
322
366
  *
323
- * @param {...(EffectConfiguration | undefined)} configs - Configurations to merge, outermost first
324
- * @returns {() => void} Restores the wiring that was in place before this call
367
+ * @param {...(EffectConfiguration | undefined)} configs - Configurations merged into one layer, outermost first
368
+ * @returns {() => void} Removes the layer this call added; a second call does nothing
325
369
  */
326
370
  const configureEffect = (...configs) => {
327
- const previousStepRunner = stepRunner;
328
- const previousRunWrapper = runWrapper;
329
- const previousCommandInterceptor = commandInterceptor;
330
- const previousRetryDefaults = retryDefaults;
331
-
332
- const options = chainHooks(...configs);
333
- stepRunner = options.onStep ? options.onStep : defaultStepRunner;
334
- runWrapper = options.onRun ? options.onRun : defaultRunWrapper;
335
- commandInterceptor = options.onBeforeCommand ? options.onBeforeCommand : defaultCommandInterceptor;
336
- retryDefaults = options.retry ? { ...defaultRetryOptions, ...options.retry } : defaultRetryOptions;
337
-
338
- const installedStepRunner = stepRunner;
339
- const installedRunWrapper = runWrapper;
340
- const installedCommandInterceptor = commandInterceptor;
341
- const installedRetryDefaults = retryDefaults;
342
-
371
+ // Only a call with no arguments at all is a reset. A call whose arguments are all absent, such as
372
+ // `configureEffect(flag ? hooks : undefined)`, is a conditional install that happened not to install
373
+ // anything, so it adds no layer and must not remove anyone else's.
374
+ if (configs.length === 0) {
375
+ layers = [];
376
+ applyLayers();
377
+ return () => {};
378
+ }
379
+ const present = configs.filter(Boolean);
380
+ if (present.length === 0) return () => {};
381
+ const layer = chainHooks(...present);
382
+ layers = [...layers, layer];
383
+ applyLayers();
343
384
  return () => {
344
- // Only undo an install that is still in effect. Without this check, two callers that install
345
- // and release in interleaved order silently discard each other's hooks: A installs, B installs,
346
- // A restores, and B's wiring is gone though B never released it.
347
- const unchanged =
348
- stepRunner === installedStepRunner &&
349
- runWrapper === installedRunWrapper &&
350
- commandInterceptor === installedCommandInterceptor &&
351
- retryDefaults === installedRetryDefaults;
352
- if (!unchanged) return;
353
-
354
- stepRunner = previousStepRunner;
355
- runWrapper = previousRunWrapper;
356
- commandInterceptor = previousCommandInterceptor;
357
- retryDefaults = previousRetryDefaults;
385
+ if (!layers.includes(layer)) return;
386
+ layers = layers.filter((l) => l !== layer);
387
+ applyLayers();
358
388
  };
359
389
  };
360
390
 
@@ -501,20 +531,32 @@ const runEffect =
501
531
  * Iterates through the Effect tree, executing Commands and handling async flow.
502
532
  * Ask effects are resolved synchronously with the context object.
503
533
  *
504
- * Per-call config takes precedence over global configureEffect defaults.
505
- * onRun fires exactly once per runEffect call. Retry attempts run inside that
534
+ * Per-call config is merged over the global configureEffect wiring, or over nothing under
535
+ * `inherit: false`. onRun fires exactly once per runEffect call. Retry attempts run inside that
506
536
  * single span rather than spawning their own, keeping telemetry non-duplicated.
507
537
  *
508
538
  * @param {Effect} effect - The Effect tree returned by a pipeline
509
539
  * @param {any} [context] - Optional context object. Passed to Ask continuations and the Command Interceptor.
510
- * @param {EffectConfiguration} [callConfig] - Per-call overrides; merged over global configureEffect defaults.
540
+ * @param {CallConfiguration} [callConfig] - Per-call configuration, added to the wiring `configureEffect`
541
+ * installed unless `inherit: false`, which ignores that wiring for this run.
511
542
  * @returns {Promise<SuccessState | FailureState>}
512
543
  */
513
544
  async function runEffect(effect, context = {}, callConfig = {}) {
514
- const localStepRunner = callConfig.onStep ? callConfig.onStep : stepRunner;
515
- const localRunWrapper = callConfig.onRun ? callConfig.onRun : runWrapper;
516
- const localCommandInterceptor = callConfig.onBeforeCommand ? callConfig.onBeforeCommand : commandInterceptor;
517
- const localRetryDefaults = callConfig.retry ? { ...retryDefaults, ...callConfig.retry } : retryDefaults;
545
+ const { inherit = true, ...local } = callConfig;
546
+ // A value that is not a boolean, such as a string left over from an older API, must not be
547
+ // coerced: `'false'` inheriting everything is exactly the silent behaviour this option removes.
548
+ if (typeof inherit !== 'boolean') {
549
+ throw new TypeError(`callConfig.inherit must be true or false, got ${JSON.stringify(inherit)}.`);
550
+ }
551
+ // The call's configuration is merged over the installed wiring, or over nothing when the call
552
+ // inherits nothing, by the same merge `configureEffect` applies to several configurations. Most
553
+ // calls carry no configuration of their own, and those use the installed wiring as it is.
554
+ const base = inherit ? globalConfig : {};
555
+ const resolved = Object.keys(local).length ? chainHooks(base, local) : base;
556
+ const localStepRunner = resolved.onStep || defaultStepRunner;
557
+ const localRunWrapper = resolved.onRun || defaultRunWrapper;
558
+ const localCommandInterceptor = resolved.onBeforeCommand || defaultCommandInterceptor;
559
+ const localRetryDefaults = { ...defaultRetryOptions, ...resolved.retry };
518
560
 
519
561
  /**
520
562
  * @param {Effect} eff
@@ -657,7 +699,17 @@ const runEffect =
657
699
  throw effectTypeError(eff, 'The flow');
658
700
  }
659
701
 
660
- return localRunWrapper(effect, () => execute(effect), context?.flowName || '');
702
+ // Every Failure leaves through here, including one a Parallel branch or a Retry fallback handed
703
+ // back. `chain` stamps only what passes through the continuations it wraps, and those subtrees
704
+ // are executed directly rather than reached through a continuation, so their Failures arrive
705
+ // carrying the subtree's own input or none. Restamping the outcome with the root's input is
706
+ // what makes "a Failure carries the input of the flow that was called" true at any depth.
707
+ const rootInput = effect?.initialInput;
708
+ const run = async () => {
709
+ const result = await execute(effect);
710
+ return result.type === 'Failure' && rootInput !== undefined ? Failure(result.error, rootInput) : result;
711
+ };
712
+ return localRunWrapper(effect, run, context?.flowName || '');
661
713
  };
662
714
 
663
715
  /**
@@ -755,11 +807,17 @@ const serializeError = (e, withStack) => {
755
807
  const reviveError = (v) => {
756
808
  if (!v || typeof v !== 'object' || v.__error !== true) return v;
757
809
  const e = new Error(v.message);
758
- e.name = v.name;
810
+ // `name` and `cause` are non-enumerable on a native Error (`name` is inherited, `cause` is set by the
811
+ // constructor), so they are defined the same way here. Assigning them would make them own enumerable
812
+ // keys, and the revived error would then never compare deep-equal to the one the Command threw, which
813
+ // is what the record-then-replay determinism check relies on. Everything else was enumerable on the
814
+ // original, since `serializeError` read it through `Object.keys`, so plain assignment is faithful.
815
+ const hidden = { enumerable: false, configurable: true, writable: true };
816
+ Object.defineProperty(e, 'name', { ...hidden, value: v.name });
759
817
  for (const [k, val] of Object.entries(v)) {
760
- if (k !== '__error' && k !== 'name' && k !== 'message') {
761
- /** @type {any} */ (e)[k] = k === 'cause' ? reviveError(val) : val;
762
- }
818
+ if (k === '__error' || k === 'name' || k === 'message') continue;
819
+ if (k === 'cause') Object.defineProperty(e, 'cause', { ...hidden, value: reviveError(val) });
820
+ else /** @type {any} */ (e)[k] = val;
763
821
  }
764
822
  return e;
765
823
  };
@@ -886,6 +944,8 @@ const recorder = (options = {}) => {
886
944
  const recordEffect = async (flowFn, initialInput, options = {}) => {
887
945
  const { context = {}, version, ...recorderOptions } = options;
888
946
  const rec = recorder(recorderOptions);
947
+ // The recorder is added to the global wiring, so recording inside an instrumented application keeps
948
+ // its spans.
889
949
  const result = await runEffect(flowFn(initialInput), context, { onStep: rec.onStep });
890
950
  return {
891
951
  result,
@@ -908,36 +968,36 @@ const entryToOutcome = (entry) => ('error' in entry ? { error: reviveError(entry
908
968
  *
909
969
  * Path matching is used whenever every entry carries a `path`, which is every trace this
910
970
  * 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.
971
+ * and still paradox-detecting: it works for `Parallel` as it is, and it tells two branches that
972
+ * call the same Command apart by where they are rather than by which finished first.
914
973
  *
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.
974
+ * A trace whose entries carry no path (recorded before paths existed, or written by hand) is
975
+ * matched positionally: entries are consumed in recorded order and each must name the Command the
976
+ * flow asks for. That is exact for sequential flows and cannot tell `Parallel` branches apart, which
977
+ * is what paths were added to fix.
919
978
  *
920
979
  * @param {TraceLog | TraceEntry[]} traceLog - A reference-format trace, or a bare array of entries
921
980
  * @param {Object} [options]
922
- * @param {boolean} [options.strict] - Positional matching with paradox detection (default `true`).
923
- * Ignored for a trace carrying paths.
981
+ * @param {(entry: TraceEntry) => void} [options.onEntry] - Observes each entry as it is handed to a step,
982
+ * which is how `replayEffect` learns which recorded entries the flow never asked for.
924
983
  * @returns {Resolver}
925
984
  */
926
985
  const fromTrace = (traceLog, options = {}) => {
927
- const { strict = true } = options;
986
+ const { onEntry } = options;
928
987
  const entries = Array.isArray(traceLog) ? traceLog : traceLog?.trace;
929
988
  if (!Array.isArray(entries)) throw replayError('Trace has no `trace` array.');
989
+ const resolveEntry = (/** @type {TraceEntry} */ entry) => {
990
+ if (onEntry) onEntry(entry);
991
+ return entryToOutcome(entry);
992
+ };
930
993
 
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
994
  if (entries.length > 0 && entries.every((e) => typeof e.path === 'string')) {
935
995
  const byPath = new Map(entries.map((e) => [e.path, e]));
936
996
  // Paths are derived from tree position, so they are unique by construction. A collision means
937
997
  // either a hand-built trace or a bug in path derivation, and silently keeping the last entry
938
998
  // would reintroduce exactly the wrong-result-per-branch failure paths exist to prevent.
939
999
  if (byPath.size !== entries.length) {
940
- throw replayError('Trace has duplicate step paths, so it cannot be matched by path.');
1000
+ throw replayError('Trace has duplicate step paths.');
941
1001
  }
942
1002
  return (step) => {
943
1003
  const entry = byPath.get(step.path);
@@ -948,29 +1008,24 @@ const fromTrace = (traceLog, options = {}) => {
948
1008
  });
949
1009
  }
950
1010
  if (entry.command !== step.name) throw timeParadox(step, entry.command);
951
- return entryToOutcome(entry);
1011
+ return resolveEntry(entry);
952
1012
  };
953
1013
  }
954
1014
 
955
- if (strict) {
956
- return (step) => {
957
- const entry = entries[step.index];
958
- if (!entry) throw replayError(`Trace exhausted: no entry #${step.index} for '${step.name}'.`);
959
- if (entry.command !== step.name) throw timeParadox(step, entry.command);
960
- return entryToOutcome(entry);
961
- };
962
- }
963
-
964
- /** @type {Map<string, TraceEntry[]>} */
965
- const queues = new Map();
966
- for (const entry of entries) {
967
- if (!queues.has(entry.command)) queues.set(entry.command, []);
968
- /** @type {TraceEntry[]} */ (queues.get(entry.command)).push(entry);
969
- }
970
1015
  return (step) => {
971
- const queue = queues.get(step.name);
972
- if (!queue || queue.length === 0) throw replayError(`No recorded result left for '${step.name}'.`);
973
- return entryToOutcome(/** @type {TraceEntry} */ (queue.shift()));
1016
+ // Positional matching pairs steps by completion order, which is exactly what makes Parallel
1017
+ // branches indistinguishable. Refusing is better than a result that is right only when the
1018
+ // replay happens to finish in production's order.
1019
+ if (typeof step.path === 'string' && /p\d+\//.test(step.path)) {
1020
+ throw replayError(
1021
+ `Trace carries no paths, so '${step.name}' inside a Parallel cannot be matched positionally.`,
1022
+ { command: step.name, path: step.path }
1023
+ );
1024
+ }
1025
+ const entry = entries[step.index];
1026
+ if (!entry) throw replayError(`Trace exhausted: no entry #${step.index} for '${step.name}'.`);
1027
+ if (entry.command !== step.name) throw timeParadox(step, entry.command);
1028
+ return resolveEntry(entry);
974
1029
  };
975
1030
  };
976
1031
 
@@ -1022,16 +1077,28 @@ const zeroRetryDelays = (eff) => {
1022
1077
  * @typedef {Object} ReplayOptions
1023
1078
  * @property {any} [context] - Context for `Ask`; pass the recorded context to reproduce a run faithfully.
1024
1079
  * @property {boolean} [fastRetry] - Strips Retry delays so a replay does not wait out production backoff.
1025
- * @property {boolean} [hooks] - Lets configured `onRun` and `onBeforeCommand` fire. Off by default, so a
1026
- * replay cannot reach a telemetry backend or a guardrail that performs I/O.
1080
+ * @property {boolean} [hooks] - Runs the replay inside the hooks `configureEffect` installed, with the
1081
+ * resolver innermost, so a configured `onStep` observes each replayed step and `onRun` and
1082
+ * `onBeforeCommand` fire. Off by default, which ignores the global hooks, so a replay cannot
1083
+ * reach a telemetry backend or a guardrail that performs I/O. Global `retry` defaults apply
1084
+ * either way, since a replay has to make the attempts production made.
1027
1085
  * @property {'throw' | 'execute'} [onMissing] - What to do when the resolver has no recording for a step.
1028
1086
  * `'throw'` (default) fails the replay, which makes side effects impossible for the whole run.
1029
1087
  * `'execute'` runs the real Command, giving partial replay: recorded prefix, live tail.
1030
1088
  * @property {(step: ReplayStep, outcome: ReplayOutcome | undefined) => void} [onResolved] - Observes each step.
1031
- * @property {boolean} [strict] - Matching mode used when a trace is passed instead of a Resolver.
1032
- * `true` (default) consumes entries positionally and raises a `TimeParadox` on divergence;
1033
- * `false` matches per-Command FIFO queues, which is required for flows containing `Parallel`.
1034
- * Ignored when a Resolver is supplied, since that Resolver has already chosen how it matches.
1089
+ */
1090
+
1091
+ /**
1092
+ * What a replay returns: the flow's own outcome, and, when a trace was supplied, the recorded
1093
+ * entries the flow never asked for. `unreached` is absent for a Resolver, since only a trace
1094
+ * knows what it holds.
1095
+ *
1096
+ * A flow that stops issuing Commands before its recording ends mismatches nothing, so no
1097
+ * `TimeParadox` fires and `result` can be a `Success` with recorded steps left over. That is
1098
+ * sometimes the point of a fix and sometimes a fix that quietly dropped a step; `unreached` is
1099
+ * the only place the difference shows, which is why it travels with the result rather than
1100
+ * behind an option.
1101
+ * @typedef {{ result: SuccessState | FailureState, unreached?: TraceEntry[] }} Replay
1035
1102
  */
1036
1103
 
1037
1104
  /**
@@ -1051,15 +1118,20 @@ const zeroRetryDelays = (eff) => {
1051
1118
  * Resolver supplying each Command's recorded outcome (`undefined` if it has none). Write a Resolver when
1052
1119
  * traces are stored in some other shape; to observe a replay without one, use `onResolved`.
1053
1120
  * @param {ReplayOptions} [options]
1054
- * @returns {Promise<SuccessState | FailureState>}
1121
+ * @returns {Promise<Replay>} `{ result, unreached }` for a trace, `{ result }` for a Resolver
1055
1122
  */
1056
1123
  // `async` so a malformed trace arrives as a rejection rather than a synchronous throw:
1057
1124
  // the function otherwise returns a promise, and callers should not have to handle both.
1058
1125
  const replayEffect = async (effect, traceOrResolver, options = {}) => {
1059
- const { context = {}, fastRetry = true, hooks = false, onMissing = 'throw', onResolved, strict = true } = options;
1126
+ const { context = {}, fastRetry = true, hooks = false, onMissing = 'throw', onResolved } = options;
1060
1127
  // A trace is data and a Resolver is a function, so nothing else is needed to tell them
1061
1128
  // apart, including the bare entries array that `fromTrace` also accepts.
1062
- const resolve = typeof traceOrResolver === 'function' ? traceOrResolver : fromTrace(traceOrResolver, { strict });
1129
+ const fromResolver = typeof traceOrResolver === 'function';
1130
+ /** @type {Set<TraceEntry>} */
1131
+ const reached = new Set();
1132
+ const resolve = fromResolver
1133
+ ? traceOrResolver
1134
+ : fromTrace(traceOrResolver, { onEntry: (entry) => void reached.add(entry) });
1063
1135
  let index = 0;
1064
1136
 
1065
1137
  /** @type {StepRunner} */
@@ -1081,32 +1153,38 @@ const replayEffect = async (effect, traceOrResolver, options = {}) => {
1081
1153
  return outcome.result;
1082
1154
  };
1083
1155
 
1084
- /** @type {EffectConfiguration} */
1085
- const callConfig = { onStep };
1086
- if (!hooks) {
1087
- callConfig.onRun = async (effect, op) => await op();
1088
- callConfig.onBeforeCommand = async () => {};
1089
- }
1090
-
1091
- return runEffect(fastRetry ? zeroRetryDelays(effect) : effect, context, callConfig);
1156
+ // Off, a replay ignores the global hooks, so it can reach neither a telemetry backend nor a guardrail
1157
+ // that performs I/O. On, it runs inside them with the resolver innermost, so a configured onStep
1158
+ // observes each replayed step and the Command still never executes. The global retry defaults are
1159
+ // kept either way: `inherit: false` would drop them with the hooks, and a Retry that production ran
1160
+ // under a configured `attempts` would then exhaust early on replay and report a Failure production
1161
+ // never saw. Retry shape is part of what a replay reproduces; the hooks are not.
1162
+ /** @type {CallConfiguration} */
1163
+ const callConfig = { onStep, inherit: hooks, retry: globalConfig.retry };
1164
+
1165
+ const result = await runEffect(fastRetry ? zeroRetryDelays(effect) : effect, context, callConfig);
1166
+ if (fromResolver) return { result };
1167
+ // `fromTrace` has already validated the shape, so the entries are here in one form or the other.
1168
+ const entries = Array.isArray(traceOrResolver) ? traceOrResolver : traceOrResolver.trace;
1169
+ return { result, unreached: entries.filter((entry) => !reached.has(entry)) };
1092
1170
  };
1093
1171
 
1094
1172
  /**
1095
1173
  * Replays a reference-format trace and narrates each step. Rebuilds the flow from the
1096
- * recorded input, reports the outcome, and warns when recorded steps were never
1174
+ * recorded input, reports the outcome, and names any recorded steps that were never
1097
1175
  * reached, which means the current code issued fewer Commands than production did.
1098
1176
  *
1099
1177
  * @param {(input: any) => Effect} flowFn - The same flow function that produced the trace
1100
1178
  * @param {TraceLog} traceLog - A trace from `recordEffect` or a `recorder`
1101
1179
  * @param {Object} [options]
1102
- * @param {boolean} [options.strict] - Positional matching with paradox detection; set `false` for `Parallel`
1103
1180
  * @param {(...args: any[]) => void} [options.log] - Defaults to `console.log`
1104
1181
  * @param {any} [options.context] - Overrides the context stored on the trace
1105
1182
  * @param {string} [options.version] - Current build id; warns when it differs from the trace's
1106
- * @returns {Promise<SuccessState | FailureState>}
1183
+ * @returns {Promise<SuccessState | FailureState>} The flow's outcome. Narration is the point of this function;
1184
+ * a caller who wants the unreached entries as data uses `replayEffect`.
1107
1185
  */
1108
1186
  const timeTravel = async (flowFn, traceLog, options = {}) => {
1109
- const { strict = true, log = console.log, context, version } = options;
1187
+ const { log = console.log, context, version } = options;
1110
1188
  const { initialInput, trace, flowName, version: traceVersion } = traceLog;
1111
1189
  // `message` and `stack` are non-enumerable on Error, so JSON.stringify alone would
1112
1190
  // drop the most useful line of the report.
@@ -1124,22 +1202,19 @@ const timeTravel = async (flowFn, traceLog, options = {}) => {
1124
1202
 
1125
1203
  // Narration goes through `onResolved` rather than a wrapped Resolver: observing each
1126
1204
  // step is all this needs, and `replayEffect` already resolves the trace itself.
1127
- let consumed = 0;
1128
1205
  // 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.
1206
+ // timings are narrated for Parallel too. Positional lookup is the fallback for a trace with no paths,
1207
+ // which is how such a trace is matched anyway.
1131
1208
  const byPath = new Map(trace.filter((e) => typeof e.path === 'string').map((e) => [e.path, e]));
1132
1209
  const timing = (/** @type {ReplayStep} */ step) => {
1133
- const recorded = byPath.get(step.path) ?? (strict ? trace[step.index] : undefined);
1210
+ const recorded = byPath.get(step.path) ?? trace[step.index];
1134
1211
  return typeof recorded?.durationMs === 'number' ? ` in ${recorded.durationMs}ms` : '';
1135
1212
  };
1136
- const result = await replayEffect(flowFn(initialInput), traceLog, {
1137
- strict,
1213
+ const replay = await replayEffect(flowFn(initialInput), traceLog, {
1138
1214
  context: context !== undefined ? context : traceLog.context || {},
1139
1215
  onResolved: (step, outcome) => {
1140
1216
  // Resolving from a trace either answers or throws, so this is never undefined.
1141
1217
  if (outcome === undefined) return;
1142
- consumed++;
1143
1218
  log(
1144
1219
  'error' in outcome
1145
1220
  ? `Step ${step.index + 1}: ${step.name} threw${timing(step)} ${format(outcome.error)}`
@@ -1147,11 +1222,21 @@ const timeTravel = async (flowFn, traceLog, options = {}) => {
1147
1222
  );
1148
1223
  }
1149
1224
  });
1225
+ const { result, unreached = [] } = replay;
1150
1226
 
1151
1227
  log(`Replay finished with state: ${result.type}`);
1152
1228
  log(result.type === 'Failure' ? `Error: ${format(result.error)}` : `Result: ${format(result.value)}`);
1153
- if (consumed < trace.length) {
1154
- log(`Warning: ${trace.length - consumed} recorded ${stepsText} were never reached. The flow diverged.`);
1229
+ // After a replay error the flow never got past the divergence, so the entries behind it are not
1230
+ // news; the error already names where the timeline split. Only a flow that ended on its own terms
1231
+ // with steps left over is worth a warning.
1232
+ const haltedByReplay = result.type === 'Failure' && /^(TimeParadox|ReplayError)$/.test(result.error?.name);
1233
+ if (unreached.length > 0 && !haltedByReplay) {
1234
+ // Named, not just counted: the step a fix stopped issuing is usually the one under suspicion.
1235
+ const names = unreached.map((e) =>
1236
+ typeof e.path === 'string' ? `${e.command} (path '${e.path}')` : e.command
1237
+ );
1238
+ const count = unreached.length === 1 ? '1 recorded step was' : `${unreached.length} recorded steps were`;
1239
+ log(`Warning: ${count} never reached: ${names.join(', ')}. The flow diverged.`);
1155
1240
  }
1156
1241
  return result;
1157
1242
  };
package/package.json CHANGED
@@ -1,19 +1,47 @@
1
1
  {
2
2
  "name": "pure-effect",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "Records what your business logic did in production and replays it anywhere: time-travel debugging for JavaScript and TypeScript, with zero dependencies. Business logic is plain data you can test without mocks.",
5
+ "keywords": [
6
+ "effect",
7
+ "effect-system",
8
+ "functional-core",
9
+ "imperative-shell",
10
+ "time-travel",
11
+ "replay",
12
+ "tracing",
13
+ "testing",
14
+ "zero-dependencies",
15
+ "typescript"
16
+ ],
5
17
  "type": "module",
6
18
  "exports": "./index.js",
7
19
  "types": "./index.d.ts",
20
+ "files": [
21
+ "index.js",
22
+ "index.d.ts",
23
+ "CHANGELOG.md"
24
+ ],
25
+ "sideEffects": false,
26
+ "engines": {
27
+ "node": ">=18"
28
+ },
8
29
  "scripts": {
9
- "test": "mocha && tsd && tsc -p tsconfig.check.json",
10
- "format": "prettier --write index.js index.d.ts examples/*.js test/all.js README.md CLAUDE.md"
30
+ "test": "mocha && tsd && tsc -p jsconfig.json",
31
+ "format": "prettier --write index.js index.d.ts examples/*.js test/all.js test/types.test-d.ts README.md CLAUDE.md CHANGELOG.md CONTRIBUTING.md"
11
32
  },
12
33
  "tsd": {
13
34
  "directory": "test"
14
35
  },
15
36
  "author": "Aycan Gulez",
16
37
  "homepage": "https://pure-effect.org",
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "git+https://github.com/aycangulez/pure-effect.git"
41
+ },
42
+ "bugs": {
43
+ "url": "https://github.com/aycangulez/pure-effect/issues"
44
+ },
17
45
  "license": "MIT",
18
46
  "devDependencies": {
19
47
  "@opentelemetry/api": "^1.9.1",
package/.prettierrc DELETED
@@ -1,12 +0,0 @@
1
- {
2
- "tabWidth": 4,
3
- "useTabs": false,
4
- "singleQuote": true,
5
- "semi": true,
6
- "trailingComma": "none",
7
- "jsxSingleQuote": true,
8
- "printWidth": 120,
9
- "arrowParens": "always",
10
- "endOfLine": "lf",
11
- "proseWrap": "never"
12
- }