@iterativeflow/conformance 2.3.0 → 2.5.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 +184 -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({
|
|
@@ -636,6 +654,88 @@ const storeConformance = (label, makeStore) => {
|
|
|
636
654
|
expect((await s.loadRunRow(runId))?.attempts).toBe(0);
|
|
637
655
|
expect(await s.markRunning(runId)).toBe(1);
|
|
638
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
|
+
});
|
|
639
739
|
it("runStats counts runs per status", async () => {
|
|
640
740
|
const s = await makeStore();
|
|
641
741
|
const a = await s.startRun({
|
|
@@ -1346,6 +1446,26 @@ const signalConformance = (label, makeBackend) => {
|
|
|
1346
1446
|
expect(b.delivered).toBe(false);
|
|
1347
1447
|
expect((await store.loadRun(runId))?.signals).toHaveLength(1);
|
|
1348
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
|
+
});
|
|
1349
1469
|
it("consumeSignals drains exactly the referenced signal, atomically with the checkpoint", async () => {
|
|
1350
1470
|
const { store } = await makeBackend();
|
|
1351
1471
|
const { runId } = await store.startRun({
|
|
@@ -1561,6 +1681,37 @@ const reconcileConformance = (label, makeBackend) => {
|
|
|
1561
1681
|
});
|
|
1562
1682
|
expect(await store.orphanedRuns(10)).not.toContain(runId);
|
|
1563
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
|
+
});
|
|
1564
1715
|
});
|
|
1565
1716
|
};
|
|
1566
1717
|
//#endregion
|
|
@@ -1605,6 +1756,39 @@ const cronConformance = (label, makeStore) => {
|
|
|
1605
1756
|
expect(due.map((c) => c.name)).toEqual(["nightly"]);
|
|
1606
1757
|
expect(due[0].input).toEqual({ kind: "changed" });
|
|
1607
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
|
+
});
|
|
1608
1792
|
it("advanceCron is a CAS — only the worker matching the expected time wins", async () => {
|
|
1609
1793
|
const s = await makeStore();
|
|
1610
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.5.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.5.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
|
}
|