@bymax-one/nest-queue 1.2.12 → 1.2.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/CHANGELOG.md +29 -1
  2. package/README.md +250 -324
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -11,6 +11,33 @@ versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
11
11
 
12
12
  ---
13
13
 
14
+ ## [1.2.13] — 2026-11-12
15
+
16
+ No action required. Measured with `diff -r` of the unpacked 1.2.12 tarball against a fresh
17
+ `npm pack`, the files that differ are `README.md`, `CHANGELOG.md` and the `version` field of
18
+ `package.json`; `dist/` is byte-identical, so the public API, the declaration files and the peer
19
+ ranges are those of 1.2.12.
20
+
21
+ ### Documentation
22
+
23
+ - **The bundle budgets are no longer misquoted.** The Testing section gave "server ≤ 18 KiB brotli,
24
+ shared ≤ 2 500 B"; the gate sets 9 200 B and 440 B. The README now points at `pnpm size` instead of
25
+ restating the numbers.
26
+ - **The tech stack names the real end-to-end dependency.** It listed `@testcontainers/redis`, which
27
+ is not a dependency; the suite uses `testcontainers`.
28
+ - **The Quick Start no longer implies `isGlobal: true` is needed.** It is the default, so the option is
29
+ gone from the snippet and the step says `QueueService` is injectable without it.
30
+ - **The connection-release claim covers only what the library opens.** In Mode A the shared client is
31
+ yours and the library never closes it.
32
+ - **Mode A, Mode B and the connection budget are one Connections section**, the deduplication examples
33
+ are one block, and the msgpackr entry is a short paragraph. API Reference and the error catalog now
34
+ follow Testing, the tech stack is a badge row, and the Quick Start is numbered.
35
+ - **Removed what a reader of the package does not need:** a note about an internal test fixture, a
36
+ history line about a BullMQ state, the pnpm 10 versus 11 narration, and the pointers to the technical
37
+ specification.
38
+
39
+ ---
40
+
14
41
  ## [1.2.12] — 2026-11-09
15
42
 
16
43
  No action required. The public API, the declaration files and the peer ranges are unchanged
@@ -895,7 +922,8 @@ v6 peer range.
895
922
 
896
923
  ---
897
924
 
898
- [Unreleased]: https://github.com/bymaxone/nest-queue/compare/v1.2.12...HEAD
925
+ [Unreleased]: https://github.com/bymaxone/nest-queue/compare/v1.2.13...HEAD
926
+ [1.2.13]: https://github.com/bymaxone/nest-queue/compare/v1.2.12...v1.2.13
899
927
  [1.2.12]: https://github.com/bymaxone/nest-queue/compare/v1.2.11...v1.2.12
900
928
  [1.2.11]: https://github.com/bymaxone/nest-queue/compare/v1.2.10...v1.2.11
901
929
  [1.2.10]: https://github.com/bymaxone/nest-queue/compare/v1.2.9...v1.2.10
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 6 · Workers · Flows · Job Schedulers · Deduplication · OpenTelemetry · Graceful Shutdown · Zero Runtime Dependencies</sub>
9
+ <sub>BullMQ 6 · Workers · Flows · Job Schedulers · Deduplication · OpenTelemetry · Graceful Shutdown</sub>
10
10
  </p>
11
11
 
12
12
  <p align="center">
@@ -42,24 +42,13 @@ OpenTelemetry passthrough, and a tested bounded graceful-shutdown protocol.
42
42
  The library has **zero direct dependencies** — `bullmq`, `ioredis`, and `@nestjs/*` arrive as
43
43
  peer dependencies, so you control exact versions and the supply-chain surface stays minimal.
44
44
 
45
- ### Why nest-queue?
46
-
47
- - **One setup, every queue.** A single `forRoot`/`forRootAsync`; queues are created on demand by
48
- name — no `registerQueue()` per queue.
49
- - **Correct connections by default.** Dual-mode (bring-your-own client or lib-owned) with the
50
- per-role `maxRetriesPerRequest` policy applied automatically — the Queue connection keeps
51
- default retries (fail fast), only the duplicated Worker/QueueEvents connections force `null`.
52
- - **Current BullMQ API only.** Recurring jobs use Job Schedulers (`upsertJobScheduler`), never the
53
- removed `addRepeatable`. Cron is validated by BullMQ's own parser, never a hand-rolled regex.
54
- - **Safe shutdown.** A bounded-drain protocol with at-least-once semantics, force-close, and
55
- stalled-job accounting — verified against a real Redis (Testcontainers) E2E suite.
56
-
57
45
  ---
58
46
 
59
47
  ## 🔥 Features
60
48
 
61
49
  ### 📤 Producing
62
50
 
51
+ - ✅ **One setup, every queue** — a single `forRoot` / `forRootAsync`; queues are created on demand by name, with no `registerQueue()` per queue
63
52
  - ✅ **Typed producer API** — `enqueue<TData, TResult>()` threads both generics through to the returned `Job`, so `job.data` and `job.returnvalue` need no cast
64
53
  - ✅ **Bounded bulk insert** — `enqueueBulk` adds up to 1 000 jobs in a single Redis roundtrip and rejects a larger batch outright, rather than letting one call inflate the heap
65
54
  - ✅ **Native deduplication** — simple, throttle, debounce and keep-last-if-active, passed straight through to BullMQ; the library writes no deduplication logic of its own
@@ -82,8 +71,6 @@ peer dependencies, so you control exact versions and the supply-chain surface st
82
71
 
83
72
  ### 🧩 Developer Experience
84
73
 
85
- - ✅ **Zero runtime dependencies** — BullMQ, ioredis and `@nestjs/*` all arrive as peer dependencies, so you pin the versions and the supply-chain surface stays yours
86
- - ✅ **Two subpaths** — the server surface, and `./shared` carrying types and constants that pull in nothing at all
87
74
  - ✅ **Stable error catalog** — namespaced codes exported from both subpaths, so a frontend maps a `code` without importing the server
88
75
  - ✅ **Dual-format output** — ESM + CJS with declarations for each format, verified against the packed tarball on every PR
89
76
  - ✅ **Opt-in observability** — OpenTelemetry passthrough via `bullmq-otel`, plus a `MetricsService` with a configurable TTL cache
@@ -132,20 +119,19 @@ Both subpaths ship ESM **and** CommonJS with declarations for each format, so a
132
119
  > A full reference application lives in
133
120
  > [`bymaxone/nest-queue-example`](https://github.com/bymaxone/nest-queue-example) —
134
121
  > an API and a web console exercising every public export against a real Redis.
135
- >
136
- > The `test/consumer-app` directory in this repository is **not** that. It is a
137
- > typecheck fixture: a few hundred lines of application-shaped code that CI
138
- > compiles against the freshly built library on every pull request, to catch an
139
- > ergonomic break in the decorator API that a signature test cannot see.
140
122
 
141
123
  ---
142
124
 
143
125
  ## 🚀 Quick Start
144
126
 
127
+ ### 1. Install
128
+
145
129
  ```bash
146
130
  pnpm add @bymax-one/nest-queue bullmq ioredis reflect-metadata
147
131
  ```
148
132
 
133
+ ### 2. Load `reflect-metadata`
134
+
149
135
  `reflect-metadata` is loaded **once by your application**, before anything else — this
150
136
  is the standard NestJS entry point and the decorators in this library depend on it:
151
137
 
@@ -163,6 +149,8 @@ async function bootstrap(): Promise<void> {
163
149
  void bootstrap()
164
150
  ```
165
151
 
152
+ ### 3. Register the module
153
+
166
154
  Wire the module in your root `AppModule`:
167
155
 
168
156
  ```typescript
@@ -174,7 +162,6 @@ import { EmailModule } from './email.module'
174
162
  imports: [
175
163
  BymaxQueueModule.forRoot({
176
164
  connection: { url: process.env.REDIS_URL ?? 'redis://localhost:6379' },
177
- isGlobal: true,
178
165
  }),
179
166
  EmailModule,
180
167
  ],
@@ -182,7 +169,9 @@ import { EmailModule } from './email.module'
182
169
  export class AppModule {}
183
170
  ```
184
171
 
185
- Enqueue a job from any service:
172
+ ### 4. Enqueue a job
173
+
174
+ From any service:
186
175
 
187
176
  ```typescript
188
177
  import { Injectable } from '@nestjs/common'
@@ -198,7 +187,9 @@ export class EmailService {
198
187
  }
199
188
  ```
200
189
 
201
- Process jobs with a decorated handler:
190
+ ### 5. Process jobs
191
+
192
+ With a decorated handler:
202
193
 
203
194
  ```typescript
204
195
  import { Injectable } from '@nestjs/common'
@@ -215,8 +206,11 @@ export class EmailProcessor {
215
206
  }
216
207
  ```
217
208
 
218
- Register the service and the processor in a feature module that `AppModule` imports, like the `EmailModule`
219
- imported above. `isGlobal: true` above makes `QueueService` injectable there without importing `BymaxQueueModule` again:
209
+ ### 6. Register the providers
210
+
211
+ Put the service and the processor in a feature module that `AppModule` imports, like the `EmailModule`
212
+ above. The module is global by default (`isGlobal` defaults to `true`), so `QueueService` is injectable
213
+ there without importing `BymaxQueueModule` again:
220
214
 
221
215
  ```typescript
222
216
  import { Module } from '@nestjs/common'
@@ -262,11 +256,16 @@ first job is enqueued.
262
256
 
263
257
  ---
264
258
 
265
- ## 🔌 Mode A — Bring Your Own Connection (recommended with `@bymax-one/nest-cache`)
259
+ ## 🔌 Connections
260
+
261
+ `connection` takes a client (**Mode A**) or a URL or ioredis options (**Mode B**); the arms are mutually
262
+ exclusive. In both modes the Queue connection keeps the ioredis defaults, and the Worker and
263
+ `QueueEvents` connections are `duplicate()`s of it with `maxRetriesPerRequest: null`.
264
+
265
+ ### Mode A — your own client
266
266
 
267
- Mode A accepts an existing `ioredis` client injected from your cache or connection layer.
268
- Worker and QueueEvents connections are duplicated from this client with
269
- `maxRetriesPerRequest: null` applied automatically; the Queue connection uses ioredis defaults.
267
+ Recommended when the application already runs `@bymax-one/nest-cache`: one client is shared and
268
+ duplicated per role, and your cache connection is left alone.
270
269
 
271
270
  ```typescript
272
271
  import { BymaxQueueModule } from '@bymax-one/nest-queue'
@@ -277,30 +276,19 @@ BymaxQueueModule.forRootAsync({
277
276
  inject: [ConnectionManager],
278
277
  useFactory: (cache: ConnectionManager) => ({
279
278
  connection: { client: cache.getClient() as Redis },
280
- isGlobal: true,
281
279
  }),
282
280
  })
283
281
  ```
284
282
 
285
- `BymaxCacheModule` is global by default and provides `ConnectionManager`; `getClient()` returns the shared
286
- client. It is typed `Redis | Cluster`, and Mode A takes a standalone `Redis`, so run
287
- `@bymax-one/nest-cache` in standalone mode.
288
-
289
- Mode A is the recommended setup when you already have `@bymax-one/nest-cache` in the app.
290
- One client is shared; the lib duplicates it per role and applies the correct retry policy
291
- without touching your cache connection.
292
-
293
- ---
283
+ `BymaxCacheModule` is global by default and provides `ConnectionManager`. `getClient()` is typed
284
+ `Redis | Cluster` and Mode A takes a standalone `Redis`, so run `@bymax-one/nest-cache` in standalone mode.
294
285
 
295
- ## 🔌 Mode B — Lib-Owned Connection
296
-
297
- Mode B lets the library own the Redis connections. Pass either a URL or raw ioredis options:
286
+ ### Mode B — a connection the library owns
298
287
 
299
288
  ```typescript
300
289
  // URL form
301
290
  BymaxQueueModule.forRoot({
302
291
  connection: { url: process.env.REDIS_URL ?? 'redis://localhost:6379' },
303
- isGlobal: true,
304
292
  })
305
293
 
306
294
  // Options form
@@ -313,222 +301,30 @@ BymaxQueueModule.forRoot({
313
301
  tls: {},
314
302
  },
315
303
  },
316
- isGlobal: true,
317
- })
318
- ```
319
-
320
- The library opens one connection for all queues and flows, and two for each worker and for each
321
- queue that has an `@OnQueueEvent` listener ([the budget is below](#-redis-connections)). The worker
322
- and `QueueEvents` connections carry `maxRetriesPerRequest: null`.
323
-
324
- ---
325
-
326
- ## 🔌 Redis connections
327
-
328
- How many connections the library holds is linear in what you register, so you can budget it before
329
- you deploy. Counts were read from `INFO clients` against Redis 8 with BullMQ 6 and `ioredis` 6, and
330
- every connection is released when the application context closes.
331
-
332
- | What you register | Connections held |
333
- | ---------------------------------------------- | ------------------------------------------------------------------------------ |
334
- | The module (Mode B, library-owned connection) | 1, shared by every producer and every flow |
335
- | The module (Mode A, your client) | 0 of its own; your client is yours and is never counted |
336
- | A queue you enqueue to, with `QueueService` | 0 more: queues share the module's connection |
337
- | `FlowService` (flows enabled) | 0 more |
338
- | Each worker (`@Processor` or `WorkerRegistry`) | 2: a blocking connection that waits for jobs, and a command connection |
339
- | Each queue observed with `@OnQueueEvent` | 2: a blocking connection that reads the event stream, and a command connection |
340
-
341
- Producers and flows add nothing, so the cost comes from consumers. The blocking connection of a
342
- worker cannot be shared: it is parked inside a blocking command until a job arrives.
343
-
344
- **Worked example.** One application instance in Mode B with 20 workers and 5 queues observed with
345
- `@OnQueueEvent` holds 1 + (2 × 20) + (2 × 5) = **51** connections; in Mode A the same instance holds
346
- 50, plus whatever your own client uses. Each instance of the application opens its own set, so three
347
- instances hold 153.
348
-
349
- **Managed Redis.** Hosted Redis plans cap the number of concurrent connections, and a plan sized for
350
- a handful of clients is exhausted by one instance with a few dozen workers. Multiply the table by
351
- the number of instances you run (include rolling deploys, where old and new instances overlap) and
352
- compare it with the limit of your plan; self-managed Redis allows 10 000 by default (`maxclients`).
353
- In `CLIENT LIST`, the blocking connection of a worker has a name that starts with
354
- `<prefix>:<base64 of the queue name>`, which identifies it; the command connection is unnamed.
355
-
356
- ---
357
-
358
- ## 📖 API Reference
359
-
360
- ### `QueueService`
361
-
362
- The central interaction point with BullMQ queues.
363
-
364
- ```typescript
365
- import { QueueService } from '@bymax-one/nest-queue'
366
- import type {
367
- Job,
368
- JobsOptions,
369
- JobSchedulerRepeatOptions,
370
- QueueMetrics,
371
- } from '@bymax-one/nest-queue'
372
- ```
373
-
374
- | Method | Signature | Description |
375
- | -------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
376
- | `enqueue` | `<TData, TResult>(queue, name, data, opts?) → Promise<Job<TData, TResult>>` | Add a single job |
377
- | `enqueueBulk` | `<TData, TResult>(queue, jobs) → Promise<Job<TData, TResult>[]>` | Add up to 1 000 jobs in one Redis roundtrip |
378
- | `getJob` | `<TData, TResult>(queue, jobId) → Promise<Job<TData, TResult> \| null>` | Get job by ID; `null` when it does not exist |
379
- | `getJobs` | `<TData, TResult>(queue, status, start?, end?) → Promise<Job<TData, TResult>[]>` | Page jobs in one status (default `0`–`50`) |
380
- | `getMetrics` | `(queue) → Promise<QueueMetrics>` | Uncached snapshot of job counts per status, with a collection timestamp |
381
- | `pauseQueue` | `(queue) → Promise<void>` | Pause a queue |
382
- | `resumeQueue` | `(queue) → Promise<void>` | Resume a paused queue |
383
- | `cleanQueue` | `(queue, gracePeriodMs, limit, status?) → Promise<string[]>` | Remove up to `limit` jobs older than `gracePeriodMs` (`0` = no limit); returns removed ids |
384
- | `upsertJobScheduler` | `<TData, TResult>(queue, id, repeat, template?) → Promise<Job<TData, TResult> \| undefined>` | Create or update a recurring Job Scheduler; returns the first scheduled job |
385
- | `removeJobScheduler` | `(queue, id) → Promise<boolean>` | Remove a Job Scheduler by ID |
386
- | `getJobSchedulers` | `(queue, start?, end?, asc?) → Promise<JobSchedulerJson[]>` | Page the schedulers registered on a queue |
387
- | `getOrCreateQueue` | `<TData, TResult>(queue, overrides?) → Queue<TData, TResult>` | Return the cached raw `Queue` instance |
388
- | `getCachedQueues` | `() → ReadonlyMap<string, Queue>` | Read-only view of every queue created so far |
389
-
390
- ### `WorkerRegistry`
391
-
392
- Programmatic API to create and manage BullMQ workers at runtime.
393
-
394
- ```typescript
395
- import path from 'node:path'
396
- import { WorkerRegistry } from '@bymax-one/nest-queue'
397
- import type { ProgrammaticWorkerConfig, SandboxedWorkerConfig } from '@bymax-one/nest-queue'
398
-
399
- // Register an in-process worker
400
- registry.register({
401
- queueName: 'email',
402
- handler: async (job) => {
403
- /* … */
404
- },
405
- options: { concurrency: 4 },
406
- })
407
-
408
- // Register a sandboxed (out-of-process) processor
409
- registry.registerSandboxed({
410
- queueName: 'heavy-compute',
411
- processorFile: path.resolve(__dirname, 'compute.processor.js'),
412
- options: { concurrency: 2 },
413
304
  })
414
305
  ```
415
306
 
416
- ### `FlowService` (opt-in)
307
+ ### Connection budget
417
308
 
418
- Enable flows in the module options and inject the service:
309
+ The connections held are linear in what you register. Counts were read from `INFO clients` against
310
+ Redis 8 with BullMQ 6 and `ioredis` 6; every connection the library opens is released when the application context closes.
419
311
 
420
- ```typescript
421
- BymaxQueueModule.forRoot({
422
- connection: { url: process.env.REDIS_URL ?? 'redis://localhost:6379' },
423
- flows: { enabled: true },
424
- })
312
+ | What you register | Connections held |
313
+ | ---------------------------------------------- | ----------------------------------------------------------------------- |
314
+ | The module, Mode B | 1, shared by every queue and every flow |
315
+ | The module, Mode A | 0 of its own; your client is yours and is never counted |
316
+ | Each worker (`@Processor` or `WorkerRegistry`) | 2: a blocking connection that waits for jobs, and a command connection |
317
+ | Each queue observed with `@OnQueueEvent` | 2: a blocking connection that reads the event stream, and a command one |
425
318
 
426
- // Then inject:
427
- import { FlowService } from '@bymax-one/nest-queue'
428
- import type { FlowJob, JobNode } from '@bymax-one/nest-queue'
319
+ Queues and `FlowService` add none, so the cost comes from consumers: a blocking connection is parked
320
+ inside a blocking command until a job arrives and cannot be shared. **Example:** 20 workers and 5 observed
321
+ queues in Mode B hold 1 + (2 × 20) + (2 × 5) = **51** connections (50 in Mode A, plus whatever your own
322
+ client uses). Each instance opens its own set, so three instances hold 153.
429
323
 
430
- const tree: JobNode = await this.flows.add({
431
- name: 'process-order',
432
- queueName: 'orders',
433
- data: { orderId: '123' },
434
- children: [
435
- { name: 'charge-payment', queueName: 'payments', data: {} },
436
- { name: 'send-confirmation', queueName: 'email', data: {} },
437
- ],
438
- })
439
- ```
440
-
441
- ### `MetricsService` (opt-in)
442
-
443
- Provide a per-queue snapshot with a configurable TTL cache:
444
-
445
- ```typescript
446
- BymaxQueueModule.forRoot({
447
- connection: { url: process.env.REDIS_URL ?? 'redis://localhost:6379' },
448
- metrics: { enabled: true, cacheTtlMs: 5_000 },
449
- })
450
-
451
- import { MetricsService } from '@bymax-one/nest-queue'
452
- import type { QueueMetrics } from '@bymax-one/nest-queue'
453
-
454
- const metrics: QueueMetrics = await this.metricsService.get('email')
455
- const all: readonly QueueMetrics[] = await this.metricsService.getAll()
456
- ```
457
-
458
- ### Other exports
459
-
460
- | Export | What it is |
461
- | ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
462
- | `WorkerRegistry.unregister(queue)`, `list()` | Stop and remove a worker and its connection; list the queue names that have a worker |
463
- | `FlowService.addBulk(flows)`, `getProducer()`, `close()` | Add several flows at once; the underlying BullMQ `FlowProducer`; close it (a no-op while flows are disabled) |
464
- | `MetricsService.invalidate(queue?)` | Drop the cached snapshot of one queue, or of all of them |
465
- | `QueueEventsRegistry` | The shared `QueueEvents` per queue (`getOrCreate`, `list`), used by `@OnQueueEvent` |
466
- | `BYMAX_QUEUE_OPTIONS`, `BYMAX_QUEUE_RESOLVED_OPTIONS` | Injection tokens for the options as given and with the defaults applied |
467
- | `BYMAX_QUEUE_REDIS_CLIENT`, `BYMAX_QUEUE_CONNECTION_MODE` | Injection tokens for the Queue-role ioredis client and for `'mode-a-byo'` or `'mode-b-owned'` |
468
- | `DEFAULT_WORKER_CONCURRENCY`, `MAX_WORKER_CONCURRENCY` | The worker concurrency used when none is given, and its ceiling |
469
- | `DEFAULT_JOB_OPTIONS`, `DEFAULT_METRICS_CACHE_TTL_MS`, `DEFAULT_SHUTDOWN_DRAIN_TIMEOUT_MS` | The defaults of the Configuration table |
470
- | `QUEUE_ERROR_MESSAGES` | The default message of each error code |
471
- | `BymaxQueueModuleOptions`, `BymaxQueueModuleAsyncOptions`, `BymaxQueueOptionsFactory`, `QueueConnectionConfig`, `QueueConnectionMode` | The option and connection types behind `forRoot` and `forRootAsync` |
472
- | `ProcessorMetadata`, `ProcessHandlerMetadata`, `QueueEventListenerMetadata` | The reflection metadata the decorators write |
473
- | `JobStatus`, `JOB_STATUS`, `BulkJob`, `SandboxedJob`, `WorkerEventName`, `QueueEventName`, `WorkerOptions` | Constants and types for the APIs above |
474
-
475
- ---
476
-
477
- ## 🚨 Error Code Catalog
478
-
479
- Whatever **this library rejects** is a `QueueException` carrying a stable,
480
- transport-independent `code`. Branch on the code, never on the HTTP status or the
481
- message text — the code is the contract.
482
-
483
- Whatever **fails underneath it** is not wrapped at all. A dropped connection, a
484
- timeout or any other operational fault reaches you as the original error, with its
485
- own type and message, rather than being relabelled as invalid input. So the catalog
486
- below is a complete list of the library's own rejections, and deliberately not a
487
- complete list of everything a call can throw.
488
-
489
- ```typescript
490
- import { QueueException, QUEUE_ERROR_CODES } from '@bymax-one/nest-queue'
491
-
492
- try {
493
- await queue.enqueue('email', 'welcome', { userId })
494
- } catch (err) {
495
- if (err instanceof QueueException) {
496
- const { code } = (err.getResponse() as { error: { code: string } }).error
497
- if (code === QUEUE_ERROR_CODES.BULK_ENQUEUE_FAILED) {
498
- /* … */
499
- }
500
- }
501
- }
502
- ```
503
-
504
- | Code | HTTP | When thrown |
505
- | ---------------------------------------- | :--: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
506
- | `queue.invalid_options` | 500 | Module options failed bootstrap validation — missing `connection`, mutually exclusive connection arms, non-positive `shutdown.drainTimeoutMs`, or negative `metrics.cacheTtlMs` |
507
- | `queue.connection_invalid` | 500 | The resolver has no usable client: not yet initialized, or Mode A's injected client is in an unusable `status` |
508
- | `queue.connection_requires_null_retries` | 500 | A consumer connection could not be coerced to `maxRetriesPerRequest: null` |
509
- | `queue.connection_timeout` | 500 | Mode B: Redis did not reach `ready` within `connectionReadyTimeoutMs` |
510
- | `queue.invalid_repeat_options` | 400 | A Job Scheduler schedule was rejected — invalid cron pattern, or neither `pattern` nor `every` |
511
- | `queue.duplicate_processor` | 500 | Two `@Processor` classes claim the same queue name |
512
- | `queue.flow_disabled` | 503 | `FlowService` used without `flows.enabled` |
513
- | `queue.metrics_disabled` | 503 | `MetricsService` used without `metrics.enabled` |
514
- | `queue.bulk_enqueue_failed` | 500 | `enqueueBulk` exceeded the 1 000-job cap, or the underlying `addBulk` failed |
515
- | `queue.worker_registration_failed` | 500 | A worker could not be constructed, started, or closed |
516
-
517
- ### Reserved codes
518
-
519
- These are part of the published catalog and stable, but nothing currently throws them.
520
- They are listed so a consumer writing an exhaustive `switch` over `QueueErrorCode`
521
- compiles today and keeps compiling when a future release starts emitting them.
522
-
523
- | Code | Why it is not thrown today |
524
- | --------------------------------- | --------------------------------------------------------------------- |
525
- | `queue.queue_not_found` | Queues are created on demand by name, so a lookup never misses |
526
- | `queue.job_not_found` | `getJob` resolves to `null` rather than throwing |
527
- | `queue.invalid_job_data` | Payloads are opaque; the library does not validate them |
528
- | `queue.shutdown_timeout_exceeded` | Exceeding the drain deadline force-closes workers rather than raising |
529
-
530
- The same catalog is exported from `@bymax-one/nest-queue/shared` with no dependencies,
531
- so a frontend can map codes to messages without importing the server surface.
324
+ Hosted Redis plans cap concurrent connections: multiply by the instances you run (rolling deploys
325
+ overlap old and new) and compare with your plan's limit; self-managed Redis allows 10 000 (`maxclients`).
326
+ In `CLIENT LIST`, a worker's blocking connection is named `<prefix>:<base64 of the queue name>…`; the
327
+ command connection is unnamed.
532
328
 
533
329
  ---
534
330
 
@@ -619,34 +415,20 @@ A pattern the parser rejects is `INVALID_REPEAT_OPTIONS` (400) before Redis is a
619
415
  Pass the `deduplication` option directly on `enqueue` — the library delegates the behavior entirely to BullMQ:
620
416
 
621
417
  ```typescript
418
+ const id = `welcome:${userId}`
419
+
622
420
  // Simple: collapse duplicates until the first in-flight job completes
623
- await this.queue.enqueue(
624
- 'email',
625
- 'welcome',
626
- { userId },
627
- {
628
- deduplication: { id: `welcome:${userId}` },
629
- },
630
- )
421
+ await this.queue.enqueue('email', 'welcome', { userId }, { deduplication: { id } })
631
422
 
632
423
  // Throttle: ignore duplicates for 30 seconds
633
- await this.queue.enqueue(
634
- 'email',
635
- 'welcome',
636
- { userId },
637
- {
638
- deduplication: { id: `welcome:${userId}`, ttl: 30_000 },
639
- },
640
- )
424
+ await this.queue.enqueue('email', 'welcome', { userId }, { deduplication: { id, ttl: 30_000 } })
641
425
 
642
426
  // Debounce: keep only the latest; each duplicate resets the TTL
643
427
  await this.queue.enqueue(
644
428
  'email',
645
429
  'welcome',
646
430
  { userId },
647
- {
648
- deduplication: { id: `welcome:${userId}`, ttl: 5_000, extend: true, replace: true },
649
- },
431
+ { deduplication: { id, ttl: 5_000, extend: true, replace: true } },
650
432
  )
651
433
  ```
652
434
 
@@ -766,14 +548,13 @@ wire connection and defaults yourself.
766
548
 
767
549
  ### Design Principles
768
550
 
769
- | Principle | Description |
770
- | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
771
- | 🔌 **Connections are role-shaped** | BullMQ blocks a connection for consumers. Only the duplicated `Worker`/`QueueEvents` connections get `maxRetriesPerRequest: null`; the `Queue` and `FlowProducer` connection keeps ioredis' default retries, so a producer fails fast during a Redis outage instead of hanging a request. |
772
- | 🏷️ **One namespace, four objects** | The configured `prefix` reaches `Queue`, `Worker`, `QueueEvents` and `FlowProducer`. BullMQ defaults an omitted prefix to `bull`, so a partial application splits producers from consumers silently. |
773
- | 💥 **Fail at boot, not at runtime** | Options are validated before any connection is opened, and every rejection carries a distinct reason. |
774
- | 🧬 **Explicit DI tokens** | Tokens are `Symbol()` and every injectable constructor parameter carries an explicit `@Inject`. The package ships bundled, where design-time metadata is not emitted — implicit resolution would break inside your build, not ours. |
775
- | 📅 **Current BullMQ API only** | Recurring jobs use Job Schedulers; the `addRepeatable` API removed in BullMQ v6 is never called. Cron is validated by BullMQ's own parser. |
776
- | 🧊 **Zero runtime dependencies** | `dependencies` is `{}`. You choose the exact `bullmq` and `ioredis` versions, and the supply-chain surface stays yours. |
551
+ The role-shaped connections, the shared `prefix` and the Job Scheduler API are described under
552
+ [Features](#-features) and [Connections](#-connections). Two principles are not:
553
+
554
+ | Principle | Description |
555
+ | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
556
+ | 💥 **Fail at boot, not at runtime** | Options are validated before any connection is opened, and every rejection carries a distinct reason. |
557
+ | 🧬 **Explicit DI tokens** | Tokens are `Symbol()` and every injectable constructor parameter carries an explicit `@Inject`. The package ships bundled, where design-time metadata is not emitted — implicit resolution would break inside your build, not ours. |
777
558
 
778
559
  ---
779
560
 
@@ -819,11 +600,9 @@ compromised client belongs in Redis ACLs or separate instances.
819
600
 
820
601
  ### Shutdown is bounded and at-least-once
821
602
 
822
- `QueueLifecycle` drains in-flight work under a deadline and then force-closes. A job still
823
- running when the deadline expires is **not** acknowledged, so BullMQ's stalled-job check
824
- re-queues it and it runs again. Delivery is at-least-once by design: **every handler must
825
- be idempotent.** A handler that charges a card or sends an email without an idempotency
826
- key will do it twice at some point.
603
+ A job still running when the drain deadline expires is **not** acknowledged, so BullMQ's stalled-job
604
+ check re-queues it and it runs again. **Every handler must be idempotent** — see
605
+ [Graceful Shutdown](#-graceful-shutdown).
827
606
 
828
607
  ### Security Checklist
829
608
 
@@ -852,25 +631,23 @@ key will do it twice at some point.
852
631
  | Namespacing | `prefix` applied uniformly to `Queue`, `Worker`, `QueueEvents` and `FlowProducer` |
853
632
  | Connection policy | `maxRetriesPerRequest: null` confined to duplicated consumer connections; producers keep fail-fast retries |
854
633
  | Delivery | At-least-once with a bounded drain; unacknowledged work is re-queued by BullMQ's stalled check |
855
- | Supply chain | `dependencies: {}`; SHA-pinned third-party Actions, OSV-Scanner, TruffleHog, OpenSSF Scorecard; npm publish over OIDC |
634
+ | Supply chain | SHA-pinned third-party Actions, OSV-Scanner, TruffleHog, OpenSSF Scorecard; npm publish over OIDC |
856
635
  | Input validation | Options validated at bootstrap; cron patterns validated by BullMQ's own parser, never a hand-rolled regex |
857
636
 
858
- > [!IMPORTANT]
859
- > **Delivery is at-least-once, and `prefix` is not an access boundary.** Handlers must be
860
- > idempotent, and anything with credentials for the Redis instance can read every
861
- > namespace on it.
862
-
863
637
  ---
864
638
 
865
639
  ## 🧱 Tech Stack
866
640
 
867
- - **Runtime:** Node.js 24+
868
- - **Framework:** NestJS 11 (`ConfigurableModuleBuilder` + `setExtras`)
869
- - **Queue engine:** BullMQ `^6.0` (peer) over `ioredis ^6.0` (peer)
870
- - **Telemetry:** `bullmq-otel ^2` (optional peer)
871
- - **Build:** tsup — ESM + CJS per subpath, with `.d.ts` _and_ `.d.cts` declarations
872
- - **Tests:** Jest + `@testcontainers/redis` (E2E) + Stryker (mutation)
873
- - **TypeScript:** 6.x strict (`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`), zero `any`
641
+ [![Node.js](https://img.shields.io/badge/Node.js-24%2B-339933?style=flat-square&logo=node.js&logoColor=white)](https://nodejs.org)
642
+ [![NestJS](https://img.shields.io/badge/NestJS-11-E0234E?style=flat-square&logo=nestjs&logoColor=white)](https://nestjs.com)
643
+ [![BullMQ](https://img.shields.io/badge/BullMQ-6-DC382D?style=flat-square)](https://bullmq.io)
644
+ [![ioredis](https://img.shields.io/badge/ioredis-6-DC382D?style=flat-square&logo=redis&logoColor=white)](https://github.com/redis/ioredis)
645
+ [![TypeScript](https://img.shields.io/badge/TypeScript-6-3178C6?style=flat-square&logo=typescript&logoColor=white)](https://www.typescriptlang.org)
646
+ [![Jest](https://img.shields.io/badge/Jest-30-C21325?style=flat-square&logo=jest)](https://jestjs.io)
647
+ [![Testcontainers](https://img.shields.io/badge/Testcontainers-E2E-2496ED?style=flat-square&logo=docker&logoColor=white)](https://testcontainers.com)
648
+ [![Stryker](https://img.shields.io/badge/Stryker-Mutation_Testing-red?style=flat-square)](https://stryker-mutator.io)
649
+ [![pnpm](https://img.shields.io/badge/pnpm-11-F69220?style=flat-square&logo=pnpm&logoColor=white)](https://pnpm.io)
650
+ [![tsup](https://img.shields.io/badge/tsup-8-orange?style=flat-square)](https://tsup.egoist.dev)
874
651
 
875
652
  ---
876
653
 
@@ -885,9 +662,7 @@ key will do it twice at some point.
885
662
  - **Published-shape gates**: `pnpm check:exports` resolves every entrypoint the way each
886
663
  module-resolution mode would (all four `attw` quadrants green, `node10` included), and
887
664
  `pnpm smoke` resolves the packed tarball from a real consumer in ESM _and_ CommonJS.
888
- - **Bundle budgets** enforced (`pnpm size`): server ≤ 18 KiB brotli, shared ≤ 2 500 B.
889
- - **Supply chain:** SHA-pinned third-party GitHub Actions, OSV-Scanner, TruffleHog, OpenSSF Scorecard,
890
- npm publish with provenance (OIDC).
665
+ - **Bundle budgets** enforced by `pnpm size`.
891
666
 
892
667
  ```bash
893
668
  pnpm typecheck && pnpm test:types && pnpm lint && pnpm test:cov:all && \
@@ -896,40 +671,196 @@ pnpm typecheck && pnpm test:types && pnpm lint && pnpm test:cov:all && \
896
671
 
897
672
  ---
898
673
 
899
- ## 🩺 Troubleshooting
674
+ ## 📖 API Reference
900
675
 
901
- ### `ERR_PNPM_IGNORED_BUILDS: msgpackr-extract` on install (pnpm 11)
676
+ ### `QueueService`
902
677
 
903
- Installing this library with **pnpm 11** can stop with:
678
+ The central interaction point with BullMQ queues.
904
679
 
680
+ ```typescript
681
+ import { QueueService } from '@bymax-one/nest-queue'
682
+ import type {
683
+ Job,
684
+ JobsOptions,
685
+ JobSchedulerRepeatOptions,
686
+ QueueMetrics,
687
+ } from '@bymax-one/nest-queue'
905
688
  ```
906
- ERR_PNPM_IGNORED_BUILDS Ignored build scripts: msgpackr-extract@3.0.4
907
- Run "pnpm approve-builds" to pick which dependencies should be allowed to run scripts.
689
+
690
+ | Method | Signature | Description |
691
+ | -------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
692
+ | `enqueue` | `<TData, TResult>(queue, name, data, opts?) → Promise<Job<TData, TResult>>` | Add a single job |
693
+ | `enqueueBulk` | `<TData, TResult>(queue, jobs) → Promise<Job<TData, TResult>[]>` | Add up to 1 000 jobs in one Redis roundtrip |
694
+ | `getJob` | `<TData, TResult>(queue, jobId) → Promise<Job<TData, TResult> \| null>` | Get job by ID; `null` when it does not exist |
695
+ | `getJobs` | `<TData, TResult>(queue, status, start?, end?) → Promise<Job<TData, TResult>[]>` | Page jobs in one status (default `0`–`50`) |
696
+ | `getMetrics` | `(queue) → Promise<QueueMetrics>` | Uncached snapshot of job counts per status, with a collection timestamp |
697
+ | `pauseQueue` | `(queue) → Promise<void>` | Pause a queue |
698
+ | `resumeQueue` | `(queue) → Promise<void>` | Resume a paused queue |
699
+ | `cleanQueue` | `(queue, gracePeriodMs, limit, status?) → Promise<string[]>` | Remove up to `limit` jobs older than `gracePeriodMs` (`0` = no limit); returns removed ids |
700
+ | `upsertJobScheduler` | `<TData, TResult>(queue, id, repeat, template?) → Promise<Job<TData, TResult> \| undefined>` | Create or update a recurring Job Scheduler; returns the first scheduled job |
701
+ | `removeJobScheduler` | `(queue, id) → Promise<boolean>` | Remove a Job Scheduler by ID |
702
+ | `getJobSchedulers` | `(queue, start?, end?, asc?) → Promise<JobSchedulerJson[]>` | Page the schedulers registered on a queue |
703
+ | `getOrCreateQueue` | `<TData, TResult>(queue, overrides?) → Queue<TData, TResult>` | Return the cached raw `Queue` instance |
704
+ | `getCachedQueues` | `() → ReadonlyMap<string, Queue>` | Read-only view of every queue created so far |
705
+
706
+ ### `WorkerRegistry`
707
+
708
+ Programmatic API to create and manage BullMQ workers at runtime.
709
+
710
+ ```typescript
711
+ import path from 'node:path'
712
+ import { WorkerRegistry } from '@bymax-one/nest-queue'
713
+ import type { ProgrammaticWorkerConfig, SandboxedWorkerConfig } from '@bymax-one/nest-queue'
714
+
715
+ // Register an in-process worker
716
+ registry.register({
717
+ queueName: 'email',
718
+ handler: async (job) => {
719
+ /* … */
720
+ },
721
+ options: { concurrency: 4 },
722
+ })
723
+
724
+ // Register a sandboxed (out-of-process) processor
725
+ registry.registerSandboxed({
726
+ queueName: 'heavy-compute',
727
+ processorFile: path.resolve(__dirname, 'compute.processor.js'),
728
+ options: { concurrency: 2 },
729
+ })
908
730
  ```
909
731
 
910
- Nothing is wrong with your setup. `msgpackr-extract` is a native optional dependency
911
- of BullMQ, and pnpm blocks build scripts by default. **pnpm 10 warns and installs
912
- anyway; pnpm 11 turns the same block into an error**, which is why this appears on
913
- an upgrade with no change on your side. It is BullMQ's dependency, not this
914
- package's: this package ships zero runtime dependencies.
732
+ ### `FlowService` (opt-in)
733
+
734
+ Enable flows in the module options and inject the service:
735
+
736
+ ```typescript
737
+ BymaxQueueModule.forRoot({
738
+ connection: { url: process.env.REDIS_URL ?? 'redis://localhost:6379' },
739
+ flows: { enabled: true },
740
+ })
915
741
 
916
- Approve it once, either interactively:
742
+ // Then inject:
743
+ import { FlowService } from '@bymax-one/nest-queue'
744
+ import type { FlowJob, JobNode } from '@bymax-one/nest-queue'
917
745
 
918
- ```bash
919
- pnpm approve-builds
746
+ const tree: JobNode = await this.flows.add({
747
+ name: 'process-order',
748
+ queueName: 'orders',
749
+ data: { orderId: '123' },
750
+ children: [
751
+ { name: 'charge-payment', queueName: 'payments', data: {} },
752
+ { name: 'send-confirmation', queueName: 'email', data: {} },
753
+ ],
754
+ })
755
+ ```
756
+
757
+ ### `MetricsService` (opt-in)
758
+
759
+ Provide a per-queue snapshot with a configurable TTL cache:
760
+
761
+ ```typescript
762
+ BymaxQueueModule.forRoot({
763
+ connection: { url: process.env.REDIS_URL ?? 'redis://localhost:6379' },
764
+ metrics: { enabled: true, cacheTtlMs: 5_000 },
765
+ })
766
+
767
+ import { MetricsService } from '@bymax-one/nest-queue'
768
+ import type { QueueMetrics } from '@bymax-one/nest-queue'
769
+
770
+ const metrics: QueueMetrics = await this.metricsService.get('email')
771
+ const all: readonly QueueMetrics[] = await this.metricsService.getAll()
920
772
  ```
921
773
 
922
- or declaratively in your `pnpm-workspace.yaml`:
774
+ ### Other exports
775
+
776
+ | Export | What it is |
777
+ | ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
778
+ | `WorkerRegistry.unregister(queue)`, `list()` | Stop and remove a worker and its connection; list the queue names that have a worker |
779
+ | `FlowService.addBulk(flows)`, `getProducer()`, `close()` | Add several flows at once; the underlying BullMQ `FlowProducer`; close it (a no-op while flows are disabled) |
780
+ | `MetricsService.invalidate(queue?)` | Drop the cached snapshot of one queue, or of all of them |
781
+ | `QueueEventsRegistry` | The shared `QueueEvents` per queue (`getOrCreate`, `list`), used by `@OnQueueEvent` |
782
+ | `BYMAX_QUEUE_OPTIONS`, `BYMAX_QUEUE_RESOLVED_OPTIONS` | Injection tokens for the options as given and with the defaults applied |
783
+ | `BYMAX_QUEUE_REDIS_CLIENT`, `BYMAX_QUEUE_CONNECTION_MODE` | Injection tokens for the Queue-role ioredis client and for `'mode-a-byo'` or `'mode-b-owned'` |
784
+ | `DEFAULT_WORKER_CONCURRENCY`, `MAX_WORKER_CONCURRENCY` | The worker concurrency used when none is given, and its ceiling |
785
+ | `DEFAULT_JOB_OPTIONS`, `DEFAULT_METRICS_CACHE_TTL_MS`, `DEFAULT_SHUTDOWN_DRAIN_TIMEOUT_MS` | The defaults of the Configuration table |
786
+ | `QUEUE_ERROR_MESSAGES` | The default message of each error code |
787
+ | `BymaxQueueModuleOptions`, `BymaxQueueModuleAsyncOptions`, `BymaxQueueOptionsFactory`, `QueueConnectionConfig`, `QueueConnectionMode` | The option and connection types behind `forRoot` and `forRootAsync` |
788
+ | `ProcessorMetadata`, `ProcessHandlerMetadata`, `QueueEventListenerMetadata` | The reflection metadata the decorators write |
789
+ | `JobStatus`, `JOB_STATUS`, `BulkJob`, `SandboxedJob`, `WorkerEventName`, `QueueEventName`, `WorkerOptions` | Constants and types for the APIs above |
790
+
791
+ ---
792
+
793
+ ## 🚨 Error Code Catalog
794
+
795
+ Whatever **this library rejects** is a `QueueException` carrying a stable,
796
+ transport-independent `code`. Branch on the code, never on the HTTP status or the
797
+ message text — the code is the contract.
798
+
799
+ Whatever **fails underneath it** is not wrapped at all. A dropped connection, a
800
+ timeout or any other operational fault reaches you as the original error, with its
801
+ own type and message, rather than being relabelled as invalid input. So the catalog
802
+ below is a complete list of the library's own rejections, and deliberately not a
803
+ complete list of everything a call can throw.
804
+
805
+ ```typescript
806
+ import { QueueException, QUEUE_ERROR_CODES } from '@bymax-one/nest-queue'
807
+
808
+ try {
809
+ await queue.enqueue('email', 'welcome', { userId })
810
+ } catch (err) {
811
+ if (err instanceof QueueException) {
812
+ const { code } = (err.getResponse() as { error: { code: string } }).error
813
+ if (code === QUEUE_ERROR_CODES.BULK_ENQUEUE_FAILED) {
814
+ /* … */
815
+ }
816
+ }
817
+ }
818
+ ```
819
+
820
+ | Code | HTTP | When thrown |
821
+ | ---------------------------------------- | :--: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
822
+ | `queue.invalid_options` | 500 | Module options failed bootstrap validation — missing `connection`, mutually exclusive connection arms, non-positive `shutdown.drainTimeoutMs`, or negative `metrics.cacheTtlMs` |
823
+ | `queue.connection_invalid` | 500 | The resolver has no usable client: not yet initialized, or Mode A's injected client is in an unusable `status` |
824
+ | `queue.connection_requires_null_retries` | 500 | A consumer connection could not be coerced to `maxRetriesPerRequest: null` |
825
+ | `queue.connection_timeout` | 500 | Mode B: Redis did not reach `ready` within `connectionReadyTimeoutMs` |
826
+ | `queue.invalid_repeat_options` | 400 | A Job Scheduler schedule was rejected — invalid cron pattern, or neither `pattern` nor `every` |
827
+ | `queue.duplicate_processor` | 500 | Two `@Processor` classes claim the same queue name |
828
+ | `queue.flow_disabled` | 503 | `FlowService` used without `flows.enabled` |
829
+ | `queue.metrics_disabled` | 503 | `MetricsService` used without `metrics.enabled` |
830
+ | `queue.bulk_enqueue_failed` | 500 | `enqueueBulk` exceeded the 1 000-job cap, or the underlying `addBulk` failed |
831
+ | `queue.worker_registration_failed` | 500 | A worker could not be constructed, started, or closed |
832
+
833
+ ### Reserved codes
834
+
835
+ These are part of the published catalog and stable, but nothing currently throws them.
836
+ They are listed so a consumer writing an exhaustive `switch` over `QueueErrorCode`
837
+ compiles today and keeps compiling when a future release starts emitting them.
838
+
839
+ | Code | Why it is not thrown today |
840
+ | --------------------------------- | --------------------------------------------------------------------- |
841
+ | `queue.queue_not_found` | Queues are created on demand by name, so a lookup never misses |
842
+ | `queue.job_not_found` | `getJob` resolves to `null` rather than throwing |
843
+ | `queue.invalid_job_data` | Payloads are opaque; the library does not validate them |
844
+ | `queue.shutdown_timeout_exceeded` | Exceeding the drain deadline force-closes workers rather than raising |
845
+
846
+ The same catalog is exported from `@bymax-one/nest-queue/shared` with no dependencies,
847
+ so a frontend can map codes to messages without importing the server surface.
848
+
849
+ ---
850
+
851
+ ## 🩺 Troubleshooting
852
+
853
+ ### `ERR_PNPM_IGNORED_BUILDS: msgpackr-extract` on install (pnpm 11)
854
+
855
+ pnpm 11 blocks the build script of `msgpackr-extract`, a native optional dependency of BullMQ, and fails the
856
+ install. Approve it once with `pnpm approve-builds`, or in `pnpm-workspace.yaml`:
923
857
 
924
858
  ```yaml
925
859
  onlyBuiltDependencies:
926
860
  - msgpackr-extract
927
861
  ```
928
862
 
929
- Skipping the build is also fine — `msgpackr` falls back to a pure-JavaScript
930
- implementation, so BullMQ still works, just without the native accelerator.
931
-
932
- ---
863
+ Skipping the build is also fine: `msgpackr` falls back to pure JavaScript, only without the native accelerator.
933
864
 
934
865
  ### `CONNECTION_REQUIRES_NULL_RETRIES`
935
866
 
@@ -964,23 +895,18 @@ do not pick them up:
964
895
  - **NestJS only** — does not work with plain Express, standalone Fastify, or other frameworks.
965
896
  - **Node.js only** — Deno and Bun are not supported.
966
897
  - **BullMQ `^6.0` only** — the library targets BullMQ 6 (with `bullmq-otel ^2` for telemetry)
967
- and does not support BullMQ 5; the `'paused'` job state that v6 removed is no longer exposed.
968
- See the BullMQ version policy in [`CHANGELOG.md`](CHANGELOG.md).
898
+ and does not support BullMQ 5. See the BullMQ version policy in [`CHANGELOG.md`](CHANGELOG.md).
969
899
  - **No Redis Cluster sharding** — BullMQ's cluster support is not exercised by this library.
970
900
  - **No built-in DLQ helper** — use the `@OnWorkerEvent('failed')` pattern above.
971
901
  - **No Prometheus metrics export** — `getMetrics()` returns snapshots; publish them
972
902
  via `prom-client` in your own health endpoint.
973
903
 
974
- For the full list of limitations and their alternatives, see
975
- [`docs/technical_specification.md § 16`](docs/technical_specification.md).
976
-
977
904
  ---
978
905
 
979
906
  ## 🤝 Contributing
980
907
 
981
908
  Pull requests are welcome. Please open an issue first for significant changes.
982
909
 
983
- - Read [`docs/technical_specification.md`](docs/technical_specification.md) for architecture decisions.
984
910
  - Run the full gate listed in [`CONTRIBUTING.md`](CONTRIBUTING.md) before opening a PR.
985
911
  - Conventional Commits are enforced by `commitlint.config.cjs`.
986
912
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bymax-one/nest-queue",
3
- "version": "1.2.12",
3
+ "version": "1.2.13",
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",