pure-effect 0.6.0 → 0.8.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 +3 -1
- package/.dirac-symbol-index/data.db +0 -0
- package/CLAUDE.md +29 -15
- package/README.md +261 -100
- package/index.d.ts +197 -73
- package/index.js +139 -33
- package/package.json +2 -2
- package/test/all.js +196 -20
- package/test/types.test-d.ts +148 -2
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, Ask, effectPipe, runEffect, configureEffect } from '../index.js';
|
|
4
|
+
import { Success, Failure, Command, Ask, Retry, Parallel, effectPipe, runEffect, configureEffect } from '../index.js';
|
|
5
5
|
import { enableTelemetry } from '../opentelemetry-example.js';
|
|
6
6
|
|
|
7
7
|
/** @import { CommandInterceptor } from "../index.js" */
|
|
@@ -81,26 +81,15 @@ describe('Pure Effect', function () {
|
|
|
81
81
|
});
|
|
82
82
|
|
|
83
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
84
|
const input = { email: 'context@test.com', password: 'password123' };
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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');
|
|
85
|
+
await runEffect(
|
|
86
|
+
registerUserFlow(input),
|
|
87
|
+
{ env: 'test' },
|
|
88
|
+
{
|
|
89
|
+
onBeforeCommand: /** @type CommandInterceptor */ async (command, context) =>
|
|
90
|
+
assert.equal(context.env, 'test')
|
|
91
|
+
}
|
|
92
|
+
);
|
|
104
93
|
});
|
|
105
94
|
|
|
106
95
|
it('should access context through Ask', async function () {
|
|
@@ -128,4 +117,191 @@ describe('Pure Effect', function () {
|
|
|
128
117
|
assert.equal(result.type, 'Success');
|
|
129
118
|
assert.deepEqual(result.value, { value: 'value', env: 'test' });
|
|
130
119
|
});
|
|
120
|
+
|
|
121
|
+
it('should return a Retry data structure', function () {
|
|
122
|
+
const inner = Command(
|
|
123
|
+
() => 'x',
|
|
124
|
+
(r) => Success(r)
|
|
125
|
+
);
|
|
126
|
+
const effect = Retry(inner, { attempts: 5 });
|
|
127
|
+
assert.equal(effect.type, 'Retry');
|
|
128
|
+
assert.deepEqual(effect.options, { attempts: 5 });
|
|
129
|
+
assert.strictEqual(effect.effect, inner);
|
|
130
|
+
assert.equal(typeof effect.next, 'function');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('should succeed after transient failures', async function () {
|
|
134
|
+
let calls = 0;
|
|
135
|
+
const effect = Retry(
|
|
136
|
+
Command(
|
|
137
|
+
function flakyCmd() {
|
|
138
|
+
if (++calls < 3) throw new Error('transient');
|
|
139
|
+
return 'ok';
|
|
140
|
+
},
|
|
141
|
+
(r) => Success(r)
|
|
142
|
+
),
|
|
143
|
+
{ attempts: 3, delay: 0 }
|
|
144
|
+
);
|
|
145
|
+
const result = await runEffect(effect);
|
|
146
|
+
assert.equal(result.type, 'Success');
|
|
147
|
+
assert.equal(result.value, 'ok');
|
|
148
|
+
assert.equal(calls, 3);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('should return rich Failure when retries are exhausted', async function () {
|
|
152
|
+
const effect = Retry(
|
|
153
|
+
Command(
|
|
154
|
+
function alwaysFails() {
|
|
155
|
+
throw new Error('boom');
|
|
156
|
+
return /** @type {any} */ (null);
|
|
157
|
+
},
|
|
158
|
+
(/** @type {any} */ r) => Success(r)
|
|
159
|
+
),
|
|
160
|
+
{ attempts: 2, delay: 0 }
|
|
161
|
+
);
|
|
162
|
+
const result = await runEffect(effect);
|
|
163
|
+
assert.equal(result.type, 'Failure');
|
|
164
|
+
if (result.type !== 'Failure') throw new Error('expected Failure');
|
|
165
|
+
const error = /** @type {import('../index.js').RetryExhaustedError<Error>} */ (result.error);
|
|
166
|
+
assert.equal(error.retryExhausted, true);
|
|
167
|
+
assert.equal(error.attempts, 2);
|
|
168
|
+
assert.equal(error.lastError.message, 'boom');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('should apply delay and backoff between retries', async function () {
|
|
172
|
+
this.timeout(2000);
|
|
173
|
+
let calls = 0;
|
|
174
|
+
const start = Date.now();
|
|
175
|
+
const effect = Retry(
|
|
176
|
+
Command(
|
|
177
|
+
function flakyCmd() {
|
|
178
|
+
if (++calls < 3) throw new Error('transient');
|
|
179
|
+
return 'ok';
|
|
180
|
+
},
|
|
181
|
+
(r) => Success(r)
|
|
182
|
+
),
|
|
183
|
+
{ attempts: 3, delay: 30, backoff: 1 }
|
|
184
|
+
);
|
|
185
|
+
const result = await runEffect(effect);
|
|
186
|
+
const elapsed = Date.now() - start;
|
|
187
|
+
assert.equal(result.type, 'Success');
|
|
188
|
+
// 2 retries × 30 ms = at least 55 ms (5 ms margin for timing variance)
|
|
189
|
+
assert.ok(elapsed >= 55, `Expected ≥ 55 ms elapsed, got ${elapsed} ms`);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('should merge per-use Retry options with call-level defaults', async function () {
|
|
193
|
+
// Call-level: attempts 1 (would exhaust on 2nd try)
|
|
194
|
+
// Per-use: attempts 3 (overrides call-level — should succeed on 3rd try)
|
|
195
|
+
let calls = 0;
|
|
196
|
+
const effect = Retry(
|
|
197
|
+
Command(
|
|
198
|
+
function flakyCmd() {
|
|
199
|
+
if (++calls < 3) throw new Error('x');
|
|
200
|
+
return 'ok';
|
|
201
|
+
},
|
|
202
|
+
(r) => Success(r)
|
|
203
|
+
),
|
|
204
|
+
{ attempts: 3 }
|
|
205
|
+
);
|
|
206
|
+
const result = await runEffect(effect, {}, { retry: { attempts: 1, delay: 0, backoff: 1 } });
|
|
207
|
+
assert.equal(result.type, 'Success');
|
|
208
|
+
assert.equal(calls, 3);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('should work at any step inside effectPipe', async function () {
|
|
212
|
+
const flow = effectPipe(
|
|
213
|
+
(input) =>
|
|
214
|
+
Retry(
|
|
215
|
+
Command(
|
|
216
|
+
function fetchCmd() {
|
|
217
|
+
return input.toUpperCase();
|
|
218
|
+
},
|
|
219
|
+
(r) => Success(r)
|
|
220
|
+
),
|
|
221
|
+
{ attempts: 2, delay: 0 }
|
|
222
|
+
),
|
|
223
|
+
(upper) => Success(`${upper}!`)
|
|
224
|
+
);
|
|
225
|
+
const result = await runEffect(flow('hello'));
|
|
226
|
+
assert.equal(result.type, 'Success');
|
|
227
|
+
assert.equal(result.value, 'HELLO!');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('should return a Parallel data structure', () => {
|
|
231
|
+
const e1 = Success(1);
|
|
232
|
+
const e2 = Success(2);
|
|
233
|
+
const next = (/** @type {any[]} */ values) => Success(values);
|
|
234
|
+
const result = Parallel([e1, e2], next);
|
|
235
|
+
assert.equal(result.type, 'Parallel');
|
|
236
|
+
assert.deepEqual(result.effects, [e1, e2]);
|
|
237
|
+
assert.equal(result.next, next);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('should run effects concurrently and pass results to next', async () => {
|
|
241
|
+
const e1 = Command(
|
|
242
|
+
async () => 'a',
|
|
243
|
+
(v) => Success(v)
|
|
244
|
+
);
|
|
245
|
+
const e2 = Command(
|
|
246
|
+
async () => 'b',
|
|
247
|
+
(v) => Success(v)
|
|
248
|
+
);
|
|
249
|
+
const flow = Parallel([e1, e2], ([a, b]) => Success({ a, b }));
|
|
250
|
+
const result = await runEffect(flow);
|
|
251
|
+
assert.equal(result.type, 'Success');
|
|
252
|
+
assert.deepEqual(result.value, { a: 'a', b: 'b' });
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('should return Failure if any parallel effect fails', async () => {
|
|
256
|
+
const e1 = Success('ok');
|
|
257
|
+
const e2 = Failure('oops');
|
|
258
|
+
const flow = Parallel([e1, e2], ([a, b]) => Success({ a, b }));
|
|
259
|
+
const result = await runEffect(flow);
|
|
260
|
+
assert.equal(result.type, 'Failure');
|
|
261
|
+
assert.equal(result.error, 'oops');
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('should work inside effectPipe', async () => {
|
|
265
|
+
const flow = effectPipe((input) =>
|
|
266
|
+
Parallel(
|
|
267
|
+
[
|
|
268
|
+
Command(
|
|
269
|
+
async () => input.a,
|
|
270
|
+
(v) => Success(v)
|
|
271
|
+
),
|
|
272
|
+
Command(
|
|
273
|
+
async () => input.b,
|
|
274
|
+
(v) => Success(v)
|
|
275
|
+
)
|
|
276
|
+
],
|
|
277
|
+
([a, b]) => Success({ a, b })
|
|
278
|
+
)
|
|
279
|
+
);
|
|
280
|
+
const result = await runEffect(flow({ a: 1, b: 2 }));
|
|
281
|
+
assert.equal(result.type, 'Success');
|
|
282
|
+
assert.deepEqual(result.value, { a: 1, b: 2 });
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('should pass context to parallel branches via Ask', async () => {
|
|
286
|
+
const flow = Parallel(
|
|
287
|
+
[Ask((/** @type {any} */ ctx) => Success(ctx.x)), Ask((/** @type {any} */ ctx) => Success(ctx.y))],
|
|
288
|
+
([x, y]) => Success({ x, y })
|
|
289
|
+
);
|
|
290
|
+
const result = await runEffect(flow, { x: 10, y: 20 });
|
|
291
|
+
assert.equal(result.type, 'Success');
|
|
292
|
+
assert.deepEqual(result.value, { x: 10, y: 20 });
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('should return Success after runEffect with telemetry disabled', async function () {
|
|
296
|
+
const input = { email: 'test-no-telemetry@test.com', password: 'password123' };
|
|
297
|
+
const result = await registerUser(input);
|
|
298
|
+
assert.equal(result.type, 'Success');
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('should return Success after runEffect with telemetry enabled', async function () {
|
|
302
|
+
enableTelemetry();
|
|
303
|
+
const input = { email: 'test-telemetry@test.com', password: 'password123' };
|
|
304
|
+
const result = await registerUser(input);
|
|
305
|
+
assert.equal(result.type, 'Success');
|
|
306
|
+
});
|
|
131
307
|
});
|
package/test/types.test-d.ts
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
import { expectType, expectError } from 'tsd';
|
|
2
|
-
import { Success, Failure, Command, Ask, effectPipe, runEffect } from '../index.js';
|
|
3
|
-
import type {
|
|
2
|
+
import { Success, Failure, Command, Ask, Retry, Parallel, effectPipe, runEffect, configureEffect } from '../index.js';
|
|
3
|
+
import type {
|
|
4
|
+
SuccessState,
|
|
5
|
+
FailureState,
|
|
6
|
+
CommandState,
|
|
7
|
+
AskState,
|
|
8
|
+
RetryState,
|
|
9
|
+
ParallelState,
|
|
10
|
+
RetryExhaustedError,
|
|
11
|
+
Effect,
|
|
12
|
+
EffectConfiguration,
|
|
13
|
+
StepRunner,
|
|
14
|
+
RunWrapper,
|
|
15
|
+
CommandInterceptor
|
|
16
|
+
} from '../index.js';
|
|
4
17
|
|
|
5
18
|
interface User {
|
|
6
19
|
email: string;
|
|
@@ -72,3 +85,136 @@ expectType<AskState<User, unknown>>(ask);
|
|
|
72
85
|
|
|
73
86
|
const askFlow = effectPipe((input: User) => Ask((_ctx) => Success(input)));
|
|
74
87
|
expectType<Effect<User>>(askFlow({ email: 'a@b.com', password: 'secret123' }));
|
|
88
|
+
|
|
89
|
+
// --- Retry ---
|
|
90
|
+
|
|
91
|
+
const innerCmd = Command(
|
|
92
|
+
async () => 42,
|
|
93
|
+
(n) => Success(n)
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
// Retry with options preserves T
|
|
97
|
+
const retried = Retry(innerCmd, { attempts: 3 });
|
|
98
|
+
expectType<RetryState<number, unknown>>(retried);
|
|
99
|
+
|
|
100
|
+
// Retry without options is valid
|
|
101
|
+
const retriedNoOpts = Retry(innerCmd);
|
|
102
|
+
expectType<RetryState<number, unknown>>(retriedNoOpts);
|
|
103
|
+
|
|
104
|
+
// Retry in effectPipe preserves type flow
|
|
105
|
+
const retryFlow = effectPipe((input: User) =>
|
|
106
|
+
Retry(
|
|
107
|
+
Command(
|
|
108
|
+
async () => ({ id: 1, ...input }) as SavedUser,
|
|
109
|
+
(s) => Success(s)
|
|
110
|
+
),
|
|
111
|
+
{ attempts: 2 }
|
|
112
|
+
)
|
|
113
|
+
);
|
|
114
|
+
expectType<Effect<SavedUser>>(retryFlow({ email: 'a@b.com', password: 'secret123' }));
|
|
115
|
+
|
|
116
|
+
// RetryExhaustedError shape is usable for narrowing exhaustion failures
|
|
117
|
+
const exhaustedErr: RetryExhaustedError<Error> = {
|
|
118
|
+
retryExhausted: true,
|
|
119
|
+
lastError: new Error('boom'),
|
|
120
|
+
attempts: 3
|
|
121
|
+
};
|
|
122
|
+
expectType<true>(exhaustedErr.retryExhausted);
|
|
123
|
+
expectType<Error>(exhaustedErr.lastError);
|
|
124
|
+
expectType<number>(exhaustedErr.attempts);
|
|
125
|
+
|
|
126
|
+
// --- error channel union across effectPipe steps ---
|
|
127
|
+
|
|
128
|
+
type ValidationError = 'invalid_email' | 'weak_password';
|
|
129
|
+
type DbError = 'db_connection' | 'duplicate_key';
|
|
130
|
+
|
|
131
|
+
const validateStep = (_input: User): Effect<User, ValidationError> => Failure<ValidationError>('invalid_email');
|
|
132
|
+
const saveStep = (_user: User): Effect<SavedUser, DbError> => Failure<DbError>('db_connection');
|
|
133
|
+
|
|
134
|
+
const typedFlow = effectPipe(validateStep, saveStep);
|
|
135
|
+
expectType<Effect<SavedUser, ValidationError | DbError>>(typedFlow({ email: 'a@b.com', password: 'secret123' }));
|
|
136
|
+
|
|
137
|
+
const typedResult = await runEffect(typedFlow({ email: 'a@b.com', password: 'secret123' }));
|
|
138
|
+
expectType<SuccessState<SavedUser> | FailureState<ValidationError | DbError>>(typedResult);
|
|
139
|
+
|
|
140
|
+
// --- Parallel ---
|
|
141
|
+
|
|
142
|
+
// Values tuple is correctly typed
|
|
143
|
+
const par = Parallel([Success(42), Success('hello')], ([n, s]) => {
|
|
144
|
+
expectType<number>(n);
|
|
145
|
+
expectType<string>(s);
|
|
146
|
+
return Success({ n, s });
|
|
147
|
+
});
|
|
148
|
+
expectType<ParallelState<[number, string], { n: number; s: string }>>(par);
|
|
149
|
+
|
|
150
|
+
// Parallel in effectPipe preserves type flow
|
|
151
|
+
const parallelFlow = effectPipe((input: User) =>
|
|
152
|
+
Parallel([Success(input.email), Success(input.password)], ([email, password]) => Success({ email, password }))
|
|
153
|
+
);
|
|
154
|
+
expectType<Effect<{ email: string; password: string }>>(parallelFlow({ email: 'a@b.com', password: 'secret123' }));
|
|
155
|
+
|
|
156
|
+
// runEffect return type flows through Parallel
|
|
157
|
+
const parallelResult = await runEffect(Parallel([Success(1), Success('x')], ([n, s]) => Success({ n, s })));
|
|
158
|
+
expectType<SuccessState<{ n: number; s: string }> | FailureState<unknown>>(parallelResult);
|
|
159
|
+
|
|
160
|
+
// --- Ctx (context type) ---
|
|
161
|
+
|
|
162
|
+
interface AppCtx {
|
|
163
|
+
db: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Ask infers Ctx from callback parameter type
|
|
167
|
+
const askWithCtx = Ask((ctx: AppCtx) => Success(ctx.db));
|
|
168
|
+
expectType<AskState<string, unknown, AppCtx>>(askWithCtx);
|
|
169
|
+
|
|
170
|
+
// effectPipe propagates Ctx through steps
|
|
171
|
+
const ctxFlow = effectPipe((input: User) => Ask((ctx: AppCtx) => Success({ ...input, conn: ctx.db })));
|
|
172
|
+
expectType<Effect<{ email: string; password: string; conn: string }, unknown, AppCtx>>(
|
|
173
|
+
ctxFlow({ email: 'a@b.com', password: 'secret123' })
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
// runEffect enforces context argument matches Ctx
|
|
177
|
+
const ctxResult = await runEffect(ctxFlow({ email: 'a@b.com', password: 'secret123' }), { db: 'conn' });
|
|
178
|
+
expectType<SuccessState<{ email: string; password: string; conn: string }> | FailureState<unknown>>(ctxResult);
|
|
179
|
+
|
|
180
|
+
// wrong context shape should error
|
|
181
|
+
expectError(runEffect(ctxFlow({ email: 'a@b.com', password: 'secret123' }), { wrong: 'thing' }));
|
|
182
|
+
|
|
183
|
+
// --- configureEffect / EffectConfiguration ---
|
|
184
|
+
|
|
185
|
+
// accepts full configuration
|
|
186
|
+
configureEffect({
|
|
187
|
+
onStep: async (_name, _type, op) => op(),
|
|
188
|
+
onRun: async (_effect, op, _flowName) => op(),
|
|
189
|
+
onBeforeCommand: async (_cmd, _ctx) => {},
|
|
190
|
+
retry: { attempts: 3, delay: 100, backoff: 2 }
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// accepts partial configuration
|
|
194
|
+
configureEffect({ retry: { attempts: 5 } });
|
|
195
|
+
configureEffect({});
|
|
196
|
+
|
|
197
|
+
// rejects invalid shapes
|
|
198
|
+
expectError(configureEffect({ onStep: 'not-a-function' }));
|
|
199
|
+
expectError(configureEffect({ retry: { attempts: 'three' } }));
|
|
200
|
+
|
|
201
|
+
// hook types are correctly shaped
|
|
202
|
+
const myStep: StepRunner = async (name, type, op) => {
|
|
203
|
+
expectType<string>(name);
|
|
204
|
+
expectType<string>(type);
|
|
205
|
+
return op();
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const myRun: RunWrapper = async (effect, op, flowName) => {
|
|
209
|
+
expectType<Effect<unknown>>(effect);
|
|
210
|
+
expectType<string | undefined>(flowName);
|
|
211
|
+
return op();
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const myInterceptor: CommandInterceptor = async (cmd, _ctx) => {
|
|
215
|
+
expectType<CommandState<unknown, unknown>>(cmd);
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
// EffectConfiguration is a usable type
|
|
219
|
+
const config: EffectConfiguration = { onStep: myStep, onRun: myRun, onBeforeCommand: myInterceptor };
|
|
220
|
+
expectType<EffectConfiguration>(config);
|