@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/CHANGELOG.md +38 -0
- package/LICENSE +21 -0
- package/README.md +677 -0
- package/dist/server/index.cjs +1653 -0
- package/dist/server/index.d.cts +1296 -0
- package/dist/server/index.d.ts +1296 -0
- package/dist/server/index.mjs +1642 -0
- package/dist/shared/index.cjs +33 -0
- package/dist/shared/index.d.cts +114 -0
- package/dist/shared/index.d.ts +114 -0
- package/dist/shared/index.mjs +30 -0
- package/package.json +123 -0
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
|
+
[](https://nestjs.com)
|
|
514
|
+
[](https://github.com/redis/ioredis)
|
|
515
|
+
[](https://www.typescriptlang.org)
|
|
516
|
+
[](https://nodejs.org)
|
|
517
|
+
[](https://jestjs.io)
|
|
518
|
+
[](https://stryker-mutator.io)
|
|
519
|
+
[](https://pnpm.io)
|
|
520
|
+
[](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>
|