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/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 { result: 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 { result: 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
 
@@ -328,7 +470,10 @@ describe('Recording and replay', function () {
328
470
  'every Command in the flow is recorded, including the guard'
329
471
  );
330
472
 
331
- const replayed = await replayEffect(registerUserFlow(/** @type {User} */ (trace.initialInput)), trace);
473
+ const { result: replayed } = await replayEffect(
474
+ registerUserFlow(/** @type {User} */ (trace.initialInput)),
475
+ trace
476
+ );
332
477
  assert.equal(replayed.type, 'Success');
333
478
  assert.deepEqual(valueOf(replayed), valueOf(result));
334
479
  });
@@ -367,7 +512,7 @@ describe('Recording and replay', function () {
367
512
  assert.equal(trace.version, 'abc123');
368
513
 
369
514
  const b = makeFlow();
370
- const replayed = await replayEffect(b.flow(trace.initialInput), trace);
515
+ const { result: replayed } = await replayEffect(b.flow(trace.initialInput), trace);
371
516
  assert.equal(replayed.type, 'Success');
372
517
  assert.deepEqual(valueOf(replayed), valueOf(result));
373
518
  assert.deepEqual(b.calls, { read: 0, write: 0 }, 'replay must not touch the world');
@@ -400,7 +545,7 @@ describe('Recording and replay', function () {
400
545
  );
401
546
 
402
547
  n = 100;
403
- const replayed = await replayEffect(readTwice(null), trace);
548
+ const { result: replayed } = await replayEffect(readTwice(null), trace);
404
549
  assert.deepEqual(valueOf(replayed), [1, 2], 'each call got its own recorded value');
405
550
  assert.equal(n, 100, 'the real command never ran');
406
551
  });
@@ -419,7 +564,7 @@ describe('Recording and replay', function () {
419
564
  const { trace } = await recordEffect(flow, 'sku-1', { context: { tenant: 'acme', flowName: 'lookup' } });
420
565
  assert.equal(/** @type {any} */ (trace.context).tenant, 'acme');
421
566
 
422
- const replayed = await replayEffect(flow('sku-1'), trace, { context: trace.context });
567
+ const { result: replayed } = await replayEffect(flow('sku-1'), trace, { context: trace.context });
423
568
  assert.deepEqual(valueOf(replayed), { id: 'sku-1', tenant: 'acme' });
424
569
  });
425
570
 
@@ -444,7 +589,7 @@ describe('Recording and replay', function () {
444
589
 
445
590
  attempt = 0;
446
591
  const start = Date.now();
447
- const replayed = await replayEffect(flow(), trace);
592
+ const { result: replayed } = await replayEffect(flow(), trace);
448
593
  const elapsed = Date.now() - start;
449
594
  assert.deepEqual(valueOf(replayed), { tempC: 21 });
450
595
  assert.equal(attempt, 0, 'no attempt was re-executed');
@@ -466,7 +611,7 @@ describe('Recording and replay', function () {
466
611
  const { result, trace } = await recordEffect(flow, null);
467
612
  assert.equal(result.type, 'Failure');
468
613
 
469
- const replayed = await replayEffect(flow(), trace);
614
+ const { result: replayed } = await replayEffect(flow(), trace);
470
615
  assert.equal(replayed.type, 'Failure');
471
616
  const error = /** @type {import('../index.js').RetryExhaustedError<Error>} */ (errorOf(replayed));
472
617
  assert.equal(error.retryExhausted, true);
@@ -506,8 +651,8 @@ describe('Recording and replay', function () {
506
651
  'each entry is labelled by its branch, so completion order does not matter'
507
652
  );
508
653
 
509
- // No `strict: false` needed: paths are order-independent, so the default replays correctly.
510
- const replayed = await replayEffect(flow('u1'), trace);
654
+ // Paths are order-independent, so the trace replays correctly whatever order the branches finished in.
655
+ const { result: replayed } = await replayEffect(flow('u1'), trace);
511
656
  assert.equal(replayed.type, 'Success');
512
657
  assert.deepEqual(valueOf(replayed), valueOf(result));
513
658
  });
@@ -539,7 +684,7 @@ describe('Recording and replay', function () {
539
684
  assert.deepEqual(valueOf(result), ['A', 'B']);
540
685
 
541
686
  // Replay with the latencies reversed, so completion order is the opposite of the recorded run.
542
- const replayed = await replayEffect(flow([5, 40]), trace);
687
+ const { result: replayed } = await replayEffect(flow([5, 40]), trace);
543
688
  assert.equal(replayed.type, 'Success');
544
689
  assert.deepEqual(valueOf(replayed), ['A', 'B'], 'branch results were not swapped');
545
690
  });
@@ -552,12 +697,12 @@ describe('Recording and replay', function () {
552
697
  trace: trace.trace.map(({ path, ...rest }) => rest)
553
698
  };
554
699
  assert.ok(
555
- legacy.trace.every((e) => e.path === undefined),
700
+ legacy.trace.every((e) => !('path' in e)),
556
701
  'the fixture really has no paths'
557
702
  );
558
703
 
559
704
  const b = makeFlow();
560
- const replayed = await replayEffect(b.flow(trace.initialInput), legacy);
705
+ const { result: replayed } = await replayEffect(b.flow(trace.initialInput), legacy);
561
706
  assert.equal(replayed.type, 'Success');
562
707
  assert.deepEqual(valueOf(replayed), valueOf(result));
563
708
  assert.deepEqual(b.calls, { read: 0, write: 0 }, 'a legacy trace still performs no I/O');
@@ -602,7 +747,7 @@ describe('Recording and replay', function () {
602
747
  )
603
748
  )(id);
604
749
 
605
- const replayed = await replayEffect(diverged('x1'), trace);
750
+ const { result: replayed } = await replayEffect(diverged('x1'), trace);
606
751
  assert.equal(replayed.type, 'Failure');
607
752
  const error = /** @type {any} */ (errorOf(replayed));
608
753
  assert.equal(error.name, 'TimeParadox');
@@ -611,23 +756,212 @@ describe('Recording and replay', function () {
611
756
  assert.equal(error.actual, 'cmdAudit');
612
757
  });
613
758
 
759
+ it('should report recorded steps a shortened flow never reached, which no TimeParadox covers', async function () {
760
+ const a = makeFlow();
761
+ const { trace } = await recordEffect(a.flow, { id: 'x1' });
762
+ assert.equal(trace.trace.length, 2);
763
+
764
+ // The same flow with its last Command removed: no step mismatches, so replay ends in Success
765
+ // with the recorded write never asked for. That silence is the gap `unreached` closes.
766
+ const shortened = (/** @type {any} */ input) =>
767
+ effectPipe(
768
+ (/** @type {any} */ i) => (i.id ? Success(i) : Failure('no_id')),
769
+ (/** @type {any} */ i) =>
770
+ Command(
771
+ function cmdRead() {
772
+ return { row: i.id };
773
+ },
774
+ (/** @type {any} */ row) => Success({ ...i, ...row })
775
+ )
776
+ )(input);
777
+
778
+ const { result: replayed, unreached } = await replayEffect(shortened(trace.initialInput), trace);
779
+ assert.equal(replayed.type, 'Success');
780
+ assert.deepEqual(
781
+ unreached.map((/** @type {any} */ e) => e.command),
782
+ ['cmdWrite']
783
+ );
784
+ assert.equal(unreached[0].path, '1');
785
+ assert.strictEqual(unreached[0], trace.trace[1], 'the recorded entry itself, not a copy');
786
+ });
787
+
788
+ it('should report an empty unreached list when every recorded step was replayed', async function () {
789
+ const a = makeFlow();
790
+ const { trace } = await recordEffect(a.flow, { id: 'x1' });
791
+ const b = makeFlow();
792
+ const { unreached } = await replayEffect(b.flow(trace.initialInput), trace);
793
+ assert.deepEqual(unreached, []);
794
+ });
795
+
796
+ it('should still report unreached steps when replay halts on a TimeParadox', async function () {
797
+ const a = makeFlow();
798
+ const { trace } = await recordEffect(a.flow, { id: 'x1' });
799
+ const diverged = (/** @type {any} */ input) =>
800
+ effectPipe(
801
+ (/** @type {any} */ i) => Success(i),
802
+ (/** @type {any} */ i) =>
803
+ Command(
804
+ function cmdAudit() {
805
+ return i;
806
+ },
807
+ (/** @type {any} */ v) => Success(v)
808
+ )
809
+ )(input);
810
+ const { result: replayed, unreached } = await replayEffect(diverged(trace.initialInput), trace);
811
+ assert.equal(/** @type {any} */ (errorOf(replayed)).name, 'TimeParadox');
812
+ // The paradox fired at the first step, so nothing in the trace was handed out.
813
+ assert.deepEqual(
814
+ unreached.map((/** @type {any} */ e) => e.command),
815
+ ['cmdRead', 'cmdWrite']
816
+ );
817
+ });
818
+
819
+ it('should report unreached steps for a legacy trace and for a bare entries array', async function () {
820
+ const a = makeFlow();
821
+ const { trace } = await recordEffect(a.flow, { id: 'legacy' });
822
+ const legacy = trace.trace.map(({ path, ...rest }) => rest);
823
+ const readOnly = (/** @type {any} */ input) =>
824
+ effectPipe(
825
+ (/** @type {any} */ i) => Success(i),
826
+ (/** @type {any} */ i) =>
827
+ Command(
828
+ function cmdRead() {
829
+ return { row: i.id };
830
+ },
831
+ (/** @type {any} */ row) => Success(row)
832
+ )
833
+ )(input);
834
+
835
+ const { result: replayed, unreached } = await replayEffect(readOnly(trace.initialInput), legacy);
836
+ assert.equal(replayed.type, 'Success');
837
+ assert.deepEqual(
838
+ unreached.map((/** @type {any} */ e) => e.command),
839
+ ['cmdWrite']
840
+ );
841
+ });
842
+
843
+ it('should return no unreached list when a Resolver supplies the outcomes', async function () {
844
+ const a = makeFlow();
845
+ const { trace } = await recordEffect(a.flow, { id: 'x1' });
846
+ const b = makeFlow();
847
+ const replay = await replayEffect(b.flow(trace.initialInput), (step) => ({
848
+ result: trace.trace[step.index].result
849
+ }));
850
+ assert.equal(replay.result.type, 'Success');
851
+ assert.equal('unreached' in replay, false, 'only a trace knows what it holds');
852
+ });
853
+
854
+ it('should refuse to match a Parallel step positionally when the trace carries no paths', async function () {
855
+ const flow = (/** @type {any} */ id) =>
856
+ Parallel(
857
+ [
858
+ Command(function cmdLeft() {
859
+ return { id };
860
+ }),
861
+ Command(function cmdRight() {
862
+ return 'r';
863
+ })
864
+ ],
865
+ (/** @type {any} */ v) => Success(v)
866
+ );
867
+ const { trace } = await recordEffect(flow, 'u1');
868
+ const legacy = trace.trace.map(({ path, ...rest }) => rest);
869
+
870
+ const { result: replayed } = await replayEffect(flow('u1'), legacy);
871
+ assert.equal(replayed.type, 'Failure');
872
+ const error = /** @type {any} */ (errorOf(replayed));
873
+ assert.equal(error.name, 'ReplayError');
874
+ assert.match(error.message, /Parallel/);
875
+ });
876
+
614
877
  it('should accept a trace directly, without building a Resolver', async function () {
615
878
  const a = makeFlow();
616
879
  const { result, trace } = await recordEffect(a.flow, { id: 'direct' });
617
880
 
618
881
  const b = makeFlow();
619
- const replayed = await replayEffect(b.flow(trace.initialInput), trace);
882
+ const { result: replayed } = await replayEffect(b.flow(trace.initialInput), trace);
620
883
  assert.equal(replayed.type, 'Success');
621
884
  assert.deepEqual(valueOf(replayed), valueOf(result));
622
885
  assert.deepEqual(b.calls, { read: 0, write: 0 });
623
886
 
624
887
  // A bare entries array is a valid trace too, without the surrounding TraceLog.
625
888
  const c = makeFlow();
626
- const fromEntries = await replayEffect(c.flow(trace.initialInput), trace.trace);
889
+ const { result: fromEntries } = await replayEffect(c.flow(trace.initialInput), trace.trace);
627
890
  assert.equal(fromEntries.type, 'Success');
628
891
  assert.deepEqual(valueOf(fromEntries), valueOf(result));
629
892
  });
630
893
 
894
+ it('should carry an error cause through a trace and back', async function () {
895
+ const flow = (/** @type {any} */ input) =>
896
+ Command(function cmdCharge() {
897
+ return Promise.reject(new Error('charge declined', { cause: new Error('card expired') }));
898
+ });
899
+
900
+ const { result, trace } = await recordEffect(flow, { id: 'cause' });
901
+ assert.equal(result.type, 'Failure');
902
+
903
+ // The trace must survive JSON, which is where a non-enumerable cause would silently vanish.
904
+ const stored = JSON.parse(JSON.stringify(trace));
905
+ const { result: replayed } = await replayEffect(flow(stored.initialInput), stored);
906
+ assert.equal(replayed.type, 'Failure');
907
+ const error = /** @type {any} */ (errorOf(replayed));
908
+ assert.equal(error.message, 'charge declined');
909
+ assert.ok(error.cause instanceof Error, 'the revived error must keep its cause');
910
+ assert.equal(error.cause.message, 'card expired');
911
+ });
912
+
913
+ it('should carry a nested cause chain and a non-Error cause', async function () {
914
+ const flow = (/** @type {any} */ input) =>
915
+ Command(function cmdFetch() {
916
+ return Promise.reject(
917
+ new Error('fetch failed', { cause: new Error('socket closed', { cause: 'ECONNRESET' }) })
918
+ );
919
+ });
920
+
921
+ const { trace } = await recordEffect(flow, { id: 'chain' });
922
+ const stored = JSON.parse(JSON.stringify(trace));
923
+ const { result: replayed } = await replayEffect(flow(stored.initialInput), stored);
924
+ const error = /** @type {any} */ (errorOf(replayed));
925
+ assert.equal(error.cause.message, 'socket closed');
926
+ assert.equal(error.cause.cause, 'ECONNRESET', 'a non-Error cause must pass through unchanged');
927
+ });
928
+
929
+ it('should revive an error that compares deep-equal to the one the Command threw', async function () {
930
+ // The README's determinism check is `assert.deepEqual(replayed, result)`, so it has to hold for a
931
+ // Failure too. `name` and `cause` are non-enumerable on a native Error; a revived error that carried
932
+ // them as own enumerable keys would never compare equal, whatever its message said.
933
+ const flow = (/** @type {any} */ input) =>
934
+ Command(function cmdCharge() {
935
+ const e = Object.assign(new Error('declined', { cause: new Error('expired') }), {
936
+ code: 'card_declined'
937
+ });
938
+ return Promise.reject(e);
939
+ });
940
+
941
+ const { result, trace } = await recordEffect(flow, { id: 'deq' });
942
+ const stored = JSON.parse(JSON.stringify(trace));
943
+ const { result: replayed } = await replayEffect(flow({ id: 'deq' }), stored);
944
+ assert.deepEqual(replayed, result);
945
+
946
+ const error = /** @type {any} */ (errorOf(replayed));
947
+ assert.deepEqual(Object.keys(error), ['code'], 'only the custom property is an own enumerable key');
948
+ assert.equal(error.name, 'Error');
949
+ assert.equal(error.cause.message, 'expired');
950
+ });
951
+
952
+ it('should keep a custom error name readable after revival', async function () {
953
+ const flow = (/** @type {any} */ input) =>
954
+ Command(function cmdCharge() {
955
+ return Promise.reject(Object.assign(new Error('nope'), { name: 'GatewayError' }));
956
+ });
957
+ const { trace } = await recordEffect(flow, { id: 'named' });
958
+ const { result: replayed } = await replayEffect(flow({ id: 'named' }), JSON.parse(JSON.stringify(trace)));
959
+ const error = /** @type {any} */ (errorOf(replayed));
960
+ assert.equal(error.name, 'GatewayError');
961
+ assert.match(String(error), /^GatewayError: nope/);
962
+ assert.equal(JSON.stringify(error), '{}', 'name stays non-enumerable, as on a native Error');
963
+ });
964
+
631
965
  it('should reject a malformed trace as a ReplayError, not a TypeError', async function () {
632
966
  const { flow } = makeFlow();
633
967
  for (const bad of [undefined, null, 42, {}, { trace: 'nope' }]) {
@@ -656,7 +990,7 @@ describe('Recording and replay', function () {
656
990
  );
657
991
 
658
992
  const { trace } = await recordEffect(flowA, null);
659
- const replayed = await replayEffect(flowB(), trace);
993
+ const { result: replayed } = await replayEffect(flowB(), trace);
660
994
  assert.equal(replayed.type, 'Failure');
661
995
  assert.equal(/** @type {Error} */ (errorOf(replayed)).name, 'TimeParadox');
662
996
  assert.match(
@@ -667,14 +1001,14 @@ describe('Recording and replay', function () {
667
1001
 
668
1002
  it('should report an exhausted trace rather than silently succeeding', async function () {
669
1003
  const { flow } = makeFlow();
670
- const replayed = await replayEffect(flow({ id: 'x' }), { trace: [] });
1004
+ const { result: replayed } = await replayEffect(flow({ id: 'x' }), { trace: [] });
671
1005
  assert.equal(replayed.type, 'Failure');
672
1006
  assert.match(/** @type {Error} */ (errorOf(replayed)).message, /Trace exhausted/);
673
1007
  });
674
1008
 
675
1009
  it('should refuse to run an unrecorded Command by default', async function () {
676
1010
  const { flow, calls } = makeFlow();
677
- const replayed = await replayEffect(flow({ id: 'x' }), () => undefined);
1011
+ const { result: replayed } = await replayEffect(flow({ id: 'x' }), () => undefined);
678
1012
  assert.equal(replayed.type, 'Failure');
679
1013
  assert.match(/** @type {Error} */ (errorOf(replayed)).message, /refusing to run the real Command/);
680
1014
  assert.deepEqual(calls, { read: 0, write: 0 });
@@ -686,7 +1020,7 @@ describe('Recording and replay', function () {
686
1020
  /** @type {import('../index.js').Resolver} */
687
1021
  const resolve = (step) => (step.index === 0 ? { result: recorded[0].result } : undefined);
688
1022
 
689
- const replayed = await replayEffect(flow({ id: 'x' }), resolve, { onMissing: 'execute' });
1023
+ const { result: replayed } = await replayEffect(flow({ id: 'x' }), resolve, { onMissing: 'execute' });
690
1024
  assert.equal(replayed.type, 'Success');
691
1025
  assert.deepEqual(valueOf(replayed), { written: 'FROM_TRACE' });
692
1026
  assert.deepEqual(calls, { read: 0, write: 1 }, 'only the unrecorded step performed I/O');
@@ -819,7 +1153,7 @@ describe('Recording and replay', function () {
819
1153
  assert.equal(result.type, 'Success');
820
1154
  assert.equal(/** @type {any} */ (trace.initialInput).password, '[redacted]');
821
1155
 
822
- const replayed = await replayEffect(flow(trace.initialInput), trace);
1156
+ const { result: replayed } = await replayEffect(flow(trace.initialInput), trace);
823
1157
  assert.equal(replayed.type, 'Success', 'the redacted input still rebuilds a matching flow');
824
1158
  assert.deepEqual(valueOf(replayed), valueOf(result), 'and the recorded hash comes back intact');
825
1159
  });
@@ -877,10 +1211,33 @@ describe('Recording and replay', function () {
877
1211
  assert.match(out, /Replaying 'checkout' \(2 recorded steps\)/);
878
1212
  assert.match(out, /Step 1: cmdRead returned/);
879
1213
  assert.doesNotMatch(out, / in \d/, 'a hand-written trace has no timings to narrate');
880
- assert.match(out, /1 recorded steps were never reached/);
1214
+ assert.match(out, /1 recorded step was never reached: cmdWrite\. The flow diverged/);
881
1215
  });
882
1216
 
883
- it('should narrate a Parallel flow when strict is off', async function () {
1217
+ it('should not warn about unreached steps when a TimeParadox already named the divergence', async function () {
1218
+ const a = makeFlow();
1219
+ const { trace } = await recordEffect(a.flow, { id: 'x1' });
1220
+ const diverged = (/** @type {any} */ input) =>
1221
+ effectPipe(
1222
+ (/** @type {any} */ i) => Success(i),
1223
+ (/** @type {any} */ i) =>
1224
+ Command(
1225
+ function cmdAudit() {
1226
+ return i;
1227
+ },
1228
+ (/** @type {any} */ v) => Success(v)
1229
+ )
1230
+ )(input);
1231
+ /** @type {string[]} */
1232
+ const lines = [];
1233
+ const result = await timeTravel(diverged, trace, { log: (l) => lines.push(l) });
1234
+ assert.equal(/** @type {any} */ (errorOf(result)).name, 'TimeParadox');
1235
+ const out = lines.join('\n');
1236
+ assert.match(out, /Time paradox at path '0'/);
1237
+ assert.doesNotMatch(out, /never reached/, 'the paradox is the news; the steps behind it are not');
1238
+ });
1239
+
1240
+ it('should narrate a Parallel flow', async function () {
884
1241
  const flow = (/** @type {any} */ id) =>
885
1242
  Parallel(
886
1243
  [
@@ -903,9 +1260,9 @@ describe('Recording and replay', function () {
903
1260
  const { trace } = await recordEffect(flow, 'u1');
904
1261
  /** @type {string[]} */
905
1262
  const lines = [];
906
- const result = await timeTravel(flow, trace, { strict: false, log: (l) => lines.push(l) });
1263
+ const result = await timeTravel(flow, trace, { log: (l) => lines.push(l) });
907
1264
 
908
- assert.equal(result.type, 'Success', 'strict must reach the trace resolution');
1265
+ assert.equal(result.type, 'Success');
909
1266
  const out = lines.join('\n');
910
1267
  assert.match(out, /cmdSlow returned/);
911
1268
  assert.match(out, /cmdFast returned/);
@@ -941,10 +1298,7 @@ describe('Recording and replay', function () {
941
1298
  assert.equal(invocations, 3);
942
1299
 
943
1300
  invocations = 0;
944
- const replayed = await replayEffect(flow({ id: 1 }), trace, {
945
- strict: false,
946
- context: trace.context
947
- });
1301
+ const { result: replayed } = await replayEffect(flow({ id: 1 }), trace, { context: trace.context });
948
1302
  assert.equal(replayed.type, 'Success');
949
1303
  assert.deepEqual(valueOf(replayed), valueOf(result));
950
1304
  assert.equal(invocations, 0, 'no Command thunk was applied');
@@ -1518,7 +1872,7 @@ describe('Recorded step timings', function () {
1518
1872
  const flow = (/** @type {any} */ input) => effectPipe(() => slow(5))(input);
1519
1873
  const { result, trace } = await recordEffect(flow, { id: 1 });
1520
1874
  assert.equal(typeof trace.trace[0].durationMs, 'number');
1521
- const replayed = await replayEffect(flow({ id: 1 }), trace);
1875
+ const { result: replayed } = await replayEffect(flow({ id: 1 }), trace);
1522
1876
  assert.equal(replayed.type, 'Success');
1523
1877
  assert.deepEqual(valueOf(replayed), valueOf(result));
1524
1878
  });
@@ -1629,7 +1983,7 @@ describe('Command identity', function () {
1629
1983
  },
1630
1984
  (/** @type {any} */ v) => Success(v)
1631
1985
  );
1632
- for (const meta of ['a string', 42, null, { name: 7 }, { name: '' }]) {
1986
+ for (const meta of /** @type {any[]} */ (['a string', 42, null, { name: 7 }, { name: '' }])) {
1633
1987
  const effect = Command(named().cmd, (/** @type {any} */ v) => Success(v), meta);
1634
1988
  await runEffect(effect, {}, { onStep });
1635
1989
  }
@@ -1660,8 +2014,8 @@ describe('Command identity', function () {
1660
2014
  'meta.name survives the rebuild chain performs on each pipe step'
1661
2015
  );
1662
2016
 
1663
- const replayed = await replayEffect(flow({ id: 'x1' }), trace);
1664
- assert.equal(replayed.type, 'Success', 'strict matching lines up on meta.name');
2017
+ const { result: replayed } = await replayEffect(flow({ id: 'x1' }), trace);
2018
+ assert.equal(replayed.type, 'Success', 'replay matching lines up on meta.name');
1665
2019
  assert.deepEqual(valueOf(replayed), valueOf(result));
1666
2020
  });
1667
2021
  });
@@ -1840,7 +2194,7 @@ describe('Malformed flows', function () {
1840
2194
 
1841
2195
  it('should call a missing return what it usually is', async function () {
1842
2196
  function ensureEmailAvailable() {}
1843
- const e = await errorFrom(() => runEffect(effectPipe(ensureEmailAvailable)({ id: 1 })));
2197
+ const e = await errorFrom(() => runEffect(effectPipe(/** @type {any} */ (ensureEmailAvailable))({ id: 1 })));
1844
2198
  assert.equal(e?.name, 'EffectTypeError');
1845
2199
  assert.match(e.message, /returned undefined, which usually means a missing return/);
1846
2200
  });
@@ -1909,8 +2263,12 @@ describe('Parallel cancellation', function () {
1909
2263
  configureEffect();
1910
2264
  });
1911
2265
 
1912
- /** Resolves after `ms`, or rejects as soon as `signal` aborts. */
1913
- const cancellableSleep = (/** @type {number} */ ms, /** @type {AbortSignal} */ signal) =>
2266
+ /**
2267
+ * Resolves after `ms`, or rejects as soon as `signal` aborts.
2268
+ * @param {number} ms
2269
+ * @param {AbortSignal} [signal] - Optional, matching the Command contract: absent outside a `Parallel`.
2270
+ */
2271
+ const cancellableSleep = (ms, signal) =>
1914
2272
  new Promise((resolve, reject) => {
1915
2273
  const timer = setTimeout(resolve, ms);
1916
2274
  signal?.addEventListener(
@@ -1932,7 +2290,7 @@ describe('Parallel cancellation', function () {
1932
2290
  Command(function cmdFail() {
1933
2291
  throw new Error('fail_fast');
1934
2292
  }),
1935
- Command(async function cmdSlow(/** @type {AbortSignal} */ signal) {
2293
+ Command(async function cmdSlow(/** @type {AbortSignal | undefined} */ signal) {
1936
2294
  await cancellableSleep(400, signal);
1937
2295
  completed = true;
1938
2296
  return 'wrote';
@@ -2009,7 +2367,7 @@ describe('Parallel cancellation', function () {
2009
2367
  }),
2010
2368
  Parallel(
2011
2369
  [
2012
- Command(async function cmdInner(/** @type {AbortSignal} */ signal) {
2370
+ Command(async function cmdInner(/** @type {AbortSignal | undefined} */ signal) {
2013
2371
  await cancellableSleep(300, signal);
2014
2372
  inner = true;
2015
2373
  return 1;
@@ -2029,7 +2387,7 @@ describe('Parallel cancellation', function () {
2029
2387
  const result = await runEffect(
2030
2388
  Parallel(
2031
2389
  [
2032
- Command(async function cmdSlowOk(/** @type {AbortSignal} */ signal) {
2390
+ Command(async function cmdSlowOk(/** @type {AbortSignal | undefined} */ signal) {
2033
2391
  await cancellableSleep(200, signal);
2034
2392
  return 'ok';
2035
2393
  }),
@@ -2065,3 +2423,24 @@ describe('Parallel cancellation', function () {
2065
2423
  assert.deepEqual(args, [[]], 'a thunk outside a Parallel is called with no arguments at all');
2066
2424
  });
2067
2425
  });
2426
+
2427
+ describe('Declaration parity', function () {
2428
+ it('should declare exactly the exports the runtime ships', function () {
2429
+ // index.d.ts is hand-maintained beside index.js, so the two can drift silently: a
2430
+ // declaration once vanished as edit collateral while every check stayed green. This pins
2431
+ // existence in both directions; signatures remain tsd's job.
2432
+ const program = ts.createProgram(['index.d.ts'], { noEmit: true });
2433
+ const source = program.getSourceFile('index.d.ts');
2434
+ assert.ok(source, 'index.d.ts must be part of the program');
2435
+ const checker = program.getTypeChecker();
2436
+ const moduleSymbol = checker.getSymbolAtLocation(/** @type {import('typescript').Node} */ (source));
2437
+ assert.ok(moduleSymbol, 'index.d.ts must be a module');
2438
+ const declared = checker
2439
+ .getExportsOfModule(moduleSymbol)
2440
+ .filter((s) => s.flags & ts.SymbolFlags.Value)
2441
+ .map((s) => s.name)
2442
+ .sort();
2443
+ const shipped = Object.keys(lib).sort();
2444
+ assert.deepEqual(declared, shipped);
2445
+ });
2446
+ });