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.
@@ -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 | 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
- | `effectPipe(...fns)` | | Composes functions into a sequential pipeline via `chain` |
27
- | `runEffect(effect, context)` | async | Interpreter: traverses the effect tree, executes Commands; resolves `Ask` with context |
28
- | `configureEffect(options)` | — | Injects telemetry hooks (`onStep`, `onRun`, `onBeforeCommand`) |
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` hooks:
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
- **Pure Effect** is a tiny, zero-dependency effect system for writing pure, testable JavaScript without mocks.
4
-
5
- It implements the "Functional Core, Imperative Shell" pattern, allowing you to decouple your business logic from external side effects like database calls or API requests. Instead of executing side effects immediately, your functions return Commands which are executed later by an interpreter.
6
-
7
- **Pure Effect** ships with JSDoc type annotations for JavaScript users and a bundled declaration file with full generic types for TypeScript users.
3
+ [![npm version](https://img.shields.io/npm/v/pure-effect)](https://www.npmjs.com/package/pure-effect)
4
+ [![bundle size](https://img.shields.io/badge/minified%2Bgzipped-%3C1KB-brightgreen)](https://bundlephobia.com/package/pure-effect)
5
+ [![license](https://img.shields.io/npm/l/pure-effect)](./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
- ## Usage
35
+ ## Quick Start
16
36
 
17
- Here is a complete example of a User Registration flow.
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 do NOT run the DB call. They return a Command object.
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); // The work to do later
32
- const next = (user) => Success(user); // Wrap result in Success
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
- const next = (saved) => Success(saved);
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
- // The Pipeline uses arrow functions to capture 'input' from the scope where needed.
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
- // The Imperative Shell
72
+ // Imperative shell: this is the only place side effects run
56
73
  async function registerUser(input) {
57
- // logic is just a data structure until we pass it to runEffect
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
- The biggest benefit of **Pure Effect** is testability. Because `registerUserFlow` returns a data structure (a tree of objects) instead of running a Promise, you can test your logic without mocking the database.
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 Validation Failure
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 format.', badInput));
81
- // ✅ Logic tested instantly, no async needed.
93
+ assert.deepEqual(result, Failure('Invalid email.', badInput));
82
94
 
83
- // 2. Test Flow Intent (Introspection)
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
- // Check if the first thing the code does is try to find a user
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
- // Check if the next thing the code will do is to save a user
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
- // We verified the *intent* of the code without touching a real DB.
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 the authenticated tenant, a request trace ID, environment config rather than from the data being processed. `Ask` lets a pipeline step read the `context` object passed to `runEffect` without touching the function signatures around it.
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 identified from the subdomain or JWT by the router. The domain layer never needs it as a parameter; it just asks for it when needed:
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
- Command(
110
- () => db[ctx.tenant].findProduct(productId),
111
- (product) => (product ? Success(product) : Failure('Product not found.'))
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
- Command(
118
- () => db[ctx.tenant].reserveStock(product.id),
119
- (reserved) => Success({ product, reserved })
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
- The router identifies the tenant and passes it as context. `checkoutFlow` never needs a tenant parameter:
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.subdomains[0] // e.g. 'acme' from acme.myapp.com
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 an object `{ type: 'Success', value }`. Represents a successful computation.
297
+ Returns `{ type: 'Success', value }`. Represents a successful computation result.
146
298
 
147
- ### `Failure(error)`
299
+ ### `Failure(error, initialInput?)`
148
300
 
149
- Returns an object `{ type: 'Failure', error, initialInput }`. Represents a failed computation. Stops the pipeline immediately.
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 an object `{ type: 'Command', cmd, next, meta }`.
305
+ Returns `{ type: 'Command', cmd, next, meta }`.
154
306
 
155
307
  - `cmdFn`: A function (sync or async) that performs the side effect.
156
- - `nextFn`: A function that receives the result of `cmdFn` and returns the next Effect (Success, Failure, or another Command).
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 an object `{ type: 'Ask', next }`. Reads the `context` passed to `runEffect` and passes it to `nextFn`, which returns the next Effect. Works at any point in the pipeline, before or after `Command`s.
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
- Command(
167
- () => db[ctx.tenant].findProduct(productId),
168
- (product) => (product ? Success(product) : Failure('Product not found.'))
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
- See [Passing Runtime Context](#passing-runtime-context) for a full example.
323
+ ### `Retry(effect, options?)`
174
324
 
175
- ### `effectPipe(...functions)`
325
+ Returns `{ type: 'Retry', effect, options, next }`. Wraps any Effect with retry-on-failure semantics.
176
326
 
177
- A combinator that runs functions in sequence. It automatically handles unpacking `Success` values and passing them to the next function. If a `Failure` occurs, the pipe stops.
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
- ### `runEffect(effect, context = {})`
332
+ On exhaustion, returns `Failure({ retryExhausted: true, lastError, attempts })`.
180
333
 
181
- The interpreter. It takes an `effect` object, executes any nested Commands using `async/await`, and returns the final `Success` or `Failure`. The optional `context` object is available to:
334
+ ### `Parallel(effects, next)`
182
335
 
183
- - `Ask` continuations use `Ask` to read context inside pipeline steps
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
- `context.flowName` may be used for naming workflows in telemetry.
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
- ### `configureEffect(options)`
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
- A configuration function that injects observability, tracing, or logging interceptors into the `runEffect` interpreter. By default, **Pure Effect** executes with zero overhead. By providing `onRun` and `onStep` callbacks, you can wrap pipeline executions and individual commands (e.g., inside OpenTelemetry spans). Please see **opentelemetry-example.js** for a quick example.
345
+ When a step needs to use a captured variable instead of the piped value, wrap it in an arrow function:
193
346
 
194
- `configureEffect` also accepts `onBeforeCommand`, which can be used to intercept each `Command` and the context passed to `runEffect` before execution.
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
- - `onRun (effect, pipeline, flowName)`
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`.
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
- - `onStep (name, type, op)`
203
- Fires every time a `Command` is executed.
204
- - `name`: The name of the command function (e.g., `cmdFindUser`).
205
- - `type`: Effect type.
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
- - `onBeforeCommand (command, context)`
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.