pure-effect 0.8.0 → 0.10.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 +137 -21
- package/README.md +267 -172
- package/examples/opentelemetry-example.js +164 -0
- package/examples/recording-example.js +104 -0
- package/index.d.ts +168 -8
- package/index.js +879 -24
- package/package.json +12 -6
- package/test/all.js +1786 -26
- 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,10 +2,15 @@
|
|
|
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',
|
|
8
|
-
* cmd: () => Promise<any>|any,
|
|
13
|
+
* cmd: (signal?: AbortSignal) => Promise<any>|any,
|
|
9
14
|
* next: (result: any) => Effect,
|
|
10
15
|
* meta?: any,
|
|
11
16
|
* initialInput?: any
|
|
@@ -63,13 +68,42 @@ const Failure = (error, initialInput) => ({
|
|
|
63
68
|
});
|
|
64
69
|
|
|
65
70
|
/**
|
|
66
|
-
* Represents a side effect to be executed later
|
|
67
|
-
*
|
|
68
|
-
* @param {(
|
|
69
|
-
*
|
|
71
|
+
* Represents a side effect to be executed later.
|
|
72
|
+
*
|
|
73
|
+
* @param {(signal?: AbortSignal) => Promise<any>|any} cmd - The side-effect function to execute. Inside a
|
|
74
|
+
* `Parallel` branch it receives an `AbortSignal` that fires when a sibling branch fails, so I/O that
|
|
75
|
+
* accepts one can be cancelled in flight. Ignoring it is fine: the interpreter still refuses to start
|
|
76
|
+
* any later Command in a cancelled branch. Outside a `Parallel` no argument is passed.
|
|
77
|
+
* @param {(result: any) => Effect} [next] - Receives the result of `cmd` and returns the next Effect.
|
|
78
|
+
* Defaults to `(result) => Success(result)`, which is what most Commands want.
|
|
79
|
+
* @param {CommandMeta} [meta] - Optional metadata, passed to `onBeforeCommand`. A string `meta.name`
|
|
80
|
+
* becomes this Command's identity for traces, replay matching, and telemetry spans, which makes
|
|
81
|
+
* the identity independent of how `cmd` was declared and immune to minification.
|
|
70
82
|
* @returns {CommandState}
|
|
71
83
|
*/
|
|
72
|
-
const Command = (cmd, next
|
|
84
|
+
const Command = (cmd, next = (/** @type {any} */ result) => Success(result), meta) => ({
|
|
85
|
+
type: 'Command',
|
|
86
|
+
cmd,
|
|
87
|
+
next,
|
|
88
|
+
meta
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The name a Command is known by: what a trace records, what strict replay matches on, and what a
|
|
93
|
+
* telemetry span is called.
|
|
94
|
+
*
|
|
95
|
+
* `meta.name` wins when it is a non-empty string. Otherwise the identity is the thunk's own `name`,
|
|
96
|
+
* so an inline arrow records as 'anonymous' and a minifier that mangles function names silently
|
|
97
|
+
* renames every step of every trace. Passing `meta.name` avoids both.
|
|
98
|
+
*
|
|
99
|
+
* @param {CommandState} eff
|
|
100
|
+
* @returns {string}
|
|
101
|
+
*/
|
|
102
|
+
const commandName = (eff) => {
|
|
103
|
+
const meta = eff.meta;
|
|
104
|
+
const named = meta && typeof meta === 'object' ? meta.name : undefined;
|
|
105
|
+
return typeof named === 'string' && named !== '' ? named : eff.cmd.name || 'anonymous';
|
|
106
|
+
};
|
|
73
107
|
|
|
74
108
|
/**
|
|
75
109
|
* Reads the context object from the current `runEffect` call.
|
|
@@ -80,6 +114,12 @@ const Ask = (next) => ({ type: 'Ask', next });
|
|
|
80
114
|
|
|
81
115
|
/**
|
|
82
116
|
* Wraps an Effect tree with retry-on-failure semantics.
|
|
117
|
+
*
|
|
118
|
+
* Each attempt runs the **entire** wrapped tree again, including Commands that already succeeded, so
|
|
119
|
+
* wrap the one Command that fails transiently rather than a pipeline. `Retry(effectPipe(charge, receipt))`
|
|
120
|
+
* charges the customer again every time the receipt step fails. Wrapping a pipeline is only safe when
|
|
121
|
+
* every Command in it is idempotent.
|
|
122
|
+
*
|
|
83
123
|
* @param {Effect} effect - The inner Effect tree to retry
|
|
84
124
|
* @param {Object} [options] - Per-use retry options; merged over global defaults at runtime
|
|
85
125
|
* @param {number} [options.attempts] - Max retries (not counting first try)
|
|
@@ -95,13 +135,68 @@ const Retry = (effect, options = {}) => ({
|
|
|
95
135
|
});
|
|
96
136
|
|
|
97
137
|
/**
|
|
98
|
-
* Runs multiple Effect trees concurrently.
|
|
138
|
+
* Runs multiple Effect trees concurrently. The first branch to fail cancels its siblings, and that
|
|
139
|
+
* branch's Failure is what the Parallel returns; `next` is skipped. When several branches fail in the
|
|
140
|
+
* same tick, the first by array order wins.
|
|
141
|
+
*
|
|
142
|
+
* Cancellation is cooperative and works at two levels. A cancelled branch starts no further Commands,
|
|
143
|
+
* which needs nothing from the caller. Stopping the Command already in flight needs its thunk to accept
|
|
144
|
+
* the `AbortSignal` it is passed and hand it to whatever performs the I/O; a thunk that ignores the
|
|
145
|
+
* signal runs to completion, so a branch's first Command can still write after a sibling has failed.
|
|
146
|
+
*
|
|
99
147
|
* @param {Effect[]} effects - Array of Effect trees to run concurrently
|
|
100
148
|
* @param {(values: any[]) => Effect} next - Receives array of success values in order, returns next Effect
|
|
101
149
|
* @returns {ParallelState}
|
|
102
150
|
*/
|
|
103
151
|
const Parallel = (effects, next) => ({ type: 'Parallel', effects, next });
|
|
104
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Describes a value for an error message, leading with the mistake it most likely is.
|
|
155
|
+
* @param {any} value
|
|
156
|
+
* @returns {string}
|
|
157
|
+
*/
|
|
158
|
+
const describeValue = (value) =>
|
|
159
|
+
value === undefined
|
|
160
|
+
? 'undefined, which usually means a missing return'
|
|
161
|
+
: value === null
|
|
162
|
+
? 'null'
|
|
163
|
+
: typeof value === 'function'
|
|
164
|
+
? 'a function, which usually means a flow was passed without being called with its input'
|
|
165
|
+
: typeof value === 'object'
|
|
166
|
+
? typeof value.type === 'string'
|
|
167
|
+
? `an object with an unrecognised type '${value.type}'`
|
|
168
|
+
: 'a plain object'
|
|
169
|
+
: `the ${typeof value} ${JSON.stringify(value)}`;
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Checks that a value is an Effect, and explains the mistake when it is not.
|
|
173
|
+
*
|
|
174
|
+
* `EffectTypeError` is a bug in the flow rather than a domain failure, so it is thrown instead of
|
|
175
|
+
* becoming a `Failure`, and the interpreter rethrows it rather than folding it into one.
|
|
176
|
+
*
|
|
177
|
+
* @param {any} value
|
|
178
|
+
* @param {string} source - What produced the value, named where it is known
|
|
179
|
+
* @returns {Error}
|
|
180
|
+
*/
|
|
181
|
+
const effectTypeError = (value, source) =>
|
|
182
|
+
Object.assign(
|
|
183
|
+
new Error(
|
|
184
|
+
`${source} returned ${describeValue(value)}. Return Success, Failure, Command, Ask, Retry, or Parallel: ` +
|
|
185
|
+
'a plain value has to be wrapped, as in Success(value).'
|
|
186
|
+
),
|
|
187
|
+
{ name: 'EffectTypeError' }
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* @param {any} value
|
|
192
|
+
* @param {string} source
|
|
193
|
+
* @returns {Effect}
|
|
194
|
+
*/
|
|
195
|
+
const asEffect = (value, source) => {
|
|
196
|
+
if (value && ['Success', 'Failure', 'Command', 'Ask', 'Retry', 'Parallel'].includes(value.type)) return value;
|
|
197
|
+
throw effectTypeError(value, source);
|
|
198
|
+
};
|
|
199
|
+
|
|
105
200
|
/**
|
|
106
201
|
* Connects an Effect to the next function in the pipeline.
|
|
107
202
|
* Handles the branching logic for Success, Failure, Command, Ask, and Retry.
|
|
@@ -122,7 +217,7 @@ const chain = (effect, fn, initialInput) => {
|
|
|
122
217
|
|
|
123
218
|
switch (effect.type) {
|
|
124
219
|
case 'Success':
|
|
125
|
-
return withII(fn(effect.value));
|
|
220
|
+
return withII(asEffect(fn(effect.value), `Step '${fn.name || 'anonymous'}'`));
|
|
126
221
|
case 'Failure':
|
|
127
222
|
return withII(effect);
|
|
128
223
|
case 'Command': {
|
|
@@ -141,6 +236,8 @@ const chain = (effect, fn, initialInput) => {
|
|
|
141
236
|
const next = (/** @type {any} */ result) => chain(effect.next(result), fn, initialInput);
|
|
142
237
|
return withII({ ...effect, next });
|
|
143
238
|
}
|
|
239
|
+
default:
|
|
240
|
+
return asEffect(effect, 'A continuation');
|
|
144
241
|
}
|
|
145
242
|
};
|
|
146
243
|
|
|
@@ -158,7 +255,13 @@ const effectPipe = (...fns) => {
|
|
|
158
255
|
};
|
|
159
256
|
};
|
|
160
257
|
|
|
161
|
-
/**
|
|
258
|
+
/**
|
|
259
|
+
* Wraps one Command execution. `path` identifies the Command's position in the Effect tree rather than
|
|
260
|
+
* its position in completion order, so it is the same in a replay as it was in the recorded run even
|
|
261
|
+
* when `Parallel` branches finish in a different order. Hooks written before `path` existed take three
|
|
262
|
+
* parameters and are unaffected.
|
|
263
|
+
* @typedef {(name: string, type: string, op: function, path?: string) => Promise<any>} StepRunner
|
|
264
|
+
*/
|
|
162
265
|
/** @type StepRunner */
|
|
163
266
|
const defaultStepRunner = async (name, type, op) => await op();
|
|
164
267
|
|
|
@@ -179,8 +282,8 @@ let retryDefaults = { ...defaultRetryOptions };
|
|
|
179
282
|
|
|
180
283
|
/**
|
|
181
284
|
* @typedef {Object} EffectConfiguration
|
|
182
|
-
* @property {StepRunner} [onStep] - Fires
|
|
183
|
-
* @property {RunWrapper} [onRun] - Fires
|
|
285
|
+
* @property {StepRunner} [onStep] - Fires every time a Command is executed. It wraps the `cmd` call.
|
|
286
|
+
* @property {RunWrapper} [onRun] - Fires once per runEffect call. It wraps the entire workflow execution.
|
|
184
287
|
* @property {CommandInterceptor} [onBeforeCommand] - Intercepts a Command and any context passed to runEffect before execution.
|
|
185
288
|
* @property {{ attempts?: number, delay?: number, backoff?: number }} [retry] - Global Retry defaults; merged under per-use options.
|
|
186
289
|
*/
|
|
@@ -188,15 +291,201 @@ let retryDefaults = { ...defaultRetryOptions };
|
|
|
188
291
|
/**
|
|
189
292
|
* Configures the global behavior of the Effect runner, including the command interceptor and telemetry.
|
|
190
293
|
*
|
|
191
|
-
*
|
|
294
|
+
* Several configurations can be passed and are merged, which is how independent concerns share the
|
|
295
|
+
* one slot each hook has:
|
|
296
|
+
*
|
|
297
|
+
* configureEffect(telemetryHooks(), recordingHooks({ sink }))
|
|
298
|
+
*
|
|
299
|
+
* `onStep` and `onRun` are wrappers, so they nest with the first configuration outermost and the last
|
|
300
|
+
* closest to the Command. `onBeforeCommand` interceptors all run, in the order given, and `retry`
|
|
301
|
+
* merges with later configurations winning.
|
|
302
|
+
*
|
|
303
|
+
* Merging happens per call and does not accumulate across calls: a later `configureEffect` still
|
|
304
|
+
* replaces the previous wiring entirely, and calling it with nothing resets every slot to its default.
|
|
305
|
+
*
|
|
306
|
+
* Returns a function that puts back whatever was installed when this call was made, so a caller can
|
|
307
|
+
* install hooks without owning the wiring forever. That is what makes this usable from a test, a
|
|
308
|
+
* request-scoped experiment, or a library that wraps this one: without it, installing anything means
|
|
309
|
+
* clobbering the host's telemetry permanently, since there is no way to read the current wiring.
|
|
310
|
+
* Restoring undoes this call only while its wiring is still in effect. If a later `configureEffect` has
|
|
311
|
+
* run since, restoring does nothing rather than discarding that newer wiring, so two callers installing
|
|
312
|
+
* and releasing in interleaved order cannot clobber each other.
|
|
313
|
+
*
|
|
314
|
+
* @param {...(EffectConfiguration | undefined)} configs - Configurations to merge, outermost first
|
|
315
|
+
* @returns {() => void} Restores the wiring that was in place before this call
|
|
192
316
|
*/
|
|
193
|
-
const configureEffect = (
|
|
317
|
+
const configureEffect = (...configs) => {
|
|
318
|
+
const previousStepRunner = stepRunner;
|
|
319
|
+
const previousRunWrapper = runWrapper;
|
|
320
|
+
const previousCommandInterceptor = commandInterceptor;
|
|
321
|
+
const previousRetryDefaults = retryDefaults;
|
|
322
|
+
|
|
323
|
+
const options = chainHooks(...configs);
|
|
194
324
|
stepRunner = options.onStep ? options.onStep : defaultStepRunner;
|
|
195
325
|
runWrapper = options.onRun ? options.onRun : defaultRunWrapper;
|
|
196
326
|
commandInterceptor = options.onBeforeCommand ? options.onBeforeCommand : defaultCommandInterceptor;
|
|
197
327
|
retryDefaults = options.retry ? { ...defaultRetryOptions, ...options.retry } : defaultRetryOptions;
|
|
328
|
+
|
|
329
|
+
const installedStepRunner = stepRunner;
|
|
330
|
+
const installedRunWrapper = runWrapper;
|
|
331
|
+
const installedCommandInterceptor = commandInterceptor;
|
|
332
|
+
const installedRetryDefaults = retryDefaults;
|
|
333
|
+
|
|
334
|
+
return () => {
|
|
335
|
+
// Only undo an install that is still in effect. Without this check, two callers that install
|
|
336
|
+
// and release in interleaved order silently discard each other's hooks: A installs, B installs,
|
|
337
|
+
// A restores, and B's wiring is gone though B never released it.
|
|
338
|
+
const unchanged =
|
|
339
|
+
stepRunner === installedStepRunner &&
|
|
340
|
+
runWrapper === installedRunWrapper &&
|
|
341
|
+
commandInterceptor === installedCommandInterceptor &&
|
|
342
|
+
retryDefaults === installedRetryDefaults;
|
|
343
|
+
if (!unchanged) return;
|
|
344
|
+
|
|
345
|
+
stepRunner = previousStepRunner;
|
|
346
|
+
runWrapper = previousRunWrapper;
|
|
347
|
+
commandInterceptor = previousCommandInterceptor;
|
|
348
|
+
retryDefaults = previousRetryDefaults;
|
|
349
|
+
};
|
|
198
350
|
};
|
|
199
351
|
|
|
352
|
+
/**
|
|
353
|
+
* @typedef {Object} StepStart
|
|
354
|
+
* @property {string} name - `cmd.name`, or 'anonymous'.
|
|
355
|
+
* @property {string} type - Always 'Command' today.
|
|
356
|
+
* @property {string} [path] - The Command's position in the Effect tree.
|
|
357
|
+
*/
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* @typedef {Object} StepEnd
|
|
361
|
+
* @property {string} name
|
|
362
|
+
* @property {string} type
|
|
363
|
+
* @property {string} [path]
|
|
364
|
+
* @property {any} [result] - What the Command returned, when it succeeded.
|
|
365
|
+
* @property {any} [error] - What it threw, when it did not.
|
|
366
|
+
* @property {number} durationMs
|
|
367
|
+
*/
|
|
368
|
+
|
|
369
|
+
const now = () => (typeof performance === 'object' ? performance.now() : Date.now());
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Wraps a step observer into an `onStep` that cannot change the run.
|
|
373
|
+
*
|
|
374
|
+
* @param {(start: StepStart) => (end: StepEnd) => void} handler - Returns a finisher for the outcome
|
|
375
|
+
* @returns {StepRunner}
|
|
376
|
+
*/
|
|
377
|
+
const observeSteps = (handler) => async (name, type, op, path) => {
|
|
378
|
+
/** @type {((end: StepEnd) => void) | undefined} */
|
|
379
|
+
let finish;
|
|
380
|
+
try {
|
|
381
|
+
finish = handler({ name, type, path });
|
|
382
|
+
} catch {
|
|
383
|
+
finish = undefined;
|
|
384
|
+
}
|
|
385
|
+
const report = (/** @type {StepEnd} */ end) => {
|
|
386
|
+
try {
|
|
387
|
+
if (finish) finish(end);
|
|
388
|
+
} catch {
|
|
389
|
+
// Observation does not get to decide the outcome, so a broken observer is dropped.
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
const started = now();
|
|
394
|
+
try {
|
|
395
|
+
const result = await op();
|
|
396
|
+
report({ name, type, path, result, durationMs: now() - started });
|
|
397
|
+
return result;
|
|
398
|
+
} catch (error) {
|
|
399
|
+
report({ name, type, path, error, durationMs: now() - started });
|
|
400
|
+
throw error;
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Merges several configurations into one, so independent concerns can share the hooks.
|
|
406
|
+
*
|
|
407
|
+
* `onStep` and `onRun` are wrappers around an `op`, so they nest. The first config given is
|
|
408
|
+
* the outermost wrapper and the last sits closest to the Command, which also means a thrown
|
|
409
|
+
* Command unwinds from the last config back to the first. `onBeforeCommand` is an observer,
|
|
410
|
+
* so every interceptor runs in the order given. `retry` is plain data and merges with later
|
|
411
|
+
* configs winning. A hook no config defines is left unset, so `configureEffect` keeps its
|
|
412
|
+
* default for that slot.
|
|
413
|
+
*
|
|
414
|
+
* @param {...(EffectConfiguration | undefined)} configs - Configurations to merge, outermost first
|
|
415
|
+
* @returns {EffectConfiguration}
|
|
416
|
+
*/
|
|
417
|
+
const chainHooks = (...configs) => {
|
|
418
|
+
const present = (/** @type {string} */ key) =>
|
|
419
|
+
configs
|
|
420
|
+
.filter(Boolean)
|
|
421
|
+
.map((c) => /** @type {any} */ (c)[key])
|
|
422
|
+
.filter(Boolean);
|
|
423
|
+
|
|
424
|
+
/** @type {EffectConfiguration} */
|
|
425
|
+
const merged = {};
|
|
426
|
+
const steps = /** @type {StepRunner[]} */ (present('onStep'));
|
|
427
|
+
const runs = /** @type {RunWrapper[]} */ (present('onRun'));
|
|
428
|
+
const interceptors = /** @type {CommandInterceptor[]} */ (present('onBeforeCommand'));
|
|
429
|
+
const retries = present('retry');
|
|
430
|
+
|
|
431
|
+
if (steps.length) {
|
|
432
|
+
merged.onStep = steps.reduceRight(
|
|
433
|
+
(inner, outer) => (name, type, op, path) => outer(name, type, () => inner(name, type, op, path), path)
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
if (runs.length) {
|
|
437
|
+
merged.onRun = runs.reduceRight(
|
|
438
|
+
(inner, outer) => (effect, op, flowName) => outer(effect, () => inner(effect, op, flowName), flowName)
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
if (interceptors.length) {
|
|
442
|
+
merged.onBeforeCommand = async (command, context) => {
|
|
443
|
+
for (const intercept of interceptors) await intercept(command, context);
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
if (retries.length) merged.retry = Object.assign({}, ...retries);
|
|
447
|
+
return merged;
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* The Failure a `Parallel` branch resolves to when a sibling branch has already failed.
|
|
452
|
+
*
|
|
453
|
+
* Branches are cancelled, not merely ignored: this is what `Parallel` returns for a branch that was
|
|
454
|
+
* still in flight, and it is deliberately distinguishable so it can be told apart from the genuine
|
|
455
|
+
* failure that triggered the cancellation.
|
|
456
|
+
* @returns {Error}
|
|
457
|
+
*/
|
|
458
|
+
const parallelCancelled = () =>
|
|
459
|
+
Object.assign(new Error('Parallel branch cancelled.'), {
|
|
460
|
+
name: 'ParallelCancelled'
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Sleeps, but gives up early when the surrounding branch is cancelled, so a sibling's failure does not
|
|
465
|
+
* have to wait out a retry backoff that is now pointless. Resolves either way: the caller's abort check
|
|
466
|
+
* is what turns a cancelled wait into a Failure.
|
|
467
|
+
*
|
|
468
|
+
* @param {number} ms
|
|
469
|
+
* @param {AbortSignal} [signal]
|
|
470
|
+
* @returns {Promise<void>}
|
|
471
|
+
*/
|
|
472
|
+
const delayFor = (ms, signal) =>
|
|
473
|
+
new Promise((resolve) => {
|
|
474
|
+
if (!signal) {
|
|
475
|
+
setTimeout(resolve, ms);
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
const onAbort = () => {
|
|
479
|
+
clearTimeout(timer);
|
|
480
|
+
resolve();
|
|
481
|
+
};
|
|
482
|
+
const timer = setTimeout(() => {
|
|
483
|
+
signal.removeEventListener('abort', onAbort);
|
|
484
|
+
resolve();
|
|
485
|
+
}, ms);
|
|
486
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
487
|
+
});
|
|
488
|
+
|
|
200
489
|
const runEffect =
|
|
201
490
|
/**
|
|
202
491
|
* The Interpreter
|
|
@@ -204,7 +493,7 @@ const runEffect =
|
|
|
204
493
|
* Ask effects are resolved synchronously with the context object.
|
|
205
494
|
*
|
|
206
495
|
* Per-call config takes precedence over global configureEffect defaults.
|
|
207
|
-
* onRun fires exactly once per runEffect call
|
|
496
|
+
* onRun fires exactly once per runEffect call. Retry attempts run inside that
|
|
208
497
|
* single span rather than spawning their own, keeping telemetry non-duplicated.
|
|
209
498
|
*
|
|
210
499
|
* @param {Effect} effect - The Effect tree returned by a pipeline
|
|
@@ -220,10 +509,25 @@ const runEffect =
|
|
|
220
509
|
|
|
221
510
|
/**
|
|
222
511
|
* @param {Effect} eff
|
|
512
|
+
* @param {AbortSignal} [signal] - Cancellation for this subtree, set for `Parallel` branches.
|
|
513
|
+
* A branch stops starting new Commands once it is aborted, and the signal is handed to
|
|
514
|
+
* each Command's thunk so I/O that accepts one can be cancelled in flight.
|
|
515
|
+
* @param {string} [path] - Prefix identifying this subtree's position in the Effect tree.
|
|
516
|
+
* Steps are numbered sequentially within a subtree and each `Parallel` branch and `Retry`
|
|
517
|
+
* attempt opens its own prefix, so a Command's full path depends only on the shape of the
|
|
518
|
+
* tree and not on the order branches happen to finish in. That is what lets a replay line
|
|
519
|
+
* a recorded step up with the step that asked for it.
|
|
223
520
|
* @returns {Promise<SuccessState | FailureState>}
|
|
224
521
|
*/
|
|
225
|
-
async function execute(eff) {
|
|
226
|
-
|
|
522
|
+
async function execute(eff, signal, path = '') {
|
|
523
|
+
let step = 0;
|
|
524
|
+
while (
|
|
525
|
+
eff &&
|
|
526
|
+
(eff.type === 'Command' || eff.type === 'Ask' || eff.type === 'Retry' || eff.type === 'Parallel')
|
|
527
|
+
) {
|
|
528
|
+
// Checked before every node, which is what short-circuits a branch whose Commands
|
|
529
|
+
// ignore the signal: the one in flight cannot be stopped, but the next never starts.
|
|
530
|
+
if (signal?.aborted) return Failure(parallelCancelled(), eff.initialInput);
|
|
227
531
|
if (eff.type === 'Ask') {
|
|
228
532
|
eff = eff.next(context);
|
|
229
533
|
continue;
|
|
@@ -233,12 +537,19 @@ const runEffect =
|
|
|
233
537
|
const { attempts } = opts;
|
|
234
538
|
let lastError;
|
|
235
539
|
let succeeded = false;
|
|
540
|
+
// Each attempt gets its own prefix, so the Commands of attempt 2 cannot be mistaken
|
|
541
|
+
// for the Commands of attempt 1 when a trace is matched back up.
|
|
542
|
+
const retryPath = `${path}${step++}r`;
|
|
236
543
|
|
|
237
544
|
for (let attempt = 0; attempt <= attempts; attempt++) {
|
|
238
545
|
if (attempt > 0) {
|
|
239
|
-
await
|
|
546
|
+
await delayFor(opts.delay * Math.pow(opts.backoff, attempt - 1), signal);
|
|
240
547
|
}
|
|
241
|
-
|
|
548
|
+
// Checked after the wait, so a branch cancelled mid-backoff stops here rather
|
|
549
|
+
// than buying one more attempt, and a sibling's failure does not pay for the
|
|
550
|
+
// rest of this branch's retry schedule.
|
|
551
|
+
if (signal?.aborted) return Failure(parallelCancelled(), eff.initialInput);
|
|
552
|
+
const result = await execute(eff.effect, signal, `${retryPath}${attempt}/`);
|
|
242
553
|
if (result.type === 'Success') {
|
|
243
554
|
eff = eff.next(result.value);
|
|
244
555
|
succeeded = true;
|
|
@@ -253,26 +564,570 @@ const runEffect =
|
|
|
253
564
|
continue;
|
|
254
565
|
}
|
|
255
566
|
if (eff.type === 'Parallel') {
|
|
256
|
-
|
|
257
|
-
|
|
567
|
+
// Branch prefixes come from the branch's index in the array, never from the order
|
|
568
|
+
// branches complete in, which is the whole point: two branches calling the same
|
|
569
|
+
// Command are told apart by where they are rather than by who finished first.
|
|
570
|
+
const branchPath = `${path}${step++}p`;
|
|
571
|
+
// One scope per Parallel, linked to the enclosing one so cancellation nests.
|
|
572
|
+
// Absent AbortController (very old runtimes), `branchSignal` stays undefined and
|
|
573
|
+
// the old run-everything-to-completion behaviour is what happens.
|
|
574
|
+
const scope = typeof AbortController === 'function' ? new AbortController() : undefined;
|
|
575
|
+
const branchSignal = scope?.signal;
|
|
576
|
+
const relay = () => scope?.abort();
|
|
577
|
+
if (signal && scope) {
|
|
578
|
+
if (signal.aborted) scope.abort();
|
|
579
|
+
else signal.addEventListener('abort', relay, { once: true });
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** @type {(SuccessState | FailureState)[]} */
|
|
583
|
+
const results = new Array(eff.effects.length);
|
|
584
|
+
// Which branch failed on its own account rather than because it was cancelled.
|
|
585
|
+
const triggered = new Array(eff.effects.length).fill(false);
|
|
586
|
+
try {
|
|
587
|
+
// Still awaits every branch, so no cancelled work is left running unobserved
|
|
588
|
+
// after the Failure is returned. Cancelled branches settle promptly; a branch
|
|
589
|
+
// whose in-flight Command ignores the signal is the one case that does not.
|
|
590
|
+
await Promise.all(
|
|
591
|
+
eff.effects.map(async (e, i) => {
|
|
592
|
+
const result = await execute(e, branchSignal, `${branchPath}${i}/`);
|
|
593
|
+
results[i] = result;
|
|
594
|
+
// Read-then-abort is atomic here, so exactly one branch is the trigger.
|
|
595
|
+
if (result.type === 'Failure' && !branchSignal?.aborted) {
|
|
596
|
+
triggered[i] = true;
|
|
597
|
+
scope?.abort();
|
|
598
|
+
}
|
|
599
|
+
})
|
|
600
|
+
);
|
|
601
|
+
} finally {
|
|
602
|
+
if (signal && scope) signal.removeEventListener('abort', relay);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const trigger = triggered.indexOf(true);
|
|
606
|
+
const failure = trigger >= 0 ? results[trigger] : results.find((r) => r.type === 'Failure');
|
|
258
607
|
if (failure) return failure;
|
|
259
608
|
eff = eff.next(results.map((r) => /** @type {SuccessState} */ (r).value));
|
|
260
609
|
continue;
|
|
261
610
|
}
|
|
262
|
-
const cmdName = eff
|
|
611
|
+
const cmdName = commandName(eff);
|
|
263
612
|
const initialInput = eff.initialInput;
|
|
613
|
+
const cmdPath = `${path}${step++}`;
|
|
614
|
+
const cmd = eff.cmd;
|
|
615
|
+
// The signal reaches the thunk only inside a Parallel, so a thunk written to take a
|
|
616
|
+
// parameter is not handed an argument it never expected anywhere else.
|
|
617
|
+
const op = signal ? () => cmd(signal) : cmd;
|
|
264
618
|
try {
|
|
265
619
|
await localCommandInterceptor(eff, context);
|
|
266
|
-
const result = await localStepRunner(cmdName, 'Command',
|
|
620
|
+
const result = await localStepRunner(cmdName, 'Command', op, cmdPath);
|
|
267
621
|
eff = eff.next(result);
|
|
268
622
|
} catch (e) {
|
|
623
|
+
// A malformed flow is a bug, not a domain failure, so it must not masquerade as one.
|
|
624
|
+
if (e instanceof Error && e.name === 'EffectTypeError') throw e;
|
|
269
625
|
return Failure(e, initialInput);
|
|
270
626
|
}
|
|
271
627
|
}
|
|
272
|
-
return eff;
|
|
628
|
+
if (eff && (eff.type === 'Success' || eff.type === 'Failure')) return eff;
|
|
629
|
+
throw effectTypeError(eff, 'The flow');
|
|
273
630
|
}
|
|
274
631
|
|
|
275
632
|
return localRunWrapper(effect, () => execute(effect), context?.flowName || '');
|
|
276
633
|
};
|
|
277
634
|
|
|
278
|
-
|
|
635
|
+
/**
|
|
636
|
+
* The step a replay is asking about. `path` is the Command's position in the Effect tree and is stable
|
|
637
|
+
* across runs; `index` is its position in this run's completion order, which is not stable for a flow
|
|
638
|
+
* containing `Parallel`. Prefer `path` when writing a Resolver.
|
|
639
|
+
* @typedef {{ index: number, name: string, type: string, path?: string }} ReplayStep
|
|
640
|
+
*/
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* What production observed for a step. `{ result }` is handed to the Command's
|
|
644
|
+
* `next`; `{ error }` is thrown so the interpreter produces a Failure. A resolver
|
|
645
|
+
* returning `undefined` means "not recorded".
|
|
646
|
+
*
|
|
647
|
+
* @typedef {{ result: any } | { error: any }} ReplayOutcome
|
|
648
|
+
*/
|
|
649
|
+
|
|
650
|
+
/** @typedef {(step: ReplayStep) => ReplayOutcome | undefined} Resolver */
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* A recorded step. `durationMs` is how long the Command took in production, rounded to microseconds,
|
|
654
|
+
* which is the one question a trace could not answer before: which step was slow.
|
|
655
|
+
* `path` locates the Command in the Effect tree, which is what a replay matches on: it does not move
|
|
656
|
+
* when `Parallel` branches finish in a different order than they did in production.
|
|
657
|
+
* @typedef {{ command: string, path?: string, result?: any, error?: any, durationMs?: number }} TraceEntry
|
|
658
|
+
*/
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* The reference trace format produced by `recorder`. A convenience, not a contract:
|
|
662
|
+
* `replayEffect` takes a Resolver, so any storage shape works.
|
|
663
|
+
* @typedef {{
|
|
664
|
+
* flowName?: string,
|
|
665
|
+
* version?: string,
|
|
666
|
+
* initialInput?: any,
|
|
667
|
+
* context?: any,
|
|
668
|
+
* dropped?: number,
|
|
669
|
+
* trace: TraceEntry[]
|
|
670
|
+
* }} TraceLog
|
|
671
|
+
*/
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Creates a replay error. Thrown from a resolver, it is caught by the interpreter
|
|
675
|
+
* and returned as a Failure, exactly like a rejected Command.
|
|
676
|
+
* @param {string} message - Human-readable reason
|
|
677
|
+
* @param {Object} [props] - Extra fields such as `name`, `index`, `expected`, `actual`
|
|
678
|
+
* @returns {Error}
|
|
679
|
+
*/
|
|
680
|
+
const replayError = (message, props = {}) => Object.assign(new Error(message), { name: 'ReplayError' }, props);
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Signals that the flow being replayed asked for a different Command than the trace
|
|
684
|
+
* recorded, which means the code has diverged from the recorded run.
|
|
685
|
+
* @param {ReplayStep} step - The step the flow asked for
|
|
686
|
+
* @param {string} recorded - The command name the trace holds at that position
|
|
687
|
+
* @returns {Error}
|
|
688
|
+
*/
|
|
689
|
+
const timeParadox = (step, recorded) =>
|
|
690
|
+
replayError(
|
|
691
|
+
`Time paradox at ${step.path !== undefined ? `path '${step.path}'` : `step ${step.index}`}: ` +
|
|
692
|
+
`flow asked for '${step.name}', trace recorded '${recorded}'`,
|
|
693
|
+
{
|
|
694
|
+
name: 'TimeParadox',
|
|
695
|
+
index: step.index,
|
|
696
|
+
path: step.path,
|
|
697
|
+
expected: recorded,
|
|
698
|
+
actual: step.name
|
|
699
|
+
}
|
|
700
|
+
);
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Converts a thrown value into something JSON can carry. `message` and `stack` are
|
|
704
|
+
* non-enumerable on Error, so a plain `JSON.stringify` would silently drop them.
|
|
705
|
+
*
|
|
706
|
+
* @param {any} e - The thrown value
|
|
707
|
+
* @param {boolean} [withStack] - Include the stack (off by default: noisy, leaks paths)
|
|
708
|
+
* @returns {any}
|
|
709
|
+
*/
|
|
710
|
+
const serializeError = (e, withStack) => {
|
|
711
|
+
if (!(e instanceof Error)) return e;
|
|
712
|
+
/** @type {any} */
|
|
713
|
+
const out = { __error: true, name: e.name, message: e.message };
|
|
714
|
+
if (withStack) out.stack = e.stack;
|
|
715
|
+
for (const k of Object.keys(e)) out[k] = /** @type {any} */ (e)[k];
|
|
716
|
+
return out;
|
|
717
|
+
};
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Rebuilds an Error from `serializeError` output. Non-Error values pass through, so a
|
|
721
|
+
* Command that rejected with a string still replays as a string.
|
|
722
|
+
* @param {any} v - A serialized error, or any other recorded value
|
|
723
|
+
* @returns {any}
|
|
724
|
+
*/
|
|
725
|
+
const reviveError = (v) => {
|
|
726
|
+
if (!v || typeof v !== 'object' || v.__error !== true) return v;
|
|
727
|
+
const e = new Error(v.message);
|
|
728
|
+
e.name = v.name;
|
|
729
|
+
for (const [k, val] of Object.entries(v)) {
|
|
730
|
+
if (k !== '__error' && k !== 'name' && k !== 'message') /** @type {any} */ (e)[k] = val;
|
|
731
|
+
}
|
|
732
|
+
return e;
|
|
733
|
+
};
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* @typedef {Object} RecorderOptions
|
|
737
|
+
* @property {(value: any, name: string, kind: string) => any} [redact] - Scrubs every value a trace holds:
|
|
738
|
+
* each Command's result, each serialized error, and the `initialInput` and `context` stored on the
|
|
739
|
+
* trace itself. `kind` is `'result'`, `'error'`, `'initialInput'`, or `'context'`, and `name` is the
|
|
740
|
+
* Command's name for the first two and the kind for the last two. It is the single place PII is kept
|
|
741
|
+
* out of a trace, so it has to see all four.
|
|
742
|
+
* @property {number} [maxEntries] - Caps trace length; further steps are counted in `dropped`, not stored.
|
|
743
|
+
* @property {boolean} [stack] - Records stack traces for thrown errors.
|
|
744
|
+
*/
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* @typedef {Object} TraceMeta
|
|
748
|
+
* @property {any} [initialInput] - The value the flow was called with.
|
|
749
|
+
* @property {string} [flowName]
|
|
750
|
+
* @property {any} [context] - Context passed to `runEffect`; required to replay `Ask`.
|
|
751
|
+
* @property {string} [version] - Commit or build id, so a replay can detect a stale trace.
|
|
752
|
+
*/
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Snapshots a value on its way into a trace.
|
|
756
|
+
*
|
|
757
|
+
* Commands return live objects, and a later step that mutates one would otherwise rewrite what the
|
|
758
|
+
* trace says an earlier step returned: `toTrace` runs after the flow finishes, so by serialization
|
|
759
|
+
* time the mutation is already baked in and the trace records a value production never saw. Values
|
|
760
|
+
* that cannot be cloned fall back to the reference.
|
|
761
|
+
*
|
|
762
|
+
* @param {any} value
|
|
763
|
+
* @returns {any}
|
|
764
|
+
*/
|
|
765
|
+
const snapshot = (value) => {
|
|
766
|
+
if (value === null || typeof value !== 'object') return value;
|
|
767
|
+
try {
|
|
768
|
+
return typeof structuredClone === 'function' ? structuredClone(value) : JSON.parse(JSON.stringify(value));
|
|
769
|
+
} catch {
|
|
770
|
+
return value;
|
|
771
|
+
}
|
|
772
|
+
};
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* Builds an `onStep` hook that records what every Command returned, plus a packager
|
|
776
|
+
* for the reference trace format. Pass `onStep` to `runEffect` as per-call config, or
|
|
777
|
+
* to `configureEffect` to record globally.
|
|
778
|
+
*
|
|
779
|
+
* @param {RecorderOptions} [options] - Redaction and size limits
|
|
780
|
+
* @returns {{ onStep: StepRunner, entries: TraceEntry[], toTrace: (meta?: TraceMeta) => TraceLog }}
|
|
781
|
+
*/
|
|
782
|
+
const recorder = (options = {}) => {
|
|
783
|
+
const { redact = (/** @type {any} */ r) => r, maxEntries = Infinity, stack = false } = options;
|
|
784
|
+
/** @type {TraceEntry[]} */
|
|
785
|
+
const entries = [];
|
|
786
|
+
let dropped = 0;
|
|
787
|
+
|
|
788
|
+
const push = (/** @type {TraceEntry} */ entry) => {
|
|
789
|
+
if (entries.length < maxEntries) entries.push(entry);
|
|
790
|
+
else dropped++;
|
|
791
|
+
};
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* `redact` is the caller's code.
|
|
795
|
+
*/
|
|
796
|
+
const safeRedact = (/** @type {any} */ value, /** @type {string} */ name, /** @type {string} */ kind) => {
|
|
797
|
+
try {
|
|
798
|
+
return redact(value, name, kind);
|
|
799
|
+
} catch {
|
|
800
|
+
return '[redaction failed]';
|
|
801
|
+
}
|
|
802
|
+
};
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* Redacts and snapshots one of the trace's own fields. `undefined` is left alone so a flow with no
|
|
806
|
+
* context does not acquire an empty object from a redact function that spreads its argument.
|
|
807
|
+
*/
|
|
808
|
+
const redactField = (/** @type {any} */ value, /** @type {string} */ kind) =>
|
|
809
|
+
value === undefined ? undefined : snapshot(safeRedact(value, kind, kind));
|
|
810
|
+
|
|
811
|
+
// Built on `observe` so the hook contract lives in one place: `op` always runs, its result is
|
|
812
|
+
// always returned, and its error always propagates.
|
|
813
|
+
const onStep = observeSteps(({ name, path }) => (end) => {
|
|
814
|
+
const durationMs = Math.round(end.durationMs * 1000) / 1000;
|
|
815
|
+
push(
|
|
816
|
+
'error' in end
|
|
817
|
+
? {
|
|
818
|
+
command: name,
|
|
819
|
+
path,
|
|
820
|
+
error: snapshot(safeRedact(serializeError(end.error, stack), name, 'error')),
|
|
821
|
+
durationMs
|
|
822
|
+
}
|
|
823
|
+
: { command: name, path, result: snapshot(safeRedact(end.result, name, 'result')), durationMs }
|
|
824
|
+
);
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* @param {TraceMeta} [meta]
|
|
829
|
+
* @returns {TraceLog}
|
|
830
|
+
*/
|
|
831
|
+
const toTrace = (meta = {}) => ({
|
|
832
|
+
flowName: meta.flowName,
|
|
833
|
+
version: meta.version,
|
|
834
|
+
initialInput: redactField(meta.initialInput, 'initialInput'),
|
|
835
|
+
context: redactField(meta.context, 'context'),
|
|
836
|
+
dropped,
|
|
837
|
+
trace: entries.slice()
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
return { onStep, entries, toTrace };
|
|
841
|
+
};
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* Runs a flow for real while recording every Command result, and returns both the
|
|
845
|
+
* outcome and a replayable trace. Convenient in tests and scripts; in an application,
|
|
846
|
+
* install `recorder().onStep` once via `configureEffect` instead of changing call sites.
|
|
847
|
+
*
|
|
848
|
+
* @param {(input: any) => Effect} flowFn - Builds the Effect tree from its input
|
|
849
|
+
* @param {any} initialInput - The value the flow is called with; stored so a replay can rebuild it
|
|
850
|
+
* @param {RecorderOptions & { context?: any, version?: string }} [options] - Recorder options, plus the
|
|
851
|
+
* `context` given to `runEffect` (stored so `Ask` can be replayed) and a build id
|
|
852
|
+
* @returns {Promise<{ result: SuccessState | FailureState, trace: TraceLog }>}
|
|
853
|
+
*/
|
|
854
|
+
const recordEffect = async (flowFn, initialInput, options = {}) => {
|
|
855
|
+
const { context = {}, version, ...recorderOptions } = options;
|
|
856
|
+
const rec = recorder(recorderOptions);
|
|
857
|
+
const result = await runEffect(flowFn(initialInput), context, { onStep: rec.onStep });
|
|
858
|
+
return {
|
|
859
|
+
result,
|
|
860
|
+
trace: rec.toTrace({ initialInput, flowName: context.flowName, context, version })
|
|
861
|
+
};
|
|
862
|
+
};
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* Turns a recorded entry into the outcome a Resolver must return.
|
|
866
|
+
*
|
|
867
|
+
* @param {TraceEntry} entry
|
|
868
|
+
* @returns {ReplayOutcome}
|
|
869
|
+
*/
|
|
870
|
+
const entryToOutcome = (entry) => ('error' in entry ? { error: reviveError(entry.error) } : { result: entry.result });
|
|
871
|
+
|
|
872
|
+
/**
|
|
873
|
+
* Builds a Resolver for the reference trace format. Internal: `replayEffect` calls this
|
|
874
|
+
* when handed a trace instead of a Resolver, and is the only caller. Callers with traces
|
|
875
|
+
* in some other shape write a Resolver instead, which is the extension point.
|
|
876
|
+
*
|
|
877
|
+
* Path matching is used whenever every entry carries a `path`, which is every trace this
|
|
878
|
+
* version records. A path is the Command's position in the Effect tree, so it is order-independent
|
|
879
|
+
* and still paradox-detecting: it works for `Parallel` without `strict: false`, and it tells two
|
|
880
|
+
* branches that call the same Command apart by where they are rather than by which finished first.
|
|
881
|
+
* `strict` does not apply, since path matching is neither positional nor lenient.
|
|
882
|
+
*
|
|
883
|
+
* The two older modes remain for traces recorded before paths existed. Strict matching consumes
|
|
884
|
+
* entries in recorded order and verifies the flow asks for the Command the trace expects. Name
|
|
885
|
+
* matching resolves per-Command FIFO queues, which was the only option for `Parallel` and is the
|
|
886
|
+
* mode that could pair a branch with a sibling's result when both called the same Command.
|
|
887
|
+
*
|
|
888
|
+
* @param {TraceLog | TraceEntry[]} traceLog - A reference-format trace, or a bare array of entries
|
|
889
|
+
* @param {Object} [options]
|
|
890
|
+
* @param {boolean} [options.strict] - Positional matching with paradox detection (default `true`).
|
|
891
|
+
* Ignored for a trace carrying paths.
|
|
892
|
+
* @returns {Resolver}
|
|
893
|
+
*/
|
|
894
|
+
const fromTrace = (traceLog, options = {}) => {
|
|
895
|
+
const { strict = true } = options;
|
|
896
|
+
const entries = Array.isArray(traceLog) ? traceLog : traceLog?.trace;
|
|
897
|
+
if (!Array.isArray(entries)) throw replayError('Trace has no `trace` array.');
|
|
898
|
+
|
|
899
|
+
// Paths win when the trace has them, whatever `strict` says: a caller who passed `strict: false`
|
|
900
|
+
// to work around Parallel should get the correct matching once their traces carry paths, rather
|
|
901
|
+
// than keeping the mode that option existed to work around.
|
|
902
|
+
if (entries.length > 0 && entries.every((e) => typeof e.path === 'string')) {
|
|
903
|
+
const byPath = new Map(entries.map((e) => [e.path, e]));
|
|
904
|
+
// Paths are derived from tree position, so they are unique by construction. A collision means
|
|
905
|
+
// either a hand-built trace or a bug in path derivation, and silently keeping the last entry
|
|
906
|
+
// would reintroduce exactly the wrong-result-per-branch failure paths exist to prevent.
|
|
907
|
+
if (byPath.size !== entries.length) {
|
|
908
|
+
throw replayError('Trace has duplicate step paths, so it cannot be matched by path.');
|
|
909
|
+
}
|
|
910
|
+
return (step) => {
|
|
911
|
+
const entry = byPath.get(step.path);
|
|
912
|
+
if (!entry) {
|
|
913
|
+
throw replayError(`Trace has no step at path '${step.path}' for '${step.name}'.`, {
|
|
914
|
+
command: step.name,
|
|
915
|
+
path: step.path
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
if (entry.command !== step.name) throw timeParadox(step, entry.command);
|
|
919
|
+
return entryToOutcome(entry);
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
if (strict) {
|
|
924
|
+
return (step) => {
|
|
925
|
+
const entry = entries[step.index];
|
|
926
|
+
if (!entry) throw replayError(`Trace exhausted: no entry #${step.index} for '${step.name}'.`);
|
|
927
|
+
if (entry.command !== step.name) throw timeParadox(step, entry.command);
|
|
928
|
+
return entryToOutcome(entry);
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/** @type {Map<string, TraceEntry[]>} */
|
|
933
|
+
const queues = new Map();
|
|
934
|
+
for (const entry of entries) {
|
|
935
|
+
if (!queues.has(entry.command)) queues.set(entry.command, []);
|
|
936
|
+
/** @type {TraceEntry[]} */ (queues.get(entry.command)).push(entry);
|
|
937
|
+
}
|
|
938
|
+
return (step) => {
|
|
939
|
+
const queue = queues.get(step.name);
|
|
940
|
+
if (!queue || queue.length === 0) throw replayError(`No recorded result left for '${step.name}'.`);
|
|
941
|
+
return entryToOutcome(/** @type {TraceEntry} */ (queue.shift()));
|
|
942
|
+
};
|
|
943
|
+
};
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* Rewrites `Retry` nodes to zero delay, lazily, through their `next` continuations.
|
|
947
|
+
*
|
|
948
|
+
* Needed because the interpreter merges per-use options over call config
|
|
949
|
+
* (`{ ...localRetryDefaults, ...eff.options }`), so a `callConfig.retry` cannot
|
|
950
|
+
* override a delay written at the call site. Without this, replaying a flow that
|
|
951
|
+
* retried in production waits out the production backoff.
|
|
952
|
+
*
|
|
953
|
+
* @param {any} eff - Any Effect node
|
|
954
|
+
* @returns {any} The same tree with Retry delays removed
|
|
955
|
+
*/
|
|
956
|
+
const zeroRetryDelays = (eff) => {
|
|
957
|
+
if (!eff || typeof eff.type !== 'string') return eff;
|
|
958
|
+
switch (eff.type) {
|
|
959
|
+
case 'Retry':
|
|
960
|
+
return {
|
|
961
|
+
...eff,
|
|
962
|
+
options: { ...eff.options, delay: 0, backoff: 1 },
|
|
963
|
+
effect: zeroRetryDelays(eff.effect),
|
|
964
|
+
next: (/** @type {any} */ value) => zeroRetryDelays(eff.next(value))
|
|
965
|
+
};
|
|
966
|
+
case 'Command':
|
|
967
|
+
return { ...eff, next: (/** @type {any} */ result) => zeroRetryDelays(eff.next(result)) };
|
|
968
|
+
case 'Ask':
|
|
969
|
+
return { ...eff, next: (/** @type {any} */ ctx) => zeroRetryDelays(eff.next(ctx)) };
|
|
970
|
+
case 'Parallel':
|
|
971
|
+
return {
|
|
972
|
+
...eff,
|
|
973
|
+
effects: eff.effects.map(zeroRetryDelays),
|
|
974
|
+
next: (/** @type {any} */ values) => zeroRetryDelays(eff.next(values))
|
|
975
|
+
};
|
|
976
|
+
default:
|
|
977
|
+
return eff;
|
|
978
|
+
}
|
|
979
|
+
};
|
|
980
|
+
|
|
981
|
+
/**
|
|
982
|
+
* @typedef {Object} ReplayOptions
|
|
983
|
+
* @property {any} [context] - Context for `Ask`; pass the recorded context to reproduce a run faithfully.
|
|
984
|
+
* @property {boolean} [fastRetry] - Strips Retry delays so a replay does not wait out production backoff.
|
|
985
|
+
* @property {boolean} [hooks] - Lets configured `onRun` and `onBeforeCommand` fire. Off by default, so a
|
|
986
|
+
* replay cannot reach a telemetry backend or a guardrail that performs I/O.
|
|
987
|
+
* @property {'throw' | 'execute'} [onMissing] - What to do when the resolver has no recording for a step.
|
|
988
|
+
* `'throw'` (default) fails the replay, which makes side effects impossible for the whole run.
|
|
989
|
+
* `'execute'` runs the real Command, giving partial replay: recorded prefix, live tail.
|
|
990
|
+
* @property {(step: ReplayStep, outcome: ReplayOutcome | undefined) => void} [onResolved] - Observes each step.
|
|
991
|
+
* @property {boolean} [strict] - Matching mode used when a trace is passed instead of a Resolver.
|
|
992
|
+
* `true` (default) consumes entries positionally and raises a `TimeParadox` on divergence;
|
|
993
|
+
* `false` matches per-Command FIFO queues, which is required for flows containing `Parallel`.
|
|
994
|
+
* Ignored when a Resolver is supplied, since that Resolver has already chosen how it matches.
|
|
995
|
+
*/
|
|
996
|
+
|
|
997
|
+
/**
|
|
998
|
+
* Replays an Effect tree, feeding recorded results to Commands instead of running them.
|
|
999
|
+
*
|
|
1000
|
+
* No side effect can occur by default. The interpreter's only execution point is
|
|
1001
|
+
* `await localStepRunner(cmdName, 'Command', eff.cmd)`, which hands the Command thunk
|
|
1002
|
+
* to `onStep` as `op` rather than calling it. The `onStep` installed here never invokes
|
|
1003
|
+
* `op` unless `onMissing: 'execute'` is set, so `eff.cmd` is never applied and the I/O
|
|
1004
|
+
* it describes does not happen.
|
|
1005
|
+
*
|
|
1006
|
+
* Driving the interpreter instead of walking the tree is what makes `Ask`, `Retry` and
|
|
1007
|
+
* `Parallel` work, and means replay cannot drift from execution semantics.
|
|
1008
|
+
*
|
|
1009
|
+
* @param {Effect} effect - The Effect tree, rebuilt from the recorded initial input
|
|
1010
|
+
* @param {Resolver | TraceLog | TraceEntry[]} traceOrResolver - A reference-format trace, resolved here, or a
|
|
1011
|
+
* Resolver supplying each Command's recorded outcome (`undefined` if it has none). Write a Resolver when
|
|
1012
|
+
* traces are stored in some other shape; to observe a replay without one, use `onResolved`.
|
|
1013
|
+
* @param {ReplayOptions} [options]
|
|
1014
|
+
* @returns {Promise<SuccessState | FailureState>}
|
|
1015
|
+
*/
|
|
1016
|
+
// `async` so a malformed trace arrives as a rejection rather than a synchronous throw:
|
|
1017
|
+
// the function otherwise returns a promise, and callers should not have to handle both.
|
|
1018
|
+
const replayEffect = async (effect, traceOrResolver, options = {}) => {
|
|
1019
|
+
const { context = {}, fastRetry = true, hooks = false, onMissing = 'throw', onResolved, strict = true } = options;
|
|
1020
|
+
// A trace is data and a Resolver is a function, so nothing else is needed to tell them
|
|
1021
|
+
// apart, including the bare entries array that `fromTrace` also accepts.
|
|
1022
|
+
const resolve = typeof traceOrResolver === 'function' ? traceOrResolver : fromTrace(traceOrResolver, { strict });
|
|
1023
|
+
let index = 0;
|
|
1024
|
+
|
|
1025
|
+
/** @type {StepRunner} */
|
|
1026
|
+
const onStep = async (name, type, op, path) => {
|
|
1027
|
+
const step = { index: index++, name, type, path };
|
|
1028
|
+
const outcome = resolve(step);
|
|
1029
|
+
if (onResolved) onResolved(step, outcome);
|
|
1030
|
+
if (outcome === undefined) {
|
|
1031
|
+
if (onMissing !== 'execute') {
|
|
1032
|
+
throw replayError(
|
|
1033
|
+
`No recorded outcome for '${name}' at step ${step.index}; refusing to run the real Command. ` +
|
|
1034
|
+
`Pass onMissing: 'execute' to allow live I/O for unrecorded steps.`,
|
|
1035
|
+
{ command: name, index: step.index }
|
|
1036
|
+
);
|
|
1037
|
+
}
|
|
1038
|
+
return await op();
|
|
1039
|
+
}
|
|
1040
|
+
if ('error' in outcome) throw outcome.error;
|
|
1041
|
+
return outcome.result;
|
|
1042
|
+
};
|
|
1043
|
+
|
|
1044
|
+
/** @type {EffectConfiguration} */
|
|
1045
|
+
const callConfig = { onStep };
|
|
1046
|
+
if (!hooks) {
|
|
1047
|
+
callConfig.onRun = async (effect, op) => await op();
|
|
1048
|
+
callConfig.onBeforeCommand = async () => {};
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
return runEffect(fastRetry ? zeroRetryDelays(effect) : effect, context, callConfig);
|
|
1052
|
+
};
|
|
1053
|
+
|
|
1054
|
+
/**
|
|
1055
|
+
* Replays a reference-format trace and narrates each step. Rebuilds the flow from the
|
|
1056
|
+
* recorded input, reports the outcome, and warns when recorded steps were never
|
|
1057
|
+
* reached, which means the current code issued fewer Commands than production did.
|
|
1058
|
+
*
|
|
1059
|
+
* @param {(input: any) => Effect} flowFn - The same flow function that produced the trace
|
|
1060
|
+
* @param {TraceLog} traceLog - A trace from `recordEffect` or a `recorder`
|
|
1061
|
+
* @param {Object} [options]
|
|
1062
|
+
* @param {boolean} [options.strict] - Positional matching with paradox detection; set `false` for `Parallel`
|
|
1063
|
+
* @param {(...args: any[]) => void} [options.log] - Defaults to `console.log`
|
|
1064
|
+
* @param {any} [options.context] - Overrides the context stored on the trace
|
|
1065
|
+
* @param {string} [options.version] - Current build id; warns when it differs from the trace's
|
|
1066
|
+
* @returns {Promise<SuccessState | FailureState>}
|
|
1067
|
+
*/
|
|
1068
|
+
const timeTravel = async (flowFn, traceLog, options = {}) => {
|
|
1069
|
+
const { strict = true, log = console.log, context, version } = options;
|
|
1070
|
+
const { initialInput, trace, flowName, version: traceVersion } = traceLog;
|
|
1071
|
+
// `message` and `stack` are non-enumerable on Error, so JSON.stringify alone would
|
|
1072
|
+
// drop the most useful line of the report.
|
|
1073
|
+
const format = (/** @type {any} */ v) =>
|
|
1074
|
+
v instanceof Error
|
|
1075
|
+
? JSON.stringify({ ...v, name: v.name, message: v.message }, null, 2)
|
|
1076
|
+
: JSON.stringify(v, null, 2);
|
|
1077
|
+
|
|
1078
|
+
if (version && traceVersion && version !== traceVersion) {
|
|
1079
|
+
log(`Warning: trace was recorded at ${traceVersion}, replaying against ${version}.`);
|
|
1080
|
+
}
|
|
1081
|
+
const stepsText = trace.length === 0 || trace.length > 1 ? 'steps' : 'step';
|
|
1082
|
+
log(`Replaying '${flowName || 'flow'}' (${trace.length} recorded ${stepsText})`);
|
|
1083
|
+
log(`Initial input: ${format(initialInput)}`);
|
|
1084
|
+
|
|
1085
|
+
// Narration goes through `onResolved` rather than a wrapped Resolver: observing each
|
|
1086
|
+
// step is all this needs, and `replayEffect` already resolves the trace itself.
|
|
1087
|
+
let consumed = 0;
|
|
1088
|
+
// A path maps a replayed step back to its recorded entry whatever order branches finished in, so
|
|
1089
|
+
// timings are narrated for Parallel too. Positional lookup is the fallback for a legacy trace with
|
|
1090
|
+
// no paths, and only under strict matching, since per-Command queues carry no position.
|
|
1091
|
+
const byPath = new Map(trace.filter((e) => typeof e.path === 'string').map((e) => [e.path, e]));
|
|
1092
|
+
const timing = (/** @type {ReplayStep} */ step) => {
|
|
1093
|
+
const recorded = byPath.get(step.path) ?? (strict ? trace[step.index] : undefined);
|
|
1094
|
+
return typeof recorded?.durationMs === 'number' ? ` in ${recorded.durationMs}ms` : '';
|
|
1095
|
+
};
|
|
1096
|
+
const result = await replayEffect(flowFn(initialInput), traceLog, {
|
|
1097
|
+
strict,
|
|
1098
|
+
context: context !== undefined ? context : traceLog.context || {},
|
|
1099
|
+
onResolved: (step, outcome) => {
|
|
1100
|
+
// Resolving from a trace either answers or throws, so this is never undefined.
|
|
1101
|
+
if (outcome === undefined) return;
|
|
1102
|
+
consumed++;
|
|
1103
|
+
log(
|
|
1104
|
+
'error' in outcome
|
|
1105
|
+
? `Step ${step.index + 1}: ${step.name} threw${timing(step)} ${format(outcome.error)}`
|
|
1106
|
+
: `Step ${step.index + 1}: ${step.name} returned${timing(step)} ${format(outcome.result)}`
|
|
1107
|
+
);
|
|
1108
|
+
}
|
|
1109
|
+
});
|
|
1110
|
+
|
|
1111
|
+
log(`Replay finished with state: ${result.type}`);
|
|
1112
|
+
log(result.type === 'Failure' ? `Error: ${format(result.error)}` : `Result: ${format(result.value)}`);
|
|
1113
|
+
if (consumed < trace.length) {
|
|
1114
|
+
log(`Warning: ${trace.length - consumed} recorded ${stepsText} were never reached. The flow diverged.`);
|
|
1115
|
+
}
|
|
1116
|
+
return result;
|
|
1117
|
+
};
|
|
1118
|
+
|
|
1119
|
+
export {
|
|
1120
|
+
Success,
|
|
1121
|
+
Failure,
|
|
1122
|
+
Command,
|
|
1123
|
+
Ask,
|
|
1124
|
+
Retry,
|
|
1125
|
+
Parallel,
|
|
1126
|
+
effectPipe,
|
|
1127
|
+
runEffect,
|
|
1128
|
+
configureEffect,
|
|
1129
|
+
recorder,
|
|
1130
|
+
recordEffect,
|
|
1131
|
+
replayEffect,
|
|
1132
|
+
timeTravel
|
|
1133
|
+
};
|