pure-effect 0.16.0 → 0.18.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
@@ -1,10 +1,29 @@
1
1
  // @ts-check
2
2
 
3
- /** @typedef {{ type: 'Success', value: any, initialInput?: any }} SuccessState */
4
- /** @typedef {{ type: 'Failure', error: any, initialInput?: any }} FailureState */
5
- /**
6
- * Metadata attached to a Command. A string `name` is read by the interpreter as the Command's
7
- * identity; every other key is carried through untouched for `onBeforeCommand`.
3
+ // Contents. Each section is a `#region`, so an editor can fold it.
4
+ //
5
+ // 1. Types: the JSDoc shapes of the nodes a flow is made of.
6
+ // 2. Building flows: the constructors; the checks, error messages and kinds of failure the later sections
7
+ // share, `checkOptions` and `Retry`'s defaults among them; and `effectPipe`, with the `chain` that joins its
8
+ // steps and the `flowInputs` it records for `onRun`.
9
+ // 3. Configuration: the hook types and their defaults, `configureEffect` and its layers, and `chainHooks`, which
10
+ // merges them.
11
+ // 4. Running flows: the helpers `Retry`, `Parallel` and Commands run on; `interpret`, which builds a run's
12
+ // `Runtime`, then `execute` and a function for each kind of node, which each take it; and `runEffect`.
13
+ // 5. Recording and replay: the trace format and replay errors, copying values and errors into a trace,
14
+ // `recorder` and `recordEffect`, then `fromTrace`, `replayEffect` and `timeTravel`, each followed by the
15
+ // functions it hands its parts to.
16
+ //
17
+ // A new definition goes in the section it serves, which is usually the one that calls it. DESIGN.md explains why
18
+ // each part works the way it does.
19
+
20
+ // #region Types
21
+
22
+ /** @typedef {{ type: 'Success', value: any }} SuccessState */
23
+ /** @typedef {{ type: 'Failure', error: any }} FailureState */
24
+ /**
25
+ * Metadata attached to a Command. A string `name` is the Command's identity; every other key is carried through
26
+ * for `onBeforeCommand`.
8
27
  * @typedef {{ name?: string } & Record<string, any>} CommandMeta
9
28
  */
10
29
  /**
@@ -12,15 +31,13 @@
12
31
  * type: 'Command',
13
32
  * cmd: (signal?: AbortSignal) => Promise<any>|any,
14
33
  * next: (result: any) => Effect,
15
- * meta?: any,
16
- * initialInput?: any
34
+ * meta?: any
17
35
  * }} CommandState
18
36
  */
19
37
  /**
20
38
  * @typedef {{
21
39
  * type: 'Ask',
22
- * next: (context: any) => Effect,
23
- * initialInput?: any
40
+ * next: (context: any) => Effect
24
41
  * }} AskState
25
42
  */
26
43
 
@@ -28,9 +45,8 @@
28
45
  * @typedef {{
29
46
  * type: 'Retry',
30
47
  * effect: Effect,
31
- * options: { attempts?: number, delay?: number, backoff?: number, onExhausted?: (error: any) => Effect },
32
- * next: (value: any) => Effect,
33
- * initialInput?: any
48
+ * options: Readonly<{ attempts?: number, delay?: number, backoff?: number, onExhausted?: (error: any) => Effect }>,
49
+ * next: (value: any) => Effect
34
50
  * }} RetryState
35
51
  */
36
52
 
@@ -45,16 +61,18 @@
45
61
  * type: 'Parallel',
46
62
  * effects: Effect[],
47
63
  * next: (values: any[]) => Effect,
48
- * options?: ParallelOptions,
49
- * initialInput?: any
64
+ * options?: Readonly<ParallelOptions>
50
65
  * }} ParallelState
51
66
  */
52
67
 
53
68
  /**
54
- * The Union type for all possible states
69
+ * Every node a flow can be made of.
55
70
  * @typedef {SuccessState | FailureState | CommandState | AskState | RetryState | ParallelState} Effect
56
71
  */
57
72
 
73
+ // #endregion
74
+ // #region Building flows
75
+
58
76
  /**
59
77
  * Represents a successful computation
60
78
  * @param {any} value - The result value
@@ -63,28 +81,21 @@
63
81
  const Success = (value) => ({ type: 'Success', value });
64
82
 
65
83
  /**
66
- * Represents a failed computation. Stops the pipeline execution
84
+ * Represents a failed computation. Stops the pipeline.
67
85
  * @param {any} error - The error reason (string, Error object, etc).
68
- * @param {any} [initialInput] - initial input passed to the flow (optional)
69
86
  * @returns {FailureState}
70
87
  */
71
- const Failure = (error, initialInput) => ({
72
- type: 'Failure',
73
- error,
74
- initialInput
75
- });
88
+ const Failure = (error) => ({ type: 'Failure', error });
76
89
 
77
90
  /**
78
91
  * Represents a side effect to be executed later.
79
92
  *
80
- * @param {(signal?: AbortSignal) => Promise<any>|any} cmd - The side-effect function to execute. Inside a
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. 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
- * @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; `null` counts as omitted.
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.
93
+ * @param {(signal?: AbortSignal) => Promise<any>|any} cmd - The function that does the I/O. Inside a `Parallel`, a
94
+ * function that declares a parameter receives an `AbortSignal` that fires when a sibling branch fails. A
95
+ * parameter with a default value does not count, so `nanoid(size = 21)` keeps its default.
96
+ * @param {(result: any) => Effect} [next] - Receives the result of `cmd`. Defaults to `Success(result)`; `null`
97
+ * counts as omitted.
98
+ * @param {CommandMeta} [meta] - Passed to `onBeforeCommand`. A string `meta.name` is the Command's identity.
88
99
  * @returns {CommandState}
89
100
  */
90
101
  const Command = (cmd, next, meta) => {
@@ -109,9 +120,8 @@ const Command = (cmd, next, meta) => {
109
120
  };
110
121
 
111
122
  /**
112
- * The name a Command is known by: what a trace records, what replay matches on, and what a
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.
123
+ * The name a Command is known by in traces, replay matching and telemetry: a non-empty string `meta.name`, else
124
+ * `cmd.name`, else 'anonymous'. Exported so a test walking a flow uses the same rule.
115
125
  *
116
126
  * @param {CommandState} eff
117
127
  * @returns {string}
@@ -138,57 +148,49 @@ const Ask = (next) => {
138
148
  };
139
149
 
140
150
  /**
141
- * Wraps an Effect tree with retry-on-failure semantics.
151
+ * Runs `effect` again when a Command in it throws.
142
152
  *
143
- * Each attempt runs the **entire** wrapped tree again, including Commands that already succeeded, so
144
- * wrap the one Command that fails transiently rather than a pipeline. `Retry(effectPipe(charge, receipt))`
145
- * charges the customer again every time the receipt step fails. Wrapping a pipeline is only safe when
146
- * every Command in it is idempotent.
153
+ * Each attempt runs the **entire** wrapped tree again, including Commands that already succeeded, so wrap the one
154
+ * Command that fails transiently. `Retry(effectPipe(charge, receipt))` charges the customer again every time the
155
+ * receipt step fails.
147
156
  *
148
157
  * @param {Effect} effect - The inner Effect tree to 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
155
- * @param {(error: any) => Effect} [options.onExhausted] - Runs a fallback Effect when every attempt has
156
- * failed, receiving `{ retryExhausted, lastError, attempts }`. The fallback's success feeds `next`
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.
158
+ * @param {Object} [options] - Merged over the defaults when it runs; an option set to `undefined` keeps its default.
159
+ * @param {number} [options.attempts] - Retries after the first try, a positive integer
160
+ * @param {number} [options.delay] - Ms before the first retry, a finite number of 0 or more
161
+ * @param {number} [options.backoff] - Multiplier applied to the delay on each later retry, a finite number of 0 or
162
+ * more
163
+ * @param {(error: any) => Effect} [options.onExhausted] - Builds a fallback from `{ retryExhausted, lastError,
164
+ * attempts }` once every attempt has failed. Its success feeds `next`; its failure propagates unwrapped.
159
165
  * @returns {RetryState}
160
166
  */
161
167
  const Retry = (effect, options) => {
162
168
  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);
169
+ if (typeof options === 'number') {
170
+ const got = describeArgument(options);
171
+ throw malformed(
172
+ `Retry's options must be an object, got ${got}: write Retry(effect, { attempts: ${options} }).`,
173
+ options
174
+ );
166
175
  }
167
- return { type: 'Retry', effect, options: options ?? {}, next: (value) => Success(value) };
176
+ checkOptions(options, 'Retry', retryOptionRules, malformed);
177
+ return { type: 'Retry', effect, options: Object.freeze({ ...options }), next: (value) => Success(value) };
168
178
  };
169
179
 
170
180
  /**
171
- * Runs multiple Effect trees concurrently. The first branch to fail cancels its siblings, and that
172
- * branch's Failure is what the Parallel returns; `next` is skipped. Which branch fails first depends on
173
- * timing, so it is recorded, and a replay returns the same one.
181
+ * Runs Effect trees at the same time. The first branch to fail cancels the others, and its Failure is the result.
182
+ * A cancelled branch starts no further Commands, but one already in flight stops only if its function passes on
183
+ * the `AbortSignal` it receives.
174
184
  *
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
177
- * signal runs to completion, so a branch's first Command can still write after a sibling has failed.
185
+ * `settled: true` runs every branch to completion and hands `next` each outcome as a `Success` or `Failure`.
186
+ * `limit: n` keeps at most `n` branches in flight. Results and paths follow array order either way.
178
187
  *
179
- * `settled: true` turns off that first-failure rule: every branch runs to completion and `next`
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.
183
- *
184
- * The second argument is the `next` function or the options, whichever it looks like, so
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.
188
+ * The second argument is `next` or the options, whichever it looks like. `undefined` or `null` there is a skipped
189
+ * `next`, so options passed third still count.
187
190
  *
188
191
  * @param {Effect[]} effects - Array of Effect trees to run concurrently
189
- * @param {((values: any[]) => Effect) | ParallelOptions | null} [nextOrOptions] - Receives array of success
190
- * values in order and returns the next Effect, or the options. `next` defaults to
191
- * `(values) => Success(values)`, same as `Command`'s default.
192
+ * @param {((values: any[]) => Effect) | ParallelOptions | null} [nextOrOptions] - Receives the success values in
193
+ * order, and defaults to `Success(values)`; or the options.
192
194
  * @param {ParallelOptions} [maybeOptions] - Options, when `next` was given or skipped
193
195
  * @returns {ParallelState}
194
196
  */
@@ -200,14 +202,13 @@ const Parallel = (effects, nextOrOptions, maybeOptions) => {
200
202
  if (!isEffect(branch)) throw malformed(`Parallel's branch ${i} is ${describeValue(branch)}.`, branch);
201
203
  });
202
204
  const hasNext = typeof nextOrOptions === 'function';
203
- // A caller forwarding an absent `next` still passes its options third, as the signature reads.
204
205
  const nextSkipped = nextOrOptions == null;
205
206
  const optionsSecond = !hasNext && !nextSkipped;
206
207
  if (optionsSecond && !isOptionsObject(nextOrOptions)) {
207
208
  const got = describeArgument(nextOrOptions);
208
209
  throw malformed(`Parallel's second argument must be next or the options, got ${got}.`, nextOrOptions);
209
210
  }
210
- // Nothing reads a third argument after the options, so a `next` passed there would silently be skipped.
211
+ // Nothing would read a third argument after the options.
211
212
  if (optionsSecond && maybeOptions != null) {
212
213
  throw malformed(
213
214
  typeof maybeOptions === 'function'
@@ -218,19 +219,17 @@ const Parallel = (effects, nextOrOptions, maybeOptions) => {
218
219
  );
219
220
  }
220
221
  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
- }
222
+ checkOptions(options, 'Parallel', parallelOptionRules, malformed);
224
223
  return {
225
224
  type: 'Parallel',
226
225
  effects,
227
226
  next: hasNext ? nextOrOptions : (/** @type {any[]} */ values) => Success(values),
228
- options: options ?? {}
227
+ options: Object.freeze({ ...options })
229
228
  };
230
229
  };
231
230
 
232
231
  /**
233
- * Whether a value is an object that can carry properties: not `null`, a primitive, or a function.
232
+ * Whether a value is an object: not `null`, a primitive, or a function.
234
233
  * @param {any} value
235
234
  * @returns {boolean}
236
235
  */
@@ -248,6 +247,18 @@ const isPositiveInteger = (value) => Number.isInteger(value) && value >= 1;
248
247
  */
249
248
  const isFiniteNonNegative = (value) => Number.isFinite(value) && value >= 0;
250
249
 
250
+ /**
251
+ * @param {any} value
252
+ * @returns {boolean}
253
+ */
254
+ const isBoolean = (value) => typeof value === 'boolean';
255
+
256
+ /**
257
+ * @param {any} value
258
+ * @returns {boolean}
259
+ */
260
+ const isFunction = (value) => typeof value === 'function';
261
+
251
262
  /**
252
263
  * An object that can hold options or meta: not an array, and not an Effect passed in the wrong place.
253
264
  * @param {any} value
@@ -255,6 +266,79 @@ const isFiniteNonNegative = (value) => Number.isFinite(value) && value >= 0;
255
266
  */
256
267
  const isOptionsObject = (value) => isObject(value) && !Array.isArray(value) && !isEffect(value);
257
268
 
269
+ /**
270
+ * Refuses an option name the function does not read, such as p-limit's `concurrency` passed to `Parallel`.
271
+ * @param {any} options
272
+ * @param {string} source - The function that takes them, as the message names it
273
+ * @param {string[]} known
274
+ * @param {(message: string, value: any) => Error} [raise] - Builds the error; a constructor's are EffectTypeErrors
275
+ */
276
+ const rejectUnknownOptions = (options, source, known, raise = (message) => new TypeError(message)) => {
277
+ const name = isObject(options) ? Object.keys(options).find((key) => !known.includes(key)) : undefined;
278
+ if (name !== undefined) {
279
+ const list = `${known.slice(0, -1).join(', ')} and ${known[known.length - 1]}`;
280
+ throw raise(`${source} has no option named '${name}'; its options are ${list}.`, options);
281
+ }
282
+ };
283
+
284
+ /**
285
+ * An option's rule: a test its value has to pass, the words for what passes, and advice for the message. `null`
286
+ * for an option that takes any value.
287
+ * @typedef {[(value: any) => boolean, string, string?] | null} OptionRule
288
+ */
289
+
290
+ /**
291
+ * Refuses options that are not an object, an option name the function does not read, and a value it cannot use.
292
+ * `null` is no options, as `undefined` is, and an option set to `undefined` keeps its default.
293
+ * @param {any} options
294
+ * @param {string} source - The function that takes them, as the message names it
295
+ * @param {Record<string, OptionRule>} rules - Every option the function reads, in the order a message lists them
296
+ * @param {(message: string, value: any) => Error} [raise] - Builds the error; a constructor's are EffectTypeErrors
297
+ */
298
+ const checkOptions = (options, source, rules, raise = (message) => new TypeError(message)) => {
299
+ if (options == null) return;
300
+ if (!isOptionsObject(options)) {
301
+ throw raise(`${source}'s options must be an object, got ${describeArgument(options)}.`, options);
302
+ }
303
+ rejectUnknownOptions(options, source, Object.keys(rules), raise);
304
+ for (const [name, rule] of Object.entries(rules)) {
305
+ const value = options[name];
306
+ if (rule === null || value === undefined || rule[0](value)) continue;
307
+ const [, takes, advice] = rule;
308
+ const message = `${source} '${name}' must be ${takes}, received ${describeArgument(value)}.`;
309
+ throw raise(advice ? `${message} ${advice}` : message, value);
310
+ }
311
+ };
312
+
313
+ /** What `Retry` uses for an option left out or set to `undefined`. */
314
+ const defaultRetryOptions = { attempts: 3, delay: 100, backoff: 1 };
315
+
316
+ /**
317
+ * Checked when the Retry is built, which keeps a frozen copy of the options, so they cannot change before it runs.
318
+ * @type {Record<string, OptionRule>}
319
+ */
320
+ const retryOptionRules = {
321
+ // Not 0, which would make `onExhausted` a catch.
322
+ attempts: [
323
+ isPositiveInteger,
324
+ 'a positive integer',
325
+ "To handle an outcome without retrying, branch on it as data in the Command's next, or isolate a failing " +
326
+ "branch with Parallel's settled option."
327
+ ],
328
+ delay: [isFiniteNonNegative, 'a finite number of 0 or more'],
329
+ backoff: [isFiniteNonNegative, 'a finite number of 0 or more'],
330
+ onExhausted: [isFunction, 'a function that returns the fallback']
331
+ };
332
+
333
+ /**
334
+ * Checked when the Parallel is built, which keeps a frozen copy, as a `Retry` does.
335
+ * @type {Record<string, OptionRule>}
336
+ */
337
+ const parallelOptionRules = {
338
+ limit: [isPositiveInteger, 'a positive integer'],
339
+ settled: [isBoolean, 'true or false']
340
+ };
341
+
258
342
  /**
259
343
  * Describes a value for an error message, leading with the mistake it most likely is.
260
344
  * @param {any} value
@@ -266,17 +350,18 @@ const describeValue = (value) => {
266
350
  if (value instanceof Promise) return 'a Promise, which usually means an async function';
267
351
  if (typeof value === 'function')
268
352
  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.
353
+ // `String` for the rest, since JSON prints NaN as null and throws on a BigInt.
270
354
  if (typeof value !== 'object') {
271
355
  return `the ${typeof value} ${typeof value === 'string' ? JSON.stringify(value) : String(value)}`;
272
356
  }
357
+ if (Array.isArray(value)) return 'an array';
273
358
  if (typeof value.type === 'string') return `an object with an unrecognised type '${value.type}'`;
274
359
  return 'a plain object';
275
360
  };
276
361
 
277
362
  /**
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.
363
+ * Describes a constructor's argument for an error message. A missing one is usually a misspelt name rather than a
364
+ * missing return, and an Effect in the wrong place is named by its type.
280
365
  * @param {any} value
281
366
  * @returns {string}
282
367
  */
@@ -284,11 +369,10 @@ const describeArgument = (value) =>
284
369
  value === undefined ? 'undefined' : isEffect(value) ? `an Effect of type '${value.type}'` : describeValue(value);
285
370
 
286
371
  /**
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`.
372
+ * Marks an error as the harness failing rather than the flow: a malformed flow, or a trace that cannot answer a
373
+ * step. The interpreter rethrows it rather than folding it into a `Failure`, so nothing that handles a domain
374
+ * failure can absorb it. A new kind of harness error gets this mark, not a name to match on. Non-enumerable, so it
375
+ * stays out of a serialized error and a `deepEqual`.
292
376
  */
293
377
  const harnessError = Symbol('pure-effect.harnessError');
294
378
 
@@ -307,37 +391,34 @@ const asHarnessError = (error) => Object.defineProperty(error, harnessError, { v
307
391
  const hasMark = (e, mark) => Boolean(e && /** @type {any} */ (e)[mark]);
308
392
 
309
393
  /**
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.
313
- * @typedef {{ type: 'IoFault', error: any, initialInput?: any }} IoFaultState
394
+ * An I/O fault: a Command's function threw, or a `Retry` ran out of attempts. It is what `Retry` retries, unlike a
395
+ * `Failure` a step returned. Only `execute` and its per-node functions return one, and `asOutcome` turns it into a
396
+ * plain `Failure` before it reaches user code.
397
+ * @typedef {{ type: 'IoFault', error: any }} IoFaultState
314
398
  */
315
399
 
316
400
  /**
317
401
  * @param {any} error
318
- * @param {any} [initialInput]
319
402
  * @returns {IoFaultState}
320
403
  */
321
- const IoFault = (error, initialInput) => ({ type: 'IoFault', error, initialInput });
404
+ const IoFault = (error) => ({ type: 'IoFault', error });
322
405
 
323
406
  /**
324
407
  * @param {SuccessState | FailureState | IoFaultState} state
325
408
  * @returns {SuccessState | FailureState}
326
409
  */
327
- const asOutcome = (state) => (state.type === 'IoFault' ? Failure(state.error, state.initialInput) : state);
410
+ const asOutcome = (state) => (state.type === 'IoFault' ? Failure(state.error) : state);
328
411
 
329
412
  /**
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`.
413
+ * Builds an `EffectTypeError`, the harness error for a malformed flow or a constructor argument it cannot use.
333
414
  *
334
415
  * @param {string} message
335
416
  * @param {any} value - The malformed value
336
417
  * @returns {Error}
337
418
  */
338
419
  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.
420
+ // The error reports the bug, so the rejection must not also crash the process as unhandled. Only a native
421
+ // Promise: calling `then` on a Knex or Mongoose query builder runs the query.
341
422
  if (value instanceof Promise) value.catch(() => {});
342
423
  return asHarnessError(Object.assign(new Error(message), { name: 'EffectTypeError' }));
343
424
  };
@@ -399,56 +480,37 @@ const asEffect = (value, source) => {
399
480
  };
400
481
 
401
482
  /**
402
- * Connects an Effect to the next function in the pipeline.
403
- * Handles the branching logic for Success, Failure, Command, Ask, Retry, and Parallel.
483
+ * Connects an Effect to the next function in the pipeline: a Success passes its value on, a Failure stops, and
484
+ * every other node gets a `next` that continues into `fn`.
404
485
  *
405
486
  * @param {Effect} effect - The current Effect object
406
487
  * @param {(value: any) => Effect} fn - The next function to run if the current effect is a Success
407
- * @param {any} [initialInput] - The pipeline's starting value, stamped on every node but a Success
408
488
  * @param {Effect} [from] - The node whose `next` returned `effect`, which an error names
409
489
  * @returns {Effect} The composed Effect
410
490
  */
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.
415
- const withII = (/** @type {Effect} */ e) =>
416
- initialInput !== undefined && e.type !== 'Success' ? { ...e, initialInput } : e;
417
-
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());
420
-
421
- switch (effect.type) {
422
- case 'Success':
423
- return withII(asEffect(fn(effect.value), `Step '${fn.name || 'anonymous'}'`));
424
- case 'Failure':
425
- return withII(effect);
426
- case 'Command': {
427
- const next = (/** @type {any} */ result) => chain(effect.next(result), fn, initialInput, effect);
428
- return withII(Command(effect.cmd, next, effect.meta));
429
- }
430
- case 'Ask': {
431
- const next = (/** @type {any} */ ctx) => chain(effect.next(ctx), fn, initialInput, effect);
432
- return withII(Ask(next));
433
- }
434
- case 'Retry': {
435
- const next = (/** @type {any} */ result) => chain(effect.next(result), fn, initialInput, effect);
436
- return withII({ ...effect, next });
437
- }
438
- case 'Parallel': {
439
- const next = (/** @type {any} */ result) => chain(effect.next(result), fn, initialInput, effect);
440
- return withII({ ...effect, next });
441
- }
442
- default:
443
- return asEffect(effect, source());
444
- }
491
+ const chain = (effect, fn, from) => {
492
+ // Before reading `.type`, so a missing return is named rather than thrown as a bare TypeError.
493
+ if (!isEffect(effect)) throw effectTypeError(effect, from ? nextOf(from) : 'A continuation');
494
+ if (effect.type === 'Success') return asEffect(fn(effect.value), `Step '${fn.name || 'anonymous'}'`);
495
+ if (effect.type === 'Failure') return effect;
496
+ const next = (/** @type {any} */ value) => chain(effect.next(value), fn, effect);
497
+ // A Command goes back through its constructor, which checks the function of one built by hand.
498
+ return effect.type === 'Command' ? Command(effect.cmd, next, effect.meta) : { ...effect, next };
445
499
  };
446
500
 
501
+ /**
502
+ * The input each flow `effectPipe` built was called with, keyed on the flow's root, for `interpret` to hand `onRun`.
503
+ * Kept off the flow, so no node or outcome carries it.
504
+ * @type {WeakMap<Effect, any>}
505
+ */
506
+ const flowInputs = new WeakMap();
507
+
447
508
  /**
448
509
  * Composes a list of functions into a single Effect pipeline.
449
510
  * Each function receives the output of the previous one.
450
511
  *
451
- * @param {...(input: any) => Effect} fns - Functions that return Success, Failure, Command, or Ask.
512
+ * @param {...(input: any) => Effect} fns - Functions that each return an Effect: Success, Failure, Command, Ask,
513
+ * Retry, or Parallel.
452
514
  * @returns {(start: any) => Effect} A function that accepts an initial input and returns the final Effect tree.
453
515
  */
454
516
  const effectPipe = (...fns) => {
@@ -463,58 +525,78 @@ const effectPipe = (...fns) => {
463
525
  }
464
526
  });
465
527
  return (start) => {
466
- const chainWithII = (/** @type {Effect} */ eff, /** @type {(v: any) => Effect} */ fn) => chain(eff, fn, start);
467
- const tree = fns.reduce(chainWithII, /** @type {Effect} */ (Success(start)));
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.
470
- return chain(tree, Success, start);
528
+ const tree = fns.reduce((eff, fn) => chain(eff, fn), /** @type {Effect} */ (Success(start)));
529
+ // Keyed on a copy, since the last step can return an object other flows share, such as a constant Failure.
530
+ const root = { ...tree };
531
+ flowInputs.set(root, start);
532
+ return root;
471
533
  };
472
534
  };
473
535
 
536
+ // #endregion
537
+ // #region Configuration
538
+
474
539
  /**
475
- * Wraps one Command execution, or one Parallel: `type` is 'Parallel', and `op` runs the branches and
476
- * returns the Parallel's decision, even when a branch threw, so a hook must call it. Only a replay passes
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.
540
+ * Wraps one Command, or one Parallel, whose `op` runs the branches and returns the decision, so a hook must call it.
541
+ * Only a replay passes `op` an argument, the recorded decision. `path` is the step's position in the Effect tree.
479
542
  * @typedef {(name: string, type: string, op: function, path?: string) => Promise<any>} StepRunner
480
543
  */
481
544
  /** @type StepRunner */
482
545
  const defaultStepRunner = async (name, type, op) => await op();
483
546
 
484
- /** @typedef {(effect: Effect, op: function, flowName?: string) => Promise<any>} RunWrapper */
547
+ /**
548
+ * Wraps one run. `initialInput` is what the flow was called with, when `effectPipe` built it.
549
+ * @typedef {(effect: Effect, op: function, flowName?: string, initialInput?: any) => Promise<any>} RunWrapper
550
+ */
485
551
  /** @type RunWrapper */
486
- const defaultRunWrapper = async (effect, op, flowName) => await op();
552
+ const defaultRunWrapper = async (effect, op, flowName, initialInput) => await op();
487
553
 
488
554
  /** @typedef {(command: CommandState, context?: any) => Promise<any>} CommandInterceptor */
489
555
  /** @type CommandInterceptor */
490
556
  const defaultCommandInterceptor = async (command, context) => {};
491
557
 
492
- const defaultRetryOptions = { attempts: 3, delay: 100, backoff: 1 };
558
+ /** The hooks a configuration can set. */
559
+ const hookNames = ['onStep', 'onRun', 'onBeforeCommand'];
493
560
 
494
561
  /**
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.
497
- * @param {any} config
498
- * @param {string} source
562
+ * Describes what was passed where a configuration belongs. A function there is usually one that builds hooks, as
563
+ * `telemetryHooks` does, passed without calling it.
564
+ * @param {any} value
565
+ * @returns {string}
499
566
  */
500
- const rejectRetryKey = (config, source) => {
501
- if (isObject(config) && 'retry' in config)
502
- throw new TypeError(
503
- `${source} no longer takes 'retry'. Retry options are per-use: pass them to Retry(effect, options).`
504
- );
567
+ const describeConfiguration = (value) =>
568
+ typeof value === 'function'
569
+ ? 'a function, which usually means one that builds hooks was passed without being called'
570
+ : describeArgument(value);
571
+
572
+ /**
573
+ * Refuses a key no hook has, such as a misspelt `onstep`, and a hook that is not a function. Only `undefined`
574
+ * leaves a hook unset.
575
+ * @param {any} config
576
+ * @param {string} source - Where it was passed, as the message names it
577
+ * @param {string} prefix - What precedes a hook's name in the message, as in `configureEffect's onStep`
578
+ * @param {string[]} known - The keys it may hold
579
+ */
580
+ const checkConfiguration = (config, source, prefix, known) => {
581
+ rejectUnknownOptions(config, source, known);
582
+ for (const name of hookNames) {
583
+ const hook = config[name];
584
+ if (hook !== undefined && typeof hook !== 'function') {
585
+ throw new TypeError(`${prefix}${name} must be a function, got ${describeArgument(hook)}.`);
586
+ }
587
+ }
505
588
  };
506
589
 
507
590
  /**
508
591
  * @typedef {Object} EffectConfiguration
509
592
  * @property {StepRunner} [onStep] - Wraps each Command's execution, and each Parallel's branches.
510
- * @property {RunWrapper} [onRun] - Fires once per runEffect call. It wraps the entire workflow execution.
511
- * @property {CommandInterceptor} [onBeforeCommand] - Intercepts a Command and any context passed to runEffect before execution.
593
+ * @property {RunWrapper} [onRun] - Wraps a whole run once, and is handed the flow's input when `effectPipe` built it.
594
+ * @property {CommandInterceptor} [onBeforeCommand] - Runs before each Command; a throw vetoes it.
512
595
  */
513
596
 
514
597
  /**
515
- * A per-call configuration: an `EffectConfiguration` plus `inherit`. With `inherit: true` (the default)
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.
598
+ * A per-call configuration. With `inherit: true` (the default) its hooks merge over the installed wiring, global
599
+ * outermost; with `inherit: false` that wiring is ignored.
518
600
  *
519
601
  * @typedef {EffectConfiguration & { inherit?: boolean }} CallConfiguration
520
602
  */
@@ -523,8 +605,8 @@ const rejectRetryKey = (config, source) => {
523
605
  let layers = [];
524
606
 
525
607
  /**
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.
608
+ * The installed layers merged into one configuration. A slot no layer defines is absent, and `interpret` picks the
609
+ * library default for it.
528
610
  * @type {EffectConfiguration}
529
611
  */
530
612
  let globalConfig = {};
@@ -535,21 +617,14 @@ const applyLayers = () => {
535
617
  };
536
618
 
537
619
  /**
538
- * Adds a configuration to the global wiring of the Effect runner: telemetry and the command
539
- * interceptor. Retry options are not part of it; they are per-use, passed to `Retry(effect, options)`.
540
- *
541
- * Each call adds one layer on top of those already installed and returns a function that removes that
542
- * layer, wherever it sits by then. Layers merge the way several configurations passed to one call do:
543
- * `onStep` and `onRun` are wrappers, so they nest with the earliest layer outermost and the latest
544
- * closest to the Command; and `onBeforeCommand` interceptors all run, in the order installed. So these
545
- * are the same:
620
+ * Adds a layer of global hooks and returns a function that removes it, wherever it sits by then. Layers merge as
621
+ * configurations passed to one call do, so these are the same:
546
622
  *
547
623
  * configureEffect(telemetryHooks(), recordingHooks({ sink }));
548
624
  * configureEffect(telemetryHooks()); configureEffect(recordingHooks({ sink }));
549
625
  *
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.
626
+ * A call with no arguments removes every layer. A call whose arguments are all `undefined`, as in
627
+ * `configureEffect(flag ? hooks : undefined)`, adds and removes nothing.
553
628
  *
554
629
  * @param {...(EffectConfiguration | undefined)} configs - Configurations merged into one layer, outermost first
555
630
  * @returns {() => void} Removes the layer this call added; a second call does nothing
@@ -560,9 +635,14 @@ const configureEffect = (...configs) => {
560
635
  applyLayers();
561
636
  return () => {};
562
637
  }
563
- const present = configs.filter(Boolean);
638
+ const present = configs.filter((config) => config !== undefined);
564
639
  // Before installing anything, so a refused call leaves the wiring untouched.
565
- present.forEach((config) => rejectRetryKey(config, 'configureEffect'));
640
+ present.forEach((config) => {
641
+ if (!isOptionsObject(config)) {
642
+ throw new TypeError(`configureEffect expects configuration objects, got ${describeConfiguration(config)}.`);
643
+ }
644
+ checkConfiguration(config, 'configureEffect', "configureEffect's ", hookNames);
645
+ });
566
646
  if (present.length === 0) return () => {};
567
647
  const layer = chainHooks(...present);
568
648
  layers = [...layers, layer];
@@ -575,65 +655,9 @@ const configureEffect = (...configs) => {
575
655
  };
576
656
 
577
657
  /**
578
- * @typedef {Object} StepStart
579
- * @property {string} name - The Command's identity: `meta.name`, else `cmd.name`, else 'anonymous'.
580
- * @property {string} type - 'Command', or 'Parallel' for a Parallel's decision.
581
- * @property {string} [path] - The Command's position in the Effect tree.
582
- */
583
-
584
- /**
585
- * @typedef {Object} StepEnd
586
- * @property {string} name
587
- * @property {string} type
588
- * @property {string} [path]
589
- * @property {any} [result] - What the Command returned, when it succeeded.
590
- * @property {any} [error] - What it threw, when it did not.
591
- * @property {number} durationMs
592
- */
593
-
594
- const now = () => (typeof performance === 'object' ? performance.now() : Date.now());
595
-
596
- /**
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.
599
- *
600
- * @param {(start: StepStart) => (end: StepEnd) => void} handler - Returns a finisher for the outcome
601
- * @returns {StepRunner}
602
- */
603
- const observeSteps = (handler) => async (name, type, op, path) => {
604
- /** @type {((end: StepEnd) => void) | undefined} */
605
- let finish;
606
- try {
607
- finish = handler({ name, type, path });
608
- } catch {
609
- finish = undefined;
610
- }
611
- const report = (/** @type {StepEnd} */ end) => {
612
- try {
613
- if (finish) finish(end);
614
- } catch {
615
- // Observation does not get to decide the outcome, so a broken observer is dropped.
616
- }
617
- };
618
-
619
- const started = now();
620
- try {
621
- const result = await op();
622
- report({ name, type, path, result, durationMs: now() - started });
623
- return result;
624
- } catch (error) {
625
- report({ name, type, path, error, durationMs: now() - started });
626
- throw error;
627
- }
628
- };
629
-
630
- /**
631
- * Merges several configurations into one, so independent concerns can share the hooks.
632
- *
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.
658
+ * Merges configurations into one, the first given outermost. `onStep` and `onRun` nest around `op`, and
659
+ * `onBeforeCommand` interceptors run in order, the first to throw vetoing the Command. A hook no configuration
660
+ * defines is left unset.
637
661
  *
638
662
  * @param {...(EffectConfiguration | undefined)} configs - Configurations to merge, outermost first
639
663
  * @returns {EffectConfiguration}
@@ -658,7 +682,8 @@ const chainHooks = (...configs) => {
658
682
  }
659
683
  if (runs.length) {
660
684
  merged.onRun = runs.reduceRight(
661
- (inner, outer) => (effect, op, flowName) => outer(effect, () => inner(effect, op, flowName), flowName)
685
+ (inner, outer) => (effect, op, flowName, initialInput) =>
686
+ outer(effect, () => inner(effect, op, flowName, initialInput), flowName, initialInput)
662
687
  );
663
688
  }
664
689
  if (interceptors.length) {
@@ -669,24 +694,27 @@ const chainHooks = (...configs) => {
669
694
  return merged;
670
695
  };
671
696
 
697
+ // #endregion
698
+ // #region Running flows
699
+
672
700
  /**
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
701
+ * The Failure a cancelled `Parallel` branch stops with, named so it is never mistaken for the failure that
702
+ * triggered the cancellation.
676
703
  * @returns {FailureState}
677
704
  */
678
- const cancelledBranch = (initialInput) =>
679
- Failure(Object.assign(new Error('Parallel branch cancelled.'), { name: 'ParallelCancelled' }), initialInput);
705
+ const cancelledBranch = () =>
706
+ Failure(Object.assign(new Error('Parallel branch cancelled.'), { name: 'ParallelCancelled' }));
680
707
 
681
708
  /**
682
- * Which branch, if any, cancelled a Parallel. `branch: null` means an enclosing Parallel cancelled it.
683
- * It is recorded as the Parallel's own step, since it is decided by timing and a replay cannot recompute it.
684
- * @typedef {{ cancelled: false } | { cancelled: true, branch: number | null }} ParallelDecision
709
+ * Which branch, if any, cancelled a Parallel, and how many branches it had; `branch: null` means an enclosing Parallel
710
+ * did. Timing decides it, so it is recorded as the Parallel's own step for a replay to hand back, and the count lets a
711
+ * replay see a branch added or removed.
712
+ * @typedef {{ cancelled: false, branches: number } | { cancelled: true, branch: number | null, branches: number }} ParallelDecision
685
713
  */
686
714
 
687
715
  /**
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.
716
+ * What running a Parallel's branches produced: each branch's outcome in array order, the decision, and the first
717
+ * error a branch threw, which the Parallel rethrows once its step has returned the decision.
690
718
  * @typedef {{
691
719
  * results: (SuccessState | FailureState | IoFaultState)[],
692
720
  * decision: ParallelDecision,
@@ -702,24 +730,30 @@ const cancelledBranch = (initialInput) =>
702
730
  const isBranchIndex = (branch, count) => Number.isInteger(branch) && branch >= 0 && branch < count;
703
731
 
704
732
  /**
705
- * Reads a recorded decision, or `undefined` for anything that is not one, which replays the Parallel
706
- * under timing.
733
+ * Names a number of branches, for a message.
734
+ * @param {number} count
735
+ * @returns {string}
736
+ */
737
+ const branchCount = (count) => (count === 1 ? '1 branch' : `${count} branches`);
738
+
739
+ /**
740
+ * Reads a recorded decision, or `undefined` for anything that is not one, which replays the Parallel by timing.
707
741
  * @param {any} value
708
742
  * @param {number} branches - How many branches the Parallel has, so a stale branch index is refused too
709
743
  * @returns {ParallelDecision | undefined}
710
744
  */
711
745
  const asDecision = (value, branches) => {
712
746
  if (!isObject(value)) return undefined;
713
- if (value.cancelled === false) return { cancelled: false };
747
+ if (value.cancelled === false) return { cancelled: false, branches };
714
748
  if (value.cancelled !== true) return undefined;
715
749
  const { branch } = value;
716
- if (branch === null) return { cancelled: true, branch: null };
717
- return isBranchIndex(branch, branches) ? { cancelled: true, branch } : undefined;
750
+ if (branch === null) return { cancelled: true, branch: null, branches };
751
+ return isBranchIndex(branch, branches) ? { cancelled: true, branch, branches } : undefined;
718
752
  };
719
753
 
720
754
  /**
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.
755
+ * Marks a step a replay reached that production never ran, in a branch a recorded decision cancelled. Only
756
+ * `resolveByPath` raises it, and `runCommand` and `runParallel` catch it and stop the branch there, as production did.
723
757
  */
724
758
  const replayCut = Symbol('pure-effect.replayCut');
725
759
 
@@ -735,27 +769,26 @@ const replayCutError = (path) =>
735
769
  );
736
770
 
737
771
  /**
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.
772
+ * A cancellation scope for one Parallel, linked to the enclosing one so cancellation nests.
740
773
  * @param {AbortSignal} [signal] - The enclosing Parallel's signal, if any
741
- * @returns {{ scope: AbortController | undefined, unlink: () => void }}
774
+ * @returns {{ scope: AbortController, unlink: () => void }}
742
775
  */
743
776
  const linkedScope = (signal) => {
744
- const scope = typeof AbortController === 'function' ? new AbortController() : undefined;
745
- const relay = () => scope?.abort();
746
- if (signal && scope) {
777
+ const scope = new AbortController();
778
+ const relay = () => scope.abort();
779
+ if (signal) {
747
780
  if (signal.aborted) scope.abort();
748
781
  else signal.addEventListener('abort', relay, { once: true });
749
782
  }
750
783
  const unlink = () => {
751
- if (signal && scope) signal.removeEventListener('abort', relay);
784
+ if (signal) signal.removeEventListener('abort', relay);
752
785
  };
753
786
  return { scope, unlink };
754
787
  };
755
788
 
756
789
  /**
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.
790
+ * Awaits every task, with at most `limit` in flight. Workers pull by index, so results land where the caller put
791
+ * the effect rather than where it finished, which keeps trace paths stable.
759
792
  * @param {(() => Promise<void>)[]} tasks
760
793
  * @param {number} [limit]
761
794
  * @returns {Promise<void>}
@@ -773,8 +806,8 @@ const runBounded = async (tasks, limit) => {
773
806
  };
774
807
 
775
808
  /**
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.
809
+ * Sleeps, but stops early when the branch is cancelled. Resolves either way; the caller's abort check turns a
810
+ * cancelled wait into a Failure.
778
811
  *
779
812
  * @param {number} ms
780
813
  * @param {AbortSignal} [signal]
@@ -786,8 +819,8 @@ const delayFor = (ms, signal) =>
786
819
  setTimeout(resolve, ms);
787
820
  return;
788
821
  }
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.
822
+ // A listener added to an aborted signal never runs, and a Retry often backs off on a signal that has already
823
+ // fired.
791
824
  if (signal.aborted) {
792
825
  resolve();
793
826
  return;
@@ -803,373 +836,406 @@ const delayFor = (ms, signal) =>
803
836
  signal.addEventListener('abort', onAbort, { once: true });
804
837
  });
805
838
 
806
- const interpret =
807
- /**
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.
812
- *
813
- * @param {Effect} effect
814
- * @param {any} [context]
815
- * @param {CallConfiguration} [callConfig]
816
- * @param {boolean} [fastRetry]
817
- * @returns {Promise<SuccessState | FailureState>}
818
- */
819
- async function interpret(effect, context = {}, callConfig = {}, fastRetry = false) {
820
- rejectRetryKey(callConfig, "runEffect's callConfig");
821
- const { inherit = true, ...local } = callConfig;
822
- // Not coerced: `'false'` quietly inheriting everything is the behaviour this option exists to remove.
823
- if (typeof inherit !== 'boolean') {
824
- throw new TypeError(`callConfig.inherit must be true or false, got ${JSON.stringify(inherit)}.`);
825
- }
826
- // Merged the way `configureEffect` merges layers. A call with no hooks of its own skips the merge.
827
- const base = inherit ? globalConfig : {};
828
- const resolved = Object.keys(local).length ? chainHooks(base, local) : base;
829
- const localStepRunner = resolved.onStep || defaultStepRunner;
830
- const localRunWrapper = resolved.onRun || defaultRunWrapper;
831
- const localCommandInterceptor = resolved.onBeforeCommand || defaultCommandInterceptor;
832
-
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
- *
838
- * @param {Effect} eff
839
- * @param {AbortSignal} [signal] - Cancellation for this subtree, set for `Parallel` branches.
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.
843
- * @returns {Promise<SuccessState | FailureState | IoFaultState>}
844
- */
845
- async function execute(eff, signal, path = '') {
846
- let step = 0;
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);
852
- if (eff.type === 'Ask') {
853
- from = eff;
854
- eff = eff.next(context);
855
- continue;
856
- }
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
- }
839
+ /**
840
+ * What every part of the interpreter needs from one run: the context `Ask` reads, whether retries wait, and the two
841
+ * hooks a step calls, resolved from the installed wiring and the call's own configuration.
842
+ * @typedef {{
843
+ * context: any,
844
+ * fastRetry: boolean,
845
+ * onStep: StepRunner,
846
+ * onBeforeCommand: CommandInterceptor
847
+ * }} Runtime
848
+ */
873
849
 
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);
912
- }
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/`);
850
+ /**
851
+ * The interpreter, which `runEffect` and `replayEffect` share, so a replay cannot drift from a run. Only a replay
852
+ * sets `fastRetry`, which waits no time between retry attempts.
853
+ *
854
+ * @param {Effect} effect
855
+ * @param {any} [context]
856
+ * @param {CallConfiguration} [callConfig]
857
+ * @param {boolean} [fastRetry]
858
+ * @returns {Promise<SuccessState | FailureState>}
859
+ */
860
+ const interpret = async (effect, context = {}, callConfig = {}, fastRetry = false) => {
861
+ if (!isOptionsObject(callConfig)) {
862
+ throw new TypeError(`runEffect's callConfig must be an object, got ${describeConfiguration(callConfig)}.`);
863
+ }
864
+ checkConfiguration(callConfig, "runEffect's callConfig", 'callConfig.', [...hookNames, 'inherit']);
865
+ const { inherit = true, ...local } = callConfig;
866
+ // Not coerced, so `'false'` cannot inherit everything.
867
+ if (typeof inherit !== 'boolean') {
868
+ throw new TypeError(`callConfig.inherit must be true or false, got ${describeArgument(inherit)}.`);
869
+ }
870
+ const base = inherit ? globalConfig : {};
871
+ const resolved = Object.keys(local).length ? chainHooks(base, local) : base;
872
+ /** @type {Runtime} */
873
+ const runtime = {
874
+ context,
875
+ fastRetry,
876
+ onStep: resolved.onStep || defaultStepRunner,
877
+ onBeforeCommand: resolved.onBeforeCommand || defaultCommandInterceptor
878
+ };
879
+ const onRun = resolved.onRun || defaultRunWrapper;
880
+ // An I/O fault becomes a plain Failure here, where every outcome leaves.
881
+ const op = async () => asOutcome(await execute(runtime, effect));
882
+ return onRun(effect, op, context?.flowName || '', flowInputs.get(effect));
883
+ };
884
+
885
+ /**
886
+ * Walks a subtree until it reaches a Success, a Failure or an I/O fault. Every node but `Ask` runs in its
887
+ * own function, which returns a Success carrying the value for the node's `next`, or what stops the subtree.
888
+ *
889
+ * @param {Runtime} runtime
890
+ * @param {Effect} eff
891
+ * @param {AbortSignal} [signal] - Cancellation for this subtree, set for `Parallel` branches.
892
+ * @param {string} [path] - This subtree's prefix. Each `Parallel` branch and `Retry` attempt opens its own, so
893
+ * a path depends only on the tree's shape, never on the order branches finish in.
894
+ * @returns {Promise<SuccessState | FailureState | IoFaultState>}
895
+ */
896
+ const execute = async (runtime, eff, signal, path = '') => {
897
+ let step = 0;
898
+ /** @type {Effect | undefined} The node whose `next` returned `eff`, which an error names. */
899
+ let from;
900
+ while (isPending(eff)) {
901
+ // A Command already in flight cannot be stopped, but the next one never starts.
902
+ if (signal?.aborted) return cancelledBranch();
903
+ if (eff.type === 'Ask') {
904
+ from = eff;
905
+ eff = eff.next(runtime.context);
906
+ continue;
928
907
  }
908
+ const stepPath = `${path}${step++}`;
909
+ const outcome =
910
+ eff.type === 'Retry'
911
+ ? await runRetry(runtime, eff, signal, stepPath)
912
+ : eff.type === 'Parallel'
913
+ ? await runParallel(runtime, eff, signal, stepPath)
914
+ : await runCommand(runtime, eff, signal, stepPath);
915
+ if (outcome.type !== 'Success') return outcome;
916
+ // Outside every catch: `next` and the pure steps it reaches are code, not I/O, so a throw there
917
+ // rejects the run.
918
+ from = eff;
919
+ eff = eff.next(outcome.value);
920
+ }
921
+ if (isOutcome(eff)) return eff;
922
+ throw effectTypeError(eff, from ? nextOf(from) : 'The flow');
923
+ };
929
924
 
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));
925
+ /**
926
+ * Runs a Retry's wrapped tree until it succeeds or runs out of attempts, then its fallback if it has one.
927
+ * Each attempt opens its own path prefix, and so does the fallback.
928
+ *
929
+ * @param {Runtime} runtime
930
+ * @param {RetryState} retry
931
+ * @param {AbortSignal | undefined} signal
932
+ * @param {string} stepPath
933
+ * @returns {Promise<SuccessState | FailureState | IoFaultState>}
934
+ */
935
+ const runRetry = async (runtime, retry, signal, stepPath) => {
936
+ const given = Object.entries(retry.options ?? {}).filter(([, value]) => value !== undefined);
937
+ const opts = /** @type {typeof defaultRetryOptions & RetryState['options']} */ ({
938
+ ...defaultRetryOptions,
939
+ ...Object.fromEntries(given)
940
+ });
941
+ const { attempts, onExhausted } = opts;
942
+ let lastError;
943
+ for (let attempt = 0; attempt <= attempts; attempt++) {
944
+ // `fastRetry` waits for no time rather than skipping the wait, so branches replayed by timing still
945
+ // interleave.
946
+ if (attempt > 0) {
947
+ await delayFor(runtime.fastRetry ? 0 : opts.delay * Math.pow(opts.backoff, attempt - 1), signal);
979
948
  }
949
+ // After the wait, so a branch cancelled mid-backoff makes no further attempt.
950
+ if (signal?.aborted) return cancelledBranch();
951
+ const result = await execute(runtime, retry.effect, signal, `${stepPath}r${attempt}/`);
952
+ // Only an I/O fault is retried. An abort passes through unwrapped.
953
+ if (result.type !== 'IoFault') return result;
954
+ lastError = result.error;
955
+ }
956
+ const exhausted = { retryExhausted: true, lastError, attempts };
957
+ // A fault too, so an enclosing Retry retries this one.
958
+ if (typeof onExhausted !== 'function') return IoFault(exhausted);
959
+ if (signal?.aborted) return cancelledBranch();
960
+ // A failing fallback propagates as it is, not wrapped as another exhaustion.
961
+ return execute(runtime, asEffect(onExhausted(exhausted), "Retry option 'onExhausted'"), signal, `${stepPath}rf/`);
962
+ };
980
963
 
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')
964
+ /**
965
+ * Runs a Parallel as one step, whose `op` runs the branches and returns the decision, so the decision is
966
+ * recorded and a replay can hand it back.
967
+ *
968
+ * @param {Runtime} runtime
969
+ * @param {ParallelState} parallel
970
+ * @param {AbortSignal | undefined} signal
971
+ * @param {string} stepPath
972
+ * @returns {Promise<SuccessState | FailureState | IoFaultState>}
973
+ */
974
+ const runParallel = async (runtime, parallel, signal, stepPath) => {
975
+ const branchPath = `${stepPath}p`;
976
+ const options = parallel.options ?? {};
977
+ const { settled } = options;
978
+ // Cast rather than annotated, since only `op` assigns it.
979
+ let branchRun = /** @type {BranchRun | undefined} */ (undefined);
980
+ // A replay passes the recorded decision, from `replayHook`'s onStep; a live run passes nothing.
981
+ const op = async (/** @type {any} */ recorded) => {
982
+ branchRun = await runBranches(runtime, parallel.effects, options, signal, branchPath, recorded);
983
+ return branchRun.decision;
984
+ };
985
+ try {
986
+ await runtime.onStep('Parallel', 'Parallel', op, branchPath);
987
+ } catch (e) {
988
+ // A cut from `fromTrace`: production had already stopped the branch this Parallel is in.
989
+ if (hasMark(e, replayCut)) return cancelledBranch();
990
+ throw e;
991
+ }
992
+ if (!branchRun) {
993
+ throw new TypeError(
994
+ `An onStep hook returned without letting op run the Parallel at path '${branchPath}'. ` +
995
+ 'A hook has to call op for a Parallel and pass on what it returns or throws, because op ' +
996
+ 'runs its branches.'
997
+ );
998
+ }
999
+ const { results, decision, thrown } = branchRun;
1000
+ // Rethrown only now, after the step has returned its decision, so the decision is recorded.
1001
+ if (thrown) throw thrown.error;
1002
+ if (settled) return Success(results.map(asOutcome));
1003
+ const failure =
1004
+ decision.cancelled && decision.branch !== null
1005
+ ? results[decision.branch]
1006
+ : results.find((r) => r.type !== 'Success');
1007
+ if (failure) return failure;
1008
+ return Success(results.map((r) => /** @type {SuccessState} */ (r).value));
1009
+ };
1010
+
1011
+ /**
1012
+ * Runs a Parallel's branches and decides which branch, if any, cancelled the others. Live, timing decides.
1013
+ * A recorded cancellation is reproduced rather than recomputed: no branch cancels another, each stops where
1014
+ * its recording stops, and the recorded branch's failure is the result.
1015
+ *
1016
+ * @param {Runtime} runtime
1017
+ * @param {Effect[]} effects
1018
+ * @param {ParallelOptions} options
1019
+ * @param {AbortSignal | undefined} signal - The enclosing Parallel's cancellation, if any
1020
+ * @param {string} branchPath
1021
+ * @param {any} recorded - The recorded decision, which `replayHook`'s onStep passes to the Parallel's
1022
+ * `op`; undefined in a live run
1023
+ * @returns {Promise<BranchRun>}
1024
+ */
1025
+ const runBranches = async (runtime, effects, options, signal, branchPath, recorded) => {
1026
+ const { limit, settled } = options;
1027
+ // A recorded branch past the end means the flow changed shape. A negative one is not a decision, and
1028
+ // replays by timing.
1029
+ const recordedBranch = recorded?.cancelled === true ? recorded.branch : undefined;
1030
+ const pastTheEnd = Number.isInteger(recordedBranch) && recordedBranch >= effects.length;
1031
+ if (pastTheEnd) {
1032
+ throw timeParadoxAt(
1033
+ `path '${branchPath}'`,
1034
+ `the recorded run was cancelled by branch ${recordedBranch}, but this Parallel has ` +
1035
+ `${branchCount(effects.length)}.`,
1036
+ { path: branchPath, branch: recordedBranch }
1037
+ );
1038
+ }
1039
+ // So does a different number of branches, checked before any branch runs, since a new branch's steps are ones
1040
+ // the trace lacks and would run live under onMissing: 'execute'. A decision recorded without a count is not
1041
+ // judged by one.
1042
+ const recordedBranches = recorded?.branches;
1043
+ if (Number.isInteger(recordedBranches) && recordedBranches !== effects.length) {
1044
+ throw timeParadoxAt(
1045
+ `path '${branchPath}'`,
1046
+ `this Parallel has ${branchCount(effects.length)}, and the trace recorded ${recordedBranches}.`,
1047
+ { path: branchPath, expected: recordedBranches, actual: effects.length }
1048
+ );
1049
+ }
1050
+ const forced = asDecision(recorded, effects.length);
1051
+ const reproducing = forced !== undefined && forced.cancelled;
1052
+ // Live, a throw cancels the others, and so does a failure unless `settled`. Reproducing, nothing does,
1053
+ // since the recording already says where each branch stops.
1054
+ const { results, thrown, trigger, cancelled } = await settleBranches(
1055
+ runtime,
1056
+ effects,
1057
+ limit,
1058
+ signal,
1059
+ branchPath,
1060
+ reproducing ? () => false : (threw, result) => threw || (!settled && result.type !== 'Success')
1061
+ );
1062
+ if (reproducing) {
1063
+ // A branch that threw has no result to check, and its throw is what is rethrown.
1064
+ const recordedTriggerSucceeded = !thrown && forced.branch !== null && results[forced.branch].type === 'Success';
1065
+ if (recordedTriggerSucceeded) {
1066
+ throw timeParadoxAt(
1067
+ `path '${branchPath}'`,
1068
+ `the recorded run was cancelled by branch ${forced.branch}, which did not fail in this replay.`,
1069
+ { path: branchPath, branch: forced.branch }
1019
1070
  );
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
- );
1030
- }
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
1071
  }
1072
+ return { results, decision: forced, thrown };
1073
+ }
1074
+ const branches = effects.length;
1075
+ /** @type {ParallelDecision} */
1076
+ const decision =
1077
+ trigger >= 0
1078
+ ? { cancelled: true, branch: trigger, branches }
1079
+ : cancelled
1080
+ ? { cancelled: true, branch: null, branches }
1081
+ : { cancelled: false, branches };
1082
+ return { results, decision, thrown };
1083
+ };
1042
1084
 
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) => {
1070
- try {
1071
- results[i] = await execute(branch, scope?.signal, `${branchPath}${i}/`);
1072
- } catch (error) {
1073
- thrown[i] = { error };
1074
- }
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();
1079
- }
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();
1089
- }
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
- };
1085
+ /**
1086
+ * Runs every branch to completion, at most `limit` at once, under one cancellation scope linked to the
1087
+ * enclosing one. The first branch to settle in a way `cancelsOthers` accepts cancels the rest, and is the
1088
+ * trigger.
1089
+ *
1090
+ * @param {Runtime} runtime
1091
+ * @param {Effect[]} effects
1092
+ * @param {number | undefined} limit
1093
+ * @param {AbortSignal | undefined} signal
1094
+ * @param {string} branchPath
1095
+ * @param {(threw: boolean, result: SuccessState | FailureState | IoFaultState) => boolean} cancelsOthers
1096
+ * @returns {Promise<{
1097
+ * results: (SuccessState | FailureState | IoFaultState)[],
1098
+ * thrown: { error: unknown } | undefined,
1099
+ * trigger: number,
1100
+ * cancelled: boolean
1101
+ * }>}
1102
+ */
1103
+ const settleBranches = async (runtime, effects, limit, signal, branchPath, cancelsOthers) => {
1104
+ const { scope, unlink } = linkedScope(signal);
1105
+ /** @type {(SuccessState | FailureState | IoFaultState)[]} */
1106
+ const results = new Array(effects.length);
1107
+ // Held rather than rethrown, so every branch still settles and no `limit` worker stops early.
1108
+ /** @type {{ error: unknown }[]} */
1109
+ const thrown = new Array(effects.length);
1110
+ // Whether each branch cancelled the others itself, rather than being cancelled.
1111
+ const triggered = new Array(effects.length).fill(false);
1112
+ const settle = async (/** @type {Effect} */ branch, /** @type {number} */ i) => {
1113
+ try {
1114
+ results[i] = await execute(runtime, branch, scope.signal, `${branchPath}${i}/`);
1115
+ } catch (error) {
1116
+ thrown[i] = { error };
1097
1117
  }
1098
-
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);
1118
+ // Read-then-abort is atomic here, so exactly one branch is the trigger.
1119
+ if (cancelsOthers(thrown[i] !== undefined, results[i])) {
1120
+ if (!scope.signal.aborted) triggered[i] = true;
1121
+ scope.abort();
1157
1122
  }
1123
+ };
1124
+ try {
1125
+ // Awaits every branch, so no cancelled work runs on after the Parallel returns.
1126
+ await runBounded(
1127
+ effects.map((branch, i) => () => settle(branch, i)),
1128
+ limit
1129
+ );
1130
+ } finally {
1131
+ unlink();
1132
+ }
1133
+ return {
1134
+ results,
1135
+ // The first by array order, since every branch has settled by now.
1136
+ thrown: thrown.find(Boolean),
1137
+ trigger: triggered.indexOf(true),
1138
+ cancelled: scope.signal.aborted
1139
+ };
1140
+ };
1158
1141
 
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.
1162
- const rootInput = effect?.initialInput;
1163
- const run = async () => {
1164
- const result = asOutcome(await execute(effect));
1165
- return result.type === 'Failure' && rootInput !== undefined ? Failure(result.error, rootInput) : result;
1166
- };
1167
- return localRunWrapper(effect, run, context?.flowName || '');
1142
+ /**
1143
+ * A Command's function wrapped as the `op` a hook is handed, with what its calls did, so `runCommand` can tell a hook
1144
+ * that lost the result, or did not wait for it, from one that answered without calling `op`. `running` and
1145
+ * `succeeded` describe the latest call, which resets them as it starts, and `value` is the last value a call returned.
1146
+ * @typedef {{
1147
+ * op: () => Promise<unknown>,
1148
+ * latest: Promise<unknown> | undefined,
1149
+ * running: boolean,
1150
+ * succeeded: boolean,
1151
+ * value: unknown
1152
+ * }} TrackedCalls
1153
+ */
1154
+
1155
+ /**
1156
+ * @param {() => unknown} call - Calls the Command's function
1157
+ * @returns {TrackedCalls}
1158
+ */
1159
+ const trackCalls = (call) => {
1160
+ /** @type {TrackedCalls} */
1161
+ const calls = {
1162
+ // Async, so a hook always gets a promise, even from a synchronous function.
1163
+ op: () => (calls.latest = invoke()),
1164
+ latest: undefined,
1165
+ running: false,
1166
+ succeeded: false,
1167
+ value: undefined
1168
1168
  };
1169
+ const invoke = async () => {
1170
+ calls.succeeded = false;
1171
+ calls.running = true;
1172
+ try {
1173
+ calls.value = await call();
1174
+ calls.succeeded = true;
1175
+ return calls.value;
1176
+ } finally {
1177
+ calls.running = false;
1178
+ }
1179
+ };
1180
+ return calls;
1181
+ };
1182
+
1183
+ /**
1184
+ * Runs one Command. A throw from an interceptor vetoes the Command, an abort. A throw from the step runner is an I/O
1185
+ * fault, unless the function had already succeeded, when it is a bug in a hook and rejects the run. A harness error
1186
+ * is rethrown from either. DESIGN.md's "Where a throw comes from" has the rest.
1187
+ *
1188
+ * @param {Runtime} runtime
1189
+ * @param {CommandState} command
1190
+ * @param {AbortSignal | undefined} signal
1191
+ * @param {string} cmdPath
1192
+ * @returns {Promise<SuccessState | FailureState | IoFaultState>}
1193
+ */
1194
+ const runCommand = async (runtime, command, signal, cmdPath) => {
1195
+ const cmdName = commandName(command);
1196
+ const { cmd } = command;
1197
+ // Only inside a Parallel, and only to a function that declares a parameter. A parameter with a default
1198
+ // value does not count toward `length`, so `nanoid(size = 21)` keeps its default; a plain first parameter
1199
+ // the function treats as optional still takes the signal, a documented sharp edge.
1200
+ const takesSignal = signal !== undefined && cmd.length > 0;
1201
+ const calls = trackCalls(() => (takesSignal ? cmd(signal) : cmd()));
1202
+ try {
1203
+ await runtime.onBeforeCommand(command, runtime.context);
1204
+ } catch (e) {
1205
+ if (hasMark(e, harnessError)) throw e;
1206
+ return Failure(e);
1207
+ }
1208
+ // Again, since an interceptor can wait (a rate limiter, say) while a sibling fails.
1209
+ if (signal?.aborted) return cancelledBranch();
1210
+ try {
1211
+ const returned = await runtime.onStep(cmdName, 'Command', calls.op, cmdPath);
1212
+ // A hook that returned without awaiting `op` is judged as though it had awaited it.
1213
+ const unwaited = returned === undefined && calls.running;
1214
+ if (unwaited) await calls.latest;
1215
+ // A hook that dropped the result would hand `next` `undefined`. Only `undefined` is refused, so a hook can
1216
+ // return a copy.
1217
+ if (returned === undefined && calls.succeeded && calls.value !== undefined) {
1218
+ throw new TypeError(
1219
+ `An onStep hook called op for '${cmdName}' at path '${cmdPath}' and returned undefined` +
1220
+ `${unwaited ? ' before op had finished' : ''}, although the Command returned a value. ` +
1221
+ 'A hook has to await op() and return what it returns.'
1222
+ );
1223
+ }
1224
+ return Success(returned);
1225
+ } catch (e) {
1226
+ // A cut from `fromTrace`: production stopped this branch before this step.
1227
+ if (hasMark(e, replayCut)) return cancelledBranch();
1228
+ if (hasMark(e, harnessError)) throw e;
1229
+ // After the function succeeded, a throw is a bug in a hook, the lost result above included. From a hook that
1230
+ // never called `op` it is a fault, which is how replay reports a recorded error.
1231
+ if (calls.succeeded) throw e;
1232
+ return IoFault(e);
1233
+ }
1234
+ };
1169
1235
 
1170
1236
  /**
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.
1237
+ * Runs a flow: executes its Commands, resolving `Ask` with the context and running `Retry` and `Parallel`. `onRun`
1238
+ * fires once per call, around every Retry attempt.
1173
1239
  *
1174
1240
  * @param {Effect} effect - The Effect tree returned by a pipeline
1175
1241
  * @param {any} [context] - Optional context object. Passed to Ask continuations and the Command Interceptor.
@@ -1179,52 +1245,67 @@ const interpret =
1179
1245
  */
1180
1246
  const runEffect = (effect, context, callConfig) => interpret(effect, context, callConfig);
1181
1247
 
1248
+ // #endregion
1249
+ // #region Recording and replay
1250
+
1182
1251
  /**
1183
- * The step a replay is asking about. `path` is the Command's position in the Effect tree and is stable
1184
- * across runs; `index` is its position in this run's completion order, which is not stable for a flow
1185
- * containing `Parallel`. Prefer `path` when writing a Resolver. A step whose `type` is 'Parallel' asks
1186
- * for a Parallel's recorded decision; it does not advance `index`, and anything but a decision replays
1187
- * that Parallel under timing.
1252
+ * The step a replay is asking about. `path` is its position in the Effect tree, stable across runs; `index` is its
1253
+ * position in completion order, which is not stable for a flow with a `Parallel`. A step whose `type` is 'Parallel'
1254
+ * asks for the recorded decision and does not advance `index`; an answer that holds no decision replays that
1255
+ * Parallel by timing.
1188
1256
  * @typedef {{ index: number, name: string, type: string, path?: string }} ReplayStep
1189
1257
  */
1190
1258
 
1191
1259
  /**
1192
- * What production observed for a step. `{ result }` is handed to the Command's
1193
- * `next`; `{ error }` is thrown so the interpreter produces a Failure. A resolver
1194
- * returning `undefined` means "not recorded".
1260
+ * What production observed for a step. `{ result }` is handed to the Command's `next`; `{ error }` is thrown. A
1261
+ * Resolver returning `undefined` means "not recorded". Any other answer, `null` included, or a throw from the
1262
+ * Resolver, rejects the replay.
1195
1263
  *
1196
1264
  * @typedef {{ result: any } | { error: any }} ReplayOutcome
1197
1265
  */
1198
1266
 
1267
+ /**
1268
+ * Whether a Resolver's answer is an outcome: an object holding `result` or `error`.
1269
+ * @param {any} value
1270
+ * @returns {boolean}
1271
+ */
1272
+ const isReplayOutcome = (value) => isObject(value) && ('result' in value || 'error' in value);
1273
+
1199
1274
  /** @typedef {(step: ReplayStep) => ReplayOutcome | undefined} Resolver */
1200
1275
 
1201
1276
  /**
1202
- * A recorded step: a Command's result or error, or a Parallel's decision, recorded as `command`
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.
1205
- * `threw` marks a step that threw. The `error` key alone cannot, since JSON drops it when the value is
1206
- * `undefined`, as it is for `reject()` with no argument or an error `redact` removed.
1207
- * @typedef {{ command: string, path?: string, result?: any, threw?: true, error?: any, durationMs?: number }} TraceEntry
1277
+ * A recorded step: a Command's result or error, or a Parallel's decision, recorded as `command` 'Parallel' at the
1278
+ * Parallel's own path. `threw` marks a throw, since JSON drops an `error` key whose value is `undefined`.
1279
+ * `unrecorded` says why the entry holds no value. `durationMs` is rounded to microseconds.
1280
+ * @typedef {{
1281
+ * command: string,
1282
+ * path?: string,
1283
+ * result?: any,
1284
+ * threw?: true,
1285
+ * error?: any,
1286
+ * unrecorded?: UnrecordedCause,
1287
+ * durationMs?: number
1288
+ * }} TraceEntry
1208
1289
  */
1209
1290
 
1210
1291
  /**
1211
- * The reference trace format produced by `recorder`. A convenience, not a contract:
1212
- * `replayEffect` takes a Resolver, so any storage shape works.
1292
+ * The reference trace format produced by `recorder`. A convenience, not a contract: `replayEffect` takes a Resolver,
1293
+ * so any storage shape works. `unrecorded` says why the trace holds no `initialInput` or `context`.
1213
1294
  * @typedef {{
1214
1295
  * flowName?: string,
1215
1296
  * version?: string,
1216
1297
  * initialInput?: any,
1217
1298
  * context?: any,
1299
+ * unrecorded?: { initialInput?: UnrecordedCause, context?: UnrecordedCause },
1218
1300
  * dropped?: number,
1219
1301
  * trace: TraceEntry[]
1220
1302
  * }} TraceLog
1221
1303
  */
1222
1304
 
1223
1305
  /**
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.
1306
+ * Marks a replay fault: the trace cannot answer the flow, or disagrees with it. `replayEffect` turns one into a
1307
+ * `Failure` at its own boundary. An `EffectTypeError` has no such mark and keeps propagating, since a malformed flow
1308
+ * is a bug in the flow rather than in the trace.
1228
1309
  */
1229
1310
  const replayFault = Symbol('pure-effect.replayFault');
1230
1311
 
@@ -1244,28 +1325,34 @@ const replayError = (message, props = {}) =>
1244
1325
  );
1245
1326
 
1246
1327
  /**
1247
- * Signals that the flow being replayed asked for a different Command than the trace
1248
- * recorded, which means the code has diverged from the recorded run.
1328
+ * Builds a `TimeParadox`: the flow no longer matches its recording at this point.
1329
+ * @param {string} at - Where, as the message names it
1330
+ * @param {string} detail - How the flow and the recording disagree
1331
+ * @param {Object} props - The fields that locate it, such as `path`
1332
+ * @returns {Error}
1333
+ */
1334
+ const timeParadoxAt = (at, detail, props) =>
1335
+ replayError(`Time paradox at ${at}: ${detail}`, { name: 'TimeParadox', ...props });
1336
+
1337
+ /**
1338
+ * Signals that the flow being replayed asked for a different Command than the trace recorded.
1249
1339
  * @param {ReplayStep} step - The step the flow asked for
1250
1340
  * @param {string} recorded - The command name the trace holds at that position
1251
1341
  * @returns {Error}
1252
1342
  */
1253
1343
  const timeParadox = (step, recorded) =>
1254
- replayError(
1255
- `Time paradox at ${step.path !== undefined ? `path '${step.path}'` : `step ${step.index}`}: ` +
1256
- `flow asked for '${step.name}', trace recorded '${recorded}'`,
1257
- {
1258
- name: 'TimeParadox',
1259
- index: step.index,
1260
- path: step.path,
1261
- expected: recorded,
1262
- actual: step.name
1263
- }
1344
+ timeParadoxAt(
1345
+ step.path !== undefined ? `path '${step.path}'` : `step ${step.index}`,
1346
+ `flow asked for '${step.name}', trace recorded '${recorded}'`,
1347
+ { index: step.index, path: step.path, expected: recorded, actual: step.name }
1264
1348
  );
1265
1349
 
1350
+ /** The keys `serializeError` carries whatever their enumerability, which a native Error sets as hidden. */
1351
+ const carriedKeys = ['name', 'message', 'cause'];
1352
+
1266
1353
  /**
1267
- * Converts a thrown value into something JSON can carry. `message` and `stack` are
1268
- * non-enumerable on Error, so a plain `JSON.stringify` would silently drop them.
1354
+ * Converts a thrown value into something JSON can carry, including the non-enumerable `message`, `stack` and
1355
+ * `cause` a plain `JSON.stringify` would drop.
1269
1356
  *
1270
1357
  * @param {any} e - The thrown value
1271
1358
  * @param {boolean} [withStack] - Include the stack (off by default: noisy, leaks paths)
@@ -1276,52 +1363,44 @@ const serializeError = (e, withStack, ancestors = new Set()) => {
1276
1363
  if (!(e instanceof Error)) return e;
1277
1364
  /** @type {any} */
1278
1365
  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
1366
+ // A chain that loops back is cut where it returns, with the name and message only. Only ancestors count, so an
1281
1367
  // error that merely appears twice is carried in full both times.
1282
1368
  if (ancestors.has(e)) return out;
1283
1369
  ancestors.add(e);
1284
1370
  if (withStack) out.stack = e.stack;
1285
- // `cause` is non-enumerable too, and it can itself be an Error, so it is carried recursively.
1286
1371
  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.
1372
+ // An AggregateError's hidden `errors` goes under its own key, so revival can tell it from an enumerable `errors`,
1373
+ // which is data and is copied below as it is.
1289
1374
  const errors = /** @type {any} */ (e).errors;
1290
1375
  const hasHiddenErrors = Array.isArray(errors) && !Object.prototype.propertyIsEnumerable.call(e, 'errors');
1291
1376
  if (hasHiddenErrors) {
1292
1377
  out.__errors = errors.map((x) => serializeError(x, withStack, ancestors));
1293
1378
  }
1294
1379
  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 {}.
1380
+ // An assigned `name`, `message` or `cause` is enumerable. It is carried above already, so it is only listed, for
1381
+ // revival to restore as it was.
1298
1382
  const shown = carriedKeys.filter((k) => Object.prototype.propertyIsEnumerable.call(e, k));
1299
1383
  if (shown.length > 0) out.__enumerable = shown;
1300
1384
  for (const k of Object.keys(e)) if (!carriedKeys.includes(k)) out[k] = /** @type {any} */ (e)[k];
1301
1385
  return out;
1302
1386
  };
1303
1387
 
1304
- /** The keys `serializeError` carries whatever their enumerability, which a native Error sets as hidden. */
1305
- const carriedKeys = ['name', 'message', 'cause'];
1306
-
1307
1388
  /**
1308
- * Rebuilds an Error from `serializeError` output. Non-Error values pass through, so a
1309
- * Command that rejected with a string still replays as a string.
1389
+ * Rebuilds an Error from `serializeError` output. Non-Error values pass through, so a Command that rejected with a
1390
+ * string still replays as a string.
1310
1391
  * @param {any} v - A serialized error, or any other recorded value
1311
1392
  * @returns {any}
1312
1393
  */
1313
1394
  const reviveError = (v) => {
1314
1395
  if (!isObject(v) || v.__error !== true) return v;
1315
1396
  const e = new Error(v.message);
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.
1397
+ // `name`, `message`, `cause` and `errors` get the enumerability the original had, so a revived error deep-equals
1398
+ // the one the Command threw.
1319
1399
  const shown = Array.isArray(v.__enumerable) ? v.__enumerable : [];
1320
1400
  const restore = (/** @type {string} */ key, /** @type {any} */ value) =>
1321
1401
  Object.defineProperty(e, key, { enumerable: shown.includes(key), configurable: true, writable: true, value });
1322
1402
  restore('name', v.name);
1323
1403
  if (shown.includes('message')) restore('message', v.message);
1324
- // The markers are dropped, and `name` and `message` are already set.
1325
1404
  const alreadyHandled = ['__error', '__enumerable', 'name', 'message'];
1326
1405
  for (const [k, val] of Object.entries(v)) {
1327
1406
  if (alreadyHandled.includes(k)) continue;
@@ -1334,157 +1413,223 @@ const reviveError = (v) => {
1334
1413
 
1335
1414
  /**
1336
1415
  * @typedef {Object} RecorderOptions
1337
- * @property {(value: any, name: string, kind: string) => any} [redact] - Scrubs every value a trace holds:
1338
- * each Command's result, each serialized error, and the `initialInput` and `context` stored on the
1339
- * trace itself. `kind` is `'result'`, `'error'`, `'initialInput'`, or `'context'`, and `name` is the
1340
- * Command's name for the first two and the kind for the last two. It is the single place PII is kept
1341
- * out of a trace, so it has to see all four. It is handed a copy, so changing the value in place
1342
- * never reaches the run.
1416
+ * @property {(value: any, name: string, kind: string) => any} [redact] - Scrubs every value a trace holds. `kind` is
1417
+ * `'result'`, `'error'`, `'initialInput'` or `'context'`, and `name` is the Command's name for the first
1418
+ * two and the kind for the others. It is handed a copy; a value it throws on is left out and marked.
1343
1419
  * @property {number} [maxEntries] - Caps trace length; further steps are counted in `dropped`, not stored.
1344
1420
  * @property {boolean} [stack] - Records stack traces for thrown errors.
1345
1421
  */
1346
1422
 
1423
+ /** @type {Record<string, OptionRule>} */
1424
+ const recorderOptionRules = {
1425
+ redact: [isFunction, 'a function'],
1426
+ maxEntries: [(value) => value === Infinity || isPositiveInteger(value), 'a positive integer or Infinity'],
1427
+ stack: [isBoolean, 'true or false']
1428
+ };
1429
+
1347
1430
  /**
1348
- * @typedef {Object} TraceMeta
1349
- * @property {any} [initialInput] - The value the flow was called with.
1350
- * @property {string} [flowName]
1351
- * @property {any} [context] - Context passed to `runEffect`; required to replay `Ask`.
1352
- * @property {string} [version] - Commit or build id, so a replay can detect a stale trace.
1431
+ * Why the recorder stored nothing for a value: `redact` threw on it, or copying it threw. It stores nothing rather
1432
+ * than a stand-in, since a stand-in would replay as what production saw.
1433
+ * @typedef {'redact' | 'copy'} UnrecordedCause
1353
1434
  */
1354
1435
 
1355
1436
  /**
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}
1437
+ * What recording a value produced: the value the trace stores, or why it stores none.
1438
+ * @typedef {{ value: any } | { unrecorded: UnrecordedCause }} Recorded
1360
1439
  */
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
1440
 
1367
1441
  /**
1368
- * Copies what `structuredClone` refused. Arrays and plain objects are rebuilt and everything inside them is
1369
- * copied in turn, so only the parts that cannot be copied, such as a function or an object holding one, are
1370
- * kept as they are. A context holding a logger is the usual case.
1371
- *
1372
- * @param {any} value
1373
- * @param {Map<object, any>} seen - Copies made so far, so a cycle is copied as a cycle
1374
- * @returns {any}
1442
+ * Runs one of the two parts of recording that can throw, `redact` or the copy, and names the one that threw. Every
1443
+ * read of the caller's value, and of what `redact` returns, goes through here. Nothing else in the recorder may
1444
+ * throw: a throw while a Command's error was being recorded would replace that error, and `Retry` would retry it.
1445
+ * @param {UnrecordedCause} cause
1446
+ * @param {() => any} compute
1447
+ * @returns {Recorded}
1375
1448
  */
1376
- const copyAround = (value, seen) => {
1377
- if (!isObject(value)) return value;
1378
- if (seen.has(value)) return seen.get(value);
1379
- if (!isPlainContainer(value)) {
1380
- try {
1381
- return structuredClone(value);
1382
- } catch {
1383
- return value;
1384
- }
1449
+ const recordPart = (cause, compute) => {
1450
+ try {
1451
+ return { value: compute() };
1452
+ } catch {
1453
+ return { unrecorded: cause };
1385
1454
  }
1386
- /** @type {any} */
1387
- const copy = Array.isArray(value) ? [] : Object.create(Object.getPrototypeOf(value));
1388
- seen.set(value, copy);
1389
- for (const key of Object.keys(value)) copy[key] = copyAround(value[key], seen);
1390
- return copy;
1391
1455
  };
1392
1456
 
1393
1457
  /**
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.
1458
+ * @typedef {Object} TraceMeta
1459
+ * @property {any} [initialInput] - The value the flow was called with.
1460
+ * @property {string} [flowName]
1461
+ * @property {any} [context] - Context passed to `runEffect`; required to replay `Ask`.
1462
+ * @property {string} [version] - Commit or build id, so a replay can detect a stale trace.
1463
+ */
1464
+
1465
+ /**
1466
+ * Copies a value through JSON, the one form a trace has, on its way into a trace and on its way out in a replay, so a
1467
+ * replay from memory hands the flow what one from storage does. Throws on a value JSON cannot encode, such as a
1468
+ * BigInt; `undefined` and a function come back as `undefined`.
1398
1469
  *
1399
1470
  * @param {any} value
1400
1471
  * @returns {any}
1401
1472
  */
1402
1473
  const snapshot = (value) => {
1403
- if (!isObject(value)) return value;
1404
- try {
1405
- return typeof structuredClone === 'function' ? structuredClone(value) : JSON.parse(JSON.stringify(value));
1406
- } catch {
1407
- return copyAround(value, new Map());
1408
- }
1474
+ // A reference back to an enclosing object is cut, so a request and a response that point at each other can still
1475
+ // be recorded. An object shared without a loop is kept.
1476
+ /** @type {object[]} */
1477
+ const ancestors = [];
1478
+ const json = JSON.stringify(value, function (key, item) {
1479
+ if (!isObject(item)) return item;
1480
+ // `this` is the object holding `item`, so popping back to it leaves only `item`'s ancestors.
1481
+ while (ancestors.length > 0 && ancestors[ancestors.length - 1] !== this) ancestors.pop();
1482
+ if (ancestors.includes(item)) return undefined;
1483
+ ancestors.push(item);
1484
+ return item;
1485
+ });
1486
+ return json === undefined ? undefined : JSON.parse(json);
1409
1487
  };
1410
1488
 
1411
1489
  /**
1412
- * Builds an `onStep` hook that records what every Command returned, plus a packager
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.
1490
+ * Builds an `onStep` hook that records every Command's result or error and every Parallel's decision, and `toTrace`,
1491
+ * which packages them. Give each run its own recorder: one installed for a whole application mixes runs into a trace
1492
+ * a replay refuses. examples/recording-example.js shows how.
1416
1493
  *
1417
1494
  * @param {RecorderOptions} [options] - Redaction and size limits
1418
1495
  * @returns {{ onStep: StepRunner, entries: TraceEntry[], toTrace: (meta?: TraceMeta) => TraceLog }}
1419
1496
  */
1420
1497
  const recorder = (options = {}) => {
1421
- const { redact = (/** @type {any} */ r) => r, maxEntries = Infinity, stack = false } = options;
1498
+ checkOptions(options, 'recorder', recorderOptionRules);
1499
+ const { redact = (/** @type {any} */ r) => r, maxEntries = Infinity, stack = false } = options ?? {};
1500
+ /** @type {RecorderSettings} */
1501
+ const settings = { redact, stack };
1422
1502
  /** @type {TraceEntry[]} */
1423
1503
  const entries = [];
1424
1504
  let dropped = 0;
1425
1505
 
1426
- const push = (/** @type {TraceEntry} */ entry) => {
1427
- if (entries.length < maxEntries) entries.push(entry);
1428
- else dropped++;
1429
- };
1430
-
1431
- // `redact` is the caller's code, and a throw from it must not reach the run.
1432
- const safeRedact = (/** @type {any} */ value, /** @type {string} */ name, /** @type {string} */ kind) => {
1506
+ /**
1507
+ * Records each step it wraps and changes nothing about it: `op` runs, its result is returned, and its error
1508
+ * propagates.
1509
+ * @type {StepRunner}
1510
+ */
1511
+ const onStep = async (name, type, op, path) => {
1512
+ const started = performance.now();
1513
+ const keep = (/** @type {boolean} */ threw, /** @type {any} */ outcome) => {
1514
+ const durationMs = Math.round((performance.now() - started) * 1000) / 1000;
1515
+ const entry = traceEntry(settings, { name, type, path, durationMs }, threw, outcome);
1516
+ if (entries.length < maxEntries) entries.push(entry);
1517
+ else dropped++;
1518
+ };
1433
1519
  try {
1434
- return redact(value, name, kind);
1435
- } catch {
1436
- return '[redaction failed]';
1520
+ const result = await op();
1521
+ keep(false, result);
1522
+ return result;
1523
+ } catch (error) {
1524
+ keep(true, error);
1525
+ throw error;
1437
1526
  }
1438
1527
  };
1439
1528
 
1440
- /**
1441
- * Snapshots one of the trace's own fields and redacts the copy. `undefined` is left alone so a flow with no
1442
- * context does not acquire an empty object from a redact function that spreads its argument.
1443
- */
1444
- const redactField = (/** @type {any} */ value, /** @type {string} */ kind) =>
1445
- value === undefined ? undefined : safeRedact(snapshot(value), kind, kind);
1446
-
1447
- const onStep = observeSteps(({ name, type, path }) => (end) => {
1448
- const durationMs = Math.round(end.durationMs * 1000) / 1000;
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.
1451
- const recorded = (/** @type {any} */ result) =>
1452
- type === 'Parallel' ? snapshot(result) : safeRedact(snapshot(result), name, 'result');
1453
- push(
1454
- 'error' in end
1455
- ? {
1456
- command: name,
1457
- path,
1458
- threw: true,
1459
- error: safeRedact(snapshot(serializeError(end.error, stack)), name, 'error'),
1460
- durationMs
1461
- }
1462
- : { command: name, path, result: recorded(end.result), durationMs }
1463
- );
1529
+ /** @param {TraceMeta} [meta] */
1530
+ const toTrace = (meta = {}) => packTrace(settings, meta, entries, dropped);
1531
+ return { onStep, entries, toTrace };
1532
+ };
1533
+
1534
+ /**
1535
+ * What recording a value needs from a recorder's options: the caller's `redact`, and whether a thrown error keeps its
1536
+ * stack.
1537
+ * @typedef {{ redact: (value: any, name: string, kind: string) => any, stack: boolean }} RecorderSettings
1538
+ */
1539
+
1540
+ /**
1541
+ * The trace entry for one step: its result or error, copied and redacted, or why it holds neither. A Parallel's
1542
+ * decision holds no user data and must survive intact for a replay, so it is not redacted.
1543
+ * @param {RecorderSettings} settings
1544
+ * @param {{ name: string, type: string, path: string | undefined, durationMs: number }} step
1545
+ * @param {boolean} threw
1546
+ * @param {any} outcome - What the step returned, or what it threw
1547
+ * @returns {TraceEntry}
1548
+ */
1549
+ const traceEntry = (settings, { name, type, path, durationMs }, threw, outcome) => {
1550
+ const recorded = threw
1551
+ ? recordError(settings, outcome, name)
1552
+ : type === 'Parallel'
1553
+ ? recordPart('copy', () => snapshot(outcome))
1554
+ : copyAndRedact(settings, outcome, name, 'result');
1555
+ /** @type {TraceEntry} */
1556
+ const entry = threw ? { command: name, path, threw: true } : { command: name, path };
1557
+ if ('unrecorded' in recorded) return { ...entry, unrecorded: recorded.unrecorded, durationMs };
1558
+ return threw ? { ...entry, error: recorded.value, durationMs } : { ...entry, result: recorded.value, durationMs };
1559
+ };
1560
+
1561
+ /**
1562
+ * Copies a value for the trace and redacts the copy, for a step's result or one of the trace's own fields.
1563
+ * @param {RecorderSettings} settings
1564
+ * @param {any} value
1565
+ * @param {string} name
1566
+ * @param {string} kind
1567
+ * @returns {Recorded}
1568
+ */
1569
+ const copyAndRedact = (settings, value, name, kind) => {
1570
+ const copied = recordPart('copy', () => snapshot(value));
1571
+ if ('unrecorded' in copied) return copied;
1572
+ // What redact returns is copied too, so it reads the same from memory as from storage.
1573
+ return recordPart('redact', () => snapshot(settings.redact(copied.value, name, kind)));
1574
+ };
1575
+
1576
+ /**
1577
+ * Copies a thrown value's serialized form and redacts the copy. An Error stays marked as one when `redact` returns an
1578
+ * object without the mark, so a replay still throws an Error.
1579
+ * @param {RecorderSettings} settings
1580
+ * @param {any} thrown
1581
+ * @param {string} name
1582
+ * @returns {Recorded}
1583
+ */
1584
+ const recordError = (settings, thrown, name) => {
1585
+ // Read inside the copy, since it reads the caller's value, and before `redact` can change the copy.
1586
+ const copied = recordPart('copy', () => {
1587
+ const copy = snapshot(serializeError(thrown, settings.stack));
1588
+ return { copy, wasError: isObject(copy) && copy.__error === true };
1464
1589
  });
1590
+ if ('unrecorded' in copied) return copied;
1591
+ const { copy: serialized, wasError } = copied.value;
1592
+ return recordPart('redact', () => {
1593
+ const redacted = snapshot(settings.redact(serialized, name, 'error'));
1594
+ const rebuilt = wasError && isObject(redacted) && !Array.isArray(redacted) && redacted.__error !== true;
1595
+ return rebuilt ? { __error: true, ...redacted } : redacted;
1596
+ });
1597
+ };
1465
1598
 
1466
- /**
1467
- * Redacts and copies `meta.initialInput` and `meta.context` when it is called, so call it before the run
1468
- * when the run can change them, and take `dropped` and `trace` from a second call once it ends.
1469
- * @param {TraceMeta} [meta]
1470
- * @returns {TraceLog}
1471
- */
1472
- const toTrace = (meta = {}) => ({
1599
+ /**
1600
+ * A recorder's `toTrace`: its entries as a trace, with `meta.initialInput` and `meta.context` copied and redacted
1601
+ * when it is called, so call it before a run that can change them, and again once it ends for `dropped` and `trace`.
1602
+ * A field it cannot record is named in `unrecorded`. It never throws. `undefined` is left alone, so a redact that
1603
+ * spreads its argument cannot invent an empty object.
1604
+ * @param {RecorderSettings} settings
1605
+ * @param {TraceMeta} meta
1606
+ * @param {TraceEntry[]} entries
1607
+ * @param {number} dropped
1608
+ * @returns {TraceLog}
1609
+ */
1610
+ const packTrace = (settings, meta, entries, dropped) => {
1611
+ const field = (/** @type {any} */ value, /** @type {string} */ kind) =>
1612
+ value === undefined ? { value } : copyAndRedact(settings, value, kind, kind);
1613
+ const initialInput = field(meta.initialInput, 'initialInput');
1614
+ const context = field(meta.context, 'context');
1615
+ /** @type {{ initialInput?: UnrecordedCause, context?: UnrecordedCause }} */
1616
+ const unrecorded = {};
1617
+ if ('unrecorded' in initialInput) unrecorded.initialInput = initialInput.unrecorded;
1618
+ if ('unrecorded' in context) unrecorded.context = context.unrecorded;
1619
+ return {
1473
1620
  flowName: meta.flowName,
1474
1621
  version: meta.version,
1475
- initialInput: redactField(meta.initialInput, 'initialInput'),
1476
- context: redactField(meta.context, 'context'),
1622
+ initialInput: 'value' in initialInput ? initialInput.value : undefined,
1623
+ context: 'value' in context ? context.value : undefined,
1624
+ ...(Object.keys(unrecorded).length > 0 ? { unrecorded } : {}),
1477
1625
  dropped,
1478
1626
  trace: entries.slice()
1479
- });
1480
-
1481
- return { onStep, entries, toTrace };
1627
+ };
1482
1628
  };
1483
1629
 
1484
1630
  /**
1485
- * Runs a flow for real while recording every Command result, and returns both the
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.
1631
+ * Runs a flow for real while recording it, and returns the outcome and a replayable trace. Convenient in tests and
1632
+ * scripts; to record an application, give each run its own recorder, as examples/recording-example.js does.
1488
1633
  *
1489
1634
  * @param {(input: any) => Effect} flowFn - Builds the Effect tree from its input
1490
1635
  * @param {any} initialInput - The value the flow is called with; stored so a replay can rebuild it
@@ -1493,11 +1638,12 @@ const recorder = (options = {}) => {
1493
1638
  * @returns {Promise<{ result: SuccessState | FailureState, trace: TraceLog }>}
1494
1639
  */
1495
1640
  const recordEffect = async (flowFn, initialInput, options = {}) => {
1496
- const { context = {}, version, ...recorderOptions } = options;
1641
+ checkOptions(options, 'recordEffect', { context: null, version: null, ...recorderOptionRules });
1642
+ checkFlowFn(flowFn, 'recordEffect');
1643
+ const { context = {}, version, ...recorderOptions } = options ?? {};
1497
1644
  const rec = recorder(recorderOptions);
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.
1500
- const head = rec.toTrace({ initialInput, flowName: context.flowName, context, version });
1645
+ // Before the run, so a Command that writes to the input or the context cannot rewrite what the trace received.
1646
+ const head = rec.toTrace({ initialInput, flowName: context?.flowName, context, version });
1501
1647
  // Merged over the global wiring, so recording inside an instrumented application keeps its spans.
1502
1648
  const result = await runEffect(flowFn(initialInput), context, { onStep: rec.onStep });
1503
1649
  const { dropped, trace } = rec.toTrace();
@@ -1505,8 +1651,7 @@ const recordEffect = async (flowFn, initialInput, options = {}) => {
1505
1651
  };
1506
1652
 
1507
1653
  /**
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.
1654
+ * Whether a recorded entry says its step threw: `threw`, or an `error` key, as older and hand-written traces say it.
1510
1655
  * @param {TraceEntry} entry
1511
1656
  * @returns {boolean}
1512
1657
  */
@@ -1526,9 +1671,82 @@ const hasPath = (step) => typeof step.path === 'string';
1526
1671
  */
1527
1672
  const isInsideParallel = (path) => typeof path === 'string' && /p\d+\//.test(path);
1528
1673
 
1674
+ /** @typedef {'Command' | 'Retry' | 'Parallel'} NodeKind */
1675
+
1676
+ /**
1677
+ * The positions a path passes through, each with the kind of node there. `0p1/2r0/1` passes a Parallel at `0` and a
1678
+ * Retry at `0p1/2`, and ends at a Command at `0p1/2r0/1`. A path not in the recorder's format gives none, so a
1679
+ * hand-built trace is never judged by its shape.
1680
+ * @param {string} path
1681
+ * @returns {[string, NodeKind][]}
1682
+ */
1683
+ const nodesAlong = (path) => {
1684
+ /** @type {[string, NodeKind][]} */
1685
+ const nodes = [];
1686
+ let prefix = '';
1687
+ for (const segment of path.split('/')) {
1688
+ const match = /^(\d+)(?:(p)\d*|(r)(?:\d+|f))?$/.exec(segment);
1689
+ if (!match) return [];
1690
+ const [, step, parallel, retry] = match;
1691
+ nodes.push([prefix + step, parallel ? 'Parallel' : retry ? 'Retry' : 'Command']);
1692
+ prefix += `${segment}/`;
1693
+ }
1694
+ return nodes;
1695
+ };
1696
+
1697
+ /** What `replayEffect` takes in place of a Resolver, as its messages name it. */
1698
+ const replayEffectTakes = "a Resolver, a trace from recordEffect or a recorder's toTrace, or its array of entries";
1699
+
1700
+ /**
1701
+ * Refuses a trace a replay cannot read: one with no `trace` array, or an entry that is not an object with a string
1702
+ * `command`, as every entry a recorder writes is. Returns the entries.
1703
+ * @param {any} traceLog
1704
+ * @param {string} source - The function given it, as the message names it
1705
+ * @param {string} takes - What that function takes, as the message names it
1706
+ * @param {boolean} takesEntries - Whether a bare array of entries will do, as it does for `replayEffect`
1707
+ * @returns {TraceEntry[]}
1708
+ */
1709
+ const checkTrace = (traceLog, source, takes, takesEntries) => {
1710
+ const bareEntries = Array.isArray(traceLog);
1711
+ const entries = bareEntries
1712
+ ? takesEntries
1713
+ ? traceLog
1714
+ : undefined
1715
+ : isObject(traceLog)
1716
+ ? traceLog.trace
1717
+ : undefined;
1718
+ if (!Array.isArray(entries)) {
1719
+ const got = bareEntries
1720
+ ? 'an array of entries, which holds no initialInput to rebuild the flow from; pass the whole trace, or ' +
1721
+ 'replay the entries with replayEffect'
1722
+ : isObject(traceLog)
1723
+ ? 'an object with no `trace` array'
1724
+ : describeArgument(traceLog);
1725
+ throw replayError(`${source} expects ${takes}, got ${got}.`);
1726
+ }
1727
+ const bad = entries.findIndex((entry) => !isObject(entry) || typeof entry.command !== 'string');
1728
+ if (bad >= 0) {
1729
+ const what = isObject(entries[bad]) ? 'has no string command' : `is ${describeArgument(entries[bad])}`;
1730
+ throw replayError(`${source}'s trace entry ${bad} ${what}; an entry is an object with a string command.`);
1731
+ }
1732
+ return entries;
1733
+ };
1734
+
1529
1735
  /**
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.
1736
+ * Refuses a flow that is not a function, for the functions that build one from a recorded input.
1737
+ * @param {any} flowFn
1738
+ * @param {string} source - The function given it, as the message names it
1739
+ */
1740
+ const checkFlowFn = (flowFn, source) => {
1741
+ if (typeof flowFn !== 'function') {
1742
+ const got = describeArgument(flowFn);
1743
+ throw new TypeError(`${source} expects the function that builds the flow from its input, got ${got}.`);
1744
+ }
1745
+ };
1746
+
1747
+ /**
1748
+ * Turns a recorded entry into the outcome a Resolver returns, copied so a replayed step that mutates its result
1749
+ * cannot rewrite the trace.
1532
1750
  *
1533
1751
  * @param {TraceEntry} entry
1534
1752
  * @returns {ReplayOutcome}
@@ -1537,9 +1755,8 @@ const entryToOutcome = (entry) =>
1537
1755
  entryThrew(entry) ? { error: reviveError(snapshot(entry.error)) } : { result: snapshot(entry.result) };
1538
1756
 
1539
1757
  /**
1540
- * Whether an entry is a Parallel's recorded decision rather than a Command's result. A Parallel's path
1541
- * ends in its `p` marker and a Command's in its step number, so a Command that happens to be named
1542
- * 'Parallel' is never mistaken for one.
1758
+ * Whether an entry is a Parallel's recorded decision. A decision's path ends in `p`, so a Command named 'Parallel'
1759
+ * is never mistaken for one.
1543
1760
  * @param {TraceEntry} entry
1544
1761
  * @returns {boolean}
1545
1762
  */
@@ -1547,79 +1764,190 @@ const isDecisionEntry = (entry) =>
1547
1764
  entry.command === 'Parallel' && typeof entry.path === 'string' && entry.path.endsWith('p');
1548
1765
 
1549
1766
  /**
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.
1552
- *
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.
1557
- *
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.
1767
+ * Builds a Resolver for the reference trace format; `replayEffect` is its only caller. A trace whose entries all
1768
+ * carry a `path` is matched by path, and one without in order. A step the trace lacks resolves to `undefined`, as
1769
+ * from a Resolver, and `missing` describes it for the error.
1560
1770
  *
1561
1771
  * @param {TraceLog | TraceEntry[]} traceLog - A reference-format trace, or a bare array of entries
1562
1772
  * @param {Object} [options]
1563
- * @param {(entry: TraceEntry) => void} [options.onEntry] - Observes each entry as it is handed to a step,
1564
- * which is how `replayEffect` learns which recorded entries the flow never asked for.
1565
- * @returns {{ resolve: Resolver, missing: (step: ReplayStep) => string }}
1773
+ * @param {(entry: TraceEntry) => void} [options.onEntry] - Observes each entry as it is handed to a step, which is
1774
+ * how `replayEffect` learns which entries the flow never asked for.
1775
+ * @returns {TraceResolver}
1566
1776
  */
1567
1777
  const fromTrace = (traceLog, options = {}) => {
1568
1778
  const { onEntry } = options;
1569
- const entries = Array.isArray(traceLog) ? traceLog : traceLog?.trace;
1570
- if (!Array.isArray(entries)) throw replayError('Trace has no `trace` array.');
1571
- const resolveEntry = (/** @type {TraceEntry} */ entry) => {
1779
+ const entries = checkTrace(traceLog, 'replayEffect', replayEffectTakes, true);
1780
+ /** @type {AnswerFrom} */
1781
+ const answerFrom = (entry, step) => {
1572
1782
  if (onEntry) onEntry(entry);
1783
+ return recordedOutcome(entry, step);
1784
+ };
1785
+ return entries.length > 0 && entries.every(hasPath)
1786
+ ? resolveByPath(indexByPath(entries), answerFrom)
1787
+ : resolveInOrder(entries, answerFrom);
1788
+ };
1789
+
1790
+ /**
1791
+ * A Resolver for a reference trace, with the words for a step it holds no entry for.
1792
+ * @typedef {{ resolve: Resolver, missing: (step: ReplayStep) => string }} TraceResolver
1793
+ */
1794
+
1795
+ /**
1796
+ * Hands a step the outcome of the entry a resolver matched it to.
1797
+ * @typedef {(entry: TraceEntry, step: ReplayStep) => ReplayOutcome} AnswerFrom
1798
+ */
1799
+
1800
+ /**
1801
+ * The outcome a recorded entry hands its step. An entry the recorder could not record stops the replay rather than
1802
+ * run live under `onMissing`, since production ran the step.
1803
+ * @param {TraceEntry} entry
1804
+ * @param {ReplayStep} step
1805
+ * @returns {ReplayOutcome}
1806
+ */
1807
+ const recordedOutcome = (entry, step) => {
1808
+ if (entry.unrecorded !== undefined) {
1809
+ throw unrecordedError(
1810
+ `outcome for '${step.name}' at path '${step.path}'`,
1811
+ entry.unrecorded,
1812
+ "Production ran the step, so onMissing: 'execute' does not run it either. To replay past it,",
1813
+ { command: step.name, index: step.index, path: step.path }
1814
+ );
1815
+ }
1816
+ // Only a hand-built trace can hold a value the copy cannot encode.
1817
+ try {
1573
1818
  return entryToOutcome(entry);
1819
+ } catch {
1820
+ throw replayError(
1821
+ `Trace entry for '${step.name}' at path '${step.path}' holds a value JSON cannot encode, such as a ` +
1822
+ 'BigInt. A recorded trace never does, so write the entry as JSON would store it.',
1823
+ { command: step.name, index: step.index, path: step.path }
1824
+ );
1825
+ }
1826
+ };
1827
+
1828
+ /**
1829
+ * A trace's entries indexed for matching by path: each entry by its path, the kind of node the recorded run had at
1830
+ * each position its steps passed through, and the recorded decisions that cancelled branches.
1831
+ * @typedef {{
1832
+ * entries: TraceEntry[],
1833
+ * byPath: Map<string | undefined, TraceEntry>,
1834
+ * kinds: Map<string, NodeKind>,
1835
+ * cancellations: TraceEntry[]
1836
+ * }} PathIndex
1837
+ */
1838
+
1839
+ /**
1840
+ * @param {TraceEntry[]} entries - Every one with a path
1841
+ * @returns {PathIndex}
1842
+ */
1843
+ const indexByPath = (entries) => {
1844
+ const byPath = new Map(entries.map((e) => [e.path, e]));
1845
+ // A recorder never writes a duplicate path, and keeping either entry would hand a branch the wrong result.
1846
+ if (byPath.size !== entries.length) {
1847
+ throw replayError('Trace has duplicate step paths.');
1848
+ }
1849
+ return {
1850
+ entries,
1851
+ byPath,
1852
+ kinds: new Map(entries.flatMap((e) => nodesAlong(/** @type {string} */ (e.path)))),
1853
+ cancellations: entries.filter(isDecisionEntry).filter((e) => e.result?.cancelled === true)
1574
1854
  };
1855
+ };
1575
1856
 
1576
- if (entries.length > 0 && entries.every(hasPath)) {
1577
- const byPath = new Map(entries.map((e) => [e.path, e]));
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.
1580
- if (byPath.size !== entries.length) {
1581
- throw replayError('Trace has duplicate step paths.');
1582
- }
1583
- /** Recorded Parallel decisions that cancelled branches, by the Parallel's path. */
1584
- const cancellations = entries.filter(isDecisionEntry).filter((e) => e.result?.cancelled === true);
1585
- // A missing step is where production stopped a branch when it lies in a branch a recorded decision
1586
- // cancelled: any branch but the cancelling one, or every branch of a Parallel cancelled from outside.
1587
- const stoppedInProduction = (/** @type {string | undefined} */ stepPath) =>
1588
- typeof stepPath === 'string' &&
1589
- cancellations.some((e) => {
1590
- const at = /** @type {string} */ (e.path);
1591
- const branch = stepPath.startsWith(at) ? /^(\d+)\//.exec(stepPath.slice(at.length)) : null;
1592
- return branch !== null && (e.result.branch === null || Number(branch[1]) !== e.result.branch);
1593
- });
1594
- /** @type {Resolver} */
1595
- const resolve = (step) => {
1596
- const entry = byPath.get(step.path);
1597
- if (step.type === 'Parallel') {
1598
- // A decision rather than I/O. With none recorded, the Parallel replays under timing.
1599
- if (entry && entry.command !== 'Parallel') throw timeParadox(step, entry.command);
1600
- if (entry) return resolveEntry(entry);
1601
- if (stoppedInProduction(step.path)) throw replayCutError(/** @type {string} */ (step.path));
1602
- return undefined;
1603
- }
1604
- if (!entry) {
1605
- // Production never ran this step, so it is not run live under `onMissing` either.
1606
- if (stoppedInProduction(step.path)) throw replayCutError(/** @type {string} */ (step.path));
1607
- return undefined;
1608
- }
1857
+ /**
1858
+ * Matches each step to the entry at its path, which tells two `Parallel` branches calling the same Command apart
1859
+ * whatever order they finish in. A Parallel's step is named 'Parallel', as its decision's entry is, so one comparison
1860
+ * covers a Command and a decision; `replayEffect` hands a decision to the Parallel's `op`, and with none recorded the
1861
+ * Parallel replays by timing.
1862
+ * @param {PathIndex} recorded
1863
+ * @param {AnswerFrom} answerFrom
1864
+ * @returns {TraceResolver}
1865
+ */
1866
+ const resolveByPath = (recorded, answerFrom) => {
1867
+ /** @type {Resolver} */
1868
+ const resolve = (step) => {
1869
+ const entry = recorded.byPath.get(step.path);
1870
+ if (entry) {
1609
1871
  if (entry.command !== step.name) throw timeParadox(step, entry.command);
1610
- return resolveEntry(entry);
1611
- };
1612
- const missing = (/** @type {ReplayStep} */ step) =>
1613
- `Trace has no step at path '${step.path}' for '${step.name}'`;
1614
- return { resolve, missing };
1615
- }
1872
+ return answerFrom(entry, step);
1873
+ }
1874
+ throwIfReshaped(recorded, step);
1875
+ if (stoppedInProduction(recorded, step.path)) throw replayCutError(/** @type {string} */ (step.path));
1876
+ return undefined;
1877
+ };
1878
+ const missing = (/** @type {ReplayStep} */ step) => `Trace has no step at path '${step.path}' for '${step.name}'`;
1879
+ return { resolve, missing };
1880
+ };
1881
+
1882
+ /**
1883
+ * Refuses a step the trace lacks when the trace recorded another kind of node along its path, as when a Command was
1884
+ * added where a Retry was. That is a change of shape, which reported as missing would run live under 'execute'.
1885
+ * @param {PathIndex} recorded
1886
+ * @param {ReplayStep} step
1887
+ */
1888
+ const throwIfReshaped = (recorded, step) => {
1889
+ const nodes = nodesAlong(/** @type {string} */ (step.path));
1890
+ const diverged = nodes.find(([at, kind]) => (recorded.kinds.get(at) ?? kind) !== kind);
1891
+ if (!diverged) return;
1892
+ const [at, kind] = diverged;
1893
+ const recordedKind = /** @type {NodeKind} */ (recorded.kinds.get(at));
1894
+ const actual = kind === 'Command' ? step.name : kind;
1895
+ const expected =
1896
+ recordedKind === 'Command' ? /** @type {TraceEntry} */ (recorded.byPath.get(at)).command : recordedKind;
1897
+ const flowHad = kind === 'Command' ? `flow asked for '${actual}'` : `flow has a ${kind} there`;
1898
+ const traceHad =
1899
+ recordedKind === 'Command'
1900
+ ? `trace recorded '${expected}'`
1901
+ : `trace recorded a ${recordedKind} there${firstStepIn(recorded.entries, at, recordedKind)}`;
1902
+ throw timeParadoxAt(`path '${at}'`, `${flowHad}, ${traceHad}`, {
1903
+ index: step.index,
1904
+ path: at,
1905
+ expected,
1906
+ actual
1907
+ });
1908
+ };
1616
1909
 
1910
+ /**
1911
+ * Names the first Command the trace recorded inside the Retry or Parallel at `at`, for a message.
1912
+ * @param {TraceEntry[]} entries
1913
+ * @param {string} at
1914
+ * @param {NodeKind} kind
1915
+ * @returns {string}
1916
+ */
1917
+ const firstStepIn = (entries, at, kind) => {
1918
+ const opens = `${at}${kind === 'Retry' ? 'r' : 'p'}`;
1919
+ const inside = entries.find((e) => e.path?.startsWith(opens) && !isDecisionEntry(e));
1920
+ return inside ? `, with '${inside.command}' at path '${inside.path}'` : '';
1921
+ };
1922
+
1923
+ /**
1924
+ * Whether a step lies in a branch a recorded decision cancelled: any branch but the trigger, or every branch of a
1925
+ * Parallel cancelled from outside. Production stopped there, so the step throws a cut, which `runCommand` or
1926
+ * `runParallel` catches to stop the branch.
1927
+ * @param {PathIndex} recorded
1928
+ * @param {string | undefined} stepPath
1929
+ * @returns {boolean}
1930
+ */
1931
+ const stoppedInProduction = (recorded, stepPath) =>
1932
+ typeof stepPath === 'string' &&
1933
+ recorded.cancellations.some((e) => {
1934
+ const at = /** @type {string} */ (e.path);
1935
+ const branch = stepPath.startsWith(at) ? /^(\d+)\//.exec(stepPath.slice(at.length)) : null;
1936
+ return branch !== null && (e.result.branch === null || Number(branch[1]) !== e.result.branch);
1937
+ });
1938
+
1939
+ /**
1940
+ * Matches steps to entries in order, for a trace without paths. That is exact for a sequential flow, but completion
1941
+ * order cannot tell `Parallel` branches apart, so a step inside one is refused rather than guessed.
1942
+ * @param {TraceEntry[]} entries
1943
+ * @param {AnswerFrom} answerFrom
1944
+ * @returns {TraceResolver}
1945
+ */
1946
+ const resolveInOrder = (entries, answerFrom) => {
1617
1947
  /** @type {Resolver} */
1618
1948
  const resolve = (step) => {
1619
- // A trace with no paths predates recorded decisions, so a Parallel replays under timing.
1949
+ // A trace with no paths predates recorded decisions, so a Parallel replays by timing.
1620
1950
  if (step.type === 'Parallel') return undefined;
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
1951
  if (isInsideParallel(step.path)) {
1624
1952
  throw replayError(
1625
1953
  `Trace carries no paths, so '${step.name}' inside a Parallel cannot be matched positionally.`,
@@ -1629,65 +1957,114 @@ const fromTrace = (traceLog, options = {}) => {
1629
1957
  const entry = entries[step.index];
1630
1958
  if (!entry) return undefined;
1631
1959
  if (entry.command !== step.name) throw timeParadox(step, entry.command);
1632
- return resolveEntry(entry);
1960
+ return answerFrom(entry, step);
1633
1961
  };
1634
1962
  const missing = (/** @type {ReplayStep} */ step) => `Trace exhausted: no entry #${step.index} for '${step.name}'`;
1635
1963
  return { resolve, missing };
1636
1964
  };
1637
1965
 
1966
+ /**
1967
+ * The error for a step a replay has no recorded outcome for, which it refuses to run live. It says whether a cap
1968
+ * may have dropped the step or production may never have run it, since the fix differs.
1969
+ * @param {ReplayStep} step
1970
+ * @param {((step: ReplayStep) => string) | undefined} describe - `fromTrace`'s `missing`; a Resolver has none
1971
+ * @param {number} droppedEntries - How many entries the trace dropped under `maxEntries`
1972
+ * @returns {Error}
1973
+ */
1974
+ const missingStepError = (step, describe, droppedEntries) => {
1975
+ const what = describe ? describe(step) : `No recorded outcome for '${step.name}' at step ${step.index}`;
1976
+ const why =
1977
+ droppedEntries > 0
1978
+ ? `The trace dropped ${droppedEntries} entries under maxEntries, so production may have run ` +
1979
+ 'this step and the recorder not kept it; record the flow with a higher maxEntries to ' +
1980
+ 'replay past it.'
1981
+ : 'Production may never have run it: a Command an onBeforeCommand hook vetoed leaves no ' +
1982
+ 'entry, and neither does a step the flow reaches now that it did not then, such as one added ' +
1983
+ "past the end of the recording. onMissing: 'execute' runs such a step for real, and every step " +
1984
+ 'after it that the trace also lacks, so pass it only where every Command the flow can still ' +
1985
+ 'reach goes to a test double or only reads.';
1986
+ return replayError(`${what}; refusing to run the real Command. ${why}`, {
1987
+ command: step.name,
1988
+ index: step.index,
1989
+ path: step.path
1990
+ });
1991
+ };
1992
+
1993
+ /** What each cause the recorder writes means, and what fixes it, for a replay error. */
1994
+ const unrecordedReasons = new Map([
1995
+ ['redact', ['redact threw on it', 'make redact handle every value it is given, null included']],
1996
+ ['copy', ['it could not be copied, as when it holds a BigInt or a getter that throws', 'keep it to plain data']]
1997
+ ]);
1998
+
1999
+ /**
2000
+ * The error for a replay that needs a value the recorder could not record.
2001
+ * @param {string} what - What the trace lacks, as the message names it
2002
+ * @param {unknown} cause - Why, as the trace records it
2003
+ * @param {string} lead - What that means for this replay, leading into the fix
2004
+ * @param {Object} props - The fields that locate it, such as `path`
2005
+ * @returns {Error}
2006
+ */
2007
+ const unrecordedError = (what, cause, lead, props) => {
2008
+ // A cause the recorder does not write, as a hand-built trace may hold, is not guessed at.
2009
+ const [reason, fix] = unrecordedReasons.get(/** @type {string} */ (cause)) ?? [
2010
+ 'it could not be recorded',
2011
+ 'find out what kept it out'
2012
+ ];
2013
+ return replayError(
2014
+ `The trace holds no ${what}: ${reason}, so the recorder left it out. ${lead} ${fix}, and record the flow again.`,
2015
+ props
2016
+ );
2017
+ };
2018
+
2019
+ /**
2020
+ * Why the recorder left one of a trace's own fields out, or `undefined` when it did not.
2021
+ * @param {TraceLog | undefined} traceLog
2022
+ * @param {'initialInput' | 'context'} field
2023
+ * @returns {unknown}
2024
+ */
2025
+ const unrecordedCause = (traceLog, field) => {
2026
+ const fields = /** @type {any} */ (traceLog?.unrecorded);
2027
+ return isObject(fields) ? fields[field] : undefined;
2028
+ };
2029
+
1638
2030
  /**
1639
2031
  * @typedef {Object} ReplayOptions
1640
2032
  * @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.
1643
- * @property {boolean} [hooks] - Runs the replay inside the hooks `configureEffect` installed, with the
1644
- * resolver innermost, so a configured `onStep` observes each replayed step and `onRun` and
1645
- * `onBeforeCommand` fire. Off by default, which ignores the global hooks, so a replay cannot
1646
- * reach a telemetry backend or a guardrail that performs I/O.
1647
- * @property {'throw' | 'execute'} [onMissing] - What to do when the resolver has no recording for a step.
1648
- * `'throw'` (default) fails the replay, which makes side effects impossible for the whole run.
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.
2033
+ * @property {boolean} [fastRetry] - Waits no time between Retry attempts. On by default.
2034
+ * @property {boolean} [hooks] - Runs the replay inside the hooks `configureEffect` installed, with the resolver
2035
+ * innermost. Off by default, so a replay cannot reach a telemetry backend or a guardrail that does I/O.
2036
+ * @property {'throw' | 'execute'} [onMissing] - What to do with a step the resolver has no recording for. `'throw'`
2037
+ * (default) fails the replay, so no side effect can occur. `'execute'` runs the real Command, so pass it
2038
+ * only where the Commands reach test doubles or only read. A trace that dropped entries refuses it.
1652
2039
  * @property {(step: ReplayStep, outcome: ReplayOutcome | undefined) => void} [onResolved] - Observes each step.
1653
2040
  */
1654
2041
 
1655
2042
  /**
1656
- * What a replay returns: the flow's own outcome, and, when a trace was supplied, the recorded
1657
- * entries the flow never asked for. `unreached` is absent for a Resolver, since only a trace
1658
- * knows what it holds.
1659
- *
1660
- * A flow that stops issuing Commands before its recording ends mismatches nothing, so no
1661
- * `TimeParadox` fires and `result` can be a `Success` with recorded steps left over. That is
1662
- * sometimes the point of a fix and sometimes a fix that quietly dropped a step; `unreached` is
1663
- * the only place the difference shows.
2043
+ * What a replay returns: the flow's own outcome and, for a trace, the recorded entries the flow never asked for. A
2044
+ * flow that stops early mismatches nothing, so `unreached` is the only sign that a step went away.
1664
2045
  * @typedef {{ result: SuccessState | FailureState, unreached?: TraceEntry[] }} Replay
1665
2046
  */
1666
2047
 
1667
2048
  /**
1668
- * Replays an Effect tree, feeding recorded results to Commands instead of running them.
1669
- *
1670
- * No side effect can occur by default. The interpreter's only execution point is
1671
- * `await localStepRunner(cmdName, 'Command', op, cmdPath)`, which hands the Command thunk
1672
- * to `onStep` as `op` rather than calling it. The `onStep` installed here never invokes
1673
- * `op` unless `onMissing: 'execute'` is set, so `eff.cmd` is never applied and the I/O
1674
- * it describes does not happen.
1675
- *
1676
- * Driving the interpreter instead of walking the tree is what makes `Ask`, `Retry` and
1677
- * `Parallel` work, and means replay cannot drift from execution semantics.
2049
+ * Replays a flow, feeding recorded outcomes to its Commands instead of running them. No side effect can occur by
2050
+ * default: the interpreter executes a Command only as the `op` it hands `onStep`, and the `onStep` here calls `op`
2051
+ * only under `onMissing: 'execute'`. Async, so a malformed trace rejects rather than throwing.
1678
2052
  *
1679
2053
  * @param {Effect} effect - The Effect tree, rebuilt from the recorded initial input
1680
- * @param {Resolver | TraceLog | TraceEntry[]} traceOrResolver - A reference-format trace, resolved here, or a
1681
- * Resolver supplying each Command's recorded outcome (`undefined` if it has none). Write a Resolver when
1682
- * traces are stored in some other shape; to observe a replay without one, use `onResolved`.
2054
+ * @param {Resolver | TraceLog | TraceEntry[]} traceOrResolver - A reference-format trace, or a Resolver supplying
2055
+ * each Command's recorded outcome (`undefined` if it has none) for traces stored in another shape.
1683
2056
  * @param {ReplayOptions} [options]
1684
2057
  * @returns {Promise<Replay>} `{ result, unreached }` for a trace, `{ result }` for a Resolver
1685
2058
  */
1686
- // `async` so a malformed trace arrives as a rejection rather than a synchronous throw.
1687
2059
  const replayEffect = async (effect, traceOrResolver, options = {}) => {
1688
- const { fastRetry = true, hooks = false, onMissing = 'throw', onResolved } = options;
1689
- // A trace is data and a Resolver is a function, so nothing else is needed to tell them
1690
- // apart, including the bare entries array that `fromTrace` also accepts.
2060
+ checkOptions(options, 'replayEffect', {
2061
+ context: null,
2062
+ fastRetry: [isBoolean, 'true or false'],
2063
+ hooks: [isBoolean, 'true or false'],
2064
+ onMissing: [(value) => value === 'throw' || value === 'execute', "'throw' or 'execute'"],
2065
+ onResolved: [isFunction, 'a function']
2066
+ });
2067
+ const { context: givenContext, fastRetry = true, hooks = false, onMissing = 'throw', onResolved } = options ?? {};
1691
2068
  const fromResolver = typeof traceOrResolver === 'function';
1692
2069
  /** @type {Set<TraceEntry>} */
1693
2070
  const reached = new Set();
@@ -1696,94 +2073,150 @@ const replayEffect = async (effect, traceOrResolver, options = {}) => {
1696
2073
  : fromTrace(traceOrResolver, { onEntry: (entry) => void reached.add(entry) });
1697
2074
  // Only a trace log carries metadata; a bare entries array and a Resolver carry none.
1698
2075
  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;
2076
+ const context = replayContext(traceLog, givenContext);
2077
+ const droppedEntries = droppedEntriesOf(traceLog, onMissing);
2078
+ const hook = replayHook({ resolve, missing, onResolved, onMissing, droppedEntries });
2079
+
2080
+ // A replay fault becomes a Failure here, outside the flow, where nothing can absorb it.
2081
+ let result;
2082
+ try {
2083
+ result = await interpret(effect, context, { onStep: hook.onStep, inherit: hooks }, fastRetry);
2084
+ } catch (e) {
2085
+ if (hook.failure) throw hook.failure.error;
2086
+ if (!hasMark(e, replayFault)) throw e;
2087
+ result = Failure(e);
2088
+ }
2089
+ if (fromResolver) return { result };
2090
+ // `fromTrace` has already checked the shape.
2091
+ const entries = Array.isArray(traceOrResolver) ? traceOrResolver : traceOrResolver.trace;
2092
+ return { result, unreached: entries.filter((entry) => !reached.has(entry)) };
2093
+ };
2094
+
2095
+ /**
2096
+ * The context a replay runs with: the one passed in, else the one the trace recorded, else `{}`. Another context can
2097
+ * take another branch at an `Ask` with nothing to flag it, so a trace whose context was not recorded needs one passed.
2098
+ * @param {TraceLog | undefined} traceLog
2099
+ * @param {any} given - `options.context`
2100
+ * @returns {any}
2101
+ */
2102
+ const replayContext = (traceLog, given) => {
2103
+ const cause = unrecordedCause(traceLog, 'context');
2104
+ if (given == null && cause !== undefined) {
2105
+ throw unrecordedError(
2106
+ 'context',
2107
+ cause,
2108
+ 'A replay with an empty one could take another branch at an Ask: pass options.context, or',
2109
+ { field: 'context' }
2110
+ );
2111
+ }
2112
+ // A recorded `null` is what production ran with, so only a trace that holds no context gets `{}`.
2113
+ const recorded = traceLog?.context === undefined ? {} : traceLog.context;
2114
+ return given ?? recorded;
2115
+ };
2116
+
2117
+ /**
2118
+ * How many entries a trace dropped under `maxEntries`. A capped trace lacks steps production ran, so it refuses
2119
+ * `onMissing: 'execute'`, which could run one of them again.
2120
+ * @param {TraceLog | undefined} traceLog
2121
+ * @param {'throw' | 'execute'} onMissing
2122
+ * @returns {number}
2123
+ */
2124
+ const droppedEntriesOf = (traceLog, onMissing) => {
2125
+ const dropped = Number(traceLog?.dropped) || 0;
2126
+ const capped = dropped > 0;
1706
2127
  if (capped && onMissing === 'execute') {
1707
2128
  throw replayError(
1708
- `The trace dropped ${droppedEntries} entries under maxEntries, so a step it lacks may be one ` +
2129
+ `The trace dropped ${dropped} entries under maxEntries, so a step it lacks may be one ` +
1709
2130
  "production ran, and onMissing: 'execute' would run it again. Replay without it to stop at the " +
1710
2131
  'first missing step, or record the flow with a higher maxEntries.'
1711
2132
  );
1712
2133
  }
2134
+ return dropped;
2135
+ };
2136
+
2137
+ /**
2138
+ * The `onStep` a replay runs the flow under, and the first error a Resolver or `onResolved` raised, or a Resolver's
2139
+ * answer that is not an outcome. The replay rejects with that error rather than letting it count as the Command
2140
+ * failing.
2141
+ * @typedef {{ onStep: StepRunner, failure: { error: unknown } | undefined }} ReplayHook
2142
+ */
2143
+
2144
+ /**
2145
+ * Builds the `onStep` that answers each step of a replay from `resolve`: the recorded outcome in place of a Command,
2146
+ * the recorded decision for a Parallel, and for a step with no record, a refusal or, under `onMissing: 'execute'`, the
2147
+ * real Command.
2148
+ * @param {{
2149
+ * resolve: Resolver,
2150
+ * missing: ((step: ReplayStep) => string) | undefined,
2151
+ * onResolved: ((step: ReplayStep, outcome: ReplayOutcome | undefined) => void) | undefined,
2152
+ * onMissing: 'throw' | 'execute',
2153
+ * droppedEntries: number
2154
+ * }} replay
2155
+ * @returns {ReplayHook}
2156
+ */
2157
+ const replayHook = ({ resolve, missing, onResolved, onMissing, droppedEntries }) => {
1713
2158
  let index = 0;
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.
1717
- let observerFailure = /** @type {{ error: unknown } | undefined} */ (undefined);
2159
+ /** Keeps the first callback failure for the replay to reject with, and returns a stand-in nothing in the flow absorbs. */
2160
+ const stopWith = (/** @type {unknown} */ error) => {
2161
+ hook.failure ??= { error };
2162
+ return asHarnessError(new Error('A replay callback failed.'));
2163
+ };
2164
+
2165
+ /**
2166
+ * Asks for a step's recorded outcome. A replay fault from `fromTrace` passes as it is.
2167
+ * @param {ReplayStep} step
2168
+ * @returns {ReplayOutcome | undefined}
2169
+ */
2170
+ const answer = (step) => {
2171
+ let outcome;
2172
+ try {
2173
+ outcome = resolve(step);
2174
+ } catch (error) {
2175
+ if (hasMark(error, harnessError)) throw error;
2176
+ throw stopWith(error);
2177
+ }
2178
+ if (outcome === undefined || isReplayOutcome(outcome)) return outcome;
2179
+ throw stopWith(
2180
+ new TypeError(
2181
+ 'A Resolver answers with { result }, { error }, or undefined for a step it has no record of, and it ' +
2182
+ `answered ${describeValue(outcome)} for '${step.name}' at path '${step.path}'.`
2183
+ )
2184
+ );
2185
+ };
1718
2186
 
1719
2187
  /** @type {StepRunner} */
1720
2188
  const onStep = async (name, type, op, path) => {
1721
2189
  if (type === 'Parallel') {
1722
- // A Parallel's step carries its recorded decision into `op`, which runs the branches under it.
1723
- // It is not a Command: `index` still counts Commands, and `onResolved` still sees only them.
1724
- const outcome = resolve({ index, name, type, path });
1725
- return await op(outcome && 'result' in outcome ? outcome.result : undefined);
2190
+ // A Parallel's step carries its recorded decision into `op`, where `runBranches` reproduces it. It is not
2191
+ // a Command: `index` still counts Commands, and `onResolved` still sees only them.
2192
+ const outcome = answer({ index, name, type, path });
2193
+ return await op(outcome !== undefined && 'result' in outcome ? outcome.result : undefined);
1726
2194
  }
1727
2195
  const step = { index: index++, name, type, path };
1728
- const outcome = resolve(step);
2196
+ const outcome = answer(step);
1729
2197
  if (onResolved) {
1730
2198
  try {
1731
2199
  onResolved(step, outcome);
1732
2200
  } catch (error) {
1733
- observerFailure ??= { error };
1734
- throw asHarnessError(new Error('onResolved threw.'));
2201
+ throw stopWith(error);
1735
2202
  }
1736
2203
  }
1737
2204
  if (outcome === undefined) {
1738
- if (onMissing !== 'execute') {
1739
- const what = missing ? missing(step) : `No recorded outcome for '${name}' at step ${step.index}`;
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
- });
1755
- }
2205
+ if (onMissing !== 'execute') throw missingStepError(step, missing, droppedEntries);
1756
2206
  return await op();
1757
2207
  }
1758
2208
  if ('error' in outcome) throw outcome.error;
1759
2209
  return outcome.result;
1760
2210
  };
1761
2211
 
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.
1764
- /** @type {CallConfiguration} */
1765
- const callConfig = { onStep, inherit: hooks };
1766
-
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.
1769
- let result;
1770
- try {
1771
- result = await interpret(effect, context, callConfig, fastRetry);
1772
- } catch (e) {
1773
- if (observerFailure) throw observerFailure.error;
1774
- if (!hasMark(e, replayFault)) throw e;
1775
- result = Failure(e, effect.initialInput);
1776
- }
1777
- if (fromResolver) return { result };
1778
- // `fromTrace` has already validated the shape, so the entries are here in one form or the other.
1779
- const entries = Array.isArray(traceOrResolver) ? traceOrResolver : traceOrResolver.trace;
1780
- return { result, unreached: entries.filter((entry) => !reached.has(entry)) };
2212
+ /** @type {ReplayHook} */
2213
+ const hook = { onStep, failure: undefined };
2214
+ return hook;
1781
2215
  };
1782
2216
 
1783
2217
  /**
1784
- * Replays a reference-format trace and narrates each step. Rebuilds the flow from the
1785
- * recorded input, reports the outcome, and names any recorded steps that were never
1786
- * reached, which means the current code issued fewer Commands than production did.
2218
+ * Replays a reference-format trace and narrates each step. Rebuilds the flow from the recorded input, reports the
2219
+ * outcome, and names any recorded steps that were never reached.
1787
2220
  *
1788
2221
  * @param {(input: any) => Effect} flowFn - The same flow function that produced the trace
1789
2222
  * @param {TraceLog} traceLog - A trace from `recordEffect` or a `recorder`
@@ -1791,83 +2224,121 @@ const replayEffect = async (effect, traceOrResolver, options = {}) => {
1791
2224
  * @param {(...args: any[]) => void} [options.log] - Defaults to `console.log`
1792
2225
  * @param {any} [options.context] - Overrides the context stored on the trace
1793
2226
  * @param {string} [options.version] - Current build id; warns when it differs from the trace's
1794
- * @returns {Promise<SuccessState | FailureState>} The flow's outcome. Narration is the point of this function;
1795
- * a caller who wants the unreached entries as data uses `replayEffect`.
2227
+ * @returns {Promise<SuccessState | FailureState>} The flow's outcome. For the unreached entries as data, use
2228
+ * `replayEffect`.
1796
2229
  */
1797
2230
  const timeTravel = async (flowFn, traceLog, options = {}) => {
1798
- const { log = console.log, context, version } = options;
1799
- const { initialInput, trace, flowName, version: traceVersion } = traceLog;
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.
1802
- const format = (/** @type {any} */ v) => {
1803
- try {
1804
- return JSON.stringify(v instanceof Error ? { ...v, name: v.name, message: v.message } : v, null, 2);
1805
- } catch {
1806
- return String(v);
2231
+ checkOptions(options, 'timeTravel', { log: [isFunction, 'a function'], context: null, version: null });
2232
+ const { log = console.log, context, version } = options ?? {};
2233
+ checkFlowFn(flowFn, 'timeTravel');
2234
+ // Checked here, since timeTravel reads the trace's own fields before `replayEffect` checks its shape.
2235
+ const trace = checkTrace(traceLog, 'timeTravel', "a trace from recordEffect or a recorder's toTrace", false);
2236
+ const { initialInput, flowName } = traceLog;
2237
+ const inputCause = unrecordedCause(traceLog, 'initialInput');
2238
+ if (inputCause !== undefined) {
2239
+ throw unrecordedError('initialInput', inputCause, 'The flow is rebuilt from it, so', { field: 'initialInput' });
2240
+ }
2241
+
2242
+ for (const warning of traceWarnings(traceLog, trace, version)) log(warning);
2243
+ // Decisions are not narrated as steps, so the header counts only Commands.
2244
+ const commandCount = trace.filter((e) => !isDecisionEntry(e)).length;
2245
+ log(`Replaying '${flowName || 'flow'}' (${commandCount} recorded ${commandCount === 1 ? 'step' : 'steps'})`);
2246
+ log(`Initial input: ${formatForLog(initialInput)}`);
2247
+ const timing = stepTimings(trace);
2248
+ // `replayEffect` defaults the context to the trace's.
2249
+ const { result, unreached = [] } = await replayEffect(flowFn(initialInput), traceLog, {
2250
+ context,
2251
+ onResolved: (step, outcome) => {
2252
+ // A step the trace does not hold is refused or run live by `replayEffect`, not narrated here.
2253
+ if (outcome === undefined) return;
2254
+ log(
2255
+ 'error' in outcome
2256
+ ? `Step ${step.index + 1}: ${step.name} threw${timing(step)} ${formatForLog(outcome.error)}`
2257
+ : `Step ${step.index + 1}: ${step.name} returned${timing(step)} ${formatForLog(outcome.result)}`
2258
+ );
1807
2259
  }
1808
- };
2260
+ });
2261
+ log(`Replay finished with state: ${result.type}`);
2262
+ log(result.type === 'Failure' ? `Error: ${formatForLog(result.error)}` : `Result: ${formatForLog(result.value)}`);
2263
+ const warning = unreachedWarning(result, unreached);
2264
+ if (warning !== undefined) log(warning);
2265
+ return result;
2266
+ };
1809
2267
 
1810
- if (version && traceVersion && version !== traceVersion) {
1811
- log(`Warning: trace was recorded at ${traceVersion}, replaying against ${version}.`);
2268
+ /**
2269
+ * The warnings `timeTravel` gives before it replays: a trace recorded at another build, one that holds no initial
2270
+ * input, and steps a replay cannot tell apart.
2271
+ * @param {TraceLog} traceLog
2272
+ * @param {TraceEntry[]} trace - Its entries, checked
2273
+ * @param {string | undefined} version - The build replaying it
2274
+ * @returns {string[]}
2275
+ */
2276
+ const traceWarnings = (traceLog, trace, version) => {
2277
+ const warnings = [];
2278
+ if (version && traceLog.version && version !== traceLog.version) {
2279
+ warnings.push(`Warning: trace was recorded at ${traceLog.version}, replaying against ${version}.`);
1812
2280
  }
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(
2281
+ if (traceLog.initialInput === undefined) {
2282
+ warnings.push(
1817
2283
  'Warning: the trace holds no initial input, so the flow is rebuilt from undefined. A recorder ' +
1818
2284
  'installed as a hook finds the input only on a flow built with effectPipe.'
1819
2285
  );
1820
2286
  }
1821
- // A replay tells steps apart by name, so two anonymous Commands that swapped places replay without complaint.
1822
2287
  const anonymous = trace.filter((e) => e.command === 'anonymous').length;
1823
2288
  if (anonymous > 0) {
1824
- log(
2289
+ warnings.push(
1825
2290
  `Warning: ${anonymous} of the recorded steps are named 'anonymous', usually inline arrow Commands, so ` +
1826
2291
  'this replay cannot tell them apart and would not notice two of them trading places. Name them with ' +
1827
2292
  'a const or meta.name.'
1828
2293
  );
1829
2294
  }
1830
- // Parallel decisions are not narrated as steps, so the header counts Commands to match the lines below.
1831
- const commandCount = trace.filter((e) => !isDecisionEntry(e)).length;
1832
- const stepsText = commandCount === 1 ? 'step' : 'steps';
1833
- log(`Replaying '${flowName || 'flow'}' (${commandCount} recorded ${stepsText})`);
1834
- log(`Initial input: ${format(initialInput)}`);
2295
+ return warnings;
2296
+ };
1835
2297
 
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.
2298
+ /**
2299
+ * Looks up how long each recorded step took, for narration: by path, or by position for a trace without paths.
2300
+ * @param {TraceEntry[]} trace
2301
+ * @returns {(step: ReplayStep) => string}
2302
+ */
2303
+ const stepTimings = (trace) => {
1838
2304
  const byPath = new Map(trace.filter(hasPath).map((e) => [e.path, e]));
1839
- const timing = (/** @type {ReplayStep} */ step) => {
2305
+ return (step) => {
1840
2306
  const recorded = byPath.get(step.path) ?? trace[step.index];
1841
2307
  return typeof recorded?.durationMs === 'number' ? ` in ${recorded.durationMs}ms` : '';
1842
2308
  };
1843
- const replay = await replayEffect(flowFn(initialInput), traceLog, {
1844
- context: context !== undefined ? context : traceLog.context || {},
1845
- onResolved: (step, outcome) => {
1846
- // A step the trace does not hold is refused or run live by `replayEffect`, not narrated here.
1847
- if (outcome === undefined) return;
1848
- log(
1849
- 'error' in outcome
1850
- ? `Step ${step.index + 1}: ${step.name} threw${timing(step)} ${format(outcome.error)}`
1851
- : `Step ${step.index + 1}: ${step.name} returned${timing(step)} ${format(outcome.result)}`
1852
- );
1853
- }
1854
- });
1855
- const { result, unreached = [] } = replay;
2309
+ };
1856
2310
 
1857
- log(`Replay finished with state: ${result.type}`);
1858
- log(result.type === 'Failure' ? `Error: ${format(result.error)}` : `Result: ${format(result.value)}`);
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.
1861
- const haltedByReplay = result.type === 'Failure' && /^(TimeParadox|ReplayError)$/.test(result.error?.name);
1862
- if (unreached.length > 0 && !haltedByReplay) {
1863
- // Named, not just counted: the step a fix stopped issuing is usually the one under suspicion.
1864
- const names = unreached.map((e) => (hasPath(e) ? `${e.command} (path '${e.path}')` : e.command));
1865
- const count = unreached.length === 1 ? '1 recorded step was' : `${unreached.length} recorded steps were`;
1866
- log(`Warning: ${count} never reached: ${names.join(', ')}. The flow diverged.`);
2311
+ /**
2312
+ * The warning for recorded steps a replay never reached, or `undefined`. A replay fault already names where the flow
2313
+ * diverged, and is told by its mark, since a flow's own error can share its name.
2314
+ * @param {SuccessState | FailureState} result
2315
+ * @param {TraceEntry[]} unreached
2316
+ * @returns {string | undefined}
2317
+ */
2318
+ const unreachedWarning = (result, unreached) => {
2319
+ const haltedByReplay = result.type === 'Failure' && hasMark(result.error, replayFault);
2320
+ if (unreached.length === 0 || haltedByReplay) return undefined;
2321
+ const names = unreached.map((e) => (hasPath(e) ? `${e.command} (path '${e.path}')` : e.command));
2322
+ const count = unreached.length === 1 ? '1 recorded step was' : `${unreached.length} recorded steps were`;
2323
+ return `Warning: ${count} never reached: ${names.join(', ')}. The flow diverged.`;
2324
+ };
2325
+
2326
+ /**
2327
+ * Prints a value for narration. An Error's hidden `message` is spread in, and a value JSON cannot print falls back to
2328
+ * `String`, since narration must not be what fails a replay.
2329
+ * @param {any} v
2330
+ * @returns {string | undefined}
2331
+ */
2332
+ const formatForLog = (v) => {
2333
+ try {
2334
+ return JSON.stringify(v instanceof Error ? { ...v, name: v.name, message: v.message } : v, null, 2);
2335
+ } catch {
2336
+ return String(v);
1867
2337
  }
1868
- return result;
1869
2338
  };
1870
2339
 
2340
+ // #endregion
2341
+
1871
2342
  export {
1872
2343
  Success,
1873
2344
  Failure,