ai-workflows 2.1.1 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +17 -1
- package/README.md +305 -184
- package/dist/barrier.d.ts +159 -0
- package/dist/barrier.d.ts.map +1 -0
- package/dist/barrier.js +377 -0
- package/dist/barrier.js.map +1 -0
- package/dist/cascade-context.d.ts +149 -0
- package/dist/cascade-context.d.ts.map +1 -0
- package/dist/cascade-context.js +324 -0
- package/dist/cascade-context.js.map +1 -0
- package/dist/cascade-executor.d.ts +196 -0
- package/dist/cascade-executor.d.ts.map +1 -0
- package/dist/cascade-executor.js +384 -0
- package/dist/cascade-executor.js.map +1 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +27 -8
- package/dist/context.js.map +1 -1
- package/dist/cron-parser.d.ts +65 -0
- package/dist/cron-parser.d.ts.map +1 -0
- package/dist/cron-parser.js +294 -0
- package/dist/cron-parser.js.map +1 -0
- package/dist/cron-scheduler.d.ts +117 -0
- package/dist/cron-scheduler.d.ts.map +1 -0
- package/dist/cron-scheduler.js +176 -0
- package/dist/cron-scheduler.js.map +1 -0
- package/dist/database-context.d.ts +184 -0
- package/dist/database-context.d.ts.map +1 -0
- package/dist/database-context.js +428 -0
- package/dist/database-context.js.map +1 -0
- package/dist/dependency-graph.d.ts +157 -0
- package/dist/dependency-graph.d.ts.map +1 -0
- package/dist/dependency-graph.js +382 -0
- package/dist/dependency-graph.js.map +1 -0
- package/dist/digital-objects-adapter.d.ts +159 -0
- package/dist/digital-objects-adapter.d.ts.map +1 -0
- package/dist/digital-objects-adapter.js +229 -0
- package/dist/digital-objects-adapter.js.map +1 -0
- package/dist/durable-execution-cloudflare.d.ts +427 -0
- package/dist/durable-execution-cloudflare.d.ts.map +1 -0
- package/dist/durable-execution-cloudflare.js +510 -0
- package/dist/durable-execution-cloudflare.js.map +1 -0
- package/dist/durable-execution.d.ts +482 -0
- package/dist/durable-execution.d.ts.map +1 -0
- package/dist/durable-execution.js +594 -0
- package/dist/durable-execution.js.map +1 -0
- package/dist/durable-workflow.d.ts +176 -0
- package/dist/durable-workflow.d.ts.map +1 -0
- package/dist/durable-workflow.js +552 -0
- package/dist/durable-workflow.js.map +1 -0
- package/dist/every.d.ts +31 -2
- package/dist/every.d.ts.map +1 -1
- package/dist/every.js +63 -32
- package/dist/every.js.map +1 -1
- package/dist/graph/index.d.ts +8 -0
- package/dist/graph/index.d.ts.map +1 -0
- package/dist/graph/index.js +8 -0
- package/dist/graph/index.js.map +1 -0
- package/dist/graph/topological-sort.d.ts +121 -0
- package/dist/graph/topological-sort.d.ts.map +1 -0
- package/dist/graph/topological-sort.js +292 -0
- package/dist/graph/topological-sort.js.map +1 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -1
- package/dist/logger.d.ts +101 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +115 -0
- package/dist/logger.js.map +1 -0
- package/dist/on.d.ts +35 -10
- package/dist/on.d.ts.map +1 -1
- package/dist/on.js +53 -19
- package/dist/on.js.map +1 -1
- package/dist/runtime.d.ts +169 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +275 -0
- package/dist/runtime.js.map +1 -0
- package/dist/send.d.ts.map +1 -1
- package/dist/send.js +4 -3
- package/dist/send.js.map +1 -1
- package/dist/telemetry.d.ts +150 -0
- package/dist/telemetry.d.ts.map +1 -0
- package/dist/telemetry.js +388 -0
- package/dist/telemetry.js.map +1 -0
- package/dist/timer-registry.d.ts +77 -0
- package/dist/timer-registry.d.ts.map +1 -0
- package/dist/timer-registry.js +154 -0
- package/dist/timer-registry.js.map +1 -0
- package/dist/types.d.ts +105 -6
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +17 -1
- package/dist/types.js.map +1 -1
- package/dist/worker/durable-step.d.ts +481 -0
- package/dist/worker/durable-step.d.ts.map +1 -0
- package/dist/worker/durable-step.js +606 -0
- package/dist/worker/durable-step.js.map +1 -0
- package/dist/worker/index.d.ts +106 -0
- package/dist/worker/index.d.ts.map +1 -0
- package/dist/worker/index.js +124 -0
- package/dist/worker/index.js.map +1 -0
- package/dist/worker/state-adapter.d.ts +230 -0
- package/dist/worker/state-adapter.d.ts.map +1 -0
- package/dist/worker/state-adapter.js +409 -0
- package/dist/worker/state-adapter.js.map +1 -0
- package/dist/worker/topological-executor.d.ts +282 -0
- package/dist/worker/topological-executor.d.ts.map +1 -0
- package/dist/worker/topological-executor.js +396 -0
- package/dist/worker/topological-executor.js.map +1 -0
- package/dist/worker/workflow-builder.d.ts +286 -0
- package/dist/worker/workflow-builder.d.ts.map +1 -0
- package/dist/worker/workflow-builder.js +565 -0
- package/dist/worker/workflow-builder.js.map +1 -0
- package/dist/worker.d.ts +800 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +2428 -0
- package/dist/worker.js.map +1 -0
- package/dist/workflow-builder.d.ts +287 -0
- package/dist/workflow-builder.d.ts.map +1 -0
- package/dist/workflow-builder.js +762 -0
- package/dist/workflow-builder.js.map +1 -0
- package/dist/workflow.d.ts +14 -30
- package/dist/workflow.d.ts.map +1 -1
- package/dist/workflow.js +136 -292
- package/dist/workflow.js.map +1 -1
- package/examples/01-ecommerce-order-pipeline.ts +358 -0
- package/examples/02-content-moderation-cascade.ts +454 -0
- package/examples/03-scheduled-reporting-dependencies.ts +479 -0
- package/examples/04-database-persistence.ts +518 -0
- package/examples/README.md +173 -0
- package/package.json +21 -4
- package/src/__tests__/digital-objects-adapter.test.ts +274 -0
- package/src/__tests__/durable-workflow.test.ts +297 -0
- package/src/barrier.ts +507 -0
- package/src/cascade-context.ts +495 -0
- package/src/cascade-executor.ts +588 -0
- package/src/context.ts +51 -17
- package/src/cron-parser.ts +347 -0
- package/src/cron-scheduler.ts +239 -0
- package/src/database-context.ts +658 -0
- package/src/dependency-graph.ts +518 -0
- package/src/digital-objects-adapter.ts +351 -0
- package/src/durable-execution-cloudflare.ts +855 -0
- package/src/durable-execution.ts +1042 -0
- package/src/durable-workflow.ts +717 -0
- package/src/every.ts +104 -35
- package/src/graph/index.ts +19 -0
- package/src/graph/topological-sort.ts +412 -0
- package/src/index.ts +147 -0
- package/src/logger.ts +148 -0
- package/src/on.ts +81 -26
- package/src/runtime.ts +436 -0
- package/src/send.ts +4 -5
- package/src/telemetry.ts +577 -0
- package/src/timer-registry.ts +179 -0
- package/src/types.ts +146 -10
- package/src/worker/durable-step.ts +976 -0
- package/src/worker/index.ts +216 -0
- package/src/worker/state-adapter.ts +589 -0
- package/src/worker/topological-executor.ts +625 -0
- package/src/worker/workflow-builder.ts +871 -0
- package/src/worker.ts +2906 -0
- package/src/workflow-builder.ts +1068 -0
- package/src/workflow.ts +199 -355
- package/test/barrier-join.test.ts +442 -0
- package/test/barrier-unhandled-rejections.test.ts +359 -0
- package/test/cascade-context.test.ts +390 -0
- package/test/cascade-executor.test.ts +852 -0
- package/test/cron-parser.test.ts +314 -0
- package/test/cron-scheduler.test.ts +291 -0
- package/test/database-context.test.ts +770 -0
- package/test/db-provider-adapter.test.ts +862 -0
- package/test/dependency-graph.test.ts +512 -0
- package/test/durable-execution-cloudflare.test.ts +606 -0
- package/test/durable-execution-in-process.test.ts +286 -0
- package/test/durable-execution.test.ts +247 -0
- package/test/e2e/workflow-scenarios.e2e.test.ts +1039 -0
- package/test/graph/topological-sort.test.ts +586 -0
- package/test/integration.test.ts +442 -0
- package/test/rpc-surface.test.ts +946 -0
- package/test/runtime.test.ts +262 -0
- package/test/schedule-timer-cleanup.test.ts +353 -0
- package/test/send-race-conditions.test.ts +400 -0
- package/test/type-safety-every.test.ts +303 -0
- package/test/worker/durable-cascade.test.ts +1117 -0
- package/test/worker/durable-step.test.ts +723 -0
- package/test/worker/topological-executor.test.ts +1240 -0
- package/test/worker/workflow-builder.test.ts +1067 -0
- package/test/worker.test.ts +608 -0
- package/test/workflow-builder.test.ts +1670 -0
- package/test/workflow-cron.test.ts +256 -0
- package/test/workflow-state-adapter.test.ts +923 -0
- package/test/workflow.test.ts +25 -22
- package/tsconfig.json +3 -1
- package/vitest.config.ts +38 -1
- package/vitest.workers.config.ts +44 -0
- package/wrangler.jsonc +22 -0
- package/.turbo/turbo-test.log +0 -7
- package/src/context.js +0 -83
- package/src/every.js +0 -267
- package/src/index.js +0 -71
- package/src/on.js +0 -79
- package/src/send.js +0 -111
- package/src/types.js +0 -4
- package/src/workflow.js +0 -455
- package/test/context.test.js +0 -116
- package/test/every.test.js +0 -282
- package/test/on.test.js +0 -80
- package/test/send.test.js +0 -89
- package/test/workflow.test.js +0 -224
- package/vitest.config.js +0 -7
|
@@ -0,0 +1,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
|
+
})
|