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,400 @@
1
+ /**
2
+ * Tests for EventBus race conditions
3
+ *
4
+ * These tests expose the race condition in send.ts:34-42 where concurrent
5
+ * emit() calls can cause events to be skipped or processed out of order.
6
+ *
7
+ * This is the RED phase of TDD - these tests are expected to fail or be flaky.
8
+ *
9
+ * KNOWN ISSUES EXPOSED:
10
+ * 1. send() returns before event is processed when processing=true
11
+ * 2. Events can be skipped when concurrent emit() calls overlap
12
+ * 3. Cascaded events ($.send inside handlers) don't await properly
13
+ * 4. Global EventBus can get stuck with processing=true
14
+ *
15
+ * TODO: These tests are skipped until the race conditions are fixed.
16
+ * @see https://github.com/org-ai/primitives/issues/XXX
17
+ */
18
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
19
+ import { send, getEventBus } from '../src/send.js'
20
+ import { on, clearEventHandlers } from '../src/on.js'
21
+
22
+ /**
23
+ * Helper to wait for the EventBus to finish processing.
24
+ * This is a workaround for the race condition where send() returns early.
25
+ */
26
+ async function waitForEventBus(maxWaitMs = 500): Promise<void> {
27
+ const start = Date.now()
28
+ // Poll until the bus is idle or timeout
29
+ while (Date.now() - start < maxWaitMs) {
30
+ // Give the event loop a chance to process
31
+ await new Promise((resolve) => setImmediate(resolve))
32
+ }
33
+ }
34
+
35
+ describe.skip('EventBus race conditions', () => {
36
+ beforeEach(async () => {
37
+ clearEventHandlers()
38
+ // Wait for any pending processing from previous tests
39
+ await waitForEventBus(50)
40
+ })
41
+
42
+ afterEach(async () => {
43
+ clearEventHandlers()
44
+ // Wait for any pending processing before next test
45
+ await waitForEventBus(50)
46
+ })
47
+
48
+ describe('concurrent emit() calls', () => {
49
+ /**
50
+ * Race condition: When multiple emit() calls happen concurrently,
51
+ * some events may not be delivered because:
52
+ * 1. emit() pushes to pending (line 24)
53
+ * 2. emit() checks !this.processing (line 26)
54
+ * 3. If another emit() pushed but process() hasn't set processing=true yet,
55
+ * multiple calls can enter process() simultaneously
56
+ */
57
+ it('should deliver all events when emit() is called concurrently', async () => {
58
+ const receivedEvents: string[] = []
59
+ const eventCount = 100
60
+
61
+ // Register a handler that records all received events
62
+ on.Test.event(async (data: { id: number }) => {
63
+ receivedEvents.push(`event-${data.id}`)
64
+ })
65
+
66
+ // Fire all events concurrently without awaiting
67
+ const promises = Array.from({ length: eventCount }, (_, i) => send('Test.event', { id: i }))
68
+
69
+ // Wait for all emits to complete
70
+ await Promise.all(promises)
71
+
72
+ // All events should be delivered
73
+ expect(receivedEvents.length).toBe(eventCount)
74
+
75
+ // Verify all event IDs are present
76
+ const expectedIds = Array.from({ length: eventCount }, (_, i) => `event-${i}`)
77
+ expect(receivedEvents.sort()).toEqual(expectedIds.sort())
78
+ })
79
+
80
+ /**
81
+ * Race condition: Events fired during handler execution may complete
82
+ * before the send() caller gets control back.
83
+ *
84
+ * When emit() is called while process() is running:
85
+ * 1. Event is pushed to pending
86
+ * 2. this.processing is true, so emit() returns immediately
87
+ * 3. The caller's await send() resolves before the event is processed
88
+ */
89
+ it('should ensure send() resolves only after the event is fully processed', async () => {
90
+ let eventProcessed = false
91
+
92
+ on.Test.event(async () => {
93
+ // Simulate async work
94
+ await new Promise((resolve) => setTimeout(resolve, 50))
95
+ eventProcessed = true
96
+ })
97
+
98
+ // First emit to start processing
99
+ const firstSend = send('Test.event', { id: 1 })
100
+
101
+ // Second emit while first is processing
102
+ // Due to the race condition, this may resolve before the handler completes
103
+ await new Promise((resolve) => setTimeout(resolve, 10))
104
+ const secondSend = send('Test.event', { id: 2 })
105
+
106
+ // When send() resolves, the event should be processed
107
+ await secondSend
108
+
109
+ // This assertion may fail due to race condition:
110
+ // secondSend resolves immediately because processing=true,
111
+ // but the event hasn't been processed yet
112
+ expect(eventProcessed).toBe(true)
113
+
114
+ await firstSend // Clean up
115
+ })
116
+
117
+ /**
118
+ * Race condition: Handler that emits more events creates a cascade
119
+ * where new events may not be awaited properly.
120
+ */
121
+ it('should properly await cascaded events from handlers', async () => {
122
+ const executionOrder: string[] = []
123
+
124
+ on.Step.one(async (_data, $) => {
125
+ executionOrder.push('step-1-start')
126
+ await new Promise((resolve) => setTimeout(resolve, 20))
127
+ await $.send('Step.two', {})
128
+ executionOrder.push('step-1-end')
129
+ })
130
+
131
+ on.Step.two(async () => {
132
+ executionOrder.push('step-2-start')
133
+ await new Promise((resolve) => setTimeout(resolve, 20))
134
+ executionOrder.push('step-2-end')
135
+ })
136
+
137
+ await send('Step.one', {})
138
+
139
+ // Expected order: step-1-start, step-2-start, step-2-end, step-1-end
140
+ // But due to race condition, step-1-end may come before step-2-end
141
+ expect(executionOrder).toEqual(['step-1-start', 'step-2-start', 'step-2-end', 'step-1-end'])
142
+ })
143
+
144
+ /**
145
+ * Race condition: Multiple concurrent emits may cause the processing
146
+ * flag to be set to false prematurely, causing events to be skipped.
147
+ */
148
+ it('should not skip events when multiple process() calls overlap', async () => {
149
+ const processedEvents: number[] = []
150
+ const totalEvents = 50
151
+
152
+ on.Concurrent.event(async (data: { id: number }) => {
153
+ // Small delay to increase chance of race condition
154
+ await new Promise((resolve) => setTimeout(resolve, Math.random() * 5))
155
+ processedEvents.push(data.id)
156
+ })
157
+
158
+ // Fire events with slight delays to maximize race window
159
+ const promises: Promise<void>[] = []
160
+ for (let i = 0; i < totalEvents; i++) {
161
+ promises.push(send('Concurrent.event', { id: i }))
162
+ // Tiny delay to spread out the calls
163
+ if (i % 10 === 0) {
164
+ await new Promise((resolve) => setTimeout(resolve, 1))
165
+ }
166
+ }
167
+
168
+ await Promise.all(promises)
169
+
170
+ // Wait a bit more for any stragglers
171
+ await new Promise((resolve) => setTimeout(resolve, 100))
172
+
173
+ // All events should be processed
174
+ expect(processedEvents.length).toBe(totalEvents)
175
+ })
176
+
177
+ /**
178
+ * Race condition: Events may be lost if emit() returns before
179
+ * processing completes, and the event was pushed during the
180
+ * while loop's shift() operation.
181
+ */
182
+ it('should handle rapid fire events without losing any', async () => {
183
+ let eventCount = 0
184
+ const targetCount = 1000
185
+
186
+ on.Rapid.fire(async () => {
187
+ eventCount++
188
+ })
189
+
190
+ // Fire events as fast as possible
191
+ const promises = Array.from({ length: targetCount }, () => send('Rapid.fire', {}))
192
+
193
+ await Promise.all(promises)
194
+
195
+ // Allow any pending processing to complete
196
+ await new Promise((resolve) => setTimeout(resolve, 100))
197
+
198
+ expect(eventCount).toBe(targetCount)
199
+ })
200
+
201
+ /**
202
+ * This test specifically targets the race between lines 26-28 and 35
203
+ * where multiple emit() calls can both see processing=false before
204
+ * either sets it to true.
205
+ */
206
+ it('should serialize event processing even with synchronous concurrent emits', async () => {
207
+ const processingConcurrency: number[] = []
208
+ let currentlyProcessing = 0
209
+ let maxConcurrency = 0
210
+
211
+ on.Serialize.check(async () => {
212
+ currentlyProcessing++
213
+ maxConcurrency = Math.max(maxConcurrency, currentlyProcessing)
214
+ processingConcurrency.push(currentlyProcessing)
215
+ // Simulate work
216
+ await new Promise((resolve) => setTimeout(resolve, 10))
217
+ currentlyProcessing--
218
+ })
219
+
220
+ // Fire many events simultaneously
221
+ const promises = Array.from({ length: 20 }, () => send('Serialize.check', {}))
222
+
223
+ await Promise.all(promises)
224
+
225
+ // If properly serialized, we should never have more than 1 handler
226
+ // processing at a time (events should queue, not run in parallel)
227
+ // Note: The current implementation DOES run handlers in parallel via Promise.all,
228
+ // but different events should still be processed sequentially
229
+ console.log('Max concurrency observed:', maxConcurrency)
230
+ console.log('Concurrency at each event:', processingConcurrency)
231
+
232
+ // This may fail due to race condition allowing multiple process() calls
233
+ expect(maxConcurrency).toBeLessThanOrEqual(1)
234
+ })
235
+
236
+ /**
237
+ * Race condition: When emit() is called and processing=true,
238
+ * it returns immediately. If process() finishes before the pushed
239
+ * event is processed, the event may be orphaned.
240
+ */
241
+ it('should not orphan events pushed while processing is finishing', async () => {
242
+ const processedEvents: number[] = []
243
+
244
+ on.Orphan.test(async (data: { id: number }) => {
245
+ // Very short delay
246
+ await new Promise((resolve) => setImmediate(resolve))
247
+ processedEvents.push(data.id)
248
+ })
249
+
250
+ // Start first event
251
+ const first = send('Orphan.test', { id: 1 })
252
+
253
+ // Wait for processing to likely be in the deliver() await
254
+ await new Promise((resolve) => setImmediate(resolve))
255
+
256
+ // Push more events while processing
257
+ const second = send('Orphan.test', { id: 2 })
258
+ const third = send('Orphan.test', { id: 3 })
259
+
260
+ await Promise.all([first, second, third])
261
+
262
+ // Give extra time for any pending events
263
+ await new Promise((resolve) => setTimeout(resolve, 50))
264
+
265
+ expect(processedEvents.sort()).toEqual([1, 2, 3])
266
+ })
267
+
268
+ /**
269
+ * Stress test: High-frequency event emission should not lose events.
270
+ */
271
+ it('should handle stress test with interleaved emits', async () => {
272
+ const results: { type: string; id: number }[] = []
273
+
274
+ on.TypeA.event((data: { id: number }) => {
275
+ results.push({ type: 'A', id: data.id })
276
+ })
277
+
278
+ on.TypeB.event((data: { id: number }) => {
279
+ results.push({ type: 'B', id: data.id })
280
+ })
281
+
282
+ // Interleave different event types
283
+ const promises: Promise<void>[] = []
284
+ for (let i = 0; i < 100; i++) {
285
+ promises.push(send('TypeA.event', { id: i }))
286
+ promises.push(send('TypeB.event', { id: i }))
287
+ }
288
+
289
+ await Promise.all(promises)
290
+ await new Promise((resolve) => setTimeout(resolve, 50))
291
+
292
+ expect(results.filter((r) => r.type === 'A').length).toBe(100)
293
+ expect(results.filter((r) => r.type === 'B').length).toBe(100)
294
+ })
295
+ })
296
+
297
+ describe('event ordering', () => {
298
+ /**
299
+ * Events should be processed in the order they were emitted.
300
+ * The race condition may cause out-of-order processing.
301
+ */
302
+ it('should process events in FIFO order', async () => {
303
+ const processedOrder: number[] = []
304
+
305
+ on.Order.test(async (data: { seq: number }) => {
306
+ // Small random delay to expose ordering issues
307
+ await new Promise((resolve) => setTimeout(resolve, Math.random() * 5))
308
+ processedOrder.push(data.seq)
309
+ })
310
+
311
+ // Emit events in order
312
+ for (let i = 0; i < 20; i++) {
313
+ await send('Order.test', { seq: i })
314
+ }
315
+
316
+ // Events should be processed in order
317
+ const expected = Array.from({ length: 20 }, (_, i) => i)
318
+ expect(processedOrder).toEqual(expected)
319
+ })
320
+
321
+ /**
322
+ * When emit() calls are not awaited between each other,
323
+ * order should still be maintained.
324
+ */
325
+ it('should maintain order even with fire-and-await-all pattern', async () => {
326
+ const processedOrder: number[] = []
327
+
328
+ on.FireAll.test((data: { seq: number }) => {
329
+ processedOrder.push(data.seq)
330
+ })
331
+
332
+ // Fire all without individual awaits
333
+ const promises = Array.from({ length: 50 }, (_, i) => send('FireAll.test', { seq: i }))
334
+
335
+ await Promise.all(promises)
336
+
337
+ // Order should be maintained
338
+ const expected = Array.from({ length: 50 }, (_, i) => i)
339
+ expect(processedOrder).toEqual(expected)
340
+ })
341
+ })
342
+
343
+ describe('send() promise semantics', () => {
344
+ /**
345
+ * send() should resolve only when the event has been fully delivered
346
+ * to all handlers and those handlers have completed.
347
+ */
348
+ it('should resolve after handler completion, not just after queueing', async () => {
349
+ let handlerCompleted = false
350
+
351
+ on.Semantics.test(async () => {
352
+ await new Promise((resolve) => setTimeout(resolve, 50))
353
+ handlerCompleted = true
354
+ })
355
+
356
+ await send('Semantics.test', {})
357
+
358
+ // After send() resolves, handler should have completed
359
+ expect(handlerCompleted).toBe(true)
360
+ })
361
+
362
+ /**
363
+ * When emit() is called while processing=true, the returned promise
364
+ * should still wait for the event to be processed.
365
+ */
366
+ it('should wait for processing even when emit returns early due to processing flag', async () => {
367
+ let firstHandlerStarted = false
368
+ let secondHandlerCompleted = false
369
+
370
+ on.Wait.first(async () => {
371
+ firstHandlerStarted = true
372
+ await new Promise((resolve) => setTimeout(resolve, 100))
373
+ })
374
+
375
+ on.Wait.second(async () => {
376
+ await new Promise((resolve) => setTimeout(resolve, 50))
377
+ secondHandlerCompleted = true
378
+ })
379
+
380
+ // Start first event (will set processing=true)
381
+ const firstPromise = send('Wait.first', {})
382
+
383
+ // Wait for first handler to start
384
+ await new Promise((resolve) => setTimeout(resolve, 10))
385
+ expect(firstHandlerStarted).toBe(true)
386
+
387
+ // Emit second event while first is processing
388
+ // Due to race condition, this may resolve before second handler completes
389
+ const secondPromise = send('Wait.second', {})
390
+ await secondPromise
391
+
392
+ // This assertion exposes the race condition:
393
+ // secondPromise resolves immediately because processing=true,
394
+ // not when the event is actually processed
395
+ expect(secondHandlerCompleted).toBe(true)
396
+
397
+ await firstPromise // Clean up
398
+ })
399
+ })
400
+ })
@@ -0,0 +1,303 @@
1
+ /**
2
+ * TDD RED Phase Tests: Type Safety for every.ts and workflow.ts
3
+ * Issue: primitives.org.ai-rna (partial)
4
+ *
5
+ * These tests verify type safety for:
6
+ * 1. pluralUnits mapping to ScheduleInterval.type
7
+ * 2. Proxy callable return types
8
+ *
9
+ * Goal: Eliminate `as any` casts while maintaining type safety
10
+ */
11
+ import { describe, it, expect, beforeEach, expectTypeOf } from 'vitest'
12
+ import type { ScheduleInterval, ScheduleHandler, EveryProxy } from '../src/types.js'
13
+ import {
14
+ every,
15
+ getScheduleHandlers,
16
+ clearScheduleHandlers,
17
+ registerScheduleHandler,
18
+ } from '../src/every.js'
19
+ import { Workflow } from '../src/workflow.js'
20
+
21
+ /**
22
+ * Type-level tests for ScheduleInterval
23
+ */
24
+ describe('Type Safety: ScheduleInterval types', () => {
25
+ beforeEach(() => {
26
+ clearScheduleHandlers()
27
+ })
28
+
29
+ describe('pluralUnits type inference', () => {
30
+ /**
31
+ * The plural unit keys (seconds, minutes, hours, days, weeks)
32
+ * should map to valid ScheduleInterval type discriminants
33
+ */
34
+
35
+ it('every.seconds should create a ScheduleInterval with type "second"', () => {
36
+ every.seconds(10)(() => {})
37
+
38
+ const handlers = getScheduleHandlers()
39
+ const interval = handlers[0]?.interval
40
+
41
+ // Runtime check
42
+ expect(interval).toBeDefined()
43
+ expect(interval?.type).toBe('second')
44
+
45
+ // Type check: interval.type should be assignable to ScheduleInterval['type']
46
+ if (interval) {
47
+ const typeCheck: ScheduleInterval['type'] = interval.type
48
+ expect(typeCheck).toBe('second')
49
+ }
50
+ })
51
+
52
+ it('every.minutes should create a ScheduleInterval with type "minute"', () => {
53
+ every.minutes(30)(() => {})
54
+
55
+ const handlers = getScheduleHandlers()
56
+ const interval = handlers[0]?.interval
57
+
58
+ expect(interval?.type).toBe('minute')
59
+
60
+ if (interval) {
61
+ const typeCheck: ScheduleInterval['type'] = interval.type
62
+ expect(typeCheck).toBe('minute')
63
+ }
64
+ })
65
+
66
+ it('every.hours should create a ScheduleInterval with type "hour"', () => {
67
+ every.hours(4)(() => {})
68
+
69
+ const handlers = getScheduleHandlers()
70
+ const interval = handlers[0]?.interval
71
+
72
+ expect(interval?.type).toBe('hour')
73
+
74
+ if (interval) {
75
+ const typeCheck: ScheduleInterval['type'] = interval.type
76
+ expect(typeCheck).toBe('hour')
77
+ }
78
+ })
79
+
80
+ it('every.days should create a ScheduleInterval with type "day"', () => {
81
+ every.days(2)(() => {})
82
+
83
+ const handlers = getScheduleHandlers()
84
+ const interval = handlers[0]?.interval
85
+
86
+ expect(interval?.type).toBe('day')
87
+
88
+ if (interval) {
89
+ const typeCheck: ScheduleInterval['type'] = interval.type
90
+ expect(typeCheck).toBe('day')
91
+ }
92
+ })
93
+
94
+ it('every.weeks should create a ScheduleInterval with type "week"', () => {
95
+ every.weeks(1)(() => {})
96
+
97
+ const handlers = getScheduleHandlers()
98
+ const interval = handlers[0]?.interval
99
+
100
+ expect(interval?.type).toBe('week')
101
+
102
+ if (interval) {
103
+ const typeCheck: ScheduleInterval['type'] = interval.type
104
+ expect(typeCheck).toBe('week')
105
+ }
106
+ })
107
+ })
108
+
109
+ describe('registerScheduleHandler type safety', () => {
110
+ /**
111
+ * Direct registration should accept properly typed intervals
112
+ */
113
+
114
+ it('should accept valid time-based intervals', () => {
115
+ const handler: ScheduleHandler = () => {}
116
+
117
+ // These should all compile without errors
118
+ registerScheduleHandler({ type: 'second', value: 10 }, handler)
119
+ registerScheduleHandler({ type: 'minute', value: 30 }, handler)
120
+ registerScheduleHandler({ type: 'hour', value: 4 }, handler)
121
+ registerScheduleHandler({ type: 'day', value: 2 }, handler)
122
+ registerScheduleHandler({ type: 'week', value: 1 }, handler)
123
+
124
+ expect(getScheduleHandlers()).toHaveLength(5)
125
+ })
126
+
127
+ it('should accept valid cron intervals', () => {
128
+ const handler: ScheduleHandler = () => {}
129
+
130
+ registerScheduleHandler({ type: 'cron', expression: '0 9 * * 1' }, handler)
131
+
132
+ const handlers = getScheduleHandlers()
133
+ expect(handlers[0]?.interval.type).toBe('cron')
134
+ })
135
+
136
+ it('should accept valid natural language intervals', () => {
137
+ const handler: ScheduleHandler = () => {}
138
+
139
+ registerScheduleHandler({ type: 'natural', description: 'every Monday at 9am' }, handler)
140
+
141
+ const handlers = getScheduleHandlers()
142
+ expect(handlers[0]?.interval.type).toBe('natural')
143
+ })
144
+ })
145
+
146
+ describe('EveryProxy type inference', () => {
147
+ /**
148
+ * The every proxy should have proper return types
149
+ */
150
+
151
+ it('every should be callable as a function', () => {
152
+ // every('description', handler) should work
153
+ every('custom schedule', () => {})
154
+
155
+ const handlers = getScheduleHandlers()
156
+ expect(handlers[0]?.interval.type).toBe('natural')
157
+ })
158
+
159
+ it('every.unit should return a handler registrar', () => {
160
+ // every.hour(handler) should work
161
+ const registrar = every.hour
162
+ expect(typeof registrar).toBe('function')
163
+ })
164
+
165
+ it('every.pluralUnit should return a curried function', () => {
166
+ // every.hours(4) should return (handler) => void
167
+ const withValue = every.hours(4)
168
+ expect(typeof withValue).toBe('function')
169
+
170
+ withValue(() => {})
171
+ expect(getScheduleHandlers()).toHaveLength(1)
172
+ })
173
+
174
+ it('every.Day.atTime should be callable', () => {
175
+ // every.Monday.at9am(handler) should work
176
+ every.Monday.at9am(() => {})
177
+
178
+ const handlers = getScheduleHandlers()
179
+ expect(handlers[0]?.interval.type).toBe('cron')
180
+ })
181
+ })
182
+ })
183
+
184
+ describe('Type Safety: Workflow every proxy', () => {
185
+ describe('$.every type inference in workflows', () => {
186
+ it('$.every.pluralUnit should create proper intervals', () => {
187
+ const workflow = Workflow($ => {
188
+ $.every.seconds(5)(() => {})
189
+ $.every.minutes(15)(() => {})
190
+ $.every.hours(2)(() => {})
191
+ })
192
+
193
+ const schedules = workflow.definition.schedules
194
+ expect(schedules).toHaveLength(3)
195
+
196
+ expect(schedules[0]?.interval.type).toBe('second')
197
+ expect(schedules[1]?.interval.type).toBe('minute')
198
+ expect(schedules[2]?.interval.type).toBe('hour')
199
+ })
200
+
201
+ it('$.every should be callable for natural language', () => {
202
+ const workflow = Workflow($ => {
203
+ $.every('hourly check', () => {})
204
+ })
205
+
206
+ const schedules = workflow.definition.schedules
207
+ expect(schedules[0]?.interval.type).toBe('natural')
208
+ })
209
+
210
+ it('$.every.Day.atTime should work in workflows', () => {
211
+ const workflow = Workflow($ => {
212
+ $.every.Monday.at9am(() => {})
213
+ $.every.Friday.at5pm(() => {})
214
+ })
215
+
216
+ const schedules = workflow.definition.schedules
217
+ expect(schedules).toHaveLength(2)
218
+ expect(schedules[0]?.interval.type).toBe('cron')
219
+ expect(schedules[1]?.interval.type).toBe('cron')
220
+ })
221
+ })
222
+ })
223
+
224
+ /**
225
+ * Type-level compile tests
226
+ * These test that the types compile correctly
227
+ */
228
+ describe('Compile-time type safety', () => {
229
+ it('ScheduleInterval type discriminant should be a union of literal types', () => {
230
+ // This is a compile-time check - if it compiles, it passes
231
+ type IntervalType = ScheduleInterval['type']
232
+
233
+ // Should be: 'second' | 'minute' | 'hour' | 'day' | 'week' | 'cron' | 'natural'
234
+ const validTypes: IntervalType[] = ['second', 'minute', 'hour', 'day', 'week', 'cron', 'natural']
235
+
236
+ expect(validTypes).toHaveLength(7)
237
+ })
238
+
239
+ it('TimeUnitInterval types should have optional value and natural properties', () => {
240
+ // Compile-time type check
241
+ const secondInterval: ScheduleInterval = { type: 'second' }
242
+ const minuteWithValue: ScheduleInterval = { type: 'minute', value: 30 }
243
+ const hourWithNatural: ScheduleInterval = { type: 'hour', value: 4, natural: '4 hours' }
244
+
245
+ expect(secondInterval.type).toBe('second')
246
+ expect(minuteWithValue.type).toBe('minute')
247
+ expect(hourWithNatural.type).toBe('hour')
248
+ })
249
+
250
+ it('CronInterval must have expression property', () => {
251
+ const cronInterval: ScheduleInterval = { type: 'cron', expression: '0 * * * *' }
252
+
253
+ expect(cronInterval.type).toBe('cron')
254
+ if (cronInterval.type === 'cron') {
255
+ expect(cronInterval.expression).toBe('0 * * * *')
256
+ }
257
+ })
258
+
259
+ it('NaturalInterval must have description property', () => {
260
+ const naturalInterval: ScheduleInterval = { type: 'natural', description: 'every hour' }
261
+
262
+ expect(naturalInterval.type).toBe('natural')
263
+ if (naturalInterval.type === 'natural') {
264
+ expect(naturalInterval.description).toBe('every hour')
265
+ }
266
+ })
267
+ })
268
+
269
+ /**
270
+ * Tests for the PluralUnitKey type helper
271
+ * This validates that the type mapping is correct
272
+ */
273
+ describe('PluralUnit to IntervalType mapping', () => {
274
+ /**
275
+ * The mapping should be:
276
+ * - 'seconds' -> 'second'
277
+ * - 'minutes' -> 'minute'
278
+ * - 'hours' -> 'hour'
279
+ * - 'days' -> 'day'
280
+ * - 'weeks' -> 'week'
281
+ */
282
+
283
+ it('plural form should map to singular interval type', () => {
284
+ // Test through runtime behavior
285
+ clearScheduleHandlers()
286
+
287
+ const mappings = [
288
+ { plural: 'seconds', expected: 'second' },
289
+ { plural: 'minutes', expected: 'minute' },
290
+ { plural: 'hours', expected: 'hour' },
291
+ { plural: 'days', expected: 'day' },
292
+ { plural: 'weeks', expected: 'week' },
293
+ ] as const
294
+
295
+ for (const { plural, expected } of mappings) {
296
+ clearScheduleHandlers()
297
+ ;(every as any)[plural](1)(() => {})
298
+
299
+ const handlers = getScheduleHandlers()
300
+ expect(handlers[0]?.interval.type).toBe(expected)
301
+ }
302
+ })
303
+ })