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