pure-effect 0.14.0 → 0.15.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 (4) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +47 -21
  3. package/index.js +760 -684
  4. package/package.json +1 -1
package/index.js CHANGED
@@ -79,36 +79,45 @@ const Failure = (error, initialInput) => ({
79
79
  *
80
80
  * @param {(signal?: AbortSignal) => Promise<any>|any} cmd - The side-effect function to execute. Inside a
81
81
  * `Parallel` branch it receives an `AbortSignal` that fires when a sibling branch fails, so I/O that
82
- * accepts one can be cancelled in flight. Ignoring it is fine: the interpreter still refuses to start
83
- * any later Command in a cancelled branch. Outside a `Parallel` no argument is passed.
82
+ * accepts one can be cancelled in flight. Outside a `Parallel` no argument is passed. The signal is the
83
+ * first argument, so wrap a function that takes an optional one, as in `() => nanoid()`.
84
84
  * @param {(result: any) => Effect} [next] - Receives the result of `cmd` and returns the next Effect.
85
- * Defaults to `(result) => Success(result)`, which is what most Commands want.
85
+ * Defaults to `(result) => Success(result)`, which is what most Commands want; `null` counts as omitted.
86
86
  * @param {CommandMeta} [meta] - Optional metadata, passed to `onBeforeCommand`. A string `meta.name`
87
- * becomes this Command's identity for traces, replay matching, and telemetry spans, which makes
88
- * the identity independent of how `cmd` was declared and immune to minification.
87
+ * becomes this Command's identity for traces, replay matching, and telemetry spans.
89
88
  * @returns {CommandState}
90
89
  */
91
- const Command = (cmd, next = (/** @type {any} */ result) => Success(result), meta) => ({
92
- type: 'Command',
93
- cmd,
94
- next,
95
- meta
96
- });
90
+ const Command = (cmd, next, meta) => {
91
+ // Checked as the flow is built, so a mistake is reported before the I/O it would have run.
92
+ if (typeof cmd !== 'function') {
93
+ throw malformed(
94
+ /** @type {any} */ (cmd) instanceof Promise
95
+ ? 'Command expects a function, got a Promise.'
96
+ : `Command expects the function that does the I/O, got ${describeArgument(cmd)}.`,
97
+ cmd
98
+ );
99
+ }
100
+ if (next != null && typeof next !== 'function') {
101
+ throw malformed(
102
+ isOptionsObject(next)
103
+ ? "Command's second argument is next, and meta goes third: Command(fn, undefined, { name: 'chargeCard' })."
104
+ : `Command's next must be a function, got ${describeArgument(next)}.`,
105
+ next
106
+ );
107
+ }
108
+ return { type: 'Command', cmd, next: next ?? ((/** @type {any} */ result) => Success(result)), meta };
109
+ };
97
110
 
98
111
  /**
99
112
  * The name a Command is known by: what a trace records, what replay matches on, and what a
100
- * telemetry span is called.
101
- *
102
- * `meta.name` wins when it is a non-empty string. Otherwise the identity is the thunk's own `name`,
103
- * so an inline arrow records as 'anonymous' and a minifier that mangles function names silently
104
- * renames every step of every trace. Passing `meta.name` avoids both.
113
+ * telemetry span is called. A non-empty string `meta.name`, else `cmd.name`, else 'anonymous'.
105
114
  *
106
115
  * @param {CommandState} eff
107
116
  * @returns {string}
108
117
  */
109
118
  const commandName = (eff) => {
110
119
  const meta = eff.meta;
111
- const named = meta && typeof meta === 'object' ? meta.name : undefined;
120
+ const named = isObject(meta) ? meta.name : undefined;
112
121
  return typeof named === 'string' && named !== '' ? named : eff.cmd.name || 'anonymous';
113
122
  };
114
123
 
@@ -117,7 +126,12 @@ const commandName = (eff) => {
117
126
  * @param {(context: any) => Effect} next - Receives the context and returns the next Effect
118
127
  * @returns {AskState}
119
128
  */
120
- const Ask = (next) => ({ type: 'Ask', next });
129
+ const Ask = (next) => {
130
+ if (typeof next !== 'function') {
131
+ throw malformed(`Ask expects a function that receives the context, got ${describeArgument(next)}.`, next);
132
+ }
133
+ return { type: 'Ask', next };
134
+ };
121
135
 
122
136
  /**
123
137
  * Wraps an Effect tree with retry-on-failure semantics.
@@ -128,87 +142,138 @@ const Ask = (next) => ({ type: 'Ask', next });
128
142
  * every Command in it is idempotent.
129
143
  *
130
144
  * @param {Effect} effect - The inner Effect tree to retry
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
133
- * @param {number} [options.attempts] - Max retries (not counting first try)
134
- * @param {number} [options.delay] - Ms before first retry
135
- * @param {number} [options.backoff] - Multiplier applied to delay on each subsequent retry
145
+ * @param {Object} [options] - Retry options, merged over the library defaults at runtime. An option set to
146
+ * `undefined` keeps its default, as an absent config key does.
147
+ * @param {number} [options.attempts] - Max retries (not counting first try), a positive integer
148
+ * @param {number} [options.delay] - Ms before first retry, a finite number of 0 or more
149
+ * @param {number} [options.backoff] - Multiplier applied to delay on each subsequent retry, a finite number
150
+ * of 0 or more
136
151
  * @param {(error: any) => Effect} [options.onExhausted] - Runs a fallback Effect when every attempt has
137
152
  * failed, receiving `{ retryExhausted, lastError, attempts }`. The fallback's success feeds `next`
138
- * exactly as the primary's would have; its failure propagates unwrapped. Per-use only, never a
139
- * global default, and a fallback never starts in a `Parallel` branch a sibling has cancelled.
153
+ * exactly as the primary's would have; its failure propagates unwrapped. A fallback never starts
154
+ * in a `Parallel` branch a sibling has cancelled.
140
155
  * @returns {RetryState}
141
156
  */
142
- const Retry = (effect, options = {}) => ({
143
- type: 'Retry',
144
- effect,
145
- options,
146
- next: (value) => Success(value)
147
- });
157
+ const Retry = (effect, options) => {
158
+ if (!isEffect(effect)) throw malformed(`Retry expects the Effect to run, got ${describeValue(effect)}.`, effect);
159
+ if (options != null && !isOptionsObject(options)) {
160
+ const hint = typeof options === 'number' ? `: write Retry(effect, { attempts: ${options} })` : '';
161
+ throw malformed(`Retry's options must be an object, got ${describeArgument(options)}${hint}.`, options);
162
+ }
163
+ return { type: 'Retry', effect, options: options ?? {}, next: (value) => Success(value) };
164
+ };
148
165
 
149
166
  /**
150
167
  * Runs multiple Effect trees concurrently. The first branch to fail cancels its siblings, and that
151
168
  * branch's Failure is what the Parallel returns; `next` is skipped. Which branch fails first depends on
152
169
  * timing, so it is recorded, and a replay returns the same one.
153
170
  *
154
- * Cancellation is cooperative and works at two levels. A cancelled branch starts no further Commands,
155
- * which needs nothing from the caller. Stopping the Command already in flight needs its thunk to accept
156
- * the `AbortSignal` it is passed and hand it to whatever performs the I/O; a thunk that ignores the
171
+ * A cancelled branch starts no further Commands. Stopping the Command already in flight needs its
172
+ * function to pass the `AbortSignal` it receives to whatever performs the I/O; one that ignores the
157
173
  * signal runs to completion, so a branch's first Command can still write after a sibling has failed.
158
174
  *
159
175
  * `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.
176
+ * receives the branch outcomes, `Success` and `Failure` nodes in array order. An `EffectTypeError`
177
+ * still escapes. `limit: n` keeps at most `n` branches in flight. Results and paths stay in array
178
+ * order either way.
164
179
  *
165
180
  * The second argument is the `next` function or the options, whichever it looks like, so
166
- * `Parallel(effects, { limit: 5 })` needs no placeholder.
181
+ * `Parallel(effects, { limit: 5 })` needs no placeholder. `undefined` or `null` there is a skipped `next`,
182
+ * so `Parallel(effects, undefined, { limit: 5 })` still reads the options.
167
183
  *
168
184
  * @param {Effect[]} effects - Array of Effect trees to run concurrently
169
- * @param {((values: any[]) => Effect) | ParallelOptions} [nextOrOptions] - Receives array of success
185
+ * @param {((values: any[]) => Effect) | ParallelOptions | null} [nextOrOptions] - Receives array of success
170
186
  * values in order and returns the next Effect, or the options. `next` defaults to
171
187
  * `(values) => Success(values)`, same as `Command`'s default.
172
- * @param {ParallelOptions} [maybeOptions] - Options, when `next` was given
188
+ * @param {ParallelOptions} [maybeOptions] - Options, when `next` was given or skipped
173
189
  * @returns {ParallelState}
174
190
  */
175
191
  const Parallel = (effects, nextOrOptions, maybeOptions) => {
192
+ if (!Array.isArray(effects)) {
193
+ throw malformed(`Parallel expects an array of Effects, got ${describeArgument(effects)}.`, effects);
194
+ }
195
+ effects.forEach((branch, i) => {
196
+ if (!isEffect(branch)) throw malformed(`Parallel's branch ${i} is ${describeValue(branch)}.`, branch);
197
+ });
176
198
  const hasNext = typeof nextOrOptions === 'function';
199
+ // A caller forwarding an absent `next` still passes its options third, as the signature reads.
200
+ const nextSkipped = nextOrOptions == null;
201
+ if (!hasNext && !nextSkipped && !isOptionsObject(nextOrOptions)) {
202
+ const got = describeArgument(nextOrOptions);
203
+ throw malformed(`Parallel's second argument must be next or the options, got ${got}.`, nextOrOptions);
204
+ }
205
+ const options = hasNext || nextSkipped ? maybeOptions : nextOrOptions;
206
+ if (options != null && !isOptionsObject(options)) {
207
+ throw malformed(`Parallel's options must be an object, got ${describeArgument(options)}.`, options);
208
+ }
177
209
  return {
178
210
  type: 'Parallel',
179
211
  effects,
180
212
  next: hasNext ? nextOrOptions : (/** @type {any[]} */ values) => Success(values),
181
- options: (hasNext ? maybeOptions : nextOrOptions) ?? {}
213
+ options: options ?? {}
182
214
  };
183
215
  };
184
216
 
217
+ /**
218
+ * Whether a value is an object that can carry properties: not `null`, a primitive, or a function.
219
+ * @param {any} value
220
+ * @returns {boolean}
221
+ */
222
+ const isObject = (value) => value !== null && typeof value === 'object';
223
+
224
+ /**
225
+ * @param {any} value
226
+ * @returns {boolean}
227
+ */
228
+ const isPositiveInteger = (value) => Number.isInteger(value) && value >= 1;
229
+
230
+ /**
231
+ * @param {any} value
232
+ * @returns {boolean}
233
+ */
234
+ const isFiniteNonNegative = (value) => Number.isFinite(value) && value >= 0;
235
+
236
+ /**
237
+ * An object that can hold options or meta: not an array, and not an Effect passed in the wrong place.
238
+ * @param {any} value
239
+ * @returns {boolean}
240
+ */
241
+ const isOptionsObject = (value) => isObject(value) && !Array.isArray(value) && !isEffect(value);
242
+
185
243
  /**
186
244
  * Describes a value for an error message, leading with the mistake it most likely is.
187
245
  * @param {any} value
188
246
  * @returns {string}
189
247
  */
190
- const describeValue = (value) =>
191
- value === undefined
192
- ? 'undefined, which usually means a missing return'
193
- : value === null
194
- ? 'null'
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.
248
+ const describeValue = (value) => {
249
+ if (value === undefined) return 'undefined, which usually means a missing return';
250
+ if (value === null) return 'null';
251
+ if (value instanceof Promise) return 'a Promise, which usually means an async function';
252
+ if (typeof value === 'function')
253
+ return 'a function, which usually means a flow was passed without being called with its input';
254
+ // `String`, since `JSON.stringify` prints NaN and Infinity as null and throws on a BigInt.
255
+ if (typeof value !== 'object') {
256
+ return `the ${typeof value} ${typeof value === 'string' ? JSON.stringify(value) : String(value)}`;
257
+ }
258
+ if (typeof value.type === 'string') return `an object with an unrecognised type '${value.type}'`;
259
+ return 'a plain object';
260
+ };
261
+
262
+ /**
263
+ * Describes a constructor's argument for an error message. A missing one is usually a misspelt name
264
+ * rather than a missing return, and an Effect in the wrong place is named by its type.
265
+ * @param {any} value
266
+ * @returns {string}
267
+ */
268
+ const describeArgument = (value) =>
269
+ value === undefined ? 'undefined' : isEffect(value) ? `an Effect of type '${value.type}'` : describeValue(value);
270
+
271
+ /**
272
+ * Marks an error as the harness failing rather than the flow: a malformed flow, or a trace that cannot
273
+ * answer a step. The interpreter rethrows anything carrying it instead of folding it into a `Failure`, so
274
+ * nothing that handles a domain failure can absorb it. A new kind of harness error gets this mark rather
275
+ * than a name the interpreter has to know. Non-enumerable, so it never shows up in a serialized error or
276
+ * a `deepEqual`.
212
277
  */
213
278
  const harnessError = Symbol('pure-effect.harnessError');
214
279
 
@@ -219,11 +284,17 @@ const harnessError = Symbol('pure-effect.harnessError');
219
284
  const asHarnessError = (error) => Object.defineProperty(error, harnessError, { value: true });
220
285
 
221
286
  /**
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.
287
+ * Whether a thrown value carries one of the library's marks: `harnessError`, `replayCut`, or `replayFault`.
288
+ * @param {unknown} e
289
+ * @param {symbol} mark
290
+ * @returns {boolean}
291
+ */
292
+ const hasMark = (e, mark) => Boolean(e && /** @type {any} */ (e)[mark]);
293
+
294
+ /**
295
+ * An I/O fault: a Command's function threw, or a `Retry` ran out of attempts. `Retry` retries this, never a
296
+ * `Failure` a step returned, which is an abort. Internal: only `execute` and its per-node functions return
297
+ * one, and `asOutcome` turns it into a plain `Failure` before it reaches user code.
227
298
  * @typedef {{ type: 'IoFault', error: any, initialInput?: any }} IoFaultState
228
299
  */
229
300
 
@@ -241,33 +312,56 @@ const IoFault = (error, initialInput) => ({ type: 'IoFault', error, initialInput
241
312
  const asOutcome = (state) => (state.type === 'IoFault' ? Failure(state.error, state.initialInput) : state);
242
313
 
243
314
  /**
244
- * Checks that a value is an Effect, and explains the mistake when it is not.
315
+ * Builds an `EffectTypeError`, for a malformed flow: a step or continuation that returned something other
316
+ * than an Effect, or a constructor given an argument it cannot use. It is a harness error: a malformed flow
317
+ * is a bug, so it is thrown rather than becoming a `Failure`.
245
318
  *
246
- * `EffectTypeError` is a bug in the flow rather than a domain failure, so it is thrown instead of
247
- * becoming a `Failure`, and the interpreter rethrows it rather than folding it into one.
319
+ * @param {string} message
320
+ * @param {any} value - The malformed value
321
+ * @returns {Error}
322
+ */
323
+ const malformed = (message, value) => {
324
+ // This error already reports the bug, so the Promise rejecting must not also crash the process as
325
+ // unhandled. Only a native Promise: calling `then` on a Knex or Mongoose query builder runs the query.
326
+ if (value instanceof Promise) value.catch(() => {});
327
+ return asHarnessError(Object.assign(new Error(message), { name: 'EffectTypeError' }));
328
+ };
329
+
330
+ /**
331
+ * Builds the error for a value that is not an Effect, explaining the likely mistake.
248
332
  *
249
333
  * @param {any} value
250
334
  * @param {string} source - What produced the value, named where it is known
251
335
  * @returns {Error}
252
336
  */
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
- )
337
+ const effectTypeError = (value, source) =>
338
+ malformed(
339
+ `${source} returned ${describeValue(value)}. Return Success, Failure, Command, Ask, Retry, or Parallel: ` +
340
+ (value instanceof Promise
341
+ ? 'a step cannot be async, so do the awaited work in a Command and continue in its next.'
342
+ : 'a plain value has to be wrapped, as in Success(value).'),
343
+ value
269
344
  );
270
- };
345
+
346
+ /**
347
+ * A Success or a Failure: a flow with nothing left to run.
348
+ * @param {any} value
349
+ * @returns {value is SuccessState | FailureState}
350
+ */
351
+ const isOutcome = (value) => ['Success', 'Failure'].includes(value?.type);
352
+
353
+ /**
354
+ * A Command, Ask, Retry, or Parallel: a flow the interpreter still has work to do on.
355
+ * @param {any} value
356
+ * @returns {value is CommandState | AskState | RetryState | ParallelState}
357
+ */
358
+ const isPending = (value) => ['Command', 'Ask', 'Retry', 'Parallel'].includes(value?.type);
359
+
360
+ /**
361
+ * @param {any} value
362
+ * @returns {value is Effect}
363
+ */
364
+ const isEffect = (value) => isOutcome(value) || isPending(value);
271
365
 
272
366
  /**
273
367
  * @param {any} value
@@ -275,7 +369,7 @@ const effectTypeError = (value, source) => {
275
369
  * @returns {Effect}
276
370
  */
277
371
  const asEffect = (value, source) => {
278
- if (value && ['Success', 'Failure', 'Command', 'Ask', 'Retry', 'Parallel'].includes(value.type)) return value;
372
+ if (isEffect(value)) return value;
279
373
  throw effectTypeError(value, source);
280
374
  };
281
375
 
@@ -289,21 +383,12 @@ const asEffect = (value, source) => {
289
383
  * @returns {Effect} The composed Effect
290
384
  */
291
385
  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.
386
+ // Overwrites a sub-pipeline's own stamp, so the root and a Failure at any depth carry the input of the
387
+ // flow that was actually called. A Success stays bare, so `deepEqual(result, Success(v))` always holds.
300
388
  const withII = (/** @type {Effect} */ e) =>
301
389
  initialInput !== undefined && e.type !== 'Success' ? { ...e, initialInput } : e;
302
390
 
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.
391
+ // Checked before `effect.type` is read, which would otherwise throw a bare TypeError naming no step.
307
392
  if (effect == null) return asEffect(effect, 'A continuation');
308
393
 
309
394
  switch (effect.type) {
@@ -340,15 +425,21 @@ const chain = (effect, fn, initialInput) => {
340
425
  * @returns {(start: any) => Effect} A function that accepts an initial input and returns the final Effect tree.
341
426
  */
342
427
  const effectPipe = (...fns) => {
428
+ // Checked where the pipeline is defined, so a misspelt import is named before any flow is built from it.
429
+ fns.forEach((fn, i) => {
430
+ if (typeof fn !== 'function') {
431
+ const got = describeArgument(fn);
432
+ throw malformed(
433
+ `effectPipe's step ${i + 1} is ${got}; each step is a function that returns an Effect.`,
434
+ fn
435
+ );
436
+ }
437
+ });
343
438
  return (start) => {
344
439
  const chainWithII = (/** @type {Effect} */ eff, /** @type {(v: any) => Effect} */ fn) => chain(eff, fn, start);
345
440
  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.
441
+ // An identity pass. `chain` stamps a sub-pipeline's nodes only through the continuations it wraps,
442
+ // and a sub-pipeline returned by the last step has no later step to wrap them.
352
443
  return chain(tree, Success, start);
353
444
  };
354
445
  };
@@ -356,10 +447,8 @@ const effectPipe = (...fns) => {
356
447
  /**
357
448
  * Wraps one Command execution, or one Parallel: `type` is 'Parallel', and `op` runs the branches and
358
449
  * 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.
450
+ * `op` an argument, the recorded decision. `path` is the step's position in the Effect tree, so it matches
451
+ * between a run and its replay whatever order `Parallel` branches finish in.
363
452
  * @typedef {(name: string, type: string, op: function, path?: string) => Promise<any>} StepRunner
364
453
  */
365
454
  /** @type StepRunner */
@@ -376,15 +465,13 @@ const defaultCommandInterceptor = async (command, context) => {};
376
465
  const defaultRetryOptions = { attempts: 3, delay: 100, backoff: 1 };
377
466
 
378
467
  /**
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.
468
+ * Refuses the removed `retry` key rather than ignoring it, which would quietly turn a configured
469
+ * `attempts: 5` back into 3. Migration scaffolding: remove at 1.0.
383
470
  * @param {any} config
384
471
  * @param {string} source
385
472
  */
386
473
  const rejectRetryKey = (config, source) => {
387
- if (config && typeof config === 'object' && 'retry' in config)
474
+ if (isObject(config) && 'retry' in config)
388
475
  throw new TypeError(
389
476
  `${source} no longer takes 'retry'. Retry options are per-use: pass them to Retry(effect, options).`
390
477
  );
@@ -392,18 +479,15 @@ const rejectRetryKey = (config, source) => {
392
479
 
393
480
  /**
394
481
  * @typedef {Object} EffectConfiguration
395
- * @property {StepRunner} [onStep] - Fires every time a Command is executed. It wraps the `cmd` call.
482
+ * @property {StepRunner} [onStep] - Wraps each Command's execution, and each Parallel's branches.
396
483
  * @property {RunWrapper} [onRun] - Fires once per runEffect call. It wraps the entire workflow execution.
397
484
  * @property {CommandInterceptor} [onBeforeCommand] - Intercepts a Command and any context passed to runEffect before execution.
398
485
  */
399
486
 
400
487
  /**
401
488
  * 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.
489
+ * the call's hooks are merged over the wiring `configureEffect` installed, global outermost. With
490
+ * `inherit: false` that wiring is ignored, so a slot the call leaves unset falls back to the library default.
407
491
  *
408
492
  * @typedef {EffectConfiguration & { inherit?: boolean }} CallConfiguration
409
493
  */
@@ -412,10 +496,8 @@ const rejectRetryKey = (config, source) => {
412
496
  let layers = [];
413
497
 
414
498
  /**
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.
499
+ * The installed layers merged into one configuration. A slot no layer defines is absent, and `runEffect`
500
+ * picks the library default where it reads the slot.
419
501
  * @type {EffectConfiguration}
420
502
  */
421
503
  let globalConfig = {};
@@ -438,29 +520,21 @@ const applyLayers = () => {
438
520
  * configureEffect(telemetryHooks(), recordingHooks({ sink }));
439
521
  * configureEffect(telemetryHooks()); configureEffect(recordingHooks({ sink }));
440
522
  *
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.
443
- *
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.
523
+ * Calling it with no arguments at all removes every layer. A call whose arguments are all `undefined`,
524
+ * such as `configureEffect(flag ? hooks : undefined)`, is a conditional install that installed nothing:
525
+ * it adds no layer and removes none.
449
526
  *
450
527
  * @param {...(EffectConfiguration | undefined)} configs - Configurations merged into one layer, outermost first
451
528
  * @returns {() => void} Removes the layer this call added; a second call does nothing
452
529
  */
453
530
  const configureEffect = (...configs) => {
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
531
  if (configs.length === 0) {
458
532
  layers = [];
459
533
  applyLayers();
460
534
  return () => {};
461
535
  }
462
536
  const present = configs.filter(Boolean);
463
- // Checked before anything is installed, so a refused call leaves the wiring exactly as it found it.
537
+ // Before installing anything, so a refused call leaves the wiring untouched.
464
538
  present.forEach((config) => rejectRetryKey(config, 'configureEffect'));
465
539
  if (present.length === 0) return () => {};
466
540
  const layer = chainHooks(...present);
@@ -493,7 +567,8 @@ const configureEffect = (...configs) => {
493
567
  const now = () => (typeof performance === 'object' ? performance.now() : Date.now());
494
568
 
495
569
  /**
496
- * Wraps a step observer into an `onStep` that cannot change the run.
570
+ * Wraps a step observer into an `onStep` that cannot change the run: `op` always runs, its result is
571
+ * returned, its error propagates, and anything the observer throws is dropped.
497
572
  *
498
573
  * @param {(start: StepStart) => (end: StepEnd) => void} handler - Returns a finisher for the outcome
499
574
  * @returns {StepRunner}
@@ -528,11 +603,10 @@ const observeSteps = (handler) => async (name, type, op, path) => {
528
603
  /**
529
604
  * Merges several configurations into one, so independent concerns can share the hooks.
530
605
  *
531
- * `onStep` and `onRun` are wrappers around an `op`, so they nest. The first config given is
532
- * the outermost wrapper and the last sits closest to the Command, which also means a thrown
533
- * Command unwinds from the last config back to the first. `onBeforeCommand` is an observer,
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.
606
+ * `onStep` and `onRun` are wrappers around an `op`, so they nest: the first config given is the
607
+ * outermost and the last sits closest to the Command, so a thrown Command unwinds from the last back
608
+ * to the first. `onBeforeCommand` is an observer, so every interceptor runs in the order given. A hook
609
+ * no config defines is left unset, so the caller keeps its default for that slot.
536
610
  *
537
611
  * @param {...(EffectConfiguration | undefined)} configs - Configurations to merge, outermost first
538
612
  * @returns {EffectConfiguration}
@@ -569,17 +643,13 @@ const chainHooks = (...configs) => {
569
643
  };
570
644
 
571
645
  /**
572
- * The Failure a `Parallel` branch resolves to when a sibling branch has already failed.
573
- *
574
- * Branches are cancelled, not merely ignored: this is what `Parallel` returns for a branch that was
575
- * still in flight, and it is deliberately distinguishable so it can be told apart from the genuine
576
- * failure that triggered the cancellation.
577
- * @returns {Error}
646
+ * The Failure a cancelled `Parallel` branch stops with. Its error is named so it is never mistaken for
647
+ * the failure that triggered the cancellation.
648
+ * @param {any} initialInput
649
+ * @returns {FailureState}
578
650
  */
579
- const parallelCancelled = () =>
580
- Object.assign(new Error('Parallel branch cancelled.'), {
581
- name: 'ParallelCancelled'
582
- });
651
+ const cancelledBranch = (initialInput) =>
652
+ Failure(Object.assign(new Error('Parallel branch cancelled.'), { name: 'ParallelCancelled' }), initialInput);
583
653
 
584
654
  /**
585
655
  * Which branch, if any, cancelled a Parallel. `branch: null` means an enclosing Parallel cancelled it.
@@ -588,25 +658,41 @@ const parallelCancelled = () =>
588
658
  */
589
659
 
590
660
  /**
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.
661
+ * What running a Parallel's branches produced: each branch's outcome in array order, the decision, and
662
+ * the first error a branch threw, which the Parallel rethrows once its step has returned the decision.
663
+ * @typedef {{
664
+ * results: (SuccessState | FailureState | IoFaultState)[],
665
+ * decision: ParallelDecision,
666
+ * thrown: { error: unknown } | undefined
667
+ * }} BranchRun
668
+ */
669
+
670
+ /**
671
+ * @param {any} branch
672
+ * @param {number} count - How many branches the Parallel has
673
+ * @returns {boolean}
674
+ */
675
+ const isBranchIndex = (branch, count) => Number.isInteger(branch) && branch >= 0 && branch < count;
676
+
677
+ /**
678
+ * Reads a recorded decision, or `undefined` for anything that is not one, which replays the Parallel
679
+ * under timing.
593
680
  * @param {any} value
594
681
  * @param {number} branches - How many branches the Parallel has, so a stale branch index is refused too
595
682
  * @returns {ParallelDecision | undefined}
596
683
  */
597
684
  const asDecision = (value, branches) => {
598
- if (!value || typeof value !== 'object') return undefined;
685
+ if (!isObject(value)) return undefined;
599
686
  if (value.cancelled === false) return { cancelled: false };
600
687
  if (value.cancelled !== true) return undefined;
601
688
  const { branch } = value;
602
689
  if (branch === null) return { cancelled: true, branch: null };
603
- return Number.isInteger(branch) && branch >= 0 && branch < branches ? { cancelled: true, branch } : undefined;
690
+ return isBranchIndex(branch, branches) ? { cancelled: true, branch } : undefined;
604
691
  };
605
692
 
606
693
  /**
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.
694
+ * Marks a step a replay reached that production never ran, in a branch a recorded decision cancelled.
695
+ * The interpreter stops the branch there, as production did. Only `fromTrace` raises it.
610
696
  */
611
697
  const replayCut = Symbol('pure-effect.replayCut');
612
698
 
@@ -622,10 +708,27 @@ const replayCutError = (path) =>
622
708
  );
623
709
 
624
710
  /**
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.
711
+ * A cancellation scope for one Parallel, linked to the enclosing one so cancellation nests. Without an
712
+ * `AbortController` there is no scope, and every branch runs to completion.
713
+ * @param {AbortSignal} [signal] - The enclosing Parallel's signal, if any
714
+ * @returns {{ scope: AbortController | undefined, unlink: () => void }}
715
+ */
716
+ const linkedScope = (signal) => {
717
+ const scope = typeof AbortController === 'function' ? new AbortController() : undefined;
718
+ const relay = () => scope?.abort();
719
+ if (signal && scope) {
720
+ if (signal.aborted) scope.abort();
721
+ else signal.addEventListener('abort', relay, { once: true });
722
+ }
723
+ const unlink = () => {
724
+ if (signal && scope) signal.removeEventListener('abort', relay);
725
+ };
726
+ return { scope, unlink };
727
+ };
728
+
729
+ /**
730
+ * Awaits every task, with at most `limit` of them in flight. Workers pull by index, so results land where
731
+ * the caller put the effect rather than where it finished, which keeps trace paths stable.
629
732
  * @param {(() => Promise<void>)[]} tasks
630
733
  * @param {number} [limit]
631
734
  * @returns {Promise<void>}
@@ -643,9 +746,8 @@ const runBounded = async (tasks, limit) => {
643
746
  };
644
747
 
645
748
  /**
646
- * Sleeps, but gives up early when the surrounding branch is cancelled, so a sibling's failure does not
647
- * have to wait out a retry backoff that is now pointless. Resolves either way: the caller's abort check
648
- * is what turns a cancelled wait into a Failure.
749
+ * Sleeps, but gives up early when the surrounding branch is cancelled. Resolves either way: the
750
+ * caller's abort check is what turns a cancelled wait into a Failure.
649
751
  *
650
752
  * @param {number} ms
651
753
  * @param {AbortSignal} [signal]
@@ -657,9 +759,8 @@ const delayFor = (ms, signal) =>
657
759
  setTimeout(resolve, ms);
658
760
  return;
659
761
  }
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.
762
+ // A listener added to an aborted signal never runs, and this is the usual case: a Command that
763
+ // honours the signal rejects when a sibling fails, and the Retry around it backs off on that signal.
663
764
  if (signal.aborted) {
664
765
  resolve();
665
766
  return;
@@ -675,33 +776,27 @@ const delayFor = (ms, signal) =>
675
776
  signal.addEventListener('abort', onAbort, { once: true });
676
777
  });
677
778
 
678
- const runEffect =
779
+ const interpret =
679
780
  /**
680
- * The Interpreter
681
- * Iterates through the Effect tree, executing Commands and handling async flow.
682
- * Ask effects are resolved synchronously with the context object.
683
- *
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
686
- * single span rather than spawning their own, keeping telemetry non-duplicated.
781
+ * The interpreter: walks the Effect tree, executing Commands and resolving Ask, Retry, and Parallel.
782
+ * `runEffect` and `replayEffect` both run flows through it, so a replay cannot drift from a run.
783
+ * `fastRetry` is the one thing only a replay sets: it waits no time between retry attempts, since a
784
+ * delay is written on the Retry node and nothing else can override it.
687
785
  *
688
- * @param {Effect} effect - The Effect tree returned by a pipeline
689
- * @param {any} [context] - Optional context object. Passed to Ask continuations and the Command Interceptor.
690
- * @param {CallConfiguration} [callConfig] - Per-call configuration, added to the wiring `configureEffect`
691
- * installed unless `inherit: false`, which ignores that wiring for this run.
786
+ * @param {Effect} effect
787
+ * @param {any} [context]
788
+ * @param {CallConfiguration} [callConfig]
789
+ * @param {boolean} [fastRetry]
692
790
  * @returns {Promise<SuccessState | FailureState>}
693
791
  */
694
- async function runEffect(effect, context = {}, callConfig = {}) {
792
+ async function interpret(effect, context = {}, callConfig = {}, fastRetry = false) {
695
793
  rejectRetryKey(callConfig, "runEffect's callConfig");
696
794
  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.
795
+ // Not coerced: `'false'` quietly inheriting everything is the behaviour this option exists to remove.
699
796
  if (typeof inherit !== 'boolean') {
700
797
  throw new TypeError(`callConfig.inherit must be true or false, got ${JSON.stringify(inherit)}.`);
701
798
  }
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.
799
+ // Merged the way `configureEffect` merges layers. A call with no hooks of its own skips the merge.
705
800
  const base = inherit ? globalConfig : {};
706
801
  const resolved = Object.keys(local).length ? chainHooks(base, local) : base;
707
802
  const localStepRunner = resolved.onStep || defaultStepRunner;
@@ -709,325 +804,330 @@ const runEffect =
709
804
  const localCommandInterceptor = resolved.onBeforeCommand || defaultCommandInterceptor;
710
805
 
711
806
  /**
807
+ * Walks a subtree until it reaches a Success or a Failure. Every node but `Ask` runs in its own
808
+ * function, which returns a Success carrying the value for the node's `next`, or the Failure or I/O
809
+ * fault that stops the subtree.
810
+ *
712
811
  * @param {Effect} eff
713
812
  * @param {AbortSignal} [signal] - Cancellation for this subtree, set for `Parallel` branches.
714
- * A branch stops starting new Commands once it is aborted, and the signal is handed to
715
- * each Command's thunk so I/O that accepts one can be cancelled in flight.
716
- * @param {string} [path] - Prefix identifying this subtree's position in the Effect tree.
717
- * Steps are numbered sequentially within a subtree and each `Parallel` branch and `Retry`
718
- * attempt opens its own prefix, so a Command's full path depends only on the shape of the
719
- * tree and not on the order branches happen to finish in. That is what lets a replay line
720
- * a recorded step up with the step that asked for it.
813
+ * @param {string} [path] - Prefix for this subtree's position in the Effect tree. Steps are numbered
814
+ * within a subtree and each `Parallel` branch and `Retry` attempt opens its own prefix, so a
815
+ * path depends only on the tree's shape, never on the order branches finish in.
721
816
  * @returns {Promise<SuccessState | FailureState | IoFaultState>}
722
817
  */
723
818
  async function execute(eff, signal, path = '') {
724
819
  let step = 0;
725
- while (
726
- eff &&
727
- (eff.type === 'Command' || eff.type === 'Ask' || eff.type === 'Retry' || eff.type === 'Parallel')
728
- ) {
729
- // Checked before every node, which is what short-circuits a branch whose Commands
730
- // ignore the signal: the one in flight cannot be stopped, but the next never starts.
731
- if (signal?.aborted) return Failure(parallelCancelled(), eff.initialInput);
820
+ while (isPending(eff)) {
821
+ // Checked before every node: a Command already in flight cannot be stopped, but the next never starts.
822
+ if (signal?.aborted) return cancelledBranch(eff.initialInput);
732
823
  if (eff.type === 'Ask') {
733
824
  eff = eff.next(context);
734
825
  continue;
735
826
  }
736
- if (eff.type === 'Retry') {
737
- const opts = { ...defaultRetryOptions, ...eff.options };
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
- );
748
- let lastError;
749
- let succeeded = false;
750
- // Each attempt gets its own prefix, so the Commands of attempt 2 cannot be mistaken
751
- // for the Commands of attempt 1 when a trace is matched back up.
752
- const retryPath = `${path}${step++}r`;
753
- // Captured while `eff` is still narrowed to a Retry node: the loop reassigns `eff`
754
- // on success, after which the checker cannot prove the fallback path still holds it.
755
- const retryNext = eff.next;
756
-
757
- for (let attempt = 0; attempt <= attempts; attempt++) {
758
- if (attempt > 0) {
759
- await delayFor(opts.delay * Math.pow(opts.backoff, attempt - 1), signal);
760
- }
761
- // Checked after the wait, so a branch cancelled mid-backoff stops here rather
762
- // than buying one more attempt, and a sibling's failure does not pay for the
763
- // rest of this branch's retry schedule.
764
- if (signal?.aborted) return Failure(parallelCancelled(), eff.initialInput);
765
- const result = await execute(eff.effect, signal, `${retryPath}${attempt}/`);
766
- if (result.type === 'Success') {
767
- eff = retryNext(result.value);
768
- succeeded = true;
769
- break;
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;
775
- lastError = result.error;
776
- }
777
-
778
- if (!succeeded) {
779
- const exhausted = { retryExhausted: true, lastError, attempts };
780
- const { onExhausted } = opts;
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);
784
- // A cancelled branch must not start its fallback, for the same reason it starts
785
- // no further Commands: recovery must not resurrect work a sibling's failure ended.
786
- if (signal?.aborted) return Failure(parallelCancelled(), eff.initialInput);
787
- // The fallback gets its own path prefix, so a fallback-path trace can never be
788
- // confused with a success-path one when it is matched back up on replay.
789
- const fallback = await execute(
790
- asEffect(onExhausted(exhausted), "Retry option 'onExhausted'"),
791
- signal,
792
- `${retryPath}f/`
793
- );
794
- // A failing fallback propagates as-is: the flow's last word was the fallback's
795
- // error, not the exhaustion it was already told about.
796
- if (fallback.type !== 'Success') return fallback;
797
- eff = retryNext(fallback.value);
798
- }
799
- continue;
827
+ const stepPath = `${path}${step++}`;
828
+ const outcome =
829
+ eff.type === 'Retry'
830
+ ? await runRetry(eff, signal, stepPath)
831
+ : eff.type === 'Parallel'
832
+ ? await runParallel(eff, signal, stepPath)
833
+ : await runCommand(eff, signal, stepPath);
834
+ if (outcome.type !== 'Success') return outcome;
835
+ // Outside every catch: `next` and the pure steps it reaches are code, not I/O, so a throw there
836
+ // rejects the run.
837
+ eff = eff.next(outcome.value);
838
+ }
839
+ if (isOutcome(eff)) return eff;
840
+ throw effectTypeError(eff, 'The flow');
841
+ }
842
+
843
+ /**
844
+ * Runs a Retry's wrapped tree until it succeeds or runs out of attempts, then its fallback if it has
845
+ * one. Each attempt opens its own path prefix, and so does the fallback.
846
+ *
847
+ * @param {RetryState} retry
848
+ * @param {AbortSignal | undefined} signal
849
+ * @param {string} stepPath
850
+ * @returns {Promise<SuccessState | FailureState | IoFaultState>}
851
+ */
852
+ async function runRetry(retry, signal, stepPath) {
853
+ // An option set to `undefined` keeps its default, since that is how an absent config key arrives.
854
+ const given = Object.entries(retry.options ?? {}).filter(([, value]) => value !== undefined);
855
+ const opts = /** @type {typeof defaultRetryOptions & RetryState['options']} */ ({
856
+ ...defaultRetryOptions,
857
+ ...Object.fromEntries(given)
858
+ });
859
+ const { attempts, onExhausted } = opts;
860
+ // `0` is refused rather than meaning run once: it would make `onExhausted` a free catch.
861
+ if (!isPositiveInteger(attempts))
862
+ throw new TypeError(
863
+ `Retry 'attempts' must be a positive integer, received ${describeValue(attempts)}. ` +
864
+ `To handle an outcome without retrying, branch on it as data in the Command's ` +
865
+ `next, or isolate a failing branch with Parallel's settled option.`
866
+ );
867
+ // A wait that is NaN, negative or infinite used to be no wait at all, so a flapping dependency was
868
+ // called back to back.
869
+ for (const key of /** @type {const} */ (['delay', 'backoff'])) {
870
+ if (!isFiniteNonNegative(opts[key]))
871
+ throw new TypeError(
872
+ `Retry '${key}' must be a finite number of 0 or more, received ${describeValue(opts[key])}.`
873
+ );
874
+ }
875
+ let lastError;
876
+ for (let attempt = 0; attempt <= attempts; attempt++) {
877
+ // Under `fastRetry` this waits for no time rather than skipping the wait, so branches replayed by
878
+ // timing still interleave as they do with a delay.
879
+ if (attempt > 0) {
880
+ await delayFor(fastRetry ? 0 : opts.delay * Math.pow(opts.backoff, attempt - 1), signal);
800
881
  }
801
- if (eff.type === 'Parallel') {
802
- // Branch prefixes come from the branch's index in the array, never from the order
803
- // branches complete in, which is the whole point: two branches calling the same
804
- // Command are told apart by where they are rather than by who finished first.
805
- const branchPath = `${path}${step++}p`;
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
- };
935
-
936
- try {
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.'
947
- );
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;
958
- }
959
-
960
- const failure =
961
- decision.cancelled && decision.branch !== null
962
- ? results[decision.branch]
963
- : results.find((r) => r.type !== 'Success');
964
- if (failure) return failure;
965
- eff = eff.next(results.map((r) => /** @type {SuccessState} */ (r).value));
966
- continue;
882
+ // After the wait, so a branch cancelled mid-backoff makes no further attempt.
883
+ if (signal?.aborted) return cancelledBranch(retry.initialInput);
884
+ const result = await execute(retry.effect, signal, `${stepPath}r${attempt}/`);
885
+ // A Success feeds `next`. An abort is the flow deciding, not the I/O failing: not retried, not
886
+ // wrapped.
887
+ if (result.type !== 'IoFault') return result;
888
+ lastError = result.error;
889
+ }
890
+ const exhausted = { retryExhausted: true, lastError, attempts };
891
+ // An exhaustion is a fault too, which keeps an enclosing Retry retrying this one.
892
+ if (typeof onExhausted !== 'function') return IoFault(exhausted, retry.initialInput);
893
+ // A cancelled branch starts no fallback, as it starts no further Commands.
894
+ if (signal?.aborted) return cancelledBranch(retry.initialInput);
895
+ // A failing fallback propagates as it is, not wrapped as another exhaustion.
896
+ return execute(asEffect(onExhausted(exhausted), "Retry option 'onExhausted'"), signal, `${stepPath}rf/`);
897
+ }
898
+
899
+ /**
900
+ * Runs a Parallel as one step of its own, whose `op` runs the branches and returns the decision, so
901
+ * the decision is recorded and a replay can hand it back.
902
+ *
903
+ * @param {ParallelState} parallel
904
+ * @param {AbortSignal | undefined} signal
905
+ * @param {string} stepPath
906
+ * @returns {Promise<SuccessState | FailureState | IoFaultState>}
907
+ */
908
+ async function runParallel(parallel, signal, stepPath) {
909
+ // Branch prefixes come from array index, never completion order, so two branches calling the same
910
+ // Command are told apart by position.
911
+ const branchPath = `${stepPath}p`;
912
+ const options = parallel.options ?? {};
913
+ const { limit, settled } = options;
914
+ if (limit !== undefined && !isPositiveInteger(limit))
915
+ throw new TypeError(`Parallel 'limit' must be a positive integer, received ${describeValue(limit)}.`);
916
+ // Cast rather than annotated, since only `op` assigns it.
917
+ let run = /** @type {BranchRun | undefined} */ (undefined);
918
+ const op = async (/** @type {any} */ recorded) => {
919
+ run = await runBranches(parallel.effects, options, signal, branchPath, recorded);
920
+ return run.decision;
921
+ };
922
+ try {
923
+ await localStepRunner('Parallel', 'Parallel', op, branchPath);
924
+ } catch (e) {
925
+ // A replay found this Parallel inside a branch production had already stopped.
926
+ if (hasMark(e, replayCut)) return cancelledBranch(parallel.initialInput);
927
+ throw e;
928
+ }
929
+ if (!run) {
930
+ throw new TypeError(
931
+ `An onStep hook returned without letting op run the Parallel at path '${branchPath}'. ` +
932
+ 'A hook has to call op for a Parallel and pass on what it returns or throws, because op ' +
933
+ 'runs its branches.'
934
+ );
935
+ }
936
+ const { results, decision, thrown } = run;
937
+ // Rethrown here, after the step has returned its decision, so the decision is recorded.
938
+ if (thrown) throw thrown.error;
939
+ // Settled hands `next` the outcomes as plain Success and Failure nodes, so the Parallel never fails
940
+ // on a branch's account.
941
+ if (settled) return Success(results.map(asOutcome));
942
+ const failure =
943
+ decision.cancelled && decision.branch !== null
944
+ ? results[decision.branch]
945
+ : results.find((r) => r.type !== 'Success');
946
+ if (failure) return failure;
947
+ return Success(results.map((r) => /** @type {SuccessState} */ (r).value));
948
+ }
949
+
950
+ /**
951
+ * Runs a Parallel's branches and decides which branch, if any, cancelled the others. In a live run
952
+ * timing decides it, which is why the decision is recorded. A recorded cancellation passed in by a
953
+ * replay is reproduced rather than recomputed: no branch cancels another, each runs its recorded steps
954
+ * and stops where its recording stops, and the recorded branch's failure is the result.
955
+ *
956
+ * @param {Effect[]} effects
957
+ * @param {ParallelOptions} options
958
+ * @param {AbortSignal | undefined} signal - The enclosing Parallel's cancellation, if any
959
+ * @param {string} branchPath
960
+ * @param {any} recorded - The recorded decision, when a replay supplies one
961
+ * @returns {Promise<BranchRun>}
962
+ */
963
+ async function runBranches(effects, options, signal, branchPath, recorded) {
964
+ const { limit, settled } = options;
965
+ // A recorded branch past the end means the flow changed shape since the recording. A negative one is
966
+ // not a decision, so it replays by timing like anything else.
967
+ const recordedBranch = recorded?.cancelled === true ? recorded.branch : undefined;
968
+ const pastTheEnd = Number.isInteger(recordedBranch) && recordedBranch >= effects.length;
969
+ if (pastTheEnd) {
970
+ const count = effects.length === 1 ? '1 branch' : `${effects.length} branches`;
971
+ throw replayError(
972
+ `Time paradox at path '${branchPath}': the recorded run was cancelled by branch ` +
973
+ `${recordedBranch}, but this Parallel has ${count}.`,
974
+ { name: 'TimeParadox', path: branchPath, branch: recordedBranch }
975
+ );
976
+ }
977
+ const forced = asDecision(recorded, effects.length);
978
+ const reproducing = forced !== undefined && forced.cancelled;
979
+ // Live, a throw cancels the others, and so does a failure unless `settled`; the run rejects on a throw
980
+ // either way, and an enclosing Parallel can still cancel this one. Reproducing, nothing cancels, since
981
+ // the recording already says where each branch stops.
982
+ const { results, thrown, trigger, cancelled } = await settleBranches(
983
+ effects,
984
+ limit,
985
+ signal,
986
+ branchPath,
987
+ reproducing ? () => false : (threw, result) => threw || (!settled && result.type !== 'Success')
988
+ );
989
+ if (reproducing) {
990
+ // A branch that threw has no result to check, and its throw is what is rethrown.
991
+ const recordedTriggerSucceeded =
992
+ !thrown && forced.branch !== null && results[forced.branch].type === 'Success';
993
+ if (recordedTriggerSucceeded) {
994
+ throw replayError(
995
+ `Time paradox at path '${branchPath}': the recorded run was cancelled by ` +
996
+ `branch ${forced.branch}, which did not fail in this replay.`,
997
+ { name: 'TimeParadox', path: branchPath, branch: forced.branch }
998
+ );
967
999
  }
968
- const cmdName = commandName(eff);
969
- const initialInput = eff.initialInput;
970
- const cmdPath = `${path}${step++}`;
971
- const cmd = eff.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.
1000
+ return { results, decision: forced, thrown };
1001
+ }
1002
+ /** @type {ParallelDecision} */
1003
+ const decision =
1004
+ trigger >= 0
1005
+ ? { cancelled: true, branch: trigger }
1006
+ : cancelled
1007
+ ? { cancelled: true, branch: null }
1008
+ : { cancelled: false };
1009
+ return { results, decision, thrown };
1010
+ }
1011
+
1012
+ /**
1013
+ * Runs every branch to completion, at most `limit` at once, under one cancellation scope linked to the
1014
+ * enclosing one. A branch that settles in a way `cancelsOthers` accepts cancels the rest, and the first
1015
+ * to do so is the trigger.
1016
+ *
1017
+ * @param {Effect[]} effects
1018
+ * @param {number | undefined} limit
1019
+ * @param {AbortSignal | undefined} signal
1020
+ * @param {string} branchPath
1021
+ * @param {(threw: boolean, result: SuccessState | FailureState | IoFaultState) => boolean} cancelsOthers
1022
+ * @returns {Promise<{
1023
+ * results: (SuccessState | FailureState | IoFaultState)[],
1024
+ * thrown: { error: unknown } | undefined,
1025
+ * trigger: number,
1026
+ * cancelled: boolean
1027
+ * }>}
1028
+ */
1029
+ async function settleBranches(effects, limit, signal, branchPath, cancelsOthers) {
1030
+ const { scope, unlink } = linkedScope(signal);
1031
+ /** @type {(SuccessState | FailureState | IoFaultState)[]} */
1032
+ const results = new Array(effects.length);
1033
+ // Held rather than rethrown, so every branch still settles and no `limit` worker stops early.
1034
+ /** @type {{ error: unknown }[]} */
1035
+ const thrown = new Array(effects.length);
1036
+ // Which branch cancelled the others on its own account rather than because it was cancelled.
1037
+ const triggered = new Array(effects.length).fill(false);
1038
+ const settle = async (/** @type {Effect} */ branch, /** @type {number} */ i) => {
992
1039
  try {
993
- await localCommandInterceptor(eff, context);
994
- } catch (e) {
995
- if (e && /** @type {any} */ (e)[harnessError]) throw e;
996
- return Failure(e, initialInput);
1040
+ results[i] = await execute(branch, scope?.signal, `${branchPath}${i}/`);
1041
+ } catch (error) {
1042
+ thrown[i] = { error };
997
1043
  }
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);
1044
+ // Read-then-abort is atomic here, so exactly one branch is the trigger.
1045
+ if (cancelsOthers(thrown[i] !== undefined, results[i])) {
1046
+ if (!scope?.signal.aborted) triggered[i] = true;
1047
+ scope?.abort();
1014
1048
  }
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);
1049
+ };
1050
+ try {
1051
+ // Awaits every branch, so no cancelled work runs on unobserved after the Parallel returns.
1052
+ await runBounded(
1053
+ effects.map((branch, i) => () => settle(branch, i)),
1054
+ limit
1055
+ );
1056
+ } finally {
1057
+ unlink();
1020
1058
  }
1021
- if (eff && (eff.type === 'Success' || eff.type === 'Failure')) return eff;
1022
- throw effectTypeError(eff, 'The flow');
1059
+ return {
1060
+ results,
1061
+ // The first by array order, since every branch has settled by now.
1062
+ thrown: thrown.find(Boolean),
1063
+ trigger: triggered.indexOf(true),
1064
+ cancelled: Boolean(scope?.signal.aborted)
1065
+ };
1066
+ }
1067
+
1068
+ /**
1069
+ * Runs one Command. A throw means something different in each region: an interceptor throwing vetoes
1070
+ * the Command, which is an abort, and the Command's function throwing is an I/O fault. A harness error
1071
+ * is rethrown from either.
1072
+ *
1073
+ * @param {CommandState} command
1074
+ * @param {AbortSignal | undefined} signal
1075
+ * @param {string} cmdPath
1076
+ * @returns {Promise<SuccessState | FailureState | IoFaultState>}
1077
+ */
1078
+ async function runCommand(command, signal, cmdPath) {
1079
+ const cmdName = commandName(command);
1080
+ const { initialInput, cmd } = command;
1081
+ // Whether the function itself succeeded: a hook throwing after it did is a bug, and retrying would
1082
+ // repeat work already done.
1083
+ let succeeded = false;
1084
+ // What it returned, so a hook that loses it is caught.
1085
+ /** @type {unknown} */
1086
+ let value;
1087
+ // The signal is passed only inside a Parallel. There it is still the first argument, so a function
1088
+ // passed by name with an optional first parameter, `nanoid(size = 21)` say, takes the signal for it:
1089
+ // a documented sharp edge. Async, so a hook always gets a promise, even from a synchronous function.
1090
+ const op = async () => {
1091
+ succeeded = false;
1092
+ value = await (signal ? cmd(signal) : cmd());
1093
+ succeeded = true;
1094
+ return value;
1095
+ };
1096
+ try {
1097
+ await localCommandInterceptor(command, context);
1098
+ } catch (e) {
1099
+ if (hasMark(e, harnessError)) throw e;
1100
+ return Failure(e, initialInput);
1101
+ }
1102
+ // Again, since an interceptor can wait (a rate limiter, say) while a sibling fails.
1103
+ if (signal?.aborted) return cancelledBranch(initialInput);
1104
+ let returned;
1105
+ try {
1106
+ returned = await localStepRunner(cmdName, 'Command', op, cmdPath);
1107
+ } catch (e) {
1108
+ // A step production never ran, in a branch a Parallel cancelled: stop here, as production did.
1109
+ if (hasMark(e, replayCut)) return cancelledBranch(initialInput);
1110
+ if (hasMark(e, harnessError)) throw e;
1111
+ // A hook threw after the function returned, which is a bug in the hook. A hook that throws
1112
+ // without calling `op` is still a fault, since that is how replay reports a recorded error.
1113
+ if (succeeded) throw e;
1114
+ return IoFault(e, initialInput);
1115
+ }
1116
+ // A hook that awaited `op` and forgot to return its result: `next` would take the branch for a Command
1117
+ // that returned nothing, while a recorder inside the hook kept the real value. Only `undefined` is
1118
+ // refused, so a hook that returns a copy of the result still works.
1119
+ if (returned === undefined && succeeded && value !== undefined) {
1120
+ throw new TypeError(
1121
+ `An onStep hook called op for '${cmdName}' at path '${cmdPath}' and returned undefined, although ` +
1122
+ 'the Command returned a value. A hook has to return what op returns.'
1123
+ );
1124
+ }
1125
+ return Success(returned);
1023
1126
  }
1024
1127
 
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.
1128
+ // Every outcome leaves through here, so this is where a Failure gets the root's input: one from a
1129
+ // Parallel branch or a Retry fallback carries its own subtree's input, which `chain` never reaches.
1130
+ // An I/O fault becomes a plain Failure here too.
1031
1131
  const rootInput = effect?.initialInput;
1032
1132
  const run = async () => {
1033
1133
  const result = asOutcome(await execute(effect));
@@ -1036,6 +1136,18 @@ const runEffect =
1036
1136
  return localRunWrapper(effect, run, context?.flowName || '');
1037
1137
  };
1038
1138
 
1139
+ /**
1140
+ * Runs a flow: executes its Commands, resolving `Ask` with the context and running `Retry` and `Parallel`.
1141
+ * onRun fires exactly once per call; Retry attempts run inside that single span.
1142
+ *
1143
+ * @param {Effect} effect - The Effect tree returned by a pipeline
1144
+ * @param {any} [context] - Optional context object. Passed to Ask continuations and the Command Interceptor.
1145
+ * @param {CallConfiguration} [callConfig] - Per-call configuration, added to the wiring `configureEffect`
1146
+ * installed unless `inherit: false`, which ignores that wiring for this run.
1147
+ * @returns {Promise<SuccessState | FailureState>}
1148
+ */
1149
+ const runEffect = (effect, context, callConfig) => interpret(effect, context, callConfig);
1150
+
1039
1151
  /**
1040
1152
  * The step a replay is asking about. `path` is the Command's position in the Effect tree and is stable
1041
1153
  * across runs; `index` is its position in this run's completion order, which is not stable for a flow
@@ -1057,11 +1169,8 @@ const runEffect =
1057
1169
 
1058
1170
  /**
1059
1171
  * 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,
1062
- * which is the one question a trace could not answer before: which step was slow.
1063
- * `path` locates the Command in the Effect tree, which is what a replay matches on: it does not move
1064
- * when `Parallel` branches finish in a different order than they did in production.
1172
+ * 'Parallel' at the Parallel's own path with the decision as its `result`. `durationMs` is how long
1173
+ * the step took in production, rounded to microseconds. `path` is what a replay matches on.
1065
1174
  * `threw` marks a step that threw. The `error` key alone cannot, since JSON drops it when the value is
1066
1175
  * `undefined`, as it is for `reject()` with no argument or an error `redact` removed.
1067
1176
  * @typedef {{ command: string, path?: string, result?: any, threw?: true, error?: any, durationMs?: number }} TraceEntry
@@ -1081,17 +1190,15 @@ const runEffect =
1081
1190
  */
1082
1191
 
1083
1192
  /**
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.
1193
+ * A replay fault: the trace cannot answer the flow, or disagrees with it. A harness error while the flow
1194
+ * runs, so nothing inside the flow can swallow it; `replayEffect` turns it into a `Failure` at its own
1195
+ * boundary. An `EffectTypeError` carries no replay mark and keeps propagating, since a malformed flow is
1196
+ * a bug in the flow rather than a problem with the trace.
1089
1197
  */
1090
1198
  const replayFault = Symbol('pure-effect.replayFault');
1091
1199
 
1092
1200
  /**
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.
1201
+ * Creates a replay error, which `replayEffect` turns into a Failure at its own boundary.
1095
1202
  * @param {string} message - Human-readable reason
1096
1203
  * @param {Object} [props] - Extra fields such as `name`, `index`, `expected`, `actual`
1097
1204
  * @returns {Error}
@@ -1131,27 +1238,41 @@ const timeParadox = (step, recorded) =>
1131
1238
  *
1132
1239
  * @param {any} e - The thrown value
1133
1240
  * @param {boolean} [withStack] - Include the stack (off by default: noisy, leaks paths)
1241
+ * @param {Set<Error>} [ancestors] - The errors whose `cause` or `errors` led here
1134
1242
  * @returns {any}
1135
1243
  */
1136
- const serializeError = (e, withStack) => {
1244
+ const serializeError = (e, withStack, ancestors = new Set()) => {
1137
1245
  if (!(e instanceof Error)) return e;
1138
1246
  /** @type {any} */
1139
1247
  const out = { __error: true, name: e.name, message: e.message };
1248
+ // A chain that comes back to an error it passed through is cut there, with the name and message only:
1249
+ // following it overflowed the stack, and the recorder dropped the whole step. Only ancestors count, so an
1250
+ // error that merely appears twice is carried in full both times.
1251
+ if (ancestors.has(e)) return out;
1252
+ ancestors.add(e);
1140
1253
  if (withStack) out.stack = e.stack;
1141
1254
  // `cause` is non-enumerable too, and it can itself be an Error, so it is carried recursively.
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.
1255
+ if ('cause' in e) out.cause = serializeError(e.cause, withStack, ancestors);
1256
+ // So is an AggregateError's `errors`, which is where its detail lives. It goes under its own key so
1257
+ // revival can tell it from an enumerable `errors`, which is data and is copied below as it is.
1147
1258
  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));
1259
+ const hasHiddenErrors = Array.isArray(errors) && !Object.prototype.propertyIsEnumerable.call(e, 'errors');
1260
+ if (hasHiddenErrors) {
1261
+ out.__errors = errors.map((x) => serializeError(x, withStack, ancestors));
1150
1262
  }
1151
- for (const k of Object.keys(e)) out[k] = /** @type {any} */ (e)[k];
1263
+ ancestors.delete(e);
1264
+ // Assigning `name`, `message` or `cause`, as `e.name = 'TimeoutError'` or `e.cause = inner` does, makes it
1265
+ // enumerable. It is carried above already, so it is listed for revival to restore as it was rather than
1266
+ // copied again, where a raw cause would replace the serialized one and reach JSON as {}.
1267
+ const shown = carriedKeys.filter((k) => Object.prototype.propertyIsEnumerable.call(e, k));
1268
+ if (shown.length > 0) out.__enumerable = shown;
1269
+ for (const k of Object.keys(e)) if (!carriedKeys.includes(k)) out[k] = /** @type {any} */ (e)[k];
1152
1270
  return out;
1153
1271
  };
1154
1272
 
1273
+ /** The keys `serializeError` carries whatever their enumerability, which a native Error sets as hidden. */
1274
+ const carriedKeys = ['name', 'message', 'cause'];
1275
+
1155
1276
  /**
1156
1277
  * Rebuilds an Error from `serializeError` output. Non-Error values pass through, so a
1157
1278
  * Command that rejected with a string still replays as a string.
@@ -1159,21 +1280,23 @@ const serializeError = (e, withStack) => {
1159
1280
  * @returns {any}
1160
1281
  */
1161
1282
  const reviveError = (v) => {
1162
- if (!v || typeof v !== 'object' || v.__error !== true) return v;
1283
+ if (!isObject(v) || v.__error !== true) return v;
1163
1284
  const e = new Error(v.message);
1164
- // `name` and `cause` are non-enumerable on a native Error (`name` is inherited, `cause` is set by the
1165
- // constructor), so they are defined the same way here. Assigning them would make them own enumerable
1166
- // keys, and the revived error would then never compare deep-equal to the one the Command threw, which
1167
- // is what the record-then-replay determinism check relies on. Everything else was enumerable on the
1168
- // original, since `serializeError` read it through `Object.keys`, so plain assignment is faithful.
1169
- const hidden = { enumerable: false, configurable: true, writable: true };
1170
- Object.defineProperty(e, 'name', { ...hidden, value: v.name });
1285
+ // `name`, `message`, `cause` and `errors` are defined as the original had them, so a revived error
1286
+ // deep-equals the one the Command threw: non-enumerable, as on a native Error, unless `__enumerable` lists
1287
+ // them. Every other key was enumerable on the original.
1288
+ const shown = Array.isArray(v.__enumerable) ? v.__enumerable : [];
1289
+ const restore = (/** @type {string} */ key, /** @type {any} */ value) =>
1290
+ Object.defineProperty(e, key, { enumerable: shown.includes(key), configurable: true, writable: true, value });
1291
+ restore('name', v.name);
1292
+ if (shown.includes('message')) restore('message', v.message);
1293
+ // The markers are dropped, and `name` and `message` are already set.
1294
+ const alreadyHandled = ['__error', '__enumerable', 'name', 'message'];
1171
1295
  for (const [k, val] of Object.entries(v)) {
1172
- if (k === '__error' || k === 'name' || k === 'message') continue;
1173
- if (k === 'cause') Object.defineProperty(e, 'cause', { ...hidden, value: reviveError(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;
1296
+ if (alreadyHandled.includes(k)) continue;
1297
+ if (k === 'cause') restore('cause', reviveError(val));
1298
+ else if (k === '__errors' && Array.isArray(val)) restore('errors', val.map(reviveError));
1299
+ else /** @type {any} */ (e)[k] = val;
1177
1300
  }
1178
1301
  return e;
1179
1302
  };
@@ -1198,21 +1321,31 @@ const reviveError = (v) => {
1198
1321
  * @property {string} [version] - Commit or build id, so a replay can detect a stale trace.
1199
1322
  */
1200
1323
 
1324
+ /**
1325
+ * An array, a plain object, or an object with no prototype: the shapes `copyAround` rebuilds itself rather
1326
+ * than handing to `structuredClone`.
1327
+ * @param {object} value
1328
+ * @returns {boolean}
1329
+ */
1330
+ const isPlainContainer = (value) => {
1331
+ if (Array.isArray(value)) return true;
1332
+ const proto = Object.getPrototypeOf(value);
1333
+ return proto === Object.prototype || proto === null;
1334
+ };
1335
+
1201
1336
  /**
1202
1337
  * Copies what `structuredClone` refused. Arrays and plain objects are rebuilt and everything inside them is
1203
1338
  * 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.
1339
+ * kept as they are. A context holding a logger is the usual case.
1206
1340
  *
1207
1341
  * @param {any} value
1208
1342
  * @param {Map<object, any>} seen - Copies made so far, so a cycle is copied as a cycle
1209
1343
  * @returns {any}
1210
1344
  */
1211
1345
  const copyAround = (value, seen) => {
1212
- if (value === null || typeof value !== 'object') return value;
1346
+ if (!isObject(value)) return value;
1213
1347
  if (seen.has(value)) return seen.get(value);
1214
- const proto = Object.getPrototypeOf(value);
1215
- if (!Array.isArray(value) && proto !== Object.prototype && proto !== null) {
1348
+ if (!isPlainContainer(value)) {
1216
1349
  try {
1217
1350
  return structuredClone(value);
1218
1351
  } catch {
@@ -1220,26 +1353,23 @@ const copyAround = (value, seen) => {
1220
1353
  }
1221
1354
  }
1222
1355
  /** @type {any} */
1223
- const copy = Array.isArray(value) ? [] : Object.create(proto);
1356
+ const copy = Array.isArray(value) ? [] : Object.create(Object.getPrototypeOf(value));
1224
1357
  seen.set(value, copy);
1225
1358
  for (const key of Object.keys(value)) copy[key] = copyAround(value[key], seen);
1226
1359
  return copy;
1227
1360
  };
1228
1361
 
1229
1362
  /**
1230
- * Snapshots a value on its way into a trace, and on its way out of one in a replay.
1231
- *
1232
- * Commands return live objects, and a later step that mutates one would otherwise rewrite what the
1233
- * trace says an earlier step returned: `toTrace` runs after the flow finishes, so by serialization
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.
1363
+ * Snapshots a value on its way into a trace, and on its way out of one in a replay, so a later mutation
1364
+ * cannot rewrite what the trace says a step returned. The copy is also what `redact` is handed, so it has
1365
+ * to be one the flow never sees. A value that cannot be cloned whole is copied around the parts that
1366
+ * cannot be copied.
1237
1367
  *
1238
1368
  * @param {any} value
1239
1369
  * @returns {any}
1240
1370
  */
1241
1371
  const snapshot = (value) => {
1242
- if (value === null || typeof value !== 'object') return value;
1372
+ if (!isObject(value)) return value;
1243
1373
  try {
1244
1374
  return typeof structuredClone === 'function' ? structuredClone(value) : JSON.parse(JSON.stringify(value));
1245
1375
  } catch {
@@ -1249,8 +1379,9 @@ const snapshot = (value) => {
1249
1379
 
1250
1380
  /**
1251
1381
  * Builds an `onStep` hook that records what every Command returned, plus a packager
1252
- * for the reference trace format. Pass `onStep` to `runEffect` as per-call config, or
1253
- * to `configureEffect` to record globally.
1382
+ * for the reference trace format. Pass `onStep` to `runEffect` as per-call config. A recorder holds the steps
1383
+ * of every run it sees, so installed with `configureEffect` for a whole application it mixes requests into one
1384
+ * trace with duplicate paths, which a replay refuses; examples/recording-example.js gives each run its own.
1254
1385
  *
1255
1386
  * @param {RecorderOptions} [options] - Redaction and size limits
1256
1387
  * @returns {{ onStep: StepRunner, entries: TraceEntry[], toTrace: (meta?: TraceMeta) => TraceLog }}
@@ -1266,9 +1397,7 @@ const recorder = (options = {}) => {
1266
1397
  else dropped++;
1267
1398
  };
1268
1399
 
1269
- /**
1270
- * `redact` is the caller's code.
1271
- */
1400
+ // `redact` is the caller's code, and a throw from it must not reach the run.
1272
1401
  const safeRedact = (/** @type {any} */ value, /** @type {string} */ name, /** @type {string} */ kind) => {
1273
1402
  try {
1274
1403
  return redact(value, name, kind);
@@ -1284,14 +1413,10 @@ const recorder = (options = {}) => {
1284
1413
  const redactField = (/** @type {any} */ value, /** @type {string} */ kind) =>
1285
1414
  value === undefined ? undefined : safeRedact(snapshot(value), kind, kind);
1286
1415
 
1287
- // Built on `observeSteps` so the hook contract lives in one place: `op` always runs, its result is
1288
- // always returned, and its error always propagates.
1289
1416
  const onStep = observeSteps(({ name, type, path }) => (end) => {
1290
1417
  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.
1418
+ // A Parallel's result is its decision, which holds no user data and must survive intact for a replay
1419
+ // to reproduce it, so it is not redacted.
1295
1420
  const recorded = (/** @type {any} */ result) =>
1296
1421
  type === 'Parallel' ? snapshot(result) : safeRedact(snapshot(result), name, 'result');
1297
1422
  push(
@@ -1327,8 +1452,8 @@ const recorder = (options = {}) => {
1327
1452
 
1328
1453
  /**
1329
1454
  * Runs a flow for real while recording every Command result, and returns both the
1330
- * outcome and a replayable trace. Convenient in tests and scripts; in an application,
1331
- * install `recorder().onStep` once via `configureEffect` instead of changing call sites.
1455
+ * outcome and a replayable trace. Convenient in tests and scripts; to record an application without
1456
+ * changing call sites, give each run its own recorder, as examples/recording-example.js does.
1332
1457
  *
1333
1458
  * @param {(input: any) => Effect} flowFn - Builds the Effect tree from its input
1334
1459
  * @param {any} initialInput - The value the flow is called with; stored so a replay can rebuild it
@@ -1339,35 +1464,46 @@ const recorder = (options = {}) => {
1339
1464
  const recordEffect = async (flowFn, initialInput, options = {}) => {
1340
1465
  const { context = {}, version, ...recorderOptions } = options;
1341
1466
  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.
1467
+ // Packaged before the run, so a Command that writes to the input or the context, as an ORM save
1468
+ // assigning an id does, cannot rewrite what the trace says the run received.
1345
1469
  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.
1470
+ // Merged over the global wiring, so recording inside an instrumented application keeps its spans.
1348
1471
  const result = await runEffect(flowFn(initialInput), context, { onStep: rec.onStep });
1349
1472
  const { dropped, trace } = rec.toTrace();
1350
1473
  return { result, trace: { ...head, dropped, trace } };
1351
1474
  };
1352
1475
 
1353
1476
  /**
1354
- * Turns a recorded entry into the outcome a Resolver must return.
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.
1477
+ * Whether a recorded entry says its step threw: `threw`, or an `error` key, which is how older and
1478
+ * hand-written traces say it.
1479
+ * @param {TraceEntry} entry
1480
+ * @returns {boolean}
1481
+ */
1482
+ const entryThrew = (entry) => entry.threw === true || 'error' in entry;
1483
+
1484
+ /**
1485
+ * Whether a trace entry or a replay step carries a path.
1486
+ * @param {{ path?: string }} step
1487
+ * @returns {boolean}
1488
+ */
1489
+ const hasPath = (step) => typeof step.path === 'string';
1490
+
1491
+ /**
1492
+ * Whether a path lies inside a Parallel branch, which a `p` followed by the branch number marks.
1493
+ * @param {string | undefined} path
1494
+ * @returns {boolean}
1495
+ */
1496
+ const isInsideParallel = (path) => typeof path === 'string' && /p\d+\//.test(path);
1497
+
1498
+ /**
1499
+ * Turns a recorded entry into the outcome a Resolver must return, snapshotted so a replayed step that
1500
+ * mutates its result cannot rewrite the trace.
1363
1501
  *
1364
1502
  * @param {TraceEntry} entry
1365
1503
  * @returns {ReplayOutcome}
1366
1504
  */
1367
1505
  const entryToOutcome = (entry) =>
1368
- entry.threw === true || 'error' in entry
1369
- ? { error: reviveError(snapshot(entry.error)) }
1370
- : { result: snapshot(entry.result) };
1506
+ entryThrew(entry) ? { error: reviveError(snapshot(entry.error)) } : { result: snapshot(entry.result) };
1371
1507
 
1372
1508
  /**
1373
1509
  * Whether an entry is a Parallel's recorded decision rather than a Command's result. A Parallel's path
@@ -1380,24 +1516,16 @@ const isDecisionEntry = (entry) =>
1380
1516
  entry.command === 'Parallel' && typeof entry.path === 'string' && entry.path.endsWith('p');
1381
1517
 
1382
1518
  /**
1383
- * Builds a Resolver for the reference trace format. Internal: `replayEffect` calls this
1384
- * when handed a trace instead of a Resolver, and is the only caller. Callers with traces
1385
- * in some other shape write a Resolver instead, which is the extension point.
1386
- *
1387
- * Path matching is used whenever every entry carries a `path`, which is every trace this
1388
- * version records. A path is the Command's position in the Effect tree, so it is order-independent
1389
- * and still paradox-detecting: it works for `Parallel` as it is, and it tells two branches that
1390
- * call the same Command apart by where they are rather than by which finished first.
1519
+ * Builds a Resolver for the reference trace format. Internal: `replayEffect` is the only caller, and a
1520
+ * caller with traces in another shape writes a Resolver instead.
1391
1521
  *
1392
- * A trace whose entries carry no path (recorded before paths existed, or written by hand) is
1393
- * matched positionally: entries are consumed in recorded order and each must name the Command the
1394
- * flow asks for. That is exact for sequential flows and cannot tell `Parallel` branches apart, which
1395
- * is what paths were added to fix.
1522
+ * When every entry carries a `path`, steps are matched by path, which is order-independent and still
1523
+ * detects a paradox: two `Parallel` branches calling the same Command are told apart by position. A
1524
+ * trace without paths is matched positionally, each entry checked against the Command the flow asks
1525
+ * for, which is exact for sequential flows and cannot tell `Parallel` branches apart.
1396
1526
  *
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.
1527
+ * A step the trace does not hold resolves to `undefined`, as from a Resolver, so `replayEffect` applies
1528
+ * `onMissing` to both; `missing` describes the step for the error.
1401
1529
  *
1402
1530
  * @param {TraceLog | TraceEntry[]} traceLog - A reference-format trace, or a bare array of entries
1403
1531
  * @param {Object} [options]
@@ -1414,11 +1542,10 @@ const fromTrace = (traceLog, options = {}) => {
1414
1542
  return entryToOutcome(entry);
1415
1543
  };
1416
1544
 
1417
- if (entries.length > 0 && entries.every((e) => typeof e.path === 'string')) {
1545
+ if (entries.length > 0 && entries.every(hasPath)) {
1418
1546
  const byPath = new Map(entries.map((e) => [e.path, e]));
1419
- // Paths are derived from tree position, so they are unique by construction. A collision means
1420
- // either a hand-built trace or a bug in path derivation, and silently keeping the last entry
1421
- // would reintroduce exactly the wrong-result-per-branch failure paths exist to prevent.
1547
+ // Paths are unique by construction, so a collision means a hand-built trace or a bug, and keeping
1548
+ // either entry would hand a branch the wrong result.
1422
1549
  if (byPath.size !== entries.length) {
1423
1550
  throw replayError('Trace has duplicate step paths.');
1424
1551
  }
@@ -1437,8 +1564,7 @@ const fromTrace = (traceLog, options = {}) => {
1437
1564
  const resolve = (step) => {
1438
1565
  const entry = byPath.get(step.path);
1439
1566
  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.
1567
+ // A decision rather than I/O. With none recorded, the Parallel replays under timing.
1442
1568
  if (entry && entry.command !== 'Parallel') throw timeParadox(step, entry.command);
1443
1569
  if (entry) return resolveEntry(entry);
1444
1570
  if (stoppedInProduction(step.path)) throw replayCutError(/** @type {string} */ (step.path));
@@ -1461,10 +1587,9 @@ const fromTrace = (traceLog, options = {}) => {
1461
1587
  const resolve = (step) => {
1462
1588
  // A trace with no paths predates recorded decisions, so a Parallel replays under timing.
1463
1589
  if (step.type === 'Parallel') return undefined;
1464
- // Positional matching pairs steps by completion order, which is exactly what makes Parallel
1465
- // branches indistinguishable. Refusing is better than a result that is right only when the
1466
- // replay happens to finish in production's order.
1467
- if (typeof step.path === 'string' && /p\d+\//.test(step.path)) {
1590
+ // Positional matching pairs steps by completion order, which cannot tell Parallel branches apart.
1591
+ // Refusing beats a result that is right only when the replay finishes in production's order.
1592
+ if (isInsideParallel(step.path)) {
1468
1593
  throw replayError(
1469
1594
  `Trace carries no paths, so '${step.name}' inside a Parallel cannot be matched positionally.`,
1470
1595
  { command: step.name, path: step.path }
@@ -1479,58 +1604,15 @@ const fromTrace = (traceLog, options = {}) => {
1479
1604
  return { resolve, missing };
1480
1605
  };
1481
1606
 
1482
- /**
1483
- * Rewrites `Retry` nodes to zero delay, lazily, through their `next` continuations.
1484
- *
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.
1488
- *
1489
- * @param {any} eff - Any Effect node
1490
- * @returns {any} The same tree with Retry delays removed
1491
- */
1492
- const zeroRetryDelays = (eff) => {
1493
- if (!eff || typeof eff.type !== 'string') return eff;
1494
- switch (eff.type) {
1495
- case 'Retry': {
1496
- const options = { ...eff.options, delay: 0, backoff: 1 };
1497
- // The fallback tree only exists once onExhausted runs, so it is rewritten lazily too;
1498
- // otherwise a Retry inside the fallback keeps its production backoff during replay.
1499
- if (typeof options.onExhausted === 'function') {
1500
- const original = options.onExhausted;
1501
- options.onExhausted = (/** @type {any} */ error) => zeroRetryDelays(original(error));
1502
- }
1503
- return {
1504
- ...eff,
1505
- options,
1506
- effect: zeroRetryDelays(eff.effect),
1507
- next: (/** @type {any} */ value) => zeroRetryDelays(eff.next(value))
1508
- };
1509
- }
1510
- case 'Command':
1511
- return { ...eff, next: (/** @type {any} */ result) => zeroRetryDelays(eff.next(result)) };
1512
- case 'Ask':
1513
- return { ...eff, next: (/** @type {any} */ ctx) => zeroRetryDelays(eff.next(ctx)) };
1514
- case 'Parallel':
1515
- return {
1516
- ...eff,
1517
- effects: eff.effects.map(zeroRetryDelays),
1518
- next: (/** @type {any} */ values) => zeroRetryDelays(eff.next(values))
1519
- };
1520
- default:
1521
- return eff;
1522
- }
1523
- };
1524
-
1525
1607
  /**
1526
1608
  * @typedef {Object} ReplayOptions
1527
- * @property {any} [context] - Context for `Ask`; pass the recorded context to reproduce a run faithfully.
1528
- * @property {boolean} [fastRetry] - Strips Retry delays so a replay does not wait out production backoff.
1609
+ * @property {any} [context] - Context for `Ask`; defaults to the context a trace recorded.
1610
+ * @property {boolean} [fastRetry] - Waits no time between Retry attempts, so a replay does not wait out
1611
+ * production backoff. On by default.
1529
1612
  * @property {boolean} [hooks] - Runs the replay inside the hooks `configureEffect` installed, with the
1530
1613
  * resolver innermost, so a configured `onStep` observes each replayed step and `onRun` and
1531
1614
  * `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.
1615
+ * reach a telemetry backend or a guardrail that performs I/O.
1534
1616
  * @property {'throw' | 'execute'} [onMissing] - What to do when the resolver has no recording for a step.
1535
1617
  * `'throw'` (default) fails the replay, which makes side effects impossible for the whole run.
1536
1618
  * `'execute'` runs the real Command, giving partial replay: recorded prefix, live tail.
@@ -1545,8 +1627,7 @@ const zeroRetryDelays = (eff) => {
1545
1627
  * A flow that stops issuing Commands before its recording ends mismatches nothing, so no
1546
1628
  * `TimeParadox` fires and `result` can be a `Success` with recorded steps left over. That is
1547
1629
  * sometimes the point of a fix and sometimes a fix that quietly dropped a step; `unreached` is
1548
- * the only place the difference shows, which is why it travels with the result rather than
1549
- * behind an option.
1630
+ * the only place the difference shows.
1550
1631
  * @typedef {{ result: SuccessState | FailureState, unreached?: TraceEntry[] }} Replay
1551
1632
  */
1552
1633
 
@@ -1569,8 +1650,7 @@ const zeroRetryDelays = (eff) => {
1569
1650
  * @param {ReplayOptions} [options]
1570
1651
  * @returns {Promise<Replay>} `{ result, unreached }` for a trace, `{ result }` for a Resolver
1571
1652
  */
1572
- // `async` so a malformed trace arrives as a rejection rather than a synchronous throw:
1573
- // the function otherwise returns a promise, and callers should not have to handle both.
1653
+ // `async` so a malformed trace arrives as a rejection rather than a synchronous throw.
1574
1654
  const replayEffect = async (effect, traceOrResolver, options = {}) => {
1575
1655
  const { fastRetry = true, hooks = false, onMissing = 'throw', onResolved } = options;
1576
1656
  // A trace is data and a Resolver is a function, so nothing else is needed to tell them
@@ -1581,16 +1661,14 @@ const replayEffect = async (effect, traceOrResolver, options = {}) => {
1581
1661
  const { resolve, missing } = fromResolver
1582
1662
  ? { resolve: traceOrResolver, missing: undefined }
1583
1663
  : 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.
1664
+ // Defaults to the trace's context: an `Ask` gate replayed with another one takes another branch, and
1665
+ // no paradox flags it.
1587
1666
  const recorded = fromResolver || Array.isArray(traceOrResolver) ? undefined : traceOrResolver.context;
1588
1667
  const context = options.context ?? recorded ?? {};
1589
1668
  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.
1669
+ // What `onResolved` threw. It stops the replay, which rejects with it, rather than counting as the
1670
+ // Command failing, which would let a Retry ask for an attempt production never made. Cast rather than
1671
+ // annotated, since only the step runner assigns it.
1594
1672
  let observerFailure = /** @type {{ error: unknown } | undefined} */ (undefined);
1595
1673
 
1596
1674
  /** @type {StepRunner} */
@@ -1614,9 +1692,13 @@ const replayEffect = async (effect, traceOrResolver, options = {}) => {
1614
1692
  if (outcome === undefined) {
1615
1693
  if (onMissing !== 'execute') {
1616
1694
  const what = missing ? missing(step) : `No recorded outcome for '${name}' at step ${step.index}`;
1695
+ // The option is named with its one safe use, since a step with no entry may be one production
1696
+ // never ran, and running it live would do I/O production refused.
1617
1697
  throw replayError(
1618
- `${what}; refusing to run the real Command. ` +
1619
- `Pass onMissing: 'execute' to allow live I/O for unrecorded steps.`,
1698
+ `${what}; refusing to run the real Command. Production may never have run it: a Command ` +
1699
+ 'an onBeforeCommand hook vetoed leaves no entry, and neither does a step added since the ' +
1700
+ "recording. Pass onMissing: 'execute' to run unrecorded steps live only when the trace was " +
1701
+ 'cut short, as by maxEntries.',
1620
1702
  { command: name, index: step.index, path }
1621
1703
  );
1622
1704
  }
@@ -1626,25 +1708,19 @@ const replayEffect = async (effect, traceOrResolver, options = {}) => {
1626
1708
  return outcome.result;
1627
1709
  };
1628
1710
 
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.
1711
+ // With `hooks` off the replay ignores the global wiring, so it reaches no telemetry backend or
1712
+ // guardrail. On, the resolver sits innermost and a configured onStep observes each replayed step.
1634
1713
  /** @type {CallConfiguration} */
1635
1714
  const callConfig = { onStep, inherit: hooks };
1636
1715
 
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.
1716
+ // The interpreter rethrows a replay fault so nothing in the flow can absorb it; it becomes a Failure
1717
+ // here, where nothing downstream can.
1642
1718
  let result;
1643
1719
  try {
1644
- result = await runEffect(fastRetry ? zeroRetryDelays(effect) : effect, context, callConfig);
1720
+ result = await interpret(effect, context, callConfig, fastRetry);
1645
1721
  } catch (e) {
1646
1722
  if (observerFailure) throw observerFailure.error;
1647
- if (!(e && /** @type {any} */ (e)[replayFault])) throw e;
1723
+ if (!hasMark(e, replayFault)) throw e;
1648
1724
  result = Failure(e, effect.initialInput);
1649
1725
  }
1650
1726
  if (fromResolver) return { result };
@@ -1670,9 +1746,8 @@ const replayEffect = async (effect, traceOrResolver, options = {}) => {
1670
1746
  const timeTravel = async (flowFn, traceLog, options = {}) => {
1671
1747
  const { log = console.log, context, version } = options;
1672
1748
  const { initialInput, trace, flowName, version: traceVersion } = traceLog;
1673
- // `message` and `stack` are non-enumerable on Error, so JSON.stringify alone would
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.
1749
+ // `message` is non-enumerable on Error, so JSON.stringify alone would drop it. JSON.stringify throws on
1750
+ // a BigInt and on a cycle, and narration must not be what fails a replay.
1676
1751
  const format = (/** @type {any} */ v) => {
1677
1752
  try {
1678
1753
  return JSON.stringify(v instanceof Error ? { ...v, name: v.name, message: v.message } : v, null, 2);
@@ -1684,19 +1759,23 @@ const timeTravel = async (flowFn, traceLog, options = {}) => {
1684
1759
  if (version && traceVersion && version !== traceVersion) {
1685
1760
  log(`Warning: trace was recorded at ${traceVersion}, replaying against ${version}.`);
1686
1761
  }
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.
1762
+ // The flow is rebuilt from this input, and a recorder installed as a hook reads it off the flow, where only
1763
+ // effectPipe puts it.
1764
+ if (initialInput === undefined) {
1765
+ log(
1766
+ 'Warning: the trace holds no initial input, so the flow is rebuilt from undefined. A recorder ' +
1767
+ 'installed as a hook finds the input only on a flow built with effectPipe.'
1768
+ );
1769
+ }
1770
+ // Parallel decisions are not narrated as steps, so the header counts Commands to match the lines below.
1689
1771
  const commandCount = trace.filter((e) => !isDecisionEntry(e)).length;
1690
1772
  const stepsText = commandCount === 1 ? 'step' : 'steps';
1691
1773
  log(`Replaying '${flowName || 'flow'}' (${commandCount} recorded ${stepsText})`);
1692
1774
  log(`Initial input: ${format(initialInput)}`);
1693
1775
 
1694
- // Narration goes through `onResolved` rather than a wrapped Resolver: observing each
1695
- // step is all this needs, and `replayEffect` already resolves the trace itself.
1696
- // A path maps a replayed step back to its recorded entry whatever order branches finished in, so
1697
- // timings are narrated for Parallel too. Positional lookup is the fallback for a trace with no paths,
1698
- // which is how such a trace is matched anyway.
1699
- const byPath = new Map(trace.filter((e) => typeof e.path === 'string').map((e) => [e.path, e]));
1776
+ // Timings are looked up by path, whatever order branches finished in, or by position for a trace
1777
+ // with no paths, which is how such a trace is matched anyway.
1778
+ const byPath = new Map(trace.filter(hasPath).map((e) => [e.path, e]));
1700
1779
  const timing = (/** @type {ReplayStep} */ step) => {
1701
1780
  const recorded = byPath.get(step.path) ?? trace[step.index];
1702
1781
  return typeof recorded?.durationMs === 'number' ? ` in ${recorded.durationMs}ms` : '';
@@ -1717,15 +1796,12 @@ const timeTravel = async (flowFn, traceLog, options = {}) => {
1717
1796
 
1718
1797
  log(`Replay finished with state: ${result.type}`);
1719
1798
  log(result.type === 'Failure' ? `Error: ${format(result.error)}` : `Result: ${format(result.value)}`);
1720
- // After a replay error the flow never got past the divergence, so the entries behind it are not
1721
- // news; the error already names where the timeline split. Only a flow that ended on its own terms
1722
- // with steps left over is worth a warning.
1799
+ // After a replay error the flow never got past the divergence, and the error already names where it
1800
+ // split. Only a flow that ended on its own terms with steps left over is worth a warning.
1723
1801
  const haltedByReplay = result.type === 'Failure' && /^(TimeParadox|ReplayError)$/.test(result.error?.name);
1724
1802
  if (unreached.length > 0 && !haltedByReplay) {
1725
1803
  // Named, not just counted: the step a fix stopped issuing is usually the one under suspicion.
1726
- const names = unreached.map((e) =>
1727
- typeof e.path === 'string' ? `${e.command} (path '${e.path}')` : e.command
1728
- );
1804
+ const names = unreached.map((e) => (hasPath(e) ? `${e.command} (path '${e.path}')` : e.command));
1729
1805
  const count = unreached.length === 1 ? '1 recorded step was' : `${unreached.length} recorded steps were`;
1730
1806
  log(`Warning: ${count} never reached: ${names.join(', ')}. The flow diverged.`);
1731
1807
  }