@nicnocquee/dataqueue 1.30.0 → 1.32.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.
@@ -0,0 +1,126 @@
1
+ import { describe, it, expect, afterEach, vi } from 'vitest';
2
+ import { getNextCronOccurrence, validateCronExpression } from './cron.js';
3
+
4
+ describe('getNextCronOccurrence', () => {
5
+ afterEach(() => {
6
+ vi.restoreAllMocks();
7
+ });
8
+
9
+ it('returns the next occurrence for a every-5-minutes expression', () => {
10
+ // Setup
11
+ const after = new Date('2026-01-15T10:02:00Z');
12
+
13
+ // Act
14
+ const next = getNextCronOccurrence('*/5 * * * *', 'UTC', after);
15
+
16
+ // Assert
17
+ expect(next).toEqual(new Date('2026-01-15T10:05:00Z'));
18
+ });
19
+
20
+ it('returns the next occurrence for a daily-at-midnight expression', () => {
21
+ // Setup
22
+ const after = new Date('2026-01-15T10:00:00Z');
23
+
24
+ // Act
25
+ const next = getNextCronOccurrence('0 0 * * *', 'UTC', after);
26
+
27
+ // Assert
28
+ expect(next).toEqual(new Date('2026-01-16T00:00:00Z'));
29
+ });
30
+
31
+ it('uses the current time when after is not provided', () => {
32
+ // Act
33
+ const next = getNextCronOccurrence('*/5 * * * *');
34
+
35
+ // Assert
36
+ expect(next).toBeInstanceOf(Date);
37
+ expect(next!.getTime()).toBeGreaterThan(Date.now() - 1000);
38
+ });
39
+
40
+ it('respects a non-UTC timezone', () => {
41
+ // Setup — 10:02 UTC is 19:02 in Asia/Tokyo (UTC+9)
42
+ const after = new Date('2026-01-15T10:02:00Z');
43
+
44
+ // Act — "0 20 * * *" = daily at 20:00 Tokyo time = 11:00 UTC
45
+ const next = getNextCronOccurrence('0 20 * * *', 'Asia/Tokyo', after);
46
+
47
+ // Assert
48
+ expect(next).toEqual(new Date('2026-01-15T11:00:00Z'));
49
+ });
50
+
51
+ it('returns null when expression cannot produce a future match', () => {
52
+ // Setup — Feb 30 never exists: "0 0 30 2 *"
53
+ const after = new Date('2026-01-01T00:00:00Z');
54
+
55
+ // Act
56
+ const next = getNextCronOccurrence('0 0 30 2 *', 'UTC', after);
57
+
58
+ // Assert
59
+ expect(next).toBeNull();
60
+ });
61
+
62
+ it('defaults to UTC timezone', () => {
63
+ // Setup
64
+ const after = new Date('2026-06-01T23:58:00Z');
65
+
66
+ // Act
67
+ const next = getNextCronOccurrence('0 0 * * *', undefined, after);
68
+
69
+ // Assert
70
+ expect(next).toEqual(new Date('2026-06-02T00:00:00Z'));
71
+ });
72
+ });
73
+
74
+ describe('validateCronExpression', () => {
75
+ afterEach(() => {
76
+ vi.restoreAllMocks();
77
+ });
78
+
79
+ it('returns true for a valid every-minute expression', () => {
80
+ // Act
81
+ const result = validateCronExpression('* * * * *');
82
+
83
+ // Assert
84
+ expect(result).toBe(true);
85
+ });
86
+
87
+ it('returns true for a valid complex expression', () => {
88
+ // Act
89
+ const result = validateCronExpression('0 9-17 * * 1-5');
90
+
91
+ // Assert
92
+ expect(result).toBe(true);
93
+ });
94
+
95
+ it('returns false for an invalid expression with too few fields', () => {
96
+ // Act
97
+ const result = validateCronExpression('* *');
98
+
99
+ // Assert
100
+ expect(result).toBe(false);
101
+ });
102
+
103
+ it('returns false for an empty string', () => {
104
+ // Act
105
+ const result = validateCronExpression('');
106
+
107
+ // Assert
108
+ expect(result).toBe(false);
109
+ });
110
+
111
+ it('returns false for a completely invalid string', () => {
112
+ // Act
113
+ const result = validateCronExpression('not a cron expression');
114
+
115
+ // Assert
116
+ expect(result).toBe(false);
117
+ });
118
+
119
+ it('returns true for an expression with step values', () => {
120
+ // Act
121
+ const result = validateCronExpression('*/15 * * * *');
122
+
123
+ // Assert
124
+ expect(result).toBe(true);
125
+ });
126
+ });
package/src/cron.ts ADDED
@@ -0,0 +1,40 @@
1
+ import { Cron } from 'croner';
2
+
3
+ /**
4
+ * Calculate the next occurrence of a cron expression after a given date.
5
+ *
6
+ * @param cronExpression - A standard cron expression (5 fields, e.g. "0 * * * *").
7
+ * @param timezone - IANA timezone string (default: "UTC").
8
+ * @param after - The reference date to compute the next run from (default: now).
9
+ * @param CronImpl - Cron class for dependency injection (default: croner's Cron).
10
+ * @returns The next occurrence as a Date, or null if the expression will never fire again.
11
+ */
12
+ export function getNextCronOccurrence(
13
+ cronExpression: string,
14
+ timezone: string = 'UTC',
15
+ after?: Date,
16
+ CronImpl: typeof Cron = Cron,
17
+ ): Date | null {
18
+ const cron = new CronImpl(cronExpression, { timezone });
19
+ const next = cron.nextRun(after ?? new Date());
20
+ return next ?? null;
21
+ }
22
+
23
+ /**
24
+ * Validate whether a string is a syntactically correct cron expression.
25
+ *
26
+ * @param cronExpression - The cron expression to validate.
27
+ * @param CronImpl - Cron class for dependency injection (default: croner's Cron).
28
+ * @returns True if the expression is valid, false otherwise.
29
+ */
30
+ export function validateCronExpression(
31
+ cronExpression: string,
32
+ CronImpl: typeof Cron = Cron,
33
+ ): boolean {
34
+ try {
35
+ new CronImpl(cronExpression);
36
+ return true;
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
package/src/index.test.ts CHANGED
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
2
  import { initJobQueue, JobQueueConfig } from './index.js';
3
3
  import { createTestDbAndPool, destroyTestDb } from './test-util.js';
4
4
  import { Pool } from 'pg';
5
+ import type { CronScheduleRecord } from './types.js';
5
6
 
6
7
  // Integration tests for index.ts
7
8
 
@@ -532,3 +533,363 @@ describe('index integration', () => {
532
533
  });
533
534
  });
534
535
  });
536
+
537
+ describe('cron schedules integration', () => {
538
+ let pool: Pool;
539
+ let dbName: string;
540
+ let testDbUrl: string;
541
+ let jobQueue: ReturnType<typeof initJobQueue<TestPayloadMap>>;
542
+
543
+ beforeEach(async () => {
544
+ const setup = await createTestDbAndPool();
545
+ pool = setup.pool;
546
+ dbName = setup.dbName;
547
+ testDbUrl = setup.testDbUrl;
548
+ const config: JobQueueConfig = {
549
+ databaseConfig: {
550
+ connectionString: testDbUrl,
551
+ },
552
+ };
553
+ jobQueue = initJobQueue<TestPayloadMap>(config);
554
+ });
555
+
556
+ afterEach(async () => {
557
+ vi.restoreAllMocks();
558
+ jobQueue.getPool().end();
559
+ await pool.end();
560
+ await destroyTestDb(dbName);
561
+ });
562
+
563
+ it('creates a cron schedule and retrieves it by ID', async () => {
564
+ // Act
565
+ const id = await jobQueue.addCronJob({
566
+ scheduleName: 'every-5-min-email',
567
+ cronExpression: '*/5 * * * *',
568
+ jobType: 'email',
569
+ payload: { to: 'cron@example.com' },
570
+ });
571
+
572
+ // Assert
573
+ const schedule = await jobQueue.getCronJob(id);
574
+ expect(schedule).not.toBeNull();
575
+ expect(schedule!.scheduleName).toBe('every-5-min-email');
576
+ expect(schedule!.cronExpression).toBe('*/5 * * * *');
577
+ expect(schedule!.jobType).toBe('email');
578
+ expect(schedule!.payload).toEqual({ to: 'cron@example.com' });
579
+ expect(schedule!.status).toBe('active');
580
+ expect(schedule!.allowOverlap).toBe(false);
581
+ expect(schedule!.timezone).toBe('UTC');
582
+ expect(schedule!.nextRunAt).toBeInstanceOf(Date);
583
+ });
584
+
585
+ it('retrieves a cron schedule by name', async () => {
586
+ // Setup
587
+ await jobQueue.addCronJob({
588
+ scheduleName: 'my-schedule',
589
+ cronExpression: '0 * * * *',
590
+ jobType: 'email',
591
+ payload: { to: 'test@example.com' },
592
+ });
593
+
594
+ // Act
595
+ const schedule = await jobQueue.getCronJobByName('my-schedule');
596
+
597
+ // Assert
598
+ expect(schedule).not.toBeNull();
599
+ expect(schedule!.scheduleName).toBe('my-schedule');
600
+ });
601
+
602
+ it('returns null for nonexistent schedule', async () => {
603
+ // Act
604
+ const byId = await jobQueue.getCronJob(99999);
605
+ const byName = await jobQueue.getCronJobByName('nonexistent');
606
+
607
+ // Assert
608
+ expect(byId).toBeNull();
609
+ expect(byName).toBeNull();
610
+ });
611
+
612
+ it('rejects duplicate schedule names', async () => {
613
+ // Setup
614
+ await jobQueue.addCronJob({
615
+ scheduleName: 'unique-name',
616
+ cronExpression: '* * * * *',
617
+ jobType: 'email',
618
+ payload: { to: 'a@example.com' },
619
+ });
620
+
621
+ // Act & Assert
622
+ await expect(
623
+ jobQueue.addCronJob({
624
+ scheduleName: 'unique-name',
625
+ cronExpression: '*/5 * * * *',
626
+ jobType: 'sms',
627
+ payload: { to: 'b@example.com' },
628
+ }),
629
+ ).rejects.toThrow();
630
+ });
631
+
632
+ it('rejects invalid cron expressions', async () => {
633
+ // Act & Assert
634
+ await expect(
635
+ jobQueue.addCronJob({
636
+ scheduleName: 'bad-cron',
637
+ cronExpression: 'not a cron',
638
+ jobType: 'email',
639
+ payload: { to: 'a@example.com' },
640
+ }),
641
+ ).rejects.toThrow('Invalid cron expression');
642
+ });
643
+
644
+ it('lists active and paused schedules', async () => {
645
+ // Setup
646
+ const id1 = await jobQueue.addCronJob({
647
+ scheduleName: 'schedule-1',
648
+ cronExpression: '* * * * *',
649
+ jobType: 'email',
650
+ payload: { to: 'a@example.com' },
651
+ });
652
+ await jobQueue.addCronJob({
653
+ scheduleName: 'schedule-2',
654
+ cronExpression: '*/5 * * * *',
655
+ jobType: 'sms',
656
+ payload: { to: 'b@example.com' },
657
+ });
658
+ await jobQueue.pauseCronJob(id1);
659
+
660
+ // Act
661
+ const all = await jobQueue.listCronJobs();
662
+ const active = await jobQueue.listCronJobs('active');
663
+ const paused = await jobQueue.listCronJobs('paused');
664
+
665
+ // Assert
666
+ expect(all).toHaveLength(2);
667
+ expect(active).toHaveLength(1);
668
+ expect(active[0].scheduleName).toBe('schedule-2');
669
+ expect(paused).toHaveLength(1);
670
+ expect(paused[0].scheduleName).toBe('schedule-1');
671
+ });
672
+
673
+ it('pauses and resumes a schedule', async () => {
674
+ // Setup
675
+ const id = await jobQueue.addCronJob({
676
+ scheduleName: 'pausable',
677
+ cronExpression: '* * * * *',
678
+ jobType: 'email',
679
+ payload: { to: 'a@example.com' },
680
+ });
681
+
682
+ // Act — pause
683
+ await jobQueue.pauseCronJob(id);
684
+ const paused = await jobQueue.getCronJob(id);
685
+
686
+ // Assert
687
+ expect(paused!.status).toBe('paused');
688
+
689
+ // Act — resume
690
+ await jobQueue.resumeCronJob(id);
691
+ const resumed = await jobQueue.getCronJob(id);
692
+
693
+ // Assert
694
+ expect(resumed!.status).toBe('active');
695
+ });
696
+
697
+ it('edits a schedule and recalculates nextRunAt when expression changes', async () => {
698
+ // Setup
699
+ const id = await jobQueue.addCronJob({
700
+ scheduleName: 'editable',
701
+ cronExpression: '* * * * *',
702
+ jobType: 'email',
703
+ payload: { to: 'old@example.com' },
704
+ });
705
+ const before = await jobQueue.getCronJob(id);
706
+
707
+ // Act
708
+ await jobQueue.editCronJob(id, {
709
+ cronExpression: '0 0 * * *',
710
+ payload: { to: 'new@example.com' },
711
+ });
712
+
713
+ // Assert
714
+ const after = await jobQueue.getCronJob(id);
715
+ expect(after!.cronExpression).toBe('0 0 * * *');
716
+ expect(after!.payload).toEqual({ to: 'new@example.com' });
717
+ expect(after!.nextRunAt!.getTime()).not.toBe(before!.nextRunAt!.getTime());
718
+ });
719
+
720
+ it('removes a schedule', async () => {
721
+ // Setup
722
+ const id = await jobQueue.addCronJob({
723
+ scheduleName: 'removable',
724
+ cronExpression: '* * * * *',
725
+ jobType: 'email',
726
+ payload: { to: 'a@example.com' },
727
+ });
728
+
729
+ // Act
730
+ await jobQueue.removeCronJob(id);
731
+
732
+ // Assert
733
+ const removed = await jobQueue.getCronJob(id);
734
+ expect(removed).toBeNull();
735
+ });
736
+
737
+ it('enqueueDueCronJobs enqueues a job when nextRunAt is due', async () => {
738
+ // Setup — insert a schedule with nextRunAt in the past
739
+ const id = await jobQueue.addCronJob({
740
+ scheduleName: 'due-now',
741
+ cronExpression: '* * * * *',
742
+ jobType: 'email',
743
+ payload: { to: 'due@example.com' },
744
+ });
745
+ // Force nextRunAt to be in the past
746
+ await pool.query(
747
+ `UPDATE cron_schedules SET next_run_at = NOW() - INTERVAL '1 minute' WHERE id = $1`,
748
+ [id],
749
+ );
750
+
751
+ // Act
752
+ const count = await jobQueue.enqueueDueCronJobs();
753
+
754
+ // Assert
755
+ expect(count).toBe(1);
756
+ const jobs = await jobQueue.getJobsByStatus('pending');
757
+ const cronJob = jobs.find(
758
+ (j) =>
759
+ j.jobType === 'email' && (j.payload as any).to === 'due@example.com',
760
+ );
761
+ expect(cronJob).toBeDefined();
762
+ });
763
+
764
+ it('enqueueDueCronJobs advances nextRunAt and sets lastJobId', async () => {
765
+ // Setup
766
+ const id = await jobQueue.addCronJob({
767
+ scheduleName: 'advance-test',
768
+ cronExpression: '* * * * *',
769
+ jobType: 'email',
770
+ payload: { to: 'advance@example.com' },
771
+ });
772
+ await pool.query(
773
+ `UPDATE cron_schedules SET next_run_at = NOW() - INTERVAL '1 minute' WHERE id = $1`,
774
+ [id],
775
+ );
776
+
777
+ // Act
778
+ await jobQueue.enqueueDueCronJobs();
779
+
780
+ // Assert
781
+ const schedule = await jobQueue.getCronJob(id);
782
+ expect(schedule!.lastJobId).not.toBeNull();
783
+ expect(schedule!.lastEnqueuedAt).toBeInstanceOf(Date);
784
+ expect(schedule!.nextRunAt).toBeInstanceOf(Date);
785
+ expect(schedule!.nextRunAt!.getTime()).toBeGreaterThan(Date.now() - 5000);
786
+ });
787
+
788
+ it('enqueueDueCronJobs skips paused schedules', async () => {
789
+ // Setup
790
+ const id = await jobQueue.addCronJob({
791
+ scheduleName: 'paused-skip',
792
+ cronExpression: '* * * * *',
793
+ jobType: 'email',
794
+ payload: { to: 'paused@example.com' },
795
+ });
796
+ await pool.query(
797
+ `UPDATE cron_schedules SET next_run_at = NOW() - INTERVAL '1 minute' WHERE id = $1`,
798
+ [id],
799
+ );
800
+ await jobQueue.pauseCronJob(id);
801
+
802
+ // Act
803
+ const count = await jobQueue.enqueueDueCronJobs();
804
+
805
+ // Assert
806
+ expect(count).toBe(0);
807
+ });
808
+
809
+ it('enqueueDueCronJobs skips schedules not yet due', async () => {
810
+ // Setup — nextRunAt is calculated to the future by addCronJob
811
+ await jobQueue.addCronJob({
812
+ scheduleName: 'future-schedule',
813
+ cronExpression: '0 0 1 1 *',
814
+ jobType: 'email',
815
+ payload: { to: 'future@example.com' },
816
+ });
817
+
818
+ // Act
819
+ const count = await jobQueue.enqueueDueCronJobs();
820
+
821
+ // Assert
822
+ expect(count).toBe(0);
823
+ });
824
+
825
+ it('enqueueDueCronJobs skips when allowOverlap=false and last job is still active', async () => {
826
+ // Setup
827
+ const id = await jobQueue.addCronJob({
828
+ scheduleName: 'no-overlap',
829
+ cronExpression: '* * * * *',
830
+ jobType: 'email',
831
+ payload: { to: 'overlap@example.com' },
832
+ allowOverlap: false,
833
+ });
834
+ await pool.query(
835
+ `UPDATE cron_schedules SET next_run_at = NOW() - INTERVAL '1 minute' WHERE id = $1`,
836
+ [id],
837
+ );
838
+
839
+ // First enqueue should succeed
840
+ const count1 = await jobQueue.enqueueDueCronJobs();
841
+ expect(count1).toBe(1);
842
+
843
+ // Set nextRunAt to past again (simulating next tick)
844
+ await pool.query(
845
+ `UPDATE cron_schedules SET next_run_at = NOW() - INTERVAL '1 minute' WHERE id = $1`,
846
+ [id],
847
+ );
848
+
849
+ // Act — second enqueue should be skipped because previous job is still pending
850
+ const count2 = await jobQueue.enqueueDueCronJobs();
851
+
852
+ // Assert
853
+ expect(count2).toBe(0);
854
+ });
855
+
856
+ it('enqueueDueCronJobs enqueues when allowOverlap=true even if last job is still active', async () => {
857
+ // Setup
858
+ const id = await jobQueue.addCronJob({
859
+ scheduleName: 'with-overlap',
860
+ cronExpression: '* * * * *',
861
+ jobType: 'email',
862
+ payload: { to: 'overlap@example.com' },
863
+ allowOverlap: true,
864
+ });
865
+ await pool.query(
866
+ `UPDATE cron_schedules SET next_run_at = NOW() - INTERVAL '1 minute' WHERE id = $1`,
867
+ [id],
868
+ );
869
+
870
+ // First enqueue
871
+ const count1 = await jobQueue.enqueueDueCronJobs();
872
+ expect(count1).toBe(1);
873
+
874
+ // Set nextRunAt to past again
875
+ await pool.query(
876
+ `UPDATE cron_schedules SET next_run_at = NOW() - INTERVAL '1 minute' WHERE id = $1`,
877
+ [id],
878
+ );
879
+
880
+ // Act — second enqueue should succeed because allowOverlap=true
881
+ const count2 = await jobQueue.enqueueDueCronJobs();
882
+
883
+ // Assert
884
+ expect(count2).toBe(1);
885
+
886
+ // Verify there are two pending jobs
887
+ const jobs = await jobQueue.getJobsByStatus('pending');
888
+ const cronJobs = jobs.filter(
889
+ (j) =>
890
+ j.jobType === 'email' &&
891
+ (j.payload as any).to === 'overlap@example.com',
892
+ );
893
+ expect(cronJobs).toHaveLength(2);
894
+ });
895
+ });