@bymax-one/nest-cache 1.0.4 → 1.0.6

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
@@ -6,6 +6,46 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [1.0.6] - 2026-08-06
10
+
11
+ ### Fixed
12
+
13
+ - `ConnectionManager.onModuleInit()` assigned a freshly created client over the field
14
+ unconditionally. When something had already opened the main client through
15
+ `getClient()`, that first socket was left connected with no reference left to close it —
16
+ `onModuleDestroy()` quits only the current client, so the abandoned one stayed in Redis's
17
+ `CLIENT LIST` until its own timeout. Init now adopts an existing client instead of
18
+ replacing it, and the assignment lives in one private accessor so no other path can
19
+ strand a live connection.
20
+
21
+ Reaching it takes touching the cache before the cache module's own init hook runs, which
22
+ is possible in two ordinary shapes: NestJS orders `onModuleInit` by module depth, so a
23
+ consumer module deeper in the graph runs first, and `app.get()` works between
24
+ `NestFactory.create()` and `app.init()`. The ordinary boot path opened exactly one client
25
+ before this change and still does.
26
+
27
+ ## [1.0.5] - 2026-08-06
28
+
29
+ **Documentation, tests and E2E only.** `dist/` is byte-identical to `1.0.4`.
30
+
31
+ ### Fixed
32
+
33
+ - The Redis Cluster E2E was flaky at roughly one run in three, failing with `CLUSTERDOWN` or
34
+ "Too many Cluster redirections". The container log line proves `redis-cli --cluster create`
35
+ printed its coverage message, but what the client needs is the state each node serves on the
36
+ port the test dials. The helper now polls `CLUSTER INFO` on every node until all six report
37
+ `cluster_state:ok` and full slot coverage. Six consecutive runs of the previously flaky spec
38
+ pass, where it failed three times in nine before.
39
+
40
+ ### Documentation
41
+
42
+ - The mutation badge said **100%**; the measured score is **99.78%**.
43
+
44
+ ### Tests
45
+
46
+ - `findProvider` gained a spec of its own; it had only ever been exercised through the module
47
+ suites, where either half of its disjunction covered for the other.
48
+
9
49
  ## [1.0.4] - 2026-08-04
10
50
 
11
51
  ### Security
@@ -128,6 +168,8 @@ type or export moved.
128
168
  - Published with npm OIDC provenance — no long-lived tokens
129
169
  - Zero direct runtime dependencies (`dependencies: {}`) — `ioredis` and NestJS via peer deps
130
170
 
171
+ [1.0.6]: https://github.com/bymaxone/nest-cache/compare/v1.0.5...v1.0.6
172
+ [1.0.5]: https://github.com/bymaxone/nest-cache/compare/v1.0.4...v1.0.5
131
173
  [1.0.4]: https://github.com/bymaxone/nest-cache/compare/v1.0.3...v1.0.4
132
174
  [1.0.3]: https://github.com/bymaxone/nest-cache/releases/tag/v1.0.3
133
175
  [1.0.2]: https://github.com/bymaxone/nest-cache/releases/tag/v1.0.2
package/README.md CHANGED
@@ -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-100%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-99.78%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>
@@ -526,7 +526,7 @@ 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
- - ✅ **100% mutation score** — verified with [Stryker](https://stryker-mutator.io/) at `break: 95` and `ignoreStatic: false`: 433 seeded faults detected (427 killed, 6 timed out), **no survivors**
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
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
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
@@ -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
  *
@@ -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
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bymax-one/nest-cache",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "Typed Redis cache for NestJS based on ioredis 5, with namespace strategy, Pub/Sub and Lua script management.",
5
5
  "author": "Bymax One <support@bymax.one>",
6
6
  "license": "MIT",
@@ -56,6 +56,7 @@
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",
@@ -64,7 +65,7 @@
64
65
  "mutation:dry-run": "stryker run --dryRunOnly",
65
66
  "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",
@@ -139,26 +140,12 @@
139
140
  "pubsub",
140
141
  "lua"
141
142
  ],
142
- "packageManager": "pnpm@10.8.1",
143
+ "packageManager": "pnpm@11.20.0",
143
144
  "engines": {
144
145
  "node": ">=24.0.0"
145
146
  },
146
147
  "publishConfig": {
147
148
  "access": "public",
148
149
  "registry": "https://registry.npmjs.org/"
149
- },
150
- "pnpm": {
151
- "overrides": {
152
- "brace-expansion@1": "^1.1.18",
153
- "brace-expansion@2": "^2.1.4",
154
- "brace-expansion@5": "^5.0.9",
155
- "esbuild": "^0.28.1",
156
- "fast-uri": "^3.1.5",
157
- "js-yaml@3": "^3.15.0",
158
- "js-yaml@4": "^4.3.0",
159
- "protobufjs": "^7.6.5",
160
- "qs": "^6.15.2",
161
- "undici": "^7.28.0"
162
- }
163
150
  }
164
151
  }