@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.
- package/README.md +63 -0
- package/package.json +80 -0
- package/src/admin-auth.js +121 -0
- package/src/admin.js +352 -0
- package/src/admin.test.js +257 -0
- package/src/auth/index.js +160 -0
- package/src/auth/middleware.js +106 -0
- package/src/auth/middleware.test.js +116 -0
- package/src/auth/password.js +9 -0
- package/src/auth.test.js +235 -0
- package/src/authorization.js +235 -0
- package/src/authorization.test.js +314 -0
- package/src/cache.js +137 -0
- package/src/cache.test.js +217 -0
- package/src/core.js +32 -0
- package/src/csrf.js +122 -0
- package/src/csrf.test.js +169 -0
- package/src/db-instrumentation.js +281 -0
- package/src/db-instrumentation.test.js +284 -0
- package/src/db-metrics.js +26 -0
- package/src/db.js +218 -0
- package/src/db.test.js +365 -0
- package/src/email-templates.js +134 -0
- package/src/email-templates.test.js +124 -0
- package/src/email.js +314 -0
- package/src/email.test.js +498 -0
- package/src/error-reporter.js +266 -0
- package/src/error-reporter.test.js +236 -0
- package/src/errors.js +192 -0
- package/src/errors.test.js +128 -0
- package/src/exports.test.js +154 -0
- package/src/file-validation.js +249 -0
- package/src/file-validation.test.js +207 -0
- package/src/index.js +58 -0
- package/src/locale.js +21 -0
- package/src/logger.js +182 -0
- package/src/logger.test.js +287 -0
- package/src/mail.js +50 -0
- package/src/metrics.js +379 -0
- package/src/metrics.test.js +380 -0
- package/src/multipart.js +144 -0
- package/src/multipart.test.js +188 -0
- package/src/pagination.js +139 -0
- package/src/pagination.test.js +210 -0
- package/src/query-builder.js +183 -0
- package/src/query-builder.test.js +234 -0
- package/src/queue/index.js +446 -0
- package/src/queue/queue.test.js +365 -0
- package/src/rate-limiter.js +253 -0
- package/src/rate-limiter.test.js +130 -0
- package/src/redis.js +154 -0
- package/src/redis.test.js +150 -0
- package/src/request-logger.js +211 -0
- package/src/request-logger.test.js +286 -0
- package/src/sms.js +208 -0
- package/src/storage-s3.js +236 -0
- package/src/storage.js +214 -0
- package/src/storage.test.js +457 -0
- package/src/stripe.js +89 -0
- package/src/testing/factories.js +89 -0
- package/src/testing/helpers.js +266 -0
- package/src/testing/index.js +46 -0
- package/src/testing/mocks.js +480 -0
- package/src/testing/testing.test.js +543 -0
- package/src/types.js +74 -0
- package/src/utils.js +219 -0
- package/src/utils.test.js +193 -0
- 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
|
+
};
|