@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.
package/dist/index.mjs CHANGED
@@ -1,1000 +1,1065 @@
1
1
  import { IgniterError } from '@igniter-js/core';
2
+ import { createBullMQAdapter } from '@igniter-js/adapter-bullmq';
2
3
 
3
4
  // src/errors/igniter-jobs.error.ts
4
5
  var IGNITER_JOBS_ERROR_CODES = {
5
- // Configuration errors
6
6
  JOBS_ADAPTER_REQUIRED: "JOBS_ADAPTER_REQUIRED",
7
+ JOBS_SERVICE_REQUIRED: "JOBS_SERVICE_REQUIRED",
7
8
  JOBS_CONTEXT_REQUIRED: "JOBS_CONTEXT_REQUIRED",
8
- JOBS_QUEUE_REQUIRED: "JOBS_QUEUE_REQUIRED",
9
9
  JOBS_CONFIGURATION_INVALID: "JOBS_CONFIGURATION_INVALID",
10
- // Queue errors
11
10
  JOBS_QUEUE_NOT_FOUND: "JOBS_QUEUE_NOT_FOUND",
12
- JOBS_QUEUE_ALREADY_EXISTS: "JOBS_QUEUE_ALREADY_EXISTS",
13
- JOBS_QUEUE_NAME_INVALID: "JOBS_QUEUE_NAME_INVALID",
14
- JOBS_QUEUE_PAUSE_FAILED: "JOBS_QUEUE_PAUSE_FAILED",
15
- JOBS_QUEUE_RESUME_FAILED: "JOBS_QUEUE_RESUME_FAILED",
16
- JOBS_QUEUE_DRAIN_FAILED: "JOBS_QUEUE_DRAIN_FAILED",
17
- JOBS_QUEUE_CLEAN_FAILED: "JOBS_QUEUE_CLEAN_FAILED",
18
- JOBS_QUEUE_OBLITERATE_FAILED: "JOBS_QUEUE_OBLITERATE_FAILED",
19
- // Job definition errors
20
- JOBS_JOB_NOT_FOUND: "JOBS_JOB_NOT_FOUND",
21
- JOBS_JOB_ALREADY_EXISTS: "JOBS_JOB_ALREADY_EXISTS",
22
- JOBS_JOB_NAME_INVALID: "JOBS_JOB_NAME_INVALID",
23
- JOBS_JOB_HANDLER_REQUIRED: "JOBS_JOB_HANDLER_REQUIRED",
24
- // Cron errors
25
- JOBS_CRON_NOT_FOUND: "JOBS_CRON_NOT_FOUND",
26
- JOBS_CRON_ALREADY_EXISTS: "JOBS_CRON_ALREADY_EXISTS",
27
- JOBS_CRON_EXPRESSION_INVALID: "JOBS_CRON_EXPRESSION_INVALID",
28
- JOBS_CRON_HANDLER_REQUIRED: "JOBS_CRON_HANDLER_REQUIRED",
29
- // Dispatch errors
30
- JOBS_DISPATCH_FAILED: "JOBS_DISPATCH_FAILED",
31
- JOBS_SCHEDULE_FAILED: "JOBS_SCHEDULE_FAILED",
32
- JOBS_INPUT_REQUIRED: "JOBS_INPUT_REQUIRED",
33
- JOBS_INPUT_VALIDATION_FAILED: "JOBS_INPUT_VALIDATION_FAILED",
34
- // Scope errors
35
- JOBS_SCOPE_REQUIRED: "JOBS_SCOPE_REQUIRED",
11
+ JOBS_QUEUE_DUPLICATE: "JOBS_QUEUE_DUPLICATE",
12
+ JOBS_QUEUE_OPERATION_FAILED: "JOBS_QUEUE_OPERATION_FAILED",
13
+ JOBS_INVALID_DEFINITION: "JOBS_INVALID_DEFINITION",
14
+ JOBS_HANDLER_REQUIRED: "JOBS_HANDLER_REQUIRED",
15
+ JOBS_DUPLICATE_JOB: "JOBS_DUPLICATE_JOB",
16
+ JOBS_NOT_FOUND: "JOBS_NOT_FOUND",
17
+ JOBS_NOT_REGISTERED: "JOBS_NOT_REGISTERED",
18
+ JOBS_EXECUTION_FAILED: "JOBS_EXECUTION_FAILED",
19
+ JOBS_TIMEOUT: "JOBS_TIMEOUT",
20
+ JOBS_CONTEXT_FACTORY_FAILED: "JOBS_CONTEXT_FACTORY_FAILED",
21
+ JOBS_VALIDATION_FAILED: "JOBS_VALIDATION_FAILED",
22
+ JOBS_INVALID_INPUT: "JOBS_INVALID_INPUT",
23
+ JOBS_INVALID_CRON: "JOBS_INVALID_CRON",
24
+ JOBS_INVALID_SCHEDULE: "JOBS_INVALID_SCHEDULE",
36
25
  JOBS_SCOPE_ALREADY_DEFINED: "JOBS_SCOPE_ALREADY_DEFINED",
37
- JOBS_SCOPE_INVALID: "JOBS_SCOPE_INVALID",
38
- // Actor errors
39
- JOBS_ACTOR_ALREADY_DEFINED: "JOBS_ACTOR_ALREADY_DEFINED",
40
- JOBS_ACTOR_INVALID: "JOBS_ACTOR_INVALID",
41
- // Job management errors
42
- JOBS_GET_FAILED: "JOBS_GET_FAILED",
43
- JOBS_RETRY_FAILED: "JOBS_RETRY_FAILED",
44
- JOBS_REMOVE_FAILED: "JOBS_REMOVE_FAILED",
45
- JOBS_PROMOTE_FAILED: "JOBS_PROMOTE_FAILED",
46
- JOBS_MOVE_FAILED: "JOBS_MOVE_FAILED",
47
- JOBS_STATE_FAILED: "JOBS_STATE_FAILED",
48
- JOBS_PROGRESS_FAILED: "JOBS_PROGRESS_FAILED",
49
- JOBS_LOGS_FAILED: "JOBS_LOGS_FAILED",
50
- // Worker errors
51
- JOBS_WORKER_CREATE_FAILED: "JOBS_WORKER_CREATE_FAILED",
52
- JOBS_WORKER_START_FAILED: "JOBS_WORKER_START_FAILED",
53
- JOBS_WORKER_STOP_FAILED: "JOBS_WORKER_STOP_FAILED",
54
- JOBS_WORKER_NOT_FOUND: "JOBS_WORKER_NOT_FOUND",
55
- JOBS_WORKER_ALREADY_RUNNING: "JOBS_WORKER_ALREADY_RUNNING",
56
- // Event/Subscribe errors
57
- JOBS_SUBSCRIBE_FAILED: "JOBS_SUBSCRIBE_FAILED",
58
- JOBS_UNSUBSCRIBE_FAILED: "JOBS_UNSUBSCRIBE_FAILED",
59
- JOBS_EVENT_EMIT_FAILED: "JOBS_EVENT_EMIT_FAILED",
60
- // Search errors
61
- JOBS_SEARCH_FAILED: "JOBS_SEARCH_FAILED",
62
- JOBS_SEARCH_INVALID_TARGET: "JOBS_SEARCH_INVALID_TARGET",
63
- // Shutdown errors
64
- JOBS_SHUTDOWN_FAILED: "JOBS_SHUTDOWN_FAILED",
65
- // Handler errors
66
- JOBS_HANDLER_FAILED: "JOBS_HANDLER_FAILED",
67
- JOBS_HANDLER_TIMEOUT: "JOBS_HANDLER_TIMEOUT"
26
+ JOBS_WORKER_FAILED: "JOBS_WORKER_FAILED",
27
+ JOBS_ADAPTER_ERROR: "JOBS_ADAPTER_ERROR",
28
+ JOBS_ADAPTER_CONNECTION_FAILED: "JOBS_ADAPTER_CONNECTION_FAILED",
29
+ JOBS_SUBSCRIBE_FAILED: "JOBS_SUBSCRIBE_FAILED"
68
30
  };
69
- var IgniterJobsError = class _IgniterJobsError extends IgniterError {
31
+ var IgniterJobsError = class extends IgniterError {
70
32
  constructor(options) {
71
- super({
72
- code: options.code,
73
- message: options.message,
74
- statusCode: options.statusCode ?? 500,
75
- causer: "@igniter-js/jobs",
76
- cause: options.cause,
77
- details: options.details,
78
- logger: options.logger
79
- });
80
- this.code = options.code;
81
- this.details = options.details;
82
- this.name = "IgniterJobsError";
83
- if (Error.captureStackTrace) {
84
- Error.captureStackTrace(this, _IgniterJobsError);
85
- }
86
- }
87
- /**
88
- * Convert error to a plain object for serialization.
89
- */
90
- toJSON() {
91
- return {
92
- name: this.name,
93
- code: this.code,
94
- message: this.message,
95
- statusCode: this.statusCode,
96
- details: this.details,
97
- stack: this.stack
98
- };
33
+ super(options);
99
34
  }
100
35
  };
101
36
 
102
37
  // src/builders/igniter-worker.builder.ts
103
- var IgniterWorkerBuilder = class {
104
- constructor(adapter, jobHandler, availableQueues) {
105
- this.adapter = adapter;
106
- this.jobHandler = jobHandler;
107
- this.availableQueues = availableQueues;
38
+ var IgniterWorkerBuilder = class _IgniterWorkerBuilder {
39
+ constructor(params) {
40
+ this.adapter = params.adapter;
41
+ this.allowedQueues = params.allowedQueues;
108
42
  this.state = {
109
- queues: [],
110
- concurrency: 1
43
+ queues: params.state?.queues ?? [],
44
+ concurrency: params.state?.concurrency,
45
+ limiter: params.state?.limiter,
46
+ handlers: params.state?.handlers ?? {}
111
47
  };
112
48
  }
49
+ clone(patch) {
50
+ return new _IgniterWorkerBuilder({
51
+ adapter: this.adapter,
52
+ allowedQueues: this.allowedQueues,
53
+ state: { ...this.state, ...patch }
54
+ });
55
+ }
113
56
  /**
114
- * Specify which queues this worker should process.
115
- * If not called, worker processes all queues.
116
- *
117
- * @param queues - Queue names to process
118
- * @returns The builder for chaining
57
+ * Adds a queue to the worker.
119
58
  *
120
- * @example
121
- * ```typescript
122
- * .forQueues('email', 'payment')
123
- * ```
59
+ * @param queue - Queue name registered on the jobs instance.
124
60
  */
125
- forQueues(...queues) {
126
- for (const queue of queues) {
127
- if (!this.availableQueues.includes(queue)) {
128
- throw new IgniterJobsError({
129
- code: "JOBS_QUEUE_NOT_FOUND",
130
- message: `Queue "${queue}" is not registered. Available queues: ${this.availableQueues.join(", ")}`,
131
- statusCode: 400,
132
- details: { queue, availableQueues: this.availableQueues }
133
- });
134
- }
61
+ addQueue(queue) {
62
+ if (!this.allowedQueues.includes(queue)) {
63
+ throw new IgniterJobsError({
64
+ code: "JOBS_QUEUE_NOT_FOUND",
65
+ message: `Queue "${queue}" is not registered on this jobs instance.`
66
+ });
135
67
  }
136
- this.state.queues = queues;
137
- return this;
68
+ if (this.state.queues.includes(queue)) return this;
69
+ return this.clone({ queues: [...this.state.queues, queue] });
138
70
  }
139
71
  /**
140
- * Set the concurrency level (jobs processed in parallel per queue).
141
- *
142
- * @param concurrency - Number of parallel jobs
143
- * @returns The builder for chaining
144
- *
145
- * @example
146
- * ```typescript
147
- * .withConcurrency(10)
148
- * ```
72
+ * Sets the worker concurrency.
149
73
  */
150
74
  withConcurrency(concurrency) {
151
- if (concurrency < 1) {
75
+ if (!Number.isFinite(concurrency) || concurrency <= 0) {
152
76
  throw new IgniterJobsError({
153
77
  code: "JOBS_CONFIGURATION_INVALID",
154
- message: "Concurrency must be at least 1",
155
- statusCode: 400,
156
- details: { concurrency }
78
+ message: "Worker concurrency must be a positive number."
157
79
  });
158
80
  }
159
- this.state.concurrency = concurrency;
160
- return this;
81
+ return this.clone({ concurrency });
161
82
  }
162
83
  /**
163
- * Set the lock duration in milliseconds.
164
- * Jobs are locked for this duration while being processed.
165
- *
166
- * @param duration - Lock duration in milliseconds
167
- * @returns The builder for chaining
168
- *
169
- * @example
170
- * ```typescript
171
- * .withLockDuration(30000) // 30 seconds
172
- * ```
84
+ * Sets a worker-level rate limiter.
173
85
  */
174
- withLockDuration(duration) {
175
- if (duration < 1e3) {
86
+ withLimiter(limiter) {
87
+ if (!limiter || !Number.isFinite(limiter.max) || !Number.isFinite(limiter.duration) || limiter.max <= 0 || limiter.duration <= 0) {
176
88
  throw new IgniterJobsError({
177
89
  code: "JOBS_CONFIGURATION_INVALID",
178
- message: "Lock duration must be at least 1000ms",
179
- statusCode: 400,
180
- details: { duration }
90
+ message: "Limiter must include positive max and duration."
181
91
  });
182
92
  }
183
- this.state.lockDuration = duration;
184
- return this;
93
+ return this.clone({ limiter });
94
+ }
95
+ onActive(handler) {
96
+ return this.clone({ handlers: { ...this.state.handlers, onActive: handler } });
97
+ }
98
+ onSuccess(handler) {
99
+ return this.clone({ handlers: { ...this.state.handlers, onSuccess: handler } });
100
+ }
101
+ onFailure(handler) {
102
+ return this.clone({ handlers: { ...this.state.handlers, onFailure: handler } });
103
+ }
104
+ onIdle(handler) {
105
+ return this.clone({ handlers: { ...this.state.handlers, onIdle: handler } });
185
106
  }
186
107
  /**
187
- * Configure rate limiting for the worker.
108
+ * Builds and starts the worker.
188
109
  *
189
- * @param config - Rate limiter configuration
190
- * @returns The builder for chaining
191
- *
192
- * @example
193
- * ```typescript
194
- * .withLimiter({ max: 100, duration: 60000 }) // 100 jobs per minute
195
- * ```
110
+ * If no queues are added explicitly, the adapter should interpret it as "all queues".
196
111
  */
197
- withLimiter(config) {
198
- if (config.max < 1) {
199
- throw new IgniterJobsError({
200
- code: "JOBS_CONFIGURATION_INVALID",
201
- message: "Limiter max must be at least 1",
202
- statusCode: 400,
203
- details: { max: config.max }
204
- });
205
- }
206
- if (config.duration < 1) {
207
- throw new IgniterJobsError({
208
- code: "JOBS_CONFIGURATION_INVALID",
209
- message: "Limiter duration must be at least 1ms",
210
- statusCode: 400,
211
- details: { duration: config.duration }
212
- });
213
- }
214
- this.state.limiter = config;
215
- return this;
112
+ async start() {
113
+ const concurrency = this.state.concurrency ?? 1;
114
+ return this.adapter.createWorker({
115
+ queues: this.state.queues,
116
+ concurrency,
117
+ limiter: this.state.limiter,
118
+ handlers: this.state.handlers
119
+ });
216
120
  }
121
+ };
122
+
123
+ // src/utils/prefix.ts
124
+ var _IgniterJobsPrefix = class _IgniterJobsPrefix {
217
125
  /**
218
- * Set a callback to be called when the worker becomes idle.
219
- *
220
- * @param callback - Idle callback
221
- * @returns The builder for chaining
126
+ * Builds a normalized queue name using the global prefix and queue id.
222
127
  *
223
128
  * @example
224
129
  * ```typescript
225
- * .onIdle(() => console.log('Worker is idle'))
130
+ * const name = IgniterJobsPrefix.buildQueueName('email')
131
+ * // -> igniter:jobs:email
226
132
  * ```
227
133
  */
228
- onIdle(callback) {
229
- this.state.onIdle = callback;
230
- return this;
134
+ static buildQueueName(queue) {
135
+ return `${_IgniterJobsPrefix.BASE_PREFIX}:${queue}`;
231
136
  }
232
137
  /**
233
- * Start the worker.
234
- *
235
- * @returns Worker handle for management
138
+ * Builds the event channel used for pub/sub.
236
139
  *
237
- * @example
238
- * ```typescript
239
- * const worker = await jobs.worker
240
- * .create()
241
- * .forQueues('email')
242
- * .start()
243
- *
244
- * // Later
245
- * await worker.pause()
246
- * await worker.close()
247
- * ```
140
+ * Unscoped events are published to a global channel per service/environment.
141
+ * Scoped events are also published to an additional channel for that scope.
248
142
  */
249
- async start() {
250
- const queuesToProcess = this.state.queues.length > 0 ? this.state.queues : this.availableQueues;
251
- try {
252
- const handle = await this.adapter.createWorker(
253
- {
254
- queues: queuesToProcess,
255
- concurrency: this.state.concurrency,
256
- lockDuration: this.state.lockDuration,
257
- limiter: this.state.limiter,
258
- onIdle: this.state.onIdle
259
- },
260
- this.jobHandler
261
- );
262
- return {
263
- id: handle.id,
264
- pause: () => handle.pause(),
265
- resume: () => handle.resume(),
266
- close: () => handle.close(),
267
- isRunning: () => handle.isRunning(),
268
- isPaused: () => handle.isPaused(),
269
- getMetrics: () => handle.getMetrics()
270
- };
271
- } catch (error) {
272
- throw new IgniterJobsError({
273
- code: "JOBS_WORKER_START_FAILED",
274
- message: "Failed to start worker",
275
- statusCode: 500,
276
- cause: error instanceof Error ? error : void 0,
277
- details: { queues: queuesToProcess }
278
- });
279
- }
143
+ static buildEventsChannel(params) {
144
+ const base = `${_IgniterJobsPrefix.BASE_PREFIX}:events:${params.environment}:${params.service}`;
145
+ if (!params.scope) return base;
146
+ return `${base}:scope:${params.scope.type}:${params.scope.id}`;
280
147
  }
281
148
  };
149
+ _IgniterJobsPrefix.BASE_PREFIX = "igniter:jobs";
150
+ var IgniterJobsPrefix = _IgniterJobsPrefix;
282
151
 
283
- // src/core/igniter-jobs.ts
284
- var IGNITER_JOBS_PREFIX = "igniter:jobs";
285
- function getFullQueueName(queueName) {
286
- return `${IGNITER_JOBS_PREFIX}:${queueName}`;
287
- }
288
- var IgniterJobsRuntime = class {
289
- constructor(config) {
290
- this.config = config;
291
- this.queueNames = Object.keys(config.queues);
292
- return this.createProxy();
293
- }
152
+ // src/utils/events.utils.ts
153
+ var IgniterJobsEventsUtils = class {
294
154
  /**
295
- * Create the main proxy that provides queue access and global methods.
155
+ * Constructs a standardized job event type string.
156
+ *
157
+ * @param queue - The queue name.
158
+ * @param jobName - The job name.
159
+ * @param event - The specific event name (e.g., 'started', 'completed').
160
+ * @returns A string in the format `queue:jobName:event`.
296
161
  */
297
- createProxy() {
298
- const self = this;
299
- return new Proxy({}, {
300
- get(_, prop) {
301
- if (prop === "subscribe") {
302
- return self.createGlobalSubscribe();
303
- }
304
- if (prop === "search") {
305
- return self.createSearch();
306
- }
307
- if (prop === "worker") {
308
- return self.createWorkerBuilder();
309
- }
310
- if (prop === "shutdown") {
311
- return () => self.shutdown();
312
- }
313
- if (self.queueNames.includes(prop)) {
314
- return self.createQueueProxy(prop);
315
- }
316
- return void 0;
317
- },
318
- has(_, prop) {
319
- return ["subscribe", "search", "worker", "shutdown", ...self.queueNames].includes(prop);
320
- },
321
- ownKeys() {
322
- return ["subscribe", "search", "worker", "shutdown", ...self.queueNames];
323
- },
324
- getOwnPropertyDescriptor(_, prop) {
325
- if (["subscribe", "search", "worker", "shutdown", ...self.queueNames].includes(prop)) {
326
- return { configurable: true, enumerable: true };
327
- }
328
- return void 0;
329
- }
330
- });
162
+ static buildJobEventType(queue, jobName, event) {
163
+ return `${queue}:${jobName}:${event}`;
331
164
  }
332
165
  /**
333
- * Create a proxy for a specific queue.
166
+ * Publishes a job event to the configured adapter.
167
+ * Handles publishing to both the base channel and the scope-specific channel if a scope is provided.
168
+ *
169
+ * @param params - Configuration parameters for publishing the event.
334
170
  */
335
- createQueueProxy(queueName) {
336
- const self = this;
337
- const queueConfig = this.config.queues[queueName];
338
- const jobNames = Object.keys(queueConfig.jobs);
339
- return new Proxy({}, {
340
- get(_, prop) {
341
- if (prop === "subscribe") {
342
- return self.createQueueSubscribe(queueName);
343
- }
344
- if (prop === "list") {
345
- return (options) => self.listQueueJobs(queueName, options);
346
- }
347
- if (prop === "get") {
348
- return () => self.createQueueManagement(queueName);
349
- }
350
- if (jobNames.includes(prop)) {
351
- return self.createJobProxy(queueName, prop);
352
- }
353
- return void 0;
354
- }
171
+ static async publishJobsEvent(params) {
172
+ const baseChannel = IgniterJobsPrefix.buildEventsChannel({
173
+ service: params.service,
174
+ environment: params.environment
355
175
  });
356
- }
357
- /**
358
- * Create a proxy for a specific job.
359
- */
360
- createJobProxy(queueName, jobName) {
361
- const self = this;
362
- return {
363
- dispatch: (input, options) => self.dispatchJob(queueName, jobName, input, options),
364
- schedule: (params) => self.scheduleJob(queueName, jobName, params),
365
- get: (jobId) => self.createJobManagement(queueName, jobId),
366
- many: (jobIds) => self.createJobBatchManagement(queueName, jobIds),
367
- pause: () => self.pauseJobType(queueName, jobName),
368
- resume: () => self.resumeJobType(queueName, jobName),
369
- subscribe: (handler) => self.subscribeToJob(queueName, jobName, handler)
370
- };
371
- }
372
- /**
373
- * Dispatch a job.
374
- */
375
- async dispatchJob(queueName, jobName, input, options) {
376
- const queueConfig = this.config.queues[queueName];
377
- const jobDef = queueConfig.jobs[jobName];
378
- if (jobDef.input) {
379
- try {
380
- const result = await jobDef.input["~standard"].validate(input);
381
- if (result.issues) {
382
- throw new IgniterJobsError({
383
- code: "JOBS_INPUT_VALIDATION_FAILED",
384
- message: `Input validation failed for job "${jobName}"`,
385
- statusCode: 400,
386
- details: { issues: result.issues }
387
- });
388
- }
389
- input = result.value;
390
- } catch (error) {
391
- if (error instanceof IgniterJobsError) throw error;
392
- if (typeof jobDef.input.parse === "function") {
393
- input = jobDef.input.parse(input);
394
- }
395
- }
396
- }
397
- if (this.config.scope?.options?.required && !options?.scope) {
398
- throw new IgniterJobsError({
399
- code: "JOBS_SCOPE_REQUIRED",
400
- message: `Scope "${this.config.scope.key}" is required for job dispatch`,
401
- statusCode: 400,
402
- details: { queue: queueName, job: jobName }
403
- });
404
- }
405
- const params = {
406
- queue: getFullQueueName(queueName),
407
- name: jobName,
408
- data: input,
409
- jobId: options?.jobId,
410
- delay: options?.delay,
411
- priority: options?.priority ?? jobDef.priority,
412
- attempts: jobDef.retry?.attempts ?? this.config.defaults?.retry?.attempts ?? 3,
413
- backoff: jobDef.retry?.backoff ?? this.config.defaults?.retry?.backoff,
414
- removeOnComplete: jobDef.removeOnComplete ?? this.config.defaults?.removeOnComplete,
415
- removeOnFail: jobDef.removeOnFail ?? this.config.defaults?.removeOnFail,
416
- scope: options?.scope,
417
- actor: options?.actor
418
- };
419
- try {
420
- const jobId = await this.config.adapter.dispatch(params);
421
- if (this.config.telemetry) {
422
- }
423
- return jobId;
424
- } catch (error) {
425
- throw new IgniterJobsError({
426
- code: "JOBS_DISPATCH_FAILED",
427
- message: `Failed to dispatch job "${jobName}" to queue "${queueName}"`,
428
- statusCode: 500,
429
- cause: error instanceof Error ? error : void 0,
430
- details: { queue: queueName, job: jobName }
431
- });
432
- }
433
- }
434
- /**
435
- * Schedule a job for future execution.
436
- */
437
- async scheduleJob(queueName, jobName, params) {
438
- const queueConfig = this.config.queues[queueName];
439
- const jobDef = queueConfig.jobs[jobName];
440
- let validatedInput = params.input;
441
- if (jobDef.input) {
442
- try {
443
- const result = await jobDef.input["~standard"].validate(params.input);
444
- if (result.issues) {
445
- throw new IgniterJobsError({
446
- code: "JOBS_INPUT_VALIDATION_FAILED",
447
- message: `Input validation failed for job "${jobName}"`,
448
- statusCode: 400,
449
- details: { issues: result.issues }
450
- });
451
- }
452
- validatedInput = result.value;
453
- } catch (error) {
454
- if (error instanceof IgniterJobsError) throw error;
455
- if (typeof jobDef.input.parse === "function") {
456
- validatedInput = jobDef.input.parse(params.input);
457
- }
458
- }
459
- }
460
- const scheduleParams = {
461
- queue: getFullQueueName(queueName),
462
- name: jobName,
463
- data: validatedInput,
464
- jobId: params.jobId,
465
- at: params.at,
466
- cron: params.cron,
467
- every: params.every,
468
- timezone: params.timezone,
469
- attempts: jobDef.retry?.attempts ?? this.config.defaults?.retry?.attempts ?? 3,
470
- backoff: jobDef.retry?.backoff ?? this.config.defaults?.retry?.backoff,
471
- removeOnComplete: jobDef.removeOnComplete ?? this.config.defaults?.removeOnComplete,
472
- removeOnFail: jobDef.removeOnFail ?? this.config.defaults?.removeOnFail,
473
- scope: params.scope,
474
- actor: params.actor
475
- };
476
- try {
477
- return await this.config.adapter.schedule(scheduleParams);
478
- } catch (error) {
479
- throw new IgniterJobsError({
480
- code: "JOBS_SCHEDULE_FAILED",
481
- message: `Failed to schedule job "${jobName}" in queue "${queueName}"`,
482
- statusCode: 500,
483
- cause: error instanceof Error ? error : void 0,
484
- details: { queue: queueName, job: jobName }
176
+ await params.adapter.publishEvent(baseChannel, params.event);
177
+ if (params.scope) {
178
+ const scopeChannel = IgniterJobsPrefix.buildEventsChannel({
179
+ service: params.service,
180
+ environment: params.environment,
181
+ scope: { type: params.scope.type, id: params.scope.id }
485
182
  });
183
+ await params.adapter.publishEvent(scopeChannel, params.event);
486
184
  }
487
185
  }
186
+ };
187
+
188
+ // src/utils/scope.ts
189
+ var IgniterJobsScopeUtils = class {
488
190
  /**
489
- * Create job management methods.
490
- */
491
- createJobManagement(queueName, jobId) {
492
- const adapter = this.config.adapter;
493
- const fullQueueName = getFullQueueName(queueName);
494
- return {
495
- retrieve: () => adapter.getJob(fullQueueName, jobId),
496
- retry: () => adapter.retryJob(fullQueueName, jobId),
497
- remove: () => adapter.removeJob(fullQueueName, jobId),
498
- state: () => adapter.getJobState(fullQueueName, jobId),
499
- progress: () => adapter.getJobProgress(fullQueueName, jobId),
500
- logs: () => adapter.getJobLogs(fullQueueName, jobId),
501
- promote: () => adapter.promoteJob(fullQueueName, jobId),
502
- move: (state, reason) => adapter.moveJob(fullQueueName, jobId, state, reason)
503
- };
504
- }
505
- /**
506
- * Create batch job management methods.
507
- */
508
- createJobBatchManagement(queueName, jobIds) {
509
- const adapter = this.config.adapter;
510
- const fullQueueName = getFullQueueName(queueName);
511
- return {
512
- retry: () => adapter.retryJobs(fullQueueName, jobIds),
513
- remove: () => adapter.removeJobs(fullQueueName, jobIds)
514
- };
515
- }
516
- /**
517
- * Create queue management methods.
191
+ * Merges a scope entry into a metadata object.
192
+ *
193
+ * @param metadata - Existing metadata object.
194
+ * @param scope - The scope entry to merge.
195
+ * @returns A new metadata object containing the scope information, or the original metadata if no scope is provided.
518
196
  */
519
- createQueueManagement(queueName) {
520
- const adapter = this.config.adapter;
521
- const fullQueueName = getFullQueueName(queueName);
197
+ static mergeMetadataWithScope(metadata, scope) {
198
+ if (!scope) return metadata;
522
199
  return {
523
- retrieve: () => adapter.getQueue(fullQueueName),
524
- pause: () => adapter.pauseQueue(fullQueueName),
525
- resume: () => adapter.resumeQueue(fullQueueName),
526
- drain: () => adapter.drainQueue(fullQueueName),
527
- clean: (options) => adapter.cleanQueue(fullQueueName, {
528
- status: options.status,
529
- olderThan: options.olderThan,
530
- limit: options.limit
531
- }),
532
- obliterate: (options) => adapter.obliterateQueue(fullQueueName, options),
533
- retryAll: () => adapter.retryAllFailed(fullQueueName)
534
- };
535
- }
536
- /**
537
- * Pause a specific job type.
538
- */
539
- async pauseJobType(queueName, jobName) {
540
- const fullQueueName = getFullQueueName(queueName);
541
- await this.config.adapter.pauseJobType(fullQueueName, jobName);
542
- }
543
- /**
544
- * Resume a specific job type.
545
- */
546
- async resumeJobType(queueName, jobName) {
547
- const fullQueueName = getFullQueueName(queueName);
548
- await this.config.adapter.resumeJobType(fullQueueName, jobName);
549
- }
550
- /**
551
- * Subscribe to events for a specific job.
552
- */
553
- async subscribeToJob(queueName, jobName, handler) {
554
- const pattern = `${queueName}:${jobName}:*`;
555
- return this.config.adapter.subscribe(pattern, handler);
556
- }
557
- /**
558
- * Create queue-level subscribe.
559
- */
560
- createQueueSubscribe(queueName) {
561
- return async (handler) => {
562
- const pattern = `${queueName}:*`;
563
- return this.config.adapter.subscribe(pattern, handler);
200
+ ...metadata ?? {},
201
+ [this.SCOPE_METADATA_KEY]: scope
564
202
  };
565
203
  }
566
204
  /**
567
- * List jobs in a queue.
205
+ * Extracts a scope entry from a metadata object.
206
+ *
207
+ * @param metadata - The metadata object to inspect.
208
+ * @returns The extracted scope entry, or undefined if not found.
568
209
  */
569
- async listQueueJobs(queueName, options) {
570
- const fullQueueName = getFullQueueName(queueName);
571
- return this.config.adapter.listJobs(fullQueueName, {
572
- status: options?.status,
573
- end: options?.limit
574
- });
210
+ static extractScopeFromMetadata(metadata) {
211
+ if (!metadata) return void 0;
212
+ const value = metadata[this.SCOPE_METADATA_KEY];
213
+ if (!value || typeof value !== "object") return void 0;
214
+ if (!("type" in value) || !("id" in value)) return void 0;
215
+ return value;
575
216
  }
217
+ };
218
+ /**
219
+ * The key used to store scope information in job metadata.
220
+ */
221
+ IgniterJobsScopeUtils.SCOPE_METADATA_KEY = "__igniter_jobs_scope";
222
+
223
+ // src/utils/telemetry.ts
224
+ var IgniterJobsTelemetryUtils = class {
576
225
  /**
577
- * Create global subscribe.
226
+ * Emits a telemetry event if telemetry is configured.
227
+ * This is a fire-and-forget operation - telemetry errors are silently ignored
228
+ * to avoid affecting job processing.
229
+ *
230
+ * @param telemetry - The telemetry instance (optional).
231
+ * @param eventName - The name of the event to emit.
232
+ * @param attributes - Attributes to attach to the event.
233
+ * @param level - The log level for the event (default: 'info').
578
234
  */
579
- createGlobalSubscribe() {
580
- return async (handler) => {
581
- return this.config.adapter.subscribe("*", handler);
582
- };
235
+ static emitTelemetry(telemetry, eventName, attributes, level = "info") {
236
+ if (!telemetry) return;
237
+ try {
238
+ telemetry.emit(eventName, { attributes, level });
239
+ } catch {
240
+ }
583
241
  }
242
+ };
243
+
244
+ // src/utils/validation.ts
245
+ var IgniterJobsValidationUtils = class {
584
246
  /**
585
- * Create search function.
247
+ * Checks if a value conforms to the Standard Schema V1 interface.
248
+ *
249
+ * @param value - The value to check.
250
+ * @returns True if the value is a Standard Schema.
586
251
  */
587
- createSearch() {
588
- const adapter = this.config.adapter;
589
- return async (target, filter) => {
590
- switch (target) {
591
- case "queues":
592
- return adapter.searchQueues(filter || {});
593
- case "jobs":
594
- return adapter.searchJobs(filter || {});
595
- case "workers":
596
- return [];
597
- default:
598
- throw new IgniterJobsError({
599
- code: "JOBS_SEARCH_INVALID_TARGET",
600
- message: `Invalid search target "${target}". Use "queues", "jobs", or "workers".`,
601
- statusCode: 400
602
- });
603
- }
604
- };
252
+ static isStandardSchema(value) {
253
+ return Boolean(
254
+ value && typeof value === "object" && "~standard" in value
255
+ );
605
256
  }
606
257
  /**
607
- * Create worker builder.
258
+ * Checks if a value conforms to a Zod-like schema interface.
259
+ *
260
+ * @param value - The value to check.
261
+ * @returns True if the value is a Zod-like schema.
608
262
  */
609
- createWorkerBuilder() {
610
- return {
611
- create: () => new IgniterWorkerBuilder(
612
- this.config.adapter,
613
- this.createJobHandler(),
614
- this.queueNames.map((q) => getFullQueueName(q))
615
- )
616
- };
263
+ static isZodLikeSchema(value) {
264
+ return Boolean(
265
+ value && typeof value === "object" && "parse" in value
266
+ );
617
267
  }
618
268
  /**
619
- * Create the job handler function for workers.
269
+ * Validates input against a provided schema (Standard Schema or Zod-like).
270
+ *
271
+ * @param schema - The schema definition.
272
+ * @param input - The input data to validate.
273
+ * @returns The validated (and possibly transformed) data.
274
+ * @throws {IgniterJobsError} If validation fails.
620
275
  */
621
- createJobHandler() {
622
- const self = this;
623
- return async (job) => {
624
- const queueName = job.queue.replace(`${IGNITER_JOBS_PREFIX}:`, "");
625
- const queueConfig = self.config.queues[queueName];
626
- if (!queueConfig) {
276
+ static async validateInput(schema, input) {
277
+ if (this.isStandardSchema(schema)) {
278
+ const result = await schema["~standard"].validate(input);
279
+ if ("issues" in result && result.issues) {
280
+ const message = result.issues.map((i) => i.message).join("; ");
627
281
  throw new IgniterJobsError({
628
- code: "JOBS_QUEUE_NOT_FOUND",
629
- message: `Queue "${queueName}" not found`,
630
- statusCode: 404
282
+ code: "JOBS_VALIDATION_FAILED",
283
+ message: `Input validation failed: ${message}`,
284
+ details: { issues: result.issues },
285
+ statusCode: 400
631
286
  });
632
287
  }
633
- const jobDef = queueConfig.jobs[job.name];
634
- if (!jobDef) {
635
- throw new IgniterJobsError({
636
- code: "JOBS_JOB_NOT_FOUND",
637
- message: `Job "${job.name}" not found in queue "${queueName}"`,
638
- statusCode: 404
639
- });
288
+ return result.value;
289
+ }
290
+ if (this.isZodLikeSchema(schema)) {
291
+ if (typeof schema.safeParse === "function") {
292
+ const result = schema.safeParse(input);
293
+ if (!result.success) {
294
+ throw new IgniterJobsError({
295
+ code: "JOBS_VALIDATION_FAILED",
296
+ message: "Input validation failed.",
297
+ details: { error: result.error },
298
+ statusCode: 400
299
+ });
300
+ }
301
+ return result.data;
640
302
  }
641
- const context = await self.config.context();
642
- const ctx = {
643
- input: job.data,
644
- context,
645
- job: {
646
- id: job.id,
647
- name: job.name,
648
- queue: queueName,
649
- timestamp: job.timestamp
650
- },
651
- attempt: job.attempt,
652
- scope: job.scope,
653
- actor: job.actor,
654
- log: job.log,
655
- updateProgress: job.updateProgress
656
- };
657
303
  try {
658
- const result = await jobDef.handler(ctx);
659
- if (jobDef.onComplete) {
660
- await jobDef.onComplete(ctx, result);
661
- }
662
- return result;
304
+ return schema.parse(input);
663
305
  } catch (error) {
664
- if (jobDef.onFailure) {
665
- await jobDef.onFailure(ctx, error instanceof Error ? error : new Error(String(error)));
666
- }
667
- throw error;
306
+ throw new IgniterJobsError({
307
+ code: "JOBS_VALIDATION_FAILED",
308
+ message: "Input validation failed.",
309
+ details: { error },
310
+ statusCode: 400
311
+ });
668
312
  }
669
- };
670
- }
671
- /**
672
- * Shutdown the jobs instance.
673
- */
674
- async shutdown() {
675
- try {
676
- await this.config.adapter.shutdown();
677
- } catch (error) {
678
- throw new IgniterJobsError({
679
- code: "JOBS_SHUTDOWN_FAILED",
680
- message: "Failed to shutdown jobs instance",
681
- statusCode: 500,
682
- cause: error instanceof Error ? error : void 0
683
- });
684
313
  }
314
+ return input;
685
315
  }
686
316
  };
687
317
 
688
- // src/builders/igniter-jobs.builder.ts
689
- var IgniterJobsBuilder = class _IgniterJobsBuilder {
690
- constructor(state) {
691
- this.state = state;
692
- }
318
+ // src/core/igniter-jobs.ts
319
+ var IgniterJobs = class {
693
320
  /**
694
- * Create a new IgniterJobs builder.
695
- *
696
- * @returns A new IgniterJobsBuilder instance
321
+ * Starts the fluent builder API for jobs.
697
322
  *
698
323
  * @example
699
324
  * ```typescript
700
325
  * const jobs = IgniterJobs.create<AppContext>()
701
- * .withAdapter(...)
326
+ * .withAdapter(IgniterJobsMemoryAdapter.create())
327
+ * .withService('my-api')
328
+ * .withEnvironment('test')
329
+ * .withContext(async () => ({ db }))
330
+ * .addQueue(emailQueue)
702
331
  * .build()
703
332
  * ```
704
333
  */
705
334
  static create() {
706
- return new _IgniterJobsBuilder({
707
- queues: {}
708
- });
335
+ return IgniterJobsBuilder.create();
709
336
  }
710
337
  /**
711
- * Configure the queue adapter (required).
712
- *
713
- * @param adapter - The queue adapter (e.g., BullMQAdapter)
714
- * @returns The builder with adapter configured
715
- *
716
- * @example
717
- * ```typescript
718
- * .withAdapter(BullMQAdapter.create({ redis }))
719
- * ```
338
+ * Creates a runtime instance from a validated configuration.
720
339
  */
721
- withAdapter(adapter) {
722
- return new _IgniterJobsBuilder({
723
- ...this.state,
724
- adapter
725
- });
340
+ static fromConfig(config) {
341
+ const internal = createInternalState(config);
342
+ return createRuntime(internal, void 0);
726
343
  }
727
- /**
728
- * Configure the application context provider (required).
729
- * This function is called for each job to provide the application context.
730
- *
731
- * @param contextFn - Function that returns the application context
732
- * @returns The builder with context configured
733
- *
734
- * @example
735
- * ```typescript
736
- * .withContext(() => ({
737
- * db: prisma,
738
- * mailer: mailerService,
739
- * cache: redis,
740
- * }))
741
- * ```
742
- */
743
- withContext(contextFn) {
744
- return new _IgniterJobsBuilder({
745
- ...this.state,
746
- context: contextFn
747
- });
344
+ };
345
+ function createInternalState(config) {
346
+ return {
347
+ config,
348
+ adapter: config.adapter,
349
+ registered: false
350
+ };
351
+ }
352
+ function createRuntime(internal, boundScope) {
353
+ if (!internal.registered) {
354
+ registerAll(internal.config, internal.adapter);
355
+ internal.registered = true;
748
356
  }
749
- /**
750
- * Add a queue to the jobs instance.
751
- *
752
- * @param queue - Queue configuration from IgniterQueue.create().build()
753
- * @returns The builder with the queue added
754
- *
755
- * @example
756
- * ```typescript
757
- * const emailQueue = IgniterQueue.create<AppContext>('email')
758
- * .addJob('sendWelcome', { ... })
759
- * .build()
760
- *
761
- * const jobs = IgniterJobs.create<AppContext>()
762
- * .addQueue(emailQueue)
763
- * .build()
764
- * ```
765
- */
766
- addQueue(queue) {
767
- const queueName = queue.name;
768
- if (queueName in this.state.queues) {
357
+ const baseChannel = IgniterJobsPrefix.buildEventsChannel({
358
+ service: internal.config.service,
359
+ environment: internal.config.environment
360
+ });
361
+ const scopeChannel = boundScope ? IgniterJobsPrefix.buildEventsChannel({
362
+ service: internal.config.service,
363
+ environment: internal.config.environment,
364
+ scope: { type: boundScope.type, id: boundScope.id }
365
+ }) : void 0;
366
+ const runtime = {
367
+ config: internal.config,
368
+ async subscribe(handler) {
369
+ const channel = boundScope ? scopeChannel : baseChannel;
370
+ return internal.adapter.subscribeEvent(channel, async (payload) => {
371
+ await handler(payload);
372
+ });
373
+ },
374
+ async search(target, filter) {
375
+ switch (target) {
376
+ case "jobs":
377
+ return internal.adapter.searchJobs(filter);
378
+ case "queues":
379
+ return internal.adapter.searchQueues(filter);
380
+ case "workers":
381
+ return internal.adapter.searchWorkers(filter);
382
+ default:
383
+ return [];
384
+ }
385
+ },
386
+ async shutdown() {
387
+ await internal.adapter.shutdown();
388
+ },
389
+ worker: {
390
+ create: () => new IgniterWorkerBuilder({
391
+ adapter: internal.adapter,
392
+ allowedQueues: Object.keys(internal.config.queues)
393
+ })
394
+ }
395
+ };
396
+ if (internal.config.scopeDefinition) {
397
+ runtime.scope = (type, id, tags) => {
398
+ const scope = { type, id, tags };
399
+ return createRuntime(internal, scope);
400
+ };
401
+ }
402
+ for (const [queueName, queueConfig] of Object.entries(
403
+ internal.config.queues
404
+ )) {
405
+ runtime[queueName] = createQueueAccessor({
406
+ internal,
407
+ boundScope,
408
+ queueName,
409
+ queueConfig
410
+ });
411
+ }
412
+ return runtime;
413
+ }
414
+ function registerAll(config, adapter) {
415
+ for (const [queueName, queue] of Object.entries(
416
+ config.queues
417
+ )) {
418
+ for (const [jobName, def] of Object.entries(
419
+ queue.jobs
420
+ )) {
421
+ adapter.registerJob(
422
+ queueName,
423
+ jobName,
424
+ wrapJobDefinition({
425
+ config,
426
+ adapter,
427
+ queueName,
428
+ jobName,
429
+ definition: def
430
+ })
431
+ );
432
+ }
433
+ for (const [cronName, cron] of Object.entries(
434
+ queue.crons
435
+ )) {
436
+ adapter.registerCron(
437
+ queueName,
438
+ cronName,
439
+ wrapCronDefinition({
440
+ config,
441
+ adapter,
442
+ queueName,
443
+ cronName,
444
+ definition: cron
445
+ })
446
+ );
447
+ }
448
+ }
449
+ }
450
+ function wrapCronDefinition(params) {
451
+ const { config, adapter, queueName, cronName, definition } = params;
452
+ const buildExecutionContext = async (ctx) => {
453
+ const realContext = await config.contextFactory();
454
+ const scope = ctx.scope ?? IgniterJobsScopeUtils.extractScopeFromMetadata(
455
+ ctx.job?.metadata
456
+ );
457
+ return {
458
+ ...ctx,
459
+ context: realContext,
460
+ job: { ...ctx.job, name: cronName, queue: queueName },
461
+ scope
462
+ };
463
+ };
464
+ const publishLifecycle = async (event, ctx, data) => {
465
+ const scope = ctx.scope ?? IgniterJobsScopeUtils.extractScopeFromMetadata(
466
+ ctx.job?.metadata
467
+ );
468
+ await IgniterJobsEventsUtils.publishJobsEvent({
469
+ adapter,
470
+ service: config.service,
471
+ environment: config.environment,
472
+ scope,
473
+ event: {
474
+ type: IgniterJobsEventsUtils.buildJobEventType(
475
+ queueName,
476
+ cronName,
477
+ event
478
+ ),
479
+ data,
480
+ timestamp: /* @__PURE__ */ new Date(),
481
+ scope
482
+ }
483
+ });
484
+ };
485
+ return {
486
+ ...definition,
487
+ handler: async (ctx) => {
488
+ const enhanced = await buildExecutionContext(ctx);
489
+ await publishLifecycle("started", enhanced, {
490
+ jobId: enhanced.job?.id,
491
+ jobName: cronName,
492
+ queue: queueName,
493
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
494
+ });
495
+ try {
496
+ const result = await definition.handler(enhanced);
497
+ await publishLifecycle("completed", enhanced, {
498
+ jobId: enhanced.job?.id,
499
+ jobName: cronName,
500
+ queue: queueName,
501
+ result,
502
+ completedAt: (/* @__PURE__ */ new Date()).toISOString()
503
+ });
504
+ return result;
505
+ } catch (error) {
506
+ await publishLifecycle("failed", enhanced, {
507
+ jobId: enhanced.job?.id,
508
+ jobName: cronName,
509
+ queue: queueName,
510
+ error: { message: error?.message ?? String(error) },
511
+ failedAt: (/* @__PURE__ */ new Date()).toISOString()
512
+ });
513
+ throw error;
514
+ }
515
+ }
516
+ };
517
+ }
518
+ function wrapJobDefinition(params) {
519
+ const { config, queueName, jobName, definition } = params;
520
+ const buildExecutionContext = async (ctx) => {
521
+ const realContext = await config.contextFactory();
522
+ const scope = ctx.scope ?? IgniterJobsScopeUtils.extractScopeFromMetadata(ctx.job.metadata);
523
+ return {
524
+ ...ctx,
525
+ context: realContext,
526
+ job: { ...ctx.job, name: jobName, queue: queueName },
527
+ scope
528
+ };
529
+ };
530
+ const publishLifecycle = async (event, ctx, data) => {
531
+ const scope = ctx.scope ?? IgniterJobsScopeUtils.extractScopeFromMetadata(
532
+ ctx.job?.metadata
533
+ );
534
+ await IgniterJobsEventsUtils.publishJobsEvent({
535
+ adapter: params.adapter,
536
+ service: config.service,
537
+ environment: config.environment,
538
+ scope,
539
+ event: {
540
+ type: IgniterJobsEventsUtils.buildJobEventType(
541
+ queueName,
542
+ jobName,
543
+ event
544
+ ),
545
+ data,
546
+ timestamp: /* @__PURE__ */ new Date(),
547
+ scope
548
+ }
549
+ });
550
+ };
551
+ return {
552
+ ...definition,
553
+ handler: async (ctx) => {
554
+ const enhanced = await buildExecutionContext(ctx);
555
+ if (definition.input) {
556
+ const validated = await IgniterJobsValidationUtils.validateInput(
557
+ definition.input,
558
+ enhanced.input
559
+ );
560
+ enhanced.input = validated;
561
+ }
562
+ return definition.handler(enhanced);
563
+ },
564
+ onStart: async (ctx) => {
565
+ const enhanced = await buildExecutionContext(ctx);
566
+ await publishLifecycle("started", enhanced, {
567
+ jobId: enhanced.job.id,
568
+ jobName,
569
+ queue: queueName,
570
+ attemptsMade: enhanced.job.attemptsMade,
571
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
572
+ });
573
+ IgniterJobsTelemetryUtils.emitTelemetry(
574
+ config.telemetry,
575
+ "igniter.jobs.job.started",
576
+ {
577
+ "ctx.job.id": enhanced.job.id,
578
+ "ctx.job.name": jobName,
579
+ "ctx.job.queue": queueName,
580
+ "ctx.job.attempt": enhanced.job.attemptsMade,
581
+ "ctx.job.maxAttempts": definition.attempts ?? 3
582
+ }
583
+ );
584
+ await definition.onStart?.(enhanced);
585
+ },
586
+ onSuccess: async (ctx) => {
587
+ const enhanced = await buildExecutionContext(ctx);
588
+ const duration = ctx.duration ?? ctx.executionTime ?? 0;
589
+ await publishLifecycle(
590
+ "completed",
591
+ { ...enhanced},
592
+ {
593
+ jobId: enhanced.job.id,
594
+ jobName,
595
+ queue: queueName,
596
+ result: ctx.result,
597
+ duration,
598
+ completedAt: (/* @__PURE__ */ new Date()).toISOString()
599
+ }
600
+ );
601
+ IgniterJobsTelemetryUtils.emitTelemetry(
602
+ config.telemetry,
603
+ "igniter.jobs.job.completed",
604
+ {
605
+ "ctx.job.id": enhanced.job.id,
606
+ "ctx.job.name": jobName,
607
+ "ctx.job.queue": queueName,
608
+ "ctx.job.duration": typeof duration === "number" ? duration : 0
609
+ }
610
+ );
611
+ await definition.onSuccess?.(enhanced);
612
+ },
613
+ onFailure: async (ctx) => {
614
+ const enhanced = await buildExecutionContext(ctx);
615
+ const duration = ctx.duration ?? ctx.executionTime ?? 0;
616
+ const isFinalAttempt = Boolean(ctx.isFinalAttempt);
617
+ const errorMessage = ctx.error?.message ?? String(ctx.error);
618
+ const errorCode = ctx.error?.code;
619
+ const maxAttempts = definition.attempts ?? 3;
620
+ await publishLifecycle(
621
+ "failed",
622
+ { ...enhanced},
623
+ {
624
+ jobId: enhanced.job.id,
625
+ jobName,
626
+ queue: queueName,
627
+ error: { message: errorMessage },
628
+ attemptsMade: enhanced.job.attemptsMade,
629
+ isFinalAttempt,
630
+ duration,
631
+ failedAt: (/* @__PURE__ */ new Date()).toISOString()
632
+ }
633
+ );
634
+ IgniterJobsTelemetryUtils.emitTelemetry(
635
+ config.telemetry,
636
+ "igniter.jobs.job.failed",
637
+ {
638
+ "ctx.job.id": enhanced.job.id,
639
+ "ctx.job.name": jobName,
640
+ "ctx.job.queue": queueName,
641
+ "ctx.job.error.message": errorMessage,
642
+ "ctx.job.error.code": errorCode ?? null,
643
+ "ctx.job.attempt": enhanced.job.attemptsMade,
644
+ "ctx.job.maxAttempts": maxAttempts,
645
+ "ctx.job.isFinalAttempt": isFinalAttempt
646
+ },
647
+ "error"
648
+ );
649
+ await definition.onFailure?.(enhanced);
650
+ },
651
+ onProgress: definition.onProgress ? async (ctx) => {
652
+ const enhanced = await buildExecutionContext(ctx);
653
+ const progress = ctx.progress ?? 0;
654
+ const message = ctx.message;
655
+ await publishLifecycle("progress", enhanced, {
656
+ jobId: enhanced.job.id,
657
+ jobName,
658
+ queue: queueName,
659
+ progress,
660
+ message,
661
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
662
+ });
663
+ IgniterJobsTelemetryUtils.emitTelemetry(
664
+ config.telemetry,
665
+ "igniter.jobs.job.progress",
666
+ {
667
+ "ctx.job.id": enhanced.job.id,
668
+ "ctx.job.name": jobName,
669
+ "ctx.job.queue": queueName,
670
+ "ctx.job.progress": typeof progress === "number" ? progress : 0,
671
+ "ctx.job.progress.message": message ?? null
672
+ }
673
+ );
674
+ await definition.onProgress?.(enhanced);
675
+ } : void 0
676
+ };
677
+ }
678
+ function createQueueAccessor(params) {
679
+ const { internal, boundScope, queueName, queueConfig } = params;
680
+ const queueAccessor = {
681
+ async list(filter) {
682
+ return internal.adapter.queues.getJobs(queueName, filter);
683
+ },
684
+ get() {
685
+ return {
686
+ retrieve: () => internal.adapter.getQueueInfo(queueName),
687
+ pause: () => internal.adapter.pauseQueue(queueName),
688
+ resume: () => internal.adapter.resumeQueue(queueName),
689
+ drain: () => internal.adapter.drainQueue(queueName),
690
+ clean: (options) => internal.adapter.cleanQueue(queueName, options),
691
+ obliterate: (options) => internal.adapter.obliterateQueue(queueName, options),
692
+ retryAll: () => internal.adapter.retryAllInQueue(queueName)
693
+ };
694
+ },
695
+ async subscribe(handler) {
696
+ const channel = boundScope ? IgniterJobsPrefix.buildEventsChannel({
697
+ service: internal.config.service,
698
+ environment: internal.config.environment,
699
+ scope: { type: boundScope.type, id: boundScope.id }
700
+ }) : IgniterJobsPrefix.buildEventsChannel({
701
+ service: internal.config.service,
702
+ environment: internal.config.environment
703
+ });
704
+ return internal.adapter.subscribeEvent(channel, async (event) => {
705
+ const typed = event;
706
+ if (typeof typed?.type === "string" && typed.type.startsWith(`${queueName}:`)) {
707
+ await handler(typed);
708
+ }
709
+ });
710
+ },
711
+ jobs: {}
712
+ };
713
+ for (const jobName of Object.keys(queueConfig.jobs)) {
714
+ queueAccessor.jobs[jobName] = createJobAccessor({
715
+ internal,
716
+ boundScope,
717
+ queueName,
718
+ jobName
719
+ });
720
+ queueAccessor[jobName] = queueAccessor.jobs[jobName];
721
+ }
722
+ return queueAccessor;
723
+ }
724
+ function createJobAccessor(params) {
725
+ const { internal, boundScope, queueName, jobName } = params;
726
+ const resolveScope = (paramsScope) => {
727
+ if (!internal.config.scopeDefinition) return void 0;
728
+ const required = Object.values(
729
+ internal.config.scopeDefinition
730
+ )[0]?.required ?? false;
731
+ const effective = boundScope ?? paramsScope;
732
+ if (required && !effective) {
769
733
  throw new IgniterJobsError({
770
- code: "JOBS_QUEUE_ALREADY_EXISTS",
771
- message: `Queue "${queueName}" has already been added`,
772
- statusCode: 400,
773
- details: { queue: queueName }
734
+ code: "JOBS_CONFIGURATION_INVALID",
735
+ message: "Scope is required for this jobs instance."
736
+ });
737
+ }
738
+ if (boundScope && paramsScope) {
739
+ if (boundScope.type !== paramsScope.type || boundScope.id !== paramsScope.id) {
740
+ throw new IgniterJobsError({
741
+ code: "JOBS_CONFIGURATION_INVALID",
742
+ message: "Cannot override scope on a scoped jobs instance."
743
+ });
744
+ }
745
+ }
746
+ return effective;
747
+ };
748
+ const publish = async (event) => {
749
+ await IgniterJobsEventsUtils.publishJobsEvent({
750
+ adapter: internal.adapter,
751
+ service: internal.config.service,
752
+ environment: internal.config.environment,
753
+ scope: event.scope,
754
+ event
755
+ });
756
+ };
757
+ const getDefinition = () => {
758
+ const q = internal.config.queues[queueName];
759
+ return q?.jobs?.[jobName];
760
+ };
761
+ return {
762
+ async dispatch(params2) {
763
+ const definition = getDefinition();
764
+ if (definition?.input) {
765
+ await IgniterJobsValidationUtils.validateInput(
766
+ definition.input,
767
+ params2.input
768
+ );
769
+ }
770
+ const scope = resolveScope(params2.scope);
771
+ const metadata = IgniterJobsScopeUtils.mergeMetadataWithScope(
772
+ params2.metadata,
773
+ scope
774
+ );
775
+ const jobId = await internal.adapter.dispatch({
776
+ queue: queueName,
777
+ jobName,
778
+ ...params2,
779
+ scope,
780
+ metadata
781
+ });
782
+ await publish({
783
+ type: IgniterJobsEventsUtils.buildJobEventType(
784
+ queueName,
785
+ jobName,
786
+ "enqueued"
787
+ ),
788
+ data: { jobId, queue: queueName, jobName },
789
+ timestamp: /* @__PURE__ */ new Date(),
790
+ scope
791
+ });
792
+ IgniterJobsTelemetryUtils.emitTelemetry(
793
+ internal.config.telemetry,
794
+ "igniter.jobs.job.enqueued",
795
+ {
796
+ "ctx.job.id": jobId,
797
+ "ctx.job.name": jobName,
798
+ "ctx.job.queue": queueName,
799
+ "ctx.job.priority": params2.priority ?? null,
800
+ "ctx.job.delay": params2.delay ?? null
801
+ }
802
+ );
803
+ return jobId;
804
+ },
805
+ async schedule(params2) {
806
+ const definition = getDefinition();
807
+ if (definition?.input) {
808
+ await IgniterJobsValidationUtils.validateInput(
809
+ definition.input,
810
+ params2.input
811
+ );
812
+ }
813
+ const scope = resolveScope(params2.scope);
814
+ const metadata = IgniterJobsScopeUtils.mergeMetadataWithScope(
815
+ params2.metadata,
816
+ scope
817
+ );
818
+ const jobId = await internal.adapter.schedule({
819
+ queue: queueName,
820
+ jobName,
821
+ ...params2,
822
+ scope,
823
+ metadata
824
+ });
825
+ await publish({
826
+ type: IgniterJobsEventsUtils.buildJobEventType(
827
+ queueName,
828
+ jobName,
829
+ "scheduled"
830
+ ),
831
+ data: { jobId, queue: queueName, jobName },
832
+ timestamp: /* @__PURE__ */ new Date(),
833
+ scope
834
+ });
835
+ IgniterJobsTelemetryUtils.emitTelemetry(
836
+ internal.config.telemetry,
837
+ "igniter.jobs.job.scheduled",
838
+ {
839
+ "ctx.job.id": jobId,
840
+ "ctx.job.name": jobName,
841
+ "ctx.job.queue": queueName,
842
+ "ctx.job.scheduledAt": params2.runAt?.toISOString?.() ?? null,
843
+ "ctx.job.cron": params2.cron ?? null
844
+ }
845
+ );
846
+ return jobId;
847
+ },
848
+ get(id) {
849
+ return {
850
+ retrieve: () => internal.adapter.getJob(id, queueName),
851
+ retry: () => internal.adapter.retryJob(id, queueName),
852
+ remove: () => internal.adapter.removeJob(id, queueName),
853
+ promote: () => internal.adapter.promoteJob(id, queueName),
854
+ move: (state, reason) => {
855
+ if (state !== "failed") return Promise.resolve();
856
+ return internal.adapter.moveJobToFailed(id, reason, queueName);
857
+ },
858
+ state: () => internal.adapter.getJobState(id, queueName),
859
+ progress: () => internal.adapter.getJobProgress(id, queueName),
860
+ logs: () => internal.adapter.getJobLogs(id, queueName)
861
+ };
862
+ },
863
+ many(ids) {
864
+ return {
865
+ retry: () => internal.adapter.retryManyJobs(ids, queueName),
866
+ remove: () => internal.adapter.removeManyJobs(ids, queueName)
867
+ };
868
+ },
869
+ pause: () => internal.adapter.pauseJobType(queueName, jobName),
870
+ resume: () => internal.adapter.resumeJobType(queueName, jobName),
871
+ async subscribe(handler) {
872
+ const channel = boundScope ? IgniterJobsPrefix.buildEventsChannel({
873
+ service: internal.config.service,
874
+ environment: internal.config.environment,
875
+ scope: { type: boundScope.type, id: boundScope.id }
876
+ }) : IgniterJobsPrefix.buildEventsChannel({
877
+ service: internal.config.service,
878
+ environment: internal.config.environment
879
+ });
880
+ return internal.adapter.subscribeEvent(channel, async (event) => {
881
+ const typed = event;
882
+ if (typeof typed?.type === "string" && typed.type.startsWith(`${queueName}:${jobName}:`)) {
883
+ await handler(typed);
884
+ }
774
885
  });
775
886
  }
887
+ };
888
+ }
889
+
890
+ // src/builders/igniter-jobs.builder.ts
891
+ var IgniterJobsBuilder = class _IgniterJobsBuilder {
892
+ constructor(state) {
893
+ this.state = {
894
+ queues: state?.queues ?? {},
895
+ ...state
896
+ };
897
+ }
898
+ /**
899
+ * Creates the initial builder with no configuration.
900
+ */
901
+ static create() {
902
+ return new _IgniterJobsBuilder({ queues: {} });
903
+ }
904
+ /**
905
+ * Returns a new builder with updated state while preserving generics.
906
+ */
907
+ clone(patch) {
776
908
  return new _IgniterJobsBuilder({
777
909
  ...this.state,
778
- queues: {
779
- ...this.state.queues,
780
- [queueName]: queue
781
- }
910
+ ...patch,
911
+ queues: patch.queues ?? this.state.queues,
912
+ scope: patch.scope ?? this.state.scope
782
913
  });
783
914
  }
784
915
  /**
785
- * Add a scope for multi-tenancy (only one scope allowed).
786
- * Scopes are used to isolate jobs by organization, tenant, etc.
916
+ * Attaches the jobs adapter.
787
917
  *
788
- * @param key - Scope key (e.g., 'organization', 'tenant')
789
- * @param options - Optional scope configuration
790
- * @returns The builder with scope configured
918
+ * @param adapter - Backend adapter implementation (BullMQ, memory, etc.).
919
+ */
920
+ withAdapter(adapter) {
921
+ return this.clone({ adapter });
922
+ }
923
+ /**
924
+ * Sets the service identifier for telemetry and metrics.
791
925
  *
792
- * @example
793
- * ```typescript
794
- * .addScope('organization', { required: true })
795
- * ```
926
+ * @param service - Service name (e.g., "my-api").
927
+ */
928
+ withService(service) {
929
+ return this.clone({ service });
930
+ }
931
+ /**
932
+ * Sets the environment name (e.g., development, staging, production).
933
+ */
934
+ withEnvironment(environment) {
935
+ return this.clone({ environment });
936
+ }
937
+ /**
938
+ * Provides a context factory used when executing jobs.
939
+ */
940
+ withContext(factory) {
941
+ return this.clone({ contextFactory: factory });
942
+ }
943
+ /**
944
+ * Adds a scope definition (single scope supported).
796
945
  */
797
- addScope(key, options) {
946
+ addScope(name, options) {
798
947
  if (this.state.scope) {
799
948
  throw new IgniterJobsError({
800
949
  code: "JOBS_SCOPE_ALREADY_DEFINED",
801
- message: `Scope "${this.state.scope.key}" is already defined. Only one scope is allowed.`,
802
- statusCode: 400,
803
- details: { existingScope: this.state.scope.key, newScope: key }
950
+ message: "Only one scope can be defined for IgniterJobs."
804
951
  });
805
952
  }
806
- return new _IgniterJobsBuilder({
807
- ...this.state,
808
- scope: { key, options }
809
- });
953
+ return this.clone({ scope: { name, options } });
810
954
  }
811
955
  /**
812
- * Add an actor for auditing (only one actor type allowed).
813
- * Actors are used to track who initiated jobs.
814
- *
815
- * @param key - Actor key (e.g., 'user', 'system')
816
- * @param options - Optional actor configuration
817
- * @returns The builder with actor configured
818
- *
819
- * @example
820
- * ```typescript
821
- * .addActor('user', { description: 'The user who initiated the job' })
822
- * ```
956
+ * Registers a queue definition on the builder.
823
957
  */
824
- addActor(key, options) {
825
- if (this.state.actor) {
958
+ addQueue(queue) {
959
+ if (this.state.queues[queue.name]) {
826
960
  throw new IgniterJobsError({
827
- code: "JOBS_ACTOR_ALREADY_DEFINED",
828
- message: `Actor "${this.state.actor.key}" is already defined. Only one actor is allowed.`,
829
- statusCode: 400,
830
- details: { existingActor: this.state.actor.key, newActor: key }
961
+ code: "JOBS_QUEUE_DUPLICATE",
962
+ message: `Queue "${queue.name}" is already registered.`
831
963
  });
832
964
  }
833
- return new _IgniterJobsBuilder({
834
- ...this.state,
835
- actor: { key, options }
836
- });
965
+ const nextQueues = {
966
+ ...this.state.queues,
967
+ [queue.name]: queue
968
+ };
969
+ return this.clone({ queues: nextQueues });
837
970
  }
838
971
  /**
839
- * Configure telemetry for observability (optional).
840
- *
841
- * @param telemetry - IgniterTelemetry instance
842
- * @returns The builder with telemetry configured
843
- *
844
- * @example
845
- * ```typescript
846
- * .withTelemetry(telemetry)
847
- * ```
972
+ * Applies default job options to all queues.
848
973
  */
849
- withTelemetry(telemetry) {
850
- return new _IgniterJobsBuilder({
851
- ...this.state,
852
- telemetry
853
- });
974
+ withQueueDefaults(defaults) {
975
+ return this.clone({ queueDefaults: defaults });
854
976
  }
855
977
  /**
856
- * Configure a custom logger (optional).
857
- *
858
- * @param logger - Logger instance
859
- * @returns The builder with logger configured
860
- *
861
- * @example
862
- * ```typescript
863
- * .withLogger(customLogger)
864
- * ```
978
+ * Applies default worker options.
865
979
  */
866
- withLogger(logger) {
867
- return new _IgniterJobsBuilder({
868
- ...this.state,
869
- logger
870
- });
980
+ withWorkerDefaults(defaults) {
981
+ return this.clone({ workerDefaults: defaults });
871
982
  }
872
983
  /**
873
- * Configure default job options applied to all jobs.
874
- *
875
- * @param defaults - Default job configuration
876
- * @returns The builder with defaults configured
877
- *
878
- * @example
879
- * ```typescript
880
- * .withDefaults({
881
- * retry: { attempts: 3, backoff: { type: 'exponential', delay: 1000 } },
882
- * timeout: 30000,
883
- * removeOnComplete: { count: 1000 },
884
- * })
885
- * ```
984
+ * Configures automatic worker startup.
886
985
  */
887
- withDefaults(defaults) {
888
- return new _IgniterJobsBuilder({
889
- ...this.state,
890
- defaults
891
- });
986
+ withAutoStartWorker(config) {
987
+ return this.clone({ autoStartWorker: config });
892
988
  }
893
989
  /**
894
- * Build the IgniterJobs instance.
895
- * Validates configuration and returns a fully typed jobs instance.
896
- *
897
- * @returns The IgniterJobs instance with proxy for typed access
898
- *
899
- * @throws {IgniterJobsError} If adapter is not configured
900
- * @throws {IgniterJobsError} If context is not configured
901
- * @throws {IgniterJobsError} If no queues are defined
902
- *
903
- * @example
904
- * ```typescript
905
- * const jobs = IgniterJobs.create<AppContext>()
906
- * .withAdapter(adapter)
907
- * .withContext(() => context)
908
- * .addQueue(emailQueue)
909
- * .build()
910
- *
911
- * // Now use with full type safety
912
- * await jobs.email.sendWelcome.dispatch({ userId: '123' })
913
- * ```
990
+ * Attaches telemetry support.
991
+ */
992
+ withTelemetry(telemetry) {
993
+ return this.clone({ telemetry });
994
+ }
995
+ /**
996
+ * Attaches a custom logger.
997
+ */
998
+ withLogger(logger) {
999
+ return this.clone({ logger });
1000
+ }
1001
+ /**
1002
+ * Finalizes the configuration and returns the runtime instance.
914
1003
  */
915
1004
  build() {
916
1005
  if (!this.state.adapter) {
917
1006
  throw new IgniterJobsError({
918
1007
  code: "JOBS_ADAPTER_REQUIRED",
919
- message: "Adapter is required. Use .withAdapter() to configure.",
920
- statusCode: 400
1008
+ message: "Jobs adapter is required. Call withAdapter() before build()."
921
1009
  });
922
1010
  }
923
- if (!this.state.context) {
1011
+ if (!this.state.service) {
924
1012
  throw new IgniterJobsError({
925
- code: "JOBS_CONTEXT_REQUIRED",
926
- message: "Context provider is required. Use .withContext() to configure.",
927
- statusCode: 400
1013
+ code: "JOBS_SERVICE_REQUIRED",
1014
+ message: "Service name is required. Call withService() before build()."
928
1015
  });
929
1016
  }
930
- if (Object.keys(this.state.queues).length === 0) {
1017
+ if (!this.state.environment) {
931
1018
  throw new IgniterJobsError({
932
- code: "JOBS_QUEUE_REQUIRED",
933
- message: "At least one queue is required. Use .addQueue() to add queues.",
934
- statusCode: 400
1019
+ code: "JOBS_CONFIGURATION_INVALID",
1020
+ message: "Environment is required. Call withEnvironment() before build()."
935
1021
  });
936
1022
  }
937
- return new IgniterJobsRuntime(
938
- this.state
939
- );
1023
+ if (!this.state.contextFactory) {
1024
+ throw new IgniterJobsError({
1025
+ code: "JOBS_CONTEXT_REQUIRED",
1026
+ message: "Context factory is required. Call withContext() before build()."
1027
+ });
1028
+ }
1029
+ const config = {
1030
+ adapter: this.state.adapter,
1031
+ service: this.state.service,
1032
+ environment: this.state.environment,
1033
+ contextFactory: this.state.contextFactory,
1034
+ queues: this.state.queues,
1035
+ scopeDefinition: this.state.scope ? {
1036
+ [this.state.scope.name]: this.state.scope.options ?? {}
1037
+ } : void 0,
1038
+ queueDefaults: this.state.queueDefaults,
1039
+ workerDefaults: this.state.workerDefaults,
1040
+ autoStartWorker: this.state.autoStartWorker,
1041
+ logger: this.state.logger,
1042
+ telemetry: this.state.telemetry
1043
+ };
1044
+ return IgniterJobs.fromConfig(config);
940
1045
  }
941
1046
  };
942
- var IgniterJobs = {
943
- create: IgniterJobsBuilder.create
944
- };
945
1047
 
946
1048
  // src/builders/igniter-queue.builder.ts
947
- function validateQueueName(name) {
948
- if (!name || typeof name !== "string") {
949
- throw new IgniterJobsError({
950
- code: "JOBS_QUEUE_NAME_INVALID",
951
- message: "Queue name must be a non-empty string",
952
- statusCode: 400
953
- });
954
- }
955
- if (!/^[a-z][a-z0-9-]*$/.test(name)) {
956
- throw new IgniterJobsError({
957
- code: "JOBS_QUEUE_NAME_INVALID",
958
- message: `Queue name "${name}" must start with a lowercase letter and contain only lowercase letters, numbers, and hyphens`,
959
- statusCode: 400,
960
- details: { name }
961
- });
962
- }
963
- }
964
- function validateJobName(name) {
965
- if (!name || typeof name !== "string") {
966
- throw new IgniterJobsError({
967
- code: "JOBS_JOB_NAME_INVALID",
968
- message: "Job name must be a non-empty string",
969
- statusCode: 400
970
- });
971
- }
972
- if (!/^[a-zA-Z][a-zA-Z0-9]*$/.test(name)) {
973
- throw new IgniterJobsError({
974
- code: "JOBS_JOB_NAME_INVALID",
975
- message: `Job name "${name}" must start with a letter and contain only letters and numbers (camelCase recommended)`,
976
- statusCode: 400,
977
- details: { name }
978
- });
979
- }
980
- }
981
1049
  var IgniterQueueBuilder = class _IgniterQueueBuilder {
982
1050
  constructor(state) {
983
1051
  this.state = state;
984
1052
  }
985
1053
  /**
986
- * Create a new queue builder.
987
- *
988
- * @param name - Queue name (kebab-case, e.g., 'email', 'payment-processing')
989
- * @returns A new IgniterQueueBuilder instance
990
- *
991
- * @example
992
- * ```typescript
993
- * const emailQueue = IgniterQueue.create<AppContext>('email')
994
- * ```
1054
+ * Creates a new queue builder for the given queue name.
995
1055
  */
996
1056
  static create(name) {
997
- validateQueueName(name);
1057
+ if (!name || typeof name !== "string") {
1058
+ throw new IgniterJobsError({
1059
+ code: "JOBS_CONFIGURATION_INVALID",
1060
+ message: "Queue name must be a non-empty string."
1061
+ });
1062
+ }
998
1063
  return new _IgniterQueueBuilder({
999
1064
  name,
1000
1065
  jobs: {},
@@ -1002,127 +1067,1196 @@ var IgniterQueueBuilder = class _IgniterQueueBuilder {
1002
1067
  });
1003
1068
  }
1004
1069
  /**
1005
- * Add a job definition to the queue.
1070
+ * Re-types this builder with the application context type.
1006
1071
  *
1007
- * @param name - Job name (camelCase, e.g., 'sendWelcome', 'processPayment')
1008
- * @param definition - Job definition with input schema, handler, retry config, etc.
1009
- * @returns The builder with the new job added
1072
+ * This is a type-level helper; it does not mutate runtime state.
1010
1073
  *
1011
1074
  * @example
1012
1075
  * ```typescript
1013
- * .addJob('sendWelcome', {
1014
- * input: z.object({
1015
- * userId: z.string(),
1016
- * email: z.string().email(),
1017
- * }),
1018
- * retry: { attempts: 3, backoff: { type: 'exponential', delay: 1000 } },
1019
- * handler: async (ctx) => {
1020
- * await ctx.context.mailer.send({
1021
- * to: ctx.input.email,
1022
- * template: 'welcome',
1023
- * })
1024
- * },
1025
- * })
1076
+ * const queue = IgniterQueue.create('email')
1077
+ * .withContext<AppContext>()
1078
+ * .addJob('send', { handler: async ({ context }) => context.mailer.send() })
1026
1079
  * ```
1027
1080
  */
1028
- addJob(name, definition) {
1029
- validateJobName(name);
1030
- if (name in this.state.jobs) {
1031
- throw new IgniterJobsError({
1032
- code: "JOBS_JOB_ALREADY_EXISTS",
1033
- message: `Job "${name}" already exists in queue "${this.state.name}"`,
1034
- statusCode: 400,
1035
- details: { queue: this.state.name, job: name }
1036
- });
1037
- }
1038
- if (!definition.handler || typeof definition.handler !== "function") {
1039
- throw new IgniterJobsError({
1040
- code: "JOBS_JOB_HANDLER_REQUIRED",
1041
- message: `Job "${name}" must have a handler function`,
1042
- statusCode: 400,
1043
- details: { queue: this.state.name, job: name }
1044
- });
1045
- }
1081
+ withContext() {
1082
+ return this;
1083
+ }
1084
+ clone(patch) {
1046
1085
  return new _IgniterQueueBuilder({
1047
1086
  ...this.state,
1048
- jobs: {
1049
- ...this.state.jobs,
1050
- [name]: definition
1051
- }
1087
+ ...patch
1052
1088
  });
1053
1089
  }
1054
1090
  /**
1055
- * Add a cron job definition to the queue.
1056
- *
1057
- * @param name - Cron job name (camelCase)
1058
- * @param definition - Cron definition with expression, handler, etc.
1059
- * @returns The builder with the new cron added
1091
+ * Registers a job on the queue.
1060
1092
  *
1061
- * @example
1062
- * ```typescript
1063
- * .addCron('cleanupExpired', {
1064
- * expression: '0 0 * * *', // Every day at midnight
1065
- * timezone: 'America/New_York',
1066
- * handler: async (ctx) => {
1067
- * await ctx.context.db.cleanup.expiredSessions()
1068
- * },
1069
- * })
1070
- * ```
1093
+ * @param jobName - Unique name of the job inside the queue.
1094
+ * @param definition - Job definition (handler, schemas, options, hooks).
1071
1095
  */
1072
- addCron(name, definition) {
1073
- validateJobName(name);
1074
- if (name in this.state.crons) {
1096
+ addJob(jobName, definition) {
1097
+ if (!jobName || typeof jobName !== "string") {
1098
+ throw new IgniterJobsError({
1099
+ code: "JOBS_INVALID_DEFINITION",
1100
+ message: "Job name must be a non-empty string."
1101
+ });
1102
+ }
1103
+ if (this.state.jobs[jobName]) {
1104
+ throw new IgniterJobsError({
1105
+ code: "JOBS_DUPLICATE_JOB",
1106
+ message: `Job "${jobName}" is already registered in queue "${this.state.name}".`
1107
+ });
1108
+ }
1109
+ if (this.state.crons[jobName]) {
1075
1110
  throw new IgniterJobsError({
1076
- code: "JOBS_CRON_ALREADY_EXISTS",
1077
- message: `Cron "${name}" already exists in queue "${this.state.name}"`,
1078
- statusCode: 400,
1079
- details: { queue: this.state.name, cron: name }
1111
+ code: "JOBS_DUPLICATE_JOB",
1112
+ message: `Job "${jobName}" conflicts with an existing cron in queue "${this.state.name}".`
1080
1113
  });
1081
1114
  }
1082
- if (!definition.expression || typeof definition.expression !== "string") {
1115
+ if (!definition || typeof definition !== "object") {
1083
1116
  throw new IgniterJobsError({
1084
- code: "JOBS_CRON_EXPRESSION_INVALID",
1085
- message: `Cron "${name}" must have a valid expression`,
1086
- statusCode: 400,
1087
- details: { queue: this.state.name, cron: name }
1117
+ code: "JOBS_INVALID_DEFINITION",
1118
+ message: `Job "${jobName}" definition must be an object.`
1088
1119
  });
1089
1120
  }
1090
1121
  if (!definition.handler || typeof definition.handler !== "function") {
1091
1122
  throw new IgniterJobsError({
1092
- code: "JOBS_CRON_HANDLER_REQUIRED",
1093
- message: `Cron "${name}" must have a handler function`,
1094
- statusCode: 400,
1095
- details: { queue: this.state.name, cron: name }
1123
+ code: "JOBS_HANDLER_REQUIRED",
1124
+ message: `Job "${jobName}" handler is required and must be a function.`
1096
1125
  });
1097
1126
  }
1098
- return new _IgniterQueueBuilder({
1099
- ...this.state,
1100
- crons: {
1101
- ...this.state.crons,
1102
- [name]: definition
1103
- }
1127
+ const nextJobs = {
1128
+ ...this.state.jobs,
1129
+ [jobName]: definition
1130
+ };
1131
+ return this.clone({
1132
+ jobs: nextJobs
1104
1133
  });
1105
1134
  }
1106
1135
  /**
1107
- * Build the queue configuration.
1136
+ * Registers a cron task on the queue.
1108
1137
  *
1109
- * @returns The queue configuration ready to be registered with IgniterJobs
1138
+ * @param cronName - Unique name of the cron task inside the queue.
1139
+ * @param definition - Cron definition (cron string, tz, handler, options).
1140
+ */
1141
+ addCron(cronName, definition) {
1142
+ if (!cronName || typeof cronName !== "string") {
1143
+ throw new IgniterJobsError({
1144
+ code: "JOBS_INVALID_CRON",
1145
+ message: "Cron name must be a non-empty string."
1146
+ });
1147
+ }
1148
+ if (this.state.crons[cronName]) {
1149
+ throw new IgniterJobsError({
1150
+ code: "JOBS_INVALID_CRON",
1151
+ message: `Cron "${cronName}" is already registered in queue "${this.state.name}".`
1152
+ });
1153
+ }
1154
+ if (this.state.jobs[cronName]) {
1155
+ throw new IgniterJobsError({
1156
+ code: "JOBS_INVALID_CRON",
1157
+ message: `Cron "${cronName}" conflicts with an existing job in queue "${this.state.name}".`
1158
+ });
1159
+ }
1160
+ if (!definition || typeof definition !== "object") {
1161
+ throw new IgniterJobsError({
1162
+ code: "JOBS_INVALID_CRON",
1163
+ message: `Cron "${cronName}" definition must be an object.`
1164
+ });
1165
+ }
1166
+ if (!definition.cron || typeof definition.cron !== "string") {
1167
+ throw new IgniterJobsError({
1168
+ code: "JOBS_INVALID_CRON",
1169
+ message: `Cron "${cronName}" must include a valid cron expression string.`
1170
+ });
1171
+ }
1172
+ if (!definition.handler || typeof definition.handler !== "function") {
1173
+ throw new IgniterJobsError({
1174
+ code: "JOBS_HANDLER_REQUIRED",
1175
+ message: `Cron "${cronName}" handler is required and must be a function.`
1176
+ });
1177
+ }
1178
+ const nextCrons = {
1179
+ ...this.state.crons,
1180
+ [cronName]: definition
1181
+ };
1182
+ return this.clone({
1183
+ crons: nextCrons
1184
+ });
1185
+ }
1186
+ /**
1187
+ * Finalizes the queue definition.
1188
+ */
1189
+ build() {
1190
+ return {
1191
+ name: this.state.name,
1192
+ jobs: this.state.jobs,
1193
+ crons: this.state.crons
1194
+ };
1195
+ }
1196
+ };
1197
+
1198
+ // src/core/igniter-queue.ts
1199
+ var IgniterQueue = class {
1200
+ /**
1201
+ * Creates a new queue builder for the given name.
1110
1202
  *
1111
1203
  * @example
1112
1204
  * ```typescript
1113
- * const emailQueue = IgniterQueue.create<AppContext>('email')
1114
- * .addJob('sendWelcome', { ... })
1205
+ * const queue = IgniterQueue.create('email')
1206
+ * .withContext<AppContext>()
1207
+ * .addJob('sendWelcome', { handler: async () => {} })
1115
1208
  * .build()
1116
1209
  * ```
1117
1210
  */
1118
- build() {
1119
- return { ...this.state };
1211
+ static create(name) {
1212
+ return IgniterQueueBuilder.create(name);
1213
+ }
1214
+ };
1215
+ function toDateArray(values) {
1216
+ if (!values) return void 0;
1217
+ return values.map((v) => v instanceof Date ? v : new Date(v));
1218
+ }
1219
+ var IgniterJobsBullMQAdapter = class _IgniterJobsBullMQAdapter {
1220
+ constructor(options) {
1221
+ this.subscribers = /* @__PURE__ */ new Map();
1222
+ this.coreAdapter = null;
1223
+ this.coreExecutor = null;
1224
+ this.executorDirty = true;
1225
+ this.jobsByQueue = /* @__PURE__ */ new Map();
1226
+ this.cronsByQueue = /* @__PURE__ */ new Map();
1227
+ this.redis = options.redis;
1228
+ this.publisher = this.redis;
1229
+ this.subscriber = this.redis.duplicate();
1230
+ this.client = { redis: this.redis };
1231
+ this.subscriber.on("message", (channel, message) => {
1232
+ const set = this.subscribers.get(channel);
1233
+ if (!set || set.size === 0) return;
1234
+ let payload = message;
1235
+ try {
1236
+ payload = JSON.parse(message);
1237
+ } catch {
1238
+ }
1239
+ for (const handler of set) handler(payload);
1240
+ });
1241
+ this.queues = {
1242
+ list: async () => this.listQueues(),
1243
+ get: async (name) => this.getQueueInfo(name),
1244
+ getJobCounts: async (name) => this.getQueueJobCounts(name),
1245
+ getJobs: async (name, filter) => {
1246
+ const full = this.toCoreQueueName(name);
1247
+ return this.core().queues.getJobs(full, filter);
1248
+ },
1249
+ pause: async (name) => this.pauseQueue(name),
1250
+ resume: async (name) => this.resumeQueue(name),
1251
+ isPaused: async (name) => {
1252
+ const full = this.toCoreQueueName(name);
1253
+ return this.core().queues.isPaused(full);
1254
+ },
1255
+ drain: async (name) => this.drainQueue(name),
1256
+ clean: async (name, options2) => this.cleanQueue(name, options2),
1257
+ obliterate: async (name, options2) => this.obliterateQueue(name, options2)
1258
+ };
1259
+ }
1260
+ static create(options) {
1261
+ return new _IgniterJobsBullMQAdapter(options);
1262
+ }
1263
+ registerJob(queueName, jobName, definition) {
1264
+ const map = this.jobsByQueue.get(queueName) ?? /* @__PURE__ */ new Map();
1265
+ if (map.has(jobName)) {
1266
+ throw new IgniterJobsError({
1267
+ code: "JOBS_DUPLICATE_JOB",
1268
+ message: `Job "${jobName}" is already registered in queue "${queueName}".`
1269
+ });
1270
+ }
1271
+ map.set(jobName, definition);
1272
+ this.jobsByQueue.set(queueName, map);
1273
+ this.executorDirty = true;
1274
+ }
1275
+ registerCron(queueName, cronName, definition) {
1276
+ const map = this.cronsByQueue.get(queueName) ?? /* @__PURE__ */ new Map();
1277
+ if (map.has(cronName)) {
1278
+ throw new IgniterJobsError({
1279
+ code: "JOBS_INVALID_CRON",
1280
+ message: `Cron "${cronName}" is already registered in queue "${queueName}".`
1281
+ });
1282
+ }
1283
+ map.set(cronName, definition);
1284
+ this.cronsByQueue.set(queueName, map);
1285
+ this.executorDirty = true;
1286
+ }
1287
+ async dispatch(params) {
1288
+ const executor = await this.executor();
1289
+ const namespace = executor[params.queue];
1290
+ if (!namespace) {
1291
+ throw new IgniterJobsError({
1292
+ code: "JOBS_QUEUE_NOT_FOUND",
1293
+ message: `Queue "${params.queue}" is not registered in the adapter.`
1294
+ });
1295
+ }
1296
+ return namespace.enqueue({
1297
+ task: params.jobName,
1298
+ input: params.input,
1299
+ jobId: params.jobId,
1300
+ delay: params.delay,
1301
+ priority: params.priority,
1302
+ attempts: params.attempts,
1303
+ metadata: params.metadata,
1304
+ removeOnComplete: params.removeOnComplete,
1305
+ removeOnFail: params.removeOnFail,
1306
+ limiter: params.limiter
1307
+ });
1308
+ }
1309
+ async schedule(params) {
1310
+ const executor = await this.executor();
1311
+ const namespace = executor[params.queue];
1312
+ if (!namespace) {
1313
+ throw new IgniterJobsError({
1314
+ code: "JOBS_QUEUE_NOT_FOUND",
1315
+ message: `Queue "${params.queue}" is not registered in the adapter.`
1316
+ });
1317
+ }
1318
+ const schedule = {
1319
+ jobId: params.jobId,
1320
+ delay: params.delay,
1321
+ priority: params.priority,
1322
+ attempts: params.attempts,
1323
+ metadata: params.metadata,
1324
+ removeOnComplete: params.removeOnComplete,
1325
+ removeOnFail: params.removeOnFail,
1326
+ limiter: params.limiter,
1327
+ at: params.at,
1328
+ repeat: params.cron || params.every || params.maxExecutions || params.skipWeekends || params.onlyBusinessHours || params.businessHours || params.onlyWeekdays || params.skipDates ? {
1329
+ cron: params.cron,
1330
+ every: params.every,
1331
+ times: params.maxExecutions,
1332
+ skipWeekends: params.skipWeekends,
1333
+ onlyBusinessHours: params.onlyBusinessHours,
1334
+ businessHours: params.businessHours,
1335
+ onlyWeekdays: params.onlyWeekdays,
1336
+ skipDates: toDateArray(params.skipDates)
1337
+ } : void 0
1338
+ };
1339
+ return namespace.schedule({
1340
+ task: params.jobName,
1341
+ input: params.input,
1342
+ ...schedule
1343
+ });
1344
+ }
1345
+ async getJob(jobId, queue) {
1346
+ const result = await this.core().job.get(jobId, queue ? this.toCoreQueueName(queue) : void 0);
1347
+ return result ? this.mapJob(result, queue) : null;
1348
+ }
1349
+ async getJobState(jobId, queue) {
1350
+ const state = await this.core().job.getState(jobId, queue ? this.toCoreQueueName(queue) : void 0);
1351
+ return state;
1352
+ }
1353
+ async getJobLogs(jobId, queue) {
1354
+ const logs = await this.core().job.getLogs(jobId, queue ? this.toCoreQueueName(queue) : void 0);
1355
+ return logs;
1356
+ }
1357
+ async getJobProgress(jobId, queue) {
1358
+ return this.core().job.getProgress(jobId, queue ? this.toCoreQueueName(queue) : void 0);
1359
+ }
1360
+ async retryJob(jobId, queue) {
1361
+ await this.core().job.retry(jobId, queue ? this.toCoreQueueName(queue) : void 0);
1362
+ }
1363
+ async removeJob(jobId, queue) {
1364
+ await this.core().job.remove(jobId, queue ? this.toCoreQueueName(queue) : void 0);
1365
+ }
1366
+ async promoteJob(jobId, queue) {
1367
+ await this.core().job.promote(jobId, queue ? this.toCoreQueueName(queue) : void 0);
1368
+ }
1369
+ async moveJobToFailed(jobId, reason, queue) {
1370
+ await this.core().job.moveToFailed(jobId, reason, queue ? this.toCoreQueueName(queue) : void 0);
1371
+ }
1372
+ async retryManyJobs(jobIds, queue) {
1373
+ await this.core().job.retryMany(jobIds, queue ? this.toCoreQueueName(queue) : void 0);
1374
+ }
1375
+ async removeManyJobs(jobIds, queue) {
1376
+ await this.core().job.removeMany(jobIds, queue ? this.toCoreQueueName(queue) : void 0);
1377
+ }
1378
+ async getQueueInfo(queue) {
1379
+ const info = await this.core().queues.get(this.toCoreQueueName(queue));
1380
+ if (!info) return null;
1381
+ return this.mapQueueInfo(info);
1382
+ }
1383
+ async getQueueJobCounts(queue) {
1384
+ const counts = await this.core().queues.getJobCounts(this.toCoreQueueName(queue));
1385
+ return counts;
1386
+ }
1387
+ async listQueues() {
1388
+ const list = await this.core().queues.list();
1389
+ return list.map((q) => this.mapQueueInfo(q));
1390
+ }
1391
+ async pauseQueue(queue) {
1392
+ await this.core().queues.pause(this.toCoreQueueName(queue));
1393
+ }
1394
+ async resumeQueue(queue) {
1395
+ await this.core().queues.resume(this.toCoreQueueName(queue));
1396
+ }
1397
+ async drainQueue(queue) {
1398
+ return this.core().queues.drain(this.toCoreQueueName(queue));
1399
+ }
1400
+ async cleanQueue(queue, options) {
1401
+ return this.core().queues.clean(this.toCoreQueueName(queue), options);
1402
+ }
1403
+ async obliterateQueue(queue, options) {
1404
+ await this.core().queues.obliterate(this.toCoreQueueName(queue), options);
1405
+ }
1406
+ async retryAllInQueue(queue) {
1407
+ const jobs = await this.core().queues.getJobs(this.toCoreQueueName(queue), { status: ["failed"], limit: 1e3 });
1408
+ await Promise.all(jobs.map((j) => this.core().job.retry(j.id, this.toCoreQueueName(queue))));
1409
+ return jobs.length;
1410
+ }
1411
+ async pauseJobType(queue, jobName) {
1412
+ throw new IgniterJobsError({
1413
+ code: "JOBS_QUEUE_OPERATION_FAILED",
1414
+ message: "BullMQ backend does not support pausing a single job type; pause the queue or adjust worker filters."
1415
+ });
1416
+ }
1417
+ async resumeJobType(queue, jobName) {
1418
+ throw new IgniterJobsError({
1419
+ code: "JOBS_QUEUE_OPERATION_FAILED",
1420
+ message: "BullMQ backend does not support resuming a single job type; resume the queue or adjust worker filters."
1421
+ });
1422
+ }
1423
+ async searchJobs(filter) {
1424
+ const queue = filter?.queue;
1425
+ const status = filter?.status;
1426
+ const limit = filter?.limit ?? 100;
1427
+ const offset = filter?.offset ?? 0;
1428
+ if (queue) {
1429
+ const jobs = await this.core().queues.getJobs(this.toCoreQueueName(queue), { status, limit, offset });
1430
+ return jobs.map((j) => this.mapJob(j, queue));
1431
+ }
1432
+ const queues = await this.listQueues();
1433
+ const results = [];
1434
+ for (const q of queues) {
1435
+ const jobs = await this.core().queues.getJobs(this.toCoreQueueName(q.name), { status, limit, offset });
1436
+ results.push(...jobs.map((j) => this.mapJob(j, q.name)));
1437
+ if (results.length >= limit) break;
1438
+ }
1439
+ return results.slice(0, limit);
1440
+ }
1441
+ async searchQueues(filter) {
1442
+ const all = await this.listQueues();
1443
+ const name = filter?.name;
1444
+ const isPaused = filter?.isPaused;
1445
+ return all.filter((q) => name ? q.name.includes(name) : true).filter((q) => typeof isPaused === "boolean" ? q.isPaused === isPaused : true);
1446
+ }
1447
+ async searchWorkers(filter) {
1448
+ const queue = filter?.queue;
1449
+ const isRunning = filter?.isRunning;
1450
+ const all = Array.from(this.core().getWorkers().values());
1451
+ return all.filter((w) => {
1452
+ if (!queue) return true;
1453
+ const coreQueue = this.toCoreQueueName(queue);
1454
+ const queues = w.config?.queues ?? [w.queueName];
1455
+ return Array.isArray(queues) ? queues.includes(coreQueue) : false;
1456
+ }).filter((w) => typeof isRunning === "boolean" ? isRunning ? w.isRunning() : !w.isRunning() : true).map((w) => this.mapWorker(w));
1457
+ }
1458
+ async createWorker(config) {
1459
+ await this.executor();
1460
+ const queuesSource = config.queues?.length ? config.queues : Array.from(/* @__PURE__ */ new Set([...this.jobsByQueue.keys(), ...this.cronsByQueue.keys()]));
1461
+ const queues = queuesSource.map((q) => this.toCoreQueueName(q));
1462
+ const coreConfig = {
1463
+ queues,
1464
+ concurrency: config.concurrency ?? 1,
1465
+ limiter: config.limiter,
1466
+ onActive: config.handlers?.onActive,
1467
+ onSuccess: config.handlers?.onSuccess,
1468
+ onFailure: config.handlers?.onFailure,
1469
+ onIdle: config.handlers?.onIdle
1470
+ };
1471
+ const handle = await this.core().worker(coreConfig);
1472
+ return this.mapWorker(handle);
1473
+ }
1474
+ getWorkers() {
1475
+ const out = /* @__PURE__ */ new Map();
1476
+ for (const [id, handle] of this.core().getWorkers()) out.set(id, this.mapWorker(handle));
1477
+ return out;
1478
+ }
1479
+ async publishEvent(channel, payload) {
1480
+ await this.publisher.publish(channel, JSON.stringify(payload));
1481
+ }
1482
+ async subscribeEvent(channel, handler) {
1483
+ const set = this.subscribers.get(channel) ?? /* @__PURE__ */ new Set();
1484
+ const wrapped = (payload) => void handler(payload);
1485
+ set.add(wrapped);
1486
+ this.subscribers.set(channel, set);
1487
+ if (set.size === 1) {
1488
+ await this.subscriber.subscribe(channel);
1489
+ }
1490
+ return async () => {
1491
+ const current = this.subscribers.get(channel);
1492
+ if (!current) return;
1493
+ current.delete(wrapped);
1494
+ if (current.size === 0) {
1495
+ this.subscribers.delete(channel);
1496
+ await this.subscriber.unsubscribe(channel);
1497
+ }
1498
+ };
1499
+ }
1500
+ async shutdown() {
1501
+ await this.subscriber.quit();
1502
+ }
1503
+ core() {
1504
+ if (!this.coreAdapter) {
1505
+ this.coreAdapter = createBullMQAdapter({
1506
+ store: { client: this.redis }
1507
+ });
1508
+ }
1509
+ return this.coreAdapter;
1510
+ }
1511
+ async executor() {
1512
+ if (!this.executorDirty && this.coreExecutor) return this.coreExecutor;
1513
+ const routers = {};
1514
+ const flattened = {};
1515
+ const allQueues = /* @__PURE__ */ new Set([...this.jobsByQueue.keys(), ...this.cronsByQueue.keys()]);
1516
+ for (const queueName of allQueues) {
1517
+ const coreJobs = {};
1518
+ const jobs = this.jobsByQueue.get(queueName);
1519
+ if (jobs) {
1520
+ for (const [jobName, def] of jobs.entries()) {
1521
+ const queue = def.queue ? `${queueName}.${def.queue}` : queueName;
1522
+ const fullQueue = IgniterJobsPrefix.buildQueueName(queue);
1523
+ coreJobs[jobName] = this.toCoreJobDefinition(queueName, jobName, def, fullQueue);
1524
+ }
1525
+ }
1526
+ const crons = this.cronsByQueue.get(queueName);
1527
+ if (crons) {
1528
+ for (const [cronName, def] of crons.entries()) {
1529
+ const fullQueue = IgniterJobsPrefix.buildQueueName(queueName);
1530
+ coreJobs[cronName] = this.toCoreCronJobDefinition(queueName, cronName, def, fullQueue);
1531
+ }
1532
+ }
1533
+ if (Object.keys(coreJobs).length === 0) continue;
1534
+ routers[queueName] = this.core().router({
1535
+ jobs: coreJobs,
1536
+ namespace: queueName
1537
+ });
1538
+ for (const [jobName, def] of Object.entries(coreJobs)) {
1539
+ flattened[`${queueName}.${jobName}`] = def;
1540
+ }
1541
+ }
1542
+ await this.core().bulkRegister(flattened);
1543
+ this.coreExecutor = this.core().merge(routers);
1544
+ this.executorDirty = false;
1545
+ return this.coreExecutor;
1546
+ }
1547
+ toCoreQueueName(queueName) {
1548
+ return IgniterJobsPrefix.buildQueueName(queueName);
1549
+ }
1550
+ mapQueueInfo(info) {
1551
+ return {
1552
+ name: this.fromCoreQueueName(info.name),
1553
+ isPaused: info.isPaused,
1554
+ jobCounts: info.jobCounts
1555
+ };
1556
+ }
1557
+ fromCoreQueueName(full) {
1558
+ const prefix = `${IgniterJobsPrefix.BASE_PREFIX}:`;
1559
+ return full.startsWith(prefix) ? full.slice(prefix.length) : full;
1560
+ }
1561
+ mapJob(job, queue) {
1562
+ const q = queue ?? this.fromCoreQueueName(job.metadata?.queue ?? job.queueName ?? "");
1563
+ const scope = job.metadata?.__igniter_jobs_scope;
1564
+ return {
1565
+ id: job.id,
1566
+ name: job.name,
1567
+ queue: q,
1568
+ status: job.status,
1569
+ input: job.payload,
1570
+ result: job.result,
1571
+ error: job.error,
1572
+ progress: 0,
1573
+ attemptsMade: job.attemptsMade ?? 0,
1574
+ priority: job.priority ?? 0,
1575
+ createdAt: job.createdAt,
1576
+ startedAt: job.processedAt,
1577
+ completedAt: job.completedAt,
1578
+ metadata: job.metadata,
1579
+ scope
1580
+ };
1581
+ }
1582
+ mapWorker(handle) {
1583
+ const queues = handle.config?.queues ?? [handle.queueName];
1584
+ return {
1585
+ id: handle.id,
1586
+ queues: queues.map((q) => this.fromCoreQueueName(q)),
1587
+ pause: () => handle.pause(),
1588
+ resume: () => handle.resume(),
1589
+ close: () => handle.close(),
1590
+ isRunning: () => handle.isRunning(),
1591
+ isPaused: () => handle.isPaused(),
1592
+ isClosed: () => handle.isClosed(),
1593
+ getMetrics: async () => handle.getMetrics()
1594
+ };
1595
+ }
1596
+ toCoreJobDefinition(queueName, jobName, def, fullQueueName) {
1597
+ const handler = async (ctx) => {
1598
+ return def.handler({
1599
+ input: ctx.input,
1600
+ context: ctx.context,
1601
+ job: {
1602
+ id: ctx.job.id,
1603
+ name: jobName,
1604
+ queue: queueName,
1605
+ attemptsMade: ctx.job.attemptsMade,
1606
+ createdAt: ctx.job.createdAt,
1607
+ metadata: ctx.job.metadata
1608
+ },
1609
+ scope: ctx.job.metadata?.__igniter_jobs_scope
1610
+ });
1611
+ };
1612
+ return {
1613
+ name: jobName,
1614
+ input: def.input,
1615
+ handler,
1616
+ queue: { name: fullQueueName },
1617
+ attempts: def.attempts,
1618
+ priority: def.priority,
1619
+ delay: def.delay,
1620
+ removeOnComplete: def.removeOnComplete,
1621
+ removeOnFail: def.removeOnFail,
1622
+ metadata: def.metadata,
1623
+ limiter: def.limiter,
1624
+ onStart: def.onStart,
1625
+ onSuccess: def.onSuccess,
1626
+ onFailure: def.onFailure,
1627
+ onProgress: def.onProgress
1628
+ };
1629
+ }
1630
+ toCoreCronJobDefinition(queueName, cronName, def, fullQueueName) {
1631
+ const handler = async (ctx) => {
1632
+ return def.handler({
1633
+ context: ctx.context,
1634
+ job: {
1635
+ id: ctx.job.id,
1636
+ name: cronName,
1637
+ queue: queueName,
1638
+ attemptsMade: ctx.job.attemptsMade,
1639
+ createdAt: ctx.job.createdAt,
1640
+ metadata: ctx.job.metadata
1641
+ },
1642
+ scope: ctx.job.metadata?.__igniter_jobs_scope
1643
+ });
1644
+ };
1645
+ return {
1646
+ name: cronName,
1647
+ handler,
1648
+ queue: { name: fullQueueName },
1649
+ repeat: {
1650
+ cron: def.cron,
1651
+ tz: def.tz,
1652
+ limit: def.maxExecutions,
1653
+ startDate: def.startDate,
1654
+ endDate: def.endDate
1655
+ },
1656
+ metadata: def.onlyBusinessHours || def.skipWeekends || def.businessHours || def.onlyWeekdays || def.skipDates || def.startDate && def.endDate ? {
1657
+ advancedScheduling: {
1658
+ onlyBusinessHours: def.onlyBusinessHours,
1659
+ skipWeekends: def.skipWeekends,
1660
+ businessHours: def.businessHours,
1661
+ skipDates: toDateArray(def.skipDates),
1662
+ onlyWeekdays: def.onlyWeekdays,
1663
+ between: def.startDate && def.endDate ? [def.startDate, def.endDate] : void 0
1664
+ }
1665
+ } : void 0
1666
+ };
1120
1667
  }
1121
1668
  };
1122
- var IgniterQueue = {
1123
- create: IgniterQueueBuilder.create
1669
+
1670
+ // src/utils/id-generator.ts
1671
+ var IgniterJobsIdGenerator = class {
1672
+ /**
1673
+ * Generates a unique identifier with a prefix.
1674
+ *
1675
+ * @example
1676
+ * ```typescript
1677
+ * const jobId = IgniterJobsIdGenerator.generate('job')
1678
+ * ```
1679
+ */
1680
+ static generate(prefix) {
1681
+ const now = Date.now().toString(36);
1682
+ const random = Math.random().toString(36).slice(2, 8);
1683
+ return `${prefix}_${now}_${random}`;
1684
+ }
1685
+ };
1686
+
1687
+ // src/adapters/memory.adapter.ts
1688
+ var IgniterJobsMemoryAdapter = class _IgniterJobsMemoryAdapter {
1689
+ constructor() {
1690
+ this.client = {
1691
+ type: "memory"
1692
+ };
1693
+ this.jobsById = /* @__PURE__ */ new Map();
1694
+ this.jobsByQueue = /* @__PURE__ */ new Map();
1695
+ this.registeredJobs = /* @__PURE__ */ new Map();
1696
+ this.registeredCrons = /* @__PURE__ */ new Map();
1697
+ this.workers = /* @__PURE__ */ new Map();
1698
+ this.subscribers = /* @__PURE__ */ new Map();
1699
+ this.queues = {
1700
+ list: async () => this.listQueues(),
1701
+ get: async (name) => this.getQueueInfo(name),
1702
+ getJobCounts: async (name) => this.getQueueJobCounts(name),
1703
+ getJobs: async (name, filter) => {
1704
+ const statuses = filter?.status;
1705
+ const limit = filter?.limit ?? 100;
1706
+ const offset = filter?.offset ?? 0;
1707
+ const results = await this.searchJobs({
1708
+ queue: name,
1709
+ status: statuses,
1710
+ limit,
1711
+ offset
1712
+ });
1713
+ return results;
1714
+ },
1715
+ pause: async (name) => this.pauseQueue(name),
1716
+ resume: async (name) => this.resumeQueue(name),
1717
+ isPaused: async (name) => {
1718
+ const info = await this.getQueueInfo(name);
1719
+ return info?.isPaused ?? false;
1720
+ },
1721
+ drain: async (name) => this.drainQueue(name),
1722
+ clean: async (name, options) => this.cleanQueue(name, options),
1723
+ obliterate: async (name, options) => this.obliterateQueue(name, options)
1724
+ };
1725
+ this.pausedQueues = /* @__PURE__ */ new Set();
1726
+ }
1727
+ static create() {
1728
+ return new _IgniterJobsMemoryAdapter();
1729
+ }
1730
+ registerJob(queueName, jobName, definition) {
1731
+ const queueJobs = this.registeredJobs.get(queueName) ?? /* @__PURE__ */ new Map();
1732
+ if (queueJobs.has(jobName)) {
1733
+ throw new IgniterJobsError({
1734
+ code: "JOBS_DUPLICATE_JOB",
1735
+ message: `Job "${jobName}" already registered for queue "${queueName}".`
1736
+ });
1737
+ }
1738
+ queueJobs.set(jobName, definition);
1739
+ this.registeredJobs.set(queueName, queueJobs);
1740
+ }
1741
+ registerCron(queueName, cronName, definition) {
1742
+ const queueCrons = this.registeredCrons.get(queueName) ?? /* @__PURE__ */ new Map();
1743
+ if (queueCrons.has(cronName)) {
1744
+ throw new IgniterJobsError({
1745
+ code: "JOBS_INVALID_CRON",
1746
+ message: `Cron "${cronName}" already registered for queue "${queueName}".`
1747
+ });
1748
+ }
1749
+ queueCrons.set(cronName, definition);
1750
+ this.registeredCrons.set(queueName, queueCrons);
1751
+ }
1752
+ async dispatch(params) {
1753
+ const jobId = params.jobId ?? IgniterJobsIdGenerator.generate("job");
1754
+ const maxAttempts = params.attempts ?? 1;
1755
+ const metadata = params.metadata ?? {};
1756
+ const job = {
1757
+ id: jobId,
1758
+ name: params.jobName,
1759
+ queue: params.queue,
1760
+ input: params.input,
1761
+ status: this.pausedQueues.has(params.queue) ? "paused" : params.delay && params.delay > 0 ? "delayed" : "waiting",
1762
+ progress: 0,
1763
+ attemptsMade: 0,
1764
+ maxAttempts,
1765
+ priority: params.priority ?? 0,
1766
+ createdAt: /* @__PURE__ */ new Date(),
1767
+ metadata,
1768
+ scope: params.scope,
1769
+ logs: []
1770
+ };
1771
+ this.jobsById.set(jobId, job);
1772
+ const queueList = this.jobsByQueue.get(params.queue) ?? [];
1773
+ queueList.push(jobId);
1774
+ this.jobsByQueue.set(params.queue, queueList);
1775
+ if (params.delay && params.delay > 0) {
1776
+ setTimeout(() => {
1777
+ const stored = this.jobsById.get(jobId);
1778
+ if (!stored) return;
1779
+ if (!this.pausedQueues.has(params.queue)) stored.status = "waiting";
1780
+ void this.kickWorkers(params.queue);
1781
+ }, params.delay);
1782
+ return jobId;
1783
+ }
1784
+ void this.kickWorkers(params.queue);
1785
+ return jobId;
1786
+ }
1787
+ async schedule(params) {
1788
+ if (params.at) {
1789
+ const delay = params.at.getTime() - Date.now();
1790
+ if (delay <= 0) {
1791
+ throw new IgniterJobsError({
1792
+ code: "JOBS_INVALID_SCHEDULE",
1793
+ message: "Scheduled time must be in the future."
1794
+ });
1795
+ }
1796
+ return this.dispatch({ ...params, delay });
1797
+ }
1798
+ if (params.cron || params.every) {
1799
+ return this.dispatch({ ...params, delay: params.delay ?? 0 });
1800
+ }
1801
+ return this.dispatch(params);
1802
+ }
1803
+ async getJob(jobId, queue) {
1804
+ const job = this.jobsById.get(jobId);
1805
+ if (!job) return null;
1806
+ if (queue && job.queue !== queue) return null;
1807
+ return this.toSearchResult(job);
1808
+ }
1809
+ async getJobState(jobId, queue) {
1810
+ const job = this.jobsById.get(jobId);
1811
+ if (!job) return null;
1812
+ if (queue && job.queue !== queue) return null;
1813
+ return job.status;
1814
+ }
1815
+ async getJobLogs(jobId, queue) {
1816
+ const job = this.jobsById.get(jobId);
1817
+ if (!job) return [];
1818
+ if (queue && job.queue !== queue) return [];
1819
+ return job.logs;
1820
+ }
1821
+ async getJobProgress(jobId, queue) {
1822
+ const job = this.jobsById.get(jobId);
1823
+ if (!job) return 0;
1824
+ if (queue && job.queue !== queue) return 0;
1825
+ return job.progress;
1826
+ }
1827
+ async retryJob(jobId, queue) {
1828
+ const job = this.jobsById.get(jobId);
1829
+ if (!job) {
1830
+ throw new IgniterJobsError({ code: "JOBS_NOT_FOUND", message: `Job "${jobId}" not found.` });
1831
+ }
1832
+ if (queue && job.queue !== queue) {
1833
+ throw new IgniterJobsError({ code: "JOBS_NOT_FOUND", message: `Job "${jobId}" not found in queue "${queue}".` });
1834
+ }
1835
+ job.status = "waiting";
1836
+ job.error = void 0;
1837
+ job.completedAt = void 0;
1838
+ job.progress = 0;
1839
+ void this.kickWorkers(job.queue);
1840
+ }
1841
+ async removeJob(jobId, queue) {
1842
+ const job = this.jobsById.get(jobId);
1843
+ if (!job) return;
1844
+ if (queue && job.queue !== queue) return;
1845
+ this.jobsById.delete(jobId);
1846
+ const list = this.jobsByQueue.get(job.queue);
1847
+ if (list) this.jobsByQueue.set(job.queue, list.filter((id) => id !== jobId));
1848
+ }
1849
+ async promoteJob(jobId, queue) {
1850
+ const job = this.jobsById.get(jobId);
1851
+ if (!job) {
1852
+ throw new IgniterJobsError({ code: "JOBS_NOT_FOUND", message: `Job "${jobId}" not found.` });
1853
+ }
1854
+ if (queue && job.queue !== queue) {
1855
+ throw new IgniterJobsError({ code: "JOBS_NOT_FOUND", message: `Job "${jobId}" not found in queue "${queue}".` });
1856
+ }
1857
+ if (job.status === "delayed" || job.status === "paused") {
1858
+ job.status = this.pausedQueues.has(job.queue) ? "paused" : "waiting";
1859
+ void this.kickWorkers(job.queue);
1860
+ }
1861
+ }
1862
+ async moveJobToFailed(jobId, reason, queue) {
1863
+ const job = this.jobsById.get(jobId);
1864
+ if (!job) {
1865
+ throw new IgniterJobsError({ code: "JOBS_NOT_FOUND", message: `Job "${jobId}" not found.` });
1866
+ }
1867
+ if (queue && job.queue !== queue) {
1868
+ throw new IgniterJobsError({ code: "JOBS_NOT_FOUND", message: `Job "${jobId}" not found in queue "${queue}".` });
1869
+ }
1870
+ job.status = "failed";
1871
+ job.error = reason;
1872
+ job.completedAt = /* @__PURE__ */ new Date();
1873
+ }
1874
+ async retryManyJobs(jobIds, queue) {
1875
+ await Promise.all(jobIds.map((id) => this.retryJob(id, queue)));
1876
+ }
1877
+ async removeManyJobs(jobIds, queue) {
1878
+ await Promise.all(jobIds.map((id) => this.removeJob(id, queue)));
1879
+ }
1880
+ async getQueueInfo(queue) {
1881
+ const counts = await this.getQueueJobCounts(queue);
1882
+ return {
1883
+ name: queue,
1884
+ isPaused: this.pausedQueues.has(queue),
1885
+ jobCounts: counts
1886
+ };
1887
+ }
1888
+ async getQueueJobCounts(queue) {
1889
+ const jobIds = this.jobsByQueue.get(queue) ?? [];
1890
+ const counts = {
1891
+ waiting: 0,
1892
+ active: 0,
1893
+ completed: 0,
1894
+ failed: 0,
1895
+ delayed: 0,
1896
+ paused: 0
1897
+ };
1898
+ for (const id of jobIds) {
1899
+ const job = this.jobsById.get(id);
1900
+ if (!job) continue;
1901
+ if (job.status in counts) {
1902
+ counts[job.status]++;
1903
+ }
1904
+ }
1905
+ return counts;
1906
+ }
1907
+ async listQueues() {
1908
+ const queues = Array.from(/* @__PURE__ */ new Set([...this.jobsByQueue.keys(), ...this.registeredJobs.keys(), ...this.registeredCrons.keys()]));
1909
+ const result = [];
1910
+ for (const q of queues) {
1911
+ result.push(await this.getQueueInfo(q));
1912
+ }
1913
+ return result;
1914
+ }
1915
+ async pauseQueue(queue) {
1916
+ this.pausedQueues.add(queue);
1917
+ const jobIds = this.jobsByQueue.get(queue) ?? [];
1918
+ for (const id of jobIds) {
1919
+ const job = this.jobsById.get(id);
1920
+ if (!job) continue;
1921
+ if (job.status === "waiting") job.status = "paused";
1922
+ }
1923
+ }
1924
+ async resumeQueue(queue) {
1925
+ this.pausedQueues.delete(queue);
1926
+ const jobIds = this.jobsByQueue.get(queue) ?? [];
1927
+ for (const id of jobIds) {
1928
+ const job = this.jobsById.get(id);
1929
+ if (!job) continue;
1930
+ if (job.status === "paused") job.status = "waiting";
1931
+ }
1932
+ void this.kickWorkers(queue);
1933
+ }
1934
+ async drainQueue(queue) {
1935
+ const jobIds = this.jobsByQueue.get(queue) ?? [];
1936
+ let removed = 0;
1937
+ for (const id of jobIds) {
1938
+ const job = this.jobsById.get(id);
1939
+ if (!job) continue;
1940
+ if (job.status === "waiting" || job.status === "paused") {
1941
+ this.jobsById.delete(id);
1942
+ removed++;
1943
+ }
1944
+ }
1945
+ this.jobsByQueue.set(queue, jobIds.filter((id) => this.jobsById.has(id)));
1946
+ return removed;
1947
+ }
1948
+ async cleanQueue(queue, options) {
1949
+ const statuses = Array.isArray(options.status) ? options.status : [options.status];
1950
+ const olderThan = options.olderThan ?? 0;
1951
+ const limit = options.limit ?? Number.POSITIVE_INFINITY;
1952
+ const jobIds = this.jobsByQueue.get(queue) ?? [];
1953
+ const now = Date.now();
1954
+ let cleaned = 0;
1955
+ for (const id of [...jobIds]) {
1956
+ if (cleaned >= limit) break;
1957
+ const job = this.jobsById.get(id);
1958
+ if (!job) continue;
1959
+ if (!statuses.includes(job.status)) continue;
1960
+ const ageMs = now - job.createdAt.getTime();
1961
+ if (ageMs < olderThan) continue;
1962
+ this.jobsById.delete(id);
1963
+ cleaned++;
1964
+ }
1965
+ this.jobsByQueue.set(queue, jobIds.filter((id) => this.jobsById.has(id)));
1966
+ return cleaned;
1967
+ }
1968
+ async obliterateQueue(queue, options) {
1969
+ const jobIds = this.jobsByQueue.get(queue) ?? [];
1970
+ for (const id of jobIds) this.jobsById.delete(id);
1971
+ this.jobsByQueue.delete(queue);
1972
+ this.registeredJobs.delete(queue);
1973
+ this.registeredCrons.delete(queue);
1974
+ this.pausedQueues.delete(queue);
1975
+ }
1976
+ async retryAllInQueue(queue) {
1977
+ const jobIds = this.jobsByQueue.get(queue) ?? [];
1978
+ let retried = 0;
1979
+ for (const id of jobIds) {
1980
+ const job = this.jobsById.get(id);
1981
+ if (!job) continue;
1982
+ if (job.status === "failed") {
1983
+ await this.retryJob(id, queue);
1984
+ retried++;
1985
+ }
1986
+ }
1987
+ return retried;
1988
+ }
1989
+ async pauseJobType(queue, jobName) {
1990
+ const jobIds = this.jobsByQueue.get(queue) ?? [];
1991
+ for (const id of jobIds) {
1992
+ const job = this.jobsById.get(id);
1993
+ if (!job) continue;
1994
+ if (job.name === jobName && job.status === "waiting") job.status = "paused";
1995
+ }
1996
+ }
1997
+ async resumeJobType(queue, jobName) {
1998
+ const jobIds = this.jobsByQueue.get(queue) ?? [];
1999
+ for (const id of jobIds) {
2000
+ const job = this.jobsById.get(id);
2001
+ if (!job) continue;
2002
+ if (job.name === jobName && job.status === "paused") job.status = "waiting";
2003
+ }
2004
+ void this.kickWorkers(queue);
2005
+ }
2006
+ async searchJobs(filter) {
2007
+ const queue = filter?.queue;
2008
+ const statuses = filter?.status;
2009
+ const limit = filter?.limit ?? 100;
2010
+ const offset = filter?.offset ?? 0;
2011
+ const all = Array.from(this.jobsById.values()).filter((j) => queue ? j.queue === queue : true).filter((j) => statuses ? statuses.includes(j.status) : true).sort((a, b) => b.priority - a.priority || a.createdAt.getTime() - b.createdAt.getTime());
2012
+ return all.slice(offset, offset + limit).map((j) => this.toSearchResult(j));
2013
+ }
2014
+ async searchQueues(filter) {
2015
+ const name = filter?.name;
2016
+ const isPaused = filter?.isPaused;
2017
+ const all = await this.listQueues();
2018
+ return all.filter((q) => name ? q.name.includes(name) : true).filter((q) => typeof isPaused === "boolean" ? q.isPaused === isPaused : true);
2019
+ }
2020
+ async searchWorkers(filter) {
2021
+ const queue = filter?.queue;
2022
+ const isRunning = filter?.isRunning;
2023
+ return Array.from(this.workers.values()).filter((w) => queue ? w.queues.includes(queue) : true).filter((w) => typeof isRunning === "boolean" ? isRunning ? !w.closed : w.closed : true).map((w) => this.toWorkerHandle(w));
2024
+ }
2025
+ async createWorker(config) {
2026
+ const workerId = IgniterJobsIdGenerator.generate("worker");
2027
+ const state = {
2028
+ id: workerId,
2029
+ queues: config.queues ?? [],
2030
+ concurrency: config.concurrency ?? 1,
2031
+ paused: false,
2032
+ closed: false,
2033
+ startedAt: /* @__PURE__ */ new Date(),
2034
+ metrics: { processed: 0, failed: 0, totalDuration: 0 },
2035
+ handlers: config.handlers
2036
+ };
2037
+ this.workers.set(workerId, state);
2038
+ for (const q of state.queues) void this.kickWorkers(q);
2039
+ return this.toWorkerHandle(state);
2040
+ }
2041
+ getWorkers() {
2042
+ const out = /* @__PURE__ */ new Map();
2043
+ for (const [id, state] of this.workers) out.set(id, this.toWorkerHandle(state));
2044
+ return out;
2045
+ }
2046
+ async publishEvent(channel, payload) {
2047
+ const handlers = this.subscribers.get(channel);
2048
+ if (!handlers) return;
2049
+ await Promise.all(Array.from(handlers).map(async (h) => h(payload)));
2050
+ }
2051
+ async subscribeEvent(channel, handler) {
2052
+ const set = this.subscribers.get(channel) ?? /* @__PURE__ */ new Set();
2053
+ set.add(handler);
2054
+ this.subscribers.set(channel, set);
2055
+ return async () => {
2056
+ const current = this.subscribers.get(channel);
2057
+ if (!current) return;
2058
+ current.delete(handler);
2059
+ if (current.size === 0) this.subscribers.delete(channel);
2060
+ };
2061
+ }
2062
+ async shutdown() {
2063
+ this.workers.clear();
2064
+ this.subscribers.clear();
2065
+ }
2066
+ toSearchResult(job) {
2067
+ return {
2068
+ id: job.id,
2069
+ name: job.name,
2070
+ queue: job.queue,
2071
+ status: job.status,
2072
+ input: job.input,
2073
+ result: job.result,
2074
+ error: job.error,
2075
+ progress: job.progress,
2076
+ attemptsMade: job.attemptsMade,
2077
+ priority: job.priority,
2078
+ createdAt: job.createdAt,
2079
+ startedAt: job.startedAt,
2080
+ completedAt: job.completedAt,
2081
+ metadata: job.metadata,
2082
+ scope: job.scope
2083
+ };
2084
+ }
2085
+ toWorkerHandle(worker) {
2086
+ return {
2087
+ id: worker.id,
2088
+ queues: worker.queues,
2089
+ pause: async () => {
2090
+ worker.paused = true;
2091
+ },
2092
+ resume: async () => {
2093
+ worker.paused = false;
2094
+ for (const q of worker.queues) void this.kickWorkers(q);
2095
+ },
2096
+ close: async () => {
2097
+ worker.closed = true;
2098
+ },
2099
+ isRunning: () => !worker.closed && !worker.paused,
2100
+ isPaused: () => worker.paused,
2101
+ isClosed: () => worker.closed,
2102
+ getMetrics: async () => this.toWorkerMetrics(worker)
2103
+ };
2104
+ }
2105
+ toWorkerMetrics(worker) {
2106
+ const uptime = Date.now() - worker.startedAt.getTime();
2107
+ const processed = worker.metrics.processed;
2108
+ return {
2109
+ processed,
2110
+ failed: worker.metrics.failed,
2111
+ avgDuration: processed > 0 ? worker.metrics.totalDuration / processed : 0,
2112
+ concurrency: worker.concurrency,
2113
+ uptime
2114
+ };
2115
+ }
2116
+ async kickWorkers(queue) {
2117
+ if (this.pausedQueues.has(queue)) return;
2118
+ const relevant = Array.from(this.workers.values()).filter((w) => !w.closed && !w.paused && (w.queues.length === 0 || w.queues.includes(queue)));
2119
+ if (relevant.length === 0) return;
2120
+ for (const w of relevant) {
2121
+ void this.processLoop(w, queue);
2122
+ }
2123
+ }
2124
+ async processLoop(worker, queue) {
2125
+ if (worker.closed || worker.paused) return;
2126
+ const concurrency = Math.max(1, worker.concurrency);
2127
+ const running = worker.__running;
2128
+ const currentRunning = running ?? 0;
2129
+ if (currentRunning >= concurrency) return;
2130
+ worker.__running = currentRunning + 1;
2131
+ try {
2132
+ const next = this.nextJob(queue);
2133
+ if (!next) return;
2134
+ await this.processJob(worker, next);
2135
+ } finally {
2136
+ worker.__running = worker.__running - 1;
2137
+ if (this.nextJob(queue)) void this.processLoop(worker, queue);
2138
+ else if (worker.handlers?.onIdle) await worker.handlers.onIdle();
2139
+ }
2140
+ }
2141
+ nextJob(queue) {
2142
+ const ids = this.jobsByQueue.get(queue) ?? [];
2143
+ const candidates = ids.map((id) => this.jobsById.get(id)).filter((j) => Boolean(j)).filter((j) => j.status === "waiting").sort((a, b) => b.priority - a.priority || a.createdAt.getTime() - b.createdAt.getTime());
2144
+ return candidates[0] ?? null;
2145
+ }
2146
+ async processJob(worker, job) {
2147
+ if (this.pausedQueues.has(job.queue)) {
2148
+ job.status = "paused";
2149
+ return;
2150
+ }
2151
+ job.status = "active";
2152
+ job.startedAt = /* @__PURE__ */ new Date();
2153
+ job.attemptsMade += 1;
2154
+ job.logs.push({ timestamp: /* @__PURE__ */ new Date(), level: "info", message: "Job started" });
2155
+ if (worker.handlers?.onActive) await worker.handlers.onActive({ job: this.toSearchResult(job) });
2156
+ const start = Date.now();
2157
+ try {
2158
+ const definition = this.registeredJobs.get(job.queue)?.get(job.name);
2159
+ if (!definition) {
2160
+ throw new IgniterJobsError({
2161
+ code: "JOBS_NOT_REGISTERED",
2162
+ message: `Job "${job.name}" is not registered for queue "${job.queue}".`
2163
+ });
2164
+ }
2165
+ if (definition.onStart) {
2166
+ await definition.onStart({
2167
+ input: job.input,
2168
+ context: {},
2169
+ job: { id: job.id, name: job.name, queue: job.queue, attemptsMade: job.attemptsMade, metadata: job.metadata },
2170
+ scope: job.scope,
2171
+ startedAt: job.startedAt
2172
+ });
2173
+ }
2174
+ const result = await definition.handler({
2175
+ input: job.input,
2176
+ context: {},
2177
+ job: { id: job.id, name: job.name, queue: job.queue, attemptsMade: job.attemptsMade, metadata: job.metadata },
2178
+ scope: job.scope
2179
+ });
2180
+ const duration = Date.now() - start;
2181
+ job.status = "completed";
2182
+ job.completedAt = /* @__PURE__ */ new Date();
2183
+ job.result = result;
2184
+ job.progress = 100;
2185
+ job.logs.push({ timestamp: /* @__PURE__ */ new Date(), level: "info", message: `Job completed in ${duration}ms` });
2186
+ worker.metrics.processed += 1;
2187
+ worker.metrics.totalDuration += duration;
2188
+ if (definition.onSuccess) {
2189
+ await definition.onSuccess({
2190
+ input: job.input,
2191
+ context: {},
2192
+ job: { id: job.id, name: job.name, queue: job.queue, attemptsMade: job.attemptsMade, metadata: job.metadata },
2193
+ scope: job.scope,
2194
+ result,
2195
+ duration
2196
+ });
2197
+ }
2198
+ if (worker.handlers?.onSuccess) await worker.handlers.onSuccess({ job: this.toSearchResult(job), result });
2199
+ } catch (error) {
2200
+ job.error = error?.message ?? String(error);
2201
+ job.logs.push({ timestamp: /* @__PURE__ */ new Date(), level: "error", message: job.error ?? "Unknown error" });
2202
+ const isFinalAttempt = job.attemptsMade >= job.maxAttempts;
2203
+ if (isFinalAttempt) {
2204
+ job.status = "failed";
2205
+ job.completedAt = /* @__PURE__ */ new Date();
2206
+ worker.metrics.failed += 1;
2207
+ const definition = this.registeredJobs.get(job.queue)?.get(job.name);
2208
+ if (definition?.onFailure) {
2209
+ await definition.onFailure({
2210
+ input: job.input,
2211
+ context: {},
2212
+ job: { id: job.id, name: job.name, queue: job.queue, attemptsMade: job.attemptsMade, metadata: job.metadata },
2213
+ scope: job.scope,
2214
+ error,
2215
+ isFinalAttempt: true
2216
+ });
2217
+ }
2218
+ if (worker.handlers?.onFailure) await worker.handlers.onFailure({ job: this.toSearchResult(job), error });
2219
+ } else {
2220
+ job.status = "waiting";
2221
+ void this.kickWorkers(job.queue);
2222
+ }
2223
+ }
2224
+ }
2225
+ };
2226
+
2227
+ // src/telemetry/jobs.telemetry.ts
2228
+ var IgniterJobsTelemetryEvents = {
2229
+ namespace: "igniter.jobs",
2230
+ events: {
2231
+ // Job lifecycle events
2232
+ job: {
2233
+ enqueued: {},
2234
+ started: {},
2235
+ completed: {},
2236
+ failed: {},
2237
+ progress: {},
2238
+ retrying: {},
2239
+ scheduled: {}
2240
+ },
2241
+ // Worker lifecycle events
2242
+ worker: {
2243
+ started: {},
2244
+ stopped: {},
2245
+ idle: {},
2246
+ paused: {},
2247
+ resumed: {}
2248
+ },
2249
+ // Queue management events
2250
+ queue: {
2251
+ paused: {},
2252
+ resumed: {},
2253
+ drained: {},
2254
+ cleaned: {},
2255
+ obliterated: {}
2256
+ }
2257
+ }
1124
2258
  };
1125
2259
 
1126
- export { IGNITER_JOBS_ERROR_CODES, IgniterJobs, IgniterJobsBuilder, IgniterJobsError, IgniterJobsRuntime, IgniterQueue, IgniterQueueBuilder, IgniterWorkerBuilder };
2260
+ export { IGNITER_JOBS_ERROR_CODES, IgniterJobs, IgniterJobsBuilder, IgniterJobsBullMQAdapter, IgniterJobsError, IgniterJobsMemoryAdapter, IgniterJobsTelemetryEvents, IgniterQueue, IgniterQueueBuilder, IgniterWorkerBuilder };
1127
2261
  //# sourceMappingURL=index.mjs.map
1128
2262
  //# sourceMappingURL=index.mjs.map