@nicnocquee/dataqueue 1.24.0 → 1.26.0-beta.20260223195940

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 (72) hide show
  1. package/README.md +44 -0
  2. package/ai/build-docs-content.ts +96 -0
  3. package/ai/build-llms-full.ts +42 -0
  4. package/ai/docs-content.json +278 -0
  5. package/ai/rules/advanced.md +132 -0
  6. package/ai/rules/basic.md +159 -0
  7. package/ai/rules/react-dashboard.md +83 -0
  8. package/ai/skills/dataqueue-advanced/SKILL.md +320 -0
  9. package/ai/skills/dataqueue-core/SKILL.md +234 -0
  10. package/ai/skills/dataqueue-react/SKILL.md +189 -0
  11. package/dist/cli.cjs +1149 -14
  12. package/dist/cli.cjs.map +1 -1
  13. package/dist/cli.d.cts +66 -1
  14. package/dist/cli.d.ts +66 -1
  15. package/dist/cli.js +1146 -13
  16. package/dist/cli.js.map +1 -1
  17. package/dist/index.cjs +4630 -928
  18. package/dist/index.cjs.map +1 -1
  19. package/dist/index.d.cts +1033 -15
  20. package/dist/index.d.ts +1033 -15
  21. package/dist/index.js +4626 -929
  22. package/dist/index.js.map +1 -1
  23. package/dist/mcp-server.cjs +186 -0
  24. package/dist/mcp-server.cjs.map +1 -0
  25. package/dist/mcp-server.d.cts +32 -0
  26. package/dist/mcp-server.d.ts +32 -0
  27. package/dist/mcp-server.js +175 -0
  28. package/dist/mcp-server.js.map +1 -0
  29. package/migrations/1751131910825_add_timeout_seconds_to_job_queue.sql +2 -2
  30. package/migrations/1751186053000_add_job_events_table.sql +12 -8
  31. package/migrations/1751984773000_add_tags_to_job_queue.sql +1 -1
  32. package/migrations/1765809419000_add_force_kill_on_timeout_to_job_queue.sql +1 -1
  33. package/migrations/1771100000000_add_idempotency_key_to_job_queue.sql +7 -0
  34. package/migrations/1781200000000_add_wait_support.sql +12 -0
  35. package/migrations/1781200000001_create_waitpoints_table.sql +18 -0
  36. package/migrations/1781200000002_add_performance_indexes.sql +34 -0
  37. package/migrations/1781200000003_add_progress_to_job_queue.sql +7 -0
  38. package/migrations/1781200000004_create_cron_schedules_table.sql +33 -0
  39. package/migrations/1781200000005_add_retry_config_to_job_queue.sql +17 -0
  40. package/package.json +40 -23
  41. package/src/backend.ts +328 -0
  42. package/src/backends/postgres.ts +2040 -0
  43. package/src/backends/redis-scripts.ts +865 -0
  44. package/src/backends/redis.test.ts +1906 -0
  45. package/src/backends/redis.ts +1792 -0
  46. package/src/cli.test.ts +82 -6
  47. package/src/cli.ts +73 -10
  48. package/src/cron.test.ts +126 -0
  49. package/src/cron.ts +40 -0
  50. package/src/db-util.ts +4 -2
  51. package/src/index.test.ts +688 -1
  52. package/src/index.ts +277 -39
  53. package/src/init-command.test.ts +449 -0
  54. package/src/init-command.ts +709 -0
  55. package/src/install-mcp-command.test.ts +216 -0
  56. package/src/install-mcp-command.ts +185 -0
  57. package/src/install-rules-command.test.ts +218 -0
  58. package/src/install-rules-command.ts +233 -0
  59. package/src/install-skills-command.test.ts +176 -0
  60. package/src/install-skills-command.ts +124 -0
  61. package/src/mcp-server.test.ts +162 -0
  62. package/src/mcp-server.ts +231 -0
  63. package/src/processor.test.ts +559 -18
  64. package/src/processor.ts +456 -49
  65. package/src/queue.test.ts +682 -6
  66. package/src/queue.ts +135 -944
  67. package/src/supervisor.test.ts +340 -0
  68. package/src/supervisor.ts +162 -0
  69. package/src/test-util.ts +32 -0
  70. package/src/types.ts +726 -17
  71. package/src/wait.test.ts +698 -0
  72. package/LICENSE +0 -21
@@ -0,0 +1,340 @@
1
+ import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
2
+ import { createSupervisor } from './supervisor.js';
3
+ import type { QueueBackend } from './backend.js';
4
+ import type { SupervisorOptions } from './types.js';
5
+
6
+ /**
7
+ * Builds a fake {@link QueueBackend} with only the methods the supervisor
8
+ * calls, each backed by a vi.fn() that resolves to 0 by default.
9
+ */
10
+ function createFakeBackend(overrides: Partial<QueueBackend> = {}) {
11
+ return {
12
+ reclaimStuckJobs: vi.fn().mockResolvedValue(0),
13
+ cleanupOldJobs: vi.fn().mockResolvedValue(0),
14
+ cleanupOldJobEvents: vi.fn().mockResolvedValue(0),
15
+ expireTimedOutWaitpoints: vi.fn().mockResolvedValue(0),
16
+ ...overrides,
17
+ } as unknown as QueueBackend;
18
+ }
19
+
20
+ describe('createSupervisor', () => {
21
+ afterEach(() => {
22
+ vi.restoreAllMocks();
23
+ vi.useRealTimers();
24
+ });
25
+
26
+ describe('start (one-shot)', () => {
27
+ it('runs all maintenance tasks and returns results', async () => {
28
+ // Setup
29
+ const backend = createFakeBackend({
30
+ reclaimStuckJobs: vi.fn().mockResolvedValue(3),
31
+ cleanupOldJobs: vi.fn().mockResolvedValue(15),
32
+ cleanupOldJobEvents: vi.fn().mockResolvedValue(7),
33
+ expireTimedOutWaitpoints: vi.fn().mockResolvedValue(2),
34
+ });
35
+ const supervisor = createSupervisor(backend);
36
+
37
+ // Act
38
+ const result = await supervisor.start();
39
+
40
+ // Assert
41
+ expect(result).toEqual({
42
+ reclaimedJobs: 3,
43
+ cleanedUpJobs: 15,
44
+ cleanedUpEvents: 7,
45
+ expiredTokens: 2,
46
+ });
47
+ expect(backend.reclaimStuckJobs).toHaveBeenCalledWith(10);
48
+ expect(backend.cleanupOldJobs).toHaveBeenCalledWith(30, 1000);
49
+ expect(backend.cleanupOldJobEvents).toHaveBeenCalledWith(30, 1000);
50
+ expect(backend.expireTimedOutWaitpoints).toHaveBeenCalledOnce();
51
+ });
52
+
53
+ it('passes custom option values to backend methods', async () => {
54
+ // Setup
55
+ const backend = createFakeBackend();
56
+ const supervisor = createSupervisor(backend, {
57
+ stuckJobsTimeoutMinutes: 20,
58
+ cleanupJobsDaysToKeep: 60,
59
+ cleanupEventsDaysToKeep: 14,
60
+ cleanupBatchSize: 500,
61
+ });
62
+
63
+ // Act
64
+ await supervisor.start();
65
+
66
+ // Assert
67
+ expect(backend.reclaimStuckJobs).toHaveBeenCalledWith(20);
68
+ expect(backend.cleanupOldJobs).toHaveBeenCalledWith(60, 500);
69
+ expect(backend.cleanupOldJobEvents).toHaveBeenCalledWith(14, 500);
70
+ });
71
+
72
+ it('skips reclaimStuckJobs when disabled', async () => {
73
+ // Setup
74
+ const backend = createFakeBackend();
75
+ const supervisor = createSupervisor(backend, {
76
+ reclaimStuckJobs: false,
77
+ });
78
+
79
+ // Act
80
+ const result = await supervisor.start();
81
+
82
+ // Assert
83
+ expect(backend.reclaimStuckJobs).not.toHaveBeenCalled();
84
+ expect(result.reclaimedJobs).toBe(0);
85
+ expect(backend.cleanupOldJobs).toHaveBeenCalledOnce();
86
+ });
87
+
88
+ it('skips cleanupOldJobs when cleanupJobsDaysToKeep is 0', async () => {
89
+ // Setup
90
+ const backend = createFakeBackend();
91
+ const supervisor = createSupervisor(backend, {
92
+ cleanupJobsDaysToKeep: 0,
93
+ });
94
+
95
+ // Act
96
+ const result = await supervisor.start();
97
+
98
+ // Assert
99
+ expect(backend.cleanupOldJobs).not.toHaveBeenCalled();
100
+ expect(result.cleanedUpJobs).toBe(0);
101
+ expect(backend.reclaimStuckJobs).toHaveBeenCalledOnce();
102
+ });
103
+
104
+ it('skips cleanupOldJobEvents when cleanupEventsDaysToKeep is 0', async () => {
105
+ // Setup
106
+ const backend = createFakeBackend();
107
+ const supervisor = createSupervisor(backend, {
108
+ cleanupEventsDaysToKeep: 0,
109
+ });
110
+
111
+ // Act
112
+ const result = await supervisor.start();
113
+
114
+ // Assert
115
+ expect(backend.cleanupOldJobEvents).not.toHaveBeenCalled();
116
+ expect(result.cleanedUpEvents).toBe(0);
117
+ });
118
+
119
+ it('skips expireTimedOutTokens when disabled', async () => {
120
+ // Setup
121
+ const backend = createFakeBackend();
122
+ const supervisor = createSupervisor(backend, {
123
+ expireTimedOutTokens: false,
124
+ });
125
+
126
+ // Act
127
+ const result = await supervisor.start();
128
+
129
+ // Assert
130
+ expect(backend.expireTimedOutWaitpoints).not.toHaveBeenCalled();
131
+ expect(result.expiredTokens).toBe(0);
132
+ });
133
+
134
+ it('calls onError and continues when a task throws', async () => {
135
+ // Setup
136
+ const onError = vi.fn();
137
+ const taskError = new Error('reclaim failed');
138
+ const backend = createFakeBackend({
139
+ reclaimStuckJobs: vi.fn().mockRejectedValue(taskError),
140
+ cleanupOldJobs: vi.fn().mockResolvedValue(5),
141
+ cleanupOldJobEvents: vi
142
+ .fn()
143
+ .mockRejectedValue(new Error('events boom')),
144
+ expireTimedOutWaitpoints: vi.fn().mockResolvedValue(1),
145
+ });
146
+ const supervisor = createSupervisor(backend, { onError });
147
+
148
+ // Act
149
+ const result = await supervisor.start();
150
+
151
+ // Assert
152
+ expect(onError).toHaveBeenCalledTimes(2);
153
+ expect(onError).toHaveBeenCalledWith(taskError);
154
+ expect(result.reclaimedJobs).toBe(0);
155
+ expect(result.cleanedUpJobs).toBe(5);
156
+ expect(result.cleanedUpEvents).toBe(0);
157
+ expect(result.expiredTokens).toBe(1);
158
+ });
159
+
160
+ it('wraps non-Error throws in an Error object', async () => {
161
+ // Setup
162
+ const onError = vi.fn();
163
+ const backend = createFakeBackend({
164
+ reclaimStuckJobs: vi.fn().mockRejectedValue('string error'),
165
+ });
166
+ const supervisor = createSupervisor(backend, { onError });
167
+
168
+ // Act
169
+ await supervisor.start();
170
+
171
+ // Assert
172
+ expect(onError).toHaveBeenCalledWith(expect.any(Error));
173
+ expect(onError.mock.calls[0][0].message).toBe('string error');
174
+ });
175
+ });
176
+
177
+ describe('startInBackground / stop', () => {
178
+ it('polls on interval and can be stopped', async () => {
179
+ // Setup
180
+ vi.useFakeTimers();
181
+ const backend = createFakeBackend();
182
+ const supervisor = createSupervisor(backend, { intervalMs: 1000 });
183
+
184
+ // Act
185
+ supervisor.startInBackground();
186
+ expect(supervisor.isRunning()).toBe(true);
187
+
188
+ // First run is immediate (microtask)
189
+ await vi.advanceTimersByTimeAsync(0);
190
+ expect(backend.reclaimStuckJobs).toHaveBeenCalledTimes(1);
191
+
192
+ // Advance to trigger second run
193
+ await vi.advanceTimersByTimeAsync(1000);
194
+ expect(backend.reclaimStuckJobs).toHaveBeenCalledTimes(2);
195
+
196
+ // Advance to trigger third run
197
+ await vi.advanceTimersByTimeAsync(1000);
198
+ expect(backend.reclaimStuckJobs).toHaveBeenCalledTimes(3);
199
+
200
+ // Stop
201
+ supervisor.stop();
202
+ expect(supervisor.isRunning()).toBe(false);
203
+
204
+ // No more runs after stop
205
+ await vi.advanceTimersByTimeAsync(5000);
206
+ expect(backend.reclaimStuckJobs).toHaveBeenCalledTimes(3);
207
+ });
208
+
209
+ it('does nothing when called while already running', async () => {
210
+ // Setup
211
+ vi.useFakeTimers();
212
+ const backend = createFakeBackend();
213
+ const supervisor = createSupervisor(backend, { intervalMs: 1000 });
214
+
215
+ // Act
216
+ supervisor.startInBackground();
217
+ supervisor.startInBackground(); // second call should be ignored
218
+ await vi.advanceTimersByTimeAsync(0);
219
+
220
+ // Assert -- only one loop running, single call
221
+ expect(backend.reclaimStuckJobs).toHaveBeenCalledTimes(1);
222
+
223
+ supervisor.stop();
224
+ });
225
+ });
226
+
227
+ describe('stopAndDrain', () => {
228
+ it('waits for current maintenance run to finish', async () => {
229
+ // Setup
230
+ vi.useFakeTimers();
231
+ let resolveTask!: () => void;
232
+ const slowTask = new Promise<number>((resolve) => {
233
+ resolveTask = () => resolve(2);
234
+ });
235
+ const backend = createFakeBackend({
236
+ reclaimStuckJobs: vi.fn().mockReturnValue(slowTask),
237
+ });
238
+ const supervisor = createSupervisor(backend, { intervalMs: 1000 });
239
+
240
+ // Act
241
+ supervisor.startInBackground();
242
+ // Let the loop start (but reclaimStuckJobs is blocked)
243
+ await vi.advanceTimersByTimeAsync(0);
244
+
245
+ let drained = false;
246
+ const drainPromise = supervisor.stopAndDrain().then(() => {
247
+ drained = true;
248
+ });
249
+
250
+ // Assert -- not drained yet because slowTask is still pending
251
+ expect(drained).toBe(false);
252
+ expect(supervisor.isRunning()).toBe(false);
253
+
254
+ // Resolve the slow task
255
+ resolveTask();
256
+ await drainPromise;
257
+
258
+ // Assert -- now drained
259
+ expect(drained).toBe(true);
260
+ });
261
+
262
+ it('resolves immediately when no maintenance run is in progress', async () => {
263
+ // Setup
264
+ const backend = createFakeBackend();
265
+ const supervisor = createSupervisor(backend);
266
+
267
+ // Act & Assert
268
+ await expect(supervisor.stopAndDrain()).resolves.toBeUndefined();
269
+ });
270
+
271
+ it('resolves after timeout if maintenance run hangs', async () => {
272
+ // Setup
273
+ vi.useFakeTimers();
274
+ const neverResolve = new Promise<number>(() => {});
275
+ const backend = createFakeBackend({
276
+ reclaimStuckJobs: vi.fn().mockReturnValue(neverResolve),
277
+ });
278
+ const supervisor = createSupervisor(backend, { intervalMs: 5000 });
279
+
280
+ // Act
281
+ supervisor.startInBackground();
282
+ await vi.advanceTimersByTimeAsync(0);
283
+
284
+ let drained = false;
285
+ const drainPromise = supervisor.stopAndDrain(500).then(() => {
286
+ drained = true;
287
+ });
288
+
289
+ // Assert -- not drained yet
290
+ expect(drained).toBe(false);
291
+
292
+ // Advance past the drain timeout
293
+ await vi.advanceTimersByTimeAsync(500);
294
+ await drainPromise;
295
+
296
+ // Assert -- drained by timeout
297
+ expect(drained).toBe(true);
298
+ });
299
+ });
300
+
301
+ describe('isRunning', () => {
302
+ it('returns false before start', () => {
303
+ // Setup
304
+ const backend = createFakeBackend();
305
+ const supervisor = createSupervisor(backend);
306
+
307
+ // Assert
308
+ expect(supervisor.isRunning()).toBe(false);
309
+ });
310
+
311
+ it('returns true after startInBackground', async () => {
312
+ // Setup
313
+ vi.useFakeTimers();
314
+ const backend = createFakeBackend();
315
+ const supervisor = createSupervisor(backend);
316
+
317
+ // Act
318
+ supervisor.startInBackground();
319
+
320
+ // Assert
321
+ expect(supervisor.isRunning()).toBe(true);
322
+
323
+ supervisor.stop();
324
+ });
325
+
326
+ it('returns false after stop', async () => {
327
+ // Setup
328
+ vi.useFakeTimers();
329
+ const backend = createFakeBackend();
330
+ const supervisor = createSupervisor(backend);
331
+
332
+ // Act
333
+ supervisor.startInBackground();
334
+ supervisor.stop();
335
+
336
+ // Assert
337
+ expect(supervisor.isRunning()).toBe(false);
338
+ });
339
+ });
340
+ });
@@ -0,0 +1,162 @@
1
+ import { QueueBackend } from './backend.js';
2
+ import { setLogContext, log } from './log-context.js';
3
+ import { Supervisor, SupervisorOptions, SupervisorRunResult } from './types.js';
4
+
5
+ /**
6
+ * Creates a background supervisor that periodically runs maintenance tasks:
7
+ * reclaiming stuck jobs, cleaning up old jobs/events, and expiring
8
+ * timed-out waitpoint tokens.
9
+ *
10
+ * @param backend - The queue backend (Postgres or Redis) to run maintenance against.
11
+ * @param options - Configuration for intervals, retention, and feature toggles.
12
+ * @returns A {@link Supervisor} with `start`, `startInBackground`, `stop`,
13
+ * `stopAndDrain`, and `isRunning` methods.
14
+ */
15
+ export const createSupervisor = (
16
+ backend: QueueBackend,
17
+ options: SupervisorOptions = {},
18
+ ): Supervisor => {
19
+ const {
20
+ intervalMs = 60_000,
21
+ stuckJobsTimeoutMinutes = 10,
22
+ cleanupJobsDaysToKeep = 30,
23
+ cleanupEventsDaysToKeep = 30,
24
+ cleanupBatchSize = 1000,
25
+ reclaimStuckJobs = true,
26
+ expireTimedOutTokens = true,
27
+ onError = (error: Error) =>
28
+ console.error('Supervisor maintenance error:', error),
29
+ verbose = false,
30
+ } = options;
31
+
32
+ let running = false;
33
+ let timeoutId: NodeJS.Timeout | null = null;
34
+ let currentRunPromise: Promise<SupervisorRunResult> | null = null;
35
+
36
+ setLogContext(verbose);
37
+
38
+ /**
39
+ * Executes every maintenance task once, isolating failures so one
40
+ * broken task does not prevent the others from running.
41
+ */
42
+ const runOnce = async (): Promise<SupervisorRunResult> => {
43
+ setLogContext(verbose);
44
+
45
+ const result: SupervisorRunResult = {
46
+ reclaimedJobs: 0,
47
+ cleanedUpJobs: 0,
48
+ cleanedUpEvents: 0,
49
+ expiredTokens: 0,
50
+ };
51
+
52
+ if (reclaimStuckJobs) {
53
+ try {
54
+ result.reclaimedJobs = await backend.reclaimStuckJobs(
55
+ stuckJobsTimeoutMinutes,
56
+ );
57
+ if (result.reclaimedJobs > 0) {
58
+ log(`Supervisor: reclaimed ${result.reclaimedJobs} stuck jobs`);
59
+ }
60
+ } catch (e) {
61
+ onError(e instanceof Error ? e : new Error(String(e)));
62
+ }
63
+ }
64
+
65
+ if (cleanupJobsDaysToKeep > 0) {
66
+ try {
67
+ result.cleanedUpJobs = await backend.cleanupOldJobs(
68
+ cleanupJobsDaysToKeep,
69
+ cleanupBatchSize,
70
+ );
71
+ if (result.cleanedUpJobs > 0) {
72
+ log(`Supervisor: cleaned up ${result.cleanedUpJobs} old jobs`);
73
+ }
74
+ } catch (e) {
75
+ onError(e instanceof Error ? e : new Error(String(e)));
76
+ }
77
+ }
78
+
79
+ if (cleanupEventsDaysToKeep > 0) {
80
+ try {
81
+ result.cleanedUpEvents = await backend.cleanupOldJobEvents(
82
+ cleanupEventsDaysToKeep,
83
+ cleanupBatchSize,
84
+ );
85
+ if (result.cleanedUpEvents > 0) {
86
+ log(
87
+ `Supervisor: cleaned up ${result.cleanedUpEvents} old job events`,
88
+ );
89
+ }
90
+ } catch (e) {
91
+ onError(e instanceof Error ? e : new Error(String(e)));
92
+ }
93
+ }
94
+
95
+ if (expireTimedOutTokens) {
96
+ try {
97
+ result.expiredTokens = await backend.expireTimedOutWaitpoints();
98
+ if (result.expiredTokens > 0) {
99
+ log(`Supervisor: expired ${result.expiredTokens} timed-out tokens`);
100
+ }
101
+ } catch (e) {
102
+ onError(e instanceof Error ? e : new Error(String(e)));
103
+ }
104
+ }
105
+
106
+ return result;
107
+ };
108
+
109
+ return {
110
+ start: async () => {
111
+ return runOnce();
112
+ },
113
+
114
+ startInBackground: () => {
115
+ if (running) return;
116
+ log('Supervisor: starting background maintenance loop');
117
+ running = true;
118
+
119
+ const loop = async () => {
120
+ if (!running) return;
121
+ currentRunPromise = runOnce();
122
+ await currentRunPromise;
123
+ currentRunPromise = null;
124
+ if (running) {
125
+ timeoutId = setTimeout(loop, intervalMs);
126
+ }
127
+ };
128
+
129
+ loop();
130
+ },
131
+
132
+ stop: () => {
133
+ running = false;
134
+ if (timeoutId !== null) {
135
+ clearTimeout(timeoutId);
136
+ timeoutId = null;
137
+ }
138
+ log('Supervisor: stopped');
139
+ },
140
+
141
+ stopAndDrain: async (timeoutMs = 30_000) => {
142
+ running = false;
143
+ if (timeoutId !== null) {
144
+ clearTimeout(timeoutId);
145
+ timeoutId = null;
146
+ }
147
+
148
+ if (currentRunPromise) {
149
+ log('Supervisor: draining current maintenance run…');
150
+ await Promise.race([
151
+ currentRunPromise,
152
+ new Promise<void>((resolve) => setTimeout(resolve, timeoutMs)),
153
+ ]);
154
+ currentRunPromise = null;
155
+ }
156
+
157
+ log('Supervisor: drained and stopped');
158
+ },
159
+
160
+ isRunning: () => running,
161
+ };
162
+ };
package/src/test-util.ts CHANGED
@@ -65,3 +65,35 @@ export async function destroyTestDb(dbName: string) {
65
65
  await adminPool.query(`DROP DATABASE IF EXISTS ${dbName}`);
66
66
  await adminPool.end();
67
67
  }
68
+
69
+ /**
70
+ * Create a Redis test setup with a unique prefix to isolate tests.
71
+ * Returns the prefix and a cleanup function.
72
+ */
73
+ export function createRedisTestPrefix(): string {
74
+ return `test_${randomUUID().replace(/-/g, '').slice(0, 12)}:`;
75
+ }
76
+
77
+ /**
78
+ * Flush all keys with the given prefix from Redis.
79
+ */
80
+ export async function cleanupRedisPrefix(
81
+ redisClient: any,
82
+ prefix: string,
83
+ ): Promise<void> {
84
+ // Use SCAN to find all keys with the prefix and delete them
85
+ let cursor = '0';
86
+ do {
87
+ const [nextCursor, keys] = await redisClient.scan(
88
+ cursor,
89
+ 'MATCH',
90
+ `${prefix}*`,
91
+ 'COUNT',
92
+ 100,
93
+ );
94
+ cursor = nextCursor;
95
+ if (keys.length > 0) {
96
+ await redisClient.del(...keys);
97
+ }
98
+ } while (cursor !== '0');
99
+ }