pure-effect 0.13.0 → 0.14.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 +44 -0
- package/README.md +259 -113
- package/index.d.ts +1118 -134
- package/index.js +647 -156
- package/package.json +3 -1
package/index.js
CHANGED
|
@@ -34,11 +34,18 @@
|
|
|
34
34
|
* }} RetryState
|
|
35
35
|
*/
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* `settled` hands branch outcomes to `next` instead of failing on the first one; `limit` caps how many
|
|
39
|
+
* branches run at once.
|
|
40
|
+
* @typedef {{ limit?: number, settled?: boolean }} ParallelOptions
|
|
41
|
+
*/
|
|
42
|
+
|
|
37
43
|
/**
|
|
38
44
|
* @typedef {{
|
|
39
45
|
* type: 'Parallel',
|
|
40
46
|
* effects: Effect[],
|
|
41
47
|
* next: (values: any[]) => Effect,
|
|
48
|
+
* options?: ParallelOptions,
|
|
42
49
|
* initialInput?: any
|
|
43
50
|
* }} ParallelState
|
|
44
51
|
*/
|
|
@@ -121,7 +128,8 @@ const Ask = (next) => ({ type: 'Ask', next });
|
|
|
121
128
|
* every Command in it is idempotent.
|
|
122
129
|
*
|
|
123
130
|
* @param {Effect} effect - The inner Effect tree to retry
|
|
124
|
-
* @param {Object} [options] -
|
|
131
|
+
* @param {Object} [options] - Retry options, merged over the library defaults at runtime; there are no
|
|
132
|
+
* configured defaults, since how a dependency misbehaves is a property of that dependency
|
|
125
133
|
* @param {number} [options.attempts] - Max retries (not counting first try)
|
|
126
134
|
* @param {number} [options.delay] - Ms before first retry
|
|
127
135
|
* @param {number} [options.backoff] - Multiplier applied to delay on each subsequent retry
|
|
@@ -140,24 +148,39 @@ const Retry = (effect, options = {}) => ({
|
|
|
140
148
|
|
|
141
149
|
/**
|
|
142
150
|
* Runs multiple Effect trees concurrently. The first branch to fail cancels its siblings, and that
|
|
143
|
-
* branch's Failure is what the Parallel returns; `next` is skipped.
|
|
144
|
-
*
|
|
151
|
+
* branch's Failure is what the Parallel returns; `next` is skipped. Which branch fails first depends on
|
|
152
|
+
* timing, so it is recorded, and a replay returns the same one.
|
|
145
153
|
*
|
|
146
154
|
* Cancellation is cooperative and works at two levels. A cancelled branch starts no further Commands,
|
|
147
155
|
* which needs nothing from the caller. Stopping the Command already in flight needs its thunk to accept
|
|
148
156
|
* the `AbortSignal` it is passed and hand it to whatever performs the I/O; a thunk that ignores the
|
|
149
157
|
* signal runs to completion, so a branch's first Command can still write after a sibling has failed.
|
|
150
158
|
*
|
|
159
|
+
* `settled: true` turns off that first-failure rule: every branch runs to completion and `next`
|
|
160
|
+
* receives the branch outcomes themselves, `Success` and `Failure` nodes in array order, so one
|
|
161
|
+
* record's failure cannot abort a batch. An `EffectTypeError` still escapes, because a malformed flow
|
|
162
|
+
* is a bug rather than a branch outcome. `limit: n` keeps at most `n` branches in flight, for a
|
|
163
|
+
* resource that rate limits; results and paths stay in array order either way.
|
|
164
|
+
*
|
|
165
|
+
* The second argument is the `next` function or the options, whichever it looks like, so
|
|
166
|
+
* `Parallel(effects, { limit: 5 })` needs no placeholder.
|
|
167
|
+
*
|
|
151
168
|
* @param {Effect[]} effects - Array of Effect trees to run concurrently
|
|
152
|
-
* @param {(values: any[]) => Effect} [
|
|
153
|
-
*
|
|
169
|
+
* @param {((values: any[]) => Effect) | ParallelOptions} [nextOrOptions] - Receives array of success
|
|
170
|
+
* values in order and returns the next Effect, or the options. `next` defaults to
|
|
171
|
+
* `(values) => Success(values)`, same as `Command`'s default.
|
|
172
|
+
* @param {ParallelOptions} [maybeOptions] - Options, when `next` was given
|
|
154
173
|
* @returns {ParallelState}
|
|
155
174
|
*/
|
|
156
|
-
const Parallel = (effects,
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
175
|
+
const Parallel = (effects, nextOrOptions, maybeOptions) => {
|
|
176
|
+
const hasNext = typeof nextOrOptions === 'function';
|
|
177
|
+
return {
|
|
178
|
+
type: 'Parallel',
|
|
179
|
+
effects,
|
|
180
|
+
next: hasNext ? nextOrOptions : (/** @type {any[]} */ values) => Success(values),
|
|
181
|
+
options: (hasNext ? maybeOptions : nextOrOptions) ?? {}
|
|
182
|
+
};
|
|
183
|
+
};
|
|
161
184
|
|
|
162
185
|
/**
|
|
163
186
|
* Describes a value for an error message, leading with the mistake it most likely is.
|
|
@@ -169,13 +192,53 @@ const describeValue = (value) =>
|
|
|
169
192
|
? 'undefined, which usually means a missing return'
|
|
170
193
|
: value === null
|
|
171
194
|
? 'null'
|
|
172
|
-
:
|
|
173
|
-
? 'a
|
|
174
|
-
: typeof value === '
|
|
175
|
-
?
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
195
|
+
: value instanceof Promise
|
|
196
|
+
? 'a Promise, which usually means an async function'
|
|
197
|
+
: typeof value === 'function'
|
|
198
|
+
? 'a function, which usually means a flow was passed without being called with its input'
|
|
199
|
+
: typeof value === 'object'
|
|
200
|
+
? typeof value.type === 'string'
|
|
201
|
+
? `an object with an unrecognised type '${value.type}'`
|
|
202
|
+
: 'a plain object'
|
|
203
|
+
: `the ${typeof value} ${JSON.stringify(value)}`;
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Marks an error as the harness failing rather than the flow. The interpreter rethrows anything
|
|
207
|
+
* carrying it instead of folding it into a `Failure`, which keeps it out of reach of everything that
|
|
208
|
+
* legitimately handles a domain failure: a Command's `next`, `Retry`'s `onExhausted`, and the outcomes
|
|
209
|
+
* a settled `Parallel` collects. A malformed flow and a trace that cannot answer a step are both this
|
|
210
|
+
* kind of error, and a new one gets the mark rather than a new name for the interpreter to know about.
|
|
211
|
+
* Non-enumerable, so it cannot show up in a serialized error or upset a `deepEqual` on an outcome.
|
|
212
|
+
*/
|
|
213
|
+
const harnessError = Symbol('pure-effect.harnessError');
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* @param {Error} error
|
|
217
|
+
* @returns {Error}
|
|
218
|
+
*/
|
|
219
|
+
const asHarnessError = (error) => Object.defineProperty(error, harnessError, { value: true });
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* An I/O fault: a Command's function threw, or a `Retry` ran out of attempts, rather than the flow
|
|
223
|
+
* deciding to stop. `Retry` acts on this and not on a `Failure` a step returned, which is an abort.
|
|
224
|
+
* Internal: only `execute` returns one, and it becomes a plain `Failure` wherever it would reach user
|
|
225
|
+
* code, at the `runEffect` boundary and in a settled `Parallel`'s outcomes. So whatever a step returns
|
|
226
|
+
* is an abort, and no object a caller holds carries provenance it could keep or lose by accident.
|
|
227
|
+
* @typedef {{ type: 'IoFault', error: any, initialInput?: any }} IoFaultState
|
|
228
|
+
*/
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* @param {any} error
|
|
232
|
+
* @param {any} [initialInput]
|
|
233
|
+
* @returns {IoFaultState}
|
|
234
|
+
*/
|
|
235
|
+
const IoFault = (error, initialInput) => ({ type: 'IoFault', error, initialInput });
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* @param {SuccessState | FailureState | IoFaultState} state
|
|
239
|
+
* @returns {SuccessState | FailureState}
|
|
240
|
+
*/
|
|
241
|
+
const asOutcome = (state) => (state.type === 'IoFault' ? Failure(state.error, state.initialInput) : state);
|
|
179
242
|
|
|
180
243
|
/**
|
|
181
244
|
* Checks that a value is an Effect, and explains the mistake when it is not.
|
|
@@ -187,14 +250,24 @@ const describeValue = (value) =>
|
|
|
187
250
|
* @param {string} source - What produced the value, named where it is known
|
|
188
251
|
* @returns {Error}
|
|
189
252
|
*/
|
|
190
|
-
const effectTypeError = (value, source) =>
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
253
|
+
const effectTypeError = (value, source) => {
|
|
254
|
+
const isPromise = value instanceof Promise;
|
|
255
|
+
// This error already reports the bug, so a rejection of the same Promise must not also crash the
|
|
256
|
+
// process as unhandled after the caller has caught it. Only a native Promise, which is what an async
|
|
257
|
+
// function returns: calling `then` on a query builder from Knex or Mongoose runs the query.
|
|
258
|
+
if (isPromise) value.catch(() => {});
|
|
259
|
+
return asHarnessError(
|
|
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
|
+
)
|
|
197
269
|
);
|
|
270
|
+
};
|
|
198
271
|
|
|
199
272
|
/**
|
|
200
273
|
* @param {any} value
|
|
@@ -208,18 +281,13 @@ const asEffect = (value, source) => {
|
|
|
208
281
|
|
|
209
282
|
/**
|
|
210
283
|
* Connects an Effect to the next function in the pipeline.
|
|
211
|
-
* Handles the branching logic for Success, Failure, Command, Ask, and
|
|
284
|
+
* Handles the branching logic for Success, Failure, Command, Ask, Retry, and Parallel.
|
|
212
285
|
*
|
|
213
286
|
* @param {Effect} effect - The current Effect object
|
|
214
287
|
* @param {(value: any) => Effect} fn - The next function to run if the current effect is a Success
|
|
288
|
+
* @param {any} [initialInput] - The pipeline's starting value, stamped on every node but a Success
|
|
215
289
|
* @returns {Effect} The composed Effect
|
|
216
290
|
*/
|
|
217
|
-
/**
|
|
218
|
-
* @param {Effect} effect
|
|
219
|
-
* @param {(value: any) => Effect} fn
|
|
220
|
-
* @param {any} [initialInput]
|
|
221
|
-
* @returns {Effect}
|
|
222
|
-
*/
|
|
223
291
|
const chain = (effect, fn, initialInput) => {
|
|
224
292
|
// The outermost pipeline wins. A step can return a sub-pipeline whose nodes already carry that
|
|
225
293
|
// sub-pipeline's start, and an outer chain reaches every one of them through the wrapped `next`
|
|
@@ -233,9 +301,9 @@ const chain = (effect, fn, initialInput) => {
|
|
|
233
301
|
initialInput !== undefined && e.type !== 'Success' ? { ...e, initialInput } : e;
|
|
234
302
|
|
|
235
303
|
// A continuation that returned nothing has to be caught before `effect.type` is read. Reading it off
|
|
236
|
-
// `undefined` throws a bare TypeError, and
|
|
237
|
-
//
|
|
238
|
-
//
|
|
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.
|
|
239
307
|
if (effect == null) return asEffect(effect, 'A continuation');
|
|
240
308
|
|
|
241
309
|
switch (effect.type) {
|
|
@@ -286,10 +354,12 @@ const effectPipe = (...fns) => {
|
|
|
286
354
|
};
|
|
287
355
|
|
|
288
356
|
/**
|
|
289
|
-
* Wraps one Command execution
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
357
|
+
* Wraps one Command execution, or one Parallel: `type` is 'Parallel', and `op` runs the branches and
|
|
358
|
+
* 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` identifies the step's position in the Effect tree rather than its position
|
|
360
|
+
* in completion order, so it is the same in a replay as it was in the recorded run even when `Parallel`
|
|
361
|
+
* branches finish in a different order. Hooks written before `path` existed take three parameters and
|
|
362
|
+
* are unaffected.
|
|
293
363
|
* @typedef {(name: string, type: string, op: function, path?: string) => Promise<any>} StepRunner
|
|
294
364
|
*/
|
|
295
365
|
/** @type StepRunner */
|
|
@@ -305,19 +375,32 @@ const defaultCommandInterceptor = async (command, context) => {};
|
|
|
305
375
|
|
|
306
376
|
const defaultRetryOptions = { attempts: 3, delay: 100, backoff: 1 };
|
|
307
377
|
|
|
378
|
+
/**
|
|
379
|
+
* Retry options were once a process-wide default that a per-use `Retry` merged over. They describe how
|
|
380
|
+
* one dependency misbehaves rather than a property of the process, so they are per-use only now, and a
|
|
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.
|
|
383
|
+
* @param {any} config
|
|
384
|
+
* @param {string} source
|
|
385
|
+
*/
|
|
386
|
+
const rejectRetryKey = (config, source) => {
|
|
387
|
+
if (config && typeof config === 'object' && 'retry' in config)
|
|
388
|
+
throw new TypeError(
|
|
389
|
+
`${source} no longer takes 'retry'. Retry options are per-use: pass them to Retry(effect, options).`
|
|
390
|
+
);
|
|
391
|
+
};
|
|
392
|
+
|
|
308
393
|
/**
|
|
309
394
|
* @typedef {Object} EffectConfiguration
|
|
310
395
|
* @property {StepRunner} [onStep] - Fires every time a Command is executed. It wraps the `cmd` call.
|
|
311
396
|
* @property {RunWrapper} [onRun] - Fires once per runEffect call. It wraps the entire workflow execution.
|
|
312
397
|
* @property {CommandInterceptor} [onBeforeCommand] - Intercepts a Command and any context passed to runEffect before execution.
|
|
313
|
-
* @property {{ attempts?: number, delay?: number, backoff?: number }} [retry] - Global Retry defaults; merged under per-use options.
|
|
314
398
|
*/
|
|
315
399
|
|
|
316
400
|
/**
|
|
317
401
|
* A per-call configuration: an `EffectConfiguration` plus `inherit`. With `inherit: true` (the default)
|
|
318
402
|
* the call's hooks are added to the wiring `configureEffect` installed, by the merge `configureEffect`
|
|
319
|
-
* applies to several configurations: global outermost, interceptors in order
|
|
320
|
-
* call winning. With `inherit: false` the global wiring is not consulted at all, so any slot the call
|
|
403
|
+
* applies to several configurations: global outermost, interceptors in order. With `inherit: false` the global wiring is not consulted at all, so any slot the call
|
|
321
404
|
* leaves unset falls back to the library default. A per-call hook cannot replace a single global slot on
|
|
322
405
|
* its own; that was the previous default, and it is how a per-call recorder silently switched off an
|
|
323
406
|
* application's tracing.
|
|
@@ -343,14 +426,14 @@ const applyLayers = () => {
|
|
|
343
426
|
};
|
|
344
427
|
|
|
345
428
|
/**
|
|
346
|
-
* Adds a configuration to the global wiring of the Effect runner: telemetry
|
|
347
|
-
*
|
|
429
|
+
* Adds a configuration to the global wiring of the Effect runner: telemetry and the command
|
|
430
|
+
* interceptor. Retry options are not part of it; they are per-use, passed to `Retry(effect, options)`.
|
|
348
431
|
*
|
|
349
432
|
* Each call adds one layer on top of those already installed and returns a function that removes that
|
|
350
433
|
* layer, wherever it sits by then. Layers merge the way several configurations passed to one call do:
|
|
351
434
|
* `onStep` and `onRun` are wrappers, so they nest with the earliest layer outermost and the latest
|
|
352
|
-
* closest to the Command; `onBeforeCommand` interceptors all run, in the order installed
|
|
353
|
-
*
|
|
435
|
+
* closest to the Command; and `onBeforeCommand` interceptors all run, in the order installed. So these
|
|
436
|
+
* are the same:
|
|
354
437
|
*
|
|
355
438
|
* configureEffect(telemetryHooks(), recordingHooks({ sink }));
|
|
356
439
|
* configureEffect(telemetryHooks()); configureEffect(recordingHooks({ sink }));
|
|
@@ -377,6 +460,8 @@ const configureEffect = (...configs) => {
|
|
|
377
460
|
return () => {};
|
|
378
461
|
}
|
|
379
462
|
const present = configs.filter(Boolean);
|
|
463
|
+
// Checked before anything is installed, so a refused call leaves the wiring exactly as it found it.
|
|
464
|
+
present.forEach((config) => rejectRetryKey(config, 'configureEffect'));
|
|
380
465
|
if (present.length === 0) return () => {};
|
|
381
466
|
const layer = chainHooks(...present);
|
|
382
467
|
layers = [...layers, layer];
|
|
@@ -390,8 +475,8 @@ const configureEffect = (...configs) => {
|
|
|
390
475
|
|
|
391
476
|
/**
|
|
392
477
|
* @typedef {Object} StepStart
|
|
393
|
-
* @property {string} name - `cmd.name`,
|
|
394
|
-
* @property {string} type -
|
|
478
|
+
* @property {string} name - The Command's identity: `meta.name`, else `cmd.name`, else 'anonymous'.
|
|
479
|
+
* @property {string} type - 'Command', or 'Parallel' for a Parallel's decision.
|
|
395
480
|
* @property {string} [path] - The Command's position in the Effect tree.
|
|
396
481
|
*/
|
|
397
482
|
|
|
@@ -446,9 +531,8 @@ const observeSteps = (handler) => async (name, type, op, path) => {
|
|
|
446
531
|
* `onStep` and `onRun` are wrappers around an `op`, so they nest. The first config given is
|
|
447
532
|
* the outermost wrapper and the last sits closest to the Command, which also means a thrown
|
|
448
533
|
* Command unwinds from the last config back to the first. `onBeforeCommand` is an observer,
|
|
449
|
-
* so every interceptor runs in the order given.
|
|
450
|
-
*
|
|
451
|
-
* default for that slot.
|
|
534
|
+
* so every interceptor runs in the order given. Nothing else merges, since every slot is a hook. A hook
|
|
535
|
+
* no config defines is left unset, so the caller of the merged wiring keeps its default for that slot.
|
|
452
536
|
*
|
|
453
537
|
* @param {...(EffectConfiguration | undefined)} configs - Configurations to merge, outermost first
|
|
454
538
|
* @returns {EffectConfiguration}
|
|
@@ -465,7 +549,6 @@ const chainHooks = (...configs) => {
|
|
|
465
549
|
const steps = /** @type {StepRunner[]} */ (present('onStep'));
|
|
466
550
|
const runs = /** @type {RunWrapper[]} */ (present('onRun'));
|
|
467
551
|
const interceptors = /** @type {CommandInterceptor[]} */ (present('onBeforeCommand'));
|
|
468
|
-
const retries = present('retry');
|
|
469
552
|
|
|
470
553
|
if (steps.length) {
|
|
471
554
|
merged.onStep = steps.reduceRight(
|
|
@@ -482,7 +565,6 @@ const chainHooks = (...configs) => {
|
|
|
482
565
|
for (const intercept of interceptors) await intercept(command, context);
|
|
483
566
|
};
|
|
484
567
|
}
|
|
485
|
-
if (retries.length) merged.retry = Object.assign({}, ...retries);
|
|
486
568
|
return merged;
|
|
487
569
|
};
|
|
488
570
|
|
|
@@ -499,6 +581,67 @@ const parallelCancelled = () =>
|
|
|
499
581
|
name: 'ParallelCancelled'
|
|
500
582
|
});
|
|
501
583
|
|
|
584
|
+
/**
|
|
585
|
+
* Which branch, if any, cancelled a Parallel. `branch: null` means an enclosing Parallel cancelled it.
|
|
586
|
+
* It is recorded as the Parallel's own step, since it is decided by timing and a replay cannot recompute it.
|
|
587
|
+
* @typedef {{ cancelled: false } | { cancelled: true, branch: number | null }} ParallelDecision
|
|
588
|
+
*/
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Reads a recorded decision, or `undefined` for anything that is not one, so a Resolver written for
|
|
592
|
+
* Commands, or a trace from before decisions were recorded, replays a Parallel under timing as before.
|
|
593
|
+
* @param {any} value
|
|
594
|
+
* @param {number} branches - How many branches the Parallel has, so a stale branch index is refused too
|
|
595
|
+
* @returns {ParallelDecision | undefined}
|
|
596
|
+
*/
|
|
597
|
+
const asDecision = (value, branches) => {
|
|
598
|
+
if (!value || typeof value !== 'object') return undefined;
|
|
599
|
+
if (value.cancelled === false) return { cancelled: false };
|
|
600
|
+
if (value.cancelled !== true) return undefined;
|
|
601
|
+
const { branch } = value;
|
|
602
|
+
if (branch === null) return { cancelled: true, branch: null };
|
|
603
|
+
return Number.isInteger(branch) && branch >= 0 && branch < branches ? { cancelled: true, branch } : undefined;
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Marks the point where a replay reached a step that production never ran, in a branch a Parallel had
|
|
608
|
+
* cancelled. The interpreter stops the branch there, as production did, rather than treating the gap
|
|
609
|
+
* as a divergence. Only `fromTrace` raises it, and only where a recorded decision explains the gap.
|
|
610
|
+
*/
|
|
611
|
+
const replayCut = Symbol('pure-effect.replayCut');
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* @param {string} path
|
|
615
|
+
* @returns {Error}
|
|
616
|
+
*/
|
|
617
|
+
const replayCutError = (path) =>
|
|
618
|
+
Object.defineProperty(
|
|
619
|
+
asHarnessError(new Error(`Replay stopped a cancelled Parallel branch at path '${path}'.`)),
|
|
620
|
+
replayCut,
|
|
621
|
+
{ value: true }
|
|
622
|
+
);
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Awaits every task, with at most `limit` of them in flight. Without a limit this is `Promise.all`,
|
|
626
|
+
* which is what `Parallel` did before the option existed. Workers pull by index, so a slow branch
|
|
627
|
+
* holds one slot rather than a position: results land where the caller put the effect, never where it
|
|
628
|
+
* happened to finish, which is also what keeps trace paths stable.
|
|
629
|
+
* @param {(() => Promise<void>)[]} tasks
|
|
630
|
+
* @param {number} [limit]
|
|
631
|
+
* @returns {Promise<void>}
|
|
632
|
+
*/
|
|
633
|
+
const runBounded = async (tasks, limit) => {
|
|
634
|
+
if (!limit || limit >= tasks.length) {
|
|
635
|
+
await Promise.all(tasks.map((task) => task()));
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
let cursor = 0;
|
|
639
|
+
const worker = async () => {
|
|
640
|
+
for (let i = cursor++; i < tasks.length; i = cursor++) await tasks[i]();
|
|
641
|
+
};
|
|
642
|
+
await Promise.all(Array.from({ length: limit }, worker));
|
|
643
|
+
};
|
|
644
|
+
|
|
502
645
|
/**
|
|
503
646
|
* Sleeps, but gives up early when the surrounding branch is cancelled, so a sibling's failure does not
|
|
504
647
|
* have to wait out a retry backoff that is now pointless. Resolves either way: the caller's abort check
|
|
@@ -514,6 +657,13 @@ const delayFor = (ms, signal) =>
|
|
|
514
657
|
setTimeout(resolve, ms);
|
|
515
658
|
return;
|
|
516
659
|
}
|
|
660
|
+
// A listener added to a signal that has already fired never runs. That is the usual case, not an edge:
|
|
661
|
+
// a Command that honours the signal rejects the moment a sibling fails, and the Retry around it starts
|
|
662
|
+
// its backoff on the aborted signal.
|
|
663
|
+
if (signal.aborted) {
|
|
664
|
+
resolve();
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
517
667
|
const onAbort = () => {
|
|
518
668
|
clearTimeout(timer);
|
|
519
669
|
resolve();
|
|
@@ -542,6 +692,7 @@ const runEffect =
|
|
|
542
692
|
* @returns {Promise<SuccessState | FailureState>}
|
|
543
693
|
*/
|
|
544
694
|
async function runEffect(effect, context = {}, callConfig = {}) {
|
|
695
|
+
rejectRetryKey(callConfig, "runEffect's callConfig");
|
|
545
696
|
const { inherit = true, ...local } = callConfig;
|
|
546
697
|
// A value that is not a boolean, such as a string left over from an older API, must not be
|
|
547
698
|
// coerced: `'false'` inheriting everything is exactly the silent behaviour this option removes.
|
|
@@ -556,7 +707,6 @@ const runEffect =
|
|
|
556
707
|
const localStepRunner = resolved.onStep || defaultStepRunner;
|
|
557
708
|
const localRunWrapper = resolved.onRun || defaultRunWrapper;
|
|
558
709
|
const localCommandInterceptor = resolved.onBeforeCommand || defaultCommandInterceptor;
|
|
559
|
-
const localRetryDefaults = { ...defaultRetryOptions, ...resolved.retry };
|
|
560
710
|
|
|
561
711
|
/**
|
|
562
712
|
* @param {Effect} eff
|
|
@@ -568,7 +718,7 @@ const runEffect =
|
|
|
568
718
|
* attempt opens its own prefix, so a Command's full path depends only on the shape of the
|
|
569
719
|
* tree and not on the order branches happen to finish in. That is what lets a replay line
|
|
570
720
|
* a recorded step up with the step that asked for it.
|
|
571
|
-
* @returns {Promise<SuccessState | FailureState>}
|
|
721
|
+
* @returns {Promise<SuccessState | FailureState | IoFaultState>}
|
|
572
722
|
*/
|
|
573
723
|
async function execute(eff, signal, path = '') {
|
|
574
724
|
let step = 0;
|
|
@@ -584,8 +734,17 @@ const runEffect =
|
|
|
584
734
|
continue;
|
|
585
735
|
}
|
|
586
736
|
if (eff.type === 'Retry') {
|
|
587
|
-
const opts = { ...
|
|
737
|
+
const opts = { ...defaultRetryOptions, ...eff.options };
|
|
588
738
|
const { attempts } = opts;
|
|
739
|
+
// A Retry that does not retry is not a Retry, and `attempts: 0` was also the one
|
|
740
|
+
// spelling that made `onExhausted` a plain catch for free. Both readings are wrong,
|
|
741
|
+
// so the value is refused rather than coerced.
|
|
742
|
+
if (!Number.isInteger(attempts) || attempts < 1)
|
|
743
|
+
throw new TypeError(
|
|
744
|
+
`Retry 'attempts' must be a positive integer, received ${describeValue(attempts)}. ` +
|
|
745
|
+
`To handle an outcome without retrying, branch on it as data in the Command's ` +
|
|
746
|
+
`next, or isolate a failing branch with Parallel's settled option.`
|
|
747
|
+
);
|
|
589
748
|
let lastError;
|
|
590
749
|
let succeeded = false;
|
|
591
750
|
// Each attempt gets its own prefix, so the Commands of attempt 2 cannot be mistaken
|
|
@@ -609,13 +768,19 @@ const runEffect =
|
|
|
609
768
|
succeeded = true;
|
|
610
769
|
break;
|
|
611
770
|
}
|
|
771
|
+
// An abort is the flow deciding, not the I/O failing, so there is nothing to
|
|
772
|
+
// try again and nothing for a fallback to answer. It leaves unwrapped: the
|
|
773
|
+
// exhaustion shape would be a claim about retrying that never happened.
|
|
774
|
+
if (result.type !== 'IoFault') return result;
|
|
612
775
|
lastError = result.error;
|
|
613
776
|
}
|
|
614
777
|
|
|
615
778
|
if (!succeeded) {
|
|
616
779
|
const exhausted = { retryExhausted: true, lastError, attempts };
|
|
617
780
|
const { onExhausted } = opts;
|
|
618
|
-
|
|
781
|
+
// An exhaustion came from I/O, so it is a fault too, which is what keeps an
|
|
782
|
+
// enclosing Retry retrying this one.
|
|
783
|
+
if (typeof onExhausted !== 'function') return IoFault(exhausted, eff.initialInput);
|
|
619
784
|
// A cancelled branch must not start its fallback, for the same reason it starts
|
|
620
785
|
// no further Commands: recovery must not resurrect work a sibling's failure ended.
|
|
621
786
|
if (signal?.aborted) return Failure(parallelCancelled(), eff.initialInput);
|
|
@@ -628,7 +793,7 @@ const runEffect =
|
|
|
628
793
|
);
|
|
629
794
|
// A failing fallback propagates as-is: the flow's last word was the fallback's
|
|
630
795
|
// error, not the exhaustion it was already told about.
|
|
631
|
-
if (fallback.type
|
|
796
|
+
if (fallback.type !== 'Success') return fallback;
|
|
632
797
|
eff = retryNext(fallback.value);
|
|
633
798
|
}
|
|
634
799
|
continue;
|
|
@@ -638,42 +803,164 @@ const runEffect =
|
|
|
638
803
|
// branches complete in, which is the whole point: two branches calling the same
|
|
639
804
|
// Command are told apart by where they are rather than by who finished first.
|
|
640
805
|
const branchPath = `${path}${step++}p`;
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
}
|
|
806
|
+
const { limit, settled } = eff.options ?? {};
|
|
807
|
+
if (limit !== undefined && (!Number.isInteger(limit) || limit < 1))
|
|
808
|
+
throw new TypeError(
|
|
809
|
+
`Parallel 'limit' must be a positive integer, received ${describeValue(limit)}.`
|
|
810
|
+
);
|
|
811
|
+
// Captured while `eff` is still narrowed to a Parallel node, since `op` below runs later.
|
|
812
|
+
const effects = eff.effects;
|
|
813
|
+
const parallelInput = eff.initialInput;
|
|
814
|
+
|
|
815
|
+
/** @type {(SuccessState | FailureState | IoFaultState)[]} */
|
|
816
|
+
let results = [];
|
|
817
|
+
// Cast rather than annotated: `runBranches` reassigns it, which the checker cannot see from
|
|
818
|
+
// here, and an annotated literal would be narrowed to `{ cancelled: false }` for good.
|
|
819
|
+
let decision = /** @type {ParallelDecision} */ ({ cancelled: false });
|
|
820
|
+
let ran = false;
|
|
821
|
+
// What a branch threw, held until this Parallel's step has returned its decision. Cast
|
|
822
|
+
// rather than annotated, for the same reason as `decision`.
|
|
823
|
+
let branchThrow = /** @type {{ error: unknown } | undefined} */ (undefined);
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* Runs the branches and returns the decision: which branch, if any, cancelled the others.
|
|
827
|
+
* That decision comes from timing, so it is handed to `onStep` as this Parallel's step
|
|
828
|
+
* and recorded like a Command's result. A replay passes the recorded decision back in,
|
|
829
|
+
* and a cancelled one is reproduced rather than recomputed: nothing aborts, each branch
|
|
830
|
+
* runs its recorded steps and is stopped where its recording stops, and the recorded
|
|
831
|
+
* branch's failure is the result. Replaying the abort at replay speed is what let a
|
|
832
|
+
* cancelled branch's AbortError win, or let a branch run past where production stopped it.
|
|
833
|
+
* A branch that throws gets a decision too, and its throw is rethrown once the step has
|
|
834
|
+
* returned it: thrown from here, the step failed and recorded the error where the decision
|
|
835
|
+
* belonged, so the replay of a run that crashed raced the branches again.
|
|
836
|
+
* @param {any} [recorded]
|
|
837
|
+
* @returns {Promise<ParallelDecision>}
|
|
838
|
+
*/
|
|
839
|
+
const runBranches = async (recorded) => {
|
|
840
|
+
ran = true;
|
|
841
|
+
// A recorded decision naming a branch past the end of this Parallel means the shape
|
|
842
|
+
// changed since the recording. Falling back to timing would let whichever remaining
|
|
843
|
+
// branch the replay reaches first decide, which is the wrong answer this fixed. A
|
|
844
|
+
// negative branch is not a decision at all, since none is ever recorded, so it replays
|
|
845
|
+
// by timing like anything else that is not one.
|
|
846
|
+
const recordedBranch = recorded?.cancelled === true ? recorded.branch : undefined;
|
|
847
|
+
if (Number.isInteger(recordedBranch) && recordedBranch >= effects.length) {
|
|
848
|
+
const count = effects.length === 1 ? '1 branch' : `${effects.length} branches`;
|
|
849
|
+
throw replayError(
|
|
850
|
+
`Time paradox at path '${branchPath}': the recorded run was cancelled by branch ` +
|
|
851
|
+
`${recordedBranch}, but this Parallel has ${count}.`,
|
|
852
|
+
{ name: 'TimeParadox', path: branchPath, branch: recordedBranch }
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
const forced = asDecision(recorded, effects.length);
|
|
856
|
+
const reproducing = forced !== undefined && forced.cancelled;
|
|
857
|
+
// One scope per Parallel, linked to the enclosing one so cancellation nests.
|
|
858
|
+
// Absent AbortController (very old runtimes), `branchSignal` stays undefined and
|
|
859
|
+
// the old run-everything-to-completion behaviour is what happens. The scope is kept
|
|
860
|
+
// under `settled` so an enclosing Parallel can still cancel this one; what settled
|
|
861
|
+
// drops is a branch cancelling its own siblings.
|
|
862
|
+
const scope = typeof AbortController === 'function' ? new AbortController() : undefined;
|
|
863
|
+
const branchSignal = scope?.signal;
|
|
864
|
+
const relay = () => scope?.abort();
|
|
865
|
+
if (signal && scope) {
|
|
866
|
+
if (signal.aborted) scope.abort();
|
|
867
|
+
else signal.addEventListener('abort', relay, { once: true });
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
results = new Array(effects.length);
|
|
871
|
+
// Which branch failed on its own account rather than because it was cancelled.
|
|
872
|
+
const triggered = new Array(effects.length).fill(false);
|
|
873
|
+
// A branch that throws (a bug in its code, or a harness error) cancels its siblings
|
|
874
|
+
// like a failing one, and is the trigger when it is the first to, under `settled` too,
|
|
875
|
+
// since the run is rejecting either way. When reproducing, nothing aborts: the recorded
|
|
876
|
+
// decision already says where each branch stops. The error is held rather than rethrown
|
|
877
|
+
// at once: rejecting `runBounded` early used to leave the siblings running unobserved,
|
|
878
|
+
// and under `limit` it stopped that worker.
|
|
879
|
+
/** @type {{ error: unknown }[]} */
|
|
880
|
+
const thrown = new Array(effects.length);
|
|
881
|
+
try {
|
|
882
|
+
// Still awaits every branch, so no cancelled work is left running unobserved
|
|
883
|
+
// after the Failure is returned. Cancelled branches settle promptly; a branch
|
|
884
|
+
// whose in-flight Command ignores the signal is the one case that does not.
|
|
885
|
+
await runBounded(
|
|
886
|
+
effects.map((e, i) => async () => {
|
|
887
|
+
let result;
|
|
888
|
+
try {
|
|
889
|
+
result = await execute(e, branchSignal, `${branchPath}${i}/`);
|
|
890
|
+
} catch (error) {
|
|
891
|
+
thrown[i] = { error };
|
|
892
|
+
if (reproducing) return;
|
|
893
|
+
if (!branchSignal?.aborted) triggered[i] = true;
|
|
894
|
+
scope?.abort();
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
results[i] = result;
|
|
898
|
+
// Read-then-abort is atomic here, so exactly one branch is the trigger.
|
|
899
|
+
const cancels = !settled && !reproducing && !branchSignal?.aborted;
|
|
900
|
+
if (result.type !== 'Success' && cancels) {
|
|
901
|
+
triggered[i] = true;
|
|
902
|
+
scope?.abort();
|
|
903
|
+
}
|
|
904
|
+
}),
|
|
905
|
+
limit
|
|
906
|
+
);
|
|
907
|
+
} finally {
|
|
908
|
+
if (signal && scope) signal.removeEventListener('abort', relay);
|
|
909
|
+
}
|
|
910
|
+
// The first by array order. A thrown error is held until every branch has settled, so
|
|
911
|
+
// position can decide here, where a failing branch is taken in the order it finishes.
|
|
912
|
+
branchThrow = thrown.find(Boolean);
|
|
913
|
+
|
|
914
|
+
if (forced !== undefined && forced.cancelled) {
|
|
915
|
+
// A branch that threw has no result to check, and its throw is what is rethrown.
|
|
916
|
+
if (!branchThrow && forced.branch !== null && results[forced.branch].type === 'Success') {
|
|
917
|
+
throw replayError(
|
|
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
|
+
};
|
|
651
935
|
|
|
652
|
-
/** @type {(SuccessState | FailureState)[]} */
|
|
653
|
-
const results = new Array(eff.effects.length);
|
|
654
|
-
// Which branch failed on its own account rather than because it was cancelled.
|
|
655
|
-
const triggered = new Array(eff.effects.length).fill(false);
|
|
656
936
|
try {
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
//
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
scope?.abort();
|
|
668
|
-
}
|
|
669
|
-
})
|
|
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.'
|
|
670
947
|
);
|
|
671
|
-
}
|
|
672
|
-
|
|
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;
|
|
673
958
|
}
|
|
674
959
|
|
|
675
|
-
const
|
|
676
|
-
|
|
960
|
+
const failure =
|
|
961
|
+
decision.cancelled && decision.branch !== null
|
|
962
|
+
? results[decision.branch]
|
|
963
|
+
: results.find((r) => r.type !== 'Success');
|
|
677
964
|
if (failure) return failure;
|
|
678
965
|
eff = eff.next(results.map((r) => /** @type {SuccessState} */ (r).value));
|
|
679
966
|
continue;
|
|
@@ -682,18 +969,54 @@ const runEffect =
|
|
|
682
969
|
const initialInput = eff.initialInput;
|
|
683
970
|
const cmdPath = `${path}${step++}`;
|
|
684
971
|
const cmd = eff.cmd;
|
|
685
|
-
//
|
|
686
|
-
//
|
|
687
|
-
|
|
972
|
+
// Whether the Command's function itself succeeded. That is what tells a throw from the step
|
|
973
|
+
// runner apart: the function failing is an I/O fault, while a hook failing after the function
|
|
974
|
+
// returned is a bug, and retrying it would repeat work that is already done.
|
|
975
|
+
let succeeded = false;
|
|
976
|
+
// The signal reaches the function only inside a Parallel, so a function written to take a
|
|
977
|
+
// parameter is not handed an argument it never expected anywhere else. `op` always returns a
|
|
978
|
+
// promise, as the declared type and the README's "must await op()" say, and a synchronous
|
|
979
|
+
// throw arrives as a rejection. It used to hand back a synchronous function's value as it was,
|
|
980
|
+
// so a hook written as `op().then(...)` compiled and then rejected every such run.
|
|
981
|
+
const op = async () => {
|
|
982
|
+
succeeded = false;
|
|
983
|
+
const value = await (signal ? cmd(signal) : cmd());
|
|
984
|
+
succeeded = true;
|
|
985
|
+
return value;
|
|
986
|
+
};
|
|
987
|
+
// Three separate regions, because a throw means something different in each. An
|
|
988
|
+
// interceptor that throws is vetoing the Command, which is the flow being stopped rather
|
|
989
|
+
// than the I/O failing, so it is an abort and `Retry` passes it on. Only a throw from the
|
|
990
|
+
// Command's function is an I/O fault. A malformed flow is a bug, not a domain failure,
|
|
991
|
+
// so in either catch it must not masquerade as one.
|
|
688
992
|
try {
|
|
689
993
|
await localCommandInterceptor(eff, context);
|
|
690
|
-
const result = await localStepRunner(cmdName, 'Command', op, cmdPath);
|
|
691
|
-
eff = eff.next(result);
|
|
692
994
|
} catch (e) {
|
|
693
|
-
|
|
694
|
-
if (e instanceof Error && e.name === 'EffectTypeError') throw e;
|
|
995
|
+
if (e && /** @type {any} */ (e)[harnessError]) throw e;
|
|
695
996
|
return Failure(e, initialInput);
|
|
696
997
|
}
|
|
998
|
+
// Checked again because an interceptor can wait, a rate limiter handing out tokens for one, and a
|
|
999
|
+
// sibling can fail meanwhile. The check at the top of the loop ran before that wait.
|
|
1000
|
+
if (signal?.aborted) return Failure(parallelCancelled(), initialInput);
|
|
1001
|
+
let result;
|
|
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);
|
|
1014
|
+
}
|
|
1015
|
+
// Outside both catches: `next`, and every pure step it reaches up to the next Command, is
|
|
1016
|
+
// code rather than I/O, so a throw there is a bug and rejects the run like a throw from
|
|
1017
|
+
// any other continuation. Inside the catch it was an I/O fault, and `Retry` re-ran a
|
|
1018
|
+
// Command that had succeeded because a TypeError followed it.
|
|
1019
|
+
eff = eff.next(result);
|
|
697
1020
|
}
|
|
698
1021
|
if (eff && (eff.type === 'Success' || eff.type === 'Failure')) return eff;
|
|
699
1022
|
throw effectTypeError(eff, 'The flow');
|
|
@@ -703,10 +1026,11 @@ const runEffect =
|
|
|
703
1026
|
// back. `chain` stamps only what passes through the continuations it wraps, and those subtrees
|
|
704
1027
|
// are executed directly rather than reached through a continuation, so their Failures arrive
|
|
705
1028
|
// carrying the subtree's own input or none. Restamping the outcome with the root's input is
|
|
706
|
-
// what makes "a Failure carries the input of the flow that was called" true at any depth.
|
|
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.
|
|
707
1031
|
const rootInput = effect?.initialInput;
|
|
708
1032
|
const run = async () => {
|
|
709
|
-
const result = await execute(effect);
|
|
1033
|
+
const result = asOutcome(await execute(effect));
|
|
710
1034
|
return result.type === 'Failure' && rootInput !== undefined ? Failure(result.error, rootInput) : result;
|
|
711
1035
|
};
|
|
712
1036
|
return localRunWrapper(effect, run, context?.flowName || '');
|
|
@@ -715,7 +1039,9 @@ const runEffect =
|
|
|
715
1039
|
/**
|
|
716
1040
|
* The step a replay is asking about. `path` is the Command's position in the Effect tree and is stable
|
|
717
1041
|
* across runs; `index` is its position in this run's completion order, which is not stable for a flow
|
|
718
|
-
* containing `Parallel`. Prefer `path` when writing a Resolver.
|
|
1042
|
+
* containing `Parallel`. Prefer `path` when writing a Resolver. A step whose `type` is 'Parallel' asks
|
|
1043
|
+
* for a Parallel's recorded decision; it does not advance `index`, and anything but a decision replays
|
|
1044
|
+
* that Parallel under timing.
|
|
719
1045
|
* @typedef {{ index: number, name: string, type: string, path?: string }} ReplayStep
|
|
720
1046
|
*/
|
|
721
1047
|
|
|
@@ -730,11 +1056,15 @@ const runEffect =
|
|
|
730
1056
|
/** @typedef {(step: ReplayStep) => ReplayOutcome | undefined} Resolver */
|
|
731
1057
|
|
|
732
1058
|
/**
|
|
733
|
-
* A recorded step
|
|
1059
|
+
* 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
|
+
* `durationMs` is how long the step took in production, rounded to microseconds,
|
|
734
1062
|
* which is the one question a trace could not answer before: which step was slow.
|
|
735
1063
|
* `path` locates the Command in the Effect tree, which is what a replay matches on: it does not move
|
|
736
1064
|
* when `Parallel` branches finish in a different order than they did in production.
|
|
737
|
-
*
|
|
1065
|
+
* `threw` marks a step that threw. The `error` key alone cannot, since JSON drops it when the value is
|
|
1066
|
+
* `undefined`, as it is for `reject()` with no argument or an error `redact` removed.
|
|
1067
|
+
* @typedef {{ command: string, path?: string, result?: any, threw?: true, error?: any, durationMs?: number }} TraceEntry
|
|
738
1068
|
*/
|
|
739
1069
|
|
|
740
1070
|
/**
|
|
@@ -751,13 +1081,29 @@ const runEffect =
|
|
|
751
1081
|
*/
|
|
752
1082
|
|
|
753
1083
|
/**
|
|
754
|
-
*
|
|
755
|
-
*
|
|
1084
|
+
* A replay fault: the trace cannot answer the flow, or disagrees with it. It is a harness error while
|
|
1085
|
+
* the flow runs, so nothing inside the flow can swallow it, and `replayEffect` turns it back into a
|
|
1086
|
+
* `Failure` at its own boundary, where there is no longer anything to swallow it and a caller can
|
|
1087
|
+
* inspect it. An `EffectTypeError` carries no replay mark and keeps propagating, since a malformed
|
|
1088
|
+
* flow is a bug in the flow rather than a problem with the trace.
|
|
1089
|
+
*/
|
|
1090
|
+
const replayFault = Symbol('pure-effect.replayFault');
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* Creates a replay error. Thrown from a resolver, it is rethrown by the interpreter rather than folded
|
|
1094
|
+
* into a Failure, and `replayEffect` turns it into one at its own boundary.
|
|
756
1095
|
* @param {string} message - Human-readable reason
|
|
757
1096
|
* @param {Object} [props] - Extra fields such as `name`, `index`, `expected`, `actual`
|
|
758
1097
|
* @returns {Error}
|
|
759
1098
|
*/
|
|
760
|
-
const replayError = (message, props = {}) =>
|
|
1099
|
+
const replayError = (message, props = {}) =>
|
|
1100
|
+
Object.defineProperty(
|
|
1101
|
+
asHarnessError(Object.assign(new Error(message), { name: 'ReplayError' }, props)),
|
|
1102
|
+
replayFault,
|
|
1103
|
+
{
|
|
1104
|
+
value: true
|
|
1105
|
+
}
|
|
1106
|
+
);
|
|
761
1107
|
|
|
762
1108
|
/**
|
|
763
1109
|
* Signals that the flow being replayed asked for a different Command than the trace
|
|
@@ -794,6 +1140,14 @@ const serializeError = (e, withStack) => {
|
|
|
794
1140
|
if (withStack) out.stack = e.stack;
|
|
795
1141
|
// `cause` is non-enumerable too, and it can itself be an Error, so it is carried recursively.
|
|
796
1142
|
if ('cause' in e) out.cause = serializeError(e.cause, withStack);
|
|
1143
|
+
// So is an AggregateError's `errors`, and that is where the detail lives: Node's refused connection to
|
|
1144
|
+
// localhost has an empty message and one entry per address it tried. It is kept under its own key so
|
|
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.
|
|
1147
|
+
const errors = /** @type {any} */ (e).errors;
|
|
1148
|
+
if (Array.isArray(errors) && !Object.prototype.propertyIsEnumerable.call(e, 'errors')) {
|
|
1149
|
+
out.__errors = errors.map((x) => serializeError(x, withStack));
|
|
1150
|
+
}
|
|
797
1151
|
for (const k of Object.keys(e)) out[k] = /** @type {any} */ (e)[k];
|
|
798
1152
|
return out;
|
|
799
1153
|
};
|
|
@@ -817,7 +1171,9 @@ const reviveError = (v) => {
|
|
|
817
1171
|
for (const [k, val] of Object.entries(v)) {
|
|
818
1172
|
if (k === '__error' || k === 'name' || k === 'message') continue;
|
|
819
1173
|
if (k === 'cause') Object.defineProperty(e, 'cause', { ...hidden, value: reviveError(val) });
|
|
820
|
-
else
|
|
1174
|
+
else if (k === '__errors' && Array.isArray(val)) {
|
|
1175
|
+
Object.defineProperty(e, 'errors', { ...hidden, value: val.map(reviveError) });
|
|
1176
|
+
} else /** @type {any} */ (e)[k] = val;
|
|
821
1177
|
}
|
|
822
1178
|
return e;
|
|
823
1179
|
};
|
|
@@ -828,7 +1184,8 @@ const reviveError = (v) => {
|
|
|
828
1184
|
* each Command's result, each serialized error, and the `initialInput` and `context` stored on the
|
|
829
1185
|
* trace itself. `kind` is `'result'`, `'error'`, `'initialInput'`, or `'context'`, and `name` is the
|
|
830
1186
|
* Command's name for the first two and the kind for the last two. It is the single place PII is kept
|
|
831
|
-
* out of a trace, so it has to see all four.
|
|
1187
|
+
* out of a trace, so it has to see all four. It is handed a copy, so changing the value in place
|
|
1188
|
+
* never reaches the run.
|
|
832
1189
|
* @property {number} [maxEntries] - Caps trace length; further steps are counted in `dropped`, not stored.
|
|
833
1190
|
* @property {boolean} [stack] - Records stack traces for thrown errors.
|
|
834
1191
|
*/
|
|
@@ -842,12 +1199,41 @@ const reviveError = (v) => {
|
|
|
842
1199
|
*/
|
|
843
1200
|
|
|
844
1201
|
/**
|
|
845
|
-
*
|
|
1202
|
+
* Copies what `structuredClone` refused. Arrays and plain objects are rebuilt and everything inside them is
|
|
1203
|
+
* 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, and keeping the whole context by reference
|
|
1205
|
+
* handed `redact` the live object the flow's `Ask` reads.
|
|
1206
|
+
*
|
|
1207
|
+
* @param {any} value
|
|
1208
|
+
* @param {Map<object, any>} seen - Copies made so far, so a cycle is copied as a cycle
|
|
1209
|
+
* @returns {any}
|
|
1210
|
+
*/
|
|
1211
|
+
const copyAround = (value, seen) => {
|
|
1212
|
+
if (value === null || typeof value !== 'object') return value;
|
|
1213
|
+
if (seen.has(value)) return seen.get(value);
|
|
1214
|
+
const proto = Object.getPrototypeOf(value);
|
|
1215
|
+
if (!Array.isArray(value) && proto !== Object.prototype && proto !== null) {
|
|
1216
|
+
try {
|
|
1217
|
+
return structuredClone(value);
|
|
1218
|
+
} catch {
|
|
1219
|
+
return value;
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
/** @type {any} */
|
|
1223
|
+
const copy = Array.isArray(value) ? [] : Object.create(proto);
|
|
1224
|
+
seen.set(value, copy);
|
|
1225
|
+
for (const key of Object.keys(value)) copy[key] = copyAround(value[key], seen);
|
|
1226
|
+
return copy;
|
|
1227
|
+
};
|
|
1228
|
+
|
|
1229
|
+
/**
|
|
1230
|
+
* Snapshots a value on its way into a trace, and on its way out of one in a replay.
|
|
846
1231
|
*
|
|
847
1232
|
* Commands return live objects, and a later step that mutates one would otherwise rewrite what the
|
|
848
1233
|
* trace says an earlier step returned: `toTrace` runs after the flow finishes, so by serialization
|
|
849
|
-
* time the mutation is already baked in and the trace records a value production never saw.
|
|
850
|
-
*
|
|
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.
|
|
851
1237
|
*
|
|
852
1238
|
* @param {any} value
|
|
853
1239
|
* @returns {any}
|
|
@@ -857,7 +1243,7 @@ const snapshot = (value) => {
|
|
|
857
1243
|
try {
|
|
858
1244
|
return typeof structuredClone === 'function' ? structuredClone(value) : JSON.parse(JSON.stringify(value));
|
|
859
1245
|
} catch {
|
|
860
|
-
return value;
|
|
1246
|
+
return copyAround(value, new Map());
|
|
861
1247
|
}
|
|
862
1248
|
};
|
|
863
1249
|
|
|
@@ -892,29 +1278,38 @@ const recorder = (options = {}) => {
|
|
|
892
1278
|
};
|
|
893
1279
|
|
|
894
1280
|
/**
|
|
895
|
-
*
|
|
1281
|
+
* Snapshots one of the trace's own fields and redacts the copy. `undefined` is left alone so a flow with no
|
|
896
1282
|
* context does not acquire an empty object from a redact function that spreads its argument.
|
|
897
1283
|
*/
|
|
898
1284
|
const redactField = (/** @type {any} */ value, /** @type {string} */ kind) =>
|
|
899
|
-
value === undefined ? undefined : snapshot(
|
|
1285
|
+
value === undefined ? undefined : safeRedact(snapshot(value), kind, kind);
|
|
900
1286
|
|
|
901
|
-
// Built on `
|
|
1287
|
+
// Built on `observeSteps` so the hook contract lives in one place: `op` always runs, its result is
|
|
902
1288
|
// always returned, and its error always propagates.
|
|
903
|
-
const onStep = observeSteps(({ name, path }) => (end) => {
|
|
1289
|
+
const onStep = observeSteps(({ name, type, path }) => (end) => {
|
|
904
1290
|
const durationMs = Math.round(end.durationMs * 1000) / 1000;
|
|
1291
|
+
// `redact` is handed a copy. Handed the live value, a redact that deleted a field in place removed it
|
|
1292
|
+
// from the result the flow's `next` was about to receive, or the error the caller would get, so
|
|
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.
|
|
1295
|
+
const recorded = (/** @type {any} */ result) =>
|
|
1296
|
+
type === 'Parallel' ? snapshot(result) : safeRedact(snapshot(result), name, 'result');
|
|
905
1297
|
push(
|
|
906
1298
|
'error' in end
|
|
907
1299
|
? {
|
|
908
1300
|
command: name,
|
|
909
1301
|
path,
|
|
910
|
-
|
|
1302
|
+
threw: true,
|
|
1303
|
+
error: safeRedact(snapshot(serializeError(end.error, stack)), name, 'error'),
|
|
911
1304
|
durationMs
|
|
912
1305
|
}
|
|
913
|
-
: { command: name, path, result:
|
|
1306
|
+
: { command: name, path, result: recorded(end.result), durationMs }
|
|
914
1307
|
);
|
|
915
1308
|
});
|
|
916
1309
|
|
|
917
1310
|
/**
|
|
1311
|
+
* Redacts and copies `meta.initialInput` and `meta.context` when it is called, so call it before the run
|
|
1312
|
+
* when the run can change them, and take `dropped` and `trace` from a second call once it ends.
|
|
918
1313
|
* @param {TraceMeta} [meta]
|
|
919
1314
|
* @returns {TraceLog}
|
|
920
1315
|
*/
|
|
@@ -944,22 +1339,45 @@ const recorder = (options = {}) => {
|
|
|
944
1339
|
const recordEffect = async (flowFn, initialInput, options = {}) => {
|
|
945
1340
|
const { context = {}, version, ...recorderOptions } = options;
|
|
946
1341
|
const rec = recorder(recorderOptions);
|
|
1342
|
+
// Packaged before the run, which is when `toTrace` copies the input and the context. Packaged after it, a
|
|
1343
|
+
// Command that wrote to either, such as an ORM save assigning an id to the object it was handed, rewrote
|
|
1344
|
+
// what the trace says production received, and the replay was rebuilt from a value the run never saw.
|
|
1345
|
+
const head = rec.toTrace({ initialInput, flowName: context.flowName, context, version });
|
|
947
1346
|
// The recorder is added to the global wiring, so recording inside an instrumented application keeps
|
|
948
1347
|
// its spans.
|
|
949
1348
|
const result = await runEffect(flowFn(initialInput), context, { onStep: rec.onStep });
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
trace: rec.toTrace({ initialInput, flowName: context.flowName, context, version })
|
|
953
|
-
};
|
|
1349
|
+
const { dropped, trace } = rec.toTrace();
|
|
1350
|
+
return { result, trace: { ...head, dropped, trace } };
|
|
954
1351
|
};
|
|
955
1352
|
|
|
956
1353
|
/**
|
|
957
1354
|
* Turns a recorded entry into the outcome a Resolver must return.
|
|
958
1355
|
*
|
|
1356
|
+
* The recorded value is snapshotted on the way out, as it was on the way in. Handing the flow the
|
|
1357
|
+
* entry's own object let a replayed step that mutates its result rewrite the trace, so replaying the
|
|
1358
|
+
* same trace twice gave two different answers.
|
|
1359
|
+
*
|
|
1360
|
+
* An entry with `threw` is an error, and so is one with an `error` key, which is how an entry recorded
|
|
1361
|
+
* before `threw` existed, or written by hand, says it. Relying on the key alone turned a step that threw
|
|
1362
|
+
* `undefined` into one that returned it, once the trace had been through JSON.
|
|
1363
|
+
*
|
|
959
1364
|
* @param {TraceEntry} entry
|
|
960
1365
|
* @returns {ReplayOutcome}
|
|
961
1366
|
*/
|
|
962
|
-
const entryToOutcome = (entry) =>
|
|
1367
|
+
const entryToOutcome = (entry) =>
|
|
1368
|
+
entry.threw === true || 'error' in entry
|
|
1369
|
+
? { error: reviveError(snapshot(entry.error)) }
|
|
1370
|
+
: { result: snapshot(entry.result) };
|
|
1371
|
+
|
|
1372
|
+
/**
|
|
1373
|
+
* Whether an entry is a Parallel's recorded decision rather than a Command's result. A Parallel's path
|
|
1374
|
+
* ends in its `p` marker and a Command's in its step number, so a Command that happens to be named
|
|
1375
|
+
* 'Parallel' is never mistaken for one.
|
|
1376
|
+
* @param {TraceEntry} entry
|
|
1377
|
+
* @returns {boolean}
|
|
1378
|
+
*/
|
|
1379
|
+
const isDecisionEntry = (entry) =>
|
|
1380
|
+
entry.command === 'Parallel' && typeof entry.path === 'string' && entry.path.endsWith('p');
|
|
963
1381
|
|
|
964
1382
|
/**
|
|
965
1383
|
* Builds a Resolver for the reference trace format. Internal: `replayEffect` calls this
|
|
@@ -976,11 +1394,16 @@ const entryToOutcome = (entry) => ('error' in entry ? { error: reviveError(entry
|
|
|
976
1394
|
* flow asks for. That is exact for sequential flows and cannot tell `Parallel` branches apart, which
|
|
977
1395
|
* is what paths were added to fix.
|
|
978
1396
|
*
|
|
1397
|
+
* A step the trace does not hold resolves to `undefined`, "not recorded", exactly as it does from a
|
|
1398
|
+
* Resolver, so `replayEffect` applies `onMissing` to both. This used to throw a `ReplayError` here,
|
|
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.
|
|
1401
|
+
*
|
|
979
1402
|
* @param {TraceLog | TraceEntry[]} traceLog - A reference-format trace, or a bare array of entries
|
|
980
1403
|
* @param {Object} [options]
|
|
981
1404
|
* @param {(entry: TraceEntry) => void} [options.onEntry] - Observes each entry as it is handed to a step,
|
|
982
1405
|
* which is how `replayEffect` learns which recorded entries the flow never asked for.
|
|
983
|
-
* @returns {Resolver}
|
|
1406
|
+
* @returns {{ resolve: Resolver, missing: (step: ReplayStep) => string }}
|
|
984
1407
|
*/
|
|
985
1408
|
const fromTrace = (traceLog, options = {}) => {
|
|
986
1409
|
const { onEntry } = options;
|
|
@@ -999,20 +1422,45 @@ const fromTrace = (traceLog, options = {}) => {
|
|
|
999
1422
|
if (byPath.size !== entries.length) {
|
|
1000
1423
|
throw replayError('Trace has duplicate step paths.');
|
|
1001
1424
|
}
|
|
1002
|
-
|
|
1425
|
+
/** Recorded Parallel decisions that cancelled branches, by the Parallel's path. */
|
|
1426
|
+
const cancellations = entries.filter(isDecisionEntry).filter((e) => e.result?.cancelled === true);
|
|
1427
|
+
// A missing step is where production stopped a branch when it lies in a branch a recorded decision
|
|
1428
|
+
// cancelled: any branch but the cancelling one, or every branch of a Parallel cancelled from outside.
|
|
1429
|
+
const stoppedInProduction = (/** @type {string | undefined} */ stepPath) =>
|
|
1430
|
+
typeof stepPath === 'string' &&
|
|
1431
|
+
cancellations.some((e) => {
|
|
1432
|
+
const at = /** @type {string} */ (e.path);
|
|
1433
|
+
const branch = stepPath.startsWith(at) ? /^(\d+)\//.exec(stepPath.slice(at.length)) : null;
|
|
1434
|
+
return branch !== null && (e.result.branch === null || Number(branch[1]) !== e.result.branch);
|
|
1435
|
+
});
|
|
1436
|
+
/** @type {Resolver} */
|
|
1437
|
+
const resolve = (step) => {
|
|
1003
1438
|
const entry = byPath.get(step.path);
|
|
1439
|
+
if (step.type === 'Parallel') {
|
|
1440
|
+
// A decision rather than I/O. With none recorded, the Parallel replays under timing, as
|
|
1441
|
+
// it did before decisions were recorded.
|
|
1442
|
+
if (entry && entry.command !== 'Parallel') throw timeParadox(step, entry.command);
|
|
1443
|
+
if (entry) return resolveEntry(entry);
|
|
1444
|
+
if (stoppedInProduction(step.path)) throw replayCutError(/** @type {string} */ (step.path));
|
|
1445
|
+
return undefined;
|
|
1446
|
+
}
|
|
1004
1447
|
if (!entry) {
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
});
|
|
1448
|
+
// Production never ran this step, so it is not run live under `onMissing` either.
|
|
1449
|
+
if (stoppedInProduction(step.path)) throw replayCutError(/** @type {string} */ (step.path));
|
|
1450
|
+
return undefined;
|
|
1009
1451
|
}
|
|
1010
1452
|
if (entry.command !== step.name) throw timeParadox(step, entry.command);
|
|
1011
1453
|
return resolveEntry(entry);
|
|
1012
1454
|
};
|
|
1455
|
+
const missing = (/** @type {ReplayStep} */ step) =>
|
|
1456
|
+
`Trace has no step at path '${step.path}' for '${step.name}'`;
|
|
1457
|
+
return { resolve, missing };
|
|
1013
1458
|
}
|
|
1014
1459
|
|
|
1015
|
-
|
|
1460
|
+
/** @type {Resolver} */
|
|
1461
|
+
const resolve = (step) => {
|
|
1462
|
+
// A trace with no paths predates recorded decisions, so a Parallel replays under timing.
|
|
1463
|
+
if (step.type === 'Parallel') return undefined;
|
|
1016
1464
|
// Positional matching pairs steps by completion order, which is exactly what makes Parallel
|
|
1017
1465
|
// branches indistinguishable. Refusing is better than a result that is right only when the
|
|
1018
1466
|
// replay happens to finish in production's order.
|
|
@@ -1023,19 +1471,20 @@ const fromTrace = (traceLog, options = {}) => {
|
|
|
1023
1471
|
);
|
|
1024
1472
|
}
|
|
1025
1473
|
const entry = entries[step.index];
|
|
1026
|
-
if (!entry)
|
|
1474
|
+
if (!entry) return undefined;
|
|
1027
1475
|
if (entry.command !== step.name) throw timeParadox(step, entry.command);
|
|
1028
1476
|
return resolveEntry(entry);
|
|
1029
1477
|
};
|
|
1478
|
+
const missing = (/** @type {ReplayStep} */ step) => `Trace exhausted: no entry #${step.index} for '${step.name}'`;
|
|
1479
|
+
return { resolve, missing };
|
|
1030
1480
|
};
|
|
1031
1481
|
|
|
1032
1482
|
/**
|
|
1033
1483
|
* Rewrites `Retry` nodes to zero delay, lazily, through their `next` continuations.
|
|
1034
1484
|
*
|
|
1035
|
-
* Needed because
|
|
1036
|
-
*
|
|
1037
|
-
*
|
|
1038
|
-
* retried in production waits out the production backoff.
|
|
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.
|
|
1039
1488
|
*
|
|
1040
1489
|
* @param {any} eff - Any Effect node
|
|
1041
1490
|
* @returns {any} The same tree with Retry delays removed
|
|
@@ -1080,8 +1529,8 @@ const zeroRetryDelays = (eff) => {
|
|
|
1080
1529
|
* @property {boolean} [hooks] - Runs the replay inside the hooks `configureEffect` installed, with the
|
|
1081
1530
|
* resolver innermost, so a configured `onStep` observes each replayed step and `onRun` and
|
|
1082
1531
|
* `onBeforeCommand` fire. Off by default, which ignores the global hooks, so a replay cannot
|
|
1083
|
-
* reach a telemetry backend or a guardrail that performs I/O.
|
|
1084
|
-
* either way,
|
|
1532
|
+
* reach a telemetry backend or a guardrail that performs I/O. Retry options travel with the
|
|
1533
|
+
* node either way, so a replay makes the attempts production made.
|
|
1085
1534
|
* @property {'throw' | 'execute'} [onMissing] - What to do when the resolver has no recording for a step.
|
|
1086
1535
|
* `'throw'` (default) fails the replay, which makes side effects impossible for the whole run.
|
|
1087
1536
|
* `'execute'` runs the real Command, giving partial replay: recorded prefix, live tail.
|
|
@@ -1105,7 +1554,7 @@ const zeroRetryDelays = (eff) => {
|
|
|
1105
1554
|
* Replays an Effect tree, feeding recorded results to Commands instead of running them.
|
|
1106
1555
|
*
|
|
1107
1556
|
* No side effect can occur by default. The interpreter's only execution point is
|
|
1108
|
-
* `await localStepRunner(cmdName, 'Command',
|
|
1557
|
+
* `await localStepRunner(cmdName, 'Command', op, cmdPath)`, which hands the Command thunk
|
|
1109
1558
|
* to `onStep` as `op` rather than calling it. The `onStep` installed here never invokes
|
|
1110
1559
|
* `op` unless `onMissing: 'execute'` is set, so `eff.cmd` is never applied and the I/O
|
|
1111
1560
|
* it describes does not happen.
|
|
@@ -1123,28 +1572,52 @@ const zeroRetryDelays = (eff) => {
|
|
|
1123
1572
|
// `async` so a malformed trace arrives as a rejection rather than a synchronous throw:
|
|
1124
1573
|
// the function otherwise returns a promise, and callers should not have to handle both.
|
|
1125
1574
|
const replayEffect = async (effect, traceOrResolver, options = {}) => {
|
|
1126
|
-
const {
|
|
1575
|
+
const { fastRetry = true, hooks = false, onMissing = 'throw', onResolved } = options;
|
|
1127
1576
|
// A trace is data and a Resolver is a function, so nothing else is needed to tell them
|
|
1128
1577
|
// apart, including the bare entries array that `fromTrace` also accepts.
|
|
1129
1578
|
const fromResolver = typeof traceOrResolver === 'function';
|
|
1130
1579
|
/** @type {Set<TraceEntry>} */
|
|
1131
1580
|
const reached = new Set();
|
|
1132
|
-
const resolve = fromResolver
|
|
1133
|
-
? traceOrResolver
|
|
1581
|
+
const { resolve, missing } = fromResolver
|
|
1582
|
+
? { resolve: traceOrResolver, missing: undefined }
|
|
1134
1583
|
: fromTrace(traceOrResolver, { onEntry: (entry) => void reached.add(entry) });
|
|
1584
|
+
// A trace carries the context production ran with, and `Ask` has to see it for the replay to take the
|
|
1585
|
+
// branches production took. An empty context replayed an `Ask` gate the other way, with no paradox to
|
|
1586
|
+
// flag it, whenever a caller passed only the flow and the trace, as the README's replays do.
|
|
1587
|
+
const recorded = fromResolver || Array.isArray(traceOrResolver) ? undefined : traceOrResolver.context;
|
|
1588
|
+
const context = options.context ?? recorded ?? {};
|
|
1135
1589
|
let index = 0;
|
|
1590
|
+
// What `onResolved` threw, if it did. The throw happened before the recorded result was handed back, so it
|
|
1591
|
+
// counted as the Command failing and a Retry asked for an attempt production never made. It stops the
|
|
1592
|
+
// replay instead, as a throw from any other code in the flow does, and the replay rejects with it. Cast
|
|
1593
|
+
// rather than annotated, since only the step runner assigns it.
|
|
1594
|
+
let observerFailure = /** @type {{ error: unknown } | undefined} */ (undefined);
|
|
1136
1595
|
|
|
1137
1596
|
/** @type {StepRunner} */
|
|
1138
1597
|
const onStep = async (name, type, op, path) => {
|
|
1598
|
+
if (type === 'Parallel') {
|
|
1599
|
+
// A Parallel's step carries its recorded decision into `op`, which runs the branches under it.
|
|
1600
|
+
// It is not a Command: `index` still counts Commands, and `onResolved` still sees only them.
|
|
1601
|
+
const outcome = resolve({ index, name, type, path });
|
|
1602
|
+
return await op(outcome && 'result' in outcome ? outcome.result : undefined);
|
|
1603
|
+
}
|
|
1139
1604
|
const step = { index: index++, name, type, path };
|
|
1140
1605
|
const outcome = resolve(step);
|
|
1141
|
-
if (onResolved)
|
|
1606
|
+
if (onResolved) {
|
|
1607
|
+
try {
|
|
1608
|
+
onResolved(step, outcome);
|
|
1609
|
+
} catch (error) {
|
|
1610
|
+
observerFailure ??= { error };
|
|
1611
|
+
throw asHarnessError(new Error('onResolved threw.'));
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1142
1614
|
if (outcome === undefined) {
|
|
1143
1615
|
if (onMissing !== 'execute') {
|
|
1616
|
+
const what = missing ? missing(step) : `No recorded outcome for '${name}' at step ${step.index}`;
|
|
1144
1617
|
throw replayError(
|
|
1145
|
-
|
|
1618
|
+
`${what}; refusing to run the real Command. ` +
|
|
1146
1619
|
`Pass onMissing: 'execute' to allow live I/O for unrecorded steps.`,
|
|
1147
|
-
{ command: name, index: step.index }
|
|
1620
|
+
{ command: name, index: step.index, path }
|
|
1148
1621
|
);
|
|
1149
1622
|
}
|
|
1150
1623
|
return await op();
|
|
@@ -1155,14 +1628,25 @@ const replayEffect = async (effect, traceOrResolver, options = {}) => {
|
|
|
1155
1628
|
|
|
1156
1629
|
// Off, a replay ignores the global hooks, so it can reach neither a telemetry backend nor a guardrail
|
|
1157
1630
|
// that performs I/O. On, it runs inside them with the resolver innermost, so a configured onStep
|
|
1158
|
-
// observes each replayed step and the Command still never executes.
|
|
1159
|
-
//
|
|
1160
|
-
//
|
|
1161
|
-
// never saw. Retry shape is part of what a replay reproduces; the hooks are not.
|
|
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.
|
|
1162
1634
|
/** @type {CallConfiguration} */
|
|
1163
|
-
const callConfig = { onStep, inherit: hooks
|
|
1164
|
-
|
|
1165
|
-
|
|
1635
|
+
const callConfig = { onStep, inherit: hooks };
|
|
1636
|
+
|
|
1637
|
+
// The interpreter rethrows a replay fault rather than folding it into a `Failure`, so that neither
|
|
1638
|
+
// `onExhausted` nor a settled `Parallel` can absorb one: a truncated trace used to replay as a
|
|
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.
|
|
1642
|
+
let result;
|
|
1643
|
+
try {
|
|
1644
|
+
result = await runEffect(fastRetry ? zeroRetryDelays(effect) : effect, context, callConfig);
|
|
1645
|
+
} catch (e) {
|
|
1646
|
+
if (observerFailure) throw observerFailure.error;
|
|
1647
|
+
if (!(e && /** @type {any} */ (e)[replayFault])) throw e;
|
|
1648
|
+
result = Failure(e, effect.initialInput);
|
|
1649
|
+
}
|
|
1166
1650
|
if (fromResolver) return { result };
|
|
1167
1651
|
// `fromTrace` has already validated the shape, so the entries are here in one form or the other.
|
|
1168
1652
|
const entries = Array.isArray(traceOrResolver) ? traceOrResolver : traceOrResolver.trace;
|
|
@@ -1187,17 +1671,24 @@ const timeTravel = async (flowFn, traceLog, options = {}) => {
|
|
|
1187
1671
|
const { log = console.log, context, version } = options;
|
|
1188
1672
|
const { initialInput, trace, flowName, version: traceVersion } = traceLog;
|
|
1189
1673
|
// `message` and `stack` are non-enumerable on Error, so JSON.stringify alone would
|
|
1190
|
-
// drop the most useful line of the report.
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1674
|
+
// drop the most useful line of the report. It throws on a BigInt and on a cycle, both of which a
|
|
1675
|
+
// recorded result can hold, and narration must not be what fails a replay.
|
|
1676
|
+
const format = (/** @type {any} */ v) => {
|
|
1677
|
+
try {
|
|
1678
|
+
return JSON.stringify(v instanceof Error ? { ...v, name: v.name, message: v.message } : v, null, 2);
|
|
1679
|
+
} catch {
|
|
1680
|
+
return String(v);
|
|
1681
|
+
}
|
|
1682
|
+
};
|
|
1195
1683
|
|
|
1196
1684
|
if (version && traceVersion && version !== traceVersion) {
|
|
1197
1685
|
log(`Warning: trace was recorded at ${traceVersion}, replaying against ${version}.`);
|
|
1198
1686
|
}
|
|
1199
|
-
|
|
1200
|
-
|
|
1687
|
+
// Parallel decisions are recorded alongside the Commands but are not narrated as steps, so the header
|
|
1688
|
+
// counts Commands and its step numbers match the lines below.
|
|
1689
|
+
const commandCount = trace.filter((e) => !isDecisionEntry(e)).length;
|
|
1690
|
+
const stepsText = commandCount === 1 ? 'step' : 'steps';
|
|
1691
|
+
log(`Replaying '${flowName || 'flow'}' (${commandCount} recorded ${stepsText})`);
|
|
1201
1692
|
log(`Initial input: ${format(initialInput)}`);
|
|
1202
1693
|
|
|
1203
1694
|
// Narration goes through `onResolved` rather than a wrapped Resolver: observing each
|
|
@@ -1213,7 +1704,7 @@ const timeTravel = async (flowFn, traceLog, options = {}) => {
|
|
|
1213
1704
|
const replay = await replayEffect(flowFn(initialInput), traceLog, {
|
|
1214
1705
|
context: context !== undefined ? context : traceLog.context || {},
|
|
1215
1706
|
onResolved: (step, outcome) => {
|
|
1216
|
-
//
|
|
1707
|
+
// A step the trace does not hold is refused or run live by `replayEffect`, not narrated here.
|
|
1217
1708
|
if (outcome === undefined) return;
|
|
1218
1709
|
log(
|
|
1219
1710
|
'error' in outcome
|