@nicnocquee/dataqueue 1.25.0 → 1.26.0-beta.20260223195940
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/ai/build-docs-content.ts +96 -0
- package/ai/build-llms-full.ts +42 -0
- package/ai/docs-content.json +278 -0
- package/ai/rules/advanced.md +132 -0
- package/ai/rules/basic.md +159 -0
- package/ai/rules/react-dashboard.md +83 -0
- package/ai/skills/dataqueue-advanced/SKILL.md +320 -0
- package/ai/skills/dataqueue-core/SKILL.md +234 -0
- package/ai/skills/dataqueue-react/SKILL.md +189 -0
- package/dist/cli.cjs +1149 -14
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.d.cts +66 -1
- package/dist/cli.d.ts +66 -1
- package/dist/cli.js +1146 -13
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +3157 -1237
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +613 -23
- package/dist/index.d.ts +613 -23
- package/dist/index.js +3156 -1238
- package/dist/index.js.map +1 -1
- package/dist/mcp-server.cjs +186 -0
- package/dist/mcp-server.cjs.map +1 -0
- package/dist/mcp-server.d.cts +32 -0
- package/dist/mcp-server.d.ts +32 -0
- package/dist/mcp-server.js +175 -0
- package/dist/mcp-server.js.map +1 -0
- package/migrations/1781200000004_create_cron_schedules_table.sql +33 -0
- package/migrations/1781200000005_add_retry_config_to_job_queue.sql +17 -0
- package/package.json +24 -21
- package/src/backend.ts +170 -5
- package/src/backends/postgres.ts +992 -63
- package/src/backends/redis-scripts.ts +358 -26
- package/src/backends/redis.test.ts +1363 -0
- package/src/backends/redis.ts +993 -35
- package/src/cli.test.ts +82 -6
- package/src/cli.ts +73 -10
- package/src/cron.test.ts +126 -0
- package/src/cron.ts +40 -0
- package/src/db-util.ts +1 -1
- package/src/index.test.ts +682 -0
- package/src/index.ts +209 -34
- package/src/init-command.test.ts +449 -0
- package/src/init-command.ts +709 -0
- package/src/install-mcp-command.test.ts +216 -0
- package/src/install-mcp-command.ts +185 -0
- package/src/install-rules-command.test.ts +218 -0
- package/src/install-rules-command.ts +233 -0
- package/src/install-skills-command.test.ts +176 -0
- package/src/install-skills-command.ts +124 -0
- package/src/mcp-server.test.ts +162 -0
- package/src/mcp-server.ts +231 -0
- package/src/processor.ts +36 -97
- package/src/queue.test.ts +465 -0
- package/src/queue.ts +34 -252
- package/src/supervisor.test.ts +340 -0
- package/src/supervisor.ts +162 -0
- package/src/types.ts +388 -12
- package/LICENSE +0 -21
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, AddJobOptions } from './types.js';
|
|
5
6
|
|
|
6
7
|
// Integration tests for index.ts
|
|
7
8
|
|
|
@@ -531,4 +532,685 @@ describe('index integration', () => {
|
|
|
531
532
|
priority: 10,
|
|
532
533
|
});
|
|
533
534
|
});
|
|
535
|
+
|
|
536
|
+
// ── Configurable retry strategy integration tests ────────────────────
|
|
537
|
+
|
|
538
|
+
it('should store and return retry config through public API', async () => {
|
|
539
|
+
const jobId = await jobQueue.addJob({
|
|
540
|
+
jobType: 'email',
|
|
541
|
+
payload: { to: 'retry-api@example.com' },
|
|
542
|
+
retryDelay: 20,
|
|
543
|
+
retryBackoff: true,
|
|
544
|
+
retryDelayMax: 300,
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
const job = await jobQueue.getJob(jobId);
|
|
548
|
+
expect(job?.retryDelay).toBe(20);
|
|
549
|
+
expect(job?.retryBackoff).toBe(true);
|
|
550
|
+
expect(job?.retryDelayMax).toBe(300);
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
it('should use fixed delay on failure through public API', async () => {
|
|
554
|
+
const jobId = await jobQueue.addJob({
|
|
555
|
+
jobType: 'email',
|
|
556
|
+
payload: { to: 'fixed-api@example.com' },
|
|
557
|
+
maxAttempts: 3,
|
|
558
|
+
retryDelay: 10,
|
|
559
|
+
retryBackoff: false,
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
const handler = vi.fn(async () => {
|
|
563
|
+
throw new Error('fail');
|
|
564
|
+
});
|
|
565
|
+
const processor = jobQueue.createProcessor({
|
|
566
|
+
email: handler,
|
|
567
|
+
sms: vi.fn(async () => {}),
|
|
568
|
+
test: vi.fn(async () => {}),
|
|
569
|
+
});
|
|
570
|
+
await processor.start();
|
|
571
|
+
|
|
572
|
+
const job = await jobQueue.getJob(jobId);
|
|
573
|
+
expect(job?.status).toBe('failed');
|
|
574
|
+
expect(job?.nextAttemptAt).not.toBeNull();
|
|
575
|
+
const delaySec =
|
|
576
|
+
(job!.nextAttemptAt!.getTime() - job!.lastFailedAt!.getTime()) / 1000;
|
|
577
|
+
expect(delaySec).toBeGreaterThanOrEqual(9);
|
|
578
|
+
expect(delaySec).toBeLessThanOrEqual(11);
|
|
579
|
+
});
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
describe('cron schedules integration', () => {
|
|
583
|
+
let pool: Pool;
|
|
584
|
+
let dbName: string;
|
|
585
|
+
let testDbUrl: string;
|
|
586
|
+
let jobQueue: ReturnType<typeof initJobQueue<TestPayloadMap>>;
|
|
587
|
+
|
|
588
|
+
beforeEach(async () => {
|
|
589
|
+
const setup = await createTestDbAndPool();
|
|
590
|
+
pool = setup.pool;
|
|
591
|
+
dbName = setup.dbName;
|
|
592
|
+
testDbUrl = setup.testDbUrl;
|
|
593
|
+
const config: JobQueueConfig = {
|
|
594
|
+
databaseConfig: {
|
|
595
|
+
connectionString: testDbUrl,
|
|
596
|
+
},
|
|
597
|
+
};
|
|
598
|
+
jobQueue = initJobQueue<TestPayloadMap>(config);
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
afterEach(async () => {
|
|
602
|
+
vi.restoreAllMocks();
|
|
603
|
+
jobQueue.getPool().end();
|
|
604
|
+
await pool.end();
|
|
605
|
+
await destroyTestDb(dbName);
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
it('creates a cron schedule and retrieves it by ID', async () => {
|
|
609
|
+
// Act
|
|
610
|
+
const id = await jobQueue.addCronJob({
|
|
611
|
+
scheduleName: 'every-5-min-email',
|
|
612
|
+
cronExpression: '*/5 * * * *',
|
|
613
|
+
jobType: 'email',
|
|
614
|
+
payload: { to: 'cron@example.com' },
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
// Assert
|
|
618
|
+
const schedule = await jobQueue.getCronJob(id);
|
|
619
|
+
expect(schedule).not.toBeNull();
|
|
620
|
+
expect(schedule!.scheduleName).toBe('every-5-min-email');
|
|
621
|
+
expect(schedule!.cronExpression).toBe('*/5 * * * *');
|
|
622
|
+
expect(schedule!.jobType).toBe('email');
|
|
623
|
+
expect(schedule!.payload).toEqual({ to: 'cron@example.com' });
|
|
624
|
+
expect(schedule!.status).toBe('active');
|
|
625
|
+
expect(schedule!.allowOverlap).toBe(false);
|
|
626
|
+
expect(schedule!.timezone).toBe('UTC');
|
|
627
|
+
expect(schedule!.nextRunAt).toBeInstanceOf(Date);
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
it('retrieves a cron schedule by name', async () => {
|
|
631
|
+
// Setup
|
|
632
|
+
await jobQueue.addCronJob({
|
|
633
|
+
scheduleName: 'my-schedule',
|
|
634
|
+
cronExpression: '0 * * * *',
|
|
635
|
+
jobType: 'email',
|
|
636
|
+
payload: { to: 'test@example.com' },
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
// Act
|
|
640
|
+
const schedule = await jobQueue.getCronJobByName('my-schedule');
|
|
641
|
+
|
|
642
|
+
// Assert
|
|
643
|
+
expect(schedule).not.toBeNull();
|
|
644
|
+
expect(schedule!.scheduleName).toBe('my-schedule');
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
it('returns null for nonexistent schedule', async () => {
|
|
648
|
+
// Act
|
|
649
|
+
const byId = await jobQueue.getCronJob(99999);
|
|
650
|
+
const byName = await jobQueue.getCronJobByName('nonexistent');
|
|
651
|
+
|
|
652
|
+
// Assert
|
|
653
|
+
expect(byId).toBeNull();
|
|
654
|
+
expect(byName).toBeNull();
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
it('rejects duplicate schedule names', async () => {
|
|
658
|
+
// Setup
|
|
659
|
+
await jobQueue.addCronJob({
|
|
660
|
+
scheduleName: 'unique-name',
|
|
661
|
+
cronExpression: '* * * * *',
|
|
662
|
+
jobType: 'email',
|
|
663
|
+
payload: { to: 'a@example.com' },
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
// Act & Assert
|
|
667
|
+
await expect(
|
|
668
|
+
jobQueue.addCronJob({
|
|
669
|
+
scheduleName: 'unique-name',
|
|
670
|
+
cronExpression: '*/5 * * * *',
|
|
671
|
+
jobType: 'sms',
|
|
672
|
+
payload: { to: 'b@example.com' },
|
|
673
|
+
}),
|
|
674
|
+
).rejects.toThrow();
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
it('rejects invalid cron expressions', async () => {
|
|
678
|
+
// Act & Assert
|
|
679
|
+
await expect(
|
|
680
|
+
jobQueue.addCronJob({
|
|
681
|
+
scheduleName: 'bad-cron',
|
|
682
|
+
cronExpression: 'not a cron',
|
|
683
|
+
jobType: 'email',
|
|
684
|
+
payload: { to: 'a@example.com' },
|
|
685
|
+
}),
|
|
686
|
+
).rejects.toThrow('Invalid cron expression');
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
it('lists active and paused schedules', async () => {
|
|
690
|
+
// Setup
|
|
691
|
+
const id1 = await jobQueue.addCronJob({
|
|
692
|
+
scheduleName: 'schedule-1',
|
|
693
|
+
cronExpression: '* * * * *',
|
|
694
|
+
jobType: 'email',
|
|
695
|
+
payload: { to: 'a@example.com' },
|
|
696
|
+
});
|
|
697
|
+
await jobQueue.addCronJob({
|
|
698
|
+
scheduleName: 'schedule-2',
|
|
699
|
+
cronExpression: '*/5 * * * *',
|
|
700
|
+
jobType: 'sms',
|
|
701
|
+
payload: { to: 'b@example.com' },
|
|
702
|
+
});
|
|
703
|
+
await jobQueue.pauseCronJob(id1);
|
|
704
|
+
|
|
705
|
+
// Act
|
|
706
|
+
const all = await jobQueue.listCronJobs();
|
|
707
|
+
const active = await jobQueue.listCronJobs('active');
|
|
708
|
+
const paused = await jobQueue.listCronJobs('paused');
|
|
709
|
+
|
|
710
|
+
// Assert
|
|
711
|
+
expect(all).toHaveLength(2);
|
|
712
|
+
expect(active).toHaveLength(1);
|
|
713
|
+
expect(active[0].scheduleName).toBe('schedule-2');
|
|
714
|
+
expect(paused).toHaveLength(1);
|
|
715
|
+
expect(paused[0].scheduleName).toBe('schedule-1');
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
it('pauses and resumes a schedule', async () => {
|
|
719
|
+
// Setup
|
|
720
|
+
const id = await jobQueue.addCronJob({
|
|
721
|
+
scheduleName: 'pausable',
|
|
722
|
+
cronExpression: '* * * * *',
|
|
723
|
+
jobType: 'email',
|
|
724
|
+
payload: { to: 'a@example.com' },
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
// Act — pause
|
|
728
|
+
await jobQueue.pauseCronJob(id);
|
|
729
|
+
const paused = await jobQueue.getCronJob(id);
|
|
730
|
+
|
|
731
|
+
// Assert
|
|
732
|
+
expect(paused!.status).toBe('paused');
|
|
733
|
+
|
|
734
|
+
// Act — resume
|
|
735
|
+
await jobQueue.resumeCronJob(id);
|
|
736
|
+
const resumed = await jobQueue.getCronJob(id);
|
|
737
|
+
|
|
738
|
+
// Assert
|
|
739
|
+
expect(resumed!.status).toBe('active');
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
it('edits a schedule and recalculates nextRunAt when expression changes', async () => {
|
|
743
|
+
// Setup
|
|
744
|
+
const id = await jobQueue.addCronJob({
|
|
745
|
+
scheduleName: 'editable',
|
|
746
|
+
cronExpression: '* * * * *',
|
|
747
|
+
jobType: 'email',
|
|
748
|
+
payload: { to: 'old@example.com' },
|
|
749
|
+
});
|
|
750
|
+
const before = await jobQueue.getCronJob(id);
|
|
751
|
+
|
|
752
|
+
// Act
|
|
753
|
+
await jobQueue.editCronJob(id, {
|
|
754
|
+
cronExpression: '0 0 * * *',
|
|
755
|
+
payload: { to: 'new@example.com' },
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
// Assert
|
|
759
|
+
const after = await jobQueue.getCronJob(id);
|
|
760
|
+
expect(after!.cronExpression).toBe('0 0 * * *');
|
|
761
|
+
expect(after!.payload).toEqual({ to: 'new@example.com' });
|
|
762
|
+
expect(after!.nextRunAt!.getTime()).not.toBe(before!.nextRunAt!.getTime());
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
it('removes a schedule', async () => {
|
|
766
|
+
// Setup
|
|
767
|
+
const id = await jobQueue.addCronJob({
|
|
768
|
+
scheduleName: 'removable',
|
|
769
|
+
cronExpression: '* * * * *',
|
|
770
|
+
jobType: 'email',
|
|
771
|
+
payload: { to: 'a@example.com' },
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
// Act
|
|
775
|
+
await jobQueue.removeCronJob(id);
|
|
776
|
+
|
|
777
|
+
// Assert
|
|
778
|
+
const removed = await jobQueue.getCronJob(id);
|
|
779
|
+
expect(removed).toBeNull();
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
it('enqueueDueCronJobs enqueues a job when nextRunAt is due', async () => {
|
|
783
|
+
// Setup — insert a schedule with nextRunAt in the past
|
|
784
|
+
const id = await jobQueue.addCronJob({
|
|
785
|
+
scheduleName: 'due-now',
|
|
786
|
+
cronExpression: '* * * * *',
|
|
787
|
+
jobType: 'email',
|
|
788
|
+
payload: { to: 'due@example.com' },
|
|
789
|
+
});
|
|
790
|
+
// Force nextRunAt to be in the past
|
|
791
|
+
await pool.query(
|
|
792
|
+
`UPDATE cron_schedules SET next_run_at = NOW() - INTERVAL '1 minute' WHERE id = $1`,
|
|
793
|
+
[id],
|
|
794
|
+
);
|
|
795
|
+
|
|
796
|
+
// Act
|
|
797
|
+
const count = await jobQueue.enqueueDueCronJobs();
|
|
798
|
+
|
|
799
|
+
// Assert
|
|
800
|
+
expect(count).toBe(1);
|
|
801
|
+
const jobs = await jobQueue.getJobsByStatus('pending');
|
|
802
|
+
const cronJob = jobs.find(
|
|
803
|
+
(j) =>
|
|
804
|
+
j.jobType === 'email' && (j.payload as any).to === 'due@example.com',
|
|
805
|
+
);
|
|
806
|
+
expect(cronJob).toBeDefined();
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
it('enqueueDueCronJobs advances nextRunAt and sets lastJobId', async () => {
|
|
810
|
+
// Setup
|
|
811
|
+
const id = await jobQueue.addCronJob({
|
|
812
|
+
scheduleName: 'advance-test',
|
|
813
|
+
cronExpression: '* * * * *',
|
|
814
|
+
jobType: 'email',
|
|
815
|
+
payload: { to: 'advance@example.com' },
|
|
816
|
+
});
|
|
817
|
+
await pool.query(
|
|
818
|
+
`UPDATE cron_schedules SET next_run_at = NOW() - INTERVAL '1 minute' WHERE id = $1`,
|
|
819
|
+
[id],
|
|
820
|
+
);
|
|
821
|
+
|
|
822
|
+
// Act
|
|
823
|
+
await jobQueue.enqueueDueCronJobs();
|
|
824
|
+
|
|
825
|
+
// Assert
|
|
826
|
+
const schedule = await jobQueue.getCronJob(id);
|
|
827
|
+
expect(schedule!.lastJobId).not.toBeNull();
|
|
828
|
+
expect(schedule!.lastEnqueuedAt).toBeInstanceOf(Date);
|
|
829
|
+
expect(schedule!.nextRunAt).toBeInstanceOf(Date);
|
|
830
|
+
expect(schedule!.nextRunAt!.getTime()).toBeGreaterThan(Date.now() - 5000);
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
it('enqueueDueCronJobs skips paused schedules', async () => {
|
|
834
|
+
// Setup
|
|
835
|
+
const id = await jobQueue.addCronJob({
|
|
836
|
+
scheduleName: 'paused-skip',
|
|
837
|
+
cronExpression: '* * * * *',
|
|
838
|
+
jobType: 'email',
|
|
839
|
+
payload: { to: 'paused@example.com' },
|
|
840
|
+
});
|
|
841
|
+
await pool.query(
|
|
842
|
+
`UPDATE cron_schedules SET next_run_at = NOW() - INTERVAL '1 minute' WHERE id = $1`,
|
|
843
|
+
[id],
|
|
844
|
+
);
|
|
845
|
+
await jobQueue.pauseCronJob(id);
|
|
846
|
+
|
|
847
|
+
// Act
|
|
848
|
+
const count = await jobQueue.enqueueDueCronJobs();
|
|
849
|
+
|
|
850
|
+
// Assert
|
|
851
|
+
expect(count).toBe(0);
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
it('enqueueDueCronJobs skips schedules not yet due', async () => {
|
|
855
|
+
// Setup — nextRunAt is calculated to the future by addCronJob
|
|
856
|
+
await jobQueue.addCronJob({
|
|
857
|
+
scheduleName: 'future-schedule',
|
|
858
|
+
cronExpression: '0 0 1 1 *',
|
|
859
|
+
jobType: 'email',
|
|
860
|
+
payload: { to: 'future@example.com' },
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
// Act
|
|
864
|
+
const count = await jobQueue.enqueueDueCronJobs();
|
|
865
|
+
|
|
866
|
+
// Assert
|
|
867
|
+
expect(count).toBe(0);
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
it('enqueueDueCronJobs skips when allowOverlap=false and last job is still active', async () => {
|
|
871
|
+
// Setup
|
|
872
|
+
const id = await jobQueue.addCronJob({
|
|
873
|
+
scheduleName: 'no-overlap',
|
|
874
|
+
cronExpression: '* * * * *',
|
|
875
|
+
jobType: 'email',
|
|
876
|
+
payload: { to: 'overlap@example.com' },
|
|
877
|
+
allowOverlap: false,
|
|
878
|
+
});
|
|
879
|
+
await pool.query(
|
|
880
|
+
`UPDATE cron_schedules SET next_run_at = NOW() - INTERVAL '1 minute' WHERE id = $1`,
|
|
881
|
+
[id],
|
|
882
|
+
);
|
|
883
|
+
|
|
884
|
+
// First enqueue should succeed
|
|
885
|
+
const count1 = await jobQueue.enqueueDueCronJobs();
|
|
886
|
+
expect(count1).toBe(1);
|
|
887
|
+
|
|
888
|
+
// Set nextRunAt to past again (simulating next tick)
|
|
889
|
+
await pool.query(
|
|
890
|
+
`UPDATE cron_schedules SET next_run_at = NOW() - INTERVAL '1 minute' WHERE id = $1`,
|
|
891
|
+
[id],
|
|
892
|
+
);
|
|
893
|
+
|
|
894
|
+
// Act — second enqueue should be skipped because previous job is still pending
|
|
895
|
+
const count2 = await jobQueue.enqueueDueCronJobs();
|
|
896
|
+
|
|
897
|
+
// Assert
|
|
898
|
+
expect(count2).toBe(0);
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
it('enqueueDueCronJobs enqueues when allowOverlap=true even if last job is still active', async () => {
|
|
902
|
+
// Setup
|
|
903
|
+
const id = await jobQueue.addCronJob({
|
|
904
|
+
scheduleName: 'with-overlap',
|
|
905
|
+
cronExpression: '* * * * *',
|
|
906
|
+
jobType: 'email',
|
|
907
|
+
payload: { to: 'overlap@example.com' },
|
|
908
|
+
allowOverlap: true,
|
|
909
|
+
});
|
|
910
|
+
await pool.query(
|
|
911
|
+
`UPDATE cron_schedules SET next_run_at = NOW() - INTERVAL '1 minute' WHERE id = $1`,
|
|
912
|
+
[id],
|
|
913
|
+
);
|
|
914
|
+
|
|
915
|
+
// First enqueue
|
|
916
|
+
const count1 = await jobQueue.enqueueDueCronJobs();
|
|
917
|
+
expect(count1).toBe(1);
|
|
918
|
+
|
|
919
|
+
// Set nextRunAt to past again
|
|
920
|
+
await pool.query(
|
|
921
|
+
`UPDATE cron_schedules SET next_run_at = NOW() - INTERVAL '1 minute' WHERE id = $1`,
|
|
922
|
+
[id],
|
|
923
|
+
);
|
|
924
|
+
|
|
925
|
+
// Act — second enqueue should succeed because allowOverlap=true
|
|
926
|
+
const count2 = await jobQueue.enqueueDueCronJobs();
|
|
927
|
+
|
|
928
|
+
// Assert
|
|
929
|
+
expect(count2).toBe(1);
|
|
930
|
+
|
|
931
|
+
// Verify there are two pending jobs
|
|
932
|
+
const jobs = await jobQueue.getJobsByStatus('pending');
|
|
933
|
+
const cronJobs = jobs.filter(
|
|
934
|
+
(j) =>
|
|
935
|
+
j.jobType === 'email' &&
|
|
936
|
+
(j.payload as any).to === 'overlap@example.com',
|
|
937
|
+
);
|
|
938
|
+
expect(cronJobs).toHaveLength(2);
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
it('should propagate retry config from cron schedule to enqueued jobs', async () => {
|
|
942
|
+
const cronId = await jobQueue.addCronJob({
|
|
943
|
+
scheduleName: 'retry-cron',
|
|
944
|
+
cronExpression: '* * * * *',
|
|
945
|
+
jobType: 'email',
|
|
946
|
+
payload: { to: 'cron-retry@example.com' },
|
|
947
|
+
retryDelay: 15,
|
|
948
|
+
retryBackoff: false,
|
|
949
|
+
retryDelayMax: 90,
|
|
950
|
+
});
|
|
951
|
+
|
|
952
|
+
// Force next_run_at to the past
|
|
953
|
+
await pool.query(
|
|
954
|
+
`UPDATE cron_schedules SET next_run_at = NOW() - INTERVAL '1 minute' WHERE id = $1`,
|
|
955
|
+
[cronId],
|
|
956
|
+
);
|
|
957
|
+
|
|
958
|
+
const count = await jobQueue.enqueueDueCronJobs();
|
|
959
|
+
expect(count).toBe(1);
|
|
960
|
+
|
|
961
|
+
const jobs = await jobQueue.getJobsByStatus('pending');
|
|
962
|
+
const cronJob = jobs.find(
|
|
963
|
+
(j) => (j.payload as any).to === 'cron-retry@example.com',
|
|
964
|
+
);
|
|
965
|
+
expect(cronJob).toBeDefined();
|
|
966
|
+
expect(cronJob?.retryDelay).toBe(15);
|
|
967
|
+
expect(cronJob?.retryBackoff).toBe(false);
|
|
968
|
+
expect(cronJob?.retryDelayMax).toBe(90);
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
it('should store retry config on cron schedule record', async () => {
|
|
972
|
+
const cronId = await jobQueue.addCronJob({
|
|
973
|
+
scheduleName: 'retry-cron-record',
|
|
974
|
+
cronExpression: '0 */2 * * *',
|
|
975
|
+
jobType: 'email',
|
|
976
|
+
payload: { to: 'cron-record@example.com' },
|
|
977
|
+
retryDelay: 30,
|
|
978
|
+
retryBackoff: true,
|
|
979
|
+
retryDelayMax: 600,
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
const schedule = await jobQueue.getCronJob(cronId);
|
|
983
|
+
expect(schedule?.retryDelay).toBe(30);
|
|
984
|
+
expect(schedule?.retryBackoff).toBe(true);
|
|
985
|
+
expect(schedule?.retryDelayMax).toBe(600);
|
|
986
|
+
});
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
// ── BYOC (Bring Your Own Connection) tests ──────────────────────────────
|
|
990
|
+
|
|
991
|
+
describe('BYOC: init with external pool', () => {
|
|
992
|
+
let pool: Pool;
|
|
993
|
+
let dbName: string;
|
|
994
|
+
let jobQueue: ReturnType<typeof initJobQueue<TestPayloadMap>>;
|
|
995
|
+
|
|
996
|
+
beforeEach(async () => {
|
|
997
|
+
const setup = await createTestDbAndPool();
|
|
998
|
+
pool = setup.pool;
|
|
999
|
+
dbName = setup.dbName;
|
|
1000
|
+
jobQueue = initJobQueue<TestPayloadMap>({ pool });
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
afterEach(async () => {
|
|
1004
|
+
await pool.end();
|
|
1005
|
+
await destroyTestDb(dbName);
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
it('uses the provided pool for addJob and getJob', async () => {
|
|
1009
|
+
// Act
|
|
1010
|
+
const jobId = await jobQueue.addJob({
|
|
1011
|
+
jobType: 'email',
|
|
1012
|
+
payload: { to: 'byoc@example.com' },
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
// Assert
|
|
1016
|
+
const job = await jobQueue.getJob(jobId);
|
|
1017
|
+
expect(job).not.toBeNull();
|
|
1018
|
+
expect(job?.jobType).toBe('email');
|
|
1019
|
+
expect(job?.payload).toEqual({ to: 'byoc@example.com' });
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
it('returns the same pool instance from getPool()', () => {
|
|
1023
|
+
// Act
|
|
1024
|
+
const returnedPool = jobQueue.getPool();
|
|
1025
|
+
|
|
1026
|
+
// Assert
|
|
1027
|
+
expect(returnedPool).toBe(pool);
|
|
1028
|
+
});
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
describe('BYOC: transactional addJob with db option', () => {
|
|
1032
|
+
let pool: Pool;
|
|
1033
|
+
let dbName: string;
|
|
1034
|
+
let testDbUrl: string;
|
|
1035
|
+
let jobQueue: ReturnType<typeof initJobQueue<TestPayloadMap>>;
|
|
1036
|
+
|
|
1037
|
+
beforeEach(async () => {
|
|
1038
|
+
const setup = await createTestDbAndPool();
|
|
1039
|
+
pool = setup.pool;
|
|
1040
|
+
dbName = setup.dbName;
|
|
1041
|
+
testDbUrl = setup.testDbUrl;
|
|
1042
|
+
jobQueue = initJobQueue<TestPayloadMap>({
|
|
1043
|
+
databaseConfig: { connectionString: testDbUrl },
|
|
1044
|
+
});
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
afterEach(async () => {
|
|
1048
|
+
jobQueue.getPool().end();
|
|
1049
|
+
await pool.end();
|
|
1050
|
+
await destroyTestDb(dbName);
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
it('rolls back the job when the transaction is rolled back', async () => {
|
|
1054
|
+
// Setup
|
|
1055
|
+
const client = await pool.connect();
|
|
1056
|
+
await client.query('BEGIN');
|
|
1057
|
+
|
|
1058
|
+
// Act
|
|
1059
|
+
const jobId = await jobQueue.addJob(
|
|
1060
|
+
{ jobType: 'email', payload: { to: 'rollback@example.com' } },
|
|
1061
|
+
{ db: client },
|
|
1062
|
+
);
|
|
1063
|
+
await client.query('ROLLBACK');
|
|
1064
|
+
client.release();
|
|
1065
|
+
|
|
1066
|
+
// Assert — job should not exist after rollback
|
|
1067
|
+
const job = await jobQueue.getJob(jobId);
|
|
1068
|
+
expect(job).toBeNull();
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
it('persists the job and event when the transaction is committed', async () => {
|
|
1072
|
+
// Setup
|
|
1073
|
+
const client = await pool.connect();
|
|
1074
|
+
await client.query('BEGIN');
|
|
1075
|
+
|
|
1076
|
+
// Act
|
|
1077
|
+
const jobId = await jobQueue.addJob(
|
|
1078
|
+
{ jobType: 'email', payload: { to: 'commit@example.com' } },
|
|
1079
|
+
{ db: client },
|
|
1080
|
+
);
|
|
1081
|
+
await client.query('COMMIT');
|
|
1082
|
+
client.release();
|
|
1083
|
+
|
|
1084
|
+
// Assert — job exists
|
|
1085
|
+
const job = await jobQueue.getJob(jobId);
|
|
1086
|
+
expect(job).not.toBeNull();
|
|
1087
|
+
expect(job?.payload).toEqual({ to: 'commit@example.com' });
|
|
1088
|
+
|
|
1089
|
+
// Assert — event was recorded in the same transaction
|
|
1090
|
+
const events = await jobQueue.getJobEvents(jobId);
|
|
1091
|
+
expect(events.length).toBeGreaterThanOrEqual(1);
|
|
1092
|
+
expect(events[0].eventType).toBe('added');
|
|
1093
|
+
});
|
|
1094
|
+
|
|
1095
|
+
it('job is visible within the transaction before commit', async () => {
|
|
1096
|
+
// Setup
|
|
1097
|
+
const client = await pool.connect();
|
|
1098
|
+
await client.query('BEGIN');
|
|
1099
|
+
|
|
1100
|
+
// Act
|
|
1101
|
+
const jobId = await jobQueue.addJob(
|
|
1102
|
+
{ jobType: 'sms', payload: { to: 'in-tx@example.com' } },
|
|
1103
|
+
{ db: client },
|
|
1104
|
+
);
|
|
1105
|
+
|
|
1106
|
+
// Assert — visible within the transaction
|
|
1107
|
+
const res = await client.query('SELECT id FROM job_queue WHERE id = $1', [
|
|
1108
|
+
jobId,
|
|
1109
|
+
]);
|
|
1110
|
+
expect(res.rows).toHaveLength(1);
|
|
1111
|
+
|
|
1112
|
+
await client.query('ROLLBACK');
|
|
1113
|
+
client.release();
|
|
1114
|
+
});
|
|
1115
|
+
});
|
|
1116
|
+
|
|
1117
|
+
describe('addJobs batch insert', () => {
|
|
1118
|
+
let pool: Pool;
|
|
1119
|
+
let dbName: string;
|
|
1120
|
+
let testDbUrl: string;
|
|
1121
|
+
let jobQueue: ReturnType<typeof initJobQueue<TestPayloadMap>>;
|
|
1122
|
+
|
|
1123
|
+
beforeEach(async () => {
|
|
1124
|
+
const setup = await createTestDbAndPool();
|
|
1125
|
+
pool = setup.pool;
|
|
1126
|
+
dbName = setup.dbName;
|
|
1127
|
+
testDbUrl = setup.testDbUrl;
|
|
1128
|
+
const config: JobQueueConfig = {
|
|
1129
|
+
databaseConfig: {
|
|
1130
|
+
connectionString: testDbUrl,
|
|
1131
|
+
},
|
|
1132
|
+
};
|
|
1133
|
+
jobQueue = initJobQueue<TestPayloadMap>(config);
|
|
1134
|
+
});
|
|
1135
|
+
|
|
1136
|
+
afterEach(async () => {
|
|
1137
|
+
jobQueue.getPool().end();
|
|
1138
|
+
await pool.end();
|
|
1139
|
+
await destroyTestDb(dbName);
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
it('inserts multiple jobs and returns IDs in order', async () => {
|
|
1143
|
+
// Act
|
|
1144
|
+
const ids = await jobQueue.addJobs([
|
|
1145
|
+
{ jobType: 'email', payload: { to: 'a@test.com' } },
|
|
1146
|
+
{ jobType: 'sms', payload: { to: '+1234' } },
|
|
1147
|
+
{ jobType: 'email', payload: { to: 'b@test.com' } },
|
|
1148
|
+
]);
|
|
1149
|
+
|
|
1150
|
+
// Assert
|
|
1151
|
+
expect(ids).toHaveLength(3);
|
|
1152
|
+
|
|
1153
|
+
const job0 = await jobQueue.getJob(ids[0]);
|
|
1154
|
+
expect(job0?.jobType).toBe('email');
|
|
1155
|
+
expect(job0?.payload).toEqual({ to: 'a@test.com' });
|
|
1156
|
+
|
|
1157
|
+
const job1 = await jobQueue.getJob(ids[1]);
|
|
1158
|
+
expect(job1?.jobType).toBe('sms');
|
|
1159
|
+
|
|
1160
|
+
const job2 = await jobQueue.getJob(ids[2]);
|
|
1161
|
+
expect(job2?.jobType).toBe('email');
|
|
1162
|
+
expect(job2?.payload).toEqual({ to: 'b@test.com' });
|
|
1163
|
+
});
|
|
1164
|
+
|
|
1165
|
+
it('returns empty array for empty input', async () => {
|
|
1166
|
+
// Act
|
|
1167
|
+
const ids = await jobQueue.addJobs([]);
|
|
1168
|
+
|
|
1169
|
+
// Assert
|
|
1170
|
+
expect(ids).toEqual([]);
|
|
1171
|
+
});
|
|
1172
|
+
|
|
1173
|
+
it('handles idempotency keys correctly', async () => {
|
|
1174
|
+
// Setup
|
|
1175
|
+
const existingId = await jobQueue.addJob({
|
|
1176
|
+
jobType: 'email',
|
|
1177
|
+
payload: { to: 'existing@test.com' },
|
|
1178
|
+
idempotencyKey: 'batch-dup',
|
|
1179
|
+
});
|
|
1180
|
+
|
|
1181
|
+
// Act
|
|
1182
|
+
const ids = await jobQueue.addJobs([
|
|
1183
|
+
{ jobType: 'email', payload: { to: 'new@test.com' } },
|
|
1184
|
+
{
|
|
1185
|
+
jobType: 'email',
|
|
1186
|
+
payload: { to: 'dup@test.com' },
|
|
1187
|
+
idempotencyKey: 'batch-dup',
|
|
1188
|
+
},
|
|
1189
|
+
]);
|
|
1190
|
+
|
|
1191
|
+
// Assert
|
|
1192
|
+
expect(ids).toHaveLength(2);
|
|
1193
|
+
expect(ids[1]).toBe(existingId);
|
|
1194
|
+
expect(ids[0]).not.toBe(existingId);
|
|
1195
|
+
});
|
|
1196
|
+
});
|
|
1197
|
+
|
|
1198
|
+
describe('BYOC: validation errors', () => {
|
|
1199
|
+
it('throws when neither databaseConfig nor pool is provided for postgres', () => {
|
|
1200
|
+
// Act & Assert
|
|
1201
|
+
expect(() =>
|
|
1202
|
+
initJobQueue<TestPayloadMap>({ backend: 'postgres' } as any),
|
|
1203
|
+
).toThrow(
|
|
1204
|
+
'PostgreSQL backend requires either "databaseConfig" or "pool" to be provided.',
|
|
1205
|
+
);
|
|
1206
|
+
});
|
|
1207
|
+
|
|
1208
|
+
it('throws when neither redisConfig nor client is provided for redis', () => {
|
|
1209
|
+
// Act & Assert
|
|
1210
|
+
expect(() =>
|
|
1211
|
+
initJobQueue<TestPayloadMap>({ backend: 'redis' } as any),
|
|
1212
|
+
).toThrow(
|
|
1213
|
+
'Redis backend requires either "redisConfig" or "client" to be provided.',
|
|
1214
|
+
);
|
|
1215
|
+
});
|
|
534
1216
|
});
|