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.
@@ -5,7 +5,8 @@
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 *)"
9
10
  ]
10
11
  }
11
12
  }
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 | 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
+ | `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` hooks:
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
- **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 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,142 +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.
96
107
  ```
97
108
 
98
109
  ## Passing Runtime Context
99
110
 
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.
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 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:
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
- Command(
110
- () => db[ctx.tenant].findProduct(productId),
111
- (product) => (product ? Success(product) : Failure('Product not found.'))
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
- 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
- ```
125
+ Ask((ctx) => {
126
+ const cmdReserveStock = () => db[ctx.tenant].reserveStock(product.id);
127
+ return Command(cmdReserveStock, (reserved) => Success({ product, reserved }));
128
+ });
129
129
 
130
- The router identifies the tenant and passes it as context. `checkoutFlow` never needs a tenant parameter:
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.subdomains[0] // e.g. 'acme' from acme.myapp.com
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 an object `{ type: 'Success', value }`. Represents a successful computation.
251
+ Returns `{ type: 'Success', value }`. Represents a successful computation result.
146
252
 
147
- ### `Failure(error)`
253
+ ### `Failure(error, initialInput?)`
148
254
 
149
- 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.
150
256
 
151
- ### `Command(cmdFn, nextFn, meta)`
257
+ ### `Command(cmdFn, nextFn, meta?)`
152
258
 
153
- Returns an object `{ type: 'Command', cmd, next, meta }`.
259
+ Returns `{ type: 'Command', cmd, next, meta }`.
154
260
 
155
261
  - `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.
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 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.
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
- Command(
167
- () => db[ctx.tenant].findProduct(productId),
168
- (product) => (product ? Success(product) : Failure('Product not found.'))
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
- See [Passing Runtime Context](#passing-runtime-context) for a full example.
277
+ ### `Retry(effect, options?)`
174
278
 
175
- ### `effectPipe(...functions)`
279
+ Returns `{ type: 'Retry', effect, options, next }`. Wraps any Effect with retry-on-failure semantics.
176
280
 
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.
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
- ### `runEffect(effect, context = {})`
286
+ On exhaustion, returns `Failure({ retryExhausted: true, lastError, attempts })`.
180
287
 
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:
288
+ ### `effectPipe(...functions)`
182
289
 
183
- - `Ask` continuations use `Ask` to read context inside pipeline steps
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
- `context.flowName` may be used for naming workflows in telemetry.
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
- ### `configureEffect(options)`
304
+ ### `runEffect(effect, context?, callConfig?)`
191
305
 
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.
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
- `configureEffect` also accepts `onBeforeCommand`, which can be used to intercept each `Command` and the context passed to `runEffect` before execution.
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
- - `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`.
312
+ ### `configureEffect(options)`
201
313
 
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.
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
- - `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.
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: unknown) => Effect<T, E>;
23
+ next: (context: Ctx) => Effect<T, E, Ctx>;
24
24
  initialInput?: unknown;
25
25
  };
26
26
 
27
- export type Effect<T, E = unknown> = SuccessState<T> | FailureState<E> | CommandState<any, T, E> | AskState<T, E>;
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>(next: (context: unknown) => Effect<T, E>): AskState<T, E>;
40
-
41
- export declare function effectPipe<A, B, E = unknown>(f1: (a: A) => Effect<B, E>): (start: A) => Effect<B, E>;
42
-
43
- export declare function effectPipe<A, B, C, E = unknown>(
44
- f1: (a: A) => Effect<B, E>,
45
- f2: (b: B) => Effect<C, E>
46
- ): (start: A) => Effect<C, E>;
47
-
48
- export declare function effectPipe<A, B, C, D, E = unknown>(
49
- f1: (a: A) => Effect<B, E>,
50
- f2: (b: B) => Effect<C, E>,
51
- f3: (c: C) => Effect<D, E>
52
- ): (start: A) => Effect<D, E>;
53
-
54
- export declare function effectPipe<A, B, C, D, F, E = unknown>(
55
- f1: (a: A) => Effect<B, E>,
56
- f2: (b: B) => Effect<C, E>,
57
- f3: (c: C) => Effect<D, E>,
58
- f4: (d: D) => Effect<F, E>
59
- ): (start: A) => Effect<F, E>;
60
-
61
- export declare function effectPipe<A, B, C, D, F, G, E = unknown>(
62
- f1: (a: A) => Effect<B, E>,
63
- f2: (b: B) => Effect<C, E>,
64
- f3: (c: C) => Effect<D, E>,
65
- f4: (d: D) => Effect<F, E>,
66
- f5: (f: F) => Effect<G, E>
67
- ): (start: A) => Effect<G, E>;
68
-
69
- export declare function effectPipe<A, B, C, D, F, G, H, E = unknown>(
70
- f1: (a: A) => Effect<B, E>,
71
- f2: (b: B) => Effect<C, E>,
72
- f3: (c: C) => Effect<D, E>,
73
- f4: (d: D) => Effect<F, E>,
74
- f5: (f: F) => Effect<G, E>,
75
- f6: (g: G) => Effect<H, E>
76
- ): (start: A) => Effect<H, E>;
77
-
78
- export declare function effectPipe<A, B, C, D, F, G, H, I, E = unknown>(
79
- f1: (a: A) => Effect<B, E>,
80
- f2: (b: B) => Effect<C, E>,
81
- f3: (c: C) => Effect<D, E>,
82
- f4: (d: D) => Effect<F, E>,
83
- f5: (f: F) => Effect<G, E>,
84
- f6: (g: G) => Effect<H, E>,
85
- f7: (h: H) => Effect<I, E>
86
- ): (start: A) => Effect<I, E>;
87
-
88
- export declare function effectPipe<A, B, C, D, F, G, H, I, J, E = unknown>(
89
- f1: (a: A) => Effect<B, E>,
90
- f2: (b: B) => Effect<C, E>,
91
- f3: (c: C) => Effect<D, E>,
92
- f4: (d: D) => Effect<F, E>,
93
- f5: (f: F) => Effect<G, E>,
94
- f6: (g: G) => Effect<H, E>,
95
- f7: (h: H) => Effect<I, E>,
96
- f8: (i: I) => Effect<J, E>
97
- ): (start: A) => Effect<J, E>;
98
-
99
- export declare function runEffect<T, E = unknown>(
100
- effect: Effect<T, E>,
101
- context?: unknown
102
- ): Promise<SuccessState<T> | FailureState<E>>;
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 Ask.
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
- const chain = (effect, fn) => {
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 effect = fns.reduce(chain, /** @type {Effect} */ (Success(start)));
97
- effect.initialInput = start;
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
- return runWrapper(
148
- effect,
149
- async () => {
150
- while (effect.type === 'Command' || effect.type === 'Ask') {
151
- if (effect.type === 'Ask') {
152
- effect = effect.next(context);
153
- continue;
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
- const cmdName = effect.cmd.name || 'anonymous';
156
- const initialInput = effect.initialInput;
157
- try {
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
- return effect;
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.6.0",
4
- "description": "A tiny, zero-dependency effect system for writing pure, testable JavaScript/TypeScript without mocks.",
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
- const result = await runEffect(registerUserFlow(input), { env: 'test' });
90
- configureEffect({ onBeforeCommand: undefined });
91
- });
92
-
93
- it('should return Success after runEffect with telemetry disabled', async function () {
94
- const input = { email: 'test-no-telemetry@test.com', password: 'password123' };
95
- const result = await registerUser(input);
96
- assert.equal(result.type, 'Success');
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
  });
@@ -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 { SuccessState, FailureState, CommandState, AskState, Effect } from '../index.js';
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);