npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 dependenciesioredis 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/setget<T> / set<T> go through a pluggable ISerializer (JSON by default). Deserialization fails closed — a malformed payload throws CacheException, never a half-decoded value.
  • 📡 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.
  • ♻️ Lifecycle done right — singleton connection with OnModuleInit / OnModuleDestroy, bounded retry strategy, READONLY-failover reconnect, and a graceful quit() with shutdown timeout.
  • 🔌 Bring your own observability — connection events surface through an events.onEvent callback; plug in @bymax-one/nest-logger or your metrics layer. No observability peer deps forced on you.
pnpm add @bymax-one/nest-cache ioredis

🔥 Features

🧬 Typed Cache API

  • Typed get/setget<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 serializationISerializer contract; swap JSON for MessagePack, CBOR, or your own codec
  • Raw string accessgetRaw / setRaw skip the serializer while keeping namespacing; pipeline / getClient are 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 flushflushNamespace() 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, graceful quit() with a shutdown timeout
  • Bounded retriesmaxRetriesPerRequest and a reconnect policy that triggers on READONLY replica failover
  • Connection eventsconnect / ready / error / close / reconnecting / end surfaced via events.onEvent
  • Health checksisHealthy() / ping() / info() for readiness and liveness endpoints

🛡️ Safety

  • Fail-closed serialization — malformed payloads raise CacheException(DESERIALIZATION_FAILED), never a partial value
  • Production flush guardflushNamespace() is blocked under NODE_ENV=production unless 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 moduleforRoot() and forRootAsync() via ConfigurableModuleBuilder; registered globally by default (isGlobal)
  • Lua script manager — register scripts up front, execute by name with transparent NOSCRIPT reload + retry
  • Structured errors — every failure is a CacheException with a stable cache.* 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, and reflect-metadata are peer dependencies (already present in any NestJS app). ioredis is 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 members array. 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 — an LPUSH queue shown alphabetically misreports what pops next. Gate any client-side ordering on type === 'set'.

Costs the surface does not hide

  • sampledCount / sampledBytes are sums over a capped SCAN, not measurements of the keyspace. isComplete is the fact; the names are the guard.
  • A page may carry slightly more than scanLimit entries. SCAN returns 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.url is 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_ratio is 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.url throws CONNECTION_FAILED with reason: 'invalid connection.url' — the URL is omitted, because it may embed a password.
  • SERIALIZATION_FAILED carries the encoder's message, never the value being encoded.
  • 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.

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:

  • namespace is distinct per tenant or per application — it is the isolation boundary, and a shared value makes every other guarantee moot
  • allowFlushInProduction stays unset; if it is on, the reason belongs in a security review
  • getClient() and pipeline() 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 explicit connection.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 ISerializer implementations throw on malformed input rather than returning a fallback value
  • If /admin is 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() or pipeline() sits outside it — those call sites are where cross-tenant reads get introduced.


🧱 Tech Stack

NestJS ioredis TypeScript Node.js Jest Stryker pnpm tsup


🧪 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.ts as a pre-publish gate, not a target
  • 100% mutation score — verified with Stryker at break: 100 and ignoreStatic: false; 441 killed, 6 timed out, 0 survived, and documented in full
  • One documented equivalent — the production source carries a single // Stryker disable directive, on configurable: false of the withheld connection accessor, genuinely equivalent because the resolved options are frozen on the way out (freezing already makes every property non-configurable); check:mutants proves it parses and carries its reason, so the score is an accounting rather than a number
  • No real Redis in unit testsioredis-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
  • Published-package smoke testscripts/dogfood-smoke-test.mjs validates 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 lockssetNx + a release script covers the common case; a future @bymax-one/nest-lock would own the edge cases
  • BullMQ wiring@bymax-one/nest-queue owns 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 typecheck

Run the full gate before opening a PR:

pnpm typecheck && pnpm lint && pnpm test:cov:all && pnpm build && pnpm size

See 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).


📄 License

MIT © Bymax One