pure-effect 0.2.0 → 0.4.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/LICENSE +1 -1
- package/README.md +35 -7
- package/index.js +76 -22
- package/opentelemetry-example.js +91 -0
- package/package.json +2 -1
- package/test/all.js +31 -5
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -53,7 +53,7 @@ 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
|
|
|
@@ -77,7 +77,7 @@ The biggest benefit of **Pure Effect** is testability. Because `registerUserFlow
|
|
|
77
77
|
const badInput = { email: 'bad-email', password: '123' };
|
|
78
78
|
const result = registerUserFlow(badInput);
|
|
79
79
|
|
|
80
|
-
assert.deepEqual(result, Failure('Invalid email.'));
|
|
80
|
+
assert.deepEqual(result, Failure('Invalid email format.', badInput));
|
|
81
81
|
// ✅ Logic tested instantly, no async needed.
|
|
82
82
|
|
|
83
83
|
// 2. Test Flow Intent (Introspection)
|
|
@@ -103,19 +103,47 @@ Returns an object `{ type: 'Success', value }`. Represents a successful computat
|
|
|
103
103
|
|
|
104
104
|
### `Failure(error)`
|
|
105
105
|
|
|
106
|
-
Returns an object `{ type: 'Failure', error }`. Represents a failed computation. Stops the pipeline immediately.
|
|
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.
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
### `configureEffect(options)`
|
|
127
|
+
|
|
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.
|
|
129
|
+
|
|
130
|
+
`configureEffect` also accepts `onBeforeCommand`, which can be used to intercept each `Command` and the context passed to `runEffect` before execution.
|
|
131
|
+
|
|
132
|
+
- `onRun (effect, pipeline, flowName)`
|
|
133
|
+
Fires once per `runEffect` call. It wraps the entire workflow execution.
|
|
134
|
+
|
|
135
|
+
- `effect`: The initial state of the effect tree (useful for extracting `initialInput`).
|
|
136
|
+
- `pipeline`: The actual interpreter. You must `await pipeline()` inside this callback to run the logic.
|
|
137
|
+
- `flowName`: The optional name of the workflow passed to `runEffect`.
|
|
138
|
+
|
|
139
|
+
- `onStep (name, type, op)`
|
|
140
|
+
Fires every time a `Command` is executed.
|
|
141
|
+
|
|
142
|
+
- `name`: The name of the command function (e.g., `cmdFindUser`).
|
|
143
|
+
- `type`: Effect type.
|
|
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.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
|
-
/** @typedef {{ type: 'Success', value: any }} SuccessState */
|
|
4
|
-
/** @typedef {{ type: 'Failure', error: any }} FailureState */
|
|
3
|
+
/** @typedef {{ type: 'Success', value: any, initialInput?: any }} SuccessState */
|
|
4
|
+
/** @typedef {{ type: 'Failure', error: any, initialInput?: any }} FailureState */
|
|
5
5
|
/**
|
|
6
6
|
* @typedef {{
|
|
7
7
|
* type: 'Command',
|
|
8
8
|
* cmd: () => Promise<any>|any,
|
|
9
|
-
* next: (result: any) => Effect
|
|
9
|
+
* next: (result: any) => Effect,
|
|
10
|
+
* meta?: any,
|
|
11
|
+
* initialInput?: any
|
|
10
12
|
* }} CommandState
|
|
11
13
|
*/
|
|
12
14
|
|
|
@@ -25,17 +27,19 @@ const Success = (value) => ({ type: 'Success', value });
|
|
|
25
27
|
/**
|
|
26
28
|
* Represents a failed computation. Stops the pipeline execution
|
|
27
29
|
* @param {any} error - The error reason (string, Error object, etc).
|
|
30
|
+
* @param {any} [initialInput] - initial input passed to the flow (optional)
|
|
28
31
|
* @returns {FailureState}
|
|
29
32
|
*/
|
|
30
|
-
const Failure = (error) => ({ type: 'Failure', error });
|
|
33
|
+
const Failure = (error, initialInput) => ({ type: 'Failure', error, initialInput });
|
|
31
34
|
|
|
32
35
|
/**
|
|
33
36
|
* Represents a side effect to be executed later
|
|
34
37
|
* @param {() => Promise<any>|any} cmd - The side-effect function to execute
|
|
35
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
|
|
36
40
|
* @returns {CommandState}
|
|
37
41
|
*/
|
|
38
|
-
const Command = (cmd, next) => ({ type: 'Command', cmd, next });
|
|
42
|
+
const Command = (cmd, next, meta) => ({ type: 'Command', cmd, next, meta });
|
|
39
43
|
|
|
40
44
|
/**
|
|
41
45
|
* Connects an Effect to the next function in the pipeline.
|
|
@@ -53,7 +57,7 @@ const chain = (effect, fn) => {
|
|
|
53
57
|
return effect;
|
|
54
58
|
case 'Command':
|
|
55
59
|
const next = (/** @type {Effect} */ result) => chain(effect.next(result), fn);
|
|
56
|
-
return Command(effect.cmd, next);
|
|
60
|
+
return Command(effect.cmd, next, effect.meta);
|
|
57
61
|
}
|
|
58
62
|
};
|
|
59
63
|
|
|
@@ -65,25 +69,75 @@ const chain = (effect, fn) => {
|
|
|
65
69
|
* @returns {(start: any) => Effect} A function that accepts an initial input and returns the final Effect tree.
|
|
66
70
|
*/
|
|
67
71
|
const effectPipe = (...fns) => {
|
|
68
|
-
return (start) =>
|
|
72
|
+
return (start) => {
|
|
73
|
+
const effect = fns.reduce(chain, Success(start));
|
|
74
|
+
effect.initialInput = start;
|
|
75
|
+
return effect;
|
|
76
|
+
};
|
|
69
77
|
};
|
|
70
78
|
|
|
79
|
+
/** @typedef {(name: string, type: string, op: function) => Promise<any>} StepRunner */
|
|
80
|
+
/** @type StepRunner */
|
|
81
|
+
const defaultStepRunner = async (name, type, op) => await op();
|
|
82
|
+
|
|
83
|
+
/** @typedef {(effect: Effect, op: function, flowName?: string) => Promise<any>} RunWrapper */
|
|
84
|
+
/** @type RunWrapper */
|
|
85
|
+
const defaultRunWrapper = async (effect, op, flowName) => await op();
|
|
86
|
+
|
|
87
|
+
/** @typedef {(command: CommandState, context?: any) => Promise<any>} CommandInterceptor */
|
|
88
|
+
/** @type CommandInterceptor */
|
|
89
|
+
const defaultCommandInterceptor = async (command, context) => {};
|
|
90
|
+
|
|
91
|
+
let stepRunner = defaultStepRunner;
|
|
92
|
+
let runWrapper = defaultRunWrapper;
|
|
93
|
+
let commandInterceptor = defaultCommandInterceptor;
|
|
94
|
+
|
|
71
95
|
/**
|
|
72
|
-
*
|
|
73
|
-
*
|
|
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.
|
|
74
104
|
*
|
|
75
|
-
* @param {
|
|
76
|
-
* @returns {Promise<SuccessState | FailureState>}
|
|
105
|
+
* @param {EffectConfiguration} options - The configuration object for the effect pipeline.
|
|
77
106
|
*/
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
107
|
+
const configureEffect = (options) => {
|
|
108
|
+
stepRunner = options.onStep ? options.onStep : defaultStepRunner;
|
|
109
|
+
runWrapper = options.onRun ? options.onRun : defaultRunWrapper;
|
|
110
|
+
commandInterceptor = options.onBeforeCommand ? options.onBeforeCommand : defaultCommandInterceptor;
|
|
111
|
+
};
|
|
112
|
+
|
|
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
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return effect;
|
|
138
|
+
},
|
|
139
|
+
context?.flowName || ''
|
|
140
|
+
);
|
|
141
|
+
};
|
|
88
142
|
|
|
89
|
-
export { Success, Failure, Command, effectPipe, runEffect };
|
|
143
|
+
export { Success, Failure, Command, effectPipe, runEffect, configureEffect };
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { NodeSDK } from '@opentelemetry/sdk-node';
|
|
4
|
+
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto';
|
|
5
|
+
import { SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base';
|
|
6
|
+
import { trace, SpanStatusCode } from '@opentelemetry/api';
|
|
7
|
+
import { configureEffect } from './index.js';
|
|
8
|
+
|
|
9
|
+
/** @import { RunWrapper, StepRunner } from "./index.js" */
|
|
10
|
+
|
|
11
|
+
const traceExporter = new OTLPTraceExporter({
|
|
12
|
+
url: 'http://localhost:4318/v1/traces',
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const sdk = new NodeSDK({
|
|
16
|
+
serviceName: 'pure-effect-test',
|
|
17
|
+
traceExporter,
|
|
18
|
+
spanProcessor: new SimpleSpanProcessor(traceExporter),
|
|
19
|
+
instrumentations: [],
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
sdk.start();
|
|
23
|
+
|
|
24
|
+
process.on('SIGTERM', () => {
|
|
25
|
+
sdk.shutdown()
|
|
26
|
+
.then(() => console.log('Tracing terminated'))
|
|
27
|
+
.catch((error) => console.log('Error terminating tracing', error))
|
|
28
|
+
.finally(() => process.exit(0));
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export function enableTelemetry() {
|
|
32
|
+
const tracer = trace.getTracer('pure-effect-test');
|
|
33
|
+
configureEffect({
|
|
34
|
+
/** @type RunWrapper */
|
|
35
|
+
onRun: (effect, pipeline, flowName) => {
|
|
36
|
+
return tracer.startActiveSpan('Effect Pipeline', async (rootSpan) => {
|
|
37
|
+
try {
|
|
38
|
+
rootSpan.setAttribute('effect.initialInput', JSON.stringify(effect.initialInput));
|
|
39
|
+
rootSpan.setAttribute('effect.flow', flowName || '');
|
|
40
|
+
|
|
41
|
+
const result = await pipeline();
|
|
42
|
+
|
|
43
|
+
if (result.type === 'Failure') {
|
|
44
|
+
rootSpan.setStatus({
|
|
45
|
+
code: SpanStatusCode.ERROR,
|
|
46
|
+
message: String(result.error),
|
|
47
|
+
});
|
|
48
|
+
} else {
|
|
49
|
+
rootSpan.setStatus({ code: SpanStatusCode.OK });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return result;
|
|
53
|
+
} catch (/** @type any */ err) {
|
|
54
|
+
rootSpan.recordException(err);
|
|
55
|
+
rootSpan.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
|
|
56
|
+
throw err;
|
|
57
|
+
} finally {
|
|
58
|
+
rootSpan.end();
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
},
|
|
62
|
+
/** @type StepRunner */
|
|
63
|
+
onStep: (name, type, op) => {
|
|
64
|
+
return tracer.startActiveSpan(name, async (span) => {
|
|
65
|
+
span.setAttribute('effect.type', type);
|
|
66
|
+
try {
|
|
67
|
+
const result = await op();
|
|
68
|
+
try {
|
|
69
|
+
if (result !== undefined) {
|
|
70
|
+
const outputString = typeof result === 'object' ? JSON.stringify(result) : String(result);
|
|
71
|
+
span.setAttribute('effect.output', outputString);
|
|
72
|
+
}
|
|
73
|
+
} catch (serializationError) {
|
|
74
|
+
span.setAttribute('effect.output', '[Circular or Non-Serializable Data]');
|
|
75
|
+
}
|
|
76
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
77
|
+
return result;
|
|
78
|
+
} catch (/** @type any */ err) {
|
|
79
|
+
span.recordException(err);
|
|
80
|
+
span.setStatus({
|
|
81
|
+
code: SpanStatusCode.ERROR,
|
|
82
|
+
message: err.message,
|
|
83
|
+
});
|
|
84
|
+
throw err;
|
|
85
|
+
} finally {
|
|
86
|
+
span.end();
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pure-effect",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "A tiny, zero-dependency effect system for writing pure, testable JavaScript without mocks.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": "./index.js",
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"homepage": "https://github.com/aycangulez/pure-effect",
|
|
12
12
|
"license": "MIT",
|
|
13
13
|
"devDependencies": {
|
|
14
|
+
"@opentelemetry/sdk-node": "^0.211.0",
|
|
14
15
|
"@types/mocha": "^10.0.10",
|
|
15
16
|
"@types/node": "^24.10.1",
|
|
16
17
|
"mocha": "^11.7.5"
|
package/test/all.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
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
|
+
import { enableTelemetry } from '../opentelemetry-example.js';
|
|
6
|
+
|
|
7
|
+
/** @import { CommandInterceptor } from "../index.js" */
|
|
5
8
|
|
|
6
9
|
/** @typedef {{id?: number, email: string, password: string}} User */
|
|
7
10
|
|
|
@@ -56,14 +59,14 @@ const registerUserFlow = (/** @type {User} */ input) =>
|
|
|
56
59
|
)(input);
|
|
57
60
|
|
|
58
61
|
async function registerUser(/** @type {User} */ input) {
|
|
59
|
-
return await runEffect(registerUserFlow(input));
|
|
62
|
+
return await runEffect(registerUserFlow(input), { flowName: 'registerUser' });
|
|
60
63
|
}
|
|
61
64
|
|
|
62
65
|
describe('Pure Effect', function () {
|
|
63
66
|
it('should return Failure when e-mail is invalid', async function () {
|
|
64
|
-
const
|
|
65
|
-
const
|
|
66
|
-
assert.deepEqual(
|
|
67
|
+
const badInput = { email: 'bad-email', password: '123' };
|
|
68
|
+
const result = registerUserFlow(badInput);
|
|
69
|
+
assert.deepEqual(result, Failure('Invalid email format.', badInput));
|
|
67
70
|
});
|
|
68
71
|
|
|
69
72
|
it('should walk through the call tree', async function () {
|
|
@@ -76,4 +79,27 @@ describe('Pure Effect', function () {
|
|
|
76
79
|
assert.equal(step2.type, 'Command');
|
|
77
80
|
assert.equal(step2.cmd.name, 'cmdSaveUser');
|
|
78
81
|
});
|
|
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
|
+
|
|
93
|
+
it('should return Success after runEffect with telemetry disabled', async function () {
|
|
94
|
+
const input = { email: 'test-no-telemetry@test.com', password: 'password123' };
|
|
95
|
+
const result = await registerUser(input);
|
|
96
|
+
assert.equal(result.type, 'Success');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('should return Success after runEffect with telemetry enabled', async function () {
|
|
100
|
+
enableTelemetry();
|
|
101
|
+
const input = { email: 'test-telemetry@test.com', password: 'password123' };
|
|
102
|
+
const result = await registerUser(input);
|
|
103
|
+
assert.equal(result.type, 'Success');
|
|
104
|
+
});
|
|
79
105
|
});
|