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
@@ -43,9 +43,9 @@ describe('Barrier/Join Semantics', () => {
43
43
 
44
44
  it('should preserve order of results', async () => {
45
45
  // step2 resolves first but should be in position 1
46
- const step1 = new Promise<number>(resolve => setTimeout(() => resolve(1), 100))
47
- const step2 = new Promise<number>(resolve => setTimeout(() => resolve(2), 50))
48
- const step3 = new Promise<number>(resolve => setTimeout(() => resolve(3), 150))
46
+ const step1 = new Promise<number>((resolve) => setTimeout(() => resolve(1), 100))
47
+ const step2 = new Promise<number>((resolve) => setTimeout(() => resolve(2), 50))
48
+ const step3 = new Promise<number>((resolve) => setTimeout(() => resolve(3), 150))
49
49
 
50
50
  const promise = waitForAll([step1, step2, step3])
51
51
  await vi.advanceTimersByTimeAsync(200)
@@ -63,9 +63,11 @@ describe('Barrier/Join Semantics', () => {
63
63
  })
64
64
 
65
65
  it('should support timeout option', async () => {
66
- const slowStep = new Promise(resolve => setTimeout(() => resolve('slow'), 5000))
66
+ const slowStep = new Promise((resolve) => setTimeout(() => resolve('slow'), 5000))
67
67
 
68
68
  const promise = waitForAll([slowStep], { timeout: 1000 })
69
+ // Add a catch to prevent unhandled rejection before we can await it
70
+ promise.catch(() => {})
69
71
 
70
72
  await vi.advanceTimersByTimeAsync(1500)
71
73
 
@@ -73,7 +75,7 @@ describe('Barrier/Join Semantics', () => {
73
75
  })
74
76
 
75
77
  it('should return results within timeout', async () => {
76
- const step1 = new Promise<string>(resolve => setTimeout(() => resolve('fast'), 100))
78
+ const step1 = new Promise<string>((resolve) => setTimeout(() => resolve('fast'), 100))
77
79
 
78
80
  const promise = waitForAll([step1], { timeout: 1000 })
79
81
  await vi.advanceTimersByTimeAsync(200)
@@ -90,9 +92,9 @@ describe('Barrier/Join Semantics', () => {
90
92
 
91
93
  describe('waitForAny', () => {
92
94
  it('should resolve when N of M steps complete', async () => {
93
- const step1 = new Promise<string>(resolve => setTimeout(() => resolve('first'), 100))
94
- const step2 = new Promise<string>(resolve => setTimeout(() => resolve('second'), 200))
95
- const step3 = new Promise<string>(resolve => setTimeout(() => resolve('third'), 300))
95
+ const step1 = new Promise<string>((resolve) => setTimeout(() => resolve('first'), 100))
96
+ const step2 = new Promise<string>((resolve) => setTimeout(() => resolve('second'), 200))
97
+ const step3 = new Promise<string>((resolve) => setTimeout(() => resolve('third'), 300))
96
98
 
97
99
  const promise = waitForAny(2, [step1, step2, step3])
98
100
  await vi.advanceTimersByTimeAsync(250)
@@ -105,7 +107,7 @@ describe('Barrier/Join Semantics', () => {
105
107
  })
106
108
 
107
109
  it('should resolve immediately when N=0', async () => {
108
- const step1 = new Promise<string>(resolve => setTimeout(() => resolve('a'), 1000))
110
+ const step1 = new Promise<string>((resolve) => setTimeout(() => resolve('a'), 1000))
109
111
 
110
112
  const result = await waitForAny(0, [step1])
111
113
 
@@ -123,10 +125,12 @@ describe('Barrier/Join Semantics', () => {
123
125
  })
124
126
 
125
127
  it('should support timeout', async () => {
126
- const slowStep1 = new Promise(resolve => setTimeout(() => resolve('a'), 5000))
127
- const slowStep2 = new Promise(resolve => setTimeout(() => resolve('b'), 5000))
128
+ const slowStep1 = new Promise((resolve) => setTimeout(() => resolve('a'), 5000))
129
+ const slowStep2 = new Promise((resolve) => setTimeout(() => resolve('b'), 5000))
128
130
 
129
131
  const promise = waitForAny(2, [slowStep1, slowStep2], { timeout: 1000 })
132
+ // Add a catch to prevent unhandled rejection before we can await it
133
+ promise.catch(() => {})
130
134
 
131
135
  await vi.advanceTimersByTimeAsync(1500)
132
136
 
@@ -134,8 +138,8 @@ describe('Barrier/Join Semantics', () => {
134
138
  })
135
139
 
136
140
  it('should return partial results on timeout when configured', async () => {
137
- const fast = new Promise<string>(resolve => setTimeout(() => resolve('fast'), 100))
138
- const slow = new Promise<string>(resolve => setTimeout(() => resolve('slow'), 5000))
141
+ const fast = new Promise<string>((resolve) => setTimeout(() => resolve('fast'), 100))
142
+ const slow = new Promise<string>((resolve) => setTimeout(() => resolve('slow'), 5000))
139
143
 
140
144
  const promise = waitForAny(2, [fast, slow], {
141
145
  timeout: 1000,
@@ -189,6 +193,8 @@ describe('Barrier/Join Semantics', () => {
189
193
  barrier.arrive('first')
190
194
 
191
195
  const promise = barrier.wait()
196
+ // Add a catch to prevent unhandled rejection before we can await it
197
+ promise.catch(() => {})
192
198
  await vi.advanceTimersByTimeAsync(1500)
193
199
 
194
200
  await expect(promise).rejects.toBeInstanceOf(BarrierTimeoutError)
@@ -265,7 +271,7 @@ describe('Barrier/Join Semantics', () => {
265
271
  const inputs = [1, 2, 3, 4, 5]
266
272
 
267
273
  // Map phase (fanout)
268
- const mapped = await waitForAll(inputs.map(x => Promise.resolve(x * 2)))
274
+ const mapped = await waitForAll(inputs.map((x) => Promise.resolve(x * 2)))
269
275
 
270
276
  // Reduce phase (convergence)
271
277
  const reduced = mapped.reduce((sum, x) => sum + x, 0)
@@ -278,16 +284,16 @@ describe('Barrier/Join Semantics', () => {
278
284
  it('should limit concurrent executions', async () => {
279
285
  const maxConcurrent = 2
280
286
  const tasks = [
281
- () => new Promise<number>(resolve => setTimeout(() => resolve(1), 100)),
282
- () => new Promise<number>(resolve => setTimeout(() => resolve(2), 100)),
283
- () => new Promise<number>(resolve => setTimeout(() => resolve(3), 100)),
284
- () => new Promise<number>(resolve => setTimeout(() => resolve(4), 100)),
287
+ () => new Promise<number>((resolve) => setTimeout(() => resolve(1), 100)),
288
+ () => new Promise<number>((resolve) => setTimeout(() => resolve(2), 100)),
289
+ () => new Promise<number>((resolve) => setTimeout(() => resolve(3), 100)),
290
+ () => new Promise<number>((resolve) => setTimeout(() => resolve(4), 100)),
285
291
  ]
286
292
 
287
293
  let concurrentCount = 0
288
294
  let maxObservedConcurrent = 0
289
295
 
290
- const trackedTasks = tasks.map(task => async () => {
296
+ const trackedTasks = tasks.map((task) => async () => {
291
297
  concurrentCount++
292
298
  maxObservedConcurrent = Math.max(maxObservedConcurrent, concurrentCount)
293
299
  try {
@@ -307,9 +313,9 @@ describe('Barrier/Join Semantics', () => {
307
313
 
308
314
  it('should preserve order even with varying task durations', async () => {
309
315
  const tasks = [
310
- () => new Promise<string>(resolve => setTimeout(() => resolve('a'), 300)),
311
- () => new Promise<string>(resolve => setTimeout(() => resolve('b'), 100)),
312
- () => new Promise<string>(resolve => setTimeout(() => resolve('c'), 200)),
316
+ () => new Promise<string>((resolve) => setTimeout(() => resolve('a'), 300)),
317
+ () => new Promise<string>((resolve) => setTimeout(() => resolve('b'), 100)),
318
+ () => new Promise<string>((resolve) => setTimeout(() => resolve('c'), 200)),
313
319
  ]
314
320
 
315
321
  const promise = withConcurrencyLimit(tasks, 2)
@@ -351,6 +357,8 @@ describe('Barrier/Join Semantics', () => {
351
357
  barrier.arrive('first')
352
358
 
353
359
  const promise = barrier.wait()
360
+ // Add a catch to prevent unhandled rejection before we can await it
361
+ promise.catch(() => {})
354
362
  await vi.advanceTimersByTimeAsync(1500)
355
363
 
356
364
  let caughtError: BarrierTimeoutError | null = null
@@ -418,8 +426,8 @@ describe('Barrier/Join Semantics', () => {
418
426
  const controller = new AbortController()
419
427
 
420
428
  const slowTasks = [
421
- new Promise<string>(resolve => setTimeout(() => resolve('a'), 5000)),
422
- new Promise<string>(resolve => setTimeout(() => resolve('b'), 5000)),
429
+ new Promise<string>((resolve) => setTimeout(() => resolve('a'), 5000)),
430
+ new Promise<string>((resolve) => setTimeout(() => resolve('b'), 5000)),
423
431
  ]
424
432
 
425
433
  const promise = waitForAll(slowTasks, { signal: controller.signal })
@@ -55,10 +55,7 @@ function createFailureHandler(error: Error | string, delay = 0): TierHandler<nev
55
55
  /**
56
56
  * Create a mock tier handler that tracks calls
57
57
  */
58
- function createTrackingHandler<T>(
59
- result: T,
60
- tracker: { calls: number[] }
61
- ): TierHandler<T> {
58
+ function createTrackingHandler<T>(result: T, tracker: { calls: number[] }): TierHandler<T> {
62
59
  const startTime = Date.now()
63
60
  return {
64
61
  name: 'tracking-handler',
@@ -394,6 +391,8 @@ describe('CascadeExecutor', () => {
394
391
  })
395
392
 
396
393
  const resultPromise = executor.execute({ input: 'test' })
394
+ // Add a catch to prevent unhandled rejection before we can await it
395
+ resultPromise.catch(() => {})
397
396
  await vi.advanceTimersByTimeAsync(3001)
398
397
 
399
398
  await expect(resultPromise).rejects.toThrow(CascadeTimeoutError)
@@ -490,6 +489,8 @@ describe('CascadeExecutor', () => {
490
489
  })
491
490
 
492
491
  const resultPromise = executor.execute({ input: 'test' })
492
+ // Add a catch to prevent unhandled rejection before we can await it
493
+ resultPromise.catch(() => {})
493
494
  await vi.advanceTimersByTimeAsync(5001)
494
495
 
495
496
  try {
@@ -656,12 +657,8 @@ describe('CascadeExecutor', () => {
656
657
  await resultPromise
657
658
 
658
659
  // Should have start and end events for both tiers
659
- const codeStart = events.find(
660
- (e) => e.what.includes('code') && e.how.status === 'running'
661
- )
662
- const codeEnd = events.find(
663
- (e) => e.what.includes('code') && e.how.status === 'failed'
664
- )
660
+ const codeStart = events.find((e) => e.what.includes('code') && e.how.status === 'running')
661
+ const codeEnd = events.find((e) => e.what.includes('code') && e.how.status === 'failed')
665
662
  const genStart = events.find(
666
663
  (e) => e.what.includes('generative') && e.how.status === 'running'
667
664
  )
@@ -691,12 +688,8 @@ describe('CascadeExecutor', () => {
691
688
  await vi.runAllTimersAsync()
692
689
  await resultPromise
693
690
 
694
- const cascadeStart = events.find(
695
- (e) => e.what === 'cascade-start'
696
- )
697
- const cascadeComplete = events.find(
698
- (e) => e.what === 'cascade-complete'
699
- )
691
+ const cascadeStart = events.find((e) => e.what === 'cascade-start')
692
+ const cascadeComplete = events.find((e) => e.what === 'cascade-complete')
700
693
 
701
694
  expect(cascadeStart).toBeDefined()
702
695
  expect(cascadeComplete).toBeDefined()
@@ -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
+ })