pure-effect 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +6 -4
- package/README.md +28 -17
- package/index.d.ts +35 -9
- package/index.js +201 -39
- package/package.json +1 -1
- package/test/all.js +274 -8
package/CLAUDE.md
CHANGED
|
@@ -14,7 +14,7 @@ npx esbuild index.js --minify --format=esm | gzip -9 | wc -c # the size the R
|
|
|
14
14
|
|
|
15
15
|
No build or lint step: the library ships as plain ES modules with no transpilation. Formatting is Prettier, configured in `.prettierrc`; match it when editing.
|
|
16
16
|
|
|
17
|
-
The README's badge and feature bullet both state a minified-and-gzipped ceiling, currently under
|
|
17
|
+
The README's badge and feature bullet both state a minified-and-gzipped ceiling, currently under 4 KB. Re-measure with the command above after adding anything to `index.js`, because the badge is a static `img.shields.io/badge/` URL with the number written into it rather than a live query, so it cannot correct itself.
|
|
18
18
|
|
|
19
19
|
**Markdown prose has no hard line breaks.** Write each paragraph and each list item as one line and let the editor soft-wrap; never wrap prose at a column. Hard breaks make later edits reflow whole paragraphs, turning a one-word change into a multi-line diff. `proseWrap: never` enforces this, so `npm run format` unwraps any prose that arrives wrapped. Even so, write it unwrapped in the first place rather than relying on the sweep, since a wrapped paragraph reads as an intended shape until something reflows it. Fenced code blocks are untouched by the setting, and table rows stay one per line; only prose joins.
|
|
20
20
|
|
|
@@ -33,7 +33,7 @@ The README's badge and feature bullet both state a minified-and-gzipped ceiling,
|
|
|
33
33
|
| `Command(cmdFn, nextFn, meta)` | `{ type: 'Command', cmd, next, meta }` | Defers a side effect for the interpreter; `meta.name` sets the Command's identity |
|
|
34
34
|
| `Ask(nextFn)` | `{ type: 'Ask', next }` | Reads the `context` passed to `runEffect`; passes it to `nextFn` |
|
|
35
35
|
| `Retry(effect, options)` | `{ type: 'Retry', effect, options, next }` | Wraps any Effect tree with retry-on-failure semantics; handled natively by the interpreter |
|
|
36
|
-
| `Parallel(effects, next)` | `{ type: 'Parallel', effects, next }` | Runs multiple Effect trees concurrently
|
|
36
|
+
| `Parallel(effects, next)` | `{ type: 'Parallel', effects, next }` | Runs multiple Effect trees concurrently; the first branch to fail cancels its siblings and its Failure is returned; context flows into all branches |
|
|
37
37
|
| `effectPipe(...fns)` | n/a | Composes functions into a sequential pipeline via `chain` |
|
|
38
38
|
| `runEffect(effect, context, callConfig?)` | async | Interpreter: traverses the effect tree, executes Commands; resolves `Ask` and `Retry`; per-call `callConfig` overrides global defaults |
|
|
39
39
|
| `configureEffect(...configs)` | n/a | Sets process-wide telemetry hooks (`onStep`, `onRun`, `onBeforeCommand`) and global `retry` defaults; merges several configurations into one wiring; returns a restore function; overridden by per-call `callConfig` |
|
|
@@ -122,7 +122,7 @@ Three semantics are surprising enough that `Documented sharp edges` in the test
|
|
|
122
122
|
|
|
123
123
|
`Retry` repeats the entire wrapped tree on every attempt, including Commands that already succeeded, because the retry loop calls `execute(eff.effect)` again rather than resuming. `Retry(effectPipe(charge, receipt))` charges three times when the receipt step fails twice, and the run still reports `Success`. The semantics are defensible, since retry means run this tree again, but the README's "wraps any Effect tree" invites exactly the dangerous construction, so both the JSDoc and the Retry section now say to wrap the failing Command instead. If this is ever changed to resume rather than repeat, the pinning test is the one to read first.
|
|
124
124
|
|
|
125
|
-
`Parallel`
|
|
125
|
+
`Parallel` cancellation is cooperative. The first branch to fail aborts an `AbortController` scoped to that `Parallel`, linked to the enclosing one so cancellation nests. Two levels of effect follow. A cancelled branch starts no further Commands, checked at the top of `execute`'s loop, which needs nothing from user code and is where most of the benefit is. Stopping the Command already in flight needs its thunk to accept the `AbortSignal` it is handed, which is why `cmd` is now called as `cmd(signal)` inside a `Parallel` and with no argument anywhere else: passing an argument only where it means something keeps a thunk written as `(x) => ...` from silently receiving one. A thunk that ignores the signal runs to completion, so a branch whose first Command is a write can still write after a sibling has failed, which is what the pinning test covers. `Parallel` still awaits every branch before returning, deliberately: returning early would leave cancelled work running unobserved after the Failure, with its rejections unhandled. The triggering branch is tracked separately from branches that failed because they were cancelled, so a cancellation never displaces the real error; when several branches fail in the same tick the first by array order wins, which is the older documented behaviour and is pinned.
|
|
126
126
|
|
|
127
127
|
Every `Failure` carries `initialInput`, stamped by `chain`, alongside the full error. Neither is trimmed and neither should be: a test asserting on a Failure and a developer reading one both need the whole thing. PII control is split deliberately. `redact` is the single mechanism for a trace, and the shell owns what reaches logs, so the guidance is to log `result.error` rather than serialize a whole Failure.
|
|
128
128
|
|
|
@@ -135,7 +135,9 @@ Because a flow is data, a production run can be recorded and replayed later. Rec
|
|
|
135
135
|
Four invariants hold the design together; breaking any of them breaks replay in a way tests may not catch:
|
|
136
136
|
|
|
137
137
|
- **Replay drives the real interpreter.** `replayEffect` calls `runEffect` rather than walking the tree itself, which is why `Ask`, `Retry`, and `Parallel` replay correctly and why replay semantics cannot drift from execution semantics. The interpreter's only execution point is `await localStepRunner(cmdName, 'Command', eff.cmd)`, which hands the Command thunk to `onStep` as `op` instead of calling it. Replay's `onStep` never invokes `op` unless `onMissing: 'execute'`, so **no side effect can occur by default**. Preserve that single-execution-point property: a Command executed anywhere else in the interpreter would be invisible to both recording and replay.
|
|
138
|
-
- **
|
|
138
|
+
- **Path matching, with the two older modes kept for legacy traces.** Every step carries a `path`: its position in the Effect tree, numbered sequentially within a subtree, with each `Parallel` branch and each `Retry` attempt opening its own prefix (`0p1/0r2/0` is branch 1, retry attempt 2, first Command). A path depends only on the shape of the tree, never on the order branches finish in, so it is order-independent **and** paradox-detecting at the same time. `fromTrace` matches by path whenever every entry has one, and it does so whatever `strict` says, deliberately: a caller who passed `strict: false` to work around `Parallel` should get correct matching once their traces carry paths rather than keeping the mode that option existed to work around. Duplicate paths throw, since a collision would reintroduce the failure paths exist to prevent. The older modes remain only for traces recorded before paths existed: `strict: true` consumes entries positionally, `strict: false` resolves per-Command FIFO queues. **The name-queue mode is the bug paths fixed.** It paired a step with the next recorded entry of the same name, which is completion order, so two `Parallel` branches calling the same Command swapped results whenever replay timing differed from production, and the replay still reported `Success`. `strict` is ignored when a `Resolver` is passed, because that `Resolver` has already chosen its own matching.
|
|
139
|
+
|
|
140
|
+
- **The hook contract carries the path as a fourth parameter.** `onStep(name, type, op, path)`. Three-parameter hooks keep working, and `chainHooks` forwards the path through nested wrappers. A hand-rolled wrapper that calls `inner(name, type, op)` without forwarding `path` silently produces a trace with no paths, which then falls back to legacy positional matching: correct for sequential flows, and the old swapping bug for `Parallel`. Forward the fourth argument in anything that wraps `onStep`.
|
|
139
141
|
- **`fastRetry` exists because of merge order.** The interpreter merges per-use options over call config (`{ ...localRetryDefaults, ...eff.options }`), so a `callConfig.retry` cannot override a delay written at the call site. `zeroRetryDelays` therefore rewrites `Retry` nodes to `delay: 0, backoff: 1` lazily, through their `next` continuations; without it, replaying a flow that retried in production waits out the production backoff.
|
|
140
142
|
- **The outcome wrapper is load-bearing.** A `Resolver` returns `{ result }`, `{ error }`, or `undefined` for "not recorded". The wrapper keeps a Command that legitimately returned `undefined` (`{ result: undefined }`) distinct from having no recording, which is what makes the `onMissing` guard trustworthy.
|
|
141
143
|
|
package/README.md
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
# Pure Effect
|
|
2
2
|
|
|
3
|
-
[](https://www.npmjs.com/package/pure-effect) [](https://www.npmjs.com/package/pure-effect) [](https://bundlephobia.com/package/pure-effect) [](https://github.com/aycangulez/pure-effect/blob/main/LICENSE)
|
|
4
4
|
|
|
5
5
|
**Pure Effect** is a zero-dependency effect library for JavaScript and TypeScript with time-travel debugging, dependency injection, retry, and OpenTelemetry, where business logic is plain data you can test without mocks.
|
|
6
6
|
|
|
7
7
|
- Replay a production failure locally, with no database and no network
|
|
8
8
|
- No mocks needed to test async pipelines
|
|
9
9
|
- Inject context without touching function signatures
|
|
10
|
-
- Built-in retry
|
|
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, under
|
|
12
|
+
- Zero dependencies, under 4 KB minified and gzipped
|
|
13
13
|
- Works in JavaScript and TypeScript (full generics, bundled `.d.ts`)
|
|
14
14
|
|
|
15
15
|
## Table of Contents
|
|
@@ -47,19 +47,21 @@ const validateRegistration = (input) => {
|
|
|
47
47
|
return Success(input);
|
|
48
48
|
};
|
|
49
49
|
|
|
50
|
-
// These functions return a Command object. They do NOT call the database.
|
|
51
|
-
// Name the
|
|
50
|
+
// These following two functions return a Command object. They do NOT call the database.
|
|
51
|
+
// Name the commands since traces, replay matching, and telemetry spans are keyed on them.
|
|
52
52
|
const ensureEmailAvailable = (input) => {
|
|
53
53
|
const cmdFindUser = () => db.findUser(input.email);
|
|
54
|
-
|
|
54
|
+
const next = (found) => (found ? Failure('Email already in use.') : Success(input));
|
|
55
|
+
return Command(cmdFindUser, next);
|
|
55
56
|
};
|
|
56
57
|
|
|
57
|
-
// With no continuation, the result passes straight through.
|
|
58
|
+
// With no continuation (next), the result passes straight through.
|
|
58
59
|
const saveUser = (input) => {
|
|
59
60
|
const cmdSaveUser = () => db.saveUser(input);
|
|
60
61
|
return Command(cmdSaveUser);
|
|
61
62
|
};
|
|
62
63
|
|
|
64
|
+
// Create flow (not executed yet)
|
|
63
65
|
const registerUserFlow = (input) => effectPipe(validateRegistration, ensureEmailAvailable, saveUser)(input);
|
|
64
66
|
|
|
65
67
|
// Imperative shell: this is the only place side effects run
|
|
@@ -125,7 +127,7 @@ it('prod incident 8f3a: a 100% promo produces a $0 charge', async () => {
|
|
|
125
127
|
});
|
|
126
128
|
```
|
|
127
129
|
|
|
128
|
-
The test verifies that the flow still takes the recorded path and handles the recorded outcomes the same way: a refactor that reorders or drops a step raises a `TimeParadox
|
|
130
|
+
The test verifies that the flow still takes the recorded path and handles the recorded outcomes the same way: a refactor that reorders or drops a step raises a `TimeParadox` naming the path it diverged at, and changed error handling fails the assertion.
|
|
129
131
|
|
|
130
132
|
**Nondeterminism belongs inside a Command.** A value that varies between runs (the current time, a random ID) is I/O as far as replay is concerned. Wrap it in a Command and it is recorded and replayed like any other response; a step that calls `Date.now()` directly computes a fresh value on every replay and silently diverges from the trace.
|
|
131
133
|
|
|
@@ -223,7 +225,7 @@ Every attempt is a recorded step, so a replay reproduces the exact sequence of f
|
|
|
223
225
|
|
|
224
226
|
## Running Effects in Parallel
|
|
225
227
|
|
|
226
|
-
`Parallel` runs multiple Effect trees concurrently and passes their results to `next` as an ordered array.
|
|
228
|
+
`Parallel` runs multiple Effect trees concurrently and passes their results to `next` as an ordered array. The first branch to fail cancels its siblings, `next` is not called, and that branch's `Failure` is what propagates.
|
|
227
229
|
|
|
228
230
|
```js
|
|
229
231
|
import { Success, Command, Parallel } from 'pure-effect';
|
|
@@ -234,7 +236,17 @@ const loadProfile = (userId) =>
|
|
|
234
236
|
|
|
235
237
|
`Ask` context flows into all parallel branches without any extra wiring.
|
|
236
238
|
|
|
237
|
-
|
|
239
|
+
**Cancellation is cooperative, and it works at two levels.** A cancelled branch starts no further Commands. For example, a three-step branch whose first step is in flight when a sibling fails runs that step and stops. Cancelling the step already in flight needs the function to accept the `AbortSignal` it is handed and pass it to whatever performs the I/O:
|
|
240
|
+
|
|
241
|
+
```js
|
|
242
|
+
// Cancellable: the request is aborted the moment a sibling branch fails.
|
|
243
|
+
const fetchProfile = (userId) => Command((signal) => fetch(`/users/${userId}`, { signal }).then((r) => r.json()));
|
|
244
|
+
|
|
245
|
+
// Not cancellable: this runs to completion even after a sibling fails.
|
|
246
|
+
const fetchProfileUncancellable = (userId) => Command(() => fetch(`/users/${userId}`).then((r) => r.json()));
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
Outside a `Parallel` the function is called with no arguments at all, so nothing changes for a Command that never runs in a branch. A `Retry` inside a cancelled branch stops retrying rather than working through the rest of its backoff schedule.
|
|
238
250
|
|
|
239
251
|
## TypeScript: Typed Errors and Context
|
|
240
252
|
|
|
@@ -296,7 +308,7 @@ Returns `{ type: 'Failure', error, initialInput }`. Stops the pipeline immediate
|
|
|
296
308
|
|
|
297
309
|
Returns `{ type: 'Command', cmd, next, meta }`.
|
|
298
310
|
|
|
299
|
-
- `cmd`: A function (sync or async) that performs the side effect.
|
|
311
|
+
- `cmd`: A function (sync or async) that performs the side effect. Inside a `Parallel` branch it is called with an `AbortSignal` that fires when a sibling branch fails; elsewhere it is called with no arguments.
|
|
300
312
|
- `next`: Receives the result of `cmd` and returns the next Effect. Optional, defaulting to `(result) => Success(result)`, which is what most Commands want.
|
|
301
313
|
- `meta`: Optional metadata, passed to `onBeforeCommand`. A string `meta.name` becomes the Command's identity. Otherwise, the name of the function is used (`cmd.name`).
|
|
302
314
|
|
|
@@ -324,7 +336,7 @@ Returns `{ type: 'Retry', effect, options, next }`.
|
|
|
324
336
|
|
|
325
337
|
#### `Parallel(effects, next)`
|
|
326
338
|
|
|
327
|
-
Returns `{ type: 'Parallel', effects, next }`. Runs all effects concurrently
|
|
339
|
+
Returns `{ type: 'Parallel', effects, next }`. Runs all effects concurrently. The first branch to fail cancels its siblings and its `Failure` is returned; `next` is not called. When several branches fail in the same tick, the first by array order wins. Each branch's Commands receive an `AbortSignal` as their only argument, so I/O that accepts one is cancelled in flight; see [Running Effects in Parallel](#running-effects-in-parallel).
|
|
328
340
|
|
|
329
341
|
### Combinators
|
|
330
342
|
|
|
@@ -404,7 +416,7 @@ restore();
|
|
|
404
416
|
|
|
405
417
|
Returns `{ onStep, entries, toTrace }`. Pass `onStep` to `runEffect` or `configureEffect` to record what every Command returned.
|
|
406
418
|
|
|
407
|
-
Each entry is `{ command, result, durationMs }`, or `{ command, error, durationMs }` when the Command threw, so a trace also answers which step was slow. Results are snapshotted on capture, so a later step that mutates a returned object cannot rewrite what the trace says an earlier step saw. Values that cannot be structurally cloned, such as an object holding a function, are stored by reference instead. Recording cannot change a run: a `redact` that throws records `'[redaction failed]'` for that step instead of failing the flow.
|
|
419
|
+
Each entry is `{ command, path, result, durationMs }`, or `{ command, path, error, durationMs }` when the Command threw, so a trace also answers which step was slow. `path` is the Command's position in the Effect tree, which is what a replay matches on. Results are snapshotted on capture, so a later step that mutates a returned object cannot rewrite what the trace says an earlier step saw. Values that cannot be structurally cloned, such as an object holding a function, are stored by reference instead. Recording cannot change a run: a `redact` that throws records `'[redaction failed]'` for that step instead of failing the flow.
|
|
408
420
|
|
|
409
421
|
- `options.redact(value, name, kind)`: The single place PII is kept out of a trace. It sees every value a trace holds, with `kind` distinguishing them:
|
|
410
422
|
|
|
@@ -433,7 +445,6 @@ Runs a flow for real while recording, returning `{ result, trace }`. Accepts `re
|
|
|
433
445
|
Replays an effect tree, feeding recorded results to Commands instead of running them.
|
|
434
446
|
|
|
435
447
|
- `traceOrResolver`: a trace (or bare entries array) to replay directly, or a resolver function for traces stored in some other shape. A resolver returns `{ result }`, `{ error }`, or `undefined` if the step is unrecorded. A malformed trace rejects with a `ReplayError`.
|
|
436
|
-
- `options.strict` (default `true`): consume trace entries in recorded order and raise `TimeParadox` if the flow asks for a different Command. Set `false` for flows containing `Parallel`, whose branches complete out of array order, to match per-Command queues instead of positions. Ignored when a resolver was passed, since it already matches its own way.
|
|
437
448
|
- `options.context`: context for `Ask`; pass the recorded context.
|
|
438
449
|
- `options.onMissing`: `'throw'` (default) fails on an unrecorded step; `'execute'` runs the real Command, giving a recorded prefix with a live tail.
|
|
439
450
|
- `options.fastRetry` (default `true`): strip `Retry` delays.
|
|
@@ -442,14 +453,14 @@ Replays an effect tree, feeding recorded results to Commands instead of running
|
|
|
442
453
|
|
|
443
454
|
#### `timeTravel(flowFn, traceLog, options?)`
|
|
444
455
|
|
|
445
|
-
Replays a trace and narrates each step with its recorded duration, warning when recorded steps were never reached or when the trace's `version` differs from `options.version`.
|
|
456
|
+
Replays a trace and narrates each step with its recorded duration, warning when recorded steps were never reached or when the trace's `version` differs from `options.version`. Takes `options.context` to override the trace's, and `options.log` in place of `console.log`.
|
|
446
457
|
|
|
447
458
|
## Limitations
|
|
448
459
|
|
|
449
460
|
- **`Retry` repeats the whole wrapped tree.** Commands that already succeeded run again on every attempt, so wrapping a pipeline re-executes its side effects. Wrap the single Command that fails transiently unless every Command in the tree is idempotent.
|
|
450
|
-
- **`Parallel`
|
|
461
|
+
- **`Parallel` cancellation is cooperative, so it cannot stop everything.** A cancelled branch starts no further Commands, and a function that accepts the `AbortSignal` it is passed can be cut off in flight. A function that ignores the signal cannot: it runs to completion, so a branch whose _first_ Command is a write can still write after a sibling has failed. `Parallel` also waits for every branch to settle before returning, deliberately, so no cancelled work is left running unobserved after the `Failure` is returned.
|
|
451
462
|
- **A `Failure` is complete by design.** It carries the full error and the `initialInput` that `effectPipe` stamped on it, and neither is trimmed, because a test asserting on a Failure and a developer debugging one both need everything. Keeping PII out of a **trace** is `redact`'s job. Keeping it out of your **logs** is the shell's: log `result.error` rather than serializing the whole `Failure`, which for a login or registration flow holds the credentials that flow received.
|
|
452
|
-
- **Replay reproduces observed inputs, not concurrency.** A stale read replays exactly. A race between two concurrent requests does not: a trace records one flow's view
|
|
463
|
+
- **Replay reproduces observed inputs, not concurrency.** A stale read replays exactly. A race between two concurrent requests does not: a trace records one flow's view. `Parallel` branches replay with the results their own branch saw, since every step is matched by its position in the tree, but the interleaving between them is not reproduced, so a flow whose branches race each other through shared state is not something a replay can settle.
|
|
453
464
|
- **A trace records responses, not requests.** Command arguments live in a closure and are unreachable, so a trace shows what a call returned but not what was passed to it. This also bounds what a replay verifies: the flow's path and its handling of recorded outcomes. The upside is that arguments can never leak into a trace.
|
|
454
465
|
- **Global configuration is per module instance.** `configureEffect` writes to module-level state, so two copies of the library in one process, from a dual ESM and CJS resolution, two versions in a dependency tree, or a worker thread, each carry their own wiring. Code that configures one gets nothing in the other, with no error.
|
|
455
466
|
- **A Command needs an identity.** `meta.name` is stable under minification; relying on `cmd.name` instead means a mangler renames every step of every trace, so mangling has to be disabled or names preserved.
|
package/index.d.ts
CHANGED
|
@@ -19,7 +19,14 @@ export type CommandMeta = { name?: string } & Record<string, unknown>;
|
|
|
19
19
|
|
|
20
20
|
export type CommandState<R, T, E = unknown, Ctx = unknown> = {
|
|
21
21
|
type: 'Command';
|
|
22
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Performs the side effect. Inside a `Parallel` branch it receives an `AbortSignal` that fires when a
|
|
24
|
+
* sibling branch fails; forward it to `fetch`, a driver, or an `AbortController`-aware client to have
|
|
25
|
+
* the work cancelled in flight. Ignoring it is fine and is what every thunk written before this did:
|
|
26
|
+
* the interpreter still refuses to start any *later* Command in a cancelled branch. Outside a
|
|
27
|
+
* `Parallel` no argument is passed at all.
|
|
28
|
+
*/
|
|
29
|
+
cmd: (signal?: AbortSignal) => Promise<R> | R;
|
|
23
30
|
next: (result: R) => Effect<T, E, Ctx>;
|
|
24
31
|
meta?: CommandMeta;
|
|
25
32
|
initialInput?: unknown;
|
|
@@ -77,7 +84,7 @@ export declare function Failure<E = unknown>(error: E, initialInput?: unknown):
|
|
|
77
84
|
* and immune to minification. Without one the identity falls back to `cmd.name`, then to 'anonymous'.
|
|
78
85
|
*/
|
|
79
86
|
export declare function Command<R, T = R, E = unknown, Ctx = unknown>(
|
|
80
|
-
cmd: () => Promise<R> | R,
|
|
87
|
+
cmd: (signal?: AbortSignal) => Promise<R> | R,
|
|
81
88
|
next?: (result: R) => Effect<T, E, Ctx>,
|
|
82
89
|
meta?: CommandMeta
|
|
83
90
|
): CommandState<R, T, E, Ctx>;
|
|
@@ -231,7 +238,12 @@ export declare function effectPipe<
|
|
|
231
238
|
f8: (i: I) => Effect<J, E8, Ctx>
|
|
232
239
|
): (start: A) => Effect<J, E1 | E2 | E3 | E4 | E5 | E6 | E7 | E8, Ctx>;
|
|
233
240
|
|
|
234
|
-
|
|
241
|
+
/**
|
|
242
|
+
* Wraps one Command execution. `path` is the Command's position in the Effect tree rather than its
|
|
243
|
+
* position in completion order, so it is the same in a replay as in the recorded run even when
|
|
244
|
+
* `Parallel` branches finish in a different order. Hooks that take three parameters are unaffected.
|
|
245
|
+
*/
|
|
246
|
+
export type StepRunner = (name: string, type: string, op: () => Promise<unknown>, path?: string) => Promise<unknown>;
|
|
235
247
|
|
|
236
248
|
export type RunWrapper = (
|
|
237
249
|
effect: Effect<unknown>,
|
|
@@ -268,12 +280,21 @@ export declare function runEffect<T, E = unknown, Ctx = unknown>(
|
|
|
268
280
|
): Promise<SuccessState<T> | FailureState<E>>;
|
|
269
281
|
|
|
270
282
|
export type ReplayStep = {
|
|
271
|
-
/**
|
|
283
|
+
/**
|
|
284
|
+
* Zero-based position in this run's completion order. Not stable for a flow containing `Parallel`,
|
|
285
|
+
* whose branches finish in whatever order they finish in. Prefer `path`.
|
|
286
|
+
*/
|
|
272
287
|
index: number;
|
|
273
288
|
/** `cmd.name`, or 'anonymous'. */
|
|
274
289
|
name: string;
|
|
275
290
|
/** Always 'Command' today; reserved. */
|
|
276
291
|
type: string;
|
|
292
|
+
/**
|
|
293
|
+
* The Command's position in the Effect tree, stable across runs: steps are numbered within a
|
|
294
|
+
* subtree, and each `Parallel` branch and `Retry` attempt opens its own prefix. This is what a
|
|
295
|
+
* replay matches on, and what a Resolver should key off. Absent on traces recorded before paths.
|
|
296
|
+
*/
|
|
297
|
+
path?: string;
|
|
277
298
|
};
|
|
278
299
|
|
|
279
300
|
/**
|
|
@@ -287,6 +308,11 @@ export type Resolver = (step: ReplayStep) => ReplayOutcome | undefined;
|
|
|
287
308
|
|
|
288
309
|
export type TraceEntry = {
|
|
289
310
|
command: string;
|
|
311
|
+
/**
|
|
312
|
+
* The Command's position in the Effect tree. Order-independent, so a replay lines a recorded step
|
|
313
|
+
* up with the step that asked for it even when `Parallel` branches finish out of order.
|
|
314
|
+
*/
|
|
315
|
+
path?: string;
|
|
290
316
|
result?: unknown;
|
|
291
317
|
error?: unknown;
|
|
292
318
|
/** How long the Command took in production, rounded to microseconds. */
|
|
@@ -351,11 +377,11 @@ export interface ReplayOptions<Ctx = unknown> {
|
|
|
351
377
|
onMissing?: 'throw' | 'execute';
|
|
352
378
|
onResolved?: (step: ReplayStep, outcome: ReplayOutcome | undefined) => void;
|
|
353
379
|
/**
|
|
354
|
-
*
|
|
355
|
-
* consumes entries positionally and raises a `TimeParadox` on divergence; `false`
|
|
356
|
-
*
|
|
357
|
-
*
|
|
358
|
-
*
|
|
380
|
+
* Legacy matching mode, used only for a trace whose entries carry no `path`. `true` (default)
|
|
381
|
+
* consumes entries positionally and raises a `TimeParadox` on divergence; `false` matches
|
|
382
|
+
* per-Command FIFO queues, which used to be required for flows containing `Parallel`. Ignored
|
|
383
|
+
* for a trace carrying paths, which is matched by path, and when a Resolver is supplied, since
|
|
384
|
+
* that Resolver has already chosen how it matches.
|
|
359
385
|
*/
|
|
360
386
|
strict?: boolean;
|
|
361
387
|
}
|
package/index.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
/**
|
|
11
11
|
* @typedef {{
|
|
12
12
|
* type: 'Command',
|
|
13
|
-
* cmd: () => Promise<any>|any,
|
|
13
|
+
* cmd: (signal?: AbortSignal) => Promise<any>|any,
|
|
14
14
|
* next: (result: any) => Effect,
|
|
15
15
|
* meta?: any,
|
|
16
16
|
* initialInput?: any
|
|
@@ -70,7 +70,10 @@ const Failure = (error, initialInput) => ({
|
|
|
70
70
|
/**
|
|
71
71
|
* Represents a side effect to be executed later.
|
|
72
72
|
*
|
|
73
|
-
* @param {() => Promise<any>|any} cmd - The side-effect function to execute
|
|
73
|
+
* @param {(signal?: AbortSignal) => Promise<any>|any} cmd - The side-effect function to execute. Inside a
|
|
74
|
+
* `Parallel` branch it receives an `AbortSignal` that fires when a sibling branch fails, so I/O that
|
|
75
|
+
* accepts one can be cancelled in flight. Ignoring it is fine: the interpreter still refuses to start
|
|
76
|
+
* any later Command in a cancelled branch. Outside a `Parallel` no argument is passed.
|
|
74
77
|
* @param {(result: any) => Effect} [next] - Receives the result of `cmd` and returns the next Effect.
|
|
75
78
|
* Defaults to `(result) => Success(result)`, which is what most Commands want.
|
|
76
79
|
* @param {CommandMeta} [meta] - Optional metadata, passed to `onBeforeCommand`. A string `meta.name`
|
|
@@ -132,7 +135,15 @@ const Retry = (effect, options = {}) => ({
|
|
|
132
135
|
});
|
|
133
136
|
|
|
134
137
|
/**
|
|
135
|
-
* Runs multiple Effect trees concurrently.
|
|
138
|
+
* Runs multiple Effect trees concurrently. The first branch to fail cancels its siblings, and that
|
|
139
|
+
* branch's Failure is what the Parallel returns; `next` is skipped. When several branches fail in the
|
|
140
|
+
* same tick, the first by array order wins.
|
|
141
|
+
*
|
|
142
|
+
* Cancellation is cooperative and works at two levels. A cancelled branch starts no further Commands,
|
|
143
|
+
* which needs nothing from the caller. Stopping the Command already in flight needs its thunk to accept
|
|
144
|
+
* the `AbortSignal` it is passed and hand it to whatever performs the I/O; a thunk that ignores the
|
|
145
|
+
* signal runs to completion, so a branch's first Command can still write after a sibling has failed.
|
|
146
|
+
*
|
|
136
147
|
* @param {Effect[]} effects - Array of Effect trees to run concurrently
|
|
137
148
|
* @param {(values: any[]) => Effect} next - Receives array of success values in order, returns next Effect
|
|
138
149
|
* @returns {ParallelState}
|
|
@@ -244,7 +255,13 @@ const effectPipe = (...fns) => {
|
|
|
244
255
|
};
|
|
245
256
|
};
|
|
246
257
|
|
|
247
|
-
/**
|
|
258
|
+
/**
|
|
259
|
+
* Wraps one Command execution. `path` identifies the Command's position in the Effect tree rather than
|
|
260
|
+
* its position in completion order, so it is the same in a replay as it was in the recorded run even
|
|
261
|
+
* when `Parallel` branches finish in a different order. Hooks written before `path` existed take three
|
|
262
|
+
* parameters and are unaffected.
|
|
263
|
+
* @typedef {(name: string, type: string, op: function, path?: string) => Promise<any>} StepRunner
|
|
264
|
+
*/
|
|
248
265
|
/** @type StepRunner */
|
|
249
266
|
const defaultStepRunner = async (name, type, op) => await op();
|
|
250
267
|
|
|
@@ -336,12 +353,14 @@ const configureEffect = (...configs) => {
|
|
|
336
353
|
* @typedef {Object} StepStart
|
|
337
354
|
* @property {string} name - `cmd.name`, or 'anonymous'.
|
|
338
355
|
* @property {string} type - Always 'Command' today.
|
|
356
|
+
* @property {string} [path] - The Command's position in the Effect tree.
|
|
339
357
|
*/
|
|
340
358
|
|
|
341
359
|
/**
|
|
342
360
|
* @typedef {Object} StepEnd
|
|
343
361
|
* @property {string} name
|
|
344
362
|
* @property {string} type
|
|
363
|
+
* @property {string} [path]
|
|
345
364
|
* @property {any} [result] - What the Command returned, when it succeeded.
|
|
346
365
|
* @property {any} [error] - What it threw, when it did not.
|
|
347
366
|
* @property {number} durationMs
|
|
@@ -355,11 +374,11 @@ const now = () => (typeof performance === 'object' ? performance.now() : Date.no
|
|
|
355
374
|
* @param {(start: StepStart) => (end: StepEnd) => void} handler - Returns a finisher for the outcome
|
|
356
375
|
* @returns {StepRunner}
|
|
357
376
|
*/
|
|
358
|
-
const observeSteps = (handler) => async (name, type, op) => {
|
|
377
|
+
const observeSteps = (handler) => async (name, type, op, path) => {
|
|
359
378
|
/** @type {((end: StepEnd) => void) | undefined} */
|
|
360
379
|
let finish;
|
|
361
380
|
try {
|
|
362
|
-
finish = handler({ name, type });
|
|
381
|
+
finish = handler({ name, type, path });
|
|
363
382
|
} catch {
|
|
364
383
|
finish = undefined;
|
|
365
384
|
}
|
|
@@ -374,10 +393,10 @@ const observeSteps = (handler) => async (name, type, op) => {
|
|
|
374
393
|
const started = now();
|
|
375
394
|
try {
|
|
376
395
|
const result = await op();
|
|
377
|
-
report({ name, type, result, durationMs: now() - started });
|
|
396
|
+
report({ name, type, path, result, durationMs: now() - started });
|
|
378
397
|
return result;
|
|
379
398
|
} catch (error) {
|
|
380
|
-
report({ name, type, error, durationMs: now() - started });
|
|
399
|
+
report({ name, type, path, error, durationMs: now() - started });
|
|
381
400
|
throw error;
|
|
382
401
|
}
|
|
383
402
|
};
|
|
@@ -411,7 +430,7 @@ const chainHooks = (...configs) => {
|
|
|
411
430
|
|
|
412
431
|
if (steps.length) {
|
|
413
432
|
merged.onStep = steps.reduceRight(
|
|
414
|
-
(inner, outer) => (name, type, op) => outer(name, type, () => inner(name, type, op))
|
|
433
|
+
(inner, outer) => (name, type, op, path) => outer(name, type, () => inner(name, type, op, path), path)
|
|
415
434
|
);
|
|
416
435
|
}
|
|
417
436
|
if (runs.length) {
|
|
@@ -428,6 +447,45 @@ const chainHooks = (...configs) => {
|
|
|
428
447
|
return merged;
|
|
429
448
|
};
|
|
430
449
|
|
|
450
|
+
/**
|
|
451
|
+
* The Failure a `Parallel` branch resolves to when a sibling branch has already failed.
|
|
452
|
+
*
|
|
453
|
+
* Branches are cancelled, not merely ignored: this is what `Parallel` returns for a branch that was
|
|
454
|
+
* still in flight, and it is deliberately distinguishable so it can be told apart from the genuine
|
|
455
|
+
* failure that triggered the cancellation.
|
|
456
|
+
* @returns {Error}
|
|
457
|
+
*/
|
|
458
|
+
const parallelCancelled = () =>
|
|
459
|
+
Object.assign(new Error('Parallel branch cancelled.'), {
|
|
460
|
+
name: 'ParallelCancelled'
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Sleeps, but gives up early when the surrounding branch is cancelled, so a sibling's failure does not
|
|
465
|
+
* have to wait out a retry backoff that is now pointless. Resolves either way: the caller's abort check
|
|
466
|
+
* is what turns a cancelled wait into a Failure.
|
|
467
|
+
*
|
|
468
|
+
* @param {number} ms
|
|
469
|
+
* @param {AbortSignal} [signal]
|
|
470
|
+
* @returns {Promise<void>}
|
|
471
|
+
*/
|
|
472
|
+
const delayFor = (ms, signal) =>
|
|
473
|
+
new Promise((resolve) => {
|
|
474
|
+
if (!signal) {
|
|
475
|
+
setTimeout(resolve, ms);
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
const onAbort = () => {
|
|
479
|
+
clearTimeout(timer);
|
|
480
|
+
resolve();
|
|
481
|
+
};
|
|
482
|
+
const timer = setTimeout(() => {
|
|
483
|
+
signal.removeEventListener('abort', onAbort);
|
|
484
|
+
resolve();
|
|
485
|
+
}, ms);
|
|
486
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
487
|
+
});
|
|
488
|
+
|
|
431
489
|
const runEffect =
|
|
432
490
|
/**
|
|
433
491
|
* The Interpreter
|
|
@@ -451,13 +509,25 @@ const runEffect =
|
|
|
451
509
|
|
|
452
510
|
/**
|
|
453
511
|
* @param {Effect} eff
|
|
512
|
+
* @param {AbortSignal} [signal] - Cancellation for this subtree, set for `Parallel` branches.
|
|
513
|
+
* A branch stops starting new Commands once it is aborted, and the signal is handed to
|
|
514
|
+
* each Command's thunk so I/O that accepts one can be cancelled in flight.
|
|
515
|
+
* @param {string} [path] - Prefix identifying this subtree's position in the Effect tree.
|
|
516
|
+
* Steps are numbered sequentially within a subtree and each `Parallel` branch and `Retry`
|
|
517
|
+
* attempt opens its own prefix, so a Command's full path depends only on the shape of the
|
|
518
|
+
* tree and not on the order branches happen to finish in. That is what lets a replay line
|
|
519
|
+
* a recorded step up with the step that asked for it.
|
|
454
520
|
* @returns {Promise<SuccessState | FailureState>}
|
|
455
521
|
*/
|
|
456
|
-
async function execute(eff) {
|
|
522
|
+
async function execute(eff, signal, path = '') {
|
|
523
|
+
let step = 0;
|
|
457
524
|
while (
|
|
458
525
|
eff &&
|
|
459
526
|
(eff.type === 'Command' || eff.type === 'Ask' || eff.type === 'Retry' || eff.type === 'Parallel')
|
|
460
527
|
) {
|
|
528
|
+
// Checked before every node, which is what short-circuits a branch whose Commands
|
|
529
|
+
// ignore the signal: the one in flight cannot be stopped, but the next never starts.
|
|
530
|
+
if (signal?.aborted) return Failure(parallelCancelled(), eff.initialInput);
|
|
461
531
|
if (eff.type === 'Ask') {
|
|
462
532
|
eff = eff.next(context);
|
|
463
533
|
continue;
|
|
@@ -467,12 +537,19 @@ const runEffect =
|
|
|
467
537
|
const { attempts } = opts;
|
|
468
538
|
let lastError;
|
|
469
539
|
let succeeded = false;
|
|
540
|
+
// Each attempt gets its own prefix, so the Commands of attempt 2 cannot be mistaken
|
|
541
|
+
// for the Commands of attempt 1 when a trace is matched back up.
|
|
542
|
+
const retryPath = `${path}${step++}r`;
|
|
470
543
|
|
|
471
544
|
for (let attempt = 0; attempt <= attempts; attempt++) {
|
|
472
545
|
if (attempt > 0) {
|
|
473
|
-
await
|
|
546
|
+
await delayFor(opts.delay * Math.pow(opts.backoff, attempt - 1), signal);
|
|
474
547
|
}
|
|
475
|
-
|
|
548
|
+
// Checked after the wait, so a branch cancelled mid-backoff stops here rather
|
|
549
|
+
// than buying one more attempt, and a sibling's failure does not pay for the
|
|
550
|
+
// rest of this branch's retry schedule.
|
|
551
|
+
if (signal?.aborted) return Failure(parallelCancelled(), eff.initialInput);
|
|
552
|
+
const result = await execute(eff.effect, signal, `${retryPath}${attempt}/`);
|
|
476
553
|
if (result.type === 'Success') {
|
|
477
554
|
eff = eff.next(result.value);
|
|
478
555
|
succeeded = true;
|
|
@@ -487,17 +564,60 @@ const runEffect =
|
|
|
487
564
|
continue;
|
|
488
565
|
}
|
|
489
566
|
if (eff.type === 'Parallel') {
|
|
490
|
-
|
|
491
|
-
|
|
567
|
+
// Branch prefixes come from the branch's index in the array, never from the order
|
|
568
|
+
// branches complete in, which is the whole point: two branches calling the same
|
|
569
|
+
// Command are told apart by where they are rather than by who finished first.
|
|
570
|
+
const branchPath = `${path}${step++}p`;
|
|
571
|
+
// One scope per Parallel, linked to the enclosing one so cancellation nests.
|
|
572
|
+
// Absent AbortController (very old runtimes), `branchSignal` stays undefined and
|
|
573
|
+
// the old run-everything-to-completion behaviour is what happens.
|
|
574
|
+
const scope = typeof AbortController === 'function' ? new AbortController() : undefined;
|
|
575
|
+
const branchSignal = scope?.signal;
|
|
576
|
+
const relay = () => scope?.abort();
|
|
577
|
+
if (signal && scope) {
|
|
578
|
+
if (signal.aborted) scope.abort();
|
|
579
|
+
else signal.addEventListener('abort', relay, { once: true });
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** @type {(SuccessState | FailureState)[]} */
|
|
583
|
+
const results = new Array(eff.effects.length);
|
|
584
|
+
// Which branch failed on its own account rather than because it was cancelled.
|
|
585
|
+
const triggered = new Array(eff.effects.length).fill(false);
|
|
586
|
+
try {
|
|
587
|
+
// Still awaits every branch, so no cancelled work is left running unobserved
|
|
588
|
+
// after the Failure is returned. Cancelled branches settle promptly; a branch
|
|
589
|
+
// whose in-flight Command ignores the signal is the one case that does not.
|
|
590
|
+
await Promise.all(
|
|
591
|
+
eff.effects.map(async (e, i) => {
|
|
592
|
+
const result = await execute(e, branchSignal, `${branchPath}${i}/`);
|
|
593
|
+
results[i] = result;
|
|
594
|
+
// Read-then-abort is atomic here, so exactly one branch is the trigger.
|
|
595
|
+
if (result.type === 'Failure' && !branchSignal?.aborted) {
|
|
596
|
+
triggered[i] = true;
|
|
597
|
+
scope?.abort();
|
|
598
|
+
}
|
|
599
|
+
})
|
|
600
|
+
);
|
|
601
|
+
} finally {
|
|
602
|
+
if (signal && scope) signal.removeEventListener('abort', relay);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const trigger = triggered.indexOf(true);
|
|
606
|
+
const failure = trigger >= 0 ? results[trigger] : results.find((r) => r.type === 'Failure');
|
|
492
607
|
if (failure) return failure;
|
|
493
608
|
eff = eff.next(results.map((r) => /** @type {SuccessState} */ (r).value));
|
|
494
609
|
continue;
|
|
495
610
|
}
|
|
496
611
|
const cmdName = commandName(eff);
|
|
497
612
|
const initialInput = eff.initialInput;
|
|
613
|
+
const cmdPath = `${path}${step++}`;
|
|
614
|
+
const cmd = eff.cmd;
|
|
615
|
+
// The signal reaches the thunk only inside a Parallel, so a thunk written to take a
|
|
616
|
+
// parameter is not handed an argument it never expected anywhere else.
|
|
617
|
+
const op = signal ? () => cmd(signal) : cmd;
|
|
498
618
|
try {
|
|
499
619
|
await localCommandInterceptor(eff, context);
|
|
500
|
-
const result = await localStepRunner(cmdName, 'Command',
|
|
620
|
+
const result = await localStepRunner(cmdName, 'Command', op, cmdPath);
|
|
501
621
|
eff = eff.next(result);
|
|
502
622
|
} catch (e) {
|
|
503
623
|
// A malformed flow is a bug, not a domain failure, so it must not masquerade as one.
|
|
@@ -512,7 +632,12 @@ const runEffect =
|
|
|
512
632
|
return localRunWrapper(effect, () => execute(effect), context?.flowName || '');
|
|
513
633
|
};
|
|
514
634
|
|
|
515
|
-
/**
|
|
635
|
+
/**
|
|
636
|
+
* The step a replay is asking about. `path` is the Command's position in the Effect tree and is stable
|
|
637
|
+
* across runs; `index` is its position in this run's completion order, which is not stable for a flow
|
|
638
|
+
* containing `Parallel`. Prefer `path` when writing a Resolver.
|
|
639
|
+
* @typedef {{ index: number, name: string, type: string, path?: string }} ReplayStep
|
|
640
|
+
*/
|
|
516
641
|
|
|
517
642
|
/**
|
|
518
643
|
* What production observed for a step. `{ result }` is handed to the Command's
|
|
@@ -527,7 +652,9 @@ const runEffect =
|
|
|
527
652
|
/**
|
|
528
653
|
* A recorded step. `durationMs` is how long the Command took in production, rounded to microseconds,
|
|
529
654
|
* which is the one question a trace could not answer before: which step was slow.
|
|
530
|
-
*
|
|
655
|
+
* `path` locates the Command in the Effect tree, which is what a replay matches on: it does not move
|
|
656
|
+
* when `Parallel` branches finish in a different order than they did in production.
|
|
657
|
+
* @typedef {{ command: string, path?: string, result?: any, error?: any, durationMs?: number }} TraceEntry
|
|
531
658
|
*/
|
|
532
659
|
|
|
533
660
|
/**
|
|
@@ -560,12 +687,17 @@ const replayError = (message, props = {}) => Object.assign(new Error(message), {
|
|
|
560
687
|
* @returns {Error}
|
|
561
688
|
*/
|
|
562
689
|
const timeParadox = (step, recorded) =>
|
|
563
|
-
replayError(
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
690
|
+
replayError(
|
|
691
|
+
`Time paradox at ${step.path !== undefined ? `path '${step.path}'` : `step ${step.index}`}: ` +
|
|
692
|
+
`flow asked for '${step.name}', trace recorded '${recorded}'`,
|
|
693
|
+
{
|
|
694
|
+
name: 'TimeParadox',
|
|
695
|
+
index: step.index,
|
|
696
|
+
path: step.path,
|
|
697
|
+
expected: recorded,
|
|
698
|
+
actual: step.name
|
|
699
|
+
}
|
|
700
|
+
);
|
|
569
701
|
|
|
570
702
|
/**
|
|
571
703
|
* Converts a thrown value into something JSON can carry. `message` and `stack` are
|
|
@@ -678,16 +810,17 @@ const recorder = (options = {}) => {
|
|
|
678
810
|
|
|
679
811
|
// Built on `observe` so the hook contract lives in one place: `op` always runs, its result is
|
|
680
812
|
// always returned, and its error always propagates.
|
|
681
|
-
const onStep = observeSteps(({ name }) => (end) => {
|
|
813
|
+
const onStep = observeSteps(({ name, path }) => (end) => {
|
|
682
814
|
const durationMs = Math.round(end.durationMs * 1000) / 1000;
|
|
683
815
|
push(
|
|
684
816
|
'error' in end
|
|
685
817
|
? {
|
|
686
818
|
command: name,
|
|
819
|
+
path,
|
|
687
820
|
error: snapshot(safeRedact(serializeError(end.error, stack), name, 'error')),
|
|
688
821
|
durationMs
|
|
689
822
|
}
|
|
690
|
-
: { command: name, result: snapshot(safeRedact(end.result, name, 'result')), durationMs }
|
|
823
|
+
: { command: name, path, result: snapshot(safeRedact(end.result, name, 'result')), durationMs }
|
|
691
824
|
);
|
|
692
825
|
});
|
|
693
826
|
|
|
@@ -741,18 +874,21 @@ const entryToOutcome = (entry) => ('error' in entry ? { error: reviveError(entry
|
|
|
741
874
|
* when handed a trace instead of a Resolver, and is the only caller. Callers with traces
|
|
742
875
|
* in some other shape write a Resolver instead, which is the extension point.
|
|
743
876
|
*
|
|
744
|
-
*
|
|
745
|
-
*
|
|
746
|
-
*
|
|
877
|
+
* Path matching is used whenever every entry carries a `path`, which is every trace this
|
|
878
|
+
* version records. A path is the Command's position in the Effect tree, so it is order-independent
|
|
879
|
+
* and still paradox-detecting: it works for `Parallel` without `strict: false`, and it tells two
|
|
880
|
+
* branches that call the same Command apart by where they are rather than by which finished first.
|
|
881
|
+
* `strict` does not apply, since path matching is neither positional nor lenient.
|
|
747
882
|
*
|
|
748
|
-
*
|
|
749
|
-
*
|
|
750
|
-
*
|
|
751
|
-
*
|
|
883
|
+
* The two older modes remain for traces recorded before paths existed. Strict matching consumes
|
|
884
|
+
* entries in recorded order and verifies the flow asks for the Command the trace expects. Name
|
|
885
|
+
* matching resolves per-Command FIFO queues, which was the only option for `Parallel` and is the
|
|
886
|
+
* mode that could pair a branch with a sibling's result when both called the same Command.
|
|
752
887
|
*
|
|
753
888
|
* @param {TraceLog | TraceEntry[]} traceLog - A reference-format trace, or a bare array of entries
|
|
754
889
|
* @param {Object} [options]
|
|
755
|
-
* @param {boolean} [options.strict] - Positional matching with paradox detection (default `true`)
|
|
890
|
+
* @param {boolean} [options.strict] - Positional matching with paradox detection (default `true`).
|
|
891
|
+
* Ignored for a trace carrying paths.
|
|
756
892
|
* @returns {Resolver}
|
|
757
893
|
*/
|
|
758
894
|
const fromTrace = (traceLog, options = {}) => {
|
|
@@ -760,6 +896,30 @@ const fromTrace = (traceLog, options = {}) => {
|
|
|
760
896
|
const entries = Array.isArray(traceLog) ? traceLog : traceLog?.trace;
|
|
761
897
|
if (!Array.isArray(entries)) throw replayError('Trace has no `trace` array.');
|
|
762
898
|
|
|
899
|
+
// Paths win when the trace has them, whatever `strict` says: a caller who passed `strict: false`
|
|
900
|
+
// to work around Parallel should get the correct matching once their traces carry paths, rather
|
|
901
|
+
// than keeping the mode that option existed to work around.
|
|
902
|
+
if (entries.length > 0 && entries.every((e) => typeof e.path === 'string')) {
|
|
903
|
+
const byPath = new Map(entries.map((e) => [e.path, e]));
|
|
904
|
+
// Paths are derived from tree position, so they are unique by construction. A collision means
|
|
905
|
+
// either a hand-built trace or a bug in path derivation, and silently keeping the last entry
|
|
906
|
+
// would reintroduce exactly the wrong-result-per-branch failure paths exist to prevent.
|
|
907
|
+
if (byPath.size !== entries.length) {
|
|
908
|
+
throw replayError('Trace has duplicate step paths, so it cannot be matched by path.');
|
|
909
|
+
}
|
|
910
|
+
return (step) => {
|
|
911
|
+
const entry = byPath.get(step.path);
|
|
912
|
+
if (!entry) {
|
|
913
|
+
throw replayError(`Trace has no step at path '${step.path}' for '${step.name}'.`, {
|
|
914
|
+
command: step.name,
|
|
915
|
+
path: step.path
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
if (entry.command !== step.name) throw timeParadox(step, entry.command);
|
|
919
|
+
return entryToOutcome(entry);
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
|
|
763
923
|
if (strict) {
|
|
764
924
|
return (step) => {
|
|
765
925
|
const entry = entries[step.index];
|
|
@@ -863,8 +1023,8 @@ const replayEffect = async (effect, traceOrResolver, options = {}) => {
|
|
|
863
1023
|
let index = 0;
|
|
864
1024
|
|
|
865
1025
|
/** @type {StepRunner} */
|
|
866
|
-
const onStep = async (name, type, op) => {
|
|
867
|
-
const step = { index: index++, name, type };
|
|
1026
|
+
const onStep = async (name, type, op, path) => {
|
|
1027
|
+
const step = { index: index++, name, type, path };
|
|
868
1028
|
const outcome = resolve(step);
|
|
869
1029
|
if (onResolved) onResolved(step, outcome);
|
|
870
1030
|
if (outcome === undefined) {
|
|
@@ -918,17 +1078,19 @@ const timeTravel = async (flowFn, traceLog, options = {}) => {
|
|
|
918
1078
|
if (version && traceVersion && version !== traceVersion) {
|
|
919
1079
|
log(`Warning: trace was recorded at ${traceVersion}, replaying against ${version}.`);
|
|
920
1080
|
}
|
|
921
|
-
const stepsText =
|
|
1081
|
+
const stepsText = trace.length === 0 || trace.length > 1 ? 'steps' : 'step';
|
|
922
1082
|
log(`Replaying '${flowName || 'flow'}' (${trace.length} recorded ${stepsText})`);
|
|
923
1083
|
log(`Initial input: ${format(initialInput)}`);
|
|
924
1084
|
|
|
925
1085
|
// Narration goes through `onResolved` rather than a wrapped Resolver: observing each
|
|
926
1086
|
// step is all this needs, and `replayEffect` already resolves the trace itself.
|
|
927
1087
|
let consumed = 0;
|
|
928
|
-
//
|
|
929
|
-
// are narrated
|
|
1088
|
+
// A path maps a replayed step back to its recorded entry whatever order branches finished in, so
|
|
1089
|
+
// timings are narrated for Parallel too. Positional lookup is the fallback for a legacy trace with
|
|
1090
|
+
// no paths, and only under strict matching, since per-Command queues carry no position.
|
|
1091
|
+
const byPath = new Map(trace.filter((e) => typeof e.path === 'string').map((e) => [e.path, e]));
|
|
930
1092
|
const timing = (/** @type {ReplayStep} */ step) => {
|
|
931
|
-
const recorded = strict ? trace[step.index] : undefined;
|
|
1093
|
+
const recorded = byPath.get(step.path) ?? (strict ? trace[step.index] : undefined);
|
|
932
1094
|
return typeof recorded?.durationMs === 'number' ? ` in ${recorded.durationMs}ms` : '';
|
|
933
1095
|
};
|
|
934
1096
|
const result = await replayEffect(flowFn(initialInput), traceLog, {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pure-effect",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "A zero-dependency effect library for JavaScript and TypeScript with time-travel debugging, dependency injection, retry, and OpenTelemetry, where business logic is plain data you can test without mocks.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": "./index.js",
|
package/test/all.js
CHANGED
|
@@ -474,7 +474,7 @@ describe('Recording and replay', function () {
|
|
|
474
474
|
assert.equal(error.lastError.message, 'down');
|
|
475
475
|
});
|
|
476
476
|
|
|
477
|
-
it('should
|
|
477
|
+
it('should replay a Parallel flow whose branches finish out of order', async function () {
|
|
478
478
|
const flow = (/** @type {any} */ id) =>
|
|
479
479
|
Parallel(
|
|
480
480
|
[
|
|
@@ -500,14 +500,115 @@ describe('Recording and replay', function () {
|
|
|
500
500
|
['cmdFast', 'cmdSlow'],
|
|
501
501
|
'recording order follows completion, not the effects array'
|
|
502
502
|
);
|
|
503
|
+
assert.deepEqual(
|
|
504
|
+
trace.trace.map((e) => e.path),
|
|
505
|
+
['0p1/0', '0p0/0'],
|
|
506
|
+
'each entry is labelled by its branch, so completion order does not matter'
|
|
507
|
+
);
|
|
508
|
+
|
|
509
|
+
// No `strict: false` needed: paths are order-independent, so the default replays correctly.
|
|
510
|
+
const replayed = await replayEffect(flow('u1'), trace);
|
|
511
|
+
assert.equal(replayed.type, 'Success');
|
|
512
|
+
assert.deepEqual(valueOf(replayed), valueOf(result));
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
it('should keep two Parallel branches that call the same Command apart', async function () {
|
|
516
|
+
// The failure this pins is silent: per-Command queues pair branches by completion order, so
|
|
517
|
+
// reversing the branch latencies between recording and replay swapped one branch's result
|
|
518
|
+
// onto the other and the replay still reported Success.
|
|
519
|
+
const flow = (/** @type {[number, number]} */ delays) =>
|
|
520
|
+
Parallel(
|
|
521
|
+
[
|
|
522
|
+
Command(
|
|
523
|
+
function cmdFetch() {
|
|
524
|
+
return new Promise((r) => setTimeout(() => r('A'), delays[0]));
|
|
525
|
+
},
|
|
526
|
+
(/** @type {any} */ v) => Success(v)
|
|
527
|
+
),
|
|
528
|
+
Command(
|
|
529
|
+
function cmdFetch() {
|
|
530
|
+
return new Promise((r) => setTimeout(() => r('B'), delays[1]));
|
|
531
|
+
},
|
|
532
|
+
(/** @type {any} */ v) => Success(v)
|
|
533
|
+
)
|
|
534
|
+
],
|
|
535
|
+
(/** @type {any} */ vals) => Success(vals)
|
|
536
|
+
);
|
|
503
537
|
|
|
504
|
-
const
|
|
505
|
-
assert.
|
|
506
|
-
assert.equal(/** @type {Error} */ (errorOf(strictReplay)).name, 'TimeParadox');
|
|
538
|
+
const { result, trace } = await recordEffect(flow, [40, 5]);
|
|
539
|
+
assert.deepEqual(valueOf(result), ['A', 'B']);
|
|
507
540
|
|
|
508
|
-
|
|
541
|
+
// Replay with the latencies reversed, so completion order is the opposite of the recorded run.
|
|
542
|
+
const replayed = await replayEffect(flow([5, 40]), trace);
|
|
543
|
+
assert.equal(replayed.type, 'Success');
|
|
544
|
+
assert.deepEqual(valueOf(replayed), ['A', 'B'], 'branch results were not swapped');
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
it('should still resolve a legacy trace that carries no paths', async function () {
|
|
548
|
+
const a = makeFlow();
|
|
549
|
+
const { result, trace } = await recordEffect(a.flow, { id: 'legacy' });
|
|
550
|
+
const legacy = {
|
|
551
|
+
...trace,
|
|
552
|
+
trace: trace.trace.map(({ path, ...rest }) => rest)
|
|
553
|
+
};
|
|
554
|
+
assert.ok(
|
|
555
|
+
legacy.trace.every((e) => e.path === undefined),
|
|
556
|
+
'the fixture really has no paths'
|
|
557
|
+
);
|
|
558
|
+
|
|
559
|
+
const b = makeFlow();
|
|
560
|
+
const replayed = await replayEffect(b.flow(trace.initialInput), legacy);
|
|
509
561
|
assert.equal(replayed.type, 'Success');
|
|
510
562
|
assert.deepEqual(valueOf(replayed), valueOf(result));
|
|
563
|
+
assert.deepEqual(b.calls, { read: 0, write: 0 }, 'a legacy trace still performs no I/O');
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
it('should raise a TimeParadox naming the path when a flow diverges', async function () {
|
|
567
|
+
const original = (/** @type {any} */ id) =>
|
|
568
|
+
effectPipe(
|
|
569
|
+
() =>
|
|
570
|
+
Command(
|
|
571
|
+
function cmdRead() {
|
|
572
|
+
return id;
|
|
573
|
+
},
|
|
574
|
+
(/** @type {any} */ v) => Success(v)
|
|
575
|
+
),
|
|
576
|
+
(/** @type {any} */ v) =>
|
|
577
|
+
Command(
|
|
578
|
+
function cmdWrite() {
|
|
579
|
+
return v;
|
|
580
|
+
},
|
|
581
|
+
(/** @type {any} */ w) => Success(w)
|
|
582
|
+
)
|
|
583
|
+
)(id);
|
|
584
|
+
const { trace } = await recordEffect(original, 'x1');
|
|
585
|
+
|
|
586
|
+
// The second step is a different Command than the trace recorded at that path.
|
|
587
|
+
const diverged = (/** @type {any} */ id) =>
|
|
588
|
+
effectPipe(
|
|
589
|
+
() =>
|
|
590
|
+
Command(
|
|
591
|
+
function cmdRead() {
|
|
592
|
+
return id;
|
|
593
|
+
},
|
|
594
|
+
(/** @type {any} */ v) => Success(v)
|
|
595
|
+
),
|
|
596
|
+
(/** @type {any} */ v) =>
|
|
597
|
+
Command(
|
|
598
|
+
function cmdAudit() {
|
|
599
|
+
return v;
|
|
600
|
+
},
|
|
601
|
+
(/** @type {any} */ w) => Success(w)
|
|
602
|
+
)
|
|
603
|
+
)(id);
|
|
604
|
+
|
|
605
|
+
const replayed = await replayEffect(diverged('x1'), trace);
|
|
606
|
+
assert.equal(replayed.type, 'Failure');
|
|
607
|
+
const error = /** @type {any} */ (errorOf(replayed));
|
|
608
|
+
assert.equal(error.name, 'TimeParadox');
|
|
609
|
+
assert.equal(error.path, '1');
|
|
610
|
+
assert.equal(error.expected, 'cmdWrite');
|
|
611
|
+
assert.equal(error.actual, 'cmdAudit');
|
|
511
612
|
});
|
|
512
613
|
|
|
513
614
|
it('should accept a trace directly, without building a Resolver', async function () {
|
|
@@ -1665,8 +1766,11 @@ describe('Documented sharp edges', function () {
|
|
|
1665
1766
|
assert.equal(charges.length, 3, 'one order, three charges: wrap the Command, not the pipeline');
|
|
1666
1767
|
});
|
|
1667
1768
|
|
|
1668
|
-
it('should
|
|
1669
|
-
// Pinned deliberately:
|
|
1769
|
+
it('should not stop an in-flight Command whose thunk ignores the signal', async function () {
|
|
1770
|
+
// Pinned deliberately: cancellation is cooperative. The sibling's write is already in flight
|
|
1771
|
+
// inside the branch's first Command, and a thunk that ignores its signal cannot be interrupted,
|
|
1772
|
+
// so the write still lands. What cancellation does buy is in the suite below: no *later*
|
|
1773
|
+
// Command in that branch starts, and a thunk that honours the signal is cut off.
|
|
1670
1774
|
/** @type {string[]} */
|
|
1671
1775
|
const written = [];
|
|
1672
1776
|
const result = await runEffect(
|
|
@@ -1695,7 +1799,7 @@ describe('Documented sharp edges', function () {
|
|
|
1695
1799
|
);
|
|
1696
1800
|
assert.equal(result.type, 'Failure');
|
|
1697
1801
|
assert.equal(errorOf(result), 'validation_failed');
|
|
1698
|
-
assert.deepEqual(written, ['wrote'], '
|
|
1802
|
+
assert.deepEqual(written, ['wrote'], 'an uninterruptible in-flight write still performed');
|
|
1699
1803
|
});
|
|
1700
1804
|
|
|
1701
1805
|
it('should carry the initial input on every Failure', async function () {
|
|
@@ -1799,3 +1903,165 @@ describe('Malformed flows', function () {
|
|
|
1799
1903
|
assert.equal(/** @type {Error} */ (errorOf(result)).message, 'boom');
|
|
1800
1904
|
});
|
|
1801
1905
|
});
|
|
1906
|
+
|
|
1907
|
+
describe('Parallel cancellation', function () {
|
|
1908
|
+
beforeEach(function () {
|
|
1909
|
+
configureEffect();
|
|
1910
|
+
});
|
|
1911
|
+
|
|
1912
|
+
/** Resolves after `ms`, or rejects as soon as `signal` aborts. */
|
|
1913
|
+
const cancellableSleep = (/** @type {number} */ ms, /** @type {AbortSignal} */ signal) =>
|
|
1914
|
+
new Promise((resolve, reject) => {
|
|
1915
|
+
const timer = setTimeout(resolve, ms);
|
|
1916
|
+
signal?.addEventListener(
|
|
1917
|
+
'abort',
|
|
1918
|
+
() => {
|
|
1919
|
+
clearTimeout(timer);
|
|
1920
|
+
reject(new Error('aborted'));
|
|
1921
|
+
},
|
|
1922
|
+
{ once: true }
|
|
1923
|
+
);
|
|
1924
|
+
});
|
|
1925
|
+
|
|
1926
|
+
it('should cancel a branch whose thunk honours the signal', async function () {
|
|
1927
|
+
let completed = false;
|
|
1928
|
+
const started = Date.now();
|
|
1929
|
+
const result = await runEffect(
|
|
1930
|
+
Parallel(
|
|
1931
|
+
[
|
|
1932
|
+
Command(function cmdFail() {
|
|
1933
|
+
throw new Error('fail_fast');
|
|
1934
|
+
}),
|
|
1935
|
+
Command(async function cmdSlow(/** @type {AbortSignal} */ signal) {
|
|
1936
|
+
await cancellableSleep(400, signal);
|
|
1937
|
+
completed = true;
|
|
1938
|
+
return 'wrote';
|
|
1939
|
+
})
|
|
1940
|
+
],
|
|
1941
|
+
(/** @type {any} */ v) => Success(v)
|
|
1942
|
+
)
|
|
1943
|
+
);
|
|
1944
|
+
assert.equal(result.type, 'Failure');
|
|
1945
|
+
assert.equal(/** @type {Error} */ (errorOf(result)).message, 'fail_fast');
|
|
1946
|
+
assert.equal(completed, false, 'the cancelled branch never finished its work');
|
|
1947
|
+
assert.ok(Date.now() - started < 200, 'the run did not wait out the cancelled branch');
|
|
1948
|
+
});
|
|
1949
|
+
|
|
1950
|
+
it('should not start a later Command in a cancelled branch', async function () {
|
|
1951
|
+
/** @type {string[]} */
|
|
1952
|
+
const ran = [];
|
|
1953
|
+
const step = (/** @type {string} */ name) => () =>
|
|
1954
|
+
Command(
|
|
1955
|
+
async function cmdWrite() {
|
|
1956
|
+
await cancellableSleep(30, undefined);
|
|
1957
|
+
ran.push(name);
|
|
1958
|
+
return name;
|
|
1959
|
+
},
|
|
1960
|
+
(/** @type {any} */ v) => Success(v)
|
|
1961
|
+
);
|
|
1962
|
+
const result = await runEffect(
|
|
1963
|
+
Parallel(
|
|
1964
|
+
[
|
|
1965
|
+
Command(async function cmdFail() {
|
|
1966
|
+
await cancellableSleep(5, undefined);
|
|
1967
|
+
throw new Error('validation_failed');
|
|
1968
|
+
}),
|
|
1969
|
+
effectPipe(step('write1'), step('write2'), step('write3'))(null)
|
|
1970
|
+
],
|
|
1971
|
+
(/** @type {any} */ v) => Success(v)
|
|
1972
|
+
)
|
|
1973
|
+
);
|
|
1974
|
+
assert.equal(result.type, 'Failure');
|
|
1975
|
+
assert.deepEqual(ran, ['write1'], 'the in-flight write landed; the two after it never started');
|
|
1976
|
+
});
|
|
1977
|
+
|
|
1978
|
+
it('should stop retrying in a cancelled branch', async function () {
|
|
1979
|
+
let attempts = 0;
|
|
1980
|
+
const result = await runEffect(
|
|
1981
|
+
Parallel(
|
|
1982
|
+
[
|
|
1983
|
+
Command(async function cmdFail() {
|
|
1984
|
+
await cancellableSleep(30, undefined);
|
|
1985
|
+
throw new Error('primary_failed');
|
|
1986
|
+
}),
|
|
1987
|
+
Retry(
|
|
1988
|
+
Command(function cmdFlaky() {
|
|
1989
|
+
attempts++;
|
|
1990
|
+
throw new Error('flaky');
|
|
1991
|
+
}),
|
|
1992
|
+
{ attempts: 5, delay: 100 }
|
|
1993
|
+
)
|
|
1994
|
+
],
|
|
1995
|
+
(/** @type {any} */ v) => Success(v)
|
|
1996
|
+
)
|
|
1997
|
+
);
|
|
1998
|
+
assert.equal(/** @type {Error} */ (errorOf(result)).message, 'primary_failed');
|
|
1999
|
+
assert.ok(attempts < 5, `the cancelled branch stopped retrying (made ${attempts} attempts, not 6)`);
|
|
2000
|
+
});
|
|
2001
|
+
|
|
2002
|
+
it('should propagate cancellation into a nested Parallel', async function () {
|
|
2003
|
+
let inner = false;
|
|
2004
|
+
const result = await runEffect(
|
|
2005
|
+
Parallel(
|
|
2006
|
+
[
|
|
2007
|
+
Command(function cmdOuterFail() {
|
|
2008
|
+
throw new Error('outer_failed');
|
|
2009
|
+
}),
|
|
2010
|
+
Parallel(
|
|
2011
|
+
[
|
|
2012
|
+
Command(async function cmdInner(/** @type {AbortSignal} */ signal) {
|
|
2013
|
+
await cancellableSleep(300, signal);
|
|
2014
|
+
inner = true;
|
|
2015
|
+
return 1;
|
|
2016
|
+
})
|
|
2017
|
+
],
|
|
2018
|
+
(/** @type {any} */ v) => Success(v)
|
|
2019
|
+
)
|
|
2020
|
+
],
|
|
2021
|
+
(/** @type {any} */ v) => Success(v)
|
|
2022
|
+
)
|
|
2023
|
+
);
|
|
2024
|
+
assert.equal(/** @type {Error} */ (errorOf(result)).message, 'outer_failed');
|
|
2025
|
+
assert.equal(inner, false, 'the inner branch was cancelled with the outer one');
|
|
2026
|
+
});
|
|
2027
|
+
|
|
2028
|
+
it('should return the triggering Failure rather than the cancellation', async function () {
|
|
2029
|
+
const result = await runEffect(
|
|
2030
|
+
Parallel(
|
|
2031
|
+
[
|
|
2032
|
+
Command(async function cmdSlowOk(/** @type {AbortSignal} */ signal) {
|
|
2033
|
+
await cancellableSleep(200, signal);
|
|
2034
|
+
return 'ok';
|
|
2035
|
+
}),
|
|
2036
|
+
Command(function cmdRealFailure() {
|
|
2037
|
+
throw new Error('THE_REAL_ERROR');
|
|
2038
|
+
})
|
|
2039
|
+
],
|
|
2040
|
+
(/** @type {any} */ v) => Success(v)
|
|
2041
|
+
)
|
|
2042
|
+
);
|
|
2043
|
+
assert.equal(
|
|
2044
|
+
/** @type {Error} */ (errorOf(result)).message,
|
|
2045
|
+
'THE_REAL_ERROR',
|
|
2046
|
+
'a cancelled sibling must not displace the failure that caused the cancellation'
|
|
2047
|
+
);
|
|
2048
|
+
});
|
|
2049
|
+
|
|
2050
|
+
it('should still pick the first Failure by array order when branches fail together', async function () {
|
|
2051
|
+
const result = await runEffect(Parallel([Failure('a'), Failure('b')], (/** @type {any} */ v) => Success(v)));
|
|
2052
|
+
assert.equal(errorOf(result), 'a');
|
|
2053
|
+
});
|
|
2054
|
+
|
|
2055
|
+
it('should pass no argument to a Command outside a Parallel', async function () {
|
|
2056
|
+
/** @type {any[]} */
|
|
2057
|
+
const args = [];
|
|
2058
|
+
const result = await runEffect(
|
|
2059
|
+
Command(function cmdRecordArgs() {
|
|
2060
|
+
args.push([...arguments]);
|
|
2061
|
+
return 'done';
|
|
2062
|
+
})
|
|
2063
|
+
);
|
|
2064
|
+
assert.equal(result.type, 'Success');
|
|
2065
|
+
assert.deepEqual(args, [[]], 'a thunk outside a Parallel is called with no arguments at all');
|
|
2066
|
+
});
|
|
2067
|
+
});
|