@bymax-one/nest-queue 1.0.10 → 1.2.0

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/CHANGELOG.md CHANGED
@@ -7,6 +7,50 @@ versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
8
  ---
9
9
 
10
+ ## [1.2.0] — 2026-08-10
11
+
12
+ **Breaking, shipped as a minor by choice.** The peer ranges move to the next major of each
13
+ Redis/BullMQ package and the `'paused'` job state is removed from the public status surface —
14
+ changes that would normally warrant a major. They ship under a minor because the package has no
15
+ downstream consumer yet; every breaking detail is called out below so a first adopter sees it.
16
+
17
+ ### Changed
18
+
19
+ - **Peer dependencies migrated to the next major:** `bullmq` `^5` → `^6`, `bullmq-otel` `^1` →
20
+ `^2`, and `ioredis` `^5` → `^6`. A consumer must upgrade all three together. `ioredis` 6 keeps
21
+ `'legacy'` reply mapping by default, so BullMQ's reply-shape assumptions are unaffected; it does
22
+ require Node.js ≥ 20, already covered by this package's `engines`.
23
+
24
+ ### Removed
25
+
26
+ - **`'paused'` is no longer a job status.** BullMQ 6 dropped `'paused'` from its job-state set —
27
+ jobs on a paused queue now live in `'wait'`. It is therefore removed from the `JobStatus` union,
28
+ from the `JOB_STATUS` constant (`JOB_STATUS.PAUSED` no longer exists), and from the
29
+ `getMetrics().counts` shape, which now reports the five remaining statuses (`waiting`, `active`,
30
+ `completed`, `failed`, `delayed`). The queue-lifecycle `'paused'`/`'resumed'` **events**
31
+ (`@OnQueueEvent`) and the `pauseQueue`/`resumeQueue` methods are unchanged — those describe the
32
+ queue, not a job.
33
+
34
+ ### Internal
35
+
36
+ - Dev-tooling bumps: `eslint` `^10`, `typescript` `^6`, `@types/node` `^26`, plus `@eslint/js`
37
+ `^10`. These do not ship and do not change `dist/`. The package's `engines` stays at Node ≥ 24.
38
+ - Mutation gate tightened: Stryker `break`/`high` raised from 95/99 → **100** (the run is at 100%,
39
+ 0 survivors).
40
+
41
+ ## [1.0.11] — 2026-08-10
42
+
43
+ **Documentation only.** The shipped declaration comments change (`dts` preserves them); no runtime
44
+ code changed.
45
+
46
+ ### Fixed
47
+
48
+ - The deduplication example — in the `QueueService` JSDoc and in
49
+ `docs/technical_specification.md` — now scopes the dedup `id` by tenant
50
+ (`` `${tenantId}:reindex:${term}` ``). The `id` is a shared key across the queue, so a
51
+ multi-tenant producer keying on the value alone lets one tenant's enqueue suppress another
52
+ tenant's job for the same value; the example previously demonstrated exactly that.
53
+
10
54
  ## [1.0.10] — 2026-08-06
11
55
 
12
56
  **Documentation and tooling only.** `dist/` is byte-identical to `1.0.9`; no source file
@@ -443,6 +487,8 @@ v6 peer range.
443
487
 
444
488
  ---
445
489
 
490
+ [1.2.0]: https://github.com/bymaxone/nest-queue/compare/v1.0.11...v1.2.0
491
+ [1.0.11]: https://github.com/bymaxone/nest-queue/compare/v1.0.10...v1.0.11
446
492
  [1.0.10]: https://github.com/bymaxone/nest-queue/compare/v1.0.9...v1.0.10
447
493
  [1.0.9]: https://github.com/bymaxone/nest-queue/compare/v1.0.8...v1.0.9
448
494
  [1.0.8]: https://github.com/bymaxone/nest-queue/compare/v1.0.7...v1.0.8
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  <p align="center">
8
8
  <strong>Typed BullMQ queues for NestJS</strong><br />
9
- <sub>BullMQ 5 · Workers · Flows · Job Schedulers · Deduplication · OpenTelemetry · Graceful Shutdown · Zero Runtime Dependencies</sub>
9
+ <sub>BullMQ 6 · Workers · Flows · Job Schedulers · Deduplication · OpenTelemetry · Graceful Shutdown · Zero Runtime Dependencies</sub>
10
10
  </p>
11
11
 
12
12
  <p align="center">
@@ -33,7 +33,7 @@
33
33
 
34
34
  ## ✨ Overview
35
35
 
36
- `@bymax-one/nest-queue` is a NestJS dynamic module that wraps **BullMQ 5** behind a typed,
36
+ `@bymax-one/nest-queue` is a NestJS dynamic module that wraps **BullMQ 6** behind a typed,
37
37
  opinionated surface. Instead of wiring `ioredis` connections, per-queue registration, and
38
38
  `maxRetriesPerRequest` policy by hand, you install one library and get typed producers,
39
39
  decorator-based workers, flows, recurring **Job Schedulers**, native deduplication,
@@ -114,11 +114,11 @@ Both subpaths ship ESM **and** CommonJS with declarations for each format, so a
114
114
 
115
115
  ### Peer dependency matrix
116
116
 
117
- | Subpath | Required peers |
118
- | ------------ | --------------------------------------------------------------------------------------------------------- |
119
- | `.` (server) | `@nestjs/common ^11.0.16`, `@nestjs/core ^11.1.18`, `bullmq ^5.16`, `ioredis ^5`, `reflect-metadata ^0.2` |
120
- | `./shared` | None |
121
- | Optional | `bullmq-otel ^1` (OpenTelemetry) |
117
+ | Subpath | Required peers |
118
+ | ------------ | ---------------------------------------------------------------------------------------------------------- |
119
+ | `.` (server) | `@nestjs/common ^11.0.16`, `@nestjs/core ^11.1.18`, `bullmq ^6.0`, `ioredis ^6.0`, `reflect-metadata ^0.2` |
120
+ | `./shared` | None |
121
+ | Optional | `bullmq-otel ^2` (OpenTelemetry) |
122
122
 
123
123
  > [!IMPORTANT]
124
124
  > `reflect-metadata` is a **global polyfill the application owns** — import it once at
@@ -785,11 +785,11 @@ key will do it twice at some point.
785
785
 
786
786
  - **Runtime:** Node.js 24+
787
787
  - **Framework:** NestJS 11 (`ConfigurableModuleBuilder` + `setExtras`)
788
- - **Queue engine:** BullMQ `^5.16` (peer) over `ioredis ^5` (peer)
789
- - **Telemetry:** `bullmq-otel ^1` (optional peer)
788
+ - **Queue engine:** BullMQ `^6.0` (peer) over `ioredis ^6.0` (peer)
789
+ - **Telemetry:** `bullmq-otel ^2` (optional peer)
790
790
  - **Build:** tsup — ESM + CJS per subpath, with `.d.ts` _and_ `.d.cts` declarations
791
791
  - **Tests:** Jest + `@testcontainers/redis` (E2E) + Stryker (mutation)
792
- - **TypeScript:** 5.x strict (`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`), zero `any`
792
+ - **TypeScript:** 6.x strict (`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`), zero `any`
793
793
 
794
794
  ---
795
795
 
@@ -884,9 +884,9 @@ do not pick them up:
884
884
 
885
885
  - **NestJS only** — does not work with plain Express, standalone Fastify, or other frameworks.
886
886
  - **Node.js only** — Deno and Bun are not supported.
887
- - **BullMQ `^5.16` (1.x floor)** — v6 promotion is planned once the E2E suite is
888
- green on the v5 + v6 matrix; see the BullMQ version policy in
889
- [`CHANGELOG.md`](CHANGELOG.md).
887
+ - **BullMQ `^6.0` only** — the library targets BullMQ 6 (with `bullmq-otel ^2` for telemetry)
888
+ and does not support BullMQ 5; the `'paused'` job state that v6 removed is no longer exposed.
889
+ See the BullMQ version policy in [`CHANGELOG.md`](CHANGELOG.md).
890
890
  - **No Redis Cluster sharding** — BullMQ's cluster support is not exercised by this library.
891
891
  - **No built-in DLQ helper** — use the `@OnWorkerEvent('failed')` pattern above.
892
892
  - **No Prometheus metrics export** — `getMetrics()` returns snapshots; publish them
@@ -198,11 +198,22 @@ exports.ConnectionResolver = class ConnectionResolver {
198
198
  return;
199
199
  }
200
200
  this.mode = "mode-b-owned";
201
- __privateSet(this, _client, "url" in cfg ? new ioredis.Redis(cfg.url, { ...cfg.options ?? {}, lazyConnect: false }) : new ioredis.Redis({ ...cfg.options, lazyConnect: false }));
201
+ __privateSet(this, _client, "url" in cfg ? new ioredis.Redis(cfg.url, this.ownedOptions(cfg.options)) : new ioredis.Redis(this.ownedOptions(cfg.options)));
202
202
  await this.waitReady(
203
203
  __privateGet(this, _options).connectionReadyTimeoutMs ?? DEFAULT_CONNECTION_READY_TIMEOUT_MS
204
204
  );
205
205
  }
206
+ /**
207
+ * Merge the consumer's options with the owned-connection policy
208
+ * (`lazyConnect: false`, so the client connects eagerly and `init` can wait
209
+ * for `ready`) and narrow the result to the shape ioredis 6's constructor
210
+ * accepts. The runtime object is unchanged — the cast only drops the
211
+ * `undefined` that `RedisOptions.replyMapping` carries and the constructor
212
+ * rejects.
213
+ */
214
+ ownedOptions(options) {
215
+ return { ...options ?? {}, lazyConnect: false };
216
+ }
206
217
  /** The resolved Queue-role client. */
207
218
  getClient() {
208
219
  if (!__privateGet(this, _client)) {
@@ -378,6 +389,11 @@ exports.QueueService = class QueueService {
378
389
  * **producers** and do not change the at-least-once guarantee on the
379
390
  * **consumer** side.
380
391
  *
392
+ * The deduplication `id` is a shared key across the queue, so a multi-tenant
393
+ * producer MUST scope it by tenant: keyed on the value alone
394
+ * (`` `reindex:${term}` ``), tenant A enqueuing a term suppresses tenant B's job
395
+ * for the same term. Include the tenant (`` `${tenantId}:reindex:${term}` ``).
396
+ *
381
397
  * Deduplication modes (`deduplication: { id, ttl?, extend?, replace?, keepLastIfActive? }`):
382
398
  * - Simple `{ id }` — collapse duplicates until the in-flight job completes or fails.
383
399
  * - Throttle `{ id, ttl }` — ignore duplicates for `ttl` milliseconds.
@@ -391,9 +407,10 @@ exports.QueueService = class QueueService {
391
407
  * @param data - Typed job payload.
392
408
  * @param options - Per-job BullMQ options (priority, delay, `jobId`, `deduplication`, ...).
393
409
  * @returns The created job.
394
- * @example Throttle: at most one reindex per term every 5 seconds.
410
+ * @example Throttle: at most one reindex per term every 5 seconds. The dedup key
411
+ * carries the tenant so one tenant's term cannot suppress another's.
395
412
  * await queueService.enqueue('search', 'reindex', { term }, {
396
- * deduplication: { id: `reindex:${term}`, ttl: 5_000 },
413
+ * deduplication: { id: `${tenantId}:reindex:${term}`, ttl: 5_000 },
397
414
  * })
398
415
  */
399
416
  async enqueue(queueName, jobName, data, options) {
@@ -465,8 +482,7 @@ exports.QueueService = class QueueService {
465
482
  "active",
466
483
  "completed",
467
484
  "failed",
468
- "delayed",
469
- "paused"
485
+ "delayed"
470
486
  );
471
487
  return {
472
488
  queue: queueName,
@@ -1335,9 +1351,9 @@ QueueLifecycle = __decorateClass([
1335
1351
 
1336
1352
  // src/server/bymax-queue.module.ts
1337
1353
  var { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN, OPTIONS_TYPE, ASYNC_OPTIONS_TYPE } = (
1338
- // The `moduleName` shapes the generated token's description only — a label read in Nest's DI
1339
- // error output, never compared against anything. Mutation testing reports it as surviving for
1340
- // that reason; no Stryker directive attaches inside this builder chain, so the note lives here.
1354
+ // The `moduleName` shapes the generated option token's identifier — `BYMAX_QUEUE_MODULE_OPTIONS`,
1355
+ // which the module spec asserts by value; emptying the name would degrade it to an opaque
1356
+ // `CONFIGURABLE_MODULE_OPTIONS[<hash>]`, so the argument is a caught regression, not a free label.
1341
1357
  new common.ConfigurableModuleBuilder({ moduleName: "BymaxQueue" }).setClassMethodName("forRoot").setFactoryMethodName("createQueueOptions").setExtras({ isGlobal: true }, (definition, extras) => ({
1342
1358
  ...definition,
1343
1359
  global: extras.isGlobal
@@ -1542,8 +1558,7 @@ var JOB_STATUS = {
1542
1558
  ACTIVE: "active",
1543
1559
  COMPLETED: "completed",
1544
1560
  FAILED: "failed",
1545
- DELAYED: "delayed",
1546
- PAUSED: "paused"
1561
+ DELAYED: "delayed"
1547
1562
  };
1548
1563
 
1549
1564
  exports.BYMAX_QUEUE_CONNECTION_MODE = BYMAX_QUEUE_CONNECTION_MODE;
@@ -269,7 +269,7 @@ interface BulkJob<TData = unknown> {
269
269
  * @example
270
270
  * const status: JobStatus = 'waiting'
271
271
  */
272
- type JobStatus = 'waiting' | 'active' | 'completed' | 'failed' | 'delayed' | 'paused';
272
+ type JobStatus = 'waiting' | 'active' | 'completed' | 'failed' | 'delayed';
273
273
 
274
274
  /**
275
275
  * @fileoverview Instantaneous queue metrics snapshot type.
@@ -282,14 +282,14 @@ type JobStatus = 'waiting' | 'active' | 'completed' | 'failed' | 'delayed' | 'pa
282
282
  * @example
283
283
  * const metrics: QueueMetrics = {
284
284
  * queue: 'email',
285
- * counts: { waiting: 3, active: 1, completed: 120, failed: 2, delayed: 0, paused: 0 },
285
+ * counts: { waiting: 3, active: 1, completed: 120, failed: 2, delayed: 0 },
286
286
  * collectedAt: '2026-06-26T12:00:00.000Z',
287
287
  * }
288
288
  */
289
289
  interface QueueMetrics {
290
290
  /** Queue name the snapshot was taken from. */
291
291
  queue: string;
292
- /** Number of jobs in each of the six BullMQ statuses. */
292
+ /** Number of jobs in each of the five BullMQ statuses. */
293
293
  counts: Record<JobStatus, number>;
294
294
  /** ISO 8601 UTC timestamp when the snapshot was collected. */
295
295
  collectedAt: string;
@@ -359,6 +359,15 @@ declare class ConnectionResolver {
359
359
  constructor(options: BymaxQueueModuleOptions);
360
360
  /** Resolve and validate the connection. Call once during module bootstrap. */
361
361
  init(): Promise<void>;
362
+ /**
363
+ * Merge the consumer's options with the owned-connection policy
364
+ * (`lazyConnect: false`, so the client connects eagerly and `init` can wait
365
+ * for `ready`) and narrow the result to the shape ioredis 6's constructor
366
+ * accepts. The runtime object is unchanged — the cast only drops the
367
+ * `undefined` that `RedisOptions.replyMapping` carries and the constructor
368
+ * rejects.
369
+ */
370
+ private ownedOptions;
362
371
  /** The resolved Queue-role client. */
363
372
  getClient(): Redis;
364
373
  /** The resolved connection mode. */
@@ -421,6 +430,11 @@ declare class QueueService {
421
430
  * **producers** and do not change the at-least-once guarantee on the
422
431
  * **consumer** side.
423
432
  *
433
+ * The deduplication `id` is a shared key across the queue, so a multi-tenant
434
+ * producer MUST scope it by tenant: keyed on the value alone
435
+ * (`` `reindex:${term}` ``), tenant A enqueuing a term suppresses tenant B's job
436
+ * for the same term. Include the tenant (`` `${tenantId}:reindex:${term}` ``).
437
+ *
424
438
  * Deduplication modes (`deduplication: { id, ttl?, extend?, replace?, keepLastIfActive? }`):
425
439
  * - Simple `{ id }` — collapse duplicates until the in-flight job completes or fails.
426
440
  * - Throttle `{ id, ttl }` — ignore duplicates for `ttl` milliseconds.
@@ -434,9 +448,10 @@ declare class QueueService {
434
448
  * @param data - Typed job payload.
435
449
  * @param options - Per-job BullMQ options (priority, delay, `jobId`, `deduplication`, ...).
436
450
  * @returns The created job.
437
- * @example Throttle: at most one reindex per term every 5 seconds.
451
+ * @example Throttle: at most one reindex per term every 5 seconds. The dedup key
452
+ * carries the tenant so one tenant's term cannot suppress another's.
438
453
  * await queueService.enqueue('search', 'reindex', { term }, {
439
- * deduplication: { id: `reindex:${term}`, ttl: 5_000 },
454
+ * deduplication: { id: `${tenantId}:reindex:${term}`, ttl: 5_000 },
440
455
  * })
441
456
  */
442
457
  enqueue<TData = unknown, TResult = unknown>(queueName: string, jobName: string, data: TData, options?: JobsOptions): Promise<TypedJob<TData, TResult>>;
@@ -1396,7 +1411,6 @@ declare const JOB_STATUS: {
1396
1411
  readonly COMPLETED: "completed";
1397
1412
  readonly FAILED: "failed";
1398
1413
  readonly DELAYED: "delayed";
1399
- readonly PAUSED: "paused";
1400
1414
  };
1401
1415
 
1402
1416
  export { BYMAX_QUEUE_CONNECTION_MODE, BYMAX_QUEUE_OPTIONS, BYMAX_QUEUE_REDIS_CLIENT, BYMAX_QUEUE_RESOLVED_OPTIONS, type BulkJob, BymaxQueueModule, type BymaxQueueModuleAsyncOptions, type BymaxQueueModuleOptions, type BymaxQueueOptionsFactory, ConnectionResolver, DEFAULT_JOB_OPTIONS, DEFAULT_METRICS_CACHE_TTL_MS, DEFAULT_SHUTDOWN_DRAIN_TIMEOUT_MS, DEFAULT_WORKER_CONCURRENCY, FlowService, JOB_STATUS, type JobSchedulerRepeatOptions, type JobStatus, MAX_WORKER_CONCURRENCY, MetricsService, OnQueueEvent, OnWorkerEvent, Process, type ProcessHandlerMetadata, Processor, type ProcessorMetadata, type ProgrammaticWorkerConfig, QUEUE_ERROR_CODES, QUEUE_ERROR_MESSAGES, type QueueConnectionConfig, type QueueConnectionMode, type QueueErrorCode, type QueueEventListenerMetadata, type QueueEventName, QueueEventsRegistry, QueueException, QueueLifecycle, type QueueMetrics, QueueService, type SandboxedWorkerConfig, type WorkerEventName, type WorkerOptions, WorkerRegistry };
@@ -269,7 +269,7 @@ interface BulkJob<TData = unknown> {
269
269
  * @example
270
270
  * const status: JobStatus = 'waiting'
271
271
  */
272
- type JobStatus = 'waiting' | 'active' | 'completed' | 'failed' | 'delayed' | 'paused';
272
+ type JobStatus = 'waiting' | 'active' | 'completed' | 'failed' | 'delayed';
273
273
 
274
274
  /**
275
275
  * @fileoverview Instantaneous queue metrics snapshot type.
@@ -282,14 +282,14 @@ type JobStatus = 'waiting' | 'active' | 'completed' | 'failed' | 'delayed' | 'pa
282
282
  * @example
283
283
  * const metrics: QueueMetrics = {
284
284
  * queue: 'email',
285
- * counts: { waiting: 3, active: 1, completed: 120, failed: 2, delayed: 0, paused: 0 },
285
+ * counts: { waiting: 3, active: 1, completed: 120, failed: 2, delayed: 0 },
286
286
  * collectedAt: '2026-06-26T12:00:00.000Z',
287
287
  * }
288
288
  */
289
289
  interface QueueMetrics {
290
290
  /** Queue name the snapshot was taken from. */
291
291
  queue: string;
292
- /** Number of jobs in each of the six BullMQ statuses. */
292
+ /** Number of jobs in each of the five BullMQ statuses. */
293
293
  counts: Record<JobStatus, number>;
294
294
  /** ISO 8601 UTC timestamp when the snapshot was collected. */
295
295
  collectedAt: string;
@@ -359,6 +359,15 @@ declare class ConnectionResolver {
359
359
  constructor(options: BymaxQueueModuleOptions);
360
360
  /** Resolve and validate the connection. Call once during module bootstrap. */
361
361
  init(): Promise<void>;
362
+ /**
363
+ * Merge the consumer's options with the owned-connection policy
364
+ * (`lazyConnect: false`, so the client connects eagerly and `init` can wait
365
+ * for `ready`) and narrow the result to the shape ioredis 6's constructor
366
+ * accepts. The runtime object is unchanged — the cast only drops the
367
+ * `undefined` that `RedisOptions.replyMapping` carries and the constructor
368
+ * rejects.
369
+ */
370
+ private ownedOptions;
362
371
  /** The resolved Queue-role client. */
363
372
  getClient(): Redis;
364
373
  /** The resolved connection mode. */
@@ -421,6 +430,11 @@ declare class QueueService {
421
430
  * **producers** and do not change the at-least-once guarantee on the
422
431
  * **consumer** side.
423
432
  *
433
+ * The deduplication `id` is a shared key across the queue, so a multi-tenant
434
+ * producer MUST scope it by tenant: keyed on the value alone
435
+ * (`` `reindex:${term}` ``), tenant A enqueuing a term suppresses tenant B's job
436
+ * for the same term. Include the tenant (`` `${tenantId}:reindex:${term}` ``).
437
+ *
424
438
  * Deduplication modes (`deduplication: { id, ttl?, extend?, replace?, keepLastIfActive? }`):
425
439
  * - Simple `{ id }` — collapse duplicates until the in-flight job completes or fails.
426
440
  * - Throttle `{ id, ttl }` — ignore duplicates for `ttl` milliseconds.
@@ -434,9 +448,10 @@ declare class QueueService {
434
448
  * @param data - Typed job payload.
435
449
  * @param options - Per-job BullMQ options (priority, delay, `jobId`, `deduplication`, ...).
436
450
  * @returns The created job.
437
- * @example Throttle: at most one reindex per term every 5 seconds.
451
+ * @example Throttle: at most one reindex per term every 5 seconds. The dedup key
452
+ * carries the tenant so one tenant's term cannot suppress another's.
438
453
  * await queueService.enqueue('search', 'reindex', { term }, {
439
- * deduplication: { id: `reindex:${term}`, ttl: 5_000 },
454
+ * deduplication: { id: `${tenantId}:reindex:${term}`, ttl: 5_000 },
440
455
  * })
441
456
  */
442
457
  enqueue<TData = unknown, TResult = unknown>(queueName: string, jobName: string, data: TData, options?: JobsOptions): Promise<TypedJob<TData, TResult>>;
@@ -1396,7 +1411,6 @@ declare const JOB_STATUS: {
1396
1411
  readonly COMPLETED: "completed";
1397
1412
  readonly FAILED: "failed";
1398
1413
  readonly DELAYED: "delayed";
1399
- readonly PAUSED: "paused";
1400
1414
  };
1401
1415
 
1402
1416
  export { BYMAX_QUEUE_CONNECTION_MODE, BYMAX_QUEUE_OPTIONS, BYMAX_QUEUE_REDIS_CLIENT, BYMAX_QUEUE_RESOLVED_OPTIONS, type BulkJob, BymaxQueueModule, type BymaxQueueModuleAsyncOptions, type BymaxQueueModuleOptions, type BymaxQueueOptionsFactory, ConnectionResolver, DEFAULT_JOB_OPTIONS, DEFAULT_METRICS_CACHE_TTL_MS, DEFAULT_SHUTDOWN_DRAIN_TIMEOUT_MS, DEFAULT_WORKER_CONCURRENCY, FlowService, JOB_STATUS, type JobSchedulerRepeatOptions, type JobStatus, MAX_WORKER_CONCURRENCY, MetricsService, OnQueueEvent, OnWorkerEvent, Process, type ProcessHandlerMetadata, Processor, type ProcessorMetadata, type ProgrammaticWorkerConfig, QUEUE_ERROR_CODES, QUEUE_ERROR_MESSAGES, type QueueConnectionConfig, type QueueConnectionMode, type QueueErrorCode, type QueueEventListenerMetadata, type QueueEventName, QueueEventsRegistry, QueueException, QueueLifecycle, type QueueMetrics, QueueService, type SandboxedWorkerConfig, type WorkerEventName, type WorkerOptions, WorkerRegistry };
@@ -196,11 +196,22 @@ var ConnectionResolver = class {
196
196
  return;
197
197
  }
198
198
  this.mode = "mode-b-owned";
199
- __privateSet(this, _client, "url" in cfg ? new Redis(cfg.url, { ...cfg.options ?? {}, lazyConnect: false }) : new Redis({ ...cfg.options, lazyConnect: false }));
199
+ __privateSet(this, _client, "url" in cfg ? new Redis(cfg.url, this.ownedOptions(cfg.options)) : new Redis(this.ownedOptions(cfg.options)));
200
200
  await this.waitReady(
201
201
  __privateGet(this, _options).connectionReadyTimeoutMs ?? DEFAULT_CONNECTION_READY_TIMEOUT_MS
202
202
  );
203
203
  }
204
+ /**
205
+ * Merge the consumer's options with the owned-connection policy
206
+ * (`lazyConnect: false`, so the client connects eagerly and `init` can wait
207
+ * for `ready`) and narrow the result to the shape ioredis 6's constructor
208
+ * accepts. The runtime object is unchanged — the cast only drops the
209
+ * `undefined` that `RedisOptions.replyMapping` carries and the constructor
210
+ * rejects.
211
+ */
212
+ ownedOptions(options) {
213
+ return { ...options ?? {}, lazyConnect: false };
214
+ }
204
215
  /** The resolved Queue-role client. */
205
216
  getClient() {
206
217
  if (!__privateGet(this, _client)) {
@@ -376,6 +387,11 @@ var QueueService = class {
376
387
  * **producers** and do not change the at-least-once guarantee on the
377
388
  * **consumer** side.
378
389
  *
390
+ * The deduplication `id` is a shared key across the queue, so a multi-tenant
391
+ * producer MUST scope it by tenant: keyed on the value alone
392
+ * (`` `reindex:${term}` ``), tenant A enqueuing a term suppresses tenant B's job
393
+ * for the same term. Include the tenant (`` `${tenantId}:reindex:${term}` ``).
394
+ *
379
395
  * Deduplication modes (`deduplication: { id, ttl?, extend?, replace?, keepLastIfActive? }`):
380
396
  * - Simple `{ id }` — collapse duplicates until the in-flight job completes or fails.
381
397
  * - Throttle `{ id, ttl }` — ignore duplicates for `ttl` milliseconds.
@@ -389,9 +405,10 @@ var QueueService = class {
389
405
  * @param data - Typed job payload.
390
406
  * @param options - Per-job BullMQ options (priority, delay, `jobId`, `deduplication`, ...).
391
407
  * @returns The created job.
392
- * @example Throttle: at most one reindex per term every 5 seconds.
408
+ * @example Throttle: at most one reindex per term every 5 seconds. The dedup key
409
+ * carries the tenant so one tenant's term cannot suppress another's.
393
410
  * await queueService.enqueue('search', 'reindex', { term }, {
394
- * deduplication: { id: `reindex:${term}`, ttl: 5_000 },
411
+ * deduplication: { id: `${tenantId}:reindex:${term}`, ttl: 5_000 },
395
412
  * })
396
413
  */
397
414
  async enqueue(queueName, jobName, data, options) {
@@ -463,8 +480,7 @@ var QueueService = class {
463
480
  "active",
464
481
  "completed",
465
482
  "failed",
466
- "delayed",
467
- "paused"
483
+ "delayed"
468
484
  );
469
485
  return {
470
486
  queue: queueName,
@@ -1333,9 +1349,9 @@ QueueLifecycle = __decorateClass([
1333
1349
 
1334
1350
  // src/server/bymax-queue.module.ts
1335
1351
  var { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN, OPTIONS_TYPE, ASYNC_OPTIONS_TYPE } = (
1336
- // The `moduleName` shapes the generated token's description only — a label read in Nest's DI
1337
- // error output, never compared against anything. Mutation testing reports it as surviving for
1338
- // that reason; no Stryker directive attaches inside this builder chain, so the note lives here.
1352
+ // The `moduleName` shapes the generated option token's identifier — `BYMAX_QUEUE_MODULE_OPTIONS`,
1353
+ // which the module spec asserts by value; emptying the name would degrade it to an opaque
1354
+ // `CONFIGURABLE_MODULE_OPTIONS[<hash>]`, so the argument is a caught regression, not a free label.
1339
1355
  new ConfigurableModuleBuilder({ moduleName: "BymaxQueue" }).setClassMethodName("forRoot").setFactoryMethodName("createQueueOptions").setExtras({ isGlobal: true }, (definition, extras) => ({
1340
1356
  ...definition,
1341
1357
  global: extras.isGlobal
@@ -1540,8 +1556,7 @@ var JOB_STATUS = {
1540
1556
  ACTIVE: "active",
1541
1557
  COMPLETED: "completed",
1542
1558
  FAILED: "failed",
1543
- DELAYED: "delayed",
1544
- PAUSED: "paused"
1559
+ DELAYED: "delayed"
1545
1560
  };
1546
1561
 
1547
1562
  export { BYMAX_QUEUE_CONNECTION_MODE, BYMAX_QUEUE_OPTIONS, BYMAX_QUEUE_REDIS_CLIENT, BYMAX_QUEUE_RESOLVED_OPTIONS, BymaxQueueModule, ConnectionResolver, DEFAULT_JOB_OPTIONS, DEFAULT_METRICS_CACHE_TTL_MS, DEFAULT_SHUTDOWN_DRAIN_TIMEOUT_MS, DEFAULT_WORKER_CONCURRENCY, FlowService, JOB_STATUS, MAX_WORKER_CONCURRENCY, MetricsService, OnQueueEvent, OnWorkerEvent, Process, Processor, QUEUE_ERROR_CODES, QUEUE_ERROR_MESSAGES, QueueEventsRegistry, QueueException, QueueService, WorkerRegistry };
@@ -6,8 +6,7 @@ var JOB_STATUS = {
6
6
  ACTIVE: "active",
7
7
  COMPLETED: "completed",
8
8
  FAILED: "failed",
9
- DELAYED: "delayed",
10
- PAUSED: "paused"
9
+ DELAYED: "delayed"
11
10
  };
12
11
 
13
12
  // src/shared/constants/error-codes.ts
@@ -9,7 +9,7 @@
9
9
  * @example
10
10
  * const status: JobStatus = 'waiting'
11
11
  */
12
- type JobStatus = 'waiting' | 'active' | 'completed' | 'failed' | 'delayed' | 'paused';
12
+ type JobStatus = 'waiting' | 'active' | 'completed' | 'failed' | 'delayed';
13
13
 
14
14
  /**
15
15
  * @fileoverview Instantaneous queue metrics snapshot type.
@@ -22,14 +22,14 @@ type JobStatus = 'waiting' | 'active' | 'completed' | 'failed' | 'delayed' | 'pa
22
22
  * @example
23
23
  * const metrics: QueueMetrics = {
24
24
  * queue: 'email',
25
- * counts: { waiting: 3, active: 1, completed: 120, failed: 2, delayed: 0, paused: 0 },
25
+ * counts: { waiting: 3, active: 1, completed: 120, failed: 2, delayed: 0 },
26
26
  * collectedAt: '2026-06-26T12:00:00.000Z',
27
27
  * }
28
28
  */
29
29
  interface QueueMetrics {
30
30
  /** Queue name the snapshot was taken from. */
31
31
  queue: string;
32
- /** Number of jobs in each of the six BullMQ statuses. */
32
+ /** Number of jobs in each of the five BullMQ statuses. */
33
33
  counts: Record<JobStatus, number>;
34
34
  /** ISO 8601 UTC timestamp when the snapshot was collected. */
35
35
  collectedAt: string;
@@ -94,7 +94,6 @@ declare const JOB_STATUS: {
94
94
  readonly COMPLETED: "completed";
95
95
  readonly FAILED: "failed";
96
96
  readonly DELAYED: "delayed";
97
- readonly PAUSED: "paused";
98
97
  };
99
98
 
100
99
  /**
@@ -9,7 +9,7 @@
9
9
  * @example
10
10
  * const status: JobStatus = 'waiting'
11
11
  */
12
- type JobStatus = 'waiting' | 'active' | 'completed' | 'failed' | 'delayed' | 'paused';
12
+ type JobStatus = 'waiting' | 'active' | 'completed' | 'failed' | 'delayed';
13
13
 
14
14
  /**
15
15
  * @fileoverview Instantaneous queue metrics snapshot type.
@@ -22,14 +22,14 @@ type JobStatus = 'waiting' | 'active' | 'completed' | 'failed' | 'delayed' | 'pa
22
22
  * @example
23
23
  * const metrics: QueueMetrics = {
24
24
  * queue: 'email',
25
- * counts: { waiting: 3, active: 1, completed: 120, failed: 2, delayed: 0, paused: 0 },
25
+ * counts: { waiting: 3, active: 1, completed: 120, failed: 2, delayed: 0 },
26
26
  * collectedAt: '2026-06-26T12:00:00.000Z',
27
27
  * }
28
28
  */
29
29
  interface QueueMetrics {
30
30
  /** Queue name the snapshot was taken from. */
31
31
  queue: string;
32
- /** Number of jobs in each of the six BullMQ statuses. */
32
+ /** Number of jobs in each of the five BullMQ statuses. */
33
33
  counts: Record<JobStatus, number>;
34
34
  /** ISO 8601 UTC timestamp when the snapshot was collected. */
35
35
  collectedAt: string;
@@ -94,7 +94,6 @@ declare const JOB_STATUS: {
94
94
  readonly COMPLETED: "completed";
95
95
  readonly FAILED: "failed";
96
96
  readonly DELAYED: "delayed";
97
- readonly PAUSED: "paused";
98
97
  };
99
98
 
100
99
  /**
@@ -4,8 +4,7 @@ var JOB_STATUS = {
4
4
  ACTIVE: "active",
5
5
  COMPLETED: "completed",
6
6
  FAILED: "failed",
7
- DELAYED: "delayed",
8
- PAUSED: "paused"
7
+ DELAYED: "delayed"
9
8
  };
10
9
 
11
10
  // src/shared/constants/error-codes.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bymax-one/nest-queue",
3
- "version": "1.0.10",
3
+ "version": "1.2.0",
4
4
  "description": "NestJS dynamic module wrapping BullMQ — typed jobs, flows, job schedulers, deduplication, OpenTelemetry, graceful shutdown",
5
5
  "author": "Bymax One <support@bymax.one>",
6
6
  "license": "MIT",
@@ -62,8 +62,8 @@
62
62
  "lint": "eslint src scripts test",
63
63
  "lint:fix": "eslint src scripts test --fix",
64
64
  "mutation": "stryker run",
65
+ "mutation:full": "node -e \"require('node:fs').rmSync('reports/stryker-incremental.json',{force:true,recursive:true})\" && stryker run",
65
66
  "mutation:dry-run": "stryker run --dryRunOnly",
66
- "mutation:incremental": "stryker run --incremental",
67
67
  "prepare": "husky",
68
68
  "prepublishOnly": "pnpm clean && pnpm typecheck && pnpm test:types && pnpm lint && pnpm check:mutants && pnpm test:cov:all && pnpm build && pnpm check:published",
69
69
  "release": "npm publish --provenance --access public",
@@ -82,9 +82,9 @@
82
82
  "peerDependencies": {
83
83
  "@nestjs/common": "^11.0.16",
84
84
  "@nestjs/core": "^11.1.18",
85
- "bullmq": "^5.16.0",
86
- "bullmq-otel": "^1.0.0",
87
- "ioredis": "^5.0.0",
85
+ "bullmq": "^6.0.0",
86
+ "bullmq-otel": "^2.0.0",
87
+ "ioredis": "^6.0.0",
88
88
  "reflect-metadata": "^0.2.0"
89
89
  },
90
90
  "peerDependenciesMeta": {
@@ -96,20 +96,19 @@
96
96
  "@arethetypeswrong/cli": "^0.18.5",
97
97
  "@commitlint/cli": "^21.2.1",
98
98
  "@commitlint/config-conventional": "^21.2.0",
99
- "@eslint/js": "^9.39.5",
99
+ "@eslint/js": "^10.0.1",
100
100
  "@nestjs/common": "^11.1.28",
101
101
  "@nestjs/core": "^11.1.28",
102
102
  "@nestjs/testing": "^11.1.28",
103
103
  "@stryker-mutator/core": "^9.6.1",
104
104
  "@stryker-mutator/jest-runner": "^9.6.1",
105
105
  "@types/jest": "^30.0.0",
106
- "@types/node": "^24.13.3",
107
- "bullmq": "^5.81.2",
108
- "eslint": "^9.39.5",
106
+ "@types/node": "^26.2.0",
107
+ "bullmq": "^6.0.0",
108
+ "eslint": "^10.8.1",
109
109
  "eslint-config-prettier": "^10.1.8",
110
110
  "husky": "^9.1.7",
111
- "ioredis": "5.11.1",
112
- "ioredis-mock": "^8.9.0",
111
+ "ioredis": "^6.0.0",
113
112
  "jest": "^30.0.0",
114
113
  "jest-environment-node": "^30.4.1",
115
114
  "lint-staged": "^17.2.0",
@@ -119,7 +118,7 @@
119
118
  "testcontainers": "^12.0.4",
120
119
  "ts-jest": "^29.4.12",
121
120
  "tsup": "^8.3.0",
122
- "typescript": "^5.6.0",
121
+ "typescript": "^6.0.3",
123
122
  "typescript-eslint": "^8.65.0"
124
123
  },
125
124
  "keywords": [