@nicnocquee/dataqueue 1.22.0 → 1.25.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.
Files changed (34) hide show
  1. package/README.md +44 -0
  2. package/dist/index.cjs +2822 -583
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +589 -12
  5. package/dist/index.d.ts +589 -12
  6. package/dist/index.js +2818 -584
  7. package/dist/index.js.map +1 -1
  8. package/migrations/1751131910825_add_timeout_seconds_to_job_queue.sql +2 -2
  9. package/migrations/1751186053000_add_job_events_table.sql +12 -8
  10. package/migrations/1751984773000_add_tags_to_job_queue.sql +1 -1
  11. package/migrations/1765809419000_add_force_kill_on_timeout_to_job_queue.sql +6 -0
  12. package/migrations/1771100000000_add_idempotency_key_to_job_queue.sql +7 -0
  13. package/migrations/1781200000000_add_wait_support.sql +12 -0
  14. package/migrations/1781200000001_create_waitpoints_table.sql +18 -0
  15. package/migrations/1781200000002_add_performance_indexes.sql +34 -0
  16. package/migrations/1781200000003_add_progress_to_job_queue.sql +7 -0
  17. package/package.json +20 -6
  18. package/src/backend.ts +163 -0
  19. package/src/backends/postgres.ts +1111 -0
  20. package/src/backends/redis-scripts.ts +533 -0
  21. package/src/backends/redis.test.ts +543 -0
  22. package/src/backends/redis.ts +834 -0
  23. package/src/db-util.ts +4 -2
  24. package/src/handler-validation.test.ts +414 -0
  25. package/src/handler-validation.ts +168 -0
  26. package/src/index.test.ts +230 -1
  27. package/src/index.ts +128 -32
  28. package/src/processor.test.ts +612 -16
  29. package/src/processor.ts +759 -47
  30. package/src/queue.test.ts +736 -3
  31. package/src/queue.ts +346 -660
  32. package/src/test-util.ts +32 -0
  33. package/src/types.ts +451 -16
  34. package/src/wait.test.ts +698 -0
package/src/index.test.ts CHANGED
@@ -81,7 +81,11 @@ describe('index integration', () => {
81
81
  await new Promise((r) => setTimeout(r, 300));
82
82
  processor.stop();
83
83
  const job = await jobQueue.getJob(jobId);
84
- expect(handler).toHaveBeenCalledWith({ foo: 'bar' }, expect.any(Object));
84
+ expect(handler).toHaveBeenCalledWith(
85
+ { foo: 'bar' },
86
+ expect.any(Object),
87
+ expect.any(Object),
88
+ );
85
89
  expect(job?.status).toBe('completed');
86
90
  });
87
91
 
@@ -228,6 +232,87 @@ describe('index integration', () => {
228
232
  expect(job2?.status).toBe('cancelled');
229
233
  });
230
234
 
235
+ it('should edit all pending jobs via JobQueue API', async () => {
236
+ // Add three pending jobs
237
+ const jobId1 = await jobQueue.addJob({
238
+ jobType: 'email',
239
+ payload: { to: 'batch1@example.com' },
240
+ priority: 0,
241
+ });
242
+ const jobId2 = await jobQueue.addJob({
243
+ jobType: 'email',
244
+ payload: { to: 'batch2@example.com' },
245
+ priority: 0,
246
+ });
247
+ const jobId3 = await jobQueue.addJob({
248
+ jobType: 'email',
249
+ payload: { to: 'batch3@example.com' },
250
+ priority: 0,
251
+ });
252
+ // Add a completed job
253
+ const jobId4 = await jobQueue.addJob({
254
+ jobType: 'email',
255
+ payload: { to: 'done@example.com' },
256
+ priority: 0,
257
+ });
258
+ await pool.query(
259
+ `UPDATE job_queue SET status = 'completed' WHERE id = $1`,
260
+ [jobId4],
261
+ );
262
+
263
+ // Edit all pending jobs
264
+ const editedCount = await jobQueue.editAllPendingJobs(undefined, {
265
+ priority: 10,
266
+ });
267
+ expect(editedCount).toBeGreaterThanOrEqual(3);
268
+
269
+ // Check that all pending jobs are updated
270
+ const job1 = await jobQueue.getJob(jobId1);
271
+ const job2 = await jobQueue.getJob(jobId2);
272
+ const job3 = await jobQueue.getJob(jobId3);
273
+ expect(job1?.priority).toBe(10);
274
+ expect(job2?.priority).toBe(10);
275
+ expect(job3?.priority).toBe(10);
276
+
277
+ // Completed job should remain unchanged
278
+ const completedJob = await jobQueue.getJob(jobId4);
279
+ expect(completedJob?.priority).toBe(0);
280
+ });
281
+
282
+ it('should edit pending jobs with filters via JobQueue API', async () => {
283
+ const emailJobId1 = await jobQueue.addJob({
284
+ jobType: 'email',
285
+ payload: { to: 'email1@example.com' },
286
+ priority: 0,
287
+ });
288
+ const emailJobId2 = await jobQueue.addJob({
289
+ jobType: 'email',
290
+ payload: { to: 'email2@example.com' },
291
+ priority: 0,
292
+ });
293
+ const smsJobId = await jobQueue.addJob({
294
+ jobType: 'sms',
295
+ payload: { to: 'sms@example.com' },
296
+ priority: 0,
297
+ });
298
+
299
+ // Edit only email jobs
300
+ const editedCount = await jobQueue.editAllPendingJobs(
301
+ { jobType: 'email' },
302
+ {
303
+ priority: 5,
304
+ },
305
+ );
306
+ expect(editedCount).toBeGreaterThanOrEqual(2);
307
+
308
+ const emailJob1 = await jobQueue.getJob(emailJobId1);
309
+ const emailJob2 = await jobQueue.getJob(emailJobId2);
310
+ const smsJob = await jobQueue.getJob(smsJobId);
311
+ expect(emailJob1?.priority).toBe(5);
312
+ expect(emailJob2?.priority).toBe(5);
313
+ expect(smsJob?.priority).toBe(0);
314
+ });
315
+
231
316
  it('should cancel all upcoming jobs by runAt', async () => {
232
317
  const runAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour in future
233
318
  const jobId1 = await jobQueue.addJob({
@@ -302,4 +387,148 @@ describe('index integration', () => {
302
387
  expect(job1?.status).toBe('cancelled');
303
388
  expect(job2?.status).toBe('pending');
304
389
  });
390
+
391
+ it('should edit a pending job via JobQueue API', async () => {
392
+ const jobId = await jobQueue.addJob({
393
+ jobType: 'email',
394
+ payload: { to: 'original@example.com' },
395
+ priority: 0,
396
+ maxAttempts: 3,
397
+ });
398
+
399
+ await jobQueue.editJob(jobId, {
400
+ payload: { to: 'updated@example.com' },
401
+ priority: 10,
402
+ maxAttempts: 5,
403
+ });
404
+
405
+ const job = await jobQueue.getJob(jobId);
406
+ expect(job?.payload).toEqual({ to: 'updated@example.com' });
407
+ expect(job?.priority).toBe(10);
408
+ expect(job?.maxAttempts).toBe(5);
409
+ expect(job?.status).toBe('pending');
410
+ });
411
+
412
+ it('should edit a job and then process it correctly', async () => {
413
+ const handler = vi.fn(async (payload: { foo: string }, _signal) => {
414
+ expect(payload.foo).toBe('updated@example.com');
415
+ });
416
+ const jobId = await jobQueue.addJob({
417
+ jobType: 'test',
418
+ payload: { foo: 'original@example.com' },
419
+ });
420
+
421
+ // Edit the job before processing
422
+ await jobQueue.editJob(jobId, {
423
+ payload: { foo: 'updated@example.com' },
424
+ });
425
+
426
+ const processor = jobQueue.createProcessor(
427
+ {
428
+ email: vi.fn(async () => {}),
429
+ sms: vi.fn(async () => {}),
430
+ test: handler,
431
+ },
432
+ { pollInterval: 100 },
433
+ );
434
+ processor.start();
435
+ await new Promise((r) => setTimeout(r, 300));
436
+ processor.stop();
437
+
438
+ expect(handler).toHaveBeenCalledWith(
439
+ { foo: 'updated@example.com' },
440
+ expect.any(Object),
441
+ expect.any(Object),
442
+ );
443
+ const job = await jobQueue.getJob(jobId);
444
+ expect(job?.status).toBe('completed');
445
+ });
446
+
447
+ it('should silently fail when editing non-pending jobs', async () => {
448
+ // Try to edit a completed job
449
+ const jobId1 = await jobQueue.addJob({
450
+ jobType: 'email',
451
+ payload: { to: 'original@example.com' },
452
+ });
453
+ const processor = jobQueue.createProcessor(
454
+ {
455
+ email: vi.fn(async () => {}),
456
+ sms: vi.fn(async () => {}),
457
+ test: vi.fn(async () => {}),
458
+ },
459
+ { pollInterval: 100 },
460
+ );
461
+ processor.start();
462
+ await new Promise((r) => setTimeout(r, 300));
463
+ processor.stop();
464
+
465
+ const originalJob = await jobQueue.getJob(jobId1);
466
+ expect(originalJob?.status).toBe('completed');
467
+
468
+ await jobQueue.editJob(jobId1, {
469
+ payload: { to: 'updated@example.com' },
470
+ });
471
+
472
+ const job = await jobQueue.getJob(jobId1);
473
+ expect(job?.status).toBe('completed');
474
+ expect(job?.payload).toEqual({ to: 'original@example.com' });
475
+
476
+ // Try to edit a processing job
477
+ // Use a handler that takes longer to ensure job stays in processing state
478
+ const slowHandler = vi.fn(async (payload: { to: string }, _signal) => {
479
+ await new Promise((r) => setTimeout(r, 200));
480
+ });
481
+ const slowHandlerTest = vi.fn(async (payload: { foo: string }, _signal) => {
482
+ await new Promise((r) => setTimeout(r, 200));
483
+ });
484
+ const processor2 = jobQueue.createProcessor(
485
+ {
486
+ email: slowHandler,
487
+ sms: slowHandler,
488
+ test: slowHandlerTest,
489
+ },
490
+ { pollInterval: 100 },
491
+ );
492
+ const jobId2 = await jobQueue.addJob({
493
+ jobType: 'email',
494
+ payload: { to: 'processing@example.com' },
495
+ });
496
+ processor2.start();
497
+ // Wait a bit for job to be picked up
498
+ await new Promise((r) => setTimeout(r, 150));
499
+ // Job should be processing now
500
+ const processingJob = await jobQueue.getJob(jobId2);
501
+ if (processingJob?.status === 'processing') {
502
+ await jobQueue.editJob(jobId2, {
503
+ payload: { to: 'updated@example.com' },
504
+ });
505
+
506
+ const job2 = await jobQueue.getJob(jobId2);
507
+ // If still processing, payload should be unchanged
508
+ if (job2?.status === 'processing') {
509
+ expect(job2?.payload).toEqual({ to: 'processing@example.com' });
510
+ }
511
+ }
512
+ processor2.stop();
513
+ });
514
+
515
+ it('should record edited event when editing via JobQueue API', async () => {
516
+ const jobId = await jobQueue.addJob({
517
+ jobType: 'email',
518
+ payload: { to: 'original@example.com' },
519
+ });
520
+
521
+ await jobQueue.editJob(jobId, {
522
+ payload: { to: 'updated@example.com' },
523
+ priority: 10,
524
+ });
525
+
526
+ const events = await jobQueue.getJobEvents(jobId);
527
+ const editEvent = events.find((e) => e.eventType === 'edited');
528
+ expect(editEvent).not.toBeUndefined();
529
+ expect(editEvent?.metadata).toMatchObject({
530
+ payload: { to: 'updated@example.com' },
531
+ priority: 10,
532
+ });
533
+ });
305
534
  });
package/src/index.ts CHANGED
@@ -1,16 +1,8 @@
1
1
  import {
2
- addJob,
3
- getJob,
4
- getJobsByStatus,
5
- retryJob,
6
- cleanupOldJobs,
7
- cancelJob,
8
- cancelAllUpcomingJobs,
9
- getAllJobs,
10
- reclaimStuckJobs,
11
- getJobEvents,
12
- getJobsByTags,
13
- getJobs,
2
+ createWaitpoint,
3
+ completeWaitpoint,
4
+ getWaitpoint,
5
+ expireTimedOutWaitpoints,
14
6
  } from './queue.js';
15
7
  import { createProcessor } from './processor.js';
16
8
  import {
@@ -19,42 +11,71 @@ import {
19
11
  JobOptions,
20
12
  ProcessorOptions,
21
13
  JobHandlers,
14
+ JobType,
15
+ PostgresJobQueueConfig,
16
+ RedisJobQueueConfig,
22
17
  } from './types.js';
18
+ import { QueueBackend } from './backend.js';
23
19
  import { setLogContext } from './log-context.js';
24
20
  import { createPool } from './db-util.js';
21
+ import { PostgresBackend } from './backends/postgres.js';
22
+ import { RedisBackend } from './backends/redis.js';
25
23
 
26
24
  /**
27
- * Initialize the job queue system
25
+ * Initialize the job queue system.
26
+ *
27
+ * Defaults to PostgreSQL when `backend` is omitted.
28
28
  */
29
29
  export const initJobQueue = <PayloadMap = any>(
30
30
  config: JobQueueConfig,
31
31
  ): JobQueue<PayloadMap> => {
32
- const { databaseConfig } = config;
32
+ const backendType = config.backend ?? 'postgres';
33
+ setLogContext(config.verbose ?? false);
33
34
 
34
- // Create database pool
35
- const pool = createPool(databaseConfig);
35
+ let backend: QueueBackend;
36
+ let pool: import('pg').Pool | undefined;
36
37
 
37
- setLogContext(config.verbose ?? false);
38
+ if (backendType === 'postgres') {
39
+ const pgConfig = config as PostgresJobQueueConfig;
40
+ pool = createPool(pgConfig.databaseConfig);
41
+ backend = new PostgresBackend(pool);
42
+ } else if (backendType === 'redis') {
43
+ const redisConfig = (config as RedisJobQueueConfig).redisConfig;
44
+ // RedisBackend constructor will throw if ioredis is not installed
45
+ backend = new RedisBackend(redisConfig);
46
+ } else {
47
+ throw new Error(`Unknown backend: ${backendType}`);
48
+ }
49
+
50
+ const requirePool = () => {
51
+ if (!pool) {
52
+ throw new Error(
53
+ 'Wait/Token features require the PostgreSQL backend. Configure with backend: "postgres" to use these features.',
54
+ );
55
+ }
56
+ return pool;
57
+ };
38
58
 
39
59
  // Return the job queue API
40
60
  return {
41
61
  // Job queue operations
42
62
  addJob: withLogContext(
43
- (job: JobOptions<PayloadMap, any>) => addJob<PayloadMap, any>(pool, job),
63
+ (job: JobOptions<PayloadMap, any>) =>
64
+ backend.addJob<PayloadMap, any>(job),
44
65
  config.verbose ?? false,
45
66
  ),
46
67
  getJob: withLogContext(
47
- (id: number) => getJob<PayloadMap, any>(pool, id),
68
+ (id: number) => backend.getJob<PayloadMap, any>(id),
48
69
  config.verbose ?? false,
49
70
  ),
50
71
  getJobsByStatus: withLogContext(
51
72
  (status: string, limit?: number, offset?: number) =>
52
- getJobsByStatus<PayloadMap, any>(pool, status, limit, offset),
73
+ backend.getJobsByStatus<PayloadMap, any>(status, limit, offset),
53
74
  config.verbose ?? false,
54
75
  ),
55
76
  getAllJobs: withLogContext(
56
77
  (limit?: number, offset?: number) =>
57
- getAllJobs<PayloadMap, any>(pool, limit, offset),
78
+ backend.getAllJobs<PayloadMap, any>(limit, offset),
58
79
  config.verbose ?? false,
59
80
  ),
60
81
  getJobs: withLogContext(
@@ -69,13 +90,45 @@ export const initJobQueue = <PayloadMap = any>(
69
90
  },
70
91
  limit?: number,
71
92
  offset?: number,
72
- ) => getJobs<PayloadMap, any>(pool, filters, limit, offset),
93
+ ) => backend.getJobs<PayloadMap, any>(filters, limit, offset),
73
94
  config.verbose ?? false,
74
95
  ),
75
- retryJob: (jobId: number) => retryJob(pool, jobId),
76
- cleanupOldJobs: (daysToKeep?: number) => cleanupOldJobs(pool, daysToKeep),
96
+ retryJob: (jobId: number) => backend.retryJob(jobId),
97
+ cleanupOldJobs: (daysToKeep?: number) => backend.cleanupOldJobs(daysToKeep),
98
+ cleanupOldJobEvents: (daysToKeep?: number) =>
99
+ backend.cleanupOldJobEvents(daysToKeep),
77
100
  cancelJob: withLogContext(
78
- (jobId: number) => cancelJob(pool, jobId),
101
+ (jobId: number) => backend.cancelJob(jobId),
102
+ config.verbose ?? false,
103
+ ),
104
+ editJob: withLogContext(
105
+ <T extends JobType<PayloadMap>>(
106
+ jobId: number,
107
+ updates: import('./types.js').EditJobOptions<PayloadMap, T>,
108
+ ) => backend.editJob(jobId, updates as import('./backend.js').JobUpdates),
109
+ config.verbose ?? false,
110
+ ),
111
+ editAllPendingJobs: withLogContext(
112
+ <T extends JobType<PayloadMap>>(
113
+ filters:
114
+ | {
115
+ jobType?: string;
116
+ priority?: number;
117
+ runAt?:
118
+ | Date
119
+ | { gt?: Date; gte?: Date; lt?: Date; lte?: Date; eq?: Date };
120
+ tags?: {
121
+ values: string[];
122
+ mode?: import('./types.js').TagQueryMode;
123
+ };
124
+ }
125
+ | undefined,
126
+ updates: import('./types.js').EditJobOptions<PayloadMap, T>,
127
+ ) =>
128
+ backend.editAllPendingJobs(
129
+ filters,
130
+ updates as import('./backend.js').JobUpdates,
131
+ ),
79
132
  config.verbose ?? false,
80
133
  ),
81
134
  cancelAllUpcomingJobs: withLogContext(
@@ -86,17 +139,17 @@ export const initJobQueue = <PayloadMap = any>(
86
139
  | Date
87
140
  | { gt?: Date; gte?: Date; lt?: Date; lte?: Date; eq?: Date };
88
141
  tags?: { values: string[]; mode?: import('./types.js').TagQueryMode };
89
- }) => cancelAllUpcomingJobs(pool, filters),
142
+ }) => backend.cancelAllUpcomingJobs(filters),
90
143
  config.verbose ?? false,
91
144
  ),
92
145
  reclaimStuckJobs: withLogContext(
93
146
  (maxProcessingTimeMinutes?: number) =>
94
- reclaimStuckJobs(pool, maxProcessingTimeMinutes),
147
+ backend.reclaimStuckJobs(maxProcessingTimeMinutes),
95
148
  config.verbose ?? false,
96
149
  ),
97
150
  getJobsByTags: withLogContext(
98
151
  (tags: string[], mode = 'all', limit?: number, offset?: number) =>
99
- getJobsByTags<PayloadMap, any>(pool, tags, mode, limit, offset),
152
+ backend.getJobsByTags<PayloadMap, any>(tags, mode, limit, offset),
100
153
  config.verbose ?? false,
101
154
  ),
102
155
 
@@ -104,14 +157,51 @@ export const initJobQueue = <PayloadMap = any>(
104
157
  createProcessor: (
105
158
  handlers: JobHandlers<PayloadMap>,
106
159
  options?: ProcessorOptions,
107
- ) => createProcessor<PayloadMap>(pool, handlers, options),
108
- // Advanced access (for custom operations)
109
- getPool: () => pool,
160
+ ) => createProcessor<PayloadMap>(backend, handlers, options),
161
+
110
162
  // Job events
111
163
  getJobEvents: withLogContext(
112
- (jobId: number) => getJobEvents(pool, jobId),
164
+ (jobId: number) => backend.getJobEvents(jobId),
113
165
  config.verbose ?? false,
114
166
  ),
167
+
168
+ // Wait / Token support (PostgreSQL-only for now)
169
+ createToken: withLogContext(
170
+ (options?: import('./types.js').CreateTokenOptions) =>
171
+ createWaitpoint(requirePool(), null, options),
172
+ config.verbose ?? false,
173
+ ),
174
+ completeToken: withLogContext(
175
+ (tokenId: string, data?: any) =>
176
+ completeWaitpoint(requirePool(), tokenId, data),
177
+ config.verbose ?? false,
178
+ ),
179
+ getToken: withLogContext(
180
+ (tokenId: string) => getWaitpoint(requirePool(), tokenId),
181
+ config.verbose ?? false,
182
+ ),
183
+ expireTimedOutTokens: withLogContext(
184
+ () => expireTimedOutWaitpoints(requirePool()),
185
+ config.verbose ?? false,
186
+ ),
187
+
188
+ // Advanced access
189
+ getPool: () => {
190
+ if (backendType !== 'postgres') {
191
+ throw new Error(
192
+ 'getPool() is only available with the PostgreSQL backend.',
193
+ );
194
+ }
195
+ return (backend as PostgresBackend).getPool();
196
+ },
197
+ getRedisClient: () => {
198
+ if (backendType !== 'redis') {
199
+ throw new Error(
200
+ 'getRedisClient() is only available with the Redis backend.',
201
+ );
202
+ }
203
+ return (backend as RedisBackend).getClient();
204
+ },
115
205
  };
116
206
  };
117
207
 
@@ -123,3 +213,9 @@ const withLogContext =
123
213
  };
124
214
 
125
215
  export * from './types.js';
216
+ export { QueueBackend } from './backend.js';
217
+ export { PostgresBackend } from './backends/postgres.js';
218
+ export {
219
+ validateHandlerSerializable,
220
+ testHandlerSerialization,
221
+ } from './handler-validation.js';