ai-workflows 2.1.1 → 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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +17 -1
- package/README.md +305 -184
- package/dist/barrier.d.ts +159 -0
- package/dist/barrier.d.ts.map +1 -0
- package/dist/barrier.js +377 -0
- package/dist/barrier.js.map +1 -0
- package/dist/cascade-context.d.ts +149 -0
- package/dist/cascade-context.d.ts.map +1 -0
- package/dist/cascade-context.js +324 -0
- package/dist/cascade-context.js.map +1 -0
- package/dist/cascade-executor.d.ts +196 -0
- package/dist/cascade-executor.d.ts.map +1 -0
- package/dist/cascade-executor.js +384 -0
- package/dist/cascade-executor.js.map +1 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +27 -8
- 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/dependency-graph.d.ts +157 -0
- package/dist/dependency-graph.d.ts.map +1 -0
- package/dist/dependency-graph.js +382 -0
- package/dist/dependency-graph.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/every.d.ts +31 -2
- package/dist/every.d.ts.map +1 -1
- package/dist/every.js +63 -32
- package/dist/every.js.map +1 -1
- package/dist/graph/index.d.ts +8 -0
- package/dist/graph/index.d.ts.map +1 -0
- package/dist/graph/index.js +8 -0
- package/dist/graph/index.js.map +1 -0
- package/dist/graph/topological-sort.d.ts +121 -0
- package/dist/graph/topological-sort.d.ts.map +1 -0
- package/dist/graph/topological-sort.js +292 -0
- package/dist/graph/topological-sort.js.map +1 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +25 -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 +35 -10
- package/dist/on.d.ts.map +1 -1
- package/dist/on.js +53 -19
- 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 +77 -0
- package/dist/timer-registry.d.ts.map +1 -0
- package/dist/timer-registry.js +154 -0
- package/dist/timer-registry.js.map +1 -0
- package/dist/types.d.ts +105 -6
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +17 -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 +136 -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 +21 -4
- package/src/__tests__/digital-objects-adapter.test.ts +274 -0
- package/src/__tests__/durable-workflow.test.ts +297 -0
- package/src/barrier.ts +507 -0
- package/src/cascade-context.ts +495 -0
- package/src/cascade-executor.ts +588 -0
- package/src/context.ts +51 -17
- package/src/cron-parser.ts +347 -0
- package/src/cron-scheduler.ts +239 -0
- package/src/database-context.ts +658 -0
- package/src/dependency-graph.ts +518 -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/every.ts +104 -35
- package/src/graph/index.ts +19 -0
- package/src/graph/topological-sort.ts +412 -0
- package/src/index.ts +147 -0
- package/src/logger.ts +148 -0
- package/src/on.ts +81 -26
- package/src/runtime.ts +436 -0
- package/src/send.ts +4 -5
- package/src/telemetry.ts +577 -0
- package/src/timer-registry.ts +179 -0
- package/src/types.ts +146 -10
- 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 +199 -355
- package/test/barrier-join.test.ts +442 -0
- package/test/barrier-unhandled-rejections.test.ts +359 -0
- package/test/cascade-context.test.ts +390 -0
- package/test/cascade-executor.test.ts +852 -0
- 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/dependency-graph.test.ts +512 -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/graph/topological-sort.test.ts +586 -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 +353 -0
- package/test/send-race-conditions.test.ts +400 -0
- package/test/type-safety-every.test.ts +303 -0
- 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 -7
- 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,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the in-process production DurableExecutionAdapter
|
|
3
|
+
* (`createInProcessDurableExecution`). Covers two modes:
|
|
4
|
+
*
|
|
5
|
+
* 1. Standalone — no runtime supplied. Behaves like the in-memory stub for
|
|
6
|
+
* callers that want the port without WorkflowRuntime integration.
|
|
7
|
+
*
|
|
8
|
+
* 2. Wrapped — a {@link WorkflowRuntime} supplied at construction time.
|
|
9
|
+
* Each run/step is reflected in the runtime's history, cascade context,
|
|
10
|
+
* and (when wired) database.
|
|
11
|
+
*
|
|
12
|
+
* The existing `durable-execution.test.ts` exercises the in-memory stub's port
|
|
13
|
+
* semantics in detail; these tests focus on the integration surface unique to
|
|
14
|
+
* the in-process adapter.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
createInProcessDurableExecution,
|
|
21
|
+
DurableStepError,
|
|
22
|
+
WaitForEventTimeoutError,
|
|
23
|
+
type DurableExecutionAdapter,
|
|
24
|
+
type InProcessWorkflowContext,
|
|
25
|
+
} from '../src/durable-execution.js'
|
|
26
|
+
import { createWorkflowRuntime } from '../src/runtime.js'
|
|
27
|
+
import type { ActionData, DatabaseContext } from '../src/types.js'
|
|
28
|
+
|
|
29
|
+
describe('createInProcessDurableExecution — standalone (no runtime)', () => {
|
|
30
|
+
it('exposes the in-process kind discriminant', () => {
|
|
31
|
+
const dx = createInProcessDurableExecution()
|
|
32
|
+
expect(dx.kind).toBe('in-process')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('satisfies the DurableExecutionAdapter port', () => {
|
|
36
|
+
const adapter: DurableExecutionAdapter = createInProcessDurableExecution()
|
|
37
|
+
expect(adapter.kind).toBe('in-process')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('runs a workflow body and returns the result', async () => {
|
|
41
|
+
const dx = createInProcessDurableExecution()
|
|
42
|
+
const result = await dx.run<number, { x: number; y: number }>(
|
|
43
|
+
'sum',
|
|
44
|
+
async (ctx) => ctx.input.x + ctx.input.y,
|
|
45
|
+
{ x: 7, y: 8 }
|
|
46
|
+
)
|
|
47
|
+
expect(result).toBe(15)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('runtime field is undefined when no runtime is supplied', () => {
|
|
51
|
+
const dx = createInProcessDurableExecution()
|
|
52
|
+
expect(dx.runtime).toBeUndefined()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('ctx.runtime is undefined inside a body when no runtime is supplied', async () => {
|
|
56
|
+
const dx = createInProcessDurableExecution()
|
|
57
|
+
let captured: InProcessWorkflowContext<undefined> | null = null
|
|
58
|
+
await dx.run(
|
|
59
|
+
'capture',
|
|
60
|
+
async (ctx) => {
|
|
61
|
+
captured = ctx
|
|
62
|
+
},
|
|
63
|
+
undefined
|
|
64
|
+
)
|
|
65
|
+
expect(captured).not.toBeNull()
|
|
66
|
+
expect(captured!.runtime).toBeUndefined()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('memoizes step results within a run (delegates to inner mechanics)', async () => {
|
|
70
|
+
const dx = createInProcessDurableExecution()
|
|
71
|
+
const fn = vi.fn(async () => Math.random())
|
|
72
|
+
const result = await dx.run(
|
|
73
|
+
'memo',
|
|
74
|
+
async (ctx) => {
|
|
75
|
+
const a = await ctx.step('once', fn)
|
|
76
|
+
const b = await ctx.step('once', fn)
|
|
77
|
+
return [a, b]
|
|
78
|
+
},
|
|
79
|
+
undefined
|
|
80
|
+
)
|
|
81
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
82
|
+
expect(result[0]).toBe(result[1])
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('retries failing steps and throws DurableStepError when exhausted', async () => {
|
|
86
|
+
const dx = createInProcessDurableExecution({ delay: async () => {} })
|
|
87
|
+
await expect(
|
|
88
|
+
dx.run(
|
|
89
|
+
'exhaust',
|
|
90
|
+
async (ctx) =>
|
|
91
|
+
ctx.step('boom', { retries: { limit: 2 } }, async () => {
|
|
92
|
+
throw new Error('persistent')
|
|
93
|
+
}),
|
|
94
|
+
undefined
|
|
95
|
+
)
|
|
96
|
+
).rejects.toBeInstanceOf(DurableStepError)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('sleep, sleepUntil, waitForEvent, emit are forwarded from the inner adapter', async () => {
|
|
100
|
+
const sleeps: number[] = []
|
|
101
|
+
const dx = createInProcessDurableExecution({
|
|
102
|
+
delay: async (ms) => {
|
|
103
|
+
sleeps.push(ms)
|
|
104
|
+
},
|
|
105
|
+
})
|
|
106
|
+
await dx.sleep('100ms')
|
|
107
|
+
await dx.sleepUntil(new Date(Date.now() + 50))
|
|
108
|
+
expect(sleeps[0]).toBe(100)
|
|
109
|
+
expect(sleeps.length).toBeGreaterThanOrEqual(1)
|
|
110
|
+
|
|
111
|
+
const promise = dx.waitForEvent<string>('go')
|
|
112
|
+
expect(dx.emit('go', 'value')).toBe(true)
|
|
113
|
+
await expect(promise).resolves.toBe('value')
|
|
114
|
+
|
|
115
|
+
await expect(dx.waitForEvent('never', 5)).rejects.toBeInstanceOf(WaitForEventTimeoutError)
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
describe('createInProcessDurableExecution — wrapped (runtime integration)', () => {
|
|
120
|
+
it('exposes the runtime through the adapter', () => {
|
|
121
|
+
const runtime = createWorkflowRuntime()
|
|
122
|
+
const dx = createInProcessDurableExecution({ runtime })
|
|
123
|
+
expect(dx.runtime).toBe(runtime)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('exposes the runtime on the workflow context inside a run', async () => {
|
|
127
|
+
const runtime = createWorkflowRuntime()
|
|
128
|
+
const dx = createInProcessDurableExecution({ runtime })
|
|
129
|
+
let captured: InProcessWorkflowContext<undefined> | null = null
|
|
130
|
+
await dx.run(
|
|
131
|
+
'capture',
|
|
132
|
+
async (ctx) => {
|
|
133
|
+
captured = ctx
|
|
134
|
+
},
|
|
135
|
+
undefined
|
|
136
|
+
)
|
|
137
|
+
expect(captured!.runtime).toBe(runtime)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('records run start/finish in runtime history', async () => {
|
|
141
|
+
const runtime = createWorkflowRuntime()
|
|
142
|
+
const dx = createInProcessDurableExecution({ runtime })
|
|
143
|
+
await dx.run('hello', async () => 'ok', undefined)
|
|
144
|
+
const names = runtime.state.history.map((entry) => entry.name)
|
|
145
|
+
expect(names).toContain('durable-run:start:hello')
|
|
146
|
+
expect(names).toContain('durable-run:finish:hello')
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('records run errors in runtime history when the body throws', async () => {
|
|
150
|
+
const runtime = createWorkflowRuntime()
|
|
151
|
+
const dx = createInProcessDurableExecution({ runtime })
|
|
152
|
+
await expect(
|
|
153
|
+
dx.run(
|
|
154
|
+
'fail',
|
|
155
|
+
async () => {
|
|
156
|
+
throw new Error('nope')
|
|
157
|
+
},
|
|
158
|
+
undefined
|
|
159
|
+
)
|
|
160
|
+
).rejects.toThrow('nope')
|
|
161
|
+
const names = runtime.state.history.map((entry) => entry.name)
|
|
162
|
+
expect(names).toContain('durable-run:start:fail')
|
|
163
|
+
expect(names).toContain('durable-run:error:fail')
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('records cascade steps for each durable step', async () => {
|
|
167
|
+
const runtime = createWorkflowRuntime()
|
|
168
|
+
const dx = createInProcessDurableExecution({ runtime })
|
|
169
|
+
await dx.run(
|
|
170
|
+
'cascade',
|
|
171
|
+
async (ctx) => {
|
|
172
|
+
await ctx.step('step-a', async () => 'a')
|
|
173
|
+
await ctx.step('step-b', async () => 'b')
|
|
174
|
+
},
|
|
175
|
+
undefined
|
|
176
|
+
)
|
|
177
|
+
const stepNames = runtime.cascade.steps.map((s) => s.name)
|
|
178
|
+
expect(stepNames).toEqual(['step-a', 'step-b'])
|
|
179
|
+
expect(runtime.cascade.steps.every((s) => s.status === 'completed')).toBe(true)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('marks cascade step as failed when step throws', async () => {
|
|
183
|
+
const runtime = createWorkflowRuntime()
|
|
184
|
+
const dx = createInProcessDurableExecution({ runtime, delay: async () => {} })
|
|
185
|
+
await expect(
|
|
186
|
+
dx.run(
|
|
187
|
+
'fail-step',
|
|
188
|
+
async (ctx) =>
|
|
189
|
+
ctx.step('boom', async () => {
|
|
190
|
+
throw new Error('kaboom')
|
|
191
|
+
}),
|
|
192
|
+
undefined
|
|
193
|
+
)
|
|
194
|
+
).rejects.toBeInstanceOf(DurableStepError)
|
|
195
|
+
expect(runtime.cascade.steps).toHaveLength(1)
|
|
196
|
+
expect(runtime.cascade.steps[0]!.status).toBe('failed')
|
|
197
|
+
expect(runtime.cascade.steps[0]!.error).toBeInstanceOf(Error)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('records step actions through runtime.$.db when configured', async () => {
|
|
201
|
+
const created: ActionData[] = []
|
|
202
|
+
const completed: Array<{ id: string; result: unknown }> = []
|
|
203
|
+
const db: DatabaseContext = {
|
|
204
|
+
recordEvent: async () => {},
|
|
205
|
+
createAction: async (action) => {
|
|
206
|
+
created.push(action)
|
|
207
|
+
},
|
|
208
|
+
completeAction: async (id, result) => {
|
|
209
|
+
completed.push({ id, result })
|
|
210
|
+
},
|
|
211
|
+
storeArtifact: async () => {},
|
|
212
|
+
getArtifact: async () => null,
|
|
213
|
+
}
|
|
214
|
+
const runtime = createWorkflowRuntime({ db })
|
|
215
|
+
const dx = createInProcessDurableExecution({ runtime })
|
|
216
|
+
|
|
217
|
+
await dx.run(
|
|
218
|
+
'with-db',
|
|
219
|
+
async (ctx) => {
|
|
220
|
+
await ctx.step('write', async () => 'ok')
|
|
221
|
+
},
|
|
222
|
+
undefined
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
expect(created).toHaveLength(1)
|
|
226
|
+
expect(created[0]).toMatchObject({
|
|
227
|
+
actor: 'workflow',
|
|
228
|
+
object: 'write',
|
|
229
|
+
action: 'step',
|
|
230
|
+
status: 'active',
|
|
231
|
+
})
|
|
232
|
+
expect(completed).toEqual([{ id: 'write', result: 'ok' }])
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('does not fail the step when db.createAction itself throws', async () => {
|
|
236
|
+
const db: DatabaseContext = {
|
|
237
|
+
recordEvent: async () => {},
|
|
238
|
+
createAction: async () => {
|
|
239
|
+
throw new Error('db down')
|
|
240
|
+
},
|
|
241
|
+
completeAction: async () => {},
|
|
242
|
+
storeArtifact: async () => {},
|
|
243
|
+
getArtifact: async () => null,
|
|
244
|
+
}
|
|
245
|
+
const runtime = createWorkflowRuntime({ db })
|
|
246
|
+
const dx = createInProcessDurableExecution({ runtime })
|
|
247
|
+
|
|
248
|
+
const result = await dx.run(
|
|
249
|
+
'tolerant',
|
|
250
|
+
async (ctx) => ctx.step('write', async () => 'still-ok'),
|
|
251
|
+
undefined
|
|
252
|
+
)
|
|
253
|
+
expect(result).toBe('still-ok')
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('integrates with $.send: durable workflow can dispatch to runtime handlers', async () => {
|
|
257
|
+
const runtime = createWorkflowRuntime()
|
|
258
|
+
const handler = vi.fn()
|
|
259
|
+
runtime.register('Plan', 'generated', handler)
|
|
260
|
+
const dx = createInProcessDurableExecution({ runtime })
|
|
261
|
+
|
|
262
|
+
await dx.run(
|
|
263
|
+
'cascade',
|
|
264
|
+
async (ctx) => {
|
|
265
|
+
const plan = await ctx.step('plan', async () => ({ id: 'p-1' }))
|
|
266
|
+
ctx.runtime!.$.send('Plan.generated', plan)
|
|
267
|
+
},
|
|
268
|
+
undefined
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
// Wait a tick for the async send to deliver.
|
|
272
|
+
await Promise.resolve()
|
|
273
|
+
await Promise.resolve()
|
|
274
|
+
expect(handler).toHaveBeenCalledTimes(1)
|
|
275
|
+
expect(handler).toHaveBeenCalledWith(expect.objectContaining({ id: 'p-1' }), runtime.$)
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it('top-level step (outside a run) also records a cascade step', async () => {
|
|
279
|
+
const runtime = createWorkflowRuntime()
|
|
280
|
+
const dx = createInProcessDurableExecution({ runtime })
|
|
281
|
+
await dx.step('one-off', async () => 'done')
|
|
282
|
+
expect(runtime.cascade.steps).toHaveLength(1)
|
|
283
|
+
expect(runtime.cascade.steps[0]!.name).toBe('one-off')
|
|
284
|
+
expect(runtime.cascade.steps[0]!.status).toBe('completed')
|
|
285
|
+
})
|
|
286
|
+
})
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the DurableExecutionAdapter port and its in-memory stub.
|
|
3
|
+
*
|
|
4
|
+
* The in-memory stub is the only adapter shipped in this bead (Phase 0). The
|
|
5
|
+
* goal of these tests is twofold: (1) prove the port shape compiles and is
|
|
6
|
+
* exercisable end-to-end without leaning on a real backend; (2) document the
|
|
7
|
+
* port semantics callers can rely on across all adapters (run, step memo,
|
|
8
|
+
* retries, sleep, waitForEvent timeouts, schedule subscription).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
createInMemoryDurableExecution,
|
|
15
|
+
DurableStepError,
|
|
16
|
+
WaitForEventTimeoutError,
|
|
17
|
+
type DurableExecutionAdapter,
|
|
18
|
+
type WorkflowContext,
|
|
19
|
+
} from '../src/durable-execution.js'
|
|
20
|
+
|
|
21
|
+
describe('DurableExecutionAdapter port', () => {
|
|
22
|
+
it('exposes the documented kind discriminant', () => {
|
|
23
|
+
const dx = createInMemoryDurableExecution()
|
|
24
|
+
expect(dx.kind).toBe('in-process')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('runs a workflow body with the supplied input', async () => {
|
|
28
|
+
const dx = createInMemoryDurableExecution()
|
|
29
|
+
const result = await dx.run<number, { x: number; y: number }>(
|
|
30
|
+
'sum',
|
|
31
|
+
async (ctx) => ctx.input.x + ctx.input.y,
|
|
32
|
+
{ x: 2, y: 3 }
|
|
33
|
+
)
|
|
34
|
+
expect(result).toBe(5)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('passes a context with stable instanceId and name', async () => {
|
|
38
|
+
const dx = createInMemoryDurableExecution()
|
|
39
|
+
let captured: WorkflowContext<undefined> | null = null
|
|
40
|
+
await dx.run(
|
|
41
|
+
'named',
|
|
42
|
+
async (ctx) => {
|
|
43
|
+
captured = ctx
|
|
44
|
+
},
|
|
45
|
+
undefined
|
|
46
|
+
)
|
|
47
|
+
expect(captured).not.toBeNull()
|
|
48
|
+
expect(captured!.name).toBe('named')
|
|
49
|
+
expect(typeof captured!.instanceId).toBe('string')
|
|
50
|
+
expect(captured!.instanceId.length).toBeGreaterThan(0)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('memoizes step results within a run', async () => {
|
|
54
|
+
const dx = createInMemoryDurableExecution()
|
|
55
|
+
const fn = vi.fn(async () => Math.random())
|
|
56
|
+
const result = await dx.run(
|
|
57
|
+
'memo',
|
|
58
|
+
async (ctx) => {
|
|
59
|
+
const a = await ctx.step('once', fn)
|
|
60
|
+
const b = await ctx.step('once', fn)
|
|
61
|
+
return [a, b]
|
|
62
|
+
},
|
|
63
|
+
undefined
|
|
64
|
+
)
|
|
65
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
66
|
+
expect(result[0]).toBe(result[1])
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('retries failing steps according to config', async () => {
|
|
70
|
+
const dx = createInMemoryDurableExecution({ delay: async () => {} })
|
|
71
|
+
let attempts = 0
|
|
72
|
+
const result = await dx.run(
|
|
73
|
+
'retry',
|
|
74
|
+
async (ctx) => {
|
|
75
|
+
return ctx.step('flaky', { retries: { limit: 3, delay: 10 } }, async () => {
|
|
76
|
+
attempts++
|
|
77
|
+
if (attempts < 3) throw new Error('transient')
|
|
78
|
+
return 'ok'
|
|
79
|
+
})
|
|
80
|
+
},
|
|
81
|
+
undefined
|
|
82
|
+
)
|
|
83
|
+
expect(attempts).toBe(3)
|
|
84
|
+
expect(result).toBe('ok')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('throws DurableStepError when retries are exhausted', async () => {
|
|
88
|
+
const dx = createInMemoryDurableExecution({ delay: async () => {} })
|
|
89
|
+
const cause = new Error('persistent')
|
|
90
|
+
await expect(
|
|
91
|
+
dx.run(
|
|
92
|
+
'exhaust',
|
|
93
|
+
async (ctx) => {
|
|
94
|
+
return ctx.step('always-fails', { retries: { limit: 2 } }, async () => {
|
|
95
|
+
throw cause
|
|
96
|
+
})
|
|
97
|
+
},
|
|
98
|
+
undefined
|
|
99
|
+
)
|
|
100
|
+
).rejects.toMatchObject({
|
|
101
|
+
name: 'DurableStepError',
|
|
102
|
+
stepName: 'always-fails',
|
|
103
|
+
attempts: 2,
|
|
104
|
+
retryable: true,
|
|
105
|
+
cause,
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('exposes DurableStepError as an Error instance with cause', async () => {
|
|
110
|
+
const dx = createInMemoryDurableExecution({ delay: async () => {} })
|
|
111
|
+
let caught: unknown
|
|
112
|
+
try {
|
|
113
|
+
await dx.run(
|
|
114
|
+
'exhaust2',
|
|
115
|
+
async (ctx) =>
|
|
116
|
+
ctx.step('boom', async () => {
|
|
117
|
+
throw new Error('nope')
|
|
118
|
+
}),
|
|
119
|
+
undefined
|
|
120
|
+
)
|
|
121
|
+
} catch (err) {
|
|
122
|
+
caught = err
|
|
123
|
+
}
|
|
124
|
+
expect(caught).toBeInstanceOf(Error)
|
|
125
|
+
expect(caught).toBeInstanceOf(DurableStepError)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('sleeps for the requested duration (string form)', async () => {
|
|
129
|
+
const sleeps: number[] = []
|
|
130
|
+
const dx = createInMemoryDurableExecution({
|
|
131
|
+
delay: async (ms) => {
|
|
132
|
+
sleeps.push(ms)
|
|
133
|
+
},
|
|
134
|
+
})
|
|
135
|
+
await dx.run(
|
|
136
|
+
'sleeper',
|
|
137
|
+
async (ctx) => {
|
|
138
|
+
await ctx.sleep('500ms')
|
|
139
|
+
await ctx.sleep(250)
|
|
140
|
+
await ctx.sleep('1 second')
|
|
141
|
+
},
|
|
142
|
+
undefined
|
|
143
|
+
)
|
|
144
|
+
expect(sleeps).toEqual([500, 250, 1000])
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('sleeps until a future date', async () => {
|
|
148
|
+
let nowVal = 1_000_000
|
|
149
|
+
const sleeps: number[] = []
|
|
150
|
+
const dx = createInMemoryDurableExecution({
|
|
151
|
+
now: () => nowVal,
|
|
152
|
+
delay: async (ms) => {
|
|
153
|
+
sleeps.push(ms)
|
|
154
|
+
nowVal += ms
|
|
155
|
+
},
|
|
156
|
+
})
|
|
157
|
+
await dx.run(
|
|
158
|
+
'until',
|
|
159
|
+
async (ctx) => {
|
|
160
|
+
await ctx.sleepUntil(new Date(nowVal + 750))
|
|
161
|
+
},
|
|
162
|
+
undefined
|
|
163
|
+
)
|
|
164
|
+
expect(sleeps).toEqual([750])
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('does not sleep when sleepUntil is in the past', async () => {
|
|
168
|
+
const sleeps: number[] = []
|
|
169
|
+
const dx = createInMemoryDurableExecution({
|
|
170
|
+
now: () => 5_000,
|
|
171
|
+
delay: async (ms) => {
|
|
172
|
+
sleeps.push(ms)
|
|
173
|
+
},
|
|
174
|
+
})
|
|
175
|
+
await dx.run(
|
|
176
|
+
'past',
|
|
177
|
+
async (ctx) => {
|
|
178
|
+
await ctx.sleepUntil(new Date(0))
|
|
179
|
+
},
|
|
180
|
+
undefined
|
|
181
|
+
)
|
|
182
|
+
expect(sleeps).toEqual([])
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('rejects unrecognised duration strings', async () => {
|
|
186
|
+
const dx = createInMemoryDurableExecution({ delay: async () => {} })
|
|
187
|
+
await expect(
|
|
188
|
+
dx.run('bad', async (ctx) => ctx.sleep('two fortnights'), undefined)
|
|
189
|
+
).rejects.toThrow(/Unrecognised duration/)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('waitForEvent resolves when emit is called with matching name', async () => {
|
|
193
|
+
const dx = createInMemoryDurableExecution()
|
|
194
|
+
const promise = dx.waitForEvent<number>('Order.placed')
|
|
195
|
+
expect(dx.emit('Order.placed', 42)).toBe(true)
|
|
196
|
+
await expect(promise).resolves.toBe(42)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('waitForEvent times out with WaitForEventTimeoutError', async () => {
|
|
200
|
+
const dx = createInMemoryDurableExecution()
|
|
201
|
+
await expect(dx.waitForEvent('never', 10)).rejects.toBeInstanceOf(WaitForEventTimeoutError)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('emit returns false when no waiter is pending', () => {
|
|
205
|
+
const dx = createInMemoryDurableExecution()
|
|
206
|
+
expect(dx.emit('nothing', null)).toBe(false)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('waitForEvent inside a workflow run resolves via emit', async () => {
|
|
210
|
+
const dx = createInMemoryDurableExecution()
|
|
211
|
+
const completion = dx.run<string, undefined>(
|
|
212
|
+
'await-event',
|
|
213
|
+
async (ctx) => ctx.waitForEvent<string>('approved'),
|
|
214
|
+
undefined
|
|
215
|
+
)
|
|
216
|
+
// Allow the run to register its waiter before we emit.
|
|
217
|
+
await Promise.resolve()
|
|
218
|
+
await Promise.resolve()
|
|
219
|
+
expect(dx.emit('approved', 'go')).toBe(true)
|
|
220
|
+
await expect(completion).resolves.toBe('go')
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('schedule returns a subscription with a stable id and unsubscribe', () => {
|
|
224
|
+
const dx = createInMemoryDurableExecution()
|
|
225
|
+
const sub = dx.schedule('midnight', '0 0 * * *', async () => undefined)
|
|
226
|
+
expect(typeof sub.id).toBe('string')
|
|
227
|
+
expect(sub.id.length).toBeGreaterThan(0)
|
|
228
|
+
sub.unsubscribe()
|
|
229
|
+
// Calling unsubscribe twice is a no-op, not an error.
|
|
230
|
+
sub.unsubscribe()
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('top-level step() executes outside a run', async () => {
|
|
234
|
+
const dx = createInMemoryDurableExecution()
|
|
235
|
+
const fn = vi.fn(async () => 'value')
|
|
236
|
+
const result = await dx.step('once', fn)
|
|
237
|
+
expect(result).toBe('value')
|
|
238
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('satisfies the DurableExecutionAdapter port type', () => {
|
|
242
|
+
// Compile-time check: assigning the stub to the port type proves
|
|
243
|
+
// structural compatibility.
|
|
244
|
+
const adapter: DurableExecutionAdapter = createInMemoryDurableExecution()
|
|
245
|
+
expect(adapter.kind).toBe('in-process')
|
|
246
|
+
})
|
|
247
|
+
})
|