@bymax-one/nest-queue 1.0.11 → 1.2.2

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,78 @@ versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
8
  ---
9
9
 
10
+ ## [1.2.2] — 2026-09-04
11
+
12
+ **1.2.1 was tagged but never published**, so this release carries its documentation fix as well
13
+ as the one below. `dist/` changes here for the first time since 1.2.0 — JSDoc reaches the emitted
14
+ `.d.ts`, so a doc-block correction is a change to the published types.
15
+
16
+ ### Fixed
17
+
18
+ - **The `enqueue` deduplication example asserted a safety property it did not provide.** It showed
19
+ `` `${tenantId}:reindex:${term}` `` and stated that "one tenant's term cannot suppress another's".
20
+ Two variable fields joined by a delimiter either may contain do not produce a unique key: with
21
+ `` `${tenantId}:${term}` ``, the pairs `('a', 'b:c')` and `('a:b', 'c')` both yield `a:b:c`. The
22
+ guidance now percent-encodes every variable field, and the claim states what the construction
23
+ delivers. This is documentation a consumer copies into their own key-building code, which is why
24
+ the shape mattered more than whether `:reindex:` in particular was reachable.
25
+ - Carried from 1.2.1: **the `MetricsService` example in `README.md` called two methods that do not
26
+ exist**, `getMetrics(name)` and `getAll([names])`, where the service exposes `get(name)` and a
27
+ no-argument `getAll()`.
28
+
29
+ ### Internal
30
+
31
+ Not shipped, listed because the tag covers them: bullmq raised from 6.0.10 to 6.3.4 in the
32
+ workspace override; `fast-uri`, `qs` and `fastify` floors raised past published advisories; the
33
+ README-snippet gate now type-checks through receivers; `AGENTS.md` and its sync workflow added.
34
+
35
+ ## [1.2.1] — 2026-08-30
36
+
37
+ Documentation only. `dist/` is byte-identical to `1.2.0`; the published tarball was unpacked and
38
+ diffed against a fresh build to confirm it rather than assert it. The release exists because
39
+ `README.md` ships inside the package, so a fix that stays on `main` leaves the npm page — where
40
+ the example is actually read — still wrong.
41
+
42
+ ### Fixed
43
+
44
+ - **The `MetricsService` example in `README.md` called two methods that do not exist.** It showed
45
+ `getMetrics(name)` and `getAll([names])`; the service exposes `get(name)` and a no-argument
46
+ `getAll()` returning `readonly QueueMetrics[]` for every queue currently cached by
47
+ `QueueService`. Code copied from that example failed to compile, and the wrong `getAll` signature
48
+ also implied a per-call queue filter the API does not offer. Every other call in the README was
49
+ checked against the source in the same pass and is correct.
50
+
51
+ ## [1.2.0] — 2026-08-10
52
+
53
+ **Breaking, shipped as a minor by choice.** The peer ranges move to the next major of each
54
+ Redis/BullMQ package and the `'paused'` job state is removed from the public status surface —
55
+ changes that would normally warrant a major. They ship under a minor because the package has no
56
+ downstream consumer yet; every breaking detail is called out below so a first adopter sees it.
57
+
58
+ ### Changed
59
+
60
+ - **Peer dependencies migrated to the next major:** `bullmq` `^5` → `^6`, `bullmq-otel` `^1` →
61
+ `^2`, and `ioredis` `^5` → `^6`. A consumer must upgrade all three together. `ioredis` 6 keeps
62
+ `'legacy'` reply mapping by default, so BullMQ's reply-shape assumptions are unaffected; it does
63
+ require Node.js ≥ 20, already covered by this package's `engines`.
64
+
65
+ ### Removed
66
+
67
+ - **`'paused'` is no longer a job status.** BullMQ 6 dropped `'paused'` from its job-state set —
68
+ jobs on a paused queue now live in `'wait'`. It is therefore removed from the `JobStatus` union,
69
+ from the `JOB_STATUS` constant (`JOB_STATUS.PAUSED` no longer exists), and from the
70
+ `getMetrics().counts` shape, which now reports the five remaining statuses (`waiting`, `active`,
71
+ `completed`, `failed`, `delayed`). The queue-lifecycle `'paused'`/`'resumed'` **events**
72
+ (`@OnQueueEvent`) and the `pauseQueue`/`resumeQueue` methods are unchanged — those describe the
73
+ queue, not a job.
74
+
75
+ ### Internal
76
+
77
+ - Dev-tooling bumps: `eslint` `^10`, `typescript` `^6`, `@types/node` `^26`, plus `@eslint/js`
78
+ `^10`. These do not ship and do not change `dist/`. The package's `engines` stays at Node ≥ 24.
79
+ - Mutation gate tightened: Stryker `break`/`high` raised from 95/99 → **100** (the run is at 100%,
80
+ 0 survivors).
81
+
10
82
  ## [1.0.11] — 2026-08-10
11
83
 
12
84
  **Documentation only.** The shipped declaration comments change (`dts` preserves them); no runtime
@@ -456,6 +528,9 @@ v6 peer range.
456
528
 
457
529
  ---
458
530
 
531
+ [1.2.2]: https://github.com/bymaxone/nest-queue/compare/v1.2.1...v1.2.2
532
+ [1.2.1]: https://github.com/bymaxone/nest-queue/compare/v1.2.0...v1.2.1
533
+ [1.2.0]: https://github.com/bymaxone/nest-queue/compare/v1.0.11...v1.2.0
459
534
  [1.0.11]: https://github.com/bymaxone/nest-queue/compare/v1.0.10...v1.0.11
460
535
  [1.0.10]: https://github.com/bymaxone/nest-queue/compare/v1.0.9...v1.0.10
461
536
  [1.0.9]: https://github.com/bymaxone/nest-queue/compare/v1.0.8...v1.0.9
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
@@ -400,8 +400,8 @@ BymaxQueueModule.forRoot({
400
400
  import { MetricsService } from '@bymax-one/nest-queue'
401
401
  import type { QueueMetrics } from '@bymax-one/nest-queue'
402
402
 
403
- const metrics: QueueMetrics = await this.metricsService.getMetrics('email')
404
- const all = await this.metricsService.getAll(['email', 'notifications'])
403
+ const metrics: QueueMetrics = await this.metricsService.get('email')
404
+ const all: readonly QueueMetrics[] = await this.metricsService.getAll()
405
405
  ```
406
406
 
407
407
  ---
@@ -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)) {
@@ -381,7 +392,13 @@ exports.QueueService = class QueueService {
381
392
  * The deduplication `id` is a shared key across the queue, so a multi-tenant
382
393
  * producer MUST scope it by tenant: keyed on the value alone
383
394
  * (`` `reindex:${term}` ``), tenant A enqueuing a term suppresses tenant B's job
384
- * for the same term. Include the tenant (`` `${tenantId}:reindex:${term}` ``).
395
+ * for the same term.
396
+ *
397
+ * Scoping alone is not enough. Two variable fields joined by a delimiter that
398
+ * either may contain do not produce a unique key: with `` `${tenantId}:${term}` ``,
399
+ * the pairs `('a', 'b:c')` and `('a:b', 'c')` both yield `a:b:c`. Percent-encode
400
+ * every variable field so the delimiter cannot appear inside one:
401
+ * `` `${encodeURIComponent(tenantId)}:${encodeURIComponent(term)}` ``.
385
402
  *
386
403
  * Deduplication modes (`deduplication: { id, ttl?, extend?, replace?, keepLastIfActive? }`):
387
404
  * - Simple `{ id }` — collapse duplicates until the in-flight job completes or fails.
@@ -396,10 +413,11 @@ exports.QueueService = class QueueService {
396
413
  * @param data - Typed job payload.
397
414
  * @param options - Per-job BullMQ options (priority, delay, `jobId`, `deduplication`, ...).
398
415
  * @returns The created job.
399
- * @example Throttle: at most one reindex per term every 5 seconds. The dedup key
400
- * carries the tenant so one tenant's term cannot suppress another's.
416
+ * @example Throttle: at most one reindex per term every 5 seconds. Both variable
417
+ * fields are percent-encoded, so no other tenant/term pair can build this key.
418
+ * const dedupId = `${encodeURIComponent(tenantId)}:reindex:${encodeURIComponent(term)}`
401
419
  * await queueService.enqueue('search', 'reindex', { term }, {
402
- * deduplication: { id: `${tenantId}:reindex:${term}`, ttl: 5_000 },
420
+ * deduplication: { id: dedupId, ttl: 5_000 },
403
421
  * })
404
422
  */
405
423
  async enqueue(queueName, jobName, data, options) {
@@ -471,8 +489,7 @@ exports.QueueService = class QueueService {
471
489
  "active",
472
490
  "completed",
473
491
  "failed",
474
- "delayed",
475
- "paused"
492
+ "delayed"
476
493
  );
477
494
  return {
478
495
  queue: queueName,
@@ -1341,9 +1358,9 @@ QueueLifecycle = __decorateClass([
1341
1358
 
1342
1359
  // src/server/bymax-queue.module.ts
1343
1360
  var { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN, OPTIONS_TYPE, ASYNC_OPTIONS_TYPE } = (
1344
- // The `moduleName` shapes the generated token's description only — a label read in Nest's DI
1345
- // error output, never compared against anything. Mutation testing reports it as surviving for
1346
- // that reason; no Stryker directive attaches inside this builder chain, so the note lives here.
1361
+ // The `moduleName` shapes the generated option token's identifier — `BYMAX_QUEUE_MODULE_OPTIONS`,
1362
+ // which the module spec asserts by value; emptying the name would degrade it to an opaque
1363
+ // `CONFIGURABLE_MODULE_OPTIONS[<hash>]`, so the argument is a caught regression, not a free label.
1347
1364
  new common.ConfigurableModuleBuilder({ moduleName: "BymaxQueue" }).setClassMethodName("forRoot").setFactoryMethodName("createQueueOptions").setExtras({ isGlobal: true }, (definition, extras) => ({
1348
1365
  ...definition,
1349
1366
  global: extras.isGlobal
@@ -1548,8 +1565,7 @@ var JOB_STATUS = {
1548
1565
  ACTIVE: "active",
1549
1566
  COMPLETED: "completed",
1550
1567
  FAILED: "failed",
1551
- DELAYED: "delayed",
1552
- PAUSED: "paused"
1568
+ DELAYED: "delayed"
1553
1569
  };
1554
1570
 
1555
1571
  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. */
@@ -424,7 +433,13 @@ declare class QueueService {
424
433
  * The deduplication `id` is a shared key across the queue, so a multi-tenant
425
434
  * producer MUST scope it by tenant: keyed on the value alone
426
435
  * (`` `reindex:${term}` ``), tenant A enqueuing a term suppresses tenant B's job
427
- * for the same term. Include the tenant (`` `${tenantId}:reindex:${term}` ``).
436
+ * for the same term.
437
+ *
438
+ * Scoping alone is not enough. Two variable fields joined by a delimiter that
439
+ * either may contain do not produce a unique key: with `` `${tenantId}:${term}` ``,
440
+ * the pairs `('a', 'b:c')` and `('a:b', 'c')` both yield `a:b:c`. Percent-encode
441
+ * every variable field so the delimiter cannot appear inside one:
442
+ * `` `${encodeURIComponent(tenantId)}:${encodeURIComponent(term)}` ``.
428
443
  *
429
444
  * Deduplication modes (`deduplication: { id, ttl?, extend?, replace?, keepLastIfActive? }`):
430
445
  * - Simple `{ id }` — collapse duplicates until the in-flight job completes or fails.
@@ -439,10 +454,11 @@ declare class QueueService {
439
454
  * @param data - Typed job payload.
440
455
  * @param options - Per-job BullMQ options (priority, delay, `jobId`, `deduplication`, ...).
441
456
  * @returns The created job.
442
- * @example Throttle: at most one reindex per term every 5 seconds. The dedup key
443
- * carries the tenant so one tenant's term cannot suppress another's.
457
+ * @example Throttle: at most one reindex per term every 5 seconds. Both variable
458
+ * fields are percent-encoded, so no other tenant/term pair can build this key.
459
+ * const dedupId = `${encodeURIComponent(tenantId)}:reindex:${encodeURIComponent(term)}`
444
460
  * await queueService.enqueue('search', 'reindex', { term }, {
445
- * deduplication: { id: `${tenantId}:reindex:${term}`, ttl: 5_000 },
461
+ * deduplication: { id: dedupId, ttl: 5_000 },
446
462
  * })
447
463
  */
448
464
  enqueue<TData = unknown, TResult = unknown>(queueName: string, jobName: string, data: TData, options?: JobsOptions): Promise<TypedJob<TData, TResult>>;
@@ -1402,7 +1418,6 @@ declare const JOB_STATUS: {
1402
1418
  readonly COMPLETED: "completed";
1403
1419
  readonly FAILED: "failed";
1404
1420
  readonly DELAYED: "delayed";
1405
- readonly PAUSED: "paused";
1406
1421
  };
1407
1422
 
1408
1423
  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. */
@@ -424,7 +433,13 @@ declare class QueueService {
424
433
  * The deduplication `id` is a shared key across the queue, so a multi-tenant
425
434
  * producer MUST scope it by tenant: keyed on the value alone
426
435
  * (`` `reindex:${term}` ``), tenant A enqueuing a term suppresses tenant B's job
427
- * for the same term. Include the tenant (`` `${tenantId}:reindex:${term}` ``).
436
+ * for the same term.
437
+ *
438
+ * Scoping alone is not enough. Two variable fields joined by a delimiter that
439
+ * either may contain do not produce a unique key: with `` `${tenantId}:${term}` ``,
440
+ * the pairs `('a', 'b:c')` and `('a:b', 'c')` both yield `a:b:c`. Percent-encode
441
+ * every variable field so the delimiter cannot appear inside one:
442
+ * `` `${encodeURIComponent(tenantId)}:${encodeURIComponent(term)}` ``.
428
443
  *
429
444
  * Deduplication modes (`deduplication: { id, ttl?, extend?, replace?, keepLastIfActive? }`):
430
445
  * - Simple `{ id }` — collapse duplicates until the in-flight job completes or fails.
@@ -439,10 +454,11 @@ declare class QueueService {
439
454
  * @param data - Typed job payload.
440
455
  * @param options - Per-job BullMQ options (priority, delay, `jobId`, `deduplication`, ...).
441
456
  * @returns The created job.
442
- * @example Throttle: at most one reindex per term every 5 seconds. The dedup key
443
- * carries the tenant so one tenant's term cannot suppress another's.
457
+ * @example Throttle: at most one reindex per term every 5 seconds. Both variable
458
+ * fields are percent-encoded, so no other tenant/term pair can build this key.
459
+ * const dedupId = `${encodeURIComponent(tenantId)}:reindex:${encodeURIComponent(term)}`
444
460
  * await queueService.enqueue('search', 'reindex', { term }, {
445
- * deduplication: { id: `${tenantId}:reindex:${term}`, ttl: 5_000 },
461
+ * deduplication: { id: dedupId, ttl: 5_000 },
446
462
  * })
447
463
  */
448
464
  enqueue<TData = unknown, TResult = unknown>(queueName: string, jobName: string, data: TData, options?: JobsOptions): Promise<TypedJob<TData, TResult>>;
@@ -1402,7 +1418,6 @@ declare const JOB_STATUS: {
1402
1418
  readonly COMPLETED: "completed";
1403
1419
  readonly FAILED: "failed";
1404
1420
  readonly DELAYED: "delayed";
1405
- readonly PAUSED: "paused";
1406
1421
  };
1407
1422
 
1408
1423
  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)) {
@@ -379,7 +390,13 @@ var QueueService = class {
379
390
  * The deduplication `id` is a shared key across the queue, so a multi-tenant
380
391
  * producer MUST scope it by tenant: keyed on the value alone
381
392
  * (`` `reindex:${term}` ``), tenant A enqueuing a term suppresses tenant B's job
382
- * for the same term. Include the tenant (`` `${tenantId}:reindex:${term}` ``).
393
+ * for the same term.
394
+ *
395
+ * Scoping alone is not enough. Two variable fields joined by a delimiter that
396
+ * either may contain do not produce a unique key: with `` `${tenantId}:${term}` ``,
397
+ * the pairs `('a', 'b:c')` and `('a:b', 'c')` both yield `a:b:c`. Percent-encode
398
+ * every variable field so the delimiter cannot appear inside one:
399
+ * `` `${encodeURIComponent(tenantId)}:${encodeURIComponent(term)}` ``.
383
400
  *
384
401
  * Deduplication modes (`deduplication: { id, ttl?, extend?, replace?, keepLastIfActive? }`):
385
402
  * - Simple `{ id }` — collapse duplicates until the in-flight job completes or fails.
@@ -394,10 +411,11 @@ var QueueService = class {
394
411
  * @param data - Typed job payload.
395
412
  * @param options - Per-job BullMQ options (priority, delay, `jobId`, `deduplication`, ...).
396
413
  * @returns The created job.
397
- * @example Throttle: at most one reindex per term every 5 seconds. The dedup key
398
- * carries the tenant so one tenant's term cannot suppress another's.
414
+ * @example Throttle: at most one reindex per term every 5 seconds. Both variable
415
+ * fields are percent-encoded, so no other tenant/term pair can build this key.
416
+ * const dedupId = `${encodeURIComponent(tenantId)}:reindex:${encodeURIComponent(term)}`
399
417
  * await queueService.enqueue('search', 'reindex', { term }, {
400
- * deduplication: { id: `${tenantId}:reindex:${term}`, ttl: 5_000 },
418
+ * deduplication: { id: dedupId, ttl: 5_000 },
401
419
  * })
402
420
  */
403
421
  async enqueue(queueName, jobName, data, options) {
@@ -469,8 +487,7 @@ var QueueService = class {
469
487
  "active",
470
488
  "completed",
471
489
  "failed",
472
- "delayed",
473
- "paused"
490
+ "delayed"
474
491
  );
475
492
  return {
476
493
  queue: queueName,
@@ -1339,9 +1356,9 @@ QueueLifecycle = __decorateClass([
1339
1356
 
1340
1357
  // src/server/bymax-queue.module.ts
1341
1358
  var { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN, OPTIONS_TYPE, ASYNC_OPTIONS_TYPE } = (
1342
- // The `moduleName` shapes the generated token's description only — a label read in Nest's DI
1343
- // error output, never compared against anything. Mutation testing reports it as surviving for
1344
- // that reason; no Stryker directive attaches inside this builder chain, so the note lives here.
1359
+ // The `moduleName` shapes the generated option token's identifier — `BYMAX_QUEUE_MODULE_OPTIONS`,
1360
+ // which the module spec asserts by value; emptying the name would degrade it to an opaque
1361
+ // `CONFIGURABLE_MODULE_OPTIONS[<hash>]`, so the argument is a caught regression, not a free label.
1345
1362
  new ConfigurableModuleBuilder({ moduleName: "BymaxQueue" }).setClassMethodName("forRoot").setFactoryMethodName("createQueueOptions").setExtras({ isGlobal: true }, (definition, extras) => ({
1346
1363
  ...definition,
1347
1364
  global: extras.isGlobal
@@ -1546,8 +1563,7 @@ var JOB_STATUS = {
1546
1563
  ACTIVE: "active",
1547
1564
  COMPLETED: "completed",
1548
1565
  FAILED: "failed",
1549
- DELAYED: "delayed",
1550
- PAUSED: "paused"
1566
+ DELAYED: "delayed"
1551
1567
  };
1552
1568
 
1553
1569
  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.11",
3
+ "version": "1.2.2",
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",
@@ -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
- "@stryker-mutator/core": "^9.6.1",
104
- "@stryker-mutator/jest-runner": "^9.6.1",
103
+ "@stryker-mutator/core": "^10.0.0",
104
+ "@stryker-mutator/jest-runner": "^10.0.0",
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": [