bunqueue 2.8.28 → 2.8.29

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.
@@ -22,7 +22,7 @@ export declare function getDlqStats(queue: string, ctx: DlqContext): DlqStats;
22
22
  /** Retry a single job from DLQ */
23
23
  export declare function retryDlqJob(queue: string, jobId: JobId, ctx: DlqContext): Job | null;
24
24
  /** Retry jobs from DLQ (backward compatible) */
25
- export declare function retryDlqJobs(queue: string, ctx: DlqContext, jobId?: JobId): number;
25
+ export declare function retryDlqJobs(queue: string, ctx: DlqContext, jobId?: JobId, limit?: number): number;
26
26
  /** Retry jobs by filter */
27
27
  export declare function retryDlqByFilter(queue: string, ctx: DlqContext, filter: DlqFilter): number;
28
28
  /** Process auto-retry for a queue */
@@ -86,10 +86,34 @@ export function retryDlqJob(queue, jobId, ctx) {
86
86
  return job;
87
87
  }
88
88
  /** Retry jobs from DLQ (backward compatible) */
89
- export function retryDlqJobs(queue, ctx, jobId) {
89
+ export function retryDlqJobs(queue, ctx, jobId, limit) {
90
90
  if (jobId) {
91
91
  return retryDlqJob(queue, jobId, ctx) ? 1 : 0;
92
92
  }
93
+ // Bounded retry (#111-class: `retryJobs({ state:'failed', count })`). Retry
94
+ // only the first `limit` DLQ entries, reusing the tested single-entry path so
95
+ // the remaining entries stay in the DLQ (memory + SQLite) instead of the
96
+ // clear-all fast path below wrongly draining the whole queue. Any provided
97
+ // `limit` engages the bounded path; a negative/NaN/fractional cap is clamped
98
+ // to a safe non-negative integer so it can never fall through to clear-all
99
+ // (skeptic: `limit >= 0` let `-1`/`NaN` drain the entire DLQ). `undefined`
100
+ // (no cap requested) still means "retry all" via the fast path below.
101
+ if (limit !== undefined) {
102
+ const cap = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : 0;
103
+ const idx = shardIndex(queue);
104
+ const shard = ctx.shards[idx];
105
+ // Snapshot ids first: retryDlqJob mutates the DLQ as it goes.
106
+ const ids = shard
107
+ .getDlqEntries(queue)
108
+ .slice(0, cap)
109
+ .map((e) => e.job.id);
110
+ let retried = 0;
111
+ for (const id of ids) {
112
+ if (retryDlqJob(queue, id, ctx))
113
+ retried++;
114
+ }
115
+ return retried;
116
+ }
93
117
  const idx = shardIndex(queue);
94
118
  const shard = ctx.shards[idx];
95
119
  const entries = shard.getDlqEntries(queue);
@@ -193,7 +193,7 @@ export declare class QueueManager {
193
193
  getDlqEntries(queue: string, filter?: DlqFilter): DlqEntry[];
194
194
  getDlqCount(queue: string): number;
195
195
  getDlqStats(queue: string): DlqStats;
196
- retryDlq(queue: string, jobId?: JobId): number;
196
+ retryDlq(queue: string, jobId?: JobId, limit?: number): number;
197
197
  purgeDlq(queue: string): number;
198
198
  retryCompleted(queue: string, jobId?: JobId): number;
199
199
  setRateLimit(queue: string, limit: number): void;
@@ -849,8 +849,8 @@ export class QueueManager {
849
849
  getDlqStats(queue) {
850
850
  return dlqOps.getDlqStats(queue, this.contextFactory.getDlqContext());
851
851
  }
852
- retryDlq(queue, jobId) {
853
- return dlqOps.retryDlqJobs(queue, this.contextFactory.getDlqContext(), jobId);
852
+ retryDlq(queue, jobId, limit) {
853
+ return dlqOps.retryDlqJobs(queue, this.contextFactory.getDlqContext(), jobId, limit);
854
854
  }
855
855
  purgeDlq(queue) {
856
856
  return dlqOps.purgeDlqJobs(queue, this.contextFactory.getDlqContext());
@@ -43,9 +43,11 @@ export declare class Bunqueue<T = unknown, R = unknown> {
43
43
  countAsync(): Promise<number>;
44
44
  cron(id: string, pattern: string, data?: T, opts?: {
45
45
  timezone?: string;
46
+ limit?: number;
46
47
  jobOpts?: JobOptions;
47
48
  }): Promise<SchedulerInfo | null>;
48
49
  every(id: string, intervalMs: number, data?: T, opts?: {
50
+ limit?: number;
49
51
  jobOpts?: JobOptions;
50
52
  }): Promise<SchedulerInfo | null>;
51
53
  removeCron(id: string): Promise<boolean>;
@@ -171,10 +171,10 @@ export class Bunqueue {
171
171
  }
172
172
  // ============ Cron ============
173
173
  cron(id, pattern, data, opts) {
174
- return this.queue.upsertJobScheduler(id, { pattern, timezone: opts?.timezone }, { name: id, data, opts: opts?.jobOpts });
174
+ return this.queue.upsertJobScheduler(id, { pattern, timezone: opts?.timezone, limit: opts?.limit }, { name: id, data, opts: opts?.jobOpts });
175
175
  }
176
176
  every(id, intervalMs, data, opts) {
177
- return this.queue.upsertJobScheduler(id, { every: intervalMs }, { name: id, data, opts: opts?.jobOpts });
177
+ return this.queue.upsertJobScheduler(id, { every: intervalMs, limit: opts?.limit }, { name: id, data, opts: opts?.jobOpts });
178
178
  }
179
179
  removeCron(id) {
180
180
  return this.queue.removeJobScheduler(id);
@@ -3,6 +3,7 @@
3
3
  * Creates simple Job objects for FlowProducer results
4
4
  */
5
5
  import { getSharedManager } from './manager';
6
+ import { buildFailCommand, failEmbeddedArgs } from './queue/failWire';
6
7
  import { jobId } from '../domain/types/job';
7
8
  /** Extract user data (remove internal fields like __parentId, __childrenIds, name) */
8
9
  export function extractUserDataFromInternal(data) {
@@ -254,12 +255,13 @@ export function createFlowJobObject(id, name, data, queueName, callbacks) {
254
255
  return null;
255
256
  },
256
257
  moveToFailed: async (error) => {
258
+ // Forward stacktrace + UnrecoverableError (was dropped; #74/#111-class).
257
259
  if (embedded) {
258
- await getSharedManager().fail(jobId(id), error.message);
260
+ await getSharedManager().fail(jobId(id), ...failEmbeddedArgs(error));
259
261
  return;
260
262
  }
261
263
  if (tcp)
262
- await tcp.send({ cmd: 'FAIL', id, error: error.message });
264
+ await tcp.send(buildFailCommand(id, error));
263
265
  },
264
266
  moveToWait: async () => {
265
267
  if (embedded)
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Shared FAIL payload builder.
3
+ *
4
+ * Several client entry points move a job to `failed` (the Queue reflection API
5
+ * `queue.getJob(id).moveToFailed()`, the job proxies, and `moveJobToFailed`).
6
+ * They must all persist the stacktrace (#74) and honour `UnrecoverableError`,
7
+ * exactly like the worker failure path — otherwise the stack and the
8
+ * "do not retry" intent are silently dropped (the #111 silent-loss class).
9
+ * Centralising the mapping here keeps every site consistent.
10
+ */
11
+ /** Build the TCP `FAIL` command for an explicit job failure. */
12
+ export declare function buildFailCommand(id: string, error: Error, token?: string): Record<string, unknown>;
13
+ /**
14
+ * Decompose an error into the trailing args of the embedded
15
+ * `manager.fail(jobId, error?, token?, unrecoverable?, stack?)` signature.
16
+ */
17
+ export declare function failEmbeddedArgs(error: Error, token?: string): [string, string | undefined, boolean, string[] | undefined];
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Shared FAIL payload builder.
3
+ *
4
+ * Several client entry points move a job to `failed` (the Queue reflection API
5
+ * `queue.getJob(id).moveToFailed()`, the job proxies, and `moveJobToFailed`).
6
+ * They must all persist the stacktrace (#74) and honour `UnrecoverableError`,
7
+ * exactly like the worker failure path — otherwise the stack and the
8
+ * "do not retry" intent are silently dropped (the #111 silent-loss class).
9
+ * Centralising the mapping here keeps every site consistent.
10
+ */
11
+ import { UnrecoverableError } from '../errors';
12
+ import { computeStackLines } from '../worker/processorHandlers';
13
+ /** Build the TCP `FAIL` command for an explicit job failure. */
14
+ export function buildFailCommand(id, error, token) {
15
+ const { wireStack } = computeStackLines(error);
16
+ return {
17
+ cmd: 'FAIL',
18
+ id,
19
+ error: error.message,
20
+ ...(wireStack ? { stack: wireStack } : {}),
21
+ ...(token ? { token } : {}),
22
+ ...(error instanceof UnrecoverableError ? { unrecoverable: true } : {}),
23
+ };
24
+ }
25
+ /**
26
+ * Decompose an error into the trailing args of the embedded
27
+ * `manager.fail(jobId, error?, token?, unrecoverable?, stack?)` signature.
28
+ */
29
+ export function failEmbeddedArgs(error, token) {
30
+ const { wireStack } = computeStackLines(error);
31
+ return [error.message, token, error instanceof UnrecoverableError, wireStack];
32
+ }
@@ -15,7 +15,7 @@ interface JobMoveContext {
15
15
  /** Move job to completed state */
16
16
  export declare function moveJobToCompleted(ctx: JobMoveContext, id: string, returnValue: unknown, _token?: string): Promise<unknown>;
17
17
  /** Move job to failed state */
18
- export declare function moveJobToFailed(ctx: JobMoveContext, id: string, error: Error, _token?: string): Promise<void>;
18
+ export declare function moveJobToFailed(ctx: JobMoveContext, id: string, error: Error, token?: string): Promise<void>;
19
19
  /** Move job back to waiting state */
20
20
  export declare function moveJobToWait(ctx: JobMoveContext, id: string, _token?: string): Promise<boolean>;
21
21
  /** Move job to delayed state */
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { getSharedManager } from '../manager';
6
6
  import { jobId } from '../../domain/types/job';
7
+ import { buildFailCommand, failEmbeddedArgs } from './failWire';
7
8
  /** Move job to completed state */
8
9
  export async function moveJobToCompleted(ctx, id, returnValue, _token) {
9
10
  if (ctx.embedded) {
@@ -14,12 +15,16 @@ export async function moveJobToCompleted(ctx, id, returnValue, _token) {
14
15
  return null;
15
16
  }
16
17
  /** Move job to failed state */
17
- export async function moveJobToFailed(ctx, id, error, _token) {
18
+ export async function moveJobToFailed(ctx, id, error, token) {
19
+ // Mirror the worker failure path: persist the stacktrace (#74) and honour
20
+ // UnrecoverableError so a job explicitly failed via the Queue reflection API
21
+ // is not silently retried and does not lose its stack. Previously both were
22
+ // dropped on this path (same silent-loss class as #111).
18
23
  if (ctx.embedded) {
19
- await getSharedManager().fail(jobId(id), error.message);
24
+ await getSharedManager().fail(jobId(id), ...failEmbeddedArgs(error, token));
20
25
  }
21
26
  else {
22
- await ctx.tcp.send({ cmd: 'FAIL', id, error: error.message });
27
+ await ctx.tcp.send(buildFailCommand(id, error, token));
23
28
  }
24
29
  }
25
30
  /** Move job back to waiting state */
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { getSharedManager } from '../manager';
6
6
  import { jobId } from '../../domain/types/job';
7
+ import { buildFailCommand, failEmbeddedArgs } from './failWire';
7
8
  function reflectFields(id, queueName, meta) {
8
9
  const opts = meta?.opts ?? {};
9
10
  const p = opts.parent;
@@ -130,7 +131,8 @@ export function createJobProxy(id, name, data, ctx, meta) {
130
131
  return null;
131
132
  },
132
133
  moveToFailed: async (error) => {
133
- await tcp.send({ cmd: 'FAIL', id, error: error.message });
134
+ // Forward stacktrace + UnrecoverableError (was dropped; #74/#111-class).
135
+ await tcp.send(buildFailCommand(id, error));
134
136
  },
135
137
  moveToWait: async () => {
136
138
  const res = await tcp.send({ cmd: 'MoveToWait', id });
@@ -352,12 +354,13 @@ export function createSimpleJob(id, name, data, timestamp, ctx) {
352
354
  return null;
353
355
  },
354
356
  moveToFailed: async (error) => {
357
+ // Forward stacktrace + UnrecoverableError (was dropped; #74/#111-class).
355
358
  if (embedded) {
356
- await getSharedManager().fail(jobId(id), error.message);
359
+ await getSharedManager().fail(jobId(id), ...failEmbeddedArgs(error));
357
360
  return;
358
361
  }
359
362
  if (tcp)
360
- await tcp.send({ cmd: 'FAIL', id, error: error.message });
363
+ await tcp.send(buildFailCommand(id, error));
361
364
  },
362
365
  moveToWait: async () => {
363
366
  if (embedded) {
@@ -56,7 +56,8 @@ export async function retryJob(ctx, id) {
56
56
  export async function retryJobs(ctx, opts) {
57
57
  if (ctx.embedded) {
58
58
  if (opts?.state === 'failed') {
59
- getSharedManager().retryDlq(ctx.name);
59
+ // #111-class: forward the count cap (was ignored → retried the whole DLQ).
60
+ getSharedManager().retryDlq(ctx.name, undefined, opts?.count);
60
61
  }
61
62
  return;
62
63
  }
@@ -45,6 +45,8 @@ export interface SchedulerInfo {
45
45
  next: number;
46
46
  pattern?: string;
47
47
  every?: number;
48
+ /** Max number of times the scheduler will fire (RepeatOpts.limit). #111 */
49
+ limit?: number;
48
50
  }
49
51
  /** Create or update a job scheduler */
50
52
  export declare function upsertJobScheduler(ctx: SchedulerContext, schedulerId: string, repeatOpts: RepeatOpts, jobTemplate?: JobTemplate): Promise<SchedulerInfo | null>;
@@ -78,6 +78,8 @@ export async function upsertJobScheduler(ctx, schedulerId, repeatOpts, jobTempla
78
78
  repeatEvery,
79
79
  priority,
80
80
  timezone: repeatOpts.timezone ?? 'UTC',
81
+ // #111: forward the run cap; addCron ignores <=0/undefined (stored NULL).
82
+ maxLimit: repeatOpts.limit,
81
83
  skipMissedOnRestart: repeatOpts.skipMissedOnRestart,
82
84
  immediately: repeatOpts.immediately,
83
85
  skipIfNoWorker: repeatOpts.skipIfNoWorker,
@@ -89,6 +91,7 @@ export async function upsertJobScheduler(ctx, schedulerId, repeatOpts, jobTempla
89
91
  id: schedulerId,
90
92
  name: jobTemplate?.name ?? 'default',
91
93
  next: Date.now() + (repeatEvery ?? 60000),
94
+ limit: repeatOpts.limit && repeatOpts.limit > 0 ? repeatOpts.limit : undefined,
92
95
  };
93
96
  }
94
97
  const response = await ctx.tcp.send({
@@ -100,6 +103,8 @@ export async function upsertJobScheduler(ctx, schedulerId, repeatOpts, jobTempla
100
103
  repeatEvery,
101
104
  priority,
102
105
  timezone: repeatOpts.timezone,
106
+ // #111: forward the run cap on the TCP path too.
107
+ maxLimit: repeatOpts.limit,
103
108
  skipMissedOnRestart: repeatOpts.skipMissedOnRestart,
104
109
  immediately: repeatOpts.immediately,
105
110
  skipIfNoWorker: repeatOpts.skipIfNoWorker,
@@ -113,6 +118,7 @@ export async function upsertJobScheduler(ctx, schedulerId, repeatOpts, jobTempla
113
118
  id: schedulerId,
114
119
  name: jobTemplate?.name ?? 'default',
115
120
  next: (response.nextRun ?? Date.now()),
121
+ limit: repeatOpts.limit && repeatOpts.limit > 0 ? repeatOpts.limit : undefined,
116
122
  };
117
123
  }
118
124
  /** Remove a job scheduler */
@@ -139,6 +145,7 @@ export async function getJobScheduler(ctx, schedulerId) {
139
145
  next: cron.nextRun,
140
146
  pattern: cron.schedule ?? undefined,
141
147
  every: cron.repeatEvery ?? undefined,
148
+ limit: cron.maxLimit ?? undefined,
142
149
  };
143
150
  }
144
151
  const response = await ctx.tcp.send({ cmd: 'CronList' });
@@ -154,6 +161,7 @@ export async function getJobScheduler(ctx, schedulerId) {
154
161
  next: cron.nextRun,
155
162
  pattern: cron.schedule ?? undefined,
156
163
  every: cron.repeatEvery ?? undefined,
164
+ limit: cron.maxLimit ?? undefined,
157
165
  };
158
166
  }
159
167
  /** Get all job schedulers for this queue */
@@ -168,6 +176,7 @@ export async function getJobSchedulers(ctx, _start = 0, _end = -1, _asc = true)
168
176
  next: c.nextRun,
169
177
  pattern: c.schedule ?? undefined,
170
178
  every: c.repeatEvery ?? undefined,
179
+ limit: c.maxLimit ?? undefined,
171
180
  }));
172
181
  }
173
182
  const response = await ctx.tcp.send({ cmd: 'CronList' });
@@ -182,6 +191,7 @@ export async function getJobSchedulers(ctx, _start = 0, _end = -1, _asc = true)
182
191
  next: c.nextRun,
183
192
  pattern: c.schedule ?? undefined,
184
193
  every: c.repeatEvery ?? undefined,
194
+ limit: c.maxLimit ?? undefined,
185
195
  }));
186
196
  }
187
197
  /** Get count of job schedulers */
@@ -7,6 +7,7 @@ import { getSharedManager } from '../manager';
7
7
  import { getSharedPool, releaseSharedPool } from '../tcpPool';
8
8
  import { createPublicJob } from '../jobConversion';
9
9
  import { jobId } from '../../domain/types/job';
10
+ import { buildFailCommand, failEmbeddedArgs } from '../queue/failWire';
10
11
  import { createProgressHandler, createLogHandler, createGetStateHandler, createGetChildrenValuesHandler, createGetFailedChildrenValuesHandler, createGetIgnoredChildrenFailuresHandler, createRemoveChildDependencyHandler, createRemoveUnprocessedChildrenHandler, createRemoveHandler, createRetryHandler, createUpdateDataHandler, createPromoteHandler, createChangeDelayHandler, createChangePriorityHandler, createExtendLockHandler, createClearLogsHandler, createMoveToWaitHandler, createMoveToDelayedHandler, createMoveToWaitingChildrenHandler, createWaitUntilFinishedHandler, createDiscardHandler, createGetDependenciesHandler, createGetDependenciesCountHandler, createRemoveDeduplicationKeyHandler, } from '../worker/processorHandlers';
11
12
  import { createWrapperScript, cleanupWrapperScript } from './wrapper';
12
13
  import { createEmbeddedOps, createTcpOps } from './queueOps';
@@ -514,12 +515,13 @@ export class SandboxedWorker extends EventEmitter {
514
515
  }
515
516
  return null;
516
517
  };
517
- const moveToFailed = async (id, error, _token) => {
518
+ const moveToFailed = async (id, error, token) => {
519
+ // Forward stacktrace + UnrecoverableError (was dropped; #74/#111-class).
518
520
  if (embedded) {
519
- await getSharedManager().fail(jobId(id), error.message);
521
+ await getSharedManager().fail(jobId(id), ...failEmbeddedArgs(error, token));
520
522
  }
521
523
  else if (tcp) {
522
- await tcp.send({ cmd: 'FAIL', id, error: error.message });
524
+ await tcp.send(buildFailCommand(id, error, token));
523
525
  }
524
526
  };
525
527
  return createPublicJob({
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { jobId } from '../../domain/types/job';
6
6
  import { getSharedManager } from '../manager';
7
+ import { UnrecoverableError } from '../errors';
7
8
  export function createProgressHandler(embedded, tcp, emitter, jobHolder) {
8
9
  return async (id, progress, message) => {
9
10
  if (embedded) {
@@ -126,9 +127,13 @@ export function createMoveToFailedHandler(embedded, tcp, internalJob, token, onC
126
127
  // natural-throw path already does; @arthurvanl's repro showed the manual
127
128
  // path lost it. Compute before the send so the server can persist it.
128
129
  const { wireStack } = computeStackLines(error);
130
+ // Honour UnrecoverableError on the in-processor moveToFailed() path too, so
131
+ // it matches the natural-throw and Queue-reflection paths (skeptic: this was
132
+ // the one site still retrying an UnrecoverableError). #74/#82/#111-class.
133
+ const unrecoverable = error instanceof UnrecoverableError;
129
134
  if (embedded) {
130
135
  const manager = getSharedManager();
131
- await manager.fail(internalJob.id, error.message, token ?? undefined, undefined, wireStack);
136
+ await manager.fail(internalJob.id, error.message, token ?? undefined, unrecoverable, wireStack);
132
137
  }
133
138
  else if (tcp) {
134
139
  await tcp.send({
@@ -137,6 +142,7 @@ export function createMoveToFailedHandler(embedded, tcp, internalJob, token, onC
137
142
  error: error.message,
138
143
  ...(wireStack ? { stack: wireStack } : {}),
139
144
  ...(token ? { token } : {}),
145
+ ...(unrecoverable ? { unrecoverable: true } : {}),
140
146
  });
141
147
  }
142
148
  onCalled(error);
@@ -322,7 +322,11 @@ export class Worker extends EventEmitter {
322
322
  if (this.embedded) {
323
323
  const manager = getSharedManager();
324
324
  if (this.opts.useLocks) {
325
- const { job, token: lockToken } = await manager.pullWithLock(this.queueKey, this.workerId, 0);
325
+ // Forward the configured lock duration; without it pullWithLock falls
326
+ // back to the server default and a custom `lockDuration` is silently
327
+ // ignored on this manual-acquire path (same class as #111). The main
328
+ // run-loop path (workerPull.ts) already forwards it.
329
+ const { job, token: lockToken } = await manager.pullWithLock(this.queueKey, this.workerId, 0, this.opts.lockDuration);
326
330
  if (job && lockToken) {
327
331
  const jobIdStr = String(job.id);
328
332
  this.pulledJobIds.add(jobIdStr);
@@ -346,6 +350,10 @@ export class Worker extends EventEmitter {
346
350
  };
347
351
  if (this.opts.useLocks) {
348
352
  cmd.owner = this.workerId;
353
+ // Forward the configured lock TTL (mirrors workerPull.ts); otherwise the
354
+ // server applies its 30s default and a custom lockDuration is dropped.
355
+ if (this.opts.lockDuration !== undefined)
356
+ cmd.lockTtl = this.opts.lockDuration;
349
357
  if (token)
350
358
  cmd.token = token;
351
359
  }
@@ -237,6 +237,8 @@ export interface RetryDlqCommand extends BaseCommand {
237
237
  readonly cmd: 'RetryDlq';
238
238
  readonly queue: string;
239
239
  readonly jobId?: string;
240
+ /** Cap the number of DLQ entries retried (omit = retry all). #111-class. */
241
+ readonly count?: number;
240
242
  }
241
243
  export interface PurgeDlqCommand extends BaseCommand {
242
244
  readonly cmd: 'PurgeDlq';
@@ -12,7 +12,8 @@ export function handleDlq(cmd, ctx, reqId) {
12
12
  /** Handle RetryDlq command - retry DLQ jobs */
13
13
  export function handleRetryDlq(cmd, ctx, reqId) {
14
14
  const jid = cmd.jobId ? jobId(cmd.jobId) : undefined;
15
- const count = ctx.queueManager.retryDlq(cmd.queue, jid);
15
+ // #111-class: honour the caller's `count` cap instead of retrying the whole DLQ.
16
+ const count = ctx.queueManager.retryDlq(cmd.queue, jid, cmd.count);
16
17
  if (count > 0) {
17
18
  const event = jid ? 'dlq:retried' : 'dlq:retry-all';
18
19
  const data = { queue: cmd.queue, count };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunqueue",
3
- "version": "2.8.28",
3
+ "version": "2.8.29",
4
4
  "description": "High-performance job queue for Bun & AI agents. SQLite persistence, cron scheduling, priorities, retries, DLQ, webhooks, native MCP server. Zero external dependencies.",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",