@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,395 @@
1
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { CancellationToken } from '../../cancellation/index.js';
3
+ import { CircuitBreaker, CircuitBreakerState } from '../../circuit-breaker/index.js';
4
+ import { runInInjectionContext } from '../../injector/index.js';
5
+ import { injectRepository } from '../../orm/server/index.js';
6
+ import { TaskProcessResult, TaskQueueProvider, TaskStatus } from '../../task-queue/index.js';
7
+ import { PostgresTask } from '../../task-queue/postgres/index.js';
8
+ import { setupIntegrationTest } from '../../testing/index.js';
9
+ import { currentTimestamp } from '../../utils/date-time.js';
10
+ import { timeout } from '../../utils/timing.js';
11
+ describe('Task Queue Branch Coverage Enhancement', () => {
12
+ let injector;
13
+ let queue;
14
+ let token;
15
+ beforeAll(async () => {
16
+ ({ injector } = await setupIntegrationTest({ modules: { taskQueue: true } }));
17
+ });
18
+ beforeEach(() => {
19
+ const queueProvider = injector.resolve(TaskQueueProvider);
20
+ const queueName = `branch-coverage-queue-${crypto.randomUUID()}`;
21
+ queue = queueProvider.get(queueName, {
22
+ visibilityTimeout: 200,
23
+ });
24
+ token = new CancellationToken();
25
+ });
26
+ afterEach(async () => {
27
+ token.set();
28
+ await queue.clear();
29
+ });
30
+ afterAll(async () => {
31
+ await injector?.dispose();
32
+ });
33
+ it('should handle TaskProcessResult.RescheduleTo in worker', async () => {
34
+ const task = await queue.enqueue('reschedule-test', {});
35
+ const future = currentTimestamp() + 5000;
36
+ queue.process({ cancellationSignal: token }, () => TaskProcessResult.RescheduleTo(future));
37
+ await timeout(500);
38
+ token.set();
39
+ const updated = await queue.getTask(task.id);
40
+ expect(updated?.status).toBe(TaskStatus.Pending);
41
+ expect(updated?.scheduleTimestamp).toBe(future);
42
+ });
43
+ it('should handle TaskProcessResult.Fail(fatal: true) in worker', async () => {
44
+ const task = await queue.enqueue('fail-fatal-test', {});
45
+ queue.process({ cancellationSignal: token }, () => TaskProcessResult.Fail(new Error('fatal error'), true));
46
+ await timeout(500);
47
+ token.set();
48
+ const updated = await queue.getTask(task.id);
49
+ expect(updated?.status).toBe(TaskStatus.Dead);
50
+ });
51
+ it('should handle errors in touch during background lease extension', async () => {
52
+ const task = await queue.enqueue('touch-error-test', {});
53
+ // Mock touchMany to throw once
54
+ const originalTouchMany = queue.touchMany.bind(queue);
55
+ let thrown = false;
56
+ vi.spyOn(queue, 'touchMany').mockImplementation(async (t, o) => {
57
+ if (!thrown) {
58
+ thrown = true;
59
+ throw new Error('touch error');
60
+ }
61
+ return originalTouchMany(t, o);
62
+ });
63
+ queue.process({ cancellationSignal: token }, async () => {
64
+ await timeout(300); // Wait for background touch to trigger
65
+ return TaskProcessResult.Complete();
66
+ });
67
+ await timeout(600);
68
+ token.set();
69
+ const updated = await queue.getTask(task.id);
70
+ expect(updated?.status).toBe(TaskStatus.Completed);
71
+ expect(thrown).toBe(true);
72
+ });
73
+ it('should handle background lease loss during processing', async () => {
74
+ // visibilityTimeout is 200ms. Background touch is every 100ms.
75
+ const task = await queue.enqueue('lease-loss-test', {});
76
+ let workerFinished = false;
77
+ // Mock touchMany to return empty array (stolen/lost)
78
+ vi.spyOn(queue, 'touchMany').mockResolvedValue([]);
79
+ queue.process({ cancellationSignal: token }, async (context) => {
80
+ // Wait for background touch to discover lease loss
81
+ for (let i = 0; i < 20; i++) {
82
+ if (context.signal.isSet)
83
+ break;
84
+ await timeout(50);
85
+ }
86
+ workerFinished = true;
87
+ return TaskProcessResult.Complete();
88
+ });
89
+ await timeout(500);
90
+ token.set();
91
+ expect(workerFinished).toBe(true);
92
+ });
93
+ it('should handle enqueueMany with empty items', async () => {
94
+ const result = await queue.enqueueMany([], { returnTasks: true });
95
+ expect(result).toEqual([]);
96
+ const result2 = await queue.enqueueMany([]);
97
+ expect(result2).toBeUndefined();
98
+ });
99
+ it('should handle getTask with non-existent id', async () => {
100
+ const task = await queue.getTask('00000000-0000-0000-0000-000000000000');
101
+ expect(task).toBeUndefined();
102
+ });
103
+ it('should handle dequeue with specific types', async () => {
104
+ await queue.enqueue('type1', { val: 1 });
105
+ await queue.enqueue('type2', { val: 2 });
106
+ const d1 = await queue.dequeue({ types: ['type2'] });
107
+ expect(d1?.type).toBe('type2');
108
+ const d2 = await queue.dequeue({ types: ['type1'] });
109
+ expect(d2?.type).toBe('type1');
110
+ });
111
+ it('should handle maintenance when no tasks need maintenance', async () => {
112
+ await queue.enqueue('t1', {});
113
+ await queue.maintenance();
114
+ const count = await queue.count({ status: TaskStatus.Pending });
115
+ expect(count).toBe(1);
116
+ });
117
+ it('should handle multiple workers competing for tasks', async () => {
118
+ await queue.enqueue('compete', {});
119
+ // Dequeue in one worker
120
+ const d1 = await queue.dequeue();
121
+ expect(d1).toBeDefined();
122
+ // Try to dequeue again - should be empty because it is locked
123
+ const d2 = await queue.dequeue();
124
+ expect(d2).toBeUndefined();
125
+ });
126
+ it('should handle reschedule by milliseconds', async () => {
127
+ const task = await queue.enqueue('reschedule-by', {});
128
+ const dequeued = await queue.dequeue();
129
+ const future = currentTimestamp() + 5000;
130
+ await queue.reschedule(dequeued.id, future);
131
+ const updated = await queue.getTask(task.id);
132
+ expect(updated?.scheduleTimestamp).toBe(future);
133
+ });
134
+ it('should handle touch with no tasks', async () => {
135
+ const result = await queue.touchMany([]);
136
+ expect(result).toEqual([]);
137
+ });
138
+ it('should handle touch with no task token', async () => {
139
+ const task = await queue.enqueue('no-token', {});
140
+ // Task is Pending, token is null
141
+ const result = await queue.touch(task);
142
+ expect(result).toBeUndefined();
143
+ });
144
+ it('should handle complete with non-existent task', async () => {
145
+ const fakeTask = { id: crypto.randomUUID(), token: crypto.randomUUID() };
146
+ // Should not throw and should not affect existing tasks
147
+ await queue.enqueue('t1', {});
148
+ await queue.complete(fakeTask);
149
+ const count = await queue.count();
150
+ expect(count).toBe(1);
151
+ });
152
+ it('should handle dequeueMany with forceDequeue', async () => {
153
+ await queue.enqueue('force', {});
154
+ const tasks = await queue.dequeueMany(1, { forceDequeue: true });
155
+ expect(tasks).toHaveLength(1);
156
+ expect(tasks[0]?.status).toBe(TaskStatus.Running);
157
+ });
158
+ it('should handle cancelMany with empty ids', async () => {
159
+ await queue.enqueue('t1', {});
160
+ await queue.cancelMany([]);
161
+ const count = await queue.count({ status: TaskStatus.Pending });
162
+ expect(count).toBe(1);
163
+ });
164
+ it('should handle touch with progress and state', async () => {
165
+ const task = await queue.enqueue('touch-data', {});
166
+ const dequeued = await queue.dequeue();
167
+ const result = await queue.touch(dequeued, { progress: 0.7, state: { ok: true } });
168
+ expect(result).toBeDefined();
169
+ const updated = await queue.getTask(task.id);
170
+ expect(updated?.progress).toBe(0.7);
171
+ expect(updated?.state).toEqual({ ok: true });
172
+ });
173
+ it('should handle touchMany with multiple tasks', async () => {
174
+ const t1 = await queue.enqueue('tm1', {});
175
+ const t2 = await queue.enqueue('tm2', {});
176
+ const d1 = await queue.dequeue();
177
+ const d2 = await queue.dequeue();
178
+ const result = await queue.touchMany([d1, d2], { progresses: [0.1, 0.2] });
179
+ expect(result).toHaveLength(2);
180
+ });
181
+ it('should handle enqueue with only completeAfter (isWaiting=false in DB, but has dependencies)', async () => {
182
+ // line 116 in pg task-queue: const isWaiting = scheduleAfterCount > 0;
183
+ // status: isWaiting ? TaskStatus.Waiting : TaskStatus.Pending
184
+ const prereq = await queue.enqueue('p', {});
185
+ const dependent = await queue.enqueue('d', {}, { completeAfter: [prereq.id] });
186
+ expect(dependent.status).toBe(TaskStatus.Pending); // Because scheduleAfterCount is 0
187
+ expect(dependent.unresolvedCompleteDependencies).toBe(1);
188
+ });
189
+ it('should handle getTask including archived tasks', async () => {
190
+ const archQueue = injector.resolve(TaskQueueProvider).get(`arch-${crypto.randomUUID()}`, { retention: 0, archiveRetention: 3600000 });
191
+ const t = await archQueue.enqueue('t', {});
192
+ const d = await archQueue.dequeue();
193
+ await archQueue.complete(d);
194
+ await timeout(50);
195
+ await archQueue.maintenance(); // Should archive
196
+ const archived = await archQueue.getTask(t.id);
197
+ expect(archived).toBeDefined();
198
+ expect(archived?.id).toBe(t.id);
199
+ });
200
+ it('should handle maintenance with no work to do', async () => {
201
+ await queue.enqueue('t1', {});
202
+ await queue.maintenance();
203
+ const count = await queue.count({ status: TaskStatus.Pending });
204
+ expect(count).toBe(1);
205
+ });
206
+ it('should handle task TTL failure in maintenance', async () => {
207
+ // retention=0 to allow maintenance to process immediately
208
+ const ttlQueue = injector.resolve(TaskQueueProvider).get(`ttl-${crypto.randomUUID()}`, { retention: 0 });
209
+ // Enqueue with TTL in the past
210
+ const task = await ttlQueue.enqueue('ttl-fail', {}, { timeToLive: currentTimestamp() - 1000 });
211
+ await timeout(100);
212
+ await ttlQueue.maintenance();
213
+ const updated = await ttlQueue.getTask(task.id);
214
+ expect(updated?.status).toBe(TaskStatus.Expired);
215
+ expect(updated?.error?.message).toContain('Task expired');
216
+ });
217
+ it('should handle cancelMany with multiple valid IDs', async () => {
218
+ const t1 = await queue.enqueue('c1', {});
219
+ const t2 = await queue.enqueue('c2', {});
220
+ await queue.cancelMany([t1.id, t2.id]);
221
+ const u1 = await queue.getTask(t1.id);
222
+ const u2 = await queue.getTask(t2.id);
223
+ expect(u1?.status).toBe(TaskStatus.Cancelled);
224
+ expect(u2?.status).toBe(TaskStatus.Cancelled);
225
+ });
226
+ it('should handle pruning long expired tasks', async () => {
227
+ // archiveRetention = 0
228
+ const pruneQueue = injector.resolve(TaskQueueProvider).get(`prune-long-${crypto.randomUUID()}`, { retention: 0, archiveRetention: 0 });
229
+ const task = await pruneQueue.enqueue('p1', {});
230
+ const d1 = await pruneQueue.dequeue();
231
+ await pruneQueue.complete(d1);
232
+ await timeout(100);
233
+ await pruneQueue.maintenance();
234
+ const updated = await pruneQueue.getTask(task.id);
235
+ expect(updated).toBeUndefined();
236
+ });
237
+ it('should handle complete with token mismatch', async () => {
238
+ const task = await queue.enqueue('token-mismatch', {});
239
+ const dequeued = await queue.dequeue();
240
+ // Create a fake task with wrong token
241
+ const fakeTask = { ...dequeued, token: '00000000-0000-0000-0000-000000000000' };
242
+ // Should not throw and should not complete
243
+ await queue.complete(fakeTask);
244
+ const updated = await queue.getTask(task.id);
245
+ expect(updated?.status).toBe(TaskStatus.Running);
246
+ });
247
+ it('should handle rescheduleManyByTags for running tasks', async () => {
248
+ const task = await queue.enqueue('tag-resched', {}, { tags: ['t1'] });
249
+ await queue.dequeue(); // Now Running
250
+ const future = currentTimestamp() + 10000;
251
+ await queue.rescheduleManyByTags('t1', future);
252
+ const updated = await queue.getTask(task.id);
253
+ expect(updated?.status).toBe(TaskStatus.Pending);
254
+ expect(updated?.tries).toBe(0); // Refunded
255
+ });
256
+ it('should handle dequeueMany shortfall refund', async () => {
257
+ // We need to trigger tasks.length < rateLimitAcquired
258
+ // This happens if rate limiter allows N, but only M < N are actually available/dequeuable
259
+ await queue.enqueue('shortfall', {});
260
+ // rateLimit is 5 by default in our setup.
261
+ // If we try to dequeue 5, but only 1 exists, it should refund 4.
262
+ const tasks = await queue.dequeueMany(5);
263
+ expect(tasks).toHaveLength(1);
264
+ });
265
+ it('should handle CircuitBreaker Half-Open state in dequeue', async () => {
266
+ const namespace = `cb-half-${crypto.randomUUID()}`;
267
+ const cbQueue = injector.resolve(TaskQueueProvider).get(namespace, { circuitBreakerThreshold: 1 });
268
+ const cb = injector.resolve(CircuitBreaker, namespace);
269
+ // Mock CB to be Half-Open
270
+ vi.spyOn(cb, 'check').mockResolvedValue({ allowed: true, state: CircuitBreakerState.HalfOpen, isProbe: true });
271
+ await cbQueue.enqueue('cb', {});
272
+ const tasks = await cbQueue.dequeue();
273
+ expect(tasks).toBeDefined();
274
+ });
275
+ it('should handle CircuitBreaker Half-Open state blocking when already probing', async () => {
276
+ const namespace = `cb-block-${crypto.randomUUID()}`;
277
+ const cbQueue = injector.resolve(TaskQueueProvider).get(namespace, { circuitBreakerThreshold: 1 });
278
+ const cb = injector.resolve(CircuitBreaker, namespace);
279
+ // Mock CB to be Half-Open but NOT a probe (already probing)
280
+ vi.spyOn(cb, 'check').mockResolvedValue({ allowed: true, state: CircuitBreakerState.HalfOpen, isProbe: false });
281
+ await cbQueue.enqueue('cb', {});
282
+ await cbQueue.enqueue('cb2', {});
283
+ // 1. Mock one task as already running in this namespace
284
+ // Actually, we can just enqueue and dequeue one normally first
285
+ const d1 = await cbQueue.dequeue({ forceDequeue: true });
286
+ expect(d1).toBeDefined();
287
+ // 2. Now the "normal" dequeue should see a running task and block because it's half-open and not a probe
288
+ const d2 = await cbQueue.dequeue();
289
+ expect(d2).toBeUndefined();
290
+ });
291
+ it('should handle complete when task has active children', async () => {
292
+ const parent = await queue.enqueue('parent', {});
293
+ await queue.enqueue('child', {}, { parentId: parent.id });
294
+ const dParent = await queue.dequeue();
295
+ await queue.complete(dParent);
296
+ const updated = await queue.getTask(parent.id);
297
+ expect(updated?.status).toBe(TaskStatus.WaitingChildren);
298
+ });
299
+ it('should handle maintenance recovering zombie tasks via touchMany logic in prune', async () => {
300
+ const pruneQueue = injector.resolve(TaskQueueProvider).get(`prune-fail-${crypto.randomUUID()}`, { maxExecutionTime: 50, retention: 0 });
301
+ const task = await pruneQueue.enqueue('p1', {});
302
+ await pruneQueue.dequeue();
303
+ await timeout(100);
304
+ await pruneQueue.maintenance();
305
+ const updated = await pruneQueue.getTask(task.id);
306
+ expect(updated?.status).toBe(TaskStatus.TimedOut);
307
+ });
308
+ it('should handle enqueueMany with parentId and waitForCompletion false', async () => {
309
+ const parent = await queue.enqueue('p', {});
310
+ const tasks = await queue.enqueueMany([{ type: 'child', data: {}, parentId: parent.id, waitForCompletion: false }], { returnTasks: true });
311
+ expect(tasks).toHaveLength(1);
312
+ expect(tasks[0]?.parentId).toBe(parent.id);
313
+ const updatedParent = await queue.getTask(parent.id);
314
+ expect(updatedParent?.unresolvedCompleteDependencies).toBe(0);
315
+ });
316
+ it('should handle failMany for running tasks', async () => {
317
+ const t1 = await queue.enqueue('t1', {});
318
+ const d1 = await queue.dequeue();
319
+ await queue.failMany([d1], [new Error('fail')]);
320
+ const updated = await queue.getTask(t1.id);
321
+ expect(updated?.status).toBe(TaskStatus.Retrying);
322
+ expect(updated?.tries).toBe(1);
323
+ });
324
+ it('should handle failMany reaching max tries (Dead state)', async () => {
325
+ const isolatedQueue = injector.resolve(TaskQueueProvider).get(`exhaust-${crypto.randomUUID()}`, { maxTries: 1 });
326
+ const t1 = await isolatedQueue.enqueue('t1', {});
327
+ const d1 = await isolatedQueue.dequeue();
328
+ await isolatedQueue.failMany([d1], [new Error('fatal')]);
329
+ const updated = await isolatedQueue.getTask(t1.id);
330
+ expect(updated?.status).toBe(TaskStatus.Dead);
331
+ });
332
+ it('should handle rescheduleMany with mixed statuses', async () => {
333
+ const t1 = await queue.enqueue('t1', {}); // Pending
334
+ const t2 = await queue.enqueue('t2', {});
335
+ await queue.dequeue(); // t2 is now Running
336
+ const future = currentTimestamp() + 10000;
337
+ await queue.rescheduleMany([t1.id, t2.id], future);
338
+ const u1 = await queue.getTask(t1.id);
339
+ const u2 = await queue.getTask(t2.id);
340
+ expect(u1?.status).toBe(TaskStatus.Pending);
341
+ expect(u1?.tries).toBe(0);
342
+ expect(u2?.status).toBe(TaskStatus.Pending);
343
+ expect(u2?.tries).toBe(0); // Refunded because it was Running
344
+ });
345
+ it('should handle restart with resetState', async () => {
346
+ const task = await queue.enqueue('t', { initial: 1 });
347
+ // Keep it Pending
348
+ await queue.restart(task.id, { resetState: true });
349
+ const updated = await queue.getTask(task.id);
350
+ expect(updated?.status).toBe(TaskStatus.Pending);
351
+ expect(updated?.state).toBeNull();
352
+ });
353
+ it('should handle clear with active tasks', async () => {
354
+ const t = await queue.enqueue('t', {});
355
+ await queue.clear();
356
+ expect(await queue.has(t.id)).toBe(false);
357
+ });
358
+ it('should handle countByTags with tags', async () => {
359
+ await queue.enqueue('t', {}, { tags: ['tag1', 'tag2'] });
360
+ expect(await queue.countByTags(['tag1'])).toBe(1);
361
+ expect(await queue.countByTags(['tag3'])).toBe(0);
362
+ });
363
+ it('should handle rescheduleManyByTags with mixed statuses', async () => {
364
+ const t1 = await queue.enqueue('t1', {}, { tags: ['tag-m'] });
365
+ const t2 = await queue.enqueue('t2', {}, { tags: ['tag-m'] });
366
+ await queue.dequeue(); // one is running
367
+ await queue.rescheduleManyByTags('tag-m', currentTimestamp() + 5000);
368
+ const u1 = await queue.getTask(t1.id);
369
+ const u2 = await queue.getTask(t2.id);
370
+ expect(u1?.tries).toBe(0);
371
+ expect(u2?.tries).toBe(0);
372
+ });
373
+ it('should handle touchMany with mismatched status/token', async () => {
374
+ const t1 = await queue.enqueue('t1', {});
375
+ const d1 = await queue.dequeue();
376
+ // Wrong token
377
+ const result = await queue.touchMany([{ ...d1, token: '00000000-0000-0000-0000-000000000000' }]);
378
+ expect(result).toHaveLength(0);
379
+ });
380
+ it('should handle large batch maintenance (> 1000 tasks)', async () => {
381
+ const namespace = `aging-batch-${crypto.randomUUID()}`;
382
+ const agingQueue = injector.resolve(TaskQueueProvider).get(namespace, { priorityAgingInterval: 60000, priorityAgingStep: 1 });
383
+ const items = Array.from({ length: 1001 }, (_, i) => ({ type: 'batch', data: { i } }));
384
+ await agingQueue.enqueueMany(items);
385
+ // Manually push timestamps into the past to qualify for aging without waiting
386
+ const repository = runInInjectionContext(injector, () => injectRepository(PostgresTask));
387
+ const twoMinutesAgo = currentTimestamp() - 120000;
388
+ await repository.updateManyByQuery({ namespace }, { priorityAgeTimestamp: twoMinutesAgo });
389
+ await agingQueue.maintenance();
390
+ });
391
+ it('should handle maintenance with empty archive and active tables', async () => {
392
+ const emptyQueue = injector.resolve(TaskQueueProvider).get(`empty-${crypto.randomUUID()}`, { retention: 0, archiveRetention: 0 });
393
+ await emptyQueue.maintenance();
394
+ });
395
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,150 @@
1
+ import { eq } from 'drizzle-orm';
2
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
3
+ import { CancellationToken } from '../../cancellation/index.js';
4
+ import { runInInjectionContext } from '../../injector/index.js';
5
+ import { Logger } from '../../logger/index.js';
6
+ import { RANDOM_UUID_V4 } from '../../orm/index.js';
7
+ import { injectRepository } from '../../orm/server/index.js';
8
+ import { TaskQueueProvider, TaskStatus } from '../../task-queue/index.js';
9
+ import { task as taskTable } from '../../task-queue/postgres/schemas.js';
10
+ import { PostgresTask } from '../../task-queue/postgres/task.model.js';
11
+ import { TaskContext } from '../../task-queue/task-context.js';
12
+ import { setupIntegrationTest } from '../../testing/index.js';
13
+ import { timeout } from '../../utils/timing.js';
14
+ describe('Task Queue Coverage Enhancement', () => {
15
+ let injector;
16
+ let queueProvider;
17
+ beforeAll(async () => {
18
+ ({ injector } = await setupIntegrationTest({ modules: { taskQueue: true } }));
19
+ queueProvider = injector.resolve(TaskQueueProvider);
20
+ });
21
+ afterAll(async () => {
22
+ await injector?.dispose();
23
+ });
24
+ describe('TaskContext', () => {
25
+ it('should correctly report basic properties', async () => {
26
+ const queue = queueProvider.get(`context-prop-${crypto.randomUUID()}`);
27
+ const task = await queue.enqueue('test', { foo: 'bar' }, { tags: ['tag1'], priority: 500 });
28
+ const context = new TaskContext(queue, task, new CancellationToken(), injector.resolve(Logger));
29
+ expect(context.id).toBe(task.id);
30
+ expect(context.type).toBe('test');
31
+ expect(context.parentId).toBeNull();
32
+ expect(context.tags).toContain('tag1');
33
+ expect(context.unresolvedScheduleDependencies).toBe(0);
34
+ expect(context.unresolvedCompleteDependencies).toBe(0);
35
+ expect(context.data).toEqual({ foo: 'bar' });
36
+ expect(context.state).toBeNull();
37
+ expect(context.attempt).toBe(0);
38
+ expect(context.triesLeft).toBe(queue.maxTries);
39
+ expect(context.isFinalAttempt).toBe(false);
40
+ });
41
+ it('should spawn children for other queues', async () => {
42
+ const queue = queueProvider.get(`context-spawn-${crypto.randomUUID()}`);
43
+ const otherQueue = queueProvider.get(`other-spawn-${crypto.randomUUID()}`);
44
+ const task = await queue.enqueue('parent', {});
45
+ const context = new TaskContext(queue, task, new CancellationToken(), injector.resolve(Logger));
46
+ const spawned = await context.spawn(otherQueue, 'spawned', { x: 1 });
47
+ expect(spawned.type).toBe('spawned');
48
+ expect(spawned.parentId).toBe(task.id);
49
+ expect(spawned.namespace).toBe(otherQueue.namespace);
50
+ const spawnedMany = await context.spawnMany(otherQueue, [{ type: 'm1', data: {} }, { type: 'm2', data: {} }]);
51
+ expect(spawnedMany).toHaveLength(2);
52
+ expect(spawnedMany.every(s => s.parentId == task.id)).toBe(true);
53
+ });
54
+ it('should handle checkpoint and reschedule', async () => {
55
+ const queue = queueProvider.get(`context-flow-${crypto.randomUUID()}`);
56
+ const task = await queue.enqueue('test', {});
57
+ const dTask = await queue.dequeue();
58
+ const context = new TaskContext(queue, dTask, new CancellationToken(), injector.resolve(Logger));
59
+ await context.checkpoint({ progress: 0.5, state: { step: 1 } });
60
+ const midTask = await queue.getTask(task.id);
61
+ expect(midTask?.progress).toBe(0.5);
62
+ expect(midTask?.state).toEqual({ step: 1 });
63
+ await context.reschedule({ delay: 10000 });
64
+ const finalTask = await queue.getTask(task.id);
65
+ expect(finalTask?.status).toBe(TaskStatus.Pending);
66
+ });
67
+ it('should handle checkpoint lease loss', async () => {
68
+ const queue = queueProvider.get(`checkpoint-loss-${crypto.randomUUID()}`);
69
+ const task = await queue.enqueue('t', {});
70
+ const dTask = await queue.dequeue();
71
+ // Manually break the token in DB
72
+ const repository = runInInjectionContext(injector, () => injectRepository(PostgresTask));
73
+ await repository.useTransaction(undefined, async (tx) => {
74
+ await tx.pgTransaction.update(taskTable).set({ token: RANDOM_UUID_V4 }).where(eq(taskTable.id, task.id));
75
+ });
76
+ const signal = new CancellationToken();
77
+ const context = new TaskContext(queue, dTask, signal, injector.resolve(Logger));
78
+ await context.checkpoint({ progress: 0.9 });
79
+ expect(signal.isSet).toBe(true);
80
+ });
81
+ });
82
+ describe('TaskQueue Utilities', () => {
83
+ it('should check for task existence', async () => {
84
+ const queue = queueProvider.get(`has-test-${crypto.randomUUID()}`);
85
+ expect(await queue.has(crypto.randomUUID())).toBe(false);
86
+ const t = await queue.enqueue('t', {});
87
+ expect(await queue.has(t.id)).toBe(true);
88
+ });
89
+ it('should count and retrieve tasks by tags', async () => {
90
+ const queue = queueProvider.get(`tags-test-${crypto.randomUUID()}`);
91
+ await queue.enqueue('t1', {}, { tags: ['tag1'] });
92
+ await queue.enqueue('t2', {}, { tags: ['tag1', 'tag2'] });
93
+ expect(await queue.countByTags(['tag1'])).toBe(2);
94
+ expect(await queue.countByTags(['tag2'])).toBe(1);
95
+ const tagged = await queue.getManyByTags(['tag2']);
96
+ expect(tagged).toHaveLength(1);
97
+ expect(tagged[0]?.type).toBe('t2');
98
+ });
99
+ it('should clear all tasks in namespace', async () => {
100
+ const queue = queueProvider.get(`clear-test-${crypto.randomUUID()}`);
101
+ await queue.enqueue('t', {});
102
+ await queue.clear();
103
+ expect(await queue.count()).toBe(0);
104
+ });
105
+ it('should load task from archive in getTask', async () => {
106
+ const archiveQueue = queueProvider.get(`archive-test-${crypto.randomUUID()}`, { retention: 0, archiveRetention: 3600000 });
107
+ const task = await archiveQueue.enqueue('t', {});
108
+ const dTask = await archiveQueue.dequeue();
109
+ await archiveQueue.complete(dTask);
110
+ await timeout(10);
111
+ await archiveQueue.maintenance();
112
+ const archivedTask = await archiveQueue.getTask(task.id);
113
+ expect(archivedTask).toBeDefined();
114
+ expect(archivedTask?.status).toBe(TaskStatus.Completed);
115
+ });
116
+ it('should handle batch touch and fail', async () => {
117
+ const isolatedQueue = queueProvider.get(`batch-util-${crypto.randomUUID()}`);
118
+ const t1 = await isolatedQueue.enqueue('t1', {});
119
+ const t2 = await isolatedQueue.enqueue('t2', {});
120
+ const dTasks = await isolatedQueue.dequeueMany(2);
121
+ const touched = await isolatedQueue.touchMany(dTasks, { progresses: [0.5, 0.8] });
122
+ expect(touched).toHaveLength(2);
123
+ await isolatedQueue.failMany(dTasks, [new Error('e1'), new Error('e2')]);
124
+ const u1 = await isolatedQueue.getTask(t1.id);
125
+ expect(u1?.status).toBe(TaskStatus.Retrying);
126
+ expect(u1?.tries).toBe(1);
127
+ });
128
+ it('should handle idempotency collisions in enqueueMany', async () => {
129
+ const queue = queueProvider.get(`idem-coll-${crypto.randomUUID()}`);
130
+ const key = `key-${crypto.randomUUID()}`;
131
+ await queue.enqueue('t1', { val: 1 }, { idempotencyKey: key });
132
+ const tasks = await queue.enqueueMany([
133
+ { type: 't1', data: { val: 2 }, idempotencyKey: key },
134
+ { type: 't2', data: { val: 3 } },
135
+ ], { returnTasks: true });
136
+ expect(tasks).toHaveLength(2);
137
+ expect(tasks.find(t => t.idempotencyKey == key)?.data).toEqual({ val: 1 });
138
+ });
139
+ it('should purge archive in maintenance', async () => {
140
+ const purgeQueue = queueProvider.get(`purge-test-${crypto.randomUUID()}`, { retention: 0, archiveRetention: 0 });
141
+ const task = await purgeQueue.enqueue('t', {});
142
+ const dTask = await purgeQueue.dequeue();
143
+ await purgeQueue.complete(dTask);
144
+ await timeout(10);
145
+ await purgeQueue.maintenance();
146
+ const purgedTask = await purgeQueue.getTask(task.id);
147
+ expect(purgedTask).toBeUndefined();
148
+ });
149
+ });
150
+ });
@@ -0,0 +1 @@
1
+ export {};