pure-effect 0.10.0 → 0.12.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.md +33 -21
- package/README.md +134 -17
- package/index.d.ts +68 -17
- package/index.js +130 -64
- package/jsconfig.json +13 -0
- package/package.json +5 -5
- package/test/all.js +416 -37
- package/test/types.test-d.ts +117 -13
- package/.claude/settings.local.json +0 -17
package/test/types.test-d.ts
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
|
-
import { expectType,
|
|
2
|
-
import {
|
|
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';
|
|
3
14
|
import type {
|
|
4
15
|
SuccessState,
|
|
5
16
|
FailureState,
|
|
@@ -12,7 +23,12 @@ import type {
|
|
|
12
23
|
EffectConfiguration,
|
|
13
24
|
StepRunner,
|
|
14
25
|
RunWrapper,
|
|
15
|
-
CommandInterceptor
|
|
26
|
+
CommandInterceptor,
|
|
27
|
+
TraceEntry,
|
|
28
|
+
TraceLog,
|
|
29
|
+
Resolver,
|
|
30
|
+
ReplayOptions,
|
|
31
|
+
Replay
|
|
16
32
|
} from '../index.js';
|
|
17
33
|
|
|
18
34
|
interface User {
|
|
@@ -28,7 +44,8 @@ interface SavedUser {
|
|
|
28
44
|
|
|
29
45
|
const s = Success(42);
|
|
30
46
|
expectType<SuccessState<number>>(s);
|
|
31
|
-
|
|
47
|
+
// @ts-expect-error missing argument
|
|
48
|
+
Success();
|
|
32
49
|
|
|
33
50
|
// --- Failure ---
|
|
34
51
|
|
|
@@ -46,7 +63,8 @@ expectType<CommandState<number, string>>(
|
|
|
46
63
|
)
|
|
47
64
|
);
|
|
48
65
|
Command(() => 42, undefined, { name: 'readRow' });
|
|
49
|
-
|
|
66
|
+
// @ts-expect-error missing cmd
|
|
67
|
+
Command();
|
|
50
68
|
|
|
51
69
|
const cmd = Command(
|
|
52
70
|
async () => ({ id: 1, email: 'a@b.com' }) as SavedUser,
|
|
@@ -57,6 +75,18 @@ const cmd = Command(
|
|
|
57
75
|
);
|
|
58
76
|
expectType<CommandState<SavedUser, SavedUser, unknown>>(cmd);
|
|
59
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
|
+
|
|
60
90
|
// --- effectPipe type propagation ---
|
|
61
91
|
|
|
62
92
|
const step1 = (input: User) => Success(input);
|
|
@@ -68,7 +98,8 @@ const step2 = (user: User) =>
|
|
|
68
98
|
|
|
69
99
|
const flow = effectPipe(step1, step2);
|
|
70
100
|
expectType<Effect<SavedUser>>(flow({ email: 'a@b.com', password: 'secret123' }));
|
|
71
|
-
|
|
101
|
+
// @ts-expect-error missing password
|
|
102
|
+
flow({ email: 'a@b.com' });
|
|
72
103
|
|
|
73
104
|
// --- runEffect return type ---
|
|
74
105
|
|
|
@@ -104,13 +135,14 @@ const innerCmd = Command(
|
|
|
104
135
|
(n) => Success(n)
|
|
105
136
|
);
|
|
106
137
|
|
|
107
|
-
// Retry with options preserves T
|
|
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
|
|
108
140
|
const retried = Retry(innerCmd, { attempts: 3 });
|
|
109
|
-
expectType<RetryState<number, unknown
|
|
141
|
+
expectType<RetryState<number, RetryExhaustedError<unknown>>>(retried);
|
|
110
142
|
|
|
111
143
|
// Retry without options is valid
|
|
112
144
|
const retriedNoOpts = Retry(innerCmd);
|
|
113
|
-
expectType<RetryState<number, unknown
|
|
145
|
+
expectType<RetryState<number, RetryExhaustedError<unknown>>>(retriedNoOpts);
|
|
114
146
|
|
|
115
147
|
// Retry in effectPipe preserves type flow
|
|
116
148
|
const retryFlow = effectPipe((input: User) =>
|
|
@@ -122,7 +154,36 @@ const retryFlow = effectPipe((input: User) =>
|
|
|
122
154
|
{ attempts: 2 }
|
|
123
155
|
)
|
|
124
156
|
);
|
|
125
|
-
expectType<Effect<SavedUser
|
|
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) } });
|
|
126
187
|
|
|
127
188
|
// RetryExhaustedError shape is usable for narrowing exhaustion failures
|
|
128
189
|
const exhaustedErr: RetryExhaustedError<Error> = {
|
|
@@ -168,6 +229,14 @@ expectType<Effect<{ email: string; password: string }>>(parallelFlow({ email: 'a
|
|
|
168
229
|
const parallelResult = await runEffect(Parallel([Success(1), Success('x')], ([n, s]) => Success({ n, s })));
|
|
169
230
|
expectType<SuccessState<{ n: number; s: string }> | FailureState<unknown>>(parallelResult);
|
|
170
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
|
+
|
|
171
240
|
// --- Ctx (context type) ---
|
|
172
241
|
|
|
173
242
|
interface AppCtx {
|
|
@@ -189,7 +258,8 @@ const ctxResult = await runEffect(ctxFlow({ email: 'a@b.com', password: 'secret1
|
|
|
189
258
|
expectType<SuccessState<{ email: string; password: string; conn: string }> | FailureState<unknown>>(ctxResult);
|
|
190
259
|
|
|
191
260
|
// wrong context shape should error
|
|
192
|
-
|
|
261
|
+
// @ts-expect-error context does not match Ctx
|
|
262
|
+
runEffect(ctxFlow({ email: 'a@b.com', password: 'secret123' }), { wrong: 'thing' });
|
|
193
263
|
|
|
194
264
|
// --- configureEffect / EffectConfiguration ---
|
|
195
265
|
|
|
@@ -206,8 +276,10 @@ configureEffect({ retry: { attempts: 5 } });
|
|
|
206
276
|
configureEffect({});
|
|
207
277
|
|
|
208
278
|
// rejects invalid shapes
|
|
209
|
-
|
|
210
|
-
|
|
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' } });
|
|
211
283
|
|
|
212
284
|
// hook types are correctly shaped
|
|
213
285
|
const myStep: StepRunner = async (name, type, op) => {
|
|
@@ -229,3 +301,35 @@ const myInterceptor: CommandInterceptor = async (cmd, _ctx) => {
|
|
|
229
301
|
// EffectConfiguration is a usable type
|
|
230
302
|
const config: EffectConfiguration = { onStep: myStep, onRun: myRun, onBeforeCommand: myInterceptor };
|
|
231
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: () => {} });
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"permissions": {
|
|
3
|
-
"allow": [
|
|
4
|
-
"Bash(npm test *)",
|
|
5
|
-
"Bash(npm view *)",
|
|
6
|
-
"Bash(npx mocha *)",
|
|
7
|
-
"Bash(echo \"exit: $?\")",
|
|
8
|
-
"Bash(npx prettier *)",
|
|
9
|
-
"Bash(echo \"npm test exit: $?\")",
|
|
10
|
-
"Bash(npx tsc *)",
|
|
11
|
-
"Bash(echo \"tsc exit: $?\")",
|
|
12
|
-
"Read(//tmp/**)",
|
|
13
|
-
"Bash(npx esbuild *)",
|
|
14
|
-
"Bash(gzip -9)"
|
|
15
|
-
]
|
|
16
|
-
}
|
|
17
|
-
}
|