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 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 3.4 KB against a measured 3,394 bytes. 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.
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 via `Promise.all`; returns the first Failure by array order if any effect fails; context flows into all branches |
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` has no cancellation. Branches are collected with `Promise.all` and the first `Failure` is chosen afterwards, so a failing branch does not stop its siblings from performing I/O.
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
- - **Positional vs name matching.** The default `strict: true` consumes entries in recorded order and raises a `TimeParadox` when the flow asks for a different Command than the trace holds, which is what detects code that has diverged from the recorded run. Flows containing `Parallel` **require** `strict: false`: branches run through `Promise.all` and complete out of array order, so recorded positions are not stable. `strict: false` resolves per-Command FIFO queues instead, which is order-independent and still keeps duplicate Command names apart. `strict` is a `replayEffect` option, forwarded by `timeTravel`, and is ignored when a `Resolver` is passed rather than a trace, because that `Resolver` has already chosen its own matching.
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
- [![npm version](https://img.shields.io/npm/v/pure-effect)](https://www.npmjs.com/package/pure-effect) [![bundle size](https://img.shields.io/badge/minified%2Bgzipped-%3C3.4KB-brightgreen)](https://bundlephobia.com/package/pure-effect) [![license](https://img.shields.io/npm/l/pure-effect)](https://github.com/aycangulez/pure-effect/blob/main/LICENSE)
3
+ [![npm version](https://img.shields.io/npm/v/pure-effect)](https://www.npmjs.com/package/pure-effect) [![bundle size](https://img.shields.io/badge/minified%2Bgzipped-4KB-brightgreen)](https://bundlephobia.com/package/pure-effect) [![license](https://img.shields.io/npm/l/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 and parallel execution with configurable delay, backoff, and `Promise.all` semantics
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 3.4 KB minified and gzipped
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 thunk: traces, replay matching, and telemetry spans are keyed on it.
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
- return Command(cmdFindUser, (found) => (found ? Failure('Email already in use.') : Success(input)));
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`, and changed error handling fails the assertion.
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. If any effect fails, `next` is not called and the `Failure` propagates immediately.
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
- Note for replay: branches complete out of array order, so recorded positions are not stable. Replay flows containing `Parallel` with `{ strict: false }`, which matches per-Command queues instead of positions.
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 via `Promise.all`. The first `Failure` is returned immediately and `next` is not called.
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`. Durations are reported only under `strict` matching, where a replayed step maps back to its recorded entry by position. Takes `options.strict` (default `true`; set `false` for `Parallel`), `options.context` to override the trace's, and `options.log` in place of `console.log`.
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` has no cancellation.** Every branch runs to completion even after another fails, because the results are collected with `Promise.all` and the first `Failure` is selected afterwards. A branch that fails validation does not prevent a sibling branch from writing.
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, and `Parallel` interleaving is not captured.
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
- cmd: () => Promise<R> | R;
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
- export type StepRunner = (name: string, type: string, op: () => Promise<unknown>) => Promise<unknown>;
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
- /** Zero-based position in this run's Command sequence. */
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
- * Matching mode used when a trace is passed instead of a Resolver. `true` (default)
355
- * consumes entries positionally and raises a `TimeParadox` on divergence; `false`
356
- * matches per-Command FIFO queues, which is required for flows containing `Parallel`,
357
- * whose branches complete out of array order. Ignored when a Resolver is supplied,
358
- * since that Resolver has already chosen how it matches.
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. If any effect fails, returns the first Failure by array order and skips next.
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
- /** @typedef {(name: string, type: string, op: function) => Promise<any>} StepRunner */
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 new Promise((r) => setTimeout(r, opts.delay * Math.pow(opts.backoff, attempt - 1)));
546
+ await delayFor(opts.delay * Math.pow(opts.backoff, attempt - 1), signal);
474
547
  }
475
- const result = await execute(eff.effect);
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
- const results = await Promise.all(eff.effects.map((e) => execute(e)));
491
- const failure = results.find((r) => r.type === 'Failure');
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', eff.cmd);
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
- /** @typedef {{ index: number, name: string, type: string }} ReplayStep */
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
- * @typedef {{ command: string, result?: any, error?: any, durationMs?: number }} TraceEntry
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(`Time paradox at step ${step.index}: flow asked for '${step.name}', trace recorded '${recorded}'`, {
564
- name: 'TimeParadox',
565
- index: step.index,
566
- expected: recorded,
567
- actual: step.name
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
- * Strict matching consumes entries in recorded order and verifies that the flow asks
745
- * for the Command the trace expects, which is what detects divergence between the
746
- * recorded run and the code being replayed.
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
- * Name matching is required for flows containing `Parallel`: branches run through
749
- * `Promise.all` and therefore complete out of array order, so recorded positions are
750
- * not stable. It resolves per-Command FIFO queues instead, which is order-independent
751
- * and still keeps duplicate Command names apart.
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 = (trace.length === 0 || trace.length > 1) ? 'steps' : 'step';
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
- // Only strict matching maps a replayed step back to its recorded entry by position, so timings
929
- // are narrated there and omitted when per-Command queues are matching by name.
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.9.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 require name matching for Parallel, whose branches finish out of order', async function () {
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 strictReplay = await replayEffect(flow('u1'), trace);
505
- assert.equal(strictReplay.type, 'Failure', 'positional matching reports a false paradox');
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
- const replayed = await replayEffect(flow('u1'), trace, { strict: false });
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 run every Parallel branch to completion even after one fails', async function () {
1669
- // Pinned deliberately: Promise.all has no cancellation, so a Failure does not stop sibling I/O.
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'], 'the sibling branch still performed its write');
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
+ });