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.
- package/.claude/settings.local.json +2 -1
- package/.dirac-symbol-index/data.db +0 -0
- package/CLAUDE.md +15 -10
- package/README.md +60 -7
- package/index.d.ts +14 -1
- package/index.js +31 -3
- package/package.json +2 -2
- package/test/all.js +66 -1
- package/test/types.test-d.ts +89 -2
|
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
|
-
| `
|
|
28
|
-
| `
|
|
29
|
-
| `
|
|
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
|
|
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
|
-
-
|
|
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
|
-
//
|
|
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,
|
|
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
|
|
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:**
|
|
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)`
|
|
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.
|
|
4
|
-
"description": "A
|
|
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);
|
package/test/types.test-d.ts
CHANGED
|
@@ -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);
|