pure-effect 0.12.0 → 0.14.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/CHANGELOG.md +192 -0
- package/README.md +328 -121
- package/index.d.ts +1129 -138
- package/index.js +757 -207
- package/package.json +32 -2
- package/.prettierrc +0 -12
- package/CLAUDE.md +0 -205
- package/examples/opentelemetry-example.js +0 -164
- package/examples/recording-example.js +0 -104
- package/jsconfig.json +0 -13
- package/test/all.js +0 -2446
- package/test/types.test-d.ts +0 -335
package/test/types.test-d.ts
DELETED
|
@@ -1,335 +0,0 @@
|
|
|
1
|
-
import { expectType, expectAssignable } from 'tsd';
|
|
2
|
-
import {
|
|
3
|
-
Success,
|
|
4
|
-
Failure,
|
|
5
|
-
Command,
|
|
6
|
-
Ask,
|
|
7
|
-
Retry,
|
|
8
|
-
Parallel,
|
|
9
|
-
effectPipe,
|
|
10
|
-
runEffect,
|
|
11
|
-
configureEffect,
|
|
12
|
-
replayEffect
|
|
13
|
-
} from '../index.js';
|
|
14
|
-
import type {
|
|
15
|
-
SuccessState,
|
|
16
|
-
FailureState,
|
|
17
|
-
CommandState,
|
|
18
|
-
AskState,
|
|
19
|
-
RetryState,
|
|
20
|
-
ParallelState,
|
|
21
|
-
RetryExhaustedError,
|
|
22
|
-
Effect,
|
|
23
|
-
EffectConfiguration,
|
|
24
|
-
StepRunner,
|
|
25
|
-
RunWrapper,
|
|
26
|
-
CommandInterceptor,
|
|
27
|
-
TraceEntry,
|
|
28
|
-
TraceLog,
|
|
29
|
-
Resolver,
|
|
30
|
-
ReplayOptions,
|
|
31
|
-
Replay
|
|
32
|
-
} from '../index.js';
|
|
33
|
-
|
|
34
|
-
interface User {
|
|
35
|
-
email: string;
|
|
36
|
-
password: string;
|
|
37
|
-
}
|
|
38
|
-
interface SavedUser {
|
|
39
|
-
id: number;
|
|
40
|
-
email: string;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// --- Success ---
|
|
44
|
-
|
|
45
|
-
const s = Success(42);
|
|
46
|
-
expectType<SuccessState<number>>(s);
|
|
47
|
-
// @ts-expect-error missing argument
|
|
48
|
-
Success();
|
|
49
|
-
|
|
50
|
-
// --- Failure ---
|
|
51
|
-
|
|
52
|
-
const f = Failure('oops');
|
|
53
|
-
expectType<FailureState<string>>(f);
|
|
54
|
-
|
|
55
|
-
// --- Command ---
|
|
56
|
-
|
|
57
|
-
// next is optional, defaulting to Success
|
|
58
|
-
expectType<CommandState<number, number>>(Command(() => 42));
|
|
59
|
-
expectType<CommandState<number, string>>(
|
|
60
|
-
Command(
|
|
61
|
-
() => 42,
|
|
62
|
-
(n: number) => Success(String(n))
|
|
63
|
-
)
|
|
64
|
-
);
|
|
65
|
-
Command(() => 42, undefined, { name: 'readRow' });
|
|
66
|
-
// @ts-expect-error missing cmd
|
|
67
|
-
Command();
|
|
68
|
-
|
|
69
|
-
const cmd = Command(
|
|
70
|
-
async () => ({ id: 1, email: 'a@b.com' }) as SavedUser,
|
|
71
|
-
(saved) => {
|
|
72
|
-
expectType<SavedUser>(saved);
|
|
73
|
-
return Success(saved);
|
|
74
|
-
}
|
|
75
|
-
);
|
|
76
|
-
expectType<CommandState<SavedUser, SavedUser, unknown>>(cmd);
|
|
77
|
-
|
|
78
|
-
// A cmd that only throws, or returns Promise.reject, infers R as never. That has to stay assignable
|
|
79
|
-
// to Effect under strictFunctionTypes, which is why every state's `next` is a method signature
|
|
80
|
-
// (bivariant parameter) rather than a function-typed property (contravariant, so `never` could not
|
|
81
|
-
// widen to the `any` in `Effect`'s CommandState member).
|
|
82
|
-
const throwing = Command(() => {
|
|
83
|
-
throw new Error('boom');
|
|
84
|
-
});
|
|
85
|
-
expectAssignable<Effect<never>>(throwing);
|
|
86
|
-
expectAssignable<Effect<never>>(Command(() => Promise.reject(new Error('down'))));
|
|
87
|
-
expectAssignable<Effect<never>>(Retry(throwing));
|
|
88
|
-
expectAssignable<Effect<never>>(effectPipe(() => throwing)(null));
|
|
89
|
-
|
|
90
|
-
// --- effectPipe type propagation ---
|
|
91
|
-
|
|
92
|
-
const step1 = (input: User) => Success(input);
|
|
93
|
-
const step2 = (user: User) =>
|
|
94
|
-
Command(
|
|
95
|
-
async () => ({ id: 1, ...user }) as SavedUser,
|
|
96
|
-
(s) => Success(s)
|
|
97
|
-
);
|
|
98
|
-
|
|
99
|
-
const flow = effectPipe(step1, step2);
|
|
100
|
-
expectType<Effect<SavedUser>>(flow({ email: 'a@b.com', password: 'secret123' }));
|
|
101
|
-
// @ts-expect-error missing password
|
|
102
|
-
flow({ email: 'a@b.com' });
|
|
103
|
-
|
|
104
|
-
// --- runEffect return type ---
|
|
105
|
-
|
|
106
|
-
const result = await runEffect(flow({ email: 'a@b.com', password: 'secret123' }));
|
|
107
|
-
expectType<SuccessState<SavedUser> | FailureState<unknown>>(result);
|
|
108
|
-
|
|
109
|
-
// --- discriminated union narrowing ---
|
|
110
|
-
|
|
111
|
-
if (result.type === 'Success') {
|
|
112
|
-
expectType<SavedUser>(result.value);
|
|
113
|
-
} else {
|
|
114
|
-
expectType<unknown>(result.error);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// --- Failure error type flows through runEffect ---
|
|
118
|
-
|
|
119
|
-
const failFlow = effectPipe((input: User): Effect<User, string> => Failure<string>('bad'));
|
|
120
|
-
const failResult = await runEffect(failFlow({ email: 'a@b.com', password: 'x' }));
|
|
121
|
-
expectType<SuccessState<User> | FailureState<string>>(failResult);
|
|
122
|
-
|
|
123
|
-
// --- Ask ---
|
|
124
|
-
|
|
125
|
-
const ask = Ask((ctx) => Success(ctx as User));
|
|
126
|
-
expectType<AskState<User, unknown>>(ask);
|
|
127
|
-
|
|
128
|
-
const askFlow = effectPipe((input: User) => Ask((_ctx) => Success(input)));
|
|
129
|
-
expectType<Effect<User>>(askFlow({ email: 'a@b.com', password: 'secret123' }));
|
|
130
|
-
|
|
131
|
-
// --- Retry ---
|
|
132
|
-
|
|
133
|
-
const innerCmd = Command(
|
|
134
|
-
async () => 42,
|
|
135
|
-
(n) => Success(n)
|
|
136
|
-
);
|
|
137
|
-
|
|
138
|
-
// Retry with options preserves T; the error type is the exhaustion failure, not the inner error,
|
|
139
|
-
// because the interpreter wraps the last inner error rather than letting it escape
|
|
140
|
-
const retried = Retry(innerCmd, { attempts: 3 });
|
|
141
|
-
expectType<RetryState<number, RetryExhaustedError<unknown>>>(retried);
|
|
142
|
-
|
|
143
|
-
// Retry without options is valid
|
|
144
|
-
const retriedNoOpts = Retry(innerCmd);
|
|
145
|
-
expectType<RetryState<number, RetryExhaustedError<unknown>>>(retriedNoOpts);
|
|
146
|
-
|
|
147
|
-
// Retry in effectPipe preserves type flow
|
|
148
|
-
const retryFlow = effectPipe((input: User) =>
|
|
149
|
-
Retry(
|
|
150
|
-
Command(
|
|
151
|
-
async () => ({ id: 1, ...input }) as SavedUser,
|
|
152
|
-
(s) => Success(s)
|
|
153
|
-
),
|
|
154
|
-
{ attempts: 2 }
|
|
155
|
-
)
|
|
156
|
-
);
|
|
157
|
-
expectType<Effect<SavedUser, RetryExhaustedError<unknown>>>(retryFlow({ email: 'a@b.com', password: 'secret123' }));
|
|
158
|
-
|
|
159
|
-
// The inner error type survives inside the exhaustion failure, so lastError is typed
|
|
160
|
-
const retriedTyped = Retry(Success(1) as Effect<number, 'net_down'>);
|
|
161
|
-
expectType<RetryState<number, RetryExhaustedError<'net_down'>>>(retriedTyped);
|
|
162
|
-
|
|
163
|
-
const retryResult = await runEffect(Retry(Success(1) as Effect<number, 'flaky'>, { attempts: 1 }));
|
|
164
|
-
if (retryResult.type === 'Failure') {
|
|
165
|
-
expectType<RetryExhaustedError<'flaky'>>(retryResult.error);
|
|
166
|
-
expectType<'flaky'>(retryResult.error.lastError);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// onExhausted consumes the exhaustion, so the fallback's error type is what remains
|
|
170
|
-
const recovered = Retry(Success(1) as Effect<number, 'flaky'>, {
|
|
171
|
-
attempts: 1,
|
|
172
|
-
onExhausted: (err) => {
|
|
173
|
-
expectType<RetryExhaustedError<'flaky'>>(err);
|
|
174
|
-
return Success(0) as Effect<number, 'cache_miss'>;
|
|
175
|
-
}
|
|
176
|
-
});
|
|
177
|
-
expectType<RetryState<number, 'cache_miss'>>(recovered);
|
|
178
|
-
|
|
179
|
-
const recoveredResult = await runEffect(recovered);
|
|
180
|
-
if (recoveredResult.type === 'Failure') {
|
|
181
|
-
expectType<'cache_miss'>(recoveredResult.error);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
// onExhausted is per-use only: global retry defaults cannot carry a fallback
|
|
185
|
-
// @ts-expect-error onExhausted is not a global retry default
|
|
186
|
-
configureEffect({ retry: { attempts: 2, onExhausted: () => Success(1) } });
|
|
187
|
-
|
|
188
|
-
// RetryExhaustedError shape is usable for narrowing exhaustion failures
|
|
189
|
-
const exhaustedErr: RetryExhaustedError<Error> = {
|
|
190
|
-
retryExhausted: true,
|
|
191
|
-
lastError: new Error('boom'),
|
|
192
|
-
attempts: 3
|
|
193
|
-
};
|
|
194
|
-
expectType<true>(exhaustedErr.retryExhausted);
|
|
195
|
-
expectType<Error>(exhaustedErr.lastError);
|
|
196
|
-
expectType<number>(exhaustedErr.attempts);
|
|
197
|
-
|
|
198
|
-
// --- error channel union across effectPipe steps ---
|
|
199
|
-
|
|
200
|
-
type ValidationError = 'invalid_email' | 'weak_password';
|
|
201
|
-
type DbError = 'db_connection' | 'duplicate_key';
|
|
202
|
-
|
|
203
|
-
const validateStep = (_input: User): Effect<User, ValidationError> => Failure<ValidationError>('invalid_email');
|
|
204
|
-
const saveStep = (_user: User): Effect<SavedUser, DbError> => Failure<DbError>('db_connection');
|
|
205
|
-
|
|
206
|
-
const typedFlow = effectPipe(validateStep, saveStep);
|
|
207
|
-
expectType<Effect<SavedUser, ValidationError | DbError>>(typedFlow({ email: 'a@b.com', password: 'secret123' }));
|
|
208
|
-
|
|
209
|
-
const typedResult = await runEffect(typedFlow({ email: 'a@b.com', password: 'secret123' }));
|
|
210
|
-
expectType<SuccessState<SavedUser> | FailureState<ValidationError | DbError>>(typedResult);
|
|
211
|
-
|
|
212
|
-
// --- Parallel ---
|
|
213
|
-
|
|
214
|
-
// Values tuple is correctly typed
|
|
215
|
-
const par = Parallel([Success(42), Success('hello')], ([n, s]) => {
|
|
216
|
-
expectType<number>(n);
|
|
217
|
-
expectType<string>(s);
|
|
218
|
-
return Success({ n, s });
|
|
219
|
-
});
|
|
220
|
-
expectType<ParallelState<[number, string], { n: number; s: string }>>(par);
|
|
221
|
-
|
|
222
|
-
// Parallel in effectPipe preserves type flow
|
|
223
|
-
const parallelFlow = effectPipe((input: User) =>
|
|
224
|
-
Parallel([Success(input.email), Success(input.password)], ([email, password]) => Success({ email, password }))
|
|
225
|
-
);
|
|
226
|
-
expectType<Effect<{ email: string; password: string }>>(parallelFlow({ email: 'a@b.com', password: 'secret123' }));
|
|
227
|
-
|
|
228
|
-
// runEffect return type flows through Parallel
|
|
229
|
-
const parallelResult = await runEffect(Parallel([Success(1), Success('x')], ([n, s]) => Success({ n, s })));
|
|
230
|
-
expectType<SuccessState<{ n: number; s: string }> | FailureState<unknown>>(parallelResult);
|
|
231
|
-
|
|
232
|
-
// With next omitted, the Parallel resolves to the values tuple itself
|
|
233
|
-
const parBare = Parallel([Success(42), Success('hello')]);
|
|
234
|
-
expectType<ParallelState<[number, string], [number, string]>>(parBare);
|
|
235
|
-
const parBareResult = await runEffect(parBare);
|
|
236
|
-
if (parBareResult.type === 'Success') {
|
|
237
|
-
expectType<[number, string]>(parBareResult.value);
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
// --- Ctx (context type) ---
|
|
241
|
-
|
|
242
|
-
interface AppCtx {
|
|
243
|
-
db: string;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
// Ask infers Ctx from callback parameter type
|
|
247
|
-
const askWithCtx = Ask((ctx: AppCtx) => Success(ctx.db));
|
|
248
|
-
expectType<AskState<string, unknown, AppCtx>>(askWithCtx);
|
|
249
|
-
|
|
250
|
-
// effectPipe propagates Ctx through steps
|
|
251
|
-
const ctxFlow = effectPipe((input: User) => Ask((ctx: AppCtx) => Success({ ...input, conn: ctx.db })));
|
|
252
|
-
expectType<Effect<{ email: string; password: string; conn: string }, unknown, AppCtx>>(
|
|
253
|
-
ctxFlow({ email: 'a@b.com', password: 'secret123' })
|
|
254
|
-
);
|
|
255
|
-
|
|
256
|
-
// runEffect enforces context argument matches Ctx
|
|
257
|
-
const ctxResult = await runEffect(ctxFlow({ email: 'a@b.com', password: 'secret123' }), { db: 'conn' });
|
|
258
|
-
expectType<SuccessState<{ email: string; password: string; conn: string }> | FailureState<unknown>>(ctxResult);
|
|
259
|
-
|
|
260
|
-
// wrong context shape should error
|
|
261
|
-
// @ts-expect-error context does not match Ctx
|
|
262
|
-
runEffect(ctxFlow({ email: 'a@b.com', password: 'secret123' }), { wrong: 'thing' });
|
|
263
|
-
|
|
264
|
-
// --- configureEffect / EffectConfiguration ---
|
|
265
|
-
|
|
266
|
-
// accepts full configuration
|
|
267
|
-
configureEffect({
|
|
268
|
-
onStep: async (_name, _type, op) => op(),
|
|
269
|
-
onRun: async (_effect, op, _flowName) => op(),
|
|
270
|
-
onBeforeCommand: async (_cmd, _ctx) => {},
|
|
271
|
-
retry: { attempts: 3, delay: 100, backoff: 2 }
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
// accepts partial configuration
|
|
275
|
-
configureEffect({ retry: { attempts: 5 } });
|
|
276
|
-
configureEffect({});
|
|
277
|
-
|
|
278
|
-
// rejects invalid shapes
|
|
279
|
-
// @ts-expect-error onStep must be a function
|
|
280
|
-
configureEffect({ onStep: 'not-a-function' });
|
|
281
|
-
// @ts-expect-error attempts must be a number
|
|
282
|
-
configureEffect({ retry: { attempts: 'three' } });
|
|
283
|
-
|
|
284
|
-
// hook types are correctly shaped
|
|
285
|
-
const myStep: StepRunner = async (name, type, op) => {
|
|
286
|
-
expectType<string>(name);
|
|
287
|
-
expectType<string>(type);
|
|
288
|
-
return op();
|
|
289
|
-
};
|
|
290
|
-
|
|
291
|
-
const myRun: RunWrapper = async (effect, op, flowName) => {
|
|
292
|
-
expectType<Effect<unknown>>(effect);
|
|
293
|
-
expectType<string | undefined>(flowName);
|
|
294
|
-
return op();
|
|
295
|
-
};
|
|
296
|
-
|
|
297
|
-
const myInterceptor: CommandInterceptor = async (cmd, _ctx) => {
|
|
298
|
-
expectType<CommandState<unknown, unknown>>(cmd);
|
|
299
|
-
};
|
|
300
|
-
|
|
301
|
-
// EffectConfiguration is a usable type
|
|
302
|
-
const config: EffectConfiguration = { onStep: myStep, onRun: myRun, onBeforeCommand: myInterceptor };
|
|
303
|
-
expectType<EffectConfiguration>(config);
|
|
304
|
-
|
|
305
|
-
// replayEffect returns the flow's outcome beside the unreached entries for a trace, and no unreached for a Resolver
|
|
306
|
-
const traceLog: TraceLog = { trace: [{ command: 'cmdRead', path: '0', result: 1 }] };
|
|
307
|
-
const readRow = Command(() => 42);
|
|
308
|
-
const replayOptions: ReplayOptions = {
|
|
309
|
-
onResolved: (step, outcome) => {
|
|
310
|
-
expectType<string>(step.name);
|
|
311
|
-
expectType<number>(step.index);
|
|
312
|
-
}
|
|
313
|
-
};
|
|
314
|
-
expectType<Promise<Replay<number, unknown>>>(replayEffect(readRow, traceLog, replayOptions));
|
|
315
|
-
expectType<Promise<Replay<number, unknown>>>(replayEffect(readRow, traceLog.trace));
|
|
316
|
-
(async () => {
|
|
317
|
-
const { result, unreached } = await replayEffect(readRow, traceLog);
|
|
318
|
-
expectType<SuccessState<number> | FailureState<unknown>>(result);
|
|
319
|
-
expectType<TraceEntry[]>(unreached);
|
|
320
|
-
const fromResolver = await replayEffect(readRow, () => ({ result: 42 }));
|
|
321
|
-
expectType<SuccessState<number> | FailureState<unknown>>(fromResolver.result);
|
|
322
|
-
// @ts-expect-error a Resolver cannot know what was left unreached
|
|
323
|
-
fromResolver.unreached;
|
|
324
|
-
})();
|
|
325
|
-
// A source typed as the union (a wrapper forwarding whatever it was handed) still type-checks, with unreached optional
|
|
326
|
-
declare const traceOrResolver: TraceLog | TraceEntry[] | Resolver;
|
|
327
|
-
(async () => {
|
|
328
|
-
const forwarded = await replayEffect(readRow, traceOrResolver);
|
|
329
|
-
expectType<SuccessState<number> | FailureState<unknown>>(forwarded.result);
|
|
330
|
-
expectType<TraceEntry[] | undefined>(forwarded.unreached);
|
|
331
|
-
})();
|
|
332
|
-
// @ts-expect-error strict was removed: paths make it unnecessary
|
|
333
|
-
replayEffect(readRow, traceLog, { strict: false });
|
|
334
|
-
// @ts-expect-error unreached is returned, not observed
|
|
335
|
-
replayEffect(readRow, traceLog, { onUnreached: () => {} });
|