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