pure-effect 0.8.0 → 0.9.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/.claude/settings.local.json +9 -5
- package/.prettierrc +2 -1
- package/CLAUDE.md +135 -21
- package/README.md +257 -173
- package/examples/opentelemetry-example.js +164 -0
- package/examples/recording-example.js +104 -0
- package/index.d.ts +139 -5
- package/index.js +707 -14
- package/package.json +12 -6
- package/test/all.js +1519 -25
- package/test/types.test-d.ts +11 -0
- package/.dirac-symbol-index/data.db +0 -0
- package/opentelemetry-example.js +0 -91
package/index.js
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
/** @typedef {{ type: 'Success', value: any, initialInput?: any }} SuccessState */
|
|
4
4
|
/** @typedef {{ type: 'Failure', error: any, initialInput?: any }} FailureState */
|
|
5
|
+
/**
|
|
6
|
+
* Metadata attached to a Command. A string `name` is read by the interpreter as the Command's
|
|
7
|
+
* identity; every other key is carried through untouched for `onBeforeCommand`.
|
|
8
|
+
* @typedef {{ name?: string } & Record<string, any>} CommandMeta
|
|
9
|
+
*/
|
|
5
10
|
/**
|
|
6
11
|
* @typedef {{
|
|
7
12
|
* type: 'Command',
|
|
@@ -63,13 +68,39 @@ const Failure = (error, initialInput) => ({
|
|
|
63
68
|
});
|
|
64
69
|
|
|
65
70
|
/**
|
|
66
|
-
* Represents a side effect to be executed later
|
|
71
|
+
* Represents a side effect to be executed later.
|
|
72
|
+
*
|
|
67
73
|
* @param {() => Promise<any>|any} cmd - The side-effect function to execute
|
|
68
|
-
* @param {(result: any) => Effect} next -
|
|
69
|
-
*
|
|
74
|
+
* @param {(result: any) => Effect} [next] - Receives the result of `cmd` and returns the next Effect.
|
|
75
|
+
* Defaults to `(result) => Success(result)`, which is what most Commands want.
|
|
76
|
+
* @param {CommandMeta} [meta] - Optional metadata, passed to `onBeforeCommand`. A string `meta.name`
|
|
77
|
+
* becomes this Command's identity for traces, replay matching, and telemetry spans, which makes
|
|
78
|
+
* the identity independent of how `cmd` was declared and immune to minification.
|
|
70
79
|
* @returns {CommandState}
|
|
71
80
|
*/
|
|
72
|
-
const Command = (cmd, next
|
|
81
|
+
const Command = (cmd, next = (/** @type {any} */ result) => Success(result), meta) => ({
|
|
82
|
+
type: 'Command',
|
|
83
|
+
cmd,
|
|
84
|
+
next,
|
|
85
|
+
meta
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The name a Command is known by: what a trace records, what strict replay matches on, and what a
|
|
90
|
+
* telemetry span is called.
|
|
91
|
+
*
|
|
92
|
+
* `meta.name` wins when it is a non-empty string. Otherwise the identity is the thunk's own `name`,
|
|
93
|
+
* so an inline arrow records as 'anonymous' and a minifier that mangles function names silently
|
|
94
|
+
* renames every step of every trace. Passing `meta.name` avoids both.
|
|
95
|
+
*
|
|
96
|
+
* @param {CommandState} eff
|
|
97
|
+
* @returns {string}
|
|
98
|
+
*/
|
|
99
|
+
const commandName = (eff) => {
|
|
100
|
+
const meta = eff.meta;
|
|
101
|
+
const named = meta && typeof meta === 'object' ? meta.name : undefined;
|
|
102
|
+
return typeof named === 'string' && named !== '' ? named : eff.cmd.name || 'anonymous';
|
|
103
|
+
};
|
|
73
104
|
|
|
74
105
|
/**
|
|
75
106
|
* Reads the context object from the current `runEffect` call.
|
|
@@ -80,6 +111,12 @@ const Ask = (next) => ({ type: 'Ask', next });
|
|
|
80
111
|
|
|
81
112
|
/**
|
|
82
113
|
* Wraps an Effect tree with retry-on-failure semantics.
|
|
114
|
+
*
|
|
115
|
+
* Each attempt runs the **entire** wrapped tree again, including Commands that already succeeded, so
|
|
116
|
+
* wrap the one Command that fails transiently rather than a pipeline. `Retry(effectPipe(charge, receipt))`
|
|
117
|
+
* charges the customer again every time the receipt step fails. Wrapping a pipeline is only safe when
|
|
118
|
+
* every Command in it is idempotent.
|
|
119
|
+
*
|
|
83
120
|
* @param {Effect} effect - The inner Effect tree to retry
|
|
84
121
|
* @param {Object} [options] - Per-use retry options; merged over global defaults at runtime
|
|
85
122
|
* @param {number} [options.attempts] - Max retries (not counting first try)
|
|
@@ -102,6 +139,53 @@ const Retry = (effect, options = {}) => ({
|
|
|
102
139
|
*/
|
|
103
140
|
const Parallel = (effects, next) => ({ type: 'Parallel', effects, next });
|
|
104
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Describes a value for an error message, leading with the mistake it most likely is.
|
|
144
|
+
* @param {any} value
|
|
145
|
+
* @returns {string}
|
|
146
|
+
*/
|
|
147
|
+
const describeValue = (value) =>
|
|
148
|
+
value === undefined
|
|
149
|
+
? 'undefined, which usually means a missing return'
|
|
150
|
+
: value === null
|
|
151
|
+
? 'null'
|
|
152
|
+
: typeof value === 'function'
|
|
153
|
+
? 'a function, which usually means a flow was passed without being called with its input'
|
|
154
|
+
: typeof value === 'object'
|
|
155
|
+
? typeof value.type === 'string'
|
|
156
|
+
? `an object with an unrecognised type '${value.type}'`
|
|
157
|
+
: 'a plain object'
|
|
158
|
+
: `the ${typeof value} ${JSON.stringify(value)}`;
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Checks that a value is an Effect, and explains the mistake when it is not.
|
|
162
|
+
*
|
|
163
|
+
* `EffectTypeError` is a bug in the flow rather than a domain failure, so it is thrown instead of
|
|
164
|
+
* becoming a `Failure`, and the interpreter rethrows it rather than folding it into one.
|
|
165
|
+
*
|
|
166
|
+
* @param {any} value
|
|
167
|
+
* @param {string} source - What produced the value, named where it is known
|
|
168
|
+
* @returns {Error}
|
|
169
|
+
*/
|
|
170
|
+
const effectTypeError = (value, source) =>
|
|
171
|
+
Object.assign(
|
|
172
|
+
new Error(
|
|
173
|
+
`${source} returned ${describeValue(value)}. Return Success, Failure, Command, Ask, Retry, or Parallel: ` +
|
|
174
|
+
'a plain value has to be wrapped, as in Success(value).'
|
|
175
|
+
),
|
|
176
|
+
{ name: 'EffectTypeError' }
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* @param {any} value
|
|
181
|
+
* @param {string} source
|
|
182
|
+
* @returns {Effect}
|
|
183
|
+
*/
|
|
184
|
+
const asEffect = (value, source) => {
|
|
185
|
+
if (value && ['Success', 'Failure', 'Command', 'Ask', 'Retry', 'Parallel'].includes(value.type)) return value;
|
|
186
|
+
throw effectTypeError(value, source);
|
|
187
|
+
};
|
|
188
|
+
|
|
105
189
|
/**
|
|
106
190
|
* Connects an Effect to the next function in the pipeline.
|
|
107
191
|
* Handles the branching logic for Success, Failure, Command, Ask, and Retry.
|
|
@@ -122,7 +206,7 @@ const chain = (effect, fn, initialInput) => {
|
|
|
122
206
|
|
|
123
207
|
switch (effect.type) {
|
|
124
208
|
case 'Success':
|
|
125
|
-
return withII(fn(effect.value));
|
|
209
|
+
return withII(asEffect(fn(effect.value), `Step '${fn.name || 'anonymous'}'`));
|
|
126
210
|
case 'Failure':
|
|
127
211
|
return withII(effect);
|
|
128
212
|
case 'Command': {
|
|
@@ -141,6 +225,8 @@ const chain = (effect, fn, initialInput) => {
|
|
|
141
225
|
const next = (/** @type {any} */ result) => chain(effect.next(result), fn, initialInput);
|
|
142
226
|
return withII({ ...effect, next });
|
|
143
227
|
}
|
|
228
|
+
default:
|
|
229
|
+
return asEffect(effect, 'A continuation');
|
|
144
230
|
}
|
|
145
231
|
};
|
|
146
232
|
|
|
@@ -179,8 +265,8 @@ let retryDefaults = { ...defaultRetryOptions };
|
|
|
179
265
|
|
|
180
266
|
/**
|
|
181
267
|
* @typedef {Object} EffectConfiguration
|
|
182
|
-
* @property {StepRunner} [onStep] - Fires
|
|
183
|
-
* @property {RunWrapper} [onRun] - Fires
|
|
268
|
+
* @property {StepRunner} [onStep] - Fires every time a Command is executed. It wraps the `cmd` call.
|
|
269
|
+
* @property {RunWrapper} [onRun] - Fires once per runEffect call. It wraps the entire workflow execution.
|
|
184
270
|
* @property {CommandInterceptor} [onBeforeCommand] - Intercepts a Command and any context passed to runEffect before execution.
|
|
185
271
|
* @property {{ attempts?: number, delay?: number, backoff?: number }} [retry] - Global Retry defaults; merged under per-use options.
|
|
186
272
|
*/
|
|
@@ -188,13 +274,158 @@ let retryDefaults = { ...defaultRetryOptions };
|
|
|
188
274
|
/**
|
|
189
275
|
* Configures the global behavior of the Effect runner, including the command interceptor and telemetry.
|
|
190
276
|
*
|
|
191
|
-
*
|
|
277
|
+
* Several configurations can be passed and are merged, which is how independent concerns share the
|
|
278
|
+
* one slot each hook has:
|
|
279
|
+
*
|
|
280
|
+
* configureEffect(telemetryHooks(), recordingHooks({ sink }))
|
|
281
|
+
*
|
|
282
|
+
* `onStep` and `onRun` are wrappers, so they nest with the first configuration outermost and the last
|
|
283
|
+
* closest to the Command. `onBeforeCommand` interceptors all run, in the order given, and `retry`
|
|
284
|
+
* merges with later configurations winning.
|
|
285
|
+
*
|
|
286
|
+
* Merging happens per call and does not accumulate across calls: a later `configureEffect` still
|
|
287
|
+
* replaces the previous wiring entirely, and calling it with nothing resets every slot to its default.
|
|
288
|
+
*
|
|
289
|
+
* Returns a function that puts back whatever was installed when this call was made, so a caller can
|
|
290
|
+
* install hooks without owning the wiring forever. That is what makes this usable from a test, a
|
|
291
|
+
* request-scoped experiment, or a library that wraps this one: without it, installing anything means
|
|
292
|
+
* clobbering the host's telemetry permanently, since there is no way to read the current wiring.
|
|
293
|
+
* Restoring undoes this call only while its wiring is still in effect. If a later `configureEffect` has
|
|
294
|
+
* run since, restoring does nothing rather than discarding that newer wiring, so two callers installing
|
|
295
|
+
* and releasing in interleaved order cannot clobber each other.
|
|
296
|
+
*
|
|
297
|
+
* @param {...(EffectConfiguration | undefined)} configs - Configurations to merge, outermost first
|
|
298
|
+
* @returns {() => void} Restores the wiring that was in place before this call
|
|
192
299
|
*/
|
|
193
|
-
const configureEffect = (
|
|
300
|
+
const configureEffect = (...configs) => {
|
|
301
|
+
const previousStepRunner = stepRunner;
|
|
302
|
+
const previousRunWrapper = runWrapper;
|
|
303
|
+
const previousCommandInterceptor = commandInterceptor;
|
|
304
|
+
const previousRetryDefaults = retryDefaults;
|
|
305
|
+
|
|
306
|
+
const options = chainHooks(...configs);
|
|
194
307
|
stepRunner = options.onStep ? options.onStep : defaultStepRunner;
|
|
195
308
|
runWrapper = options.onRun ? options.onRun : defaultRunWrapper;
|
|
196
309
|
commandInterceptor = options.onBeforeCommand ? options.onBeforeCommand : defaultCommandInterceptor;
|
|
197
310
|
retryDefaults = options.retry ? { ...defaultRetryOptions, ...options.retry } : defaultRetryOptions;
|
|
311
|
+
|
|
312
|
+
const installedStepRunner = stepRunner;
|
|
313
|
+
const installedRunWrapper = runWrapper;
|
|
314
|
+
const installedCommandInterceptor = commandInterceptor;
|
|
315
|
+
const installedRetryDefaults = retryDefaults;
|
|
316
|
+
|
|
317
|
+
return () => {
|
|
318
|
+
// Only undo an install that is still in effect. Without this check, two callers that install
|
|
319
|
+
// and release in interleaved order silently discard each other's hooks: A installs, B installs,
|
|
320
|
+
// A restores, and B's wiring is gone though B never released it.
|
|
321
|
+
const unchanged =
|
|
322
|
+
stepRunner === installedStepRunner &&
|
|
323
|
+
runWrapper === installedRunWrapper &&
|
|
324
|
+
commandInterceptor === installedCommandInterceptor &&
|
|
325
|
+
retryDefaults === installedRetryDefaults;
|
|
326
|
+
if (!unchanged) return;
|
|
327
|
+
|
|
328
|
+
stepRunner = previousStepRunner;
|
|
329
|
+
runWrapper = previousRunWrapper;
|
|
330
|
+
commandInterceptor = previousCommandInterceptor;
|
|
331
|
+
retryDefaults = previousRetryDefaults;
|
|
332
|
+
};
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* @typedef {Object} StepStart
|
|
337
|
+
* @property {string} name - `cmd.name`, or 'anonymous'.
|
|
338
|
+
* @property {string} type - Always 'Command' today.
|
|
339
|
+
*/
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* @typedef {Object} StepEnd
|
|
343
|
+
* @property {string} name
|
|
344
|
+
* @property {string} type
|
|
345
|
+
* @property {any} [result] - What the Command returned, when it succeeded.
|
|
346
|
+
* @property {any} [error] - What it threw, when it did not.
|
|
347
|
+
* @property {number} durationMs
|
|
348
|
+
*/
|
|
349
|
+
|
|
350
|
+
const now = () => (typeof performance === 'object' ? performance.now() : Date.now());
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Wraps a step observer into an `onStep` that cannot change the run.
|
|
354
|
+
*
|
|
355
|
+
* @param {(start: StepStart) => (end: StepEnd) => void} handler - Returns a finisher for the outcome
|
|
356
|
+
* @returns {StepRunner}
|
|
357
|
+
*/
|
|
358
|
+
const observeSteps = (handler) => async (name, type, op) => {
|
|
359
|
+
/** @type {((end: StepEnd) => void) | undefined} */
|
|
360
|
+
let finish;
|
|
361
|
+
try {
|
|
362
|
+
finish = handler({ name, type });
|
|
363
|
+
} catch {
|
|
364
|
+
finish = undefined;
|
|
365
|
+
}
|
|
366
|
+
const report = (/** @type {StepEnd} */ end) => {
|
|
367
|
+
try {
|
|
368
|
+
if (finish) finish(end);
|
|
369
|
+
} catch {
|
|
370
|
+
// Observation does not get to decide the outcome, so a broken observer is dropped.
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
const started = now();
|
|
375
|
+
try {
|
|
376
|
+
const result = await op();
|
|
377
|
+
report({ name, type, result, durationMs: now() - started });
|
|
378
|
+
return result;
|
|
379
|
+
} catch (error) {
|
|
380
|
+
report({ name, type, error, durationMs: now() - started });
|
|
381
|
+
throw error;
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Merges several configurations into one, so independent concerns can share the hooks.
|
|
387
|
+
*
|
|
388
|
+
* `onStep` and `onRun` are wrappers around an `op`, so they nest. The first config given is
|
|
389
|
+
* the outermost wrapper and the last sits closest to the Command, which also means a thrown
|
|
390
|
+
* Command unwinds from the last config back to the first. `onBeforeCommand` is an observer,
|
|
391
|
+
* so every interceptor runs in the order given. `retry` is plain data and merges with later
|
|
392
|
+
* configs winning. A hook no config defines is left unset, so `configureEffect` keeps its
|
|
393
|
+
* default for that slot.
|
|
394
|
+
*
|
|
395
|
+
* @param {...(EffectConfiguration | undefined)} configs - Configurations to merge, outermost first
|
|
396
|
+
* @returns {EffectConfiguration}
|
|
397
|
+
*/
|
|
398
|
+
const chainHooks = (...configs) => {
|
|
399
|
+
const present = (/** @type {string} */ key) =>
|
|
400
|
+
configs
|
|
401
|
+
.filter(Boolean)
|
|
402
|
+
.map((c) => /** @type {any} */ (c)[key])
|
|
403
|
+
.filter(Boolean);
|
|
404
|
+
|
|
405
|
+
/** @type {EffectConfiguration} */
|
|
406
|
+
const merged = {};
|
|
407
|
+
const steps = /** @type {StepRunner[]} */ (present('onStep'));
|
|
408
|
+
const runs = /** @type {RunWrapper[]} */ (present('onRun'));
|
|
409
|
+
const interceptors = /** @type {CommandInterceptor[]} */ (present('onBeforeCommand'));
|
|
410
|
+
const retries = present('retry');
|
|
411
|
+
|
|
412
|
+
if (steps.length) {
|
|
413
|
+
merged.onStep = steps.reduceRight(
|
|
414
|
+
(inner, outer) => (name, type, op) => outer(name, type, () => inner(name, type, op))
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
if (runs.length) {
|
|
418
|
+
merged.onRun = runs.reduceRight(
|
|
419
|
+
(inner, outer) => (effect, op, flowName) => outer(effect, () => inner(effect, op, flowName), flowName)
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
if (interceptors.length) {
|
|
423
|
+
merged.onBeforeCommand = async (command, context) => {
|
|
424
|
+
for (const intercept of interceptors) await intercept(command, context);
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
if (retries.length) merged.retry = Object.assign({}, ...retries);
|
|
428
|
+
return merged;
|
|
198
429
|
};
|
|
199
430
|
|
|
200
431
|
const runEffect =
|
|
@@ -204,7 +435,7 @@ const runEffect =
|
|
|
204
435
|
* Ask effects are resolved synchronously with the context object.
|
|
205
436
|
*
|
|
206
437
|
* Per-call config takes precedence over global configureEffect defaults.
|
|
207
|
-
* onRun fires exactly once per runEffect call
|
|
438
|
+
* onRun fires exactly once per runEffect call. Retry attempts run inside that
|
|
208
439
|
* single span rather than spawning their own, keeping telemetry non-duplicated.
|
|
209
440
|
*
|
|
210
441
|
* @param {Effect} effect - The Effect tree returned by a pipeline
|
|
@@ -223,7 +454,10 @@ const runEffect =
|
|
|
223
454
|
* @returns {Promise<SuccessState | FailureState>}
|
|
224
455
|
*/
|
|
225
456
|
async function execute(eff) {
|
|
226
|
-
while (
|
|
457
|
+
while (
|
|
458
|
+
eff &&
|
|
459
|
+
(eff.type === 'Command' || eff.type === 'Ask' || eff.type === 'Retry' || eff.type === 'Parallel')
|
|
460
|
+
) {
|
|
227
461
|
if (eff.type === 'Ask') {
|
|
228
462
|
eff = eff.next(context);
|
|
229
463
|
continue;
|
|
@@ -259,20 +493,479 @@ const runEffect =
|
|
|
259
493
|
eff = eff.next(results.map((r) => /** @type {SuccessState} */ (r).value));
|
|
260
494
|
continue;
|
|
261
495
|
}
|
|
262
|
-
const cmdName = eff
|
|
496
|
+
const cmdName = commandName(eff);
|
|
263
497
|
const initialInput = eff.initialInput;
|
|
264
498
|
try {
|
|
265
499
|
await localCommandInterceptor(eff, context);
|
|
266
500
|
const result = await localStepRunner(cmdName, 'Command', eff.cmd);
|
|
267
501
|
eff = eff.next(result);
|
|
268
502
|
} catch (e) {
|
|
503
|
+
// A malformed flow is a bug, not a domain failure, so it must not masquerade as one.
|
|
504
|
+
if (e instanceof Error && e.name === 'EffectTypeError') throw e;
|
|
269
505
|
return Failure(e, initialInput);
|
|
270
506
|
}
|
|
271
507
|
}
|
|
272
|
-
return eff;
|
|
508
|
+
if (eff && (eff.type === 'Success' || eff.type === 'Failure')) return eff;
|
|
509
|
+
throw effectTypeError(eff, 'The flow');
|
|
273
510
|
}
|
|
274
511
|
|
|
275
512
|
return localRunWrapper(effect, () => execute(effect), context?.flowName || '');
|
|
276
513
|
};
|
|
277
514
|
|
|
278
|
-
|
|
515
|
+
/** @typedef {{ index: number, name: string, type: string }} ReplayStep */
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* What production observed for a step. `{ result }` is handed to the Command's
|
|
519
|
+
* `next`; `{ error }` is thrown so the interpreter produces a Failure. A resolver
|
|
520
|
+
* returning `undefined` means "not recorded".
|
|
521
|
+
*
|
|
522
|
+
* @typedef {{ result: any } | { error: any }} ReplayOutcome
|
|
523
|
+
*/
|
|
524
|
+
|
|
525
|
+
/** @typedef {(step: ReplayStep) => ReplayOutcome | undefined} Resolver */
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* A recorded step. `durationMs` is how long the Command took in production, rounded to microseconds,
|
|
529
|
+
* which is the one question a trace could not answer before: which step was slow.
|
|
530
|
+
* @typedef {{ command: string, result?: any, error?: any, durationMs?: number }} TraceEntry
|
|
531
|
+
*/
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* The reference trace format produced by `recorder`. A convenience, not a contract:
|
|
535
|
+
* `replayEffect` takes a Resolver, so any storage shape works.
|
|
536
|
+
* @typedef {{
|
|
537
|
+
* flowName?: string,
|
|
538
|
+
* version?: string,
|
|
539
|
+
* initialInput?: any,
|
|
540
|
+
* context?: any,
|
|
541
|
+
* dropped?: number,
|
|
542
|
+
* trace: TraceEntry[]
|
|
543
|
+
* }} TraceLog
|
|
544
|
+
*/
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Creates a replay error. Thrown from a resolver, it is caught by the interpreter
|
|
548
|
+
* and returned as a Failure, exactly like a rejected Command.
|
|
549
|
+
* @param {string} message - Human-readable reason
|
|
550
|
+
* @param {Object} [props] - Extra fields such as `name`, `index`, `expected`, `actual`
|
|
551
|
+
* @returns {Error}
|
|
552
|
+
*/
|
|
553
|
+
const replayError = (message, props = {}) => Object.assign(new Error(message), { name: 'ReplayError' }, props);
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Signals that the flow being replayed asked for a different Command than the trace
|
|
557
|
+
* recorded, which means the code has diverged from the recorded run.
|
|
558
|
+
* @param {ReplayStep} step - The step the flow asked for
|
|
559
|
+
* @param {string} recorded - The command name the trace holds at that position
|
|
560
|
+
* @returns {Error}
|
|
561
|
+
*/
|
|
562
|
+
const timeParadox = (step, recorded) =>
|
|
563
|
+
replayError(`Time paradox at step ${step.index}: flow asked for '${step.name}', trace recorded '${recorded}'`, {
|
|
564
|
+
name: 'TimeParadox',
|
|
565
|
+
index: step.index,
|
|
566
|
+
expected: recorded,
|
|
567
|
+
actual: step.name
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Converts a thrown value into something JSON can carry. `message` and `stack` are
|
|
572
|
+
* non-enumerable on Error, so a plain `JSON.stringify` would silently drop them.
|
|
573
|
+
*
|
|
574
|
+
* @param {any} e - The thrown value
|
|
575
|
+
* @param {boolean} [withStack] - Include the stack (off by default: noisy, leaks paths)
|
|
576
|
+
* @returns {any}
|
|
577
|
+
*/
|
|
578
|
+
const serializeError = (e, withStack) => {
|
|
579
|
+
if (!(e instanceof Error)) return e;
|
|
580
|
+
/** @type {any} */
|
|
581
|
+
const out = { __error: true, name: e.name, message: e.message };
|
|
582
|
+
if (withStack) out.stack = e.stack;
|
|
583
|
+
for (const k of Object.keys(e)) out[k] = /** @type {any} */ (e)[k];
|
|
584
|
+
return out;
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Rebuilds an Error from `serializeError` output. Non-Error values pass through, so a
|
|
589
|
+
* Command that rejected with a string still replays as a string.
|
|
590
|
+
* @param {any} v - A serialized error, or any other recorded value
|
|
591
|
+
* @returns {any}
|
|
592
|
+
*/
|
|
593
|
+
const reviveError = (v) => {
|
|
594
|
+
if (!v || typeof v !== 'object' || v.__error !== true) return v;
|
|
595
|
+
const e = new Error(v.message);
|
|
596
|
+
e.name = v.name;
|
|
597
|
+
for (const [k, val] of Object.entries(v)) {
|
|
598
|
+
if (k !== '__error' && k !== 'name' && k !== 'message') /** @type {any} */ (e)[k] = val;
|
|
599
|
+
}
|
|
600
|
+
return e;
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* @typedef {Object} RecorderOptions
|
|
605
|
+
* @property {(value: any, name: string, kind: string) => any} [redact] - Scrubs every value a trace holds:
|
|
606
|
+
* each Command's result, each serialized error, and the `initialInput` and `context` stored on the
|
|
607
|
+
* trace itself. `kind` is `'result'`, `'error'`, `'initialInput'`, or `'context'`, and `name` is the
|
|
608
|
+
* Command's name for the first two and the kind for the last two. It is the single place PII is kept
|
|
609
|
+
* out of a trace, so it has to see all four.
|
|
610
|
+
* @property {number} [maxEntries] - Caps trace length; further steps are counted in `dropped`, not stored.
|
|
611
|
+
* @property {boolean} [stack] - Records stack traces for thrown errors.
|
|
612
|
+
*/
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* @typedef {Object} TraceMeta
|
|
616
|
+
* @property {any} [initialInput] - The value the flow was called with.
|
|
617
|
+
* @property {string} [flowName]
|
|
618
|
+
* @property {any} [context] - Context passed to `runEffect`; required to replay `Ask`.
|
|
619
|
+
* @property {string} [version] - Commit or build id, so a replay can detect a stale trace.
|
|
620
|
+
*/
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Snapshots a value on its way into a trace.
|
|
624
|
+
*
|
|
625
|
+
* Commands return live objects, and a later step that mutates one would otherwise rewrite what the
|
|
626
|
+
* trace says an earlier step returned: `toTrace` runs after the flow finishes, so by serialization
|
|
627
|
+
* time the mutation is already baked in and the trace records a value production never saw. Values
|
|
628
|
+
* that cannot be cloned fall back to the reference.
|
|
629
|
+
*
|
|
630
|
+
* @param {any} value
|
|
631
|
+
* @returns {any}
|
|
632
|
+
*/
|
|
633
|
+
const snapshot = (value) => {
|
|
634
|
+
if (value === null || typeof value !== 'object') return value;
|
|
635
|
+
try {
|
|
636
|
+
return typeof structuredClone === 'function' ? structuredClone(value) : JSON.parse(JSON.stringify(value));
|
|
637
|
+
} catch {
|
|
638
|
+
return value;
|
|
639
|
+
}
|
|
640
|
+
};
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Builds an `onStep` hook that records what every Command returned, plus a packager
|
|
644
|
+
* for the reference trace format. Pass `onStep` to `runEffect` as per-call config, or
|
|
645
|
+
* to `configureEffect` to record globally.
|
|
646
|
+
*
|
|
647
|
+
* @param {RecorderOptions} [options] - Redaction and size limits
|
|
648
|
+
* @returns {{ onStep: StepRunner, entries: TraceEntry[], toTrace: (meta?: TraceMeta) => TraceLog }}
|
|
649
|
+
*/
|
|
650
|
+
const recorder = (options = {}) => {
|
|
651
|
+
const { redact = (/** @type {any} */ r) => r, maxEntries = Infinity, stack = false } = options;
|
|
652
|
+
/** @type {TraceEntry[]} */
|
|
653
|
+
const entries = [];
|
|
654
|
+
let dropped = 0;
|
|
655
|
+
|
|
656
|
+
const push = (/** @type {TraceEntry} */ entry) => {
|
|
657
|
+
if (entries.length < maxEntries) entries.push(entry);
|
|
658
|
+
else dropped++;
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* `redact` is the caller's code.
|
|
663
|
+
*/
|
|
664
|
+
const safeRedact = (/** @type {any} */ value, /** @type {string} */ name, /** @type {string} */ kind) => {
|
|
665
|
+
try {
|
|
666
|
+
return redact(value, name, kind);
|
|
667
|
+
} catch {
|
|
668
|
+
return '[redaction failed]';
|
|
669
|
+
}
|
|
670
|
+
};
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Redacts and snapshots one of the trace's own fields. `undefined` is left alone so a flow with no
|
|
674
|
+
* context does not acquire an empty object from a redact function that spreads its argument.
|
|
675
|
+
*/
|
|
676
|
+
const redactField = (/** @type {any} */ value, /** @type {string} */ kind) =>
|
|
677
|
+
value === undefined ? undefined : snapshot(safeRedact(value, kind, kind));
|
|
678
|
+
|
|
679
|
+
// Built on `observe` so the hook contract lives in one place: `op` always runs, its result is
|
|
680
|
+
// always returned, and its error always propagates.
|
|
681
|
+
const onStep = observeSteps(({ name }) => (end) => {
|
|
682
|
+
const durationMs = Math.round(end.durationMs * 1000) / 1000;
|
|
683
|
+
push(
|
|
684
|
+
'error' in end
|
|
685
|
+
? {
|
|
686
|
+
command: name,
|
|
687
|
+
error: snapshot(safeRedact(serializeError(end.error, stack), name, 'error')),
|
|
688
|
+
durationMs
|
|
689
|
+
}
|
|
690
|
+
: { command: name, result: snapshot(safeRedact(end.result, name, 'result')), durationMs }
|
|
691
|
+
);
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* @param {TraceMeta} [meta]
|
|
696
|
+
* @returns {TraceLog}
|
|
697
|
+
*/
|
|
698
|
+
const toTrace = (meta = {}) => ({
|
|
699
|
+
flowName: meta.flowName,
|
|
700
|
+
version: meta.version,
|
|
701
|
+
initialInput: redactField(meta.initialInput, 'initialInput'),
|
|
702
|
+
context: redactField(meta.context, 'context'),
|
|
703
|
+
dropped,
|
|
704
|
+
trace: entries.slice()
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
return { onStep, entries, toTrace };
|
|
708
|
+
};
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Runs a flow for real while recording every Command result, and returns both the
|
|
712
|
+
* outcome and a replayable trace. Convenient in tests and scripts; in an application,
|
|
713
|
+
* install `recorder().onStep` once via `configureEffect` instead of changing call sites.
|
|
714
|
+
*
|
|
715
|
+
* @param {(input: any) => Effect} flowFn - Builds the Effect tree from its input
|
|
716
|
+
* @param {any} initialInput - The value the flow is called with; stored so a replay can rebuild it
|
|
717
|
+
* @param {RecorderOptions & { context?: any, version?: string }} [options] - Recorder options, plus the
|
|
718
|
+
* `context` given to `runEffect` (stored so `Ask` can be replayed) and a build id
|
|
719
|
+
* @returns {Promise<{ result: SuccessState | FailureState, trace: TraceLog }>}
|
|
720
|
+
*/
|
|
721
|
+
const recordEffect = async (flowFn, initialInput, options = {}) => {
|
|
722
|
+
const { context = {}, version, ...recorderOptions } = options;
|
|
723
|
+
const rec = recorder(recorderOptions);
|
|
724
|
+
const result = await runEffect(flowFn(initialInput), context, { onStep: rec.onStep });
|
|
725
|
+
return {
|
|
726
|
+
result,
|
|
727
|
+
trace: rec.toTrace({ initialInput, flowName: context.flowName, context, version })
|
|
728
|
+
};
|
|
729
|
+
};
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Turns a recorded entry into the outcome a Resolver must return.
|
|
733
|
+
*
|
|
734
|
+
* @param {TraceEntry} entry
|
|
735
|
+
* @returns {ReplayOutcome}
|
|
736
|
+
*/
|
|
737
|
+
const entryToOutcome = (entry) => ('error' in entry ? { error: reviveError(entry.error) } : { result: entry.result });
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* Builds a Resolver for the reference trace format. Internal: `replayEffect` calls this
|
|
741
|
+
* when handed a trace instead of a Resolver, and is the only caller. Callers with traces
|
|
742
|
+
* in some other shape write a Resolver instead, which is the extension point.
|
|
743
|
+
*
|
|
744
|
+
* Strict matching consumes entries in recorded order and verifies that the flow asks
|
|
745
|
+
* for the Command the trace expects, which is what detects divergence between the
|
|
746
|
+
* recorded run and the code being replayed.
|
|
747
|
+
*
|
|
748
|
+
* Name matching is required for flows containing `Parallel`: branches run through
|
|
749
|
+
* `Promise.all` and therefore complete out of array order, so recorded positions are
|
|
750
|
+
* not stable. It resolves per-Command FIFO queues instead, which is order-independent
|
|
751
|
+
* and still keeps duplicate Command names apart.
|
|
752
|
+
*
|
|
753
|
+
* @param {TraceLog | TraceEntry[]} traceLog - A reference-format trace, or a bare array of entries
|
|
754
|
+
* @param {Object} [options]
|
|
755
|
+
* @param {boolean} [options.strict] - Positional matching with paradox detection (default `true`)
|
|
756
|
+
* @returns {Resolver}
|
|
757
|
+
*/
|
|
758
|
+
const fromTrace = (traceLog, options = {}) => {
|
|
759
|
+
const { strict = true } = options;
|
|
760
|
+
const entries = Array.isArray(traceLog) ? traceLog : traceLog?.trace;
|
|
761
|
+
if (!Array.isArray(entries)) throw replayError('Trace has no `trace` array.');
|
|
762
|
+
|
|
763
|
+
if (strict) {
|
|
764
|
+
return (step) => {
|
|
765
|
+
const entry = entries[step.index];
|
|
766
|
+
if (!entry) throw replayError(`Trace exhausted: no entry #${step.index} for '${step.name}'.`);
|
|
767
|
+
if (entry.command !== step.name) throw timeParadox(step, entry.command);
|
|
768
|
+
return entryToOutcome(entry);
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/** @type {Map<string, TraceEntry[]>} */
|
|
773
|
+
const queues = new Map();
|
|
774
|
+
for (const entry of entries) {
|
|
775
|
+
if (!queues.has(entry.command)) queues.set(entry.command, []);
|
|
776
|
+
/** @type {TraceEntry[]} */ (queues.get(entry.command)).push(entry);
|
|
777
|
+
}
|
|
778
|
+
return (step) => {
|
|
779
|
+
const queue = queues.get(step.name);
|
|
780
|
+
if (!queue || queue.length === 0) throw replayError(`No recorded result left for '${step.name}'.`);
|
|
781
|
+
return entryToOutcome(/** @type {TraceEntry} */ (queue.shift()));
|
|
782
|
+
};
|
|
783
|
+
};
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* Rewrites `Retry` nodes to zero delay, lazily, through their `next` continuations.
|
|
787
|
+
*
|
|
788
|
+
* Needed because the interpreter merges per-use options over call config
|
|
789
|
+
* (`{ ...localRetryDefaults, ...eff.options }`), so a `callConfig.retry` cannot
|
|
790
|
+
* override a delay written at the call site. Without this, replaying a flow that
|
|
791
|
+
* retried in production waits out the production backoff.
|
|
792
|
+
*
|
|
793
|
+
* @param {any} eff - Any Effect node
|
|
794
|
+
* @returns {any} The same tree with Retry delays removed
|
|
795
|
+
*/
|
|
796
|
+
const zeroRetryDelays = (eff) => {
|
|
797
|
+
if (!eff || typeof eff.type !== 'string') return eff;
|
|
798
|
+
switch (eff.type) {
|
|
799
|
+
case 'Retry':
|
|
800
|
+
return {
|
|
801
|
+
...eff,
|
|
802
|
+
options: { ...eff.options, delay: 0, backoff: 1 },
|
|
803
|
+
effect: zeroRetryDelays(eff.effect),
|
|
804
|
+
next: (/** @type {any} */ value) => zeroRetryDelays(eff.next(value))
|
|
805
|
+
};
|
|
806
|
+
case 'Command':
|
|
807
|
+
return { ...eff, next: (/** @type {any} */ result) => zeroRetryDelays(eff.next(result)) };
|
|
808
|
+
case 'Ask':
|
|
809
|
+
return { ...eff, next: (/** @type {any} */ ctx) => zeroRetryDelays(eff.next(ctx)) };
|
|
810
|
+
case 'Parallel':
|
|
811
|
+
return {
|
|
812
|
+
...eff,
|
|
813
|
+
effects: eff.effects.map(zeroRetryDelays),
|
|
814
|
+
next: (/** @type {any} */ values) => zeroRetryDelays(eff.next(values))
|
|
815
|
+
};
|
|
816
|
+
default:
|
|
817
|
+
return eff;
|
|
818
|
+
}
|
|
819
|
+
};
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* @typedef {Object} ReplayOptions
|
|
823
|
+
* @property {any} [context] - Context for `Ask`; pass the recorded context to reproduce a run faithfully.
|
|
824
|
+
* @property {boolean} [fastRetry] - Strips Retry delays so a replay does not wait out production backoff.
|
|
825
|
+
* @property {boolean} [hooks] - Lets configured `onRun` and `onBeforeCommand` fire. Off by default, so a
|
|
826
|
+
* replay cannot reach a telemetry backend or a guardrail that performs I/O.
|
|
827
|
+
* @property {'throw' | 'execute'} [onMissing] - What to do when the resolver has no recording for a step.
|
|
828
|
+
* `'throw'` (default) fails the replay, which makes side effects impossible for the whole run.
|
|
829
|
+
* `'execute'` runs the real Command, giving partial replay: recorded prefix, live tail.
|
|
830
|
+
* @property {(step: ReplayStep, outcome: ReplayOutcome | undefined) => void} [onResolved] - Observes each step.
|
|
831
|
+
* @property {boolean} [strict] - Matching mode used when a trace is passed instead of a Resolver.
|
|
832
|
+
* `true` (default) consumes entries positionally and raises a `TimeParadox` on divergence;
|
|
833
|
+
* `false` matches per-Command FIFO queues, which is required for flows containing `Parallel`.
|
|
834
|
+
* Ignored when a Resolver is supplied, since that Resolver has already chosen how it matches.
|
|
835
|
+
*/
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* Replays an Effect tree, feeding recorded results to Commands instead of running them.
|
|
839
|
+
*
|
|
840
|
+
* No side effect can occur by default. The interpreter's only execution point is
|
|
841
|
+
* `await localStepRunner(cmdName, 'Command', eff.cmd)`, which hands the Command thunk
|
|
842
|
+
* to `onStep` as `op` rather than calling it. The `onStep` installed here never invokes
|
|
843
|
+
* `op` unless `onMissing: 'execute'` is set, so `eff.cmd` is never applied and the I/O
|
|
844
|
+
* it describes does not happen.
|
|
845
|
+
*
|
|
846
|
+
* Driving the interpreter instead of walking the tree is what makes `Ask`, `Retry` and
|
|
847
|
+
* `Parallel` work, and means replay cannot drift from execution semantics.
|
|
848
|
+
*
|
|
849
|
+
* @param {Effect} effect - The Effect tree, rebuilt from the recorded initial input
|
|
850
|
+
* @param {Resolver | TraceLog | TraceEntry[]} traceOrResolver - A reference-format trace, resolved here, or a
|
|
851
|
+
* Resolver supplying each Command's recorded outcome (`undefined` if it has none). Write a Resolver when
|
|
852
|
+
* traces are stored in some other shape; to observe a replay without one, use `onResolved`.
|
|
853
|
+
* @param {ReplayOptions} [options]
|
|
854
|
+
* @returns {Promise<SuccessState | FailureState>}
|
|
855
|
+
*/
|
|
856
|
+
// `async` so a malformed trace arrives as a rejection rather than a synchronous throw:
|
|
857
|
+
// the function otherwise returns a promise, and callers should not have to handle both.
|
|
858
|
+
const replayEffect = async (effect, traceOrResolver, options = {}) => {
|
|
859
|
+
const { context = {}, fastRetry = true, hooks = false, onMissing = 'throw', onResolved, strict = true } = options;
|
|
860
|
+
// A trace is data and a Resolver is a function, so nothing else is needed to tell them
|
|
861
|
+
// apart, including the bare entries array that `fromTrace` also accepts.
|
|
862
|
+
const resolve = typeof traceOrResolver === 'function' ? traceOrResolver : fromTrace(traceOrResolver, { strict });
|
|
863
|
+
let index = 0;
|
|
864
|
+
|
|
865
|
+
/** @type {StepRunner} */
|
|
866
|
+
const onStep = async (name, type, op) => {
|
|
867
|
+
const step = { index: index++, name, type };
|
|
868
|
+
const outcome = resolve(step);
|
|
869
|
+
if (onResolved) onResolved(step, outcome);
|
|
870
|
+
if (outcome === undefined) {
|
|
871
|
+
if (onMissing !== 'execute') {
|
|
872
|
+
throw replayError(
|
|
873
|
+
`No recorded outcome for '${name}' at step ${step.index}; refusing to run the real Command. ` +
|
|
874
|
+
`Pass onMissing: 'execute' to allow live I/O for unrecorded steps.`,
|
|
875
|
+
{ command: name, index: step.index }
|
|
876
|
+
);
|
|
877
|
+
}
|
|
878
|
+
return await op();
|
|
879
|
+
}
|
|
880
|
+
if ('error' in outcome) throw outcome.error;
|
|
881
|
+
return outcome.result;
|
|
882
|
+
};
|
|
883
|
+
|
|
884
|
+
/** @type {EffectConfiguration} */
|
|
885
|
+
const callConfig = { onStep };
|
|
886
|
+
if (!hooks) {
|
|
887
|
+
callConfig.onRun = async (effect, op) => await op();
|
|
888
|
+
callConfig.onBeforeCommand = async () => {};
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
return runEffect(fastRetry ? zeroRetryDelays(effect) : effect, context, callConfig);
|
|
892
|
+
};
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* Replays a reference-format trace and narrates each step. Rebuilds the flow from the
|
|
896
|
+
* recorded input, reports the outcome, and warns when recorded steps were never
|
|
897
|
+
* reached, which means the current code issued fewer Commands than production did.
|
|
898
|
+
*
|
|
899
|
+
* @param {(input: any) => Effect} flowFn - The same flow function that produced the trace
|
|
900
|
+
* @param {TraceLog} traceLog - A trace from `recordEffect` or a `recorder`
|
|
901
|
+
* @param {Object} [options]
|
|
902
|
+
* @param {boolean} [options.strict] - Positional matching with paradox detection; set `false` for `Parallel`
|
|
903
|
+
* @param {(...args: any[]) => void} [options.log] - Defaults to `console.log`
|
|
904
|
+
* @param {any} [options.context] - Overrides the context stored on the trace
|
|
905
|
+
* @param {string} [options.version] - Current build id; warns when it differs from the trace's
|
|
906
|
+
* @returns {Promise<SuccessState | FailureState>}
|
|
907
|
+
*/
|
|
908
|
+
const timeTravel = async (flowFn, traceLog, options = {}) => {
|
|
909
|
+
const { strict = true, log = console.log, context, version } = options;
|
|
910
|
+
const { initialInput, trace, flowName, version: traceVersion } = traceLog;
|
|
911
|
+
// `message` and `stack` are non-enumerable on Error, so JSON.stringify alone would
|
|
912
|
+
// drop the most useful line of the report.
|
|
913
|
+
const format = (/** @type {any} */ v) =>
|
|
914
|
+
v instanceof Error
|
|
915
|
+
? JSON.stringify({ ...v, name: v.name, message: v.message }, null, 2)
|
|
916
|
+
: JSON.stringify(v, null, 2);
|
|
917
|
+
|
|
918
|
+
if (version && traceVersion && version !== traceVersion) {
|
|
919
|
+
log(`Warning: trace was recorded at ${traceVersion}, replaying against ${version}.`);
|
|
920
|
+
}
|
|
921
|
+
const stepsText = (trace.length === 0 || trace.length > 1) ? 'steps' : 'step';
|
|
922
|
+
log(`Replaying '${flowName || 'flow'}' (${trace.length} recorded ${stepsText})`);
|
|
923
|
+
log(`Initial input: ${format(initialInput)}`);
|
|
924
|
+
|
|
925
|
+
// Narration goes through `onResolved` rather than a wrapped Resolver: observing each
|
|
926
|
+
// step is all this needs, and `replayEffect` already resolves the trace itself.
|
|
927
|
+
let consumed = 0;
|
|
928
|
+
// Only strict matching maps a replayed step back to its recorded entry by position, so timings
|
|
929
|
+
// are narrated there and omitted when per-Command queues are matching by name.
|
|
930
|
+
const timing = (/** @type {ReplayStep} */ step) => {
|
|
931
|
+
const recorded = strict ? trace[step.index] : undefined;
|
|
932
|
+
return typeof recorded?.durationMs === 'number' ? ` in ${recorded.durationMs}ms` : '';
|
|
933
|
+
};
|
|
934
|
+
const result = await replayEffect(flowFn(initialInput), traceLog, {
|
|
935
|
+
strict,
|
|
936
|
+
context: context !== undefined ? context : traceLog.context || {},
|
|
937
|
+
onResolved: (step, outcome) => {
|
|
938
|
+
// Resolving from a trace either answers or throws, so this is never undefined.
|
|
939
|
+
if (outcome === undefined) return;
|
|
940
|
+
consumed++;
|
|
941
|
+
log(
|
|
942
|
+
'error' in outcome
|
|
943
|
+
? `Step ${step.index + 1}: ${step.name} threw${timing(step)} ${format(outcome.error)}`
|
|
944
|
+
: `Step ${step.index + 1}: ${step.name} returned${timing(step)} ${format(outcome.result)}`
|
|
945
|
+
);
|
|
946
|
+
}
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
log(`Replay finished with state: ${result.type}`);
|
|
950
|
+
log(result.type === 'Failure' ? `Error: ${format(result.error)}` : `Result: ${format(result.value)}`);
|
|
951
|
+
if (consumed < trace.length) {
|
|
952
|
+
log(`Warning: ${trace.length - consumed} recorded ${stepsText} were never reached. The flow diverged.`);
|
|
953
|
+
}
|
|
954
|
+
return result;
|
|
955
|
+
};
|
|
956
|
+
|
|
957
|
+
export {
|
|
958
|
+
Success,
|
|
959
|
+
Failure,
|
|
960
|
+
Command,
|
|
961
|
+
Ask,
|
|
962
|
+
Retry,
|
|
963
|
+
Parallel,
|
|
964
|
+
effectPipe,
|
|
965
|
+
runEffect,
|
|
966
|
+
configureEffect,
|
|
967
|
+
recorder,
|
|
968
|
+
recordEffect,
|
|
969
|
+
replayEffect,
|
|
970
|
+
timeTravel
|
|
971
|
+
};
|