@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
@@ -2,7 +2,6 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from
2
2
  import { CancellationToken } from '../../cancellation/index.js';
3
3
  import { TaskQueueProvider, TaskStatus } from '../../task-queue/index.js';
4
4
  import { setupIntegrationTest } from '../../testing/index.js';
5
- import { timeout } from '../../utils/timing.js';
6
5
  describe('Queue Dependencies & Tree Tests', () => {
7
6
  let injector;
8
7
  let queue;
@@ -11,7 +10,7 @@ describe('Queue Dependencies & Tree Tests', () => {
11
10
  });
12
11
  beforeEach(() => {
13
12
  const queueProvider = injector.resolve(TaskQueueProvider);
14
- const queueName = `dep-queue-${Date.now()}-${Math.random()}`;
13
+ const queueName = `dep-queue-${crypto.randomUUID()}`;
15
14
  queue = queueProvider.get(queueName, {
16
15
  visibilityTimeout: 1000,
17
16
  });
@@ -22,15 +21,6 @@ describe('Queue Dependencies & Tree Tests', () => {
22
21
  afterAll(async () => {
23
22
  await injector?.dispose();
24
23
  });
25
- async function waitForStatus(id, status) {
26
- for (let i = 0; i < 20; i++) {
27
- const task = await queue.getTask(id);
28
- if (task?.status == status) {
29
- return;
30
- }
31
- await timeout(50);
32
- }
33
- }
34
24
  describe('Dependencies (DAG)', () => {
35
25
  it('should schedule a task only after dependency completes (completeAfter)', async () => {
36
26
  // 1. Prereq
@@ -63,7 +53,7 @@ describe('Queue Dependencies & Tree Tests', () => {
63
53
  // 3. Complete prereq
64
54
  const dequeued = await queue.dequeue({ types: ['prereq'] });
65
55
  await queue.complete(dequeued);
66
- await waitForStatus(dependent.id, TaskStatus.Pending);
56
+ await queue.waitForTasks([dependent.id], { statuses: [TaskStatus.Pending] });
67
57
  // 5. Dependent should be Pending (ready to run)
68
58
  const updatedDependent = await queue.getTask(dependent.id);
69
59
  expect(updatedDependent?.status).toBe(TaskStatus.Pending);
@@ -71,56 +61,165 @@ describe('Queue Dependencies & Tree Tests', () => {
71
61
  const d2 = await queue.dequeue({ types: ['dependent'] });
72
62
  expect(d2?.id).toBe(dependent.id);
73
63
  });
74
- it('should fail-fast if dependency fails', async () => {
64
+ it('should abort if dependency fails', async () => {
75
65
  const prereq = await queue.enqueue('prereq', {});
76
66
  const dependent = await queue.enqueue('dependent', {}, {
77
67
  scheduleAfter: [prereq.id],
78
- failFast: true
68
+ abortOnDependencyFailure: true,
79
69
  });
80
70
  const dequeued = await queue.dequeue({ types: ['prereq'] });
81
71
  // Fail fatally
82
72
  await queue.fail(dequeued, new Error('boom'), { fatal: true });
83
73
  await queue.waitForTasks([dependent.id]);
84
74
  const updatedDependent = await queue.getTask(dependent.id);
85
- expect(updatedDependent?.status).toBe(TaskStatus.Dead);
75
+ expect(updatedDependent?.status).toBe(TaskStatus.Skipped);
86
76
  expect(updatedDependent?.error?.code).toBe('DependencyFailed');
87
77
  });
78
+ it('should NOT overwrite terminal states during cancellation (abortOnDependencyFailure + complete)', async () => {
79
+ const dep = await queue.enqueue('dep', {});
80
+ // completeAfter allows 'main' to be Running while 'dep' is not finished
81
+ const main = await queue.enqueue('main', {}, { completeAfter: [dep.id], abortOnDependencyFailure: true });
82
+ const runningDep = await queue.dequeue();
83
+ expect(runningDep?.id).toBe(dep.id);
84
+ const runningMain = await queue.dequeue();
85
+ expect(runningMain?.id).toBe(main.id);
86
+ expect(runningMain?.status).toBe(TaskStatus.Running);
87
+ // Fail 'dep' -> this should make 'main' Skipped
88
+ await queue.fail(runningDep, new Error('Dependency failed'), { fatal: true });
89
+ const skippedMain = await queue.getTask(main.id);
90
+ expect(skippedMain?.status).toBe(TaskStatus.Skipped);
91
+ expect(skippedMain?.token).toBeNull();
92
+ // Call complete() on the old task reference (which still has the token)
93
+ await queue.complete(runningMain);
94
+ const finalMain = await queue.getTask(main.id);
95
+ expect(finalMain?.status).toBe(TaskStatus.Skipped);
96
+ });
97
+ it('should resolve dependencies across namespaces', async () => {
98
+ const queueProvider = injector.resolve(TaskQueueProvider);
99
+ const queueA = queueProvider.get(`QueueA-${crypto.randomUUID()}`);
100
+ const queueB = queueProvider.get(`QueueB-${crypto.randomUUID()}`);
101
+ const taskA = await queueA.enqueue('test', { value: 'A' });
102
+ const taskB = await queueB.enqueue('test', { value: 'B' }, { scheduleAfter: [{ id: taskA.id }] });
103
+ expect(taskB.status).toBe(TaskStatus.Waiting);
104
+ const dequeuedA = await queueA.dequeue();
105
+ await queueA.complete(dequeuedA);
106
+ queueB.notify();
107
+ await queueB.waitForTasks([taskB.id], { statuses: [TaskStatus.Pending], timeout: 10000 });
108
+ const updatedB = await queueB.getTask(taskB.id);
109
+ expect(updatedB?.status).toBe(TaskStatus.Pending);
110
+ await queueA.clear();
111
+ await queueB.clear();
112
+ });
113
+ it('should correctly skip dependent tasks across namespaces when abortOnDependencyFailure is triggered', async () => {
114
+ const queueProvider = injector.resolve(TaskQueueProvider);
115
+ const queueA = queueProvider.get(`QueueA-${crypto.randomUUID()}`);
116
+ const queueB = queueProvider.get(`QueueB-${crypto.randomUUID()}`);
117
+ const taskA = await queueA.enqueue('test', { value: 'A' });
118
+ const taskB = await queueB.enqueue('test', { value: 'B' }, {
119
+ abortOnDependencyFailure: true,
120
+ scheduleAfter: [{ id: taskA.id }],
121
+ });
122
+ const dequeuedA = await queueA.dequeue();
123
+ await queueA.fail(dequeuedA, new Error('Failed'), { fatal: true });
124
+ queueB.notify();
125
+ await queueB.waitForTasks([taskB.id], { statuses: [TaskStatus.Skipped], timeout: 10000 });
126
+ const updatedB = await queueB.getTask(taskB.id);
127
+ expect(updatedB?.status).toBe(TaskStatus.Skipped);
128
+ await queueA.clear();
129
+ await queueB.clear();
130
+ });
88
131
  it('should respect requiredStatuses', async () => {
89
132
  const prereq = await queue.enqueue('prereq', {});
90
133
  // Wait for prereq to be Dead or Completed
91
134
  const dependent = await queue.enqueue('dependent', {}, {
92
- scheduleAfter: [{ id: prereq.id, requiredStatuses: [TaskStatus.Dead, TaskStatus.Completed] }]
135
+ scheduleAfter: [{ id: prereq.id, requiredStatuses: [TaskStatus.Dead, TaskStatus.Completed] }],
93
136
  });
94
137
  // Fail fatally -> Dead
95
138
  const d1 = await queue.dequeue({ types: ['prereq'] });
96
139
  await queue.fail(d1, new Error('boom'), { fatal: true });
97
- await waitForStatus(dependent.id, TaskStatus.Pending);
140
+ await queue.waitForTasks([dependent.id], { statuses: [TaskStatus.Pending] });
98
141
  const updated = await queue.getTask(dependent.id);
99
142
  expect(updated?.status).toBe(TaskStatus.Pending);
100
143
  });
144
+ it('should schedule a task immediately if dependency is already completed (Bug 1)', async () => {
145
+ // 1. Enqueue and complete task A
146
+ const taskA = await queue.enqueue('taskA', {});
147
+ const dequeuedA = await queue.dequeue({ types: ['taskA'] });
148
+ await queue.complete(dequeuedA);
149
+ const finishedA = await queue.getTask(taskA.id);
150
+ expect(finishedA?.status).toBe(TaskStatus.Completed);
151
+ // 2. Enqueue task B with scheduleAfter: [A.id]
152
+ const taskB = await queue.enqueue('taskB', {}, {
153
+ scheduleAfter: [taskA.id],
154
+ });
155
+ // Wait for proactive resolution to trigger
156
+ await queue.waitForTasks([taskB.id], { statuses: [TaskStatus.Pending] });
157
+ const updatedB = await queue.getTask(taskB.id);
158
+ expect(updatedB?.status).toBe(TaskStatus.Pending);
159
+ });
160
+ it('should transition to Pending even if duplicate dependencies are specified (Bug 2)', async () => {
161
+ // 1. Enqueue task A
162
+ const taskA = await queue.enqueue('taskA', {});
163
+ // 2. Enqueue task B with duplicate scheduleAfter: [A.id, A.id]
164
+ const taskB = await queue.enqueue('taskB', {}, {
165
+ scheduleAfter: [taskA.id, taskA.id],
166
+ });
167
+ expect(taskB.status).toBe(TaskStatus.Waiting);
168
+ // 3. Complete task A
169
+ const dequeuedA = await queue.dequeue({ types: ['taskA'] });
170
+ await queue.complete(dequeuedA);
171
+ await queue.waitForTasks([taskB.id], { statuses: [TaskStatus.Pending] });
172
+ const updatedB = await queue.getTask(taskB.id);
173
+ expect(updatedB?.status).toBe(TaskStatus.Pending);
174
+ });
175
+ it('should NOT resurrect terminal tasks when calling rescheduleMany (Issue 1)', async () => {
176
+ const task = await queue.enqueue('test', { val: 1 });
177
+ const dequeued = await queue.dequeue();
178
+ await queue.complete(dequeued);
179
+ const completedTask = await queue.getTask(task.id);
180
+ expect(completedTask?.status).toBe(TaskStatus.Completed);
181
+ // Attempt to reschedule
182
+ await queue.rescheduleMany([task.id], Date.now());
183
+ const stillCompletedTask = await queue.getTask(task.id);
184
+ expect(stillCompletedTask?.status).toBe(TaskStatus.Completed);
185
+ expect(stillCompletedTask?.completeTimestamp).toBeDefined();
186
+ });
101
187
  });
102
188
  describe('Tree Operations & Cancellation', () => {
103
- it('should retrieve task tree and cancel hierarchy', async () => {
189
+ it('should retrieve task tree and cancel hierarchy (multiple children)', async () => {
104
190
  // Root
105
191
  const root = await queue.enqueue('root', {});
106
- // Child
107
- const child = await queue.enqueue('child', {}, { parentId: root.id });
192
+ // Children
193
+ const child1 = await queue.enqueue('child1', {}, { parentId: root.id });
194
+ const child2 = await queue.enqueue('child2', {}, { parentId: root.id });
108
195
  // Grandchild
109
- const grandchild = await queue.enqueue('grandchild', {}, { parentId: child.id });
196
+ const grandchild = await queue.enqueue('grandchild', {}, { parentId: child1.id });
110
197
  // Get Tree
111
198
  const tree = await queue.getTree(root.id);
112
- expect(tree.length).toBe(3);
199
+ expect(tree.length).toBe(4);
113
200
  expect(tree.map((t) => t.id)).toContain(grandchild.id);
201
+ expect(tree.map((t) => t.id)).toContain(child2.id);
114
202
  // Cancel Root
115
203
  await queue.cancel(root.id);
116
204
  // Verify all cancelled
117
205
  const tRoot = await queue.getTask(root.id);
118
- const tChild = await queue.getTask(child.id);
206
+ const tChild1 = await queue.getTask(child1.id);
207
+ const tChild2 = await queue.getTask(child2.id);
119
208
  const tGrand = await queue.getTask(grandchild.id);
120
209
  expect(tRoot?.status).toBe(TaskStatus.Cancelled);
121
- expect(tChild?.status).toBe(TaskStatus.Cancelled);
210
+ expect(tChild1?.status).toBe(TaskStatus.Cancelled);
211
+ expect(tChild2?.status).toBe(TaskStatus.Cancelled);
122
212
  expect(tGrand?.status).toBe(TaskStatus.Cancelled);
123
213
  });
214
+ it('should retrieve subtree via getTreeByQuery', async () => {
215
+ const root = await queue.enqueue('root', { val: 'root' });
216
+ const child = await queue.enqueue('child', { val: 'child' }, { parentId: root.id });
217
+ await queue.enqueue('other', { val: 'other' });
218
+ const tree = await queue.getTreeByQuery({ id: root.id });
219
+ expect(tree.length).toBe(2);
220
+ expect(tree.map(t => t.id)).toContain(root.id);
221
+ expect(tree.map(t => t.id)).toContain(child.id);
222
+ });
124
223
  it('should cancel many by tags', async () => {
125
224
  await queue.enqueue('t1', {}, { tags: ['group-a'] });
126
225
  await queue.enqueue('t2', {}, { tags: ['group-a'] });
@@ -133,6 +232,25 @@ describe('Queue Dependencies & Tree Tests', () => {
133
232
  const tasksB = await queue.getManyByTags('group-b');
134
233
  expect(tasksB[0]?.status).toBe(TaskStatus.Pending);
135
234
  });
235
+ it('should NOT resolve completed tasks as Cancelled when calling cancelMany (Issue 3)', async () => {
236
+ const taskA = await queue.enqueue('test', { name: 'A' });
237
+ const taskB = await queue.enqueue('test', { name: 'B' }, { scheduleAfter: [taskA.id], abortOnDependencyFailure: true });
238
+ const dequeuedA = await queue.dequeue();
239
+ await queue.complete(dequeuedA);
240
+ // B should have unresolvedScheduleDependencies = 0 and be Pending because A completed
241
+ const freshB = await queue.getTask(taskB.id);
242
+ expect(freshB?.unresolvedScheduleDependencies).toBe(0);
243
+ expect(freshB?.status).toBe(TaskStatus.Pending);
244
+ // Cancel A. Since A is Completed, it shouldn't be touched in DB.
245
+ // Importantly, it shouldn't trigger dependency resolution for B as 'Cancelled'.
246
+ // In the old code, this would have caused B to be transitioned to 'Skipped' because A was resolved as 'Cancelled' but B required 'Completed' (and abortOnDependencyFailure is true).
247
+ await queue.cancelMany([taskA.id]);
248
+ const stillFreshA = await queue.getTask(taskA.id);
249
+ expect(stillFreshA?.status).toBe(TaskStatus.Completed);
250
+ const stillFreshB = await queue.getTask(taskB.id);
251
+ expect(stillFreshB?.status).toBe(TaskStatus.Pending); // Should STILL be Pending
252
+ expect(stillFreshB?.unresolvedScheduleDependencies).toBe(0);
253
+ });
136
254
  });
137
255
  describe('Restart & Consumers', () => {
138
256
  it('should restart a dead/cancelled task', async () => {
@@ -162,7 +280,7 @@ describe('Queue Dependencies & Tree Tests', () => {
162
280
  await queue.enqueueMany([
163
281
  { type: 'b', data: { val: 1 } },
164
282
  { type: 'b', data: { val: 2 } },
165
- { type: 'b', data: { val: 3 } }
283
+ { type: 'b', data: { val: 3 } },
166
284
  ]);
167
285
  const token = new CancellationToken();
168
286
  const batchConsumer = queue.getBatchConsumer(2, token);
@@ -0,0 +1,125 @@
1
+ import { eq, inArray } from 'drizzle-orm';
2
+ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
3
+ import { Database } from '../../orm/server/index.js';
4
+ import { TaskQueueEnqueueBatch } from '../../task-queue/enqueue-batch.js';
5
+ import { TaskQueueProvider } from '../../task-queue/index.js';
6
+ import { taskDependency as taskDependencyTable } from '../../task-queue/postgres/schemas.js';
7
+ import { setupIntegrationTest } from '../../testing/index.js';
8
+ describe('TaskQueueEnqueueBatch', () => {
9
+ let injector;
10
+ let queue;
11
+ let database;
12
+ let queueProvider;
13
+ beforeAll(async () => {
14
+ ({ injector } = await setupIntegrationTest({ modules: { taskQueue: true } }));
15
+ queueProvider = injector.resolve(TaskQueueProvider);
16
+ queue = queueProvider.get(`batch-queue-${crypto.randomUUID()}`);
17
+ database = injector.resolve(Database);
18
+ });
19
+ afterAll(async () => {
20
+ await injector?.dispose();
21
+ });
22
+ it('should restore items if enqueueMany fails', async () => {
23
+ const batch = new TaskQueueEnqueueBatch(queue);
24
+ batch.add('t1', { val: 1 });
25
+ batch.add('t2', { val: 2 });
26
+ expect(batch.items.length).toBe(2);
27
+ // Mock enqueueMany to throw
28
+ vi.spyOn(queue, 'enqueueMany').mockRejectedValueOnce(new Error('db failure'));
29
+ await expect(batch.enqueue()).rejects.toThrow('db failure');
30
+ // Check items restored
31
+ expect(batch.items.length).toBe(2);
32
+ expect(batch.items[0].type).toBe('t1');
33
+ expect(batch.items[1].type).toBe('t2');
34
+ // Should work on second attempt (without mock)
35
+ vi.restoreAllMocks();
36
+ const tasks = await batch.enqueue({ returnTasks: true });
37
+ expect(batch.items.length).toBe(0);
38
+ const t1 = await queue.getTask(tasks[0].id);
39
+ expect(t1).toBeDefined();
40
+ expect(t1?.type).toBe('t1');
41
+ });
42
+ it('should work correctly with queue.batch()', async () => {
43
+ const batch = queue.batch();
44
+ batch.add('b1', { x: 1 });
45
+ batch.add('b2', { x: 2 });
46
+ const tasks = await batch.enqueue({ returnTasks: true });
47
+ expect(tasks.length).toBe(2);
48
+ expect(batch.items.length).toBe(0);
49
+ });
50
+ describe('enqueueMany Alignment and Dependencies', () => {
51
+ it('should correctly map dependencies when enqueuing a mix of tasks with and without idempotency keys', async () => {
52
+ const q = queueProvider.get(`q-${crypto.randomUUID()}`);
53
+ const items = [
54
+ { type: 'test', data: { value: '1' } },
55
+ { type: 'test', data: { value: '2' }, idempotencyKey: 'key-2' },
56
+ { type: 'test', data: { value: '3' }, scheduleAfter: [0] },
57
+ { type: 'test', data: { value: '4' }, idempotencyKey: 'key-4' },
58
+ { type: 'test', data: { value: '5' }, scheduleAfter: [0] },
59
+ ];
60
+ const tasks = await q.enqueueMany(items, { returnTasks: true });
61
+ expect(tasks).toHaveLength(5);
62
+ const task1Id = tasks[0].id;
63
+ const task3Id = tasks[2].id;
64
+ const task5Id = tasks[4].id;
65
+ const deps = await database
66
+ .select()
67
+ .from(taskDependencyTable)
68
+ .where(inArray(taskDependencyTable.taskId, [task3Id, task5Id]));
69
+ expect(deps).toHaveLength(2);
70
+ expect(deps.every(d => d.dependencyTaskId === task1Id)).toBe(true);
71
+ });
72
+ it('should correctly map dependencies when batch-enqueuing with pre-existing idempotency keys', async () => {
73
+ const q = queueProvider.get(`q-${crypto.randomUUID()}`);
74
+ const existingTask = await q.enqueue('test', { value: 'existing' }, { idempotencyKey: 'shared-key' });
75
+ const items = [
76
+ { type: 'test', data: { value: 'new' } },
77
+ { type: 'test', data: { value: 'duplicate' }, idempotencyKey: 'shared-key' },
78
+ { type: 'test', data: { value: 'dependent' }, scheduleAfter: [1] },
79
+ ];
80
+ const tasks = await q.enqueueMany(items, { returnTasks: true, replace: false });
81
+ expect(tasks[1].id).toBe(existingTask.id);
82
+ const dependentTaskId = tasks[2].id;
83
+ const deps = await database
84
+ .select()
85
+ .from(taskDependencyTable)
86
+ .where(eq(taskDependencyTable.taskId, dependentTaskId));
87
+ expect(deps).toHaveLength(1);
88
+ expect(deps[0].dependencyTaskId).toBe(existingTask.id);
89
+ });
90
+ it('should accurately increment parent unresolvedCompleteDependencies across multiple namespaces in a single batch', async () => {
91
+ const queueA = queueProvider.get(`QueueA-${crypto.randomUUID()}`);
92
+ const queueB = queueProvider.get(`QueueB-${crypto.randomUUID()}`);
93
+ const queueC = queueProvider.get(`QueueC-${crypto.randomUUID()}`);
94
+ const parentA = await queueA.enqueue('test', { value: 'parentA' });
95
+ const parentB = await queueB.enqueue('test', { value: 'parentB' });
96
+ await queueC.enqueueMany([
97
+ { type: 'test', data: { value: 'child1' }, parentId: parentA.id },
98
+ { type: 'test', data: { value: 'child2' }, parentId: parentA.id },
99
+ { type: 'test', data: { value: 'child3' }, parentId: parentB.id },
100
+ ]);
101
+ const upA = await queueA.getTask(parentA.id);
102
+ const upB = await queueB.getTask(parentB.id);
103
+ expect(upA?.unresolvedCompleteDependencies).toBe(2);
104
+ expect(upB?.unresolvedCompleteDependencies).toBe(1);
105
+ });
106
+ it('should correctly map dependencies when using object with index', async () => {
107
+ const q = queueProvider.get(`q-${crypto.randomUUID()}`);
108
+ const items = [
109
+ { type: 'test', data: { value: '1' } },
110
+ { type: 'test', data: { value: '2' }, scheduleAfter: [0] },
111
+ { type: 'test', data: { value: '3' }, scheduleAfter: [{ index: 0 }] },
112
+ ];
113
+ const tasks = await q.enqueueMany(items, { returnTasks: true });
114
+ const task1Id = tasks[0].id;
115
+ const task2Id = tasks[1].id;
116
+ const task3Id = tasks[2].id;
117
+ const deps = await database
118
+ .select()
119
+ .from(taskDependencyTable)
120
+ .where(inArray(taskDependencyTable.taskId, [task2Id, task3Id]));
121
+ expect(deps).toHaveLength(2);
122
+ expect(deps.every(d => d.dependencyTaskId === task1Id)).toBe(true);
123
+ });
124
+ });
125
+ });
@@ -1,16 +1,21 @@
1
1
  import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
2
+ import { Database } from '../../orm/server/index.js';
2
3
  import { TaskQueueProvider, TaskStatus } from '../../task-queue/index.js';
4
+ import { task as taskTable } from '../../task-queue/postgres/schemas.js';
3
5
  import { setupIntegrationTest } from '../../testing/index.js';
4
- import { timeout } from '../../utils/timing.js';
6
+ import { currentTimestamp } from '../../utils/date-time.js';
7
+ import { eq } from 'drizzle-orm';
5
8
  describe('Fan-Out Spawning', () => {
6
9
  let injector;
7
10
  let queue;
11
+ let database;
8
12
  beforeAll(async () => {
9
13
  ({ injector } = await setupIntegrationTest({ modules: { taskQueue: true } }));
14
+ database = injector.resolve(Database);
10
15
  });
11
16
  beforeEach(() => {
12
17
  const queueProvider = injector.resolve(TaskQueueProvider);
13
- const queueName = `fan-out-queue-${Date.now()}-${Math.random()}`;
18
+ const queueName = `fan-out-queue-${crypto.randomUUID()}`;
14
19
  queue = queueProvider.get(queueName, {
15
20
  visibilityTimeout: 1000,
16
21
  });
@@ -40,14 +45,50 @@ describe('Fan-Out Spawning', () => {
40
45
  const fParent = await queue.getTask(parent.id);
41
46
  expect(fParent?.status).toBe(TaskStatus.Completed);
42
47
  });
43
- it('should NOT transition parent to Waiting if waitForCompletion is false', async () => {
48
+ it('should NOT transition parent to Waiting if blockParent is false', async () => {
44
49
  const parent = await queue.enqueue('parent', {});
45
50
  const dParent = await queue.dequeue();
46
- // Spawn child with waitForCompletion: false
47
- await queue.enqueueMany([{ type: 'child', data: {}, parentId: parent.id, waitForCompletion: false }], { returnTasks: true });
51
+ // Spawn child with blockParent: false
52
+ await queue.enqueueMany([{ type: 'child', data: {}, parentId: parent.id, blockParent: false }], { returnTasks: true });
48
53
  await queue.complete(dParent);
49
54
  const uParent = await queue.getTask(parent.id);
50
55
  expect(uParent?.status).toBe(TaskStatus.Completed); // Finished immediately
51
56
  expect(uParent?.unresolvedCompleteDependencies).toBe(0);
52
57
  });
58
+ it('should transition parent from WaitingChildren to Completed exactly when the last child finishes', async () => {
59
+ const parent = await queue.enqueue('parent', {});
60
+ const c1 = await queue.enqueue('child', { val: 1 }, { parentId: parent.id });
61
+ const c2 = await queue.enqueue('child', { val: 2 }, { parentId: parent.id });
62
+ const dp = await queue.dequeue();
63
+ await queue.complete(dp);
64
+ const up1 = await queue.getTask(parent.id);
65
+ expect(up1?.status).toBe(TaskStatus.WaitingChildren);
66
+ const dc1 = await queue.dequeue();
67
+ await queue.complete(dc1);
68
+ const up2 = await queue.getTask(parent.id);
69
+ expect(up2?.status).toBe(TaskStatus.WaitingChildren);
70
+ const dc2 = await queue.dequeue();
71
+ await queue.complete(dc2);
72
+ const up3 = await queue.getTask(parent.id);
73
+ expect(up3?.status).toBe(TaskStatus.Completed);
74
+ });
75
+ it('should protect parent tasks from archival if orphaned child tasks still exist', async () => {
76
+ const queueProvider = injector.resolve(TaskQueueProvider);
77
+ const ephemeralQueue = queueProvider.get(`ephemeral-${crypto.randomUUID()}`, { retention: 0 });
78
+ const parent = await ephemeralQueue.enqueue('parent', {});
79
+ await ephemeralQueue.enqueue('child', {}, { parentId: parent.id });
80
+ // Manually complete parent while child is still pending
81
+ await database
82
+ .update(taskTable)
83
+ .set({
84
+ status: TaskStatus.Completed,
85
+ completeTimestamp: currentTimestamp() - 1000,
86
+ })
87
+ .where(eq(taskTable.id, parent.id));
88
+ await ephemeralQueue.maintenance();
89
+ const updatedParent = await ephemeralQueue.getTask(parent.id);
90
+ expect(updatedParent).toBeDefined(); // Should still be in main table
91
+ expect(updatedParent?.status).toBe(TaskStatus.Completed);
92
+ await ephemeralQueue.clear();
93
+ });
53
94
  });
@@ -1,16 +1,21 @@
1
+ import { eq } from 'drizzle-orm';
1
2
  import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
3
+ import { Database } from '../../orm/server/index.js';
2
4
  import { TaskQueueProvider, TaskStatus } from '../../task-queue/index.js';
5
+ import { taskDependency as taskDependencyTable } from '../../task-queue/postgres/schemas.js';
3
6
  import { setupIntegrationTest } from '../../testing/index.js';
4
7
  import { timeout } from '../../utils/timing.js';
5
8
  describe('Idempotent Replacement', () => {
6
9
  let injector;
7
10
  let queue;
11
+ let database;
8
12
  beforeAll(async () => {
9
13
  ({ injector } = await setupIntegrationTest({ modules: { taskQueue: true } }));
14
+ database = injector.resolve(Database);
10
15
  });
11
16
  beforeEach(() => {
12
17
  const queueProvider = injector.resolve(TaskQueueProvider);
13
- const queueName = `idempotent-queue-${Date.now()}-${Math.random()}`;
18
+ const queueName = `idempotent-queue-${crypto.randomUUID()}`;
14
19
  queue = queueProvider.get(queueName, {
15
20
  visibilityTimeout: 1000,
16
21
  });
@@ -58,4 +63,52 @@ describe('Idempotent Replacement', () => {
58
63
  const fTask = await queue.getTask(taskV1.id);
59
64
  expect(fTask?.status).toBe(TaskStatus.Pending);
60
65
  });
66
+ it('should update parentId on replacement', async () => {
67
+ const parent1 = await queue.enqueue('parent', { id: 1 });
68
+ const parent2 = await queue.enqueue('parent', { id: 2 });
69
+ const idempotencyKey = 'task-1';
70
+ const taskV1 = await queue.enqueue('child', {}, { idempotencyKey, parentId: parent1.id });
71
+ expect(taskV1.parentId).toBe(parent1.id);
72
+ // Replace with new parent
73
+ const taskV2 = await queue.enqueue('child', {}, { idempotencyKey, parentId: parent2.id, replace: true });
74
+ expect(taskV2.id).toBe(taskV1.id);
75
+ expect(taskV2.parentId).toBe(parent2.id);
76
+ });
77
+ it('should NOT corrupt state on idempotent enqueues (replace: false)', async () => {
78
+ const dep = await queue.enqueue('dep', {});
79
+ const idempotencyKey = 'task-2';
80
+ // Initial enqueue without dependency
81
+ const taskV1 = await queue.enqueue('main', {}, { idempotencyKey });
82
+ expect(taskV1.status).toBe(TaskStatus.Pending);
83
+ // Deduplicated enqueue with dependency
84
+ const taskV2 = await queue.enqueue('main', {}, { idempotencyKey, scheduleAfter: [dep.id], replace: false });
85
+ expect(taskV2.id).toBe(taskV1.id);
86
+ const task = await queue.getTask(taskV2.id);
87
+ expect(task?.status).toBe(TaskStatus.Pending);
88
+ expect(task?.unresolvedScheduleDependencies).toBe(0);
89
+ });
90
+ it('should NOT orphan children on task replacement', async () => {
91
+ const idempotencyKey = 'parent-task';
92
+ const parent = await queue.enqueue('parent', {}, { idempotencyKey });
93
+ // Enqueue child
94
+ await queue.enqueue('child', {}, { parentId: parent.id });
95
+ const parentAfterChild = await queue.getTask(parent.id);
96
+ expect(parentAfterChild?.unresolvedCompleteDependencies).toBe(1);
97
+ // Replace parent
98
+ const replacedParent = await queue.enqueue('parent', { replaced: true }, { idempotencyKey, replace: true });
99
+ expect(replacedParent.unresolvedCompleteDependencies).toBe(1);
100
+ });
101
+ it('should clear and recreate dependencies when enqueuing existing tasks with replace: true', async () => {
102
+ const dep1 = await queue.enqueue('test', { value: 'dep1' });
103
+ const dep2 = await queue.enqueue('test', { value: 'dep2' });
104
+ const task = await queue.enqueue('test', { value: 'main' }, { idempotencyKey: 'main-key', scheduleAfter: [dep1.id] });
105
+ // Re-enqueue with replace: true and different dependency
106
+ await queue.enqueue('test', { value: 'main-replaced' }, { idempotencyKey: 'main-key', replace: true, scheduleAfter: [dep2.id] });
107
+ const deps = await database
108
+ .select()
109
+ .from(taskDependencyTable)
110
+ .where(eq(taskDependencyTable.taskId, task.id));
111
+ expect(deps).toHaveLength(1);
112
+ expect(deps[0].dependencyTaskId).toBe(dep2.id);
113
+ });
61
114
  });
@@ -1,5 +1,5 @@
1
1
  import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
2
- import { TaskQueueProvider } from '../../task-queue/index.js';
2
+ import { TaskQueueProvider, TaskStatus } from '../../task-queue/index.js';
3
3
  import { setupIntegrationTest } from '../../testing/index.js';
4
4
  describe('Missing Idempotent Tasks Bug', () => {
5
5
  let injector;
@@ -9,7 +9,7 @@ describe('Missing Idempotent Tasks Bug', () => {
9
9
  });
10
10
  beforeEach(() => {
11
11
  const queueProvider = injector.resolve(TaskQueueProvider);
12
- const queueName = `missing-idem-queue-${Date.now()}-${Math.random()}`;
12
+ const queueName = `missing-idem-queue-${crypto.randomUUID()}`;
13
13
  queue = queueProvider.get(queueName, {
14
14
  visibilityTimeout: 1000,
15
15
  });
@@ -24,15 +24,16 @@ describe('Missing Idempotent Tasks Bug', () => {
24
24
  const key = 'idem-key';
25
25
  const dep = await queue.enqueue('dep', {});
26
26
  // 1. Initial enqueue
27
- await queue.enqueue('main', {}, { idempotencyKey: key });
27
+ const task1 = await queue.enqueue('main', {}, { idempotencyKey: key });
28
28
  // 2. Second enqueue with same key and a dependency, returnTasks: false
29
29
  // This should NOT crash.
30
- // It should ideally either:
31
- // a) Ignore the new dependency because it's a duplicate and replace=false
32
- // b) Add the dependency to the existing task (though replace=false usually means ignore)
33
- // The current bug is that it CRASHES with TypeError.
30
+ // replace=false means we should ignore the new data AND the new dependency for the existing task.
34
31
  await expect(queue.enqueueMany([
35
- { type: 'main', data: {}, idempotencyKey: key, scheduleAfter: [dep.id] }
32
+ { type: 'main', data: { new: 'data' }, idempotencyKey: key, scheduleAfter: [dep.id] }
36
33
  ], { replace: false, returnTasks: false })).resolves.toBeUndefined();
34
+ const updatedTask = await queue.getTask(task1.id);
35
+ expect(updatedTask?.data).toEqual({}); // Data should not have changed
36
+ expect(updatedTask?.unresolvedScheduleDependencies).toBe(0); // Dependency should not have been added
37
+ expect(updatedTask?.status).toBe(TaskStatus.Pending); // Should still be Pending, not Waiting
37
38
  });
38
39
  });