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