pure-effect 0.6.0 → 0.8.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 +3 -1
- package/.dirac-symbol-index/data.db +0 -0
- package/CLAUDE.md +29 -15
- package/README.md +261 -100
- package/index.d.ts +197 -73
- package/index.js +139 -33
- package/package.json +2 -2
- package/test/all.js +196 -20
- package/test/types.test-d.ts +148 -2
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
"Bash(npm test *)",
|
|
6
6
|
"Bash(git *)",
|
|
7
7
|
"Bash(node *)",
|
|
8
|
-
"Bash(npx tsc *)"
|
|
8
|
+
"Bash(npx tsc *)",
|
|
9
|
+
"Bash(npx tsd *)",
|
|
10
|
+
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); hooks=d.get\\('hooks',{}\\); print\\(json.dumps\\(hooks, indent=2\\)\\)\")"
|
|
9
11
|
]
|
|
10
12
|
}
|
|
11
13
|
}
|
|
Binary file
|
package/CLAUDE.md
CHANGED
|
@@ -17,41 +17,55 @@ No build or lint step — the library ships as plain ES modules with no transpil
|
|
|
17
17
|
|
|
18
18
|
### Core abstractions (all in `index.js`)
|
|
19
19
|
|
|
20
|
-
| Export
|
|
21
|
-
|
|
|
22
|
-
| `Success(value)`
|
|
23
|
-
| `Failure(error, initialInput)`
|
|
24
|
-
| `Command(cmdFn, nextFn, meta)`
|
|
25
|
-
| `Ask(nextFn)`
|
|
26
|
-
| `
|
|
27
|
-
| `
|
|
28
|
-
| `
|
|
20
|
+
| Export | Shape | Purpose |
|
|
21
|
+
| ----------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
22
|
+
| `Success(value)` | `{ type: 'Success', value }` | Wraps a successful result |
|
|
23
|
+
| `Failure(error, initialInput)` | `{ type: 'Failure', error, initialInput }` | Short-circuits the pipeline |
|
|
24
|
+
| `Command(cmdFn, nextFn, meta)` | `{ type: 'Command', cmd, next, meta }` | Defers a side effect for the interpreter |
|
|
25
|
+
| `Ask(nextFn)` | `{ type: 'Ask', next }` | Reads the `context` passed to `runEffect`; passes it to `nextFn` |
|
|
26
|
+
| `Retry(effect, options)` | `{ type: 'Retry', effect, options, next }` | Wraps any Effect tree with retry-on-failure semantics; handled natively by the interpreter |
|
|
27
|
+
| `Parallel(effects, next)` | `{ type: 'Parallel', effects, next }` | Runs multiple Effect trees concurrently via `Promise.all`; returns the first Failure by array order if any effect fails; context flows into all branches |
|
|
28
|
+
| `effectPipe(...fns)` | — | Composes functions into a sequential pipeline via `chain` |
|
|
29
|
+
| `runEffect(effect, context, callConfig?)` | async | Interpreter: traverses the effect tree, executes Commands; resolves `Ask` and `Retry`; per-call `callConfig` overrides global defaults |
|
|
30
|
+
| `configureEffect(options)` | — | Sets process-wide telemetry hooks (`onStep`, `onRun`, `onBeforeCommand`) and global `retry` defaults; overridden by per-call `callConfig` |
|
|
29
31
|
|
|
30
32
|
### Data flow
|
|
31
33
|
|
|
32
34
|
```
|
|
33
35
|
effectPipe(f1, f2, f3)(input)
|
|
34
|
-
→ returns tree of Success / Failure / Command / Ask values
|
|
35
|
-
→ f1 runs eagerly here
|
|
36
|
+
→ returns tree of Success / Failure / Command / Ask / Retry values
|
|
37
|
+
→ f1 runs eagerly here; initialInput is threaded through chain so every
|
|
38
|
+
node in the tree carries it without post-construction mutation
|
|
36
39
|
|
|
37
|
-
runEffect(tree, context)
|
|
40
|
+
runEffect(tree, context, callConfig?)
|
|
41
|
+
→ per-call callConfig merges over global configureEffect defaults
|
|
38
42
|
→ executes Commands async, passes results into next(result), repeats
|
|
39
43
|
→ resolves Ask by calling next(context), continues
|
|
44
|
+
→ resolves Retry via an inner execute() loop (not recursive runEffect),
|
|
45
|
+
so onRun fires exactly once per runEffect call regardless of attempts;
|
|
46
|
+
on exhaustion returns Failure({ retryExhausted: true, lastError, attempts })
|
|
47
|
+
→ resolves Parallel by running all effects via Promise.all with the same
|
|
48
|
+
context; if any effect returns Failure, returns the first Failure by
|
|
49
|
+
array index and skips next; otherwise calls next with the array of
|
|
50
|
+
unwrapped success values
|
|
40
51
|
→ resolves to final Success or Failure
|
|
41
52
|
```
|
|
42
53
|
|
|
43
|
-
The `chain` combinator (internal) drives composition: `Success` passes its value to the next function, `Failure` short-circuits, `Command` defers execution, `Ask` wraps its continuation so the chain propagates through it. `runEffect` loops through the tree with a `while` loop rather than recursion.
|
|
54
|
+
The `chain` combinator (internal) drives composition: `Success` passes its value to the next function, `Failure` short-circuits, `Command` defers execution, `Ask` wraps its continuation so the chain propagates through it, `Retry` wraps its continuation via object spread (same pattern as `Ask`). `chain` accepts an optional `initialInput` parameter; `effectPipe` passes the pipeline's starting value through every `chain` call so that `initialInput` is stamped on all nodes — including mid-pipeline `Failure`s — without mutation. `runEffect` loops through the tree with a `while` loop rather than recursion.
|
|
44
55
|
|
|
45
|
-
`configureEffect`
|
|
56
|
+
`configureEffect` options (all overridable per-call via `runEffect`'s third argument):
|
|
46
57
|
|
|
47
58
|
- `onStep` — fires on every Command execution; wraps the `cmd` call (use for per-command tracing)
|
|
48
|
-
- `onRun` — fires once per `runEffect` call; wraps the whole workflow (use for top-level spans); receives `context.flowName` as the third argument
|
|
59
|
+
- `onRun` — fires once per `runEffect` call; wraps the whole workflow (use for top-level spans); receives `context.flowName` as the third argument; does **not** fire again for Retry attempts
|
|
49
60
|
- `onBeforeCommand` — intercepts each Command before execution; receives the Command and the `context` passed to `runEffect`
|
|
61
|
+
- `retry` — global Retry defaults `{ attempts, delay, backoff }`; per-use options passed to `Retry(effect, options)` merge on top (defaults: `attempts: 3`, `delay: 100ms`, `backoff: 1` flat)
|
|
50
62
|
|
|
51
63
|
### TypeScript
|
|
52
64
|
|
|
53
65
|
Full generic type declarations are in `index.d.ts` and referenced via the `types` field in `package.json`. Type-level tests live in `test/types.test-d.ts` and run via `tsd` as part of `npm test`.
|
|
54
66
|
|
|
67
|
+
`Effect<T, E, Ctx>` carries three type parameters: value type, error union, and context type. `Ctx` flows through `AskState`, `CommandState`, `RetryState`, and all `effectPipe` overloads. `Ask<T, E, Ctx>` types the context callback parameter, and `runEffect<T, E, Ctx>` enforces that the supplied context matches. `Ctx` defaults to `unknown` so existing code without context typing is unaffected.
|
|
68
|
+
|
|
55
69
|
### Observability
|
|
56
70
|
|
|
57
71
|
`opentelemetry-example.js` shows how to wire OpenTelemetry spans into `configureEffect`'s hooks — it is reference code, not part of the library.
|
package/README.md
CHANGED
|
@@ -1,10 +1,30 @@
|
|
|
1
1
|
# Pure Effect
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
**Pure Effect**
|
|
3
|
+
[](https://www.npmjs.com/package/pure-effect)
|
|
4
|
+
[](https://bundlephobia.com/package/pure-effect)
|
|
5
|
+
[](./LICENSE)
|
|
6
|
+
|
|
7
|
+
**Pure Effect** is a zero-dependency effect library for JavaScript and TypeScript with built-in support for dependency injection, retry, and OpenTelemetry where business logic is plain data you can test without mocks.
|
|
8
|
+
|
|
9
|
+
- No mocks needed to test async pipelines
|
|
10
|
+
- Inject context without touching function signatures
|
|
11
|
+
- Built-in retry and parallel execution with configurable delay, backoff, and `Promise.all` semantics
|
|
12
|
+
- OpenTelemetry-ready via lifecycle hooks
|
|
13
|
+
- Zero dependencies, under 1 KB minified and gzipped
|
|
14
|
+
- Works in JavaScript and TypeScript (full generics, bundled `.d.ts`)
|
|
15
|
+
- Six primitives: learn the whole API in an afternoon
|
|
16
|
+
|
|
17
|
+
## Table of Contents
|
|
18
|
+
|
|
19
|
+
- [Installation](#installation)
|
|
20
|
+
- [Quick Start](#quick-start)
|
|
21
|
+
- [Testing Without Mocks](#testing-without-mocks)
|
|
22
|
+
- [Passing Runtime Context](#passing-runtime-context)
|
|
23
|
+
- [Retrying Transient Failures](#retrying-transient-failures)
|
|
24
|
+
- [Running Effects in Parallel](#running-effects-in-parallel)
|
|
25
|
+
- [TypeScript: Typed Errors and Context](#typescript-typed-errors-and-context)
|
|
26
|
+
- [Why Pure Effect](#why-pure-effect)
|
|
27
|
+
- [API Reference](#api-reference)
|
|
8
28
|
|
|
9
29
|
## Installation
|
|
10
30
|
|
|
@@ -12,9 +32,9 @@ It implements the "Functional Core, Imperative Shell" pattern, allowing you to d
|
|
|
12
32
|
npm install pure-effect
|
|
13
33
|
```
|
|
14
34
|
|
|
15
|
-
##
|
|
35
|
+
## Quick Start
|
|
16
36
|
|
|
17
|
-
Here is a complete example of a
|
|
37
|
+
Here is a complete example of a user registration flow:
|
|
18
38
|
|
|
19
39
|
```js
|
|
20
40
|
import { Success, Failure, Command, effectPipe, runEffect } from 'pure-effect';
|
|
@@ -25,40 +45,33 @@ const validateRegistration = (input) => {
|
|
|
25
45
|
return Success(input);
|
|
26
46
|
};
|
|
27
47
|
|
|
28
|
-
// These functions
|
|
29
|
-
// The 'next' function defines what happens with the result of the async call.
|
|
48
|
+
// These functions return a Command object. They do NOT call the database.
|
|
30
49
|
const findUser = (email) => {
|
|
31
|
-
const cmdFindUser = () => db.findUser(email);
|
|
32
|
-
|
|
33
|
-
return Command(cmdFindUser, next);
|
|
50
|
+
const cmdFindUser = () => db.findUser(email);
|
|
51
|
+
return Command(cmdFindUser, (user) => Success(user));
|
|
34
52
|
};
|
|
35
53
|
|
|
36
54
|
const saveUser = (input) => {
|
|
37
55
|
const cmdSaveUser = () => db.saveUser(input);
|
|
38
|
-
|
|
39
|
-
return Command(cmdSaveUser, next);
|
|
56
|
+
return Command(cmdSaveUser, (saved) => Success(saved));
|
|
40
57
|
};
|
|
41
58
|
|
|
42
|
-
const ensureEmailAvailable = (user) =>
|
|
43
|
-
return user ? Failure('Email already in use.') : Success(true);
|
|
44
|
-
};
|
|
59
|
+
const ensureEmailAvailable = (user) => (user ? Failure('Email already in use.') : Success(true));
|
|
45
60
|
|
|
46
|
-
//
|
|
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.
|
|
47
64
|
const registerUserFlow = (input) =>
|
|
48
65
|
effectPipe(
|
|
49
|
-
validateRegistration,
|
|
50
|
-
() => findUser(input.email),
|
|
51
|
-
ensureEmailAvailable,
|
|
52
|
-
() => saveUser(input)
|
|
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
|
|
53
70
|
)(input);
|
|
54
71
|
|
|
55
|
-
//
|
|
72
|
+
// Imperative shell: this is the only place side effects run
|
|
56
73
|
async function registerUser(input) {
|
|
57
|
-
|
|
58
|
-
const logic = registerUserFlow(input);
|
|
59
|
-
|
|
60
|
-
// runEffect performs the actual async work
|
|
61
|
-
const result = await runEffect(logic);
|
|
74
|
+
const result = await runEffect(registerUserFlow(input));
|
|
62
75
|
|
|
63
76
|
if (result.type === 'Success') {
|
|
64
77
|
console.log('User created:', result.value);
|
|
@@ -70,142 +83,290 @@ async function registerUser(input) {
|
|
|
70
83
|
|
|
71
84
|
## Testing Without Mocks
|
|
72
85
|
|
|
73
|
-
|
|
86
|
+
Because pipelines return plain objects, you can assert on _what the code intends to do_ without executing any of it. Using the registration flow defined in the previous section:
|
|
74
87
|
|
|
75
88
|
```js
|
|
76
|
-
// 1. Test
|
|
89
|
+
// 1. Test validation failure synchronously
|
|
77
90
|
const badInput = { email: 'bad-email', password: '123' };
|
|
78
91
|
const result = registerUserFlow(badInput);
|
|
79
92
|
|
|
80
|
-
assert.deepEqual(result, Failure('Invalid email
|
|
81
|
-
// ✅ Logic tested instantly, no async needed.
|
|
93
|
+
assert.deepEqual(result, Failure('Invalid email.', badInput));
|
|
82
94
|
|
|
83
|
-
// 2.
|
|
95
|
+
// 2. Walk the pipeline to verify intent
|
|
84
96
|
const goodInput = { email: 'test@test.com', password: 'password123' };
|
|
85
97
|
const step1 = registerUserFlow(goodInput);
|
|
86
98
|
|
|
87
|
-
//
|
|
99
|
+
// First thing the code does: look up the user
|
|
88
100
|
assert.equal(step1.type, 'Command');
|
|
89
101
|
assert.equal(step1.cmd.name, 'cmdFindUser');
|
|
90
102
|
|
|
91
|
-
//
|
|
103
|
+
// Next thing: save the user (simulate "user not found" result)
|
|
92
104
|
const step2 = step1.next(null);
|
|
93
105
|
assert.equal(step2.type, 'Command');
|
|
94
106
|
assert.equal(step2.cmd.name, 'cmdSaveUser');
|
|
95
|
-
//
|
|
107
|
+
// The full flow is verified. The database was never touched.
|
|
96
108
|
```
|
|
97
109
|
|
|
98
110
|
## Passing Runtime Context
|
|
99
111
|
|
|
100
|
-
Some values come from the framework layer such as
|
|
112
|
+
Some values come from the framework layer such as an authenticated tenant, a request trace ID, an environment config rather than from the data being processed. `Ask` lets a pipeline step read the `context` object passed to `runEffect` without threading it through every function signature.
|
|
101
113
|
|
|
102
|
-
In the example below, `ctx.tenant` is
|
|
114
|
+
In the example below, `ctx.tenant` is resolved from the JWT by the router. The domain layer never receives it as a parameter; it just asks for it when needed:
|
|
103
115
|
|
|
104
116
|
```js
|
|
105
117
|
import { Success, Failure, Command, Ask, effectPipe, runEffect } from 'pure-effect';
|
|
106
118
|
|
|
107
119
|
const findProduct = (productId) =>
|
|
108
|
-
Ask((ctx) =>
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
)
|
|
113
|
-
);
|
|
120
|
+
Ask((ctx) => {
|
|
121
|
+
const cmdFindProduct = () => db[ctx.tenant].findProduct(productId);
|
|
122
|
+
return Command(cmdFindProduct, (product) => (product ? Success(product) : Failure('Product not found.')));
|
|
123
|
+
});
|
|
114
124
|
|
|
115
125
|
const reserveStock = (product) =>
|
|
116
|
-
Ask((ctx) =>
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
)
|
|
121
|
-
);
|
|
122
|
-
|
|
123
|
-
const checkoutFlow = (productId) =>
|
|
124
|
-
effectPipe(
|
|
125
|
-
() => findProduct(productId),
|
|
126
|
-
({ product }) => reserveStock(product)
|
|
127
|
-
)(productId);
|
|
128
|
-
```
|
|
126
|
+
Ask((ctx) => {
|
|
127
|
+
const cmdReserveStock = () => db[ctx.tenant].reserveStock(product.id);
|
|
128
|
+
return Command(cmdReserveStock, (reserved) => Success({ product, reserved }));
|
|
129
|
+
});
|
|
129
130
|
|
|
130
|
-
|
|
131
|
+
const checkoutFlow = (productId) => effectPipe(findProduct, ({ product }) => reserveStock(product))(productId);
|
|
131
132
|
|
|
132
|
-
```js
|
|
133
133
|
app.post('/checkout', async (req, res) => {
|
|
134
134
|
const result = await runEffect(checkoutFlow(req.body.productId), {
|
|
135
|
-
tenant: req.
|
|
135
|
+
tenant: req.tenant
|
|
136
136
|
});
|
|
137
137
|
res.json(result);
|
|
138
138
|
});
|
|
139
139
|
```
|
|
140
140
|
|
|
141
|
+
## Retrying Transient Failures
|
|
142
|
+
|
|
143
|
+
`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
|
+
|
|
145
|
+
```js
|
|
146
|
+
import { Success, Failure, Command, Retry, effectPipe, runEffect } from 'pure-effect';
|
|
147
|
+
|
|
148
|
+
const fetchWeather = (city) => {
|
|
149
|
+
const cmdFetchWeather = () =>
|
|
150
|
+
fetch(`https://example-weather-api.com/v1/current?city=${city}`).then((r) => r.json());
|
|
151
|
+
return Retry(
|
|
152
|
+
Command(cmdFetchWeather, (data) => (data.error ? Failure(data.error) : Success(data))),
|
|
153
|
+
{ attempts: 3, delay: 200, backoff: 2 } // 200ms, 400ms, 800ms
|
|
154
|
+
);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// Assert on the retry config without making any network calls
|
|
158
|
+
const weatherFn = fetchWeather('Tokyo');
|
|
159
|
+
assert.equal(weatherFn.type, 'Retry');
|
|
160
|
+
assert.equal(weatherFn.options.attempts, 3);
|
|
161
|
+
assert.equal(weatherFn.effect.type, 'Command');
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
When all attempts are exhausted, `runEffect` returns a structured `Failure`:
|
|
165
|
+
|
|
166
|
+
```js
|
|
167
|
+
{
|
|
168
|
+
retryExhausted: true,
|
|
169
|
+
lastError: <the last error>,
|
|
170
|
+
attempts: 3
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Global defaults can be set via `configureEffect` and overridden per-use:
|
|
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
|
+
```
|
|
184
|
+
|
|
185
|
+
## Running Effects in Parallel
|
|
186
|
+
|
|
187
|
+
`Parallel` runs multiple Effect trees concurrently and passes their results to `next` as an ordered array. If any effect fails, `next` is not called and the `Failure` propagates immediately.
|
|
188
|
+
|
|
189
|
+
```js
|
|
190
|
+
import { Success, Failure, Command, Parallel, effectPipe, runEffect } from 'pure-effect';
|
|
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
|
+
};
|
|
201
|
+
|
|
202
|
+
const loadProfile = (userId) =>
|
|
203
|
+
Parallel([getUser(userId), getPermissions(userId)], ([user, permissions]) => Success({ user, permissions }));
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Because `Parallel` is a plain object, you can assert on its structure without running anything:
|
|
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
|
+
```
|
|
215
|
+
|
|
216
|
+
`Parallel` composes naturally inside `effectPipe`:
|
|
217
|
+
|
|
218
|
+
```js
|
|
219
|
+
const checkoutFlow = (input) =>
|
|
220
|
+
effectPipe(
|
|
221
|
+
validate,
|
|
222
|
+
({ productId, userId }) =>
|
|
223
|
+
Parallel([getProduct(productId), getUser(userId)], ([product, user]) => Success({ product, user })),
|
|
224
|
+
({ product, user }) => reserveStock(product, user)
|
|
225
|
+
)(input);
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
`Ask` context flows into all parallel branches without any extra wiring.
|
|
229
|
+
|
|
230
|
+
## TypeScript: Typed Errors and Context
|
|
231
|
+
|
|
232
|
+
### Error union across pipeline steps
|
|
233
|
+
|
|
234
|
+
Each step in `effectPipe` carries its own error type. The compiler collects them into a union automatically, so the return type of `runEffect` tells you exactly which errors are possible with full exhaustive narrowing, no extra boilerplate.
|
|
235
|
+
|
|
236
|
+
```ts
|
|
237
|
+
import { effectPipe, runEffect, Failure, Success, Command } from 'pure-effect';
|
|
238
|
+
import type { Effect } from 'pure-effect';
|
|
239
|
+
|
|
240
|
+
type ValidationError = 'invalid_email' | 'weak_password';
|
|
241
|
+
type ApiError = 'network_timeout' | 'rate_limited';
|
|
242
|
+
|
|
243
|
+
const validate = (input: { email: string }): Effect<{ email: string }, ValidationError> => {
|
|
244
|
+
if (!input.email.includes('@')) return Failure('invalid_email');
|
|
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>
|
|
257
|
+
|
|
258
|
+
if (result.type === 'Failure') {
|
|
259
|
+
result.error; // 'invalid_email' | 'weak_password' | 'network_timeout' | 'rate_limited'
|
|
260
|
+
}
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
### Typed context with `Ask`
|
|
264
|
+
|
|
265
|
+
`Effect<T, E, Ctx>` carries a third type parameter for the context object. TypeScript enforces that `runEffect` receives a matching value when you annotate `Ask` with a context type:
|
|
266
|
+
|
|
267
|
+
```ts
|
|
268
|
+
import { Ask, Command, Success, Failure, effectPipe, runEffect } from 'pure-effect';
|
|
269
|
+
import type { Effect } from 'pure-effect';
|
|
270
|
+
|
|
271
|
+
type AppContext = { tenant: string; requestId: string };
|
|
272
|
+
|
|
273
|
+
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
|
+
});
|
|
278
|
+
|
|
279
|
+
// ctx is typed as AppContext, no cast needed
|
|
280
|
+
const result = await runEffect(findProduct('abc'), { tenant: 'acme', requestId: '123' });
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
## Why Pure Effect
|
|
284
|
+
|
|
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, and parallel execution. If your problem is testability and async pipeline boilerplate, Pure Effect solves it with just six primitives you can learn in an afternoon. If you need fibers, in-flight cancellation, or streaming, Effect-TS is the right tool.
|
|
286
|
+
|
|
287
|
+
**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
|
+
|
|
289
|
+
**vs. plain async/await with mocks:** A mock that passes all your tests but diverges from what the real database driver or HTTP client actually does is worse than no test at all; it gives false confidence. Pure Effect eliminates the problem at the source: business logic never executes I/O, so there is nothing to mock. You assert on what the code _intends_ to do, not on a substitute that approximates it.
|
|
290
|
+
|
|
291
|
+
**When to use something else:** If your codebase has little async I/O or test isolation isn't a pain point, plain async/await is the simpler choice.
|
|
292
|
+
|
|
141
293
|
## API Reference
|
|
142
294
|
|
|
143
295
|
### `Success(value)`
|
|
144
296
|
|
|
145
|
-
Returns
|
|
297
|
+
Returns `{ type: 'Success', value }`. Represents a successful computation result.
|
|
146
298
|
|
|
147
|
-
### `Failure(error)`
|
|
299
|
+
### `Failure(error, initialInput?)`
|
|
148
300
|
|
|
149
|
-
Returns
|
|
301
|
+
Returns `{ type: 'Failure', error, initialInput }`. Stops the pipeline immediately and short-circuits any remaining steps.
|
|
150
302
|
|
|
151
|
-
### `Command(cmdFn, nextFn, meta)`
|
|
303
|
+
### `Command(cmdFn, nextFn, meta?)`
|
|
152
304
|
|
|
153
|
-
Returns
|
|
305
|
+
Returns `{ type: 'Command', cmd, next, meta }`.
|
|
154
306
|
|
|
155
307
|
- `cmdFn`: A function (sync or async) that performs the side effect.
|
|
156
|
-
- `nextFn`:
|
|
157
|
-
- `meta`: Optional metadata.
|
|
308
|
+
- `nextFn`: Receives the result of `cmdFn` and returns the next Effect.
|
|
309
|
+
- `meta`: Optional metadata (available to `onBeforeCommand`).
|
|
158
310
|
|
|
159
311
|
### `Ask(nextFn)`
|
|
160
312
|
|
|
161
|
-
Returns
|
|
313
|
+
Returns `{ type: 'Ask', next }`. Passes the `context` from `runEffect` into `nextFn`, which returns the next Effect. Works at any point in a pipeline.
|
|
162
314
|
|
|
163
315
|
```js
|
|
164
316
|
const findProduct = (productId) =>
|
|
165
|
-
Ask((ctx) =>
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
)
|
|
170
|
-
);
|
|
317
|
+
Ask((ctx) => {
|
|
318
|
+
const cmdFindProduct = () => db[ctx.tenant].findProduct(productId);
|
|
319
|
+
return Command(cmdFindProduct, (product) => (product ? Success(product) : Failure('Product not found.')));
|
|
320
|
+
});
|
|
171
321
|
```
|
|
172
322
|
|
|
173
|
-
|
|
323
|
+
### `Retry(effect, options?)`
|
|
174
324
|
|
|
175
|
-
|
|
325
|
+
Returns `{ type: 'Retry', effect, options, next }`. Wraps any Effect with retry-on-failure semantics.
|
|
176
326
|
|
|
177
|
-
|
|
327
|
+
- `effect`: Any Effect: a `Command`, an `effectPipe` result, or another `Retry`.
|
|
328
|
+
- `options.attempts`: Max retries, not counting the first try (default: `3`).
|
|
329
|
+
- `options.delay`: Ms before the first retry (default: `100`).
|
|
330
|
+
- `options.backoff`: Multiplier applied to delay on each attempt (default: `1`, flat).
|
|
178
331
|
|
|
179
|
-
|
|
332
|
+
On exhaustion, returns `Failure({ retryExhausted: true, lastError, attempts })`.
|
|
180
333
|
|
|
181
|
-
|
|
334
|
+
### `Parallel(effects, next)`
|
|
182
335
|
|
|
183
|
-
|
|
184
|
-
- The `onBeforeCommand` interceptor (see `configureEffect` below)
|
|
336
|
+
Returns `{ type: 'Parallel', effects, next }`. Runs all effects concurrently via `Promise.all`. If any effect fails, the first `Failure` is returned immediately and `next` is not called. `Ask` context flows into all branches.
|
|
185
337
|
|
|
186
|
-
`
|
|
338
|
+
- `effects`: Array of any Effects such as `Command`s, `effectPipe` results, nested `Retry`s, etc.
|
|
339
|
+
- `next`: Receives the array of unwrapped success values in the same order as `effects`, returns the next Effect.
|
|
187
340
|
|
|
188
|
-
|
|
341
|
+
### `effectPipe(...functions)`
|
|
189
342
|
|
|
190
|
-
|
|
343
|
+
Composes functions into a sequential pipeline. Each function receives the unwrapped `Success` value from the previous step. A `Failure` from any step stops the pipeline immediately.
|
|
191
344
|
|
|
192
|
-
|
|
345
|
+
When a step needs to use a captured variable instead of the piped value, wrap it in an arrow function:
|
|
193
346
|
|
|
194
|
-
|
|
347
|
+
```js
|
|
348
|
+
const flow = (input) =>
|
|
349
|
+
effectPipe(
|
|
350
|
+
validate, // receives input
|
|
351
|
+
() => findUser(input.email), // ignores piped value, uses captured input
|
|
352
|
+
ensureAvailable,
|
|
353
|
+
() => saveUser(input) // ignores piped value, uses captured input
|
|
354
|
+
)(input);
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
### `runEffect(effect, context?, callConfig?)`
|
|
358
|
+
|
|
359
|
+
The interpreter. Traverses the effect tree, executes Commands with `async/await`, resolves `Ask` with the supplied `context`, and returns the final `Success` or `Failure`.
|
|
195
360
|
|
|
196
|
-
- `
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
361
|
+
- `context`: Optional object passed to `Ask` continuations and `onBeforeCommand`. `context.flowName` names the workflow in telemetry.
|
|
362
|
+
- `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. The interpreter does not re-enter `runEffect` per attempt.
|
|
364
|
+
|
|
365
|
+
### `configureEffect(options)`
|
|
201
366
|
|
|
202
|
-
- `
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
- `op`: The actual side-effect function. You must `await op()` inside this callback and return its result.
|
|
367
|
+
- `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 the pipeline.
|
|
370
|
+
- `retry: { attempts?, delay?, backoff? }` global retry defaults.
|
|
207
371
|
|
|
208
|
-
-
|
|
209
|
-
Fires before a `Command` is executed. Ideal for inspecting metadata and context. If you throw, the pipeline stops immediately.
|
|
210
|
-
- `command`: The `Command` object.
|
|
211
|
-
- `context`: The context object passed to `runEffect`, if any.
|
|
372
|
+
See **opentelemetry-example.js** in the repository for a complete OpenTelemetry wiring example.
|