openworkflow 0.3.0 → 0.4.1

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.
Files changed (131) hide show
  1. package/README.md +54 -18
  2. package/dist/backend-sqlite/backend.d.ts +38 -0
  3. package/dist/backend-sqlite/backend.d.ts.map +1 -0
  4. package/dist/backend-sqlite/backend.js +628 -0
  5. package/dist/backend-sqlite/backend.js.map +1 -0
  6. package/dist/backend-sqlite/index.d.ts +2 -0
  7. package/dist/backend-sqlite/index.d.ts.map +1 -0
  8. package/dist/backend-sqlite/index.js +2 -0
  9. package/dist/backend-sqlite/index.js.map +1 -0
  10. package/dist/backend-sqlite/sqlite.d.ts +45 -0
  11. package/dist/backend-sqlite/sqlite.d.ts.map +1 -0
  12. package/dist/backend-sqlite/sqlite.js +229 -0
  13. package/dist/backend-sqlite/sqlite.js.map +1 -0
  14. package/dist/config/config.d.ts +102 -0
  15. package/dist/config/config.d.ts.map +1 -0
  16. package/dist/config/config.js +29 -0
  17. package/dist/config/config.js.map +1 -0
  18. package/dist/config/index.d.ts +3 -0
  19. package/dist/config/index.d.ts.map +1 -0
  20. package/dist/config/index.js +2 -0
  21. package/dist/config/index.js.map +1 -0
  22. package/dist/config.d.ts +28 -0
  23. package/dist/config.d.ts.map +1 -0
  24. package/dist/config.js +41 -0
  25. package/dist/config.js.map +1 -0
  26. package/dist/core/backend-test-suite.d.ts +22 -0
  27. package/dist/core/backend-test-suite.d.ts.map +1 -0
  28. package/dist/core/backend-test-suite.js +960 -0
  29. package/dist/core/backend-test-suite.js.map +1 -0
  30. package/dist/core/backend.d.ts +103 -0
  31. package/dist/core/backend.d.ts.map +1 -0
  32. package/dist/core/backend.js +2 -0
  33. package/dist/core/backend.js.map +1 -0
  34. package/dist/core/backend.testsuite.d.ts +21 -0
  35. package/dist/core/backend.testsuite.d.ts.map +1 -0
  36. package/dist/core/backend.testsuite.js +958 -0
  37. package/dist/core/backend.testsuite.js.map +1 -0
  38. package/dist/{duration.d.ts → core/duration.d.ts} +2 -1
  39. package/dist/core/duration.d.ts.map +1 -0
  40. package/dist/{duration.js → core/duration.js} +6 -5
  41. package/dist/core/duration.js.map +1 -0
  42. package/dist/core/duration.test.d.ts +2 -0
  43. package/dist/core/duration.test.d.ts.map +1 -0
  44. package/dist/core/duration.test.js +264 -0
  45. package/dist/core/duration.test.js.map +1 -0
  46. package/dist/core/json.d.ts +5 -0
  47. package/dist/core/json.d.ts.map +1 -0
  48. package/dist/core/json.js +2 -0
  49. package/dist/core/json.js.map +1 -0
  50. package/dist/core/result.d.ts +12 -0
  51. package/dist/core/result.d.ts.map +1 -0
  52. package/dist/core/result.js +7 -0
  53. package/dist/core/result.js.map +1 -0
  54. package/dist/core/result.test.d.ts +2 -0
  55. package/dist/core/result.test.d.ts.map +1 -0
  56. package/dist/core/result.test.js +11 -0
  57. package/dist/core/result.test.js.map +1 -0
  58. package/dist/core/retry.d.ts +16 -0
  59. package/dist/core/retry.d.ts.map +1 -0
  60. package/dist/{backend.js → core/retry.js} +1 -3
  61. package/dist/core/retry.js.map +1 -0
  62. package/dist/core/retry.test.d.ts +2 -0
  63. package/dist/core/retry.test.d.ts.map +1 -0
  64. package/dist/core/retry.test.js +36 -0
  65. package/dist/core/retry.test.js.map +1 -0
  66. package/dist/core/schema.d.ts +57 -0
  67. package/dist/core/schema.d.ts.map +1 -0
  68. package/dist/core/schema.js +2 -0
  69. package/dist/core/schema.js.map +1 -0
  70. package/dist/core/step.d.ts +120 -0
  71. package/dist/core/step.d.ts.map +1 -0
  72. package/dist/core/step.js +101 -0
  73. package/dist/core/step.js.map +1 -0
  74. package/dist/core/step.test.d.ts +2 -0
  75. package/dist/core/step.test.d.ts.map +1 -0
  76. package/dist/core/step.test.js +340 -0
  77. package/dist/core/step.test.js.map +1 -0
  78. package/dist/core/workflow.d.ts +108 -0
  79. package/dist/core/workflow.d.ts.map +1 -0
  80. package/dist/core/workflow.js +79 -0
  81. package/dist/core/workflow.js.map +1 -0
  82. package/dist/core/workflow.test.d.ts +2 -0
  83. package/dist/core/workflow.test.d.ts.map +1 -0
  84. package/dist/core/workflow.test.js +216 -0
  85. package/dist/core/workflow.test.js.map +1 -0
  86. package/dist/execution/execution.d.ts +91 -0
  87. package/dist/execution/execution.d.ts.map +1 -0
  88. package/dist/execution/execution.js +188 -0
  89. package/dist/execution/execution.js.map +1 -0
  90. package/dist/execution/execution.test.d.ts +2 -0
  91. package/dist/execution/execution.test.d.ts.map +1 -0
  92. package/dist/execution/execution.test.js +382 -0
  93. package/dist/execution/execution.test.js.map +1 -0
  94. package/dist/global.d.ts +62 -0
  95. package/dist/global.d.ts.map +1 -0
  96. package/dist/global.js +78 -0
  97. package/dist/global.js.map +1 -0
  98. package/dist/index.d.ts +9 -5
  99. package/dist/index.d.ts.map +1 -1
  100. package/dist/index.js +4 -3
  101. package/dist/index.js.map +1 -1
  102. package/dist/sdk/sdk.d.ts +182 -0
  103. package/dist/sdk/sdk.d.ts.map +1 -0
  104. package/dist/sdk/sdk.js +208 -0
  105. package/dist/sdk/sdk.js.map +1 -0
  106. package/dist/sdk/sdk.test.d.ts +2 -0
  107. package/dist/sdk/sdk.test.d.ts.map +1 -0
  108. package/dist/sdk/sdk.test.js +195 -0
  109. package/dist/sdk/sdk.test.js.map +1 -0
  110. package/dist/tsconfig.tsbuildinfo +1 -1
  111. package/dist/{worker.d.ts → worker/worker.d.ts} +4 -4
  112. package/dist/worker/worker.d.ts.map +1 -0
  113. package/dist/worker/worker.js +208 -0
  114. package/dist/worker/worker.js.map +1 -0
  115. package/dist/worker/worker.test.d.ts +2 -0
  116. package/dist/worker/worker.test.d.ts.map +1 -0
  117. package/dist/worker/worker.test.js +786 -0
  118. package/dist/worker/worker.test.js.map +1 -0
  119. package/package.json +9 -3
  120. package/dist/backend.d.ts +0 -159
  121. package/dist/backend.d.ts.map +0 -1
  122. package/dist/backend.js.map +0 -1
  123. package/dist/client.d.ts +0 -141
  124. package/dist/client.d.ts.map +0 -1
  125. package/dist/client.js +0 -135
  126. package/dist/client.js.map +0 -1
  127. package/dist/duration.d.ts.map +0 -1
  128. package/dist/duration.js.map +0 -1
  129. package/dist/worker.d.ts.map +0 -1
  130. package/dist/worker.js +0 -375
  131. package/dist/worker.js.map +0 -1
@@ -0,0 +1,958 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
3
+ /**
4
+ * Runs the Backend test suite.
5
+ *
6
+ * This function wraps all the tests that verify a Backend implementation
7
+ * conforms to the Backend interface contract. It uses the setup function to
8
+ * create backend instances and the teardown function to clean them up.
9
+ */
10
+ export function testBackend(options) {
11
+ const { setup, teardown } = options;
12
+ describe("Backend", () => {
13
+ let backend;
14
+ beforeAll(async () => {
15
+ backend = await setup();
16
+ });
17
+ afterAll(async () => {
18
+ await teardown(backend);
19
+ });
20
+ describe("createWorkflowRun()", () => {
21
+ test("creates a workflow run", async () => {
22
+ const expected = {
23
+ namespaceId: "", // -
24
+ id: "", // -
25
+ workflowName: randomUUID(),
26
+ version: randomUUID(),
27
+ status: "pending",
28
+ idempotencyKey: randomUUID(),
29
+ config: { key: "val" },
30
+ context: { key: "val" },
31
+ input: { key: "val" },
32
+ output: null,
33
+ error: null,
34
+ attempts: 0,
35
+ parentStepAttemptNamespaceId: null,
36
+ parentStepAttemptId: null,
37
+ workerId: null,
38
+ availableAt: newDateInOneYear(), // -
39
+ deadlineAt: newDateInOneYear(),
40
+ startedAt: null,
41
+ finishedAt: null,
42
+ createdAt: new Date(), // -
43
+ updatedAt: new Date(), // -
44
+ };
45
+ // Create with all fields
46
+ const created = await backend.createWorkflowRun({
47
+ workflowName: expected.workflowName,
48
+ version: expected.version,
49
+ idempotencyKey: expected.idempotencyKey,
50
+ input: expected.input,
51
+ config: expected.config,
52
+ context: expected.context,
53
+ availableAt: expected.availableAt,
54
+ deadlineAt: expected.deadlineAt,
55
+ });
56
+ expect(created.namespaceId).toHaveLength(36);
57
+ expect(created.id).toHaveLength(36);
58
+ expect(deltaSeconds(created.availableAt)).toBeGreaterThan(1);
59
+ expect(deltaSeconds(created.createdAt)).toBeLessThan(1);
60
+ expect(deltaSeconds(created.updatedAt)).toBeLessThan(1);
61
+ expected.namespaceId = created.namespaceId;
62
+ expected.id = created.id;
63
+ expected.availableAt = created.availableAt;
64
+ expected.createdAt = created.createdAt;
65
+ expected.updatedAt = created.updatedAt;
66
+ expect(created).toEqual(expected);
67
+ // Create with minimal fields
68
+ const createdMin = await backend.createWorkflowRun({
69
+ workflowName: expected.workflowName,
70
+ version: null,
71
+ idempotencyKey: null,
72
+ input: null,
73
+ config: {},
74
+ context: null,
75
+ availableAt: null,
76
+ deadlineAt: null,
77
+ });
78
+ expect(createdMin.version).toBeNull();
79
+ expect(createdMin.idempotencyKey).toBeNull();
80
+ expect(createdMin.input).toBeNull();
81
+ expect(createdMin.context).toBeNull();
82
+ expect(deltaSeconds(createdMin.availableAt)).toBeLessThan(1); // defaults to NOW()
83
+ expect(createdMin.deadlineAt).toBeNull();
84
+ });
85
+ });
86
+ describe("listWorkflowRuns()", () => {
87
+ test("lists workflow runs ordered by creation time", async () => {
88
+ const backend = await setup();
89
+ const first = await createPendingWorkflowRun(backend);
90
+ await sleep(10); // ensure timestamp difference
91
+ const second = await createPendingWorkflowRun(backend);
92
+ const listed = await backend.listWorkflowRuns({});
93
+ expect(listed.data.map((run) => run.id)).toEqual([first.id, second.id]);
94
+ await teardown(backend);
95
+ });
96
+ test("paginates workflow runs", async () => {
97
+ const backend = await setup();
98
+ const runs = [];
99
+ for (let i = 0; i < 5; i++) {
100
+ runs.push(await createPendingWorkflowRun(backend));
101
+ await sleep(10);
102
+ }
103
+ // p1
104
+ const page1 = await backend.listWorkflowRuns({ limit: 2 });
105
+ expect(page1.data).toHaveLength(2);
106
+ expect(page1.data[0]?.id).toBe(runs[0]?.id);
107
+ expect(page1.data[1]?.id).toBe(runs[1]?.id);
108
+ expect(page1.pagination.next).not.toBeNull();
109
+ expect(page1.pagination.prev).toBeNull();
110
+ // p2
111
+ const page2 = await backend.listWorkflowRuns({
112
+ limit: 2,
113
+ after: page1.pagination.next, // eslint-disable-line @typescript-eslint/no-non-null-assertion
114
+ });
115
+ expect(page2.data).toHaveLength(2);
116
+ expect(page2.data[0]?.id).toBe(runs[2]?.id);
117
+ expect(page2.data[1]?.id).toBe(runs[3]?.id);
118
+ expect(page2.pagination.next).not.toBeNull();
119
+ expect(page2.pagination.prev).not.toBeNull();
120
+ // p3
121
+ const page3 = await backend.listWorkflowRuns({
122
+ limit: 2,
123
+ after: page2.pagination.next, // eslint-disable-line @typescript-eslint/no-non-null-assertion
124
+ });
125
+ expect(page3.data).toHaveLength(1);
126
+ expect(page3.data[0]?.id).toBe(runs[4]?.id);
127
+ expect(page3.pagination.next).toBeNull();
128
+ expect(page3.pagination.prev).not.toBeNull();
129
+ // p2 again
130
+ const page2Back = await backend.listWorkflowRuns({
131
+ limit: 2,
132
+ before: page3.pagination.prev, // eslint-disable-line @typescript-eslint/no-non-null-assertion
133
+ });
134
+ expect(page2Back.data).toHaveLength(2);
135
+ expect(page2Back.data[0]?.id).toBe(runs[2]?.id);
136
+ expect(page2Back.data[1]?.id).toBe(runs[3]?.id);
137
+ expect(page2Back.pagination.next).toEqual(page2.pagination.next);
138
+ expect(page2Back.pagination.prev).toEqual(page2.pagination.prev);
139
+ await teardown(backend);
140
+ });
141
+ test("handles empty results", async () => {
142
+ const backend = await setup();
143
+ const listed = await backend.listWorkflowRuns({});
144
+ expect(listed.data).toHaveLength(0);
145
+ expect(listed.pagination.next).toBeNull();
146
+ expect(listed.pagination.prev).toBeNull();
147
+ await teardown(backend);
148
+ });
149
+ });
150
+ describe("claimWorkflowRun()", () => {
151
+ // because claims involve timing and leases, we create and teardown a new
152
+ // namespaced backend instance for each test
153
+ test("claims workflow runs and respects leases, reclaiming if lease expires", async () => {
154
+ const backend = await setup();
155
+ await createPendingWorkflowRun(backend);
156
+ const firstLeaseMs = 30;
157
+ const firstWorker = randomUUID();
158
+ const claimed = await backend.claimWorkflowRun({
159
+ workerId: firstWorker,
160
+ leaseDurationMs: firstLeaseMs,
161
+ });
162
+ expect(claimed?.status).toBe("running");
163
+ expect(claimed?.workerId).toBe(firstWorker);
164
+ expect(claimed?.attempts).toBe(1);
165
+ expect(claimed?.startedAt).not.toBeNull();
166
+ const secondWorker = randomUUID();
167
+ const blocked = await backend.claimWorkflowRun({
168
+ workerId: secondWorker,
169
+ leaseDurationMs: 10,
170
+ });
171
+ expect(blocked).toBeNull();
172
+ await sleep(firstLeaseMs);
173
+ const reclaimed = await backend.claimWorkflowRun({
174
+ workerId: secondWorker,
175
+ leaseDurationMs: 10,
176
+ });
177
+ expect(reclaimed?.id).toBe(claimed?.id);
178
+ expect(reclaimed?.attempts).toBe(2);
179
+ expect(reclaimed?.workerId).toBe(secondWorker);
180
+ expect(reclaimed?.startedAt?.getTime()).toBe(claimed?.startedAt?.getTime());
181
+ await teardown(backend);
182
+ });
183
+ test("prioritizes pending workflow runs over expired running ones", async () => {
184
+ const backend = await setup();
185
+ const running = await createPendingWorkflowRun(backend);
186
+ const runningClaim = await backend.claimWorkflowRun({
187
+ workerId: "worker-running",
188
+ leaseDurationMs: 5,
189
+ });
190
+ if (!runningClaim)
191
+ throw new Error("expected claim");
192
+ expect(runningClaim.id).toBe(running.id);
193
+ await sleep(10); // wait for running's lease to expire
194
+ // pending claimed first, even though running expired
195
+ const pending = await createPendingWorkflowRun(backend);
196
+ const claimedFirst = await backend.claimWorkflowRun({
197
+ workerId: "worker-second",
198
+ leaseDurationMs: 100,
199
+ });
200
+ expect(claimedFirst?.id).toBe(pending.id);
201
+ // running claimed second
202
+ const claimedSecond = await backend.claimWorkflowRun({
203
+ workerId: "worker-third",
204
+ leaseDurationMs: 100,
205
+ });
206
+ expect(claimedSecond?.id).toBe(running.id);
207
+ await teardown(backend);
208
+ });
209
+ test("returns null when no workflow runs are available", async () => {
210
+ const backend = await setup();
211
+ const claimed = await backend.claimWorkflowRun({
212
+ workerId: randomUUID(),
213
+ leaseDurationMs: 10,
214
+ });
215
+ expect(claimed).toBeNull();
216
+ await teardown(backend);
217
+ });
218
+ });
219
+ describe("extendWorkflowRunLease()", () => {
220
+ test("extends the lease for running workflow runs", async () => {
221
+ const workerId = randomUUID();
222
+ await createPendingWorkflowRun(backend);
223
+ const claimed = await backend.claimWorkflowRun({
224
+ workerId,
225
+ leaseDurationMs: 20,
226
+ });
227
+ if (!claimed)
228
+ throw new Error("Expected workflow run to be claimed"); // for type narrowing
229
+ const previousExpiry = claimed.availableAt;
230
+ const extended = await backend.extendWorkflowRunLease({
231
+ workflowRunId: claimed.id,
232
+ workerId,
233
+ leaseDurationMs: 200,
234
+ });
235
+ expect(extended.availableAt?.getTime()).toBeGreaterThan(previousExpiry?.getTime() ?? Infinity);
236
+ });
237
+ });
238
+ describe("sleepWorkflowRun()", () => {
239
+ test("sets a running workflow to sleeping status until a future time", async () => {
240
+ const workerId = randomUUID();
241
+ await createPendingWorkflowRun(backend);
242
+ const claimed = await backend.claimWorkflowRun({
243
+ workerId,
244
+ leaseDurationMs: 100,
245
+ });
246
+ if (!claimed)
247
+ throw new Error("Expected workflow run to be claimed");
248
+ const sleepUntil = new Date(Date.now() + 5000); // 5 seconds from now
249
+ await backend.sleepWorkflowRun({
250
+ workflowRunId: claimed.id,
251
+ workerId,
252
+ availableAt: sleepUntil,
253
+ });
254
+ const fetched = await backend.getWorkflowRun({
255
+ workflowRunId: claimed.id,
256
+ });
257
+ expect(fetched).not.toBeNull();
258
+ expect(fetched?.availableAt?.getTime()).toBe(sleepUntil.getTime());
259
+ expect(fetched?.workerId).toBeNull();
260
+ expect(fetched?.status).toBe("sleeping");
261
+ });
262
+ test("fails when trying to sleep a canceled workflow", async () => {
263
+ const backend = await setup();
264
+ // completed run
265
+ let claimed = await createClaimedWorkflowRun(backend);
266
+ await backend.completeWorkflowRun({
267
+ workflowRunId: claimed.id,
268
+ workerId: claimed.workerId ?? "",
269
+ output: null,
270
+ });
271
+ await expect(backend.sleepWorkflowRun({
272
+ workflowRunId: claimed.id,
273
+ workerId: claimed.workerId ?? "",
274
+ availableAt: new Date(Date.now() + 60_000),
275
+ })).rejects.toThrow("Failed to sleep workflow run");
276
+ // failed run
277
+ claimed = await createClaimedWorkflowRun(backend);
278
+ await backend.failWorkflowRun({
279
+ workflowRunId: claimed.id,
280
+ workerId: claimed.workerId ?? "",
281
+ error: null,
282
+ });
283
+ await expect(backend.sleepWorkflowRun({
284
+ workflowRunId: claimed.id,
285
+ workerId: claimed.workerId ?? "",
286
+ availableAt: new Date(Date.now() + 60_000),
287
+ })).rejects.toThrow("Failed to sleep workflow run");
288
+ // canceled run
289
+ claimed = await createClaimedWorkflowRun(backend);
290
+ await backend.cancelWorkflowRun({
291
+ workflowRunId: claimed.id,
292
+ });
293
+ await expect(backend.sleepWorkflowRun({
294
+ workflowRunId: claimed.id,
295
+ workerId: claimed.workerId ?? "",
296
+ availableAt: new Date(Date.now() + 60_000),
297
+ })).rejects.toThrow("Failed to sleep workflow run");
298
+ await teardown(backend);
299
+ });
300
+ });
301
+ describe("completeWorkflowRun()", () => {
302
+ test("marks running workflow runs as completed", async () => {
303
+ const workerId = randomUUID();
304
+ await createPendingWorkflowRun(backend);
305
+ const claimed = await backend.claimWorkflowRun({
306
+ workerId,
307
+ leaseDurationMs: 20,
308
+ });
309
+ if (!claimed)
310
+ throw new Error("Expected workflow run to be claimed"); // for type narrowing
311
+ const output = { ok: true };
312
+ const completed = await backend.completeWorkflowRun({
313
+ workflowRunId: claimed.id,
314
+ workerId,
315
+ output,
316
+ });
317
+ expect(completed.status).toBe("completed");
318
+ expect(completed.output).toEqual(output);
319
+ expect(completed.error).toBeNull();
320
+ expect(completed.finishedAt).not.toBeNull();
321
+ expect(completed.availableAt).toBeNull();
322
+ });
323
+ });
324
+ describe("failWorkflowRun()", () => {
325
+ test("reschedules workflow runs with exponential backoff on first failure", async () => {
326
+ const workerId = randomUUID();
327
+ await createPendingWorkflowRun(backend);
328
+ const claimed = await backend.claimWorkflowRun({
329
+ workerId,
330
+ leaseDurationMs: 20,
331
+ });
332
+ if (!claimed)
333
+ throw new Error("Expected workflow run to be claimed");
334
+ const beforeFailTime = Date.now();
335
+ const error = { message: "boom" };
336
+ const failed = await backend.failWorkflowRun({
337
+ workflowRunId: claimed.id,
338
+ workerId,
339
+ error,
340
+ });
341
+ // rescheduled, not permanently failed
342
+ expect(failed.status).toBe("pending");
343
+ expect(failed.error).toEqual(error);
344
+ expect(failed.output).toBeNull();
345
+ expect(failed.finishedAt).toBeNull();
346
+ expect(failed.workerId).toBeNull();
347
+ expect(failed.availableAt).not.toBeNull();
348
+ if (!failed.availableAt)
349
+ throw new Error("Expected availableAt");
350
+ const delayMs = failed.availableAt.getTime() - beforeFailTime;
351
+ expect(delayMs).toBeGreaterThanOrEqual(900); // ~1s with some tolerance
352
+ expect(delayMs).toBeLessThan(1500);
353
+ });
354
+ test("reschedules with increasing backoff on multiple failures (known slow test)", async () => {
355
+ // this test needs isolated namespace
356
+ const backend = await setup();
357
+ await createPendingWorkflowRun(backend);
358
+ // fail first attempt
359
+ let workerId = randomUUID();
360
+ let claimed = await backend.claimWorkflowRun({
361
+ workerId,
362
+ leaseDurationMs: 20,
363
+ });
364
+ if (!claimed)
365
+ throw new Error("Expected workflow run to be claimed");
366
+ expect(claimed.attempts).toBe(1);
367
+ const firstFailed = await backend.failWorkflowRun({
368
+ workflowRunId: claimed.id,
369
+ workerId,
370
+ error: { message: "first failure" },
371
+ });
372
+ expect(firstFailed.status).toBe("pending");
373
+ await sleep(1100); // wait for first backoff (~1s)
374
+ // fail second attempt
375
+ workerId = randomUUID();
376
+ claimed = await backend.claimWorkflowRun({
377
+ workerId,
378
+ leaseDurationMs: 20,
379
+ });
380
+ if (!claimed)
381
+ throw new Error("Expected workflow run to be claimed");
382
+ expect(claimed.attempts).toBe(2);
383
+ const beforeSecondFail = Date.now();
384
+ const secondFailed = await backend.failWorkflowRun({
385
+ workflowRunId: claimed.id,
386
+ workerId,
387
+ error: { message: "second failure" },
388
+ });
389
+ expect(secondFailed.status).toBe("pending");
390
+ // second attempt should have ~2s backoff (1s * 2^1)
391
+ if (!secondFailed.availableAt)
392
+ throw new Error("Expected availableAt");
393
+ const delayMs = secondFailed.availableAt.getTime() - beforeSecondFail;
394
+ expect(delayMs).toBeGreaterThanOrEqual(1900); // ~2s with some tolerance
395
+ expect(delayMs).toBeLessThan(2500);
396
+ await teardown(backend);
397
+ });
398
+ });
399
+ describe("createStepAttempt()", () => {
400
+ test("creates a step attempt", async () => {
401
+ const workflowRun = await createClaimedWorkflowRun(backend);
402
+ const expected = {
403
+ namespaceId: workflowRun.namespaceId,
404
+ id: "", // -
405
+ workflowRunId: workflowRun.id,
406
+ stepName: randomUUID(),
407
+ kind: "function",
408
+ status: "running",
409
+ config: { key: "val" },
410
+ context: null,
411
+ output: null,
412
+ error: null,
413
+ childWorkflowRunNamespaceId: null,
414
+ childWorkflowRunId: null,
415
+ startedAt: null,
416
+ finishedAt: null,
417
+ createdAt: new Date(), // -
418
+ updatedAt: new Date(), // -
419
+ };
420
+ const created = await backend.createStepAttempt({
421
+ workflowRunId: expected.workflowRunId,
422
+ workerId: workflowRun.workerId, // eslint-disable-line @typescript-eslint/no-non-null-assertion
423
+ stepName: expected.stepName,
424
+ kind: expected.kind,
425
+ config: expected.config,
426
+ context: expected.context,
427
+ });
428
+ expect(created.id).toHaveLength(36);
429
+ expect(deltaSeconds(created.startedAt)).toBeLessThan(1);
430
+ expect(deltaSeconds(created.createdAt)).toBeLessThan(1);
431
+ expect(deltaSeconds(created.updatedAt)).toBeLessThan(1);
432
+ expected.id = created.id;
433
+ expected.startedAt = created.startedAt;
434
+ expected.createdAt = created.createdAt;
435
+ expected.updatedAt = created.updatedAt;
436
+ expect(created).toEqual(expected);
437
+ });
438
+ });
439
+ describe("getStepAttempt()", () => {
440
+ test("returns a persisted step attempt", async () => {
441
+ const claimed = await createClaimedWorkflowRun(backend);
442
+ const created = await backend.createStepAttempt({
443
+ workflowRunId: claimed.id,
444
+ workerId: claimed.workerId, // eslint-disable-line @typescript-eslint/no-non-null-assertion
445
+ stepName: randomUUID(),
446
+ kind: "function",
447
+ config: {},
448
+ context: null,
449
+ });
450
+ const got = await backend.getStepAttempt({
451
+ stepAttemptId: created.id,
452
+ });
453
+ expect(got).toEqual(created);
454
+ });
455
+ });
456
+ describe("listStepAttempts()", () => {
457
+ test("lists step attempts ordered by creation time", async () => {
458
+ const claimed = await createClaimedWorkflowRun(backend);
459
+ const first = await backend.createStepAttempt({
460
+ workflowRunId: claimed.id,
461
+ workerId: claimed.workerId, // eslint-disable-line @typescript-eslint/no-non-null-assertion
462
+ stepName: randomUUID(),
463
+ kind: "function",
464
+ config: {},
465
+ context: null,
466
+ });
467
+ await backend.completeStepAttempt({
468
+ workflowRunId: claimed.id,
469
+ stepAttemptId: first.id,
470
+ workerId: claimed.workerId, // eslint-disable-line @typescript-eslint/no-non-null-assertion,
471
+ output: { ok: true },
472
+ });
473
+ const second = await backend.createStepAttempt({
474
+ workflowRunId: claimed.id,
475
+ workerId: claimed.workerId, // eslint-disable-line @typescript-eslint/no-non-null-assertion
476
+ stepName: randomUUID(),
477
+ kind: "function",
478
+ config: {},
479
+ context: null,
480
+ });
481
+ const listed = await backend.listStepAttempts({
482
+ workflowRunId: claimed.id,
483
+ });
484
+ expect(listed.data.map((step) => step.stepName)).toEqual([
485
+ first.stepName,
486
+ second.stepName,
487
+ ]);
488
+ });
489
+ test("paginates step attempts", async () => {
490
+ const claimed = await createClaimedWorkflowRun(backend);
491
+ for (let i = 0; i < 5; i++) {
492
+ await backend.createStepAttempt({
493
+ workflowRunId: claimed.id,
494
+ workerId: claimed.workerId, // eslint-disable-line @typescript-eslint/no-non-null-assertion
495
+ stepName: `step-${String(i)}`,
496
+ kind: "function",
497
+ config: {},
498
+ context: null,
499
+ });
500
+ await sleep(10); // ensure createdAt differs
501
+ }
502
+ // p1
503
+ const page1 = await backend.listStepAttempts({
504
+ workflowRunId: claimed.id,
505
+ limit: 2,
506
+ });
507
+ expect(page1.data).toHaveLength(2);
508
+ expect(page1.data[0]?.stepName).toBe("step-0");
509
+ expect(page1.data[1]?.stepName).toBe("step-1");
510
+ expect(page1.pagination.next).not.toBeNull();
511
+ expect(page1.pagination.prev).toBeNull();
512
+ // p2
513
+ const page2 = await backend.listStepAttempts({
514
+ workflowRunId: claimed.id,
515
+ limit: 2,
516
+ after: page1.pagination.next, // eslint-disable-line @typescript-eslint/no-non-null-assertion
517
+ });
518
+ expect(page2.data).toHaveLength(2);
519
+ expect(page2.data[0]?.stepName).toBe("step-2");
520
+ expect(page2.data[1]?.stepName).toBe("step-3");
521
+ expect(page2.pagination.next).not.toBeNull();
522
+ expect(page2.pagination.prev).not.toBeNull();
523
+ // p3
524
+ const page3 = await backend.listStepAttempts({
525
+ workflowRunId: claimed.id,
526
+ limit: 2,
527
+ after: page2.pagination.next, // eslint-disable-line @typescript-eslint/no-non-null-assertion
528
+ });
529
+ expect(page3.data).toHaveLength(1);
530
+ expect(page3.data[0]?.stepName).toBe("step-4");
531
+ expect(page3.pagination.next).toBeNull();
532
+ expect(page3.pagination.prev).not.toBeNull();
533
+ // p2 again
534
+ const page2Back = await backend.listStepAttempts({
535
+ workflowRunId: claimed.id,
536
+ limit: 2,
537
+ before: page3.pagination.prev, // eslint-disable-line @typescript-eslint/no-non-null-assertion
538
+ });
539
+ expect(page2Back.data).toHaveLength(2);
540
+ expect(page2Back.data[0]?.stepName).toBe("step-2");
541
+ expect(page2Back.data[1]?.stepName).toBe("step-3");
542
+ expect(page2Back.pagination.next).toEqual(page2.pagination.next);
543
+ expect(page2Back.pagination.prev).toEqual(page2.pagination.prev);
544
+ });
545
+ test("handles empty results", async () => {
546
+ const claimed = await createClaimedWorkflowRun(backend);
547
+ const listed = await backend.listStepAttempts({
548
+ workflowRunId: claimed.id,
549
+ });
550
+ expect(listed.data).toHaveLength(0);
551
+ expect(listed.pagination.next).toBeNull();
552
+ expect(listed.pagination.prev).toBeNull();
553
+ });
554
+ test("handles exact limit match", async () => {
555
+ const claimed = await createClaimedWorkflowRun(backend);
556
+ await backend.createStepAttempt({
557
+ workflowRunId: claimed.id,
558
+ workerId: claimed.workerId, // eslint-disable-line @typescript-eslint/no-non-null-assertion
559
+ stepName: "step-1",
560
+ kind: "function",
561
+ config: {},
562
+ context: null,
563
+ });
564
+ const listed = await backend.listStepAttempts({
565
+ workflowRunId: claimed.id,
566
+ limit: 1,
567
+ });
568
+ expect(listed.data).toHaveLength(1);
569
+ expect(listed.pagination.next).toBeNull();
570
+ expect(listed.pagination.prev).toBeNull();
571
+ });
572
+ });
573
+ describe("getStepAttempt() duplicate", () => {
574
+ test("returns a persisted step attempt", async () => {
575
+ const claimed = await createClaimedWorkflowRun(backend);
576
+ const created = await backend.createStepAttempt({
577
+ workflowRunId: claimed.id,
578
+ workerId: claimed.workerId, // eslint-disable-line @typescript-eslint/no-non-null-assertion
579
+ stepName: randomUUID(),
580
+ kind: "function",
581
+ config: {},
582
+ context: null,
583
+ });
584
+ const got = await backend.getStepAttempt({
585
+ stepAttemptId: created.id,
586
+ });
587
+ expect(got).toEqual(created);
588
+ });
589
+ });
590
+ describe("completeStepAttempt()", () => {
591
+ test("marks running step attempts as completed", async () => {
592
+ const claimed = await createClaimedWorkflowRun(backend);
593
+ const created = await backend.createStepAttempt({
594
+ workflowRunId: claimed.id,
595
+ workerId: claimed.workerId, // eslint-disable-line @typescript-eslint/no-non-null-assertion
596
+ stepName: randomUUID(),
597
+ kind: "function",
598
+ config: {},
599
+ context: null,
600
+ });
601
+ const output = { foo: "bar" };
602
+ const completed = await backend.completeStepAttempt({
603
+ workflowRunId: claimed.id,
604
+ stepAttemptId: created.id,
605
+ workerId: claimed.workerId, // eslint-disable-line @typescript-eslint/no-non-null-assertion
606
+ output,
607
+ });
608
+ expect(completed.status).toBe("completed");
609
+ expect(completed.output).toEqual(output);
610
+ expect(completed.error).toBeNull();
611
+ expect(completed.finishedAt).not.toBeNull();
612
+ const fetched = await backend.getStepAttempt({
613
+ stepAttemptId: created.id,
614
+ });
615
+ expect(fetched?.status).toBe("completed");
616
+ expect(fetched?.output).toEqual(output);
617
+ expect(fetched?.error).toBeNull();
618
+ expect(fetched?.finishedAt).not.toBeNull();
619
+ });
620
+ });
621
+ describe("failStepAttempt()", () => {
622
+ test("marks running step attempts as failed", async () => {
623
+ const claimed = await createClaimedWorkflowRun(backend);
624
+ const created = await backend.createStepAttempt({
625
+ workflowRunId: claimed.id,
626
+ workerId: claimed.workerId, // eslint-disable-line @typescript-eslint/no-non-null-assertion
627
+ stepName: randomUUID(),
628
+ kind: "function",
629
+ config: {},
630
+ context: null,
631
+ });
632
+ const error = { message: "nope" };
633
+ const failed = await backend.failStepAttempt({
634
+ workflowRunId: claimed.id,
635
+ stepAttemptId: created.id,
636
+ workerId: claimed.workerId, // eslint-disable-line @typescript-eslint/no-non-null-assertion
637
+ error,
638
+ });
639
+ expect(failed.status).toBe("failed");
640
+ expect(failed.error).toEqual(error);
641
+ expect(failed.output).toBeNull();
642
+ expect(failed.finishedAt).not.toBeNull();
643
+ const fetched = await backend.getStepAttempt({
644
+ stepAttemptId: created.id,
645
+ });
646
+ expect(fetched?.status).toBe("failed");
647
+ expect(fetched?.error).toEqual(error);
648
+ expect(fetched?.output).toBeNull();
649
+ expect(fetched?.finishedAt).not.toBeNull();
650
+ });
651
+ });
652
+ describe("deadline_at", () => {
653
+ test("creates a workflow run with a deadline", async () => {
654
+ const deadline = new Date(Date.now() + 60_000); // in 1 minute
655
+ const created = await backend.createWorkflowRun({
656
+ workflowName: randomUUID(),
657
+ version: null,
658
+ idempotencyKey: null,
659
+ input: null,
660
+ config: {},
661
+ context: null,
662
+ availableAt: null,
663
+ deadlineAt: deadline,
664
+ });
665
+ expect(created.deadlineAt).not.toBeNull();
666
+ expect(created.deadlineAt?.getTime()).toBe(deadline.getTime());
667
+ });
668
+ test("does not claim workflow runs past their deadline", async () => {
669
+ const backend = await setup();
670
+ const pastDeadline = new Date(Date.now() - 1000);
671
+ await backend.createWorkflowRun({
672
+ workflowName: randomUUID(),
673
+ version: null,
674
+ idempotencyKey: null,
675
+ input: null,
676
+ config: {},
677
+ context: null,
678
+ availableAt: null,
679
+ deadlineAt: pastDeadline,
680
+ });
681
+ const claimed = await backend.claimWorkflowRun({
682
+ workerId: randomUUID(),
683
+ leaseDurationMs: 1000,
684
+ });
685
+ expect(claimed).toBeNull();
686
+ await teardown(backend);
687
+ });
688
+ test("marks deadline-expired workflow runs as failed when claiming", async () => {
689
+ const backend = await setup();
690
+ const pastDeadline = new Date(Date.now() - 1000);
691
+ const created = await backend.createWorkflowRun({
692
+ workflowName: randomUUID(),
693
+ version: null,
694
+ idempotencyKey: null,
695
+ input: null,
696
+ config: {},
697
+ context: null,
698
+ availableAt: null,
699
+ deadlineAt: pastDeadline,
700
+ });
701
+ // attempt to claim triggers deadline check
702
+ const claimed = await backend.claimWorkflowRun({
703
+ workerId: randomUUID(),
704
+ leaseDurationMs: 1000,
705
+ });
706
+ expect(claimed).toBeNull();
707
+ // verify it was marked as failed
708
+ const failed = await backend.getWorkflowRun({
709
+ workflowRunId: created.id,
710
+ });
711
+ expect(failed?.status).toBe("failed");
712
+ expect(failed?.error).toEqual({
713
+ message: "Workflow run deadline exceeded",
714
+ });
715
+ expect(failed?.finishedAt).not.toBeNull();
716
+ expect(failed?.availableAt).toBeNull();
717
+ await teardown(backend);
718
+ });
719
+ test("does not reschedule failed workflow runs if next retry would exceed deadline", async () => {
720
+ const backend = await setup();
721
+ const deadline = new Date(Date.now() + 500); // 500ms from now
722
+ const created = await backend.createWorkflowRun({
723
+ workflowName: randomUUID(),
724
+ version: null,
725
+ idempotencyKey: null,
726
+ input: null,
727
+ config: {},
728
+ context: null,
729
+ availableAt: null,
730
+ deadlineAt: deadline,
731
+ });
732
+ const workerId = randomUUID();
733
+ const claimed = await backend.claimWorkflowRun({
734
+ workerId,
735
+ leaseDurationMs: 100,
736
+ });
737
+ expect(claimed).not.toBeNull();
738
+ // should mark as permanently failed since retry backoff (1s) would exceed deadline (500ms)
739
+ const failed = await backend.failWorkflowRun({
740
+ workflowRunId: created.id,
741
+ workerId,
742
+ error: { message: "test error" },
743
+ });
744
+ expect(failed.status).toBe("failed");
745
+ expect(failed.availableAt).toBeNull();
746
+ expect(failed.finishedAt).not.toBeNull();
747
+ await teardown(backend);
748
+ });
749
+ test("reschedules failed workflow runs if retry would complete before deadline", async () => {
750
+ const backend = await setup();
751
+ const deadline = new Date(Date.now() + 5000); // in 5 seconds
752
+ const created = await backend.createWorkflowRun({
753
+ workflowName: randomUUID(),
754
+ version: null,
755
+ idempotencyKey: null,
756
+ input: null,
757
+ config: {},
758
+ context: null,
759
+ availableAt: null,
760
+ deadlineAt: deadline,
761
+ });
762
+ const workerId = randomUUID();
763
+ const claimed = await backend.claimWorkflowRun({
764
+ workerId,
765
+ leaseDurationMs: 100,
766
+ });
767
+ expect(claimed).not.toBeNull();
768
+ // should reschedule since retry backoff (1s) is before deadline (5s
769
+ const failed = await backend.failWorkflowRun({
770
+ workflowRunId: created.id,
771
+ workerId,
772
+ error: { message: "test error" },
773
+ });
774
+ expect(failed.status).toBe("pending");
775
+ expect(failed.availableAt).not.toBeNull();
776
+ expect(failed.finishedAt).toBeNull();
777
+ await teardown(backend);
778
+ });
779
+ });
780
+ describe("cancelWorkflowRun()", () => {
781
+ test("cancels a pending workflow run", async () => {
782
+ const backend = await setup();
783
+ const created = await createPendingWorkflowRun(backend);
784
+ expect(created.status).toBe("pending");
785
+ const canceled = await backend.cancelWorkflowRun({
786
+ workflowRunId: created.id,
787
+ });
788
+ expect(canceled.status).toBe("canceled");
789
+ expect(canceled.workerId).toBeNull();
790
+ expect(canceled.availableAt).toBeNull();
791
+ expect(canceled.finishedAt).not.toBeNull();
792
+ expect(deltaSeconds(canceled.finishedAt)).toBeLessThan(1);
793
+ await teardown(backend);
794
+ });
795
+ test("cancels a running workflow run", async () => {
796
+ const backend = await setup();
797
+ const created = await createClaimedWorkflowRun(backend);
798
+ expect(created.status).toBe("running");
799
+ expect(created.workerId).not.toBeNull();
800
+ const canceled = await backend.cancelWorkflowRun({
801
+ workflowRunId: created.id,
802
+ });
803
+ expect(canceled.status).toBe("canceled");
804
+ expect(canceled.workerId).toBeNull();
805
+ expect(canceled.availableAt).toBeNull();
806
+ expect(canceled.finishedAt).not.toBeNull();
807
+ await teardown(backend);
808
+ });
809
+ test("cancels a sleeping workflow run", async () => {
810
+ const backend = await setup();
811
+ const claimed = await createClaimedWorkflowRun(backend);
812
+ // put workflow to sleep
813
+ const sleepUntil = new Date(Date.now() + 60_000); // 1 minute from now
814
+ const sleeping = await backend.sleepWorkflowRun({
815
+ workflowRunId: claimed.id,
816
+ workerId: claimed.workerId ?? "",
817
+ availableAt: sleepUntil,
818
+ });
819
+ expect(sleeping.status).toBe("sleeping");
820
+ const canceled = await backend.cancelWorkflowRun({
821
+ workflowRunId: sleeping.id,
822
+ });
823
+ expect(canceled.status).toBe("canceled");
824
+ expect(canceled.workerId).toBeNull();
825
+ expect(canceled.availableAt).toBeNull();
826
+ expect(canceled.finishedAt).not.toBeNull();
827
+ await teardown(backend);
828
+ });
829
+ test("throws error when canceling a completed workflow run", async () => {
830
+ const backend = await setup();
831
+ const claimed = await createClaimedWorkflowRun(backend);
832
+ // mark as completed
833
+ await backend.completeWorkflowRun({
834
+ workflowRunId: claimed.id,
835
+ workerId: claimed.workerId ?? "",
836
+ output: { result: "success" },
837
+ });
838
+ await expect(backend.cancelWorkflowRun({
839
+ workflowRunId: claimed.id,
840
+ })).rejects.toThrow(/Cannot cancel workflow run .* with status completed/);
841
+ await teardown(backend);
842
+ });
843
+ test("throws error when canceling a failed workflow run", async () => {
844
+ const backend = await setup();
845
+ // create with deadline that's already passed to make it fail
846
+ const workflowWithDeadline = await backend.createWorkflowRun({
847
+ workflowName: randomUUID(),
848
+ version: null,
849
+ idempotencyKey: null,
850
+ input: null,
851
+ config: {},
852
+ context: null,
853
+ availableAt: null,
854
+ deadlineAt: new Date(Date.now() - 1000), // deadline in the past
855
+ });
856
+ // try to claim it, which should mark it as failed due to deadline
857
+ const claimed = await backend.claimWorkflowRun({
858
+ workerId: randomUUID(),
859
+ leaseDurationMs: 100,
860
+ });
861
+ // if claim succeeds, manually fail it
862
+ if (claimed?.workerId) {
863
+ await backend.failWorkflowRun({
864
+ workflowRunId: claimed.id,
865
+ workerId: claimed.workerId,
866
+ error: { message: "test error" },
867
+ });
868
+ }
869
+ // get a workflow that's definitely failed
870
+ const failedRun = await backend.getWorkflowRun({
871
+ workflowRunId: workflowWithDeadline.id,
872
+ });
873
+ if (failedRun?.status === "failed") {
874
+ await expect(backend.cancelWorkflowRun({
875
+ workflowRunId: failedRun.id,
876
+ })).rejects.toThrow(/Cannot cancel workflow run .* with status failed/);
877
+ }
878
+ await teardown(backend);
879
+ });
880
+ test("is idempotent when canceling an already canceled workflow run", async () => {
881
+ const backend = await setup();
882
+ const created = await createPendingWorkflowRun(backend);
883
+ const firstCancel = await backend.cancelWorkflowRun({
884
+ workflowRunId: created.id,
885
+ });
886
+ expect(firstCancel.status).toBe("canceled");
887
+ const secondCancel = await backend.cancelWorkflowRun({
888
+ workflowRunId: created.id,
889
+ });
890
+ expect(secondCancel.status).toBe("canceled");
891
+ expect(secondCancel.id).toBe(firstCancel.id);
892
+ await teardown(backend);
893
+ });
894
+ test("throws error when canceling a non-existent workflow run", async () => {
895
+ const backend = await setup();
896
+ const nonExistentId = randomUUID();
897
+ await expect(backend.cancelWorkflowRun({
898
+ workflowRunId: nonExistentId,
899
+ })).rejects.toThrow(`Workflow run ${nonExistentId} does not exist`);
900
+ await teardown(backend);
901
+ });
902
+ test("canceled workflow is not claimed by workers", async () => {
903
+ const backend = await setup();
904
+ const created = await createPendingWorkflowRun(backend);
905
+ // cancel the workflow
906
+ await backend.cancelWorkflowRun({
907
+ workflowRunId: created.id,
908
+ });
909
+ // try to claim work
910
+ const claimed = await backend.claimWorkflowRun({
911
+ workerId: randomUUID(),
912
+ leaseDurationMs: 100,
913
+ });
914
+ // should not claim the canceled workflow
915
+ expect(claimed).toBeNull();
916
+ await teardown(backend);
917
+ });
918
+ });
919
+ // Helper function for creating workflow runs that uses the shared backend
920
+ async function createPendingWorkflowRun(b) {
921
+ return await b.createWorkflowRun({
922
+ workflowName: randomUUID(),
923
+ version: null,
924
+ idempotencyKey: null,
925
+ input: null,
926
+ config: {},
927
+ context: null,
928
+ availableAt: null,
929
+ deadlineAt: null,
930
+ });
931
+ }
932
+ async function createClaimedWorkflowRun(b) {
933
+ await createPendingWorkflowRun(b);
934
+ const claimed = await b.claimWorkflowRun({
935
+ workerId: randomUUID(),
936
+ leaseDurationMs: 100,
937
+ });
938
+ if (!claimed)
939
+ throw new Error("Failed to claim workflow run");
940
+ return claimed;
941
+ }
942
+ });
943
+ }
944
+ // Helper functions
945
+ function deltaSeconds(date) {
946
+ if (!date)
947
+ return Infinity;
948
+ return Math.abs((Date.now() - date.getTime()) / 1000);
949
+ }
950
+ function newDateInOneYear() {
951
+ const d = new Date();
952
+ d.setFullYear(d.getFullYear() + 1);
953
+ return d;
954
+ }
955
+ function sleep(ms) {
956
+ return new Promise((resolve) => setTimeout(resolve, ms));
957
+ }
958
+ //# sourceMappingURL=backend.testsuite.js.map