pure-effect 0.6.0 → 0.7.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 +2 -1
- package/.dirac-symbol-index/data.db +0 -0
- package/CLAUDE.md +24 -15
- package/README.md +208 -100
- package/index.d.ts +184 -73
- package/index.js +111 -33
- package/package.json +2 -2
- package/test/all.js +131 -20
- package/test/types.test-d.ts +61 -2
|
Binary file
|
package/CLAUDE.md
CHANGED
|
@@ -17,41 +17,50 @@ 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
|
+
| `effectPipe(...fns)` | — | Composes functions into a sequential pipeline via `chain` |
|
|
28
|
+
| `runEffect(effect, context, callConfig?)` | async | Interpreter: traverses the effect tree, executes Commands; resolves `Ask` and `Retry`; per-call `callConfig` overrides global defaults |
|
|
29
|
+
| `configureEffect(options)` | — | Sets process-wide telemetry hooks (`onStep`, `onRun`, `onBeforeCommand`) and global `retry` defaults; overridden by per-call `callConfig` |
|
|
29
30
|
|
|
30
31
|
### Data flow
|
|
31
32
|
|
|
32
33
|
```
|
|
33
34
|
effectPipe(f1, f2, f3)(input)
|
|
34
|
-
→ returns tree of Success / Failure / Command / Ask values
|
|
35
|
-
→ f1 runs eagerly here
|
|
35
|
+
→ returns tree of Success / Failure / Command / Ask / Retry values
|
|
36
|
+
→ f1 runs eagerly here; initialInput is threaded through chain so every
|
|
37
|
+
node in the tree carries it without post-construction mutation
|
|
36
38
|
|
|
37
|
-
runEffect(tree, context)
|
|
39
|
+
runEffect(tree, context, callConfig?)
|
|
40
|
+
→ per-call callConfig merges over global configureEffect defaults
|
|
38
41
|
→ executes Commands async, passes results into next(result), repeats
|
|
39
42
|
→ resolves Ask by calling next(context), continues
|
|
43
|
+
→ resolves Retry via an inner execute() loop (not recursive runEffect),
|
|
44
|
+
so onRun fires exactly once per runEffect call regardless of attempts;
|
|
45
|
+
on exhaustion returns Failure({ retryExhausted: true, lastError, attempts })
|
|
40
46
|
→ resolves to final Success or Failure
|
|
41
47
|
```
|
|
42
48
|
|
|
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.
|
|
49
|
+
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
50
|
|
|
45
|
-
`configureEffect`
|
|
51
|
+
`configureEffect` options (all overridable per-call via `runEffect`'s third argument):
|
|
46
52
|
|
|
47
53
|
- `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
|
|
54
|
+
- `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
55
|
- `onBeforeCommand` — intercepts each Command before execution; receives the Command and the `context` passed to `runEffect`
|
|
56
|
+
- `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
57
|
|
|
51
58
|
### TypeScript
|
|
52
59
|
|
|
53
60
|
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
61
|
|
|
62
|
+
`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.
|
|
63
|
+
|
|
55
64
|
### Observability
|
|
56
65
|
|
|
57
66
|
`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,29 @@
|
|
|
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 with configurable delay and backoff
|
|
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
|
+
- Five 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
|
+
- [TypeScript: Typed Errors and Context](#typescript-typed-errors-and-context)
|
|
25
|
+
- [Why Pure Effect](#why-pure-effect)
|
|
26
|
+
- [API Reference](#api-reference)
|
|
8
27
|
|
|
9
28
|
## Installation
|
|
10
29
|
|
|
@@ -12,9 +31,9 @@ It implements the "Functional Core, Imperative Shell" pattern, allowing you to d
|
|
|
12
31
|
npm install pure-effect
|
|
13
32
|
```
|
|
14
33
|
|
|
15
|
-
##
|
|
34
|
+
## Quick Start
|
|
16
35
|
|
|
17
|
-
Here is a complete example of a
|
|
36
|
+
Here is a complete example of a user registration flow:
|
|
18
37
|
|
|
19
38
|
```js
|
|
20
39
|
import { Success, Failure, Command, effectPipe, runEffect } from 'pure-effect';
|
|
@@ -25,40 +44,33 @@ const validateRegistration = (input) => {
|
|
|
25
44
|
return Success(input);
|
|
26
45
|
};
|
|
27
46
|
|
|
28
|
-
//
|
|
29
|
-
// The 'next' function defines what happens with the result of the async call.
|
|
47
|
+
// Theese function return a Command object. They do NOT call the database.
|
|
30
48
|
const findUser = (email) => {
|
|
31
|
-
const cmdFindUser = () => db.findUser(email);
|
|
32
|
-
|
|
33
|
-
return Command(cmdFindUser, next);
|
|
49
|
+
const cmdFindUser = () => db.findUser(email);
|
|
50
|
+
return Command(cmdFindUser, (user) => Success(user));
|
|
34
51
|
};
|
|
35
52
|
|
|
36
53
|
const saveUser = (input) => {
|
|
37
54
|
const cmdSaveUser = () => db.saveUser(input);
|
|
38
|
-
|
|
39
|
-
return Command(cmdSaveUser, next);
|
|
55
|
+
return Command(cmdSaveUser, (saved) => Success(saved));
|
|
40
56
|
};
|
|
41
57
|
|
|
42
|
-
const ensureEmailAvailable = (user) =>
|
|
43
|
-
return user ? Failure('Email already in use.') : Success(true);
|
|
44
|
-
};
|
|
58
|
+
const ensureEmailAvailable = (user) => (user ? Failure('Email already in use.') : Success(true));
|
|
45
59
|
|
|
46
|
-
//
|
|
60
|
+
// effectPipe threads the output of each step into the next.
|
|
61
|
+
// When you need to use a captured variable instead of the piped value,
|
|
62
|
+
// wrap the call in an arrow function.
|
|
47
63
|
const registerUserFlow = (input) =>
|
|
48
64
|
effectPipe(
|
|
49
|
-
validateRegistration,
|
|
50
|
-
() => findUser(input.email),
|
|
51
|
-
ensureEmailAvailable,
|
|
52
|
-
() => saveUser(input)
|
|
65
|
+
validateRegistration, // input -> Success(input)
|
|
66
|
+
() => findUser(input.email), // ignores piped value, uses captured input
|
|
67
|
+
ensureEmailAvailable, // found user (or null) -> Success(true)
|
|
68
|
+
() => saveUser(input) // ignores piped value, uses captured input
|
|
53
69
|
)(input);
|
|
54
70
|
|
|
55
|
-
//
|
|
71
|
+
// Imperative shell: this is the only place side effects run
|
|
56
72
|
async function registerUser(input) {
|
|
57
|
-
|
|
58
|
-
const logic = registerUserFlow(input);
|
|
59
|
-
|
|
60
|
-
// runEffect performs the actual async work
|
|
61
|
-
const result = await runEffect(logic);
|
|
73
|
+
const result = await runEffect(registerUserFlow(input));
|
|
62
74
|
|
|
63
75
|
if (result.type === 'Success') {
|
|
64
76
|
console.log('User created:', result.value);
|
|
@@ -70,142 +82,238 @@ async function registerUser(input) {
|
|
|
70
82
|
|
|
71
83
|
## Testing Without Mocks
|
|
72
84
|
|
|
73
|
-
|
|
85
|
+
Because pipelines return plain objects, you can assert on _what the code intends to do_ without executing any of it. Using the registration flow defined in the previous section:
|
|
74
86
|
|
|
75
87
|
```js
|
|
76
|
-
// 1. Test
|
|
88
|
+
// 1. Test validation failure synchronously
|
|
77
89
|
const badInput = { email: 'bad-email', password: '123' };
|
|
78
90
|
const result = registerUserFlow(badInput);
|
|
79
91
|
|
|
80
|
-
assert.deepEqual(result, Failure('Invalid email
|
|
81
|
-
// ✅ Logic tested instantly, no async needed.
|
|
92
|
+
assert.deepEqual(result, Failure('Invalid email.', badInput));
|
|
82
93
|
|
|
83
|
-
// 2.
|
|
94
|
+
// 2. Walk the pipeline to verify intent
|
|
84
95
|
const goodInput = { email: 'test@test.com', password: 'password123' };
|
|
85
96
|
const step1 = registerUserFlow(goodInput);
|
|
86
97
|
|
|
87
|
-
//
|
|
98
|
+
// First thing the code does: look up the user
|
|
88
99
|
assert.equal(step1.type, 'Command');
|
|
89
100
|
assert.equal(step1.cmd.name, 'cmdFindUser');
|
|
90
101
|
|
|
91
|
-
//
|
|
102
|
+
// Next thing: save the user (simulate "user not found" result)
|
|
92
103
|
const step2 = step1.next(null);
|
|
93
104
|
assert.equal(step2.type, 'Command');
|
|
94
105
|
assert.equal(step2.cmd.name, 'cmdSaveUser');
|
|
95
|
-
//
|
|
106
|
+
// The full flow is verified. The database was never touched.
|
|
96
107
|
```
|
|
97
108
|
|
|
98
109
|
## Passing Runtime Context
|
|
99
110
|
|
|
100
|
-
Some values come from the framework layer such as
|
|
111
|
+
Some values come from the framework layer such as an authenticated tenant, a request trace ID, an environment config rather than from the data being processed. `Ask` lets a pipeline step read the `context` object passed to `runEffect` without threading it through every function signature.
|
|
101
112
|
|
|
102
|
-
In the example below, `ctx.tenant` is
|
|
113
|
+
In the example below, `ctx.tenant` is resolved from the JWT by the router. The domain layer never receives it as a parameter; it just asks for it when needed:
|
|
103
114
|
|
|
104
115
|
```js
|
|
105
116
|
import { Success, Failure, Command, Ask, effectPipe, runEffect } from 'pure-effect';
|
|
106
117
|
|
|
107
118
|
const findProduct = (productId) =>
|
|
108
|
-
Ask((ctx) =>
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
)
|
|
113
|
-
);
|
|
119
|
+
Ask((ctx) => {
|
|
120
|
+
const cmdFindProduct = () => db[ctx.tenant].findProduct(productId);
|
|
121
|
+
return Command(cmdFindProduct, (product) => (product ? Success(product) : Failure('Product not found.')));
|
|
122
|
+
});
|
|
114
123
|
|
|
115
124
|
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
|
-
```
|
|
125
|
+
Ask((ctx) => {
|
|
126
|
+
const cmdReserveStock = () => db[ctx.tenant].reserveStock(product.id);
|
|
127
|
+
return Command(cmdReserveStock, (reserved) => Success({ product, reserved }));
|
|
128
|
+
});
|
|
129
129
|
|
|
130
|
-
|
|
130
|
+
const checkoutFlow = (productId) => effectPipe(findProduct, ({ product }) => reserveStock(product))(productId);
|
|
131
131
|
|
|
132
|
-
```js
|
|
133
132
|
app.post('/checkout', async (req, res) => {
|
|
134
133
|
const result = await runEffect(checkoutFlow(req.body.productId), {
|
|
135
|
-
tenant: req.
|
|
134
|
+
tenant: req.tenant
|
|
136
135
|
});
|
|
137
136
|
res.json(result);
|
|
138
137
|
});
|
|
139
138
|
```
|
|
140
139
|
|
|
140
|
+
## Retrying Transient Failures
|
|
141
|
+
|
|
142
|
+
`Retry` wraps any Effect tree with retry-on-failure semantics. Like everything else in Pure Effect, the retry configuration is a plain object you can inspect and assert on without running anything.
|
|
143
|
+
|
|
144
|
+
```js
|
|
145
|
+
import { Success, Failure, Command, Retry, effectPipe, runEffect } from 'pure-effect';
|
|
146
|
+
|
|
147
|
+
const fetchWeather = (city) => {
|
|
148
|
+
const cmdFetchWeather = () =>
|
|
149
|
+
fetch(`https://example-weather-api.com/v1/current?city=${city}`).then((r) => r.json());
|
|
150
|
+
return Retry(
|
|
151
|
+
Command(cmdFetchWeather, (data) => (data.error ? Failure(data.error) : Success(data))),
|
|
152
|
+
{ attempts: 3, delay: 200, backoff: 2 } // 200ms, 400ms, 800ms
|
|
153
|
+
);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// Assert on the retry config without making any network calls
|
|
157
|
+
const weatherFn = fetchWeather('Tokyo');
|
|
158
|
+
assert.equal(weatherFn.type, 'Retry');
|
|
159
|
+
assert.equal(weatherFn.options.attempts, 3);
|
|
160
|
+
assert.equal(weatherFn.effect.type, 'Command');
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
When all attempts are exhausted, `runEffect` returns a structured `Failure`:
|
|
164
|
+
|
|
165
|
+
```js
|
|
166
|
+
{
|
|
167
|
+
retryExhausted: true,
|
|
168
|
+
lastError: <the last error>,
|
|
169
|
+
attempts: 3
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Global defaults can be set via `configureEffect` and overridden per-use:
|
|
174
|
+
|
|
175
|
+
```js
|
|
176
|
+
configureEffect({
|
|
177
|
+
retry: { attempts: 3, delay: 100, backoff: 1 } // flat delay by default
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// Per-use options are merged on top of global defaults
|
|
181
|
+
Retry(effect, { delay: 500 }); // uses global attempts, custom delay
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## TypeScript: Typed Errors and Context
|
|
185
|
+
|
|
186
|
+
### Error union across pipeline steps
|
|
187
|
+
|
|
188
|
+
Each step in `effectPipe` carries its own error type. The compiler collects them into a union automatically, so the return type of `runEffect` tells you exactly which errors are possible with full exhaustive narrowing, no extra boilerplate.
|
|
189
|
+
|
|
190
|
+
```ts
|
|
191
|
+
import { effectPipe, runEffect, Failure, Success, Command } from 'pure-effect';
|
|
192
|
+
import type { Effect } from 'pure-effect';
|
|
193
|
+
|
|
194
|
+
type ValidationError = 'invalid_email' | 'weak_password';
|
|
195
|
+
type ApiError = 'network_timeout' | 'rate_limited';
|
|
196
|
+
|
|
197
|
+
const validate = (input: { email: string }): Effect<{ email: string }, ValidationError> => {
|
|
198
|
+
if (!input.email.includes('@')) return Failure('invalid_email');
|
|
199
|
+
return Success(input);
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const submit = (input: { email: string }): Effect<{ id: number }, ApiError> => {
|
|
203
|
+
const cmdSubmitUser = () =>
|
|
204
|
+
fetch('/api/users', { method: 'POST', body: JSON.stringify(input) }).then((r) => r.json());
|
|
205
|
+
return Command(cmdSubmitUser, (data) => Success(data));
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const flow = effectPipe(validate, submit);
|
|
209
|
+
const result = await runEffect(flow({ email: 'user@example.com' }));
|
|
210
|
+
// result: SuccessState<{ id: number }> | FailureState<ValidationError | ApiError>
|
|
211
|
+
|
|
212
|
+
if (result.type === 'Failure') {
|
|
213
|
+
result.error; // 'invalid_email' | 'weak_password' | 'network_timeout' | 'rate_limited'
|
|
214
|
+
}
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
### Typed context with `Ask`
|
|
218
|
+
|
|
219
|
+
`Effect<T, E, Ctx>` carries a third type parameter for the context object. TypeScript enforces that `runEffect` receives a matching value when you annotate `Ask` with a context type:
|
|
220
|
+
|
|
221
|
+
```ts
|
|
222
|
+
import { Ask, Command, Success, Failure, effectPipe, runEffect } from 'pure-effect';
|
|
223
|
+
import type { Effect } from 'pure-effect';
|
|
224
|
+
|
|
225
|
+
type AppContext = { tenant: string; requestId: string };
|
|
226
|
+
|
|
227
|
+
const findProduct = (productId: string): Effect<Product, 'not_found', AppContext> =>
|
|
228
|
+
Ask<Product, 'not_found', AppContext>((ctx) => {
|
|
229
|
+
const cmdFindProduct = () => db[ctx.tenant].findProduct(productId);
|
|
230
|
+
return Command(cmdFindProduct, (product) => (product ? Success(product) : Failure('not_found')));
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// ctx is typed as AppContext, no cast needed
|
|
234
|
+
const result = await runEffect(findProduct('abc'), { tenant: 'acme', requestId: '123' });
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
## Why Pure Effect
|
|
238
|
+
|
|
239
|
+
**vs. Effect-TS:** Effect-TS is a full functional programming ecosystem with fibers, streaming, schema validation, dependency injection, and is probably the right choice if you need that breadth. It arguably comes with a steep learning curve though. Pure Effect targets a narrower scope (testable pipelines, context injection, retry) and can be learned in an afternoon.
|
|
240
|
+
|
|
241
|
+
**vs. fp-ts:** fp-ts applies category theory abstractions (functors, monads) to TypeScript. Pure Effect borrows only the concept of effects as data and expresses it without that vocabulary.
|
|
242
|
+
|
|
243
|
+
**vs. plain async/await with mocks:** Mocks can drift from real implementations silently. Pure Effect sidesteps the problem: business logic never executes I/O, so there is nothing to mock.
|
|
244
|
+
|
|
245
|
+
**When to use something else:** If your codebase has little async I/O or test isolation isn't a pain point, plain async/await is the simpler choice.
|
|
246
|
+
|
|
141
247
|
## API Reference
|
|
142
248
|
|
|
143
249
|
### `Success(value)`
|
|
144
250
|
|
|
145
|
-
Returns
|
|
251
|
+
Returns `{ type: 'Success', value }`. Represents a successful computation result.
|
|
146
252
|
|
|
147
|
-
### `Failure(error)`
|
|
253
|
+
### `Failure(error, initialInput?)`
|
|
148
254
|
|
|
149
|
-
Returns
|
|
255
|
+
Returns `{ type: 'Failure', error, initialInput }`. Stops the pipeline immediately and short-circuits any remaining steps.
|
|
150
256
|
|
|
151
|
-
### `Command(cmdFn, nextFn, meta)`
|
|
257
|
+
### `Command(cmdFn, nextFn, meta?)`
|
|
152
258
|
|
|
153
|
-
Returns
|
|
259
|
+
Returns `{ type: 'Command', cmd, next, meta }`.
|
|
154
260
|
|
|
155
261
|
- `cmdFn`: A function (sync or async) that performs the side effect.
|
|
156
|
-
- `nextFn`:
|
|
157
|
-
- `meta`: Optional metadata.
|
|
262
|
+
- `nextFn`: Receives the result of `cmdFn` and returns the next Effect.
|
|
263
|
+
- `meta`: Optional metadata (available to `onBeforeCommand`).
|
|
158
264
|
|
|
159
265
|
### `Ask(nextFn)`
|
|
160
266
|
|
|
161
|
-
Returns
|
|
267
|
+
Returns `{ type: 'Ask', next }`. Passes the `context` from `runEffect` into `nextFn`, which returns the next Effect. Works at any point in a pipeline.
|
|
162
268
|
|
|
163
269
|
```js
|
|
164
270
|
const findProduct = (productId) =>
|
|
165
|
-
Ask((ctx) =>
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
)
|
|
170
|
-
);
|
|
271
|
+
Ask((ctx) => {
|
|
272
|
+
const cmdFindProduct = () => db[ctx.tenant].findProduct(productId);
|
|
273
|
+
return Command(cmdFindProduct, (product) => (product ? Success(product) : Failure('Product not found.')));
|
|
274
|
+
});
|
|
171
275
|
```
|
|
172
276
|
|
|
173
|
-
|
|
277
|
+
### `Retry(effect, options?)`
|
|
174
278
|
|
|
175
|
-
|
|
279
|
+
Returns `{ type: 'Retry', effect, options, next }`. Wraps any Effect with retry-on-failure semantics.
|
|
176
280
|
|
|
177
|
-
|
|
281
|
+
- `effect`: Any Effect: a `Command`, an `effectPipe` result, or another `Retry`.
|
|
282
|
+
- `options.attempts`: Max retries, not counting the first try (default: `3`).
|
|
283
|
+
- `options.delay`: Ms before the first retry (default: `100`).
|
|
284
|
+
- `options.backoff`: Multiplier applied to delay on each attempt (default: `1`, flat).
|
|
178
285
|
|
|
179
|
-
|
|
286
|
+
On exhaustion, returns `Failure({ retryExhausted: true, lastError, attempts })`.
|
|
180
287
|
|
|
181
|
-
|
|
288
|
+
### `effectPipe(...functions)`
|
|
182
289
|
|
|
183
|
-
|
|
184
|
-
- The `onBeforeCommand` interceptor (see `configureEffect` below)
|
|
290
|
+
Composes functions into a sequential pipeline. Each function receives the unwrapped `Success` value from the previous step. A `Failure` from any step stops the pipeline immediately.
|
|
185
291
|
|
|
186
|
-
|
|
292
|
+
When a step needs to use a captured variable instead of the piped value, wrap it in an arrow function:
|
|
187
293
|
|
|
188
|
-
|
|
294
|
+
```js
|
|
295
|
+
const flow = (input) =>
|
|
296
|
+
effectPipe(
|
|
297
|
+
validate, // receives input
|
|
298
|
+
() => findUser(input.email), // ignores piped value, uses captured input
|
|
299
|
+
ensureAvailable,
|
|
300
|
+
() => saveUser(input) // ignores piped value, uses captured input
|
|
301
|
+
)(input);
|
|
302
|
+
```
|
|
189
303
|
|
|
190
|
-
### `
|
|
304
|
+
### `runEffect(effect, context?, callConfig?)`
|
|
191
305
|
|
|
192
|
-
|
|
306
|
+
The interpreter. Traverses the effect tree, executes Commands with `async/await`, resolves `Ask` with the supplied `context`, and returns the final `Success` or `Failure`.
|
|
193
307
|
|
|
194
|
-
|
|
308
|
+
- `context`: Optional object passed to `Ask` continuations and `onBeforeCommand`. `context.flowName` names the workflow in telemetry.
|
|
309
|
+
- `callConfig`: Per-call overrides for `onStep`, `onRun`, `onBeforeCommand`, and `retry`. Takes precedence over `configureEffect` globals.
|
|
310
|
+
- `onRun` fires exactly once per `runEffect` call. Retry attempts run inside that single span. The interpreter does not re-enter `runEffect` per attempt.
|
|
195
311
|
|
|
196
|
-
|
|
197
|
-
Fires once per `runEffect` call. It wraps the entire workflow execution.
|
|
198
|
-
- `effect`: The initial state of the effect tree (useful for extracting `initialInput`).
|
|
199
|
-
- `pipeline`: The actual interpreter. You must `await pipeline()` inside this callback to run the logic.
|
|
200
|
-
- `flowName`: The optional name of the workflow passed to `runEffect`.
|
|
312
|
+
### `configureEffect(options)`
|
|
201
313
|
|
|
202
|
-
- `
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
- `op`: The actual side-effect function. You must `await op()` inside this callback and return its result.
|
|
314
|
+
- `onRun(effect, pipeline, flowName)` wraps the entire workflow; must `await pipeline()`.
|
|
315
|
+
- `onStep(name, type, op)` wraps each Command; must `await op()` and return its result.
|
|
316
|
+
- `onBeforeCommand(command, context)` ires before each Command; throw to abort the pipeline.
|
|
317
|
+
- `retry: { attempts?, delay?, backoff? }` global retry defaults.
|
|
207
318
|
|
|
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.
|
|
319
|
+
See **opentelemetry-example.js** in the repository for a complete OpenTelemetry wiring example.
|
package/index.d.ts
CHANGED
|
@@ -10,96 +10,200 @@ export type FailureState<E = unknown> = {
|
|
|
10
10
|
initialInput?: unknown;
|
|
11
11
|
};
|
|
12
12
|
|
|
13
|
-
export type CommandState<R, T, E = unknown> = {
|
|
13
|
+
export type CommandState<R, T, E = unknown, Ctx = unknown> = {
|
|
14
14
|
type: 'Command';
|
|
15
15
|
cmd: () => Promise<R> | R;
|
|
16
|
-
next: (result: R) => Effect<T, E>;
|
|
16
|
+
next: (result: R) => Effect<T, E, Ctx>;
|
|
17
17
|
meta?: unknown;
|
|
18
18
|
initialInput?: unknown;
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
-
export type AskState<T, E = unknown> = {
|
|
21
|
+
export type AskState<T, E = unknown, Ctx = unknown> = {
|
|
22
22
|
type: 'Ask';
|
|
23
|
-
next: (context:
|
|
23
|
+
next: (context: Ctx) => Effect<T, E, Ctx>;
|
|
24
24
|
initialInput?: unknown;
|
|
25
25
|
};
|
|
26
26
|
|
|
27
|
-
export type
|
|
27
|
+
export type RetryOptions = {
|
|
28
|
+
attempts?: number;
|
|
29
|
+
delay?: number;
|
|
30
|
+
backoff?: number;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type RetryState<T, E = unknown, Ctx = unknown> = {
|
|
34
|
+
type: 'Retry';
|
|
35
|
+
effect: Effect<T, E, Ctx>;
|
|
36
|
+
options: RetryOptions;
|
|
37
|
+
next: (value: T) => Effect<T, E, Ctx>;
|
|
38
|
+
initialInput?: unknown;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type RetryExhaustedError<E = unknown> = {
|
|
42
|
+
retryExhausted: true;
|
|
43
|
+
lastError: E;
|
|
44
|
+
attempts: number;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type Effect<T, E = unknown, Ctx = unknown> =
|
|
48
|
+
| SuccessState<T>
|
|
49
|
+
| FailureState<E>
|
|
50
|
+
| CommandState<any, T, E, Ctx>
|
|
51
|
+
| AskState<T, E, Ctx>
|
|
52
|
+
| RetryState<T, E, Ctx>;
|
|
28
53
|
|
|
29
54
|
export declare function Success<T>(value: T): SuccessState<T>;
|
|
30
55
|
|
|
31
56
|
export declare function Failure<E = unknown>(error: E, initialInput?: unknown): FailureState<E>;
|
|
32
57
|
|
|
33
|
-
export declare function Command<R, T, E = unknown>(
|
|
58
|
+
export declare function Command<R, T, E = unknown, Ctx = unknown>(
|
|
34
59
|
cmd: () => Promise<R> | R,
|
|
35
|
-
next: (result: R) => Effect<T, E>,
|
|
60
|
+
next: (result: R) => Effect<T, E, Ctx>,
|
|
36
61
|
meta?: unknown
|
|
37
|
-
): CommandState<R, T, E>;
|
|
38
|
-
|
|
39
|
-
export declare function Ask<T, E = unknown
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
export declare function
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
):
|
|
47
|
-
|
|
48
|
-
export declare function effectPipe<A, B,
|
|
49
|
-
f1: (a: A) => Effect<B,
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
62
|
+
): CommandState<R, T, E, Ctx>;
|
|
63
|
+
|
|
64
|
+
export declare function Ask<T, E = unknown, Ctx = unknown>(
|
|
65
|
+
next: (context: Ctx) => Effect<T, E, Ctx>
|
|
66
|
+
): AskState<T, E, Ctx>;
|
|
67
|
+
|
|
68
|
+
export declare function Retry<T, E = unknown, Ctx = unknown>(
|
|
69
|
+
effect: Effect<T, E, Ctx>,
|
|
70
|
+
options?: RetryOptions
|
|
71
|
+
): RetryState<T, E, Ctx>;
|
|
72
|
+
|
|
73
|
+
export declare function effectPipe<A, B, E1 = unknown, Ctx = unknown>(
|
|
74
|
+
f1: (a: A) => Effect<B, E1, Ctx>
|
|
75
|
+
): (start: A) => Effect<B, E1, Ctx>;
|
|
76
|
+
|
|
77
|
+
export declare function effectPipe<A, B, C, E1 = unknown, E2 = unknown, Ctx = unknown>(
|
|
78
|
+
f1: (a: A) => Effect<B, E1, Ctx>,
|
|
79
|
+
f2: (b: B) => Effect<C, E2, Ctx>
|
|
80
|
+
): (start: A) => Effect<C, E1 | E2, Ctx>;
|
|
81
|
+
|
|
82
|
+
export declare function effectPipe<A, B, C, D, E1 = unknown, E2 = unknown, E3 = unknown, Ctx = unknown>(
|
|
83
|
+
f1: (a: A) => Effect<B, E1, Ctx>,
|
|
84
|
+
f2: (b: B) => Effect<C, E2, Ctx>,
|
|
85
|
+
f3: (c: C) => Effect<D, E3, Ctx>
|
|
86
|
+
): (start: A) => Effect<D, E1 | E2 | E3, Ctx>;
|
|
87
|
+
|
|
88
|
+
export declare function effectPipe<
|
|
89
|
+
A,
|
|
90
|
+
B,
|
|
91
|
+
C,
|
|
92
|
+
D,
|
|
93
|
+
F,
|
|
94
|
+
E1 = unknown,
|
|
95
|
+
E2 = unknown,
|
|
96
|
+
E3 = unknown,
|
|
97
|
+
E4 = unknown,
|
|
98
|
+
Ctx = unknown
|
|
99
|
+
>(
|
|
100
|
+
f1: (a: A) => Effect<B, E1, Ctx>,
|
|
101
|
+
f2: (b: B) => Effect<C, E2, Ctx>,
|
|
102
|
+
f3: (c: C) => Effect<D, E3, Ctx>,
|
|
103
|
+
f4: (d: D) => Effect<F, E4, Ctx>
|
|
104
|
+
): (start: A) => Effect<F, E1 | E2 | E3 | E4, Ctx>;
|
|
105
|
+
|
|
106
|
+
export declare function effectPipe<
|
|
107
|
+
A,
|
|
108
|
+
B,
|
|
109
|
+
C,
|
|
110
|
+
D,
|
|
111
|
+
F,
|
|
112
|
+
G,
|
|
113
|
+
E1 = unknown,
|
|
114
|
+
E2 = unknown,
|
|
115
|
+
E3 = unknown,
|
|
116
|
+
E4 = unknown,
|
|
117
|
+
E5 = unknown,
|
|
118
|
+
Ctx = unknown
|
|
119
|
+
>(
|
|
120
|
+
f1: (a: A) => Effect<B, E1, Ctx>,
|
|
121
|
+
f2: (b: B) => Effect<C, E2, Ctx>,
|
|
122
|
+
f3: (c: C) => Effect<D, E3, Ctx>,
|
|
123
|
+
f4: (d: D) => Effect<F, E4, Ctx>,
|
|
124
|
+
f5: (f: F) => Effect<G, E5, Ctx>
|
|
125
|
+
): (start: A) => Effect<G, E1 | E2 | E3 | E4 | E5, Ctx>;
|
|
126
|
+
|
|
127
|
+
export declare function effectPipe<
|
|
128
|
+
A,
|
|
129
|
+
B,
|
|
130
|
+
C,
|
|
131
|
+
D,
|
|
132
|
+
F,
|
|
133
|
+
G,
|
|
134
|
+
H,
|
|
135
|
+
E1 = unknown,
|
|
136
|
+
E2 = unknown,
|
|
137
|
+
E3 = unknown,
|
|
138
|
+
E4 = unknown,
|
|
139
|
+
E5 = unknown,
|
|
140
|
+
E6 = unknown,
|
|
141
|
+
Ctx = unknown
|
|
142
|
+
>(
|
|
143
|
+
f1: (a: A) => Effect<B, E1, Ctx>,
|
|
144
|
+
f2: (b: B) => Effect<C, E2, Ctx>,
|
|
145
|
+
f3: (c: C) => Effect<D, E3, Ctx>,
|
|
146
|
+
f4: (d: D) => Effect<F, E4, Ctx>,
|
|
147
|
+
f5: (f: F) => Effect<G, E5, Ctx>,
|
|
148
|
+
f6: (g: G) => Effect<H, E6, Ctx>
|
|
149
|
+
): (start: A) => Effect<H, E1 | E2 | E3 | E4 | E5 | E6, Ctx>;
|
|
150
|
+
|
|
151
|
+
export declare function effectPipe<
|
|
152
|
+
A,
|
|
153
|
+
B,
|
|
154
|
+
C,
|
|
155
|
+
D,
|
|
156
|
+
F,
|
|
157
|
+
G,
|
|
158
|
+
H,
|
|
159
|
+
I,
|
|
160
|
+
E1 = unknown,
|
|
161
|
+
E2 = unknown,
|
|
162
|
+
E3 = unknown,
|
|
163
|
+
E4 = unknown,
|
|
164
|
+
E5 = unknown,
|
|
165
|
+
E6 = unknown,
|
|
166
|
+
E7 = unknown,
|
|
167
|
+
Ctx = unknown
|
|
168
|
+
>(
|
|
169
|
+
f1: (a: A) => Effect<B, E1, Ctx>,
|
|
170
|
+
f2: (b: B) => Effect<C, E2, Ctx>,
|
|
171
|
+
f3: (c: C) => Effect<D, E3, Ctx>,
|
|
172
|
+
f4: (d: D) => Effect<F, E4, Ctx>,
|
|
173
|
+
f5: (f: F) => Effect<G, E5, Ctx>,
|
|
174
|
+
f6: (g: G) => Effect<H, E6, Ctx>,
|
|
175
|
+
f7: (h: H) => Effect<I, E7, Ctx>
|
|
176
|
+
): (start: A) => Effect<I, E1 | E2 | E3 | E4 | E5 | E6 | E7, Ctx>;
|
|
177
|
+
|
|
178
|
+
export declare function effectPipe<
|
|
179
|
+
A,
|
|
180
|
+
B,
|
|
181
|
+
C,
|
|
182
|
+
D,
|
|
183
|
+
F,
|
|
184
|
+
G,
|
|
185
|
+
H,
|
|
186
|
+
I,
|
|
187
|
+
J,
|
|
188
|
+
E1 = unknown,
|
|
189
|
+
E2 = unknown,
|
|
190
|
+
E3 = unknown,
|
|
191
|
+
E4 = unknown,
|
|
192
|
+
E5 = unknown,
|
|
193
|
+
E6 = unknown,
|
|
194
|
+
E7 = unknown,
|
|
195
|
+
E8 = unknown,
|
|
196
|
+
Ctx = unknown
|
|
197
|
+
>(
|
|
198
|
+
f1: (a: A) => Effect<B, E1, Ctx>,
|
|
199
|
+
f2: (b: B) => Effect<C, E2, Ctx>,
|
|
200
|
+
f3: (c: C) => Effect<D, E3, Ctx>,
|
|
201
|
+
f4: (d: D) => Effect<F, E4, Ctx>,
|
|
202
|
+
f5: (f: F) => Effect<G, E5, Ctx>,
|
|
203
|
+
f6: (g: G) => Effect<H, E6, Ctx>,
|
|
204
|
+
f7: (h: H) => Effect<I, E7, Ctx>,
|
|
205
|
+
f8: (i: I) => Effect<J, E8, Ctx>
|
|
206
|
+
): (start: A) => Effect<J, E1 | E2 | E3 | E4 | E5 | E6 | E7 | E8, Ctx>;
|
|
103
207
|
|
|
104
208
|
export type StepRunner = (name: string, type: string, op: () => Promise<unknown>) => Promise<unknown>;
|
|
105
209
|
|
|
@@ -115,6 +219,13 @@ export interface EffectConfiguration {
|
|
|
115
219
|
onStep?: StepRunner;
|
|
116
220
|
onRun?: RunWrapper;
|
|
117
221
|
onBeforeCommand?: CommandInterceptor;
|
|
222
|
+
retry?: RetryOptions;
|
|
118
223
|
}
|
|
119
224
|
|
|
120
225
|
export declare function configureEffect(options: EffectConfiguration): void;
|
|
226
|
+
|
|
227
|
+
export declare function runEffect<T, E = unknown, Ctx = unknown>(
|
|
228
|
+
effect: Effect<T, E, Ctx>,
|
|
229
|
+
context?: Ctx,
|
|
230
|
+
callConfig?: EffectConfiguration
|
|
231
|
+
): Promise<SuccessState<T> | FailureState<E>>;
|
package/index.js
CHANGED
|
@@ -19,9 +19,19 @@
|
|
|
19
19
|
* }} AskState
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* @typedef {{
|
|
24
|
+
* type: 'Retry',
|
|
25
|
+
* effect: Effect,
|
|
26
|
+
* options: { attempts?: number, delay?: number, backoff?: number },
|
|
27
|
+
* next: (value: any) => Effect,
|
|
28
|
+
* initialInput?: any
|
|
29
|
+
* }} RetryState
|
|
30
|
+
*/
|
|
31
|
+
|
|
22
32
|
/**
|
|
23
33
|
* The Union type for all possible states
|
|
24
|
-
* @typedef {SuccessState | FailureState | CommandState | AskState} Effect
|
|
34
|
+
* @typedef {SuccessState | FailureState | CommandState | AskState | RetryState} Effect
|
|
25
35
|
*/
|
|
26
36
|
|
|
27
37
|
/**
|
|
@@ -59,27 +69,56 @@ const Command = (cmd, next, meta) => ({ type: 'Command', cmd, next, meta });
|
|
|
59
69
|
*/
|
|
60
70
|
const Ask = (next) => ({ type: 'Ask', next });
|
|
61
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Wraps an Effect tree with retry-on-failure semantics.
|
|
74
|
+
* @param {Effect} effect - The inner Effect tree to retry
|
|
75
|
+
* @param {Object} [options] - Per-use retry options; merged over global defaults at runtime
|
|
76
|
+
* @param {number} [options.attempts] - Max retries (not counting first try)
|
|
77
|
+
* @param {number} [options.delay] - Ms before first retry
|
|
78
|
+
* @param {number} [options.backoff] - Multiplier applied to delay on each subsequent retry
|
|
79
|
+
* @returns {RetryState}
|
|
80
|
+
*/
|
|
81
|
+
const Retry = (effect, options = {}) => ({
|
|
82
|
+
type: 'Retry',
|
|
83
|
+
effect,
|
|
84
|
+
options,
|
|
85
|
+
next: (value) => Success(value)
|
|
86
|
+
});
|
|
87
|
+
|
|
62
88
|
/**
|
|
63
89
|
* Connects an Effect to the next function in the pipeline.
|
|
64
|
-
* Handles the branching logic for Success, Failure, Command, and
|
|
90
|
+
* Handles the branching logic for Success, Failure, Command, Ask, and Retry.
|
|
65
91
|
*
|
|
66
92
|
* @param {Effect} effect - The current Effect object
|
|
67
93
|
* @param {(value: any) => Effect} fn - The next function to run if the current effect is a Success
|
|
68
94
|
* @returns {Effect} The composed Effect
|
|
69
95
|
*/
|
|
70
|
-
|
|
96
|
+
/**
|
|
97
|
+
* @param {Effect} effect
|
|
98
|
+
* @param {(value: any) => Effect} fn
|
|
99
|
+
* @param {any} [initialInput]
|
|
100
|
+
* @returns {Effect}
|
|
101
|
+
*/
|
|
102
|
+
const chain = (effect, fn, initialInput) => {
|
|
103
|
+
const withII = (/** @type {Effect} */ e) =>
|
|
104
|
+
initialInput !== undefined && e.initialInput === undefined ? { ...e, initialInput } : e;
|
|
105
|
+
|
|
71
106
|
switch (effect.type) {
|
|
72
107
|
case 'Success':
|
|
73
|
-
return fn(effect.value);
|
|
108
|
+
return withII(fn(effect.value));
|
|
74
109
|
case 'Failure':
|
|
75
|
-
return effect;
|
|
110
|
+
return withII(effect);
|
|
76
111
|
case 'Command': {
|
|
77
|
-
const next = (/** @type {any} */ result) => chain(effect.next(result), fn);
|
|
78
|
-
return Command(effect.cmd, next, effect.meta);
|
|
112
|
+
const next = (/** @type {any} */ result) => chain(effect.next(result), fn, initialInput);
|
|
113
|
+
return withII(Command(effect.cmd, next, effect.meta));
|
|
79
114
|
}
|
|
80
115
|
case 'Ask': {
|
|
81
|
-
const next = (/** @type {any} */ ctx) => chain(effect.next(ctx), fn);
|
|
82
|
-
return Ask(next);
|
|
116
|
+
const next = (/** @type {any} */ ctx) => chain(effect.next(ctx), fn, initialInput);
|
|
117
|
+
return withII(Ask(next));
|
|
118
|
+
}
|
|
119
|
+
case 'Retry': {
|
|
120
|
+
const next = (/** @type {any} */ result) => chain(effect.next(result), fn, initialInput);
|
|
121
|
+
return withII({ ...effect, next });
|
|
83
122
|
}
|
|
84
123
|
}
|
|
85
124
|
};
|
|
@@ -93,9 +132,8 @@ const chain = (effect, fn) => {
|
|
|
93
132
|
*/
|
|
94
133
|
const effectPipe = (...fns) => {
|
|
95
134
|
return (start) => {
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
return effect;
|
|
135
|
+
const chainWithII = (/** @type {Effect} */ eff, /** @type {(v: any) => Effect} */ fn) => chain(eff, fn, start);
|
|
136
|
+
return fns.reduce(chainWithII, /** @type {Effect} */ (Success(start)));
|
|
99
137
|
};
|
|
100
138
|
};
|
|
101
139
|
|
|
@@ -115,11 +153,15 @@ let stepRunner = defaultStepRunner;
|
|
|
115
153
|
let runWrapper = defaultRunWrapper;
|
|
116
154
|
let commandInterceptor = defaultCommandInterceptor;
|
|
117
155
|
|
|
156
|
+
const defaultRetryOptions = { attempts: 3, delay: 100, backoff: 1 };
|
|
157
|
+
let retryDefaults = { ...defaultRetryOptions };
|
|
158
|
+
|
|
118
159
|
/**
|
|
119
160
|
* @typedef {Object} EffectConfiguration
|
|
120
161
|
* @property {StepRunner} [onStep] - Fires once per runEffect call. It wraps the entire workflow execution.
|
|
121
162
|
* @property {RunWrapper} [onRun] - Fires every time a Command is executed.
|
|
122
163
|
* @property {CommandInterceptor} [onBeforeCommand] - Intercepts a Command and any context passed to runEffect before execution.
|
|
164
|
+
* @property {{ attempts?: number, delay?: number, backoff?: number }} [retry] - Global Retry defaults; merged under per-use options.
|
|
123
165
|
*/
|
|
124
166
|
|
|
125
167
|
/**
|
|
@@ -131,6 +173,7 @@ const configureEffect = (options) => {
|
|
|
131
173
|
stepRunner = options.onStep ? options.onStep : defaultStepRunner;
|
|
132
174
|
runWrapper = options.onRun ? options.onRun : defaultRunWrapper;
|
|
133
175
|
commandInterceptor = options.onBeforeCommand ? options.onBeforeCommand : defaultCommandInterceptor;
|
|
176
|
+
retryDefaults = options.retry ? { ...defaultRetryOptions, ...options.retry } : defaultRetryOptions;
|
|
134
177
|
};
|
|
135
178
|
|
|
136
179
|
const runEffect =
|
|
@@ -139,34 +182,69 @@ const runEffect =
|
|
|
139
182
|
* Iterates through the Effect tree, executing Commands and handling async flow.
|
|
140
183
|
* Ask effects are resolved synchronously with the context object.
|
|
141
184
|
*
|
|
185
|
+
* Per-call config takes precedence over global configureEffect defaults.
|
|
186
|
+
* onRun fires exactly once per runEffect call — Retry attempts run inside that
|
|
187
|
+
* single span rather than spawning their own, keeping telemetry non-duplicated.
|
|
188
|
+
*
|
|
142
189
|
* @param {Effect} effect - The Effect tree returned by a pipeline
|
|
143
190
|
* @param {any} [context] - Optional context object. Passed to Ask continuations and the Command Interceptor.
|
|
191
|
+
* @param {EffectConfiguration} [callConfig] - Per-call overrides; merged over global configureEffect defaults.
|
|
144
192
|
* @returns {Promise<SuccessState | FailureState>}
|
|
145
193
|
*/
|
|
146
|
-
async function runEffect(effect, context = {}) {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
194
|
+
async function runEffect(effect, context = {}, callConfig = {}) {
|
|
195
|
+
const localStepRunner = callConfig.onStep ? callConfig.onStep : stepRunner;
|
|
196
|
+
const localRunWrapper = callConfig.onRun ? callConfig.onRun : runWrapper;
|
|
197
|
+
const localCommandInterceptor = callConfig.onBeforeCommand ? callConfig.onBeforeCommand : commandInterceptor;
|
|
198
|
+
const localRetryDefaults = callConfig.retry ? { ...retryDefaults, ...callConfig.retry } : retryDefaults;
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* @param {Effect} eff
|
|
202
|
+
* @returns {Promise<SuccessState | FailureState>}
|
|
203
|
+
*/
|
|
204
|
+
async function execute(eff) {
|
|
205
|
+
while (eff.type === 'Command' || eff.type === 'Ask' || eff.type === 'Retry') {
|
|
206
|
+
if (eff.type === 'Ask') {
|
|
207
|
+
eff = eff.next(context);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (eff.type === 'Retry') {
|
|
211
|
+
const opts = { ...localRetryDefaults, ...eff.options };
|
|
212
|
+
const { attempts } = opts;
|
|
213
|
+
let lastError;
|
|
214
|
+
let succeeded = false;
|
|
215
|
+
|
|
216
|
+
for (let attempt = 0; attempt <= attempts; attempt++) {
|
|
217
|
+
if (attempt > 0) {
|
|
218
|
+
await new Promise((r) => setTimeout(r, opts.delay * Math.pow(opts.backoff, attempt - 1)));
|
|
219
|
+
}
|
|
220
|
+
const result = await execute(eff.effect);
|
|
221
|
+
if (result.type === 'Success') {
|
|
222
|
+
eff = eff.next(result.value);
|
|
223
|
+
succeeded = true;
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
lastError = result.error;
|
|
154
227
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
await commandInterceptor(effect, context);
|
|
159
|
-
const result = await stepRunner(cmdName, 'Command', effect.cmd);
|
|
160
|
-
effect = effect.next(result);
|
|
161
|
-
} catch (e) {
|
|
162
|
-
return Failure(e, initialInput);
|
|
228
|
+
|
|
229
|
+
if (!succeeded) {
|
|
230
|
+
return Failure({ retryExhausted: true, lastError, attempts }, eff.initialInput);
|
|
163
231
|
}
|
|
232
|
+
continue;
|
|
164
233
|
}
|
|
234
|
+
const cmdName = eff.cmd.name || 'anonymous';
|
|
235
|
+
const initialInput = eff.initialInput;
|
|
236
|
+
try {
|
|
237
|
+
await localCommandInterceptor(eff, context);
|
|
238
|
+
const result = await localStepRunner(cmdName, 'Command', eff.cmd);
|
|
239
|
+
eff = eff.next(result);
|
|
240
|
+
} catch (e) {
|
|
241
|
+
return Failure(e, initialInput);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return eff;
|
|
245
|
+
}
|
|
165
246
|
|
|
166
|
-
|
|
167
|
-
},
|
|
168
|
-
context?.flowName || ''
|
|
169
|
-
);
|
|
247
|
+
return localRunWrapper(effect, () => execute(effect), context?.flowName || '');
|
|
170
248
|
};
|
|
171
249
|
|
|
172
|
-
export { Success, Failure, Command, Ask, effectPipe, runEffect, configureEffect };
|
|
250
|
+
export { Success, Failure, Command, Ask, Retry, effectPipe, runEffect, configureEffect };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pure-effect",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "A
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"description": "A TypeScript effect library where effects are plain objects you can inspect, test, and reason about. Learn the whole API in an afternoon. Tiny footprint, zero dependencies, works with plain JavaScript too.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": "./index.js",
|
|
7
7
|
"types": "./index.d.ts",
|
package/test/all.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
3
|
import { strict as assert } from 'assert';
|
|
4
|
-
import { Success, Failure, Command, Ask, effectPipe, runEffect, configureEffect } from '../index.js';
|
|
4
|
+
import { Success, Failure, Command, Ask, Retry, effectPipe, runEffect, configureEffect } from '../index.js';
|
|
5
5
|
import { enableTelemetry } from '../opentelemetry-example.js';
|
|
6
6
|
|
|
7
7
|
/** @import { CommandInterceptor } from "../index.js" */
|
|
@@ -81,26 +81,15 @@ describe('Pure Effect', function () {
|
|
|
81
81
|
});
|
|
82
82
|
|
|
83
83
|
it('should access context through onBeforeCommand', async function () {
|
|
84
|
-
configureEffect({
|
|
85
|
-
onBeforeCommand: /** @type CommandInterceptor */ async (command, context) =>
|
|
86
|
-
assert.equal(context.env, 'test')
|
|
87
|
-
});
|
|
88
84
|
const input = { email: 'context@test.com', password: 'password123' };
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
it('should return Success after runEffect with telemetry enabled', async function () {
|
|
100
|
-
enableTelemetry();
|
|
101
|
-
const input = { email: 'test-telemetry@test.com', password: 'password123' };
|
|
102
|
-
const result = await registerUser(input);
|
|
103
|
-
assert.equal(result.type, 'Success');
|
|
85
|
+
await runEffect(
|
|
86
|
+
registerUserFlow(input),
|
|
87
|
+
{ env: 'test' },
|
|
88
|
+
{
|
|
89
|
+
onBeforeCommand: /** @type CommandInterceptor */ async (command, context) =>
|
|
90
|
+
assert.equal(context.env, 'test')
|
|
91
|
+
}
|
|
92
|
+
);
|
|
104
93
|
});
|
|
105
94
|
|
|
106
95
|
it('should access context through Ask', async function () {
|
|
@@ -128,4 +117,126 @@ describe('Pure Effect', function () {
|
|
|
128
117
|
assert.equal(result.type, 'Success');
|
|
129
118
|
assert.deepEqual(result.value, { value: 'value', env: 'test' });
|
|
130
119
|
});
|
|
120
|
+
|
|
121
|
+
it('should return a Retry data structure', function () {
|
|
122
|
+
const inner = Command(
|
|
123
|
+
() => 'x',
|
|
124
|
+
(r) => Success(r)
|
|
125
|
+
);
|
|
126
|
+
const effect = Retry(inner, { attempts: 5 });
|
|
127
|
+
assert.equal(effect.type, 'Retry');
|
|
128
|
+
assert.deepEqual(effect.options, { attempts: 5 });
|
|
129
|
+
assert.strictEqual(effect.effect, inner);
|
|
130
|
+
assert.equal(typeof effect.next, 'function');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('should succeed after transient failures', async function () {
|
|
134
|
+
let calls = 0;
|
|
135
|
+
const effect = Retry(
|
|
136
|
+
Command(
|
|
137
|
+
function flakyCmd() {
|
|
138
|
+
if (++calls < 3) throw new Error('transient');
|
|
139
|
+
return 'ok';
|
|
140
|
+
},
|
|
141
|
+
(r) => Success(r)
|
|
142
|
+
),
|
|
143
|
+
{ attempts: 3, delay: 0 }
|
|
144
|
+
);
|
|
145
|
+
const result = await runEffect(effect);
|
|
146
|
+
assert.equal(result.type, 'Success');
|
|
147
|
+
assert.equal(result.value, 'ok');
|
|
148
|
+
assert.equal(calls, 3);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('should return rich Failure when retries are exhausted', async function () {
|
|
152
|
+
const effect = Retry(
|
|
153
|
+
Command(
|
|
154
|
+
function alwaysFails() {
|
|
155
|
+
throw new Error('boom');
|
|
156
|
+
return /** @type {any} */ (null);
|
|
157
|
+
},
|
|
158
|
+
(/** @type {any} */ r) => Success(r)
|
|
159
|
+
),
|
|
160
|
+
{ attempts: 2, delay: 0 }
|
|
161
|
+
);
|
|
162
|
+
const result = await runEffect(effect);
|
|
163
|
+
assert.equal(result.type, 'Failure');
|
|
164
|
+
if (result.type !== 'Failure') throw new Error('expected Failure');
|
|
165
|
+
const error = /** @type {import('../index.js').RetryExhaustedError<Error>} */ (result.error);
|
|
166
|
+
assert.equal(error.retryExhausted, true);
|
|
167
|
+
assert.equal(error.attempts, 2);
|
|
168
|
+
assert.equal(error.lastError.message, 'boom');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('should apply delay and backoff between retries', async function () {
|
|
172
|
+
this.timeout(2000);
|
|
173
|
+
let calls = 0;
|
|
174
|
+
const start = Date.now();
|
|
175
|
+
const effect = Retry(
|
|
176
|
+
Command(
|
|
177
|
+
function flakyCmd() {
|
|
178
|
+
if (++calls < 3) throw new Error('transient');
|
|
179
|
+
return 'ok';
|
|
180
|
+
},
|
|
181
|
+
(r) => Success(r)
|
|
182
|
+
),
|
|
183
|
+
{ attempts: 3, delay: 30, backoff: 1 }
|
|
184
|
+
);
|
|
185
|
+
const result = await runEffect(effect);
|
|
186
|
+
const elapsed = Date.now() - start;
|
|
187
|
+
assert.equal(result.type, 'Success');
|
|
188
|
+
// 2 retries × 30 ms = at least 55 ms (5 ms margin for timing variance)
|
|
189
|
+
assert.ok(elapsed >= 55, `Expected ≥ 55 ms elapsed, got ${elapsed} ms`);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('should merge per-use Retry options with call-level defaults', async function () {
|
|
193
|
+
// Call-level: attempts 1 (would exhaust on 2nd try)
|
|
194
|
+
// Per-use: attempts 3 (overrides call-level — should succeed on 3rd try)
|
|
195
|
+
let calls = 0;
|
|
196
|
+
const effect = Retry(
|
|
197
|
+
Command(
|
|
198
|
+
function flakyCmd() {
|
|
199
|
+
if (++calls < 3) throw new Error('x');
|
|
200
|
+
return 'ok';
|
|
201
|
+
},
|
|
202
|
+
(r) => Success(r)
|
|
203
|
+
),
|
|
204
|
+
{ attempts: 3 }
|
|
205
|
+
);
|
|
206
|
+
const result = await runEffect(effect, {}, { retry: { attempts: 1, delay: 0, backoff: 1 } });
|
|
207
|
+
assert.equal(result.type, 'Success');
|
|
208
|
+
assert.equal(calls, 3);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('should work at any step inside effectPipe', async function () {
|
|
212
|
+
const flow = effectPipe(
|
|
213
|
+
(input) =>
|
|
214
|
+
Retry(
|
|
215
|
+
Command(
|
|
216
|
+
function fetchCmd() {
|
|
217
|
+
return input.toUpperCase();
|
|
218
|
+
},
|
|
219
|
+
(r) => Success(r)
|
|
220
|
+
),
|
|
221
|
+
{ attempts: 2, delay: 0 }
|
|
222
|
+
),
|
|
223
|
+
(upper) => Success(`${upper}!`)
|
|
224
|
+
);
|
|
225
|
+
const result = await runEffect(flow('hello'));
|
|
226
|
+
assert.equal(result.type, 'Success');
|
|
227
|
+
assert.equal(result.value, 'HELLO!');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('should return Success after runEffect with telemetry disabled', async function () {
|
|
231
|
+
const input = { email: 'test-no-telemetry@test.com', password: 'password123' };
|
|
232
|
+
const result = await registerUser(input);
|
|
233
|
+
assert.equal(result.type, 'Success');
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('should return Success after runEffect with telemetry enabled', async function () {
|
|
237
|
+
enableTelemetry();
|
|
238
|
+
const input = { email: 'test-telemetry@test.com', password: 'password123' };
|
|
239
|
+
const result = await registerUser(input);
|
|
240
|
+
assert.equal(result.type, 'Success');
|
|
241
|
+
});
|
|
131
242
|
});
|
package/test/types.test-d.ts
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { expectType, expectError } from 'tsd';
|
|
2
|
-
import { Success, Failure, Command, Ask, effectPipe, runEffect } from '../index.js';
|
|
3
|
-
import type {
|
|
2
|
+
import { Success, Failure, Command, Ask, Retry, effectPipe, runEffect } from '../index.js';
|
|
3
|
+
import type {
|
|
4
|
+
SuccessState,
|
|
5
|
+
FailureState,
|
|
6
|
+
CommandState,
|
|
7
|
+
AskState,
|
|
8
|
+
RetryState,
|
|
9
|
+
RetryExhaustedError,
|
|
10
|
+
Effect
|
|
11
|
+
} from '../index.js';
|
|
4
12
|
|
|
5
13
|
interface User {
|
|
6
14
|
email: string;
|
|
@@ -72,3 +80,54 @@ expectType<AskState<User, unknown>>(ask);
|
|
|
72
80
|
|
|
73
81
|
const askFlow = effectPipe((input: User) => Ask((_ctx) => Success(input)));
|
|
74
82
|
expectType<Effect<User>>(askFlow({ email: 'a@b.com', password: 'secret123' }));
|
|
83
|
+
|
|
84
|
+
// --- Retry ---
|
|
85
|
+
|
|
86
|
+
const innerCmd = Command(
|
|
87
|
+
async () => 42,
|
|
88
|
+
(n) => Success(n)
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
// Retry with options preserves T
|
|
92
|
+
const retried = Retry(innerCmd, { attempts: 3 });
|
|
93
|
+
expectType<RetryState<number, unknown>>(retried);
|
|
94
|
+
|
|
95
|
+
// Retry without options is valid
|
|
96
|
+
const retriedNoOpts = Retry(innerCmd);
|
|
97
|
+
expectType<RetryState<number, unknown>>(retriedNoOpts);
|
|
98
|
+
|
|
99
|
+
// Retry in effectPipe preserves type flow
|
|
100
|
+
const retryFlow = effectPipe((input: User) =>
|
|
101
|
+
Retry(
|
|
102
|
+
Command(
|
|
103
|
+
async () => ({ id: 1, ...input }) as SavedUser,
|
|
104
|
+
(s) => Success(s)
|
|
105
|
+
),
|
|
106
|
+
{ attempts: 2 }
|
|
107
|
+
)
|
|
108
|
+
);
|
|
109
|
+
expectType<Effect<SavedUser>>(retryFlow({ email: 'a@b.com', password: 'secret123' }));
|
|
110
|
+
|
|
111
|
+
// RetryExhaustedError shape is usable for narrowing exhaustion failures
|
|
112
|
+
const exhaustedErr: RetryExhaustedError<Error> = {
|
|
113
|
+
retryExhausted: true,
|
|
114
|
+
lastError: new Error('boom'),
|
|
115
|
+
attempts: 3
|
|
116
|
+
};
|
|
117
|
+
expectType<true>(exhaustedErr.retryExhausted);
|
|
118
|
+
expectType<Error>(exhaustedErr.lastError);
|
|
119
|
+
expectType<number>(exhaustedErr.attempts);
|
|
120
|
+
|
|
121
|
+
// --- error channel union across effectPipe steps ---
|
|
122
|
+
|
|
123
|
+
type ValidationError = 'invalid_email' | 'weak_password';
|
|
124
|
+
type DbError = 'db_connection' | 'duplicate_key';
|
|
125
|
+
|
|
126
|
+
const validateStep = (_input: User): Effect<User, ValidationError> => Failure<ValidationError>('invalid_email');
|
|
127
|
+
const saveStep = (_user: User): Effect<SavedUser, DbError> => Failure<DbError>('db_connection');
|
|
128
|
+
|
|
129
|
+
const typedFlow = effectPipe(validateStep, saveStep);
|
|
130
|
+
expectType<Effect<SavedUser, ValidationError | DbError>>(typedFlow({ email: 'a@b.com', password: 'secret123' }));
|
|
131
|
+
|
|
132
|
+
const typedResult = await runEffect(typedFlow({ email: 'a@b.com', password: 'secret123' }));
|
|
133
|
+
expectType<SuccessState<SavedUser> | FailureState<ValidationError | DbError>>(typedResult);
|