@bymax-one/nest-cache 1.2.2 → 1.2.4

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,6 +4,116 @@ 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]
8
+
9
+ ## [1.2.4] - 2026-10-31
10
+
11
+ Two calls now refuse input they could not honor, one error code is added, and the documentation catches up with the code.
12
+
13
+ ### Added
14
+
15
+ - **`CACHE_ERROR_CODES.INVALID_TTL` (`cache.invalid_ttl`, HTTP 400)**, thrown for a TTL the library refuses. See Changed.
16
+
17
+ ### Changed
18
+
19
+ - **`CacheService.keys()` is refused in cluster mode.** `KEYS` carries no key, so the cluster client sends
20
+ it to one sampled node and the reply listed only that node's share of the keyspace, with no sign that it
21
+ was partial. It now throws `UNSUPPORTED_IN_CLUSTER` with `details.operation: 'keys'`, like `scan`,
22
+ `getClient` and `flushNamespace`. **Apply to a consumer.** A cluster deployment that called `keys()`
23
+ must iterate with `scan` on each node through its own client, or avoid listing keys; standalone and
24
+ sentinel are unchanged.
25
+ - **`ttlSeconds` is validated, with a new error code, `cache.invalid_ttl` (HTTP 400).** `set`, `setRaw`
26
+ and `setNx` need a safe integer of at least 1, or none for no expiry; `expire` needs a safe integer,
27
+ and zero or a negative one still deletes the key, as in Redis. Anything else throws `CacheException`
28
+ with `INVALID_TTL` and `details.reason` set to `not_a_safe_integer` or `below_minimum`, before a command
29
+ is sent. **Apply to a consumer.** What now throws `INVALID_TTL`: `set`, `setRaw`, `setNx` with `0`, a negative number, a fraction, `NaN`, `Infinity` or an integer above 2^53; `expire` with a fraction, `NaN`, `Infinity` or an integer above 2^53. Redis documents `SET ... EX` as a positive integer and `EXPIRE` as an
30
+ integer, and answers anything else with a raw reply error that carries no `cache.*` code, so the values
31
+ refused here already failed. The one exception is an integer above 2^53 seconds, a TTL of about 285
32
+ million years, which this refuses and Redis may accept. Code that caught the raw error by its message
33
+ should switch to `error.code === 'cache.invalid_ttl'`.
34
+
35
+ ### Deprecated
36
+
37
+ - **Implementation helpers exported from `@bymax-one/nest-cache/admin`** are marked `@deprecated` and will
38
+ leave the public surface in 2.0.0: `validateScopes`, `findScope`, `isKeyInScope`, `readScopePattern`,
39
+ `resolveAdminOptions`, `ResolvedAdminOptions`, `parseInfoFields`, `readRedisStats`, `readKeyTtl`,
40
+ `readKeyType`, `readSizeBytes`, `IRedisReader`, `IRedisReaderSource`, `IRedisPipeline`,
41
+ `PipelineReply` and `ICacheProbe`. They keep working in 1.x. Do not depend on them.
42
+
43
+ ### Documentation
44
+
45
+ - The README error-code table described `cache.shutdown_timeout` as thrown when `quit()` exceeds
46
+ `shutdownTimeoutMs`. It never reaches the caller: the library forces `disconnect()` and emits an
47
+ `error` event with `reason: 'forced_disconnect'`. The row now says so.
48
+ - The README states that `app.enableShutdownHooks()` is required for the graceful shutdown to run, and
49
+ carries a warning that `/admin` has no authentication of its own. It documents the `/admin` options
50
+ and their defaults and the exported types, constants and tokens it left out, lists `keys` among the
51
+ cluster-refused calls, and marks the three codes the library never throws as reserved.
52
+ - The sentinel example passed `process.env.REDIS_PASSWORD` as `password`, which does not compile
53
+ for a consumer that enables `exactOptionalPropertyTypes`. It now falls back to an empty string,
54
+ which ioredis treats like no password.
55
+
56
+ ### Tests
57
+
58
+ - `src/readme.spec.ts` parses the README's option-defaults table and error-code table and asserts them
59
+ against the resolved defaults, the minimum timeouts, `CACHE_ERROR_CODES` and the status each
60
+ `CacheException` carries, so a README that drifts from the code fails CI.
61
+
62
+ ### Internal
63
+
64
+ - `check:published` compiles every README snippet, not only the ones that import the package, through the
65
+ TypeScript API so a syntax error in one snippet no longer hides the type errors in the others. A
66
+ snippet that omits its import is given the package's public names, and the names a snippet takes from
67
+ the surrounding prose are typed in `scripts/readme-prelude.d.ts`, so a wrong method, option or field is
68
+ an error instead of a call on `any`.
69
+ - The same gate no longer excuses a wrong method on an imported type or on a union-typed value as a
70
+ class the README abbreviates, and a relative import in a snippet that resolves to nothing fails it.
71
+ - The same gate now checks the typed names the snippets rely on, so a name in that declaration file that
72
+ stops resolving is an error instead of silently becoming `any`, and it tolerates a parse error only
73
+ where the parse tree shows a `...` elision, so a genuine syntax error in a snippet fails it.
74
+
75
+ ## [1.2.3] - 2026-10-30
76
+
77
+ ### Changed
78
+
79
+ - `JsonSerializer.deserialize` throws `DESERIALIZATION_FAILED` with `details` of a fixed `error` text and the
80
+ `length` of the stored value. `details.preview` and the parser's own message, which quoted the start of the
81
+ input, are gone. The error code is unchanged; a consumer that read `details.preview` must stop.
82
+ - **Apply to a consumer.** Do not read `details.preview` or `details.error` to debug a decoding
83
+ failure. Log the error on the server by its code, `cache.deserialization_failed`; the call site already
84
+ knows the prefix and id it asked for. Use `details.length` when you need a size signal. A backend that
85
+ sent `details` to its clients needs no other change: the response no longer carries any part of the
86
+ stored value.
87
+ - Apart from that, nothing you execute changes.
88
+
89
+ ### Documentation
90
+
91
+ - `README.md`, `docs/technical_specification.md` and the contributor documents state that
92
+ `KeyBuilder` validates `prefix` and `id` only for emptiness. A separator inside either
93
+ collides (`build('a', 'b:c')` and `build('a:b', 'c')` both give `app:a:b:c`), and a glob
94
+ metacharacter in `prefix` widens `keys()` and `scan()` within the namespace. Behaviour is
95
+ unchanged. Keys are unambiguous only for a single-character separator that appears in neither field; encoding each field with `encodeURIComponent` provides that for a separator it escapes and never emits, such as the default `':'`.
96
+ - Contributor and security documents match the current tooling: pnpm 11, the admin
97
+ read-only check in the verification chain, mutation testing after merge and weekly, and
98
+ how dependency advisories are handled.
99
+
100
+ ### Security
101
+
102
+ - **A non-JSON stored value no longer appears in the exception body.** `CacheException` extends
103
+ `HttpException`, so a default Nest filter sends `details` to the HTTP client. A cached value that was
104
+ not JSON (written by another writer, or by another serializer) leaked up to 100 characters of itself
105
+ through `details.preview`, and the start of it through `details.error`. It could carry PII or a token.
106
+ Only a malformed entry triggers it; a valid JSON value never does.
107
+ - Dependency advisories in development-only transitive dependencies are fixed by raising the
108
+ `overrides` floors in `pnpm-workspace.yaml`. None of them ships: the package
109
+ publishes with `"dependencies": {}`, so no consumer was exposed to these advisories.
110
+ - `GHSA-gcqx-w48m-w2h4` (`deepmerge`, reached only through `jest-config`) has no patched
111
+ release. It is suppressed in `osv-scanner.toml` until 2027-01-27 and nowhere else.
112
+
113
+ ### Internal
114
+
115
+ - Development dependencies updated within their ranges; GitHub Actions bumped.
116
+
7
117
  ## [1.2.2] - 2026-09-04
8
118
 
9
119
  ### Changed
@@ -350,6 +460,9 @@ type or export moved.
350
460
  - Published with npm OIDC provenance — no long-lived tokens
351
461
  - Zero direct runtime dependencies (`dependencies: {}`) — `ioredis` and NestJS via peer deps
352
462
 
463
+ [Unreleased]: https://github.com/bymaxone/nest-cache/compare/v1.2.4...HEAD
464
+ [1.2.4]: https://github.com/bymaxone/nest-cache/compare/v1.2.3...v1.2.4
465
+ [1.2.3]: https://github.com/bymaxone/nest-cache/compare/v1.2.2...v1.2.3
353
466
  [1.2.2]: https://github.com/bymaxone/nest-cache/compare/v1.2.1...v1.2.2
354
467
  [1.2.1]: https://github.com/bymaxone/nest-cache/compare/v1.2.0...v1.2.1
355
468
  [1.2.0]: https://github.com/bymaxone/nest-cache/compare/v1.1.0...v1.2.0
package/README.md CHANGED
@@ -47,7 +47,7 @@ so you control exact versions and the supply-chain surface stays minimal.
47
47
  - **🧬 Typed get/set** — `get<T>` / `set<T>` go through a pluggable `ISerializer` (JSON by default). Deserialization **fails closed** — a malformed payload throws `CacheException`, never a half-decoded value.
48
48
  - **📡 Batteries included** — Pub/Sub on namespaced channels and a Lua script manager (`EVALSHA` + `NOSCRIPT` fallback) ship in the box, on top of the full string/hash/set/numeric command surface.
49
49
  - **♻️ Lifecycle done right** — singleton connection with `OnModuleInit` / `OnModuleDestroy`, bounded retry strategy, `READONLY`-failover reconnect, and a graceful `quit()` with shutdown timeout.
50
- - **🔌 Bring your own observability** — connection events surface through an `events.onEvent` callback; plug in [`@bymax-one/nest-logger`](https://github.com/bymaxone/nest-logger) or your metrics layer. No observability peer deps forced on you.
50
+ - **🔌 Bring your own observability** — connection events and Pub/Sub handler failures surface through an `events.onEvent` callback; plug in [`@bymax-one/nest-logger`](https://github.com/bymaxone/nest-logger) or your metrics layer. No observability peer deps forced on you.
51
51
 
52
52
  ```
53
53
  pnpm add @bymax-one/nest-cache ioredis
@@ -69,21 +69,21 @@ pnpm add @bymax-one/nest-cache ioredis
69
69
  - ✅ **Automatic namespacing** — the key builder enforces tenant/feature isolation; no manual string concatenation
70
70
  - ✅ **Namespaced channels** — Pub/Sub channels are composed through the same builder as keys
71
71
  - ✅ **Surgical flush** — `flushNamespace()` scans only `{namespace}:*`, never another namespace's keys
72
- - ✅ **Validated at bootstrap** — an empty namespace, or one containing the key separator, fails module initialization
72
+ - ✅ **Validated at bootstrap** — an empty namespace, an empty key separator, or a namespace containing the key separator or a Redis glob metacharacter (`*`, `?`, `[`, `\`) fails module initialization
73
73
 
74
74
  ### ⚙️ Reliability & Topology
75
75
 
76
76
  - ✅ **Multi-topology** — standalone, Sentinel, and Cluster modes from the same options shape
77
77
  - ✅ **Managed lifecycle** — singleton connection via `OnModuleInit` / `OnModuleDestroy`, graceful `quit()` with a shutdown timeout
78
78
  - ✅ **Bounded retries** — `maxRetriesPerRequest` and a reconnect policy that triggers on `READONLY` replica failover
79
- - ✅ **Connection events** — `connect` / `ready` / `error` / `close` / `reconnecting` / `end` surfaced via `events.onEvent`
79
+ - ✅ **Connection events** — `connect` / `ready` / `error` / `close` / `reconnecting` / `end` surfaced via `events.onEvent`, which also receives Pub/Sub handler and deserialization failures as `'error'` with `reason: 'handler_error'`
80
80
  - ✅ **Health checks** — `isHealthy()` / `ping()` / `info()` for readiness and liveness endpoints
81
81
 
82
82
  ### 🛡️ Safety
83
83
 
84
84
  - ✅ **Fail-closed serialization** — malformed payloads raise `CacheException(DESERIALIZATION_FAILED)`, never a partial value
85
85
  - ✅ **Production flush guard** — `flushNamespace()` is blocked under `NODE_ENV=production` unless explicitly allowed
86
- - ✅ **Secrets never echoed** — connection URLs and cached values are kept out of error `details`; previews are truncated
86
+ - ✅ **Secrets never echoed** — connection URLs and cached values are kept out of error `details`; no part of a stored value is ever echoed
87
87
  - ✅ **Script bodies are registered, not interpolated** — call sites pass a name plus keys/args, never Lua source
88
88
 
89
89
  ### 🧩 Developer Experience
@@ -171,7 +171,7 @@ BymaxCacheModule.forRoot({
171
171
  { host: 'sentinel2.example.com', port: 26379 }
172
172
  ],
173
173
  name: 'mymaster',
174
- password: process.env.REDIS_PASSWORD
174
+ password: process.env.REDIS_PASSWORD ?? ''
175
175
  },
176
176
  namespace: 'app'
177
177
  })
@@ -219,6 +219,15 @@ import { BymaxCacheModule } from '@bymax-one/nest-cache'
219
219
  export class AppModule {}
220
220
  ```
221
221
 
222
+ > [!IMPORTANT]
223
+ > **Call `app.enableShutdownHooks()` in `main.ts`.** Nest runs `OnModuleDestroy` on `SIGTERM` / `SIGINT` only when shutdown hooks are enabled. Without it the process exits on a signal without quitting the Redis connection gracefully, and `shutdownTimeoutMs` never comes into play.
224
+
225
+ ```typescript
226
+ const app = await NestFactory.create(AppModule)
227
+ app.enableShutdownHooks()
228
+ await app.listen(3000)
229
+ ```
230
+
222
231
  ### 3. Inject `CacheService`
223
232
 
224
233
  The module is global by default, so no re-import is needed in feature modules:
@@ -248,18 +257,25 @@ Keys resolve to `app:user-profile:<userId>` — namespaced automatically.
248
257
 
249
258
  ## ⚙️ Configuration
250
259
 
251
- | Option | Type | Default | Description |
252
- | ------------------- | ----------------------------------------- | ---------------- | ------------------------------------------------------------------ |
253
- | `mode` | `'standalone' \| 'sentinel' \| 'cluster'` | `'standalone'` | Redis topology |
254
- | `connection.url` | `string` | — | `redis://` / `rediss://` URL (overrides discrete host/port fields) |
255
- | `connection.tls` | `tls.ConnectionOptions` | — | TLS options for `rediss://` |
256
- | `namespace` | `string` | `'app'` | Key prefix for tenant/feature isolation |
257
- | `serializer` | `ISerializer` | `JsonSerializer` | Value encoding/decoding (plug MsgPack, CBOR, etc.) |
258
- | `events.onEvent` | `(event, data) => void` | — | Connection-event hook (plug a logger or metrics) |
259
- | `scripts` | `IScriptDefinition[]` | `[]` | Lua scripts to preload on init |
260
- | `shutdownTimeoutMs` | `number` | `5000` | Graceful `quit()` timeout before forced `disconnect()` |
261
-
262
- Both `forRoot(options)` (synchronous) and `forRootAsync({ useFactory, inject, imports })` are supported. The module registers globally by default — pass `isGlobal: false` to scope it to the importing module.
260
+ | Option | Type | Default | Description |
261
+ | ------------------------ | ----------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
262
+ | `mode` | `'standalone' \| 'sentinel' \| 'cluster'` | `'standalone'` | Redis topology |
263
+ | `connection.url` | `string` | — | `redis://` / `rediss://` URL (overrides discrete host/port fields) |
264
+ | `connection.tls` | `tls.ConnectionOptions` | — | TLS options for `rediss://` |
265
+ | `namespace` | `string` | `'app'` | Key prefix for tenant/feature isolation |
266
+ | `serializer` | `ISerializer` | `JsonSerializer` | Value encoding/decoding (plug MsgPack, CBOR, etc.) |
267
+ | `events.onEvent` | `(event, data) => void` | — | Lifecycle and Pub/Sub failure hook (plug a logger or metrics); receives connection events, and `'error'` with `reason: 'handler_error'` when a subscriber handler or deserialization fails |
268
+ | `scripts` | `IScriptDefinition[]` | — | Lua scripts to preload on init |
269
+ | `shutdownTimeoutMs` | `number` | `5000` | Graceful `quit()` timeout before forced `disconnect()` (minimum `100`) |
270
+ | `keySeparator` | `string` | `':'` | Separator between namespace, prefix and id segments |
271
+ | `allowFlushInProduction` | `boolean` | `false` | Allows `flushNamespace()` when `NODE_ENV=production` |
272
+ | `isGlobal` | `boolean` | `true` | Register the module globally |
273
+ | `sentinel` | `{ sentinels, name, ... }` | — | Sentinel connection block; required when `mode: 'sentinel'` |
274
+ | `cluster` | `{ nodes, options? }` | — | Cluster connection block; required when `mode: 'cluster'` |
275
+
276
+ `connection` also accepts `host`, `port`, `password`, `db`, `username`, `lazyConnect`, `connectTimeout` (minimum `100`), `commandTimeout`, `maxRetriesPerRequest`, `enableReadyCheck`, `enableOfflineQueue`, `retryStrategy`, `reconnectOnError`, `keepAlive`, `noDelay` and `family`; `url` takes precedence over the discrete fields.
277
+
278
+ Both `forRoot(options)` (synchronous) and `forRootAsync({ useFactory, inject, imports })` are supported. The module registers globally by default — pass `isGlobal: false` to scope it to the importing module. With `forRootAsync`, `isGlobal` is read at the call site (`forRootAsync({ isGlobal, ... })`); a value returned from `useFactory` has no effect.
263
279
 
264
280
  ---
265
281
 
@@ -271,6 +287,26 @@ keeps tenants and features isolated and makes `flushNamespace()` surgical. Reach
271
287
  `getClient()` to set raw, un-namespaced keys is supported as an escape hatch but documented as an
272
288
  anti-pattern.
273
289
 
290
+ `prefix` and `id` are supplied by the caller and the library validates them only for emptiness
291
+ (`INVALID_KEY`); it does **not** reject the separator or glob characters inside them. Two
292
+ consequences follow:
293
+
294
+ - **Separator collision.** `build('a', 'b:c')` and `build('a:b', 'c')` both produce `app:a:b:c`, so
295
+ two different `(prefix, id)` pairs address the same key.
296
+ - **Glob widening.** `keys(prefix, pattern)` and `scan(prefix, pattern)` pass the composed key to
297
+ Redis as a match pattern, so a glob metacharacter (`*`, `?`, `[`, `\`) in `prefix` can match keys
298
+ outside that prefix, still within the namespace. Single-key commands (`get`, `set`, `del`, ...)
299
+ treat the key literally and are not affected; `flushNamespace()` uses only the validated namespace.
300
+
301
+ This is documentation only; behavior is unchanged. When a `prefix` or `id` comes from untrusted
302
+ input, encode each field, for example `cache.get(encodeURIComponent(prefix), encodeURIComponent(id))`.
303
+ Keys are unambiguous only when `keySeparator` is a single character that appears in neither `prefix`
304
+ nor `id`. Per-field encoding guarantees that for a separator `encodeURIComponent` escapes and never
305
+ emits: the default `':'` qualifies; `%` and `- _ . ! ~ * ' ( )` do not, so for them reject the
306
+ separator in the fields. A multi-character separator can straddle a field boundary (with `'aa'`,
307
+ `('ba', 'c')` and `('b', 'ac')` build the same key), so keep a single character. A `prefix` used with
308
+ `keys()` / `scan()` must also avoid glob characters (`*`, `?`, `[`, `\`).
309
+
274
310
  ---
275
311
 
276
312
  ## 📡 Pub/Sub
@@ -393,6 +429,9 @@ export class HealthController {
393
429
 
394
430
  A read-only surface for an operator-facing console: is the cache answering, what is it doing, what is in it.
395
431
 
432
+ > [!WARNING]
433
+ > **`/admin` has no authentication or authorization of its own.** The library registers services, not routes: `CacheStatusService` and `CacheAdminService` list keys, report `INFO` statistics and, for a readable scope, return stored values to whoever calls them. Put every route you build on them behind your own guard (an authenticated operator role), and never on a public route.
434
+
396
435
  ```ts
397
436
  import { BymaxCacheModule } from '@bymax-one/nest-cache'
398
437
  import { BymaxCacheAdminModule } from '@bymax-one/nest-cache/admin'
@@ -444,6 +483,21 @@ await this.admin.listKeys('cache', { includeSize: true })
444
483
  await this.admin.revealValue('auth', 'auth:sess:1') // { status: 'withheld', origin }
445
484
  ```
446
485
 
486
+ ### Options
487
+
488
+ | Option | Type | Default | Description |
489
+ | ------------------- | ----------------------- | ------- | ------------------------------------------------------------------------ |
490
+ | `scopes` | `readonly CacheScope[]` | — | The keyspaces this deployment exposes; required |
491
+ | `degradedAboveMs` | `number` | `250` | Probe latency above which `health()` reports `degraded` |
492
+ | `probeTimeoutMs` | `number` | `2000` | How long `health()` waits for `PING` before reporting `down` |
493
+ | `scanLimit` | `number` | `500` | Entries one `listKeys` page may carry (a page can slightly exceed it) |
494
+ | `commandBatchLimit` | `number` | `100` | Commands in one pipeline batch |
495
+ | `revealLimit` | `number` | `200` | Members or fields `revealValue` returns before `isComplete` is `false` |
496
+ | `revealStringLimit` | `number` | `4096` | Characters of a string value `revealValue` returns |
497
+ | `isGlobal` | `boolean` | `false` | Register the admin module globally. The cache module's default is `true` |
498
+
499
+ Every threshold must be a positive integer; one that is not fails the boot with `cache.invalid_scope`. `CacheAdminService` also has `listScopes()` and `describeKey(scope, key)`, which returns a key's type, TTL and whether its value is readable, without the value.
500
+
447
501
  ### What the library owns, and what you own
448
502
 
449
503
  The library owns the **mechanism**: validating the allowlist, scanning against it, describing keys, withholding values. The application owns **which keyspaces exist**, the `origin` prose that explains them, the routes, and the guards. A cache library cannot know that another library writes at Redis root through `getClient()`, and making it depend on that library to find out would invert two packages to save an application from stating one thing about itself.
@@ -575,7 +629,7 @@ A cache sits between your application and every value it has ever computed, so t
575
629
 
576
630
  ### Namespace isolation is structural
577
631
 
578
- Every key and every Pub/Sub channel is composed through `KeyBuilder`, which prepends `{namespace}{separator}` before the command reaches Redis. `CacheService.get('user-profile', '42')` can only ever read `app:user-profile:42` — there is no code path through the typed API that emits a bare key.
632
+ Every key and every Pub/Sub channel is composed through `KeyBuilder`, which prepends `{namespace}{separator}` before the command reaches Redis. With fixed literals, `CacheService.get('user-profile', '42')` reads `app:user-profile:42` — there is no code path through the typed API that emits a bare key. The namespace is the isolation boundary; the library does not constrain `prefix` and `id` beyond non-emptiness.
579
633
 
580
634
  The namespace itself is validated at module bootstrap, not at call time:
581
635
 
@@ -583,10 +637,14 @@ The namespace itself is validated at module bootstrap, not at call time:
583
637
  | --------------------------------------- | ---------------------------------------------------------------- |
584
638
  | Empty or whitespace-only namespace | `CacheException(INVALID_NAMESPACE)` — the module fails to start |
585
639
  | Namespace containing the key separator | `CacheException(INVALID_NAMESPACE)` — prevents prefix collisions |
640
+ | Empty key separator | `CacheException(INVALID_NAMESPACE)` — the module fails to start |
641
+ | Namespace containing `*`, `?`, `[`, `\` | `CacheException(INVALID_NAMESPACE)` — see the glob section below |
586
642
  | Empty `prefix` or `id` at the call site | `CacheException(INVALID_KEY)` — no key is sent to Redis |
587
643
 
588
644
  A namespace containing the separator is rejected because `namespace: 'a:b'` and `namespace: 'a'` with prefix `b` would resolve to the same keyspace — a tenant boundary that reads as isolated but is not.
589
645
 
646
+ The same collision exists below the namespace and is **not** enforced by the library: `prefix` and `id` are validated only for emptiness, so `build('a', 'b:c')` and `build('a:b', 'c')` both give `app:a:b:c`, and a glob metacharacter in `prefix` widens `keys()` / `scan()` within the namespace. Behavior is unchanged; keys are unambiguous only for a single-character `keySeparator` that appears in neither `prefix` nor `id`: encode untrusted values per field with `encodeURIComponent` only when the separator is one it escapes and never emits (the default `':'` is; `%` and `- _ . ! ~ * ' ( )` are not), otherwise reject the separator in them; a multi-character separator can straddle a field boundary, so keep a single character; reject glob characters in `prefix`.
647
+
590
648
  ### Deserialization fails closed
591
649
 
592
650
  `JsonSerializer.deserialize` throws `CacheException(DESERIALIZATION_FAILED)` on any payload that is not valid JSON. It never returns `undefined`, never returns a partial object, and never lets a corrupted entry masquerade as a valid `T`. The same contract is required of any custom `ISerializer` — fail-closed is the invariant, not the default implementation's private choice.
@@ -599,7 +657,7 @@ Error `details` are built to be safe to log:
599
657
 
600
658
  - A malformed `connection.url` throws `CONNECTION_FAILED` with `reason: 'invalid connection.url'` — the URL is omitted, because it may embed a password.
601
659
  - `SERIALIZATION_FAILED` carries the encoder's message, never the value being encoded.
602
- - `DESERIALIZATION_FAILED` carries a `preview` of the raw payload truncated to **100 characters** with an ellipsis — enough to debug a codec mismatch, bounded so a cached record full of PII is not copied into a log line.
660
+ - `DESERIALIZATION_FAILED` carries a fixed `error` text and the `length` of the stored value, and nothing from its content — not a preview, not the parser's message, which quotes the start of its input. The exception is an `HttpException`, so a default Nest filter sends `details` to the HTTP client; a cached record holding PII or a token must never reach it.
603
661
 
604
662
  ### Destructive operations are guarded in production
605
663
 
@@ -615,7 +673,7 @@ Scripts are declared up front — through `options.scripts` or `ScriptManagerSer
615
673
 
616
674
  ### Cluster mode refuses commands it cannot honor safely
617
675
 
618
- `scan()`, `flushNamespace()`, and `getClient()` throw `UNSUPPORTED_IN_CLUSTER` under `mode: 'cluster'` rather than silently operating on one node. A partial flush that reports success is worse than an error.
676
+ `scan()`, `keys()`, `flushNamespace()`, and `getClient()` throw `UNSUPPORTED_IN_CLUSTER` under `mode: 'cluster'` rather than silently operating on one node. `KEYS` carries no key, so the cluster client sends it to a single node and the reply would list only that node's share of the keyspace. A partial flush that reports success is worse than an error.
619
677
 
620
678
  ### Security Checklist
621
679
 
@@ -624,6 +682,7 @@ When integrating `@bymax-one/nest-cache` in production, verify each of the follo
624
682
  - `namespace` is distinct per tenant or per application — it is the isolation boundary, and a shared value makes every other guarantee moot
625
683
  - `allowFlushInProduction` stays unset; if it is on, the reason belongs in a security review
626
684
  - `getClient()` and `pipeline()` call sites are audited — they are the only paths that bypass namespacing
685
+ - `prefix` and `id` that derive from untrusted input are encoded per field (`encodeURIComponent`) — the library checks them only for emptiness, so a separator inside either collides keys and a glob character in `prefix` widens `keys()` / `scan()`; keys are unambiguous only for a single-character `keySeparator` absent from both fields; per-field `encodeURIComponent` provides that only for a separator it escapes and never emits (the default `':'`; not `%` or `- _ . ! ~ * ' ( )`), otherwise the separator must be rejected, and a multi-character separator cannot be made safe that way; glob characters in `prefix` must be rejected
627
686
  - Connection credentials come from the environment (`config.getOrThrow('REDIS_URL')`), never from a URL literal in module options in source control
628
687
  - `rediss://` (or an explicit `connection.tls`) is used for any Redis reachable off-host
629
688
  - Values that must not be readable by whoever can read Redis are encrypted by the application (or a custom `ISerializer`) before they are cached — the library stores what you hand it
@@ -636,22 +695,22 @@ When integrating `@bymax-one/nest-cache` in production, verify each of the follo
636
695
 
637
696
  ## 🛡️ Security Table
638
697
 
639
- | Layer | Implementation |
640
- | --------------------- | --------------------------------------------------------------------------------------------------------------------------- |
641
- | Tenant Isolation | Every key and channel composed by `KeyBuilder` as `{namespace}{sep}{prefix}{sep}{id}` — no bare-key path in the API |
642
- | Namespace Validation | Empty, separator-containing, or glob-metacharacter namespace rejected at bootstrap (`INVALID_NAMESPACE`) |
643
- | Admin Scope Allowlist | Scopes declared at wiring, validated and frozen; a caller names a scope by id and can never supply a match pattern |
644
- | Admin Read-Only | The `/admin` subpath issues no mutating command — enforced by the `check:admin-readonly` build gate, not by convention |
645
- | Key Validation | Empty `prefix` / `id` rejected before the command is issued (`INVALID_KEY`) |
646
- | Deserialization | Fails closed — `DESERIALIZATION_FAILED`; never a partial or wrongly-typed value |
647
- | Serialization | Top-level `undefined` / function / symbol rejected; the value is never echoed into `details` |
648
- | Error Payloads | Connection URLs omitted (may embed credentials); raw payload previews truncated to 100 characters |
649
- | Destructive Ops | `flushNamespace()` blocked under `NODE_ENV=production` unless `allowFlushInProduction`; `SCAN` + `UNLINK`, namespace-scoped |
650
- | Lua Execution | Scripts registered by name; call sites pass keys/args only — request input never reaches a script body |
651
- | Cluster Safety | `scan` / `flushNamespace` / `getClient` throw `UNSUPPORTED_IN_CLUSTER` instead of acting on a single node |
652
- | Transport | `rediss://` sets TLS on the client; explicit `connection.tls` supported for custom CA / mTLS |
653
- | Connection Resilience | Bounded `maxRetriesPerRequest`, `READONLY`-failover reconnect, graceful `quit()` with a shutdown timeout |
654
- | Supply Chain | `"dependencies": {}` — no transitive runtime packages of the library's own choosing; published with npm provenance |
698
+ | Layer | Implementation |
699
+ | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
700
+ | Tenant Isolation | Every key and channel composed by `KeyBuilder` as `{namespace}{sep}{prefix}{sep}{id}` — no bare-key path in the API |
701
+ | Namespace Validation | Empty, separator-containing, or glob-metacharacter namespace rejected at bootstrap (`INVALID_NAMESPACE`) |
702
+ | Admin Scope Allowlist | Scopes declared at wiring, validated and frozen; a caller names a scope by id and can never supply a match pattern |
703
+ | Admin Read-Only | The `/admin` subpath issues no mutating command — enforced by the `check:admin-readonly` build gate, not by convention |
704
+ | Key Validation | Empty `prefix` / `id` rejected before the command is issued (`INVALID_KEY`); emptiness only — separators and glob characters in `prefix` / `id` are not rejected |
705
+ | Deserialization | Fails closed — `DESERIALIZATION_FAILED`; never a partial or wrongly-typed value |
706
+ | Serialization | Top-level `undefined` / function / symbol rejected; the value is never echoed into `details` |
707
+ | Error Payloads | Connection URLs omitted (may embed credentials); a stored value is never echoed (a fixed text and its length only) |
708
+ | Destructive Ops | `flushNamespace()` blocked under `NODE_ENV=production` unless `allowFlushInProduction`; `SCAN` + `UNLINK`, namespace-scoped |
709
+ | Lua Execution | Scripts registered by name; call sites pass keys/args only — request input never reaches a script body |
710
+ | Cluster Safety | `scan` / `keys` / `flushNamespace` / `getClient` throw `UNSUPPORTED_IN_CLUSTER` instead of acting on a single node |
711
+ | Transport | `rediss://` sets TLS on the client; explicit `connection.tls` supported for custom CA / mTLS |
712
+ | Connection Resilience | Bounded `maxRetriesPerRequest`, `READONLY`-failover reconnect, graceful `quit()` with a shutdown timeout |
713
+ | Supply Chain | `"dependencies": {}` — no transitive runtime packages of the library's own choosing; published with npm provenance |
655
714
 
656
715
  > [!IMPORTANT]
657
716
  > The namespace is the isolation boundary. Anything reached through `getClient()` or `pipeline()` sits outside it — those call sites are where cross-tenant reads get introduced.
@@ -666,7 +725,7 @@ When integrating `@bymax-one/nest-cache` in production, verify each of the follo
666
725
  [![Node.js](https://img.shields.io/badge/Node.js-24-339933?style=flat-square&logo=node.js&logoColor=white)](https://nodejs.org)
667
726
  [![Jest](https://img.shields.io/badge/Jest-30-C21325?style=flat-square&logo=jest)](https://jestjs.io)
668
727
  [![Stryker](https://img.shields.io/badge/Stryker-Mutation_Testing-red?style=flat-square)](https://stryker-mutator.io)
669
- [![pnpm](https://img.shields.io/badge/pnpm-10-F69220?style=flat-square&logo=pnpm&logoColor=white)](https://pnpm.io)
728
+ [![pnpm](https://img.shields.io/badge/pnpm-11-F69220?style=flat-square&logo=pnpm&logoColor=white)](https://pnpm.io)
670
729
  [![tsup](https://img.shields.io/badge/tsup-8-orange?style=flat-square)](https://tsup.egoist.dev)
671
730
 
672
731
  ---
@@ -676,7 +735,7 @@ When integrating `@bymax-one/nest-cache` in production, verify each of the follo
676
735
  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**.
677
736
 
678
737
  - ✅ **100% line coverage** — statements, branches, functions, and lines, enforced by `jest.coverage.config.ts` as a pre-publish gate, not a target
679
- - ✅ **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)
738
+ - ✅ **100% mutation score** — verified with [Stryker](https://stryker-mutator.io/) at `break: 100` and `ignoreStatic: false`, **0 survived**, and [documented in full](./docs/mutation_testing_results.md)
680
739
  - ✅ **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
681
740
  - ✅ **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
682
741
  - ✅ **Published-package smoke test** — `scripts/dogfood-smoke-test.mjs` validates exports, tarball shape, and a consumer install before tagging
@@ -685,7 +744,7 @@ A cache is consulted on the hot path of every request that touches it, so the su
685
744
  pnpm test # unit tests (Jest)
686
745
  pnpm test:e2e # end-to-end tests (@nestjs/testing + Testcontainers)
687
746
  pnpm test:cov:all # full coverage gate (100% statements/branches/functions/lines)
688
- pnpm mutation # Stryker mutation testing (95% break gate)
747
+ pnpm mutation # Stryker mutation testing (100% break gate)
689
748
  pnpm typecheck # tsc strict check
690
749
  pnpm lint # ESLint
691
750
  ```
@@ -714,6 +773,8 @@ pnpm lint # ESLint
714
773
  | Namespace | `flushNamespace` (prod-guarded) |
715
774
  | Health | `isHealthy` · `ping` · `info` |
716
775
 
776
+ **TTLs.** `ttlSeconds` on `set`, `setRaw` and `setNx` is a safe integer of at least `1`; omit it for no expiry. `expire` takes a safe integer, and as in Redis a zero or negative one deletes the key. Anything else throws `INVALID_TTL` before a command is sent.
777
+
717
778
  ### `PubSubService`
718
779
 
719
780
  `publish<T>(channel, message)` · `subscribe<T>(channel, handler)` · `psubscribe<T>(pattern, handler)`
@@ -726,29 +787,54 @@ pnpm lint # ESLint
726
787
 
727
788
  `CacheException` (extends `HttpException`) + `CACHE_ERROR_CODES` (namespaced `cache.*`).
728
789
 
790
+ ### Types, constants and tokens
791
+
792
+ From `@bymax-one/nest-cache`:
793
+
794
+ | Export | What it is |
795
+ | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
796
+ | `BymaxCacheModuleOptions`, `BymaxCacheModuleAsyncOptions` | The options of `forRoot()` and `forRootAsync()`; the Configuration table lists their fields |
797
+ | `BymaxCacheStandaloneConnection`, `BymaxCacheSentinelConnection`, `BymaxCacheClusterConnection` | The `connection`, `sentinel` and `cluster` blocks |
798
+ | `RedisOptions`, `ClusterOptions`, `ClusterNode`, `SentinelAddress` | The ioredis option types the blocks above refer to, re-exported |
799
+ | `ResolvedOptions` | The shape injected under `BYMAX_CACHE_OPTIONS`, with every default applied |
800
+ | `IPubSubHandler`, `IPubSubPatternHandler`, `Unsubscribe` | The handler signatures and the function `subscribe` / `psubscribe` return |
801
+ | `CACHE_ERROR_MESSAGES` | The default message of every code, keyed by code |
802
+ | `DEFAULT_NAMESPACE`, `DEFAULT_KEY_SEPARATOR`, `DEFAULT_REDIS_PORT` | `'app'`, `':'` and `6379` |
803
+
804
+ From `@bymax-one/nest-cache/shared` (also re-exported from the main entry): `CACHE_EVENT_NAMES`, `CacheEventName`, `CacheConnectionStatus`, `CacheErrorCode`, `CacheNamespace`, `CacheKeyPrefix` and `SerializableValue`.
805
+
806
+ From `@bymax-one/nest-cache/admin`: `BymaxCacheAdminModuleOptions` and `BymaxCacheAdminModuleAsyncOptions` (the Options table above), `CacheScope`, `ListKeysOptions`, the result types `CacheHealth`, `CacheConfig`, `CacheMode`, `RedisStats`, `KeyspacePage`, `KeyEntry`, `KeyDetail`, `KeyTtl`, `RevealResult` and `RevealedValue`, the token `BYMAX_CACHE_ADMIN_OPTIONS`, and the defaults `DEFAULT_DEGRADED_ABOVE_MS`, `DEFAULT_PROBE_TIMEOUT_MS`, `DEFAULT_SCAN_LIMIT`, `DEFAULT_COMMAND_BATCH_LIMIT`, `DEFAULT_REVEAL_LIMIT` and `DEFAULT_REVEAL_STRING_LIMIT`.
807
+
808
+ The other names `/admin` exports (the scope and `INFO` helpers such as `validateScopes` and `readRedisStats`, `resolveAdminOptions`, and the reader contracts) are implementation helpers, marked `@deprecated`: they will leave the public surface in 2.0.0, so do not depend on them.
809
+
729
810
  ---
730
811
 
731
812
  ## 🪪 Default Error Codes
732
813
 
733
814
  All errors are instances of `CacheException` and carry a stable `code` string from `CACHE_ERROR_CODES`:
734
815
 
735
- | Code | HTTP | When thrown |
736
- | ------------------------------------ | ---- | --------------------------------------------------------------- |
737
- | `cache.connection_failed` | 500 | Cannot connect after retries |
738
- | `cache.command_timeout` | 504 | Command exceeded `commandTimeout` |
739
- | `cache.connection_lost` | 503 | Connection dropped during an in-flight operation |
740
- | `cache.deserialization_failed` | 500 | Malformed payload in `get<T>` |
741
- | `cache.serialization_failed` | 500 | Unserializable value in `set<T>` |
742
- | `cache.invalid_key` | 400 | Empty prefix or id passed to `build` / `applyNamespace` |
743
- | `cache.invalid_namespace` | 500 | Empty or separator-containing namespace |
744
- | `cache.script_not_registered` | 500 | `eval(name)` before `register(name)` |
745
- | `cache.script_execution_failed` | 500 | Lua runtime error or NOSCRIPT retry failure |
746
- | `cache.script_registry_missing` | 500 | `eval` called when no `ScriptManagerService` is wired |
747
- | `cache.flush_disabled_in_production` | 403 | `flushNamespace()` in prod without `allowFlushInProduction` |
748
- | `cache.unsupported_in_cluster` | 500 | `scan`, `flushNamespace`, or `getClient` called in cluster mode |
749
- | `cache.cluster_misconfigured` | 500 | `mode: 'cluster'` without `cluster.nodes` |
750
- | `cache.sentinel_misconfigured` | 500 | `mode: 'sentinel'` without `sentinel.sentinels`/`name` |
751
- | `cache.shutdown_timeout` | 500 | `quit()` exceeded `shutdownTimeoutMs` |
816
+ | Code | HTTP | When thrown |
817
+ | ------------------------------------ | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
818
+ | `cache.connection_failed` | 500 | Cannot connect after retries; also bootstrap validation of `connection` and timeouts |
819
+ | `cache.command_timeout` | 504 | Reserved, never thrown today |
820
+ | `cache.connection_lost` | 503 | Reserved, never thrown today |
821
+ | `cache.deserialization_failed` | 500 | Malformed payload in `get<T>` |
822
+ | `cache.serialization_failed` | 500 | Unserializable value in `set<T>` |
823
+ | `cache.invalid_key` | 400 | Empty prefix or id (`build`), or empty key (`applyNamespace`) |
824
+ | `cache.invalid_ttl` | 400 | `ttlSeconds` is not a safe integer, or is below 1 for `set` / `setRaw` / `setNx` |
825
+ | `cache.invalid_namespace` | 500 | Empty or whitespace-only namespace, empty `keySeparator`, namespace containing the separator or a glob metacharacter (`*`, `?`, `[`, `\`) |
826
+ | `cache.script_not_registered` | 500 | `eval(name)` before `register(name)` |
827
+ | `cache.script_execution_failed` | 500 | Lua runtime error or NOSCRIPT retry failure |
828
+ | `cache.script_registry_missing` | 500 | `eval` called when no `ScriptManagerService` is wired |
829
+ | `cache.flush_disabled_in_production` | 403 | `flushNamespace()` in prod without `allowFlushInProduction` |
830
+ | `cache.unsupported_in_cluster` | 500 | `scan`, `keys`, `flushNamespace`, or `getClient` called in cluster mode |
831
+ | `cache.cluster_misconfigured` | 500 | `mode: 'cluster'` without `cluster.nodes` |
832
+ | `cache.sentinel_misconfigured` | 500 | `mode: 'sentinel'` without `sentinel.sentinels`/`name` |
833
+ | `cache.shutdown_timeout` | 500 | Internal, never thrown to the caller: `quit()` exceeded `shutdownTimeoutMs`, so the library forces `disconnect()` and emits `error` with `reason: 'forced_disconnect'` |
834
+ | `cache.invalid_scope` | 500 | `/admin` scope allowlist invalid at wiring |
835
+ | `cache.scope_not_found` | 500 | `/admin` call named an undeclared scope id |
836
+ | `cache.scope_not_readable` | 500 | Reserved, never thrown today (unreadable scopes return `withheld`) |
837
+ | `cache.key_not_in_scope` | 500 | `/admin` call named a key outside the named scope |
752
838
 
753
839
  Full catalog and HTTP status mapping: [`docs/technical_specification.md §12`](./docs/technical_specification.md).
754
840
 
@@ -775,7 +861,7 @@ See §13 of the [technical specification](./docs/technical_specification.md) for
775
861
  Contributions are welcome. Development follows the Bymax coding standards:
776
862
 
777
863
  - TypeScript strict (`noImplicitAny`, `exactOptionalPropertyTypes`, `noUncheckedIndexedAccess`)
778
- - TDD with a 100% coverage gate and a 95% mutation break threshold
864
+ - TDD with a 100% coverage gate and a 100% mutation break threshold
779
865
  - Conventional Commits enforced by commitlint + husky
780
866
  - No direct dependencies — peer deps only
781
867
  - All boolean identifiers prefixed with `is / has / should / can`
@@ -801,7 +887,7 @@ pnpm typecheck
801
887
  Run the full gate before opening a PR:
802
888
 
803
889
  ```bash
804
- pnpm typecheck && pnpm lint && pnpm test:cov:all && pnpm build && pnpm size
890
+ pnpm typecheck && pnpm lint && pnpm check:admin-readonly && pnpm test:cov:all && pnpm build && pnpm size && pnpm check:exports
805
891
  ```
806
892
 
807
893
  See [CONTRIBUTING.md](./CONTRIBUTING.md) and [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md) for the full process.