pure-effect 0.17.0 → 0.19.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,28 @@
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; `effectPipe`, with the `chain` that joins its
8
+ // steps, the stop listeners `chain` keeps for generator flows, and the `flowInputs` it records for `onRun`;
9
+ // and `step` and `effectGen`, which build a flow from a generator function.
10
+ // 3. Configuration: the hook types and their defaults, `configureEffect` and its layers, and `chainHooks`, which
11
+ // merges them.
12
+ // 4. Running flows: the helpers `Retry`, `Parallel` and Commands run on; `interpret`, which builds a run's
13
+ // `Runtime`, then `stopAt`, which tells a waiting generator flow its step stopped, `execute` and a function
14
+ // for each kind of node, which each take it; and `runEffect`.
11
15
  // 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`.
16
+ // `recorder` and `recordEffect`, then `fromTrace`, `replayEffect` and `timeTravel`, each followed by the
17
+ // functions it hands its parts to.
13
18
  //
14
- // A new definition goes in the section it serves, which is usually the one that calls it.
19
+ // A new definition goes in the section it serves, which is usually the one that calls it. DESIGN.md explains why
20
+ // each part works the way it does.
15
21
 
16
22
  // #region Types
17
23
 
18
- /** @typedef {{ type: 'Success', value: any, initialInput?: any }} SuccessState */
19
- /** @typedef {{ type: 'Failure', error: any, initialInput?: any }} FailureState */
24
+ /** @typedef {{ type: 'Success', value: any }} SuccessState */
25
+ /** @typedef {{ type: 'Failure', error: any }} FailureState */
20
26
  /**
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`.
27
+ * Metadata attached to a Command. A string `name` is the Command's identity; every other key is carried through
28
+ * for `onBeforeCommand`.
23
29
  * @typedef {{ name?: string } & Record<string, any>} CommandMeta
24
30
  */
25
31
  /**
@@ -27,15 +33,13 @@
27
33
  * type: 'Command',
28
34
  * cmd: (signal?: AbortSignal) => Promise<any>|any,
29
35
  * next: (result: any) => Effect,
30
- * meta?: any,
31
- * initialInput?: any
36
+ * meta?: any
32
37
  * }} CommandState
33
38
  */
34
39
  /**
35
40
  * @typedef {{
36
41
  * type: 'Ask',
37
- * next: (context: any) => Effect,
38
- * initialInput?: any
42
+ * next: (context: any) => Effect
39
43
  * }} AskState
40
44
  */
41
45
 
@@ -43,9 +47,8 @@
43
47
  * @typedef {{
44
48
  * type: 'Retry',
45
49
  * effect: Effect,
46
- * options: { attempts?: number, delay?: number, backoff?: number, onExhausted?: (error: any) => Effect },
47
- * next: (value: any) => Effect,
48
- * initialInput?: any
50
+ * options: Readonly<{ attempts?: number, delay?: number, backoff?: number, onExhausted?: (error: any) => Effect }>,
51
+ * next: (value: any) => Effect
49
52
  * }} RetryState
50
53
  */
51
54
 
@@ -60,13 +63,12 @@
60
63
  * type: 'Parallel',
61
64
  * effects: Effect[],
62
65
  * next: (values: any[]) => Effect,
63
- * options?: ParallelOptions,
64
- * initialInput?: any
66
+ * options?: Readonly<ParallelOptions>
65
67
  * }} ParallelState
66
68
  */
67
69
 
68
70
  /**
69
- * The Union type for all possible states
71
+ * Every node a flow can be made of.
70
72
  * @typedef {SuccessState | FailureState | CommandState | AskState | RetryState | ParallelState} Effect
71
73
  */
72
74
 
@@ -81,28 +83,21 @@
81
83
  const Success = (value) => ({ type: 'Success', value });
82
84
 
83
85
  /**
84
- * Represents a failed computation. Stops the pipeline execution
86
+ * Represents a failed computation. Stops the pipeline.
85
87
  * @param {any} error - The error reason (string, Error object, etc).
86
- * @param {any} [initialInput] - initial input passed to the flow (optional)
87
88
  * @returns {FailureState}
88
89
  */
89
- const Failure = (error, initialInput) => ({
90
- type: 'Failure',
91
- error,
92
- initialInput
93
- });
90
+ const Failure = (error) => ({ type: 'Failure', error });
94
91
 
95
92
  /**
96
93
  * Represents a side effect to be executed later.
97
94
  *
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.
95
+ * @param {(signal?: AbortSignal) => Promise<any>|any} cmd - The function that does the I/O. Inside a `Parallel`, a
96
+ * function that declares a parameter receives an `AbortSignal` that fires when a sibling branch fails. A
97
+ * parameter with a default value does not count, so `nanoid(size = 21)` keeps its default.
98
+ * @param {(result: any) => Effect} [next] - Receives the result of `cmd`. Defaults to `Success(result)`; `null`
99
+ * counts as omitted.
100
+ * @param {CommandMeta} [meta] - Passed to `onBeforeCommand`. A string `meta.name` is the Command's identity.
106
101
  * @returns {CommandState}
107
102
  */
108
103
  const Command = (cmd, next, meta) => {
@@ -127,9 +122,8 @@ const Command = (cmd, next, meta) => {
127
122
  };
128
123
 
129
124
  /**
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.
125
+ * The name a Command is known by in traces, replay matching and telemetry: a non-empty string `meta.name`, else
126
+ * `cmd.name`, else 'anonymous'. Exported so a test walking a flow uses the same rule.
133
127
  *
134
128
  * @param {CommandState} eff
135
129
  * @returns {string}
@@ -145,10 +139,14 @@ const commandName = (eff) => {
145
139
 
146
140
  /**
147
141
  * Reads the context object from the current `runEffect` call.
148
- * @param {(context: any) => Effect} next - Receives the context and returns the next Effect
142
+ * @param {...((context: any) => Effect)} args - `next`, which receives the context and returns the next Effect. With
143
+ * no argument, the Ask succeeds with the context itself, as `yield* step(Ask())` reads it.
149
144
  * @returns {AskState}
150
145
  */
151
- const Ask = (next) => {
146
+ const Ask = (...args) => {
147
+ // Only a call with no argument: an explicit `undefined` is usually a misspelt name.
148
+ if (args.length === 0) return { type: 'Ask', next: (/** @type {any} */ context) => Success(context) };
149
+ const [next] = args;
152
150
  if (typeof next !== 'function') {
153
151
  throw malformed(`Ask expects a function that receives the context, got ${describeArgument(next)}.`, next);
154
152
  }
@@ -156,60 +154,49 @@ const Ask = (next) => {
156
154
  };
157
155
 
158
156
  /**
159
- * Wraps an Effect tree with retry-on-failure semantics.
157
+ * Runs `effect` again when a Command in it throws.
160
158
  *
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.
159
+ * Each attempt runs the **entire** wrapped tree again, including Commands that already succeeded, so wrap the one
160
+ * Command that fails transiently. `Retry(effectPipe(charge, receipt))` charges the customer again every time the
161
+ * receipt step fails.
165
162
  *
166
163
  * @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.
164
+ * @param {Object} [options] - Merged over the defaults when it runs; an option set to `undefined` keeps its default.
165
+ * @param {number} [options.attempts] - Retries after the first try, a positive integer
166
+ * @param {number} [options.delay] - Ms before the first retry, a finite number of 0 or more
167
+ * @param {number} [options.backoff] - Multiplier applied to the delay on each later retry, a finite number of 0 or
168
+ * more
169
+ * @param {(error: any) => Effect} [options.onExhausted] - Builds a fallback from `{ retryExhausted, lastError,
170
+ * attempts }` once every attempt has failed. Its success feeds `next`; its failure propagates unwrapped.
177
171
  * @returns {RetryState}
178
172
  */
179
173
  const Retry = (effect, options) => {
180
174
  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);
175
+ if (typeof options === 'number') {
176
+ const got = describeArgument(options);
177
+ throw malformed(
178
+ `Retry's options must be an object, got ${got}: write Retry(effect, { attempts: ${options} }).`,
179
+ options
180
+ );
184
181
  }
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) };
182
+ checkOptions(options, 'Retry', retryOptionRules, malformed);
183
+ return { type: 'Retry', effect, options: Object.freeze({ ...options }), next: (value) => Success(value) };
189
184
  };
190
185
 
191
186
  /**
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.
187
+ * Runs Effect trees at the same time. The first branch to fail cancels the others, and its Failure is the result.
188
+ * A cancelled branch starts no further Commands, but one already in flight stops only if its function passes on
189
+ * the `AbortSignal` it receives.
199
190
  *
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.
191
+ * `settled: true` runs every branch to completion and hands `next` each outcome as a `Success` or `Failure`.
192
+ * `limit: n` keeps at most `n` branches in flight. Results and paths follow array order either way.
204
193
  *
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.
194
+ * The second argument is `next` or the options, whichever it looks like. `undefined` or `null` there is a skipped
195
+ * `next`, so options passed third still count.
208
196
  *
209
197
  * @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.
198
+ * @param {((values: any[]) => Effect) | ParallelOptions | null} [nextOrOptions] - Receives the success values in
199
+ * order, and defaults to `Success(values)`; or the options.
213
200
  * @param {ParallelOptions} [maybeOptions] - Options, when `next` was given or skipped
214
201
  * @returns {ParallelState}
215
202
  */
@@ -221,14 +208,13 @@ const Parallel = (effects, nextOrOptions, maybeOptions) => {
221
208
  if (!isEffect(branch)) throw malformed(`Parallel's branch ${i} is ${describeValue(branch)}.`, branch);
222
209
  });
223
210
  const hasNext = typeof nextOrOptions === 'function';
224
- // A caller forwarding an absent `next` still passes its options third, as the signature reads.
225
211
  const nextSkipped = nextOrOptions == null;
226
212
  const optionsSecond = !hasNext && !nextSkipped;
227
213
  if (optionsSecond && !isOptionsObject(nextOrOptions)) {
228
214
  const got = describeArgument(nextOrOptions);
229
215
  throw malformed(`Parallel's second argument must be next or the options, got ${got}.`, nextOrOptions);
230
216
  }
231
- // Nothing reads a third argument after the options, so a `next` passed there would silently be skipped.
217
+ // Nothing would read a third argument after the options.
232
218
  if (optionsSecond && maybeOptions != null) {
233
219
  throw malformed(
234
220
  typeof maybeOptions === 'function'
@@ -239,20 +225,17 @@ const Parallel = (effects, nextOrOptions, maybeOptions) => {
239
225
  );
240
226
  }
241
227
  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));
228
+ checkOptions(options, 'Parallel', parallelOptionRules, malformed);
246
229
  return {
247
230
  type: 'Parallel',
248
231
  effects,
249
232
  next: hasNext ? nextOrOptions : (/** @type {any[]} */ values) => Success(values),
250
- options: options ?? {}
233
+ options: Object.freeze({ ...options })
251
234
  };
252
235
  };
253
236
 
254
237
  /**
255
- * Whether a value is an object that can carry properties: not `null`, a primitive, or a function.
238
+ * Whether a value is an object: not `null`, a primitive, or a function.
256
239
  * @param {any} value
257
240
  * @returns {boolean}
258
241
  */
@@ -270,6 +253,18 @@ const isPositiveInteger = (value) => Number.isInteger(value) && value >= 1;
270
253
  */
271
254
  const isFiniteNonNegative = (value) => Number.isFinite(value) && value >= 0;
272
255
 
256
+ /**
257
+ * @param {any} value
258
+ * @returns {boolean}
259
+ */
260
+ const isBoolean = (value) => typeof value === 'boolean';
261
+
262
+ /**
263
+ * @param {any} value
264
+ * @returns {boolean}
265
+ */
266
+ const isFunction = (value) => typeof value === 'function';
267
+
273
268
  /**
274
269
  * An object that can hold options or meta: not an array, and not an Effect passed in the wrong place.
275
270
  * @param {any} value
@@ -278,21 +273,78 @@ const isFiniteNonNegative = (value) => Number.isFinite(value) && value >= 0;
278
273
  const isOptionsObject = (value) => isObject(value) && !Array.isArray(value) && !isEffect(value);
279
274
 
280
275
  /**
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.
276
+ * Refuses an option name the function does not read, such as p-limit's `concurrency` passed to `Parallel`.
283
277
  * @param {any} options
284
278
  * @param {string} source - The function that takes them, as the message names it
285
279
  * @param {string[]} known
286
- * @param {(message: string) => Error} [raise] - Builds the error; a constructor's argument errors are EffectTypeErrors
280
+ * @param {(message: string, value: any) => Error} [raise] - Builds the error; a constructor's are EffectTypeErrors
287
281
  */
288
282
  const rejectUnknownOptions = (options, source, known, raise = (message) => new TypeError(message)) => {
289
283
  const name = isObject(options) ? Object.keys(options).find((key) => !known.includes(key)) : undefined;
290
284
  if (name !== undefined) {
291
285
  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}.`);
286
+ throw raise(`${source} has no option named '${name}'; its options are ${list}.`, options);
287
+ }
288
+ };
289
+
290
+ /**
291
+ * An option's rule: a test its value has to pass, the words for what passes, and advice for the message. `null`
292
+ * for an option that takes any value.
293
+ * @typedef {[(value: any) => boolean, string, string?] | null} OptionRule
294
+ */
295
+
296
+ /**
297
+ * Refuses options that are not an object, an option name the function does not read, and a value it cannot use.
298
+ * `null` is no options, as `undefined` is, and an option set to `undefined` keeps its default.
299
+ * @param {any} options
300
+ * @param {string} source - The function that takes them, as the message names it
301
+ * @param {Record<string, OptionRule>} rules - Every option the function reads, in the order a message lists them
302
+ * @param {(message: string, value: any) => Error} [raise] - Builds the error; a constructor's are EffectTypeErrors
303
+ */
304
+ const checkOptions = (options, source, rules, raise = (message) => new TypeError(message)) => {
305
+ if (options == null) return;
306
+ if (!isOptionsObject(options)) {
307
+ throw raise(`${source}'s options must be an object, got ${describeArgument(options)}.`, options);
308
+ }
309
+ rejectUnknownOptions(options, source, Object.keys(rules), raise);
310
+ for (const [name, rule] of Object.entries(rules)) {
311
+ const value = options[name];
312
+ if (rule === null || value === undefined || rule[0](value)) continue;
313
+ const [, takes, advice] = rule;
314
+ const message = `${source} '${name}' must be ${takes}, received ${describeArgument(value)}.`;
315
+ throw raise(advice ? `${message} ${advice}` : message, value);
293
316
  }
294
317
  };
295
318
 
319
+ /** What `Retry` uses for an option left out or set to `undefined`. */
320
+ const defaultRetryOptions = { attempts: 3, delay: 100, backoff: 1 };
321
+
322
+ /**
323
+ * Checked when the Retry is built, which keeps a frozen copy of the options, so they cannot change before it runs.
324
+ * @type {Record<string, OptionRule>}
325
+ */
326
+ const retryOptionRules = {
327
+ // Not 0, which would make `onExhausted` a catch.
328
+ attempts: [
329
+ isPositiveInteger,
330
+ 'a positive integer',
331
+ "To handle an outcome without retrying, branch on it as data in the Command's next, or isolate a failing " +
332
+ "branch with Parallel's settled option."
333
+ ],
334
+ delay: [isFiniteNonNegative, 'a finite number of 0 or more'],
335
+ backoff: [isFiniteNonNegative, 'a finite number of 0 or more'],
336
+ onExhausted: [isFunction, 'a function that returns the fallback']
337
+ };
338
+
339
+ /**
340
+ * Checked when the Parallel is built, which keeps a frozen copy, as a `Retry` does.
341
+ * @type {Record<string, OptionRule>}
342
+ */
343
+ const parallelOptionRules = {
344
+ limit: [isPositiveInteger, 'a positive integer'],
345
+ settled: [isBoolean, 'true or false']
346
+ };
347
+
296
348
  /**
297
349
  * Describes a value for an error message, leading with the mistake it most likely is.
298
350
  * @param {any} value
@@ -304,17 +356,18 @@ const describeValue = (value) => {
304
356
  if (value instanceof Promise) return 'a Promise, which usually means an async function';
305
357
  if (typeof value === 'function')
306
358
  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.
359
+ // `String` for the rest, since JSON prints NaN as null and throws on a BigInt.
308
360
  if (typeof value !== 'object') {
309
361
  return `the ${typeof value} ${typeof value === 'string' ? JSON.stringify(value) : String(value)}`;
310
362
  }
363
+ if (Array.isArray(value)) return 'an array';
311
364
  if (typeof value.type === 'string') return `an object with an unrecognised type '${value.type}'`;
312
365
  return 'a plain object';
313
366
  };
314
367
 
315
368
  /**
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.
369
+ * Describes a constructor's argument for an error message. A missing one is usually a misspelt name rather than a
370
+ * missing return, and an Effect in the wrong place is named by its type.
318
371
  * @param {any} value
319
372
  * @returns {string}
320
373
  */
@@ -322,11 +375,10 @@ const describeArgument = (value) =>
322
375
  value === undefined ? 'undefined' : isEffect(value) ? `an Effect of type '${value.type}'` : describeValue(value);
323
376
 
324
377
  /**
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`.
378
+ * Marks an error as the harness failing rather than the flow: a malformed flow, or a trace that cannot answer a
379
+ * step. The interpreter rethrows it rather than folding it into a `Failure`, so nothing that handles a domain
380
+ * failure can absorb it. A new kind of harness error gets this mark, not a name to match on. Non-enumerable, so it
381
+ * stays out of a serialized error and a `deepEqual`.
330
382
  */
331
383
  const harnessError = Symbol('pure-effect.harnessError');
332
384
 
@@ -345,37 +397,34 @@ const asHarnessError = (error) => Object.defineProperty(error, harnessError, { v
345
397
  const hasMark = (e, mark) => Boolean(e && /** @type {any} */ (e)[mark]);
346
398
 
347
399
  /**
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
400
+ * An I/O fault: a Command's function threw, or a `Retry` ran out of attempts. It is what `Retry` retries, unlike a
401
+ * `Failure` a step returned. Only `execute` and its per-node functions return one, and `asOutcome` turns it into a
402
+ * plain `Failure` before it reaches user code.
403
+ * @typedef {{ type: 'IoFault', error: any }} IoFaultState
352
404
  */
353
405
 
354
406
  /**
355
407
  * @param {any} error
356
- * @param {any} [initialInput]
357
408
  * @returns {IoFaultState}
358
409
  */
359
- const IoFault = (error, initialInput) => ({ type: 'IoFault', error, initialInput });
410
+ const IoFault = (error) => ({ type: 'IoFault', error });
360
411
 
361
412
  /**
362
413
  * @param {SuccessState | FailureState | IoFaultState} state
363
414
  * @returns {SuccessState | FailureState}
364
415
  */
365
- const asOutcome = (state) => (state.type === 'IoFault' ? Failure(state.error, state.initialInput) : state);
416
+ const asOutcome = (state) => (state.type === 'IoFault' ? Failure(state.error) : state);
366
417
 
367
418
  /**
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`.
419
+ * Builds an `EffectTypeError`, the harness error for a malformed flow or a constructor argument it cannot use.
371
420
  *
372
421
  * @param {string} message
373
422
  * @param {any} value - The malformed value
374
423
  * @returns {Error}
375
424
  */
376
425
  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.
426
+ // The error reports the bug, so the rejection must not also crash the process as unhandled. Only a native
427
+ // Promise: calling `then` on a Knex or Mongoose query builder runs the query.
379
428
  if (value instanceof Promise) value.catch(() => {});
380
429
  return asHarnessError(Object.assign(new Error(message), { name: 'EffectTypeError' }));
381
430
  };
@@ -437,51 +486,65 @@ const asEffect = (value, source) => {
437
486
  };
438
487
 
439
488
  /**
440
- * Connects an Effect to the next function in the pipeline.
441
- * Handles the branching logic for Success, Failure, Command, Ask, Retry, and Parallel.
489
+ * Connects an Effect to the next function in the pipeline: a Success passes its value on, a Failure stops, and
490
+ * every other node gets a `next` that continues into `fn`.
442
491
  *
443
492
  * @param {Effect} effect - The current Effect object
444
493
  * @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
494
  * @param {Effect} [from] - The node whose `next` returned `effect`, which an error names
495
+ * @param {StopListener} [onStop] - Set by `effectGen`: told when a Failure passes `fn`, and kept on each copy for
496
+ * `execute`'s `stopAt`, which calls it when the copy itself stops the flow
447
497
  * @returns {Effect} The composed Effect
448
498
  */
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());
499
+ const chain = (effect, fn, from, onStop) => {
500
+ // Before reading `.type`, so a missing return is named rather than thrown as a bare TypeError.
501
+ if (!isEffect(effect)) throw effectTypeError(effect, from ? nextOf(from) : 'A continuation');
502
+ if (effect.type === 'Success') return asEffect(fn(effect.value), `Step '${fn.name || 'anonymous'}'`);
503
+ if (effect.type === 'Failure') {
504
+ onStop?.(from);
505
+ return effect;
482
506
  }
507
+ const next = (/** @type {any} */ value) => chain(effect.next(value), fn, effect, onStop);
508
+ // A Command goes back through its constructor, which checks the function of one built by hand.
509
+ const copy = effect.type === 'Command' ? Command(effect.cmd, next, effect.meta) : { ...effect, next };
510
+ const waiting = stopListeners.get(effect);
511
+ if (onStop) stopListeners.set(copy, [...(waiting ?? []), onStop]);
512
+ else if (waiting) stopListeners.set(copy, waiting);
513
+ return copy;
514
+ };
515
+
516
+ /**
517
+ * Told that the flow stopped at `node`, or at a Failure when `node` is undefined, so a generator flow waiting on it
518
+ * runs its `catch` and `finally` blocks.
519
+ * @typedef {(node: Effect | undefined) => void} StopListener
520
+ */
521
+
522
+ /**
523
+ * The generator flows waiting on each node `chain` copied for them, innermost first. `chain` calls one when a Failure
524
+ * passes the continuation it guards, and `execute`'s `stopAt` calls them all when the node itself stops the flow.
525
+ * @type {WeakMap<Effect, StopListener[]>}
526
+ */
527
+ const stopListeners = new WeakMap();
528
+
529
+ /**
530
+ * Copies a flow's root, which `flowInputs` is keyed on, keeping the generator flows waiting on it.
531
+ * @param {Effect} tree
532
+ * @returns {Effect}
533
+ */
534
+ const copyRoot = (tree) => {
535
+ const root = { ...tree };
536
+ const waiting = stopListeners.get(tree);
537
+ if (waiting) stopListeners.set(root, waiting);
538
+ return root;
483
539
  };
484
540
 
541
+ /**
542
+ * The input each flow `effectPipe` built was called with, keyed on the flow's root, for `interpret` to hand `onRun`.
543
+ * Kept off the flow, so no node or outcome carries it.
544
+ * @type {WeakMap<Effect, any>}
545
+ */
546
+ const flowInputs = new WeakMap();
547
+
485
548
  /**
486
549
  * Composes a list of functions into a single Effect pipeline.
487
550
  * Each function receives the output of the previous one.
@@ -502,11 +565,410 @@ const effectPipe = (...fns) => {
502
565
  }
503
566
  });
504
567
  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);
568
+ const tree = fns.reduce((eff, fn) => chain(eff, fn), /** @type {Effect} */ (Success(start)));
569
+ // Keyed on a copy, since the last step can return an object other flows share, such as a constant Failure.
570
+ const root = copyRoot(tree);
571
+ flowInputs.set(root, start);
572
+ return root;
573
+ };
574
+ };
575
+
576
+ /**
577
+ * Every marker a step's iterator yielded, so `effectGen` can tell one from a value the body yielded itself.
578
+ * @type {WeakSet<StepMarker>}
579
+ */
580
+ const stepMarkers = new WeakSet();
581
+
582
+ /**
583
+ * The markers `effectGen` received, so a step's iterator can tell `yield*` from a spread or a `for...of`.
584
+ * @type {WeakSet<StepMarker>}
585
+ */
586
+ const claimedMarkers = new WeakSet();
587
+
588
+ /**
589
+ * The Effect inside each step wrapper, so `effectGen` can name a step yielded or returned without `yield*`.
590
+ * @type {WeakMap<object, Effect>}
591
+ */
592
+ const stepEffects = new WeakMap();
593
+
594
+ /** @typedef {Readonly<{ effect: Effect }>} StepMarker */
595
+
596
+ /**
597
+ * @param {any} value
598
+ * @returns {boolean}
599
+ */
600
+ const isThenable = (value) => isObject(value) && typeof value.then === 'function';
601
+
602
+ /**
603
+ * Names a step for a message, and for comparing one run of a body with the next.
604
+ * @param {Effect} effect
605
+ * @returns {string}
606
+ */
607
+ const describeStep = (effect) =>
608
+ effect.type === 'Command'
609
+ ? `Command '${commandName(effect)}'`
610
+ : `${effect.type === 'Ask' ? 'an' : 'a'} ${effect.type}`;
611
+
612
+ /**
613
+ * Wraps an Effect for `yield*` inside `effectGen`, which gives back the Effect's value. The wrapper is a proxy that throws
614
+ * when it is used as a value, and its iterator throws unless `effectGen` claimed what it yielded.
615
+ *
616
+ * @param {Effect} effect
617
+ * @returns {any}
618
+ */
619
+ const step = (effect) => {
620
+ if (!isEffect(effect)) {
621
+ throw malformed(
622
+ isThenable(effect)
623
+ ? 'step expects an Effect, got a Promise: put the call in a Command, as in ' +
624
+ 'yield* step(Command(() => db.findUser(email))).'
625
+ : `step expects an Effect, got ${describeArgument(effect)}.`,
626
+ effect
627
+ );
628
+ }
629
+ /** @returns {Generator<StepMarker, any, any>} */
630
+ const iterate = function* () {
631
+ const marker = Object.freeze({ effect });
632
+ stepMarkers.add(marker);
633
+ try {
634
+ return yield marker;
635
+ } finally {
636
+ // Only `effectGen` claims a marker, so an unclaimed one means a spread, `Array.from` or a `for...of`.
637
+ if (!claimedMarkers.has(marker)) {
638
+ throw malformed(
639
+ `step(${describeStep(effect)}) was iterated outside yield*; write yield* step(...).`,
640
+ effect
641
+ );
642
+ }
643
+ }
644
+ };
645
+ const refuse = () => {
646
+ throw malformed(`step(${describeStep(effect)}) was used as a value; write yield* step(...).`, effect);
647
+ };
648
+ const inspectCustom = Symbol.for('nodejs.util.inspect.custom');
649
+ const hint = () => `[step: ${describeStep(effect)}, use with yield*]`;
650
+ // On the target as well, since Node's inspect reads a proxy's target without running its traps.
651
+ const target = Object.freeze(Object.defineProperty({}, inspectCustom, { value: hint }));
652
+ const wrapper = new Proxy(target, {
653
+ get: (_, key) => (key === Symbol.iterator ? iterate : key === inspectCustom ? hint : refuse()),
654
+ has: refuse,
655
+ ownKeys: refuse,
656
+ getOwnPropertyDescriptor: refuse,
657
+ set: refuse,
658
+ defineProperty: refuse,
659
+ deleteProperty: refuse
660
+ });
661
+ stepEffects.set(wrapper, effect);
662
+ return wrapper;
663
+ };
664
+
665
+ /** What an `async function*` inherits from, which `effectGen` refuses before it builds anything. */
666
+ const asyncGeneratorFunction = Object.getPrototypeOf(async function* () {});
667
+
668
+ /**
669
+ * A generator flow called with its input, which a re-run starts its body from again.
670
+ * @typedef {{ body: (input: any) => any, input: any }} GenFlow
671
+ */
672
+
673
+ /**
674
+ * One run of a body: its generator, the position of the step it is waiting at, and whether it can still resume.
675
+ * @typedef {{ generator: Generator<any, any, any>, position: number, state: 'suspended' | 'done' }} BodyRun
676
+ */
677
+
678
+ /**
679
+ * The answers a body was given before a position, newest first, each with the step it answered, so a re-run can feed
680
+ * them again and check the body asks for the same steps. Shared between continuations, never copied.
681
+ * @typedef {{ signature: string, answer: any, earlier: Trail } | null} Trail
682
+ */
683
+
684
+ /**
685
+ * @typedef {(generator: Generator<any, any, any>) => IteratorResult<any, any>} Resume
686
+ */
687
+
688
+ /**
689
+ * Starts a body. A function that returns anything but a synchronous generator is refused here, which covers a bundled
690
+ * `async function*` the prototype check in `effectGen` missed.
691
+ * @param {GenFlow} flow
692
+ * @returns {BodyRun}
693
+ */
694
+ const startRun = (flow) => {
695
+ const generator = flow.body(flow.input);
696
+ const isGenerator =
697
+ isObject(generator) &&
698
+ ['next', 'throw', 'return'].every((name) => typeof generator[name] === 'function') &&
699
+ generator[Symbol.toStringTag] !== 'AsyncGenerator';
700
+ if (!isGenerator) {
701
+ throw malformed(
702
+ 'effectGen expects a generator function, function* (input) { ... }; ' +
703
+ `its function returned ${describeValue(generator)}.`,
704
+ generator
705
+ );
706
+ }
707
+ return { generator, position: 0, state: 'suspended' };
708
+ };
709
+
710
+ /**
711
+ * Adds a hint to V8's error for `yield*` on a value that is not iterable, keeping the original as `cause`.
712
+ * @param {unknown} e
713
+ * @returns {unknown}
714
+ */
715
+ const explainBodyThrow = (e) => {
716
+ // V8's two wordings; the second is not unique to yield*, so the hint is conditional.
717
+ const notIterable = [
718
+ /^yield\* .* is not iterable$/,
719
+ /^object is not iterable \(cannot read property Symbol\(Symbol\.iterator\)\)$/
720
+ ];
721
+ if (!(e instanceof TypeError) || hasMark(e, harnessError) || !notIterable.some((p) => p.test(e.message))) {
722
+ return e;
723
+ }
724
+ const error = malformed(
725
+ `A generator flow threw "${e.message}". If it came from yield* on an Effect, write yield* step(effect).`,
726
+ undefined
727
+ );
728
+ return Object.defineProperty(error, 'cause', { value: e, writable: true, configurable: true });
729
+ };
730
+
731
+ /**
732
+ * @param {BodyRun} run
733
+ * @param {Resume} resume
734
+ * @returns {IteratorResult<any, any>}
735
+ */
736
+ const resumeBody = (run, resume) => {
737
+ try {
738
+ const result = resume(run.generator);
739
+ if (result.done) run.state = 'done';
740
+ return result;
741
+ } catch (e) {
742
+ run.state = 'done';
743
+ throw explainBodyThrow(e);
744
+ }
745
+ };
746
+
747
+ /**
748
+ * Takes the Effect out of what a body yielded, claiming its marker, or refuses what is not a step.
749
+ * @param {any} value
750
+ * @returns {Effect}
751
+ */
752
+ const claimStep = (value) => {
753
+ // First, since reading anything from a step wrapper throws.
754
+ const unstarred = stepEffects.get(value);
755
+ if (unstarred) {
756
+ throw malformed(
757
+ `A generator flow yielded step(${describeStep(unstarred)}) without the star; write yield* step(...).`,
758
+ undefined
759
+ );
760
+ }
761
+ if (stepMarkers.has(value)) {
762
+ claimedMarkers.add(value);
763
+ return value.effect;
764
+ }
765
+ if (isEffect(value)) {
766
+ throw malformed(`A generator flow yielded ${describeArgument(value)}; write yield* step(effect).`, value);
767
+ }
768
+ if (isThenable(value)) {
769
+ throw malformed(
770
+ 'A generator flow yielded a Promise; put the call in a Command and yield* step(command).',
771
+ value
772
+ );
773
+ }
774
+ throw malformed(`A generator flow yielded ${describeValue(value)}; it can only yield* step(effect).`, value);
775
+ };
776
+
777
+ /**
778
+ * What a body's `return` means: an Effect runs as it is, a plain value is a `Success`.
779
+ * @param {any} value
780
+ * @returns {Effect}
781
+ */
782
+ const returnedEffect = (value) => {
783
+ if (stepEffects.has(value)) {
784
+ throw malformed(
785
+ 'A generator flow returned step(...); write return yield* step(...), or return the Effect itself.',
786
+ undefined
787
+ );
788
+ }
789
+ if (isEffect(value)) return value;
790
+ if (isThenable(value)) {
791
+ throw malformed(
792
+ 'A generator flow returned a Promise; put the call in a Command and return yield* step(command).',
793
+ value
794
+ );
795
+ }
796
+ return Success(value);
797
+ };
798
+
799
+ /**
800
+ * Runs a body again from the top, feeding it the answers in `trail`, and checks it asks for the same steps. A step it
801
+ * builds again but does not run, since its answer is known, is stopped as a skipped one is (`stopSkipped`), so a
802
+ * generator flow inside it that has already entered its `try` runs its `finally`.
803
+ * @param {GenFlow} flow
804
+ * @param {Trail} trail
805
+ * @param {string} signature - The step the body has to be waiting at once the answers are fed
806
+ * @returns {BodyRun} The new run, waiting at that step
807
+ */
808
+ const rerun = (flow, trail, signature) => {
809
+ const run = startRun(flow);
810
+ /** @returns {Effect} The step the body asked for */
811
+ const expect = (/** @type {IteratorResult<any, any>} */ result, /** @type {string} */ wanted) => {
812
+ const effect = result.done ? undefined : claimStep(result.value);
813
+ const asked = effect && describeStep(effect);
814
+ if (!effect || asked !== wanted) {
815
+ const did = asked === undefined ? 'returned' : `asked for ${asked}`;
816
+ throw malformed(
817
+ `A generator flow ${did} where its earlier run asked for ${wanted}. Something in its ` +
818
+ 'body changes between runs, such as Date.now(), Math.random(), or a variable outside the body; ' +
819
+ "make it a Command, or pass it in the flow's input.",
820
+ undefined
821
+ );
822
+ }
823
+ if (effect.type !== 'Success') stopSkipped(effect, Success(undefined), undefined);
824
+ return effect;
825
+ };
826
+ /** @type {{ signature: string, answer: any }[]} */
827
+ const answers = [];
828
+ for (let cell = trail; cell; cell = cell.earlier) answers.unshift(cell);
829
+ let result = resumeBody(run, (g) => g.next());
830
+ for (const { signature: asked, answer } of answers) {
831
+ const effect = expect(result, asked);
832
+ run.position++;
833
+ // A pure step's value is the one this run just computed, never an object the earlier run went on to change.
834
+ const value = effect.type === 'Success' ? effect.value : answer;
835
+ result = resumeBody(run, (g) => g.next(value));
836
+ }
837
+ expect(result, signature);
838
+ return run;
839
+ };
840
+
841
+ /**
842
+ * The run waiting at `position`: the live one if it is there, else a new one the earlier answers bring there, as
843
+ * when a test walks a second branch or a `Retry` repeats the flow.
844
+ * @param {GenFlow} flow
845
+ * @param {BodyRun} run
846
+ * @param {number} position
847
+ * @param {Trail} trail
848
+ * @param {string} signature
849
+ * @returns {BodyRun}
850
+ */
851
+ const runAt = (flow, run, position, trail, signature) =>
852
+ run.state === 'suspended' && run.position === position ? run : rerun(flow, trail, signature);
853
+
854
+ /**
855
+ * Resumes a body and turns what it does next into an Effect: the step it yields, chained back into the body, or what
856
+ * it returns.
857
+ * @param {GenFlow} flow
858
+ * @param {BodyRun} run
859
+ * @param {Trail} trail - The answers before the position the body resumes at
860
+ * @param {Resume} resume
861
+ * @returns {Effect}
862
+ */
863
+ const advance = (flow, run, trail, resume) => {
864
+ for (;;) {
865
+ const result = resumeBody(run, resume);
866
+ if (result.done) return returnedEffect(result.value);
867
+ const effect = claimStep(result.value);
868
+ const position = run.position;
869
+ const signature = describeStep(effect);
870
+ const before = trail;
871
+ if (effect.type === 'Success') {
872
+ // Fed straight back rather than through `chain`, so a long run of pure steps does not grow the stack.
873
+ trail = { signature, answer: effect.value, earlier: before };
874
+ run.position = position + 1;
875
+ resume = (g) => g.next(effect.value);
876
+ continue;
877
+ }
878
+ return chain(
879
+ effect,
880
+ (answer) => continueBody(flow, run, position, before, signature, answer),
881
+ undefined,
882
+ (node) => stopBody(flow, run, position, before, signature, node)
883
+ );
884
+ }
885
+ };
886
+
887
+ /**
888
+ * Hands a step's answer to the body waiting at it.
889
+ * @param {GenFlow} flow
890
+ * @param {BodyRun} run
891
+ * @param {number} position
892
+ * @param {Trail} trail
893
+ * @param {string} signature
894
+ * @param {any} answer
895
+ * @returns {Effect}
896
+ */
897
+ const continueBody = (flow, run, position, trail, signature, answer) => {
898
+ const current = runAt(flow, run, position, trail, signature);
899
+ current.position = position + 1;
900
+ return advance(flow, current, { signature, answer, earlier: trail }, (g) => g.next(answer));
901
+ };
902
+
903
+ /**
904
+ * Throws a stop into the body waiting at a step that stopped the flow, so its `catch` and `finally` blocks run. Only
905
+ * the stop coming back out is accepted: a body that swallows it, throws something else, or yields while stopping
906
+ * rejects the run.
907
+ * @param {GenFlow} flow
908
+ * @param {BodyRun} run
909
+ * @param {number} position
910
+ * @param {Trail} trail
911
+ * @param {string} signature
912
+ * @param {Effect | undefined} node - The node that stopped the flow; undefined for a Failure
913
+ */
914
+ const stopBody = (flow, run, position, trail, signature, node) => {
915
+ const current = runAt(flow, run, position, trail, signature);
916
+ current.state = 'done';
917
+ const where = node && isPending(node) ? describeStep(node) : 'a Failure';
918
+ const advice = "return the error as data inside the Command's function, or use Retry's onExhausted.";
919
+ // Carries neither the error nor what kind of stop it was, so user code cannot branch on either.
920
+ const stop = Object.assign(
921
+ new Error(`The flow stopped ${node ? 'after' : 'at'} ${where}. Nothing can be caught here: ${advice}`),
922
+ {
923
+ name: 'FlowStopped'
924
+ }
925
+ );
926
+ try {
927
+ current.generator.throw(stop);
928
+ } catch (e) {
929
+ if (e === stop) return;
930
+ if (hasMark(e, harnessError)) throw e;
931
+ const error = malformed(
932
+ `A catch in a generator flow threw another error after ${where} stopped it; a catch around yield* ` +
933
+ 'cannot recover, so let the stop pass.',
934
+ undefined
935
+ );
936
+ throw Object.defineProperty(error, 'cause', { value: e, writable: true, configurable: true });
937
+ }
938
+ throw malformed(
939
+ `A generator flow kept going after ${where} stopped it: a catch swallowed the stop, or a finally ran a ` +
940
+ `step. Nothing can be caught here: ${advice}`,
941
+ undefined
942
+ );
943
+ };
944
+
945
+ /**
946
+ * Builds a flow function from a generator function, which writes `yield* step(effect)` where `async` code writes
947
+ * `await`. Calling the flow function runs the body up to its first step, and the flow it returns is made of ordinary
948
+ * nodes, so it records, replays and tests as a pipeline does.
949
+ *
950
+ * @param {(input: any) => Generator<any, any, any>} body
951
+ * @returns {(input: any) => Effect}
952
+ */
953
+ const effectGen = (body) => {
954
+ if (typeof body !== 'function') {
955
+ throw malformed(
956
+ `effectGen expects a generator function, function* (input) { ... }, got ${describeArgument(body)}.`,
957
+ body
958
+ );
959
+ }
960
+ if (Object.getPrototypeOf(body) === asyncGeneratorFunction) {
961
+ throw malformed(
962
+ 'effectGen expects function*, not async function*: get each result with yield* step(command) instead of await.',
963
+ body
964
+ );
965
+ }
966
+ return (input) => {
967
+ const flow = { body, input };
968
+ // Keyed on a copy, as `effectPipe`'s root is, since a body can return a shared object.
969
+ const root = copyRoot(advance(flow, startRun(flow), null, (g) => g.next()));
970
+ flowInputs.set(root, input);
971
+ return root;
510
972
  };
511
973
  };
512
974
 
@@ -514,49 +976,66 @@ const effectPipe = (...fns) => {
514
976
  // #region Configuration
515
977
 
516
978
  /**
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.
979
+ * Wraps one Command, or one Parallel, whose `op` runs the branches and returns the decision, so a hook must call it.
980
+ * Only a replay passes `op` an argument, the recorded decision. `path` is the step's position in the Effect tree.
521
981
  * @typedef {(name: string, type: string, op: function, path?: string) => Promise<any>} StepRunner
522
982
  */
523
983
  /** @type StepRunner */
524
984
  const defaultStepRunner = async (name, type, op) => await op();
525
985
 
526
- /** @typedef {(effect: Effect, op: function, flowName?: string) => Promise<any>} RunWrapper */
986
+ /**
987
+ * Wraps one run. `initialInput` is what the flow was called with, when `effectPipe` built it.
988
+ * @typedef {(effect: Effect, op: function, flowName?: string, initialInput?: any) => Promise<any>} RunWrapper
989
+ */
527
990
  /** @type RunWrapper */
528
- const defaultRunWrapper = async (effect, op, flowName) => await op();
991
+ const defaultRunWrapper = async (effect, op, flowName, initialInput) => await op();
529
992
 
530
993
  /** @typedef {(command: CommandState, context?: any) => Promise<any>} CommandInterceptor */
531
994
  /** @type CommandInterceptor */
532
995
  const defaultCommandInterceptor = async (command, context) => {};
533
996
 
534
- const defaultRetryOptions = { attempts: 3, delay: 100, backoff: 1 };
997
+ /** The hooks a configuration can set. */
998
+ const hookNames = ['onStep', 'onRun', 'onBeforeCommand'];
535
999
 
536
1000
  /**
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
1001
+ * Describes what was passed where a configuration belongs. A function there is usually one that builds hooks, as
1002
+ * `telemetryHooks` does, passed without calling it.
1003
+ * @param {any} value
1004
+ * @returns {string}
541
1005
  */
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
- );
1006
+ const describeConfiguration = (value) =>
1007
+ typeof value === 'function'
1008
+ ? 'a function, which usually means one that builds hooks was passed without being called'
1009
+ : describeArgument(value);
1010
+
1011
+ /**
1012
+ * Refuses a key no hook has, such as a misspelt `onstep`, and a hook that is not a function. Only `undefined`
1013
+ * leaves a hook unset.
1014
+ * @param {any} config
1015
+ * @param {string} source - Where it was passed, as the message names it
1016
+ * @param {string} prefix - What precedes a hook's name in the message, as in `configureEffect's onStep`
1017
+ * @param {string[]} known - The keys it may hold
1018
+ */
1019
+ const checkConfiguration = (config, source, prefix, known) => {
1020
+ rejectUnknownOptions(config, source, known);
1021
+ for (const name of hookNames) {
1022
+ const hook = config[name];
1023
+ if (hook !== undefined && typeof hook !== 'function') {
1024
+ throw new TypeError(`${prefix}${name} must be a function, got ${describeArgument(hook)}.`);
1025
+ }
1026
+ }
547
1027
  };
548
1028
 
549
1029
  /**
550
1030
  * @typedef {Object} EffectConfiguration
551
1031
  * @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.
1032
+ * @property {RunWrapper} [onRun] - Wraps a whole run once, and is handed the flow's input when `effectPipe` built it.
1033
+ * @property {CommandInterceptor} [onBeforeCommand] - Runs before each Command; a throw vetoes it.
554
1034
  */
555
1035
 
556
1036
  /**
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.
1037
+ * A per-call configuration. With `inherit: true` (the default) its hooks merge over the installed wiring, global
1038
+ * outermost; with `inherit: false` that wiring is ignored.
560
1039
  *
561
1040
  * @typedef {EffectConfiguration & { inherit?: boolean }} CallConfiguration
562
1041
  */
@@ -565,8 +1044,8 @@ const rejectRetryKey = (config, source) => {
565
1044
  let layers = [];
566
1045
 
567
1046
  /**
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.
1047
+ * The installed layers merged into one configuration. A slot no layer defines is absent, and `interpret` picks the
1048
+ * library default for it.
570
1049
  * @type {EffectConfiguration}
571
1050
  */
572
1051
  let globalConfig = {};
@@ -577,22 +1056,14 @@ const applyLayers = () => {
577
1056
  };
578
1057
 
579
1058
  /**
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:
1059
+ * Adds a layer of global hooks and returns a function that removes it, wherever it sits by then. Layers merge as
1060
+ * configurations passed to one call do, so these are the same:
589
1061
  *
590
1062
  * configureEffect(telemetryHooks(), recordingHooks({ sink }));
591
1063
  * configureEffect(telemetryHooks()); configureEffect(recordingHooks({ sink }));
592
1064
  *
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.
1065
+ * A call with no arguments removes every layer. A call whose arguments are all `undefined`, as in
1066
+ * `configureEffect(flag ? hooks : undefined)`, adds and removes nothing.
596
1067
  *
597
1068
  * @param {...(EffectConfiguration | undefined)} configs - Configurations merged into one layer, outermost first
598
1069
  * @returns {() => void} Removes the layer this call added; a second call does nothing
@@ -603,9 +1074,14 @@ const configureEffect = (...configs) => {
603
1074
  applyLayers();
604
1075
  return () => {};
605
1076
  }
606
- const present = configs.filter(Boolean);
1077
+ const present = configs.filter((config) => config !== undefined);
607
1078
  // Before installing anything, so a refused call leaves the wiring untouched.
608
- present.forEach((config) => rejectRetryKey(config, 'configureEffect'));
1079
+ present.forEach((config) => {
1080
+ if (!isOptionsObject(config)) {
1081
+ throw new TypeError(`configureEffect expects configuration objects, got ${describeConfiguration(config)}.`);
1082
+ }
1083
+ checkConfiguration(config, 'configureEffect', "configureEffect's ", hookNames);
1084
+ });
609
1085
  if (present.length === 0) return () => {};
610
1086
  const layer = chainHooks(...present);
611
1087
  layers = [...layers, layer];
@@ -618,13 +1094,9 @@ const configureEffect = (...configs) => {
618
1094
  };
619
1095
 
620
1096
  /**
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.
1097
+ * Merges configurations into one, the first given outermost. `onStep` and `onRun` nest around `op`, and
1098
+ * `onBeforeCommand` interceptors run in order, the first to throw vetoing the Command. A hook no configuration
1099
+ * defines is left unset.
628
1100
  *
629
1101
  * @param {...(EffectConfiguration | undefined)} configs - Configurations to merge, outermost first
630
1102
  * @returns {EffectConfiguration}
@@ -649,7 +1121,8 @@ const chainHooks = (...configs) => {
649
1121
  }
650
1122
  if (runs.length) {
651
1123
  merged.onRun = runs.reduceRight(
652
- (inner, outer) => (effect, op, flowName) => outer(effect, () => inner(effect, op, flowName), flowName)
1124
+ (inner, outer) => (effect, op, flowName, initialInput) =>
1125
+ outer(effect, () => inner(effect, op, flowName, initialInput), flowName, initialInput)
653
1126
  );
654
1127
  }
655
1128
  if (interceptors.length) {
@@ -664,23 +1137,25 @@ const chainHooks = (...configs) => {
664
1137
  // #region Running flows
665
1138
 
666
1139
  /**
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
1140
+ * The Failure a cancelled `Parallel` branch stops with, named so it is never mistaken for the failure that
1141
+ * triggered the cancellation.
670
1142
  * @returns {FailureState}
671
1143
  */
672
- const cancelledBranch = (initialInput) =>
673
- Failure(Object.assign(new Error('Parallel branch cancelled.'), { name: 'ParallelCancelled' }), initialInput);
1144
+ const cancelledBranch = () =>
1145
+ Failure(Object.assign(new Error('Parallel branch cancelled.'), { name: 'ParallelCancelled' }));
674
1146
 
675
1147
  /**
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
1148
+ * Which branch, if any, cancelled a Parallel, and how many branches it had; `branch: null` means an enclosing Parallel
1149
+ * 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
1150
+ * replay see a branch added or removed. `threw` says a branch threw, which marked the scope, so a replay stops no
1151
+ * generator flow there, as production did not once the throw came.
1152
+ * @typedef {{ cancelled: false, branches: number }
1153
+ * | { cancelled: true, branch: number | null, branches: number, threw?: true }} ParallelDecision
679
1154
  */
680
1155
 
681
1156
  /**
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.
1157
+ * What running a Parallel's branches produced: each branch's outcome in array order, the decision, and the first
1158
+ * error a branch threw, which the Parallel rethrows once its step has returned the decision.
684
1159
  * @typedef {{
685
1160
  * results: (SuccessState | FailureState | IoFaultState)[],
686
1161
  * decision: ParallelDecision,
@@ -696,25 +1171,32 @@ const cancelledBranch = (initialInput) =>
696
1171
  const isBranchIndex = (branch, count) => Number.isInteger(branch) && branch >= 0 && branch < count;
697
1172
 
698
1173
  /**
699
- * Reads a recorded decision, or `undefined` for anything that is not one, which replays the Parallel
700
- * under timing.
1174
+ * Names a number of branches, for a message.
1175
+ * @param {number} count
1176
+ * @returns {string}
1177
+ */
1178
+ const branchCount = (count) => (count === 1 ? '1 branch' : `${count} branches`);
1179
+
1180
+ /**
1181
+ * Reads a recorded decision, or `undefined` for anything that is not one, which replays the Parallel by timing.
701
1182
  * @param {any} value
702
1183
  * @param {number} branches - How many branches the Parallel has, so a stale branch index is refused too
703
1184
  * @returns {ParallelDecision | undefined}
704
1185
  */
705
1186
  const asDecision = (value, branches) => {
706
1187
  if (!isObject(value)) return undefined;
707
- if (value.cancelled === false) return { cancelled: false };
1188
+ if (value.cancelled === false) return { cancelled: false, branches };
708
1189
  if (value.cancelled !== true) return undefined;
709
1190
  const { branch } = value;
710
- if (branch === null) return { cancelled: true, branch: null };
711
- return isBranchIndex(branch, branches) ? { cancelled: true, branch } : undefined;
1191
+ if (branch !== null && !isBranchIndex(branch, branches)) return undefined;
1192
+ return value.threw === true
1193
+ ? { cancelled: true, branch, branches, threw: true }
1194
+ : { cancelled: true, branch, branches };
712
1195
  };
713
1196
 
714
1197
  /**
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.
1198
+ * Marks a step a replay reached that production never ran, in a branch a recorded decision cancelled. Only
1199
+ * `resolveByPath` raises it, and `runCommand` and `runParallel` catch it and stop the branch there, as production did.
718
1200
  */
719
1201
  const replayCut = Symbol('pure-effect.replayCut');
720
1202
 
@@ -730,27 +1212,37 @@ const replayCutError = (path) =>
730
1212
  );
731
1213
 
732
1214
  /**
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.
1215
+ * Cancellation scopes a branch's throw aborted, and the scopes linked under them. `stopAt` stops no generator flow
1216
+ * under one, since the throw rejects the run.
1217
+ * @type {WeakSet<AbortSignal>}
1218
+ */
1219
+ const thrownScopes = new WeakSet();
1220
+
1221
+ /**
1222
+ * A cancellation scope for one Parallel, linked to the enclosing one so cancellation nests.
735
1223
  * @param {AbortSignal} [signal] - The enclosing Parallel's signal, if any
736
- * @returns {{ scope: AbortController | undefined, unlink: () => void }}
1224
+ * @returns {{ scope: AbortController, unlink: () => void }}
737
1225
  */
738
1226
  const linkedScope = (signal) => {
739
- const scope = typeof AbortController === 'function' ? new AbortController() : undefined;
740
- const relay = () => scope?.abort();
741
- if (signal && scope) {
742
- if (signal.aborted) scope.abort();
1227
+ const scope = new AbortController();
1228
+ if (signal && thrownScopes.has(signal)) thrownScopes.add(scope.signal);
1229
+ const relay = () => {
1230
+ if (signal && thrownScopes.has(signal)) thrownScopes.add(scope.signal);
1231
+ scope.abort();
1232
+ };
1233
+ if (signal) {
1234
+ if (signal.aborted) relay();
743
1235
  else signal.addEventListener('abort', relay, { once: true });
744
1236
  }
745
1237
  const unlink = () => {
746
- if (signal && scope) signal.removeEventListener('abort', relay);
1238
+ if (signal) signal.removeEventListener('abort', relay);
747
1239
  };
748
1240
  return { scope, unlink };
749
1241
  };
750
1242
 
751
1243
  /**
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.
1244
+ * Awaits every task, with at most `limit` in flight. Workers pull by index, so results land where the caller put
1245
+ * the effect rather than where it finished, which keeps trace paths stable.
754
1246
  * @param {(() => Promise<void>)[]} tasks
755
1247
  * @param {number} [limit]
756
1248
  * @returns {Promise<void>}
@@ -768,8 +1260,8 @@ const runBounded = async (tasks, limit) => {
768
1260
  };
769
1261
 
770
1262
  /**
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.
1263
+ * Sleeps, but stops early when the branch is cancelled. Resolves either way; the caller's abort check turns a
1264
+ * cancelled wait into a Failure.
773
1265
  *
774
1266
  * @param {number} ms
775
1267
  * @param {AbortSignal} [signal]
@@ -781,8 +1273,8 @@ const delayFor = (ms, signal) =>
781
1273
  setTimeout(resolve, ms);
782
1274
  return;
783
1275
  }
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.
1276
+ // A listener added to an aborted signal never runs, and a Retry often backs off on a signal that has already
1277
+ // fired.
786
1278
  if (signal.aborted) {
787
1279
  resolve();
788
1280
  return;
@@ -798,397 +1290,447 @@ const delayFor = (ms, signal) =>
798
1290
  signal.addEventListener('abort', onAbort, { once: true });
799
1291
  });
800
1292
 
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
- }
1293
+ /**
1294
+ * What every part of the interpreter needs from one run: the context `Ask` reads, whether retries wait, and the two
1295
+ * hooks a step calls, resolved from the installed wiring and the call's own configuration.
1296
+ * @typedef {{
1297
+ * context: any,
1298
+ * fastRetry: boolean,
1299
+ * onStep: StepRunner,
1300
+ * onBeforeCommand: CommandInterceptor
1301
+ * }} Runtime
1302
+ */
868
1303
 
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/`);
923
- }
1304
+ /**
1305
+ * The interpreter, which `runEffect` and `replayEffect` share, so a replay cannot drift from a run. Only a replay
1306
+ * sets `fastRetry`, which waits no time between retry attempts.
1307
+ *
1308
+ * @param {Effect} effect
1309
+ * @param {any} [context]
1310
+ * @param {CallConfiguration} [callConfig]
1311
+ * @param {boolean} [fastRetry]
1312
+ * @returns {Promise<SuccessState | FailureState>}
1313
+ */
1314
+ const interpret = async (effect, context = {}, callConfig = {}, fastRetry = false) => {
1315
+ if (!isOptionsObject(callConfig)) {
1316
+ throw new TypeError(`runEffect's callConfig must be an object, got ${describeConfiguration(callConfig)}.`);
1317
+ }
1318
+ checkConfiguration(callConfig, "runEffect's callConfig", 'callConfig.', [...hookNames, 'inherit']);
1319
+ const { inherit = true, ...local } = callConfig;
1320
+ // Not coerced, so `'false'` cannot inherit everything.
1321
+ if (typeof inherit !== 'boolean') {
1322
+ throw new TypeError(`callConfig.inherit must be true or false, got ${describeArgument(inherit)}.`);
1323
+ }
1324
+ const base = inherit ? globalConfig : {};
1325
+ const resolved = Object.keys(local).length ? chainHooks(base, local) : base;
1326
+ /** @type {Runtime} */
1327
+ const runtime = {
1328
+ context,
1329
+ fastRetry,
1330
+ onStep: resolved.onStep || defaultStepRunner,
1331
+ onBeforeCommand: resolved.onBeforeCommand || defaultCommandInterceptor
1332
+ };
1333
+ const onRun = resolved.onRun || defaultRunWrapper;
1334
+ // An I/O fault becomes a plain Failure here, where every outcome leaves.
1335
+ const op = async () => asOutcome(await execute(runtime, effect));
1336
+ return onRun(effect, op, context?.flowName || '', flowInputs.get(effect));
1337
+ };
924
1338
 
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));
975
- }
1339
+ /**
1340
+ * Tells the generator flows waiting on `node` that it stopped the flow, innermost first. The other end of the
1341
+ * protocol is `chain`, which keeps the listeners on each node it copies. Returns `result`, for a `return` to pass on.
1342
+ * Under a scope a throw cancelled it tells none, since the run is about to reject and a `catch` could hide why.
1343
+ *
1344
+ * @param {Effect} node
1345
+ * @param {SuccessState | FailureState | IoFaultState} result
1346
+ * @param {AbortSignal | undefined} signal
1347
+ * @returns {SuccessState | FailureState | IoFaultState}
1348
+ */
1349
+ const stopAt = (node, result, signal) => {
1350
+ if (signal && thrownScopes.has(signal)) return result;
1351
+ stopListeners.get(node)?.forEach((listener) => listener(node));
1352
+ return result;
1353
+ };
976
1354
 
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')
1016
- );
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 };
1355
+ /**
1356
+ * `stopAt` for a node a cancellation skipped, which also tells the generator flows built inside a skipped `Retry` or
1357
+ * `Parallel`, innermost first, since each body ran up to its first step as it was built.
1358
+ *
1359
+ * @param {Effect} node
1360
+ * @param {SuccessState | FailureState | IoFaultState} result
1361
+ * @param {AbortSignal | undefined} signal
1362
+ * @returns {SuccessState | FailureState | IoFaultState}
1363
+ */
1364
+ const stopSkipped = (node, result, signal) => {
1365
+ if (node.type === 'Retry') stopSkipped(node.effect, result, signal);
1366
+ if (node.type === 'Parallel') node.effects.forEach((branch) => stopSkipped(branch, result, signal));
1367
+ return stopAt(node, result, signal);
1368
+ };
1369
+
1370
+ /**
1371
+ * Walks a subtree until it reaches a Success, a Failure or an I/O fault. Every node but `Ask` runs in its
1372
+ * own function, which returns a Success carrying the value for the node's `next`, or what stops the subtree.
1373
+ *
1374
+ * @param {Runtime} runtime
1375
+ * @param {Effect} eff
1376
+ * @param {AbortSignal} [signal] - Cancellation for this subtree, set for `Parallel` branches.
1377
+ * @param {string} [path] - This subtree's prefix. Each `Parallel` branch and `Retry` attempt opens its own, so
1378
+ * a path depends only on the tree's shape, never on the order branches finish in.
1379
+ * @returns {Promise<SuccessState | FailureState | IoFaultState>}
1380
+ */
1381
+ const execute = async (runtime, eff, signal, path = '') => {
1382
+ let step = 0;
1383
+ /** @type {Effect | undefined} The node whose `next` returned `eff`, which an error names. */
1384
+ let from;
1385
+ while (isPending(eff)) {
1386
+ // A Command already in flight cannot be stopped, but the next one never starts.
1387
+ if (signal?.aborted) return stopSkipped(eff, cancelledBranch(), signal);
1388
+ if (eff.type === 'Ask') {
1389
+ from = eff;
1390
+ eff = eff.next(runtime.context);
1391
+ continue;
1038
1392
  }
1393
+ const stepPath = `${path}${step++}`;
1394
+ const outcome =
1395
+ eff.type === 'Retry'
1396
+ ? await runRetry(runtime, eff, signal, stepPath)
1397
+ : eff.type === 'Parallel'
1398
+ ? await runParallel(runtime, eff, signal, stepPath)
1399
+ : await runCommand(runtime, eff, signal, stepPath);
1400
+ if (outcome.type !== 'Success') return stopAt(eff, outcome, signal);
1401
+ // Outside every catch: `next` and the pure steps it reaches are code, not I/O, so a throw there
1402
+ // rejects the run.
1403
+ from = eff;
1404
+ eff = eff.next(outcome.value);
1405
+ }
1406
+ if (isOutcome(eff)) return eff;
1407
+ throw effectTypeError(eff, from ? nextOf(from) : 'The flow');
1408
+ };
1039
1409
 
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
- };
1410
+ /**
1411
+ * Runs a Retry's wrapped tree until it succeeds or runs out of attempts, then its fallback if it has one.
1412
+ * Each attempt opens its own path prefix, and so does the fallback.
1413
+ *
1414
+ * @param {Runtime} runtime
1415
+ * @param {RetryState} retry
1416
+ * @param {AbortSignal | undefined} signal
1417
+ * @param {string} stepPath
1418
+ * @returns {Promise<SuccessState | FailureState | IoFaultState>}
1419
+ */
1420
+ const runRetry = async (runtime, retry, signal, stepPath) => {
1421
+ const given = Object.entries(retry.options ?? {}).filter(([, value]) => value !== undefined);
1422
+ const opts = /** @type {typeof defaultRetryOptions & RetryState['options']} */ ({
1423
+ ...defaultRetryOptions,
1424
+ ...Object.fromEntries(given)
1425
+ });
1426
+ const { attempts, onExhausted } = opts;
1427
+ let lastError;
1428
+ for (let attempt = 0; attempt <= attempts; attempt++) {
1429
+ // `fastRetry` waits for no time rather than skipping the wait, so branches replayed by timing still
1430
+ // interleave.
1431
+ if (attempt > 0) {
1432
+ await delayFor(runtime.fastRetry ? 0 : opts.delay * Math.pow(opts.backoff, attempt - 1), signal);
1094
1433
  }
1434
+ // After the wait, so a branch cancelled mid-backoff makes no further attempt.
1435
+ if (signal?.aborted) return cancelledBranch();
1436
+ const result = await execute(runtime, retry.effect, signal, `${stepPath}r${attempt}/`);
1437
+ // Only an I/O fault is retried. An abort passes through unwrapped.
1438
+ if (result.type !== 'IoFault') return result;
1439
+ lastError = result.error;
1440
+ }
1441
+ const exhausted = { retryExhausted: true, lastError, attempts };
1442
+ // A fault too, so an enclosing Retry retries this one.
1443
+ if (typeof onExhausted !== 'function') return IoFault(exhausted);
1444
+ if (signal?.aborted) return cancelledBranch();
1445
+ // A failing fallback propagates as it is, not wrapped as another exhaustion.
1446
+ return execute(runtime, asEffect(onExhausted(exhausted), "Retry option 'onExhausted'"), signal, `${stepPath}rf/`);
1447
+ };
1095
1448
 
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);
1449
+ /**
1450
+ * Runs a Parallel as one step, whose `op` runs the branches and returns the decision, so the decision is
1451
+ * recorded and a replay can hand it back.
1452
+ *
1453
+ * @param {Runtime} runtime
1454
+ * @param {ParallelState} parallel
1455
+ * @param {AbortSignal | undefined} signal
1456
+ * @param {string} stepPath
1457
+ * @returns {Promise<SuccessState | FailureState | IoFaultState>}
1458
+ */
1459
+ const runParallel = async (runtime, parallel, signal, stepPath) => {
1460
+ const branchPath = `${stepPath}p`;
1461
+ const options = parallel.options ?? {};
1462
+ const { settled } = options;
1463
+ // Cast rather than annotated, since only `op` assigns it.
1464
+ let branchRun = /** @type {BranchRun | undefined} */ (undefined);
1465
+ // A replay passes the recorded decision, from `replayHook`'s onStep; a live run passes nothing.
1466
+ const op = async (/** @type {any} */ recorded) => {
1467
+ branchRun = await runBranches(runtime, parallel.effects, options, signal, branchPath, recorded);
1468
+ return branchRun.decision;
1469
+ };
1470
+ try {
1471
+ await runtime.onStep('Parallel', 'Parallel', op, branchPath);
1472
+ } catch (e) {
1473
+ // A cut from `fromTrace`: production had already stopped the branch this Parallel is in, so it skipped this
1474
+ // Parallel, and `stopSkipped` tells the flows built in its branches, as production's cancellation did.
1475
+ if (hasMark(e, replayCut)) {
1476
+ parallel.effects.forEach((branch) => stopSkipped(branch, cancelledBranch(), signal));
1477
+ return cancelledBranch();
1176
1478
  }
1479
+ throw e;
1480
+ }
1481
+ if (!branchRun) {
1482
+ throw new TypeError(
1483
+ `An onStep hook returned without letting op run the Parallel at path '${branchPath}'. ` +
1484
+ 'A hook has to call op for a Parallel and pass on what it returns or throws, because op ' +
1485
+ 'runs its branches.'
1486
+ );
1487
+ }
1488
+ const { results, decision, thrown } = branchRun;
1489
+ // Rethrown only now, after the step has returned its decision, so the decision is recorded.
1490
+ if (thrown) throw thrown.error;
1491
+ if (settled) return Success(results.map(asOutcome));
1492
+ const failure =
1493
+ decision.cancelled && decision.branch !== null
1494
+ ? results[decision.branch]
1495
+ : results.find((r) => r.type !== 'Success');
1496
+ if (failure) return failure;
1497
+ return Success(results.map((r) => /** @type {SuccessState} */ (r).value));
1498
+ };
1177
1499
 
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 || '');
1500
+ /**
1501
+ * Runs a Parallel's branches and decides which branch, if any, cancelled the others. Live, timing decides.
1502
+ * A recorded cancellation is reproduced rather than recomputed: no branch cancels another, each stops where
1503
+ * its recording stops, and the recorded branch's failure is the result.
1504
+ *
1505
+ * @param {Runtime} runtime
1506
+ * @param {Effect[]} effects
1507
+ * @param {ParallelOptions} options
1508
+ * @param {AbortSignal | undefined} signal - The enclosing Parallel's cancellation, if any
1509
+ * @param {string} branchPath
1510
+ * @param {any} recorded - The recorded decision, which `replayHook`'s onStep passes to the Parallel's
1511
+ * `op`; undefined in a live run
1512
+ * @returns {Promise<BranchRun>}
1513
+ */
1514
+ const runBranches = async (runtime, effects, options, signal, branchPath, recorded) => {
1515
+ const { limit, settled } = options;
1516
+ // A recorded branch past the end means the flow changed shape. A negative one is not a decision, and
1517
+ // replays by timing.
1518
+ const recordedBranch = recorded?.cancelled === true ? recorded.branch : undefined;
1519
+ const pastTheEnd = Number.isInteger(recordedBranch) && recordedBranch >= effects.length;
1520
+ if (pastTheEnd) {
1521
+ throw timeParadoxAt(
1522
+ `path '${branchPath}'`,
1523
+ `the recorded run was cancelled by branch ${recordedBranch}, but this Parallel has ` +
1524
+ `${branchCount(effects.length)}.`,
1525
+ { path: branchPath, branch: recordedBranch }
1526
+ );
1527
+ }
1528
+ // So does a different number of branches, checked before any branch runs, since a new branch's steps are ones
1529
+ // the trace lacks and would run live under onMissing: 'execute'. A decision recorded without a count is not
1530
+ // judged by one.
1531
+ const recordedBranches = recorded?.branches;
1532
+ if (Number.isInteger(recordedBranches) && recordedBranches !== effects.length) {
1533
+ throw timeParadoxAt(
1534
+ `path '${branchPath}'`,
1535
+ `this Parallel has ${branchCount(effects.length)}, and the trace recorded ${recordedBranches}.`,
1536
+ { path: branchPath, expected: recordedBranches, actual: effects.length }
1537
+ );
1538
+ }
1539
+ const forced = asDecision(recorded, effects.length);
1540
+ const reproducing = forced !== undefined && forced.cancelled;
1541
+ // Live, a throw cancels the others, and so does a failure unless `settled`. Reproducing, nothing does,
1542
+ // since the recording already says where each branch stops.
1543
+ const { results, thrown, trigger, cancelled } = await settleBranches(
1544
+ runtime,
1545
+ effects,
1546
+ limit,
1547
+ signal,
1548
+ branchPath,
1549
+ reproducing ? () => false : (threw, result) => threw || (!settled && result.type !== 'Success'),
1550
+ reproducing && forced.threw === true
1551
+ );
1552
+ if (reproducing) {
1553
+ // A branch that threw has no result to check, and its throw is what is rethrown.
1554
+ const recordedTriggerSucceeded = !thrown && forced.branch !== null && results[forced.branch].type === 'Success';
1555
+ if (recordedTriggerSucceeded) {
1556
+ throw timeParadoxAt(
1557
+ `path '${branchPath}'`,
1558
+ `the recorded run was cancelled by branch ${forced.branch}, which did not fail in this replay.`,
1559
+ { path: branchPath, branch: forced.branch }
1560
+ );
1561
+ }
1562
+ return { results, decision: forced, thrown };
1563
+ }
1564
+ const branches = effects.length;
1565
+ /** @type {ParallelDecision} */
1566
+ const decision =
1567
+ trigger >= 0
1568
+ ? { cancelled: true, branch: trigger, branches }
1569
+ : cancelled
1570
+ ? { cancelled: true, branch: null, branches }
1571
+ : { cancelled: false, branches };
1572
+ // A throw from any branch marked the scope, whichever branch cancelled the others first.
1573
+ if (thrown !== undefined && decision.cancelled) decision.threw = true;
1574
+ return { results, decision, thrown };
1575
+ };
1576
+
1577
+ /**
1578
+ * Runs every branch to completion, at most `limit` at once, under one cancellation scope linked to the
1579
+ * enclosing one. The first branch to settle in a way `cancelsOthers` accepts cancels the rest, and is the
1580
+ * trigger.
1581
+ *
1582
+ * @param {Runtime} runtime
1583
+ * @param {Effect[]} effects
1584
+ * @param {number | undefined} limit
1585
+ * @param {AbortSignal | undefined} signal
1586
+ * @param {string} branchPath
1587
+ * @param {(threw: boolean, result: SuccessState | FailureState | IoFaultState) => boolean} cancelsOthers
1588
+ * @param {boolean} [recordedThrow] - A replay's recorded decision says a branch threw, so no generator flow stops
1589
+ * @returns {Promise<{
1590
+ * results: (SuccessState | FailureState | IoFaultState)[],
1591
+ * thrown: { error: unknown } | undefined,
1592
+ * trigger: number,
1593
+ * cancelled: boolean
1594
+ * }>}
1595
+ */
1596
+ const settleBranches = async (runtime, effects, limit, signal, branchPath, cancelsOthers, recordedThrow = false) => {
1597
+ const { scope, unlink } = linkedScope(signal);
1598
+ if (recordedThrow) thrownScopes.add(scope.signal);
1599
+ /** @type {(SuccessState | FailureState | IoFaultState)[]} */
1600
+ const results = new Array(effects.length);
1601
+ // Held rather than rethrown, so every branch still settles and no `limit` worker stops early.
1602
+ /** @type {{ error: unknown }[]} */
1603
+ const thrown = new Array(effects.length);
1604
+ // Whether each branch cancelled the others itself, rather than being cancelled.
1605
+ const triggered = new Array(effects.length).fill(false);
1606
+ const settle = async (/** @type {Effect} */ branch, /** @type {number} */ i) => {
1607
+ try {
1608
+ results[i] = await execute(runtime, branch, scope.signal, `${branchPath}${i}/`);
1609
+ } catch (error) {
1610
+ thrown[i] = { error };
1611
+ }
1612
+ // Read-then-abort is atomic here, so exactly one branch is the trigger.
1613
+ if (cancelsOthers(thrown[i] !== undefined, results[i])) {
1614
+ if (!scope.signal.aborted) triggered[i] = true;
1615
+ if (thrown[i] !== undefined) thrownScopes.add(scope.signal);
1616
+ scope.abort();
1617
+ }
1618
+ };
1619
+ try {
1620
+ // Awaits every branch, so no cancelled work runs on after the Parallel returns.
1621
+ await runBounded(
1622
+ effects.map((branch, i) => () => settle(branch, i)),
1623
+ limit
1624
+ );
1625
+ } finally {
1626
+ unlink();
1627
+ }
1628
+ return {
1629
+ results,
1630
+ // The first by array order, since every branch has settled by now.
1631
+ thrown: thrown.find(Boolean),
1632
+ trigger: triggered.indexOf(true),
1633
+ cancelled: scope.signal.aborted
1634
+ };
1635
+ };
1636
+
1637
+ /**
1638
+ * A Command's function wrapped as the `op` a hook is handed, with what its calls did, so `runCommand` can tell a hook
1639
+ * that lost the result, or did not wait for it, from one that answered without calling `op`. `running` and
1640
+ * `succeeded` describe the latest call, which resets them as it starts, and `value` is the last value a call returned.
1641
+ * @typedef {{
1642
+ * op: () => Promise<unknown>,
1643
+ * latest: Promise<unknown> | undefined,
1644
+ * running: boolean,
1645
+ * succeeded: boolean,
1646
+ * value: unknown
1647
+ * }} TrackedCalls
1648
+ */
1649
+
1650
+ /**
1651
+ * @param {() => unknown} call - Calls the Command's function
1652
+ * @returns {TrackedCalls}
1653
+ */
1654
+ const trackCalls = (call) => {
1655
+ /** @type {TrackedCalls} */
1656
+ const calls = {
1657
+ // Async, so a hook always gets a promise, even from a synchronous function.
1658
+ op: () => (calls.latest = invoke()),
1659
+ latest: undefined,
1660
+ running: false,
1661
+ succeeded: false,
1662
+ value: undefined
1663
+ };
1664
+ const invoke = async () => {
1665
+ calls.succeeded = false;
1666
+ calls.running = true;
1667
+ try {
1668
+ calls.value = await call();
1669
+ calls.succeeded = true;
1670
+ return calls.value;
1671
+ } finally {
1672
+ calls.running = false;
1673
+ }
1187
1674
  };
1675
+ return calls;
1676
+ };
1677
+
1678
+ /**
1679
+ * Runs one Command. A throw from an interceptor vetoes the Command, an abort. A throw from the step runner is an I/O
1680
+ * fault, unless the function had already succeeded, when it is a bug in a hook and rejects the run. A harness error
1681
+ * is rethrown from either. DESIGN.md's "Where a throw comes from" has the rest.
1682
+ *
1683
+ * @param {Runtime} runtime
1684
+ * @param {CommandState} command
1685
+ * @param {AbortSignal | undefined} signal
1686
+ * @param {string} cmdPath
1687
+ * @returns {Promise<SuccessState | FailureState | IoFaultState>}
1688
+ */
1689
+ const runCommand = async (runtime, command, signal, cmdPath) => {
1690
+ const cmdName = commandName(command);
1691
+ const { cmd } = command;
1692
+ // Only inside a Parallel, and only to a function that declares a parameter. A parameter with a default
1693
+ // value does not count toward `length`, so `nanoid(size = 21)` keeps its default; a plain first parameter
1694
+ // the function treats as optional still takes the signal, a documented sharp edge.
1695
+ const takesSignal = signal !== undefined && cmd.length > 0;
1696
+ const calls = trackCalls(() => (takesSignal ? cmd(signal) : cmd()));
1697
+ try {
1698
+ await runtime.onBeforeCommand(command, runtime.context);
1699
+ } catch (e) {
1700
+ if (hasMark(e, harnessError)) throw e;
1701
+ return Failure(e);
1702
+ }
1703
+ // Again, since an interceptor can wait (a rate limiter, say) while a sibling fails.
1704
+ if (signal?.aborted) return cancelledBranch();
1705
+ try {
1706
+ const returned = await runtime.onStep(cmdName, 'Command', calls.op, cmdPath);
1707
+ // A hook that returned without awaiting `op` is judged as though it had awaited it.
1708
+ const unwaited = returned === undefined && calls.running;
1709
+ if (unwaited) await calls.latest;
1710
+ // A hook that dropped the result would hand `next` `undefined`. Only `undefined` is refused, so a hook can
1711
+ // return a copy.
1712
+ if (returned === undefined && calls.succeeded && calls.value !== undefined) {
1713
+ throw new TypeError(
1714
+ `An onStep hook called op for '${cmdName}' at path '${cmdPath}' and returned undefined` +
1715
+ `${unwaited ? ' before op had finished' : ''}, although the Command returned a value. ` +
1716
+ 'A hook has to await op() and return what it returns.'
1717
+ );
1718
+ }
1719
+ return Success(returned);
1720
+ } catch (e) {
1721
+ // A cut from `fromTrace`: production stopped this branch before this step.
1722
+ if (hasMark(e, replayCut)) return cancelledBranch();
1723
+ if (hasMark(e, harnessError)) throw e;
1724
+ // After the function succeeded, a throw is a bug in a hook, the lost result above included. From a hook that
1725
+ // never called `op` it is a fault, which is how replay reports a recorded error.
1726
+ if (calls.succeeded) throw e;
1727
+ return IoFault(e);
1728
+ }
1729
+ };
1188
1730
 
1189
1731
  /**
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.
1732
+ * Runs a flow: executes its Commands, resolving `Ask` with the context and running `Retry` and `Parallel`. `onRun`
1733
+ * fires once per call, around every Retry attempt.
1192
1734
  *
1193
1735
  * @param {Effect} effect - The Effect tree returned by a pipeline
1194
1736
  * @param {any} [context] - Optional context object. Passed to Ask continuations and the Command Interceptor.
@@ -1202,51 +1744,63 @@ const runEffect = (effect, context, callConfig) => interpret(effect, context, ca
1202
1744
  // #region Recording and replay
1203
1745
 
1204
1746
  /**
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.
1747
+ * The step a replay is asking about. `path` is its position in the Effect tree, stable across runs; `index` is its
1748
+ * position in completion order, which is not stable for a flow with a `Parallel`. A step whose `type` is 'Parallel'
1749
+ * asks for the recorded decision and does not advance `index`; an answer that holds no decision replays that
1750
+ * Parallel by timing.
1210
1751
  * @typedef {{ index: number, name: string, type: string, path?: string }} ReplayStep
1211
1752
  */
1212
1753
 
1213
1754
  /**
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".
1755
+ * What production observed for a step. `{ result }` is handed to the Command's `next`; `{ error }` is thrown. A
1756
+ * Resolver returning `undefined` means "not recorded". Any other answer, `null` included, or a throw from the
1757
+ * Resolver, rejects the replay.
1217
1758
  *
1218
1759
  * @typedef {{ result: any } | { error: any }} ReplayOutcome
1219
1760
  */
1220
1761
 
1762
+ /**
1763
+ * Whether a Resolver's answer is an outcome: an object holding `result` or `error`.
1764
+ * @param {any} value
1765
+ * @returns {boolean}
1766
+ */
1767
+ const isReplayOutcome = (value) => isObject(value) && ('result' in value || 'error' in value);
1768
+
1221
1769
  /** @typedef {(step: ReplayStep) => ReplayOutcome | undefined} Resolver */
1222
1770
 
1223
1771
  /**
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
1772
+ * A recorded step: a Command's result or error, or a Parallel's decision, recorded as `command` 'Parallel' at the
1773
+ * Parallel's own path. `threw` marks a throw, since JSON drops an `error` key whose value is `undefined`.
1774
+ * `unrecorded` says why the entry holds no value. `durationMs` is rounded to microseconds.
1775
+ * @typedef {{
1776
+ * command: string,
1777
+ * path?: string,
1778
+ * result?: any,
1779
+ * threw?: true,
1780
+ * error?: any,
1781
+ * unrecorded?: UnrecordedCause,
1782
+ * durationMs?: number
1783
+ * }} TraceEntry
1230
1784
  */
1231
1785
 
1232
1786
  /**
1233
- * The reference trace format produced by `recorder`. A convenience, not a contract:
1234
- * `replayEffect` takes a Resolver, so any storage shape works.
1787
+ * The reference trace format produced by `recorder`. A convenience, not a contract: `replayEffect` takes a Resolver,
1788
+ * so any storage shape works. `unrecorded` says why the trace holds no `initialInput` or `context`.
1235
1789
  * @typedef {{
1236
1790
  * flowName?: string,
1237
1791
  * version?: string,
1238
1792
  * initialInput?: any,
1239
1793
  * context?: any,
1794
+ * unrecorded?: { initialInput?: UnrecordedCause, context?: UnrecordedCause },
1240
1795
  * dropped?: number,
1241
1796
  * trace: TraceEntry[]
1242
1797
  * }} TraceLog
1243
1798
  */
1244
1799
 
1245
1800
  /**
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.
1801
+ * Marks a replay fault: the trace cannot answer the flow, or disagrees with it. `replayEffect` turns one into a
1802
+ * `Failure` at its own boundary. An `EffectTypeError` has no such mark and keeps propagating, since a malformed flow
1803
+ * is a bug in the flow rather than in the trace.
1250
1804
  */
1251
1805
  const replayFault = Symbol('pure-effect.replayFault');
1252
1806
 
@@ -1266,8 +1820,7 @@ const replayError = (message, props = {}) =>
1266
1820
  );
1267
1821
 
1268
1822
  /**
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.
1823
+ * Builds a `TimeParadox`: the flow no longer matches its recording at this point.
1271
1824
  * @param {string} at - Where, as the message names it
1272
1825
  * @param {string} detail - How the flow and the recording disagree
1273
1826
  * @param {Object} props - The fields that locate it, such as `path`
@@ -1293,8 +1846,8 @@ const timeParadox = (step, recorded) =>
1293
1846
  const carriedKeys = ['name', 'message', 'cause'];
1294
1847
 
1295
1848
  /**
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.
1849
+ * Converts a thrown value into something JSON can carry, including the non-enumerable `message`, `stack` and
1850
+ * `cause` a plain `JSON.stringify` would drop.
1298
1851
  *
1299
1852
  * @param {any} e - The thrown value
1300
1853
  * @param {boolean} [withStack] - Include the stack (off by default: noisy, leaks paths)
@@ -1305,25 +1858,22 @@ const serializeError = (e, withStack, ancestors = new Set()) => {
1305
1858
  if (!(e instanceof Error)) return e;
1306
1859
  /** @type {any} */
1307
1860
  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
1861
+ // A chain that loops back is cut where it returns, with the name and message only. Only ancestors count, so an
1310
1862
  // error that merely appears twice is carried in full both times.
1311
1863
  if (ancestors.has(e)) return out;
1312
1864
  ancestors.add(e);
1313
1865
  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
1866
  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.
1867
+ // An AggregateError's hidden `errors` goes under its own key, so revival can tell it from an enumerable `errors`,
1868
+ // which is data and is copied below as it is.
1318
1869
  const errors = /** @type {any} */ (e).errors;
1319
1870
  const hasHiddenErrors = Array.isArray(errors) && !Object.prototype.propertyIsEnumerable.call(e, 'errors');
1320
1871
  if (hasHiddenErrors) {
1321
1872
  out.__errors = errors.map((x) => serializeError(x, withStack, ancestors));
1322
1873
  }
1323
1874
  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 {}.
1875
+ // An assigned `name`, `message` or `cause` is enumerable. It is carried above already, so it is only listed, for
1876
+ // revival to restore as it was.
1327
1877
  const shown = carriedKeys.filter((k) => Object.prototype.propertyIsEnumerable.call(e, k));
1328
1878
  if (shown.length > 0) out.__enumerable = shown;
1329
1879
  for (const k of Object.keys(e)) if (!carriedKeys.includes(k)) out[k] = /** @type {any} */ (e)[k];
@@ -1331,23 +1881,21 @@ const serializeError = (e, withStack, ancestors = new Set()) => {
1331
1881
  };
1332
1882
 
1333
1883
  /**
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.
1884
+ * Rebuilds an Error from `serializeError` output. Non-Error values pass through, so a Command that rejected with a
1885
+ * string still replays as a string.
1336
1886
  * @param {any} v - A serialized error, or any other recorded value
1337
1887
  * @returns {any}
1338
1888
  */
1339
1889
  const reviveError = (v) => {
1340
1890
  if (!isObject(v) || v.__error !== true) return v;
1341
1891
  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.
1892
+ // `name`, `message`, `cause` and `errors` get the enumerability the original had, so a revived error deep-equals
1893
+ // the one the Command threw.
1345
1894
  const shown = Array.isArray(v.__enumerable) ? v.__enumerable : [];
1346
1895
  const restore = (/** @type {string} */ key, /** @type {any} */ value) =>
1347
1896
  Object.defineProperty(e, key, { enumerable: shown.includes(key), configurable: true, writable: true, value });
1348
1897
  restore('name', v.name);
1349
1898
  if (shown.includes('message')) restore('message', v.message);
1350
- // The markers are dropped, and `name` and `message` are already set.
1351
1899
  const alreadyHandled = ['__error', '__enumerable', 'name', 'message'];
1352
1900
  for (const [k, val] of Object.entries(v)) {
1353
1901
  if (alreadyHandled.includes(k)) continue;
@@ -1360,212 +1908,223 @@ const reviveError = (v) => {
1360
1908
 
1361
1909
  /**
1362
1910
  * @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.
1911
+ * @property {(value: any, name: string, kind: string) => any} [redact] - Scrubs every value a trace holds. `kind` is
1912
+ * `'result'`, `'error'`, `'initialInput'` or `'context'`, and `name` is the Command's name for the first
1913
+ * two and the kind for the others. It is handed a copy; a value it throws on is left out and marked.
1369
1914
  * @property {number} [maxEntries] - Caps trace length; further steps are counted in `dropped`, not stored.
1370
1915
  * @property {boolean} [stack] - Records stack traces for thrown errors.
1371
1916
  */
1372
1917
 
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
- */
1918
+ /** @type {Record<string, OptionRule>} */
1919
+ const recorderOptionRules = {
1920
+ redact: [isFunction, 'a function'],
1921
+ maxEntries: [(value) => value === Infinity || isPositiveInteger(value), 'a positive integer or Infinity'],
1922
+ stack: [isBoolean, 'true or false']
1923
+ };
1380
1924
 
1381
1925
  /**
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}
1926
+ * Why the recorder stored nothing for a value: `redact` threw on it, or copying it threw. It stores nothing rather
1927
+ * than a stand-in, since a stand-in would replay as what production saw.
1928
+ * @typedef {'redact' | 'copy'} UnrecordedCause
1386
1929
  */
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
1930
 
1393
1931
  /**
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}
1932
+ * What recording a value produced: the value the trace stores, or why it stores none.
1933
+ * @typedef {{ value: any } | { unrecorded: UnrecordedCause }} Recorded
1401
1934
  */
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
1935
 
1419
1936
  /**
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}
1937
+ * Runs one of the two parts of recording that can throw, `redact` or the copy, and names the one that threw. Every
1938
+ * read of the caller's value, and of what `redact` returns, goes through here. Nothing else in the recorder may
1939
+ * throw: a throw while a Command's error was being recorded would replace that error, and `Retry` would retry it.
1940
+ * @param {UnrecordedCause} cause
1941
+ * @param {() => any} compute
1942
+ * @returns {Recorded}
1427
1943
  */
1428
- const snapshot = (value) => {
1429
- if (!isObject(value)) return value;
1944
+ const recordPart = (cause, compute) => {
1430
1945
  try {
1431
- return typeof structuredClone === 'function' ? structuredClone(value) : JSON.parse(JSON.stringify(value));
1946
+ return { value: compute() };
1432
1947
  } catch {
1433
- return copyAround(value, new Map());
1948
+ return { unrecorded: cause };
1434
1949
  }
1435
1950
  };
1436
1951
 
1437
1952
  /**
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
1953
+ * @typedef {Object} TraceMeta
1954
+ * @property {any} [initialInput] - The value the flow was called with.
1955
+ * @property {string} [flowName]
1956
+ * @property {any} [context] - Context passed to `runEffect`; required to replay `Ask`.
1957
+ * @property {string} [version] - Commit or build id, so a replay can detect a stale trace.
1452
1958
  */
1453
1959
 
1454
- const now = () => (typeof performance === 'object' ? performance.now() : Date.now());
1455
-
1456
1960
  /**
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.
1961
+ * 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
1962
+ * replay from memory hands the flow what one from storage does. Throws on a value JSON cannot encode, such as a
1963
+ * BigInt; `undefined` and a function come back as `undefined`.
1459
1964
  *
1460
- * @param {(start: StepStart) => (end: StepEnd) => void} handler - Returns a finisher for the outcome
1461
- * @returns {StepRunner}
1965
+ * @param {any} value
1966
+ * @returns {any}
1462
1967
  */
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
- }
1968
+ const snapshot = (value) => {
1969
+ // A reference back to an enclosing object is cut, so a request and a response that point at each other can still
1970
+ // be recorded. An object shared without a loop is kept.
1971
+ /** @type {object[]} */
1972
+ const ancestors = [];
1973
+ const json = JSON.stringify(value, function (key, item) {
1974
+ if (!isObject(item)) return item;
1975
+ // `this` is the object holding `item`, so popping back to it leaves only `item`'s ancestors.
1976
+ while (ancestors.length > 0 && ancestors[ancestors.length - 1] !== this) ancestors.pop();
1977
+ if (ancestors.includes(item)) return undefined;
1978
+ ancestors.push(item);
1979
+ return item;
1980
+ });
1981
+ return json === undefined ? undefined : JSON.parse(json);
1488
1982
  };
1489
1983
 
1490
1984
  /**
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.
1985
+ * Builds an `onStep` hook that records every Command's result or error and every Parallel's decision, and `toTrace`,
1986
+ * which packages them. Give each run its own recorder: one installed for a whole application mixes runs into a trace
1987
+ * a replay refuses. examples/recording-example.js shows how.
1496
1988
  *
1497
1989
  * @param {RecorderOptions} [options] - Redaction and size limits
1498
1990
  * @returns {{ onStep: StepRunner, entries: TraceEntry[], toTrace: (meta?: TraceMeta) => TraceLog }}
1499
1991
  */
1500
1992
  const recorder = (options = {}) => {
1501
- rejectUnknownOptions(options, 'recorder', ['redact', 'maxEntries', 'stack']);
1502
- const { redact = (/** @type {any} */ r) => r, maxEntries = Infinity, stack = false } = options;
1993
+ checkOptions(options, 'recorder', recorderOptionRules);
1994
+ const { redact = (/** @type {any} */ r) => r, maxEntries = Infinity, stack = false } = options ?? {};
1995
+ /** @type {RecorderSettings} */
1996
+ const settings = { redact, stack };
1503
1997
  /** @type {TraceEntry[]} */
1504
1998
  const entries = [];
1505
1999
  let dropped = 0;
1506
2000
 
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) => {
2001
+ /**
2002
+ * Records each step it wraps and changes nothing about it: `op` runs, its result is returned, and its error
2003
+ * propagates.
2004
+ * @type {StepRunner}
2005
+ */
2006
+ const onStep = async (name, type, op, path) => {
2007
+ const started = performance.now();
2008
+ const keep = (/** @type {boolean} */ threw, /** @type {any} */ outcome) => {
2009
+ const durationMs = Math.round((performance.now() - started) * 1000) / 1000;
2010
+ const entry = traceEntry(settings, { name, type, path, durationMs }, threw, outcome);
2011
+ if (entries.length < maxEntries) entries.push(entry);
2012
+ else dropped++;
2013
+ };
1514
2014
  try {
1515
- return redact(value, name, kind);
1516
- } catch {
1517
- return '[redaction failed]';
2015
+ const result = await op();
2016
+ keep(false, result);
2017
+ return result;
2018
+ } catch (error) {
2019
+ keep(true, error);
2020
+ throw error;
1518
2021
  }
1519
2022
  };
1520
2023
 
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
- );
2024
+ /** @param {TraceMeta} [meta] */
2025
+ const toTrace = (meta = {}) => packTrace(settings, meta, entries, dropped);
2026
+ return { onStep, entries, toTrace };
2027
+ };
2028
+
2029
+ /**
2030
+ * What recording a value needs from a recorder's options: the caller's `redact`, and whether a thrown error keeps its
2031
+ * stack.
2032
+ * @typedef {{ redact: (value: any, name: string, kind: string) => any, stack: boolean }} RecorderSettings
2033
+ */
2034
+
2035
+ /**
2036
+ * The trace entry for one step: its result or error, copied and redacted, or why it holds neither. A Parallel's
2037
+ * decision holds no user data and must survive intact for a replay, so it is not redacted.
2038
+ * @param {RecorderSettings} settings
2039
+ * @param {{ name: string, type: string, path: string | undefined, durationMs: number }} step
2040
+ * @param {boolean} threw
2041
+ * @param {any} outcome - What the step returned, or what it threw
2042
+ * @returns {TraceEntry}
2043
+ */
2044
+ const traceEntry = (settings, { name, type, path, durationMs }, threw, outcome) => {
2045
+ const recorded = threw
2046
+ ? recordError(settings, outcome, name)
2047
+ : type === 'Parallel'
2048
+ ? recordPart('copy', () => snapshot(outcome))
2049
+ : copyAndRedact(settings, outcome, name, 'result');
2050
+ /** @type {TraceEntry} */
2051
+ const entry = threw ? { command: name, path, threw: true } : { command: name, path };
2052
+ if ('unrecorded' in recorded) return { ...entry, unrecorded: recorded.unrecorded, durationMs };
2053
+ return threw ? { ...entry, error: recorded.value, durationMs } : { ...entry, result: recorded.value, durationMs };
2054
+ };
2055
+
2056
+ /**
2057
+ * Copies a value for the trace and redacts the copy, for a step's result or one of the trace's own fields.
2058
+ * @param {RecorderSettings} settings
2059
+ * @param {any} value
2060
+ * @param {string} name
2061
+ * @param {string} kind
2062
+ * @returns {Recorded}
2063
+ */
2064
+ const copyAndRedact = (settings, value, name, kind) => {
2065
+ const copied = recordPart('copy', () => snapshot(value));
2066
+ if ('unrecorded' in copied) return copied;
2067
+ // What redact returns is copied too, so it reads the same from memory as from storage.
2068
+ return recordPart('redact', () => snapshot(settings.redact(copied.value, name, kind)));
2069
+ };
2070
+
2071
+ /**
2072
+ * Copies a thrown value's serialized form and redacts the copy. An Error stays marked as one when `redact` returns an
2073
+ * object without the mark, so a replay still throws an Error.
2074
+ * @param {RecorderSettings} settings
2075
+ * @param {any} thrown
2076
+ * @param {string} name
2077
+ * @returns {Recorded}
2078
+ */
2079
+ const recordError = (settings, thrown, name) => {
2080
+ // Read inside the copy, since it reads the caller's value, and before `redact` can change the copy.
2081
+ const copied = recordPart('copy', () => {
2082
+ const copy = snapshot(serializeError(thrown, settings.stack));
2083
+ return { copy, wasError: isObject(copy) && copy.__error === true };
2084
+ });
2085
+ if ('unrecorded' in copied) return copied;
2086
+ const { copy: serialized, wasError } = copied.value;
2087
+ return recordPart('redact', () => {
2088
+ const redacted = snapshot(settings.redact(serialized, name, 'error'));
2089
+ const rebuilt = wasError && isObject(redacted) && !Array.isArray(redacted) && redacted.__error !== true;
2090
+ return rebuilt ? { __error: true, ...redacted } : redacted;
1545
2091
  });
2092
+ };
1546
2093
 
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 = {}) => ({
2094
+ /**
2095
+ * A recorder's `toTrace`: its entries as a trace, with `meta.initialInput` and `meta.context` copied and redacted
2096
+ * when it is called, so call it before a run that can change them, and again once it ends for `dropped` and `trace`.
2097
+ * A field it cannot record is named in `unrecorded`. It never throws. `undefined` is left alone, so a redact that
2098
+ * spreads its argument cannot invent an empty object.
2099
+ * @param {RecorderSettings} settings
2100
+ * @param {TraceMeta} meta
2101
+ * @param {TraceEntry[]} entries
2102
+ * @param {number} dropped
2103
+ * @returns {TraceLog}
2104
+ */
2105
+ const packTrace = (settings, meta, entries, dropped) => {
2106
+ const field = (/** @type {any} */ value, /** @type {string} */ kind) =>
2107
+ value === undefined ? { value } : copyAndRedact(settings, value, kind, kind);
2108
+ const initialInput = field(meta.initialInput, 'initialInput');
2109
+ const context = field(meta.context, 'context');
2110
+ /** @type {{ initialInput?: UnrecordedCause, context?: UnrecordedCause }} */
2111
+ const unrecorded = {};
2112
+ if ('unrecorded' in initialInput) unrecorded.initialInput = initialInput.unrecorded;
2113
+ if ('unrecorded' in context) unrecorded.context = context.unrecorded;
2114
+ return {
1554
2115
  flowName: meta.flowName,
1555
2116
  version: meta.version,
1556
- initialInput: redactField(meta.initialInput, 'initialInput'),
1557
- context: redactField(meta.context, 'context'),
2117
+ initialInput: 'value' in initialInput ? initialInput.value : undefined,
2118
+ context: 'value' in context ? context.value : undefined,
2119
+ ...(Object.keys(unrecorded).length > 0 ? { unrecorded } : {}),
1558
2120
  dropped,
1559
2121
  trace: entries.slice()
1560
- });
1561
-
1562
- return { onStep, entries, toTrace };
2122
+ };
1563
2123
  };
1564
2124
 
1565
2125
  /**
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.
2126
+ * Runs a flow for real while recording it, and returns the outcome and a replayable trace. Convenient in tests and
2127
+ * scripts; to record an application, give each run its own recorder, as examples/recording-example.js does.
1569
2128
  *
1570
2129
  * @param {(input: any) => Effect} flowFn - Builds the Effect tree from its input
1571
2130
  * @param {any} initialInput - The value the flow is called with; stored so a replay can rebuild it
@@ -1574,12 +2133,12 @@ const recorder = (options = {}) => {
1574
2133
  * @returns {Promise<{ result: SuccessState | FailureState, trace: TraceLog }>}
1575
2134
  */
1576
2135
  const recordEffect = async (flowFn, initialInput, options = {}) => {
1577
- rejectUnknownOptions(options, 'recordEffect', ['context', 'version', 'redact', 'maxEntries', 'stack']);
1578
- const { context = {}, version, ...recorderOptions } = options;
2136
+ checkOptions(options, 'recordEffect', { context: null, version: null, ...recorderOptionRules });
2137
+ checkFlowFn(flowFn, 'recordEffect');
2138
+ const { context = {}, version, ...recorderOptions } = options ?? {};
1579
2139
  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 });
2140
+ // Before the run, so a Command that writes to the input or the context cannot rewrite what the trace received.
2141
+ const head = rec.toTrace({ initialInput, flowName: context?.flowName, context, version });
1583
2142
  // Merged over the global wiring, so recording inside an instrumented application keeps its spans.
1584
2143
  const result = await runEffect(flowFn(initialInput), context, { onStep: rec.onStep });
1585
2144
  const { dropped, trace } = rec.toTrace();
@@ -1587,8 +2146,7 @@ const recordEffect = async (flowFn, initialInput, options = {}) => {
1587
2146
  };
1588
2147
 
1589
2148
  /**
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.
2149
+ * Whether a recorded entry says its step threw: `threw`, or an `error` key, as older and hand-written traces say it.
1592
2150
  * @param {TraceEntry} entry
1593
2151
  * @returns {boolean}
1594
2152
  */
@@ -1608,9 +2166,82 @@ const hasPath = (step) => typeof step.path === 'string';
1608
2166
  */
1609
2167
  const isInsideParallel = (path) => typeof path === 'string' && /p\d+\//.test(path);
1610
2168
 
2169
+ /** @typedef {'Command' | 'Retry' | 'Parallel'} NodeKind */
2170
+
1611
2171
  /**
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.
2172
+ * The positions a path passes through, each with the kind of node there. `0p1/2r0/1` passes a Parallel at `0` and a
2173
+ * 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
2174
+ * hand-built trace is never judged by its shape.
2175
+ * @param {string} path
2176
+ * @returns {[string, NodeKind][]}
2177
+ */
2178
+ const nodesAlong = (path) => {
2179
+ /** @type {[string, NodeKind][]} */
2180
+ const nodes = [];
2181
+ let prefix = '';
2182
+ for (const segment of path.split('/')) {
2183
+ const match = /^(\d+)(?:(p)\d*|(r)(?:\d+|f))?$/.exec(segment);
2184
+ if (!match) return [];
2185
+ const [, step, parallel, retry] = match;
2186
+ nodes.push([prefix + step, parallel ? 'Parallel' : retry ? 'Retry' : 'Command']);
2187
+ prefix += `${segment}/`;
2188
+ }
2189
+ return nodes;
2190
+ };
2191
+
2192
+ /** What `replayEffect` takes in place of a Resolver, as its messages name it. */
2193
+ const replayEffectTakes = "a Resolver, a trace from recordEffect or a recorder's toTrace, or its array of entries";
2194
+
2195
+ /**
2196
+ * Refuses a trace a replay cannot read: one with no `trace` array, or an entry that is not an object with a string
2197
+ * `command`, as every entry a recorder writes is. Returns the entries.
2198
+ * @param {any} traceLog
2199
+ * @param {string} source - The function given it, as the message names it
2200
+ * @param {string} takes - What that function takes, as the message names it
2201
+ * @param {boolean} takesEntries - Whether a bare array of entries will do, as it does for `replayEffect`
2202
+ * @returns {TraceEntry[]}
2203
+ */
2204
+ const checkTrace = (traceLog, source, takes, takesEntries) => {
2205
+ const bareEntries = Array.isArray(traceLog);
2206
+ const entries = bareEntries
2207
+ ? takesEntries
2208
+ ? traceLog
2209
+ : undefined
2210
+ : isObject(traceLog)
2211
+ ? traceLog.trace
2212
+ : undefined;
2213
+ if (!Array.isArray(entries)) {
2214
+ const got = bareEntries
2215
+ ? 'an array of entries, which holds no initialInput to rebuild the flow from; pass the whole trace, or ' +
2216
+ 'replay the entries with replayEffect'
2217
+ : isObject(traceLog)
2218
+ ? 'an object with no `trace` array'
2219
+ : describeArgument(traceLog);
2220
+ throw replayError(`${source} expects ${takes}, got ${got}.`);
2221
+ }
2222
+ const bad = entries.findIndex((entry) => !isObject(entry) || typeof entry.command !== 'string');
2223
+ if (bad >= 0) {
2224
+ const what = isObject(entries[bad]) ? 'has no string command' : `is ${describeArgument(entries[bad])}`;
2225
+ throw replayError(`${source}'s trace entry ${bad} ${what}; an entry is an object with a string command.`);
2226
+ }
2227
+ return entries;
2228
+ };
2229
+
2230
+ /**
2231
+ * Refuses a flow that is not a function, for the functions that build one from a recorded input.
2232
+ * @param {any} flowFn
2233
+ * @param {string} source - The function given it, as the message names it
2234
+ */
2235
+ const checkFlowFn = (flowFn, source) => {
2236
+ if (typeof flowFn !== 'function') {
2237
+ const got = describeArgument(flowFn);
2238
+ throw new TypeError(`${source} expects the function that builds the flow from its input, got ${got}.`);
2239
+ }
2240
+ };
2241
+
2242
+ /**
2243
+ * Turns a recorded entry into the outcome a Resolver returns, copied so a replayed step that mutates its result
2244
+ * cannot rewrite the trace.
1614
2245
  *
1615
2246
  * @param {TraceEntry} entry
1616
2247
  * @returns {ReplayOutcome}
@@ -1619,9 +2250,8 @@ const entryToOutcome = (entry) =>
1619
2250
  entryThrew(entry) ? { error: reviveError(snapshot(entry.error)) } : { result: snapshot(entry.result) };
1620
2251
 
1621
2252
  /**
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.
2253
+ * Whether an entry is a Parallel's recorded decision. A decision's path ends in `p`, so a Command named 'Parallel'
2254
+ * is never mistaken for one.
1625
2255
  * @param {TraceEntry} entry
1626
2256
  * @returns {boolean}
1627
2257
  */
@@ -1629,81 +2259,190 @@ const isDecisionEntry = (entry) =>
1629
2259
  entry.command === 'Parallel' && typeof entry.path === 'string' && entry.path.endsWith('p');
1630
2260
 
1631
2261
  /**
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.
2262
+ * Builds a Resolver for the reference trace format; `replayEffect` is its only caller. A trace whose entries all
2263
+ * carry a `path` is matched by path, and one without in order. A step the trace lacks resolves to `undefined`, as
2264
+ * from a Resolver, and `missing` describes it for the error.
1642
2265
  *
1643
2266
  * @param {TraceLog | TraceEntry[]} traceLog - A reference-format trace, or a bare array of entries
1644
2267
  * @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 }}
2268
+ * @param {(entry: TraceEntry) => void} [options.onEntry] - Observes each entry as it is handed to a step, which is
2269
+ * how `replayEffect` learns which entries the flow never asked for.
2270
+ * @returns {TraceResolver}
1648
2271
  */
1649
2272
  const fromTrace = (traceLog, options = {}) => {
1650
2273
  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) => {
2274
+ const entries = checkTrace(traceLog, 'replayEffect', replayEffectTakes, true);
2275
+ /** @type {AnswerFrom} */
2276
+ const answerFrom = (entry, step) => {
1654
2277
  if (onEntry) onEntry(entry);
2278
+ return recordedOutcome(entry, step);
2279
+ };
2280
+ return entries.length > 0 && entries.every(hasPath)
2281
+ ? resolveByPath(indexByPath(entries), answerFrom)
2282
+ : resolveInOrder(entries, answerFrom);
2283
+ };
2284
+
2285
+ /**
2286
+ * A Resolver for a reference trace, with the words for a step it holds no entry for.
2287
+ * @typedef {{ resolve: Resolver, missing: (step: ReplayStep) => string }} TraceResolver
2288
+ */
2289
+
2290
+ /**
2291
+ * Hands a step the outcome of the entry a resolver matched it to.
2292
+ * @typedef {(entry: TraceEntry, step: ReplayStep) => ReplayOutcome} AnswerFrom
2293
+ */
2294
+
2295
+ /**
2296
+ * The outcome a recorded entry hands its step. An entry the recorder could not record stops the replay rather than
2297
+ * run live under `onMissing`, since production ran the step.
2298
+ * @param {TraceEntry} entry
2299
+ * @param {ReplayStep} step
2300
+ * @returns {ReplayOutcome}
2301
+ */
2302
+ const recordedOutcome = (entry, step) => {
2303
+ if (entry.unrecorded !== undefined) {
2304
+ throw unrecordedError(
2305
+ `outcome for '${step.name}' at path '${step.path}'`,
2306
+ entry.unrecorded,
2307
+ "Production ran the step, so onMissing: 'execute' does not run it either. To replay past it,",
2308
+ { command: step.name, index: step.index, path: step.path }
2309
+ );
2310
+ }
2311
+ // Only a hand-built trace can hold a value the copy cannot encode.
2312
+ try {
1655
2313
  return entryToOutcome(entry);
2314
+ } catch {
2315
+ throw replayError(
2316
+ `Trace entry for '${step.name}' at path '${step.path}' holds a value JSON cannot encode, such as a ` +
2317
+ 'BigInt. A recorded trace never does, so write the entry as JSON would store it.',
2318
+ { command: step.name, index: step.index, path: step.path }
2319
+ );
2320
+ }
2321
+ };
2322
+
2323
+ /**
2324
+ * A trace's entries indexed for matching by path: each entry by its path, the kind of node the recorded run had at
2325
+ * each position its steps passed through, and the recorded decisions that cancelled branches.
2326
+ * @typedef {{
2327
+ * entries: TraceEntry[],
2328
+ * byPath: Map<string | undefined, TraceEntry>,
2329
+ * kinds: Map<string, NodeKind>,
2330
+ * cancellations: TraceEntry[]
2331
+ * }} PathIndex
2332
+ */
2333
+
2334
+ /**
2335
+ * @param {TraceEntry[]} entries - Every one with a path
2336
+ * @returns {PathIndex}
2337
+ */
2338
+ const indexByPath = (entries) => {
2339
+ const byPath = new Map(entries.map((e) => [e.path, e]));
2340
+ // A recorder never writes a duplicate path, and keeping either entry would hand a branch the wrong result.
2341
+ if (byPath.size !== entries.length) {
2342
+ throw replayError('Trace has duplicate step paths.');
2343
+ }
2344
+ return {
2345
+ entries,
2346
+ byPath,
2347
+ kinds: new Map(entries.flatMap((e) => nodesAlong(/** @type {string} */ (e.path)))),
2348
+ cancellations: entries.filter(isDecisionEntry).filter((e) => e.result?.cancelled === true)
1656
2349
  };
2350
+ };
1657
2351
 
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
- }
2352
+ /**
2353
+ * Matches each step to the entry at its path, which tells two `Parallel` branches calling the same Command apart
2354
+ * whatever order they finish in. A Parallel's step is named 'Parallel', as its decision's entry is, so one comparison
2355
+ * covers a Command and a decision; `replayEffect` hands a decision to the Parallel's `op`, and with none recorded the
2356
+ * Parallel replays by timing.
2357
+ * @param {PathIndex} recorded
2358
+ * @param {AnswerFrom} answerFrom
2359
+ * @returns {TraceResolver}
2360
+ */
2361
+ const resolveByPath = (recorded, answerFrom) => {
2362
+ /** @type {Resolver} */
2363
+ const resolve = (step) => {
2364
+ const entry = recorded.byPath.get(step.path);
2365
+ if (entry) {
1693
2366
  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
- }
2367
+ return answerFrom(entry, step);
2368
+ }
2369
+ throwIfReshaped(recorded, step);
2370
+ if (stoppedInProduction(recorded, step.path)) throw replayCutError(/** @type {string} */ (step.path));
2371
+ return undefined;
2372
+ };
2373
+ const missing = (/** @type {ReplayStep} */ step) => `Trace has no step at path '${step.path}' for '${step.name}'`;
2374
+ return { resolve, missing };
2375
+ };
1700
2376
 
2377
+ /**
2378
+ * Refuses a step the trace lacks when the trace recorded another kind of node along its path, as when a Command was
2379
+ * added where a Retry was. That is a change of shape, which reported as missing would run live under 'execute'.
2380
+ * @param {PathIndex} recorded
2381
+ * @param {ReplayStep} step
2382
+ */
2383
+ const throwIfReshaped = (recorded, step) => {
2384
+ const nodes = nodesAlong(/** @type {string} */ (step.path));
2385
+ const diverged = nodes.find(([at, kind]) => (recorded.kinds.get(at) ?? kind) !== kind);
2386
+ if (!diverged) return;
2387
+ const [at, kind] = diverged;
2388
+ const recordedKind = /** @type {NodeKind} */ (recorded.kinds.get(at));
2389
+ const actual = kind === 'Command' ? step.name : kind;
2390
+ const expected =
2391
+ recordedKind === 'Command' ? /** @type {TraceEntry} */ (recorded.byPath.get(at)).command : recordedKind;
2392
+ const flowHad = kind === 'Command' ? `flow asked for '${actual}'` : `flow has a ${kind} there`;
2393
+ const traceHad =
2394
+ recordedKind === 'Command'
2395
+ ? `trace recorded '${expected}'`
2396
+ : `trace recorded a ${recordedKind} there${firstStepIn(recorded.entries, at, recordedKind)}`;
2397
+ throw timeParadoxAt(`path '${at}'`, `${flowHad}, ${traceHad}`, {
2398
+ index: step.index,
2399
+ path: at,
2400
+ expected,
2401
+ actual
2402
+ });
2403
+ };
2404
+
2405
+ /**
2406
+ * Names the first Command the trace recorded inside the Retry or Parallel at `at`, for a message.
2407
+ * @param {TraceEntry[]} entries
2408
+ * @param {string} at
2409
+ * @param {NodeKind} kind
2410
+ * @returns {string}
2411
+ */
2412
+ const firstStepIn = (entries, at, kind) => {
2413
+ const opens = `${at}${kind === 'Retry' ? 'r' : 'p'}`;
2414
+ const inside = entries.find((e) => e.path?.startsWith(opens) && !isDecisionEntry(e));
2415
+ return inside ? `, with '${inside.command}' at path '${inside.path}'` : '';
2416
+ };
2417
+
2418
+ /**
2419
+ * Whether a step lies in a branch a recorded decision cancelled: any branch but the trigger, or every branch of a
2420
+ * Parallel cancelled from outside. Production stopped there, so the step throws a cut, which `runCommand` or
2421
+ * `runParallel` catches to stop the branch.
2422
+ * @param {PathIndex} recorded
2423
+ * @param {string | undefined} stepPath
2424
+ * @returns {boolean}
2425
+ */
2426
+ const stoppedInProduction = (recorded, stepPath) =>
2427
+ typeof stepPath === 'string' &&
2428
+ recorded.cancellations.some((e) => {
2429
+ const at = /** @type {string} */ (e.path);
2430
+ const branch = stepPath.startsWith(at) ? /^(\d+)\//.exec(stepPath.slice(at.length)) : null;
2431
+ return branch !== null && (e.result.branch === null || Number(branch[1]) !== e.result.branch);
2432
+ });
2433
+
2434
+ /**
2435
+ * Matches steps to entries in order, for a trace without paths. That is exact for a sequential flow, but completion
2436
+ * order cannot tell `Parallel` branches apart, so a step inside one is refused rather than guessed.
2437
+ * @param {TraceEntry[]} entries
2438
+ * @param {AnswerFrom} answerFrom
2439
+ * @returns {TraceResolver}
2440
+ */
2441
+ const resolveInOrder = (entries, answerFrom) => {
1701
2442
  /** @type {Resolver} */
1702
2443
  const resolve = (step) => {
1703
- // A trace with no paths predates recorded decisions, so a Parallel replays under timing.
2444
+ // A trace with no paths predates recorded decisions, so a Parallel replays by timing.
1704
2445
  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
2446
  if (isInsideParallel(step.path)) {
1708
2447
  throw replayError(
1709
2448
  `Trace carries no paths, so '${step.name}' inside a Parallel cannot be matched positionally.`,
@@ -1713,14 +2452,15 @@ const fromTrace = (traceLog, options = {}) => {
1713
2452
  const entry = entries[step.index];
1714
2453
  if (!entry) return undefined;
1715
2454
  if (entry.command !== step.name) throw timeParadox(step, entry.command);
1716
- return resolveEntry(entry);
2455
+ return answerFrom(entry, step);
1717
2456
  };
1718
2457
  const missing = (/** @type {ReplayStep} */ step) => `Trace exhausted: no entry #${step.index} for '${step.name}'`;
1719
2458
  return { resolve, missing };
1720
2459
  };
1721
2460
 
1722
2461
  /**
1723
- * The error for a step a replay has no recorded outcome for, which it refuses to run live.
2462
+ * The error for a step a replay has no recorded outcome for, which it refuses to run live. It says whether a cap
2463
+ * may have dropped the step or production may never have run it, since the fix differs.
1724
2464
  * @param {ReplayStep} step
1725
2465
  * @param {((step: ReplayStep) => string) | undefined} describe - `fromTrace`'s `missing`; a Resolver has none
1726
2466
  * @param {number} droppedEntries - How many entries the trace dropped under `maxEntries`
@@ -1728,19 +2468,16 @@ const fromTrace = (traceLog, options = {}) => {
1728
2468
  */
1729
2469
  const missingStepError = (step, describe, droppedEntries) => {
1730
2470
  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
2471
  const why =
1735
2472
  droppedEntries > 0
1736
2473
  ? `The trace dropped ${droppedEntries} entries under maxEntries, so production may have run ` +
1737
2474
  'this step and the recorder not kept it; record the flow with a higher maxEntries to ' +
1738
2475
  'replay past it.'
1739
2476
  : '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.';
2477
+ 'entry, and neither does a step the flow reaches now that it did not then, such as one added ' +
2478
+ "past the end of the recording. onMissing: 'execute' runs such a step for real, and every step " +
2479
+ 'after it that the trace also lacks, so pass it only where every Command the flow can still ' +
2480
+ 'reach goes to a test double or only reads.';
1744
2481
  return replayError(`${what}; refusing to run the real Command. ${why}`, {
1745
2482
  command: step.name,
1746
2483
  index: step.index,
@@ -1748,60 +2485,81 @@ const missingStepError = (step, describe, droppedEntries) => {
1748
2485
  });
1749
2486
  };
1750
2487
 
2488
+ /** What each cause the recorder writes means, and what fixes it, for a replay error. */
2489
+ const unrecordedReasons = new Map([
2490
+ ['redact', ['redact threw on it', 'make redact handle every value it is given, null included']],
2491
+ ['copy', ['it could not be copied, as when it holds a BigInt or a getter that throws', 'keep it to plain data']]
2492
+ ]);
2493
+
2494
+ /**
2495
+ * The error for a replay that needs a value the recorder could not record.
2496
+ * @param {string} what - What the trace lacks, as the message names it
2497
+ * @param {unknown} cause - Why, as the trace records it
2498
+ * @param {string} lead - What that means for this replay, leading into the fix
2499
+ * @param {Object} props - The fields that locate it, such as `path`
2500
+ * @returns {Error}
2501
+ */
2502
+ const unrecordedError = (what, cause, lead, props) => {
2503
+ // A cause the recorder does not write, as a hand-built trace may hold, is not guessed at.
2504
+ const [reason, fix] = unrecordedReasons.get(/** @type {string} */ (cause)) ?? [
2505
+ 'it could not be recorded',
2506
+ 'find out what kept it out'
2507
+ ];
2508
+ return replayError(
2509
+ `The trace holds no ${what}: ${reason}, so the recorder left it out. ${lead} ${fix}, and record the flow again.`,
2510
+ props
2511
+ );
2512
+ };
2513
+
2514
+ /**
2515
+ * Why the recorder left one of a trace's own fields out, or `undefined` when it did not.
2516
+ * @param {TraceLog | undefined} traceLog
2517
+ * @param {'initialInput' | 'context'} field
2518
+ * @returns {unknown}
2519
+ */
2520
+ const unrecordedCause = (traceLog, field) => {
2521
+ const fields = /** @type {any} */ (traceLog?.unrecorded);
2522
+ return isObject(fields) ? fields[field] : undefined;
2523
+ };
2524
+
1751
2525
  /**
1752
2526
  * @typedef {Object} ReplayOptions
1753
2527
  * @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.
2528
+ * @property {boolean} [fastRetry] - Waits no time between Retry attempts. On by default.
2529
+ * @property {boolean} [hooks] - Runs the replay inside the hooks `configureEffect` installed, with the resolver
2530
+ * innermost. Off by default, so a replay cannot reach a telemetry backend or a guardrail that does I/O.
2531
+ * @property {'throw' | 'execute'} [onMissing] - What to do with a step the resolver has no recording for. `'throw'`
2532
+ * (default) fails the replay, so no side effect can occur. `'execute'` runs the real Command, so pass it
2533
+ * only where the Commands reach test doubles or only read. A trace that dropped entries refuses it.
1765
2534
  * @property {(step: ReplayStep, outcome: ReplayOutcome | undefined) => void} [onResolved] - Observes each step.
1766
2535
  */
1767
2536
 
1768
2537
  /**
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.
2538
+ * What a replay returns: the flow's own outcome and, for a trace, the recorded entries the flow never asked for. A
2539
+ * flow that stops early mismatches nothing, so `unreached` is the only sign that a step went away.
1777
2540
  * @typedef {{ result: SuccessState | FailureState, unreached?: TraceEntry[] }} Replay
1778
2541
  */
1779
2542
 
1780
2543
  /**
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.
2544
+ * Replays a flow, feeding recorded outcomes to its Commands instead of running them. No side effect can occur by
2545
+ * default: the interpreter executes a Command only as the `op` it hands `onStep`, and the `onStep` here calls `op`
2546
+ * only under `onMissing: 'execute'`. Async, so a malformed trace rejects rather than throwing.
1791
2547
  *
1792
2548
  * @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`.
2549
+ * @param {Resolver | TraceLog | TraceEntry[]} traceOrResolver - A reference-format trace, or a Resolver supplying
2550
+ * each Command's recorded outcome (`undefined` if it has none) for traces stored in another shape.
1796
2551
  * @param {ReplayOptions} [options]
1797
2552
  * @returns {Promise<Replay>} `{ result, unreached }` for a trace, `{ result }` for a Resolver
1798
2553
  */
1799
- // `async` so a malformed trace arrives as a rejection rather than a synchronous throw.
1800
2554
  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.
2555
+ checkOptions(options, 'replayEffect', {
2556
+ context: null,
2557
+ fastRetry: [isBoolean, 'true or false'],
2558
+ hooks: [isBoolean, 'true or false'],
2559
+ onMissing: [(value) => value === 'throw' || value === 'execute', "'throw' or 'execute'"],
2560
+ onResolved: [isFunction, 'a function']
2561
+ });
2562
+ const { context: givenContext, fastRetry = true, hooks = false, onMissing = 'throw', onResolved } = options ?? {};
1805
2563
  const fromResolver = typeof traceOrResolver === 'function';
1806
2564
  /** @type {Set<TraceEntry>} */
1807
2565
  const reached = new Set();
@@ -1810,42 +2568,132 @@ const replayEffect = async (effect, traceOrResolver, options = {}) => {
1810
2568
  : fromTrace(traceOrResolver, { onEntry: (entry) => void reached.add(entry) });
1811
2569
  // Only a trace log carries metadata; a bare entries array and a Resolver carry none.
1812
2570
  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;
2571
+ const context = replayContext(traceLog, givenContext);
2572
+ const droppedEntries = droppedEntriesOf(traceLog, onMissing);
2573
+ const hook = replayHook({ resolve, missing, onResolved, onMissing, droppedEntries });
2574
+
2575
+ // A replay fault becomes a Failure here, outside the flow, where nothing can absorb it.
2576
+ let result;
2577
+ try {
2578
+ result = await interpret(effect, context, { onStep: hook.onStep, inherit: hooks }, fastRetry);
2579
+ } catch (e) {
2580
+ if (hook.failure) throw hook.failure.error;
2581
+ if (!hasMark(e, replayFault)) throw e;
2582
+ result = Failure(e);
2583
+ }
2584
+ if (fromResolver) return { result };
2585
+ // `fromTrace` has already checked the shape.
2586
+ const entries = Array.isArray(traceOrResolver) ? traceOrResolver : traceOrResolver.trace;
2587
+ return { result, unreached: entries.filter((entry) => !reached.has(entry)) };
2588
+ };
2589
+
2590
+ /**
2591
+ * The context a replay runs with: the one passed in, else the one the trace recorded, else `{}`. Another context can
2592
+ * take another branch at an `Ask` with nothing to flag it, so a trace whose context was not recorded needs one passed.
2593
+ * @param {TraceLog | undefined} traceLog
2594
+ * @param {any} given - `options.context`
2595
+ * @returns {any}
2596
+ */
2597
+ const replayContext = (traceLog, given) => {
2598
+ const cause = unrecordedCause(traceLog, 'context');
2599
+ if (given == null && cause !== undefined) {
2600
+ throw unrecordedError(
2601
+ 'context',
2602
+ cause,
2603
+ 'A replay with an empty one could take another branch at an Ask: pass options.context, or',
2604
+ { field: 'context' }
2605
+ );
2606
+ }
2607
+ // A recorded `null` is what production ran with, so only a trace that holds no context gets `{}`.
2608
+ const recorded = traceLog?.context === undefined ? {} : traceLog.context;
2609
+ return given ?? recorded;
2610
+ };
2611
+
2612
+ /**
2613
+ * How many entries a trace dropped under `maxEntries`. A capped trace lacks steps production ran, so it refuses
2614
+ * `onMissing: 'execute'`, which could run one of them again.
2615
+ * @param {TraceLog | undefined} traceLog
2616
+ * @param {'throw' | 'execute'} onMissing
2617
+ * @returns {number}
2618
+ */
2619
+ const droppedEntriesOf = (traceLog, onMissing) => {
2620
+ const dropped = Number(traceLog?.dropped) || 0;
2621
+ const capped = dropped > 0;
1820
2622
  if (capped && onMissing === 'execute') {
1821
2623
  throw replayError(
1822
- `The trace dropped ${droppedEntries} entries under maxEntries, so a step it lacks may be one ` +
2624
+ `The trace dropped ${dropped} entries under maxEntries, so a step it lacks may be one ` +
1823
2625
  "production ran, and onMissing: 'execute' would run it again. Replay without it to stop at the " +
1824
2626
  'first missing step, or record the flow with a higher maxEntries.'
1825
2627
  );
1826
2628
  }
2629
+ return dropped;
2630
+ };
2631
+
2632
+ /**
2633
+ * The `onStep` a replay runs the flow under, and the first error a Resolver or `onResolved` raised, or a Resolver's
2634
+ * answer that is not an outcome. The replay rejects with that error rather than letting it count as the Command
2635
+ * failing.
2636
+ * @typedef {{ onStep: StepRunner, failure: { error: unknown } | undefined }} ReplayHook
2637
+ */
2638
+
2639
+ /**
2640
+ * Builds the `onStep` that answers each step of a replay from `resolve`: the recorded outcome in place of a Command,
2641
+ * the recorded decision for a Parallel, and for a step with no record, a refusal or, under `onMissing: 'execute'`, the
2642
+ * real Command.
2643
+ * @param {{
2644
+ * resolve: Resolver,
2645
+ * missing: ((step: ReplayStep) => string) | undefined,
2646
+ * onResolved: ((step: ReplayStep, outcome: ReplayOutcome | undefined) => void) | undefined,
2647
+ * onMissing: 'throw' | 'execute',
2648
+ * droppedEntries: number
2649
+ * }} replay
2650
+ * @returns {ReplayHook}
2651
+ */
2652
+ const replayHook = ({ resolve, missing, onResolved, onMissing, droppedEntries }) => {
1827
2653
  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);
2654
+ /** Keeps the first callback failure for the replay to reject with, and returns a stand-in nothing in the flow absorbs. */
2655
+ const stopWith = (/** @type {unknown} */ error) => {
2656
+ hook.failure ??= { error };
2657
+ return asHarnessError(new Error('A replay callback failed.'));
2658
+ };
2659
+
2660
+ /**
2661
+ * Asks for a step's recorded outcome. A replay fault from `fromTrace` passes as it is.
2662
+ * @param {ReplayStep} step
2663
+ * @returns {ReplayOutcome | undefined}
2664
+ */
2665
+ const answer = (step) => {
2666
+ let outcome;
2667
+ try {
2668
+ outcome = resolve(step);
2669
+ } catch (error) {
2670
+ if (hasMark(error, harnessError)) throw error;
2671
+ throw stopWith(error);
2672
+ }
2673
+ if (outcome === undefined || isReplayOutcome(outcome)) return outcome;
2674
+ throw stopWith(
2675
+ new TypeError(
2676
+ 'A Resolver answers with { result }, { error }, or undefined for a step it has no record of, and it ' +
2677
+ `answered ${describeValue(outcome)} for '${step.name}' at path '${step.path}'.`
2678
+ )
2679
+ );
2680
+ };
1832
2681
 
1833
2682
  /** @type {StepRunner} */
1834
2683
  const onStep = async (name, type, op, path) => {
1835
2684
  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);
2685
+ // A Parallel's step carries its recorded decision into `op`, where `runBranches` reproduces it. It is not
2686
+ // a Command: `index` still counts Commands, and `onResolved` still sees only them.
2687
+ const outcome = answer({ index, name, type, path });
2688
+ return await op(outcome !== undefined && 'result' in outcome ? outcome.result : undefined);
1840
2689
  }
1841
2690
  const step = { index: index++, name, type, path };
1842
- const outcome = resolve(step);
2691
+ const outcome = answer(step);
1843
2692
  if (onResolved) {
1844
2693
  try {
1845
2694
  onResolved(step, outcome);
1846
2695
  } catch (error) {
1847
- observerFailure ??= { error };
1848
- throw asHarnessError(new Error('onResolved threw.'));
2696
+ throw stopWith(error);
1849
2697
  }
1850
2698
  }
1851
2699
  if (outcome === undefined) {
@@ -1856,31 +2704,14 @@ const replayEffect = async (effect, traceOrResolver, options = {}) => {
1856
2704
  return outcome.result;
1857
2705
  };
1858
2706
 
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)) };
2707
+ /** @type {ReplayHook} */
2708
+ const hook = { onStep, failure: undefined };
2709
+ return hook;
1878
2710
  };
1879
2711
 
1880
2712
  /**
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.
2713
+ * Replays a reference-format trace and narrates each step. Rebuilds the flow from the recorded input, reports the
2714
+ * outcome, and names any recorded steps that were never reached.
1884
2715
  *
1885
2716
  * @param {(input: any) => Effect} flowFn - The same flow function that produced the trace
1886
2717
  * @param {TraceLog} traceLog - A trace from `recordEffect` or a `recorder`
@@ -1888,83 +2719,117 @@ const replayEffect = async (effect, traceOrResolver, options = {}) => {
1888
2719
  * @param {(...args: any[]) => void} [options.log] - Defaults to `console.log`
1889
2720
  * @param {any} [options.context] - Overrides the context stored on the trace
1890
2721
  * @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`.
2722
+ * @returns {Promise<SuccessState | FailureState>} The flow's outcome. For the unreached entries as data, use
2723
+ * `replayEffect`.
1893
2724
  */
1894
2725
  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);
2726
+ checkOptions(options, 'timeTravel', { log: [isFunction, 'a function'], context: null, version: null });
2727
+ const { log = console.log, context, version } = options ?? {};
2728
+ checkFlowFn(flowFn, 'timeTravel');
2729
+ // Checked here, since timeTravel reads the trace's own fields before `replayEffect` checks its shape.
2730
+ const trace = checkTrace(traceLog, 'timeTravel', "a trace from recordEffect or a recorder's toTrace", false);
2731
+ const { initialInput, flowName } = traceLog;
2732
+ const inputCause = unrecordedCause(traceLog, 'initialInput');
2733
+ if (inputCause !== undefined) {
2734
+ throw unrecordedError('initialInput', inputCause, 'The flow is rebuilt from it, so', { field: 'initialInput' });
2735
+ }
2736
+
2737
+ for (const warning of traceWarnings(traceLog, trace, version)) log(warning);
2738
+ // Decisions are not narrated as steps, so the header counts only Commands.
2739
+ const commandCount = trace.filter((e) => !isDecisionEntry(e)).length;
2740
+ log(`Replaying '${flowName || 'flow'}' (${commandCount} recorded ${commandCount === 1 ? 'step' : 'steps'})`);
2741
+ log(`Initial input: ${formatForLog(initialInput)}`);
2742
+ const timing = stepTimings(trace);
2743
+ // `replayEffect` defaults the context to the trace's.
2744
+ const { result, unreached = [] } = await replayEffect(flowFn(initialInput), traceLog, {
2745
+ context,
2746
+ onResolved: (step, outcome) => {
2747
+ // A step the trace does not hold is refused or run live by `replayEffect`, not narrated here.
2748
+ if (outcome === undefined) return;
2749
+ log(
2750
+ 'error' in outcome
2751
+ ? `Step ${step.index + 1}: ${step.name} threw${timing(step)} ${formatForLog(outcome.error)}`
2752
+ : `Step ${step.index + 1}: ${step.name} returned${timing(step)} ${formatForLog(outcome.result)}`
2753
+ );
1905
2754
  }
1906
- };
2755
+ });
2756
+ log(`Replay finished with state: ${result.type}`);
2757
+ log(result.type === 'Failure' ? `Error: ${formatForLog(result.error)}` : `Result: ${formatForLog(result.value)}`);
2758
+ const warning = unreachedWarning(result, unreached);
2759
+ if (warning !== undefined) log(warning);
2760
+ return result;
2761
+ };
1907
2762
 
1908
- if (version && traceVersion && version !== traceVersion) {
1909
- log(`Warning: trace was recorded at ${traceVersion}, replaying against ${version}.`);
2763
+ /**
2764
+ * The warnings `timeTravel` gives before it replays: a trace recorded at another build, one that holds no initial
2765
+ * input, and steps a replay cannot tell apart.
2766
+ * @param {TraceLog} traceLog
2767
+ * @param {TraceEntry[]} trace - Its entries, checked
2768
+ * @param {string | undefined} version - The build replaying it
2769
+ * @returns {string[]}
2770
+ */
2771
+ const traceWarnings = (traceLog, trace, version) => {
2772
+ const warnings = [];
2773
+ if (version && traceLog.version && version !== traceLog.version) {
2774
+ warnings.push(`Warning: trace was recorded at ${traceLog.version}, replaying against ${version}.`);
1910
2775
  }
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(
2776
+ if (traceLog.initialInput === undefined) {
2777
+ warnings.push(
1915
2778
  'Warning: the trace holds no initial input, so the flow is rebuilt from undefined. A recorder ' +
1916
2779
  'installed as a hook finds the input only on a flow built with effectPipe.'
1917
2780
  );
1918
2781
  }
1919
- // A replay tells steps apart by name, so two anonymous Commands that swapped places replay without complaint.
1920
2782
  const anonymous = trace.filter((e) => e.command === 'anonymous').length;
1921
2783
  if (anonymous > 0) {
1922
- log(
2784
+ warnings.push(
1923
2785
  `Warning: ${anonymous} of the recorded steps are named 'anonymous', usually inline arrow Commands, so ` +
1924
2786
  'this replay cannot tell them apart and would not notice two of them trading places. Name them with ' +
1925
2787
  'a const or meta.name.'
1926
2788
  );
1927
2789
  }
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)}`);
2790
+ return warnings;
2791
+ };
1933
2792
 
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.
2793
+ /**
2794
+ * Looks up how long each recorded step took, for narration: by path, or by position for a trace without paths.
2795
+ * @param {TraceEntry[]} trace
2796
+ * @returns {(step: ReplayStep) => string}
2797
+ */
2798
+ const stepTimings = (trace) => {
1936
2799
  const byPath = new Map(trace.filter(hasPath).map((e) => [e.path, e]));
1937
- const timing = (/** @type {ReplayStep} */ step) => {
2800
+ return (step) => {
1938
2801
  const recorded = byPath.get(step.path) ?? trace[step.index];
1939
2802
  return typeof recorded?.durationMs === 'number' ? ` in ${recorded.durationMs}ms` : '';
1940
2803
  };
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;
2804
+ };
1954
2805
 
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.
2806
+ /**
2807
+ * The warning for recorded steps a replay never reached, or `undefined`. A replay fault already names where the flow
2808
+ * diverged, and is told by its mark, since a flow's own error can share its name.
2809
+ * @param {SuccessState | FailureState} result
2810
+ * @param {TraceEntry[]} unreached
2811
+ * @returns {string | undefined}
2812
+ */
2813
+ const unreachedWarning = (result, unreached) => {
1960
2814
  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.`);
2815
+ if (unreached.length === 0 || haltedByReplay) return undefined;
2816
+ const names = unreached.map((e) => (hasPath(e) ? `${e.command} (path '${e.path}')` : e.command));
2817
+ const count = unreached.length === 1 ? '1 recorded step was' : `${unreached.length} recorded steps were`;
2818
+ return `Warning: ${count} never reached: ${names.join(', ')}. The flow diverged.`;
2819
+ };
2820
+
2821
+ /**
2822
+ * Prints a value for narration. An Error's hidden `message` is spread in, and a value JSON cannot print falls back to
2823
+ * `String`, since narration must not be what fails a replay.
2824
+ * @param {any} v
2825
+ * @returns {string | undefined}
2826
+ */
2827
+ const formatForLog = (v) => {
2828
+ try {
2829
+ return JSON.stringify(v instanceof Error ? { ...v, name: v.name, message: v.message } : v, null, 2);
2830
+ } catch {
2831
+ return String(v);
1966
2832
  }
1967
- return result;
1968
2833
  };
1969
2834
 
1970
2835
  // #endregion
@@ -1983,5 +2848,7 @@ export {
1983
2848
  recordEffect,
1984
2849
  replayEffect,
1985
2850
  timeTravel,
1986
- commandName
2851
+ commandName,
2852
+ effectGen,
2853
+ step
1987
2854
  };