@bymax-one/nest-cache
v1.2.2
Published
Typed Redis cache for NestJS based on ioredis 6, with namespace strategy, Pub/Sub and Lua script management.
Readme
✨ Overview
@bymax-one/nest-cache wraps a single, correctly-managed ioredis connection behind a typed
NestJS module. Instead of scattering raw Redis calls across your services, you get a namespaced,
serializer-backed API with first-class Pub/Sub and atomic Lua scripting — and a connection whose
lifecycle, reconnection, and graceful shutdown are handled for you.
The library has zero direct dependencies — ioredis and NestJS arrive as peer dependencies,
so you control exact versions and the supply-chain surface stays minimal.
Why nest-cache?
- 🔑 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. - 🧬 Typed get/set —
get<T>/set<T>go through a pluggableISerializer(JSON by default). Deserialization fails closed — a malformed payload throwsCacheException, never a half-decoded value. - 📡 Batteries included — Pub/Sub on namespaced channels and a Lua script manager (
EVALSHA+NOSCRIPTfallback) ship in the box, on top of the full string/hash/set/numeric command surface. - ♻️ Lifecycle done right — singleton connection with
OnModuleInit/OnModuleDestroy, bounded retry strategy,READONLY-failover reconnect, and a gracefulquit()with shutdown timeout. - 🔌 Bring your own observability — connection events surface through an
events.onEventcallback; plug in@bymax-one/nest-loggeror your metrics layer. No observability peer deps forced on you.
pnpm add @bymax-one/nest-cache ioredis🔥 Features
🧬 Typed Cache API
- ✅ Typed get/set —
get<T>/set<T>/setNx<T>/mget<T>/mset<T>through a pluggable serializer - ✅ Full command surface — strings, numbers (
incr/decr), hashes, sets, TTL (expire/ttl/persist), iteration (scan) - ✅ Pluggable serialization —
ISerializercontract; swap JSON for MessagePack, CBOR, or your own codec - ✅ Raw string access —
getRaw/setRawskip the serializer while keeping namespacing;pipeline/getClientare the documented escape hatches
🔑 Isolation & Namespacing
- ✅ Automatic namespacing — the key builder enforces tenant/feature isolation; no manual string concatenation
- ✅ Namespaced channels — Pub/Sub channels are composed through the same builder as keys
- ✅ Surgical flush —
flushNamespace()scans only{namespace}:*, never another namespace's keys - ✅ Validated at bootstrap — an empty namespace, or one containing the key separator, fails module initialization
⚙️ Reliability & Topology
- ✅ Multi-topology — standalone, Sentinel, and Cluster modes from the same options shape
- ✅ Managed lifecycle — singleton connection via
OnModuleInit/OnModuleDestroy, gracefulquit()with a shutdown timeout - ✅ Bounded retries —
maxRetriesPerRequestand a reconnect policy that triggers onREADONLYreplica failover - ✅ Connection events —
connect/ready/error/close/reconnecting/endsurfaced viaevents.onEvent - ✅ Health checks —
isHealthy()/ping()/info()for readiness and liveness endpoints
🛡️ Safety
- ✅ Fail-closed serialization — malformed payloads raise
CacheException(DESERIALIZATION_FAILED), never a partial value - ✅ Production flush guard —
flushNamespace()is blocked underNODE_ENV=productionunless explicitly allowed - ✅ Secrets never echoed — connection URLs and cached values are kept out of error
details; previews are truncated - ✅ Script bodies are registered, not interpolated — call sites pass a name plus keys/args, never Lua source
🧩 Developer Experience
- ✅ Dynamic module —
forRoot()andforRootAsync()viaConfigurableModuleBuilder; registered globally by default (isGlobal) - ✅ Lua script manager — register scripts up front, execute by name with transparent
NOSCRIPTreload + retry - ✅ Structured errors — every failure is a
CacheExceptionwith a stablecache.*code and an HTTP status - ✅ Zero runtime dependencies — everything is a peer dependency;
dependencies: {}
📦 Subpath Exports
One package, three entry points — import only what your app needs:
| Subpath | Import | Purpose | Dependencies |
| ---------- | ------------------------------ | -------------------------------------------------------------------------------------------------------- | :------------------------------------: |
| Server | @bymax-one/nest-cache | BymaxCacheModule, CacheService, PubSubService, ScriptManagerService, DI tokens, CacheException | NestJS 11, ioredis 6, reflect-metadata |
| Admin | @bymax-one/nest-cache/admin | Read-only administration — health, INFO statistics, keyspace listing, key inspection, value reveal | NestJS 11, ioredis 6, the server entry |
| Shared | @bymax-one/nest-cache/shared | Types + constants — CACHE_ERROR_CODES, CacheEventName, config types | None |
shared (zero deps)
↑
server ← admin/admin is a privileged surface and is kept out of the main entry on purpose: importing it is a greppable, reviewable act, a consumer who never wires it cannot resolve a reveal service from DI by accident, and it never lands in the main bundle.
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.
[!TIP] Prefer to learn from a working app? See the nest-cache-example — a full NestJS project wired with this library.
🚀 Quick Start
1. Install
# Using pnpm (recommended)
pnpm add @bymax-one/nest-cache ioredis
# Using npm
npm install @bymax-one/nest-cache ioredis
# Using yarn
yarn add @bymax-one/nest-cache ioredis[!IMPORTANT]
@nestjs/common,@nestjs/core, andreflect-metadataare peer dependencies (already present in any NestJS app).ioredisis the single functional peer — the Redis client itself.
2. Register the Module
Pick the topology that matches your deployment. All four forms share the same options shape.
Scenario 1 — Standalone (dev / single node)
import { Module } from '@nestjs/common'
import { BymaxCacheModule } from '@bymax-one/nest-cache'
@Module({
imports: [
BymaxCacheModule.forRoot({
connection: { url: 'redis://localhost:6379' },
namespace: 'app'
})
]
})
export class AppModule {}Scenario 2 — Sentinel (high availability)
BymaxCacheModule.forRoot({
mode: 'sentinel',
sentinel: {
sentinels: [
{ host: 'sentinel1.example.com', port: 26379 },
{ host: 'sentinel2.example.com', port: 26379 }
],
name: 'mymaster',
password: process.env.REDIS_PASSWORD
},
namespace: 'app'
})Scenario 3 — Cluster (sharded)
BymaxCacheModule.forRoot({
mode: 'cluster',
cluster: {
nodes: [
{ host: 'cluster1.example.com', port: 7000 },
{ host: 'cluster2.example.com', port: 7001 },
{ host: 'cluster3.example.com', port: 7002 }
]
},
namespace: 'app'
})Scenario 4 — forRootAsync with ConfigService
import { Module } from '@nestjs/common'
import { ConfigModule, ConfigService } from '@nestjs/config'
import { BymaxCacheModule } from '@bymax-one/nest-cache'
@Module({
imports: [
BymaxCacheModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
connection: { url: config.getOrThrow<string>('REDIS_URL') },
namespace: 'app',
events: {
// Plug @bymax-one/nest-logger or a metrics sink here
onEvent: (event, data) => console.log(`[cache] ${event}`, data)
}
})
})
]
})
export class AppModule {}3. Inject CacheService
The module is global by default, so no re-import is needed in feature modules:
import { Injectable } from '@nestjs/common'
import { CacheService } from '@bymax-one/nest-cache'
@Injectable()
export class ProfileService {
constructor(private readonly cache: CacheService) {}
async getProfile(userId: string): Promise<Profile | null> {
const cached = await this.cache.get<Profile>('user-profile', userId)
if (cached) return cached
const profile = await this.repo.findProfile(userId)
await this.cache.set('user-profile', userId, profile, 3600) // TTL in seconds
return profile
}
}Keys resolve to app:user-profile:<userId> — namespaced automatically.
⚙️ Configuration
| Option | Type | Default | Description |
| ------------------- | ----------------------------------------- | ---------------- | ------------------------------------------------------------------ |
| mode | 'standalone' \| 'sentinel' \| 'cluster' | 'standalone' | Redis topology |
| connection.url | string | — | redis:// / rediss:// URL (overrides discrete host/port fields) |
| connection.tls | tls.ConnectionOptions | — | TLS options for rediss:// |
| namespace | string | 'app' | Key prefix for tenant/feature isolation |
| serializer | ISerializer | JsonSerializer | Value encoding/decoding (plug MsgPack, CBOR, etc.) |
| events.onEvent | (event, data) => void | — | Connection-event hook (plug a logger or metrics) |
| scripts | IScriptDefinition[] | [] | Lua scripts to preload on init |
| shutdownTimeoutMs | number | 5000 | Graceful quit() timeout before forced disconnect() |
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.
🔑 Key Namespacing
Every key is composed as {namespace}{separator}{prefix}{separator}{id} (default separator :).
Calling cache.get('user-profile', '42') under namespace app reads app:user-profile:42. This
keeps tenants and features isolated and makes flushNamespace() surgical. Reaching for
getClient() to set raw, un-namespaced keys is supported as an escape hatch but documented as an
anti-pattern.
📡 Pub/Sub
const unsubscribe = await pubsub.subscribe<UserEvent>('user-events', async (msg) => {
await handle(msg)
})
await pubsub.publish<UserEvent>('user-events', { type: 'created', id: '42' })
// ...later
await unsubscribe()Channels are namespaced like keys. The subscriber connection is created lazily on the first subscription. Redis Pub/Sub is fire-and-forget — messages published while a subscriber is offline are not replayed.
📜 Lua Scripts
Register scripts at module init, then execute them atomically by name. The manager caches the
SHA1 and uses EVALSHA, transparently reloading on NOSCRIPT:
// In module options:
scripts: [{ name: 'compareAndSet', lua: '...' }]
// At call site — keys are flat strings passed directly to Lua's KEYS[] table.
// CacheService prepends the namespace via applyNamespace(), so 'lock:job'
// becomes 'app:lock:job' in Redis. Pass the full suffix as a single string.
const ok = await cache.eval('compareAndSet', ['lock:job'], [expected, next])🔁 Custom Serializer
Swap the default JsonSerializer with any ISerializer implementation — MsgPack, CBOR, or your own:
import { encode, decode } from '@msgpack/msgpack'
import type { ISerializer } from '@bymax-one/nest-cache'
class MsgPackSerializer implements ISerializer {
serialize<T>(value: T): string {
return Buffer.from(encode(value)).toString('base64')
}
deserialize<T>(raw: string): T {
return decode(Buffer.from(raw, 'base64')) as T
}
}
// In module options:
BymaxCacheModule.forRoot({
connection: { url: 'redis://localhost:6379' },
serializer: new MsgPackSerializer()
})🔗 Plug with @bymax-one/nest-logger
Wire connection events into your logger via the events.onEvent hook:
import { BymaxOneLogger } from '@bymax-one/nest-logger'
BymaxCacheModule.forRootAsync({
imports: [ConfigModule, LoggerModule],
inject: [ConfigService, BymaxOneLogger],
useFactory: (config: ConfigService, logger: BymaxOneLogger) => ({
connection: { url: config.getOrThrow('REDIS_URL') },
namespace: 'app',
events: {
onEvent: (event, data) => {
if (event === 'error') logger.error('[cache]', data)
else logger.log(`[cache] ${event}`, data)
}
}
})
})❤️ Health Check (terminus integration)
import { Controller, Get } from '@nestjs/common'
import { HealthCheck, HealthCheckService } from '@nestjs/terminus'
import { CacheService } from '@bymax-one/nest-cache'
@Controller('health')
export class HealthController {
constructor(
private readonly health: HealthCheckService,
private readonly cache: CacheService
) {}
@Get()
@HealthCheck()
check() {
return this.health.check([
() =>
this.cache
.isHealthy()
.then((ok) =>
ok ? { redis: { status: 'up' } } : Promise.reject(new Error('Redis not ready'))
)
])
}
}🔎 Administration surface (/admin)
A read-only surface for an operator-facing console: is the cache answering, what is it doing, what is in it.
import { BymaxCacheModule } from '@bymax-one/nest-cache'
import { BymaxCacheAdminModule } from '@bymax-one/nest-cache/admin'
// `config` is a ConfigService reachable where the module is declared; see
// Scenario 4 above for the forRootAsync form that injects it properly.
@Module({
imports: [
BymaxCacheModule.forRoot({
connection: { url: config.getOrThrow<string>('REDIS_URL') },
namespace: 'my-app'
}),
BymaxCacheAdminModule.forRoot({
scopes: [
{
id: 'cache',
label: 'Application cache',
pattern: 'my-app:*',
isReadable: true,
origin: "the application's own namespace, written through the typed API"
},
{
id: 'auth',
label: 'Authentication',
pattern: 'auth:*',
isReadable: false,
origin:
'written by another library through the un-namespaced client, so it sits at Redis ' +
'root. Values are refused: this keyspace holds session records.'
}
]
})
]
})
export class AppModule {}constructor(
@Inject(CacheStatusService) private readonly status: CacheStatusService,
@Inject(CacheAdminService) private readonly admin: CacheAdminService
) {}
await this.status.health() // { status: 'up', latencyMs: 3, mode, isScanSupported, degradedAboveMs }
await this.status.stats() // parsed INFO
this.status.config() // resolved wiring, connection URL withheld
this.admin.listScopes() // never touches the connection
await this.admin.listKeys('cache', { includeSize: true })
await this.admin.revealValue('auth', 'auth:sess:1') // { status: 'withheld', origin }What the library owns, and what you own
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.
isReadable: false withholds the value — and nothing else
Listing, types, TTLs and sizes stay available on an unreadable scope. Only the value is refused, and the refusal is returned before the value is read.
This is the easy thing to get wrong, because "unreadable" reads like "return nothing" — and the unreadable scope is typically the one that holds the most interesting keys. A surface that renders it as empty tells an operator the region holds nothing while it is full, which is the same defect as a blank log page during an outage: a reading meaning "I may not tell you" drawn identically to one meaning "there is nothing here".
Scope patterns: a literal prefix, optionally ending in *
auth:*, my-app:* and exact keys are accepted. app:*:v1, *, a?b and a[bc] are refused at wiring.
The restriction exists because a caller names a key, so the library must decide whether that key belongs to the named scope — otherwise a caller names the readable scope and passes a key from the credential-bearing one. Deciding that for arbitrary globs means reimplementing Redis's stringmatchlen — greedy * with backtracking, [a-z] classes, ^ negation, escapes, and the unterminated-class case where ten[ant matches nothing at all — and a matcher even slightly more permissive than the server's is a silent cross-scope leak that no happy-path test would show. With this shape, membership is exact by construction, and the E2E suite checks it differentially against a real server's own KEYS.
Do not relax this to be helpful. Widening it later is compatible; a leak is not un-shippable.
Readings that would carry two meanings are unions, not nullables
| Reading | Type | Why |
| ------------ | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| maxmemory | unbounded \| limited \| unreported | Redis spells "no ceiling" as maxmemory:0; read as a literal ceiling it draws a full saturation bar on the least constrained server there is — and "unbounded" is not "the server didn't say" |
| TTL | expiring \| persistent \| missing | -1 and -2 are different facts, and the key that expired between the scan and the read is the one an operator is watching |
| aofEnabled | boolean \| null | false for an absent field is a durability claim made without evidence |
| health | { up \| degraded, latencyMs } \| { down, reason, code } | A latency exists if and only if the ping answered — expressed as latencyMs: number \| null that is a convention a future catch can break; as a union it does not compile |
mode, isScanSupported and degradedAboveMs sit outside the health union: a cluster deployment that is down should still report that scanning was never going to work.
Reading a value: revealed / withheld / missing
revealValue() answers with a discriminated union, not a nullable value, because "I may not tell you" and "there is nothing here" are different answers and rendering them identically is the defect this whole surface exists to avoid.
import type { CacheAdminService } from '@bymax-one/nest-cache/admin'
async function describeValue(admin: CacheAdminService, scope: string, key: string) {
const result = await admin.revealValue(scope, key)
switch (result.status) {
case 'revealed':
// `result.type` is the Redis type; `result.value` is shaped by it.
return { type: result.type, value: result.value }
case 'withheld':
// The scope declares `isReadable: false`. `origin` explains why, verbatim.
// Listing, types, TTLs and sizes for this key remain available.
return { refusedBecause: result.origin }
case 'missing':
return { gone: true }
}
}A withheld value is not an authorization failure. The caller is allowed to ask; the deployment declared the keyspace unreadable at wiring, and no credential changes that — so serving it as 403 would tell a client that some other permission would unlock it, which is false.
The revealed value is shaped by the key's type:
| value.kind | Shape | Notes |
| ------------- | ---------------------------------- | ---------------------------------------------------------------------------------------- |
| string | { value: string } | Truncated to revealStringLimit |
| hash | { fields: { field, value }[] } | field, matching Redis's own vocabulary (HSET key field value) |
| members | { members: string[] } | Sets and lists both. The enclosing type says which — see the ordering caveat below |
| scored | { members: { member, score }[] } | Sorted sets. score is a string: parsing to a number would round large integers |
| unsupported | { type: RedisKeyType } | Streams and module types — says so, rather than returning an empty value |
Every arm except unsupported carries isComplete. It is deliberately positive polarity: an absent boolean reads as false, and under a name like truncated that default would be the reassuring answer — a value silently claiming nothing was cut.
[!IMPORTANT] List order is meaningful; set order is not, and both arrive in the same
membersarray. Nothing in the type stops a renderer from sorting either one. Sorting a set for display is fine; sorting a list is a lie about the data — anLPUSHqueue shown alphabetically misreports what pops next. Gate any client-side ordering ontype === 'set'.
Costs the surface does not hide
sampledCount/sampledBytesare sums over a cappedSCAN, not measurements of the keyspace.isCompleteis the fact; the names are the guard.- A page may carry slightly more than
scanLimitentries.SCANreturns whole batches and the cursor has already moved past them, so the limit stops the loop rather than trimming the result — trimming would drop keys no later page could reach. - Sizing is opt-in (
includeSize), and every pipeline batch is bounded in commands, not keys. Redis is single-threaded, so a pipeline converts a network cost into a server-blocking one: one flush of N keys × 3 commands blocks every other client for the whole burst, on a server someone is inspecting precisely because it is unwell. connection.urlis never on the wire. The config payload carries host, port and a TLS flag; the URL is never read into the admin subpath at all.mem_fragmentation_ratiois reported raw. On an instance holding very little, allocator and copy-on-write overhead dominate and the figure reads far above 1 without indicating a problem — 9.07 was measured on an instance holding 1.1 MiB. Turning it into a verdict is deployment policy.
Cluster
Every scan-based operation throws UNSUPPORTED_IN_CLUSTER, inherited from CacheService.getClient() rather than restated. isScanSupported travels on the health payload so a console never has to re-derive that rule from mode.
🏗️ Architecture
The package runs inside your NestJS application as a dynamic module — not as a separate service:
┌─────────────────────────────────────────────────────┐
│ Your NestJS Application │
│ │
│ ┌───────────────────────────────────────────────┐ │
│ │ @bymax-one/nest-cache │ │
│ │ │ │
│ │ CacheService ←→ ConnectionManager ←→ Redis │ │
│ │ PubSubService ←→ lazy subscriber conn │ │
│ │ ScriptManagerService ←→ EVALSHA + NOSCRIPT │ │
│ │ KeyBuilder → {namespace}:{prefix}:{id} │ │
│ └─────────────┬─────────────────┬───────────────┘ │
│ │ │ │
│ ┌───────▼──────┐ ┌───────▼──────┐ │
│ │ ISerializer │ │ ICacheEvents │ │
│ │ (yours) │ │ (yours) │ │
│ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────┘Both consumer-facing contracts are optional: omit serializer and you get JsonSerializer; omit events.onEvent and connection events are simply not forwarded anywhere.
DI tokens are Symbols (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.
Design Principles
| Principle | Description |
| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 🔑 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 |
| 🚪 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 |
| 🔌 Interface-Driven | ISerializer and ICacheEvents are contracts — MessagePack, a metrics sink, or a logger is a consumer implementation, never a dependency of this package |
| 🪶 Singleton Connection | One ioredis client per module, owned by ConnectionManager with OnModuleInit / OnModuleDestroy — no Scope.REQUEST, no per-call connection churn |
| 🌳 Zero Runtime Deps | "dependencies": {} — every package arrives as a peer dependency, so consumers pin exact versions and the supply-chain surface stays theirs |
| 🧭 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 |
🔐 Security Model
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.
Namespace isolation is structural
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.
The namespace itself is validated at module bootstrap, not at call time:
| Violation | Result |
| --------------------------------------- | ---------------------------------------------------------------- |
| Empty or whitespace-only namespace | CacheException(INVALID_NAMESPACE) — the module fails to start |
| Namespace containing the key separator | CacheException(INVALID_NAMESPACE) — prevents prefix collisions |
| Empty prefix or id at the call site | CacheException(INVALID_KEY) — no key is sent to Redis |
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.
Deserialization fails closed
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.
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.
Secrets stay out of error payloads
Error details are built to be safe to log:
- A malformed
connection.urlthrowsCONNECTION_FAILEDwithreason: 'invalid connection.url'— the URL is omitted, because it may embed a password. SERIALIZATION_FAILEDcarries the encoder's message, never the value being encoded.DESERIALIZATION_FAILEDcarries apreviewof 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.
Destructive operations are guarded in production
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.
Lua scripts are registered, never interpolated
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.
The namespace cannot widen a destructive pattern
validateOptions rejects a namespace containing a Redis glob metacharacter (*, ?, [, \). The namespace is composed into flushNamespace()'s match pattern, so a metacharacter there is not cosmetic — measured against Redis 8.10.0, * and ? widen the pattern into other keyspaces, \ escapes into a different one while sparing its own keys, and [ opens a character class that never closes so the pattern matches nothing and the flush reports success having removed no keys. ] is accepted: measured to be a literal that neither widens nor silences. This matters most when the namespace is derived from input — multi-tenant wiring using a tenant slug reads like isolation and would otherwise be one unsanitised character from a cross-tenant delete.
Cluster mode refuses commands it cannot honor safely
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.
Security Checklist
When integrating @bymax-one/nest-cache in production, verify each of the following:
namespaceis distinct per tenant or per application — it is the isolation boundary, and a shared value makes every other guarantee mootallowFlushInProductionstays unset; if it is on, the reason belongs in a security reviewgetClient()andpipeline()call sites are audited — they are the only paths that bypass namespacing- Connection credentials come from the environment (
config.getOrThrow('REDIS_URL')), never from a URL literal in module options in source control rediss://(or an explicitconnection.tls) is used for any Redis reachable off-host- 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 - Cached entries carry a TTL sized to your data-retention policy; namespacing bounds who can read an entry, not how long it exists
- Custom
ISerializerimplementations throw on malformed input rather than returning a fallback value - If
/adminis wired, its routes are behind the application's own authorization — the library validates scopes and withholds values, it does not authenticate anyone - Any admin scope whose keyspace holds credentials is declared
isReadable: false, and the deployment understands that this withholds the value only — listing, types, TTLs and sizes stay visible by design
🛡️ Security Table
| Layer | Implementation |
| --------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| Tenant Isolation | Every key and channel composed by KeyBuilder as {namespace}{sep}{prefix}{sep}{id} — no bare-key path in the API |
| Namespace Validation | Empty, separator-containing, or glob-metacharacter namespace rejected at bootstrap (INVALID_NAMESPACE) |
| Admin Scope Allowlist | Scopes declared at wiring, validated and frozen; a caller names a scope by id and can never supply a match pattern |
| Admin Read-Only | The /admin subpath issues no mutating command — enforced by the check:admin-readonly build gate, not by convention |
| Key Validation | Empty prefix / id rejected before the command is issued (INVALID_KEY) |
| Deserialization | Fails closed — DESERIALIZATION_FAILED; never a partial or wrongly-typed value |
| Serialization | Top-level undefined / function / symbol rejected; the value is never echoed into details |
| Error Payloads | Connection URLs omitted (may embed credentials); raw payload previews truncated to 100 characters |
| Destructive Ops | flushNamespace() blocked under NODE_ENV=production unless allowFlushInProduction; SCAN + UNLINK, namespace-scoped |
| Lua Execution | Scripts registered by name; call sites pass keys/args only — request input never reaches a script body |
| Cluster Safety | scan / flushNamespace / getClient throw UNSUPPORTED_IN_CLUSTER instead of acting on a single node |
| Transport | rediss:// sets TLS on the client; explicit connection.tls supported for custom CA / mTLS |
| Connection Resilience | Bounded maxRetriesPerRequest, READONLY-failover reconnect, graceful quit() with a shutdown timeout |
| Supply Chain | "dependencies": {} — no transitive runtime packages of the library's own choosing; published with npm provenance |
[!IMPORTANT] The namespace is the isolation boundary. Anything reached through
getClient()orpipeline()sits outside it — those call sites are where cross-tenant reads get introduced.
🧱 Tech Stack
🧪 Testing & Quality
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.
- ✅ 100% line coverage — statements, branches, functions, and lines, enforced by
jest.coverage.config.tsas a pre-publish gate, not a target - ✅ 100% mutation score — verified with Stryker at
break: 100andignoreStatic: false; 441 killed, 6 timed out, 0 survived, and documented in full - ✅ One documented equivalent — the production source carries a single
// Stryker disabledirective, onconfigurable: falseof the withheld connection accessor, genuinely equivalent because the resolved options are frozen on the way out (freezing already makes every property non-configurable);check:mutantsproves it parses and carries its reason, so the score is an accounting rather than a number - ✅ No real Redis in unit tests —
ioredis-mockthroughout; e2e tests exercise the wired module through@nestjs/testingand Testcontainers against a real Redis for connection lifecycle, Pub/Sub, and Lua scripts - ✅ Published-package smoke test —
scripts/dogfood-smoke-test.mjsvalidates exports, tarball shape, and a consumer install before tagging
pnpm test # unit tests (Jest)
pnpm test:e2e # end-to-end tests (@nestjs/testing + Testcontainers)
pnpm test:cov:all # full coverage gate (100% statements/branches/functions/lines)
pnpm mutation # Stryker mutation testing (95% break gate)
pnpm typecheck # tsc strict check
pnpm lint # ESLint[!NOTE] 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.
📖 API Reference
CacheService
| Group | Methods |
| --------------- | ------------------------------------------------------ |
| Strings | get<T> · getRaw · set<T> · setRaw · setNx<T> |
| Delete / exists | del · delMany · exists |
| TTL | ttl · expire · persist |
| Numbers | incr · decr |
| Batch | mget<T> · mset<T> |
| Hashes | hget<T> · hset<T> · hgetall<T> · hdel |
| Sets | sadd · srem · smembers · sismember · scard |
| Iteration | keys (avoid in prod) · scan (cursor) |
| Scripts | eval |
| Escape hatch | pipeline · getClient |
| Namespace | flushNamespace (prod-guarded) |
| Health | isHealthy · ping · info |
PubSubService
publish<T>(channel, message) · subscribe<T>(channel, handler) · psubscribe<T>(pattern, handler)
ScriptManagerService
register(name, lua) · load(name) · eval(name, keys, args)
Errors
CacheException (extends HttpException) + CACHE_ERROR_CODES (namespaced cache.*).
🪪 Default Error Codes
All errors are instances of CacheException and carry a stable code string from CACHE_ERROR_CODES:
| Code | HTTP | When thrown |
| ------------------------------------ | ---- | --------------------------------------------------------------- |
| cache.connection_failed | 500 | Cannot connect after retries |
| cache.command_timeout | 504 | Command exceeded commandTimeout |
| cache.connection_lost | 503 | Connection dropped during an in-flight operation |
| cache.deserialization_failed | 500 | Malformed payload in get<T> |
| cache.serialization_failed | 500 | Unserializable value in set<T> |
| cache.invalid_key | 400 | Empty prefix or id passed to build / applyNamespace |
| cache.invalid_namespace | 500 | Empty or separator-containing namespace |
| cache.script_not_registered | 500 | eval(name) before register(name) |
| cache.script_execution_failed | 500 | Lua runtime error or NOSCRIPT retry failure |
| cache.script_registry_missing | 500 | eval called when no ScriptManagerService is wired |
| cache.flush_disabled_in_production | 403 | flushNamespace() in prod without allowFlushInProduction |
| cache.unsupported_in_cluster | 500 | scan, flushNamespace, or getClient called in cluster mode |
| cache.cluster_misconfigured | 500 | mode: 'cluster' without cluster.nodes |
| cache.sentinel_misconfigured | 500 | mode: 'sentinel' without sentinel.sentinels/name |
| cache.shutdown_timeout | 500 | quit() exceeded shutdownTimeoutMs |
Full catalog and HTTP status mapping: docs/technical_specification.md §12.
🚫 What This Library Does NOT Do
Reliable atomic primitives are in scope; opinionated policies are not. By design, the following are out of scope:
- ❌ Rate limiting — compose
incr+expire, a custom Lua script, or a future@bymax-one/nest-rate-limit - ❌ Distributed locks —
setNx+ a release script covers the common case; a future@bymax-one/nest-lockwould own the edge cases - ❌ BullMQ wiring —
@bymax-one/nest-queueowns its own connection - ❌ Cache-aside / read-through patterns — that policy belongs in your repositories, not in the client
- ❌ Compression and at-rest encryption — implement a custom
ISerializer - ❌ Tag-based invalidation — namespaces and prefixes are the invalidation unit
- ❌ Redis Streams — a different consumption model than Pub/Sub; out of scope for this package
See §13 of the technical specification for the rationale.
🤝 Contributing
Contributions are welcome. Development follows the Bymax coding standards:
- TypeScript strict (
noImplicitAny,exactOptionalPropertyTypes,noUncheckedIndexedAccess) - TDD with a 100% coverage gate and a 95% mutation break threshold
- Conventional Commits enforced by commitlint + husky
- No direct dependencies — peer deps only
- All boolean identifiers prefixed with
is / has / should / can
# Clone the repository
git clone https://github.com/bymaxone/nest-cache.git
cd nest-cache
# Install dependencies
pnpm install
# Run tests
pnpm test
# Build
pnpm build
# Type check
pnpm typecheckRun the full gate before opening a PR:
pnpm typecheck && pnpm lint && pnpm test:cov:all && pnpm build && pnpm sizeSee CONTRIBUTING.md and CODE_OF_CONDUCT.md for the full process.
🔒 Security Policy
If you discover a security vulnerability, please do not open a public issue. Instead, email us at [email protected] with details. We take security seriously and will respond promptly.
See SECURITY.md for the private reporting process, supported versions, and the threat model (cache poisoning, key injection, unsafe deserialization, production flush guard, Lua injection).
