pure-effect 0.12.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,22 +81,48 @@ 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
+ ```
90
92
 
91
- // 2. Walk the pipeline to verify intent
92
- const step1 = registerUserFlow({ email: 'test@test.com', password: 'password123' });
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.
106
+
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
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.
@@ -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
 
@@ -140,7 +166,7 @@ it('prod incident 8f3a: a 100% promo produces a $0 charge', async () => {
140
166
 
141
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
- One divergence is quieter. A flow that stops issuing Commands before the recording ends (a fix that skips the charge, say) mismatches nothing, so no paradox fires 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:
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
171
  ```js
146
172
  it('incident 8f3a fixed: a 100% promo checks out without a charge', async () => {
@@ -155,9 +181,11 @@ it('incident 8f3a fixed: a 100% promo checks out without a charge', async () =>
155
181
 
156
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.
157
183
 
158
- **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.
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.
159
185
 
160
- **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:
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:
161
189
 
162
190
  ```js
163
191
  const { result, trace } = await recordEffect(registerUserFlow, input);
@@ -220,7 +248,7 @@ Recording stores the context alongside the trace, so `Ask` replays with the valu
220
248
 
221
249
  ## Retrying Transient Failures
222
250
 
223
- `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.
224
252
 
225
253
  **Wrap the Command that fails, not the pipeline.** Every attempt re-runs the whole wrapped tree, including Commands that already succeeded:
226
254
 
@@ -232,7 +260,7 @@ Retry(effectPipe(chargeCard, sendReceipt)(order), { attempts: 3 });
232
260
  effectPipe(chargeCard, (charge) => Retry(sendReceipt(charge), { attempts: 3 }))(order);
233
261
  ```
234
262
 
235
- 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).
236
264
 
237
265
  ```js
238
266
  import { Success, Failure, Command, Retry, runEffect } from 'pure-effect';
@@ -253,7 +281,7 @@ assert.equal(weatherFn.options.attempts, 3);
253
281
 
254
282
  When all attempts are exhausted, `runEffect` returns a structured `Failure`:
255
283
 
256
- ```js
284
+ ```text
257
285
  { retryExhausted: true, lastError: <the last error>, attempts: 3 }
258
286
  ```
259
287
 
@@ -269,13 +297,13 @@ const fetchPrice = (sku) =>
269
297
  });
270
298
  ```
271
299
 
272
- 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>`.
273
301
 
274
302
  Every attempt is a recorded step, so a replay reproduces the exact sequence of failures. It also skips the delays.
275
303
 
276
304
  ## Running Effects in Parallel
277
305
 
278
- `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.
279
307
 
280
308
  ```js
281
309
  import { Success, Command, Parallel } from 'pure-effect';
@@ -288,7 +316,7 @@ const loadProfile = (userId) =>
288
316
 
289
317
  `Ask` context flows into all parallel branches without any extra wiring.
290
318
 
291
- **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:
292
320
 
293
321
  ```js
294
322
  // Cancellable: the request is aborted the moment a sibling branch fails.
@@ -302,9 +330,9 @@ Outside a `Parallel` the function is called with no arguments at all, so nothing
302
330
 
303
331
  ## Composing Larger Flows
304
332
 
305
- `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:
306
334
 
307
- **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:
308
336
 
309
337
  ```js
310
338
  const processOrder = (order) => (order.isGift ? giftFlow(order) : standardFlow(order));
@@ -312,7 +340,7 @@ const processOrder = (order) => (order.isGift ? giftFlow(order) : standardFlow(o
312
340
  const fulfillment = effectPipe(validateOrder, processOrder, scheduleShipping);
313
341
  ```
314
342
 
315
- **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:
316
344
 
317
345
  ```js
318
346
  const loadCheckout = effectPipe(
@@ -321,7 +349,7 @@ const loadCheckout = effectPipe(
321
349
  );
322
350
  ```
323
351
 
324
- **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:
325
353
 
326
354
  ```js
327
355
  const applyLoyaltyDiscount = (orderId) =>
@@ -336,7 +364,7 @@ const applyLoyaltyDiscount = (orderId) =>
336
364
 
337
365
  ## Which Errors Are Data
338
366
 
339
- 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.
340
368
 
341
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:
342
370
 
@@ -358,9 +386,9 @@ const fetchPrice = (sku) => {
358
386
 
359
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.
360
388
 
361
- **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.
362
390
 
363
- 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).
364
392
 
365
393
  ## TypeScript: Typed Errors and Context
366
394
 
@@ -408,7 +436,7 @@ const result = await runEffect(findProduct('abc'), { tenant: 'acme', requestId:
408
436
 
409
437
  ## API Reference
410
438
 
411
- ### Primitives
439
+ ### Building blocks
412
440
 
413
441
  #### `Success(value)`
414
442
 
@@ -430,7 +458,9 @@ Returns `{ type: 'Command', cmd, next, meta }`.
430
458
 
431
459
  ```js
432
460
  Command(cmdFn, next, { name: 'chargeCard' }); // 1. meta.name, independent of how cmdFn was written
433
- 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
434
464
  Command(() => api.charge(), next); // 3. neither, so 'anonymous'
435
465
  ```
436
466
 
@@ -479,14 +509,14 @@ One shape to avoid in either language:
479
509
 
480
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.
481
511
 
482
- ### Interpreter
512
+ ### Running a flow
483
513
 
484
514
  #### `runEffect(effect, context?, callConfig?)`
485
515
 
486
- 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`.
487
517
 
488
518
  - `context`: Passed to `Ask`'s next function and to `onBeforeCommand`. `context.flowName` names the workflow in telemetry.
489
- - `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).
490
520
  - `onRun` fires exactly once per `runEffect` call. Retry attempts run inside that single span.
491
521
 
492
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`:
@@ -505,25 +535,57 @@ The same check catches a missing `return`, a Command's next function returning a
505
535
  - `onBeforeCommand(command, context)` fires before each Command; throw to abort.
506
536
  - `retry: { attempts?, delay?, backoff? }` global retry defaults.
507
537
 
508
- 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`:
509
539
 
510
540
  ```js
511
541
  configureEffect(telemetryHooks(), recordingHooks({ sink }));
542
+
543
+ // or, as two layers:
544
+ configureEffect(telemetryHooks());
545
+ configureEffect(recordingHooks({ sink }));
512
546
  ```
513
547
 
514
- - `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.
515
- - `onBeforeCommand` interceptors all run, in the order given.
516
- - `retry` merges, with later configurations winning.
517
- - 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.
518
- - 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.
519
570
 
520
571
  ```js
521
- const restore = configureEffect(telemetryHooks());
572
+ const remove = configureEffect(telemetryHooks());
522
573
  // ... later
523
- 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 }
524
586
  ```
525
587
 
526
- **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`.
527
589
 
528
590
  ### Recording and replay
529
591
 
@@ -531,7 +593,7 @@ restore();
531
593
 
532
594
  Returns `{ onStep, entries, toTrace }`. Pass `onStep` to `runEffect` or `configureEffect` to record what every Command returned.
533
595
 
534
- 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.
535
597
 
536
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:
537
599
 
@@ -557,13 +619,13 @@ Runs a flow for real while recording, returning `{ result, trace }`. Accepts `re
557
619
 
558
620
  #### `replayEffect(effect, traceOrResolver, options?)`
559
621
 
560
- Replays an effect tree, 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.
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.
561
623
 
562
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`.
563
625
  - `options.context`: context for `Ask`; pass the recorded context.
564
626
  - `options.onMissing`: `'throw'` (default) fails on an unrecorded step; `'execute'` runs the real Command, giving a recorded prefix with a live tail.
565
627
  - `options.fastRetry` (default `true`): strip `Retry` delays.
566
- - `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.
567
629
  - `options.onResolved(step, outcome)`: observe each replayed step.
568
630
 
569
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.
@@ -574,11 +636,10 @@ Replays a trace and narrates each step with its recorded duration, naming any re
574
636
 
575
637
  ## Limitations
576
638
 
577
- - **`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.
578
- - **`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.
579
- - **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.
580
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.
581
- - **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.
582
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.
583
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.
584
- - **`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.
package/index.d.ts CHANGED
@@ -296,22 +296,26 @@ export interface EffectConfiguration {
296
296
  }
297
297
 
298
298
  /**
299
- * Configures the global runner. Several configurations can be passed and are merged, which is how
300
- * independent concerns share the one slot each hook has: `onStep` and `onRun` nest with the first
301
- * outermost, `onBeforeCommand` interceptors all run in order, and `retry` merges with later winning.
302
- * Merging does not accumulate across calls: a later call replaces the previous wiring, and calling
303
- * this with nothing resets every slot to its default.
304
- *
305
- * Returns a function that puts back whatever was installed when this call was made, so a caller can
306
- * install hooks without owning the global wiring forever. Restoring is a snapshot rather than a stack:
307
- * if a later call has run since, restoring reverts to the older snapshot and discards the newer one.
299
+ * Adds a layer to the global runner's wiring and returns a function that removes it. Layers merge:
300
+ * `onStep` and `onRun` nest with the earliest layer outermost, `onBeforeCommand` interceptors all run
301
+ * in the order installed, and `retry` merges with later layers winning. Several configurations passed
302
+ * to one call form one layer, which is the same as installing them in separate calls. Calling with no
303
+ * arguments at all removes every layer; a call whose arguments are all `undefined` adds and removes nothing.
308
304
  */
309
305
  export declare function configureEffect(...configs: (EffectConfiguration | undefined)[]): () => void;
310
306
 
307
+ /**
308
+ * A per-call configuration for `runEffect`. With `inherit: true` (default) the call's hooks are added to
309
+ * the wiring `configureEffect` installed: where both define a hook the two nest, global outermost, and
310
+ * `retry` merges with the call winning. With `inherit: false` the global wiring is ignored, so unset
311
+ * slots fall back to the library defaults.
312
+ */
313
+ export type CallConfiguration = EffectConfiguration & { inherit?: boolean };
314
+
311
315
  export declare function runEffect<T, E = unknown, Ctx = unknown>(
312
316
  effect: Effect<T, E, Ctx>,
313
317
  context?: Ctx,
314
- callConfig?: EffectConfiguration
318
+ callConfig?: CallConfiguration
315
319
  ): Promise<SuccessState<T> | FailureState<E>>;
316
320
 
317
321
  export type ReplayStep = {
@@ -402,7 +406,10 @@ export interface ReplayOptions<Ctx = unknown> {
402
406
  context?: Ctx;
403
407
  /** Strip `Retry` delays so a replay does not wait out production backoff (default `true`). */
404
408
  fastRetry?: boolean;
405
- /** Allow configured `onRun` / `onBeforeCommand` to fire (default `false`). */
409
+ /**
410
+ * Run the replay inside the global hooks, resolver innermost, so configured hooks observe it
411
+ * (default `false`, which ignores the global hooks). Global `retry` defaults apply either way.
412
+ */
406
413
  hooks?: boolean;
407
414
  /**
408
415
  * What to do when the Resolver has no recording for a step.