pure-effect 0.4.0 → 0.6.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 +11 -0
- package/.dirac-symbol-index/data.db +0 -0
- package/.prettierrc +11 -0
- package/CLAUDE.md +63 -0
- package/README.md +85 -23
- package/index.d.ts +120 -0
- package/index.js +40 -11
- package/opentelemetry-example.js +5 -5
- package/package.json +9 -4
- package/test/all.js +29 -3
- package/test/types.test-d.ts +74 -0
|
Binary file
|
package/.prettierrc
ADDED
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## Commands
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm test # run all tests
|
|
9
|
+
npx mocha test/all.js --grep "pattern" # run a single test by name
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
No build or lint step — the library ships as plain ES modules with no transpilation.
|
|
13
|
+
|
|
14
|
+
## Architecture
|
|
15
|
+
|
|
16
|
+
**pure-effect** is a zero-dependency effect system for JavaScript implementing the "Functional Core, Imperative Shell" pattern. Business logic returns plain data structures instead of executing side effects, enabling testing without mocks.
|
|
17
|
+
|
|
18
|
+
### Core abstractions (all in `index.js`)
|
|
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`) |
|
|
29
|
+
|
|
30
|
+
### Data flow
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
effectPipe(f1, f2, f3)(input)
|
|
34
|
+
→ returns tree of Success / Failure / Command / Ask values
|
|
35
|
+
→ f1 runs eagerly here
|
|
36
|
+
|
|
37
|
+
runEffect(tree, context)
|
|
38
|
+
→ executes Commands async, passes results into next(result), repeats
|
|
39
|
+
→ resolves Ask by calling next(context), continues
|
|
40
|
+
→ resolves to final Success or Failure
|
|
41
|
+
```
|
|
42
|
+
|
|
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.
|
|
44
|
+
|
|
45
|
+
`configureEffect` hooks:
|
|
46
|
+
|
|
47
|
+
- `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
|
|
49
|
+
- `onBeforeCommand` — intercepts each Command before execution; receives the Command and the `context` passed to `runEffect`
|
|
50
|
+
|
|
51
|
+
### TypeScript
|
|
52
|
+
|
|
53
|
+
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
|
+
|
|
55
|
+
### Observability
|
|
56
|
+
|
|
57
|
+
`opentelemetry-example.js` shows how to wire OpenTelemetry spans into `configureEffect`'s hooks — it is reference code, not part of the library.
|
|
58
|
+
|
|
59
|
+
### Tests
|
|
60
|
+
|
|
61
|
+
`test/all.js` contains all runtime tests and uses a user-registration domain as the running example. Tests assert on the _returned data structures_ (Commands, Failures) rather than on side effects, which is the core usage pattern to preserve.
|
|
62
|
+
|
|
63
|
+
`test/types.test-d.ts` contains type-level tests using `tsd`, verifying that generic type parameters flow correctly through `effectPipe` and `runEffect`.
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
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
6
|
|
|
7
|
-
**Pure Effect**
|
|
7
|
+
**Pure Effect** ships with JSDoc type annotations for JavaScript users and a bundled declaration file with full generic types for TypeScript users.
|
|
8
8
|
|
|
9
9
|
## Installation
|
|
10
10
|
|
|
@@ -95,6 +95,49 @@ assert.equal(step2.cmd.name, 'cmdSaveUser');
|
|
|
95
95
|
// ✅ We verified the *intent* of the code without touching a real DB.
|
|
96
96
|
```
|
|
97
97
|
|
|
98
|
+
## Passing Runtime Context
|
|
99
|
+
|
|
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.
|
|
101
|
+
|
|
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:
|
|
103
|
+
|
|
104
|
+
```js
|
|
105
|
+
import { Success, Failure, Command, Ask, effectPipe, runEffect } from 'pure-effect';
|
|
106
|
+
|
|
107
|
+
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
|
+
);
|
|
114
|
+
|
|
115
|
+
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
|
+
```
|
|
129
|
+
|
|
130
|
+
The router identifies the tenant and passes it as context. `checkoutFlow` never needs a tenant parameter:
|
|
131
|
+
|
|
132
|
+
```js
|
|
133
|
+
app.post('/checkout', async (req, res) => {
|
|
134
|
+
const result = await runEffect(checkoutFlow(req.body.productId), {
|
|
135
|
+
tenant: req.subdomains[0] // e.g. 'acme' from acme.myapp.com
|
|
136
|
+
});
|
|
137
|
+
res.json(result);
|
|
138
|
+
});
|
|
139
|
+
```
|
|
140
|
+
|
|
98
141
|
## API Reference
|
|
99
142
|
|
|
100
143
|
### `Success(value)`
|
|
@@ -109,9 +152,25 @@ Returns an object `{ type: 'Failure', error, initialInput }`. Represents a faile
|
|
|
109
152
|
|
|
110
153
|
Returns an object `{ type: 'Command', cmd, next, meta }`.
|
|
111
154
|
|
|
112
|
-
-
|
|
113
|
-
-
|
|
114
|
-
-
|
|
155
|
+
- `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.
|
|
158
|
+
|
|
159
|
+
### `Ask(nextFn)`
|
|
160
|
+
|
|
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.
|
|
162
|
+
|
|
163
|
+
```js
|
|
164
|
+
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
|
+
);
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
See [Passing Runtime Context](#passing-runtime-context) for a full example.
|
|
115
174
|
|
|
116
175
|
### `effectPipe(...functions)`
|
|
117
176
|
|
|
@@ -119,7 +178,12 @@ A combinator that runs functions in sequence. It automatically handles unpacking
|
|
|
119
178
|
|
|
120
179
|
### `runEffect(effect, context = {})`
|
|
121
180
|
|
|
122
|
-
The interpreter. It takes an `effect` object, executes any nested Commands
|
|
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:
|
|
182
|
+
|
|
183
|
+
- `Ask` continuations — use `Ask` to read context inside pipeline steps
|
|
184
|
+
- The `onBeforeCommand` interceptor (see `configureEffect` below)
|
|
185
|
+
|
|
186
|
+
`context.flowName` may be used for naming workflows in telemetry.
|
|
123
187
|
|
|
124
188
|
---
|
|
125
189
|
|
|
@@ -129,21 +193,19 @@ A configuration function that injects observability, tracing, or logging interce
|
|
|
129
193
|
|
|
130
194
|
`configureEffect` also accepts `onBeforeCommand`, which can be used to intercept each `Command` and the context passed to `runEffect` before execution.
|
|
131
195
|
|
|
132
|
-
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
-
|
|
136
|
-
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
-
|
|
147
|
-
|
|
148
|
-
- `command`: The `Command` object.
|
|
149
|
-
- `context`: The context object passed to `runEffect`, if any.
|
|
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`.
|
|
201
|
+
|
|
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.
|
|
207
|
+
|
|
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.
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
export type SuccessState<T> = {
|
|
2
|
+
type: 'Success';
|
|
3
|
+
value: T;
|
|
4
|
+
initialInput?: unknown;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export type FailureState<E = unknown> = {
|
|
8
|
+
type: 'Failure';
|
|
9
|
+
error: E;
|
|
10
|
+
initialInput?: unknown;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type CommandState<R, T, E = unknown> = {
|
|
14
|
+
type: 'Command';
|
|
15
|
+
cmd: () => Promise<R> | R;
|
|
16
|
+
next: (result: R) => Effect<T, E>;
|
|
17
|
+
meta?: unknown;
|
|
18
|
+
initialInput?: unknown;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type AskState<T, E = unknown> = {
|
|
22
|
+
type: 'Ask';
|
|
23
|
+
next: (context: unknown) => Effect<T, E>;
|
|
24
|
+
initialInput?: unknown;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type Effect<T, E = unknown> = SuccessState<T> | FailureState<E> | CommandState<any, T, E> | AskState<T, E>;
|
|
28
|
+
|
|
29
|
+
export declare function Success<T>(value: T): SuccessState<T>;
|
|
30
|
+
|
|
31
|
+
export declare function Failure<E = unknown>(error: E, initialInput?: unknown): FailureState<E>;
|
|
32
|
+
|
|
33
|
+
export declare function Command<R, T, E = unknown>(
|
|
34
|
+
cmd: () => Promise<R> | R,
|
|
35
|
+
next: (result: R) => Effect<T, E>,
|
|
36
|
+
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>>;
|
|
103
|
+
|
|
104
|
+
export type StepRunner = (name: string, type: string, op: () => Promise<unknown>) => Promise<unknown>;
|
|
105
|
+
|
|
106
|
+
export type RunWrapper = (
|
|
107
|
+
effect: Effect<unknown>,
|
|
108
|
+
op: () => Promise<SuccessState<unknown> | FailureState<unknown>>,
|
|
109
|
+
flowName?: string
|
|
110
|
+
) => Promise<SuccessState<unknown> | FailureState<unknown>>;
|
|
111
|
+
|
|
112
|
+
export type CommandInterceptor = (command: CommandState<unknown, unknown>, context?: any) => Promise<void>;
|
|
113
|
+
|
|
114
|
+
export interface EffectConfiguration {
|
|
115
|
+
onStep?: StepRunner;
|
|
116
|
+
onRun?: RunWrapper;
|
|
117
|
+
onBeforeCommand?: CommandInterceptor;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export declare function configureEffect(options: EffectConfiguration): void;
|
package/index.js
CHANGED
|
@@ -11,10 +11,17 @@
|
|
|
11
11
|
* initialInput?: any
|
|
12
12
|
* }} CommandState
|
|
13
13
|
*/
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {{
|
|
16
|
+
* type: 'Ask',
|
|
17
|
+
* next: (context: any) => Effect,
|
|
18
|
+
* initialInput?: any
|
|
19
|
+
* }} AskState
|
|
20
|
+
*/
|
|
14
21
|
|
|
15
22
|
/**
|
|
16
23
|
* The Union type for all possible states
|
|
17
|
-
* @typedef {SuccessState | FailureState | CommandState} Effect
|
|
24
|
+
* @typedef {SuccessState | FailureState | CommandState | AskState} Effect
|
|
18
25
|
*/
|
|
19
26
|
|
|
20
27
|
/**
|
|
@@ -30,7 +37,11 @@ const Success = (value) => ({ type: 'Success', value });
|
|
|
30
37
|
* @param {any} [initialInput] - initial input passed to the flow (optional)
|
|
31
38
|
* @returns {FailureState}
|
|
32
39
|
*/
|
|
33
|
-
const Failure = (error, initialInput) => ({
|
|
40
|
+
const Failure = (error, initialInput) => ({
|
|
41
|
+
type: 'Failure',
|
|
42
|
+
error,
|
|
43
|
+
initialInput
|
|
44
|
+
});
|
|
34
45
|
|
|
35
46
|
/**
|
|
36
47
|
* Represents a side effect to be executed later
|
|
@@ -41,9 +52,16 @@ const Failure = (error, initialInput) => ({ type: 'Failure', error, initialInput
|
|
|
41
52
|
*/
|
|
42
53
|
const Command = (cmd, next, meta) => ({ type: 'Command', cmd, next, meta });
|
|
43
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Reads the context object from the current `runEffect` call.
|
|
57
|
+
* @param {(context: any) => Effect} next - Receives the context and returns the next Effect
|
|
58
|
+
* @returns {AskState}
|
|
59
|
+
*/
|
|
60
|
+
const Ask = (next) => ({ type: 'Ask', next });
|
|
61
|
+
|
|
44
62
|
/**
|
|
45
63
|
* Connects an Effect to the next function in the pipeline.
|
|
46
|
-
* Handles the branching logic for Success, Failure, and
|
|
64
|
+
* Handles the branching logic for Success, Failure, Command, and Ask.
|
|
47
65
|
*
|
|
48
66
|
* @param {Effect} effect - The current Effect object
|
|
49
67
|
* @param {(value: any) => Effect} fn - The next function to run if the current effect is a Success
|
|
@@ -55,9 +73,14 @@ const chain = (effect, fn) => {
|
|
|
55
73
|
return fn(effect.value);
|
|
56
74
|
case 'Failure':
|
|
57
75
|
return effect;
|
|
58
|
-
case 'Command':
|
|
59
|
-
const next = (/** @type {
|
|
76
|
+
case 'Command': {
|
|
77
|
+
const next = (/** @type {any} */ result) => chain(effect.next(result), fn);
|
|
60
78
|
return Command(effect.cmd, next, effect.meta);
|
|
79
|
+
}
|
|
80
|
+
case 'Ask': {
|
|
81
|
+
const next = (/** @type {any} */ ctx) => chain(effect.next(ctx), fn);
|
|
82
|
+
return Ask(next);
|
|
83
|
+
}
|
|
61
84
|
}
|
|
62
85
|
};
|
|
63
86
|
|
|
@@ -65,12 +88,12 @@ const chain = (effect, fn) => {
|
|
|
65
88
|
* Composes a list of functions into a single Effect pipeline.
|
|
66
89
|
* Each function receives the output of the previous one.
|
|
67
90
|
*
|
|
68
|
-
* @param {...(input: any) => Effect} fns - Functions that return Success, Failure, or
|
|
91
|
+
* @param {...(input: any) => Effect} fns - Functions that return Success, Failure, Command, or Ask.
|
|
69
92
|
* @returns {(start: any) => Effect} A function that accepts an initial input and returns the final Effect tree.
|
|
70
93
|
*/
|
|
71
94
|
const effectPipe = (...fns) => {
|
|
72
95
|
return (start) => {
|
|
73
|
-
const effect = fns.reduce(chain, Success(start));
|
|
96
|
+
const effect = fns.reduce(chain, /** @type {Effect} */ (Success(start)));
|
|
74
97
|
effect.initialInput = start;
|
|
75
98
|
return effect;
|
|
76
99
|
};
|
|
@@ -114,23 +137,29 @@ const runEffect =
|
|
|
114
137
|
/**
|
|
115
138
|
* The Interpreter
|
|
116
139
|
* Iterates through the Effect tree, executing Commands and handling async flow.
|
|
140
|
+
* Ask effects are resolved synchronously with the context object.
|
|
117
141
|
*
|
|
118
142
|
* @param {Effect} effect - The Effect tree returned by a pipeline
|
|
119
|
-
* @param {any} [context] - Optional context object
|
|
143
|
+
* @param {any} [context] - Optional context object. Passed to Ask continuations and the Command Interceptor.
|
|
120
144
|
* @returns {Promise<SuccessState | FailureState>}
|
|
121
145
|
*/
|
|
122
146
|
async function runEffect(effect, context = {}) {
|
|
123
147
|
return runWrapper(
|
|
124
148
|
effect,
|
|
125
149
|
async () => {
|
|
126
|
-
while (effect.type === 'Command') {
|
|
150
|
+
while (effect.type === 'Command' || effect.type === 'Ask') {
|
|
151
|
+
if (effect.type === 'Ask') {
|
|
152
|
+
effect = effect.next(context);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
127
155
|
const cmdName = effect.cmd.name || 'anonymous';
|
|
156
|
+
const initialInput = effect.initialInput;
|
|
128
157
|
try {
|
|
129
158
|
await commandInterceptor(effect, context);
|
|
130
159
|
const result = await stepRunner(cmdName, 'Command', effect.cmd);
|
|
131
160
|
effect = effect.next(result);
|
|
132
161
|
} catch (e) {
|
|
133
|
-
return Failure(e,
|
|
162
|
+
return Failure(e, initialInput);
|
|
134
163
|
}
|
|
135
164
|
}
|
|
136
165
|
|
|
@@ -140,4 +169,4 @@ const runEffect =
|
|
|
140
169
|
);
|
|
141
170
|
};
|
|
142
171
|
|
|
143
|
-
export { Success, Failure, Command, effectPipe, runEffect, configureEffect };
|
|
172
|
+
export { Success, Failure, Command, Ask, effectPipe, runEffect, configureEffect };
|
package/opentelemetry-example.js
CHANGED
|
@@ -9,14 +9,14 @@ import { configureEffect } from './index.js';
|
|
|
9
9
|
/** @import { RunWrapper, StepRunner } from "./index.js" */
|
|
10
10
|
|
|
11
11
|
const traceExporter = new OTLPTraceExporter({
|
|
12
|
-
url: 'http://localhost:4318/v1/traces'
|
|
12
|
+
url: 'http://localhost:4318/v1/traces'
|
|
13
13
|
});
|
|
14
14
|
|
|
15
15
|
const sdk = new NodeSDK({
|
|
16
16
|
serviceName: 'pure-effect-test',
|
|
17
17
|
traceExporter,
|
|
18
18
|
spanProcessor: new SimpleSpanProcessor(traceExporter),
|
|
19
|
-
instrumentations: []
|
|
19
|
+
instrumentations: []
|
|
20
20
|
});
|
|
21
21
|
|
|
22
22
|
sdk.start();
|
|
@@ -43,7 +43,7 @@ export function enableTelemetry() {
|
|
|
43
43
|
if (result.type === 'Failure') {
|
|
44
44
|
rootSpan.setStatus({
|
|
45
45
|
code: SpanStatusCode.ERROR,
|
|
46
|
-
message: String(result.error)
|
|
46
|
+
message: String(result.error)
|
|
47
47
|
});
|
|
48
48
|
} else {
|
|
49
49
|
rootSpan.setStatus({ code: SpanStatusCode.OK });
|
|
@@ -79,13 +79,13 @@ export function enableTelemetry() {
|
|
|
79
79
|
span.recordException(err);
|
|
80
80
|
span.setStatus({
|
|
81
81
|
code: SpanStatusCode.ERROR,
|
|
82
|
-
message: err.message
|
|
82
|
+
message: err.message
|
|
83
83
|
});
|
|
84
84
|
throw err;
|
|
85
85
|
} finally {
|
|
86
86
|
span.end();
|
|
87
87
|
}
|
|
88
88
|
});
|
|
89
|
-
}
|
|
89
|
+
}
|
|
90
90
|
});
|
|
91
91
|
}
|
package/package.json
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pure-effect",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "A tiny, zero-dependency effect system for writing pure, testable JavaScript without mocks.",
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "A tiny, zero-dependency effect system for writing pure, testable JavaScript/TypeScript without mocks.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": "./index.js",
|
|
7
|
+
"types": "./index.d.ts",
|
|
7
8
|
"scripts": {
|
|
8
|
-
"test": "mocha"
|
|
9
|
+
"test": "mocha && tsd"
|
|
10
|
+
},
|
|
11
|
+
"tsd": {
|
|
12
|
+
"directory": "test"
|
|
9
13
|
},
|
|
10
14
|
"author": "Aycan Gulez",
|
|
11
15
|
"homepage": "https://github.com/aycangulez/pure-effect",
|
|
@@ -14,6 +18,7 @@
|
|
|
14
18
|
"@opentelemetry/sdk-node": "^0.211.0",
|
|
15
19
|
"@types/mocha": "^10.0.10",
|
|
16
20
|
"@types/node": "^24.10.1",
|
|
17
|
-
"mocha": "^11.7.5"
|
|
21
|
+
"mocha": "^11.7.5",
|
|
22
|
+
"tsd": "^0.33.0"
|
|
18
23
|
}
|
|
19
24
|
}
|
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, effectPipe, runEffect, configureEffect } from '../index.js';
|
|
4
|
+
import { Success, Failure, Command, Ask, effectPipe, runEffect, configureEffect } from '../index.js';
|
|
5
5
|
import { enableTelemetry } from '../opentelemetry-example.js';
|
|
6
6
|
|
|
7
7
|
/** @import { CommandInterceptor } from "../index.js" */
|
|
@@ -17,7 +17,7 @@ const db = {
|
|
|
17
17
|
const u = { ...user, id: Date.now() };
|
|
18
18
|
this.users.set(user.email, u);
|
|
19
19
|
return u;
|
|
20
|
-
}
|
|
20
|
+
}
|
|
21
21
|
};
|
|
22
22
|
|
|
23
23
|
function validateRegistration(/** @type {User} */ input) {
|
|
@@ -83,7 +83,7 @@ describe('Pure Effect', function () {
|
|
|
83
83
|
it('should access context through onBeforeCommand', async function () {
|
|
84
84
|
configureEffect({
|
|
85
85
|
onBeforeCommand: /** @type CommandInterceptor */ async (command, context) =>
|
|
86
|
-
assert.equal(context.env, 'test')
|
|
86
|
+
assert.equal(context.env, 'test')
|
|
87
87
|
});
|
|
88
88
|
const input = { email: 'context@test.com', password: 'password123' };
|
|
89
89
|
const result = await runEffect(registerUserFlow(input), { env: 'test' });
|
|
@@ -102,4 +102,30 @@ describe('Pure Effect', function () {
|
|
|
102
102
|
const result = await registerUser(input);
|
|
103
103
|
assert.equal(result.type, 'Success');
|
|
104
104
|
});
|
|
105
|
+
|
|
106
|
+
it('should access context through Ask', async function () {
|
|
107
|
+
/** @type {any} */
|
|
108
|
+
let capturedCtx;
|
|
109
|
+
const step = () =>
|
|
110
|
+
Ask((ctx) => {
|
|
111
|
+
capturedCtx = ctx;
|
|
112
|
+
return Success(null);
|
|
113
|
+
});
|
|
114
|
+
await runEffect(step(), { env: 'test' });
|
|
115
|
+
assert.equal(capturedCtx.env, 'test');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('should work with Ask at any point in the pipeline', async function () {
|
|
119
|
+
const flow = effectPipe(
|
|
120
|
+
() =>
|
|
121
|
+
Command(
|
|
122
|
+
() => 'value',
|
|
123
|
+
(r) => Success(r)
|
|
124
|
+
),
|
|
125
|
+
(value) => Ask((/** @type {any} */ ctx) => Success({ value, env: ctx.env }))
|
|
126
|
+
);
|
|
127
|
+
const result = await runEffect(flow(null), { env: 'test' });
|
|
128
|
+
assert.equal(result.type, 'Success');
|
|
129
|
+
assert.deepEqual(result.value, { value: 'value', env: 'test' });
|
|
130
|
+
});
|
|
105
131
|
});
|
|
@@ -0,0 +1,74 @@
|
|
|
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';
|
|
4
|
+
|
|
5
|
+
interface User {
|
|
6
|
+
email: string;
|
|
7
|
+
password: string;
|
|
8
|
+
}
|
|
9
|
+
interface SavedUser {
|
|
10
|
+
id: number;
|
|
11
|
+
email: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// --- Success ---
|
|
15
|
+
|
|
16
|
+
const s = Success(42);
|
|
17
|
+
expectType<SuccessState<number>>(s);
|
|
18
|
+
expectError(Success()); // missing argument
|
|
19
|
+
|
|
20
|
+
// --- Failure ---
|
|
21
|
+
|
|
22
|
+
const f = Failure('oops');
|
|
23
|
+
expectType<FailureState<string>>(f);
|
|
24
|
+
|
|
25
|
+
// --- Command ---
|
|
26
|
+
|
|
27
|
+
const cmd = Command(
|
|
28
|
+
async () => ({ id: 1, email: 'a@b.com' }) as SavedUser,
|
|
29
|
+
(saved) => {
|
|
30
|
+
expectType<SavedUser>(saved);
|
|
31
|
+
return Success(saved);
|
|
32
|
+
}
|
|
33
|
+
);
|
|
34
|
+
expectType<CommandState<SavedUser, SavedUser, unknown>>(cmd);
|
|
35
|
+
|
|
36
|
+
// --- effectPipe type propagation ---
|
|
37
|
+
|
|
38
|
+
const step1 = (input: User) => Success(input);
|
|
39
|
+
const step2 = (user: User) =>
|
|
40
|
+
Command(
|
|
41
|
+
async () => ({ id: 1, ...user }) as SavedUser,
|
|
42
|
+
(s) => Success(s)
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const flow = effectPipe(step1, step2);
|
|
46
|
+
expectType<Effect<SavedUser>>(flow({ email: 'a@b.com', password: 'secret123' }));
|
|
47
|
+
expectError(flow({ email: 'a@b.com' })); // missing password
|
|
48
|
+
|
|
49
|
+
// --- runEffect return type ---
|
|
50
|
+
|
|
51
|
+
const result = await runEffect(flow({ email: 'a@b.com', password: 'secret123' }));
|
|
52
|
+
expectType<SuccessState<SavedUser> | FailureState<unknown>>(result);
|
|
53
|
+
|
|
54
|
+
// --- discriminated union narrowing ---
|
|
55
|
+
|
|
56
|
+
if (result.type === 'Success') {
|
|
57
|
+
expectType<SavedUser>(result.value);
|
|
58
|
+
} else {
|
|
59
|
+
expectType<unknown>(result.error);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// --- Failure error type flows through runEffect ---
|
|
63
|
+
|
|
64
|
+
const failFlow = effectPipe((input: User): Effect<User, string> => Failure<string>('bad'));
|
|
65
|
+
const failResult = await runEffect(failFlow({ email: 'a@b.com', password: 'x' }));
|
|
66
|
+
expectType<SuccessState<User> | FailureState<string>>(failResult);
|
|
67
|
+
|
|
68
|
+
// --- Ask ---
|
|
69
|
+
|
|
70
|
+
const ask = Ask((ctx) => Success(ctx as User));
|
|
71
|
+
expectType<AskState<User, unknown>>(ask);
|
|
72
|
+
|
|
73
|
+
const askFlow = effectPipe((input: User) => Ask((_ctx) => Success(input)));
|
|
74
|
+
expectType<Effect<User>>(askFlow({ email: 'a@b.com', password: 'secret123' }));
|