pure-effect 0.12.0 → 0.14.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
@@ -34,11 +34,18 @@
34
34
  * }} RetryState
35
35
  */
36
36
 
37
+ /**
38
+ * `settled` hands branch outcomes to `next` instead of failing on the first one; `limit` caps how many
39
+ * branches run at once.
40
+ * @typedef {{ limit?: number, settled?: boolean }} ParallelOptions
41
+ */
42
+
37
43
  /**
38
44
  * @typedef {{
39
45
  * type: 'Parallel',
40
46
  * effects: Effect[],
41
47
  * next: (values: any[]) => Effect,
48
+ * options?: ParallelOptions,
42
49
  * initialInput?: any
43
50
  * }} ParallelState
44
51
  */
@@ -121,7 +128,8 @@ const Ask = (next) => ({ type: 'Ask', next });
121
128
  * every Command in it is idempotent.
122
129
  *
123
130
  * @param {Effect} effect - The inner Effect tree to retry
124
- * @param {Object} [options] - Per-use retry options; merged over global defaults at runtime
131
+ * @param {Object} [options] - Retry options, merged over the library defaults at runtime; there are no
132
+ * configured defaults, since how a dependency misbehaves is a property of that dependency
125
133
  * @param {number} [options.attempts] - Max retries (not counting first try)
126
134
  * @param {number} [options.delay] - Ms before first retry
127
135
  * @param {number} [options.backoff] - Multiplier applied to delay on each subsequent retry
@@ -140,24 +148,39 @@ const Retry = (effect, options = {}) => ({
140
148
 
141
149
  /**
142
150
  * Runs multiple Effect trees concurrently. The first branch to fail cancels its siblings, and that
143
- * branch's Failure is what the Parallel returns; `next` is skipped. When several branches fail in the
144
- * same tick, the first by array order wins.
151
+ * branch's Failure is what the Parallel returns; `next` is skipped. Which branch fails first depends on
152
+ * timing, so it is recorded, and a replay returns the same one.
145
153
  *
146
154
  * Cancellation is cooperative and works at two levels. A cancelled branch starts no further Commands,
147
155
  * which needs nothing from the caller. Stopping the Command already in flight needs its thunk to accept
148
156
  * the `AbortSignal` it is passed and hand it to whatever performs the I/O; a thunk that ignores the
149
157
  * signal runs to completion, so a branch's first Command can still write after a sibling has failed.
150
158
  *
159
+ * `settled: true` turns off that first-failure rule: every branch runs to completion and `next`
160
+ * receives the branch outcomes themselves, `Success` and `Failure` nodes in array order, so one
161
+ * record's failure cannot abort a batch. An `EffectTypeError` still escapes, because a malformed flow
162
+ * is a bug rather than a branch outcome. `limit: n` keeps at most `n` branches in flight, for a
163
+ * resource that rate limits; results and paths stay in array order either way.
164
+ *
165
+ * The second argument is the `next` function or the options, whichever it looks like, so
166
+ * `Parallel(effects, { limit: 5 })` needs no placeholder.
167
+ *
151
168
  * @param {Effect[]} effects - Array of Effect trees to run concurrently
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.
169
+ * @param {((values: any[]) => Effect) | ParallelOptions} [nextOrOptions] - Receives array of success
170
+ * values in order and returns the next Effect, or the options. `next` defaults to
171
+ * `(values) => Success(values)`, same as `Command`'s default.
172
+ * @param {ParallelOptions} [maybeOptions] - Options, when `next` was given
154
173
  * @returns {ParallelState}
155
174
  */
156
- const Parallel = (effects, next = (/** @type {any[]} */ values) => Success(values)) => ({
157
- type: 'Parallel',
158
- effects,
159
- next
160
- });
175
+ const Parallel = (effects, nextOrOptions, maybeOptions) => {
176
+ const hasNext = typeof nextOrOptions === 'function';
177
+ return {
178
+ type: 'Parallel',
179
+ effects,
180
+ next: hasNext ? nextOrOptions : (/** @type {any[]} */ values) => Success(values),
181
+ options: (hasNext ? maybeOptions : nextOrOptions) ?? {}
182
+ };
183
+ };
161
184
 
162
185
  /**
163
186
  * Describes a value for an error message, leading with the mistake it most likely is.
@@ -169,13 +192,53 @@ const describeValue = (value) =>
169
192
  ? 'undefined, which usually means a missing return'
170
193
  : value === null
171
194
  ? 'null'
172
- : typeof value === 'function'
173
- ? 'a function, which usually means a flow was passed without being called with its input'
174
- : typeof value === 'object'
175
- ? typeof value.type === 'string'
176
- ? `an object with an unrecognised type '${value.type}'`
177
- : 'a plain object'
178
- : `the ${typeof value} ${JSON.stringify(value)}`;
195
+ : value instanceof Promise
196
+ ? 'a Promise, which usually means an async function'
197
+ : typeof value === 'function'
198
+ ? 'a function, which usually means a flow was passed without being called with its input'
199
+ : typeof value === 'object'
200
+ ? typeof value.type === 'string'
201
+ ? `an object with an unrecognised type '${value.type}'`
202
+ : 'a plain object'
203
+ : `the ${typeof value} ${JSON.stringify(value)}`;
204
+
205
+ /**
206
+ * Marks an error as the harness failing rather than the flow. The interpreter rethrows anything
207
+ * carrying it instead of folding it into a `Failure`, which keeps it out of reach of everything that
208
+ * legitimately handles a domain failure: a Command's `next`, `Retry`'s `onExhausted`, and the outcomes
209
+ * a settled `Parallel` collects. A malformed flow and a trace that cannot answer a step are both this
210
+ * kind of error, and a new one gets the mark rather than a new name for the interpreter to know about.
211
+ * Non-enumerable, so it cannot show up in a serialized error or upset a `deepEqual` on an outcome.
212
+ */
213
+ const harnessError = Symbol('pure-effect.harnessError');
214
+
215
+ /**
216
+ * @param {Error} error
217
+ * @returns {Error}
218
+ */
219
+ const asHarnessError = (error) => Object.defineProperty(error, harnessError, { value: true });
220
+
221
+ /**
222
+ * An I/O fault: a Command's function threw, or a `Retry` ran out of attempts, rather than the flow
223
+ * deciding to stop. `Retry` acts on this and not on a `Failure` a step returned, which is an abort.
224
+ * Internal: only `execute` returns one, and it becomes a plain `Failure` wherever it would reach user
225
+ * code, at the `runEffect` boundary and in a settled `Parallel`'s outcomes. So whatever a step returns
226
+ * is an abort, and no object a caller holds carries provenance it could keep or lose by accident.
227
+ * @typedef {{ type: 'IoFault', error: any, initialInput?: any }} IoFaultState
228
+ */
229
+
230
+ /**
231
+ * @param {any} error
232
+ * @param {any} [initialInput]
233
+ * @returns {IoFaultState}
234
+ */
235
+ const IoFault = (error, initialInput) => ({ type: 'IoFault', error, initialInput });
236
+
237
+ /**
238
+ * @param {SuccessState | FailureState | IoFaultState} state
239
+ * @returns {SuccessState | FailureState}
240
+ */
241
+ const asOutcome = (state) => (state.type === 'IoFault' ? Failure(state.error, state.initialInput) : state);
179
242
 
180
243
  /**
181
244
  * Checks that a value is an Effect, and explains the mistake when it is not.
@@ -187,14 +250,24 @@ const describeValue = (value) =>
187
250
  * @param {string} source - What produced the value, named where it is known
188
251
  * @returns {Error}
189
252
  */
190
- const effectTypeError = (value, source) =>
191
- Object.assign(
192
- new Error(
193
- `${source} returned ${describeValue(value)}. Return Success, Failure, Command, Ask, Retry, or Parallel: ` +
194
- 'a plain value has to be wrapped, as in Success(value).'
195
- ),
196
- { name: 'EffectTypeError' }
253
+ const effectTypeError = (value, source) => {
254
+ const isPromise = value instanceof Promise;
255
+ // This error already reports the bug, so a rejection of the same Promise must not also crash the
256
+ // process as unhandled after the caller has caught it. Only a native Promise, which is what an async
257
+ // function returns: calling `then` on a query builder from Knex or Mongoose runs the query.
258
+ if (isPromise) value.catch(() => {});
259
+ return asHarnessError(
260
+ Object.assign(
261
+ new Error(
262
+ `${source} returned ${describeValue(value)}. Return Success, Failure, Command, Ask, Retry, or Parallel: ` +
263
+ (isPromise
264
+ ? 'a step cannot be async, so do the awaited work in a Command and continue in its next.'
265
+ : 'a plain value has to be wrapped, as in Success(value).')
266
+ ),
267
+ { name: 'EffectTypeError' }
268
+ )
197
269
  );
270
+ };
198
271
 
199
272
  /**
200
273
  * @param {any} value
@@ -208,21 +281,30 @@ const asEffect = (value, source) => {
208
281
 
209
282
  /**
210
283
  * Connects an Effect to the next function in the pipeline.
211
- * Handles the branching logic for Success, Failure, Command, Ask, and Retry.
284
+ * Handles the branching logic for Success, Failure, Command, Ask, Retry, and Parallel.
212
285
  *
213
286
  * @param {Effect} effect - The current Effect object
214
287
  * @param {(value: any) => Effect} fn - The next function to run if the current effect is a Success
288
+ * @param {any} [initialInput] - The pipeline's starting value, stamped on every node but a Success
215
289
  * @returns {Effect} The composed Effect
216
290
  */
217
- /**
218
- * @param {Effect} effect
219
- * @param {(value: any) => Effect} fn
220
- * @param {any} [initialInput]
221
- * @returns {Effect}
222
- */
223
291
  const chain = (effect, fn, initialInput) => {
292
+ // The outermost pipeline wins. A step can return a sub-pipeline whose nodes already carry that
293
+ // sub-pipeline's start, and an outer chain reaches every one of them through the wrapped `next`
294
+ // continuations, so overwriting here is what makes the whole tree, a Failure from any depth, and
295
+ // the root a hook-based recorder reads carry the input of the flow that was actually called. The
296
+ // previous rule, stamp only when unset, let a Retry- or Parallel-headed sub-pipeline leave its own
297
+ // input on the root, and a trace recorded from it rebuilt the flow from the wrong value. A Success
298
+ // is never stamped: nothing reads the input off a terminal value, and leaving it bare is what lets
299
+ // `assert.deepEqual(result, Success(v))` hold whatever shape the flow had.
224
300
  const withII = (/** @type {Effect} */ e) =>
225
- initialInput !== undefined && e.initialInput === undefined ? { ...e, initialInput } : e;
301
+ initialInput !== undefined && e.type !== 'Success' ? { ...e, initialInput } : e;
302
+
303
+ // A continuation that returned nothing has to be caught before `effect.type` is read. Reading it off
304
+ // `undefined` throws a bare TypeError that names no step, and the run would reject with that. This
305
+ // guard turns it into an EffectTypeError that says what went wrong. The `default` arm below never sees
306
+ // it, because the switch itself is what throws.
307
+ if (effect == null) return asEffect(effect, 'A continuation');
226
308
 
227
309
  switch (effect.type) {
228
310
  case 'Success':
@@ -260,15 +342,24 @@ const chain = (effect, fn, initialInput) => {
260
342
  const effectPipe = (...fns) => {
261
343
  return (start) => {
262
344
  const chainWithII = (/** @type {Effect} */ eff, /** @type {(v: any) => Effect} */ fn) => chain(eff, fn, start);
263
- return fns.reduce(chainWithII, /** @type {Effect} */ (Success(start)));
345
+ const tree = fns.reduce(chainWithII, /** @type {Effect} */ (Success(start)));
346
+ // One identity pass over the finished tree. `chain` re-stamps a sub-pipeline's nodes only as
347
+ // it wraps the continuations that lead to them, and the last step has no later step to do
348
+ // that wrapping, so a sub-pipeline returned by the last step would keep its own input on
349
+ // everything past its head. The pass wraps those continuations too, so every node reachable
350
+ // from here, a Failure at any depth included, carries this pipeline's `start`. `Success` is the
351
+ // identity step: it can never return a non-Effect, so no message is ever attributed to it.
352
+ return chain(tree, Success, start);
264
353
  };
265
354
  };
266
355
 
267
356
  /**
268
- * Wraps one Command execution. `path` identifies the Command's position in the Effect tree rather than
269
- * its position in completion order, so it is the same in a replay as it was in the recorded run even
270
- * when `Parallel` branches finish in a different order. Hooks written before `path` existed take three
271
- * parameters and are unaffected.
357
+ * Wraps one Command execution, or one Parallel: `type` is 'Parallel', and `op` runs the branches and
358
+ * returns the Parallel's decision, even when a branch threw, so a hook must call it. Only a replay passes
359
+ * `op` an argument, the recorded decision. `path` identifies the step's position in the Effect tree rather than its position
360
+ * in completion order, so it is the same in a replay as it was in the recorded run even when `Parallel`
361
+ * branches finish in a different order. Hooks written before `path` existed take three parameters and
362
+ * are unaffected.
272
363
  * @typedef {(name: string, type: string, op: function, path?: string) => Promise<any>} StepRunner
273
364
  */
274
365
  /** @type StepRunner */
@@ -282,86 +373,110 @@ const defaultRunWrapper = async (effect, op, flowName) => await op();
282
373
  /** @type CommandInterceptor */
283
374
  const defaultCommandInterceptor = async (command, context) => {};
284
375
 
285
- let stepRunner = defaultStepRunner;
286
- let runWrapper = defaultRunWrapper;
287
- let commandInterceptor = defaultCommandInterceptor;
288
-
289
376
  const defaultRetryOptions = { attempts: 3, delay: 100, backoff: 1 };
290
- let retryDefaults = { ...defaultRetryOptions };
377
+
378
+ /**
379
+ * Retry options were once a process-wide default that a per-use `Retry` merged over. They describe how
380
+ * one dependency misbehaves rather than a property of the process, so they are per-use only now, and a
381
+ * leftover `retry` key throws instead of being ignored: silently falling back to the library defaults
382
+ * is how a configured `attempts: 5` quietly becomes 3. Remove this guard at 1.0.
383
+ * @param {any} config
384
+ * @param {string} source
385
+ */
386
+ const rejectRetryKey = (config, source) => {
387
+ if (config && typeof config === 'object' && 'retry' in config)
388
+ throw new TypeError(
389
+ `${source} no longer takes 'retry'. Retry options are per-use: pass them to Retry(effect, options).`
390
+ );
391
+ };
291
392
 
292
393
  /**
293
394
  * @typedef {Object} EffectConfiguration
294
395
  * @property {StepRunner} [onStep] - Fires every time a Command is executed. It wraps the `cmd` call.
295
396
  * @property {RunWrapper} [onRun] - Fires once per runEffect call. It wraps the entire workflow execution.
296
397
  * @property {CommandInterceptor} [onBeforeCommand] - Intercepts a Command and any context passed to runEffect before execution.
297
- * @property {{ attempts?: number, delay?: number, backoff?: number }} [retry] - Global Retry defaults; merged under per-use options.
298
398
  */
299
399
 
300
400
  /**
301
- * Configures the global behavior of the Effect runner, including the command interceptor and telemetry.
401
+ * A per-call configuration: an `EffectConfiguration` plus `inherit`. With `inherit: true` (the default)
402
+ * the call's hooks are added to the wiring `configureEffect` installed, by the merge `configureEffect`
403
+ * applies to several configurations: global outermost, interceptors in order. With `inherit: false` the global wiring is not consulted at all, so any slot the call
404
+ * leaves unset falls back to the library default. A per-call hook cannot replace a single global slot on
405
+ * its own; that was the previous default, and it is how a per-call recorder silently switched off an
406
+ * application's tracing.
302
407
  *
303
- * Several configurations can be passed and are merged, which is how independent concerns share the
304
- * one slot each hook has:
408
+ * @typedef {EffectConfiguration & { inherit?: boolean }} CallConfiguration
409
+ */
410
+
411
+ /** @type {EffectConfiguration[]} */
412
+ let layers = [];
413
+
414
+ /**
415
+ * The installed layers merged into one configuration, empty when nothing is configured. This is the
416
+ * only derived form of `layers`: `runEffect` reads it and merges the call's configuration over it,
417
+ * and a slot no layer defines is simply absent, so the library default is chosen at the one place the
418
+ * hook is used rather than held in a second set of variables kept in step by hand.
419
+ * @type {EffectConfiguration}
420
+ */
421
+ let globalConfig = {};
422
+
423
+ /** Recomputes the effective wiring from the installed layers, earlier layers outermost. */
424
+ const applyLayers = () => {
425
+ globalConfig = chainHooks(...layers);
426
+ };
427
+
428
+ /**
429
+ * Adds a configuration to the global wiring of the Effect runner: telemetry and the command
430
+ * interceptor. Retry options are not part of it; they are per-use, passed to `Retry(effect, options)`.
305
431
  *
306
- * configureEffect(telemetryHooks(), recordingHooks({ sink }))
432
+ * Each call adds one layer on top of those already installed and returns a function that removes that
433
+ * layer, wherever it sits by then. Layers merge the way several configurations passed to one call do:
434
+ * `onStep` and `onRun` are wrappers, so they nest with the earliest layer outermost and the latest
435
+ * closest to the Command; and `onBeforeCommand` interceptors all run, in the order installed. So these
436
+ * are the same:
307
437
  *
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.
438
+ * configureEffect(telemetryHooks(), recordingHooks({ sink }));
439
+ * configureEffect(telemetryHooks()); configureEffect(recordingHooks({ sink }));
311
440
  *
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.
441
+ * Calling it with no arguments at all removes every layer. A call whose arguments are all `undefined`
442
+ * is a conditional install that installed nothing: it adds no layer and removes none.
314
443
  *
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.
444
+ * Layers replaced the previous one-slot rule, under which a later call displaced the earlier wiring and
445
+ * the returned function restored a snapshot, guarded so that interleaved installs could not clobber
446
+ * each other. Removing a layer needs no guard: A installs, B installs, A removes, and B is still there.
447
+ * It also means a library can install its own hooks without erasing its host's, which under the old rule
448
+ * was possible only if the host passed the library's configuration into its own call.
322
449
  *
323
- * @param {...(EffectConfiguration | undefined)} configs - Configurations to merge, outermost first
324
- * @returns {() => void} Restores the wiring that was in place before this call
450
+ * @param {...(EffectConfiguration | undefined)} configs - Configurations merged into one layer, outermost first
451
+ * @returns {() => void} Removes the layer this call added; a second call does nothing
325
452
  */
326
453
  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
-
454
+ // Only a call with no arguments at all is a reset. A call whose arguments are all absent, such as
455
+ // `configureEffect(flag ? hooks : undefined)`, is a conditional install that happened not to install
456
+ // anything, so it adds no layer and must not remove anyone else's.
457
+ if (configs.length === 0) {
458
+ layers = [];
459
+ applyLayers();
460
+ return () => {};
461
+ }
462
+ const present = configs.filter(Boolean);
463
+ // Checked before anything is installed, so a refused call leaves the wiring exactly as it found it.
464
+ present.forEach((config) => rejectRetryKey(config, 'configureEffect'));
465
+ if (present.length === 0) return () => {};
466
+ const layer = chainHooks(...present);
467
+ layers = [...layers, layer];
468
+ applyLayers();
343
469
  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;
470
+ if (!layers.includes(layer)) return;
471
+ layers = layers.filter((l) => l !== layer);
472
+ applyLayers();
358
473
  };
359
474
  };
360
475
 
361
476
  /**
362
477
  * @typedef {Object} StepStart
363
- * @property {string} name - `cmd.name`, or 'anonymous'.
364
- * @property {string} type - Always 'Command' today.
478
+ * @property {string} name - The Command's identity: `meta.name`, else `cmd.name`, else 'anonymous'.
479
+ * @property {string} type - 'Command', or 'Parallel' for a Parallel's decision.
365
480
  * @property {string} [path] - The Command's position in the Effect tree.
366
481
  */
367
482
 
@@ -416,9 +531,8 @@ const observeSteps = (handler) => async (name, type, op, path) => {
416
531
  * `onStep` and `onRun` are wrappers around an `op`, so they nest. The first config given is
417
532
  * the outermost wrapper and the last sits closest to the Command, which also means a thrown
418
533
  * Command unwinds from the last config back to the first. `onBeforeCommand` is an observer,
419
- * so every interceptor runs in the order given. `retry` is plain data and merges with later
420
- * configs winning. A hook no config defines is left unset, so `configureEffect` keeps its
421
- * default for that slot.
534
+ * so every interceptor runs in the order given. Nothing else merges, since every slot is a hook. A hook
535
+ * no config defines is left unset, so the caller of the merged wiring keeps its default for that slot.
422
536
  *
423
537
  * @param {...(EffectConfiguration | undefined)} configs - Configurations to merge, outermost first
424
538
  * @returns {EffectConfiguration}
@@ -435,7 +549,6 @@ const chainHooks = (...configs) => {
435
549
  const steps = /** @type {StepRunner[]} */ (present('onStep'));
436
550
  const runs = /** @type {RunWrapper[]} */ (present('onRun'));
437
551
  const interceptors = /** @type {CommandInterceptor[]} */ (present('onBeforeCommand'));
438
- const retries = present('retry');
439
552
 
440
553
  if (steps.length) {
441
554
  merged.onStep = steps.reduceRight(
@@ -452,7 +565,6 @@ const chainHooks = (...configs) => {
452
565
  for (const intercept of interceptors) await intercept(command, context);
453
566
  };
454
567
  }
455
- if (retries.length) merged.retry = Object.assign({}, ...retries);
456
568
  return merged;
457
569
  };
458
570
 
@@ -469,6 +581,67 @@ const parallelCancelled = () =>
469
581
  name: 'ParallelCancelled'
470
582
  });
471
583
 
584
+ /**
585
+ * Which branch, if any, cancelled a Parallel. `branch: null` means an enclosing Parallel cancelled it.
586
+ * It is recorded as the Parallel's own step, since it is decided by timing and a replay cannot recompute it.
587
+ * @typedef {{ cancelled: false } | { cancelled: true, branch: number | null }} ParallelDecision
588
+ */
589
+
590
+ /**
591
+ * Reads a recorded decision, or `undefined` for anything that is not one, so a Resolver written for
592
+ * Commands, or a trace from before decisions were recorded, replays a Parallel under timing as before.
593
+ * @param {any} value
594
+ * @param {number} branches - How many branches the Parallel has, so a stale branch index is refused too
595
+ * @returns {ParallelDecision | undefined}
596
+ */
597
+ const asDecision = (value, branches) => {
598
+ if (!value || typeof value !== 'object') return undefined;
599
+ if (value.cancelled === false) return { cancelled: false };
600
+ if (value.cancelled !== true) return undefined;
601
+ const { branch } = value;
602
+ if (branch === null) return { cancelled: true, branch: null };
603
+ return Number.isInteger(branch) && branch >= 0 && branch < branches ? { cancelled: true, branch } : undefined;
604
+ };
605
+
606
+ /**
607
+ * Marks the point where a replay reached a step that production never ran, in a branch a Parallel had
608
+ * cancelled. The interpreter stops the branch there, as production did, rather than treating the gap
609
+ * as a divergence. Only `fromTrace` raises it, and only where a recorded decision explains the gap.
610
+ */
611
+ const replayCut = Symbol('pure-effect.replayCut');
612
+
613
+ /**
614
+ * @param {string} path
615
+ * @returns {Error}
616
+ */
617
+ const replayCutError = (path) =>
618
+ Object.defineProperty(
619
+ asHarnessError(new Error(`Replay stopped a cancelled Parallel branch at path '${path}'.`)),
620
+ replayCut,
621
+ { value: true }
622
+ );
623
+
624
+ /**
625
+ * Awaits every task, with at most `limit` of them in flight. Without a limit this is `Promise.all`,
626
+ * which is what `Parallel` did before the option existed. Workers pull by index, so a slow branch
627
+ * holds one slot rather than a position: results land where the caller put the effect, never where it
628
+ * happened to finish, which is also what keeps trace paths stable.
629
+ * @param {(() => Promise<void>)[]} tasks
630
+ * @param {number} [limit]
631
+ * @returns {Promise<void>}
632
+ */
633
+ const runBounded = async (tasks, limit) => {
634
+ if (!limit || limit >= tasks.length) {
635
+ await Promise.all(tasks.map((task) => task()));
636
+ return;
637
+ }
638
+ let cursor = 0;
639
+ const worker = async () => {
640
+ for (let i = cursor++; i < tasks.length; i = cursor++) await tasks[i]();
641
+ };
642
+ await Promise.all(Array.from({ length: limit }, worker));
643
+ };
644
+
472
645
  /**
473
646
  * Sleeps, but gives up early when the surrounding branch is cancelled, so a sibling's failure does not
474
647
  * have to wait out a retry backoff that is now pointless. Resolves either way: the caller's abort check
@@ -484,6 +657,13 @@ const delayFor = (ms, signal) =>
484
657
  setTimeout(resolve, ms);
485
658
  return;
486
659
  }
660
+ // A listener added to a signal that has already fired never runs. That is the usual case, not an edge:
661
+ // a Command that honours the signal rejects the moment a sibling fails, and the Retry around it starts
662
+ // its backoff on the aborted signal.
663
+ if (signal.aborted) {
664
+ resolve();
665
+ return;
666
+ }
487
667
  const onAbort = () => {
488
668
  clearTimeout(timer);
489
669
  resolve();
@@ -501,20 +681,32 @@ const runEffect =
501
681
  * Iterates through the Effect tree, executing Commands and handling async flow.
502
682
  * Ask effects are resolved synchronously with the context object.
503
683
  *
504
- * Per-call config takes precedence over global configureEffect defaults.
505
- * onRun fires exactly once per runEffect call. Retry attempts run inside that
684
+ * Per-call config is merged over the global configureEffect wiring, or over nothing under
685
+ * `inherit: false`. onRun fires exactly once per runEffect call. Retry attempts run inside that
506
686
  * single span rather than spawning their own, keeping telemetry non-duplicated.
507
687
  *
508
688
  * @param {Effect} effect - The Effect tree returned by a pipeline
509
689
  * @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.
690
+ * @param {CallConfiguration} [callConfig] - Per-call configuration, added to the wiring `configureEffect`
691
+ * installed unless `inherit: false`, which ignores that wiring for this run.
511
692
  * @returns {Promise<SuccessState | FailureState>}
512
693
  */
513
694
  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;
695
+ rejectRetryKey(callConfig, "runEffect's callConfig");
696
+ const { inherit = true, ...local } = callConfig;
697
+ // A value that is not a boolean, such as a string left over from an older API, must not be
698
+ // coerced: `'false'` inheriting everything is exactly the silent behaviour this option removes.
699
+ if (typeof inherit !== 'boolean') {
700
+ throw new TypeError(`callConfig.inherit must be true or false, got ${JSON.stringify(inherit)}.`);
701
+ }
702
+ // The call's configuration is merged over the installed wiring, or over nothing when the call
703
+ // inherits nothing, by the same merge `configureEffect` applies to several configurations. Most
704
+ // calls carry no configuration of their own, and those use the installed wiring as it is.
705
+ const base = inherit ? globalConfig : {};
706
+ const resolved = Object.keys(local).length ? chainHooks(base, local) : base;
707
+ const localStepRunner = resolved.onStep || defaultStepRunner;
708
+ const localRunWrapper = resolved.onRun || defaultRunWrapper;
709
+ const localCommandInterceptor = resolved.onBeforeCommand || defaultCommandInterceptor;
518
710
 
519
711
  /**
520
712
  * @param {Effect} eff
@@ -526,7 +718,7 @@ const runEffect =
526
718
  * attempt opens its own prefix, so a Command's full path depends only on the shape of the
527
719
  * tree and not on the order branches happen to finish in. That is what lets a replay line
528
720
  * a recorded step up with the step that asked for it.
529
- * @returns {Promise<SuccessState | FailureState>}
721
+ * @returns {Promise<SuccessState | FailureState | IoFaultState>}
530
722
  */
531
723
  async function execute(eff, signal, path = '') {
532
724
  let step = 0;
@@ -542,8 +734,17 @@ const runEffect =
542
734
  continue;
543
735
  }
544
736
  if (eff.type === 'Retry') {
545
- const opts = { ...localRetryDefaults, ...eff.options };
737
+ const opts = { ...defaultRetryOptions, ...eff.options };
546
738
  const { attempts } = opts;
739
+ // A Retry that does not retry is not a Retry, and `attempts: 0` was also the one
740
+ // spelling that made `onExhausted` a plain catch for free. Both readings are wrong,
741
+ // so the value is refused rather than coerced.
742
+ if (!Number.isInteger(attempts) || attempts < 1)
743
+ throw new TypeError(
744
+ `Retry 'attempts' must be a positive integer, received ${describeValue(attempts)}. ` +
745
+ `To handle an outcome without retrying, branch on it as data in the Command's ` +
746
+ `next, or isolate a failing branch with Parallel's settled option.`
747
+ );
547
748
  let lastError;
548
749
  let succeeded = false;
549
750
  // Each attempt gets its own prefix, so the Commands of attempt 2 cannot be mistaken
@@ -567,13 +768,19 @@ const runEffect =
567
768
  succeeded = true;
568
769
  break;
569
770
  }
771
+ // An abort is the flow deciding, not the I/O failing, so there is nothing to
772
+ // try again and nothing for a fallback to answer. It leaves unwrapped: the
773
+ // exhaustion shape would be a claim about retrying that never happened.
774
+ if (result.type !== 'IoFault') return result;
570
775
  lastError = result.error;
571
776
  }
572
777
 
573
778
  if (!succeeded) {
574
779
  const exhausted = { retryExhausted: true, lastError, attempts };
575
780
  const { onExhausted } = opts;
576
- if (typeof onExhausted !== 'function') return Failure(exhausted, eff.initialInput);
781
+ // An exhaustion came from I/O, so it is a fault too, which is what keeps an
782
+ // enclosing Retry retrying this one.
783
+ if (typeof onExhausted !== 'function') return IoFault(exhausted, eff.initialInput);
577
784
  // A cancelled branch must not start its fallback, for the same reason it starts
578
785
  // no further Commands: recovery must not resurrect work a sibling's failure ended.
579
786
  if (signal?.aborted) return Failure(parallelCancelled(), eff.initialInput);
@@ -586,7 +793,7 @@ const runEffect =
586
793
  );
587
794
  // A failing fallback propagates as-is: the flow's last word was the fallback's
588
795
  // error, not the exhaustion it was already told about.
589
- if (fallback.type === 'Failure') return fallback;
796
+ if (fallback.type !== 'Success') return fallback;
590
797
  eff = retryNext(fallback.value);
591
798
  }
592
799
  continue;
@@ -596,42 +803,164 @@ const runEffect =
596
803
  // branches complete in, which is the whole point: two branches calling the same
597
804
  // Command are told apart by where they are rather than by who finished first.
598
805
  const branchPath = `${path}${step++}p`;
599
- // One scope per Parallel, linked to the enclosing one so cancellation nests.
600
- // Absent AbortController (very old runtimes), `branchSignal` stays undefined and
601
- // the old run-everything-to-completion behaviour is what happens.
602
- const scope = typeof AbortController === 'function' ? new AbortController() : undefined;
603
- const branchSignal = scope?.signal;
604
- const relay = () => scope?.abort();
605
- if (signal && scope) {
606
- if (signal.aborted) scope.abort();
607
- else signal.addEventListener('abort', relay, { once: true });
608
- }
806
+ const { limit, settled } = eff.options ?? {};
807
+ if (limit !== undefined && (!Number.isInteger(limit) || limit < 1))
808
+ throw new TypeError(
809
+ `Parallel 'limit' must be a positive integer, received ${describeValue(limit)}.`
810
+ );
811
+ // Captured while `eff` is still narrowed to a Parallel node, since `op` below runs later.
812
+ const effects = eff.effects;
813
+ const parallelInput = eff.initialInput;
814
+
815
+ /** @type {(SuccessState | FailureState | IoFaultState)[]} */
816
+ let results = [];
817
+ // Cast rather than annotated: `runBranches` reassigns it, which the checker cannot see from
818
+ // here, and an annotated literal would be narrowed to `{ cancelled: false }` for good.
819
+ let decision = /** @type {ParallelDecision} */ ({ cancelled: false });
820
+ let ran = false;
821
+ // What a branch threw, held until this Parallel's step has returned its decision. Cast
822
+ // rather than annotated, for the same reason as `decision`.
823
+ let branchThrow = /** @type {{ error: unknown } | undefined} */ (undefined);
824
+
825
+ /**
826
+ * Runs the branches and returns the decision: which branch, if any, cancelled the others.
827
+ * That decision comes from timing, so it is handed to `onStep` as this Parallel's step
828
+ * and recorded like a Command's result. A replay passes the recorded decision back in,
829
+ * and a cancelled one is reproduced rather than recomputed: nothing aborts, each branch
830
+ * runs its recorded steps and is stopped where its recording stops, and the recorded
831
+ * branch's failure is the result. Replaying the abort at replay speed is what let a
832
+ * cancelled branch's AbortError win, or let a branch run past where production stopped it.
833
+ * A branch that throws gets a decision too, and its throw is rethrown once the step has
834
+ * returned it: thrown from here, the step failed and recorded the error where the decision
835
+ * belonged, so the replay of a run that crashed raced the branches again.
836
+ * @param {any} [recorded]
837
+ * @returns {Promise<ParallelDecision>}
838
+ */
839
+ const runBranches = async (recorded) => {
840
+ ran = true;
841
+ // A recorded decision naming a branch past the end of this Parallel means the shape
842
+ // changed since the recording. Falling back to timing would let whichever remaining
843
+ // branch the replay reaches first decide, which is the wrong answer this fixed. A
844
+ // negative branch is not a decision at all, since none is ever recorded, so it replays
845
+ // by timing like anything else that is not one.
846
+ const recordedBranch = recorded?.cancelled === true ? recorded.branch : undefined;
847
+ if (Number.isInteger(recordedBranch) && recordedBranch >= effects.length) {
848
+ const count = effects.length === 1 ? '1 branch' : `${effects.length} branches`;
849
+ throw replayError(
850
+ `Time paradox at path '${branchPath}': the recorded run was cancelled by branch ` +
851
+ `${recordedBranch}, but this Parallel has ${count}.`,
852
+ { name: 'TimeParadox', path: branchPath, branch: recordedBranch }
853
+ );
854
+ }
855
+ const forced = asDecision(recorded, effects.length);
856
+ const reproducing = forced !== undefined && forced.cancelled;
857
+ // One scope per Parallel, linked to the enclosing one so cancellation nests.
858
+ // Absent AbortController (very old runtimes), `branchSignal` stays undefined and
859
+ // the old run-everything-to-completion behaviour is what happens. The scope is kept
860
+ // under `settled` so an enclosing Parallel can still cancel this one; what settled
861
+ // drops is a branch cancelling its own siblings.
862
+ const scope = typeof AbortController === 'function' ? new AbortController() : undefined;
863
+ const branchSignal = scope?.signal;
864
+ const relay = () => scope?.abort();
865
+ if (signal && scope) {
866
+ if (signal.aborted) scope.abort();
867
+ else signal.addEventListener('abort', relay, { once: true });
868
+ }
869
+
870
+ results = new Array(effects.length);
871
+ // Which branch failed on its own account rather than because it was cancelled.
872
+ const triggered = new Array(effects.length).fill(false);
873
+ // A branch that throws (a bug in its code, or a harness error) cancels its siblings
874
+ // like a failing one, and is the trigger when it is the first to, under `settled` too,
875
+ // since the run is rejecting either way. When reproducing, nothing aborts: the recorded
876
+ // decision already says where each branch stops. The error is held rather than rethrown
877
+ // at once: rejecting `runBounded` early used to leave the siblings running unobserved,
878
+ // and under `limit` it stopped that worker.
879
+ /** @type {{ error: unknown }[]} */
880
+ const thrown = new Array(effects.length);
881
+ try {
882
+ // Still awaits every branch, so no cancelled work is left running unobserved
883
+ // after the Failure is returned. Cancelled branches settle promptly; a branch
884
+ // whose in-flight Command ignores the signal is the one case that does not.
885
+ await runBounded(
886
+ effects.map((e, i) => async () => {
887
+ let result;
888
+ try {
889
+ result = await execute(e, branchSignal, `${branchPath}${i}/`);
890
+ } catch (error) {
891
+ thrown[i] = { error };
892
+ if (reproducing) return;
893
+ if (!branchSignal?.aborted) triggered[i] = true;
894
+ scope?.abort();
895
+ return;
896
+ }
897
+ results[i] = result;
898
+ // Read-then-abort is atomic here, so exactly one branch is the trigger.
899
+ const cancels = !settled && !reproducing && !branchSignal?.aborted;
900
+ if (result.type !== 'Success' && cancels) {
901
+ triggered[i] = true;
902
+ scope?.abort();
903
+ }
904
+ }),
905
+ limit
906
+ );
907
+ } finally {
908
+ if (signal && scope) signal.removeEventListener('abort', relay);
909
+ }
910
+ // The first by array order. A thrown error is held until every branch has settled, so
911
+ // position can decide here, where a failing branch is taken in the order it finishes.
912
+ branchThrow = thrown.find(Boolean);
913
+
914
+ if (forced !== undefined && forced.cancelled) {
915
+ // A branch that threw has no result to check, and its throw is what is rethrown.
916
+ if (!branchThrow && forced.branch !== null && results[forced.branch].type === 'Success') {
917
+ throw replayError(
918
+ `Time paradox at path '${branchPath}': the recorded run was cancelled by ` +
919
+ `branch ${forced.branch}, which did not fail in this replay.`,
920
+ { name: 'TimeParadox', path: branchPath, branch: forced.branch }
921
+ );
922
+ }
923
+ decision = forced;
924
+ return decision;
925
+ }
926
+ const trigger = triggered.indexOf(true);
927
+ decision =
928
+ trigger >= 0
929
+ ? { cancelled: true, branch: trigger }
930
+ : branchSignal?.aborted
931
+ ? { cancelled: true, branch: null }
932
+ : { cancelled: false };
933
+ return decision;
934
+ };
609
935
 
610
- /** @type {(SuccessState | FailureState)[]} */
611
- const results = new Array(eff.effects.length);
612
- // Which branch failed on its own account rather than because it was cancelled.
613
- const triggered = new Array(eff.effects.length).fill(false);
614
936
  try {
615
- // Still awaits every branch, so no cancelled work is left running unobserved
616
- // after the Failure is returned. Cancelled branches settle promptly; a branch
617
- // whose in-flight Command ignores the signal is the one case that does not.
618
- await Promise.all(
619
- eff.effects.map(async (e, i) => {
620
- const result = await execute(e, branchSignal, `${branchPath}${i}/`);
621
- results[i] = result;
622
- // Read-then-abort is atomic here, so exactly one branch is the trigger.
623
- if (result.type === 'Failure' && !branchSignal?.aborted) {
624
- triggered[i] = true;
625
- scope?.abort();
626
- }
627
- })
937
+ await localStepRunner('Parallel', 'Parallel', runBranches, branchPath);
938
+ } catch (e) {
939
+ // A replay found this Parallel inside a branch production had already stopped.
940
+ if (e && /** @type {any} */ (e)[replayCut]) return Failure(parallelCancelled(), parallelInput);
941
+ throw e;
942
+ }
943
+ if (!ran) {
944
+ throw new TypeError(
945
+ `An onStep hook returned without calling op for the Parallel at path '${branchPath}'. ` +
946
+ 'A hook has to call op for a Parallel, because op runs its branches.'
628
947
  );
629
- } finally {
630
- if (signal && scope) signal.removeEventListener('abort', relay);
948
+ }
949
+ // Rethrown here, after the step has returned its decision, so the decision is recorded.
950
+ if (branchThrow) throw branchThrow.error;
951
+
952
+ // Settled hands the outcomes on as plain Success and Failure nodes. A branch that
953
+ // failed is data here, so `next` runs and the Parallel itself never fails on a
954
+ // branch's account; and a Failure `next` returns is an abort like any other.
955
+ if (settled) {
956
+ eff = eff.next(results.map(asOutcome));
957
+ continue;
631
958
  }
632
959
 
633
- const trigger = triggered.indexOf(true);
634
- const failure = trigger >= 0 ? results[trigger] : results.find((r) => r.type === 'Failure');
960
+ const failure =
961
+ decision.cancelled && decision.branch !== null
962
+ ? results[decision.branch]
963
+ : results.find((r) => r.type !== 'Success');
635
964
  if (failure) return failure;
636
965
  eff = eff.next(results.map((r) => /** @type {SuccessState} */ (r).value));
637
966
  continue;
@@ -640,30 +969,79 @@ const runEffect =
640
969
  const initialInput = eff.initialInput;
641
970
  const cmdPath = `${path}${step++}`;
642
971
  const cmd = eff.cmd;
643
- // The signal reaches the thunk only inside a Parallel, so a thunk written to take a
644
- // parameter is not handed an argument it never expected anywhere else.
645
- const op = signal ? () => cmd(signal) : cmd;
972
+ // Whether the Command's function itself succeeded. That is what tells a throw from the step
973
+ // runner apart: the function failing is an I/O fault, while a hook failing after the function
974
+ // returned is a bug, and retrying it would repeat work that is already done.
975
+ let succeeded = false;
976
+ // The signal reaches the function only inside a Parallel, so a function written to take a
977
+ // parameter is not handed an argument it never expected anywhere else. `op` always returns a
978
+ // promise, as the declared type and the README's "must await op()" say, and a synchronous
979
+ // throw arrives as a rejection. It used to hand back a synchronous function's value as it was,
980
+ // so a hook written as `op().then(...)` compiled and then rejected every such run.
981
+ const op = async () => {
982
+ succeeded = false;
983
+ const value = await (signal ? cmd(signal) : cmd());
984
+ succeeded = true;
985
+ return value;
986
+ };
987
+ // Three separate regions, because a throw means something different in each. An
988
+ // interceptor that throws is vetoing the Command, which is the flow being stopped rather
989
+ // than the I/O failing, so it is an abort and `Retry` passes it on. Only a throw from the
990
+ // Command's function is an I/O fault. A malformed flow is a bug, not a domain failure,
991
+ // so in either catch it must not masquerade as one.
646
992
  try {
647
993
  await localCommandInterceptor(eff, context);
648
- const result = await localStepRunner(cmdName, 'Command', op, cmdPath);
649
- eff = eff.next(result);
650
994
  } catch (e) {
651
- // A malformed flow is a bug, not a domain failure, so it must not masquerade as one.
652
- if (e instanceof Error && e.name === 'EffectTypeError') throw e;
995
+ if (e && /** @type {any} */ (e)[harnessError]) throw e;
653
996
  return Failure(e, initialInput);
654
997
  }
998
+ // Checked again because an interceptor can wait, a rate limiter handing out tokens for one, and a
999
+ // sibling can fail meanwhile. The check at the top of the loop ran before that wait.
1000
+ if (signal?.aborted) return Failure(parallelCancelled(), initialInput);
1001
+ let result;
1002
+ try {
1003
+ result = await localStepRunner(cmdName, 'Command', op, cmdPath);
1004
+ } catch (e) {
1005
+ // A replay reached a step production never ran, in a branch a Parallel had cancelled:
1006
+ // this is where production stopped the branch, so the replay stops it here too.
1007
+ if (e && /** @type {any} */ (e)[replayCut]) return Failure(parallelCancelled(), initialInput);
1008
+ if (e && /** @type {any} */ (e)[harnessError]) throw e;
1009
+ // The function returned and a hook threw afterwards: a bug in the hook, rejected like a
1010
+ // throw from `next`. A hook that throws without calling `op` is still a fault, since that
1011
+ // is how replay reports a recorded error.
1012
+ if (succeeded) throw e;
1013
+ return IoFault(e, initialInput);
1014
+ }
1015
+ // Outside both catches: `next`, and every pure step it reaches up to the next Command, is
1016
+ // code rather than I/O, so a throw there is a bug and rejects the run like a throw from
1017
+ // any other continuation. Inside the catch it was an I/O fault, and `Retry` re-ran a
1018
+ // Command that had succeeded because a TypeError followed it.
1019
+ eff = eff.next(result);
655
1020
  }
656
1021
  if (eff && (eff.type === 'Success' || eff.type === 'Failure')) return eff;
657
1022
  throw effectTypeError(eff, 'The flow');
658
1023
  }
659
1024
 
660
- return localRunWrapper(effect, () => execute(effect), context?.flowName || '');
1025
+ // Every Failure leaves through here, including one a Parallel branch or a Retry fallback handed
1026
+ // back. `chain` stamps only what passes through the continuations it wraps, and those subtrees
1027
+ // are executed directly rather than reached through a continuation, so their Failures arrive
1028
+ // carrying the subtree's own input or none. Restamping the outcome with the root's input is
1029
+ // what makes "a Failure carries the input of the flow that was called" true at any depth. An I/O
1030
+ // fault becomes a plain Failure here too, since this is where it would first reach the caller.
1031
+ const rootInput = effect?.initialInput;
1032
+ const run = async () => {
1033
+ const result = asOutcome(await execute(effect));
1034
+ return result.type === 'Failure' && rootInput !== undefined ? Failure(result.error, rootInput) : result;
1035
+ };
1036
+ return localRunWrapper(effect, run, context?.flowName || '');
661
1037
  };
662
1038
 
663
1039
  /**
664
1040
  * The step a replay is asking about. `path` is the Command's position in the Effect tree and is stable
665
1041
  * across runs; `index` is its position in this run's completion order, which is not stable for a flow
666
- * containing `Parallel`. Prefer `path` when writing a Resolver.
1042
+ * containing `Parallel`. Prefer `path` when writing a Resolver. A step whose `type` is 'Parallel' asks
1043
+ * for a Parallel's recorded decision; it does not advance `index`, and anything but a decision replays
1044
+ * that Parallel under timing.
667
1045
  * @typedef {{ index: number, name: string, type: string, path?: string }} ReplayStep
668
1046
  */
669
1047
 
@@ -678,11 +1056,15 @@ const runEffect =
678
1056
  /** @typedef {(step: ReplayStep) => ReplayOutcome | undefined} Resolver */
679
1057
 
680
1058
  /**
681
- * A recorded step. `durationMs` is how long the Command took in production, rounded to microseconds,
1059
+ * A recorded step: a Command's result or error, or a Parallel's decision, recorded as `command`
1060
+ * 'Parallel' at the Parallel's own path with the decision as its `result`.
1061
+ * `durationMs` is how long the step took in production, rounded to microseconds,
682
1062
  * which is the one question a trace could not answer before: which step was slow.
683
1063
  * `path` locates the Command in the Effect tree, which is what a replay matches on: it does not move
684
1064
  * when `Parallel` branches finish in a different order than they did in production.
685
- * @typedef {{ command: string, path?: string, result?: any, error?: any, durationMs?: number }} TraceEntry
1065
+ * `threw` marks a step that threw. The `error` key alone cannot, since JSON drops it when the value is
1066
+ * `undefined`, as it is for `reject()` with no argument or an error `redact` removed.
1067
+ * @typedef {{ command: string, path?: string, result?: any, threw?: true, error?: any, durationMs?: number }} TraceEntry
686
1068
  */
687
1069
 
688
1070
  /**
@@ -699,13 +1081,29 @@ const runEffect =
699
1081
  */
700
1082
 
701
1083
  /**
702
- * Creates a replay error. Thrown from a resolver, it is caught by the interpreter
703
- * and returned as a Failure, exactly like a rejected Command.
1084
+ * A replay fault: the trace cannot answer the flow, or disagrees with it. It is a harness error while
1085
+ * the flow runs, so nothing inside the flow can swallow it, and `replayEffect` turns it back into a
1086
+ * `Failure` at its own boundary, where there is no longer anything to swallow it and a caller can
1087
+ * inspect it. An `EffectTypeError` carries no replay mark and keeps propagating, since a malformed
1088
+ * flow is a bug in the flow rather than a problem with the trace.
1089
+ */
1090
+ const replayFault = Symbol('pure-effect.replayFault');
1091
+
1092
+ /**
1093
+ * Creates a replay error. Thrown from a resolver, it is rethrown by the interpreter rather than folded
1094
+ * into a Failure, and `replayEffect` turns it into one at its own boundary.
704
1095
  * @param {string} message - Human-readable reason
705
1096
  * @param {Object} [props] - Extra fields such as `name`, `index`, `expected`, `actual`
706
1097
  * @returns {Error}
707
1098
  */
708
- const replayError = (message, props = {}) => Object.assign(new Error(message), { name: 'ReplayError' }, props);
1099
+ const replayError = (message, props = {}) =>
1100
+ Object.defineProperty(
1101
+ asHarnessError(Object.assign(new Error(message), { name: 'ReplayError' }, props)),
1102
+ replayFault,
1103
+ {
1104
+ value: true
1105
+ }
1106
+ );
709
1107
 
710
1108
  /**
711
1109
  * Signals that the flow being replayed asked for a different Command than the trace
@@ -742,6 +1140,14 @@ const serializeError = (e, withStack) => {
742
1140
  if (withStack) out.stack = e.stack;
743
1141
  // `cause` is non-enumerable too, and it can itself be an Error, so it is carried recursively.
744
1142
  if ('cause' in e) out.cause = serializeError(e.cause, withStack);
1143
+ // So is an AggregateError's `errors`, and that is where the detail lives: Node's refused connection to
1144
+ // localhost has an empty message and one entry per address it tried. It is kept under its own key so
1145
+ // that revival can tell it from an enumerable `errors`, which a validation error carries as data and
1146
+ // the loop below copies as it is.
1147
+ const errors = /** @type {any} */ (e).errors;
1148
+ if (Array.isArray(errors) && !Object.prototype.propertyIsEnumerable.call(e, 'errors')) {
1149
+ out.__errors = errors.map((x) => serializeError(x, withStack));
1150
+ }
745
1151
  for (const k of Object.keys(e)) out[k] = /** @type {any} */ (e)[k];
746
1152
  return out;
747
1153
  };
@@ -765,7 +1171,9 @@ const reviveError = (v) => {
765
1171
  for (const [k, val] of Object.entries(v)) {
766
1172
  if (k === '__error' || k === 'name' || k === 'message') continue;
767
1173
  if (k === 'cause') Object.defineProperty(e, 'cause', { ...hidden, value: reviveError(val) });
768
- else /** @type {any} */ (e)[k] = val;
1174
+ else if (k === '__errors' && Array.isArray(val)) {
1175
+ Object.defineProperty(e, 'errors', { ...hidden, value: val.map(reviveError) });
1176
+ } else /** @type {any} */ (e)[k] = val;
769
1177
  }
770
1178
  return e;
771
1179
  };
@@ -776,7 +1184,8 @@ const reviveError = (v) => {
776
1184
  * each Command's result, each serialized error, and the `initialInput` and `context` stored on the
777
1185
  * trace itself. `kind` is `'result'`, `'error'`, `'initialInput'`, or `'context'`, and `name` is the
778
1186
  * Command's name for the first two and the kind for the last two. It is the single place PII is kept
779
- * out of a trace, so it has to see all four.
1187
+ * out of a trace, so it has to see all four. It is handed a copy, so changing the value in place
1188
+ * never reaches the run.
780
1189
  * @property {number} [maxEntries] - Caps trace length; further steps are counted in `dropped`, not stored.
781
1190
  * @property {boolean} [stack] - Records stack traces for thrown errors.
782
1191
  */
@@ -790,12 +1199,41 @@ const reviveError = (v) => {
790
1199
  */
791
1200
 
792
1201
  /**
793
- * Snapshots a value on its way into a trace.
1202
+ * Copies what `structuredClone` refused. Arrays and plain objects are rebuilt and everything inside them is
1203
+ * copied in turn, so only the parts that cannot be copied, such as a function or an object holding one, are
1204
+ * kept as they are. A context holding a logger is the usual case, and keeping the whole context by reference
1205
+ * handed `redact` the live object the flow's `Ask` reads.
1206
+ *
1207
+ * @param {any} value
1208
+ * @param {Map<object, any>} seen - Copies made so far, so a cycle is copied as a cycle
1209
+ * @returns {any}
1210
+ */
1211
+ const copyAround = (value, seen) => {
1212
+ if (value === null || typeof value !== 'object') return value;
1213
+ if (seen.has(value)) return seen.get(value);
1214
+ const proto = Object.getPrototypeOf(value);
1215
+ if (!Array.isArray(value) && proto !== Object.prototype && proto !== null) {
1216
+ try {
1217
+ return structuredClone(value);
1218
+ } catch {
1219
+ return value;
1220
+ }
1221
+ }
1222
+ /** @type {any} */
1223
+ const copy = Array.isArray(value) ? [] : Object.create(proto);
1224
+ seen.set(value, copy);
1225
+ for (const key of Object.keys(value)) copy[key] = copyAround(value[key], seen);
1226
+ return copy;
1227
+ };
1228
+
1229
+ /**
1230
+ * Snapshots a value on its way into a trace, and on its way out of one in a replay.
794
1231
  *
795
1232
  * Commands return live objects, and a later step that mutates one would otherwise rewrite what the
796
1233
  * trace says an earlier step returned: `toTrace` runs after the flow finishes, so by serialization
797
- * time the mutation is already baked in and the trace records a value production never saw. Values
798
- * that cannot be cloned fall back to the reference.
1234
+ * time the mutation is already baked in and the trace records a value production never saw. The copy
1235
+ * is also what `redact` is handed, so it has to be one the flow never sees. A value that cannot be
1236
+ * cloned whole is copied around the parts that cannot be copied.
799
1237
  *
800
1238
  * @param {any} value
801
1239
  * @returns {any}
@@ -805,7 +1243,7 @@ const snapshot = (value) => {
805
1243
  try {
806
1244
  return typeof structuredClone === 'function' ? structuredClone(value) : JSON.parse(JSON.stringify(value));
807
1245
  } catch {
808
- return value;
1246
+ return copyAround(value, new Map());
809
1247
  }
810
1248
  };
811
1249
 
@@ -840,29 +1278,38 @@ const recorder = (options = {}) => {
840
1278
  };
841
1279
 
842
1280
  /**
843
- * Redacts and snapshots one of the trace's own fields. `undefined` is left alone so a flow with no
1281
+ * Snapshots one of the trace's own fields and redacts the copy. `undefined` is left alone so a flow with no
844
1282
  * context does not acquire an empty object from a redact function that spreads its argument.
845
1283
  */
846
1284
  const redactField = (/** @type {any} */ value, /** @type {string} */ kind) =>
847
- value === undefined ? undefined : snapshot(safeRedact(value, kind, kind));
1285
+ value === undefined ? undefined : safeRedact(snapshot(value), kind, kind);
848
1286
 
849
- // Built on `observe` so the hook contract lives in one place: `op` always runs, its result is
1287
+ // Built on `observeSteps` so the hook contract lives in one place: `op` always runs, its result is
850
1288
  // always returned, and its error always propagates.
851
- const onStep = observeSteps(({ name, path }) => (end) => {
1289
+ const onStep = observeSteps(({ name, type, path }) => (end) => {
852
1290
  const durationMs = Math.round(end.durationMs * 1000) / 1000;
1291
+ // `redact` is handed a copy. Handed the live value, a redact that deleted a field in place removed it
1292
+ // from the result the flow's `next` was about to receive, or the error the caller would get, so
1293
+ // recording changed what production did. A Parallel's result is its decision, which holds no user
1294
+ // data and has to survive intact for a replay to reproduce it, so `redact` is not asked about it.
1295
+ const recorded = (/** @type {any} */ result) =>
1296
+ type === 'Parallel' ? snapshot(result) : safeRedact(snapshot(result), name, 'result');
853
1297
  push(
854
1298
  'error' in end
855
1299
  ? {
856
1300
  command: name,
857
1301
  path,
858
- error: snapshot(safeRedact(serializeError(end.error, stack), name, 'error')),
1302
+ threw: true,
1303
+ error: safeRedact(snapshot(serializeError(end.error, stack)), name, 'error'),
859
1304
  durationMs
860
1305
  }
861
- : { command: name, path, result: snapshot(safeRedact(end.result, name, 'result')), durationMs }
1306
+ : { command: name, path, result: recorded(end.result), durationMs }
862
1307
  );
863
1308
  });
864
1309
 
865
1310
  /**
1311
+ * Redacts and copies `meta.initialInput` and `meta.context` when it is called, so call it before the run
1312
+ * when the run can change them, and take `dropped` and `trace` from a second call once it ends.
866
1313
  * @param {TraceMeta} [meta]
867
1314
  * @returns {TraceLog}
868
1315
  */
@@ -892,20 +1339,45 @@ const recorder = (options = {}) => {
892
1339
  const recordEffect = async (flowFn, initialInput, options = {}) => {
893
1340
  const { context = {}, version, ...recorderOptions } = options;
894
1341
  const rec = recorder(recorderOptions);
1342
+ // Packaged before the run, which is when `toTrace` copies the input and the context. Packaged after it, a
1343
+ // Command that wrote to either, such as an ORM save assigning an id to the object it was handed, rewrote
1344
+ // what the trace says production received, and the replay was rebuilt from a value the run never saw.
1345
+ const head = rec.toTrace({ initialInput, flowName: context.flowName, context, version });
1346
+ // The recorder is added to the global wiring, so recording inside an instrumented application keeps
1347
+ // its spans.
895
1348
  const result = await runEffect(flowFn(initialInput), context, { onStep: rec.onStep });
896
- return {
897
- result,
898
- trace: rec.toTrace({ initialInput, flowName: context.flowName, context, version })
899
- };
1349
+ const { dropped, trace } = rec.toTrace();
1350
+ return { result, trace: { ...head, dropped, trace } };
900
1351
  };
901
1352
 
902
1353
  /**
903
1354
  * Turns a recorded entry into the outcome a Resolver must return.
904
1355
  *
1356
+ * The recorded value is snapshotted on the way out, as it was on the way in. Handing the flow the
1357
+ * entry's own object let a replayed step that mutates its result rewrite the trace, so replaying the
1358
+ * same trace twice gave two different answers.
1359
+ *
1360
+ * An entry with `threw` is an error, and so is one with an `error` key, which is how an entry recorded
1361
+ * before `threw` existed, or written by hand, says it. Relying on the key alone turned a step that threw
1362
+ * `undefined` into one that returned it, once the trace had been through JSON.
1363
+ *
905
1364
  * @param {TraceEntry} entry
906
1365
  * @returns {ReplayOutcome}
907
1366
  */
908
- const entryToOutcome = (entry) => ('error' in entry ? { error: reviveError(entry.error) } : { result: entry.result });
1367
+ const entryToOutcome = (entry) =>
1368
+ entry.threw === true || 'error' in entry
1369
+ ? { error: reviveError(snapshot(entry.error)) }
1370
+ : { result: snapshot(entry.result) };
1371
+
1372
+ /**
1373
+ * Whether an entry is a Parallel's recorded decision rather than a Command's result. A Parallel's path
1374
+ * ends in its `p` marker and a Command's in its step number, so a Command that happens to be named
1375
+ * 'Parallel' is never mistaken for one.
1376
+ * @param {TraceEntry} entry
1377
+ * @returns {boolean}
1378
+ */
1379
+ const isDecisionEntry = (entry) =>
1380
+ entry.command === 'Parallel' && typeof entry.path === 'string' && entry.path.endsWith('p');
909
1381
 
910
1382
  /**
911
1383
  * Builds a Resolver for the reference trace format. Internal: `replayEffect` calls this
@@ -922,11 +1394,16 @@ const entryToOutcome = (entry) => ('error' in entry ? { error: reviveError(entry
922
1394
  * flow asks for. That is exact for sequential flows and cannot tell `Parallel` branches apart, which
923
1395
  * is what paths were added to fix.
924
1396
  *
1397
+ * A step the trace does not hold resolves to `undefined`, "not recorded", exactly as it does from a
1398
+ * Resolver, so `replayEffect` applies `onMissing` to both. This used to throw a `ReplayError` here,
1399
+ * which meant `onMissing: 'execute'` had no effect on a trace. `missing` describes such a step, so the
1400
+ * error `replayEffect` raises under `'throw'` still says what the trace lacks.
1401
+ *
925
1402
  * @param {TraceLog | TraceEntry[]} traceLog - A reference-format trace, or a bare array of entries
926
1403
  * @param {Object} [options]
927
1404
  * @param {(entry: TraceEntry) => void} [options.onEntry] - Observes each entry as it is handed to a step,
928
1405
  * which is how `replayEffect` learns which recorded entries the flow never asked for.
929
- * @returns {Resolver}
1406
+ * @returns {{ resolve: Resolver, missing: (step: ReplayStep) => string }}
930
1407
  */
931
1408
  const fromTrace = (traceLog, options = {}) => {
932
1409
  const { onEntry } = options;
@@ -945,20 +1422,45 @@ const fromTrace = (traceLog, options = {}) => {
945
1422
  if (byPath.size !== entries.length) {
946
1423
  throw replayError('Trace has duplicate step paths.');
947
1424
  }
948
- return (step) => {
1425
+ /** Recorded Parallel decisions that cancelled branches, by the Parallel's path. */
1426
+ const cancellations = entries.filter(isDecisionEntry).filter((e) => e.result?.cancelled === true);
1427
+ // A missing step is where production stopped a branch when it lies in a branch a recorded decision
1428
+ // cancelled: any branch but the cancelling one, or every branch of a Parallel cancelled from outside.
1429
+ const stoppedInProduction = (/** @type {string | undefined} */ stepPath) =>
1430
+ typeof stepPath === 'string' &&
1431
+ cancellations.some((e) => {
1432
+ const at = /** @type {string} */ (e.path);
1433
+ const branch = stepPath.startsWith(at) ? /^(\d+)\//.exec(stepPath.slice(at.length)) : null;
1434
+ return branch !== null && (e.result.branch === null || Number(branch[1]) !== e.result.branch);
1435
+ });
1436
+ /** @type {Resolver} */
1437
+ const resolve = (step) => {
949
1438
  const entry = byPath.get(step.path);
1439
+ if (step.type === 'Parallel') {
1440
+ // A decision rather than I/O. With none recorded, the Parallel replays under timing, as
1441
+ // it did before decisions were recorded.
1442
+ if (entry && entry.command !== 'Parallel') throw timeParadox(step, entry.command);
1443
+ if (entry) return resolveEntry(entry);
1444
+ if (stoppedInProduction(step.path)) throw replayCutError(/** @type {string} */ (step.path));
1445
+ return undefined;
1446
+ }
950
1447
  if (!entry) {
951
- throw replayError(`Trace has no step at path '${step.path}' for '${step.name}'.`, {
952
- command: step.name,
953
- path: step.path
954
- });
1448
+ // Production never ran this step, so it is not run live under `onMissing` either.
1449
+ if (stoppedInProduction(step.path)) throw replayCutError(/** @type {string} */ (step.path));
1450
+ return undefined;
955
1451
  }
956
1452
  if (entry.command !== step.name) throw timeParadox(step, entry.command);
957
1453
  return resolveEntry(entry);
958
1454
  };
1455
+ const missing = (/** @type {ReplayStep} */ step) =>
1456
+ `Trace has no step at path '${step.path}' for '${step.name}'`;
1457
+ return { resolve, missing };
959
1458
  }
960
1459
 
961
- return (step) => {
1460
+ /** @type {Resolver} */
1461
+ const resolve = (step) => {
1462
+ // A trace with no paths predates recorded decisions, so a Parallel replays under timing.
1463
+ if (step.type === 'Parallel') return undefined;
962
1464
  // Positional matching pairs steps by completion order, which is exactly what makes Parallel
963
1465
  // branches indistinguishable. Refusing is better than a result that is right only when the
964
1466
  // replay happens to finish in production's order.
@@ -969,19 +1471,20 @@ const fromTrace = (traceLog, options = {}) => {
969
1471
  );
970
1472
  }
971
1473
  const entry = entries[step.index];
972
- if (!entry) throw replayError(`Trace exhausted: no entry #${step.index} for '${step.name}'.`);
1474
+ if (!entry) return undefined;
973
1475
  if (entry.command !== step.name) throw timeParadox(step, entry.command);
974
1476
  return resolveEntry(entry);
975
1477
  };
1478
+ const missing = (/** @type {ReplayStep} */ step) => `Trace exhausted: no entry #${step.index} for '${step.name}'`;
1479
+ return { resolve, missing };
976
1480
  };
977
1481
 
978
1482
  /**
979
1483
  * Rewrites `Retry` nodes to zero delay, lazily, through their `next` continuations.
980
1484
  *
981
- * Needed because the interpreter merges per-use options over call config
982
- * (`{ ...localRetryDefaults, ...eff.options }`), so a `callConfig.retry` cannot
983
- * override a delay written at the call site. Without this, replaying a flow that
984
- * retried in production waits out the production backoff.
1485
+ * Needed because a delay is written at the call site and nothing outside the node
1486
+ * can override it: retry options are per-use only. Without this, replaying a flow
1487
+ * that retried in production waits out the production backoff.
985
1488
  *
986
1489
  * @param {any} eff - Any Effect node
987
1490
  * @returns {any} The same tree with Retry delays removed
@@ -1023,8 +1526,11 @@ const zeroRetryDelays = (eff) => {
1023
1526
  * @typedef {Object} ReplayOptions
1024
1527
  * @property {any} [context] - Context for `Ask`; pass the recorded context to reproduce a run faithfully.
1025
1528
  * @property {boolean} [fastRetry] - Strips Retry delays so a replay does not wait out production backoff.
1026
- * @property {boolean} [hooks] - Lets configured `onRun` and `onBeforeCommand` fire. Off by default, so a
1027
- * replay cannot reach a telemetry backend or a guardrail that performs I/O.
1529
+ * @property {boolean} [hooks] - Runs the replay inside the hooks `configureEffect` installed, with the
1530
+ * resolver innermost, so a configured `onStep` observes each replayed step and `onRun` and
1531
+ * `onBeforeCommand` fire. Off by default, which ignores the global hooks, so a replay cannot
1532
+ * reach a telemetry backend or a guardrail that performs I/O. Retry options travel with the
1533
+ * node either way, so a replay makes the attempts production made.
1028
1534
  * @property {'throw' | 'execute'} [onMissing] - What to do when the resolver has no recording for a step.
1029
1535
  * `'throw'` (default) fails the replay, which makes side effects impossible for the whole run.
1030
1536
  * `'execute'` runs the real Command, giving partial replay: recorded prefix, live tail.
@@ -1048,7 +1554,7 @@ const zeroRetryDelays = (eff) => {
1048
1554
  * Replays an Effect tree, feeding recorded results to Commands instead of running them.
1049
1555
  *
1050
1556
  * No side effect can occur by default. The interpreter's only execution point is
1051
- * `await localStepRunner(cmdName, 'Command', eff.cmd)`, which hands the Command thunk
1557
+ * `await localStepRunner(cmdName, 'Command', op, cmdPath)`, which hands the Command thunk
1052
1558
  * to `onStep` as `op` rather than calling it. The `onStep` installed here never invokes
1053
1559
  * `op` unless `onMissing: 'execute'` is set, so `eff.cmd` is never applied and the I/O
1054
1560
  * it describes does not happen.
@@ -1066,28 +1572,52 @@ const zeroRetryDelays = (eff) => {
1066
1572
  // `async` so a malformed trace arrives as a rejection rather than a synchronous throw:
1067
1573
  // the function otherwise returns a promise, and callers should not have to handle both.
1068
1574
  const replayEffect = async (effect, traceOrResolver, options = {}) => {
1069
- const { context = {}, fastRetry = true, hooks = false, onMissing = 'throw', onResolved } = options;
1575
+ const { fastRetry = true, hooks = false, onMissing = 'throw', onResolved } = options;
1070
1576
  // A trace is data and a Resolver is a function, so nothing else is needed to tell them
1071
1577
  // apart, including the bare entries array that `fromTrace` also accepts.
1072
1578
  const fromResolver = typeof traceOrResolver === 'function';
1073
1579
  /** @type {Set<TraceEntry>} */
1074
1580
  const reached = new Set();
1075
- const resolve = fromResolver
1076
- ? traceOrResolver
1581
+ const { resolve, missing } = fromResolver
1582
+ ? { resolve: traceOrResolver, missing: undefined }
1077
1583
  : fromTrace(traceOrResolver, { onEntry: (entry) => void reached.add(entry) });
1584
+ // A trace carries the context production ran with, and `Ask` has to see it for the replay to take the
1585
+ // branches production took. An empty context replayed an `Ask` gate the other way, with no paradox to
1586
+ // flag it, whenever a caller passed only the flow and the trace, as the README's replays do.
1587
+ const recorded = fromResolver || Array.isArray(traceOrResolver) ? undefined : traceOrResolver.context;
1588
+ const context = options.context ?? recorded ?? {};
1078
1589
  let index = 0;
1590
+ // What `onResolved` threw, if it did. The throw happened before the recorded result was handed back, so it
1591
+ // counted as the Command failing and a Retry asked for an attempt production never made. It stops the
1592
+ // replay instead, as a throw from any other code in the flow does, and the replay rejects with it. Cast
1593
+ // rather than annotated, since only the step runner assigns it.
1594
+ let observerFailure = /** @type {{ error: unknown } | undefined} */ (undefined);
1079
1595
 
1080
1596
  /** @type {StepRunner} */
1081
1597
  const onStep = async (name, type, op, path) => {
1598
+ if (type === 'Parallel') {
1599
+ // A Parallel's step carries its recorded decision into `op`, which runs the branches under it.
1600
+ // It is not a Command: `index` still counts Commands, and `onResolved` still sees only them.
1601
+ const outcome = resolve({ index, name, type, path });
1602
+ return await op(outcome && 'result' in outcome ? outcome.result : undefined);
1603
+ }
1082
1604
  const step = { index: index++, name, type, path };
1083
1605
  const outcome = resolve(step);
1084
- if (onResolved) onResolved(step, outcome);
1606
+ if (onResolved) {
1607
+ try {
1608
+ onResolved(step, outcome);
1609
+ } catch (error) {
1610
+ observerFailure ??= { error };
1611
+ throw asHarnessError(new Error('onResolved threw.'));
1612
+ }
1613
+ }
1085
1614
  if (outcome === undefined) {
1086
1615
  if (onMissing !== 'execute') {
1616
+ const what = missing ? missing(step) : `No recorded outcome for '${name}' at step ${step.index}`;
1087
1617
  throw replayError(
1088
- `No recorded outcome for '${name}' at step ${step.index}; refusing to run the real Command. ` +
1618
+ `${what}; refusing to run the real Command. ` +
1089
1619
  `Pass onMissing: 'execute' to allow live I/O for unrecorded steps.`,
1090
- { command: name, index: step.index }
1620
+ { command: name, index: step.index, path }
1091
1621
  );
1092
1622
  }
1093
1623
  return await op();
@@ -1096,14 +1626,27 @@ const replayEffect = async (effect, traceOrResolver, options = {}) => {
1096
1626
  return outcome.result;
1097
1627
  };
1098
1628
 
1099
- /** @type {EffectConfiguration} */
1100
- const callConfig = { onStep };
1101
- if (!hooks) {
1102
- callConfig.onRun = async (effect, op) => await op();
1103
- callConfig.onBeforeCommand = async () => {};
1629
+ // Off, a replay ignores the global hooks, so it can reach neither a telemetry backend nor a guardrail
1630
+ // that performs I/O. On, it runs inside them with the resolver innermost, so a configured onStep
1631
+ // observes each replayed step and the Command still never executes. Retry shape needs nothing from
1632
+ // the wiring either way, since retry options live on the node: a replay makes the attempts production
1633
+ // made whichever way this is set.
1634
+ /** @type {CallConfiguration} */
1635
+ const callConfig = { onStep, inherit: hooks };
1636
+
1637
+ // The interpreter rethrows a replay fault rather than folding it into a `Failure`, so that neither
1638
+ // `onExhausted` nor a settled `Parallel` can absorb one: a truncated trace used to replay as a
1639
+ // `Success` whose branches each carried the ReplayError as though production had returned it. It
1640
+ // becomes a `Failure` here instead, which is the shape this function has always returned and the
1641
+ // one place where nothing downstream can catch it.
1642
+ let result;
1643
+ try {
1644
+ result = await runEffect(fastRetry ? zeroRetryDelays(effect) : effect, context, callConfig);
1645
+ } catch (e) {
1646
+ if (observerFailure) throw observerFailure.error;
1647
+ if (!(e && /** @type {any} */ (e)[replayFault])) throw e;
1648
+ result = Failure(e, effect.initialInput);
1104
1649
  }
1105
-
1106
- const result = await runEffect(fastRetry ? zeroRetryDelays(effect) : effect, context, callConfig);
1107
1650
  if (fromResolver) return { result };
1108
1651
  // `fromTrace` has already validated the shape, so the entries are here in one form or the other.
1109
1652
  const entries = Array.isArray(traceOrResolver) ? traceOrResolver : traceOrResolver.trace;
@@ -1128,17 +1671,24 @@ const timeTravel = async (flowFn, traceLog, options = {}) => {
1128
1671
  const { log = console.log, context, version } = options;
1129
1672
  const { initialInput, trace, flowName, version: traceVersion } = traceLog;
1130
1673
  // `message` and `stack` are non-enumerable on Error, so JSON.stringify alone would
1131
- // drop the most useful line of the report.
1132
- const format = (/** @type {any} */ v) =>
1133
- v instanceof Error
1134
- ? JSON.stringify({ ...v, name: v.name, message: v.message }, null, 2)
1135
- : JSON.stringify(v, null, 2);
1674
+ // drop the most useful line of the report. It throws on a BigInt and on a cycle, both of which a
1675
+ // recorded result can hold, and narration must not be what fails a replay.
1676
+ const format = (/** @type {any} */ v) => {
1677
+ try {
1678
+ return JSON.stringify(v instanceof Error ? { ...v, name: v.name, message: v.message } : v, null, 2);
1679
+ } catch {
1680
+ return String(v);
1681
+ }
1682
+ };
1136
1683
 
1137
1684
  if (version && traceVersion && version !== traceVersion) {
1138
1685
  log(`Warning: trace was recorded at ${traceVersion}, replaying against ${version}.`);
1139
1686
  }
1140
- const stepsText = trace.length === 0 || trace.length > 1 ? 'steps' : 'step';
1141
- log(`Replaying '${flowName || 'flow'}' (${trace.length} recorded ${stepsText})`);
1687
+ // Parallel decisions are recorded alongside the Commands but are not narrated as steps, so the header
1688
+ // counts Commands and its step numbers match the lines below.
1689
+ const commandCount = trace.filter((e) => !isDecisionEntry(e)).length;
1690
+ const stepsText = commandCount === 1 ? 'step' : 'steps';
1691
+ log(`Replaying '${flowName || 'flow'}' (${commandCount} recorded ${stepsText})`);
1142
1692
  log(`Initial input: ${format(initialInput)}`);
1143
1693
 
1144
1694
  // Narration goes through `onResolved` rather than a wrapped Resolver: observing each
@@ -1154,7 +1704,7 @@ const timeTravel = async (flowFn, traceLog, options = {}) => {
1154
1704
  const replay = await replayEffect(flowFn(initialInput), traceLog, {
1155
1705
  context: context !== undefined ? context : traceLog.context || {},
1156
1706
  onResolved: (step, outcome) => {
1157
- // Resolving from a trace either answers or throws, so this is never undefined.
1707
+ // A step the trace does not hold is refused or run live by `replayEffect`, not narrated here.
1158
1708
  if (outcome === undefined) return;
1159
1709
  log(
1160
1710
  'error' in outcome