pure-effect 0.11.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/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "pure-effect",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Records what your business logic did in production and replays it anywhere: time-travel debugging for JavaScript and TypeScript, with zero dependencies. Business logic is plain data you can test without mocks.",
5
5
  "type": "module",
6
6
  "exports": "./index.js",
7
7
  "types": "./index.d.ts",
8
8
  "scripts": {
9
- "test": "mocha && tsd && tsc -p tsconfig.check.json",
10
- "format": "prettier --write index.js index.d.ts examples/*.js test/all.js README.md CLAUDE.md"
9
+ "test": "mocha && tsd && tsc -p jsconfig.json",
10
+ "format": "prettier --write index.js index.d.ts examples/*.js test/all.js test/types.test-d.ts README.md CLAUDE.md"
11
11
  },
12
12
  "tsd": {
13
13
  "directory": "test"
package/test/all.js CHANGED
@@ -418,7 +418,7 @@ describe('Retry onExhausted', function () {
418
418
  'the fallback step must be recorded under a fallback path prefix'
419
419
  );
420
420
  const before = { ...calls };
421
- const replayed = /** @type {any} */ (await replayEffect(flow('sku-1'), trace));
421
+ const { result: replayed } = /** @type {any} */ (await replayEffect(flow('sku-1'), trace));
422
422
  assert.equal(replayed.type, 'Success');
423
423
  assert.deepEqual(replayed.value, /** @type {any} */ (result).value);
424
424
  assert.deepEqual(calls, before, 'replay must perform no I/O');
@@ -444,7 +444,7 @@ describe('Retry onExhausted', function () {
444
444
  );
445
445
  const { trace } = await recordEffect(flow, 1);
446
446
  const started = Date.now();
447
- const replayed = /** @type {any} */ (await replayEffect(flow(1), trace));
447
+ const { result: replayed } = /** @type {any} */ (await replayEffect(flow(1), trace));
448
448
  const elapsed = Date.now() - started;
449
449
  assert.equal(replayed.type, 'Failure');
450
450
  assert.ok(elapsed < 150, `replay must not wait out the fallback backoff, took ${elapsed}ms`);
@@ -470,7 +470,10 @@ describe('Recording and replay', function () {
470
470
  'every Command in the flow is recorded, including the guard'
471
471
  );
472
472
 
473
- 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
+ );
474
477
  assert.equal(replayed.type, 'Success');
475
478
  assert.deepEqual(valueOf(replayed), valueOf(result));
476
479
  });
@@ -509,7 +512,7 @@ describe('Recording and replay', function () {
509
512
  assert.equal(trace.version, 'abc123');
510
513
 
511
514
  const b = makeFlow();
512
- const replayed = await replayEffect(b.flow(trace.initialInput), trace);
515
+ const { result: replayed } = await replayEffect(b.flow(trace.initialInput), trace);
513
516
  assert.equal(replayed.type, 'Success');
514
517
  assert.deepEqual(valueOf(replayed), valueOf(result));
515
518
  assert.deepEqual(b.calls, { read: 0, write: 0 }, 'replay must not touch the world');
@@ -542,7 +545,7 @@ describe('Recording and replay', function () {
542
545
  );
543
546
 
544
547
  n = 100;
545
- const replayed = await replayEffect(readTwice(null), trace);
548
+ const { result: replayed } = await replayEffect(readTwice(null), trace);
546
549
  assert.deepEqual(valueOf(replayed), [1, 2], 'each call got its own recorded value');
547
550
  assert.equal(n, 100, 'the real command never ran');
548
551
  });
@@ -561,7 +564,7 @@ describe('Recording and replay', function () {
561
564
  const { trace } = await recordEffect(flow, 'sku-1', { context: { tenant: 'acme', flowName: 'lookup' } });
562
565
  assert.equal(/** @type {any} */ (trace.context).tenant, 'acme');
563
566
 
564
- const replayed = await replayEffect(flow('sku-1'), trace, { context: trace.context });
567
+ const { result: replayed } = await replayEffect(flow('sku-1'), trace, { context: trace.context });
565
568
  assert.deepEqual(valueOf(replayed), { id: 'sku-1', tenant: 'acme' });
566
569
  });
567
570
 
@@ -586,7 +589,7 @@ describe('Recording and replay', function () {
586
589
 
587
590
  attempt = 0;
588
591
  const start = Date.now();
589
- const replayed = await replayEffect(flow(), trace);
592
+ const { result: replayed } = await replayEffect(flow(), trace);
590
593
  const elapsed = Date.now() - start;
591
594
  assert.deepEqual(valueOf(replayed), { tempC: 21 });
592
595
  assert.equal(attempt, 0, 'no attempt was re-executed');
@@ -608,7 +611,7 @@ describe('Recording and replay', function () {
608
611
  const { result, trace } = await recordEffect(flow, null);
609
612
  assert.equal(result.type, 'Failure');
610
613
 
611
- const replayed = await replayEffect(flow(), trace);
614
+ const { result: replayed } = await replayEffect(flow(), trace);
612
615
  assert.equal(replayed.type, 'Failure');
613
616
  const error = /** @type {import('../index.js').RetryExhaustedError<Error>} */ (errorOf(replayed));
614
617
  assert.equal(error.retryExhausted, true);
@@ -648,8 +651,8 @@ describe('Recording and replay', function () {
648
651
  'each entry is labelled by its branch, so completion order does not matter'
649
652
  );
650
653
 
651
- // No `strict: false` needed: paths are order-independent, so the default replays correctly.
652
- 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);
653
656
  assert.equal(replayed.type, 'Success');
654
657
  assert.deepEqual(valueOf(replayed), valueOf(result));
655
658
  });
@@ -681,7 +684,7 @@ describe('Recording and replay', function () {
681
684
  assert.deepEqual(valueOf(result), ['A', 'B']);
682
685
 
683
686
  // Replay with the latencies reversed, so completion order is the opposite of the recorded run.
684
- const replayed = await replayEffect(flow([5, 40]), trace);
687
+ const { result: replayed } = await replayEffect(flow([5, 40]), trace);
685
688
  assert.equal(replayed.type, 'Success');
686
689
  assert.deepEqual(valueOf(replayed), ['A', 'B'], 'branch results were not swapped');
687
690
  });
@@ -694,12 +697,12 @@ describe('Recording and replay', function () {
694
697
  trace: trace.trace.map(({ path, ...rest }) => rest)
695
698
  };
696
699
  assert.ok(
697
- legacy.trace.every((e) => e.path === undefined),
700
+ legacy.trace.every((e) => !('path' in e)),
698
701
  'the fixture really has no paths'
699
702
  );
700
703
 
701
704
  const b = makeFlow();
702
- const replayed = await replayEffect(b.flow(trace.initialInput), legacy);
705
+ const { result: replayed } = await replayEffect(b.flow(trace.initialInput), legacy);
703
706
  assert.equal(replayed.type, 'Success');
704
707
  assert.deepEqual(valueOf(replayed), valueOf(result));
705
708
  assert.deepEqual(b.calls, { read: 0, write: 0 }, 'a legacy trace still performs no I/O');
@@ -744,7 +747,7 @@ describe('Recording and replay', function () {
744
747
  )
745
748
  )(id);
746
749
 
747
- const replayed = await replayEffect(diverged('x1'), trace);
750
+ const { result: replayed } = await replayEffect(diverged('x1'), trace);
748
751
  assert.equal(replayed.type, 'Failure');
749
752
  const error = /** @type {any} */ (errorOf(replayed));
750
753
  assert.equal(error.name, 'TimeParadox');
@@ -753,19 +756,137 @@ describe('Recording and replay', function () {
753
756
  assert.equal(error.actual, 'cmdAudit');
754
757
  });
755
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
+
756
877
  it('should accept a trace directly, without building a Resolver', async function () {
757
878
  const a = makeFlow();
758
879
  const { result, trace } = await recordEffect(a.flow, { id: 'direct' });
759
880
 
760
881
  const b = makeFlow();
761
- const replayed = await replayEffect(b.flow(trace.initialInput), trace);
882
+ const { result: replayed } = await replayEffect(b.flow(trace.initialInput), trace);
762
883
  assert.equal(replayed.type, 'Success');
763
884
  assert.deepEqual(valueOf(replayed), valueOf(result));
764
885
  assert.deepEqual(b.calls, { read: 0, write: 0 });
765
886
 
766
887
  // A bare entries array is a valid trace too, without the surrounding TraceLog.
767
888
  const c = makeFlow();
768
- const fromEntries = await replayEffect(c.flow(trace.initialInput), trace.trace);
889
+ const { result: fromEntries } = await replayEffect(c.flow(trace.initialInput), trace.trace);
769
890
  assert.equal(fromEntries.type, 'Success');
770
891
  assert.deepEqual(valueOf(fromEntries), valueOf(result));
771
892
  });
@@ -781,7 +902,7 @@ describe('Recording and replay', function () {
781
902
 
782
903
  // The trace must survive JSON, which is where a non-enumerable cause would silently vanish.
783
904
  const stored = JSON.parse(JSON.stringify(trace));
784
- const replayed = await replayEffect(flow(stored.initialInput), stored);
905
+ const { result: replayed } = await replayEffect(flow(stored.initialInput), stored);
785
906
  assert.equal(replayed.type, 'Failure');
786
907
  const error = /** @type {any} */ (errorOf(replayed));
787
908
  assert.equal(error.message, 'charge declined');
@@ -799,12 +920,48 @@ describe('Recording and replay', function () {
799
920
 
800
921
  const { trace } = await recordEffect(flow, { id: 'chain' });
801
922
  const stored = JSON.parse(JSON.stringify(trace));
802
- const replayed = await replayEffect(flow(stored.initialInput), stored);
923
+ const { result: replayed } = await replayEffect(flow(stored.initialInput), stored);
803
924
  const error = /** @type {any} */ (errorOf(replayed));
804
925
  assert.equal(error.cause.message, 'socket closed');
805
926
  assert.equal(error.cause.cause, 'ECONNRESET', 'a non-Error cause must pass through unchanged');
806
927
  });
807
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
+
808
965
  it('should reject a malformed trace as a ReplayError, not a TypeError', async function () {
809
966
  const { flow } = makeFlow();
810
967
  for (const bad of [undefined, null, 42, {}, { trace: 'nope' }]) {
@@ -833,7 +990,7 @@ describe('Recording and replay', function () {
833
990
  );
834
991
 
835
992
  const { trace } = await recordEffect(flowA, null);
836
- const replayed = await replayEffect(flowB(), trace);
993
+ const { result: replayed } = await replayEffect(flowB(), trace);
837
994
  assert.equal(replayed.type, 'Failure');
838
995
  assert.equal(/** @type {Error} */ (errorOf(replayed)).name, 'TimeParadox');
839
996
  assert.match(
@@ -844,14 +1001,14 @@ describe('Recording and replay', function () {
844
1001
 
845
1002
  it('should report an exhausted trace rather than silently succeeding', async function () {
846
1003
  const { flow } = makeFlow();
847
- const replayed = await replayEffect(flow({ id: 'x' }), { trace: [] });
1004
+ const { result: replayed } = await replayEffect(flow({ id: 'x' }), { trace: [] });
848
1005
  assert.equal(replayed.type, 'Failure');
849
1006
  assert.match(/** @type {Error} */ (errorOf(replayed)).message, /Trace exhausted/);
850
1007
  });
851
1008
 
852
1009
  it('should refuse to run an unrecorded Command by default', async function () {
853
1010
  const { flow, calls } = makeFlow();
854
- const replayed = await replayEffect(flow({ id: 'x' }), () => undefined);
1011
+ const { result: replayed } = await replayEffect(flow({ id: 'x' }), () => undefined);
855
1012
  assert.equal(replayed.type, 'Failure');
856
1013
  assert.match(/** @type {Error} */ (errorOf(replayed)).message, /refusing to run the real Command/);
857
1014
  assert.deepEqual(calls, { read: 0, write: 0 });
@@ -863,7 +1020,7 @@ describe('Recording and replay', function () {
863
1020
  /** @type {import('../index.js').Resolver} */
864
1021
  const resolve = (step) => (step.index === 0 ? { result: recorded[0].result } : undefined);
865
1022
 
866
- const replayed = await replayEffect(flow({ id: 'x' }), resolve, { onMissing: 'execute' });
1023
+ const { result: replayed } = await replayEffect(flow({ id: 'x' }), resolve, { onMissing: 'execute' });
867
1024
  assert.equal(replayed.type, 'Success');
868
1025
  assert.deepEqual(valueOf(replayed), { written: 'FROM_TRACE' });
869
1026
  assert.deepEqual(calls, { read: 0, write: 1 }, 'only the unrecorded step performed I/O');
@@ -996,7 +1153,7 @@ describe('Recording and replay', function () {
996
1153
  assert.equal(result.type, 'Success');
997
1154
  assert.equal(/** @type {any} */ (trace.initialInput).password, '[redacted]');
998
1155
 
999
- const replayed = await replayEffect(flow(trace.initialInput), trace);
1156
+ const { result: replayed } = await replayEffect(flow(trace.initialInput), trace);
1000
1157
  assert.equal(replayed.type, 'Success', 'the redacted input still rebuilds a matching flow');
1001
1158
  assert.deepEqual(valueOf(replayed), valueOf(result), 'and the recorded hash comes back intact');
1002
1159
  });
@@ -1054,10 +1211,33 @@ describe('Recording and replay', function () {
1054
1211
  assert.match(out, /Replaying 'checkout' \(2 recorded steps\)/);
1055
1212
  assert.match(out, /Step 1: cmdRead returned/);
1056
1213
  assert.doesNotMatch(out, / in \d/, 'a hand-written trace has no timings to narrate');
1057
- assert.match(out, /1 recorded steps were never reached/);
1214
+ assert.match(out, /1 recorded step was never reached: cmdWrite\. The flow diverged/);
1215
+ });
1216
+
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');
1058
1238
  });
1059
1239
 
1060
- it('should narrate a Parallel flow when strict is off', async function () {
1240
+ it('should narrate a Parallel flow', async function () {
1061
1241
  const flow = (/** @type {any} */ id) =>
1062
1242
  Parallel(
1063
1243
  [
@@ -1080,9 +1260,9 @@ describe('Recording and replay', function () {
1080
1260
  const { trace } = await recordEffect(flow, 'u1');
1081
1261
  /** @type {string[]} */
1082
1262
  const lines = [];
1083
- 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) });
1084
1264
 
1085
- assert.equal(result.type, 'Success', 'strict must reach the trace resolution');
1265
+ assert.equal(result.type, 'Success');
1086
1266
  const out = lines.join('\n');
1087
1267
  assert.match(out, /cmdSlow returned/);
1088
1268
  assert.match(out, /cmdFast returned/);
@@ -1118,10 +1298,7 @@ describe('Recording and replay', function () {
1118
1298
  assert.equal(invocations, 3);
1119
1299
 
1120
1300
  invocations = 0;
1121
- const replayed = await replayEffect(flow({ id: 1 }), trace, {
1122
- strict: false,
1123
- context: trace.context
1124
- });
1301
+ const { result: replayed } = await replayEffect(flow({ id: 1 }), trace, { context: trace.context });
1125
1302
  assert.equal(replayed.type, 'Success');
1126
1303
  assert.deepEqual(valueOf(replayed), valueOf(result));
1127
1304
  assert.equal(invocations, 0, 'no Command thunk was applied');
@@ -1695,7 +1872,7 @@ describe('Recorded step timings', function () {
1695
1872
  const flow = (/** @type {any} */ input) => effectPipe(() => slow(5))(input);
1696
1873
  const { result, trace } = await recordEffect(flow, { id: 1 });
1697
1874
  assert.equal(typeof trace.trace[0].durationMs, 'number');
1698
- const replayed = await replayEffect(flow({ id: 1 }), trace);
1875
+ const { result: replayed } = await replayEffect(flow({ id: 1 }), trace);
1699
1876
  assert.equal(replayed.type, 'Success');
1700
1877
  assert.deepEqual(valueOf(replayed), valueOf(result));
1701
1878
  });
@@ -1806,7 +1983,7 @@ describe('Command identity', function () {
1806
1983
  },
1807
1984
  (/** @type {any} */ v) => Success(v)
1808
1985
  );
1809
- 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: '' }])) {
1810
1987
  const effect = Command(named().cmd, (/** @type {any} */ v) => Success(v), meta);
1811
1988
  await runEffect(effect, {}, { onStep });
1812
1989
  }
@@ -1837,8 +2014,8 @@ describe('Command identity', function () {
1837
2014
  'meta.name survives the rebuild chain performs on each pipe step'
1838
2015
  );
1839
2016
 
1840
- const replayed = await replayEffect(flow({ id: 'x1' }), trace);
1841
- 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');
1842
2019
  assert.deepEqual(valueOf(replayed), valueOf(result));
1843
2020
  });
1844
2021
  });
@@ -2017,7 +2194,7 @@ describe('Malformed flows', function () {
2017
2194
 
2018
2195
  it('should call a missing return what it usually is', async function () {
2019
2196
  function ensureEmailAvailable() {}
2020
- const e = await errorFrom(() => runEffect(effectPipe(ensureEmailAvailable)({ id: 1 })));
2197
+ const e = await errorFrom(() => runEffect(effectPipe(/** @type {any} */ (ensureEmailAvailable))({ id: 1 })));
2021
2198
  assert.equal(e?.name, 'EffectTypeError');
2022
2199
  assert.match(e.message, /returned undefined, which usually means a missing return/);
2023
2200
  });
@@ -2086,8 +2263,12 @@ describe('Parallel cancellation', function () {
2086
2263
  configureEffect();
2087
2264
  });
2088
2265
 
2089
- /** Resolves after `ms`, or rejects as soon as `signal` aborts. */
2090
- 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) =>
2091
2272
  new Promise((resolve, reject) => {
2092
2273
  const timer = setTimeout(resolve, ms);
2093
2274
  signal?.addEventListener(
@@ -2109,7 +2290,7 @@ describe('Parallel cancellation', function () {
2109
2290
  Command(function cmdFail() {
2110
2291
  throw new Error('fail_fast');
2111
2292
  }),
2112
- Command(async function cmdSlow(/** @type {AbortSignal} */ signal) {
2293
+ Command(async function cmdSlow(/** @type {AbortSignal | undefined} */ signal) {
2113
2294
  await cancellableSleep(400, signal);
2114
2295
  completed = true;
2115
2296
  return 'wrote';
@@ -2186,7 +2367,7 @@ describe('Parallel cancellation', function () {
2186
2367
  }),
2187
2368
  Parallel(
2188
2369
  [
2189
- Command(async function cmdInner(/** @type {AbortSignal} */ signal) {
2370
+ Command(async function cmdInner(/** @type {AbortSignal | undefined} */ signal) {
2190
2371
  await cancellableSleep(300, signal);
2191
2372
  inner = true;
2192
2373
  return 1;
@@ -2206,7 +2387,7 @@ describe('Parallel cancellation', function () {
2206
2387
  const result = await runEffect(
2207
2388
  Parallel(
2208
2389
  [
2209
- Command(async function cmdSlowOk(/** @type {AbortSignal} */ signal) {
2390
+ Command(async function cmdSlowOk(/** @type {AbortSignal | undefined} */ signal) {
2210
2391
  await cancellableSleep(200, signal);
2211
2392
  return 'ok';
2212
2393
  }),
@@ -1,5 +1,16 @@
1
- import { expectType, expectError } from 'tsd';
2
- import { Success, Failure, Command, Ask, Retry, Parallel, effectPipe, runEffect, configureEffect } from '../index.js';
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
- expectError(Success()); // missing argument
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
- expectError(Command());
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
- expectError(flow({ email: 'a@b.com' })); // missing password
101
+ // @ts-expect-error missing password
102
+ flow({ email: 'a@b.com' });
72
103
 
73
104
  // --- runEffect return type ---
74
105
 
@@ -151,7 +182,8 @@ if (recoveredResult.type === 'Failure') {
151
182
  }
152
183
 
153
184
  // onExhausted is per-use only: global retry defaults cannot carry a fallback
154
- expectError(configureEffect({ retry: { attempts: 2, onExhausted: () => Success(1) } }));
185
+ // @ts-expect-error onExhausted is not a global retry default
186
+ configureEffect({ retry: { attempts: 2, onExhausted: () => Success(1) } });
155
187
 
156
188
  // RetryExhaustedError shape is usable for narrowing exhaustion failures
157
189
  const exhaustedErr: RetryExhaustedError<Error> = {
@@ -226,7 +258,8 @@ const ctxResult = await runEffect(ctxFlow({ email: 'a@b.com', password: 'secret1
226
258
  expectType<SuccessState<{ email: string; password: string; conn: string }> | FailureState<unknown>>(ctxResult);
227
259
 
228
260
  // wrong context shape should error
229
- expectError(runEffect(ctxFlow({ email: 'a@b.com', password: 'secret123' }), { wrong: 'thing' }));
261
+ // @ts-expect-error context does not match Ctx
262
+ runEffect(ctxFlow({ email: 'a@b.com', password: 'secret123' }), { wrong: 'thing' });
230
263
 
231
264
  // --- configureEffect / EffectConfiguration ---
232
265
 
@@ -243,8 +276,10 @@ configureEffect({ retry: { attempts: 5 } });
243
276
  configureEffect({});
244
277
 
245
278
  // rejects invalid shapes
246
- expectError(configureEffect({ onStep: 'not-a-function' }));
247
- expectError(configureEffect({ retry: { attempts: 'three' } }));
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' } });
248
283
 
249
284
  // hook types are correctly shaped
250
285
  const myStep: StepRunner = async (name, type, op) => {
@@ -266,3 +301,35 @@ const myInterceptor: CommandInterceptor = async (cmd, _ctx) => {
266
301
  // EffectConfiguration is a usable type
267
302
  const config: EffectConfiguration = { onStep: myStep, onRun: myRun, onBeforeCommand: myInterceptor };
268
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: () => {} });