pure-effect 0.13.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.
Files changed (5) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +259 -113
  3. package/index.d.ts +1118 -134
  4. package/index.js +647 -156
  5. package/package.json +3 -1
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,18 +281,13 @@ 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) => {
224
292
  // The outermost pipeline wins. A step can return a sub-pipeline whose nodes already carry that
225
293
  // sub-pipeline's start, and an outer chain reaches every one of them through the wrapped `next`
@@ -233,9 +301,9 @@ const chain = (effect, fn, initialInput) => {
233
301
  initialInput !== undefined && e.type !== 'Success' ? { ...e, initialInput } : e;
234
302
 
235
303
  // A continuation that returned nothing has to be caught before `effect.type` is read. Reading it off
236
- // `undefined` throws a bare TypeError, and since every continuation runs inside the interpreter's try
237
- // block, that TypeError became a domain Failure: the flow bug took the business-error branch and no
238
- // step was named. The `default` arm below never sees it, because the switch itself is what throws.
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.
239
307
  if (effect == null) return asEffect(effect, 'A continuation');
240
308
 
241
309
  switch (effect.type) {
@@ -286,10 +354,12 @@ const effectPipe = (...fns) => {
286
354
  };
287
355
 
288
356
  /**
289
- * Wraps one Command execution. `path` identifies the Command's position in the Effect tree rather than
290
- * its position in completion order, so it is the same in a replay as it was in the recorded run even
291
- * when `Parallel` branches finish in a different order. Hooks written before `path` existed take three
292
- * 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.
293
363
  * @typedef {(name: string, type: string, op: function, path?: string) => Promise<any>} StepRunner
294
364
  */
295
365
  /** @type StepRunner */
@@ -305,19 +375,32 @@ const defaultCommandInterceptor = async (command, context) => {};
305
375
 
306
376
  const defaultRetryOptions = { attempts: 3, delay: 100, backoff: 1 };
307
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
+ };
392
+
308
393
  /**
309
394
  * @typedef {Object} EffectConfiguration
310
395
  * @property {StepRunner} [onStep] - Fires every time a Command is executed. It wraps the `cmd` call.
311
396
  * @property {RunWrapper} [onRun] - Fires once per runEffect call. It wraps the entire workflow execution.
312
397
  * @property {CommandInterceptor} [onBeforeCommand] - Intercepts a Command and any context passed to runEffect before execution.
313
- * @property {{ attempts?: number, delay?: number, backoff?: number }} [retry] - Global Retry defaults; merged under per-use options.
314
398
  */
315
399
 
316
400
  /**
317
401
  * A per-call configuration: an `EffectConfiguration` plus `inherit`. With `inherit: true` (the default)
318
402
  * the call's hooks are added to the wiring `configureEffect` installed, by the merge `configureEffect`
319
- * applies to several configurations: global outermost, interceptors in order, `retry` merging with the
320
- * call winning. With `inherit: false` the global wiring is not consulted at all, so any slot the call
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
321
404
  * leaves unset falls back to the library default. A per-call hook cannot replace a single global slot on
322
405
  * its own; that was the previous default, and it is how a per-call recorder silently switched off an
323
406
  * application's tracing.
@@ -343,14 +426,14 @@ const applyLayers = () => {
343
426
  };
344
427
 
345
428
  /**
346
- * Adds a configuration to the global wiring of the Effect runner: telemetry, the command interceptor,
347
- * and retry defaults.
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)`.
348
431
  *
349
432
  * Each call adds one layer on top of those already installed and returns a function that removes that
350
433
  * layer, wherever it sits by then. Layers merge the way several configurations passed to one call do:
351
434
  * `onStep` and `onRun` are wrappers, so they nest with the earliest layer outermost and the latest
352
- * closest to the Command; `onBeforeCommand` interceptors all run, in the order installed; and `retry`
353
- * merges with later layers winning. So these are the same:
435
+ * closest to the Command; and `onBeforeCommand` interceptors all run, in the order installed. So these
436
+ * are the same:
354
437
  *
355
438
  * configureEffect(telemetryHooks(), recordingHooks({ sink }));
356
439
  * configureEffect(telemetryHooks()); configureEffect(recordingHooks({ sink }));
@@ -377,6 +460,8 @@ const configureEffect = (...configs) => {
377
460
  return () => {};
378
461
  }
379
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'));
380
465
  if (present.length === 0) return () => {};
381
466
  const layer = chainHooks(...present);
382
467
  layers = [...layers, layer];
@@ -390,8 +475,8 @@ const configureEffect = (...configs) => {
390
475
 
391
476
  /**
392
477
  * @typedef {Object} StepStart
393
- * @property {string} name - `cmd.name`, or 'anonymous'.
394
- * @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.
395
480
  * @property {string} [path] - The Command's position in the Effect tree.
396
481
  */
397
482
 
@@ -446,9 +531,8 @@ const observeSteps = (handler) => async (name, type, op, path) => {
446
531
  * `onStep` and `onRun` are wrappers around an `op`, so they nest. The first config given is
447
532
  * the outermost wrapper and the last sits closest to the Command, which also means a thrown
448
533
  * Command unwinds from the last config back to the first. `onBeforeCommand` is an observer,
449
- * so every interceptor runs in the order given. `retry` is plain data and merges with later
450
- * configs winning. A hook no config defines is left unset, so `configureEffect` keeps its
451
- * 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.
452
536
  *
453
537
  * @param {...(EffectConfiguration | undefined)} configs - Configurations to merge, outermost first
454
538
  * @returns {EffectConfiguration}
@@ -465,7 +549,6 @@ const chainHooks = (...configs) => {
465
549
  const steps = /** @type {StepRunner[]} */ (present('onStep'));
466
550
  const runs = /** @type {RunWrapper[]} */ (present('onRun'));
467
551
  const interceptors = /** @type {CommandInterceptor[]} */ (present('onBeforeCommand'));
468
- const retries = present('retry');
469
552
 
470
553
  if (steps.length) {
471
554
  merged.onStep = steps.reduceRight(
@@ -482,7 +565,6 @@ const chainHooks = (...configs) => {
482
565
  for (const intercept of interceptors) await intercept(command, context);
483
566
  };
484
567
  }
485
- if (retries.length) merged.retry = Object.assign({}, ...retries);
486
568
  return merged;
487
569
  };
488
570
 
@@ -499,6 +581,67 @@ const parallelCancelled = () =>
499
581
  name: 'ParallelCancelled'
500
582
  });
501
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
+
502
645
  /**
503
646
  * Sleeps, but gives up early when the surrounding branch is cancelled, so a sibling's failure does not
504
647
  * have to wait out a retry backoff that is now pointless. Resolves either way: the caller's abort check
@@ -514,6 +657,13 @@ const delayFor = (ms, signal) =>
514
657
  setTimeout(resolve, ms);
515
658
  return;
516
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
+ }
517
667
  const onAbort = () => {
518
668
  clearTimeout(timer);
519
669
  resolve();
@@ -542,6 +692,7 @@ const runEffect =
542
692
  * @returns {Promise<SuccessState | FailureState>}
543
693
  */
544
694
  async function runEffect(effect, context = {}, callConfig = {}) {
695
+ rejectRetryKey(callConfig, "runEffect's callConfig");
545
696
  const { inherit = true, ...local } = callConfig;
546
697
  // A value that is not a boolean, such as a string left over from an older API, must not be
547
698
  // coerced: `'false'` inheriting everything is exactly the silent behaviour this option removes.
@@ -556,7 +707,6 @@ const runEffect =
556
707
  const localStepRunner = resolved.onStep || defaultStepRunner;
557
708
  const localRunWrapper = resolved.onRun || defaultRunWrapper;
558
709
  const localCommandInterceptor = resolved.onBeforeCommand || defaultCommandInterceptor;
559
- const localRetryDefaults = { ...defaultRetryOptions, ...resolved.retry };
560
710
 
561
711
  /**
562
712
  * @param {Effect} eff
@@ -568,7 +718,7 @@ const runEffect =
568
718
  * attempt opens its own prefix, so a Command's full path depends only on the shape of the
569
719
  * tree and not on the order branches happen to finish in. That is what lets a replay line
570
720
  * a recorded step up with the step that asked for it.
571
- * @returns {Promise<SuccessState | FailureState>}
721
+ * @returns {Promise<SuccessState | FailureState | IoFaultState>}
572
722
  */
573
723
  async function execute(eff, signal, path = '') {
574
724
  let step = 0;
@@ -584,8 +734,17 @@ const runEffect =
584
734
  continue;
585
735
  }
586
736
  if (eff.type === 'Retry') {
587
- const opts = { ...localRetryDefaults, ...eff.options };
737
+ const opts = { ...defaultRetryOptions, ...eff.options };
588
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
+ );
589
748
  let lastError;
590
749
  let succeeded = false;
591
750
  // Each attempt gets its own prefix, so the Commands of attempt 2 cannot be mistaken
@@ -609,13 +768,19 @@ const runEffect =
609
768
  succeeded = true;
610
769
  break;
611
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;
612
775
  lastError = result.error;
613
776
  }
614
777
 
615
778
  if (!succeeded) {
616
779
  const exhausted = { retryExhausted: true, lastError, attempts };
617
780
  const { onExhausted } = opts;
618
- 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);
619
784
  // A cancelled branch must not start its fallback, for the same reason it starts
620
785
  // no further Commands: recovery must not resurrect work a sibling's failure ended.
621
786
  if (signal?.aborted) return Failure(parallelCancelled(), eff.initialInput);
@@ -628,7 +793,7 @@ const runEffect =
628
793
  );
629
794
  // A failing fallback propagates as-is: the flow's last word was the fallback's
630
795
  // error, not the exhaustion it was already told about.
631
- if (fallback.type === 'Failure') return fallback;
796
+ if (fallback.type !== 'Success') return fallback;
632
797
  eff = retryNext(fallback.value);
633
798
  }
634
799
  continue;
@@ -638,42 +803,164 @@ const runEffect =
638
803
  // branches complete in, which is the whole point: two branches calling the same
639
804
  // Command are told apart by where they are rather than by who finished first.
640
805
  const branchPath = `${path}${step++}p`;
641
- // One scope per Parallel, linked to the enclosing one so cancellation nests.
642
- // Absent AbortController (very old runtimes), `branchSignal` stays undefined and
643
- // the old run-everything-to-completion behaviour is what happens.
644
- const scope = typeof AbortController === 'function' ? new AbortController() : undefined;
645
- const branchSignal = scope?.signal;
646
- const relay = () => scope?.abort();
647
- if (signal && scope) {
648
- if (signal.aborted) scope.abort();
649
- else signal.addEventListener('abort', relay, { once: true });
650
- }
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
+ };
651
935
 
652
- /** @type {(SuccessState | FailureState)[]} */
653
- const results = new Array(eff.effects.length);
654
- // Which branch failed on its own account rather than because it was cancelled.
655
- const triggered = new Array(eff.effects.length).fill(false);
656
936
  try {
657
- // Still awaits every branch, so no cancelled work is left running unobserved
658
- // after the Failure is returned. Cancelled branches settle promptly; a branch
659
- // whose in-flight Command ignores the signal is the one case that does not.
660
- await Promise.all(
661
- eff.effects.map(async (e, i) => {
662
- const result = await execute(e, branchSignal, `${branchPath}${i}/`);
663
- results[i] = result;
664
- // Read-then-abort is atomic here, so exactly one branch is the trigger.
665
- if (result.type === 'Failure' && !branchSignal?.aborted) {
666
- triggered[i] = true;
667
- scope?.abort();
668
- }
669
- })
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.'
670
947
  );
671
- } finally {
672
- 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;
673
958
  }
674
959
 
675
- const trigger = triggered.indexOf(true);
676
- 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');
677
964
  if (failure) return failure;
678
965
  eff = eff.next(results.map((r) => /** @type {SuccessState} */ (r).value));
679
966
  continue;
@@ -682,18 +969,54 @@ const runEffect =
682
969
  const initialInput = eff.initialInput;
683
970
  const cmdPath = `${path}${step++}`;
684
971
  const cmd = eff.cmd;
685
- // The signal reaches the thunk only inside a Parallel, so a thunk written to take a
686
- // parameter is not handed an argument it never expected anywhere else.
687
- 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.
688
992
  try {
689
993
  await localCommandInterceptor(eff, context);
690
- const result = await localStepRunner(cmdName, 'Command', op, cmdPath);
691
- eff = eff.next(result);
692
994
  } catch (e) {
693
- // A malformed flow is a bug, not a domain failure, so it must not masquerade as one.
694
- if (e instanceof Error && e.name === 'EffectTypeError') throw e;
995
+ if (e && /** @type {any} */ (e)[harnessError]) throw e;
695
996
  return Failure(e, initialInput);
696
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);
697
1020
  }
698
1021
  if (eff && (eff.type === 'Success' || eff.type === 'Failure')) return eff;
699
1022
  throw effectTypeError(eff, 'The flow');
@@ -703,10 +1026,11 @@ const runEffect =
703
1026
  // back. `chain` stamps only what passes through the continuations it wraps, and those subtrees
704
1027
  // are executed directly rather than reached through a continuation, so their Failures arrive
705
1028
  // carrying the subtree's own input or none. Restamping the outcome with the root's input is
706
- // what makes "a Failure carries the input of the flow that was called" true at any depth.
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.
707
1031
  const rootInput = effect?.initialInput;
708
1032
  const run = async () => {
709
- const result = await execute(effect);
1033
+ const result = asOutcome(await execute(effect));
710
1034
  return result.type === 'Failure' && rootInput !== undefined ? Failure(result.error, rootInput) : result;
711
1035
  };
712
1036
  return localRunWrapper(effect, run, context?.flowName || '');
@@ -715,7 +1039,9 @@ const runEffect =
715
1039
  /**
716
1040
  * The step a replay is asking about. `path` is the Command's position in the Effect tree and is stable
717
1041
  * across runs; `index` is its position in this run's completion order, which is not stable for a flow
718
- * 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.
719
1045
  * @typedef {{ index: number, name: string, type: string, path?: string }} ReplayStep
720
1046
  */
721
1047
 
@@ -730,11 +1056,15 @@ const runEffect =
730
1056
  /** @typedef {(step: ReplayStep) => ReplayOutcome | undefined} Resolver */
731
1057
 
732
1058
  /**
733
- * 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,
734
1062
  * which is the one question a trace could not answer before: which step was slow.
735
1063
  * `path` locates the Command in the Effect tree, which is what a replay matches on: it does not move
736
1064
  * when `Parallel` branches finish in a different order than they did in production.
737
- * @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
738
1068
  */
739
1069
 
740
1070
  /**
@@ -751,13 +1081,29 @@ const runEffect =
751
1081
  */
752
1082
 
753
1083
  /**
754
- * Creates a replay error. Thrown from a resolver, it is caught by the interpreter
755
- * 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.
756
1095
  * @param {string} message - Human-readable reason
757
1096
  * @param {Object} [props] - Extra fields such as `name`, `index`, `expected`, `actual`
758
1097
  * @returns {Error}
759
1098
  */
760
- 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
+ );
761
1107
 
762
1108
  /**
763
1109
  * Signals that the flow being replayed asked for a different Command than the trace
@@ -794,6 +1140,14 @@ const serializeError = (e, withStack) => {
794
1140
  if (withStack) out.stack = e.stack;
795
1141
  // `cause` is non-enumerable too, and it can itself be an Error, so it is carried recursively.
796
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
+ }
797
1151
  for (const k of Object.keys(e)) out[k] = /** @type {any} */ (e)[k];
798
1152
  return out;
799
1153
  };
@@ -817,7 +1171,9 @@ const reviveError = (v) => {
817
1171
  for (const [k, val] of Object.entries(v)) {
818
1172
  if (k === '__error' || k === 'name' || k === 'message') continue;
819
1173
  if (k === 'cause') Object.defineProperty(e, 'cause', { ...hidden, value: reviveError(val) });
820
- 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;
821
1177
  }
822
1178
  return e;
823
1179
  };
@@ -828,7 +1184,8 @@ const reviveError = (v) => {
828
1184
  * each Command's result, each serialized error, and the `initialInput` and `context` stored on the
829
1185
  * trace itself. `kind` is `'result'`, `'error'`, `'initialInput'`, or `'context'`, and `name` is the
830
1186
  * Command's name for the first two and the kind for the last two. It is the single place PII is kept
831
- * 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.
832
1189
  * @property {number} [maxEntries] - Caps trace length; further steps are counted in `dropped`, not stored.
833
1190
  * @property {boolean} [stack] - Records stack traces for thrown errors.
834
1191
  */
@@ -842,12 +1199,41 @@ const reviveError = (v) => {
842
1199
  */
843
1200
 
844
1201
  /**
845
- * 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.
846
1231
  *
847
1232
  * Commands return live objects, and a later step that mutates one would otherwise rewrite what the
848
1233
  * trace says an earlier step returned: `toTrace` runs after the flow finishes, so by serialization
849
- * time the mutation is already baked in and the trace records a value production never saw. Values
850
- * 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.
851
1237
  *
852
1238
  * @param {any} value
853
1239
  * @returns {any}
@@ -857,7 +1243,7 @@ const snapshot = (value) => {
857
1243
  try {
858
1244
  return typeof structuredClone === 'function' ? structuredClone(value) : JSON.parse(JSON.stringify(value));
859
1245
  } catch {
860
- return value;
1246
+ return copyAround(value, new Map());
861
1247
  }
862
1248
  };
863
1249
 
@@ -892,29 +1278,38 @@ const recorder = (options = {}) => {
892
1278
  };
893
1279
 
894
1280
  /**
895
- * 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
896
1282
  * context does not acquire an empty object from a redact function that spreads its argument.
897
1283
  */
898
1284
  const redactField = (/** @type {any} */ value, /** @type {string} */ kind) =>
899
- value === undefined ? undefined : snapshot(safeRedact(value, kind, kind));
1285
+ value === undefined ? undefined : safeRedact(snapshot(value), kind, kind);
900
1286
 
901
- // 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
902
1288
  // always returned, and its error always propagates.
903
- const onStep = observeSteps(({ name, path }) => (end) => {
1289
+ const onStep = observeSteps(({ name, type, path }) => (end) => {
904
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');
905
1297
  push(
906
1298
  'error' in end
907
1299
  ? {
908
1300
  command: name,
909
1301
  path,
910
- error: snapshot(safeRedact(serializeError(end.error, stack), name, 'error')),
1302
+ threw: true,
1303
+ error: safeRedact(snapshot(serializeError(end.error, stack)), name, 'error'),
911
1304
  durationMs
912
1305
  }
913
- : { command: name, path, result: snapshot(safeRedact(end.result, name, 'result')), durationMs }
1306
+ : { command: name, path, result: recorded(end.result), durationMs }
914
1307
  );
915
1308
  });
916
1309
 
917
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.
918
1313
  * @param {TraceMeta} [meta]
919
1314
  * @returns {TraceLog}
920
1315
  */
@@ -944,22 +1339,45 @@ const recorder = (options = {}) => {
944
1339
  const recordEffect = async (flowFn, initialInput, options = {}) => {
945
1340
  const { context = {}, version, ...recorderOptions } = options;
946
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 });
947
1346
  // The recorder is added to the global wiring, so recording inside an instrumented application keeps
948
1347
  // its spans.
949
1348
  const result = await runEffect(flowFn(initialInput), context, { onStep: rec.onStep });
950
- return {
951
- result,
952
- trace: rec.toTrace({ initialInput, flowName: context.flowName, context, version })
953
- };
1349
+ const { dropped, trace } = rec.toTrace();
1350
+ return { result, trace: { ...head, dropped, trace } };
954
1351
  };
955
1352
 
956
1353
  /**
957
1354
  * Turns a recorded entry into the outcome a Resolver must return.
958
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
+ *
959
1364
  * @param {TraceEntry} entry
960
1365
  * @returns {ReplayOutcome}
961
1366
  */
962
- 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');
963
1381
 
964
1382
  /**
965
1383
  * Builds a Resolver for the reference trace format. Internal: `replayEffect` calls this
@@ -976,11 +1394,16 @@ const entryToOutcome = (entry) => ('error' in entry ? { error: reviveError(entry
976
1394
  * flow asks for. That is exact for sequential flows and cannot tell `Parallel` branches apart, which
977
1395
  * is what paths were added to fix.
978
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
+ *
979
1402
  * @param {TraceLog | TraceEntry[]} traceLog - A reference-format trace, or a bare array of entries
980
1403
  * @param {Object} [options]
981
1404
  * @param {(entry: TraceEntry) => void} [options.onEntry] - Observes each entry as it is handed to a step,
982
1405
  * which is how `replayEffect` learns which recorded entries the flow never asked for.
983
- * @returns {Resolver}
1406
+ * @returns {{ resolve: Resolver, missing: (step: ReplayStep) => string }}
984
1407
  */
985
1408
  const fromTrace = (traceLog, options = {}) => {
986
1409
  const { onEntry } = options;
@@ -999,20 +1422,45 @@ const fromTrace = (traceLog, options = {}) => {
999
1422
  if (byPath.size !== entries.length) {
1000
1423
  throw replayError('Trace has duplicate step paths.');
1001
1424
  }
1002
- 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) => {
1003
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
+ }
1004
1447
  if (!entry) {
1005
- throw replayError(`Trace has no step at path '${step.path}' for '${step.name}'.`, {
1006
- command: step.name,
1007
- path: step.path
1008
- });
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;
1009
1451
  }
1010
1452
  if (entry.command !== step.name) throw timeParadox(step, entry.command);
1011
1453
  return resolveEntry(entry);
1012
1454
  };
1455
+ const missing = (/** @type {ReplayStep} */ step) =>
1456
+ `Trace has no step at path '${step.path}' for '${step.name}'`;
1457
+ return { resolve, missing };
1013
1458
  }
1014
1459
 
1015
- 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;
1016
1464
  // Positional matching pairs steps by completion order, which is exactly what makes Parallel
1017
1465
  // branches indistinguishable. Refusing is better than a result that is right only when the
1018
1466
  // replay happens to finish in production's order.
@@ -1023,19 +1471,20 @@ const fromTrace = (traceLog, options = {}) => {
1023
1471
  );
1024
1472
  }
1025
1473
  const entry = entries[step.index];
1026
- if (!entry) throw replayError(`Trace exhausted: no entry #${step.index} for '${step.name}'.`);
1474
+ if (!entry) return undefined;
1027
1475
  if (entry.command !== step.name) throw timeParadox(step, entry.command);
1028
1476
  return resolveEntry(entry);
1029
1477
  };
1478
+ const missing = (/** @type {ReplayStep} */ step) => `Trace exhausted: no entry #${step.index} for '${step.name}'`;
1479
+ return { resolve, missing };
1030
1480
  };
1031
1481
 
1032
1482
  /**
1033
1483
  * Rewrites `Retry` nodes to zero delay, lazily, through their `next` continuations.
1034
1484
  *
1035
- * Needed because the interpreter merges per-use options over call config
1036
- * (`{ ...localRetryDefaults, ...eff.options }`), so a `callConfig.retry` cannot
1037
- * override a delay written at the call site. Without this, replaying a flow that
1038
- * 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.
1039
1488
  *
1040
1489
  * @param {any} eff - Any Effect node
1041
1490
  * @returns {any} The same tree with Retry delays removed
@@ -1080,8 +1529,8 @@ const zeroRetryDelays = (eff) => {
1080
1529
  * @property {boolean} [hooks] - Runs the replay inside the hooks `configureEffect` installed, with the
1081
1530
  * resolver innermost, so a configured `onStep` observes each replayed step and `onRun` and
1082
1531
  * `onBeforeCommand` fire. Off by default, which ignores the global hooks, so a replay cannot
1083
- * reach a telemetry backend or a guardrail that performs I/O. Global `retry` defaults apply
1084
- * either way, since a replay has to make the attempts production made.
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.
1085
1534
  * @property {'throw' | 'execute'} [onMissing] - What to do when the resolver has no recording for a step.
1086
1535
  * `'throw'` (default) fails the replay, which makes side effects impossible for the whole run.
1087
1536
  * `'execute'` runs the real Command, giving partial replay: recorded prefix, live tail.
@@ -1105,7 +1554,7 @@ const zeroRetryDelays = (eff) => {
1105
1554
  * Replays an Effect tree, feeding recorded results to Commands instead of running them.
1106
1555
  *
1107
1556
  * No side effect can occur by default. The interpreter's only execution point is
1108
- * `await localStepRunner(cmdName, 'Command', eff.cmd)`, which hands the Command thunk
1557
+ * `await localStepRunner(cmdName, 'Command', op, cmdPath)`, which hands the Command thunk
1109
1558
  * to `onStep` as `op` rather than calling it. The `onStep` installed here never invokes
1110
1559
  * `op` unless `onMissing: 'execute'` is set, so `eff.cmd` is never applied and the I/O
1111
1560
  * it describes does not happen.
@@ -1123,28 +1572,52 @@ const zeroRetryDelays = (eff) => {
1123
1572
  // `async` so a malformed trace arrives as a rejection rather than a synchronous throw:
1124
1573
  // the function otherwise returns a promise, and callers should not have to handle both.
1125
1574
  const replayEffect = async (effect, traceOrResolver, options = {}) => {
1126
- const { context = {}, fastRetry = true, hooks = false, onMissing = 'throw', onResolved } = options;
1575
+ const { fastRetry = true, hooks = false, onMissing = 'throw', onResolved } = options;
1127
1576
  // A trace is data and a Resolver is a function, so nothing else is needed to tell them
1128
1577
  // apart, including the bare entries array that `fromTrace` also accepts.
1129
1578
  const fromResolver = typeof traceOrResolver === 'function';
1130
1579
  /** @type {Set<TraceEntry>} */
1131
1580
  const reached = new Set();
1132
- const resolve = fromResolver
1133
- ? traceOrResolver
1581
+ const { resolve, missing } = fromResolver
1582
+ ? { resolve: traceOrResolver, missing: undefined }
1134
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 ?? {};
1135
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);
1136
1595
 
1137
1596
  /** @type {StepRunner} */
1138
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
+ }
1139
1604
  const step = { index: index++, name, type, path };
1140
1605
  const outcome = resolve(step);
1141
- 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
+ }
1142
1614
  if (outcome === undefined) {
1143
1615
  if (onMissing !== 'execute') {
1616
+ const what = missing ? missing(step) : `No recorded outcome for '${name}' at step ${step.index}`;
1144
1617
  throw replayError(
1145
- `No recorded outcome for '${name}' at step ${step.index}; refusing to run the real Command. ` +
1618
+ `${what}; refusing to run the real Command. ` +
1146
1619
  `Pass onMissing: 'execute' to allow live I/O for unrecorded steps.`,
1147
- { command: name, index: step.index }
1620
+ { command: name, index: step.index, path }
1148
1621
  );
1149
1622
  }
1150
1623
  return await op();
@@ -1155,14 +1628,25 @@ const replayEffect = async (effect, traceOrResolver, options = {}) => {
1155
1628
 
1156
1629
  // Off, a replay ignores the global hooks, so it can reach neither a telemetry backend nor a guardrail
1157
1630
  // that performs I/O. On, it runs inside them with the resolver innermost, so a configured onStep
1158
- // observes each replayed step and the Command still never executes. The global retry defaults are
1159
- // kept either way: `inherit: false` would drop them with the hooks, and a Retry that production ran
1160
- // under a configured `attempts` would then exhaust early on replay and report a Failure production
1161
- // never saw. Retry shape is part of what a replay reproduces; the hooks are not.
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.
1162
1634
  /** @type {CallConfiguration} */
1163
- const callConfig = { onStep, inherit: hooks, retry: globalConfig.retry };
1164
-
1165
- const result = await runEffect(fastRetry ? zeroRetryDelays(effect) : effect, context, callConfig);
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);
1649
+ }
1166
1650
  if (fromResolver) return { result };
1167
1651
  // `fromTrace` has already validated the shape, so the entries are here in one form or the other.
1168
1652
  const entries = Array.isArray(traceOrResolver) ? traceOrResolver : traceOrResolver.trace;
@@ -1187,17 +1671,24 @@ const timeTravel = async (flowFn, traceLog, options = {}) => {
1187
1671
  const { log = console.log, context, version } = options;
1188
1672
  const { initialInput, trace, flowName, version: traceVersion } = traceLog;
1189
1673
  // `message` and `stack` are non-enumerable on Error, so JSON.stringify alone would
1190
- // drop the most useful line of the report.
1191
- const format = (/** @type {any} */ v) =>
1192
- v instanceof Error
1193
- ? JSON.stringify({ ...v, name: v.name, message: v.message }, null, 2)
1194
- : 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
+ };
1195
1683
 
1196
1684
  if (version && traceVersion && version !== traceVersion) {
1197
1685
  log(`Warning: trace was recorded at ${traceVersion}, replaying against ${version}.`);
1198
1686
  }
1199
- const stepsText = trace.length === 0 || trace.length > 1 ? 'steps' : 'step';
1200
- 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})`);
1201
1692
  log(`Initial input: ${format(initialInput)}`);
1202
1693
 
1203
1694
  // Narration goes through `onResolved` rather than a wrapped Resolver: observing each
@@ -1213,7 +1704,7 @@ const timeTravel = async (flowFn, traceLog, options = {}) => {
1213
1704
  const replay = await replayEffect(flowFn(initialInput), traceLog, {
1214
1705
  context: context !== undefined ? context : traceLog.context || {},
1215
1706
  onResolved: (step, outcome) => {
1216
- // 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.
1217
1708
  if (outcome === undefined) return;
1218
1709
  log(
1219
1710
  'error' in outcome