@igniter-js/jobs 0.1.0

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.
@@ -0,0 +1,596 @@
1
+ import { IgniterError } from '@igniter-js/core';
2
+
3
+ // src/errors/igniter-jobs.error.ts
4
+ var IgniterJobsError = class _IgniterJobsError extends IgniterError {
5
+ constructor(options) {
6
+ super({
7
+ code: options.code,
8
+ message: options.message,
9
+ statusCode: options.statusCode ?? 500,
10
+ causer: "@igniter-js/jobs",
11
+ cause: options.cause,
12
+ details: options.details,
13
+ logger: options.logger
14
+ });
15
+ this.code = options.code;
16
+ this.details = options.details;
17
+ this.name = "IgniterJobsError";
18
+ if (Error.captureStackTrace) {
19
+ Error.captureStackTrace(this, _IgniterJobsError);
20
+ }
21
+ }
22
+ /**
23
+ * Convert error to a plain object for serialization.
24
+ */
25
+ toJSON() {
26
+ return {
27
+ name: this.name,
28
+ code: this.code,
29
+ message: this.message,
30
+ statusCode: this.statusCode,
31
+ details: this.details,
32
+ stack: this.stack
33
+ };
34
+ }
35
+ };
36
+
37
+ // src/adapters/bullmq.adapter.ts
38
+ var BullMQAdapter = class _BullMQAdapter {
39
+ constructor(options) {
40
+ this.queues = /* @__PURE__ */ new Map();
41
+ this.workers = /* @__PURE__ */ new Map();
42
+ this.queueEvents = /* @__PURE__ */ new Map();
43
+ this.BullMQ = null;
44
+ this.redis = options.redis;
45
+ }
46
+ /**
47
+ * Create a new BullMQ adapter.
48
+ *
49
+ * @param options - Adapter options with Redis connection
50
+ * @returns A new BullMQAdapter instance
51
+ */
52
+ static create(options) {
53
+ return new _BullMQAdapter(options);
54
+ }
55
+ /**
56
+ * Get the underlying Redis client.
57
+ */
58
+ get client() {
59
+ return this.redis;
60
+ }
61
+ /**
62
+ * Lazily load BullMQ module.
63
+ */
64
+ async getBullMQ() {
65
+ if (!this.BullMQ) {
66
+ this.BullMQ = await import('bullmq');
67
+ }
68
+ return this.BullMQ;
69
+ }
70
+ /**
71
+ * Get or create a queue instance.
72
+ */
73
+ async getOrCreateQueue(name) {
74
+ if (!this.queues.has(name)) {
75
+ const { Queue } = await this.getBullMQ();
76
+ const queue = new Queue(name, {
77
+ connection: this.redis
78
+ });
79
+ this.queues.set(name, queue);
80
+ }
81
+ return this.queues.get(name);
82
+ }
83
+ /**
84
+ * Get or create queue events instance.
85
+ */
86
+ async getQueueEvents(name) {
87
+ if (!this.queueEvents.has(name)) {
88
+ const { QueueEvents } = await this.getBullMQ();
89
+ const events = new QueueEvents(name, {
90
+ connection: this.redis
91
+ });
92
+ this.queueEvents.set(name, events);
93
+ }
94
+ return this.queueEvents.get(name);
95
+ }
96
+ /**
97
+ * Convert BullMQ job state to IgniterJobStatus.
98
+ */
99
+ mapJobState(state) {
100
+ const stateMap = {
101
+ waiting: "waiting",
102
+ active: "active",
103
+ completed: "completed",
104
+ failed: "failed",
105
+ delayed: "delayed",
106
+ paused: "paused",
107
+ "waiting-children": "waiting"
108
+ };
109
+ return stateMap[state] || "waiting";
110
+ }
111
+ /**
112
+ * Convert BullMQ job to IgniterJobInfo.
113
+ */
114
+ async mapJobToInfo(job) {
115
+ const data = job.data;
116
+ const state = await job.getState();
117
+ return {
118
+ id: job.id,
119
+ name: job.name,
120
+ queue: job.queueName,
121
+ state: this.mapJobState(state || "waiting"),
122
+ data: data.input ?? data,
123
+ result: job.returnvalue,
124
+ error: job.failedReason,
125
+ progress: typeof job.progress === "number" ? job.progress : 0,
126
+ attempts: job.attemptsMade,
127
+ timestamp: job.timestamp,
128
+ processedOn: job.processedOn,
129
+ finishedOn: job.finishedOn,
130
+ delay: job.delay,
131
+ priority: job.opts?.priority,
132
+ scope: data.scope,
133
+ actor: data.actor,
134
+ metadata: data.metadata
135
+ };
136
+ }
137
+ // ==========================================
138
+ // JOB OPERATIONS
139
+ // ==========================================
140
+ async dispatch(params) {
141
+ const queue = await this.getOrCreateQueue(params.queue);
142
+ const jobOptions = {
143
+ jobId: params.jobId,
144
+ delay: params.delay,
145
+ priority: params.priority,
146
+ attempts: params.attempts ?? 3,
147
+ backoff: params.backoff ? {
148
+ type: params.backoff.type === "custom" ? "fixed" : params.backoff.type,
149
+ delay: params.backoff.type === "custom" ? params.backoff.delays[0] : params.backoff.delay
150
+ } : void 0,
151
+ removeOnComplete: params.removeOnComplete,
152
+ removeOnFail: params.removeOnFail
153
+ };
154
+ const jobData = {
155
+ input: params.data,
156
+ scope: params.scope,
157
+ actor: params.actor
158
+ };
159
+ const job = await queue.add(params.name, jobData, jobOptions);
160
+ return job.id;
161
+ }
162
+ async schedule(params) {
163
+ const queue = await this.getOrCreateQueue(params.queue);
164
+ const jobOptions = {
165
+ jobId: params.jobId,
166
+ delay: params.at ? params.at.getTime() - Date.now() : params.delay,
167
+ priority: params.priority,
168
+ attempts: params.attempts ?? 3,
169
+ backoff: params.backoff ? {
170
+ type: params.backoff.type === "custom" ? "fixed" : params.backoff.type,
171
+ delay: params.backoff.type === "custom" ? params.backoff.delays[0] : params.backoff.delay
172
+ } : void 0,
173
+ removeOnComplete: params.removeOnComplete,
174
+ removeOnFail: params.removeOnFail,
175
+ repeat: params.cron ? {
176
+ pattern: params.cron,
177
+ tz: params.timezone
178
+ } : params.every ? {
179
+ every: params.every
180
+ } : void 0
181
+ };
182
+ const jobData = {
183
+ input: params.data,
184
+ scope: params.scope,
185
+ actor: params.actor
186
+ };
187
+ const job = await queue.add(params.name, jobData, jobOptions);
188
+ return job.id;
189
+ }
190
+ async getJob(queue, jobId) {
191
+ const q = await this.getOrCreateQueue(queue);
192
+ const job = await q.getJob(jobId);
193
+ if (!job) return null;
194
+ return await this.mapJobToInfo(job);
195
+ }
196
+ async getJobState(queue, jobId) {
197
+ const q = await this.getOrCreateQueue(queue);
198
+ const job = await q.getJob(jobId);
199
+ if (!job) return null;
200
+ const state = await job.getState();
201
+ return this.mapJobState(state);
202
+ }
203
+ async getJobProgress(queue, jobId) {
204
+ const q = await this.getOrCreateQueue(queue);
205
+ const job = await q.getJob(jobId);
206
+ if (!job) return 0;
207
+ return typeof job.progress === "number" ? job.progress : 0;
208
+ }
209
+ async getJobLogs(queue, jobId) {
210
+ const q = await this.getOrCreateQueue(queue);
211
+ const job = await q.getJob(jobId);
212
+ if (!job) return [];
213
+ const { logs } = await q.getJobLogs(jobId);
214
+ return logs.map((log, index) => ({
215
+ timestamp: /* @__PURE__ */ new Date(),
216
+ message: log,
217
+ level: "info"
218
+ }));
219
+ }
220
+ async retryJob(queue, jobId) {
221
+ const q = await this.getOrCreateQueue(queue);
222
+ const job = await q.getJob(jobId);
223
+ if (!job) {
224
+ throw new IgniterJobsError({
225
+ code: "JOBS_JOB_NOT_FOUND",
226
+ message: `Job "${jobId}" not found in queue "${queue}"`,
227
+ statusCode: 404
228
+ });
229
+ }
230
+ await job.retry();
231
+ }
232
+ async removeJob(queue, jobId) {
233
+ const q = await this.getOrCreateQueue(queue);
234
+ const job = await q.getJob(jobId);
235
+ if (!job) return;
236
+ await job.remove();
237
+ }
238
+ async promoteJob(queue, jobId) {
239
+ const q = await this.getOrCreateQueue(queue);
240
+ const job = await q.getJob(jobId);
241
+ if (!job) {
242
+ throw new IgniterJobsError({
243
+ code: "JOBS_JOB_NOT_FOUND",
244
+ message: `Job "${jobId}" not found in queue "${queue}"`,
245
+ statusCode: 404
246
+ });
247
+ }
248
+ await job.promote();
249
+ }
250
+ async moveJob(queue, jobId, state, reason) {
251
+ const q = await this.getOrCreateQueue(queue);
252
+ const job = await q.getJob(jobId);
253
+ if (!job) {
254
+ throw new IgniterJobsError({
255
+ code: "JOBS_JOB_NOT_FOUND",
256
+ message: `Job "${jobId}" not found in queue "${queue}"`,
257
+ statusCode: 404
258
+ });
259
+ }
260
+ if (state === "failed") {
261
+ await job.moveToFailed(new Error(reason || "Manually moved to failed"), "manual");
262
+ } else {
263
+ await job.moveToCompleted(reason || "Manually completed", "manual");
264
+ }
265
+ }
266
+ async retryJobs(queue, jobIds) {
267
+ const q = await this.getOrCreateQueue(queue);
268
+ await Promise.all(
269
+ jobIds.map(async (jobId) => {
270
+ const job = await q.getJob(jobId);
271
+ if (job) await job.retry();
272
+ })
273
+ );
274
+ }
275
+ async removeJobs(queue, jobIds) {
276
+ const q = await this.getOrCreateQueue(queue);
277
+ await Promise.all(
278
+ jobIds.map(async (jobId) => {
279
+ const job = await q.getJob(jobId);
280
+ if (job) await job.remove();
281
+ })
282
+ );
283
+ }
284
+ // ==========================================
285
+ // QUEUE OPERATIONS
286
+ // ==========================================
287
+ async getQueue(queue) {
288
+ const q = await this.getOrCreateQueue(queue);
289
+ const isPaused = await q.isPaused();
290
+ const counts = await q.getJobCounts();
291
+ return {
292
+ name: queue,
293
+ isPaused,
294
+ jobCounts: {
295
+ waiting: counts.waiting || 0,
296
+ active: counts.active || 0,
297
+ completed: counts.completed || 0,
298
+ failed: counts.failed || 0,
299
+ delayed: counts.delayed || 0,
300
+ paused: counts.paused || 0
301
+ }
302
+ };
303
+ }
304
+ async pauseQueue(queue) {
305
+ const q = await this.getOrCreateQueue(queue);
306
+ await q.pause();
307
+ }
308
+ async resumeQueue(queue) {
309
+ const q = await this.getOrCreateQueue(queue);
310
+ await q.resume();
311
+ }
312
+ async drainQueue(queue) {
313
+ const q = await this.getOrCreateQueue(queue);
314
+ await q.drain();
315
+ return 0;
316
+ }
317
+ async cleanQueue(queue, options) {
318
+ const q = await this.getOrCreateQueue(queue);
319
+ const statuses = Array.isArray(options.status) ? options.status : [options.status];
320
+ let total = 0;
321
+ for (const status of statuses) {
322
+ const cleaned = await q.clean(
323
+ options.olderThan ?? 0,
324
+ options.limit ?? 1e3,
325
+ status
326
+ );
327
+ total += cleaned.length;
328
+ }
329
+ return total;
330
+ }
331
+ async obliterateQueue(queue, options) {
332
+ const q = await this.getOrCreateQueue(queue);
333
+ await q.obliterate({ force: options?.force });
334
+ }
335
+ async retryAllFailed(queue) {
336
+ const q = await this.getOrCreateQueue(queue);
337
+ const failed = await q.getFailed();
338
+ await Promise.all(failed.map((job) => job.retry()));
339
+ return failed.length;
340
+ }
341
+ async getJobCounts(queue) {
342
+ const q = await this.getOrCreateQueue(queue);
343
+ const counts = await q.getJobCounts();
344
+ return {
345
+ waiting: counts.waiting || 0,
346
+ active: counts.active || 0,
347
+ completed: counts.completed || 0,
348
+ failed: counts.failed || 0,
349
+ delayed: counts.delayed || 0,
350
+ paused: counts.paused || 0
351
+ };
352
+ }
353
+ async listJobs(queue, options) {
354
+ const q = await this.getOrCreateQueue(queue);
355
+ const statuses = options?.status || ["waiting", "active", "completed", "failed", "delayed"];
356
+ const jobs = [];
357
+ for (const status of statuses) {
358
+ const statusJobs = await q.getJobs([status], options?.start, options?.end);
359
+ jobs.push(...statusJobs);
360
+ }
361
+ return Promise.all(jobs.map(async (job) => {
362
+ const data = job.data;
363
+ const state = await job.getState();
364
+ return {
365
+ id: job.id,
366
+ name: job.name,
367
+ queue: job.queueName,
368
+ state: this.mapJobState(state || "waiting"),
369
+ data: data.input ?? data,
370
+ result: job.returnvalue,
371
+ error: job.failedReason,
372
+ progress: typeof job.progress === "number" ? job.progress : 0,
373
+ attempts: job.attemptsMade,
374
+ timestamp: job.timestamp,
375
+ processedOn: job.processedOn,
376
+ finishedOn: job.finishedOn,
377
+ scope: data.scope,
378
+ actor: data.actor
379
+ };
380
+ }));
381
+ }
382
+ // ==========================================
383
+ // PAUSE/RESUME JOB TYPES
384
+ // ==========================================
385
+ async pauseJobType(queue, jobName) {
386
+ this.config?.logger?.warn?.(`pauseJobType is not fully supported in BullMQ adapter`);
387
+ }
388
+ async resumeJobType(queue, jobName) {
389
+ this.config?.logger?.warn?.(`resumeJobType is not fully supported in BullMQ adapter`);
390
+ }
391
+ // ==========================================
392
+ // EVENTS
393
+ // ==========================================
394
+ async subscribe(pattern, handler) {
395
+ const subscriptions = [];
396
+ for (const [queueName] of this.queues) {
397
+ const events = await this.getQueueEvents(queueName);
398
+ const completedHandler = async (args) => {
399
+ if (this.matchesPattern(pattern, `${queueName}:${args.jobId}:completed`)) {
400
+ await handler({
401
+ type: "completed",
402
+ data: args,
403
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
404
+ });
405
+ }
406
+ };
407
+ const failedHandler = async (args) => {
408
+ if (this.matchesPattern(pattern, `${queueName}:${args.jobId}:failed`)) {
409
+ await handler({
410
+ type: "failed",
411
+ data: args,
412
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
413
+ });
414
+ }
415
+ };
416
+ events.on("completed", completedHandler);
417
+ events.on("failed", failedHandler);
418
+ subscriptions.push(async () => {
419
+ events.off("completed", completedHandler);
420
+ events.off("failed", failedHandler);
421
+ });
422
+ }
423
+ return async () => {
424
+ await Promise.all(subscriptions.map((unsub) => unsub()));
425
+ };
426
+ }
427
+ matchesPattern(pattern, eventType) {
428
+ if (pattern === "*") return true;
429
+ const regex = new RegExp(
430
+ "^" + pattern.replace(/\*/g, ".*").replace(/\?/g, ".") + "$"
431
+ );
432
+ return regex.test(eventType);
433
+ }
434
+ // ==========================================
435
+ // WORKERS
436
+ // ==========================================
437
+ async createWorker(config, handler) {
438
+ const { Worker } = await this.getBullMQ();
439
+ const workerId = `worker-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
440
+ const workers = [];
441
+ for (const queueName of config.queues) {
442
+ const workerOptions = {
443
+ connection: this.redis,
444
+ concurrency: config.concurrency,
445
+ lockDuration: config.lockDuration,
446
+ limiter: config.limiter
447
+ };
448
+ const worker = new Worker(
449
+ queueName,
450
+ async (job) => {
451
+ const data = job.data;
452
+ return handler({
453
+ id: job.id,
454
+ name: job.name,
455
+ queue: job.queueName,
456
+ data: data.input ?? data,
457
+ attempt: job.attemptsMade + 1,
458
+ timestamp: job.timestamp,
459
+ scope: data.scope,
460
+ actor: data.actor,
461
+ log: async (level, message) => {
462
+ await job.log(`[${level.toUpperCase()}] ${message}`);
463
+ },
464
+ updateProgress: async (progress) => {
465
+ await job.updateProgress(progress);
466
+ }
467
+ });
468
+ },
469
+ workerOptions
470
+ );
471
+ if (config.onIdle) {
472
+ worker.on("drained", config.onIdle);
473
+ }
474
+ workers.push(worker);
475
+ this.workers.set(`${workerId}-${queueName}`, worker);
476
+ }
477
+ let isPaused = false;
478
+ const startTime = Date.now();
479
+ let processed = 0;
480
+ let failed = 0;
481
+ let completed = 0;
482
+ for (const worker of workers) {
483
+ worker.on("completed", () => {
484
+ processed++;
485
+ completed++;
486
+ });
487
+ worker.on("failed", () => {
488
+ processed++;
489
+ failed++;
490
+ });
491
+ }
492
+ return {
493
+ id: workerId,
494
+ pause: async () => {
495
+ await Promise.all(workers.map((w) => w.pause()));
496
+ isPaused = true;
497
+ },
498
+ resume: async () => {
499
+ await Promise.all(workers.map((w) => w.resume()));
500
+ isPaused = false;
501
+ },
502
+ close: async () => {
503
+ await Promise.all(workers.map((w) => w.close()));
504
+ for (const worker of workers) {
505
+ const key = Array.from(this.workers.entries()).find(
506
+ ([_, w]) => w === worker
507
+ )?.[0];
508
+ if (key) this.workers.delete(key);
509
+ }
510
+ },
511
+ isRunning: () => !isPaused && workers.every((w) => w.isRunning()),
512
+ isPaused: () => isPaused,
513
+ getMetrics: async () => ({
514
+ processed,
515
+ failed,
516
+ completed,
517
+ active: workers.reduce((sum, w) => sum + (w.isRunning() ? 1 : 0), 0),
518
+ uptime: Date.now() - startTime
519
+ })
520
+ };
521
+ }
522
+ // ==========================================
523
+ // SEARCH
524
+ // ==========================================
525
+ async searchJobs(filter) {
526
+ const results = [];
527
+ const queuesToSearch = filter.queue ? [filter.queue] : Array.from(this.queues.keys());
528
+ for (const queueName of queuesToSearch) {
529
+ const jobs = await this.listJobs(queueName, {
530
+ status: filter.status,
531
+ start: filter.offset,
532
+ end: filter.limit ? (filter.offset || 0) + filter.limit : void 0
533
+ });
534
+ for (const job of jobs) {
535
+ if (filter.jobName && job.name !== filter.jobName) continue;
536
+ if (filter.scopeId && job.scope?.id !== filter.scopeId) continue;
537
+ if (filter.actorId && job.actor?.id !== filter.actorId) continue;
538
+ if (filter.dateRange?.from && job.timestamp < filter.dateRange.from.getTime()) continue;
539
+ if (filter.dateRange?.to && job.timestamp > filter.dateRange.to.getTime()) continue;
540
+ results.push(job);
541
+ }
542
+ }
543
+ if (filter.orderBy) {
544
+ const [field, direction] = filter.orderBy.split(":");
545
+ results.sort((a, b) => {
546
+ const aVal = field === "createdAt" ? a.timestamp : a[field];
547
+ const bVal = field === "createdAt" ? b.timestamp : b[field];
548
+ return direction === "asc" ? aVal - bVal : bVal - aVal;
549
+ });
550
+ }
551
+ return results.slice(0, filter.limit || 100);
552
+ }
553
+ async searchQueues(filter) {
554
+ const results = [];
555
+ for (const [queueName, queue] of this.queues) {
556
+ if (filter.name && !queueName.includes(filter.name)) continue;
557
+ const isPaused = await queue.isPaused();
558
+ if (filter.isPaused !== void 0 && isPaused !== filter.isPaused) continue;
559
+ const counts = await queue.getJobCounts();
560
+ results.push({
561
+ name: queueName,
562
+ isPaused,
563
+ jobCounts: {
564
+ waiting: counts.waiting || 0,
565
+ active: counts.active || 0,
566
+ completed: counts.completed || 0,
567
+ failed: counts.failed || 0,
568
+ delayed: counts.delayed || 0,
569
+ paused: counts.paused || 0
570
+ }
571
+ });
572
+ }
573
+ return results;
574
+ }
575
+ // ==========================================
576
+ // LIFECYCLE
577
+ // ==========================================
578
+ async shutdown() {
579
+ for (const [_, worker] of this.workers) {
580
+ await worker.close();
581
+ }
582
+ this.workers.clear();
583
+ for (const [_, events] of this.queueEvents) {
584
+ await events.close();
585
+ }
586
+ this.queueEvents.clear();
587
+ for (const [_, queue] of this.queues) {
588
+ await queue.close();
589
+ }
590
+ this.queues.clear();
591
+ }
592
+ };
593
+
594
+ export { BullMQAdapter };
595
+ //# sourceMappingURL=bullmq.adapter.mjs.map
596
+ //# sourceMappingURL=bullmq.adapter.mjs.map