pure-effect 0.5.0 → 0.7.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 +12 -0
- package/.dirac-symbol-index/data.db +0 -0
- package/.prettierrc +11 -0
- package/CLAUDE.md +29 -16
- package/README.md +236 -66
- package/index.d.ts +190 -86
- package/index.js +138 -31
- package/opentelemetry-example.js +5 -5
- package/package.json +2 -2
- package/test/all.js +145 -8
- package/test/types.test-d.ts +88 -8
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, effectPipe, runEffect, configureEffect } from '../index.js';
|
|
4
|
+
import { Success, Failure, Command, Ask, Retry, effectPipe, runEffect, configureEffect } from '../index.js';
|
|
5
5
|
import { enableTelemetry } from '../opentelemetry-example.js';
|
|
6
6
|
|
|
7
7
|
/** @import { CommandInterceptor } from "../index.js" */
|
|
@@ -17,7 +17,7 @@ const db = {
|
|
|
17
17
|
const u = { ...user, id: Date.now() };
|
|
18
18
|
this.users.set(user.email, u);
|
|
19
19
|
return u;
|
|
20
|
-
}
|
|
20
|
+
}
|
|
21
21
|
};
|
|
22
22
|
|
|
23
23
|
function validateRegistration(/** @type {User} */ input) {
|
|
@@ -81,13 +81,150 @@ 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
|
-
|
|
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
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should access context through Ask', async function () {
|
|
96
|
+
/** @type {any} */
|
|
97
|
+
let capturedCtx;
|
|
98
|
+
const step = () =>
|
|
99
|
+
Ask((ctx) => {
|
|
100
|
+
capturedCtx = ctx;
|
|
101
|
+
return Success(null);
|
|
102
|
+
});
|
|
103
|
+
await runEffect(step(), { env: 'test' });
|
|
104
|
+
assert.equal(capturedCtx.env, 'test');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('should work with Ask at any point in the pipeline', async function () {
|
|
108
|
+
const flow = effectPipe(
|
|
109
|
+
() =>
|
|
110
|
+
Command(
|
|
111
|
+
() => 'value',
|
|
112
|
+
(r) => Success(r)
|
|
113
|
+
),
|
|
114
|
+
(value) => Ask((/** @type {any} */ ctx) => Success({ value, env: ctx.env }))
|
|
115
|
+
);
|
|
116
|
+
const result = await runEffect(flow(null), { env: 'test' });
|
|
117
|
+
assert.equal(result.type, 'Success');
|
|
118
|
+
assert.deepEqual(result.value, { value: 'value', env: 'test' });
|
|
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!');
|
|
91
228
|
});
|
|
92
229
|
|
|
93
230
|
it('should return Success after runEffect with telemetry disabled', async function () {
|
package/test/types.test-d.ts
CHANGED
|
@@ -1,9 +1,23 @@
|
|
|
1
1
|
import { expectType, expectError } from 'tsd';
|
|
2
|
-
import { Success, Failure, Command, effectPipe, runEffect } from '../index.js';
|
|
3
|
-
import type {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
import { Success, Failure, Command, Ask, Retry, effectPipe, runEffect } from '../index.js';
|
|
3
|
+
import type {
|
|
4
|
+
SuccessState,
|
|
5
|
+
FailureState,
|
|
6
|
+
CommandState,
|
|
7
|
+
AskState,
|
|
8
|
+
RetryState,
|
|
9
|
+
RetryExhaustedError,
|
|
10
|
+
Effect
|
|
11
|
+
} from '../index.js';
|
|
12
|
+
|
|
13
|
+
interface User {
|
|
14
|
+
email: string;
|
|
15
|
+
password: string;
|
|
16
|
+
}
|
|
17
|
+
interface SavedUser {
|
|
18
|
+
id: number;
|
|
19
|
+
email: string;
|
|
20
|
+
}
|
|
7
21
|
|
|
8
22
|
// --- Success ---
|
|
9
23
|
|
|
@@ -19,15 +33,22 @@ expectType<FailureState<string>>(f);
|
|
|
19
33
|
// --- Command ---
|
|
20
34
|
|
|
21
35
|
const cmd = Command(
|
|
22
|
-
async () => ({ id: 1, email: 'a@b.com' } as SavedUser
|
|
23
|
-
(saved) => {
|
|
36
|
+
async () => ({ id: 1, email: 'a@b.com' }) as SavedUser,
|
|
37
|
+
(saved) => {
|
|
38
|
+
expectType<SavedUser>(saved);
|
|
39
|
+
return Success(saved);
|
|
40
|
+
}
|
|
24
41
|
);
|
|
25
42
|
expectType<CommandState<SavedUser, SavedUser, unknown>>(cmd);
|
|
26
43
|
|
|
27
44
|
// --- effectPipe type propagation ---
|
|
28
45
|
|
|
29
46
|
const step1 = (input: User) => Success(input);
|
|
30
|
-
const step2 = (user: User) =>
|
|
47
|
+
const step2 = (user: User) =>
|
|
48
|
+
Command(
|
|
49
|
+
async () => ({ id: 1, ...user }) as SavedUser,
|
|
50
|
+
(s) => Success(s)
|
|
51
|
+
);
|
|
31
52
|
|
|
32
53
|
const flow = effectPipe(step1, step2);
|
|
33
54
|
expectType<Effect<SavedUser>>(flow({ email: 'a@b.com', password: 'secret123' }));
|
|
@@ -51,3 +72,62 @@ if (result.type === 'Success') {
|
|
|
51
72
|
const failFlow = effectPipe((input: User): Effect<User, string> => Failure<string>('bad'));
|
|
52
73
|
const failResult = await runEffect(failFlow({ email: 'a@b.com', password: 'x' }));
|
|
53
74
|
expectType<SuccessState<User> | FailureState<string>>(failResult);
|
|
75
|
+
|
|
76
|
+
// --- Ask ---
|
|
77
|
+
|
|
78
|
+
const ask = Ask((ctx) => Success(ctx as User));
|
|
79
|
+
expectType<AskState<User, unknown>>(ask);
|
|
80
|
+
|
|
81
|
+
const askFlow = effectPipe((input: User) => Ask((_ctx) => Success(input)));
|
|
82
|
+
expectType<Effect<User>>(askFlow({ email: 'a@b.com', password: 'secret123' }));
|
|
83
|
+
|
|
84
|
+
// --- Retry ---
|
|
85
|
+
|
|
86
|
+
const innerCmd = Command(
|
|
87
|
+
async () => 42,
|
|
88
|
+
(n) => Success(n)
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
// Retry with options preserves T
|
|
92
|
+
const retried = Retry(innerCmd, { attempts: 3 });
|
|
93
|
+
expectType<RetryState<number, unknown>>(retried);
|
|
94
|
+
|
|
95
|
+
// Retry without options is valid
|
|
96
|
+
const retriedNoOpts = Retry(innerCmd);
|
|
97
|
+
expectType<RetryState<number, unknown>>(retriedNoOpts);
|
|
98
|
+
|
|
99
|
+
// Retry in effectPipe preserves type flow
|
|
100
|
+
const retryFlow = effectPipe((input: User) =>
|
|
101
|
+
Retry(
|
|
102
|
+
Command(
|
|
103
|
+
async () => ({ id: 1, ...input }) as SavedUser,
|
|
104
|
+
(s) => Success(s)
|
|
105
|
+
),
|
|
106
|
+
{ attempts: 2 }
|
|
107
|
+
)
|
|
108
|
+
);
|
|
109
|
+
expectType<Effect<SavedUser>>(retryFlow({ email: 'a@b.com', password: 'secret123' }));
|
|
110
|
+
|
|
111
|
+
// RetryExhaustedError shape is usable for narrowing exhaustion failures
|
|
112
|
+
const exhaustedErr: RetryExhaustedError<Error> = {
|
|
113
|
+
retryExhausted: true,
|
|
114
|
+
lastError: new Error('boom'),
|
|
115
|
+
attempts: 3
|
|
116
|
+
};
|
|
117
|
+
expectType<true>(exhaustedErr.retryExhausted);
|
|
118
|
+
expectType<Error>(exhaustedErr.lastError);
|
|
119
|
+
expectType<number>(exhaustedErr.attempts);
|
|
120
|
+
|
|
121
|
+
// --- error channel union across effectPipe steps ---
|
|
122
|
+
|
|
123
|
+
type ValidationError = 'invalid_email' | 'weak_password';
|
|
124
|
+
type DbError = 'db_connection' | 'duplicate_key';
|
|
125
|
+
|
|
126
|
+
const validateStep = (_input: User): Effect<User, ValidationError> => Failure<ValidationError>('invalid_email');
|
|
127
|
+
const saveStep = (_user: User): Effect<SavedUser, DbError> => Failure<DbError>('db_connection');
|
|
128
|
+
|
|
129
|
+
const typedFlow = effectPipe(validateStep, saveStep);
|
|
130
|
+
expectType<Effect<SavedUser, ValidationError | DbError>>(typedFlow({ email: 'a@b.com', password: 'secret123' }));
|
|
131
|
+
|
|
132
|
+
const typedResult = await runEffect(typedFlow({ email: 'a@b.com', password: 'secret123' }));
|
|
133
|
+
expectType<SuccessState<SavedUser> | FailureState<ValidationError | DbError>>(typedResult);
|