@nicnocquee/dataqueue 1.24.0 → 1.26.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 (38) hide show
  1. package/README.md +44 -0
  2. package/migrations/1751131910825_add_timeout_seconds_to_job_queue.sql +2 -2
  3. package/migrations/1751186053000_add_job_events_table.sql +12 -8
  4. package/migrations/1751984773000_add_tags_to_job_queue.sql +1 -1
  5. package/migrations/1765809419000_add_force_kill_on_timeout_to_job_queue.sql +1 -1
  6. package/migrations/1771100000000_add_idempotency_key_to_job_queue.sql +7 -0
  7. package/migrations/1781200000000_add_wait_support.sql +12 -0
  8. package/migrations/1781200000001_create_waitpoints_table.sql +18 -0
  9. package/migrations/1781200000002_add_performance_indexes.sql +34 -0
  10. package/migrations/1781200000003_add_progress_to_job_queue.sql +7 -0
  11. package/package.json +20 -6
  12. package/src/backend.ts +163 -0
  13. package/src/backends/postgres.ts +1111 -0
  14. package/src/backends/redis-scripts.ts +533 -0
  15. package/src/backends/redis.test.ts +543 -0
  16. package/src/backends/redis.ts +834 -0
  17. package/src/db-util.ts +4 -2
  18. package/src/index.test.ts +6 -1
  19. package/src/index.ts +99 -36
  20. package/src/processor.test.ts +559 -18
  21. package/src/processor.ts +512 -44
  22. package/src/queue.test.ts +217 -6
  23. package/src/queue.ts +311 -902
  24. package/src/test-util.ts +32 -0
  25. package/src/types.ts +349 -16
  26. package/src/wait.test.ts +698 -0
  27. package/dist/cli.cjs +0 -88
  28. package/dist/cli.cjs.map +0 -1
  29. package/dist/cli.d.cts +0 -12
  30. package/dist/cli.d.ts +0 -12
  31. package/dist/cli.js +0 -81
  32. package/dist/cli.js.map +0 -1
  33. package/dist/index.cjs +0 -1420
  34. package/dist/index.cjs.map +0 -1
  35. package/dist/index.d.cts +0 -445
  36. package/dist/index.d.ts +0 -445
  37. package/dist/index.js +0 -1410
  38. package/dist/index.js.map +0 -1
package/src/test-util.ts CHANGED
@@ -65,3 +65,35 @@ export async function destroyTestDb(dbName: string) {
65
65
  await adminPool.query(`DROP DATABASE IF EXISTS ${dbName}`);
66
66
  await adminPool.end();
67
67
  }
68
+
69
+ /**
70
+ * Create a Redis test setup with a unique prefix to isolate tests.
71
+ * Returns the prefix and a cleanup function.
72
+ */
73
+ export function createRedisTestPrefix(): string {
74
+ return `test_${randomUUID().replace(/-/g, '').slice(0, 12)}:`;
75
+ }
76
+
77
+ /**
78
+ * Flush all keys with the given prefix from Redis.
79
+ */
80
+ export async function cleanupRedisPrefix(
81
+ redisClient: any,
82
+ prefix: string,
83
+ ): Promise<void> {
84
+ // Use SCAN to find all keys with the prefix and delete them
85
+ let cursor = '0';
86
+ do {
87
+ const [nextCursor, keys] = await redisClient.scan(
88
+ cursor,
89
+ 'MATCH',
90
+ `${prefix}*`,
91
+ 'COUNT',
92
+ 100,
93
+ );
94
+ cursor = nextCursor;
95
+ if (keys.length > 0) {
96
+ await redisClient.del(...keys);
97
+ }
98
+ } while (cursor !== '0');
99
+ }
package/src/types.ts CHANGED
@@ -1,5 +1,3 @@
1
- import { Pool } from 'pg';
2
-
3
1
  // Utility type for job type keys
4
2
  export type JobType<PayloadMap> = keyof PayloadMap & string;
5
3
 
@@ -63,6 +61,18 @@ export interface JobOptions<PayloadMap, T extends JobType<PayloadMap>> {
63
61
  * Tags for this job. Used for grouping, searching, or batch operations.
64
62
  */
65
63
  tags?: string[];
64
+ /**
65
+ * Optional idempotency key. When provided, ensures that only one job exists for a given key.
66
+ * If a job with the same idempotency key already exists, `addJob` returns the existing job's ID
67
+ * instead of creating a duplicate.
68
+ *
69
+ * Useful for preventing duplicate jobs caused by retries, double-clicks, webhook replays,
70
+ * or serverless function re-invocations.
71
+ *
72
+ * The key is unique across the entire `job_queue` table regardless of job status.
73
+ * Once a key exists, it cannot be reused until the job is cleaned up (via `cleanupOldJobs`).
74
+ */
75
+ idempotencyKey?: string;
66
76
  }
67
77
 
68
78
  /**
@@ -86,6 +96,8 @@ export enum JobEventType {
86
96
  Cancelled = 'cancelled',
87
97
  Retried = 'retried',
88
98
  Edited = 'edited',
99
+ Prolonged = 'prolonged',
100
+ Waiting = 'waiting',
89
101
  }
90
102
 
91
103
  export interface JobEvent {
@@ -107,7 +119,8 @@ export type JobStatus =
107
119
  | 'processing'
108
120
  | 'completed'
109
121
  | 'failed'
110
- | 'cancelled';
122
+ | 'cancelled'
123
+ | 'waiting';
111
124
 
112
125
  export interface JobRecord<PayloadMap, T extends JobType<PayloadMap>> {
113
126
  id: number;
@@ -162,11 +175,211 @@ export interface JobRecord<PayloadMap, T extends JobType<PayloadMap>> {
162
175
  * Tags for this job. Used for grouping, searching, or batch operations.
163
176
  */
164
177
  tags?: string[];
178
+ /**
179
+ * The idempotency key for this job, if one was provided when the job was created.
180
+ */
181
+ idempotencyKey?: string | null;
182
+ /**
183
+ * The time the job is waiting until (for time-based waits).
184
+ */
185
+ waitUntil?: Date | null;
186
+ /**
187
+ * The waitpoint token ID the job is waiting for (for token-based waits).
188
+ */
189
+ waitTokenId?: string | null;
190
+ /**
191
+ * Step data for the job. Stores completed step results for replay on re-invocation.
192
+ */
193
+ stepData?: Record<string, any>;
194
+ /**
195
+ * Progress percentage for the job (0-100), or null if no progress has been reported.
196
+ * Updated by the handler via `ctx.setProgress(percent)`.
197
+ */
198
+ progress?: number | null;
199
+ }
200
+
201
+ /**
202
+ * Callback registered via `onTimeout`. Invoked when the timeout fires, before the AbortSignal is triggered.
203
+ * Return a number (ms) to extend the timeout, or return nothing to let the timeout proceed.
204
+ */
205
+ export type OnTimeoutCallback = () => number | void | undefined;
206
+
207
+ /**
208
+ * Context object passed to job handlers as the third argument.
209
+ * Provides mechanisms to extend the job's timeout while it's running,
210
+ * as well as step tracking and wait capabilities.
211
+ */
212
+ export interface JobContext {
213
+ /**
214
+ * Proactively reset the timeout deadline.
215
+ * - If `ms` is provided, sets the deadline to `ms` milliseconds from now.
216
+ * - If omitted, resets the deadline to the original `timeoutMs` from now (heartbeat-style).
217
+ * - No-op if the job has no timeout set or if `forceKillOnTimeout` is true.
218
+ */
219
+ prolong: (ms?: number) => void;
220
+
221
+ /**
222
+ * Register a callback that is invoked when the timeout fires, **before** the AbortSignal is triggered.
223
+ * - If the callback returns a number > 0, the timeout is reset to that many ms from now.
224
+ * - If the callback returns `undefined`, `null`, `0`, or a negative number, the timeout proceeds normally.
225
+ * - The callback may be invoked multiple times if the job keeps extending.
226
+ * - Only one callback can be registered; subsequent calls replace the previous one.
227
+ * - No-op if the job has no timeout set or if `forceKillOnTimeout` is true.
228
+ */
229
+ onTimeout: (callback: OnTimeoutCallback) => void;
230
+
231
+ /**
232
+ * Execute a named step with memoization. If the step was already completed
233
+ * in a previous invocation (e.g., before a wait), the cached result is returned
234
+ * without re-executing the function.
235
+ *
236
+ * Step names must be unique within a handler and stable across re-invocations.
237
+ *
238
+ * @param stepName - A unique identifier for this step.
239
+ * @param fn - The function to execute. Its return value is cached.
240
+ * @returns The result of the step (from cache or fresh execution).
241
+ */
242
+ run: <T>(stepName: string, fn: () => Promise<T>) => Promise<T>;
243
+
244
+ /**
245
+ * Wait for a specified duration before continuing execution.
246
+ * The job will be paused and resumed after the duration elapses.
247
+ *
248
+ * When this is called, the handler throws a WaitSignal internally.
249
+ * The job is set to 'waiting' status and will be re-invoked after the
250
+ * specified duration. All steps completed via `ctx.run()` before this
251
+ * call will be replayed from cache on re-invocation.
252
+ *
253
+ * @param duration - The duration to wait (e.g., `{ hours: 1 }`, `{ days: 7 }`).
254
+ */
255
+ waitFor: (duration: WaitDuration) => Promise<void>;
256
+
257
+ /**
258
+ * Wait until a specific date/time before continuing execution.
259
+ * The job will be paused and resumed at (or after) the specified date.
260
+ *
261
+ * @param date - The date to wait until.
262
+ */
263
+ waitUntil: (date: Date) => Promise<void>;
264
+
265
+ /**
266
+ * Create a waitpoint token. The token can be completed externally
267
+ * (by calling `jobQueue.completeToken()`) to resume a waiting job.
268
+ *
269
+ * Tokens can be created inside handlers or outside (via `jobQueue.createToken()`).
270
+ *
271
+ * @param options - Optional token configuration (timeout, tags).
272
+ * @returns A token object with `id` that can be passed to `waitForToken()`.
273
+ */
274
+ createToken: (options?: CreateTokenOptions) => Promise<WaitToken>;
275
+
276
+ /**
277
+ * Wait for a waitpoint token to be completed by an external signal.
278
+ * The job will be paused until `jobQueue.completeToken(tokenId, data)` is called
279
+ * or the token times out.
280
+ *
281
+ * @param tokenId - The ID of the token to wait for.
282
+ * @returns A result object indicating success or timeout.
283
+ */
284
+ waitForToken: <T = any>(tokenId: string) => Promise<WaitTokenResult<T>>;
285
+
286
+ /**
287
+ * Report progress for this job (0-100).
288
+ * The value is persisted to the database and can be read by clients
289
+ * via `getJob()` or the React SDK's `useJob()` hook.
290
+ *
291
+ * @param percent - Progress percentage (0-100). Values are rounded to the nearest integer.
292
+ * @throws If percent is outside the 0-100 range.
293
+ */
294
+ setProgress: (percent: number) => Promise<void>;
295
+ }
296
+
297
+ /**
298
+ * Duration specification for `ctx.waitFor()`.
299
+ * At least one field must be provided. Fields are additive.
300
+ */
301
+ export interface WaitDuration {
302
+ seconds?: number;
303
+ minutes?: number;
304
+ hours?: number;
305
+ days?: number;
306
+ weeks?: number;
307
+ months?: number;
308
+ years?: number;
309
+ }
310
+
311
+ /**
312
+ * Options for creating a waitpoint token.
313
+ */
314
+ export interface CreateTokenOptions {
315
+ /**
316
+ * Maximum time to wait for the token to be completed.
317
+ * Accepts a duration string like '10m', '1h', '24h', '7d'.
318
+ * If not provided, the token has no timeout.
319
+ */
320
+ timeout?: string;
321
+ /**
322
+ * Tags to attach to the token for filtering.
323
+ */
324
+ tags?: string[];
325
+ }
326
+
327
+ /**
328
+ * A waitpoint token returned by `ctx.createToken()`.
329
+ */
330
+ export interface WaitToken {
331
+ /** The unique token ID. */
332
+ id: string;
333
+ }
334
+
335
+ /**
336
+ * Result of `ctx.waitForToken()`.
337
+ */
338
+ export type WaitTokenResult<T = any> =
339
+ | { ok: true; output: T }
340
+ | { ok: false; error: string };
341
+
342
+ /**
343
+ * Internal signal thrown by wait methods to pause handler execution.
344
+ * This is not a real error -- the processor catches it and transitions the job to 'waiting' status.
345
+ */
346
+ export class WaitSignal extends Error {
347
+ readonly isWaitSignal = true;
348
+
349
+ constructor(
350
+ public readonly type: 'duration' | 'date' | 'token',
351
+ public readonly waitUntil: Date | undefined,
352
+ public readonly tokenId: string | undefined,
353
+ public readonly stepData: Record<string, any>,
354
+ ) {
355
+ super('WaitSignal');
356
+ this.name = 'WaitSignal';
357
+ }
358
+ }
359
+
360
+ /**
361
+ * Status of a waitpoint token.
362
+ */
363
+ export type WaitpointStatus = 'waiting' | 'completed' | 'timed_out';
364
+
365
+ /**
366
+ * A waitpoint record from the database.
367
+ */
368
+ export interface WaitpointRecord {
369
+ id: string;
370
+ jobId: number | null;
371
+ status: WaitpointStatus;
372
+ output: any;
373
+ timeoutAt: Date | null;
374
+ createdAt: Date;
375
+ completedAt: Date | null;
376
+ tags: string[] | null;
165
377
  }
166
378
 
167
379
  export type JobHandler<PayloadMap, T extends keyof PayloadMap> = (
168
380
  payload: PayloadMap[T],
169
381
  signal: AbortSignal,
382
+ ctx: JobContext,
170
383
  ) => Promise<void>;
171
384
 
172
385
  export type JobHandlers<PayloadMap> = {
@@ -214,8 +427,18 @@ export interface Processor {
214
427
  startInBackground: () => void;
215
428
  /**
216
429
  * Stop the job processor that runs in the background.
430
+ * Does not wait for in-flight jobs to complete.
217
431
  */
218
432
  stop: () => void;
433
+ /**
434
+ * Stop the job processor and wait for all in-flight jobs to complete.
435
+ * Useful for graceful shutdown (e.g., SIGTERM handling).
436
+ * No new batches will be started after calling this method.
437
+ *
438
+ * @param timeoutMs - Maximum time to wait for in-flight jobs (default: 30000ms).
439
+ * If jobs don't complete within this time, the promise resolves anyway.
440
+ */
441
+ stopAndDrain: (timeoutMs?: number) => Promise<void>;
219
442
  /**
220
443
  * Check if the job processor is running.
221
444
  */
@@ -248,7 +471,12 @@ export interface DatabaseSSLConfig {
248
471
  rejectUnauthorized?: boolean;
249
472
  }
250
473
 
251
- export interface JobQueueConfig {
474
+ /**
475
+ * Configuration for PostgreSQL backend (default).
476
+ * Backward-compatible: omitting `backend` defaults to 'postgres'.
477
+ */
478
+ export interface PostgresJobQueueConfig {
479
+ backend?: 'postgres';
252
480
  databaseConfig: {
253
481
  connectionString?: string;
254
482
  host?: string;
@@ -261,6 +489,48 @@ export interface JobQueueConfig {
261
489
  verbose?: boolean;
262
490
  }
263
491
 
492
+ /**
493
+ * TLS configuration for the Redis connection.
494
+ */
495
+ export interface RedisTLSConfig {
496
+ ca?: string;
497
+ cert?: string;
498
+ key?: string;
499
+ rejectUnauthorized?: boolean;
500
+ }
501
+
502
+ /**
503
+ * Configuration for Redis backend.
504
+ */
505
+ export interface RedisJobQueueConfig {
506
+ backend: 'redis';
507
+ redisConfig: {
508
+ /** Redis URL (e.g. redis://localhost:6379) */
509
+ url?: string;
510
+ host?: string;
511
+ port?: number;
512
+ password?: string;
513
+ /** Redis database number (default: 0) */
514
+ db?: number;
515
+ tls?: RedisTLSConfig;
516
+ /**
517
+ * Key prefix for all Redis keys (default: 'dq:').
518
+ * Useful to namespace multiple queues in the same Redis instance.
519
+ */
520
+ keyPrefix?: string;
521
+ };
522
+ verbose?: boolean;
523
+ }
524
+
525
+ /**
526
+ * Job queue configuration — discriminated union.
527
+ * If `backend` is omitted, PostgreSQL is used.
528
+ */
529
+ export type JobQueueConfig = PostgresJobQueueConfig | RedisJobQueueConfig;
530
+
531
+ /** @deprecated Use JobQueueConfig instead. Alias kept for backward compat. */
532
+ export type JobQueueConfigLegacy = PostgresJobQueueConfig;
533
+
264
534
  export type TagQueryMode = 'exact' | 'all' | 'any' | 'none';
265
535
 
266
536
  export interface JobQueue<PayloadMap> {
@@ -310,16 +580,25 @@ export interface JobQueue<PayloadMap> {
310
580
  offset?: number,
311
581
  ) => Promise<JobRecord<PayloadMap, T>[]>;
312
582
  /**
313
- * Get jobs by filters.
314
- /**
315
- * Get jobs by filters.
316
- */
317
- getJobs: <T extends JobType<PayloadMap>>(filters?: {
318
- jobType?: string;
319
- priority?: number;
320
- runAt?: Date | { gt?: Date; gte?: Date; lt?: Date; lte?: Date; eq?: Date };
321
- tags?: { values: string[]; mode?: TagQueryMode };
322
- }) => Promise<JobRecord<PayloadMap, T>[]>;
583
+ * Get jobs by filters, with pagination support.
584
+ * - Use `cursor` for efficient keyset pagination (recommended for large datasets).
585
+ * - Use `limit` and `offset` for traditional pagination.
586
+ * - Do not combine `cursor` with `offset`.
587
+ */
588
+ getJobs: <T extends JobType<PayloadMap>>(
589
+ filters?: {
590
+ jobType?: string;
591
+ priority?: number;
592
+ runAt?:
593
+ | Date
594
+ | { gt?: Date; gte?: Date; lt?: Date; lte?: Date; eq?: Date };
595
+ tags?: { values: string[]; mode?: TagQueryMode };
596
+ /** Cursor for keyset pagination. Only return jobs with id < cursor. */
597
+ cursor?: number;
598
+ },
599
+ limit?: number,
600
+ offset?: number,
601
+ ) => Promise<JobRecord<PayloadMap, T>[]>;
323
602
  /**
324
603
  * Retry a job given its ID.
325
604
  * - This will set the job status back to 'pending', clear the locked_at and locked_by, and allow it to be picked up by other workers.
@@ -329,6 +608,10 @@ export interface JobQueue<PayloadMap> {
329
608
  * Cleanup jobs that are older than the specified number of days.
330
609
  */
331
610
  cleanupOldJobs: (daysToKeep?: number) => Promise<number>;
611
+ /**
612
+ * Cleanup job events that are older than the specified number of days.
613
+ */
614
+ cleanupOldJobEvents: (daysToKeep?: number) => Promise<number>;
332
615
  /**
333
616
  * Cancel a job given its ID.
334
617
  * - This will set the job status to 'cancelled' and clear the locked_at and locked_by.
@@ -406,8 +689,58 @@ export interface JobQueue<PayloadMap> {
406
689
  * Get the job events for a job.
407
690
  */
408
691
  getJobEvents: (jobId: number) => Promise<JobEvent[]>;
692
+
693
+ /**
694
+ * Create a waitpoint token.
695
+ * Tokens can be completed externally to resume a waiting job.
696
+ * Can be called outside of handlers (e.g., from an API route).
697
+ *
698
+ * **PostgreSQL backend only.** Throws if the backend is Redis.
699
+ *
700
+ * @param options - Optional token configuration (timeout, tags).
701
+ * @returns A token object with `id`.
702
+ */
703
+ createToken: (options?: CreateTokenOptions) => Promise<WaitToken>;
704
+
705
+ /**
706
+ * Complete a waitpoint token, resuming the associated waiting job.
707
+ * Can be called from anywhere (API routes, external services, etc.).
708
+ *
709
+ * **PostgreSQL backend only.** Throws if the backend is Redis.
710
+ *
711
+ * @param tokenId - The ID of the token to complete.
712
+ * @param data - Optional data to pass to the waiting handler.
713
+ */
714
+ completeToken: (tokenId: string, data?: any) => Promise<void>;
715
+
716
+ /**
717
+ * Retrieve a waitpoint token by its ID.
718
+ *
719
+ * **PostgreSQL backend only.** Throws if the backend is Redis.
720
+ *
721
+ * @param tokenId - The ID of the token to retrieve.
722
+ * @returns The token record, or null if not found.
723
+ */
724
+ getToken: (tokenId: string) => Promise<WaitpointRecord | null>;
725
+
726
+ /**
727
+ * Expire timed-out waitpoint tokens and resume their associated jobs.
728
+ * Call this periodically (e.g., alongside `reclaimStuckJobs`).
729
+ *
730
+ * **PostgreSQL backend only.** Throws if the backend is Redis.
731
+ *
732
+ * @returns The number of tokens that were expired.
733
+ */
734
+ expireTimedOutTokens: () => Promise<number>;
735
+
736
+ /**
737
+ * Get the PostgreSQL database pool.
738
+ * Throws if the backend is not PostgreSQL.
739
+ */
740
+ getPool: () => import('pg').Pool;
409
741
  /**
410
- * Get the database pool.
742
+ * Get the Redis client instance (ioredis).
743
+ * Throws if the backend is not Redis.
411
744
  */
412
- getPool: () => Pool;
745
+ getRedisClient: () => unknown;
413
746
  }