pure-effect 0.14.0 → 0.16.0

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