@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
@@ -0,0 +1,188 @@
1
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
2
+ import { TaskQueueProvider, TaskStatus } from '../../task-queue/index.js';
3
+ import { setupIntegrationTest } from '../../testing/index.js';
4
+ import { timeout } from '../../utils/timing.js';
5
+ describe('Extensive Task Queue Dependency Tests', () => {
6
+ let injector;
7
+ let queue;
8
+ beforeAll(async () => {
9
+ ({ injector } = await setupIntegrationTest({ modules: { taskQueue: true } }));
10
+ });
11
+ beforeEach(() => {
12
+ const queueProvider = injector.resolve(TaskQueueProvider);
13
+ const queueName = `extensive-dep-queue-${crypto.randomUUID()}`;
14
+ queue = queueProvider.get(queueName, {
15
+ visibilityTimeout: 1000,
16
+ });
17
+ });
18
+ afterEach(async () => {
19
+ await queue.clear();
20
+ });
21
+ afterAll(async () => {
22
+ await injector?.dispose();
23
+ });
24
+ async function completeTask(type) {
25
+ const dequeued = await queue.dequeue({ types: [type] });
26
+ if (!dequeued)
27
+ throw new Error(`Could not dequeue task of type ${type}`);
28
+ await queue.complete(dequeued);
29
+ }
30
+ it('should handle complex mixed chain: A -> (B -> D, C -> E) -> F', async () => {
31
+ // A is the root
32
+ const taskA = await queue.enqueue('A', {});
33
+ // B and C depend on A
34
+ const taskB = await queue.enqueue('B', {}, { scheduleAfter: [taskA.id] });
35
+ const taskC = await queue.enqueue('C', {}, { scheduleAfter: [taskA.id] });
36
+ // D depends on B, E depends on C
37
+ const taskD = await queue.enqueue('D', {}, { scheduleAfter: [taskB.id] });
38
+ const taskE = await queue.enqueue('E', {}, { scheduleAfter: [taskC.id] });
39
+ // F depends on D and E
40
+ const taskF = await queue.enqueue('F', {}, { scheduleAfter: [taskD.id, taskE.id] });
41
+ expect(taskA.status).toBe(TaskStatus.Pending);
42
+ expect(taskB.status).toBe(TaskStatus.Waiting);
43
+ expect(taskC.status).toBe(TaskStatus.Waiting);
44
+ expect(taskD.status).toBe(TaskStatus.Waiting);
45
+ expect(taskE.status).toBe(TaskStatus.Waiting);
46
+ expect(taskF.status).toBe(TaskStatus.Waiting);
47
+ // 1. Complete A
48
+ await completeTask('A');
49
+ await queue.waitForTasks([taskB.id, taskC.id], { statuses: [TaskStatus.Pending] });
50
+ // 2. Complete B
51
+ await completeTask('B');
52
+ await queue.waitForTasks([taskD.id], { statuses: [TaskStatus.Pending] });
53
+ // 3. Complete C
54
+ await completeTask('C');
55
+ await queue.waitForTasks([taskE.id], { statuses: [TaskStatus.Pending] });
56
+ // F should still be waiting
57
+ const uF = await queue.getTask(taskF.id);
58
+ expect(uF?.status).toBe(TaskStatus.Waiting);
59
+ // 4. Complete D
60
+ await completeTask('D');
61
+ // F still waiting for E
62
+ expect((await queue.getTask(taskF.id))?.status).toBe(TaskStatus.Waiting);
63
+ // 5. Complete E
64
+ await completeTask('E');
65
+ await queue.waitForTasks([taskF.id], { statuses: [TaskStatus.Pending] });
66
+ });
67
+ it('should respect failFast = false (continue other branches)', async () => {
68
+ const taskA = await queue.enqueue('A', {});
69
+ const taskB = await queue.enqueue('B', {}, { scheduleAfter: [taskA.id], failFast: false });
70
+ const taskC = await queue.enqueue('C', {}); // Independent
71
+ const dA = await queue.dequeue({ types: ['A'] });
72
+ await queue.fail(dA, new Error('fatal'), { fatal: true });
73
+ // taskB should stay Waiting (default requiredStatus is Completed)
74
+ // If failFast is false, it should transition to Pending once the dependency is terminal, even if it failed.
75
+ await timeout(100);
76
+ const uB = await queue.getTask(taskB.id);
77
+ expect(uB?.status).toBe(TaskStatus.Pending);
78
+ const dC = await queue.dequeue({ types: ['C'] });
79
+ expect(dC?.id).toBe(taskC.id);
80
+ });
81
+ it('should handle priority aging', async () => {
82
+ const agingQueue = injector.resolve(TaskQueueProvider).get(`aging-${crypto.randomUUID()}`, {
83
+ priorityAgingInterval: 50,
84
+ priorityAgingStep: 10,
85
+ });
86
+ const t1 = await agingQueue.enqueue('low', {}, { priority: 2000 });
87
+ const t2 = await agingQueue.enqueue('high', {}, { priority: 1000 });
88
+ // Wait for aging to trigger
89
+ await timeout(100);
90
+ await agingQueue.maintenance();
91
+ const u1 = await agingQueue.getTask(t1.id);
92
+ const u2 = await agingQueue.getTask(t2.id);
93
+ expect(u1.priority).toBeLessThan(2000);
94
+ expect(u2.priority).toBeLessThan(1000);
95
+ });
96
+ it('should handle rate limiting', async () => {
97
+ const rateQueue = injector.resolve(TaskQueueProvider).get(`rate-${crypto.randomUUID()}`, {
98
+ rateLimit: 5,
99
+ rateInterval: 200,
100
+ });
101
+ // Limit is 5 per 200ms
102
+ for (let i = 0; i < 10; i++) {
103
+ await rateQueue.enqueue('task', { i });
104
+ }
105
+ const batch1 = await rateQueue.dequeueMany(10);
106
+ expect(batch1.length).toBe(5);
107
+ await timeout(500);
108
+ const batch2 = await rateQueue.dequeueMany(10);
109
+ expect(batch2.length).toBe(5);
110
+ });
111
+ it('should handle Diamond Dependency (A -> B, A -> C, (B&C) -> D)', async () => {
112
+ const taskA = await queue.enqueue('A', {});
113
+ const taskB = await queue.enqueue('B', {}, { scheduleAfter: [taskA.id] });
114
+ const taskC = await queue.enqueue('C', {}, { scheduleAfter: [taskA.id] });
115
+ const taskD = await queue.enqueue('D', {}, { scheduleAfter: [taskB.id, taskC.id] });
116
+ await completeTask('A');
117
+ await queue.waitForTasks([taskB.id, taskC.id], { statuses: [TaskStatus.Pending] });
118
+ await completeTask('B');
119
+ expect((await queue.getTask(taskD.id))?.status).toBe(TaskStatus.Waiting);
120
+ await completeTask('C');
121
+ await queue.waitForTasks([taskD.id], { statuses: [TaskStatus.Pending] });
122
+ });
123
+ it('should handle Deep Chain (A -> B -> C -> D)', async () => {
124
+ const taskA = await queue.enqueue('A', {});
125
+ const taskB = await queue.enqueue('B', {}, { scheduleAfter: [taskA.id] });
126
+ const taskC = await queue.enqueue('C', {}, { scheduleAfter: [taskB.id] });
127
+ const taskD = await queue.enqueue('D', {}, { scheduleAfter: [taskC.id] });
128
+ await completeTask('A');
129
+ await queue.waitForTasks([taskB.id], { statuses: [TaskStatus.Pending] });
130
+ await completeTask('B');
131
+ await queue.waitForTasks([taskC.id], { statuses: [TaskStatus.Pending] });
132
+ await completeTask('C');
133
+ await queue.waitForTasks([taskD.id], { statuses: [TaskStatus.Pending] });
134
+ });
135
+ it('should handle wide fan-out (A -> [B1...B50] -> C)', async () => {
136
+ const taskA = await queue.enqueue('A', {});
137
+ const bTasks = await queue.enqueueMany(Array.from({ length: 50 }, (_, i) => ({ type: `B${i}`, data: {}, scheduleAfter: [taskA.id] })), { returnTasks: true });
138
+ const taskC = await queue.enqueue('C', {}, { scheduleAfter: bTasks.map(t => t.id) });
139
+ await completeTask('A');
140
+ for (let i = 0; i < 49; i++) {
141
+ await completeTask(`B${i}`);
142
+ expect((await queue.getTask(taskC.id))?.status).toBe(TaskStatus.Waiting);
143
+ }
144
+ await completeTask('B49');
145
+ await queue.waitForTasks([taskC.id], { statuses: [TaskStatus.Pending] });
146
+ });
147
+ it('should handle deep tree with branching and merging', async () => {
148
+ const taskA = await queue.enqueue('A', {});
149
+ const taskB1 = await queue.enqueue('B1', {}, { scheduleAfter: [taskA.id] });
150
+ const taskB2 = await queue.enqueue('B2', {}, { scheduleAfter: [taskA.id] });
151
+ const taskC = await queue.enqueue('C', {}, { scheduleAfter: [taskB1.id, taskB2.id] });
152
+ const taskD1 = await queue.enqueue('D1', {}, { scheduleAfter: [taskC.id] });
153
+ const taskD2 = await queue.enqueue('D2', {}, { scheduleAfter: [taskC.id] });
154
+ const taskE = await queue.enqueue('E', {}, { scheduleAfter: [taskD1.id, taskD2.id] });
155
+ await completeTask('A');
156
+ await completeTask('B1');
157
+ await completeTask('B2');
158
+ await queue.waitForTasks([taskC.id], { statuses: [TaskStatus.Pending] });
159
+ await completeTask('C');
160
+ await queue.waitForTasks([taskD1.id], { statuses: [TaskStatus.Pending] });
161
+ await queue.waitForTasks([taskD2.id], { statuses: [TaskStatus.Pending] });
162
+ await completeTask('D1');
163
+ await completeTask('D2');
164
+ await queue.waitForTasks([taskE.id], { statuses: [TaskStatus.Pending] });
165
+ });
166
+ it('should handle fail-fast cascade', async () => {
167
+ const taskA = await queue.enqueue('A', {});
168
+ const taskB = await queue.enqueue('B', {}, { scheduleAfter: [taskA.id], failFast: true });
169
+ const taskC = await queue.enqueue('C', {}, { scheduleAfter: [taskB.id], failFast: true });
170
+ const taskD = await queue.enqueue('D', {}, { scheduleAfter: [taskA.id], failFast: true });
171
+ const taskE = await queue.enqueue('E', {}, { scheduleAfter: [taskC.id, taskD.id], failFast: true });
172
+ const dA = await queue.dequeue({ types: ['A'] });
173
+ await queue.fail(dA, new Error('fatal'), { fatal: true });
174
+ await queue.waitForTasks([taskB.id, taskC.id, taskD.id, taskE.id], { statuses: [TaskStatus.Skipped] });
175
+ });
176
+ it('should NOT schedule if only one of two dependencies is met (AND logic)', async () => {
177
+ const taskA = await queue.enqueue('A', {});
178
+ const taskB = await queue.enqueue('B', {});
179
+ const taskC = await queue.enqueue('C', {}, { scheduleAfter: [taskA.id, taskB.id] });
180
+ await completeTask('A');
181
+ expect((await queue.getTask(taskC.id))?.status).toBe(TaskStatus.Waiting);
182
+ // Still waiting even after some time
183
+ await timeout(100);
184
+ expect((await queue.getTask(taskC.id))?.status).toBe(TaskStatus.Waiting);
185
+ await completeTask('B');
186
+ await queue.waitForTasks([taskC.id], { statuses: [TaskStatus.Pending] });
187
+ });
188
+ });
@@ -1,8 +1,7 @@
1
1
  import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
2
2
  import { CancellationToken } from '../../cancellation/index.js';
3
- import { DependencyJoinMode, TaskQueueProvider, TaskStatus } from '../../task-queue/index.js';
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,46 +21,39 @@ 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 queue.processPendingFanIn();
32
- await timeout(50);
33
- }
34
- }
35
- describe('Dependencies (Fan-In)', () => {
36
- it('should schedule a task only after dependency completes (completeAfterTags)', async () => {
37
- // 1. Create a dependent task (Waiting)
24
+ describe('Dependencies (DAG)', () => {
25
+ it('should schedule a task only after dependency completes (completeAfter)', async () => {
26
+ // 1. Prereq
27
+ const prereq = await queue.enqueue('prereq', { val: 1 });
28
+ // 2. Dependent (Waiting)
38
29
  const dependent = await queue.enqueue('dependent', { foo: 'bar' }, {
39
- completeAfterTags: ['tag-a'],
30
+ completeAfter: [prereq.id],
40
31
  });
41
- expect(dependent.status).toBe(TaskStatus.Waiting);
42
- // 2. Create the prerequisite task
43
- const prereq = await queue.enqueue('prereq', { val: 1 }, { tags: ['tag-a'] });
32
+ expect(dependent.status).toBe(TaskStatus.Pending);
44
33
  // 3. Complete prereq
45
34
  const dequeued = await queue.dequeue({ types: ['prereq'] });
46
35
  expect(dequeued?.id).toBe(prereq.id);
47
36
  await queue.complete(dequeued);
48
- await queue.processPendingFanIn();
37
+ // Complete dependent too so it reaches finalized state for waitForTasks
38
+ const dDependent = await queue.dequeue({ types: ['dependent'] });
39
+ expect(dDependent?.id).toBe(dependent.id);
40
+ await queue.complete(dDependent);
49
41
  await queue.waitForTasks([dependent.id]);
50
42
  const updatedDependent = await queue.getTask(dependent.id);
51
43
  expect(updatedDependent?.status).toBe(TaskStatus.Completed);
52
44
  });
53
- it('should schedule a task to run after dependency completes (scheduleAfterTags)', async () => {
54
- // 1. Dependent task
45
+ it('should schedule a task to run after dependency completes (scheduleAfter)', async () => {
46
+ // 1. Prereq
47
+ const prereq = await queue.enqueue('prereq', {});
48
+ // 2. Dependent task
55
49
  const dependent = await queue.enqueue('dependent', { foo: 'bar' }, {
56
- scheduleAfterTags: ['tag-b'],
50
+ scheduleAfter: [prereq.id],
57
51
  });
58
52
  expect(dependent.status).toBe(TaskStatus.Waiting);
59
- // 2. Prereq
60
- const prereq = await queue.enqueue('prereq', {}, { tags: ['tag-b'] });
61
53
  // 3. Complete prereq
62
54
  const dequeued = await queue.dequeue({ types: ['prereq'] });
63
55
  await queue.complete(dequeued);
64
- await waitForStatus(dependent.id, TaskStatus.Pending);
56
+ await queue.waitForTasks([dependent.id], { statuses: [TaskStatus.Pending] });
65
57
  // 5. Dependent should be Pending (ready to run)
66
58
  const updatedDependent = await queue.getTask(dependent.id);
67
59
  expect(updatedDependent?.status).toBe(TaskStatus.Pending);
@@ -70,57 +62,164 @@ describe('Queue Dependencies & Tree Tests', () => {
70
62
  expect(d2?.id).toBe(dependent.id);
71
63
  });
72
64
  it('should fail-fast if dependency fails', async () => {
65
+ const prereq = await queue.enqueue('prereq', {});
73
66
  const dependent = await queue.enqueue('dependent', {}, {
74
- scheduleAfterTags: ['tag-fail'],
67
+ scheduleAfter: [prereq.id],
75
68
  failFast: true
76
69
  });
77
- const prereq = await queue.enqueue('prereq', {}, { tags: ['tag-fail'] });
78
70
  const dequeued = await queue.dequeue({ types: ['prereq'] });
79
71
  // Fail fatally
80
72
  await queue.fail(dequeued, new Error('boom'), { fatal: true });
81
- await queue.processPendingFanIn();
82
73
  await queue.waitForTasks([dependent.id]);
83
74
  const updatedDependent = await queue.getTask(dependent.id);
84
- expect(updatedDependent?.status).toBe(TaskStatus.Dead);
75
+ expect(updatedDependent?.status).toBe(TaskStatus.Skipped);
85
76
  expect(updatedDependent?.error?.code).toBe('DependencyFailed');
86
77
  });
87
- it('should respect DependencyJoinMode.Or', async () => {
88
- // Wait for tag-1 OR tag-2
78
+ it('should NOT overwrite terminal states during cancellation (failFast + 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], failFast: 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 failFast 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
+ failFast: 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
+ });
131
+ it('should respect requiredStatuses', async () => {
132
+ const prereq = await queue.enqueue('prereq', {});
133
+ // Wait for prereq to be Dead or Completed
89
134
  const dependent = await queue.enqueue('dependent', {}, {
90
- scheduleAfterTags: ['tag-1', 'tag-2'],
91
- dependencyJoinMode: DependencyJoinMode.Or
135
+ scheduleAfter: [{ id: prereq.id, requiredStatuses: [TaskStatus.Dead, TaskStatus.Completed] }]
92
136
  });
93
- // Complete tag-1 only
94
- await queue.enqueue('t1', {}, { tags: ['tag-1'] });
95
- const d1 = await queue.dequeue({ types: ['t1'] });
96
- await queue.complete(d1);
97
- await waitForStatus(dependent.id, TaskStatus.Pending);
137
+ // Fail fatally -> Dead
138
+ const d1 = await queue.dequeue({ types: ['prereq'] });
139
+ await queue.fail(d1, new Error('boom'), { fatal: true });
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], failFast: 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 failFast 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 () => {
@@ -0,0 +1 @@
1
+ export {};
@@ -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
+ });
@@ -0,0 +1 @@
1
+ export {};