@tstdl/base 0.93.140 → 0.93.142

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 (123) hide show
  1. package/application/application.d.ts +1 -1
  2. package/application/application.js +1 -1
  3. package/application/providers.d.ts +20 -2
  4. package/application/providers.js +34 -7
  5. package/audit/module.d.ts +5 -0
  6. package/audit/module.js +9 -1
  7. package/authentication/client/authentication.service.d.ts +1 -0
  8. package/authentication/client/authentication.service.js +3 -2
  9. package/authentication/server/module.d.ts +5 -0
  10. package/authentication/server/module.js +9 -1
  11. package/authentication/tests/authentication.api-controller.test.js +1 -1
  12. package/authentication/tests/authentication.api-request-token.provider.test.js +1 -1
  13. package/authentication/tests/authentication.client-service.test.js +1 -1
  14. package/circuit-breaker/circuit-breaker.d.ts +6 -4
  15. package/circuit-breaker/postgres/circuit-breaker.d.ts +1 -0
  16. package/circuit-breaker/postgres/circuit-breaker.js +8 -5
  17. package/circuit-breaker/postgres/module.d.ts +1 -0
  18. package/circuit-breaker/postgres/module.js +5 -1
  19. package/circuit-breaker/tests/circuit-breaker.test.js +20 -0
  20. package/document-management/server/configure.js +5 -1
  21. package/document-management/server/module.d.ts +1 -1
  22. package/document-management/server/module.js +1 -1
  23. package/document-management/server/services/document-management-ancillary.service.js +1 -1
  24. package/document-management/tests/ai-config-hierarchy.test.js +0 -5
  25. package/document-management/tests/document-management-ai-overrides.test.js +0 -1
  26. package/document-management/tests/document-validation-ai-overrides.test.js +0 -1
  27. package/examples/document-management/main.d.ts +1 -0
  28. package/examples/document-management/main.js +14 -11
  29. package/key-value-store/postgres/module.d.ts +1 -0
  30. package/key-value-store/postgres/module.js +5 -1
  31. package/lock/postgres/module.d.ts +1 -0
  32. package/lock/postgres/module.js +5 -1
  33. package/mail/module.d.ts +5 -1
  34. package/mail/module.js +11 -6
  35. package/module/modules/web-server.module.js +2 -3
  36. package/notification/server/module.d.ts +1 -0
  37. package/notification/server/module.js +5 -1
  38. package/notification/tests/notification-api.test.js +5 -1
  39. package/notification/tests/notification-flow.test.js +8 -5
  40. package/orm/decorators.d.ts +22 -5
  41. package/orm/decorators.js +10 -1
  42. package/orm/server/bootstrap.d.ts +11 -0
  43. package/orm/server/bootstrap.js +31 -0
  44. package/orm/server/drizzle/schema-converter.d.ts +3 -1
  45. package/orm/server/drizzle/schema-converter.js +85 -56
  46. package/orm/server/encryption.d.ts +0 -1
  47. package/orm/server/encryption.js +1 -4
  48. package/orm/server/extension.d.ts +14 -0
  49. package/orm/server/extension.js +27 -0
  50. package/orm/server/index.d.ts +3 -6
  51. package/orm/server/index.js +3 -6
  52. package/orm/server/migration.d.ts +18 -0
  53. package/orm/server/migration.js +58 -0
  54. package/orm/server/repository.d.ts +2 -1
  55. package/orm/server/repository.js +19 -9
  56. package/orm/server/transaction.d.ts +6 -10
  57. package/orm/server/transaction.js +25 -26
  58. package/orm/server/transactional.js +3 -3
  59. package/orm/tests/database-extension.test.js +63 -0
  60. package/orm/tests/database-migration.test.js +83 -0
  61. package/orm/tests/encryption.test.js +3 -4
  62. package/orm/tests/repository-compound-primary-key.test.d.ts +2 -0
  63. package/orm/tests/repository-compound-primary-key.test.js +234 -0
  64. package/orm/tests/schema-generation.test.d.ts +1 -0
  65. package/orm/tests/schema-generation.test.js +52 -5
  66. package/orm/utils.d.ts +17 -2
  67. package/orm/utils.js +49 -1
  68. package/package.json +5 -4
  69. package/rate-limit/postgres/module.d.ts +1 -0
  70. package/rate-limit/postgres/module.js +5 -1
  71. package/reflection/decorator-data.js +11 -12
  72. package/task-queue/README.md +2 -10
  73. package/task-queue/postgres/drizzle/0000_great_gwen_stacy.sql +84 -0
  74. package/task-queue/postgres/drizzle/meta/0000_snapshot.json +250 -89
  75. package/task-queue/postgres/drizzle/meta/_journal.json +2 -2
  76. package/task-queue/postgres/module.d.ts +1 -0
  77. package/task-queue/postgres/module.js +6 -1
  78. package/task-queue/postgres/schemas.d.ts +15 -6
  79. package/task-queue/postgres/schemas.js +4 -3
  80. package/task-queue/postgres/task-queue.d.ts +18 -15
  81. package/task-queue/postgres/task-queue.js +797 -499
  82. package/task-queue/postgres/task.model.d.ts +20 -9
  83. package/task-queue/postgres/task.model.js +65 -39
  84. package/task-queue/task-context.d.ts +12 -7
  85. package/task-queue/task-context.js +8 -6
  86. package/task-queue/task-queue.d.ts +364 -43
  87. package/task-queue/task-queue.js +153 -41
  88. package/task-queue/tests/coverage-branch.test.d.ts +1 -0
  89. package/task-queue/tests/coverage-branch.test.js +395 -0
  90. package/task-queue/tests/coverage-enhancement.test.d.ts +1 -0
  91. package/task-queue/tests/coverage-enhancement.test.js +150 -0
  92. package/task-queue/tests/dag.test.d.ts +1 -0
  93. package/task-queue/tests/dag.test.js +188 -0
  94. package/task-queue/tests/dependencies.test.js +165 -47
  95. package/task-queue/tests/enqueue-batch.test.d.ts +1 -0
  96. package/task-queue/tests/enqueue-batch.test.js +125 -0
  97. package/task-queue/tests/fan-out-spawning.test.d.ts +1 -0
  98. package/task-queue/tests/fan-out-spawning.test.js +94 -0
  99. package/task-queue/tests/idempotent-replacement.test.d.ts +1 -0
  100. package/task-queue/tests/idempotent-replacement.test.js +114 -0
  101. package/task-queue/tests/missing-idempotent-tasks.test.d.ts +1 -0
  102. package/task-queue/tests/missing-idempotent-tasks.test.js +39 -0
  103. package/task-queue/tests/queue.test.js +294 -49
  104. package/task-queue/tests/shutdown.test.d.ts +1 -0
  105. package/task-queue/tests/shutdown.test.js +41 -0
  106. package/task-queue/tests/transactions.test.d.ts +1 -0
  107. package/task-queue/tests/transactions.test.js +47 -0
  108. package/task-queue/tests/worker.test.js +63 -15
  109. package/task-queue/tests/zombie-parent.test.d.ts +1 -0
  110. package/task-queue/tests/zombie-parent.test.js +45 -0
  111. package/task-queue/tests/zombie-recovery.test.d.ts +1 -0
  112. package/task-queue/tests/zombie-recovery.test.js +51 -0
  113. package/test5.js +5 -5
  114. package/testing/integration-setup.d.ts +4 -4
  115. package/testing/integration-setup.js +56 -29
  116. package/text/localization.service.js +2 -2
  117. package/utils/file-reader.js +1 -2
  118. package/utils/timing.d.ts +2 -2
  119. package/task-queue/postgres/drizzle/0000_simple_invisible_woman.sql +0 -74
  120. package/task-queue/tests/complex.test.js +0 -306
  121. package/task-queue/tests/extensive-dependencies.test.js +0 -234
  122. /package/{task-queue/tests/complex.test.d.ts → orm/tests/database-extension.test.d.ts} +0 -0
  123. /package/{task-queue/tests/extensive-dependencies.test.d.ts → orm/tests/database-migration.test.d.ts} +0 -0
@@ -1,14 +1,17 @@
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';
7
10
  import { timeout } from '../../utils/timing.js';
11
+ import { isDefined } from '../../utils/type-guards.js';
8
12
  describe('Queue Integration Tests', () => {
9
13
  let injector;
10
14
  let queue;
11
- const queueName = `test-queue-${Date.now()}`;
12
15
  // Helper to verify state in DB
13
16
  async function assertTaskStatus(id, state, message) {
14
17
  const task = await queue.getTask(id);
@@ -16,10 +19,10 @@ describe('Queue Integration Tests', () => {
16
19
  }
17
20
  beforeAll(async () => {
18
21
  ({ injector } = await setupIntegrationTest({ modules: { taskQueue: true } }));
19
- // Use the QueueProvider to Create/Retrieve the queue.
20
- // This is the cleanest way to get a configured queue instance without
21
- // messing with `injectArgument` manually in the test body.
22
+ });
23
+ beforeEach(async () => {
22
24
  const queueProvider = injector.resolve(TaskQueueProvider);
25
+ const queueName = `test-queue-${crypto.randomUUID()}`;
23
26
  queue = queueProvider.get(queueName, {
24
27
  visibilityTimeout: 5000,
25
28
  retryDelayMinimum: 100, // Fast retries for testing
@@ -32,7 +35,9 @@ describe('Queue Integration Tests', () => {
32
35
  });
33
36
  afterAll(async () => {
34
37
  try {
35
- await queue.clear();
38
+ if (isDefined(queue)) {
39
+ await queue.clear();
40
+ }
36
41
  await injector.dispose();
37
42
  }
38
43
  catch (error) {
@@ -68,7 +73,7 @@ describe('Queue Integration Tests', () => {
68
73
  await queue.complete(second);
69
74
  });
70
75
  it('Deduplication (Idempotency Keys)', async () => {
71
- const key = `unique-${Date.now()}`;
76
+ const key = `unique-${crypto.randomUUID()}`;
72
77
  // 1. Initial Insert
73
78
  const t1 = await queue.enqueue('test', { value: 'original' }, { idempotencyKey: key });
74
79
  // 2. Default Strategy (replace: false): Should return existing task, ignore new data
@@ -76,9 +81,9 @@ describe('Queue Integration Tests', () => {
76
81
  expect(t2.id, 'Same ID if not replaced').toBe(t1.id);
77
82
  const check1 = await queue.getTask(t1.id);
78
83
  expect((check1?.data)['value']).toBe('original');
79
- // 3. Replace Strategy: Should replace existing task with new data AND new ID
84
+ // 3. Replace Strategy: Should replace existing task with new data. ID stays the same to avoid foreign key violations.
80
85
  const t3 = await queue.enqueueMany([{ type: 'test', data: { value: 'updated' }, idempotencyKey: key }], { replace: true, returnTasks: true });
81
- expect(t3[0].id, 'New ID if replaced').not.toBe(t1.id);
86
+ expect(t3[0].id, 'Same ID if replaced').toBe(t1.id);
82
87
  // New task should have new data
83
88
  const checkNew = await queue.getTask(t3[0].id);
84
89
  expect((checkNew?.data)['value']).toBe('updated');
@@ -90,6 +95,8 @@ describe('Queue Integration Tests', () => {
90
95
  const attempt1 = await queue.dequeue();
91
96
  expect(attempt1?.id).toBe(task.id);
92
97
  await queue.fail(attempt1, { message: 'oops' });
98
+ // Verify Retrying status
99
+ await assertTaskStatus(task.id, TaskStatus.Retrying, 'Task enters Retrying status after failure');
93
100
  // Force reschedule to now to bypass retryDelay
94
101
  await queue.reschedule(task.id, currentTimestamp());
95
102
  // Try 2
@@ -129,12 +136,14 @@ describe('Queue Integration Tests', () => {
129
136
  describe('PostgresQueue (Distributed Task Orchestration)', () => {
130
137
  let injector;
131
138
  let queue;
139
+ let database;
132
140
  beforeAll(async () => {
133
141
  ({ injector } = await setupIntegrationTest({ modules: { taskQueue: true } }));
142
+ database = injector.resolve(Database);
134
143
  });
135
144
  beforeEach(() => {
136
145
  const queueProvider = injector.resolve(TaskQueueProvider);
137
- const queueName = `pg-test-queue-${Date.now()}-${Math.random()}`;
146
+ const queueName = `pg-test-queue-${crypto.randomUUID()}`;
138
147
  queue = queueProvider.get(queueName, {
139
148
  visibilityTimeout: 50, // Short timeout for testing
140
149
  retryDelayMinimum: 50,
@@ -172,11 +181,96 @@ describe('PostgresQueue (Distributed Task Orchestration)', () => {
172
181
  const dequeued = await queue.dequeue();
173
182
  await queue.fail(dequeued, new Error('temp failure'));
174
183
  const updated = await queue.getTask(task.id);
175
- expect(updated?.status).toBe(TaskStatus.Pending);
184
+ expect(updated?.status).toBe(TaskStatus.Retrying);
176
185
  expect(updated?.tries).toBe(1);
177
186
  expect(updated?.error).toBeDefined();
178
187
  });
179
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, waitForCompletion: 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
+ waitForCompletion: 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 waitForCompletion: 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, waitForCompletion: true });
270
+ const updatedParent = await queue.getTask(parent.id);
271
+ expect(updatedParent?.unresolvedCompleteDependencies).toBe(1);
272
+ });
273
+ });
180
274
  describe('Priority and Scheduling', () => {
181
275
  it('should dequeue tasks in priority order (lower number first)', async () => {
182
276
  await queue.enqueue('foo', { foo: 'low' }, { priority: 2000 });
@@ -200,24 +294,18 @@ describe('PostgresQueue (Distributed Task Orchestration)', () => {
200
294
  });
201
295
  });
202
296
  describe('Concurrency Control', () => {
203
- it('should respect global concurrency limits', async () => {
204
- const queueProvider = injector.resolve(TaskQueueProvider);
205
- const limitedQueue = queueProvider.get(`limit-test-${Date.now()}`, { globalConcurrency: 2 });
206
- await limitedQueue.enqueueMany([
207
- { type: 'foo', data: { foo: '1' } },
208
- { type: 'foo', data: { foo: '2' } },
209
- { type: 'foo', data: { foo: '3' } },
210
- ]);
211
- const t1 = await limitedQueue.dequeue();
212
- const t2 = await limitedQueue.dequeue();
213
- const t3 = await limitedQueue.dequeue();
214
- expect(t1).toBeDefined();
215
- expect(t2).toBeDefined();
216
- expect(t3).toBeUndefined(); // Limit reached
217
- await limitedQueue.complete(t1);
218
- const t3Retry = await limitedQueue.dequeue();
219
- expect(t3Retry).toBeDefined(); // Slot opened
220
- 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();
221
309
  });
222
310
  });
223
311
  describe('Circuit Breaker', () => {
@@ -255,21 +343,21 @@ describe('PostgresQueue (Distributed Task Orchestration)', () => {
255
343
  await timeout(100);
256
344
  await queue.maintenance();
257
345
  const recovered = await queue.getTask(task.id);
258
- expect(recovered?.status).toBe(TaskStatus.Pending);
346
+ expect(recovered?.status).toBe(TaskStatus.Retrying);
259
347
  expect(recovered?.tries).toBe(1);
260
348
  expect(recovered?.token).toBeNull();
261
349
  });
262
350
  it('should fail tasks that exceed Hard Execution Timeout via prune', async () => {
263
351
  // Re-configure queue with very short execution timeout
264
352
  const queueProvider = injector.resolve(TaskQueueProvider);
265
- const shortQueue = queueProvider.get(`prune-test-${Date.now()}`, { maxExecutionTime: 50 });
353
+ const shortQueue = queueProvider.get(`prune-test-${crypto.randomUUID()}`, { maxExecutionTime: 50 });
266
354
  const task = await shortQueue.enqueue('foo', { foo: 'long-running' });
267
355
  await shortQueue.dequeue();
268
356
  await timeout(75);
269
357
  await shortQueue.maintenance();
270
358
  const updated = await shortQueue.getTask(task.id);
271
- expect(updated?.status).toBe(TaskStatus.Dead);
272
- expect(updated?.error?.message).toContain('Hard Execution Timeout');
359
+ expect(updated?.status).toBe(TaskStatus.TimedOut);
360
+ expect(updated?.error?.code).toBe('MaxTimeExceeded');
273
361
  await shortQueue.clear();
274
362
  });
275
363
  it('should touch a task to extend token', async () => {
@@ -278,7 +366,7 @@ describe('PostgresQueue (Distributed Task Orchestration)', () => {
278
366
  const initialLock = dequeued.visibilityDeadline;
279
367
  await timeout(20);
280
368
  const touched = await queue.touch(dequeued);
281
- expect(touched?.visibilityDeadline > initialLock).toBe(true);
369
+ expect(touched.visibilityDeadline > initialLock).toBe(true);
282
370
  });
283
371
  it('should prevent touching if token is lost (stolen by another worker)', async () => {
284
372
  await queue.enqueue('foo', { foo: 'work' });
@@ -292,6 +380,46 @@ describe('PostgresQueue (Distributed Task Orchestration)', () => {
292
380
  const touchResult = await queue.touch(dequeued);
293
381
  expect(touchResult).toBeUndefined();
294
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
+ });
295
423
  });
296
424
  describe('Batch Operations', () => {
297
425
  it('should complete many tasks efficiently', async () => {
@@ -307,6 +435,62 @@ describe('PostgresQueue (Distributed Task Orchestration)', () => {
307
435
  expect(t1?.status).toBe(TaskStatus.Completed);
308
436
  expect(t2?.status).toBe(TaskStatus.Completed);
309
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
+ });
310
494
  });
311
495
  describe('Rescheduling', () => {
312
496
  it('should reschedule and refund tries if running', async () => {
@@ -320,6 +504,15 @@ describe('PostgresQueue (Distributed Task Orchestration)', () => {
320
504
  expect(updated?.tries).toBe(0); // Refunded
321
505
  expect(updated?.scheduleTimestamp).toBe(inFuture);
322
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
+ });
323
516
  });
324
517
  describe('TaskContext (Worker DX)', () => {
325
518
  it('checkpoint() should update progress and handle token loss', async () => {
@@ -419,27 +612,79 @@ describe('PostgresQueue (Distributed Task Orchestration)', () => {
419
612
  expect(result.cancelled).toBe(false);
420
613
  });
421
614
  it('should wait for parent task to reach finalized state after child completion', async () => {
422
- const childTag = `child-of-${crypto.randomUUID()}`;
423
- const parent = await queue.enqueue('test', { value: 'parent' }, { completeAfterTags: [childTag] });
424
- const dParent = await queue.dequeue();
615
+ const parent = await queue.enqueue('parent', { value: 'parent' });
616
+ const dParent = await queue.dequeue({ types: ['parent'] });
617
+ expect(dParent).toBeDefined();
425
618
  // Spawn a child
426
- await queue.enqueueMany([{ type: 'test', data: { value: 'child' }, parentId: parent.id, tags: [childTag] }], { transaction: undefined });
427
- // Complete parent (it will move to Waiting because of completeAfterTags)
428
- await queue.complete(dParent);
429
- const checkParent = await queue.getTask(parent.id);
430
- expect(checkParent?.status).toBe(TaskStatus.Waiting);
619
+ const [child] = await queue.enqueueMany([{ type: 'child', data: { value: 'child' }, parentId: parent.id, completeAfter: [] }], { returnTasks: true });
620
+ expect(child).toBeDefined();
621
+ // Re-enqueuing with dependency:
622
+ const parentWithDep = await queue.enqueue('parent-dep', { value: 'parent' }, { completeAfter: [child.id] });
623
+ let dParent2;
624
+ for (let i = 0; i < 10; i++) {
625
+ dParent2 = await queue.dequeue({ types: ['parent-dep'] });
626
+ if (dParent2)
627
+ break;
628
+ await timeout(50);
629
+ }
630
+ expect(dParent2?.id).toBe(parentWithDep.id);
631
+ // Complete parent (it will move to Waiting because of completeAfter)
632
+ await queue.complete(dParent2);
633
+ const checkParent = await queue.getTask(parentWithDep.id);
634
+ expect(checkParent?.status).toBe(TaskStatus.WaitingChildren);
431
635
  void (async () => {
432
636
  await timeout(100);
433
- const dChild = await queue.dequeue();
434
- if (dChild) {
435
- await queue.complete(dChild);
436
- }
437
- // Manual fan-in processing since we are in a test environment and might want immediate result
438
- await queue.processPendingFanIn();
637
+ const dChild = await queue.dequeue({ types: ['child'] });
638
+ await queue.complete(dChild);
439
639
  })();
440
- await queue.waitForTasks([parent.id], { timeout: 2000 });
441
- const finalParent = await queue.getTask(parent.id);
640
+ await queue.waitForTasks([parentWithDep.id], { timeout: 2000 });
641
+ const finalParent = await queue.getTask(parentWithDep.id);
442
642
  expect(finalParent?.status).toBe(TaskStatus.Completed);
443
643
  });
444
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
+ });
445
690
  });
@@ -0,0 +1 @@
1
+ export {};
@@ -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
+ });