servcraft 0.1.0 → 0.1.3

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 (217) hide show
  1. package/.claude/settings.local.json +30 -0
  2. package/.github/CODEOWNERS +18 -0
  3. package/.github/PULL_REQUEST_TEMPLATE.md +46 -0
  4. package/.github/dependabot.yml +59 -0
  5. package/.github/workflows/ci.yml +188 -0
  6. package/.github/workflows/release.yml +195 -0
  7. package/AUDIT.md +602 -0
  8. package/LICENSE +21 -0
  9. package/README.md +1102 -1
  10. package/dist/cli/index.cjs +2026 -2168
  11. package/dist/cli/index.cjs.map +1 -1
  12. package/dist/cli/index.js +2026 -2168
  13. package/dist/cli/index.js.map +1 -1
  14. package/dist/index.cjs +595 -616
  15. package/dist/index.cjs.map +1 -1
  16. package/dist/index.d.cts +114 -52
  17. package/dist/index.d.ts +114 -52
  18. package/dist/index.js +595 -616
  19. package/dist/index.js.map +1 -1
  20. package/docs/CLI-001_MULTI_DB_PLAN.md +546 -0
  21. package/docs/DATABASE_MULTI_ORM.md +399 -0
  22. package/docs/PHASE1_BREAKDOWN.md +346 -0
  23. package/docs/PROGRESS.md +550 -0
  24. package/docs/modules/ANALYTICS.md +226 -0
  25. package/docs/modules/API-VERSIONING.md +252 -0
  26. package/docs/modules/AUDIT.md +192 -0
  27. package/docs/modules/AUTH.md +431 -0
  28. package/docs/modules/CACHE.md +346 -0
  29. package/docs/modules/EMAIL.md +254 -0
  30. package/docs/modules/FEATURE-FLAG.md +291 -0
  31. package/docs/modules/I18N.md +294 -0
  32. package/docs/modules/MEDIA-PROCESSING.md +281 -0
  33. package/docs/modules/MFA.md +266 -0
  34. package/docs/modules/NOTIFICATION.md +311 -0
  35. package/docs/modules/OAUTH.md +237 -0
  36. package/docs/modules/PAYMENT.md +804 -0
  37. package/docs/modules/QUEUE.md +540 -0
  38. package/docs/modules/RATE-LIMIT.md +339 -0
  39. package/docs/modules/SEARCH.md +288 -0
  40. package/docs/modules/SECURITY.md +327 -0
  41. package/docs/modules/SESSION.md +382 -0
  42. package/docs/modules/SWAGGER.md +305 -0
  43. package/docs/modules/UPLOAD.md +296 -0
  44. package/docs/modules/USER.md +505 -0
  45. package/docs/modules/VALIDATION.md +294 -0
  46. package/docs/modules/WEBHOOK.md +270 -0
  47. package/docs/modules/WEBSOCKET.md +691 -0
  48. package/package.json +53 -38
  49. package/prisma/schema.prisma +395 -1
  50. package/src/cli/commands/add-module.ts +520 -87
  51. package/src/cli/commands/db.ts +3 -4
  52. package/src/cli/commands/docs.ts +256 -6
  53. package/src/cli/commands/generate.ts +12 -19
  54. package/src/cli/commands/init.ts +384 -214
  55. package/src/cli/index.ts +0 -4
  56. package/src/cli/templates/repository.ts +6 -1
  57. package/src/cli/templates/routes.ts +6 -21
  58. package/src/cli/utils/docs-generator.ts +6 -7
  59. package/src/cli/utils/env-manager.ts +717 -0
  60. package/src/cli/utils/field-parser.ts +16 -7
  61. package/src/cli/utils/interactive-prompt.ts +223 -0
  62. package/src/cli/utils/template-manager.ts +346 -0
  63. package/src/config/database.config.ts +183 -0
  64. package/src/config/env.ts +0 -10
  65. package/src/config/index.ts +0 -14
  66. package/src/core/server.ts +1 -1
  67. package/src/database/adapters/mongoose.adapter.ts +132 -0
  68. package/src/database/adapters/prisma.adapter.ts +118 -0
  69. package/src/database/connection.ts +190 -0
  70. package/src/database/interfaces/database.interface.ts +85 -0
  71. package/src/database/interfaces/index.ts +7 -0
  72. package/src/database/interfaces/repository.interface.ts +129 -0
  73. package/src/database/models/mongoose/index.ts +7 -0
  74. package/src/database/models/mongoose/payment.schema.ts +347 -0
  75. package/src/database/models/mongoose/user.schema.ts +154 -0
  76. package/src/database/prisma.ts +1 -4
  77. package/src/database/redis.ts +101 -0
  78. package/src/database/repositories/mongoose/index.ts +7 -0
  79. package/src/database/repositories/mongoose/payment.repository.ts +380 -0
  80. package/src/database/repositories/mongoose/user.repository.ts +255 -0
  81. package/src/database/seed.ts +6 -1
  82. package/src/index.ts +9 -20
  83. package/src/middleware/security.ts +2 -6
  84. package/src/modules/analytics/analytics.routes.ts +80 -0
  85. package/src/modules/analytics/analytics.service.ts +364 -0
  86. package/src/modules/analytics/index.ts +18 -0
  87. package/src/modules/analytics/types.ts +180 -0
  88. package/src/modules/api-versioning/index.ts +15 -0
  89. package/src/modules/api-versioning/types.ts +86 -0
  90. package/src/modules/api-versioning/versioning.middleware.ts +120 -0
  91. package/src/modules/api-versioning/versioning.routes.ts +54 -0
  92. package/src/modules/api-versioning/versioning.service.ts +189 -0
  93. package/src/modules/audit/audit.repository.ts +206 -0
  94. package/src/modules/audit/audit.service.ts +27 -59
  95. package/src/modules/auth/auth.controller.ts +2 -2
  96. package/src/modules/auth/auth.middleware.ts +3 -9
  97. package/src/modules/auth/auth.routes.ts +10 -107
  98. package/src/modules/auth/auth.service.ts +126 -23
  99. package/src/modules/auth/index.ts +3 -4
  100. package/src/modules/cache/cache.service.ts +367 -0
  101. package/src/modules/cache/index.ts +10 -0
  102. package/src/modules/cache/types.ts +44 -0
  103. package/src/modules/email/email.service.ts +3 -10
  104. package/src/modules/email/templates.ts +2 -8
  105. package/src/modules/feature-flag/feature-flag.repository.ts +303 -0
  106. package/src/modules/feature-flag/feature-flag.routes.ts +247 -0
  107. package/src/modules/feature-flag/feature-flag.service.ts +566 -0
  108. package/src/modules/feature-flag/index.ts +20 -0
  109. package/src/modules/feature-flag/types.ts +192 -0
  110. package/src/modules/i18n/i18n.middleware.ts +186 -0
  111. package/src/modules/i18n/i18n.routes.ts +191 -0
  112. package/src/modules/i18n/i18n.service.ts +456 -0
  113. package/src/modules/i18n/index.ts +18 -0
  114. package/src/modules/i18n/types.ts +118 -0
  115. package/src/modules/media-processing/index.ts +17 -0
  116. package/src/modules/media-processing/media-processing.routes.ts +111 -0
  117. package/src/modules/media-processing/media-processing.service.ts +245 -0
  118. package/src/modules/media-processing/types.ts +156 -0
  119. package/src/modules/mfa/index.ts +20 -0
  120. package/src/modules/mfa/mfa.repository.ts +206 -0
  121. package/src/modules/mfa/mfa.routes.ts +595 -0
  122. package/src/modules/mfa/mfa.service.ts +572 -0
  123. package/src/modules/mfa/totp.ts +150 -0
  124. package/src/modules/mfa/types.ts +57 -0
  125. package/src/modules/notification/index.ts +20 -0
  126. package/src/modules/notification/notification.repository.ts +356 -0
  127. package/src/modules/notification/notification.service.ts +483 -0
  128. package/src/modules/notification/types.ts +119 -0
  129. package/src/modules/oauth/index.ts +20 -0
  130. package/src/modules/oauth/oauth.repository.ts +219 -0
  131. package/src/modules/oauth/oauth.routes.ts +446 -0
  132. package/src/modules/oauth/oauth.service.ts +293 -0
  133. package/src/modules/oauth/providers/apple.provider.ts +250 -0
  134. package/src/modules/oauth/providers/facebook.provider.ts +181 -0
  135. package/src/modules/oauth/providers/github.provider.ts +248 -0
  136. package/src/modules/oauth/providers/google.provider.ts +189 -0
  137. package/src/modules/oauth/providers/twitter.provider.ts +214 -0
  138. package/src/modules/oauth/types.ts +94 -0
  139. package/src/modules/payment/index.ts +19 -0
  140. package/src/modules/payment/payment.repository.ts +733 -0
  141. package/src/modules/payment/payment.routes.ts +390 -0
  142. package/src/modules/payment/payment.service.ts +354 -0
  143. package/src/modules/payment/providers/mobile-money.provider.ts +274 -0
  144. package/src/modules/payment/providers/paypal.provider.ts +190 -0
  145. package/src/modules/payment/providers/stripe.provider.ts +215 -0
  146. package/src/modules/payment/types.ts +140 -0
  147. package/src/modules/queue/cron.ts +438 -0
  148. package/src/modules/queue/index.ts +87 -0
  149. package/src/modules/queue/queue.routes.ts +600 -0
  150. package/src/modules/queue/queue.service.ts +842 -0
  151. package/src/modules/queue/types.ts +222 -0
  152. package/src/modules/queue/workers.ts +366 -0
  153. package/src/modules/rate-limit/index.ts +59 -0
  154. package/src/modules/rate-limit/rate-limit.middleware.ts +134 -0
  155. package/src/modules/rate-limit/rate-limit.routes.ts +269 -0
  156. package/src/modules/rate-limit/rate-limit.service.ts +348 -0
  157. package/src/modules/rate-limit/stores/memory.store.ts +165 -0
  158. package/src/modules/rate-limit/stores/redis.store.ts +322 -0
  159. package/src/modules/rate-limit/types.ts +153 -0
  160. package/src/modules/search/adapters/elasticsearch.adapter.ts +326 -0
  161. package/src/modules/search/adapters/meilisearch.adapter.ts +261 -0
  162. package/src/modules/search/adapters/memory.adapter.ts +278 -0
  163. package/src/modules/search/index.ts +21 -0
  164. package/src/modules/search/search.service.ts +234 -0
  165. package/src/modules/search/types.ts +214 -0
  166. package/src/modules/security/index.ts +40 -0
  167. package/src/modules/security/sanitize.ts +223 -0
  168. package/src/modules/security/security-audit.service.ts +388 -0
  169. package/src/modules/security/security.middleware.ts +398 -0
  170. package/src/modules/session/index.ts +3 -0
  171. package/src/modules/session/session.repository.ts +159 -0
  172. package/src/modules/session/session.service.ts +340 -0
  173. package/src/modules/session/types.ts +38 -0
  174. package/src/modules/swagger/index.ts +7 -1
  175. package/src/modules/swagger/schema-builder.ts +16 -4
  176. package/src/modules/swagger/swagger.service.ts +9 -10
  177. package/src/modules/swagger/types.ts +0 -2
  178. package/src/modules/upload/index.ts +14 -0
  179. package/src/modules/upload/types.ts +83 -0
  180. package/src/modules/upload/upload.repository.ts +199 -0
  181. package/src/modules/upload/upload.routes.ts +311 -0
  182. package/src/modules/upload/upload.service.ts +448 -0
  183. package/src/modules/user/index.ts +3 -3
  184. package/src/modules/user/user.controller.ts +15 -9
  185. package/src/modules/user/user.repository.ts +237 -113
  186. package/src/modules/user/user.routes.ts +39 -164
  187. package/src/modules/user/user.service.ts +4 -3
  188. package/src/modules/validation/validator.ts +12 -17
  189. package/src/modules/webhook/index.ts +91 -0
  190. package/src/modules/webhook/retry.ts +196 -0
  191. package/src/modules/webhook/signature.ts +135 -0
  192. package/src/modules/webhook/types.ts +181 -0
  193. package/src/modules/webhook/webhook.repository.ts +358 -0
  194. package/src/modules/webhook/webhook.routes.ts +442 -0
  195. package/src/modules/webhook/webhook.service.ts +457 -0
  196. package/src/modules/websocket/features.ts +504 -0
  197. package/src/modules/websocket/index.ts +106 -0
  198. package/src/modules/websocket/middlewares.ts +298 -0
  199. package/src/modules/websocket/types.ts +181 -0
  200. package/src/modules/websocket/websocket.service.ts +692 -0
  201. package/src/utils/errors.ts +7 -0
  202. package/src/utils/pagination.ts +4 -1
  203. package/tests/helpers/db-check.ts +79 -0
  204. package/tests/integration/auth-redis.test.ts +94 -0
  205. package/tests/integration/cache-redis.test.ts +387 -0
  206. package/tests/integration/mongoose-repositories.test.ts +410 -0
  207. package/tests/integration/payment-prisma.test.ts +637 -0
  208. package/tests/integration/queue-bullmq.test.ts +417 -0
  209. package/tests/integration/user-prisma.test.ts +441 -0
  210. package/tests/integration/websocket-socketio.test.ts +552 -0
  211. package/tests/setup.ts +11 -9
  212. package/vitest.config.ts +3 -8
  213. package/npm-cache/_cacache/content-v2/sha512/1c/d0/03440d500a0487621aad1d6402978340698976602046db8e24fa03c01ee6c022c69b0582f969042d9442ee876ac35c038e960dd427d1e622fa24b8eb7dba +0 -0
  214. package/npm-cache/_cacache/content-v2/sha512/42/55/28b493ca491833e5aab0e9c3108d29ab3f36c248ca88f45d4630674fce9130959e56ae308797ac2b6328fa7f09a610b9550ed09cb971d039876d293fc69d +0 -0
  215. package/npm-cache/_cacache/content-v2/sha512/e0/12/f360dc9315ee5f17844a0c8c233ee6bf7c30837c4a02ea0d56c61c7f7ab21c0e958e50ed2c57c59f983c762b93056778c9009b2398ffc26def0183999b13 +0 -0
  216. package/npm-cache/_cacache/content-v2/sha512/ed/b0/fae1161902898f4c913c67d7f6cdf6be0665aec3b389b9c4f4f0a101ca1da59badf1b59c4e0030f5223023b8d63cfe501c46a32c20c895d4fb3f11ca2232 +0 -0
  217. package/npm-cache/_cacache/index-v5/58/94/c2cba79e0f16b4c10e95a87e32255741149e8222cc314a476aab67c39cc0 +0 -5
@@ -0,0 +1,842 @@
1
+ import type { Job as BullJob } from 'bullmq';
2
+ import { Queue, Worker as BullWorker, QueueEvents } from 'bullmq';
3
+ import { EventEmitter } from 'events';
4
+ import { Redis } from 'ioredis';
5
+ import { logger } from '../../core/logger.js';
6
+ import { NotFoundError, BadRequestError } from '../../utils/errors.js';
7
+ import type {
8
+ Job,
9
+ JobOptions,
10
+ QueueConfig,
11
+ QueueStats,
12
+ Worker,
13
+ JobStatus,
14
+ JobFilter,
15
+ BulkJobOptions,
16
+ QueueMetrics,
17
+ JobEvent,
18
+ } from './types.js';
19
+
20
+ const defaultConfig: Required<QueueConfig> = {
21
+ redis: {
22
+ host: process.env.REDIS_HOST || 'localhost',
23
+ port: parseInt(process.env.REDIS_PORT || '6379', 10),
24
+ password: process.env.REDIS_PASSWORD,
25
+ db: parseInt(process.env.REDIS_DB || '0', 10),
26
+ },
27
+ defaultJobOptions: {
28
+ attempts: 3,
29
+ backoff: {
30
+ type: 'exponential',
31
+ delay: 1000,
32
+ },
33
+ removeOnComplete: true,
34
+ removeOnFail: false,
35
+ timeout: 60000,
36
+ },
37
+ prefix: 'servcraft:queue',
38
+ metrics: true,
39
+ };
40
+
41
+ /**
42
+ * Queue Service
43
+ * Manages background jobs and task queues using BullMQ with Redis persistence
44
+ *
45
+ * Features:
46
+ * - Persistent job storage in Redis
47
+ * - Automatic retries with exponential backoff
48
+ * - Job prioritization (critical, high, normal, low)
49
+ * - Delayed/scheduled jobs
50
+ * - Repeatable/cron jobs
51
+ * - Concurrency control per worker
52
+ * - Real-time events and metrics
53
+ * - Multi-instance safe (horizontal scaling)
54
+ */
55
+ export class QueueService extends EventEmitter {
56
+ private config: Required<QueueConfig>;
57
+ private connection: Redis;
58
+ private queues = new Map<string, Queue>();
59
+ private workers = new Map<string, BullWorker>();
60
+ private queueEvents = new Map<string, QueueEvents>();
61
+ private workerProcessors = new Map<string, Map<string, Worker>>();
62
+ private metrics = new Map<string, QueueMetrics>();
63
+ private isClosing = false;
64
+
65
+ constructor(config?: QueueConfig) {
66
+ super();
67
+ this.config = {
68
+ ...defaultConfig,
69
+ ...config,
70
+ redis: { ...defaultConfig.redis, ...config?.redis },
71
+ defaultJobOptions: { ...defaultConfig.defaultJobOptions, ...config?.defaultJobOptions },
72
+ };
73
+
74
+ // Create Redis connection
75
+ this.connection = new Redis({
76
+ host: this.config.redis.host,
77
+ port: this.config.redis.port,
78
+ password: this.config.redis.password,
79
+ db: this.config.redis.db,
80
+ maxRetriesPerRequest: null, // Required for BullMQ
81
+ enableReadyCheck: false,
82
+ });
83
+
84
+ this.connection.on('error', (err: Error) => {
85
+ logger.error({ err }, 'Queue Redis connection error');
86
+ });
87
+
88
+ this.connection.on('connect', () => {
89
+ logger.info('Queue Redis connected');
90
+ });
91
+ }
92
+
93
+ /**
94
+ * Create a new queue
95
+ */
96
+ createQueue(name: string): Queue {
97
+ if (this.queues.has(name)) {
98
+ return this.queues.get(name)!;
99
+ }
100
+
101
+ const queue = new Queue(name, {
102
+ connection: this.connection.duplicate(),
103
+ prefix: this.config.prefix,
104
+ defaultJobOptions: this.mapJobOptionsToBullMQ(this.config.defaultJobOptions),
105
+ });
106
+
107
+ this.queues.set(name, queue);
108
+ this.workerProcessors.set(name, new Map());
109
+
110
+ // Initialize metrics
111
+ if (this.config.metrics) {
112
+ this.metrics.set(name, {
113
+ totalProcessed: 0,
114
+ totalFailed: 0,
115
+ avgProcessingTime: 0,
116
+ throughput: 0,
117
+ peakConcurrency: 0,
118
+ currentConcurrency: 0,
119
+ successRate: 100,
120
+ });
121
+ }
122
+
123
+ // Setup queue events
124
+ this.setupQueueEvents(name);
125
+
126
+ logger.info({ queueName: name }, 'Queue created with BullMQ');
127
+
128
+ return queue;
129
+ }
130
+
131
+ /**
132
+ * Setup queue events listener
133
+ */
134
+ private setupQueueEvents(name: string): void {
135
+ const queueEvents = new QueueEvents(name, {
136
+ connection: this.connection.duplicate(),
137
+ prefix: this.config.prefix,
138
+ });
139
+
140
+ this.queueEvents.set(name, queueEvents);
141
+
142
+ queueEvents.on('completed', ({ jobId, returnvalue }) => {
143
+ this.emitEvent({
144
+ event: 'completed',
145
+ jobId,
146
+ data: returnvalue,
147
+ timestamp: new Date(),
148
+ });
149
+ });
150
+
151
+ queueEvents.on('failed', ({ jobId, failedReason }) => {
152
+ this.emitEvent({
153
+ event: 'failed',
154
+ jobId,
155
+ data: failedReason,
156
+ timestamp: new Date(),
157
+ });
158
+ });
159
+
160
+ queueEvents.on('progress', ({ jobId, data }) => {
161
+ this.emitEvent({
162
+ event: 'progress',
163
+ jobId,
164
+ data,
165
+ timestamp: new Date(),
166
+ });
167
+ });
168
+
169
+ queueEvents.on('stalled', ({ jobId }) => {
170
+ this.emitEvent({
171
+ event: 'stalled',
172
+ jobId,
173
+ timestamp: new Date(),
174
+ });
175
+ });
176
+
177
+ queueEvents.on('active', ({ jobId }) => {
178
+ this.emitEvent({
179
+ event: 'active',
180
+ jobId,
181
+ timestamp: new Date(),
182
+ });
183
+ });
184
+ }
185
+
186
+ /**
187
+ * Map our JobOptions to BullMQ options
188
+ */
189
+ private mapJobOptionsToBullMQ(options: JobOptions): Record<string, unknown> {
190
+ const bullOptions: Record<string, unknown> = {};
191
+
192
+ if (options.priority) {
193
+ // BullMQ uses numeric priority (lower = higher priority)
194
+ const priorityMap = { critical: 1, high: 2, normal: 3, low: 4 };
195
+ bullOptions.priority = priorityMap[options.priority];
196
+ }
197
+
198
+ if (options.delay !== undefined) {
199
+ bullOptions.delay = options.delay;
200
+ }
201
+
202
+ if (options.attempts !== undefined) {
203
+ bullOptions.attempts = options.attempts;
204
+ }
205
+
206
+ if (options.backoff) {
207
+ bullOptions.backoff = {
208
+ type: options.backoff.type,
209
+ delay: options.backoff.delay,
210
+ };
211
+ }
212
+
213
+ if (options.removeOnComplete !== undefined) {
214
+ bullOptions.removeOnComplete = options.removeOnComplete;
215
+ }
216
+
217
+ if (options.removeOnFail !== undefined) {
218
+ bullOptions.removeOnFail = options.removeOnFail;
219
+ }
220
+
221
+ if (options.repeat) {
222
+ bullOptions.repeat = {
223
+ pattern: options.repeat.cron,
224
+ every: options.repeat.every,
225
+ limit: options.repeat.limit,
226
+ immediately: options.repeat.immediately,
227
+ };
228
+ }
229
+
230
+ return bullOptions;
231
+ }
232
+
233
+ /**
234
+ * Map BullMQ job to our Job interface
235
+ */
236
+ private mapBullJobToJob<T>(bullJob: BullJob<T>, queueName: string): Job<T> {
237
+ const state = bullJob.returnvalue !== undefined ? 'completed' : 'waiting';
238
+
239
+ return {
240
+ id: bullJob.id || '',
241
+ queueName,
242
+ name: bullJob.name,
243
+ data: bullJob.data,
244
+ options: {
245
+ priority: this.mapPriorityFromBullMQ(bullJob.opts.priority),
246
+ delay: bullJob.opts.delay,
247
+ attempts: bullJob.opts.attempts,
248
+ backoff: bullJob.opts.backoff as JobOptions['backoff'],
249
+ removeOnComplete: bullJob.opts.removeOnComplete as boolean | number | undefined,
250
+ removeOnFail: bullJob.opts.removeOnFail as boolean | number | undefined,
251
+ },
252
+ status: state as JobStatus,
253
+ progress: typeof bullJob.progress === 'number' ? bullJob.progress : undefined,
254
+ attemptsMade: bullJob.attemptsMade,
255
+ result: bullJob.returnvalue,
256
+ error: bullJob.failedReason,
257
+ stacktrace: bullJob.stacktrace,
258
+ createdAt: new Date(bullJob.timestamp),
259
+ processedAt: bullJob.processedOn ? new Date(bullJob.processedOn) : undefined,
260
+ completedAt: bullJob.finishedOn ? new Date(bullJob.finishedOn) : undefined,
261
+ failedAt: bullJob.failedReason ? new Date(bullJob.finishedOn || Date.now()) : undefined,
262
+ delayedUntil: bullJob.delay ? new Date(bullJob.timestamp + bullJob.delay) : undefined,
263
+ };
264
+ }
265
+
266
+ /**
267
+ * Map BullMQ numeric priority to our priority type
268
+ */
269
+ private mapPriorityFromBullMQ(priority?: number): JobOptions['priority'] {
270
+ if (!priority) return 'normal';
271
+ if (priority <= 1) return 'critical';
272
+ if (priority <= 2) return 'high';
273
+ if (priority <= 3) return 'normal';
274
+ return 'low';
275
+ }
276
+
277
+ /**
278
+ * Add a job to a queue
279
+ */
280
+ async addJob<T = unknown>(
281
+ queueName: string,
282
+ jobName: string,
283
+ data: T,
284
+ options?: JobOptions
285
+ ): Promise<Job<T>> {
286
+ const queue = this.createQueue(queueName);
287
+
288
+ const mergedOptions = { ...this.config.defaultJobOptions, ...options };
289
+ const bullOptions = this.mapJobOptionsToBullMQ(mergedOptions);
290
+
291
+ const bullJob = await queue.add(jobName, data, bullOptions);
292
+
293
+ const job = this.mapBullJobToJob<T>(bullJob, queueName);
294
+
295
+ this.emitEvent({
296
+ event: 'added',
297
+ jobId: job.id,
298
+ data: job,
299
+ timestamp: new Date(),
300
+ });
301
+
302
+ logger.debug({ jobId: job.id, queueName, jobName }, 'Job added to queue');
303
+
304
+ return job;
305
+ }
306
+
307
+ /**
308
+ * Add multiple jobs in bulk
309
+ */
310
+ async addBulkJobs(queueName: string, bulkOptions: BulkJobOptions): Promise<Job[]> {
311
+ const queue = this.createQueue(queueName);
312
+
313
+ const bullJobs = bulkOptions.jobs.map((jobData) => ({
314
+ name: jobData.name,
315
+ data: jobData.data,
316
+ opts: jobData.opts ? this.mapJobOptionsToBullMQ(jobData.opts) : undefined,
317
+ }));
318
+
319
+ const addedJobs = await queue.addBulk(bullJobs);
320
+
321
+ const jobs = addedJobs.map((bullJob) => this.mapBullJobToJob(bullJob, queueName));
322
+
323
+ logger.info({ queueName, count: jobs.length }, 'Bulk jobs added');
324
+
325
+ return jobs;
326
+ }
327
+
328
+ /**
329
+ * Register a worker for a job type
330
+ */
331
+ registerWorker<T = unknown>(queueName: string, worker: Worker<T>): void {
332
+ this.createQueue(queueName);
333
+
334
+ // Store the worker processor
335
+ this.workerProcessors.get(queueName)!.set(worker.name, worker as Worker);
336
+
337
+ // Create or update BullMQ worker
338
+ this.ensureBullWorker(queueName);
339
+
340
+ logger.info({ queueName, workerName: worker.name }, 'Worker registered');
341
+ }
342
+
343
+ /**
344
+ * Ensure BullMQ worker exists for queue
345
+ */
346
+ private ensureBullWorker(queueName: string): void {
347
+ // If worker already exists, close it and recreate
348
+ if (this.workers.has(queueName)) {
349
+ return; // Worker already running
350
+ }
351
+
352
+ const processors = this.workerProcessors.get(queueName);
353
+ if (!processors || processors.size === 0) {
354
+ return;
355
+ }
356
+
357
+ // Get max concurrency from all workers
358
+ const maxConcurrency = Math.max(
359
+ ...Array.from(processors.values()).map((w) => w.concurrency || 1)
360
+ );
361
+
362
+ const bullWorker = new BullWorker(
363
+ queueName,
364
+ async (bullJob: BullJob) => {
365
+ const worker = processors.get(bullJob.name);
366
+ if (!worker) {
367
+ throw new Error(`No worker registered for job type: ${bullJob.name}`);
368
+ }
369
+
370
+ const job = this.mapBullJobToJob(bullJob, queueName);
371
+ const startTime = Date.now();
372
+
373
+ try {
374
+ const result = await worker.process(job);
375
+
376
+ // Update metrics on success
377
+ this.updateMetrics(queueName, true, Date.now() - startTime);
378
+
379
+ return result;
380
+ } catch (error) {
381
+ // Update metrics on failure
382
+ this.updateMetrics(queueName, false, Date.now() - startTime);
383
+ throw error;
384
+ }
385
+ },
386
+ {
387
+ connection: this.connection.duplicate(),
388
+ prefix: this.config.prefix,
389
+ concurrency: maxConcurrency,
390
+ }
391
+ );
392
+
393
+ bullWorker.on('completed', (job) => {
394
+ logger.info({ jobId: job.id, queueName, jobName: job.name }, 'Job completed');
395
+ });
396
+
397
+ bullWorker.on('failed', (job, err) => {
398
+ logger.error(
399
+ { jobId: job?.id, queueName, jobName: job?.name, error: err.message },
400
+ 'Job failed'
401
+ );
402
+ });
403
+
404
+ bullWorker.on('error', (err) => {
405
+ logger.error({ queueName, error: err.message }, 'Worker error');
406
+ });
407
+
408
+ this.workers.set(queueName, bullWorker);
409
+ }
410
+
411
+ /**
412
+ * Update metrics
413
+ */
414
+ private updateMetrics(queueName: string, success: boolean, duration: number): void {
415
+ if (!this.config.metrics) return;
416
+
417
+ const metric = this.metrics.get(queueName);
418
+ if (!metric) return;
419
+
420
+ if (success) {
421
+ metric.totalProcessed++;
422
+ } else {
423
+ metric.totalFailed++;
424
+ }
425
+
426
+ // Update average processing time
427
+ const total = metric.totalProcessed + metric.totalFailed;
428
+ metric.avgProcessingTime = (metric.avgProcessingTime * (total - 1) + duration) / total;
429
+
430
+ // Update success rate
431
+ metric.successRate = total > 0 ? (metric.totalProcessed / total) * 100 : 100;
432
+ }
433
+
434
+ /**
435
+ * Get job by ID
436
+ */
437
+ async getJob(queueName: string, jobId: string): Promise<Job> {
438
+ const queue = this.queues.get(queueName);
439
+ if (!queue) {
440
+ throw new NotFoundError('Queue not found');
441
+ }
442
+
443
+ const bullJob = await queue.getJob(jobId);
444
+ if (!bullJob) {
445
+ throw new NotFoundError('Job not found');
446
+ }
447
+
448
+ // Get actual state from BullMQ
449
+ const state = await bullJob.getState();
450
+ const job = this.mapBullJobToJob(bullJob, queueName);
451
+ job.status = state as JobStatus;
452
+
453
+ return job;
454
+ }
455
+
456
+ /**
457
+ * List jobs with filters
458
+ */
459
+ async listJobs(queueName: string, filter?: JobFilter): Promise<Job[]> {
460
+ const queue = this.queues.get(queueName);
461
+ if (!queue) {
462
+ throw new NotFoundError('Queue not found');
463
+ }
464
+
465
+ const statuses = filter?.status
466
+ ? Array.isArray(filter.status)
467
+ ? filter.status
468
+ : [filter.status]
469
+ : ['waiting', 'active', 'completed', 'failed', 'delayed'];
470
+
471
+ const offset = filter?.offset || 0;
472
+ const limit = filter?.limit || 100;
473
+
474
+ // Get jobs from each status
475
+ const allJobs: Job[] = [];
476
+
477
+ for (const status of statuses) {
478
+ let bullJobs: BullJob[] = [];
479
+
480
+ switch (status) {
481
+ case 'waiting':
482
+ bullJobs = await queue.getWaiting(offset, offset + limit);
483
+ break;
484
+ case 'active':
485
+ bullJobs = await queue.getActive(offset, offset + limit);
486
+ break;
487
+ case 'completed':
488
+ bullJobs = await queue.getCompleted(offset, offset + limit);
489
+ break;
490
+ case 'failed':
491
+ bullJobs = await queue.getFailed(offset, offset + limit);
492
+ break;
493
+ case 'delayed':
494
+ bullJobs = await queue.getDelayed(offset, offset + limit);
495
+ break;
496
+ }
497
+
498
+ for (const bullJob of bullJobs) {
499
+ const job = this.mapBullJobToJob(bullJob, queueName);
500
+ job.status = status as JobStatus;
501
+
502
+ // Apply name filter
503
+ if (filter?.name && job.name !== filter.name) {
504
+ continue;
505
+ }
506
+
507
+ // Apply date filters
508
+ if (filter?.startDate && job.createdAt < filter.startDate) {
509
+ continue;
510
+ }
511
+ if (filter?.endDate && job.createdAt > filter.endDate) {
512
+ continue;
513
+ }
514
+
515
+ allJobs.push(job);
516
+ }
517
+ }
518
+
519
+ // Sort by creation date (newest first)
520
+ allJobs.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
521
+
522
+ // Apply pagination
523
+ return allJobs.slice(0, limit);
524
+ }
525
+
526
+ /**
527
+ * Get queue statistics
528
+ */
529
+ async getStats(queueName: string): Promise<QueueStats> {
530
+ const queue = this.queues.get(queueName);
531
+ if (!queue) {
532
+ throw new NotFoundError('Queue not found');
533
+ }
534
+
535
+ const counts = await queue.getJobCounts(
536
+ 'waiting',
537
+ 'active',
538
+ 'completed',
539
+ 'failed',
540
+ 'delayed',
541
+ 'paused'
542
+ );
543
+
544
+ return {
545
+ name: queueName,
546
+ waiting: counts.waiting || 0,
547
+ active: counts.active || 0,
548
+ completed: counts.completed || 0,
549
+ failed: counts.failed || 0,
550
+ delayed: counts.delayed || 0,
551
+ paused: counts.paused || 0,
552
+ };
553
+ }
554
+
555
+ /**
556
+ * Get queue metrics
557
+ */
558
+ async getMetrics(queueName: string): Promise<QueueMetrics> {
559
+ const metric = this.metrics.get(queueName);
560
+ if (!metric) {
561
+ throw new NotFoundError('Queue not found or metrics disabled');
562
+ }
563
+
564
+ // Update current concurrency from worker
565
+ const worker = this.workers.get(queueName);
566
+ if (worker) {
567
+ const queue = this.queues.get(queueName);
568
+ if (queue) {
569
+ const activeCount = await queue.getActiveCount();
570
+ metric.currentConcurrency = activeCount;
571
+ metric.peakConcurrency = Math.max(metric.peakConcurrency, activeCount);
572
+ }
573
+ }
574
+
575
+ return { ...metric };
576
+ }
577
+
578
+ /**
579
+ * Remove a job
580
+ */
581
+ async removeJob(queueName: string, jobId: string): Promise<void> {
582
+ const queue = this.queues.get(queueName);
583
+ if (!queue) {
584
+ throw new NotFoundError('Queue not found');
585
+ }
586
+
587
+ const bullJob = await queue.getJob(jobId);
588
+ if (!bullJob) {
589
+ throw new NotFoundError('Job not found');
590
+ }
591
+
592
+ const state = await bullJob.getState();
593
+ if (state === 'active') {
594
+ throw new BadRequestError('Cannot remove active job');
595
+ }
596
+
597
+ await bullJob.remove();
598
+
599
+ this.emitEvent({
600
+ event: 'removed',
601
+ jobId,
602
+ timestamp: new Date(),
603
+ });
604
+
605
+ logger.info({ jobId, queueName }, 'Job removed');
606
+ }
607
+
608
+ /**
609
+ * Retry a failed job
610
+ */
611
+ async retryJob(queueName: string, jobId: string): Promise<void> {
612
+ const queue = this.queues.get(queueName);
613
+ if (!queue) {
614
+ throw new NotFoundError('Queue not found');
615
+ }
616
+
617
+ const bullJob = await queue.getJob(jobId);
618
+ if (!bullJob) {
619
+ throw new NotFoundError('Job not found');
620
+ }
621
+
622
+ const state = await bullJob.getState();
623
+ if (state !== 'failed') {
624
+ throw new BadRequestError('Can only retry failed jobs');
625
+ }
626
+
627
+ await bullJob.retry();
628
+
629
+ logger.info({ jobId, queueName }, 'Job retry initiated');
630
+ }
631
+
632
+ /**
633
+ * Clean completed/failed jobs
634
+ */
635
+ async cleanJobs(
636
+ queueName: string,
637
+ status: JobStatus | JobStatus[],
638
+ olderThanMs: number = 24 * 60 * 60 * 1000
639
+ ): Promise<number> {
640
+ const queue = this.queues.get(queueName);
641
+ if (!queue) {
642
+ throw new NotFoundError('Queue not found');
643
+ }
644
+
645
+ const statuses = Array.isArray(status) ? status : [status];
646
+ let cleaned = 0;
647
+
648
+ for (const s of statuses) {
649
+ if (s === 'completed' || s === 'failed') {
650
+ const removed = await queue.clean(olderThanMs, 1000, s);
651
+ cleaned += removed.length;
652
+ }
653
+ }
654
+
655
+ logger.info({ queueName, cleaned, status: statuses }, 'Jobs cleaned');
656
+
657
+ return cleaned;
658
+ }
659
+
660
+ /**
661
+ * Emit job event
662
+ */
663
+ private emitEvent(event: JobEvent): void {
664
+ this.emit(event.event, event);
665
+ this.emit('job:event', event);
666
+ }
667
+
668
+ /**
669
+ * List all queues
670
+ */
671
+ async listQueues(): Promise<string[]> {
672
+ return Array.from(this.queues.keys());
673
+ }
674
+
675
+ /**
676
+ * Pause a queue
677
+ */
678
+ async pauseQueue(queueName: string): Promise<void> {
679
+ const queue = this.queues.get(queueName);
680
+ if (!queue) {
681
+ throw new NotFoundError('Queue not found');
682
+ }
683
+
684
+ await queue.pause();
685
+
686
+ logger.info({ queueName }, 'Queue paused');
687
+ }
688
+
689
+ /**
690
+ * Resume a queue
691
+ */
692
+ async resumeQueue(queueName: string): Promise<void> {
693
+ const queue = this.queues.get(queueName);
694
+ if (!queue) {
695
+ throw new NotFoundError('Queue not found');
696
+ }
697
+
698
+ await queue.resume();
699
+
700
+ logger.info({ queueName }, 'Queue resumed');
701
+ }
702
+
703
+ /**
704
+ * Drain a queue (remove all jobs)
705
+ */
706
+ async drainQueue(queueName: string): Promise<void> {
707
+ const queue = this.queues.get(queueName);
708
+ if (!queue) {
709
+ throw new NotFoundError('Queue not found');
710
+ }
711
+
712
+ await queue.drain();
713
+
714
+ logger.info({ queueName }, 'Queue drained');
715
+ }
716
+
717
+ /**
718
+ * Obliterate a queue (remove queue and all data)
719
+ */
720
+ async obliterateQueue(queueName: string): Promise<void> {
721
+ const queue = this.queues.get(queueName);
722
+ if (!queue) {
723
+ throw new NotFoundError('Queue not found');
724
+ }
725
+
726
+ // Close worker first
727
+ const worker = this.workers.get(queueName);
728
+ if (worker) {
729
+ await worker.close();
730
+ this.workers.delete(queueName);
731
+ }
732
+
733
+ // Close queue events
734
+ const queueEvents = this.queueEvents.get(queueName);
735
+ if (queueEvents) {
736
+ await queueEvents.close();
737
+ this.queueEvents.delete(queueName);
738
+ }
739
+
740
+ // Obliterate queue
741
+ await queue.obliterate();
742
+ this.queues.delete(queueName);
743
+ this.workerProcessors.delete(queueName);
744
+ this.metrics.delete(queueName);
745
+
746
+ logger.info({ queueName }, 'Queue obliterated');
747
+ }
748
+
749
+ /**
750
+ * Get job progress
751
+ */
752
+ async getJobProgress(
753
+ queueName: string,
754
+ jobId: string
755
+ ): Promise<number | object | string | boolean> {
756
+ const queue = this.queues.get(queueName);
757
+ if (!queue) {
758
+ throw new NotFoundError('Queue not found');
759
+ }
760
+
761
+ const bullJob = await queue.getJob(jobId);
762
+ if (!bullJob) {
763
+ throw new NotFoundError('Job not found');
764
+ }
765
+
766
+ return bullJob.progress;
767
+ }
768
+
769
+ /**
770
+ * Update job progress
771
+ */
772
+ async updateJobProgress(
773
+ queueName: string,
774
+ jobId: string,
775
+ progress: number | object
776
+ ): Promise<void> {
777
+ const queue = this.queues.get(queueName);
778
+ if (!queue) {
779
+ throw new NotFoundError('Queue not found');
780
+ }
781
+
782
+ const bullJob = await queue.getJob(jobId);
783
+ if (!bullJob) {
784
+ throw new NotFoundError('Job not found');
785
+ }
786
+
787
+ await bullJob.updateProgress(progress);
788
+ }
789
+
790
+ /**
791
+ * Graceful shutdown
792
+ */
793
+ async close(): Promise<void> {
794
+ if (this.isClosing) {
795
+ return;
796
+ }
797
+
798
+ this.isClosing = true;
799
+ logger.info('Closing queue service...');
800
+
801
+ // Close all workers (wait for active jobs)
802
+ const workerPromises = Array.from(this.workers.values()).map((worker) => worker.close());
803
+ await Promise.all(workerPromises);
804
+
805
+ // Close all queue events
806
+ const eventPromises = Array.from(this.queueEvents.values()).map((events) => events.close());
807
+ await Promise.all(eventPromises);
808
+
809
+ // Close all queues
810
+ const queuePromises = Array.from(this.queues.values()).map((queue) => queue.close());
811
+ await Promise.all(queuePromises);
812
+
813
+ // Close Redis connection
814
+ await this.connection.quit();
815
+
816
+ this.workers.clear();
817
+ this.queueEvents.clear();
818
+ this.queues.clear();
819
+ this.workerProcessors.clear();
820
+ this.metrics.clear();
821
+
822
+ logger.info('Queue service closed');
823
+ }
824
+
825
+ /**
826
+ * Check if service is connected
827
+ */
828
+ isConnected(): boolean {
829
+ return this.connection.status === 'ready';
830
+ }
831
+
832
+ /**
833
+ * Get Redis connection info
834
+ */
835
+ getConnectionInfo(): { host: string; port: number; status: string } {
836
+ return {
837
+ host: this.config.redis.host || 'localhost',
838
+ port: this.config.redis.port || 6379,
839
+ status: this.connection.status,
840
+ };
841
+ }
842
+ }