@nicnocquee/dataqueue 1.26.0 → 1.31.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/dist/cli.cjs +88 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.cts +12 -0
- package/dist/cli.d.ts +12 -0
- package/dist/cli.js +81 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.cjs +3968 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +1088 -0
- package/dist/index.d.ts +1088 -0
- package/dist/index.js +3953 -0
- package/dist/index.js.map +1 -0
- package/migrations/1781200000004_create_cron_schedules_table.sql +33 -0
- package/package.json +15 -18
- package/src/backend.ts +69 -0
- package/src/backends/postgres.ts +331 -1
- package/src/backends/redis.test.ts +350 -0
- package/src/backends/redis.ts +389 -1
- package/src/cron.test.ts +126 -0
- package/src/cron.ts +40 -0
- package/src/index.test.ts +361 -0
- package/src/index.ts +157 -4
- package/src/processor.ts +22 -4
- package/src/types.ts +149 -0
- package/LICENSE +0 -21
|
@@ -541,3 +541,353 @@ describe('Redis backend integration', () => {
|
|
|
541
541
|
expect(job?.status).toBe('pending');
|
|
542
542
|
});
|
|
543
543
|
});
|
|
544
|
+
|
|
545
|
+
describe('Redis cron schedules integration', () => {
|
|
546
|
+
let prefix: string;
|
|
547
|
+
let jobQueue: ReturnType<typeof initJobQueue<TestPayloadMap>>;
|
|
548
|
+
let redisClient: any;
|
|
549
|
+
|
|
550
|
+
beforeEach(async () => {
|
|
551
|
+
prefix = createRedisTestPrefix();
|
|
552
|
+
const config: RedisJobQueueConfig = {
|
|
553
|
+
backend: 'redis',
|
|
554
|
+
redisConfig: {
|
|
555
|
+
url: REDIS_URL,
|
|
556
|
+
keyPrefix: prefix,
|
|
557
|
+
},
|
|
558
|
+
};
|
|
559
|
+
jobQueue = initJobQueue<TestPayloadMap>(config);
|
|
560
|
+
redisClient = jobQueue.getRedisClient();
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
afterEach(async () => {
|
|
564
|
+
vi.restoreAllMocks();
|
|
565
|
+
await cleanupRedisPrefix(redisClient, prefix);
|
|
566
|
+
await redisClient.quit();
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
it('creates a cron schedule and retrieves it by ID', async () => {
|
|
570
|
+
// Act
|
|
571
|
+
const id = await jobQueue.addCronJob({
|
|
572
|
+
scheduleName: 'every-5-min-email',
|
|
573
|
+
cronExpression: '*/5 * * * *',
|
|
574
|
+
jobType: 'email',
|
|
575
|
+
payload: { to: 'cron@example.com' },
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
// Assert
|
|
579
|
+
const schedule = await jobQueue.getCronJob(id);
|
|
580
|
+
expect(schedule).not.toBeNull();
|
|
581
|
+
expect(schedule!.scheduleName).toBe('every-5-min-email');
|
|
582
|
+
expect(schedule!.cronExpression).toBe('*/5 * * * *');
|
|
583
|
+
expect(schedule!.jobType).toBe('email');
|
|
584
|
+
expect(schedule!.payload).toEqual({ to: 'cron@example.com' });
|
|
585
|
+
expect(schedule!.status).toBe('active');
|
|
586
|
+
expect(schedule!.allowOverlap).toBe(false);
|
|
587
|
+
expect(schedule!.timezone).toBe('UTC');
|
|
588
|
+
expect(schedule!.nextRunAt).toBeInstanceOf(Date);
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
it('retrieves a cron schedule by name', async () => {
|
|
592
|
+
// Setup
|
|
593
|
+
await jobQueue.addCronJob({
|
|
594
|
+
scheduleName: 'my-schedule',
|
|
595
|
+
cronExpression: '0 * * * *',
|
|
596
|
+
jobType: 'email',
|
|
597
|
+
payload: { to: 'test@example.com' },
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
// Act
|
|
601
|
+
const schedule = await jobQueue.getCronJobByName('my-schedule');
|
|
602
|
+
|
|
603
|
+
// Assert
|
|
604
|
+
expect(schedule).not.toBeNull();
|
|
605
|
+
expect(schedule!.scheduleName).toBe('my-schedule');
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
it('returns null for nonexistent schedule', async () => {
|
|
609
|
+
// Act
|
|
610
|
+
const byId = await jobQueue.getCronJob(99999);
|
|
611
|
+
const byName = await jobQueue.getCronJobByName('nonexistent');
|
|
612
|
+
|
|
613
|
+
// Assert
|
|
614
|
+
expect(byId).toBeNull();
|
|
615
|
+
expect(byName).toBeNull();
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
it('rejects duplicate schedule names', async () => {
|
|
619
|
+
// Setup
|
|
620
|
+
await jobQueue.addCronJob({
|
|
621
|
+
scheduleName: 'unique-name',
|
|
622
|
+
cronExpression: '* * * * *',
|
|
623
|
+
jobType: 'email',
|
|
624
|
+
payload: { to: 'a@example.com' },
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
// Act & Assert
|
|
628
|
+
await expect(
|
|
629
|
+
jobQueue.addCronJob({
|
|
630
|
+
scheduleName: 'unique-name',
|
|
631
|
+
cronExpression: '*/5 * * * *',
|
|
632
|
+
jobType: 'sms',
|
|
633
|
+
payload: { to: 'b@example.com' },
|
|
634
|
+
}),
|
|
635
|
+
).rejects.toThrow();
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
it('rejects invalid cron expressions', async () => {
|
|
639
|
+
// Act & Assert
|
|
640
|
+
await expect(
|
|
641
|
+
jobQueue.addCronJob({
|
|
642
|
+
scheduleName: 'bad-cron',
|
|
643
|
+
cronExpression: 'not a cron',
|
|
644
|
+
jobType: 'email',
|
|
645
|
+
payload: { to: 'a@example.com' },
|
|
646
|
+
}),
|
|
647
|
+
).rejects.toThrow('Invalid cron expression');
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
it('lists active and paused schedules', async () => {
|
|
651
|
+
// Setup
|
|
652
|
+
const id1 = await jobQueue.addCronJob({
|
|
653
|
+
scheduleName: 'schedule-1',
|
|
654
|
+
cronExpression: '* * * * *',
|
|
655
|
+
jobType: 'email',
|
|
656
|
+
payload: { to: 'a@example.com' },
|
|
657
|
+
});
|
|
658
|
+
await jobQueue.addCronJob({
|
|
659
|
+
scheduleName: 'schedule-2',
|
|
660
|
+
cronExpression: '*/5 * * * *',
|
|
661
|
+
jobType: 'sms',
|
|
662
|
+
payload: { to: 'b@example.com' },
|
|
663
|
+
});
|
|
664
|
+
await jobQueue.pauseCronJob(id1);
|
|
665
|
+
|
|
666
|
+
// Act
|
|
667
|
+
const all = await jobQueue.listCronJobs();
|
|
668
|
+
const active = await jobQueue.listCronJobs('active');
|
|
669
|
+
const paused = await jobQueue.listCronJobs('paused');
|
|
670
|
+
|
|
671
|
+
// Assert
|
|
672
|
+
expect(all).toHaveLength(2);
|
|
673
|
+
expect(active).toHaveLength(1);
|
|
674
|
+
expect(active[0].scheduleName).toBe('schedule-2');
|
|
675
|
+
expect(paused).toHaveLength(1);
|
|
676
|
+
expect(paused[0].scheduleName).toBe('schedule-1');
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
it('pauses and resumes a schedule', async () => {
|
|
680
|
+
// Setup
|
|
681
|
+
const id = await jobQueue.addCronJob({
|
|
682
|
+
scheduleName: 'pausable',
|
|
683
|
+
cronExpression: '* * * * *',
|
|
684
|
+
jobType: 'email',
|
|
685
|
+
payload: { to: 'a@example.com' },
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
// Act — pause
|
|
689
|
+
await jobQueue.pauseCronJob(id);
|
|
690
|
+
const paused = await jobQueue.getCronJob(id);
|
|
691
|
+
|
|
692
|
+
// Assert
|
|
693
|
+
expect(paused!.status).toBe('paused');
|
|
694
|
+
|
|
695
|
+
// Act — resume
|
|
696
|
+
await jobQueue.resumeCronJob(id);
|
|
697
|
+
const resumed = await jobQueue.getCronJob(id);
|
|
698
|
+
|
|
699
|
+
// Assert
|
|
700
|
+
expect(resumed!.status).toBe('active');
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
it('edits a schedule and recalculates nextRunAt when expression changes', async () => {
|
|
704
|
+
// Setup
|
|
705
|
+
const id = await jobQueue.addCronJob({
|
|
706
|
+
scheduleName: 'editable',
|
|
707
|
+
cronExpression: '* * * * *',
|
|
708
|
+
jobType: 'email',
|
|
709
|
+
payload: { to: 'old@example.com' },
|
|
710
|
+
});
|
|
711
|
+
const before = await jobQueue.getCronJob(id);
|
|
712
|
+
|
|
713
|
+
// Act
|
|
714
|
+
await jobQueue.editCronJob(id, {
|
|
715
|
+
cronExpression: '0 0 * * *',
|
|
716
|
+
payload: { to: 'new@example.com' },
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
// Assert
|
|
720
|
+
const after = await jobQueue.getCronJob(id);
|
|
721
|
+
expect(after!.cronExpression).toBe('0 0 * * *');
|
|
722
|
+
expect(after!.payload).toEqual({ to: 'new@example.com' });
|
|
723
|
+
expect(after!.nextRunAt!.getTime()).not.toBe(before!.nextRunAt!.getTime());
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
it('removes a schedule', async () => {
|
|
727
|
+
// Setup
|
|
728
|
+
const id = await jobQueue.addCronJob({
|
|
729
|
+
scheduleName: 'removable',
|
|
730
|
+
cronExpression: '* * * * *',
|
|
731
|
+
jobType: 'email',
|
|
732
|
+
payload: { to: 'a@example.com' },
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
// Act
|
|
736
|
+
await jobQueue.removeCronJob(id);
|
|
737
|
+
|
|
738
|
+
// Assert
|
|
739
|
+
const removed = await jobQueue.getCronJob(id);
|
|
740
|
+
expect(removed).toBeNull();
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
it('enqueueDueCronJobs enqueues a job when nextRunAt is due', async () => {
|
|
744
|
+
// Setup — create schedule then force nextRunAt into the past
|
|
745
|
+
const id = await jobQueue.addCronJob({
|
|
746
|
+
scheduleName: 'due-now',
|
|
747
|
+
cronExpression: '* * * * *',
|
|
748
|
+
jobType: 'email',
|
|
749
|
+
payload: { to: 'due@example.com' },
|
|
750
|
+
});
|
|
751
|
+
const pastMs = (Date.now() - 60_000).toString();
|
|
752
|
+
await redisClient.hset(`${prefix}cron:${id}`, 'nextRunAt', pastMs);
|
|
753
|
+
await redisClient.zadd(`${prefix}cron_due`, Number(pastMs), id.toString());
|
|
754
|
+
|
|
755
|
+
// Act
|
|
756
|
+
const count = await jobQueue.enqueueDueCronJobs();
|
|
757
|
+
|
|
758
|
+
// Assert
|
|
759
|
+
expect(count).toBe(1);
|
|
760
|
+
const jobs = await jobQueue.getJobsByStatus('pending');
|
|
761
|
+
const cronJob = jobs.find(
|
|
762
|
+
(j) =>
|
|
763
|
+
j.jobType === 'email' && (j.payload as any).to === 'due@example.com',
|
|
764
|
+
);
|
|
765
|
+
expect(cronJob).toBeDefined();
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
it('enqueueDueCronJobs advances nextRunAt and sets lastJobId', async () => {
|
|
769
|
+
// Setup
|
|
770
|
+
const id = await jobQueue.addCronJob({
|
|
771
|
+
scheduleName: 'advance-test',
|
|
772
|
+
cronExpression: '* * * * *',
|
|
773
|
+
jobType: 'email',
|
|
774
|
+
payload: { to: 'advance@example.com' },
|
|
775
|
+
});
|
|
776
|
+
const pastMs = (Date.now() - 60_000).toString();
|
|
777
|
+
await redisClient.hset(`${prefix}cron:${id}`, 'nextRunAt', pastMs);
|
|
778
|
+
await redisClient.zadd(`${prefix}cron_due`, Number(pastMs), id.toString());
|
|
779
|
+
|
|
780
|
+
// Act
|
|
781
|
+
await jobQueue.enqueueDueCronJobs();
|
|
782
|
+
|
|
783
|
+
// Assert
|
|
784
|
+
const schedule = await jobQueue.getCronJob(id);
|
|
785
|
+
expect(schedule!.lastJobId).not.toBeNull();
|
|
786
|
+
expect(schedule!.lastEnqueuedAt).toBeInstanceOf(Date);
|
|
787
|
+
expect(schedule!.nextRunAt).toBeInstanceOf(Date);
|
|
788
|
+
expect(schedule!.nextRunAt!.getTime()).toBeGreaterThan(Date.now() - 5000);
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
it('enqueueDueCronJobs skips paused schedules', async () => {
|
|
792
|
+
// Setup
|
|
793
|
+
const id = await jobQueue.addCronJob({
|
|
794
|
+
scheduleName: 'paused-skip',
|
|
795
|
+
cronExpression: '* * * * *',
|
|
796
|
+
jobType: 'email',
|
|
797
|
+
payload: { to: 'paused@example.com' },
|
|
798
|
+
});
|
|
799
|
+
const pastMs = (Date.now() - 60_000).toString();
|
|
800
|
+
await redisClient.hset(`${prefix}cron:${id}`, 'nextRunAt', pastMs);
|
|
801
|
+
await redisClient.zadd(`${prefix}cron_due`, Number(pastMs), id.toString());
|
|
802
|
+
await jobQueue.pauseCronJob(id);
|
|
803
|
+
|
|
804
|
+
// Act
|
|
805
|
+
const count = await jobQueue.enqueueDueCronJobs();
|
|
806
|
+
|
|
807
|
+
// Assert
|
|
808
|
+
expect(count).toBe(0);
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
it('enqueueDueCronJobs skips schedules not yet due', async () => {
|
|
812
|
+
// Setup — nextRunAt is in the future by default
|
|
813
|
+
await jobQueue.addCronJob({
|
|
814
|
+
scheduleName: 'future-schedule',
|
|
815
|
+
cronExpression: '0 0 1 1 *',
|
|
816
|
+
jobType: 'email',
|
|
817
|
+
payload: { to: 'future@example.com' },
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
// Act
|
|
821
|
+
const count = await jobQueue.enqueueDueCronJobs();
|
|
822
|
+
|
|
823
|
+
// Assert
|
|
824
|
+
expect(count).toBe(0);
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
it('enqueueDueCronJobs skips when allowOverlap=false and last job is still active', async () => {
|
|
828
|
+
// Setup
|
|
829
|
+
const id = await jobQueue.addCronJob({
|
|
830
|
+
scheduleName: 'no-overlap',
|
|
831
|
+
cronExpression: '* * * * *',
|
|
832
|
+
jobType: 'email',
|
|
833
|
+
payload: { to: 'overlap@example.com' },
|
|
834
|
+
allowOverlap: false,
|
|
835
|
+
});
|
|
836
|
+
const pastMs = (Date.now() - 60_000).toString();
|
|
837
|
+
await redisClient.hset(`${prefix}cron:${id}`, 'nextRunAt', pastMs);
|
|
838
|
+
await redisClient.zadd(`${prefix}cron_due`, Number(pastMs), id.toString());
|
|
839
|
+
|
|
840
|
+
// First enqueue should succeed
|
|
841
|
+
const count1 = await jobQueue.enqueueDueCronJobs();
|
|
842
|
+
expect(count1).toBe(1);
|
|
843
|
+
|
|
844
|
+
// Force nextRunAt into the past again
|
|
845
|
+
const pastMs2 = (Date.now() - 60_000).toString();
|
|
846
|
+
await redisClient.hset(`${prefix}cron:${id}`, 'nextRunAt', pastMs2);
|
|
847
|
+
await redisClient.zadd(`${prefix}cron_due`, Number(pastMs2), id.toString());
|
|
848
|
+
|
|
849
|
+
// Act — second enqueue should be skipped because previous job is 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
|
+
const pastMs = (Date.now() - 60_000).toString();
|
|
866
|
+
await redisClient.hset(`${prefix}cron:${id}`, 'nextRunAt', pastMs);
|
|
867
|
+
await redisClient.zadd(`${prefix}cron_due`, Number(pastMs), id.toString());
|
|
868
|
+
|
|
869
|
+
// First enqueue
|
|
870
|
+
const count1 = await jobQueue.enqueueDueCronJobs();
|
|
871
|
+
expect(count1).toBe(1);
|
|
872
|
+
|
|
873
|
+
// Force nextRunAt into the past again
|
|
874
|
+
const pastMs2 = (Date.now() - 60_000).toString();
|
|
875
|
+
await redisClient.hset(`${prefix}cron:${id}`, 'nextRunAt', pastMs2);
|
|
876
|
+
await redisClient.zadd(`${prefix}cron_due`, Number(pastMs2), id.toString());
|
|
877
|
+
|
|
878
|
+
// Act — second enqueue should succeed because allowOverlap=true
|
|
879
|
+
const count2 = await jobQueue.enqueueDueCronJobs();
|
|
880
|
+
|
|
881
|
+
// Assert
|
|
882
|
+
expect(count2).toBe(1);
|
|
883
|
+
|
|
884
|
+
// Verify two pending jobs
|
|
885
|
+
const jobs = await jobQueue.getJobsByStatus('pending');
|
|
886
|
+
const cronJobs = jobs.filter(
|
|
887
|
+
(j) =>
|
|
888
|
+
j.jobType === 'email' &&
|
|
889
|
+
(j.payload as any).to === 'overlap@example.com',
|
|
890
|
+
);
|
|
891
|
+
expect(cronJobs).toHaveLength(2);
|
|
892
|
+
});
|
|
893
|
+
});
|