pure-effect 0.7.0 → 0.9.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/README.md CHANGED
@@ -1,72 +1,66 @@
1
1
  # Pure Effect
2
2
 
3
- [![npm version](https://img.shields.io/npm/v/pure-effect)](https://www.npmjs.com/package/pure-effect)
4
- [![bundle size](https://img.shields.io/badge/minified%2Bgzipped-%3C1KB-brightgreen)](https://bundlephobia.com/package/pure-effect)
5
- [![license](https://img.shields.io/npm/l/pure-effect)](./LICENSE)
3
+ [![npm version](https://img.shields.io/npm/v/pure-effect)](https://www.npmjs.com/package/pure-effect) [![bundle size](https://img.shields.io/badge/minified%2Bgzipped-%3C3.4KB-brightgreen)](https://bundlephobia.com/package/pure-effect) [![license](https://img.shields.io/npm/l/pure-effect)](https://github.com/aycangulez/pure-effect/blob/main/LICENSE)
6
4
 
7
- **Pure Effect** is a zero-dependency effect library for JavaScript and TypeScript with built-in support for dependency injection, retry, and OpenTelemetry where business logic is plain data you can test without mocks.
5
+ **Pure Effect** is a zero-dependency effect library for JavaScript and TypeScript with time-travel debugging, dependency injection, retry, and OpenTelemetry, where business logic is plain data you can test without mocks.
8
6
 
7
+ - Replay a production failure locally, with no database and no network
9
8
  - No mocks needed to test async pipelines
10
9
  - Inject context without touching function signatures
11
- - Built-in retry with configurable delay and backoff
10
+ - Built-in retry and parallel execution with configurable delay, backoff, and `Promise.all` semantics
12
11
  - OpenTelemetry-ready via lifecycle hooks
13
- - Zero dependencies, under 1 KB minified and gzipped
12
+ - Zero dependencies, under 3.4 KB minified and gzipped
14
13
  - Works in JavaScript and TypeScript (full generics, bundled `.d.ts`)
15
- - Five primitives: learn the whole API in an afternoon
16
14
 
17
15
  ## Table of Contents
18
16
 
19
17
  - [Installation](#installation)
20
18
  - [Quick Start](#quick-start)
21
19
  - [Testing Without Mocks](#testing-without-mocks)
20
+ - [Time-Travel Debugging](#time-travel-debugging)
21
+ - [Recording in Production](#recording-in-production)
22
22
  - [Passing Runtime Context](#passing-runtime-context)
23
23
  - [Retrying Transient Failures](#retrying-transient-failures)
24
+ - [Running Effects in Parallel](#running-effects-in-parallel)
24
25
  - [TypeScript: Typed Errors and Context](#typescript-typed-errors-and-context)
25
26
  - [Why Pure Effect](#why-pure-effect)
26
27
  - [API Reference](#api-reference)
28
+ - [Limitations](#limitations)
27
29
 
28
30
  ## Installation
29
31
 
30
- ```bash
32
+ ```
31
33
  npm install pure-effect
32
34
  ```
33
35
 
34
36
  ## Quick Start
35
37
 
36
- Here is a complete example of a user registration flow:
38
+ A complete user registration flow. Every step receives the value the previous step produced, so the pipeline reads top-to-bottom as the flow of data:
37
39
 
38
40
  ```js
39
41
  import { Success, Failure, Command, effectPipe, runEffect } from 'pure-effect';
40
42
 
43
+ // Pure. No I/O, instantly testable.
41
44
  const validateRegistration = (input) => {
42
45
  if (!input.email.includes('@')) return Failure('Invalid email.');
43
46
  if (input.password.length < 8) return Failure('Password too short.');
44
47
  return Success(input);
45
48
  };
46
49
 
47
- // Theese function return a Command object. They do NOT call the database.
48
- const findUser = (email) => {
49
- const cmdFindUser = () => db.findUser(email);
50
- return Command(cmdFindUser, (user) => Success(user));
50
+ // These functions return a Command object. They do NOT call the database.
51
+ // Name the thunk: traces, replay matching, and telemetry spans are keyed on it.
52
+ const ensureEmailAvailable = (input) => {
53
+ const cmdFindUser = () => db.findUser(input.email);
54
+ return Command(cmdFindUser, (found) => (found ? Failure('Email already in use.') : Success(input)));
51
55
  };
52
56
 
57
+ // With no continuation, the result passes straight through.
53
58
  const saveUser = (input) => {
54
59
  const cmdSaveUser = () => db.saveUser(input);
55
- return Command(cmdSaveUser, (saved) => Success(saved));
60
+ return Command(cmdSaveUser);
56
61
  };
57
62
 
58
- const ensureEmailAvailable = (user) => (user ? Failure('Email already in use.') : Success(true));
59
-
60
- // effectPipe threads the output of each step into the next.
61
- // When you need to use a captured variable instead of the piped value,
62
- // wrap the call in an arrow function.
63
- const registerUserFlow = (input) =>
64
- effectPipe(
65
- validateRegistration, // input -> Success(input)
66
- () => findUser(input.email), // ignores piped value, uses captured input
67
- ensureEmailAvailable, // found user (or null) -> Success(true)
68
- () => saveUser(input) // ignores piped value, uses captured input
69
- )(input);
63
+ const registerUserFlow = (input) => effectPipe(validateRegistration, ensureEmailAvailable, saveUser)(input);
70
64
 
71
65
  // Imperative shell: this is the only place side effects run
72
66
  async function registerUser(input) {
@@ -82,35 +76,92 @@ async function registerUser(input) {
82
76
 
83
77
  ## Testing Without Mocks
84
78
 
85
- Because pipelines return plain objects, you can assert on _what the code intends to do_ without executing any of it. Using the registration flow defined in the previous section:
79
+ Because pipelines return plain objects, you can assert on _what the code intends to do_ without executing any of it:
86
80
 
87
81
  ```js
88
82
  // 1. Test validation failure synchronously
89
83
  const badInput = { email: 'bad-email', password: '123' };
90
- const result = registerUserFlow(badInput);
91
-
92
- assert.deepEqual(result, Failure('Invalid email.', badInput));
84
+ assert.deepEqual(registerUserFlow(badInput), Failure('Invalid email.', badInput));
93
85
 
94
86
  // 2. Walk the pipeline to verify intent
95
- const goodInput = { email: 'test@test.com', password: 'password123' };
96
- const step1 = registerUserFlow(goodInput);
97
-
98
- // First thing the code does: look up the user
99
- assert.equal(step1.type, 'Command');
87
+ const step1 = registerUserFlow({ email: 'test@test.com', password: 'password123' });
100
88
  assert.equal(step1.cmd.name, 'cmdFindUser');
101
89
 
102
- // Next thing: save the user (simulate "user not found" result)
103
- const step2 = step1.next(null);
104
- assert.equal(step2.type, 'Command');
90
+ const step2 = step1.next(null); // simulate "user not found"
105
91
  assert.equal(step2.cmd.name, 'cmdSaveUser');
106
92
  // The full flow is verified. The database was never touched.
107
93
  ```
108
94
 
109
- ## Passing Runtime Context
95
+ ## Time-Travel Debugging
96
+
97
+ Record what each Command returned, then feed those results back into the same flow to retrace the exact path a request took, with no database and no network attached.
110
98
 
111
- Some values come from the framework layer such as an authenticated tenant, a request trace ID, an environment config rather than from the data being processed. `Ask` lets a pipeline step read the `context` object passed to `runEffect` without threading it through every function signature.
99
+ ```js
100
+ import { recordEffect, replayEffect, timeTravel } from 'pure-effect';
112
101
 
113
- In the example below, `ctx.tenant` is resolved from the JWT by the router. The domain layer never receives it as a parameter; it just asks for it when needed:
102
+ // Record a real run.
103
+ const { result, trace } = await recordEffect(checkoutFlow, input, { version: process.env.BUILD_ID });
104
+
105
+ // Later, somewhere else, with nothing connected:
106
+ await timeTravel(checkoutFlow, trace);
107
+ ```
108
+
109
+ ```
110
+ Replaying 'checkout' (3 recorded steps)
111
+ Initial input: { "cartId": "cart_abc123", "promoCode": "FREE_YEAR_VIP" }
112
+ Step 1: cmdFetchCart returned { "totalAmount": "120.00" }
113
+ Step 2: cmdValidatePromo returned { "isValid": true, "discountValue": 100 }
114
+ Step 3: cmdChargeCreditCard threw { "message": "Amount must be non-zero.", "code": "invalid_amount" }
115
+ Replay finished with state: Failure
116
+ ```
117
+
118
+ A production incident becomes a permanent test of the flow's logic, with no mocks and no fixtures:
119
+
120
+ ```js
121
+ it('prod incident 8f3a: a 100% promo produces a $0 charge', async () => {
122
+ const result = await replayEffect(checkoutFlow(trace.initialInput), trace);
123
+ assert.equal(result.type, 'Failure');
124
+ assert.equal(result.error.code, 'invalid_amount');
125
+ });
126
+ ```
127
+
128
+ The test verifies that the flow still takes the recorded path and handles the recorded outcomes the same way: a refactor that reorders or drops a step raises a `TimeParadox`, and changed error handling fails the assertion.
129
+
130
+ **Nondeterminism belongs inside a Command.** A value that varies between runs (the current time, a random ID) is I/O as far as replay is concerned. Wrap it in a Command and it is recorded and replayed like any other response; a step that calls `Date.now()` directly computes a fresh value on every replay and silently diverges from the trace.
131
+
132
+ **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.
133
+
134
+ ```js
135
+ // A resolver answers one question: what did production get back for this step?
136
+ const resolve = (step) => ({ result: mySpans[step.index].attributes.output });
137
+ await replayEffect(checkoutFlow(input), resolve);
138
+ ```
139
+
140
+ ## Recording in Production
141
+
142
+ `recordEffect` covers tests and scripts, where one call site holds the whole run. To record an application without touching any call site, install the hooks once at startup (see: `examples/recording-example.js`).
143
+
144
+ ```js
145
+ import { configureEffect } from 'pure-effect';
146
+ import { recordingHooks } from './recording-example.js';
147
+ import { telemetryHooks } from './opentelemetry-example.js';
148
+
149
+ configureEffect(
150
+ telemetryHooks(),
151
+ recordingHooks({
152
+ sink: (trace) => putObject(`traces/${trace.flowName}/${requestId}.json`, JSON.stringify(trace)),
153
+ redact: (value, name, kind) => (kind === 'initialInput' ? { ...value, password: '[redacted]' } : value),
154
+ maxEntries: 500,
155
+ keep: (result) => result.type === 'Failure' // the default; keep everything, or sample
156
+ })
157
+ );
158
+ ```
159
+
160
+ Successful runs are buffered and discarded by default, so steady-state cost is memory only. `redact` runs before anything enters the trace, including the stored `initialInput` and `context`. `maxEntries` caps a runaway trace, reporting the overflow as `dropped`. A trace is plain JSON, so a sink can be S3 or a database column.
161
+
162
+ ## Passing Runtime Context
163
+
164
+ Some values come from the framework layer (an authenticated tenant, a request trace ID, an environment config) rather than from the data being processed. `Ask` lets a pipeline step read the `context` object passed to `runEffect` without threading it through every function signature:
114
165
 
115
166
  ```js
116
167
  import { Success, Failure, Command, Ask, effectPipe, runEffect } from 'pure-effect';
@@ -121,28 +172,32 @@ const findProduct = (productId) =>
121
172
  return Command(cmdFindProduct, (product) => (product ? Success(product) : Failure('Product not found.')));
122
173
  });
123
174
 
124
- const reserveStock = (product) =>
125
- Ask((ctx) => {
126
- const cmdReserveStock = () => db[ctx.tenant].reserveStock(product.id);
127
- return Command(cmdReserveStock, (reserved) => Success({ product, reserved }));
128
- });
129
-
130
- const checkoutFlow = (productId) => effectPipe(findProduct, ({ product }) => reserveStock(product))(productId);
131
-
132
175
  app.post('/checkout', async (req, res) => {
133
- const result = await runEffect(checkoutFlow(req.body.productId), {
134
- tenant: req.tenant
135
- });
176
+ const result = await runEffect(checkoutFlow(req.body.productId), { tenant: req.tenant });
136
177
  res.json(result);
137
178
  });
138
179
  ```
139
180
 
181
+ Recording stores the context alongside the trace, so `Ask` replays with the values the original request saw.
182
+
140
183
  ## Retrying Transient Failures
141
184
 
142
185
  `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.
143
186
 
187
+ **Wrap the Command that fails, not the pipeline.** Every attempt re-runs the whole wrapped tree, including Commands that already succeeded:
188
+
189
+ ```js
190
+ // Dangerous: a flaky receipt step charges the customer again on every attempt.
191
+ Retry(effectPipe(chargeCard, sendReceipt)(order), { attempts: 3 });
192
+
193
+ // Correct: only the step that fails transiently is retried.
194
+ effectPipe(chargeCard, (charge) => Retry(sendReceipt(charge), { attempts: 3 }))(order);
195
+ ```
196
+
197
+ Wrapping a pipeline is safe only when every Command in it is idempotent.
198
+
144
199
  ```js
145
- import { Success, Failure, Command, Retry, effectPipe, runEffect } from 'pure-effect';
200
+ import { Success, Failure, Command, Retry, runEffect } from 'pure-effect';
146
201
 
147
202
  const fetchWeather = (city) => {
148
203
  const cmdFetchWeather = () =>
@@ -153,167 +208,249 @@ const fetchWeather = (city) => {
153
208
  );
154
209
  };
155
210
 
156
- // Assert on the retry config without making any network calls
157
211
  const weatherFn = fetchWeather('Tokyo');
158
212
  assert.equal(weatherFn.type, 'Retry');
159
213
  assert.equal(weatherFn.options.attempts, 3);
160
- assert.equal(weatherFn.effect.type, 'Command');
161
214
  ```
162
215
 
163
216
  When all attempts are exhausted, `runEffect` returns a structured `Failure`:
164
217
 
165
218
  ```js
166
- {
167
- retryExhausted: true,
168
- lastError: <the last error>,
169
- attempts: 3
170
- }
219
+ { retryExhausted: true, lastError: <the last error>, attempts: 3 }
171
220
  ```
172
221
 
173
- Global defaults can be set via `configureEffect` and overridden per-use:
222
+ Every attempt is a recorded step, so a replay reproduces the exact sequence of failures. It also skips the delays, because waiting out production backoff during debugging is never what you want.
223
+
224
+ ## Running Effects in Parallel
225
+
226
+ `Parallel` runs multiple Effect trees concurrently and passes their results to `next` as an ordered array. If any effect fails, `next` is not called and the `Failure` propagates immediately.
174
227
 
175
228
  ```js
176
- configureEffect({
177
- retry: { attempts: 3, delay: 100, backoff: 1 } // flat delay by default
178
- });
229
+ import { Success, Command, Parallel } from 'pure-effect';
179
230
 
180
- // Per-use options are merged on top of global defaults
181
- Retry(effect, { delay: 500 }); // uses global attempts, custom delay
231
+ const loadProfile = (userId) =>
232
+ Parallel([getUser(userId), getPermissions(userId)], ([user, permissions]) => Success({ user, permissions }));
182
233
  ```
183
234
 
235
+ `Ask` context flows into all parallel branches without any extra wiring.
236
+
237
+ Note for replay: branches complete out of array order, so recorded positions are not stable. Replay flows containing `Parallel` with `{ strict: false }`, which matches per-Command queues instead of positions.
238
+
184
239
  ## TypeScript: Typed Errors and Context
185
240
 
186
241
  ### Error union across pipeline steps
187
242
 
188
- Each step in `effectPipe` carries its own error type. The compiler collects them into a union automatically, so the return type of `runEffect` tells you exactly which errors are possible with full exhaustive narrowing, no extra boilerplate.
243
+ Each step in `effectPipe` carries its own error type. The compiler collects them into a union automatically:
189
244
 
190
245
  ```ts
191
- import { effectPipe, runEffect, Failure, Success, Command } from 'pure-effect';
192
- import type { Effect } from 'pure-effect';
193
-
194
246
  type ValidationError = 'invalid_email' | 'weak_password';
195
247
  type ApiError = 'network_timeout' | 'rate_limited';
196
248
 
197
- const validate = (input: { email: string }): Effect<{ email: string }, ValidationError> => {
198
- if (!input.email.includes('@')) return Failure('invalid_email');
199
- return Success(input);
200
- };
201
-
202
- const submit = (input: { email: string }): Effect<{ id: number }, ApiError> => {
203
- const cmdSubmitUser = () =>
204
- fetch('/api/users', { method: 'POST', body: JSON.stringify(input) }).then((r) => r.json());
205
- return Command(cmdSubmitUser, (data) => Success(data));
206
- };
207
-
208
- const flow = effectPipe(validate, submit);
209
- const result = await runEffect(flow({ email: 'user@example.com' }));
210
- // result: SuccessState<{ id: number }> | FailureState<ValidationError | ApiError>
249
+ const validate = (input: { email: string }): Effect<{ email: string }, ValidationError> => { ... };
250
+ const submit = (input: { email: string }): Effect<{ id: number }, ApiError> => { ... };
211
251
 
252
+ const result = await runEffect(effectPipe(validate, submit)({ email: 'user@example.com' }));
212
253
  if (result.type === 'Failure') {
213
254
  result.error; // 'invalid_email' | 'weak_password' | 'network_timeout' | 'rate_limited'
214
255
  }
215
256
  ```
216
257
 
258
+ Value types thread through the pipeline as well, so a step that reads a field the accumulator does not have yet is a compile error rather than a runtime surprise. This only works while every step accepts the piped value. A step written as `() => doSomething(outer)` discards it, and the chain stops being checked.
259
+
217
260
  ### Typed context with `Ask`
218
261
 
219
- `Effect<T, E, Ctx>` carries a third type parameter for the context object. TypeScript enforces that `runEffect` receives a matching value when you annotate `Ask` with a context type:
262
+ `Effect<T, E, Ctx>` carries a third type parameter for the context object:
220
263
 
221
264
  ```ts
222
- import { Ask, Command, Success, Failure, effectPipe, runEffect } from 'pure-effect';
223
- import type { Effect } from 'pure-effect';
224
-
225
265
  type AppContext = { tenant: string; requestId: string };
226
266
 
227
267
  const findProduct = (productId: string): Effect<Product, 'not_found', AppContext> =>
228
- Ask<Product, 'not_found', AppContext>((ctx) => {
229
- const cmdFindProduct = () => db[ctx.tenant].findProduct(productId);
230
- return Command(cmdFindProduct, (product) => (product ? Success(product) : Failure('not_found')));
231
- });
268
+ Ask<Product, 'not_found', AppContext>((ctx) => { ... });
232
269
 
233
- // ctx is typed as AppContext, no cast needed
234
270
  const result = await runEffect(findProduct('abc'), { tenant: 'acme', requestId: '123' });
235
271
  ```
236
272
 
237
273
  ## Why Pure Effect
238
274
 
239
- **vs. Effect-TS:** Effect-TS is a full functional programming ecosystem with fibers, streaming, schema validation, dependency injection, and is probably the right choice if you need that breadth. It arguably comes with a steep learning curve though. Pure Effect targets a narrower scope (testable pipelines, context injection, retry) and can be learned in an afternoon.
275
+ **vs. Effect-TS:** Effect-TS is a full functional programming ecosystem with fibers, streaming, schema validation, structured concurrency, and more, though it comes with a steep learning curve. Pure Effect covers a narrower scope: testable pipelines, context injection, retry, parallel execution, and replayable traces. If you need fibers, in-flight cancellation, or streaming, Effect-TS is the right tool.
240
276
 
241
- **vs. fp-ts:** fp-ts applies category theory abstractions (functors, monads) to TypeScript. Pure Effect borrows only the concept of effects as data and expresses it without that vocabulary.
277
+ **vs. fp-ts:** fp-ts brings category theory abstractions (functors, monads, applicatives) to TypeScript. Pure Effect borrows only the concept of effects as data, without that vocabulary.
242
278
 
243
- **vs. plain async/await with mocks:** Mocks can drift from real implementations silently. Pure Effect sidesteps the problem: business logic never executes I/O, so there is nothing to mock.
279
+ **vs. plain async/await with mocks:** A mock that passes all your tests but diverges from what the real driver does is worse than no test. Business logic never executes I/O, so there is nothing to mock.
244
280
 
245
- **When to use something else:** If your codebase has little async I/O or test isolation isn't a pain point, plain async/await is the simpler choice.
281
+ **When to use something else:** If your codebase has little async I/O, or test isolation and production debuggability are not pain points, plain async/await is the simpler choice.
246
282
 
247
283
  ## API Reference
248
284
 
249
- ### `Success(value)`
285
+ ### Primitives
250
286
 
251
- Returns `{ type: 'Success', value }`. Represents a successful computation result.
287
+ #### `Success(value)`
252
288
 
253
- ### `Failure(error, initialInput?)`
289
+ Returns `{ type: 'Success', value }`.
254
290
 
255
- Returns `{ type: 'Failure', error, initialInput }`. Stops the pipeline immediately and short-circuits any remaining steps.
291
+ #### `Failure(error, initialInput?)`
256
292
 
257
- ### `Command(cmdFn, nextFn, meta?)`
293
+ Returns `{ type: 'Failure', error, initialInput }`. Stops the pipeline immediately.
258
294
 
259
- Returns `{ type: 'Command', cmd, next, meta }`.
295
+ #### `Command(cmdFn, nextFn?, meta?)`
260
296
 
261
- - `cmdFn`: A function (sync or async) that performs the side effect.
262
- - `nextFn`: Receives the result of `cmdFn` and returns the next Effect.
263
- - `meta`: Optional metadata (available to `onBeforeCommand`).
297
+ Returns `{ type: 'Command', cmd, next, meta }`.
264
298
 
265
- ### `Ask(nextFn)`
299
+ - `cmd`: A function (sync or async) that performs the side effect.
300
+ - `next`: Receives the result of `cmd` and returns the next Effect. Optional, defaulting to `(result) => Success(result)`, which is what most Commands want.
301
+ - `meta`: Optional metadata, passed to `onBeforeCommand`. A string `meta.name` becomes the Command's identity. Otherwise, the name of the function is used (`cmd.name`).
266
302
 
267
- Returns `{ type: 'Ask', next }`. Passes the `context` from `runEffect` into `nextFn`, which returns the next Effect. Works at any point in a pipeline.
303
+ **Every Command needs an identity**, because it is what test assertions, trace entries, replay matching, and telemetry spans are keyed on. It resolves in this order:
268
304
 
269
305
  ```js
270
- const findProduct = (productId) =>
271
- Ask((ctx) => {
272
- const cmdFindProduct = () => db[ctx.tenant].findProduct(productId);
273
- return Command(cmdFindProduct, (product) => (product ? Success(product) : Failure('Product not found.')));
274
- });
306
+ Command(cmdFn, next, { name: 'chargeCard' }); // 1. meta.name, independent of how cmdFn was written
307
+ Command(function cmdChargeCard() { ... }, next); // 2. the function's own name
308
+ Command(() => api.charge(), next); // 3. neither, so 'anonymous'
275
309
  ```
276
310
 
277
- ### `Retry(effect, options?)`
311
+ Prefer `meta.name` in code that gets minified, since a mangler rewrites function names and would rename every step of every trace. Naming the function stays fine everywhere else, and is what the examples do.
312
+
313
+ #### `Ask(nextFn)`
314
+
315
+ Returns `{ type: 'Ask', next }`. Passes the `context` from `runEffect` into `nextFn`.
278
316
 
279
- Returns `{ type: 'Retry', effect, options, next }`. Wraps any Effect with retry-on-failure semantics.
317
+ #### `Retry(effect, options?)`
318
+
319
+ Returns `{ type: 'Retry', effect, options, next }`.
280
320
 
281
- - `effect`: Any Effect: a `Command`, an `effectPipe` result, or another `Retry`.
282
321
  - `options.attempts`: Max retries, not counting the first try (default: `3`).
283
322
  - `options.delay`: Ms before the first retry (default: `100`).
284
323
  - `options.backoff`: Multiplier applied to delay on each attempt (default: `1`, flat).
285
324
 
286
- On exhaustion, returns `Failure({ retryExhausted: true, lastError, attempts })`.
325
+ #### `Parallel(effects, next)`
326
+
327
+ Returns `{ type: 'Parallel', effects, next }`. Runs all effects concurrently via `Promise.all`. The first `Failure` is returned immediately and `next` is not called.
328
+
329
+ ### Combinators
287
330
 
288
- ### `effectPipe(...functions)`
331
+ #### `effectPipe(...functions)`
289
332
 
290
- Composes functions into a sequential pipeline. Each function receives the unwrapped `Success` value from the previous step. A `Failure` from any step stops the pipeline immediately.
333
+ Composes functions into a sequential pipeline. Each function receives the unwrapped `Success` value from the previous step, and a `Failure` from any step stops the pipeline.
291
334
 
292
- When a step needs to use a captured variable instead of the piped value, wrap it in an arrow function:
335
+ A step does not have to use the value it receives. In JavaScript, closing over something from the enclosing scope is fine:
293
336
 
294
337
  ```js
295
- const flow = (input) =>
296
- effectPipe(
297
- validate, // receives input
298
- () => findUser(input.email), // ignores piped value, uses captured input
299
- ensureAvailable,
300
- () => saveUser(input) // ignores piped value, uses captured input
301
- )(input);
338
+ // The last step ignores what came before and uses the enclosing input instead.
339
+ const registerUserFlow = (input) => effectPipe(validateRegistration, () => saveUser(input))(input);
302
340
  ```
303
341
 
304
- ### `runEffect(effect, context?, callConfig?)`
342
+ In TypeScript that step is where the type chain stops being checked, because a function that ignores its parameter constrains nothing about what produced it. See [TypeScript: Typed Errors and Context](#typescript-typed-errors-and-context). Threading the value through every step is what keeps the whole pipeline checked, which is why the Quick Start is written that way.
305
343
 
306
- The interpreter. Traverses the effect tree, executes Commands with `async/await`, resolves `Ask` with the supplied `context`, and returns the final `Success` or `Failure`.
344
+ One shape to avoid in either language:
307
345
 
308
- - `context`: Optional object passed to `Ask` continuations and `onBeforeCommand`. `context.flowName` names the workflow in telemetry.
346
+ ```js
347
+ (value) => {
348
+ sendWelcomeEmail(value); // built and thrown away: the email is never sent
349
+ return Success(value);
350
+ };
351
+ ```
352
+
353
+ 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.
354
+
355
+ ### Interpreter
356
+
357
+ #### `runEffect(effect, context?, callConfig?)`
358
+
359
+ Traverses the effect tree, executes Commands with `async/await`, resolves `Ask` with the supplied `context`, and returns the final `Success` or `Failure`.
360
+
361
+ - `context`: Passed to `Ask` continuations and `onBeforeCommand`. `context.flowName` names the workflow in telemetry.
309
362
  - `callConfig`: Per-call overrides for `onStep`, `onRun`, `onBeforeCommand`, and `retry`. Takes precedence over `configureEffect` globals.
310
- - `onRun` fires exactly once per `runEffect` call. Retry attempts run inside that single span. The interpreter does not re-enter `runEffect` per attempt.
363
+ - `onRun` fires exactly once per `runEffect` call. Retry attempts run inside that single span.
364
+
365
+ 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`:
311
366
 
312
- ### `configureEffect(options)`
367
+ ```
368
+ Step 'validateRegistration' returned a plain object. Return Success, Failure, Command, Ask,
369
+ Retry, or Parallel: a plain value has to be wrapped, as in Success(value).
370
+ ```
371
+
372
+ The same check catches a missing `return`, a Command continuation that returns a plain value, and `runEffect(flow)` where `runEffect(flow(input))` was meant. A Command that throws is still a `Failure`.
373
+
374
+ #### `configureEffect(...configs)`
313
375
 
314
376
  - `onRun(effect, pipeline, flowName)` wraps the entire workflow; must `await pipeline()`.
315
- - `onStep(name, type, op)` wraps each Command; must `await op()` and return its result.
316
- - `onBeforeCommand(command, context)` ires before each Command; throw to abort the pipeline.
377
+ - `onStep(name, type, op)` wraps each Command; must `await op()` and return its result. Returning a value _without_ calling `op()` is how replay works.
378
+ - `onBeforeCommand(command, context)` fires before each Command; throw to abort.
317
379
  - `retry: { attempts?, delay?, backoff? }` global retry defaults.
318
380
 
319
- See **opentelemetry-example.js** in the repository for a complete OpenTelemetry wiring example.
381
+ Several configurations can be passed and are merged, which is how independent concerns share the one slot each hook has:
382
+
383
+ ```js
384
+ configureEffect(telemetryHooks(), recordingHooks({ sink }));
385
+ ```
386
+
387
+ - `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.
388
+ - `onBeforeCommand` interceptors all run, in the order given.
389
+ - `retry` merges, with later configurations winning.
390
+ - 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.
391
+ - 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:
392
+
393
+ ```js
394
+ const restore = configureEffect(telemetryHooks());
395
+ // ... later
396
+ restore();
397
+ ```
398
+
399
+ **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.
400
+
401
+ ### Recording and replay
402
+
403
+ #### `recorder(options?)`
404
+
405
+ Returns `{ onStep, entries, toTrace }`. Pass `onStep` to `runEffect` or `configureEffect` to record what every Command returned.
406
+
407
+ Each entry is `{ command, result, durationMs }`, or `{ command, error, durationMs }` when the Command threw, so a trace also answers which step was slow. Results are snapshotted on capture, so a later step that mutates a returned object cannot rewrite what the trace says an earlier step saw. Values that cannot be structurally cloned, such as an object holding a function, are stored by reference instead. Recording cannot change a run: a `redact` that throws records `'[redaction failed]'` for that step instead of failing the flow.
408
+
409
+ - `options.redact(value, name, kind)`: The single place PII is kept out of a trace. It sees every value a trace holds, with `kind` distinguishing them:
410
+
411
+ ```js
412
+ recorder({
413
+ redact: (value, name, kind) => {
414
+ if (kind === 'initialInput') return { ...value, password: '[redacted]' };
415
+ if (kind === 'context') return { ...value, authToken: '[redacted]' };
416
+ if (kind === 'error') return { ...value, attempted: '[redacted]' };
417
+ return name === 'cmdFetchUser' ? { ...value, email: '[redacted]' } : value;
418
+ }
419
+ });
420
+ ```
421
+
422
+ `'result'` and `'error'` arrive with the Command's name; `'initialInput'` and `'context'` are the trace's own fields and pass the kind as the name. Redacting `initialInput` rarely breaks replay, since replay feeds recorded results rather than running Commands, so a stripped field only matters when the flow's control flow reads it. Redacting `context` does break `Ask` replay if you strip something a step reads.
423
+
424
+ - `options.maxEntries`: Cap trace length; overflow is counted in `dropped`.
425
+ - `options.stack`: Record stack traces for thrown errors (off by default).
426
+
427
+ #### `recordEffect(flowFn, initialInput, options?)`
428
+
429
+ Runs a flow for real while recording, returning `{ result, trace }`. Accepts `recorder` options plus `context` and `version`. For tests and scripts; in an application install `recorder().onStep` via `configureEffect` instead.
430
+
431
+ #### `replayEffect(effect, traceOrResolver, options?)`
432
+
433
+ Replays an effect tree, feeding recorded results to Commands instead of running them.
434
+
435
+ - `traceOrResolver`: a trace (or bare entries array) to replay directly, or a resolver function for traces stored in some other shape. A resolver returns `{ result }`, `{ error }`, or `undefined` if the step is unrecorded. A malformed trace rejects with a `ReplayError`.
436
+ - `options.strict` (default `true`): consume trace entries in recorded order and raise `TimeParadox` if the flow asks for a different Command. Set `false` for flows containing `Parallel`, whose branches complete out of array order, to match per-Command queues instead of positions. Ignored when a resolver was passed, since it already matches its own way.
437
+ - `options.context`: context for `Ask`; pass the recorded context.
438
+ - `options.onMissing`: `'throw'` (default) fails on an unrecorded step; `'execute'` runs the real Command, giving a recorded prefix with a live tail.
439
+ - `options.fastRetry` (default `true`): strip `Retry` delays.
440
+ - `options.hooks` (default `false`): whether configured `onRun` / `onBeforeCommand` may fire. Off so a replay cannot reach a telemetry backend.
441
+ - `options.onResolved(step, outcome)`: observe each replayed step.
442
+
443
+ #### `timeTravel(flowFn, traceLog, options?)`
444
+
445
+ Replays a trace and narrates each step with its recorded duration, warning when recorded steps were never reached or when the trace's `version` differs from `options.version`. Durations are reported only under `strict` matching, where a replayed step maps back to its recorded entry by position. Takes `options.strict` (default `true`; set `false` for `Parallel`), `options.context` to override the trace's, and `options.log` in place of `console.log`.
446
+
447
+ ## Limitations
448
+
449
+ - **`Retry` repeats the whole wrapped tree.** Commands that already succeeded run again on every attempt, so wrapping a pipeline re-executes its side effects. Wrap the single Command that fails transiently unless every Command in the tree is idempotent.
450
+ - **`Parallel` has no cancellation.** Every branch runs to completion even after another fails, because the results are collected with `Promise.all` and the first `Failure` is selected afterwards. A branch that fails validation does not prevent a sibling branch from writing.
451
+ - **A `Failure` is complete by design.** It carries the full error and the `initialInput` that `effectPipe` stamped on it, and neither is trimmed, because a test asserting on a Failure and a developer debugging one both need everything. Keeping PII out of a **trace** is `redact`'s job. Keeping it out of your **logs** is the shell's: log `result.error` rather than serializing the whole `Failure`, which for a login or registration flow holds the credentials that flow received.
452
+ - **Replay reproduces observed inputs, not concurrency.** A stale read replays exactly. A race between two concurrent requests does not: a trace records one flow's view, and `Parallel` interleaving is not captured.
453
+ - **A trace records responses, not requests.** Command arguments live in a closure and are unreachable, so a trace shows what a call returned but not what was passed to it. This also bounds what a replay verifies: the flow's path and its handling of recorded outcomes. The upside is that arguments can never leak into a trace.
454
+ - **Global configuration is per module instance.** `configureEffect` writes to module-level state, so two copies of the library in one process, from a dual ESM and CJS resolution, two versions in a dependency tree, or a worker thread, each carry their own wiring. Code that configures one gets nothing in the other, with no error.
455
+ - **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.
456
+ - **`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.