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.
Files changed (188) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +14 -1
  3. package/README.md +2 -0
  4. package/dist/barrier.d.ts +6 -0
  5. package/dist/barrier.d.ts.map +1 -1
  6. package/dist/barrier.js +45 -7
  7. package/dist/barrier.js.map +1 -1
  8. package/dist/cascade-context.d.ts.map +1 -1
  9. package/dist/cascade-context.js +25 -25
  10. package/dist/cascade-context.js.map +1 -1
  11. package/dist/cascade-executor.d.ts.map +1 -1
  12. package/dist/cascade-executor.js +1 -1
  13. package/dist/cascade-executor.js.map +1 -1
  14. package/dist/context.d.ts.map +1 -1
  15. package/dist/context.js +23 -7
  16. package/dist/context.js.map +1 -1
  17. package/dist/cron-parser.d.ts +65 -0
  18. package/dist/cron-parser.d.ts.map +1 -0
  19. package/dist/cron-parser.js +294 -0
  20. package/dist/cron-parser.js.map +1 -0
  21. package/dist/cron-scheduler.d.ts +117 -0
  22. package/dist/cron-scheduler.d.ts.map +1 -0
  23. package/dist/cron-scheduler.js +176 -0
  24. package/dist/cron-scheduler.js.map +1 -0
  25. package/dist/database-context.d.ts +184 -0
  26. package/dist/database-context.d.ts.map +1 -0
  27. package/dist/database-context.js +428 -0
  28. package/dist/database-context.js.map +1 -0
  29. package/dist/digital-objects-adapter.d.ts +159 -0
  30. package/dist/digital-objects-adapter.d.ts.map +1 -0
  31. package/dist/digital-objects-adapter.js +229 -0
  32. package/dist/digital-objects-adapter.js.map +1 -0
  33. package/dist/durable-execution-cloudflare.d.ts +427 -0
  34. package/dist/durable-execution-cloudflare.d.ts.map +1 -0
  35. package/dist/durable-execution-cloudflare.js +510 -0
  36. package/dist/durable-execution-cloudflare.js.map +1 -0
  37. package/dist/durable-execution.d.ts +482 -0
  38. package/dist/durable-execution.d.ts.map +1 -0
  39. package/dist/durable-execution.js +594 -0
  40. package/dist/durable-execution.js.map +1 -0
  41. package/dist/durable-workflow.d.ts +176 -0
  42. package/dist/durable-workflow.d.ts.map +1 -0
  43. package/dist/durable-workflow.js +552 -0
  44. package/dist/durable-workflow.js.map +1 -0
  45. package/dist/graph/topological-sort.d.ts.map +1 -1
  46. package/dist/graph/topological-sort.js +5 -5
  47. package/dist/graph/topological-sort.js.map +1 -1
  48. package/dist/index.d.ts +4 -0
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +15 -0
  51. package/dist/index.js.map +1 -1
  52. package/dist/logger.d.ts +101 -0
  53. package/dist/logger.d.ts.map +1 -0
  54. package/dist/logger.js +115 -0
  55. package/dist/logger.js.map +1 -0
  56. package/dist/on.d.ts.map +1 -1
  57. package/dist/on.js +3 -3
  58. package/dist/on.js.map +1 -1
  59. package/dist/runtime.d.ts +169 -0
  60. package/dist/runtime.d.ts.map +1 -0
  61. package/dist/runtime.js +275 -0
  62. package/dist/runtime.js.map +1 -0
  63. package/dist/send.d.ts.map +1 -1
  64. package/dist/send.js +4 -3
  65. package/dist/send.js.map +1 -1
  66. package/dist/telemetry.d.ts +150 -0
  67. package/dist/telemetry.d.ts.map +1 -0
  68. package/dist/telemetry.js +388 -0
  69. package/dist/telemetry.js.map +1 -0
  70. package/dist/timer-registry.d.ts +25 -0
  71. package/dist/timer-registry.d.ts.map +1 -1
  72. package/dist/timer-registry.js +42 -8
  73. package/dist/timer-registry.js.map +1 -1
  74. package/dist/types.d.ts +17 -6
  75. package/dist/types.d.ts.map +1 -1
  76. package/dist/types.js +1 -1
  77. package/dist/types.js.map +1 -1
  78. package/dist/worker/durable-step.d.ts +481 -0
  79. package/dist/worker/durable-step.d.ts.map +1 -0
  80. package/dist/worker/durable-step.js +606 -0
  81. package/dist/worker/durable-step.js.map +1 -0
  82. package/dist/worker/index.d.ts +106 -0
  83. package/dist/worker/index.d.ts.map +1 -0
  84. package/dist/worker/index.js +124 -0
  85. package/dist/worker/index.js.map +1 -0
  86. package/dist/worker/state-adapter.d.ts +230 -0
  87. package/dist/worker/state-adapter.d.ts.map +1 -0
  88. package/dist/worker/state-adapter.js +409 -0
  89. package/dist/worker/state-adapter.js.map +1 -0
  90. package/dist/worker/topological-executor.d.ts +282 -0
  91. package/dist/worker/topological-executor.d.ts.map +1 -0
  92. package/dist/worker/topological-executor.js +396 -0
  93. package/dist/worker/topological-executor.js.map +1 -0
  94. package/dist/worker/workflow-builder.d.ts +286 -0
  95. package/dist/worker/workflow-builder.d.ts.map +1 -0
  96. package/dist/worker/workflow-builder.js +565 -0
  97. package/dist/worker/workflow-builder.js.map +1 -0
  98. package/dist/worker.d.ts +800 -0
  99. package/dist/worker.d.ts.map +1 -0
  100. package/dist/worker.js +2428 -0
  101. package/dist/worker.js.map +1 -0
  102. package/dist/workflow-builder.d.ts +287 -0
  103. package/dist/workflow-builder.d.ts.map +1 -0
  104. package/dist/workflow-builder.js +762 -0
  105. package/dist/workflow-builder.js.map +1 -0
  106. package/dist/workflow.d.ts +14 -30
  107. package/dist/workflow.d.ts.map +1 -1
  108. package/dist/workflow.js +132 -292
  109. package/dist/workflow.js.map +1 -1
  110. package/examples/01-ecommerce-order-pipeline.ts +358 -0
  111. package/examples/02-content-moderation-cascade.ts +454 -0
  112. package/examples/03-scheduled-reporting-dependencies.ts +479 -0
  113. package/examples/04-database-persistence.ts +518 -0
  114. package/examples/README.md +173 -0
  115. package/package.json +30 -13
  116. package/src/__tests__/digital-objects-adapter.test.ts +274 -0
  117. package/src/__tests__/durable-workflow.test.ts +297 -0
  118. package/src/barrier.ts +48 -7
  119. package/src/cascade-context.ts +36 -29
  120. package/src/cascade-executor.ts +3 -2
  121. package/src/context.ts +41 -12
  122. package/src/cron-parser.ts +347 -0
  123. package/src/cron-scheduler.ts +239 -0
  124. package/src/database-context.ts +658 -0
  125. package/src/digital-objects-adapter.ts +351 -0
  126. package/src/durable-execution-cloudflare.ts +855 -0
  127. package/src/durable-execution.ts +1042 -0
  128. package/src/durable-workflow.ts +717 -0
  129. package/src/graph/topological-sort.ts +6 -8
  130. package/src/index.ts +69 -0
  131. package/src/logger.ts +148 -0
  132. package/src/on.ts +8 -9
  133. package/src/runtime.ts +436 -0
  134. package/src/send.ts +4 -5
  135. package/src/telemetry.ts +577 -0
  136. package/src/timer-registry.ts +44 -10
  137. package/src/types.ts +32 -17
  138. package/src/worker/durable-step.ts +976 -0
  139. package/src/worker/index.ts +216 -0
  140. package/src/worker/state-adapter.ts +589 -0
  141. package/src/worker/topological-executor.ts +625 -0
  142. package/src/worker/workflow-builder.ts +871 -0
  143. package/src/worker.ts +2906 -0
  144. package/src/workflow-builder.ts +1068 -0
  145. package/src/workflow.ts +188 -351
  146. package/test/barrier-join.test.ts +32 -24
  147. package/test/cascade-executor.test.ts +9 -16
  148. package/test/cron-parser.test.ts +314 -0
  149. package/test/cron-scheduler.test.ts +291 -0
  150. package/test/database-context.test.ts +770 -0
  151. package/test/db-provider-adapter.test.ts +862 -0
  152. package/test/durable-execution-cloudflare.test.ts +606 -0
  153. package/test/durable-execution-in-process.test.ts +286 -0
  154. package/test/durable-execution.test.ts +247 -0
  155. package/test/e2e/workflow-scenarios.e2e.test.ts +1039 -0
  156. package/test/integration.test.ts +442 -0
  157. package/test/rpc-surface.test.ts +946 -0
  158. package/test/runtime.test.ts +262 -0
  159. package/test/schedule-timer-cleanup.test.ts +30 -21
  160. package/test/send-race-conditions.test.ts +30 -40
  161. package/test/worker/durable-cascade.test.ts +1117 -0
  162. package/test/worker/durable-step.test.ts +723 -0
  163. package/test/worker/topological-executor.test.ts +1240 -0
  164. package/test/worker/workflow-builder.test.ts +1067 -0
  165. package/test/worker.test.ts +608 -0
  166. package/test/workflow-builder.test.ts +1670 -0
  167. package/test/workflow-cron.test.ts +256 -0
  168. package/test/workflow-state-adapter.test.ts +923 -0
  169. package/test/workflow.test.ts +25 -22
  170. package/tsconfig.json +3 -1
  171. package/vitest.config.ts +38 -1
  172. package/vitest.workers.config.ts +44 -0
  173. package/wrangler.jsonc +22 -0
  174. package/.turbo/turbo-test.log +0 -169
  175. package/LICENSE +0 -21
  176. package/src/context.js +0 -83
  177. package/src/every.js +0 -267
  178. package/src/index.js +0 -71
  179. package/src/on.js +0 -79
  180. package/src/send.js +0 -111
  181. package/src/types.js +0 -4
  182. package/src/workflow.js +0 -455
  183. package/test/context.test.js +0 -116
  184. package/test/every.test.js +0 -282
  185. package/test/on.test.js +0 -80
  186. package/test/send.test.js +0 -89
  187. package/test/workflow.test.js +0 -224
  188. package/vitest.config.js +0 -7
@@ -0,0 +1,770 @@
1
+ /**
2
+ * DatabaseContext Tests
3
+ *
4
+ * Tests for the persistence layer using ai-database integration.
5
+ *
6
+ * ## Test Categories
7
+ * 1. Event sourcing (recordEvent, getEvents, replay)
8
+ * 2. Action management (createAction, completeAction)
9
+ * 3. Artifact storage (storeArtifact, getArtifact)
10
+ * 4. Snapshot management (createSnapshot, restoreSnapshot, getSnapshots)
11
+ * 5. Integration with ai-database
12
+ */
13
+
14
+ import { describe, it, expect, beforeEach } from 'vitest'
15
+ import {
16
+ createDatabaseContext,
17
+ createMemoryDatabaseContext,
18
+ type DatabaseProvider,
19
+ type EventsAPI,
20
+ type EventSourcingContext,
21
+ } from '../src/database-context.js'
22
+
23
+ /**
24
+ * In-memory database provider for testing
25
+ */
26
+ class MemoryDatabaseProvider implements DatabaseProvider {
27
+ private stores = new Map<string, Map<string, Record<string, unknown>>>()
28
+ private emittedEvents: Array<{ event: string; data: unknown }> = []
29
+
30
+ private getStore(type: string): Map<string, Record<string, unknown>> {
31
+ if (!this.stores.has(type)) {
32
+ this.stores.set(type, new Map())
33
+ }
34
+ return this.stores.get(type)!
35
+ }
36
+
37
+ async get(type: string, id: string): Promise<Record<string, unknown> | null> {
38
+ const store = this.getStore(type)
39
+ return store.get(id) ?? null
40
+ }
41
+
42
+ async create(
43
+ type: string,
44
+ data: Record<string, unknown>,
45
+ id?: string
46
+ ): Promise<Record<string, unknown>> {
47
+ const store = this.getStore(type)
48
+ const entityId = id ?? `id-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
49
+ const record = { ...data, $id: entityId, $type: type }
50
+ store.set(entityId, record)
51
+ return record
52
+ }
53
+
54
+ async update(
55
+ type: string,
56
+ id: string,
57
+ data: Record<string, unknown>
58
+ ): Promise<Record<string, unknown>> {
59
+ const store = this.getStore(type)
60
+ const existing = store.get(id)
61
+ const updated = { ...(existing ?? {}), ...data, $id: id, $type: type }
62
+ store.set(id, updated)
63
+ return updated
64
+ }
65
+
66
+ async delete(type: string, id: string): Promise<boolean> {
67
+ const store = this.getStore(type)
68
+ return store.delete(id)
69
+ }
70
+
71
+ async list(
72
+ type: string,
73
+ options?: { limit?: number; offset?: number; where?: Record<string, unknown> }
74
+ ): Promise<Record<string, unknown>[]> {
75
+ const store = this.getStore(type)
76
+ let results = Array.from(store.values())
77
+
78
+ if (options?.where) {
79
+ results = results.filter((record) => {
80
+ for (const [key, value] of Object.entries(options.where!)) {
81
+ if (record[key] !== value) {
82
+ return false
83
+ }
84
+ }
85
+ return true
86
+ })
87
+ }
88
+
89
+ if (options?.offset) {
90
+ results = results.slice(options.offset)
91
+ }
92
+
93
+ if (options?.limit) {
94
+ results = results.slice(0, options.limit)
95
+ }
96
+
97
+ return results
98
+ }
99
+
100
+ async emit(event: string, data: unknown): Promise<{ id: string }> {
101
+ this.emittedEvents.push({ event, data })
102
+ return { id: `event-${Date.now()}` }
103
+ }
104
+
105
+ getEmittedEvents(): Array<{ event: string; data: unknown }> {
106
+ return [...this.emittedEvents]
107
+ }
108
+
109
+ clear(): void {
110
+ this.stores.clear()
111
+ this.emittedEvents = []
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Mock Events API for testing
117
+ */
118
+ class MockEventsAPI implements EventsAPI {
119
+ private handlers = new Map<string, Set<(data: unknown) => void>>()
120
+ private emittedEvents: Array<{ event: string; data: unknown }> = []
121
+
122
+ on(event: string, handler: (data: unknown) => void): () => void {
123
+ if (!this.handlers.has(event)) {
124
+ this.handlers.set(event, new Set())
125
+ }
126
+ this.handlers.get(event)!.add(handler)
127
+ return () => {
128
+ this.handlers.get(event)?.delete(handler)
129
+ }
130
+ }
131
+
132
+ async emit(
133
+ eventOrData: string | { event: string; [key: string]: unknown },
134
+ data?: unknown
135
+ ): Promise<{ id: string }> {
136
+ const eventName = typeof eventOrData === 'string' ? eventOrData : eventOrData.event
137
+ const eventData = typeof eventOrData === 'string' ? data : eventOrData
138
+ this.emittedEvents.push({ event: eventName, data: eventData })
139
+
140
+ // Trigger handlers
141
+ const handlers = this.handlers.get(eventName)
142
+ if (handlers) {
143
+ for (const handler of handlers) {
144
+ handler(eventData)
145
+ }
146
+ }
147
+
148
+ return { id: `event-${Date.now()}` }
149
+ }
150
+
151
+ getEmittedEvents(): Array<{ event: string; data: unknown }> {
152
+ return [...this.emittedEvents]
153
+ }
154
+
155
+ clear(): void {
156
+ this.handlers.clear()
157
+ this.emittedEvents = []
158
+ }
159
+ }
160
+
161
+ describe('DatabaseContext', () => {
162
+ let db: MemoryDatabaseProvider
163
+ let events: MockEventsAPI
164
+ let ctx: EventSourcingContext
165
+
166
+ beforeEach(() => {
167
+ db = new MemoryDatabaseProvider()
168
+ events = new MockEventsAPI()
169
+ ctx = createDatabaseContext({
170
+ db,
171
+ events,
172
+ workflowId: 'test-workflow',
173
+ source: 'test',
174
+ })
175
+ })
176
+
177
+ describe('construction', () => {
178
+ it('creates context with database provider', () => {
179
+ const context = createDatabaseContext({ db })
180
+ expect(context).toBeDefined()
181
+ expect(context.recordEvent).toBeDefined()
182
+ expect(context.createAction).toBeDefined()
183
+ expect(context.storeArtifact).toBeDefined()
184
+ })
185
+
186
+ it('creates context with events API', () => {
187
+ const context = createDatabaseContext({ db, events })
188
+ expect(context).toBeDefined()
189
+ })
190
+
191
+ it('creates context with workflow ID', () => {
192
+ const context = createDatabaseContext({
193
+ db,
194
+ workflowId: 'my-workflow',
195
+ })
196
+ expect(context).toBeDefined()
197
+ })
198
+ })
199
+
200
+ describe('recordEvent() - event sourcing', () => {
201
+ it('records an event to database', async () => {
202
+ await ctx.recordEvent('Customer.created', { id: '123', name: 'John' })
203
+
204
+ const storedEvents = await ctx.getEvents()
205
+ expect(storedEvents).toHaveLength(1)
206
+ expect(storedEvents[0].eventType).toBe('Customer.created')
207
+ })
208
+
209
+ it('stores event data as JSON', async () => {
210
+ await ctx.recordEvent('Order.completed', { orderId: 'order-1', total: 99.99 })
211
+
212
+ const storedEvents = await ctx.getEvents()
213
+ const data = JSON.parse(storedEvents[0].data)
214
+ expect(data.orderId).toBe('order-1')
215
+ expect(data.total).toBe(99.99)
216
+ })
217
+
218
+ it('records timestamp on events', async () => {
219
+ const before = Date.now()
220
+ await ctx.recordEvent('Test.event', { value: 1 })
221
+ const after = Date.now()
222
+
223
+ const storedEvents = await ctx.getEvents()
224
+ expect(storedEvents[0].timestamp).toBeGreaterThanOrEqual(before)
225
+ expect(storedEvents[0].timestamp).toBeLessThanOrEqual(after)
226
+ })
227
+
228
+ it('includes workflow ID on events', async () => {
229
+ await ctx.recordEvent('Test.event', { value: 1 })
230
+
231
+ const storedEvents = await ctx.getEvents()
232
+ expect(storedEvents[0].workflowId).toBe('test-workflow')
233
+ })
234
+
235
+ it('emits event to events API', async () => {
236
+ await ctx.recordEvent('Customer.created', { id: '123' })
237
+
238
+ const emitted = events.getEmittedEvents()
239
+ expect(emitted.some((e) => e.event === 'WorkflowEvent.created')).toBe(true)
240
+ })
241
+
242
+ it('records multiple events in sequence', async () => {
243
+ await ctx.recordEvent('Step1.started', { step: 1 })
244
+ await ctx.recordEvent('Step1.completed', { step: 1, result: 'ok' })
245
+ await ctx.recordEvent('Step2.started', { step: 2 })
246
+
247
+ const storedEvents = await ctx.getEvents()
248
+ expect(storedEvents).toHaveLength(3)
249
+ expect(storedEvents.map((e) => e.eventType)).toEqual([
250
+ 'Step1.started',
251
+ 'Step1.completed',
252
+ 'Step2.started',
253
+ ])
254
+ })
255
+ })
256
+
257
+ describe('getEvents() - event retrieval', () => {
258
+ beforeEach(async () => {
259
+ await ctx.recordEvent('Event1', { seq: 1 })
260
+ await new Promise((r) => setTimeout(r, 10))
261
+ await ctx.recordEvent('Event2', { seq: 2 })
262
+ await new Promise((r) => setTimeout(r, 10))
263
+ await ctx.recordEvent('Event3', { seq: 3 })
264
+ })
265
+
266
+ it('returns all events', async () => {
267
+ const storedEvents = await ctx.getEvents()
268
+ expect(storedEvents).toHaveLength(3)
269
+ })
270
+
271
+ it('returns events in timestamp order', async () => {
272
+ const storedEvents = await ctx.getEvents()
273
+ for (let i = 1; i < storedEvents.length; i++) {
274
+ expect(storedEvents[i].timestamp).toBeGreaterThanOrEqual(storedEvents[i - 1].timestamp)
275
+ }
276
+ })
277
+
278
+ it('filters events by since timestamp', async () => {
279
+ const storedEvents = await ctx.getEvents()
280
+ const midTimestamp = storedEvents[1].timestamp
281
+
282
+ const filtered = await ctx.getEvents({ since: new Date(midTimestamp) })
283
+ expect(filtered.length).toBeGreaterThanOrEqual(1)
284
+ })
285
+
286
+ it('limits number of events returned', async () => {
287
+ const storedEvents = await ctx.getEvents({ limit: 2 })
288
+ expect(storedEvents).toHaveLength(2)
289
+ })
290
+ })
291
+
292
+ describe('replay() - event replay', () => {
293
+ beforeEach(async () => {
294
+ await ctx.recordEvent('Step1.completed', { result: 'a' })
295
+ await ctx.recordEvent('Step2.completed', { result: 'b' })
296
+ await ctx.recordEvent('Step3.completed', { result: 'c' })
297
+ })
298
+
299
+ it('replays all events through handler', async () => {
300
+ const replayed: Array<{ event: string; data: unknown }> = []
301
+
302
+ await ctx.replay(async (event, data) => {
303
+ replayed.push({ event, data })
304
+ })
305
+
306
+ expect(replayed).toHaveLength(3)
307
+ expect(replayed.map((r) => r.event)).toEqual([
308
+ 'Step1.completed',
309
+ 'Step2.completed',
310
+ 'Step3.completed',
311
+ ])
312
+ })
313
+
314
+ it('replays events in order', async () => {
315
+ const results: string[] = []
316
+
317
+ await ctx.replay(async (event, data) => {
318
+ results.push((data as { result: string }).result)
319
+ })
320
+
321
+ expect(results).toEqual(['a', 'b', 'c'])
322
+ })
323
+
324
+ it('can reconstruct state from events', async () => {
325
+ const state: Record<string, unknown> = {}
326
+
327
+ await ctx.replay(async (event, data) => {
328
+ const stepNumber = event.match(/Step(\d+)/)?.[1]
329
+ if (stepNumber) {
330
+ state[`step${stepNumber}`] = (data as { result: string }).result
331
+ }
332
+ })
333
+
334
+ expect(state).toEqual({ step1: 'a', step2: 'b', step3: 'c' })
335
+ })
336
+
337
+ it('replays events since a given time', async () => {
338
+ const storedEvents = await ctx.getEvents()
339
+ const midTimestamp = storedEvents[1].timestamp
340
+
341
+ const replayed: string[] = []
342
+ await ctx.replay(
343
+ async (event) => {
344
+ replayed.push(event)
345
+ },
346
+ { since: new Date(midTimestamp) }
347
+ )
348
+
349
+ expect(replayed.length).toBeGreaterThanOrEqual(1)
350
+ })
351
+ })
352
+
353
+ describe('createAction() - action management', () => {
354
+ it('creates a pending action', async () => {
355
+ await ctx.createAction({
356
+ actor: 'user:john',
357
+ object: 'Order/order-123',
358
+ action: 'approve',
359
+ })
360
+
361
+ const actions = await db.list('WorkflowAction')
362
+ expect(actions).toHaveLength(1)
363
+ expect(actions[0].actor).toBe('user:john')
364
+ expect(actions[0].status).toBe('pending')
365
+ })
366
+
367
+ it('stores action metadata', async () => {
368
+ await ctx.createAction({
369
+ actor: 'system',
370
+ object: 'Report/report-1',
371
+ action: 'generate',
372
+ metadata: { format: 'pdf', pages: 10 },
373
+ })
374
+
375
+ const actions = await db.list('WorkflowAction')
376
+ const metadata = JSON.parse(actions[0].metadata as string)
377
+ expect(metadata.format).toBe('pdf')
378
+ expect(metadata.pages).toBe(10)
379
+ })
380
+
381
+ it('emits action created event', async () => {
382
+ await ctx.createAction({
383
+ actor: 'user:alice',
384
+ object: 'Document/doc-1',
385
+ action: 'review',
386
+ })
387
+
388
+ const emitted = events.getEmittedEvents()
389
+ expect(emitted.some((e) => e.event === 'WorkflowAction.created')).toBe(true)
390
+ })
391
+ })
392
+
393
+ describe('completeAction() - action completion', () => {
394
+ it('marks action as completed', async () => {
395
+ await ctx.createAction({
396
+ actor: 'user:john',
397
+ object: 'Order/order-123',
398
+ action: 'approve',
399
+ })
400
+
401
+ const actions = await db.list('WorkflowAction')
402
+ const actionId = actions[0].$id as string
403
+
404
+ await ctx.completeAction(actionId, { approved: true })
405
+
406
+ const updated = await db.get('WorkflowAction', actionId)
407
+ expect(updated?.status).toBe('completed')
408
+ })
409
+
410
+ it('stores action result', async () => {
411
+ await ctx.createAction({
412
+ actor: 'system',
413
+ object: 'Task/task-1',
414
+ action: 'process',
415
+ })
416
+
417
+ const actions = await db.list('WorkflowAction')
418
+ const actionId = actions[0].$id as string
419
+
420
+ await ctx.completeAction(actionId, { output: 'processed', items: 42 })
421
+
422
+ const updated = await db.get('WorkflowAction', actionId)
423
+ const result = JSON.parse(updated?.result as string)
424
+ expect(result.output).toBe('processed')
425
+ expect(result.items).toBe(42)
426
+ })
427
+
428
+ it('throws error for non-existent action', async () => {
429
+ await expect(ctx.completeAction('non-existent', {})).rejects.toThrow('Action not found')
430
+ })
431
+
432
+ it('emits action completed event', async () => {
433
+ await ctx.createAction({
434
+ actor: 'user:bob',
435
+ object: 'Request/req-1',
436
+ action: 'approve',
437
+ })
438
+
439
+ const actions = await db.list('WorkflowAction')
440
+ const actionId = actions[0].$id as string
441
+
442
+ await ctx.completeAction(actionId, { approved: true })
443
+
444
+ const emitted = events.getEmittedEvents()
445
+ expect(emitted.some((e) => e.event === 'WorkflowAction.completed')).toBe(true)
446
+ })
447
+ })
448
+
449
+ describe('storeArtifact() - artifact storage', () => {
450
+ it('stores an artifact', async () => {
451
+ await ctx.storeArtifact({
452
+ key: 'compiled/workflow-1/code.esm',
453
+ type: 'esm',
454
+ sourceHash: 'abc123',
455
+ content: 'export function handler() {}',
456
+ })
457
+
458
+ const stored = await ctx.getArtifact('compiled/workflow-1/code.esm')
459
+ expect(stored).toBe('export function handler() {}')
460
+ })
461
+
462
+ it('stores complex artifact content', async () => {
463
+ const ast = {
464
+ type: 'Program',
465
+ body: [{ type: 'ExportDeclaration' }],
466
+ }
467
+
468
+ await ctx.storeArtifact({
469
+ key: 'parsed/module.ast',
470
+ type: 'ast',
471
+ sourceHash: 'def456',
472
+ content: ast,
473
+ })
474
+
475
+ const stored = await ctx.getArtifact('parsed/module.ast')
476
+ expect(stored).toEqual(ast)
477
+ })
478
+
479
+ it('stores artifact metadata', async () => {
480
+ await ctx.storeArtifact({
481
+ key: 'bundle/app.js',
482
+ type: 'bundle',
483
+ sourceHash: 'ghi789',
484
+ content: 'bundled code',
485
+ metadata: { size: 1024, modules: 5 },
486
+ })
487
+
488
+ // Metadata is stored but getArtifact only returns content
489
+ const stored = await ctx.getArtifact('bundle/app.js')
490
+ expect(stored).toBe('bundled code')
491
+ })
492
+
493
+ it('returns null for non-existent artifact', async () => {
494
+ const stored = await ctx.getArtifact('non-existent')
495
+ expect(stored).toBeNull()
496
+ })
497
+
498
+ it('overwrites existing artifact with same key', async () => {
499
+ await ctx.storeArtifact({
500
+ key: 'cache/data',
501
+ type: 'bundle',
502
+ sourceHash: 'v1',
503
+ content: 'version 1',
504
+ })
505
+
506
+ await ctx.storeArtifact({
507
+ key: 'cache/data',
508
+ type: 'bundle',
509
+ sourceHash: 'v2',
510
+ content: 'version 2',
511
+ })
512
+
513
+ const stored = await ctx.getArtifact('cache/data')
514
+ expect(stored).toBe('version 2')
515
+ })
516
+ })
517
+
518
+ describe('createSnapshot() - state snapshots', () => {
519
+ it('creates a snapshot of current state', async () => {
520
+ const state = { step: 3, context: { userId: '123' } }
521
+ const snapshotId = await ctx.createSnapshot(state)
522
+
523
+ expect(snapshotId).toBeDefined()
524
+ expect(snapshotId).toContain('snap-')
525
+ })
526
+
527
+ it('creates snapshot with label', async () => {
528
+ const snapshotId = await ctx.createSnapshot({ data: 'important' }, 'before-risky-operation')
529
+
530
+ const snapshots = await ctx.getSnapshots()
531
+ const snapshot = snapshots.find((s) => s.id === snapshotId)
532
+ expect(snapshot?.label).toBe('before-risky-operation')
533
+ })
534
+
535
+ it('records event sequence in snapshot', async () => {
536
+ await ctx.recordEvent('Event1', { seq: 1 })
537
+ await ctx.recordEvent('Event2', { seq: 2 })
538
+
539
+ const snapshotId = await ctx.createSnapshot({ step: 2 })
540
+
541
+ // Verify sequence is tracked
542
+ expect(ctx.getEventSequence()).toBe(2)
543
+ })
544
+
545
+ it('emits snapshot created event', async () => {
546
+ await ctx.createSnapshot({ state: 'test' })
547
+
548
+ const emitted = events.getEmittedEvents()
549
+ expect(emitted.some((e) => e.event === 'WorkflowSnapshot.created')).toBe(true)
550
+ })
551
+ })
552
+
553
+ describe('restoreSnapshot() - state restoration', () => {
554
+ it('restores state from snapshot', async () => {
555
+ const originalState = { step: 5, data: { processed: true } }
556
+ const snapshotId = await ctx.createSnapshot(originalState)
557
+
558
+ const restored = await ctx.restoreSnapshot(snapshotId)
559
+ expect(restored).toEqual(originalState)
560
+ })
561
+
562
+ it('throws error for non-existent snapshot', async () => {
563
+ await expect(ctx.restoreSnapshot('non-existent')).rejects.toThrow('Snapshot not found')
564
+ })
565
+
566
+ it('restores event sequence from snapshot', async () => {
567
+ await ctx.recordEvent('Event1', {})
568
+ await ctx.recordEvent('Event2', {})
569
+ const snapshotId = await ctx.createSnapshot({ at: 2 })
570
+
571
+ // Record more events
572
+ await ctx.recordEvent('Event3', {})
573
+ await ctx.recordEvent('Event4', {})
574
+
575
+ // Restore should reset sequence
576
+ await ctx.restoreSnapshot(snapshotId)
577
+ expect(ctx.getEventSequence()).toBe(2)
578
+ })
579
+
580
+ it('emits snapshot restored event', async () => {
581
+ const snapshotId = await ctx.createSnapshot({ data: 'test' })
582
+ await ctx.restoreSnapshot(snapshotId)
583
+
584
+ const emitted = events.getEmittedEvents()
585
+ expect(emitted.some((e) => e.event === 'WorkflowSnapshot.restored')).toBe(true)
586
+ })
587
+ })
588
+
589
+ describe('getSnapshots() - snapshot listing', () => {
590
+ it('returns all snapshots for workflow', async () => {
591
+ await ctx.createSnapshot({ v: 1 }, 'checkpoint-1')
592
+ await ctx.createSnapshot({ v: 2 }, 'checkpoint-2')
593
+ await ctx.createSnapshot({ v: 3 }, 'checkpoint-3')
594
+
595
+ const snapshots = await ctx.getSnapshots()
596
+ expect(snapshots).toHaveLength(3)
597
+ })
598
+
599
+ it('returns snapshots in reverse chronological order', async () => {
600
+ await ctx.createSnapshot({ v: 1 }, 'first')
601
+ await new Promise((r) => setTimeout(r, 10))
602
+ await ctx.createSnapshot({ v: 2 }, 'second')
603
+ await new Promise((r) => setTimeout(r, 10))
604
+ await ctx.createSnapshot({ v: 3 }, 'third')
605
+
606
+ const snapshots = await ctx.getSnapshots()
607
+ expect(snapshots[0].label).toBe('third')
608
+ expect(snapshots[2].label).toBe('first')
609
+ })
610
+
611
+ it('returns empty array when no snapshots exist', async () => {
612
+ const snapshots = await ctx.getSnapshots()
613
+ expect(snapshots).toEqual([])
614
+ })
615
+ })
616
+
617
+ describe('getEventSequence() - sequence tracking', () => {
618
+ it('starts at 0', () => {
619
+ const newCtx = createDatabaseContext({ db })
620
+ expect(newCtx.getEventSequence()).toBe(0)
621
+ })
622
+
623
+ it('increments with each event', async () => {
624
+ expect(ctx.getEventSequence()).toBe(0)
625
+
626
+ await ctx.recordEvent('Event1', {})
627
+ expect(ctx.getEventSequence()).toBe(1)
628
+
629
+ await ctx.recordEvent('Event2', {})
630
+ expect(ctx.getEventSequence()).toBe(2)
631
+
632
+ await ctx.recordEvent('Event3', {})
633
+ expect(ctx.getEventSequence()).toBe(3)
634
+ })
635
+ })
636
+ })
637
+
638
+ describe('createMemoryDatabaseContext', () => {
639
+ let ctx: EventSourcingContext
640
+
641
+ beforeEach(() => {
642
+ ctx = createMemoryDatabaseContext()
643
+ })
644
+
645
+ describe('in-memory implementation', () => {
646
+ it('provides all DatabaseContext methods', () => {
647
+ expect(ctx.recordEvent).toBeDefined()
648
+ expect(ctx.createAction).toBeDefined()
649
+ expect(ctx.completeAction).toBeDefined()
650
+ expect(ctx.storeArtifact).toBeDefined()
651
+ expect(ctx.getArtifact).toBeDefined()
652
+ })
653
+
654
+ it('provides event sourcing methods', () => {
655
+ expect(ctx.getEvents).toBeDefined()
656
+ expect(ctx.replay).toBeDefined()
657
+ expect(ctx.createSnapshot).toBeDefined()
658
+ expect(ctx.restoreSnapshot).toBeDefined()
659
+ expect(ctx.getSnapshots).toBeDefined()
660
+ expect(ctx.getEventSequence).toBeDefined()
661
+ })
662
+
663
+ it('records and retrieves events', async () => {
664
+ await ctx.recordEvent('Test.event', { value: 42 })
665
+
666
+ const storedEvents = await ctx.getEvents()
667
+ expect(storedEvents).toHaveLength(1)
668
+
669
+ const data = JSON.parse(storedEvents[0].data)
670
+ expect(data.value).toBe(42)
671
+ })
672
+
673
+ it('manages actions', async () => {
674
+ await ctx.createAction({
675
+ actor: 'test',
676
+ object: 'Test/1',
677
+ action: 'process',
678
+ })
679
+
680
+ // Action was created (no direct query, but completeAction would fail if not)
681
+ await expect(ctx.completeAction('non-existent', {})).rejects.toThrow()
682
+ })
683
+
684
+ it('stores and retrieves artifacts', async () => {
685
+ await ctx.storeArtifact({
686
+ key: 'test-key',
687
+ type: 'bundle',
688
+ sourceHash: 'hash123',
689
+ content: { data: 'test' },
690
+ })
691
+
692
+ const artifact = await ctx.getArtifact('test-key')
693
+ expect(artifact).toEqual({ data: 'test' })
694
+ })
695
+
696
+ it('creates and restores snapshots', async () => {
697
+ const state = { step: 5, data: { items: [1, 2, 3] } }
698
+ const snapshotId = await ctx.createSnapshot(state, 'test-snapshot')
699
+
700
+ const restored = await ctx.restoreSnapshot(snapshotId)
701
+ expect(restored).toEqual(state)
702
+ })
703
+
704
+ it('replays events', async () => {
705
+ await ctx.recordEvent('A', { seq: 1 })
706
+ await ctx.recordEvent('B', { seq: 2 })
707
+ await ctx.recordEvent('C', { seq: 3 })
708
+
709
+ const replayed: number[] = []
710
+ await ctx.replay(async (event, data) => {
711
+ replayed.push((data as { seq: number }).seq)
712
+ })
713
+
714
+ expect(replayed).toEqual([1, 2, 3])
715
+ })
716
+
717
+ it('tracks event sequence', async () => {
718
+ expect(ctx.getEventSequence()).toBe(0)
719
+
720
+ await ctx.recordEvent('Event', {})
721
+ expect(ctx.getEventSequence()).toBe(1)
722
+ })
723
+ })
724
+ })
725
+
726
+ describe('Workflow integration', () => {
727
+ it('DatabaseContext can be used with Workflow options', async () => {
728
+ const ctx = createMemoryDatabaseContext()
729
+
730
+ // This test verifies the type compatibility
731
+ // In real usage:
732
+ // const workflow = Workflow($ => { ... }, { db: ctx })
733
+
734
+ expect(ctx.recordEvent).toBeDefined()
735
+ expect(ctx.createAction).toBeDefined()
736
+ expect(ctx.completeAction).toBeDefined()
737
+ expect(ctx.storeArtifact).toBeDefined()
738
+ expect(ctx.getArtifact).toBeDefined()
739
+ })
740
+
741
+ it('supports full event sourcing workflow', async () => {
742
+ const ctx = createMemoryDatabaseContext()
743
+
744
+ // Simulate workflow execution with event sourcing
745
+ await ctx.recordEvent('Workflow.started', { input: { orderId: 'order-1' } })
746
+ await ctx.recordEvent('Step.validate.completed', { valid: true })
747
+ await ctx.recordEvent('Step.process.completed', { processed: true })
748
+ await ctx.recordEvent('Workflow.completed', { output: { success: true } })
749
+
750
+ // Create snapshot for recovery
751
+ const snapshotId = await ctx.createSnapshot({ status: 'completed', output: { success: true } })
752
+
753
+ // Replay events to reconstruct state
754
+ const reconstructed: Record<string, unknown> = {}
755
+ await ctx.replay(async (event, data) => {
756
+ if (event === 'Workflow.started') {
757
+ reconstructed.input = (data as { input: unknown }).input
758
+ } else if (event === 'Workflow.completed') {
759
+ reconstructed.output = (data as { output: unknown }).output
760
+ }
761
+ })
762
+
763
+ expect(reconstructed.input).toEqual({ orderId: 'order-1' })
764
+ expect(reconstructed.output).toEqual({ success: true })
765
+
766
+ // Restore from snapshot
767
+ const restored = await ctx.restoreSnapshot(snapshotId)
768
+ expect(restored).toEqual({ status: 'completed', output: { success: true } })
769
+ })
770
+ })