pure-effect 0.14.0 → 0.16.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 +50 -0
- package/README.md +372 -120
- package/examples/opentelemetry-example.js +173 -0
- package/examples/recording-example.js +170 -0
- package/index.d.ts +1067 -1089
- package/index.js +835 -698
- package/package.json +7 -4
package/index.js
CHANGED
|
@@ -79,36 +79,49 @@ const Failure = (error, initialInput) => ({
|
|
|
79
79
|
*
|
|
80
80
|
* @param {(signal?: AbortSignal) => Promise<any>|any} cmd - The side-effect function to execute. Inside a
|
|
81
81
|
* `Parallel` branch it receives an `AbortSignal` that fires when a sibling branch fails, so I/O that
|
|
82
|
-
* accepts one can be cancelled in flight.
|
|
83
|
-
*
|
|
82
|
+
* accepts one can be cancelled in flight. Outside a `Parallel` no argument is passed. The signal is the
|
|
83
|
+
* first argument, so wrap a function that takes an optional one, as in `() => nanoid()`.
|
|
84
84
|
* @param {(result: any) => Effect} [next] - Receives the result of `cmd` and returns the next Effect.
|
|
85
|
-
* Defaults to `(result) => Success(result)`, which is what most Commands want.
|
|
85
|
+
* Defaults to `(result) => Success(result)`, which is what most Commands want; `null` counts as omitted.
|
|
86
86
|
* @param {CommandMeta} [meta] - Optional metadata, passed to `onBeforeCommand`. A string `meta.name`
|
|
87
|
-
* becomes this Command's identity for traces, replay matching, and telemetry spans
|
|
88
|
-
* the identity independent of how `cmd` was declared and immune to minification.
|
|
87
|
+
* becomes this Command's identity for traces, replay matching, and telemetry spans.
|
|
89
88
|
* @returns {CommandState}
|
|
90
89
|
*/
|
|
91
|
-
const Command = (cmd, next
|
|
92
|
-
|
|
93
|
-
cmd
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
90
|
+
const Command = (cmd, next, meta) => {
|
|
91
|
+
// Checked as the flow is built, so a mistake is reported before the I/O it would have run.
|
|
92
|
+
if (typeof cmd !== 'function') {
|
|
93
|
+
throw malformed(
|
|
94
|
+
/** @type {any} */ (cmd) instanceof Promise
|
|
95
|
+
? 'Command expects a function, got a Promise.'
|
|
96
|
+
: `Command expects the function that does the I/O, got ${describeArgument(cmd)}.`,
|
|
97
|
+
cmd
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
if (next != null && typeof next !== 'function') {
|
|
101
|
+
throw malformed(
|
|
102
|
+
isOptionsObject(next)
|
|
103
|
+
? "Command's second argument is next, and meta goes third: Command(fn, undefined, { name: 'chargeCard' })."
|
|
104
|
+
: `Command's next must be a function, got ${describeArgument(next)}.`,
|
|
105
|
+
next
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
return { type: 'Command', cmd, next: next ?? ((/** @type {any} */ result) => Success(result)), meta };
|
|
109
|
+
};
|
|
97
110
|
|
|
98
111
|
/**
|
|
99
112
|
* The name a Command is known by: what a trace records, what replay matches on, and what a
|
|
100
|
-
* telemetry span is called.
|
|
101
|
-
*
|
|
102
|
-
* `meta.name` wins when it is a non-empty string. Otherwise the identity is the thunk's own `name`,
|
|
103
|
-
* so an inline arrow records as 'anonymous' and a minifier that mangles function names silently
|
|
104
|
-
* renames every step of every trace. Passing `meta.name` avoids both.
|
|
113
|
+
* telemetry span is called. A non-empty string `meta.name`, else `cmd.name`, else 'anonymous'.
|
|
114
|
+
* Exported so a test walking a flow checks each step by the same rule.
|
|
105
115
|
*
|
|
106
116
|
* @param {CommandState} eff
|
|
107
117
|
* @returns {string}
|
|
108
118
|
*/
|
|
109
119
|
const commandName = (eff) => {
|
|
120
|
+
if (eff?.type !== 'Command') {
|
|
121
|
+
throw malformed(`commandName expects a Command, got ${describeArgument(eff)}.`, eff);
|
|
122
|
+
}
|
|
110
123
|
const meta = eff.meta;
|
|
111
|
-
const named = meta
|
|
124
|
+
const named = isObject(meta) ? meta.name : undefined;
|
|
112
125
|
return typeof named === 'string' && named !== '' ? named : eff.cmd.name || 'anonymous';
|
|
113
126
|
};
|
|
114
127
|
|
|
@@ -117,7 +130,12 @@ const commandName = (eff) => {
|
|
|
117
130
|
* @param {(context: any) => Effect} next - Receives the context and returns the next Effect
|
|
118
131
|
* @returns {AskState}
|
|
119
132
|
*/
|
|
120
|
-
const Ask = (next) =>
|
|
133
|
+
const Ask = (next) => {
|
|
134
|
+
if (typeof next !== 'function') {
|
|
135
|
+
throw malformed(`Ask expects a function that receives the context, got ${describeArgument(next)}.`, next);
|
|
136
|
+
}
|
|
137
|
+
return { type: 'Ask', next };
|
|
138
|
+
};
|
|
121
139
|
|
|
122
140
|
/**
|
|
123
141
|
* Wraps an Effect tree with retry-on-failure semantics.
|
|
@@ -128,87 +146,149 @@ const Ask = (next) => ({ type: 'Ask', next });
|
|
|
128
146
|
* every Command in it is idempotent.
|
|
129
147
|
*
|
|
130
148
|
* @param {Effect} effect - The inner Effect tree to retry
|
|
131
|
-
* @param {Object} [options] - Retry options, merged over the library defaults at runtime
|
|
132
|
-
*
|
|
133
|
-
* @param {number} [options.attempts] - Max retries (not counting first try)
|
|
134
|
-
* @param {number} [options.delay] - Ms before first retry
|
|
135
|
-
* @param {number} [options.backoff] - Multiplier applied to delay on each subsequent retry
|
|
149
|
+
* @param {Object} [options] - Retry options, merged over the library defaults at runtime. An option set to
|
|
150
|
+
* `undefined` keeps its default, as an absent config key does.
|
|
151
|
+
* @param {number} [options.attempts] - Max retries (not counting first try), a positive integer
|
|
152
|
+
* @param {number} [options.delay] - Ms before first retry, a finite number of 0 or more
|
|
153
|
+
* @param {number} [options.backoff] - Multiplier applied to delay on each subsequent retry, a finite number
|
|
154
|
+
* of 0 or more
|
|
136
155
|
* @param {(error: any) => Effect} [options.onExhausted] - Runs a fallback Effect when every attempt has
|
|
137
156
|
* failed, receiving `{ retryExhausted, lastError, attempts }`. The fallback's success feeds `next`
|
|
138
|
-
* exactly as the primary's would have; its failure propagates unwrapped.
|
|
139
|
-
*
|
|
157
|
+
* exactly as the primary's would have; its failure propagates unwrapped. A fallback never starts
|
|
158
|
+
* in a `Parallel` branch a sibling has cancelled.
|
|
140
159
|
* @returns {RetryState}
|
|
141
160
|
*/
|
|
142
|
-
const Retry = (effect, options
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
161
|
+
const Retry = (effect, options) => {
|
|
162
|
+
if (!isEffect(effect)) throw malformed(`Retry expects the Effect to run, got ${describeValue(effect)}.`, effect);
|
|
163
|
+
if (options != null && !isOptionsObject(options)) {
|
|
164
|
+
const hint = typeof options === 'number' ? `: write Retry(effect, { attempts: ${options} })` : '';
|
|
165
|
+
throw malformed(`Retry's options must be an object, got ${describeArgument(options)}${hint}.`, options);
|
|
166
|
+
}
|
|
167
|
+
return { type: 'Retry', effect, options: options ?? {}, next: (value) => Success(value) };
|
|
168
|
+
};
|
|
148
169
|
|
|
149
170
|
/**
|
|
150
171
|
* Runs multiple Effect trees concurrently. The first branch to fail cancels its siblings, and that
|
|
151
172
|
* branch's Failure is what the Parallel returns; `next` is skipped. Which branch fails first depends on
|
|
152
173
|
* timing, so it is recorded, and a replay returns the same one.
|
|
153
174
|
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
* the `AbortSignal` it is passed and hand it to whatever performs the I/O; a thunk that ignores the
|
|
175
|
+
* A cancelled branch starts no further Commands. Stopping the Command already in flight needs its
|
|
176
|
+
* function to pass the `AbortSignal` it receives to whatever performs the I/O; one that ignores the
|
|
157
177
|
* signal runs to completion, so a branch's first Command can still write after a sibling has failed.
|
|
158
178
|
*
|
|
159
179
|
* `settled: true` turns off that first-failure rule: every branch runs to completion and `next`
|
|
160
|
-
* receives the branch outcomes
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
* resource that rate limits; results and paths stay in array order either way.
|
|
180
|
+
* receives the branch outcomes, `Success` and `Failure` nodes in array order. An `EffectTypeError`
|
|
181
|
+
* still escapes. `limit: n` keeps at most `n` branches in flight. Results and paths stay in array
|
|
182
|
+
* order either way.
|
|
164
183
|
*
|
|
165
184
|
* The second argument is the `next` function or the options, whichever it looks like, so
|
|
166
|
-
* `Parallel(effects, { limit: 5 })` needs no placeholder.
|
|
185
|
+
* `Parallel(effects, { limit: 5 })` needs no placeholder. `undefined` or `null` there is a skipped `next`,
|
|
186
|
+
* so `Parallel(effects, undefined, { limit: 5 })` still reads the options.
|
|
167
187
|
*
|
|
168
188
|
* @param {Effect[]} effects - Array of Effect trees to run concurrently
|
|
169
|
-
* @param {((values: any[]) => Effect) | ParallelOptions} [nextOrOptions] - Receives array of success
|
|
189
|
+
* @param {((values: any[]) => Effect) | ParallelOptions | null} [nextOrOptions] - Receives array of success
|
|
170
190
|
* values in order and returns the next Effect, or the options. `next` defaults to
|
|
171
191
|
* `(values) => Success(values)`, same as `Command`'s default.
|
|
172
|
-
* @param {ParallelOptions} [maybeOptions] - Options, when `next` was given
|
|
192
|
+
* @param {ParallelOptions} [maybeOptions] - Options, when `next` was given or skipped
|
|
173
193
|
* @returns {ParallelState}
|
|
174
194
|
*/
|
|
175
195
|
const Parallel = (effects, nextOrOptions, maybeOptions) => {
|
|
196
|
+
if (!Array.isArray(effects)) {
|
|
197
|
+
throw malformed(`Parallel expects an array of Effects, got ${describeArgument(effects)}.`, effects);
|
|
198
|
+
}
|
|
199
|
+
effects.forEach((branch, i) => {
|
|
200
|
+
if (!isEffect(branch)) throw malformed(`Parallel's branch ${i} is ${describeValue(branch)}.`, branch);
|
|
201
|
+
});
|
|
176
202
|
const hasNext = typeof nextOrOptions === 'function';
|
|
203
|
+
// A caller forwarding an absent `next` still passes its options third, as the signature reads.
|
|
204
|
+
const nextSkipped = nextOrOptions == null;
|
|
205
|
+
const optionsSecond = !hasNext && !nextSkipped;
|
|
206
|
+
if (optionsSecond && !isOptionsObject(nextOrOptions)) {
|
|
207
|
+
const got = describeArgument(nextOrOptions);
|
|
208
|
+
throw malformed(`Parallel's second argument must be next or the options, got ${got}.`, nextOrOptions);
|
|
209
|
+
}
|
|
210
|
+
// Nothing reads a third argument after the options, so a `next` passed there would silently be skipped.
|
|
211
|
+
if (optionsSecond && maybeOptions != null) {
|
|
212
|
+
throw malformed(
|
|
213
|
+
typeof maybeOptions === 'function'
|
|
214
|
+
? "Parallel's next goes second and its options third: Parallel(effects, next, options)."
|
|
215
|
+
: `Parallel takes one options object, and with the options second its third argument, ` +
|
|
216
|
+
`${describeArgument(maybeOptions)}, would be ignored.`,
|
|
217
|
+
maybeOptions
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
const options = optionsSecond ? nextOrOptions : maybeOptions;
|
|
221
|
+
if (options != null && !isOptionsObject(options)) {
|
|
222
|
+
throw malformed(`Parallel's options must be an object, got ${describeArgument(options)}.`, options);
|
|
223
|
+
}
|
|
177
224
|
return {
|
|
178
225
|
type: 'Parallel',
|
|
179
226
|
effects,
|
|
180
227
|
next: hasNext ? nextOrOptions : (/** @type {any[]} */ values) => Success(values),
|
|
181
|
-
options:
|
|
228
|
+
options: options ?? {}
|
|
182
229
|
};
|
|
183
230
|
};
|
|
184
231
|
|
|
232
|
+
/**
|
|
233
|
+
* Whether a value is an object that can carry properties: not `null`, a primitive, or a function.
|
|
234
|
+
* @param {any} value
|
|
235
|
+
* @returns {boolean}
|
|
236
|
+
*/
|
|
237
|
+
const isObject = (value) => value !== null && typeof value === 'object';
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* @param {any} value
|
|
241
|
+
* @returns {boolean}
|
|
242
|
+
*/
|
|
243
|
+
const isPositiveInteger = (value) => Number.isInteger(value) && value >= 1;
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* @param {any} value
|
|
247
|
+
* @returns {boolean}
|
|
248
|
+
*/
|
|
249
|
+
const isFiniteNonNegative = (value) => Number.isFinite(value) && value >= 0;
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* An object that can hold options or meta: not an array, and not an Effect passed in the wrong place.
|
|
253
|
+
* @param {any} value
|
|
254
|
+
* @returns {boolean}
|
|
255
|
+
*/
|
|
256
|
+
const isOptionsObject = (value) => isObject(value) && !Array.isArray(value) && !isEffect(value);
|
|
257
|
+
|
|
185
258
|
/**
|
|
186
259
|
* Describes a value for an error message, leading with the mistake it most likely is.
|
|
187
260
|
* @param {any} value
|
|
188
261
|
* @returns {string}
|
|
189
262
|
*/
|
|
190
|
-
const describeValue = (value) =>
|
|
191
|
-
value === undefined
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
263
|
+
const describeValue = (value) => {
|
|
264
|
+
if (value === undefined) return 'undefined, which usually means a missing return';
|
|
265
|
+
if (value === null) return 'null';
|
|
266
|
+
if (value instanceof Promise) return 'a Promise, which usually means an async function';
|
|
267
|
+
if (typeof value === 'function')
|
|
268
|
+
return 'a function, which usually means a flow was passed without being called with its input';
|
|
269
|
+
// `String`, since `JSON.stringify` prints NaN and Infinity as null and throws on a BigInt.
|
|
270
|
+
if (typeof value !== 'object') {
|
|
271
|
+
return `the ${typeof value} ${typeof value === 'string' ? JSON.stringify(value) : String(value)}`;
|
|
272
|
+
}
|
|
273
|
+
if (typeof value.type === 'string') return `an object with an unrecognised type '${value.type}'`;
|
|
274
|
+
return 'a plain object';
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Describes a constructor's argument for an error message. A missing one is usually a misspelt name
|
|
279
|
+
* rather than a missing return, and an Effect in the wrong place is named by its type.
|
|
280
|
+
* @param {any} value
|
|
281
|
+
* @returns {string}
|
|
282
|
+
*/
|
|
283
|
+
const describeArgument = (value) =>
|
|
284
|
+
value === undefined ? 'undefined' : isEffect(value) ? `an Effect of type '${value.type}'` : describeValue(value);
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Marks an error as the harness failing rather than the flow: a malformed flow, or a trace that cannot
|
|
288
|
+
* answer a step. The interpreter rethrows anything carrying it instead of folding it into a `Failure`, so
|
|
289
|
+
* nothing that handles a domain failure can absorb it. A new kind of harness error gets this mark rather
|
|
290
|
+
* than a name the interpreter has to know. Non-enumerable, so it never shows up in a serialized error or
|
|
291
|
+
* a `deepEqual`.
|
|
212
292
|
*/
|
|
213
293
|
const harnessError = Symbol('pure-effect.harnessError');
|
|
214
294
|
|
|
@@ -219,11 +299,17 @@ const harnessError = Symbol('pure-effect.harnessError');
|
|
|
219
299
|
const asHarnessError = (error) => Object.defineProperty(error, harnessError, { value: true });
|
|
220
300
|
|
|
221
301
|
/**
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
|
|
302
|
+
* Whether a thrown value carries one of the library's marks: `harnessError`, `replayCut`, or `replayFault`.
|
|
303
|
+
* @param {unknown} e
|
|
304
|
+
* @param {symbol} mark
|
|
305
|
+
* @returns {boolean}
|
|
306
|
+
*/
|
|
307
|
+
const hasMark = (e, mark) => Boolean(e && /** @type {any} */ (e)[mark]);
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* An I/O fault: a Command's function threw, or a `Retry` ran out of attempts. `Retry` retries this, never a
|
|
311
|
+
* `Failure` a step returned, which is an abort. Internal: only `execute` and its per-node functions return
|
|
312
|
+
* one, and `asOutcome` turns it into a plain `Failure` before it reaches user code.
|
|
227
313
|
* @typedef {{ type: 'IoFault', error: any, initialInput?: any }} IoFaultState
|
|
228
314
|
*/
|
|
229
315
|
|
|
@@ -241,33 +327,66 @@ const IoFault = (error, initialInput) => ({ type: 'IoFault', error, initialInput
|
|
|
241
327
|
const asOutcome = (state) => (state.type === 'IoFault' ? Failure(state.error, state.initialInput) : state);
|
|
242
328
|
|
|
243
329
|
/**
|
|
244
|
-
*
|
|
330
|
+
* Builds an `EffectTypeError`, for a malformed flow: a step or continuation that returned something other
|
|
331
|
+
* than an Effect, or a constructor given an argument it cannot use. It is a harness error: a malformed flow
|
|
332
|
+
* is a bug, so it is thrown rather than becoming a `Failure`.
|
|
245
333
|
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
334
|
+
* @param {string} message
|
|
335
|
+
* @param {any} value - The malformed value
|
|
336
|
+
* @returns {Error}
|
|
337
|
+
*/
|
|
338
|
+
const malformed = (message, value) => {
|
|
339
|
+
// This error already reports the bug, so the Promise rejecting must not also crash the process as
|
|
340
|
+
// unhandled. Only a native Promise: calling `then` on a Knex or Mongoose query builder runs the query.
|
|
341
|
+
if (value instanceof Promise) value.catch(() => {});
|
|
342
|
+
return asHarnessError(Object.assign(new Error(message), { name: 'EffectTypeError' }));
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Builds the error for a value that is not an Effect, explaining the likely mistake.
|
|
248
347
|
*
|
|
249
348
|
* @param {any} value
|
|
250
349
|
* @param {string} source - What produced the value, named where it is known
|
|
251
350
|
* @returns {Error}
|
|
252
351
|
*/
|
|
253
|
-
const effectTypeError = (value, source) =>
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
Object.assign(
|
|
261
|
-
new Error(
|
|
262
|
-
`${source} returned ${describeValue(value)}. Return Success, Failure, Command, Ask, Retry, or Parallel: ` +
|
|
263
|
-
(isPromise
|
|
264
|
-
? 'a step cannot be async, so do the awaited work in a Command and continue in its next.'
|
|
265
|
-
: 'a plain value has to be wrapped, as in Success(value).')
|
|
266
|
-
),
|
|
267
|
-
{ name: 'EffectTypeError' }
|
|
268
|
-
)
|
|
352
|
+
const effectTypeError = (value, source) =>
|
|
353
|
+
malformed(
|
|
354
|
+
`${source} returned ${describeValue(value)}. Return Success, Failure, Command, Ask, Retry, or Parallel: ` +
|
|
355
|
+
(value instanceof Promise
|
|
356
|
+
? 'a step cannot be async, so do the awaited work in a Command and continue in its next.'
|
|
357
|
+
: 'a plain value has to be wrapped, as in Success(value).'),
|
|
358
|
+
value
|
|
269
359
|
);
|
|
270
|
-
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Names the node whose `next` returned a value, for an error message: a Command by its name, any other by its type.
|
|
363
|
+
* @param {Effect} node
|
|
364
|
+
* @returns {string}
|
|
365
|
+
*/
|
|
366
|
+
const nextOf = (node) =>
|
|
367
|
+
node.type === 'Command'
|
|
368
|
+
? `The next of Command '${commandName(node)}'`
|
|
369
|
+
: `The next of ${node.type === 'Ask' ? 'an' : 'a'} ${node.type}`;
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* A Success or a Failure: a flow with nothing left to run.
|
|
373
|
+
* @param {any} value
|
|
374
|
+
* @returns {value is SuccessState | FailureState}
|
|
375
|
+
*/
|
|
376
|
+
const isOutcome = (value) => ['Success', 'Failure'].includes(value?.type);
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* A Command, Ask, Retry, or Parallel: a flow the interpreter still has work to do on.
|
|
380
|
+
* @param {any} value
|
|
381
|
+
* @returns {value is CommandState | AskState | RetryState | ParallelState}
|
|
382
|
+
*/
|
|
383
|
+
const isPending = (value) => ['Command', 'Ask', 'Retry', 'Parallel'].includes(value?.type);
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* @param {any} value
|
|
387
|
+
* @returns {value is Effect}
|
|
388
|
+
*/
|
|
389
|
+
const isEffect = (value) => isOutcome(value) || isPending(value);
|
|
271
390
|
|
|
272
391
|
/**
|
|
273
392
|
* @param {any} value
|
|
@@ -275,7 +394,7 @@ const effectTypeError = (value, source) => {
|
|
|
275
394
|
* @returns {Effect}
|
|
276
395
|
*/
|
|
277
396
|
const asEffect = (value, source) => {
|
|
278
|
-
if (
|
|
397
|
+
if (isEffect(value)) return value;
|
|
279
398
|
throw effectTypeError(value, source);
|
|
280
399
|
};
|
|
281
400
|
|
|
@@ -286,25 +405,18 @@ const asEffect = (value, source) => {
|
|
|
286
405
|
* @param {Effect} effect - The current Effect object
|
|
287
406
|
* @param {(value: any) => Effect} fn - The next function to run if the current effect is a Success
|
|
288
407
|
* @param {any} [initialInput] - The pipeline's starting value, stamped on every node but a Success
|
|
408
|
+
* @param {Effect} [from] - The node whose `next` returned `effect`, which an error names
|
|
289
409
|
* @returns {Effect} The composed Effect
|
|
290
410
|
*/
|
|
291
|
-
const chain = (effect, fn, initialInput) => {
|
|
292
|
-
|
|
293
|
-
// sub-pipeline's
|
|
294
|
-
//
|
|
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.
|
|
411
|
+
const chain = (effect, fn, initialInput, from) => {
|
|
412
|
+
const source = () => (from ? nextOf(from) : 'A continuation');
|
|
413
|
+
// Overwrites a sub-pipeline's own stamp, so the root and a Failure at any depth carry the input of the
|
|
414
|
+
// flow that was actually called. A Success stays bare, so `deepEqual(result, Success(v))` always holds.
|
|
300
415
|
const withII = (/** @type {Effect} */ e) =>
|
|
301
416
|
initialInput !== undefined && e.type !== 'Success' ? { ...e, initialInput } : e;
|
|
302
417
|
|
|
303
|
-
//
|
|
304
|
-
|
|
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');
|
|
418
|
+
// Checked before `effect.type` is read, which would otherwise throw a bare TypeError naming no step.
|
|
419
|
+
if (effect == null) return asEffect(effect, source());
|
|
308
420
|
|
|
309
421
|
switch (effect.type) {
|
|
310
422
|
case 'Success':
|
|
@@ -312,23 +424,23 @@ const chain = (effect, fn, initialInput) => {
|
|
|
312
424
|
case 'Failure':
|
|
313
425
|
return withII(effect);
|
|
314
426
|
case 'Command': {
|
|
315
|
-
const next = (/** @type {any} */ result) => chain(effect.next(result), fn, initialInput);
|
|
427
|
+
const next = (/** @type {any} */ result) => chain(effect.next(result), fn, initialInput, effect);
|
|
316
428
|
return withII(Command(effect.cmd, next, effect.meta));
|
|
317
429
|
}
|
|
318
430
|
case 'Ask': {
|
|
319
|
-
const next = (/** @type {any} */ ctx) => chain(effect.next(ctx), fn, initialInput);
|
|
431
|
+
const next = (/** @type {any} */ ctx) => chain(effect.next(ctx), fn, initialInput, effect);
|
|
320
432
|
return withII(Ask(next));
|
|
321
433
|
}
|
|
322
434
|
case 'Retry': {
|
|
323
|
-
const next = (/** @type {any} */ result) => chain(effect.next(result), fn, initialInput);
|
|
435
|
+
const next = (/** @type {any} */ result) => chain(effect.next(result), fn, initialInput, effect);
|
|
324
436
|
return withII({ ...effect, next });
|
|
325
437
|
}
|
|
326
438
|
case 'Parallel': {
|
|
327
|
-
const next = (/** @type {any} */ result) => chain(effect.next(result), fn, initialInput);
|
|
439
|
+
const next = (/** @type {any} */ result) => chain(effect.next(result), fn, initialInput, effect);
|
|
328
440
|
return withII({ ...effect, next });
|
|
329
441
|
}
|
|
330
442
|
default:
|
|
331
|
-
return asEffect(effect,
|
|
443
|
+
return asEffect(effect, source());
|
|
332
444
|
}
|
|
333
445
|
};
|
|
334
446
|
|
|
@@ -340,15 +452,21 @@ const chain = (effect, fn, initialInput) => {
|
|
|
340
452
|
* @returns {(start: any) => Effect} A function that accepts an initial input and returns the final Effect tree.
|
|
341
453
|
*/
|
|
342
454
|
const effectPipe = (...fns) => {
|
|
455
|
+
// Checked where the pipeline is defined, so a misspelt import is named before any flow is built from it.
|
|
456
|
+
fns.forEach((fn, i) => {
|
|
457
|
+
if (typeof fn !== 'function') {
|
|
458
|
+
const got = describeArgument(fn);
|
|
459
|
+
throw malformed(
|
|
460
|
+
`effectPipe's step ${i + 1} is ${got}; each step is a function that returns an Effect.`,
|
|
461
|
+
fn
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
});
|
|
343
465
|
return (start) => {
|
|
344
466
|
const chainWithII = (/** @type {Effect} */ eff, /** @type {(v: any) => Effect} */ fn) => chain(eff, fn, start);
|
|
345
467
|
const tree = fns.reduce(chainWithII, /** @type {Effect} */ (Success(start)));
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
// that wrapping, so a sub-pipeline returned by the last step would keep its own input on
|
|
349
|
-
// everything past its head. The pass wraps those continuations too, so every node reachable
|
|
350
|
-
// from here, a Failure at any depth included, carries this pipeline's `start`. `Success` is the
|
|
351
|
-
// identity step: it can never return a non-Effect, so no message is ever attributed to it.
|
|
468
|
+
// An identity pass. `chain` stamps a sub-pipeline's nodes only through the continuations it wraps,
|
|
469
|
+
// and a sub-pipeline returned by the last step has no later step to wrap them.
|
|
352
470
|
return chain(tree, Success, start);
|
|
353
471
|
};
|
|
354
472
|
};
|
|
@@ -356,10 +474,8 @@ const effectPipe = (...fns) => {
|
|
|
356
474
|
/**
|
|
357
475
|
* Wraps one Command execution, or one Parallel: `type` is 'Parallel', and `op` runs the branches and
|
|
358
476
|
* returns the Parallel's decision, even when a branch threw, so a hook must call it. Only a replay passes
|
|
359
|
-
* `op` an argument, the recorded decision. `path`
|
|
360
|
-
*
|
|
361
|
-
* branches finish in a different order. Hooks written before `path` existed take three parameters and
|
|
362
|
-
* are unaffected.
|
|
477
|
+
* `op` an argument, the recorded decision. `path` is the step's position in the Effect tree, so it matches
|
|
478
|
+
* between a run and its replay whatever order `Parallel` branches finish in.
|
|
363
479
|
* @typedef {(name: string, type: string, op: function, path?: string) => Promise<any>} StepRunner
|
|
364
480
|
*/
|
|
365
481
|
/** @type StepRunner */
|
|
@@ -376,15 +492,13 @@ const defaultCommandInterceptor = async (command, context) => {};
|
|
|
376
492
|
const defaultRetryOptions = { attempts: 3, delay: 100, backoff: 1 };
|
|
377
493
|
|
|
378
494
|
/**
|
|
379
|
-
*
|
|
380
|
-
*
|
|
381
|
-
* leftover `retry` key throws instead of being ignored: silently falling back to the library defaults
|
|
382
|
-
* is how a configured `attempts: 5` quietly becomes 3. Remove this guard at 1.0.
|
|
495
|
+
* Refuses the removed `retry` key rather than ignoring it, which would quietly turn a configured
|
|
496
|
+
* `attempts: 5` back into 3. Migration scaffolding: remove at 1.0.
|
|
383
497
|
* @param {any} config
|
|
384
498
|
* @param {string} source
|
|
385
499
|
*/
|
|
386
500
|
const rejectRetryKey = (config, source) => {
|
|
387
|
-
if (config &&
|
|
501
|
+
if (isObject(config) && 'retry' in config)
|
|
388
502
|
throw new TypeError(
|
|
389
503
|
`${source} no longer takes 'retry'. Retry options are per-use: pass them to Retry(effect, options).`
|
|
390
504
|
);
|
|
@@ -392,18 +506,15 @@ const rejectRetryKey = (config, source) => {
|
|
|
392
506
|
|
|
393
507
|
/**
|
|
394
508
|
* @typedef {Object} EffectConfiguration
|
|
395
|
-
* @property {StepRunner} [onStep] -
|
|
509
|
+
* @property {StepRunner} [onStep] - Wraps each Command's execution, and each Parallel's branches.
|
|
396
510
|
* @property {RunWrapper} [onRun] - Fires once per runEffect call. It wraps the entire workflow execution.
|
|
397
511
|
* @property {CommandInterceptor} [onBeforeCommand] - Intercepts a Command and any context passed to runEffect before execution.
|
|
398
512
|
*/
|
|
399
513
|
|
|
400
514
|
/**
|
|
401
515
|
* A per-call configuration: an `EffectConfiguration` plus `inherit`. With `inherit: true` (the default)
|
|
402
|
-
* the call's hooks are
|
|
403
|
-
*
|
|
404
|
-
* leaves unset falls back to the library default. A per-call hook cannot replace a single global slot on
|
|
405
|
-
* its own; that was the previous default, and it is how a per-call recorder silently switched off an
|
|
406
|
-
* application's tracing.
|
|
516
|
+
* the call's hooks are merged over the wiring `configureEffect` installed, global outermost. With
|
|
517
|
+
* `inherit: false` that wiring is ignored, so a slot the call leaves unset falls back to the library default.
|
|
407
518
|
*
|
|
408
519
|
* @typedef {EffectConfiguration & { inherit?: boolean }} CallConfiguration
|
|
409
520
|
*/
|
|
@@ -412,10 +523,8 @@ const rejectRetryKey = (config, source) => {
|
|
|
412
523
|
let layers = [];
|
|
413
524
|
|
|
414
525
|
/**
|
|
415
|
-
* The installed layers merged into one configuration
|
|
416
|
-
*
|
|
417
|
-
* and a slot no layer defines is simply absent, so the library default is chosen at the one place the
|
|
418
|
-
* hook is used rather than held in a second set of variables kept in step by hand.
|
|
526
|
+
* The installed layers merged into one configuration. A slot no layer defines is absent, and `runEffect`
|
|
527
|
+
* picks the library default where it reads the slot.
|
|
419
528
|
* @type {EffectConfiguration}
|
|
420
529
|
*/
|
|
421
530
|
let globalConfig = {};
|
|
@@ -438,29 +547,21 @@ const applyLayers = () => {
|
|
|
438
547
|
* configureEffect(telemetryHooks(), recordingHooks({ sink }));
|
|
439
548
|
* configureEffect(telemetryHooks()); configureEffect(recordingHooks({ sink }));
|
|
440
549
|
*
|
|
441
|
-
* Calling it with no arguments at all removes every layer. A call whose arguments are all `undefined
|
|
442
|
-
* is a conditional install that installed nothing:
|
|
443
|
-
*
|
|
444
|
-
* Layers replaced the previous one-slot rule, under which a later call displaced the earlier wiring and
|
|
445
|
-
* the returned function restored a snapshot, guarded so that interleaved installs could not clobber
|
|
446
|
-
* each other. Removing a layer needs no guard: A installs, B installs, A removes, and B is still there.
|
|
447
|
-
* It also means a library can install its own hooks without erasing its host's, which under the old rule
|
|
448
|
-
* was possible only if the host passed the library's configuration into its own call.
|
|
550
|
+
* Calling it with no arguments at all removes every layer. A call whose arguments are all `undefined`,
|
|
551
|
+
* such as `configureEffect(flag ? hooks : undefined)`, is a conditional install that installed nothing:
|
|
552
|
+
* it adds no layer and removes none.
|
|
449
553
|
*
|
|
450
554
|
* @param {...(EffectConfiguration | undefined)} configs - Configurations merged into one layer, outermost first
|
|
451
555
|
* @returns {() => void} Removes the layer this call added; a second call does nothing
|
|
452
556
|
*/
|
|
453
557
|
const configureEffect = (...configs) => {
|
|
454
|
-
// Only a call with no arguments at all is a reset. A call whose arguments are all absent, such as
|
|
455
|
-
// `configureEffect(flag ? hooks : undefined)`, is a conditional install that happened not to install
|
|
456
|
-
// anything, so it adds no layer and must not remove anyone else's.
|
|
457
558
|
if (configs.length === 0) {
|
|
458
559
|
layers = [];
|
|
459
560
|
applyLayers();
|
|
460
561
|
return () => {};
|
|
461
562
|
}
|
|
462
563
|
const present = configs.filter(Boolean);
|
|
463
|
-
//
|
|
564
|
+
// Before installing anything, so a refused call leaves the wiring untouched.
|
|
464
565
|
present.forEach((config) => rejectRetryKey(config, 'configureEffect'));
|
|
465
566
|
if (present.length === 0) return () => {};
|
|
466
567
|
const layer = chainHooks(...present);
|
|
@@ -493,7 +594,8 @@ const configureEffect = (...configs) => {
|
|
|
493
594
|
const now = () => (typeof performance === 'object' ? performance.now() : Date.now());
|
|
494
595
|
|
|
495
596
|
/**
|
|
496
|
-
* Wraps a step observer into an `onStep` that cannot change the run
|
|
597
|
+
* Wraps a step observer into an `onStep` that cannot change the run: `op` always runs, its result is
|
|
598
|
+
* returned, its error propagates, and anything the observer throws is dropped.
|
|
497
599
|
*
|
|
498
600
|
* @param {(start: StepStart) => (end: StepEnd) => void} handler - Returns a finisher for the outcome
|
|
499
601
|
* @returns {StepRunner}
|
|
@@ -528,11 +630,10 @@ const observeSteps = (handler) => async (name, type, op, path) => {
|
|
|
528
630
|
/**
|
|
529
631
|
* Merges several configurations into one, so independent concerns can share the hooks.
|
|
530
632
|
*
|
|
531
|
-
* `onStep` and `onRun` are wrappers around an `op`, so they nest
|
|
532
|
-
*
|
|
533
|
-
*
|
|
534
|
-
*
|
|
535
|
-
* no config defines is left unset, so the caller of the merged wiring keeps its default for that slot.
|
|
633
|
+
* `onStep` and `onRun` are wrappers around an `op`, so they nest: the first config given is the
|
|
634
|
+
* outermost and the last sits closest to the Command, so a thrown Command unwinds from the last back
|
|
635
|
+
* to the first. `onBeforeCommand` is an observer, so every interceptor runs in the order given. A hook
|
|
636
|
+
* no config defines is left unset, so the caller keeps its default for that slot.
|
|
536
637
|
*
|
|
537
638
|
* @param {...(EffectConfiguration | undefined)} configs - Configurations to merge, outermost first
|
|
538
639
|
* @returns {EffectConfiguration}
|
|
@@ -569,17 +670,13 @@ const chainHooks = (...configs) => {
|
|
|
569
670
|
};
|
|
570
671
|
|
|
571
672
|
/**
|
|
572
|
-
* The Failure a `Parallel` branch
|
|
573
|
-
*
|
|
574
|
-
*
|
|
575
|
-
*
|
|
576
|
-
* failure that triggered the cancellation.
|
|
577
|
-
* @returns {Error}
|
|
673
|
+
* The Failure a cancelled `Parallel` branch stops with. Its error is named so it is never mistaken for
|
|
674
|
+
* the failure that triggered the cancellation.
|
|
675
|
+
* @param {any} initialInput
|
|
676
|
+
* @returns {FailureState}
|
|
578
677
|
*/
|
|
579
|
-
const
|
|
580
|
-
Object.assign(new Error('Parallel branch cancelled.'), {
|
|
581
|
-
name: 'ParallelCancelled'
|
|
582
|
-
});
|
|
678
|
+
const cancelledBranch = (initialInput) =>
|
|
679
|
+
Failure(Object.assign(new Error('Parallel branch cancelled.'), { name: 'ParallelCancelled' }), initialInput);
|
|
583
680
|
|
|
584
681
|
/**
|
|
585
682
|
* Which branch, if any, cancelled a Parallel. `branch: null` means an enclosing Parallel cancelled it.
|
|
@@ -588,25 +685,41 @@ const parallelCancelled = () =>
|
|
|
588
685
|
*/
|
|
589
686
|
|
|
590
687
|
/**
|
|
591
|
-
*
|
|
592
|
-
*
|
|
688
|
+
* What running a Parallel's branches produced: each branch's outcome in array order, the decision, and
|
|
689
|
+
* the first error a branch threw, which the Parallel rethrows once its step has returned the decision.
|
|
690
|
+
* @typedef {{
|
|
691
|
+
* results: (SuccessState | FailureState | IoFaultState)[],
|
|
692
|
+
* decision: ParallelDecision,
|
|
693
|
+
* thrown: { error: unknown } | undefined
|
|
694
|
+
* }} BranchRun
|
|
695
|
+
*/
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* @param {any} branch
|
|
699
|
+
* @param {number} count - How many branches the Parallel has
|
|
700
|
+
* @returns {boolean}
|
|
701
|
+
*/
|
|
702
|
+
const isBranchIndex = (branch, count) => Number.isInteger(branch) && branch >= 0 && branch < count;
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* Reads a recorded decision, or `undefined` for anything that is not one, which replays the Parallel
|
|
706
|
+
* under timing.
|
|
593
707
|
* @param {any} value
|
|
594
708
|
* @param {number} branches - How many branches the Parallel has, so a stale branch index is refused too
|
|
595
709
|
* @returns {ParallelDecision | undefined}
|
|
596
710
|
*/
|
|
597
711
|
const asDecision = (value, branches) => {
|
|
598
|
-
if (!value
|
|
712
|
+
if (!isObject(value)) return undefined;
|
|
599
713
|
if (value.cancelled === false) return { cancelled: false };
|
|
600
714
|
if (value.cancelled !== true) return undefined;
|
|
601
715
|
const { branch } = value;
|
|
602
716
|
if (branch === null) return { cancelled: true, branch: null };
|
|
603
|
-
return
|
|
717
|
+
return isBranchIndex(branch, branches) ? { cancelled: true, branch } : undefined;
|
|
604
718
|
};
|
|
605
719
|
|
|
606
720
|
/**
|
|
607
|
-
* Marks
|
|
608
|
-
*
|
|
609
|
-
* as a divergence. Only `fromTrace` raises it, and only where a recorded decision explains the gap.
|
|
721
|
+
* Marks a step a replay reached that production never ran, in a branch a recorded decision cancelled.
|
|
722
|
+
* The interpreter stops the branch there, as production did. Only `fromTrace` raises it.
|
|
610
723
|
*/
|
|
611
724
|
const replayCut = Symbol('pure-effect.replayCut');
|
|
612
725
|
|
|
@@ -622,10 +735,27 @@ const replayCutError = (path) =>
|
|
|
622
735
|
);
|
|
623
736
|
|
|
624
737
|
/**
|
|
625
|
-
*
|
|
626
|
-
*
|
|
627
|
-
*
|
|
628
|
-
*
|
|
738
|
+
* A cancellation scope for one Parallel, linked to the enclosing one so cancellation nests. Without an
|
|
739
|
+
* `AbortController` there is no scope, and every branch runs to completion.
|
|
740
|
+
* @param {AbortSignal} [signal] - The enclosing Parallel's signal, if any
|
|
741
|
+
* @returns {{ scope: AbortController | undefined, unlink: () => void }}
|
|
742
|
+
*/
|
|
743
|
+
const linkedScope = (signal) => {
|
|
744
|
+
const scope = typeof AbortController === 'function' ? new AbortController() : undefined;
|
|
745
|
+
const relay = () => scope?.abort();
|
|
746
|
+
if (signal && scope) {
|
|
747
|
+
if (signal.aborted) scope.abort();
|
|
748
|
+
else signal.addEventListener('abort', relay, { once: true });
|
|
749
|
+
}
|
|
750
|
+
const unlink = () => {
|
|
751
|
+
if (signal && scope) signal.removeEventListener('abort', relay);
|
|
752
|
+
};
|
|
753
|
+
return { scope, unlink };
|
|
754
|
+
};
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Awaits every task, with at most `limit` of them in flight. Workers pull by index, so results land where
|
|
758
|
+
* the caller put the effect rather than where it finished, which keeps trace paths stable.
|
|
629
759
|
* @param {(() => Promise<void>)[]} tasks
|
|
630
760
|
* @param {number} [limit]
|
|
631
761
|
* @returns {Promise<void>}
|
|
@@ -643,9 +773,8 @@ const runBounded = async (tasks, limit) => {
|
|
|
643
773
|
};
|
|
644
774
|
|
|
645
775
|
/**
|
|
646
|
-
* Sleeps, but gives up early when the surrounding branch is cancelled
|
|
647
|
-
*
|
|
648
|
-
* is what turns a cancelled wait into a Failure.
|
|
776
|
+
* Sleeps, but gives up early when the surrounding branch is cancelled. Resolves either way: the
|
|
777
|
+
* caller's abort check is what turns a cancelled wait into a Failure.
|
|
649
778
|
*
|
|
650
779
|
* @param {number} ms
|
|
651
780
|
* @param {AbortSignal} [signal]
|
|
@@ -657,9 +786,8 @@ const delayFor = (ms, signal) =>
|
|
|
657
786
|
setTimeout(resolve, ms);
|
|
658
787
|
return;
|
|
659
788
|
}
|
|
660
|
-
// A listener added to
|
|
661
|
-
//
|
|
662
|
-
// its backoff on the aborted signal.
|
|
789
|
+
// A listener added to an aborted signal never runs, and this is the usual case: a Command that
|
|
790
|
+
// honours the signal rejects when a sibling fails, and the Retry around it backs off on that signal.
|
|
663
791
|
if (signal.aborted) {
|
|
664
792
|
resolve();
|
|
665
793
|
return;
|
|
@@ -675,33 +803,27 @@ const delayFor = (ms, signal) =>
|
|
|
675
803
|
signal.addEventListener('abort', onAbort, { once: true });
|
|
676
804
|
});
|
|
677
805
|
|
|
678
|
-
const
|
|
806
|
+
const interpret =
|
|
679
807
|
/**
|
|
680
|
-
* The
|
|
681
|
-
*
|
|
682
|
-
*
|
|
683
|
-
*
|
|
684
|
-
* Per-call config is merged over the global configureEffect wiring, or over nothing under
|
|
685
|
-
* `inherit: false`. onRun fires exactly once per runEffect call. Retry attempts run inside that
|
|
686
|
-
* single span rather than spawning their own, keeping telemetry non-duplicated.
|
|
808
|
+
* The interpreter: walks the Effect tree, executing Commands and resolving Ask, Retry, and Parallel.
|
|
809
|
+
* `runEffect` and `replayEffect` both run flows through it, so a replay cannot drift from a run.
|
|
810
|
+
* `fastRetry` is the one thing only a replay sets: it waits no time between retry attempts, since a
|
|
811
|
+
* delay is written on the Retry node and nothing else can override it.
|
|
687
812
|
*
|
|
688
|
-
* @param {Effect} effect
|
|
689
|
-
* @param {any} [context]
|
|
690
|
-
* @param {CallConfiguration} [callConfig]
|
|
691
|
-
*
|
|
813
|
+
* @param {Effect} effect
|
|
814
|
+
* @param {any} [context]
|
|
815
|
+
* @param {CallConfiguration} [callConfig]
|
|
816
|
+
* @param {boolean} [fastRetry]
|
|
692
817
|
* @returns {Promise<SuccessState | FailureState>}
|
|
693
818
|
*/
|
|
694
|
-
async function
|
|
819
|
+
async function interpret(effect, context = {}, callConfig = {}, fastRetry = false) {
|
|
695
820
|
rejectRetryKey(callConfig, "runEffect's callConfig");
|
|
696
821
|
const { inherit = true, ...local } = callConfig;
|
|
697
|
-
//
|
|
698
|
-
// coerced: `'false'` inheriting everything is exactly the silent behaviour this option removes.
|
|
822
|
+
// Not coerced: `'false'` quietly inheriting everything is the behaviour this option exists to remove.
|
|
699
823
|
if (typeof inherit !== 'boolean') {
|
|
700
824
|
throw new TypeError(`callConfig.inherit must be true or false, got ${JSON.stringify(inherit)}.`);
|
|
701
825
|
}
|
|
702
|
-
//
|
|
703
|
-
// inherits nothing, by the same merge `configureEffect` applies to several configurations. Most
|
|
704
|
-
// calls carry no configuration of their own, and those use the installed wiring as it is.
|
|
826
|
+
// Merged the way `configureEffect` merges layers. A call with no hooks of its own skips the merge.
|
|
705
827
|
const base = inherit ? globalConfig : {};
|
|
706
828
|
const resolved = Object.keys(local).length ? chainHooks(base, local) : base;
|
|
707
829
|
const localStepRunner = resolved.onStep || defaultStepRunner;
|
|
@@ -709,325 +831,334 @@ const runEffect =
|
|
|
709
831
|
const localCommandInterceptor = resolved.onBeforeCommand || defaultCommandInterceptor;
|
|
710
832
|
|
|
711
833
|
/**
|
|
834
|
+
* Walks a subtree until it reaches a Success or a Failure. Every node but `Ask` runs in its own
|
|
835
|
+
* function, which returns a Success carrying the value for the node's `next`, or the Failure or I/O
|
|
836
|
+
* fault that stops the subtree.
|
|
837
|
+
*
|
|
712
838
|
* @param {Effect} eff
|
|
713
839
|
* @param {AbortSignal} [signal] - Cancellation for this subtree, set for `Parallel` branches.
|
|
714
|
-
*
|
|
715
|
-
* each
|
|
716
|
-
*
|
|
717
|
-
* Steps are numbered sequentially within a subtree and each `Parallel` branch and `Retry`
|
|
718
|
-
* attempt opens its own prefix, so a Command's full path depends only on the shape of the
|
|
719
|
-
* tree and not on the order branches happen to finish in. That is what lets a replay line
|
|
720
|
-
* a recorded step up with the step that asked for it.
|
|
840
|
+
* @param {string} [path] - Prefix for this subtree's position in the Effect tree. Steps are numbered
|
|
841
|
+
* within a subtree and each `Parallel` branch and `Retry` attempt opens its own prefix, so a
|
|
842
|
+
* path depends only on the tree's shape, never on the order branches finish in.
|
|
721
843
|
* @returns {Promise<SuccessState | FailureState | IoFaultState>}
|
|
722
844
|
*/
|
|
723
845
|
async function execute(eff, signal, path = '') {
|
|
724
846
|
let step = 0;
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
// ignore the signal: the one in flight cannot be stopped, but the next never starts.
|
|
731
|
-
if (signal?.aborted) return Failure(parallelCancelled(), eff.initialInput);
|
|
847
|
+
/** @type {Effect | undefined} The node whose `next` returned `eff`, which an error names. */
|
|
848
|
+
let from;
|
|
849
|
+
while (isPending(eff)) {
|
|
850
|
+
// Checked before every node: a Command already in flight cannot be stopped, but the next never starts.
|
|
851
|
+
if (signal?.aborted) return cancelledBranch(eff.initialInput);
|
|
732
852
|
if (eff.type === 'Ask') {
|
|
853
|
+
from = eff;
|
|
733
854
|
eff = eff.next(context);
|
|
734
855
|
continue;
|
|
735
856
|
}
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
signal,
|
|
792
|
-
`${retryPath}f/`
|
|
793
|
-
);
|
|
794
|
-
// A failing fallback propagates as-is: the flow's last word was the fallback's
|
|
795
|
-
// error, not the exhaustion it was already told about.
|
|
796
|
-
if (fallback.type !== 'Success') return fallback;
|
|
797
|
-
eff = retryNext(fallback.value);
|
|
798
|
-
}
|
|
799
|
-
continue;
|
|
857
|
+
const stepPath = `${path}${step++}`;
|
|
858
|
+
const outcome =
|
|
859
|
+
eff.type === 'Retry'
|
|
860
|
+
? await runRetry(eff, signal, stepPath)
|
|
861
|
+
: eff.type === 'Parallel'
|
|
862
|
+
? await runParallel(eff, signal, stepPath)
|
|
863
|
+
: await runCommand(eff, signal, stepPath);
|
|
864
|
+
if (outcome.type !== 'Success') return outcome;
|
|
865
|
+
// Outside every catch: `next` and the pure steps it reaches are code, not I/O, so a throw there
|
|
866
|
+
// rejects the run.
|
|
867
|
+
from = eff;
|
|
868
|
+
eff = eff.next(outcome.value);
|
|
869
|
+
}
|
|
870
|
+
if (isOutcome(eff)) return eff;
|
|
871
|
+
throw effectTypeError(eff, from ? nextOf(from) : 'The flow');
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* Runs a Retry's wrapped tree until it succeeds or runs out of attempts, then its fallback if it has
|
|
876
|
+
* one. Each attempt opens its own path prefix, and so does the fallback.
|
|
877
|
+
*
|
|
878
|
+
* @param {RetryState} retry
|
|
879
|
+
* @param {AbortSignal | undefined} signal
|
|
880
|
+
* @param {string} stepPath
|
|
881
|
+
* @returns {Promise<SuccessState | FailureState | IoFaultState>}
|
|
882
|
+
*/
|
|
883
|
+
async function runRetry(retry, signal, stepPath) {
|
|
884
|
+
// An option set to `undefined` keeps its default, since that is how an absent config key arrives.
|
|
885
|
+
const given = Object.entries(retry.options ?? {}).filter(([, value]) => value !== undefined);
|
|
886
|
+
const opts = /** @type {typeof defaultRetryOptions & RetryState['options']} */ ({
|
|
887
|
+
...defaultRetryOptions,
|
|
888
|
+
...Object.fromEntries(given)
|
|
889
|
+
});
|
|
890
|
+
const { attempts, onExhausted } = opts;
|
|
891
|
+
// `0` is refused rather than meaning run once: it would make `onExhausted` a free catch.
|
|
892
|
+
if (!isPositiveInteger(attempts))
|
|
893
|
+
throw new TypeError(
|
|
894
|
+
`Retry 'attempts' must be a positive integer, received ${describeValue(attempts)}. ` +
|
|
895
|
+
`To handle an outcome without retrying, branch on it as data in the Command's ` +
|
|
896
|
+
`next, or isolate a failing branch with Parallel's settled option.`
|
|
897
|
+
);
|
|
898
|
+
// A wait that is NaN, negative or infinite used to be no wait at all, so a flapping dependency was
|
|
899
|
+
// called back to back.
|
|
900
|
+
for (const key of /** @type {const} */ (['delay', 'backoff'])) {
|
|
901
|
+
if (!isFiniteNonNegative(opts[key]))
|
|
902
|
+
throw new TypeError(
|
|
903
|
+
`Retry '${key}' must be a finite number of 0 or more, received ${describeValue(opts[key])}.`
|
|
904
|
+
);
|
|
905
|
+
}
|
|
906
|
+
let lastError;
|
|
907
|
+
for (let attempt = 0; attempt <= attempts; attempt++) {
|
|
908
|
+
// Under `fastRetry` this waits for no time rather than skipping the wait, so branches replayed by
|
|
909
|
+
// timing still interleave as they do with a delay.
|
|
910
|
+
if (attempt > 0) {
|
|
911
|
+
await delayFor(fastRetry ? 0 : opts.delay * Math.pow(opts.backoff, attempt - 1), signal);
|
|
800
912
|
}
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
`Time paradox at path '${branchPath}': the recorded run was cancelled by ` +
|
|
919
|
-
`branch ${forced.branch}, which did not fail in this replay.`,
|
|
920
|
-
{ name: 'TimeParadox', path: branchPath, branch: forced.branch }
|
|
921
|
-
);
|
|
922
|
-
}
|
|
923
|
-
decision = forced;
|
|
924
|
-
return decision;
|
|
925
|
-
}
|
|
926
|
-
const trigger = triggered.indexOf(true);
|
|
927
|
-
decision =
|
|
928
|
-
trigger >= 0
|
|
929
|
-
? { cancelled: true, branch: trigger }
|
|
930
|
-
: branchSignal?.aborted
|
|
931
|
-
? { cancelled: true, branch: null }
|
|
932
|
-
: { cancelled: false };
|
|
933
|
-
return decision;
|
|
934
|
-
};
|
|
935
|
-
|
|
936
|
-
try {
|
|
937
|
-
await localStepRunner('Parallel', 'Parallel', runBranches, branchPath);
|
|
938
|
-
} catch (e) {
|
|
939
|
-
// A replay found this Parallel inside a branch production had already stopped.
|
|
940
|
-
if (e && /** @type {any} */ (e)[replayCut]) return Failure(parallelCancelled(), parallelInput);
|
|
941
|
-
throw e;
|
|
942
|
-
}
|
|
943
|
-
if (!ran) {
|
|
944
|
-
throw new TypeError(
|
|
945
|
-
`An onStep hook returned without calling op for the Parallel at path '${branchPath}'. ` +
|
|
946
|
-
'A hook has to call op for a Parallel, because op runs its branches.'
|
|
947
|
-
);
|
|
948
|
-
}
|
|
949
|
-
// Rethrown here, after the step has returned its decision, so the decision is recorded.
|
|
950
|
-
if (branchThrow) throw branchThrow.error;
|
|
951
|
-
|
|
952
|
-
// Settled hands the outcomes on as plain Success and Failure nodes. A branch that
|
|
953
|
-
// failed is data here, so `next` runs and the Parallel itself never fails on a
|
|
954
|
-
// branch's account; and a Failure `next` returns is an abort like any other.
|
|
955
|
-
if (settled) {
|
|
956
|
-
eff = eff.next(results.map(asOutcome));
|
|
957
|
-
continue;
|
|
958
|
-
}
|
|
959
|
-
|
|
960
|
-
const failure =
|
|
961
|
-
decision.cancelled && decision.branch !== null
|
|
962
|
-
? results[decision.branch]
|
|
963
|
-
: results.find((r) => r.type !== 'Success');
|
|
964
|
-
if (failure) return failure;
|
|
965
|
-
eff = eff.next(results.map((r) => /** @type {SuccessState} */ (r).value));
|
|
966
|
-
continue;
|
|
913
|
+
// After the wait, so a branch cancelled mid-backoff makes no further attempt.
|
|
914
|
+
if (signal?.aborted) return cancelledBranch(retry.initialInput);
|
|
915
|
+
const result = await execute(retry.effect, signal, `${stepPath}r${attempt}/`);
|
|
916
|
+
// A Success feeds `next`. An abort is the flow deciding, not the I/O failing: not retried, not
|
|
917
|
+
// wrapped.
|
|
918
|
+
if (result.type !== 'IoFault') return result;
|
|
919
|
+
lastError = result.error;
|
|
920
|
+
}
|
|
921
|
+
const exhausted = { retryExhausted: true, lastError, attempts };
|
|
922
|
+
// An exhaustion is a fault too, which keeps an enclosing Retry retrying this one.
|
|
923
|
+
if (typeof onExhausted !== 'function') return IoFault(exhausted, retry.initialInput);
|
|
924
|
+
// A cancelled branch starts no fallback, as it starts no further Commands.
|
|
925
|
+
if (signal?.aborted) return cancelledBranch(retry.initialInput);
|
|
926
|
+
// A failing fallback propagates as it is, not wrapped as another exhaustion.
|
|
927
|
+
return execute(asEffect(onExhausted(exhausted), "Retry option 'onExhausted'"), signal, `${stepPath}rf/`);
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/**
|
|
931
|
+
* Runs a Parallel as one step of its own, whose `op` runs the branches and returns the decision, so
|
|
932
|
+
* the decision is recorded and a replay can hand it back.
|
|
933
|
+
*
|
|
934
|
+
* @param {ParallelState} parallel
|
|
935
|
+
* @param {AbortSignal | undefined} signal
|
|
936
|
+
* @param {string} stepPath
|
|
937
|
+
* @returns {Promise<SuccessState | FailureState | IoFaultState>}
|
|
938
|
+
*/
|
|
939
|
+
async function runParallel(parallel, signal, stepPath) {
|
|
940
|
+
// Branch prefixes come from array index, never completion order, so two branches calling the same
|
|
941
|
+
// Command are told apart by position.
|
|
942
|
+
const branchPath = `${stepPath}p`;
|
|
943
|
+
const options = parallel.options ?? {};
|
|
944
|
+
const { limit, settled } = options;
|
|
945
|
+
if (limit !== undefined && !isPositiveInteger(limit))
|
|
946
|
+
throw new TypeError(`Parallel 'limit' must be a positive integer, received ${describeValue(limit)}.`);
|
|
947
|
+
// Cast rather than annotated, since only `op` assigns it.
|
|
948
|
+
let run = /** @type {BranchRun | undefined} */ (undefined);
|
|
949
|
+
const op = async (/** @type {any} */ recorded) => {
|
|
950
|
+
run = await runBranches(parallel.effects, options, signal, branchPath, recorded);
|
|
951
|
+
return run.decision;
|
|
952
|
+
};
|
|
953
|
+
try {
|
|
954
|
+
await localStepRunner('Parallel', 'Parallel', op, branchPath);
|
|
955
|
+
} catch (e) {
|
|
956
|
+
// A replay found this Parallel inside a branch production had already stopped.
|
|
957
|
+
if (hasMark(e, replayCut)) return cancelledBranch(parallel.initialInput);
|
|
958
|
+
throw e;
|
|
959
|
+
}
|
|
960
|
+
if (!run) {
|
|
961
|
+
throw new TypeError(
|
|
962
|
+
`An onStep hook returned without letting op run the Parallel at path '${branchPath}'. ` +
|
|
963
|
+
'A hook has to call op for a Parallel and pass on what it returns or throws, because op ' +
|
|
964
|
+
'runs its branches.'
|
|
965
|
+
);
|
|
966
|
+
}
|
|
967
|
+
const { results, decision, thrown } = run;
|
|
968
|
+
// Rethrown here, after the step has returned its decision, so the decision is recorded.
|
|
969
|
+
if (thrown) throw thrown.error;
|
|
970
|
+
// Settled hands `next` the outcomes as plain Success and Failure nodes, so the Parallel never fails
|
|
971
|
+
// on a branch's account.
|
|
972
|
+
if (settled) return Success(results.map(asOutcome));
|
|
973
|
+
const failure =
|
|
974
|
+
decision.cancelled && decision.branch !== null
|
|
975
|
+
? results[decision.branch]
|
|
976
|
+
: results.find((r) => r.type !== 'Success');
|
|
977
|
+
if (failure) return failure;
|
|
978
|
+
return Success(results.map((r) => /** @type {SuccessState} */ (r).value));
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/**
|
|
982
|
+
* Runs a Parallel's branches and decides which branch, if any, cancelled the others. In a live run
|
|
983
|
+
* timing decides it, which is why the decision is recorded. A recorded cancellation passed in by a
|
|
984
|
+
* replay is reproduced rather than recomputed: no branch cancels another, each runs its recorded steps
|
|
985
|
+
* and stops where its recording stops, and the recorded branch's failure is the result.
|
|
986
|
+
*
|
|
987
|
+
* @param {Effect[]} effects
|
|
988
|
+
* @param {ParallelOptions} options
|
|
989
|
+
* @param {AbortSignal | undefined} signal - The enclosing Parallel's cancellation, if any
|
|
990
|
+
* @param {string} branchPath
|
|
991
|
+
* @param {any} recorded - The recorded decision, when a replay supplies one
|
|
992
|
+
* @returns {Promise<BranchRun>}
|
|
993
|
+
*/
|
|
994
|
+
async function runBranches(effects, options, signal, branchPath, recorded) {
|
|
995
|
+
const { limit, settled } = options;
|
|
996
|
+
// A recorded branch past the end means the flow changed shape since the recording. A negative one is
|
|
997
|
+
// not a decision, so it replays by timing like anything else.
|
|
998
|
+
const recordedBranch = recorded?.cancelled === true ? recorded.branch : undefined;
|
|
999
|
+
const pastTheEnd = Number.isInteger(recordedBranch) && recordedBranch >= effects.length;
|
|
1000
|
+
if (pastTheEnd) {
|
|
1001
|
+
const count = effects.length === 1 ? '1 branch' : `${effects.length} branches`;
|
|
1002
|
+
throw replayError(
|
|
1003
|
+
`Time paradox at path '${branchPath}': the recorded run was cancelled by branch ` +
|
|
1004
|
+
`${recordedBranch}, but this Parallel has ${count}.`,
|
|
1005
|
+
{ name: 'TimeParadox', path: branchPath, branch: recordedBranch }
|
|
1006
|
+
);
|
|
1007
|
+
}
|
|
1008
|
+
const forced = asDecision(recorded, effects.length);
|
|
1009
|
+
const reproducing = forced !== undefined && forced.cancelled;
|
|
1010
|
+
// Live, a throw cancels the others, and so does a failure unless `settled`; the run rejects on a throw
|
|
1011
|
+
// either way, and an enclosing Parallel can still cancel this one. Reproducing, nothing cancels, since
|
|
1012
|
+
// the recording already says where each branch stops.
|
|
1013
|
+
const { results, thrown, trigger, cancelled } = await settleBranches(
|
|
1014
|
+
effects,
|
|
1015
|
+
limit,
|
|
1016
|
+
signal,
|
|
1017
|
+
branchPath,
|
|
1018
|
+
reproducing ? () => false : (threw, result) => threw || (!settled && result.type !== 'Success')
|
|
1019
|
+
);
|
|
1020
|
+
if (reproducing) {
|
|
1021
|
+
// A branch that threw has no result to check, and its throw is what is rethrown.
|
|
1022
|
+
const recordedTriggerSucceeded =
|
|
1023
|
+
!thrown && forced.branch !== null && results[forced.branch].type === 'Success';
|
|
1024
|
+
if (recordedTriggerSucceeded) {
|
|
1025
|
+
throw replayError(
|
|
1026
|
+
`Time paradox at path '${branchPath}': the recorded run was cancelled by ` +
|
|
1027
|
+
`branch ${forced.branch}, which did not fail in this replay.`,
|
|
1028
|
+
{ name: 'TimeParadox', path: branchPath, branch: forced.branch }
|
|
1029
|
+
);
|
|
967
1030
|
}
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
1031
|
+
return { results, decision: forced, thrown };
|
|
1032
|
+
}
|
|
1033
|
+
/** @type {ParallelDecision} */
|
|
1034
|
+
const decision =
|
|
1035
|
+
trigger >= 0
|
|
1036
|
+
? { cancelled: true, branch: trigger }
|
|
1037
|
+
: cancelled
|
|
1038
|
+
? { cancelled: true, branch: null }
|
|
1039
|
+
: { cancelled: false };
|
|
1040
|
+
return { results, decision, thrown };
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/**
|
|
1044
|
+
* Runs every branch to completion, at most `limit` at once, under one cancellation scope linked to the
|
|
1045
|
+
* enclosing one. A branch that settles in a way `cancelsOthers` accepts cancels the rest, and the first
|
|
1046
|
+
* to do so is the trigger.
|
|
1047
|
+
*
|
|
1048
|
+
* @param {Effect[]} effects
|
|
1049
|
+
* @param {number | undefined} limit
|
|
1050
|
+
* @param {AbortSignal | undefined} signal
|
|
1051
|
+
* @param {string} branchPath
|
|
1052
|
+
* @param {(threw: boolean, result: SuccessState | FailureState | IoFaultState) => boolean} cancelsOthers
|
|
1053
|
+
* @returns {Promise<{
|
|
1054
|
+
* results: (SuccessState | FailureState | IoFaultState)[],
|
|
1055
|
+
* thrown: { error: unknown } | undefined,
|
|
1056
|
+
* trigger: number,
|
|
1057
|
+
* cancelled: boolean
|
|
1058
|
+
* }>}
|
|
1059
|
+
*/
|
|
1060
|
+
async function settleBranches(effects, limit, signal, branchPath, cancelsOthers) {
|
|
1061
|
+
const { scope, unlink } = linkedScope(signal);
|
|
1062
|
+
/** @type {(SuccessState | FailureState | IoFaultState)[]} */
|
|
1063
|
+
const results = new Array(effects.length);
|
|
1064
|
+
// Held rather than rethrown, so every branch still settles and no `limit` worker stops early.
|
|
1065
|
+
/** @type {{ error: unknown }[]} */
|
|
1066
|
+
const thrown = new Array(effects.length);
|
|
1067
|
+
// Which branch cancelled the others on its own account rather than because it was cancelled.
|
|
1068
|
+
const triggered = new Array(effects.length).fill(false);
|
|
1069
|
+
const settle = async (/** @type {Effect} */ branch, /** @type {number} */ i) => {
|
|
992
1070
|
try {
|
|
993
|
-
await
|
|
994
|
-
} catch (
|
|
995
|
-
|
|
996
|
-
return Failure(e, initialInput);
|
|
1071
|
+
results[i] = await execute(branch, scope?.signal, `${branchPath}${i}/`);
|
|
1072
|
+
} catch (error) {
|
|
1073
|
+
thrown[i] = { error };
|
|
997
1074
|
}
|
|
998
|
-
//
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
try {
|
|
1003
|
-
result = await localStepRunner(cmdName, 'Command', op, cmdPath);
|
|
1004
|
-
} catch (e) {
|
|
1005
|
-
// A replay reached a step production never ran, in a branch a Parallel had cancelled:
|
|
1006
|
-
// this is where production stopped the branch, so the replay stops it here too.
|
|
1007
|
-
if (e && /** @type {any} */ (e)[replayCut]) return Failure(parallelCancelled(), initialInput);
|
|
1008
|
-
if (e && /** @type {any} */ (e)[harnessError]) throw e;
|
|
1009
|
-
// The function returned and a hook threw afterwards: a bug in the hook, rejected like a
|
|
1010
|
-
// throw from `next`. A hook that throws without calling `op` is still a fault, since that
|
|
1011
|
-
// is how replay reports a recorded error.
|
|
1012
|
-
if (succeeded) throw e;
|
|
1013
|
-
return IoFault(e, initialInput);
|
|
1075
|
+
// Read-then-abort is atomic here, so exactly one branch is the trigger.
|
|
1076
|
+
if (cancelsOthers(thrown[i] !== undefined, results[i])) {
|
|
1077
|
+
if (!scope?.signal.aborted) triggered[i] = true;
|
|
1078
|
+
scope?.abort();
|
|
1014
1079
|
}
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
//
|
|
1018
|
-
|
|
1019
|
-
|
|
1080
|
+
};
|
|
1081
|
+
try {
|
|
1082
|
+
// Awaits every branch, so no cancelled work runs on unobserved after the Parallel returns.
|
|
1083
|
+
await runBounded(
|
|
1084
|
+
effects.map((branch, i) => () => settle(branch, i)),
|
|
1085
|
+
limit
|
|
1086
|
+
);
|
|
1087
|
+
} finally {
|
|
1088
|
+
unlink();
|
|
1020
1089
|
}
|
|
1021
|
-
|
|
1022
|
-
|
|
1090
|
+
return {
|
|
1091
|
+
results,
|
|
1092
|
+
// The first by array order, since every branch has settled by now.
|
|
1093
|
+
thrown: thrown.find(Boolean),
|
|
1094
|
+
trigger: triggered.indexOf(true),
|
|
1095
|
+
cancelled: Boolean(scope?.signal.aborted)
|
|
1096
|
+
};
|
|
1023
1097
|
}
|
|
1024
1098
|
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1099
|
+
/**
|
|
1100
|
+
* Runs one Command. A throw means something different in each region: an interceptor throwing vetoes
|
|
1101
|
+
* the Command, which is an abort, and the Command's function throwing is an I/O fault. A harness error
|
|
1102
|
+
* is rethrown from either.
|
|
1103
|
+
*
|
|
1104
|
+
* @param {CommandState} command
|
|
1105
|
+
* @param {AbortSignal | undefined} signal
|
|
1106
|
+
* @param {string} cmdPath
|
|
1107
|
+
* @returns {Promise<SuccessState | FailureState | IoFaultState>}
|
|
1108
|
+
*/
|
|
1109
|
+
async function runCommand(command, signal, cmdPath) {
|
|
1110
|
+
const cmdName = commandName(command);
|
|
1111
|
+
const { initialInput, cmd } = command;
|
|
1112
|
+
// Whether the function itself succeeded: a hook throwing after it did is a bug, and retrying would
|
|
1113
|
+
// repeat work already done.
|
|
1114
|
+
let succeeded = false;
|
|
1115
|
+
// What it returned, so a hook that loses it is caught.
|
|
1116
|
+
/** @type {unknown} */
|
|
1117
|
+
let value;
|
|
1118
|
+
// The signal is passed only inside a Parallel. There it is still the first argument, so a function
|
|
1119
|
+
// passed by name with an optional first parameter, `nanoid(size = 21)` say, takes the signal for it:
|
|
1120
|
+
// a documented sharp edge. Async, so a hook always gets a promise, even from a synchronous function.
|
|
1121
|
+
const op = async () => {
|
|
1122
|
+
succeeded = false;
|
|
1123
|
+
value = await (signal ? cmd(signal) : cmd());
|
|
1124
|
+
succeeded = true;
|
|
1125
|
+
return value;
|
|
1126
|
+
};
|
|
1127
|
+
try {
|
|
1128
|
+
await localCommandInterceptor(command, context);
|
|
1129
|
+
} catch (e) {
|
|
1130
|
+
if (hasMark(e, harnessError)) throw e;
|
|
1131
|
+
return Failure(e, initialInput);
|
|
1132
|
+
}
|
|
1133
|
+
// Again, since an interceptor can wait (a rate limiter, say) while a sibling fails.
|
|
1134
|
+
if (signal?.aborted) return cancelledBranch(initialInput);
|
|
1135
|
+
let returned;
|
|
1136
|
+
try {
|
|
1137
|
+
returned = await localStepRunner(cmdName, 'Command', op, cmdPath);
|
|
1138
|
+
} catch (e) {
|
|
1139
|
+
// A step production never ran, in a branch a Parallel cancelled: stop here, as production did.
|
|
1140
|
+
if (hasMark(e, replayCut)) return cancelledBranch(initialInput);
|
|
1141
|
+
if (hasMark(e, harnessError)) throw e;
|
|
1142
|
+
// A hook threw after the function returned, which is a bug in the hook. A hook that throws
|
|
1143
|
+
// without calling `op` is still a fault, since that is how replay reports a recorded error.
|
|
1144
|
+
if (succeeded) throw e;
|
|
1145
|
+
return IoFault(e, initialInput);
|
|
1146
|
+
}
|
|
1147
|
+
// A hook that awaited `op` and forgot to return its result: `next` would take the branch for a Command
|
|
1148
|
+
// that returned nothing, while a recorder inside the hook kept the real value. Only `undefined` is
|
|
1149
|
+
// refused, so a hook that returns a copy of the result still works.
|
|
1150
|
+
if (returned === undefined && succeeded && value !== undefined) {
|
|
1151
|
+
throw new TypeError(
|
|
1152
|
+
`An onStep hook called op for '${cmdName}' at path '${cmdPath}' and returned undefined, although ` +
|
|
1153
|
+
'the Command returned a value. A hook has to return what op returns.'
|
|
1154
|
+
);
|
|
1155
|
+
}
|
|
1156
|
+
return Success(returned);
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
// Every outcome leaves through here, so this is where a Failure gets the root's input: one from a
|
|
1160
|
+
// Parallel branch or a Retry fallback carries its own subtree's input, which `chain` never reaches.
|
|
1161
|
+
// An I/O fault becomes a plain Failure here too.
|
|
1031
1162
|
const rootInput = effect?.initialInput;
|
|
1032
1163
|
const run = async () => {
|
|
1033
1164
|
const result = asOutcome(await execute(effect));
|
|
@@ -1036,6 +1167,18 @@ const runEffect =
|
|
|
1036
1167
|
return localRunWrapper(effect, run, context?.flowName || '');
|
|
1037
1168
|
};
|
|
1038
1169
|
|
|
1170
|
+
/**
|
|
1171
|
+
* Runs a flow: executes its Commands, resolving `Ask` with the context and running `Retry` and `Parallel`.
|
|
1172
|
+
* onRun fires exactly once per call; Retry attempts run inside that single span.
|
|
1173
|
+
*
|
|
1174
|
+
* @param {Effect} effect - The Effect tree returned by a pipeline
|
|
1175
|
+
* @param {any} [context] - Optional context object. Passed to Ask continuations and the Command Interceptor.
|
|
1176
|
+
* @param {CallConfiguration} [callConfig] - Per-call configuration, added to the wiring `configureEffect`
|
|
1177
|
+
* installed unless `inherit: false`, which ignores that wiring for this run.
|
|
1178
|
+
* @returns {Promise<SuccessState | FailureState>}
|
|
1179
|
+
*/
|
|
1180
|
+
const runEffect = (effect, context, callConfig) => interpret(effect, context, callConfig);
|
|
1181
|
+
|
|
1039
1182
|
/**
|
|
1040
1183
|
* The step a replay is asking about. `path` is the Command's position in the Effect tree and is stable
|
|
1041
1184
|
* across runs; `index` is its position in this run's completion order, which is not stable for a flow
|
|
@@ -1057,11 +1200,8 @@ const runEffect =
|
|
|
1057
1200
|
|
|
1058
1201
|
/**
|
|
1059
1202
|
* A recorded step: a Command's result or error, or a Parallel's decision, recorded as `command`
|
|
1060
|
-
* 'Parallel' at the Parallel's own path with the decision as its `result`.
|
|
1061
|
-
*
|
|
1062
|
-
* which is the one question a trace could not answer before: which step was slow.
|
|
1063
|
-
* `path` locates the Command in the Effect tree, which is what a replay matches on: it does not move
|
|
1064
|
-
* when `Parallel` branches finish in a different order than they did in production.
|
|
1203
|
+
* 'Parallel' at the Parallel's own path with the decision as its `result`. `durationMs` is how long
|
|
1204
|
+
* the step took in production, rounded to microseconds. `path` is what a replay matches on.
|
|
1065
1205
|
* `threw` marks a step that threw. The `error` key alone cannot, since JSON drops it when the value is
|
|
1066
1206
|
* `undefined`, as it is for `reject()` with no argument or an error `redact` removed.
|
|
1067
1207
|
* @typedef {{ command: string, path?: string, result?: any, threw?: true, error?: any, durationMs?: number }} TraceEntry
|
|
@@ -1081,17 +1221,15 @@ const runEffect =
|
|
|
1081
1221
|
*/
|
|
1082
1222
|
|
|
1083
1223
|
/**
|
|
1084
|
-
* A replay fault: the trace cannot answer the flow, or disagrees with it.
|
|
1085
|
-
*
|
|
1086
|
-
*
|
|
1087
|
-
*
|
|
1088
|
-
* flow is a bug in the flow rather than a problem with the trace.
|
|
1224
|
+
* A replay fault: the trace cannot answer the flow, or disagrees with it. A harness error while the flow
|
|
1225
|
+
* runs, so nothing inside the flow can swallow it; `replayEffect` turns it into a `Failure` at its own
|
|
1226
|
+
* boundary. An `EffectTypeError` carries no replay mark and keeps propagating, since a malformed flow is
|
|
1227
|
+
* a bug in the flow rather than a problem with the trace.
|
|
1089
1228
|
*/
|
|
1090
1229
|
const replayFault = Symbol('pure-effect.replayFault');
|
|
1091
1230
|
|
|
1092
1231
|
/**
|
|
1093
|
-
* Creates a replay error
|
|
1094
|
-
* into a Failure, and `replayEffect` turns it into one at its own boundary.
|
|
1232
|
+
* Creates a replay error, which `replayEffect` turns into a Failure at its own boundary.
|
|
1095
1233
|
* @param {string} message - Human-readable reason
|
|
1096
1234
|
* @param {Object} [props] - Extra fields such as `name`, `index`, `expected`, `actual`
|
|
1097
1235
|
* @returns {Error}
|
|
@@ -1131,27 +1269,41 @@ const timeParadox = (step, recorded) =>
|
|
|
1131
1269
|
*
|
|
1132
1270
|
* @param {any} e - The thrown value
|
|
1133
1271
|
* @param {boolean} [withStack] - Include the stack (off by default: noisy, leaks paths)
|
|
1272
|
+
* @param {Set<Error>} [ancestors] - The errors whose `cause` or `errors` led here
|
|
1134
1273
|
* @returns {any}
|
|
1135
1274
|
*/
|
|
1136
|
-
const serializeError = (e, withStack) => {
|
|
1275
|
+
const serializeError = (e, withStack, ancestors = new Set()) => {
|
|
1137
1276
|
if (!(e instanceof Error)) return e;
|
|
1138
1277
|
/** @type {any} */
|
|
1139
1278
|
const out = { __error: true, name: e.name, message: e.message };
|
|
1279
|
+
// A chain that comes back to an error it passed through is cut there, with the name and message only:
|
|
1280
|
+
// following it overflowed the stack, and the recorder dropped the whole step. Only ancestors count, so an
|
|
1281
|
+
// error that merely appears twice is carried in full both times.
|
|
1282
|
+
if (ancestors.has(e)) return out;
|
|
1283
|
+
ancestors.add(e);
|
|
1140
1284
|
if (withStack) out.stack = e.stack;
|
|
1141
1285
|
// `cause` is non-enumerable too, and it can itself be an Error, so it is carried recursively.
|
|
1142
|
-
if ('cause' in e) out.cause = serializeError(e.cause, withStack);
|
|
1143
|
-
// So is an AggregateError's `errors`,
|
|
1144
|
-
//
|
|
1145
|
-
// that revival can tell it from an enumerable `errors`, which a validation error carries as data and
|
|
1146
|
-
// the loop below copies as it is.
|
|
1286
|
+
if ('cause' in e) out.cause = serializeError(e.cause, withStack, ancestors);
|
|
1287
|
+
// So is an AggregateError's `errors`, which is where its detail lives. It goes under its own key so
|
|
1288
|
+
// revival can tell it from an enumerable `errors`, which is data and is copied below as it is.
|
|
1147
1289
|
const errors = /** @type {any} */ (e).errors;
|
|
1148
|
-
|
|
1149
|
-
|
|
1290
|
+
const hasHiddenErrors = Array.isArray(errors) && !Object.prototype.propertyIsEnumerable.call(e, 'errors');
|
|
1291
|
+
if (hasHiddenErrors) {
|
|
1292
|
+
out.__errors = errors.map((x) => serializeError(x, withStack, ancestors));
|
|
1150
1293
|
}
|
|
1151
|
-
|
|
1294
|
+
ancestors.delete(e);
|
|
1295
|
+
// Assigning `name`, `message` or `cause`, as `e.name = 'TimeoutError'` or `e.cause = inner` does, makes it
|
|
1296
|
+
// enumerable. It is carried above already, so it is listed for revival to restore as it was rather than
|
|
1297
|
+
// copied again, where a raw cause would replace the serialized one and reach JSON as {}.
|
|
1298
|
+
const shown = carriedKeys.filter((k) => Object.prototype.propertyIsEnumerable.call(e, k));
|
|
1299
|
+
if (shown.length > 0) out.__enumerable = shown;
|
|
1300
|
+
for (const k of Object.keys(e)) if (!carriedKeys.includes(k)) out[k] = /** @type {any} */ (e)[k];
|
|
1152
1301
|
return out;
|
|
1153
1302
|
};
|
|
1154
1303
|
|
|
1304
|
+
/** The keys `serializeError` carries whatever their enumerability, which a native Error sets as hidden. */
|
|
1305
|
+
const carriedKeys = ['name', 'message', 'cause'];
|
|
1306
|
+
|
|
1155
1307
|
/**
|
|
1156
1308
|
* Rebuilds an Error from `serializeError` output. Non-Error values pass through, so a
|
|
1157
1309
|
* Command that rejected with a string still replays as a string.
|
|
@@ -1159,21 +1311,23 @@ const serializeError = (e, withStack) => {
|
|
|
1159
1311
|
* @returns {any}
|
|
1160
1312
|
*/
|
|
1161
1313
|
const reviveError = (v) => {
|
|
1162
|
-
if (!v ||
|
|
1314
|
+
if (!isObject(v) || v.__error !== true) return v;
|
|
1163
1315
|
const e = new Error(v.message);
|
|
1164
|
-
// `name` and `
|
|
1165
|
-
//
|
|
1166
|
-
//
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1316
|
+
// `name`, `message`, `cause` and `errors` are defined as the original had them, so a revived error
|
|
1317
|
+
// deep-equals the one the Command threw: non-enumerable, as on a native Error, unless `__enumerable` lists
|
|
1318
|
+
// them. Every other key was enumerable on the original.
|
|
1319
|
+
const shown = Array.isArray(v.__enumerable) ? v.__enumerable : [];
|
|
1320
|
+
const restore = (/** @type {string} */ key, /** @type {any} */ value) =>
|
|
1321
|
+
Object.defineProperty(e, key, { enumerable: shown.includes(key), configurable: true, writable: true, value });
|
|
1322
|
+
restore('name', v.name);
|
|
1323
|
+
if (shown.includes('message')) restore('message', v.message);
|
|
1324
|
+
// The markers are dropped, and `name` and `message` are already set.
|
|
1325
|
+
const alreadyHandled = ['__error', '__enumerable', 'name', 'message'];
|
|
1171
1326
|
for (const [k, val] of Object.entries(v)) {
|
|
1172
|
-
if (k
|
|
1173
|
-
if (k === 'cause')
|
|
1174
|
-
else if (k === '__errors' && Array.isArray(val))
|
|
1175
|
-
|
|
1176
|
-
} else /** @type {any} */ (e)[k] = val;
|
|
1327
|
+
if (alreadyHandled.includes(k)) continue;
|
|
1328
|
+
if (k === 'cause') restore('cause', reviveError(val));
|
|
1329
|
+
else if (k === '__errors' && Array.isArray(val)) restore('errors', val.map(reviveError));
|
|
1330
|
+
else /** @type {any} */ (e)[k] = val;
|
|
1177
1331
|
}
|
|
1178
1332
|
return e;
|
|
1179
1333
|
};
|
|
@@ -1198,21 +1352,31 @@ const reviveError = (v) => {
|
|
|
1198
1352
|
* @property {string} [version] - Commit or build id, so a replay can detect a stale trace.
|
|
1199
1353
|
*/
|
|
1200
1354
|
|
|
1355
|
+
/**
|
|
1356
|
+
* An array, a plain object, or an object with no prototype: the shapes `copyAround` rebuilds itself rather
|
|
1357
|
+
* than handing to `structuredClone`.
|
|
1358
|
+
* @param {object} value
|
|
1359
|
+
* @returns {boolean}
|
|
1360
|
+
*/
|
|
1361
|
+
const isPlainContainer = (value) => {
|
|
1362
|
+
if (Array.isArray(value)) return true;
|
|
1363
|
+
const proto = Object.getPrototypeOf(value);
|
|
1364
|
+
return proto === Object.prototype || proto === null;
|
|
1365
|
+
};
|
|
1366
|
+
|
|
1201
1367
|
/**
|
|
1202
1368
|
* Copies what `structuredClone` refused. Arrays and plain objects are rebuilt and everything inside them is
|
|
1203
1369
|
* copied in turn, so only the parts that cannot be copied, such as a function or an object holding one, are
|
|
1204
|
-
* kept as they are. A context holding a logger is the usual case
|
|
1205
|
-
* handed `redact` the live object the flow's `Ask` reads.
|
|
1370
|
+
* kept as they are. A context holding a logger is the usual case.
|
|
1206
1371
|
*
|
|
1207
1372
|
* @param {any} value
|
|
1208
1373
|
* @param {Map<object, any>} seen - Copies made so far, so a cycle is copied as a cycle
|
|
1209
1374
|
* @returns {any}
|
|
1210
1375
|
*/
|
|
1211
1376
|
const copyAround = (value, seen) => {
|
|
1212
|
-
if (value
|
|
1377
|
+
if (!isObject(value)) return value;
|
|
1213
1378
|
if (seen.has(value)) return seen.get(value);
|
|
1214
|
-
|
|
1215
|
-
if (!Array.isArray(value) && proto !== Object.prototype && proto !== null) {
|
|
1379
|
+
if (!isPlainContainer(value)) {
|
|
1216
1380
|
try {
|
|
1217
1381
|
return structuredClone(value);
|
|
1218
1382
|
} catch {
|
|
@@ -1220,26 +1384,23 @@ const copyAround = (value, seen) => {
|
|
|
1220
1384
|
}
|
|
1221
1385
|
}
|
|
1222
1386
|
/** @type {any} */
|
|
1223
|
-
const copy = Array.isArray(value) ? [] : Object.create(
|
|
1387
|
+
const copy = Array.isArray(value) ? [] : Object.create(Object.getPrototypeOf(value));
|
|
1224
1388
|
seen.set(value, copy);
|
|
1225
1389
|
for (const key of Object.keys(value)) copy[key] = copyAround(value[key], seen);
|
|
1226
1390
|
return copy;
|
|
1227
1391
|
};
|
|
1228
1392
|
|
|
1229
1393
|
/**
|
|
1230
|
-
* Snapshots a value on its way into a trace, and on its way out of one in a replay
|
|
1231
|
-
*
|
|
1232
|
-
*
|
|
1233
|
-
*
|
|
1234
|
-
* time the mutation is already baked in and the trace records a value production never saw. The copy
|
|
1235
|
-
* is also what `redact` is handed, so it has to be one the flow never sees. A value that cannot be
|
|
1236
|
-
* cloned whole is copied around the parts that cannot be copied.
|
|
1394
|
+
* Snapshots a value on its way into a trace, and on its way out of one in a replay, so a later mutation
|
|
1395
|
+
* cannot rewrite what the trace says a step returned. The copy is also what `redact` is handed, so it has
|
|
1396
|
+
* to be one the flow never sees. A value that cannot be cloned whole is copied around the parts that
|
|
1397
|
+
* cannot be copied.
|
|
1237
1398
|
*
|
|
1238
1399
|
* @param {any} value
|
|
1239
1400
|
* @returns {any}
|
|
1240
1401
|
*/
|
|
1241
1402
|
const snapshot = (value) => {
|
|
1242
|
-
if (value
|
|
1403
|
+
if (!isObject(value)) return value;
|
|
1243
1404
|
try {
|
|
1244
1405
|
return typeof structuredClone === 'function' ? structuredClone(value) : JSON.parse(JSON.stringify(value));
|
|
1245
1406
|
} catch {
|
|
@@ -1249,8 +1410,9 @@ const snapshot = (value) => {
|
|
|
1249
1410
|
|
|
1250
1411
|
/**
|
|
1251
1412
|
* Builds an `onStep` hook that records what every Command returned, plus a packager
|
|
1252
|
-
* for the reference trace format. Pass `onStep` to `runEffect` as per-call config
|
|
1253
|
-
*
|
|
1413
|
+
* for the reference trace format. Pass `onStep` to `runEffect` as per-call config. A recorder holds the steps
|
|
1414
|
+
* of every run it sees, so installed with `configureEffect` for a whole application it mixes requests into one
|
|
1415
|
+
* trace with duplicate paths, which a replay refuses; examples/recording-example.js gives each run its own.
|
|
1254
1416
|
*
|
|
1255
1417
|
* @param {RecorderOptions} [options] - Redaction and size limits
|
|
1256
1418
|
* @returns {{ onStep: StepRunner, entries: TraceEntry[], toTrace: (meta?: TraceMeta) => TraceLog }}
|
|
@@ -1266,9 +1428,7 @@ const recorder = (options = {}) => {
|
|
|
1266
1428
|
else dropped++;
|
|
1267
1429
|
};
|
|
1268
1430
|
|
|
1269
|
-
|
|
1270
|
-
* `redact` is the caller's code.
|
|
1271
|
-
*/
|
|
1431
|
+
// `redact` is the caller's code, and a throw from it must not reach the run.
|
|
1272
1432
|
const safeRedact = (/** @type {any} */ value, /** @type {string} */ name, /** @type {string} */ kind) => {
|
|
1273
1433
|
try {
|
|
1274
1434
|
return redact(value, name, kind);
|
|
@@ -1284,14 +1444,10 @@ const recorder = (options = {}) => {
|
|
|
1284
1444
|
const redactField = (/** @type {any} */ value, /** @type {string} */ kind) =>
|
|
1285
1445
|
value === undefined ? undefined : safeRedact(snapshot(value), kind, kind);
|
|
1286
1446
|
|
|
1287
|
-
// Built on `observeSteps` so the hook contract lives in one place: `op` always runs, its result is
|
|
1288
|
-
// always returned, and its error always propagates.
|
|
1289
1447
|
const onStep = observeSteps(({ name, type, path }) => (end) => {
|
|
1290
1448
|
const durationMs = Math.round(end.durationMs * 1000) / 1000;
|
|
1291
|
-
//
|
|
1292
|
-
//
|
|
1293
|
-
// recording changed what production did. A Parallel's result is its decision, which holds no user
|
|
1294
|
-
// data and has to survive intact for a replay to reproduce it, so `redact` is not asked about it.
|
|
1449
|
+
// A Parallel's result is its decision, which holds no user data and must survive intact for a replay
|
|
1450
|
+
// to reproduce it, so it is not redacted.
|
|
1295
1451
|
const recorded = (/** @type {any} */ result) =>
|
|
1296
1452
|
type === 'Parallel' ? snapshot(result) : safeRedact(snapshot(result), name, 'result');
|
|
1297
1453
|
push(
|
|
@@ -1327,8 +1483,8 @@ const recorder = (options = {}) => {
|
|
|
1327
1483
|
|
|
1328
1484
|
/**
|
|
1329
1485
|
* Runs a flow for real while recording every Command result, and returns both the
|
|
1330
|
-
* outcome and a replayable trace. Convenient in tests and scripts;
|
|
1331
|
-
*
|
|
1486
|
+
* outcome and a replayable trace. Convenient in tests and scripts; to record an application without
|
|
1487
|
+
* changing call sites, give each run its own recorder, as examples/recording-example.js does.
|
|
1332
1488
|
*
|
|
1333
1489
|
* @param {(input: any) => Effect} flowFn - Builds the Effect tree from its input
|
|
1334
1490
|
* @param {any} initialInput - The value the flow is called with; stored so a replay can rebuild it
|
|
@@ -1339,35 +1495,46 @@ const recorder = (options = {}) => {
|
|
|
1339
1495
|
const recordEffect = async (flowFn, initialInput, options = {}) => {
|
|
1340
1496
|
const { context = {}, version, ...recorderOptions } = options;
|
|
1341
1497
|
const rec = recorder(recorderOptions);
|
|
1342
|
-
// Packaged before the run,
|
|
1343
|
-
//
|
|
1344
|
-
// what the trace says production received, and the replay was rebuilt from a value the run never saw.
|
|
1498
|
+
// Packaged before the run, so a Command that writes to the input or the context, as an ORM save
|
|
1499
|
+
// assigning an id does, cannot rewrite what the trace says the run received.
|
|
1345
1500
|
const head = rec.toTrace({ initialInput, flowName: context.flowName, context, version });
|
|
1346
|
-
//
|
|
1347
|
-
// its spans.
|
|
1501
|
+
// Merged over the global wiring, so recording inside an instrumented application keeps its spans.
|
|
1348
1502
|
const result = await runEffect(flowFn(initialInput), context, { onStep: rec.onStep });
|
|
1349
1503
|
const { dropped, trace } = rec.toTrace();
|
|
1350
1504
|
return { result, trace: { ...head, dropped, trace } };
|
|
1351
1505
|
};
|
|
1352
1506
|
|
|
1353
1507
|
/**
|
|
1354
|
-
*
|
|
1355
|
-
*
|
|
1356
|
-
*
|
|
1357
|
-
*
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
*
|
|
1508
|
+
* Whether a recorded entry says its step threw: `threw`, or an `error` key, which is how older and
|
|
1509
|
+
* hand-written traces say it.
|
|
1510
|
+
* @param {TraceEntry} entry
|
|
1511
|
+
* @returns {boolean}
|
|
1512
|
+
*/
|
|
1513
|
+
const entryThrew = (entry) => entry.threw === true || 'error' in entry;
|
|
1514
|
+
|
|
1515
|
+
/**
|
|
1516
|
+
* Whether a trace entry or a replay step carries a path.
|
|
1517
|
+
* @param {{ path?: string }} step
|
|
1518
|
+
* @returns {boolean}
|
|
1519
|
+
*/
|
|
1520
|
+
const hasPath = (step) => typeof step.path === 'string';
|
|
1521
|
+
|
|
1522
|
+
/**
|
|
1523
|
+
* Whether a path lies inside a Parallel branch, which a `p` followed by the branch number marks.
|
|
1524
|
+
* @param {string | undefined} path
|
|
1525
|
+
* @returns {boolean}
|
|
1526
|
+
*/
|
|
1527
|
+
const isInsideParallel = (path) => typeof path === 'string' && /p\d+\//.test(path);
|
|
1528
|
+
|
|
1529
|
+
/**
|
|
1530
|
+
* Turns a recorded entry into the outcome a Resolver must return, snapshotted so a replayed step that
|
|
1531
|
+
* mutates its result cannot rewrite the trace.
|
|
1363
1532
|
*
|
|
1364
1533
|
* @param {TraceEntry} entry
|
|
1365
1534
|
* @returns {ReplayOutcome}
|
|
1366
1535
|
*/
|
|
1367
1536
|
const entryToOutcome = (entry) =>
|
|
1368
|
-
entry
|
|
1369
|
-
? { error: reviveError(snapshot(entry.error)) }
|
|
1370
|
-
: { result: snapshot(entry.result) };
|
|
1537
|
+
entryThrew(entry) ? { error: reviveError(snapshot(entry.error)) } : { result: snapshot(entry.result) };
|
|
1371
1538
|
|
|
1372
1539
|
/**
|
|
1373
1540
|
* Whether an entry is a Parallel's recorded decision rather than a Command's result. A Parallel's path
|
|
@@ -1380,24 +1547,16 @@ const isDecisionEntry = (entry) =>
|
|
|
1380
1547
|
entry.command === 'Parallel' && typeof entry.path === 'string' && entry.path.endsWith('p');
|
|
1381
1548
|
|
|
1382
1549
|
/**
|
|
1383
|
-
* Builds a Resolver for the reference trace format. Internal: `replayEffect`
|
|
1384
|
-
*
|
|
1385
|
-
* in some other shape write a Resolver instead, which is the extension point.
|
|
1550
|
+
* Builds a Resolver for the reference trace format. Internal: `replayEffect` is the only caller, and a
|
|
1551
|
+
* caller with traces in another shape writes a Resolver instead.
|
|
1386
1552
|
*
|
|
1387
|
-
*
|
|
1388
|
-
*
|
|
1389
|
-
*
|
|
1390
|
-
*
|
|
1553
|
+
* When every entry carries a `path`, steps are matched by path, which is order-independent and still
|
|
1554
|
+
* detects a paradox: two `Parallel` branches calling the same Command are told apart by position. A
|
|
1555
|
+
* trace without paths is matched positionally, each entry checked against the Command the flow asks
|
|
1556
|
+
* for, which is exact for sequential flows and cannot tell `Parallel` branches apart.
|
|
1391
1557
|
*
|
|
1392
|
-
* A trace
|
|
1393
|
-
*
|
|
1394
|
-
* flow asks for. That is exact for sequential flows and cannot tell `Parallel` branches apart, which
|
|
1395
|
-
* is what paths were added to fix.
|
|
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.
|
|
1558
|
+
* A step the trace does not hold resolves to `undefined`, as from a Resolver, so `replayEffect` applies
|
|
1559
|
+
* `onMissing` to both; `missing` describes the step for the error.
|
|
1401
1560
|
*
|
|
1402
1561
|
* @param {TraceLog | TraceEntry[]} traceLog - A reference-format trace, or a bare array of entries
|
|
1403
1562
|
* @param {Object} [options]
|
|
@@ -1414,11 +1573,10 @@ const fromTrace = (traceLog, options = {}) => {
|
|
|
1414
1573
|
return entryToOutcome(entry);
|
|
1415
1574
|
};
|
|
1416
1575
|
|
|
1417
|
-
if (entries.length > 0 && entries.every(
|
|
1576
|
+
if (entries.length > 0 && entries.every(hasPath)) {
|
|
1418
1577
|
const byPath = new Map(entries.map((e) => [e.path, e]));
|
|
1419
|
-
// Paths are
|
|
1420
|
-
// either
|
|
1421
|
-
// would reintroduce exactly the wrong-result-per-branch failure paths exist to prevent.
|
|
1578
|
+
// Paths are unique by construction, so a collision means a hand-built trace or a bug, and keeping
|
|
1579
|
+
// either entry would hand a branch the wrong result.
|
|
1422
1580
|
if (byPath.size !== entries.length) {
|
|
1423
1581
|
throw replayError('Trace has duplicate step paths.');
|
|
1424
1582
|
}
|
|
@@ -1437,8 +1595,7 @@ const fromTrace = (traceLog, options = {}) => {
|
|
|
1437
1595
|
const resolve = (step) => {
|
|
1438
1596
|
const entry = byPath.get(step.path);
|
|
1439
1597
|
if (step.type === 'Parallel') {
|
|
1440
|
-
// A decision rather than I/O. With none recorded, the Parallel replays under timing
|
|
1441
|
-
// it did before decisions were recorded.
|
|
1598
|
+
// A decision rather than I/O. With none recorded, the Parallel replays under timing.
|
|
1442
1599
|
if (entry && entry.command !== 'Parallel') throw timeParadox(step, entry.command);
|
|
1443
1600
|
if (entry) return resolveEntry(entry);
|
|
1444
1601
|
if (stoppedInProduction(step.path)) throw replayCutError(/** @type {string} */ (step.path));
|
|
@@ -1461,10 +1618,9 @@ const fromTrace = (traceLog, options = {}) => {
|
|
|
1461
1618
|
const resolve = (step) => {
|
|
1462
1619
|
// A trace with no paths predates recorded decisions, so a Parallel replays under timing.
|
|
1463
1620
|
if (step.type === 'Parallel') return undefined;
|
|
1464
|
-
// Positional matching pairs steps by completion order, which
|
|
1465
|
-
//
|
|
1466
|
-
|
|
1467
|
-
if (typeof step.path === 'string' && /p\d+\//.test(step.path)) {
|
|
1621
|
+
// Positional matching pairs steps by completion order, which cannot tell Parallel branches apart.
|
|
1622
|
+
// Refusing beats a result that is right only when the replay finishes in production's order.
|
|
1623
|
+
if (isInsideParallel(step.path)) {
|
|
1468
1624
|
throw replayError(
|
|
1469
1625
|
`Trace carries no paths, so '${step.name}' inside a Parallel cannot be matched positionally.`,
|
|
1470
1626
|
{ command: step.name, path: step.path }
|
|
@@ -1479,61 +1635,20 @@ const fromTrace = (traceLog, options = {}) => {
|
|
|
1479
1635
|
return { resolve, missing };
|
|
1480
1636
|
};
|
|
1481
1637
|
|
|
1482
|
-
/**
|
|
1483
|
-
* Rewrites `Retry` nodes to zero delay, lazily, through their `next` continuations.
|
|
1484
|
-
*
|
|
1485
|
-
* Needed because a delay is written at the call site and nothing outside the node
|
|
1486
|
-
* can override it: retry options are per-use only. Without this, replaying a flow
|
|
1487
|
-
* that retried in production waits out the production backoff.
|
|
1488
|
-
*
|
|
1489
|
-
* @param {any} eff - Any Effect node
|
|
1490
|
-
* @returns {any} The same tree with Retry delays removed
|
|
1491
|
-
*/
|
|
1492
|
-
const zeroRetryDelays = (eff) => {
|
|
1493
|
-
if (!eff || typeof eff.type !== 'string') return eff;
|
|
1494
|
-
switch (eff.type) {
|
|
1495
|
-
case 'Retry': {
|
|
1496
|
-
const options = { ...eff.options, delay: 0, backoff: 1 };
|
|
1497
|
-
// The fallback tree only exists once onExhausted runs, so it is rewritten lazily too;
|
|
1498
|
-
// otherwise a Retry inside the fallback keeps its production backoff during replay.
|
|
1499
|
-
if (typeof options.onExhausted === 'function') {
|
|
1500
|
-
const original = options.onExhausted;
|
|
1501
|
-
options.onExhausted = (/** @type {any} */ error) => zeroRetryDelays(original(error));
|
|
1502
|
-
}
|
|
1503
|
-
return {
|
|
1504
|
-
...eff,
|
|
1505
|
-
options,
|
|
1506
|
-
effect: zeroRetryDelays(eff.effect),
|
|
1507
|
-
next: (/** @type {any} */ value) => zeroRetryDelays(eff.next(value))
|
|
1508
|
-
};
|
|
1509
|
-
}
|
|
1510
|
-
case 'Command':
|
|
1511
|
-
return { ...eff, next: (/** @type {any} */ result) => zeroRetryDelays(eff.next(result)) };
|
|
1512
|
-
case 'Ask':
|
|
1513
|
-
return { ...eff, next: (/** @type {any} */ ctx) => zeroRetryDelays(eff.next(ctx)) };
|
|
1514
|
-
case 'Parallel':
|
|
1515
|
-
return {
|
|
1516
|
-
...eff,
|
|
1517
|
-
effects: eff.effects.map(zeroRetryDelays),
|
|
1518
|
-
next: (/** @type {any} */ values) => zeroRetryDelays(eff.next(values))
|
|
1519
|
-
};
|
|
1520
|
-
default:
|
|
1521
|
-
return eff;
|
|
1522
|
-
}
|
|
1523
|
-
};
|
|
1524
|
-
|
|
1525
1638
|
/**
|
|
1526
1639
|
* @typedef {Object} ReplayOptions
|
|
1527
|
-
* @property {any} [context] - Context for `Ask`;
|
|
1528
|
-
* @property {boolean} [fastRetry] -
|
|
1640
|
+
* @property {any} [context] - Context for `Ask`; defaults to the context a trace recorded.
|
|
1641
|
+
* @property {boolean} [fastRetry] - Waits no time between Retry attempts, so a replay does not wait out
|
|
1642
|
+
* production backoff. On by default.
|
|
1529
1643
|
* @property {boolean} [hooks] - Runs the replay inside the hooks `configureEffect` installed, with the
|
|
1530
1644
|
* resolver innermost, so a configured `onStep` observes each replayed step and `onRun` and
|
|
1531
1645
|
* `onBeforeCommand` fire. Off by default, which ignores the global hooks, so a replay cannot
|
|
1532
|
-
* reach a telemetry backend or a guardrail that performs I/O.
|
|
1533
|
-
* node either way, so a replay makes the attempts production made.
|
|
1646
|
+
* reach a telemetry backend or a guardrail that performs I/O.
|
|
1534
1647
|
* @property {'throw' | 'execute'} [onMissing] - What to do when the resolver has no recording for a step.
|
|
1535
1648
|
* `'throw'` (default) fails the replay, which makes side effects impossible for the whole run.
|
|
1536
|
-
* `'execute'` runs the real Command,
|
|
1649
|
+
* `'execute'` runs the real Command, so pass it only where the Commands reach test doubles or only
|
|
1650
|
+
* read. A trace that dropped entries under `maxEntries` refuses it, since a step it lacks may be one
|
|
1651
|
+
* production ran.
|
|
1537
1652
|
* @property {(step: ReplayStep, outcome: ReplayOutcome | undefined) => void} [onResolved] - Observes each step.
|
|
1538
1653
|
*/
|
|
1539
1654
|
|
|
@@ -1545,8 +1660,7 @@ const zeroRetryDelays = (eff) => {
|
|
|
1545
1660
|
* A flow that stops issuing Commands before its recording ends mismatches nothing, so no
|
|
1546
1661
|
* `TimeParadox` fires and `result` can be a `Success` with recorded steps left over. That is
|
|
1547
1662
|
* sometimes the point of a fix and sometimes a fix that quietly dropped a step; `unreached` is
|
|
1548
|
-
* the only place the difference shows
|
|
1549
|
-
* behind an option.
|
|
1663
|
+
* the only place the difference shows.
|
|
1550
1664
|
* @typedef {{ result: SuccessState | FailureState, unreached?: TraceEntry[] }} Replay
|
|
1551
1665
|
*/
|
|
1552
1666
|
|
|
@@ -1569,8 +1683,7 @@ const zeroRetryDelays = (eff) => {
|
|
|
1569
1683
|
* @param {ReplayOptions} [options]
|
|
1570
1684
|
* @returns {Promise<Replay>} `{ result, unreached }` for a trace, `{ result }` for a Resolver
|
|
1571
1685
|
*/
|
|
1572
|
-
// `async` so a malformed trace arrives as a rejection rather than a synchronous throw
|
|
1573
|
-
// the function otherwise returns a promise, and callers should not have to handle both.
|
|
1686
|
+
// `async` so a malformed trace arrives as a rejection rather than a synchronous throw.
|
|
1574
1687
|
const replayEffect = async (effect, traceOrResolver, options = {}) => {
|
|
1575
1688
|
const { fastRetry = true, hooks = false, onMissing = 'throw', onResolved } = options;
|
|
1576
1689
|
// A trace is data and a Resolver is a function, so nothing else is needed to tell them
|
|
@@ -1581,16 +1694,26 @@ const replayEffect = async (effect, traceOrResolver, options = {}) => {
|
|
|
1581
1694
|
const { resolve, missing } = fromResolver
|
|
1582
1695
|
? { resolve: traceOrResolver, missing: undefined }
|
|
1583
1696
|
: fromTrace(traceOrResolver, { onEntry: (entry) => void reached.add(entry) });
|
|
1584
|
-
//
|
|
1585
|
-
|
|
1586
|
-
//
|
|
1587
|
-
|
|
1588
|
-
const context = options.context ??
|
|
1697
|
+
// Only a trace log carries metadata; a bare entries array and a Resolver carry none.
|
|
1698
|
+
const traceLog = fromResolver || Array.isArray(traceOrResolver) ? undefined : traceOrResolver;
|
|
1699
|
+
// Defaults to the trace's context: an `Ask` gate replayed with another one takes another branch, and
|
|
1700
|
+
// no paradox flags it.
|
|
1701
|
+
const context = options.context ?? traceLog?.context ?? {};
|
|
1702
|
+
// A trace capped by `maxEntries` lacks steps production ran, so running a missing step live repeats
|
|
1703
|
+
// production's I/O: a billing batch replayed that way charged and invoiced subscriptions again.
|
|
1704
|
+
const droppedEntries = Number(traceLog?.dropped) || 0;
|
|
1705
|
+
const capped = droppedEntries > 0;
|
|
1706
|
+
if (capped && onMissing === 'execute') {
|
|
1707
|
+
throw replayError(
|
|
1708
|
+
`The trace dropped ${droppedEntries} entries under maxEntries, so a step it lacks may be one ` +
|
|
1709
|
+
"production ran, and onMissing: 'execute' would run it again. Replay without it to stop at the " +
|
|
1710
|
+
'first missing step, or record the flow with a higher maxEntries.'
|
|
1711
|
+
);
|
|
1712
|
+
}
|
|
1589
1713
|
let index = 0;
|
|
1590
|
-
// What `onResolved` threw
|
|
1591
|
-
//
|
|
1592
|
-
//
|
|
1593
|
-
// rather than annotated, since only the step runner assigns it.
|
|
1714
|
+
// What `onResolved` threw. It stops the replay, which rejects with it, rather than counting as the
|
|
1715
|
+
// Command failing, which would let a Retry ask for an attempt production never made. Cast rather than
|
|
1716
|
+
// annotated, since only the step runner assigns it.
|
|
1594
1717
|
let observerFailure = /** @type {{ error: unknown } | undefined} */ (undefined);
|
|
1595
1718
|
|
|
1596
1719
|
/** @type {StepRunner} */
|
|
@@ -1614,11 +1737,21 @@ const replayEffect = async (effect, traceOrResolver, options = {}) => {
|
|
|
1614
1737
|
if (outcome === undefined) {
|
|
1615
1738
|
if (onMissing !== 'execute') {
|
|
1616
1739
|
const what = missing ? missing(step) : `No recorded outcome for '${name}' at step ${step.index}`;
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1740
|
+
// Why the step may be missing decides the fix. A capped trace needs a higher cap, since the step
|
|
1741
|
+
// may be one production ran; any other missing step may be one production never ran, and
|
|
1742
|
+
// running it live against production would do I/O production refused.
|
|
1743
|
+
const why = capped
|
|
1744
|
+
? `The trace dropped ${droppedEntries} entries under maxEntries, so production may have run ` +
|
|
1745
|
+
'this step and the recorder not kept it; record the flow with a higher maxEntries to ' +
|
|
1746
|
+
'replay past it.'
|
|
1747
|
+
: 'Production may never have run it: a Command an onBeforeCommand hook vetoed leaves no ' +
|
|
1748
|
+
"entry, and neither does a step added since the recording. onMissing: 'execute' runs such " +
|
|
1749
|
+
'steps for real, so pass it only where the Commands reach test doubles or only read.';
|
|
1750
|
+
throw replayError(`${what}; refusing to run the real Command. ${why}`, {
|
|
1751
|
+
command: name,
|
|
1752
|
+
index: step.index,
|
|
1753
|
+
path
|
|
1754
|
+
});
|
|
1622
1755
|
}
|
|
1623
1756
|
return await op();
|
|
1624
1757
|
}
|
|
@@ -1626,25 +1759,19 @@ const replayEffect = async (effect, traceOrResolver, options = {}) => {
|
|
|
1626
1759
|
return outcome.result;
|
|
1627
1760
|
};
|
|
1628
1761
|
|
|
1629
|
-
//
|
|
1630
|
-
//
|
|
1631
|
-
// observes each replayed step and the Command still never executes. Retry shape needs nothing from
|
|
1632
|
-
// the wiring either way, since retry options live on the node: a replay makes the attempts production
|
|
1633
|
-
// made whichever way this is set.
|
|
1762
|
+
// With `hooks` off the replay ignores the global wiring, so it reaches no telemetry backend or
|
|
1763
|
+
// guardrail. On, the resolver sits innermost and a configured onStep observes each replayed step.
|
|
1634
1764
|
/** @type {CallConfiguration} */
|
|
1635
1765
|
const callConfig = { onStep, inherit: hooks };
|
|
1636
1766
|
|
|
1637
|
-
// The interpreter rethrows a replay fault
|
|
1638
|
-
//
|
|
1639
|
-
// `Success` whose branches each carried the ReplayError as though production had returned it. It
|
|
1640
|
-
// becomes a `Failure` here instead, which is the shape this function has always returned and the
|
|
1641
|
-
// one place where nothing downstream can catch it.
|
|
1767
|
+
// The interpreter rethrows a replay fault so nothing in the flow can absorb it; it becomes a Failure
|
|
1768
|
+
// here, where nothing downstream can.
|
|
1642
1769
|
let result;
|
|
1643
1770
|
try {
|
|
1644
|
-
result = await
|
|
1771
|
+
result = await interpret(effect, context, callConfig, fastRetry);
|
|
1645
1772
|
} catch (e) {
|
|
1646
1773
|
if (observerFailure) throw observerFailure.error;
|
|
1647
|
-
if (!(e
|
|
1774
|
+
if (!hasMark(e, replayFault)) throw e;
|
|
1648
1775
|
result = Failure(e, effect.initialInput);
|
|
1649
1776
|
}
|
|
1650
1777
|
if (fromResolver) return { result };
|
|
@@ -1670,9 +1797,8 @@ const replayEffect = async (effect, traceOrResolver, options = {}) => {
|
|
|
1670
1797
|
const timeTravel = async (flowFn, traceLog, options = {}) => {
|
|
1671
1798
|
const { log = console.log, context, version } = options;
|
|
1672
1799
|
const { initialInput, trace, flowName, version: traceVersion } = traceLog;
|
|
1673
|
-
// `message`
|
|
1674
|
-
//
|
|
1675
|
-
// recorded result can hold, and narration must not be what fails a replay.
|
|
1800
|
+
// `message` is non-enumerable on Error, so JSON.stringify alone would drop it. JSON.stringify throws on
|
|
1801
|
+
// a BigInt and on a cycle, and narration must not be what fails a replay.
|
|
1676
1802
|
const format = (/** @type {any} */ v) => {
|
|
1677
1803
|
try {
|
|
1678
1804
|
return JSON.stringify(v instanceof Error ? { ...v, name: v.name, message: v.message } : v, null, 2);
|
|
@@ -1684,19 +1810,32 @@ const timeTravel = async (flowFn, traceLog, options = {}) => {
|
|
|
1684
1810
|
if (version && traceVersion && version !== traceVersion) {
|
|
1685
1811
|
log(`Warning: trace was recorded at ${traceVersion}, replaying against ${version}.`);
|
|
1686
1812
|
}
|
|
1687
|
-
//
|
|
1688
|
-
//
|
|
1813
|
+
// The flow is rebuilt from this input, and a recorder installed as a hook reads it off the flow, where only
|
|
1814
|
+
// effectPipe puts it.
|
|
1815
|
+
if (initialInput === undefined) {
|
|
1816
|
+
log(
|
|
1817
|
+
'Warning: the trace holds no initial input, so the flow is rebuilt from undefined. A recorder ' +
|
|
1818
|
+
'installed as a hook finds the input only on a flow built with effectPipe.'
|
|
1819
|
+
);
|
|
1820
|
+
}
|
|
1821
|
+
// A replay tells steps apart by name, so two anonymous Commands that swapped places replay without complaint.
|
|
1822
|
+
const anonymous = trace.filter((e) => e.command === 'anonymous').length;
|
|
1823
|
+
if (anonymous > 0) {
|
|
1824
|
+
log(
|
|
1825
|
+
`Warning: ${anonymous} of the recorded steps are named 'anonymous', usually inline arrow Commands, so ` +
|
|
1826
|
+
'this replay cannot tell them apart and would not notice two of them trading places. Name them with ' +
|
|
1827
|
+
'a const or meta.name.'
|
|
1828
|
+
);
|
|
1829
|
+
}
|
|
1830
|
+
// Parallel decisions are not narrated as steps, so the header counts Commands to match the lines below.
|
|
1689
1831
|
const commandCount = trace.filter((e) => !isDecisionEntry(e)).length;
|
|
1690
1832
|
const stepsText = commandCount === 1 ? 'step' : 'steps';
|
|
1691
1833
|
log(`Replaying '${flowName || 'flow'}' (${commandCount} recorded ${stepsText})`);
|
|
1692
1834
|
log(`Initial input: ${format(initialInput)}`);
|
|
1693
1835
|
|
|
1694
|
-
//
|
|
1695
|
-
//
|
|
1696
|
-
|
|
1697
|
-
// timings are narrated for Parallel too. Positional lookup is the fallback for a trace with no paths,
|
|
1698
|
-
// which is how such a trace is matched anyway.
|
|
1699
|
-
const byPath = new Map(trace.filter((e) => typeof e.path === 'string').map((e) => [e.path, e]));
|
|
1836
|
+
// Timings are looked up by path, whatever order branches finished in, or by position for a trace
|
|
1837
|
+
// with no paths, which is how such a trace is matched anyway.
|
|
1838
|
+
const byPath = new Map(trace.filter(hasPath).map((e) => [e.path, e]));
|
|
1700
1839
|
const timing = (/** @type {ReplayStep} */ step) => {
|
|
1701
1840
|
const recorded = byPath.get(step.path) ?? trace[step.index];
|
|
1702
1841
|
return typeof recorded?.durationMs === 'number' ? ` in ${recorded.durationMs}ms` : '';
|
|
@@ -1717,15 +1856,12 @@ const timeTravel = async (flowFn, traceLog, options = {}) => {
|
|
|
1717
1856
|
|
|
1718
1857
|
log(`Replay finished with state: ${result.type}`);
|
|
1719
1858
|
log(result.type === 'Failure' ? `Error: ${format(result.error)}` : `Result: ${format(result.value)}`);
|
|
1720
|
-
// After a replay error the flow never got past the divergence,
|
|
1721
|
-
//
|
|
1722
|
-
// with steps left over is worth a warning.
|
|
1859
|
+
// After a replay error the flow never got past the divergence, and the error already names where it
|
|
1860
|
+
// split. Only a flow that ended on its own terms with steps left over is worth a warning.
|
|
1723
1861
|
const haltedByReplay = result.type === 'Failure' && /^(TimeParadox|ReplayError)$/.test(result.error?.name);
|
|
1724
1862
|
if (unreached.length > 0 && !haltedByReplay) {
|
|
1725
1863
|
// Named, not just counted: the step a fix stopped issuing is usually the one under suspicion.
|
|
1726
|
-
const names = unreached.map((e) =>
|
|
1727
|
-
typeof e.path === 'string' ? `${e.command} (path '${e.path}')` : e.command
|
|
1728
|
-
);
|
|
1864
|
+
const names = unreached.map((e) => (hasPath(e) ? `${e.command} (path '${e.path}')` : e.command));
|
|
1729
1865
|
const count = unreached.length === 1 ? '1 recorded step was' : `${unreached.length} recorded steps were`;
|
|
1730
1866
|
log(`Warning: ${count} never reached: ${names.join(', ')}. The flow diverged.`);
|
|
1731
1867
|
}
|
|
@@ -1745,5 +1881,6 @@ export {
|
|
|
1745
1881
|
recorder,
|
|
1746
1882
|
recordEffect,
|
|
1747
1883
|
replayEffect,
|
|
1748
|
-
timeTravel
|
|
1884
|
+
timeTravel,
|
|
1885
|
+
commandName
|
|
1749
1886
|
};
|