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