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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +14 -1
- package/README.md +2 -0
- package/dist/barrier.d.ts +6 -0
- package/dist/barrier.d.ts.map +1 -1
- package/dist/barrier.js +45 -7
- package/dist/barrier.js.map +1 -1
- package/dist/cascade-context.d.ts.map +1 -1
- package/dist/cascade-context.js +25 -25
- package/dist/cascade-context.js.map +1 -1
- package/dist/cascade-executor.d.ts.map +1 -1
- package/dist/cascade-executor.js +1 -1
- package/dist/cascade-executor.js.map +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +23 -7
- 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/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/graph/topological-sort.d.ts.map +1 -1
- package/dist/graph/topological-sort.js +5 -5
- package/dist/graph/topological-sort.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -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.map +1 -1
- package/dist/on.js +3 -3
- 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 +25 -0
- package/dist/timer-registry.d.ts.map +1 -1
- package/dist/timer-registry.js +42 -8
- package/dist/timer-registry.js.map +1 -1
- package/dist/types.d.ts +17 -6
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -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 +132 -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 +30 -13
- package/src/__tests__/digital-objects-adapter.test.ts +274 -0
- package/src/__tests__/durable-workflow.test.ts +297 -0
- package/src/barrier.ts +48 -7
- package/src/cascade-context.ts +36 -29
- package/src/cascade-executor.ts +3 -2
- package/src/context.ts +41 -12
- package/src/cron-parser.ts +347 -0
- package/src/cron-scheduler.ts +239 -0
- package/src/database-context.ts +658 -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/graph/topological-sort.ts +6 -8
- package/src/index.ts +69 -0
- package/src/logger.ts +148 -0
- package/src/on.ts +8 -9
- package/src/runtime.ts +436 -0
- package/src/send.ts +4 -5
- package/src/telemetry.ts +577 -0
- package/src/timer-registry.ts +44 -10
- package/src/types.ts +32 -17
- 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 +188 -351
- package/test/barrier-join.test.ts +32 -24
- package/test/cascade-executor.test.ts +9 -16
- 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/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/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 +30 -21
- package/test/send-race-conditions.test.ts +30 -40
- 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 -169
- package/LICENSE +0 -21
- 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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
})
|