pure-effect 0.3.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2025 Aycan Gulez
3
+ Copyright (c) 2025-2026 Aycan Gulez
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -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, 'registerUser');
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, flowName = '')`
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`. It also accepts an optional `flowName` that comes in handy for telemetry.
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
- ### `configureTelemetry(options)`
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
- Please see **opentelemetry-example.js** to see a quick example.
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.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
- /** @type {(name: string, type: string, op: function) => Promise<any>} */
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
- /** @type {(effect: Effect, op: function, flowName?: string) => Promise<any>} */
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
- * Enables OpenTelemetry support if it receives an OpenTelemetry onStep option.
88
- * Otherwise OpenTelemetry support is disabled.
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 {any} options
105
+ * @param {EffectConfiguration} options - The configuration object for the effect pipeline.
91
106
  */
92
- const configureTelemetry = (/** @type any **/ options) => {
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
- * 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);
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
- return effect;
122
- },
123
- flowName || ''
124
- );
125
- }
137
+ return effect;
138
+ },
139
+ context?.flowName || ''
140
+ );
141
+ };
126
142
 
127
- export { Success, Failure, Command, effectPipe, runEffect, configureTelemetry };
143
+ export { Success, Failure, Command, effectPipe, runEffect, configureEffect };
@@ -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 { configureTelemetry } from './index.js';
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
- configureTelemetry({
32
- onRun: (/** @type any */ effect, /** @type any */ pipeline, /** @type string */ flowName) => {
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: (/** @type string */ name, /** @type string */ type, /** @type function */ op) => {
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,6 +1,6 @@
1
1
  {
2
2
  "name": "pure-effect",
3
- "version": "0.3.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",
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);