pure-effect 0.16.0 → 0.17.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 +14 -0
- package/README.md +24 -8
- package/index.js +223 -122
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to pure-effect are recorded here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and from 1.0.0 the project follows [Semantic Versioning](https://semver.org/). Before 1.0.0 a minor version could change behaviour; each such change is marked below.
|
|
4
4
|
|
|
5
|
+
## [0.17.0] - 2026-10-06
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- **Inside a `Parallel`, a Command's function gets the `AbortSignal` only if it takes a parameter.** Every function was handed the signal, so one passed by name whose first parameter has a default value read the signal in its place: `Command(nanoid)` returned an empty ID inside a `Parallel` and a full one outside, and a helper with a default page size got the signal as its page size. The signal now goes only to a function that declares a parameter, counted as `Function.length` counts them, which leaves out parameters with default values, so such a function keeps its default everywhere. A function that reads the signal through a parameter with a default value or through `...args`, or that a wrapper hides behind a `length` of 0, is no longer given it, and runs to completion when its branch is cancelled, as a function that ignores the signal does; write it as `(signal) => ...`. A plain first parameter that a function treats as optional still takes the signal, as before.
|
|
10
|
+
- **An `options` argument refuses a name it does not read.** `Retry`, `Parallel`, `recorder`, `recordEffect`, `replayEffect` and `timeTravel` ignored any other name, so a misspelt or borrowed one ran with the default and nothing said so: `Parallel(batch, { concurrency: 4 })`, the name p-limit uses, ran with no limit, `{ settle: true }` ran fail-fast so one bad record cancelled the batch, and `{ attemps: 5 }` got 3 attempts. Such a name now throws, naming it and the options the function reads; `Retry` and `Parallel` throw an `EffectTypeError` as the flow is built, the others a `TypeError`. TypeScript already refused most of them in an object literal written in the call. An option set to `undefined` is still accepted.
|
|
11
|
+
- **The message for a step a trace lacks says what `onMissing: 'execute'` would run.** It advised `'execute'` where the Commands only read, so a replay that met a newly added lookup could run it that way, and every step after it, which had moved to a new path, ran live too. The message now says so, and advises `'execute'` only where every Command the flow can still reach goes to a test double or only reads.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- **An `onStep` hook that calls `op()` without awaiting it can no longer replace a Command's result with `undefined`.** A hook that returned `undefined` in place of the Command's value made the run reject, but the check ran as soon as the hook returned, while a Command that took any time was still running, so a found user still reached `next` as `undefined` and the flow took the branch for a missing one. The interpreter now waits for a Command the hook left running before it judges the step: the run rejects if the Command returned a value, a throw from it is an I/O fault, as it is for a hook that awaited `op`, and nothing is left running unobserved.
|
|
16
|
+
- **`timeTravel` warns about unreached steps after a flow's own error named like a replay error.** It recognized its own replay errors by name, so a flow that ended on an error of its own named `ReplayError` or `TimeParadox` lost the warning about recorded steps it never reached. It now recognizes only the errors the library raises.
|
|
17
|
+
|
|
5
18
|
## [0.16.0] - 2026-10-01
|
|
6
19
|
|
|
7
20
|
### Added
|
|
@@ -223,6 +236,7 @@ All notable changes to pure-effect are recorded here. The format follows [Keep a
|
|
|
223
236
|
|
|
224
237
|
- Initial release: `Success`, `Failure`, `Command`, `effectPipe`, and `runEffect`.
|
|
225
238
|
|
|
239
|
+
[0.17.0]: https://github.com/aycangulez/pure-effect/compare/v0.16.0...v0.17.0
|
|
226
240
|
[0.16.0]: https://github.com/aycangulez/pure-effect/compare/v0.15.0...v0.16.0
|
|
227
241
|
[0.15.0]: https://github.com/aycangulez/pure-effect/compare/v0.14.0...v0.15.0
|
|
228
242
|
[0.14.0]: https://github.com/aycangulez/pure-effect/compare/v0.13.0...v0.14.0
|
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
- Inject context without touching function signatures
|
|
10
10
|
- Built-in retry, plus parallel execution that cancels sibling branches on the first failure
|
|
11
11
|
- OpenTelemetry-ready via lifecycle hooks
|
|
12
|
-
- Zero dependencies,
|
|
12
|
+
- Zero dependencies, under 8 KB minified and gzipped
|
|
13
13
|
- Works in JavaScript, and in TypeScript 5.1 or later (full generics, bundled `.d.ts`)
|
|
14
14
|
|
|
15
15
|
## Table of Contents
|
|
@@ -171,6 +171,8 @@ A flow does what `async`/`await` code does, as data. Instead of making a call an
|
|
|
171
171
|
| a value from the request, such as the tenant | `Ask`; see [Passing Runtime Context](#passing-runtime-context) |
|
|
172
172
|
| `Date.now()` or a random ID | a Command, so a replay gets the recorded value |
|
|
173
173
|
|
|
174
|
+
One rule is behind every row. In `async` code, an `if` or a loop can have an `await` inside it. In a flow it can't: an `if` goes in a step or a Command's `next`, where the answer has already arrived, and a loop that makes calls becomes a `Parallel` or a `Retry`. So each decision lives in a small function of its own, which a test can call directly.
|
|
175
|
+
|
|
174
176
|
The Quick Start's `registerUserFlow` is this function, translated row by row: each `await` became a Command, the code after it became that Command's `next`, and each `throw` became a `Failure`.
|
|
175
177
|
|
|
176
178
|
```js
|
|
@@ -205,6 +207,20 @@ assert.deepEqual(ensureEmailAvailable(input).next({ id: 1 }), Failure('Email alr
|
|
|
205
207
|
|
|
206
208
|
This is what the small functions are for. Each step is a place a test can start, so a branch is tested by calling the step it lives in. An `async` function has one way in, so a test of any branch runs everything before it, with every call on the way replaced. The mocks can check one thing these tests cannot: the arguments each call received, which [Testing Without Mocks](#testing-without-mocks) leaves to an integration test.
|
|
207
209
|
|
|
210
|
+
You can switch one function at a time. The function keeps its name and parameters, and its body runs the flow and turns a `Failure` back into a throw, so the code that calls it does not change. The mocked test above passes against this version too, so it checks the switch before the flow's own tests replace it:
|
|
211
|
+
|
|
212
|
+
```js
|
|
213
|
+
// Callers still await registerUserAsync and catch what it throws.
|
|
214
|
+
async function registerUserAsync(input) {
|
|
215
|
+
const result = await runEffect(registerUserFlow(input));
|
|
216
|
+
// A step's Failure holds the error as written, here a string; a Command's function threw an Error.
|
|
217
|
+
if (result.type === 'Failure') throw result.error instanceof Error ? result.error : new Error(result.error);
|
|
218
|
+
return result.value;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
await assert.rejects(registerUserAsync({ email: 'bad-email', password: '123' }), { message: 'Invalid email.' });
|
|
222
|
+
```
|
|
223
|
+
|
|
208
224
|
A loop is written in two parts: a step that gets the list, and a `Parallel` over it. `Parallel` runs a list of flows, at the same time unless told otherwise, and `limit: 1` makes it run them one after another, like a `for` loop. The `async` version is usually split the same way. Paying a customer's unpaid invoices in order, and stopping at the first that fails:
|
|
209
225
|
|
|
210
226
|
```js
|
|
@@ -570,7 +586,7 @@ Bugs in the flow are not collected. An `EffectTypeError`, thrown for a malformed
|
|
|
570
586
|
|
|
571
587
|
`limit` caps how many branches run at once; the rest start as others finish. Results and recorded paths stay in array order, so a limit changes only the pacing, and a trace recorded with a limit replays the same without one. A `limit` that is not a positive integer throws a `TypeError`.
|
|
572
588
|
|
|
573
|
-
**Cancelling is a request, not a guarantee.** A cancelled branch starts no new Commands: a three-step branch whose first step is running when a sibling fails finishes that step and stops. To stop the running step itself, its function has to
|
|
589
|
+
**Cancelling is a request, not a guarantee.** A cancelled branch starts no new Commands: a three-step branch whose first step is running when a sibling fails finishes that step and stops. To stop the running step itself, its function has to take the `AbortSignal` as a parameter, written without a default value, and pass it on to the I/O:
|
|
574
590
|
|
|
575
591
|
```js
|
|
576
592
|
// Cancellable: the request is aborted the moment a sibling branch fails.
|
|
@@ -737,8 +753,6 @@ Retry(
|
|
|
737
753
|
| a step returned `Failure(...)`, or an `onBeforeCommand` hook threw | an abort | nothing; it propagates at once, unwrapped | nothing |
|
|
738
754
|
| a Command's function threw | an I/O fault | retries it | the exhaustion, once the attempts are gone |
|
|
739
755
|
| a `next` function or a pure step threw, or the flow is malformed | a bug | nothing; it is thrown, not returned | nothing |
|
|
740
|
-
| an `onStep` hook threw after the Command's function succeeded | a bug | nothing; it is thrown, not returned | nothing |
|
|
741
|
-
| an `onStep` hook returned `undefined` after the Command's function returned a value | a bug | nothing; it is thrown, not returned | nothing |
|
|
742
756
|
|
|
743
757
|
In short, **you can recover from an error your I/O produced, but you cannot catch a `Failure` a step returned.** Only the code that called `runEffect` acts on it. For the same reason, do not throw business errors from a Command's function: a throw there tells `Retry` that the I/O broke, and `Retry` will try again. A throw anywhere else in the flow is treated as a bug: `runEffect` rejects with the thrown error instead of returning a `Failure`.
|
|
744
758
|
|
|
@@ -817,6 +831,8 @@ Timings are from Node 22 on a MacBook Pro with M4 Pro CPU:
|
|
|
817
831
|
|
|
818
832
|
## API Reference
|
|
819
833
|
|
|
834
|
+
Every `options` argument below throws for a name it does not read, so a misspelt option fails instead of running with its default.
|
|
835
|
+
|
|
820
836
|
### Building blocks
|
|
821
837
|
|
|
822
838
|
#### `Success(value)`
|
|
@@ -831,7 +847,7 @@ Returns `{ type: 'Failure', error, initialInput }`. Stops the pipeline immediate
|
|
|
831
847
|
|
|
832
848
|
Returns `{ type: 'Command', cmd, next, meta }`.
|
|
833
849
|
|
|
834
|
-
- `cmd`: the function, sync or async, that does the I/O. Inside a `Parallel` branch
|
|
850
|
+
- `cmd`: the function, sync or async, that does the I/O. Inside a `Parallel` branch, a function that takes a parameter gets an `AbortSignal` in it that fires when a sibling fails; one with no parameters, or only parameters with default values, gets none, and so does every function outside a `Parallel` (see [Limitations](#limitations)).
|
|
835
851
|
- `next`: receives `cmd`'s result and returns the next Effect; by default `(result) => Success(result)`.
|
|
836
852
|
- `meta`: metadata passed to `onBeforeCommand`. A string `meta.name` names the Command.
|
|
837
853
|
|
|
@@ -931,7 +947,7 @@ remove();
|
|
|
931
947
|
- `onRun(effect, pipeline, flowName)`: wraps the whole run. It must `await pipeline()` and return its result.
|
|
932
948
|
- `onStep(name, type, op, path)`: wraps each Command, and each `Parallel` with `name` and `type` both `'Parallel'`. It must `await op()` and return its result, and pass `path` on to any hook it calls, since a replay matches steps on it.
|
|
933
949
|
- For a Command, `op()` returns a promise, even for a synchronous function. Returning a value without calling `op()` answers for the Command, which is how replay works, and throwing without calling it counts as the Command failing.
|
|
934
|
-
- A throw after `op()` succeeded, or `undefined` returned in place of its value, as a hook that forgot its `return` does, is a bug in the hook: the run rejects, and `Retry` does not run the Command again.
|
|
950
|
+
- A throw after `op()` succeeded, or `undefined` returned in place of its value, as a hook that forgot its `return` or its `await` does, is a bug in the hook: the run rejects, and `Retry` does not run the Command again.
|
|
935
951
|
- For a `Parallel`, `op()` runs the branches, so a hook must call it or the run rejects with a `TypeError`. It returns which branch, if any, cancelled the others, as in `{ cancelled: true, branch: 0 }`, even when a branch threw.
|
|
936
952
|
- `onBeforeCommand(command, context)`: runs before each Command. A throw vetoes the Command: the run returns a `Failure` carrying the thrown error, and `Retry` does not retry it.
|
|
937
953
|
|
|
@@ -1033,8 +1049,8 @@ Replays a trace and narrates each step with its recorded duration, naming any re
|
|
|
1033
1049
|
## Limitations
|
|
1034
1050
|
|
|
1035
1051
|
- **`Retry` repeats everything it wraps, including a Command's `next`.** When a Command's function throws, every Command inside the `Retry` runs again, including the ones that already succeeded. If a retried Command's `next` continues the flow, everything after it is retried too. Give a retried Command the default `next` and continue in a later pipeline step, or make sure every Command it reaches is safe to run more than once. A `Failure` a step returned is not retried at all, so a function that catches its own error and returns it as a value is never retried: see [Which Errors Are Data](#which-errors-are-data).
|
|
1036
|
-
- **Cancelling a `Parallel` branch cannot stop everything.** A cancelled branch starts no new Commands, and a function that uses the `AbortSignal` it is given can be stopped while running. A function that ignores the signal runs to completion, so a branch whose _first_ Command is a write can still write after another branch has failed. `Parallel` waits for every branch to finish before returning, so no cancelled work is still running after the `Failure` is returned.
|
|
1037
|
-
- **Inside a `Parallel`, a Command's function gets the `AbortSignal`
|
|
1052
|
+
- **Cancelling a `Parallel` branch cannot stop everything.** A cancelled branch starts no new Commands, and a function that uses the `AbortSignal` it is given can be stopped while running. A function that ignores the signal runs to completion, so a branch whose _first_ Command is a write can still write after another branch has failed. So does a function that is never given the signal: one written as `(...args)` or `({ signal } = {})`, or wrapped by a helper that hides its parameters. `Parallel` waits for every branch to finish before returning, so no cancelled work is still running after the `Failure` is returned.
|
|
1053
|
+
- **Inside a `Parallel`, a Command's function that takes a parameter gets the `AbortSignal` in it.** A function passed by name whose first parameter is optional but written without a default value takes the signal for it, so the same Command behaves differently inside a `Parallel` than outside one: `function listUsers(page) { page ??= 1; return db.users(page); }` gets the signal as its page. A parameter with a default value does not count, so `Command(nanoid)`, whose size is written `size = 21`, works everywhere. Wrap such a function, as in `Command(() => listUsers())`. In TypeScript, a function whose first parameter cannot be an `AbortSignal` does not compile as a Command.
|
|
1038
1054
|
- **A `Failure` carries everything.** It holds the full error and the `initialInput` that `effectPipe` attached, and neither is trimmed, because tests and debugging need both. `redact` keeps sensitive data out of a **trace**. Keeping it out of your **logs** is up to you: log `result.error` rather than the whole `Failure`. The same goes for the outcomes a settled `Parallel` passes to `next`, which can carry their branch's input; for a login or registration flow, that input holds credentials.
|
|
1039
1055
|
- **Replay checks the path, not the values.** Replaying an old trace cannot check a fix that changes what a flow computes, because every Command's result comes from the recording: the replay hands the step the value from before the fix, reports `Success`, and flags nothing. It can check a fix that changes which Commands run. A removed step shows up in `unreached` only if it was the last step the flow reached; removed from the middle, it shifts the later paths and the replay stops at a `TimeParadox`.
|
|
1040
1056
|
- **A trace keeps data, not objects.** Recorded results come back without their classes: a money object or a database entity as a plain object, a `Buffer` as a plain byte array, and an error inside a result without properties such as `code`. Through JSON, a `Date` also comes back as a string and a `Map` as `{}`. A flow whose `next` calls a method on a result, or branches on one of those properties, replays differently from production. Return plain data from Commands; see [Recording in Production](#recording-in-production).
|
package/index.js
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
|
+
// Contents. Each section is a `#region`, so an editor can fold it.
|
|
4
|
+
//
|
|
5
|
+
// 1. Types: the JSDoc shapes of the nodes a flow is made of.
|
|
6
|
+
// 2. Building flows: the constructors; the checks, error messages and kinds of failure the later sections
|
|
7
|
+
// share; and `effectPipe`, with the `chain` that joins its steps.
|
|
8
|
+
// 3. Configuration: the hook types and the library's defaults, `configureEffect` and its layers, and
|
|
9
|
+
// `chainHooks`, which merges them.
|
|
10
|
+
// 4. Running flows: the helpers `Retry` and `Parallel` run on, the interpreter, and `runEffect`.
|
|
11
|
+
// 5. Recording and replay: the trace format and replay errors, copying values and errors into a trace,
|
|
12
|
+
// `recorder`, built on `observeSteps`, and `recordEffect`, then `fromTrace`, `replayEffect` and `timeTravel`.
|
|
13
|
+
//
|
|
14
|
+
// A new definition goes in the section it serves, which is usually the one that calls it.
|
|
15
|
+
|
|
16
|
+
// #region Types
|
|
17
|
+
|
|
3
18
|
/** @typedef {{ type: 'Success', value: any, initialInput?: any }} SuccessState */
|
|
4
19
|
/** @typedef {{ type: 'Failure', error: any, initialInput?: any }} FailureState */
|
|
5
20
|
/**
|
|
@@ -55,6 +70,9 @@
|
|
|
55
70
|
* @typedef {SuccessState | FailureState | CommandState | AskState | RetryState | ParallelState} Effect
|
|
56
71
|
*/
|
|
57
72
|
|
|
73
|
+
// #endregion
|
|
74
|
+
// #region Building flows
|
|
75
|
+
|
|
58
76
|
/**
|
|
59
77
|
* Represents a successful computation
|
|
60
78
|
* @param {any} value - The result value
|
|
@@ -78,9 +96,9 @@ const Failure = (error, initialInput) => ({
|
|
|
78
96
|
* Represents a side effect to be executed later.
|
|
79
97
|
*
|
|
80
98
|
* @param {(signal?: AbortSignal) => Promise<any>|any} cmd - The side-effect function to execute. Inside a
|
|
81
|
-
* `Parallel` branch
|
|
82
|
-
* accepts one can be cancelled in flight.
|
|
83
|
-
*
|
|
99
|
+
* `Parallel` branch, a function that declares a parameter receives an `AbortSignal` in it that fires when a
|
|
100
|
+
* sibling branch fails, so I/O that accepts one can be cancelled in flight. A parameter with a default value
|
|
101
|
+
* does not count, so `nanoid(size = 21)` keeps its default. Outside a `Parallel` no argument is passed.
|
|
84
102
|
* @param {(result: any) => Effect} [next] - Receives the result of `cmd` and returns the next Effect.
|
|
85
103
|
* Defaults to `(result) => Success(result)`, which is what most Commands want; `null` counts as omitted.
|
|
86
104
|
* @param {CommandMeta} [meta] - Optional metadata, passed to `onBeforeCommand`. A string `meta.name`
|
|
@@ -164,6 +182,9 @@ const Retry = (effect, options) => {
|
|
|
164
182
|
const hint = typeof options === 'number' ? `: write Retry(effect, { attempts: ${options} })` : '';
|
|
165
183
|
throw malformed(`Retry's options must be an object, got ${describeArgument(options)}${hint}.`, options);
|
|
166
184
|
}
|
|
185
|
+
rejectUnknownOptions(options, 'Retry', ['attempts', 'delay', 'backoff', 'onExhausted'], (m) =>
|
|
186
|
+
malformed(m, options)
|
|
187
|
+
);
|
|
167
188
|
return { type: 'Retry', effect, options: options ?? {}, next: (value) => Success(value) };
|
|
168
189
|
};
|
|
169
190
|
|
|
@@ -221,6 +242,7 @@ const Parallel = (effects, nextOrOptions, maybeOptions) => {
|
|
|
221
242
|
if (options != null && !isOptionsObject(options)) {
|
|
222
243
|
throw malformed(`Parallel's options must be an object, got ${describeArgument(options)}.`, options);
|
|
223
244
|
}
|
|
245
|
+
rejectUnknownOptions(options, 'Parallel', ['limit', 'settled'], (m) => malformed(m, options));
|
|
224
246
|
return {
|
|
225
247
|
type: 'Parallel',
|
|
226
248
|
effects,
|
|
@@ -255,6 +277,22 @@ const isFiniteNonNegative = (value) => Number.isFinite(value) && value >= 0;
|
|
|
255
277
|
*/
|
|
256
278
|
const isOptionsObject = (value) => isObject(value) && !Array.isArray(value) && !isEffect(value);
|
|
257
279
|
|
|
280
|
+
/**
|
|
281
|
+
* Refuses an option name a function does not read. A misspelt name, or one borrowed from another library, as
|
|
282
|
+
* `concurrency` is from p-limit, otherwise ran with the default and nothing to say so.
|
|
283
|
+
* @param {any} options
|
|
284
|
+
* @param {string} source - The function that takes them, as the message names it
|
|
285
|
+
* @param {string[]} known
|
|
286
|
+
* @param {(message: string) => Error} [raise] - Builds the error; a constructor's argument errors are EffectTypeErrors
|
|
287
|
+
*/
|
|
288
|
+
const rejectUnknownOptions = (options, source, known, raise = (message) => new TypeError(message)) => {
|
|
289
|
+
const name = isObject(options) ? Object.keys(options).find((key) => !known.includes(key)) : undefined;
|
|
290
|
+
if (name !== undefined) {
|
|
291
|
+
const list = `${known.slice(0, -1).join(', ')} and ${known[known.length - 1]}`;
|
|
292
|
+
throw raise(`${source} has no option named '${name}'; its options are ${list}.`);
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
|
|
258
296
|
/**
|
|
259
297
|
* Describes a value for an error message, leading with the mistake it most likely is.
|
|
260
298
|
* @param {any} value
|
|
@@ -448,7 +486,8 @@ const chain = (effect, fn, initialInput, from) => {
|
|
|
448
486
|
* Composes a list of functions into a single Effect pipeline.
|
|
449
487
|
* Each function receives the output of the previous one.
|
|
450
488
|
*
|
|
451
|
-
* @param {...(input: any) => Effect} fns - Functions that return Success, Failure, Command,
|
|
489
|
+
* @param {...(input: any) => Effect} fns - Functions that each return an Effect: Success, Failure, Command, Ask,
|
|
490
|
+
* Retry, or Parallel.
|
|
452
491
|
* @returns {(start: any) => Effect} A function that accepts an initial input and returns the final Effect tree.
|
|
453
492
|
*/
|
|
454
493
|
const effectPipe = (...fns) => {
|
|
@@ -471,6 +510,9 @@ const effectPipe = (...fns) => {
|
|
|
471
510
|
};
|
|
472
511
|
};
|
|
473
512
|
|
|
513
|
+
// #endregion
|
|
514
|
+
// #region Configuration
|
|
515
|
+
|
|
474
516
|
/**
|
|
475
517
|
* Wraps one Command execution, or one Parallel: `type` is 'Parallel', and `op` runs the branches and
|
|
476
518
|
* returns the Parallel's decision, even when a branch threw, so a hook must call it. Only a replay passes
|
|
@@ -541,7 +583,8 @@ const applyLayers = () => {
|
|
|
541
583
|
* Each call adds one layer on top of those already installed and returns a function that removes that
|
|
542
584
|
* layer, wherever it sits by then. Layers merge the way several configurations passed to one call do:
|
|
543
585
|
* `onStep` and `onRun` are wrappers, so they nest with the earliest layer outermost and the latest
|
|
544
|
-
* closest to the Command; and `onBeforeCommand` interceptors
|
|
586
|
+
* closest to the Command; and `onBeforeCommand` interceptors run in the order installed, the first to throw
|
|
587
|
+
* vetoing the Command. So these
|
|
545
588
|
* are the same:
|
|
546
589
|
*
|
|
547
590
|
* configureEffect(telemetryHooks(), recordingHooks({ sink }));
|
|
@@ -574,65 +617,13 @@ const configureEffect = (...configs) => {
|
|
|
574
617
|
};
|
|
575
618
|
};
|
|
576
619
|
|
|
577
|
-
/**
|
|
578
|
-
* @typedef {Object} StepStart
|
|
579
|
-
* @property {string} name - The Command's identity: `meta.name`, else `cmd.name`, else 'anonymous'.
|
|
580
|
-
* @property {string} type - 'Command', or 'Parallel' for a Parallel's decision.
|
|
581
|
-
* @property {string} [path] - The Command's position in the Effect tree.
|
|
582
|
-
*/
|
|
583
|
-
|
|
584
|
-
/**
|
|
585
|
-
* @typedef {Object} StepEnd
|
|
586
|
-
* @property {string} name
|
|
587
|
-
* @property {string} type
|
|
588
|
-
* @property {string} [path]
|
|
589
|
-
* @property {any} [result] - What the Command returned, when it succeeded.
|
|
590
|
-
* @property {any} [error] - What it threw, when it did not.
|
|
591
|
-
* @property {number} durationMs
|
|
592
|
-
*/
|
|
593
|
-
|
|
594
|
-
const now = () => (typeof performance === 'object' ? performance.now() : Date.now());
|
|
595
|
-
|
|
596
|
-
/**
|
|
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.
|
|
599
|
-
*
|
|
600
|
-
* @param {(start: StepStart) => (end: StepEnd) => void} handler - Returns a finisher for the outcome
|
|
601
|
-
* @returns {StepRunner}
|
|
602
|
-
*/
|
|
603
|
-
const observeSteps = (handler) => async (name, type, op, path) => {
|
|
604
|
-
/** @type {((end: StepEnd) => void) | undefined} */
|
|
605
|
-
let finish;
|
|
606
|
-
try {
|
|
607
|
-
finish = handler({ name, type, path });
|
|
608
|
-
} catch {
|
|
609
|
-
finish = undefined;
|
|
610
|
-
}
|
|
611
|
-
const report = (/** @type {StepEnd} */ end) => {
|
|
612
|
-
try {
|
|
613
|
-
if (finish) finish(end);
|
|
614
|
-
} catch {
|
|
615
|
-
// Observation does not get to decide the outcome, so a broken observer is dropped.
|
|
616
|
-
}
|
|
617
|
-
};
|
|
618
|
-
|
|
619
|
-
const started = now();
|
|
620
|
-
try {
|
|
621
|
-
const result = await op();
|
|
622
|
-
report({ name, type, path, result, durationMs: now() - started });
|
|
623
|
-
return result;
|
|
624
|
-
} catch (error) {
|
|
625
|
-
report({ name, type, path, error, durationMs: now() - started });
|
|
626
|
-
throw error;
|
|
627
|
-
}
|
|
628
|
-
};
|
|
629
|
-
|
|
630
620
|
/**
|
|
631
621
|
* Merges several configurations into one, so independent concerns can share the hooks.
|
|
632
622
|
*
|
|
633
623
|
* `onStep` and `onRun` are wrappers around an `op`, so they nest: the first config given is the
|
|
634
624
|
* outermost and the last sits closest to the Command, so a thrown Command unwinds from the last back
|
|
635
|
-
* to the first. `onBeforeCommand`
|
|
625
|
+
* to the first. `onBeforeCommand` interceptors do not nest: they run in the order given, and the first to
|
|
626
|
+
* throw vetoes the Command, so the ones after it do not run. A hook
|
|
636
627
|
* no config defines is left unset, so the caller keeps its default for that slot.
|
|
637
628
|
*
|
|
638
629
|
* @param {...(EffectConfiguration | undefined)} configs - Configurations to merge, outermost first
|
|
@@ -669,6 +660,9 @@ const chainHooks = (...configs) => {
|
|
|
669
660
|
return merged;
|
|
670
661
|
};
|
|
671
662
|
|
|
663
|
+
// #endregion
|
|
664
|
+
// #region Running flows
|
|
665
|
+
|
|
672
666
|
/**
|
|
673
667
|
* The Failure a cancelled `Parallel` branch stops with. Its error is named so it is never mistaken for
|
|
674
668
|
* the failure that triggered the cancellation.
|
|
@@ -719,7 +713,8 @@ const asDecision = (value, branches) => {
|
|
|
719
713
|
|
|
720
714
|
/**
|
|
721
715
|
* Marks a step a replay reached that production never ran, in a branch a recorded decision cancelled.
|
|
722
|
-
*
|
|
716
|
+
* Only `fromTrace` raises it, and `runCommand` and `runParallel` catch it and stop the branch there, as
|
|
717
|
+
* production did.
|
|
723
718
|
*/
|
|
724
719
|
const replayCut = Symbol('pure-effect.replayCut');
|
|
725
720
|
|
|
@@ -946,6 +941,7 @@ const interpret =
|
|
|
946
941
|
throw new TypeError(`Parallel 'limit' must be a positive integer, received ${describeValue(limit)}.`);
|
|
947
942
|
// Cast rather than annotated, since only `op` assigns it.
|
|
948
943
|
let run = /** @type {BranchRun | undefined} */ (undefined);
|
|
944
|
+
// A replay passes the recorded decision, from `replayEffect`'s onStep; a live run passes nothing.
|
|
949
945
|
const op = async (/** @type {any} */ recorded) => {
|
|
950
946
|
run = await runBranches(parallel.effects, options, signal, branchPath, recorded);
|
|
951
947
|
return run.decision;
|
|
@@ -953,7 +949,7 @@ const interpret =
|
|
|
953
949
|
try {
|
|
954
950
|
await localStepRunner('Parallel', 'Parallel', op, branchPath);
|
|
955
951
|
} catch (e) {
|
|
956
|
-
// A replay found this Parallel inside a branch production had already stopped.
|
|
952
|
+
// A cut from `fromTrace`: a replay found this Parallel inside a branch production had already stopped.
|
|
957
953
|
if (hasMark(e, replayCut)) return cancelledBranch(parallel.initialInput);
|
|
958
954
|
throw e;
|
|
959
955
|
}
|
|
@@ -988,7 +984,8 @@ const interpret =
|
|
|
988
984
|
* @param {ParallelOptions} options
|
|
989
985
|
* @param {AbortSignal | undefined} signal - The enclosing Parallel's cancellation, if any
|
|
990
986
|
* @param {string} branchPath
|
|
991
|
-
* @param {any} recorded - The recorded decision,
|
|
987
|
+
* @param {any} recorded - The recorded decision, which `replayEffect`'s onStep passes to the Parallel's
|
|
988
|
+
* `op`; undefined in a live run
|
|
992
989
|
* @returns {Promise<BranchRun>}
|
|
993
990
|
*/
|
|
994
991
|
async function runBranches(effects, options, signal, branchPath, recorded) {
|
|
@@ -999,10 +996,10 @@ const interpret =
|
|
|
999
996
|
const pastTheEnd = Number.isInteger(recordedBranch) && recordedBranch >= effects.length;
|
|
1000
997
|
if (pastTheEnd) {
|
|
1001
998
|
const count = effects.length === 1 ? '1 branch' : `${effects.length} branches`;
|
|
1002
|
-
throw
|
|
1003
|
-
`
|
|
1004
|
-
|
|
1005
|
-
{
|
|
999
|
+
throw timeParadoxAt(
|
|
1000
|
+
`path '${branchPath}'`,
|
|
1001
|
+
`the recorded run was cancelled by branch ${recordedBranch}, but this Parallel has ${count}.`,
|
|
1002
|
+
{ path: branchPath, branch: recordedBranch }
|
|
1006
1003
|
);
|
|
1007
1004
|
}
|
|
1008
1005
|
const forced = asDecision(recorded, effects.length);
|
|
@@ -1022,10 +1019,10 @@ const interpret =
|
|
|
1022
1019
|
const recordedTriggerSucceeded =
|
|
1023
1020
|
!thrown && forced.branch !== null && results[forced.branch].type === 'Success';
|
|
1024
1021
|
if (recordedTriggerSucceeded) {
|
|
1025
|
-
throw
|
|
1026
|
-
`
|
|
1027
|
-
|
|
1028
|
-
{
|
|
1022
|
+
throw timeParadoxAt(
|
|
1023
|
+
`path '${branchPath}'`,
|
|
1024
|
+
`the recorded run was cancelled by branch ${forced.branch}, which did not fail in this replay.`,
|
|
1025
|
+
{ path: branchPath, branch: forced.branch }
|
|
1029
1026
|
);
|
|
1030
1027
|
}
|
|
1031
1028
|
return { results, decision: forced, thrown };
|
|
@@ -1115,15 +1112,28 @@ const interpret =
|
|
|
1115
1112
|
// What it returned, so a hook that loses it is caught.
|
|
1116
1113
|
/** @type {unknown} */
|
|
1117
1114
|
let value;
|
|
1118
|
-
// The signal
|
|
1119
|
-
//
|
|
1120
|
-
//
|
|
1121
|
-
|
|
1115
|
+
// The signal goes only to a function that declares a parameter for it, and only inside a Parallel. A
|
|
1116
|
+
// parameter with a default value does not count toward `length`, so `nanoid(size = 21)` passed by name
|
|
1117
|
+
// keeps its default rather than reading the signal as its size. A plain first parameter the function
|
|
1118
|
+
// treats as optional still takes the signal: a documented sharp edge.
|
|
1119
|
+
const takesSignal = signal !== undefined && cmd.length > 0;
|
|
1120
|
+
// Async, so a hook always gets a promise, even from a synchronous function. The latest call is kept,
|
|
1121
|
+
// with whether it is still running, so a hook that does not wait for it is caught.
|
|
1122
|
+
/** @type {Promise<unknown> | undefined} */
|
|
1123
|
+
let call;
|
|
1124
|
+
let running = false;
|
|
1125
|
+
const run = async () => {
|
|
1122
1126
|
succeeded = false;
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1127
|
+
running = true;
|
|
1128
|
+
try {
|
|
1129
|
+
value = await (takesSignal ? cmd(signal) : cmd());
|
|
1130
|
+
succeeded = true;
|
|
1131
|
+
return value;
|
|
1132
|
+
} finally {
|
|
1133
|
+
running = false;
|
|
1134
|
+
}
|
|
1126
1135
|
};
|
|
1136
|
+
const op = () => (call = run());
|
|
1127
1137
|
try {
|
|
1128
1138
|
await localCommandInterceptor(command, context);
|
|
1129
1139
|
} catch (e) {
|
|
@@ -1133,10 +1143,18 @@ const interpret =
|
|
|
1133
1143
|
// Again, since an interceptor can wait (a rate limiter, say) while a sibling fails.
|
|
1134
1144
|
if (signal?.aborted) return cancelledBranch(initialInput);
|
|
1135
1145
|
let returned;
|
|
1146
|
+
let unwaited = false;
|
|
1136
1147
|
try {
|
|
1137
1148
|
returned = await localStepRunner(cmdName, 'Command', op, cmdPath);
|
|
1149
|
+
// A hook that called `op` without waiting for it returned while the Command ran, so the check below
|
|
1150
|
+
// would have nothing to check yet. Waiting here judges the step as though the hook had awaited `op`.
|
|
1151
|
+
if (returned === undefined && running) {
|
|
1152
|
+
unwaited = true;
|
|
1153
|
+
await call;
|
|
1154
|
+
}
|
|
1138
1155
|
} catch (e) {
|
|
1139
|
-
// A step production never ran, in a branch a Parallel cancelled
|
|
1156
|
+
// A cut from `fromTrace`: a step production never ran, in a branch a Parallel cancelled. Stop here,
|
|
1157
|
+
// as production did.
|
|
1140
1158
|
if (hasMark(e, replayCut)) return cancelledBranch(initialInput);
|
|
1141
1159
|
if (hasMark(e, harnessError)) throw e;
|
|
1142
1160
|
// A hook threw after the function returned, which is a bug in the hook. A hook that throws
|
|
@@ -1149,8 +1167,9 @@ const interpret =
|
|
|
1149
1167
|
// refused, so a hook that returns a copy of the result still works.
|
|
1150
1168
|
if (returned === undefined && succeeded && value !== undefined) {
|
|
1151
1169
|
throw new TypeError(
|
|
1152
|
-
`An onStep hook called op for '${cmdName}' at path '${cmdPath}' and returned undefined
|
|
1153
|
-
'
|
|
1170
|
+
`An onStep hook called op for '${cmdName}' at path '${cmdPath}' and returned undefined` +
|
|
1171
|
+
`${unwaited ? ' before op had finished' : ''}, although the Command returned a value. ` +
|
|
1172
|
+
'A hook has to await op() and return what it returns.'
|
|
1154
1173
|
);
|
|
1155
1174
|
}
|
|
1156
1175
|
return Success(returned);
|
|
@@ -1179,6 +1198,9 @@ const interpret =
|
|
|
1179
1198
|
*/
|
|
1180
1199
|
const runEffect = (effect, context, callConfig) => interpret(effect, context, callConfig);
|
|
1181
1200
|
|
|
1201
|
+
// #endregion
|
|
1202
|
+
// #region Recording and replay
|
|
1203
|
+
|
|
1182
1204
|
/**
|
|
1183
1205
|
* The step a replay is asking about. `path` is the Command's position in the Effect tree and is stable
|
|
1184
1206
|
* across runs; `index` is its position in this run's completion order, which is not stable for a flow
|
|
@@ -1244,25 +1266,32 @@ const replayError = (message, props = {}) =>
|
|
|
1244
1266
|
);
|
|
1245
1267
|
|
|
1246
1268
|
/**
|
|
1247
|
-
*
|
|
1248
|
-
*
|
|
1269
|
+
* Builds a `TimeParadox`: the replay reached a point where the flow no longer matches its recording, which
|
|
1270
|
+
* means the code has diverged from the recorded run.
|
|
1271
|
+
* @param {string} at - Where, as the message names it
|
|
1272
|
+
* @param {string} detail - How the flow and the recording disagree
|
|
1273
|
+
* @param {Object} props - The fields that locate it, such as `path`
|
|
1274
|
+
* @returns {Error}
|
|
1275
|
+
*/
|
|
1276
|
+
const timeParadoxAt = (at, detail, props) =>
|
|
1277
|
+
replayError(`Time paradox at ${at}: ${detail}`, { name: 'TimeParadox', ...props });
|
|
1278
|
+
|
|
1279
|
+
/**
|
|
1280
|
+
* Signals that the flow being replayed asked for a different Command than the trace recorded.
|
|
1249
1281
|
* @param {ReplayStep} step - The step the flow asked for
|
|
1250
1282
|
* @param {string} recorded - The command name the trace holds at that position
|
|
1251
1283
|
* @returns {Error}
|
|
1252
1284
|
*/
|
|
1253
1285
|
const timeParadox = (step, recorded) =>
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
{
|
|
1258
|
-
name: 'TimeParadox',
|
|
1259
|
-
index: step.index,
|
|
1260
|
-
path: step.path,
|
|
1261
|
-
expected: recorded,
|
|
1262
|
-
actual: step.name
|
|
1263
|
-
}
|
|
1286
|
+
timeParadoxAt(
|
|
1287
|
+
step.path !== undefined ? `path '${step.path}'` : `step ${step.index}`,
|
|
1288
|
+
`flow asked for '${step.name}', trace recorded '${recorded}'`,
|
|
1289
|
+
{ index: step.index, path: step.path, expected: recorded, actual: step.name }
|
|
1264
1290
|
);
|
|
1265
1291
|
|
|
1292
|
+
/** The keys `serializeError` carries whatever their enumerability, which a native Error sets as hidden. */
|
|
1293
|
+
const carriedKeys = ['name', 'message', 'cause'];
|
|
1294
|
+
|
|
1266
1295
|
/**
|
|
1267
1296
|
* Converts a thrown value into something JSON can carry. `message` and `stack` are
|
|
1268
1297
|
* non-enumerable on Error, so a plain `JSON.stringify` would silently drop them.
|
|
@@ -1301,9 +1330,6 @@ const serializeError = (e, withStack, ancestors = new Set()) => {
|
|
|
1301
1330
|
return out;
|
|
1302
1331
|
};
|
|
1303
1332
|
|
|
1304
|
-
/** The keys `serializeError` carries whatever their enumerability, which a native Error sets as hidden. */
|
|
1305
|
-
const carriedKeys = ['name', 'message', 'cause'];
|
|
1306
|
-
|
|
1307
1333
|
/**
|
|
1308
1334
|
* Rebuilds an Error from `serializeError` output. Non-Error values pass through, so a
|
|
1309
1335
|
* Command that rejected with a string still replays as a string.
|
|
@@ -1409,15 +1435,70 @@ const snapshot = (value) => {
|
|
|
1409
1435
|
};
|
|
1410
1436
|
|
|
1411
1437
|
/**
|
|
1412
|
-
*
|
|
1413
|
-
*
|
|
1414
|
-
*
|
|
1415
|
-
*
|
|
1438
|
+
* @typedef {Object} StepStart
|
|
1439
|
+
* @property {string} name - The Command's identity: `meta.name`, else `cmd.name`, else 'anonymous'.
|
|
1440
|
+
* @property {string} type - 'Command', or 'Parallel' for a Parallel's decision.
|
|
1441
|
+
* @property {string} [path] - The Command's position in the Effect tree.
|
|
1442
|
+
*/
|
|
1443
|
+
|
|
1444
|
+
/**
|
|
1445
|
+
* @typedef {Object} StepEnd
|
|
1446
|
+
* @property {string} name
|
|
1447
|
+
* @property {string} type
|
|
1448
|
+
* @property {string} [path]
|
|
1449
|
+
* @property {any} [result] - What the Command returned, when it succeeded.
|
|
1450
|
+
* @property {any} [error] - What it threw, when it did not.
|
|
1451
|
+
* @property {number} durationMs
|
|
1452
|
+
*/
|
|
1453
|
+
|
|
1454
|
+
const now = () => (typeof performance === 'object' ? performance.now() : Date.now());
|
|
1455
|
+
|
|
1456
|
+
/**
|
|
1457
|
+
* Wraps a step observer into an `onStep` that cannot change the run: `op` always runs, its result is
|
|
1458
|
+
* returned, its error propagates, and anything the observer throws is dropped.
|
|
1459
|
+
*
|
|
1460
|
+
* @param {(start: StepStart) => (end: StepEnd) => void} handler - Returns a finisher for the outcome
|
|
1461
|
+
* @returns {StepRunner}
|
|
1462
|
+
*/
|
|
1463
|
+
const observeSteps = (handler) => async (name, type, op, path) => {
|
|
1464
|
+
/** @type {((end: StepEnd) => void) | undefined} */
|
|
1465
|
+
let finish;
|
|
1466
|
+
try {
|
|
1467
|
+
finish = handler({ name, type, path });
|
|
1468
|
+
} catch {
|
|
1469
|
+
finish = undefined;
|
|
1470
|
+
}
|
|
1471
|
+
const report = (/** @type {StepEnd} */ end) => {
|
|
1472
|
+
try {
|
|
1473
|
+
if (finish) finish(end);
|
|
1474
|
+
} catch {
|
|
1475
|
+
// Observation does not get to decide the outcome, so a broken observer is dropped.
|
|
1476
|
+
}
|
|
1477
|
+
};
|
|
1478
|
+
|
|
1479
|
+
const started = now();
|
|
1480
|
+
try {
|
|
1481
|
+
const result = await op();
|
|
1482
|
+
report({ name, type, path, result, durationMs: now() - started });
|
|
1483
|
+
return result;
|
|
1484
|
+
} catch (error) {
|
|
1485
|
+
report({ name, type, path, error, durationMs: now() - started });
|
|
1486
|
+
throw error;
|
|
1487
|
+
}
|
|
1488
|
+
};
|
|
1489
|
+
|
|
1490
|
+
/**
|
|
1491
|
+
* Builds an `onStep` hook that records every Command's result or error and every Parallel's decision, plus
|
|
1492
|
+
* a packager for the reference trace format. Pass `onStep` to `runEffect` as per-call config. A recorder holds
|
|
1493
|
+
* the steps of every run it sees, so installed with `configureEffect` for a whole application it mixes requests
|
|
1494
|
+
* into one trace with duplicate paths, which a replay refuses; examples/recording-example.js gives each run its
|
|
1495
|
+
* own.
|
|
1416
1496
|
*
|
|
1417
1497
|
* @param {RecorderOptions} [options] - Redaction and size limits
|
|
1418
1498
|
* @returns {{ onStep: StepRunner, entries: TraceEntry[], toTrace: (meta?: TraceMeta) => TraceLog }}
|
|
1419
1499
|
*/
|
|
1420
1500
|
const recorder = (options = {}) => {
|
|
1501
|
+
rejectUnknownOptions(options, 'recorder', ['redact', 'maxEntries', 'stack']);
|
|
1421
1502
|
const { redact = (/** @type {any} */ r) => r, maxEntries = Infinity, stack = false } = options;
|
|
1422
1503
|
/** @type {TraceEntry[]} */
|
|
1423
1504
|
const entries = [];
|
|
@@ -1493,6 +1574,7 @@ const recorder = (options = {}) => {
|
|
|
1493
1574
|
* @returns {Promise<{ result: SuccessState | FailureState, trace: TraceLog }>}
|
|
1494
1575
|
*/
|
|
1495
1576
|
const recordEffect = async (flowFn, initialInput, options = {}) => {
|
|
1577
|
+
rejectUnknownOptions(options, 'recordEffect', ['context', 'version', 'redact', 'maxEntries', 'stack']);
|
|
1496
1578
|
const { context = {}, version, ...recorderOptions } = options;
|
|
1497
1579
|
const rec = recorder(recorderOptions);
|
|
1498
1580
|
// Packaged before the run, so a Command that writes to the input or the context, as an ORM save
|
|
@@ -1583,7 +1665,8 @@ const fromTrace = (traceLog, options = {}) => {
|
|
|
1583
1665
|
/** Recorded Parallel decisions that cancelled branches, by the Parallel's path. */
|
|
1584
1666
|
const cancellations = entries.filter(isDecisionEntry).filter((e) => e.result?.cancelled === true);
|
|
1585
1667
|
// A missing step is where production stopped a branch when it lies in a branch a recorded decision
|
|
1586
|
-
// cancelled: any branch but the cancelling one, or every branch of a Parallel cancelled from outside.
|
|
1668
|
+
// cancelled: any branch but the cancelling one, or every branch of a Parallel cancelled from outside. Such a
|
|
1669
|
+
// step throws a cut, which `runCommand` or `runParallel` catches to stop the branch.
|
|
1587
1670
|
const stoppedInProduction = (/** @type {string | undefined} */ stepPath) =>
|
|
1588
1671
|
typeof stepPath === 'string' &&
|
|
1589
1672
|
cancellations.some((e) => {
|
|
@@ -1595,7 +1678,8 @@ const fromTrace = (traceLog, options = {}) => {
|
|
|
1595
1678
|
const resolve = (step) => {
|
|
1596
1679
|
const entry = byPath.get(step.path);
|
|
1597
1680
|
if (step.type === 'Parallel') {
|
|
1598
|
-
// A decision rather than I/O.
|
|
1681
|
+
// A decision rather than I/O. `replayEffect` hands a recorded one to the Parallel's `op`, and
|
|
1682
|
+
// `runBranches` reproduces it; with none recorded, the Parallel replays under timing.
|
|
1599
1683
|
if (entry && entry.command !== 'Parallel') throw timeParadox(step, entry.command);
|
|
1600
1684
|
if (entry) return resolveEntry(entry);
|
|
1601
1685
|
if (stoppedInProduction(step.path)) throw replayCutError(/** @type {string} */ (step.path));
|
|
@@ -1635,6 +1719,35 @@ const fromTrace = (traceLog, options = {}) => {
|
|
|
1635
1719
|
return { resolve, missing };
|
|
1636
1720
|
};
|
|
1637
1721
|
|
|
1722
|
+
/**
|
|
1723
|
+
* The error for a step a replay has no recorded outcome for, which it refuses to run live.
|
|
1724
|
+
* @param {ReplayStep} step
|
|
1725
|
+
* @param {((step: ReplayStep) => string) | undefined} describe - `fromTrace`'s `missing`; a Resolver has none
|
|
1726
|
+
* @param {number} droppedEntries - How many entries the trace dropped under `maxEntries`
|
|
1727
|
+
* @returns {Error}
|
|
1728
|
+
*/
|
|
1729
|
+
const missingStepError = (step, describe, droppedEntries) => {
|
|
1730
|
+
const what = describe ? describe(step) : `No recorded outcome for '${step.name}' at step ${step.index}`;
|
|
1731
|
+
// Why the step may be missing decides the fix. A capped trace needs a higher cap, since the step may be one
|
|
1732
|
+
// production ran; any other missing step may be one production never ran, and running it live against
|
|
1733
|
+
// production would do I/O production refused.
|
|
1734
|
+
const why =
|
|
1735
|
+
droppedEntries > 0
|
|
1736
|
+
? `The trace dropped ${droppedEntries} entries under maxEntries, so production may have run ` +
|
|
1737
|
+
'this step and the recorder not kept it; record the flow with a higher maxEntries to ' +
|
|
1738
|
+
'replay past it.'
|
|
1739
|
+
: 'Production may never have run it: a Command an onBeforeCommand hook vetoed leaves no ' +
|
|
1740
|
+
'entry, and neither does a step added since the recording or one the flow reaches now that ' +
|
|
1741
|
+
"it did not then. onMissing: 'execute' runs such a step for real, and after an added step " +
|
|
1742
|
+
'every step that follows it, since they all move to new paths, so pass it only where every ' +
|
|
1743
|
+
'Command the flow can still reach goes to a test double or only reads.';
|
|
1744
|
+
return replayError(`${what}; refusing to run the real Command. ${why}`, {
|
|
1745
|
+
command: step.name,
|
|
1746
|
+
index: step.index,
|
|
1747
|
+
path: step.path
|
|
1748
|
+
});
|
|
1749
|
+
};
|
|
1750
|
+
|
|
1638
1751
|
/**
|
|
1639
1752
|
* @typedef {Object} ReplayOptions
|
|
1640
1753
|
* @property {any} [context] - Context for `Ask`; defaults to the context a trace recorded.
|
|
@@ -1685,6 +1798,7 @@ const fromTrace = (traceLog, options = {}) => {
|
|
|
1685
1798
|
*/
|
|
1686
1799
|
// `async` so a malformed trace arrives as a rejection rather than a synchronous throw.
|
|
1687
1800
|
const replayEffect = async (effect, traceOrResolver, options = {}) => {
|
|
1801
|
+
rejectUnknownOptions(options, 'replayEffect', ['context', 'fastRetry', 'hooks', 'onMissing', 'onResolved']);
|
|
1688
1802
|
const { fastRetry = true, hooks = false, onMissing = 'throw', onResolved } = options;
|
|
1689
1803
|
// A trace is data and a Resolver is a function, so nothing else is needed to tell them
|
|
1690
1804
|
// apart, including the bare entries array that `fromTrace` also accepts.
|
|
@@ -1719,7 +1833,7 @@ const replayEffect = async (effect, traceOrResolver, options = {}) => {
|
|
|
1719
1833
|
/** @type {StepRunner} */
|
|
1720
1834
|
const onStep = async (name, type, op, path) => {
|
|
1721
1835
|
if (type === 'Parallel') {
|
|
1722
|
-
// A Parallel's step carries its recorded decision into `op`,
|
|
1836
|
+
// A Parallel's step carries its recorded decision into `op`, where `runBranches` reproduces it.
|
|
1723
1837
|
// It is not a Command: `index` still counts Commands, and `onResolved` still sees only them.
|
|
1724
1838
|
const outcome = resolve({ index, name, type, path });
|
|
1725
1839
|
return await op(outcome && 'result' in outcome ? outcome.result : undefined);
|
|
@@ -1735,24 +1849,7 @@ const replayEffect = async (effect, traceOrResolver, options = {}) => {
|
|
|
1735
1849
|
}
|
|
1736
1850
|
}
|
|
1737
1851
|
if (outcome === undefined) {
|
|
1738
|
-
if (onMissing !== 'execute')
|
|
1739
|
-
const what = missing ? missing(step) : `No recorded outcome for '${name}' at step ${step.index}`;
|
|
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
|
-
});
|
|
1755
|
-
}
|
|
1852
|
+
if (onMissing !== 'execute') throw missingStepError(step, missing, droppedEntries);
|
|
1756
1853
|
return await op();
|
|
1757
1854
|
}
|
|
1758
1855
|
if ('error' in outcome) throw outcome.error;
|
|
@@ -1795,6 +1892,7 @@ const replayEffect = async (effect, traceOrResolver, options = {}) => {
|
|
|
1795
1892
|
* a caller who wants the unreached entries as data uses `replayEffect`.
|
|
1796
1893
|
*/
|
|
1797
1894
|
const timeTravel = async (flowFn, traceLog, options = {}) => {
|
|
1895
|
+
rejectUnknownOptions(options, 'timeTravel', ['log', 'context', 'version']);
|
|
1798
1896
|
const { log = console.log, context, version } = options;
|
|
1799
1897
|
const { initialInput, trace, flowName, version: traceVersion } = traceLog;
|
|
1800
1898
|
// `message` is non-enumerable on Error, so JSON.stringify alone would drop it. JSON.stringify throws on
|
|
@@ -1857,8 +1955,9 @@ const timeTravel = async (flowFn, traceLog, options = {}) => {
|
|
|
1857
1955
|
log(`Replay finished with state: ${result.type}`);
|
|
1858
1956
|
log(result.type === 'Failure' ? `Error: ${format(result.error)}` : `Result: ${format(result.value)}`);
|
|
1859
1957
|
// 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.
|
|
1861
|
-
|
|
1958
|
+
// split. Only a flow that ended on its own terms with steps left over is worth a warning. Told by the
|
|
1959
|
+
// mark rather than the name, which a flow's own error can share.
|
|
1960
|
+
const haltedByReplay = result.type === 'Failure' && hasMark(result.error, replayFault);
|
|
1862
1961
|
if (unreached.length > 0 && !haltedByReplay) {
|
|
1863
1962
|
// Named, not just counted: the step a fix stopped issuing is usually the one under suspicion.
|
|
1864
1963
|
const names = unreached.map((e) => (hasPath(e) ? `${e.command} (path '${e.path}')` : e.command));
|
|
@@ -1868,6 +1967,8 @@ const timeTravel = async (flowFn, traceLog, options = {}) => {
|
|
|
1868
1967
|
return result;
|
|
1869
1968
|
};
|
|
1870
1969
|
|
|
1970
|
+
// #endregion
|
|
1971
|
+
|
|
1871
1972
|
export {
|
|
1872
1973
|
Success,
|
|
1873
1974
|
Failure,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pure-effect",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "Records what your business logic did in production and replays it anywhere: time-travel debugging for JavaScript and TypeScript, with zero dependencies. Business logic is plain data you can test without mocks.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"effect",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"test": "mocha && tsd && tsc -p jsconfig.json",
|
|
32
32
|
"test:ts-minimum": "npx --yes -p typescript@5.1 tsc -p test/tsconfig.minimum.json",
|
|
33
33
|
"generate": "node scripts/effect-pipe-overloads.js",
|
|
34
|
-
"format": "prettier --write index.js index.d.ts examples/*.js scripts/*.js test/all.js test/types.test-d.ts README.md CLAUDE.md CHANGELOG.md CONTRIBUTING.md"
|
|
34
|
+
"format": "prettier --write index.js index.d.ts examples/*.js scripts/*.js test/all.js test/types.test-d.ts README.md CLAUDE.md DESIGN.md CHANGELOG.md CONTRIBUTING.md"
|
|
35
35
|
},
|
|
36
36
|
"tsd": {
|
|
37
37
|
"directory": "test"
|