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/CLAUDE.md +19 -16
- package/README.md +25 -6
- package/index.d.ts +35 -14
- package/index.js +85 -59
- package/{tsconfig.check.json → jsconfig.json} +2 -1
- package/package.json +3 -3
- package/test/all.js +222 -41
- package/test/types.test-d.ts +77 -10
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pure-effect",
|
|
3
|
-
"version": "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
|
|
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(
|
|
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
|
-
//
|
|
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) =>
|
|
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
|
|
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
|
|
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, {
|
|
1263
|
+
const result = await timeTravel(flow, trace, { log: (l) => lines.push(l) });
|
|
1084
1264
|
|
|
1085
|
-
assert.equal(result.type, 'Success'
|
|
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', '
|
|
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
|
-
/**
|
|
2090
|
-
|
|
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
|
}),
|
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
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
247
|
-
|
|
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: () => {} });
|