@bymax-one/nest-cache 1.0.5 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,7 +4,47 @@ All notable changes to this project are documented in this file. The format is
4
4
  based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this
5
5
  project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
- ## [Unreleased]
7
+ ## [1.1.0] - 2026-08-11
8
+
9
+ ### Changed
10
+
11
+ - **BREAKING: peer dependency `ioredis` migrated `^5` → `^6`.** A consumer must move to
12
+ ioredis 6, which aligns this package with `@bymax-one/nest-queue` so a single ioredis copy
13
+ resolves across a workspace that uses both. ioredis 6 negotiates **RESP3** on the wire by
14
+ default — the protocol changes at runtime — but its `'legacy'` reply mapping preserves every
15
+ reply shape this cache relies on (GET/SET/TTL, EVALSHA, Pub/Sub message events, cluster and
16
+ sentinel commands), so observable behaviour is unchanged; the only source change is the
17
+ compile-time typing narrowing below. ioredis 6 requires Node.js ≥ 20, already covered by
18
+ this package's `engines` (Node ≥ 24).
19
+
20
+ ### Internal
21
+
22
+ - `ConnectionManager` narrows the options handed to the `Redis` and `Cluster` constructors
23
+ to a local shape (`OwnedRedisOptions` / `OwnedClusterOptions`). ioredis 6's constructor
24
+ overloads intersect `replyMapping` with a non-`undefined` variant to infer the
25
+ reply-mapping generic, which makes a plain `RedisOptions`/`ClusterOptions` value
26
+ unassignable under `exactOptionalPropertyTypes`. The narrowing drops only that `undefined`;
27
+ the runtime object is untouched.
28
+ - Mutation gate tightened: Stryker `break`/`high`/`low` raised to **100** (the run is at
29
+ 100%, 0 survivors).
30
+
31
+ ## [1.0.6] - 2026-08-06
32
+
33
+ ### Fixed
34
+
35
+ - `ConnectionManager.onModuleInit()` assigned a freshly created client over the field
36
+ unconditionally. When something had already opened the main client through
37
+ `getClient()`, that first socket was left connected with no reference left to close it —
38
+ `onModuleDestroy()` quits only the current client, so the abandoned one stayed in Redis's
39
+ `CLIENT LIST` until its own timeout. Init now adopts an existing client instead of
40
+ replacing it, and the assignment lives in one private accessor so no other path can
41
+ strand a live connection.
42
+
43
+ Reaching it takes touching the cache before the cache module's own init hook runs, which
44
+ is possible in two ordinary shapes: NestJS orders `onModuleInit` by module depth, so a
45
+ consumer module deeper in the graph runs first, and `app.get()` works between
46
+ `NestFactory.create()` and `app.init()`. The ordinary boot path opened exactly one client
47
+ before this change and still does.
8
48
 
9
49
  ## [1.0.5] - 2026-08-06
10
50
 
@@ -150,6 +190,8 @@ type or export moved.
150
190
  - Published with npm OIDC provenance — no long-lived tokens
151
191
  - Zero direct runtime dependencies (`dependencies: {}`) — `ioredis` and NestJS via peer deps
152
192
 
193
+ [1.1.0]: https://github.com/bymaxone/nest-cache/compare/v1.0.6...v1.1.0
194
+ [1.0.6]: https://github.com/bymaxone/nest-cache/compare/v1.0.5...v1.0.6
153
195
  [1.0.5]: https://github.com/bymaxone/nest-cache/compare/v1.0.4...v1.0.5
154
196
  [1.0.4]: https://github.com/bymaxone/nest-cache/compare/v1.0.3...v1.0.4
155
197
  [1.0.3]: https://github.com/bymaxone/nest-cache/releases/tag/v1.0.3
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  <p align="center">
8
8
  <strong>Typed Redis cache for NestJS</strong><br />
9
- <sub>ioredis 5 · Namespacing · Pub/Sub · Lua Scripts · Multi-Tenant · Zero Runtime Dependencies</sub>
9
+ <sub>ioredis 6 · Namespacing · Pub/Sub · Lua Scripts · Multi-Tenant · Zero Runtime Dependencies</sub>
10
10
  </p>
11
11
 
12
12
  <p align="center">
@@ -14,7 +14,7 @@
14
14
  <a href="https://www.npmjs.com/package/@bymax-one/nest-cache"><img src="https://img.shields.io/npm/dm/@bymax-one/nest-cache?style=flat-square&colorA=000000&colorB=000000" alt="npm downloads" /></a>
15
15
  <a href="https://github.com/bymaxone/nest-cache/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/bymaxone/nest-cache/ci.yml?branch=main&style=flat-square&colorA=000000&label=CI" alt="CI status" /></a>
16
16
  <a href="https://github.com/bymaxone/nest-cache/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-cache/blob/main/docs/mutation_testing_results.md"><img src="https://img.shields.io/badge/mutation-99.78%25-brightgreen?style=flat-square&colorA=000000" alt="mutation score" /></a>
17
+ <a href="https://github.com/bymaxone/nest-cache/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-cache"><img src="https://api.scorecard.dev/projects/github.com/bymaxone/nest-cache/badge?style=flat-square" alt="OpenSSF Scorecard" /></a>
19
19
  <a href="https://github.com/bymaxone/nest-cache/blob/main/LICENSE"><img src="https://img.shields.io/github/license/bymaxone/nest-cache?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>
@@ -101,7 +101,7 @@ One package, two entry points — import only what your app needs:
101
101
 
102
102
  | Subpath | Import | Purpose | Dependencies |
103
103
  | ---------- | ------------------------------ | -------------------------------------------------------------------------------------------------------- | :------------------------------------: |
104
- | **Server** | `@bymax-one/nest-cache` | `BymaxCacheModule`, `CacheService`, `PubSubService`, `ScriptManagerService`, DI tokens, `CacheException` | NestJS 11, ioredis 5, reflect-metadata |
104
+ | **Server** | `@bymax-one/nest-cache` | `BymaxCacheModule`, `CacheService`, `PubSubService`, `ScriptManagerService`, DI tokens, `CacheException` | NestJS 11, ioredis 6, reflect-metadata |
105
105
  | **Shared** | `@bymax-one/nest-cache/shared` | Types + constants — `CACHE_ERROR_CODES`, `CacheEventName`, config types | None |
106
106
 
107
107
  ```
@@ -511,7 +511,7 @@ When integrating `@bymax-one/nest-cache` in production, verify each of the follo
511
511
  ## 🧱 Tech Stack
512
512
 
513
513
  [![NestJS](https://img.shields.io/badge/NestJS-11-E0234E?style=flat-square&logo=nestjs&logoColor=white)](https://nestjs.com)
514
- [![ioredis](https://img.shields.io/badge/ioredis-5-DC382D?style=flat-square&logo=redis&logoColor=white)](https://github.com/redis/ioredis)
514
+ [![ioredis](https://img.shields.io/badge/ioredis-6-DC382D?style=flat-square&logo=redis&logoColor=white)](https://github.com/redis/ioredis)
515
515
  [![TypeScript](https://img.shields.io/badge/TypeScript-5.9-3178C6?style=flat-square&logo=typescript&logoColor=white)](https://www.typescriptlang.org)
516
516
  [![Node.js](https://img.shields.io/badge/Node.js-24-339933?style=flat-square&logo=node.js&logoColor=white)](https://nodejs.org)
517
517
  [![Jest](https://img.shields.io/badge/Jest-30-C21325?style=flat-square&logo=jest)](https://jestjs.io)
@@ -526,8 +526,8 @@ When integrating `@bymax-one/nest-cache` in production, verify each of the follo
526
526
  A cache is consulted on the hot path of every request that touches it, so the suite is held to a bar beyond "it runs" — every behavior is pinned so that a regression **fails a test**.
527
527
 
528
528
  - ✅ **100% line coverage** — statements, branches, functions, and lines, enforced by `jest.coverage.config.ts` as a pre-publish gate, not a target
529
- - ✅ **99.78% mutation score** — verified with [Stryker](https://stryker-mutator.io/) at `break: 95` and `ignoreStatic: false`; the single survivor is `configurable: false` on the withheld connection accessor, equivalent here because the resolved options are frozen on the way out, and [documented as such](./docs/mutation_testing_results.md)
530
- - ✅ **Zero suppressions** — the production source carries no coverage or mutation directives; the one would-be equivalent mutant was refactored away rather than silenced, so the score is an accounting rather than a number
529
+ - ✅ **100% mutation score** — verified with [Stryker](https://stryker-mutator.io/) at `break: 100` and `ignoreStatic: false`; 441 killed, 6 timed out, **0 survived**, and [documented in full](./docs/mutation_testing_results.md)
530
+ - ✅ **One documented equivalent** — the production source carries a single `// Stryker disable` directive, on `configurable: false` of the withheld connection accessor, genuinely equivalent because the resolved options are frozen on the way out (freezing already makes every property non-configurable); `check:mutants` proves it parses and carries its reason, so the score is an accounting rather than a number
531
531
  - ✅ **No real Redis in unit tests** — `ioredis-mock` throughout; e2e tests exercise the wired module through `@nestjs/testing` and Testcontainers against a real Redis for connection lifecycle, Pub/Sub, and Lua scripts
532
532
  - ✅ **Published-package smoke test** — `scripts/dogfood-smoke-test.mjs` validates exports, tarball shape, and a consumer install before tagging
533
533
 
@@ -227,6 +227,7 @@ function applyDefaults(options) {
227
227
  Object.defineProperty(resolved, key, {
228
228
  get: () => value,
229
229
  enumerable: false,
230
+ // Stryker disable next-line BooleanLiteral: equivalent HERE — `resolved` is `Object.freeze`d on the way out and freezing makes every property non-configurable anyway, so flipping this flag changes nothing observable. It stays because it states the guarantee where the accessor is defined, and nest-storage withholds its credentials the same way WITHOUT freezing, where this flag is the only thing enforcing it.
230
231
  configurable: false
231
232
  });
232
233
  }
@@ -259,12 +260,19 @@ exports.ConnectionManager = class ConnectionManager {
259
260
  this.defaultReconnectOnError = (err) => err.message.includes("READONLY");
260
261
  __privateSet(this, _redisOptionsResolved, this.buildRedisOptions(options));
261
262
  }
262
- /** Opens the main client and waits for readiness unless `lazyConnect`. */
263
+ /**
264
+ * Opens the main client and waits for readiness unless `lazyConnect`.
265
+ *
266
+ * Adopts a client {@link getClient} already opened rather than replacing it:
267
+ * NestJS orders `onModuleInit` by module depth, and `app.get()` works before
268
+ * `app.init()`, so a consumer can reach the cache first. Overwriting here
269
+ * would strand that socket with no reference left to close it — teardown
270
+ * quits only the current client.
271
+ */
263
272
  async onModuleInit() {
264
- __privateSet(this, _client, this.createClient());
265
- this.registerListeners(__privateGet(this, _client), "main");
273
+ const client = this.ensureClient();
266
274
  if (!this.options.connection?.lazyConnect) {
267
- await this.waitUntilReady(__privateGet(this, _client));
275
+ await this.waitUntilReady(client);
268
276
  }
269
277
  }
270
278
  /**
@@ -274,11 +282,7 @@ exports.ConnectionManager = class ConnectionManager {
274
282
  * @returns The shared main client.
275
283
  */
276
284
  getClient() {
277
- if (!__privateGet(this, _client)) {
278
- __privateSet(this, _client, this.createClient());
279
- this.registerListeners(__privateGet(this, _client), "main");
280
- }
281
- return __privateGet(this, _client);
285
+ return this.ensureClient();
282
286
  }
283
287
  /**
284
288
  * Creates a brand-new dedicated connection for subscriber mode (a subscriber
@@ -336,6 +340,20 @@ exports.ConnectionManager = class ConnectionManager {
336
340
  }
337
341
  }
338
342
  // ─── Private ──────────────────────────────────────────────────────────────
343
+ /**
344
+ * Returns the main client, opening and wiring it on the first call. The only
345
+ * place a live client is assigned, so no caller can replace one; teardown
346
+ * only clears the field back to `null`.
347
+ *
348
+ * @returns The shared main client.
349
+ */
350
+ ensureClient() {
351
+ if (!__privateGet(this, _client)) {
352
+ __privateSet(this, _client, this.createClient());
353
+ this.registerListeners(__privateGet(this, _client), "main");
354
+ }
355
+ return __privateGet(this, _client);
356
+ }
339
357
  /**
340
358
  * Instantiates the client matching the configured mode.
341
359
  *
@@ -358,7 +376,7 @@ exports.ConnectionManager = class ConnectionManager {
358
376
  sentinelPassword: sentinel.sentinelPassword
359
377
  },
360
378
  ...sentinel.password !== void 0 && { password: sentinel.password },
361
- // Normalize 'replica' → 'slave' — ioredis 5 only accepts 'slave' at the
379
+ // Normalize 'replica' → 'slave' — ioredis only accepts 'slave' at the
362
380
  // wire level; our public interface accepts 'replica' per Redis 7 naming.
363
381
  ...sentinel.role !== void 0 && {
364
382
  role: sentinel.role === "replica" ? "slave" : sentinel.role
@@ -369,7 +369,15 @@ declare class ConnectionManager implements OnModuleInit, OnModuleDestroy {
369
369
  * module can provide `null` when the consumer omits `events`.
370
370
  */
371
371
  constructor(options: ResolvedOptions, events?: ICacheEvents | undefined);
372
- /** Opens the main client and waits for readiness unless `lazyConnect`. */
372
+ /**
373
+ * Opens the main client and waits for readiness unless `lazyConnect`.
374
+ *
375
+ * Adopts a client {@link getClient} already opened rather than replacing it:
376
+ * NestJS orders `onModuleInit` by module depth, and `app.get()` works before
377
+ * `app.init()`, so a consumer can reach the cache first. Overwriting here
378
+ * would strand that socket with no reference left to close it — teardown
379
+ * quits only the current client.
380
+ */
373
381
  onModuleInit(): Promise<void>;
374
382
  /**
375
383
  * Returns the singleton main client, creating it on first access if the
@@ -398,6 +406,14 @@ declare class ConnectionManager implements OnModuleInit, OnModuleDestroy {
398
406
  createSubscriberClient(): AnyRedis;
399
407
  /** Quits the main client gracefully, forcing `disconnect()` on timeout. */
400
408
  onModuleDestroy(): Promise<void>;
409
+ /**
410
+ * Returns the main client, opening and wiring it on the first call. The only
411
+ * place a live client is assigned, so no caller can replace one; teardown
412
+ * only clears the field back to `null`.
413
+ *
414
+ * @returns The shared main client.
415
+ */
416
+ private ensureClient;
401
417
  /**
402
418
  * Instantiates the client matching the configured mode.
403
419
  *
@@ -369,7 +369,15 @@ declare class ConnectionManager implements OnModuleInit, OnModuleDestroy {
369
369
  * module can provide `null` when the consumer omits `events`.
370
370
  */
371
371
  constructor(options: ResolvedOptions, events?: ICacheEvents | undefined);
372
- /** Opens the main client and waits for readiness unless `lazyConnect`. */
372
+ /**
373
+ * Opens the main client and waits for readiness unless `lazyConnect`.
374
+ *
375
+ * Adopts a client {@link getClient} already opened rather than replacing it:
376
+ * NestJS orders `onModuleInit` by module depth, and `app.get()` works before
377
+ * `app.init()`, so a consumer can reach the cache first. Overwriting here
378
+ * would strand that socket with no reference left to close it — teardown
379
+ * quits only the current client.
380
+ */
373
381
  onModuleInit(): Promise<void>;
374
382
  /**
375
383
  * Returns the singleton main client, creating it on first access if the
@@ -398,6 +406,14 @@ declare class ConnectionManager implements OnModuleInit, OnModuleDestroy {
398
406
  createSubscriberClient(): AnyRedis;
399
407
  /** Quits the main client gracefully, forcing `disconnect()` on timeout. */
400
408
  onModuleDestroy(): Promise<void>;
409
+ /**
410
+ * Returns the main client, opening and wiring it on the first call. The only
411
+ * place a live client is assigned, so no caller can replace one; teardown
412
+ * only clears the field back to `null`.
413
+ *
414
+ * @returns The shared main client.
415
+ */
416
+ private ensureClient;
401
417
  /**
402
418
  * Instantiates the client matching the configured mode.
403
419
  *
@@ -225,6 +225,7 @@ function applyDefaults(options) {
225
225
  Object.defineProperty(resolved, key, {
226
226
  get: () => value,
227
227
  enumerable: false,
228
+ // Stryker disable next-line BooleanLiteral: equivalent HERE — `resolved` is `Object.freeze`d on the way out and freezing makes every property non-configurable anyway, so flipping this flag changes nothing observable. It stays because it states the guarantee where the accessor is defined, and nest-storage withholds its credentials the same way WITHOUT freezing, where this flag is the only thing enforcing it.
228
229
  configurable: false
229
230
  });
230
231
  }
@@ -257,12 +258,19 @@ var ConnectionManager = class {
257
258
  this.defaultReconnectOnError = (err) => err.message.includes("READONLY");
258
259
  __privateSet(this, _redisOptionsResolved, this.buildRedisOptions(options));
259
260
  }
260
- /** Opens the main client and waits for readiness unless `lazyConnect`. */
261
+ /**
262
+ * Opens the main client and waits for readiness unless `lazyConnect`.
263
+ *
264
+ * Adopts a client {@link getClient} already opened rather than replacing it:
265
+ * NestJS orders `onModuleInit` by module depth, and `app.get()` works before
266
+ * `app.init()`, so a consumer can reach the cache first. Overwriting here
267
+ * would strand that socket with no reference left to close it — teardown
268
+ * quits only the current client.
269
+ */
261
270
  async onModuleInit() {
262
- __privateSet(this, _client, this.createClient());
263
- this.registerListeners(__privateGet(this, _client), "main");
271
+ const client = this.ensureClient();
264
272
  if (!this.options.connection?.lazyConnect) {
265
- await this.waitUntilReady(__privateGet(this, _client));
273
+ await this.waitUntilReady(client);
266
274
  }
267
275
  }
268
276
  /**
@@ -272,11 +280,7 @@ var ConnectionManager = class {
272
280
  * @returns The shared main client.
273
281
  */
274
282
  getClient() {
275
- if (!__privateGet(this, _client)) {
276
- __privateSet(this, _client, this.createClient());
277
- this.registerListeners(__privateGet(this, _client), "main");
278
- }
279
- return __privateGet(this, _client);
283
+ return this.ensureClient();
280
284
  }
281
285
  /**
282
286
  * Creates a brand-new dedicated connection for subscriber mode (a subscriber
@@ -334,6 +338,20 @@ var ConnectionManager = class {
334
338
  }
335
339
  }
336
340
  // ─── Private ──────────────────────────────────────────────────────────────
341
+ /**
342
+ * Returns the main client, opening and wiring it on the first call. The only
343
+ * place a live client is assigned, so no caller can replace one; teardown
344
+ * only clears the field back to `null`.
345
+ *
346
+ * @returns The shared main client.
347
+ */
348
+ ensureClient() {
349
+ if (!__privateGet(this, _client)) {
350
+ __privateSet(this, _client, this.createClient());
351
+ this.registerListeners(__privateGet(this, _client), "main");
352
+ }
353
+ return __privateGet(this, _client);
354
+ }
337
355
  /**
338
356
  * Instantiates the client matching the configured mode.
339
357
  *
@@ -356,7 +374,7 @@ var ConnectionManager = class {
356
374
  sentinelPassword: sentinel.sentinelPassword
357
375
  },
358
376
  ...sentinel.password !== void 0 && { password: sentinel.password },
359
- // Normalize 'replica' → 'slave' — ioredis 5 only accepts 'slave' at the
377
+ // Normalize 'replica' → 'slave' — ioredis only accepts 'slave' at the
360
378
  // wire level; our public interface accepts 'replica' per Redis 7 naming.
361
379
  ...sentinel.role !== void 0 && {
362
380
  role: sentinel.role === "replica" ? "slave" : sentinel.role
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@bymax-one/nest-cache",
3
- "version": "1.0.5",
4
- "description": "Typed Redis cache for NestJS based on ioredis 5, with namespace strategy, Pub/Sub and Lua script management.",
3
+ "version": "1.1.0",
4
+ "description": "Typed Redis cache for NestJS based on ioredis 6, with namespace strategy, Pub/Sub and Lua script management.",
5
5
  "author": "Bymax One <support@bymax.one>",
6
6
  "license": "MIT",
7
7
  "homepage": "https://github.com/bymaxone/nest-cache#readme",
@@ -56,15 +56,16 @@
56
56
  "scripts": {
57
57
  "build": "pnpm clean && tsup",
58
58
  "check:exports": "attw --pack .",
59
+ "check:mutants": "node scripts/check-mutation-directives.mjs",
59
60
  "check:published": "node scripts/check-published-surface.mjs",
60
61
  "clean": "rm -rf dist coverage",
61
62
  "lint": "eslint src scripts",
62
63
  "lint:fix": "eslint src scripts --fix",
63
64
  "mutation": "stryker run",
65
+ "mutation:full": "node -e \"require('node:fs').rmSync('reports/stryker-incremental.json',{force:true,recursive:true})\" && stryker run",
64
66
  "mutation:dry-run": "stryker run --dryRunOnly",
65
- "mutation:incremental": "stryker run --incremental",
66
67
  "prepare": "husky",
67
- "prepublishOnly": "pnpm clean && pnpm typecheck && pnpm test:types && pnpm lint && pnpm test:cov:all && pnpm build && pnpm check:published",
68
+ "prepublishOnly": "pnpm clean && pnpm typecheck && pnpm test:types && pnpm lint && pnpm check:mutants && pnpm test:cov:all && pnpm build && pnpm check:published",
68
69
  "release": "npm publish --provenance --access public",
69
70
  "size": "node scripts/check-size.mjs",
70
71
  "test": "jest",
@@ -89,7 +90,7 @@
89
90
  "peerDependencies": {
90
91
  "@nestjs/common": "^11.0.16",
91
92
  "@nestjs/core": "^11.1.18",
92
- "ioredis": "^5.0.0",
93
+ "ioredis": "^6.0.0",
93
94
  "reflect-metadata": "^0.2.0"
94
95
  },
95
96
  "devDependencies": {
@@ -115,7 +116,7 @@
115
116
  "eslint-plugin-security": "^4.0.0",
116
117
  "globals": "^17.6.0",
117
118
  "husky": "^9.1.7",
118
- "ioredis": "^5.10.1",
119
+ "ioredis": "^6.0.0",
119
120
  "ioredis-mock": "^8.13.1",
120
121
  "jest": "^30.4.2",
121
122
  "lint-staged": "^17.2.0",