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