bunqueue 2.8.2 → 2.8.4

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/README.md CHANGED
@@ -25,6 +25,14 @@
25
25
 
26
26
  ---
27
27
 
28
+ ## Requirements
29
+
30
+ bunqueue is **Bun-only** (`bun >= 1.3.9`). The client, TCP transport and persistence
31
+ rely on Bun's runtime APIs (`Bun.connect`, `Bun.file`, `Bun.hash`, …) and ship as
32
+ ESM with extensionless specifiers, so they do **not** run under Node.js. Importing
33
+ the package from Node fails fast with a clear error pointing here rather than a
34
+ cryptic resolver crash. Install Bun from [bun.sh](https://bun.sh) and run with `bun`.
35
+
28
36
  ## Quickstart
29
37
 
30
38
  ```bash
@@ -453,6 +461,27 @@ await app.close(); // graceful shutdown
453
461
  await app.close(true); // force shutdown
454
462
  ```
455
463
 
464
+ ### Inspecting jobs & counts
465
+
466
+ `getJobCounts()` and the per-state lists follow BullMQ semantics:
467
+
468
+ ```typescript
469
+ const counts = await queue.getJobCountsAsync();
470
+ // { waiting, prioritized, active, completed, failed, delayed, paused }
471
+
472
+ // Failed jobs (those that exhausted their attempts) are enumerable by state —
473
+ // the failed list reflects the same jobs that `failed` counts.
474
+ const failed = await queue.getFailedAsync(0, 50);
475
+ const alsoFailed = await queue.getJobsAsync({ state: 'failed', start: 0, end: 50 });
476
+
477
+ // Paused queue: ready jobs are reported under `paused`, never double-counted as
478
+ // `waiting`. While paused, getJobCounts() returns waiting:0 / paused:N, and the
479
+ // jobs are listed by `getJobsAsync({ state: 'paused' })` (not by `waiting`).
480
+ queue.pause();
481
+ const c = await queue.getJobCountsAsync(); // waiting: 0, paused: N
482
+ const pausedJobs = await queue.getJobsAsync({ state: 'paused', start: 0, end: 50 });
483
+ ```
484
+
456
485
  ### Full Example
457
486
 
458
487
  ```typescript
@@ -250,28 +250,55 @@ function collectWaitingChildrenFromShard(shard, queue, max) {
250
250
  }
251
251
  return wcJobs;
252
252
  }
253
+ /**
254
+ * Resolve which sources to collect for a state filter.
255
+ *
256
+ * When the queue is paused, an EXPLICIT waiting/prioritized query returns nothing:
257
+ * those jobs are reported under 'paused' instead (BullMQ semantics, #92). An
258
+ * unfiltered query (states === null) still lists them by their temporal state.
259
+ */
260
+ function resolveStateNeeds(states, paused) {
261
+ const want = (s) => !states || states.includes(s);
262
+ const suppressReady = !!states && paused;
263
+ return {
264
+ waiting: want('waiting') && !suppressReady,
265
+ prioritized: want('prioritized') && !suppressReady,
266
+ delayed: want('delayed'),
267
+ paused: !!states && states.includes('paused') && paused,
268
+ active: want('active'),
269
+ failed: want('failed'),
270
+ completed: want('completed'),
271
+ waitingChildren: want('waiting-children'),
272
+ };
273
+ }
274
+ /** When paused, the queue's ready jobs (waiting + prioritized) ARE the paused set (#92). */
275
+ function collectPausedJobs(shard, queue, maxPerSource) {
276
+ const pausedJobs = collectTemporalJobs(shard, queue, { waiting: true, prioritized: true, delayed: false }, maxPerSource);
277
+ return tagState(pausedJobs, 'paused');
278
+ }
253
279
  function collectJobsByState(queue, shardIdx, states, ctx, maxPerSource = Infinity) {
254
280
  const shard = ctx.shards[shardIdx];
255
281
  const jobs = [];
256
- const needWaiting = !states || states.includes('waiting');
257
- const needPrioritized = !states || states.includes('prioritized');
258
- const needDelayed = !states || states.includes('delayed');
259
- if (needWaiting || needPrioritized || needDelayed) {
260
- const temporal = collectTemporalJobs(shard, queue, { waiting: needWaiting, prioritized: needPrioritized, delayed: needDelayed }, maxPerSource);
282
+ const need = resolveStateNeeds(states, shard.getState(queue).paused);
283
+ if (need.waiting || need.prioritized || need.delayed) {
284
+ const temporal = collectTemporalJobs(shard, queue, { waiting: need.waiting, prioritized: need.prioritized, delayed: need.delayed }, maxPerSource);
261
285
  tagTemporalState(temporal);
262
286
  jobs.push(...temporal);
263
287
  }
264
- if (!states || states.includes('active')) {
288
+ if (need.paused) {
289
+ jobs.push(...collectPausedJobs(shard, queue, maxPerSource));
290
+ }
291
+ if (need.active) {
265
292
  jobs.push(...tagState(collectActiveJobs(queue, shardIdx, ctx, maxPerSource), 'active'));
266
293
  }
267
- if (!states || states.includes('failed')) {
294
+ if (need.failed) {
268
295
  const dlq = shard.getDlq(queue);
269
296
  jobs.push(...tagState(maxPerSource < dlq.length ? dlq.slice(0, maxPerSource) : dlq, 'failed'));
270
297
  }
271
- if (!states || states.includes('completed')) {
298
+ if (need.completed) {
272
299
  jobs.push(...tagState(collectCompletedJobs(queue, ctx, maxPerSource), 'completed'));
273
300
  }
274
- if (!states || states.includes('waiting-children')) {
301
+ if (need.waitingChildren) {
275
302
  jobs.push(...tagState(collectWaitingChildrenFromShard(shard, queue, maxPerSource), 'waiting-children'));
276
303
  }
277
304
  return jobs;
@@ -315,6 +342,14 @@ function querySqliteWithPriority(storage, queue, sqlFilteredStates, opts) {
315
342
  }
316
343
  return jobs.slice(0, opts.limit);
317
344
  }
345
+ /** Merge SQL rows with in-memory extras (each gathered from index 0), sort by
346
+ * createdAt, and paginate [start, end) once — so offset-unaware extras don't
347
+ * duplicate or drop rows across pages (#92). */
348
+ function mergePage(sqlJobs, extras, start, end, asc) {
349
+ const merged = sqlJobs.concat(extras);
350
+ merged.sort((a, b) => (asc ? a.createdAt - b.createdAt : b.createdAt - a.createdAt));
351
+ return merged.slice(start, end);
352
+ }
318
353
  /** Get jobs from queue with filters */
319
354
  export function getJobs(queue, shardIdx, options, ctx) {
320
355
  const { state, start = 0, end = 100, asc = true } = options;
@@ -327,29 +362,60 @@ export function getJobs(queue, shardIdx, options, ctx) {
327
362
  : [state];
328
363
  const limit = end - start;
329
364
  if (ctx.storage) {
365
+ const shard = ctx.shards[shardIdx];
366
+ const isPaused = shard.getState(queue).paused;
367
+ const maxPerSource = end + 1;
368
+ // Derived sources are NOT offset-aware (the DLQ and the paused/waiting-children
369
+ // views come from in-memory maps). When any contributes, we must gather [0, end)
370
+ // from EVERY source, merge, sort, then slice [start, end) exactly once — pushing
371
+ // `offset` into the SQL query would drop rows and duplicate across pages (#92).
330
372
  if (!states) {
331
- return ctx.storage.queryJobs(queue, { limit, offset: start, asc });
373
+ const dlq = tagState(shard.getDlq(queue), 'failed');
374
+ if (dlq.length === 0) {
375
+ return ctx.storage.queryJobs(queue, { limit, offset: start, asc });
376
+ }
377
+ const all = ctx.storage.queryJobs(queue, { limit: end, offset: 0, asc });
378
+ return mergePage(all, dlq, start, end, asc);
379
+ }
380
+ // States with no jobs-table row are collected from in-memory sources:
381
+ // - 'failed' -> DLQ (the failed job lives in the dlq table) (#92)
382
+ // - 'waiting-children' -> deps/children maps
383
+ // - 'paused' -> when paused, the would-be-waiting jobs ARE the paused set (#92)
384
+ const extras = [];
385
+ if (states.includes('failed')) {
386
+ extras.push(...tagState(shard.getDlq(queue), 'failed'));
387
+ }
388
+ if (states.includes('waiting-children')) {
389
+ extras.push(...collectWaitingChildrenJobs(shard, queue));
332
390
  }
333
- const hasWaitingChildren = states.includes('waiting-children');
334
- const sqlFilteredStates = states.filter((s) => s !== 'waiting-children');
335
- // Only waiting-children: collect from in-memory
336
- if (hasWaitingChildren && sqlFilteredStates.length === 0) {
337
- const jobs = collectWaitingChildrenJobs(ctx.shards[shardIdx], queue);
338
- jobs.sort((a, b) => (asc ? a.createdAt - b.createdAt : b.createdAt - a.createdAt));
339
- return jobs.slice(start, end);
391
+ if (states.includes('paused') && isPaused) {
392
+ const pausedJobs = collectTemporalJobs(shard, queue, { waiting: true, prioritized: true, delayed: false }, maxPerSource);
393
+ extras.push(...tagState(pausedJobs, 'paused'));
340
394
  }
341
- const jobs = sqlFilteredStates.length > 0
395
+ // jobs-table states. A paused queue reports its waiting/prioritized jobs under
396
+ // 'paused', so they must not also surface in the waiting/prioritized lists (#92).
397
+ const sqlFilteredStates = states.filter((s) => s !== 'failed' &&
398
+ s !== 'waiting-children' &&
399
+ s !== 'paused' &&
400
+ !(isPaused && (s === 'waiting' || s === 'prioritized')));
401
+ // Fast path: only jobs-table states, no derived sources — SQL paginates directly.
402
+ if (extras.length === 0) {
403
+ return sqlFilteredStates.length > 0
404
+ ? querySqliteWithPriority(ctx.storage, queue, sqlFilteredStates, {
405
+ limit,
406
+ offset: start,
407
+ asc,
408
+ })
409
+ : [];
410
+ }
411
+ const sqlJobs = sqlFilteredStates.length > 0
342
412
  ? querySqliteWithPriority(ctx.storage, queue, sqlFilteredStates, {
343
- limit,
344
- offset: start,
413
+ limit: end,
414
+ offset: 0,
345
415
  asc,
346
416
  })
347
417
  : [];
348
- if (!hasWaitingChildren)
349
- return jobs;
350
- const merged = jobs.concat(collectWaitingChildrenJobs(ctx.shards[shardIdx], queue));
351
- merged.sort((a, b) => (asc ? a.createdAt - b.createdAt : b.createdAt - a.createdAt));
352
- return merged.slice(0, limit);
418
+ return mergePage(sqlJobs, extras, start, end, asc);
353
419
  }
354
420
  // In-memory path (embedded mode only)
355
421
  const maxPerSource = end + 1;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Bun-only entry stub.
3
+ *
4
+ * bunqueue is built for the Bun runtime: its TCP transport, persistence and
5
+ * client rely on Bun globals (Bun.connect, Bun.file, Bun.write, Bun.hash, ...)
6
+ * with no Node fallback, and the published ESM uses directory/extensionless
7
+ * relative specifiers that Node's ESM resolver rejects (ERR_UNSUPPORTED_DIR_IMPORT).
8
+ *
9
+ * The package.json `exports` map points the `"node"` condition at this single,
10
+ * self-contained file so a Node import fails fast with a clear, actionable error
11
+ * instead of a cryptic resolver crash or a deep `Bun is not defined`. Bun resolves
12
+ * the real entry via the higher-priority `"bun"` condition and never loads this.
13
+ *
14
+ * Keep this file free of relative imports so it stays resolvable on its own.
15
+ */
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ /**
3
+ * Bun-only entry stub.
4
+ *
5
+ * bunqueue is built for the Bun runtime: its TCP transport, persistence and
6
+ * client rely on Bun globals (Bun.connect, Bun.file, Bun.write, Bun.hash, ...)
7
+ * with no Node fallback, and the published ESM uses directory/extensionless
8
+ * relative specifiers that Node's ESM resolver rejects (ERR_UNSUPPORTED_DIR_IMPORT).
9
+ *
10
+ * The package.json `exports` map points the `"node"` condition at this single,
11
+ * self-contained file so a Node import fails fast with a clear, actionable error
12
+ * instead of a cryptic resolver crash or a deep `Bun is not defined`. Bun resolves
13
+ * the real entry via the higher-priority `"bun"` condition and never loads this.
14
+ *
15
+ * Keep this file free of relative imports so it stays resolvable on its own.
16
+ */
17
+ throw new Error('bunqueue is Bun-only and requires the Bun runtime (https://bun.sh). ' +
18
+ 'Node.js is not supported: install Bun and run your program with `bun`. ' +
19
+ 'See https://github.com/egeominotti/bunqueue#requirements');
package/dist/cli/index.js CHANGED
File without changes
@@ -18,6 +18,7 @@
18
18
  * worker.on('progress', (job, progress) => console.log(progress));
19
19
  * ```
20
20
  */
21
+ import '../require-bun';
21
22
  export { defineConfig } from '../config';
22
23
  export type { BunqueueConfig } from '../config';
23
24
  export { Queue } from './queue';
@@ -18,6 +18,8 @@
18
18
  * worker.on('progress', (job, progress) => console.log(progress));
19
19
  * ```
20
20
  */
21
+ // Bun-only runtime guard — must evaluate before any module touching Bun.* globals.
22
+ import '../require-bun';
21
23
  export { defineConfig } from '../config';
22
24
  export { Queue } from './queue';
23
25
  export { Worker } from './worker';
@@ -4,6 +4,7 @@
4
4
  * getJobCounts, getWaitingCount, getDelayedCount, etc.
5
5
  */
6
6
  import { getSharedManager } from '../../manager';
7
+ import { pausedView } from '../../../shared/pausedView';
7
8
  /** Get job counts (sync, embedded only) */
8
9
  export function getJobCounts(ctx) {
9
10
  if (!ctx.embedded) {
@@ -21,14 +22,17 @@ export function getJobCounts(ctx) {
21
22
  // Use queue-specific counts
22
23
  const counts = manager.getQueueJobCounts(ctx.name);
23
24
  const isPaused = manager.isPaused(ctx.name);
25
+ // When paused, ready jobs (waiting + prioritized) are reported under `paused` —
26
+ // not in their own buckets, which would double-count (#92, BullMQ semantics).
27
+ const pv = pausedView(counts.waiting, counts.prioritized, isPaused);
24
28
  return {
25
- waiting: counts.waiting,
26
- prioritized: counts.prioritized,
29
+ waiting: pv.waiting,
30
+ prioritized: pv.prioritized,
27
31
  active: counts.active,
28
32
  completed: counts.completed,
29
33
  failed: counts.failed,
30
34
  delayed: counts.delayed,
31
- paused: isPaused ? counts.waiting : 0,
35
+ paused: pv.paused,
32
36
  };
33
37
  }
34
38
  /** Get job counts (async, works with TCP) */
@@ -16,6 +16,7 @@
16
16
  * const run = await engine.start('onboarding', { email: 'user@test.com' });
17
17
  * ```
18
18
  */
19
+ import '../../require-bun';
19
20
  export { Workflow } from './workflow';
20
21
  export { Engine } from './engine';
21
22
  export { WorkflowEmitter } from './emitter';
@@ -16,6 +16,8 @@
16
16
  * const run = await engine.start('onboarding', { email: 'user@test.com' });
17
17
  * ```
18
18
  */
19
+ // Bun-only runtime guard — must evaluate before any module touching Bun.* globals.
20
+ import '../../require-bun';
19
21
  export { Workflow } from './workflow';
20
22
  export { Engine } from './engine';
21
23
  export { WorkflowEmitter } from './emitter';
@@ -9,7 +9,11 @@ const prevQueueTotals = new Map();
9
9
  /** Per-queue previous waiting count for backlog velocity */
10
10
  const prevQueueWaiting = new Map();
11
11
  // ─── Heavy data collectors (called every ~90s) ───
12
- /** All job states (BullMQ v5 compatible) */
12
+ /** All job states (BullMQ v5 compatible).
13
+ * 'paused' yields jobs only when the queue is actually paused (where 'waiting' /
14
+ * 'prioritized' are suppressed), so a paused queue's ready jobs still appear in
15
+ * the snapshot — under `paused` — instead of vanishing (#92). No double-collect:
16
+ * a job is returned by exactly one of waiting/prioritized/paused. */
13
17
  const ALL_STATES = [
14
18
  'waiting',
15
19
  'active',
@@ -17,6 +21,7 @@ const ALL_STATES = [
17
21
  'failed',
18
22
  'completed',
19
23
  'prioritized',
24
+ 'paused',
20
25
  'waiting-children',
21
26
  ];
22
27
  /** Convert falsy/empty values to undefined for compact serialization */
@@ -34,7 +34,19 @@ export class SqliteStorage {
34
34
  static MAX_RETAINED_LOSSES = 100;
35
35
  constructor(config) {
36
36
  this.db = new Database(config.path, { create: true });
37
- this.db.run(PRAGMA_SETTINGS);
37
+ // PRAGMA settings are performance/optimization hints, not correctness
38
+ // requirements. A transient filesystem IOERR (e.g. SQLITE_IOERR_FSTAT from
39
+ // `mmap_size` calling fstat() on the fd during a restart/cleanup race) must
40
+ // NOT propagate out of the constructor — in a deferred/async context Bun
41
+ // reports it as an "Unhandled error between tests" and tears down CI.
42
+ try {
43
+ this.db.run(PRAGMA_SETTINGS);
44
+ }
45
+ catch (err) {
46
+ storageLog.error('Failed to apply PRAGMA settings', {
47
+ error: err instanceof Error ? err.message : String(err),
48
+ });
49
+ }
38
50
  this.migrate();
39
51
  this.statements = prepareStatements(this.db);
40
52
  this._onCriticalLoss = config.onCriticalLoss;
@@ -6,6 +6,7 @@
6
6
  import * as resp from '../../../domain/types/response';
7
7
  import { throughputTracker } from '../../../application/throughputTracker';
8
8
  import { latencyTracker } from '../../../application/latencyTracker';
9
+ import { pausedView } from '../../../shared/pausedView';
9
10
  /** Dashboard overview - single call for all dashboard data */
10
11
  export function handleDashboardOverview(_cmd, ctx, reqId) {
11
12
  const stats = ctx.queueManager.getStats();
@@ -92,8 +93,17 @@ export function handleDashboardQueues(_cmd, ctx, reqId) {
92
93
  /** Dashboard single queue detail */
93
94
  export function handleDashboardQueue(cmd, ctx, reqId) {
94
95
  const queue = cmd.queue;
95
- const counts = ctx.queueManager.getQueueJobCounts(queue);
96
+ const rawCounts = ctx.queueManager.getQueueJobCounts(queue);
96
97
  const paused = ctx.queueManager.isPaused(queue);
98
+ // Keep the counts consistent with the per-state lists below (#92): a paused
99
+ // queue reports ready jobs under `paused`, not `waiting`/`prioritized`.
100
+ const pv = pausedView(rawCounts.waiting, rawCounts.prioritized, paused);
101
+ const counts = {
102
+ ...rawCounts,
103
+ waiting: pv.waiting,
104
+ prioritized: pv.prioritized,
105
+ paused: pv.paused,
106
+ };
97
107
  const dlqJobs = ctx.queueManager.getDlq(queue, 10);
98
108
  const priorityCounts = ctx.queueManager.getCountsPerPriority(queue);
99
109
  const result = {
@@ -111,13 +121,19 @@ export function handleDashboardQueue(cmd, ctx, reqId) {
111
121
  };
112
122
  if (cmd.includeJobs) {
113
123
  const limit = Math.min(cmd.jobsLimit ?? 10, 50);
124
+ // When paused, ready jobs surface under `paused` (waiting/prioritized are
125
+ // empty) — match the counts above (#92).
114
126
  const waiting = ctx.queueManager.getJobs(queue, { state: 'waiting', end: limit });
115
127
  const active = ctx.queueManager.getJobs(queue, { state: 'active', end: limit });
116
128
  const delayed = ctx.queueManager.getJobs(queue, { state: 'delayed', end: limit });
129
+ const pausedJobs = paused
130
+ ? ctx.queueManager.getJobs(queue, { state: 'paused', end: limit })
131
+ : [];
117
132
  result.jobs = {
118
133
  waiting: waiting.map(jobSummary),
119
134
  active: active.map(jobSummary),
120
135
  delayed: delayed.map(jobSummary),
136
+ paused: pausedJobs.map(jobSummary),
121
137
  };
122
138
  }
123
139
  return resp.data(result, reqId);
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import * as resp from '../../../domain/types/response';
6
6
  import { jobId } from '../../../domain/types/job';
7
+ import { pausedView } from '../../../shared/pausedView';
7
8
  /** Handle GetJob command */
8
9
  export async function handleGetJob(cmd, ctx, reqId) {
9
10
  const jid = jobId(cmd.id);
@@ -28,15 +29,18 @@ export function handleGetJobCounts(cmd, ctx, reqId) {
28
29
  // Get queue-specific counts
29
30
  const counts = ctx.queueManager.getQueueJobCounts(cmd.queue);
30
31
  const isPaused = ctx.queueManager.isPaused(cmd.queue);
32
+ // When paused, ready jobs (waiting + prioritized) are reported under `paused` —
33
+ // not in their own buckets, which would double-count (#92, BullMQ semantics).
34
+ const pv = pausedView(counts.waiting, counts.prioritized, isPaused);
31
35
  return resp.counts({
32
- waiting: counts.waiting,
33
- prioritized: counts.prioritized,
36
+ waiting: pv.waiting,
37
+ prioritized: pv.prioritized,
34
38
  delayed: counts.delayed,
35
39
  active: counts.active,
36
40
  completed: counts.completed,
37
41
  failed: counts.failed,
38
42
  'waiting-children': counts['waiting-children'],
39
- paused: isPaused ? counts.waiting : 0,
43
+ paused: pv.paused,
40
44
  }, reqId);
41
45
  }
42
46
  /** Handle GetCountsPerPriority command */
@@ -4,6 +4,7 @@
4
4
  import { VERSION } from '../../shared/version';
5
5
  import { throughputTracker } from '../../application/throughputTracker';
6
6
  import { latencyTracker } from '../../application/latencyTracker';
7
+ import { pausedView } from '../../shared/pausedView';
7
8
  /** JSON response helper */
8
9
  export function jsonResponse(data, status = 200, corsOrigins) {
9
10
  const headers = {
@@ -256,8 +257,17 @@ export function dashboardQueuesEndpoint(queueManager, limit, offset, corsOrigins
256
257
  }
257
258
  /** Dashboard single queue detail endpoint */
258
259
  export function dashboardQueueDetailEndpoint(queueManager, queue, includeJobs, corsOrigins) {
259
- const counts = queueManager.getQueueJobCounts(queue);
260
+ const rawCounts = queueManager.getQueueJobCounts(queue);
260
261
  const paused = queueManager.isPaused(queue);
262
+ // Keep counts consistent with the per-state lists below (#92): a paused queue
263
+ // reports ready jobs under `paused`, not `waiting`/`prioritized`.
264
+ const pv = pausedView(rawCounts.waiting, rawCounts.prioritized, paused);
265
+ const counts = {
266
+ ...rawCounts,
267
+ waiting: pv.waiting,
268
+ prioritized: pv.prioritized,
269
+ paused: pv.paused,
270
+ };
261
271
  const dlqJobs = queueManager.getDlq(queue, 10);
262
272
  const priorityCounts = queueManager.getCountsPerPriority(queue);
263
273
  const result = {
@@ -275,9 +285,12 @@ export function dashboardQueueDetailEndpoint(queueManager, queue, includeJobs, c
275
285
  timestamp: Date.now(),
276
286
  };
277
287
  if (includeJobs) {
288
+ // When paused, ready jobs surface under `paused` (waiting is empty) — match
289
+ // the counts above (#92).
278
290
  const waiting = queueManager.getJobs(queue, { state: 'waiting', end: 10 });
279
291
  const active = queueManager.getJobs(queue, { state: 'active', end: 10 });
280
292
  const delayed = queueManager.getJobs(queue, { state: 'delayed', end: 10 });
293
+ const pausedJobs = paused ? queueManager.getJobs(queue, { state: 'paused', end: 10 }) : [];
281
294
  const toSummary = (j) => ({
282
295
  id: j.id,
283
296
  priority: j.priority,
@@ -290,6 +303,7 @@ export function dashboardQueueDetailEndpoint(queueManager, queue, includeJobs, c
290
303
  waiting: waiting.map(toSummary),
291
304
  active: active.map(toSummary),
292
305
  delayed: delayed.map(toSummary),
306
+ paused: pausedJobs.map(toSummary),
293
307
  };
294
308
  }
295
309
  return jsonResponse(result, 200, corsOrigins);
@@ -0,0 +1,250 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * bunqueue - High-performance job queue server for Bun
4
+ * Main entry point - routes to CLI for client commands or starts server
5
+ */
6
+ // Only run startup dispatch when this file is the program entry point.
7
+ // Issue #85: re-exporting `defineConfig` means user config files import this
8
+ // module; without this guard, the top-level dispatch would re-run the CLI/server
9
+ // on every import and cause "Failed to listen at 0.0.0.0".
10
+ if (import.meta.main) {
11
+ const clientCommands = [
12
+ 'push',
13
+ 'pull',
14
+ 'ack',
15
+ 'fail',
16
+ 'job',
17
+ 'queue',
18
+ 'dlq',
19
+ 'cron',
20
+ 'worker',
21
+ 'webhook',
22
+ 'rate-limit',
23
+ 'concurrency',
24
+ 'stats',
25
+ 'metrics',
26
+ 'health',
27
+ 'backup',
28
+ ];
29
+ const firstArg = process.argv[2];
30
+ const isClientCommand = firstArg && clientCommands.includes(firstArg);
31
+ const isStartCommand = firstArg === 'start';
32
+ const hasHelpOrVersion = process.argv.includes('--help') || process.argv.includes('--version');
33
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- process.argv[2] can be undefined at runtime
34
+ const hasFlags = firstArg?.startsWith('-');
35
+ if (isClientCommand || hasHelpOrVersion || isStartCommand || hasFlags) {
36
+ void import('./cli/index').then(({ main }) => main());
37
+ }
38
+ else {
39
+ void startServer();
40
+ }
41
+ }
42
+ import { QueueManager } from './application/queueManager';
43
+ import { createTcpServer } from './infrastructure/server/tcp';
44
+ import { createHttpServer } from './infrastructure/server/http';
45
+ import { Logger, serverLog, statsLog } from './shared/logger';
46
+ import { stopRateLimiter } from './infrastructure/server/rateLimiter';
47
+ import { VERSION } from './shared/version';
48
+ import { S3BackupManager } from './infrastructure/backup';
49
+ import { CloudAgent } from './infrastructure/cloud';
50
+ import { SHARD_COUNT } from './shared/hash';
51
+ import { loadConfigFile, resolveServerConfig, resolveCloudConfig, resolveBackupConfig, } from './config';
52
+ export { defineConfig } from './config';
53
+ /** Print startup banner */
54
+ function printBanner(config, cloudUrl) {
55
+ const dim = '\x1b[2m';
56
+ const reset = '\x1b[0m';
57
+ const bold = '\x1b[1m';
58
+ const magenta = '\x1b[35m';
59
+ const green = '\x1b[32m';
60
+ const yellow = '\x1b[33m';
61
+ // Format TCP endpoint display
62
+ const tcpDisplay = config.tcpSocketPath
63
+ ? `${bold}${config.tcpSocketPath}${reset} ${dim}(unix)${reset}`
64
+ : `${bold}${config.hostname}:${config.tcpPort}${reset}`;
65
+ // Format HTTP endpoint display
66
+ const httpDisplay = config.httpSocketPath
67
+ ? `${bold}${config.httpSocketPath}${reset} ${dim}(unix)${reset}`
68
+ : `${bold}${config.hostname}:${config.httpPort}${reset}`;
69
+ // Socket mode display
70
+ const hasUnixSockets = config.tcpSocketPath !== undefined || config.httpSocketPath !== undefined;
71
+ const socketDisplay = hasUnixSockets
72
+ ? `${green}enabled${reset} ${dim}(${config.tcpSocketPath ? 'TCP' : ''}${config.tcpSocketPath && config.httpSocketPath ? '+' : ''}${config.httpSocketPath ? 'HTTP' : ''})${reset}`
73
+ : `${dim}disabled${reset}`;
74
+ console.log(`
75
+ ${magenta} (\\(\\ ${reset}
76
+ ${magenta} ( -.-) ${bold}bunqueue${reset} ${dim}v${VERSION}${reset}
77
+ ${magenta} o_(")(") ${reset}${dim}High-performance job queue for Bun${reset}
78
+
79
+ ${dim}─────────────────────────────────────────────────${reset}
80
+
81
+ ${green}●${reset} TCP ${tcpDisplay}
82
+ ${green}●${reset} HTTP ${httpDisplay}
83
+ ${yellow}●${reset} Socket ${socketDisplay}
84
+ ${yellow}●${reset} Data ${config.dataPath ?? 'in-memory'}
85
+ ${yellow}●${reset} Auth ${config.authTokens.length > 0 ? `${green}enabled${reset}` : `${dim}disabled${reset}`}
86
+ ${yellow}●${reset} S3 Backup ${config.s3BackupEnabled ? `${green}enabled${reset}` : `${dim}disabled${reset}`}
87
+ ${yellow}●${reset} Cloud ${cloudUrl ? `${green}enabled${reset} ${dim}→ ${cloudUrl}${reset}` : `${dim}disabled${reset}`}
88
+ ${dim}●${reset} Shards ${bold}${SHARD_COUNT}${reset} ${dim}(${navigator.hardwareConcurrency} CPU cores)${reset}
89
+
90
+ ${dim}─────────────────────────────────────────────────${reset}
91
+
92
+ `);
93
+ }
94
+ /** Start the server (direct mode) */
95
+ async function startServer() {
96
+ // Load config file (bunqueue.config.ts) if present, then merge with env vars
97
+ const fileConfig = await loadConfigFile();
98
+ const config = resolveServerConfig(fileConfig);
99
+ // Apply logging config before anything else
100
+ const logFormat = fileConfig?.logging?.format ?? Bun.env.LOG_FORMAT;
101
+ const logLevel = fileConfig?.logging?.level ?? Bun.env.LOG_LEVEL?.toLowerCase();
102
+ if (logFormat === 'json')
103
+ Logger.enableJsonMode();
104
+ if (logLevel) {
105
+ const validLevels = ['debug', 'info', 'warn', 'error'];
106
+ if (validLevels.includes(logLevel))
107
+ Logger.setLevel(logLevel);
108
+ }
109
+ // Resolve cloud config
110
+ const cloudConfig = resolveCloudConfig(fileConfig, config.dataPath);
111
+ printBanner(config, cloudConfig?.url);
112
+ // Create queue manager
113
+ const queueManager = new QueueManager({
114
+ dataPath: config.dataPath,
115
+ });
116
+ // Start TCP server
117
+ const tcpServer = createTcpServer(queueManager, {
118
+ port: config.tcpPort,
119
+ hostname: config.hostname,
120
+ authTokens: config.authTokens,
121
+ });
122
+ // Start HTTP server
123
+ const httpServer = createHttpServer(queueManager, {
124
+ port: config.httpPort,
125
+ hostname: config.hostname,
126
+ authTokens: config.authTokens,
127
+ corsOrigins: config.corsOrigins,
128
+ requireAuthForMetrics: config.requireAuthForMetrics,
129
+ });
130
+ // Initialize S3 backup manager
131
+ let backupManager = null;
132
+ if (config.dataPath) {
133
+ const backupConfig = resolveBackupConfig(fileConfig, config.dataPath);
134
+ backupManager = new S3BackupManager(backupConfig);
135
+ backupManager.setDashboardEmit(queueManager.emitDashboardEvent.bind(queueManager));
136
+ backupManager.start();
137
+ }
138
+ // Initialize bunqueue Cloud agent (remote dashboard telemetry)
139
+ const cloudAgent = cloudConfig ? CloudAgent.createFromConfig(queueManager, cloudConfig) : null;
140
+ if (cloudAgent) {
141
+ cloudAgent.setServerHandles({
142
+ getConnectionCount: () => tcpServer.getConnectionCount(),
143
+ getWsClientCount: () => httpServer.getWsClientCount(),
144
+ getSseClientCount: () => httpServer.getSseClientCount(),
145
+ getBackupStatus: () => backupManager?.getStatus() ?? null,
146
+ });
147
+ }
148
+ queueManager.emitDashboardEvent('server:started', {
149
+ tcpPort: config.tcpPort,
150
+ httpPort: config.httpPort,
151
+ shards: SHARD_COUNT,
152
+ });
153
+ // Graceful shutdown
154
+ let shuttingDown = false;
155
+ const shutdown = async (signal) => {
156
+ if (shuttingDown)
157
+ return;
158
+ shuttingDown = true;
159
+ serverLog.info(`Received ${signal}, shutting down...`);
160
+ // Stop stats interval immediately
161
+ clearInterval(statsInterval);
162
+ tcpServer.stop();
163
+ httpServer.stop();
164
+ const shutdownTimeout = config.shutdownTimeoutMs;
165
+ const start = Date.now();
166
+ while (Date.now() - start < shutdownTimeout) {
167
+ const stats = queueManager.getStats();
168
+ if (stats.active === 0)
169
+ break;
170
+ serverLog.info(`Waiting for ${stats.active} active jobs...`);
171
+ await Bun.sleep(1000);
172
+ }
173
+ // Stop backup manager
174
+ if (backupManager) {
175
+ backupManager.stop();
176
+ }
177
+ // Stop Cloud agent (sends final shutdown snapshot)
178
+ if (cloudAgent) {
179
+ await cloudAgent.stop();
180
+ }
181
+ queueManager.emitDashboardEvent('server:shutdown', { signal });
182
+ queueManager.shutdown();
183
+ stopRateLimiter();
184
+ serverLog.info('Shutdown complete');
185
+ process.exit(0);
186
+ };
187
+ process.on('SIGINT', () => void shutdown('SIGINT'));
188
+ process.on('SIGTERM', () => void shutdown('SIGTERM'));
189
+ process.on('uncaughtException', (err) => {
190
+ serverLog.error('Uncaught exception - initiating shutdown', {
191
+ error: err.message,
192
+ stack: err.stack,
193
+ });
194
+ void shutdown('uncaughtException');
195
+ });
196
+ process.on('unhandledRejection', (reason) => {
197
+ serverLog.error('Unhandled promise rejection - initiating shutdown', {
198
+ reason: reason instanceof Error ? reason.message : String(reason),
199
+ stack: reason instanceof Error ? reason.stack : undefined,
200
+ });
201
+ void shutdown('unhandledRejection');
202
+ });
203
+ // Print stats periodically
204
+ const statsInterval = setInterval(() => {
205
+ const stats = queueManager.getStats();
206
+ const memStats = queueManager.getMemoryStats();
207
+ const workerStats = queueManager.workerManager.getStats();
208
+ const mem = process.memoryUsage();
209
+ const now = new Date();
210
+ const timestamp = now.toLocaleTimeString('en-GB', {
211
+ hour: '2-digit',
212
+ minute: '2-digit',
213
+ second: '2-digit',
214
+ });
215
+ statsLog.info('Queue statistics', {
216
+ time: timestamp,
217
+ waiting: stats.waiting,
218
+ active: stats.active,
219
+ delayed: stats.delayed,
220
+ completed: stats.completed,
221
+ dlq: stats.dlq,
222
+ tcp: tcpServer.getConnectionCount(),
223
+ ws: httpServer.getWsClientCount(),
224
+ sse: httpServer.getSseClientCount(),
225
+ workers: `${workerStats.active}/${workerStats.total}`,
226
+ mem: `${Math.round(mem.heapUsed / 1024 / 1024)}MB/${Math.round(mem.heapTotal / 1024 / 1024)}MB`,
227
+ rss: `${Math.round(mem.rss / 1024 / 1024)}MB`,
228
+ // Internal collection sizes (for memory debugging)
229
+ idx: memStats.jobIndex,
230
+ locks: memStats.jobLocks,
231
+ clients: memStats.clientJobsTotal,
232
+ });
233
+ }, config.statsIntervalMs);
234
+ }
235
+ // Logger env-var bootstrap only applies when this file is the entry point.
236
+ // Imported consumers (e.g. user config files using `defineConfig`) must not
237
+ // have their process Logger state mutated as a side effect — see Issue #85.
238
+ if (import.meta.main) {
239
+ if (Bun.env.LOG_FORMAT === 'json') {
240
+ Logger.enableJsonMode();
241
+ }
242
+ if (Bun.env.LOG_LEVEL) {
243
+ const validLevels = ['debug', 'info', 'warn', 'error'];
244
+ const level = Bun.env.LOG_LEVEL.toLowerCase();
245
+ if (validLevels.includes(level)) {
246
+ Logger.setLevel(level);
247
+ }
248
+ }
249
+ }
250
+ //# sourceMappingURL=main.js.map
package/dist/mcp/index.js CHANGED
File without changes
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Bun-only runtime guard (defense-in-depth for the bundled path).
3
+ *
4
+ * The `"node"` export condition (-> bun-only.ts) already fails fast for a plain
5
+ * `node` import and for node-target bundlers. This guard covers the remaining
6
+ * case: a browser/neutral-target bundler that inlines the real client, whose
7
+ * output is then run on Node. Without it, the failure surfaces deep inside as a
8
+ * cryptic `Bun is not defined` / `Bun.connect is not a function`.
9
+ *
10
+ * Imported FIRST by the client entrypoints so it evaluates before any module
11
+ * that touches `Bun.*` at top level. No-op under Bun. Keep relative-import-free.
12
+ */
13
+ export {};
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Bun-only runtime guard (defense-in-depth for the bundled path).
3
+ *
4
+ * The `"node"` export condition (-> bun-only.ts) already fails fast for a plain
5
+ * `node` import and for node-target bundlers. This guard covers the remaining
6
+ * case: a browser/neutral-target bundler that inlines the real client, whose
7
+ * output is then run on Node. Without it, the failure surfaces deep inside as a
8
+ * cryptic `Bun is not defined` / `Bun.connect is not a function`.
9
+ *
10
+ * Imported FIRST by the client entrypoints so it evaluates before any module
11
+ * that touches `Bun.*` at top level. No-op under Bun. Keep relative-import-free.
12
+ */
13
+ if (typeof globalThis.Bun === 'undefined') {
14
+ throw new Error('bunqueue is Bun-only and requires the Bun runtime (https://bun.sh). ' +
15
+ 'Node.js is not supported: install Bun and run your program with `bun`.');
16
+ }
17
+ export {};
@@ -0,0 +1,19 @@
1
+ /**
2
+ * BullMQ paused-view counts (#92).
3
+ *
4
+ * When a queue is paused, none of its ready jobs (waiting + prioritized) can be
5
+ * pulled, so they are reported under `paused` — and NOT in their own buckets, to
6
+ * avoid double-counting a single job. This mirrors the per-state lists, where a
7
+ * paused queue lists those jobs under `paused` and returns nothing for `waiting`
8
+ * / `prioritized`. Delayed and active jobs keep their own state.
9
+ *
10
+ * Single source of truth shared by every count surface that exposes the BullMQ
11
+ * `getJobCounts` shape (client SDK, TCP handler, dashboard detail), so they can
12
+ * never drift apart.
13
+ */
14
+ export interface PausedDerivedCounts {
15
+ waiting: number;
16
+ prioritized: number;
17
+ paused: number;
18
+ }
19
+ export declare function pausedView(waiting: number, prioritized: number, isPaused: boolean): PausedDerivedCounts;
@@ -0,0 +1,7 @@
1
+ export function pausedView(waiting, prioritized, isPaused) {
2
+ return {
3
+ waiting: isPaused ? 0 : waiting,
4
+ prioritized: isPaused ? 0 : prioritized,
5
+ paused: isPaused ? waiting + prioritized : 0,
6
+ };
7
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunqueue",
3
- "version": "2.8.2",
3
+ "version": "2.8.4",
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",
@@ -12,24 +12,35 @@
12
12
  "exports": {
13
13
  ".": {
14
14
  "types": "./dist/main.d.ts",
15
+ "bun": "./dist/main.js",
16
+ "node": "./dist/bun-only.js",
15
17
  "import": "./dist/main.js"
16
18
  },
17
19
  "./client": {
18
20
  "types": "./dist/client/index.d.ts",
21
+ "bun": "./dist/client/index.js",
22
+ "node": "./dist/bun-only.js",
19
23
  "import": "./dist/client/index.js"
20
24
  },
21
25
  "./queue": {
22
26
  "types": "./dist/application/queueManager.d.ts",
27
+ "bun": "./dist/application/queueManager.js",
28
+ "node": "./dist/bun-only.js",
23
29
  "import": "./dist/application/queueManager.js"
24
30
  },
25
31
  "./mcp": {
26
32
  "types": "./dist/mcp/index.d.ts",
33
+ "bun": "./dist/mcp/index.js",
34
+ "node": "./dist/bun-only.js",
27
35
  "import": "./dist/mcp/index.js"
28
36
  },
29
37
  "./workflow": {
30
38
  "types": "./dist/client/workflow/index.d.ts",
39
+ "bun": "./dist/client/workflow/index.js",
40
+ "node": "./dist/bun-only.js",
31
41
  "import": "./dist/client/workflow/index.js"
32
- }
42
+ },
43
+ "./package.json": "./package.json"
33
44
  },
34
45
  "files": [
35
46
  "dist/**/*.js",
@@ -136,7 +147,6 @@
136
147
  },
137
148
  "homepage": "https://bunqueue.dev",
138
149
  "engines": {
139
- "node": ">=18.0.0",
140
150
  "bun": ">=1.3.9"
141
151
  }
142
152
  }