pure-effect 0.3.0 → 0.5.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/.dirac-symbol-index/data.db +0 -0
- package/CLAUDE.md +59 -0
- package/LICENSE +1 -1
- package/README.md +18 -11
- package/index.d.ts +127 -0
- package/index.js +52 -36
- package/opentelemetry-example.js +9 -6
- package/package.json +9 -4
- package/test/all.js +14 -2
- package/test/types.test-d.ts +53 -0
|
Binary file
|
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
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
|
+
| `effectPipe(...fns)` | — | Composes functions into a sequential pipeline via `chain` |
|
|
26
|
+
| `runEffect(effect, context)` | async | Interpreter: traverses the effect tree, executes Commands |
|
|
27
|
+
| `configureEffect(options)` | — | Injects telemetry hooks (`onStep`, `onRun`, `onBeforeCommand`) |
|
|
28
|
+
|
|
29
|
+
### Data flow
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
effectPipe(f1, f2, f3)(input)
|
|
33
|
+
→ returns tree of Success / Failure / Command values
|
|
34
|
+
|
|
35
|
+
runEffect(tree, context)
|
|
36
|
+
→ executes Commands async, passes results into next(), repeats
|
|
37
|
+
→ resolves to final Success or Failure
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The `chain` combinator (internal) drives composition: `Success` passes its value to the next function, `Failure` short-circuits, `Command` defers execution. `runEffect` loops through the tree with a `while` loop rather than recursion.
|
|
41
|
+
|
|
42
|
+
`configureEffect` hooks:
|
|
43
|
+
- `onStep` — fires on every Command execution; wraps the `cmd` call (use for per-command tracing)
|
|
44
|
+
- `onRun` — fires once per `runEffect` call; wraps the whole workflow (use for top-level spans); receives `context.flowName` as the third argument
|
|
45
|
+
- `onBeforeCommand` — intercepts each Command before execution; receives the Command and the `context` passed to `runEffect`
|
|
46
|
+
|
|
47
|
+
### TypeScript
|
|
48
|
+
|
|
49
|
+
Full generic type declarations are in `index.d.ts` and referenced via the `types` field in `package.json`. Type-level tests live in `test/types.test-d.ts` and run via `tsd` as part of `npm test`.
|
|
50
|
+
|
|
51
|
+
### Observability
|
|
52
|
+
|
|
53
|
+
`opentelemetry-example.js` shows how to wire OpenTelemetry spans into `configureEffect`'s hooks — it is reference code, not part of the library.
|
|
54
|
+
|
|
55
|
+
### Tests
|
|
56
|
+
|
|
57
|
+
`test/all.js` contains all runtime tests and uses a user-registration domain as the running example. Tests assert on the *returned data structures* (Commands, Failures) rather than on side effects, which is the core usage pattern to preserve.
|
|
58
|
+
|
|
59
|
+
`test/types.test-d.ts` contains type-level tests using `tsd`, verifying that generic type parameters flow correctly through `effectPipe` and `runEffect`.
|
package/LICENSE
CHANGED
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 `index.d.ts` declaration file with full generic types for TypeScript users.
|
|
8
8
|
|
|
9
9
|
## Installation
|
|
10
10
|
|
|
@@ -53,12 +53,12 @@ const registerUserFlow = (input) =>
|
|
|
53
53
|
)(input);
|
|
54
54
|
|
|
55
55
|
// The Imperative Shell
|
|
56
|
-
async function registerUser() {
|
|
56
|
+
async function registerUser(input) {
|
|
57
57
|
// logic is just a data structure until we pass it to runEffect
|
|
58
58
|
const logic = registerUserFlow(input);
|
|
59
59
|
|
|
60
60
|
// runEffect performs the actual async work
|
|
61
|
-
const result = await runEffect(logic
|
|
61
|
+
const result = await runEffect(logic);
|
|
62
62
|
|
|
63
63
|
if (result.type === 'Success') {
|
|
64
64
|
console.log('User created:', result.value);
|
|
@@ -105,38 +105,45 @@ Returns an object `{ type: 'Success', value }`. Represents a successful computat
|
|
|
105
105
|
|
|
106
106
|
Returns an object `{ type: 'Failure', error, initialInput }`. Represents a failed computation. Stops the pipeline immediately.
|
|
107
107
|
|
|
108
|
-
### `Command(cmdFn, nextFn)`
|
|
108
|
+
### `Command(cmdFn, nextFn, meta)`
|
|
109
109
|
|
|
110
|
-
Returns an object `{ type: 'Command', cmd, next }`.
|
|
110
|
+
Returns an object `{ type: 'Command', cmd, next, meta }`.
|
|
111
111
|
|
|
112
112
|
- `cmdFn`: A function (sync or async) that performs the side effect.
|
|
113
113
|
- `nextFn`: A function that receives the result of `cmdFn` and returns the next Effect (Success, Failure, or another Command).
|
|
114
|
+
- `meta`: Optional metadata.
|
|
114
115
|
|
|
115
116
|
### `effectPipe(...functions)`
|
|
116
117
|
|
|
117
118
|
A combinator that runs functions in sequence. It automatically handles unpacking `Success` values and passing them to the next function. If a `Failure` occurs, the pipe stops.
|
|
118
119
|
|
|
119
|
-
### `runEffect(effect,
|
|
120
|
+
### `runEffect(effect, context = {})`
|
|
120
121
|
|
|
121
|
-
The interpreter. It takes an `effect` object, executes any nested Commands recursively using `async/await`, and returns the final `Success` or `Failure`.
|
|
122
|
+
The interpreter. It takes an `effect` object, executes any nested Commands recursively using `async/await`, and returns the final `Success` or `Failure`. The optional `context` object is _only_ passed to the command interceptor configured via the `onBeforeCommand` option in `configureEffect` (see below). Additionally, `context.flowName` may be used for naming workflows in telemetry.
|
|
122
123
|
|
|
123
124
|
---
|
|
124
125
|
|
|
125
|
-
### `
|
|
126
|
+
### `configureEffect(options)`
|
|
126
127
|
|
|
127
|
-
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).
|
|
128
|
+
A configuration function that injects observability, tracing, or logging interceptors into the `runEffect` interpreter. By default, **Pure Effect** executes with zero overhead. By providing `onRun` and `onStep` callbacks, you can wrap pipeline executions and individual commands (e.g., inside OpenTelemetry spans). Please see **opentelemetry-example.js** for a quick example.
|
|
128
129
|
|
|
129
|
-
|
|
130
|
+
`configureEffect` also accepts `onBeforeCommand`, which can be used to intercept each `Command` and the context passed to `runEffect` before execution.
|
|
130
131
|
|
|
131
132
|
- `onRun (effect, pipeline, flowName)`
|
|
132
133
|
Fires once per `runEffect` call. It wraps the entire workflow execution.
|
|
133
134
|
|
|
134
135
|
- `effect`: The initial state of the effect tree (useful for extracting `initialInput`).
|
|
135
136
|
- `pipeline`: The actual interpreter. You must `await pipeline()` inside this callback to run the logic.
|
|
136
|
-
- `flowName`: The optional name of the workflow passed to runEffect
|
|
137
|
+
- `flowName`: The optional name of the workflow passed to `runEffect`.
|
|
137
138
|
|
|
138
139
|
- `onStep (name, type, op)`
|
|
139
140
|
Fires every time a `Command` is executed.
|
|
141
|
+
|
|
140
142
|
- `name`: The name of the command function (e.g., `cmdFindUser`).
|
|
141
143
|
- `type`: Effect type.
|
|
142
144
|
- `op`: The actual side-effect function. You must `await op()` inside this callback and return its result.
|
|
145
|
+
|
|
146
|
+
- `onBeforeCommand (command, context)`
|
|
147
|
+
Fires before a `Command` is executed. Ideal for inspecting metadata and context. If you throw, the pipeline stops immediately.
|
|
148
|
+
- `command`: The `Command` object.
|
|
149
|
+
- `context`: The context object passed to `runEffect`, if any.
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
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 Effect<T, E = unknown> =
|
|
22
|
+
| SuccessState<T>
|
|
23
|
+
| FailureState<E>
|
|
24
|
+
| CommandState<any, T, E>;
|
|
25
|
+
|
|
26
|
+
export declare function Success<T>(value: T): SuccessState<T>;
|
|
27
|
+
|
|
28
|
+
export declare function Failure<E = unknown>(
|
|
29
|
+
error: E,
|
|
30
|
+
initialInput?: unknown
|
|
31
|
+
): 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 effectPipe<A, B, E = unknown>(
|
|
40
|
+
f1: (a: A) => Effect<B, E>
|
|
41
|
+
): (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 = (
|
|
105
|
+
name: string,
|
|
106
|
+
type: string,
|
|
107
|
+
op: () => Promise<unknown>
|
|
108
|
+
) => Promise<unknown>;
|
|
109
|
+
|
|
110
|
+
export type RunWrapper = (
|
|
111
|
+
effect: Effect<unknown>,
|
|
112
|
+
op: () => Promise<SuccessState<unknown> | FailureState<unknown>>,
|
|
113
|
+
flowName?: string
|
|
114
|
+
) => Promise<SuccessState<unknown> | FailureState<unknown>>;
|
|
115
|
+
|
|
116
|
+
export type CommandInterceptor = (
|
|
117
|
+
command: CommandState<unknown, unknown>,
|
|
118
|
+
context?: any
|
|
119
|
+
) => Promise<void>;
|
|
120
|
+
|
|
121
|
+
export interface EffectConfiguration {
|
|
122
|
+
onStep?: StepRunner;
|
|
123
|
+
onRun?: RunWrapper;
|
|
124
|
+
onBeforeCommand?: CommandInterceptor;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export declare function configureEffect(options: EffectConfiguration): void;
|
package/index.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* type: 'Command',
|
|
8
8
|
* cmd: () => Promise<any>|any,
|
|
9
9
|
* next: (result: any) => Effect,
|
|
10
|
+
* meta?: any,
|
|
10
11
|
* initialInput?: any
|
|
11
12
|
* }} CommandState
|
|
12
13
|
*/
|
|
@@ -35,9 +36,10 @@ const Failure = (error, initialInput) => ({ type: 'Failure', error, initialInput
|
|
|
35
36
|
* Represents a side effect to be executed later
|
|
36
37
|
* @param {() => Promise<any>|any} cmd - The side-effect function to execute
|
|
37
38
|
* @param {(result: any) => Effect} next - A function that receives the result of `cmd` and returns the next Effect
|
|
39
|
+
* @param {any} [meta] - Optional metadata
|
|
38
40
|
* @returns {CommandState}
|
|
39
41
|
*/
|
|
40
|
-
const Command = (cmd, next) => ({ type: 'Command', cmd, next });
|
|
42
|
+
const Command = (cmd, next, meta) => ({ type: 'Command', cmd, next, meta });
|
|
41
43
|
|
|
42
44
|
/**
|
|
43
45
|
* Connects an Effect to the next function in the pipeline.
|
|
@@ -55,7 +57,7 @@ const chain = (effect, fn) => {
|
|
|
55
57
|
return effect;
|
|
56
58
|
case 'Command':
|
|
57
59
|
const next = (/** @type {Effect} */ result) => chain(effect.next(result), fn);
|
|
58
|
-
return Command(effect.cmd, next);
|
|
60
|
+
return Command(effect.cmd, next, effect.meta);
|
|
59
61
|
}
|
|
60
62
|
};
|
|
61
63
|
|
|
@@ -74,54 +76,68 @@ const effectPipe = (...fns) => {
|
|
|
74
76
|
};
|
|
75
77
|
};
|
|
76
78
|
|
|
77
|
-
/** @
|
|
79
|
+
/** @typedef {(name: string, type: string, op: function) => Promise<any>} StepRunner */
|
|
80
|
+
/** @type StepRunner */
|
|
78
81
|
const defaultStepRunner = async (name, type, op) => await op();
|
|
79
82
|
|
|
80
|
-
/** @
|
|
83
|
+
/** @typedef {(effect: Effect, op: function, flowName?: string) => Promise<any>} RunWrapper */
|
|
84
|
+
/** @type RunWrapper */
|
|
81
85
|
const defaultRunWrapper = async (effect, op, flowName) => await op();
|
|
82
86
|
|
|
87
|
+
/** @typedef {(command: CommandState, context?: any) => Promise<any>} CommandInterceptor */
|
|
88
|
+
/** @type CommandInterceptor */
|
|
89
|
+
const defaultCommandInterceptor = async (command, context) => {};
|
|
90
|
+
|
|
83
91
|
let stepRunner = defaultStepRunner;
|
|
84
92
|
let runWrapper = defaultRunWrapper;
|
|
93
|
+
let commandInterceptor = defaultCommandInterceptor;
|
|
85
94
|
|
|
86
95
|
/**
|
|
87
|
-
*
|
|
88
|
-
*
|
|
96
|
+
* @typedef {Object} EffectConfiguration
|
|
97
|
+
* @property {StepRunner} [onStep] - Fires once per runEffect call. It wraps the entire workflow execution.
|
|
98
|
+
* @property {RunWrapper} [onRun] - Fires every time a Command is executed.
|
|
99
|
+
* @property {CommandInterceptor} [onBeforeCommand] - Intercepts a Command and any context passed to runEffect before execution.
|
|
100
|
+
*/
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Configures the global behavior of the Effect runner, including the command interceptor and telemetry.
|
|
89
104
|
*
|
|
90
|
-
* @param {
|
|
105
|
+
* @param {EffectConfiguration} options - The configuration object for the effect pipeline.
|
|
91
106
|
*/
|
|
92
|
-
const
|
|
107
|
+
const configureEffect = (options) => {
|
|
93
108
|
stepRunner = options.onStep ? options.onStep : defaultStepRunner;
|
|
94
109
|
runWrapper = options.onRun ? options.onRun : defaultRunWrapper;
|
|
110
|
+
commandInterceptor = options.onBeforeCommand ? options.onBeforeCommand : defaultCommandInterceptor;
|
|
95
111
|
};
|
|
96
112
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
113
|
+
const runEffect =
|
|
114
|
+
/**
|
|
115
|
+
* The Interpreter
|
|
116
|
+
* Iterates through the Effect tree, executing Commands and handling async flow.
|
|
117
|
+
*
|
|
118
|
+
* @param {Effect} effect - The Effect tree returned by a pipeline
|
|
119
|
+
* @param {any} [context] - Optional context object passed to the Command Interceptor
|
|
120
|
+
* @returns {Promise<SuccessState | FailureState>}
|
|
121
|
+
*/
|
|
122
|
+
async function runEffect(effect, context = {}) {
|
|
123
|
+
return runWrapper(
|
|
124
|
+
effect,
|
|
125
|
+
async () => {
|
|
126
|
+
while (effect.type === 'Command') {
|
|
127
|
+
const cmdName = effect.cmd.name || 'anonymous';
|
|
128
|
+
try {
|
|
129
|
+
await commandInterceptor(effect, context);
|
|
130
|
+
const result = await stepRunner(cmdName, 'Command', effect.cmd);
|
|
131
|
+
effect = effect.next(result);
|
|
132
|
+
} catch (e) {
|
|
133
|
+
return Failure(e, effect.initialInput);
|
|
134
|
+
}
|
|
118
135
|
}
|
|
119
|
-
}
|
|
120
136
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
}
|
|
137
|
+
return effect;
|
|
138
|
+
},
|
|
139
|
+
context?.flowName || ''
|
|
140
|
+
);
|
|
141
|
+
};
|
|
126
142
|
|
|
127
|
-
export { Success, Failure, Command, effectPipe, runEffect,
|
|
143
|
+
export { Success, Failure, Command, effectPipe, runEffect, configureEffect };
|
package/opentelemetry-example.js
CHANGED
|
@@ -4,7 +4,9 @@ import { NodeSDK } from '@opentelemetry/sdk-node';
|
|
|
4
4
|
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto';
|
|
5
5
|
import { SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base';
|
|
6
6
|
import { trace, SpanStatusCode } from '@opentelemetry/api';
|
|
7
|
-
import {
|
|
7
|
+
import { configureEffect } from './index.js';
|
|
8
|
+
|
|
9
|
+
/** @import { RunWrapper, StepRunner } from "./index.js" */
|
|
8
10
|
|
|
9
11
|
const traceExporter = new OTLPTraceExporter({
|
|
10
12
|
url: 'http://localhost:4318/v1/traces',
|
|
@@ -28,12 +30,13 @@ process.on('SIGTERM', () => {
|
|
|
28
30
|
|
|
29
31
|
export function enableTelemetry() {
|
|
30
32
|
const tracer = trace.getTracer('pure-effect-test');
|
|
31
|
-
|
|
32
|
-
|
|
33
|
+
configureEffect({
|
|
34
|
+
/** @type RunWrapper */
|
|
35
|
+
onRun: (effect, pipeline, flowName) => {
|
|
33
36
|
return tracer.startActiveSpan('Effect Pipeline', async (rootSpan) => {
|
|
34
37
|
try {
|
|
35
38
|
rootSpan.setAttribute('effect.initialInput', JSON.stringify(effect.initialInput));
|
|
36
|
-
rootSpan.setAttribute('effect.flow', flowName);
|
|
39
|
+
rootSpan.setAttribute('effect.flow', flowName || '');
|
|
37
40
|
|
|
38
41
|
const result = await pipeline();
|
|
39
42
|
|
|
@@ -56,8 +59,8 @@ export function enableTelemetry() {
|
|
|
56
59
|
}
|
|
57
60
|
});
|
|
58
61
|
},
|
|
59
|
-
|
|
60
|
-
onStep: (
|
|
62
|
+
/** @type StepRunner */
|
|
63
|
+
onStep: (name, type, op) => {
|
|
61
64
|
return tracer.startActiveSpan(name, async (span) => {
|
|
62
65
|
span.setAttribute('effect.type', type);
|
|
63
66
|
try {
|
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.5.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,9 +1,11 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
3
|
import { strict as assert } from 'assert';
|
|
4
|
-
import { Success, Failure, Command, effectPipe, runEffect } from '../index.js';
|
|
4
|
+
import { Success, Failure, Command, effectPipe, runEffect, configureEffect } from '../index.js';
|
|
5
5
|
import { enableTelemetry } from '../opentelemetry-example.js';
|
|
6
6
|
|
|
7
|
+
/** @import { CommandInterceptor } from "../index.js" */
|
|
8
|
+
|
|
7
9
|
/** @typedef {{id?: number, email: string, password: string}} User */
|
|
8
10
|
|
|
9
11
|
const db = {
|
|
@@ -57,7 +59,7 @@ const registerUserFlow = (/** @type {User} */ input) =>
|
|
|
57
59
|
)(input);
|
|
58
60
|
|
|
59
61
|
async function registerUser(/** @type {User} */ input) {
|
|
60
|
-
return await runEffect(registerUserFlow(input), 'registerUser');
|
|
62
|
+
return await runEffect(registerUserFlow(input), { flowName: 'registerUser' });
|
|
61
63
|
}
|
|
62
64
|
|
|
63
65
|
describe('Pure Effect', function () {
|
|
@@ -78,6 +80,16 @@ describe('Pure Effect', function () {
|
|
|
78
80
|
assert.equal(step2.cmd.name, 'cmdSaveUser');
|
|
79
81
|
});
|
|
80
82
|
|
|
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
|
+
const input = { email: 'context@test.com', password: 'password123' };
|
|
89
|
+
const result = await runEffect(registerUserFlow(input), { env: 'test' });
|
|
90
|
+
configureEffect({ onBeforeCommand: undefined });
|
|
91
|
+
});
|
|
92
|
+
|
|
81
93
|
it('should return Success after runEffect with telemetry disabled', async function () {
|
|
82
94
|
const input = { email: 'test-no-telemetry@test.com', password: 'password123' };
|
|
83
95
|
const result = await registerUser(input);
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { expectType, expectError } from 'tsd';
|
|
2
|
+
import { Success, Failure, Command, effectPipe, runEffect } from '../index.js';
|
|
3
|
+
import type { SuccessState, FailureState, CommandState, Effect } from '../index.js';
|
|
4
|
+
|
|
5
|
+
interface User { email: string; password: string; }
|
|
6
|
+
interface SavedUser { id: number; email: string; }
|
|
7
|
+
|
|
8
|
+
// --- Success ---
|
|
9
|
+
|
|
10
|
+
const s = Success(42);
|
|
11
|
+
expectType<SuccessState<number>>(s);
|
|
12
|
+
expectError(Success()); // missing argument
|
|
13
|
+
|
|
14
|
+
// --- Failure ---
|
|
15
|
+
|
|
16
|
+
const f = Failure('oops');
|
|
17
|
+
expectType<FailureState<string>>(f);
|
|
18
|
+
|
|
19
|
+
// --- Command ---
|
|
20
|
+
|
|
21
|
+
const cmd = Command(
|
|
22
|
+
async () => ({ id: 1, email: 'a@b.com' } as SavedUser),
|
|
23
|
+
(saved) => { expectType<SavedUser>(saved); return Success(saved); }
|
|
24
|
+
);
|
|
25
|
+
expectType<CommandState<SavedUser, SavedUser, unknown>>(cmd);
|
|
26
|
+
|
|
27
|
+
// --- effectPipe type propagation ---
|
|
28
|
+
|
|
29
|
+
const step1 = (input: User) => Success(input);
|
|
30
|
+
const step2 = (user: User) => Command(async () => ({ id: 1, ...user } as SavedUser), (s) => Success(s));
|
|
31
|
+
|
|
32
|
+
const flow = effectPipe(step1, step2);
|
|
33
|
+
expectType<Effect<SavedUser>>(flow({ email: 'a@b.com', password: 'secret123' }));
|
|
34
|
+
expectError(flow({ email: 'a@b.com' })); // missing password
|
|
35
|
+
|
|
36
|
+
// --- runEffect return type ---
|
|
37
|
+
|
|
38
|
+
const result = await runEffect(flow({ email: 'a@b.com', password: 'secret123' }));
|
|
39
|
+
expectType<SuccessState<SavedUser> | FailureState<unknown>>(result);
|
|
40
|
+
|
|
41
|
+
// --- discriminated union narrowing ---
|
|
42
|
+
|
|
43
|
+
if (result.type === 'Success') {
|
|
44
|
+
expectType<SavedUser>(result.value);
|
|
45
|
+
} else {
|
|
46
|
+
expectType<unknown>(result.error);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// --- Failure error type flows through runEffect ---
|
|
50
|
+
|
|
51
|
+
const failFlow = effectPipe((input: User): Effect<User, string> => Failure<string>('bad'));
|
|
52
|
+
const failResult = await runEffect(failFlow({ email: 'a@b.com', password: 'x' }));
|
|
53
|
+
expectType<SuccessState<User> | FailureState<string>>(failResult);
|