@igniter-js/jobs 0.1.0 → 0.1.1

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.
@@ -1,596 +1,498 @@
1
+ import { createBullMQAdapter } from '@igniter-js/adapter-bullmq';
1
2
  import { IgniterError } from '@igniter-js/core';
2
3
 
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
- }
4
+ // src/adapters/bullmq.adapter.ts
5
+
6
+ // src/utils/prefix.ts
7
+ var _IgniterJobsPrefix = class _IgniterJobsPrefix {
8
+ /**
9
+ * Builds a normalized queue name using the global prefix and queue id.
10
+ *
11
+ * @example
12
+ * ```typescript
13
+ * const name = IgniterJobsPrefix.buildQueueName('email')
14
+ * // -> igniter:jobs:email
15
+ * ```
16
+ */
17
+ static buildQueueName(queue) {
18
+ return `${_IgniterJobsPrefix.BASE_PREFIX}:${queue}`;
21
19
  }
22
20
  /**
23
- * Convert error to a plain object for serialization.
21
+ * Builds the event channel used for pub/sub.
22
+ *
23
+ * Unscoped events are published to a global channel per service/environment.
24
+ * Scoped events are also published to an additional channel for that scope.
24
25
  */
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
- };
26
+ static buildEventsChannel(params) {
27
+ const base = `${_IgniterJobsPrefix.BASE_PREFIX}:events:${params.environment}:${params.service}`;
28
+ if (!params.scope) return base;
29
+ return `${base}:scope:${params.scope.type}:${params.scope.id}`;
30
+ }
31
+ };
32
+ _IgniterJobsPrefix.BASE_PREFIX = "igniter:jobs";
33
+ var IgniterJobsPrefix = _IgniterJobsPrefix;
34
+ var IgniterJobsError = class extends IgniterError {
35
+ constructor(options) {
36
+ super(options);
34
37
  }
35
38
  };
36
39
 
37
40
  // src/adapters/bullmq.adapter.ts
38
- var BullMQAdapter = class _BullMQAdapter {
41
+ function toDateArray(values) {
42
+ if (!values) return void 0;
43
+ return values.map((v) => v instanceof Date ? v : new Date(v));
44
+ }
45
+ var IgniterJobsBullMQAdapter = class _IgniterJobsBullMQAdapter {
39
46
  constructor(options) {
40
- this.queues = /* @__PURE__ */ new Map();
41
- this.workers = /* @__PURE__ */ new Map();
42
- this.queueEvents = /* @__PURE__ */ new Map();
43
- this.BullMQ = null;
47
+ this.subscribers = /* @__PURE__ */ new Map();
48
+ this.coreAdapter = null;
49
+ this.coreExecutor = null;
50
+ this.executorDirty = true;
51
+ this.jobsByQueue = /* @__PURE__ */ new Map();
52
+ this.cronsByQueue = /* @__PURE__ */ new Map();
44
53
  this.redis = options.redis;
54
+ this.publisher = this.redis;
55
+ this.subscriber = this.redis.duplicate();
56
+ this.client = { redis: this.redis };
57
+ this.subscriber.on("message", (channel, message) => {
58
+ const set = this.subscribers.get(channel);
59
+ if (!set || set.size === 0) return;
60
+ let payload = message;
61
+ try {
62
+ payload = JSON.parse(message);
63
+ } catch {
64
+ }
65
+ for (const handler of set) handler(payload);
66
+ });
67
+ this.queues = {
68
+ list: async () => this.listQueues(),
69
+ get: async (name) => this.getQueueInfo(name),
70
+ getJobCounts: async (name) => this.getQueueJobCounts(name),
71
+ getJobs: async (name, filter) => {
72
+ const full = this.toCoreQueueName(name);
73
+ return this.core().queues.getJobs(full, filter);
74
+ },
75
+ pause: async (name) => this.pauseQueue(name),
76
+ resume: async (name) => this.resumeQueue(name),
77
+ isPaused: async (name) => {
78
+ const full = this.toCoreQueueName(name);
79
+ return this.core().queues.isPaused(full);
80
+ },
81
+ drain: async (name) => this.drainQueue(name),
82
+ clean: async (name, options2) => this.cleanQueue(name, options2),
83
+ obliterate: async (name, options2) => this.obliterateQueue(name, options2)
84
+ };
45
85
  }
46
- /**
47
- * Create a new BullMQ adapter.
48
- *
49
- * @param options - Adapter options with Redis connection
50
- * @returns A new BullMQAdapter instance
51
- */
52
86
  static create(options) {
53
- return new _BullMQAdapter(options);
54
- }
55
- /**
56
- * Get the underlying Redis client.
57
- */
58
- get client() {
59
- return this.redis;
87
+ return new _IgniterJobsBullMQAdapter(options);
60
88
  }
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
89
+ registerJob(queueName, jobName, definition) {
90
+ const map = this.jobsByQueue.get(queueName) ?? /* @__PURE__ */ new Map();
91
+ if (map.has(jobName)) {
92
+ throw new IgniterJobsError({
93
+ code: "JOBS_DUPLICATE_JOB",
94
+ message: `Job "${jobName}" is already registered in queue "${queueName}".`
78
95
  });
79
- this.queues.set(name, queue);
80
96
  }
81
- return this.queues.get(name);
97
+ map.set(jobName, definition);
98
+ this.jobsByQueue.set(queueName, map);
99
+ this.executorDirty = true;
82
100
  }
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
101
+ registerCron(queueName, cronName, definition) {
102
+ const map = this.cronsByQueue.get(queueName) ?? /* @__PURE__ */ new Map();
103
+ if (map.has(cronName)) {
104
+ throw new IgniterJobsError({
105
+ code: "JOBS_INVALID_CRON",
106
+ message: `Cron "${cronName}" is already registered in queue "${queueName}".`
91
107
  });
92
- this.queueEvents.set(name, events);
93
108
  }
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";
109
+ map.set(cronName, definition);
110
+ this.cronsByQueue.set(queueName, map);
111
+ this.executorDirty = true;
110
112
  }
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
113
  async dispatch(params) {
141
- const queue = await this.getOrCreateQueue(params.queue);
142
- const jobOptions = {
114
+ const executor = await this.executor();
115
+ const namespace = executor[params.queue];
116
+ if (!namespace) {
117
+ throw new IgniterJobsError({
118
+ code: "JOBS_QUEUE_NOT_FOUND",
119
+ message: `Queue "${params.queue}" is not registered in the adapter.`
120
+ });
121
+ }
122
+ return namespace.enqueue({
123
+ task: params.jobName,
124
+ input: params.input,
143
125
  jobId: params.jobId,
144
126
  delay: params.delay,
145
127
  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,
128
+ attempts: params.attempts,
129
+ metadata: params.metadata,
151
130
  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;
131
+ removeOnFail: params.removeOnFail,
132
+ limiter: params.limiter
133
+ });
161
134
  }
162
135
  async schedule(params) {
163
- const queue = await this.getOrCreateQueue(params.queue);
164
- const jobOptions = {
136
+ const executor = await this.executor();
137
+ const namespace = executor[params.queue];
138
+ if (!namespace) {
139
+ throw new IgniterJobsError({
140
+ code: "JOBS_QUEUE_NOT_FOUND",
141
+ message: `Queue "${params.queue}" is not registered in the adapter.`
142
+ });
143
+ }
144
+ const schedule = {
165
145
  jobId: params.jobId,
166
- delay: params.at ? params.at.getTime() - Date.now() : params.delay,
146
+ delay: params.delay,
167
147
  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,
148
+ attempts: params.attempts,
149
+ metadata: params.metadata,
173
150
  removeOnComplete: params.removeOnComplete,
174
151
  removeOnFail: params.removeOnFail,
175
- repeat: params.cron ? {
176
- pattern: params.cron,
177
- tz: params.timezone
178
- } : params.every ? {
179
- every: params.every
152
+ limiter: params.limiter,
153
+ at: params.at,
154
+ repeat: params.cron || params.every || params.maxExecutions || params.skipWeekends || params.onlyBusinessHours || params.businessHours || params.onlyWeekdays || params.skipDates ? {
155
+ cron: params.cron,
156
+ every: params.every,
157
+ times: params.maxExecutions,
158
+ skipWeekends: params.skipWeekends,
159
+ onlyBusinessHours: params.onlyBusinessHours,
160
+ businessHours: params.businessHours,
161
+ onlyWeekdays: params.onlyWeekdays,
162
+ skipDates: toDateArray(params.skipDates)
180
163
  } : void 0
181
164
  };
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();
165
+ return namespace.schedule({
166
+ task: params.jobName,
167
+ input: params.input,
168
+ ...schedule
169
+ });
249
170
  }
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
- }
171
+ async getJob(jobId, queue) {
172
+ const result = await this.core().job.get(jobId, queue ? this.toCoreQueueName(queue) : void 0);
173
+ return result ? this.mapJob(result, queue) : null;
265
174
  }
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
- };
175
+ async getJobState(jobId, queue) {
176
+ const state = await this.core().job.getState(jobId, queue ? this.toCoreQueueName(queue) : void 0);
177
+ return state;
178
+ }
179
+ async getJobLogs(jobId, queue) {
180
+ const logs = await this.core().job.getLogs(jobId, queue ? this.toCoreQueueName(queue) : void 0);
181
+ return logs;
182
+ }
183
+ async getJobProgress(jobId, queue) {
184
+ return this.core().job.getProgress(jobId, queue ? this.toCoreQueueName(queue) : void 0);
185
+ }
186
+ async retryJob(jobId, queue) {
187
+ await this.core().job.retry(jobId, queue ? this.toCoreQueueName(queue) : void 0);
188
+ }
189
+ async removeJob(jobId, queue) {
190
+ await this.core().job.remove(jobId, queue ? this.toCoreQueueName(queue) : void 0);
191
+ }
192
+ async promoteJob(jobId, queue) {
193
+ await this.core().job.promote(jobId, queue ? this.toCoreQueueName(queue) : void 0);
194
+ }
195
+ async moveJobToFailed(jobId, reason, queue) {
196
+ await this.core().job.moveToFailed(jobId, reason, queue ? this.toCoreQueueName(queue) : void 0);
197
+ }
198
+ async retryManyJobs(jobIds, queue) {
199
+ await this.core().job.retryMany(jobIds, queue ? this.toCoreQueueName(queue) : void 0);
200
+ }
201
+ async removeManyJobs(jobIds, queue) {
202
+ await this.core().job.removeMany(jobIds, queue ? this.toCoreQueueName(queue) : void 0);
203
+ }
204
+ async getQueueInfo(queue) {
205
+ const info = await this.core().queues.get(this.toCoreQueueName(queue));
206
+ if (!info) return null;
207
+ return this.mapQueueInfo(info);
208
+ }
209
+ async getQueueJobCounts(queue) {
210
+ const counts = await this.core().queues.getJobCounts(this.toCoreQueueName(queue));
211
+ return counts;
212
+ }
213
+ async listQueues() {
214
+ const list = await this.core().queues.list();
215
+ return list.map((q) => this.mapQueueInfo(q));
303
216
  }
304
217
  async pauseQueue(queue) {
305
- const q = await this.getOrCreateQueue(queue);
306
- await q.pause();
218
+ await this.core().queues.pause(this.toCoreQueueName(queue));
307
219
  }
308
220
  async resumeQueue(queue) {
309
- const q = await this.getOrCreateQueue(queue);
310
- await q.resume();
221
+ await this.core().queues.resume(this.toCoreQueueName(queue));
311
222
  }
312
223
  async drainQueue(queue) {
313
- const q = await this.getOrCreateQueue(queue);
314
- await q.drain();
315
- return 0;
224
+ return this.core().queues.drain(this.toCoreQueueName(queue));
316
225
  }
317
226
  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;
227
+ return this.core().queues.clean(this.toCoreQueueName(queue), options);
330
228
  }
331
229
  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
- };
230
+ await this.core().queues.obliterate(this.toCoreQueueName(queue), options);
231
+ }
232
+ async retryAllInQueue(queue) {
233
+ const jobs = await this.core().queues.getJobs(this.toCoreQueueName(queue), { status: ["failed"], limit: 1e3 });
234
+ await Promise.all(jobs.map((j) => this.core().job.retry(j.id, this.toCoreQueueName(queue))));
235
+ return jobs.length;
352
236
  }
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
237
  async pauseJobType(queue, jobName) {
386
- this.config?.logger?.warn?.(`pauseJobType is not fully supported in BullMQ adapter`);
238
+ throw new IgniterJobsError({
239
+ code: "JOBS_QUEUE_OPERATION_FAILED",
240
+ message: "BullMQ backend does not support pausing a single job type; pause the queue or adjust worker filters."
241
+ });
387
242
  }
388
243
  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
- });
244
+ throw new IgniterJobsError({
245
+ code: "JOBS_QUEUE_OPERATION_FAILED",
246
+ message: "BullMQ backend does not support resuming a single job type; resume the queue or adjust worker filters."
247
+ });
248
+ }
249
+ async searchJobs(filter) {
250
+ const queue = filter?.queue;
251
+ const status = filter?.status;
252
+ const limit = filter?.limit ?? 100;
253
+ const offset = filter?.offset ?? 0;
254
+ if (queue) {
255
+ const jobs = await this.core().queues.getJobs(this.toCoreQueueName(queue), { status, limit, offset });
256
+ return jobs.map((j) => this.mapJob(j, queue));
257
+ }
258
+ const queues = await this.listQueues();
259
+ const results = [];
260
+ for (const q of queues) {
261
+ const jobs = await this.core().queues.getJobs(this.toCoreQueueName(q.name), { status, limit, offset });
262
+ results.push(...jobs.map((j) => this.mapJob(j, q.name)));
263
+ if (results.length >= limit) break;
264
+ }
265
+ return results.slice(0, limit);
266
+ }
267
+ async searchQueues(filter) {
268
+ const all = await this.listQueues();
269
+ const name = filter?.name;
270
+ const isPaused = filter?.isPaused;
271
+ return all.filter((q) => name ? q.name.includes(name) : true).filter((q) => typeof isPaused === "boolean" ? q.isPaused === isPaused : true);
272
+ }
273
+ async searchWorkers(filter) {
274
+ const queue = filter?.queue;
275
+ const isRunning = filter?.isRunning;
276
+ const all = Array.from(this.core().getWorkers().values());
277
+ return all.filter((w) => {
278
+ if (!queue) return true;
279
+ const coreQueue = this.toCoreQueueName(queue);
280
+ const queues = w.config?.queues ?? [w.queueName];
281
+ return Array.isArray(queues) ? queues.includes(coreQueue) : false;
282
+ }).filter((w) => typeof isRunning === "boolean" ? isRunning ? w.isRunning() : !w.isRunning() : true).map((w) => this.mapWorker(w));
283
+ }
284
+ async createWorker(config) {
285
+ await this.executor();
286
+ const queuesSource = config.queues?.length ? config.queues : Array.from(/* @__PURE__ */ new Set([...this.jobsByQueue.keys(), ...this.cronsByQueue.keys()]));
287
+ const queues = queuesSource.map((q) => this.toCoreQueueName(q));
288
+ const coreConfig = {
289
+ queues,
290
+ concurrency: config.concurrency ?? 1,
291
+ limiter: config.limiter,
292
+ onActive: config.handlers?.onActive,
293
+ onSuccess: config.handlers?.onSuccess,
294
+ onFailure: config.handlers?.onFailure,
295
+ onIdle: config.handlers?.onIdle
296
+ };
297
+ const handle = await this.core().worker(coreConfig);
298
+ return this.mapWorker(handle);
299
+ }
300
+ getWorkers() {
301
+ const out = /* @__PURE__ */ new Map();
302
+ for (const [id, handle] of this.core().getWorkers()) out.set(id, this.mapWorker(handle));
303
+ return out;
304
+ }
305
+ async publishEvent(channel, payload) {
306
+ await this.publisher.publish(channel, JSON.stringify(payload));
307
+ }
308
+ async subscribeEvent(channel, handler) {
309
+ const set = this.subscribers.get(channel) ?? /* @__PURE__ */ new Set();
310
+ const wrapped = (payload) => void handler(payload);
311
+ set.add(wrapped);
312
+ this.subscribers.set(channel, set);
313
+ if (set.size === 1) {
314
+ await this.subscriber.subscribe(channel);
422
315
  }
423
316
  return async () => {
424
- await Promise.all(subscriptions.map((unsub) => unsub()));
317
+ const current = this.subscribers.get(channel);
318
+ if (!current) return;
319
+ current.delete(wrapped);
320
+ if (current.size === 0) {
321
+ this.subscribers.delete(channel);
322
+ await this.subscriber.unsubscribe(channel);
323
+ }
425
324
  };
426
325
  }
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++;
326
+ async shutdown() {
327
+ await this.subscriber.quit();
328
+ }
329
+ core() {
330
+ if (!this.coreAdapter) {
331
+ this.coreAdapter = createBullMQAdapter({
332
+ store: { client: this.redis }
490
333
  });
491
334
  }
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);
335
+ return this.coreAdapter;
336
+ }
337
+ async executor() {
338
+ if (!this.executorDirty && this.coreExecutor) return this.coreExecutor;
339
+ const routers = {};
340
+ const flattened = {};
341
+ const allQueues = /* @__PURE__ */ new Set([...this.jobsByQueue.keys(), ...this.cronsByQueue.keys()]);
342
+ for (const queueName of allQueues) {
343
+ const coreJobs = {};
344
+ const jobs = this.jobsByQueue.get(queueName);
345
+ if (jobs) {
346
+ for (const [jobName, def] of jobs.entries()) {
347
+ const queue = def.queue ? `${queueName}.${def.queue}` : queueName;
348
+ const fullQueue = IgniterJobsPrefix.buildQueueName(queue);
349
+ coreJobs[jobName] = this.toCoreJobDefinition(queueName, jobName, def, fullQueue);
509
350
  }
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
351
  }
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;
352
+ const crons = this.cronsByQueue.get(queueName);
353
+ if (crons) {
354
+ for (const [cronName, def] of crons.entries()) {
355
+ const fullQueue = IgniterJobsPrefix.buildQueueName(queueName);
356
+ coreJobs[cronName] = this.toCoreCronJobDefinition(queueName, cronName, def, fullQueue);
357
+ }
358
+ }
359
+ if (Object.keys(coreJobs).length === 0) continue;
360
+ routers[queueName] = this.core().router({
361
+ jobs: coreJobs,
362
+ namespace: queueName
549
363
  });
364
+ for (const [jobName, def] of Object.entries(coreJobs)) {
365
+ flattened[`${queueName}.${jobName}`] = def;
366
+ }
550
367
  }
551
- return results.slice(0, filter.limit || 100);
368
+ await this.core().bulkRegister(flattened);
369
+ this.coreExecutor = this.core().merge(routers);
370
+ this.executorDirty = false;
371
+ return this.coreExecutor;
552
372
  }
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
- }
373
+ toCoreQueueName(queueName) {
374
+ return IgniterJobsPrefix.buildQueueName(queueName);
375
+ }
376
+ mapQueueInfo(info) {
377
+ return {
378
+ name: this.fromCoreQueueName(info.name),
379
+ isPaused: info.isPaused,
380
+ jobCounts: info.jobCounts
381
+ };
382
+ }
383
+ fromCoreQueueName(full) {
384
+ const prefix = `${IgniterJobsPrefix.BASE_PREFIX}:`;
385
+ return full.startsWith(prefix) ? full.slice(prefix.length) : full;
386
+ }
387
+ mapJob(job, queue) {
388
+ const q = queue ?? this.fromCoreQueueName(job.metadata?.queue ?? job.queueName ?? "");
389
+ const scope = job.metadata?.__igniter_jobs_scope;
390
+ return {
391
+ id: job.id,
392
+ name: job.name,
393
+ queue: q,
394
+ status: job.status,
395
+ input: job.payload,
396
+ result: job.result,
397
+ error: job.error,
398
+ progress: 0,
399
+ attemptsMade: job.attemptsMade ?? 0,
400
+ priority: job.priority ?? 0,
401
+ createdAt: job.createdAt,
402
+ startedAt: job.processedAt,
403
+ completedAt: job.completedAt,
404
+ metadata: job.metadata,
405
+ scope
406
+ };
407
+ }
408
+ mapWorker(handle) {
409
+ const queues = handle.config?.queues ?? [handle.queueName];
410
+ return {
411
+ id: handle.id,
412
+ queues: queues.map((q) => this.fromCoreQueueName(q)),
413
+ pause: () => handle.pause(),
414
+ resume: () => handle.resume(),
415
+ close: () => handle.close(),
416
+ isRunning: () => handle.isRunning(),
417
+ isPaused: () => handle.isPaused(),
418
+ isClosed: () => handle.isClosed(),
419
+ getMetrics: async () => handle.getMetrics()
420
+ };
421
+ }
422
+ toCoreJobDefinition(queueName, jobName, def, fullQueueName) {
423
+ const handler = async (ctx) => {
424
+ return def.handler({
425
+ input: ctx.input,
426
+ context: ctx.context,
427
+ job: {
428
+ id: ctx.job.id,
429
+ name: jobName,
430
+ queue: queueName,
431
+ attemptsMade: ctx.job.attemptsMade,
432
+ createdAt: ctx.job.createdAt,
433
+ metadata: ctx.job.metadata
434
+ },
435
+ scope: ctx.job.metadata?.__igniter_jobs_scope
571
436
  });
572
- }
573
- return results;
437
+ };
438
+ return {
439
+ name: jobName,
440
+ input: def.input,
441
+ handler,
442
+ queue: { name: fullQueueName },
443
+ attempts: def.attempts,
444
+ priority: def.priority,
445
+ delay: def.delay,
446
+ removeOnComplete: def.removeOnComplete,
447
+ removeOnFail: def.removeOnFail,
448
+ metadata: def.metadata,
449
+ limiter: def.limiter,
450
+ onStart: def.onStart,
451
+ onSuccess: def.onSuccess,
452
+ onFailure: def.onFailure,
453
+ onProgress: def.onProgress
454
+ };
574
455
  }
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();
456
+ toCoreCronJobDefinition(queueName, cronName, def, fullQueueName) {
457
+ const handler = async (ctx) => {
458
+ return def.handler({
459
+ context: ctx.context,
460
+ job: {
461
+ id: ctx.job.id,
462
+ name: cronName,
463
+ queue: queueName,
464
+ attemptsMade: ctx.job.attemptsMade,
465
+ createdAt: ctx.job.createdAt,
466
+ metadata: ctx.job.metadata
467
+ },
468
+ scope: ctx.job.metadata?.__igniter_jobs_scope
469
+ });
470
+ };
471
+ return {
472
+ name: cronName,
473
+ handler,
474
+ queue: { name: fullQueueName },
475
+ repeat: {
476
+ cron: def.cron,
477
+ tz: def.tz,
478
+ limit: def.maxExecutions,
479
+ startDate: def.startDate,
480
+ endDate: def.endDate
481
+ },
482
+ metadata: def.onlyBusinessHours || def.skipWeekends || def.businessHours || def.onlyWeekdays || def.skipDates || def.startDate && def.endDate ? {
483
+ advancedScheduling: {
484
+ onlyBusinessHours: def.onlyBusinessHours,
485
+ skipWeekends: def.skipWeekends,
486
+ businessHours: def.businessHours,
487
+ skipDates: toDateArray(def.skipDates),
488
+ onlyWeekdays: def.onlyWeekdays,
489
+ between: def.startDate && def.endDate ? [def.startDate, def.endDate] : void 0
490
+ }
491
+ } : void 0
492
+ };
591
493
  }
592
494
  };
593
495
 
594
- export { BullMQAdapter };
496
+ export { IgniterJobsBullMQAdapter };
595
497
  //# sourceMappingURL=bullmq.adapter.mjs.map
596
498
  //# sourceMappingURL=bullmq.adapter.mjs.map