pure-effect 0.12.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.
- package/CHANGELOG.md +192 -0
- package/README.md +328 -121
- package/index.d.ts +1129 -138
- package/index.js +757 -207
- package/package.json +32 -2
- package/.prettierrc +0 -12
- package/CLAUDE.md +0 -205
- package/examples/opentelemetry-example.js +0 -164
- package/examples/recording-example.js +0 -104
- package/jsconfig.json +0 -13
- package/test/all.js +0 -2446
- package/test/types.test-d.ts +0 -335
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Pure Effect
|
|
2
2
|
|
|
3
|
-
[](https://www.npmjs.com/package/pure-effect) [](https://www.npmjs.com/package/pure-effect) [](https://bundlejs.com/?q=pure-effect) [](https://github.com/aycangulez/pure-effect/blob/main/LICENSE)
|
|
4
4
|
|
|
5
5
|
**Pure Effect** records what your business logic did in production and replays it anywhere: time-travel debugging for JavaScript and TypeScript, with zero dependencies. Business logic is plain data you can test without mocks.
|
|
6
6
|
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
- Inject context without touching function signatures
|
|
10
10
|
- Built-in retry, plus parallel execution that cancels sibling branches on the first failure
|
|
11
11
|
- OpenTelemetry-ready via lifecycle hooks
|
|
12
|
-
- Zero dependencies,
|
|
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
|
|
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
|
-
//
|
|
54
|
-
// Name the commands
|
|
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));
|
|
@@ -67,7 +68,7 @@ const saveUser = (input) => {
|
|
|
67
68
|
// Build the flow. Validation runs now; no I/O happens until runEffect.
|
|
68
69
|
const registerUserFlow = (input) => effectPipe(validateRegistration, ensureEmailAvailable, saveUser)(input);
|
|
69
70
|
|
|
70
|
-
//
|
|
71
|
+
// This is the only place side effects actually run
|
|
71
72
|
async function registerUser(input) {
|
|
72
73
|
const result = await runEffect(registerUserFlow(input));
|
|
73
74
|
|
|
@@ -81,33 +82,57 @@ async function registerUser(input) {
|
|
|
81
82
|
|
|
82
83
|
## Testing Without Mocks
|
|
83
84
|
|
|
84
|
-
|
|
85
|
+
Pipelines return plain objects, so you can check what the code will do without running it.
|
|
86
|
+
|
|
87
|
+
Validation runs as soon as you build the flow, so testing it needs nothing else:
|
|
85
88
|
|
|
86
89
|
```js
|
|
87
|
-
// 1. Test validation failure synchronously
|
|
88
90
|
const badInput = { email: 'bad-email', password: '123' };
|
|
89
91
|
assert.deepEqual(registerUserFlow(badInput), Failure('Invalid email.', badInput));
|
|
92
|
+
```
|
|
90
93
|
|
|
91
|
-
|
|
92
|
-
|
|
94
|
+
Next, test the steps. Hand a step an answer and check what it returns:
|
|
95
|
+
|
|
96
|
+
```js
|
|
97
|
+
const input = { email: 'test@test.com', password: 'password123' };
|
|
98
|
+
|
|
99
|
+
// The email is free, so the step passes the input on.
|
|
100
|
+
assert.deepEqual(ensureEmailAvailable(input).next(null), Success(input));
|
|
101
|
+
|
|
102
|
+
// The email is taken, so the flow stops here.
|
|
103
|
+
assert.deepEqual(ensureEmailAvailable(input).next({ id: 1 }), Failure('Email already in use.'));
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Then test the flow: the right calls, in the right order.
|
|
107
|
+
|
|
108
|
+
```js
|
|
109
|
+
const step1 = registerUserFlow(input);
|
|
93
110
|
assert.equal(step1.cmd.name, 'cmdFindUser');
|
|
94
111
|
|
|
95
|
-
const step2 = step1.next(null); //
|
|
112
|
+
const step2 = step1.next(null); // pretend no user was found
|
|
96
113
|
assert.equal(step2.cmd.name, 'cmdSaveUser');
|
|
97
|
-
// The
|
|
114
|
+
// The database was never touched.
|
|
98
115
|
```
|
|
99
116
|
|
|
117
|
+
Write both kinds, because they catch different bugs. Only step tests see the value passed from one step to the next.
|
|
118
|
+
|
|
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.
|
|
120
|
+
|
|
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.
|
|
122
|
+
|
|
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.
|
|
124
|
+
|
|
100
125
|
## How It Works
|
|
101
126
|
|
|
102
|
-
A flow is a chain of pairs: an I/O call
|
|
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.
|
|
103
128
|
|
|
104
|
-
Recording and replay
|
|
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.
|
|
105
130
|
|
|
106
|
-
Building a flow runs the pure steps
|
|
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.
|
|
107
132
|
|
|
108
133
|
## Time-Travel Debugging
|
|
109
134
|
|
|
110
|
-
Record what each Command returned, then feed those results back into the
|
|
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.
|
|
111
136
|
|
|
112
137
|
```js
|
|
113
138
|
import { recordEffect, replayEffect, timeTravel } from 'pure-effect';
|
|
@@ -124,11 +149,11 @@ Replaying 'checkout' (3 recorded steps)
|
|
|
124
149
|
Initial input: { "cartId": "cart_abc123", "promoCode": "FREE_YEAR_VIP" }
|
|
125
150
|
Step 1: cmdFetchCart returned { "totalAmount": "120.00" }
|
|
126
151
|
Step 2: cmdValidatePromo returned { "isValid": true, "discountValue": 100 }
|
|
127
|
-
Step 3:
|
|
152
|
+
Step 3: cmdChargeCard threw { "message": "Amount must be non-zero.", "code": "invalid_amount" }
|
|
128
153
|
Replay finished with state: Failure
|
|
129
154
|
```
|
|
130
155
|
|
|
131
|
-
A production incident becomes a
|
|
156
|
+
A production incident becomes a regression test, with no mocks or fixtures:
|
|
132
157
|
|
|
133
158
|
```js
|
|
134
159
|
it('prod incident 8f3a: a 100% promo produces a $0 charge', async () => {
|
|
@@ -138,9 +163,9 @@ it('prod incident 8f3a: a 100% promo produces a $0 charge', async () => {
|
|
|
138
163
|
});
|
|
139
164
|
```
|
|
140
165
|
|
|
141
|
-
The test
|
|
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.
|
|
142
167
|
|
|
143
|
-
|
|
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:
|
|
144
169
|
|
|
145
170
|
```js
|
|
146
171
|
it('incident 8f3a fixed: a 100% promo checks out without a charge', async () => {
|
|
@@ -153,11 +178,17 @@ it('incident 8f3a fixed: a 100% promo checks out without a charge', async () =>
|
|
|
153
178
|
});
|
|
154
179
|
```
|
|
155
180
|
|
|
156
|
-
For every other recording
|
|
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.
|
|
186
|
+
|
|
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.
|
|
157
188
|
|
|
158
|
-
**
|
|
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.
|
|
159
190
|
|
|
160
|
-
**
|
|
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:
|
|
161
192
|
|
|
162
193
|
```js
|
|
163
194
|
const { result, trace } = await recordEffect(registerUserFlow, input);
|
|
@@ -165,9 +196,9 @@ const { result: replayed } = await replayEffect(registerUserFlow(input), trace);
|
|
|
165
196
|
assert.deepEqual(replayed, result); // fails if any step computed a fresh value
|
|
166
197
|
```
|
|
167
198
|
|
|
168
|
-
|
|
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.
|
|
169
200
|
|
|
170
|
-
**
|
|
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.
|
|
171
202
|
|
|
172
203
|
```js
|
|
173
204
|
// A resolver answers one question: what did production get back for this step?
|
|
@@ -177,7 +208,7 @@ await replayEffect(checkoutFlow(input), resolve);
|
|
|
177
208
|
|
|
178
209
|
## Recording in Production
|
|
179
210
|
|
|
180
|
-
`recordEffect`
|
|
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`).
|
|
181
212
|
|
|
182
213
|
```js
|
|
183
214
|
import { configureEffect } from 'pure-effect';
|
|
@@ -195,11 +226,17 @@ configureEffect(
|
|
|
195
226
|
);
|
|
196
227
|
```
|
|
197
228
|
|
|
198
|
-
|
|
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.
|
|
199
236
|
|
|
200
237
|
## Passing Runtime Context
|
|
201
238
|
|
|
202
|
-
Some values come from the framework
|
|
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:
|
|
203
240
|
|
|
204
241
|
```js
|
|
205
242
|
import { Success, Failure, Command, Ask, effectPipe, runEffect } from 'pure-effect';
|
|
@@ -212,7 +249,9 @@ const findProduct = (productId) =>
|
|
|
212
249
|
|
|
213
250
|
app.post('/checkout', async (req, res) => {
|
|
214
251
|
const result = await runEffect(checkoutFlow(req.body.productId), { tenant: req.tenant });
|
|
215
|
-
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.' });
|
|
216
255
|
});
|
|
217
256
|
```
|
|
218
257
|
|
|
@@ -220,19 +259,14 @@ Recording stores the context alongside the trace, so `Ask` replays with the valu
|
|
|
220
259
|
|
|
221
260
|
## Retrying Transient Failures
|
|
222
261
|
|
|
223
|
-
`Retry`
|
|
224
|
-
|
|
225
|
-
**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:
|
|
226
263
|
|
|
227
264
|
```js
|
|
228
|
-
|
|
229
|
-
Retry(
|
|
230
|
-
|
|
231
|
-
// Correct: only the step that fails transiently is retried.
|
|
232
|
-
effectPipe(chargeCard, (charge) => Retry(sendReceipt(charge), { attempts: 3 }))(order);
|
|
265
|
+
const flakyNetwork = { attempts: 3, delay: 200, backoff: 2 };
|
|
266
|
+
Retry(fetchPrice(sku), flakyNetwork);
|
|
233
267
|
```
|
|
234
268
|
|
|
235
|
-
|
|
269
|
+
The retry settings are a plain object, so you can inspect and assert on them without running anything:
|
|
236
270
|
|
|
237
271
|
```js
|
|
238
272
|
import { Success, Failure, Command, Retry, runEffect } from 'pure-effect';
|
|
@@ -251,15 +285,49 @@ assert.equal(weatherFn.type, 'Retry');
|
|
|
251
285
|
assert.equal(weatherFn.options.attempts, 3);
|
|
252
286
|
```
|
|
253
287
|
|
|
254
|
-
|
|
288
|
+
**Wrap the Command that fails, not the pipeline.** Each attempt re-runs everything inside the `Retry`, including Commands that already succeeded:
|
|
255
289
|
|
|
256
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:
|
|
323
|
+
|
|
324
|
+
```text
|
|
257
325
|
{ retryExhausted: true, lastError: <the last error>, attempts: 3 }
|
|
258
326
|
```
|
|
259
327
|
|
|
260
|
-
In TypeScript, a `Retry`
|
|
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`.
|
|
261
329
|
|
|
262
|
-
**
|
|
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:
|
|
263
331
|
|
|
264
332
|
```js
|
|
265
333
|
const fetchPrice = (sku) =>
|
|
@@ -269,13 +337,13 @@ const fetchPrice = (sku) =>
|
|
|
269
337
|
});
|
|
270
338
|
```
|
|
271
339
|
|
|
272
|
-
Fallback steps are recorded
|
|
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`.
|
|
273
341
|
|
|
274
|
-
Every attempt is
|
|
342
|
+
Every attempt is recorded, so a replay repeats the same sequence of failures, without the delays.
|
|
275
343
|
|
|
276
344
|
## Running Effects in Parallel
|
|
277
345
|
|
|
278
|
-
`Parallel` runs
|
|
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.
|
|
279
347
|
|
|
280
348
|
```js
|
|
281
349
|
import { Success, Command, Parallel } from 'pure-effect';
|
|
@@ -284,11 +352,43 @@ const loadProfile = (userId) =>
|
|
|
284
352
|
Parallel([getUser(userId), getPermissions(userId)], ([user, permissions]) => Success({ user, permissions }));
|
|
285
353
|
```
|
|
286
354
|
|
|
287
|
-
`next` is optional,
|
|
355
|
+
`next` is optional, as with `Command`. Without it, `Parallel(effects)` returns the array of values.
|
|
288
356
|
|
|
289
|
-
|
|
357
|
+
Every branch can read the `Ask` context.
|
|
290
358
|
|
|
291
|
-
**
|
|
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.
|
|
370
|
+
|
|
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`.
|
|
390
|
+
|
|
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:
|
|
292
392
|
|
|
293
393
|
```js
|
|
294
394
|
// Cancellable: the request is aborted the moment a sibling branch fails.
|
|
@@ -298,13 +398,15 @@ const fetchProfile = (userId) => Command((signal) => fetch(`/users/${userId}`, {
|
|
|
298
398
|
const fetchProfileUncancellable = (userId) => Command(() => fetch(`/users/${userId}`).then((r) => r.json()));
|
|
299
399
|
```
|
|
300
400
|
|
|
301
|
-
Outside a `Parallel
|
|
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.
|
|
302
404
|
|
|
303
405
|
## Composing Larger Flows
|
|
304
406
|
|
|
305
|
-
`effectPipe`
|
|
407
|
+
`effectPipe` runs steps in a straight line. Branching and joining use the same pieces:
|
|
306
408
|
|
|
307
|
-
**A step can return a sub-pipeline.** `effectPipe(...)(value)` returns an Effect like any other, so a step can branch into
|
|
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:
|
|
308
410
|
|
|
309
411
|
```js
|
|
310
412
|
const processOrder = (order) => (order.isGift ? giftFlow(order) : standardFlow(order));
|
|
@@ -312,7 +414,7 @@ const processOrder = (order) => (order.isGift ? giftFlow(order) : standardFlow(o
|
|
|
312
414
|
const fulfillment = effectPipe(validateOrder, processOrder, scheduleShipping);
|
|
313
415
|
```
|
|
314
416
|
|
|
315
|
-
**
|
|
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:
|
|
316
418
|
|
|
317
419
|
```js
|
|
318
420
|
const loadCheckout = effectPipe(
|
|
@@ -321,7 +423,7 @@ const loadCheckout = effectPipe(
|
|
|
321
423
|
);
|
|
322
424
|
```
|
|
323
425
|
|
|
324
|
-
**
|
|
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:
|
|
325
427
|
|
|
326
428
|
```js
|
|
327
429
|
const applyLoyaltyDiscount = (orderId) =>
|
|
@@ -332,13 +434,13 @@ const applyLoyaltyDiscount = (orderId) =>
|
|
|
332
434
|
)(orderId);
|
|
333
435
|
```
|
|
334
436
|
|
|
335
|
-
**
|
|
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.
|
|
336
438
|
|
|
337
439
|
## Which Errors Are Data
|
|
338
440
|
|
|
339
|
-
|
|
441
|
+
Pure Effect has no catch, on purpose. A flow's outcomes are of two kinds, and each is written differently.
|
|
340
442
|
|
|
341
|
-
**An outcome the flow handles is data.** A Command's `next` receives the result and can
|
|
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:
|
|
342
444
|
|
|
343
445
|
```js
|
|
344
446
|
const fetchCachedPrice = (sku) => {
|
|
@@ -351,16 +453,50 @@ const fetchPrice = (sku) => {
|
|
|
351
453
|
pricing
|
|
352
454
|
.get(sku)
|
|
353
455
|
.then((price) => ({ ok: true, price }))
|
|
354
|
-
.catch((error) => ({ ok: false, error }));
|
|
456
|
+
.catch((error) => ({ ok: false, code: error.code, message: error.message }));
|
|
355
457
|
return Command(cmdFetchLivePrice, (r) => (r.ok ? Success({ ...r.price, stale: false }) : fetchCachedPrice(sku)));
|
|
356
458
|
};
|
|
357
459
|
```
|
|
358
460
|
|
|
359
|
-
The failed
|
|
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)).
|
|
360
462
|
|
|
361
|
-
**A `Failure` means abort.** It
|
|
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.
|
|
362
464
|
|
|
363
|
-
|
|
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:
|
|
491
|
+
|
|
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 |
|
|
498
|
+
|
|
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`.
|
|
364
500
|
|
|
365
501
|
## TypeScript: Typed Errors and Context
|
|
366
502
|
|
|
@@ -381,7 +517,9 @@ if (result.type === 'Failure') {
|
|
|
381
517
|
}
|
|
382
518
|
```
|
|
383
519
|
|
|
384
|
-
Value types
|
|
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))`.
|
|
385
523
|
|
|
386
524
|
### Typed context with `Ask`
|
|
387
525
|
|
|
@@ -396,19 +534,40 @@ const findProduct = (productId: string): Effect<Product, 'not_found', AppContext
|
|
|
396
534
|
const result = await runEffect(findProduct('abc'), { tenant: 'acme', requestId: '123' });
|
|
397
535
|
```
|
|
398
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
|
+
|
|
399
539
|
## Why Pure Effect
|
|
400
540
|
|
|
401
|
-
**vs. Temporal and durable execution (Restate, Inngest):** The closest relatives,
|
|
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.
|
|
402
554
|
|
|
403
|
-
|
|
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.
|
|
404
561
|
|
|
405
|
-
**
|
|
562
|
+
**Batches and parallel work.**
|
|
406
563
|
|
|
407
|
-
|
|
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`.
|
|
408
567
|
|
|
409
568
|
## API Reference
|
|
410
569
|
|
|
411
|
-
###
|
|
570
|
+
### Building blocks
|
|
412
571
|
|
|
413
572
|
#### `Success(value)`
|
|
414
573
|
|
|
@@ -423,18 +582,20 @@ Returns `{ type: 'Failure', error, initialInput }`. Stops the pipeline immediate
|
|
|
423
582
|
Returns `{ type: 'Command', cmd, next, meta }`.
|
|
424
583
|
|
|
425
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.
|
|
426
|
-
- `next`: Receives the result of `cmd` and returns the next Effect. Optional, defaulting to `(result) => Success(result)
|
|
585
|
+
- `next`: Receives the result of `cmd` and returns the next Effect. Optional, defaulting to `(result) => Success(result)`.
|
|
427
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`).
|
|
428
587
|
|
|
429
|
-
**Every Command needs
|
|
588
|
+
**Every Command needs a name.** Test assertions, trace entries, replay matching, and telemetry spans all use it. It is chosen in this order:
|
|
430
589
|
|
|
431
590
|
```js
|
|
432
591
|
Command(cmdFn, next, { name: 'chargeCard' }); // 1. meta.name, independent of how cmdFn was written
|
|
433
|
-
Command(function cmdChargeCard() {
|
|
592
|
+
Command(function cmdChargeCard() {
|
|
593
|
+
return api.charge();
|
|
594
|
+
}, next); // 2. the function's own name
|
|
434
595
|
Command(() => api.charge(), next); // 3. neither, so 'anonymous'
|
|
435
596
|
```
|
|
436
597
|
|
|
437
|
-
|
|
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.
|
|
438
599
|
|
|
439
600
|
#### `Ask(nextFn)`
|
|
440
601
|
|
|
@@ -444,14 +605,19 @@ Returns `{ type: 'Ask', next }`. Passes the `context` from `runEffect` into `nex
|
|
|
444
605
|
|
|
445
606
|
Returns `{ type: 'Retry', effect, options, next }`.
|
|
446
607
|
|
|
447
|
-
- `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`.
|
|
448
609
|
- `options.delay`: Ms before the first retry (default: `100`).
|
|
449
|
-
- `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).
|
|
450
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).
|
|
451
612
|
|
|
452
|
-
#### `Parallel(effects, next?)`
|
|
613
|
+
#### `Parallel(effects, next?, options?)`
|
|
453
614
|
|
|
454
|
-
Returns `{ type: 'Parallel', effects, next }`. Runs all effects
|
|
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.
|
|
455
621
|
|
|
456
622
|
### Building pipelines
|
|
457
623
|
|
|
@@ -466,7 +632,7 @@ A step does not have to use the value it receives. In JavaScript, closing over s
|
|
|
466
632
|
const registerUserFlow = (input) => effectPipe(validateRegistration, () => saveUser(input))(input);
|
|
467
633
|
```
|
|
468
634
|
|
|
469
|
-
In TypeScript
|
|
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.
|
|
470
636
|
|
|
471
637
|
One shape to avoid in either language:
|
|
472
638
|
|
|
@@ -477,76 +643,117 @@ One shape to avoid in either language:
|
|
|
477
643
|
};
|
|
478
644
|
```
|
|
479
645
|
|
|
480
|
-
|
|
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.
|
|
481
647
|
|
|
482
|
-
###
|
|
648
|
+
### Running a flow
|
|
483
649
|
|
|
484
650
|
#### `runEffect(effect, context?, callConfig?)`
|
|
485
651
|
|
|
486
|
-
|
|
652
|
+
Walks the flow, runs each Command with `async/await`, resolves `Ask` with the supplied `context`, and returns the final `Success` or `Failure`.
|
|
487
653
|
|
|
488
654
|
- `context`: Passed to `Ask`'s next function and to `onBeforeCommand`. `context.flowName` names the workflow in telemetry.
|
|
489
|
-
- `callConfig`: Per-call
|
|
490
|
-
- `onRun` fires
|
|
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.
|
|
491
657
|
|
|
492
|
-
A step that returns something other than an Effect is a bug
|
|
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`:
|
|
493
659
|
|
|
494
660
|
```
|
|
495
661
|
Step 'validateRegistration' returned a plain object. Return Success, Failure, Command, Ask,
|
|
496
662
|
Retry, or Parallel: a plain value has to be wrapped, as in Success(value).
|
|
497
663
|
```
|
|
498
664
|
|
|
499
|
-
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
|
|
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.
|
|
500
666
|
|
|
501
667
|
#### `configureEffect(...configs)`
|
|
502
668
|
|
|
503
|
-
- `onRun(effect, pipeline, flowName)` wraps the entire workflow; must `await pipeline()
|
|
504
|
-
- `onStep(name, type, op)` wraps each Command; must `await op()` and return its result. Returning a value _without_ calling `op()` is how replay works.
|
|
505
|
-
- `
|
|
506
|
-
- `
|
|
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`.
|
|
507
675
|
|
|
508
|
-
|
|
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:
|
|
509
677
|
|
|
510
678
|
```js
|
|
511
679
|
configureEffect(telemetryHooks(), recordingHooks({ sink }));
|
|
680
|
+
|
|
681
|
+
// or, as two layers:
|
|
682
|
+
configureEffect(telemetryHooks());
|
|
683
|
+
configureEffect(recordingHooks({ sink }));
|
|
512
684
|
```
|
|
513
685
|
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
686
|
+
This is the order the hooks run in during one `runEffect`:
|
|
687
|
+
|
|
688
|
+
```
|
|
689
|
+
runEffect(flow(input))
|
|
690
|
+
│
|
|
691
|
+
├─ telemetry.onRun first configuration: outermost
|
|
692
|
+
│ └─ recording.onRun last configuration: innermost
|
|
693
|
+
│ │
|
|
694
|
+
│ │ for each Command:
|
|
695
|
+
│ ├─ telemetry.onBeforeCommand interceptors all run, in the order given
|
|
696
|
+
│ ├─ recording.onBeforeCommand
|
|
697
|
+
│ ├─ telemetry.onStep
|
|
698
|
+
│ │ └─ recording.onStep closest to the Command
|
|
699
|
+
│ │ └─ cmd()
|
|
700
|
+
│ │ ┌─ recording.onStep returns a result, or a thrown error, unwinds
|
|
701
|
+
│ ├─ telemetry.onStep returns from the inside out
|
|
702
|
+
│ │
|
|
703
|
+
│ ┌─ recording.onRun returns
|
|
704
|
+
├─ telemetry.onRun returns
|
|
705
|
+
```
|
|
706
|
+
|
|
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.
|
|
519
708
|
|
|
520
709
|
```js
|
|
521
|
-
const
|
|
710
|
+
const remove = configureEffect(telemetryHooks());
|
|
522
711
|
// ... later
|
|
523
|
-
|
|
712
|
+
remove();
|
|
713
|
+
```
|
|
714
|
+
|
|
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`:
|
|
716
|
+
|
|
717
|
+
```
|
|
718
|
+
inherit: true (default) inherit: false
|
|
719
|
+
|
|
720
|
+
onRun C.onRun (none)
|
|
721
|
+
onBeforeCommand C.onBeforeCommand (none)
|
|
722
|
+
onStep C.onStep( K.onStep( cmd ) ) K.onStep
|
|
524
723
|
```
|
|
525
724
|
|
|
526
|
-
|
|
725
|
+
`recordEffect` inherits, so recording inside an application that already has tracing still produces spans. `replayEffect` passes `inherit: false` unless `hooks: true`.
|
|
527
726
|
|
|
528
727
|
### Recording and replay
|
|
529
728
|
|
|
530
729
|
#### `recorder(options?)`
|
|
531
730
|
|
|
532
|
-
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.
|
|
533
732
|
|
|
534
|
-
Each entry is `{ command, path, result, durationMs }`, or `{ command, path, error, durationMs }` when the Command threw, so a trace also
|
|
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.
|
|
535
734
|
|
|
536
|
-
- `options.redact(value, name, kind)`:
|
|
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:
|
|
537
736
|
|
|
538
737
|
```js
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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
|
|
547
752
|
```
|
|
548
753
|
|
|
549
|
-
|
|
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.
|
|
550
757
|
|
|
551
758
|
- `options.maxEntries`: Cap trace length; overflow is counted in `dropped`.
|
|
552
759
|
- `options.stack`: Record stack traces for thrown errors (off by default).
|
|
@@ -557,16 +764,16 @@ Runs a flow for real while recording, returning `{ result, trace }`. Accepts `re
|
|
|
557
764
|
|
|
558
765
|
#### `replayEffect(effect, traceOrResolver, options?)`
|
|
559
766
|
|
|
560
|
-
Replays
|
|
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.
|
|
561
768
|
|
|
562
|
-
- `traceOrResolver`: a trace (or bare entries array) to replay directly, or a resolver function for traces stored in some other shape. A resolver returns `{ result }`, `{ error }`, or `undefined` if the step is unrecorded. A malformed trace rejects with a `ReplayError`.
|
|
563
|
-
- `options.context`: context for `Ask
|
|
564
|
-
- `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.
|
|
565
772
|
- `options.fastRetry` (default `true`): strip `Retry` delays.
|
|
566
|
-
- `options.hooks` (default `false`):
|
|
567
|
-
- `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.
|
|
568
775
|
|
|
569
|
-
A trace whose entries carry no `path` (written by hand, or recorded before paths existed) is matched
|
|
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.
|
|
570
777
|
|
|
571
778
|
#### `timeTravel(flowFn, traceLog, options?)`
|
|
572
779
|
|
|
@@ -574,11 +781,11 @@ Replays a trace and narrates each step with its recorded duration, naming any re
|
|
|
574
781
|
|
|
575
782
|
## Limitations
|
|
576
783
|
|
|
577
|
-
- **`Retry` repeats the
|
|
578
|
-
-
|
|
579
|
-
- **A `Failure`
|
|
580
|
-
- **Replay
|
|
581
|
-
- **A trace
|
|
582
|
-
- **
|
|
583
|
-
- **
|
|
584
|
-
-
|
|
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.
|