pure-effect 0.9.0 → 0.11.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/test/all.js CHANGED
@@ -16,6 +16,8 @@ import {
16
16
  replayEffect,
17
17
  timeTravel
18
18
  } from '../index.js';
19
+ import * as lib from '../index.js';
20
+ import ts from 'typescript';
19
21
  import { enableTelemetry, telemetryHooks } from '../examples/opentelemetry-example.js';
20
22
  import { enableRecording, recordingHooks } from '../examples/recording-example.js';
21
23
 
@@ -247,6 +249,14 @@ describe('Core', function () {
247
249
  assert.equal(result.next, next);
248
250
  });
249
251
 
252
+ it('should default next to Success of the values array when omitted', async () => {
253
+ const e1 = Command(async () => 'a');
254
+ const e2 = Command(async () => 'b');
255
+ const result = await runEffect(Parallel([e1, e2]));
256
+ assert.equal(result.type, 'Success');
257
+ assert.deepEqual(result.value, ['a', 'b']);
258
+ });
259
+
250
260
  it('should run effects concurrently and pass results to next', async () => {
251
261
  const e1 = Command(
252
262
  async () => 'a',
@@ -309,6 +319,138 @@ describe('Core', function () {
309
319
  });
310
320
  });
311
321
 
322
+ describe('Retry onExhausted', function () {
323
+ beforeEach(() => configureEffect());
324
+
325
+ it('should run the fallback on exhaustion and feed its value downstream', async function () {
326
+ let liveCalls = 0;
327
+ /** @type {any} */
328
+ let sawError;
329
+ const flow = effectPipe(
330
+ (/** @type {any} */ sku) =>
331
+ Retry(
332
+ Command(function cmdFetchLive() {
333
+ liveCalls++;
334
+ return Promise.reject(new Error('down'));
335
+ }),
336
+ {
337
+ attempts: 2,
338
+ delay: 0,
339
+ onExhausted: (/** @type {any} */ err) => {
340
+ sawError = err;
341
+ return Command(function cmdFetchCached() {
342
+ return Promise.resolve({ sku, amount: 95 });
343
+ });
344
+ }
345
+ }
346
+ ),
347
+ (/** @type {any} */ price) => Success({ ...price, stale: true })
348
+ );
349
+ const result = /** @type {any} */ (await runEffect(flow('sku-1')));
350
+ assert.equal(result.type, 'Success');
351
+ assert.deepEqual(result.value, { sku: 'sku-1', amount: 95, stale: true });
352
+ assert.equal(liveCalls, 3, 'the primary still runs the full retry schedule first');
353
+ assert.equal(sawError.retryExhausted, true);
354
+ assert.equal(sawError.attempts, 2);
355
+ assert.equal(sawError.lastError.message, 'down');
356
+ });
357
+
358
+ it('should propagate a failing fallback unwrapped', async function () {
359
+ const flow = Retry(
360
+ Command(function cmdPrimary() {
361
+ return Promise.reject('primary down');
362
+ }),
363
+ { attempts: 0, delay: 0, onExhausted: () => Failure('cache empty') }
364
+ );
365
+ const result = /** @type {any} */ (await runEffect(flow));
366
+ assert.equal(result.type, 'Failure');
367
+ assert.equal(result.error, 'cache empty', 'the fallback failure must not be wrapped in retryExhausted');
368
+ });
369
+
370
+ it('should not run the fallback in a cancelled Parallel branch', async function () {
371
+ let fallbackRuns = 0;
372
+ const slowFailing = Retry(
373
+ Command(async function cmdSlowFail() {
374
+ await new Promise((resolve) => setTimeout(resolve, 30));
375
+ throw new Error('slow branch failed');
376
+ }),
377
+ {
378
+ attempts: 0,
379
+ delay: 0,
380
+ onExhausted: () => {
381
+ fallbackRuns++;
382
+ return Success('recovered');
383
+ }
384
+ }
385
+ );
386
+ const fastFailing = Command(async function cmdFastFail() {
387
+ await new Promise((resolve) => setTimeout(resolve, 5));
388
+ throw new Error('fast branch failed');
389
+ });
390
+ const result = /** @type {any} */ (await runEffect(Parallel([fastFailing, slowFailing])));
391
+ assert.equal(result.type, 'Failure');
392
+ assert.equal(result.error.message, 'fast branch failed');
393
+ assert.equal(fallbackRuns, 0, 'a cancelled branch must not start its fallback');
394
+ });
395
+
396
+ it('should record the fallback under its own path and replay it with zero I/O', async function () {
397
+ const calls = { live: 0, cached: 0 };
398
+ const flow = (/** @type {any} */ sku) =>
399
+ Retry(
400
+ Command(function cmdFetchLive() {
401
+ calls.live++;
402
+ return Promise.reject(new Error('down'));
403
+ }),
404
+ {
405
+ attempts: 1,
406
+ delay: 0,
407
+ onExhausted: () =>
408
+ Command(function cmdFetchCached() {
409
+ calls.cached++;
410
+ return Promise.resolve(95);
411
+ })
412
+ }
413
+ );
414
+ const { result, trace } = await recordEffect(flow, 'sku-1');
415
+ assert.equal(result.type, 'Success');
416
+ assert.ok(
417
+ trace.trace.some((/** @type {any} */ e) => e.command === 'cmdFetchCached' && /f\//.test(e.path)),
418
+ 'the fallback step must be recorded under a fallback path prefix'
419
+ );
420
+ const before = { ...calls };
421
+ const replayed = /** @type {any} */ (await replayEffect(flow('sku-1'), trace));
422
+ assert.equal(replayed.type, 'Success');
423
+ assert.deepEqual(replayed.value, /** @type {any} */ (result).value);
424
+ assert.deepEqual(calls, before, 'replay must perform no I/O');
425
+ });
426
+
427
+ it('should strip Retry delays inside the fallback during replay', async function () {
428
+ const flow = (/** @type {any} */ n) =>
429
+ Retry(
430
+ Command(function cmdPrimary() {
431
+ return Promise.reject('nope');
432
+ }),
433
+ {
434
+ attempts: 0,
435
+ delay: 0,
436
+ onExhausted: () =>
437
+ Retry(
438
+ Command(function cmdSecondary() {
439
+ return Promise.reject('still nope');
440
+ }),
441
+ { attempts: 2, delay: 200 }
442
+ )
443
+ }
444
+ );
445
+ const { trace } = await recordEffect(flow, 1);
446
+ const started = Date.now();
447
+ const replayed = /** @type {any} */ (await replayEffect(flow(1), trace));
448
+ const elapsed = Date.now() - started;
449
+ assert.equal(replayed.type, 'Failure');
450
+ assert.ok(elapsed < 150, `replay must not wait out the fallback backoff, took ${elapsed}ms`);
451
+ });
452
+ });
453
+
312
454
  /** Reads `.value` off a runEffect result once its type has been asserted. */
313
455
  const valueOf = (/** @type {any} */ result) => result.value;
314
456
 
@@ -474,7 +616,7 @@ describe('Recording and replay', function () {
474
616
  assert.equal(error.lastError.message, 'down');
475
617
  });
476
618
 
477
- it('should require name matching for Parallel, whose branches finish out of order', async function () {
619
+ it('should replay a Parallel flow whose branches finish out of order', async function () {
478
620
  const flow = (/** @type {any} */ id) =>
479
621
  Parallel(
480
622
  [
@@ -500,14 +642,115 @@ describe('Recording and replay', function () {
500
642
  ['cmdFast', 'cmdSlow'],
501
643
  'recording order follows completion, not the effects array'
502
644
  );
645
+ assert.deepEqual(
646
+ trace.trace.map((e) => e.path),
647
+ ['0p1/0', '0p0/0'],
648
+ 'each entry is labelled by its branch, so completion order does not matter'
649
+ );
503
650
 
504
- const strictReplay = await replayEffect(flow('u1'), trace);
505
- assert.equal(strictReplay.type, 'Failure', 'positional matching reports a false paradox');
506
- assert.equal(/** @type {Error} */ (errorOf(strictReplay)).name, 'TimeParadox');
651
+ // No `strict: false` needed: paths are order-independent, so the default replays correctly.
652
+ const replayed = await replayEffect(flow('u1'), trace);
653
+ assert.equal(replayed.type, 'Success');
654
+ assert.deepEqual(valueOf(replayed), valueOf(result));
655
+ });
507
656
 
508
- const replayed = await replayEffect(flow('u1'), trace, { strict: false });
657
+ it('should keep two Parallel branches that call the same Command apart', async function () {
658
+ // The failure this pins is silent: per-Command queues pair branches by completion order, so
659
+ // reversing the branch latencies between recording and replay swapped one branch's result
660
+ // onto the other and the replay still reported Success.
661
+ const flow = (/** @type {[number, number]} */ delays) =>
662
+ Parallel(
663
+ [
664
+ Command(
665
+ function cmdFetch() {
666
+ return new Promise((r) => setTimeout(() => r('A'), delays[0]));
667
+ },
668
+ (/** @type {any} */ v) => Success(v)
669
+ ),
670
+ Command(
671
+ function cmdFetch() {
672
+ return new Promise((r) => setTimeout(() => r('B'), delays[1]));
673
+ },
674
+ (/** @type {any} */ v) => Success(v)
675
+ )
676
+ ],
677
+ (/** @type {any} */ vals) => Success(vals)
678
+ );
679
+
680
+ const { result, trace } = await recordEffect(flow, [40, 5]);
681
+ assert.deepEqual(valueOf(result), ['A', 'B']);
682
+
683
+ // Replay with the latencies reversed, so completion order is the opposite of the recorded run.
684
+ const replayed = await replayEffect(flow([5, 40]), trace);
685
+ assert.equal(replayed.type, 'Success');
686
+ assert.deepEqual(valueOf(replayed), ['A', 'B'], 'branch results were not swapped');
687
+ });
688
+
689
+ it('should still resolve a legacy trace that carries no paths', async function () {
690
+ const a = makeFlow();
691
+ const { result, trace } = await recordEffect(a.flow, { id: 'legacy' });
692
+ const legacy = {
693
+ ...trace,
694
+ trace: trace.trace.map(({ path, ...rest }) => rest)
695
+ };
696
+ assert.ok(
697
+ legacy.trace.every((e) => e.path === undefined),
698
+ 'the fixture really has no paths'
699
+ );
700
+
701
+ const b = makeFlow();
702
+ const replayed = await replayEffect(b.flow(trace.initialInput), legacy);
509
703
  assert.equal(replayed.type, 'Success');
510
704
  assert.deepEqual(valueOf(replayed), valueOf(result));
705
+ assert.deepEqual(b.calls, { read: 0, write: 0 }, 'a legacy trace still performs no I/O');
706
+ });
707
+
708
+ it('should raise a TimeParadox naming the path when a flow diverges', async function () {
709
+ const original = (/** @type {any} */ id) =>
710
+ effectPipe(
711
+ () =>
712
+ Command(
713
+ function cmdRead() {
714
+ return id;
715
+ },
716
+ (/** @type {any} */ v) => Success(v)
717
+ ),
718
+ (/** @type {any} */ v) =>
719
+ Command(
720
+ function cmdWrite() {
721
+ return v;
722
+ },
723
+ (/** @type {any} */ w) => Success(w)
724
+ )
725
+ )(id);
726
+ const { trace } = await recordEffect(original, 'x1');
727
+
728
+ // The second step is a different Command than the trace recorded at that path.
729
+ const diverged = (/** @type {any} */ id) =>
730
+ effectPipe(
731
+ () =>
732
+ Command(
733
+ function cmdRead() {
734
+ return id;
735
+ },
736
+ (/** @type {any} */ v) => Success(v)
737
+ ),
738
+ (/** @type {any} */ v) =>
739
+ Command(
740
+ function cmdAudit() {
741
+ return v;
742
+ },
743
+ (/** @type {any} */ w) => Success(w)
744
+ )
745
+ )(id);
746
+
747
+ const replayed = await replayEffect(diverged('x1'), trace);
748
+ assert.equal(replayed.type, 'Failure');
749
+ const error = /** @type {any} */ (errorOf(replayed));
750
+ assert.equal(error.name, 'TimeParadox');
751
+ assert.equal(error.path, '1');
752
+ assert.equal(error.expected, 'cmdWrite');
753
+ assert.equal(error.actual, 'cmdAudit');
511
754
  });
512
755
 
513
756
  it('should accept a trace directly, without building a Resolver', async function () {
@@ -527,6 +770,41 @@ describe('Recording and replay', function () {
527
770
  assert.deepEqual(valueOf(fromEntries), valueOf(result));
528
771
  });
529
772
 
773
+ it('should carry an error cause through a trace and back', async function () {
774
+ const flow = (/** @type {any} */ input) =>
775
+ Command(function cmdCharge() {
776
+ return Promise.reject(new Error('charge declined', { cause: new Error('card expired') }));
777
+ });
778
+
779
+ const { result, trace } = await recordEffect(flow, { id: 'cause' });
780
+ assert.equal(result.type, 'Failure');
781
+
782
+ // The trace must survive JSON, which is where a non-enumerable cause would silently vanish.
783
+ const stored = JSON.parse(JSON.stringify(trace));
784
+ const replayed = await replayEffect(flow(stored.initialInput), stored);
785
+ assert.equal(replayed.type, 'Failure');
786
+ const error = /** @type {any} */ (errorOf(replayed));
787
+ assert.equal(error.message, 'charge declined');
788
+ assert.ok(error.cause instanceof Error, 'the revived error must keep its cause');
789
+ assert.equal(error.cause.message, 'card expired');
790
+ });
791
+
792
+ it('should carry a nested cause chain and a non-Error cause', async function () {
793
+ const flow = (/** @type {any} */ input) =>
794
+ Command(function cmdFetch() {
795
+ return Promise.reject(
796
+ new Error('fetch failed', { cause: new Error('socket closed', { cause: 'ECONNRESET' }) })
797
+ );
798
+ });
799
+
800
+ const { trace } = await recordEffect(flow, { id: 'chain' });
801
+ const stored = JSON.parse(JSON.stringify(trace));
802
+ const replayed = await replayEffect(flow(stored.initialInput), stored);
803
+ const error = /** @type {any} */ (errorOf(replayed));
804
+ assert.equal(error.cause.message, 'socket closed');
805
+ assert.equal(error.cause.cause, 'ECONNRESET', 'a non-Error cause must pass through unchanged');
806
+ });
807
+
530
808
  it('should reject a malformed trace as a ReplayError, not a TypeError', async function () {
531
809
  const { flow } = makeFlow();
532
810
  for (const bad of [undefined, null, 42, {}, { trace: 'nope' }]) {
@@ -1665,8 +1943,11 @@ describe('Documented sharp edges', function () {
1665
1943
  assert.equal(charges.length, 3, 'one order, three charges: wrap the Command, not the pipeline');
1666
1944
  });
1667
1945
 
1668
- it('should run every Parallel branch to completion even after one fails', async function () {
1669
- // Pinned deliberately: Promise.all has no cancellation, so a Failure does not stop sibling I/O.
1946
+ it('should not stop an in-flight Command whose thunk ignores the signal', async function () {
1947
+ // Pinned deliberately: cancellation is cooperative. The sibling's write is already in flight
1948
+ // inside the branch's first Command, and a thunk that ignores its signal cannot be interrupted,
1949
+ // so the write still lands. What cancellation does buy is in the suite below: no *later*
1950
+ // Command in that branch starts, and a thunk that honours the signal is cut off.
1670
1951
  /** @type {string[]} */
1671
1952
  const written = [];
1672
1953
  const result = await runEffect(
@@ -1695,7 +1976,7 @@ describe('Documented sharp edges', function () {
1695
1976
  );
1696
1977
  assert.equal(result.type, 'Failure');
1697
1978
  assert.equal(errorOf(result), 'validation_failed');
1698
- assert.deepEqual(written, ['wrote'], 'the sibling branch still performed its write');
1979
+ assert.deepEqual(written, ['wrote'], 'an uninterruptible in-flight write still performed');
1699
1980
  });
1700
1981
 
1701
1982
  it('should carry the initial input on every Failure', async function () {
@@ -1799,3 +2080,186 @@ describe('Malformed flows', function () {
1799
2080
  assert.equal(/** @type {Error} */ (errorOf(result)).message, 'boom');
1800
2081
  });
1801
2082
  });
2083
+
2084
+ describe('Parallel cancellation', function () {
2085
+ beforeEach(function () {
2086
+ configureEffect();
2087
+ });
2088
+
2089
+ /** Resolves after `ms`, or rejects as soon as `signal` aborts. */
2090
+ const cancellableSleep = (/** @type {number} */ ms, /** @type {AbortSignal} */ signal) =>
2091
+ new Promise((resolve, reject) => {
2092
+ const timer = setTimeout(resolve, ms);
2093
+ signal?.addEventListener(
2094
+ 'abort',
2095
+ () => {
2096
+ clearTimeout(timer);
2097
+ reject(new Error('aborted'));
2098
+ },
2099
+ { once: true }
2100
+ );
2101
+ });
2102
+
2103
+ it('should cancel a branch whose thunk honours the signal', async function () {
2104
+ let completed = false;
2105
+ const started = Date.now();
2106
+ const result = await runEffect(
2107
+ Parallel(
2108
+ [
2109
+ Command(function cmdFail() {
2110
+ throw new Error('fail_fast');
2111
+ }),
2112
+ Command(async function cmdSlow(/** @type {AbortSignal} */ signal) {
2113
+ await cancellableSleep(400, signal);
2114
+ completed = true;
2115
+ return 'wrote';
2116
+ })
2117
+ ],
2118
+ (/** @type {any} */ v) => Success(v)
2119
+ )
2120
+ );
2121
+ assert.equal(result.type, 'Failure');
2122
+ assert.equal(/** @type {Error} */ (errorOf(result)).message, 'fail_fast');
2123
+ assert.equal(completed, false, 'the cancelled branch never finished its work');
2124
+ assert.ok(Date.now() - started < 200, 'the run did not wait out the cancelled branch');
2125
+ });
2126
+
2127
+ it('should not start a later Command in a cancelled branch', async function () {
2128
+ /** @type {string[]} */
2129
+ const ran = [];
2130
+ const step = (/** @type {string} */ name) => () =>
2131
+ Command(
2132
+ async function cmdWrite() {
2133
+ await cancellableSleep(30, undefined);
2134
+ ran.push(name);
2135
+ return name;
2136
+ },
2137
+ (/** @type {any} */ v) => Success(v)
2138
+ );
2139
+ const result = await runEffect(
2140
+ Parallel(
2141
+ [
2142
+ Command(async function cmdFail() {
2143
+ await cancellableSleep(5, undefined);
2144
+ throw new Error('validation_failed');
2145
+ }),
2146
+ effectPipe(step('write1'), step('write2'), step('write3'))(null)
2147
+ ],
2148
+ (/** @type {any} */ v) => Success(v)
2149
+ )
2150
+ );
2151
+ assert.equal(result.type, 'Failure');
2152
+ assert.deepEqual(ran, ['write1'], 'the in-flight write landed; the two after it never started');
2153
+ });
2154
+
2155
+ it('should stop retrying in a cancelled branch', async function () {
2156
+ let attempts = 0;
2157
+ const result = await runEffect(
2158
+ Parallel(
2159
+ [
2160
+ Command(async function cmdFail() {
2161
+ await cancellableSleep(30, undefined);
2162
+ throw new Error('primary_failed');
2163
+ }),
2164
+ Retry(
2165
+ Command(function cmdFlaky() {
2166
+ attempts++;
2167
+ throw new Error('flaky');
2168
+ }),
2169
+ { attempts: 5, delay: 100 }
2170
+ )
2171
+ ],
2172
+ (/** @type {any} */ v) => Success(v)
2173
+ )
2174
+ );
2175
+ assert.equal(/** @type {Error} */ (errorOf(result)).message, 'primary_failed');
2176
+ assert.ok(attempts < 5, `the cancelled branch stopped retrying (made ${attempts} attempts, not 6)`);
2177
+ });
2178
+
2179
+ it('should propagate cancellation into a nested Parallel', async function () {
2180
+ let inner = false;
2181
+ const result = await runEffect(
2182
+ Parallel(
2183
+ [
2184
+ Command(function cmdOuterFail() {
2185
+ throw new Error('outer_failed');
2186
+ }),
2187
+ Parallel(
2188
+ [
2189
+ Command(async function cmdInner(/** @type {AbortSignal} */ signal) {
2190
+ await cancellableSleep(300, signal);
2191
+ inner = true;
2192
+ return 1;
2193
+ })
2194
+ ],
2195
+ (/** @type {any} */ v) => Success(v)
2196
+ )
2197
+ ],
2198
+ (/** @type {any} */ v) => Success(v)
2199
+ )
2200
+ );
2201
+ assert.equal(/** @type {Error} */ (errorOf(result)).message, 'outer_failed');
2202
+ assert.equal(inner, false, 'the inner branch was cancelled with the outer one');
2203
+ });
2204
+
2205
+ it('should return the triggering Failure rather than the cancellation', async function () {
2206
+ const result = await runEffect(
2207
+ Parallel(
2208
+ [
2209
+ Command(async function cmdSlowOk(/** @type {AbortSignal} */ signal) {
2210
+ await cancellableSleep(200, signal);
2211
+ return 'ok';
2212
+ }),
2213
+ Command(function cmdRealFailure() {
2214
+ throw new Error('THE_REAL_ERROR');
2215
+ })
2216
+ ],
2217
+ (/** @type {any} */ v) => Success(v)
2218
+ )
2219
+ );
2220
+ assert.equal(
2221
+ /** @type {Error} */ (errorOf(result)).message,
2222
+ 'THE_REAL_ERROR',
2223
+ 'a cancelled sibling must not displace the failure that caused the cancellation'
2224
+ );
2225
+ });
2226
+
2227
+ it('should still pick the first Failure by array order when branches fail together', async function () {
2228
+ const result = await runEffect(Parallel([Failure('a'), Failure('b')], (/** @type {any} */ v) => Success(v)));
2229
+ assert.equal(errorOf(result), 'a');
2230
+ });
2231
+
2232
+ it('should pass no argument to a Command outside a Parallel', async function () {
2233
+ /** @type {any[]} */
2234
+ const args = [];
2235
+ const result = await runEffect(
2236
+ Command(function cmdRecordArgs() {
2237
+ args.push([...arguments]);
2238
+ return 'done';
2239
+ })
2240
+ );
2241
+ assert.equal(result.type, 'Success');
2242
+ assert.deepEqual(args, [[]], 'a thunk outside a Parallel is called with no arguments at all');
2243
+ });
2244
+ });
2245
+
2246
+ describe('Declaration parity', function () {
2247
+ it('should declare exactly the exports the runtime ships', function () {
2248
+ // index.d.ts is hand-maintained beside index.js, so the two can drift silently: a
2249
+ // declaration once vanished as edit collateral while every check stayed green. This pins
2250
+ // existence in both directions; signatures remain tsd's job.
2251
+ const program = ts.createProgram(['index.d.ts'], { noEmit: true });
2252
+ const source = program.getSourceFile('index.d.ts');
2253
+ assert.ok(source, 'index.d.ts must be part of the program');
2254
+ const checker = program.getTypeChecker();
2255
+ const moduleSymbol = checker.getSymbolAtLocation(/** @type {import('typescript').Node} */ (source));
2256
+ assert.ok(moduleSymbol, 'index.d.ts must be a module');
2257
+ const declared = checker
2258
+ .getExportsOfModule(moduleSymbol)
2259
+ .filter((s) => s.flags & ts.SymbolFlags.Value)
2260
+ .map((s) => s.name)
2261
+ .sort();
2262
+ const shipped = Object.keys(lib).sort();
2263
+ assert.deepEqual(declared, shipped);
2264
+ });
2265
+ });
@@ -104,13 +104,14 @@ const innerCmd = Command(
104
104
  (n) => Success(n)
105
105
  );
106
106
 
107
- // Retry with options preserves T
107
+ // Retry with options preserves T; the error type is the exhaustion failure, not the inner error,
108
+ // because the interpreter wraps the last inner error rather than letting it escape
108
109
  const retried = Retry(innerCmd, { attempts: 3 });
109
- expectType<RetryState<number, unknown>>(retried);
110
+ expectType<RetryState<number, RetryExhaustedError<unknown>>>(retried);
110
111
 
111
112
  // Retry without options is valid
112
113
  const retriedNoOpts = Retry(innerCmd);
113
- expectType<RetryState<number, unknown>>(retriedNoOpts);
114
+ expectType<RetryState<number, RetryExhaustedError<unknown>>>(retriedNoOpts);
114
115
 
115
116
  // Retry in effectPipe preserves type flow
116
117
  const retryFlow = effectPipe((input: User) =>
@@ -122,7 +123,35 @@ const retryFlow = effectPipe((input: User) =>
122
123
  { attempts: 2 }
123
124
  )
124
125
  );
125
- expectType<Effect<SavedUser>>(retryFlow({ email: 'a@b.com', password: 'secret123' }));
126
+ expectType<Effect<SavedUser, RetryExhaustedError<unknown>>>(retryFlow({ email: 'a@b.com', password: 'secret123' }));
127
+
128
+ // The inner error type survives inside the exhaustion failure, so lastError is typed
129
+ const retriedTyped = Retry(Success(1) as Effect<number, 'net_down'>);
130
+ expectType<RetryState<number, RetryExhaustedError<'net_down'>>>(retriedTyped);
131
+
132
+ const retryResult = await runEffect(Retry(Success(1) as Effect<number, 'flaky'>, { attempts: 1 }));
133
+ if (retryResult.type === 'Failure') {
134
+ expectType<RetryExhaustedError<'flaky'>>(retryResult.error);
135
+ expectType<'flaky'>(retryResult.error.lastError);
136
+ }
137
+
138
+ // onExhausted consumes the exhaustion, so the fallback's error type is what remains
139
+ const recovered = Retry(Success(1) as Effect<number, 'flaky'>, {
140
+ attempts: 1,
141
+ onExhausted: (err) => {
142
+ expectType<RetryExhaustedError<'flaky'>>(err);
143
+ return Success(0) as Effect<number, 'cache_miss'>;
144
+ }
145
+ });
146
+ expectType<RetryState<number, 'cache_miss'>>(recovered);
147
+
148
+ const recoveredResult = await runEffect(recovered);
149
+ if (recoveredResult.type === 'Failure') {
150
+ expectType<'cache_miss'>(recoveredResult.error);
151
+ }
152
+
153
+ // onExhausted is per-use only: global retry defaults cannot carry a fallback
154
+ expectError(configureEffect({ retry: { attempts: 2, onExhausted: () => Success(1) } }));
126
155
 
127
156
  // RetryExhaustedError shape is usable for narrowing exhaustion failures
128
157
  const exhaustedErr: RetryExhaustedError<Error> = {
@@ -168,6 +197,14 @@ expectType<Effect<{ email: string; password: string }>>(parallelFlow({ email: 'a
168
197
  const parallelResult = await runEffect(Parallel([Success(1), Success('x')], ([n, s]) => Success({ n, s })));
169
198
  expectType<SuccessState<{ n: number; s: string }> | FailureState<unknown>>(parallelResult);
170
199
 
200
+ // With next omitted, the Parallel resolves to the values tuple itself
201
+ const parBare = Parallel([Success(42), Success('hello')]);
202
+ expectType<ParallelState<[number, string], [number, string]>>(parBare);
203
+ const parBareResult = await runEffect(parBare);
204
+ if (parBareResult.type === 'Success') {
205
+ expectType<[number, string]>(parBareResult.value);
206
+ }
207
+
171
208
  // --- Ctx (context type) ---
172
209
 
173
210
  interface AppCtx {
@@ -0,0 +1,12 @@
1
+ {
2
+ "compilerOptions": {
3
+ "allowJs": true,
4
+ "checkJs": true,
5
+ "noEmit": true,
6
+ "target": "es2022",
7
+ "module": "nodenext",
8
+ "moduleResolution": "nodenext",
9
+ "lib": ["es2022", "dom"]
10
+ },
11
+ "files": ["index.js"]
12
+ }
@@ -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
- }