@iterativeflow/conformance 2.2.0 → 2.4.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/README.md +6 -0
- package/dist/index.mjs +205 -0
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -30,6 +30,12 @@ level of a test file.
|
|
|
30
30
|
behaviors built on the ports.
|
|
31
31
|
- `engineConformance` — the composed engine (retry/dead-letter, signal resume,
|
|
32
32
|
cancel cascade, fan-out, drift) end-to-end on your backend.
|
|
33
|
+
- `claimFilterConformance`, `shardedClaimConformance` — flow-name-filtered claims,
|
|
34
|
+
and concurrent claims across a fleet. `shardedClaimConformance` is for backends
|
|
35
|
+
that support concurrent writers; single-writer backends (memory, sqlite,
|
|
36
|
+
durable-objects) skip it by design.
|
|
37
|
+
- `pendingWorkConformance` — the autoscaling backlog reads (`Queue.depth`,
|
|
38
|
+
`Timer.dueCount`, `Store.dueCronCount`) that `engine.pendingWork` composes.
|
|
33
39
|
|
|
34
40
|
Every suite takes `(label, makeBackend)` where `makeBackend` returns a fresh
|
|
35
41
|
`Backend` (or a `Promise` of one) per case.
|
package/dist/index.mjs
CHANGED
|
@@ -219,6 +219,24 @@ const storeConformance = (label, makeStore) => {
|
|
|
219
219
|
attempts: 1
|
|
220
220
|
});
|
|
221
221
|
});
|
|
222
|
+
it("a step memo round-trips through JSON — a Date comes back as a string on every backend", async () => {
|
|
223
|
+
const s = await makeStore();
|
|
224
|
+
const { runId } = await s.startRun({
|
|
225
|
+
name: "f",
|
|
226
|
+
version: 1,
|
|
227
|
+
input: {}
|
|
228
|
+
});
|
|
229
|
+
const at = /* @__PURE__ */ new Date("2030-01-01T00:00:00.000Z");
|
|
230
|
+
await s.checkpointStep({
|
|
231
|
+
runId,
|
|
232
|
+
cursorKey: "a",
|
|
233
|
+
status: "ok",
|
|
234
|
+
result: { at },
|
|
235
|
+
attempts: 1
|
|
236
|
+
});
|
|
237
|
+
const memo = (await s.loadRun(runId))?.steps.get("a")?.result;
|
|
238
|
+
expect(memo.at).toBe("2030-01-01T00:00:00.000Z");
|
|
239
|
+
});
|
|
222
240
|
it("checkpointStep round-trips the shape tag (drift-guard evidence)", async () => {
|
|
223
241
|
const s = await makeStore();
|
|
224
242
|
const { runId } = await s.startRun({
|
|
@@ -615,6 +633,109 @@ const storeConformance = (label, makeStore) => {
|
|
|
615
633
|
const again = await s.retryRun(runId);
|
|
616
634
|
expect(again.retried).toBe(false);
|
|
617
635
|
});
|
|
636
|
+
it("retryRun clears the spent attempt budget, so a dead-lettered run gets dispatched again", async () => {
|
|
637
|
+
const s = await makeStore();
|
|
638
|
+
const { runId } = await s.startRun({
|
|
639
|
+
name: "f",
|
|
640
|
+
version: 1,
|
|
641
|
+
input: {}
|
|
642
|
+
});
|
|
643
|
+
await s.markRunning(runId);
|
|
644
|
+
await s.markRunning(runId);
|
|
645
|
+
expect(await s.markRunning(runId)).toBe(3);
|
|
646
|
+
await s.markTerminal(runId, {
|
|
647
|
+
status: "failed",
|
|
648
|
+
error: {
|
|
649
|
+
code: "X",
|
|
650
|
+
message: "boom"
|
|
651
|
+
}
|
|
652
|
+
});
|
|
653
|
+
await s.retryRun(runId);
|
|
654
|
+
expect((await s.loadRunRow(runId))?.attempts).toBe(0);
|
|
655
|
+
expect(await s.markRunning(runId)).toBe(1);
|
|
656
|
+
});
|
|
657
|
+
it("cancelRuns cancels the live matched set and never a terminal run", async () => {
|
|
658
|
+
const s = await makeStore();
|
|
659
|
+
const mk = async (name) => (await s.startRun({
|
|
660
|
+
name,
|
|
661
|
+
version: 1,
|
|
662
|
+
input: {}
|
|
663
|
+
})).runId;
|
|
664
|
+
const live1 = await mk("f");
|
|
665
|
+
const live2 = await mk("f");
|
|
666
|
+
const other = await mk("g");
|
|
667
|
+
const done = await mk("f");
|
|
668
|
+
await s.markTerminal(done, {
|
|
669
|
+
status: "done",
|
|
670
|
+
output: 1
|
|
671
|
+
});
|
|
672
|
+
expect(await s.cancelRuns({ name: "f" }, 100)).toBe(2);
|
|
673
|
+
expect((await s.loadRunRow(live1))?.status).toBe("canceled");
|
|
674
|
+
expect((await s.loadRunRow(live2))?.status).toBe("canceled");
|
|
675
|
+
expect((await s.loadRunRow(other))?.status).toBe("pending");
|
|
676
|
+
expect((await s.loadRunRow(done))?.status).toBe("done");
|
|
677
|
+
expect(await s.cancelRuns({ name: "f" }, 100)).toBe(0);
|
|
678
|
+
await expect(s.cancelRuns({}, 100)).rejects.toThrow();
|
|
679
|
+
});
|
|
680
|
+
it("retryRuns re-drives the failed matched set, clearing the spent attempt budget", async () => {
|
|
681
|
+
const s = await makeStore();
|
|
682
|
+
const mk = async (name) => (await s.startRun({
|
|
683
|
+
name,
|
|
684
|
+
version: 1,
|
|
685
|
+
input: {}
|
|
686
|
+
})).runId;
|
|
687
|
+
const a = await mk("f");
|
|
688
|
+
const b = await mk("f");
|
|
689
|
+
const live = await mk("f");
|
|
690
|
+
await s.markRunning(a);
|
|
691
|
+
await s.markTerminal(a, {
|
|
692
|
+
status: "failed",
|
|
693
|
+
error: {
|
|
694
|
+
code: "X",
|
|
695
|
+
message: "x"
|
|
696
|
+
}
|
|
697
|
+
});
|
|
698
|
+
await s.markTerminal(b, {
|
|
699
|
+
status: "failed",
|
|
700
|
+
error: {
|
|
701
|
+
code: "X",
|
|
702
|
+
message: "x"
|
|
703
|
+
}
|
|
704
|
+
});
|
|
705
|
+
expect(await s.retryRuns({ name: "f" }, 100)).toBe(2);
|
|
706
|
+
for (const id of [a, b]) {
|
|
707
|
+
const row = await s.loadRunRow(id);
|
|
708
|
+
expect(row?.status).toBe("pending");
|
|
709
|
+
expect(row?.error).toBeUndefined();
|
|
710
|
+
expect(row?.attempts).toBe(0);
|
|
711
|
+
}
|
|
712
|
+
expect((await s.loadRunRow(live))?.status).toBe("pending");
|
|
713
|
+
expect(await s.retryRuns({ name: "f" }, 100)).toBe(0);
|
|
714
|
+
await expect(s.retryRuns({}, 100)).rejects.toThrow();
|
|
715
|
+
});
|
|
716
|
+
it("listRuns filters by version, not just name", async () => {
|
|
717
|
+
const s = await makeStore();
|
|
718
|
+
const v1 = (await s.startRun({
|
|
719
|
+
name: "f",
|
|
720
|
+
version: 1,
|
|
721
|
+
input: {}
|
|
722
|
+
})).runId;
|
|
723
|
+
const v2 = (await s.startRun({
|
|
724
|
+
name: "f",
|
|
725
|
+
version: 2,
|
|
726
|
+
input: {}
|
|
727
|
+
})).runId;
|
|
728
|
+
const only1 = await s.listRuns({
|
|
729
|
+
name: "f",
|
|
730
|
+
version: 1
|
|
731
|
+
}, { limit: 10 });
|
|
732
|
+
expect(only1.runs.map((r) => r.id)).toEqual([v1]);
|
|
733
|
+
const only2 = await s.listRuns({
|
|
734
|
+
name: "f",
|
|
735
|
+
version: 2
|
|
736
|
+
}, { limit: 10 });
|
|
737
|
+
expect(only2.runs.map((r) => r.id)).toEqual([v2]);
|
|
738
|
+
});
|
|
618
739
|
it("runStats counts runs per status", async () => {
|
|
619
740
|
const s = await makeStore();
|
|
620
741
|
const a = await s.startRun({
|
|
@@ -1325,6 +1446,26 @@ const signalConformance = (label, makeBackend) => {
|
|
|
1325
1446
|
expect(b.delivered).toBe(false);
|
|
1326
1447
|
expect((await store.loadRun(runId))?.signals).toHaveLength(1);
|
|
1327
1448
|
});
|
|
1449
|
+
it("postSignal stays idempotent AFTER the signal is consumed — a late redelivery lands once", async () => {
|
|
1450
|
+
const { store } = await makeBackend();
|
|
1451
|
+
const { runId } = await store.startRun({
|
|
1452
|
+
name: "f",
|
|
1453
|
+
version: 1,
|
|
1454
|
+
input: {}
|
|
1455
|
+
});
|
|
1456
|
+
await store.postSignal(runId, "approve", 1, { idempotencyKey: "evt-1" });
|
|
1457
|
+
const delivered = (await store.loadRun(runId))?.signals ?? [];
|
|
1458
|
+
await store.checkpointStep({
|
|
1459
|
+
runId,
|
|
1460
|
+
cursorKey: "wait",
|
|
1461
|
+
status: "ok",
|
|
1462
|
+
result: 1,
|
|
1463
|
+
attempts: 1
|
|
1464
|
+
}, { consumeSignals: [delivered[0].id] });
|
|
1465
|
+
const again = await store.postSignal(runId, "approve", 1, { idempotencyKey: "evt-1" });
|
|
1466
|
+
expect(again.delivered).toBe(false);
|
|
1467
|
+
expect((await store.loadRun(runId))?.signals).toHaveLength(0);
|
|
1468
|
+
});
|
|
1328
1469
|
it("consumeSignals drains exactly the referenced signal, atomically with the checkpoint", async () => {
|
|
1329
1470
|
const { store } = await makeBackend();
|
|
1330
1471
|
const { runId } = await store.startRun({
|
|
@@ -1540,6 +1681,37 @@ const reconcileConformance = (label, makeBackend) => {
|
|
|
1540
1681
|
});
|
|
1541
1682
|
expect(await store.orphanedRuns(10)).not.toContain(runId);
|
|
1542
1683
|
});
|
|
1684
|
+
it("retryRuns re-enqueues, and cancelRuns clears the timer — not just a status flip", async () => {
|
|
1685
|
+
const { store, queue, timer } = await makeBackend();
|
|
1686
|
+
const now = /* @__PURE__ */ new Date("2030-01-01T00:00:00Z");
|
|
1687
|
+
const failed = (await store.startRun({
|
|
1688
|
+
name: "f",
|
|
1689
|
+
version: 1,
|
|
1690
|
+
input: {}
|
|
1691
|
+
})).runId;
|
|
1692
|
+
await store.markTerminal(failed, {
|
|
1693
|
+
status: "failed",
|
|
1694
|
+
error: {
|
|
1695
|
+
code: "X",
|
|
1696
|
+
message: "x"
|
|
1697
|
+
}
|
|
1698
|
+
});
|
|
1699
|
+
const live = (await store.startRun({
|
|
1700
|
+
name: "g",
|
|
1701
|
+
version: 1,
|
|
1702
|
+
input: {}
|
|
1703
|
+
})).runId;
|
|
1704
|
+
await timer.schedule(live, now);
|
|
1705
|
+
expect(await store.retryRuns({ name: "f" }, 10)).toBe(1);
|
|
1706
|
+
const leases = await queue.claim({
|
|
1707
|
+
limit: 10,
|
|
1708
|
+
leaseMs: 1e3,
|
|
1709
|
+
now
|
|
1710
|
+
});
|
|
1711
|
+
expect(leases.map((l) => l.runId)).toContain(failed);
|
|
1712
|
+
expect(await store.cancelRuns({ name: "g" }, 10)).toBe(1);
|
|
1713
|
+
expect(await timer.dueCount(now)).toBe(0);
|
|
1714
|
+
});
|
|
1543
1715
|
});
|
|
1544
1716
|
};
|
|
1545
1717
|
//#endregion
|
|
@@ -1584,6 +1756,39 @@ const cronConformance = (label, makeStore) => {
|
|
|
1584
1756
|
expect(due.map((c) => c.name)).toEqual(["nightly"]);
|
|
1585
1757
|
expect(due[0].input).toEqual({ kind: "changed" });
|
|
1586
1758
|
});
|
|
1759
|
+
it("a CHANGED schedule takes its new nextRunAt (the old one would outlive the deploy)", async () => {
|
|
1760
|
+
const s = await makeStore();
|
|
1761
|
+
await s.upsertCron({
|
|
1762
|
+
...spec,
|
|
1763
|
+
schedule: "0 3 * * *",
|
|
1764
|
+
nextRunAt: at(999999)
|
|
1765
|
+
});
|
|
1766
|
+
await s.upsertCron({
|
|
1767
|
+
...spec,
|
|
1768
|
+
schedule: "*/5 * * * *",
|
|
1769
|
+
nextRunAt: at(5e3)
|
|
1770
|
+
});
|
|
1771
|
+
const due = await s.dueCrons(at(5e3), 10);
|
|
1772
|
+
expect(due.map((c) => c.name)).toEqual(["nightly"]);
|
|
1773
|
+
expect(due[0].schedule).toBe("*/5 * * * *");
|
|
1774
|
+
});
|
|
1775
|
+
it("listCrons returns every registered cron; removeCron stops one firing", async () => {
|
|
1776
|
+
const s = await makeStore();
|
|
1777
|
+
await s.upsertCron({
|
|
1778
|
+
...spec,
|
|
1779
|
+
nextRunAt: at(1e3)
|
|
1780
|
+
});
|
|
1781
|
+
await s.upsertCron({
|
|
1782
|
+
...spec,
|
|
1783
|
+
name: "weekly",
|
|
1784
|
+
nextRunAt: at(2e3)
|
|
1785
|
+
});
|
|
1786
|
+
expect((await s.listCrons()).map((c) => c.name).sort()).toEqual(["nightly", "weekly"]);
|
|
1787
|
+
expect(await s.removeCron("weekly")).toBe(true);
|
|
1788
|
+
expect(await s.removeCron("weekly")).toBe(false);
|
|
1789
|
+
expect((await s.listCrons()).map((c) => c.name)).toEqual(["nightly"]);
|
|
1790
|
+
expect((await s.dueCrons(at(2e3), 10)).map((c) => c.name)).toEqual(["nightly"]);
|
|
1791
|
+
});
|
|
1587
1792
|
it("advanceCron is a CAS — only the worker matching the expected time wins", async () => {
|
|
1588
1793
|
const s = await makeStore();
|
|
1589
1794
|
await s.upsertCron({
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iterativeflow/conformance",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/ahmedrowaihi/iterativeflow.git",
|
|
8
|
-
"directory": "
|
|
8
|
+
"directory": "packages/conformance"
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"dist",
|
|
@@ -25,11 +25,14 @@
|
|
|
25
25
|
"access": "public"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@iterativeflow/core": "2.
|
|
28
|
+
"@iterativeflow/core": "2.4.0"
|
|
29
29
|
},
|
|
30
30
|
"peerDependencies": {
|
|
31
31
|
"vitest": ">=3"
|
|
32
32
|
},
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=20"
|
|
35
|
+
},
|
|
33
36
|
"scripts": {
|
|
34
37
|
"build": "tsdown"
|
|
35
38
|
}
|