pure-effect 0.13.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 (5) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/README.md +296 -124
  3. package/index.d.ts +1118 -134
  4. package/index.js +1029 -462
  5. package/package.json +3 -1
package/index.js CHANGED
@@ -34,11 +34,18 @@
34
34
  * }} RetryState
35
35
  */
36
36
 
37
+ /**
38
+ * `settled` hands branch outcomes to `next` instead of failing on the first one; `limit` caps how many
39
+ * branches run at once.
40
+ * @typedef {{ limit?: number, settled?: boolean }} ParallelOptions
41
+ */
42
+
37
43
  /**
38
44
  * @typedef {{
39
45
  * type: 'Parallel',
40
46
  * effects: Effect[],
41
47
  * next: (values: any[]) => Effect,
48
+ * options?: ParallelOptions,
42
49
  * initialInput?: any
43
50
  * }} ParallelState
44
51
  */
@@ -72,36 +79,45 @@ const Failure = (error, initialInput) => ({
72
79
  *
73
80
  * @param {(signal?: AbortSignal) => Promise<any>|any} cmd - The side-effect function to execute. Inside a
74
81
  * `Parallel` branch it receives an `AbortSignal` that fires when a sibling branch fails, so I/O that
75
- * accepts one can be cancelled in flight. Ignoring it is fine: the interpreter still refuses to start
76
- * any later Command in a cancelled branch. Outside a `Parallel` no argument is passed.
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()`.
77
84
  * @param {(result: any) => Effect} [next] - Receives the result of `cmd` and returns the next Effect.
78
- * Defaults to `(result) => Success(result)`, which is what most Commands want.
85
+ * Defaults to `(result) => Success(result)`, which is what most Commands want; `null` counts as omitted.
79
86
  * @param {CommandMeta} [meta] - Optional metadata, passed to `onBeforeCommand`. A string `meta.name`
80
- * becomes this Command's identity for traces, replay matching, and telemetry spans, which makes
81
- * the identity independent of how `cmd` was declared and immune to minification.
87
+ * becomes this Command's identity for traces, replay matching, and telemetry spans.
82
88
  * @returns {CommandState}
83
89
  */
84
- const Command = (cmd, next = (/** @type {any} */ result) => Success(result), meta) => ({
85
- type: 'Command',
86
- cmd,
87
- next,
88
- meta
89
- });
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
+ };
90
110
 
91
111
  /**
92
112
  * The name a Command is known by: what a trace records, what replay matches on, and what a
93
- * telemetry span is called.
94
- *
95
- * `meta.name` wins when it is a non-empty string. Otherwise the identity is the thunk's own `name`,
96
- * so an inline arrow records as 'anonymous' and a minifier that mangles function names silently
97
- * renames every step of every trace. Passing `meta.name` avoids both.
113
+ * telemetry span is called. A non-empty string `meta.name`, else `cmd.name`, else 'anonymous'.
98
114
  *
99
115
  * @param {CommandState} eff
100
116
  * @returns {string}
101
117
  */
102
118
  const commandName = (eff) => {
103
119
  const meta = eff.meta;
104
- const named = meta && typeof meta === 'object' ? meta.name : undefined;
120
+ const named = isObject(meta) ? meta.name : undefined;
105
121
  return typeof named === 'string' && named !== '' ? named : eff.cmd.name || 'anonymous';
106
122
  };
107
123
 
@@ -110,7 +126,12 @@ const commandName = (eff) => {
110
126
  * @param {(context: any) => Effect} next - Receives the context and returns the next Effect
111
127
  * @returns {AskState}
112
128
  */
113
- 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
+ };
114
135
 
115
136
  /**
116
137
  * Wraps an Effect tree with retry-on-failure semantics.
@@ -121,121 +142,253 @@ const Ask = (next) => ({ type: 'Ask', next });
121
142
  * every Command in it is idempotent.
122
143
  *
123
144
  * @param {Effect} effect - The inner Effect tree to retry
124
- * @param {Object} [options] - Per-use retry options; merged over global defaults at runtime
125
- * @param {number} [options.attempts] - Max retries (not counting first try)
126
- * @param {number} [options.delay] - Ms before first retry
127
- * @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
128
151
  * @param {(error: any) => Effect} [options.onExhausted] - Runs a fallback Effect when every attempt has
129
152
  * failed, receiving `{ retryExhausted, lastError, attempts }`. The fallback's success feeds `next`
130
- * exactly as the primary's would have; its failure propagates unwrapped. Per-use only, never a
131
- * 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.
132
155
  * @returns {RetryState}
133
156
  */
134
- const Retry = (effect, options = {}) => ({
135
- type: 'Retry',
136
- effect,
137
- options,
138
- next: (value) => Success(value)
139
- });
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
+ };
140
165
 
141
166
  /**
142
167
  * Runs multiple Effect trees concurrently. The first branch to fail cancels its siblings, and that
143
- * branch's Failure is what the Parallel returns; `next` is skipped. When several branches fail in the
144
- * same tick, the first by array order wins.
168
+ * branch's Failure is what the Parallel returns; `next` is skipped. Which branch fails first depends on
169
+ * timing, so it is recorded, and a replay returns the same one.
145
170
  *
146
- * Cancellation is cooperative and works at two levels. A cancelled branch starts no further Commands,
147
- * which needs nothing from the caller. Stopping the Command already in flight needs its thunk to accept
148
- * 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
149
173
  * signal runs to completion, so a branch's first Command can still write after a sibling has failed.
150
174
  *
175
+ * `settled: true` turns off that first-failure rule: every branch runs to completion and `next`
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.
179
+ *
180
+ * The second argument is the `next` function or the options, whichever it looks like, so
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.
183
+ *
151
184
  * @param {Effect[]} effects - Array of Effect trees to run concurrently
152
- * @param {(values: any[]) => Effect} [next] - Receives array of success values in order, returns next Effect.
153
- * Defaults to `(values) => Success(values)`, same as `Command`'s default.
185
+ * @param {((values: any[]) => Effect) | ParallelOptions | null} [nextOrOptions] - Receives array of success
186
+ * values in order and returns the next Effect, or the options. `next` defaults to
187
+ * `(values) => Success(values)`, same as `Command`'s default.
188
+ * @param {ParallelOptions} [maybeOptions] - Options, when `next` was given or skipped
154
189
  * @returns {ParallelState}
155
190
  */
156
- const Parallel = (effects, next = (/** @type {any[]} */ values) => Success(values)) => ({
157
- type: 'Parallel',
158
- effects,
159
- next
160
- });
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
+ });
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
+ }
209
+ return {
210
+ type: 'Parallel',
211
+ effects,
212
+ next: hasNext ? nextOrOptions : (/** @type {any[]} */ values) => Success(values),
213
+ options: options ?? {}
214
+ };
215
+ };
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);
161
242
 
162
243
  /**
163
244
  * Describes a value for an error message, leading with the mistake it most likely is.
164
245
  * @param {any} value
165
246
  * @returns {string}
166
247
  */
167
- const describeValue = (value) =>
168
- value === undefined
169
- ? 'undefined, which usually means a missing return'
170
- : value === null
171
- ? 'null'
172
- : typeof value === 'function'
173
- ? 'a function, which usually means a flow was passed without being called with its input'
174
- : typeof value === 'object'
175
- ? typeof value.type === 'string'
176
- ? `an object with an unrecognised type '${value.type}'`
177
- : 'a plain object'
178
- : `the ${typeof value} ${JSON.stringify(value)}`;
179
-
180
- /**
181
- * Checks that a value is an Effect, and explains the mistake when it is not.
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`.
277
+ */
278
+ const harnessError = Symbol('pure-effect.harnessError');
279
+
280
+ /**
281
+ * @param {Error} error
282
+ * @returns {Error}
283
+ */
284
+ const asHarnessError = (error) => Object.defineProperty(error, harnessError, { value: true });
285
+
286
+ /**
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.
298
+ * @typedef {{ type: 'IoFault', error: any, initialInput?: any }} IoFaultState
299
+ */
300
+
301
+ /**
302
+ * @param {any} error
303
+ * @param {any} [initialInput]
304
+ * @returns {IoFaultState}
305
+ */
306
+ const IoFault = (error, initialInput) => ({ type: 'IoFault', error, initialInput });
307
+
308
+ /**
309
+ * @param {SuccessState | FailureState | IoFaultState} state
310
+ * @returns {SuccessState | FailureState}
311
+ */
312
+ const asOutcome = (state) => (state.type === 'IoFault' ? Failure(state.error, state.initialInput) : state);
313
+
314
+ /**
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`.
182
318
  *
183
- * `EffectTypeError` is a bug in the flow rather than a domain failure, so it is thrown instead of
184
- * 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.
185
332
  *
186
333
  * @param {any} value
187
334
  * @param {string} source - What produced the value, named where it is known
188
335
  * @returns {Error}
189
336
  */
190
337
  const effectTypeError = (value, source) =>
191
- Object.assign(
192
- new Error(
193
- `${source} returned ${describeValue(value)}. Return Success, Failure, Command, Ask, Retry, or Parallel: ` +
194
- 'a plain value has to be wrapped, as in Success(value).'
195
- ),
196
- { name: 'EffectTypeError' }
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
197
344
  );
198
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);
365
+
199
366
  /**
200
367
  * @param {any} value
201
368
  * @param {string} source
202
369
  * @returns {Effect}
203
370
  */
204
371
  const asEffect = (value, source) => {
205
- if (value && ['Success', 'Failure', 'Command', 'Ask', 'Retry', 'Parallel'].includes(value.type)) return value;
372
+ if (isEffect(value)) return value;
206
373
  throw effectTypeError(value, source);
207
374
  };
208
375
 
209
376
  /**
210
377
  * Connects an Effect to the next function in the pipeline.
211
- * Handles the branching logic for Success, Failure, Command, Ask, and Retry.
378
+ * Handles the branching logic for Success, Failure, Command, Ask, Retry, and Parallel.
212
379
  *
213
380
  * @param {Effect} effect - The current Effect object
214
381
  * @param {(value: any) => Effect} fn - The next function to run if the current effect is a Success
382
+ * @param {any} [initialInput] - The pipeline's starting value, stamped on every node but a Success
215
383
  * @returns {Effect} The composed Effect
216
384
  */
217
- /**
218
- * @param {Effect} effect
219
- * @param {(value: any) => Effect} fn
220
- * @param {any} [initialInput]
221
- * @returns {Effect}
222
- */
223
385
  const chain = (effect, fn, initialInput) => {
224
- // The outermost pipeline wins. A step can return a sub-pipeline whose nodes already carry that
225
- // sub-pipeline's start, and an outer chain reaches every one of them through the wrapped `next`
226
- // continuations, so overwriting here is what makes the whole tree, a Failure from any depth, and
227
- // the root a hook-based recorder reads carry the input of the flow that was actually called. The
228
- // previous rule, stamp only when unset, let a Retry- or Parallel-headed sub-pipeline leave its own
229
- // input on the root, and a trace recorded from it rebuilt the flow from the wrong value. A Success
230
- // is never stamped: nothing reads the input off a terminal value, and leaving it bare is what lets
231
- // `assert.deepEqual(result, Success(v))` hold whatever shape the flow had.
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.
232
388
  const withII = (/** @type {Effect} */ e) =>
233
389
  initialInput !== undefined && e.type !== 'Success' ? { ...e, initialInput } : e;
234
390
 
235
- // A continuation that returned nothing has to be caught before `effect.type` is read. Reading it off
236
- // `undefined` throws a bare TypeError, and since every continuation runs inside the interpreter's try
237
- // block, that TypeError became a domain Failure: the flow bug took the business-error branch and no
238
- // step was named. The `default` arm below never sees it, because the switch itself is what throws.
391
+ // Checked before `effect.type` is read, which would otherwise throw a bare TypeError naming no step.
239
392
  if (effect == null) return asEffect(effect, 'A continuation');
240
393
 
241
394
  switch (effect.type) {
@@ -272,24 +425,30 @@ const chain = (effect, fn, initialInput) => {
272
425
  * @returns {(start: any) => Effect} A function that accepts an initial input and returns the final Effect tree.
273
426
  */
274
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
+ });
275
438
  return (start) => {
276
439
  const chainWithII = (/** @type {Effect} */ eff, /** @type {(v: any) => Effect} */ fn) => chain(eff, fn, start);
277
440
  const tree = fns.reduce(chainWithII, /** @type {Effect} */ (Success(start)));
278
- // One identity pass over the finished tree. `chain` re-stamps a sub-pipeline's nodes only as
279
- // it wraps the continuations that lead to them, and the last step has no later step to do
280
- // that wrapping, so a sub-pipeline returned by the last step would keep its own input on
281
- // everything past its head. The pass wraps those continuations too, so every node reachable
282
- // from here, a Failure at any depth included, carries this pipeline's `start`. `Success` is the
283
- // identity step: it can never return a non-Effect, so no message is ever attributed to it.
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.
284
443
  return chain(tree, Success, start);
285
444
  };
286
445
  };
287
446
 
288
447
  /**
289
- * Wraps one Command execution. `path` identifies the Command's position in the Effect tree rather than
290
- * its position in completion order, so it is the same in a replay as it was in the recorded run even
291
- * when `Parallel` branches finish in a different order. Hooks written before `path` existed take three
292
- * parameters and are unaffected.
448
+ * Wraps one Command execution, or one Parallel: `type` is 'Parallel', and `op` runs the branches and
449
+ * returns the Parallel's decision, even when a branch threw, so a hook must call it. Only a replay passes
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.
293
452
  * @typedef {(name: string, type: string, op: function, path?: string) => Promise<any>} StepRunner
294
453
  */
295
454
  /** @type StepRunner */
@@ -305,22 +464,30 @@ const defaultCommandInterceptor = async (command, context) => {};
305
464
 
306
465
  const defaultRetryOptions = { attempts: 3, delay: 100, backoff: 1 };
307
466
 
467
+ /**
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.
470
+ * @param {any} config
471
+ * @param {string} source
472
+ */
473
+ const rejectRetryKey = (config, source) => {
474
+ if (isObject(config) && 'retry' in config)
475
+ throw new TypeError(
476
+ `${source} no longer takes 'retry'. Retry options are per-use: pass them to Retry(effect, options).`
477
+ );
478
+ };
479
+
308
480
  /**
309
481
  * @typedef {Object} EffectConfiguration
310
- * @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.
311
483
  * @property {RunWrapper} [onRun] - Fires once per runEffect call. It wraps the entire workflow execution.
312
484
  * @property {CommandInterceptor} [onBeforeCommand] - Intercepts a Command and any context passed to runEffect before execution.
313
- * @property {{ attempts?: number, delay?: number, backoff?: number }} [retry] - Global Retry defaults; merged under per-use options.
314
485
  */
315
486
 
316
487
  /**
317
488
  * A per-call configuration: an `EffectConfiguration` plus `inherit`. With `inherit: true` (the default)
318
- * the call's hooks are added to the wiring `configureEffect` installed, by the merge `configureEffect`
319
- * applies to several configurations: global outermost, interceptors in order, `retry` merging with the
320
- * call winning. With `inherit: false` the global wiring is not consulted at all, so any slot the call
321
- * leaves unset falls back to the library default. A per-call hook cannot replace a single global slot on
322
- * its own; that was the previous default, and it is how a per-call recorder silently switched off an
323
- * application's tracing.
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.
324
491
  *
325
492
  * @typedef {EffectConfiguration & { inherit?: boolean }} CallConfiguration
326
493
  */
@@ -329,10 +496,8 @@ const defaultRetryOptions = { attempts: 3, delay: 100, backoff: 1 };
329
496
  let layers = [];
330
497
 
331
498
  /**
332
- * The installed layers merged into one configuration, empty when nothing is configured. This is the
333
- * only derived form of `layers`: `runEffect` reads it and merges the call's configuration over it,
334
- * and a slot no layer defines is simply absent, so the library default is chosen at the one place the
335
- * hook is used rather than held in a second set of variables kept in step by hand.
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.
336
501
  * @type {EffectConfiguration}
337
502
  */
338
503
  let globalConfig = {};
@@ -343,40 +508,34 @@ const applyLayers = () => {
343
508
  };
344
509
 
345
510
  /**
346
- * Adds a configuration to the global wiring of the Effect runner: telemetry, the command interceptor,
347
- * and retry defaults.
511
+ * Adds a configuration to the global wiring of the Effect runner: telemetry and the command
512
+ * interceptor. Retry options are not part of it; they are per-use, passed to `Retry(effect, options)`.
348
513
  *
349
514
  * Each call adds one layer on top of those already installed and returns a function that removes that
350
515
  * layer, wherever it sits by then. Layers merge the way several configurations passed to one call do:
351
516
  * `onStep` and `onRun` are wrappers, so they nest with the earliest layer outermost and the latest
352
- * closest to the Command; `onBeforeCommand` interceptors all run, in the order installed; and `retry`
353
- * merges with later layers winning. So these are the same:
517
+ * closest to the Command; and `onBeforeCommand` interceptors all run, in the order installed. So these
518
+ * are the same:
354
519
  *
355
520
  * configureEffect(telemetryHooks(), recordingHooks({ sink }));
356
521
  * configureEffect(telemetryHooks()); configureEffect(recordingHooks({ sink }));
357
522
  *
358
- * Calling it with no arguments at all removes every layer. A call whose arguments are all `undefined`
359
- * is a conditional install that installed nothing: it adds no layer and removes none.
360
- *
361
- * Layers replaced the previous one-slot rule, under which a later call displaced the earlier wiring and
362
- * the returned function restored a snapshot, guarded so that interleaved installs could not clobber
363
- * each other. Removing a layer needs no guard: A installs, B installs, A removes, and B is still there.
364
- * It also means a library can install its own hooks without erasing its host's, which under the old rule
365
- * was possible only if the host passed the library's configuration into its own call.
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.
366
526
  *
367
527
  * @param {...(EffectConfiguration | undefined)} configs - Configurations merged into one layer, outermost first
368
528
  * @returns {() => void} Removes the layer this call added; a second call does nothing
369
529
  */
370
530
  const configureEffect = (...configs) => {
371
- // Only a call with no arguments at all is a reset. A call whose arguments are all absent, such as
372
- // `configureEffect(flag ? hooks : undefined)`, is a conditional install that happened not to install
373
- // anything, so it adds no layer and must not remove anyone else's.
374
531
  if (configs.length === 0) {
375
532
  layers = [];
376
533
  applyLayers();
377
534
  return () => {};
378
535
  }
379
536
  const present = configs.filter(Boolean);
537
+ // Before installing anything, so a refused call leaves the wiring untouched.
538
+ present.forEach((config) => rejectRetryKey(config, 'configureEffect'));
380
539
  if (present.length === 0) return () => {};
381
540
  const layer = chainHooks(...present);
382
541
  layers = [...layers, layer];
@@ -390,8 +549,8 @@ const configureEffect = (...configs) => {
390
549
 
391
550
  /**
392
551
  * @typedef {Object} StepStart
393
- * @property {string} name - `cmd.name`, or 'anonymous'.
394
- * @property {string} type - Always 'Command' today.
552
+ * @property {string} name - The Command's identity: `meta.name`, else `cmd.name`, else 'anonymous'.
553
+ * @property {string} type - 'Command', or 'Parallel' for a Parallel's decision.
395
554
  * @property {string} [path] - The Command's position in the Effect tree.
396
555
  */
397
556
 
@@ -408,7 +567,8 @@ const configureEffect = (...configs) => {
408
567
  const now = () => (typeof performance === 'object' ? performance.now() : Date.now());
409
568
 
410
569
  /**
411
- * 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.
412
572
  *
413
573
  * @param {(start: StepStart) => (end: StepEnd) => void} handler - Returns a finisher for the outcome
414
574
  * @returns {StepRunner}
@@ -443,12 +603,10 @@ const observeSteps = (handler) => async (name, type, op, path) => {
443
603
  /**
444
604
  * Merges several configurations into one, so independent concerns can share the hooks.
445
605
  *
446
- * `onStep` and `onRun` are wrappers around an `op`, so they nest. The first config given is
447
- * the outermost wrapper and the last sits closest to the Command, which also means a thrown
448
- * Command unwinds from the last config back to the first. `onBeforeCommand` is an observer,
449
- * so every interceptor runs in the order given. `retry` is plain data and merges with later
450
- * configs winning. A hook no config defines is left unset, so `configureEffect` keeps its
451
- * default for that slot.
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.
452
610
  *
453
611
  * @param {...(EffectConfiguration | undefined)} configs - Configurations to merge, outermost first
454
612
  * @returns {EffectConfiguration}
@@ -465,7 +623,6 @@ const chainHooks = (...configs) => {
465
623
  const steps = /** @type {StepRunner[]} */ (present('onStep'));
466
624
  const runs = /** @type {RunWrapper[]} */ (present('onRun'));
467
625
  const interceptors = /** @type {CommandInterceptor[]} */ (present('onBeforeCommand'));
468
- const retries = present('retry');
469
626
 
470
627
  if (steps.length) {
471
628
  merged.onStep = steps.reduceRight(
@@ -482,27 +639,115 @@ const chainHooks = (...configs) => {
482
639
  for (const intercept of interceptors) await intercept(command, context);
483
640
  };
484
641
  }
485
- if (retries.length) merged.retry = Object.assign({}, ...retries);
486
642
  return merged;
487
643
  };
488
644
 
489
645
  /**
490
- * The Failure a `Parallel` branch resolves to when a sibling branch has already failed.
491
- *
492
- * Branches are cancelled, not merely ignored: this is what `Parallel` returns for a branch that was
493
- * still in flight, and it is deliberately distinguishable so it can be told apart from the genuine
494
- * failure that triggered the cancellation.
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}
650
+ */
651
+ const cancelledBranch = (initialInput) =>
652
+ Failure(Object.assign(new Error('Parallel branch cancelled.'), { name: 'ParallelCancelled' }), initialInput);
653
+
654
+ /**
655
+ * Which branch, if any, cancelled a Parallel. `branch: null` means an enclosing Parallel cancelled it.
656
+ * It is recorded as the Parallel's own step, since it is decided by timing and a replay cannot recompute it.
657
+ * @typedef {{ cancelled: false } | { cancelled: true, branch: number | null }} ParallelDecision
658
+ */
659
+
660
+ /**
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.
680
+ * @param {any} value
681
+ * @param {number} branches - How many branches the Parallel has, so a stale branch index is refused too
682
+ * @returns {ParallelDecision | undefined}
683
+ */
684
+ const asDecision = (value, branches) => {
685
+ if (!isObject(value)) return undefined;
686
+ if (value.cancelled === false) return { cancelled: false };
687
+ if (value.cancelled !== true) return undefined;
688
+ const { branch } = value;
689
+ if (branch === null) return { cancelled: true, branch: null };
690
+ return isBranchIndex(branch, branches) ? { cancelled: true, branch } : undefined;
691
+ };
692
+
693
+ /**
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.
696
+ */
697
+ const replayCut = Symbol('pure-effect.replayCut');
698
+
699
+ /**
700
+ * @param {string} path
495
701
  * @returns {Error}
496
702
  */
497
- const parallelCancelled = () =>
498
- Object.assign(new Error('Parallel branch cancelled.'), {
499
- name: 'ParallelCancelled'
500
- });
703
+ const replayCutError = (path) =>
704
+ Object.defineProperty(
705
+ asHarnessError(new Error(`Replay stopped a cancelled Parallel branch at path '${path}'.`)),
706
+ replayCut,
707
+ { value: true }
708
+ );
709
+
710
+ /**
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
+ };
501
728
 
502
729
  /**
503
- * Sleeps, but gives up early when the surrounding branch is cancelled, so a sibling's failure does not
504
- * have to wait out a retry backoff that is now pointless. Resolves either way: the caller's abort check
505
- * is what turns a cancelled wait into a Failure.
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.
732
+ * @param {(() => Promise<void>)[]} tasks
733
+ * @param {number} [limit]
734
+ * @returns {Promise<void>}
735
+ */
736
+ const runBounded = async (tasks, limit) => {
737
+ if (!limit || limit >= tasks.length) {
738
+ await Promise.all(tasks.map((task) => task()));
739
+ return;
740
+ }
741
+ let cursor = 0;
742
+ const worker = async () => {
743
+ for (let i = cursor++; i < tasks.length; i = cursor++) await tasks[i]();
744
+ };
745
+ await Promise.all(Array.from({ length: limit }, worker));
746
+ };
747
+
748
+ /**
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.
506
751
  *
507
752
  * @param {number} ms
508
753
  * @param {AbortSignal} [signal]
@@ -514,6 +759,12 @@ const delayFor = (ms, signal) =>
514
759
  setTimeout(resolve, ms);
515
760
  return;
516
761
  }
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.
764
+ if (signal.aborted) {
765
+ resolve();
766
+ return;
767
+ }
517
768
  const onAbort = () => {
518
769
  clearTimeout(timer);
519
770
  resolve();
@@ -525,197 +776,384 @@ const delayFor = (ms, signal) =>
525
776
  signal.addEventListener('abort', onAbort, { once: true });
526
777
  });
527
778
 
528
- const runEffect =
779
+ const interpret =
529
780
  /**
530
- * The Interpreter
531
- * Iterates through the Effect tree, executing Commands and handling async flow.
532
- * Ask effects are resolved synchronously with the context object.
533
- *
534
- * Per-call config is merged over the global configureEffect wiring, or over nothing under
535
- * `inherit: false`. onRun fires exactly once per runEffect call. Retry attempts run inside that
536
- * 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.
537
785
  *
538
- * @param {Effect} effect - The Effect tree returned by a pipeline
539
- * @param {any} [context] - Optional context object. Passed to Ask continuations and the Command Interceptor.
540
- * @param {CallConfiguration} [callConfig] - Per-call configuration, added to the wiring `configureEffect`
541
- * 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]
542
790
  * @returns {Promise<SuccessState | FailureState>}
543
791
  */
544
- async function runEffect(effect, context = {}, callConfig = {}) {
792
+ async function interpret(effect, context = {}, callConfig = {}, fastRetry = false) {
793
+ rejectRetryKey(callConfig, "runEffect's callConfig");
545
794
  const { inherit = true, ...local } = callConfig;
546
- // A value that is not a boolean, such as a string left over from an older API, must not be
547
- // coerced: `'false'` inheriting everything is exactly the silent behaviour this option removes.
795
+ // Not coerced: `'false'` quietly inheriting everything is the behaviour this option exists to remove.
548
796
  if (typeof inherit !== 'boolean') {
549
797
  throw new TypeError(`callConfig.inherit must be true or false, got ${JSON.stringify(inherit)}.`);
550
798
  }
551
- // The call's configuration is merged over the installed wiring, or over nothing when the call
552
- // inherits nothing, by the same merge `configureEffect` applies to several configurations. Most
553
- // calls carry no configuration of their own, and those use the installed wiring as it is.
799
+ // Merged the way `configureEffect` merges layers. A call with no hooks of its own skips the merge.
554
800
  const base = inherit ? globalConfig : {};
555
801
  const resolved = Object.keys(local).length ? chainHooks(base, local) : base;
556
802
  const localStepRunner = resolved.onStep || defaultStepRunner;
557
803
  const localRunWrapper = resolved.onRun || defaultRunWrapper;
558
804
  const localCommandInterceptor = resolved.onBeforeCommand || defaultCommandInterceptor;
559
- const localRetryDefaults = { ...defaultRetryOptions, ...resolved.retry };
560
805
 
561
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
+ *
562
811
  * @param {Effect} eff
563
812
  * @param {AbortSignal} [signal] - Cancellation for this subtree, set for `Parallel` branches.
564
- * A branch stops starting new Commands once it is aborted, and the signal is handed to
565
- * each Command's thunk so I/O that accepts one can be cancelled in flight.
566
- * @param {string} [path] - Prefix identifying this subtree's position in the Effect tree.
567
- * Steps are numbered sequentially within a subtree and each `Parallel` branch and `Retry`
568
- * attempt opens its own prefix, so a Command's full path depends only on the shape of the
569
- * tree and not on the order branches happen to finish in. That is what lets a replay line
570
- * a recorded step up with the step that asked for it.
571
- * @returns {Promise<SuccessState | FailureState>}
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.
816
+ * @returns {Promise<SuccessState | FailureState | IoFaultState>}
572
817
  */
573
818
  async function execute(eff, signal, path = '') {
574
819
  let step = 0;
575
- while (
576
- eff &&
577
- (eff.type === 'Command' || eff.type === 'Ask' || eff.type === 'Retry' || eff.type === 'Parallel')
578
- ) {
579
- // Checked before every node, which is what short-circuits a branch whose Commands
580
- // ignore the signal: the one in flight cannot be stopped, but the next never starts.
581
- 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);
582
823
  if (eff.type === 'Ask') {
583
824
  eff = eff.next(context);
584
825
  continue;
585
826
  }
586
- if (eff.type === 'Retry') {
587
- const opts = { ...localRetryDefaults, ...eff.options };
588
- const { attempts } = opts;
589
- let lastError;
590
- let succeeded = false;
591
- // Each attempt gets its own prefix, so the Commands of attempt 2 cannot be mistaken
592
- // for the Commands of attempt 1 when a trace is matched back up.
593
- const retryPath = `${path}${step++}r`;
594
- // Captured while `eff` is still narrowed to a Retry node: the loop reassigns `eff`
595
- // on success, after which the checker cannot prove the fallback path still holds it.
596
- const retryNext = eff.next;
597
-
598
- for (let attempt = 0; attempt <= attempts; attempt++) {
599
- if (attempt > 0) {
600
- await delayFor(opts.delay * Math.pow(opts.backoff, attempt - 1), signal);
601
- }
602
- // Checked after the wait, so a branch cancelled mid-backoff stops here rather
603
- // than buying one more attempt, and a sibling's failure does not pay for the
604
- // rest of this branch's retry schedule.
605
- if (signal?.aborted) return Failure(parallelCancelled(), eff.initialInput);
606
- const result = await execute(eff.effect, signal, `${retryPath}${attempt}/`);
607
- if (result.type === 'Success') {
608
- eff = retryNext(result.value);
609
- succeeded = true;
610
- break;
611
- }
612
- lastError = result.error;
613
- }
614
-
615
- if (!succeeded) {
616
- const exhausted = { retryExhausted: true, lastError, attempts };
617
- const { onExhausted } = opts;
618
- if (typeof onExhausted !== 'function') return Failure(exhausted, eff.initialInput);
619
- // A cancelled branch must not start its fallback, for the same reason it starts
620
- // no further Commands: recovery must not resurrect work a sibling's failure ended.
621
- if (signal?.aborted) return Failure(parallelCancelled(), eff.initialInput);
622
- // The fallback gets its own path prefix, so a fallback-path trace can never be
623
- // confused with a success-path one when it is matched back up on replay.
624
- const fallback = await execute(
625
- asEffect(onExhausted(exhausted), "Retry option 'onExhausted'"),
626
- signal,
627
- `${retryPath}f/`
628
- );
629
- // A failing fallback propagates as-is: the flow's last word was the fallback's
630
- // error, not the exhaustion it was already told about.
631
- if (fallback.type === 'Failure') return fallback;
632
- eff = retryNext(fallback.value);
633
- }
634
- 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);
635
881
  }
636
- if (eff.type === 'Parallel') {
637
- // Branch prefixes come from the branch's index in the array, never from the order
638
- // branches complete in, which is the whole point: two branches calling the same
639
- // Command are told apart by where they are rather than by who finished first.
640
- const branchPath = `${path}${step++}p`;
641
- // One scope per Parallel, linked to the enclosing one so cancellation nests.
642
- // Absent AbortController (very old runtimes), `branchSignal` stays undefined and
643
- // the old run-everything-to-completion behaviour is what happens.
644
- const scope = typeof AbortController === 'function' ? new AbortController() : undefined;
645
- const branchSignal = scope?.signal;
646
- const relay = () => scope?.abort();
647
- if (signal && scope) {
648
- if (signal.aborted) scope.abort();
649
- else signal.addEventListener('abort', relay, { once: true });
650
- }
651
-
652
- /** @type {(SuccessState | FailureState)[]} */
653
- const results = new Array(eff.effects.length);
654
- // Which branch failed on its own account rather than because it was cancelled.
655
- const triggered = new Array(eff.effects.length).fill(false);
656
- try {
657
- // Still awaits every branch, so no cancelled work is left running unobserved
658
- // after the Failure is returned. Cancelled branches settle promptly; a branch
659
- // whose in-flight Command ignores the signal is the one case that does not.
660
- await Promise.all(
661
- eff.effects.map(async (e, i) => {
662
- const result = await execute(e, branchSignal, `${branchPath}${i}/`);
663
- results[i] = result;
664
- // Read-then-abort is atomic here, so exactly one branch is the trigger.
665
- if (result.type === 'Failure' && !branchSignal?.aborted) {
666
- triggered[i] = true;
667
- scope?.abort();
668
- }
669
- })
670
- );
671
- } finally {
672
- if (signal && scope) signal.removeEventListener('abort', relay);
673
- }
674
-
675
- const trigger = triggered.indexOf(true);
676
- const failure = trigger >= 0 ? results[trigger] : results.find((r) => r.type === 'Failure');
677
- if (failure) return failure;
678
- eff = eff.next(results.map((r) => /** @type {SuccessState} */ (r).value));
679
- 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
+ );
680
999
  }
681
- const cmdName = commandName(eff);
682
- const initialInput = eff.initialInput;
683
- const cmdPath = `${path}${step++}`;
684
- const cmd = eff.cmd;
685
- // The signal reaches the thunk only inside a Parallel, so a thunk written to take a
686
- // parameter is not handed an argument it never expected anywhere else.
687
- const op = signal ? () => cmd(signal) : cmd;
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) => {
688
1039
  try {
689
- await localCommandInterceptor(eff, context);
690
- const result = await localStepRunner(cmdName, 'Command', op, cmdPath);
691
- eff = eff.next(result);
692
- } catch (e) {
693
- // A malformed flow is a bug, not a domain failure, so it must not masquerade as one.
694
- if (e instanceof Error && e.name === 'EffectTypeError') throw e;
695
- return Failure(e, initialInput);
1040
+ results[i] = await execute(branch, scope?.signal, `${branchPath}${i}/`);
1041
+ } catch (error) {
1042
+ thrown[i] = { error };
1043
+ }
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();
696
1048
  }
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();
697
1058
  }
698
- if (eff && (eff.type === 'Success' || eff.type === 'Failure')) return eff;
699
- 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
+ };
700
1066
  }
701
1067
 
702
- // Every Failure leaves through here, including one a Parallel branch or a Retry fallback handed
703
- // back. `chain` stamps only what passes through the continuations it wraps, and those subtrees
704
- // are executed directly rather than reached through a continuation, so their Failures arrive
705
- // carrying the subtree's own input or none. Restamping the outcome with the root's input is
706
- // what makes "a Failure carries the input of the flow that was called" true at any depth.
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);
1126
+ }
1127
+
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.
707
1131
  const rootInput = effect?.initialInput;
708
1132
  const run = async () => {
709
- const result = await execute(effect);
1133
+ const result = asOutcome(await execute(effect));
710
1134
  return result.type === 'Failure' && rootInput !== undefined ? Failure(result.error, rootInput) : result;
711
1135
  };
712
1136
  return localRunWrapper(effect, run, context?.flowName || '');
713
1137
  };
714
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
+
715
1151
  /**
716
1152
  * The step a replay is asking about. `path` is the Command's position in the Effect tree and is stable
717
1153
  * across runs; `index` is its position in this run's completion order, which is not stable for a flow
718
- * containing `Parallel`. Prefer `path` when writing a Resolver.
1154
+ * containing `Parallel`. Prefer `path` when writing a Resolver. A step whose `type` is 'Parallel' asks
1155
+ * for a Parallel's recorded decision; it does not advance `index`, and anything but a decision replays
1156
+ * that Parallel under timing.
719
1157
  * @typedef {{ index: number, name: string, type: string, path?: string }} ReplayStep
720
1158
  */
721
1159
 
@@ -730,11 +1168,12 @@ const runEffect =
730
1168
  /** @typedef {(step: ReplayStep) => ReplayOutcome | undefined} Resolver */
731
1169
 
732
1170
  /**
733
- * A recorded step. `durationMs` is how long the Command took in production, rounded to microseconds,
734
- * which is the one question a trace could not answer before: which step was slow.
735
- * `path` locates the Command in the Effect tree, which is what a replay matches on: it does not move
736
- * when `Parallel` branches finish in a different order than they did in production.
737
- * @typedef {{ command: string, path?: string, result?: any, error?: any, durationMs?: number }} TraceEntry
1171
+ * A recorded step: a Command's result or error, or a Parallel's decision, recorded as `command`
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.
1174
+ * `threw` marks a step that threw. The `error` key alone cannot, since JSON drops it when the value is
1175
+ * `undefined`, as it is for `reject()` with no argument or an error `redact` removed.
1176
+ * @typedef {{ command: string, path?: string, result?: any, threw?: true, error?: any, durationMs?: number }} TraceEntry
738
1177
  */
739
1178
 
740
1179
  /**
@@ -751,13 +1190,27 @@ const runEffect =
751
1190
  */
752
1191
 
753
1192
  /**
754
- * Creates a replay error. Thrown from a resolver, it is caught by the interpreter
755
- * and returned as a Failure, exactly like a rejected Command.
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.
1197
+ */
1198
+ const replayFault = Symbol('pure-effect.replayFault');
1199
+
1200
+ /**
1201
+ * Creates a replay error, which `replayEffect` turns into a Failure at its own boundary.
756
1202
  * @param {string} message - Human-readable reason
757
1203
  * @param {Object} [props] - Extra fields such as `name`, `index`, `expected`, `actual`
758
1204
  * @returns {Error}
759
1205
  */
760
- const replayError = (message, props = {}) => Object.assign(new Error(message), { name: 'ReplayError' }, props);
1206
+ const replayError = (message, props = {}) =>
1207
+ Object.defineProperty(
1208
+ asHarnessError(Object.assign(new Error(message), { name: 'ReplayError' }, props)),
1209
+ replayFault,
1210
+ {
1211
+ value: true
1212
+ }
1213
+ );
761
1214
 
762
1215
  /**
763
1216
  * Signals that the flow being replayed asked for a different Command than the trace
@@ -785,19 +1238,41 @@ const timeParadox = (step, recorded) =>
785
1238
  *
786
1239
  * @param {any} e - The thrown value
787
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
788
1242
  * @returns {any}
789
1243
  */
790
- const serializeError = (e, withStack) => {
1244
+ const serializeError = (e, withStack, ancestors = new Set()) => {
791
1245
  if (!(e instanceof Error)) return e;
792
1246
  /** @type {any} */
793
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);
794
1253
  if (withStack) out.stack = e.stack;
795
1254
  // `cause` is non-enumerable too, and it can itself be an Error, so it is carried recursively.
796
- if ('cause' in e) out.cause = serializeError(e.cause, withStack);
797
- for (const k of Object.keys(e)) out[k] = /** @type {any} */ (e)[k];
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.
1258
+ const errors = /** @type {any} */ (e).errors;
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));
1262
+ }
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];
798
1270
  return out;
799
1271
  };
800
1272
 
1273
+ /** The keys `serializeError` carries whatever their enumerability, which a native Error sets as hidden. */
1274
+ const carriedKeys = ['name', 'message', 'cause'];
1275
+
801
1276
  /**
802
1277
  * Rebuilds an Error from `serializeError` output. Non-Error values pass through, so a
803
1278
  * Command that rejected with a string still replays as a string.
@@ -805,18 +1280,22 @@ const serializeError = (e, withStack) => {
805
1280
  * @returns {any}
806
1281
  */
807
1282
  const reviveError = (v) => {
808
- if (!v || typeof v !== 'object' || v.__error !== true) return v;
1283
+ if (!isObject(v) || v.__error !== true) return v;
809
1284
  const e = new Error(v.message);
810
- // `name` and `cause` are non-enumerable on a native Error (`name` is inherited, `cause` is set by the
811
- // constructor), so they are defined the same way here. Assigning them would make them own enumerable
812
- // keys, and the revived error would then never compare deep-equal to the one the Command threw, which
813
- // is what the record-then-replay determinism check relies on. Everything else was enumerable on the
814
- // original, since `serializeError` read it through `Object.keys`, so plain assignment is faithful.
815
- const hidden = { enumerable: false, configurable: true, writable: true };
816
- Object.defineProperty(e, 'name', { ...hidden, value: v.name });
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'];
817
1295
  for (const [k, val] of Object.entries(v)) {
818
- if (k === '__error' || k === 'name' || k === 'message') continue;
819
- if (k === 'cause') Object.defineProperty(e, 'cause', { ...hidden, value: reviveError(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));
820
1299
  else /** @type {any} */ (e)[k] = val;
821
1300
  }
822
1301
  return e;
@@ -828,7 +1307,8 @@ const reviveError = (v) => {
828
1307
  * each Command's result, each serialized error, and the `initialInput` and `context` stored on the
829
1308
  * trace itself. `kind` is `'result'`, `'error'`, `'initialInput'`, or `'context'`, and `name` is the
830
1309
  * Command's name for the first two and the kind for the last two. It is the single place PII is kept
831
- * out of a trace, so it has to see all four.
1310
+ * out of a trace, so it has to see all four. It is handed a copy, so changing the value in place
1311
+ * never reaches the run.
832
1312
  * @property {number} [maxEntries] - Caps trace length; further steps are counted in `dropped`, not stored.
833
1313
  * @property {boolean} [stack] - Records stack traces for thrown errors.
834
1314
  */
@@ -842,29 +1322,66 @@ const reviveError = (v) => {
842
1322
  */
843
1323
 
844
1324
  /**
845
- * Snapshots a value on its way into a trace.
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
+
1336
+ /**
1337
+ * Copies what `structuredClone` refused. Arrays and plain objects are rebuilt and everything inside them is
1338
+ * copied in turn, so only the parts that cannot be copied, such as a function or an object holding one, are
1339
+ * kept as they are. A context holding a logger is the usual case.
846
1340
  *
847
- * Commands return live objects, and a later step that mutates one would otherwise rewrite what the
848
- * trace says an earlier step returned: `toTrace` runs after the flow finishes, so by serialization
849
- * time the mutation is already baked in and the trace records a value production never saw. Values
850
- * that cannot be cloned fall back to the reference.
1341
+ * @param {any} value
1342
+ * @param {Map<object, any>} seen - Copies made so far, so a cycle is copied as a cycle
1343
+ * @returns {any}
1344
+ */
1345
+ const copyAround = (value, seen) => {
1346
+ if (!isObject(value)) return value;
1347
+ if (seen.has(value)) return seen.get(value);
1348
+ if (!isPlainContainer(value)) {
1349
+ try {
1350
+ return structuredClone(value);
1351
+ } catch {
1352
+ return value;
1353
+ }
1354
+ }
1355
+ /** @type {any} */
1356
+ const copy = Array.isArray(value) ? [] : Object.create(Object.getPrototypeOf(value));
1357
+ seen.set(value, copy);
1358
+ for (const key of Object.keys(value)) copy[key] = copyAround(value[key], seen);
1359
+ return copy;
1360
+ };
1361
+
1362
+ /**
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.
851
1367
  *
852
1368
  * @param {any} value
853
1369
  * @returns {any}
854
1370
  */
855
1371
  const snapshot = (value) => {
856
- if (value === null || typeof value !== 'object') return value;
1372
+ if (!isObject(value)) return value;
857
1373
  try {
858
1374
  return typeof structuredClone === 'function' ? structuredClone(value) : JSON.parse(JSON.stringify(value));
859
1375
  } catch {
860
- return value;
1376
+ return copyAround(value, new Map());
861
1377
  }
862
1378
  };
863
1379
 
864
1380
  /**
865
1381
  * Builds an `onStep` hook that records what every Command returned, plus a packager
866
- * for the reference trace format. Pass `onStep` to `runEffect` as per-call config, or
867
- * 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.
868
1385
  *
869
1386
  * @param {RecorderOptions} [options] - Redaction and size limits
870
1387
  * @returns {{ onStep: StepRunner, entries: TraceEntry[], toTrace: (meta?: TraceMeta) => TraceLog }}
@@ -880,9 +1397,7 @@ const recorder = (options = {}) => {
880
1397
  else dropped++;
881
1398
  };
882
1399
 
883
- /**
884
- * `redact` is the caller's code.
885
- */
1400
+ // `redact` is the caller's code, and a throw from it must not reach the run.
886
1401
  const safeRedact = (/** @type {any} */ value, /** @type {string} */ name, /** @type {string} */ kind) => {
887
1402
  try {
888
1403
  return redact(value, name, kind);
@@ -892,29 +1407,34 @@ const recorder = (options = {}) => {
892
1407
  };
893
1408
 
894
1409
  /**
895
- * Redacts and snapshots one of the trace's own fields. `undefined` is left alone so a flow with no
1410
+ * Snapshots one of the trace's own fields and redacts the copy. `undefined` is left alone so a flow with no
896
1411
  * context does not acquire an empty object from a redact function that spreads its argument.
897
1412
  */
898
1413
  const redactField = (/** @type {any} */ value, /** @type {string} */ kind) =>
899
- value === undefined ? undefined : snapshot(safeRedact(value, kind, kind));
1414
+ value === undefined ? undefined : safeRedact(snapshot(value), kind, kind);
900
1415
 
901
- // Built on `observe` so the hook contract lives in one place: `op` always runs, its result is
902
- // always returned, and its error always propagates.
903
- const onStep = observeSteps(({ name, path }) => (end) => {
1416
+ const onStep = observeSteps(({ name, type, path }) => (end) => {
904
1417
  const durationMs = Math.round(end.durationMs * 1000) / 1000;
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.
1420
+ const recorded = (/** @type {any} */ result) =>
1421
+ type === 'Parallel' ? snapshot(result) : safeRedact(snapshot(result), name, 'result');
905
1422
  push(
906
1423
  'error' in end
907
1424
  ? {
908
1425
  command: name,
909
1426
  path,
910
- error: snapshot(safeRedact(serializeError(end.error, stack), name, 'error')),
1427
+ threw: true,
1428
+ error: safeRedact(snapshot(serializeError(end.error, stack)), name, 'error'),
911
1429
  durationMs
912
1430
  }
913
- : { command: name, path, result: snapshot(safeRedact(end.result, name, 'result')), durationMs }
1431
+ : { command: name, path, result: recorded(end.result), durationMs }
914
1432
  );
915
1433
  });
916
1434
 
917
1435
  /**
1436
+ * Redacts and copies `meta.initialInput` and `meta.context` when it is called, so call it before the run
1437
+ * when the run can change them, and take `dropped` and `trace` from a second call once it ends.
918
1438
  * @param {TraceMeta} [meta]
919
1439
  * @returns {TraceLog}
920
1440
  */
@@ -932,8 +1452,8 @@ const recorder = (options = {}) => {
932
1452
 
933
1453
  /**
934
1454
  * Runs a flow for real while recording every Command result, and returns both the
935
- * outcome and a replayable trace. Convenient in tests and scripts; in an application,
936
- * 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.
937
1457
  *
938
1458
  * @param {(input: any) => Effect} flowFn - Builds the Effect tree from its input
939
1459
  * @param {any} initialInput - The value the flow is called with; stored so a replay can rebuild it
@@ -944,43 +1464,74 @@ const recorder = (options = {}) => {
944
1464
  const recordEffect = async (flowFn, initialInput, options = {}) => {
945
1465
  const { context = {}, version, ...recorderOptions } = options;
946
1466
  const rec = recorder(recorderOptions);
947
- // The recorder is added to the global wiring, so recording inside an instrumented application keeps
948
- // its spans.
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.
1469
+ const head = rec.toTrace({ initialInput, flowName: context.flowName, context, version });
1470
+ // Merged over the global wiring, so recording inside an instrumented application keeps its spans.
949
1471
  const result = await runEffect(flowFn(initialInput), context, { onStep: rec.onStep });
950
- return {
951
- result,
952
- trace: rec.toTrace({ initialInput, flowName: context.flowName, context, version })
953
- };
1472
+ const { dropped, trace } = rec.toTrace();
1473
+ return { result, trace: { ...head, dropped, trace } };
954
1474
  };
955
1475
 
956
1476
  /**
957
- * Turns a recorded entry into the outcome a Resolver must return.
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.
958
1501
  *
959
1502
  * @param {TraceEntry} entry
960
1503
  * @returns {ReplayOutcome}
961
1504
  */
962
- const entryToOutcome = (entry) => ('error' in entry ? { error: reviveError(entry.error) } : { result: entry.result });
1505
+ const entryToOutcome = (entry) =>
1506
+ entryThrew(entry) ? { error: reviveError(snapshot(entry.error)) } : { result: snapshot(entry.result) };
963
1507
 
964
1508
  /**
965
- * Builds a Resolver for the reference trace format. Internal: `replayEffect` calls this
966
- * when handed a trace instead of a Resolver, and is the only caller. Callers with traces
967
- * in some other shape write a Resolver instead, which is the extension point.
1509
+ * Whether an entry is a Parallel's recorded decision rather than a Command's result. A Parallel's path
1510
+ * ends in its `p` marker and a Command's in its step number, so a Command that happens to be named
1511
+ * 'Parallel' is never mistaken for one.
1512
+ * @param {TraceEntry} entry
1513
+ * @returns {boolean}
1514
+ */
1515
+ const isDecisionEntry = (entry) =>
1516
+ entry.command === 'Parallel' && typeof entry.path === 'string' && entry.path.endsWith('p');
1517
+
1518
+ /**
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.
968
1521
  *
969
- * Path matching is used whenever every entry carries a `path`, which is every trace this
970
- * version records. A path is the Command's position in the Effect tree, so it is order-independent
971
- * and still paradox-detecting: it works for `Parallel` as it is, and it tells two branches that
972
- * call the same Command apart by where they are rather than by which finished first.
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.
973
1526
  *
974
- * A trace whose entries carry no path (recorded before paths existed, or written by hand) is
975
- * matched positionally: entries are consumed in recorded order and each must name the Command the
976
- * flow asks for. That is exact for sequential flows and cannot tell `Parallel` branches apart, which
977
- * is what paths were added to fix.
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.
978
1529
  *
979
1530
  * @param {TraceLog | TraceEntry[]} traceLog - A reference-format trace, or a bare array of entries
980
1531
  * @param {Object} [options]
981
1532
  * @param {(entry: TraceEntry) => void} [options.onEntry] - Observes each entry as it is handed to a step,
982
1533
  * which is how `replayEffect` learns which recorded entries the flow never asked for.
983
- * @returns {Resolver}
1534
+ * @returns {{ resolve: Resolver, missing: (step: ReplayStep) => string }}
984
1535
  */
985
1536
  const fromTrace = (traceLog, options = {}) => {
986
1537
  const { onEntry } = options;
@@ -991,97 +1542,77 @@ const fromTrace = (traceLog, options = {}) => {
991
1542
  return entryToOutcome(entry);
992
1543
  };
993
1544
 
994
- if (entries.length > 0 && entries.every((e) => typeof e.path === 'string')) {
1545
+ if (entries.length > 0 && entries.every(hasPath)) {
995
1546
  const byPath = new Map(entries.map((e) => [e.path, e]));
996
- // Paths are derived from tree position, so they are unique by construction. A collision means
997
- // either a hand-built trace or a bug in path derivation, and silently keeping the last entry
998
- // 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.
999
1549
  if (byPath.size !== entries.length) {
1000
1550
  throw replayError('Trace has duplicate step paths.');
1001
1551
  }
1002
- return (step) => {
1552
+ /** Recorded Parallel decisions that cancelled branches, by the Parallel's path. */
1553
+ const cancellations = entries.filter(isDecisionEntry).filter((e) => e.result?.cancelled === true);
1554
+ // A missing step is where production stopped a branch when it lies in a branch a recorded decision
1555
+ // cancelled: any branch but the cancelling one, or every branch of a Parallel cancelled from outside.
1556
+ const stoppedInProduction = (/** @type {string | undefined} */ stepPath) =>
1557
+ typeof stepPath === 'string' &&
1558
+ cancellations.some((e) => {
1559
+ const at = /** @type {string} */ (e.path);
1560
+ const branch = stepPath.startsWith(at) ? /^(\d+)\//.exec(stepPath.slice(at.length)) : null;
1561
+ return branch !== null && (e.result.branch === null || Number(branch[1]) !== e.result.branch);
1562
+ });
1563
+ /** @type {Resolver} */
1564
+ const resolve = (step) => {
1003
1565
  const entry = byPath.get(step.path);
1566
+ if (step.type === 'Parallel') {
1567
+ // A decision rather than I/O. With none recorded, the Parallel replays under timing.
1568
+ if (entry && entry.command !== 'Parallel') throw timeParadox(step, entry.command);
1569
+ if (entry) return resolveEntry(entry);
1570
+ if (stoppedInProduction(step.path)) throw replayCutError(/** @type {string} */ (step.path));
1571
+ return undefined;
1572
+ }
1004
1573
  if (!entry) {
1005
- throw replayError(`Trace has no step at path '${step.path}' for '${step.name}'.`, {
1006
- command: step.name,
1007
- path: step.path
1008
- });
1574
+ // Production never ran this step, so it is not run live under `onMissing` either.
1575
+ if (stoppedInProduction(step.path)) throw replayCutError(/** @type {string} */ (step.path));
1576
+ return undefined;
1009
1577
  }
1010
1578
  if (entry.command !== step.name) throw timeParadox(step, entry.command);
1011
1579
  return resolveEntry(entry);
1012
1580
  };
1581
+ const missing = (/** @type {ReplayStep} */ step) =>
1582
+ `Trace has no step at path '${step.path}' for '${step.name}'`;
1583
+ return { resolve, missing };
1013
1584
  }
1014
1585
 
1015
- return (step) => {
1016
- // Positional matching pairs steps by completion order, which is exactly what makes Parallel
1017
- // branches indistinguishable. Refusing is better than a result that is right only when the
1018
- // replay happens to finish in production's order.
1019
- if (typeof step.path === 'string' && /p\d+\//.test(step.path)) {
1586
+ /** @type {Resolver} */
1587
+ const resolve = (step) => {
1588
+ // A trace with no paths predates recorded decisions, so a Parallel replays under timing.
1589
+ if (step.type === 'Parallel') return undefined;
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)) {
1020
1593
  throw replayError(
1021
1594
  `Trace carries no paths, so '${step.name}' inside a Parallel cannot be matched positionally.`,
1022
1595
  { command: step.name, path: step.path }
1023
1596
  );
1024
1597
  }
1025
1598
  const entry = entries[step.index];
1026
- if (!entry) throw replayError(`Trace exhausted: no entry #${step.index} for '${step.name}'.`);
1599
+ if (!entry) return undefined;
1027
1600
  if (entry.command !== step.name) throw timeParadox(step, entry.command);
1028
1601
  return resolveEntry(entry);
1029
1602
  };
1030
- };
1031
-
1032
- /**
1033
- * Rewrites `Retry` nodes to zero delay, lazily, through their `next` continuations.
1034
- *
1035
- * Needed because the interpreter merges per-use options over call config
1036
- * (`{ ...localRetryDefaults, ...eff.options }`), so a `callConfig.retry` cannot
1037
- * override a delay written at the call site. Without this, replaying a flow that
1038
- * retried in production waits out the production backoff.
1039
- *
1040
- * @param {any} eff - Any Effect node
1041
- * @returns {any} The same tree with Retry delays removed
1042
- */
1043
- const zeroRetryDelays = (eff) => {
1044
- if (!eff || typeof eff.type !== 'string') return eff;
1045
- switch (eff.type) {
1046
- case 'Retry': {
1047
- const options = { ...eff.options, delay: 0, backoff: 1 };
1048
- // The fallback tree only exists once onExhausted runs, so it is rewritten lazily too;
1049
- // otherwise a Retry inside the fallback keeps its production backoff during replay.
1050
- if (typeof options.onExhausted === 'function') {
1051
- const original = options.onExhausted;
1052
- options.onExhausted = (/** @type {any} */ error) => zeroRetryDelays(original(error));
1053
- }
1054
- return {
1055
- ...eff,
1056
- options,
1057
- effect: zeroRetryDelays(eff.effect),
1058
- next: (/** @type {any} */ value) => zeroRetryDelays(eff.next(value))
1059
- };
1060
- }
1061
- case 'Command':
1062
- return { ...eff, next: (/** @type {any} */ result) => zeroRetryDelays(eff.next(result)) };
1063
- case 'Ask':
1064
- return { ...eff, next: (/** @type {any} */ ctx) => zeroRetryDelays(eff.next(ctx)) };
1065
- case 'Parallel':
1066
- return {
1067
- ...eff,
1068
- effects: eff.effects.map(zeroRetryDelays),
1069
- next: (/** @type {any} */ values) => zeroRetryDelays(eff.next(values))
1070
- };
1071
- default:
1072
- return eff;
1073
- }
1603
+ const missing = (/** @type {ReplayStep} */ step) => `Trace exhausted: no entry #${step.index} for '${step.name}'`;
1604
+ return { resolve, missing };
1074
1605
  };
1075
1606
 
1076
1607
  /**
1077
1608
  * @typedef {Object} ReplayOptions
1078
- * @property {any} [context] - Context for `Ask`; pass the recorded context to reproduce a run faithfully.
1079
- * @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.
1080
1612
  * @property {boolean} [hooks] - Runs the replay inside the hooks `configureEffect` installed, with the
1081
1613
  * resolver innermost, so a configured `onStep` observes each replayed step and `onRun` and
1082
1614
  * `onBeforeCommand` fire. Off by default, which ignores the global hooks, so a replay cannot
1083
- * reach a telemetry backend or a guardrail that performs I/O. Global `retry` defaults apply
1084
- * either way, since a replay has to make the attempts production made.
1615
+ * reach a telemetry backend or a guardrail that performs I/O.
1085
1616
  * @property {'throw' | 'execute'} [onMissing] - What to do when the resolver has no recording for a step.
1086
1617
  * `'throw'` (default) fails the replay, which makes side effects impossible for the whole run.
1087
1618
  * `'execute'` runs the real Command, giving partial replay: recorded prefix, live tail.
@@ -1096,8 +1627,7 @@ const zeroRetryDelays = (eff) => {
1096
1627
  * A flow that stops issuing Commands before its recording ends mismatches nothing, so no
1097
1628
  * `TimeParadox` fires and `result` can be a `Success` with recorded steps left over. That is
1098
1629
  * sometimes the point of a fix and sometimes a fix that quietly dropped a step; `unreached` is
1099
- * the only place the difference shows, which is why it travels with the result rather than
1100
- * behind an option.
1630
+ * the only place the difference shows.
1101
1631
  * @typedef {{ result: SuccessState | FailureState, unreached?: TraceEntry[] }} Replay
1102
1632
  */
1103
1633
 
@@ -1105,7 +1635,7 @@ const zeroRetryDelays = (eff) => {
1105
1635
  * Replays an Effect tree, feeding recorded results to Commands instead of running them.
1106
1636
  *
1107
1637
  * No side effect can occur by default. The interpreter's only execution point is
1108
- * `await localStepRunner(cmdName, 'Command', eff.cmd)`, which hands the Command thunk
1638
+ * `await localStepRunner(cmdName, 'Command', op, cmdPath)`, which hands the Command thunk
1109
1639
  * to `onStep` as `op` rather than calling it. The `onStep` installed here never invokes
1110
1640
  * `op` unless `onMissing: 'execute'` is set, so `eff.cmd` is never applied and the I/O
1111
1641
  * it describes does not happen.
@@ -1120,31 +1650,56 @@ const zeroRetryDelays = (eff) => {
1120
1650
  * @param {ReplayOptions} [options]
1121
1651
  * @returns {Promise<Replay>} `{ result, unreached }` for a trace, `{ result }` for a Resolver
1122
1652
  */
1123
- // `async` so a malformed trace arrives as a rejection rather than a synchronous throw:
1124
- // 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.
1125
1654
  const replayEffect = async (effect, traceOrResolver, options = {}) => {
1126
- const { context = {}, fastRetry = true, hooks = false, onMissing = 'throw', onResolved } = options;
1655
+ const { fastRetry = true, hooks = false, onMissing = 'throw', onResolved } = options;
1127
1656
  // A trace is data and a Resolver is a function, so nothing else is needed to tell them
1128
1657
  // apart, including the bare entries array that `fromTrace` also accepts.
1129
1658
  const fromResolver = typeof traceOrResolver === 'function';
1130
1659
  /** @type {Set<TraceEntry>} */
1131
1660
  const reached = new Set();
1132
- const resolve = fromResolver
1133
- ? traceOrResolver
1661
+ const { resolve, missing } = fromResolver
1662
+ ? { resolve: traceOrResolver, missing: undefined }
1134
1663
  : fromTrace(traceOrResolver, { onEntry: (entry) => void reached.add(entry) });
1664
+ // Defaults to the trace's context: an `Ask` gate replayed with another one takes another branch, and
1665
+ // no paradox flags it.
1666
+ const recorded = fromResolver || Array.isArray(traceOrResolver) ? undefined : traceOrResolver.context;
1667
+ const context = options.context ?? recorded ?? {};
1135
1668
  let index = 0;
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.
1672
+ let observerFailure = /** @type {{ error: unknown } | undefined} */ (undefined);
1136
1673
 
1137
1674
  /** @type {StepRunner} */
1138
1675
  const onStep = async (name, type, op, path) => {
1676
+ if (type === 'Parallel') {
1677
+ // A Parallel's step carries its recorded decision into `op`, which runs the branches under it.
1678
+ // It is not a Command: `index` still counts Commands, and `onResolved` still sees only them.
1679
+ const outcome = resolve({ index, name, type, path });
1680
+ return await op(outcome && 'result' in outcome ? outcome.result : undefined);
1681
+ }
1139
1682
  const step = { index: index++, name, type, path };
1140
1683
  const outcome = resolve(step);
1141
- if (onResolved) onResolved(step, outcome);
1684
+ if (onResolved) {
1685
+ try {
1686
+ onResolved(step, outcome);
1687
+ } catch (error) {
1688
+ observerFailure ??= { error };
1689
+ throw asHarnessError(new Error('onResolved threw.'));
1690
+ }
1691
+ }
1142
1692
  if (outcome === undefined) {
1143
1693
  if (onMissing !== 'execute') {
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.
1144
1697
  throw replayError(
1145
- `No recorded outcome for '${name}' at step ${step.index}; refusing to run the real Command. ` +
1146
- `Pass onMissing: 'execute' to allow live I/O for unrecorded steps.`,
1147
- { command: name, index: step.index }
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.',
1702
+ { command: name, index: step.index, path }
1148
1703
  );
1149
1704
  }
1150
1705
  return await op();
@@ -1153,16 +1708,21 @@ const replayEffect = async (effect, traceOrResolver, options = {}) => {
1153
1708
  return outcome.result;
1154
1709
  };
1155
1710
 
1156
- // Off, a replay ignores the global hooks, so it can reach neither a telemetry backend nor a guardrail
1157
- // that performs I/O. On, it runs inside them with the resolver innermost, so a configured onStep
1158
- // observes each replayed step and the Command still never executes. The global retry defaults are
1159
- // kept either way: `inherit: false` would drop them with the hooks, and a Retry that production ran
1160
- // under a configured `attempts` would then exhaust early on replay and report a Failure production
1161
- // never saw. Retry shape is part of what a replay reproduces; the hooks are not.
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.
1162
1713
  /** @type {CallConfiguration} */
1163
- const callConfig = { onStep, inherit: hooks, retry: globalConfig.retry };
1714
+ const callConfig = { onStep, inherit: hooks };
1164
1715
 
1165
- const result = await runEffect(fastRetry ? zeroRetryDelays(effect) : effect, context, callConfig);
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.
1718
+ let result;
1719
+ try {
1720
+ result = await interpret(effect, context, callConfig, fastRetry);
1721
+ } catch (e) {
1722
+ if (observerFailure) throw observerFailure.error;
1723
+ if (!hasMark(e, replayFault)) throw e;
1724
+ result = Failure(e, effect.initialInput);
1725
+ }
1166
1726
  if (fromResolver) return { result };
1167
1727
  // `fromTrace` has already validated the shape, so the entries are here in one form or the other.
1168
1728
  const entries = Array.isArray(traceOrResolver) ? traceOrResolver : traceOrResolver.trace;
@@ -1186,26 +1746,36 @@ const replayEffect = async (effect, traceOrResolver, options = {}) => {
1186
1746
  const timeTravel = async (flowFn, traceLog, options = {}) => {
1187
1747
  const { log = console.log, context, version } = options;
1188
1748
  const { initialInput, trace, flowName, version: traceVersion } = traceLog;
1189
- // `message` and `stack` are non-enumerable on Error, so JSON.stringify alone would
1190
- // drop the most useful line of the report.
1191
- const format = (/** @type {any} */ v) =>
1192
- v instanceof Error
1193
- ? JSON.stringify({ ...v, name: v.name, message: v.message }, null, 2)
1194
- : JSON.stringify(v, null, 2);
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.
1751
+ const format = (/** @type {any} */ v) => {
1752
+ try {
1753
+ return JSON.stringify(v instanceof Error ? { ...v, name: v.name, message: v.message } : v, null, 2);
1754
+ } catch {
1755
+ return String(v);
1756
+ }
1757
+ };
1195
1758
 
1196
1759
  if (version && traceVersion && version !== traceVersion) {
1197
1760
  log(`Warning: trace was recorded at ${traceVersion}, replaying against ${version}.`);
1198
1761
  }
1199
- const stepsText = trace.length === 0 || trace.length > 1 ? 'steps' : 'step';
1200
- log(`Replaying '${flowName || 'flow'}' (${trace.length} recorded ${stepsText})`);
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.
1771
+ const commandCount = trace.filter((e) => !isDecisionEntry(e)).length;
1772
+ const stepsText = commandCount === 1 ? 'step' : 'steps';
1773
+ log(`Replaying '${flowName || 'flow'}' (${commandCount} recorded ${stepsText})`);
1201
1774
  log(`Initial input: ${format(initialInput)}`);
1202
1775
 
1203
- // Narration goes through `onResolved` rather than a wrapped Resolver: observing each
1204
- // step is all this needs, and `replayEffect` already resolves the trace itself.
1205
- // A path maps a replayed step back to its recorded entry whatever order branches finished in, so
1206
- // timings are narrated for Parallel too. Positional lookup is the fallback for a trace with no paths,
1207
- // which is how such a trace is matched anyway.
1208
- 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]));
1209
1779
  const timing = (/** @type {ReplayStep} */ step) => {
1210
1780
  const recorded = byPath.get(step.path) ?? trace[step.index];
1211
1781
  return typeof recorded?.durationMs === 'number' ? ` in ${recorded.durationMs}ms` : '';
@@ -1213,7 +1783,7 @@ const timeTravel = async (flowFn, traceLog, options = {}) => {
1213
1783
  const replay = await replayEffect(flowFn(initialInput), traceLog, {
1214
1784
  context: context !== undefined ? context : traceLog.context || {},
1215
1785
  onResolved: (step, outcome) => {
1216
- // Resolving from a trace either answers or throws, so this is never undefined.
1786
+ // A step the trace does not hold is refused or run live by `replayEffect`, not narrated here.
1217
1787
  if (outcome === undefined) return;
1218
1788
  log(
1219
1789
  'error' in outcome
@@ -1226,15 +1796,12 @@ const timeTravel = async (flowFn, traceLog, options = {}) => {
1226
1796
 
1227
1797
  log(`Replay finished with state: ${result.type}`);
1228
1798
  log(result.type === 'Failure' ? `Error: ${format(result.error)}` : `Result: ${format(result.value)}`);
1229
- // After a replay error the flow never got past the divergence, so the entries behind it are not
1230
- // news; the error already names where the timeline split. Only a flow that ended on its own terms
1231
- // with steps left over is worth a warning.
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.
1232
1801
  const haltedByReplay = result.type === 'Failure' && /^(TimeParadox|ReplayError)$/.test(result.error?.name);
1233
1802
  if (unreached.length > 0 && !haltedByReplay) {
1234
1803
  // Named, not just counted: the step a fix stopped issuing is usually the one under suspicion.
1235
- const names = unreached.map((e) =>
1236
- typeof e.path === 'string' ? `${e.command} (path '${e.path}')` : e.command
1237
- );
1804
+ const names = unreached.map((e) => (hasPath(e) ? `${e.command} (path '${e.path}')` : e.command));
1238
1805
  const count = unreached.length === 1 ? '1 recorded step was' : `${unreached.length} recorded steps were`;
1239
1806
  log(`Warning: ${count} never reached: ${names.join(', ')}. The flow diverged.`);
1240
1807
  }