pure-effect 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,148 @@
1
+ # Changelog
2
+
3
+ All notable changes to pure-effect are recorded here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and from 1.0.0 the project follows [Semantic Versioning](https://semver.org/). Before 1.0.0 a minor version could change behaviour; each such change is marked below.
4
+
5
+ ## [0.13.0] - 2026-09-22
6
+
7
+ ### Changed
8
+
9
+ - **`configureEffect` is additive.** Installing hooks no longer switches off hooks someone else installed: each call adds a layer on top of those already there and returns a function that removes that one layer, wherever it sits by then. Layers merge the way several configurations passed to one call always did: `onStep` and `onRun` nest with the earliest layer outermost, `onBeforeCommand` interceptors run in installation order, and `retry` merges with later layers winning. Calling it with no arguments removes every layer, while a call whose arguments are all `undefined` installs and removes nothing. Previously a later call replaced the hooks an earlier one had installed, and the returned function put back whatever had been configured before it.
10
+ - **A per-call `callConfig` merges over the hooks already installed** instead of replacing them one at a time, with the call innermost. Passing an `onStep` to `runEffect` used to switch the configured `onStep` off for that run, which is how `recordEffect` inside an application that already had tracing silently produced runs with no spans.
11
+ - **Every `Failure` carries the input the flow was called with**, however deep it came from. A `Failure` escaping a `Parallel` branch, a `Retry` fallback, or a sub-pipeline previously carried that subtree's own input or none.
12
+ - A Command continuation that returns nothing, at any position in a pipeline, rejects with an `EffectTypeError` naming the missing return, rather than resolving to a `Failure` carrying a bare `TypeError`.
13
+ - A `Success` no longer carries `initialInput`, so `assert.deepEqual(result, Success(v))` holds for every flow rather than only those whose last step returned a `Success` directly.
14
+
15
+ ### Added
16
+
17
+ - **`runEffect`'s third argument takes `inherit`, a boolean.** `true`, the default, is the merge above; `false` leaves the installed hooks out of the run entirely, so anything the call does not set falls back to the library defaults, `retry` included. Anything other than a boolean throws a `TypeError`.
18
+ - A GitHub Actions workflow runs the test gate on Node 18 through 24, plus Prettier's format check.
19
+
20
+ ### Fixed
21
+
22
+ - **`replayEffect` under the default `hooks: false` still applies global `retry` defaults.** Ignoring the installed hooks for a replay dropped `retry` along with them, so a `Retry` that production ran under a configured `attempts` exhausted early on replay and reported a `Failure` production never saw.
23
+
24
+ ## [0.12.0] - 2026-09-04
25
+
26
+ ### Changed
27
+
28
+ - **`replayEffect` returns `{ result, unreached }`** for a trace and `{ result }` for a `Resolver`, mirroring `recordEffect`'s `{ result, trace }`. `unreached` is the recorded entries the flow never asked for: a flow that stops issuing Commands before its recording ends mismatches nothing, so no `TimeParadox` fires and the replay can end in `Success` with steps left over. Previously the bare outcome was returned.
29
+ - **The `strict` replay option is removed.** Every trace since 0.9.0 carries paths, which are order-independent and paradox-detecting, so the option had nothing left to choose. A path-less trace is matched positionally, and a `Parallel` step in one is refused with a `ReplayError`.
30
+
31
+ ### Fixed
32
+
33
+ - Revived errors define `name` and `cause` as non-enumerable own properties, as a native `Error` keeps them, so `assert.deepEqual(replayed, result)` holds for a `Failure` carrying a plain error.
34
+
35
+ ## [0.11.0] - 2026-09-01
36
+
37
+ ### Added
38
+
39
+ - **`Retry(effect, { onExhausted })`** runs a fallback Effect when every attempt has failed, instead of returning the structured exhaustion `Failure`. The fallback's success feeds `next` and its failure propagates unwrapped. It executes under the retry's own path prefix so replays reproduce it, a cancelled `Parallel` branch skips it, and `fastRetry` rewrites it. Per-use only: the global `retry` defaults cannot carry one.
40
+ - `Parallel`'s `next` is optional, defaulting to `(values) => Success(values)`, matching `Command`.
41
+ - README sections **Composing Larger Flows** and **Which Errors Are Data**, documenting sub-pipeline branching, joining results from `Parallel`, local joins, and why there is no catch.
42
+
43
+ ### Fixed
44
+
45
+ - Serialized errors carry `cause` recursively. It is non-enumerable on `Error`, so traces silently dropped it.
46
+ - `Retry`'s declared error type matches what `runEffect` returns: `RetryExhaustedError<E>` without `onExhausted`, the fallback's own error type with it. The declaration previously claimed `E`.
47
+
48
+ ## [0.10.0] - 2026-08-28
49
+
50
+ ### Added
51
+
52
+ - **`Parallel` cancels its other branches when one fails.** The first branch to fail aborts an `AbortController` scoped to that `Parallel` and linked to any enclosing one. Cancelling is a request rather than a guarantee: a cancelled branch starts no further Commands, and a Command function that accepts the `AbortSignal` it is passed can be cut off in flight. Inside a `Parallel` the function is called as `cmd(signal)`; anywhere else it is called with no argument. `Parallel` still awaits every branch before returning, and the triggering `Failure` is returned rather than a cancellation.
53
+
54
+ ## [0.9.0] - 2026-08-08
55
+
56
+ ### Added
57
+
58
+ - **Time-travel debugging**: `recorder(options)` returns an `onStep` hook and a trace packager; `recordEffect(flow, input)` runs a flow for real and returns `{ result, trace }`; `replayEffect(effect, traceOrResolver)` replays a flow from a trace or a `Resolver` with no I/O; `timeTravel(flow, trace)` is a narrated replay with timings and divergence warnings. Every recorded step carries a `path`, its position in the flow, and a `durationMs`.
59
+ - `Command(cmd, next, meta)`: a non-empty `meta.name` is the Command's identity, then `cmd.name`, then `'anonymous'`, so an inline arrow or a minified function can still be named in a trace.
60
+ - `Command`'s `next` is optional, defaulting to `(result) => Success(result)`.
61
+ - `configureEffect` accepts several configurations and merges them, and returns a function that puts back whatever was configured before.
62
+ - A pipeline step returning something other than an Effect throws an `EffectTypeError` naming the step, instead of failing later with a bare `TypeError`.
63
+ - `examples/recording-example.js`, a reference for production recording with one recorder per run in an `AsyncLocalStorage` scope. Both examples are covered by tests.
64
+
65
+ ## [0.8.0] - 2026-05-03
66
+
67
+ ### Added
68
+
69
+ - **`Parallel(effects, next)`** runs several flows at the same time with the same context. The first `Failure` by array index is returned; otherwise `next` receives the array of unwrapped success values.
70
+
71
+ ## [0.7.0] - 2026-05-02
72
+
73
+ ### Added
74
+
75
+ - **`Retry(effect, options)`** repeats the flow it wraps on failure with `attempts`, `delay`, and `backoff`, handled natively by `runEffect`. On exhaustion it returns `Failure({ retryExhausted: true, lastError, attempts })`. `configureEffect({ retry })` sets global defaults.
76
+ - `runEffect(effect, context, callConfig)`: per-call configuration overriding the global hooks and retry defaults.
77
+ - A `Ctx` type parameter flows through every Effect type and `effectPipe`, so `runEffect` checks the supplied context against what `Ask` reads.
78
+
79
+ ### Changed
80
+
81
+ - `initialInput` is threaded through the pipeline by `chain` rather than attached after the flow is built.
82
+
83
+ ### Fixed
84
+
85
+ - `onRun` fires once per `runEffect` call rather than once per `Retry` attempt.
86
+
87
+ ## [0.6.0] - 2026-05-01
88
+
89
+ ### Added
90
+
91
+ - **`Ask(next)`** reads the `context` passed to `runEffect` from any pipeline step, for ambient values such as tenant id or auth info.
92
+
93
+ ## [0.5.0] - 2026-04-27
94
+
95
+ ### Added
96
+
97
+ - **TypeScript declarations** in `index.d.ts` with full generics: `Success<T>`, `Failure<E>`, `Command`, `effectPipe` overloads, and `runEffect`'s return type. Type-level tests run under `tsd` as part of `npm test`.
98
+
99
+ ## [0.4.0] - 2026-03-31
100
+
101
+ ### Added
102
+
103
+ - `Command(cmd, next, meta)`: a `meta` object carried on the Command for hooks to read.
104
+ - `runEffect(effect, context)`: a context object passed to `onBeforeCommand`.
105
+ - `onBeforeCommand` hook, intercepting each Command before it runs.
106
+
107
+ ### Changed
108
+
109
+ - `configureTelemetry` is renamed `configureEffect`.
110
+
111
+ ## [0.3.0] - 2026-02-21
112
+
113
+ ### Added
114
+
115
+ - `configureTelemetry({ onStep, onRun })`: hooks wrapping each Command and each run, with an OpenTelemetry example.
116
+
117
+ ## [0.2.0] - 2025-11-29
118
+
119
+ ### Added
120
+
121
+ - JSDoc type annotations throughout `index.js`.
122
+
123
+ ## [0.1.2] - 2025-11-27
124
+
125
+ ### Changed
126
+
127
+ - README revisions.
128
+
129
+ ## [0.1.0] - 2025-11-24
130
+
131
+ ### Added
132
+
133
+ - Initial release: `Success`, `Failure`, `Command`, `effectPipe`, and `runEffect`.
134
+
135
+ [0.13.0]: https://github.com/aycangulez/pure-effect/compare/v0.12.0...v0.13.0
136
+ [0.12.0]: https://github.com/aycangulez/pure-effect/compare/v0.11.0...v0.12.0
137
+ [0.11.0]: https://github.com/aycangulez/pure-effect/compare/v0.10.0...v0.11.0
138
+ [0.10.0]: https://github.com/aycangulez/pure-effect/compare/v0.9.0...v0.10.0
139
+ [0.9.0]: https://github.com/aycangulez/pure-effect/compare/v0.8.0...v0.9.0
140
+ [0.8.0]: https://github.com/aycangulez/pure-effect/compare/v0.7.0...v0.8.0
141
+ [0.7.0]: https://github.com/aycangulez/pure-effect/compare/v0.6.0...v0.7.0
142
+ [0.6.0]: https://github.com/aycangulez/pure-effect/compare/v0.5.0...v0.6.0
143
+ [0.5.0]: https://github.com/aycangulez/pure-effect/compare/v0.4.0...v0.5.0
144
+ [0.4.0]: https://github.com/aycangulez/pure-effect/compare/v0.3.0...v0.4.0
145
+ [0.3.0]: https://github.com/aycangulez/pure-effect/compare/v0.2.0...v0.3.0
146
+ [0.2.0]: https://github.com/aycangulez/pure-effect/compare/v0.1.2...v0.2.0
147
+ [0.1.2]: https://github.com/aycangulez/pure-effect/compare/v0.1.0...v0.1.2
148
+ [0.1.0]: https://github.com/aycangulez/pure-effect/releases/tag/v0.1.0
package/README.md CHANGED
@@ -1,6 +1,6 @@
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-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) [![minified size (gzip)](https://img.shields.io/bundlejs/size/pure-effect)](https://bundlejs.com/?q=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** records what your business logic did in production and replays it anywhere: time-travel debugging for JavaScript and TypeScript, with zero dependencies. Business logic is plain data you can test without mocks.
6
6
 
@@ -9,7 +9,7 @@
9
9
  - Inject context without touching function signatures
10
10
  - Built-in retry, plus parallel execution that cancels sibling branches on the first failure
11
11
  - OpenTelemetry-ready via lifecycle hooks
12
- - Zero dependencies, 4 KB minified and gzipped
12
+ - Zero dependencies, about 4 KB minified and gzipped
13
13
  - Works in JavaScript and TypeScript (full generics, bundled `.d.ts`)
14
14
 
15
15
  ## Table of Contents
@@ -67,7 +67,7 @@ const saveUser = (input) => {
67
67
  // Build the flow. Validation runs now; no I/O happens until runEffect.
68
68
  const registerUserFlow = (input) => effectPipe(validateRegistration, ensureEmailAvailable, saveUser)(input);
69
69
 
70
- // Imperative shell: this is the only place side effects run
70
+ // This is the only place side effects actually run
71
71
  async function registerUser(input) {
72
72
  const result = await runEffect(registerUserFlow(input));
73
73
 
@@ -81,25 +81,51 @@ async function registerUser(input) {
81
81
 
82
82
  ## Testing Without Mocks
83
83
 
84
- Because pipelines return plain objects, you can assert on _what the code intends to do_ without executing any of it:
84
+ Because pipelines return plain objects, you can assert on _what the code intends to do_ without executing any of it.
85
+
86
+ Validation runs as soon as you build the flow, so testing it needs nothing else:
85
87
 
86
88
  ```js
87
- // 1. Test validation failure synchronously
88
89
  const badInput = { email: 'bad-email', password: '123' };
89
90
  assert.deepEqual(registerUserFlow(badInput), Failure('Invalid email.', badInput));
91
+ ```
92
+
93
+ Next, test the steps. Hand a step an answer and check what it returns:
94
+
95
+ ```js
96
+ const input = { email: 'test@test.com', password: 'password123' };
97
+
98
+ // The email is free, so the step passes the input on.
99
+ assert.deepEqual(ensureEmailAvailable(input).next(null), Success(input));
100
+
101
+ // The email is taken, so the flow stops here.
102
+ assert.deepEqual(ensureEmailAvailable(input).next({ id: 1 }), Failure('Email already in use.'));
103
+ ```
104
+
105
+ Then test the flow: the right calls, in the right order.
90
106
 
91
- // 2. Walk the pipeline to verify intent
92
- const step1 = registerUserFlow({ email: 'test@test.com', password: 'password123' });
107
+ ```js
108
+ const step1 = registerUserFlow(input);
93
109
  assert.equal(step1.cmd.name, 'cmdFindUser');
94
110
 
95
- const step2 = step1.next(null); // simulate "user not found"
111
+ const step2 = step1.next(null); // pretend no user was found
96
112
  assert.equal(step2.cmd.name, 'cmdSaveUser');
97
- // The full flow is verified. The database was never touched.
113
+ // The database was never touched.
98
114
  ```
99
115
 
116
+ Write both kinds. They catch different things, and the step tests are the ones that catch real bugs, because they are the only place you can see the value moving from one step to the next.
117
+
118
+ Say the email guard returned `Success(true)` by mistake, instead of `Success(input)`. The step test fails at once: it asked for the input back and got `true`. The flow test does not fail. `cmdFindUser` is still the first call and `cmdSaveUser` is still the second, so both assertions hold, and the flow saves `true` to the database instead of the user. That bug was in this README once, and it looked fine.
119
+
120
+ Two smaller things to know.
121
+
122
+ A step tested on its own has no `initialInput` on its `Failure`. That is why the assertion above is `Failure('Email already in use.')` with nothing after the message, while the validation one is `Failure('Invalid email.', badInput)`. `effectPipe` adds that value while it builds a flow, so a `Failure` only carries it when it came out of a flow.
123
+
124
+ Nothing can see inside a Command's function without running it. If `cmdFindUser` said `db.findUser(input.name)` instead of `input.email`, every test on this page would still pass. Keep those functions to a single call, and let an integration test cover them.
125
+
100
126
  ## How It Works
101
127
 
102
- A flow is a chain of pairs: an I/O call to make, plus a `next` function that receives its answer and returns whatever comes after, another Command, a Success, or a Failure. `runEffect` walks that chain in a loop.
128
+ A flow is a chain of pairs: an I/O call to make, plus a `next` function that receives its answer and returns whatever comes after, another Command, a Success, or a Failure. `runEffect` walks that chain in a loop.
103
129
 
104
130
  Recording and replay attach at the single point where a Command's function is called: recording wraps that call to write down each answer, and replay substitutes the recorded answer without making the call, which is why a replayed flow performs no I/O.
105
131
 
@@ -124,7 +150,7 @@ Replaying 'checkout' (3 recorded steps)
124
150
  Initial input: { "cartId": "cart_abc123", "promoCode": "FREE_YEAR_VIP" }
125
151
  Step 1: cmdFetchCart returned { "totalAmount": "120.00" }
126
152
  Step 2: cmdValidatePromo returned { "isValid": true, "discountValue": 100 }
127
- Step 3: cmdChargeCreditCard threw { "message": "Amount must be non-zero.", "code": "invalid_amount" }
153
+ Step 3: cmdChargeCard threw { "message": "Amount must be non-zero.", "code": "invalid_amount" }
128
154
  Replay finished with state: Failure
129
155
  ```
130
156
 
@@ -132,24 +158,43 @@ A production incident becomes a permanent test of the flow's logic, with no mock
132
158
 
133
159
  ```js
134
160
  it('prod incident 8f3a: a 100% promo produces a $0 charge', async () => {
135
- const result = await replayEffect(checkoutFlow(trace.initialInput), trace);
161
+ const { result } = await replayEffect(checkoutFlow(trace.initialInput), trace);
136
162
  assert.equal(result.type, 'Failure');
137
163
  assert.equal(result.error.code, 'invalid_amount');
138
164
  });
139
165
  ```
140
166
 
141
- 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.
167
+ The test verifies that the flow still takes the recorded path and handles the recorded outcomes the same way: a refactor that reorders or replaces a step raises a `TimeParadox` naming the path it diverged at, and changed error handling fails the assertion.
142
168
 
143
- **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.
169
+ One divergence is quieter. A flow that stops issuing Commands before the recording ends (a fix that skips the charge, say) mismatches nothing, so nothing mismatches and the replay can end in `Success` with recorded steps left over. `replayEffect` returns those steps as `unreached` beside the result, so a test can say which ones it expects to skip, and fail if the answer changes:
144
170
 
145
- **A determinism check is one round trip.** Record a flow, replay it immediately, and compare the outcomes. A step that computes a fresh nondeterministic value surfaces as a `TimeParadox` when it changes which Commands run, or as a mismatch in the final value:
171
+ ```js
172
+ it('incident 8f3a fixed: a 100% promo checks out without a charge', async () => {
173
+ const { result, unreached } = await replayEffect(checkoutFlow(trace.initialInput), trace);
174
+ assert.equal(result.type, 'Success');
175
+ assert.deepEqual(
176
+ unreached.map((e) => e.command),
177
+ ['cmdChargeCard']
178
+ );
179
+ });
180
+ ```
181
+
182
+ For every other recording of the flow, `unreached` should be empty: a fix that skips a step it was not meant to skip then fails loudly instead of passing.
183
+
184
+ **A trace records what each Command returned, not what it was asked.** It does not need to. Given the recorded input and the recorded results, the flow does the same thing again, so replaying to a step rebuilds the exact arguments that Command ran with, in the real code, under a debugger if you want one. Storing them as well would only double what `redact` has to cover, since arguments are usually the sensitive half of a call.
185
+
186
+ **Anything that varies between runs belongs inside a Command.** The current time or a random ID counts as 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.
187
+
188
+ **Checking this takes one round trip.** Record a flow, replay it immediately, and compare the outcomes. A step that computes a fresh nondeterministic value surfaces as a `TimeParadox` when it changes which Commands run, or as a mismatch in the final value:
146
189
 
147
190
  ```js
148
191
  const { result, trace } = await recordEffect(registerUserFlow, input);
149
- const replayed = await replayEffect(registerUserFlow(input), trace);
192
+ const { result: replayed } = await replayEffect(registerUserFlow(input), trace);
150
193
  assert.deepEqual(replayed, result); // fails if any step computed a fresh value
151
194
  ```
152
195
 
196
+ The comparison holds for a `Failure` as well: an error that crossed the trace is revived with the same message, name, cause, and custom properties, and compares deep-equal to the one the Command threw. The one exception is an error class of your own, which revives as a plain `Error` carrying that class's name; compare `error.name` and `error.message` there.
197
+
153
198
  **The trace format is yours.** `replayEffect` also takes a resolver function in place of a trace, so OpenTelemetry spans, a log pipeline, or a database table work as well as the JSON that `recorder` produces.
154
199
 
155
200
  ```js
@@ -203,7 +248,7 @@ Recording stores the context alongside the trace, so `Ask` replays with the valu
203
248
 
204
249
  ## Retrying Transient Failures
205
250
 
206
- `Retry` wraps any Effect tree with retry-on-failure semantics. Like everything else in Pure Effect, the retry configuration is a plain object you can inspect and assert on without running anything.
251
+ `Retry` runs part of a flow again when it fails. Like everything else in Pure Effect, the retry configuration is a plain object you can inspect and assert on without running anything.
207
252
 
208
253
  **Wrap the Command that fails, not the pipeline.** Every attempt re-runs the whole wrapped tree, including Commands that already succeeded:
209
254
 
@@ -215,7 +260,7 @@ Retry(effectPipe(chargeCard, sendReceipt)(order), { attempts: 3 });
215
260
  effectPipe(chargeCard, (charge) => Retry(sendReceipt(charge), { attempts: 3 }))(order);
216
261
  ```
217
262
 
218
- Wrapping a pipeline is safe only when every Command in it is idempotent.
263
+ Wrapping a pipeline is safe only when every Command in it is idempotent (safe to run more than once).
219
264
 
220
265
  ```js
221
266
  import { Success, Failure, Command, Retry, runEffect } from 'pure-effect';
@@ -236,7 +281,7 @@ assert.equal(weatherFn.options.attempts, 3);
236
281
 
237
282
  When all attempts are exhausted, `runEffect` returns a structured `Failure`:
238
283
 
239
- ```js
284
+ ```text
240
285
  { retryExhausted: true, lastError: <the last error>, attempts: 3 }
241
286
  ```
242
287
 
@@ -252,13 +297,13 @@ const fetchPrice = (sku) =>
252
297
  });
253
298
  ```
254
299
 
255
- Fallback steps are recorded under their own trace paths, so a replay reproduces the fallback exactly, and a fallback never starts in a `Parallel` branch that a sibling's failure has already cancelled. With `onExhausted` set, the exhaustion error never escapes, so in TypeScript the node contributes the fallback's error type to the pipeline's union instead of `RetryExhaustedError<E>`.
300
+ Fallback steps are recorded under their own trace paths, so a replay reproduces the fallback exactly, and a fallback never starts in a `Parallel` branch that a sibling's failure has already cancelled. With `onExhausted` set, the exhaustion error never escapes, so in TypeScript the `Retry` contributes the fallback's error type to the pipeline's union instead of `RetryExhaustedError<E>`.
256
301
 
257
302
  Every attempt is a recorded step, so a replay reproduces the exact sequence of failures. It also skips the delays.
258
303
 
259
304
  ## Running Effects in Parallel
260
305
 
261
- `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.
306
+ `Parallel` runs several flows at the same time 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.
262
307
 
263
308
  ```js
264
309
  import { Success, Command, Parallel } from 'pure-effect';
@@ -271,7 +316,7 @@ const loadProfile = (userId) =>
271
316
 
272
317
  `Ask` context flows into all parallel branches without any extra wiring.
273
318
 
274
- **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:
319
+ **Cancelling is a request, not a guarantee, 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:
275
320
 
276
321
  ```js
277
322
  // Cancellable: the request is aborted the moment a sibling branch fails.
@@ -285,9 +330,9 @@ Outside a `Parallel` the function is called with no arguments at all, so nothing
285
330
 
286
331
  ## Composing Larger Flows
287
332
 
288
- `effectPipe` is a straight line, but flows rarely are. Branching and fan-in work with the existing primitives. Here is how:
333
+ `effectPipe` is a straight line, but flows rarely are. Branching and joining work with the pieces already here. Here is how:
289
334
 
290
- **A step can return a sub-pipeline.** `effectPipe(...)(value)` returns an Effect like any other, so a step can branch into a whole sub-flow, and the sub-flow's `Failure` short-circuits the outer pipeline exactly like a local one:
335
+ **A step can return a sub-pipeline.** `effectPipe(...)(value)` returns an Effect like any other, so a step can branch into a whole sub-flow, and the sub-flow's `Failure` stops the outer pipeline exactly like a local one:
291
336
 
292
337
  ```js
293
338
  const processOrder = (order) => (order.isGift ? giftFlow(order) : standardFlow(order));
@@ -295,7 +340,7 @@ const processOrder = (order) => (order.isGift ? giftFlow(order) : standardFlow(o
295
340
  const fulfillment = effectPipe(validateOrder, processOrder, scheduleShipping);
296
341
  ```
297
342
 
298
- **Independent fan-in: `Parallel`.** When a later step needs several values that do not depend on each other, run them concurrently and join structurally. With `next` omitted, the branch results arrive as an ordered array:
343
+ **Joining values that do not depend on each other: `Parallel`.** When a later step needs several such values, run them at the same time and collect the results. With `next` omitted, the branch results arrive as an ordered array:
299
344
 
300
345
  ```js
301
346
  const loadCheckout = effectPipe(
@@ -304,7 +349,7 @@ const loadCheckout = effectPipe(
304
349
  );
305
350
  ```
306
351
 
307
- **Dependent fan-in: join locally.** When step B needs step A's result and step C needs both, carry both forward in a value shaped for the next step. A local sub-pipeline that closes over its own parameter makes the join without nested callbacks:
352
+ **Joining values that do depend on each other: join locally.** When step B needs step A's result and step C needs both, carry both forward in a value shaped for the next step. A local sub-pipeline that closes over its own parameter makes the join without nested callbacks:
308
353
 
309
354
  ```js
310
355
  const applyLoyaltyDiscount = (orderId) =>
@@ -319,7 +364,7 @@ const applyLoyaltyDiscount = (orderId) =>
319
364
 
320
365
  ## Which Errors Are Data
321
366
 
322
- There is no catch combinator, and that is deliberate. A flow's outcomes divide into two kinds, and the division decides how each is written.
367
+ There is no catch, and that is deliberate. A flow's outcomes divide into two kinds, and the division decides how each is written.
323
368
 
324
369
  **An outcome the flow handles is data.** A Command's `next` receives the result and can branch into any Effect, including a whole fallback sub-pipeline. When the I/O itself can reject, catch inside the Command's `cmd` function and return the miss as a value:
325
370
 
@@ -341,9 +386,9 @@ const fetchPrice = (sku) => {
341
386
 
342
387
  The failed live attempt is recorded as that Command's result, error included, so a replay takes the same fallback branch and an incident trace still becomes a regression test. Nothing is hidden; the miss is simply categorized as what it is, an outcome the flow was written to handle.
343
388
 
344
- **A `Failure` means abort.** It short-circuits everything and lands in the shell, which is the one place that decides what a dead flow means: an HTTP status, a queue retry, an alert. Reserving `Failure` for outcomes the flow cannot handle keeps its meaning brutally simple; a reader never has to scan up the tree for a handler, because there is none. A catch combinator would reintroduce exactly the non-local control flow that writing effects as data is meant to eliminate.
389
+ **A `Failure` means abort.** It stops everything and lands in the shell, which is the one place that decides what a dead flow means: an HTTP status, a queue retry, an alert. Reserving `Failure` for outcomes the flow cannot handle keeps its meaning brutally simple; a reader never has to scan up the tree for a handler, because there is none. A catch would reintroduce exactly the non-local control flow that writing effects as data is meant to eliminate.
345
390
 
346
- The rule of thumb: if you would handle it, return it; if you would only report it, fail with it. The one handled outcome that cannot be modeled as data is retry exhaustion, since the failing happens inside the `Retry` node; that case has its own in-flow form, the `onExhausted` option in [Retrying Transient Failures](#retrying-transient-failures).
391
+ The rule of thumb: if you would handle it, return it; if you would only report it, fail with it. The one handled outcome that cannot be modeled as data is retry exhaustion, since the failing happens inside `Retry`; that case has its own in-flow form, the `onExhausted` option in [Retrying Transient Failures](#retrying-transient-failures).
347
392
 
348
393
  ## TypeScript: Typed Errors and Context
349
394
 
@@ -391,7 +436,7 @@ const result = await runEffect(findProduct('abc'), { tenant: 'acme', requestId:
391
436
 
392
437
  ## API Reference
393
438
 
394
- ### Primitives
439
+ ### Building blocks
395
440
 
396
441
  #### `Success(value)`
397
442
 
@@ -413,7 +458,9 @@ Returns `{ type: 'Command', cmd, next, meta }`.
413
458
 
414
459
  ```js
415
460
  Command(cmdFn, next, { name: 'chargeCard' }); // 1. meta.name, independent of how cmdFn was written
416
- Command(function cmdChargeCard() { ... }, next); // 2. the function's own name
461
+ Command(function cmdChargeCard() {
462
+ return api.charge();
463
+ }, next); // 2. the function's own name
417
464
  Command(() => api.charge(), next); // 3. neither, so 'anonymous'
418
465
  ```
419
466
 
@@ -462,14 +509,14 @@ One shape to avoid in either language:
462
509
 
463
510
  Commands are data, so a Command that is constructed and discarded never runs, and a `Failure` it would have produced is swallowed. Return the effect itself, or have that Command's own `next` return the value the rest of the pipeline needs.
464
511
 
465
- ### Interpreter
512
+ ### Running a flow
466
513
 
467
514
  #### `runEffect(effect, context?, callConfig?)`
468
515
 
469
- Traverses the effect tree, executes Commands with `async/await`, resolves `Ask` with the supplied `context`, and returns the final `Success` or `Failure`.
516
+ Walks the flow, runs each Command with `async/await`, resolves `Ask` with the supplied `context`, and returns the final `Success` or `Failure`.
470
517
 
471
518
  - `context`: Passed to `Ask`'s next function and to `onBeforeCommand`. `context.flowName` names the workflow in telemetry.
472
- - `callConfig`: Per-call overrides for `onStep`, `onRun`, `onBeforeCommand`, and `retry`. Takes precedence over `configureEffect` globals.
519
+ - `callConfig`: Per-call `onStep`, `onRun`, `onBeforeCommand`, and `retry`, added to the `configureEffect` wiring unless `inherit: false`, which ignores that wiring for the run. See [`configureEffect`](#configureeffectconfigs).
473
520
  - `onRun` fires exactly once per `runEffect` call. Retry attempts run inside that single span.
474
521
 
475
522
  A step that returns something other than an Effect is a bug in the flow, not a domain failure, so it throws an `EffectTypeError` naming the step rather than resolving to a `Failure`:
@@ -488,25 +535,57 @@ The same check catches a missing `return`, a Command's next function returning a
488
535
  - `onBeforeCommand(command, context)` fires before each Command; throw to abort.
489
536
  - `retry: { attempts?, delay?, backoff? }` global retry defaults.
490
537
 
491
- Several configurations can be passed and are merged, which is how independent concerns share the one slot each hook has:
538
+ Each call adds a layer of hooks on top of those already installed and returns a function that removes that layer. Layers merge, and several configurations passed to one call are merged the same way, so the two forms below are equivalent. Everything about the merge is visible in what runs during a single `runEffect`:
492
539
 
493
540
  ```js
494
541
  configureEffect(telemetryHooks(), recordingHooks({ sink }));
542
+
543
+ // or, as two layers:
544
+ configureEffect(telemetryHooks());
545
+ configureEffect(recordingHooks({ sink }));
495
546
  ```
496
547
 
497
- - `onStep` and `onRun` are wrappers, so they nest: the first configuration is outermost, the last sits closest to the Command, and a thrown Command unwinds from the last back to the first.
498
- - `onBeforeCommand` interceptors all run, in the order given.
499
- - `retry` merges, with later configurations winning.
500
- - Merging is per call and does not accumulate: a later `configureEffect` replaces the previous wiring, an unset slot returns to its default, and calling it with nothing resets everything.
501
- - Returns a function that restores whatever was installed before the call, so hooks can be installed without owning the global wiring forever. It undoes this call only while its wiring is still in effect, so a later `configureEffect` is never silently discarded:
548
+ ```
549
+ runEffect(flow(input))
550
+ │
551
+ ├─ telemetry.onRun first configuration: outermost
552
+ │ └─ recording.onRun last configuration: innermost
553
+ │ │
554
+ │ │ for each Command:
555
+ │ ├─ telemetry.onBeforeCommand interceptors all run, in the order given
556
+ │ ├─ recording.onBeforeCommand
557
+ │ ├─ telemetry.onStep
558
+ │ │ └─ recording.onStep closest to the Command
559
+ │ │ └─ cmd()
560
+ │ │ ┌─ recording.onStep returns a result, or a thrown error, unwinds
561
+ │ ├─ telemetry.onStep returns from the inside out
562
+ │ │
563
+ │ ┌─ recording.onRun returns
564
+ ├─ telemetry.onRun returns
565
+ │
566
+ retry: { ...telemetry.retry, ...recording.retry } later configurations win
567
+ ```
568
+
569
+ Removing a layer takes out exactly that layer, whatever was installed after it, so a library can add its own hooks without touching its host's and give them back when it is done. Calling `configureEffect()` with no arguments removes every layer. A call whose arguments are all `undefined`, such as a conditional `configureEffect(flag ? hooks : undefined)`, installs nothing and removes nothing.
502
570
 
503
571
  ```js
504
- const restore = configureEffect(telemetryHooks());
572
+ const remove = configureEffect(telemetryHooks());
505
573
  // ... later
506
- restore();
574
+ remove();
575
+ ```
576
+
577
+ **A per-call `callConfig` is added to the configured wiring, or ignores it.** By default the call's hooks merge over the configured ones by the same rules as above: wrappers nest with the configured one outside, interceptors run configured first, and `retry` combines with the call's values winning. `inherit: false` leaves the configured wiring out of the run entirely. Given a configured wiring `C` and a call `K` that supplies only `onStep` and `retry`:
578
+
579
+ ```
580
+ inherit: true (default) inherit: false
581
+
582
+ onRun C.onRun (none)
583
+ onBeforeCommand C.onBeforeCommand (none)
584
+ onStep C.onStep( K.onStep( cmd ) ) K.onStep
585
+ retry { ...C.retry, ...K.retry } { ...defaults, ...K.retry }
507
586
  ```
508
587
 
509
- **A per-call `callConfig` replaces a global hook rather than layering over it.** `runEffect(effect, context, { onStep })` uses that `onStep` _instead of_ the configured one, so a `recordEffect` call inside an instrumented application produces a run with no spans, and a `replayEffect` call produces one with no recording. This is deliberate: replay works by displacing `onStep` so the Command never executes. Pass both concerns in one `callConfig` when a single run needs both.
588
+ `recordEffect` inherits, so recording inside an application that already has tracing keeps its spans. `replayEffect` passes `inherit: false` unless `hooks: true`.
510
589
 
511
590
  ### Recording and replay
512
591
 
@@ -514,7 +593,7 @@ restore();
514
593
 
515
594
  Returns `{ onStep, entries, toTrace }`. Pass `onStep` to `runEffect` or `configureEffect` to record what every Command returned.
516
595
 
517
- 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.
596
+ 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 flow, 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.
518
597
 
519
598
  - `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:
520
599
 
@@ -540,26 +619,27 @@ Runs a flow for real while recording, returning `{ result, trace }`. Accepts `re
540
619
 
541
620
  #### `replayEffect(effect, traceOrResolver, options?)`
542
621
 
543
- Replays an effect tree, feeding recorded results to Commands instead of running them.
622
+ Replays a flow, feeding recorded results to Commands instead of running them. Returns `{ result, unreached }`: the flow's outcome, and the recorded entries the flow never asked for (empty when every step was reached). A flow that stops early mismatches nothing and raises no `TimeParadox`, so `unreached` is where that divergence shows. For a resolver only `{ result }` is returned, since only a trace knows what it holds.
544
623
 
545
624
  - `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`.
546
625
  - `options.context`: context for `Ask`; pass the recorded context.
547
626
  - `options.onMissing`: `'throw'` (default) fails on an unrecorded step; `'execute'` runs the real Command, giving a recorded prefix with a live tail.
548
627
  - `options.fastRetry` (default `true`): strip `Retry` delays.
549
- - `options.hooks` (default `false`): whether configured `onRun` / `onBeforeCommand` may fire. Off so a replay cannot reach a telemetry backend.
628
+ - `options.hooks` (default `false`): run the replay inside the global hooks, resolver innermost, so configured hooks observe it, a global recorder included. Off ignores the global hooks, so a replay cannot reach a telemetry backend or a trace sink. Global `retry` defaults apply either way, since a replay has to make the attempts production made.
550
629
  - `options.onResolved(step, outcome)`: observe each replayed step.
551
630
 
631
+ A trace whose entries carry no `path` (written by hand, or recorded before paths existed) is matched positionally, which is exact for a sequential flow; a `Parallel` step in such a trace is refused with a `ReplayError`, since completion order cannot tell its branches apart.
632
+
552
633
  #### `timeTravel(flowFn, traceLog, options?)`
553
634
 
554
- 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`.
635
+ Replays a trace and narrates each step with its recorded duration, naming any recorded steps that were never reached and warning when the trace's `version` differs from `options.version`. Returns the flow's outcome; for the unreached entries as data, use `replayEffect`. Takes `options.context` to override the trace's, and `options.log` in place of `console.log`.
555
636
 
556
637
  ## Limitations
557
638
 
558
- - **`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.
559
- - **`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.
560
- - **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.
639
+ - **`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 it is safe to run more than once.
640
+ - **Cancelling a `Parallel` branch is a request, 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.
641
+ - **A `Failure` is complete by design.** It carries the full error and the `initialInput` that `effectPipe` attached to 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.
561
642
  - **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.
562
- - **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.
563
643
  - **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.
564
644
  - **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.
565
- - **`Retry` delays cannot be overridden from `callConfig`.** Per-use options are merged over call config, so a delay written at the call site always wins. `replayEffect` works around this by rewriting `Retry` nodes; other callers cannot.
645
+ - **`Retry` delays cannot be overridden from `callConfig`.** Per-use options are merged over call config, so a delay written at the call site always wins. `replayEffect` works around this by rewriting the `Retry`s in the flow; other callers cannot.