@dudousxd/nestjs-durable-testing 0.19.1 → 0.21.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.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { AdmissionBackend, StateStore, RunStatus, WorkflowEngine, InMemoryStateStore, InMemoryTransport, RunResult, WorkflowRef, StartOptions, WorkflowRun, StepCheckpoint, Transport } from '@dudousxd/nestjs-durable-core';
2
+ export { RUN_HISTORY_FIXTURE_VERSION } from '@dudousxd/nestjs-durable-core';
2
3
 
3
4
  /**
4
5
  * Builds an {@link AdmissionBackend} bound to the given test clock. Implementations that hold their
@@ -184,6 +185,34 @@ interface RunHistory {
184
185
  run: WorkflowRun;
185
186
  checkpoints: StepCheckpoint[];
186
187
  }
188
+
189
+ /**
190
+ * Capture a run's replayable history (the run row + its full checkpoint timeline) from anything with
191
+ * the read API: a `WorkflowEngine`, or a bare `StateStore` (the CLI's `durable export` uses the
192
+ * store). The fixture half of the {@link assertReplayable} loop: capture a representative run,
193
+ * commit it with {@link serializeRunHistory}, and assert it in CI via {@link parseRunHistory}.
194
+ * Returns `null` for an unknown run.
195
+ */
196
+ declare function captureHistory(source: {
197
+ getRun(runId: string): Promise<WorkflowRun | null | undefined>;
198
+ listCheckpoints(runId: string): Promise<StepCheckpoint[]>;
199
+ }, runId: string): Promise<RunHistory | null>;
200
+ /**
201
+ * Serialize a captured history as a pretty-printed JSON fixture (with a trailing newline, so it
202
+ * commits cleanly). Read it back with {@link parseRunHistory}.
203
+ */
204
+ declare function serializeRunHistory(history: RunHistory): string;
205
+ /**
206
+ * Parse a committed fixture (what {@link serializeRunHistory} / `durable export` writes) back into a
207
+ * {@link RunHistory}, reviving the `Date` fields JSON flattened to ISO strings, so
208
+ * `assertReplayable(register, parseRunHistory(readFileSync('fixture.json', 'utf8')))` just works.
209
+ *
210
+ * Throws on a fixture whose `version` is not {@link RUN_HISTORY_FIXTURE_VERSION}: a fixture written
211
+ * in another format would otherwise replay against the wrong shape. A fixture with NO `version` (a
212
+ * hand-built `{ run, checkpoints }`, the shape these docs showed before the marker existed) is read
213
+ * as version 1.
214
+ */
215
+ declare function parseRunHistory(json: string): RunHistory;
187
216
  /**
188
217
  * Replay a recorded run's history against the CURRENT workflow code and throw if they diverged.
189
218
  *
@@ -288,4 +317,4 @@ declare function aggregationFixture(): StepCheckpoint[];
288
317
  */
289
318
  declare function registerCheckpointAggregationCases(t: (label: string, fn: () => Promise<void>) => void, getStore: () => StateStore, run: (over?: Partial<WorkflowRun>) => WorkflowRun): void;
290
319
 
291
- export { type AdmissionBackendFactory, DURABLE_CANONICAL_COLUMNS, type DurableColumnMap, type DurableColumnMismatch, type HandleableTransport, MutableClock, type RunHistory, type StateStoreContext, type StateStoreFactory, StateStoreUnavailableError, type TestEngine, aggregationFixture, assertDurableColumns, assertOutput, assertReplayable, assertRunStatus, assertStepAttempts, assertStepsRan, assertTransportConformance, createTestEngine, failOnce, failTimes, preserveColumnExpectation, recordedSteps, registerCheckpointAggregationCases, runAdmissionBackendContract, runStateStoreContract };
320
+ export { type AdmissionBackendFactory, DURABLE_CANONICAL_COLUMNS, type DurableColumnMap, type DurableColumnMismatch, type HandleableTransport, MutableClock, type RunHistory, type StateStoreContext, type StateStoreFactory, StateStoreUnavailableError, type TestEngine, aggregationFixture, assertDurableColumns, assertOutput, assertReplayable, assertRunStatus, assertStepAttempts, assertStepsRan, assertTransportConformance, captureHistory, createTestEngine, failOnce, failTimes, parseRunHistory, preserveColumnExpectation, recordedSteps, registerCheckpointAggregationCases, runAdmissionBackendContract, runStateStoreContract, serializeRunHistory };
package/dist/index.js CHANGED
@@ -375,7 +375,50 @@ function createTestEngine() {
375
375
  __name(createTestEngine, "createTestEngine");
376
376
 
377
377
  // src/replay.ts
378
- import { InMemoryStateStore as InMemoryStateStore2, WorkflowEngine as WorkflowEngine2 } from "@dudousxd/nestjs-durable-core";
378
+ import { InMemoryStateStore as InMemoryStateStore2, RUN_HISTORY_FIXTURE_VERSION, WorkflowEngine as WorkflowEngine2 } from "@dudousxd/nestjs-durable-core";
379
+ async function captureHistory(source, runId) {
380
+ const run2 = await source.getRun(runId);
381
+ if (!run2) return null;
382
+ return {
383
+ run: run2,
384
+ checkpoints: await source.listCheckpoints(runId)
385
+ };
386
+ }
387
+ __name(captureHistory, "captureHistory");
388
+ function serializeRunHistory(history) {
389
+ return `${JSON.stringify({
390
+ version: RUN_HISTORY_FIXTURE_VERSION,
391
+ ...history
392
+ }, null, 2)}
393
+ `;
394
+ }
395
+ __name(serializeRunHistory, "serializeRunHistory");
396
+ function parseRunHistory(json) {
397
+ const raw = JSON.parse(json);
398
+ if (!raw || typeof raw !== "object" || !raw.run || !Array.isArray(raw.checkpoints)) {
399
+ throw new Error("not a run history fixture: expected { run, checkpoints }");
400
+ }
401
+ if (raw.version !== void 0 && raw.version !== RUN_HISTORY_FIXTURE_VERSION) {
402
+ throw new Error(`unsupported run history fixture version ${JSON.stringify(raw.version)} (this kit reads version ${RUN_HISTORY_FIXTURE_VERSION}); re-export the fixture or upgrade @dudousxd/nestjs-durable-testing`);
403
+ }
404
+ const date = /* @__PURE__ */ __name((v) => new Date(v), "date");
405
+ const run2 = {
406
+ ...raw.run,
407
+ createdAt: date(raw.run.createdAt),
408
+ updatedAt: date(raw.run.updatedAt)
409
+ };
410
+ const checkpoints = raw.checkpoints.map((cp2) => ({
411
+ ...cp2,
412
+ enqueuedAt: date(cp2.enqueuedAt),
413
+ startedAt: date(cp2.startedAt),
414
+ finishedAt: date(cp2.finishedAt)
415
+ }));
416
+ return {
417
+ run: run2,
418
+ checkpoints
419
+ };
420
+ }
421
+ __name(parseRunHistory, "parseRunHistory");
379
422
  async function assertReplayable(register, history) {
380
423
  const store = new InMemoryStateStore2();
381
424
  await store.createRun({
@@ -399,7 +442,7 @@ async function assertReplayable(register, history) {
399
442
  __name(assertReplayable, "assertReplayable");
400
443
 
401
444
  // src/state-store-conformance.ts
402
- import { SINGLETON_ADMITTED_TAG, WorkflowEngine as WorkflowEngine3 } from "@dudousxd/nestjs-durable-core";
445
+ import { SINGLETON_ADMITTED_TAG, SINGLETON_TRACKED_TAG, WorkflowEngine as WorkflowEngine3 } from "@dudousxd/nestjs-durable-core";
403
446
  import { beforeEach, describe as describe2, expect as expect3, it as it2 } from "vitest";
404
447
 
405
448
  // src/checkpoint-aggregation-contract.ts
@@ -932,7 +975,8 @@ function runStateStoreContract(name, makeStore) {
932
975
  await store.createRun(run({
933
976
  id,
934
977
  tags: [
935
- "singleton:k"
978
+ "singleton:k",
979
+ SINGLETON_TRACKED_TAG
936
980
  ]
937
981
  }));
938
982
  }
@@ -1021,6 +1065,134 @@ function runStateStoreContract(name, makeStore) {
1021
1065
  }));
1022
1066
  expect3(await claim("other-workflow", "singleton:k", "other")).toBe(true);
1023
1067
  });
1068
+ const created = /* @__PURE__ */ __name((offset) => new Date(at.getTime() + offset * 1e3), "created");
1069
+ t("singleton admission counts an untagged pre-0.73 holder (rolling upgrade)", async () => {
1070
+ const claim = /* @__PURE__ */ __name((id, wakeAt) => store.tryAdmitSingleton?.(id, "singleton:k", "checkout", 1, wakeAt), "claim");
1071
+ await store.createRun(run({
1072
+ id: "legacy",
1073
+ tags: [
1074
+ "singleton:k"
1075
+ ],
1076
+ createdAt: created(1)
1077
+ }));
1078
+ await store.createRun(run({
1079
+ id: "new",
1080
+ tags: [
1081
+ "singleton:k",
1082
+ SINGLETON_TRACKED_TAG
1083
+ ],
1084
+ createdAt: created(0)
1085
+ }));
1086
+ expect3(await claim("new", 12e3)).toBe(false);
1087
+ expect3(await store.getRun("new")).toMatchObject({
1088
+ status: "suspended",
1089
+ wakeAt: 12e3
1090
+ });
1091
+ expect3((await store.getRun("new"))?.tags).toContain(SINGLETON_TRACKED_TAG);
1092
+ await store.updateRun("legacy", {
1093
+ status: "suspended",
1094
+ wakeAt: 2e4
1095
+ });
1096
+ expect3(await claim("new")).toBe(false);
1097
+ await store.updateRun("legacy", {
1098
+ status: "completed"
1099
+ });
1100
+ expect3(await claim("new")).toBe(true);
1101
+ expect3((await store.getRun("new"))?.tags).toEqual([
1102
+ "singleton:k",
1103
+ SINGLETON_ADMITTED_TAG
1104
+ ]);
1105
+ await store.updateRun("new", {
1106
+ status: "completed"
1107
+ });
1108
+ expect3((await store.getRun("new"))?.tags).toEqual([
1109
+ "singleton:k"
1110
+ ]);
1111
+ });
1112
+ t("legacy singleton waiters keep their FIFO place across the upgrade", async () => {
1113
+ const claim = /* @__PURE__ */ __name((id) => store.tryAdmitSingleton?.(id, "singleton:k", "checkout", 1), "claim");
1114
+ await store.createRun(run({
1115
+ id: "holder",
1116
+ tags: [
1117
+ "singleton:k"
1118
+ ],
1119
+ createdAt: created(0)
1120
+ }));
1121
+ await store.createRun(run({
1122
+ id: "old-waiter",
1123
+ status: "suspended",
1124
+ wakeAt: 5e3,
1125
+ tags: [
1126
+ "singleton:k"
1127
+ ],
1128
+ createdAt: created(1)
1129
+ }));
1130
+ await store.createRun(run({
1131
+ id: "new-run",
1132
+ tags: [
1133
+ "singleton:k",
1134
+ SINGLETON_TRACKED_TAG
1135
+ ],
1136
+ createdAt: created(2)
1137
+ }));
1138
+ expect3(await claim("new-run")).toBe(false);
1139
+ expect3(await claim("old-waiter")).toBe(false);
1140
+ expect3(await store.tryAdmitSingleton?.("old-waiter", "singleton:k", "checkout", 1, 6e3)).toBe(false);
1141
+ expect3((await store.getRun("old-waiter"))?.tags).toContain(SINGLETON_TRACKED_TAG);
1142
+ await store.updateRun("holder", {
1143
+ status: "completed"
1144
+ });
1145
+ expect3(await claim("new-run")).toBe(false);
1146
+ expect3(await claim("old-waiter")).toBe(true);
1147
+ expect3(await claim("new-run")).toBe(false);
1148
+ await store.updateRun("old-waiter", {
1149
+ status: "completed"
1150
+ });
1151
+ expect3(await claim("new-run")).toBe(true);
1152
+ });
1153
+ t("concurrent claims never over-admit beside an untagged legacy holder", async () => {
1154
+ await store.createRun(run({
1155
+ id: "legacy",
1156
+ tags: [
1157
+ "singleton:k"
1158
+ ],
1159
+ createdAt: created(0)
1160
+ }));
1161
+ const ids2 = [
1162
+ "c1",
1163
+ "c2",
1164
+ "c3",
1165
+ "c4"
1166
+ ];
1167
+ for (const [i, id] of ids2.entries()) {
1168
+ await store.createRun(run({
1169
+ id,
1170
+ tags: [
1171
+ "singleton:k",
1172
+ SINGLETON_TRACKED_TAG
1173
+ ],
1174
+ createdAt: created(i + 1)
1175
+ }));
1176
+ }
1177
+ const results = await Promise.all([
1178
+ ...ids2
1179
+ ].reverse().map((id, i) => (i % 2 ? peerStore : store).tryAdmitSingleton?.(id, "singleton:k", "checkout", 2)));
1180
+ expect3(results).toEqual([
1181
+ false,
1182
+ false,
1183
+ false,
1184
+ true
1185
+ ]);
1186
+ const active = await store.listRuns({
1187
+ workflow: "checkout",
1188
+ statuses: [
1189
+ "running"
1190
+ ]
1191
+ });
1192
+ expect3(active.filter((r) => r.tags?.includes(SINGLETON_ADMITTED_TAG)).map((r) => r.id)).toEqual([
1193
+ "c1"
1194
+ ]);
1195
+ });
1024
1196
  t("creates a run and reads it back with its JSON input", async () => {
1025
1197
  await store.createRun(run());
1026
1198
  const loaded = await store.getRun("r1");
@@ -1501,40 +1673,40 @@ function runStateStoreContract(name, makeStore) {
1501
1673
  t("listOrphanedRuns returns incomplete runs with a free or expired lease, oldest first", async () => {
1502
1674
  expect3(store.listOrphanedRuns).toBeDefined();
1503
1675
  if (!store.listOrphanedRuns) return;
1504
- const created = /* @__PURE__ */ __name((offset) => new Date(at.getTime() + offset * 1e3), "created");
1676
+ const created2 = /* @__PURE__ */ __name((offset) => new Date(at.getTime() + offset * 1e3), "created");
1505
1677
  await store.createRun(run({
1506
1678
  id: "held",
1507
- createdAt: created(1)
1679
+ createdAt: created2(1)
1508
1680
  }));
1509
1681
  expect3(await store.tryLockRun("held", "live", NOW + 3e4, NOW)).toBe(true);
1510
1682
  await store.createRun(run({
1511
1683
  id: "expired",
1512
- createdAt: created(3)
1684
+ createdAt: created2(3)
1513
1685
  }));
1514
1686
  expect3(await store.tryLockRun("expired", "dead", NOW, NOW - 1)).toBe(true);
1515
1687
  await store.createRun(run({
1516
1688
  id: "free",
1517
- createdAt: created(2)
1689
+ createdAt: created2(2)
1518
1690
  }));
1519
1691
  await store.createRun(run({
1520
1692
  id: "compensating",
1521
1693
  status: "cancelling",
1522
- createdAt: created(4)
1694
+ createdAt: created2(4)
1523
1695
  }));
1524
1696
  await store.createRun(run({
1525
1697
  id: "parked",
1526
1698
  status: "suspended",
1527
- createdAt: created(0)
1699
+ createdAt: created2(0)
1528
1700
  }));
1529
1701
  await store.createRun(run({
1530
1702
  id: "done",
1531
1703
  status: "completed",
1532
- createdAt: created(0)
1704
+ createdAt: created2(0)
1533
1705
  }));
1534
1706
  await store.createRun(run({
1535
1707
  id: "tenant",
1536
1708
  namespace: "beta",
1537
- createdAt: created(5)
1709
+ createdAt: created2(5)
1538
1710
  }));
1539
1711
  expect3((await store.listOrphanedRuns(NOW, 10)).map((r) => r.id)).toEqual([
1540
1712
  "free",
@@ -1552,6 +1724,50 @@ function runStateStoreContract(name, makeStore) {
1552
1724
  expect3((await store.listOrphanedRuns(NOW + 3e4, 10)).map((r) => r.id)).toContain("held");
1553
1725
  expect3(await store.listOrphanedRuns(NOW, 0)).toEqual([]);
1554
1726
  });
1727
+ t("listOrphanedRuns also returns the owner's live leases taken before its cutoff", async () => {
1728
+ if (!store.listOrphanedRuns) return;
1729
+ const created2 = /* @__PURE__ */ __name((offset) => new Date(at.getTime() + offset * 1e3), "created");
1730
+ await store.createRun(run({
1731
+ id: "own-old",
1732
+ createdAt: created2(1)
1733
+ }));
1734
+ expect3(await store.tryLockRun("own-old", "pod-a", NOW + 1e4, NOW)).toBe(true);
1735
+ await store.createRun(run({
1736
+ id: "own-new",
1737
+ createdAt: created2(2)
1738
+ }));
1739
+ expect3(await store.tryLockRun("own-new", "pod-a", NOW + 3e4, NOW)).toBe(true);
1740
+ await store.createRun(run({
1741
+ id: "foreign",
1742
+ createdAt: created2(3)
1743
+ }));
1744
+ expect3(await store.tryLockRun("foreign", "pod-b", NOW + 1e4, NOW)).toBe(true);
1745
+ await store.createRun(run({
1746
+ id: "own-parked",
1747
+ status: "suspended",
1748
+ createdAt: created2(4)
1749
+ }));
1750
+ expect3(await store.tryLockRun("own-parked", "pod-a", NOW + 1e4, NOW)).toBe(true);
1751
+ await store.createRun(run({
1752
+ id: "free",
1753
+ createdAt: created2(5)
1754
+ }));
1755
+ const ownedBy = {
1756
+ owner: "pod-a",
1757
+ lockedBefore: NOW + 3e4
1758
+ };
1759
+ expect3((await store.listOrphanedRuns(NOW, 10)).map((r) => r.id)).toEqual([
1760
+ "free"
1761
+ ]);
1762
+ expect3((await store.listOrphanedRuns(NOW, 10, void 0, ownedBy)).map((r) => r.id)).toEqual([
1763
+ "own-old",
1764
+ "free"
1765
+ ]);
1766
+ expect3((await store.listOrphanedRuns(NOW, 1, void 0, ownedBy)).map((r) => r.id)).toEqual([
1767
+ "own-old"
1768
+ ]);
1769
+ expect3((await store.listOrphanedRuns(NOW, 10, "beta", ownedBy)).map((r) => r.id)).toEqual([]);
1770
+ });
1555
1771
  t("updateRunIf writes only over an expected status", async () => {
1556
1772
  expect3(store.updateRunIf).toBeDefined();
1557
1773
  if (!store.updateRunIf) return;
@@ -2141,6 +2357,18 @@ function runStateStoreContract(name, makeStore) {
2141
2357
  await store.releaseRunLock("r1");
2142
2358
  expect3(await store.tryLockRun("r1", "C", 9e3, 2600)).toBe(true);
2143
2359
  });
2360
+ t("releaseRunLock with an owner clears only a lease that owner still holds", async () => {
2361
+ await store.createRun(run({
2362
+ id: "r1"
2363
+ }));
2364
+ expect3(await store.tryLockRun("r1", "A", 2e3, 1e3)).toBe(true);
2365
+ expect3(await store.tryLockRun("r1", "B", 4e3, 2500)).toBe(true);
2366
+ await store.releaseRunLock("r1", "A");
2367
+ expect3(await store.tryLockRun("r1", "C", 9e3, 3e3)).toBe(false);
2368
+ expect3((await store.getRun("r1"))?.lockedBy).toBe("B");
2369
+ await store.releaseRunLock("r1", "B");
2370
+ expect3(await store.tryLockRun("r1", "C", 9e3, 3e3)).toBe(true);
2371
+ });
2144
2372
  t("renewRunLock only succeeds for the current owner", async () => {
2145
2373
  await store.createRun(run({
2146
2374
  id: "r1"
@@ -3894,6 +4122,77 @@ function runStateStoreContract(name, makeStore) {
3894
4122
  })).rejects.toThrow(/vwf@9 is not registered/);
3895
4123
  expect3(await store.getRun("ghost-run")).toBeNull();
3896
4124
  });
4125
+ t("times out every unfinished run of its workflow and namespace, except one compensating", async () => {
4126
+ const engine = new WorkflowEngine3({
4127
+ store,
4128
+ namespace: "alpha"
4129
+ });
4130
+ engine.register("t-capped", "1", async () => void 0, {
4131
+ executionTimeout: "1h"
4132
+ });
4133
+ const old = {
4134
+ workflow: "t-capped",
4135
+ namespace: "alpha",
4136
+ createdAt: /* @__PURE__ */ new Date(1e3)
4137
+ };
4138
+ await store.createRun(run({
4139
+ ...old,
4140
+ id: "tc-pending",
4141
+ status: "pending"
4142
+ }));
4143
+ await store.createRun(run({
4144
+ ...old,
4145
+ id: "tc-running",
4146
+ status: "running"
4147
+ }));
4148
+ await store.createRun(run({
4149
+ ...old,
4150
+ id: "tc-suspended",
4151
+ status: "suspended"
4152
+ }));
4153
+ await store.createRun(run({
4154
+ ...old,
4155
+ id: "tc-blocked",
4156
+ status: "blocked"
4157
+ }));
4158
+ await store.createRun(run({
4159
+ ...old,
4160
+ id: "tc-cancelling",
4161
+ status: "cancelling"
4162
+ }));
4163
+ await store.createRun(run({
4164
+ ...old,
4165
+ id: "tc-done",
4166
+ status: "completed"
4167
+ }));
4168
+ await store.createRun(run({
4169
+ ...old,
4170
+ id: "tc-other-tenant",
4171
+ status: "pending",
4172
+ namespace: "beta"
4173
+ }));
4174
+ await store.createRun(run({
4175
+ ...old,
4176
+ id: "tc-other-workflow",
4177
+ status: "pending",
4178
+ workflow: "t-uncapped"
4179
+ }));
4180
+ await engine.sweepTimeouts(1e3 + 37e5);
4181
+ for (const id of [
4182
+ "tc-pending",
4183
+ "tc-running",
4184
+ "tc-suspended",
4185
+ "tc-blocked"
4186
+ ]) {
4187
+ const swept = await store.getRun(id);
4188
+ expect3(swept?.status, id).toBe("cancelled");
4189
+ expect3(swept?.error?.code, id).toBe("execution_timeout");
4190
+ }
4191
+ expect3((await store.getRun("tc-cancelling"))?.status).toBe("cancelling");
4192
+ expect3((await store.getRun("tc-done"))?.status).toBe("completed");
4193
+ expect3((await store.getRun("tc-other-tenant"))?.status).toBe("pending");
4194
+ expect3((await store.getRun("tc-other-workflow"))?.status).toBe("pending");
4195
+ });
3897
4196
  t("cancels a timed-out run\u2019s live child subtree, not just the run itself", async () => {
3898
4197
  const engine = new WorkflowEngine3({
3899
4198
  store
@@ -3995,6 +4294,7 @@ __name(assertTransportConformance, "assertTransportConformance");
3995
4294
  export {
3996
4295
  DURABLE_CANONICAL_COLUMNS,
3997
4296
  MutableClock,
4297
+ RUN_HISTORY_FIXTURE_VERSION,
3998
4298
  StateStoreUnavailableError,
3999
4299
  aggregationFixture,
4000
4300
  assertDurableColumns,
@@ -4004,13 +4304,16 @@ export {
4004
4304
  assertStepAttempts,
4005
4305
  assertStepsRan,
4006
4306
  assertTransportConformance,
4307
+ captureHistory,
4007
4308
  createTestEngine,
4008
4309
  failOnce,
4009
4310
  failTimes,
4311
+ parseRunHistory,
4010
4312
  preserveColumnExpectation,
4011
4313
  recordedSteps,
4012
4314
  registerCheckpointAggregationCases,
4013
4315
  runAdmissionBackendContract,
4014
- runStateStoreContract
4316
+ runStateStoreContract,
4317
+ serializeRunHistory
4015
4318
  };
4016
4319
  //# sourceMappingURL=index.js.map