@tstdl/base 0.93.141 → 0.93.143

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 (71) hide show
  1. package/authentication/client/authentication.service.d.ts +1 -0
  2. package/authentication/client/authentication.service.js +3 -2
  3. package/circuit-breaker/circuit-breaker.d.ts +6 -4
  4. package/circuit-breaker/postgres/circuit-breaker.d.ts +1 -0
  5. package/circuit-breaker/postgres/circuit-breaker.js +8 -5
  6. package/circuit-breaker/tests/circuit-breaker.test.js +20 -0
  7. package/examples/document-management/main.js +2 -2
  8. package/notification/tests/notification-api.test.js +5 -1
  9. package/notification/tests/notification-flow.test.js +9 -6
  10. package/orm/decorators.d.ts +17 -4
  11. package/orm/decorators.js +9 -0
  12. package/orm/server/bootstrap.d.ts +11 -0
  13. package/orm/server/bootstrap.js +31 -0
  14. package/orm/server/drizzle/schema-converter.d.ts +3 -1
  15. package/orm/server/drizzle/schema-converter.js +71 -29
  16. package/orm/server/extension.d.ts +14 -0
  17. package/orm/server/extension.js +27 -0
  18. package/orm/server/index.d.ts +2 -0
  19. package/orm/server/index.js +2 -0
  20. package/orm/server/migration.d.ts +2 -3
  21. package/orm/server/migration.js +7 -21
  22. package/orm/server/repository.d.ts +1 -0
  23. package/orm/server/repository.js +19 -9
  24. package/orm/server/transaction.d.ts +1 -0
  25. package/orm/server/transaction.js +3 -0
  26. package/orm/tests/database-extension.test.js +63 -0
  27. package/orm/tests/database-migration.test.js +7 -6
  28. package/orm/tests/repository-compound-primary-key.test.d.ts +2 -0
  29. package/orm/tests/repository-compound-primary-key.test.js +234 -0
  30. package/orm/tests/schema-generation.test.d.ts +1 -0
  31. package/orm/tests/schema-generation.test.js +52 -5
  32. package/package.json +4 -4
  33. package/task-queue/README.md +0 -1
  34. package/task-queue/postgres/drizzle/0000_faithful_daimon_hellstrom.sql +84 -0
  35. package/task-queue/postgres/drizzle/meta/0000_snapshot.json +155 -72
  36. package/task-queue/postgres/drizzle/meta/_journal.json +2 -2
  37. package/task-queue/postgres/module.js +2 -1
  38. package/task-queue/postgres/schemas.d.ts +6 -0
  39. package/task-queue/postgres/task-queue.d.ts +18 -5
  40. package/task-queue/postgres/task-queue.js +577 -357
  41. package/task-queue/postgres/task.model.d.ts +10 -6
  42. package/task-queue/postgres/task.model.js +28 -28
  43. package/task-queue/task-context.d.ts +10 -5
  44. package/task-queue/task-context.js +5 -3
  45. package/task-queue/task-queue.d.ts +342 -38
  46. package/task-queue/task-queue.js +135 -31
  47. package/task-queue/tests/coverage-branch.test.js +46 -58
  48. package/task-queue/tests/coverage-enhancement.test.js +123 -117
  49. package/task-queue/tests/{extensive-dependencies.test.js → dag.test.js} +66 -37
  50. package/task-queue/tests/dependencies.test.js +143 -25
  51. package/task-queue/tests/enqueue-batch.test.js +125 -0
  52. package/task-queue/tests/fan-out-spawning.test.js +46 -5
  53. package/task-queue/tests/idempotent-replacement.test.js +54 -1
  54. package/task-queue/tests/missing-idempotent-tasks.test.js +9 -8
  55. package/task-queue/tests/queue.test.js +261 -25
  56. package/task-queue/tests/shutdown.test.js +41 -0
  57. package/task-queue/tests/transactions.test.d.ts +1 -0
  58. package/task-queue/tests/transactions.test.js +47 -0
  59. package/task-queue/tests/worker.test.js +46 -13
  60. package/task-queue/tests/zombie-parent.test.js +4 -4
  61. package/task-queue/tests/zombie-recovery.test.js +3 -3
  62. package/testing/integration-setup.js +5 -3
  63. package/utils/timing.d.ts +2 -2
  64. package/task-queue/postgres/drizzle/0000_wakeful_sunspot.sql +0 -82
  65. package/task-queue/tests/cascading-cancellations.test.js +0 -38
  66. package/task-queue/tests/complex.test.js +0 -122
  67. package/task-queue/tests/dag-dependencies.test.js +0 -41
  68. /package/{task-queue/tests/cascading-cancellations.test.d.ts → orm/tests/database-extension.test.d.ts} +0 -0
  69. /package/task-queue/tests/{complex.test.d.ts → dag.test.d.ts} +0 -0
  70. /package/task-queue/tests/{dag-dependencies.test.d.ts → enqueue-batch.test.d.ts} +0 -0
  71. /package/task-queue/tests/{extensive-dependencies.test.d.ts → shutdown.test.d.ts} +0 -0
@@ -1,6 +1,9 @@
1
+ import { eq, or } from 'drizzle-orm';
1
2
  import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
2
3
  import { CancellationToken } from '../../cancellation/index.js';
4
+ import { Database } from '../../orm/server/index.js';
3
5
  import { TaskQueueProvider, TaskStatus } from '../../task-queue/index.js';
6
+ import { task as taskTable } from '../../task-queue/postgres/schemas.js';
4
7
  import { setupIntegrationTest } from '../../testing/index.js';
5
8
  import { currentTimestamp } from '../../utils/date-time.js';
6
9
  import { Timer } from '../../utils/timer.js';
@@ -70,7 +73,7 @@ describe('Queue Integration Tests', () => {
70
73
  await queue.complete(second);
71
74
  });
72
75
  it('Deduplication (Idempotency Keys)', async () => {
73
- const key = `unique-${Date.now()}`;
76
+ const key = `unique-${crypto.randomUUID()}`;
74
77
  // 1. Initial Insert
75
78
  const t1 = await queue.enqueue('test', { value: 'original' }, { idempotencyKey: key });
76
79
  // 2. Default Strategy (replace: false): Should return existing task, ignore new data
@@ -92,6 +95,8 @@ describe('Queue Integration Tests', () => {
92
95
  const attempt1 = await queue.dequeue();
93
96
  expect(attempt1?.id).toBe(task.id);
94
97
  await queue.fail(attempt1, { message: 'oops' });
98
+ // Verify Retrying status
99
+ await assertTaskStatus(task.id, TaskStatus.Retrying, 'Task enters Retrying status after failure');
95
100
  // Force reschedule to now to bypass retryDelay
96
101
  await queue.reschedule(task.id, currentTimestamp());
97
102
  // Try 2
@@ -131,12 +136,14 @@ describe('Queue Integration Tests', () => {
131
136
  describe('PostgresQueue (Distributed Task Orchestration)', () => {
132
137
  let injector;
133
138
  let queue;
139
+ let database;
134
140
  beforeAll(async () => {
135
141
  ({ injector } = await setupIntegrationTest({ modules: { taskQueue: true } }));
142
+ database = injector.resolve(Database);
136
143
  });
137
144
  beforeEach(() => {
138
145
  const queueProvider = injector.resolve(TaskQueueProvider);
139
- const queueName = `pg-test-queue-${Date.now()}-${Math.random()}`;
146
+ const queueName = `pg-test-queue-${crypto.randomUUID()}`;
140
147
  queue = queueProvider.get(queueName, {
141
148
  visibilityTimeout: 50, // Short timeout for testing
142
149
  retryDelayMinimum: 50,
@@ -174,11 +181,96 @@ describe('PostgresQueue (Distributed Task Orchestration)', () => {
174
181
  const dequeued = await queue.dequeue();
175
182
  await queue.fail(dequeued, new Error('temp failure'));
176
183
  const updated = await queue.getTask(task.id);
177
- expect(updated?.status).toBe(TaskStatus.Pending);
184
+ expect(updated?.status).toBe(TaskStatus.Retrying);
178
185
  expect(updated?.tries).toBe(1);
179
186
  expect(updated?.error).toBeDefined();
180
187
  });
181
188
  });
189
+ describe('Hierarchy and Cross-Namespace', () => {
190
+ it('should correctly increment parent unresolved dependencies when a child is spawned in a different namespace', async () => {
191
+ const queueProvider = injector.resolve(TaskQueueProvider);
192
+ const nameA = `QueueA-${crypto.randomUUID()}`;
193
+ const nameB = `QueueB-${crypto.randomUUID()}`;
194
+ const queueA = queueProvider.get(nameA);
195
+ const queueB = queueProvider.get(nameB);
196
+ const parent = await queueA.enqueue('test', { value: 'parent' });
197
+ expect(parent.unresolvedCompleteDependencies).toBe(0);
198
+ await queueB.enqueue('test', { value: 'child' }, { parentId: parent.id, blockParent: true });
199
+ const updatedParent = await queueA.getTask(parent.id);
200
+ expect(updatedParent?.unresolvedCompleteDependencies).toBe(1);
201
+ await database.update(taskTable).set({ parentId: null }).where(or(eq(taskTable.namespace, nameA), eq(taskTable.namespace, nameB)));
202
+ await queueB.clear();
203
+ await queueA.clear();
204
+ });
205
+ it('should recursively cancel the entire task tree spanning multiple namespaces', async () => {
206
+ const queueProvider = injector.resolve(TaskQueueProvider);
207
+ const nameA = `NamespaceA-${crypto.randomUUID()}`;
208
+ const nameB = `NamespaceB-${crypto.randomUUID()}`;
209
+ const queueA = queueProvider.get(nameA);
210
+ const queueB = queueProvider.get(nameB);
211
+ const parent = await queueA.enqueue('test', { value: 'parent' });
212
+ const child = await queueB.enqueue('test', { value: 'child' }, { parentId: parent.id });
213
+ const grandchild = await queueA.enqueue('test', { value: 'grandchild' }, { parentId: child.id });
214
+ await queueA.cancel(parent.id);
215
+ await queueB.waitForTasks([child.id], { statuses: [TaskStatus.Cancelled] });
216
+ await queueA.waitForTasks([grandchild.id], { statuses: [TaskStatus.Cancelled] });
217
+ const updatedChild = await queueB.getTask(child.id);
218
+ const updatedGrandchild = await queueA.getTask(grandchild.id);
219
+ expect(updatedChild?.status).toBe(TaskStatus.Cancelled);
220
+ expect(updatedGrandchild?.status).toBe(TaskStatus.Cancelled);
221
+ await database.update(taskTable).set({ parentId: null }).where(or(eq(taskTable.namespace, nameA), eq(taskTable.namespace, nameB)));
222
+ await queueB.clear();
223
+ await queueA.clear();
224
+ });
225
+ it('should successfully cancel tasks from other namespaces (Bug 4)', async () => {
226
+ const queueProvider = injector.resolve(TaskQueueProvider);
227
+ const nameA = `QueueA-${crypto.randomUUID()}`;
228
+ const nameB = `QueueB-${crypto.randomUUID()}`;
229
+ const queueA = queueProvider.get(nameA);
230
+ const queueB = queueProvider.get(nameB);
231
+ // 1. Enqueue task in queueB
232
+ const taskB = await queueB.enqueue('test', {});
233
+ expect(taskB.status).toBe(TaskStatus.Pending);
234
+ // 2. Cancel task in queueA (using taskB.id which is from other namespace)
235
+ await queueA.cancel(taskB.id);
236
+ // 3. Verify taskB IS cancelled (because ID is global)
237
+ const updatedB = await queueB.getTask(taskB.id);
238
+ expect(updatedB?.status).toBe(TaskStatus.Cancelled);
239
+ await database.update(taskTable).set({ parentId: null }).where(or(eq(taskTable.namespace, nameA), eq(taskTable.namespace, nameB)));
240
+ await queueB.clear();
241
+ await queueA.clear();
242
+ });
243
+ it('should complete parent task if idempotent child is already completed (Bug 6)', async () => {
244
+ const q = injector.resolve(TaskQueueProvider).get(`q-${crypto.randomUUID()}`);
245
+ // 1. Enqueue and complete child task with idempotency key
246
+ const idempotencyKey = 'child-idempotency-key';
247
+ const child = await q.enqueue('test', {}, { idempotencyKey });
248
+ const dequeuedChild = await q.dequeue();
249
+ await q.complete(dequeuedChild);
250
+ const finishedChild = await q.getTask(child.id);
251
+ expect(finishedChild?.status).toBe(TaskStatus.Completed);
252
+ // 2. Enqueue parent task that spawns the same child (via idempotency key)
253
+ const parent = await q.enqueue('test', {});
254
+ await q.enqueue('test', {}, {
255
+ idempotencyKey,
256
+ parentId: parent.id,
257
+ blockParent: true,
258
+ });
259
+ // Dequeue and complete parent
260
+ const dequeuedParent = await q.dequeue();
261
+ await q.complete(dequeuedParent);
262
+ const updatedParent = await q.getTask(parent.id);
263
+ expect(updatedParent?.status).toBe(TaskStatus.Completed);
264
+ await q.clear();
265
+ });
266
+ it('should increment unresolvedCompleteDependencies for children with blockParent: true (Bug 6-2)', async () => {
267
+ const parent = await queue.enqueue('parent', {});
268
+ expect(parent.unresolvedCompleteDependencies).toBe(0);
269
+ await queue.enqueue('child', {}, { parentId: parent.id, blockParent: true });
270
+ const updatedParent = await queue.getTask(parent.id);
271
+ expect(updatedParent?.unresolvedCompleteDependencies).toBe(1);
272
+ });
273
+ });
182
274
  describe('Priority and Scheduling', () => {
183
275
  it('should dequeue tasks in priority order (lower number first)', async () => {
184
276
  await queue.enqueue('foo', { foo: 'low' }, { priority: 2000 });
@@ -202,24 +294,18 @@ describe('PostgresQueue (Distributed Task Orchestration)', () => {
202
294
  });
203
295
  });
204
296
  describe('Concurrency Control', () => {
205
- it('should respect global concurrency limits', async () => {
206
- const queueProvider = injector.resolve(TaskQueueProvider);
207
- const limitedQueue = queueProvider.get(`limit-test-${Date.now()}`, { globalConcurrency: 2 });
208
- await limitedQueue.enqueueMany([
209
- { type: 'foo', data: { foo: '1' } },
210
- { type: 'foo', data: { foo: '2' } },
211
- { type: 'foo', data: { foo: '3' } },
212
- ]);
213
- const t1 = await limitedQueue.dequeue();
214
- const t2 = await limitedQueue.dequeue();
215
- const t3 = await limitedQueue.dequeue();
216
- expect(t1).toBeDefined();
217
- expect(t2).toBeDefined();
218
- expect(t3).toBeUndefined(); // Limit reached
219
- await limitedQueue.complete(t1);
220
- const t3Retry = await limitedQueue.dequeue();
221
- expect(t3Retry).toBeDefined(); // Slot opened
222
- await limitedQueue.clear();
297
+ it('should refund the rate limiter tokens when dequeueMany retrieves fewer tasks than requested', async () => {
298
+ const q = injector.resolve(TaskQueueProvider).get(`q-${crypto.randomUUID()}`, {
299
+ rateLimit: 5,
300
+ rateInterval: 60000,
301
+ });
302
+ await q.enqueue('test', { value: '1' });
303
+ const tasks1 = await q.dequeueMany(5); // Consumes 5, gets 1, refunds 4.
304
+ expect(tasks1).toHaveLength(1);
305
+ await q.enqueue('test', { value: '2' });
306
+ const tasks2 = await q.dequeueMany(4); // Should be allowed because of refund.
307
+ expect(tasks2).toHaveLength(1);
308
+ await q.clear();
223
309
  });
224
310
  });
225
311
  describe('Circuit Breaker', () => {
@@ -257,21 +343,21 @@ describe('PostgresQueue (Distributed Task Orchestration)', () => {
257
343
  await timeout(100);
258
344
  await queue.maintenance();
259
345
  const recovered = await queue.getTask(task.id);
260
- expect(recovered?.status).toBe(TaskStatus.Pending);
346
+ expect(recovered?.status).toBe(TaskStatus.Retrying);
261
347
  expect(recovered?.tries).toBe(1);
262
348
  expect(recovered?.token).toBeNull();
263
349
  });
264
350
  it('should fail tasks that exceed Hard Execution Timeout via prune', async () => {
265
351
  // Re-configure queue with very short execution timeout
266
352
  const queueProvider = injector.resolve(TaskQueueProvider);
267
- const shortQueue = queueProvider.get(`prune-test-${Date.now()}`, { maxExecutionTime: 50 });
353
+ const shortQueue = queueProvider.get(`prune-test-${crypto.randomUUID()}`, { maxExecutionTime: 50 });
268
354
  const task = await shortQueue.enqueue('foo', { foo: 'long-running' });
269
355
  await shortQueue.dequeue();
270
356
  await timeout(75);
271
357
  await shortQueue.maintenance();
272
358
  const updated = await shortQueue.getTask(task.id);
273
- expect(updated?.status).toBe(TaskStatus.Dead);
274
- expect(updated?.error?.message).toContain('Hard Execution Timeout');
359
+ expect(updated?.status).toBe(TaskStatus.TimedOut);
360
+ expect(updated?.error?.code).toBe('MaxTimeExceeded');
275
361
  await shortQueue.clear();
276
362
  });
277
363
  it('should touch a task to extend token', async () => {
@@ -294,6 +380,46 @@ describe('PostgresQueue (Distributed Task Orchestration)', () => {
294
380
  const touchResult = await queue.touch(dequeued);
295
381
  expect(touchResult).toBeUndefined();
296
382
  });
383
+ it('should correctly identify and transition zombie tasks to Orphaned after max retries', async () => {
384
+ const q = injector.resolve(TaskQueueProvider).get(`q-${crypto.randomUUID()}`, { maxTries: 1 });
385
+ const task = await q.enqueue('test', { value: 'zombie' });
386
+ // Dequeue to start it
387
+ await q.dequeue();
388
+ // Manually make it a zombie
389
+ await database
390
+ .update(taskTable)
391
+ .set({ visibilityDeadline: currentTimestamp() - 1000 })
392
+ .where(eq(taskTable.id, task.id));
393
+ await q.maintenance();
394
+ const updated = await q.getTask(task.id);
395
+ expect(updated?.status).toBe(TaskStatus.Orphaned);
396
+ await q.clear();
397
+ });
398
+ it('should age priority correctly', async () => {
399
+ const q = injector.resolve(TaskQueueProvider).get(`q-${crypto.randomUUID()}`, {
400
+ priorityAgingInterval: 0,
401
+ priorityAgingStep: 10,
402
+ });
403
+ const task = await q.enqueue('test', { value: 'aging' }, { priority: 100 });
404
+ // Manually backdate priorityAgeTimestamp
405
+ await database
406
+ .update(taskTable)
407
+ .set({ priorityAgeTimestamp: currentTimestamp() - 1000 })
408
+ .where(eq(taskTable.id, task.id));
409
+ await q.maintenance();
410
+ const updated = await q.getTask(task.id);
411
+ expect(updated?.priority).toBe(90);
412
+ await q.clear();
413
+ });
414
+ it('clear() should NOT throw foreign key violations', async () => {
415
+ const queueProvider = injector.resolve(TaskQueueProvider);
416
+ const otherQueue = queueProvider.get('other-namespace', {});
417
+ const parent = await queue.enqueue('parent', {});
418
+ await otherQueue.enqueue('child', {}, { parentId: parent.id });
419
+ // Clearing the queue containing the parent should NOT throw even if other-namespace has a child pointing to it.
420
+ await otherQueue.clear();
421
+ await expect(queue.clear()).resolves.toBeUndefined();
422
+ });
297
423
  });
298
424
  describe('Batch Operations', () => {
299
425
  it('should complete many tasks efficiently', async () => {
@@ -309,6 +435,62 @@ describe('PostgresQueue (Distributed Task Orchestration)', () => {
309
435
  expect(t1?.status).toBe(TaskStatus.Completed);
310
436
  expect(t2?.status).toBe(TaskStatus.Completed);
311
437
  });
438
+ it('should successfully completeMany tasks that have a NULL token', async () => {
439
+ const q = injector.resolve(TaskQueueProvider).get(`q-${crypto.randomUUID()}`);
440
+ const t1 = await q.enqueue('test', { value: '1' });
441
+ const t2 = await q.enqueue('test', { value: '2' });
442
+ expect(t1.token).toBeNull();
443
+ expect(t2.token).toBeNull();
444
+ await q.completeMany([t1, t2]);
445
+ const ut1 = await q.getTask(t1.id);
446
+ const ut2 = await q.getTask(t2.id);
447
+ expect(ut1?.status).toBe(TaskStatus.Completed);
448
+ expect(ut2?.status).toBe(TaskStatus.Completed);
449
+ await q.clear();
450
+ });
451
+ it('should successfully failMany tasks that have a NULL token', async () => {
452
+ const q = injector.resolve(TaskQueueProvider).get(`q-${crypto.randomUUID()}`, { maxTries: 0 });
453
+ const t1 = await q.enqueue('test', { value: '1' });
454
+ await q.failMany([t1], [new Error('fail')]);
455
+ const ut1 = await q.getTask(t1.id);
456
+ expect(ut1?.status).toBe(TaskStatus.Dead);
457
+ await q.clear();
458
+ });
459
+ it('should successfully touchMany tasks with missing or NULL states', async () => {
460
+ const q = injector.resolve(TaskQueueProvider).get(`q-${crypto.randomUUID()}`);
461
+ const t1 = await q.enqueue('test', { value: '1' });
462
+ const t2 = await q.enqueue('test', { value: '2' });
463
+ const d1 = await q.dequeue();
464
+ const d2 = await q.dequeue();
465
+ await q.touchMany([d1, d2], {
466
+ progresses: [0.5, 0.8],
467
+ states: [{ step: 'halfway' }, undefined],
468
+ });
469
+ const ut1 = await q.getTask(t1.id);
470
+ const ut2 = await q.getTask(t2.id);
471
+ expect(ut1?.progress).toBe(0.5);
472
+ expect(ut1?.state).toEqual({ step: 'halfway' });
473
+ expect(ut2?.progress).toBe(0.8);
474
+ expect(ut2?.state).toBeNull();
475
+ await q.clear();
476
+ });
477
+ it('should reject bulk updates (completeMany/failMany) if the provided token does not match the database', async () => {
478
+ const q = injector.resolve(TaskQueueProvider).get(`q-${crypto.randomUUID()}`);
479
+ const t1 = await q.enqueue('test', { value: '1' });
480
+ // Task is Pending, token is NULL.
481
+ // Try to complete with a fake token.
482
+ await q.completeMany([{ ...t1, token: crypto.randomUUID() }]);
483
+ const ut1 = await q.getTask(t1.id);
484
+ expect(ut1?.status).toBe(TaskStatus.Pending); // Should still be Pending
485
+ // Dequeue to get a real token
486
+ const d1 = await q.dequeue();
487
+ expect(d1?.token).toBeDefined();
488
+ // Try to complete with NULL token
489
+ await q.completeMany([{ ...d1, token: null }]);
490
+ const ut2 = await q.getTask(t1.id);
491
+ expect(ut2?.status).toBe(TaskStatus.Running); // Should still be Running
492
+ await q.clear();
493
+ });
312
494
  });
313
495
  describe('Rescheduling', () => {
314
496
  it('should reschedule and refund tries if running', async () => {
@@ -322,6 +504,15 @@ describe('PostgresQueue (Distributed Task Orchestration)', () => {
322
504
  expect(updated?.tries).toBe(0); // Refunded
323
505
  expect(updated?.scheduleTimestamp).toBe(inFuture);
324
506
  });
507
+ it('rescheduling should NOT bypass dependency constraints (Bug 7)', async () => {
508
+ const dep = await queue.enqueue('dep', {});
509
+ const main = await queue.enqueue('main', {}, { scheduleAfter: [dep.id] });
510
+ expect(main.status).toBe(TaskStatus.Waiting);
511
+ expect(main.unresolvedScheduleDependencies).toBe(1);
512
+ await queue.reschedule(main.id, currentTimestamp());
513
+ const updatedMain = await queue.getTask(main.id);
514
+ expect(updatedMain?.status).toBe(TaskStatus.Waiting);
515
+ });
325
516
  });
326
517
  describe('TaskContext (Worker DX)', () => {
327
518
  it('checkpoint() should update progress and handle token loss', async () => {
@@ -451,4 +642,49 @@ describe('PostgresQueue (Distributed Task Orchestration)', () => {
451
642
  expect(finalParent?.status).toBe(TaskStatus.Completed);
452
643
  });
453
644
  });
645
+ describe('Bugs and Edge Cases', () => {
646
+ it('should NOT overwrite terminal states during cancellation (Bug 3)', async () => {
647
+ const q = injector.resolve(TaskQueueProvider).get(`q-${crypto.randomUUID()}`);
648
+ // 1. Enqueue parent and two children
649
+ const parent = await q.enqueue('parent', {});
650
+ const child1 = await q.enqueue('child', {}, { parentId: parent.id });
651
+ const child2 = await q.enqueue('child', {}, { parentId: parent.id });
652
+ // 2. Complete child1
653
+ const dequeued1 = await q.dequeue({ types: ['child'] });
654
+ await q.complete(dequeued1);
655
+ const finished1 = await q.getTask(child1.id);
656
+ expect(finished1?.status).toBe(TaskStatus.Completed);
657
+ // 3. Cancel parent tree
658
+ await q.cancel(parent.id);
659
+ // 4. Verify child1 is STILL Completed, not Cancelled
660
+ const updated1 = await q.getTask(child1.id);
661
+ expect(updated1?.status).toBe(TaskStatus.Completed);
662
+ // 5. Verify parent and child2 ARE Cancelled
663
+ const updatedParent = await q.getTask(parent.id);
664
+ const updated2 = await q.getTask(child2.id);
665
+ expect(updatedParent?.status).toBe(TaskStatus.Cancelled);
666
+ expect(updated2?.status).toBe(TaskStatus.Cancelled);
667
+ await q.clear();
668
+ });
669
+ it('should expire tasks in Waiting status (Bug 5)', async () => {
670
+ const q = injector.resolve(TaskQueueProvider).get(`q-${crypto.randomUUID()}`);
671
+ // 1. Enqueue task A (will never complete)
672
+ const taskA = await q.enqueue('test', {});
673
+ // 2. Enqueue task B with scheduleAfter: [A.id] and short TTL
674
+ const taskB = await q.enqueue('test', {}, {
675
+ scheduleAfter: [taskA.id],
676
+ timeToLive: 100, // 100ms
677
+ });
678
+ expect(taskB.status).toBe(TaskStatus.Waiting);
679
+ // 3. Wait for TTL to pass
680
+ await timeout(200);
681
+ // 4. Run maintenance
682
+ await q.maintenance();
683
+ // 5. Verify task B is Expired
684
+ await q.waitForTasks([taskB.id]);
685
+ const updatedB = await q.getTask(taskB.id);
686
+ expect(updatedB?.status).toBe(TaskStatus.Expired);
687
+ await q.clear();
688
+ });
689
+ });
454
690
  });
@@ -0,0 +1,41 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
2
+ import { CancellationSignal } from '../../cancellation/token.js';
3
+ import { DeferredPromise } from '../../promise/deferred-promise.js';
4
+ import { TaskProcessResult, TaskQueueProvider } from '../../task-queue/index.js';
5
+ import { setupIntegrationTest } from '../../testing/index.js';
6
+ import { timeout } from '../../utils/timing.js';
7
+ describe('Graceful Worker Shutdown', () => {
8
+ let injector;
9
+ let queue;
10
+ beforeAll(async () => {
11
+ ({ injector } = await setupIntegrationTest({ modules: { taskQueue: true } }));
12
+ const queueProvider = injector.resolve(TaskQueueProvider);
13
+ queue = queueProvider.get(`shutdown-queue-${crypto.randomUUID()}`);
14
+ });
15
+ afterAll(async () => {
16
+ await injector?.dispose();
17
+ });
18
+ it('should wait for active handler to finish on disposal', async () => {
19
+ const cancellationSignal = injector.resolve(CancellationSignal);
20
+ await queue.enqueue('shutdown-test', {});
21
+ let handlerFinished = false;
22
+ const handlerStarted = new DeferredPromise();
23
+ queue.process({ cancellationSignal }, async () => {
24
+ handlerStarted.resolve();
25
+ await timeout(300); // simulate work
26
+ handlerFinished = true;
27
+ return TaskProcessResult.Complete();
28
+ });
29
+ queue.notify();
30
+ // Wait for worker to pick it up
31
+ await handlerStarted;
32
+ // Call disposal
33
+ const disposePromise = injector.dispose();
34
+ // Check that disposal waits for the handler
35
+ expect(handlerFinished).toBe(false);
36
+ await disposePromise;
37
+ expect(handlerFinished).toBe(true);
38
+ // Note: We can't call queue.getTask here because pool is closed.
39
+ // But handlerFinished = true and successful disposePromise already prove it waited.
40
+ });
41
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,47 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
2
+ import { TaskQueueProvider, TaskStatus } from '../../task-queue/index.js';
3
+ import { setupIntegrationTest } from '../../testing/index.js';
4
+ describe('Task Queue Transactions', () => {
5
+ let injector;
6
+ let queue;
7
+ beforeAll(async () => {
8
+ ({ injector } = await setupIntegrationTest({ modules: { taskQueue: true } }));
9
+ const queueProvider = injector.resolve(TaskQueueProvider);
10
+ queue = queueProvider.get(`tx-queue-${crypto.randomUUID()}`);
11
+ });
12
+ afterAll(async () => {
13
+ await injector?.dispose();
14
+ });
15
+ it('should respect transaction rollbacks', async () => {
16
+ let taskId;
17
+ await queue.transaction(async (tx) => {
18
+ const task = await queue.enqueue('tx-task', { foo: 'bar' }, { transaction: tx });
19
+ taskId = task.id;
20
+ const hasTask = await queue.has(taskId, { transaction: tx });
21
+ expect(hasTask).toBe(true);
22
+ await tx.rollback();
23
+ });
24
+ const hasTaskAfterRollback = await queue.has(taskId);
25
+ expect(hasTaskAfterRollback).toBe(false);
26
+ });
27
+ it('should participate in external transaction (commit)', async () => {
28
+ let taskId;
29
+ await queue.transaction(async (tx) => {
30
+ const task = await queue.enqueue('tx-commit', { foo: 'bar' }, { transaction: tx });
31
+ taskId = task.id;
32
+ });
33
+ const hasTaskAfterCommit = await queue.has(taskId);
34
+ expect(hasTaskAfterCommit).toBe(true);
35
+ });
36
+ it('should handle multiple operations in one transaction', async () => {
37
+ await queue.transaction(async (tx) => {
38
+ const t1 = await queue.enqueue('t1', {}, { transaction: tx });
39
+ const t2 = await queue.enqueue('t2', {}, { transaction: tx });
40
+ await queue.cancel(t1.id, { transaction: tx });
41
+ const u1 = await queue.getTask(t1.id, { transaction: tx });
42
+ expect(u1?.status).toBe(TaskStatus.Cancelled);
43
+ const u2 = await queue.getTask(t2.id, { transaction: tx });
44
+ expect(u2?.status).toBe(TaskStatus.Pending);
45
+ });
46
+ });
47
+ });
@@ -1,18 +1,23 @@
1
1
  import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
2
2
  import { CancellationToken } from '../../cancellation/index.js';
3
+ import { runInInjectionContext } from '../../injector/index.js';
4
+ import { injectRepository } from '../../orm/server/index.js';
3
5
  import { TaskProcessResult, TaskQueueProvider, TaskStatus } from '../../task-queue/index.js';
6
+ import { PostgresTask } from '../../task-queue/postgres/index.js';
4
7
  import { setupIntegrationTest } from '../../testing/index.js';
5
8
  import { timeout } from '../../utils/timing.js';
6
9
  describe('Worker & Base Class Tests', () => {
7
10
  let injector;
8
11
  let queue;
9
12
  let token;
13
+ let otherQueueName;
10
14
  beforeAll(async () => {
11
15
  ({ injector } = await setupIntegrationTest({ modules: { taskQueue: true } }));
16
+ otherQueueName = `other-queue-${crypto.randomUUID()}`;
12
17
  });
13
18
  beforeEach(() => {
14
19
  const queueProvider = injector.resolve(TaskQueueProvider);
15
- const queueName = `worker-queue-${Date.now()}-${Math.random()}`;
20
+ const queueName = `worker-queue-${crypto.randomUUID()}`;
16
21
  queue = queueProvider.get(queueName, {
17
22
  visibilityTimeout: 200, // Short visibility for testing lease loss
18
23
  });
@@ -20,9 +25,14 @@ describe('Worker & Base Class Tests', () => {
20
25
  });
21
26
  afterEach(async () => {
22
27
  token.set();
28
+ const repository = runInInjectionContext(injector, () => injectRepository(PostgresTask));
29
+ const namespace = queue.getTransactionalContextData().namespace;
30
+ // Clear foreign keys
31
+ await repository.updateManyByQuery({ namespace }, { parentId: null });
32
+ await repository.updateManyByQuery({ namespace: otherQueueName }, { parentId: null });
23
33
  await queue.clear();
24
34
  const queueProvider = injector.resolve(TaskQueueProvider);
25
- await queueProvider.get('other-queue').clear();
35
+ await queueProvider.get(otherQueueName).clear();
26
36
  });
27
37
  afterAll(async () => {
28
38
  await injector?.dispose();
@@ -39,6 +49,7 @@ describe('Worker & Base Class Tests', () => {
39
49
  for (let i = 0; i < 50; i++) {
40
50
  if (processed.length == 2)
41
51
  break;
52
+ queue.notify();
42
53
  await timeout(20);
43
54
  }
44
55
  token.set(); // Stop worker
@@ -56,17 +67,18 @@ describe('Worker & Base Class Tests', () => {
56
67
  queue.process({ cancellationSignal: token }, async () => {
57
68
  throw new Error('worker error');
58
69
  });
59
- // Wait until task is processed (error recorded and status is Pending)
70
+ // Wait until task is processed (error recorded and status is Retrying)
60
71
  for (let i = 0; i < 50; i++) {
61
72
  const updated = await queue.getTask(task.id);
62
- if (updated?.tries == 1 && updated.status == TaskStatus.Pending) {
73
+ if (updated?.tries == 1 && updated.status == TaskStatus.Retrying) {
63
74
  break;
64
75
  }
76
+ queue.notify();
65
77
  await timeout(20);
66
78
  }
67
79
  token.set();
68
80
  const updated = await queue.getTask(task.id);
69
- expect(updated?.status).toBe(TaskStatus.Pending); // Should retry
81
+ expect(updated?.status).toBe(TaskStatus.Retrying); // Should retry
70
82
  expect(updated?.tries).toBe(1);
71
83
  expect(updated?.error?.message).toBe('worker error');
72
84
  });
@@ -79,6 +91,7 @@ describe('Worker & Base Class Tests', () => {
79
91
  executed = true;
80
92
  return TaskProcessResult.Complete();
81
93
  });
94
+ queue.notify();
82
95
  await queue.waitForTasks([task.id], { timeout: 5000 });
83
96
  token.set();
84
97
  expect(executed).toBe(true);
@@ -99,18 +112,19 @@ describe('Worker & Base Class Tests', () => {
99
112
  }
100
113
  return TaskProcessResult.Complete();
101
114
  });
102
- // Wait until tasks are processed (error/reschedule recorded and status is Pending)
115
+ // Wait until tasks are processed (error/reschedule recorded and status is Retrying/Pending)
103
116
  for (let i = 0; i < 50; i++) {
104
117
  const uFail = await queue.getTask(tFail.id);
105
118
  const uResched = await queue.getTask(tResched.id);
106
- if (uFail?.tries == 1 && uResched?.status == TaskStatus.Pending && (uResched?.scheduleTimestamp ?? 0) > Date.now()) {
119
+ if (uFail?.tries == 1 && uFail.status == TaskStatus.Retrying && uResched?.status == TaskStatus.Pending && (uResched?.scheduleTimestamp ?? 0) > Date.now()) {
107
120
  break;
108
121
  }
122
+ queue.notify();
109
123
  await timeout(20);
110
124
  }
111
125
  token.set();
112
126
  const uFail = await queue.getTask(tFail.id);
113
- expect(uFail?.status).toBe(TaskStatus.Pending); // Retry
127
+ expect(uFail?.status).toBe(TaskStatus.Retrying); // Retry
114
128
  expect(uFail?.error?.message).toBe('explicit fail');
115
129
  const uResched = await queue.getTask(tResched.id);
116
130
  expect(uResched?.status).toBe(TaskStatus.Pending);
@@ -132,9 +146,9 @@ describe('Worker & Base Class Tests', () => {
132
146
  const children = await context.spawnMany([{ type: 'child', data: { c: 2 } }]);
133
147
  expect(children[0]?.parentId).toBe(task.id);
134
148
  // Other queue spawn
135
- const otherQueue = injector.resolve(TaskQueueProvider).get('other-queue');
149
+ const otherQueue = injector.resolve(TaskQueueProvider).get(otherQueueName);
136
150
  const otherChild = await context.spawn(otherQueue, 'other', { x: 1 });
137
- expect(otherChild.namespace).toBe('other-queue');
151
+ expect(otherChild.namespace).toBe(otherQueueName);
138
152
  await context.spawnMany(otherQueue, [{ type: 'other', data: { x: 2 } }]);
139
153
  executed = true;
140
154
  return TaskProcessResult.Complete();
@@ -143,18 +157,29 @@ describe('Worker & Base Class Tests', () => {
143
157
  void (async () => {
144
158
  while (!executed) {
145
159
  await timeout(50);
160
+ queue.notify();
146
161
  }
147
162
  // At this point parent should be WaitingChildren if children are not done
163
+ await queue.waitForTasks([task.id], { statuses: [TaskStatus.WaitingChildren], timeout: 2000 });
148
164
  const midTask = await queue.getTask(task.id);
149
165
  expect(midTask?.status).toBe(TaskStatus.WaitingChildren);
150
166
  while (true) {
151
167
  const dChild = await queue.dequeue({ types: ['child'] });
152
- if (!dChild)
168
+ if (!dChild) {
169
+ // Check other-queue as well
170
+ const otherQueue = injector.resolve(TaskQueueProvider).get(otherQueueName);
171
+ const dOther = await otherQueue.dequeue();
172
+ if (dOther) {
173
+ await otherQueue.complete(dOther);
174
+ continue;
175
+ }
153
176
  break;
177
+ }
154
178
  await queue.complete(dChild);
155
179
  }
156
180
  })();
157
- await queue.waitForTasks([task.id], { interval: 50, timeout: 5000 });
181
+ queue.notify();
182
+ await queue.waitForTasks([task.id], { interval: 50, timeout: 2000 });
158
183
  token.set();
159
184
  expect(executed).toBe(true);
160
185
  const finalTask = await queue.getTask(task.id);
@@ -162,7 +187,7 @@ describe('Worker & Base Class Tests', () => {
162
187
  });
163
188
  it('should correctly report isFinalAttempt in TaskContext', async () => {
164
189
  const queueProvider = injector.resolve(TaskQueueProvider);
165
- const queueName = `final-try-test-${Date.now()}`;
190
+ const queueName = `final-try-test-${crypto.randomUUID()}`;
166
191
  const testQueue = queueProvider.get(queueName, {
167
192
  maxTries: 2,
168
193
  retryDelayMinimum: 0,
@@ -188,4 +213,12 @@ describe('Worker & Base Class Tests', () => {
188
213
  expect(finalAttemptValues).toEqual([false, true]);
189
214
  await testQueue.clear();
190
215
  });
216
+ it('should handle unsupported task result action', async () => {
217
+ const task = await queue.enqueue('unsupported-action', {});
218
+ const workerPromise = queue.processWorker(token, () => ({ payload: { action: 'magic' } }));
219
+ await expect(workerPromise).rejects.toThrow('Unsupported task result action');
220
+ const updated = await queue.getTask(task.id);
221
+ expect(updated?.status).toBe(TaskStatus.Retrying);
222
+ expect(updated?.error?.message).toContain('Unsupported task result action');
223
+ });
191
224
  });
@@ -10,7 +10,7 @@ describe('Zombie Parent Deadlock', () => {
10
10
  });
11
11
  beforeEach(() => {
12
12
  const queueProvider = injector.resolve(TaskQueueProvider);
13
- const queueName = `zombie-queue-${Date.now()}-${Math.random()}`;
13
+ const queueName = `zombie-queue-${crypto.randomUUID()}`;
14
14
  queue = queueProvider.get(queueName, {
15
15
  visibilityTimeout: 1000,
16
16
  });
@@ -21,11 +21,11 @@ describe('Zombie Parent Deadlock', () => {
21
21
  afterAll(async () => {
22
22
  await injector?.dispose();
23
23
  });
24
- it('should resolve parent even if child fails (failFast: false)', async () => {
24
+ it('should resolve parent even if child fails (abortOnDependencyFailure: false)', async () => {
25
25
  const parent = await queue.enqueue('parent', {});
26
26
  const dParent = await queue.dequeue();
27
- // Spawn a child that will fail. Parent has failFast: false by default.
28
- const [child] = await queue.enqueueMany([{ type: 'child', data: {}, parentId: parent.id, failFast: false }], { returnTasks: true });
27
+ // Spawn a child that will fail. Parent has abortOnDependencyFailure: false by default.
28
+ const [child] = await queue.enqueueMany([{ type: 'child', data: {}, parentId: parent.id, abortOnDependencyFailure: false }], { returnTasks: true });
29
29
  await queue.complete(dParent);
30
30
  // Parent should be WaitingChildren
31
31
  const uParent = await queue.getTask(parent.id);