@usequark/quark-core 2.5.4

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 (68) hide show
  1. package/README.md +63 -0
  2. package/package.json +80 -0
  3. package/src/admin-auth.js +121 -0
  4. package/src/admin.js +352 -0
  5. package/src/admin.test.js +257 -0
  6. package/src/auth/index.js +160 -0
  7. package/src/auth/middleware.js +106 -0
  8. package/src/auth/middleware.test.js +116 -0
  9. package/src/auth/password.js +9 -0
  10. package/src/auth.test.js +235 -0
  11. package/src/authorization.js +235 -0
  12. package/src/authorization.test.js +314 -0
  13. package/src/cache.js +137 -0
  14. package/src/cache.test.js +217 -0
  15. package/src/core.js +32 -0
  16. package/src/csrf.js +122 -0
  17. package/src/csrf.test.js +169 -0
  18. package/src/db-instrumentation.js +281 -0
  19. package/src/db-instrumentation.test.js +284 -0
  20. package/src/db-metrics.js +26 -0
  21. package/src/db.js +218 -0
  22. package/src/db.test.js +365 -0
  23. package/src/email-templates.js +134 -0
  24. package/src/email-templates.test.js +124 -0
  25. package/src/email.js +314 -0
  26. package/src/email.test.js +498 -0
  27. package/src/error-reporter.js +266 -0
  28. package/src/error-reporter.test.js +236 -0
  29. package/src/errors.js +192 -0
  30. package/src/errors.test.js +128 -0
  31. package/src/exports.test.js +154 -0
  32. package/src/file-validation.js +249 -0
  33. package/src/file-validation.test.js +207 -0
  34. package/src/index.js +58 -0
  35. package/src/locale.js +21 -0
  36. package/src/logger.js +182 -0
  37. package/src/logger.test.js +287 -0
  38. package/src/mail.js +50 -0
  39. package/src/metrics.js +379 -0
  40. package/src/metrics.test.js +380 -0
  41. package/src/multipart.js +144 -0
  42. package/src/multipart.test.js +188 -0
  43. package/src/pagination.js +139 -0
  44. package/src/pagination.test.js +210 -0
  45. package/src/query-builder.js +183 -0
  46. package/src/query-builder.test.js +234 -0
  47. package/src/queue/index.js +446 -0
  48. package/src/queue/queue.test.js +365 -0
  49. package/src/rate-limiter.js +253 -0
  50. package/src/rate-limiter.test.js +130 -0
  51. package/src/redis.js +154 -0
  52. package/src/redis.test.js +150 -0
  53. package/src/request-logger.js +211 -0
  54. package/src/request-logger.test.js +286 -0
  55. package/src/sms.js +208 -0
  56. package/src/storage-s3.js +236 -0
  57. package/src/storage.js +214 -0
  58. package/src/storage.test.js +457 -0
  59. package/src/stripe.js +89 -0
  60. package/src/testing/factories.js +89 -0
  61. package/src/testing/helpers.js +266 -0
  62. package/src/testing/index.js +46 -0
  63. package/src/testing/mocks.js +480 -0
  64. package/src/testing/testing.test.js +543 -0
  65. package/src/types.js +74 -0
  66. package/src/utils.js +219 -0
  67. package/src/utils.test.js +193 -0
  68. package/src/validation.js +26 -0
@@ -0,0 +1,234 @@
1
+ import assert from "node:assert";
2
+ import { describe, test } from "node:test";
3
+ import { createQueryBuilder, QueryBuilder } from "./query-builder.js";
4
+
5
+ describe("QueryBuilder - Filter", () => {
6
+ test("adds simple equality filter", () => {
7
+ const qb = new QueryBuilder({
8
+ filterableFields: ["status", "category"],
9
+ });
10
+
11
+ qb.filter("status", "eq", "published");
12
+ const where = qb.toWhere();
13
+
14
+ assert.deepStrictEqual(where, { status: { equals: "published" } });
15
+ });
16
+
17
+ test("adds multiple filters with AND", () => {
18
+ const qb = new QueryBuilder({
19
+ filterableFields: ["status", "authorId"],
20
+ });
21
+
22
+ qb.filter("status", "eq", "published").filter("authorId", "eq", "user-123");
23
+
24
+ const where = qb.toWhere();
25
+ assert.deepStrictEqual(where, {
26
+ AND: [
27
+ { status: { equals: "published" } },
28
+ { authorId: { equals: "user-123" } },
29
+ ],
30
+ });
31
+ });
32
+
33
+ test("supports contains operator", () => {
34
+ const qb = new QueryBuilder({ filterableFields: ["title"] });
35
+ qb.filter("title", "contains", "hello");
36
+
37
+ assert.deepStrictEqual(qb.toWhere(), { title: { contains: "hello" } });
38
+ });
39
+
40
+ test("supports gt/gte/lt/lte operators", () => {
41
+ const qb = new QueryBuilder({ filterableFields: ["views"] });
42
+ qb.filter("views", "gte", 100);
43
+
44
+ assert.deepStrictEqual(qb.toWhere(), { views: { gte: 100 } });
45
+ });
46
+
47
+ test("supports in operator with array", () => {
48
+ const qb = new QueryBuilder({ filterableFields: ["category"] });
49
+ qb.filter("category", "in", ["tech", "science"]);
50
+
51
+ assert.deepStrictEqual(qb.toWhere(), {
52
+ category: { in: ["tech", "science"] },
53
+ });
54
+ });
55
+
56
+ test("supports not (ne) operator", () => {
57
+ const qb = new QueryBuilder({ filterableFields: ["status"] });
58
+ qb.filter("status", "ne", "draft");
59
+
60
+ assert.deepStrictEqual(qb.toWhere(), { status: { not: "draft" } });
61
+ });
62
+
63
+ test("throws on non-filterable field", () => {
64
+ const qb = new QueryBuilder({ filterableFields: ["status"] });
65
+ assert.throws(() => qb.filter("password", "eq", "test"), /not filterable/);
66
+ });
67
+
68
+ test("throws on invalid operator", () => {
69
+ const qb = new QueryBuilder({ filterableFields: ["status"] });
70
+ assert.throws(() => qb.filter("status", "regex", "test"), /not supported/);
71
+ });
72
+ });
73
+
74
+ describe("QueryBuilder - Search", () => {
75
+ test("creates OR condition across search fields", () => {
76
+ const qb = new QueryBuilder({
77
+ searchFields: ["title", "content"],
78
+ });
79
+
80
+ qb.search("hello world");
81
+ const where = qb.toWhere();
82
+
83
+ assert.deepStrictEqual(where, {
84
+ OR: [
85
+ { title: { contains: "hello world", mode: "insensitive" } },
86
+ { content: { contains: "hello world", mode: "insensitive" } },
87
+ ],
88
+ });
89
+ });
90
+
91
+ test("combines search with filters", () => {
92
+ const qb = new QueryBuilder({
93
+ searchFields: ["title"],
94
+ filterableFields: ["status"],
95
+ });
96
+
97
+ qb.search("test").filter("status", "eq", "published");
98
+
99
+ const where = qb.toWhere();
100
+ assert.deepStrictEqual(where, {
101
+ AND: [
102
+ { status: { equals: "published" } },
103
+ { OR: [{ title: { contains: "test", mode: "insensitive" } }] },
104
+ ],
105
+ });
106
+ });
107
+
108
+ test("ignores empty search term", () => {
109
+ const qb = new QueryBuilder({ searchFields: ["title"] });
110
+ qb.search("");
111
+
112
+ assert.deepStrictEqual(qb.toWhere(), {});
113
+ });
114
+
115
+ test("trims search term", () => {
116
+ const qb = new QueryBuilder({ searchFields: ["title"] });
117
+ qb.search(" test ");
118
+
119
+ const where = qb.toWhere();
120
+ assert.ok(where.OR[0].title.contains === "test");
121
+ });
122
+
123
+ test("throws when no search fields configured", () => {
124
+ const qb = new QueryBuilder({ searchFields: [] });
125
+ assert.throws(() => qb.search("test"), /No search fields/);
126
+ });
127
+ });
128
+
129
+ describe("QueryBuilder - Sort", () => {
130
+ test("creates orderBy clause with asc", () => {
131
+ const qb = new QueryBuilder({ sortableFields: ["createdAt"] });
132
+ qb.sort("createdAt", "asc");
133
+
134
+ assert.deepStrictEqual(qb.toOrderBy(), { createdAt: "asc" });
135
+ });
136
+
137
+ test("creates orderBy clause with desc", () => {
138
+ const qb = new QueryBuilder({ sortableFields: ["title"] });
139
+ qb.sort("title", "desc");
140
+
141
+ assert.deepStrictEqual(qb.toOrderBy(), { title: "desc" });
142
+ });
143
+
144
+ test("defaults to asc when direction not specified", () => {
145
+ const qb = new QueryBuilder({ sortableFields: ["createdAt"] });
146
+ qb.sort("createdAt");
147
+
148
+ assert.deepStrictEqual(qb.toOrderBy(), { createdAt: "asc" });
149
+ });
150
+
151
+ test("returns undefined when no sort specified", () => {
152
+ const qb = new QueryBuilder({ sortableFields: ["createdAt"] });
153
+ assert.strictEqual(qb.toOrderBy(), undefined);
154
+ });
155
+
156
+ test("throws on non-sortable field", () => {
157
+ const qb = new QueryBuilder({ sortableFields: ["createdAt"] });
158
+ assert.throws(() => qb.sort("password"), /not sortable/);
159
+ });
160
+
161
+ test("throws on invalid direction", () => {
162
+ const qb = new QueryBuilder({ sortableFields: ["createdAt"] });
163
+ assert.throws(
164
+ () => qb.sort("createdAt", "random"),
165
+ /must be "asc" or "desc"/,
166
+ );
167
+ });
168
+ });
169
+
170
+ describe("QueryBuilder - Integration", () => {
171
+ test("combines filter, search, and sort", () => {
172
+ const qb = new QueryBuilder({
173
+ filterableFields: ["status", "authorId"],
174
+ searchFields: ["title", "content"],
175
+ sortableFields: ["createdAt", "title"],
176
+ });
177
+
178
+ qb.filter("status", "eq", "published")
179
+ .search("typescript")
180
+ .sort("createdAt", "desc");
181
+
182
+ const where = qb.toWhere();
183
+ const orderBy = qb.toOrderBy();
184
+
185
+ assert.deepStrictEqual(where, {
186
+ AND: [
187
+ { status: { equals: "published" } },
188
+ {
189
+ OR: [
190
+ { title: { contains: "typescript", mode: "insensitive" } },
191
+ { content: { contains: "typescript", mode: "insensitive" } },
192
+ ],
193
+ },
194
+ ],
195
+ });
196
+
197
+ assert.deepStrictEqual(orderBy, { createdAt: "desc" });
198
+ });
199
+
200
+ test("reset clears all conditions", () => {
201
+ const qb = new QueryBuilder({
202
+ filterableFields: ["status"],
203
+ searchFields: ["title"],
204
+ sortableFields: ["createdAt"],
205
+ });
206
+
207
+ qb.filter("status", "eq", "published")
208
+ .search("test")
209
+ .sort("createdAt", "desc");
210
+
211
+ qb.reset();
212
+
213
+ assert.deepStrictEqual(qb.toWhere(), {});
214
+ assert.strictEqual(qb.toOrderBy(), undefined);
215
+ });
216
+
217
+ test("empty query builder returns empty where", () => {
218
+ const qb = new QueryBuilder({
219
+ filterableFields: ["status"],
220
+ searchFields: ["title"],
221
+ sortableFields: ["createdAt"],
222
+ });
223
+
224
+ assert.deepStrictEqual(qb.toWhere(), {});
225
+ assert.strictEqual(qb.toOrderBy(), undefined);
226
+ });
227
+ });
228
+
229
+ describe("QueryBuilder - Factory", () => {
230
+ test("createQueryBuilder returns QueryBuilder instance", () => {
231
+ const qb = createQueryBuilder({ filterableFields: ["status"] });
232
+ assert.ok(qb instanceof QueryBuilder);
233
+ });
234
+ });
@@ -0,0 +1,446 @@
1
+ /**
2
+ * @techstream/quark-core - Job Queue Module
3
+ * Provides BullMQ queue initialization and management
4
+ */
5
+
6
+ import { Queue, QueueEvents, Worker } from "bullmq";
7
+ import { ServiceError } from "../errors.js";
8
+ import { createLogger } from "../logger.js";
9
+ import { jobDuration, jobQueueDepth, jobsProcessedTotal } from "../metrics.js";
10
+ import { resolveRedisConnection } from "../redis.js";
11
+
12
+ const logger = createLogger("queue");
13
+
14
+ /**
15
+ * Returns the default Redis config for BullMQ connections.
16
+ * Evaluated lazily so env vars set after module load (e.g. via dotenv) are respected.
17
+ * Uses resolveRedisConnection() - supports REDIS_URL, REDIS_HOST/REDIS_PORT, and localhost fallback.
18
+ */
19
+ function getDefaultRedisConfig() {
20
+ return {
21
+ ...resolveRedisConnection(),
22
+ db: parseInt(process.env.REDIS_DB || "0", 10),
23
+ retryStrategy: (times) => {
24
+ const delay = Math.min(times * 50, 2000);
25
+ return delay;
26
+ },
27
+ };
28
+ }
29
+
30
+ /**
31
+ * Redis config for health checks only.
32
+ * Disables the offline queue so commands fail immediately if not connected,
33
+ * and sets a short connect timeout so we don't hang indefinitely.
34
+ */
35
+ function getHealthCheckRedisConfig() {
36
+ return {
37
+ ...getDefaultRedisConfig(),
38
+ enableOfflineQueue: false,
39
+ connectTimeout: 3000,
40
+ maxRetriesPerRequest: 0,
41
+ retryStrategy: () => null, // don't auto-retry - let waitForRedis control the loop
42
+ };
43
+ }
44
+
45
+ /**
46
+ * Map to store singleton queue instances
47
+ */
48
+ const queues = new Map();
49
+ const queueConnections = new Map();
50
+
51
+ /**
52
+ * Evicts a queue instance from the singleton registries.
53
+ * Only evicts when this exact instance is the registered one, so a stale
54
+ * close() can never remove a newer queue created under the same name.
55
+ * @param {string} name - Queue name
56
+ * @param {Queue} queue - Queue instance being evicted
57
+ */
58
+ const evictQueue = (name, queue) => {
59
+ if (queues.get(name) === queue) {
60
+ queues.delete(name);
61
+ queueConnections.delete(name);
62
+ }
63
+ };
64
+
65
+ /**
66
+ * Best-effort check for an already-closed BullMQ queue.
67
+ * Prefers a public isClosed() when BullMQ exposes one; BullMQ 5 has none,
68
+ * so fall back to QueueBase's own `closing`/`closed` fields.
69
+ * @param {Queue} queue - Queue instance
70
+ * @returns {boolean}
71
+ */
72
+ const isQueueClosed = (queue) => {
73
+ if (typeof queue.isClosed === "function") {
74
+ return queue.isClosed();
75
+ }
76
+ return Boolean(queue.closing || queue.closed);
77
+ };
78
+
79
+ const getRedisAddress = (redisConfig) => {
80
+ const cfg = redisConfig || getDefaultRedisConfig();
81
+ return `${cfg.host}:${cfg.port}`;
82
+ };
83
+
84
+ /**
85
+ * Creates or retrieves a singleton BullMQ queue.
86
+ *
87
+ * Close-safe: `queue.close()` evicts the instance from the registry, so a
88
+ * later `createQueue(name)` returns a fresh, usable queue instead of the
89
+ * closed one. While open, repeated calls return the same instance.
90
+ *
91
+ * @param {string} name - Queue name
92
+ * @param {Object} options - Queue options
93
+ * @param {Object} options.redis - Redis connection config
94
+ * @param {Object} options.defaultJobOptions - Default job options
95
+ * @returns {Queue} BullMQ Queue instance
96
+ */
97
+ export const createQueue = (name, options = {}) => {
98
+ const existing = queues.get(name);
99
+ if (existing) {
100
+ // A closed queue is unusable - never hand it out again.
101
+ // (close() evicts below; this is a defensive check for a queue closed
102
+ // through some other path, e.g. during shutdown.)
103
+ if (isQueueClosed(existing)) {
104
+ evictQueue(name, existing);
105
+ } else {
106
+ return existing;
107
+ }
108
+ }
109
+
110
+ const {
111
+ redis = getDefaultRedisConfig(),
112
+ defaultJobOptions = {
113
+ attempts: 3,
114
+ backoff: {
115
+ type: "exponential",
116
+ delay: 2000,
117
+ },
118
+ removeOnComplete: { age: 60, count: 100 },
119
+ },
120
+ ...queueOptions
121
+ } = options;
122
+
123
+ const queue = new Queue(name, {
124
+ connection: redis,
125
+ defaultJobOptions,
126
+ ...queueOptions,
127
+ });
128
+
129
+ queues.set(name, queue);
130
+ queueConnections.set(name, redis);
131
+
132
+ // Close-safe singleton: closing a queue evicts it from the registry so the
133
+ // next createQueue(name) builds a fresh instance instead of returning a
134
+ // poisoned, already-closed one. Eviction happens regardless of whether
135
+ // close() resolves - a failed close leaves the instance equally unusable.
136
+ const originalClose = queue.close.bind(queue);
137
+ queue.close = async (...closeArgs) => {
138
+ try {
139
+ return await originalClose(...closeArgs);
140
+ } finally {
141
+ evictQueue(name, queue);
142
+ }
143
+ };
144
+
145
+ // Clean up on graceful shutdown
146
+ queue.on("error", (error) => {
147
+ logger.error(`Queue "${name}" encountered an error`, {
148
+ error: error.message,
149
+ code: error.code,
150
+ });
151
+ });
152
+
153
+ return queue;
154
+ };
155
+
156
+ /**
157
+ * Creates a job processor worker
158
+ * @param {string} queueName - Queue name
159
+ * @param {Function} handler - Job handler function(job) => Promise<any>
160
+ * @param {Object} options - Worker options
161
+ * @param {Object} options.redis - Redis connection config
162
+ * @param {number} options.concurrency - Concurrency level
163
+ * @returns {Worker} BullMQ Worker instance
164
+ */
165
+ export const createWorker = (queueName, handler, options = {}) => {
166
+ const {
167
+ redis = getDefaultRedisConfig(),
168
+ concurrency = 1,
169
+ ...workerOptions
170
+ } = options;
171
+
172
+ const worker = new Worker(queueName, handler, {
173
+ connection: redis,
174
+ concurrency,
175
+ ...workerOptions,
176
+ });
177
+
178
+ worker.on("error", (error) => {
179
+ logger.error(`Worker for queue "${queueName}" encountered an error`, {
180
+ error: error.message,
181
+ code: error.code,
182
+ });
183
+ });
184
+
185
+ worker.on("completed", (job) => {
186
+ jobsProcessedTotal.inc({ queue: queueName, status: "completed" });
187
+ if (job.processedOn != null && job.finishedOn != null) {
188
+ jobDuration.observe(
189
+ { queue: queueName, name: job.name },
190
+ (job.finishedOn - job.processedOn) / 1000,
191
+ );
192
+ }
193
+ });
194
+
195
+ worker.on("failed", (job, error) => {
196
+ logger.error(`Job ${job?.id} in queue "${queueName}" failed`, {
197
+ jobId: job?.id,
198
+ queue: queueName,
199
+ error: error.message,
200
+ attempts: job?.attemptsMade,
201
+ });
202
+ jobsProcessedTotal.inc({ queue: queueName, status: "failed" });
203
+ if (job?.processedOn != null && job?.finishedOn != null) {
204
+ jobDuration.observe(
205
+ { queue: queueName, name: job.name },
206
+ (job.finishedOn - job.processedOn) / 1000,
207
+ );
208
+ }
209
+ });
210
+
211
+ return worker;
212
+ };
213
+
214
+ /**
215
+ * Gets or creates queue events listener
216
+ * @param {string} queueName - Queue name
217
+ * @param {Object} options - QueueEvents options
218
+ * @returns {QueueEvents} BullMQ QueueEvents instance
219
+ */
220
+ export const createQueueEvents = (queueName, options = {}) => {
221
+ const { redis = getDefaultRedisConfig(), ...eventsOptions } = options;
222
+
223
+ return new QueueEvents(queueName, {
224
+ connection: redis,
225
+ ...eventsOptions,
226
+ });
227
+ };
228
+
229
+ /**
230
+ * Utility to add a job to a queue with error handling.
231
+ * Supports optional deduplication via a caller-provided dedupKey, mapped onto
232
+ * BullMQ's own `deduplication` job option.
233
+ *
234
+ * Deduplication used to be hand-rolled with a raw `SET NX` against
235
+ * `queue.client`. That reached into BullMQ internals for a feature BullMQ has
236
+ * provided natively since v5, and BullMQ 6 removed the public `client` getter -
237
+ * so the `SET NX` threw, the failure was swallowed by a best-effort `catch`,
238
+ * and deduplication silently stopped working. Use the supported option instead.
239
+ *
240
+ * @param {Queue} queue - BullMQ Queue instance
241
+ * @param {string} jobName - Job name/type (e.g., 'send-welcome-email')
242
+ * @param {Object} data - Job data
243
+ * @param {Object} jobOptions - Job-specific options
244
+ * @param {string} [jobOptions.dedupKey] - Unique key for deduplication. If set, only one job
245
+ * with this key will be created within the dedupTTL window.
246
+ * @param {number} [jobOptions.dedupTTL=86400] - TTL in seconds for the dedup key (default 24h).
247
+ * The dedup key auto-expires, allowing legitimate re-sends after the TTL.
248
+ * @returns {Promise<Job|null>} Queued job, or null if BullMQ throttled the add
249
+ * because a job with the same `dedupKey` is already in flight.
250
+ * @throws {ServiceError} When the job fails to queue (not on dedup skip).
251
+ */
252
+ export const addJob = async (queue, jobName, data, jobOptions = {}) => {
253
+ // Map our dedup options onto BullMQ's native `deduplication` option.
254
+ // BullMQ expresses the TTL in milliseconds; ours is documented in seconds.
255
+ const { dedupKey, dedupTTL, ...bullOptions } = jobOptions;
256
+ const options = dedupKey
257
+ ? {
258
+ ...bullOptions,
259
+ deduplication: {
260
+ id: dedupKey,
261
+ ttl: (dedupTTL ?? 86_400) * 1000,
262
+ },
263
+ }
264
+ : bullOptions;
265
+
266
+ try {
267
+ const job = await queue.add(jobName, data, options);
268
+ return job;
269
+ } catch (error) {
270
+ throw new ServiceError(
271
+ "BullMQ",
272
+ `Failed to add job "${jobName}" to queue "${queue.name}": ${error.message}`,
273
+ 500,
274
+ );
275
+ }
276
+ };
277
+
278
+ /**
279
+ * Atomically registers or updates a repeatable job scheduler.
280
+ * Uses BullMQ's upsertJobScheduler (Redis Lua script) to prevent duplicate
281
+ * schedulers on worker restart - unlike queue.add() + repeat which is not atomic.
282
+ *
283
+ * @param {Queue} queue - BullMQ Queue instance
284
+ * @param {string} schedulerId - Unique identifier for this scheduler (e.g. 'cleanup-orphaned-files')
285
+ * @param {string} jobName - Job name/type (e.g., 'cleanup-orphaned-files')
286
+ * @param {Object} data - Job data passed to each execution
287
+ * @param {Object} repeatOptions - BullMQ repeat options (pattern or every + optional tz)
288
+ * @param {string} [repeatOptions.pattern] - Cron pattern (e.g. '0 8 * * *')
289
+ * @param {number} [repeatOptions.every] - Interval in milliseconds (e.g. 86400000)
290
+ * @param {string} [repeatOptions.tz] - Timezone for cron pattern (e.g. 'Pacific/Auckland')
291
+ * @returns {Promise<JobScheduler>} The upserted job scheduler
292
+ */
293
+ export const addRepeatableJob = async (
294
+ queue,
295
+ schedulerId,
296
+ jobName,
297
+ data,
298
+ repeatOptions,
299
+ ) => {
300
+ return queue.upsertJobScheduler(schedulerId, repeatOptions, {
301
+ name: jobName,
302
+ data,
303
+ });
304
+ };
305
+
306
+ /**
307
+ * Utility to get job status and progress
308
+ * @param {Job} job - BullMQ Job instance
309
+ * @returns {Promise<Object>} Job status information
310
+ */
311
+ export const getJobStatus = async (job) => {
312
+ return {
313
+ id: job.id,
314
+ state: await job.getState(),
315
+ progress: job.progress(),
316
+ attempts: job.attemptsMade,
317
+ maxAttempts: job.opts.attempts,
318
+ data: job.data,
319
+ };
320
+ };
321
+
322
+ /**
323
+ * Clears all jobs from a queue (use with caution!)
324
+ * @param {Queue} queue - BullMQ Queue instance
325
+ * @param {Object} options - Clear options
326
+ * @returns {Promise<void>}
327
+ */
328
+ export const clearQueue = async (queue, options = {}) => {
329
+ const { grace = 5000 } = options;
330
+ try {
331
+ await queue.clean(grace, 100);
332
+ } catch (error) {
333
+ logger.error(`Failed to clear queue "${queue.name}"`, {
334
+ error: error.message,
335
+ });
336
+ throw error;
337
+ }
338
+ };
339
+
340
+ /**
341
+ * Gracefully closes all queues, workers, and listeners
342
+ * Useful for process shutdown
343
+ * @returns {Promise<void>}
344
+ */
345
+ export const closeAllQueues = async () => {
346
+ try {
347
+ // Iterate over a snapshot: close() evicts each queue from the Map via
348
+ // the close-safe wrapper, so a live iteration would mutate as we walk it.
349
+ for (const [name, queue] of [...queues]) {
350
+ await queue.close();
351
+ logger.info(`Queue "${name}" closed`);
352
+ }
353
+ queues.clear();
354
+ queueConnections.clear();
355
+ } catch (error) {
356
+ logger.error("Failed to close queues during shutdown", {
357
+ error: error.message,
358
+ });
359
+ throw error;
360
+ }
361
+ };
362
+
363
+ /**
364
+ * Health check for Redis connectivity
365
+ *
366
+ * Uses BullMQ's public `waitUntilReady()` rather than the raw `client` getter.
367
+ * `client` is not part of BullMQ's stable surface: BullMQ 6 removed it, which
368
+ * made this check report a healthy Redis as unavailable. `waitUntilReady()`
369
+ * is the supported readiness primitive and behaves the same on v5 and v6.
370
+ *
371
+ * @returns {Promise<boolean>} True if Redis is accessible
372
+ * @throws {ServiceError} When Redis is unreachable or misconfigured
373
+ */
374
+ export const checkQueueHealth = async () => {
375
+ const defaultRedisAddr = getRedisAddress();
376
+
377
+ if (queues.size === 0) {
378
+ // Create a temporary queue to test connectivity.
379
+ // Attach a no-op error listener so ioredis connection errors don't
380
+ // leak as unhandled 'error' events and print raw stack traces to stderr.
381
+ const testQueue = new Queue("_health_check", {
382
+ connection: getHealthCheckRedisConfig(),
383
+ });
384
+ testQueue.on("error", () => {});
385
+ try {
386
+ await testQueue.waitUntilReady();
387
+ return true;
388
+ } catch (error) {
389
+ const code = error.code ?? null;
390
+ const detail = error.message || error.code || "connection failed";
391
+ const suffix = code ? ` (${code})` : "";
392
+ const summary = detail === code ? "" : `: ${detail}`;
393
+ throw new ServiceError(
394
+ "Redis",
395
+ `Redis unavailable at ${defaultRedisAddr}${suffix}${summary}`,
396
+ 503,
397
+ );
398
+ } finally {
399
+ // Always clean up the temporary queue
400
+ await testQueue.close().catch(() => {});
401
+ }
402
+ }
403
+
404
+ const [firstQueue] = queues.values();
405
+ const redisAddr = getRedisAddress(queueConnections.get(firstQueue.name));
406
+
407
+ try {
408
+ await firstQueue.waitUntilReady();
409
+ return true;
410
+ } catch (error) {
411
+ const code = error.code ?? null;
412
+ const detail = error.message || error.code || "connection failed";
413
+ const suffix = code ? ` (${code})` : "";
414
+ const summary = detail === code ? "" : `: ${detail}`;
415
+ throw new ServiceError(
416
+ "Redis",
417
+ `Redis unavailable at ${redisAddr}${suffix}${summary}`,
418
+ 503,
419
+ );
420
+ }
421
+ };
422
+
423
+ /**
424
+ * Returns all registered queue instances.
425
+ * @returns {Map<string, Queue>}
426
+ */
427
+ export const getRegisteredQueues = () => queues;
428
+
429
+ /**
430
+ * Refreshes the job_queue_depth gauge for all registered queues.
431
+ * Call periodically (e.g. every 30 s) in the worker to keep the gauge accurate.
432
+ * @returns {Promise<void>}
433
+ */
434
+ export const updateQueueDepths = async () => {
435
+ for (const [name, queue] of queues) {
436
+ try {
437
+ const waiting = await queue.getWaitingCount();
438
+ jobQueueDepth.set({ queue: name }, waiting);
439
+ } catch (error) {
440
+ // Non-critical - depth gauge is best-effort
441
+ logger.warn(`Failed to update depth gauge for queue "${name}"`, {
442
+ error: error.message,
443
+ });
444
+ }
445
+ }
446
+ };