@bymax-one/nest-queue 1.2.3 → 1.2.5

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,132 @@ versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
8
  ---
9
9
 
10
+ ## [Unreleased]
11
+
12
+ ---
13
+
14
+ ## [1.2.5] — 2026-10-31
15
+
16
+ No action required: behaviour, the public API, the types and the peer ranges are unchanged.
17
+ Measured with `diff -r` of a fresh build against the unpacked 1.2.4 tarball, `dist/` differs in
18
+ one comment line only, the description of the `drainOnShutdown` step, which sits in the bundles
19
+ (`index.mjs`, `index.cjs`) and in the declarations (`index.d.ts`, `index.d.cts`); the package
20
+ otherwise changes in `README.md`, this file and the version in `package.json`.
21
+
22
+ ### Documentation
23
+
24
+ - **Several statements in `README.md` described behaviour the code does not have.** The prose and the
25
+ tables were checked against the source, which the snippet gate does not do:
26
+ - `shutdown.drainOnShutdown` was described as waiting for the queue to drain. It calls `drain()`
27
+ on every cached queue after the workers close, which removes the waiting jobs, so it is for
28
+ development and tests only.
29
+ - `@OnQueueEvent` was said to need `queueEvents: { enabled: true }`. No such option exists; the
30
+ library opens the `QueueEvents` connection when it discovers the first listener.
31
+ - Graceful Shutdown named `onApplicationShutdown`; `QueueLifecycle` runs from `onModuleDestroy`.
32
+ - The Troubleshooting entry for `CONNECTION_REQUIRES_NULL_RETRIES` told Mode A users to pass a
33
+ client built with `maxRetriesPerRequest: null`. Worker connections are `duplicate()` calls that
34
+ force it, so a stock ioredis client never triggers the error; the entry now says so.
35
+ - The stuck-jobs entry pointed `lockDuration` at `workerOptions` (the `options` of `@Processor`
36
+ and `register` are what a consumer sets) and at a `maxStalledCount` setting the library does not
37
+ expose.
38
+ - The Testing section and the badge quoted a mutation score of 99.68% against `break 95`; the
39
+ suite is held at 100.00% against `break 100`. The shared bundle budget is 2 500 B, not 2.5 KiB, and the gate
40
+ chain now includes `pnpm check:published`.
41
+ - `QueueException.details` was described by four keys; it carries more, always scalar. The
42
+ `cleanQueue` row named its second parameter `graceMs`, which is `gracePeriodMs`.
43
+
44
+ - A warning callout under the Configuration table now says plainly that `shutdown.drainOnShutdown`
45
+ deletes the waiting jobs of every queue the process created and must not be enabled
46
+ in production; the table cell alone was easy to miss.
47
+
48
+ - **Two README examples did not compile against the published types.** The OpenTelemetry example
49
+ built `new BullMQOtel('my-service', tracer)`, the `bullmq-otel` 1.x signature; the `^2` peer takes
50
+ an options object, so it is now `new BullMQOtel({ tracerName: 'my-service' })`. The
51
+ `WorkerRegistry.register` example passed `processor:`; the property is `handler`. Two fragments
52
+ also used `path` and `Module` without importing them.
53
+
54
+ - **Further README statements were wrong, and the exports nothing described are now listed.** The
55
+ Configuration table said that injecting `FlowService` or `MetricsService` while disabled throws;
56
+ both are always provided and every method throws `FLOW_DISABLED` or `METRICS_DISABLED`, except
57
+ `FlowService.close()`, which is a no-op while flows are disabled. The
58
+ `@OnQueueEvent` example declared `(jobId, returnValue)`, but the handler receives one serialized
59
+ payload object (`completed` carries `{ jobId, returnvalue, prev? }`), so the example and the prose
60
+ above it now say so. An "Other exports" table now covers the exports the README never named.
61
+
62
+ ### Tests
63
+
64
+ - `pnpm check:published` now fails on a wrong method, option or field anywhere in the README.
65
+ It reads syntactic and semantic diagnostics together, compiles every snippet, not only those
66
+ that import the package, types the names the prose introduces in
67
+ `scripts/readme-prelude.d.ts`, resolves a relative import to the snippet that exports it and
68
+ fails one that no snippet satisfies. The prelude is compiled as a `.ts` file, so a type in it that
69
+ stops resolving is an error instead of `any`, and no syntax error is tolerated: a snippet that
70
+ shortens an example with a bare `...` fails and is written out in full.
71
+ - The Mode A null-retries probe is exercised with real ioredis clients: a stock client with finite
72
+ retries is accepted, a client whose `duplicate()` ignores the override is refused with
73
+ `CONNECTION_REQUIRES_NULL_RETRIES`, the probe is closed in both cases, and BullMQ itself refuses
74
+ a Worker on the connection the probe rejected.
75
+ - A spec fails when a name exported from `src/server/index.ts` or `src/shared/index.ts` is not
76
+ named in the README, so an export nobody documented cannot ship unnoticed again.
77
+ - The README's option-defaults table and error-code catalog are now parsed and compared with
78
+ `applyDefaults`, `DEFAULT_JOB_OPTIONS` and `QUEUE_ERROR_CODES`, so a default or a code that drifts
79
+ fails the suite instead of reaching the npm page.
80
+
81
+ ---
82
+
83
+ ## [1.2.4] — 2026-10-30
84
+
85
+ No action required: behaviour, the public API, the types and the peer ranges are unchanged.
86
+
87
+ The status codes a `QueueException` carries are the same as in 1.2.3, but the bundle under `dist/`
88
+ is not byte-identical, and the differences are listed below so a reader does not have to take
89
+ "no change" on trust.
90
+
91
+ ### Changed
92
+
93
+ - `QueueException` is now thrown with the named `HttpStatus` members (`BAD_REQUEST`,
94
+ `INTERNAL_SERVER_ERROR`, `SERVICE_UNAVAILABLE`) instead of the numbers 400, 500 and 503, at every
95
+ call site. The values are the same; the change satisfies `no-unsafe-enum-assignment`. Against a
96
+ fresh build of 1.2.3, the CommonJS bundle is identical once those members are replaced by their
97
+ numeric values, the ES module bundle differs only in the order of its `@nestjs/common` imports and
98
+ in line wrapping, and the type declarations differ in one JSDoc line (`FlowService`, which now
99
+ names `HttpStatus.SERVICE_UNAVAILABLE`).
100
+
101
+ ### Security
102
+
103
+ Not shipped:
104
+
105
+ - Dependency resolution of the tooling the repository builds and tests with was raised to patched
106
+ versions: `fastify`, `undici`, `fast-uri`, `js-yaml`, `brace-expansion`, `qs`, `zod`,
107
+ `handlebars`, `@grpc/grpc-js` and `@nestjs/platform-fastify`. None of these ships in the package,
108
+ which still declares `"dependencies": {}`; they reach it only through development and example-app
109
+ tooling.
110
+ - `osv-scanner.toml` ignores `GHSA-gcqx-w48m-w2h4` (`deepmerge` 4.3.1, no patched release) until
111
+ 2027-01-27. The package is reached only through `jest-config`, a development dependency.
112
+
113
+ ### Documentation
114
+
115
+ - The README supply-chain wording now says that third-party GitHub Actions are pinned to a commit SHA,
116
+ which is what the workflows do; the organisation's own reusable workflows follow a version tag.
117
+
118
+ ### Internal
119
+
120
+ Not shipped:
121
+
122
+ - Tests kill the four mutants that survived the full mutation run, and the one equivalent mutant is
123
+ disabled in place with the reason beside it. The mutation score is 100%.
124
+ - The README snippet check (`scripts/check-published-surface.mjs`) decides whether a snippet
125
+ declares a receiver from the parsed snippet rather than from its text, so decorated constructor
126
+ parameters, object-literal keys and JSX are classified correctly.
127
+ - Development and GitHub Actions dependencies were updated (Stryker, Jest, ts-jest, Prettier,
128
+ typescript-eslint, testcontainers, NestJS 11.2.x, and the CodeQL, pnpm, harden-runner, TruffleHog
129
+ and setup-node actions).
130
+ - The shared review-rules block in `AGENTS.md` was synchronised with its canonical copy.
131
+ - `SECURITY.md`, `CONTRIBUTING.md`, `CLAUDE.md`, `AGENTS.md` and the mutation-testing documents were
132
+ brought in line with the current configuration.
133
+
134
+ ---
135
+
10
136
  ## [1.2.3] — 2026-09-05
11
137
 
12
138
  **Nothing that runs in this package changed.** A fresh build of the release commit was packed and
@@ -552,6 +678,9 @@ v6 peer range.
552
678
 
553
679
  ---
554
680
 
681
+ [Unreleased]: https://github.com/bymaxone/nest-queue/compare/v1.2.5...HEAD
682
+ [1.2.5]: https://github.com/bymaxone/nest-queue/compare/v1.2.4...v1.2.5
683
+ [1.2.4]: https://github.com/bymaxone/nest-queue/compare/v1.2.3...v1.2.4
555
684
  [1.2.3]: https://github.com/bymaxone/nest-queue/compare/v1.2.2...v1.2.3
556
685
  [1.2.2]: https://github.com/bymaxone/nest-queue/compare/v1.2.1...v1.2.2
557
686
  [1.2.1]: https://github.com/bymaxone/nest-queue/compare/v1.2.0...v1.2.1
package/README.md CHANGED
@@ -14,7 +14,7 @@
14
14
  <a href="https://www.npmjs.com/package/@bymax-one/nest-queue"><img src="https://img.shields.io/npm/dm/@bymax-one/nest-queue?style=flat-square&colorA=000000&colorB=000000" alt="npm downloads" /></a>
15
15
  <a href="https://github.com/bymaxone/nest-queue/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/bymaxone/nest-queue/ci.yml?branch=main&style=flat-square&colorA=000000&label=CI" alt="CI status" /></a>
16
16
  <a href="https://github.com/bymaxone/nest-queue/actions/workflows/ci.yml"><img src="https://img.shields.io/badge/coverage-100%25-brightgreen?style=flat-square&colorA=000000" alt="coverage" /></a>
17
- <a href="https://github.com/bymaxone/nest-queue/blob/main/docs/mutation_testing_results.md"><img src="https://img.shields.io/badge/mutation-99.68%25-brightgreen?style=flat-square&colorA=000000" alt="mutation score" /></a>
17
+ <a href="https://github.com/bymaxone/nest-queue/blob/main/docs/mutation_testing_results.md"><img src="https://img.shields.io/badge/mutation-100%25-brightgreen?style=flat-square&colorA=000000" alt="mutation score" /></a>
18
18
  <a href="https://scorecard.dev/viewer/?uri=github.com/bymaxone/nest-queue"><img src="https://api.scorecard.dev/projects/github.com/bymaxone/nest-queue/badge?style=flat-square" alt="OpenSSF Scorecard" /></a>
19
19
  <a href="https://github.com/bymaxone/nest-queue/blob/main/LICENSE"><img src="https://img.shields.io/github/license/bymaxone/nest-queue?style=flat-square&colorA=000000&colorB=000000" alt="license" /></a>
20
20
  <a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-strict-3178C6?style=flat-square&logo=typescript&logoColor=white" alt="TypeScript" /></a>
@@ -216,6 +216,9 @@ export class EmailProcessor {
216
216
  Register the processor in your module alongside `BymaxQueueModule`:
217
217
 
218
218
  ```typescript
219
+ import { Module } from '@nestjs/common'
220
+ import { EmailProcessor } from './email.processor'
221
+
219
222
  @Module({
220
223
  providers: [EmailProcessor],
221
224
  })
@@ -228,20 +231,26 @@ export class EmailModule {}
228
231
 
229
232
  Every option passed to `forRoot` / `forRootAsync`. Only `connection` is required.
230
233
 
231
- | Option | Type | Default | Description |
232
- | -------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
233
- | `connection` | `{ client } \| { url, options? } \| { options }` | — | **Required.** Mode A supplies an existing `ioredis` client; Mode B lets the library open its own. The three arms are mutually exclusive. |
234
- | `prefix` | `string` | `'bull'` | Redis key namespace. Applied to the `Queue`, `Worker`, `QueueEvents` **and** `FlowProducer` — see [Architecture](#-architecture). |
235
- | `defaultJobOptions` | `JobsOptions` | `attempts: 3` · exponential backoff `2000` ms · `removeOnComplete: { age: 24 h, count: 1000 }` · `removeOnFail: { age: 7 d, count: 5000 }` | Merged into every job enqueued through the service; per-job options win. The bounded `removeOn*` caps are what keep Redis from growing without limit under a failure storm. |
236
- | `queueOptions` | `Partial<QueueOptions>` | `{}` | Extra BullMQ `Queue` options. `connection`, `defaultJobOptions` and `prefix` are owned by the library and cannot be set here. |
237
- | `flows.enabled` | `boolean` | `false` | Registers `FlowService`. Injecting it while disabled throws `FLOW_DISABLED`. |
238
- | `metrics.enabled` | `boolean` | `false` | Registers `MetricsService`. Injecting it while disabled throws `METRICS_DISABLED`. |
239
- | `metrics.cacheTtlMs` | `number` | `5_000` | TTL of the metrics snapshot cache. `0` disables caching. Must be `>= 0`. |
240
- | `telemetry` | `Telemetry` | — | A BullMQ `Telemetry` instance (typically `new BullMQOtel(...)` from the optional `bullmq-otel` peer), attached to every `Queue` and `Worker`. |
241
- | `shutdown.drainTimeoutMs` | `number` | `30_000` | Upper bound on the graceful drain before workers are force-closed. Must be `> 0`. |
242
- | `shutdown.drainOnShutdown` | `boolean` | `false` | Wait for the queue to drain, not just for in-flight jobs to finish. |
243
- | `connectionReadyTimeoutMs` | `number` | `10_000` | Mode B only: how long to wait for Redis `ready` before failing with `CONNECTION_TIMEOUT`. |
244
- | `isGlobal` | `boolean` | `true` | Register the module globally, so `QueueService` is injectable without re-importing. |
234
+ | Option | Type | Default | Description |
235
+ | -------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
236
+ | `connection` | `{ client } \| { url, options? } \| { options }` | — | **Required.** Mode A supplies an existing `ioredis` client; Mode B lets the library open its own. The arms are mutually exclusive. |
237
+ | `prefix` | `string` | `'bull'` | Redis key namespace. Applied to the `Queue`, `Worker`, `QueueEvents` **and** `FlowProducer` — see [Architecture](#-architecture). |
238
+ | `defaultJobOptions` | `JobsOptions` | `attempts: 3` · exponential backoff `2000` ms · `removeOnComplete: { age: 24 h, count: 1000 }` · `removeOnFail: { age: 7 d, count: 5000 }` | Merged into every job enqueued through the service; per-job options win. The bounded `removeOn*` caps are what keep Redis from growing without limit under a failure storm. |
239
+ | `queueOptions` | `Partial<QueueOptions>` | `{}` | Extra BullMQ `Queue` options. `connection`, `defaultJobOptions` and `prefix` are owned by the library and cannot be set here. |
240
+ | `flows.enabled` | `boolean` | `false` | `FlowService` is always provided; while disabled, every method except `close()` throws `FLOW_DISABLED`. |
241
+ | `metrics.enabled` | `boolean` | `false` | `MetricsService` is always provided; while disabled, every method throws `METRICS_DISABLED`. |
242
+ | `metrics.cacheTtlMs` | `number` | `5_000` | TTL of the metrics snapshot cache. `0` disables caching. Must be `>= 0`. |
243
+ | `telemetry` | `Telemetry` | — | A BullMQ `Telemetry` instance (typically `new BullMQOtel(...)` from the optional `bullmq-otel` peer), attached to every `Queue` and `Worker`. |
244
+ | `shutdown.drainTimeoutMs` | `number` | `30_000` | Upper bound on the graceful drain before workers are force-closed. Must be `> 0`. |
245
+ | `shutdown.drainOnShutdown` | `boolean` | `false` | Development and test only: after the workers close, call `drain()` on every cached queue, which **removes** its waiting jobs (delayed and active jobs are left alone). Leave it `false` in production. |
246
+ | `connectionReadyTimeoutMs` | `number` | `10_000` | Mode B only: how long to wait for Redis `ready` before failing with `CONNECTION_TIMEOUT`. |
247
+ | `isGlobal` | `boolean` | `true` | Register the module globally, so `QueueService` is injectable without re-importing. |
248
+
249
+ > [!WARNING]
250
+ > **`shutdown.drainOnShutdown` deletes jobs.** When it is `true`, shutdown calls `drain()` on every
251
+ > queue this process created, which removes all of that queue's waiting jobs from
252
+ > Redis, including the ones other instances were about to process. It exists for development and
253
+ > tests that need an empty queue. Never enable it in production.
245
254
 
246
255
  Invalid options are rejected at bootstrap with `QueueException(INVALID_OPTIONS)` — never
247
256
  silently corrected. A misconfigured queue fails when the application starts, not when the
@@ -300,8 +309,8 @@ BymaxQueueModule.forRoot({
300
309
  })
301
310
  ```
302
311
 
303
- The library opens one connection for queues and one per worker/QueueEvents listener, each
304
- with `maxRetriesPerRequest: null` where BullMQ requires it.
312
+ The library opens one connection for queues, one per worker and one per queue that has an
313
+ `@OnQueueEvent` listener. The worker and `QueueEvents` connections carry `maxRetriesPerRequest: null`.
305
314
 
306
315
  ---
307
316
 
@@ -321,34 +330,35 @@ import type {
321
330
  } from '@bymax-one/nest-queue'
322
331
  ```
323
332
 
324
- | Method | Signature | Description |
325
- | -------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
326
- | `enqueue` | `<TData, TResult>(queue, name, data, opts?) → Promise<Job<TData, TResult>>` | Add a single job |
327
- | `enqueueBulk` | `<TData, TResult>(queue, jobs) → Promise<Job<TData, TResult>[]>` | Add up to 1 000 jobs in one Redis roundtrip |
328
- | `getJob` | `<TData, TResult>(queue, jobId) → Promise<Job<TData, TResult> \| null>` | Get job by ID; `null` when it does not exist |
329
- | `getJobs` | `<TData, TResult>(queue, status, start?, end?) → Promise<Job<TData, TResult>[]>` | Page jobs in one status (default `0`–`50`) |
330
- | `getMetrics` | `(queue) → Promise<QueueMetrics>` | Uncached snapshot of job counts per status, with a collection timestamp |
331
- | `pauseQueue` | `(queue) → Promise<void>` | Pause a queue |
332
- | `resumeQueue` | `(queue) → Promise<void>` | Resume a paused queue |
333
- | `cleanQueue` | `(queue, graceMs, limit, status?) → Promise<string[]>` | Remove up to `limit` jobs older than `graceMs` (`0` = no limit); returns removed ids |
334
- | `upsertJobScheduler` | `<TData, TResult>(queue, id, repeat, template?) → Promise<Job<TData, TResult> \| undefined>` | Create or update a recurring Job Scheduler; returns the first scheduled job |
335
- | `removeJobScheduler` | `(queue, id) → Promise<boolean>` | Remove a Job Scheduler by ID |
336
- | `getJobSchedulers` | `(queue, start?, end?, asc?) → Promise<JobSchedulerJson[]>` | Page the schedulers registered on a queue |
337
- | `getOrCreateQueue` | `<TData, TResult>(queue, overrides?) → Queue<TData, TResult>` | Return the cached raw `Queue` instance |
338
- | `getCachedQueues` | `() → ReadonlyMap<string, Queue>` | Read-only view of every queue created so far |
333
+ | Method | Signature | Description |
334
+ | -------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
335
+ | `enqueue` | `<TData, TResult>(queue, name, data, opts?) → Promise<Job<TData, TResult>>` | Add a single job |
336
+ | `enqueueBulk` | `<TData, TResult>(queue, jobs) → Promise<Job<TData, TResult>[]>` | Add up to 1 000 jobs in one Redis roundtrip |
337
+ | `getJob` | `<TData, TResult>(queue, jobId) → Promise<Job<TData, TResult> \| null>` | Get job by ID; `null` when it does not exist |
338
+ | `getJobs` | `<TData, TResult>(queue, status, start?, end?) → Promise<Job<TData, TResult>[]>` | Page jobs in one status (default `0`–`50`) |
339
+ | `getMetrics` | `(queue) → Promise<QueueMetrics>` | Uncached snapshot of job counts per status, with a collection timestamp |
340
+ | `pauseQueue` | `(queue) → Promise<void>` | Pause a queue |
341
+ | `resumeQueue` | `(queue) → Promise<void>` | Resume a paused queue |
342
+ | `cleanQueue` | `(queue, gracePeriodMs, limit, status?) → Promise<string[]>` | Remove up to `limit` jobs older than `gracePeriodMs` (`0` = no limit); returns removed ids |
343
+ | `upsertJobScheduler` | `<TData, TResult>(queue, id, repeat, template?) → Promise<Job<TData, TResult> \| undefined>` | Create or update a recurring Job Scheduler; returns the first scheduled job |
344
+ | `removeJobScheduler` | `(queue, id) → Promise<boolean>` | Remove a Job Scheduler by ID |
345
+ | `getJobSchedulers` | `(queue, start?, end?, asc?) → Promise<JobSchedulerJson[]>` | Page the schedulers registered on a queue |
346
+ | `getOrCreateQueue` | `<TData, TResult>(queue, overrides?) → Queue<TData, TResult>` | Return the cached raw `Queue` instance |
347
+ | `getCachedQueues` | `() → ReadonlyMap<string, Queue>` | Read-only view of every queue created so far |
339
348
 
340
349
  ### `WorkerRegistry`
341
350
 
342
351
  Programmatic API to create and manage BullMQ workers at runtime.
343
352
 
344
353
  ```typescript
354
+ import path from 'node:path'
345
355
  import { WorkerRegistry } from '@bymax-one/nest-queue'
346
356
  import type { ProgrammaticWorkerConfig, SandboxedWorkerConfig } from '@bymax-one/nest-queue'
347
357
 
348
358
  // Register an in-process worker
349
359
  registry.register({
350
360
  queueName: 'email',
351
- processor: async (job) => {
361
+ handler: async (job) => {
352
362
  /* … */
353
363
  },
354
364
  options: { concurrency: 4 },
@@ -404,6 +414,23 @@ const metrics: QueueMetrics = await this.metricsService.get('email')
404
414
  const all: readonly QueueMetrics[] = await this.metricsService.getAll()
405
415
  ```
406
416
 
417
+ ### Other exports
418
+
419
+ | Export | What it is |
420
+ | ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
421
+ | `WorkerRegistry.unregister(queue)`, `list()` | Stop and remove a worker and its connection; list the queue names that have a worker |
422
+ | `FlowService.addBulk(flows)`, `getProducer()`, `close()` | Add several flows at once; the underlying BullMQ `FlowProducer`; close it (a no-op while flows are disabled) |
423
+ | `MetricsService.invalidate(queue?)` | Drop the cached snapshot of one queue, or of all of them |
424
+ | `QueueEventsRegistry` | The shared `QueueEvents` per queue (`getOrCreate`, `list`), used by `@OnQueueEvent` |
425
+ | `BYMAX_QUEUE_OPTIONS`, `BYMAX_QUEUE_RESOLVED_OPTIONS` | Injection tokens for the options as given and with the defaults applied |
426
+ | `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'` |
427
+ | `DEFAULT_WORKER_CONCURRENCY`, `MAX_WORKER_CONCURRENCY` | The worker concurrency used when none is given, and its ceiling |
428
+ | `DEFAULT_JOB_OPTIONS`, `DEFAULT_METRICS_CACHE_TTL_MS`, `DEFAULT_SHUTDOWN_DRAIN_TIMEOUT_MS` | The defaults of the Configuration table |
429
+ | `QUEUE_ERROR_MESSAGES` | The default message of each error code |
430
+ | `BymaxQueueModuleOptions`, `BymaxQueueModuleAsyncOptions`, `BymaxQueueOptionsFactory`, `QueueConnectionConfig`, `QueueConnectionMode` | The option and connection types behind `forRoot` and `forRootAsync` |
431
+ | `ProcessorMetadata`, `ProcessHandlerMetadata`, `QueueEventListenerMetadata` | The reflection metadata the decorators write |
432
+ | `JobStatus`, `JOB_STATUS`, `BulkJob`, `SandboxedJob`, `WorkerEventName`, `QueueEventName`, `WorkerOptions` | Constants and types for the APIs above |
433
+
407
434
  ---
408
435
 
409
436
  ## 🚨 Error Code Catalog
@@ -495,12 +522,14 @@ onFailed(job: Job | undefined, err: Error): void {
495
522
 
496
523
  ### `@OnQueueEvent(eventName)`
497
524
 
498
- Listens to BullMQ `QueueEvents` (global, cross-process events). Requires `queueEvents: { enabled: true }` in module options.
525
+ Listens to BullMQ `QueueEvents` (global, cross-process events). The library opens one shared `QueueEvents` connection per queue when it discovers the first listener for it; there is no option to enable.
526
+
527
+ The handler receives one serialized payload object, not a `Job`: `completed` carries `{ jobId, returnvalue, prev? }`, `failed` carries `{ jobId, failedReason, prev? }`, and `error` carries a real `Error`. Fetch the job with `QueueService.getJob` when you need it.
499
528
 
500
529
  ```typescript
501
530
  @OnQueueEvent('completed')
502
- onCompleted(jobId: string, returnValue: string): void {
503
- console.log(`Job ${jobId} completed`)
531
+ onCompleted({ jobId, returnvalue }: { jobId: string; returnvalue: string }): void {
532
+ console.log(`Job ${jobId} completed with ${returnvalue}`)
504
533
  }
505
534
  ```
506
535
 
@@ -586,7 +615,7 @@ import { BullMQOtel } from 'bullmq-otel'
586
615
 
587
616
  BymaxQueueModule.forRoot({
588
617
  connection: { url: process.env.REDIS_URL ?? 'redis://localhost:6379' },
589
- telemetry: new BullMQOtel('my-service', tracer),
618
+ telemetry: new BullMQOtel({ tracerName: 'my-service' }),
590
619
  })
591
620
  ```
592
621
 
@@ -594,7 +623,7 @@ BymaxQueueModule.forRoot({
594
623
 
595
624
  ## 🛑 Graceful Shutdown
596
625
 
597
- `QueueLifecycle` is registered and invoked automatically via NestJS `onApplicationShutdown`.
626
+ `QueueLifecycle` runs automatically when NestJS closes the application context, through its `onModuleDestroy` hook (call `app.enableShutdownHooks()` so that a termination signal closes the context).
598
627
  It implements a **bounded drain protocol** with at-least-once semantics:
599
628
 
600
629
  1. All workers are closed with a grace period (`drainTimeoutMs`, default 30 s).
@@ -708,7 +737,7 @@ Connection details arrive through injected options — the library never reads
708
737
  `process.env` itself, so credentials live wherever your configuration layer keeps them.
709
738
  No connection string, password, or TLS material is written to a log line, an exception
710
739
  message, or an exception payload. `QueueException.details` carries only scalar
711
- configuration values (`{ actualValue, expectedValue, limit, received }`), never a
740
+ values — a reason, a limit, a queue or scheduler name, a status — never a
712
741
  connection object and never `job.data`.
713
742
 
714
743
  They are also not reachable by serializing the objects that hold them. The connection is
@@ -766,12 +795,12 @@ key will do it twice at some point.
766
795
  | Layer | Implementation |
767
796
  | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
768
797
  | Credentials | Injected options only; never read from `process.env`, never logged, never placed in an exception; held in a non-enumerable accessor and private fields, so serializing a service omits them |
769
- | Error payloads | `QueueException.details` restricted to scalar configuration values — no `job.data`, no connection object |
798
+ | Error payloads | `QueueException.details` restricted to scalar values — no `job.data`, no connection object |
770
799
  | Job payloads | Treated as opaque; never deep-merged (prototype-pollution guard), never logged |
771
800
  | Namespacing | `prefix` applied uniformly to `Queue`, `Worker`, `QueueEvents` and `FlowProducer` |
772
801
  | Connection policy | `maxRetriesPerRequest: null` confined to duplicated consumer connections; producers keep fail-fast retries |
773
802
  | Delivery | At-least-once with a bounded drain; unacknowledged work is re-queued by BullMQ's stalled check |
774
- | Supply chain | `dependencies: {}`; SHA-pinned Actions, OSV-Scanner, TruffleHog, OpenSSF Scorecard; npm publish over OIDC |
803
+ | Supply chain | `dependencies: {}`; SHA-pinned third-party Actions, OSV-Scanner, TruffleHog, OpenSSF Scorecard; npm publish over OIDC |
775
804
  | Input validation | Options validated at bootstrap; cron patterns validated by BullMQ's own parser, never a hand-rolled regex |
776
805
 
777
806
  > [!IMPORTANT]
@@ -796,7 +825,7 @@ key will do it twice at some point.
796
825
  ## 🧪 Testing & Quality
797
826
 
798
827
  - **100% line/branch coverage** on every implemented file (hard CI gate).
799
- - **Mutation score 99.68%** (Stryker `break 95`) — see [`docs/mutation_testing_results.md`](docs/mutation_testing_results.md).
828
+ - **Mutation score 100.00%** (Stryker `break 100`) — see [`docs/mutation_testing_results.md`](docs/mutation_testing_results.md).
800
829
  - **Real-Redis E2E** via Testcontainers (workers, flows, schedulers, deduplication, retries,
801
830
  DLQ, graceful shutdown).
802
831
  - **Type-level API tests** (`pnpm test:types`) pin the published signatures, so a refactor
@@ -804,13 +833,13 @@ key will do it twice at some point.
804
833
  - **Published-shape gates**: `pnpm check:exports` resolves every entrypoint the way each
805
834
  module-resolution mode would (all four `attw` quadrants green, `node10` included), and
806
835
  `pnpm smoke` resolves the packed tarball from a real consumer in ESM _and_ CommonJS.
807
- - **Bundle budgets** enforced (`pnpm size`): server ≤ 18 KiB brotli, shared ≤ 2.5 KiB.
808
- - **Supply chain:** SHA-pinned GitHub Actions, OSV-Scanner, TruffleHog, OpenSSF Scorecard,
836
+ - **Bundle budgets** enforced (`pnpm size`): server ≤ 18 KiB brotli, shared ≤ 2 500 B.
837
+ - **Supply chain:** SHA-pinned third-party GitHub Actions, OSV-Scanner, TruffleHog, OpenSSF Scorecard,
809
838
  npm publish with provenance (OIDC).
810
839
 
811
840
  ```bash
812
841
  pnpm typecheck && pnpm test:types && pnpm lint && pnpm test:cov:all && \
813
- pnpm build && pnpm size && pnpm check:exports && pnpm smoke
842
+ pnpm build && pnpm size && pnpm check:exports && pnpm check:published && pnpm smoke
814
843
  ```
815
844
 
816
845
  ---
@@ -852,20 +881,17 @@ implementation, so BullMQ still works, just without the native accelerator.
852
881
 
853
882
  ### `CONNECTION_REQUIRES_NULL_RETRIES`
854
883
 
855
- This error fires in Mode A when the injected client was not configured with
856
- `maxRetriesPerRequest: null`. BullMQ workers require this setting to block on
857
- Redis operations rather than giving up after a few retries.
858
-
859
- If you use `@bymax-one/nest-cache`, pass a queue-dedicated client with `maxRetriesPerRequest: null`:
860
-
861
- ```typescript
862
- // In your cache module, create a second ioredis client for the queue
863
- const queueRedis = new Redis({ ..., maxRetriesPerRequest: null })
864
- ```
884
+ BullMQ workers need `maxRetriesPerRequest: null` to block on Redis operations rather
885
+ than giving up after a few retries. The library does not ask you to configure it: every
886
+ Worker and `QueueEvents` connection is a `duplicate()` of the client you pass, made with
887
+ `maxRetriesPerRequest: null`, and in Mode A it checks that a duplicate really comes back
888
+ that way. This error means one did not — measured with ioredis, a client created with
889
+ `maxRetriesPerRequest: 3` duplicates to `null`, so a stock client never triggers it.
865
890
 
866
- The library duplicates the client you pass and applies the correct setting; ensure
867
- the **original** client passed to Mode A is usable as a duplication source (ioredis
868
- allows duplicate on any state).
891
+ If you see it, the client you passed is not a plain ioredis client (a wrapper or a test
892
+ double whose `duplicate()` ignores its options). Pass the ioredis client itself, or let the
893
+ library open its own connection with Mode B. The producer keeps the client's own retry
894
+ policy, which is what lets it fail fast during an outage.
869
895
 
870
896
  ### Jobs get stuck (not processed after restart)
871
897
 
@@ -873,10 +899,11 @@ Stalled jobs are re-queued automatically by BullMQ's stalled-job check. If worke
873
899
  do not pick them up:
874
900
 
875
901
  1. Verify `drainTimeoutMs` is larger than the worst-case job runtime.
876
- 2. Verify `lockDuration` (in `workerOptions`) covers the handler execution time —
877
- a worker that runs longer than `lockDuration` will have its lock stolen, marking
878
- the job as stalled.
879
- 3. Check `worker.maxStalledCount` is `> 0` (default `1`) so stalled jobs re-queue.
902
+ 2. Verify `lockDuration` (in the `options` you pass to `@Processor` or `register`) covers
903
+ the handler execution time — a worker that runs longer than `lockDuration` will have its
904
+ lock stolen, marking the job as stalled.
905
+ 3. BullMQ re-queues a stalled job up to its `maxStalledCount` times (default `1`) and then
906
+ fails it; this library does not expose that setting.
880
907
 
881
908
  ---
882
909
 
@@ -80,7 +80,9 @@ var QueueException = class extends common.HttpException {
80
80
 
81
81
  // src/server/config/validate-options.ts
82
82
  function invalid(reason) {
83
- throw new QueueException(QUEUE_ERROR_CODES.INVALID_OPTIONS, 500, { reason });
83
+ throw new QueueException(QUEUE_ERROR_CODES.INVALID_OPTIONS, common.HttpStatus.INTERNAL_SERVER_ERROR, {
84
+ reason
85
+ });
84
86
  }
85
87
  function validateOptions(opts) {
86
88
  const cfg = opts.connection;
@@ -152,15 +154,17 @@ var BYMAX_QUEUE_OPTIONS = /* @__PURE__ */ Symbol("BYMAX_QUEUE_OPTIONS");
152
154
  var BYMAX_QUEUE_REDIS_CLIENT = /* @__PURE__ */ Symbol("BYMAX_QUEUE_REDIS_CLIENT");
153
155
  var BYMAX_QUEUE_CONNECTION_MODE = /* @__PURE__ */ Symbol("BYMAX_QUEUE_CONNECTION_MODE");
154
156
  var BYMAX_QUEUE_RESOLVED_OPTIONS = /* @__PURE__ */ Symbol("BYMAX_QUEUE_RESOLVED_OPTIONS");
155
-
156
- // src/server/utils/validate-connection.ts
157
157
  function assertBlockingConnection(client) {
158
158
  const actualValue = client.options?.maxRetriesPerRequest;
159
159
  if (actualValue !== null) {
160
- throw new QueueException(QUEUE_ERROR_CODES.CONNECTION_REQUIRES_NULL_RETRIES, 500, {
161
- actualValue: actualValue ?? null,
162
- expectedValue: null
163
- });
160
+ throw new QueueException(
161
+ QUEUE_ERROR_CODES.CONNECTION_REQUIRES_NULL_RETRIES,
162
+ common.HttpStatus.INTERNAL_SERVER_ERROR,
163
+ {
164
+ actualValue: actualValue ?? null,
165
+ expectedValue: null
166
+ }
167
+ );
164
168
  }
165
169
  }
166
170
  function isClientUsable(client) {
@@ -217,18 +221,26 @@ exports.ConnectionResolver = class ConnectionResolver {
217
221
  /** The resolved Queue-role client. */
218
222
  getClient() {
219
223
  if (!__privateGet(this, _client)) {
220
- throw new QueueException(QUEUE_ERROR_CODES.CONNECTION_INVALID, 500, {
221
- reason: "not initialized"
222
- });
224
+ throw new QueueException(
225
+ QUEUE_ERROR_CODES.CONNECTION_INVALID,
226
+ common.HttpStatus.INTERNAL_SERVER_ERROR,
227
+ {
228
+ reason: "not initialized"
229
+ }
230
+ );
223
231
  }
224
232
  return __privateGet(this, _client);
225
233
  }
226
234
  /** The resolved connection mode. */
227
235
  getMode() {
228
236
  if (!this.mode) {
229
- throw new QueueException(QUEUE_ERROR_CODES.CONNECTION_INVALID, 500, {
230
- reason: "not initialized"
231
- });
237
+ throw new QueueException(
238
+ QUEUE_ERROR_CODES.CONNECTION_INVALID,
239
+ common.HttpStatus.INTERNAL_SERVER_ERROR,
240
+ {
241
+ reason: "not initialized"
242
+ }
243
+ );
232
244
  }
233
245
  return this.mode;
234
246
  }
@@ -250,7 +262,11 @@ exports.ConnectionResolver = class ConnectionResolver {
250
262
  this.mode = "mode-a-byo";
251
263
  __privateSet(this, _client, client);
252
264
  if (!isClientUsable(client)) {
253
- throw new QueueException(QUEUE_ERROR_CODES.CONNECTION_INVALID, 500, { status: client.status });
265
+ throw new QueueException(
266
+ QUEUE_ERROR_CODES.CONNECTION_INVALID,
267
+ common.HttpStatus.INTERNAL_SERVER_ERROR,
268
+ { status: client.status }
269
+ );
254
270
  }
255
271
  const probe = duplicateConnection(client);
256
272
  try {
@@ -279,7 +295,13 @@ exports.ConnectionResolver = class ConnectionResolver {
279
295
  };
280
296
  const timer = setTimeout(() => {
281
297
  cleanup();
282
- reject(new QueueException(QUEUE_ERROR_CODES.CONNECTION_TIMEOUT, 500, { timeoutMs }));
298
+ reject(
299
+ new QueueException(
300
+ QUEUE_ERROR_CODES.CONNECTION_TIMEOUT,
301
+ common.HttpStatus.INTERNAL_SERVER_ERROR,
302
+ { timeoutMs }
303
+ )
304
+ );
283
305
  }, timeoutMs);
284
306
  client.once("ready", onReady);
285
307
  client.once("error", onError);
@@ -292,10 +314,10 @@ exports.ConnectionResolver = __decorateClass([
292
314
  common.Injectable(),
293
315
  __decorateParam(0, common.Inject(BYMAX_QUEUE_OPTIONS))
294
316
  ], exports.ConnectionResolver);
295
-
296
- // src/server/utils/validate-job-scheduler-options.ts
297
317
  function invalid2(reason) {
298
- throw new QueueException(QUEUE_ERROR_CODES.INVALID_REPEAT_OPTIONS, 400, { reason });
318
+ throw new QueueException(QUEUE_ERROR_CODES.INVALID_REPEAT_OPTIONS, common.HttpStatus.BAD_REQUEST, {
319
+ reason
320
+ });
299
321
  }
300
322
  function toEpochMs(date) {
301
323
  return typeof date === "number" ? date : Date.parse(date);
@@ -435,11 +457,15 @@ exports.QueueService = class QueueService {
435
457
  */
436
458
  async enqueueBulk(queueName, jobs) {
437
459
  if (jobs.length > MAX_BULK_SIZE) {
438
- throw new QueueException(QUEUE_ERROR_CODES.BULK_ENQUEUE_FAILED, 500, {
439
- reason: "batch size exceeds limit",
440
- limit: MAX_BULK_SIZE,
441
- received: jobs.length
442
- });
460
+ throw new QueueException(
461
+ QUEUE_ERROR_CODES.BULK_ENQUEUE_FAILED,
462
+ common.HttpStatus.INTERNAL_SERVER_ERROR,
463
+ {
464
+ reason: "batch size exceeds limit",
465
+ limit: MAX_BULK_SIZE,
466
+ received: jobs.length
467
+ }
468
+ );
443
469
  }
444
470
  try {
445
471
  const created = await this.queueFor(queueName).addBulk(
@@ -451,9 +477,13 @@ exports.QueueService = class QueueService {
451
477
  );
452
478
  return created;
453
479
  } catch (err) {
454
- throw new QueueException(QUEUE_ERROR_CODES.BULK_ENQUEUE_FAILED, 500, {
455
- cause: err.message
456
- });
480
+ throw new QueueException(
481
+ QUEUE_ERROR_CODES.BULK_ENQUEUE_FAILED,
482
+ common.HttpStatus.INTERNAL_SERVER_ERROR,
483
+ {
484
+ cause: err.message
485
+ }
486
+ );
457
487
  }
458
488
  }
459
489
  /**
@@ -529,7 +559,7 @@ exports.QueueService = class QueueService {
529
559
  return job2;
530
560
  } catch (err) {
531
561
  if (isOperationalError(err)) throw err;
532
- throw new QueueException(QUEUE_ERROR_CODES.INVALID_REPEAT_OPTIONS, 400, {
562
+ throw new QueueException(QUEUE_ERROR_CODES.INVALID_REPEAT_OPTIONS, common.HttpStatus.BAD_REQUEST, {
533
563
  reason: "pattern must be a valid cron expression (5- or 6-field)"
534
564
  });
535
565
  }
@@ -682,7 +712,7 @@ exports.FlowService = class FlowService {
682
712
  */
683
713
  requireProducer() {
684
714
  if (!this.producer) {
685
- throw new QueueException(QUEUE_ERROR_CODES.FLOW_DISABLED, 503);
715
+ throw new QueueException(QUEUE_ERROR_CODES.FLOW_DISABLED, common.HttpStatus.SERVICE_UNAVAILABLE);
686
716
  }
687
717
  return this.producer;
688
718
  }
@@ -750,7 +780,7 @@ exports.MetricsService = class MetricsService {
750
780
  */
751
781
  ensureEnabled() {
752
782
  if (!this.enabled) {
753
- throw new QueueException(QUEUE_ERROR_CODES.METRICS_DISABLED, 503);
783
+ throw new QueueException(QUEUE_ERROR_CODES.METRICS_DISABLED, common.HttpStatus.SERVICE_UNAVAILABLE);
754
784
  }
755
785
  }
756
786
  };
@@ -791,10 +821,14 @@ exports.WorkerRegistry = class WorkerRegistry {
791
821
  worker = new bullmq.Worker(queueName, handler, bullOpts);
792
822
  } catch (cause) {
793
823
  conn.disconnect();
794
- throw new QueueException(QUEUE_ERROR_CODES.WORKER_REGISTRATION_FAILED, 500, {
795
- queueName,
796
- cause: cause instanceof Error ? cause.message : String(cause)
797
- });
824
+ throw new QueueException(
825
+ QUEUE_ERROR_CODES.WORKER_REGISTRATION_FAILED,
826
+ common.HttpStatus.INTERNAL_SERVER_ERROR,
827
+ {
828
+ queueName,
829
+ cause: cause instanceof Error ? cause.message : String(cause)
830
+ }
831
+ );
798
832
  }
799
833
  attachDefaultErrorListener(worker, this.logger, "Worker", queueName);
800
834
  __privateGet(this, _entries).set(queueName, { worker, connection: conn });
@@ -830,10 +864,14 @@ exports.WorkerRegistry = class WorkerRegistry {
830
864
  worker = new bullmq.Worker(queueName, processorFile, bullOpts);
831
865
  } catch (cause) {
832
866
  conn.disconnect();
833
- throw new QueueException(QUEUE_ERROR_CODES.WORKER_REGISTRATION_FAILED, 500, {
834
- queueName,
835
- cause: cause instanceof Error ? cause.message : String(cause)
836
- });
867
+ throw new QueueException(
868
+ QUEUE_ERROR_CODES.WORKER_REGISTRATION_FAILED,
869
+ common.HttpStatus.INTERNAL_SERVER_ERROR,
870
+ {
871
+ queueName,
872
+ cause: cause instanceof Error ? cause.message : String(cause)
873
+ }
874
+ );
837
875
  }
838
876
  attachDefaultErrorListener(worker, this.logger, "Worker", queueName);
839
877
  __privateGet(this, _entries).set(queueName, { worker, connection: conn });
@@ -906,7 +944,11 @@ exports.WorkerRegistry = class WorkerRegistry {
906
944
  */
907
945
  guardDuplicate(queueName) {
908
946
  if (__privateGet(this, _entries).has(queueName)) {
909
- throw new QueueException(QUEUE_ERROR_CODES.DUPLICATE_PROCESSOR, 500, { queueName });
947
+ throw new QueueException(
948
+ QUEUE_ERROR_CODES.DUPLICATE_PROCESSOR,
949
+ common.HttpStatus.INTERNAL_SERVER_ERROR,
950
+ { queueName }
951
+ );
910
952
  }
911
953
  }
912
954
  /**
@@ -920,30 +962,46 @@ exports.WorkerRegistry = class WorkerRegistry {
920
962
  validateOptions(opts) {
921
963
  if (opts?.concurrency !== void 0) {
922
964
  if (opts.concurrency < 1) {
923
- throw new QueueException(QUEUE_ERROR_CODES.WORKER_REGISTRATION_FAILED, 500, {
924
- reason: "concurrency must be >= 1",
925
- actual: opts.concurrency
926
- });
965
+ throw new QueueException(
966
+ QUEUE_ERROR_CODES.WORKER_REGISTRATION_FAILED,
967
+ common.HttpStatus.INTERNAL_SERVER_ERROR,
968
+ {
969
+ reason: "concurrency must be >= 1",
970
+ actual: opts.concurrency
971
+ }
972
+ );
927
973
  }
928
974
  if (opts.concurrency > MAX_WORKER_CONCURRENCY) {
929
- throw new QueueException(QUEUE_ERROR_CODES.WORKER_REGISTRATION_FAILED, 500, {
930
- reason: `concurrency must be <= ${MAX_WORKER_CONCURRENCY.toString()}`,
931
- actual: opts.concurrency
932
- });
975
+ throw new QueueException(
976
+ QUEUE_ERROR_CODES.WORKER_REGISTRATION_FAILED,
977
+ common.HttpStatus.INTERNAL_SERVER_ERROR,
978
+ {
979
+ reason: `concurrency must be <= ${MAX_WORKER_CONCURRENCY.toString()}`,
980
+ actual: opts.concurrency
981
+ }
982
+ );
933
983
  }
934
984
  }
935
985
  if (opts?.limiter !== void 0) {
936
986
  if (opts.limiter.max < 1) {
937
- throw new QueueException(QUEUE_ERROR_CODES.WORKER_REGISTRATION_FAILED, 500, {
938
- reason: "limiter.max must be >= 1",
939
- actual: opts.limiter.max
940
- });
987
+ throw new QueueException(
988
+ QUEUE_ERROR_CODES.WORKER_REGISTRATION_FAILED,
989
+ common.HttpStatus.INTERNAL_SERVER_ERROR,
990
+ {
991
+ reason: "limiter.max must be >= 1",
992
+ actual: opts.limiter.max
993
+ }
994
+ );
941
995
  }
942
996
  if (opts.limiter.duration < 1) {
943
- throw new QueueException(QUEUE_ERROR_CODES.WORKER_REGISTRATION_FAILED, 500, {
944
- reason: "limiter.duration must be >= 1",
945
- actual: opts.limiter.duration
946
- });
997
+ throw new QueueException(
998
+ QUEUE_ERROR_CODES.WORKER_REGISTRATION_FAILED,
999
+ common.HttpStatus.INTERNAL_SERVER_ERROR,
1000
+ {
1001
+ reason: "limiter.duration must be >= 1",
1002
+ actual: opts.limiter.duration
1003
+ }
1004
+ );
947
1005
  }
948
1006
  }
949
1007
  }
@@ -959,17 +1017,25 @@ exports.WorkerRegistry = class WorkerRegistry {
959
1017
  validateProcessorFile(processorFile) {
960
1018
  if (processorFile instanceof URL) {
961
1019
  if (processorFile.protocol !== "file:") {
962
- throw new QueueException(QUEUE_ERROR_CODES.WORKER_REGISTRATION_FAILED, 500, {
963
- reason: "processorFile URL must use the file: protocol"
964
- });
1020
+ throw new QueueException(
1021
+ QUEUE_ERROR_CODES.WORKER_REGISTRATION_FAILED,
1022
+ common.HttpStatus.INTERNAL_SERVER_ERROR,
1023
+ {
1024
+ reason: "processorFile URL must use the file: protocol"
1025
+ }
1026
+ );
965
1027
  }
966
1028
  this.assertProcessorExtension(processorFile.pathname);
967
1029
  return;
968
1030
  }
969
1031
  if (!path.isAbsolute(processorFile)) {
970
- throw new QueueException(QUEUE_ERROR_CODES.WORKER_REGISTRATION_FAILED, 500, {
971
- reason: "processorFile must be an absolute path"
972
- });
1032
+ throw new QueueException(
1033
+ QUEUE_ERROR_CODES.WORKER_REGISTRATION_FAILED,
1034
+ common.HttpStatus.INTERNAL_SERVER_ERROR,
1035
+ {
1036
+ reason: "processorFile must be an absolute path"
1037
+ }
1038
+ );
973
1039
  }
974
1040
  this.assertProcessorExtension(processorFile);
975
1041
  }
@@ -983,9 +1049,13 @@ exports.WorkerRegistry = class WorkerRegistry {
983
1049
  assertProcessorExtension(filePath) {
984
1050
  const allowedExtensions = /* @__PURE__ */ new Set([".js", ".cjs", ".mjs"]);
985
1051
  if (!allowedExtensions.has(path.extname(filePath))) {
986
- throw new QueueException(QUEUE_ERROR_CODES.WORKER_REGISTRATION_FAILED, 500, {
987
- reason: "processorFile must have a .js, .cjs, or .mjs extension"
988
- });
1052
+ throw new QueueException(
1053
+ QUEUE_ERROR_CODES.WORKER_REGISTRATION_FAILED,
1054
+ common.HttpStatus.INTERNAL_SERVER_ERROR,
1055
+ {
1056
+ reason: "processorFile must have a .js, .cjs, or .mjs extension"
1057
+ }
1058
+ );
989
1059
  }
990
1060
  }
991
1061
  /**
@@ -1173,7 +1243,11 @@ var ProcessorDiscoveryService = class {
1173
1243
  const { queueName, workerOptions, _warnedNoConcurrency } = processorMeta;
1174
1244
  const ctor = instance.constructor;
1175
1245
  if (this.registeredQueues.has(queueName)) {
1176
- throw new QueueException(QUEUE_ERROR_CODES.DUPLICATE_PROCESSOR, 500, { queueName });
1246
+ throw new QueueException(
1247
+ QUEUE_ERROR_CODES.DUPLICATE_PROCESSOR,
1248
+ common.HttpStatus.INTERNAL_SERVER_ERROR,
1249
+ { queueName }
1250
+ );
1177
1251
  }
1178
1252
  this.registeredQueues.add(queueName);
1179
1253
  if (_warnedNoConcurrency) {
@@ -1322,7 +1396,7 @@ var QueueLifecycle = class {
1322
1396
  }
1323
1397
  /**
1324
1398
  * Drain every cached queue when `drainOnShutdown` is enabled (dev/test only).
1325
- * Removes waiting and delayed jobs; off by default to stay production-safe.
1399
+ * Removes waiting jobs and leaves delayed and active ones; off by default to stay production-safe.
1326
1400
  */
1327
1401
  async drainQueuesIfRequested() {
1328
1402
  if (!this.resolved.shutdown.drainOnShutdown) return;
@@ -579,7 +579,7 @@ declare class QueueService {
579
579
  *
580
580
  * Opt-in: this provider is registered unconditionally so that injecting it never
581
581
  * raises `UnknownDependenciesException`; instead, every operation throws
582
- * `QueueException(FLOW_DISABLED, 503)` when `flows.enabled !== true`.
582
+ * `QueueException(FLOW_DISABLED, HttpStatus.SERVICE_UNAVAILABLE)` when `flows.enabled !== true`.
583
583
  *
584
584
  * @example
585
585
  * await flowService.add({
@@ -1067,7 +1067,7 @@ declare class QueueLifecycle implements OnModuleDestroy {
1067
1067
  private closeDuplicatedConnections;
1068
1068
  /**
1069
1069
  * Drain every cached queue when `drainOnShutdown` is enabled (dev/test only).
1070
- * Removes waiting and delayed jobs; off by default to stay production-safe.
1070
+ * Removes waiting jobs and leaves delayed and active ones; off by default to stay production-safe.
1071
1071
  */
1072
1072
  private drainQueuesIfRequested;
1073
1073
  /** Close the flow producer (idempotent), swallowing any failure. */
@@ -579,7 +579,7 @@ declare class QueueService {
579
579
  *
580
580
  * Opt-in: this provider is registered unconditionally so that injecting it never
581
581
  * raises `UnknownDependenciesException`; instead, every operation throws
582
- * `QueueException(FLOW_DISABLED, 503)` when `flows.enabled !== true`.
582
+ * `QueueException(FLOW_DISABLED, HttpStatus.SERVICE_UNAVAILABLE)` when `flows.enabled !== true`.
583
583
  *
584
584
  * @example
585
585
  * await flowService.add({
@@ -1067,7 +1067,7 @@ declare class QueueLifecycle implements OnModuleDestroy {
1067
1067
  private closeDuplicatedConnections;
1068
1068
  /**
1069
1069
  * Drain every cached queue when `drainOnShutdown` is enabled (dev/test only).
1070
- * Removes waiting and delayed jobs; off by default to stay production-safe.
1070
+ * Removes waiting jobs and leaves delayed and active ones; off by default to stay production-safe.
1071
1071
  */
1072
1072
  private drainQueuesIfRequested;
1073
1073
  /** Close the flow producer (idempotent), swallowing any failure. */
@@ -1,4 +1,4 @@
1
- import { Injectable, Inject, ConfigurableModuleBuilder, Module, Logger, HttpException, HttpStatus } from '@nestjs/common';
1
+ import { Injectable, Inject, ConfigurableModuleBuilder, Module, HttpStatus, Logger, HttpException } from '@nestjs/common';
2
2
  import { DiscoveryService, DiscoveryModule } from '@nestjs/core';
3
3
  import { Redis } from 'ioredis';
4
4
  import { Worker, QueueEvents, Queue, FlowProducer } from 'bullmq';
@@ -78,7 +78,9 @@ var QueueException = class extends HttpException {
78
78
 
79
79
  // src/server/config/validate-options.ts
80
80
  function invalid(reason) {
81
- throw new QueueException(QUEUE_ERROR_CODES.INVALID_OPTIONS, 500, { reason });
81
+ throw new QueueException(QUEUE_ERROR_CODES.INVALID_OPTIONS, HttpStatus.INTERNAL_SERVER_ERROR, {
82
+ reason
83
+ });
82
84
  }
83
85
  function validateOptions(opts) {
84
86
  const cfg = opts.connection;
@@ -150,15 +152,17 @@ var BYMAX_QUEUE_OPTIONS = /* @__PURE__ */ Symbol("BYMAX_QUEUE_OPTIONS");
150
152
  var BYMAX_QUEUE_REDIS_CLIENT = /* @__PURE__ */ Symbol("BYMAX_QUEUE_REDIS_CLIENT");
151
153
  var BYMAX_QUEUE_CONNECTION_MODE = /* @__PURE__ */ Symbol("BYMAX_QUEUE_CONNECTION_MODE");
152
154
  var BYMAX_QUEUE_RESOLVED_OPTIONS = /* @__PURE__ */ Symbol("BYMAX_QUEUE_RESOLVED_OPTIONS");
153
-
154
- // src/server/utils/validate-connection.ts
155
155
  function assertBlockingConnection(client) {
156
156
  const actualValue = client.options?.maxRetriesPerRequest;
157
157
  if (actualValue !== null) {
158
- throw new QueueException(QUEUE_ERROR_CODES.CONNECTION_REQUIRES_NULL_RETRIES, 500, {
159
- actualValue: actualValue ?? null,
160
- expectedValue: null
161
- });
158
+ throw new QueueException(
159
+ QUEUE_ERROR_CODES.CONNECTION_REQUIRES_NULL_RETRIES,
160
+ HttpStatus.INTERNAL_SERVER_ERROR,
161
+ {
162
+ actualValue: actualValue ?? null,
163
+ expectedValue: null
164
+ }
165
+ );
162
166
  }
163
167
  }
164
168
  function isClientUsable(client) {
@@ -215,18 +219,26 @@ var ConnectionResolver = class {
215
219
  /** The resolved Queue-role client. */
216
220
  getClient() {
217
221
  if (!__privateGet(this, _client)) {
218
- throw new QueueException(QUEUE_ERROR_CODES.CONNECTION_INVALID, 500, {
219
- reason: "not initialized"
220
- });
222
+ throw new QueueException(
223
+ QUEUE_ERROR_CODES.CONNECTION_INVALID,
224
+ HttpStatus.INTERNAL_SERVER_ERROR,
225
+ {
226
+ reason: "not initialized"
227
+ }
228
+ );
221
229
  }
222
230
  return __privateGet(this, _client);
223
231
  }
224
232
  /** The resolved connection mode. */
225
233
  getMode() {
226
234
  if (!this.mode) {
227
- throw new QueueException(QUEUE_ERROR_CODES.CONNECTION_INVALID, 500, {
228
- reason: "not initialized"
229
- });
235
+ throw new QueueException(
236
+ QUEUE_ERROR_CODES.CONNECTION_INVALID,
237
+ HttpStatus.INTERNAL_SERVER_ERROR,
238
+ {
239
+ reason: "not initialized"
240
+ }
241
+ );
230
242
  }
231
243
  return this.mode;
232
244
  }
@@ -248,7 +260,11 @@ var ConnectionResolver = class {
248
260
  this.mode = "mode-a-byo";
249
261
  __privateSet(this, _client, client);
250
262
  if (!isClientUsable(client)) {
251
- throw new QueueException(QUEUE_ERROR_CODES.CONNECTION_INVALID, 500, { status: client.status });
263
+ throw new QueueException(
264
+ QUEUE_ERROR_CODES.CONNECTION_INVALID,
265
+ HttpStatus.INTERNAL_SERVER_ERROR,
266
+ { status: client.status }
267
+ );
252
268
  }
253
269
  const probe = duplicateConnection(client);
254
270
  try {
@@ -277,7 +293,13 @@ var ConnectionResolver = class {
277
293
  };
278
294
  const timer = setTimeout(() => {
279
295
  cleanup();
280
- reject(new QueueException(QUEUE_ERROR_CODES.CONNECTION_TIMEOUT, 500, { timeoutMs }));
296
+ reject(
297
+ new QueueException(
298
+ QUEUE_ERROR_CODES.CONNECTION_TIMEOUT,
299
+ HttpStatus.INTERNAL_SERVER_ERROR,
300
+ { timeoutMs }
301
+ )
302
+ );
281
303
  }, timeoutMs);
282
304
  client.once("ready", onReady);
283
305
  client.once("error", onError);
@@ -290,10 +312,10 @@ ConnectionResolver = __decorateClass([
290
312
  Injectable(),
291
313
  __decorateParam(0, Inject(BYMAX_QUEUE_OPTIONS))
292
314
  ], ConnectionResolver);
293
-
294
- // src/server/utils/validate-job-scheduler-options.ts
295
315
  function invalid2(reason) {
296
- throw new QueueException(QUEUE_ERROR_CODES.INVALID_REPEAT_OPTIONS, 400, { reason });
316
+ throw new QueueException(QUEUE_ERROR_CODES.INVALID_REPEAT_OPTIONS, HttpStatus.BAD_REQUEST, {
317
+ reason
318
+ });
297
319
  }
298
320
  function toEpochMs(date) {
299
321
  return typeof date === "number" ? date : Date.parse(date);
@@ -433,11 +455,15 @@ var QueueService = class {
433
455
  */
434
456
  async enqueueBulk(queueName, jobs) {
435
457
  if (jobs.length > MAX_BULK_SIZE) {
436
- throw new QueueException(QUEUE_ERROR_CODES.BULK_ENQUEUE_FAILED, 500, {
437
- reason: "batch size exceeds limit",
438
- limit: MAX_BULK_SIZE,
439
- received: jobs.length
440
- });
458
+ throw new QueueException(
459
+ QUEUE_ERROR_CODES.BULK_ENQUEUE_FAILED,
460
+ HttpStatus.INTERNAL_SERVER_ERROR,
461
+ {
462
+ reason: "batch size exceeds limit",
463
+ limit: MAX_BULK_SIZE,
464
+ received: jobs.length
465
+ }
466
+ );
441
467
  }
442
468
  try {
443
469
  const created = await this.queueFor(queueName).addBulk(
@@ -449,9 +475,13 @@ var QueueService = class {
449
475
  );
450
476
  return created;
451
477
  } catch (err) {
452
- throw new QueueException(QUEUE_ERROR_CODES.BULK_ENQUEUE_FAILED, 500, {
453
- cause: err.message
454
- });
478
+ throw new QueueException(
479
+ QUEUE_ERROR_CODES.BULK_ENQUEUE_FAILED,
480
+ HttpStatus.INTERNAL_SERVER_ERROR,
481
+ {
482
+ cause: err.message
483
+ }
484
+ );
455
485
  }
456
486
  }
457
487
  /**
@@ -527,7 +557,7 @@ var QueueService = class {
527
557
  return job2;
528
558
  } catch (err) {
529
559
  if (isOperationalError(err)) throw err;
530
- throw new QueueException(QUEUE_ERROR_CODES.INVALID_REPEAT_OPTIONS, 400, {
560
+ throw new QueueException(QUEUE_ERROR_CODES.INVALID_REPEAT_OPTIONS, HttpStatus.BAD_REQUEST, {
531
561
  reason: "pattern must be a valid cron expression (5- or 6-field)"
532
562
  });
533
563
  }
@@ -680,7 +710,7 @@ var FlowService = class {
680
710
  */
681
711
  requireProducer() {
682
712
  if (!this.producer) {
683
- throw new QueueException(QUEUE_ERROR_CODES.FLOW_DISABLED, 503);
713
+ throw new QueueException(QUEUE_ERROR_CODES.FLOW_DISABLED, HttpStatus.SERVICE_UNAVAILABLE);
684
714
  }
685
715
  return this.producer;
686
716
  }
@@ -748,7 +778,7 @@ var MetricsService = class {
748
778
  */
749
779
  ensureEnabled() {
750
780
  if (!this.enabled) {
751
- throw new QueueException(QUEUE_ERROR_CODES.METRICS_DISABLED, 503);
781
+ throw new QueueException(QUEUE_ERROR_CODES.METRICS_DISABLED, HttpStatus.SERVICE_UNAVAILABLE);
752
782
  }
753
783
  }
754
784
  };
@@ -789,10 +819,14 @@ var WorkerRegistry = class {
789
819
  worker = new Worker(queueName, handler, bullOpts);
790
820
  } catch (cause) {
791
821
  conn.disconnect();
792
- throw new QueueException(QUEUE_ERROR_CODES.WORKER_REGISTRATION_FAILED, 500, {
793
- queueName,
794
- cause: cause instanceof Error ? cause.message : String(cause)
795
- });
822
+ throw new QueueException(
823
+ QUEUE_ERROR_CODES.WORKER_REGISTRATION_FAILED,
824
+ HttpStatus.INTERNAL_SERVER_ERROR,
825
+ {
826
+ queueName,
827
+ cause: cause instanceof Error ? cause.message : String(cause)
828
+ }
829
+ );
796
830
  }
797
831
  attachDefaultErrorListener(worker, this.logger, "Worker", queueName);
798
832
  __privateGet(this, _entries).set(queueName, { worker, connection: conn });
@@ -828,10 +862,14 @@ var WorkerRegistry = class {
828
862
  worker = new Worker(queueName, processorFile, bullOpts);
829
863
  } catch (cause) {
830
864
  conn.disconnect();
831
- throw new QueueException(QUEUE_ERROR_CODES.WORKER_REGISTRATION_FAILED, 500, {
832
- queueName,
833
- cause: cause instanceof Error ? cause.message : String(cause)
834
- });
865
+ throw new QueueException(
866
+ QUEUE_ERROR_CODES.WORKER_REGISTRATION_FAILED,
867
+ HttpStatus.INTERNAL_SERVER_ERROR,
868
+ {
869
+ queueName,
870
+ cause: cause instanceof Error ? cause.message : String(cause)
871
+ }
872
+ );
835
873
  }
836
874
  attachDefaultErrorListener(worker, this.logger, "Worker", queueName);
837
875
  __privateGet(this, _entries).set(queueName, { worker, connection: conn });
@@ -904,7 +942,11 @@ var WorkerRegistry = class {
904
942
  */
905
943
  guardDuplicate(queueName) {
906
944
  if (__privateGet(this, _entries).has(queueName)) {
907
- throw new QueueException(QUEUE_ERROR_CODES.DUPLICATE_PROCESSOR, 500, { queueName });
945
+ throw new QueueException(
946
+ QUEUE_ERROR_CODES.DUPLICATE_PROCESSOR,
947
+ HttpStatus.INTERNAL_SERVER_ERROR,
948
+ { queueName }
949
+ );
908
950
  }
909
951
  }
910
952
  /**
@@ -918,30 +960,46 @@ var WorkerRegistry = class {
918
960
  validateOptions(opts) {
919
961
  if (opts?.concurrency !== void 0) {
920
962
  if (opts.concurrency < 1) {
921
- throw new QueueException(QUEUE_ERROR_CODES.WORKER_REGISTRATION_FAILED, 500, {
922
- reason: "concurrency must be >= 1",
923
- actual: opts.concurrency
924
- });
963
+ throw new QueueException(
964
+ QUEUE_ERROR_CODES.WORKER_REGISTRATION_FAILED,
965
+ HttpStatus.INTERNAL_SERVER_ERROR,
966
+ {
967
+ reason: "concurrency must be >= 1",
968
+ actual: opts.concurrency
969
+ }
970
+ );
925
971
  }
926
972
  if (opts.concurrency > MAX_WORKER_CONCURRENCY) {
927
- throw new QueueException(QUEUE_ERROR_CODES.WORKER_REGISTRATION_FAILED, 500, {
928
- reason: `concurrency must be <= ${MAX_WORKER_CONCURRENCY.toString()}`,
929
- actual: opts.concurrency
930
- });
973
+ throw new QueueException(
974
+ QUEUE_ERROR_CODES.WORKER_REGISTRATION_FAILED,
975
+ HttpStatus.INTERNAL_SERVER_ERROR,
976
+ {
977
+ reason: `concurrency must be <= ${MAX_WORKER_CONCURRENCY.toString()}`,
978
+ actual: opts.concurrency
979
+ }
980
+ );
931
981
  }
932
982
  }
933
983
  if (opts?.limiter !== void 0) {
934
984
  if (opts.limiter.max < 1) {
935
- throw new QueueException(QUEUE_ERROR_CODES.WORKER_REGISTRATION_FAILED, 500, {
936
- reason: "limiter.max must be >= 1",
937
- actual: opts.limiter.max
938
- });
985
+ throw new QueueException(
986
+ QUEUE_ERROR_CODES.WORKER_REGISTRATION_FAILED,
987
+ HttpStatus.INTERNAL_SERVER_ERROR,
988
+ {
989
+ reason: "limiter.max must be >= 1",
990
+ actual: opts.limiter.max
991
+ }
992
+ );
939
993
  }
940
994
  if (opts.limiter.duration < 1) {
941
- throw new QueueException(QUEUE_ERROR_CODES.WORKER_REGISTRATION_FAILED, 500, {
942
- reason: "limiter.duration must be >= 1",
943
- actual: opts.limiter.duration
944
- });
995
+ throw new QueueException(
996
+ QUEUE_ERROR_CODES.WORKER_REGISTRATION_FAILED,
997
+ HttpStatus.INTERNAL_SERVER_ERROR,
998
+ {
999
+ reason: "limiter.duration must be >= 1",
1000
+ actual: opts.limiter.duration
1001
+ }
1002
+ );
945
1003
  }
946
1004
  }
947
1005
  }
@@ -957,17 +1015,25 @@ var WorkerRegistry = class {
957
1015
  validateProcessorFile(processorFile) {
958
1016
  if (processorFile instanceof URL) {
959
1017
  if (processorFile.protocol !== "file:") {
960
- throw new QueueException(QUEUE_ERROR_CODES.WORKER_REGISTRATION_FAILED, 500, {
961
- reason: "processorFile URL must use the file: protocol"
962
- });
1018
+ throw new QueueException(
1019
+ QUEUE_ERROR_CODES.WORKER_REGISTRATION_FAILED,
1020
+ HttpStatus.INTERNAL_SERVER_ERROR,
1021
+ {
1022
+ reason: "processorFile URL must use the file: protocol"
1023
+ }
1024
+ );
963
1025
  }
964
1026
  this.assertProcessorExtension(processorFile.pathname);
965
1027
  return;
966
1028
  }
967
1029
  if (!isAbsolute(processorFile)) {
968
- throw new QueueException(QUEUE_ERROR_CODES.WORKER_REGISTRATION_FAILED, 500, {
969
- reason: "processorFile must be an absolute path"
970
- });
1030
+ throw new QueueException(
1031
+ QUEUE_ERROR_CODES.WORKER_REGISTRATION_FAILED,
1032
+ HttpStatus.INTERNAL_SERVER_ERROR,
1033
+ {
1034
+ reason: "processorFile must be an absolute path"
1035
+ }
1036
+ );
971
1037
  }
972
1038
  this.assertProcessorExtension(processorFile);
973
1039
  }
@@ -981,9 +1047,13 @@ var WorkerRegistry = class {
981
1047
  assertProcessorExtension(filePath) {
982
1048
  const allowedExtensions = /* @__PURE__ */ new Set([".js", ".cjs", ".mjs"]);
983
1049
  if (!allowedExtensions.has(extname(filePath))) {
984
- throw new QueueException(QUEUE_ERROR_CODES.WORKER_REGISTRATION_FAILED, 500, {
985
- reason: "processorFile must have a .js, .cjs, or .mjs extension"
986
- });
1050
+ throw new QueueException(
1051
+ QUEUE_ERROR_CODES.WORKER_REGISTRATION_FAILED,
1052
+ HttpStatus.INTERNAL_SERVER_ERROR,
1053
+ {
1054
+ reason: "processorFile must have a .js, .cjs, or .mjs extension"
1055
+ }
1056
+ );
987
1057
  }
988
1058
  }
989
1059
  /**
@@ -1171,7 +1241,11 @@ var ProcessorDiscoveryService = class {
1171
1241
  const { queueName, workerOptions, _warnedNoConcurrency } = processorMeta;
1172
1242
  const ctor = instance.constructor;
1173
1243
  if (this.registeredQueues.has(queueName)) {
1174
- throw new QueueException(QUEUE_ERROR_CODES.DUPLICATE_PROCESSOR, 500, { queueName });
1244
+ throw new QueueException(
1245
+ QUEUE_ERROR_CODES.DUPLICATE_PROCESSOR,
1246
+ HttpStatus.INTERNAL_SERVER_ERROR,
1247
+ { queueName }
1248
+ );
1175
1249
  }
1176
1250
  this.registeredQueues.add(queueName);
1177
1251
  if (_warnedNoConcurrency) {
@@ -1320,7 +1394,7 @@ var QueueLifecycle = class {
1320
1394
  }
1321
1395
  /**
1322
1396
  * Drain every cached queue when `drainOnShutdown` is enabled (dev/test only).
1323
- * Removes waiting and delayed jobs; off by default to stay production-safe.
1397
+ * Removes waiting jobs and leaves delayed and active ones; off by default to stay production-safe.
1324
1398
  */
1325
1399
  async drainQueuesIfRequested() {
1326
1400
  if (!this.resolved.shutdown.drainOnShutdown) return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bymax-one/nest-queue",
3
- "version": "1.2.3",
3
+ "version": "1.2.5",
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",
@@ -103,7 +103,7 @@
103
103
  "@stryker-mutator/core": "^10.0.0",
104
104
  "@stryker-mutator/jest-runner": "^10.0.0",
105
105
  "@types/jest": "^30.0.0",
106
- "@types/node": "^26.2.0",
106
+ "@types/node": "^24.19.2",
107
107
  "bullmq": "^6.0.0",
108
108
  "eslint": "^10.8.1",
109
109
  "eslint-config-prettier": "^10.1.8",