pure-effect 0.13.0 → 0.15.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 +64 -0
- package/README.md +296 -124
- package/index.d.ts +1118 -134
- package/index.js +1029 -462
- package/package.json +3 -1
package/index.js
CHANGED
|
@@ -34,11 +34,18 @@
|
|
|
34
34
|
* }} RetryState
|
|
35
35
|
*/
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* `settled` hands branch outcomes to `next` instead of failing on the first one; `limit` caps how many
|
|
39
|
+
* branches run at once.
|
|
40
|
+
* @typedef {{ limit?: number, settled?: boolean }} ParallelOptions
|
|
41
|
+
*/
|
|
42
|
+
|
|
37
43
|
/**
|
|
38
44
|
* @typedef {{
|
|
39
45
|
* type: 'Parallel',
|
|
40
46
|
* effects: Effect[],
|
|
41
47
|
* next: (values: any[]) => Effect,
|
|
48
|
+
* options?: ParallelOptions,
|
|
42
49
|
* initialInput?: any
|
|
43
50
|
* }} ParallelState
|
|
44
51
|
*/
|
|
@@ -72,36 +79,45 @@ const Failure = (error, initialInput) => ({
|
|
|
72
79
|
*
|
|
73
80
|
* @param {(signal?: AbortSignal) => Promise<any>|any} cmd - The side-effect function to execute. Inside a
|
|
74
81
|
* `Parallel` branch it receives an `AbortSignal` that fires when a sibling branch fails, so I/O that
|
|
75
|
-
* accepts one can be cancelled in flight.
|
|
76
|
-
*
|
|
82
|
+
* accepts one can be cancelled in flight. Outside a `Parallel` no argument is passed. The signal is the
|
|
83
|
+
* first argument, so wrap a function that takes an optional one, as in `() => nanoid()`.
|
|
77
84
|
* @param {(result: any) => Effect} [next] - Receives the result of `cmd` and returns the next Effect.
|
|
78
|
-
* Defaults to `(result) => Success(result)`, which is what most Commands want.
|
|
85
|
+
* Defaults to `(result) => Success(result)`, which is what most Commands want; `null` counts as omitted.
|
|
79
86
|
* @param {CommandMeta} [meta] - Optional metadata, passed to `onBeforeCommand`. A string `meta.name`
|
|
80
|
-
* becomes this Command's identity for traces, replay matching, and telemetry spans
|
|
81
|
-
* the identity independent of how `cmd` was declared and immune to minification.
|
|
87
|
+
* becomes this Command's identity for traces, replay matching, and telemetry spans.
|
|
82
88
|
* @returns {CommandState}
|
|
83
89
|
*/
|
|
84
|
-
const Command = (cmd, next
|
|
85
|
-
|
|
86
|
-
cmd
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
+
const Command = (cmd, next, meta) => {
|
|
91
|
+
// Checked as the flow is built, so a mistake is reported before the I/O it would have run.
|
|
92
|
+
if (typeof cmd !== 'function') {
|
|
93
|
+
throw malformed(
|
|
94
|
+
/** @type {any} */ (cmd) instanceof Promise
|
|
95
|
+
? 'Command expects a function, got a Promise.'
|
|
96
|
+
: `Command expects the function that does the I/O, got ${describeArgument(cmd)}.`,
|
|
97
|
+
cmd
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
if (next != null && typeof next !== 'function') {
|
|
101
|
+
throw malformed(
|
|
102
|
+
isOptionsObject(next)
|
|
103
|
+
? "Command's second argument is next, and meta goes third: Command(fn, undefined, { name: 'chargeCard' })."
|
|
104
|
+
: `Command's next must be a function, got ${describeArgument(next)}.`,
|
|
105
|
+
next
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
return { type: 'Command', cmd, next: next ?? ((/** @type {any} */ result) => Success(result)), meta };
|
|
109
|
+
};
|
|
90
110
|
|
|
91
111
|
/**
|
|
92
112
|
* The name a Command is known by: what a trace records, what replay matches on, and what a
|
|
93
|
-
* telemetry span is called.
|
|
94
|
-
*
|
|
95
|
-
* `meta.name` wins when it is a non-empty string. Otherwise the identity is the thunk's own `name`,
|
|
96
|
-
* so an inline arrow records as 'anonymous' and a minifier that mangles function names silently
|
|
97
|
-
* renames every step of every trace. Passing `meta.name` avoids both.
|
|
113
|
+
* telemetry span is called. A non-empty string `meta.name`, else `cmd.name`, else 'anonymous'.
|
|
98
114
|
*
|
|
99
115
|
* @param {CommandState} eff
|
|
100
116
|
* @returns {string}
|
|
101
117
|
*/
|
|
102
118
|
const commandName = (eff) => {
|
|
103
119
|
const meta = eff.meta;
|
|
104
|
-
const named = meta
|
|
120
|
+
const named = isObject(meta) ? meta.name : undefined;
|
|
105
121
|
return typeof named === 'string' && named !== '' ? named : eff.cmd.name || 'anonymous';
|
|
106
122
|
};
|
|
107
123
|
|
|
@@ -110,7 +126,12 @@ const commandName = (eff) => {
|
|
|
110
126
|
* @param {(context: any) => Effect} next - Receives the context and returns the next Effect
|
|
111
127
|
* @returns {AskState}
|
|
112
128
|
*/
|
|
113
|
-
const Ask = (next) =>
|
|
129
|
+
const Ask = (next) => {
|
|
130
|
+
if (typeof next !== 'function') {
|
|
131
|
+
throw malformed(`Ask expects a function that receives the context, got ${describeArgument(next)}.`, next);
|
|
132
|
+
}
|
|
133
|
+
return { type: 'Ask', next };
|
|
134
|
+
};
|
|
114
135
|
|
|
115
136
|
/**
|
|
116
137
|
* Wraps an Effect tree with retry-on-failure semantics.
|
|
@@ -121,121 +142,253 @@ const Ask = (next) => ({ type: 'Ask', next });
|
|
|
121
142
|
* every Command in it is idempotent.
|
|
122
143
|
*
|
|
123
144
|
* @param {Effect} effect - The inner Effect tree to retry
|
|
124
|
-
* @param {Object} [options] -
|
|
125
|
-
*
|
|
126
|
-
* @param {number} [options.
|
|
127
|
-
* @param {number} [options.
|
|
145
|
+
* @param {Object} [options] - Retry options, merged over the library defaults at runtime. An option set to
|
|
146
|
+
* `undefined` keeps its default, as an absent config key does.
|
|
147
|
+
* @param {number} [options.attempts] - Max retries (not counting first try), a positive integer
|
|
148
|
+
* @param {number} [options.delay] - Ms before first retry, a finite number of 0 or more
|
|
149
|
+
* @param {number} [options.backoff] - Multiplier applied to delay on each subsequent retry, a finite number
|
|
150
|
+
* of 0 or more
|
|
128
151
|
* @param {(error: any) => Effect} [options.onExhausted] - Runs a fallback Effect when every attempt has
|
|
129
152
|
* failed, receiving `{ retryExhausted, lastError, attempts }`. The fallback's success feeds `next`
|
|
130
|
-
* exactly as the primary's would have; its failure propagates unwrapped.
|
|
131
|
-
*
|
|
153
|
+
* exactly as the primary's would have; its failure propagates unwrapped. A fallback never starts
|
|
154
|
+
* in a `Parallel` branch a sibling has cancelled.
|
|
132
155
|
* @returns {RetryState}
|
|
133
156
|
*/
|
|
134
|
-
const Retry = (effect, options
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
}
|
|
157
|
+
const Retry = (effect, options) => {
|
|
158
|
+
if (!isEffect(effect)) throw malformed(`Retry expects the Effect to run, got ${describeValue(effect)}.`, effect);
|
|
159
|
+
if (options != null && !isOptionsObject(options)) {
|
|
160
|
+
const hint = typeof options === 'number' ? `: write Retry(effect, { attempts: ${options} })` : '';
|
|
161
|
+
throw malformed(`Retry's options must be an object, got ${describeArgument(options)}${hint}.`, options);
|
|
162
|
+
}
|
|
163
|
+
return { type: 'Retry', effect, options: options ?? {}, next: (value) => Success(value) };
|
|
164
|
+
};
|
|
140
165
|
|
|
141
166
|
/**
|
|
142
167
|
* Runs multiple Effect trees concurrently. The first branch to fail cancels its siblings, and that
|
|
143
|
-
* branch's Failure is what the Parallel returns; `next` is skipped.
|
|
144
|
-
*
|
|
168
|
+
* branch's Failure is what the Parallel returns; `next` is skipped. Which branch fails first depends on
|
|
169
|
+
* timing, so it is recorded, and a replay returns the same one.
|
|
145
170
|
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
* the `AbortSignal` it is passed and hand it to whatever performs the I/O; a thunk that ignores the
|
|
171
|
+
* A cancelled branch starts no further Commands. Stopping the Command already in flight needs its
|
|
172
|
+
* function to pass the `AbortSignal` it receives to whatever performs the I/O; one that ignores the
|
|
149
173
|
* signal runs to completion, so a branch's first Command can still write after a sibling has failed.
|
|
150
174
|
*
|
|
175
|
+
* `settled: true` turns off that first-failure rule: every branch runs to completion and `next`
|
|
176
|
+
* receives the branch outcomes, `Success` and `Failure` nodes in array order. An `EffectTypeError`
|
|
177
|
+
* still escapes. `limit: n` keeps at most `n` branches in flight. Results and paths stay in array
|
|
178
|
+
* order either way.
|
|
179
|
+
*
|
|
180
|
+
* The second argument is the `next` function or the options, whichever it looks like, so
|
|
181
|
+
* `Parallel(effects, { limit: 5 })` needs no placeholder. `undefined` or `null` there is a skipped `next`,
|
|
182
|
+
* so `Parallel(effects, undefined, { limit: 5 })` still reads the options.
|
|
183
|
+
*
|
|
151
184
|
* @param {Effect[]} effects - Array of Effect trees to run concurrently
|
|
152
|
-
* @param {(values: any[]) => Effect} [
|
|
153
|
-
*
|
|
185
|
+
* @param {((values: any[]) => Effect) | ParallelOptions | null} [nextOrOptions] - Receives array of success
|
|
186
|
+
* values in order and returns the next Effect, or the options. `next` defaults to
|
|
187
|
+
* `(values) => Success(values)`, same as `Command`'s default.
|
|
188
|
+
* @param {ParallelOptions} [maybeOptions] - Options, when `next` was given or skipped
|
|
154
189
|
* @returns {ParallelState}
|
|
155
190
|
*/
|
|
156
|
-
const Parallel = (effects,
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
191
|
+
const Parallel = (effects, nextOrOptions, maybeOptions) => {
|
|
192
|
+
if (!Array.isArray(effects)) {
|
|
193
|
+
throw malformed(`Parallel expects an array of Effects, got ${describeArgument(effects)}.`, effects);
|
|
194
|
+
}
|
|
195
|
+
effects.forEach((branch, i) => {
|
|
196
|
+
if (!isEffect(branch)) throw malformed(`Parallel's branch ${i} is ${describeValue(branch)}.`, branch);
|
|
197
|
+
});
|
|
198
|
+
const hasNext = typeof nextOrOptions === 'function';
|
|
199
|
+
// A caller forwarding an absent `next` still passes its options third, as the signature reads.
|
|
200
|
+
const nextSkipped = nextOrOptions == null;
|
|
201
|
+
if (!hasNext && !nextSkipped && !isOptionsObject(nextOrOptions)) {
|
|
202
|
+
const got = describeArgument(nextOrOptions);
|
|
203
|
+
throw malformed(`Parallel's second argument must be next or the options, got ${got}.`, nextOrOptions);
|
|
204
|
+
}
|
|
205
|
+
const options = hasNext || nextSkipped ? maybeOptions : nextOrOptions;
|
|
206
|
+
if (options != null && !isOptionsObject(options)) {
|
|
207
|
+
throw malformed(`Parallel's options must be an object, got ${describeArgument(options)}.`, options);
|
|
208
|
+
}
|
|
209
|
+
return {
|
|
210
|
+
type: 'Parallel',
|
|
211
|
+
effects,
|
|
212
|
+
next: hasNext ? nextOrOptions : (/** @type {any[]} */ values) => Success(values),
|
|
213
|
+
options: options ?? {}
|
|
214
|
+
};
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Whether a value is an object that can carry properties: not `null`, a primitive, or a function.
|
|
219
|
+
* @param {any} value
|
|
220
|
+
* @returns {boolean}
|
|
221
|
+
*/
|
|
222
|
+
const isObject = (value) => value !== null && typeof value === 'object';
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* @param {any} value
|
|
226
|
+
* @returns {boolean}
|
|
227
|
+
*/
|
|
228
|
+
const isPositiveInteger = (value) => Number.isInteger(value) && value >= 1;
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* @param {any} value
|
|
232
|
+
* @returns {boolean}
|
|
233
|
+
*/
|
|
234
|
+
const isFiniteNonNegative = (value) => Number.isFinite(value) && value >= 0;
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* An object that can hold options or meta: not an array, and not an Effect passed in the wrong place.
|
|
238
|
+
* @param {any} value
|
|
239
|
+
* @returns {boolean}
|
|
240
|
+
*/
|
|
241
|
+
const isOptionsObject = (value) => isObject(value) && !Array.isArray(value) && !isEffect(value);
|
|
161
242
|
|
|
162
243
|
/**
|
|
163
244
|
* Describes a value for an error message, leading with the mistake it most likely is.
|
|
164
245
|
* @param {any} value
|
|
165
246
|
* @returns {string}
|
|
166
247
|
*/
|
|
167
|
-
const describeValue = (value) =>
|
|
168
|
-
value === undefined
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
248
|
+
const describeValue = (value) => {
|
|
249
|
+
if (value === undefined) return 'undefined, which usually means a missing return';
|
|
250
|
+
if (value === null) return 'null';
|
|
251
|
+
if (value instanceof Promise) return 'a Promise, which usually means an async function';
|
|
252
|
+
if (typeof value === 'function')
|
|
253
|
+
return 'a function, which usually means a flow was passed without being called with its input';
|
|
254
|
+
// `String`, since `JSON.stringify` prints NaN and Infinity as null and throws on a BigInt.
|
|
255
|
+
if (typeof value !== 'object') {
|
|
256
|
+
return `the ${typeof value} ${typeof value === 'string' ? JSON.stringify(value) : String(value)}`;
|
|
257
|
+
}
|
|
258
|
+
if (typeof value.type === 'string') return `an object with an unrecognised type '${value.type}'`;
|
|
259
|
+
return 'a plain object';
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Describes a constructor's argument for an error message. A missing one is usually a misspelt name
|
|
264
|
+
* rather than a missing return, and an Effect in the wrong place is named by its type.
|
|
265
|
+
* @param {any} value
|
|
266
|
+
* @returns {string}
|
|
267
|
+
*/
|
|
268
|
+
const describeArgument = (value) =>
|
|
269
|
+
value === undefined ? 'undefined' : isEffect(value) ? `an Effect of type '${value.type}'` : describeValue(value);
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Marks an error as the harness failing rather than the flow: a malformed flow, or a trace that cannot
|
|
273
|
+
* answer a step. The interpreter rethrows anything carrying it instead of folding it into a `Failure`, so
|
|
274
|
+
* nothing that handles a domain failure can absorb it. A new kind of harness error gets this mark rather
|
|
275
|
+
* than a name the interpreter has to know. Non-enumerable, so it never shows up in a serialized error or
|
|
276
|
+
* a `deepEqual`.
|
|
277
|
+
*/
|
|
278
|
+
const harnessError = Symbol('pure-effect.harnessError');
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* @param {Error} error
|
|
282
|
+
* @returns {Error}
|
|
283
|
+
*/
|
|
284
|
+
const asHarnessError = (error) => Object.defineProperty(error, harnessError, { value: true });
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Whether a thrown value carries one of the library's marks: `harnessError`, `replayCut`, or `replayFault`.
|
|
288
|
+
* @param {unknown} e
|
|
289
|
+
* @param {symbol} mark
|
|
290
|
+
* @returns {boolean}
|
|
291
|
+
*/
|
|
292
|
+
const hasMark = (e, mark) => Boolean(e && /** @type {any} */ (e)[mark]);
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* An I/O fault: a Command's function threw, or a `Retry` ran out of attempts. `Retry` retries this, never a
|
|
296
|
+
* `Failure` a step returned, which is an abort. Internal: only `execute` and its per-node functions return
|
|
297
|
+
* one, and `asOutcome` turns it into a plain `Failure` before it reaches user code.
|
|
298
|
+
* @typedef {{ type: 'IoFault', error: any, initialInput?: any }} IoFaultState
|
|
299
|
+
*/
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* @param {any} error
|
|
303
|
+
* @param {any} [initialInput]
|
|
304
|
+
* @returns {IoFaultState}
|
|
305
|
+
*/
|
|
306
|
+
const IoFault = (error, initialInput) => ({ type: 'IoFault', error, initialInput });
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* @param {SuccessState | FailureState | IoFaultState} state
|
|
310
|
+
* @returns {SuccessState | FailureState}
|
|
311
|
+
*/
|
|
312
|
+
const asOutcome = (state) => (state.type === 'IoFault' ? Failure(state.error, state.initialInput) : state);
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Builds an `EffectTypeError`, for a malformed flow: a step or continuation that returned something other
|
|
316
|
+
* than an Effect, or a constructor given an argument it cannot use. It is a harness error: a malformed flow
|
|
317
|
+
* is a bug, so it is thrown rather than becoming a `Failure`.
|
|
182
318
|
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
319
|
+
* @param {string} message
|
|
320
|
+
* @param {any} value - The malformed value
|
|
321
|
+
* @returns {Error}
|
|
322
|
+
*/
|
|
323
|
+
const malformed = (message, value) => {
|
|
324
|
+
// This error already reports the bug, so the Promise rejecting must not also crash the process as
|
|
325
|
+
// unhandled. Only a native Promise: calling `then` on a Knex or Mongoose query builder runs the query.
|
|
326
|
+
if (value instanceof Promise) value.catch(() => {});
|
|
327
|
+
return asHarnessError(Object.assign(new Error(message), { name: 'EffectTypeError' }));
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Builds the error for a value that is not an Effect, explaining the likely mistake.
|
|
185
332
|
*
|
|
186
333
|
* @param {any} value
|
|
187
334
|
* @param {string} source - What produced the value, named where it is known
|
|
188
335
|
* @returns {Error}
|
|
189
336
|
*/
|
|
190
337
|
const effectTypeError = (value, source) =>
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
'a
|
|
195
|
-
|
|
196
|
-
|
|
338
|
+
malformed(
|
|
339
|
+
`${source} returned ${describeValue(value)}. Return Success, Failure, Command, Ask, Retry, or Parallel: ` +
|
|
340
|
+
(value instanceof Promise
|
|
341
|
+
? 'a step cannot be async, so do the awaited work in a Command and continue in its next.'
|
|
342
|
+
: 'a plain value has to be wrapped, as in Success(value).'),
|
|
343
|
+
value
|
|
197
344
|
);
|
|
198
345
|
|
|
346
|
+
/**
|
|
347
|
+
* A Success or a Failure: a flow with nothing left to run.
|
|
348
|
+
* @param {any} value
|
|
349
|
+
* @returns {value is SuccessState | FailureState}
|
|
350
|
+
*/
|
|
351
|
+
const isOutcome = (value) => ['Success', 'Failure'].includes(value?.type);
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* A Command, Ask, Retry, or Parallel: a flow the interpreter still has work to do on.
|
|
355
|
+
* @param {any} value
|
|
356
|
+
* @returns {value is CommandState | AskState | RetryState | ParallelState}
|
|
357
|
+
*/
|
|
358
|
+
const isPending = (value) => ['Command', 'Ask', 'Retry', 'Parallel'].includes(value?.type);
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* @param {any} value
|
|
362
|
+
* @returns {value is Effect}
|
|
363
|
+
*/
|
|
364
|
+
const isEffect = (value) => isOutcome(value) || isPending(value);
|
|
365
|
+
|
|
199
366
|
/**
|
|
200
367
|
* @param {any} value
|
|
201
368
|
* @param {string} source
|
|
202
369
|
* @returns {Effect}
|
|
203
370
|
*/
|
|
204
371
|
const asEffect = (value, source) => {
|
|
205
|
-
if (
|
|
372
|
+
if (isEffect(value)) return value;
|
|
206
373
|
throw effectTypeError(value, source);
|
|
207
374
|
};
|
|
208
375
|
|
|
209
376
|
/**
|
|
210
377
|
* Connects an Effect to the next function in the pipeline.
|
|
211
|
-
* Handles the branching logic for Success, Failure, Command, Ask, and
|
|
378
|
+
* Handles the branching logic for Success, Failure, Command, Ask, Retry, and Parallel.
|
|
212
379
|
*
|
|
213
380
|
* @param {Effect} effect - The current Effect object
|
|
214
381
|
* @param {(value: any) => Effect} fn - The next function to run if the current effect is a Success
|
|
382
|
+
* @param {any} [initialInput] - The pipeline's starting value, stamped on every node but a Success
|
|
215
383
|
* @returns {Effect} The composed Effect
|
|
216
384
|
*/
|
|
217
|
-
/**
|
|
218
|
-
* @param {Effect} effect
|
|
219
|
-
* @param {(value: any) => Effect} fn
|
|
220
|
-
* @param {any} [initialInput]
|
|
221
|
-
* @returns {Effect}
|
|
222
|
-
*/
|
|
223
385
|
const chain = (effect, fn, initialInput) => {
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
// continuations, so overwriting here is what makes the whole tree, a Failure from any depth, and
|
|
227
|
-
// the root a hook-based recorder reads carry the input of the flow that was actually called. The
|
|
228
|
-
// previous rule, stamp only when unset, let a Retry- or Parallel-headed sub-pipeline leave its own
|
|
229
|
-
// input on the root, and a trace recorded from it rebuilt the flow from the wrong value. A Success
|
|
230
|
-
// is never stamped: nothing reads the input off a terminal value, and leaving it bare is what lets
|
|
231
|
-
// `assert.deepEqual(result, Success(v))` hold whatever shape the flow had.
|
|
386
|
+
// Overwrites a sub-pipeline's own stamp, so the root and a Failure at any depth carry the input of the
|
|
387
|
+
// flow that was actually called. A Success stays bare, so `deepEqual(result, Success(v))` always holds.
|
|
232
388
|
const withII = (/** @type {Effect} */ e) =>
|
|
233
389
|
initialInput !== undefined && e.type !== 'Success' ? { ...e, initialInput } : e;
|
|
234
390
|
|
|
235
|
-
//
|
|
236
|
-
// `undefined` throws a bare TypeError, and since every continuation runs inside the interpreter's try
|
|
237
|
-
// block, that TypeError became a domain Failure: the flow bug took the business-error branch and no
|
|
238
|
-
// step was named. The `default` arm below never sees it, because the switch itself is what throws.
|
|
391
|
+
// Checked before `effect.type` is read, which would otherwise throw a bare TypeError naming no step.
|
|
239
392
|
if (effect == null) return asEffect(effect, 'A continuation');
|
|
240
393
|
|
|
241
394
|
switch (effect.type) {
|
|
@@ -272,24 +425,30 @@ const chain = (effect, fn, initialInput) => {
|
|
|
272
425
|
* @returns {(start: any) => Effect} A function that accepts an initial input and returns the final Effect tree.
|
|
273
426
|
*/
|
|
274
427
|
const effectPipe = (...fns) => {
|
|
428
|
+
// Checked where the pipeline is defined, so a misspelt import is named before any flow is built from it.
|
|
429
|
+
fns.forEach((fn, i) => {
|
|
430
|
+
if (typeof fn !== 'function') {
|
|
431
|
+
const got = describeArgument(fn);
|
|
432
|
+
throw malformed(
|
|
433
|
+
`effectPipe's step ${i + 1} is ${got}; each step is a function that returns an Effect.`,
|
|
434
|
+
fn
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
});
|
|
275
438
|
return (start) => {
|
|
276
439
|
const chainWithII = (/** @type {Effect} */ eff, /** @type {(v: any) => Effect} */ fn) => chain(eff, fn, start);
|
|
277
440
|
const tree = fns.reduce(chainWithII, /** @type {Effect} */ (Success(start)));
|
|
278
|
-
//
|
|
279
|
-
//
|
|
280
|
-
// that wrapping, so a sub-pipeline returned by the last step would keep its own input on
|
|
281
|
-
// everything past its head. The pass wraps those continuations too, so every node reachable
|
|
282
|
-
// from here, a Failure at any depth included, carries this pipeline's `start`. `Success` is the
|
|
283
|
-
// identity step: it can never return a non-Effect, so no message is ever attributed to it.
|
|
441
|
+
// An identity pass. `chain` stamps a sub-pipeline's nodes only through the continuations it wraps,
|
|
442
|
+
// and a sub-pipeline returned by the last step has no later step to wrap them.
|
|
284
443
|
return chain(tree, Success, start);
|
|
285
444
|
};
|
|
286
445
|
};
|
|
287
446
|
|
|
288
447
|
/**
|
|
289
|
-
* Wraps one Command execution
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
448
|
+
* Wraps one Command execution, or one Parallel: `type` is 'Parallel', and `op` runs the branches and
|
|
449
|
+
* returns the Parallel's decision, even when a branch threw, so a hook must call it. Only a replay passes
|
|
450
|
+
* `op` an argument, the recorded decision. `path` is the step's position in the Effect tree, so it matches
|
|
451
|
+
* between a run and its replay whatever order `Parallel` branches finish in.
|
|
293
452
|
* @typedef {(name: string, type: string, op: function, path?: string) => Promise<any>} StepRunner
|
|
294
453
|
*/
|
|
295
454
|
/** @type StepRunner */
|
|
@@ -305,22 +464,30 @@ const defaultCommandInterceptor = async (command, context) => {};
|
|
|
305
464
|
|
|
306
465
|
const defaultRetryOptions = { attempts: 3, delay: 100, backoff: 1 };
|
|
307
466
|
|
|
467
|
+
/**
|
|
468
|
+
* Refuses the removed `retry` key rather than ignoring it, which would quietly turn a configured
|
|
469
|
+
* `attempts: 5` back into 3. Migration scaffolding: remove at 1.0.
|
|
470
|
+
* @param {any} config
|
|
471
|
+
* @param {string} source
|
|
472
|
+
*/
|
|
473
|
+
const rejectRetryKey = (config, source) => {
|
|
474
|
+
if (isObject(config) && 'retry' in config)
|
|
475
|
+
throw new TypeError(
|
|
476
|
+
`${source} no longer takes 'retry'. Retry options are per-use: pass them to Retry(effect, options).`
|
|
477
|
+
);
|
|
478
|
+
};
|
|
479
|
+
|
|
308
480
|
/**
|
|
309
481
|
* @typedef {Object} EffectConfiguration
|
|
310
|
-
* @property {StepRunner} [onStep] -
|
|
482
|
+
* @property {StepRunner} [onStep] - Wraps each Command's execution, and each Parallel's branches.
|
|
311
483
|
* @property {RunWrapper} [onRun] - Fires once per runEffect call. It wraps the entire workflow execution.
|
|
312
484
|
* @property {CommandInterceptor} [onBeforeCommand] - Intercepts a Command and any context passed to runEffect before execution.
|
|
313
|
-
* @property {{ attempts?: number, delay?: number, backoff?: number }} [retry] - Global Retry defaults; merged under per-use options.
|
|
314
485
|
*/
|
|
315
486
|
|
|
316
487
|
/**
|
|
317
488
|
* A per-call configuration: an `EffectConfiguration` plus `inherit`. With `inherit: true` (the default)
|
|
318
|
-
* the call's hooks are
|
|
319
|
-
*
|
|
320
|
-
* call winning. With `inherit: false` the global wiring is not consulted at all, so any slot the call
|
|
321
|
-
* leaves unset falls back to the library default. A per-call hook cannot replace a single global slot on
|
|
322
|
-
* its own; that was the previous default, and it is how a per-call recorder silently switched off an
|
|
323
|
-
* application's tracing.
|
|
489
|
+
* the call's hooks are merged over the wiring `configureEffect` installed, global outermost. With
|
|
490
|
+
* `inherit: false` that wiring is ignored, so a slot the call leaves unset falls back to the library default.
|
|
324
491
|
*
|
|
325
492
|
* @typedef {EffectConfiguration & { inherit?: boolean }} CallConfiguration
|
|
326
493
|
*/
|
|
@@ -329,10 +496,8 @@ const defaultRetryOptions = { attempts: 3, delay: 100, backoff: 1 };
|
|
|
329
496
|
let layers = [];
|
|
330
497
|
|
|
331
498
|
/**
|
|
332
|
-
* The installed layers merged into one configuration
|
|
333
|
-
*
|
|
334
|
-
* and a slot no layer defines is simply absent, so the library default is chosen at the one place the
|
|
335
|
-
* hook is used rather than held in a second set of variables kept in step by hand.
|
|
499
|
+
* The installed layers merged into one configuration. A slot no layer defines is absent, and `runEffect`
|
|
500
|
+
* picks the library default where it reads the slot.
|
|
336
501
|
* @type {EffectConfiguration}
|
|
337
502
|
*/
|
|
338
503
|
let globalConfig = {};
|
|
@@ -343,40 +508,34 @@ const applyLayers = () => {
|
|
|
343
508
|
};
|
|
344
509
|
|
|
345
510
|
/**
|
|
346
|
-
* Adds a configuration to the global wiring of the Effect runner: telemetry
|
|
347
|
-
*
|
|
511
|
+
* Adds a configuration to the global wiring of the Effect runner: telemetry and the command
|
|
512
|
+
* interceptor. Retry options are not part of it; they are per-use, passed to `Retry(effect, options)`.
|
|
348
513
|
*
|
|
349
514
|
* Each call adds one layer on top of those already installed and returns a function that removes that
|
|
350
515
|
* layer, wherever it sits by then. Layers merge the way several configurations passed to one call do:
|
|
351
516
|
* `onStep` and `onRun` are wrappers, so they nest with the earliest layer outermost and the latest
|
|
352
|
-
* closest to the Command; `onBeforeCommand` interceptors all run, in the order installed
|
|
353
|
-
*
|
|
517
|
+
* closest to the Command; and `onBeforeCommand` interceptors all run, in the order installed. So these
|
|
518
|
+
* are the same:
|
|
354
519
|
*
|
|
355
520
|
* configureEffect(telemetryHooks(), recordingHooks({ sink }));
|
|
356
521
|
* configureEffect(telemetryHooks()); configureEffect(recordingHooks({ sink }));
|
|
357
522
|
*
|
|
358
|
-
* Calling it with no arguments at all removes every layer. A call whose arguments are all `undefined
|
|
359
|
-
* is a conditional install that installed nothing:
|
|
360
|
-
*
|
|
361
|
-
* Layers replaced the previous one-slot rule, under which a later call displaced the earlier wiring and
|
|
362
|
-
* the returned function restored a snapshot, guarded so that interleaved installs could not clobber
|
|
363
|
-
* each other. Removing a layer needs no guard: A installs, B installs, A removes, and B is still there.
|
|
364
|
-
* It also means a library can install its own hooks without erasing its host's, which under the old rule
|
|
365
|
-
* was possible only if the host passed the library's configuration into its own call.
|
|
523
|
+
* Calling it with no arguments at all removes every layer. A call whose arguments are all `undefined`,
|
|
524
|
+
* such as `configureEffect(flag ? hooks : undefined)`, is a conditional install that installed nothing:
|
|
525
|
+
* it adds no layer and removes none.
|
|
366
526
|
*
|
|
367
527
|
* @param {...(EffectConfiguration | undefined)} configs - Configurations merged into one layer, outermost first
|
|
368
528
|
* @returns {() => void} Removes the layer this call added; a second call does nothing
|
|
369
529
|
*/
|
|
370
530
|
const configureEffect = (...configs) => {
|
|
371
|
-
// Only a call with no arguments at all is a reset. A call whose arguments are all absent, such as
|
|
372
|
-
// `configureEffect(flag ? hooks : undefined)`, is a conditional install that happened not to install
|
|
373
|
-
// anything, so it adds no layer and must not remove anyone else's.
|
|
374
531
|
if (configs.length === 0) {
|
|
375
532
|
layers = [];
|
|
376
533
|
applyLayers();
|
|
377
534
|
return () => {};
|
|
378
535
|
}
|
|
379
536
|
const present = configs.filter(Boolean);
|
|
537
|
+
// Before installing anything, so a refused call leaves the wiring untouched.
|
|
538
|
+
present.forEach((config) => rejectRetryKey(config, 'configureEffect'));
|
|
380
539
|
if (present.length === 0) return () => {};
|
|
381
540
|
const layer = chainHooks(...present);
|
|
382
541
|
layers = [...layers, layer];
|
|
@@ -390,8 +549,8 @@ const configureEffect = (...configs) => {
|
|
|
390
549
|
|
|
391
550
|
/**
|
|
392
551
|
* @typedef {Object} StepStart
|
|
393
|
-
* @property {string} name - `cmd.name`,
|
|
394
|
-
* @property {string} type -
|
|
552
|
+
* @property {string} name - The Command's identity: `meta.name`, else `cmd.name`, else 'anonymous'.
|
|
553
|
+
* @property {string} type - 'Command', or 'Parallel' for a Parallel's decision.
|
|
395
554
|
* @property {string} [path] - The Command's position in the Effect tree.
|
|
396
555
|
*/
|
|
397
556
|
|
|
@@ -408,7 +567,8 @@ const configureEffect = (...configs) => {
|
|
|
408
567
|
const now = () => (typeof performance === 'object' ? performance.now() : Date.now());
|
|
409
568
|
|
|
410
569
|
/**
|
|
411
|
-
* Wraps a step observer into an `onStep` that cannot change the run
|
|
570
|
+
* Wraps a step observer into an `onStep` that cannot change the run: `op` always runs, its result is
|
|
571
|
+
* returned, its error propagates, and anything the observer throws is dropped.
|
|
412
572
|
*
|
|
413
573
|
* @param {(start: StepStart) => (end: StepEnd) => void} handler - Returns a finisher for the outcome
|
|
414
574
|
* @returns {StepRunner}
|
|
@@ -443,12 +603,10 @@ const observeSteps = (handler) => async (name, type, op, path) => {
|
|
|
443
603
|
/**
|
|
444
604
|
* Merges several configurations into one, so independent concerns can share the hooks.
|
|
445
605
|
*
|
|
446
|
-
* `onStep` and `onRun` are wrappers around an `op`, so they nest
|
|
447
|
-
*
|
|
448
|
-
*
|
|
449
|
-
*
|
|
450
|
-
* configs winning. A hook no config defines is left unset, so `configureEffect` keeps its
|
|
451
|
-
* default for that slot.
|
|
606
|
+
* `onStep` and `onRun` are wrappers around an `op`, so they nest: the first config given is the
|
|
607
|
+
* outermost and the last sits closest to the Command, so a thrown Command unwinds from the last back
|
|
608
|
+
* to the first. `onBeforeCommand` is an observer, so every interceptor runs in the order given. A hook
|
|
609
|
+
* no config defines is left unset, so the caller keeps its default for that slot.
|
|
452
610
|
*
|
|
453
611
|
* @param {...(EffectConfiguration | undefined)} configs - Configurations to merge, outermost first
|
|
454
612
|
* @returns {EffectConfiguration}
|
|
@@ -465,7 +623,6 @@ const chainHooks = (...configs) => {
|
|
|
465
623
|
const steps = /** @type {StepRunner[]} */ (present('onStep'));
|
|
466
624
|
const runs = /** @type {RunWrapper[]} */ (present('onRun'));
|
|
467
625
|
const interceptors = /** @type {CommandInterceptor[]} */ (present('onBeforeCommand'));
|
|
468
|
-
const retries = present('retry');
|
|
469
626
|
|
|
470
627
|
if (steps.length) {
|
|
471
628
|
merged.onStep = steps.reduceRight(
|
|
@@ -482,27 +639,115 @@ const chainHooks = (...configs) => {
|
|
|
482
639
|
for (const intercept of interceptors) await intercept(command, context);
|
|
483
640
|
};
|
|
484
641
|
}
|
|
485
|
-
if (retries.length) merged.retry = Object.assign({}, ...retries);
|
|
486
642
|
return merged;
|
|
487
643
|
};
|
|
488
644
|
|
|
489
645
|
/**
|
|
490
|
-
* The Failure a `Parallel` branch
|
|
491
|
-
*
|
|
492
|
-
*
|
|
493
|
-
*
|
|
494
|
-
|
|
646
|
+
* The Failure a cancelled `Parallel` branch stops with. Its error is named so it is never mistaken for
|
|
647
|
+
* the failure that triggered the cancellation.
|
|
648
|
+
* @param {any} initialInput
|
|
649
|
+
* @returns {FailureState}
|
|
650
|
+
*/
|
|
651
|
+
const cancelledBranch = (initialInput) =>
|
|
652
|
+
Failure(Object.assign(new Error('Parallel branch cancelled.'), { name: 'ParallelCancelled' }), initialInput);
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Which branch, if any, cancelled a Parallel. `branch: null` means an enclosing Parallel cancelled it.
|
|
656
|
+
* It is recorded as the Parallel's own step, since it is decided by timing and a replay cannot recompute it.
|
|
657
|
+
* @typedef {{ cancelled: false } | { cancelled: true, branch: number | null }} ParallelDecision
|
|
658
|
+
*/
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* What running a Parallel's branches produced: each branch's outcome in array order, the decision, and
|
|
662
|
+
* the first error a branch threw, which the Parallel rethrows once its step has returned the decision.
|
|
663
|
+
* @typedef {{
|
|
664
|
+
* results: (SuccessState | FailureState | IoFaultState)[],
|
|
665
|
+
* decision: ParallelDecision,
|
|
666
|
+
* thrown: { error: unknown } | undefined
|
|
667
|
+
* }} BranchRun
|
|
668
|
+
*/
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* @param {any} branch
|
|
672
|
+
* @param {number} count - How many branches the Parallel has
|
|
673
|
+
* @returns {boolean}
|
|
674
|
+
*/
|
|
675
|
+
const isBranchIndex = (branch, count) => Number.isInteger(branch) && branch >= 0 && branch < count;
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* Reads a recorded decision, or `undefined` for anything that is not one, which replays the Parallel
|
|
679
|
+
* under timing.
|
|
680
|
+
* @param {any} value
|
|
681
|
+
* @param {number} branches - How many branches the Parallel has, so a stale branch index is refused too
|
|
682
|
+
* @returns {ParallelDecision | undefined}
|
|
683
|
+
*/
|
|
684
|
+
const asDecision = (value, branches) => {
|
|
685
|
+
if (!isObject(value)) return undefined;
|
|
686
|
+
if (value.cancelled === false) return { cancelled: false };
|
|
687
|
+
if (value.cancelled !== true) return undefined;
|
|
688
|
+
const { branch } = value;
|
|
689
|
+
if (branch === null) return { cancelled: true, branch: null };
|
|
690
|
+
return isBranchIndex(branch, branches) ? { cancelled: true, branch } : undefined;
|
|
691
|
+
};
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Marks a step a replay reached that production never ran, in a branch a recorded decision cancelled.
|
|
695
|
+
* The interpreter stops the branch there, as production did. Only `fromTrace` raises it.
|
|
696
|
+
*/
|
|
697
|
+
const replayCut = Symbol('pure-effect.replayCut');
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* @param {string} path
|
|
495
701
|
* @returns {Error}
|
|
496
702
|
*/
|
|
497
|
-
const
|
|
498
|
-
Object.
|
|
499
|
-
|
|
500
|
-
|
|
703
|
+
const replayCutError = (path) =>
|
|
704
|
+
Object.defineProperty(
|
|
705
|
+
asHarnessError(new Error(`Replay stopped a cancelled Parallel branch at path '${path}'.`)),
|
|
706
|
+
replayCut,
|
|
707
|
+
{ value: true }
|
|
708
|
+
);
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* A cancellation scope for one Parallel, linked to the enclosing one so cancellation nests. Without an
|
|
712
|
+
* `AbortController` there is no scope, and every branch runs to completion.
|
|
713
|
+
* @param {AbortSignal} [signal] - The enclosing Parallel's signal, if any
|
|
714
|
+
* @returns {{ scope: AbortController | undefined, unlink: () => void }}
|
|
715
|
+
*/
|
|
716
|
+
const linkedScope = (signal) => {
|
|
717
|
+
const scope = typeof AbortController === 'function' ? new AbortController() : undefined;
|
|
718
|
+
const relay = () => scope?.abort();
|
|
719
|
+
if (signal && scope) {
|
|
720
|
+
if (signal.aborted) scope.abort();
|
|
721
|
+
else signal.addEventListener('abort', relay, { once: true });
|
|
722
|
+
}
|
|
723
|
+
const unlink = () => {
|
|
724
|
+
if (signal && scope) signal.removeEventListener('abort', relay);
|
|
725
|
+
};
|
|
726
|
+
return { scope, unlink };
|
|
727
|
+
};
|
|
501
728
|
|
|
502
729
|
/**
|
|
503
|
-
*
|
|
504
|
-
*
|
|
505
|
-
*
|
|
730
|
+
* Awaits every task, with at most `limit` of them in flight. Workers pull by index, so results land where
|
|
731
|
+
* the caller put the effect rather than where it finished, which keeps trace paths stable.
|
|
732
|
+
* @param {(() => Promise<void>)[]} tasks
|
|
733
|
+
* @param {number} [limit]
|
|
734
|
+
* @returns {Promise<void>}
|
|
735
|
+
*/
|
|
736
|
+
const runBounded = async (tasks, limit) => {
|
|
737
|
+
if (!limit || limit >= tasks.length) {
|
|
738
|
+
await Promise.all(tasks.map((task) => task()));
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
let cursor = 0;
|
|
742
|
+
const worker = async () => {
|
|
743
|
+
for (let i = cursor++; i < tasks.length; i = cursor++) await tasks[i]();
|
|
744
|
+
};
|
|
745
|
+
await Promise.all(Array.from({ length: limit }, worker));
|
|
746
|
+
};
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Sleeps, but gives up early when the surrounding branch is cancelled. Resolves either way: the
|
|
750
|
+
* caller's abort check is what turns a cancelled wait into a Failure.
|
|
506
751
|
*
|
|
507
752
|
* @param {number} ms
|
|
508
753
|
* @param {AbortSignal} [signal]
|
|
@@ -514,6 +759,12 @@ const delayFor = (ms, signal) =>
|
|
|
514
759
|
setTimeout(resolve, ms);
|
|
515
760
|
return;
|
|
516
761
|
}
|
|
762
|
+
// A listener added to an aborted signal never runs, and this is the usual case: a Command that
|
|
763
|
+
// honours the signal rejects when a sibling fails, and the Retry around it backs off on that signal.
|
|
764
|
+
if (signal.aborted) {
|
|
765
|
+
resolve();
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
517
768
|
const onAbort = () => {
|
|
518
769
|
clearTimeout(timer);
|
|
519
770
|
resolve();
|
|
@@ -525,197 +776,384 @@ const delayFor = (ms, signal) =>
|
|
|
525
776
|
signal.addEventListener('abort', onAbort, { once: true });
|
|
526
777
|
});
|
|
527
778
|
|
|
528
|
-
const
|
|
779
|
+
const interpret =
|
|
529
780
|
/**
|
|
530
|
-
* The
|
|
531
|
-
*
|
|
532
|
-
*
|
|
533
|
-
*
|
|
534
|
-
* Per-call config is merged over the global configureEffect wiring, or over nothing under
|
|
535
|
-
* `inherit: false`. onRun fires exactly once per runEffect call. Retry attempts run inside that
|
|
536
|
-
* single span rather than spawning their own, keeping telemetry non-duplicated.
|
|
781
|
+
* The interpreter: walks the Effect tree, executing Commands and resolving Ask, Retry, and Parallel.
|
|
782
|
+
* `runEffect` and `replayEffect` both run flows through it, so a replay cannot drift from a run.
|
|
783
|
+
* `fastRetry` is the one thing only a replay sets: it waits no time between retry attempts, since a
|
|
784
|
+
* delay is written on the Retry node and nothing else can override it.
|
|
537
785
|
*
|
|
538
|
-
* @param {Effect} effect
|
|
539
|
-
* @param {any} [context]
|
|
540
|
-
* @param {CallConfiguration} [callConfig]
|
|
541
|
-
*
|
|
786
|
+
* @param {Effect} effect
|
|
787
|
+
* @param {any} [context]
|
|
788
|
+
* @param {CallConfiguration} [callConfig]
|
|
789
|
+
* @param {boolean} [fastRetry]
|
|
542
790
|
* @returns {Promise<SuccessState | FailureState>}
|
|
543
791
|
*/
|
|
544
|
-
async function
|
|
792
|
+
async function interpret(effect, context = {}, callConfig = {}, fastRetry = false) {
|
|
793
|
+
rejectRetryKey(callConfig, "runEffect's callConfig");
|
|
545
794
|
const { inherit = true, ...local } = callConfig;
|
|
546
|
-
//
|
|
547
|
-
// coerced: `'false'` inheriting everything is exactly the silent behaviour this option removes.
|
|
795
|
+
// Not coerced: `'false'` quietly inheriting everything is the behaviour this option exists to remove.
|
|
548
796
|
if (typeof inherit !== 'boolean') {
|
|
549
797
|
throw new TypeError(`callConfig.inherit must be true or false, got ${JSON.stringify(inherit)}.`);
|
|
550
798
|
}
|
|
551
|
-
//
|
|
552
|
-
// inherits nothing, by the same merge `configureEffect` applies to several configurations. Most
|
|
553
|
-
// calls carry no configuration of their own, and those use the installed wiring as it is.
|
|
799
|
+
// Merged the way `configureEffect` merges layers. A call with no hooks of its own skips the merge.
|
|
554
800
|
const base = inherit ? globalConfig : {};
|
|
555
801
|
const resolved = Object.keys(local).length ? chainHooks(base, local) : base;
|
|
556
802
|
const localStepRunner = resolved.onStep || defaultStepRunner;
|
|
557
803
|
const localRunWrapper = resolved.onRun || defaultRunWrapper;
|
|
558
804
|
const localCommandInterceptor = resolved.onBeforeCommand || defaultCommandInterceptor;
|
|
559
|
-
const localRetryDefaults = { ...defaultRetryOptions, ...resolved.retry };
|
|
560
805
|
|
|
561
806
|
/**
|
|
807
|
+
* Walks a subtree until it reaches a Success or a Failure. Every node but `Ask` runs in its own
|
|
808
|
+
* function, which returns a Success carrying the value for the node's `next`, or the Failure or I/O
|
|
809
|
+
* fault that stops the subtree.
|
|
810
|
+
*
|
|
562
811
|
* @param {Effect} eff
|
|
563
812
|
* @param {AbortSignal} [signal] - Cancellation for this subtree, set for `Parallel` branches.
|
|
564
|
-
*
|
|
565
|
-
* each
|
|
566
|
-
*
|
|
567
|
-
*
|
|
568
|
-
* attempt opens its own prefix, so a Command's full path depends only on the shape of the
|
|
569
|
-
* tree and not on the order branches happen to finish in. That is what lets a replay line
|
|
570
|
-
* a recorded step up with the step that asked for it.
|
|
571
|
-
* @returns {Promise<SuccessState | FailureState>}
|
|
813
|
+
* @param {string} [path] - Prefix for this subtree's position in the Effect tree. Steps are numbered
|
|
814
|
+
* within a subtree and each `Parallel` branch and `Retry` attempt opens its own prefix, so a
|
|
815
|
+
* path depends only on the tree's shape, never on the order branches finish in.
|
|
816
|
+
* @returns {Promise<SuccessState | FailureState | IoFaultState>}
|
|
572
817
|
*/
|
|
573
818
|
async function execute(eff, signal, path = '') {
|
|
574
819
|
let step = 0;
|
|
575
|
-
while (
|
|
576
|
-
|
|
577
|
-
(
|
|
578
|
-
) {
|
|
579
|
-
// Checked before every node, which is what short-circuits a branch whose Commands
|
|
580
|
-
// ignore the signal: the one in flight cannot be stopped, but the next never starts.
|
|
581
|
-
if (signal?.aborted) return Failure(parallelCancelled(), eff.initialInput);
|
|
820
|
+
while (isPending(eff)) {
|
|
821
|
+
// Checked before every node: a Command already in flight cannot be stopped, but the next never starts.
|
|
822
|
+
if (signal?.aborted) return cancelledBranch(eff.initialInput);
|
|
582
823
|
if (eff.type === 'Ask') {
|
|
583
824
|
eff = eff.next(context);
|
|
584
825
|
continue;
|
|
585
826
|
}
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
827
|
+
const stepPath = `${path}${step++}`;
|
|
828
|
+
const outcome =
|
|
829
|
+
eff.type === 'Retry'
|
|
830
|
+
? await runRetry(eff, signal, stepPath)
|
|
831
|
+
: eff.type === 'Parallel'
|
|
832
|
+
? await runParallel(eff, signal, stepPath)
|
|
833
|
+
: await runCommand(eff, signal, stepPath);
|
|
834
|
+
if (outcome.type !== 'Success') return outcome;
|
|
835
|
+
// Outside every catch: `next` and the pure steps it reaches are code, not I/O, so a throw there
|
|
836
|
+
// rejects the run.
|
|
837
|
+
eff = eff.next(outcome.value);
|
|
838
|
+
}
|
|
839
|
+
if (isOutcome(eff)) return eff;
|
|
840
|
+
throw effectTypeError(eff, 'The flow');
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* Runs a Retry's wrapped tree until it succeeds or runs out of attempts, then its fallback if it has
|
|
845
|
+
* one. Each attempt opens its own path prefix, and so does the fallback.
|
|
846
|
+
*
|
|
847
|
+
* @param {RetryState} retry
|
|
848
|
+
* @param {AbortSignal | undefined} signal
|
|
849
|
+
* @param {string} stepPath
|
|
850
|
+
* @returns {Promise<SuccessState | FailureState | IoFaultState>}
|
|
851
|
+
*/
|
|
852
|
+
async function runRetry(retry, signal, stepPath) {
|
|
853
|
+
// An option set to `undefined` keeps its default, since that is how an absent config key arrives.
|
|
854
|
+
const given = Object.entries(retry.options ?? {}).filter(([, value]) => value !== undefined);
|
|
855
|
+
const opts = /** @type {typeof defaultRetryOptions & RetryState['options']} */ ({
|
|
856
|
+
...defaultRetryOptions,
|
|
857
|
+
...Object.fromEntries(given)
|
|
858
|
+
});
|
|
859
|
+
const { attempts, onExhausted } = opts;
|
|
860
|
+
// `0` is refused rather than meaning run once: it would make `onExhausted` a free catch.
|
|
861
|
+
if (!isPositiveInteger(attempts))
|
|
862
|
+
throw new TypeError(
|
|
863
|
+
`Retry 'attempts' must be a positive integer, received ${describeValue(attempts)}. ` +
|
|
864
|
+
`To handle an outcome without retrying, branch on it as data in the Command's ` +
|
|
865
|
+
`next, or isolate a failing branch with Parallel's settled option.`
|
|
866
|
+
);
|
|
867
|
+
// A wait that is NaN, negative or infinite used to be no wait at all, so a flapping dependency was
|
|
868
|
+
// called back to back.
|
|
869
|
+
for (const key of /** @type {const} */ (['delay', 'backoff'])) {
|
|
870
|
+
if (!isFiniteNonNegative(opts[key]))
|
|
871
|
+
throw new TypeError(
|
|
872
|
+
`Retry '${key}' must be a finite number of 0 or more, received ${describeValue(opts[key])}.`
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
let lastError;
|
|
876
|
+
for (let attempt = 0; attempt <= attempts; attempt++) {
|
|
877
|
+
// Under `fastRetry` this waits for no time rather than skipping the wait, so branches replayed by
|
|
878
|
+
// timing still interleave as they do with a delay.
|
|
879
|
+
if (attempt > 0) {
|
|
880
|
+
await delayFor(fastRetry ? 0 : opts.delay * Math.pow(opts.backoff, attempt - 1), signal);
|
|
635
881
|
}
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
882
|
+
// After the wait, so a branch cancelled mid-backoff makes no further attempt.
|
|
883
|
+
if (signal?.aborted) return cancelledBranch(retry.initialInput);
|
|
884
|
+
const result = await execute(retry.effect, signal, `${stepPath}r${attempt}/`);
|
|
885
|
+
// A Success feeds `next`. An abort is the flow deciding, not the I/O failing: not retried, not
|
|
886
|
+
// wrapped.
|
|
887
|
+
if (result.type !== 'IoFault') return result;
|
|
888
|
+
lastError = result.error;
|
|
889
|
+
}
|
|
890
|
+
const exhausted = { retryExhausted: true, lastError, attempts };
|
|
891
|
+
// An exhaustion is a fault too, which keeps an enclosing Retry retrying this one.
|
|
892
|
+
if (typeof onExhausted !== 'function') return IoFault(exhausted, retry.initialInput);
|
|
893
|
+
// A cancelled branch starts no fallback, as it starts no further Commands.
|
|
894
|
+
if (signal?.aborted) return cancelledBranch(retry.initialInput);
|
|
895
|
+
// A failing fallback propagates as it is, not wrapped as another exhaustion.
|
|
896
|
+
return execute(asEffect(onExhausted(exhausted), "Retry option 'onExhausted'"), signal, `${stepPath}rf/`);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/**
|
|
900
|
+
* Runs a Parallel as one step of its own, whose `op` runs the branches and returns the decision, so
|
|
901
|
+
* the decision is recorded and a replay can hand it back.
|
|
902
|
+
*
|
|
903
|
+
* @param {ParallelState} parallel
|
|
904
|
+
* @param {AbortSignal | undefined} signal
|
|
905
|
+
* @param {string} stepPath
|
|
906
|
+
* @returns {Promise<SuccessState | FailureState | IoFaultState>}
|
|
907
|
+
*/
|
|
908
|
+
async function runParallel(parallel, signal, stepPath) {
|
|
909
|
+
// Branch prefixes come from array index, never completion order, so two branches calling the same
|
|
910
|
+
// Command are told apart by position.
|
|
911
|
+
const branchPath = `${stepPath}p`;
|
|
912
|
+
const options = parallel.options ?? {};
|
|
913
|
+
const { limit, settled } = options;
|
|
914
|
+
if (limit !== undefined && !isPositiveInteger(limit))
|
|
915
|
+
throw new TypeError(`Parallel 'limit' must be a positive integer, received ${describeValue(limit)}.`);
|
|
916
|
+
// Cast rather than annotated, since only `op` assigns it.
|
|
917
|
+
let run = /** @type {BranchRun | undefined} */ (undefined);
|
|
918
|
+
const op = async (/** @type {any} */ recorded) => {
|
|
919
|
+
run = await runBranches(parallel.effects, options, signal, branchPath, recorded);
|
|
920
|
+
return run.decision;
|
|
921
|
+
};
|
|
922
|
+
try {
|
|
923
|
+
await localStepRunner('Parallel', 'Parallel', op, branchPath);
|
|
924
|
+
} catch (e) {
|
|
925
|
+
// A replay found this Parallel inside a branch production had already stopped.
|
|
926
|
+
if (hasMark(e, replayCut)) return cancelledBranch(parallel.initialInput);
|
|
927
|
+
throw e;
|
|
928
|
+
}
|
|
929
|
+
if (!run) {
|
|
930
|
+
throw new TypeError(
|
|
931
|
+
`An onStep hook returned without letting op run the Parallel at path '${branchPath}'. ` +
|
|
932
|
+
'A hook has to call op for a Parallel and pass on what it returns or throws, because op ' +
|
|
933
|
+
'runs its branches.'
|
|
934
|
+
);
|
|
935
|
+
}
|
|
936
|
+
const { results, decision, thrown } = run;
|
|
937
|
+
// Rethrown here, after the step has returned its decision, so the decision is recorded.
|
|
938
|
+
if (thrown) throw thrown.error;
|
|
939
|
+
// Settled hands `next` the outcomes as plain Success and Failure nodes, so the Parallel never fails
|
|
940
|
+
// on a branch's account.
|
|
941
|
+
if (settled) return Success(results.map(asOutcome));
|
|
942
|
+
const failure =
|
|
943
|
+
decision.cancelled && decision.branch !== null
|
|
944
|
+
? results[decision.branch]
|
|
945
|
+
: results.find((r) => r.type !== 'Success');
|
|
946
|
+
if (failure) return failure;
|
|
947
|
+
return Success(results.map((r) => /** @type {SuccessState} */ (r).value));
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/**
|
|
951
|
+
* Runs a Parallel's branches and decides which branch, if any, cancelled the others. In a live run
|
|
952
|
+
* timing decides it, which is why the decision is recorded. A recorded cancellation passed in by a
|
|
953
|
+
* replay is reproduced rather than recomputed: no branch cancels another, each runs its recorded steps
|
|
954
|
+
* and stops where its recording stops, and the recorded branch's failure is the result.
|
|
955
|
+
*
|
|
956
|
+
* @param {Effect[]} effects
|
|
957
|
+
* @param {ParallelOptions} options
|
|
958
|
+
* @param {AbortSignal | undefined} signal - The enclosing Parallel's cancellation, if any
|
|
959
|
+
* @param {string} branchPath
|
|
960
|
+
* @param {any} recorded - The recorded decision, when a replay supplies one
|
|
961
|
+
* @returns {Promise<BranchRun>}
|
|
962
|
+
*/
|
|
963
|
+
async function runBranches(effects, options, signal, branchPath, recorded) {
|
|
964
|
+
const { limit, settled } = options;
|
|
965
|
+
// A recorded branch past the end means the flow changed shape since the recording. A negative one is
|
|
966
|
+
// not a decision, so it replays by timing like anything else.
|
|
967
|
+
const recordedBranch = recorded?.cancelled === true ? recorded.branch : undefined;
|
|
968
|
+
const pastTheEnd = Number.isInteger(recordedBranch) && recordedBranch >= effects.length;
|
|
969
|
+
if (pastTheEnd) {
|
|
970
|
+
const count = effects.length === 1 ? '1 branch' : `${effects.length} branches`;
|
|
971
|
+
throw replayError(
|
|
972
|
+
`Time paradox at path '${branchPath}': the recorded run was cancelled by branch ` +
|
|
973
|
+
`${recordedBranch}, but this Parallel has ${count}.`,
|
|
974
|
+
{ name: 'TimeParadox', path: branchPath, branch: recordedBranch }
|
|
975
|
+
);
|
|
976
|
+
}
|
|
977
|
+
const forced = asDecision(recorded, effects.length);
|
|
978
|
+
const reproducing = forced !== undefined && forced.cancelled;
|
|
979
|
+
// Live, a throw cancels the others, and so does a failure unless `settled`; the run rejects on a throw
|
|
980
|
+
// either way, and an enclosing Parallel can still cancel this one. Reproducing, nothing cancels, since
|
|
981
|
+
// the recording already says where each branch stops.
|
|
982
|
+
const { results, thrown, trigger, cancelled } = await settleBranches(
|
|
983
|
+
effects,
|
|
984
|
+
limit,
|
|
985
|
+
signal,
|
|
986
|
+
branchPath,
|
|
987
|
+
reproducing ? () => false : (threw, result) => threw || (!settled && result.type !== 'Success')
|
|
988
|
+
);
|
|
989
|
+
if (reproducing) {
|
|
990
|
+
// A branch that threw has no result to check, and its throw is what is rethrown.
|
|
991
|
+
const recordedTriggerSucceeded =
|
|
992
|
+
!thrown && forced.branch !== null && results[forced.branch].type === 'Success';
|
|
993
|
+
if (recordedTriggerSucceeded) {
|
|
994
|
+
throw replayError(
|
|
995
|
+
`Time paradox at path '${branchPath}': the recorded run was cancelled by ` +
|
|
996
|
+
`branch ${forced.branch}, which did not fail in this replay.`,
|
|
997
|
+
{ name: 'TimeParadox', path: branchPath, branch: forced.branch }
|
|
998
|
+
);
|
|
680
999
|
}
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
1000
|
+
return { results, decision: forced, thrown };
|
|
1001
|
+
}
|
|
1002
|
+
/** @type {ParallelDecision} */
|
|
1003
|
+
const decision =
|
|
1004
|
+
trigger >= 0
|
|
1005
|
+
? { cancelled: true, branch: trigger }
|
|
1006
|
+
: cancelled
|
|
1007
|
+
? { cancelled: true, branch: null }
|
|
1008
|
+
: { cancelled: false };
|
|
1009
|
+
return { results, decision, thrown };
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* Runs every branch to completion, at most `limit` at once, under one cancellation scope linked to the
|
|
1014
|
+
* enclosing one. A branch that settles in a way `cancelsOthers` accepts cancels the rest, and the first
|
|
1015
|
+
* to do so is the trigger.
|
|
1016
|
+
*
|
|
1017
|
+
* @param {Effect[]} effects
|
|
1018
|
+
* @param {number | undefined} limit
|
|
1019
|
+
* @param {AbortSignal | undefined} signal
|
|
1020
|
+
* @param {string} branchPath
|
|
1021
|
+
* @param {(threw: boolean, result: SuccessState | FailureState | IoFaultState) => boolean} cancelsOthers
|
|
1022
|
+
* @returns {Promise<{
|
|
1023
|
+
* results: (SuccessState | FailureState | IoFaultState)[],
|
|
1024
|
+
* thrown: { error: unknown } | undefined,
|
|
1025
|
+
* trigger: number,
|
|
1026
|
+
* cancelled: boolean
|
|
1027
|
+
* }>}
|
|
1028
|
+
*/
|
|
1029
|
+
async function settleBranches(effects, limit, signal, branchPath, cancelsOthers) {
|
|
1030
|
+
const { scope, unlink } = linkedScope(signal);
|
|
1031
|
+
/** @type {(SuccessState | FailureState | IoFaultState)[]} */
|
|
1032
|
+
const results = new Array(effects.length);
|
|
1033
|
+
// Held rather than rethrown, so every branch still settles and no `limit` worker stops early.
|
|
1034
|
+
/** @type {{ error: unknown }[]} */
|
|
1035
|
+
const thrown = new Array(effects.length);
|
|
1036
|
+
// Which branch cancelled the others on its own account rather than because it was cancelled.
|
|
1037
|
+
const triggered = new Array(effects.length).fill(false);
|
|
1038
|
+
const settle = async (/** @type {Effect} */ branch, /** @type {number} */ i) => {
|
|
688
1039
|
try {
|
|
689
|
-
await
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
1040
|
+
results[i] = await execute(branch, scope?.signal, `${branchPath}${i}/`);
|
|
1041
|
+
} catch (error) {
|
|
1042
|
+
thrown[i] = { error };
|
|
1043
|
+
}
|
|
1044
|
+
// Read-then-abort is atomic here, so exactly one branch is the trigger.
|
|
1045
|
+
if (cancelsOthers(thrown[i] !== undefined, results[i])) {
|
|
1046
|
+
if (!scope?.signal.aborted) triggered[i] = true;
|
|
1047
|
+
scope?.abort();
|
|
696
1048
|
}
|
|
1049
|
+
};
|
|
1050
|
+
try {
|
|
1051
|
+
// Awaits every branch, so no cancelled work runs on unobserved after the Parallel returns.
|
|
1052
|
+
await runBounded(
|
|
1053
|
+
effects.map((branch, i) => () => settle(branch, i)),
|
|
1054
|
+
limit
|
|
1055
|
+
);
|
|
1056
|
+
} finally {
|
|
1057
|
+
unlink();
|
|
697
1058
|
}
|
|
698
|
-
|
|
699
|
-
|
|
1059
|
+
return {
|
|
1060
|
+
results,
|
|
1061
|
+
// The first by array order, since every branch has settled by now.
|
|
1062
|
+
thrown: thrown.find(Boolean),
|
|
1063
|
+
trigger: triggered.indexOf(true),
|
|
1064
|
+
cancelled: Boolean(scope?.signal.aborted)
|
|
1065
|
+
};
|
|
700
1066
|
}
|
|
701
1067
|
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
1068
|
+
/**
|
|
1069
|
+
* Runs one Command. A throw means something different in each region: an interceptor throwing vetoes
|
|
1070
|
+
* the Command, which is an abort, and the Command's function throwing is an I/O fault. A harness error
|
|
1071
|
+
* is rethrown from either.
|
|
1072
|
+
*
|
|
1073
|
+
* @param {CommandState} command
|
|
1074
|
+
* @param {AbortSignal | undefined} signal
|
|
1075
|
+
* @param {string} cmdPath
|
|
1076
|
+
* @returns {Promise<SuccessState | FailureState | IoFaultState>}
|
|
1077
|
+
*/
|
|
1078
|
+
async function runCommand(command, signal, cmdPath) {
|
|
1079
|
+
const cmdName = commandName(command);
|
|
1080
|
+
const { initialInput, cmd } = command;
|
|
1081
|
+
// Whether the function itself succeeded: a hook throwing after it did is a bug, and retrying would
|
|
1082
|
+
// repeat work already done.
|
|
1083
|
+
let succeeded = false;
|
|
1084
|
+
// What it returned, so a hook that loses it is caught.
|
|
1085
|
+
/** @type {unknown} */
|
|
1086
|
+
let value;
|
|
1087
|
+
// The signal is passed only inside a Parallel. There it is still the first argument, so a function
|
|
1088
|
+
// passed by name with an optional first parameter, `nanoid(size = 21)` say, takes the signal for it:
|
|
1089
|
+
// a documented sharp edge. Async, so a hook always gets a promise, even from a synchronous function.
|
|
1090
|
+
const op = async () => {
|
|
1091
|
+
succeeded = false;
|
|
1092
|
+
value = await (signal ? cmd(signal) : cmd());
|
|
1093
|
+
succeeded = true;
|
|
1094
|
+
return value;
|
|
1095
|
+
};
|
|
1096
|
+
try {
|
|
1097
|
+
await localCommandInterceptor(command, context);
|
|
1098
|
+
} catch (e) {
|
|
1099
|
+
if (hasMark(e, harnessError)) throw e;
|
|
1100
|
+
return Failure(e, initialInput);
|
|
1101
|
+
}
|
|
1102
|
+
// Again, since an interceptor can wait (a rate limiter, say) while a sibling fails.
|
|
1103
|
+
if (signal?.aborted) return cancelledBranch(initialInput);
|
|
1104
|
+
let returned;
|
|
1105
|
+
try {
|
|
1106
|
+
returned = await localStepRunner(cmdName, 'Command', op, cmdPath);
|
|
1107
|
+
} catch (e) {
|
|
1108
|
+
// A step production never ran, in a branch a Parallel cancelled: stop here, as production did.
|
|
1109
|
+
if (hasMark(e, replayCut)) return cancelledBranch(initialInput);
|
|
1110
|
+
if (hasMark(e, harnessError)) throw e;
|
|
1111
|
+
// A hook threw after the function returned, which is a bug in the hook. A hook that throws
|
|
1112
|
+
// without calling `op` is still a fault, since that is how replay reports a recorded error.
|
|
1113
|
+
if (succeeded) throw e;
|
|
1114
|
+
return IoFault(e, initialInput);
|
|
1115
|
+
}
|
|
1116
|
+
// A hook that awaited `op` and forgot to return its result: `next` would take the branch for a Command
|
|
1117
|
+
// that returned nothing, while a recorder inside the hook kept the real value. Only `undefined` is
|
|
1118
|
+
// refused, so a hook that returns a copy of the result still works.
|
|
1119
|
+
if (returned === undefined && succeeded && value !== undefined) {
|
|
1120
|
+
throw new TypeError(
|
|
1121
|
+
`An onStep hook called op for '${cmdName}' at path '${cmdPath}' and returned undefined, although ` +
|
|
1122
|
+
'the Command returned a value. A hook has to return what op returns.'
|
|
1123
|
+
);
|
|
1124
|
+
}
|
|
1125
|
+
return Success(returned);
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
// Every outcome leaves through here, so this is where a Failure gets the root's input: one from a
|
|
1129
|
+
// Parallel branch or a Retry fallback carries its own subtree's input, which `chain` never reaches.
|
|
1130
|
+
// An I/O fault becomes a plain Failure here too.
|
|
707
1131
|
const rootInput = effect?.initialInput;
|
|
708
1132
|
const run = async () => {
|
|
709
|
-
const result = await execute(effect);
|
|
1133
|
+
const result = asOutcome(await execute(effect));
|
|
710
1134
|
return result.type === 'Failure' && rootInput !== undefined ? Failure(result.error, rootInput) : result;
|
|
711
1135
|
};
|
|
712
1136
|
return localRunWrapper(effect, run, context?.flowName || '');
|
|
713
1137
|
};
|
|
714
1138
|
|
|
1139
|
+
/**
|
|
1140
|
+
* Runs a flow: executes its Commands, resolving `Ask` with the context and running `Retry` and `Parallel`.
|
|
1141
|
+
* onRun fires exactly once per call; Retry attempts run inside that single span.
|
|
1142
|
+
*
|
|
1143
|
+
* @param {Effect} effect - The Effect tree returned by a pipeline
|
|
1144
|
+
* @param {any} [context] - Optional context object. Passed to Ask continuations and the Command Interceptor.
|
|
1145
|
+
* @param {CallConfiguration} [callConfig] - Per-call configuration, added to the wiring `configureEffect`
|
|
1146
|
+
* installed unless `inherit: false`, which ignores that wiring for this run.
|
|
1147
|
+
* @returns {Promise<SuccessState | FailureState>}
|
|
1148
|
+
*/
|
|
1149
|
+
const runEffect = (effect, context, callConfig) => interpret(effect, context, callConfig);
|
|
1150
|
+
|
|
715
1151
|
/**
|
|
716
1152
|
* The step a replay is asking about. `path` is the Command's position in the Effect tree and is stable
|
|
717
1153
|
* across runs; `index` is its position in this run's completion order, which is not stable for a flow
|
|
718
|
-
* containing `Parallel`. Prefer `path` when writing a Resolver.
|
|
1154
|
+
* containing `Parallel`. Prefer `path` when writing a Resolver. A step whose `type` is 'Parallel' asks
|
|
1155
|
+
* for a Parallel's recorded decision; it does not advance `index`, and anything but a decision replays
|
|
1156
|
+
* that Parallel under timing.
|
|
719
1157
|
* @typedef {{ index: number, name: string, type: string, path?: string }} ReplayStep
|
|
720
1158
|
*/
|
|
721
1159
|
|
|
@@ -730,11 +1168,12 @@ const runEffect =
|
|
|
730
1168
|
/** @typedef {(step: ReplayStep) => ReplayOutcome | undefined} Resolver */
|
|
731
1169
|
|
|
732
1170
|
/**
|
|
733
|
-
* A recorded step
|
|
734
|
-
*
|
|
735
|
-
*
|
|
736
|
-
*
|
|
737
|
-
*
|
|
1171
|
+
* A recorded step: a Command's result or error, or a Parallel's decision, recorded as `command`
|
|
1172
|
+
* 'Parallel' at the Parallel's own path with the decision as its `result`. `durationMs` is how long
|
|
1173
|
+
* the step took in production, rounded to microseconds. `path` is what a replay matches on.
|
|
1174
|
+
* `threw` marks a step that threw. The `error` key alone cannot, since JSON drops it when the value is
|
|
1175
|
+
* `undefined`, as it is for `reject()` with no argument or an error `redact` removed.
|
|
1176
|
+
* @typedef {{ command: string, path?: string, result?: any, threw?: true, error?: any, durationMs?: number }} TraceEntry
|
|
738
1177
|
*/
|
|
739
1178
|
|
|
740
1179
|
/**
|
|
@@ -751,13 +1190,27 @@ const runEffect =
|
|
|
751
1190
|
*/
|
|
752
1191
|
|
|
753
1192
|
/**
|
|
754
|
-
*
|
|
755
|
-
*
|
|
1193
|
+
* A replay fault: the trace cannot answer the flow, or disagrees with it. A harness error while the flow
|
|
1194
|
+
* runs, so nothing inside the flow can swallow it; `replayEffect` turns it into a `Failure` at its own
|
|
1195
|
+
* boundary. An `EffectTypeError` carries no replay mark and keeps propagating, since a malformed flow is
|
|
1196
|
+
* a bug in the flow rather than a problem with the trace.
|
|
1197
|
+
*/
|
|
1198
|
+
const replayFault = Symbol('pure-effect.replayFault');
|
|
1199
|
+
|
|
1200
|
+
/**
|
|
1201
|
+
* Creates a replay error, which `replayEffect` turns into a Failure at its own boundary.
|
|
756
1202
|
* @param {string} message - Human-readable reason
|
|
757
1203
|
* @param {Object} [props] - Extra fields such as `name`, `index`, `expected`, `actual`
|
|
758
1204
|
* @returns {Error}
|
|
759
1205
|
*/
|
|
760
|
-
const replayError = (message, props = {}) =>
|
|
1206
|
+
const replayError = (message, props = {}) =>
|
|
1207
|
+
Object.defineProperty(
|
|
1208
|
+
asHarnessError(Object.assign(new Error(message), { name: 'ReplayError' }, props)),
|
|
1209
|
+
replayFault,
|
|
1210
|
+
{
|
|
1211
|
+
value: true
|
|
1212
|
+
}
|
|
1213
|
+
);
|
|
761
1214
|
|
|
762
1215
|
/**
|
|
763
1216
|
* Signals that the flow being replayed asked for a different Command than the trace
|
|
@@ -785,19 +1238,41 @@ const timeParadox = (step, recorded) =>
|
|
|
785
1238
|
*
|
|
786
1239
|
* @param {any} e - The thrown value
|
|
787
1240
|
* @param {boolean} [withStack] - Include the stack (off by default: noisy, leaks paths)
|
|
1241
|
+
* @param {Set<Error>} [ancestors] - The errors whose `cause` or `errors` led here
|
|
788
1242
|
* @returns {any}
|
|
789
1243
|
*/
|
|
790
|
-
const serializeError = (e, withStack) => {
|
|
1244
|
+
const serializeError = (e, withStack, ancestors = new Set()) => {
|
|
791
1245
|
if (!(e instanceof Error)) return e;
|
|
792
1246
|
/** @type {any} */
|
|
793
1247
|
const out = { __error: true, name: e.name, message: e.message };
|
|
1248
|
+
// A chain that comes back to an error it passed through is cut there, with the name and message only:
|
|
1249
|
+
// following it overflowed the stack, and the recorder dropped the whole step. Only ancestors count, so an
|
|
1250
|
+
// error that merely appears twice is carried in full both times.
|
|
1251
|
+
if (ancestors.has(e)) return out;
|
|
1252
|
+
ancestors.add(e);
|
|
794
1253
|
if (withStack) out.stack = e.stack;
|
|
795
1254
|
// `cause` is non-enumerable too, and it can itself be an Error, so it is carried recursively.
|
|
796
|
-
if ('cause' in e) out.cause = serializeError(e.cause, withStack);
|
|
797
|
-
|
|
1255
|
+
if ('cause' in e) out.cause = serializeError(e.cause, withStack, ancestors);
|
|
1256
|
+
// So is an AggregateError's `errors`, which is where its detail lives. It goes under its own key so
|
|
1257
|
+
// revival can tell it from an enumerable `errors`, which is data and is copied below as it is.
|
|
1258
|
+
const errors = /** @type {any} */ (e).errors;
|
|
1259
|
+
const hasHiddenErrors = Array.isArray(errors) && !Object.prototype.propertyIsEnumerable.call(e, 'errors');
|
|
1260
|
+
if (hasHiddenErrors) {
|
|
1261
|
+
out.__errors = errors.map((x) => serializeError(x, withStack, ancestors));
|
|
1262
|
+
}
|
|
1263
|
+
ancestors.delete(e);
|
|
1264
|
+
// Assigning `name`, `message` or `cause`, as `e.name = 'TimeoutError'` or `e.cause = inner` does, makes it
|
|
1265
|
+
// enumerable. It is carried above already, so it is listed for revival to restore as it was rather than
|
|
1266
|
+
// copied again, where a raw cause would replace the serialized one and reach JSON as {}.
|
|
1267
|
+
const shown = carriedKeys.filter((k) => Object.prototype.propertyIsEnumerable.call(e, k));
|
|
1268
|
+
if (shown.length > 0) out.__enumerable = shown;
|
|
1269
|
+
for (const k of Object.keys(e)) if (!carriedKeys.includes(k)) out[k] = /** @type {any} */ (e)[k];
|
|
798
1270
|
return out;
|
|
799
1271
|
};
|
|
800
1272
|
|
|
1273
|
+
/** The keys `serializeError` carries whatever their enumerability, which a native Error sets as hidden. */
|
|
1274
|
+
const carriedKeys = ['name', 'message', 'cause'];
|
|
1275
|
+
|
|
801
1276
|
/**
|
|
802
1277
|
* Rebuilds an Error from `serializeError` output. Non-Error values pass through, so a
|
|
803
1278
|
* Command that rejected with a string still replays as a string.
|
|
@@ -805,18 +1280,22 @@ const serializeError = (e, withStack) => {
|
|
|
805
1280
|
* @returns {any}
|
|
806
1281
|
*/
|
|
807
1282
|
const reviveError = (v) => {
|
|
808
|
-
if (!v ||
|
|
1283
|
+
if (!isObject(v) || v.__error !== true) return v;
|
|
809
1284
|
const e = new Error(v.message);
|
|
810
|
-
// `name` and `
|
|
811
|
-
//
|
|
812
|
-
//
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
1285
|
+
// `name`, `message`, `cause` and `errors` are defined as the original had them, so a revived error
|
|
1286
|
+
// deep-equals the one the Command threw: non-enumerable, as on a native Error, unless `__enumerable` lists
|
|
1287
|
+
// them. Every other key was enumerable on the original.
|
|
1288
|
+
const shown = Array.isArray(v.__enumerable) ? v.__enumerable : [];
|
|
1289
|
+
const restore = (/** @type {string} */ key, /** @type {any} */ value) =>
|
|
1290
|
+
Object.defineProperty(e, key, { enumerable: shown.includes(key), configurable: true, writable: true, value });
|
|
1291
|
+
restore('name', v.name);
|
|
1292
|
+
if (shown.includes('message')) restore('message', v.message);
|
|
1293
|
+
// The markers are dropped, and `name` and `message` are already set.
|
|
1294
|
+
const alreadyHandled = ['__error', '__enumerable', 'name', 'message'];
|
|
817
1295
|
for (const [k, val] of Object.entries(v)) {
|
|
818
|
-
if (k
|
|
819
|
-
if (k === 'cause')
|
|
1296
|
+
if (alreadyHandled.includes(k)) continue;
|
|
1297
|
+
if (k === 'cause') restore('cause', reviveError(val));
|
|
1298
|
+
else if (k === '__errors' && Array.isArray(val)) restore('errors', val.map(reviveError));
|
|
820
1299
|
else /** @type {any} */ (e)[k] = val;
|
|
821
1300
|
}
|
|
822
1301
|
return e;
|
|
@@ -828,7 +1307,8 @@ const reviveError = (v) => {
|
|
|
828
1307
|
* each Command's result, each serialized error, and the `initialInput` and `context` stored on the
|
|
829
1308
|
* trace itself. `kind` is `'result'`, `'error'`, `'initialInput'`, or `'context'`, and `name` is the
|
|
830
1309
|
* Command's name for the first two and the kind for the last two. It is the single place PII is kept
|
|
831
|
-
* out of a trace, so it has to see all four.
|
|
1310
|
+
* out of a trace, so it has to see all four. It is handed a copy, so changing the value in place
|
|
1311
|
+
* never reaches the run.
|
|
832
1312
|
* @property {number} [maxEntries] - Caps trace length; further steps are counted in `dropped`, not stored.
|
|
833
1313
|
* @property {boolean} [stack] - Records stack traces for thrown errors.
|
|
834
1314
|
*/
|
|
@@ -842,29 +1322,66 @@ const reviveError = (v) => {
|
|
|
842
1322
|
*/
|
|
843
1323
|
|
|
844
1324
|
/**
|
|
845
|
-
*
|
|
1325
|
+
* An array, a plain object, or an object with no prototype: the shapes `copyAround` rebuilds itself rather
|
|
1326
|
+
* than handing to `structuredClone`.
|
|
1327
|
+
* @param {object} value
|
|
1328
|
+
* @returns {boolean}
|
|
1329
|
+
*/
|
|
1330
|
+
const isPlainContainer = (value) => {
|
|
1331
|
+
if (Array.isArray(value)) return true;
|
|
1332
|
+
const proto = Object.getPrototypeOf(value);
|
|
1333
|
+
return proto === Object.prototype || proto === null;
|
|
1334
|
+
};
|
|
1335
|
+
|
|
1336
|
+
/**
|
|
1337
|
+
* Copies what `structuredClone` refused. Arrays and plain objects are rebuilt and everything inside them is
|
|
1338
|
+
* copied in turn, so only the parts that cannot be copied, such as a function or an object holding one, are
|
|
1339
|
+
* kept as they are. A context holding a logger is the usual case.
|
|
846
1340
|
*
|
|
847
|
-
*
|
|
848
|
-
*
|
|
849
|
-
*
|
|
850
|
-
|
|
1341
|
+
* @param {any} value
|
|
1342
|
+
* @param {Map<object, any>} seen - Copies made so far, so a cycle is copied as a cycle
|
|
1343
|
+
* @returns {any}
|
|
1344
|
+
*/
|
|
1345
|
+
const copyAround = (value, seen) => {
|
|
1346
|
+
if (!isObject(value)) return value;
|
|
1347
|
+
if (seen.has(value)) return seen.get(value);
|
|
1348
|
+
if (!isPlainContainer(value)) {
|
|
1349
|
+
try {
|
|
1350
|
+
return structuredClone(value);
|
|
1351
|
+
} catch {
|
|
1352
|
+
return value;
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
/** @type {any} */
|
|
1356
|
+
const copy = Array.isArray(value) ? [] : Object.create(Object.getPrototypeOf(value));
|
|
1357
|
+
seen.set(value, copy);
|
|
1358
|
+
for (const key of Object.keys(value)) copy[key] = copyAround(value[key], seen);
|
|
1359
|
+
return copy;
|
|
1360
|
+
};
|
|
1361
|
+
|
|
1362
|
+
/**
|
|
1363
|
+
* Snapshots a value on its way into a trace, and on its way out of one in a replay, so a later mutation
|
|
1364
|
+
* cannot rewrite what the trace says a step returned. The copy is also what `redact` is handed, so it has
|
|
1365
|
+
* to be one the flow never sees. A value that cannot be cloned whole is copied around the parts that
|
|
1366
|
+
* cannot be copied.
|
|
851
1367
|
*
|
|
852
1368
|
* @param {any} value
|
|
853
1369
|
* @returns {any}
|
|
854
1370
|
*/
|
|
855
1371
|
const snapshot = (value) => {
|
|
856
|
-
if (value
|
|
1372
|
+
if (!isObject(value)) return value;
|
|
857
1373
|
try {
|
|
858
1374
|
return typeof structuredClone === 'function' ? structuredClone(value) : JSON.parse(JSON.stringify(value));
|
|
859
1375
|
} catch {
|
|
860
|
-
return value;
|
|
1376
|
+
return copyAround(value, new Map());
|
|
861
1377
|
}
|
|
862
1378
|
};
|
|
863
1379
|
|
|
864
1380
|
/**
|
|
865
1381
|
* Builds an `onStep` hook that records what every Command returned, plus a packager
|
|
866
|
-
* for the reference trace format. Pass `onStep` to `runEffect` as per-call config
|
|
867
|
-
*
|
|
1382
|
+
* for the reference trace format. Pass `onStep` to `runEffect` as per-call config. A recorder holds the steps
|
|
1383
|
+
* of every run it sees, so installed with `configureEffect` for a whole application it mixes requests into one
|
|
1384
|
+
* trace with duplicate paths, which a replay refuses; examples/recording-example.js gives each run its own.
|
|
868
1385
|
*
|
|
869
1386
|
* @param {RecorderOptions} [options] - Redaction and size limits
|
|
870
1387
|
* @returns {{ onStep: StepRunner, entries: TraceEntry[], toTrace: (meta?: TraceMeta) => TraceLog }}
|
|
@@ -880,9 +1397,7 @@ const recorder = (options = {}) => {
|
|
|
880
1397
|
else dropped++;
|
|
881
1398
|
};
|
|
882
1399
|
|
|
883
|
-
|
|
884
|
-
* `redact` is the caller's code.
|
|
885
|
-
*/
|
|
1400
|
+
// `redact` is the caller's code, and a throw from it must not reach the run.
|
|
886
1401
|
const safeRedact = (/** @type {any} */ value, /** @type {string} */ name, /** @type {string} */ kind) => {
|
|
887
1402
|
try {
|
|
888
1403
|
return redact(value, name, kind);
|
|
@@ -892,29 +1407,34 @@ const recorder = (options = {}) => {
|
|
|
892
1407
|
};
|
|
893
1408
|
|
|
894
1409
|
/**
|
|
895
|
-
*
|
|
1410
|
+
* Snapshots one of the trace's own fields and redacts the copy. `undefined` is left alone so a flow with no
|
|
896
1411
|
* context does not acquire an empty object from a redact function that spreads its argument.
|
|
897
1412
|
*/
|
|
898
1413
|
const redactField = (/** @type {any} */ value, /** @type {string} */ kind) =>
|
|
899
|
-
value === undefined ? undefined : snapshot(
|
|
1414
|
+
value === undefined ? undefined : safeRedact(snapshot(value), kind, kind);
|
|
900
1415
|
|
|
901
|
-
|
|
902
|
-
// always returned, and its error always propagates.
|
|
903
|
-
const onStep = observeSteps(({ name, path }) => (end) => {
|
|
1416
|
+
const onStep = observeSteps(({ name, type, path }) => (end) => {
|
|
904
1417
|
const durationMs = Math.round(end.durationMs * 1000) / 1000;
|
|
1418
|
+
// A Parallel's result is its decision, which holds no user data and must survive intact for a replay
|
|
1419
|
+
// to reproduce it, so it is not redacted.
|
|
1420
|
+
const recorded = (/** @type {any} */ result) =>
|
|
1421
|
+
type === 'Parallel' ? snapshot(result) : safeRedact(snapshot(result), name, 'result');
|
|
905
1422
|
push(
|
|
906
1423
|
'error' in end
|
|
907
1424
|
? {
|
|
908
1425
|
command: name,
|
|
909
1426
|
path,
|
|
910
|
-
|
|
1427
|
+
threw: true,
|
|
1428
|
+
error: safeRedact(snapshot(serializeError(end.error, stack)), name, 'error'),
|
|
911
1429
|
durationMs
|
|
912
1430
|
}
|
|
913
|
-
: { command: name, path, result:
|
|
1431
|
+
: { command: name, path, result: recorded(end.result), durationMs }
|
|
914
1432
|
);
|
|
915
1433
|
});
|
|
916
1434
|
|
|
917
1435
|
/**
|
|
1436
|
+
* Redacts and copies `meta.initialInput` and `meta.context` when it is called, so call it before the run
|
|
1437
|
+
* when the run can change them, and take `dropped` and `trace` from a second call once it ends.
|
|
918
1438
|
* @param {TraceMeta} [meta]
|
|
919
1439
|
* @returns {TraceLog}
|
|
920
1440
|
*/
|
|
@@ -932,8 +1452,8 @@ const recorder = (options = {}) => {
|
|
|
932
1452
|
|
|
933
1453
|
/**
|
|
934
1454
|
* Runs a flow for real while recording every Command result, and returns both the
|
|
935
|
-
* outcome and a replayable trace. Convenient in tests and scripts;
|
|
936
|
-
*
|
|
1455
|
+
* outcome and a replayable trace. Convenient in tests and scripts; to record an application without
|
|
1456
|
+
* changing call sites, give each run its own recorder, as examples/recording-example.js does.
|
|
937
1457
|
*
|
|
938
1458
|
* @param {(input: any) => Effect} flowFn - Builds the Effect tree from its input
|
|
939
1459
|
* @param {any} initialInput - The value the flow is called with; stored so a replay can rebuild it
|
|
@@ -944,43 +1464,74 @@ const recorder = (options = {}) => {
|
|
|
944
1464
|
const recordEffect = async (flowFn, initialInput, options = {}) => {
|
|
945
1465
|
const { context = {}, version, ...recorderOptions } = options;
|
|
946
1466
|
const rec = recorder(recorderOptions);
|
|
947
|
-
//
|
|
948
|
-
//
|
|
1467
|
+
// Packaged before the run, so a Command that writes to the input or the context, as an ORM save
|
|
1468
|
+
// assigning an id does, cannot rewrite what the trace says the run received.
|
|
1469
|
+
const head = rec.toTrace({ initialInput, flowName: context.flowName, context, version });
|
|
1470
|
+
// Merged over the global wiring, so recording inside an instrumented application keeps its spans.
|
|
949
1471
|
const result = await runEffect(flowFn(initialInput), context, { onStep: rec.onStep });
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
trace: rec.toTrace({ initialInput, flowName: context.flowName, context, version })
|
|
953
|
-
};
|
|
1472
|
+
const { dropped, trace } = rec.toTrace();
|
|
1473
|
+
return { result, trace: { ...head, dropped, trace } };
|
|
954
1474
|
};
|
|
955
1475
|
|
|
956
1476
|
/**
|
|
957
|
-
*
|
|
1477
|
+
* Whether a recorded entry says its step threw: `threw`, or an `error` key, which is how older and
|
|
1478
|
+
* hand-written traces say it.
|
|
1479
|
+
* @param {TraceEntry} entry
|
|
1480
|
+
* @returns {boolean}
|
|
1481
|
+
*/
|
|
1482
|
+
const entryThrew = (entry) => entry.threw === true || 'error' in entry;
|
|
1483
|
+
|
|
1484
|
+
/**
|
|
1485
|
+
* Whether a trace entry or a replay step carries a path.
|
|
1486
|
+
* @param {{ path?: string }} step
|
|
1487
|
+
* @returns {boolean}
|
|
1488
|
+
*/
|
|
1489
|
+
const hasPath = (step) => typeof step.path === 'string';
|
|
1490
|
+
|
|
1491
|
+
/**
|
|
1492
|
+
* Whether a path lies inside a Parallel branch, which a `p` followed by the branch number marks.
|
|
1493
|
+
* @param {string | undefined} path
|
|
1494
|
+
* @returns {boolean}
|
|
1495
|
+
*/
|
|
1496
|
+
const isInsideParallel = (path) => typeof path === 'string' && /p\d+\//.test(path);
|
|
1497
|
+
|
|
1498
|
+
/**
|
|
1499
|
+
* Turns a recorded entry into the outcome a Resolver must return, snapshotted so a replayed step that
|
|
1500
|
+
* mutates its result cannot rewrite the trace.
|
|
958
1501
|
*
|
|
959
1502
|
* @param {TraceEntry} entry
|
|
960
1503
|
* @returns {ReplayOutcome}
|
|
961
1504
|
*/
|
|
962
|
-
const entryToOutcome = (entry) =>
|
|
1505
|
+
const entryToOutcome = (entry) =>
|
|
1506
|
+
entryThrew(entry) ? { error: reviveError(snapshot(entry.error)) } : { result: snapshot(entry.result) };
|
|
963
1507
|
|
|
964
1508
|
/**
|
|
965
|
-
*
|
|
966
|
-
*
|
|
967
|
-
*
|
|
1509
|
+
* Whether an entry is a Parallel's recorded decision rather than a Command's result. A Parallel's path
|
|
1510
|
+
* ends in its `p` marker and a Command's in its step number, so a Command that happens to be named
|
|
1511
|
+
* 'Parallel' is never mistaken for one.
|
|
1512
|
+
* @param {TraceEntry} entry
|
|
1513
|
+
* @returns {boolean}
|
|
1514
|
+
*/
|
|
1515
|
+
const isDecisionEntry = (entry) =>
|
|
1516
|
+
entry.command === 'Parallel' && typeof entry.path === 'string' && entry.path.endsWith('p');
|
|
1517
|
+
|
|
1518
|
+
/**
|
|
1519
|
+
* Builds a Resolver for the reference trace format. Internal: `replayEffect` is the only caller, and a
|
|
1520
|
+
* caller with traces in another shape writes a Resolver instead.
|
|
968
1521
|
*
|
|
969
|
-
*
|
|
970
|
-
*
|
|
971
|
-
*
|
|
972
|
-
*
|
|
1522
|
+
* When every entry carries a `path`, steps are matched by path, which is order-independent and still
|
|
1523
|
+
* detects a paradox: two `Parallel` branches calling the same Command are told apart by position. A
|
|
1524
|
+
* trace without paths is matched positionally, each entry checked against the Command the flow asks
|
|
1525
|
+
* for, which is exact for sequential flows and cannot tell `Parallel` branches apart.
|
|
973
1526
|
*
|
|
974
|
-
* A trace
|
|
975
|
-
*
|
|
976
|
-
* flow asks for. That is exact for sequential flows and cannot tell `Parallel` branches apart, which
|
|
977
|
-
* is what paths were added to fix.
|
|
1527
|
+
* A step the trace does not hold resolves to `undefined`, as from a Resolver, so `replayEffect` applies
|
|
1528
|
+
* `onMissing` to both; `missing` describes the step for the error.
|
|
978
1529
|
*
|
|
979
1530
|
* @param {TraceLog | TraceEntry[]} traceLog - A reference-format trace, or a bare array of entries
|
|
980
1531
|
* @param {Object} [options]
|
|
981
1532
|
* @param {(entry: TraceEntry) => void} [options.onEntry] - Observes each entry as it is handed to a step,
|
|
982
1533
|
* which is how `replayEffect` learns which recorded entries the flow never asked for.
|
|
983
|
-
* @returns {Resolver}
|
|
1534
|
+
* @returns {{ resolve: Resolver, missing: (step: ReplayStep) => string }}
|
|
984
1535
|
*/
|
|
985
1536
|
const fromTrace = (traceLog, options = {}) => {
|
|
986
1537
|
const { onEntry } = options;
|
|
@@ -991,97 +1542,77 @@ const fromTrace = (traceLog, options = {}) => {
|
|
|
991
1542
|
return entryToOutcome(entry);
|
|
992
1543
|
};
|
|
993
1544
|
|
|
994
|
-
if (entries.length > 0 && entries.every(
|
|
1545
|
+
if (entries.length > 0 && entries.every(hasPath)) {
|
|
995
1546
|
const byPath = new Map(entries.map((e) => [e.path, e]));
|
|
996
|
-
// Paths are
|
|
997
|
-
// either
|
|
998
|
-
// would reintroduce exactly the wrong-result-per-branch failure paths exist to prevent.
|
|
1547
|
+
// Paths are unique by construction, so a collision means a hand-built trace or a bug, and keeping
|
|
1548
|
+
// either entry would hand a branch the wrong result.
|
|
999
1549
|
if (byPath.size !== entries.length) {
|
|
1000
1550
|
throw replayError('Trace has duplicate step paths.');
|
|
1001
1551
|
}
|
|
1002
|
-
|
|
1552
|
+
/** Recorded Parallel decisions that cancelled branches, by the Parallel's path. */
|
|
1553
|
+
const cancellations = entries.filter(isDecisionEntry).filter((e) => e.result?.cancelled === true);
|
|
1554
|
+
// A missing step is where production stopped a branch when it lies in a branch a recorded decision
|
|
1555
|
+
// cancelled: any branch but the cancelling one, or every branch of a Parallel cancelled from outside.
|
|
1556
|
+
const stoppedInProduction = (/** @type {string | undefined} */ stepPath) =>
|
|
1557
|
+
typeof stepPath === 'string' &&
|
|
1558
|
+
cancellations.some((e) => {
|
|
1559
|
+
const at = /** @type {string} */ (e.path);
|
|
1560
|
+
const branch = stepPath.startsWith(at) ? /^(\d+)\//.exec(stepPath.slice(at.length)) : null;
|
|
1561
|
+
return branch !== null && (e.result.branch === null || Number(branch[1]) !== e.result.branch);
|
|
1562
|
+
});
|
|
1563
|
+
/** @type {Resolver} */
|
|
1564
|
+
const resolve = (step) => {
|
|
1003
1565
|
const entry = byPath.get(step.path);
|
|
1566
|
+
if (step.type === 'Parallel') {
|
|
1567
|
+
// A decision rather than I/O. With none recorded, the Parallel replays under timing.
|
|
1568
|
+
if (entry && entry.command !== 'Parallel') throw timeParadox(step, entry.command);
|
|
1569
|
+
if (entry) return resolveEntry(entry);
|
|
1570
|
+
if (stoppedInProduction(step.path)) throw replayCutError(/** @type {string} */ (step.path));
|
|
1571
|
+
return undefined;
|
|
1572
|
+
}
|
|
1004
1573
|
if (!entry) {
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
});
|
|
1574
|
+
// Production never ran this step, so it is not run live under `onMissing` either.
|
|
1575
|
+
if (stoppedInProduction(step.path)) throw replayCutError(/** @type {string} */ (step.path));
|
|
1576
|
+
return undefined;
|
|
1009
1577
|
}
|
|
1010
1578
|
if (entry.command !== step.name) throw timeParadox(step, entry.command);
|
|
1011
1579
|
return resolveEntry(entry);
|
|
1012
1580
|
};
|
|
1581
|
+
const missing = (/** @type {ReplayStep} */ step) =>
|
|
1582
|
+
`Trace has no step at path '${step.path}' for '${step.name}'`;
|
|
1583
|
+
return { resolve, missing };
|
|
1013
1584
|
}
|
|
1014
1585
|
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
//
|
|
1018
|
-
|
|
1019
|
-
|
|
1586
|
+
/** @type {Resolver} */
|
|
1587
|
+
const resolve = (step) => {
|
|
1588
|
+
// A trace with no paths predates recorded decisions, so a Parallel replays under timing.
|
|
1589
|
+
if (step.type === 'Parallel') return undefined;
|
|
1590
|
+
// Positional matching pairs steps by completion order, which cannot tell Parallel branches apart.
|
|
1591
|
+
// Refusing beats a result that is right only when the replay finishes in production's order.
|
|
1592
|
+
if (isInsideParallel(step.path)) {
|
|
1020
1593
|
throw replayError(
|
|
1021
1594
|
`Trace carries no paths, so '${step.name}' inside a Parallel cannot be matched positionally.`,
|
|
1022
1595
|
{ command: step.name, path: step.path }
|
|
1023
1596
|
);
|
|
1024
1597
|
}
|
|
1025
1598
|
const entry = entries[step.index];
|
|
1026
|
-
if (!entry)
|
|
1599
|
+
if (!entry) return undefined;
|
|
1027
1600
|
if (entry.command !== step.name) throw timeParadox(step, entry.command);
|
|
1028
1601
|
return resolveEntry(entry);
|
|
1029
1602
|
};
|
|
1030
|
-
}
|
|
1031
|
-
|
|
1032
|
-
/**
|
|
1033
|
-
* Rewrites `Retry` nodes to zero delay, lazily, through their `next` continuations.
|
|
1034
|
-
*
|
|
1035
|
-
* Needed because the interpreter merges per-use options over call config
|
|
1036
|
-
* (`{ ...localRetryDefaults, ...eff.options }`), so a `callConfig.retry` cannot
|
|
1037
|
-
* override a delay written at the call site. Without this, replaying a flow that
|
|
1038
|
-
* retried in production waits out the production backoff.
|
|
1039
|
-
*
|
|
1040
|
-
* @param {any} eff - Any Effect node
|
|
1041
|
-
* @returns {any} The same tree with Retry delays removed
|
|
1042
|
-
*/
|
|
1043
|
-
const zeroRetryDelays = (eff) => {
|
|
1044
|
-
if (!eff || typeof eff.type !== 'string') return eff;
|
|
1045
|
-
switch (eff.type) {
|
|
1046
|
-
case 'Retry': {
|
|
1047
|
-
const options = { ...eff.options, delay: 0, backoff: 1 };
|
|
1048
|
-
// The fallback tree only exists once onExhausted runs, so it is rewritten lazily too;
|
|
1049
|
-
// otherwise a Retry inside the fallback keeps its production backoff during replay.
|
|
1050
|
-
if (typeof options.onExhausted === 'function') {
|
|
1051
|
-
const original = options.onExhausted;
|
|
1052
|
-
options.onExhausted = (/** @type {any} */ error) => zeroRetryDelays(original(error));
|
|
1053
|
-
}
|
|
1054
|
-
return {
|
|
1055
|
-
...eff,
|
|
1056
|
-
options,
|
|
1057
|
-
effect: zeroRetryDelays(eff.effect),
|
|
1058
|
-
next: (/** @type {any} */ value) => zeroRetryDelays(eff.next(value))
|
|
1059
|
-
};
|
|
1060
|
-
}
|
|
1061
|
-
case 'Command':
|
|
1062
|
-
return { ...eff, next: (/** @type {any} */ result) => zeroRetryDelays(eff.next(result)) };
|
|
1063
|
-
case 'Ask':
|
|
1064
|
-
return { ...eff, next: (/** @type {any} */ ctx) => zeroRetryDelays(eff.next(ctx)) };
|
|
1065
|
-
case 'Parallel':
|
|
1066
|
-
return {
|
|
1067
|
-
...eff,
|
|
1068
|
-
effects: eff.effects.map(zeroRetryDelays),
|
|
1069
|
-
next: (/** @type {any} */ values) => zeroRetryDelays(eff.next(values))
|
|
1070
|
-
};
|
|
1071
|
-
default:
|
|
1072
|
-
return eff;
|
|
1073
|
-
}
|
|
1603
|
+
const missing = (/** @type {ReplayStep} */ step) => `Trace exhausted: no entry #${step.index} for '${step.name}'`;
|
|
1604
|
+
return { resolve, missing };
|
|
1074
1605
|
};
|
|
1075
1606
|
|
|
1076
1607
|
/**
|
|
1077
1608
|
* @typedef {Object} ReplayOptions
|
|
1078
|
-
* @property {any} [context] - Context for `Ask`;
|
|
1079
|
-
* @property {boolean} [fastRetry] -
|
|
1609
|
+
* @property {any} [context] - Context for `Ask`; defaults to the context a trace recorded.
|
|
1610
|
+
* @property {boolean} [fastRetry] - Waits no time between Retry attempts, so a replay does not wait out
|
|
1611
|
+
* production backoff. On by default.
|
|
1080
1612
|
* @property {boolean} [hooks] - Runs the replay inside the hooks `configureEffect` installed, with the
|
|
1081
1613
|
* resolver innermost, so a configured `onStep` observes each replayed step and `onRun` and
|
|
1082
1614
|
* `onBeforeCommand` fire. Off by default, which ignores the global hooks, so a replay cannot
|
|
1083
|
-
* reach a telemetry backend or a guardrail that performs I/O.
|
|
1084
|
-
* either way, since a replay has to make the attempts production made.
|
|
1615
|
+
* reach a telemetry backend or a guardrail that performs I/O.
|
|
1085
1616
|
* @property {'throw' | 'execute'} [onMissing] - What to do when the resolver has no recording for a step.
|
|
1086
1617
|
* `'throw'` (default) fails the replay, which makes side effects impossible for the whole run.
|
|
1087
1618
|
* `'execute'` runs the real Command, giving partial replay: recorded prefix, live tail.
|
|
@@ -1096,8 +1627,7 @@ const zeroRetryDelays = (eff) => {
|
|
|
1096
1627
|
* A flow that stops issuing Commands before its recording ends mismatches nothing, so no
|
|
1097
1628
|
* `TimeParadox` fires and `result` can be a `Success` with recorded steps left over. That is
|
|
1098
1629
|
* sometimes the point of a fix and sometimes a fix that quietly dropped a step; `unreached` is
|
|
1099
|
-
* the only place the difference shows
|
|
1100
|
-
* behind an option.
|
|
1630
|
+
* the only place the difference shows.
|
|
1101
1631
|
* @typedef {{ result: SuccessState | FailureState, unreached?: TraceEntry[] }} Replay
|
|
1102
1632
|
*/
|
|
1103
1633
|
|
|
@@ -1105,7 +1635,7 @@ const zeroRetryDelays = (eff) => {
|
|
|
1105
1635
|
* Replays an Effect tree, feeding recorded results to Commands instead of running them.
|
|
1106
1636
|
*
|
|
1107
1637
|
* No side effect can occur by default. The interpreter's only execution point is
|
|
1108
|
-
* `await localStepRunner(cmdName, 'Command',
|
|
1638
|
+
* `await localStepRunner(cmdName, 'Command', op, cmdPath)`, which hands the Command thunk
|
|
1109
1639
|
* to `onStep` as `op` rather than calling it. The `onStep` installed here never invokes
|
|
1110
1640
|
* `op` unless `onMissing: 'execute'` is set, so `eff.cmd` is never applied and the I/O
|
|
1111
1641
|
* it describes does not happen.
|
|
@@ -1120,31 +1650,56 @@ const zeroRetryDelays = (eff) => {
|
|
|
1120
1650
|
* @param {ReplayOptions} [options]
|
|
1121
1651
|
* @returns {Promise<Replay>} `{ result, unreached }` for a trace, `{ result }` for a Resolver
|
|
1122
1652
|
*/
|
|
1123
|
-
// `async` so a malformed trace arrives as a rejection rather than a synchronous throw
|
|
1124
|
-
// the function otherwise returns a promise, and callers should not have to handle both.
|
|
1653
|
+
// `async` so a malformed trace arrives as a rejection rather than a synchronous throw.
|
|
1125
1654
|
const replayEffect = async (effect, traceOrResolver, options = {}) => {
|
|
1126
|
-
const {
|
|
1655
|
+
const { fastRetry = true, hooks = false, onMissing = 'throw', onResolved } = options;
|
|
1127
1656
|
// A trace is data and a Resolver is a function, so nothing else is needed to tell them
|
|
1128
1657
|
// apart, including the bare entries array that `fromTrace` also accepts.
|
|
1129
1658
|
const fromResolver = typeof traceOrResolver === 'function';
|
|
1130
1659
|
/** @type {Set<TraceEntry>} */
|
|
1131
1660
|
const reached = new Set();
|
|
1132
|
-
const resolve = fromResolver
|
|
1133
|
-
? traceOrResolver
|
|
1661
|
+
const { resolve, missing } = fromResolver
|
|
1662
|
+
? { resolve: traceOrResolver, missing: undefined }
|
|
1134
1663
|
: fromTrace(traceOrResolver, { onEntry: (entry) => void reached.add(entry) });
|
|
1664
|
+
// Defaults to the trace's context: an `Ask` gate replayed with another one takes another branch, and
|
|
1665
|
+
// no paradox flags it.
|
|
1666
|
+
const recorded = fromResolver || Array.isArray(traceOrResolver) ? undefined : traceOrResolver.context;
|
|
1667
|
+
const context = options.context ?? recorded ?? {};
|
|
1135
1668
|
let index = 0;
|
|
1669
|
+
// What `onResolved` threw. It stops the replay, which rejects with it, rather than counting as the
|
|
1670
|
+
// Command failing, which would let a Retry ask for an attempt production never made. Cast rather than
|
|
1671
|
+
// annotated, since only the step runner assigns it.
|
|
1672
|
+
let observerFailure = /** @type {{ error: unknown } | undefined} */ (undefined);
|
|
1136
1673
|
|
|
1137
1674
|
/** @type {StepRunner} */
|
|
1138
1675
|
const onStep = async (name, type, op, path) => {
|
|
1676
|
+
if (type === 'Parallel') {
|
|
1677
|
+
// A Parallel's step carries its recorded decision into `op`, which runs the branches under it.
|
|
1678
|
+
// It is not a Command: `index` still counts Commands, and `onResolved` still sees only them.
|
|
1679
|
+
const outcome = resolve({ index, name, type, path });
|
|
1680
|
+
return await op(outcome && 'result' in outcome ? outcome.result : undefined);
|
|
1681
|
+
}
|
|
1139
1682
|
const step = { index: index++, name, type, path };
|
|
1140
1683
|
const outcome = resolve(step);
|
|
1141
|
-
if (onResolved)
|
|
1684
|
+
if (onResolved) {
|
|
1685
|
+
try {
|
|
1686
|
+
onResolved(step, outcome);
|
|
1687
|
+
} catch (error) {
|
|
1688
|
+
observerFailure ??= { error };
|
|
1689
|
+
throw asHarnessError(new Error('onResolved threw.'));
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1142
1692
|
if (outcome === undefined) {
|
|
1143
1693
|
if (onMissing !== 'execute') {
|
|
1694
|
+
const what = missing ? missing(step) : `No recorded outcome for '${name}' at step ${step.index}`;
|
|
1695
|
+
// The option is named with its one safe use, since a step with no entry may be one production
|
|
1696
|
+
// never ran, and running it live would do I/O production refused.
|
|
1144
1697
|
throw replayError(
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1698
|
+
`${what}; refusing to run the real Command. Production may never have run it: a Command ` +
|
|
1699
|
+
'an onBeforeCommand hook vetoed leaves no entry, and neither does a step added since the ' +
|
|
1700
|
+
"recording. Pass onMissing: 'execute' to run unrecorded steps live only when the trace was " +
|
|
1701
|
+
'cut short, as by maxEntries.',
|
|
1702
|
+
{ command: name, index: step.index, path }
|
|
1148
1703
|
);
|
|
1149
1704
|
}
|
|
1150
1705
|
return await op();
|
|
@@ -1153,16 +1708,21 @@ const replayEffect = async (effect, traceOrResolver, options = {}) => {
|
|
|
1153
1708
|
return outcome.result;
|
|
1154
1709
|
};
|
|
1155
1710
|
|
|
1156
|
-
//
|
|
1157
|
-
//
|
|
1158
|
-
// observes each replayed step and the Command still never executes. The global retry defaults are
|
|
1159
|
-
// kept either way: `inherit: false` would drop them with the hooks, and a Retry that production ran
|
|
1160
|
-
// under a configured `attempts` would then exhaust early on replay and report a Failure production
|
|
1161
|
-
// never saw. Retry shape is part of what a replay reproduces; the hooks are not.
|
|
1711
|
+
// With `hooks` off the replay ignores the global wiring, so it reaches no telemetry backend or
|
|
1712
|
+
// guardrail. On, the resolver sits innermost and a configured onStep observes each replayed step.
|
|
1162
1713
|
/** @type {CallConfiguration} */
|
|
1163
|
-
const callConfig = { onStep, inherit: hooks
|
|
1714
|
+
const callConfig = { onStep, inherit: hooks };
|
|
1164
1715
|
|
|
1165
|
-
|
|
1716
|
+
// The interpreter rethrows a replay fault so nothing in the flow can absorb it; it becomes a Failure
|
|
1717
|
+
// here, where nothing downstream can.
|
|
1718
|
+
let result;
|
|
1719
|
+
try {
|
|
1720
|
+
result = await interpret(effect, context, callConfig, fastRetry);
|
|
1721
|
+
} catch (e) {
|
|
1722
|
+
if (observerFailure) throw observerFailure.error;
|
|
1723
|
+
if (!hasMark(e, replayFault)) throw e;
|
|
1724
|
+
result = Failure(e, effect.initialInput);
|
|
1725
|
+
}
|
|
1166
1726
|
if (fromResolver) return { result };
|
|
1167
1727
|
// `fromTrace` has already validated the shape, so the entries are here in one form or the other.
|
|
1168
1728
|
const entries = Array.isArray(traceOrResolver) ? traceOrResolver : traceOrResolver.trace;
|
|
@@ -1186,26 +1746,36 @@ const replayEffect = async (effect, traceOrResolver, options = {}) => {
|
|
|
1186
1746
|
const timeTravel = async (flowFn, traceLog, options = {}) => {
|
|
1187
1747
|
const { log = console.log, context, version } = options;
|
|
1188
1748
|
const { initialInput, trace, flowName, version: traceVersion } = traceLog;
|
|
1189
|
-
// `message`
|
|
1190
|
-
//
|
|
1191
|
-
const format = (/** @type {any} */ v) =>
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1749
|
+
// `message` is non-enumerable on Error, so JSON.stringify alone would drop it. JSON.stringify throws on
|
|
1750
|
+
// a BigInt and on a cycle, and narration must not be what fails a replay.
|
|
1751
|
+
const format = (/** @type {any} */ v) => {
|
|
1752
|
+
try {
|
|
1753
|
+
return JSON.stringify(v instanceof Error ? { ...v, name: v.name, message: v.message } : v, null, 2);
|
|
1754
|
+
} catch {
|
|
1755
|
+
return String(v);
|
|
1756
|
+
}
|
|
1757
|
+
};
|
|
1195
1758
|
|
|
1196
1759
|
if (version && traceVersion && version !== traceVersion) {
|
|
1197
1760
|
log(`Warning: trace was recorded at ${traceVersion}, replaying against ${version}.`);
|
|
1198
1761
|
}
|
|
1199
|
-
|
|
1200
|
-
|
|
1762
|
+
// The flow is rebuilt from this input, and a recorder installed as a hook reads it off the flow, where only
|
|
1763
|
+
// effectPipe puts it.
|
|
1764
|
+
if (initialInput === undefined) {
|
|
1765
|
+
log(
|
|
1766
|
+
'Warning: the trace holds no initial input, so the flow is rebuilt from undefined. A recorder ' +
|
|
1767
|
+
'installed as a hook finds the input only on a flow built with effectPipe.'
|
|
1768
|
+
);
|
|
1769
|
+
}
|
|
1770
|
+
// Parallel decisions are not narrated as steps, so the header counts Commands to match the lines below.
|
|
1771
|
+
const commandCount = trace.filter((e) => !isDecisionEntry(e)).length;
|
|
1772
|
+
const stepsText = commandCount === 1 ? 'step' : 'steps';
|
|
1773
|
+
log(`Replaying '${flowName || 'flow'}' (${commandCount} recorded ${stepsText})`);
|
|
1201
1774
|
log(`Initial input: ${format(initialInput)}`);
|
|
1202
1775
|
|
|
1203
|
-
//
|
|
1204
|
-
//
|
|
1205
|
-
|
|
1206
|
-
// timings are narrated for Parallel too. Positional lookup is the fallback for a trace with no paths,
|
|
1207
|
-
// which is how such a trace is matched anyway.
|
|
1208
|
-
const byPath = new Map(trace.filter((e) => typeof e.path === 'string').map((e) => [e.path, e]));
|
|
1776
|
+
// Timings are looked up by path, whatever order branches finished in, or by position for a trace
|
|
1777
|
+
// with no paths, which is how such a trace is matched anyway.
|
|
1778
|
+
const byPath = new Map(trace.filter(hasPath).map((e) => [e.path, e]));
|
|
1209
1779
|
const timing = (/** @type {ReplayStep} */ step) => {
|
|
1210
1780
|
const recorded = byPath.get(step.path) ?? trace[step.index];
|
|
1211
1781
|
return typeof recorded?.durationMs === 'number' ? ` in ${recorded.durationMs}ms` : '';
|
|
@@ -1213,7 +1783,7 @@ const timeTravel = async (flowFn, traceLog, options = {}) => {
|
|
|
1213
1783
|
const replay = await replayEffect(flowFn(initialInput), traceLog, {
|
|
1214
1784
|
context: context !== undefined ? context : traceLog.context || {},
|
|
1215
1785
|
onResolved: (step, outcome) => {
|
|
1216
|
-
//
|
|
1786
|
+
// A step the trace does not hold is refused or run live by `replayEffect`, not narrated here.
|
|
1217
1787
|
if (outcome === undefined) return;
|
|
1218
1788
|
log(
|
|
1219
1789
|
'error' in outcome
|
|
@@ -1226,15 +1796,12 @@ const timeTravel = async (flowFn, traceLog, options = {}) => {
|
|
|
1226
1796
|
|
|
1227
1797
|
log(`Replay finished with state: ${result.type}`);
|
|
1228
1798
|
log(result.type === 'Failure' ? `Error: ${format(result.error)}` : `Result: ${format(result.value)}`);
|
|
1229
|
-
// After a replay error the flow never got past the divergence,
|
|
1230
|
-
//
|
|
1231
|
-
// with steps left over is worth a warning.
|
|
1799
|
+
// After a replay error the flow never got past the divergence, and the error already names where it
|
|
1800
|
+
// split. Only a flow that ended on its own terms with steps left over is worth a warning.
|
|
1232
1801
|
const haltedByReplay = result.type === 'Failure' && /^(TimeParadox|ReplayError)$/.test(result.error?.name);
|
|
1233
1802
|
if (unreached.length > 0 && !haltedByReplay) {
|
|
1234
1803
|
// Named, not just counted: the step a fix stopped issuing is usually the one under suspicion.
|
|
1235
|
-
const names = unreached.map((e) =>
|
|
1236
|
-
typeof e.path === 'string' ? `${e.command} (path '${e.path}')` : e.command
|
|
1237
|
-
);
|
|
1804
|
+
const names = unreached.map((e) => (hasPath(e) ? `${e.command} (path '${e.path}')` : e.command));
|
|
1238
1805
|
const count = unreached.length === 1 ? '1 recorded step was' : `${unreached.length} recorded steps were`;
|
|
1239
1806
|
log(`Warning: ${count} never reached: ${names.join(', ')}. The flow diverged.`);
|
|
1240
1807
|
}
|