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.
Files changed (211) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +17 -1
  3. package/README.md +305 -184
  4. package/dist/barrier.d.ts +159 -0
  5. package/dist/barrier.d.ts.map +1 -0
  6. package/dist/barrier.js +377 -0
  7. package/dist/barrier.js.map +1 -0
  8. package/dist/cascade-context.d.ts +149 -0
  9. package/dist/cascade-context.d.ts.map +1 -0
  10. package/dist/cascade-context.js +324 -0
  11. package/dist/cascade-context.js.map +1 -0
  12. package/dist/cascade-executor.d.ts +196 -0
  13. package/dist/cascade-executor.d.ts.map +1 -0
  14. package/dist/cascade-executor.js +384 -0
  15. package/dist/cascade-executor.js.map +1 -0
  16. package/dist/context.d.ts.map +1 -1
  17. package/dist/context.js +27 -8
  18. package/dist/context.js.map +1 -1
  19. package/dist/cron-parser.d.ts +65 -0
  20. package/dist/cron-parser.d.ts.map +1 -0
  21. package/dist/cron-parser.js +294 -0
  22. package/dist/cron-parser.js.map +1 -0
  23. package/dist/cron-scheduler.d.ts +117 -0
  24. package/dist/cron-scheduler.d.ts.map +1 -0
  25. package/dist/cron-scheduler.js +176 -0
  26. package/dist/cron-scheduler.js.map +1 -0
  27. package/dist/database-context.d.ts +184 -0
  28. package/dist/database-context.d.ts.map +1 -0
  29. package/dist/database-context.js +428 -0
  30. package/dist/database-context.js.map +1 -0
  31. package/dist/dependency-graph.d.ts +157 -0
  32. package/dist/dependency-graph.d.ts.map +1 -0
  33. package/dist/dependency-graph.js +382 -0
  34. package/dist/dependency-graph.js.map +1 -0
  35. package/dist/digital-objects-adapter.d.ts +159 -0
  36. package/dist/digital-objects-adapter.d.ts.map +1 -0
  37. package/dist/digital-objects-adapter.js +229 -0
  38. package/dist/digital-objects-adapter.js.map +1 -0
  39. package/dist/durable-execution-cloudflare.d.ts +427 -0
  40. package/dist/durable-execution-cloudflare.d.ts.map +1 -0
  41. package/dist/durable-execution-cloudflare.js +510 -0
  42. package/dist/durable-execution-cloudflare.js.map +1 -0
  43. package/dist/durable-execution.d.ts +482 -0
  44. package/dist/durable-execution.d.ts.map +1 -0
  45. package/dist/durable-execution.js +594 -0
  46. package/dist/durable-execution.js.map +1 -0
  47. package/dist/durable-workflow.d.ts +176 -0
  48. package/dist/durable-workflow.d.ts.map +1 -0
  49. package/dist/durable-workflow.js +552 -0
  50. package/dist/durable-workflow.js.map +1 -0
  51. package/dist/every.d.ts +31 -2
  52. package/dist/every.d.ts.map +1 -1
  53. package/dist/every.js +63 -32
  54. package/dist/every.js.map +1 -1
  55. package/dist/graph/index.d.ts +8 -0
  56. package/dist/graph/index.d.ts.map +1 -0
  57. package/dist/graph/index.js +8 -0
  58. package/dist/graph/index.js.map +1 -0
  59. package/dist/graph/topological-sort.d.ts +121 -0
  60. package/dist/graph/topological-sort.d.ts.map +1 -0
  61. package/dist/graph/topological-sort.js +292 -0
  62. package/dist/graph/topological-sort.js.map +1 -0
  63. package/dist/index.d.ts +10 -1
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +25 -0
  66. package/dist/index.js.map +1 -1
  67. package/dist/logger.d.ts +101 -0
  68. package/dist/logger.d.ts.map +1 -0
  69. package/dist/logger.js +115 -0
  70. package/dist/logger.js.map +1 -0
  71. package/dist/on.d.ts +35 -10
  72. package/dist/on.d.ts.map +1 -1
  73. package/dist/on.js +53 -19
  74. package/dist/on.js.map +1 -1
  75. package/dist/runtime.d.ts +169 -0
  76. package/dist/runtime.d.ts.map +1 -0
  77. package/dist/runtime.js +275 -0
  78. package/dist/runtime.js.map +1 -0
  79. package/dist/send.d.ts.map +1 -1
  80. package/dist/send.js +4 -3
  81. package/dist/send.js.map +1 -1
  82. package/dist/telemetry.d.ts +150 -0
  83. package/dist/telemetry.d.ts.map +1 -0
  84. package/dist/telemetry.js +388 -0
  85. package/dist/telemetry.js.map +1 -0
  86. package/dist/timer-registry.d.ts +77 -0
  87. package/dist/timer-registry.d.ts.map +1 -0
  88. package/dist/timer-registry.js +154 -0
  89. package/dist/timer-registry.js.map +1 -0
  90. package/dist/types.d.ts +105 -6
  91. package/dist/types.d.ts.map +1 -1
  92. package/dist/types.js +17 -1
  93. package/dist/types.js.map +1 -1
  94. package/dist/worker/durable-step.d.ts +481 -0
  95. package/dist/worker/durable-step.d.ts.map +1 -0
  96. package/dist/worker/durable-step.js +606 -0
  97. package/dist/worker/durable-step.js.map +1 -0
  98. package/dist/worker/index.d.ts +106 -0
  99. package/dist/worker/index.d.ts.map +1 -0
  100. package/dist/worker/index.js +124 -0
  101. package/dist/worker/index.js.map +1 -0
  102. package/dist/worker/state-adapter.d.ts +230 -0
  103. package/dist/worker/state-adapter.d.ts.map +1 -0
  104. package/dist/worker/state-adapter.js +409 -0
  105. package/dist/worker/state-adapter.js.map +1 -0
  106. package/dist/worker/topological-executor.d.ts +282 -0
  107. package/dist/worker/topological-executor.d.ts.map +1 -0
  108. package/dist/worker/topological-executor.js +396 -0
  109. package/dist/worker/topological-executor.js.map +1 -0
  110. package/dist/worker/workflow-builder.d.ts +286 -0
  111. package/dist/worker/workflow-builder.d.ts.map +1 -0
  112. package/dist/worker/workflow-builder.js +565 -0
  113. package/dist/worker/workflow-builder.js.map +1 -0
  114. package/dist/worker.d.ts +800 -0
  115. package/dist/worker.d.ts.map +1 -0
  116. package/dist/worker.js +2428 -0
  117. package/dist/worker.js.map +1 -0
  118. package/dist/workflow-builder.d.ts +287 -0
  119. package/dist/workflow-builder.d.ts.map +1 -0
  120. package/dist/workflow-builder.js +762 -0
  121. package/dist/workflow-builder.js.map +1 -0
  122. package/dist/workflow.d.ts +14 -30
  123. package/dist/workflow.d.ts.map +1 -1
  124. package/dist/workflow.js +136 -292
  125. package/dist/workflow.js.map +1 -1
  126. package/examples/01-ecommerce-order-pipeline.ts +358 -0
  127. package/examples/02-content-moderation-cascade.ts +454 -0
  128. package/examples/03-scheduled-reporting-dependencies.ts +479 -0
  129. package/examples/04-database-persistence.ts +518 -0
  130. package/examples/README.md +173 -0
  131. package/package.json +21 -4
  132. package/src/__tests__/digital-objects-adapter.test.ts +274 -0
  133. package/src/__tests__/durable-workflow.test.ts +297 -0
  134. package/src/barrier.ts +507 -0
  135. package/src/cascade-context.ts +495 -0
  136. package/src/cascade-executor.ts +588 -0
  137. package/src/context.ts +51 -17
  138. package/src/cron-parser.ts +347 -0
  139. package/src/cron-scheduler.ts +239 -0
  140. package/src/database-context.ts +658 -0
  141. package/src/dependency-graph.ts +518 -0
  142. package/src/digital-objects-adapter.ts +351 -0
  143. package/src/durable-execution-cloudflare.ts +855 -0
  144. package/src/durable-execution.ts +1042 -0
  145. package/src/durable-workflow.ts +717 -0
  146. package/src/every.ts +104 -35
  147. package/src/graph/index.ts +19 -0
  148. package/src/graph/topological-sort.ts +412 -0
  149. package/src/index.ts +147 -0
  150. package/src/logger.ts +148 -0
  151. package/src/on.ts +81 -26
  152. package/src/runtime.ts +436 -0
  153. package/src/send.ts +4 -5
  154. package/src/telemetry.ts +577 -0
  155. package/src/timer-registry.ts +179 -0
  156. package/src/types.ts +146 -10
  157. package/src/worker/durable-step.ts +976 -0
  158. package/src/worker/index.ts +216 -0
  159. package/src/worker/state-adapter.ts +589 -0
  160. package/src/worker/topological-executor.ts +625 -0
  161. package/src/worker/workflow-builder.ts +871 -0
  162. package/src/worker.ts +2906 -0
  163. package/src/workflow-builder.ts +1068 -0
  164. package/src/workflow.ts +199 -355
  165. package/test/barrier-join.test.ts +442 -0
  166. package/test/barrier-unhandled-rejections.test.ts +359 -0
  167. package/test/cascade-context.test.ts +390 -0
  168. package/test/cascade-executor.test.ts +852 -0
  169. package/test/cron-parser.test.ts +314 -0
  170. package/test/cron-scheduler.test.ts +291 -0
  171. package/test/database-context.test.ts +770 -0
  172. package/test/db-provider-adapter.test.ts +862 -0
  173. package/test/dependency-graph.test.ts +512 -0
  174. package/test/durable-execution-cloudflare.test.ts +606 -0
  175. package/test/durable-execution-in-process.test.ts +286 -0
  176. package/test/durable-execution.test.ts +247 -0
  177. package/test/e2e/workflow-scenarios.e2e.test.ts +1039 -0
  178. package/test/graph/topological-sort.test.ts +586 -0
  179. package/test/integration.test.ts +442 -0
  180. package/test/rpc-surface.test.ts +946 -0
  181. package/test/runtime.test.ts +262 -0
  182. package/test/schedule-timer-cleanup.test.ts +353 -0
  183. package/test/send-race-conditions.test.ts +400 -0
  184. package/test/type-safety-every.test.ts +303 -0
  185. package/test/worker/durable-cascade.test.ts +1117 -0
  186. package/test/worker/durable-step.test.ts +723 -0
  187. package/test/worker/topological-executor.test.ts +1240 -0
  188. package/test/worker/workflow-builder.test.ts +1067 -0
  189. package/test/worker.test.ts +608 -0
  190. package/test/workflow-builder.test.ts +1670 -0
  191. package/test/workflow-cron.test.ts +256 -0
  192. package/test/workflow-state-adapter.test.ts +923 -0
  193. package/test/workflow.test.ts +25 -22
  194. package/tsconfig.json +3 -1
  195. package/vitest.config.ts +38 -1
  196. package/vitest.workers.config.ts +44 -0
  197. package/wrangler.jsonc +22 -0
  198. package/.turbo/turbo-test.log +0 -7
  199. package/src/context.js +0 -83
  200. package/src/every.js +0 -267
  201. package/src/index.js +0 -71
  202. package/src/on.js +0 -79
  203. package/src/send.js +0 -111
  204. package/src/types.js +0 -4
  205. package/src/workflow.js +0 -455
  206. package/test/context.test.js +0 -116
  207. package/test/every.test.js +0 -282
  208. package/test/on.test.js +0 -80
  209. package/test/send.test.js +0 -89
  210. package/test/workflow.test.js +0 -224
  211. package/vitest.config.js +0 -7
@@ -0,0 +1,946 @@
1
+ /**
2
+ * RPC Surface Tests (RED Phase)
3
+ *
4
+ * Failing tests that define the expected interface for the full RPC surface
5
+ * of WorkflowService and WorkflowServiceCore.
6
+ *
7
+ * These tests are written in the RED phase of TDD - they define the expected
8
+ * behavior before implementation. They should fail initially.
9
+ *
10
+ * ## RPC Surface Categories
11
+ * 1. Workflow Creation and Registration
12
+ * 2. Workflow Lifecycle Management
13
+ * 3. Event Emission and Handling
14
+ * 4. State Management (in-memory and persisted)
15
+ * 5. Query and List Operations
16
+ * 6. Batch Operations
17
+ * 7. Workflow Introspection
18
+ * 8. Error Handling and Recovery
19
+ * 9. Metrics and Observability
20
+ * 10. Serialization/Deserialization for RPC
21
+ */
22
+
23
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
24
+ import { WorkflowService, WorkflowServiceCore } from '../src/worker.js'
25
+ import type { WorkflowInstanceInfo } from '../src/worker.js'
26
+
27
+ describe('RPC Surface: WorkflowServiceCore', () => {
28
+ let service: WorkflowServiceCore
29
+
30
+ beforeEach(() => {
31
+ service = new WorkflowServiceCore()
32
+ })
33
+
34
+ afterEach(() => {
35
+ service.clear()
36
+ service.clearGlobalEventHandlers()
37
+ service.clearGlobalScheduleHandlers()
38
+ })
39
+
40
+ // ===========================================================================
41
+ // 1. Workflow Creation and Registration
42
+ // ===========================================================================
43
+ describe('Workflow Creation RPC Methods', () => {
44
+ describe('createFromDefinition()', () => {
45
+ it('creates a workflow from a serializable definition object', () => {
46
+ const definition = {
47
+ name: 'test-workflow',
48
+ events: [
49
+ { noun: 'Customer', event: 'created' },
50
+ { noun: 'Order', event: 'placed' },
51
+ ],
52
+ schedules: [{ type: 'hour' as const }],
53
+ }
54
+
55
+ const info = service.createFromDefinition(definition)
56
+
57
+ expect(info.id).toBeDefined()
58
+ expect(info.name).toBe('test-workflow')
59
+ expect(info.eventCount).toBe(2)
60
+ expect(info.scheduleCount).toBe(1)
61
+ })
62
+
63
+ it('validates definition schema before creation', () => {
64
+ const invalidDefinition = {
65
+ name: '', // Invalid: empty name
66
+ events: 'not-an-array', // Invalid: should be array
67
+ }
68
+
69
+ expect(() => {
70
+ service.createFromDefinition(invalidDefinition as unknown)
71
+ }).toThrow(/invalid.*definition/i)
72
+ })
73
+ })
74
+
75
+ describe('clone()', () => {
76
+ it('clones an existing workflow with a new ID', () => {
77
+ const original = service.create('original-workflow', {
78
+ context: { key: 'value' },
79
+ })
80
+
81
+ const cloned = service.clone(original.id, 'cloned-workflow')
82
+
83
+ expect(cloned.id).not.toBe(original.id)
84
+ expect(cloned.name).toBe('cloned-workflow')
85
+ expect(cloned.state.context).toEqual({ key: 'value' })
86
+ })
87
+
88
+ it('throws for non-existent workflow', () => {
89
+ expect(() => {
90
+ service.clone('non-existent', 'new-name')
91
+ }).toThrow('not found')
92
+ })
93
+ })
94
+
95
+ describe('import()', () => {
96
+ it('imports a workflow from a serialized state snapshot', async () => {
97
+ // Create and export a workflow
98
+ const original = service.create('export-test', {
99
+ context: { data: 'important' },
100
+ })
101
+ service.setState(original.id, 'progress', 50)
102
+ const exported = await service.export(original.id)
103
+
104
+ // Clear and import
105
+ service.clear()
106
+ const imported = await service.import(exported)
107
+
108
+ expect(imported.name).toBe('export-test')
109
+ expect(service.getValue(imported.id, 'progress')).toBe(50)
110
+ })
111
+ })
112
+
113
+ describe('export()', () => {
114
+ it('exports a workflow to a serializable format', async () => {
115
+ const info = service.create('export-test', {
116
+ context: { userId: '123' },
117
+ })
118
+ service.setState(info.id, 'step', 'processing')
119
+
120
+ const exported = await service.export(info.id)
121
+
122
+ expect(exported).toMatchObject({
123
+ name: 'export-test',
124
+ state: expect.objectContaining({
125
+ context: expect.objectContaining({ userId: '123', step: 'processing' }),
126
+ }),
127
+ version: expect.any(Number),
128
+ })
129
+ })
130
+
131
+ it('throws for non-existent workflow', async () => {
132
+ await expect(service.export('non-existent')).rejects.toThrow('not found')
133
+ })
134
+ })
135
+ })
136
+
137
+ // ===========================================================================
138
+ // 2. Workflow Lifecycle Management
139
+ // ===========================================================================
140
+ describe('Lifecycle RPC Methods', () => {
141
+ describe('pause()', () => {
142
+ it('pauses a running workflow', async () => {
143
+ const info = service.create('pause-test')
144
+ await service.start(info.id)
145
+
146
+ await service.pause(info.id)
147
+
148
+ const updated = service.get(info.id)
149
+ expect(updated?.started).toBe(false)
150
+ // Paused workflows should retain their state
151
+ expect(service.getState(info.id).context).toBeDefined()
152
+ })
153
+
154
+ it('records pause in workflow history', async () => {
155
+ const info = service.create('pause-history-test')
156
+ await service.start(info.id)
157
+ await service.pause(info.id)
158
+
159
+ const state = service.getState(info.id)
160
+ expect(state.history).toContainEqual(
161
+ expect.objectContaining({
162
+ type: 'lifecycle',
163
+ action: 'paused',
164
+ })
165
+ )
166
+ })
167
+ })
168
+
169
+ describe('resume()', () => {
170
+ it('resumes a paused workflow', async () => {
171
+ const info = service.create('resume-test')
172
+ await service.start(info.id)
173
+ await service.pause(info.id)
174
+
175
+ await service.resume(info.id)
176
+
177
+ const updated = service.get(info.id)
178
+ expect(updated?.started).toBe(true)
179
+ })
180
+ })
181
+
182
+ describe('restart()', () => {
183
+ it('restarts a workflow from the beginning', async () => {
184
+ const info = service.create('restart-test', {
185
+ context: { counter: 0 },
186
+ })
187
+ await service.start(info.id)
188
+ service.setState(info.id, 'counter', 10)
189
+
190
+ await service.restart(info.id)
191
+
192
+ // After restart, state should be reset but workflow should be running
193
+ const state = service.getState(info.id)
194
+ expect(state.context.counter).toBe(0)
195
+ expect(service.get(info.id)?.started).toBe(true)
196
+ })
197
+ })
198
+
199
+ describe('getStatus()', () => {
200
+ it('returns detailed workflow status', () => {
201
+ const info = service.create('status-test')
202
+
203
+ const status = service.getStatus(info.id)
204
+
205
+ expect(status).toMatchObject({
206
+ id: info.id,
207
+ name: 'status-test',
208
+ started: false,
209
+ paused: false,
210
+ eventsPending: 0,
211
+ lastActivity: expect.any(Date),
212
+ })
213
+ })
214
+ })
215
+ })
216
+
217
+ // ===========================================================================
218
+ // 3. Event Emission and Handling
219
+ // ===========================================================================
220
+ describe('Event RPC Methods', () => {
221
+ describe('emitBatch()', () => {
222
+ it('emits multiple events in a single RPC call', () => {
223
+ const info = service.createWithSetup(($) => {
224
+ $.on.Customer.created(() => {})
225
+ $.on.Order.placed(() => {})
226
+ })
227
+
228
+ const eventIds = service.emitBatch(info.id, [
229
+ { event: 'Customer.created', data: { id: '1' } },
230
+ { event: 'Order.placed', data: { id: '2' } },
231
+ ])
232
+
233
+ expect(eventIds).toHaveLength(2)
234
+ expect(eventIds.every((id) => typeof id === 'string')).toBe(true)
235
+ })
236
+
237
+ it('is atomic - all events succeed or none', () => {
238
+ const info = service.create('batch-atomic-test')
239
+
240
+ // Emitting to a workflow without handlers should still return event IDs
241
+ // But attempting to batch emit to non-existent workflow should fail all
242
+ expect(() => {
243
+ service.emitBatch('non-existent', [
244
+ { event: 'Test.event1', data: {} },
245
+ { event: 'Test.event2', data: {} },
246
+ ])
247
+ }).toThrow('not found')
248
+ })
249
+ })
250
+
251
+ describe('emitWithDelay()', () => {
252
+ it('schedules an event for future emission', async () => {
253
+ const info = service.createWithSetup(($) => {
254
+ $.on.Test.delayed(() => {})
255
+ })
256
+
257
+ const scheduledId = await service.emitWithDelay(
258
+ info.id,
259
+ 'Test.delayed',
260
+ { value: 42 },
261
+ 1000 // 1 second delay
262
+ )
263
+
264
+ expect(scheduledId).toBeDefined()
265
+ // The event should not have been processed yet
266
+ const pending = service.getPendingEvents(info.id)
267
+ expect(pending).toContainEqual(
268
+ expect.objectContaining({
269
+ event: 'Test.delayed',
270
+ scheduledFor: expect.any(Date),
271
+ })
272
+ )
273
+ })
274
+
275
+ it('supports cancellation of scheduled events', async () => {
276
+ const info = service.create('cancel-test')
277
+ const scheduledId = await service.emitWithDelay(info.id, 'Test.event', {}, 10000)
278
+
279
+ const cancelled = await service.cancelScheduledEvent(info.id, scheduledId)
280
+
281
+ expect(cancelled).toBe(true)
282
+ const pending = service.getPendingEvents(info.id)
283
+ expect(pending.find((e) => e.id === scheduledId)).toBeUndefined()
284
+ })
285
+ })
286
+
287
+ describe('subscribeToEvents()', () => {
288
+ it('returns event stream for a workflow', async () => {
289
+ const info = service.create('subscribe-test')
290
+ const events: Array<{ event: string; data: unknown }> = []
291
+
292
+ const subscription = service.subscribeToEvents(info.id, (event, data) => {
293
+ events.push({ event, data })
294
+ })
295
+
296
+ service.emit(info.id, 'Test.event', { value: 1 })
297
+ service.emit(info.id, 'Test.event', { value: 2 })
298
+
299
+ // Wait for events to be processed
300
+ await new Promise((resolve) => setTimeout(resolve, 100))
301
+
302
+ expect(events).toHaveLength(2)
303
+ subscription.unsubscribe()
304
+ })
305
+ })
306
+
307
+ describe('getEventHistory()', () => {
308
+ it('returns history of emitted events', () => {
309
+ const info = service.create('history-test')
310
+ service.emit(info.id, 'Test.event1', { a: 1 })
311
+ service.emit(info.id, 'Test.event2', { b: 2 })
312
+
313
+ const history = service.getEventHistory(info.id)
314
+
315
+ expect(history).toHaveLength(2)
316
+ expect(history[0]).toMatchObject({
317
+ event: 'Test.event1',
318
+ data: { a: 1 },
319
+ timestamp: expect.any(Date),
320
+ })
321
+ })
322
+
323
+ it('supports pagination', () => {
324
+ const info = service.create('paginated-history-test')
325
+ for (let i = 0; i < 10; i++) {
326
+ service.emit(info.id, 'Test.event', { i })
327
+ }
328
+
329
+ const page1 = service.getEventHistory(info.id, { limit: 5, offset: 0 })
330
+ const page2 = service.getEventHistory(info.id, { limit: 5, offset: 5 })
331
+
332
+ expect(page1).toHaveLength(5)
333
+ expect(page2).toHaveLength(5)
334
+ expect(page1[0].data).not.toEqual(page2[0].data)
335
+ })
336
+ })
337
+ })
338
+
339
+ // ===========================================================================
340
+ // 4. State Management
341
+ // ===========================================================================
342
+ describe('State RPC Methods', () => {
343
+ describe('getStateSnapshot()', () => {
344
+ it('returns immutable snapshot of current state', () => {
345
+ const info = service.create('snapshot-test', {
346
+ context: { mutable: 'value' },
347
+ })
348
+
349
+ const snapshot = service.getStateSnapshot(info.id)
350
+
351
+ // Modifying snapshot should not affect actual state
352
+ snapshot.context.mutable = 'changed'
353
+ expect(service.getValue(info.id, 'mutable')).toBe('value')
354
+ })
355
+ })
356
+
357
+ describe('mergeState()', () => {
358
+ it('deep merges state updates', () => {
359
+ const info = service.create('merge-test', {
360
+ context: {
361
+ user: { name: 'John', age: 30 },
362
+ settings: { theme: 'dark' },
363
+ },
364
+ })
365
+
366
+ service.mergeState(info.id, {
367
+ user: { age: 31, email: 'john@example.com' },
368
+ })
369
+
370
+ const state = service.getState(info.id)
371
+ expect(state.context).toEqual({
372
+ user: { name: 'John', age: 31, email: 'john@example.com' },
373
+ settings: { theme: 'dark' },
374
+ })
375
+ })
376
+ })
377
+
378
+ describe('deleteValue()', () => {
379
+ it('removes a key from workflow context', () => {
380
+ const info = service.create('delete-test', {
381
+ context: { keep: 'this', remove: 'this' },
382
+ })
383
+
384
+ service.deleteValue(info.id, 'remove')
385
+
386
+ const state = service.getState(info.id)
387
+ expect(state.context.keep).toBe('this')
388
+ expect(state.context.remove).toBeUndefined()
389
+ })
390
+ })
391
+
392
+ describe('hasValue()', () => {
393
+ it('checks if a key exists in context', () => {
394
+ const info = service.create('has-test', {
395
+ context: { exists: true },
396
+ })
397
+
398
+ expect(service.hasValue(info.id, 'exists')).toBe(true)
399
+ expect(service.hasValue(info.id, 'missing')).toBe(false)
400
+ })
401
+ })
402
+
403
+ describe('getValues()', () => {
404
+ it('returns multiple values in a single call', () => {
405
+ const info = service.create('multi-get-test', {
406
+ context: { a: 1, b: 2, c: 3 },
407
+ })
408
+
409
+ const values = service.getValues(info.id, ['a', 'c', 'missing'])
410
+
411
+ expect(values).toEqual({ a: 1, c: 3, missing: undefined })
412
+ })
413
+ })
414
+
415
+ describe('setValues()', () => {
416
+ it('sets multiple values in a single call', () => {
417
+ const info = service.create('multi-set-test')
418
+
419
+ service.setValues(info.id, { x: 10, y: 20, z: 30 })
420
+
421
+ expect(service.getValue(info.id, 'x')).toBe(10)
422
+ expect(service.getValue(info.id, 'y')).toBe(20)
423
+ expect(service.getValue(info.id, 'z')).toBe(30)
424
+ })
425
+ })
426
+ })
427
+
428
+ // ===========================================================================
429
+ // 5. Query and List Operations
430
+ // ===========================================================================
431
+ describe('Query RPC Methods', () => {
432
+ describe('listByName()', () => {
433
+ it('lists workflows by name pattern', () => {
434
+ service.create('order-workflow-1')
435
+ service.create('order-workflow-2')
436
+ service.create('customer-workflow-1')
437
+
438
+ const orderWorkflows = service.listByName('order-*')
439
+
440
+ expect(orderWorkflows).toHaveLength(2)
441
+ expect(orderWorkflows.every((id) => id.includes('order'))).toBe(false) // IDs don't contain name
442
+ // Verify by getting the info
443
+ for (const id of orderWorkflows) {
444
+ const info = service.get(id)
445
+ expect(info?.name).toMatch(/^order-workflow/)
446
+ }
447
+ })
448
+ })
449
+
450
+ describe('listByStatus()', () => {
451
+ it('lists workflows by running status', async () => {
452
+ const wf1 = service.create('running-1')
453
+ const wf2 = service.create('running-2')
454
+ const wf3 = service.create('stopped-1')
455
+
456
+ await service.start(wf1.id)
457
+ await service.start(wf2.id)
458
+ // wf3 not started
459
+
460
+ const running = service.listByStatus('running')
461
+ const stopped = service.listByStatus('stopped')
462
+
463
+ expect(running).toHaveLength(2)
464
+ expect(stopped).toHaveLength(1)
465
+ })
466
+ })
467
+
468
+ describe('count()', () => {
469
+ it('returns total count of workflows', () => {
470
+ service.create('count-1')
471
+ service.create('count-2')
472
+ service.create('count-3')
473
+
474
+ expect(service.count()).toBe(3)
475
+ })
476
+ })
477
+
478
+ describe('find()', () => {
479
+ it('finds workflows matching a predicate', () => {
480
+ service.create('findable-1', { context: { type: 'order' } })
481
+ service.create('findable-2', { context: { type: 'customer' } })
482
+ service.create('findable-3', { context: { type: 'order' } })
483
+
484
+ const orderWorkflows = service.find((info) => info.state.context.type === 'order')
485
+
486
+ expect(orderWorkflows).toHaveLength(2)
487
+ })
488
+ })
489
+
490
+ describe('getAll()', () => {
491
+ it('returns all workflow info objects', () => {
492
+ service.create('all-1')
493
+ service.create('all-2')
494
+
495
+ const all = service.getAll()
496
+
497
+ expect(all).toHaveLength(2)
498
+ expect(all[0]).toMatchObject({
499
+ id: expect.any(String),
500
+ name: expect.any(String),
501
+ started: false,
502
+ })
503
+ })
504
+ })
505
+ })
506
+
507
+ // ===========================================================================
508
+ // 6. Batch Operations
509
+ // ===========================================================================
510
+ describe('Batch RPC Methods', () => {
511
+ describe('startBatch()', () => {
512
+ it('starts multiple workflows in a single call', async () => {
513
+ const wf1 = service.create('batch-start-1')
514
+ const wf2 = service.create('batch-start-2')
515
+ const wf3 = service.create('batch-start-3')
516
+
517
+ const results = await service.startBatch([wf1.id, wf2.id, wf3.id])
518
+
519
+ expect(results.successful).toHaveLength(3)
520
+ expect(results.failed).toHaveLength(0)
521
+ expect(service.get(wf1.id)?.started).toBe(true)
522
+ expect(service.get(wf2.id)?.started).toBe(true)
523
+ expect(service.get(wf3.id)?.started).toBe(true)
524
+ })
525
+
526
+ it('reports partial failures', async () => {
527
+ const wf1 = service.create('batch-partial-1')
528
+
529
+ const results = await service.startBatch([wf1.id, 'non-existent'])
530
+
531
+ expect(results.successful).toContain(wf1.id)
532
+ expect(results.failed).toContainEqual({
533
+ id: 'non-existent',
534
+ error: expect.stringMatching(/not found/i),
535
+ })
536
+ })
537
+ })
538
+
539
+ describe('stopBatch()', () => {
540
+ it('stops multiple workflows in a single call', async () => {
541
+ const wf1 = service.create('batch-stop-1')
542
+ const wf2 = service.create('batch-stop-2')
543
+ await service.start(wf1.id)
544
+ await service.start(wf2.id)
545
+
546
+ const results = await service.stopBatch([wf1.id, wf2.id])
547
+
548
+ expect(results.successful).toHaveLength(2)
549
+ expect(service.get(wf1.id)?.started).toBe(false)
550
+ })
551
+ })
552
+
553
+ describe('destroyBatch()', () => {
554
+ it('destroys multiple workflows in a single call', async () => {
555
+ const wf1 = service.create('batch-destroy-1')
556
+ const wf2 = service.create('batch-destroy-2')
557
+
558
+ const results = await service.destroyBatch([wf1.id, wf2.id])
559
+
560
+ expect(results.successful).toHaveLength(2)
561
+ expect(service.has(wf1.id)).toBe(false)
562
+ expect(service.has(wf2.id)).toBe(false)
563
+ })
564
+ })
565
+
566
+ describe('emitToAll()', () => {
567
+ it('emits an event to all workflows', () => {
568
+ service.createWithSetup(($) => {
569
+ $.on.Global.broadcast(() => {})
570
+ })
571
+ service.createWithSetup(($) => {
572
+ $.on.Global.broadcast(() => {})
573
+ })
574
+
575
+ const results = service.emitToAll('Global.broadcast', { message: 'hello' })
576
+
577
+ expect(results.eventIds).toHaveLength(2)
578
+ })
579
+ })
580
+ })
581
+
582
+ // ===========================================================================
583
+ // 7. Workflow Introspection
584
+ // ===========================================================================
585
+ describe('Introspection RPC Methods', () => {
586
+ describe('getDefinition()', () => {
587
+ it('returns the workflow definition', () => {
588
+ const info = service.createWithSetup(($) => {
589
+ $.on.Customer.created(() => {})
590
+ $.on.Order.placed(() => {})
591
+ $.every.hour(() => {})
592
+ })
593
+
594
+ const definition = service.getDefinition(info.id)
595
+
596
+ expect(definition.events).toContainEqual({ noun: 'Customer', event: 'created' })
597
+ expect(definition.events).toContainEqual({ noun: 'Order', event: 'placed' })
598
+ expect(definition.schedules).toHaveLength(1)
599
+ })
600
+ })
601
+
602
+ describe('getRegisteredEvents()', () => {
603
+ it('returns list of events the workflow handles', () => {
604
+ const info = service.createWithSetup(($) => {
605
+ $.on.Customer.created(() => {})
606
+ $.on.Customer.updated(() => {})
607
+ $.on.Order.placed(() => {})
608
+ })
609
+
610
+ const events = service.getRegisteredEvents(info.id)
611
+
612
+ expect(events).toContain('Customer.created')
613
+ expect(events).toContain('Customer.updated')
614
+ expect(events).toContain('Order.placed')
615
+ })
616
+ })
617
+
618
+ describe('getRegisteredSchedules()', () => {
619
+ it('returns list of schedules the workflow handles', () => {
620
+ const info = service.createWithSetup(($) => {
621
+ $.every.hour(() => {})
622
+ $.every.minutes(30)(() => {})
623
+ })
624
+
625
+ const schedules = service.getRegisteredSchedules(info.id)
626
+
627
+ expect(schedules).toHaveLength(2)
628
+ expect(schedules).toContainEqual(expect.objectContaining({ type: 'hour' }))
629
+ expect(schedules).toContainEqual(expect.objectContaining({ type: 'minute', value: 30 }))
630
+ })
631
+ })
632
+
633
+ describe('canHandle()', () => {
634
+ it('checks if a workflow can handle a specific event', () => {
635
+ const info = service.createWithSetup(($) => {
636
+ $.on.Customer.created(() => {})
637
+ })
638
+
639
+ expect(service.canHandle(info.id, 'Customer.created')).toBe(true)
640
+ expect(service.canHandle(info.id, 'Order.placed')).toBe(false)
641
+ })
642
+ })
643
+ })
644
+
645
+ // ===========================================================================
646
+ // 8. Error Handling and Recovery
647
+ // ===========================================================================
648
+ describe('Error and Recovery RPC Methods', () => {
649
+ describe('getErrors()', () => {
650
+ it('returns list of errors that occurred in workflow', async () => {
651
+ const info = service.createWithSetup(($) => {
652
+ $.on.Test.error(() => {
653
+ throw new Error('Intentional error')
654
+ })
655
+ })
656
+
657
+ // Emit event that causes error
658
+ service.emit(info.id, 'Test.error', {})
659
+ await new Promise((resolve) => setTimeout(resolve, 100))
660
+
661
+ const errors = service.getErrors(info.id)
662
+
663
+ expect(errors).toContainEqual(
664
+ expect.objectContaining({
665
+ message: 'Intentional error',
666
+ event: 'Test.error',
667
+ timestamp: expect.any(Date),
668
+ })
669
+ )
670
+ })
671
+ })
672
+
673
+ describe('clearErrors()', () => {
674
+ it('clears error history for a workflow', async () => {
675
+ const info = service.createWithSetup(($) => {
676
+ $.on.Test.error(() => {
677
+ throw new Error('Error')
678
+ })
679
+ })
680
+
681
+ service.emit(info.id, 'Test.error', {})
682
+ await new Promise((resolve) => setTimeout(resolve, 100))
683
+
684
+ service.clearErrors(info.id)
685
+
686
+ expect(service.getErrors(info.id)).toHaveLength(0)
687
+ })
688
+ })
689
+
690
+ describe('retry()', () => {
691
+ it('retries the last failed operation', async () => {
692
+ let attempts = 0
693
+ const info = service.createWithSetup(($) => {
694
+ $.on.Test.retry(() => {
695
+ attempts++
696
+ if (attempts < 2) {
697
+ throw new Error('Retry me')
698
+ }
699
+ })
700
+ })
701
+
702
+ service.emit(info.id, 'Test.retry', {})
703
+ await new Promise((resolve) => setTimeout(resolve, 100))
704
+
705
+ const result = await service.retry(info.id)
706
+
707
+ expect(result.success).toBe(true)
708
+ expect(attempts).toBe(2)
709
+ })
710
+ })
711
+
712
+ describe('setErrorHandler()', () => {
713
+ it('sets a global error handler for workflow', () => {
714
+ const errors: Error[] = []
715
+ const info = service.createWithSetup(($) => {
716
+ $.on.Test.error(() => {
717
+ throw new Error('Caught error')
718
+ })
719
+ })
720
+
721
+ service.setErrorHandler(info.id, (error) => {
722
+ errors.push(error)
723
+ })
724
+
725
+ service.emit(info.id, 'Test.error', {})
726
+
727
+ // Error handler should be called
728
+ expect(errors).toHaveLength(1)
729
+ expect(errors[0].message).toBe('Caught error')
730
+ })
731
+ })
732
+ })
733
+
734
+ // ===========================================================================
735
+ // 9. Metrics and Observability
736
+ // ===========================================================================
737
+ describe('Metrics RPC Methods', () => {
738
+ describe('getMetrics()', () => {
739
+ it('returns workflow execution metrics', () => {
740
+ const info = service.create('metrics-test')
741
+ service.emit(info.id, 'Test.event1', {})
742
+ service.emit(info.id, 'Test.event2', {})
743
+
744
+ const metrics = service.getMetrics(info.id)
745
+
746
+ expect(metrics).toMatchObject({
747
+ eventsProcessed: expect.any(Number),
748
+ eventsEmitted: expect.any(Number),
749
+ errorCount: expect.any(Number),
750
+ uptime: expect.any(Number),
751
+ lastEventAt: expect.any(Date),
752
+ })
753
+ })
754
+ })
755
+
756
+ describe('getAggregateMetrics()', () => {
757
+ it('returns aggregate metrics across all workflows', () => {
758
+ service.create('agg-1')
759
+ service.create('agg-2')
760
+ service.emit(service.list()[0], 'Test.event', {})
761
+
762
+ const metrics = service.getAggregateMetrics()
763
+
764
+ expect(metrics).toMatchObject({
765
+ totalWorkflows: 2,
766
+ runningWorkflows: 0,
767
+ totalEventsProcessed: expect.any(Number),
768
+ totalErrors: expect.any(Number),
769
+ })
770
+ })
771
+ })
772
+
773
+ describe('resetMetrics()', () => {
774
+ it('resets metrics for a workflow', () => {
775
+ const info = service.create('reset-metrics-test')
776
+ service.emit(info.id, 'Test.event', {})
777
+
778
+ service.resetMetrics(info.id)
779
+
780
+ const metrics = service.getMetrics(info.id)
781
+ expect(metrics.eventsProcessed).toBe(0)
782
+ })
783
+ })
784
+ })
785
+
786
+ // ===========================================================================
787
+ // 10. Serialization for RPC
788
+ // ===========================================================================
789
+ describe('RPC Serialization', () => {
790
+ describe('toJSON()', () => {
791
+ it('serializes workflow info to JSON-safe format', () => {
792
+ const info = service.create('json-test', {
793
+ context: { date: new Date(), func: () => {} },
794
+ })
795
+
796
+ const json = service.toJSON(info.id)
797
+ const parsed = JSON.parse(JSON.stringify(json))
798
+
799
+ expect(parsed.id).toBe(info.id)
800
+ expect(parsed.name).toBe('json-test')
801
+ // Functions should be excluded
802
+ expect(parsed.state.context.func).toBeUndefined()
803
+ // Dates should be serialized
804
+ expect(typeof parsed.state.context.date).toBe('string')
805
+ })
806
+ })
807
+
808
+ describe('describe()', () => {
809
+ it('returns RPC interface description', () => {
810
+ const description = service.describe()
811
+
812
+ expect(description.methods).toContain('create')
813
+ expect(description.methods).toContain('start')
814
+ expect(description.methods).toContain('emit')
815
+ expect(description.version).toBeDefined()
816
+ })
817
+ })
818
+ })
819
+ })
820
+
821
+ // =============================================================================
822
+ // WorkflowService WorkerEntrypoint Tests
823
+ // =============================================================================
824
+ describe('RPC Surface: WorkflowService (WorkerEntrypoint)', () => {
825
+ describe('connect() with options', () => {
826
+ it('accepts configuration options', () => {
827
+ // This tests that connect() can accept optional configuration
828
+ // Note: We can't fully test WorkerEntrypoint without Workers runtime
829
+ expect(typeof WorkflowService.prototype.connect).toBe('function')
830
+ })
831
+ })
832
+
833
+ describe('RPC method signatures', () => {
834
+ it('all methods are callable via RPC', () => {
835
+ const service = new WorkflowServiceCore()
836
+
837
+ // Verify all expected RPC methods exist
838
+ const expectedMethods = [
839
+ // Creation
840
+ 'create',
841
+ 'createWithSetup',
842
+ 'createFromDefinition',
843
+ 'clone',
844
+ 'import',
845
+ 'export',
846
+ // Lifecycle
847
+ 'start',
848
+ 'stop',
849
+ 'pause',
850
+ 'resume',
851
+ 'restart',
852
+ 'destroy',
853
+ 'getStatus',
854
+ // Events
855
+ 'emit',
856
+ 'emitBatch',
857
+ 'emitWithDelay',
858
+ 'cancelScheduledEvent',
859
+ 'getPendingEvents',
860
+ 'subscribeToEvents',
861
+ 'getEventHistory',
862
+ 'sendGlobal',
863
+ // State
864
+ 'getState',
865
+ 'setState',
866
+ 'getValue',
867
+ 'getStateSnapshot',
868
+ 'mergeState',
869
+ 'deleteValue',
870
+ 'hasValue',
871
+ 'getValues',
872
+ 'setValues',
873
+ // Query
874
+ 'get',
875
+ 'list',
876
+ 'has',
877
+ 'listByName',
878
+ 'listByStatus',
879
+ 'count',
880
+ 'find',
881
+ 'getAll',
882
+ // Batch
883
+ 'startBatch',
884
+ 'stopBatch',
885
+ 'destroyBatch',
886
+ 'emitToAll',
887
+ // Introspection
888
+ 'getDefinition',
889
+ 'getRegisteredEvents',
890
+ 'getRegisteredSchedules',
891
+ 'canHandle',
892
+ // Errors
893
+ 'getErrors',
894
+ 'clearErrors',
895
+ 'retry',
896
+ 'setErrorHandler',
897
+ // Metrics
898
+ 'getMetrics',
899
+ 'getAggregateMetrics',
900
+ 'resetMetrics',
901
+ // Utilities
902
+ 'parseEvent',
903
+ 'toCron',
904
+ 'intervalToMs',
905
+ 'formatInterval',
906
+ 'createTestContext',
907
+ 'clear',
908
+ 'toJSON',
909
+ 'describe',
910
+ // Global handlers
911
+ 'registerGlobalEvent',
912
+ 'registerGlobalSchedule',
913
+ 'getGlobalEventHandlers',
914
+ 'getGlobalScheduleHandlers',
915
+ 'clearGlobalEventHandlers',
916
+ 'clearGlobalScheduleHandlers',
917
+ // State persistence
918
+ 'hasStatePersistence',
919
+ 'getStateAdapter',
920
+ 'persistState',
921
+ 'loadPersistedState',
922
+ 'saveCheckpoint',
923
+ 'getCheckpoint',
924
+ 'updateStateWithVersion',
925
+ 'queryByStatus',
926
+ 'queryByIds',
927
+ 'deletePersistedState',
928
+ 'listPersistedWorkflows',
929
+ 'createSnapshot',
930
+ 'restoreSnapshot',
931
+ 'getSnapshots',
932
+ // WorkflowBuilder
933
+ 'registerWorkflow',
934
+ ]
935
+
936
+ for (const method of expectedMethods) {
937
+ expect(
938
+ typeof (service as Record<string, unknown>)[method],
939
+ `Method ${method} should exist`
940
+ ).toBe('function')
941
+ }
942
+
943
+ service.clear()
944
+ })
945
+ })
946
+ })