pure-effect 0.1.2 → 0.3.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/README.md CHANGED
@@ -4,6 +4,8 @@
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** comes with JSDoc type annotations, so it can be used with TypeScript as well.
8
+
7
9
  ## Installation
8
10
 
9
11
  ```bash
@@ -56,7 +58,7 @@ async function registerUser() {
56
58
  const logic = registerUserFlow(input);
57
59
 
58
60
  // runEffect performs the actual async work
59
- const result = await runEffect(logic);
61
+ const result = await runEffect(logic, 'registerUser');
60
62
 
61
63
  if (result.type === 'Success') {
62
64
  console.log('User created:', result.value);
@@ -75,7 +77,7 @@ The biggest benefit of **Pure Effect** is testability. Because `registerUserFlow
75
77
  const badInput = { email: 'bad-email', password: '123' };
76
78
  const result = registerUserFlow(badInput);
77
79
 
78
- assert.deepEqual(result, Failure('Invalid email.'));
80
+ assert.deepEqual(result, Failure('Invalid email format.', badInput));
79
81
  // ✅ Logic tested instantly, no async needed.
80
82
 
81
83
  // 2. Test Flow Intent (Introspection)
@@ -101,7 +103,7 @@ Returns an object `{ type: 'Success', value }`. Represents a successful computat
101
103
 
102
104
  ### `Failure(error)`
103
105
 
104
- 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.
105
107
 
106
108
  ### `Command(cmdFn, nextFn)`
107
109
 
@@ -114,6 +116,27 @@ Returns an object `{ type: 'Command', cmd, next }`.
114
116
 
115
117
  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.
116
118
 
117
- ### `runEffect(effect)`
119
+ ### `runEffect(effect, flowName = '')`
120
+
121
+ The interpreter. It takes an `effect` object, executes any nested Commands recursively using `async/await`, and returns the final `Success` or `Failure`. It also accepts an optional `flowName` that comes in handy for telemetry.
122
+
123
+ ---
124
+
125
+ ### `configureTelemetry(options)`
126
+
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
+
129
+ Please see **opentelemetry-example.js** to see a quick example.
130
+
131
+ - `onRun (effect, pipeline, flowName)`
132
+ Fires once per `runEffect` call. It wraps the entire workflow execution.
133
+
134
+ - `effect`: The initial state of the effect tree (useful for extracting `initialInput`).
135
+ - `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.
118
137
 
119
- The interpreter. It takes an `effect` object, executes any nested Commands recursively using `async/await`, and returns the final `Success` or `Failure`.
138
+ - `onStep (name, type, op)`
139
+ Fires every time a `Command` is executed.
140
+ - `name`: The name of the command function (e.g., `cmdFindUser`).
141
+ - `type`: Effect type.
142
+ - `op`: The actual side-effect function. You must `await op()` inside this callback and return its result.
package/index.js CHANGED
@@ -1,7 +1,52 @@
1
+ // @ts-check
2
+
3
+ /** @typedef {{ type: 'Success', value: any, initialInput?: any }} SuccessState */
4
+ /** @typedef {{ type: 'Failure', error: any, initialInput?: any }} FailureState */
5
+ /**
6
+ * @typedef {{
7
+ * type: 'Command',
8
+ * cmd: () => Promise<any>|any,
9
+ * next: (result: any) => Effect,
10
+ * initialInput?: any
11
+ * }} CommandState
12
+ */
13
+
14
+ /**
15
+ * The Union type for all possible states
16
+ * @typedef {SuccessState | FailureState | CommandState} Effect
17
+ */
18
+
19
+ /**
20
+ * Represents a successful computation
21
+ * @param {any} value - The result value
22
+ * @returns {SuccessState}
23
+ */
1
24
  const Success = (value) => ({ type: 'Success', value });
2
- const Failure = (error) => ({ type: 'Failure', error });
25
+
26
+ /**
27
+ * Represents a failed computation. Stops the pipeline execution
28
+ * @param {any} error - The error reason (string, Error object, etc).
29
+ * @param {any} [initialInput] - initial input passed to the flow (optional)
30
+ * @returns {FailureState}
31
+ */
32
+ const Failure = (error, initialInput) => ({ type: 'Failure', error, initialInput });
33
+
34
+ /**
35
+ * Represents a side effect to be executed later
36
+ * @param {() => Promise<any>|any} cmd - The side-effect function to execute
37
+ * @param {(result: any) => Effect} next - A function that receives the result of `cmd` and returns the next Effect
38
+ * @returns {CommandState}
39
+ */
3
40
  const Command = (cmd, next) => ({ type: 'Command', cmd, next });
4
41
 
42
+ /**
43
+ * Connects an Effect to the next function in the pipeline.
44
+ * Handles the branching logic for Success, Failure, and Command.
45
+ *
46
+ * @param {Effect} effect - The current Effect object
47
+ * @param {(value: any) => Effect} fn - The next function to run if the current effect is a Success
48
+ * @returns {Effect} The composed Effect
49
+ */
5
50
  const chain = (effect, fn) => {
6
51
  switch (effect.type) {
7
52
  case 'Success':
@@ -9,24 +54,74 @@ const chain = (effect, fn) => {
9
54
  case 'Failure':
10
55
  return effect;
11
56
  case 'Command':
12
- const next = (result) => chain(effect.next(result), fn);
57
+ const next = (/** @type {Effect} */ result) => chain(effect.next(result), fn);
13
58
  return Command(effect.cmd, next);
14
59
  }
15
60
  };
16
61
 
62
+ /**
63
+ * Composes a list of functions into a single Effect pipeline.
64
+ * Each function receives the output of the previous one.
65
+ *
66
+ * @param {...(input: any) => Effect} fns - Functions that return Success, Failure, or Command.
67
+ * @returns {(start: any) => Effect} A function that accepts an initial input and returns the final Effect tree.
68
+ */
17
69
  const effectPipe = (...fns) => {
18
- return (start) => fns.reduce(chain, Success(start));
70
+ return (start) => {
71
+ const effect = fns.reduce(chain, Success(start));
72
+ effect.initialInput = start;
73
+ return effect;
74
+ };
19
75
  };
20
76
 
21
- async function runEffect(effect) {
22
- while (effect.type === 'Command') {
23
- try {
24
- effect = effect.next(await effect.cmd());
25
- } catch (e) {
26
- return Failure(e);
27
- }
28
- }
29
- return effect;
77
+ /** @type {(name: string, type: string, op: function) => Promise<any>} */
78
+ const defaultStepRunner = async (name, type, op) => await op();
79
+
80
+ /** @type {(effect: Effect, op: function, flowName?: string) => Promise<any>} */
81
+ const defaultRunWrapper = async (effect, op, flowName) => await op();
82
+
83
+ let stepRunner = defaultStepRunner;
84
+ let runWrapper = defaultRunWrapper;
85
+
86
+ /**
87
+ * Enables OpenTelemetry support if it receives an OpenTelemetry onStep option.
88
+ * Otherwise OpenTelemetry support is disabled.
89
+ *
90
+ * @param {any} options
91
+ */
92
+ const configureTelemetry = (/** @type any **/ options) => {
93
+ stepRunner = options.onStep ? options.onStep : defaultStepRunner;
94
+ runWrapper = options.onRun ? options.onRun : defaultRunWrapper;
95
+ };
96
+
97
+ /**
98
+ * The Interpreter
99
+ * Iterates through the Effect tree, executing Commands and handling async flow.
100
+ *
101
+ * @param {Effect} effect - The Effect tree returned by a pipeline
102
+ * @param {string} flowName - Name of the workflow
103
+ * @returns {Promise<SuccessState | FailureState>}
104
+ */
105
+ async function runEffect(effect, flowName = '') {
106
+ return runWrapper(
107
+ effect,
108
+ async () => {
109
+ while (effect.type === 'Command') {
110
+ const currentCmd = effect.cmd;
111
+ const cmdName = currentCmd.name || 'anonymous';
112
+
113
+ try {
114
+ const result = await stepRunner(cmdName, 'Command', currentCmd);
115
+ effect = effect.next(result);
116
+ } catch (e) {
117
+ return Failure(e, effect.initialInput);
118
+ }
119
+ }
120
+
121
+ return effect;
122
+ },
123
+ flowName || ''
124
+ );
30
125
  }
31
126
 
32
- export { Success, Failure, Command, effectPipe, runEffect };
127
+ export { Success, Failure, Command, effectPipe, runEffect, configureTelemetry };
@@ -0,0 +1,88 @@
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 { configureTelemetry } from './index.js';
8
+
9
+ const traceExporter = new OTLPTraceExporter({
10
+ url: 'http://localhost:4318/v1/traces',
11
+ });
12
+
13
+ const sdk = new NodeSDK({
14
+ serviceName: 'pure-effect-test',
15
+ traceExporter,
16
+ spanProcessor: new SimpleSpanProcessor(traceExporter),
17
+ instrumentations: [],
18
+ });
19
+
20
+ sdk.start();
21
+
22
+ process.on('SIGTERM', () => {
23
+ sdk.shutdown()
24
+ .then(() => console.log('Tracing terminated'))
25
+ .catch((error) => console.log('Error terminating tracing', error))
26
+ .finally(() => process.exit(0));
27
+ });
28
+
29
+ export function enableTelemetry() {
30
+ const tracer = trace.getTracer('pure-effect-test');
31
+ configureTelemetry({
32
+ onRun: (/** @type any */ effect, /** @type any */ pipeline, /** @type string */ flowName) => {
33
+ return tracer.startActiveSpan('Effect Pipeline', async (rootSpan) => {
34
+ try {
35
+ rootSpan.setAttribute('effect.initialInput', JSON.stringify(effect.initialInput));
36
+ rootSpan.setAttribute('effect.flow', flowName);
37
+
38
+ const result = await pipeline();
39
+
40
+ if (result.type === 'Failure') {
41
+ rootSpan.setStatus({
42
+ code: SpanStatusCode.ERROR,
43
+ message: String(result.error),
44
+ });
45
+ } else {
46
+ rootSpan.setStatus({ code: SpanStatusCode.OK });
47
+ }
48
+
49
+ return result;
50
+ } catch (/** @type any */ err) {
51
+ rootSpan.recordException(err);
52
+ rootSpan.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
53
+ throw err;
54
+ } finally {
55
+ rootSpan.end();
56
+ }
57
+ });
58
+ },
59
+
60
+ onStep: (/** @type string */ name, /** @type string */ type, /** @type function */ op) => {
61
+ return tracer.startActiveSpan(name, async (span) => {
62
+ span.setAttribute('effect.type', type);
63
+ try {
64
+ const result = await op();
65
+ try {
66
+ if (result !== undefined) {
67
+ const outputString = typeof result === 'object' ? JSON.stringify(result) : String(result);
68
+ span.setAttribute('effect.output', outputString);
69
+ }
70
+ } catch (serializationError) {
71
+ span.setAttribute('effect.output', '[Circular or Non-Serializable Data]');
72
+ }
73
+ span.setStatus({ code: SpanStatusCode.OK });
74
+ return result;
75
+ } catch (/** @type any */ err) {
76
+ span.recordException(err);
77
+ span.setStatus({
78
+ code: SpanStatusCode.ERROR,
79
+ message: err.message,
80
+ });
81
+ throw err;
82
+ } finally {
83
+ span.end();
84
+ }
85
+ });
86
+ },
87
+ });
88
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pure-effect",
3
- "version": "0.1.2",
3
+ "version": "0.3.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,9 @@
11
11
  "homepage": "https://github.com/aycangulez/pure-effect",
12
12
  "license": "MIT",
13
13
  "devDependencies": {
14
+ "@opentelemetry/sdk-node": "^0.211.0",
15
+ "@types/mocha": "^10.0.10",
16
+ "@types/node": "^24.10.1",
14
17
  "mocha": "^11.7.5"
15
18
  }
16
19
  }
package/test/all.js CHANGED
@@ -1,19 +1,24 @@
1
+ // @ts-check
2
+
1
3
  import { strict as assert } from 'assert';
2
4
  import { Success, Failure, Command, effectPipe, runEffect } from '../index.js';
5
+ import { enableTelemetry } from '../opentelemetry-example.js';
6
+
7
+ /** @typedef {{id?: number, email: string, password: string}} User */
3
8
 
4
9
  const db = {
5
10
  users: new Map(),
6
- async findUserByEmail(email) {
11
+ async findUserByEmail(/** @type string */ email) {
7
12
  return this.users.get(email) || null;
8
13
  },
9
- async saveUser(user) {
10
- const u = { id: Date.now(), ...user };
14
+ async saveUser(/** @type {User} */ user) {
15
+ const u = { ...user, id: Date.now() };
11
16
  this.users.set(user.email, u);
12
17
  return u;
13
18
  },
14
19
  };
15
20
 
16
- function validateRegistration(input) {
21
+ function validateRegistration(/** @type {User} */ input) {
17
22
  const { email, password } = input;
18
23
  if (!email?.includes('@')) {
19
24
  return Failure('Invalid email format.');
@@ -24,26 +29,26 @@ function validateRegistration(input) {
24
29
  return Success(input);
25
30
  }
26
31
 
27
- function findUserByEmail(email) {
32
+ function findUserByEmail(/** @type string */ email) {
28
33
  const cmdFindUser = () => db.findUserByEmail(email);
29
- const next = (foundUser) => Success(foundUser);
34
+ const next = (/** @type {User} */ foundUser) => Success(foundUser);
30
35
  return Command(cmdFindUser, next);
31
36
  }
32
37
 
33
- function ensureEmailIsAvailable(foundUser) {
38
+ function ensureEmailIsAvailable(/** @type {User} */ foundUser) {
34
39
  return foundUser ? Failure('Email already in use.') : Success(true);
35
40
  }
36
41
 
37
- function saveUser(input) {
42
+ function saveUser(/** @type {User} */ input) {
38
43
  const { email, password } = input;
39
44
  const hashedPassword = `hashed_${password}`;
40
45
  const userToSave = { email, password: hashedPassword };
41
46
  const cmdSaveUser = () => db.saveUser(userToSave);
42
- const next = (savedUser) => Success(savedUser);
47
+ const next = (/** @type {User} */ savedUser) => Success(savedUser);
43
48
  return Command(cmdSaveUser, next);
44
49
  }
45
50
 
46
- const registerUserFlow = (input) =>
51
+ const registerUserFlow = (/** @type {User} */ input) =>
47
52
  effectPipe(
48
53
  validateRegistration,
49
54
  () => findUserByEmail(input.email),
@@ -51,15 +56,15 @@ const registerUserFlow = (input) =>
51
56
  () => saveUser(input)
52
57
  )(input);
53
58
 
54
- async function registerUser(input) {
55
- return await runEffect(registerUserFlow(input));
59
+ async function registerUser(/** @type {User} */ input) {
60
+ return await runEffect(registerUserFlow(input), 'registerUser');
56
61
  }
57
62
 
58
63
  describe('Pure Effect', function () {
59
64
  it('should return Failure when e-mail is invalid', async function () {
60
- const input = { email: 'bad-email', password: '123' };
61
- const effect = await registerUser(input);
62
- assert.deepEqual(effect, Failure('Invalid email format.'));
65
+ const badInput = { email: 'bad-email', password: '123' };
66
+ const result = registerUserFlow(badInput);
67
+ assert.deepEqual(result, Failure('Invalid email format.', badInput));
63
68
  });
64
69
 
65
70
  it('should walk through the call tree', async function () {
@@ -72,4 +77,17 @@ describe('Pure Effect', function () {
72
77
  assert.equal(step2.type, 'Command');
73
78
  assert.equal(step2.cmd.name, 'cmdSaveUser');
74
79
  });
80
+
81
+ it('should return Success after runEffect with telemetry disabled', async function () {
82
+ const input = { email: 'test-no-telemetry@test.com', password: 'password123' };
83
+ const result = await registerUser(input);
84
+ assert.equal(result.type, 'Success');
85
+ });
86
+
87
+ it('should return Success after runEffect with telemetry enabled', async function () {
88
+ enableTelemetry();
89
+ const input = { email: 'test-telemetry@test.com', password: 'password123' };
90
+ const result = await registerUser(input);
91
+ assert.equal(result.type, 'Success');
92
+ });
75
93
  });