ai-workflows 2.1.3 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +8 -1
  3. package/README.md +2 -0
  4. package/dist/barrier.d.ts +6 -0
  5. package/dist/barrier.d.ts.map +1 -1
  6. package/dist/barrier.js +45 -7
  7. package/dist/barrier.js.map +1 -1
  8. package/dist/cascade-context.d.ts.map +1 -1
  9. package/dist/cascade-context.js +25 -25
  10. package/dist/cascade-context.js.map +1 -1
  11. package/dist/cascade-executor.d.ts.map +1 -1
  12. package/dist/cascade-executor.js +1 -1
  13. package/dist/cascade-executor.js.map +1 -1
  14. package/dist/context.d.ts.map +1 -1
  15. package/dist/context.js +23 -7
  16. package/dist/context.js.map +1 -1
  17. package/dist/cron-parser.d.ts +65 -0
  18. package/dist/cron-parser.d.ts.map +1 -0
  19. package/dist/cron-parser.js +294 -0
  20. package/dist/cron-parser.js.map +1 -0
  21. package/dist/cron-scheduler.d.ts +117 -0
  22. package/dist/cron-scheduler.d.ts.map +1 -0
  23. package/dist/cron-scheduler.js +176 -0
  24. package/dist/cron-scheduler.js.map +1 -0
  25. package/dist/database-context.d.ts +184 -0
  26. package/dist/database-context.d.ts.map +1 -0
  27. package/dist/database-context.js +428 -0
  28. package/dist/database-context.js.map +1 -0
  29. package/dist/digital-objects-adapter.d.ts +159 -0
  30. package/dist/digital-objects-adapter.d.ts.map +1 -0
  31. package/dist/digital-objects-adapter.js +229 -0
  32. package/dist/digital-objects-adapter.js.map +1 -0
  33. package/dist/durable-execution-cloudflare.d.ts +427 -0
  34. package/dist/durable-execution-cloudflare.d.ts.map +1 -0
  35. package/dist/durable-execution-cloudflare.js +510 -0
  36. package/dist/durable-execution-cloudflare.js.map +1 -0
  37. package/dist/durable-execution.d.ts +482 -0
  38. package/dist/durable-execution.d.ts.map +1 -0
  39. package/dist/durable-execution.js +594 -0
  40. package/dist/durable-execution.js.map +1 -0
  41. package/dist/durable-workflow.d.ts +176 -0
  42. package/dist/durable-workflow.d.ts.map +1 -0
  43. package/dist/durable-workflow.js +552 -0
  44. package/dist/durable-workflow.js.map +1 -0
  45. package/dist/graph/topological-sort.d.ts.map +1 -1
  46. package/dist/graph/topological-sort.js +5 -5
  47. package/dist/graph/topological-sort.js.map +1 -1
  48. package/dist/index.d.ts +4 -0
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +15 -0
  51. package/dist/index.js.map +1 -1
  52. package/dist/logger.d.ts +101 -0
  53. package/dist/logger.d.ts.map +1 -0
  54. package/dist/logger.js +115 -0
  55. package/dist/logger.js.map +1 -0
  56. package/dist/on.d.ts.map +1 -1
  57. package/dist/on.js +3 -3
  58. package/dist/on.js.map +1 -1
  59. package/dist/runtime.d.ts +169 -0
  60. package/dist/runtime.d.ts.map +1 -0
  61. package/dist/runtime.js +275 -0
  62. package/dist/runtime.js.map +1 -0
  63. package/dist/send.d.ts.map +1 -1
  64. package/dist/send.js +4 -3
  65. package/dist/send.js.map +1 -1
  66. package/dist/telemetry.d.ts +150 -0
  67. package/dist/telemetry.d.ts.map +1 -0
  68. package/dist/telemetry.js +388 -0
  69. package/dist/telemetry.js.map +1 -0
  70. package/dist/timer-registry.d.ts +25 -0
  71. package/dist/timer-registry.d.ts.map +1 -1
  72. package/dist/timer-registry.js +42 -8
  73. package/dist/timer-registry.js.map +1 -1
  74. package/dist/types.d.ts +17 -6
  75. package/dist/types.d.ts.map +1 -1
  76. package/dist/types.js +1 -1
  77. package/dist/types.js.map +1 -1
  78. package/dist/worker/durable-step.d.ts +481 -0
  79. package/dist/worker/durable-step.d.ts.map +1 -0
  80. package/dist/worker/durable-step.js +606 -0
  81. package/dist/worker/durable-step.js.map +1 -0
  82. package/dist/worker/index.d.ts +106 -0
  83. package/dist/worker/index.d.ts.map +1 -0
  84. package/dist/worker/index.js +124 -0
  85. package/dist/worker/index.js.map +1 -0
  86. package/dist/worker/state-adapter.d.ts +230 -0
  87. package/dist/worker/state-adapter.d.ts.map +1 -0
  88. package/dist/worker/state-adapter.js +409 -0
  89. package/dist/worker/state-adapter.js.map +1 -0
  90. package/dist/worker/topological-executor.d.ts +282 -0
  91. package/dist/worker/topological-executor.d.ts.map +1 -0
  92. package/dist/worker/topological-executor.js +396 -0
  93. package/dist/worker/topological-executor.js.map +1 -0
  94. package/dist/worker/workflow-builder.d.ts +286 -0
  95. package/dist/worker/workflow-builder.d.ts.map +1 -0
  96. package/dist/worker/workflow-builder.js +565 -0
  97. package/dist/worker/workflow-builder.js.map +1 -0
  98. package/dist/worker.d.ts +800 -0
  99. package/dist/worker.d.ts.map +1 -0
  100. package/dist/worker.js +2428 -0
  101. package/dist/worker.js.map +1 -0
  102. package/dist/workflow-builder.d.ts +287 -0
  103. package/dist/workflow-builder.d.ts.map +1 -0
  104. package/dist/workflow-builder.js +762 -0
  105. package/dist/workflow-builder.js.map +1 -0
  106. package/dist/workflow.d.ts +14 -30
  107. package/dist/workflow.d.ts.map +1 -1
  108. package/dist/workflow.js +132 -292
  109. package/dist/workflow.js.map +1 -1
  110. package/examples/01-ecommerce-order-pipeline.ts +358 -0
  111. package/examples/02-content-moderation-cascade.ts +454 -0
  112. package/examples/03-scheduled-reporting-dependencies.ts +479 -0
  113. package/examples/04-database-persistence.ts +518 -0
  114. package/examples/README.md +173 -0
  115. package/package.json +30 -13
  116. package/src/__tests__/digital-objects-adapter.test.ts +274 -0
  117. package/src/__tests__/durable-workflow.test.ts +297 -0
  118. package/src/barrier.ts +48 -7
  119. package/src/cascade-context.ts +36 -29
  120. package/src/cascade-executor.ts +3 -2
  121. package/src/context.ts +41 -12
  122. package/src/cron-parser.ts +347 -0
  123. package/src/cron-scheduler.ts +239 -0
  124. package/src/database-context.ts +658 -0
  125. package/src/digital-objects-adapter.ts +351 -0
  126. package/src/durable-execution-cloudflare.ts +855 -0
  127. package/src/durable-execution.ts +1042 -0
  128. package/src/durable-workflow.ts +717 -0
  129. package/src/graph/topological-sort.ts +6 -8
  130. package/src/index.ts +69 -0
  131. package/src/logger.ts +148 -0
  132. package/src/on.ts +8 -9
  133. package/src/runtime.ts +436 -0
  134. package/src/send.ts +4 -5
  135. package/src/telemetry.ts +577 -0
  136. package/src/timer-registry.ts +44 -10
  137. package/src/types.ts +32 -17
  138. package/src/worker/durable-step.ts +976 -0
  139. package/src/worker/index.ts +216 -0
  140. package/src/worker/state-adapter.ts +589 -0
  141. package/src/worker/topological-executor.ts +625 -0
  142. package/src/worker/workflow-builder.ts +871 -0
  143. package/src/worker.ts +2906 -0
  144. package/src/workflow-builder.ts +1068 -0
  145. package/src/workflow.ts +188 -351
  146. package/test/barrier-join.test.ts +32 -24
  147. package/test/cascade-executor.test.ts +9 -16
  148. package/test/cron-parser.test.ts +314 -0
  149. package/test/cron-scheduler.test.ts +291 -0
  150. package/test/database-context.test.ts +770 -0
  151. package/test/db-provider-adapter.test.ts +862 -0
  152. package/test/durable-execution-cloudflare.test.ts +606 -0
  153. package/test/durable-execution-in-process.test.ts +286 -0
  154. package/test/durable-execution.test.ts +247 -0
  155. package/test/e2e/workflow-scenarios.e2e.test.ts +1039 -0
  156. package/test/integration.test.ts +442 -0
  157. package/test/rpc-surface.test.ts +946 -0
  158. package/test/runtime.test.ts +262 -0
  159. package/test/schedule-timer-cleanup.test.ts +30 -21
  160. package/test/send-race-conditions.test.ts +30 -40
  161. package/test/worker/durable-cascade.test.ts +1117 -0
  162. package/test/worker/durable-step.test.ts +723 -0
  163. package/test/worker/topological-executor.test.ts +1240 -0
  164. package/test/worker/workflow-builder.test.ts +1067 -0
  165. package/test/worker.test.ts +608 -0
  166. package/test/workflow-builder.test.ts +1670 -0
  167. package/test/workflow-cron.test.ts +256 -0
  168. package/test/workflow-state-adapter.test.ts +923 -0
  169. package/test/workflow.test.ts +25 -22
  170. package/tsconfig.json +3 -1
  171. package/vitest.config.ts +38 -1
  172. package/vitest.workers.config.ts +44 -0
  173. package/wrangler.jsonc +22 -0
  174. package/.turbo/turbo-test.log +0 -169
  175. package/LICENSE +0 -21
  176. package/src/context.js +0 -83
  177. package/src/every.js +0 -267
  178. package/src/index.js +0 -71
  179. package/src/on.js +0 -79
  180. package/src/send.js +0 -111
  181. package/src/types.js +0 -4
  182. package/src/workflow.js +0 -455
  183. package/test/context.test.js +0 -116
  184. package/test/every.test.js +0 -282
  185. package/test/on.test.js +0 -80
  186. package/test/send.test.js +0 -89
  187. package/test/workflow.test.js +0 -224
  188. package/vitest.config.js +0 -7
@@ -0,0 +1,606 @@
1
+ /**
2
+ * Tests for the Cloudflare Workflows DurableExecutionAdapter.
3
+ *
4
+ * The adapter bridges the port (`run`/`step`/`sleep`/`waitForEvent`/`schedule`)
5
+ * to CF's class-based runtime (`WorkflowEntrypoint.run(event, step)` +
6
+ * `step.do`/`step.sleep`/`step.waitForEvent`). These tests do not require a
7
+ * real CF account or Miniflare — they use a structurally-typed fake binding
8
+ * (`WorkflowsBindingLike`) and a fake `WorkflowStepLike` to exercise the
9
+ * bridge logic directly.
10
+ *
11
+ * The structural-fake pattern mirrors `do-sqlite-adapter.test.ts` from
12
+ * `ai-database`: declare the minimal subset of the CF surface as TypeScript
13
+ * interfaces, hand-build a JS object satisfying the interface, and assert
14
+ * against the captured calls.
15
+ */
16
+
17
+ import { describe, expect, it, vi } from 'vitest'
18
+
19
+ import {
20
+ createCloudflareWorkflowsDurableExecution,
21
+ createWorkflowEntrypoint,
22
+ DurableStepError,
23
+ WaitForEventTimeoutError,
24
+ type CloudflareWorkflowsDurableExecution,
25
+ type DurableExecutionAdapter,
26
+ type WorkflowEnvelope,
27
+ type WorkflowEventLike,
28
+ type WorkflowInstanceLike,
29
+ type WorkflowsBindingLike,
30
+ type WorkflowStepLike,
31
+ type WorkflowStepConfigLike,
32
+ } from '../src/durable-execution.js'
33
+
34
+ // =============================================================================
35
+ // Fakes
36
+ // =============================================================================
37
+
38
+ /**
39
+ * Build a fake `Workflow` binding whose `create()` returns an instance whose
40
+ * `status()` cycles through the supplied statuses (last entry sticks). The
41
+ * instance also captures any `params` it was created with for assertions.
42
+ */
43
+ function makeFakeBinding(
44
+ options: {
45
+ statuses?: Array<{
46
+ status: string
47
+ output?: unknown
48
+ error?: { name: string; message: string }
49
+ }>
50
+ } = {}
51
+ ): {
52
+ binding: WorkflowsBindingLike
53
+ created: Array<{ id: string; params: WorkflowEnvelope | undefined }>
54
+ instances: WorkflowInstanceLike[]
55
+ } {
56
+ const created: Array<{ id: string; params: WorkflowEnvelope | undefined }> = []
57
+ const instances: WorkflowInstanceLike[] = []
58
+ let seq = 0
59
+ const baseStatuses = options.statuses ?? [{ status: 'complete', output: 'ok' }]
60
+
61
+ const binding: WorkflowsBindingLike = {
62
+ async create(opts) {
63
+ const id = opts?.id ?? `fake-${++seq}`
64
+ created.push({ id, params: opts?.params as WorkflowEnvelope | undefined })
65
+ let i = 0
66
+ const statusCalls: number[] = []
67
+ const inst: WorkflowInstanceLike = {
68
+ id,
69
+ async status() {
70
+ const idx = Math.min(i++, baseStatuses.length - 1)
71
+ statusCalls.push(idx)
72
+ return baseStatuses[idx]! as Awaited<ReturnType<WorkflowInstanceLike['status']>>
73
+ },
74
+ }
75
+ instances.push(inst)
76
+ return inst
77
+ },
78
+ async get(id: string) {
79
+ const found = instances.find((inst) => inst.id === id)
80
+ if (!found) throw new Error(`unknown instance ${id}`)
81
+ return found
82
+ },
83
+ }
84
+
85
+ return { binding, created, instances }
86
+ }
87
+
88
+ /**
89
+ * Build a fake CF `WorkflowStep` that records every call. `do` invokes the
90
+ * callback synchronously; `sleep`/`sleepUntil` no-op; `waitForEvent` resolves
91
+ * to a configured value or throws if `throwTimeout` is set.
92
+ */
93
+ function makeFakeStep(
94
+ options: {
95
+ eventValues?: Record<string, unknown>
96
+ throwTimeout?: boolean
97
+ } = {}
98
+ ): {
99
+ step: WorkflowStepLike
100
+ doCalls: Array<{ name: string; config?: WorkflowStepConfigLike }>
101
+ sleepCalls: Array<{ name: string; duration: string | number }>
102
+ sleepUntilCalls: Array<{ name: string; timestamp: Date | number }>
103
+ waitCalls: Array<{ name: string; type: string; timeout?: string | number }>
104
+ } {
105
+ const doCalls: Array<{ name: string; config?: WorkflowStepConfigLike }> = []
106
+ const sleepCalls: Array<{ name: string; duration: string | number }> = []
107
+ const sleepUntilCalls: Array<{ name: string; timestamp: Date | number }> = []
108
+ const waitCalls: Array<{ name: string; type: string; timeout?: string | number }> = []
109
+
110
+ const step: WorkflowStepLike = {
111
+ do: (async (
112
+ name: string,
113
+ configOrFn: WorkflowStepConfigLike | (() => Promise<unknown>),
114
+ maybeFn?: () => Promise<unknown>
115
+ ) => {
116
+ if (typeof configOrFn === 'function') {
117
+ doCalls.push({ name })
118
+ return configOrFn()
119
+ }
120
+ doCalls.push({ name, config: configOrFn })
121
+ return maybeFn!()
122
+ }) as WorkflowStepLike['do'],
123
+ async sleep(name, duration) {
124
+ sleepCalls.push({ name, duration })
125
+ },
126
+ async sleepUntil(name, timestamp) {
127
+ sleepUntilCalls.push({ name, timestamp })
128
+ },
129
+ async waitForEvent<T = unknown>(
130
+ name: string,
131
+ opts: { type: string; timeout?: string | number }
132
+ ): Promise<T> {
133
+ const entry: { name: string; type: string; timeout?: string | number } = {
134
+ name,
135
+ type: opts.type,
136
+ }
137
+ if (opts.timeout !== undefined) entry.timeout = opts.timeout
138
+ waitCalls.push(entry)
139
+ if (options.throwTimeout) {
140
+ throw new Error(`timed out waiting for ${opts.type}`)
141
+ }
142
+ const value = options.eventValues?.[opts.type]
143
+ // Match CF's envelope shape; the adapter unwraps `payload`.
144
+ return { payload: value, type: opts.type, timestamp: new Date() } as unknown as T
145
+ },
146
+ }
147
+
148
+ return { step, doCalls, sleepCalls, sleepUntilCalls, waitCalls }
149
+ }
150
+
151
+ // =============================================================================
152
+ // Tests
153
+ // =============================================================================
154
+
155
+ describe('createCloudflareWorkflowsDurableExecution', () => {
156
+ it('exposes the cloudflare kind discriminant', () => {
157
+ const { binding } = makeFakeBinding()
158
+ const dx = createCloudflareWorkflowsDurableExecution({ binding })
159
+ expect(dx.kind).toBe('cloudflare')
160
+ })
161
+
162
+ it('declares the documented limits (25K steps, 50K concurrent, 365 days, 1 MiB)', () => {
163
+ const { binding } = makeFakeBinding()
164
+ const dx = createCloudflareWorkflowsDurableExecution({ binding })
165
+ expect(dx.limits).toEqual({
166
+ maxSteps: 25_000,
167
+ maxConcurrentInstances: 50_000,
168
+ maxSleepDays: 365,
169
+ maxPayloadBytes: 1_048_576,
170
+ })
171
+ })
172
+
173
+ it('satisfies the DurableExecutionAdapter port', () => {
174
+ const { binding } = makeFakeBinding()
175
+ const adapter: DurableExecutionAdapter = createCloudflareWorkflowsDurableExecution({ binding })
176
+ expect(adapter.kind).toBe('cloudflare')
177
+ })
178
+
179
+ it('accepts a thunk for the binding so per-request env access works', async () => {
180
+ const { binding, created } = makeFakeBinding()
181
+ const thunk = vi.fn(() => binding)
182
+ const dx = createCloudflareWorkflowsDurableExecution({ binding: thunk })
183
+ dx.register('echo', async (ctx) => ctx.input)
184
+ await dx.run('echo', async (ctx) => ctx.input, { hello: 'world' })
185
+ expect(thunk).toHaveBeenCalled()
186
+ expect(created).toHaveLength(1)
187
+ })
188
+
189
+ describe('run() — triggering through the binding', () => {
190
+ it('creates an instance via binding.create with the wf-name/input envelope', async () => {
191
+ const { binding, created } = makeFakeBinding()
192
+ const dx = createCloudflareWorkflowsDurableExecution({ binding })
193
+ await dx.run('cascade', async (ctx) => ctx.input, { customerId: 'c-1' })
194
+ expect(created).toHaveLength(1)
195
+ expect(created[0]!.params).toEqual({
196
+ __wfName: 'cascade',
197
+ __wfInput: { customerId: 'c-1' },
198
+ })
199
+ })
200
+
201
+ it('returns the workflow output when status reaches complete', async () => {
202
+ const { binding } = makeFakeBinding({
203
+ statuses: [{ status: 'complete', output: 42 }],
204
+ })
205
+ const dx = createCloudflareWorkflowsDurableExecution({
206
+ binding,
207
+ delay: async () => {},
208
+ })
209
+ const result = await dx.run('compute', async () => 42, undefined)
210
+ expect(result).toBe(42)
211
+ })
212
+
213
+ it('polls until the instance terminates', async () => {
214
+ const { binding } = makeFakeBinding({
215
+ statuses: [
216
+ { status: 'queued' },
217
+ { status: 'running' },
218
+ { status: 'waiting' },
219
+ { status: 'complete', output: 'done' },
220
+ ],
221
+ })
222
+ const dx = createCloudflareWorkflowsDurableExecution({
223
+ binding,
224
+ pollIntervalMs: 1,
225
+ delay: async () => {},
226
+ })
227
+ const result = await dx.run('slow', async () => 'done', undefined)
228
+ expect(result).toBe('done')
229
+ })
230
+
231
+ it('throws DurableStepError with retryable=false when status is errored', async () => {
232
+ const { binding } = makeFakeBinding({
233
+ statuses: [{ status: 'errored', error: { name: 'BoomError', message: 'kaboom' } }],
234
+ })
235
+ const dx = createCloudflareWorkflowsDurableExecution({
236
+ binding,
237
+ delay: async () => {},
238
+ })
239
+ await expect(dx.run('bad', async () => 'unused', undefined)).rejects.toMatchObject({
240
+ name: 'DurableStepError',
241
+ retryable: false,
242
+ })
243
+ })
244
+
245
+ it('throws DurableStepError when polling exceeds pollTimeoutMs', async () => {
246
+ const { binding } = makeFakeBinding({
247
+ statuses: [{ status: 'running' }],
248
+ })
249
+ const dx = createCloudflareWorkflowsDurableExecution({
250
+ binding,
251
+ pollIntervalMs: 1,
252
+ pollTimeoutMs: 0, // immediate timeout after first poll
253
+ delay: async () => {},
254
+ })
255
+ await expect(dx.run('hang', async () => 'unused', undefined)).rejects.toBeInstanceOf(
256
+ DurableStepError
257
+ )
258
+ })
259
+
260
+ it('returns the instance handle when waitForCompletion is false', async () => {
261
+ const { binding } = makeFakeBinding()
262
+ const dx = createCloudflareWorkflowsDurableExecution({
263
+ binding,
264
+ waitForCompletion: false,
265
+ })
266
+ const result = (await dx.run('fire', async () => 'unused', undefined)) as unknown
267
+ expect(result).toBeDefined()
268
+ expect(typeof (result as WorkflowInstanceLike).id).toBe('string')
269
+ })
270
+
271
+ it('implicitly registers the workflow body the first time run is called', async () => {
272
+ const { binding } = makeFakeBinding()
273
+ const dx = createCloudflareWorkflowsDurableExecution({
274
+ binding,
275
+ delay: async () => {},
276
+ })
277
+ const fn = vi.fn(async () => 'ok')
278
+ await dx.run('implicit', fn, undefined)
279
+ // The body is then dispatchable through the entrypoint handler:
280
+ const { step } = makeFakeStep()
281
+ const event: WorkflowEventLike<WorkflowEnvelope> = {
282
+ payload: { __wfName: 'implicit', __wfInput: undefined },
283
+ instanceId: 'inst-1',
284
+ }
285
+ await dx.entrypointHandler(event, step)
286
+ expect(fn).toHaveBeenCalledTimes(1)
287
+ })
288
+ })
289
+
290
+ describe('entrypointHandler — bridge from CF run(event, step) to the body', () => {
291
+ it('throws when the event payload is missing the __wfName envelope', async () => {
292
+ const { binding } = makeFakeBinding()
293
+ const dx = createCloudflareWorkflowsDurableExecution({ binding })
294
+ const { step } = makeFakeStep()
295
+ await expect(
296
+ dx.entrypointHandler({ payload: { not: 'an envelope' } } as WorkflowEventLike, step)
297
+ ).rejects.toThrow(/missing __wfName/)
298
+ })
299
+
300
+ it('throws when the workflow name is not registered', async () => {
301
+ const { binding } = makeFakeBinding()
302
+ const dx = createCloudflareWorkflowsDurableExecution({ binding })
303
+ const { step } = makeFakeStep()
304
+ await expect(
305
+ dx.entrypointHandler(
306
+ { payload: { __wfName: 'unknown', __wfInput: null } } as WorkflowEventLike,
307
+ step
308
+ )
309
+ ).rejects.toThrow(/no workflow registered for name "unknown"/)
310
+ })
311
+
312
+ it('passes input through to ctx.input', async () => {
313
+ const { binding } = makeFakeBinding()
314
+ const dx = createCloudflareWorkflowsDurableExecution({ binding })
315
+ const fn = vi.fn(async (ctx: { input: { x: number } }) => ctx.input.x * 2)
316
+ dx.register('double', fn as unknown as Parameters<typeof dx.register>[1])
317
+ const { step } = makeFakeStep()
318
+ const result = await dx.entrypointHandler(
319
+ {
320
+ payload: { __wfName: 'double', __wfInput: { x: 21 } },
321
+ instanceId: 'inst-double',
322
+ } as WorkflowEventLike,
323
+ step
324
+ )
325
+ expect(result).toBe(42)
326
+ expect(fn).toHaveBeenCalledOnce()
327
+ })
328
+
329
+ it('supplies ctx.instanceId from the event', async () => {
330
+ const { binding } = makeFakeBinding()
331
+ const dx = createCloudflareWorkflowsDurableExecution({ binding })
332
+ let captured: string | undefined
333
+ dx.register('capture', async (ctx) => {
334
+ captured = ctx.instanceId
335
+ })
336
+ const { step } = makeFakeStep()
337
+ await dx.entrypointHandler(
338
+ {
339
+ payload: { __wfName: 'capture', __wfInput: null },
340
+ instanceId: 'inst-xyz',
341
+ } as WorkflowEventLike,
342
+ step
343
+ )
344
+ expect(captured).toBe('inst-xyz')
345
+ })
346
+ })
347
+
348
+ describe('ctx.step — translates to step.do', () => {
349
+ it('forwards step name and callback to step.do', async () => {
350
+ const { binding } = makeFakeBinding()
351
+ const dx = createCloudflareWorkflowsDurableExecution({ binding })
352
+ const fn = vi.fn(async () => 'value')
353
+ dx.register('uses-step', async (ctx) => ctx.step('compute', fn))
354
+ const { step, doCalls } = makeFakeStep()
355
+ const result = await dx.entrypointHandler(
356
+ { payload: { __wfName: 'uses-step', __wfInput: null } } as WorkflowEventLike,
357
+ step
358
+ )
359
+ expect(result).toBe('value')
360
+ expect(doCalls).toEqual([{ name: 'compute' }])
361
+ expect(fn).toHaveBeenCalledOnce()
362
+ })
363
+
364
+ it('forwards step config to step.do(name, config, fn)', async () => {
365
+ const { binding } = makeFakeBinding()
366
+ const dx = createCloudflareWorkflowsDurableExecution({ binding })
367
+ dx.register('cfg-step', async (ctx) =>
368
+ ctx.step(
369
+ 'flaky',
370
+ { retries: { limit: 3, delay: '1 second', backoff: 'exponential' } },
371
+ async () => 'ok'
372
+ )
373
+ )
374
+ const { step, doCalls } = makeFakeStep()
375
+ await dx.entrypointHandler(
376
+ { payload: { __wfName: 'cfg-step', __wfInput: null } } as WorkflowEventLike,
377
+ step
378
+ )
379
+ expect(doCalls).toHaveLength(1)
380
+ expect(doCalls[0]!.name).toBe('flaky')
381
+ expect(doCalls[0]!.config).toEqual({
382
+ retries: { limit: 3, delay: '1 second', backoff: 'exponential' },
383
+ })
384
+ })
385
+ })
386
+
387
+ describe('ctx.sleep / ctx.sleepUntil — translate to step.sleep/sleepUntil', () => {
388
+ it('synthesises stable, deterministic step names for sleeps', async () => {
389
+ const { binding } = makeFakeBinding()
390
+ const dx = createCloudflareWorkflowsDurableExecution({ binding })
391
+ dx.register('sleeper', async (ctx) => {
392
+ await ctx.sleep('1 second')
393
+ await ctx.sleep(500)
394
+ await ctx.sleep('1 minute')
395
+ })
396
+ const { step, sleepCalls } = makeFakeStep()
397
+ await dx.entrypointHandler(
398
+ { payload: { __wfName: 'sleeper', __wfInput: null } } as WorkflowEventLike,
399
+ step
400
+ )
401
+ expect(sleepCalls).toEqual([
402
+ { name: '__sleep__1', duration: '1 second' },
403
+ { name: '__sleep__2', duration: 500 },
404
+ { name: '__sleep__3', duration: '1 minute' },
405
+ ])
406
+ })
407
+
408
+ it('forwards sleepUntil with auto-named steps', async () => {
409
+ const { binding } = makeFakeBinding()
410
+ const dx = createCloudflareWorkflowsDurableExecution({ binding })
411
+ const date = new Date('2030-01-01T00:00:00.000Z')
412
+ dx.register('until', async (ctx) => {
413
+ await ctx.sleepUntil(date)
414
+ })
415
+ const { step, sleepUntilCalls } = makeFakeStep()
416
+ await dx.entrypointHandler(
417
+ { payload: { __wfName: 'until', __wfInput: null } } as WorkflowEventLike,
418
+ step
419
+ )
420
+ expect(sleepUntilCalls).toEqual([{ name: '__sleepUntil__1', timestamp: date }])
421
+ })
422
+ })
423
+
424
+ describe('ctx.waitForEvent — translates to step.waitForEvent', () => {
425
+ it('forwards type and timeout to step.waitForEvent', async () => {
426
+ const { binding } = makeFakeBinding()
427
+ const dx = createCloudflareWorkflowsDurableExecution({ binding })
428
+ dx.register('waiter', async (ctx) => {
429
+ const value = await ctx.waitForEvent<string>('Order.placed', '5 minutes')
430
+ return value
431
+ })
432
+ const { step, waitCalls } = makeFakeStep({
433
+ eventValues: { 'Order.placed': 'order-1' },
434
+ })
435
+ const result = await dx.entrypointHandler(
436
+ { payload: { __wfName: 'waiter', __wfInput: null } } as WorkflowEventLike,
437
+ step
438
+ )
439
+ expect(result).toBe('order-1')
440
+ expect(waitCalls).toEqual([
441
+ { name: '__waitForEvent__Order.placed__1', type: 'Order.placed', timeout: '5 minutes' },
442
+ ])
443
+ })
444
+
445
+ it('omits timeout when the caller did not pass one', async () => {
446
+ const { binding } = makeFakeBinding()
447
+ const dx = createCloudflareWorkflowsDurableExecution({ binding })
448
+ dx.register('forever', async (ctx) => ctx.waitForEvent('whenever'))
449
+ const { step, waitCalls } = makeFakeStep({
450
+ eventValues: { whenever: 'eventually' },
451
+ })
452
+ await dx.entrypointHandler(
453
+ { payload: { __wfName: 'forever', __wfInput: null } } as WorkflowEventLike,
454
+ step
455
+ )
456
+ expect(waitCalls).toEqual([{ name: '__waitForEvent__whenever__1', type: 'whenever' }])
457
+ })
458
+
459
+ it('translates CF timeout errors to WaitForEventTimeoutError when timeout was set', async () => {
460
+ const { binding } = makeFakeBinding()
461
+ const dx = createCloudflareWorkflowsDurableExecution({ binding })
462
+ dx.register('times-out', async (ctx) => ctx.waitForEvent('never', '1 second'))
463
+ const { step } = makeFakeStep({ throwTimeout: true })
464
+ await expect(
465
+ dx.entrypointHandler(
466
+ { payload: { __wfName: 'times-out', __wfInput: null } } as WorkflowEventLike,
467
+ step
468
+ )
469
+ ).rejects.toBeInstanceOf(WaitForEventTimeoutError)
470
+ })
471
+ })
472
+
473
+ describe('schedule / defineSchedule / runSchedule', () => {
474
+ it('returns a subscription whose id equals the workflow name', () => {
475
+ const { binding } = makeFakeBinding()
476
+ const dx = createCloudflareWorkflowsDurableExecution({ binding })
477
+ const sub = dx.defineSchedule('nightly', '0 0 * * *', async () => undefined)
478
+ expect(sub.id).toBe('nightly')
479
+ })
480
+
481
+ it('runSchedule triggers the registered body via binding.create', async () => {
482
+ const { binding, created } = makeFakeBinding()
483
+ const dx = createCloudflareWorkflowsDurableExecution({
484
+ binding,
485
+ delay: async () => {},
486
+ })
487
+ const fn = vi.fn(async () => 'scheduled-result')
488
+ dx.defineSchedule('nightly', '0 0 * * *', fn)
489
+ const result = await dx.runSchedule('nightly')
490
+ expect(created).toHaveLength(1)
491
+ expect(created[0]!.params).toEqual({
492
+ __wfName: 'nightly',
493
+ __wfInput: undefined,
494
+ })
495
+ // Default polling completes from the default fake binding.
496
+ expect(result).toBe('ok')
497
+ })
498
+
499
+ it('runSchedule throws when the schedule is not registered', async () => {
500
+ const { binding } = makeFakeBinding()
501
+ const dx = createCloudflareWorkflowsDurableExecution({ binding })
502
+ await expect(dx.runSchedule('missing')).rejects.toThrow(/no schedule registered/)
503
+ })
504
+
505
+ it('unsubscribe removes the schedule registration', async () => {
506
+ const { binding } = makeFakeBinding()
507
+ const dx = createCloudflareWorkflowsDurableExecution({ binding })
508
+ const sub = dx.defineSchedule('weekly', '0 0 * * 0', async () => undefined)
509
+ sub.unsubscribe()
510
+ await expect(dx.runSchedule('weekly')).rejects.toThrow(/no schedule registered/)
511
+ })
512
+ })
513
+
514
+ describe('top-level surface (outside a body)', () => {
515
+ it('top-level step() invokes the function once (no memoization, no CF involvement)', async () => {
516
+ const { binding } = makeFakeBinding()
517
+ const dx = createCloudflareWorkflowsDurableExecution({ binding })
518
+ const fn = vi.fn(async () => 'value')
519
+ const result = await dx.step('once', fn)
520
+ expect(result).toBe('value')
521
+ expect(fn).toHaveBeenCalledTimes(1)
522
+ })
523
+
524
+ it('top-level sleep delegates to the configured delay', async () => {
525
+ const { binding } = makeFakeBinding()
526
+ const sleeps: number[] = []
527
+ const dx = createCloudflareWorkflowsDurableExecution({
528
+ binding,
529
+ delay: async (ms) => {
530
+ sleeps.push(ms)
531
+ },
532
+ })
533
+ await dx.sleep('500ms')
534
+ await dx.sleep(250)
535
+ await dx.sleep('1 second')
536
+ expect(sleeps).toEqual([500, 250, 1000])
537
+ })
538
+
539
+ it('top-level waitForEvent rejects with a helpful message', async () => {
540
+ const { binding } = makeFakeBinding()
541
+ const dx = createCloudflareWorkflowsDurableExecution({ binding })
542
+ await expect(dx.waitForEvent('Order.placed')).rejects.toThrow(
543
+ /only supported inside a workflow body/
544
+ )
545
+ })
546
+ })
547
+ })
548
+
549
+ describe('createWorkflowEntrypoint', () => {
550
+ it('returns a constructor whose run() forwards to adapter.entrypointHandler', async () => {
551
+ const { binding } = makeFakeBinding()
552
+ const dx: CloudflareWorkflowsDurableExecution = createCloudflareWorkflowsDurableExecution({
553
+ binding,
554
+ })
555
+ const fn = vi.fn(async (ctx: { input: number }) => ctx.input + 1)
556
+ dx.register('inc', fn as unknown as Parameters<typeof dx.register>[1])
557
+
558
+ const Entry = createWorkflowEntrypoint(dx)
559
+ // CF instantiates with (ctx, env); we pass dummies.
560
+ const instance = new Entry({}, {}) as unknown as {
561
+ run(event: WorkflowEventLike, step: WorkflowStepLike): Promise<unknown>
562
+ }
563
+ const { step } = makeFakeStep()
564
+ const result = await instance.run(
565
+ {
566
+ payload: { __wfName: 'inc', __wfInput: 41 },
567
+ instanceId: 'cf-1',
568
+ } as WorkflowEventLike,
569
+ step
570
+ )
571
+ expect(result).toBe(42)
572
+ expect(fn).toHaveBeenCalledOnce()
573
+ })
574
+
575
+ it('extends a supplied Base class so users can pass cloudflare:workers WorkflowEntrypoint', async () => {
576
+ const { binding } = makeFakeBinding()
577
+ const dx = createCloudflareWorkflowsDurableExecution({ binding })
578
+ dx.register('echo', async (ctx) => ctx.input)
579
+
580
+ // Stand-in for `WorkflowEntrypoint` from `cloudflare:workers`. We capture
581
+ // the constructor args to verify the subclass forwards them.
582
+ const baseConstructorCalls: Array<{ ctx: unknown; env: unknown }> = []
583
+ class FakeBase {
584
+ constructor(ctx: unknown, env: unknown) {
585
+ baseConstructorCalls.push({ ctx, env })
586
+ }
587
+ async run(): Promise<unknown> {
588
+ // The generated subclass overrides this; if our subclass doesn't
589
+ // override, we'd return this sentinel.
590
+ return '<base>'
591
+ }
592
+ }
593
+
594
+ const Entry = createWorkflowEntrypoint(dx, FakeBase as never)
595
+ const inst = new Entry({ ctxMarker: 1 }, { envMarker: 2 }) as unknown as {
596
+ run(event: WorkflowEventLike, step: WorkflowStepLike): Promise<unknown>
597
+ }
598
+ expect(baseConstructorCalls).toEqual([{ ctx: { ctxMarker: 1 }, env: { envMarker: 2 } }])
599
+ const { step } = makeFakeStep()
600
+ const result = await inst.run(
601
+ { payload: { __wfName: 'echo', __wfInput: 'hi' } } as WorkflowEventLike,
602
+ step
603
+ )
604
+ expect(result).toBe('hi')
605
+ })
606
+ })