@bymax-one/nest-cache 1.0.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/README.md ADDED
@@ -0,0 +1,677 @@
1
+ <p align="center">
2
+ <img src="https://img.shields.io/badge/%40bymax--one-nest--cache-000000?style=for-the-badge&logo=nestjs&logoColor=E0234E" alt="@bymax-one/nest-cache" />
3
+ </p>
4
+
5
+ <h1 align="center">@bymax-one/nest-cache</h1>
6
+
7
+ <p align="center">
8
+ <strong>Typed Redis cache for NestJS</strong><br />
9
+ <sub>ioredis 5 · Namespacing · Pub/Sub · Lua Scripts · Multi-Tenant · Zero Runtime Dependencies</sub>
10
+ </p>
11
+
12
+ <p align="center">
13
+ <a href="https://www.npmjs.com/package/@bymax-one/nest-cache"><img src="https://img.shields.io/npm/v/@bymax-one/nest-cache?style=flat-square&colorA=000000&colorB=000000" alt="npm version" /></a>
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
+ <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
+ <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>
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
+ <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
+ <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>
21
+ <a href="https://nodejs.org/"><img src="https://img.shields.io/badge/Node.js-24%2B-339933?style=flat-square&logo=node.js&logoColor=white" alt="Node.js" /></a>
22
+ </p>
23
+
24
+ <p align="center">
25
+ <a href="https://github.com/bymaxone/nest-cache">GitHub</a> ·
26
+ <a href="https://github.com/bymaxone/nest-cache/issues">Issues</a> ·
27
+ <a href="#-quick-start">Quick Start</a> ·
28
+ <a href="#-api-reference">API Reference</a> ·
29
+ <a href="https://github.com/bymaxone/nest-cache-example">Example App</a>
30
+ </p>
31
+
32
+ ---
33
+
34
+ ## ✨ Overview
35
+
36
+ `@bymax-one/nest-cache` wraps a single, correctly-managed `ioredis` connection behind a typed
37
+ NestJS module. Instead of scattering raw Redis calls across your services, you get a namespaced,
38
+ serializer-backed API with first-class Pub/Sub and atomic Lua scripting — and a connection whose
39
+ lifecycle, reconnection, and graceful shutdown are handled for you.
40
+
41
+ The library has **zero direct dependencies** — `ioredis` and NestJS arrive as peer dependencies,
42
+ so you control exact versions and the supply-chain surface stays minimal.
43
+
44
+ ### Why nest-cache?
45
+
46
+ - **🔑 Namespaced by design** — every key is composed through a key builder (`{namespace}:{prefix}:{id}`), so tenants and features never collide. Raw, un-namespaced access is a documented anti-pattern.
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
+ - **📡 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
+ - **♻️ 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.
51
+
52
+ ```
53
+ pnpm add @bymax-one/nest-cache ioredis
54
+ ```
55
+
56
+ ---
57
+
58
+ ## 🔥 Features
59
+
60
+ ### 🧬 Typed Cache API
61
+
62
+ - ✅ **Typed get/set** — `get<T>` / `set<T>` / `setNx<T>` / `mget<T>` / `mset<T>` through a pluggable serializer
63
+ - ✅ **Full command surface** — strings, numbers (`incr`/`decr`), hashes, sets, TTL (`expire`/`ttl`/`persist`), iteration (`scan`)
64
+ - ✅ **Pluggable serialization** — `ISerializer` contract; swap JSON for MessagePack, CBOR, or your own codec
65
+ - ✅ **Raw string access** — `getRaw` / `setRaw` skip the serializer while keeping namespacing; `pipeline` / `getClient` are the documented escape hatches
66
+
67
+ ### 🔑 Isolation & Namespacing
68
+
69
+ - ✅ **Automatic namespacing** — the key builder enforces tenant/feature isolation; no manual string concatenation
70
+ - ✅ **Namespaced channels** — Pub/Sub channels are composed through the same builder as keys
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
73
+
74
+ ### ⚙️ Reliability & Topology
75
+
76
+ - ✅ **Multi-topology** — standalone, Sentinel, and Cluster modes from the same options shape
77
+ - ✅ **Managed lifecycle** — singleton connection via `OnModuleInit` / `OnModuleDestroy`, graceful `quit()` with a shutdown timeout
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`
80
+ - ✅ **Health checks** — `isHealthy()` / `ping()` / `info()` for readiness and liveness endpoints
81
+
82
+ ### 🛡️ Safety
83
+
84
+ - ✅ **Fail-closed serialization** — malformed payloads raise `CacheException(DESERIALIZATION_FAILED)`, never a partial value
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
87
+ - ✅ **Script bodies are registered, not interpolated** — call sites pass a name plus keys/args, never Lua source
88
+
89
+ ### 🧩 Developer Experience
90
+
91
+ - ✅ **Dynamic module** — `forRoot()` and `forRootAsync()` via `ConfigurableModuleBuilder`; registered globally by default (`isGlobal`)
92
+ - ✅ **Lua script manager** — register scripts up front, execute by name with transparent `NOSCRIPT` reload + retry
93
+ - ✅ **Structured errors** — every failure is a `CacheException` with a stable `cache.*` code and an HTTP status
94
+ - ✅ **Zero runtime dependencies** — everything is a peer dependency; `dependencies: {}`
95
+
96
+ ---
97
+
98
+ ## 📦 Subpath Exports
99
+
100
+ One package, two entry points — import only what your app needs:
101
+
102
+ | Subpath | Import | Purpose | Dependencies |
103
+ | ---------- | ------------------------------ | -------------------------------------------------------------------------------------------------------- | :------------------------------------: |
104
+ | **Server** | `@bymax-one/nest-cache` | `BymaxCacheModule`, `CacheService`, `PubSubService`, `ScriptManagerService`, DI tokens, `CacheException` | NestJS 11, ioredis 5, reflect-metadata |
105
+ | **Shared** | `@bymax-one/nest-cache/shared` | Types + constants — `CACHE_ERROR_CODES`, `CacheEventName`, config types | None |
106
+
107
+ ```
108
+ shared (zero deps)
109
+
110
+ server
111
+ ```
112
+
113
+ The `/shared` subpath is safe to import in isomorphic code, test helpers, CLI scripts, or shared packages that must not pull in NestJS or ioredis.
114
+
115
+ ---
116
+
117
+ > [!TIP]
118
+ > Prefer to learn from a working app? See the [nest-cache-example](https://github.com/bymaxone/nest-cache-example) — a full NestJS project wired with this library.
119
+
120
+ ## 🚀 Quick Start
121
+
122
+ ### 1. Install
123
+
124
+ ```bash
125
+ # Using pnpm (recommended)
126
+ pnpm add @bymax-one/nest-cache ioredis
127
+
128
+ # Using npm
129
+ npm install @bymax-one/nest-cache ioredis
130
+
131
+ # Using yarn
132
+ yarn add @bymax-one/nest-cache ioredis
133
+ ```
134
+
135
+ > [!IMPORTANT]
136
+ > `@nestjs/common`, `@nestjs/core`, and `reflect-metadata` are peer dependencies (already present in
137
+ > any NestJS app). `ioredis` is the single functional peer — the Redis client itself.
138
+
139
+ ### 2. Register the Module
140
+
141
+ Pick the topology that matches your deployment. All four forms share the same options shape.
142
+
143
+ #### Scenario 1 — Standalone (dev / single node)
144
+
145
+ ```typescript
146
+ import { Module } from '@nestjs/common'
147
+ import { BymaxCacheModule } from '@bymax-one/nest-cache'
148
+
149
+ @Module({
150
+ imports: [
151
+ BymaxCacheModule.forRoot({
152
+ connection: { url: 'redis://localhost:6379' },
153
+ namespace: 'app'
154
+ })
155
+ ]
156
+ })
157
+ export class AppModule {}
158
+ ```
159
+
160
+ #### Scenario 2 — Sentinel (high availability)
161
+
162
+ ```typescript
163
+ BymaxCacheModule.forRoot({
164
+ mode: 'sentinel',
165
+ sentinel: {
166
+ sentinels: [
167
+ { host: 'sentinel1.example.com', port: 26379 },
168
+ { host: 'sentinel2.example.com', port: 26379 }
169
+ ],
170
+ name: 'mymaster',
171
+ password: process.env.REDIS_PASSWORD
172
+ },
173
+ namespace: 'app'
174
+ })
175
+ ```
176
+
177
+ #### Scenario 3 — Cluster (sharded)
178
+
179
+ ```typescript
180
+ BymaxCacheModule.forRoot({
181
+ mode: 'cluster',
182
+ cluster: {
183
+ nodes: [
184
+ { host: 'cluster1.example.com', port: 7000 },
185
+ { host: 'cluster2.example.com', port: 7001 },
186
+ { host: 'cluster3.example.com', port: 7002 }
187
+ ]
188
+ },
189
+ namespace: 'app'
190
+ })
191
+ ```
192
+
193
+ #### Scenario 4 — forRootAsync with ConfigService
194
+
195
+ ```typescript
196
+ import { Module } from '@nestjs/common'
197
+ import { ConfigModule, ConfigService } from '@nestjs/config'
198
+ import { BymaxCacheModule } from '@bymax-one/nest-cache'
199
+
200
+ @Module({
201
+ imports: [
202
+ BymaxCacheModule.forRootAsync({
203
+ imports: [ConfigModule],
204
+ inject: [ConfigService],
205
+ useFactory: (config: ConfigService) => ({
206
+ connection: { url: config.getOrThrow<string>('REDIS_URL') },
207
+ namespace: 'app',
208
+ events: {
209
+ // Plug @bymax-one/nest-logger or a metrics sink here
210
+ onEvent: (event, data) => console.log(`[cache] ${event}`, data)
211
+ }
212
+ })
213
+ })
214
+ ]
215
+ })
216
+ export class AppModule {}
217
+ ```
218
+
219
+ ### 3. Inject `CacheService`
220
+
221
+ The module is global by default, so no re-import is needed in feature modules:
222
+
223
+ ```typescript
224
+ import { Injectable } from '@nestjs/common'
225
+ import { CacheService } from '@bymax-one/nest-cache'
226
+
227
+ @Injectable()
228
+ export class ProfileService {
229
+ constructor(private readonly cache: CacheService) {}
230
+
231
+ async getProfile(userId: string): Promise<Profile | null> {
232
+ const cached = await this.cache.get<Profile>('user-profile', userId)
233
+ if (cached) return cached
234
+
235
+ const profile = await this.repo.findProfile(userId)
236
+ await this.cache.set('user-profile', userId, profile, 3600) // TTL in seconds
237
+ return profile
238
+ }
239
+ }
240
+ ```
241
+
242
+ Keys resolve to `app:user-profile:<userId>` — namespaced automatically.
243
+
244
+ ---
245
+
246
+ ## ⚙️ Configuration
247
+
248
+ | Option | Type | Default | Description |
249
+ | ------------------- | ----------------------------------------- | ---------------- | ------------------------------------------------------------------ |
250
+ | `mode` | `'standalone' \| 'sentinel' \| 'cluster'` | `'standalone'` | Redis topology |
251
+ | `connection.url` | `string` | — | `redis://` / `rediss://` URL (overrides discrete host/port fields) |
252
+ | `connection.tls` | `tls.ConnectionOptions` | — | TLS options for `rediss://` |
253
+ | `namespace` | `string` | `'app'` | Key prefix for tenant/feature isolation |
254
+ | `serializer` | `ISerializer` | `JsonSerializer` | Value encoding/decoding (plug MsgPack, CBOR, etc.) |
255
+ | `events.onEvent` | `(event, data) => void` | — | Connection-event hook (plug a logger or metrics) |
256
+ | `scripts` | `IScriptDefinition[]` | `[]` | Lua scripts to preload on init |
257
+ | `shutdownTimeoutMs` | `number` | `5000` | Graceful `quit()` timeout before forced `disconnect()` |
258
+
259
+ 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
+
261
+ ---
262
+
263
+ ## 🔑 Key Namespacing
264
+
265
+ Every key is composed as `{namespace}{separator}{prefix}{separator}{id}` (default separator `:`).
266
+ Calling `cache.get('user-profile', '42')` under namespace `app` reads `app:user-profile:42`. This
267
+ keeps tenants and features isolated and makes `flushNamespace()` surgical. Reaching for
268
+ `getClient()` to set raw, un-namespaced keys is supported as an escape hatch but documented as an
269
+ anti-pattern.
270
+
271
+ ---
272
+
273
+ ## 📡 Pub/Sub
274
+
275
+ ```typescript
276
+ const unsubscribe = await pubsub.subscribe<UserEvent>('user-events', async (msg) => {
277
+ await handle(msg)
278
+ })
279
+ await pubsub.publish<UserEvent>('user-events', { type: 'created', id: '42' })
280
+ // ...later
281
+ await unsubscribe()
282
+ ```
283
+
284
+ Channels are namespaced like keys. The subscriber connection is created lazily on the first
285
+ subscription. Redis Pub/Sub is fire-and-forget — messages published while a subscriber is offline
286
+ are not replayed.
287
+
288
+ ---
289
+
290
+ ## 📜 Lua Scripts
291
+
292
+ Register scripts at module init, then execute them atomically by name. The manager caches the
293
+ SHA1 and uses `EVALSHA`, transparently reloading on `NOSCRIPT`:
294
+
295
+ ```typescript
296
+ // In module options:
297
+ scripts: [{ name: 'compareAndSet', lua: '...' }]
298
+
299
+ // At call site — keys are flat strings passed directly to Lua's KEYS[] table.
300
+ // CacheService prepends the namespace via applyNamespace(), so 'lock:job'
301
+ // becomes 'app:lock:job' in Redis. Pass the full suffix as a single string.
302
+ const ok = await cache.eval('compareAndSet', ['lock:job'], [expected, next])
303
+ ```
304
+
305
+ ---
306
+
307
+ ## 🔁 Custom Serializer
308
+
309
+ Swap the default `JsonSerializer` with any `ISerializer` implementation — MsgPack, CBOR, or your own:
310
+
311
+ ```typescript
312
+ import { encode, decode } from '@msgpack/msgpack'
313
+ import type { ISerializer } from '@bymax-one/nest-cache'
314
+
315
+ class MsgPackSerializer implements ISerializer {
316
+ serialize<T>(value: T): string {
317
+ return Buffer.from(encode(value)).toString('base64')
318
+ }
319
+ deserialize<T>(raw: string): T {
320
+ return decode(Buffer.from(raw, 'base64')) as T
321
+ }
322
+ }
323
+
324
+ // In module options:
325
+ BymaxCacheModule.forRoot({
326
+ connection: { url: 'redis://localhost:6379' },
327
+ serializer: new MsgPackSerializer()
328
+ })
329
+ ```
330
+
331
+ ---
332
+
333
+ ## 🔗 Plug with @bymax-one/nest-logger
334
+
335
+ Wire connection events into your logger via the `events.onEvent` hook:
336
+
337
+ ```typescript
338
+ import { BymaxOneLogger } from '@bymax-one/nest-logger'
339
+
340
+ BymaxCacheModule.forRootAsync({
341
+ imports: [ConfigModule, LoggerModule],
342
+ inject: [ConfigService, BymaxOneLogger],
343
+ useFactory: (config: ConfigService, logger: BymaxOneLogger) => ({
344
+ connection: { url: config.getOrThrow('REDIS_URL') },
345
+ namespace: 'app',
346
+ events: {
347
+ onEvent: (event, data) => {
348
+ if (event === 'error') logger.error('[cache]', data)
349
+ else logger.log(`[cache] ${event}`, data)
350
+ }
351
+ }
352
+ })
353
+ })
354
+ ```
355
+
356
+ ---
357
+
358
+ ## ❤️ Health Check (terminus integration)
359
+
360
+ ```typescript
361
+ import { Controller, Get } from '@nestjs/common'
362
+ import { HealthCheck, HealthCheckService } from '@nestjs/terminus'
363
+ import { CacheService } from '@bymax-one/nest-cache'
364
+
365
+ @Controller('health')
366
+ export class HealthController {
367
+ constructor(
368
+ private readonly health: HealthCheckService,
369
+ private readonly cache: CacheService
370
+ ) {}
371
+
372
+ @Get()
373
+ @HealthCheck()
374
+ check() {
375
+ return this.health.check([
376
+ () =>
377
+ this.cache
378
+ .isHealthy()
379
+ .then((ok) =>
380
+ ok ? { redis: { status: 'up' } } : Promise.reject(new Error('Redis not ready'))
381
+ )
382
+ ])
383
+ }
384
+ }
385
+ ```
386
+
387
+ ---
388
+
389
+ ## 🏗️ Architecture
390
+
391
+ The package runs **inside** your NestJS application as a dynamic module — not as a separate service:
392
+
393
+ ```
394
+ ┌─────────────────────────────────────────────────────┐
395
+ │ Your NestJS Application │
396
+ │ │
397
+ │ ┌───────────────────────────────────────────────┐ │
398
+ │ │ @bymax-one/nest-cache │ │
399
+ │ │ │ │
400
+ │ │ CacheService ←→ ConnectionManager ←→ Redis │ │
401
+ │ │ PubSubService ←→ lazy subscriber conn │ │
402
+ │ │ ScriptManagerService ←→ EVALSHA + NOSCRIPT │ │
403
+ │ │ KeyBuilder → {namespace}:{prefix}:{id} │ │
404
+ │ └─────────────┬─────────────────┬───────────────┘ │
405
+ │ │ │ │
406
+ │ ┌───────▼──────┐ ┌───────▼──────┐ │
407
+ │ │ ISerializer │ │ ICacheEvents │ │
408
+ │ │ (yours) │ │ (yours) │ │
409
+ │ └──────────────┘ └──────────────┘ │
410
+ └─────────────────────────────────────────────────────┘
411
+ ```
412
+
413
+ Both consumer-facing contracts are optional: omit `serializer` and you get `JsonSerializer`; omit `events.onEvent` and connection events are simply not forwarded anywhere.
414
+
415
+ DI tokens are `Symbol`s (`BYMAX_CACHE_OPTIONS`, `BYMAX_CACHE_CONNECTION`, `BYMAX_CACHE_SCRIPT_REGISTRY`, `BYMAX_CACHE_EVENTS`, `BYMAX_CACHE_SERIALIZER`, `BYMAX_CACHE_KEY_BUILDER`); all providers are singletons. The module is built with `ConfigurableModuleBuilder` and registers globally by default via the `isGlobal` extra (which sets `DynamicModule.global`) — there is no `@Global()` decorator.
416
+
417
+ ### Design Principles
418
+
419
+ | Principle | Description |
420
+ | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
421
+ | **🔑 Structural Isolation** | Every key and channel is composed by `KeyBuilder` — namespacing is enforced by the type of the API, not by a convention a caller can forget |
422
+ | **🚪 Fail Closed** | A payload that cannot be decoded throws; it never degrades into `undefined` or a partially-typed object, because a silently wrong cache read is worse than a miss |
423
+ | **🔌 Interface-Driven** | `ISerializer` and `ICacheEvents` are contracts — MessagePack, a metrics sink, or a logger is a consumer implementation, never a dependency of this package |
424
+ | **🪶 Singleton Connection** | One `ioredis` client per module, owned by `ConnectionManager` with `OnModuleInit` / `OnModuleDestroy` — no `Scope.REQUEST`, no per-call connection churn |
425
+ | **🌳 Zero Runtime Deps** | `"dependencies": {}` — every package arrives as a peer dependency, so consumers pin exact versions and the supply-chain surface stays theirs |
426
+ | **🧭 Explicit Escape Hatch** | `getClient()` exists, is documented, and is an anti-pattern — un-namespaced access is possible on purpose, and named so a reviewer sees it |
427
+
428
+ ---
429
+
430
+ ## 🔐 Security Model
431
+
432
+ A cache sits between your application and every value it has ever computed, so the security posture is about two things: one tenant's data never being reachable from another tenant's key, and a hostile or corrupt payload never being handed back as a valid object.
433
+
434
+ ### Namespace isolation is structural
435
+
436
+ 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.
437
+
438
+ The namespace itself is validated at module bootstrap, not at call time:
439
+
440
+ | Violation | Result |
441
+ | --------------------------------------- | ---------------------------------------------------------------- |
442
+ | Empty or whitespace-only namespace | `CacheException(INVALID_NAMESPACE)` — the module fails to start |
443
+ | Namespace containing the key separator | `CacheException(INVALID_NAMESPACE)` — prevents prefix collisions |
444
+ | Empty `prefix` or `id` at the call site | `CacheException(INVALID_KEY)` — no key is sent to Redis |
445
+
446
+ 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.
447
+
448
+ ### Deserialization fails closed
449
+
450
+ `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.
451
+
452
+ Serialization is symmetric: a top-level `undefined`, function, or `symbol` is rejected up front, because `JSON.stringify` returns the JS value `undefined` for those **without throwing**, which would otherwise escape the try/catch and break the `string` return contract.
453
+
454
+ ### Secrets stay out of error payloads
455
+
456
+ Error `details` are built to be safe to log:
457
+
458
+ - A malformed `connection.url` throws `CONNECTION_FAILED` with `reason: 'invalid connection.url'` — the URL is omitted, because it may embed a password.
459
+ - `SERIALIZATION_FAILED` carries the encoder's message, never the value being encoded.
460
+ - `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.
461
+
462
+ ### Destructive operations are guarded in production
463
+
464
+ `flushNamespace()` throws `CacheException(FLUSH_DISABLED_IN_PRODUCTION)` when `NODE_ENV === 'production'` unless `allowFlushInProduction` is explicitly set. When it does run, it iterates with `SCAN` scoped to `{namespace}{separator}*` and removes keys with `UNLINK` (asynchronous reclaim), so it neither touches another namespace nor blocks the server on a large keyset.
465
+
466
+ ### Lua scripts are registered, never interpolated
467
+
468
+ Scripts are declared up front — through `options.scripts` or `ScriptManagerService.register(name, lua)` — and executed **by name**. A call site passes `eval(scriptName, keys, args)`; it has no way to pass a script body. Keys are namespaced before execution and arguments arrive as Redis `ARGV[]`, which Lua treats as data, so request input cannot become script source. Standalone and Sentinel use `EVALSHA` with a `NOSCRIPT` reload-and-retry; Cluster sends the full body via `EVAL`, because `EVALSHA` routes by key slot and a keyless reload would not reach the node that reported `NOSCRIPT`.
469
+
470
+ ### Cluster mode refuses commands it cannot honor safely
471
+
472
+ `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.
473
+
474
+ ### Security Checklist
475
+
476
+ When integrating `@bymax-one/nest-cache` in production, verify each of the following:
477
+
478
+ - `namespace` is distinct per tenant or per application — it is the isolation boundary, and a shared value makes every other guarantee moot
479
+ - `allowFlushInProduction` stays unset; if it is on, the reason belongs in a security review
480
+ - `getClient()` and `pipeline()` call sites are audited — they are the only paths that bypass namespacing
481
+ - Connection credentials come from the environment (`config.getOrThrow('REDIS_URL')`), never from a URL literal in module options in source control
482
+ - `rediss://` (or an explicit `connection.tls`) is used for any Redis reachable off-host
483
+ - 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
484
+ - Cached entries carry a TTL sized to your data-retention policy; namespacing bounds who can read an entry, not how long it exists
485
+ - Custom `ISerializer` implementations throw on malformed input rather than returning a fallback value
486
+
487
+ ---
488
+
489
+ ## 🛡️ Security Table
490
+
491
+ | Layer | Implementation |
492
+ | --------------------- | --------------------------------------------------------------------------------------------------------------------------- |
493
+ | Tenant Isolation | Every key and channel composed by `KeyBuilder` as `{namespace}{sep}{prefix}{sep}{id}` — no bare-key path in the API |
494
+ | Namespace Validation | Empty or separator-containing namespace rejected at bootstrap (`INVALID_NAMESPACE`) |
495
+ | Key Validation | Empty `prefix` / `id` rejected before the command is issued (`INVALID_KEY`) |
496
+ | Deserialization | Fails closed — `DESERIALIZATION_FAILED`; never a partial or wrongly-typed value |
497
+ | Serialization | Top-level `undefined` / function / symbol rejected; the value is never echoed into `details` |
498
+ | Error Payloads | Connection URLs omitted (may embed credentials); raw payload previews truncated to 100 characters |
499
+ | Destructive Ops | `flushNamespace()` blocked under `NODE_ENV=production` unless `allowFlushInProduction`; `SCAN` + `UNLINK`, namespace-scoped |
500
+ | Lua Execution | Scripts registered by name; call sites pass keys/args only — request input never reaches a script body |
501
+ | Cluster Safety | `scan` / `flushNamespace` / `getClient` throw `UNSUPPORTED_IN_CLUSTER` instead of acting on a single node |
502
+ | Transport | `rediss://` sets TLS on the client; explicit `connection.tls` supported for custom CA / mTLS |
503
+ | Connection Resilience | Bounded `maxRetriesPerRequest`, `READONLY`-failover reconnect, graceful `quit()` with a shutdown timeout |
504
+ | Supply Chain | `"dependencies": {}` — no transitive runtime packages of the library's own choosing; published with npm provenance |
505
+
506
+ > [!IMPORTANT]
507
+ > 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.
508
+
509
+ ---
510
+
511
+ ## 🧱 Tech Stack
512
+
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)
515
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.9-3178C6?style=flat-square&logo=typescript&logoColor=white)](https://www.typescriptlang.org)
516
+ [![Node.js](https://img.shields.io/badge/Node.js-24-339933?style=flat-square&logo=node.js&logoColor=white)](https://nodejs.org)
517
+ [![Jest](https://img.shields.io/badge/Jest-30-C21325?style=flat-square&logo=jest)](https://jestjs.io)
518
+ [![Stryker](https://img.shields.io/badge/Stryker-Mutation_Testing-red?style=flat-square)](https://stryker-mutator.io)
519
+ [![pnpm](https://img.shields.io/badge/pnpm-10-F69220?style=flat-square&logo=pnpm&logoColor=white)](https://pnpm.io)
520
+ [![tsup](https://img.shields.io/badge/tsup-8-orange?style=flat-square)](https://tsup.egoist.dev)
521
+
522
+ ---
523
+
524
+ ## 🧪 Testing & Quality
525
+
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
+
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**
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
+ - ✅ **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
+ - ✅ **Published-package smoke test** — `scripts/dogfood-smoke-test.mjs` validates exports, tarball shape, and a consumer install before tagging
533
+
534
+ ```bash
535
+ pnpm test # unit tests (Jest)
536
+ pnpm test:e2e # end-to-end tests (@nestjs/testing + Testcontainers)
537
+ pnpm test:cov:all # full coverage gate (100% statements/branches/functions/lines)
538
+ pnpm mutation # Stryker mutation testing (95% break gate)
539
+ pnpm typecheck # tsc strict check
540
+ pnpm lint # ESLint
541
+ ```
542
+
543
+ > [!NOTE]
544
+ > Line coverage proves a line _executed_ under test; mutation testing proves a test _would fail_ if that line were wrong. The full methodology and per-area breakdown are in [docs/mutation_testing_results.md](./docs/mutation_testing_results.md).
545
+
546
+ ---
547
+
548
+ ## 📖 API Reference
549
+
550
+ ### `CacheService`
551
+
552
+ | Group | Methods |
553
+ | --------------- | ------------------------------------------------------ |
554
+ | Strings | `get<T>` · `getRaw` · `set<T>` · `setRaw` · `setNx<T>` |
555
+ | Delete / exists | `del` · `delMany` · `exists` |
556
+ | TTL | `ttl` · `expire` · `persist` |
557
+ | Numbers | `incr` · `decr` |
558
+ | Batch | `mget<T>` · `mset<T>` |
559
+ | Hashes | `hget<T>` · `hset<T>` · `hgetall<T>` · `hdel` |
560
+ | Sets | `sadd` · `srem` · `smembers` · `sismember` · `scard` |
561
+ | Iteration | `keys` (avoid in prod) · `scan` (cursor) |
562
+ | Scripts | `eval` |
563
+ | Escape hatch | `pipeline` · `getClient` |
564
+ | Namespace | `flushNamespace` (prod-guarded) |
565
+ | Health | `isHealthy` · `ping` · `info` |
566
+
567
+ ### `PubSubService`
568
+
569
+ `publish<T>(channel, message)` · `subscribe<T>(channel, handler)` · `psubscribe<T>(pattern, handler)`
570
+
571
+ ### `ScriptManagerService`
572
+
573
+ `register(name, lua)` · `load(name)` · `eval(name, keys, args)`
574
+
575
+ ### Errors
576
+
577
+ `CacheException` (extends `HttpException`) + `CACHE_ERROR_CODES` (namespaced `cache.*`).
578
+
579
+ ---
580
+
581
+ ## 🪪 Default Error Codes
582
+
583
+ All errors are instances of `CacheException` and carry a stable `code` string from `CACHE_ERROR_CODES`:
584
+
585
+ | Code | HTTP | When thrown |
586
+ | ------------------------------------ | ---- | --------------------------------------------------------------- |
587
+ | `cache.connection_failed` | 500 | Cannot connect after retries |
588
+ | `cache.command_timeout` | 504 | Command exceeded `commandTimeout` |
589
+ | `cache.connection_lost` | 503 | Connection dropped during an in-flight operation |
590
+ | `cache.deserialization_failed` | 500 | Malformed payload in `get<T>` |
591
+ | `cache.serialization_failed` | 500 | Unserializable value in `set<T>` |
592
+ | `cache.invalid_key` | 400 | Empty prefix or id passed to `build` / `applyNamespace` |
593
+ | `cache.invalid_namespace` | 500 | Empty or separator-containing namespace |
594
+ | `cache.script_not_registered` | 500 | `eval(name)` before `register(name)` |
595
+ | `cache.script_execution_failed` | 500 | Lua runtime error or NOSCRIPT retry failure |
596
+ | `cache.script_registry_missing` | 500 | `eval` called when no `ScriptManagerService` is wired |
597
+ | `cache.flush_disabled_in_production` | 403 | `flushNamespace()` in prod without `allowFlushInProduction` |
598
+ | `cache.unsupported_in_cluster` | 500 | `scan`, `flushNamespace`, or `getClient` called in cluster mode |
599
+ | `cache.cluster_misconfigured` | 500 | `mode: 'cluster'` without `cluster.nodes` |
600
+ | `cache.sentinel_misconfigured` | 500 | `mode: 'sentinel'` without `sentinel.sentinels`/`name` |
601
+ | `cache.shutdown_timeout` | 500 | `quit()` exceeded `shutdownTimeoutMs` |
602
+
603
+ Full catalog and HTTP status mapping: [`docs/technical_specification.md §12`](./docs/technical_specification.md).
604
+
605
+ ---
606
+
607
+ ## 🚫 What This Library Does NOT Do
608
+
609
+ Reliable atomic primitives are in scope; opinionated policies are not. By design, the following are out of scope:
610
+
611
+ - ❌ **Rate limiting** — compose `incr` + `expire`, a custom Lua script, or a future `@bymax-one/nest-rate-limit`
612
+ - ❌ **Distributed locks** — `setNx` + a release script covers the common case; a future `@bymax-one/nest-lock` would own the edge cases
613
+ - ❌ **BullMQ wiring** — [`@bymax-one/nest-queue`](https://github.com/bymaxone/nest-queue) owns its own connection
614
+ - ❌ **Cache-aside / read-through patterns** — that policy belongs in your repositories, not in the client
615
+ - ❌ **Compression and at-rest encryption** — implement a custom `ISerializer`
616
+ - ❌ **Tag-based invalidation** — namespaces and prefixes are the invalidation unit
617
+ - ❌ **Redis Streams** — a different consumption model than Pub/Sub; out of scope for this package
618
+
619
+ See §13 of the [technical specification](./docs/technical_specification.md) for the rationale.
620
+
621
+ ---
622
+
623
+ ## 🤝 Contributing
624
+
625
+ Contributions are welcome. Development follows the Bymax coding standards:
626
+
627
+ - TypeScript strict (`noImplicitAny`, `exactOptionalPropertyTypes`, `noUncheckedIndexedAccess`)
628
+ - TDD with a 100% coverage gate and a 95% mutation break threshold
629
+ - Conventional Commits enforced by commitlint + husky
630
+ - No direct dependencies — peer deps only
631
+ - All boolean identifiers prefixed with `is / has / should / can`
632
+
633
+ ```bash
634
+ # Clone the repository
635
+ git clone https://github.com/bymaxone/nest-cache.git
636
+ cd nest-cache
637
+
638
+ # Install dependencies
639
+ pnpm install
640
+
641
+ # Run tests
642
+ pnpm test
643
+
644
+ # Build
645
+ pnpm build
646
+
647
+ # Type check
648
+ pnpm typecheck
649
+ ```
650
+
651
+ Run the full gate before opening a PR:
652
+
653
+ ```bash
654
+ pnpm typecheck && pnpm lint && pnpm test:cov:all && pnpm build && pnpm size
655
+ ```
656
+
657
+ See [CONTRIBUTING.md](./CONTRIBUTING.md) and [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md) for the full process.
658
+
659
+ ---
660
+
661
+ ## 🔒 Security Policy
662
+
663
+ If you discover a security vulnerability, please **do not** open a public issue. Instead, email us at **support@bymax.one** with details. We take security seriously and will respond promptly.
664
+
665
+ See [SECURITY.md](./SECURITY.md) for the private reporting process, supported versions, and the threat model (cache poisoning, key injection, unsafe deserialization, production flush guard, Lua injection).
666
+
667
+ ---
668
+
669
+ ## 📄 License
670
+
671
+ [MIT](./LICENSE) © [Bymax One](https://github.com/bymaxone)
672
+
673
+ ---
674
+
675
+ <p align="center">
676
+ <sub>Built with ❤️ by <a href="https://github.com/bymaxone">Bymax One</a></sub>
677
+ </p>