pure-effect 0.13.0 → 0.14.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.
Files changed (5) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +259 -113
  3. package/index.d.ts +1118 -134
  4. package/index.js +647 -156
  5. package/package.json +3 -1
package/README.md CHANGED
@@ -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, about 4 KB minified and gzipped
12
+ - Zero dependencies, about 6 KB minified and gzipped
13
13
  - Works in JavaScript and TypeScript (full generics, bundled `.d.ts`)
14
14
 
15
15
  ## Table of Contents
@@ -27,6 +27,7 @@
27
27
  - [Which Errors Are Data](#which-errors-are-data)
28
28
  - [TypeScript: Typed Errors and Context](#typescript-typed-errors-and-context)
29
29
  - [Why Pure Effect](#why-pure-effect)
30
+ - [Load Tests](#load-tests)
30
31
  - [API Reference](#api-reference)
31
32
  - [Limitations](#limitations)
32
33
 
@@ -38,7 +39,7 @@ npm install pure-effect
38
39
 
39
40
  ## Quick Start
40
41
 
41
- 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. Each I/O step is a pair: the call to make, plus a `next` function that receives its answer and decides what follows.
42
+ A user registration flow. Each step receives the value the previous step produced. An I/O step is a pair: the call to make, and a `next` function that receives the answer and decides what happens next.
42
43
 
43
44
  ```js
44
45
  import { Success, Failure, Command, effectPipe, runEffect } from 'pure-effect';
@@ -50,8 +51,8 @@ const validateRegistration = (input) => {
50
51
  return Success(input);
51
52
  };
52
53
 
53
- // These following two functions return a Command object. They do NOT call the database.
54
- // Name the commands since traces, replay matching, and telemetry spans are keyed on them.
54
+ // The next two functions return a Command object. They do not call the database.
55
+ // Name the commands: traces, replay matching, and telemetry spans use the names.
55
56
  const ensureEmailAvailable = (input) => {
56
57
  const cmdFindUser = () => db.findUser(input.email);
57
58
  const next = (found) => (found ? Failure('Email already in use.') : Success(input));
@@ -81,7 +82,7 @@ async function registerUser(input) {
81
82
 
82
83
  ## Testing Without Mocks
83
84
 
84
- Because pipelines return plain objects, you can assert on _what the code intends to do_ without executing any of it.
85
+ Pipelines return plain objects, so you can check what the code will do without running it.
85
86
 
86
87
  Validation runs as soon as you build the flow, so testing it needs nothing else:
87
88
 
@@ -113,27 +114,25 @@ assert.equal(step2.cmd.name, 'cmdSaveUser');
113
114
  // The database was never touched.
114
115
  ```
115
116
 
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
+ Write both kinds, because they catch different bugs. Only step tests see the value passed from one step to the next.
117
118
 
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
+ For example, if the email guard returned `Success(true)` instead of `Success(input)`, the step test would fail: it expects the input back and gets `true`. The flow test would still pass, because `cmdFindUser` is still the first call and `cmdSaveUser` the second, while the flow saves `true` to the database instead of the user.
119
120
 
120
- Two smaller things to know.
121
+ A step tested on its own returns a `Failure` without `initialInput`. That is why the assertion above is `Failure('Email already in use.')`, while the validation one is `Failure('Invalid email.', badInput)`: `effectPipe` adds the input when it builds a flow.
121
122
 
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.
123
+ Tests cannot 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
124
 
126
125
  ## How It Works
127
126
 
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.
127
+ A flow is a chain of pairs: an I/O call, and a `next` function that receives its answer and returns the next Command, a Success, or a Failure. `runEffect` walks the chain in a loop.
129
128
 
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.
129
+ Recording and replay hook into the one place where a Command's function is called. Recording writes down each answer. Replay supplies the recorded answer instead of making the call, so a replayed flow does no I/O.
131
130
 
132
- Building a flow runs the pure steps immediately: `registerUserFlow(badInput)` returns the validation `Failure` synchronously, which is why the tests above do not need `runEffect`. Only the I/O inside Commands requires it.
131
+ Building a flow runs the pure steps right away: `registerUserFlow(badInput)` returns the validation `Failure` synchronously, so the tests above do not need `runEffect`. Only Commands need it.
133
132
 
134
133
  ## Time-Travel Debugging
135
134
 
136
- 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.
135
+ Record what each Command returned, then feed those results back into the flow to retrace the path a request took, with no database or network.
137
136
 
138
137
  ```js
139
138
  import { recordEffect, replayEffect, timeTravel } from 'pure-effect';
@@ -154,7 +153,7 @@ Step 3: cmdChargeCard threw { "message": "Amount must be non-zero.", "code": "in
154
153
  Replay finished with state: Failure
155
154
  ```
156
155
 
157
- A production incident becomes a permanent test of the flow's logic, with no mocks and no fixtures:
156
+ A production incident becomes a regression test, with no mocks or fixtures:
158
157
 
159
158
  ```js
160
159
  it('prod incident 8f3a: a 100% promo produces a $0 charge', async () => {
@@ -164,9 +163,9 @@ it('prod incident 8f3a: a 100% promo produces a $0 charge', async () => {
164
163
  });
165
164
  ```
166
165
 
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.
166
+ The test checks that the flow still takes the recorded path and handles the recorded outcomes the same way. If a refactor reorders or replaces a step, the replay raises a `TimeParadox` naming where it diverged. If the error handling changes, the assertion fails.
168
167
 
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:
168
+ A flow that stops issuing Commands before the recording ends (for example, a fix that skips the charge) raises no `TimeParadox`, and the replay can end in `Success` with recorded steps left over. `replayEffect` returns those steps as `unreached`, so a test can assert which ones it expects to skip:
170
169
 
171
170
  ```js
172
171
  it('incident 8f3a fixed: a 100% promo checks out without a charge', async () => {
@@ -179,13 +178,17 @@ it('incident 8f3a fixed: a 100% promo checks out without a charge', async () =>
179
178
  });
180
179
  ```
181
180
 
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.
181
+ For every other recording, `unreached` should be empty, so a fix that skips a step by accident fails the test.
182
+
183
+ This works when the removed step is the last one the flow reaches. Remove a step from the middle and every later step moves to a different path, so the replay stops at a `TimeParadox`. In that case `unreached` lists the steps the replay never got to, not the steps the fix made unnecessary.
184
+
185
+ **Replay checks the path, not the values.** It shows that the flow asks for the same Commands in the same order and handles the same answers. It cannot check what the flow computes, because every Command's result comes from the recording, not from the new code. If you fix how a refund amount is calculated and replay the bad run, the charge step gets the old amount from the recording: the replay reports `Success`, returns the value from before the fix, and flags nothing. Test a change to a value against the pure step that computes it.
183
186
 
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.
187
+ **A trace records what each Command returned, not the arguments it was called with.** Given the recorded input and results, the flow does the same thing again, so replaying up to a step rebuilds the arguments that Command ran with. You can inspect them in a debugger, but you cannot assert on them. Storing arguments would also double what `redact` has to cover, since arguments are usually the sensitive part of a call.
185
188
 
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.
189
+ **Put anything that varies between runs inside a Command.** For replay, the current time or a random ID counts as I/O. Wrapped in a Command, it is recorded and replayed like any other result. A step that calls `Date.now()` directly gets a new value on every replay and drifts from the trace.
187
190
 
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:
191
+ **To check for this,** record a flow, replay it right away, and compare the outcomes. A step that computes a new value on each run shows up as a `TimeParadox` if it changes which Commands run, or as a different final value:
189
192
 
190
193
  ```js
191
194
  const { result, trace } = await recordEffect(registerUserFlow, input);
@@ -193,9 +196,9 @@ const { result: replayed } = await replayEffect(registerUserFlow(input), trace);
193
196
  assert.deepEqual(replayed, result); // fails if any step computed a fresh value
194
197
  ```
195
198
 
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.
199
+ This also works for a `Failure`: an error read back from a trace keeps its message, name, cause, custom properties, and an `AggregateError`'s list of errors, and is deep-equal to the one the Command threw. The exception is a custom error class, which comes back as a plain `Error` with that class's name; compare `error.name` and `error.message` instead.
197
200
 
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.
201
+ **You choose the trace format.** `replayEffect` also accepts a resolver function instead of a trace, so you can replay from OpenTelemetry spans, a log pipeline, or a database table, not only from the JSON that `recorder` produces.
199
202
 
200
203
  ```js
201
204
  // A resolver answers one question: what did production get back for this step?
@@ -205,7 +208,7 @@ await replayEffect(checkoutFlow(input), resolve);
205
208
 
206
209
  ## Recording in Production
207
210
 
208
- `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`).
211
+ `recordEffect` suits tests and scripts, where one call covers the whole run. To record an application without changing any call site, install the hooks once at startup (see `examples/recording-example.js`).
209
212
 
210
213
  ```js
211
214
  import { configureEffect } from 'pure-effect';
@@ -223,11 +226,17 @@ configureEffect(
223
226
  );
224
227
  ```
225
228
 
226
- 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.
229
+ By default, successful runs are held in memory and then discarded. A run that rejects because your own code threw, such as a `TypeError` after an API changed the shape of its response, is offered to `keep` as a `Failure` carrying the thrown error, so by default its trace is kept, and the run still rejects. Replaying that trace reproduces the throw. If `keep` or the sink throws, for example because `JSON.stringify` meets a value it cannot serialize, the run keeps its own outcome and the error goes to `onSinkError`, which defaults to `console.error`. `redact` runs before anything enters the trace, including the stored `initialInput` and `context`. `maxEntries` caps the length of a trace and reports the overflow as `dropped`. The sink can write a trace to S3 or a database column as JSON, as long as your Commands return plain data.
230
+
231
+ The example reads the input from the flow itself, where `effectPipe` stores it, so build the outermost flow with `effectPipe`. A flow that starts with a bare `Command`, `Ask`, `Retry`, or `Parallel` records no input, and `timeTravel` would rebuild it from `undefined`. A one-step pipeline is enough, as in `runEffect(effectPipe(loadProfile)(userId))`. The example also takes the context from the run's first Command, so a run that stops before any Command, for example at an `Ask` check, records no context. Such a run did no I/O, so running the flow again with its input and the context from your logs reproduces it.
232
+
233
+ **A trace keeps data, not objects.** A result is copied when it is recorded, and copied again each time a replay hands it to the flow, so neither a later step nor a replay can change what the trace says. The copy keeps values but not classes: a money object or a database entity comes back as a plain object without its methods, and a `Buffer` as a plain byte array. An error returned inside a result keeps its message but loses properties such as `code`. Writing the trace as JSON loses more: a `Date` becomes a string, a `Map` becomes `{}`, an error inside a result becomes `{}`, and a `BigInt` makes the sink throw.
234
+
235
+ So return plain data from a Command's function. Turn a class instance into a plain object before returning it, with a small named function you can test on its own, as in `() => db.findCart(id).then(cartToData)`, and rebuild the object in `next` if the flow needs its methods. When you catch an error to return it as data, copy the fields the flow branches on, as in `.catch((error) => ({ ok: false, code: error.code }))`, rather than returning the error itself.
227
236
 
228
237
  ## Passing Runtime Context
229
238
 
230
- 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:
239
+ Some values come from the framework (an authenticated tenant, a request ID, environment config) rather than from the data being processed. `Ask` lets a step read the `context` object passed to `runEffect` without passing it through every function:
231
240
 
232
241
  ```js
233
242
  import { Success, Failure, Command, Ask, effectPipe, runEffect } from 'pure-effect';
@@ -240,7 +249,9 @@ const findProduct = (productId) =>
240
249
 
241
250
  app.post('/checkout', async (req, res) => {
242
251
  const result = await runEffect(checkoutFlow(req.body.productId), { tenant: req.tenant });
243
- res.json(result);
252
+ if (result.type === 'Success') return res.json(result.value);
253
+ // A Failure also carries the flow's input, so send the client only an error meant for it.
254
+ res.status(400).json({ error: typeof result.error === 'string' ? result.error : 'Checkout failed.' });
244
255
  });
245
256
  ```
246
257
 
@@ -248,19 +259,14 @@ Recording stores the context alongside the trace, so `Ask` replays with the valu
248
259
 
249
260
  ## Retrying Transient Failures
250
261
 
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.
252
-
253
- **Wrap the Command that fails, not the pipeline.** Every attempt re-runs the whole wrapped tree, including Commands that already succeeded:
262
+ `Retry` runs part of a flow again when its I/O fails, meaning a Command's function throws. It does not retry a `Failure` that a step returned: that means the flow decided to stop, so the `Failure` is passed on at once, unchanged. Options are given where `Retry` is used, since they describe one dependency. To reuse the same settings, share an object:
254
263
 
255
264
  ```js
256
- // Dangerous: a flaky receipt step charges the customer again on every attempt.
257
- Retry(effectPipe(chargeCard, sendReceipt)(order), { attempts: 3 });
258
-
259
- // Correct: only the step that fails transiently is retried.
260
- effectPipe(chargeCard, (charge) => Retry(sendReceipt(charge), { attempts: 3 }))(order);
265
+ const flakyNetwork = { attempts: 3, delay: 200, backoff: 2 };
266
+ Retry(fetchPrice(sku), flakyNetwork);
261
267
  ```
262
268
 
263
- Wrapping a pipeline is safe only when every Command in it is idempotent (safe to run more than once).
269
+ The retry settings are a plain object, so you can inspect and assert on them without running anything:
264
270
 
265
271
  ```js
266
272
  import { Success, Failure, Command, Retry, runEffect } from 'pure-effect';
@@ -279,15 +285,49 @@ assert.equal(weatherFn.type, 'Retry');
279
285
  assert.equal(weatherFn.options.attempts, 3);
280
286
  ```
281
287
 
282
- When all attempts are exhausted, `runEffect` returns a structured `Failure`:
288
+ **Wrap the Command that fails, not the pipeline.** Each attempt re-runs everything inside the `Retry`, including Commands that already succeeded:
289
+
290
+ ```js
291
+ // Dangerous: a flaky receipt step charges the customer again on every attempt.
292
+ Retry(effectPipe(chargeCard, sendReceipt)(order), { attempts: 3 });
293
+
294
+ // Correct: only the step that fails transiently is retried.
295
+ effectPipe(chargeCard, (charge) => Retry(sendReceipt(charge), { attempts: 3 }))(order);
296
+ ```
297
+
298
+ Wrapping a pipeline is safe only when every Command in it is idempotent (safe to run more than once).
299
+
300
+ **Wrapping one Command is not always enough.** A Command's `next` is inside the `Retry` too, so if `next` continues the flow, everything after it is retried as well. This looks like it follows the advice above, but it does not:
301
+
302
+ ```js
303
+ // Dangerous: `next` continues the booking, so every later step is inside the Retry.
304
+ Retry(
305
+ Command(cmdHoldSeat, (seat) => chargeCard(trip, seat)),
306
+ { attempts: 2 }
307
+ );
308
+ ```
309
+
310
+ If a later Command's function throws, the `Retry` runs again, which repeats the seat hold and the charge. With two of these nested, a failure in the last step of a booking charges the card nine times.
311
+
312
+ Instead, leave the retried Command with its default `next` and continue in a later pipeline step, outside the `Retry`:
313
+
314
+ ```js
315
+ // Safe: the tree the Retry repeats is that one Command.
316
+ effectPipe(
317
+ () => Retry(Command(cmdHoldSeat), { attempts: 2 }),
318
+ (seat) => chargeCard(trip, seat)
319
+ );
320
+ ```
321
+
322
+ When every attempt fails, `runEffect` returns a `Failure` with this error:
283
323
 
284
324
  ```text
285
325
  { retryExhausted: true, lastError: <the last error>, attempts: 3 }
286
326
  ```
287
327
 
288
- In TypeScript, a `Retry` contributes `RetryExhaustedError<E>` to the pipeline's error union rather than the inner `E` itself, matching what the Failure actually carries: the wrapped tree's error type survives as `result.error.lastError`.
328
+ In TypeScript, a `Retry` adds `RetryExhaustedError` to the pipeline's error union, next to the error type of the steps it wraps, since a `Failure` those steps return passes through unchanged. `lastError` is typed `unknown`: it is whatever the Command's function threw, and nothing in the types says what that is, so check it before using it, for example with `instanceof Error`.
289
329
 
290
- **Recover from exhaustion in-flow with `onExhausted`.** When every attempt has failed, the fallback Effect runs instead of returning the structured Failure. Its success feeds the rest of the pipeline exactly as the primary's would have, and its failure propagates unwrapped:
330
+ **Use `onExhausted` to fall back when every attempt fails.** The fallback runs instead of returning the `Failure`. If it succeeds, its value continues down the pipeline. If it fails, its own `Failure` is returned as is:
291
331
 
292
332
  ```js
293
333
  const fetchPrice = (sku) =>
@@ -297,13 +337,13 @@ const fetchPrice = (sku) =>
297
337
  });
298
338
  ```
299
339
 
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>`.
340
+ Fallback steps are recorded, so a replay repeats the fallback too. A fallback never starts in a `Parallel` branch that has already been cancelled. With `onExhausted` set, the `Retry` adds the fallback's error type to the TypeScript error union instead of `RetryExhaustedError`.
301
341
 
302
- Every attempt is a recorded step, so a replay reproduces the exact sequence of failures. It also skips the delays.
342
+ Every attempt is recorded, so a replay repeats the same sequence of failures, without the delays.
303
343
 
304
344
  ## Running Effects in Parallel
305
345
 
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.
346
+ `Parallel` runs several flows at the same time and passes their results to `next` as an array, in order. If a branch fails, the other branches are cancelled, `next` is not called, and that branch's `Failure` is returned.
307
347
 
308
348
  ```js
309
349
  import { Success, Command, Parallel } from 'pure-effect';
@@ -312,11 +352,43 @@ const loadProfile = (userId) =>
312
352
  Parallel([getUser(userId), getPermissions(userId)], ([user, permissions]) => Success({ user, permissions }));
313
353
  ```
314
354
 
315
- `next` is optional, defaulting to `(values) => Success(values)` just like `Command`'s, so a bare `Parallel(effects)` resolves to the ordered array of success values.
355
+ `next` is optional, as with `Command`. Without it, `Parallel(effects)` returns the array of values.
356
+
357
+ Every branch can read the `Ask` context.
358
+
359
+ **Options for batches.** The second argument can be either `next` or an options object:
360
+
361
+ ```js
362
+ // At most 5 branches in flight, for a gateway that rate limits.
363
+ Parallel(subscriptions.map(billOne), { limit: 5 });
364
+
365
+ // Every branch runs to completion, and `next` receives the outcomes rather than the values.
366
+ Parallel(subscriptions.map(billOne), { limit: 5, settled: true });
367
+ ```
368
+
369
+ Without `settled`, one failing branch cancels the rest and becomes the result of the whole `Parallel`, so one bad record can stop a batch halfway through. With `settled: true`, every branch runs to the end and `next` receives one `Success` or `Failure` per branch, in order, so a failed record is something you can count instead of the end of the job. In TypeScript, write `settled: true` where you call `Parallel`, or mark a shared options object `as const`: since `settled` decides what `next` receives, a value the compiler only knows as `true` or `false` is refused.
316
370
 
317
- `Ask` context flows into all parallel branches without any extra wiring.
371
+ ```js
372
+ Parallel(subscriptions.map(billOne), (outcomes) => Success(outcomes.map(summarize)), { limit: 5, settled: true });
373
+ ```
374
+
375
+ A `Failure` in the list carries its branch's input when that branch was built with `effectPipe`, and no input otherwise, so match outcomes to records by their position in the list. When you report the outcomes, pick the fields you need rather than logging the whole object:
376
+
377
+ ```js
378
+ Parallel(work, (outcomes) => Success(outcomes.map((o) => (o.type === 'Success' ? o.value : o.error.message))), {
379
+ settled: true
380
+ });
381
+ ```
382
+
383
+ Logging the outcomes as they are would include each branch's input, which for a registration or login contains credentials.
384
+
385
+ The outcomes are plain `Success` and `Failure` objects. Returning one of them from `next` is the same as returning any other `Failure`: the flow stops, and `Retry` does not run it again.
386
+
387
+ Bugs in the flow are not collected. An `EffectTypeError`, thrown for a malformed flow, and an error thrown by a `next` function or a pure step both still escape a settled `Parallel`, after the other branches are cancelled.
388
+
389
+ `limit` caps how many branches run at once; the rest start as others finish. Results and recorded paths stay in array order, so a limit changes only the pacing, and a trace recorded with a limit replays the same without one. A `limit` that is not a positive integer throws a `TypeError`.
318
390
 
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:
391
+ **Cancelling is a request, not a guarantee.** A cancelled branch starts no new Commands: a three-step branch whose first step is running when a sibling fails finishes that step and stops. To stop the running step itself, its function has to accept the `AbortSignal` it is given and pass it on to the I/O:
320
392
 
321
393
  ```js
322
394
  // Cancellable: the request is aborted the moment a sibling branch fails.
@@ -326,13 +398,15 @@ const fetchProfile = (userId) => Command((signal) => fetch(`/users/${userId}`, {
326
398
  const fetchProfileUncancellable = (userId) => Command(() => fetch(`/users/${userId}`).then((r) => r.json()));
327
399
  ```
328
400
 
329
- Outside a `Parallel` the function is called with no arguments at all, so nothing changes for a Command that never runs in a branch. A `Retry` inside a cancelled branch stops retrying rather than working through the rest of its backoff schedule.
401
+ Outside a `Parallel`, the function is called with no arguments. A `Retry` inside a cancelled branch stops retrying.
402
+
403
+ Which branch failed first and cancelled the others depends on timing, so it is recorded with the trace. A replay of a cancelled `Parallel` returns the same failure production did, and stops each other branch where production stopped it, rather than letting whichever branch the replay reaches first decide. The same holds when the branch that cancelled the others was a `next` function or a pure step that threw: the replay throws the same error. If the branch that cancelled the others no longer fails, or the `Parallel` no longer has that branch, the replay raises a `TimeParadox` naming it.
330
404
 
331
405
  ## Composing Larger Flows
332
406
 
333
- `effectPipe` is a straight line, but flows rarely are. Branching and joining work with the pieces already here. Here is how:
407
+ `effectPipe` runs steps in a straight line. Branching and joining use the same pieces:
334
408
 
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:
409
+ **A step can return a sub-pipeline.** `effectPipe(...)(value)` returns an Effect like any other, so a step can branch into another flow. A `Failure` in that flow stops the outer pipeline too:
336
410
 
337
411
  ```js
338
412
  const processOrder = (order) => (order.isGift ? giftFlow(order) : standardFlow(order));
@@ -340,7 +414,7 @@ const processOrder = (order) => (order.isGift ? giftFlow(order) : standardFlow(o
340
414
  const fulfillment = effectPipe(validateOrder, processOrder, scheduleShipping);
341
415
  ```
342
416
 
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:
417
+ **Use `Parallel` to join values that do not depend on each other.** When a later step needs several such values, fetch them at the same time. Without `next`, the results arrive as an array, in order:
344
418
 
345
419
  ```js
346
420
  const loadCheckout = effectPipe(
@@ -349,7 +423,7 @@ const loadCheckout = effectPipe(
349
423
  );
350
424
  ```
351
425
 
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:
426
+ **Join values that depend on each other locally.** When step B needs step A's result and step C needs both, pass both forward in one value. A small pipeline inside the step does this without nested callbacks:
353
427
 
354
428
  ```js
355
429
  const applyLoyaltyDiscount = (orderId) =>
@@ -360,13 +434,13 @@ const applyLoyaltyDiscount = (orderId) =>
360
434
  )(orderId);
361
435
  ```
362
436
 
363
- **Carry exactly what downstream needs, never an accumulator.** Passing a minimal, purpose-shaped value between steps, rather than a scope object that grows with everything ever computed, is a strategy for avoiding bugs, not a style preference. Each step's input is its complete contract, so a step cannot quietly depend on a value produced far upstream, a stale field cannot outlive the step that should have replaced it, and two steps cannot collide on a key neither knows the other uses. It also keeps data alive no longer than the flow needs it, which matters when the values are credentials or PII. In TypeScript the same discipline is what keeps the pipeline checked end to end: every step consumes the value it receives, so changing what an upstream step produces is a compile error at exactly the step that reads it, instead of an `undefined` at runtime.
437
+ **Pass only what the next steps need.** Avoid one object that collects everything computed so far. When a step's input is all it gets, it cannot depend on a value from far upstream without showing it, stale fields do not linger, and two steps cannot clash over the same key. Data also lives only as long as the flow needs it, which matters for credentials and personal data. In TypeScript, it keeps the pipeline type-checked: if an upstream step changes what it returns, the step that reads the value fails to compile instead of getting `undefined` at runtime.
364
438
 
365
439
  ## Which Errors Are Data
366
440
 
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.
441
+ Pure Effect has no catch, on purpose. A flow's outcomes are of two kinds, and each is written differently.
368
442
 
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:
443
+ **An outcome the flow handles is data.** A Command's `next` receives the result and can return any Effect, including a fallback pipeline. When the I/O can reject, catch the error inside the Command's function and return it as a value:
370
444
 
371
445
  ```js
372
446
  const fetchCachedPrice = (sku) => {
@@ -379,16 +453,50 @@ const fetchPrice = (sku) => {
379
453
  pricing
380
454
  .get(sku)
381
455
  .then((price) => ({ ok: true, price }))
382
- .catch((error) => ({ ok: false, error }));
456
+ .catch((error) => ({ ok: false, code: error.code, message: error.message }));
383
457
  return Command(cmdFetchLivePrice, (r) => (r.ok ? Success({ ...r.price, stale: false }) : fetchCachedPrice(sku)));
384
458
  };
385
459
  ```
386
460
 
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.
461
+ The failed attempt is recorded as that Command's result, with the error's `code` and `message`, so a replay takes the same fallback branch. Copy the fields you need rather than returning the error itself: a trace keeps data, not objects, so an error stored inside a result loses its properties (see [Recording in Production](#recording-in-production)).
462
+
463
+ **A `Failure` means abort.** It stops the flow and goes back to the code that called `runEffect`, which decides what it means: an HTTP status, a queue retry, an alert. Nothing inside the flow can catch a `Failure`, so a reader never has to look elsewhere for a handler.
464
+
465
+ Rule of thumb: if you would handle it, return it as a value; if you would only report it, return a `Failure`. The one exception is retry exhaustion, which happens inside `Retry`; handle it with the `onExhausted` option in [Retrying Transient Failures](#retrying-transient-failures).
466
+
467
+ **Catch or retry, not both.** `Retry` only reacts to a function that throws. If you catch a 503 inside the function and return it as a value, then wrap the step in `Retry`, it makes one call and never retries. Catch the error when the flow should handle it as data; let it throw when `Retry` should try again.
468
+
469
+ ```js
470
+ // Handled as data: one call, and `next` decides what the miss means.
471
+ Command(
472
+ function cmdFetchPrice() {
473
+ return pricing
474
+ .get(sku)
475
+ .then((price) => ({ ok: true, price }))
476
+ .catch((error) => ({ ok: false, code: error.code, message: error.message }));
477
+ },
478
+ (r) => (r.ok ? Success(r.price) : fetchCachedPrice(sku))
479
+ );
480
+
481
+ // Retried: the function lets the rejection out, so the interpreter sees an I/O fault.
482
+ Retry(
483
+ Command(function cmdFetchPrice() {
484
+ return pricing.get(sku);
485
+ }),
486
+ { attempts: 3 }
487
+ );
488
+ ```
489
+
490
+ `runEffect` tells these cases apart:
388
491
 
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.
492
+ | what happened | what it is | what `Retry` does | what `onExhausted` sees |
493
+ | --- | --- | --- | --- |
494
+ | a step returned `Failure(...)`, or an `onBeforeCommand` hook threw | an abort | nothing; it propagates at once, unwrapped | nothing |
495
+ | a Command's function threw | an I/O fault | retries it | the exhaustion, once the attempts are gone |
496
+ | a `next` function or a pure step threw, or the flow is malformed | a bug | nothing; it is thrown, not returned | nothing |
497
+ | an `onStep` hook threw after the Command's function succeeded | a bug | nothing; it is thrown, not returned | nothing |
390
498
 
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).
499
+ In short, **you can recover from an error your I/O produced, but you cannot catch a `Failure` a step returned.** Only the code that called `runEffect` acts on it. For the same reason, do not throw business errors from a Command's function: a throw there tells `Retry` that the I/O broke, and `Retry` will try again. A throw anywhere else in the flow is treated as a bug: `runEffect` rejects with the thrown error instead of returning a `Failure`.
392
500
 
393
501
  ## TypeScript: Typed Errors and Context
394
502
 
@@ -409,7 +517,9 @@ if (result.type === 'Failure') {
409
517
  }
410
518
  ```
411
519
 
412
- 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.
520
+ Value types are checked through the pipeline too, so a step that reads a field the previous step does not return is a compile error. This only works while every step uses the value it receives. A step written as `() => doSomething(outer)` ignores it, and the types stop being checked at that point.
521
+
522
+ `effectPipe` is typed for up to 20 steps. A pipeline is itself a step, so a longer one nests, which is usually easier to read anyway: `effectPipe(effectPipe(s1, s2), effectPipe(s3, s4))`.
413
523
 
414
524
  ### Typed context with `Ask`
415
525
 
@@ -424,15 +534,36 @@ const findProduct = (productId: string): Effect<Product, 'not_found', AppContext
424
534
  const result = await runEffect(findProduct('abc'), { tenant: 'acme', requestId: '123' });
425
535
  ```
426
536
 
537
+ Every step's context counts, so a pipeline needs all the contexts its steps read, even when the first step reads none. `runEffect` requires a context whenever the flow reads one, since the flow would otherwise get an empty object.
538
+
427
539
  ## Why Pure Effect
428
540
 
429
- **vs. Temporal and durable execution (Restate, Inngest):** The closest relatives, because they are built on the same idea: record what every step returned, replay it deterministically. Those engines run it as a managed execution guarantee: histories persist server-side, and workflows resume automatically after a crash. The difference is who operates it. An engine stores histories and restarts dead runs for you; with Pure Effect that is your application's job, and there is no infrastructure to run.
541
+ **vs. Temporal and durable execution (Restate, Inngest):** The closest relatives, built on the same idea: record what every step returned and replay it. Those engines store the history on a server and resume workflows automatically after a crash. Pure Effect leaves storage and restarts to your application, and there is no infrastructure to run.
542
+
543
+ **vs. Effect-TS (and fp-ts):** A full ecosystem with fibers, streaming, schema validation, structured concurrency, and more, with a steep learning curve and its own vocabulary. Pure Effect takes only the idea of describing side effects as data, and does less: testable pipelines, context injection, retry, parallel execution, and replayable traces. If you need fibers or streaming, use Effect-TS.
544
+
545
+ **vs. plain async/await with mocks:** A mock can pass every test while behaving differently from the real driver. Here, business logic never does I/O, so there is nothing to mock.
546
+
547
+ **When to use something else:** If your code does little async I/O, or testing and debugging production are not problems for you, plain async/await is simpler.
548
+
549
+ ## Load Tests
550
+
551
+ Timings are from Node 22 on a MacBook Pro with M4 Pro CPU:
552
+
553
+ **A web service under load.** A `node:http` service ran signup and checkout flows, where each checkout was a `Parallel` of a retried stock reservation and a card charge, with the OpenTelemetry and recording examples installed.
430
554
 
431
- **vs. Effect-TS (and fp-ts):** A full functional programming ecosystem with fibers, streaming, schema validation, structured concurrency, and more, though it comes with a steep learning curve and a vocabulary of its own. Pure Effect borrows only the concept of effects as data, and 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.
555
+ - 800 requests at once, across 4 tenants: every response and every recorded trace belonged to its own request, and no tenant, user, or request ID crossed between runs.
556
+ - The ~3,500 telemetry spans formed 800 request traces, with every span inside its own request's trace.
557
+ - Side effects matched the outcomes: 320 charges and 320 orders, and 80 declined cards stopped 30 stock reservations that were still running.
558
+ - 30,000 runs in a row kept memory between 10 and 11 MB, and left nothing running afterwards.
559
+ - A background worker that ran 20,000 jobs in one flow, looping through a Command's `next`, finished in under ~400 ms without holding on to memory.
560
+ - Stopping the server with flows still running, or clients hanging up mid-request: every flow that had started finished and was recorded, with no unhandled rejections.
432
561
 
433
- **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.
562
+ **Batches and parallel work.**
434
563
 
435
- **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.
564
+ - A settled `Parallel` over 200 records with `limit: 7` never had more than 7 records in flight, and returned the outcomes in order.
565
+ - 5,000 branches in one `Parallel` ran in ~25 ms.
566
+ - Running a Command through `runEffect` costs about 0.2 microseconds, against 0.06 for a plain `await`.
436
567
 
437
568
  ## API Reference
438
569
 
@@ -451,10 +582,10 @@ Returns `{ type: 'Failure', error, initialInput }`. Stops the pipeline immediate
451
582
  Returns `{ type: 'Command', cmd, next, meta }`.
452
583
 
453
584
  - `cmd`: A function (sync or async) that performs the side effect. Inside a `Parallel` branch it is called with an `AbortSignal` that fires when a sibling branch fails; elsewhere it is called with no arguments.
454
- - `next`: Receives the result of `cmd` and returns the next Effect. Optional, defaulting to `(result) => Success(result)`, which is what most Commands want.
585
+ - `next`: Receives the result of `cmd` and returns the next Effect. Optional, defaulting to `(result) => Success(result)`.
455
586
  - `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`).
456
587
 
457
- **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:
588
+ **Every Command needs a name.** Test assertions, trace entries, replay matching, and telemetry spans all use it. It is chosen in this order:
458
589
 
459
590
  ```js
460
591
  Command(cmdFn, next, { name: 'chargeCard' }); // 1. meta.name, independent of how cmdFn was written
@@ -464,7 +595,7 @@ Command(function cmdChargeCard() {
464
595
  Command(() => api.charge(), next); // 3. neither, so 'anonymous'
465
596
  ```
466
597
 
467
- 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.
598
+ Use `meta.name` in code that gets minified, since minifiers rename functions and would rename every step in every trace. Otherwise, naming the function is fine, and is what the examples do.
468
599
 
469
600
  #### `Ask(nextFn)`
470
601
 
@@ -474,14 +605,19 @@ Returns `{ type: 'Ask', next }`. Passes the `context` from `runEffect` into `nex
474
605
 
475
606
  Returns `{ type: 'Retry', effect, options, next }`.
476
607
 
477
- - `options.attempts`: Max retries, not counting the first try (default: `3`).
608
+ - `options.attempts`: Max retries, not counting the first try (default: `3`). Must be a positive integer; anything else, including `0`, throws a `TypeError`. To handle an outcome without retrying, branch on it in the Command's `next`, or isolate a failing branch with `Parallel`'s `settled`.
478
609
  - `options.delay`: Ms before the first retry (default: `100`).
479
- - `options.backoff`: Multiplier applied to delay on each attempt (default: `1`, flat).
610
+ - `options.backoff`: Multiplier applied to the delay on each attempt (default: `1`, flat).
480
611
  - `options.onExhausted(error)`: Runs a fallback Effect when every attempt has failed, receiving `{ retryExhausted, lastError, attempts }`. The fallback's success feeds `next`; its failure propagates unwrapped. Per-use only. See [Retrying Transient Failures](#retrying-transient-failures).
481
612
 
482
- #### `Parallel(effects, next?)`
613
+ #### `Parallel(effects, next?, options?)`
483
614
 
484
- Returns `{ type: 'Parallel', effects, next }`. Runs all effects concurrently. `next` receives the ordered array of success values and is optional, defaulting to `(values) => Success(values)` like `Command`'s. The first branch to fail cancels its siblings and its `Failure` is returned; `next` is not called. When several branches fail in the same tick, the first by array order wins. Each branch's Commands receive an `AbortSignal` as their only argument, so I/O that accepts one is cancelled in flight; see [Running Effects in Parallel](#running-effects-in-parallel).
615
+ Returns `{ type: 'Parallel', effects, next, options }`. Runs all effects at the same time. `next` receives the array of success values, in order, and is optional, defaulting to `(values) => Success(values)` as with `Command`. The first branch to fail cancels the others and its `Failure` is returned; `next` is not called. Which branch fails first depends on timing, so it is recorded, and a replay returns the same one. Each branch's Commands receive an `AbortSignal` as their only argument, so I/O that accepts it can be stopped while running; see [Running Effects in Parallel](#running-effects-in-parallel).
616
+
617
+ The second argument can be `next` or the options, so `Parallel(effects, { limit: 5 })` works.
618
+
619
+ - `limit`: the most branches running at once. Results and recorded paths stay in array order, so a limit changes only the pacing. A value that is not a positive integer throws a `TypeError`.
620
+ - `settled`: run every branch to the end and pass `next` one outcome per branch, `Success` or `Failure`, in array order. No branch cancels the others, and the `Parallel` never fails because of a branch. An `EffectTypeError` still escapes, because a malformed flow is a bug, not a branch outcome.
485
621
 
486
622
  ### Building pipelines
487
623
 
@@ -496,7 +632,7 @@ A step does not have to use the value it receives. In JavaScript, closing over s
496
632
  const registerUserFlow = (input) => effectPipe(validateRegistration, () => saveUser(input))(input);
497
633
  ```
498
634
 
499
- 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.
635
+ In TypeScript, types stop being checked at that step, because a function that ignores its parameter puts no constraint on the step before it. See [TypeScript: Typed Errors and Context](#typescript-typed-errors-and-context). Passing the value through every step keeps the whole pipeline checked, which is why the [Quick Start](#quick-start) does it.
500
636
 
501
637
  One shape to avoid in either language:
502
638
 
@@ -507,7 +643,7 @@ One shape to avoid in either language:
507
643
  };
508
644
  ```
509
645
 
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.
646
+ A Command is only data, so one that is created and not returned never runs, and any `Failure` it would have produced is lost. Return the Command, and have its `next` return the value the rest of the pipeline needs.
511
647
 
512
648
  ### Running a flow
513
649
 
@@ -516,26 +652,28 @@ Commands are data, so a Command that is constructed and discarded never runs, an
516
652
  Walks the flow, runs each Command with `async/await`, resolves `Ask` with the supplied `context`, and returns the final `Success` or `Failure`.
517
653
 
518
654
  - `context`: Passed to `Ask`'s next function and to `onBeforeCommand`. `context.flowName` names the workflow in telemetry.
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).
520
- - `onRun` fires exactly once per `runEffect` call. Retry attempts run inside that single span.
655
+ - `callConfig`: Per-call `onStep`, `onRun`, and `onBeforeCommand`, added to the `configureEffect` wiring unless `inherit: false`, which ignores that wiring for the run. A `retry` key throws a `TypeError`: retry options are per-use, passed to `Retry`. See [`configureEffect`](#configureeffectconfigs).
656
+ - `onRun` fires once per `runEffect` call. Retry attempts run inside that one span.
521
657
 
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`:
658
+ A step that returns something other than an Effect is a bug, so `runEffect` throws an `EffectTypeError` naming the step instead of returning a `Failure`:
523
659
 
524
660
  ```
525
661
  Step 'validateRegistration' returned a plain object. Return Success, Failure, Command, Ask,
526
662
  Retry, or Parallel: a plain value has to be wrapped, as in Success(value).
527
663
  ```
528
664
 
529
- The same check catches a missing `return`, a Command's next function returning a plain value, and `runEffect(flow)` where `runEffect(flow(input))` was meant. A Command that throws is still a `Failure`.
665
+ The same check catches a missing `return`, a Command's next function returning a plain value, a step or next function written as `async` (it returns a Promise, so do the awaited work in a Command instead), and `runEffect(flow)` where `runEffect(flow(input))` was meant. A Command whose function throws is still a `Failure`. A throw from a `next` function or a pure step is also a bug, and `runEffect` rejects with the error as thrown.
530
666
 
531
667
  #### `configureEffect(...configs)`
532
668
 
533
- - `onRun(effect, pipeline, flowName)` wraps the entire workflow; must `await pipeline()`.
534
- - `onStep(name, type, op)` wraps each Command; must `await op()` and return its result. Returning a value _without_ calling `op()` is how replay works.
535
- - `onBeforeCommand(command, context)` fires before each Command; throw to abort.
536
- - `retry: { attempts?, delay?, backoff? }` global retry defaults.
669
+ - `onRun(effect, pipeline, flowName)` wraps the entire workflow; must `await pipeline()` and return its result.
670
+ - `onStep(name, type, op, path)` wraps each Command; must `await op()` and return its result. A hook that calls another hook passes `path` on, since a replay matches steps on it. `op()` returns a promise, even for a synchronous Command. Returning a value _without_ calling `op()` is how replay works. A throw after `op()` succeeded is a bug in the hook: the run rejects, and `Retry` does not run the Command again. A throw without calling `op()` counts as the Command failing.
671
+ - `onStep` also wraps each `Parallel`, with `name` and `type` both `'Parallel'`. Its `op()` runs the branches, so a hook must call it; a hook that returns without calling it makes the run reject with a `TypeError`. It returns which branch, if any, cancelled the others, such as `{ cancelled: true, branch: 0 }`, and it returns even when a branch threw, since the run rejects only after the hook has returned. Telemetry gets one span per `Parallel`, with the spans of its branches' Commands inside it, marked as an error when the `Parallel` was cancelled.
672
+ - `onBeforeCommand(command, context)` fires before each Command; throw to abort. The run returns a `Failure` carrying the thrown error, and `Retry` does not retry it.
673
+
674
+ It only configures hooks. Retry options are passed to `Retry(effect, options)`, and a `retry` key here throws a `TypeError`.
537
675
 
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`:
676
+ Each call adds a layer of hooks on top of those already installed and returns a function that removes that layer. Passing several configurations to one call is the same as calling it once for each, so these two forms are equivalent:
539
677
 
540
678
  ```js
541
679
  configureEffect(telemetryHooks(), recordingHooks({ sink }));
@@ -545,6 +683,8 @@ configureEffect(telemetryHooks());
545
683
  configureEffect(recordingHooks({ sink }));
546
684
  ```
547
685
 
686
+ This is the order the hooks run in during one `runEffect`:
687
+
548
688
  ```
549
689
  runEffect(flow(input))
550
690
  │
@@ -562,11 +702,9 @@ runEffect(flow(input))
562
702
  │ │
563
703
  │ ┌─ recording.onRun returns
564
704
  ├─ telemetry.onRun returns
565
- │
566
- retry: { ...telemetry.retry, ...recording.retry } later configurations win
567
705
  ```
568
706
 
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.
707
+ Removing a layer removes only that layer, even if others were installed after it, so a library can add its own hooks and remove them later without affecting the application's. 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.
570
708
 
571
709
  ```js
572
710
  const remove = configureEffect(telemetryHooks());
@@ -574,7 +712,7 @@ const remove = configureEffect(telemetryHooks());
574
712
  remove();
575
713
  ```
576
714
 
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`:
715
+ **Per-call hooks.** By default, the hooks in a `callConfig` are merged with the configured ones by the same rules: configured wrappers go outside, and configured `onBeforeCommand` hooks run first. `inherit: false` leaves the configured hooks out of the run. With configured hooks `C` and a call `K` that supplies only `onStep`:
578
716
 
579
717
  ```
580
718
  inherit: true (default) inherit: false
@@ -582,33 +720,40 @@ remove();
582
720
  onRun C.onRun (none)
583
721
  onBeforeCommand C.onBeforeCommand (none)
584
722
  onStep C.onStep( K.onStep( cmd ) ) K.onStep
585
- retry { ...C.retry, ...K.retry } { ...defaults, ...K.retry }
586
723
  ```
587
724
 
588
- `recordEffect` inherits, so recording inside an application that already has tracing keeps its spans. `replayEffect` passes `inherit: false` unless `hooks: true`.
725
+ `recordEffect` inherits, so recording inside an application that already has tracing still produces spans. `replayEffect` passes `inherit: false` unless `hooks: true`.
589
726
 
590
727
  ### Recording and replay
591
728
 
592
729
  #### `recorder(options?)`
593
730
 
594
- Returns `{ onStep, entries, toTrace }`. Pass `onStep` to `runEffect` or `configureEffect` to record what every Command returned.
731
+ Returns `{ onStep, entries, toTrace }`. Pass `onStep` to `runEffect` or `configureEffect` to record what every Command returned. `toTrace(meta)` packages the trace, and it copies the `initialInput` and `context` you give it at the moment you call it. A value that cannot be copied whole, such as a context holding a logger function, is copied around the parts that cannot be copied, which are kept as they are. If a Command can change either, for example an ORM save that adds an id to the object it was given, call `toTrace` with them before the run and take `trace` and `dropped` from a second call afterwards, as `recordEffect` does.
595
732
 
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.
733
+ Each entry is `{ command, path, result, durationMs }`, or `{ command, path, threw: true, error, durationMs }` when the Command threw, so a trace also shows which step was slow. `threw` is what marks a step that threw, since a JSON copy of the trace drops `error` when the Command threw `undefined`; an entry with `error` and no `threw`, as older traces have, still counts as one. `path` is the Command's position in the flow, which is what a replay matches on. Each `Parallel` adds one entry, `{ command: 'Parallel', path, result }`, whose result says which branch, if any, cancelled the others: `{ cancelled: false }`, `{ cancelled: true, branch: 0 }`, or `branch: null` when an enclosing `Parallel` cancelled it. `redact` is not called for it, since it holds no data from your flow. Results are copied when recorded, so a later step that changes a returned object does not change the trace, and copied again when a replay hands them to the flow. In a value that cannot be copied whole, such as an object holding a function, the parts that cannot be copied are kept as they are. The copy keeps data but not classes; see [Recording in Production](#recording-in-production). Recording never changes the outcome of a run: if `redact` throws, the step is recorded as `'[redaction failed]'` and the flow carries on.
597
734
 
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:
735
+ - `options.redact(value, name, kind)`: Removes sensitive data from a trace. It receives every value the trace stores, and `kind` says which one it is. Each value is a copy, so `redact` can delete fields in place or return a new value, and the flow still sees what it was given:
599
736
 
600
737
  ```js
601
- recorder({
602
- redact: (value, name, kind) => {
603
- if (kind === 'initialInput') return { ...value, password: '[redacted]' };
604
- if (kind === 'context') return { ...value, authToken: '[redacted]' };
605
- if (kind === 'error') return { ...value, attempted: '[redacted]' };
606
- return name === 'cmdFetchUser' ? { ...value, email: '[redacted]' } : value;
607
- }
608
- });
738
+ // Masks a field only where there is one, so a lookup that found nothing still records null.
739
+ const mask = (value, field) =>
740
+ value && typeof value === 'object' && field in value ? { ...value, [field]: '[redacted]' } : value;
741
+
742
+ const redact = (value, name, kind) => {
743
+ if (kind === 'initialInput') return mask(value, 'password');
744
+ if (kind === 'context') return mask(value, 'authToken');
745
+ if (kind === 'error') return mask(value, 'attempted');
746
+ return name === 'cmdFetchUser' ? mask(value, 'email') : value;
747
+ };
748
+ recorder({ redact });
749
+
750
+ assert.equal(redact(null, 'cmdFetchUser', 'result'), null); // still nothing found
751
+ assert.equal(redact('card_declined', 'cmdCharge', 'error'), 'card_declined'); // still a string
609
752
  ```
610
753
 
611
- `'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.
754
+ Check the value before replacing a field in it. `{ ...value, email: '[redacted]' }` turns a `null` into an object, so a lookup that found nothing replays as one that found a user, and the flow takes the other branch. It also turns an error thrown as a string into an object holding its letters.
755
+
756
+ For `'result'` and `'error'`, `name` is the Command's name; for `'initialInput'` and `'context'`, it is the kind. Redacting `initialInput` rarely breaks replay, since Commands are not run; it matters only if a step branches on the removed field. Redacting `context` breaks `Ask` replay if a step reads what you removed.
612
757
 
613
758
  - `options.maxEntries`: Cap trace length; overflow is counted in `dropped`.
614
759
  - `options.stack`: Record stack traces for thrown errors (off by default).
@@ -619,16 +764,16 @@ Runs a flow for real while recording, returning `{ result, trace }`. Accepts `re
619
764
 
620
765
  #### `replayEffect(effect, traceOrResolver, options?)`
621
766
 
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.
767
+ 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 raises no `TimeParadox`, so `unreached` is where that shows up. With a resolver, only `{ result }` is returned, since a resolver cannot list what it holds.
623
768
 
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`.
625
- - `options.context`: context for `Ask`; pass the recorded context.
626
- - `options.onMissing`: `'throw'` (default) fails on an unrecorded step; `'execute'` runs the real Command, giving a recorded prefix with a live tail.
769
+ - `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 resolver is also asked about each `Parallel`, with `step.type` set to `'Parallel'`: answering with `{ result }` holding the recorded cancellation replays it as production decided, and anything else replays that `Parallel` by timing, as before. A malformed trace rejects with a `ReplayError`.
770
+ - `options.context`: context for `Ask`. With a trace it defaults to the context the trace recorded, so pass one only to replay with a different one. A resolver has no recorded context, so pass it one if the flow reads `Ask`.
771
+ - `options.onMissing`: `'throw'` (default) fails on an unrecorded step; `'execute'` runs the real Command, giving a recorded prefix with a live tail. Use it for a trace that was cut short, such as one capped by `maxEntries`. After a flow changes shape, for example when a Command is newly wrapped in `Retry`, its steps sit at new positions, so `'execute'` would run all of them live and leave the recorded ones unused: record the flow again instead.
627
772
  - `options.fastRetry` (default `true`): strip `Retry` delays.
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.
629
- - `options.onResolved(step, outcome)`: observe each replayed step.
773
+ - `options.hooks` (default `false`): run the replay inside the configured hooks, so they see the replayed steps, a configured recorder included. When off, the configured hooks are skipped, so a replay cannot reach a telemetry backend or a trace sink.
774
+ - `options.onResolved(step, outcome)`: observe each replayed step. If it throws, the replay stops there and `replayEffect` rejects with that error; it never changes a step's outcome.
630
775
 
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.
776
+ A trace whose entries carry no `path` (written by hand, or recorded before paths existed) is matched by position, which works for a sequential flow; a `Parallel` step in such a trace is refused with a `ReplayError`, since the order branches finish in cannot tell them apart.
632
777
 
633
778
  #### `timeTravel(flowFn, traceLog, options?)`
634
779
 
@@ -636,10 +781,11 @@ Replays a trace and narrates each step with its recorded duration, naming any re
636
781
 
637
782
  ## Limitations
638
783
 
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.
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.
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.
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.
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.
784
+ - **`Retry` repeats everything it wraps, including a Command's `next`.** When a Command's function throws, every Command inside the `Retry` runs again, including the ones that already succeeded. If a retried Command's `next` continues the flow, everything after it is retried too. Give a retried Command the default `next` and continue in a later pipeline step, or make sure every Command it reaches is safe to run more than once. A `Failure` a step returned is not retried at all, so a function that catches its own error and returns it as a value is never retried: see [Which Errors Are Data](#which-errors-are-data).
785
+ - **Cancelling a `Parallel` branch cannot stop everything.** A cancelled branch starts no new Commands, and a function that uses the `AbortSignal` it is given can be stopped while running. A function that ignores the signal runs to completion, so a branch whose _first_ Command is a write can still write after another branch has failed. `Parallel` waits for every branch to finish before returning, so no cancelled work is still running after the `Failure` is returned.
786
+ - **A `Failure` carries everything.** It holds the full error and the `initialInput` that `effectPipe` attached, and neither is trimmed, because tests and debugging need both. `redact` keeps sensitive data out of a **trace**. Keeping it out of your **logs** is up to you: log `result.error` rather than the whole `Failure`. The same goes for the outcomes a settled `Parallel` passes to `next`, which can carry their branch's input; for a login or registration flow, that input holds credentials.
787
+ - **Replay checks the path, not the values.** Replaying an old trace cannot check a fix that changes what a flow computes, because every Command's result comes from the recording: the replay hands the step the value from before the fix, reports `Success`, and flags nothing. It can check a fix that changes which Commands run. A removed step shows up in `unreached` only if it was the last step the flow reached; removed from the middle, it shifts the later paths and the replay stops at a `TimeParadox`.
788
+ - **A trace keeps data, not objects.** Recorded results come back without their classes: a money object or a database entity as a plain object, a `Buffer` as a plain byte array, and an error inside a result without properties such as `code`. Through JSON, a `Date` also comes back as a string and a `Map` as `{}`. A flow whose `next` calls a method on a result, or branches on one of those properties, replays differently from production. Return plain data from Commands; see [Recording in Production](#recording-in-production).
789
+ - **Replay reproduces what one flow saw, not timing.** A stale read replays exactly. A race between two concurrent requests does not, because a trace records one flow. `Parallel` branches replay with the results each branch saw, and a cancelled `Parallel` with the branch that cancelled it, but not the order they ran in, so replay cannot settle a race between branches that share state.
790
+ - **Configuration belongs to one copy of the library.** `configureEffect` stores hooks in module state, so two copies of the library in one process (from a dual ESM and CJS setup, two versions in the dependency tree, or a worker thread) each have their own. Hooks configured in one do not apply to the other, and nothing warns you.
791
+ - **A Command needs a name.** `meta.name` survives minification. `cmd.name` does not, unless the minifier is set to keep function names.