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,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
+ })