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