@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,297 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
3
+ import { z } from "zod";
4
+ import { BackendPostgres } from ".";
5
+ import { declareWorkflow, OpenWorkflow } from "./client";
6
+ import { KNEX_GLOBAL_CONFIG } from "./testing/connection";
7
+
8
+ describe("OpenWorkflow", () => {
9
+ let backend: BackendPostgres;
10
+
11
+ beforeEach(async () => {
12
+ backend = await BackendPostgres.connect(KNEX_GLOBAL_CONFIG, {
13
+ namespaceId: randomUUID(),
14
+ runMigrations: false,
15
+ });
16
+ });
17
+
18
+ afterEach(async () => {
19
+ await backend.stop();
20
+ });
21
+
22
+ test("enqueues workflow runs via backend", async () => {
23
+ const client = new OpenWorkflow({ backend });
24
+
25
+ const workflow = client.defineWorkflow({ name: "enqueue-test" }, noopFn);
26
+ await workflow.run({ docUrl: "https://example.com" });
27
+
28
+ const workerId = "enqueue-worker";
29
+ const claimed = await backend.claimWorkflowRun({
30
+ workerId,
31
+ leaseDurationMs: 1000,
32
+ });
33
+
34
+ expect(claimed?.workflowName).toBe("enqueue-test");
35
+ expect(claimed?.workerId).toBe(workerId);
36
+ expect(claimed?.input).toEqual({ docUrl: "https://example.com" });
37
+ });
38
+
39
+ describe("schema validation", () => {
40
+ describe("Zod schema", () => {
41
+ const schema = z.object({
42
+ userId: z.uuid(),
43
+ count: z.number().int().positive(),
44
+ });
45
+
46
+ test("accepts valid input", async () => {
47
+ const client = new OpenWorkflow({ backend });
48
+ const workflow = client.defineWorkflow({ name: "schema-zod-valid", schema }, noopFn);
49
+
50
+ const handle = await workflow.run({
51
+ userId: randomUUID(),
52
+ count: 3,
53
+ });
54
+
55
+ await handle.cancel();
56
+ });
57
+
58
+ test("rejects invalid input", async () => {
59
+ const client = new OpenWorkflow({ backend });
60
+ const workflow = client.defineWorkflow({ name: "schema-zod-invalid", schema }, noopFn);
61
+
62
+ await expect(workflow.run({ userId: "not-a-uuid", count: 0 } as never)).rejects.toThrow();
63
+ });
64
+ });
65
+ });
66
+
67
+ test("result resolves when workflow succeeds", async () => {
68
+ const client = new OpenWorkflow({ backend });
69
+
70
+ const workflow = client.defineWorkflow({ name: "result-success" }, noopFn);
71
+ const handle = await workflow.run({ value: 1 });
72
+
73
+ const workerId = "test-worker";
74
+ const claimed = await backend.claimWorkflowRun({
75
+ workerId,
76
+ leaseDurationMs: 1000,
77
+ });
78
+ expect(claimed).not.toBeNull();
79
+ if (!claimed) throw new Error("workflow run was not claimed");
80
+
81
+ await backend.completeWorkflowRun({
82
+ workflowRunId: claimed.id,
83
+ workerId,
84
+ output: { ok: true },
85
+ });
86
+
87
+ // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression
88
+ const result = await handle.result();
89
+ expect(result).toEqual({ ok: true });
90
+ });
91
+
92
+ test("result rejects when workflow fails", async () => {
93
+ const client = new OpenWorkflow({ backend });
94
+
95
+ const workflow = client.defineWorkflow({ name: "result-failure" }, noopFn);
96
+ await workflow.run({ value: 1 });
97
+
98
+ const workerId = "test-worker";
99
+ const claimed = await backend.claimWorkflowRun({
100
+ workerId,
101
+ leaseDurationMs: 1000,
102
+ });
103
+ expect(claimed).not.toBeNull();
104
+ if (!claimed) throw new Error("workflow run was not claimed");
105
+
106
+ // mark as failed (should reschedule))
107
+ await backend.failWorkflowRun({
108
+ workflowRunId: claimed.id,
109
+ workerId,
110
+ error: { message: "boom" },
111
+ });
112
+
113
+ const rescheduled = await backend.getWorkflowRun({
114
+ workflowRunId: claimed.id,
115
+ });
116
+ expect(rescheduled?.status).toBe("pending");
117
+ expect(rescheduled?.error).toEqual({ message: "boom" });
118
+ });
119
+
120
+ test("creates workflow run with deadline", async () => {
121
+ const client = new OpenWorkflow({ backend });
122
+
123
+ const workflow = client.defineWorkflow({ name: "deadline-test" }, noopFn);
124
+ const deadline = new Date(Date.now() + 60_000); // in 1 minute
125
+ const handle = await workflow.run({ value: 1 }, { deadlineAt: deadline });
126
+
127
+ expect(handle.workflowRun.deadlineAt).not.toBeNull();
128
+ expect(handle.workflowRun.deadlineAt?.getTime()).toBe(deadline.getTime());
129
+ });
130
+
131
+ test("creates workflow run with version", async () => {
132
+ const client = new OpenWorkflow({ backend });
133
+
134
+ const workflow = client.defineWorkflow({ name: "versioned-test", version: "v2.0" }, noopFn);
135
+ const handle = await workflow.run({ value: 1 });
136
+
137
+ expect(handle.workflowRun.version).toBe("v2.0");
138
+ });
139
+
140
+ test("creates workflow run without version", async () => {
141
+ const client = new OpenWorkflow({ backend });
142
+
143
+ const workflow = client.defineWorkflow({ name: "unversioned-test" }, noopFn);
144
+ const handle = await workflow.run({ value: 1 });
145
+
146
+ expect(handle.workflowRun.version).toBeNull();
147
+ });
148
+
149
+ test("cancels workflow run via handle", async () => {
150
+ const client = new OpenWorkflow({ backend });
151
+
152
+ const workflow = client.defineWorkflow({ name: "cancel-test" }, noopFn);
153
+ const handle = await workflow.run({ value: 1 });
154
+
155
+ await handle.cancel();
156
+
157
+ const workflowRun = await backend.getWorkflowRun({
158
+ workflowRunId: handle.workflowRun.id,
159
+ });
160
+ expect(workflowRun?.status).toBe("canceled");
161
+ expect(workflowRun?.finishedAt).not.toBeNull();
162
+ });
163
+
164
+ describe("declareWorkflow / implementWorkflow API", () => {
165
+ test("declareWorkflow returns a spec that can be used to schedule runs", async () => {
166
+ const client = new OpenWorkflow({ backend });
167
+
168
+ const spec = declareWorkflow({ name: "declare-test" });
169
+
170
+ const handle = await client.runWorkflow(spec, { message: "hello" });
171
+ expect(handle.workflowRun.workflowName).toBe("declare-test");
172
+
173
+ await handle.cancel();
174
+ });
175
+
176
+ test("implementWorkflow registers the workflow for worker execution", async () => {
177
+ const client = new OpenWorkflow({ backend });
178
+
179
+ const spec = declareWorkflow({ name: "implement-test" });
180
+ client.implementWorkflow(spec, ({ input }) => {
181
+ return { received: input };
182
+ });
183
+
184
+ const handle = await client.runWorkflow(spec, { data: 42 });
185
+ const worker = client.newWorker();
186
+ await worker.tick();
187
+ await sleep(100); // wait for background execution
188
+
189
+ const result = await handle.result();
190
+ expect(result).toEqual({ received: { data: 42 } });
191
+ });
192
+
193
+ test("implementWorkflow throws when workflow is already registered", async () => {
194
+ const client = new OpenWorkflow({ backend });
195
+
196
+ const spec = declareWorkflow({ name: "duplicate-test" });
197
+ client.implementWorkflow(spec, noopFn);
198
+
199
+ expect(() => {
200
+ client.implementWorkflow(spec, noopFn);
201
+ }).toThrow('Workflow "duplicate-test" is already registered');
202
+ });
203
+
204
+ test("implementWorkflow allows registering different versions of the same workflow", async () => {
205
+ const client = new OpenWorkflow({ backend });
206
+
207
+ const specV1 = declareWorkflow({
208
+ name: "multi-version",
209
+ version: "v1",
210
+ });
211
+ const specV2 = declareWorkflow({
212
+ name: "multi-version",
213
+ version: "v2",
214
+ });
215
+
216
+ // no throwing...
217
+ client.implementWorkflow(specV1, noopFn);
218
+ client.implementWorkflow(specV2, noopFn);
219
+ });
220
+
221
+ test("implementWorkflow throws for same name+version combination", async () => {
222
+ const client = new OpenWorkflow({ backend });
223
+
224
+ const spec1 = declareWorkflow({
225
+ name: "version-duplicate",
226
+ version: "v1",
227
+ });
228
+ const spec2 = declareWorkflow({
229
+ name: "version-duplicate",
230
+ version: "v1",
231
+ });
232
+
233
+ client.implementWorkflow(spec1, noopFn);
234
+
235
+ expect(() => {
236
+ client.implementWorkflow(spec2, noopFn);
237
+ }).toThrow('Workflow "version-duplicate" (version: v1) is already registered');
238
+ });
239
+
240
+ test("declareWorkflow with schema validates input on runWorkflow", async () => {
241
+ const client = new OpenWorkflow({ backend });
242
+
243
+ const schema = z.object({
244
+ email: z.email(),
245
+ });
246
+ const spec = declareWorkflow({
247
+ name: "declare-schema-test",
248
+ schema,
249
+ });
250
+
251
+ const handle = await client.runWorkflow(spec, {
252
+ email: "test@example.com",
253
+ });
254
+ await handle.cancel();
255
+
256
+ await expect(client.runWorkflow(spec, { email: "not-an-email" })).rejects.toThrow();
257
+ });
258
+
259
+ test("declareWorkflow with version sets version on workflow run", async () => {
260
+ const client = new OpenWorkflow({ backend });
261
+
262
+ const spec = declareWorkflow({
263
+ name: "declare-version-test",
264
+ version: "v1.2.3",
265
+ });
266
+
267
+ const handle = await client.runWorkflow(spec);
268
+ expect(handle.workflowRun.version).toBe("v1.2.3");
269
+
270
+ await handle.cancel();
271
+ });
272
+
273
+ test("defineWorkflow wraps declareWorkflow and implementWorkflow", async () => {
274
+ const client = new OpenWorkflow({ backend });
275
+
276
+ const workflow = client.defineWorkflow({ name: "define-wrap-test" }, ({ input }) => ({
277
+ doubled: (input as { n: number }).n * 2,
278
+ }));
279
+
280
+ const handle = await workflow.run({ n: 21 });
281
+ const worker = client.newWorker();
282
+ await worker.tick();
283
+ await sleep(100); // wait for background execution
284
+
285
+ const result = await handle.result();
286
+ expect(result).toEqual({ doubled: 42 });
287
+ });
288
+ });
289
+ });
290
+
291
+ function sleep(ms: number): Promise<void> {
292
+ return new Promise((resolve) => setTimeout(resolve, ms));
293
+ }
294
+
295
+ async function noopFn() {
296
+ // no-op
297
+ }
package/src/client.ts ADDED
@@ -0,0 +1,331 @@
1
+ import type { Backend } from "./backend";
2
+ import type { StandardSchemaV1 } from "./core/schema";
3
+ import type { SchemaInput, SchemaOutput, WorkflowRun } from "./core/workflow";
4
+ import { validateInput } from "./core/workflow";
5
+ import type { WorkflowFunction } from "./execution";
6
+ import { WorkflowRegistry } from "./registry";
7
+ import { Worker, type WorkerOptions } from "./worker";
8
+ import { defineWorkflow, defineWorkflowSpec, type Workflow, type WorkflowSpec } from "./workflow";
9
+
10
+ const DEFAULT_RESULT_POLL_INTERVAL_MS = 1000; // 1s
11
+ const DEFAULT_RESULT_TIMEOUT_MS = 5 * 60 * 1000; // 5m
12
+
13
+ /* The data the worker function receives (after transformation). */
14
+ type WorkflowHandlerInput<TSchema, Input> = SchemaOutput<TSchema, Input>;
15
+
16
+ /* The data the client sends (before transformation) */
17
+ type WorkflowRunInput<TSchema, Input> = SchemaInput<TSchema, Input>;
18
+
19
+ /**
20
+ * Options for the OpenWorkflow client.
21
+ */
22
+ export interface OpenWorkflowOptions {
23
+ backend: Backend;
24
+ }
25
+
26
+ /**
27
+ * Client used to register workflows and start runs.
28
+ */
29
+ export class OpenWorkflow {
30
+ private backend: Backend;
31
+ private registry = new WorkflowRegistry();
32
+
33
+ constructor(options: OpenWorkflowOptions) {
34
+ this.backend = options.backend;
35
+ }
36
+
37
+ /**
38
+ * Create a new Worker with this client's backend and workflows.
39
+ * @param options - Worker options
40
+ * @param options.concurrency - Max concurrent workflow runs
41
+ * @returns Worker instance
42
+ */
43
+ newWorker(options?: {
44
+ concurrency?: number | undefined;
45
+ usePubSub?: boolean;
46
+ listenDelay?: number;
47
+ }): Worker {
48
+ return new Worker({
49
+ backend: this.backend,
50
+ registry: this.registry,
51
+ concurrency: options?.concurrency,
52
+ usePubSub: options?.usePubSub,
53
+ listenDelay: options?.listenDelay,
54
+ } satisfies WorkerOptions);
55
+ }
56
+
57
+ /**
58
+ * Provide the implementation for a declared workflow. This links the workflow
59
+ * specification to its execution logic and registers it with this
60
+ * OpenWorkflow instance for worker execution.
61
+ * @param spec - Workflow spec
62
+ * @param fn - Workflow implementation
63
+ */
64
+ implementWorkflow<Input, Output, RunInput = Input>(
65
+ spec: WorkflowSpec<Input, Output, RunInput>,
66
+ fn: WorkflowFunction<Input, Output>,
67
+ ): void {
68
+ const workflow: Workflow<Input, Output, RunInput> = { spec, fn };
69
+ this.registry.register(workflow as Workflow<unknown, unknown, unknown>);
70
+ }
71
+
72
+ /**
73
+ * Run a workflow from its specification. This is the primary way to schedule
74
+ * a workflow using only its WorkflowSpec.
75
+ * @param spec - Workflow spec
76
+ * @param input - Workflow input
77
+ * @param options - Run options
78
+ * @returns Handle for awaiting the result
79
+ * @example
80
+ * ```ts
81
+ * const handle = await ow.runWorkflow(emailWorkflow, { to: 'user@example.com' });
82
+ * const result = await handle.result();
83
+ * ```
84
+ */
85
+ async runWorkflow<Input, Output, RunInput = Input>(
86
+ spec: WorkflowSpec<Input, Output, RunInput>,
87
+ input?: RunInput,
88
+ options?: WorkflowRunOptions,
89
+ ): Promise<WorkflowRunHandle<Output>> {
90
+ const validationResult = await validateInput(spec.schema, input);
91
+ if (!validationResult.success) {
92
+ throw new Error(validationResult.error);
93
+ }
94
+ const parsedInput = validationResult.value;
95
+ const workflowRun = await this.backend.createWorkflowRun({
96
+ workflowName: spec.name,
97
+ version: spec.version ?? null,
98
+ idempotencyKey: null,
99
+ config: {},
100
+ context: null,
101
+ input: parsedInput ?? null,
102
+ availableAt: null,
103
+ deadlineAt: options?.deadlineAt ?? null,
104
+ });
105
+
106
+ if (options?.publishToChannel) {
107
+ await this.backend.publish(workflowRun.id);
108
+ }
109
+
110
+ return new WorkflowRunHandle<Output>({
111
+ backend: this.backend,
112
+ workflowRun: workflowRun,
113
+ resultPollIntervalMs: DEFAULT_RESULT_POLL_INTERVAL_MS,
114
+ resultTimeoutMs: DEFAULT_RESULT_TIMEOUT_MS,
115
+ });
116
+ }
117
+
118
+ /**
119
+ * Define and register a new workflow.
120
+ *
121
+ * This is a convenience method that combines `declareWorkflow` and
122
+ * `implementWorkflow` into a single call. For better code splitting and to
123
+ * separate declaration from implementation, consider using those methods
124
+ * separately.
125
+ * @param config - Workflow config
126
+ * @param fn - Workflow implementation
127
+ * @returns Runnable workflow
128
+ * @example
129
+ * ```ts
130
+ * const workflow = ow.defineWorkflow(
131
+ * { name: 'my-workflow' },
132
+ * async ({ input, step }) => {
133
+ * // workflow implementation
134
+ * },
135
+ * );
136
+ * ```
137
+ */
138
+ defineWorkflow<Input, Output, TSchema extends StandardSchemaV1 | undefined = undefined>(
139
+ spec: WorkflowSpec<
140
+ WorkflowHandlerInput<TSchema, Input>,
141
+ Output,
142
+ WorkflowRunInput<TSchema, Input>
143
+ >,
144
+ fn: WorkflowFunction<WorkflowHandlerInput<TSchema, Input>, Output>,
145
+ ): RunnableWorkflow<
146
+ WorkflowHandlerInput<TSchema, Input>,
147
+ Output,
148
+ WorkflowRunInput<TSchema, Input>
149
+ > {
150
+ const workflow = defineWorkflow(spec, fn);
151
+ this.registry.register(workflow as Workflow<unknown, unknown, unknown>);
152
+ return new RunnableWorkflow(this, workflow);
153
+ }
154
+
155
+ /**
156
+ * Unregister a workflow from the registry.
157
+ * @param name - The workflow name
158
+ * @param version - The workflow version (null for unversioned)
159
+ * @example
160
+ * ```ts
161
+ * ow.unregisterWorkflow("my-workflow", "v1");
162
+ * ```
163
+ */
164
+ unregisterWorkflow(name: string, version: string | null): void {
165
+ this.registry.remove(name, version);
166
+ }
167
+
168
+ /**
169
+ * Check if a workflow is registered in the registry.
170
+ * @param name - The workflow name
171
+ * @param version - The workflow version (null for unversioned)
172
+ * @returns True if the workflow is registered, false otherwise
173
+ * @example
174
+ * ```ts
175
+ * ow.isWorkflowRegistered("my-workflow", "v1");
176
+ * ```
177
+ */
178
+ isWorkflowRegistered(name: string, version: string | null): boolean {
179
+ return this.registry.has(name, version);
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Declare a workflow without providing its implementation (which is provided
185
+ * separately via `implementWorkflow`). Returns a lightweight WorkflowSpec
186
+ * that can be used to schedule workflow runs.
187
+ * @param config - Workflow config
188
+ * @param spec - Workflow spec
189
+ * @returns Workflow spec
190
+ * @example
191
+ * ```ts
192
+ * export const emailWorkflow = declareWorkflow({
193
+ * name: 'send-email',
194
+ * schema: z.object({ to: z.string().email() }),
195
+ * });
196
+ * ```
197
+ */
198
+ // kept for backwards compatibility, to be deprecated
199
+ // eslint-disable-next-line unicorn/prefer-export-from
200
+ export const declareWorkflow = defineWorkflowSpec;
201
+
202
+ //
203
+ // --- Workflow Definition
204
+ //
205
+
206
+ /**
207
+ * A fully defined workflow with its implementation. This class is returned by
208
+ * `defineWorkflow` and provides the `.run()` method for scheduling workflow
209
+ * runs.
210
+ */
211
+ export class RunnableWorkflow<Input, Output, RunInput = Input> {
212
+ private readonly ow: OpenWorkflow;
213
+ readonly workflow: Workflow<Input, Output, RunInput>;
214
+
215
+ constructor(ow: OpenWorkflow, workflow: Workflow<Input, Output, RunInput>) {
216
+ this.ow = ow;
217
+ this.workflow = workflow;
218
+ }
219
+
220
+ /**
221
+ * Starts a new workflow run.
222
+ * @param input - Workflow input
223
+ * @param options - Run options
224
+ * @returns Workflow run handle
225
+ */
226
+ async run(input?: RunInput, options?: WorkflowRunOptions): Promise<WorkflowRunHandle<Output>> {
227
+ return this.ow.runWorkflow(this.workflow.spec, input, options);
228
+ }
229
+ }
230
+
231
+ //
232
+ // --- Workflow Run
233
+ //
234
+
235
+ /**
236
+ * Options for creating a new workflow run from a runnable workflow when calling
237
+ * `workflow.run()`.
238
+ */
239
+ export interface WorkflowRunOptions {
240
+ /**
241
+ * Set a deadline for the workflow run. If the workflow exceeds this deadline,
242
+ * it will be marked as failed.
243
+ */
244
+ deadlineAt?: Date;
245
+
246
+ /**
247
+ * Publish when the workflow run is created to the channel.
248
+ * Default: true
249
+ */
250
+ publishToChannel?: boolean;
251
+ }
252
+
253
+ /**
254
+ * Options for WorkflowHandle.
255
+ */
256
+ export interface WorkflowHandleOptions {
257
+ backend: Backend;
258
+ workflowRun: WorkflowRun;
259
+ resultPollIntervalMs: number;
260
+ resultTimeoutMs: number;
261
+ }
262
+
263
+ /**
264
+ * Represents a started workflow run and provides methods to await its result.
265
+ * Returned from `workflowDef.run()`.
266
+ */
267
+ export class WorkflowRunHandle<Output> {
268
+ private backend: Backend;
269
+ readonly workflowRun: WorkflowRun;
270
+ private resultPollIntervalMs: number;
271
+ private resultTimeoutMs: number;
272
+
273
+ constructor(options: WorkflowHandleOptions) {
274
+ this.backend = options.backend;
275
+ this.workflowRun = options.workflowRun;
276
+ this.resultPollIntervalMs = options.resultPollIntervalMs;
277
+ this.resultTimeoutMs = options.resultTimeoutMs;
278
+ }
279
+
280
+ /**
281
+ * Waits for the workflow run to complete and returns the result.
282
+ * @returns Workflow output
283
+ */
284
+ async result(): Promise<Output> {
285
+ const start = Date.now();
286
+
287
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
288
+ while (true) {
289
+ const latest = await this.backend.getWorkflowRun({
290
+ workflowRunId: this.workflowRun.id,
291
+ });
292
+
293
+ if (!latest) {
294
+ throw new Error(`Workflow run ${this.workflowRun.id} no longer exists`);
295
+ }
296
+
297
+ // 'succeeded' status is deprecated
298
+ if (latest.status === "succeeded" || latest.status === "completed") {
299
+ return latest.output as Output;
300
+ }
301
+
302
+ if (latest.status === "failed") {
303
+ throw new Error(
304
+ `Workflow ${this.workflowRun.workflowName} failed: ${JSON.stringify(latest.error)}`,
305
+ );
306
+ }
307
+
308
+ if (latest.status === "canceled") {
309
+ throw new Error(`Workflow ${this.workflowRun.workflowName} was canceled`);
310
+ }
311
+
312
+ if (Date.now() - start > this.resultTimeoutMs) {
313
+ throw new Error(`Timed out waiting for workflow run ${this.workflowRun.id} to finish`);
314
+ }
315
+
316
+ await new Promise((resolve) => {
317
+ setTimeout(resolve, this.resultPollIntervalMs);
318
+ });
319
+ }
320
+ }
321
+
322
+ /**
323
+ * Cancels the workflow run. Only workflows in pending, running, or sleeping
324
+ * status can be canceled.
325
+ */
326
+ async cancel(): Promise<void> {
327
+ await this.backend.cancelWorkflowRun({
328
+ workflowRunId: this.workflowRun.id,
329
+ });
330
+ }
331
+ }
@@ -0,0 +1,23 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { defineConfig } from "./config";
3
+ import { createBackend } from "./testing/connection";
4
+
5
+ describe("defineConfig", async () => {
6
+ const backend = await createBackend();
7
+
8
+ test("returns the same config", () => {
9
+ const config = { backend };
10
+ const result = defineConfig(config);
11
+ expect(result).toBe(config);
12
+ });
13
+ });
14
+
15
+ describe("loadConfig", () => {
16
+ test("loads config file in the specified directory", async () => {
17
+ const { loadConfig } = await import("./config");
18
+ const { config, configFile } = await loadConfig("./templates");
19
+ expect(config).toBeDefined();
20
+ expect(config.backend).toBeDefined();
21
+ expect(configFile).toContain("/templates/openworkflow.config.ts");
22
+ });
23
+ });
package/src/config.ts ADDED
@@ -0,0 +1,35 @@
1
+ import { loadConfig as loadC12Config } from "c12";
2
+ import type { Backend } from "./backend";
3
+ import type { WorkerOptions } from "./worker";
4
+
5
+ export interface OpenWorkflowConfig {
6
+ backend: Backend;
7
+ worker?: WorkerConfig;
8
+ }
9
+
10
+ export type WorkerConfig = Pick<WorkerOptions, "concurrency">;
11
+
12
+ /**
13
+ * Create a typed OpenWorkflow configuration.
14
+ * @param config - the config
15
+ * @returns the config
16
+ */
17
+ export function defineConfig(config: OpenWorkflowConfig): OpenWorkflowConfig {
18
+ return config;
19
+ }
20
+
21
+ /**
22
+ * Load the OpenWorkflow config at openworkflow.config.ts (or other extension;
23
+ * see https://github.com/unjs/c12)
24
+ * @param rootDir - Optional root directory to search from (defaults to
25
+ * process.cwd())
26
+ * @returns The loaded configuration and metadata
27
+ */
28
+ export async function loadConfig(rootDir?: string) {
29
+ const cwd = rootDir ?? process.cwd();
30
+
31
+ return await loadC12Config<OpenWorkflowConfig>({
32
+ cwd,
33
+ name: "openworkflow",
34
+ });
35
+ }