pure-effect 0.7.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.
@@ -6,7 +6,8 @@
6
6
  "Bash(git *)",
7
7
  "Bash(node *)",
8
8
  "Bash(npx tsc *)",
9
- "Bash(npx tsd *)"
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\\)\\)\")"
10
11
  ]
11
12
  }
12
13
  }
Binary file
package/CLAUDE.md CHANGED
@@ -17,16 +17,17 @@ 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
- | `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` |
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` |
30
31
 
31
32
  ### Data flow
32
33
 
@@ -43,6 +44,10 @@ runEffect(tree, context, callConfig?)
43
44
  → resolves Retry via an inner execute() loop (not recursive runEffect),
44
45
  so onRun fires exactly once per runEffect call regardless of attempts;
45
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
46
51
  → resolves to final Success or Failure
47
52
  ```
48
53
 
package/README.md CHANGED
@@ -8,11 +8,11 @@
8
8
 
9
9
  - No mocks needed to test async pipelines
10
10
  - Inject context without touching function signatures
11
- - Built-in retry with configurable delay and backoff
11
+ - Built-in retry and parallel execution with configurable delay, backoff, and `Promise.all` semantics
12
12
  - OpenTelemetry-ready via lifecycle hooks
13
13
  - Zero dependencies, under 1 KB minified and gzipped
14
14
  - Works in JavaScript and TypeScript (full generics, bundled `.d.ts`)
15
- - Five primitives: learn the whole API in an afternoon
15
+ - Six primitives: learn the whole API in an afternoon
16
16
 
17
17
  ## Table of Contents
18
18
 
@@ -21,6 +21,7 @@
21
21
  - [Testing Without Mocks](#testing-without-mocks)
22
22
  - [Passing Runtime Context](#passing-runtime-context)
23
23
  - [Retrying Transient Failures](#retrying-transient-failures)
24
+ - [Running Effects in Parallel](#running-effects-in-parallel)
24
25
  - [TypeScript: Typed Errors and Context](#typescript-typed-errors-and-context)
25
26
  - [Why Pure Effect](#why-pure-effect)
26
27
  - [API Reference](#api-reference)
@@ -44,7 +45,7 @@ const validateRegistration = (input) => {
44
45
  return Success(input);
45
46
  };
46
47
 
47
- // Theese function return a Command object. They do NOT call the database.
48
+ // These functions return a Command object. They do NOT call the database.
48
49
  const findUser = (email) => {
49
50
  const cmdFindUser = () => db.findUser(email);
50
51
  return Command(cmdFindUser, (user) => Success(user));
@@ -181,6 +182,51 @@ configureEffect({
181
182
  Retry(effect, { delay: 500 }); // uses global attempts, custom delay
182
183
  ```
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
+
184
230
  ## TypeScript: Typed Errors and Context
185
231
 
186
232
  ### Error union across pipeline steps
@@ -236,11 +282,11 @@ const result = await runEffect(findProduct('abc'), { tenant: 'acme', requestId:
236
282
 
237
283
  ## Why Pure Effect
238
284
 
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.
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.
240
286
 
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.
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.
242
288
 
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.
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.
244
290
 
245
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.
246
292
 
@@ -285,6 +331,13 @@ Returns `{ type: 'Retry', effect, options, next }`. Wraps any Effect with retry-
285
331
 
286
332
  On exhaustion, returns `Failure({ retryExhausted: true, lastError, attempts })`.
287
333
 
334
+ ### `Parallel(effects, next)`
335
+
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.
337
+
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.
340
+
288
341
  ### `effectPipe(...functions)`
289
342
 
290
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.
@@ -313,7 +366,7 @@ The interpreter. Traverses the effect tree, executes Commands with `async/await`
313
366
 
314
367
  - `onRun(effect, pipeline, flowName)` wraps the entire workflow; must `await pipeline()`.
315
368
  - `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.
369
+ - `onBeforeCommand(command, context)` fires before each Command; throw to abort the pipeline.
317
370
  - `retry: { attempts?, delay?, backoff? }` global retry defaults.
318
371
 
319
372
  See **opentelemetry-example.js** in the repository for a complete OpenTelemetry wiring example.
package/index.d.ts CHANGED
@@ -44,12 +44,20 @@ export type RetryExhaustedError<E = unknown> = {
44
44
  attempts: number;
45
45
  };
46
46
 
47
+ export type ParallelState<T extends readonly unknown[], R, E = unknown, Ctx = unknown> = {
48
+ type: 'Parallel';
49
+ effects: { [K in keyof T]: Effect<T[K], E, Ctx> };
50
+ next: (values: [...T]) => Effect<R, E, Ctx>;
51
+ initialInput?: unknown;
52
+ };
53
+
47
54
  export type Effect<T, E = unknown, Ctx = unknown> =
48
55
  | SuccessState<T>
49
56
  | FailureState<E>
50
57
  | CommandState<any, T, E, Ctx>
51
58
  | AskState<T, E, Ctx>
52
- | RetryState<T, E, Ctx>;
59
+ | RetryState<T, E, Ctx>
60
+ | ParallelState<any, T, E, Ctx>;
53
61
 
54
62
  export declare function Success<T>(value: T): SuccessState<T>;
55
63
 
@@ -70,6 +78,11 @@ export declare function Retry<T, E = unknown, Ctx = unknown>(
70
78
  options?: RetryOptions
71
79
  ): RetryState<T, E, Ctx>;
72
80
 
81
+ export declare function Parallel<T extends readonly unknown[], R, E = unknown, Ctx = unknown>(
82
+ effects: { [K in keyof T]: Effect<T[K], E, Ctx> },
83
+ next: (values: [...T]) => Effect<R, E, Ctx>
84
+ ): ParallelState<[...T], R, E, Ctx>;
85
+
73
86
  export declare function effectPipe<A, B, E1 = unknown, Ctx = unknown>(
74
87
  f1: (a: A) => Effect<B, E1, Ctx>
75
88
  ): (start: A) => Effect<B, E1, Ctx>;
package/index.js CHANGED
@@ -29,9 +29,18 @@
29
29
  * }} RetryState
30
30
  */
31
31
 
32
+ /**
33
+ * @typedef {{
34
+ * type: 'Parallel',
35
+ * effects: Effect[],
36
+ * next: (values: any[]) => Effect,
37
+ * initialInput?: any
38
+ * }} ParallelState
39
+ */
40
+
32
41
  /**
33
42
  * The Union type for all possible states
34
- * @typedef {SuccessState | FailureState | CommandState | AskState | RetryState} Effect
43
+ * @typedef {SuccessState | FailureState | CommandState | AskState | RetryState | ParallelState} Effect
35
44
  */
36
45
 
37
46
  /**
@@ -85,6 +94,14 @@ const Retry = (effect, options = {}) => ({
85
94
  next: (value) => Success(value)
86
95
  });
87
96
 
97
+ /**
98
+ * Runs multiple Effect trees concurrently. If any effect fails, returns the first Failure by array order and skips next.
99
+ * @param {Effect[]} effects - Array of Effect trees to run concurrently
100
+ * @param {(values: any[]) => Effect} next - Receives array of success values in order, returns next Effect
101
+ * @returns {ParallelState}
102
+ */
103
+ const Parallel = (effects, next) => ({ type: 'Parallel', effects, next });
104
+
88
105
  /**
89
106
  * Connects an Effect to the next function in the pipeline.
90
107
  * Handles the branching logic for Success, Failure, Command, Ask, and Retry.
@@ -120,6 +137,10 @@ const chain = (effect, fn, initialInput) => {
120
137
  const next = (/** @type {any} */ result) => chain(effect.next(result), fn, initialInput);
121
138
  return withII({ ...effect, next });
122
139
  }
140
+ case 'Parallel': {
141
+ const next = (/** @type {any} */ result) => chain(effect.next(result), fn, initialInput);
142
+ return withII({ ...effect, next });
143
+ }
123
144
  }
124
145
  };
125
146
 
@@ -202,7 +223,7 @@ const runEffect =
202
223
  * @returns {Promise<SuccessState | FailureState>}
203
224
  */
204
225
  async function execute(eff) {
205
- while (eff.type === 'Command' || eff.type === 'Ask' || eff.type === 'Retry') {
226
+ while (eff.type === 'Command' || eff.type === 'Ask' || eff.type === 'Retry' || eff.type === 'Parallel') {
206
227
  if (eff.type === 'Ask') {
207
228
  eff = eff.next(context);
208
229
  continue;
@@ -231,6 +252,13 @@ const runEffect =
231
252
  }
232
253
  continue;
233
254
  }
255
+ if (eff.type === 'Parallel') {
256
+ const results = await Promise.all(eff.effects.map((e) => execute(e)));
257
+ const failure = results.find((r) => r.type === 'Failure');
258
+ if (failure) return failure;
259
+ eff = eff.next(results.map((r) => /** @type {SuccessState} */ (r).value));
260
+ continue;
261
+ }
234
262
  const cmdName = eff.cmd.name || 'anonymous';
235
263
  const initialInput = eff.initialInput;
236
264
  try {
@@ -247,4 +275,4 @@ const runEffect =
247
275
  return localRunWrapper(effect, () => execute(effect), context?.flowName || '');
248
276
  };
249
277
 
250
- export { Success, Failure, Command, Ask, Retry, effectPipe, runEffect, configureEffect };
278
+ export { Success, Failure, Command, Ask, Retry, Parallel, effectPipe, runEffect, configureEffect };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pure-effect",
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.",
3
+ "version": "0.8.0",
4
+ "description": "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.",
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, Retry, effectPipe, runEffect, configureEffect } from '../index.js';
4
+ import { Success, Failure, Command, Ask, Retry, Parallel, effectPipe, runEffect, configureEffect } from '../index.js';
5
5
  import { enableTelemetry } from '../opentelemetry-example.js';
6
6
 
7
7
  /** @import { CommandInterceptor } from "../index.js" */
@@ -227,6 +227,71 @@ describe('Pure Effect', function () {
227
227
  assert.equal(result.value, 'HELLO!');
228
228
  });
229
229
 
230
+ it('should return a Parallel data structure', () => {
231
+ const e1 = Success(1);
232
+ const e2 = Success(2);
233
+ const next = (/** @type {any[]} */ values) => Success(values);
234
+ const result = Parallel([e1, e2], next);
235
+ assert.equal(result.type, 'Parallel');
236
+ assert.deepEqual(result.effects, [e1, e2]);
237
+ assert.equal(result.next, next);
238
+ });
239
+
240
+ it('should run effects concurrently and pass results to next', async () => {
241
+ const e1 = Command(
242
+ async () => 'a',
243
+ (v) => Success(v)
244
+ );
245
+ const e2 = Command(
246
+ async () => 'b',
247
+ (v) => Success(v)
248
+ );
249
+ const flow = Parallel([e1, e2], ([a, b]) => Success({ a, b }));
250
+ const result = await runEffect(flow);
251
+ assert.equal(result.type, 'Success');
252
+ assert.deepEqual(result.value, { a: 'a', b: 'b' });
253
+ });
254
+
255
+ it('should return Failure if any parallel effect fails', async () => {
256
+ const e1 = Success('ok');
257
+ const e2 = Failure('oops');
258
+ const flow = Parallel([e1, e2], ([a, b]) => Success({ a, b }));
259
+ const result = await runEffect(flow);
260
+ assert.equal(result.type, 'Failure');
261
+ assert.equal(result.error, 'oops');
262
+ });
263
+
264
+ it('should work inside effectPipe', async () => {
265
+ const flow = effectPipe((input) =>
266
+ Parallel(
267
+ [
268
+ Command(
269
+ async () => input.a,
270
+ (v) => Success(v)
271
+ ),
272
+ Command(
273
+ async () => input.b,
274
+ (v) => Success(v)
275
+ )
276
+ ],
277
+ ([a, b]) => Success({ a, b })
278
+ )
279
+ );
280
+ const result = await runEffect(flow({ a: 1, b: 2 }));
281
+ assert.equal(result.type, 'Success');
282
+ assert.deepEqual(result.value, { a: 1, b: 2 });
283
+ });
284
+
285
+ it('should pass context to parallel branches via Ask', async () => {
286
+ const flow = Parallel(
287
+ [Ask((/** @type {any} */ ctx) => Success(ctx.x)), Ask((/** @type {any} */ ctx) => Success(ctx.y))],
288
+ ([x, y]) => Success({ x, y })
289
+ );
290
+ const result = await runEffect(flow, { x: 10, y: 20 });
291
+ assert.equal(result.type, 'Success');
292
+ assert.deepEqual(result.value, { x: 10, y: 20 });
293
+ });
294
+
230
295
  it('should return Success after runEffect with telemetry disabled', async function () {
231
296
  const input = { email: 'test-no-telemetry@test.com', password: 'password123' };
232
297
  const result = await registerUser(input);
@@ -1,13 +1,18 @@
1
1
  import { expectType, expectError } from 'tsd';
2
- import { Success, Failure, Command, Ask, Retry, effectPipe, runEffect } from '../index.js';
2
+ import { Success, Failure, Command, Ask, Retry, Parallel, effectPipe, runEffect, configureEffect } from '../index.js';
3
3
  import type {
4
4
  SuccessState,
5
5
  FailureState,
6
6
  CommandState,
7
7
  AskState,
8
8
  RetryState,
9
+ ParallelState,
9
10
  RetryExhaustedError,
10
- Effect
11
+ Effect,
12
+ EffectConfiguration,
13
+ StepRunner,
14
+ RunWrapper,
15
+ CommandInterceptor
11
16
  } from '../index.js';
12
17
 
13
18
  interface User {
@@ -131,3 +136,85 @@ expectType<Effect<SavedUser, ValidationError | DbError>>(typedFlow({ email: 'a@b
131
136
 
132
137
  const typedResult = await runEffect(typedFlow({ email: 'a@b.com', password: 'secret123' }));
133
138
  expectType<SuccessState<SavedUser> | FailureState<ValidationError | DbError>>(typedResult);
139
+
140
+ // --- Parallel ---
141
+
142
+ // Values tuple is correctly typed
143
+ const par = Parallel([Success(42), Success('hello')], ([n, s]) => {
144
+ expectType<number>(n);
145
+ expectType<string>(s);
146
+ return Success({ n, s });
147
+ });
148
+ expectType<ParallelState<[number, string], { n: number; s: string }>>(par);
149
+
150
+ // Parallel in effectPipe preserves type flow
151
+ const parallelFlow = effectPipe((input: User) =>
152
+ Parallel([Success(input.email), Success(input.password)], ([email, password]) => Success({ email, password }))
153
+ );
154
+ expectType<Effect<{ email: string; password: string }>>(parallelFlow({ email: 'a@b.com', password: 'secret123' }));
155
+
156
+ // runEffect return type flows through Parallel
157
+ const parallelResult = await runEffect(Parallel([Success(1), Success('x')], ([n, s]) => Success({ n, s })));
158
+ expectType<SuccessState<{ n: number; s: string }> | FailureState<unknown>>(parallelResult);
159
+
160
+ // --- Ctx (context type) ---
161
+
162
+ interface AppCtx {
163
+ db: string;
164
+ }
165
+
166
+ // Ask infers Ctx from callback parameter type
167
+ const askWithCtx = Ask((ctx: AppCtx) => Success(ctx.db));
168
+ expectType<AskState<string, unknown, AppCtx>>(askWithCtx);
169
+
170
+ // effectPipe propagates Ctx through steps
171
+ const ctxFlow = effectPipe((input: User) => Ask((ctx: AppCtx) => Success({ ...input, conn: ctx.db })));
172
+ expectType<Effect<{ email: string; password: string; conn: string }, unknown, AppCtx>>(
173
+ ctxFlow({ email: 'a@b.com', password: 'secret123' })
174
+ );
175
+
176
+ // runEffect enforces context argument matches Ctx
177
+ const ctxResult = await runEffect(ctxFlow({ email: 'a@b.com', password: 'secret123' }), { db: 'conn' });
178
+ expectType<SuccessState<{ email: string; password: string; conn: string }> | FailureState<unknown>>(ctxResult);
179
+
180
+ // wrong context shape should error
181
+ expectError(runEffect(ctxFlow({ email: 'a@b.com', password: 'secret123' }), { wrong: 'thing' }));
182
+
183
+ // --- configureEffect / EffectConfiguration ---
184
+
185
+ // accepts full configuration
186
+ configureEffect({
187
+ onStep: async (_name, _type, op) => op(),
188
+ onRun: async (_effect, op, _flowName) => op(),
189
+ onBeforeCommand: async (_cmd, _ctx) => {},
190
+ retry: { attempts: 3, delay: 100, backoff: 2 }
191
+ });
192
+
193
+ // accepts partial configuration
194
+ configureEffect({ retry: { attempts: 5 } });
195
+ configureEffect({});
196
+
197
+ // rejects invalid shapes
198
+ expectError(configureEffect({ onStep: 'not-a-function' }));
199
+ expectError(configureEffect({ retry: { attempts: 'three' } }));
200
+
201
+ // hook types are correctly shaped
202
+ const myStep: StepRunner = async (name, type, op) => {
203
+ expectType<string>(name);
204
+ expectType<string>(type);
205
+ return op();
206
+ };
207
+
208
+ const myRun: RunWrapper = async (effect, op, flowName) => {
209
+ expectType<Effect<unknown>>(effect);
210
+ expectType<string | undefined>(flowName);
211
+ return op();
212
+ };
213
+
214
+ const myInterceptor: CommandInterceptor = async (cmd, _ctx) => {
215
+ expectType<CommandState<unknown, unknown>>(cmd);
216
+ };
217
+
218
+ // EffectConfiguration is a usable type
219
+ const config: EffectConfiguration = { onStep: myStep, onRun: myRun, onBeforeCommand: myInterceptor };
220
+ expectType<EffectConfiguration>(config);