@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.
Files changed (59) hide show
  1. package/ai/build-docs-content.ts +96 -0
  2. package/ai/build-llms-full.ts +42 -0
  3. package/ai/docs-content.json +278 -0
  4. package/ai/rules/advanced.md +132 -0
  5. package/ai/rules/basic.md +159 -0
  6. package/ai/rules/react-dashboard.md +83 -0
  7. package/ai/skills/dataqueue-advanced/SKILL.md +320 -0
  8. package/ai/skills/dataqueue-core/SKILL.md +234 -0
  9. package/ai/skills/dataqueue-react/SKILL.md +189 -0
  10. package/dist/cli.cjs +1149 -14
  11. package/dist/cli.cjs.map +1 -1
  12. package/dist/cli.d.cts +66 -1
  13. package/dist/cli.d.ts +66 -1
  14. package/dist/cli.js +1146 -13
  15. package/dist/cli.js.map +1 -1
  16. package/dist/index.cjs +3157 -1237
  17. package/dist/index.cjs.map +1 -1
  18. package/dist/index.d.cts +613 -23
  19. package/dist/index.d.ts +613 -23
  20. package/dist/index.js +3156 -1238
  21. package/dist/index.js.map +1 -1
  22. package/dist/mcp-server.cjs +186 -0
  23. package/dist/mcp-server.cjs.map +1 -0
  24. package/dist/mcp-server.d.cts +32 -0
  25. package/dist/mcp-server.d.ts +32 -0
  26. package/dist/mcp-server.js +175 -0
  27. package/dist/mcp-server.js.map +1 -0
  28. package/migrations/1781200000004_create_cron_schedules_table.sql +33 -0
  29. package/migrations/1781200000005_add_retry_config_to_job_queue.sql +17 -0
  30. package/package.json +24 -21
  31. package/src/backend.ts +170 -5
  32. package/src/backends/postgres.ts +992 -63
  33. package/src/backends/redis-scripts.ts +358 -26
  34. package/src/backends/redis.test.ts +1363 -0
  35. package/src/backends/redis.ts +993 -35
  36. package/src/cli.test.ts +82 -6
  37. package/src/cli.ts +73 -10
  38. package/src/cron.test.ts +126 -0
  39. package/src/cron.ts +40 -0
  40. package/src/db-util.ts +1 -1
  41. package/src/index.test.ts +682 -0
  42. package/src/index.ts +209 -34
  43. package/src/init-command.test.ts +449 -0
  44. package/src/init-command.ts +709 -0
  45. package/src/install-mcp-command.test.ts +216 -0
  46. package/src/install-mcp-command.ts +185 -0
  47. package/src/install-rules-command.test.ts +218 -0
  48. package/src/install-rules-command.ts +233 -0
  49. package/src/install-skills-command.test.ts +176 -0
  50. package/src/install-skills-command.ts +124 -0
  51. package/src/mcp-server.test.ts +162 -0
  52. package/src/mcp-server.ts +231 -0
  53. package/src/processor.ts +36 -97
  54. package/src/queue.test.ts +465 -0
  55. package/src/queue.ts +34 -252
  56. package/src/supervisor.test.ts +340 -0
  57. package/src/supervisor.ts +162 -0
  58. package/src/types.ts +388 -12
  59. 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
  });