@nicnocquee/dataqueue 1.19.2 → 1.20.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/src/queue.test.ts CHANGED
@@ -5,8 +5,6 @@ import { createTestDbAndPool, destroyTestDb } from './test-util.js';
5
5
  import { JobEvent, JobEventType } from './types.js';
6
6
  import { objectKeysToCamelCase } from './utils.js';
7
7
 
8
- // Example integration test setup
9
-
10
8
  describe('queue integration', () => {
11
9
  let pool: Pool;
12
10
  let dbName: string;
@@ -501,3 +499,252 @@ describe('job lifecycle timestamp columns', () => {
501
499
  expect(job.lastRetriedAt).not.toBeNull();
502
500
  });
503
501
  });
502
+
503
+ describe('tags feature', () => {
504
+ let pool: Pool;
505
+ let dbName: string;
506
+
507
+ beforeEach(async () => {
508
+ const setup = await createTestDbAndPool();
509
+ pool = setup.pool;
510
+ dbName = setup.dbName;
511
+ });
512
+
513
+ afterEach(async () => {
514
+ await pool.end();
515
+ await destroyTestDb(dbName);
516
+ });
517
+
518
+ it('should add a job with tags and retrieve it by tags (all mode)', async () => {
519
+ const jobId = await queue.addJob<{ email: { to: string } }, 'email'>(pool, {
520
+ jobType: 'email',
521
+ payload: { to: 'tagged@example.com' },
522
+ tags: ['welcome', 'user:1'],
523
+ });
524
+ const jobs = await queue.getJobsByTags(pool, ['welcome'], 'all');
525
+ expect(jobs.map((j) => j.id)).toContain(jobId);
526
+ expect(jobs[0].tags).toContain('welcome');
527
+ expect(jobs[0].tags).toContain('user:1');
528
+ });
529
+
530
+ it('should only return jobs that match all specified tags (all mode)', async () => {
531
+ const jobId1 = await queue.addJob<{ email: { to: string } }, 'email'>(
532
+ pool,
533
+ {
534
+ jobType: 'email',
535
+ payload: { to: 'a@example.com' },
536
+ tags: ['foo', 'bar'],
537
+ },
538
+ );
539
+ const jobId2 = await queue.addJob<{ email: { to: string } }, 'email'>(
540
+ pool,
541
+ {
542
+ jobType: 'email',
543
+ payload: { to: 'b@example.com' },
544
+ tags: ['foo'],
545
+ },
546
+ );
547
+
548
+ const jobId3 = await queue.addJob<{ email: { to: string } }, 'email'>(
549
+ pool,
550
+ {
551
+ jobType: 'email',
552
+ payload: { to: 'c@example.com' },
553
+ tags: ['foo', 'bar', 'baz'],
554
+ },
555
+ );
556
+ const jobs = await queue.getJobsByTags(pool, ['foo', 'bar'], 'all');
557
+ expect(jobs.map((j) => j.id)).toContain(jobId1);
558
+ expect(jobs.map((j) => j.id)).not.toContain(jobId2);
559
+ expect(jobs.map((j) => j.id)).toContain(jobId3);
560
+ });
561
+
562
+ it('should return jobs with exactly the same tags (exact mode)', async () => {
563
+ const jobId1 = await queue.addJob<{ email: { to: string } }, 'email'>(
564
+ pool,
565
+ {
566
+ jobType: 'email',
567
+ payload: { to: 'a@example.com' },
568
+ tags: ['foo', 'bar'],
569
+ },
570
+ );
571
+ const jobId2 = await queue.addJob<{ email: { to: string } }, 'email'>(
572
+ pool,
573
+ {
574
+ jobType: 'email',
575
+ payload: { to: 'b@example.com' },
576
+ tags: ['foo', 'bar', 'baz'],
577
+ },
578
+ );
579
+ const jobs = await queue.getJobsByTags(pool, ['foo', 'bar'], 'exact');
580
+ expect(jobs.map((j) => j.id)).toContain(jobId1);
581
+ expect(jobs.map((j) => j.id)).not.toContain(jobId2);
582
+ });
583
+
584
+ it('should return jobs that have any of the given tags (any mode)', async () => {
585
+ const jobId1 = await queue.addJob<{ email: { to: string } }, 'email'>(
586
+ pool,
587
+ {
588
+ jobType: 'email',
589
+ payload: { to: 'a@example.com' },
590
+ tags: ['foo', 'bar'],
591
+ },
592
+ );
593
+ const jobId2 = await queue.addJob<{ email: { to: string } }, 'email'>(
594
+ pool,
595
+ {
596
+ jobType: 'email',
597
+ payload: { to: 'b@example.com' },
598
+ tags: ['baz'],
599
+ },
600
+ );
601
+ const jobs = await queue.getJobsByTags(pool, ['bar', 'baz'], 'any');
602
+ expect(jobs.map((j) => j.id)).toContain(jobId1);
603
+ expect(jobs.map((j) => j.id)).toContain(jobId2);
604
+ });
605
+
606
+ it('should return jobs that have none of the given tags (none mode)', async () => {
607
+ const jobId1 = await queue.addJob<{ email: { to: string } }, 'email'>(
608
+ pool,
609
+ {
610
+ jobType: 'email',
611
+ payload: { to: 'a@example.com' },
612
+ tags: ['foo'],
613
+ },
614
+ );
615
+ const jobId2 = await queue.addJob<{ email: { to: string } }, 'email'>(
616
+ pool,
617
+ {
618
+ jobType: 'email',
619
+ payload: { to: 'b@example.com' },
620
+ tags: ['bar'],
621
+ },
622
+ );
623
+ const jobId3 = await queue.addJob<{ email: { to: string } }, 'email'>(
624
+ pool,
625
+ {
626
+ jobType: 'email',
627
+ payload: { to: 'c@example.com' },
628
+ tags: ['baz'],
629
+ },
630
+ );
631
+ const jobs = await queue.getJobsByTags(pool, ['foo', 'bar'], 'none');
632
+ expect(jobs.map((j) => j.id)).toContain(jobId3);
633
+ expect(jobs.map((j) => j.id)).not.toContain(jobId1);
634
+ expect(jobs.map((j) => j.id)).not.toContain(jobId2);
635
+ });
636
+
637
+ it('should handle jobs with no tags', async () => {
638
+ const jobId = await queue.addJob<{ email: { to: string } }, 'email'>(pool, {
639
+ jobType: 'email',
640
+ payload: { to: 'notag@example.com' },
641
+ });
642
+ const jobs = await queue.getJobsByTags(pool, ['anytag'], 'all');
643
+ expect(jobs.map((j) => j.id)).not.toContain(jobId);
644
+ });
645
+
646
+ it('should cancel jobs by tags (all mode)', async () => {
647
+ const jobId = await queue.addJob<{ email: { to: string } }, 'email'>(pool, {
648
+ jobType: 'email',
649
+ payload: { to: 'cancelme@example.com' },
650
+ tags: ['cancel', 'urgent'],
651
+ });
652
+ const cancelled = await queue.cancelAllUpcomingJobs(pool, {
653
+ tags: { values: ['cancel', 'urgent'], mode: 'all' },
654
+ });
655
+ expect(cancelled).toBeGreaterThanOrEqual(1);
656
+ const job = await queue.getJob(pool, jobId);
657
+ expect(job?.status).toBe('cancelled');
658
+ });
659
+
660
+ it('should cancel jobs by tags (exact mode)', async () => {
661
+ const jobId1 = await queue.addJob<{ email: { to: string } }, 'email'>(
662
+ pool,
663
+ {
664
+ jobType: 'email',
665
+ payload: { to: 'cancel1@example.com' },
666
+ tags: ['cancel', 'urgent'],
667
+ },
668
+ );
669
+ const jobId2 = await queue.addJob<{ email: { to: string } }, 'email'>(
670
+ pool,
671
+ {
672
+ jobType: 'email',
673
+ payload: { to: 'cancel2@example.com' },
674
+ tags: ['cancel', 'urgent', 'other'],
675
+ },
676
+ );
677
+ const cancelled = await queue.cancelAllUpcomingJobs(pool, {
678
+ tags: { values: ['cancel', 'urgent'], mode: 'exact' },
679
+ });
680
+ expect(cancelled).toBe(1);
681
+ const job1 = await queue.getJob(pool, jobId1);
682
+ const job2 = await queue.getJob(pool, jobId2);
683
+ expect(job1?.status).toBe('cancelled');
684
+ expect(job2?.status).toBe('pending');
685
+ });
686
+
687
+ it('should cancel jobs by tags (any mode)', async () => {
688
+ const jobId1 = await queue.addJob<{ email: { to: string } }, 'email'>(
689
+ pool,
690
+ {
691
+ jobType: 'email',
692
+ payload: { to: 'cancel1@example.com' },
693
+ tags: ['cancel', 'urgent'],
694
+ },
695
+ );
696
+ const jobId2 = await queue.addJob<{ email: { to: string } }, 'email'>(
697
+ pool,
698
+ {
699
+ jobType: 'email',
700
+ payload: { to: 'cancel2@example.com' },
701
+ tags: ['other'],
702
+ },
703
+ );
704
+ const cancelled = await queue.cancelAllUpcomingJobs(pool, {
705
+ tags: { values: ['cancel', 'other'], mode: 'any' },
706
+ });
707
+ expect(cancelled).toBe(2);
708
+ const job1 = await queue.getJob(pool, jobId1);
709
+ const job2 = await queue.getJob(pool, jobId2);
710
+ expect(job1?.status).toBe('cancelled');
711
+ expect(job2?.status).toBe('cancelled');
712
+ });
713
+
714
+ it('should cancel jobs by tags (none mode)', async () => {
715
+ const jobId1 = await queue.addJob<{ email: { to: string } }, 'email'>(
716
+ pool,
717
+ {
718
+ jobType: 'email',
719
+ payload: { to: 'cancel1@example.com' },
720
+ tags: ['foo'],
721
+ },
722
+ );
723
+ const jobId2 = await queue.addJob<{ email: { to: string } }, 'email'>(
724
+ pool,
725
+ {
726
+ jobType: 'email',
727
+ payload: { to: 'cancel2@example.com' },
728
+ tags: ['bar'],
729
+ },
730
+ );
731
+ const jobId3 = await queue.addJob<{ email: { to: string } }, 'email'>(
732
+ pool,
733
+ {
734
+ jobType: 'email',
735
+ payload: { to: 'keep@example.com' },
736
+ tags: ['baz'],
737
+ },
738
+ );
739
+ const cancelled = await queue.cancelAllUpcomingJobs(pool, {
740
+ tags: { values: ['foo', 'bar'], mode: 'none' },
741
+ });
742
+ expect(cancelled).toBe(1);
743
+ const job1 = await queue.getJob(pool, jobId1);
744
+ const job2 = await queue.getJob(pool, jobId2);
745
+ const job3 = await queue.getJob(pool, jobId3);
746
+ expect(job1?.status).toBe('pending');
747
+ expect(job2?.status).toBe('pending');
748
+ expect(job3?.status).toBe('cancelled');
749
+ });
750
+ });
package/src/queue.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  FailureReason,
6
6
  JobEvent,
7
7
  JobEventType,
8
+ TagQueryMode,
8
9
  } from './types.js';
9
10
  import { log } from './log-context.js';
10
11
 
@@ -43,6 +44,7 @@ export const addJob = async <PayloadMap, T extends keyof PayloadMap & string>(
43
44
  priority = 0,
44
45
  runAt = null,
45
46
  timeoutMs = undefined,
47
+ tags = undefined,
46
48
  }: JobOptions<PayloadMap, T>,
47
49
  ): Promise<number> => {
48
50
  const client = await pool.connect();
@@ -51,29 +53,45 @@ export const addJob = async <PayloadMap, T extends keyof PayloadMap & string>(
51
53
  if (runAt) {
52
54
  result = await client.query(
53
55
  `INSERT INTO job_queue
54
- (job_type, payload, max_attempts, priority, run_at, timeout_ms)
55
- VALUES ($1, $2, $3, $4, $5, $6)
56
+ (job_type, payload, max_attempts, priority, run_at, timeout_ms, tags)
57
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
56
58
  RETURNING id`,
57
- [jobType, payload, maxAttempts, priority, runAt, timeoutMs ?? null],
59
+ [
60
+ jobType,
61
+ payload,
62
+ maxAttempts,
63
+ priority,
64
+ runAt,
65
+ timeoutMs ?? null,
66
+ tags ?? null,
67
+ ],
58
68
  );
59
69
  log(
60
- `Added job ${result.rows[0].id}: payload ${JSON.stringify(payload)}, runAt ${runAt.toISOString()}, priority ${priority}, maxAttempts ${maxAttempts} jobType ${jobType}`,
70
+ `Added job ${result.rows[0].id}: payload ${JSON.stringify(payload)}, runAt ${runAt.toISOString()}, priority ${priority}, maxAttempts ${maxAttempts} jobType ${jobType}, tags ${JSON.stringify(tags)}`,
61
71
  );
62
72
  } else {
63
73
  result = await client.query(
64
74
  `INSERT INTO job_queue
65
- (job_type, payload, max_attempts, priority, timeout_ms)
66
- VALUES ($1, $2, $3, $4, $5)
75
+ (job_type, payload, max_attempts, priority, timeout_ms, tags)
76
+ VALUES ($1, $2, $3, $4, $5, $6)
67
77
  RETURNING id`,
68
- [jobType, payload, maxAttempts, priority, timeoutMs ?? null],
78
+ [
79
+ jobType,
80
+ payload,
81
+ maxAttempts,
82
+ priority,
83
+ timeoutMs ?? null,
84
+ tags ?? null,
85
+ ],
69
86
  );
70
87
  log(
71
- `Added job ${result.rows[0].id}: payload ${JSON.stringify(payload)}, priority ${priority}, maxAttempts ${maxAttempts} jobType ${jobType}`,
88
+ `Added job ${result.rows[0].id}: payload ${JSON.stringify(payload)}, priority ${priority}, maxAttempts ${maxAttempts} jobType ${jobType}, tags ${JSON.stringify(tags)}`,
72
89
  );
73
90
  }
74
91
  await recordJobEvent(pool, result.rows[0].id, JobEventType.Added, {
75
92
  jobType,
76
93
  payload,
94
+ tags,
77
95
  });
78
96
  return result.rows[0].id;
79
97
  } catch (error) {
@@ -400,7 +418,12 @@ export const cancelJob = async (pool: Pool, jobId: number): Promise<void> => {
400
418
  */
401
419
  export const cancelAllUpcomingJobs = async (
402
420
  pool: Pool,
403
- filters?: { jobType?: string; priority?: number; runAt?: Date },
421
+ filters?: {
422
+ jobType?: string;
423
+ priority?: number;
424
+ runAt?: Date;
425
+ tags?: { values: string[]; mode?: TagQueryMode };
426
+ },
404
427
  ): Promise<number> => {
405
428
  const client = await pool.connect();
406
429
  try {
@@ -423,6 +446,35 @@ export const cancelAllUpcomingJobs = async (
423
446
  query += ` AND run_at = $${paramIdx++}`;
424
447
  params.push(filters.runAt);
425
448
  }
449
+ if (
450
+ filters.tags &&
451
+ filters.tags.values &&
452
+ filters.tags.values.length > 0
453
+ ) {
454
+ const mode = filters.tags.mode || 'all';
455
+ const tagValues = filters.tags.values;
456
+ switch (mode) {
457
+ case 'exact':
458
+ query += ` AND tags = $${paramIdx++}`;
459
+ params.push(tagValues);
460
+ break;
461
+ case 'all':
462
+ query += ` AND tags @> $${paramIdx++}`;
463
+ params.push(tagValues);
464
+ break;
465
+ case 'any':
466
+ query += ` AND tags && $${paramIdx++}`;
467
+ params.push(tagValues);
468
+ break;
469
+ case 'none':
470
+ query += ` AND NOT (tags && $${paramIdx++})`;
471
+ params.push(tagValues);
472
+ break;
473
+ default:
474
+ query += ` AND tags @> $${paramIdx++}`;
475
+ params.push(tagValues);
476
+ }
477
+ }
426
478
  }
427
479
  query += '\nRETURNING id';
428
480
  const result = await client.query(query, params);
@@ -548,3 +600,64 @@ export const getJobEvents = async (
548
600
  client.release();
549
601
  }
550
602
  };
603
+
604
+ /**
605
+ * Get jobs by tags (matches all specified tags)
606
+ */
607
+ export const getJobsByTags = async <
608
+ PayloadMap,
609
+ T extends keyof PayloadMap & string,
610
+ >(
611
+ pool: Pool,
612
+ tags: string[],
613
+ mode: TagQueryMode = 'all',
614
+ limit = 100,
615
+ offset = 0,
616
+ ): Promise<JobRecord<PayloadMap, T>[]> => {
617
+ const client = await pool.connect();
618
+ try {
619
+ let query = `SELECT id, job_type AS "jobType", payload, status, max_attempts AS "maxAttempts", attempts, priority, run_at AS "runAt", timeout_ms AS "timeoutMs", created_at AS "createdAt", updated_at AS "updatedAt", started_at AS "startedAt", completed_at AS "completedAt", last_failed_at AS "lastFailedAt", locked_at AS "lockedAt", locked_by AS "lockedBy", error_history AS "errorHistory", failure_reason AS "failureReason", next_attempt_at AS "nextAttemptAt", last_failed_at AS "lastFailedAt", last_retried_at AS "lastRetriedAt", last_cancelled_at AS "lastCancelledAt", pending_reason AS "pendingReason", tags
620
+ FROM job_queue`;
621
+ let params: any[] = [];
622
+ switch (mode) {
623
+ case 'exact':
624
+ query += ' WHERE tags = $1';
625
+ params = [tags];
626
+ break;
627
+ case 'all':
628
+ query += ' WHERE tags @> $1';
629
+ params = [tags];
630
+ break;
631
+ case 'any':
632
+ query += ' WHERE tags && $1';
633
+ params = [tags];
634
+ break;
635
+ case 'none':
636
+ query += ' WHERE NOT (tags && $1)';
637
+ params = [tags];
638
+ break;
639
+ default:
640
+ query += ' WHERE tags @> $1';
641
+ params = [tags];
642
+ }
643
+ query += ' ORDER BY created_at DESC LIMIT $2 OFFSET $3';
644
+ params.push(limit, offset);
645
+ const result = await client.query(query, params);
646
+ log(
647
+ `Found ${result.rows.length} jobs by tags ${JSON.stringify(tags)} (mode: ${mode})`,
648
+ );
649
+ return result.rows.map((job) => ({
650
+ ...job,
651
+ payload: job.payload,
652
+ timeoutMs: job.timeoutMs,
653
+ failureReason: job.failureReason,
654
+ }));
655
+ } catch (error) {
656
+ log(
657
+ `Error getting jobs by tags ${JSON.stringify(tags)} (mode: ${mode}): ${error}`,
658
+ );
659
+ throw error;
660
+ } finally {
661
+ client.release();
662
+ }
663
+ };
package/src/test-util.ts CHANGED
@@ -1,8 +1,12 @@
1
1
  import { Pool } from 'pg';
2
2
  import { randomUUID } from 'crypto';
3
- import { join } from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ import { join, dirname } from 'path';
4
5
  import { runner } from 'node-pg-migrate';
5
6
 
7
+ const __filename = fileURLToPath(import.meta.url);
8
+ const __dirname = dirname(__filename);
9
+
6
10
  export async function createTestDbAndPool() {
7
11
  const baseDatabaseUrl =
8
12
  process.env.PG_TEST_URL ||
package/src/types.ts CHANGED
@@ -13,6 +13,10 @@ export interface JobOptions<PayloadMap, T extends JobType<PayloadMap>> {
13
13
  * Timeout for this job in milliseconds. If not set, uses the processor default or unlimited.
14
14
  */
15
15
  timeoutMs?: number;
16
+ /**
17
+ * Tags for this job. Used for grouping, searching, or batch operations.
18
+ */
19
+ tags?: string[];
16
20
  }
17
21
 
18
22
  export enum JobEventType {
@@ -89,6 +93,10 @@ export interface JobRecord<PayloadMap, T extends JobType<PayloadMap>> {
89
93
  * The time the job was last cancelled.
90
94
  */
91
95
  lastCancelledAt: Date | null;
96
+ /**
97
+ * Tags for this job. Used for grouping, searching, or batch operations.
98
+ */
99
+ tags?: string[];
92
100
  }
93
101
 
94
102
  export type JobHandler<PayloadMap, T extends keyof PayloadMap> = (
@@ -169,6 +177,8 @@ export interface JobQueueConfig {
169
177
  verbose?: boolean;
170
178
  }
171
179
 
180
+ export type TagQueryMode = 'exact' | 'all' | 'any' | 'none';
181
+
172
182
  export interface JobQueue<PayloadMap> {
173
183
  /**
174
184
  * Add a job to the job queue.
@@ -193,6 +203,21 @@ export interface JobQueue<PayloadMap> {
193
203
  limit?: number,
194
204
  offset?: number,
195
205
  ) => Promise<JobRecord<PayloadMap, T>[]>;
206
+ /**
207
+ * Get jobs by tag(s).
208
+ * - Modes:
209
+ * - 'exact': Jobs with exactly the same tags (no more, no less)
210
+ * - 'all': Jobs that have all the given tags (can have more)
211
+ * - 'any': Jobs that have at least one of the given tags
212
+ * - 'none': Jobs that have none of the given tags
213
+ * - Default mode is 'all'.
214
+ */
215
+ getJobsByTags: <T extends JobType<PayloadMap>>(
216
+ tags: string[],
217
+ mode?: TagQueryMode,
218
+ limit?: number,
219
+ offset?: number,
220
+ ) => Promise<JobRecord<PayloadMap, T>[]>;
196
221
  /**
197
222
  * Get all jobs.
198
223
  */
@@ -229,11 +254,13 @@ export interface JobQueue<PayloadMap> {
229
254
  * - jobType: The job type to cancel.
230
255
  * - priority: The priority of the job to cancel.
231
256
  * - runAt: The time the job is scheduled to run at.
257
+ * - tags: An object with 'values' (string[]) and 'mode' (TagQueryMode) for tag-based cancellation.
232
258
  */
233
259
  cancelAllUpcomingJobs: (filters?: {
234
260
  jobType?: string;
235
261
  priority?: number;
236
262
  runAt?: Date;
263
+ tags?: { values: string[]; mode?: TagQueryMode };
237
264
  }) => Promise<number>;
238
265
  /**
239
266
  * Create a job processor. Handlers must be provided per-processor.