@igniter-js/jobs 0.1.13 → 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/AGENTS.md +119 -243
  2. package/README.md +352 -158
  3. package/dist/{adapter-CXZxomI9.d.mts → adapter-DDyMVche.d.mts} +125 -20
  4. package/dist/{adapter-CXZxomI9.d.ts → adapter-DDyMVche.d.ts} +125 -20
  5. package/dist/adapters/bun.d.mts +101 -0
  6. package/dist/adapters/bun.d.ts +101 -0
  7. package/dist/adapters/bun.js +1048 -0
  8. package/dist/adapters/bun.js.map +1 -0
  9. package/dist/adapters/bun.mjs +1046 -0
  10. package/dist/adapters/bun.mjs.map +1 -0
  11. package/dist/adapters/{memory.adapter.d.ts → mock.d.mts} +7 -3
  12. package/dist/adapters/{memory.adapter.d.mts → mock.d.ts} +7 -3
  13. package/dist/adapters/{memory.adapter.js → mock.js} +122 -25
  14. package/dist/adapters/mock.js.map +1 -0
  15. package/dist/adapters/{memory.adapter.mjs → mock.mjs} +122 -25
  16. package/dist/adapters/mock.mjs.map +1 -0
  17. package/dist/adapters/{bullmq.adapter.d.mts → node.d.mts} +8 -3
  18. package/dist/adapters/{bullmq.adapter.d.ts → node.d.ts} +8 -3
  19. package/dist/adapters/{bullmq.adapter.js → node.js} +194 -40
  20. package/dist/adapters/node.js.map +1 -0
  21. package/dist/adapters/{bullmq.adapter.mjs → node.mjs} +194 -40
  22. package/dist/adapters/node.mjs.map +1 -0
  23. package/dist/index.d.mts +41 -38
  24. package/dist/index.d.ts +41 -38
  25. package/dist/index.js +145 -1856
  26. package/dist/index.js.map +1 -1
  27. package/dist/index.mjs +146 -1854
  28. package/dist/index.mjs.map +1 -1
  29. package/package.json +29 -41
  30. package/CHANGELOG.md +0 -13
  31. package/dist/adapters/bullmq.adapter.js.map +0 -1
  32. package/dist/adapters/bullmq.adapter.mjs.map +0 -1
  33. package/dist/adapters/index.d.mts +0 -143
  34. package/dist/adapters/index.d.ts +0 -143
  35. package/dist/adapters/index.js +0 -1891
  36. package/dist/adapters/index.js.map +0 -1
  37. package/dist/adapters/index.mjs +0 -1887
  38. package/dist/adapters/index.mjs.map +0 -1
  39. package/dist/adapters/memory.adapter.js.map +0 -1
  40. package/dist/adapters/memory.adapter.mjs.map +0 -1
@@ -0,0 +1,1046 @@
1
+ import { mkdir } from 'fs/promises';
2
+ import { dirname } from 'path';
3
+ import { IgniterError } from '@igniter-js/common';
4
+
5
+ // src/adapters/bun-sqlite.adapter.ts
6
+
7
+ // src/utils/id-generator.ts
8
+ var IgniterJobsIdGenerator = class {
9
+ /**
10
+ * Generates a unique identifier with a prefix.
11
+ *
12
+ * @example
13
+ * ```typescript
14
+ * const jobId = IgniterJobsIdGenerator.generate('job')
15
+ * ```
16
+ */
17
+ static generate(prefix) {
18
+ const now = Date.now().toString(36);
19
+ const random = Math.random().toString(36).slice(2, 8);
20
+ return `${prefix}_${now}_${random}`;
21
+ }
22
+ };
23
+ var IgniterJobsError = class extends IgniterError {
24
+ constructor(options) {
25
+ super(options);
26
+ }
27
+ };
28
+
29
+ // src/adapters/bun-sqlite.adapter.ts
30
+ var IgniterJobsBunSQLiteAdapter = class _IgniterJobsBunSQLiteAdapter {
31
+ constructor(options) {
32
+ this.queues = {
33
+ list: async () => this.listQueues(),
34
+ get: async (name) => this.getQueueInfo(name),
35
+ getJobCounts: async (name) => this.getQueueJobCounts(name),
36
+ getJobs: async (name, filter) => this.searchJobs({
37
+ queue: name,
38
+ status: filter?.status,
39
+ limit: filter?.limit,
40
+ offset: filter?.offset
41
+ }),
42
+ pause: async (name) => this.pauseQueue(name),
43
+ resume: async (name) => this.resumeQueue(name),
44
+ isPaused: async (name) => (await this.getQueueInfo(name))?.isPaused ?? false,
45
+ drain: async (name) => this.drainQueue(name),
46
+ clean: async (name, options) => this.cleanQueue(name, options),
47
+ obliterate: async (name, options) => this.obliterateQueue(name, options)
48
+ };
49
+ this.bunqueueModulePromise = null;
50
+ this.queueRuntimes = /* @__PURE__ */ new Map();
51
+ this.workers = /* @__PURE__ */ new Map();
52
+ this.subscribers = /* @__PURE__ */ new Map();
53
+ this.streamSubscribers = /* @__PURE__ */ new Map();
54
+ this.registeredJobs = /* @__PURE__ */ new Map();
55
+ this.streamDatabasePromise = null;
56
+ this.registeredCrons = /* @__PURE__ */ new Map();
57
+ this.options = {
58
+ path: options.path,
59
+ durable: options.durable ?? false,
60
+ heartbeatInterval: options.heartbeatInterval ?? 1e4,
61
+ pollTimeout: options.pollTimeout ?? 0,
62
+ batchSize: options.batchSize ?? 10,
63
+ lockDuration: options.lockDuration ?? 3e4,
64
+ maxStalledCount: options.maxStalledCount ?? 1
65
+ };
66
+ this.client = {
67
+ type: "bun-sqlite",
68
+ path: this.options.path
69
+ };
70
+ }
71
+ static create(options) {
72
+ return new _IgniterJobsBunSQLiteAdapter(options);
73
+ }
74
+ registerJob(queueName, jobName, definition) {
75
+ const jobs = this.registeredJobs.get(queueName) ?? /* @__PURE__ */ new Map();
76
+ if (jobs.has(jobName)) {
77
+ throw new IgniterJobsError({
78
+ code: "JOBS_DUPLICATE_JOB",
79
+ message: `Job "${jobName}" already registered for queue "${queueName}".`
80
+ });
81
+ }
82
+ jobs.set(jobName, definition);
83
+ this.registeredJobs.set(queueName, jobs);
84
+ this.markQueueCronsDirty(queueName);
85
+ }
86
+ registerCron(queueName, cronName, definition) {
87
+ const crons = this.registeredCrons.get(queueName) ?? /* @__PURE__ */ new Map();
88
+ if (crons.has(cronName)) {
89
+ throw new IgniterJobsError({
90
+ code: "JOBS_INVALID_CRON",
91
+ message: `Cron "${cronName}" already registered for queue "${queueName}".`
92
+ });
93
+ }
94
+ crons.set(cronName, definition);
95
+ this.registeredCrons.set(queueName, crons);
96
+ this.markQueueCronsDirty(queueName);
97
+ }
98
+ async dispatch(params) {
99
+ const queue = await this.getQueue(params.queue);
100
+ const job = await queue.add(
101
+ params.jobName,
102
+ this.createEnvelope(params.input, params.metadata, params.scope),
103
+ this.toJobOptions(params)
104
+ );
105
+ return job.id;
106
+ }
107
+ async schedule(params) {
108
+ const queue = await this.getQueue(params.queue);
109
+ if (params.at) {
110
+ const delay = params.at.getTime() - Date.now();
111
+ if (delay <= 0) {
112
+ throw new IgniterJobsError({
113
+ code: "JOBS_INVALID_SCHEDULE",
114
+ message: "Scheduled time must be in the future."
115
+ });
116
+ }
117
+ const job2 = await queue.add(
118
+ params.jobName,
119
+ this.createEnvelope(params.input, params.metadata, params.scope),
120
+ this.toJobOptions({ ...params, delay })
121
+ );
122
+ return job2.id;
123
+ }
124
+ const scheduleEnvelope = this.createEnvelope(
125
+ params.input,
126
+ params.metadata,
127
+ params.scope,
128
+ params
129
+ );
130
+ if (params.cron || params.every) {
131
+ const job2 = await queue.add(
132
+ params.jobName,
133
+ scheduleEnvelope,
134
+ this.toJobOptions(params, {
135
+ repeat: {
136
+ pattern: params.cron,
137
+ every: params.every,
138
+ limit: params.maxExecutions,
139
+ tz: params.tz
140
+ }
141
+ })
142
+ );
143
+ return job2.id;
144
+ }
145
+ const job = await queue.add(
146
+ params.jobName,
147
+ scheduleEnvelope,
148
+ this.toJobOptions(params)
149
+ );
150
+ return job.id;
151
+ }
152
+ async getJob(jobId, queue) {
153
+ if (queue) {
154
+ const job = await this.getQueue(queue).then((q) => q.getJob(jobId));
155
+ return job ? this.mapJob(job, queue) : null;
156
+ }
157
+ for (const queueName of await this.getAllQueueNames()) {
158
+ const job = await this.getQueue(queueName).then((q) => q.getJob(jobId));
159
+ if (job) return this.mapJob(job, queueName);
160
+ }
161
+ return null;
162
+ }
163
+ async getJobState(jobId, queue) {
164
+ const job = await this.getJob(jobId, queue);
165
+ return job?.status ?? null;
166
+ }
167
+ async getJobLogs(jobId, queue) {
168
+ const queueName = queue ?? await this.findQueueByJobId(jobId);
169
+ if (!queueName) return [];
170
+ const logs = await this.getQueue(queueName).then(
171
+ (q) => q.getJobLogs(jobId)
172
+ );
173
+ return logs.logs.map((entry) => this.parseLogEntry(entry));
174
+ }
175
+ async getJobProgress(jobId, queue) {
176
+ const job = await this.getJob(jobId, queue);
177
+ return job?.progress ?? 0;
178
+ }
179
+ async retryJob(jobId, queue) {
180
+ const job = await this.requireJob(jobId, queue);
181
+ await job.retry();
182
+ }
183
+ async removeJob(jobId, queue) {
184
+ const job = await this.requireJob(jobId, queue);
185
+ await job.remove();
186
+ }
187
+ async promoteJob(jobId, queue) {
188
+ const job = await this.requireJob(jobId, queue);
189
+ await job.promote();
190
+ }
191
+ async moveJobToFailed(jobId, reason, queue) {
192
+ const queueName = queue ?? await this.findQueueByJobId(jobId);
193
+ if (!queueName) {
194
+ throw new IgniterJobsError({
195
+ code: "JOBS_NOT_FOUND",
196
+ message: `Job "${jobId}" not found.`
197
+ });
198
+ }
199
+ await this.getQueue(queueName).then(
200
+ (q) => q.moveJobToFailed(jobId, new Error(reason))
201
+ );
202
+ }
203
+ async retryManyJobs(jobIds, queue) {
204
+ await Promise.all(jobIds.map((jobId) => this.retryJob(jobId, queue)));
205
+ }
206
+ async removeManyJobs(jobIds, queue) {
207
+ await Promise.all(jobIds.map((jobId) => this.removeJob(jobId, queue)));
208
+ }
209
+ async getQueueInfo(queue) {
210
+ const counts = await this.getQueueJobCounts(queue);
211
+ return {
212
+ name: queue,
213
+ isPaused: await this.getQueue(queue).then((q) => q.isPausedAsync()),
214
+ jobCounts: counts
215
+ };
216
+ }
217
+ async getQueueJobCounts(queue) {
218
+ const counts = await this.getQueue(queue).then(
219
+ (q) => q.getJobCountsAsync()
220
+ );
221
+ return {
222
+ waiting: counts.waiting + counts.prioritized,
223
+ active: counts.active,
224
+ completed: counts.completed,
225
+ failed: counts.failed,
226
+ delayed: counts.delayed,
227
+ paused: counts.paused
228
+ };
229
+ }
230
+ async listQueues() {
231
+ const queueNames = await this.getAllQueueNames();
232
+ return Promise.all(
233
+ queueNames.map((queueName) => this.getQueueInfo(queueName))
234
+ ).then((queues) => queues.filter(Boolean));
235
+ }
236
+ async pauseQueue(queue) {
237
+ const queueClient = await this.getQueue(queue);
238
+ queueClient.pause();
239
+ for (const worker of this.workers.values()) {
240
+ if (worker.workers.has(queue)) {
241
+ worker.paused = true;
242
+ worker.workers.get(queue)?.pause();
243
+ }
244
+ }
245
+ }
246
+ async resumeQueue(queue) {
247
+ const queueClient = await this.getQueue(queue);
248
+ queueClient.resume();
249
+ for (const worker of this.workers.values()) {
250
+ if (worker.workers.has(queue)) {
251
+ worker.paused = false;
252
+ worker.workers.get(queue)?.resume();
253
+ }
254
+ }
255
+ }
256
+ async drainQueue(queue) {
257
+ const queueClient = await this.getQueue(queue);
258
+ const before = await this.getQueueJobCounts(queue);
259
+ queueClient.drain();
260
+ const after = await this.getQueueJobCounts(queue);
261
+ return Math.max(
262
+ 0,
263
+ before.waiting + before.delayed + before.paused - after.waiting - after.delayed - after.paused
264
+ );
265
+ }
266
+ async cleanQueue(queue, options) {
267
+ const statuses = Array.isArray(options.status) ? options.status : [options.status];
268
+ let cleaned = 0;
269
+ const queueClient = await this.getQueue(queue);
270
+ for (const status of statuses) {
271
+ const removed = await queueClient.cleanAsync(
272
+ options.olderThan ?? 0,
273
+ options.limit ?? 1e3,
274
+ this.toBunqueueStatus(status)
275
+ );
276
+ cleaned += removed.length;
277
+ }
278
+ return cleaned;
279
+ }
280
+ async obliterateQueue(queue, _options) {
281
+ const queueClient = await this.getQueue(queue);
282
+ queueClient.obliterate();
283
+ this.registeredJobs.delete(queue);
284
+ this.registeredCrons.delete(queue);
285
+ this.queueRuntimes.delete(queue);
286
+ }
287
+ async retryAllInQueue(queue) {
288
+ const queueClient = await this.getQueue(queue);
289
+ const failedJobs = await queueClient.getFailedAsync(0, 1e4);
290
+ await queueClient.retryJobs({
291
+ state: "failed",
292
+ count: failedJobs.length || 1e4
293
+ });
294
+ return failedJobs.length;
295
+ }
296
+ async searchJobs(filter) {
297
+ const queueName = filter.queue;
298
+ const statuses = filter.status;
299
+ const limit = filter.limit ?? 100;
300
+ const offset = filter.offset ?? 0;
301
+ const queueNames = queueName ? [queueName] : await this.getAllQueueNames();
302
+ const mappedStates = statuses?.flatMap(
303
+ (status) => this.toBunqueueStates(status)
304
+ );
305
+ const results = [];
306
+ for (const currentQueue of queueNames) {
307
+ const queueClient = await this.getQueue(currentQueue);
308
+ const jobs = await queueClient.getJobsAsync({
309
+ state: mappedStates,
310
+ start: 0,
311
+ end: offset + limit - 1
312
+ });
313
+ for (const job of jobs) {
314
+ results.push(
315
+ await this.mapJob(job, currentQueue)
316
+ );
317
+ }
318
+ }
319
+ return results.slice(offset, offset + limit);
320
+ }
321
+ async searchQueues(filter) {
322
+ const queues = await this.listQueues();
323
+ const name = filter.name;
324
+ const isPaused = filter.isPaused;
325
+ return queues.filter((queue) => name ? queue.name.includes(name) : true).filter(
326
+ (queue) => typeof isPaused === "boolean" ? queue.isPaused === isPaused : true
327
+ );
328
+ }
329
+ async searchWorkers(filter) {
330
+ const queue = filter.queue;
331
+ const isRunning = filter.isRunning;
332
+ return Array.from(this.workers.values()).filter((worker) => queue ? worker.queues.includes(queue) : true).filter(
333
+ (worker) => typeof isRunning === "boolean" ? isRunning ? !worker.closed && !worker.paused : worker.closed || worker.paused : true
334
+ ).map((worker) => this.toWorkerHandle(worker));
335
+ }
336
+ async createWorker(config) {
337
+ const bunqueue = await this.loadBunqueue();
338
+ const queueNames = config.queues.length ? config.queues : await this.getAllQueueNames();
339
+ const workerId = IgniterJobsIdGenerator.generate("worker");
340
+ const workerState = {
341
+ id: workerId,
342
+ queues: [...queueNames],
343
+ concurrency: config.concurrency ?? 1,
344
+ paused: false,
345
+ closed: false,
346
+ startedAt: /* @__PURE__ */ new Date(),
347
+ activeJobs: /* @__PURE__ */ new Set(),
348
+ metrics: { processed: 0, failed: 0, totalDuration: 0 },
349
+ workers: /* @__PURE__ */ new Map(),
350
+ handlers: config.handlers
351
+ };
352
+ const perQueueConcurrency = this.distributeConcurrency(
353
+ workerState.concurrency,
354
+ queueNames
355
+ );
356
+ for (const queueName of queueNames) {
357
+ await this.getQueue(queueName);
358
+ await this.syncCronSchedulers(queueName);
359
+ const worker = new bunqueue.Worker(
360
+ queueName,
361
+ async (job) => this.processJob(workerState, queueName, job),
362
+ {
363
+ embedded: true,
364
+ dataPath: this.options.path,
365
+ autorun: false,
366
+ concurrency: perQueueConcurrency.get(queueName) ?? 1,
367
+ heartbeatInterval: this.options.heartbeatInterval,
368
+ batchSize: this.options.batchSize,
369
+ pollTimeout: this.options.pollTimeout,
370
+ lockDuration: this.options.lockDuration,
371
+ maxStalledCount: this.options.maxStalledCount,
372
+ limiter: config.limiter ? { max: config.limiter.max, duration: config.limiter.duration } : void 0
373
+ }
374
+ );
375
+ this.attachWorkerEvents(workerState, queueName, worker);
376
+ worker.run();
377
+ workerState.workers.set(queueName, worker);
378
+ }
379
+ this.workers.set(workerId, workerState);
380
+ return this.toWorkerHandle(workerState);
381
+ }
382
+ getWorkers() {
383
+ return new Map(
384
+ Array.from(this.workers.entries()).map(([id, worker]) => [
385
+ id,
386
+ this.toWorkerHandle(worker)
387
+ ])
388
+ );
389
+ }
390
+ async publishEvent(channel, payload) {
391
+ const handlers = this.subscribers.get(channel);
392
+ if (!handlers) return;
393
+ await Promise.all(
394
+ Array.from(handlers).map(async (handler) => handler(payload))
395
+ );
396
+ }
397
+ async subscribeEvent(channel, handler) {
398
+ const handlers = this.subscribers.get(channel) ?? /* @__PURE__ */ new Set();
399
+ handlers.add(handler);
400
+ this.subscribers.set(channel, handlers);
401
+ return async () => {
402
+ const current = this.subscribers.get(channel);
403
+ if (!current) return;
404
+ current.delete(handler);
405
+ if (current.size === 0) this.subscribers.delete(channel);
406
+ };
407
+ }
408
+ async writeJobStreamEvent(params) {
409
+ const key = this.getStreamKey(params.queue, params.jobId);
410
+ let eventId = String(Date.now());
411
+ if (params.persistence?.enabled) {
412
+ const db = await this.getStreamDatabase();
413
+ const result = db.prepare(
414
+ `
415
+ INSERT INTO igniter_jobs_stream_events (
416
+ queue, job_id, job_name, type, data, timestamp, scope
417
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
418
+ `
419
+ ).run(
420
+ params.queue,
421
+ params.jobId,
422
+ params.jobName,
423
+ params.event.type,
424
+ JSON.stringify(params.event.data),
425
+ params.event.timestamp.toISOString(),
426
+ params.scope ? JSON.stringify(params.scope) : null
427
+ );
428
+ eventId = String(result.lastInsertRowid);
429
+ const maxEvents = params.persistence.maxEvents;
430
+ if (typeof maxEvents === "number" && maxEvents > 0) {
431
+ db.prepare(
432
+ `
433
+ DELETE FROM igniter_jobs_stream_events
434
+ WHERE id IN (
435
+ SELECT id FROM igniter_jobs_stream_events
436
+ WHERE queue = ? AND job_id = ?
437
+ ORDER BY id DESC
438
+ LIMIT -1 OFFSET ?
439
+ )
440
+ `
441
+ ).run(params.queue, params.jobId, maxEvents);
442
+ }
443
+ }
444
+ const event = {
445
+ ...params.event,
446
+ id: eventId
447
+ };
448
+ const handlers = this.streamSubscribers.get(key);
449
+ if (handlers?.size) {
450
+ await Promise.all(
451
+ Array.from(handlers).map(async (handler) => handler(event))
452
+ );
453
+ }
454
+ return eventId;
455
+ }
456
+ async readJobStream(params) {
457
+ const db = await this.getStreamDatabase();
458
+ const after = params.after ? Number(params.after) : 0;
459
+ const limit = params.limit ?? 100;
460
+ const rows = db.prepare(
461
+ `
462
+ SELECT id, type, data, timestamp, job_id, job_name, queue, scope
463
+ FROM igniter_jobs_stream_events
464
+ WHERE queue = ? AND job_id = ? AND id > ?
465
+ ORDER BY id ASC
466
+ LIMIT ?
467
+ `
468
+ ).all(params.queue, params.jobId, after, limit + 1);
469
+ const hasMore = rows.length > limit;
470
+ const items = rows.slice(0, limit).map((row) => this.mapStreamRow(row));
471
+ return {
472
+ items,
473
+ nextCursor: items.at(-1)?.id,
474
+ hasMore
475
+ };
476
+ }
477
+ async subscribeJobStream(params) {
478
+ const key = this.getStreamKey(params.queue, params.jobId);
479
+ const set = this.streamSubscribers.get(key) ?? /* @__PURE__ */ new Set();
480
+ set.add(params.handler);
481
+ this.streamSubscribers.set(key, set);
482
+ return async () => {
483
+ const current = this.streamSubscribers.get(key);
484
+ if (!current) return;
485
+ current.delete(params.handler);
486
+ if (current.size === 0) this.streamSubscribers.delete(key);
487
+ };
488
+ }
489
+ async shutdown() {
490
+ for (const worker of this.workers.values()) {
491
+ await this.closeWorkerState(worker);
492
+ }
493
+ this.workers.clear();
494
+ for (const runtime of this.queueRuntimes.values()) {
495
+ runtime.queue.close();
496
+ }
497
+ this.queueRuntimes.clear();
498
+ this.subscribers.clear();
499
+ this.streamSubscribers.clear();
500
+ if (this.streamDatabasePromise) {
501
+ const db = await this.streamDatabasePromise;
502
+ db.close();
503
+ this.streamDatabasePromise = null;
504
+ }
505
+ if (this.bunqueueModulePromise) {
506
+ const bunqueue = await this.bunqueueModulePromise;
507
+ bunqueue.shutdownManager();
508
+ }
509
+ }
510
+ async loadBunqueue() {
511
+ if (typeof globalThis.Bun === "undefined") {
512
+ throw new IgniterJobsError({
513
+ code: "JOBS_ADAPTER_ERROR",
514
+ message: "The Bun SQLite adapter can only run inside Bun. Use the BullMQ adapter in Node.js runtimes."
515
+ });
516
+ }
517
+ this.bunqueueModulePromise ?? (this.bunqueueModulePromise = import('bunqueue/client'));
518
+ return this.bunqueueModulePromise;
519
+ }
520
+ async getStreamDatabase() {
521
+ this.streamDatabasePromise ?? (this.streamDatabasePromise = (async () => {
522
+ await mkdir(dirname(this.options.path), { recursive: true });
523
+ const sqlite = await import('bun:sqlite');
524
+ const db = new sqlite.Database(this.options.path, {
525
+ create: true
526
+ });
527
+ db.exec(`
528
+ CREATE TABLE IF NOT EXISTS igniter_jobs_stream_events (
529
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
530
+ queue TEXT NOT NULL,
531
+ job_id TEXT NOT NULL,
532
+ job_name TEXT NOT NULL,
533
+ type TEXT NOT NULL,
534
+ data TEXT NOT NULL,
535
+ timestamp TEXT NOT NULL,
536
+ scope TEXT
537
+ );
538
+ CREATE INDEX IF NOT EXISTS idx_igniter_jobs_stream_events_job
539
+ ON igniter_jobs_stream_events(queue, job_id, id);
540
+ `);
541
+ return db;
542
+ })());
543
+ return this.streamDatabasePromise;
544
+ }
545
+ async getQueue(queueName) {
546
+ const existing = this.queueRuntimes.get(queueName);
547
+ if (existing) return existing.queue;
548
+ const bunqueue = await this.loadBunqueue();
549
+ await mkdir(dirname(this.options.path), { recursive: true });
550
+ const queue = new bunqueue.Queue(queueName, {
551
+ embedded: true,
552
+ dataPath: this.options.path,
553
+ defaultJobOptions: {
554
+ durable: this.options.durable
555
+ }
556
+ });
557
+ await queue.waitUntilReady();
558
+ this.queueRuntimes.set(queueName, { queue, cronsSynced: false });
559
+ return queue;
560
+ }
561
+ async getAllQueueNames() {
562
+ return Array.from(
563
+ /* @__PURE__ */ new Set([
564
+ ...this.registeredJobs.keys(),
565
+ ...this.registeredCrons.keys(),
566
+ ...this.queueRuntimes.keys()
567
+ ])
568
+ );
569
+ }
570
+ markQueueCronsDirty(queueName) {
571
+ const runtime = this.queueRuntimes.get(queueName);
572
+ if (runtime) runtime.cronsSynced = false;
573
+ }
574
+ async syncCronSchedulers(queueName) {
575
+ const runtime = this.queueRuntimes.get(queueName);
576
+ if (!runtime || runtime.cronsSynced) return;
577
+ const cronDefinitions = this.registeredCrons.get(queueName) ?? /* @__PURE__ */ new Map();
578
+ const existing = await runtime.queue.getJobSchedulers(0, 1e4);
579
+ const existingIds = new Set(existing.map((scheduler) => scheduler.id));
580
+ for (const [cronName, definition] of cronDefinitions.entries()) {
581
+ await runtime.queue.upsertJobScheduler(
582
+ cronName,
583
+ {
584
+ pattern: definition.cron,
585
+ limit: definition.maxExecutions,
586
+ timezone: definition.tz
587
+ },
588
+ {
589
+ name: cronName,
590
+ data: this.createEnvelope(
591
+ void 0,
592
+ void 0,
593
+ void 0,
594
+ definition
595
+ ),
596
+ opts: { durable: this.options.durable }
597
+ }
598
+ );
599
+ existingIds.delete(cronName);
600
+ }
601
+ for (const schedulerId of existingIds) {
602
+ await runtime.queue.removeJobScheduler(schedulerId);
603
+ }
604
+ runtime.cronsSynced = true;
605
+ }
606
+ createEnvelope(input, metadata, scope, scheduleSource) {
607
+ return {
608
+ __igniterJobs: true,
609
+ input,
610
+ metadata,
611
+ scope,
612
+ schedule: scheduleSource ? {
613
+ skipWeekends: scheduleSource.skipWeekends,
614
+ onlyBusinessHours: scheduleSource.onlyBusinessHours,
615
+ businessHours: scheduleSource.businessHours,
616
+ onlyWeekdays: scheduleSource.onlyWeekdays,
617
+ skipDates: scheduleSource.skipDates?.map(
618
+ (value) => value instanceof Date ? value.toISOString() : value
619
+ )
620
+ } : void 0
621
+ };
622
+ }
623
+ toJobOptions(params, overrides) {
624
+ return {
625
+ jobId: params.jobId,
626
+ priority: params.priority,
627
+ delay: params.delay,
628
+ attempts: params.attempts,
629
+ removeOnComplete: params.removeOnComplete,
630
+ removeOnFail: params.removeOnFail,
631
+ durable: this.options.durable,
632
+ ...overrides
633
+ };
634
+ }
635
+ async processJob(workerState, queueName, job) {
636
+ const envelope = this.normalizeEnvelope(job.data);
637
+ if (!this.shouldExecuteScheduledJob(envelope.schedule, /* @__PURE__ */ new Date())) {
638
+ await this.writeLog(
639
+ queueName,
640
+ job.id,
641
+ "info",
642
+ "Scheduled run skipped by adapter rules."
643
+ );
644
+ return { skipped: true };
645
+ }
646
+ const registeredJob = this.registeredJobs.get(queueName)?.get(job.name);
647
+ if (registeredJob) {
648
+ return this.processRegisteredDefinition(
649
+ workerState,
650
+ queueName,
651
+ job,
652
+ envelope,
653
+ registeredJob
654
+ );
655
+ }
656
+ const registeredCron = this.registeredCrons.get(queueName)?.get(job.name);
657
+ if (registeredCron) {
658
+ return this.processRegisteredCron(
659
+ queueName,
660
+ job,
661
+ envelope,
662
+ registeredCron
663
+ );
664
+ }
665
+ throw new IgniterJobsError({
666
+ code: "JOBS_NOT_REGISTERED",
667
+ message: `Job "${job.name}" is not registered for queue "${queueName}".`
668
+ });
669
+ }
670
+ async processRegisteredDefinition(workerState, queueName, job, envelope, definition) {
671
+ const baseContext = this.createExecutionContext(
672
+ queueName,
673
+ job,
674
+ envelope,
675
+ definition
676
+ );
677
+ const startedAt = /* @__PURE__ */ new Date();
678
+ await this.writeLog(queueName, job.id, "info", "Job started.");
679
+ await this.invokeHook(
680
+ () => definition.onStart?.({ ...baseContext, startedAt })
681
+ );
682
+ try {
683
+ const result = await definition.handler(baseContext);
684
+ const duration = Date.now() - startedAt.getTime();
685
+ workerState.metrics.processed += 1;
686
+ workerState.metrics.totalDuration += duration;
687
+ await this.writeLog(
688
+ queueName,
689
+ job.id,
690
+ "info",
691
+ `Job completed in ${duration}ms.`
692
+ );
693
+ await this.invokeHook(
694
+ () => definition.onSuccess?.({
695
+ ...baseContext,
696
+ startedAt,
697
+ duration,
698
+ result
699
+ })
700
+ );
701
+ return result;
702
+ } catch (error) {
703
+ const runtimeError = error instanceof Error ? error : new Error(String(error));
704
+ const duration = Date.now() - startedAt.getTime();
705
+ const maxAttempts = definition.attempts ?? job.opts.attempts ?? 1;
706
+ const isFinalAttempt = job.attemptsMade >= maxAttempts;
707
+ workerState.metrics.failed += 1;
708
+ await this.writeLog(queueName, job.id, "error", runtimeError.message);
709
+ await this.invokeHook(
710
+ () => definition.onFailure?.({
711
+ ...baseContext,
712
+ startedAt,
713
+ duration,
714
+ error: runtimeError,
715
+ isFinalAttempt
716
+ })
717
+ );
718
+ throw runtimeError;
719
+ }
720
+ }
721
+ async processRegisteredCron(queueName, job, envelope, definition) {
722
+ await this.writeLog(queueName, job.id, "info", "Cron execution started.");
723
+ try {
724
+ const result = await definition.handler({
725
+ context: {},
726
+ job: {
727
+ id: job.id,
728
+ name: job.name,
729
+ queue: queueName,
730
+ attemptsMade: job.attemptsMade,
731
+ createdAt: new Date(job.timestamp),
732
+ metadata: envelope.metadata
733
+ },
734
+ scope: envelope.scope
735
+ });
736
+ await this.writeLog(
737
+ queueName,
738
+ job.id,
739
+ "info",
740
+ "Cron execution completed."
741
+ );
742
+ return result;
743
+ } catch (error) {
744
+ const runtimeError = error instanceof Error ? error : new Error(String(error));
745
+ await this.writeLog(queueName, job.id, "error", runtimeError.message);
746
+ throw runtimeError;
747
+ }
748
+ }
749
+ createExecutionContext(queueName, job, envelope, definition) {
750
+ const baseContext = {
751
+ input: envelope.input,
752
+ context: {},
753
+ job: {
754
+ id: job.id,
755
+ name: job.name,
756
+ queue: queueName,
757
+ attemptsMade: job.attemptsMade,
758
+ createdAt: new Date(job.timestamp),
759
+ metadata: envelope.metadata,
760
+ updateProgress: async (progress, message) => {
761
+ await job.updateProgress(progress, message);
762
+ await this.writeLog(
763
+ queueName,
764
+ job.id,
765
+ "info",
766
+ `Progress updated to ${progress}%.`
767
+ );
768
+ await this.invokeHook(
769
+ () => definition.onProgress?.({
770
+ ...baseContext,
771
+ progress,
772
+ message
773
+ })
774
+ );
775
+ }
776
+ },
777
+ scope: envelope.scope
778
+ };
779
+ return baseContext;
780
+ }
781
+ attachWorkerEvents(workerState, queueName, worker) {
782
+ worker.on("active", async (job) => {
783
+ workerState.activeJobs.add(job.id);
784
+ await this.invokeHandler(
785
+ () => workerState.handlers?.onActive?.({
786
+ job: this.mapJobSync(job, queueName)
787
+ })
788
+ );
789
+ });
790
+ worker.on("completed", async (job, result) => {
791
+ workerState.activeJobs.delete(job.id);
792
+ await this.invokeHandler(
793
+ () => workerState.handlers?.onSuccess?.({
794
+ job: this.mapJobSync(job, queueName, "completed"),
795
+ result
796
+ })
797
+ );
798
+ });
799
+ worker.on("failed", async (job, error) => {
800
+ workerState.activeJobs.delete(job.id);
801
+ await this.invokeHandler(
802
+ () => workerState.handlers?.onFailure?.({
803
+ job: this.mapJobSync(job, queueName, "failed"),
804
+ error
805
+ })
806
+ );
807
+ });
808
+ worker.on("drained", async () => {
809
+ if (workerState.activeJobs.size === 0) {
810
+ await this.invokeHandler(() => workerState.handlers?.onIdle?.());
811
+ }
812
+ });
813
+ worker.on("error", async (error) => {
814
+ await this.writeQueueLog(
815
+ queueName,
816
+ "error",
817
+ `Worker error: ${error.message}`
818
+ );
819
+ });
820
+ }
821
+ toWorkerHandle(worker) {
822
+ return {
823
+ id: worker.id,
824
+ queues: [...worker.queues],
825
+ pause: async () => {
826
+ worker.paused = true;
827
+ for (const runtime of worker.workers.values()) runtime.pause();
828
+ },
829
+ resume: async () => {
830
+ worker.paused = false;
831
+ for (const runtime of worker.workers.values()) runtime.resume();
832
+ },
833
+ close: async () => {
834
+ await this.closeWorkerState(worker);
835
+ },
836
+ isRunning: () => !worker.closed && !worker.paused,
837
+ isPaused: () => worker.paused,
838
+ isClosed: () => worker.closed,
839
+ getMetrics: async () => this.buildWorkerMetrics(worker)
840
+ };
841
+ }
842
+ buildWorkerMetrics(worker) {
843
+ return {
844
+ processed: worker.metrics.processed,
845
+ failed: worker.metrics.failed,
846
+ avgDuration: worker.metrics.processed > 0 ? worker.metrics.totalDuration / worker.metrics.processed : 0,
847
+ concurrency: worker.concurrency,
848
+ uptime: Date.now() - worker.startedAt.getTime()
849
+ };
850
+ }
851
+ async closeWorkerState(worker) {
852
+ worker.closed = true;
853
+ await Promise.all(
854
+ Array.from(worker.workers.values()).map((runtime) => runtime.close())
855
+ );
856
+ worker.workers.clear();
857
+ }
858
+ async requireJob(jobId, queue) {
859
+ const queueName = queue ?? await this.findQueueByJobId(jobId);
860
+ if (!queueName) {
861
+ throw new IgniterJobsError({
862
+ code: "JOBS_NOT_FOUND",
863
+ message: `Job "${jobId}" not found.`
864
+ });
865
+ }
866
+ const job = await this.getQueue(queueName).then(
867
+ (queueClient) => queueClient.getJob(jobId)
868
+ );
869
+ if (!job) {
870
+ throw new IgniterJobsError({
871
+ code: "JOBS_NOT_FOUND",
872
+ message: `Job "${jobId}" not found${queue ? ` in queue "${queue}"` : ""}.`
873
+ });
874
+ }
875
+ return job;
876
+ }
877
+ async findQueueByJobId(jobId) {
878
+ for (const queueName of await this.getAllQueueNames()) {
879
+ const job = await this.getQueue(queueName).then(
880
+ (queueClient) => queueClient.getJob(jobId)
881
+ );
882
+ if (job) return queueName;
883
+ }
884
+ return null;
885
+ }
886
+ getStreamKey(queue, jobId) {
887
+ return `${queue}:${jobId}`;
888
+ }
889
+ mapStreamRow(row) {
890
+ return {
891
+ id: String(row.id),
892
+ type: row.type,
893
+ data: JSON.parse(row.data),
894
+ timestamp: new Date(row.timestamp),
895
+ jobId: row.job_id,
896
+ jobName: row.job_name,
897
+ queue: row.queue,
898
+ scope: row.scope ? JSON.parse(row.scope) : void 0
899
+ };
900
+ }
901
+ async mapJob(job, queueName, forcedState) {
902
+ const queue = await this.getQueue(queueName);
903
+ const rawState = await job.getState();
904
+ return this.mapJobSync(
905
+ job,
906
+ queueName,
907
+ forcedState ?? this.toPublicStatus(rawState, queue.isPaused())
908
+ );
909
+ }
910
+ mapJobSync(job, queueName, status) {
911
+ const envelope = this.normalizeEnvelope(job.data);
912
+ return {
913
+ id: job.id,
914
+ name: job.name,
915
+ queue: queueName,
916
+ status: status ?? this.toPublicStatus("waiting", false),
917
+ input: envelope.input,
918
+ result: job.returnvalue,
919
+ error: job.failedReason ?? void 0,
920
+ progress: typeof job.progress === "number" ? job.progress : 0,
921
+ attemptsMade: job.attemptsMade,
922
+ priority: job.priority ?? 0,
923
+ createdAt: new Date(job.timestamp),
924
+ startedAt: job.processedOn ? new Date(job.processedOn) : void 0,
925
+ completedAt: job.finishedOn ? new Date(job.finishedOn) : void 0,
926
+ metadata: envelope.metadata,
927
+ scope: envelope.scope
928
+ };
929
+ }
930
+ normalizeEnvelope(data) {
931
+ if (data && typeof data === "object" && data.__igniterJobs === true) {
932
+ return data;
933
+ }
934
+ return {
935
+ __igniterJobs: true,
936
+ input: data
937
+ };
938
+ }
939
+ toPublicStatus(rawState, isQueuePaused) {
940
+ if ((rawState === "waiting" || rawState === "prioritized") && isQueuePaused) {
941
+ return "paused";
942
+ }
943
+ switch (rawState) {
944
+ case "prioritized":
945
+ case "waiting-children":
946
+ return "waiting";
947
+ case "waiting":
948
+ case "active":
949
+ case "completed":
950
+ case "failed":
951
+ case "delayed":
952
+ case "paused":
953
+ return rawState;
954
+ default:
955
+ return "waiting";
956
+ }
957
+ }
958
+ toBunqueueStatus(status) {
959
+ switch (status) {
960
+ case "waiting":
961
+ return "waiting";
962
+ default:
963
+ return status;
964
+ }
965
+ }
966
+ toBunqueueStates(status) {
967
+ switch (status) {
968
+ case "waiting":
969
+ return ["waiting", "prioritized"];
970
+ case "paused":
971
+ return ["paused"];
972
+ default:
973
+ return [status];
974
+ }
975
+ }
976
+ distributeConcurrency(total, queues) {
977
+ const result = /* @__PURE__ */ new Map();
978
+ if (queues.length === 0) return result;
979
+ const base = Math.max(1, Math.floor(total / queues.length));
980
+ let remainder = Math.max(0, total - base * queues.length);
981
+ for (const queue of queues) {
982
+ const value = base + (remainder > 0 ? 1 : 0);
983
+ result.set(queue, value);
984
+ remainder = Math.max(0, remainder - 1);
985
+ }
986
+ return result;
987
+ }
988
+ shouldExecuteScheduledJob(schedule, now) {
989
+ if (!schedule) return true;
990
+ const day = now.getDay();
991
+ const isoDate = now.toISOString().slice(0, 10);
992
+ if (schedule.skipWeekends && (day === 0 || day === 6)) return false;
993
+ if (schedule.onlyWeekdays?.length && !schedule.onlyWeekdays.includes(day))
994
+ return false;
995
+ if (schedule.skipDates?.some((value) => value.slice(0, 10) === isoDate))
996
+ return false;
997
+ if (schedule.onlyBusinessHours && schedule.businessHours) {
998
+ const hour = now.getHours();
999
+ return hour >= schedule.businessHours.start && hour < schedule.businessHours.end;
1000
+ }
1001
+ return true;
1002
+ }
1003
+ parseLogEntry(entry) {
1004
+ try {
1005
+ const parsed = JSON.parse(entry);
1006
+ if (parsed && typeof parsed.message === "string" && typeof parsed.timestamp === "string" && (parsed.level === "info" || parsed.level === "warn" || parsed.level === "error")) {
1007
+ return {
1008
+ timestamp: new Date(parsed.timestamp),
1009
+ level: parsed.level,
1010
+ message: parsed.message
1011
+ };
1012
+ }
1013
+ } catch {
1014
+ }
1015
+ return {
1016
+ timestamp: /* @__PURE__ */ new Date(),
1017
+ level: "info",
1018
+ message: entry
1019
+ };
1020
+ }
1021
+ async writeLog(queueName, jobId, level, message) {
1022
+ const queue = await this.getQueue(queueName);
1023
+ await queue.addJobLog(
1024
+ jobId,
1025
+ JSON.stringify({ timestamp: (/* @__PURE__ */ new Date()).toISOString(), level, message })
1026
+ );
1027
+ }
1028
+ async writeQueueLog(queueName, level, message) {
1029
+ }
1030
+ async invokeHook(fn) {
1031
+ try {
1032
+ await fn();
1033
+ } catch {
1034
+ }
1035
+ }
1036
+ async invokeHandler(fn) {
1037
+ try {
1038
+ await fn();
1039
+ } catch {
1040
+ }
1041
+ }
1042
+ };
1043
+
1044
+ export { IgniterJobsBunSQLiteAdapter };
1045
+ //# sourceMappingURL=bun.mjs.map
1046
+ //# sourceMappingURL=bun.mjs.map