ai-workflows 2.1.3 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +14 -1
  3. package/README.md +2 -0
  4. package/dist/barrier.d.ts +6 -0
  5. package/dist/barrier.d.ts.map +1 -1
  6. package/dist/barrier.js +45 -7
  7. package/dist/barrier.js.map +1 -1
  8. package/dist/cascade-context.d.ts.map +1 -1
  9. package/dist/cascade-context.js +25 -25
  10. package/dist/cascade-context.js.map +1 -1
  11. package/dist/cascade-executor.d.ts.map +1 -1
  12. package/dist/cascade-executor.js +1 -1
  13. package/dist/cascade-executor.js.map +1 -1
  14. package/dist/context.d.ts.map +1 -1
  15. package/dist/context.js +23 -7
  16. package/dist/context.js.map +1 -1
  17. package/dist/cron-parser.d.ts +65 -0
  18. package/dist/cron-parser.d.ts.map +1 -0
  19. package/dist/cron-parser.js +294 -0
  20. package/dist/cron-parser.js.map +1 -0
  21. package/dist/cron-scheduler.d.ts +117 -0
  22. package/dist/cron-scheduler.d.ts.map +1 -0
  23. package/dist/cron-scheduler.js +176 -0
  24. package/dist/cron-scheduler.js.map +1 -0
  25. package/dist/database-context.d.ts +184 -0
  26. package/dist/database-context.d.ts.map +1 -0
  27. package/dist/database-context.js +428 -0
  28. package/dist/database-context.js.map +1 -0
  29. package/dist/digital-objects-adapter.d.ts +159 -0
  30. package/dist/digital-objects-adapter.d.ts.map +1 -0
  31. package/dist/digital-objects-adapter.js +229 -0
  32. package/dist/digital-objects-adapter.js.map +1 -0
  33. package/dist/durable-execution-cloudflare.d.ts +427 -0
  34. package/dist/durable-execution-cloudflare.d.ts.map +1 -0
  35. package/dist/durable-execution-cloudflare.js +510 -0
  36. package/dist/durable-execution-cloudflare.js.map +1 -0
  37. package/dist/durable-execution.d.ts +482 -0
  38. package/dist/durable-execution.d.ts.map +1 -0
  39. package/dist/durable-execution.js +594 -0
  40. package/dist/durable-execution.js.map +1 -0
  41. package/dist/durable-workflow.d.ts +176 -0
  42. package/dist/durable-workflow.d.ts.map +1 -0
  43. package/dist/durable-workflow.js +552 -0
  44. package/dist/durable-workflow.js.map +1 -0
  45. package/dist/graph/topological-sort.d.ts.map +1 -1
  46. package/dist/graph/topological-sort.js +5 -5
  47. package/dist/graph/topological-sort.js.map +1 -1
  48. package/dist/index.d.ts +4 -0
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +15 -0
  51. package/dist/index.js.map +1 -1
  52. package/dist/logger.d.ts +101 -0
  53. package/dist/logger.d.ts.map +1 -0
  54. package/dist/logger.js +115 -0
  55. package/dist/logger.js.map +1 -0
  56. package/dist/on.d.ts.map +1 -1
  57. package/dist/on.js +3 -3
  58. package/dist/on.js.map +1 -1
  59. package/dist/runtime.d.ts +169 -0
  60. package/dist/runtime.d.ts.map +1 -0
  61. package/dist/runtime.js +275 -0
  62. package/dist/runtime.js.map +1 -0
  63. package/dist/send.d.ts.map +1 -1
  64. package/dist/send.js +4 -3
  65. package/dist/send.js.map +1 -1
  66. package/dist/telemetry.d.ts +150 -0
  67. package/dist/telemetry.d.ts.map +1 -0
  68. package/dist/telemetry.js +388 -0
  69. package/dist/telemetry.js.map +1 -0
  70. package/dist/timer-registry.d.ts +25 -0
  71. package/dist/timer-registry.d.ts.map +1 -1
  72. package/dist/timer-registry.js +42 -8
  73. package/dist/timer-registry.js.map +1 -1
  74. package/dist/types.d.ts +17 -6
  75. package/dist/types.d.ts.map +1 -1
  76. package/dist/types.js +1 -1
  77. package/dist/types.js.map +1 -1
  78. package/dist/worker/durable-step.d.ts +481 -0
  79. package/dist/worker/durable-step.d.ts.map +1 -0
  80. package/dist/worker/durable-step.js +606 -0
  81. package/dist/worker/durable-step.js.map +1 -0
  82. package/dist/worker/index.d.ts +106 -0
  83. package/dist/worker/index.d.ts.map +1 -0
  84. package/dist/worker/index.js +124 -0
  85. package/dist/worker/index.js.map +1 -0
  86. package/dist/worker/state-adapter.d.ts +230 -0
  87. package/dist/worker/state-adapter.d.ts.map +1 -0
  88. package/dist/worker/state-adapter.js +409 -0
  89. package/dist/worker/state-adapter.js.map +1 -0
  90. package/dist/worker/topological-executor.d.ts +282 -0
  91. package/dist/worker/topological-executor.d.ts.map +1 -0
  92. package/dist/worker/topological-executor.js +396 -0
  93. package/dist/worker/topological-executor.js.map +1 -0
  94. package/dist/worker/workflow-builder.d.ts +286 -0
  95. package/dist/worker/workflow-builder.d.ts.map +1 -0
  96. package/dist/worker/workflow-builder.js +565 -0
  97. package/dist/worker/workflow-builder.js.map +1 -0
  98. package/dist/worker.d.ts +800 -0
  99. package/dist/worker.d.ts.map +1 -0
  100. package/dist/worker.js +2428 -0
  101. package/dist/worker.js.map +1 -0
  102. package/dist/workflow-builder.d.ts +287 -0
  103. package/dist/workflow-builder.d.ts.map +1 -0
  104. package/dist/workflow-builder.js +762 -0
  105. package/dist/workflow-builder.js.map +1 -0
  106. package/dist/workflow.d.ts +14 -30
  107. package/dist/workflow.d.ts.map +1 -1
  108. package/dist/workflow.js +132 -292
  109. package/dist/workflow.js.map +1 -1
  110. package/examples/01-ecommerce-order-pipeline.ts +358 -0
  111. package/examples/02-content-moderation-cascade.ts +454 -0
  112. package/examples/03-scheduled-reporting-dependencies.ts +479 -0
  113. package/examples/04-database-persistence.ts +518 -0
  114. package/examples/README.md +173 -0
  115. package/package.json +30 -13
  116. package/src/__tests__/digital-objects-adapter.test.ts +274 -0
  117. package/src/__tests__/durable-workflow.test.ts +297 -0
  118. package/src/barrier.ts +48 -7
  119. package/src/cascade-context.ts +36 -29
  120. package/src/cascade-executor.ts +3 -2
  121. package/src/context.ts +41 -12
  122. package/src/cron-parser.ts +347 -0
  123. package/src/cron-scheduler.ts +239 -0
  124. package/src/database-context.ts +658 -0
  125. package/src/digital-objects-adapter.ts +351 -0
  126. package/src/durable-execution-cloudflare.ts +855 -0
  127. package/src/durable-execution.ts +1042 -0
  128. package/src/durable-workflow.ts +717 -0
  129. package/src/graph/topological-sort.ts +6 -8
  130. package/src/index.ts +69 -0
  131. package/src/logger.ts +148 -0
  132. package/src/on.ts +8 -9
  133. package/src/runtime.ts +436 -0
  134. package/src/send.ts +4 -5
  135. package/src/telemetry.ts +577 -0
  136. package/src/timer-registry.ts +44 -10
  137. package/src/types.ts +32 -17
  138. package/src/worker/durable-step.ts +976 -0
  139. package/src/worker/index.ts +216 -0
  140. package/src/worker/state-adapter.ts +589 -0
  141. package/src/worker/topological-executor.ts +625 -0
  142. package/src/worker/workflow-builder.ts +871 -0
  143. package/src/worker.ts +2906 -0
  144. package/src/workflow-builder.ts +1068 -0
  145. package/src/workflow.ts +188 -351
  146. package/test/barrier-join.test.ts +32 -24
  147. package/test/cascade-executor.test.ts +9 -16
  148. package/test/cron-parser.test.ts +314 -0
  149. package/test/cron-scheduler.test.ts +291 -0
  150. package/test/database-context.test.ts +770 -0
  151. package/test/db-provider-adapter.test.ts +862 -0
  152. package/test/durable-execution-cloudflare.test.ts +606 -0
  153. package/test/durable-execution-in-process.test.ts +286 -0
  154. package/test/durable-execution.test.ts +247 -0
  155. package/test/e2e/workflow-scenarios.e2e.test.ts +1039 -0
  156. package/test/integration.test.ts +442 -0
  157. package/test/rpc-surface.test.ts +946 -0
  158. package/test/runtime.test.ts +262 -0
  159. package/test/schedule-timer-cleanup.test.ts +30 -21
  160. package/test/send-race-conditions.test.ts +30 -40
  161. package/test/worker/durable-cascade.test.ts +1117 -0
  162. package/test/worker/durable-step.test.ts +723 -0
  163. package/test/worker/topological-executor.test.ts +1240 -0
  164. package/test/worker/workflow-builder.test.ts +1067 -0
  165. package/test/worker.test.ts +608 -0
  166. package/test/workflow-builder.test.ts +1670 -0
  167. package/test/workflow-cron.test.ts +256 -0
  168. package/test/workflow-state-adapter.test.ts +923 -0
  169. package/test/workflow.test.ts +25 -22
  170. package/tsconfig.json +3 -1
  171. package/vitest.config.ts +38 -1
  172. package/vitest.workers.config.ts +44 -0
  173. package/wrangler.jsonc +22 -0
  174. package/.turbo/turbo-test.log +0 -169
  175. package/LICENSE +0 -21
  176. package/src/context.js +0 -83
  177. package/src/every.js +0 -267
  178. package/src/index.js +0 -71
  179. package/src/on.js +0 -79
  180. package/src/send.js +0 -111
  181. package/src/types.js +0 -4
  182. package/src/workflow.js +0 -455
  183. package/test/context.test.js +0 -116
  184. package/test/every.test.js +0 -282
  185. package/test/on.test.js +0 -80
  186. package/test/send.test.js +0 -89
  187. package/test/workflow.test.js +0 -224
  188. package/vitest.config.js +0 -7
@@ -0,0 +1,347 @@
1
+ /**
2
+ * Cron Expression Parser
3
+ *
4
+ * Parses standard 5-field cron expressions:
5
+ * minute hour day-of-month month day-of-week
6
+ *
7
+ * Also supports optional 6-field expressions with seconds:
8
+ * second minute hour day-of-month month day-of-week
9
+ *
10
+ * Supported syntax:
11
+ * - Numbers: 0, 5, 15
12
+ * - Ranges: 1-5, 9-17
13
+ * - Lists: 1,3,5, Mon,Wed,Fri
14
+ * - Steps: star/5, 0-30/5
15
+ * - Wildcards: star (asterisk)
16
+ * - Day names: Mon, Tue, Wed, Thu, Fri, Sat, Sun
17
+ * - Month names: Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec
18
+ */
19
+
20
+ /**
21
+ * Parsed cron expression
22
+ */
23
+ export interface ParsedCron {
24
+ /** Seconds (0-59) - optional, defaults to [0] */
25
+ seconds: number[]
26
+ /** Minutes (0-59) */
27
+ minutes: number[]
28
+ /** Hours (0-23) */
29
+ hours: number[]
30
+ /** Days of month (1-31) */
31
+ daysOfMonth: number[]
32
+ /** Months (1-12) */
33
+ months: number[]
34
+ /** Days of week (0-6, Sunday = 0) */
35
+ daysOfWeek: number[]
36
+ /** Whether day of month is a wildcard */
37
+ dayOfMonthWildcard: boolean
38
+ /** Whether day of week is a wildcard */
39
+ dayOfWeekWildcard: boolean
40
+ }
41
+
42
+ /**
43
+ * Day name to number mapping (Sunday = 0)
44
+ */
45
+ const DAY_NAMES: Record<string, number> = {
46
+ sun: 0,
47
+ mon: 1,
48
+ tue: 2,
49
+ wed: 3,
50
+ thu: 4,
51
+ fri: 5,
52
+ sat: 6,
53
+ }
54
+
55
+ /**
56
+ * Month name to number mapping (January = 1)
57
+ */
58
+ const MONTH_NAMES: Record<string, number> = {
59
+ jan: 1,
60
+ feb: 2,
61
+ mar: 3,
62
+ apr: 4,
63
+ may: 5,
64
+ jun: 6,
65
+ jul: 7,
66
+ aug: 8,
67
+ sep: 9,
68
+ oct: 10,
69
+ nov: 11,
70
+ dec: 12,
71
+ }
72
+
73
+ /**
74
+ * Parse a single cron field value
75
+ */
76
+ function parseFieldValue(
77
+ value: string,
78
+ min: number,
79
+ max: number,
80
+ names?: Record<string, number>
81
+ ): number {
82
+ // Check for named value (e.g., Mon, Jan)
83
+ if (names) {
84
+ const lower = value.toLowerCase()
85
+ if (lower in names) {
86
+ return names[lower]!
87
+ }
88
+ }
89
+
90
+ const num = parseInt(value, 10)
91
+ if (isNaN(num) || num < min || num > max) {
92
+ throw new Error(`Invalid cron field value: ${value} (expected ${min}-${max})`)
93
+ }
94
+ return num
95
+ }
96
+
97
+ /**
98
+ * Parse a single cron field
99
+ * Examples: "0", "*", "1-5", "* /15" (no space), "1,3,5"
100
+ */
101
+ function parseField(
102
+ field: string,
103
+ min: number,
104
+ max: number,
105
+ names?: Record<string, number>
106
+ ): number[] {
107
+ const values: Set<number> = new Set()
108
+
109
+ // Split by comma for lists
110
+ const parts = field.split(',')
111
+
112
+ for (const part of parts) {
113
+ // Check for step (e.g., */5 or 0-30/5)
114
+ const stepMatch = part.match(/^(.+)\/(\d+)$/)
115
+ const step = stepMatch ? parseInt(stepMatch[2]!, 10) : 1
116
+ const range = stepMatch ? stepMatch[1]! : part
117
+
118
+ let start: number
119
+ let end: number
120
+
121
+ if (range === '*') {
122
+ start = min
123
+ end = max
124
+ } else if (range.includes('-')) {
125
+ // Range (e.g., 1-5)
126
+ const [rangeStart, rangeEnd] = range.split('-')
127
+ start = parseFieldValue(rangeStart!, min, max, names)
128
+ end = parseFieldValue(rangeEnd!, min, max, names)
129
+ } else {
130
+ // Single value
131
+ start = parseFieldValue(range, min, max, names)
132
+ end = start
133
+ }
134
+
135
+ // Generate values with step
136
+ for (let i = start; i <= end; i += step) {
137
+ values.add(i)
138
+ }
139
+ }
140
+
141
+ return Array.from(values).sort((a, b) => a - b)
142
+ }
143
+
144
+ /**
145
+ * Parse a cron expression
146
+ *
147
+ * @param expression - Cron expression (5 or 6 fields)
148
+ * @returns ParsedCron object
149
+ * @throws Error if expression is invalid
150
+ */
151
+ export function parseCron(expression: string): ParsedCron {
152
+ const fields = expression.trim().split(/\s+/)
153
+
154
+ if (fields.length < 5 || fields.length > 6) {
155
+ throw new Error(`Invalid cron expression: expected 5 or 6 fields, got ${fields.length}`)
156
+ }
157
+
158
+ // Determine if we have seconds field
159
+ const hasSeconds = fields.length === 6
160
+ const offset = hasSeconds ? 0 : -1
161
+
162
+ const secondsField = hasSeconds ? fields[0]! : '0'
163
+ const minutesField = fields[offset + 1]!
164
+ const hoursField = fields[offset + 2]!
165
+ const daysOfMonthField = fields[offset + 3]!
166
+ const monthsField = fields[offset + 4]!
167
+ const daysOfWeekField = fields[offset + 5]!
168
+
169
+ return {
170
+ seconds: parseField(secondsField, 0, 59),
171
+ minutes: parseField(minutesField, 0, 59),
172
+ hours: parseField(hoursField, 0, 23),
173
+ daysOfMonth: parseField(daysOfMonthField, 1, 31),
174
+ months: parseField(monthsField, 1, 12, MONTH_NAMES),
175
+ daysOfWeek: parseField(daysOfWeekField, 0, 6, DAY_NAMES),
176
+ dayOfMonthWildcard: daysOfMonthField === '*',
177
+ dayOfWeekWildcard: daysOfWeekField === '*',
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Check if a date matches a parsed cron expression
183
+ */
184
+ export function matchesCron(date: Date, cron: ParsedCron): boolean {
185
+ const second = date.getSeconds()
186
+ const minute = date.getMinutes()
187
+ const hour = date.getHours()
188
+ const dayOfMonth = date.getDate()
189
+ const month = date.getMonth() + 1 // JavaScript months are 0-indexed
190
+ const dayOfWeek = date.getDay()
191
+
192
+ // Check basic fields
193
+ if (!cron.seconds.includes(second)) return false
194
+ if (!cron.minutes.includes(minute)) return false
195
+ if (!cron.hours.includes(hour)) return false
196
+ if (!cron.months.includes(month)) return false
197
+
198
+ // Handle day matching (special cron semantics)
199
+ // If both day-of-month and day-of-week are specified (not wildcards),
200
+ // either one matching is sufficient (OR logic)
201
+ // If only one is specified, that one must match
202
+ const domMatches = cron.daysOfMonth.includes(dayOfMonth)
203
+ const dowMatches = cron.daysOfWeek.includes(dayOfWeek)
204
+
205
+ if (cron.dayOfMonthWildcard && cron.dayOfWeekWildcard) {
206
+ // Both wildcards - any day matches
207
+ return true
208
+ } else if (cron.dayOfMonthWildcard) {
209
+ // Only day-of-week specified
210
+ return dowMatches
211
+ } else if (cron.dayOfWeekWildcard) {
212
+ // Only day-of-month specified
213
+ return domMatches
214
+ } else {
215
+ // Both specified - OR logic (standard cron behavior)
216
+ return domMatches || dowMatches
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Find the next date that matches a cron expression
222
+ *
223
+ * @param cron - Parsed cron expression
224
+ * @param from - Start date (defaults to now)
225
+ * @param maxIterations - Maximum iterations to prevent infinite loops
226
+ * @returns Next matching Date or null if not found within iterations
227
+ */
228
+ export function getNextCronDate(
229
+ cron: ParsedCron,
230
+ from: Date = new Date(),
231
+ maxIterations: number = 366 * 24 * 60 // ~1 year of minutes
232
+ ): Date | null {
233
+ // Start from the next second
234
+ const next = new Date(from.getTime())
235
+ next.setMilliseconds(0)
236
+ next.setSeconds(next.getSeconds() + 1)
237
+
238
+ let iterations = 0
239
+
240
+ while (iterations < maxIterations) {
241
+ iterations++
242
+
243
+ // Check if current time matches
244
+ if (matchesCron(next, cron)) {
245
+ return next
246
+ }
247
+
248
+ // Advance to next possible match
249
+ // Start by advancing the smallest unit that doesn't match
250
+
251
+ // Check seconds
252
+ if (!cron.seconds.includes(next.getSeconds())) {
253
+ const nextSecond = findNextValue(next.getSeconds(), cron.seconds)
254
+ if (nextSecond !== null) {
255
+ next.setSeconds(nextSecond)
256
+ } else {
257
+ // Roll over to next minute
258
+ next.setSeconds(cron.seconds[0]!)
259
+ next.setMinutes(next.getMinutes() + 1)
260
+ }
261
+ continue
262
+ }
263
+
264
+ // Check minutes
265
+ if (!cron.minutes.includes(next.getMinutes())) {
266
+ const nextMinute = findNextValue(next.getMinutes(), cron.minutes)
267
+ if (nextMinute !== null) {
268
+ next.setMinutes(nextMinute)
269
+ next.setSeconds(cron.seconds[0]!)
270
+ } else {
271
+ // Roll over to next hour
272
+ next.setMinutes(cron.minutes[0]!)
273
+ next.setSeconds(cron.seconds[0]!)
274
+ next.setHours(next.getHours() + 1)
275
+ }
276
+ continue
277
+ }
278
+
279
+ // Check hours
280
+ if (!cron.hours.includes(next.getHours())) {
281
+ const nextHour = findNextValue(next.getHours(), cron.hours)
282
+ if (nextHour !== null) {
283
+ next.setHours(nextHour)
284
+ next.setMinutes(cron.minutes[0]!)
285
+ next.setSeconds(cron.seconds[0]!)
286
+ } else {
287
+ // Roll over to next day
288
+ next.setHours(cron.hours[0]!)
289
+ next.setMinutes(cron.minutes[0]!)
290
+ next.setSeconds(cron.seconds[0]!)
291
+ next.setDate(next.getDate() + 1)
292
+ }
293
+ continue
294
+ }
295
+
296
+ // Check month
297
+ if (!cron.months.includes(next.getMonth() + 1)) {
298
+ const nextMonth = findNextValue(next.getMonth() + 1, cron.months)
299
+ if (nextMonth !== null) {
300
+ next.setMonth(nextMonth - 1)
301
+ next.setDate(1)
302
+ next.setHours(cron.hours[0]!)
303
+ next.setMinutes(cron.minutes[0]!)
304
+ next.setSeconds(cron.seconds[0]!)
305
+ } else {
306
+ // Roll over to next year
307
+ next.setFullYear(next.getFullYear() + 1)
308
+ next.setMonth(cron.months[0]! - 1)
309
+ next.setDate(1)
310
+ next.setHours(cron.hours[0]!)
311
+ next.setMinutes(cron.minutes[0]!)
312
+ next.setSeconds(cron.seconds[0]!)
313
+ }
314
+ continue
315
+ }
316
+
317
+ // Day checks are complex due to OR semantics
318
+ // Just advance by one day and re-check
319
+ next.setDate(next.getDate() + 1)
320
+ next.setHours(cron.hours[0]!)
321
+ next.setMinutes(cron.minutes[0]!)
322
+ next.setSeconds(cron.seconds[0]!)
323
+ }
324
+
325
+ return null
326
+ }
327
+
328
+ /**
329
+ * Find the next value in a sorted array that is greater than the current value
330
+ */
331
+ function findNextValue(current: number, values: number[]): number | null {
332
+ for (const value of values) {
333
+ if (value > current) {
334
+ return value
335
+ }
336
+ }
337
+ return null
338
+ }
339
+
340
+ /**
341
+ * Calculate milliseconds until the next cron occurrence
342
+ */
343
+ export function getNextCronMs(cron: ParsedCron, from: Date = new Date()): number | null {
344
+ const next = getNextCronDate(cron, from)
345
+ if (!next) return null
346
+ return next.getTime() - from.getTime()
347
+ }
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Cron Scheduler
3
+ *
4
+ * Provides scheduling functionality for cron expressions using accurate
5
+ * time-based scheduling rather than polling.
6
+ *
7
+ * Uses a self-adjusting timer approach:
8
+ * 1. Calculate time until next cron occurrence
9
+ * 2. Set a timeout for that duration
10
+ * 3. Execute handler and schedule next occurrence
11
+ *
12
+ * This is more accurate and efficient than setInterval polling.
13
+ */
14
+
15
+ import { parseCron, getNextCronDate, type ParsedCron } from './cron-parser.js'
16
+ import { getLogger } from './logger.js'
17
+
18
+ /**
19
+ * Scheduled job handle
20
+ */
21
+ export interface CronJob {
22
+ /** Unique job ID */
23
+ id: string
24
+ /** Original cron expression */
25
+ expression: string
26
+ /** Parsed cron data */
27
+ cron: ParsedCron
28
+ /** Handler function */
29
+ handler: () => void | Promise<void>
30
+ /** Current timer reference */
31
+ timer: NodeJS.Timeout | null
32
+ /** Next scheduled run time */
33
+ nextRun: Date | null
34
+ /** Whether the job is running */
35
+ running: boolean
36
+ /** Whether the job is stopped */
37
+ stopped: boolean
38
+ /** Error handler */
39
+ onError?: (error: Error) => void
40
+ }
41
+
42
+ /**
43
+ * Job counter for unique IDs
44
+ */
45
+ let jobCounter = 0
46
+
47
+ /**
48
+ * Active cron jobs
49
+ */
50
+ const activeJobs: Map<string, CronJob> = new Map()
51
+
52
+ /**
53
+ * Create a cron job
54
+ *
55
+ * @param expression - Cron expression (5 or 6 fields)
56
+ * @param handler - Function to execute on each occurrence
57
+ * @param options - Optional configuration
58
+ * @returns CronJob handle for managing the job
59
+ *
60
+ * @example
61
+ * ```ts
62
+ * // Run every hour at minute 0
63
+ * const job = createCronJob('0 * * * *', () => {
64
+ * console.log('Hourly task')
65
+ * })
66
+ *
67
+ * // Run Monday at 9am
68
+ * const monday9am = createCronJob('0 9 * * 1', async () => {
69
+ * await sendReport()
70
+ * })
71
+ *
72
+ * // Stop the job
73
+ * job.stop()
74
+ * ```
75
+ */
76
+ export function createCronJob(
77
+ expression: string,
78
+ handler: () => void | Promise<void>,
79
+ options: {
80
+ id?: string
81
+ onError?: (error: Error) => void
82
+ startImmediately?: boolean
83
+ } = {}
84
+ ): CronJob {
85
+ const cron = parseCron(expression)
86
+ const id = options.id ?? `cron-job-${++jobCounter}`
87
+
88
+ const job: CronJob = {
89
+ id,
90
+ expression,
91
+ cron,
92
+ handler,
93
+ timer: null,
94
+ nextRun: null,
95
+ running: false,
96
+ stopped: false,
97
+ ...(options.onError !== undefined && { onError: options.onError }),
98
+ }
99
+
100
+ activeJobs.set(id, job)
101
+
102
+ // Start immediately by default
103
+ if (options.startImmediately !== false) {
104
+ scheduleNext(job)
105
+ }
106
+
107
+ return job
108
+ }
109
+
110
+ /**
111
+ * Schedule the next execution of a cron job
112
+ */
113
+ function scheduleNext(job: CronJob): void {
114
+ if (job.stopped) return
115
+
116
+ const now = new Date()
117
+ const nextRun = getNextCronDate(job.cron, now)
118
+
119
+ if (!nextRun) {
120
+ getLogger().warn(`[cron] Could not calculate next run for job ${job.id}`)
121
+ return
122
+ }
123
+
124
+ job.nextRun = nextRun
125
+ const delay = nextRun.getTime() - now.getTime()
126
+
127
+ // Clear any existing timer
128
+ if (job.timer) {
129
+ clearTimeout(job.timer)
130
+ }
131
+
132
+ // Schedule next execution
133
+ job.timer = setTimeout(async () => {
134
+ if (job.stopped) return
135
+
136
+ job.running = true
137
+ try {
138
+ await job.handler()
139
+ } catch (error) {
140
+ if (job.onError) {
141
+ job.onError(error instanceof Error ? error : new Error(String(error)))
142
+ } else {
143
+ getLogger().error(`[cron] Error in job ${job.id}:`, error)
144
+ }
145
+ } finally {
146
+ job.running = false
147
+ // Schedule next occurrence
148
+ scheduleNext(job)
149
+ }
150
+ }, delay)
151
+ }
152
+
153
+ /**
154
+ * Stop a cron job
155
+ */
156
+ export function stopCronJob(job: CronJob): void {
157
+ job.stopped = true
158
+ if (job.timer) {
159
+ clearTimeout(job.timer)
160
+ job.timer = null
161
+ }
162
+ job.nextRun = null
163
+ activeJobs.delete(job.id)
164
+ }
165
+
166
+ /**
167
+ * Start a stopped cron job
168
+ */
169
+ export function startCronJob(job: CronJob): void {
170
+ job.stopped = false
171
+ scheduleNext(job)
172
+ activeJobs.set(job.id, job)
173
+ }
174
+
175
+ /**
176
+ * Get all active cron jobs
177
+ */
178
+ export function getActiveCronJobs(): CronJob[] {
179
+ return Array.from(activeJobs.values())
180
+ }
181
+
182
+ /**
183
+ * Stop all active cron jobs
184
+ */
185
+ export function stopAllCronJobs(): void {
186
+ for (const job of activeJobs.values()) {
187
+ stopCronJob(job)
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Get the count of active cron jobs
193
+ */
194
+ export function getActiveCronJobCount(): number {
195
+ return activeJobs.size
196
+ }
197
+
198
+ /**
199
+ * Cron job registry object for external access
200
+ */
201
+ export const cronJobRegistry = {
202
+ create: createCronJob,
203
+ stop: stopCronJob,
204
+ start: startCronJob,
205
+ getActive: getActiveCronJobs,
206
+ getActiveCount: getActiveCronJobCount,
207
+ stopAll: stopAllCronJobs,
208
+ }
209
+
210
+ /**
211
+ * Schedule type for cron-based intervals
212
+ * Used to convert schedule intervals to cron jobs
213
+ */
214
+ export interface CronScheduleOptions {
215
+ /** Unique identifier for the schedule */
216
+ id?: string
217
+ /** Error handler */
218
+ onError?: (error: Error) => void
219
+ }
220
+
221
+ /**
222
+ * Create a schedule from a cron expression or well-known pattern
223
+ *
224
+ * @param expression - Cron expression or known pattern name
225
+ * @param handler - Function to execute
226
+ * @param options - Optional configuration
227
+ * @returns CronJob handle
228
+ */
229
+ export function schedule(
230
+ expression: string,
231
+ handler: () => void | Promise<void>,
232
+ options: CronScheduleOptions = {}
233
+ ): CronJob {
234
+ return createCronJob(expression, handler, {
235
+ ...(options.id !== undefined && { id: options.id }),
236
+ ...(options.onError !== undefined && { onError: options.onError }),
237
+ startImmediately: true,
238
+ })
239
+ }