ai-workflows 2.1.3 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +14 -1
- package/README.md +2 -0
- package/dist/barrier.d.ts +6 -0
- package/dist/barrier.d.ts.map +1 -1
- package/dist/barrier.js +45 -7
- package/dist/barrier.js.map +1 -1
- package/dist/cascade-context.d.ts.map +1 -1
- package/dist/cascade-context.js +25 -25
- package/dist/cascade-context.js.map +1 -1
- package/dist/cascade-executor.d.ts.map +1 -1
- package/dist/cascade-executor.js +1 -1
- package/dist/cascade-executor.js.map +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +23 -7
- package/dist/context.js.map +1 -1
- package/dist/cron-parser.d.ts +65 -0
- package/dist/cron-parser.d.ts.map +1 -0
- package/dist/cron-parser.js +294 -0
- package/dist/cron-parser.js.map +1 -0
- package/dist/cron-scheduler.d.ts +117 -0
- package/dist/cron-scheduler.d.ts.map +1 -0
- package/dist/cron-scheduler.js +176 -0
- package/dist/cron-scheduler.js.map +1 -0
- package/dist/database-context.d.ts +184 -0
- package/dist/database-context.d.ts.map +1 -0
- package/dist/database-context.js +428 -0
- package/dist/database-context.js.map +1 -0
- package/dist/digital-objects-adapter.d.ts +159 -0
- package/dist/digital-objects-adapter.d.ts.map +1 -0
- package/dist/digital-objects-adapter.js +229 -0
- package/dist/digital-objects-adapter.js.map +1 -0
- package/dist/durable-execution-cloudflare.d.ts +427 -0
- package/dist/durable-execution-cloudflare.d.ts.map +1 -0
- package/dist/durable-execution-cloudflare.js +510 -0
- package/dist/durable-execution-cloudflare.js.map +1 -0
- package/dist/durable-execution.d.ts +482 -0
- package/dist/durable-execution.d.ts.map +1 -0
- package/dist/durable-execution.js +594 -0
- package/dist/durable-execution.js.map +1 -0
- package/dist/durable-workflow.d.ts +176 -0
- package/dist/durable-workflow.d.ts.map +1 -0
- package/dist/durable-workflow.js +552 -0
- package/dist/durable-workflow.js.map +1 -0
- package/dist/graph/topological-sort.d.ts.map +1 -1
- package/dist/graph/topological-sort.js +5 -5
- package/dist/graph/topological-sort.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -1
- package/dist/logger.d.ts +101 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +115 -0
- package/dist/logger.js.map +1 -0
- package/dist/on.d.ts.map +1 -1
- package/dist/on.js +3 -3
- package/dist/on.js.map +1 -1
- package/dist/runtime.d.ts +169 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +275 -0
- package/dist/runtime.js.map +1 -0
- package/dist/send.d.ts.map +1 -1
- package/dist/send.js +4 -3
- package/dist/send.js.map +1 -1
- package/dist/telemetry.d.ts +150 -0
- package/dist/telemetry.d.ts.map +1 -0
- package/dist/telemetry.js +388 -0
- package/dist/telemetry.js.map +1 -0
- package/dist/timer-registry.d.ts +25 -0
- package/dist/timer-registry.d.ts.map +1 -1
- package/dist/timer-registry.js +42 -8
- package/dist/timer-registry.js.map +1 -1
- package/dist/types.d.ts +17 -6
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -1
- package/dist/types.js.map +1 -1
- package/dist/worker/durable-step.d.ts +481 -0
- package/dist/worker/durable-step.d.ts.map +1 -0
- package/dist/worker/durable-step.js +606 -0
- package/dist/worker/durable-step.js.map +1 -0
- package/dist/worker/index.d.ts +106 -0
- package/dist/worker/index.d.ts.map +1 -0
- package/dist/worker/index.js +124 -0
- package/dist/worker/index.js.map +1 -0
- package/dist/worker/state-adapter.d.ts +230 -0
- package/dist/worker/state-adapter.d.ts.map +1 -0
- package/dist/worker/state-adapter.js +409 -0
- package/dist/worker/state-adapter.js.map +1 -0
- package/dist/worker/topological-executor.d.ts +282 -0
- package/dist/worker/topological-executor.d.ts.map +1 -0
- package/dist/worker/topological-executor.js +396 -0
- package/dist/worker/topological-executor.js.map +1 -0
- package/dist/worker/workflow-builder.d.ts +286 -0
- package/dist/worker/workflow-builder.d.ts.map +1 -0
- package/dist/worker/workflow-builder.js +565 -0
- package/dist/worker/workflow-builder.js.map +1 -0
- package/dist/worker.d.ts +800 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +2428 -0
- package/dist/worker.js.map +1 -0
- package/dist/workflow-builder.d.ts +287 -0
- package/dist/workflow-builder.d.ts.map +1 -0
- package/dist/workflow-builder.js +762 -0
- package/dist/workflow-builder.js.map +1 -0
- package/dist/workflow.d.ts +14 -30
- package/dist/workflow.d.ts.map +1 -1
- package/dist/workflow.js +132 -292
- package/dist/workflow.js.map +1 -1
- package/examples/01-ecommerce-order-pipeline.ts +358 -0
- package/examples/02-content-moderation-cascade.ts +454 -0
- package/examples/03-scheduled-reporting-dependencies.ts +479 -0
- package/examples/04-database-persistence.ts +518 -0
- package/examples/README.md +173 -0
- package/package.json +30 -13
- package/src/__tests__/digital-objects-adapter.test.ts +274 -0
- package/src/__tests__/durable-workflow.test.ts +297 -0
- package/src/barrier.ts +48 -7
- package/src/cascade-context.ts +36 -29
- package/src/cascade-executor.ts +3 -2
- package/src/context.ts +41 -12
- package/src/cron-parser.ts +347 -0
- package/src/cron-scheduler.ts +239 -0
- package/src/database-context.ts +658 -0
- package/src/digital-objects-adapter.ts +351 -0
- package/src/durable-execution-cloudflare.ts +855 -0
- package/src/durable-execution.ts +1042 -0
- package/src/durable-workflow.ts +717 -0
- package/src/graph/topological-sort.ts +6 -8
- package/src/index.ts +69 -0
- package/src/logger.ts +148 -0
- package/src/on.ts +8 -9
- package/src/runtime.ts +436 -0
- package/src/send.ts +4 -5
- package/src/telemetry.ts +577 -0
- package/src/timer-registry.ts +44 -10
- package/src/types.ts +32 -17
- package/src/worker/durable-step.ts +976 -0
- package/src/worker/index.ts +216 -0
- package/src/worker/state-adapter.ts +589 -0
- package/src/worker/topological-executor.ts +625 -0
- package/src/worker/workflow-builder.ts +871 -0
- package/src/worker.ts +2906 -0
- package/src/workflow-builder.ts +1068 -0
- package/src/workflow.ts +188 -351
- package/test/barrier-join.test.ts +32 -24
- package/test/cascade-executor.test.ts +9 -16
- package/test/cron-parser.test.ts +314 -0
- package/test/cron-scheduler.test.ts +291 -0
- package/test/database-context.test.ts +770 -0
- package/test/db-provider-adapter.test.ts +862 -0
- package/test/durable-execution-cloudflare.test.ts +606 -0
- package/test/durable-execution-in-process.test.ts +286 -0
- package/test/durable-execution.test.ts +247 -0
- package/test/e2e/workflow-scenarios.e2e.test.ts +1039 -0
- package/test/integration.test.ts +442 -0
- package/test/rpc-surface.test.ts +946 -0
- package/test/runtime.test.ts +262 -0
- package/test/schedule-timer-cleanup.test.ts +30 -21
- package/test/send-race-conditions.test.ts +30 -40
- package/test/worker/durable-cascade.test.ts +1117 -0
- package/test/worker/durable-step.test.ts +723 -0
- package/test/worker/topological-executor.test.ts +1240 -0
- package/test/worker/workflow-builder.test.ts +1067 -0
- package/test/worker.test.ts +608 -0
- package/test/workflow-builder.test.ts +1670 -0
- package/test/workflow-cron.test.ts +256 -0
- package/test/workflow-state-adapter.test.ts +923 -0
- package/test/workflow.test.ts +25 -22
- package/tsconfig.json +3 -1
- package/vitest.config.ts +38 -1
- package/vitest.workers.config.ts +44 -0
- package/wrangler.jsonc +22 -0
- package/.turbo/turbo-test.log +0 -169
- package/LICENSE +0 -21
- package/src/context.js +0 -83
- package/src/every.js +0 -267
- package/src/index.js +0 -71
- package/src/on.js +0 -79
- package/src/send.js +0 -111
- package/src/types.js +0 -4
- package/src/workflow.js +0 -455
- package/test/context.test.js +0 -116
- package/test/every.test.js +0 -282
- package/test/on.test.js +0 -80
- package/test/send.test.js +0 -89
- package/test/workflow.test.js +0 -224
- 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
|
+
})
|