pure-effect 0.14.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 +20 -0
- package/README.md +47 -21
- package/index.js +760 -684
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -79,36 +79,45 @@ const Failure = (error, initialInput) => ({
|
|
|
79
79
|
*
|
|
80
80
|
* @param {(signal?: AbortSignal) => Promise<any>|any} cmd - The side-effect function to execute. Inside a
|
|
81
81
|
* `Parallel` branch it receives an `AbortSignal` that fires when a sibling branch fails, so I/O that
|
|
82
|
-
* accepts one can be cancelled in flight.
|
|
83
|
-
*
|
|
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()`.
|
|
84
84
|
* @param {(result: any) => Effect} [next] - Receives the result of `cmd` and returns the next Effect.
|
|
85
|
-
* 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.
|
|
86
86
|
* @param {CommandMeta} [meta] - Optional metadata, passed to `onBeforeCommand`. A string `meta.name`
|
|
87
|
-
* becomes this Command's identity for traces, replay matching, and telemetry spans
|
|
88
|
-
* 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.
|
|
89
88
|
* @returns {CommandState}
|
|
90
89
|
*/
|
|
91
|
-
const Command = (cmd, next
|
|
92
|
-
|
|
93
|
-
cmd
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
+
};
|
|
97
110
|
|
|
98
111
|
/**
|
|
99
112
|
* The name a Command is known by: what a trace records, what replay matches on, and what a
|
|
100
|
-
* telemetry span is called.
|
|
101
|
-
*
|
|
102
|
-
* `meta.name` wins when it is a non-empty string. Otherwise the identity is the thunk's own `name`,
|
|
103
|
-
* so an inline arrow records as 'anonymous' and a minifier that mangles function names silently
|
|
104
|
-
* 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'.
|
|
105
114
|
*
|
|
106
115
|
* @param {CommandState} eff
|
|
107
116
|
* @returns {string}
|
|
108
117
|
*/
|
|
109
118
|
const commandName = (eff) => {
|
|
110
119
|
const meta = eff.meta;
|
|
111
|
-
const named = meta
|
|
120
|
+
const named = isObject(meta) ? meta.name : undefined;
|
|
112
121
|
return typeof named === 'string' && named !== '' ? named : eff.cmd.name || 'anonymous';
|
|
113
122
|
};
|
|
114
123
|
|
|
@@ -117,7 +126,12 @@ const commandName = (eff) => {
|
|
|
117
126
|
* @param {(context: any) => Effect} next - Receives the context and returns the next Effect
|
|
118
127
|
* @returns {AskState}
|
|
119
128
|
*/
|
|
120
|
-
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
|
+
};
|
|
121
135
|
|
|
122
136
|
/**
|
|
123
137
|
* Wraps an Effect tree with retry-on-failure semantics.
|
|
@@ -128,87 +142,138 @@ const Ask = (next) => ({ type: 'Ask', next });
|
|
|
128
142
|
* every Command in it is idempotent.
|
|
129
143
|
*
|
|
130
144
|
* @param {Effect} effect - The inner Effect tree to retry
|
|
131
|
-
* @param {Object} [options] - Retry options, merged over the library defaults at runtime
|
|
132
|
-
*
|
|
133
|
-
* @param {number} [options.attempts] - Max retries (not counting first try)
|
|
134
|
-
* @param {number} [options.delay] - Ms before first retry
|
|
135
|
-
* @param {number} [options.backoff] - Multiplier applied to delay on each subsequent retry
|
|
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
|
|
136
151
|
* @param {(error: any) => Effect} [options.onExhausted] - Runs a fallback Effect when every attempt has
|
|
137
152
|
* failed, receiving `{ retryExhausted, lastError, attempts }`. The fallback's success feeds `next`
|
|
138
|
-
* exactly as the primary's would have; its failure propagates unwrapped.
|
|
139
|
-
*
|
|
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.
|
|
140
155
|
* @returns {RetryState}
|
|
141
156
|
*/
|
|
142
|
-
const Retry = (effect, options
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
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
|
+
};
|
|
148
165
|
|
|
149
166
|
/**
|
|
150
167
|
* Runs multiple Effect trees concurrently. The first branch to fail cancels its siblings, and that
|
|
151
168
|
* branch's Failure is what the Parallel returns; `next` is skipped. Which branch fails first depends on
|
|
152
169
|
* timing, so it is recorded, and a replay returns the same one.
|
|
153
170
|
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
* 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
|
|
157
173
|
* signal runs to completion, so a branch's first Command can still write after a sibling has failed.
|
|
158
174
|
*
|
|
159
175
|
* `settled: true` turns off that first-failure rule: every branch runs to completion and `next`
|
|
160
|
-
* receives the branch outcomes
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
* resource that rate limits; results and paths stay in array order either way.
|
|
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.
|
|
164
179
|
*
|
|
165
180
|
* The second argument is the `next` function or the options, whichever it looks like, so
|
|
166
|
-
* `Parallel(effects, { limit: 5 })` needs no placeholder.
|
|
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.
|
|
167
183
|
*
|
|
168
184
|
* @param {Effect[]} effects - Array of Effect trees to run concurrently
|
|
169
|
-
* @param {((values: any[]) => Effect) | ParallelOptions} [nextOrOptions] - Receives array of success
|
|
185
|
+
* @param {((values: any[]) => Effect) | ParallelOptions | null} [nextOrOptions] - Receives array of success
|
|
170
186
|
* values in order and returns the next Effect, or the options. `next` defaults to
|
|
171
187
|
* `(values) => Success(values)`, same as `Command`'s default.
|
|
172
|
-
* @param {ParallelOptions} [maybeOptions] - Options, when `next` was given
|
|
188
|
+
* @param {ParallelOptions} [maybeOptions] - Options, when `next` was given or skipped
|
|
173
189
|
* @returns {ParallelState}
|
|
174
190
|
*/
|
|
175
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
|
+
});
|
|
176
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
|
+
}
|
|
177
209
|
return {
|
|
178
210
|
type: 'Parallel',
|
|
179
211
|
effects,
|
|
180
212
|
next: hasNext ? nextOrOptions : (/** @type {any[]} */ values) => Success(values),
|
|
181
|
-
options:
|
|
213
|
+
options: options ?? {}
|
|
182
214
|
};
|
|
183
215
|
};
|
|
184
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);
|
|
242
|
+
|
|
185
243
|
/**
|
|
186
244
|
* Describes a value for an error message, leading with the mistake it most likely is.
|
|
187
245
|
* @param {any} value
|
|
188
246
|
* @returns {string}
|
|
189
247
|
*/
|
|
190
|
-
const describeValue = (value) =>
|
|
191
|
-
value === undefined
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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`.
|
|
212
277
|
*/
|
|
213
278
|
const harnessError = Symbol('pure-effect.harnessError');
|
|
214
279
|
|
|
@@ -219,11 +284,17 @@ const harnessError = Symbol('pure-effect.harnessError');
|
|
|
219
284
|
const asHarnessError = (error) => Object.defineProperty(error, harnessError, { value: true });
|
|
220
285
|
|
|
221
286
|
/**
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
|
|
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.
|
|
227
298
|
* @typedef {{ type: 'IoFault', error: any, initialInput?: any }} IoFaultState
|
|
228
299
|
*/
|
|
229
300
|
|
|
@@ -241,33 +312,56 @@ const IoFault = (error, initialInput) => ({ type: 'IoFault', error, initialInput
|
|
|
241
312
|
const asOutcome = (state) => (state.type === 'IoFault' ? Failure(state.error, state.initialInput) : state);
|
|
242
313
|
|
|
243
314
|
/**
|
|
244
|
-
*
|
|
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`.
|
|
245
318
|
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
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.
|
|
248
332
|
*
|
|
249
333
|
* @param {any} value
|
|
250
334
|
* @param {string} source - What produced the value, named where it is known
|
|
251
335
|
* @returns {Error}
|
|
252
336
|
*/
|
|
253
|
-
const effectTypeError = (value, source) =>
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
Object.assign(
|
|
261
|
-
new Error(
|
|
262
|
-
`${source} returned ${describeValue(value)}. Return Success, Failure, Command, Ask, Retry, or Parallel: ` +
|
|
263
|
-
(isPromise
|
|
264
|
-
? 'a step cannot be async, so do the awaited work in a Command and continue in its next.'
|
|
265
|
-
: 'a plain value has to be wrapped, as in Success(value).')
|
|
266
|
-
),
|
|
267
|
-
{ name: 'EffectTypeError' }
|
|
268
|
-
)
|
|
337
|
+
const effectTypeError = (value, source) =>
|
|
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
|
|
269
344
|
);
|
|
270
|
-
|
|
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);
|
|
271
365
|
|
|
272
366
|
/**
|
|
273
367
|
* @param {any} value
|
|
@@ -275,7 +369,7 @@ const effectTypeError = (value, source) => {
|
|
|
275
369
|
* @returns {Effect}
|
|
276
370
|
*/
|
|
277
371
|
const asEffect = (value, source) => {
|
|
278
|
-
if (
|
|
372
|
+
if (isEffect(value)) return value;
|
|
279
373
|
throw effectTypeError(value, source);
|
|
280
374
|
};
|
|
281
375
|
|
|
@@ -289,21 +383,12 @@ const asEffect = (value, source) => {
|
|
|
289
383
|
* @returns {Effect} The composed Effect
|
|
290
384
|
*/
|
|
291
385
|
const chain = (effect, fn, initialInput) => {
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
// continuations, so overwriting here is what makes the whole tree, a Failure from any depth, and
|
|
295
|
-
// the root a hook-based recorder reads carry the input of the flow that was actually called. The
|
|
296
|
-
// previous rule, stamp only when unset, let a Retry- or Parallel-headed sub-pipeline leave its own
|
|
297
|
-
// input on the root, and a trace recorded from it rebuilt the flow from the wrong value. A Success
|
|
298
|
-
// is never stamped: nothing reads the input off a terminal value, and leaving it bare is what lets
|
|
299
|
-
// `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.
|
|
300
388
|
const withII = (/** @type {Effect} */ e) =>
|
|
301
389
|
initialInput !== undefined && e.type !== 'Success' ? { ...e, initialInput } : e;
|
|
302
390
|
|
|
303
|
-
//
|
|
304
|
-
// `undefined` throws a bare TypeError that names no step, and the run would reject with that. This
|
|
305
|
-
// guard turns it into an EffectTypeError that says what went wrong. The `default` arm below never sees
|
|
306
|
-
// 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.
|
|
307
392
|
if (effect == null) return asEffect(effect, 'A continuation');
|
|
308
393
|
|
|
309
394
|
switch (effect.type) {
|
|
@@ -340,15 +425,21 @@ const chain = (effect, fn, initialInput) => {
|
|
|
340
425
|
* @returns {(start: any) => Effect} A function that accepts an initial input and returns the final Effect tree.
|
|
341
426
|
*/
|
|
342
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
|
+
});
|
|
343
438
|
return (start) => {
|
|
344
439
|
const chainWithII = (/** @type {Effect} */ eff, /** @type {(v: any) => Effect} */ fn) => chain(eff, fn, start);
|
|
345
440
|
const tree = fns.reduce(chainWithII, /** @type {Effect} */ (Success(start)));
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
// that wrapping, so a sub-pipeline returned by the last step would keep its own input on
|
|
349
|
-
// everything past its head. The pass wraps those continuations too, so every node reachable
|
|
350
|
-
// from here, a Failure at any depth included, carries this pipeline's `start`. `Success` is the
|
|
351
|
-
// 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.
|
|
352
443
|
return chain(tree, Success, start);
|
|
353
444
|
};
|
|
354
445
|
};
|
|
@@ -356,10 +447,8 @@ const effectPipe = (...fns) => {
|
|
|
356
447
|
/**
|
|
357
448
|
* Wraps one Command execution, or one Parallel: `type` is 'Parallel', and `op` runs the branches and
|
|
358
449
|
* returns the Parallel's decision, even when a branch threw, so a hook must call it. Only a replay passes
|
|
359
|
-
* `op` an argument, the recorded decision. `path`
|
|
360
|
-
*
|
|
361
|
-
* branches finish in a different order. Hooks written before `path` existed take three parameters and
|
|
362
|
-
* are unaffected.
|
|
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.
|
|
363
452
|
* @typedef {(name: string, type: string, op: function, path?: string) => Promise<any>} StepRunner
|
|
364
453
|
*/
|
|
365
454
|
/** @type StepRunner */
|
|
@@ -376,15 +465,13 @@ const defaultCommandInterceptor = async (command, context) => {};
|
|
|
376
465
|
const defaultRetryOptions = { attempts: 3, delay: 100, backoff: 1 };
|
|
377
466
|
|
|
378
467
|
/**
|
|
379
|
-
*
|
|
380
|
-
*
|
|
381
|
-
* leftover `retry` key throws instead of being ignored: silently falling back to the library defaults
|
|
382
|
-
* is how a configured `attempts: 5` quietly becomes 3. Remove this guard at 1.0.
|
|
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.
|
|
383
470
|
* @param {any} config
|
|
384
471
|
* @param {string} source
|
|
385
472
|
*/
|
|
386
473
|
const rejectRetryKey = (config, source) => {
|
|
387
|
-
if (config &&
|
|
474
|
+
if (isObject(config) && 'retry' in config)
|
|
388
475
|
throw new TypeError(
|
|
389
476
|
`${source} no longer takes 'retry'. Retry options are per-use: pass them to Retry(effect, options).`
|
|
390
477
|
);
|
|
@@ -392,18 +479,15 @@ const rejectRetryKey = (config, source) => {
|
|
|
392
479
|
|
|
393
480
|
/**
|
|
394
481
|
* @typedef {Object} EffectConfiguration
|
|
395
|
-
* @property {StepRunner} [onStep] -
|
|
482
|
+
* @property {StepRunner} [onStep] - Wraps each Command's execution, and each Parallel's branches.
|
|
396
483
|
* @property {RunWrapper} [onRun] - Fires once per runEffect call. It wraps the entire workflow execution.
|
|
397
484
|
* @property {CommandInterceptor} [onBeforeCommand] - Intercepts a Command and any context passed to runEffect before execution.
|
|
398
485
|
*/
|
|
399
486
|
|
|
400
487
|
/**
|
|
401
488
|
* A per-call configuration: an `EffectConfiguration` plus `inherit`. With `inherit: true` (the default)
|
|
402
|
-
* the call's hooks are
|
|
403
|
-
*
|
|
404
|
-
* leaves unset falls back to the library default. A per-call hook cannot replace a single global slot on
|
|
405
|
-
* its own; that was the previous default, and it is how a per-call recorder silently switched off an
|
|
406
|
-
* 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.
|
|
407
491
|
*
|
|
408
492
|
* @typedef {EffectConfiguration & { inherit?: boolean }} CallConfiguration
|
|
409
493
|
*/
|
|
@@ -412,10 +496,8 @@ const rejectRetryKey = (config, source) => {
|
|
|
412
496
|
let layers = [];
|
|
413
497
|
|
|
414
498
|
/**
|
|
415
|
-
* The installed layers merged into one configuration
|
|
416
|
-
*
|
|
417
|
-
* and a slot no layer defines is simply absent, so the library default is chosen at the one place the
|
|
418
|
-
* 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.
|
|
419
501
|
* @type {EffectConfiguration}
|
|
420
502
|
*/
|
|
421
503
|
let globalConfig = {};
|
|
@@ -438,29 +520,21 @@ const applyLayers = () => {
|
|
|
438
520
|
* configureEffect(telemetryHooks(), recordingHooks({ sink }));
|
|
439
521
|
* configureEffect(telemetryHooks()); configureEffect(recordingHooks({ sink }));
|
|
440
522
|
*
|
|
441
|
-
* Calling it with no arguments at all removes every layer. A call whose arguments are all `undefined
|
|
442
|
-
* is a conditional install that installed nothing:
|
|
443
|
-
*
|
|
444
|
-
* Layers replaced the previous one-slot rule, under which a later call displaced the earlier wiring and
|
|
445
|
-
* the returned function restored a snapshot, guarded so that interleaved installs could not clobber
|
|
446
|
-
* each other. Removing a layer needs no guard: A installs, B installs, A removes, and B is still there.
|
|
447
|
-
* It also means a library can install its own hooks without erasing its host's, which under the old rule
|
|
448
|
-
* 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.
|
|
449
526
|
*
|
|
450
527
|
* @param {...(EffectConfiguration | undefined)} configs - Configurations merged into one layer, outermost first
|
|
451
528
|
* @returns {() => void} Removes the layer this call added; a second call does nothing
|
|
452
529
|
*/
|
|
453
530
|
const configureEffect = (...configs) => {
|
|
454
|
-
// Only a call with no arguments at all is a reset. A call whose arguments are all absent, such as
|
|
455
|
-
// `configureEffect(flag ? hooks : undefined)`, is a conditional install that happened not to install
|
|
456
|
-
// anything, so it adds no layer and must not remove anyone else's.
|
|
457
531
|
if (configs.length === 0) {
|
|
458
532
|
layers = [];
|
|
459
533
|
applyLayers();
|
|
460
534
|
return () => {};
|
|
461
535
|
}
|
|
462
536
|
const present = configs.filter(Boolean);
|
|
463
|
-
//
|
|
537
|
+
// Before installing anything, so a refused call leaves the wiring untouched.
|
|
464
538
|
present.forEach((config) => rejectRetryKey(config, 'configureEffect'));
|
|
465
539
|
if (present.length === 0) return () => {};
|
|
466
540
|
const layer = chainHooks(...present);
|
|
@@ -493,7 +567,8 @@ const configureEffect = (...configs) => {
|
|
|
493
567
|
const now = () => (typeof performance === 'object' ? performance.now() : Date.now());
|
|
494
568
|
|
|
495
569
|
/**
|
|
496
|
-
* 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.
|
|
497
572
|
*
|
|
498
573
|
* @param {(start: StepStart) => (end: StepEnd) => void} handler - Returns a finisher for the outcome
|
|
499
574
|
* @returns {StepRunner}
|
|
@@ -528,11 +603,10 @@ const observeSteps = (handler) => async (name, type, op, path) => {
|
|
|
528
603
|
/**
|
|
529
604
|
* Merges several configurations into one, so independent concerns can share the hooks.
|
|
530
605
|
*
|
|
531
|
-
* `onStep` and `onRun` are wrappers around an `op`, so they nest
|
|
532
|
-
*
|
|
533
|
-
*
|
|
534
|
-
*
|
|
535
|
-
* no config defines is left unset, so the caller of the merged wiring keeps its 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.
|
|
536
610
|
*
|
|
537
611
|
* @param {...(EffectConfiguration | undefined)} configs - Configurations to merge, outermost first
|
|
538
612
|
* @returns {EffectConfiguration}
|
|
@@ -569,17 +643,13 @@ const chainHooks = (...configs) => {
|
|
|
569
643
|
};
|
|
570
644
|
|
|
571
645
|
/**
|
|
572
|
-
* The Failure a `Parallel` branch
|
|
573
|
-
*
|
|
574
|
-
*
|
|
575
|
-
*
|
|
576
|
-
* failure that triggered the cancellation.
|
|
577
|
-
* @returns {Error}
|
|
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}
|
|
578
650
|
*/
|
|
579
|
-
const
|
|
580
|
-
Object.assign(new Error('Parallel branch cancelled.'), {
|
|
581
|
-
name: 'ParallelCancelled'
|
|
582
|
-
});
|
|
651
|
+
const cancelledBranch = (initialInput) =>
|
|
652
|
+
Failure(Object.assign(new Error('Parallel branch cancelled.'), { name: 'ParallelCancelled' }), initialInput);
|
|
583
653
|
|
|
584
654
|
/**
|
|
585
655
|
* Which branch, if any, cancelled a Parallel. `branch: null` means an enclosing Parallel cancelled it.
|
|
@@ -588,25 +658,41 @@ const parallelCancelled = () =>
|
|
|
588
658
|
*/
|
|
589
659
|
|
|
590
660
|
/**
|
|
591
|
-
*
|
|
592
|
-
*
|
|
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.
|
|
593
680
|
* @param {any} value
|
|
594
681
|
* @param {number} branches - How many branches the Parallel has, so a stale branch index is refused too
|
|
595
682
|
* @returns {ParallelDecision | undefined}
|
|
596
683
|
*/
|
|
597
684
|
const asDecision = (value, branches) => {
|
|
598
|
-
if (!value
|
|
685
|
+
if (!isObject(value)) return undefined;
|
|
599
686
|
if (value.cancelled === false) return { cancelled: false };
|
|
600
687
|
if (value.cancelled !== true) return undefined;
|
|
601
688
|
const { branch } = value;
|
|
602
689
|
if (branch === null) return { cancelled: true, branch: null };
|
|
603
|
-
return
|
|
690
|
+
return isBranchIndex(branch, branches) ? { cancelled: true, branch } : undefined;
|
|
604
691
|
};
|
|
605
692
|
|
|
606
693
|
/**
|
|
607
|
-
* Marks
|
|
608
|
-
*
|
|
609
|
-
* as a divergence. Only `fromTrace` raises it, and only where a recorded decision explains the gap.
|
|
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.
|
|
610
696
|
*/
|
|
611
697
|
const replayCut = Symbol('pure-effect.replayCut');
|
|
612
698
|
|
|
@@ -622,10 +708,27 @@ const replayCutError = (path) =>
|
|
|
622
708
|
);
|
|
623
709
|
|
|
624
710
|
/**
|
|
625
|
-
*
|
|
626
|
-
*
|
|
627
|
-
*
|
|
628
|
-
*
|
|
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
|
+
};
|
|
728
|
+
|
|
729
|
+
/**
|
|
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.
|
|
629
732
|
* @param {(() => Promise<void>)[]} tasks
|
|
630
733
|
* @param {number} [limit]
|
|
631
734
|
* @returns {Promise<void>}
|
|
@@ -643,9 +746,8 @@ const runBounded = async (tasks, limit) => {
|
|
|
643
746
|
};
|
|
644
747
|
|
|
645
748
|
/**
|
|
646
|
-
* Sleeps, but gives up early when the surrounding branch is cancelled
|
|
647
|
-
*
|
|
648
|
-
* is what turns a cancelled wait into a Failure.
|
|
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.
|
|
649
751
|
*
|
|
650
752
|
* @param {number} ms
|
|
651
753
|
* @param {AbortSignal} [signal]
|
|
@@ -657,9 +759,8 @@ const delayFor = (ms, signal) =>
|
|
|
657
759
|
setTimeout(resolve, ms);
|
|
658
760
|
return;
|
|
659
761
|
}
|
|
660
|
-
// A listener added to
|
|
661
|
-
//
|
|
662
|
-
// its backoff on the aborted signal.
|
|
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.
|
|
663
764
|
if (signal.aborted) {
|
|
664
765
|
resolve();
|
|
665
766
|
return;
|
|
@@ -675,33 +776,27 @@ const delayFor = (ms, signal) =>
|
|
|
675
776
|
signal.addEventListener('abort', onAbort, { once: true });
|
|
676
777
|
});
|
|
677
778
|
|
|
678
|
-
const
|
|
779
|
+
const interpret =
|
|
679
780
|
/**
|
|
680
|
-
* The
|
|
681
|
-
*
|
|
682
|
-
*
|
|
683
|
-
*
|
|
684
|
-
* Per-call config is merged over the global configureEffect wiring, or over nothing under
|
|
685
|
-
* `inherit: false`. onRun fires exactly once per runEffect call. Retry attempts run inside that
|
|
686
|
-
* 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.
|
|
687
785
|
*
|
|
688
|
-
* @param {Effect} effect
|
|
689
|
-
* @param {any} [context]
|
|
690
|
-
* @param {CallConfiguration} [callConfig]
|
|
691
|
-
*
|
|
786
|
+
* @param {Effect} effect
|
|
787
|
+
* @param {any} [context]
|
|
788
|
+
* @param {CallConfiguration} [callConfig]
|
|
789
|
+
* @param {boolean} [fastRetry]
|
|
692
790
|
* @returns {Promise<SuccessState | FailureState>}
|
|
693
791
|
*/
|
|
694
|
-
async function
|
|
792
|
+
async function interpret(effect, context = {}, callConfig = {}, fastRetry = false) {
|
|
695
793
|
rejectRetryKey(callConfig, "runEffect's callConfig");
|
|
696
794
|
const { inherit = true, ...local } = callConfig;
|
|
697
|
-
//
|
|
698
|
-
// 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.
|
|
699
796
|
if (typeof inherit !== 'boolean') {
|
|
700
797
|
throw new TypeError(`callConfig.inherit must be true or false, got ${JSON.stringify(inherit)}.`);
|
|
701
798
|
}
|
|
702
|
-
//
|
|
703
|
-
// inherits nothing, by the same merge `configureEffect` applies to several configurations. Most
|
|
704
|
-
// 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.
|
|
705
800
|
const base = inherit ? globalConfig : {};
|
|
706
801
|
const resolved = Object.keys(local).length ? chainHooks(base, local) : base;
|
|
707
802
|
const localStepRunner = resolved.onStep || defaultStepRunner;
|
|
@@ -709,325 +804,330 @@ const runEffect =
|
|
|
709
804
|
const localCommandInterceptor = resolved.onBeforeCommand || defaultCommandInterceptor;
|
|
710
805
|
|
|
711
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
|
+
*
|
|
712
811
|
* @param {Effect} eff
|
|
713
812
|
* @param {AbortSignal} [signal] - Cancellation for this subtree, set for `Parallel` branches.
|
|
714
|
-
*
|
|
715
|
-
* each
|
|
716
|
-
*
|
|
717
|
-
* Steps are numbered sequentially within a subtree and each `Parallel` branch and `Retry`
|
|
718
|
-
* attempt opens its own prefix, so a Command's full path depends only on the shape of the
|
|
719
|
-
* tree and not on the order branches happen to finish in. That is what lets a replay line
|
|
720
|
-
* a recorded step up with the step that asked for it.
|
|
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.
|
|
721
816
|
* @returns {Promise<SuccessState | FailureState | IoFaultState>}
|
|
722
817
|
*/
|
|
723
818
|
async function execute(eff, signal, path = '') {
|
|
724
819
|
let step = 0;
|
|
725
|
-
while (
|
|
726
|
-
|
|
727
|
-
(
|
|
728
|
-
) {
|
|
729
|
-
// Checked before every node, which is what short-circuits a branch whose Commands
|
|
730
|
-
// ignore the signal: the one in flight cannot be stopped, but the next never starts.
|
|
731
|
-
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);
|
|
732
823
|
if (eff.type === 'Ask') {
|
|
733
824
|
eff = eff.next(context);
|
|
734
825
|
continue;
|
|
735
826
|
}
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
asEffect(onExhausted(exhausted), "Retry option 'onExhausted'"),
|
|
791
|
-
signal,
|
|
792
|
-
`${retryPath}f/`
|
|
793
|
-
);
|
|
794
|
-
// A failing fallback propagates as-is: the flow's last word was the fallback's
|
|
795
|
-
// error, not the exhaustion it was already told about.
|
|
796
|
-
if (fallback.type !== 'Success') return fallback;
|
|
797
|
-
eff = retryNext(fallback.value);
|
|
798
|
-
}
|
|
799
|
-
continue;
|
|
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);
|
|
800
881
|
}
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
`Time paradox at path '${branchPath}': the recorded run was cancelled by ` +
|
|
919
|
-
`branch ${forced.branch}, which did not fail in this replay.`,
|
|
920
|
-
{ name: 'TimeParadox', path: branchPath, branch: forced.branch }
|
|
921
|
-
);
|
|
922
|
-
}
|
|
923
|
-
decision = forced;
|
|
924
|
-
return decision;
|
|
925
|
-
}
|
|
926
|
-
const trigger = triggered.indexOf(true);
|
|
927
|
-
decision =
|
|
928
|
-
trigger >= 0
|
|
929
|
-
? { cancelled: true, branch: trigger }
|
|
930
|
-
: branchSignal?.aborted
|
|
931
|
-
? { cancelled: true, branch: null }
|
|
932
|
-
: { cancelled: false };
|
|
933
|
-
return decision;
|
|
934
|
-
};
|
|
935
|
-
|
|
936
|
-
try {
|
|
937
|
-
await localStepRunner('Parallel', 'Parallel', runBranches, branchPath);
|
|
938
|
-
} catch (e) {
|
|
939
|
-
// A replay found this Parallel inside a branch production had already stopped.
|
|
940
|
-
if (e && /** @type {any} */ (e)[replayCut]) return Failure(parallelCancelled(), parallelInput);
|
|
941
|
-
throw e;
|
|
942
|
-
}
|
|
943
|
-
if (!ran) {
|
|
944
|
-
throw new TypeError(
|
|
945
|
-
`An onStep hook returned without calling op for the Parallel at path '${branchPath}'. ` +
|
|
946
|
-
'A hook has to call op for a Parallel, because op runs its branches.'
|
|
947
|
-
);
|
|
948
|
-
}
|
|
949
|
-
// Rethrown here, after the step has returned its decision, so the decision is recorded.
|
|
950
|
-
if (branchThrow) throw branchThrow.error;
|
|
951
|
-
|
|
952
|
-
// Settled hands the outcomes on as plain Success and Failure nodes. A branch that
|
|
953
|
-
// failed is data here, so `next` runs and the Parallel itself never fails on a
|
|
954
|
-
// branch's account; and a Failure `next` returns is an abort like any other.
|
|
955
|
-
if (settled) {
|
|
956
|
-
eff = eff.next(results.map(asOutcome));
|
|
957
|
-
continue;
|
|
958
|
-
}
|
|
959
|
-
|
|
960
|
-
const failure =
|
|
961
|
-
decision.cancelled && decision.branch !== null
|
|
962
|
-
? results[decision.branch]
|
|
963
|
-
: results.find((r) => r.type !== 'Success');
|
|
964
|
-
if (failure) return failure;
|
|
965
|
-
eff = eff.next(results.map((r) => /** @type {SuccessState} */ (r).value));
|
|
966
|
-
continue;
|
|
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
|
+
);
|
|
967
999
|
}
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
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) => {
|
|
992
1039
|
try {
|
|
993
|
-
await
|
|
994
|
-
} catch (
|
|
995
|
-
|
|
996
|
-
return Failure(e, initialInput);
|
|
1040
|
+
results[i] = await execute(branch, scope?.signal, `${branchPath}${i}/`);
|
|
1041
|
+
} catch (error) {
|
|
1042
|
+
thrown[i] = { error };
|
|
997
1043
|
}
|
|
998
|
-
//
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
try {
|
|
1003
|
-
result = await localStepRunner(cmdName, 'Command', op, cmdPath);
|
|
1004
|
-
} catch (e) {
|
|
1005
|
-
// A replay reached a step production never ran, in a branch a Parallel had cancelled:
|
|
1006
|
-
// this is where production stopped the branch, so the replay stops it here too.
|
|
1007
|
-
if (e && /** @type {any} */ (e)[replayCut]) return Failure(parallelCancelled(), initialInput);
|
|
1008
|
-
if (e && /** @type {any} */ (e)[harnessError]) throw e;
|
|
1009
|
-
// The function returned and a hook threw afterwards: a bug in the hook, rejected like a
|
|
1010
|
-
// throw from `next`. A hook that throws without calling `op` is still a fault, since that
|
|
1011
|
-
// is how replay reports a recorded error.
|
|
1012
|
-
if (succeeded) throw e;
|
|
1013
|
-
return IoFault(e, initialInput);
|
|
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();
|
|
1014
1048
|
}
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
//
|
|
1018
|
-
|
|
1019
|
-
|
|
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();
|
|
1020
1058
|
}
|
|
1021
|
-
|
|
1022
|
-
|
|
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
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
|
|
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);
|
|
1023
1126
|
}
|
|
1024
1127
|
|
|
1025
|
-
// Every
|
|
1026
|
-
//
|
|
1027
|
-
//
|
|
1028
|
-
// carrying the subtree's own input or none. Restamping the outcome with the root's input is
|
|
1029
|
-
// what makes "a Failure carries the input of the flow that was called" true at any depth. An I/O
|
|
1030
|
-
// fault becomes a plain Failure here too, since this is where it would first reach the caller.
|
|
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.
|
|
1031
1131
|
const rootInput = effect?.initialInput;
|
|
1032
1132
|
const run = async () => {
|
|
1033
1133
|
const result = asOutcome(await execute(effect));
|
|
@@ -1036,6 +1136,18 @@ const runEffect =
|
|
|
1036
1136
|
return localRunWrapper(effect, run, context?.flowName || '');
|
|
1037
1137
|
};
|
|
1038
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
|
+
|
|
1039
1151
|
/**
|
|
1040
1152
|
* The step a replay is asking about. `path` is the Command's position in the Effect tree and is stable
|
|
1041
1153
|
* across runs; `index` is its position in this run's completion order, which is not stable for a flow
|
|
@@ -1057,11 +1169,8 @@ const runEffect =
|
|
|
1057
1169
|
|
|
1058
1170
|
/**
|
|
1059
1171
|
* A recorded step: a Command's result or error, or a Parallel's decision, recorded as `command`
|
|
1060
|
-
* 'Parallel' at the Parallel's own path with the decision as its `result`.
|
|
1061
|
-
*
|
|
1062
|
-
* which is the one question a trace could not answer before: which step was slow.
|
|
1063
|
-
* `path` locates the Command in the Effect tree, which is what a replay matches on: it does not move
|
|
1064
|
-
* when `Parallel` branches finish in a different order than they did in production.
|
|
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.
|
|
1065
1174
|
* `threw` marks a step that threw. The `error` key alone cannot, since JSON drops it when the value is
|
|
1066
1175
|
* `undefined`, as it is for `reject()` with no argument or an error `redact` removed.
|
|
1067
1176
|
* @typedef {{ command: string, path?: string, result?: any, threw?: true, error?: any, durationMs?: number }} TraceEntry
|
|
@@ -1081,17 +1190,15 @@ const runEffect =
|
|
|
1081
1190
|
*/
|
|
1082
1191
|
|
|
1083
1192
|
/**
|
|
1084
|
-
* A replay fault: the trace cannot answer the flow, or disagrees with it.
|
|
1085
|
-
*
|
|
1086
|
-
*
|
|
1087
|
-
*
|
|
1088
|
-
* flow is a bug in the flow rather than a problem with the trace.
|
|
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.
|
|
1089
1197
|
*/
|
|
1090
1198
|
const replayFault = Symbol('pure-effect.replayFault');
|
|
1091
1199
|
|
|
1092
1200
|
/**
|
|
1093
|
-
* Creates a replay error
|
|
1094
|
-
* into a Failure, and `replayEffect` turns it into one at its own boundary.
|
|
1201
|
+
* Creates a replay error, which `replayEffect` turns into a Failure at its own boundary.
|
|
1095
1202
|
* @param {string} message - Human-readable reason
|
|
1096
1203
|
* @param {Object} [props] - Extra fields such as `name`, `index`, `expected`, `actual`
|
|
1097
1204
|
* @returns {Error}
|
|
@@ -1131,27 +1238,41 @@ const timeParadox = (step, recorded) =>
|
|
|
1131
1238
|
*
|
|
1132
1239
|
* @param {any} e - The thrown value
|
|
1133
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
|
|
1134
1242
|
* @returns {any}
|
|
1135
1243
|
*/
|
|
1136
|
-
const serializeError = (e, withStack) => {
|
|
1244
|
+
const serializeError = (e, withStack, ancestors = new Set()) => {
|
|
1137
1245
|
if (!(e instanceof Error)) return e;
|
|
1138
1246
|
/** @type {any} */
|
|
1139
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);
|
|
1140
1253
|
if (withStack) out.stack = e.stack;
|
|
1141
1254
|
// `cause` is non-enumerable too, and it can itself be an Error, so it is carried recursively.
|
|
1142
|
-
if ('cause' in e) out.cause = serializeError(e.cause, withStack);
|
|
1143
|
-
// So is an AggregateError's `errors`,
|
|
1144
|
-
//
|
|
1145
|
-
// that revival can tell it from an enumerable `errors`, which a validation error carries as data and
|
|
1146
|
-
// the loop below copies as it is.
|
|
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.
|
|
1147
1258
|
const errors = /** @type {any} */ (e).errors;
|
|
1148
|
-
|
|
1149
|
-
|
|
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));
|
|
1150
1262
|
}
|
|
1151
|
-
|
|
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];
|
|
1152
1270
|
return out;
|
|
1153
1271
|
};
|
|
1154
1272
|
|
|
1273
|
+
/** The keys `serializeError` carries whatever their enumerability, which a native Error sets as hidden. */
|
|
1274
|
+
const carriedKeys = ['name', 'message', 'cause'];
|
|
1275
|
+
|
|
1155
1276
|
/**
|
|
1156
1277
|
* Rebuilds an Error from `serializeError` output. Non-Error values pass through, so a
|
|
1157
1278
|
* Command that rejected with a string still replays as a string.
|
|
@@ -1159,21 +1280,23 @@ const serializeError = (e, withStack) => {
|
|
|
1159
1280
|
* @returns {any}
|
|
1160
1281
|
*/
|
|
1161
1282
|
const reviveError = (v) => {
|
|
1162
|
-
if (!v ||
|
|
1283
|
+
if (!isObject(v) || v.__error !== true) return v;
|
|
1163
1284
|
const e = new Error(v.message);
|
|
1164
|
-
// `name` and `
|
|
1165
|
-
//
|
|
1166
|
-
//
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
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'];
|
|
1171
1295
|
for (const [k, val] of Object.entries(v)) {
|
|
1172
|
-
if (k
|
|
1173
|
-
if (k === 'cause')
|
|
1174
|
-
else if (k === '__errors' && Array.isArray(val))
|
|
1175
|
-
|
|
1176
|
-
} else /** @type {any} */ (e)[k] = val;
|
|
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));
|
|
1299
|
+
else /** @type {any} */ (e)[k] = val;
|
|
1177
1300
|
}
|
|
1178
1301
|
return e;
|
|
1179
1302
|
};
|
|
@@ -1198,21 +1321,31 @@ const reviveError = (v) => {
|
|
|
1198
1321
|
* @property {string} [version] - Commit or build id, so a replay can detect a stale trace.
|
|
1199
1322
|
*/
|
|
1200
1323
|
|
|
1324
|
+
/**
|
|
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
|
+
|
|
1201
1336
|
/**
|
|
1202
1337
|
* Copies what `structuredClone` refused. Arrays and plain objects are rebuilt and everything inside them is
|
|
1203
1338
|
* copied in turn, so only the parts that cannot be copied, such as a function or an object holding one, are
|
|
1204
|
-
* kept as they are. A context holding a logger is the usual case
|
|
1205
|
-
* handed `redact` the live object the flow's `Ask` reads.
|
|
1339
|
+
* kept as they are. A context holding a logger is the usual case.
|
|
1206
1340
|
*
|
|
1207
1341
|
* @param {any} value
|
|
1208
1342
|
* @param {Map<object, any>} seen - Copies made so far, so a cycle is copied as a cycle
|
|
1209
1343
|
* @returns {any}
|
|
1210
1344
|
*/
|
|
1211
1345
|
const copyAround = (value, seen) => {
|
|
1212
|
-
if (value
|
|
1346
|
+
if (!isObject(value)) return value;
|
|
1213
1347
|
if (seen.has(value)) return seen.get(value);
|
|
1214
|
-
|
|
1215
|
-
if (!Array.isArray(value) && proto !== Object.prototype && proto !== null) {
|
|
1348
|
+
if (!isPlainContainer(value)) {
|
|
1216
1349
|
try {
|
|
1217
1350
|
return structuredClone(value);
|
|
1218
1351
|
} catch {
|
|
@@ -1220,26 +1353,23 @@ const copyAround = (value, seen) => {
|
|
|
1220
1353
|
}
|
|
1221
1354
|
}
|
|
1222
1355
|
/** @type {any} */
|
|
1223
|
-
const copy = Array.isArray(value) ? [] : Object.create(
|
|
1356
|
+
const copy = Array.isArray(value) ? [] : Object.create(Object.getPrototypeOf(value));
|
|
1224
1357
|
seen.set(value, copy);
|
|
1225
1358
|
for (const key of Object.keys(value)) copy[key] = copyAround(value[key], seen);
|
|
1226
1359
|
return copy;
|
|
1227
1360
|
};
|
|
1228
1361
|
|
|
1229
1362
|
/**
|
|
1230
|
-
* Snapshots a value on its way into a trace, and on its way out of one in a replay
|
|
1231
|
-
*
|
|
1232
|
-
*
|
|
1233
|
-
*
|
|
1234
|
-
* time the mutation is already baked in and the trace records a value production never saw. The copy
|
|
1235
|
-
* is also what `redact` is handed, so it has to be one the flow never sees. A value that cannot be
|
|
1236
|
-
* cloned whole is copied around the parts that cannot be copied.
|
|
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.
|
|
1237
1367
|
*
|
|
1238
1368
|
* @param {any} value
|
|
1239
1369
|
* @returns {any}
|
|
1240
1370
|
*/
|
|
1241
1371
|
const snapshot = (value) => {
|
|
1242
|
-
if (value
|
|
1372
|
+
if (!isObject(value)) return value;
|
|
1243
1373
|
try {
|
|
1244
1374
|
return typeof structuredClone === 'function' ? structuredClone(value) : JSON.parse(JSON.stringify(value));
|
|
1245
1375
|
} catch {
|
|
@@ -1249,8 +1379,9 @@ const snapshot = (value) => {
|
|
|
1249
1379
|
|
|
1250
1380
|
/**
|
|
1251
1381
|
* Builds an `onStep` hook that records what every Command returned, plus a packager
|
|
1252
|
-
* for the reference trace format. Pass `onStep` to `runEffect` as per-call config
|
|
1253
|
-
*
|
|
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.
|
|
1254
1385
|
*
|
|
1255
1386
|
* @param {RecorderOptions} [options] - Redaction and size limits
|
|
1256
1387
|
* @returns {{ onStep: StepRunner, entries: TraceEntry[], toTrace: (meta?: TraceMeta) => TraceLog }}
|
|
@@ -1266,9 +1397,7 @@ const recorder = (options = {}) => {
|
|
|
1266
1397
|
else dropped++;
|
|
1267
1398
|
};
|
|
1268
1399
|
|
|
1269
|
-
|
|
1270
|
-
* `redact` is the caller's code.
|
|
1271
|
-
*/
|
|
1400
|
+
// `redact` is the caller's code, and a throw from it must not reach the run.
|
|
1272
1401
|
const safeRedact = (/** @type {any} */ value, /** @type {string} */ name, /** @type {string} */ kind) => {
|
|
1273
1402
|
try {
|
|
1274
1403
|
return redact(value, name, kind);
|
|
@@ -1284,14 +1413,10 @@ const recorder = (options = {}) => {
|
|
|
1284
1413
|
const redactField = (/** @type {any} */ value, /** @type {string} */ kind) =>
|
|
1285
1414
|
value === undefined ? undefined : safeRedact(snapshot(value), kind, kind);
|
|
1286
1415
|
|
|
1287
|
-
// Built on `observeSteps` so the hook contract lives in one place: `op` always runs, its result is
|
|
1288
|
-
// always returned, and its error always propagates.
|
|
1289
1416
|
const onStep = observeSteps(({ name, type, path }) => (end) => {
|
|
1290
1417
|
const durationMs = Math.round(end.durationMs * 1000) / 1000;
|
|
1291
|
-
//
|
|
1292
|
-
//
|
|
1293
|
-
// recording changed what production did. A Parallel's result is its decision, which holds no user
|
|
1294
|
-
// data and has to survive intact for a replay to reproduce it, so `redact` is not asked about it.
|
|
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.
|
|
1295
1420
|
const recorded = (/** @type {any} */ result) =>
|
|
1296
1421
|
type === 'Parallel' ? snapshot(result) : safeRedact(snapshot(result), name, 'result');
|
|
1297
1422
|
push(
|
|
@@ -1327,8 +1452,8 @@ const recorder = (options = {}) => {
|
|
|
1327
1452
|
|
|
1328
1453
|
/**
|
|
1329
1454
|
* Runs a flow for real while recording every Command result, and returns both the
|
|
1330
|
-
* outcome and a replayable trace. Convenient in tests and scripts;
|
|
1331
|
-
*
|
|
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.
|
|
1332
1457
|
*
|
|
1333
1458
|
* @param {(input: any) => Effect} flowFn - Builds the Effect tree from its input
|
|
1334
1459
|
* @param {any} initialInput - The value the flow is called with; stored so a replay can rebuild it
|
|
@@ -1339,35 +1464,46 @@ const recorder = (options = {}) => {
|
|
|
1339
1464
|
const recordEffect = async (flowFn, initialInput, options = {}) => {
|
|
1340
1465
|
const { context = {}, version, ...recorderOptions } = options;
|
|
1341
1466
|
const rec = recorder(recorderOptions);
|
|
1342
|
-
// Packaged before the run,
|
|
1343
|
-
//
|
|
1344
|
-
// what the trace says production received, and the replay was rebuilt from a value the run never saw.
|
|
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.
|
|
1345
1469
|
const head = rec.toTrace({ initialInput, flowName: context.flowName, context, version });
|
|
1346
|
-
//
|
|
1347
|
-
// its spans.
|
|
1470
|
+
// Merged over the global wiring, so recording inside an instrumented application keeps its spans.
|
|
1348
1471
|
const result = await runEffect(flowFn(initialInput), context, { onStep: rec.onStep });
|
|
1349
1472
|
const { dropped, trace } = rec.toTrace();
|
|
1350
1473
|
return { result, trace: { ...head, dropped, trace } };
|
|
1351
1474
|
};
|
|
1352
1475
|
|
|
1353
1476
|
/**
|
|
1354
|
-
*
|
|
1355
|
-
*
|
|
1356
|
-
*
|
|
1357
|
-
*
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
*
|
|
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.
|
|
1363
1501
|
*
|
|
1364
1502
|
* @param {TraceEntry} entry
|
|
1365
1503
|
* @returns {ReplayOutcome}
|
|
1366
1504
|
*/
|
|
1367
1505
|
const entryToOutcome = (entry) =>
|
|
1368
|
-
entry
|
|
1369
|
-
? { error: reviveError(snapshot(entry.error)) }
|
|
1370
|
-
: { result: snapshot(entry.result) };
|
|
1506
|
+
entryThrew(entry) ? { error: reviveError(snapshot(entry.error)) } : { result: snapshot(entry.result) };
|
|
1371
1507
|
|
|
1372
1508
|
/**
|
|
1373
1509
|
* Whether an entry is a Parallel's recorded decision rather than a Command's result. A Parallel's path
|
|
@@ -1380,24 +1516,16 @@ const isDecisionEntry = (entry) =>
|
|
|
1380
1516
|
entry.command === 'Parallel' && typeof entry.path === 'string' && entry.path.endsWith('p');
|
|
1381
1517
|
|
|
1382
1518
|
/**
|
|
1383
|
-
* Builds a Resolver for the reference trace format. Internal: `replayEffect`
|
|
1384
|
-
*
|
|
1385
|
-
* in some other shape write a Resolver instead, which is the extension point.
|
|
1386
|
-
*
|
|
1387
|
-
* Path matching is used whenever every entry carries a `path`, which is every trace this
|
|
1388
|
-
* version records. A path is the Command's position in the Effect tree, so it is order-independent
|
|
1389
|
-
* and still paradox-detecting: it works for `Parallel` as it is, and it tells two branches that
|
|
1390
|
-
* call the same Command apart by where they are rather than by which finished first.
|
|
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.
|
|
1391
1521
|
*
|
|
1392
|
-
*
|
|
1393
|
-
*
|
|
1394
|
-
*
|
|
1395
|
-
* is
|
|
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.
|
|
1396
1526
|
*
|
|
1397
|
-
* A step the trace does not hold resolves to `undefined`,
|
|
1398
|
-
*
|
|
1399
|
-
* which meant `onMissing: 'execute'` had no effect on a trace. `missing` describes such a step, so the
|
|
1400
|
-
* error `replayEffect` raises under `'throw'` still says what the trace lacks.
|
|
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.
|
|
1401
1529
|
*
|
|
1402
1530
|
* @param {TraceLog | TraceEntry[]} traceLog - A reference-format trace, or a bare array of entries
|
|
1403
1531
|
* @param {Object} [options]
|
|
@@ -1414,11 +1542,10 @@ const fromTrace = (traceLog, options = {}) => {
|
|
|
1414
1542
|
return entryToOutcome(entry);
|
|
1415
1543
|
};
|
|
1416
1544
|
|
|
1417
|
-
if (entries.length > 0 && entries.every(
|
|
1545
|
+
if (entries.length > 0 && entries.every(hasPath)) {
|
|
1418
1546
|
const byPath = new Map(entries.map((e) => [e.path, e]));
|
|
1419
|
-
// Paths are
|
|
1420
|
-
// either
|
|
1421
|
-
// 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.
|
|
1422
1549
|
if (byPath.size !== entries.length) {
|
|
1423
1550
|
throw replayError('Trace has duplicate step paths.');
|
|
1424
1551
|
}
|
|
@@ -1437,8 +1564,7 @@ const fromTrace = (traceLog, options = {}) => {
|
|
|
1437
1564
|
const resolve = (step) => {
|
|
1438
1565
|
const entry = byPath.get(step.path);
|
|
1439
1566
|
if (step.type === 'Parallel') {
|
|
1440
|
-
// A decision rather than I/O. With none recorded, the Parallel replays under timing
|
|
1441
|
-
// it did before decisions were recorded.
|
|
1567
|
+
// A decision rather than I/O. With none recorded, the Parallel replays under timing.
|
|
1442
1568
|
if (entry && entry.command !== 'Parallel') throw timeParadox(step, entry.command);
|
|
1443
1569
|
if (entry) return resolveEntry(entry);
|
|
1444
1570
|
if (stoppedInProduction(step.path)) throw replayCutError(/** @type {string} */ (step.path));
|
|
@@ -1461,10 +1587,9 @@ const fromTrace = (traceLog, options = {}) => {
|
|
|
1461
1587
|
const resolve = (step) => {
|
|
1462
1588
|
// A trace with no paths predates recorded decisions, so a Parallel replays under timing.
|
|
1463
1589
|
if (step.type === 'Parallel') return undefined;
|
|
1464
|
-
// Positional matching pairs steps by completion order, which
|
|
1465
|
-
//
|
|
1466
|
-
|
|
1467
|
-
if (typeof step.path === 'string' && /p\d+\//.test(step.path)) {
|
|
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)) {
|
|
1468
1593
|
throw replayError(
|
|
1469
1594
|
`Trace carries no paths, so '${step.name}' inside a Parallel cannot be matched positionally.`,
|
|
1470
1595
|
{ command: step.name, path: step.path }
|
|
@@ -1479,58 +1604,15 @@ const fromTrace = (traceLog, options = {}) => {
|
|
|
1479
1604
|
return { resolve, missing };
|
|
1480
1605
|
};
|
|
1481
1606
|
|
|
1482
|
-
/**
|
|
1483
|
-
* Rewrites `Retry` nodes to zero delay, lazily, through their `next` continuations.
|
|
1484
|
-
*
|
|
1485
|
-
* Needed because a delay is written at the call site and nothing outside the node
|
|
1486
|
-
* can override it: retry options are per-use only. Without this, replaying a flow
|
|
1487
|
-
* that retried in production waits out the production backoff.
|
|
1488
|
-
*
|
|
1489
|
-
* @param {any} eff - Any Effect node
|
|
1490
|
-
* @returns {any} The same tree with Retry delays removed
|
|
1491
|
-
*/
|
|
1492
|
-
const zeroRetryDelays = (eff) => {
|
|
1493
|
-
if (!eff || typeof eff.type !== 'string') return eff;
|
|
1494
|
-
switch (eff.type) {
|
|
1495
|
-
case 'Retry': {
|
|
1496
|
-
const options = { ...eff.options, delay: 0, backoff: 1 };
|
|
1497
|
-
// The fallback tree only exists once onExhausted runs, so it is rewritten lazily too;
|
|
1498
|
-
// otherwise a Retry inside the fallback keeps its production backoff during replay.
|
|
1499
|
-
if (typeof options.onExhausted === 'function') {
|
|
1500
|
-
const original = options.onExhausted;
|
|
1501
|
-
options.onExhausted = (/** @type {any} */ error) => zeroRetryDelays(original(error));
|
|
1502
|
-
}
|
|
1503
|
-
return {
|
|
1504
|
-
...eff,
|
|
1505
|
-
options,
|
|
1506
|
-
effect: zeroRetryDelays(eff.effect),
|
|
1507
|
-
next: (/** @type {any} */ value) => zeroRetryDelays(eff.next(value))
|
|
1508
|
-
};
|
|
1509
|
-
}
|
|
1510
|
-
case 'Command':
|
|
1511
|
-
return { ...eff, next: (/** @type {any} */ result) => zeroRetryDelays(eff.next(result)) };
|
|
1512
|
-
case 'Ask':
|
|
1513
|
-
return { ...eff, next: (/** @type {any} */ ctx) => zeroRetryDelays(eff.next(ctx)) };
|
|
1514
|
-
case 'Parallel':
|
|
1515
|
-
return {
|
|
1516
|
-
...eff,
|
|
1517
|
-
effects: eff.effects.map(zeroRetryDelays),
|
|
1518
|
-
next: (/** @type {any} */ values) => zeroRetryDelays(eff.next(values))
|
|
1519
|
-
};
|
|
1520
|
-
default:
|
|
1521
|
-
return eff;
|
|
1522
|
-
}
|
|
1523
|
-
};
|
|
1524
|
-
|
|
1525
1607
|
/**
|
|
1526
1608
|
* @typedef {Object} ReplayOptions
|
|
1527
|
-
* @property {any} [context] - Context for `Ask`;
|
|
1528
|
-
* @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.
|
|
1529
1612
|
* @property {boolean} [hooks] - Runs the replay inside the hooks `configureEffect` installed, with the
|
|
1530
1613
|
* resolver innermost, so a configured `onStep` observes each replayed step and `onRun` and
|
|
1531
1614
|
* `onBeforeCommand` fire. Off by default, which ignores the global hooks, so a replay cannot
|
|
1532
|
-
* reach a telemetry backend or a guardrail that performs I/O.
|
|
1533
|
-
* node either way, so a replay makes the attempts production made.
|
|
1615
|
+
* reach a telemetry backend or a guardrail that performs I/O.
|
|
1534
1616
|
* @property {'throw' | 'execute'} [onMissing] - What to do when the resolver has no recording for a step.
|
|
1535
1617
|
* `'throw'` (default) fails the replay, which makes side effects impossible for the whole run.
|
|
1536
1618
|
* `'execute'` runs the real Command, giving partial replay: recorded prefix, live tail.
|
|
@@ -1545,8 +1627,7 @@ const zeroRetryDelays = (eff) => {
|
|
|
1545
1627
|
* A flow that stops issuing Commands before its recording ends mismatches nothing, so no
|
|
1546
1628
|
* `TimeParadox` fires and `result` can be a `Success` with recorded steps left over. That is
|
|
1547
1629
|
* sometimes the point of a fix and sometimes a fix that quietly dropped a step; `unreached` is
|
|
1548
|
-
* the only place the difference shows
|
|
1549
|
-
* behind an option.
|
|
1630
|
+
* the only place the difference shows.
|
|
1550
1631
|
* @typedef {{ result: SuccessState | FailureState, unreached?: TraceEntry[] }} Replay
|
|
1551
1632
|
*/
|
|
1552
1633
|
|
|
@@ -1569,8 +1650,7 @@ const zeroRetryDelays = (eff) => {
|
|
|
1569
1650
|
* @param {ReplayOptions} [options]
|
|
1570
1651
|
* @returns {Promise<Replay>} `{ result, unreached }` for a trace, `{ result }` for a Resolver
|
|
1571
1652
|
*/
|
|
1572
|
-
// `async` so a malformed trace arrives as a rejection rather than a synchronous throw
|
|
1573
|
-
// 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.
|
|
1574
1654
|
const replayEffect = async (effect, traceOrResolver, options = {}) => {
|
|
1575
1655
|
const { fastRetry = true, hooks = false, onMissing = 'throw', onResolved } = options;
|
|
1576
1656
|
// A trace is data and a Resolver is a function, so nothing else is needed to tell them
|
|
@@ -1581,16 +1661,14 @@ const replayEffect = async (effect, traceOrResolver, options = {}) => {
|
|
|
1581
1661
|
const { resolve, missing } = fromResolver
|
|
1582
1662
|
? { resolve: traceOrResolver, missing: undefined }
|
|
1583
1663
|
: fromTrace(traceOrResolver, { onEntry: (entry) => void reached.add(entry) });
|
|
1584
|
-
//
|
|
1585
|
-
//
|
|
1586
|
-
// flag it, whenever a caller passed only the flow and the trace, as the README's replays do.
|
|
1664
|
+
// Defaults to the trace's context: an `Ask` gate replayed with another one takes another branch, and
|
|
1665
|
+
// no paradox flags it.
|
|
1587
1666
|
const recorded = fromResolver || Array.isArray(traceOrResolver) ? undefined : traceOrResolver.context;
|
|
1588
1667
|
const context = options.context ?? recorded ?? {};
|
|
1589
1668
|
let index = 0;
|
|
1590
|
-
// What `onResolved` threw
|
|
1591
|
-
//
|
|
1592
|
-
//
|
|
1593
|
-
// rather than annotated, since only the step runner assigns it.
|
|
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.
|
|
1594
1672
|
let observerFailure = /** @type {{ error: unknown } | undefined} */ (undefined);
|
|
1595
1673
|
|
|
1596
1674
|
/** @type {StepRunner} */
|
|
@@ -1614,9 +1692,13 @@ const replayEffect = async (effect, traceOrResolver, options = {}) => {
|
|
|
1614
1692
|
if (outcome === undefined) {
|
|
1615
1693
|
if (onMissing !== 'execute') {
|
|
1616
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.
|
|
1617
1697
|
throw replayError(
|
|
1618
|
-
`${what}; refusing to run the real Command. ` +
|
|
1619
|
-
|
|
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.',
|
|
1620
1702
|
{ command: name, index: step.index, path }
|
|
1621
1703
|
);
|
|
1622
1704
|
}
|
|
@@ -1626,25 +1708,19 @@ const replayEffect = async (effect, traceOrResolver, options = {}) => {
|
|
|
1626
1708
|
return outcome.result;
|
|
1627
1709
|
};
|
|
1628
1710
|
|
|
1629
|
-
//
|
|
1630
|
-
//
|
|
1631
|
-
// observes each replayed step and the Command still never executes. Retry shape needs nothing from
|
|
1632
|
-
// the wiring either way, since retry options live on the node: a replay makes the attempts production
|
|
1633
|
-
// made whichever way this is set.
|
|
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.
|
|
1634
1713
|
/** @type {CallConfiguration} */
|
|
1635
1714
|
const callConfig = { onStep, inherit: hooks };
|
|
1636
1715
|
|
|
1637
|
-
// The interpreter rethrows a replay fault
|
|
1638
|
-
//
|
|
1639
|
-
// `Success` whose branches each carried the ReplayError as though production had returned it. It
|
|
1640
|
-
// becomes a `Failure` here instead, which is the shape this function has always returned and the
|
|
1641
|
-
// one place where nothing downstream can catch it.
|
|
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.
|
|
1642
1718
|
let result;
|
|
1643
1719
|
try {
|
|
1644
|
-
result = await
|
|
1720
|
+
result = await interpret(effect, context, callConfig, fastRetry);
|
|
1645
1721
|
} catch (e) {
|
|
1646
1722
|
if (observerFailure) throw observerFailure.error;
|
|
1647
|
-
if (!(e
|
|
1723
|
+
if (!hasMark(e, replayFault)) throw e;
|
|
1648
1724
|
result = Failure(e, effect.initialInput);
|
|
1649
1725
|
}
|
|
1650
1726
|
if (fromResolver) return { result };
|
|
@@ -1670,9 +1746,8 @@ const replayEffect = async (effect, traceOrResolver, options = {}) => {
|
|
|
1670
1746
|
const timeTravel = async (flowFn, traceLog, options = {}) => {
|
|
1671
1747
|
const { log = console.log, context, version } = options;
|
|
1672
1748
|
const { initialInput, trace, flowName, version: traceVersion } = traceLog;
|
|
1673
|
-
// `message`
|
|
1674
|
-
//
|
|
1675
|
-
// recorded result can hold, and narration must not be what fails a replay.
|
|
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.
|
|
1676
1751
|
const format = (/** @type {any} */ v) => {
|
|
1677
1752
|
try {
|
|
1678
1753
|
return JSON.stringify(v instanceof Error ? { ...v, name: v.name, message: v.message } : v, null, 2);
|
|
@@ -1684,19 +1759,23 @@ const timeTravel = async (flowFn, traceLog, options = {}) => {
|
|
|
1684
1759
|
if (version && traceVersion && version !== traceVersion) {
|
|
1685
1760
|
log(`Warning: trace was recorded at ${traceVersion}, replaying against ${version}.`);
|
|
1686
1761
|
}
|
|
1687
|
-
//
|
|
1688
|
-
//
|
|
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.
|
|
1689
1771
|
const commandCount = trace.filter((e) => !isDecisionEntry(e)).length;
|
|
1690
1772
|
const stepsText = commandCount === 1 ? 'step' : 'steps';
|
|
1691
1773
|
log(`Replaying '${flowName || 'flow'}' (${commandCount} recorded ${stepsText})`);
|
|
1692
1774
|
log(`Initial input: ${format(initialInput)}`);
|
|
1693
1775
|
|
|
1694
|
-
//
|
|
1695
|
-
//
|
|
1696
|
-
|
|
1697
|
-
// timings are narrated for Parallel too. Positional lookup is the fallback for a trace with no paths,
|
|
1698
|
-
// which is how such a trace is matched anyway.
|
|
1699
|
-
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]));
|
|
1700
1779
|
const timing = (/** @type {ReplayStep} */ step) => {
|
|
1701
1780
|
const recorded = byPath.get(step.path) ?? trace[step.index];
|
|
1702
1781
|
return typeof recorded?.durationMs === 'number' ? ` in ${recorded.durationMs}ms` : '';
|
|
@@ -1717,15 +1796,12 @@ const timeTravel = async (flowFn, traceLog, options = {}) => {
|
|
|
1717
1796
|
|
|
1718
1797
|
log(`Replay finished with state: ${result.type}`);
|
|
1719
1798
|
log(result.type === 'Failure' ? `Error: ${format(result.error)}` : `Result: ${format(result.value)}`);
|
|
1720
|
-
// After a replay error the flow never got past the divergence,
|
|
1721
|
-
//
|
|
1722
|
-
// 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.
|
|
1723
1801
|
const haltedByReplay = result.type === 'Failure' && /^(TimeParadox|ReplayError)$/.test(result.error?.name);
|
|
1724
1802
|
if (unreached.length > 0 && !haltedByReplay) {
|
|
1725
1803
|
// Named, not just counted: the step a fix stopped issuing is usually the one under suspicion.
|
|
1726
|
-
const names = unreached.map((e) =>
|
|
1727
|
-
typeof e.path === 'string' ? `${e.command} (path '${e.path}')` : e.command
|
|
1728
|
-
);
|
|
1804
|
+
const names = unreached.map((e) => (hasPath(e) ? `${e.command} (path '${e.path}')` : e.command));
|
|
1729
1805
|
const count = unreached.length === 1 ? '1 recorded step was' : `${unreached.length} recorded steps were`;
|
|
1730
1806
|
log(`Warning: ${count} never reached: ${names.join(', ')}. The flow diverged.`);
|
|
1731
1807
|
}
|