@dudousxd/nestjs-durable-testing 0.13.0 → 0.15.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/dist/index.js CHANGED
@@ -287,6 +287,19 @@ var DURABLE_CANONICAL_COLUMNS = {
287
287
  name: "name",
288
288
  payload: "payload",
289
289
  publishedAt: "published_at"
290
+ },
291
+ // Persisted schedules (`engine.schedules`). `spec`/`state` are engine-owned JSON documents.
292
+ durable_schedules: {
293
+ id: "id",
294
+ namespace: "namespace",
295
+ workflow: "workflow",
296
+ paused: "paused",
297
+ nextFireAt: "next_fire_at",
298
+ tags: "tags",
299
+ spec: "spec",
300
+ state: "state",
301
+ createdAt: "created_at",
302
+ updatedAt: "updated_at"
290
303
  }
291
304
  };
292
305
  function assertDurableColumns(resolve, expected = DURABLE_CANONICAL_COLUMNS) {
@@ -403,6 +416,8 @@ function isUnavailable(err) {
403
416
  }
404
417
  __name(isUnavailable, "isUnavailable");
405
418
  var at = /* @__PURE__ */ new Date("2026-06-11T00:00:00.000Z");
419
+ var NOW = Date.parse("2026-06-20T00:00:00.000Z");
420
+ var DAY = 864e5;
406
421
  var run = /* @__PURE__ */ __name((over = {}) => ({
407
422
  id: "r1",
408
423
  workflow: "checkout",
@@ -544,6 +559,249 @@ function runStateStoreContract(name, makeStore) {
544
559
  t("deleteRun is a no-op for a missing run", async () => {
545
560
  await expect2(store.deleteRun("nope")).resolves.toBeUndefined();
546
561
  });
562
+ t("deleteRuns removes every listed run with its rows and ignores missing ids", async () => {
563
+ await store.createRun(run({
564
+ id: "d1",
565
+ searchAttributes: {
566
+ tier: "pro"
567
+ }
568
+ }));
569
+ await store.createRun(run({
570
+ id: "d2"
571
+ }));
572
+ await store.createRun(run({
573
+ id: "keep"
574
+ }));
575
+ await store.saveCheckpoint(checkpoint({
576
+ runId: "d1",
577
+ stepId: "d1:0"
578
+ }));
579
+ await store.putSignalWaiter({
580
+ token: "approve-d2",
581
+ runId: "d2",
582
+ seq: 0
583
+ });
584
+ expect2(typeof store.deleteRuns).toBe("function");
585
+ await store.deleteRuns?.([
586
+ "d1",
587
+ "d2",
588
+ "ghost"
589
+ ]);
590
+ expect2(await store.getRun("d1")).toBeNull();
591
+ expect2(await store.getRun("d2")).toBeNull();
592
+ expect2(await store.getRun("keep")).not.toBeNull();
593
+ expect2(await store.listCheckpoints("d1")).toEqual([]);
594
+ expect2(await store.listSignalWaiters("approve-")).toEqual([]);
595
+ expect2(await store.listRuns({
596
+ attributes: [
597
+ {
598
+ key: "tier",
599
+ op: "eq",
600
+ value: "pro"
601
+ }
602
+ ]
603
+ })).toEqual([]);
604
+ await expect2(store.deleteRuns?.([])).resolves.toBeUndefined();
605
+ });
606
+ const aged = /* @__PURE__ */ __name((id, ageDays, over = {}) => run({
607
+ id,
608
+ status: "completed",
609
+ createdAt: new Date(NOW - ageDays * DAY),
610
+ updatedAt: new Date(NOW - ageDays * DAY),
611
+ ...over
612
+ }), "aged");
613
+ const ids = /* @__PURE__ */ __name(async () => (await store.listRuns({})).map((r) => r.id).sort(), "ids");
614
+ t("pruneTerminalRuns drops terminal runs past maxAge and never touches live ones", async () => {
615
+ await store.createRun(aged("old-done", 10));
616
+ await store.createRun(aged("old-failed", 10, {
617
+ status: "failed"
618
+ }));
619
+ await store.createRun(aged("old-live", 10, {
620
+ status: "suspended"
621
+ }));
622
+ await store.createRun(aged("fresh-done", 1));
623
+ await store.saveCheckpoint(checkpoint({
624
+ runId: "old-done",
625
+ stepId: "old-done:0"
626
+ }));
627
+ expect2(typeof store.pruneTerminalRuns).toBe("function");
628
+ const deleted = await store.pruneTerminalRuns?.({
629
+ statuses: [
630
+ "completed",
631
+ "failed",
632
+ "suspended"
633
+ ],
634
+ maxAge: "7d"
635
+ }, NOW, 100);
636
+ expect2(deleted).toBe(2);
637
+ expect2(await ids()).toEqual([
638
+ "fresh-done",
639
+ "old-live"
640
+ ]);
641
+ expect2(await store.listCheckpoints("old-done")).toEqual([]);
642
+ });
643
+ t("pruneTerminalRuns keeps only the newest maxCount and drains in batches of limit", async () => {
644
+ for (let i = 1; i <= 5; i++) await store.createRun(aged(`c${i}`, i));
645
+ const policy = {
646
+ statuses: [
647
+ "completed"
648
+ ],
649
+ maxCount: 2
650
+ };
651
+ expect2(await store.pruneTerminalRuns?.(policy, NOW, 2)).toBe(2);
652
+ expect2(await store.pruneTerminalRuns?.(policy, NOW, 2)).toBe(1);
653
+ expect2(await store.pruneTerminalRuns?.(policy, NOW, 2)).toBe(0);
654
+ expect2(await ids()).toEqual([
655
+ "c1",
656
+ "c2"
657
+ ]);
658
+ });
659
+ t("a scoped retention policy only prunes (and counts) the runs its scope matches", async () => {
660
+ await store.createRun(aged("acme-old", 10, {
661
+ namespace: "acme"
662
+ }));
663
+ await store.createRun(aged("acme-new", 1, {
664
+ namespace: "acme"
665
+ }));
666
+ await store.createRun(aged("beta-old", 10, {
667
+ namespace: "beta"
668
+ }));
669
+ await store.createRun(aged("report-old", 10, {
670
+ workflow: "report"
671
+ }));
672
+ await store.pruneTerminalRuns?.({
673
+ statuses: [
674
+ "completed"
675
+ ],
676
+ maxAge: "7d",
677
+ scope: {
678
+ namespace: "acme"
679
+ }
680
+ }, NOW, 100);
681
+ expect2(await ids()).toEqual([
682
+ "acme-new",
683
+ "beta-old",
684
+ "report-old"
685
+ ]);
686
+ await store.pruneTerminalRuns?.({
687
+ statuses: [
688
+ "completed"
689
+ ],
690
+ maxAge: "7d",
691
+ scope: {
692
+ workflows: [
693
+ "report"
694
+ ]
695
+ }
696
+ }, NOW, 100);
697
+ expect2(await ids()).toEqual([
698
+ "acme-new",
699
+ "beta-old"
700
+ ]);
701
+ await store.createRun(aged("acme-mid", 3, {
702
+ namespace: "acme"
703
+ }));
704
+ await store.pruneTerminalRuns?.({
705
+ statuses: [
706
+ "completed"
707
+ ],
708
+ maxCount: 1,
709
+ scope: {
710
+ namespace: "acme"
711
+ }
712
+ }, NOW, 100);
713
+ expect2(await ids()).toEqual([
714
+ "acme-new",
715
+ "beta-old"
716
+ ]);
717
+ });
718
+ t("a retention scope can select by tag and by search attribute", async () => {
719
+ if (!supportsTagFilter) return;
720
+ await store.createRun(aged("chat-old", 10, {
721
+ tags: [
722
+ "chat",
723
+ "tenant:t1"
724
+ ]
725
+ }));
726
+ await store.createRun(aged("chatty-old", 10, {
727
+ tags: [
728
+ "chatty"
729
+ ]
730
+ }));
731
+ await store.createRun(aged("attr-old", 10, {
732
+ searchAttributes: {
733
+ tenantId: "t2"
734
+ }
735
+ }));
736
+ await store.createRun(aged("attr-other", 10, {
737
+ searchAttributes: {
738
+ tenantId: "t3"
739
+ }
740
+ }));
741
+ await store.pruneTerminalRuns?.({
742
+ statuses: [
743
+ "completed"
744
+ ],
745
+ maxAge: "7d",
746
+ scope: {
747
+ tags: [
748
+ "chat"
749
+ ]
750
+ }
751
+ }, NOW, 100);
752
+ expect2(await ids()).toEqual([
753
+ "attr-old",
754
+ "attr-other",
755
+ "chatty-old"
756
+ ]);
757
+ await store.pruneTerminalRuns?.({
758
+ statuses: [
759
+ "completed"
760
+ ],
761
+ maxAge: "7d",
762
+ scope: {
763
+ attributes: [
764
+ {
765
+ key: "tenantId",
766
+ op: "eq",
767
+ value: "t2"
768
+ }
769
+ ]
770
+ }
771
+ }, NOW, 100);
772
+ expect2(await ids()).toEqual([
773
+ "attr-other",
774
+ "chatty-old"
775
+ ]);
776
+ });
777
+ t("engine.purgeRuns deletes a scope with its child subtrees and cancels live runs", async () => {
778
+ const engine = new WorkflowEngine3({
779
+ store
780
+ });
781
+ engine.register("p-child", "1", async (ctx) => ctx.waitForSignal("never"));
782
+ engine.register("p-root", "1", async (ctx) => {
783
+ await ctx.startChild("p-child", {}, "pc");
784
+ return ctx.waitForSignal("never");
785
+ });
786
+ engine.register("p-other", "1", async () => "done");
787
+ await engine.start("p-root", {}, "pr");
788
+ await engine.start("p-other", {}, "po");
789
+ expect2((await engine.waitForRun("pc", {
790
+ timeoutMs: 2e4
791
+ })).status).toBe("suspended");
792
+ expect2((await engine.waitForRun("po", {
793
+ timeoutMs: 2e4
794
+ })).status).toBe("completed");
795
+ await expect2(engine.purgeRuns({})).rejects.toThrow(/empty scope/);
796
+ expect2(await engine.purgeRuns({
797
+ workflows: [
798
+ "p-root"
799
+ ]
800
+ })).toBe(2);
801
+ expect2(await store.getRun("pr")).toBeNull();
802
+ expect2(await store.getRun("pc")).toBeNull();
803
+ expect2(await store.getRun("po")).not.toBeNull();
804
+ });
547
805
  t("updateRun maps every patchable field \u2014 clears error and patches tags/lockedBy/input/timers", async () => {
548
806
  await store.createRun(run({
549
807
  status: "failed",
@@ -1080,6 +1338,808 @@ function runStateStoreContract(name, makeStore) {
1080
1338
  }
1081
1339
  ]);
1082
1340
  });
1341
+ t("matches ANY of a set of statuses, workflows, tenants or origins", async () => {
1342
+ await store.createRun(run({
1343
+ id: "a",
1344
+ status: "failed",
1345
+ namespace: "acme",
1346
+ workflow: "wa"
1347
+ }));
1348
+ await store.createRun(run({
1349
+ id: "b",
1350
+ status: "dead",
1351
+ namespace: "globex",
1352
+ workflow: "wb"
1353
+ }));
1354
+ await store.createRun(run({
1355
+ id: "c",
1356
+ status: "completed",
1357
+ namespace: "initech",
1358
+ workflow: "wc"
1359
+ }));
1360
+ expect2((await store.listRuns({
1361
+ statuses: [
1362
+ "failed",
1363
+ "dead"
1364
+ ]
1365
+ })).map((r) => r.id).sort()).toEqual([
1366
+ "a",
1367
+ "b"
1368
+ ]);
1369
+ expect2((await store.listRuns({
1370
+ namespaces: [
1371
+ "acme",
1372
+ "globex"
1373
+ ]
1374
+ })).map((r) => r.id).sort()).toEqual([
1375
+ "a",
1376
+ "b"
1377
+ ]);
1378
+ expect2((await store.listRuns({
1379
+ workflows: [
1380
+ "wa",
1381
+ "wc"
1382
+ ]
1383
+ })).map((r) => r.id).sort()).toEqual([
1384
+ "a",
1385
+ "c"
1386
+ ]);
1387
+ });
1388
+ t("carries the absent-origin bucket INSIDE a set of origins", async () => {
1389
+ await store.createRun(run({
1390
+ id: "a",
1391
+ origin: "pkg-a"
1392
+ }));
1393
+ await store.createRun(run({
1394
+ id: "b",
1395
+ origin: "pkg-b"
1396
+ }));
1397
+ await store.createRun(run({
1398
+ id: "c"
1399
+ }));
1400
+ expect2((await store.listRuns({
1401
+ origins: [
1402
+ "pkg-a",
1403
+ null
1404
+ ]
1405
+ })).map((r) => r.id).sort()).toEqual([
1406
+ "a",
1407
+ "c"
1408
+ ]);
1409
+ expect2((await store.listRuns({
1410
+ origins: [
1411
+ "pkg-a",
1412
+ "pkg-b"
1413
+ ]
1414
+ })).map((r) => r.id).sort()).toEqual([
1415
+ "a",
1416
+ "b"
1417
+ ]);
1418
+ });
1419
+ t("ANDs a plural predicate with everything else, and matches nothing when empty", async () => {
1420
+ await store.createRun(run({
1421
+ id: "a",
1422
+ status: "failed",
1423
+ namespace: "acme"
1424
+ }));
1425
+ await store.createRun(run({
1426
+ id: "b",
1427
+ status: "dead",
1428
+ namespace: "acme"
1429
+ }));
1430
+ expect2((await store.listRuns({
1431
+ namespaces: [
1432
+ "acme"
1433
+ ],
1434
+ statuses: [
1435
+ "dead"
1436
+ ]
1437
+ })).map((r) => r.id)).toEqual([
1438
+ "b"
1439
+ ]);
1440
+ expect2(await store.listRuns({
1441
+ namespaces: []
1442
+ })).toEqual([]);
1443
+ expect2(await store.listRuns({
1444
+ statuses: []
1445
+ })).toEqual([]);
1446
+ expect2(await store.listRuns({
1447
+ origins: []
1448
+ })).toEqual([]);
1449
+ });
1450
+ t("matches runs carrying ANY of a set of tags", async () => {
1451
+ if (!supportsTagFilter) return;
1452
+ await store.createRun(run({
1453
+ id: "a",
1454
+ tags: [
1455
+ "etl"
1456
+ ]
1457
+ }));
1458
+ await store.createRun(run({
1459
+ id: "b",
1460
+ tags: [
1461
+ "nightly",
1462
+ "batch"
1463
+ ]
1464
+ }));
1465
+ await store.createRun(run({
1466
+ id: "c",
1467
+ tags: [
1468
+ "adhoc"
1469
+ ]
1470
+ }));
1471
+ expect2((await store.listRuns({
1472
+ tags: [
1473
+ "etl",
1474
+ "nightly"
1475
+ ]
1476
+ })).map((r) => r.id).sort()).toEqual([
1477
+ "a",
1478
+ "b"
1479
+ ]);
1480
+ expect2(await store.listRuns({
1481
+ tags: []
1482
+ })).toEqual([]);
1483
+ });
1484
+ t("matches a search attribute against a SET of values (the `in` predicate)", async () => {
1485
+ await store.createRun(run({
1486
+ id: "a",
1487
+ searchAttributes: {
1488
+ tier: "pro"
1489
+ }
1490
+ }));
1491
+ await store.createRun(run({
1492
+ id: "b",
1493
+ searchAttributes: {
1494
+ tier: "enterprise"
1495
+ }
1496
+ }));
1497
+ await store.createRun(run({
1498
+ id: "c",
1499
+ searchAttributes: {
1500
+ tier: "free"
1501
+ }
1502
+ }));
1503
+ await store.createRun(run({
1504
+ id: "d",
1505
+ searchAttributes: {
1506
+ amount: 200
1507
+ }
1508
+ }));
1509
+ const inSet = await store.listRuns({
1510
+ attributes: [
1511
+ {
1512
+ key: "tier",
1513
+ op: "in",
1514
+ values: [
1515
+ "pro",
1516
+ "enterprise"
1517
+ ]
1518
+ }
1519
+ ]
1520
+ });
1521
+ expect2(inSet.map((r) => r.id).sort()).toEqual([
1522
+ "a",
1523
+ "b"
1524
+ ]);
1525
+ const mixed = await store.listRuns({
1526
+ attributes: [
1527
+ {
1528
+ key: "amount",
1529
+ op: "in",
1530
+ values: [
1531
+ 200,
1532
+ "none"
1533
+ ]
1534
+ }
1535
+ ]
1536
+ });
1537
+ expect2(mixed.map((r) => r.id)).toEqual([
1538
+ "d"
1539
+ ]);
1540
+ expect2(await store.listRuns({
1541
+ attributes: [
1542
+ {
1543
+ key: "tier",
1544
+ op: "in",
1545
+ values: []
1546
+ }
1547
+ ]
1548
+ })).toEqual([]);
1549
+ expect2((await store.listRuns({
1550
+ attributes: [
1551
+ {
1552
+ key: "tier",
1553
+ op: "in",
1554
+ values: [
1555
+ "pro"
1556
+ ]
1557
+ }
1558
+ ]
1559
+ })).map((r) => r.id)).toEqual([
1560
+ "a"
1561
+ ]);
1562
+ });
1563
+ t("enumerates the distinct values of a run column, counted and ordered by frequency", async () => {
1564
+ if (!store.runValueFacets) return;
1565
+ await store.createRun(run({
1566
+ id: "a",
1567
+ namespace: "acme"
1568
+ }));
1569
+ await store.createRun(run({
1570
+ id: "b",
1571
+ namespace: "acme"
1572
+ }));
1573
+ await store.createRun(run({
1574
+ id: "c",
1575
+ namespace: "globex"
1576
+ }));
1577
+ expect2(await store.runValueFacets({
1578
+ field: "namespace"
1579
+ }, {})).toEqual([
1580
+ {
1581
+ value: "acme",
1582
+ count: 2
1583
+ },
1584
+ {
1585
+ value: "globex",
1586
+ count: 1
1587
+ }
1588
+ ]);
1589
+ });
1590
+ t("reports the unattributed bucket as a `null` value, not as a name", async () => {
1591
+ if (!store.runValueFacets) return;
1592
+ await store.createRun(run({
1593
+ id: "a",
1594
+ origin: "pkg-a"
1595
+ }));
1596
+ await store.createRun(run({
1597
+ id: "b"
1598
+ }));
1599
+ const values = await store.runValueFacets({
1600
+ field: "origin"
1601
+ }, {});
1602
+ expect2(values).toContainEqual({
1603
+ value: "pkg-a",
1604
+ count: 1
1605
+ });
1606
+ expect2(values).toContainEqual({
1607
+ value: null,
1608
+ count: 1
1609
+ });
1610
+ });
1611
+ t("scopes the values to the SAME predicates the list is under", async () => {
1612
+ if (!store.runValueFacets) return;
1613
+ if (!supportsTagFilter) return;
1614
+ await store.createRun(run({
1615
+ id: "a",
1616
+ namespace: "acme",
1617
+ tags: [
1618
+ "etl"
1619
+ ]
1620
+ }));
1621
+ await store.createRun(run({
1622
+ id: "b",
1623
+ namespace: "globex",
1624
+ tags: [
1625
+ "nightly"
1626
+ ]
1627
+ }));
1628
+ expect2(await store.runValueFacets({
1629
+ field: "tag"
1630
+ }, {
1631
+ namespace: "acme"
1632
+ })).toEqual([
1633
+ {
1634
+ value: "etl",
1635
+ count: 1
1636
+ }
1637
+ ]);
1638
+ });
1639
+ t("enumerates tags, attribute keys and the values under one key", async () => {
1640
+ if (!store.runValueFacets) return;
1641
+ await store.createRun(run({
1642
+ id: "a",
1643
+ tags: [
1644
+ "etl"
1645
+ ],
1646
+ searchAttributes: {
1647
+ tier: "pro"
1648
+ }
1649
+ }));
1650
+ await store.createRun(run({
1651
+ id: "b",
1652
+ tags: [
1653
+ "etl",
1654
+ "nightly"
1655
+ ],
1656
+ searchAttributes: {
1657
+ tier: "free"
1658
+ }
1659
+ }));
1660
+ expect2(await store.runValueFacets({
1661
+ field: "tag"
1662
+ }, {})).toEqual([
1663
+ {
1664
+ value: "etl",
1665
+ count: 2
1666
+ },
1667
+ {
1668
+ value: "nightly",
1669
+ count: 1
1670
+ }
1671
+ ]);
1672
+ expect2(await store.runValueFacets({
1673
+ field: "attributeKey"
1674
+ }, {})).toEqual([
1675
+ {
1676
+ value: "tier",
1677
+ count: 2
1678
+ }
1679
+ ]);
1680
+ expect2((await store.runValueFacets({
1681
+ field: "attributeValue",
1682
+ key: "tier"
1683
+ }, {})).map((r) => r.value).sort()).toEqual([
1684
+ "free",
1685
+ "pro"
1686
+ ]);
1687
+ });
1688
+ t("searches values server-side, BEFORE the bound", async () => {
1689
+ if (!store.runValueFacets) return;
1690
+ await store.createRun(run({
1691
+ id: "a",
1692
+ namespace: "acme-prod"
1693
+ }));
1694
+ await store.createRun(run({
1695
+ id: "b",
1696
+ namespace: "acme-staging"
1697
+ }));
1698
+ await store.createRun(run({
1699
+ id: "c",
1700
+ namespace: "globex"
1701
+ }));
1702
+ await store.createRun(run({
1703
+ id: "d",
1704
+ namespace: "globex"
1705
+ }));
1706
+ const matches = await store.runValueFacets({
1707
+ field: "namespace"
1708
+ }, {}, {
1709
+ search: "acme"
1710
+ });
1711
+ expect2(matches.map((row) => row.value).sort()).toEqual([
1712
+ "acme-prod",
1713
+ "acme-staging"
1714
+ ]);
1715
+ expect2((await store.runValueFacets({
1716
+ field: "namespace"
1717
+ }, {}, {
1718
+ limit: 1
1719
+ })).map((r) => r.value)).toEqual([
1720
+ "globex"
1721
+ ]);
1722
+ expect2((await store.runValueFacets({
1723
+ field: "namespace"
1724
+ }, {}, {
1725
+ limit: 1,
1726
+ search: "staging"
1727
+ })).map((r) => r.value)).toEqual([
1728
+ "acme-staging"
1729
+ ]);
1730
+ });
1731
+ t("pages values in a stable order, so page two continues page one", async () => {
1732
+ if (!store.runValueFacets) return;
1733
+ await store.createRun(run({
1734
+ id: "a",
1735
+ namespace: "one"
1736
+ }));
1737
+ await store.createRun(run({
1738
+ id: "b",
1739
+ namespace: "one"
1740
+ }));
1741
+ await store.createRun(run({
1742
+ id: "c",
1743
+ namespace: "one"
1744
+ }));
1745
+ await store.createRun(run({
1746
+ id: "d",
1747
+ namespace: "two"
1748
+ }));
1749
+ await store.createRun(run({
1750
+ id: "e",
1751
+ namespace: "two"
1752
+ }));
1753
+ await store.createRun(run({
1754
+ id: "f",
1755
+ namespace: "three"
1756
+ }));
1757
+ const first = await store.runValueFacets({
1758
+ field: "namespace"
1759
+ }, {}, {
1760
+ limit: 2
1761
+ });
1762
+ const second = await store.runValueFacets({
1763
+ field: "namespace"
1764
+ }, {}, {
1765
+ limit: 2,
1766
+ offset: 2
1767
+ });
1768
+ expect2(first).toEqual([
1769
+ {
1770
+ value: "one",
1771
+ count: 3
1772
+ },
1773
+ {
1774
+ value: "two",
1775
+ count: 2
1776
+ }
1777
+ ]);
1778
+ expect2(second).toEqual([
1779
+ {
1780
+ value: "three",
1781
+ count: 1
1782
+ }
1783
+ ]);
1784
+ expect2(new Set([
1785
+ ...first,
1786
+ ...second
1787
+ ].map((r) => r.value)).size).toBe(3);
1788
+ });
1789
+ t("searches and pages the tag axis too, where the values actually pile up", async () => {
1790
+ if (!store.runValueFacets) return;
1791
+ if (!supportsTagFilter) return;
1792
+ await store.createRun(run({
1793
+ id: "a",
1794
+ tags: [
1795
+ "type:mvr",
1796
+ "etl"
1797
+ ]
1798
+ }));
1799
+ await store.createRun(run({
1800
+ id: "b",
1801
+ tags: [
1802
+ "type:mel",
1803
+ "etl"
1804
+ ]
1805
+ }));
1806
+ await store.createRun(run({
1807
+ id: "c",
1808
+ tags: [
1809
+ "etl"
1810
+ ]
1811
+ }));
1812
+ expect2((await store.runValueFacets({
1813
+ field: "tag"
1814
+ }, {}, {
1815
+ search: "type:"
1816
+ })).map((r) => r.value).sort()).toEqual([
1817
+ "type:mel",
1818
+ "type:mvr"
1819
+ ]);
1820
+ expect2(await store.runValueFacets({
1821
+ field: "tag"
1822
+ }, {}, {
1823
+ limit: 1
1824
+ })).toEqual([
1825
+ {
1826
+ value: "etl",
1827
+ count: 3
1828
+ }
1829
+ ]);
1830
+ });
1831
+ t("bounds the values it returns, keeping the most common ones", async () => {
1832
+ if (!store.runValueFacets) return;
1833
+ await store.createRun(run({
1834
+ id: "a",
1835
+ namespace: "acme"
1836
+ }));
1837
+ await store.createRun(run({
1838
+ id: "b",
1839
+ namespace: "acme"
1840
+ }));
1841
+ await store.createRun(run({
1842
+ id: "c",
1843
+ namespace: "globex"
1844
+ }));
1845
+ expect2(await store.runValueFacets({
1846
+ field: "namespace"
1847
+ }, {}, {
1848
+ limit: 1
1849
+ })).toEqual([
1850
+ {
1851
+ value: "acme",
1852
+ count: 2
1853
+ }
1854
+ ]);
1855
+ });
1856
+ t("countRuns counts the same set listRuns returns, without the paging", async () => {
1857
+ expect2(typeof store.countRuns).toBe("function");
1858
+ await store.createRun(run({
1859
+ id: "n1",
1860
+ status: "running",
1861
+ namespace: "a",
1862
+ tags: [
1863
+ "q"
1864
+ ]
1865
+ }));
1866
+ await store.createRun(run({
1867
+ id: "n2",
1868
+ status: "pending",
1869
+ namespace: "a"
1870
+ }));
1871
+ await store.createRun(run({
1872
+ id: "n3",
1873
+ status: "completed",
1874
+ namespace: "a",
1875
+ tags: [
1876
+ "q"
1877
+ ]
1878
+ }));
1879
+ await store.createRun(run({
1880
+ id: "n4",
1881
+ status: "running",
1882
+ namespace: "b",
1883
+ tags: [
1884
+ "q"
1885
+ ]
1886
+ }));
1887
+ expect2(await store.countRuns?.({})).toBe(4);
1888
+ expect2(await store.countRuns?.({
1889
+ namespace: "a"
1890
+ })).toBe(3);
1891
+ expect2(await store.countRuns?.({
1892
+ namespace: "a",
1893
+ statuses: [
1894
+ "pending",
1895
+ "running"
1896
+ ]
1897
+ })).toBe(2);
1898
+ expect2(await store.countRuns?.({
1899
+ statuses: []
1900
+ })).toBe(0);
1901
+ if (supportsTagFilter) {
1902
+ expect2(await store.countRuns?.({
1903
+ tag: "q",
1904
+ statuses: [
1905
+ "running",
1906
+ "pending"
1907
+ ]
1908
+ })).toBe(2);
1909
+ }
1910
+ });
1911
+ t("engine rejects a start over its concurrency quota and frees the slot once a run settles", async () => {
1912
+ if (!supportsTagFilter) return;
1913
+ const engine = new WorkflowEngine3({
1914
+ store
1915
+ });
1916
+ engine.register("q-wait", "1", async (ctx) => ctx.waitForSignal("never"));
1917
+ const quota = {
1918
+ key: "tenant:a",
1919
+ limit: 1,
1920
+ countStatuses: [
1921
+ "pending",
1922
+ "running",
1923
+ "suspended"
1924
+ ]
1925
+ };
1926
+ await engine.start("q-wait", {}, "q1", {
1927
+ concurrency: quota
1928
+ });
1929
+ expect2((await engine.waitForRun("q1", {
1930
+ timeoutMs: 2e4
1931
+ })).status).toBe("suspended");
1932
+ await expect2(engine.start("q-wait", {}, "q2", {
1933
+ concurrency: quota
1934
+ })).rejects.toThrow(/concurrency limit/);
1935
+ expect2(await store.getRun("q2")).toBeNull();
1936
+ await engine.start("q-wait", {}, "q3", {
1937
+ concurrency: {
1938
+ ...quota,
1939
+ key: "tenant:b"
1940
+ }
1941
+ });
1942
+ expect2((await engine.waitForRun("q1", {
1943
+ timeoutMs: 2e4
1944
+ })).status).toBe("suspended");
1945
+ await engine.cancel("q1");
1946
+ expect2((await store.getRun("q1"))?.status).toBe("cancelled");
1947
+ await engine.start("q-wait", {}, "q4", {
1948
+ concurrency: quota
1949
+ });
1950
+ expect2(await store.getRun("q4")).not.toBeNull();
1951
+ for (const id of [
1952
+ "q3",
1953
+ "q4"
1954
+ ]) await engine.waitForRun(id, {
1955
+ timeoutMs: 2e4
1956
+ });
1957
+ await engine.drain();
1958
+ });
1959
+ const schedule = /* @__PURE__ */ __name((over = {}) => ({
1960
+ id: "s1",
1961
+ namespace: "default",
1962
+ workflow: "digest",
1963
+ paused: false,
1964
+ nextFireAt: NOW + 6e4,
1965
+ tags: [
1966
+ "tenant:a"
1967
+ ],
1968
+ spec: {
1969
+ v: 1,
1970
+ cron: "0 9 * * *",
1971
+ input: {
1972
+ userId: "u1",
1973
+ deep: [
1974
+ 1,
1975
+ {
1976
+ x: true
1977
+ }
1978
+ ]
1979
+ }
1980
+ },
1981
+ state: {
1982
+ window: NOW + 6e4
1983
+ },
1984
+ createdAt: at,
1985
+ updatedAt: at,
1986
+ ...over
1987
+ }), "schedule");
1988
+ t("saves, reads back, replaces and deletes a schedule", async () => {
1989
+ expect2(typeof store.saveSchedule).toBe("function");
1990
+ await store.saveSchedule?.(schedule());
1991
+ const loaded = await store.getSchedule?.("s1");
1992
+ expect2(loaded).toMatchObject({
1993
+ id: "s1",
1994
+ namespace: "default",
1995
+ workflow: "digest",
1996
+ paused: false,
1997
+ nextFireAt: NOW + 6e4,
1998
+ tags: [
1999
+ "tenant:a"
2000
+ ],
2001
+ spec: {
2002
+ v: 1,
2003
+ cron: "0 9 * * *",
2004
+ input: {
2005
+ userId: "u1",
2006
+ deep: [
2007
+ 1,
2008
+ {
2009
+ x: true
2010
+ }
2011
+ ]
2012
+ }
2013
+ },
2014
+ state: {
2015
+ window: NOW + 6e4
2016
+ }
2017
+ });
2018
+ expect2(loaded?.createdAt.getTime()).toBe(at.getTime());
2019
+ await store.saveSchedule?.(schedule({
2020
+ workflow: "digest2",
2021
+ nextFireAt: null,
2022
+ tags: void 0
2023
+ }));
2024
+ const replaced = await store.getSchedule?.("s1");
2025
+ expect2(replaced?.workflow).toBe("digest2");
2026
+ expect2(replaced?.nextFireAt).toBeNull();
2027
+ expect2(replaced?.tags ?? []).toEqual([]);
2028
+ expect2(await store.getSchedule?.("nope")).toBeNull();
2029
+ expect2(await store.deleteSchedule?.("s1")).toBe(true);
2030
+ expect2(await store.deleteSchedule?.("s1")).toBe(false);
2031
+ expect2(await store.getSchedule?.("s1")).toBeNull();
2032
+ });
2033
+ t("updateSchedule patches, and compare-and-sets on nextFireAt", async () => {
2034
+ await store.saveSchedule?.(schedule());
2035
+ expect2(await store.updateSchedule?.("s1", {
2036
+ paused: true
2037
+ })).toBe(true);
2038
+ expect2((await store.getSchedule?.("s1"))?.paused).toBe(true);
2039
+ const next = {
2040
+ nextFireAt: NOW + 12e4,
2041
+ state: {
2042
+ window: NOW + 12e4,
2043
+ fires: 1
2044
+ }
2045
+ };
2046
+ expect2(await store.updateSchedule?.("s1", next, NOW + 6e4)).toBe(true);
2047
+ expect2(await store.updateSchedule?.("s1", next, NOW + 6e4)).toBe(false);
2048
+ const after = await store.getSchedule?.("s1");
2049
+ expect2(after?.nextFireAt).toBe(NOW + 12e4);
2050
+ expect2(after?.state).toEqual({
2051
+ window: NOW + 12e4,
2052
+ fires: 1
2053
+ });
2054
+ await store.updateSchedule?.("s1", {
2055
+ nextFireAt: null
2056
+ });
2057
+ expect2(await store.updateSchedule?.("s1", {
2058
+ nextFireAt: NOW
2059
+ }, NOW + 1)).toBe(false);
2060
+ expect2(await store.updateSchedule?.("s1", {
2061
+ nextFireAt: NOW
2062
+ }, null)).toBe(true);
2063
+ expect2(await store.updateSchedule?.("ghost", {
2064
+ paused: true
2065
+ })).toBe(false);
2066
+ });
2067
+ t('lists schedules by namespace/workflow/tag/paused and answers "due by"', async () => {
2068
+ await store.saveSchedule?.(schedule({
2069
+ id: "a",
2070
+ nextFireAt: NOW - 10
2071
+ }));
2072
+ await store.saveSchedule?.(schedule({
2073
+ id: "b",
2074
+ nextFireAt: NOW - 20,
2075
+ namespace: "x"
2076
+ }));
2077
+ await store.saveSchedule?.(schedule({
2078
+ id: "c",
2079
+ nextFireAt: NOW - 30,
2080
+ paused: true
2081
+ }));
2082
+ await store.saveSchedule?.(schedule({
2083
+ id: "d",
2084
+ nextFireAt: NOW + 10,
2085
+ workflow: "other"
2086
+ }));
2087
+ await store.saveSchedule?.(schedule({
2088
+ id: "e",
2089
+ nextFireAt: null,
2090
+ tags: [
2091
+ "tenant:b"
2092
+ ]
2093
+ }));
2094
+ const ids2 = /* @__PURE__ */ __name(async (q) => (await store.listSchedules?.(q) ?? []).map((r) => r.id), "ids");
2095
+ expect2(await ids2({})).toEqual([
2096
+ "c",
2097
+ "b",
2098
+ "a",
2099
+ "d",
2100
+ "e"
2101
+ ]);
2102
+ expect2(await ids2({
2103
+ dueBy: NOW
2104
+ })).toEqual([
2105
+ "b",
2106
+ "a"
2107
+ ]);
2108
+ expect2(await ids2({
2109
+ dueBy: NOW,
2110
+ namespace: "default"
2111
+ })).toEqual([
2112
+ "a"
2113
+ ]);
2114
+ expect2(await ids2({
2115
+ dueBy: NOW,
2116
+ limit: 1
2117
+ })).toEqual([
2118
+ "b"
2119
+ ]);
2120
+ expect2(await ids2({
2121
+ workflow: "other"
2122
+ })).toEqual([
2123
+ "d"
2124
+ ]);
2125
+ expect2(await ids2({
2126
+ paused: true
2127
+ })).toEqual([
2128
+ "c"
2129
+ ]);
2130
+ if (supportsTagFilter) expect2(await ids2({
2131
+ tag: "tenant:b"
2132
+ })).toEqual([
2133
+ "e"
2134
+ ]);
2135
+ expect2(await ids2({
2136
+ limit: 2,
2137
+ offset: 1
2138
+ })).toEqual([
2139
+ "b",
2140
+ "a"
2141
+ ]);
2142
+ });
1083
2143
  t("orders listRuns newest-first and paginates with limit/offset", async () => {
1084
2144
  await store.createRun(run({
1085
2145
  id: "old",