@dudousxd/nestjs-durable-testing 0.14.0 → 0.16.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) {
@@ -386,7 +399,7 @@ async function assertReplayable(register, history) {
386
399
  __name(assertReplayable, "assertReplayable");
387
400
 
388
401
  // src/state-store-conformance.ts
389
- import { WorkflowEngine as WorkflowEngine3 } from "@dudousxd/nestjs-durable-core";
402
+ import { SINGLETON_ADMITTED_TAG, WorkflowEngine as WorkflowEngine3 } from "@dudousxd/nestjs-durable-core";
390
403
  import { beforeEach, describe as describe2, expect as expect2, it as it2 } from "vitest";
391
404
  var StateStoreUnavailableError = class extends Error {
392
405
  static {
@@ -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",
@@ -434,6 +449,7 @@ var checkpoint = /* @__PURE__ */ __name((over = {}) => ({
434
449
  function runStateStoreContract(name, makeStore) {
435
450
  describe2(`StateStore contract: ${name}`, () => {
436
451
  let store;
452
+ let peerStore;
437
453
  let cleanup;
438
454
  let supportsAsyncTransaction = true;
439
455
  let supportsTagFilter = true;
@@ -443,6 +459,7 @@ function runStateStoreContract(name, makeStore) {
443
459
  try {
444
460
  const ctx = await makeStore();
445
461
  store = ctx.store;
462
+ peerStore = ctx.peerStore ?? ctx.store;
446
463
  cleanup = ctx.cleanup;
447
464
  supportsAsyncTransaction = ctx.supportsAsyncTransaction ?? true;
448
465
  supportsTagFilter = ctx.supportsTagFilter ?? true;
@@ -468,6 +485,197 @@ function runStateStoreContract(name, makeStore) {
468
485
  await fn();
469
486
  });
470
487
  }, "t");
488
+ t("atomically admits holders without displacement by later same-millisecond IDs", async () => {
489
+ expect2(store.tryAdmitSingleton).toBeTypeOf("function");
490
+ const claim = /* @__PURE__ */ __name((id, limit = 1) => store.tryAdmitSingleton?.(id, "singleton:k", "checkout", limit), "claim");
491
+ await store.createRun(run({
492
+ id: "z-holder",
493
+ tags: [
494
+ "singleton:k",
495
+ "user-tag"
496
+ ]
497
+ }));
498
+ expect2(await claim("z-holder")).toBe(true);
499
+ await store.updateRun("z-holder", {
500
+ status: "suspended",
501
+ wakeAt: 1e4
502
+ });
503
+ expect2(await claim("z-holder")).toBe(true);
504
+ await store.updateRun("z-holder", {
505
+ status: "blocked",
506
+ lockedBy: "expired",
507
+ lockedUntil: 0
508
+ });
509
+ await store.createRun(run({
510
+ id: "a-waiter",
511
+ tags: [
512
+ "singleton:k"
513
+ ]
514
+ }));
515
+ expect2(await claim("a-waiter")).toBe(false);
516
+ expect2(await claim("z-holder")).toBe(true);
517
+ await store.updateRun("z-holder", {
518
+ status: "cancelling"
519
+ });
520
+ expect2(await claim("a-waiter")).toBe(false);
521
+ await store.updateRun("z-holder", {
522
+ status: "completed"
523
+ });
524
+ expect2((await store.getRun("z-holder"))?.tags).toEqual([
525
+ "singleton:k",
526
+ "user-tag"
527
+ ]);
528
+ expect2(await claim("a-waiter")).toBe(true);
529
+ await store.updateRun("z-holder", {
530
+ status: "running"
531
+ });
532
+ expect2(await claim("z-holder")).toBe(false);
533
+ expect2(await claim("missing")).toBe(false);
534
+ });
535
+ t("singleton denial atomically parks waiters and preserves cancelling/terminal state", async () => {
536
+ await store.createRun(run({
537
+ id: "holder",
538
+ tags: [
539
+ "singleton:k"
540
+ ]
541
+ }));
542
+ expect2(await store.tryAdmitSingleton?.("holder", "singleton:k", "checkout", 1)).toBe(true);
543
+ await store.createRun(run({
544
+ id: "waiter",
545
+ tags: [
546
+ "singleton:k"
547
+ ]
548
+ }));
549
+ expect2(await peerStore.tryAdmitSingleton?.("waiter", "singleton:k", "checkout", 1, 12e3)).toBe(false);
550
+ expect2(await store.getRun("waiter")).toMatchObject({
551
+ status: "suspended",
552
+ wakeAt: 12e3
553
+ });
554
+ await store.updateRun("waiter", {
555
+ status: "cancelling",
556
+ wakeAt: void 0
557
+ });
558
+ expect2(await peerStore.tryAdmitSingleton?.("waiter", "singleton:k", "checkout", 1, 14e3)).toBe(false);
559
+ expect2(await store.getRun("waiter")).toMatchObject({
560
+ status: "cancelling"
561
+ });
562
+ expect2((await store.getRun("waiter"))?.wakeAt).toBeUndefined();
563
+ await store.updateRun("waiter", {
564
+ status: "cancelled"
565
+ });
566
+ expect2(await peerStore.tryAdmitSingleton?.("waiter", "singleton:k", "checkout", 1, 16e3)).toBe(false);
567
+ expect2((await store.getRun("waiter"))?.status).toBe("cancelled");
568
+ await store.updateRun("holder", {
569
+ status: "completed"
570
+ });
571
+ await store.createRun(run({
572
+ id: "cancelling",
573
+ status: "cancelling",
574
+ tags: [
575
+ "singleton:k"
576
+ ]
577
+ }));
578
+ expect2(await store.tryAdmitSingleton?.("cancelling", "singleton:k", "checkout", 1, 18e3)).toBe(false);
579
+ expect2((await store.getRun("cancelling"))?.tags).not.toContain(SINGLETON_ADMITTED_TAG);
580
+ });
581
+ t("competing store instances atomically claim an empty singleton slot", async () => {
582
+ for (const id of [
583
+ "a-first",
584
+ "b-second",
585
+ "c-third"
586
+ ]) {
587
+ await store.createRun(run({
588
+ id,
589
+ tags: [
590
+ "singleton:k"
591
+ ]
592
+ }));
593
+ }
594
+ const results = await Promise.all([
595
+ peerStore.tryAdmitSingleton?.("b-second", "singleton:k", "checkout", 1),
596
+ store.tryAdmitSingleton?.("a-first", "singleton:k", "checkout", 1),
597
+ peerStore.tryAdmitSingleton?.("c-third", "singleton:k", "checkout", 1)
598
+ ]);
599
+ expect2(results).toEqual([
600
+ false,
601
+ true,
602
+ false
603
+ ]);
604
+ expect2(await peerStore.tryAdmitSingleton?.("a-first", "singleton:k", "checkout", 1)).toBe(true);
605
+ expect2((await store.getRun("a-first"))?.tags).toContain(SINGLETON_ADMITTED_TAG);
606
+ });
607
+ t("concurrent singleton claims honor limits and independent keys/workflows", async () => {
608
+ const claim = /* @__PURE__ */ __name((id, tag = "singleton:k", workflow = "checkout") => store.tryAdmitSingleton?.(id, tag, workflow, 2), "claim");
609
+ for (const id of [
610
+ "z-holder",
611
+ "y-holder"
612
+ ]) {
613
+ await store.createRun(run({
614
+ id,
615
+ tags: [
616
+ "singleton:k"
617
+ ]
618
+ }));
619
+ expect2(await claim(id)).toBe(true);
620
+ }
621
+ for (const id of [
622
+ "a-waiter",
623
+ "b-waiter",
624
+ "c-waiter"
625
+ ]) {
626
+ await store.createRun(run({
627
+ id,
628
+ status: "pending",
629
+ tags: [
630
+ "singleton:k"
631
+ ]
632
+ }));
633
+ }
634
+ expect2(await Promise.all([
635
+ "a-waiter",
636
+ "b-waiter",
637
+ "c-waiter"
638
+ ].map((id) => claim(id)))).toEqual([
639
+ false,
640
+ false,
641
+ false
642
+ ]);
643
+ await store.updateRun("z-holder", {
644
+ status: "failed"
645
+ });
646
+ expect2(await Promise.all([
647
+ "c-waiter",
648
+ "b-waiter",
649
+ "a-waiter"
650
+ ].map((id) => claim(id)))).toEqual([
651
+ false,
652
+ false,
653
+ true
654
+ ]);
655
+ const active = await store.listRuns({
656
+ workflow: "checkout",
657
+ statuses: [
658
+ "running",
659
+ "pending"
660
+ ]
661
+ });
662
+ expect2(active.filter((r) => r.tags?.includes(SINGLETON_ADMITTED_TAG))).toHaveLength(2);
663
+ await store.createRun(run({
664
+ id: "other-key",
665
+ tags: [
666
+ "singleton:other"
667
+ ]
668
+ }));
669
+ expect2(await claim("other-key", "singleton:other")).toBe(true);
670
+ await store.createRun(run({
671
+ id: "other-workflow",
672
+ workflow: "other",
673
+ tags: [
674
+ "singleton:k"
675
+ ]
676
+ }));
677
+ expect2(await claim("other-workflow", "singleton:k", "other")).toBe(true);
678
+ });
471
679
  t("creates a run and reads it back with its JSON input", async () => {
472
680
  await store.createRun(run());
473
681
  const loaded = await store.getRun("r1");
@@ -544,6 +752,249 @@ function runStateStoreContract(name, makeStore) {
544
752
  t("deleteRun is a no-op for a missing run", async () => {
545
753
  await expect2(store.deleteRun("nope")).resolves.toBeUndefined();
546
754
  });
755
+ t("deleteRuns removes every listed run with its rows and ignores missing ids", async () => {
756
+ await store.createRun(run({
757
+ id: "d1",
758
+ searchAttributes: {
759
+ tier: "pro"
760
+ }
761
+ }));
762
+ await store.createRun(run({
763
+ id: "d2"
764
+ }));
765
+ await store.createRun(run({
766
+ id: "keep"
767
+ }));
768
+ await store.saveCheckpoint(checkpoint({
769
+ runId: "d1",
770
+ stepId: "d1:0"
771
+ }));
772
+ await store.putSignalWaiter({
773
+ token: "approve-d2",
774
+ runId: "d2",
775
+ seq: 0
776
+ });
777
+ expect2(typeof store.deleteRuns).toBe("function");
778
+ await store.deleteRuns?.([
779
+ "d1",
780
+ "d2",
781
+ "ghost"
782
+ ]);
783
+ expect2(await store.getRun("d1")).toBeNull();
784
+ expect2(await store.getRun("d2")).toBeNull();
785
+ expect2(await store.getRun("keep")).not.toBeNull();
786
+ expect2(await store.listCheckpoints("d1")).toEqual([]);
787
+ expect2(await store.listSignalWaiters("approve-")).toEqual([]);
788
+ expect2(await store.listRuns({
789
+ attributes: [
790
+ {
791
+ key: "tier",
792
+ op: "eq",
793
+ value: "pro"
794
+ }
795
+ ]
796
+ })).toEqual([]);
797
+ await expect2(store.deleteRuns?.([])).resolves.toBeUndefined();
798
+ });
799
+ const aged = /* @__PURE__ */ __name((id, ageDays, over = {}) => run({
800
+ id,
801
+ status: "completed",
802
+ createdAt: new Date(NOW - ageDays * DAY),
803
+ updatedAt: new Date(NOW - ageDays * DAY),
804
+ ...over
805
+ }), "aged");
806
+ const ids = /* @__PURE__ */ __name(async () => (await store.listRuns({})).map((r) => r.id).sort(), "ids");
807
+ t("pruneTerminalRuns drops terminal runs past maxAge and never touches live ones", async () => {
808
+ await store.createRun(aged("old-done", 10));
809
+ await store.createRun(aged("old-failed", 10, {
810
+ status: "failed"
811
+ }));
812
+ await store.createRun(aged("old-live", 10, {
813
+ status: "suspended"
814
+ }));
815
+ await store.createRun(aged("fresh-done", 1));
816
+ await store.saveCheckpoint(checkpoint({
817
+ runId: "old-done",
818
+ stepId: "old-done:0"
819
+ }));
820
+ expect2(typeof store.pruneTerminalRuns).toBe("function");
821
+ const deleted = await store.pruneTerminalRuns?.({
822
+ statuses: [
823
+ "completed",
824
+ "failed",
825
+ "suspended"
826
+ ],
827
+ maxAge: "7d"
828
+ }, NOW, 100);
829
+ expect2(deleted).toBe(2);
830
+ expect2(await ids()).toEqual([
831
+ "fresh-done",
832
+ "old-live"
833
+ ]);
834
+ expect2(await store.listCheckpoints("old-done")).toEqual([]);
835
+ });
836
+ t("pruneTerminalRuns keeps only the newest maxCount and drains in batches of limit", async () => {
837
+ for (let i = 1; i <= 5; i++) await store.createRun(aged(`c${i}`, i));
838
+ const policy = {
839
+ statuses: [
840
+ "completed"
841
+ ],
842
+ maxCount: 2
843
+ };
844
+ expect2(await store.pruneTerminalRuns?.(policy, NOW, 2)).toBe(2);
845
+ expect2(await store.pruneTerminalRuns?.(policy, NOW, 2)).toBe(1);
846
+ expect2(await store.pruneTerminalRuns?.(policy, NOW, 2)).toBe(0);
847
+ expect2(await ids()).toEqual([
848
+ "c1",
849
+ "c2"
850
+ ]);
851
+ });
852
+ t("a scoped retention policy only prunes (and counts) the runs its scope matches", async () => {
853
+ await store.createRun(aged("acme-old", 10, {
854
+ namespace: "acme"
855
+ }));
856
+ await store.createRun(aged("acme-new", 1, {
857
+ namespace: "acme"
858
+ }));
859
+ await store.createRun(aged("beta-old", 10, {
860
+ namespace: "beta"
861
+ }));
862
+ await store.createRun(aged("report-old", 10, {
863
+ workflow: "report"
864
+ }));
865
+ await store.pruneTerminalRuns?.({
866
+ statuses: [
867
+ "completed"
868
+ ],
869
+ maxAge: "7d",
870
+ scope: {
871
+ namespace: "acme"
872
+ }
873
+ }, NOW, 100);
874
+ expect2(await ids()).toEqual([
875
+ "acme-new",
876
+ "beta-old",
877
+ "report-old"
878
+ ]);
879
+ await store.pruneTerminalRuns?.({
880
+ statuses: [
881
+ "completed"
882
+ ],
883
+ maxAge: "7d",
884
+ scope: {
885
+ workflows: [
886
+ "report"
887
+ ]
888
+ }
889
+ }, NOW, 100);
890
+ expect2(await ids()).toEqual([
891
+ "acme-new",
892
+ "beta-old"
893
+ ]);
894
+ await store.createRun(aged("acme-mid", 3, {
895
+ namespace: "acme"
896
+ }));
897
+ await store.pruneTerminalRuns?.({
898
+ statuses: [
899
+ "completed"
900
+ ],
901
+ maxCount: 1,
902
+ scope: {
903
+ namespace: "acme"
904
+ }
905
+ }, NOW, 100);
906
+ expect2(await ids()).toEqual([
907
+ "acme-new",
908
+ "beta-old"
909
+ ]);
910
+ });
911
+ t("a retention scope can select by tag and by search attribute", async () => {
912
+ if (!supportsTagFilter) return;
913
+ await store.createRun(aged("chat-old", 10, {
914
+ tags: [
915
+ "chat",
916
+ "tenant:t1"
917
+ ]
918
+ }));
919
+ await store.createRun(aged("chatty-old", 10, {
920
+ tags: [
921
+ "chatty"
922
+ ]
923
+ }));
924
+ await store.createRun(aged("attr-old", 10, {
925
+ searchAttributes: {
926
+ tenantId: "t2"
927
+ }
928
+ }));
929
+ await store.createRun(aged("attr-other", 10, {
930
+ searchAttributes: {
931
+ tenantId: "t3"
932
+ }
933
+ }));
934
+ await store.pruneTerminalRuns?.({
935
+ statuses: [
936
+ "completed"
937
+ ],
938
+ maxAge: "7d",
939
+ scope: {
940
+ tags: [
941
+ "chat"
942
+ ]
943
+ }
944
+ }, NOW, 100);
945
+ expect2(await ids()).toEqual([
946
+ "attr-old",
947
+ "attr-other",
948
+ "chatty-old"
949
+ ]);
950
+ await store.pruneTerminalRuns?.({
951
+ statuses: [
952
+ "completed"
953
+ ],
954
+ maxAge: "7d",
955
+ scope: {
956
+ attributes: [
957
+ {
958
+ key: "tenantId",
959
+ op: "eq",
960
+ value: "t2"
961
+ }
962
+ ]
963
+ }
964
+ }, NOW, 100);
965
+ expect2(await ids()).toEqual([
966
+ "attr-other",
967
+ "chatty-old"
968
+ ]);
969
+ });
970
+ t("engine.purgeRuns deletes a scope with its child subtrees and cancels live runs", async () => {
971
+ const engine = new WorkflowEngine3({
972
+ store
973
+ });
974
+ engine.register("p-child", "1", async (ctx) => ctx.waitForSignal("never"));
975
+ engine.register("p-root", "1", async (ctx) => {
976
+ await ctx.startChild("p-child", {}, "pc");
977
+ return ctx.waitForSignal("never");
978
+ });
979
+ engine.register("p-other", "1", async () => "done");
980
+ await engine.start("p-root", {}, "pr");
981
+ await engine.start("p-other", {}, "po");
982
+ expect2((await engine.waitForRun("pc", {
983
+ timeoutMs: 2e4
984
+ })).status).toBe("suspended");
985
+ expect2((await engine.waitForRun("po", {
986
+ timeoutMs: 2e4
987
+ })).status).toBe("completed");
988
+ await expect2(engine.purgeRuns({})).rejects.toThrow(/empty scope/);
989
+ expect2(await engine.purgeRuns({
990
+ workflows: [
991
+ "p-root"
992
+ ]
993
+ })).toBe(2);
994
+ expect2(await store.getRun("pr")).toBeNull();
995
+ expect2(await store.getRun("pc")).toBeNull();
996
+ expect2(await store.getRun("po")).not.toBeNull();
997
+ });
547
998
  t("updateRun maps every patchable field \u2014 clears error and patches tags/lockedBy/input/timers", async () => {
548
999
  await store.createRun(run({
549
1000
  status: "failed",
@@ -1427,6 +1878,149 @@ function runStateStoreContract(name, makeStore) {
1427
1878
  "pro"
1428
1879
  ]);
1429
1880
  });
1881
+ t("searches values server-side, BEFORE the bound", async () => {
1882
+ if (!store.runValueFacets) return;
1883
+ await store.createRun(run({
1884
+ id: "a",
1885
+ namespace: "acme-prod"
1886
+ }));
1887
+ await store.createRun(run({
1888
+ id: "b",
1889
+ namespace: "acme-staging"
1890
+ }));
1891
+ await store.createRun(run({
1892
+ id: "c",
1893
+ namespace: "globex"
1894
+ }));
1895
+ await store.createRun(run({
1896
+ id: "d",
1897
+ namespace: "globex"
1898
+ }));
1899
+ const matches = await store.runValueFacets({
1900
+ field: "namespace"
1901
+ }, {}, {
1902
+ search: "acme"
1903
+ });
1904
+ expect2(matches.map((row) => row.value).sort()).toEqual([
1905
+ "acme-prod",
1906
+ "acme-staging"
1907
+ ]);
1908
+ expect2((await store.runValueFacets({
1909
+ field: "namespace"
1910
+ }, {}, {
1911
+ limit: 1
1912
+ })).map((r) => r.value)).toEqual([
1913
+ "globex"
1914
+ ]);
1915
+ expect2((await store.runValueFacets({
1916
+ field: "namespace"
1917
+ }, {}, {
1918
+ limit: 1,
1919
+ search: "staging"
1920
+ })).map((r) => r.value)).toEqual([
1921
+ "acme-staging"
1922
+ ]);
1923
+ });
1924
+ t("pages values in a stable order, so page two continues page one", async () => {
1925
+ if (!store.runValueFacets) return;
1926
+ await store.createRun(run({
1927
+ id: "a",
1928
+ namespace: "one"
1929
+ }));
1930
+ await store.createRun(run({
1931
+ id: "b",
1932
+ namespace: "one"
1933
+ }));
1934
+ await store.createRun(run({
1935
+ id: "c",
1936
+ namespace: "one"
1937
+ }));
1938
+ await store.createRun(run({
1939
+ id: "d",
1940
+ namespace: "two"
1941
+ }));
1942
+ await store.createRun(run({
1943
+ id: "e",
1944
+ namespace: "two"
1945
+ }));
1946
+ await store.createRun(run({
1947
+ id: "f",
1948
+ namespace: "three"
1949
+ }));
1950
+ const first = await store.runValueFacets({
1951
+ field: "namespace"
1952
+ }, {}, {
1953
+ limit: 2
1954
+ });
1955
+ const second = await store.runValueFacets({
1956
+ field: "namespace"
1957
+ }, {}, {
1958
+ limit: 2,
1959
+ offset: 2
1960
+ });
1961
+ expect2(first).toEqual([
1962
+ {
1963
+ value: "one",
1964
+ count: 3
1965
+ },
1966
+ {
1967
+ value: "two",
1968
+ count: 2
1969
+ }
1970
+ ]);
1971
+ expect2(second).toEqual([
1972
+ {
1973
+ value: "three",
1974
+ count: 1
1975
+ }
1976
+ ]);
1977
+ expect2(new Set([
1978
+ ...first,
1979
+ ...second
1980
+ ].map((r) => r.value)).size).toBe(3);
1981
+ });
1982
+ t("searches and pages the tag axis too, where the values actually pile up", async () => {
1983
+ if (!store.runValueFacets) return;
1984
+ if (!supportsTagFilter) return;
1985
+ await store.createRun(run({
1986
+ id: "a",
1987
+ tags: [
1988
+ "type:mvr",
1989
+ "etl"
1990
+ ]
1991
+ }));
1992
+ await store.createRun(run({
1993
+ id: "b",
1994
+ tags: [
1995
+ "type:mel",
1996
+ "etl"
1997
+ ]
1998
+ }));
1999
+ await store.createRun(run({
2000
+ id: "c",
2001
+ tags: [
2002
+ "etl"
2003
+ ]
2004
+ }));
2005
+ expect2((await store.runValueFacets({
2006
+ field: "tag"
2007
+ }, {}, {
2008
+ search: "type:"
2009
+ })).map((r) => r.value).sort()).toEqual([
2010
+ "type:mel",
2011
+ "type:mvr"
2012
+ ]);
2013
+ expect2(await store.runValueFacets({
2014
+ field: "tag"
2015
+ }, {}, {
2016
+ limit: 1
2017
+ })).toEqual([
2018
+ {
2019
+ value: "etl",
2020
+ count: 3
2021
+ }
2022
+ ]);
2023
+ });
1430
2024
  t("bounds the values it returns, keeping the most common ones", async () => {
1431
2025
  if (!store.runValueFacets) return;
1432
2026
  await store.createRun(run({
@@ -1452,6 +2046,293 @@ function runStateStoreContract(name, makeStore) {
1452
2046
  }
1453
2047
  ]);
1454
2048
  });
2049
+ t("countRuns counts the same set listRuns returns, without the paging", async () => {
2050
+ expect2(typeof store.countRuns).toBe("function");
2051
+ await store.createRun(run({
2052
+ id: "n1",
2053
+ status: "running",
2054
+ namespace: "a",
2055
+ tags: [
2056
+ "q"
2057
+ ]
2058
+ }));
2059
+ await store.createRun(run({
2060
+ id: "n2",
2061
+ status: "pending",
2062
+ namespace: "a"
2063
+ }));
2064
+ await store.createRun(run({
2065
+ id: "n3",
2066
+ status: "completed",
2067
+ namespace: "a",
2068
+ tags: [
2069
+ "q"
2070
+ ]
2071
+ }));
2072
+ await store.createRun(run({
2073
+ id: "n4",
2074
+ status: "running",
2075
+ namespace: "b",
2076
+ tags: [
2077
+ "q"
2078
+ ]
2079
+ }));
2080
+ expect2(await store.countRuns?.({})).toBe(4);
2081
+ expect2(await store.countRuns?.({
2082
+ namespace: "a"
2083
+ })).toBe(3);
2084
+ expect2(await store.countRuns?.({
2085
+ namespace: "a",
2086
+ statuses: [
2087
+ "pending",
2088
+ "running"
2089
+ ]
2090
+ })).toBe(2);
2091
+ expect2(await store.countRuns?.({
2092
+ statuses: []
2093
+ })).toBe(0);
2094
+ if (supportsTagFilter) {
2095
+ expect2(await store.countRuns?.({
2096
+ tag: "q",
2097
+ statuses: [
2098
+ "running",
2099
+ "pending"
2100
+ ]
2101
+ })).toBe(2);
2102
+ }
2103
+ });
2104
+ t("engine rejects a start over its concurrency quota and frees the slot once a run settles", async () => {
2105
+ if (!supportsTagFilter) return;
2106
+ const engine = new WorkflowEngine3({
2107
+ store
2108
+ });
2109
+ engine.register("q-wait", "1", async (ctx) => ctx.waitForSignal("never"));
2110
+ const quota = {
2111
+ key: "tenant:a",
2112
+ limit: 1,
2113
+ countStatuses: [
2114
+ "pending",
2115
+ "running",
2116
+ "suspended"
2117
+ ]
2118
+ };
2119
+ await engine.start("q-wait", {}, "q1", {
2120
+ concurrency: quota
2121
+ });
2122
+ expect2((await engine.waitForRun("q1", {
2123
+ timeoutMs: 2e4
2124
+ })).status).toBe("suspended");
2125
+ await expect2(engine.start("q-wait", {}, "q2", {
2126
+ concurrency: quota
2127
+ })).rejects.toThrow(/concurrency limit/);
2128
+ expect2(await store.getRun("q2")).toBeNull();
2129
+ await engine.start("q-wait", {}, "q3", {
2130
+ concurrency: {
2131
+ ...quota,
2132
+ key: "tenant:b"
2133
+ }
2134
+ });
2135
+ expect2((await engine.waitForRun("q1", {
2136
+ timeoutMs: 2e4
2137
+ })).status).toBe("suspended");
2138
+ await engine.cancel("q1");
2139
+ expect2((await store.getRun("q1"))?.status).toBe("cancelled");
2140
+ await engine.start("q-wait", {}, "q4", {
2141
+ concurrency: quota
2142
+ });
2143
+ expect2(await store.getRun("q4")).not.toBeNull();
2144
+ for (const id of [
2145
+ "q3",
2146
+ "q4"
2147
+ ]) await engine.waitForRun(id, {
2148
+ timeoutMs: 2e4
2149
+ });
2150
+ await engine.drain();
2151
+ });
2152
+ const schedule = /* @__PURE__ */ __name((over = {}) => ({
2153
+ id: "s1",
2154
+ namespace: "default",
2155
+ workflow: "digest",
2156
+ paused: false,
2157
+ nextFireAt: NOW + 6e4,
2158
+ tags: [
2159
+ "tenant:a"
2160
+ ],
2161
+ spec: {
2162
+ v: 1,
2163
+ cron: "0 9 * * *",
2164
+ input: {
2165
+ userId: "u1",
2166
+ deep: [
2167
+ 1,
2168
+ {
2169
+ x: true
2170
+ }
2171
+ ]
2172
+ }
2173
+ },
2174
+ state: {
2175
+ window: NOW + 6e4
2176
+ },
2177
+ createdAt: at,
2178
+ updatedAt: at,
2179
+ ...over
2180
+ }), "schedule");
2181
+ t("saves, reads back, replaces and deletes a schedule", async () => {
2182
+ expect2(typeof store.saveSchedule).toBe("function");
2183
+ await store.saveSchedule?.(schedule());
2184
+ const loaded = await store.getSchedule?.("s1");
2185
+ expect2(loaded).toMatchObject({
2186
+ id: "s1",
2187
+ namespace: "default",
2188
+ workflow: "digest",
2189
+ paused: false,
2190
+ nextFireAt: NOW + 6e4,
2191
+ tags: [
2192
+ "tenant:a"
2193
+ ],
2194
+ spec: {
2195
+ v: 1,
2196
+ cron: "0 9 * * *",
2197
+ input: {
2198
+ userId: "u1",
2199
+ deep: [
2200
+ 1,
2201
+ {
2202
+ x: true
2203
+ }
2204
+ ]
2205
+ }
2206
+ },
2207
+ state: {
2208
+ window: NOW + 6e4
2209
+ }
2210
+ });
2211
+ expect2(loaded?.createdAt.getTime()).toBe(at.getTime());
2212
+ await store.saveSchedule?.(schedule({
2213
+ workflow: "digest2",
2214
+ nextFireAt: null,
2215
+ tags: void 0
2216
+ }));
2217
+ const replaced = await store.getSchedule?.("s1");
2218
+ expect2(replaced?.workflow).toBe("digest2");
2219
+ expect2(replaced?.nextFireAt).toBeNull();
2220
+ expect2(replaced?.tags ?? []).toEqual([]);
2221
+ expect2(await store.getSchedule?.("nope")).toBeNull();
2222
+ expect2(await store.deleteSchedule?.("s1")).toBe(true);
2223
+ expect2(await store.deleteSchedule?.("s1")).toBe(false);
2224
+ expect2(await store.getSchedule?.("s1")).toBeNull();
2225
+ });
2226
+ t("updateSchedule patches, and compare-and-sets on nextFireAt", async () => {
2227
+ await store.saveSchedule?.(schedule());
2228
+ expect2(await store.updateSchedule?.("s1", {
2229
+ paused: true
2230
+ })).toBe(true);
2231
+ expect2((await store.getSchedule?.("s1"))?.paused).toBe(true);
2232
+ const next = {
2233
+ nextFireAt: NOW + 12e4,
2234
+ state: {
2235
+ window: NOW + 12e4,
2236
+ fires: 1
2237
+ }
2238
+ };
2239
+ expect2(await store.updateSchedule?.("s1", next, NOW + 6e4)).toBe(true);
2240
+ expect2(await store.updateSchedule?.("s1", next, NOW + 6e4)).toBe(false);
2241
+ const after = await store.getSchedule?.("s1");
2242
+ expect2(after?.nextFireAt).toBe(NOW + 12e4);
2243
+ expect2(after?.state).toEqual({
2244
+ window: NOW + 12e4,
2245
+ fires: 1
2246
+ });
2247
+ await store.updateSchedule?.("s1", {
2248
+ nextFireAt: null
2249
+ });
2250
+ expect2(await store.updateSchedule?.("s1", {
2251
+ nextFireAt: NOW
2252
+ }, NOW + 1)).toBe(false);
2253
+ expect2(await store.updateSchedule?.("s1", {
2254
+ nextFireAt: NOW
2255
+ }, null)).toBe(true);
2256
+ expect2(await store.updateSchedule?.("ghost", {
2257
+ paused: true
2258
+ })).toBe(false);
2259
+ });
2260
+ t('lists schedules by namespace/workflow/tag/paused and answers "due by"', async () => {
2261
+ await store.saveSchedule?.(schedule({
2262
+ id: "a",
2263
+ nextFireAt: NOW - 10
2264
+ }));
2265
+ await store.saveSchedule?.(schedule({
2266
+ id: "b",
2267
+ nextFireAt: NOW - 20,
2268
+ namespace: "x"
2269
+ }));
2270
+ await store.saveSchedule?.(schedule({
2271
+ id: "c",
2272
+ nextFireAt: NOW - 30,
2273
+ paused: true
2274
+ }));
2275
+ await store.saveSchedule?.(schedule({
2276
+ id: "d",
2277
+ nextFireAt: NOW + 10,
2278
+ workflow: "other"
2279
+ }));
2280
+ await store.saveSchedule?.(schedule({
2281
+ id: "e",
2282
+ nextFireAt: null,
2283
+ tags: [
2284
+ "tenant:b"
2285
+ ]
2286
+ }));
2287
+ const ids2 = /* @__PURE__ */ __name(async (q) => (await store.listSchedules?.(q) ?? []).map((r) => r.id), "ids");
2288
+ expect2(await ids2({})).toEqual([
2289
+ "c",
2290
+ "b",
2291
+ "a",
2292
+ "d",
2293
+ "e"
2294
+ ]);
2295
+ expect2(await ids2({
2296
+ dueBy: NOW
2297
+ })).toEqual([
2298
+ "b",
2299
+ "a"
2300
+ ]);
2301
+ expect2(await ids2({
2302
+ dueBy: NOW,
2303
+ namespace: "default"
2304
+ })).toEqual([
2305
+ "a"
2306
+ ]);
2307
+ expect2(await ids2({
2308
+ dueBy: NOW,
2309
+ limit: 1
2310
+ })).toEqual([
2311
+ "b"
2312
+ ]);
2313
+ expect2(await ids2({
2314
+ workflow: "other"
2315
+ })).toEqual([
2316
+ "d"
2317
+ ]);
2318
+ expect2(await ids2({
2319
+ paused: true
2320
+ })).toEqual([
2321
+ "c"
2322
+ ]);
2323
+ if (supportsTagFilter) expect2(await ids2({
2324
+ tag: "tenant:b"
2325
+ })).toEqual([
2326
+ "e"
2327
+ ]);
2328
+ expect2(await ids2({
2329
+ limit: 2,
2330
+ offset: 1
2331
+ })).toEqual([
2332
+ "b",
2333
+ "a"
2334
+ ]);
2335
+ });
1455
2336
  t("orders listRuns newest-first and paginates with limit/offset", async () => {
1456
2337
  await store.createRun(run({
1457
2338
  id: "old",
@@ -1751,6 +2632,184 @@ function runStateStoreContract(name, makeStore) {
1751
2632
  expect2((await store.takeSignalWaiter("child:r.child.0"))?.parallelGroup).toBe("gather:0");
1752
2633
  expect2((await store.takeSignalWaiter("child:r.child.9"))?.parallelGroup).toBeUndefined();
1753
2634
  });
2635
+ t("registerChildWaits writes every waiter and placeholder, and upserts on repeat", async () => {
2636
+ expect2(store.registerChildWaits).toBeTypeOf("function");
2637
+ await store.createRun(run({
2638
+ id: "p"
2639
+ }));
2640
+ const entry = /* @__PURE__ */ __name((i, status) => ({
2641
+ waiter: {
2642
+ token: `child:p.all.0.${i}`,
2643
+ runId: "p",
2644
+ seq: i,
2645
+ parallelGroup: "all:0"
2646
+ },
2647
+ checkpoint: checkpoint({
2648
+ runId: "p",
2649
+ seq: i,
2650
+ stepId: `p:${i}`,
2651
+ name: `signal:child:p.all.0.${i}`,
2652
+ kind: "signal",
2653
+ status,
2654
+ parallelGroup: "all:0"
2655
+ })
2656
+ }), "entry");
2657
+ const entries = Array.from({
2658
+ length: 250
2659
+ }, (_, i) => entry(i, "running"));
2660
+ await store.registerChildWaits?.(entries);
2661
+ const waiters = await store.listSignalWaiters("child:p.all.0.");
2662
+ expect2(waiters).toHaveLength(250);
2663
+ expect2(waiters.find((w) => w.token === "child:p.all.0.7")).toEqual(entries[7]?.waiter);
2664
+ const cps = await store.listCheckpoints("p");
2665
+ expect2(cps).toHaveLength(250);
2666
+ expect2(cps[7]).toMatchObject({
2667
+ name: "signal:child:p.all.0.7",
2668
+ status: "running"
2669
+ });
2670
+ expect2(cps[7]?.parallelGroup).toBe("all:0");
2671
+ await store.registerChildWaits?.([
2672
+ entry(7, "completed")
2673
+ ]);
2674
+ expect2((await store.getCheckpoint("p", 7))?.status).toBe("completed");
2675
+ expect2(await store.listCheckpoints("p")).toHaveLength(250);
2676
+ await expect2(store.registerChildWaits?.([])).resolves.toBeUndefined();
2677
+ });
2678
+ t("compactCheckpoints replaces a seq range with one checkpoint, atomically", async () => {
2679
+ expect2(store.compactCheckpoints).toBeTypeOf("function");
2680
+ await store.createRun(run({
2681
+ id: "r"
2682
+ }));
2683
+ await store.createRun(run({
2684
+ id: "other"
2685
+ }));
2686
+ for (let seq = 0; seq < 10; seq += 1) {
2687
+ await store.saveCheckpoint(checkpoint({
2688
+ runId: "r",
2689
+ seq,
2690
+ stepId: `r:${seq}`
2691
+ }));
2692
+ }
2693
+ await store.saveCheckpoint(checkpoint({
2694
+ runId: "other",
2695
+ seq: 3,
2696
+ stepId: "other:3"
2697
+ }));
2698
+ const into = checkpoint({
2699
+ runId: "r",
2700
+ seq: 2,
2701
+ stepId: "r:2",
2702
+ name: "fan:r.all.2.",
2703
+ kind: "signal",
2704
+ output: {
2705
+ from: 0,
2706
+ values: [
2707
+ {
2708
+ n: 1
2709
+ },
2710
+ "two",
2711
+ null
2712
+ ]
2713
+ },
2714
+ parallelGroup: "all:2"
2715
+ });
2716
+ await store.compactCheckpoints?.("r", 2, 7, into);
2717
+ const left = await store.listCheckpoints("r");
2718
+ expect2(left.map((cp) => cp.seq)).toEqual([
2719
+ 0,
2720
+ 1,
2721
+ 2,
2722
+ 8,
2723
+ 9
2724
+ ]);
2725
+ expect2(left[2]).toMatchObject({
2726
+ name: "fan:r.all.2.",
2727
+ parallelGroup: "all:2"
2728
+ });
2729
+ expect2(left[2]?.output).toEqual({
2730
+ from: 0,
2731
+ values: [
2732
+ {
2733
+ n: 1
2734
+ },
2735
+ "two",
2736
+ null
2737
+ ]
2738
+ });
2739
+ expect2(await store.listCheckpoints("other")).toHaveLength(1);
2740
+ });
2741
+ t("listSignalWaitersByRun returns only the given runs' waiters", async () => {
2742
+ expect2(store.listSignalWaitersByRun).toBeTypeOf("function");
2743
+ await store.putSignalWaiter({
2744
+ token: "child:a.all.0.0",
2745
+ runId: "a",
2746
+ seq: 0,
2747
+ parallelGroup: "all:0"
2748
+ });
2749
+ await store.putSignalWaiter({
2750
+ token: "approve-a",
2751
+ runId: "a",
2752
+ seq: 1
2753
+ });
2754
+ await store.putSignalWaiter({
2755
+ token: "approve-b",
2756
+ runId: "b",
2757
+ seq: 0
2758
+ });
2759
+ await store.putSignalWaiter({
2760
+ token: "approve-c",
2761
+ runId: "c",
2762
+ seq: 0
2763
+ });
2764
+ const tokens = /* @__PURE__ */ __name(async (ids2) => (await store.listSignalWaitersByRun?.(ids2) ?? []).map((w) => w.token).sort(), "tokens");
2765
+ expect2(await tokens([
2766
+ "a",
2767
+ "b"
2768
+ ])).toEqual([
2769
+ "approve-a",
2770
+ "approve-b",
2771
+ "child:a.all.0.0"
2772
+ ]);
2773
+ expect2(await tokens([
2774
+ "missing"
2775
+ ])).toEqual([]);
2776
+ expect2(await tokens([])).toEqual([]);
2777
+ expect2((await store.listSignalWaitersByRun?.([
2778
+ "a"
2779
+ ]))?.find((w) => w.seq === 0)).toEqual({
2780
+ token: "child:a.all.0.0",
2781
+ runId: "a",
2782
+ seq: 0,
2783
+ parallelGroup: "all:0"
2784
+ });
2785
+ });
2786
+ t("hasSignalWaiter answers by run and exact token prefix", async () => {
2787
+ expect2(store.hasSignalWaiter).toBeTypeOf("function");
2788
+ const has = /* @__PURE__ */ __name((runId, prefix) => store.hasSignalWaiter?.(runId, prefix), "has");
2789
+ await store.putSignalWaiter({
2790
+ token: "child:p_1.all.3.0",
2791
+ runId: "p_1",
2792
+ seq: 3,
2793
+ parallelGroup: "all:3"
2794
+ });
2795
+ await store.putSignalWaiter({
2796
+ token: "child:p_1.all.4.0.all.0.0",
2797
+ runId: "p_1.all.4.0",
2798
+ seq: 0
2799
+ });
2800
+ await store.putSignalWaiter({
2801
+ token: "child:pX1.all.3.0",
2802
+ runId: "p_1",
2803
+ seq: 9
2804
+ });
2805
+ expect2(await has("p_1", "child:p_1.all.3.")).toBe(true);
2806
+ expect2(await has("p_1", "child:p_1.all.4.")).toBe(false);
2807
+ expect2(await has("other", "child:p_1.all.3.")).toBe(false);
2808
+ expect2(await has("p_1", "child:p%.all.3.")).toBe(false);
2809
+ expect2(await has("p_1", "CHILD:p_1.all.3.")).toBe(false);
2810
+ await store.takeSignalWaiter("child:p_1.all.3.0");
2811
+ expect2(await has("p_1", "child:p_1.all.3.")).toBe(false);
2812
+ });
1754
2813
  t("removeSignalWaiter deletes only the exact (token, runId, seq) row, never a different run's", async () => {
1755
2814
  await store.putSignalWaiter({
1756
2815
  token: "approve-1",