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,314 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
parseCron,
|
|
4
|
+
matchesCron,
|
|
5
|
+
getNextCronDate,
|
|
6
|
+
getNextCronMs,
|
|
7
|
+
type ParsedCron,
|
|
8
|
+
} from '../src/cron-parser.js'
|
|
9
|
+
|
|
10
|
+
describe('parseCron', () => {
|
|
11
|
+
describe('basic parsing', () => {
|
|
12
|
+
it('should parse * * * * * (every minute)', () => {
|
|
13
|
+
const cron = parseCron('* * * * *')
|
|
14
|
+
expect(cron.minutes).toHaveLength(60) // 0-59
|
|
15
|
+
expect(cron.hours).toHaveLength(24) // 0-23
|
|
16
|
+
expect(cron.daysOfMonth).toHaveLength(31) // 1-31
|
|
17
|
+
expect(cron.months).toHaveLength(12) // 1-12
|
|
18
|
+
expect(cron.daysOfWeek).toHaveLength(7) // 0-6
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('should parse 0 * * * * (every hour at minute 0)', () => {
|
|
22
|
+
const cron = parseCron('0 * * * *')
|
|
23
|
+
expect(cron.minutes).toEqual([0])
|
|
24
|
+
expect(cron.hours).toHaveLength(24)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('should parse 0 9 * * * (every day at 9am)', () => {
|
|
28
|
+
const cron = parseCron('0 9 * * *')
|
|
29
|
+
expect(cron.minutes).toEqual([0])
|
|
30
|
+
expect(cron.hours).toEqual([9])
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('should parse 0 9 * * 1 (every Monday at 9am)', () => {
|
|
34
|
+
const cron = parseCron('0 9 * * 1')
|
|
35
|
+
expect(cron.minutes).toEqual([0])
|
|
36
|
+
expect(cron.hours).toEqual([9])
|
|
37
|
+
expect(cron.daysOfWeek).toEqual([1])
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('should parse 0 0 1 * * (first of every month at midnight)', () => {
|
|
41
|
+
const cron = parseCron('0 0 1 * *')
|
|
42
|
+
expect(cron.minutes).toEqual([0])
|
|
43
|
+
expect(cron.hours).toEqual([0])
|
|
44
|
+
expect(cron.daysOfMonth).toEqual([1])
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
describe('ranges', () => {
|
|
49
|
+
it('should parse 0 9-17 * * * (every hour from 9am to 5pm)', () => {
|
|
50
|
+
const cron = parseCron('0 9-17 * * *')
|
|
51
|
+
expect(cron.hours).toEqual([9, 10, 11, 12, 13, 14, 15, 16, 17])
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('should parse 0 0 * * 1-5 (weekdays)', () => {
|
|
55
|
+
const cron = parseCron('0 0 * * 1-5')
|
|
56
|
+
expect(cron.daysOfWeek).toEqual([1, 2, 3, 4, 5])
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('lists', () => {
|
|
61
|
+
it('should parse 0 9,12,17 * * * (at 9am, noon, and 5pm)', () => {
|
|
62
|
+
const cron = parseCron('0 9,12,17 * * *')
|
|
63
|
+
expect(cron.hours).toEqual([9, 12, 17])
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('should parse 0 0 * * 0,6 (weekends)', () => {
|
|
67
|
+
const cron = parseCron('0 0 * * 0,6')
|
|
68
|
+
expect(cron.daysOfWeek).toEqual([0, 6])
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('steps', () => {
|
|
73
|
+
it('should parse */5 * * * * (every 5 minutes)', () => {
|
|
74
|
+
const cron = parseCron('*/5 * * * *')
|
|
75
|
+
expect(cron.minutes).toEqual([0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55])
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('should parse */15 * * * * (every 15 minutes)', () => {
|
|
79
|
+
const cron = parseCron('*/15 * * * *')
|
|
80
|
+
expect(cron.minutes).toEqual([0, 15, 30, 45])
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('should parse 0-30/5 * * * * (every 5 minutes for first half hour)', () => {
|
|
84
|
+
const cron = parseCron('0-30/5 * * * *')
|
|
85
|
+
expect(cron.minutes).toEqual([0, 5, 10, 15, 20, 25, 30])
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('should parse 0 */2 * * * (every 2 hours)', () => {
|
|
89
|
+
const cron = parseCron('0 */2 * * *')
|
|
90
|
+
expect(cron.hours).toEqual([0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22])
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('day names', () => {
|
|
95
|
+
it('should parse 0 9 * * Mon (Monday at 9am)', () => {
|
|
96
|
+
const cron = parseCron('0 9 * * Mon')
|
|
97
|
+
expect(cron.daysOfWeek).toEqual([1])
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('should parse 0 9 * * Mon-Fri (weekdays)', () => {
|
|
101
|
+
const cron = parseCron('0 9 * * Mon-Fri')
|
|
102
|
+
expect(cron.daysOfWeek).toEqual([1, 2, 3, 4, 5])
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('should parse 0 9 * * Sat,Sun (weekends)', () => {
|
|
106
|
+
const cron = parseCron('0 9 * * Sat,Sun')
|
|
107
|
+
expect(cron.daysOfWeek).toEqual([0, 6])
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
describe('month names', () => {
|
|
112
|
+
it('should parse 0 0 1 Jan * (New Year)', () => {
|
|
113
|
+
const cron = parseCron('0 0 1 Jan *')
|
|
114
|
+
expect(cron.months).toEqual([1])
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('should parse 0 0 1 Jan,Jul * (Jan and July)', () => {
|
|
118
|
+
const cron = parseCron('0 0 1 Jan,Jul *')
|
|
119
|
+
expect(cron.months).toEqual([1, 7])
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
describe('6-field expressions with seconds', () => {
|
|
124
|
+
it('should parse * * * * * * (every second)', () => {
|
|
125
|
+
const cron = parseCron('* * * * * *')
|
|
126
|
+
expect(cron.seconds).toHaveLength(60)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('should parse 0 0 9 * * 1 (Monday at 9am, second 0)', () => {
|
|
130
|
+
const cron = parseCron('0 0 9 * * 1')
|
|
131
|
+
expect(cron.seconds).toEqual([0])
|
|
132
|
+
expect(cron.minutes).toEqual([0])
|
|
133
|
+
expect(cron.hours).toEqual([9])
|
|
134
|
+
expect(cron.daysOfWeek).toEqual([1])
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('should parse */10 * * * * * (every 10 seconds)', () => {
|
|
138
|
+
const cron = parseCron('*/10 * * * * *')
|
|
139
|
+
expect(cron.seconds).toEqual([0, 10, 20, 30, 40, 50])
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
describe('error handling', () => {
|
|
144
|
+
it('should throw for invalid field count', () => {
|
|
145
|
+
expect(() => parseCron('* * *')).toThrow('expected 5 or 6 fields')
|
|
146
|
+
expect(() => parseCron('* * * * * * *')).toThrow('expected 5 or 6 fields')
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('should throw for invalid field values', () => {
|
|
150
|
+
expect(() => parseCron('60 * * * *')).toThrow('Invalid cron field value')
|
|
151
|
+
expect(() => parseCron('* 24 * * *')).toThrow('Invalid cron field value')
|
|
152
|
+
expect(() => parseCron('* * 32 * *')).toThrow('Invalid cron field value')
|
|
153
|
+
expect(() => parseCron('* * * 13 *')).toThrow('Invalid cron field value')
|
|
154
|
+
expect(() => parseCron('* * * * 7')).toThrow('Invalid cron field value')
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
describe('wildcard tracking', () => {
|
|
159
|
+
it('should track day-of-month wildcard', () => {
|
|
160
|
+
const cron = parseCron('0 9 * * 1')
|
|
161
|
+
expect(cron.dayOfMonthWildcard).toBe(true)
|
|
162
|
+
expect(cron.dayOfWeekWildcard).toBe(false)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('should track day-of-week wildcard', () => {
|
|
166
|
+
const cron = parseCron('0 9 15 * *')
|
|
167
|
+
expect(cron.dayOfMonthWildcard).toBe(false)
|
|
168
|
+
expect(cron.dayOfWeekWildcard).toBe(true)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('should track both wildcards', () => {
|
|
172
|
+
const cron = parseCron('0 9 * * *')
|
|
173
|
+
expect(cron.dayOfMonthWildcard).toBe(true)
|
|
174
|
+
expect(cron.dayOfWeekWildcard).toBe(true)
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
describe('matchesCron', () => {
|
|
180
|
+
it('should match * * * * * for any time', () => {
|
|
181
|
+
const cron = parseCron('* * * * *')
|
|
182
|
+
expect(matchesCron(new Date('2024-01-15T10:30:00'), cron)).toBe(true)
|
|
183
|
+
expect(matchesCron(new Date('2024-06-20T23:59:00'), cron)).toBe(true)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('should match specific minute', () => {
|
|
187
|
+
const cron = parseCron('30 * * * *')
|
|
188
|
+
expect(matchesCron(new Date('2024-01-15T10:30:00'), cron)).toBe(true)
|
|
189
|
+
expect(matchesCron(new Date('2024-01-15T10:31:00'), cron)).toBe(false)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('should match specific hour', () => {
|
|
193
|
+
const cron = parseCron('0 9 * * *')
|
|
194
|
+
expect(matchesCron(new Date('2024-01-15T09:00:00'), cron)).toBe(true)
|
|
195
|
+
expect(matchesCron(new Date('2024-01-15T10:00:00'), cron)).toBe(false)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('should match specific day of week', () => {
|
|
199
|
+
const cron = parseCron('0 9 * * 1') // Monday
|
|
200
|
+
// Jan 15, 2024 is a Monday
|
|
201
|
+
expect(matchesCron(new Date('2024-01-15T09:00:00'), cron)).toBe(true)
|
|
202
|
+
// Jan 16, 2024 is a Tuesday
|
|
203
|
+
expect(matchesCron(new Date('2024-01-16T09:00:00'), cron)).toBe(false)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('should match specific day of month', () => {
|
|
207
|
+
const cron = parseCron('0 9 15 * *')
|
|
208
|
+
expect(matchesCron(new Date('2024-01-15T09:00:00'), cron)).toBe(true)
|
|
209
|
+
expect(matchesCron(new Date('2024-01-16T09:00:00'), cron)).toBe(false)
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('should match specific month', () => {
|
|
213
|
+
const cron = parseCron('0 9 15 6 *') // June 15 at 9am
|
|
214
|
+
expect(matchesCron(new Date('2024-06-15T09:00:00'), cron)).toBe(true)
|
|
215
|
+
expect(matchesCron(new Date('2024-07-15T09:00:00'), cron)).toBe(false)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('should use OR logic when both day-of-month and day-of-week are specified', () => {
|
|
219
|
+
// 15th of month OR Monday
|
|
220
|
+
const cron = parseCron('0 9 15 * 1')
|
|
221
|
+
// Jan 15, 2024 is a Monday (matches both)
|
|
222
|
+
expect(matchesCron(new Date('2024-01-15T09:00:00'), cron)).toBe(true)
|
|
223
|
+
// Jan 22, 2024 is a Monday (day of week matches)
|
|
224
|
+
expect(matchesCron(new Date('2024-01-22T09:00:00'), cron)).toBe(true)
|
|
225
|
+
// Feb 15, 2024 is a Thursday (day of month matches)
|
|
226
|
+
expect(matchesCron(new Date('2024-02-15T09:00:00'), cron)).toBe(true)
|
|
227
|
+
// Jan 23, 2024 is a Tuesday, not the 15th (neither matches)
|
|
228
|
+
expect(matchesCron(new Date('2024-01-23T09:00:00'), cron)).toBe(false)
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('should match 6-field expression with seconds', () => {
|
|
232
|
+
const cron = parseCron('30 * * * * *') // At second 30
|
|
233
|
+
expect(matchesCron(new Date('2024-01-15T10:30:30'), cron)).toBe(true)
|
|
234
|
+
expect(matchesCron(new Date('2024-01-15T10:30:31'), cron)).toBe(false)
|
|
235
|
+
})
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
describe('getNextCronDate', () => {
|
|
239
|
+
it('should find next minute for * * * * *', () => {
|
|
240
|
+
const cron = parseCron('* * * * *')
|
|
241
|
+
const from = new Date('2024-01-15T10:30:00')
|
|
242
|
+
const next = getNextCronDate(cron, from)
|
|
243
|
+
expect(next).not.toBeNull()
|
|
244
|
+
expect(next!.getTime()).toBeGreaterThan(from.getTime())
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it('should find next occurrence of 0 * * * *', () => {
|
|
248
|
+
const cron = parseCron('0 * * * *')
|
|
249
|
+
const from = new Date('2024-01-15T10:30:00')
|
|
250
|
+
const next = getNextCronDate(cron, from)
|
|
251
|
+
expect(next).not.toBeNull()
|
|
252
|
+
expect(next!.getMinutes()).toBe(0)
|
|
253
|
+
expect(next!.getHours()).toBe(11) // Next hour
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('should find next Monday for 0 9 * * 1', () => {
|
|
257
|
+
const cron = parseCron('0 9 * * 1')
|
|
258
|
+
// Jan 16, 2024 is a Tuesday
|
|
259
|
+
const from = new Date('2024-01-16T10:00:00')
|
|
260
|
+
const next = getNextCronDate(cron, from)
|
|
261
|
+
expect(next).not.toBeNull()
|
|
262
|
+
expect(next!.getDay()).toBe(1) // Monday
|
|
263
|
+
expect(next!.getHours()).toBe(9)
|
|
264
|
+
expect(next!.getMinutes()).toBe(0)
|
|
265
|
+
// Should be Jan 22, 2024
|
|
266
|
+
expect(next!.getDate()).toBe(22)
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it('should find next 15th for 0 9 15 * *', () => {
|
|
270
|
+
const cron = parseCron('0 9 15 * *')
|
|
271
|
+
const from = new Date('2024-01-16T10:00:00')
|
|
272
|
+
const next = getNextCronDate(cron, from)
|
|
273
|
+
expect(next).not.toBeNull()
|
|
274
|
+
expect(next!.getDate()).toBe(15)
|
|
275
|
+
expect(next!.getMonth()).toBe(1) // February
|
|
276
|
+
expect(next!.getHours()).toBe(9)
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
it('should handle year rollover', () => {
|
|
280
|
+
const cron = parseCron('0 0 1 1 *') // Jan 1 at midnight
|
|
281
|
+
const from = new Date('2024-06-15T10:00:00')
|
|
282
|
+
const next = getNextCronDate(cron, from)
|
|
283
|
+
expect(next).not.toBeNull()
|
|
284
|
+
expect(next!.getFullYear()).toBe(2025)
|
|
285
|
+
expect(next!.getMonth()).toBe(0) // January
|
|
286
|
+
expect(next!.getDate()).toBe(1)
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it('should handle */5 step', () => {
|
|
290
|
+
const cron = parseCron('*/5 * * * *')
|
|
291
|
+
const from = new Date('2024-01-15T10:32:00')
|
|
292
|
+
const next = getNextCronDate(cron, from)
|
|
293
|
+
expect(next).not.toBeNull()
|
|
294
|
+
expect(next!.getMinutes()).toBe(35) // Next 5-minute mark
|
|
295
|
+
})
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
describe('getNextCronMs', () => {
|
|
299
|
+
it('should return milliseconds until next occurrence', () => {
|
|
300
|
+
const cron = parseCron('0 * * * *') // Every hour at minute 0
|
|
301
|
+
const from = new Date('2024-01-15T10:30:00')
|
|
302
|
+
const ms = getNextCronMs(cron, from)
|
|
303
|
+
expect(ms).not.toBeNull()
|
|
304
|
+
// Should be ~30 minutes (1800000ms)
|
|
305
|
+
expect(ms).toBe(30 * 60 * 1000)
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
it('should return positive milliseconds', () => {
|
|
309
|
+
const cron = parseCron('* * * * *')
|
|
310
|
+
const ms = getNextCronMs(cron)
|
|
311
|
+
expect(ms).not.toBeNull()
|
|
312
|
+
expect(ms).toBeGreaterThan(0)
|
|
313
|
+
})
|
|
314
|
+
})
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
createCronJob,
|
|
4
|
+
stopCronJob,
|
|
5
|
+
startCronJob,
|
|
6
|
+
getActiveCronJobs,
|
|
7
|
+
stopAllCronJobs,
|
|
8
|
+
getActiveCronJobCount,
|
|
9
|
+
schedule,
|
|
10
|
+
} from '../src/cron-scheduler.js'
|
|
11
|
+
|
|
12
|
+
describe('cron-scheduler', () => {
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
vi.useFakeTimers()
|
|
15
|
+
stopAllCronJobs() // Clean up any existing jobs
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
stopAllCronJobs()
|
|
20
|
+
vi.useRealTimers()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
describe('createCronJob', () => {
|
|
24
|
+
it('should create a cron job with valid expression', () => {
|
|
25
|
+
const handler = vi.fn()
|
|
26
|
+
const job = createCronJob('0 * * * *', handler)
|
|
27
|
+
|
|
28
|
+
expect(job).toBeDefined()
|
|
29
|
+
expect(job.expression).toBe('0 * * * *')
|
|
30
|
+
expect(job.stopped).toBe(false)
|
|
31
|
+
expect(job.running).toBe(false)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('should allow custom job ID', () => {
|
|
35
|
+
const job = createCronJob('* * * * *', () => {}, { id: 'my-custom-job' })
|
|
36
|
+
expect(job.id).toBe('my-custom-job')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('should add job to active jobs', () => {
|
|
40
|
+
const job = createCronJob('* * * * *', () => {})
|
|
41
|
+
const active = getActiveCronJobs()
|
|
42
|
+
expect(active).toContain(job)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('should calculate next run time', () => {
|
|
46
|
+
const job = createCronJob('0 * * * *', () => {})
|
|
47
|
+
expect(job.nextRun).not.toBeNull()
|
|
48
|
+
expect(job.nextRun!.getMinutes()).toBe(0)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('should execute handler on cron schedule', async () => {
|
|
52
|
+
const handler = vi.fn()
|
|
53
|
+
// Every minute
|
|
54
|
+
createCronJob('* * * * *', handler)
|
|
55
|
+
|
|
56
|
+
// Advance time to next minute
|
|
57
|
+
await vi.advanceTimersByTimeAsync(60 * 1000)
|
|
58
|
+
|
|
59
|
+
expect(handler).toHaveBeenCalledTimes(1)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('should execute multiple times on recurring schedule', async () => {
|
|
63
|
+
const handler = vi.fn()
|
|
64
|
+
// Every minute
|
|
65
|
+
createCronJob('* * * * *', handler)
|
|
66
|
+
|
|
67
|
+
// Advance time by 3 minutes
|
|
68
|
+
await vi.advanceTimersByTimeAsync(60 * 1000)
|
|
69
|
+
await vi.advanceTimersByTimeAsync(60 * 1000)
|
|
70
|
+
await vi.advanceTimersByTimeAsync(60 * 1000)
|
|
71
|
+
|
|
72
|
+
expect(handler).toHaveBeenCalledTimes(3)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('should handle async handlers', async () => {
|
|
76
|
+
const results: number[] = []
|
|
77
|
+
const handler = async () => {
|
|
78
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
79
|
+
results.push(Date.now())
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
createCronJob('* * * * *', handler)
|
|
83
|
+
|
|
84
|
+
await vi.advanceTimersByTimeAsync(60 * 1000)
|
|
85
|
+
expect(results.length).toBeGreaterThanOrEqual(1)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('should call error handler on error', async () => {
|
|
89
|
+
const error = new Error('Test error')
|
|
90
|
+
const handler = vi.fn(() => {
|
|
91
|
+
throw error
|
|
92
|
+
})
|
|
93
|
+
const errorHandler = vi.fn()
|
|
94
|
+
|
|
95
|
+
createCronJob('* * * * *', handler, { onError: errorHandler })
|
|
96
|
+
|
|
97
|
+
await vi.advanceTimersByTimeAsync(60 * 1000)
|
|
98
|
+
|
|
99
|
+
expect(handler).toHaveBeenCalled()
|
|
100
|
+
expect(errorHandler).toHaveBeenCalledWith(error)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('should continue scheduling after error', async () => {
|
|
104
|
+
const handler = vi.fn(() => {
|
|
105
|
+
throw new Error('Test error')
|
|
106
|
+
})
|
|
107
|
+
const errorHandler = vi.fn()
|
|
108
|
+
|
|
109
|
+
createCronJob('* * * * *', handler, { onError: errorHandler })
|
|
110
|
+
|
|
111
|
+
// Advance 3 minutes
|
|
112
|
+
await vi.advanceTimersByTimeAsync(60 * 1000)
|
|
113
|
+
await vi.advanceTimersByTimeAsync(60 * 1000)
|
|
114
|
+
await vi.advanceTimersByTimeAsync(60 * 1000)
|
|
115
|
+
|
|
116
|
+
// Handler should have been called 3 times despite errors
|
|
117
|
+
expect(handler).toHaveBeenCalledTimes(3)
|
|
118
|
+
expect(errorHandler).toHaveBeenCalledTimes(3)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('should not start if startImmediately is false', () => {
|
|
122
|
+
const handler = vi.fn()
|
|
123
|
+
const job = createCronJob('* * * * *', handler, { startImmediately: false })
|
|
124
|
+
|
|
125
|
+
expect(job.timer).toBeNull()
|
|
126
|
+
expect(job.nextRun).toBeNull()
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
describe('stopCronJob', () => {
|
|
131
|
+
it('should stop a cron job', () => {
|
|
132
|
+
const handler = vi.fn()
|
|
133
|
+
const job = createCronJob('* * * * *', handler)
|
|
134
|
+
|
|
135
|
+
stopCronJob(job)
|
|
136
|
+
|
|
137
|
+
expect(job.stopped).toBe(true)
|
|
138
|
+
expect(job.timer).toBeNull()
|
|
139
|
+
expect(job.nextRun).toBeNull()
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('should remove job from active jobs', () => {
|
|
143
|
+
const job = createCronJob('* * * * *', () => {})
|
|
144
|
+
expect(getActiveCronJobCount()).toBe(1)
|
|
145
|
+
|
|
146
|
+
stopCronJob(job)
|
|
147
|
+
expect(getActiveCronJobCount()).toBe(0)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('should prevent future executions', async () => {
|
|
151
|
+
const handler = vi.fn()
|
|
152
|
+
const job = createCronJob('* * * * *', handler)
|
|
153
|
+
|
|
154
|
+
// Stop before first execution
|
|
155
|
+
stopCronJob(job)
|
|
156
|
+
|
|
157
|
+
await vi.advanceTimersByTimeAsync(60 * 1000)
|
|
158
|
+
expect(handler).not.toHaveBeenCalled()
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
describe('startCronJob', () => {
|
|
163
|
+
it('should start a stopped job', () => {
|
|
164
|
+
const handler = vi.fn()
|
|
165
|
+
const job = createCronJob('* * * * *', handler, { startImmediately: false })
|
|
166
|
+
|
|
167
|
+
expect(job.nextRun).toBeNull()
|
|
168
|
+
|
|
169
|
+
startCronJob(job)
|
|
170
|
+
|
|
171
|
+
expect(job.stopped).toBe(false)
|
|
172
|
+
expect(job.nextRun).not.toBeNull()
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('should resume execution after restart', async () => {
|
|
176
|
+
const handler = vi.fn()
|
|
177
|
+
const job = createCronJob('* * * * *', handler)
|
|
178
|
+
|
|
179
|
+
stopCronJob(job)
|
|
180
|
+
await vi.advanceTimersByTimeAsync(60 * 1000)
|
|
181
|
+
expect(handler).not.toHaveBeenCalled()
|
|
182
|
+
|
|
183
|
+
startCronJob(job)
|
|
184
|
+
await vi.advanceTimersByTimeAsync(60 * 1000)
|
|
185
|
+
expect(handler).toHaveBeenCalledTimes(1)
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
describe('getActiveCronJobs', () => {
|
|
190
|
+
it('should return all active jobs', () => {
|
|
191
|
+
const job1 = createCronJob('* * * * *', () => {})
|
|
192
|
+
const job2 = createCronJob('0 * * * *', () => {})
|
|
193
|
+
|
|
194
|
+
const active = getActiveCronJobs()
|
|
195
|
+
expect(active).toHaveLength(2)
|
|
196
|
+
expect(active).toContain(job1)
|
|
197
|
+
expect(active).toContain(job2)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('should not include stopped jobs', () => {
|
|
201
|
+
const job1 = createCronJob('* * * * *', () => {})
|
|
202
|
+
const job2 = createCronJob('0 * * * *', () => {})
|
|
203
|
+
|
|
204
|
+
stopCronJob(job1)
|
|
205
|
+
|
|
206
|
+
const active = getActiveCronJobs()
|
|
207
|
+
expect(active).toHaveLength(1)
|
|
208
|
+
expect(active).not.toContain(job1)
|
|
209
|
+
expect(active).toContain(job2)
|
|
210
|
+
})
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
describe('stopAllCronJobs', () => {
|
|
214
|
+
it('should stop all active jobs', () => {
|
|
215
|
+
createCronJob('* * * * *', () => {})
|
|
216
|
+
createCronJob('0 * * * *', () => {})
|
|
217
|
+
createCronJob('0 0 * * *', () => {})
|
|
218
|
+
|
|
219
|
+
expect(getActiveCronJobCount()).toBe(3)
|
|
220
|
+
|
|
221
|
+
stopAllCronJobs()
|
|
222
|
+
|
|
223
|
+
expect(getActiveCronJobCount()).toBe(0)
|
|
224
|
+
})
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
describe('getActiveCronJobCount', () => {
|
|
228
|
+
it('should return count of active jobs', () => {
|
|
229
|
+
expect(getActiveCronJobCount()).toBe(0)
|
|
230
|
+
|
|
231
|
+
createCronJob('* * * * *', () => {})
|
|
232
|
+
expect(getActiveCronJobCount()).toBe(1)
|
|
233
|
+
|
|
234
|
+
createCronJob('0 * * * *', () => {})
|
|
235
|
+
expect(getActiveCronJobCount()).toBe(2)
|
|
236
|
+
})
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
describe('schedule', () => {
|
|
240
|
+
it('should be an alias for createCronJob', () => {
|
|
241
|
+
const handler = vi.fn()
|
|
242
|
+
const job = schedule('0 9 * * 1', handler)
|
|
243
|
+
|
|
244
|
+
expect(job).toBeDefined()
|
|
245
|
+
expect(job.expression).toBe('0 9 * * 1')
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('should support options', () => {
|
|
249
|
+
const errorHandler = vi.fn()
|
|
250
|
+
const job = schedule('* * * * *', () => {}, {
|
|
251
|
+
id: 'my-schedule',
|
|
252
|
+
onError: errorHandler,
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
expect(job.id).toBe('my-schedule')
|
|
256
|
+
expect(job.onError).toBe(errorHandler)
|
|
257
|
+
})
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
describe('complex cron expressions', () => {
|
|
261
|
+
it('should handle */5 * * * * (every 5 minutes)', async () => {
|
|
262
|
+
const handler = vi.fn()
|
|
263
|
+
// Set a specific time: 10:02
|
|
264
|
+
vi.setSystemTime(new Date('2024-01-15T10:02:00'))
|
|
265
|
+
|
|
266
|
+
createCronJob('*/5 * * * *', handler)
|
|
267
|
+
|
|
268
|
+
// Advance to 10:05 (next 5-minute mark)
|
|
269
|
+
await vi.advanceTimersByTimeAsync(3 * 60 * 1000)
|
|
270
|
+
|
|
271
|
+
expect(handler).toHaveBeenCalledTimes(1)
|
|
272
|
+
|
|
273
|
+
// Advance another 5 minutes to 10:10
|
|
274
|
+
await vi.advanceTimersByTimeAsync(5 * 60 * 1000)
|
|
275
|
+
expect(handler).toHaveBeenCalledTimes(2)
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it('should handle 0 9 * * 1-5 (weekdays at 9am)', async () => {
|
|
279
|
+
const handler = vi.fn()
|
|
280
|
+
// Set to Monday 8am
|
|
281
|
+
vi.setSystemTime(new Date('2024-01-15T08:00:00'))
|
|
282
|
+
|
|
283
|
+
createCronJob('0 9 * * 1-5', handler)
|
|
284
|
+
|
|
285
|
+
// Advance 1 hour to 9am Monday
|
|
286
|
+
await vi.advanceTimersByTimeAsync(60 * 60 * 1000)
|
|
287
|
+
|
|
288
|
+
expect(handler).toHaveBeenCalledTimes(1)
|
|
289
|
+
})
|
|
290
|
+
})
|
|
291
|
+
})
|