pure-effect 0.17.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
@@ -4,22 +4,26 @@
4
4
  //
5
5
  // 1. Types: the JSDoc shapes of the nodes a flow is made of.
6
6
  // 2. Building flows: the constructors; the checks, error messages and kinds of failure the later sections
7
- // share; and `effectPipe`, with the `chain` that joins its steps.
8
- // 3. Configuration: the hook types and the library's defaults, `configureEffect` and its layers, and
9
- // `chainHooks`, which merges them.
10
- // 4. Running flows: the helpers `Retry` and `Parallel` run on, the interpreter, and `runEffect`.
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`.
11
13
  // 5. Recording and replay: the trace format and replay errors, copying values and errors into a trace,
12
- // `recorder`, built on `observeSteps`, and `recordEffect`, then `fromTrace`, `replayEffect` and `timeTravel`.
14
+ // `recorder` and `recordEffect`, then `fromTrace`, `replayEffect` and `timeTravel`, each followed by the
15
+ // functions it hands its parts to.
13
16
  //
14
- // A new definition goes in the section it serves, which is usually the one that calls it.
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.
15
19
 
16
20
  // #region Types
17
21
 
18
- /** @typedef {{ type: 'Success', value: any, initialInput?: any }} SuccessState */
19
- /** @typedef {{ type: 'Failure', error: any, initialInput?: any }} FailureState */
22
+ /** @typedef {{ type: 'Success', value: any }} SuccessState */
23
+ /** @typedef {{ type: 'Failure', error: any }} FailureState */
20
24
  /**
21
- * Metadata attached to a Command. A string `name` is read by the interpreter as the Command's
22
- * identity; every other key is carried through untouched for `onBeforeCommand`.
25
+ * Metadata attached to a Command. A string `name` is the Command's identity; every other key is carried through
26
+ * for `onBeforeCommand`.
23
27
  * @typedef {{ name?: string } & Record<string, any>} CommandMeta
24
28
  */
25
29
  /**
@@ -27,15 +31,13 @@
27
31
  * type: 'Command',
28
32
  * cmd: (signal?: AbortSignal) => Promise<any>|any,
29
33
  * next: (result: any) => Effect,
30
- * meta?: any,
31
- * initialInput?: any
34
+ * meta?: any
32
35
  * }} CommandState
33
36
  */
34
37
  /**
35
38
  * @typedef {{
36
39
  * type: 'Ask',
37
- * next: (context: any) => Effect,
38
- * initialInput?: any
40
+ * next: (context: any) => Effect
39
41
  * }} AskState
40
42
  */
41
43
 
@@ -43,9 +45,8 @@
43
45
  * @typedef {{
44
46
  * type: 'Retry',
45
47
  * effect: Effect,
46
- * options: { attempts?: number, delay?: number, backoff?: number, onExhausted?: (error: any) => Effect },
47
- * next: (value: any) => Effect,
48
- * initialInput?: any
48
+ * options: Readonly<{ attempts?: number, delay?: number, backoff?: number, onExhausted?: (error: any) => Effect }>,
49
+ * next: (value: any) => Effect
49
50
  * }} RetryState
50
51
  */
51
52
 
@@ -60,13 +61,12 @@
60
61
  * type: 'Parallel',
61
62
  * effects: Effect[],
62
63
  * next: (values: any[]) => Effect,
63
- * options?: ParallelOptions,
64
- * initialInput?: any
64
+ * options?: Readonly<ParallelOptions>
65
65
  * }} ParallelState
66
66
  */
67
67
 
68
68
  /**
69
- * The Union type for all possible states
69
+ * Every node a flow can be made of.
70
70
  * @typedef {SuccessState | FailureState | CommandState | AskState | RetryState | ParallelState} Effect
71
71
  */
72
72
 
@@ -81,28 +81,21 @@
81
81
  const Success = (value) => ({ type: 'Success', value });
82
82
 
83
83
  /**
84
- * Represents a failed computation. Stops the pipeline execution
84
+ * Represents a failed computation. Stops the pipeline.
85
85
  * @param {any} error - The error reason (string, Error object, etc).
86
- * @param {any} [initialInput] - initial input passed to the flow (optional)
87
86
  * @returns {FailureState}
88
87
  */
89
- const Failure = (error, initialInput) => ({
90
- type: 'Failure',
91
- error,
92
- initialInput
93
- });
88
+ const Failure = (error) => ({ type: 'Failure', error });
94
89
 
95
90
  /**
96
91
  * Represents a side effect to be executed later.
97
92
  *
98
- * @param {(signal?: AbortSignal) => Promise<any>|any} cmd - The side-effect function to execute. Inside a
99
- * `Parallel` branch, a function that declares a parameter receives an `AbortSignal` in it that fires when a
100
- * sibling branch fails, so I/O that accepts one can be cancelled in flight. A parameter with a default value
101
- * does not count, so `nanoid(size = 21)` keeps its default. Outside a `Parallel` no argument is passed.
102
- * @param {(result: any) => Effect} [next] - Receives the result of `cmd` and returns the next Effect.
103
- * Defaults to `(result) => Success(result)`, which is what most Commands want; `null` counts as omitted.
104
- * @param {CommandMeta} [meta] - Optional metadata, passed to `onBeforeCommand`. A string `meta.name`
105
- * 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.
106
99
  * @returns {CommandState}
107
100
  */
108
101
  const Command = (cmd, next, meta) => {
@@ -127,9 +120,8 @@ const Command = (cmd, next, meta) => {
127
120
  };
128
121
 
129
122
  /**
130
- * The name a Command is known by: what a trace records, what replay matches on, and what a
131
- * telemetry span is called. A non-empty string `meta.name`, else `cmd.name`, else 'anonymous'.
132
- * 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.
133
125
  *
134
126
  * @param {CommandState} eff
135
127
  * @returns {string}
@@ -156,60 +148,49 @@ const Ask = (next) => {
156
148
  };
157
149
 
158
150
  /**
159
- * Wraps an Effect tree with retry-on-failure semantics.
151
+ * Runs `effect` again when a Command in it throws.
160
152
  *
161
- * Each attempt runs the **entire** wrapped tree again, including Commands that already succeeded, so
162
- * wrap the one Command that fails transiently rather than a pipeline. `Retry(effectPipe(charge, receipt))`
163
- * charges the customer again every time the receipt step fails. Wrapping a pipeline is only safe when
164
- * 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.
165
156
  *
166
157
  * @param {Effect} effect - The inner Effect tree to retry
167
- * @param {Object} [options] - Retry options, merged over the library defaults at runtime. An option set to
168
- * `undefined` keeps its default, as an absent config key does.
169
- * @param {number} [options.attempts] - Max retries (not counting first try), a positive integer
170
- * @param {number} [options.delay] - Ms before first retry, a finite number of 0 or more
171
- * @param {number} [options.backoff] - Multiplier applied to delay on each subsequent retry, a finite number
172
- * of 0 or more
173
- * @param {(error: any) => Effect} [options.onExhausted] - Runs a fallback Effect when every attempt has
174
- * failed, receiving `{ retryExhausted, lastError, attempts }`. The fallback's success feeds `next`
175
- * exactly as the primary's would have; its failure propagates unwrapped. A fallback never starts
176
- * 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.
177
165
  * @returns {RetryState}
178
166
  */
179
167
  const Retry = (effect, options) => {
180
168
  if (!isEffect(effect)) throw malformed(`Retry expects the Effect to run, got ${describeValue(effect)}.`, effect);
181
- if (options != null && !isOptionsObject(options)) {
182
- const hint = typeof options === 'number' ? `: write Retry(effect, { attempts: ${options} })` : '';
183
- 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
+ );
184
175
  }
185
- rejectUnknownOptions(options, 'Retry', ['attempts', 'delay', 'backoff', 'onExhausted'], (m) =>
186
- malformed(m, options)
187
- );
188
- 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) };
189
178
  };
190
179
 
191
180
  /**
192
- * Runs multiple Effect trees concurrently. The first branch to fail cancels its siblings, and that
193
- * branch's Failure is what the Parallel returns; `next` is skipped. Which branch fails first depends on
194
- * timing, so it is recorded, and a replay returns the same one.
195
- *
196
- * A cancelled branch starts no further Commands. Stopping the Command already in flight needs its
197
- * function to pass the `AbortSignal` it receives to whatever performs the I/O; one that ignores the
198
- * signal runs to completion, so a branch's first Command can still write after a sibling has failed.
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.
199
184
  *
200
- * `settled: true` turns off that first-failure rule: every branch runs to completion and `next`
201
- * receives the branch outcomes, `Success` and `Failure` nodes in array order. An `EffectTypeError`
202
- * still escapes. `limit: n` keeps at most `n` branches in flight. Results and paths stay in array
203
- * order either way.
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.
204
187
  *
205
- * The second argument is the `next` function or the options, whichever it looks like, so
206
- * `Parallel(effects, { limit: 5 })` needs no placeholder. `undefined` or `null` there is a skipped `next`,
207
- * 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.
208
190
  *
209
191
  * @param {Effect[]} effects - Array of Effect trees to run concurrently
210
- * @param {((values: any[]) => Effect) | ParallelOptions | null} [nextOrOptions] - Receives array of success
211
- * values in order and returns the next Effect, or the options. `next` defaults to
212
- * `(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.
213
194
  * @param {ParallelOptions} [maybeOptions] - Options, when `next` was given or skipped
214
195
  * @returns {ParallelState}
215
196
  */
@@ -221,14 +202,13 @@ const Parallel = (effects, nextOrOptions, maybeOptions) => {
221
202
  if (!isEffect(branch)) throw malformed(`Parallel's branch ${i} is ${describeValue(branch)}.`, branch);
222
203
  });
223
204
  const hasNext = typeof nextOrOptions === 'function';
224
- // A caller forwarding an absent `next` still passes its options third, as the signature reads.
225
205
  const nextSkipped = nextOrOptions == null;
226
206
  const optionsSecond = !hasNext && !nextSkipped;
227
207
  if (optionsSecond && !isOptionsObject(nextOrOptions)) {
228
208
  const got = describeArgument(nextOrOptions);
229
209
  throw malformed(`Parallel's second argument must be next or the options, got ${got}.`, nextOrOptions);
230
210
  }
231
- // 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.
232
212
  if (optionsSecond && maybeOptions != null) {
233
213
  throw malformed(
234
214
  typeof maybeOptions === 'function'
@@ -239,20 +219,17 @@ const Parallel = (effects, nextOrOptions, maybeOptions) => {
239
219
  );
240
220
  }
241
221
  const options = optionsSecond ? nextOrOptions : maybeOptions;
242
- if (options != null && !isOptionsObject(options)) {
243
- throw malformed(`Parallel's options must be an object, got ${describeArgument(options)}.`, options);
244
- }
245
- rejectUnknownOptions(options, 'Parallel', ['limit', 'settled'], (m) => malformed(m, options));
222
+ checkOptions(options, 'Parallel', parallelOptionRules, malformed);
246
223
  return {
247
224
  type: 'Parallel',
248
225
  effects,
249
226
  next: hasNext ? nextOrOptions : (/** @type {any[]} */ values) => Success(values),
250
- options: options ?? {}
227
+ options: Object.freeze({ ...options })
251
228
  };
252
229
  };
253
230
 
254
231
  /**
255
- * 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.
256
233
  * @param {any} value
257
234
  * @returns {boolean}
258
235
  */
@@ -270,6 +247,18 @@ const isPositiveInteger = (value) => Number.isInteger(value) && value >= 1;
270
247
  */
271
248
  const isFiniteNonNegative = (value) => Number.isFinite(value) && value >= 0;
272
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
+
273
262
  /**
274
263
  * An object that can hold options or meta: not an array, and not an Effect passed in the wrong place.
275
264
  * @param {any} value
@@ -278,21 +267,78 @@ const isFiniteNonNegative = (value) => Number.isFinite(value) && value >= 0;
278
267
  const isOptionsObject = (value) => isObject(value) && !Array.isArray(value) && !isEffect(value);
279
268
 
280
269
  /**
281
- * Refuses an option name a function does not read. A misspelt name, or one borrowed from another library, as
282
- * `concurrency` is from p-limit, otherwise ran with the default and nothing to say so.
270
+ * Refuses an option name the function does not read, such as p-limit's `concurrency` passed to `Parallel`.
283
271
  * @param {any} options
284
272
  * @param {string} source - The function that takes them, as the message names it
285
273
  * @param {string[]} known
286
- * @param {(message: string) => Error} [raise] - Builds the error; a constructor's argument errors are EffectTypeErrors
274
+ * @param {(message: string, value: any) => Error} [raise] - Builds the error; a constructor's are EffectTypeErrors
287
275
  */
288
276
  const rejectUnknownOptions = (options, source, known, raise = (message) => new TypeError(message)) => {
289
277
  const name = isObject(options) ? Object.keys(options).find((key) => !known.includes(key)) : undefined;
290
278
  if (name !== undefined) {
291
279
  const list = `${known.slice(0, -1).join(', ')} and ${known[known.length - 1]}`;
292
- throw raise(`${source} has no option named '${name}'; its options are ${list}.`);
280
+ throw raise(`${source} has no option named '${name}'; its options are ${list}.`, options);
293
281
  }
294
282
  };
295
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
+
296
342
  /**
297
343
  * Describes a value for an error message, leading with the mistake it most likely is.
298
344
  * @param {any} value
@@ -304,17 +350,18 @@ const describeValue = (value) => {
304
350
  if (value instanceof Promise) return 'a Promise, which usually means an async function';
305
351
  if (typeof value === 'function')
306
352
  return 'a function, which usually means a flow was passed without being called with its input';
307
- // `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.
308
354
  if (typeof value !== 'object') {
309
355
  return `the ${typeof value} ${typeof value === 'string' ? JSON.stringify(value) : String(value)}`;
310
356
  }
357
+ if (Array.isArray(value)) return 'an array';
311
358
  if (typeof value.type === 'string') return `an object with an unrecognised type '${value.type}'`;
312
359
  return 'a plain object';
313
360
  };
314
361
 
315
362
  /**
316
- * Describes a constructor's argument for an error message. A missing one is usually a misspelt name
317
- * 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.
318
365
  * @param {any} value
319
366
  * @returns {string}
320
367
  */
@@ -322,11 +369,10 @@ const describeArgument = (value) =>
322
369
  value === undefined ? 'undefined' : isEffect(value) ? `an Effect of type '${value.type}'` : describeValue(value);
323
370
 
324
371
  /**
325
- * Marks an error as the harness failing rather than the flow: a malformed flow, or a trace that cannot
326
- * answer a step. The interpreter rethrows anything carrying it instead of folding it into a `Failure`, so
327
- * nothing that handles a domain failure can absorb it. A new kind of harness error gets this mark rather
328
- * than a name the interpreter has to know. Non-enumerable, so it never shows up in a serialized error or
329
- * 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`.
330
376
  */
331
377
  const harnessError = Symbol('pure-effect.harnessError');
332
378
 
@@ -345,37 +391,34 @@ const asHarnessError = (error) => Object.defineProperty(error, harnessError, { v
345
391
  const hasMark = (e, mark) => Boolean(e && /** @type {any} */ (e)[mark]);
346
392
 
347
393
  /**
348
- * An I/O fault: a Command's function threw, or a `Retry` ran out of attempts. `Retry` retries this, never a
349
- * `Failure` a step returned, which is an abort. Internal: only `execute` and its per-node functions return
350
- * one, and `asOutcome` turns it into a plain `Failure` before it reaches user code.
351
- * @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
352
398
  */
353
399
 
354
400
  /**
355
401
  * @param {any} error
356
- * @param {any} [initialInput]
357
402
  * @returns {IoFaultState}
358
403
  */
359
- const IoFault = (error, initialInput) => ({ type: 'IoFault', error, initialInput });
404
+ const IoFault = (error) => ({ type: 'IoFault', error });
360
405
 
361
406
  /**
362
407
  * @param {SuccessState | FailureState | IoFaultState} state
363
408
  * @returns {SuccessState | FailureState}
364
409
  */
365
- const asOutcome = (state) => (state.type === 'IoFault' ? Failure(state.error, state.initialInput) : state);
410
+ const asOutcome = (state) => (state.type === 'IoFault' ? Failure(state.error) : state);
366
411
 
367
412
  /**
368
- * Builds an `EffectTypeError`, for a malformed flow: a step or continuation that returned something other
369
- * than an Effect, or a constructor given an argument it cannot use. It is a harness error: a malformed flow
370
- * 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.
371
414
  *
372
415
  * @param {string} message
373
416
  * @param {any} value - The malformed value
374
417
  * @returns {Error}
375
418
  */
376
419
  const malformed = (message, value) => {
377
- // This error already reports the bug, so the Promise rejecting must not also crash the process as
378
- // 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.
379
422
  if (value instanceof Promise) value.catch(() => {});
380
423
  return asHarnessError(Object.assign(new Error(message), { name: 'EffectTypeError' }));
381
424
  };
@@ -437,51 +480,31 @@ const asEffect = (value, source) => {
437
480
  };
438
481
 
439
482
  /**
440
- * Connects an Effect to the next function in the pipeline.
441
- * 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`.
442
485
  *
443
486
  * @param {Effect} effect - The current Effect object
444
487
  * @param {(value: any) => Effect} fn - The next function to run if the current effect is a Success
445
- * @param {any} [initialInput] - The pipeline's starting value, stamped on every node but a Success
446
488
  * @param {Effect} [from] - The node whose `next` returned `effect`, which an error names
447
489
  * @returns {Effect} The composed Effect
448
490
  */
449
- const chain = (effect, fn, initialInput, from) => {
450
- const source = () => (from ? nextOf(from) : 'A continuation');
451
- // Overwrites a sub-pipeline's own stamp, so the root and a Failure at any depth carry the input of the
452
- // flow that was actually called. A Success stays bare, so `deepEqual(result, Success(v))` always holds.
453
- const withII = (/** @type {Effect} */ e) =>
454
- initialInput !== undefined && e.type !== 'Success' ? { ...e, initialInput } : e;
455
-
456
- // Checked before `effect.type` is read, which would otherwise throw a bare TypeError naming no step.
457
- if (effect == null) return asEffect(effect, source());
458
-
459
- switch (effect.type) {
460
- case 'Success':
461
- return withII(asEffect(fn(effect.value), `Step '${fn.name || 'anonymous'}'`));
462
- case 'Failure':
463
- return withII(effect);
464
- case 'Command': {
465
- const next = (/** @type {any} */ result) => chain(effect.next(result), fn, initialInput, effect);
466
- return withII(Command(effect.cmd, next, effect.meta));
467
- }
468
- case 'Ask': {
469
- const next = (/** @type {any} */ ctx) => chain(effect.next(ctx), fn, initialInput, effect);
470
- return withII(Ask(next));
471
- }
472
- case 'Retry': {
473
- const next = (/** @type {any} */ result) => chain(effect.next(result), fn, initialInput, effect);
474
- return withII({ ...effect, next });
475
- }
476
- case 'Parallel': {
477
- const next = (/** @type {any} */ result) => chain(effect.next(result), fn, initialInput, effect);
478
- return withII({ ...effect, next });
479
- }
480
- default:
481
- return asEffect(effect, source());
482
- }
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 };
483
499
  };
484
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
+
485
508
  /**
486
509
  * Composes a list of functions into a single Effect pipeline.
487
510
  * Each function receives the output of the previous one.
@@ -502,11 +525,11 @@ const effectPipe = (...fns) => {
502
525
  }
503
526
  });
504
527
  return (start) => {
505
- const chainWithII = (/** @type {Effect} */ eff, /** @type {(v: any) => Effect} */ fn) => chain(eff, fn, start);
506
- const tree = fns.reduce(chainWithII, /** @type {Effect} */ (Success(start)));
507
- // An identity pass. `chain` stamps a sub-pipeline's nodes only through the continuations it wraps,
508
- // and a sub-pipeline returned by the last step has no later step to wrap them.
509
- 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;
510
533
  };
511
534
  };
512
535
 
@@ -514,49 +537,66 @@ const effectPipe = (...fns) => {
514
537
  // #region Configuration
515
538
 
516
539
  /**
517
- * Wraps one Command execution, or one Parallel: `type` is 'Parallel', and `op` runs the branches and
518
- * returns the Parallel's decision, even when a branch threw, so a hook must call it. Only a replay passes
519
- * `op` an argument, the recorded decision. `path` is the step's position in the Effect tree, so it matches
520
- * 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.
521
542
  * @typedef {(name: string, type: string, op: function, path?: string) => Promise<any>} StepRunner
522
543
  */
523
544
  /** @type StepRunner */
524
545
  const defaultStepRunner = async (name, type, op) => await op();
525
546
 
526
- /** @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
+ */
527
551
  /** @type RunWrapper */
528
- const defaultRunWrapper = async (effect, op, flowName) => await op();
552
+ const defaultRunWrapper = async (effect, op, flowName, initialInput) => await op();
529
553
 
530
554
  /** @typedef {(command: CommandState, context?: any) => Promise<any>} CommandInterceptor */
531
555
  /** @type CommandInterceptor */
532
556
  const defaultCommandInterceptor = async (command, context) => {};
533
557
 
534
- const defaultRetryOptions = { attempts: 3, delay: 100, backoff: 1 };
558
+ /** The hooks a configuration can set. */
559
+ const hookNames = ['onStep', 'onRun', 'onBeforeCommand'];
535
560
 
536
561
  /**
537
- * Refuses the removed `retry` key rather than ignoring it, which would quietly turn a configured
538
- * `attempts: 5` back into 3. Migration scaffolding: remove at 1.0.
539
- * @param {any} config
540
- * @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}
541
566
  */
542
- const rejectRetryKey = (config, source) => {
543
- if (isObject(config) && 'retry' in config)
544
- throw new TypeError(
545
- `${source} no longer takes 'retry'. Retry options are per-use: pass them to Retry(effect, options).`
546
- );
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
+ }
547
588
  };
548
589
 
549
590
  /**
550
591
  * @typedef {Object} EffectConfiguration
551
592
  * @property {StepRunner} [onStep] - Wraps each Command's execution, and each Parallel's branches.
552
- * @property {RunWrapper} [onRun] - Fires once per runEffect call. It wraps the entire workflow execution.
553
- * @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.
554
595
  */
555
596
 
556
597
  /**
557
- * A per-call configuration: an `EffectConfiguration` plus `inherit`. With `inherit: true` (the default)
558
- * the call's hooks are merged over the wiring `configureEffect` installed, global outermost. With
559
- * `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.
560
600
  *
561
601
  * @typedef {EffectConfiguration & { inherit?: boolean }} CallConfiguration
562
602
  */
@@ -565,8 +605,8 @@ const rejectRetryKey = (config, source) => {
565
605
  let layers = [];
566
606
 
567
607
  /**
568
- * The installed layers merged into one configuration. A slot no layer defines is absent, and `runEffect`
569
- * 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.
570
610
  * @type {EffectConfiguration}
571
611
  */
572
612
  let globalConfig = {};
@@ -577,22 +617,14 @@ const applyLayers = () => {
577
617
  };
578
618
 
579
619
  /**
580
- * Adds a configuration to the global wiring of the Effect runner: telemetry and the command
581
- * interceptor. Retry options are not part of it; they are per-use, passed to `Retry(effect, options)`.
582
- *
583
- * Each call adds one layer on top of those already installed and returns a function that removes that
584
- * layer, wherever it sits by then. Layers merge the way several configurations passed to one call do:
585
- * `onStep` and `onRun` are wrappers, so they nest with the earliest layer outermost and the latest
586
- * closest to the Command; and `onBeforeCommand` interceptors run in the order installed, the first to throw
587
- * vetoing the Command. So these
588
- * 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:
589
622
  *
590
623
  * configureEffect(telemetryHooks(), recordingHooks({ sink }));
591
624
  * configureEffect(telemetryHooks()); configureEffect(recordingHooks({ sink }));
592
625
  *
593
- * Calling it with no arguments at all removes every layer. A call whose arguments are all `undefined`,
594
- * such as `configureEffect(flag ? hooks : undefined)`, is a conditional install that installed nothing:
595
- * 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.
596
628
  *
597
629
  * @param {...(EffectConfiguration | undefined)} configs - Configurations merged into one layer, outermost first
598
630
  * @returns {() => void} Removes the layer this call added; a second call does nothing
@@ -603,9 +635,14 @@ const configureEffect = (...configs) => {
603
635
  applyLayers();
604
636
  return () => {};
605
637
  }
606
- const present = configs.filter(Boolean);
638
+ const present = configs.filter((config) => config !== undefined);
607
639
  // Before installing anything, so a refused call leaves the wiring untouched.
608
- 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
+ });
609
646
  if (present.length === 0) return () => {};
610
647
  const layer = chainHooks(...present);
611
648
  layers = [...layers, layer];
@@ -618,13 +655,9 @@ const configureEffect = (...configs) => {
618
655
  };
619
656
 
620
657
  /**
621
- * Merges several configurations into one, so independent concerns can share the hooks.
622
- *
623
- * `onStep` and `onRun` are wrappers around an `op`, so they nest: the first config given is the
624
- * outermost and the last sits closest to the Command, so a thrown Command unwinds from the last back
625
- * to the first. `onBeforeCommand` interceptors do not nest: they run in the order given, and the first to
626
- * throw vetoes the Command, so the ones after it do not run. A hook
627
- * 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.
628
661
  *
629
662
  * @param {...(EffectConfiguration | undefined)} configs - Configurations to merge, outermost first
630
663
  * @returns {EffectConfiguration}
@@ -649,7 +682,8 @@ const chainHooks = (...configs) => {
649
682
  }
650
683
  if (runs.length) {
651
684
  merged.onRun = runs.reduceRight(
652
- (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)
653
687
  );
654
688
  }
655
689
  if (interceptors.length) {
@@ -664,23 +698,23 @@ const chainHooks = (...configs) => {
664
698
  // #region Running flows
665
699
 
666
700
  /**
667
- * The Failure a cancelled `Parallel` branch stops with. Its error is named so it is never mistaken for
668
- * the failure that triggered the cancellation.
669
- * @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.
670
703
  * @returns {FailureState}
671
704
  */
672
- const cancelledBranch = (initialInput) =>
673
- 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' }));
674
707
 
675
708
  /**
676
- * Which branch, if any, cancelled a Parallel. `branch: null` means an enclosing Parallel cancelled it.
677
- * It is recorded as the Parallel's own step, since it is decided by timing and a replay cannot recompute it.
678
- * @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
679
713
  */
680
714
 
681
715
  /**
682
- * What running a Parallel's branches produced: each branch's outcome in array order, the decision, and
683
- * 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.
684
718
  * @typedef {{
685
719
  * results: (SuccessState | FailureState | IoFaultState)[],
686
720
  * decision: ParallelDecision,
@@ -696,25 +730,30 @@ const cancelledBranch = (initialInput) =>
696
730
  const isBranchIndex = (branch, count) => Number.isInteger(branch) && branch >= 0 && branch < count;
697
731
 
698
732
  /**
699
- * Reads a recorded decision, or `undefined` for anything that is not one, which replays the Parallel
700
- * 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.
701
741
  * @param {any} value
702
742
  * @param {number} branches - How many branches the Parallel has, so a stale branch index is refused too
703
743
  * @returns {ParallelDecision | undefined}
704
744
  */
705
745
  const asDecision = (value, branches) => {
706
746
  if (!isObject(value)) return undefined;
707
- if (value.cancelled === false) return { cancelled: false };
747
+ if (value.cancelled === false) return { cancelled: false, branches };
708
748
  if (value.cancelled !== true) return undefined;
709
749
  const { branch } = value;
710
- if (branch === null) return { cancelled: true, branch: null };
711
- 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;
712
752
  };
713
753
 
714
754
  /**
715
- * Marks a step a replay reached that production never ran, in a branch a recorded decision cancelled.
716
- * Only `fromTrace` raises it, and `runCommand` and `runParallel` catch it and stop the branch there, as
717
- * production did.
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.
718
757
  */
719
758
  const replayCut = Symbol('pure-effect.replayCut');
720
759
 
@@ -730,27 +769,26 @@ const replayCutError = (path) =>
730
769
  );
731
770
 
732
771
  /**
733
- * A cancellation scope for one Parallel, linked to the enclosing one so cancellation nests. Without an
734
- * `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.
735
773
  * @param {AbortSignal} [signal] - The enclosing Parallel's signal, if any
736
- * @returns {{ scope: AbortController | undefined, unlink: () => void }}
774
+ * @returns {{ scope: AbortController, unlink: () => void }}
737
775
  */
738
776
  const linkedScope = (signal) => {
739
- const scope = typeof AbortController === 'function' ? new AbortController() : undefined;
740
- const relay = () => scope?.abort();
741
- if (signal && scope) {
777
+ const scope = new AbortController();
778
+ const relay = () => scope.abort();
779
+ if (signal) {
742
780
  if (signal.aborted) scope.abort();
743
781
  else signal.addEventListener('abort', relay, { once: true });
744
782
  }
745
783
  const unlink = () => {
746
- if (signal && scope) signal.removeEventListener('abort', relay);
784
+ if (signal) signal.removeEventListener('abort', relay);
747
785
  };
748
786
  return { scope, unlink };
749
787
  };
750
788
 
751
789
  /**
752
- * Awaits every task, with at most `limit` of them in flight. Workers pull by index, so results land where
753
- * 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.
754
792
  * @param {(() => Promise<void>)[]} tasks
755
793
  * @param {number} [limit]
756
794
  * @returns {Promise<void>}
@@ -768,8 +806,8 @@ const runBounded = async (tasks, limit) => {
768
806
  };
769
807
 
770
808
  /**
771
- * Sleeps, but gives up early when the surrounding branch is cancelled. Resolves either way: the
772
- * 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.
773
811
  *
774
812
  * @param {number} ms
775
813
  * @param {AbortSignal} [signal]
@@ -781,8 +819,8 @@ const delayFor = (ms, signal) =>
781
819
  setTimeout(resolve, ms);
782
820
  return;
783
821
  }
784
- // A listener added to an aborted signal never runs, and this is the usual case: a Command that
785
- // 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.
786
824
  if (signal.aborted) {
787
825
  resolve();
788
826
  return;
@@ -798,397 +836,406 @@ const delayFor = (ms, signal) =>
798
836
  signal.addEventListener('abort', onAbort, { once: true });
799
837
  });
800
838
 
801
- const interpret =
802
- /**
803
- * The interpreter: walks the Effect tree, executing Commands and resolving Ask, Retry, and Parallel.
804
- * `runEffect` and `replayEffect` both run flows through it, so a replay cannot drift from a run.
805
- * `fastRetry` is the one thing only a replay sets: it waits no time between retry attempts, since a
806
- * delay is written on the Retry node and nothing else can override it.
807
- *
808
- * @param {Effect} effect
809
- * @param {any} [context]
810
- * @param {CallConfiguration} [callConfig]
811
- * @param {boolean} [fastRetry]
812
- * @returns {Promise<SuccessState | FailureState>}
813
- */
814
- async function interpret(effect, context = {}, callConfig = {}, fastRetry = false) {
815
- rejectRetryKey(callConfig, "runEffect's callConfig");
816
- const { inherit = true, ...local } = callConfig;
817
- // Not coerced: `'false'` quietly inheriting everything is the behaviour this option exists to remove.
818
- if (typeof inherit !== 'boolean') {
819
- throw new TypeError(`callConfig.inherit must be true or false, got ${JSON.stringify(inherit)}.`);
820
- }
821
- // Merged the way `configureEffect` merges layers. A call with no hooks of its own skips the merge.
822
- const base = inherit ? globalConfig : {};
823
- const resolved = Object.keys(local).length ? chainHooks(base, local) : base;
824
- const localStepRunner = resolved.onStep || defaultStepRunner;
825
- const localRunWrapper = resolved.onRun || defaultRunWrapper;
826
- const localCommandInterceptor = resolved.onBeforeCommand || defaultCommandInterceptor;
827
-
828
- /**
829
- * Walks a subtree until it reaches a Success or a Failure. Every node but `Ask` runs in its own
830
- * function, which returns a Success carrying the value for the node's `next`, or the Failure or I/O
831
- * fault that stops the subtree.
832
- *
833
- * @param {Effect} eff
834
- * @param {AbortSignal} [signal] - Cancellation for this subtree, set for `Parallel` branches.
835
- * @param {string} [path] - Prefix for this subtree's position in the Effect tree. Steps are numbered
836
- * within a subtree and each `Parallel` branch and `Retry` attempt opens its own prefix, so a
837
- * path depends only on the tree's shape, never on the order branches finish in.
838
- * @returns {Promise<SuccessState | FailureState | IoFaultState>}
839
- */
840
- async function execute(eff, signal, path = '') {
841
- let step = 0;
842
- /** @type {Effect | undefined} The node whose `next` returned `eff`, which an error names. */
843
- let from;
844
- while (isPending(eff)) {
845
- // Checked before every node: a Command already in flight cannot be stopped, but the next never starts.
846
- if (signal?.aborted) return cancelledBranch(eff.initialInput);
847
- if (eff.type === 'Ask') {
848
- from = eff;
849
- eff = eff.next(context);
850
- continue;
851
- }
852
- const stepPath = `${path}${step++}`;
853
- const outcome =
854
- eff.type === 'Retry'
855
- ? await runRetry(eff, signal, stepPath)
856
- : eff.type === 'Parallel'
857
- ? await runParallel(eff, signal, stepPath)
858
- : await runCommand(eff, signal, stepPath);
859
- if (outcome.type !== 'Success') return outcome;
860
- // Outside every catch: `next` and the pure steps it reaches are code, not I/O, so a throw there
861
- // rejects the run.
862
- from = eff;
863
- eff = eff.next(outcome.value);
864
- }
865
- if (isOutcome(eff)) return eff;
866
- throw effectTypeError(eff, from ? nextOf(from) : 'The flow');
867
- }
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
+ */
868
849
 
869
- /**
870
- * Runs a Retry's wrapped tree until it succeeds or runs out of attempts, then its fallback if it has
871
- * one. Each attempt opens its own path prefix, and so does the fallback.
872
- *
873
- * @param {RetryState} retry
874
- * @param {AbortSignal | undefined} signal
875
- * @param {string} stepPath
876
- * @returns {Promise<SuccessState | FailureState | IoFaultState>}
877
- */
878
- async function runRetry(retry, signal, stepPath) {
879
- // An option set to `undefined` keeps its default, since that is how an absent config key arrives.
880
- const given = Object.entries(retry.options ?? {}).filter(([, value]) => value !== undefined);
881
- const opts = /** @type {typeof defaultRetryOptions & RetryState['options']} */ ({
882
- ...defaultRetryOptions,
883
- ...Object.fromEntries(given)
884
- });
885
- const { attempts, onExhausted } = opts;
886
- // `0` is refused rather than meaning run once: it would make `onExhausted` a free catch.
887
- if (!isPositiveInteger(attempts))
888
- throw new TypeError(
889
- `Retry 'attempts' must be a positive integer, received ${describeValue(attempts)}. ` +
890
- `To handle an outcome without retrying, branch on it as data in the Command's ` +
891
- `next, or isolate a failing branch with Parallel's settled option.`
892
- );
893
- // A wait that is NaN, negative or infinite used to be no wait at all, so a flapping dependency was
894
- // called back to back.
895
- for (const key of /** @type {const} */ (['delay', 'backoff'])) {
896
- if (!isFiniteNonNegative(opts[key]))
897
- throw new TypeError(
898
- `Retry '${key}' must be a finite number of 0 or more, received ${describeValue(opts[key])}.`
899
- );
900
- }
901
- let lastError;
902
- for (let attempt = 0; attempt <= attempts; attempt++) {
903
- // Under `fastRetry` this waits for no time rather than skipping the wait, so branches replayed by
904
- // timing still interleave as they do with a delay.
905
- if (attempt > 0) {
906
- await delayFor(fastRetry ? 0 : opts.delay * Math.pow(opts.backoff, attempt - 1), signal);
907
- }
908
- // After the wait, so a branch cancelled mid-backoff makes no further attempt.
909
- if (signal?.aborted) return cancelledBranch(retry.initialInput);
910
- const result = await execute(retry.effect, signal, `${stepPath}r${attempt}/`);
911
- // A Success feeds `next`. An abort is the flow deciding, not the I/O failing: not retried, not
912
- // wrapped.
913
- if (result.type !== 'IoFault') return result;
914
- lastError = result.error;
915
- }
916
- const exhausted = { retryExhausted: true, lastError, attempts };
917
- // An exhaustion is a fault too, which keeps an enclosing Retry retrying this one.
918
- if (typeof onExhausted !== 'function') return IoFault(exhausted, retry.initialInput);
919
- // A cancelled branch starts no fallback, as it starts no further Commands.
920
- if (signal?.aborted) return cancelledBranch(retry.initialInput);
921
- // A failing fallback propagates as it is, not wrapped as another exhaustion.
922
- 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;
923
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
+ };
924
924
 
925
- /**
926
- * Runs a Parallel as one step of its own, whose `op` runs the branches and returns the decision, so
927
- * the decision is recorded and a replay can hand it back.
928
- *
929
- * @param {ParallelState} parallel
930
- * @param {AbortSignal | undefined} signal
931
- * @param {string} stepPath
932
- * @returns {Promise<SuccessState | FailureState | IoFaultState>}
933
- */
934
- async function runParallel(parallel, signal, stepPath) {
935
- // Branch prefixes come from array index, never completion order, so two branches calling the same
936
- // Command are told apart by position.
937
- const branchPath = `${stepPath}p`;
938
- const options = parallel.options ?? {};
939
- const { limit, settled } = options;
940
- if (limit !== undefined && !isPositiveInteger(limit))
941
- throw new TypeError(`Parallel 'limit' must be a positive integer, received ${describeValue(limit)}.`);
942
- // Cast rather than annotated, since only `op` assigns it.
943
- let run = /** @type {BranchRun | undefined} */ (undefined);
944
- // A replay passes the recorded decision, from `replayEffect`'s onStep; a live run passes nothing.
945
- const op = async (/** @type {any} */ recorded) => {
946
- run = await runBranches(parallel.effects, options, signal, branchPath, recorded);
947
- return run.decision;
948
- };
949
- try {
950
- await localStepRunner('Parallel', 'Parallel', op, branchPath);
951
- } catch (e) {
952
- // A cut from `fromTrace`: a replay found this Parallel inside a branch production had already stopped.
953
- if (hasMark(e, replayCut)) return cancelledBranch(parallel.initialInput);
954
- throw e;
955
- }
956
- if (!run) {
957
- throw new TypeError(
958
- `An onStep hook returned without letting op run the Parallel at path '${branchPath}'. ` +
959
- 'A hook has to call op for a Parallel and pass on what it returns or throws, because op ' +
960
- 'runs its branches.'
961
- );
962
- }
963
- const { results, decision, thrown } = run;
964
- // Rethrown here, after the step has returned its decision, so the decision is recorded.
965
- if (thrown) throw thrown.error;
966
- // Settled hands `next` the outcomes as plain Success and Failure nodes, so the Parallel never fails
967
- // on a branch's account.
968
- if (settled) return Success(results.map(asOutcome));
969
- const failure =
970
- decision.cancelled && decision.branch !== null
971
- ? results[decision.branch]
972
- : results.find((r) => r.type !== 'Success');
973
- if (failure) return failure;
974
- 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);
975
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
+ };
976
963
 
977
- /**
978
- * Runs a Parallel's branches and decides which branch, if any, cancelled the others. In a live run
979
- * timing decides it, which is why the decision is recorded. A recorded cancellation passed in by a
980
- * replay is reproduced rather than recomputed: no branch cancels another, each runs its recorded steps
981
- * and stops where its recording stops, and the recorded branch's failure is the result.
982
- *
983
- * @param {Effect[]} effects
984
- * @param {ParallelOptions} options
985
- * @param {AbortSignal | undefined} signal - The enclosing Parallel's cancellation, if any
986
- * @param {string} branchPath
987
- * @param {any} recorded - The recorded decision, which `replayEffect`'s onStep passes to the Parallel's
988
- * `op`; undefined in a live run
989
- * @returns {Promise<BranchRun>}
990
- */
991
- async function runBranches(effects, options, signal, branchPath, recorded) {
992
- const { limit, settled } = options;
993
- // A recorded branch past the end means the flow changed shape since the recording. A negative one is
994
- // not a decision, so it replays by timing like anything else.
995
- const recordedBranch = recorded?.cancelled === true ? recorded.branch : undefined;
996
- const pastTheEnd = Number.isInteger(recordedBranch) && recordedBranch >= effects.length;
997
- if (pastTheEnd) {
998
- const count = effects.length === 1 ? '1 branch' : `${effects.length} branches`;
999
- throw timeParadoxAt(
1000
- `path '${branchPath}'`,
1001
- `the recorded run was cancelled by branch ${recordedBranch}, but this Parallel has ${count}.`,
1002
- { path: branchPath, branch: recordedBranch }
1003
- );
1004
- }
1005
- const forced = asDecision(recorded, effects.length);
1006
- const reproducing = forced !== undefined && forced.cancelled;
1007
- // Live, a throw cancels the others, and so does a failure unless `settled`; the run rejects on a throw
1008
- // either way, and an enclosing Parallel can still cancel this one. Reproducing, nothing cancels, since
1009
- // the recording already says where each branch stops.
1010
- const { results, thrown, trigger, cancelled } = await settleBranches(
1011
- effects,
1012
- limit,
1013
- signal,
1014
- branchPath,
1015
- 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 }
1016
1070
  );
1017
- if (reproducing) {
1018
- // A branch that threw has no result to check, and its throw is what is rethrown.
1019
- const recordedTriggerSucceeded =
1020
- !thrown && forced.branch !== null && results[forced.branch].type === 'Success';
1021
- if (recordedTriggerSucceeded) {
1022
- throw timeParadoxAt(
1023
- `path '${branchPath}'`,
1024
- `the recorded run was cancelled by branch ${forced.branch}, which did not fail in this replay.`,
1025
- { path: branchPath, branch: forced.branch }
1026
- );
1027
- }
1028
- return { results, decision: forced, thrown };
1029
- }
1030
- /** @type {ParallelDecision} */
1031
- const decision =
1032
- trigger >= 0
1033
- ? { cancelled: true, branch: trigger }
1034
- : cancelled
1035
- ? { cancelled: true, branch: null }
1036
- : { cancelled: false };
1037
- return { results, decision, thrown };
1038
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
+ };
1039
1084
 
1040
- /**
1041
- * Runs every branch to completion, at most `limit` at once, under one cancellation scope linked to the
1042
- * enclosing one. A branch that settles in a way `cancelsOthers` accepts cancels the rest, and the first
1043
- * to do so is the trigger.
1044
- *
1045
- * @param {Effect[]} effects
1046
- * @param {number | undefined} limit
1047
- * @param {AbortSignal | undefined} signal
1048
- * @param {string} branchPath
1049
- * @param {(threw: boolean, result: SuccessState | FailureState | IoFaultState) => boolean} cancelsOthers
1050
- * @returns {Promise<{
1051
- * results: (SuccessState | FailureState | IoFaultState)[],
1052
- * thrown: { error: unknown } | undefined,
1053
- * trigger: number,
1054
- * cancelled: boolean
1055
- * }>}
1056
- */
1057
- async function settleBranches(effects, limit, signal, branchPath, cancelsOthers) {
1058
- const { scope, unlink } = linkedScope(signal);
1059
- /** @type {(SuccessState | FailureState | IoFaultState)[]} */
1060
- const results = new Array(effects.length);
1061
- // Held rather than rethrown, so every branch still settles and no `limit` worker stops early.
1062
- /** @type {{ error: unknown }[]} */
1063
- const thrown = new Array(effects.length);
1064
- // Which branch cancelled the others on its own account rather than because it was cancelled.
1065
- const triggered = new Array(effects.length).fill(false);
1066
- const settle = async (/** @type {Effect} */ branch, /** @type {number} */ i) => {
1067
- try {
1068
- results[i] = await execute(branch, scope?.signal, `${branchPath}${i}/`);
1069
- } catch (error) {
1070
- thrown[i] = { error };
1071
- }
1072
- // Read-then-abort is atomic here, so exactly one branch is the trigger.
1073
- if (cancelsOthers(thrown[i] !== undefined, results[i])) {
1074
- if (!scope?.signal.aborted) triggered[i] = true;
1075
- scope?.abort();
1076
- }
1077
- };
1078
- try {
1079
- // Awaits every branch, so no cancelled work runs on unobserved after the Parallel returns.
1080
- await runBounded(
1081
- effects.map((branch, i) => () => settle(branch, i)),
1082
- limit
1083
- );
1084
- } finally {
1085
- unlink();
1086
- }
1087
- return {
1088
- results,
1089
- // The first by array order, since every branch has settled by now.
1090
- thrown: thrown.find(Boolean),
1091
- trigger: triggered.indexOf(true),
1092
- cancelled: Boolean(scope?.signal.aborted)
1093
- };
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 };
1094
1117
  }
1095
-
1096
- /**
1097
- * Runs one Command. A throw means something different in each region: an interceptor throwing vetoes
1098
- * the Command, which is an abort, and the Command's function throwing is an I/O fault. A harness error
1099
- * is rethrown from either.
1100
- *
1101
- * @param {CommandState} command
1102
- * @param {AbortSignal | undefined} signal
1103
- * @param {string} cmdPath
1104
- * @returns {Promise<SuccessState | FailureState | IoFaultState>}
1105
- */
1106
- async function runCommand(command, signal, cmdPath) {
1107
- const cmdName = commandName(command);
1108
- const { initialInput, cmd } = command;
1109
- // Whether the function itself succeeded: a hook throwing after it did is a bug, and retrying would
1110
- // repeat work already done.
1111
- let succeeded = false;
1112
- // What it returned, so a hook that loses it is caught.
1113
- /** @type {unknown} */
1114
- let value;
1115
- // The signal goes only to a function that declares a parameter for it, and only inside a Parallel. A
1116
- // parameter with a default value does not count toward `length`, so `nanoid(size = 21)` passed by name
1117
- // keeps its default rather than reading the signal as its size. A plain first parameter the function
1118
- // treats as optional still takes the signal: a documented sharp edge.
1119
- const takesSignal = signal !== undefined && cmd.length > 0;
1120
- // Async, so a hook always gets a promise, even from a synchronous function. The latest call is kept,
1121
- // with whether it is still running, so a hook that does not wait for it is caught.
1122
- /** @type {Promise<unknown> | undefined} */
1123
- let call;
1124
- let running = false;
1125
- const run = async () => {
1126
- succeeded = false;
1127
- running = true;
1128
- try {
1129
- value = await (takesSignal ? cmd(signal) : cmd());
1130
- succeeded = true;
1131
- return value;
1132
- } finally {
1133
- running = false;
1134
- }
1135
- };
1136
- const op = () => (call = run());
1137
- try {
1138
- await localCommandInterceptor(command, context);
1139
- } catch (e) {
1140
- if (hasMark(e, harnessError)) throw e;
1141
- return Failure(e, initialInput);
1142
- }
1143
- // Again, since an interceptor can wait (a rate limiter, say) while a sibling fails.
1144
- if (signal?.aborted) return cancelledBranch(initialInput);
1145
- let returned;
1146
- let unwaited = false;
1147
- try {
1148
- returned = await localStepRunner(cmdName, 'Command', op, cmdPath);
1149
- // A hook that called `op` without waiting for it returned while the Command ran, so the check below
1150
- // would have nothing to check yet. Waiting here judges the step as though the hook had awaited `op`.
1151
- if (returned === undefined && running) {
1152
- unwaited = true;
1153
- await call;
1154
- }
1155
- } catch (e) {
1156
- // A cut from `fromTrace`: a step production never ran, in a branch a Parallel cancelled. Stop here,
1157
- // as production did.
1158
- if (hasMark(e, replayCut)) return cancelledBranch(initialInput);
1159
- if (hasMark(e, harnessError)) throw e;
1160
- // A hook threw after the function returned, which is a bug in the hook. A hook that throws
1161
- // without calling `op` is still a fault, since that is how replay reports a recorded error.
1162
- if (succeeded) throw e;
1163
- return IoFault(e, initialInput);
1164
- }
1165
- // A hook that awaited `op` and forgot to return its result: `next` would take the branch for a Command
1166
- // that returned nothing, while a recorder inside the hook kept the real value. Only `undefined` is
1167
- // refused, so a hook that returns a copy of the result still works.
1168
- if (returned === undefined && succeeded && value !== undefined) {
1169
- throw new TypeError(
1170
- `An onStep hook called op for '${cmdName}' at path '${cmdPath}' and returned undefined` +
1171
- `${unwaited ? ' before op had finished' : ''}, although the Command returned a value. ` +
1172
- 'A hook has to await op() and return what it returns.'
1173
- );
1174
- }
1175
- 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();
1176
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
+ };
1177
1141
 
1178
- // Every outcome leaves through here, so this is where a Failure gets the root's input: one from a
1179
- // Parallel branch or a Retry fallback carries its own subtree's input, which `chain` never reaches.
1180
- // An I/O fault becomes a plain Failure here too.
1181
- const rootInput = effect?.initialInput;
1182
- const run = async () => {
1183
- const result = asOutcome(await execute(effect));
1184
- return result.type === 'Failure' && rootInput !== undefined ? Failure(result.error, rootInput) : result;
1185
- };
1186
- 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
+ };
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
+ }
1187
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
+ };
1188
1235
 
1189
1236
  /**
1190
- * Runs a flow: executes its Commands, resolving `Ask` with the context and running `Retry` and `Parallel`.
1191
- * 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.
1192
1239
  *
1193
1240
  * @param {Effect} effect - The Effect tree returned by a pipeline
1194
1241
  * @param {any} [context] - Optional context object. Passed to Ask continuations and the Command Interceptor.
@@ -1202,51 +1249,63 @@ const runEffect = (effect, context, callConfig) => interpret(effect, context, ca
1202
1249
  // #region Recording and replay
1203
1250
 
1204
1251
  /**
1205
- * The step a replay is asking about. `path` is the Command's position in the Effect tree and is stable
1206
- * across runs; `index` is its position in this run's completion order, which is not stable for a flow
1207
- * containing `Parallel`. Prefer `path` when writing a Resolver. A step whose `type` is 'Parallel' asks
1208
- * for a Parallel's recorded decision; it does not advance `index`, and anything but a decision replays
1209
- * 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.
1210
1256
  * @typedef {{ index: number, name: string, type: string, path?: string }} ReplayStep
1211
1257
  */
1212
1258
 
1213
1259
  /**
1214
- * What production observed for a step. `{ result }` is handed to the Command's
1215
- * `next`; `{ error }` is thrown so the interpreter produces a Failure. A resolver
1216
- * 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.
1217
1263
  *
1218
1264
  * @typedef {{ result: any } | { error: any }} ReplayOutcome
1219
1265
  */
1220
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
+
1221
1274
  /** @typedef {(step: ReplayStep) => ReplayOutcome | undefined} Resolver */
1222
1275
 
1223
1276
  /**
1224
- * A recorded step: a Command's result or error, or a Parallel's decision, recorded as `command`
1225
- * 'Parallel' at the Parallel's own path with the decision as its `result`. `durationMs` is how long
1226
- * the step took in production, rounded to microseconds. `path` is what a replay matches on.
1227
- * `threw` marks a step that threw. The `error` key alone cannot, since JSON drops it when the value is
1228
- * `undefined`, as it is for `reject()` with no argument or an error `redact` removed.
1229
- * @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
1230
1289
  */
1231
1290
 
1232
1291
  /**
1233
- * The reference trace format produced by `recorder`. A convenience, not a contract:
1234
- * `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`.
1235
1294
  * @typedef {{
1236
1295
  * flowName?: string,
1237
1296
  * version?: string,
1238
1297
  * initialInput?: any,
1239
1298
  * context?: any,
1299
+ * unrecorded?: { initialInput?: UnrecordedCause, context?: UnrecordedCause },
1240
1300
  * dropped?: number,
1241
1301
  * trace: TraceEntry[]
1242
1302
  * }} TraceLog
1243
1303
  */
1244
1304
 
1245
1305
  /**
1246
- * A replay fault: the trace cannot answer the flow, or disagrees with it. A harness error while the flow
1247
- * runs, so nothing inside the flow can swallow it; `replayEffect` turns it into a `Failure` at its own
1248
- * boundary. An `EffectTypeError` carries no replay mark and keeps propagating, since a malformed flow is
1249
- * 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.
1250
1309
  */
1251
1310
  const replayFault = Symbol('pure-effect.replayFault');
1252
1311
 
@@ -1266,8 +1325,7 @@ const replayError = (message, props = {}) =>
1266
1325
  );
1267
1326
 
1268
1327
  /**
1269
- * Builds a `TimeParadox`: the replay reached a point where the flow no longer matches its recording, which
1270
- * means the code has diverged from the recorded run.
1328
+ * Builds a `TimeParadox`: the flow no longer matches its recording at this point.
1271
1329
  * @param {string} at - Where, as the message names it
1272
1330
  * @param {string} detail - How the flow and the recording disagree
1273
1331
  * @param {Object} props - The fields that locate it, such as `path`
@@ -1293,8 +1351,8 @@ const timeParadox = (step, recorded) =>
1293
1351
  const carriedKeys = ['name', 'message', 'cause'];
1294
1352
 
1295
1353
  /**
1296
- * Converts a thrown value into something JSON can carry. `message` and `stack` are
1297
- * 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.
1298
1356
  *
1299
1357
  * @param {any} e - The thrown value
1300
1358
  * @param {boolean} [withStack] - Include the stack (off by default: noisy, leaks paths)
@@ -1305,25 +1363,22 @@ const serializeError = (e, withStack, ancestors = new Set()) => {
1305
1363
  if (!(e instanceof Error)) return e;
1306
1364
  /** @type {any} */
1307
1365
  const out = { __error: true, name: e.name, message: e.message };
1308
- // A chain that comes back to an error it passed through is cut there, with the name and message only:
1309
- // 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
1310
1367
  // error that merely appears twice is carried in full both times.
1311
1368
  if (ancestors.has(e)) return out;
1312
1369
  ancestors.add(e);
1313
1370
  if (withStack) out.stack = e.stack;
1314
- // `cause` is non-enumerable too, and it can itself be an Error, so it is carried recursively.
1315
1371
  if ('cause' in e) out.cause = serializeError(e.cause, withStack, ancestors);
1316
- // So is an AggregateError's `errors`, which is where its detail lives. It goes under its own key so
1317
- // 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.
1318
1374
  const errors = /** @type {any} */ (e).errors;
1319
1375
  const hasHiddenErrors = Array.isArray(errors) && !Object.prototype.propertyIsEnumerable.call(e, 'errors');
1320
1376
  if (hasHiddenErrors) {
1321
1377
  out.__errors = errors.map((x) => serializeError(x, withStack, ancestors));
1322
1378
  }
1323
1379
  ancestors.delete(e);
1324
- // Assigning `name`, `message` or `cause`, as `e.name = 'TimeoutError'` or `e.cause = inner` does, makes it
1325
- // enumerable. It is carried above already, so it is listed for revival to restore as it was rather than
1326
- // 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.
1327
1382
  const shown = carriedKeys.filter((k) => Object.prototype.propertyIsEnumerable.call(e, k));
1328
1383
  if (shown.length > 0) out.__enumerable = shown;
1329
1384
  for (const k of Object.keys(e)) if (!carriedKeys.includes(k)) out[k] = /** @type {any} */ (e)[k];
@@ -1331,23 +1386,21 @@ const serializeError = (e, withStack, ancestors = new Set()) => {
1331
1386
  };
1332
1387
 
1333
1388
  /**
1334
- * Rebuilds an Error from `serializeError` output. Non-Error values pass through, so a
1335
- * 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.
1336
1391
  * @param {any} v - A serialized error, or any other recorded value
1337
1392
  * @returns {any}
1338
1393
  */
1339
1394
  const reviveError = (v) => {
1340
1395
  if (!isObject(v) || v.__error !== true) return v;
1341
1396
  const e = new Error(v.message);
1342
- // `name`, `message`, `cause` and `errors` are defined as the original had them, so a revived error
1343
- // deep-equals the one the Command threw: non-enumerable, as on a native Error, unless `__enumerable` lists
1344
- // 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.
1345
1399
  const shown = Array.isArray(v.__enumerable) ? v.__enumerable : [];
1346
1400
  const restore = (/** @type {string} */ key, /** @type {any} */ value) =>
1347
1401
  Object.defineProperty(e, key, { enumerable: shown.includes(key), configurable: true, writable: true, value });
1348
1402
  restore('name', v.name);
1349
1403
  if (shown.includes('message')) restore('message', v.message);
1350
- // The markers are dropped, and `name` and `message` are already set.
1351
1404
  const alreadyHandled = ['__error', '__enumerable', 'name', 'message'];
1352
1405
  for (const [k, val] of Object.entries(v)) {
1353
1406
  if (alreadyHandled.includes(k)) continue;
@@ -1360,212 +1413,223 @@ const reviveError = (v) => {
1360
1413
 
1361
1414
  /**
1362
1415
  * @typedef {Object} RecorderOptions
1363
- * @property {(value: any, name: string, kind: string) => any} [redact] - Scrubs every value a trace holds:
1364
- * each Command's result, each serialized error, and the `initialInput` and `context` stored on the
1365
- * trace itself. `kind` is `'result'`, `'error'`, `'initialInput'`, or `'context'`, and `name` is the
1366
- * Command's name for the first two and the kind for the last two. It is the single place PII is kept
1367
- * out of a trace, so it has to see all four. It is handed a copy, so changing the value in place
1368
- * 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.
1369
1419
  * @property {number} [maxEntries] - Caps trace length; further steps are counted in `dropped`, not stored.
1370
1420
  * @property {boolean} [stack] - Records stack traces for thrown errors.
1371
1421
  */
1372
1422
 
1373
- /**
1374
- * @typedef {Object} TraceMeta
1375
- * @property {any} [initialInput] - The value the flow was called with.
1376
- * @property {string} [flowName]
1377
- * @property {any} [context] - Context passed to `runEffect`; required to replay `Ask`.
1378
- * @property {string} [version] - Commit or build id, so a replay can detect a stale trace.
1379
- */
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
+ };
1380
1429
 
1381
1430
  /**
1382
- * An array, a plain object, or an object with no prototype: the shapes `copyAround` rebuilds itself rather
1383
- * than handing to `structuredClone`.
1384
- * @param {object} value
1385
- * @returns {boolean}
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
1386
1434
  */
1387
- const isPlainContainer = (value) => {
1388
- if (Array.isArray(value)) return true;
1389
- const proto = Object.getPrototypeOf(value);
1390
- return proto === Object.prototype || proto === null;
1391
- };
1392
1435
 
1393
1436
  /**
1394
- * Copies what `structuredClone` refused. Arrays and plain objects are rebuilt and everything inside them is
1395
- * copied in turn, so only the parts that cannot be copied, such as a function or an object holding one, are
1396
- * kept as they are. A context holding a logger is the usual case.
1397
- *
1398
- * @param {any} value
1399
- * @param {Map<object, any>} seen - Copies made so far, so a cycle is copied as a cycle
1400
- * @returns {any}
1437
+ * What recording a value produced: the value the trace stores, or why it stores none.
1438
+ * @typedef {{ value: any } | { unrecorded: UnrecordedCause }} Recorded
1401
1439
  */
1402
- const copyAround = (value, seen) => {
1403
- if (!isObject(value)) return value;
1404
- if (seen.has(value)) return seen.get(value);
1405
- if (!isPlainContainer(value)) {
1406
- try {
1407
- return structuredClone(value);
1408
- } catch {
1409
- return value;
1410
- }
1411
- }
1412
- /** @type {any} */
1413
- const copy = Array.isArray(value) ? [] : Object.create(Object.getPrototypeOf(value));
1414
- seen.set(value, copy);
1415
- for (const key of Object.keys(value)) copy[key] = copyAround(value[key], seen);
1416
- return copy;
1417
- };
1418
1440
 
1419
1441
  /**
1420
- * Snapshots a value on its way into a trace, and on its way out of one in a replay, so a later mutation
1421
- * cannot rewrite what the trace says a step returned. The copy is also what `redact` is handed, so it has
1422
- * to be one the flow never sees. A value that cannot be cloned whole is copied around the parts that
1423
- * cannot be copied.
1424
- *
1425
- * @param {any} value
1426
- * @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}
1427
1448
  */
1428
- const snapshot = (value) => {
1429
- if (!isObject(value)) return value;
1449
+ const recordPart = (cause, compute) => {
1430
1450
  try {
1431
- return typeof structuredClone === 'function' ? structuredClone(value) : JSON.parse(JSON.stringify(value));
1451
+ return { value: compute() };
1432
1452
  } catch {
1433
- return copyAround(value, new Map());
1453
+ return { unrecorded: cause };
1434
1454
  }
1435
1455
  };
1436
1456
 
1437
1457
  /**
1438
- * @typedef {Object} StepStart
1439
- * @property {string} name - The Command's identity: `meta.name`, else `cmd.name`, else 'anonymous'.
1440
- * @property {string} type - 'Command', or 'Parallel' for a Parallel's decision.
1441
- * @property {string} [path] - The Command's position in the Effect tree.
1442
- */
1443
-
1444
- /**
1445
- * @typedef {Object} StepEnd
1446
- * @property {string} name
1447
- * @property {string} type
1448
- * @property {string} [path]
1449
- * @property {any} [result] - What the Command returned, when it succeeded.
1450
- * @property {any} [error] - What it threw, when it did not.
1451
- * @property {number} durationMs
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.
1452
1463
  */
1453
1464
 
1454
- const now = () => (typeof performance === 'object' ? performance.now() : Date.now());
1455
-
1456
1465
  /**
1457
- * Wraps a step observer into an `onStep` that cannot change the run: `op` always runs, its result is
1458
- * returned, its error propagates, and anything the observer throws is dropped.
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`.
1459
1469
  *
1460
- * @param {(start: StepStart) => (end: StepEnd) => void} handler - Returns a finisher for the outcome
1461
- * @returns {StepRunner}
1470
+ * @param {any} value
1471
+ * @returns {any}
1462
1472
  */
1463
- const observeSteps = (handler) => async (name, type, op, path) => {
1464
- /** @type {((end: StepEnd) => void) | undefined} */
1465
- let finish;
1466
- try {
1467
- finish = handler({ name, type, path });
1468
- } catch {
1469
- finish = undefined;
1470
- }
1471
- const report = (/** @type {StepEnd} */ end) => {
1472
- try {
1473
- if (finish) finish(end);
1474
- } catch {
1475
- // Observation does not get to decide the outcome, so a broken observer is dropped.
1476
- }
1477
- };
1478
-
1479
- const started = now();
1480
- try {
1481
- const result = await op();
1482
- report({ name, type, path, result, durationMs: now() - started });
1483
- return result;
1484
- } catch (error) {
1485
- report({ name, type, path, error, durationMs: now() - started });
1486
- throw error;
1487
- }
1473
+ const snapshot = (value) => {
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);
1488
1487
  };
1489
1488
 
1490
1489
  /**
1491
- * Builds an `onStep` hook that records every Command's result or error and every Parallel's decision, plus
1492
- * a packager for the reference trace format. Pass `onStep` to `runEffect` as per-call config. A recorder holds
1493
- * the steps of every run it sees, so installed with `configureEffect` for a whole application it mixes requests
1494
- * into one trace with duplicate paths, which a replay refuses; examples/recording-example.js gives each run its
1495
- * 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.
1496
1493
  *
1497
1494
  * @param {RecorderOptions} [options] - Redaction and size limits
1498
1495
  * @returns {{ onStep: StepRunner, entries: TraceEntry[], toTrace: (meta?: TraceMeta) => TraceLog }}
1499
1496
  */
1500
1497
  const recorder = (options = {}) => {
1501
- rejectUnknownOptions(options, 'recorder', ['redact', 'maxEntries', 'stack']);
1502
- 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 };
1503
1502
  /** @type {TraceEntry[]} */
1504
1503
  const entries = [];
1505
1504
  let dropped = 0;
1506
1505
 
1507
- const push = (/** @type {TraceEntry} */ entry) => {
1508
- if (entries.length < maxEntries) entries.push(entry);
1509
- else dropped++;
1510
- };
1511
-
1512
- // `redact` is the caller's code, and a throw from it must not reach the run.
1513
- 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
+ };
1514
1519
  try {
1515
- return redact(value, name, kind);
1516
- } catch {
1517
- 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;
1518
1526
  }
1519
1527
  };
1520
1528
 
1521
- /**
1522
- * Snapshots one of the trace's own fields and redacts the copy. `undefined` is left alone so a flow with no
1523
- * context does not acquire an empty object from a redact function that spreads its argument.
1524
- */
1525
- const redactField = (/** @type {any} */ value, /** @type {string} */ kind) =>
1526
- value === undefined ? undefined : safeRedact(snapshot(value), kind, kind);
1527
-
1528
- const onStep = observeSteps(({ name, type, path }) => (end) => {
1529
- const durationMs = Math.round(end.durationMs * 1000) / 1000;
1530
- // A Parallel's result is its decision, which holds no user data and must survive intact for a replay
1531
- // to reproduce it, so it is not redacted.
1532
- const recorded = (/** @type {any} */ result) =>
1533
- type === 'Parallel' ? snapshot(result) : safeRedact(snapshot(result), name, 'result');
1534
- push(
1535
- 'error' in end
1536
- ? {
1537
- command: name,
1538
- path,
1539
- threw: true,
1540
- error: safeRedact(snapshot(serializeError(end.error, stack)), name, 'error'),
1541
- durationMs
1542
- }
1543
- : { command: name, path, result: recorded(end.result), durationMs }
1544
- );
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 };
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;
1545
1596
  });
1597
+ };
1546
1598
 
1547
- /**
1548
- * Redacts and copies `meta.initialInput` and `meta.context` when it is called, so call it before the run
1549
- * when the run can change them, and take `dropped` and `trace` from a second call once it ends.
1550
- * @param {TraceMeta} [meta]
1551
- * @returns {TraceLog}
1552
- */
1553
- 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 {
1554
1620
  flowName: meta.flowName,
1555
1621
  version: meta.version,
1556
- initialInput: redactField(meta.initialInput, 'initialInput'),
1557
- 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 } : {}),
1558
1625
  dropped,
1559
1626
  trace: entries.slice()
1560
- });
1561
-
1562
- return { onStep, entries, toTrace };
1627
+ };
1563
1628
  };
1564
1629
 
1565
1630
  /**
1566
- * Runs a flow for real while recording every Command result, and returns both the
1567
- * outcome and a replayable trace. Convenient in tests and scripts; to record an application without
1568
- * 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.
1569
1633
  *
1570
1634
  * @param {(input: any) => Effect} flowFn - Builds the Effect tree from its input
1571
1635
  * @param {any} initialInput - The value the flow is called with; stored so a replay can rebuild it
@@ -1574,12 +1638,12 @@ const recorder = (options = {}) => {
1574
1638
  * @returns {Promise<{ result: SuccessState | FailureState, trace: TraceLog }>}
1575
1639
  */
1576
1640
  const recordEffect = async (flowFn, initialInput, options = {}) => {
1577
- rejectUnknownOptions(options, 'recordEffect', ['context', 'version', 'redact', 'maxEntries', 'stack']);
1578
- const { context = {}, version, ...recorderOptions } = options;
1641
+ checkOptions(options, 'recordEffect', { context: null, version: null, ...recorderOptionRules });
1642
+ checkFlowFn(flowFn, 'recordEffect');
1643
+ const { context = {}, version, ...recorderOptions } = options ?? {};
1579
1644
  const rec = recorder(recorderOptions);
1580
- // Packaged before the run, so a Command that writes to the input or the context, as an ORM save
1581
- // assigning an id does, cannot rewrite what the trace says the run received.
1582
- 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 });
1583
1647
  // Merged over the global wiring, so recording inside an instrumented application keeps its spans.
1584
1648
  const result = await runEffect(flowFn(initialInput), context, { onStep: rec.onStep });
1585
1649
  const { dropped, trace } = rec.toTrace();
@@ -1587,8 +1651,7 @@ const recordEffect = async (flowFn, initialInput, options = {}) => {
1587
1651
  };
1588
1652
 
1589
1653
  /**
1590
- * Whether a recorded entry says its step threw: `threw`, or an `error` key, which is how older and
1591
- * 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.
1592
1655
  * @param {TraceEntry} entry
1593
1656
  * @returns {boolean}
1594
1657
  */
@@ -1608,9 +1671,82 @@ const hasPath = (step) => typeof step.path === 'string';
1608
1671
  */
1609
1672
  const isInsideParallel = (path) => typeof path === 'string' && /p\d+\//.test(path);
1610
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
+
1611
1735
  /**
1612
- * Turns a recorded entry into the outcome a Resolver must return, snapshotted so a replayed step that
1613
- * 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.
1614
1750
  *
1615
1751
  * @param {TraceEntry} entry
1616
1752
  * @returns {ReplayOutcome}
@@ -1619,9 +1755,8 @@ const entryToOutcome = (entry) =>
1619
1755
  entryThrew(entry) ? { error: reviveError(snapshot(entry.error)) } : { result: snapshot(entry.result) };
1620
1756
 
1621
1757
  /**
1622
- * Whether an entry is a Parallel's recorded decision rather than a Command's result. A Parallel's path
1623
- * ends in its `p` marker and a Command's in its step number, so a Command that happens to be named
1624
- * '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.
1625
1760
  * @param {TraceEntry} entry
1626
1761
  * @returns {boolean}
1627
1762
  */
@@ -1629,81 +1764,190 @@ const isDecisionEntry = (entry) =>
1629
1764
  entry.command === 'Parallel' && typeof entry.path === 'string' && entry.path.endsWith('p');
1630
1765
 
1631
1766
  /**
1632
- * Builds a Resolver for the reference trace format. Internal: `replayEffect` is the only caller, and a
1633
- * caller with traces in another shape writes a Resolver instead.
1634
- *
1635
- * When every entry carries a `path`, steps are matched by path, which is order-independent and still
1636
- * detects a paradox: two `Parallel` branches calling the same Command are told apart by position. A
1637
- * trace without paths is matched positionally, each entry checked against the Command the flow asks
1638
- * for, which is exact for sequential flows and cannot tell `Parallel` branches apart.
1639
- *
1640
- * A step the trace does not hold resolves to `undefined`, as from a Resolver, so `replayEffect` applies
1641
- * `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.
1642
1770
  *
1643
1771
  * @param {TraceLog | TraceEntry[]} traceLog - A reference-format trace, or a bare array of entries
1644
1772
  * @param {Object} [options]
1645
- * @param {(entry: TraceEntry) => void} [options.onEntry] - Observes each entry as it is handed to a step,
1646
- * which is how `replayEffect` learns which recorded entries the flow never asked for.
1647
- * @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}
1648
1776
  */
1649
1777
  const fromTrace = (traceLog, options = {}) => {
1650
1778
  const { onEntry } = options;
1651
- const entries = Array.isArray(traceLog) ? traceLog : traceLog?.trace;
1652
- if (!Array.isArray(entries)) throw replayError('Trace has no `trace` array.');
1653
- const resolveEntry = (/** @type {TraceEntry} */ entry) => {
1779
+ const entries = checkTrace(traceLog, 'replayEffect', replayEffectTakes, true);
1780
+ /** @type {AnswerFrom} */
1781
+ const answerFrom = (entry, step) => {
1654
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 {
1655
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)
1656
1854
  };
1855
+ };
1657
1856
 
1658
- if (entries.length > 0 && entries.every(hasPath)) {
1659
- const byPath = new Map(entries.map((e) => [e.path, e]));
1660
- // Paths are unique by construction, so a collision means a hand-built trace or a bug, and keeping
1661
- // either entry would hand a branch the wrong result.
1662
- if (byPath.size !== entries.length) {
1663
- throw replayError('Trace has duplicate step paths.');
1664
- }
1665
- /** Recorded Parallel decisions that cancelled branches, by the Parallel's path. */
1666
- const cancellations = entries.filter(isDecisionEntry).filter((e) => e.result?.cancelled === true);
1667
- // A missing step is where production stopped a branch when it lies in a branch a recorded decision
1668
- // cancelled: any branch but the cancelling one, or every branch of a Parallel cancelled from outside. Such a
1669
- // step throws a cut, which `runCommand` or `runParallel` catches to stop the branch.
1670
- const stoppedInProduction = (/** @type {string | undefined} */ stepPath) =>
1671
- typeof stepPath === 'string' &&
1672
- cancellations.some((e) => {
1673
- const at = /** @type {string} */ (e.path);
1674
- const branch = stepPath.startsWith(at) ? /^(\d+)\//.exec(stepPath.slice(at.length)) : null;
1675
- return branch !== null && (e.result.branch === null || Number(branch[1]) !== e.result.branch);
1676
- });
1677
- /** @type {Resolver} */
1678
- const resolve = (step) => {
1679
- const entry = byPath.get(step.path);
1680
- if (step.type === 'Parallel') {
1681
- // A decision rather than I/O. `replayEffect` hands a recorded one to the Parallel's `op`, and
1682
- // `runBranches` reproduces it; with none recorded, the Parallel replays under timing.
1683
- if (entry && entry.command !== 'Parallel') throw timeParadox(step, entry.command);
1684
- if (entry) return resolveEntry(entry);
1685
- if (stoppedInProduction(step.path)) throw replayCutError(/** @type {string} */ (step.path));
1686
- return undefined;
1687
- }
1688
- if (!entry) {
1689
- // Production never ran this step, so it is not run live under `onMissing` either.
1690
- if (stoppedInProduction(step.path)) throw replayCutError(/** @type {string} */ (step.path));
1691
- return undefined;
1692
- }
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) {
1693
1871
  if (entry.command !== step.name) throw timeParadox(step, entry.command);
1694
- return resolveEntry(entry);
1695
- };
1696
- const missing = (/** @type {ReplayStep} */ step) =>
1697
- `Trace has no step at path '${step.path}' for '${step.name}'`;
1698
- return { resolve, missing };
1699
- }
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
+ };
1700
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) => {
1701
1947
  /** @type {Resolver} */
1702
1948
  const resolve = (step) => {
1703
- // 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.
1704
1950
  if (step.type === 'Parallel') return undefined;
1705
- // Positional matching pairs steps by completion order, which cannot tell Parallel branches apart.
1706
- // Refusing beats a result that is right only when the replay finishes in production's order.
1707
1951
  if (isInsideParallel(step.path)) {
1708
1952
  throw replayError(
1709
1953
  `Trace carries no paths, so '${step.name}' inside a Parallel cannot be matched positionally.`,
@@ -1713,14 +1957,15 @@ const fromTrace = (traceLog, options = {}) => {
1713
1957
  const entry = entries[step.index];
1714
1958
  if (!entry) return undefined;
1715
1959
  if (entry.command !== step.name) throw timeParadox(step, entry.command);
1716
- return resolveEntry(entry);
1960
+ return answerFrom(entry, step);
1717
1961
  };
1718
1962
  const missing = (/** @type {ReplayStep} */ step) => `Trace exhausted: no entry #${step.index} for '${step.name}'`;
1719
1963
  return { resolve, missing };
1720
1964
  };
1721
1965
 
1722
1966
  /**
1723
- * The error for a step a replay has no recorded outcome for, which it refuses to run live.
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.
1724
1969
  * @param {ReplayStep} step
1725
1970
  * @param {((step: ReplayStep) => string) | undefined} describe - `fromTrace`'s `missing`; a Resolver has none
1726
1971
  * @param {number} droppedEntries - How many entries the trace dropped under `maxEntries`
@@ -1728,19 +1973,16 @@ const fromTrace = (traceLog, options = {}) => {
1728
1973
  */
1729
1974
  const missingStepError = (step, describe, droppedEntries) => {
1730
1975
  const what = describe ? describe(step) : `No recorded outcome for '${step.name}' at step ${step.index}`;
1731
- // Why the step may be missing decides the fix. A capped trace needs a higher cap, since the step may be one
1732
- // production ran; any other missing step may be one production never ran, and running it live against
1733
- // production would do I/O production refused.
1734
1976
  const why =
1735
1977
  droppedEntries > 0
1736
1978
  ? `The trace dropped ${droppedEntries} entries under maxEntries, so production may have run ` +
1737
1979
  'this step and the recorder not kept it; record the flow with a higher maxEntries to ' +
1738
1980
  'replay past it.'
1739
1981
  : 'Production may never have run it: a Command an onBeforeCommand hook vetoed leaves no ' +
1740
- 'entry, and neither does a step added since the recording or one the flow reaches now that ' +
1741
- "it did not then. onMissing: 'execute' runs such a step for real, and after an added step " +
1742
- 'every step that follows it, since they all move to new paths, so pass it only where every ' +
1743
- 'Command the flow can still reach goes to a test double or only reads.';
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.';
1744
1986
  return replayError(`${what}; refusing to run the real Command. ${why}`, {
1745
1987
  command: step.name,
1746
1988
  index: step.index,
@@ -1748,60 +1990,81 @@ const missingStepError = (step, describe, droppedEntries) => {
1748
1990
  });
1749
1991
  };
1750
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
+
1751
2030
  /**
1752
2031
  * @typedef {Object} ReplayOptions
1753
2032
  * @property {any} [context] - Context for `Ask`; defaults to the context a trace recorded.
1754
- * @property {boolean} [fastRetry] - Waits no time between Retry attempts, so a replay does not wait out
1755
- * production backoff. On by default.
1756
- * @property {boolean} [hooks] - Runs the replay inside the hooks `configureEffect` installed, with the
1757
- * resolver innermost, so a configured `onStep` observes each replayed step and `onRun` and
1758
- * `onBeforeCommand` fire. Off by default, which ignores the global hooks, so a replay cannot
1759
- * reach a telemetry backend or a guardrail that performs I/O.
1760
- * @property {'throw' | 'execute'} [onMissing] - What to do when the resolver has no recording for a step.
1761
- * `'throw'` (default) fails the replay, which makes side effects impossible for the whole run.
1762
- * `'execute'` runs the real Command, so pass it only where the Commands reach test doubles or only
1763
- * read. A trace that dropped entries under `maxEntries` refuses it, since a step it lacks may be one
1764
- * 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.
1765
2039
  * @property {(step: ReplayStep, outcome: ReplayOutcome | undefined) => void} [onResolved] - Observes each step.
1766
2040
  */
1767
2041
 
1768
2042
  /**
1769
- * What a replay returns: the flow's own outcome, and, when a trace was supplied, the recorded
1770
- * entries the flow never asked for. `unreached` is absent for a Resolver, since only a trace
1771
- * knows what it holds.
1772
- *
1773
- * A flow that stops issuing Commands before its recording ends mismatches nothing, so no
1774
- * `TimeParadox` fires and `result` can be a `Success` with recorded steps left over. That is
1775
- * sometimes the point of a fix and sometimes a fix that quietly dropped a step; `unreached` is
1776
- * 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.
1777
2045
  * @typedef {{ result: SuccessState | FailureState, unreached?: TraceEntry[] }} Replay
1778
2046
  */
1779
2047
 
1780
2048
  /**
1781
- * Replays an Effect tree, feeding recorded results to Commands instead of running them.
1782
- *
1783
- * No side effect can occur by default. The interpreter's only execution point is
1784
- * `await localStepRunner(cmdName, 'Command', op, cmdPath)`, which hands the Command thunk
1785
- * to `onStep` as `op` rather than calling it. The `onStep` installed here never invokes
1786
- * `op` unless `onMissing: 'execute'` is set, so `eff.cmd` is never applied and the I/O
1787
- * it describes does not happen.
1788
- *
1789
- * Driving the interpreter instead of walking the tree is what makes `Ask`, `Retry` and
1790
- * `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.
1791
2052
  *
1792
2053
  * @param {Effect} effect - The Effect tree, rebuilt from the recorded initial input
1793
- * @param {Resolver | TraceLog | TraceEntry[]} traceOrResolver - A reference-format trace, resolved here, or a
1794
- * Resolver supplying each Command's recorded outcome (`undefined` if it has none). Write a Resolver when
1795
- * 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.
1796
2056
  * @param {ReplayOptions} [options]
1797
2057
  * @returns {Promise<Replay>} `{ result, unreached }` for a trace, `{ result }` for a Resolver
1798
2058
  */
1799
- // `async` so a malformed trace arrives as a rejection rather than a synchronous throw.
1800
2059
  const replayEffect = async (effect, traceOrResolver, options = {}) => {
1801
- rejectUnknownOptions(options, 'replayEffect', ['context', 'fastRetry', 'hooks', 'onMissing', 'onResolved']);
1802
- const { fastRetry = true, hooks = false, onMissing = 'throw', onResolved } = options;
1803
- // A trace is data and a Resolver is a function, so nothing else is needed to tell them
1804
- // 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 ?? {};
1805
2068
  const fromResolver = typeof traceOrResolver === 'function';
1806
2069
  /** @type {Set<TraceEntry>} */
1807
2070
  const reached = new Set();
@@ -1810,42 +2073,132 @@ const replayEffect = async (effect, traceOrResolver, options = {}) => {
1810
2073
  : fromTrace(traceOrResolver, { onEntry: (entry) => void reached.add(entry) });
1811
2074
  // Only a trace log carries metadata; a bare entries array and a Resolver carry none.
1812
2075
  const traceLog = fromResolver || Array.isArray(traceOrResolver) ? undefined : traceOrResolver;
1813
- // Defaults to the trace's context: an `Ask` gate replayed with another one takes another branch, and
1814
- // no paradox flags it.
1815
- const context = options.context ?? traceLog?.context ?? {};
1816
- // A trace capped by `maxEntries` lacks steps production ran, so running a missing step live repeats
1817
- // production's I/O: a billing batch replayed that way charged and invoiced subscriptions again.
1818
- const droppedEntries = Number(traceLog?.dropped) || 0;
1819
- 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;
1820
2127
  if (capped && onMissing === 'execute') {
1821
2128
  throw replayError(
1822
- `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 ` +
1823
2130
  "production ran, and onMissing: 'execute' would run it again. Replay without it to stop at the " +
1824
2131
  'first missing step, or record the flow with a higher maxEntries.'
1825
2132
  );
1826
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 }) => {
1827
2158
  let index = 0;
1828
- // What `onResolved` threw. It stops the replay, which rejects with it, rather than counting as the
1829
- // Command failing, which would let a Retry ask for an attempt production never made. Cast rather than
1830
- // annotated, since only the step runner assigns it.
1831
- 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
+ };
1832
2186
 
1833
2187
  /** @type {StepRunner} */
1834
2188
  const onStep = async (name, type, op, path) => {
1835
2189
  if (type === 'Parallel') {
1836
- // A Parallel's step carries its recorded decision into `op`, where `runBranches` reproduces it.
1837
- // It is not a Command: `index` still counts Commands, and `onResolved` still sees only them.
1838
- const outcome = resolve({ index, name, type, path });
1839
- 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);
1840
2194
  }
1841
2195
  const step = { index: index++, name, type, path };
1842
- const outcome = resolve(step);
2196
+ const outcome = answer(step);
1843
2197
  if (onResolved) {
1844
2198
  try {
1845
2199
  onResolved(step, outcome);
1846
2200
  } catch (error) {
1847
- observerFailure ??= { error };
1848
- throw asHarnessError(new Error('onResolved threw.'));
2201
+ throw stopWith(error);
1849
2202
  }
1850
2203
  }
1851
2204
  if (outcome === undefined) {
@@ -1856,31 +2209,14 @@ const replayEffect = async (effect, traceOrResolver, options = {}) => {
1856
2209
  return outcome.result;
1857
2210
  };
1858
2211
 
1859
- // With `hooks` off the replay ignores the global wiring, so it reaches no telemetry backend or
1860
- // guardrail. On, the resolver sits innermost and a configured onStep observes each replayed step.
1861
- /** @type {CallConfiguration} */
1862
- const callConfig = { onStep, inherit: hooks };
1863
-
1864
- // The interpreter rethrows a replay fault so nothing in the flow can absorb it; it becomes a Failure
1865
- // here, where nothing downstream can.
1866
- let result;
1867
- try {
1868
- result = await interpret(effect, context, callConfig, fastRetry);
1869
- } catch (e) {
1870
- if (observerFailure) throw observerFailure.error;
1871
- if (!hasMark(e, replayFault)) throw e;
1872
- result = Failure(e, effect.initialInput);
1873
- }
1874
- if (fromResolver) return { result };
1875
- // `fromTrace` has already validated the shape, so the entries are here in one form or the other.
1876
- const entries = Array.isArray(traceOrResolver) ? traceOrResolver : traceOrResolver.trace;
1877
- return { result, unreached: entries.filter((entry) => !reached.has(entry)) };
2212
+ /** @type {ReplayHook} */
2213
+ const hook = { onStep, failure: undefined };
2214
+ return hook;
1878
2215
  };
1879
2216
 
1880
2217
  /**
1881
- * Replays a reference-format trace and narrates each step. Rebuilds the flow from the
1882
- * recorded input, reports the outcome, and names any recorded steps that were never
1883
- * 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.
1884
2220
  *
1885
2221
  * @param {(input: any) => Effect} flowFn - The same flow function that produced the trace
1886
2222
  * @param {TraceLog} traceLog - A trace from `recordEffect` or a `recorder`
@@ -1888,83 +2224,117 @@ const replayEffect = async (effect, traceOrResolver, options = {}) => {
1888
2224
  * @param {(...args: any[]) => void} [options.log] - Defaults to `console.log`
1889
2225
  * @param {any} [options.context] - Overrides the context stored on the trace
1890
2226
  * @param {string} [options.version] - Current build id; warns when it differs from the trace's
1891
- * @returns {Promise<SuccessState | FailureState>} The flow's outcome. Narration is the point of this function;
1892
- * 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`.
1893
2229
  */
1894
2230
  const timeTravel = async (flowFn, traceLog, options = {}) => {
1895
- rejectUnknownOptions(options, 'timeTravel', ['log', 'context', 'version']);
1896
- const { log = console.log, context, version } = options;
1897
- const { initialInput, trace, flowName, version: traceVersion } = traceLog;
1898
- // `message` is non-enumerable on Error, so JSON.stringify alone would drop it. JSON.stringify throws on
1899
- // a BigInt and on a cycle, and narration must not be what fails a replay.
1900
- const format = (/** @type {any} */ v) => {
1901
- try {
1902
- return JSON.stringify(v instanceof Error ? { ...v, name: v.name, message: v.message } : v, null, 2);
1903
- } catch {
1904
- 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
+ );
1905
2259
  }
1906
- };
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
+ };
1907
2267
 
1908
- if (version && traceVersion && version !== traceVersion) {
1909
- 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}.`);
1910
2280
  }
1911
- // The flow is rebuilt from this input, and a recorder installed as a hook reads it off the flow, where only
1912
- // effectPipe puts it.
1913
- if (initialInput === undefined) {
1914
- log(
2281
+ if (traceLog.initialInput === undefined) {
2282
+ warnings.push(
1915
2283
  'Warning: the trace holds no initial input, so the flow is rebuilt from undefined. A recorder ' +
1916
2284
  'installed as a hook finds the input only on a flow built with effectPipe.'
1917
2285
  );
1918
2286
  }
1919
- // A replay tells steps apart by name, so two anonymous Commands that swapped places replay without complaint.
1920
2287
  const anonymous = trace.filter((e) => e.command === 'anonymous').length;
1921
2288
  if (anonymous > 0) {
1922
- log(
2289
+ warnings.push(
1923
2290
  `Warning: ${anonymous} of the recorded steps are named 'anonymous', usually inline arrow Commands, so ` +
1924
2291
  'this replay cannot tell them apart and would not notice two of them trading places. Name them with ' +
1925
2292
  'a const or meta.name.'
1926
2293
  );
1927
2294
  }
1928
- // Parallel decisions are not narrated as steps, so the header counts Commands to match the lines below.
1929
- const commandCount = trace.filter((e) => !isDecisionEntry(e)).length;
1930
- const stepsText = commandCount === 1 ? 'step' : 'steps';
1931
- log(`Replaying '${flowName || 'flow'}' (${commandCount} recorded ${stepsText})`);
1932
- log(`Initial input: ${format(initialInput)}`);
2295
+ return warnings;
2296
+ };
1933
2297
 
1934
- // Timings are looked up by path, whatever order branches finished in, or by position for a trace
1935
- // 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) => {
1936
2304
  const byPath = new Map(trace.filter(hasPath).map((e) => [e.path, e]));
1937
- const timing = (/** @type {ReplayStep} */ step) => {
2305
+ return (step) => {
1938
2306
  const recorded = byPath.get(step.path) ?? trace[step.index];
1939
2307
  return typeof recorded?.durationMs === 'number' ? ` in ${recorded.durationMs}ms` : '';
1940
2308
  };
1941
- const replay = await replayEffect(flowFn(initialInput), traceLog, {
1942
- context: context !== undefined ? context : traceLog.context || {},
1943
- onResolved: (step, outcome) => {
1944
- // A step the trace does not hold is refused or run live by `replayEffect`, not narrated here.
1945
- if (outcome === undefined) return;
1946
- log(
1947
- 'error' in outcome
1948
- ? `Step ${step.index + 1}: ${step.name} threw${timing(step)} ${format(outcome.error)}`
1949
- : `Step ${step.index + 1}: ${step.name} returned${timing(step)} ${format(outcome.result)}`
1950
- );
1951
- }
1952
- });
1953
- const { result, unreached = [] } = replay;
2309
+ };
1954
2310
 
1955
- log(`Replay finished with state: ${result.type}`);
1956
- log(result.type === 'Failure' ? `Error: ${format(result.error)}` : `Result: ${format(result.value)}`);
1957
- // After a replay error the flow never got past the divergence, and the error already names where it
1958
- // split. Only a flow that ended on its own terms with steps left over is worth a warning. Told by the
1959
- // mark rather than the name, which a flow's own error can share.
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) => {
1960
2319
  const haltedByReplay = result.type === 'Failure' && hasMark(result.error, replayFault);
1961
- if (unreached.length > 0 && !haltedByReplay) {
1962
- // Named, not just counted: the step a fix stopped issuing is usually the one under suspicion.
1963
- const names = unreached.map((e) => (hasPath(e) ? `${e.command} (path '${e.path}')` : e.command));
1964
- const count = unreached.length === 1 ? '1 recorded step was' : `${unreached.length} recorded steps were`;
1965
- log(`Warning: ${count} never reached: ${names.join(', ')}. The flow diverged.`);
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);
1966
2337
  }
1967
- return result;
1968
2338
  };
1969
2339
 
1970
2340
  // #endregion