pure-effect 0.8.0 → 0.10.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
@@ -2,10 +2,15 @@
2
2
 
3
3
  /** @typedef {{ type: 'Success', value: any, initialInput?: any }} SuccessState */
4
4
  /** @typedef {{ type: 'Failure', error: any, initialInput?: any }} FailureState */
5
+ /**
6
+ * Metadata attached to a Command. A string `name` is read by the interpreter as the Command's
7
+ * identity; every other key is carried through untouched for `onBeforeCommand`.
8
+ * @typedef {{ name?: string } & Record<string, any>} CommandMeta
9
+ */
5
10
  /**
6
11
  * @typedef {{
7
12
  * type: 'Command',
8
- * cmd: () => Promise<any>|any,
13
+ * cmd: (signal?: AbortSignal) => Promise<any>|any,
9
14
  * next: (result: any) => Effect,
10
15
  * meta?: any,
11
16
  * initialInput?: any
@@ -63,13 +68,42 @@ const Failure = (error, initialInput) => ({
63
68
  });
64
69
 
65
70
  /**
66
- * Represents a side effect to be executed later
67
- * @param {() => Promise<any>|any} cmd - The side-effect function to execute
68
- * @param {(result: any) => Effect} next - A function that receives the result of `cmd` and returns the next Effect
69
- * @param {any} [meta] - Optional metadata
71
+ * Represents a side effect to be executed later.
72
+ *
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.
77
+ * @param {(result: any) => Effect} [next] - Receives the result of `cmd` and returns the next Effect.
78
+ * Defaults to `(result) => Success(result)`, which is what most Commands want.
79
+ * @param {CommandMeta} [meta] - Optional metadata, passed to `onBeforeCommand`. A string `meta.name`
80
+ * becomes this Command's identity for traces, replay matching, and telemetry spans, which makes
81
+ * the identity independent of how `cmd` was declared and immune to minification.
70
82
  * @returns {CommandState}
71
83
  */
72
- const Command = (cmd, next, meta) => ({ type: 'Command', cmd, next, meta });
84
+ const Command = (cmd, next = (/** @type {any} */ result) => Success(result), meta) => ({
85
+ type: 'Command',
86
+ cmd,
87
+ next,
88
+ meta
89
+ });
90
+
91
+ /**
92
+ * The name a Command is known by: what a trace records, what strict replay matches on, and what a
93
+ * telemetry span is called.
94
+ *
95
+ * `meta.name` wins when it is a non-empty string. Otherwise the identity is the thunk's own `name`,
96
+ * so an inline arrow records as 'anonymous' and a minifier that mangles function names silently
97
+ * renames every step of every trace. Passing `meta.name` avoids both.
98
+ *
99
+ * @param {CommandState} eff
100
+ * @returns {string}
101
+ */
102
+ const commandName = (eff) => {
103
+ const meta = eff.meta;
104
+ const named = meta && typeof meta === 'object' ? meta.name : undefined;
105
+ return typeof named === 'string' && named !== '' ? named : eff.cmd.name || 'anonymous';
106
+ };
73
107
 
74
108
  /**
75
109
  * Reads the context object from the current `runEffect` call.
@@ -80,6 +114,12 @@ const Ask = (next) => ({ type: 'Ask', next });
80
114
 
81
115
  /**
82
116
  * Wraps an Effect tree with retry-on-failure semantics.
117
+ *
118
+ * Each attempt runs the **entire** wrapped tree again, including Commands that already succeeded, so
119
+ * wrap the one Command that fails transiently rather than a pipeline. `Retry(effectPipe(charge, receipt))`
120
+ * charges the customer again every time the receipt step fails. Wrapping a pipeline is only safe when
121
+ * every Command in it is idempotent.
122
+ *
83
123
  * @param {Effect} effect - The inner Effect tree to retry
84
124
  * @param {Object} [options] - Per-use retry options; merged over global defaults at runtime
85
125
  * @param {number} [options.attempts] - Max retries (not counting first try)
@@ -95,13 +135,68 @@ const Retry = (effect, options = {}) => ({
95
135
  });
96
136
 
97
137
  /**
98
- * Runs multiple Effect trees concurrently. If any effect fails, returns the first Failure by array order and skips next.
138
+ * Runs multiple Effect trees concurrently. The first branch to fail cancels its siblings, and that
139
+ * branch's Failure is what the Parallel returns; `next` is skipped. When several branches fail in the
140
+ * same tick, the first by array order wins.
141
+ *
142
+ * Cancellation is cooperative and works at two levels. A cancelled branch starts no further Commands,
143
+ * which needs nothing from the caller. Stopping the Command already in flight needs its thunk to accept
144
+ * the `AbortSignal` it is passed and hand it to whatever performs the I/O; a thunk that ignores the
145
+ * signal runs to completion, so a branch's first Command can still write after a sibling has failed.
146
+ *
99
147
  * @param {Effect[]} effects - Array of Effect trees to run concurrently
100
148
  * @param {(values: any[]) => Effect} next - Receives array of success values in order, returns next Effect
101
149
  * @returns {ParallelState}
102
150
  */
103
151
  const Parallel = (effects, next) => ({ type: 'Parallel', effects, next });
104
152
 
153
+ /**
154
+ * Describes a value for an error message, leading with the mistake it most likely is.
155
+ * @param {any} value
156
+ * @returns {string}
157
+ */
158
+ const describeValue = (value) =>
159
+ value === undefined
160
+ ? 'undefined, which usually means a missing return'
161
+ : value === null
162
+ ? 'null'
163
+ : typeof value === 'function'
164
+ ? 'a function, which usually means a flow was passed without being called with its input'
165
+ : typeof value === 'object'
166
+ ? typeof value.type === 'string'
167
+ ? `an object with an unrecognised type '${value.type}'`
168
+ : 'a plain object'
169
+ : `the ${typeof value} ${JSON.stringify(value)}`;
170
+
171
+ /**
172
+ * Checks that a value is an Effect, and explains the mistake when it is not.
173
+ *
174
+ * `EffectTypeError` is a bug in the flow rather than a domain failure, so it is thrown instead of
175
+ * becoming a `Failure`, and the interpreter rethrows it rather than folding it into one.
176
+ *
177
+ * @param {any} value
178
+ * @param {string} source - What produced the value, named where it is known
179
+ * @returns {Error}
180
+ */
181
+ const effectTypeError = (value, source) =>
182
+ Object.assign(
183
+ new Error(
184
+ `${source} returned ${describeValue(value)}. Return Success, Failure, Command, Ask, Retry, or Parallel: ` +
185
+ 'a plain value has to be wrapped, as in Success(value).'
186
+ ),
187
+ { name: 'EffectTypeError' }
188
+ );
189
+
190
+ /**
191
+ * @param {any} value
192
+ * @param {string} source
193
+ * @returns {Effect}
194
+ */
195
+ const asEffect = (value, source) => {
196
+ if (value && ['Success', 'Failure', 'Command', 'Ask', 'Retry', 'Parallel'].includes(value.type)) return value;
197
+ throw effectTypeError(value, source);
198
+ };
199
+
105
200
  /**
106
201
  * Connects an Effect to the next function in the pipeline.
107
202
  * Handles the branching logic for Success, Failure, Command, Ask, and Retry.
@@ -122,7 +217,7 @@ const chain = (effect, fn, initialInput) => {
122
217
 
123
218
  switch (effect.type) {
124
219
  case 'Success':
125
- return withII(fn(effect.value));
220
+ return withII(asEffect(fn(effect.value), `Step '${fn.name || 'anonymous'}'`));
126
221
  case 'Failure':
127
222
  return withII(effect);
128
223
  case 'Command': {
@@ -141,6 +236,8 @@ const chain = (effect, fn, initialInput) => {
141
236
  const next = (/** @type {any} */ result) => chain(effect.next(result), fn, initialInput);
142
237
  return withII({ ...effect, next });
143
238
  }
239
+ default:
240
+ return asEffect(effect, 'A continuation');
144
241
  }
145
242
  };
146
243
 
@@ -158,7 +255,13 @@ const effectPipe = (...fns) => {
158
255
  };
159
256
  };
160
257
 
161
- /** @typedef {(name: string, type: string, op: function) => Promise<any>} StepRunner */
258
+ /**
259
+ * Wraps one Command execution. `path` identifies the Command's position in the Effect tree rather than
260
+ * its position in completion order, so it is the same in a replay as it was in the recorded run even
261
+ * when `Parallel` branches finish in a different order. Hooks written before `path` existed take three
262
+ * parameters and are unaffected.
263
+ * @typedef {(name: string, type: string, op: function, path?: string) => Promise<any>} StepRunner
264
+ */
162
265
  /** @type StepRunner */
163
266
  const defaultStepRunner = async (name, type, op) => await op();
164
267
 
@@ -179,8 +282,8 @@ let retryDefaults = { ...defaultRetryOptions };
179
282
 
180
283
  /**
181
284
  * @typedef {Object} EffectConfiguration
182
- * @property {StepRunner} [onStep] - Fires once per runEffect call. It wraps the entire workflow execution.
183
- * @property {RunWrapper} [onRun] - Fires every time a Command is executed.
285
+ * @property {StepRunner} [onStep] - Fires every time a Command is executed. It wraps the `cmd` call.
286
+ * @property {RunWrapper} [onRun] - Fires once per runEffect call. It wraps the entire workflow execution.
184
287
  * @property {CommandInterceptor} [onBeforeCommand] - Intercepts a Command and any context passed to runEffect before execution.
185
288
  * @property {{ attempts?: number, delay?: number, backoff?: number }} [retry] - Global Retry defaults; merged under per-use options.
186
289
  */
@@ -188,15 +291,201 @@ let retryDefaults = { ...defaultRetryOptions };
188
291
  /**
189
292
  * Configures the global behavior of the Effect runner, including the command interceptor and telemetry.
190
293
  *
191
- * @param {EffectConfiguration} options - The configuration object for the effect pipeline.
294
+ * Several configurations can be passed and are merged, which is how independent concerns share the
295
+ * one slot each hook has:
296
+ *
297
+ * configureEffect(telemetryHooks(), recordingHooks({ sink }))
298
+ *
299
+ * `onStep` and `onRun` are wrappers, so they nest with the first configuration outermost and the last
300
+ * closest to the Command. `onBeforeCommand` interceptors all run, in the order given, and `retry`
301
+ * merges with later configurations winning.
302
+ *
303
+ * Merging happens per call and does not accumulate across calls: a later `configureEffect` still
304
+ * replaces the previous wiring entirely, and calling it with nothing resets every slot to its default.
305
+ *
306
+ * Returns a function that puts back whatever was installed when this call was made, so a caller can
307
+ * install hooks without owning the wiring forever. That is what makes this usable from a test, a
308
+ * request-scoped experiment, or a library that wraps this one: without it, installing anything means
309
+ * clobbering the host's telemetry permanently, since there is no way to read the current wiring.
310
+ * Restoring undoes this call only while its wiring is still in effect. If a later `configureEffect` has
311
+ * run since, restoring does nothing rather than discarding that newer wiring, so two callers installing
312
+ * and releasing in interleaved order cannot clobber each other.
313
+ *
314
+ * @param {...(EffectConfiguration | undefined)} configs - Configurations to merge, outermost first
315
+ * @returns {() => void} Restores the wiring that was in place before this call
192
316
  */
193
- const configureEffect = (options) => {
317
+ const configureEffect = (...configs) => {
318
+ const previousStepRunner = stepRunner;
319
+ const previousRunWrapper = runWrapper;
320
+ const previousCommandInterceptor = commandInterceptor;
321
+ const previousRetryDefaults = retryDefaults;
322
+
323
+ const options = chainHooks(...configs);
194
324
  stepRunner = options.onStep ? options.onStep : defaultStepRunner;
195
325
  runWrapper = options.onRun ? options.onRun : defaultRunWrapper;
196
326
  commandInterceptor = options.onBeforeCommand ? options.onBeforeCommand : defaultCommandInterceptor;
197
327
  retryDefaults = options.retry ? { ...defaultRetryOptions, ...options.retry } : defaultRetryOptions;
328
+
329
+ const installedStepRunner = stepRunner;
330
+ const installedRunWrapper = runWrapper;
331
+ const installedCommandInterceptor = commandInterceptor;
332
+ const installedRetryDefaults = retryDefaults;
333
+
334
+ return () => {
335
+ // Only undo an install that is still in effect. Without this check, two callers that install
336
+ // and release in interleaved order silently discard each other's hooks: A installs, B installs,
337
+ // A restores, and B's wiring is gone though B never released it.
338
+ const unchanged =
339
+ stepRunner === installedStepRunner &&
340
+ runWrapper === installedRunWrapper &&
341
+ commandInterceptor === installedCommandInterceptor &&
342
+ retryDefaults === installedRetryDefaults;
343
+ if (!unchanged) return;
344
+
345
+ stepRunner = previousStepRunner;
346
+ runWrapper = previousRunWrapper;
347
+ commandInterceptor = previousCommandInterceptor;
348
+ retryDefaults = previousRetryDefaults;
349
+ };
198
350
  };
199
351
 
352
+ /**
353
+ * @typedef {Object} StepStart
354
+ * @property {string} name - `cmd.name`, or 'anonymous'.
355
+ * @property {string} type - Always 'Command' today.
356
+ * @property {string} [path] - The Command's position in the Effect tree.
357
+ */
358
+
359
+ /**
360
+ * @typedef {Object} StepEnd
361
+ * @property {string} name
362
+ * @property {string} type
363
+ * @property {string} [path]
364
+ * @property {any} [result] - What the Command returned, when it succeeded.
365
+ * @property {any} [error] - What it threw, when it did not.
366
+ * @property {number} durationMs
367
+ */
368
+
369
+ const now = () => (typeof performance === 'object' ? performance.now() : Date.now());
370
+
371
+ /**
372
+ * Wraps a step observer into an `onStep` that cannot change the run.
373
+ *
374
+ * @param {(start: StepStart) => (end: StepEnd) => void} handler - Returns a finisher for the outcome
375
+ * @returns {StepRunner}
376
+ */
377
+ const observeSteps = (handler) => async (name, type, op, path) => {
378
+ /** @type {((end: StepEnd) => void) | undefined} */
379
+ let finish;
380
+ try {
381
+ finish = handler({ name, type, path });
382
+ } catch {
383
+ finish = undefined;
384
+ }
385
+ const report = (/** @type {StepEnd} */ end) => {
386
+ try {
387
+ if (finish) finish(end);
388
+ } catch {
389
+ // Observation does not get to decide the outcome, so a broken observer is dropped.
390
+ }
391
+ };
392
+
393
+ const started = now();
394
+ try {
395
+ const result = await op();
396
+ report({ name, type, path, result, durationMs: now() - started });
397
+ return result;
398
+ } catch (error) {
399
+ report({ name, type, path, error, durationMs: now() - started });
400
+ throw error;
401
+ }
402
+ };
403
+
404
+ /**
405
+ * Merges several configurations into one, so independent concerns can share the hooks.
406
+ *
407
+ * `onStep` and `onRun` are wrappers around an `op`, so they nest. The first config given is
408
+ * the outermost wrapper and the last sits closest to the Command, which also means a thrown
409
+ * Command unwinds from the last config back to the first. `onBeforeCommand` is an observer,
410
+ * so every interceptor runs in the order given. `retry` is plain data and merges with later
411
+ * configs winning. A hook no config defines is left unset, so `configureEffect` keeps its
412
+ * default for that slot.
413
+ *
414
+ * @param {...(EffectConfiguration | undefined)} configs - Configurations to merge, outermost first
415
+ * @returns {EffectConfiguration}
416
+ */
417
+ const chainHooks = (...configs) => {
418
+ const present = (/** @type {string} */ key) =>
419
+ configs
420
+ .filter(Boolean)
421
+ .map((c) => /** @type {any} */ (c)[key])
422
+ .filter(Boolean);
423
+
424
+ /** @type {EffectConfiguration} */
425
+ const merged = {};
426
+ const steps = /** @type {StepRunner[]} */ (present('onStep'));
427
+ const runs = /** @type {RunWrapper[]} */ (present('onRun'));
428
+ const interceptors = /** @type {CommandInterceptor[]} */ (present('onBeforeCommand'));
429
+ const retries = present('retry');
430
+
431
+ if (steps.length) {
432
+ merged.onStep = steps.reduceRight(
433
+ (inner, outer) => (name, type, op, path) => outer(name, type, () => inner(name, type, op, path), path)
434
+ );
435
+ }
436
+ if (runs.length) {
437
+ merged.onRun = runs.reduceRight(
438
+ (inner, outer) => (effect, op, flowName) => outer(effect, () => inner(effect, op, flowName), flowName)
439
+ );
440
+ }
441
+ if (interceptors.length) {
442
+ merged.onBeforeCommand = async (command, context) => {
443
+ for (const intercept of interceptors) await intercept(command, context);
444
+ };
445
+ }
446
+ if (retries.length) merged.retry = Object.assign({}, ...retries);
447
+ return merged;
448
+ };
449
+
450
+ /**
451
+ * The Failure a `Parallel` branch resolves to when a sibling branch has already failed.
452
+ *
453
+ * Branches are cancelled, not merely ignored: this is what `Parallel` returns for a branch that was
454
+ * still in flight, and it is deliberately distinguishable so it can be told apart from the genuine
455
+ * failure that triggered the cancellation.
456
+ * @returns {Error}
457
+ */
458
+ const parallelCancelled = () =>
459
+ Object.assign(new Error('Parallel branch cancelled.'), {
460
+ name: 'ParallelCancelled'
461
+ });
462
+
463
+ /**
464
+ * Sleeps, but gives up early when the surrounding branch is cancelled, so a sibling's failure does not
465
+ * have to wait out a retry backoff that is now pointless. Resolves either way: the caller's abort check
466
+ * is what turns a cancelled wait into a Failure.
467
+ *
468
+ * @param {number} ms
469
+ * @param {AbortSignal} [signal]
470
+ * @returns {Promise<void>}
471
+ */
472
+ const delayFor = (ms, signal) =>
473
+ new Promise((resolve) => {
474
+ if (!signal) {
475
+ setTimeout(resolve, ms);
476
+ return;
477
+ }
478
+ const onAbort = () => {
479
+ clearTimeout(timer);
480
+ resolve();
481
+ };
482
+ const timer = setTimeout(() => {
483
+ signal.removeEventListener('abort', onAbort);
484
+ resolve();
485
+ }, ms);
486
+ signal.addEventListener('abort', onAbort, { once: true });
487
+ });
488
+
200
489
  const runEffect =
201
490
  /**
202
491
  * The Interpreter
@@ -204,7 +493,7 @@ const runEffect =
204
493
  * Ask effects are resolved synchronously with the context object.
205
494
  *
206
495
  * Per-call config takes precedence over global configureEffect defaults.
207
- * onRun fires exactly once per runEffect call — Retry attempts run inside that
496
+ * onRun fires exactly once per runEffect call. Retry attempts run inside that
208
497
  * single span rather than spawning their own, keeping telemetry non-duplicated.
209
498
  *
210
499
  * @param {Effect} effect - The Effect tree returned by a pipeline
@@ -220,10 +509,25 @@ const runEffect =
220
509
 
221
510
  /**
222
511
  * @param {Effect} eff
512
+ * @param {AbortSignal} [signal] - Cancellation for this subtree, set for `Parallel` branches.
513
+ * A branch stops starting new Commands once it is aborted, and the signal is handed to
514
+ * each Command's thunk so I/O that accepts one can be cancelled in flight.
515
+ * @param {string} [path] - Prefix identifying this subtree's position in the Effect tree.
516
+ * Steps are numbered sequentially within a subtree and each `Parallel` branch and `Retry`
517
+ * attempt opens its own prefix, so a Command's full path depends only on the shape of the
518
+ * tree and not on the order branches happen to finish in. That is what lets a replay line
519
+ * a recorded step up with the step that asked for it.
223
520
  * @returns {Promise<SuccessState | FailureState>}
224
521
  */
225
- async function execute(eff) {
226
- while (eff.type === 'Command' || eff.type === 'Ask' || eff.type === 'Retry' || eff.type === 'Parallel') {
522
+ async function execute(eff, signal, path = '') {
523
+ let step = 0;
524
+ while (
525
+ eff &&
526
+ (eff.type === 'Command' || eff.type === 'Ask' || eff.type === 'Retry' || eff.type === 'Parallel')
527
+ ) {
528
+ // Checked before every node, which is what short-circuits a branch whose Commands
529
+ // ignore the signal: the one in flight cannot be stopped, but the next never starts.
530
+ if (signal?.aborted) return Failure(parallelCancelled(), eff.initialInput);
227
531
  if (eff.type === 'Ask') {
228
532
  eff = eff.next(context);
229
533
  continue;
@@ -233,12 +537,19 @@ const runEffect =
233
537
  const { attempts } = opts;
234
538
  let lastError;
235
539
  let succeeded = false;
540
+ // Each attempt gets its own prefix, so the Commands of attempt 2 cannot be mistaken
541
+ // for the Commands of attempt 1 when a trace is matched back up.
542
+ const retryPath = `${path}${step++}r`;
236
543
 
237
544
  for (let attempt = 0; attempt <= attempts; attempt++) {
238
545
  if (attempt > 0) {
239
- await new Promise((r) => setTimeout(r, opts.delay * Math.pow(opts.backoff, attempt - 1)));
546
+ await delayFor(opts.delay * Math.pow(opts.backoff, attempt - 1), signal);
240
547
  }
241
- const result = await execute(eff.effect);
548
+ // Checked after the wait, so a branch cancelled mid-backoff stops here rather
549
+ // than buying one more attempt, and a sibling's failure does not pay for the
550
+ // rest of this branch's retry schedule.
551
+ if (signal?.aborted) return Failure(parallelCancelled(), eff.initialInput);
552
+ const result = await execute(eff.effect, signal, `${retryPath}${attempt}/`);
242
553
  if (result.type === 'Success') {
243
554
  eff = eff.next(result.value);
244
555
  succeeded = true;
@@ -253,26 +564,570 @@ const runEffect =
253
564
  continue;
254
565
  }
255
566
  if (eff.type === 'Parallel') {
256
- const results = await Promise.all(eff.effects.map((e) => execute(e)));
257
- const failure = results.find((r) => r.type === 'Failure');
567
+ // Branch prefixes come from the branch's index in the array, never from the order
568
+ // branches complete in, which is the whole point: two branches calling the same
569
+ // Command are told apart by where they are rather than by who finished first.
570
+ const branchPath = `${path}${step++}p`;
571
+ // One scope per Parallel, linked to the enclosing one so cancellation nests.
572
+ // Absent AbortController (very old runtimes), `branchSignal` stays undefined and
573
+ // the old run-everything-to-completion behaviour is what happens.
574
+ const scope = typeof AbortController === 'function' ? new AbortController() : undefined;
575
+ const branchSignal = scope?.signal;
576
+ const relay = () => scope?.abort();
577
+ if (signal && scope) {
578
+ if (signal.aborted) scope.abort();
579
+ else signal.addEventListener('abort', relay, { once: true });
580
+ }
581
+
582
+ /** @type {(SuccessState | FailureState)[]} */
583
+ const results = new Array(eff.effects.length);
584
+ // Which branch failed on its own account rather than because it was cancelled.
585
+ const triggered = new Array(eff.effects.length).fill(false);
586
+ try {
587
+ // Still awaits every branch, so no cancelled work is left running unobserved
588
+ // after the Failure is returned. Cancelled branches settle promptly; a branch
589
+ // whose in-flight Command ignores the signal is the one case that does not.
590
+ await Promise.all(
591
+ eff.effects.map(async (e, i) => {
592
+ const result = await execute(e, branchSignal, `${branchPath}${i}/`);
593
+ results[i] = result;
594
+ // Read-then-abort is atomic here, so exactly one branch is the trigger.
595
+ if (result.type === 'Failure' && !branchSignal?.aborted) {
596
+ triggered[i] = true;
597
+ scope?.abort();
598
+ }
599
+ })
600
+ );
601
+ } finally {
602
+ if (signal && scope) signal.removeEventListener('abort', relay);
603
+ }
604
+
605
+ const trigger = triggered.indexOf(true);
606
+ const failure = trigger >= 0 ? results[trigger] : results.find((r) => r.type === 'Failure');
258
607
  if (failure) return failure;
259
608
  eff = eff.next(results.map((r) => /** @type {SuccessState} */ (r).value));
260
609
  continue;
261
610
  }
262
- const cmdName = eff.cmd.name || 'anonymous';
611
+ const cmdName = commandName(eff);
263
612
  const initialInput = eff.initialInput;
613
+ const cmdPath = `${path}${step++}`;
614
+ const cmd = eff.cmd;
615
+ // The signal reaches the thunk only inside a Parallel, so a thunk written to take a
616
+ // parameter is not handed an argument it never expected anywhere else.
617
+ const op = signal ? () => cmd(signal) : cmd;
264
618
  try {
265
619
  await localCommandInterceptor(eff, context);
266
- const result = await localStepRunner(cmdName, 'Command', eff.cmd);
620
+ const result = await localStepRunner(cmdName, 'Command', op, cmdPath);
267
621
  eff = eff.next(result);
268
622
  } catch (e) {
623
+ // A malformed flow is a bug, not a domain failure, so it must not masquerade as one.
624
+ if (e instanceof Error && e.name === 'EffectTypeError') throw e;
269
625
  return Failure(e, initialInput);
270
626
  }
271
627
  }
272
- return eff;
628
+ if (eff && (eff.type === 'Success' || eff.type === 'Failure')) return eff;
629
+ throw effectTypeError(eff, 'The flow');
273
630
  }
274
631
 
275
632
  return localRunWrapper(effect, () => execute(effect), context?.flowName || '');
276
633
  };
277
634
 
278
- export { Success, Failure, Command, Ask, Retry, Parallel, effectPipe, runEffect, configureEffect };
635
+ /**
636
+ * The step a replay is asking about. `path` is the Command's position in the Effect tree and is stable
637
+ * across runs; `index` is its position in this run's completion order, which is not stable for a flow
638
+ * containing `Parallel`. Prefer `path` when writing a Resolver.
639
+ * @typedef {{ index: number, name: string, type: string, path?: string }} ReplayStep
640
+ */
641
+
642
+ /**
643
+ * What production observed for a step. `{ result }` is handed to the Command's
644
+ * `next`; `{ error }` is thrown so the interpreter produces a Failure. A resolver
645
+ * returning `undefined` means "not recorded".
646
+ *
647
+ * @typedef {{ result: any } | { error: any }} ReplayOutcome
648
+ */
649
+
650
+ /** @typedef {(step: ReplayStep) => ReplayOutcome | undefined} Resolver */
651
+
652
+ /**
653
+ * A recorded step. `durationMs` is how long the Command took in production, rounded to microseconds,
654
+ * which is the one question a trace could not answer before: which step was slow.
655
+ * `path` locates the Command in the Effect tree, which is what a replay matches on: it does not move
656
+ * when `Parallel` branches finish in a different order than they did in production.
657
+ * @typedef {{ command: string, path?: string, result?: any, error?: any, durationMs?: number }} TraceEntry
658
+ */
659
+
660
+ /**
661
+ * The reference trace format produced by `recorder`. A convenience, not a contract:
662
+ * `replayEffect` takes a Resolver, so any storage shape works.
663
+ * @typedef {{
664
+ * flowName?: string,
665
+ * version?: string,
666
+ * initialInput?: any,
667
+ * context?: any,
668
+ * dropped?: number,
669
+ * trace: TraceEntry[]
670
+ * }} TraceLog
671
+ */
672
+
673
+ /**
674
+ * Creates a replay error. Thrown from a resolver, it is caught by the interpreter
675
+ * and returned as a Failure, exactly like a rejected Command.
676
+ * @param {string} message - Human-readable reason
677
+ * @param {Object} [props] - Extra fields such as `name`, `index`, `expected`, `actual`
678
+ * @returns {Error}
679
+ */
680
+ const replayError = (message, props = {}) => Object.assign(new Error(message), { name: 'ReplayError' }, props);
681
+
682
+ /**
683
+ * Signals that the flow being replayed asked for a different Command than the trace
684
+ * recorded, which means the code has diverged from the recorded run.
685
+ * @param {ReplayStep} step - The step the flow asked for
686
+ * @param {string} recorded - The command name the trace holds at that position
687
+ * @returns {Error}
688
+ */
689
+ const timeParadox = (step, recorded) =>
690
+ replayError(
691
+ `Time paradox at ${step.path !== undefined ? `path '${step.path}'` : `step ${step.index}`}: ` +
692
+ `flow asked for '${step.name}', trace recorded '${recorded}'`,
693
+ {
694
+ name: 'TimeParadox',
695
+ index: step.index,
696
+ path: step.path,
697
+ expected: recorded,
698
+ actual: step.name
699
+ }
700
+ );
701
+
702
+ /**
703
+ * Converts a thrown value into something JSON can carry. `message` and `stack` are
704
+ * non-enumerable on Error, so a plain `JSON.stringify` would silently drop them.
705
+ *
706
+ * @param {any} e - The thrown value
707
+ * @param {boolean} [withStack] - Include the stack (off by default: noisy, leaks paths)
708
+ * @returns {any}
709
+ */
710
+ const serializeError = (e, withStack) => {
711
+ if (!(e instanceof Error)) return e;
712
+ /** @type {any} */
713
+ const out = { __error: true, name: e.name, message: e.message };
714
+ if (withStack) out.stack = e.stack;
715
+ for (const k of Object.keys(e)) out[k] = /** @type {any} */ (e)[k];
716
+ return out;
717
+ };
718
+
719
+ /**
720
+ * Rebuilds an Error from `serializeError` output. Non-Error values pass through, so a
721
+ * Command that rejected with a string still replays as a string.
722
+ * @param {any} v - A serialized error, or any other recorded value
723
+ * @returns {any}
724
+ */
725
+ const reviveError = (v) => {
726
+ if (!v || typeof v !== 'object' || v.__error !== true) return v;
727
+ const e = new Error(v.message);
728
+ e.name = v.name;
729
+ for (const [k, val] of Object.entries(v)) {
730
+ if (k !== '__error' && k !== 'name' && k !== 'message') /** @type {any} */ (e)[k] = val;
731
+ }
732
+ return e;
733
+ };
734
+
735
+ /**
736
+ * @typedef {Object} RecorderOptions
737
+ * @property {(value: any, name: string, kind: string) => any} [redact] - Scrubs every value a trace holds:
738
+ * each Command's result, each serialized error, and the `initialInput` and `context` stored on the
739
+ * trace itself. `kind` is `'result'`, `'error'`, `'initialInput'`, or `'context'`, and `name` is the
740
+ * Command's name for the first two and the kind for the last two. It is the single place PII is kept
741
+ * out of a trace, so it has to see all four.
742
+ * @property {number} [maxEntries] - Caps trace length; further steps are counted in `dropped`, not stored.
743
+ * @property {boolean} [stack] - Records stack traces for thrown errors.
744
+ */
745
+
746
+ /**
747
+ * @typedef {Object} TraceMeta
748
+ * @property {any} [initialInput] - The value the flow was called with.
749
+ * @property {string} [flowName]
750
+ * @property {any} [context] - Context passed to `runEffect`; required to replay `Ask`.
751
+ * @property {string} [version] - Commit or build id, so a replay can detect a stale trace.
752
+ */
753
+
754
+ /**
755
+ * Snapshots a value on its way into a trace.
756
+ *
757
+ * Commands return live objects, and a later step that mutates one would otherwise rewrite what the
758
+ * trace says an earlier step returned: `toTrace` runs after the flow finishes, so by serialization
759
+ * time the mutation is already baked in and the trace records a value production never saw. Values
760
+ * that cannot be cloned fall back to the reference.
761
+ *
762
+ * @param {any} value
763
+ * @returns {any}
764
+ */
765
+ const snapshot = (value) => {
766
+ if (value === null || typeof value !== 'object') return value;
767
+ try {
768
+ return typeof structuredClone === 'function' ? structuredClone(value) : JSON.parse(JSON.stringify(value));
769
+ } catch {
770
+ return value;
771
+ }
772
+ };
773
+
774
+ /**
775
+ * Builds an `onStep` hook that records what every Command returned, plus a packager
776
+ * for the reference trace format. Pass `onStep` to `runEffect` as per-call config, or
777
+ * to `configureEffect` to record globally.
778
+ *
779
+ * @param {RecorderOptions} [options] - Redaction and size limits
780
+ * @returns {{ onStep: StepRunner, entries: TraceEntry[], toTrace: (meta?: TraceMeta) => TraceLog }}
781
+ */
782
+ const recorder = (options = {}) => {
783
+ const { redact = (/** @type {any} */ r) => r, maxEntries = Infinity, stack = false } = options;
784
+ /** @type {TraceEntry[]} */
785
+ const entries = [];
786
+ let dropped = 0;
787
+
788
+ const push = (/** @type {TraceEntry} */ entry) => {
789
+ if (entries.length < maxEntries) entries.push(entry);
790
+ else dropped++;
791
+ };
792
+
793
+ /**
794
+ * `redact` is the caller's code.
795
+ */
796
+ const safeRedact = (/** @type {any} */ value, /** @type {string} */ name, /** @type {string} */ kind) => {
797
+ try {
798
+ return redact(value, name, kind);
799
+ } catch {
800
+ return '[redaction failed]';
801
+ }
802
+ };
803
+
804
+ /**
805
+ * Redacts and snapshots one of the trace's own fields. `undefined` is left alone so a flow with no
806
+ * context does not acquire an empty object from a redact function that spreads its argument.
807
+ */
808
+ const redactField = (/** @type {any} */ value, /** @type {string} */ kind) =>
809
+ value === undefined ? undefined : snapshot(safeRedact(value, kind, kind));
810
+
811
+ // Built on `observe` so the hook contract lives in one place: `op` always runs, its result is
812
+ // always returned, and its error always propagates.
813
+ const onStep = observeSteps(({ name, path }) => (end) => {
814
+ const durationMs = Math.round(end.durationMs * 1000) / 1000;
815
+ push(
816
+ 'error' in end
817
+ ? {
818
+ command: name,
819
+ path,
820
+ error: snapshot(safeRedact(serializeError(end.error, stack), name, 'error')),
821
+ durationMs
822
+ }
823
+ : { command: name, path, result: snapshot(safeRedact(end.result, name, 'result')), durationMs }
824
+ );
825
+ });
826
+
827
+ /**
828
+ * @param {TraceMeta} [meta]
829
+ * @returns {TraceLog}
830
+ */
831
+ const toTrace = (meta = {}) => ({
832
+ flowName: meta.flowName,
833
+ version: meta.version,
834
+ initialInput: redactField(meta.initialInput, 'initialInput'),
835
+ context: redactField(meta.context, 'context'),
836
+ dropped,
837
+ trace: entries.slice()
838
+ });
839
+
840
+ return { onStep, entries, toTrace };
841
+ };
842
+
843
+ /**
844
+ * Runs a flow for real while recording every Command result, and returns both the
845
+ * outcome and a replayable trace. Convenient in tests and scripts; in an application,
846
+ * install `recorder().onStep` once via `configureEffect` instead of changing call sites.
847
+ *
848
+ * @param {(input: any) => Effect} flowFn - Builds the Effect tree from its input
849
+ * @param {any} initialInput - The value the flow is called with; stored so a replay can rebuild it
850
+ * @param {RecorderOptions & { context?: any, version?: string }} [options] - Recorder options, plus the
851
+ * `context` given to `runEffect` (stored so `Ask` can be replayed) and a build id
852
+ * @returns {Promise<{ result: SuccessState | FailureState, trace: TraceLog }>}
853
+ */
854
+ const recordEffect = async (flowFn, initialInput, options = {}) => {
855
+ const { context = {}, version, ...recorderOptions } = options;
856
+ const rec = recorder(recorderOptions);
857
+ const result = await runEffect(flowFn(initialInput), context, { onStep: rec.onStep });
858
+ return {
859
+ result,
860
+ trace: rec.toTrace({ initialInput, flowName: context.flowName, context, version })
861
+ };
862
+ };
863
+
864
+ /**
865
+ * Turns a recorded entry into the outcome a Resolver must return.
866
+ *
867
+ * @param {TraceEntry} entry
868
+ * @returns {ReplayOutcome}
869
+ */
870
+ const entryToOutcome = (entry) => ('error' in entry ? { error: reviveError(entry.error) } : { result: entry.result });
871
+
872
+ /**
873
+ * Builds a Resolver for the reference trace format. Internal: `replayEffect` calls this
874
+ * when handed a trace instead of a Resolver, and is the only caller. Callers with traces
875
+ * in some other shape write a Resolver instead, which is the extension point.
876
+ *
877
+ * Path matching is used whenever every entry carries a `path`, which is every trace this
878
+ * 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.
882
+ *
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.
887
+ *
888
+ * @param {TraceLog | TraceEntry[]} traceLog - A reference-format trace, or a bare array of entries
889
+ * @param {Object} [options]
890
+ * @param {boolean} [options.strict] - Positional matching with paradox detection (default `true`).
891
+ * Ignored for a trace carrying paths.
892
+ * @returns {Resolver}
893
+ */
894
+ const fromTrace = (traceLog, options = {}) => {
895
+ const { strict = true } = options;
896
+ const entries = Array.isArray(traceLog) ? traceLog : traceLog?.trace;
897
+ if (!Array.isArray(entries)) throw replayError('Trace has no `trace` array.');
898
+
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
+ if (entries.length > 0 && entries.every((e) => typeof e.path === 'string')) {
903
+ const byPath = new Map(entries.map((e) => [e.path, e]));
904
+ // Paths are derived from tree position, so they are unique by construction. A collision means
905
+ // either a hand-built trace or a bug in path derivation, and silently keeping the last entry
906
+ // would reintroduce exactly the wrong-result-per-branch failure paths exist to prevent.
907
+ if (byPath.size !== entries.length) {
908
+ throw replayError('Trace has duplicate step paths, so it cannot be matched by path.');
909
+ }
910
+ return (step) => {
911
+ const entry = byPath.get(step.path);
912
+ if (!entry) {
913
+ throw replayError(`Trace has no step at path '${step.path}' for '${step.name}'.`, {
914
+ command: step.name,
915
+ path: step.path
916
+ });
917
+ }
918
+ 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);
929
+ };
930
+ }
931
+
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
+ 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()));
942
+ };
943
+ };
944
+
945
+ /**
946
+ * Rewrites `Retry` nodes to zero delay, lazily, through their `next` continuations.
947
+ *
948
+ * Needed because the interpreter merges per-use options over call config
949
+ * (`{ ...localRetryDefaults, ...eff.options }`), so a `callConfig.retry` cannot
950
+ * override a delay written at the call site. Without this, replaying a flow that
951
+ * retried in production waits out the production backoff.
952
+ *
953
+ * @param {any} eff - Any Effect node
954
+ * @returns {any} The same tree with Retry delays removed
955
+ */
956
+ const zeroRetryDelays = (eff) => {
957
+ if (!eff || typeof eff.type !== 'string') return eff;
958
+ switch (eff.type) {
959
+ case 'Retry':
960
+ return {
961
+ ...eff,
962
+ options: { ...eff.options, delay: 0, backoff: 1 },
963
+ effect: zeroRetryDelays(eff.effect),
964
+ next: (/** @type {any} */ value) => zeroRetryDelays(eff.next(value))
965
+ };
966
+ case 'Command':
967
+ return { ...eff, next: (/** @type {any} */ result) => zeroRetryDelays(eff.next(result)) };
968
+ case 'Ask':
969
+ return { ...eff, next: (/** @type {any} */ ctx) => zeroRetryDelays(eff.next(ctx)) };
970
+ case 'Parallel':
971
+ return {
972
+ ...eff,
973
+ effects: eff.effects.map(zeroRetryDelays),
974
+ next: (/** @type {any} */ values) => zeroRetryDelays(eff.next(values))
975
+ };
976
+ default:
977
+ return eff;
978
+ }
979
+ };
980
+
981
+ /**
982
+ * @typedef {Object} ReplayOptions
983
+ * @property {any} [context] - Context for `Ask`; pass the recorded context to reproduce a run faithfully.
984
+ * @property {boolean} [fastRetry] - Strips Retry delays so a replay does not wait out production backoff.
985
+ * @property {boolean} [hooks] - Lets configured `onRun` and `onBeforeCommand` fire. Off by default, so a
986
+ * replay cannot reach a telemetry backend or a guardrail that performs I/O.
987
+ * @property {'throw' | 'execute'} [onMissing] - What to do when the resolver has no recording for a step.
988
+ * `'throw'` (default) fails the replay, which makes side effects impossible for the whole run.
989
+ * `'execute'` runs the real Command, giving partial replay: recorded prefix, live tail.
990
+ * @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.
995
+ */
996
+
997
+ /**
998
+ * Replays an Effect tree, feeding recorded results to Commands instead of running them.
999
+ *
1000
+ * No side effect can occur by default. The interpreter's only execution point is
1001
+ * `await localStepRunner(cmdName, 'Command', eff.cmd)`, which hands the Command thunk
1002
+ * to `onStep` as `op` rather than calling it. The `onStep` installed here never invokes
1003
+ * `op` unless `onMissing: 'execute'` is set, so `eff.cmd` is never applied and the I/O
1004
+ * it describes does not happen.
1005
+ *
1006
+ * Driving the interpreter instead of walking the tree is what makes `Ask`, `Retry` and
1007
+ * `Parallel` work, and means replay cannot drift from execution semantics.
1008
+ *
1009
+ * @param {Effect} effect - The Effect tree, rebuilt from the recorded initial input
1010
+ * @param {Resolver | TraceLog | TraceEntry[]} traceOrResolver - A reference-format trace, resolved here, or a
1011
+ * Resolver supplying each Command's recorded outcome (`undefined` if it has none). Write a Resolver when
1012
+ * traces are stored in some other shape; to observe a replay without one, use `onResolved`.
1013
+ * @param {ReplayOptions} [options]
1014
+ * @returns {Promise<SuccessState | FailureState>}
1015
+ */
1016
+ // `async` so a malformed trace arrives as a rejection rather than a synchronous throw:
1017
+ // the function otherwise returns a promise, and callers should not have to handle both.
1018
+ const replayEffect = async (effect, traceOrResolver, options = {}) => {
1019
+ const { context = {}, fastRetry = true, hooks = false, onMissing = 'throw', onResolved, strict = true } = options;
1020
+ // A trace is data and a Resolver is a function, so nothing else is needed to tell them
1021
+ // apart, including the bare entries array that `fromTrace` also accepts.
1022
+ const resolve = typeof traceOrResolver === 'function' ? traceOrResolver : fromTrace(traceOrResolver, { strict });
1023
+ let index = 0;
1024
+
1025
+ /** @type {StepRunner} */
1026
+ const onStep = async (name, type, op, path) => {
1027
+ const step = { index: index++, name, type, path };
1028
+ const outcome = resolve(step);
1029
+ if (onResolved) onResolved(step, outcome);
1030
+ if (outcome === undefined) {
1031
+ if (onMissing !== 'execute') {
1032
+ throw replayError(
1033
+ `No recorded outcome for '${name}' at step ${step.index}; refusing to run the real Command. ` +
1034
+ `Pass onMissing: 'execute' to allow live I/O for unrecorded steps.`,
1035
+ { command: name, index: step.index }
1036
+ );
1037
+ }
1038
+ return await op();
1039
+ }
1040
+ if ('error' in outcome) throw outcome.error;
1041
+ return outcome.result;
1042
+ };
1043
+
1044
+ /** @type {EffectConfiguration} */
1045
+ const callConfig = { onStep };
1046
+ if (!hooks) {
1047
+ callConfig.onRun = async (effect, op) => await op();
1048
+ callConfig.onBeforeCommand = async () => {};
1049
+ }
1050
+
1051
+ return runEffect(fastRetry ? zeroRetryDelays(effect) : effect, context, callConfig);
1052
+ };
1053
+
1054
+ /**
1055
+ * 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
1057
+ * reached, which means the current code issued fewer Commands than production did.
1058
+ *
1059
+ * @param {(input: any) => Effect} flowFn - The same flow function that produced the trace
1060
+ * @param {TraceLog} traceLog - A trace from `recordEffect` or a `recorder`
1061
+ * @param {Object} [options]
1062
+ * @param {boolean} [options.strict] - Positional matching with paradox detection; set `false` for `Parallel`
1063
+ * @param {(...args: any[]) => void} [options.log] - Defaults to `console.log`
1064
+ * @param {any} [options.context] - Overrides the context stored on the trace
1065
+ * @param {string} [options.version] - Current build id; warns when it differs from the trace's
1066
+ * @returns {Promise<SuccessState | FailureState>}
1067
+ */
1068
+ const timeTravel = async (flowFn, traceLog, options = {}) => {
1069
+ const { strict = true, log = console.log, context, version } = options;
1070
+ const { initialInput, trace, flowName, version: traceVersion } = traceLog;
1071
+ // `message` and `stack` are non-enumerable on Error, so JSON.stringify alone would
1072
+ // drop the most useful line of the report.
1073
+ const format = (/** @type {any} */ v) =>
1074
+ v instanceof Error
1075
+ ? JSON.stringify({ ...v, name: v.name, message: v.message }, null, 2)
1076
+ : JSON.stringify(v, null, 2);
1077
+
1078
+ if (version && traceVersion && version !== traceVersion) {
1079
+ log(`Warning: trace was recorded at ${traceVersion}, replaying against ${version}.`);
1080
+ }
1081
+ const stepsText = trace.length === 0 || trace.length > 1 ? 'steps' : 'step';
1082
+ log(`Replaying '${flowName || 'flow'}' (${trace.length} recorded ${stepsText})`);
1083
+ log(`Initial input: ${format(initialInput)}`);
1084
+
1085
+ // Narration goes through `onResolved` rather than a wrapped Resolver: observing each
1086
+ // step is all this needs, and `replayEffect` already resolves the trace itself.
1087
+ let consumed = 0;
1088
+ // 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.
1091
+ const byPath = new Map(trace.filter((e) => typeof e.path === 'string').map((e) => [e.path, e]));
1092
+ const timing = (/** @type {ReplayStep} */ step) => {
1093
+ const recorded = byPath.get(step.path) ?? (strict ? trace[step.index] : undefined);
1094
+ return typeof recorded?.durationMs === 'number' ? ` in ${recorded.durationMs}ms` : '';
1095
+ };
1096
+ const result = await replayEffect(flowFn(initialInput), traceLog, {
1097
+ strict,
1098
+ context: context !== undefined ? context : traceLog.context || {},
1099
+ onResolved: (step, outcome) => {
1100
+ // Resolving from a trace either answers or throws, so this is never undefined.
1101
+ if (outcome === undefined) return;
1102
+ consumed++;
1103
+ log(
1104
+ 'error' in outcome
1105
+ ? `Step ${step.index + 1}: ${step.name} threw${timing(step)} ${format(outcome.error)}`
1106
+ : `Step ${step.index + 1}: ${step.name} returned${timing(step)} ${format(outcome.result)}`
1107
+ );
1108
+ }
1109
+ });
1110
+
1111
+ log(`Replay finished with state: ${result.type}`);
1112
+ 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.`);
1115
+ }
1116
+ return result;
1117
+ };
1118
+
1119
+ export {
1120
+ Success,
1121
+ Failure,
1122
+ Command,
1123
+ Ask,
1124
+ Retry,
1125
+ Parallel,
1126
+ effectPipe,
1127
+ runEffect,
1128
+ configureEffect,
1129
+ recorder,
1130
+ recordEffect,
1131
+ replayEffect,
1132
+ timeTravel
1133
+ };