pure-effect 0.5.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.
@@ -0,0 +1,12 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(npx mocha *)",
5
+ "Bash(npm test *)",
6
+ "Bash(git *)",
7
+ "Bash(node *)",
8
+ "Bash(npx tsc *)",
9
+ "Bash(npx tsd *)"
10
+ ]
11
+ }
12
+ }
Binary file
package/.prettierrc ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "tabWidth": 4,
3
+ "useTabs": false,
4
+ "singleQuote": true,
5
+ "semi": true,
6
+ "trailingComma": "none",
7
+ "jsxSingleQuote": true,
8
+ "printWidth": 120,
9
+ "arrowParens": "always",
10
+ "endOfLine": "lf"
11
+ }
package/CLAUDE.md CHANGED
@@ -17,43 +17,56 @@ 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
- | `effectPipe(...fns)` | | Composes functions into a sequential pipeline via `chain` |
26
- | `runEffect(effect, context)` | async | Interpreter: traverses the effect tree, executes Commands |
27
- | `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
+ | `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` |
28
30
 
29
31
  ### Data flow
30
32
 
31
33
  ```
32
34
  effectPipe(f1, f2, f3)(input)
33
- → returns tree of Success / Failure / Command values
34
-
35
- runEffect(tree, context)
36
- → executes Commands async, passes results into next(), repeats
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
38
+
39
+ runEffect(tree, context, callConfig?)
40
+ → per-call callConfig merges over global configureEffect defaults
41
+ → executes Commands async, passes results into next(result), repeats
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 })
37
46
  → resolves to final Success or Failure
38
47
  ```
39
48
 
40
- The `chain` combinator (internal) drives composition: `Success` passes its value to the next function, `Failure` short-circuits, `Command` defers execution. `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.
50
+
51
+ `configureEffect` options (all overridable per-call via `runEffect`'s third argument):
41
52
 
42
- `configureEffect` hooks:
43
53
  - `onStep` — fires on every Command execution; wraps the `cmd` call (use for per-command tracing)
44
- - `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
45
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)
46
57
 
47
58
  ### TypeScript
48
59
 
49
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`.
50
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
+
51
64
  ### Observability
52
65
 
53
66
  `opentelemetry-example.js` shows how to wire OpenTelemetry spans into `configureEffect`'s hooks — it is reference code, not part of the library.
54
67
 
55
68
  ### Tests
56
69
 
57
- `test/all.js` contains all runtime tests and uses a user-registration domain as the running example. Tests assert on the *returned data structures* (Commands, Failures) rather than on side effects, which is the core usage pattern to preserve.
70
+ `test/all.js` contains all runtime tests and uses a user-registration domain as the running example. Tests assert on the _returned data structures_ (Commands, Failures) rather than on side effects, which is the core usage pattern to preserve.
58
71
 
59
72
  `test/types.test-d.ts` contains type-level tests using `tsd`, verifying that generic type parameters flow correctly through `effectPipe` and `runEffect`.
package/README.md CHANGED
@@ -1,10 +1,29 @@
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 `index.d.ts` 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 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
- ## Usage
34
+ ## Quick Start
16
35
 
17
- Here is a complete example of a User Registration flow.
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
- // 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.
47
+ // Theese function return a Command object. They do NOT call the database.
30
48
  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);
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
- const next = (saved) => Success(saved);
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
- // The Pipeline uses arrow functions to capture 'input' from the scope where needed.
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
- // The Imperative Shell
71
+ // Imperative shell: this is the only place side effects run
56
72
  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);
73
+ const result = await runEffect(registerUserFlow(input));
62
74
 
63
75
  if (result.type === 'Success') {
64
76
  console.log('User created:', result.value);
@@ -70,80 +82,238 @@ async function registerUser(input) {
70
82
 
71
83
  ## Testing Without Mocks
72
84
 
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.
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 Validation Failure
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 format.', badInput));
81
- // ✅ Logic tested instantly, no async needed.
92
+ assert.deepEqual(result, Failure('Invalid email.', badInput));
82
93
 
83
- // 2. Test Flow Intent (Introspection)
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
- // Check if the first thing the code does is try to find a user
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
- // Check if the next thing the code will do is to save a user
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
- // We verified the *intent* of the code without touching a real DB.
106
+ // The full flow is verified. The database was never touched.
107
+ ```
108
+
109
+ ## Passing Runtime Context
110
+
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.
112
+
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:
114
+
115
+ ```js
116
+ import { Success, Failure, Command, Ask, effectPipe, runEffect } from 'pure-effect';
117
+
118
+ const findProduct = (productId) =>
119
+ Ask((ctx) => {
120
+ const cmdFindProduct = () => db[ctx.tenant].findProduct(productId);
121
+ return Command(cmdFindProduct, (product) => (product ? Success(product) : Failure('Product not found.')));
122
+ });
123
+
124
+ const reserveStock = (product) =>
125
+ Ask((ctx) => {
126
+ const cmdReserveStock = () => db[ctx.tenant].reserveStock(product.id);
127
+ return Command(cmdReserveStock, (reserved) => Success({ product, reserved }));
128
+ });
129
+
130
+ const checkoutFlow = (productId) => effectPipe(findProduct, ({ product }) => reserveStock(product))(productId);
131
+
132
+ app.post('/checkout', async (req, res) => {
133
+ const result = await runEffect(checkoutFlow(req.body.productId), {
134
+ tenant: req.tenant
135
+ });
136
+ res.json(result);
137
+ });
138
+ ```
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');
96
161
  ```
97
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
+
98
247
  ## API Reference
99
248
 
100
249
  ### `Success(value)`
101
250
 
102
- Returns an object `{ type: 'Success', value }`. Represents a successful computation.
251
+ Returns `{ type: 'Success', value }`. Represents a successful computation result.
103
252
 
104
- ### `Failure(error)`
253
+ ### `Failure(error, initialInput?)`
105
254
 
106
- Returns an object `{ type: 'Failure', error, initialInput }`. Represents a failed computation. Stops the pipeline immediately.
255
+ Returns `{ type: 'Failure', error, initialInput }`. Stops the pipeline immediately and short-circuits any remaining steps.
107
256
 
108
- ### `Command(cmdFn, nextFn, meta)`
257
+ ### `Command(cmdFn, nextFn, meta?)`
109
258
 
110
- Returns an object `{ type: 'Command', cmd, next, meta }`.
259
+ Returns `{ type: 'Command', cmd, next, meta }`.
111
260
 
112
- - `cmdFn`: A function (sync or async) that performs the side effect.
113
- - `nextFn`: A function that receives the result of `cmdFn` and returns the next Effect (Success, Failure, or another Command).
114
- - `meta`: Optional metadata.
261
+ - `cmdFn`: A function (sync or async) that performs the side effect.
262
+ - `nextFn`: Receives the result of `cmdFn` and returns the next Effect.
263
+ - `meta`: Optional metadata (available to `onBeforeCommand`).
115
264
 
116
- ### `effectPipe(...functions)`
265
+ ### `Ask(nextFn)`
266
+
267
+ Returns `{ type: 'Ask', next }`. Passes the `context` from `runEffect` into `nextFn`, which returns the next Effect. Works at any point in a pipeline.
117
268
 
118
- 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.
269
+ ```js
270
+ const findProduct = (productId) =>
271
+ Ask((ctx) => {
272
+ const cmdFindProduct = () => db[ctx.tenant].findProduct(productId);
273
+ return Command(cmdFindProduct, (product) => (product ? Success(product) : Failure('Product not found.')));
274
+ });
275
+ ```
119
276
 
120
- ### `runEffect(effect, context = {})`
277
+ ### `Retry(effect, options?)`
121
278
 
122
- The interpreter. It takes an `effect` object, executes any nested Commands recursively using `async/await`, and returns the final `Success` or `Failure`. The optional `context` object is _only_ passed to the command interceptor configured via the `onBeforeCommand` option in `configureEffect` (see below). Additionally, `context.flowName` may be used for naming workflows in telemetry.
279
+ Returns `{ type: 'Retry', effect, options, next }`. Wraps any Effect with retry-on-failure semantics.
123
280
 
124
- ---
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).
125
285
 
126
- ### `configureEffect(options)`
286
+ On exhaustion, returns `Failure({ retryExhausted: true, lastError, attempts })`.
287
+
288
+ ### `effectPipe(...functions)`
289
+
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.
127
291
 
128
- 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.
292
+ When a step needs to use a captured variable instead of the piped value, wrap it in an arrow function:
293
+
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
+ ```
129
303
 
130
- `configureEffect` also accepts `onBeforeCommand`, which can be used to intercept each `Command` and the context passed to `runEffect` before execution.
304
+ ### `runEffect(effect, context?, callConfig?)`
131
305
 
132
- - `onRun (effect, pipeline, flowName)`
133
- Fires once per `runEffect` call. It wraps the entire workflow execution.
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`.
134
307
 
135
- - `effect`: The initial state of the effect tree (useful for extracting `initialInput`).
136
- - `pipeline`: The actual interpreter. You must `await pipeline()` inside this callback to run the logic.
137
- - `flowName`: The optional name of the workflow passed to `runEffect`.
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.
138
311
 
139
- - `onStep (name, type, op)`
140
- Fires every time a `Command` is executed.
312
+ ### `configureEffect(options)`
141
313
 
142
- - `name`: The name of the command function (e.g., `cmdFindUser`).
143
- - `type`: Effect type.
144
- - `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.
145
318
 
146
- - `onBeforeCommand (command, context)`
147
- Fires before a `Command` is executed. Ideal for inspecting metadata and context. If you throw, the pipeline stops immediately.
148
- - `command`: The `Command` object.
149
- - `context`: The context object passed to `runEffect`, if any.
319
+ See **opentelemetry-example.js** in the repository for a complete OpenTelemetry wiring example.