@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
@@ -0,0 +1,698 @@
1
+ import { Pool } from 'pg';
2
+ import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
3
+ import { processJobWithHandlers } from './processor.js';
4
+ import * as queue from './queue.js';
5
+ import { createTestDbAndPool, destroyTestDb } from './test-util.js';
6
+ import { JobHandler, JobContext, WaitSignal } from './types.js';
7
+ import { PostgresBackend } from './backends/postgres.js';
8
+
9
+ // Payload map for wait-related tests
10
+ interface WaitPayloadMap {
11
+ stepJob: { value: string };
12
+ waitJob: { step: number };
13
+ tokenJob: { userId: string };
14
+ multiWait: { data: string };
15
+ }
16
+
17
+ /**
18
+ * Claims a job by transitioning it to 'processing' status (simulates getNextBatch).
19
+ * Tests that call processJobWithHandlers directly need the job in 'processing' state.
20
+ */
21
+ async function claimJob(p: Pool, jobId: number) {
22
+ await p.query(
23
+ `UPDATE job_queue SET status = 'processing', locked_by = 'test-worker', locked_at = NOW() WHERE id = $1`,
24
+ [jobId],
25
+ );
26
+ }
27
+
28
+ // Full handlers object (all job types must be present)
29
+ function makeHandlers(overrides: Partial<Record<keyof WaitPayloadMap, any>>) {
30
+ return {
31
+ stepJob: vi.fn(async () => {}),
32
+ waitJob: vi.fn(async () => {}),
33
+ tokenJob: vi.fn(async () => {}),
34
+ multiWait: vi.fn(async () => {}),
35
+ ...overrides,
36
+ };
37
+ }
38
+
39
+ describe('ctx.run step tracking', () => {
40
+ let pool: Pool;
41
+ let dbName: string;
42
+ let backend: PostgresBackend;
43
+
44
+ beforeEach(async () => {
45
+ const setup = await createTestDbAndPool();
46
+ pool = setup.pool;
47
+ dbName = setup.dbName;
48
+ backend = new PostgresBackend(pool);
49
+ });
50
+
51
+ afterEach(async () => {
52
+ await pool.end();
53
+ await destroyTestDb(dbName);
54
+ });
55
+
56
+ it('should execute steps and persist step data', async () => {
57
+ const executionOrder: string[] = [];
58
+
59
+ const handler: JobHandler<WaitPayloadMap, 'stepJob'> = async (
60
+ payload,
61
+ _signal,
62
+ ctx,
63
+ ) => {
64
+ const result1 = await ctx.run('step1', async () => {
65
+ executionOrder.push('step1');
66
+ return 'result-1';
67
+ });
68
+ expect(result1).toBe('result-1');
69
+
70
+ const result2 = await ctx.run('step2', async () => {
71
+ executionOrder.push('step2');
72
+ return 42;
73
+ });
74
+ expect(result2).toBe(42);
75
+ };
76
+
77
+ const handlers = makeHandlers({ stepJob: handler });
78
+ const jobId = await queue.addJob<WaitPayloadMap, 'stepJob'>(pool, {
79
+ jobType: 'stepJob',
80
+ payload: { value: 'test' },
81
+ });
82
+ await claimJob(pool, jobId);
83
+ const job = await queue.getJob<WaitPayloadMap, 'stepJob'>(pool, jobId);
84
+ await processJobWithHandlers(backend, job!, handlers);
85
+
86
+ // Job should be completed
87
+ const completed = await queue.getJob(pool, jobId);
88
+ expect(completed?.status).toBe('completed');
89
+ expect(executionOrder).toEqual(['step1', 'step2']);
90
+ });
91
+
92
+ it('should replay completed steps from cache on re-invocation', async () => {
93
+ const executionOrder: string[] = [];
94
+ let invocationCount = 0;
95
+
96
+ const handler: JobHandler<WaitPayloadMap, 'stepJob'> = async (
97
+ payload,
98
+ _signal,
99
+ ctx,
100
+ ) => {
101
+ invocationCount++;
102
+
103
+ await ctx.run('step1', async () => {
104
+ executionOrder.push('step1-executed');
105
+ return 'done';
106
+ });
107
+
108
+ // On first invocation, this will throw WaitSignal
109
+ // On second invocation, the wait is already completed
110
+ await ctx.waitFor({ seconds: 1 });
111
+
112
+ await ctx.run('step2', async () => {
113
+ executionOrder.push('step2-executed');
114
+ return 'done';
115
+ });
116
+ };
117
+
118
+ const handlers = makeHandlers({ stepJob: handler });
119
+
120
+ // First invocation: step1 executes, waitFor throws WaitSignal
121
+ const jobId = await queue.addJob<WaitPayloadMap, 'stepJob'>(pool, {
122
+ jobType: 'stepJob',
123
+ payload: { value: 'test' },
124
+ });
125
+ await claimJob(pool, jobId);
126
+ let job = await queue.getJob<WaitPayloadMap, 'stepJob'>(pool, jobId);
127
+ await processJobWithHandlers(backend, job!, handlers);
128
+
129
+ // Job should be in 'waiting' status
130
+ job = await queue.getJob<WaitPayloadMap, 'stepJob'>(pool, jobId);
131
+ expect(job?.status).toBe('waiting');
132
+ expect(job?.waitUntil).toBeInstanceOf(Date);
133
+ expect(executionOrder).toEqual(['step1-executed']);
134
+
135
+ // Simulate wait elapsed by setting wait_until to the past
136
+ const client = await pool.connect();
137
+ await client.query(
138
+ `UPDATE job_queue SET wait_until = NOW() - INTERVAL '1 second' WHERE id = $1`,
139
+ [jobId],
140
+ );
141
+ client.release();
142
+
143
+ // Pick up the job again (simulating processor poll)
144
+ const batch = await queue.getNextBatch<WaitPayloadMap, 'stepJob'>(
145
+ pool,
146
+ 'worker-test',
147
+ 1,
148
+ );
149
+ expect(batch.length).toBe(1);
150
+
151
+ // Second invocation: step1 replayed from cache, wait skipped, step2 executes
152
+ await processJobWithHandlers(backend, batch[0]!, handlers);
153
+
154
+ const completed = await queue.getJob(pool, jobId);
155
+ expect(completed?.status).toBe('completed');
156
+ expect(invocationCount).toBe(2);
157
+ // step1 should only have executed once (replayed from cache on second run)
158
+ expect(executionOrder).toEqual(['step1-executed', 'step2-executed']);
159
+ });
160
+
161
+ it('should not increment attempts when resuming from wait', async () => {
162
+ const handler: JobHandler<WaitPayloadMap, 'stepJob'> = async (
163
+ _payload,
164
+ _signal,
165
+ ctx,
166
+ ) => {
167
+ await ctx.run('step1', async () => 'done');
168
+ await ctx.waitFor({ seconds: 1 });
169
+ await ctx.run('step2', async () => 'done');
170
+ };
171
+
172
+ const handlers = makeHandlers({ stepJob: handler });
173
+ const jobId = await queue.addJob<WaitPayloadMap, 'stepJob'>(pool, {
174
+ jobType: 'stepJob',
175
+ payload: { value: 'test' },
176
+ maxAttempts: 3,
177
+ });
178
+
179
+ // First invocation
180
+ await claimJob(pool, jobId);
181
+ let job = await queue.getJob<WaitPayloadMap, 'stepJob'>(pool, jobId);
182
+ await processJobWithHandlers(backend, job!, handlers);
183
+
184
+ // Check attempts after first wait
185
+ job = await queue.getJob<WaitPayloadMap, 'stepJob'>(pool, jobId);
186
+ expect(job?.status).toBe('waiting');
187
+
188
+ // Simulate wait elapsed
189
+ const client = await pool.connect();
190
+ await client.query(
191
+ `UPDATE job_queue SET wait_until = NOW() - INTERVAL '1 second' WHERE id = $1`,
192
+ [jobId],
193
+ );
194
+ client.release();
195
+
196
+ // Pick up the job (should NOT increment attempts)
197
+ const batch = await queue.getNextBatch<WaitPayloadMap, 'stepJob'>(
198
+ pool,
199
+ 'worker-test',
200
+ 1,
201
+ );
202
+ expect(batch.length).toBe(1);
203
+
204
+ // The attempts should still be 0 (waiting jobs don't increment)
205
+ expect(batch[0]!.attempts).toBe(0);
206
+ });
207
+ });
208
+
209
+ describe('ctx.waitFor / ctx.waitUntil', () => {
210
+ let pool: Pool;
211
+ let dbName: string;
212
+ let backend: PostgresBackend;
213
+
214
+ beforeEach(async () => {
215
+ const setup = await createTestDbAndPool();
216
+ pool = setup.pool;
217
+ dbName = setup.dbName;
218
+ backend = new PostgresBackend(pool);
219
+ });
220
+
221
+ afterEach(async () => {
222
+ await pool.end();
223
+ await destroyTestDb(dbName);
224
+ });
225
+
226
+ it('should set job to waiting status with wait_until', async () => {
227
+ const handler: JobHandler<WaitPayloadMap, 'waitJob'> = async (
228
+ _payload,
229
+ _signal,
230
+ ctx,
231
+ ) => {
232
+ await ctx.waitFor({ hours: 1 });
233
+ };
234
+
235
+ const handlers = makeHandlers({ waitJob: handler });
236
+ const jobId = await queue.addJob<WaitPayloadMap, 'waitJob'>(pool, {
237
+ jobType: 'waitJob',
238
+ payload: { step: 0 },
239
+ });
240
+ await claimJob(pool, jobId);
241
+ const job = await queue.getJob<WaitPayloadMap, 'waitJob'>(pool, jobId);
242
+ await processJobWithHandlers(backend, job!, handlers);
243
+
244
+ const waiting = await queue.getJob(pool, jobId);
245
+ expect(waiting?.status).toBe('waiting');
246
+ expect(waiting?.waitUntil).toBeInstanceOf(Date);
247
+ // wait_until should be approximately 1 hour from now
248
+ const diff = waiting!.waitUntil!.getTime() - Date.now();
249
+ expect(diff).toBeGreaterThan(55 * 60 * 1000); // at least 55 minutes
250
+ expect(diff).toBeLessThan(65 * 60 * 1000); // at most 65 minutes
251
+ });
252
+
253
+ it('should set job to waiting status with waitUntil date', async () => {
254
+ const targetDate = new Date(Date.now() + 2 * 60 * 60 * 1000); // 2 hours
255
+
256
+ const handler: JobHandler<WaitPayloadMap, 'waitJob'> = async (
257
+ _payload,
258
+ _signal,
259
+ ctx,
260
+ ) => {
261
+ await ctx.waitUntil(targetDate);
262
+ };
263
+
264
+ const handlers = makeHandlers({ waitJob: handler });
265
+ const jobId = await queue.addJob<WaitPayloadMap, 'waitJob'>(pool, {
266
+ jobType: 'waitJob',
267
+ payload: { step: 0 },
268
+ });
269
+ await claimJob(pool, jobId);
270
+ const job = await queue.getJob<WaitPayloadMap, 'waitJob'>(pool, jobId);
271
+ await processJobWithHandlers(backend, job!, handlers);
272
+
273
+ const waiting = await queue.getJob(pool, jobId);
274
+ expect(waiting?.status).toBe('waiting');
275
+ const timeDiff = Math.abs(
276
+ waiting!.waitUntil!.getTime() - targetDate.getTime(),
277
+ );
278
+ expect(timeDiff).toBeLessThan(1000); // within 1 second of target
279
+ });
280
+
281
+ it('should record a waiting event', async () => {
282
+ const handler: JobHandler<WaitPayloadMap, 'waitJob'> = async (
283
+ _payload,
284
+ _signal,
285
+ ctx,
286
+ ) => {
287
+ await ctx.waitFor({ minutes: 30 });
288
+ };
289
+
290
+ const handlers = makeHandlers({ waitJob: handler });
291
+ const jobId = await queue.addJob<WaitPayloadMap, 'waitJob'>(pool, {
292
+ jobType: 'waitJob',
293
+ payload: { step: 0 },
294
+ });
295
+ await claimJob(pool, jobId);
296
+ const job = await queue.getJob<WaitPayloadMap, 'waitJob'>(pool, jobId);
297
+ await processJobWithHandlers(backend, job!, handlers);
298
+
299
+ const events = await queue.getJobEvents(pool, jobId);
300
+ const waitingEvents = events.filter((e) => e.eventType === 'waiting');
301
+ expect(waitingEvents.length).toBe(1);
302
+ expect(waitingEvents[0]!.metadata).toHaveProperty('waitUntil');
303
+ });
304
+
305
+ it('waiting jobs should not be picked up before wait_until', async () => {
306
+ const handler: JobHandler<WaitPayloadMap, 'waitJob'> = async (
307
+ _payload,
308
+ _signal,
309
+ ctx,
310
+ ) => {
311
+ await ctx.waitFor({ hours: 1 });
312
+ };
313
+
314
+ const handlers = makeHandlers({ waitJob: handler });
315
+ const jobId = await queue.addJob<WaitPayloadMap, 'waitJob'>(pool, {
316
+ jobType: 'waitJob',
317
+ payload: { step: 0 },
318
+ });
319
+ await claimJob(pool, jobId);
320
+ const job = await queue.getJob<WaitPayloadMap, 'waitJob'>(pool, jobId);
321
+ await processJobWithHandlers(backend, job!, handlers);
322
+
323
+ // Try to pick up -- should get nothing (wait_until is in the future)
324
+ const batch = await queue.getNextBatch<WaitPayloadMap, 'waitJob'>(
325
+ pool,
326
+ 'worker-test',
327
+ 1,
328
+ );
329
+ expect(batch.length).toBe(0);
330
+ });
331
+
332
+ it('should handle multiple sequential waits', async () => {
333
+ let phase = 0;
334
+
335
+ const handler: JobHandler<WaitPayloadMap, 'multiWait'> = async (
336
+ _payload,
337
+ _signal,
338
+ ctx,
339
+ ) => {
340
+ await ctx.run('phase1', async () => {
341
+ phase = 1;
342
+ });
343
+ await ctx.waitFor({ seconds: 1 });
344
+
345
+ await ctx.run('phase2', async () => {
346
+ phase = 2;
347
+ });
348
+ await ctx.waitFor({ seconds: 1 });
349
+
350
+ await ctx.run('phase3', async () => {
351
+ phase = 3;
352
+ });
353
+ };
354
+
355
+ const handlers = makeHandlers({ multiWait: handler });
356
+ const jobId = await queue.addJob<WaitPayloadMap, 'multiWait'>(pool, {
357
+ jobType: 'multiWait',
358
+ payload: { data: 'test' },
359
+ });
360
+
361
+ // First invocation: phase1 runs, first waitFor triggers
362
+ await claimJob(pool, jobId);
363
+ let job = await queue.getJob<WaitPayloadMap, 'multiWait'>(pool, jobId);
364
+ await processJobWithHandlers(backend, job!, handlers);
365
+ expect(phase).toBe(1);
366
+
367
+ let waiting = await queue.getJob(pool, jobId);
368
+ expect(waiting?.status).toBe('waiting');
369
+
370
+ // Simulate wait elapsed
371
+ const client = await pool.connect();
372
+ await client.query(
373
+ `UPDATE job_queue SET wait_until = NOW() - INTERVAL '1 second' WHERE id = $1`,
374
+ [jobId],
375
+ );
376
+ client.release();
377
+
378
+ // Second invocation: phase1 cached, first wait skipped, phase2 runs, second waitFor triggers
379
+ let batch = await queue.getNextBatch<WaitPayloadMap, 'multiWait'>(
380
+ pool,
381
+ 'worker-test',
382
+ 1,
383
+ );
384
+ await processJobWithHandlers(backend, batch[0]!, handlers);
385
+ expect(phase).toBe(2);
386
+
387
+ waiting = await queue.getJob(pool, jobId);
388
+ expect(waiting?.status).toBe('waiting');
389
+
390
+ // Simulate second wait elapsed
391
+ const client2 = await pool.connect();
392
+ await client2.query(
393
+ `UPDATE job_queue SET wait_until = NOW() - INTERVAL '1 second' WHERE id = $1`,
394
+ [jobId],
395
+ );
396
+ client2.release();
397
+
398
+ // Third invocation: all previous steps cached, both waits skipped, phase3 runs
399
+ batch = await queue.getNextBatch<WaitPayloadMap, 'multiWait'>(
400
+ pool,
401
+ 'worker-test',
402
+ 1,
403
+ );
404
+ await processJobWithHandlers(backend, batch[0]!, handlers);
405
+ expect(phase).toBe(3);
406
+
407
+ const completed = await queue.getJob(pool, jobId);
408
+ expect(completed?.status).toBe('completed');
409
+ });
410
+ });
411
+
412
+ describe('ctx.waitForToken', () => {
413
+ let pool: Pool;
414
+ let dbName: string;
415
+ let backend: PostgresBackend;
416
+
417
+ beforeEach(async () => {
418
+ const setup = await createTestDbAndPool();
419
+ pool = setup.pool;
420
+ dbName = setup.dbName;
421
+ backend = new PostgresBackend(pool);
422
+ });
423
+
424
+ afterEach(async () => {
425
+ await pool.end();
426
+ await destroyTestDb(dbName);
427
+ });
428
+
429
+ it('should pause job and resume when token is completed', async () => {
430
+ let tokenId: string | undefined;
431
+ let tokenResult: any;
432
+
433
+ const handler: JobHandler<WaitPayloadMap, 'tokenJob'> = async (
434
+ _payload,
435
+ _signal,
436
+ ctx,
437
+ ) => {
438
+ const token = await ctx.run('create-token', async () => {
439
+ return await ctx.createToken({ timeout: '10m' });
440
+ });
441
+ tokenId = token.id;
442
+
443
+ const result = await ctx.waitForToken<{ status: string }>(token.id);
444
+ tokenResult = result;
445
+ };
446
+
447
+ const handlers = makeHandlers({ tokenJob: handler });
448
+
449
+ // First invocation: creates token, then pauses
450
+ const jobId = await queue.addJob<WaitPayloadMap, 'tokenJob'>(pool, {
451
+ jobType: 'tokenJob',
452
+ payload: { userId: 'user-123' },
453
+ });
454
+ await claimJob(pool, jobId);
455
+ let job = await queue.getJob<WaitPayloadMap, 'tokenJob'>(pool, jobId);
456
+ await processJobWithHandlers(backend, job!, handlers);
457
+
458
+ expect(tokenId).toBeDefined();
459
+ job = await queue.getJob<WaitPayloadMap, 'tokenJob'>(pool, jobId);
460
+ expect(job?.status).toBe('waiting');
461
+ expect(job?.waitTokenId).toBe(tokenId);
462
+
463
+ // Verify the waitpoint exists
464
+ const wp = await queue.getWaitpoint(pool, tokenId!);
465
+ expect(wp).not.toBeNull();
466
+ expect(wp?.status).toBe('waiting');
467
+
468
+ // Complete the token externally
469
+ await queue.completeWaitpoint(pool, tokenId!, {
470
+ status: 'approved',
471
+ });
472
+
473
+ // Job should be back to 'pending'
474
+ job = await queue.getJob<WaitPayloadMap, 'tokenJob'>(pool, jobId);
475
+ expect(job?.status).toBe('pending');
476
+
477
+ // Second invocation: step1 replayed, waitForToken returns the result
478
+ const batch = await queue.getNextBatch<WaitPayloadMap, 'tokenJob'>(
479
+ pool,
480
+ 'worker-test',
481
+ 1,
482
+ );
483
+ expect(batch.length).toBe(1);
484
+ await processJobWithHandlers(backend, batch[0]!, handlers);
485
+
486
+ const completed = await queue.getJob(pool, jobId);
487
+ expect(completed?.status).toBe('completed');
488
+ expect(tokenResult).toEqual({ ok: true, output: { status: 'approved' } });
489
+ });
490
+
491
+ it('should handle token timeout', async () => {
492
+ let tokenId: string | undefined;
493
+ let tokenResult: any;
494
+
495
+ const handler: JobHandler<WaitPayloadMap, 'tokenJob'> = async (
496
+ _payload,
497
+ _signal,
498
+ ctx,
499
+ ) => {
500
+ const token = await ctx.run('create-token', async () => {
501
+ return await ctx.createToken({ timeout: '1s' });
502
+ });
503
+ tokenId = token.id;
504
+
505
+ const result = await ctx.waitForToken<{ status: string }>(token.id);
506
+ tokenResult = result;
507
+ };
508
+
509
+ const handlers = makeHandlers({ tokenJob: handler });
510
+ const jobId = await queue.addJob<WaitPayloadMap, 'tokenJob'>(pool, {
511
+ jobType: 'tokenJob',
512
+ payload: { userId: 'user-456' },
513
+ });
514
+ await claimJob(pool, jobId);
515
+ let job = await queue.getJob<WaitPayloadMap, 'tokenJob'>(pool, jobId);
516
+ await processJobWithHandlers(backend, job!, handlers);
517
+
518
+ expect(tokenId).toBeDefined();
519
+ job = await queue.getJob<WaitPayloadMap, 'tokenJob'>(pool, jobId);
520
+ expect(job?.status).toBe('waiting');
521
+
522
+ // Simulate token timeout by setting timeout_at in the past
523
+ const client = await pool.connect();
524
+ await client.query(
525
+ `UPDATE waitpoints SET timeout_at = NOW() - INTERVAL '1 second' WHERE id = $1`,
526
+ [tokenId],
527
+ );
528
+ client.release();
529
+
530
+ // Expire timed-out tokens
531
+ const expired = await queue.expireTimedOutWaitpoints(pool);
532
+ expect(expired).toBe(1);
533
+
534
+ // Job should be back to 'pending'
535
+ job = await queue.getJob<WaitPayloadMap, 'tokenJob'>(pool, jobId);
536
+ expect(job?.status).toBe('pending');
537
+
538
+ // Verify waitpoint is timed_out
539
+ const wp = await queue.getWaitpoint(pool, tokenId!);
540
+ expect(wp?.status).toBe('timed_out');
541
+
542
+ // Second invocation: waitForToken returns timeout result
543
+ const batch = await queue.getNextBatch<WaitPayloadMap, 'tokenJob'>(
544
+ pool,
545
+ 'worker-test',
546
+ 1,
547
+ );
548
+ expect(batch.length).toBe(1);
549
+ await processJobWithHandlers(backend, batch[0]!, handlers);
550
+
551
+ const completed = await queue.getJob(pool, jobId);
552
+ expect(completed?.status).toBe('completed');
553
+ expect(tokenResult).toEqual({ ok: false, error: 'Token timed out' });
554
+ });
555
+ });
556
+
557
+ describe('cancel waiting job', () => {
558
+ let pool: Pool;
559
+ let dbName: string;
560
+ let backend: PostgresBackend;
561
+
562
+ beforeEach(async () => {
563
+ const setup = await createTestDbAndPool();
564
+ pool = setup.pool;
565
+ dbName = setup.dbName;
566
+ backend = new PostgresBackend(pool);
567
+ });
568
+
569
+ afterEach(async () => {
570
+ await pool.end();
571
+ await destroyTestDb(dbName);
572
+ });
573
+
574
+ it('should cancel a job in waiting status', async () => {
575
+ const handler: JobHandler<WaitPayloadMap, 'waitJob'> = async (
576
+ _payload,
577
+ _signal,
578
+ ctx,
579
+ ) => {
580
+ await ctx.waitFor({ hours: 24 });
581
+ };
582
+
583
+ const handlers = makeHandlers({ waitJob: handler });
584
+ const jobId = await queue.addJob<WaitPayloadMap, 'waitJob'>(pool, {
585
+ jobType: 'waitJob',
586
+ payload: { step: 0 },
587
+ });
588
+ await claimJob(pool, jobId);
589
+ const job = await queue.getJob<WaitPayloadMap, 'waitJob'>(pool, jobId);
590
+ await processJobWithHandlers(backend, job!, handlers);
591
+
592
+ // Verify waiting
593
+ let waiting = await queue.getJob(pool, jobId);
594
+ expect(waiting?.status).toBe('waiting');
595
+
596
+ // Cancel the waiting job
597
+ await queue.cancelJob(pool, jobId);
598
+
599
+ const cancelled = await queue.getJob(pool, jobId);
600
+ expect(cancelled?.status).toBe('cancelled');
601
+ expect(cancelled?.waitUntil).toBeNull();
602
+ expect(cancelled?.waitTokenId).toBeNull();
603
+ });
604
+ });
605
+
606
+ describe('createToken / completeToken outside handlers', () => {
607
+ let pool: Pool;
608
+ let dbName: string;
609
+ let backend: PostgresBackend;
610
+
611
+ beforeEach(async () => {
612
+ const setup = await createTestDbAndPool();
613
+ pool = setup.pool;
614
+ dbName = setup.dbName;
615
+ backend = new PostgresBackend(pool);
616
+ });
617
+
618
+ afterEach(async () => {
619
+ await pool.end();
620
+ await destroyTestDb(dbName);
621
+ });
622
+
623
+ it('should create and retrieve a token', async () => {
624
+ const token = await queue.createWaitpoint(pool, null, {
625
+ timeout: '10m',
626
+ tags: ['approval', 'user:123'],
627
+ });
628
+
629
+ expect(token.id).toMatch(/^wp_/);
630
+
631
+ const retrieved = await queue.getWaitpoint(pool, token.id);
632
+ expect(retrieved).not.toBeNull();
633
+ expect(retrieved?.status).toBe('waiting');
634
+ expect(retrieved?.timeoutAt).toBeInstanceOf(Date);
635
+ expect(retrieved?.tags).toEqual(['approval', 'user:123']);
636
+ });
637
+
638
+ it('should complete a token and store output', async () => {
639
+ const token = await queue.createWaitpoint(pool, null);
640
+ await queue.completeWaitpoint(pool, token.id, { approved: true });
641
+
642
+ const retrieved = await queue.getWaitpoint(pool, token.id);
643
+ expect(retrieved?.status).toBe('completed');
644
+ expect(retrieved?.output).toEqual({ approved: true });
645
+ expect(retrieved?.completedAt).toBeInstanceOf(Date);
646
+ });
647
+ });
648
+
649
+ describe('WaitSignal class', () => {
650
+ it('should be an instance of Error', () => {
651
+ const signal = new WaitSignal('duration', new Date(), undefined, {});
652
+ expect(signal).toBeInstanceOf(Error);
653
+ expect(signal).toBeInstanceOf(WaitSignal);
654
+ expect(signal.isWaitSignal).toBe(true);
655
+ expect(signal.name).toBe('WaitSignal');
656
+ });
657
+ });
658
+
659
+ describe('existing handlers without wait features', () => {
660
+ let pool: Pool;
661
+ let dbName: string;
662
+ let backend: PostgresBackend;
663
+
664
+ beforeEach(async () => {
665
+ const setup = await createTestDbAndPool();
666
+ pool = setup.pool;
667
+ dbName = setup.dbName;
668
+ backend = new PostgresBackend(pool);
669
+ });
670
+
671
+ afterEach(async () => {
672
+ await pool.end();
673
+ await destroyTestDb(dbName);
674
+ });
675
+
676
+ it('should still work without using ctx.run or wait methods', async () => {
677
+ const handler: JobHandler<WaitPayloadMap, 'stepJob'> = async (
678
+ payload,
679
+ _signal,
680
+ _ctx,
681
+ ) => {
682
+ // Traditional handler that ignores ctx.run and waits
683
+ expect(payload.value).toBe('hello');
684
+ };
685
+
686
+ const handlers = makeHandlers({ stepJob: handler });
687
+ const jobId = await queue.addJob<WaitPayloadMap, 'stepJob'>(pool, {
688
+ jobType: 'stepJob',
689
+ payload: { value: 'hello' },
690
+ });
691
+ await claimJob(pool, jobId);
692
+ const job = await queue.getJob<WaitPayloadMap, 'stepJob'>(pool, jobId);
693
+ await processJobWithHandlers(backend, job!, handlers);
694
+
695
+ const completed = await queue.getJob(pool, jobId);
696
+ expect(completed?.status).toBe('completed');
697
+ });
698
+ });