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.
@@ -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: () => {} });