pure-effect 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +33 -21
- package/README.md +134 -17
- package/index.d.ts +68 -17
- package/index.js +130 -64
- package/jsconfig.json +13 -0
- package/package.json +5 -5
- package/test/all.js +416 -37
- package/test/types.test-d.ts +117 -13
- package/.claude/settings.local.json +0 -17
package/test/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(
|
|
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
|
-
//
|
|
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) =>
|
|
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
|
|
1214
|
+
assert.match(out, /1 recorded step was never reached: cmdWrite\. The flow diverged/);
|
|
881
1215
|
});
|
|
882
1216
|
|
|
883
|
-
it('should
|
|
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, {
|
|
1263
|
+
const result = await timeTravel(flow, trace, { log: (l) => lines.push(l) });
|
|
907
1264
|
|
|
908
|
-
assert.equal(result.type, 'Success'
|
|
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', '
|
|
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
|
-
/**
|
|
1913
|
-
|
|
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
|
+
});
|