@bymax-one/nest-cache 1.2.2 → 1.2.3
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 +46 -0
- package/README.md +92 -56
- package/dist/server/index.cjs +7 -7
- package/dist/server/index.d.cts +3 -3
- package/dist/server/index.d.ts +3 -3
- package/dist/server/index.mjs +7 -7
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,50 @@ All notable changes to this project are documented in this file. The format is
|
|
|
4
4
|
based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this
|
|
5
5
|
project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [1.2.3] - 2026-10-30
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- `JsonSerializer.deserialize` throws `DESERIALIZATION_FAILED` with `details` of a fixed `error` text and the
|
|
14
|
+
`length` of the stored value. `details.preview` and the parser's own message, which quoted the start of the
|
|
15
|
+
input, are gone. The error code is unchanged; a consumer that read `details.preview` must stop.
|
|
16
|
+
- **Apply to a consumer.** Do not read `details.preview` or `details.error` to debug a decoding
|
|
17
|
+
failure. Log the error on the server by its code, `cache.deserialization_failed`; the call site already
|
|
18
|
+
knows the prefix and id it asked for. Use `details.length` when you need a size signal. A backend that
|
|
19
|
+
sent `details` to its clients needs no other change: the response no longer carries any part of the
|
|
20
|
+
stored value.
|
|
21
|
+
- Apart from that, nothing you execute changes.
|
|
22
|
+
|
|
23
|
+
### Documentation
|
|
24
|
+
|
|
25
|
+
- `README.md`, `docs/technical_specification.md` and the contributor documents state that
|
|
26
|
+
`KeyBuilder` validates `prefix` and `id` only for emptiness. A separator inside either
|
|
27
|
+
collides (`build('a', 'b:c')` and `build('a:b', 'c')` both give `app:a:b:c`), and a glob
|
|
28
|
+
metacharacter in `prefix` widens `keys()` and `scan()` within the namespace. Behaviour is
|
|
29
|
+
unchanged. Keys are unambiguous only for a single-character separator that appears in neither field; encoding each field with `encodeURIComponent` provides that for a separator it escapes and never emits, such as the default `':'`.
|
|
30
|
+
- Contributor and security documents match the current tooling: pnpm 11, the admin
|
|
31
|
+
read-only check in the verification chain, mutation testing after merge and weekly, and
|
|
32
|
+
how dependency advisories are handled.
|
|
33
|
+
|
|
34
|
+
### Security
|
|
35
|
+
|
|
36
|
+
- **A non-JSON stored value no longer appears in the exception body.** `CacheException` extends
|
|
37
|
+
`HttpException`, so a default Nest filter sends `details` to the HTTP client. A cached value that was
|
|
38
|
+
not JSON (written by another writer, or by another serializer) leaked up to 100 characters of itself
|
|
39
|
+
through `details.preview`, and the start of it through `details.error`. It could carry PII or a token.
|
|
40
|
+
Only a malformed entry triggers it; a valid JSON value never does.
|
|
41
|
+
- Dependency advisories in development-only transitive dependencies are fixed by raising the
|
|
42
|
+
`overrides` floors in `pnpm-workspace.yaml`. None of them ships: the package
|
|
43
|
+
publishes with `"dependencies": {}`, so no consumer was exposed to these advisories.
|
|
44
|
+
- `GHSA-gcqx-w48m-w2h4` (`deepmerge`, reached only through `jest-config`) has no patched
|
|
45
|
+
release. It is suppressed in `osv-scanner.toml` until 2027-01-27 and nowhere else.
|
|
46
|
+
|
|
47
|
+
### Internal
|
|
48
|
+
|
|
49
|
+
- Development dependencies updated within their ranges; GitHub Actions bumped.
|
|
50
|
+
|
|
7
51
|
## [1.2.2] - 2026-09-04
|
|
8
52
|
|
|
9
53
|
### Changed
|
|
@@ -350,6 +394,8 @@ type or export moved.
|
|
|
350
394
|
- Published with npm OIDC provenance — no long-lived tokens
|
|
351
395
|
- Zero direct runtime dependencies (`dependencies: {}`) — `ioredis` and NestJS via peer deps
|
|
352
396
|
|
|
397
|
+
[Unreleased]: https://github.com/bymaxone/nest-cache/compare/v1.2.3...HEAD
|
|
398
|
+
[1.2.3]: https://github.com/bymaxone/nest-cache/compare/v1.2.2...v1.2.3
|
|
353
399
|
[1.2.2]: https://github.com/bymaxone/nest-cache/compare/v1.2.1...v1.2.2
|
|
354
400
|
[1.2.1]: https://github.com/bymaxone/nest-cache/compare/v1.2.0...v1.2.1
|
|
355
401
|
[1.2.0]: https://github.com/bymaxone/nest-cache/compare/v1.1.0...v1.2.0
|
package/README.md
CHANGED
|
@@ -47,7 +47,7 @@ so you control exact versions and the supply-chain surface stays minimal.
|
|
|
47
47
|
- **🧬 Typed get/set** — `get<T>` / `set<T>` go through a pluggable `ISerializer` (JSON by default). Deserialization **fails closed** — a malformed payload throws `CacheException`, never a half-decoded value.
|
|
48
48
|
- **📡 Batteries included** — Pub/Sub on namespaced channels and a Lua script manager (`EVALSHA` + `NOSCRIPT` fallback) ship in the box, on top of the full string/hash/set/numeric command surface.
|
|
49
49
|
- **♻️ Lifecycle done right** — singleton connection with `OnModuleInit` / `OnModuleDestroy`, bounded retry strategy, `READONLY`-failover reconnect, and a graceful `quit()` with shutdown timeout.
|
|
50
|
-
- **🔌 Bring your own observability** — connection events surface through an `events.onEvent` callback; plug in [`@bymax-one/nest-logger`](https://github.com/bymaxone/nest-logger) or your metrics layer. No observability peer deps forced on you.
|
|
50
|
+
- **🔌 Bring your own observability** — connection events and Pub/Sub handler failures surface through an `events.onEvent` callback; plug in [`@bymax-one/nest-logger`](https://github.com/bymaxone/nest-logger) or your metrics layer. No observability peer deps forced on you.
|
|
51
51
|
|
|
52
52
|
```
|
|
53
53
|
pnpm add @bymax-one/nest-cache ioredis
|
|
@@ -69,21 +69,21 @@ pnpm add @bymax-one/nest-cache ioredis
|
|
|
69
69
|
- ✅ **Automatic namespacing** — the key builder enforces tenant/feature isolation; no manual string concatenation
|
|
70
70
|
- ✅ **Namespaced channels** — Pub/Sub channels are composed through the same builder as keys
|
|
71
71
|
- ✅ **Surgical flush** — `flushNamespace()` scans only `{namespace}:*`, never another namespace's keys
|
|
72
|
-
- ✅ **Validated at bootstrap** — an empty namespace, or
|
|
72
|
+
- ✅ **Validated at bootstrap** — an empty namespace, an empty key separator, or a namespace containing the key separator or a Redis glob metacharacter (`*`, `?`, `[`, `\`) fails module initialization
|
|
73
73
|
|
|
74
74
|
### ⚙️ Reliability & Topology
|
|
75
75
|
|
|
76
76
|
- ✅ **Multi-topology** — standalone, Sentinel, and Cluster modes from the same options shape
|
|
77
77
|
- ✅ **Managed lifecycle** — singleton connection via `OnModuleInit` / `OnModuleDestroy`, graceful `quit()` with a shutdown timeout
|
|
78
78
|
- ✅ **Bounded retries** — `maxRetriesPerRequest` and a reconnect policy that triggers on `READONLY` replica failover
|
|
79
|
-
- ✅ **Connection events** — `connect` / `ready` / `error` / `close` / `reconnecting` / `end` surfaced via `events.onEvent`
|
|
79
|
+
- ✅ **Connection events** — `connect` / `ready` / `error` / `close` / `reconnecting` / `end` surfaced via `events.onEvent`, which also receives Pub/Sub handler and deserialization failures as `'error'` with `reason: 'handler_error'`
|
|
80
80
|
- ✅ **Health checks** — `isHealthy()` / `ping()` / `info()` for readiness and liveness endpoints
|
|
81
81
|
|
|
82
82
|
### 🛡️ Safety
|
|
83
83
|
|
|
84
84
|
- ✅ **Fail-closed serialization** — malformed payloads raise `CacheException(DESERIALIZATION_FAILED)`, never a partial value
|
|
85
85
|
- ✅ **Production flush guard** — `flushNamespace()` is blocked under `NODE_ENV=production` unless explicitly allowed
|
|
86
|
-
- ✅ **Secrets never echoed** — connection URLs and cached values are kept out of error `details`;
|
|
86
|
+
- ✅ **Secrets never echoed** — connection URLs and cached values are kept out of error `details`; no part of a stored value is ever echoed
|
|
87
87
|
- ✅ **Script bodies are registered, not interpolated** — call sites pass a name plus keys/args, never Lua source
|
|
88
88
|
|
|
89
89
|
### 🧩 Developer Experience
|
|
@@ -248,18 +248,25 @@ Keys resolve to `app:user-profile:<userId>` — namespaced automatically.
|
|
|
248
248
|
|
|
249
249
|
## ⚙️ Configuration
|
|
250
250
|
|
|
251
|
-
| Option
|
|
252
|
-
|
|
|
253
|
-
| `mode`
|
|
254
|
-
| `connection.url`
|
|
255
|
-
| `connection.tls`
|
|
256
|
-
| `namespace`
|
|
257
|
-
| `serializer`
|
|
258
|
-
| `events.onEvent`
|
|
259
|
-
| `scripts`
|
|
260
|
-
| `shutdownTimeoutMs`
|
|
261
|
-
|
|
262
|
-
|
|
251
|
+
| Option | Type | Default | Description |
|
|
252
|
+
| ------------------------ | ----------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
253
|
+
| `mode` | `'standalone' \| 'sentinel' \| 'cluster'` | `'standalone'` | Redis topology |
|
|
254
|
+
| `connection.url` | `string` | — | `redis://` / `rediss://` URL (overrides discrete host/port fields) |
|
|
255
|
+
| `connection.tls` | `tls.ConnectionOptions` | — | TLS options for `rediss://` |
|
|
256
|
+
| `namespace` | `string` | `'app'` | Key prefix for tenant/feature isolation |
|
|
257
|
+
| `serializer` | `ISerializer` | `JsonSerializer` | Value encoding/decoding (plug MsgPack, CBOR, etc.) |
|
|
258
|
+
| `events.onEvent` | `(event, data) => void` | — | Lifecycle and Pub/Sub failure hook (plug a logger or metrics); receives connection events, and `'error'` with `reason: 'handler_error'` when a subscriber handler or deserialization fails |
|
|
259
|
+
| `scripts` | `IScriptDefinition[]` | — | Lua scripts to preload on init |
|
|
260
|
+
| `shutdownTimeoutMs` | `number` | `5000` | Graceful `quit()` timeout before forced `disconnect()` (minimum `100`) |
|
|
261
|
+
| `keySeparator` | `string` | `':'` | Separator between namespace, prefix and id segments |
|
|
262
|
+
| `allowFlushInProduction` | `boolean` | `false` | Allows `flushNamespace()` when `NODE_ENV=production` |
|
|
263
|
+
| `isGlobal` | `boolean` | `true` | Register the module globally |
|
|
264
|
+
| `sentinel` | `{ sentinels, name, ... }` | — | Sentinel connection block; required when `mode: 'sentinel'` |
|
|
265
|
+
| `cluster` | `{ nodes, options? }` | — | Cluster connection block; required when `mode: 'cluster'` |
|
|
266
|
+
|
|
267
|
+
`connection` also accepts `host`, `port`, `password`, `db`, `username`, `lazyConnect`, `connectTimeout` (minimum `100`), `commandTimeout`, `maxRetriesPerRequest`, `enableReadyCheck`, `enableOfflineQueue`, `retryStrategy`, `reconnectOnError`, `keepAlive`, `noDelay` and `family`; `url` takes precedence over the discrete fields.
|
|
268
|
+
|
|
269
|
+
Both `forRoot(options)` (synchronous) and `forRootAsync({ useFactory, inject, imports })` are supported. The module registers globally by default — pass `isGlobal: false` to scope it to the importing module. With `forRootAsync`, `isGlobal` is read at the call site (`forRootAsync({ isGlobal, ... })`); a value returned from `useFactory` has no effect.
|
|
263
270
|
|
|
264
271
|
---
|
|
265
272
|
|
|
@@ -271,6 +278,26 @@ keeps tenants and features isolated and makes `flushNamespace()` surgical. Reach
|
|
|
271
278
|
`getClient()` to set raw, un-namespaced keys is supported as an escape hatch but documented as an
|
|
272
279
|
anti-pattern.
|
|
273
280
|
|
|
281
|
+
`prefix` and `id` are supplied by the caller and the library validates them only for emptiness
|
|
282
|
+
(`INVALID_KEY`); it does **not** reject the separator or glob characters inside them. Two
|
|
283
|
+
consequences follow:
|
|
284
|
+
|
|
285
|
+
- **Separator collision.** `build('a', 'b:c')` and `build('a:b', 'c')` both produce `app:a:b:c`, so
|
|
286
|
+
two different `(prefix, id)` pairs address the same key.
|
|
287
|
+
- **Glob widening.** `keys(prefix, pattern)` and `scan(prefix, pattern)` pass the composed key to
|
|
288
|
+
Redis as a match pattern, so a glob metacharacter (`*`, `?`, `[`, `\`) in `prefix` can match keys
|
|
289
|
+
outside that prefix, still within the namespace. Single-key commands (`get`, `set`, `del`, ...)
|
|
290
|
+
treat the key literally and are not affected; `flushNamespace()` uses only the validated namespace.
|
|
291
|
+
|
|
292
|
+
This is documentation only; behavior is unchanged. When a `prefix` or `id` comes from untrusted
|
|
293
|
+
input, encode each field, for example `cache.get(encodeURIComponent(prefix), encodeURIComponent(id))`.
|
|
294
|
+
Keys are unambiguous only when `keySeparator` is a single character that appears in neither `prefix`
|
|
295
|
+
nor `id`. Per-field encoding guarantees that for a separator `encodeURIComponent` escapes and never
|
|
296
|
+
emits: the default `':'` qualifies; `%` and `- _ . ! ~ * ' ( )` do not, so for them reject the
|
|
297
|
+
separator in the fields. A multi-character separator can straddle a field boundary (with `'aa'`,
|
|
298
|
+
`('ba', 'c')` and `('b', 'ac')` build the same key), so keep a single character. A `prefix` used with
|
|
299
|
+
`keys()` / `scan()` must also avoid glob characters (`*`, `?`, `[`, `\`).
|
|
300
|
+
|
|
274
301
|
---
|
|
275
302
|
|
|
276
303
|
## 📡 Pub/Sub
|
|
@@ -575,7 +602,7 @@ A cache sits between your application and every value it has ever computed, so t
|
|
|
575
602
|
|
|
576
603
|
### Namespace isolation is structural
|
|
577
604
|
|
|
578
|
-
Every key and every Pub/Sub channel is composed through `KeyBuilder`, which prepends `{namespace}{separator}` before the command reaches Redis. `CacheService.get('user-profile', '42')`
|
|
605
|
+
Every key and every Pub/Sub channel is composed through `KeyBuilder`, which prepends `{namespace}{separator}` before the command reaches Redis. With fixed literals, `CacheService.get('user-profile', '42')` reads `app:user-profile:42` — there is no code path through the typed API that emits a bare key. The namespace is the isolation boundary; the library does not constrain `prefix` and `id` beyond non-emptiness.
|
|
579
606
|
|
|
580
607
|
The namespace itself is validated at module bootstrap, not at call time:
|
|
581
608
|
|
|
@@ -583,10 +610,14 @@ The namespace itself is validated at module bootstrap, not at call time:
|
|
|
583
610
|
| --------------------------------------- | ---------------------------------------------------------------- |
|
|
584
611
|
| Empty or whitespace-only namespace | `CacheException(INVALID_NAMESPACE)` — the module fails to start |
|
|
585
612
|
| Namespace containing the key separator | `CacheException(INVALID_NAMESPACE)` — prevents prefix collisions |
|
|
613
|
+
| Empty key separator | `CacheException(INVALID_NAMESPACE)` — the module fails to start |
|
|
614
|
+
| Namespace containing `*`, `?`, `[`, `\` | `CacheException(INVALID_NAMESPACE)` — see the glob section below |
|
|
586
615
|
| Empty `prefix` or `id` at the call site | `CacheException(INVALID_KEY)` — no key is sent to Redis |
|
|
587
616
|
|
|
588
617
|
A namespace containing the separator is rejected because `namespace: 'a:b'` and `namespace: 'a'` with prefix `b` would resolve to the same keyspace — a tenant boundary that reads as isolated but is not.
|
|
589
618
|
|
|
619
|
+
The same collision exists below the namespace and is **not** enforced by the library: `prefix` and `id` are validated only for emptiness, so `build('a', 'b:c')` and `build('a:b', 'c')` both give `app:a:b:c`, and a glob metacharacter in `prefix` widens `keys()` / `scan()` within the namespace. Behavior is unchanged; keys are unambiguous only for a single-character `keySeparator` that appears in neither `prefix` nor `id`: encode untrusted values per field with `encodeURIComponent` only when the separator is one it escapes and never emits (the default `':'` is; `%` and `- _ . ! ~ * ' ( )` are not), otherwise reject the separator in them; a multi-character separator can straddle a field boundary, so keep a single character; reject glob characters in `prefix`.
|
|
620
|
+
|
|
590
621
|
### Deserialization fails closed
|
|
591
622
|
|
|
592
623
|
`JsonSerializer.deserialize` throws `CacheException(DESERIALIZATION_FAILED)` on any payload that is not valid JSON. It never returns `undefined`, never returns a partial object, and never lets a corrupted entry masquerade as a valid `T`. The same contract is required of any custom `ISerializer` — fail-closed is the invariant, not the default implementation's private choice.
|
|
@@ -599,7 +630,7 @@ Error `details` are built to be safe to log:
|
|
|
599
630
|
|
|
600
631
|
- A malformed `connection.url` throws `CONNECTION_FAILED` with `reason: 'invalid connection.url'` — the URL is omitted, because it may embed a password.
|
|
601
632
|
- `SERIALIZATION_FAILED` carries the encoder's message, never the value being encoded.
|
|
602
|
-
- `DESERIALIZATION_FAILED` carries a `
|
|
633
|
+
- `DESERIALIZATION_FAILED` carries a fixed `error` text and the `length` of the stored value, and nothing from its content — not a preview, not the parser's message, which quotes the start of its input. The exception is an `HttpException`, so a default Nest filter sends `details` to the HTTP client; a cached record holding PII or a token must never reach it.
|
|
603
634
|
|
|
604
635
|
### Destructive operations are guarded in production
|
|
605
636
|
|
|
@@ -624,6 +655,7 @@ When integrating `@bymax-one/nest-cache` in production, verify each of the follo
|
|
|
624
655
|
- `namespace` is distinct per tenant or per application — it is the isolation boundary, and a shared value makes every other guarantee moot
|
|
625
656
|
- `allowFlushInProduction` stays unset; if it is on, the reason belongs in a security review
|
|
626
657
|
- `getClient()` and `pipeline()` call sites are audited — they are the only paths that bypass namespacing
|
|
658
|
+
- `prefix` and `id` that derive from untrusted input are encoded per field (`encodeURIComponent`) — the library checks them only for emptiness, so a separator inside either collides keys and a glob character in `prefix` widens `keys()` / `scan()`; keys are unambiguous only for a single-character `keySeparator` absent from both fields; per-field `encodeURIComponent` provides that only for a separator it escapes and never emits (the default `':'`; not `%` or `- _ . ! ~ * ' ( )`), otherwise the separator must be rejected, and a multi-character separator cannot be made safe that way; glob characters in `prefix` must be rejected
|
|
627
659
|
- Connection credentials come from the environment (`config.getOrThrow('REDIS_URL')`), never from a URL literal in module options in source control
|
|
628
660
|
- `rediss://` (or an explicit `connection.tls`) is used for any Redis reachable off-host
|
|
629
661
|
- Values that must not be readable by whoever can read Redis are encrypted by the application (or a custom `ISerializer`) before they are cached — the library stores what you hand it
|
|
@@ -636,22 +668,22 @@ When integrating `@bymax-one/nest-cache` in production, verify each of the follo
|
|
|
636
668
|
|
|
637
669
|
## 🛡️ Security Table
|
|
638
670
|
|
|
639
|
-
| Layer | Implementation
|
|
640
|
-
| --------------------- |
|
|
641
|
-
| Tenant Isolation | Every key and channel composed by `KeyBuilder` as `{namespace}{sep}{prefix}{sep}{id}` — no bare-key path in the API
|
|
642
|
-
| Namespace Validation | Empty, separator-containing, or glob-metacharacter namespace rejected at bootstrap (`INVALID_NAMESPACE`)
|
|
643
|
-
| Admin Scope Allowlist | Scopes declared at wiring, validated and frozen; a caller names a scope by id and can never supply a match pattern
|
|
644
|
-
| Admin Read-Only | The `/admin` subpath issues no mutating command — enforced by the `check:admin-readonly` build gate, not by convention
|
|
645
|
-
| Key Validation | Empty `prefix` / `id` rejected before the command is issued (`INVALID_KEY`)
|
|
646
|
-
| Deserialization | Fails closed — `DESERIALIZATION_FAILED`; never a partial or wrongly-typed value
|
|
647
|
-
| Serialization | Top-level `undefined` / function / symbol rejected; the value is never echoed into `details`
|
|
648
|
-
| Error Payloads | Connection URLs omitted (may embed credentials);
|
|
649
|
-
| Destructive Ops | `flushNamespace()` blocked under `NODE_ENV=production` unless `allowFlushInProduction`; `SCAN` + `UNLINK`, namespace-scoped
|
|
650
|
-
| Lua Execution | Scripts registered by name; call sites pass keys/args only — request input never reaches a script body
|
|
651
|
-
| Cluster Safety | `scan` / `flushNamespace` / `getClient` throw `UNSUPPORTED_IN_CLUSTER` instead of acting on a single node
|
|
652
|
-
| Transport | `rediss://` sets TLS on the client; explicit `connection.tls` supported for custom CA / mTLS
|
|
653
|
-
| Connection Resilience | Bounded `maxRetriesPerRequest`, `READONLY`-failover reconnect, graceful `quit()` with a shutdown timeout
|
|
654
|
-
| Supply Chain | `"dependencies": {}` — no transitive runtime packages of the library's own choosing; published with npm provenance
|
|
671
|
+
| Layer | Implementation |
|
|
672
|
+
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
673
|
+
| Tenant Isolation | Every key and channel composed by `KeyBuilder` as `{namespace}{sep}{prefix}{sep}{id}` — no bare-key path in the API |
|
|
674
|
+
| Namespace Validation | Empty, separator-containing, or glob-metacharacter namespace rejected at bootstrap (`INVALID_NAMESPACE`) |
|
|
675
|
+
| Admin Scope Allowlist | Scopes declared at wiring, validated and frozen; a caller names a scope by id and can never supply a match pattern |
|
|
676
|
+
| Admin Read-Only | The `/admin` subpath issues no mutating command — enforced by the `check:admin-readonly` build gate, not by convention |
|
|
677
|
+
| Key Validation | Empty `prefix` / `id` rejected before the command is issued (`INVALID_KEY`); emptiness only — separators and glob characters in `prefix` / `id` are not rejected |
|
|
678
|
+
| Deserialization | Fails closed — `DESERIALIZATION_FAILED`; never a partial or wrongly-typed value |
|
|
679
|
+
| Serialization | Top-level `undefined` / function / symbol rejected; the value is never echoed into `details` |
|
|
680
|
+
| Error Payloads | Connection URLs omitted (may embed credentials); a stored value is never echoed (a fixed text and its length only) |
|
|
681
|
+
| Destructive Ops | `flushNamespace()` blocked under `NODE_ENV=production` unless `allowFlushInProduction`; `SCAN` + `UNLINK`, namespace-scoped |
|
|
682
|
+
| Lua Execution | Scripts registered by name; call sites pass keys/args only — request input never reaches a script body |
|
|
683
|
+
| Cluster Safety | `scan` / `flushNamespace` / `getClient` throw `UNSUPPORTED_IN_CLUSTER` instead of acting on a single node |
|
|
684
|
+
| Transport | `rediss://` sets TLS on the client; explicit `connection.tls` supported for custom CA / mTLS |
|
|
685
|
+
| Connection Resilience | Bounded `maxRetriesPerRequest`, `READONLY`-failover reconnect, graceful `quit()` with a shutdown timeout |
|
|
686
|
+
| Supply Chain | `"dependencies": {}` — no transitive runtime packages of the library's own choosing; published with npm provenance |
|
|
655
687
|
|
|
656
688
|
> [!IMPORTANT]
|
|
657
689
|
> The namespace is the isolation boundary. Anything reached through `getClient()` or `pipeline()` sits outside it — those call sites are where cross-tenant reads get introduced.
|
|
@@ -666,7 +698,7 @@ When integrating `@bymax-one/nest-cache` in production, verify each of the follo
|
|
|
666
698
|
[](https://nodejs.org)
|
|
667
699
|
[](https://jestjs.io)
|
|
668
700
|
[](https://stryker-mutator.io)
|
|
669
|
-
[](https://pnpm.io)
|
|
670
702
|
[](https://tsup.egoist.dev)
|
|
671
703
|
|
|
672
704
|
---
|
|
@@ -676,7 +708,7 @@ When integrating `@bymax-one/nest-cache` in production, verify each of the follo
|
|
|
676
708
|
A cache is consulted on the hot path of every request that touches it, so the suite is held to a bar beyond "it runs" — every behavior is pinned so that a regression **fails a test**.
|
|
677
709
|
|
|
678
710
|
- ✅ **100% line coverage** — statements, branches, functions, and lines, enforced by `jest.coverage.config.ts` as a pre-publish gate, not a target
|
|
679
|
-
- ✅ **100% mutation score** — verified with [Stryker](https://stryker-mutator.io/) at `break: 100` and `ignoreStatic: false
|
|
711
|
+
- ✅ **100% mutation score** — verified with [Stryker](https://stryker-mutator.io/) at `break: 100` and `ignoreStatic: false`, **0 survived**, and [documented in full](./docs/mutation_testing_results.md)
|
|
680
712
|
- ✅ **One documented equivalent** — the production source carries a single `// Stryker disable` directive, on `configurable: false` of the withheld connection accessor, genuinely equivalent because the resolved options are frozen on the way out (freezing already makes every property non-configurable); `check:mutants` proves it parses and carries its reason, so the score is an accounting rather than a number
|
|
681
713
|
- ✅ **No real Redis in unit tests** — `ioredis-mock` throughout; e2e tests exercise the wired module through `@nestjs/testing` and Testcontainers against a real Redis for connection lifecycle, Pub/Sub, and Lua scripts
|
|
682
714
|
- ✅ **Published-package smoke test** — `scripts/dogfood-smoke-test.mjs` validates exports, tarball shape, and a consumer install before tagging
|
|
@@ -685,7 +717,7 @@ A cache is consulted on the hot path of every request that touches it, so the su
|
|
|
685
717
|
pnpm test # unit tests (Jest)
|
|
686
718
|
pnpm test:e2e # end-to-end tests (@nestjs/testing + Testcontainers)
|
|
687
719
|
pnpm test:cov:all # full coverage gate (100% statements/branches/functions/lines)
|
|
688
|
-
pnpm mutation # Stryker mutation testing (
|
|
720
|
+
pnpm mutation # Stryker mutation testing (100% break gate)
|
|
689
721
|
pnpm typecheck # tsc strict check
|
|
690
722
|
pnpm lint # ESLint
|
|
691
723
|
```
|
|
@@ -732,23 +764,27 @@ pnpm lint # ESLint
|
|
|
732
764
|
|
|
733
765
|
All errors are instances of `CacheException` and carry a stable `code` string from `CACHE_ERROR_CODES`:
|
|
734
766
|
|
|
735
|
-
| Code | HTTP | When thrown
|
|
736
|
-
| ------------------------------------ | ---- |
|
|
737
|
-
| `cache.connection_failed` | 500 | Cannot connect after retries
|
|
738
|
-
| `cache.command_timeout` | 504 |
|
|
739
|
-
| `cache.connection_lost` | 503 |
|
|
740
|
-
| `cache.deserialization_failed` | 500 | Malformed payload in `get<T>`
|
|
741
|
-
| `cache.serialization_failed` | 500 | Unserializable value in `set<T>`
|
|
742
|
-
| `cache.invalid_key` | 400 | Empty prefix or id
|
|
743
|
-
| `cache.invalid_namespace` | 500 | Empty or
|
|
744
|
-
| `cache.script_not_registered` | 500 | `eval(name)` before `register(name)`
|
|
745
|
-
| `cache.script_execution_failed` | 500 | Lua runtime error or NOSCRIPT retry failure
|
|
746
|
-
| `cache.script_registry_missing` | 500 | `eval` called when no `ScriptManagerService` is wired
|
|
747
|
-
| `cache.flush_disabled_in_production` | 403 | `flushNamespace()` in prod without `allowFlushInProduction`
|
|
748
|
-
| `cache.unsupported_in_cluster` | 500 | `scan`, `flushNamespace`, or `getClient` called in cluster mode
|
|
749
|
-
| `cache.cluster_misconfigured` | 500 | `mode: 'cluster'` without `cluster.nodes`
|
|
750
|
-
| `cache.sentinel_misconfigured` | 500 | `mode: 'sentinel'` without `sentinel.sentinels`/`name`
|
|
751
|
-
| `cache.shutdown_timeout` | 500 | `quit()` exceeded `shutdownTimeoutMs`
|
|
767
|
+
| Code | HTTP | When thrown |
|
|
768
|
+
| ------------------------------------ | ---- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
|
769
|
+
| `cache.connection_failed` | 500 | Cannot connect after retries; also bootstrap validation of `connection` and timeouts |
|
|
770
|
+
| `cache.command_timeout` | 504 | Reserved; not currently thrown by the library |
|
|
771
|
+
| `cache.connection_lost` | 503 | Reserved; not currently thrown by the library |
|
|
772
|
+
| `cache.deserialization_failed` | 500 | Malformed payload in `get<T>` |
|
|
773
|
+
| `cache.serialization_failed` | 500 | Unserializable value in `set<T>` |
|
|
774
|
+
| `cache.invalid_key` | 400 | Empty prefix or id (`build`), or empty key (`applyNamespace`) |
|
|
775
|
+
| `cache.invalid_namespace` | 500 | Empty or whitespace-only namespace, empty `keySeparator`, namespace containing the separator or a glob metacharacter (`*`, `?`, `[`, `\`) |
|
|
776
|
+
| `cache.script_not_registered` | 500 | `eval(name)` before `register(name)` |
|
|
777
|
+
| `cache.script_execution_failed` | 500 | Lua runtime error or NOSCRIPT retry failure |
|
|
778
|
+
| `cache.script_registry_missing` | 500 | `eval` called when no `ScriptManagerService` is wired |
|
|
779
|
+
| `cache.flush_disabled_in_production` | 403 | `flushNamespace()` in prod without `allowFlushInProduction` |
|
|
780
|
+
| `cache.unsupported_in_cluster` | 500 | `scan`, `flushNamespace`, or `getClient` called in cluster mode |
|
|
781
|
+
| `cache.cluster_misconfigured` | 500 | `mode: 'cluster'` without `cluster.nodes` |
|
|
782
|
+
| `cache.sentinel_misconfigured` | 500 | `mode: 'sentinel'` without `sentinel.sentinels`/`name` |
|
|
783
|
+
| `cache.shutdown_timeout` | 500 | `quit()` exceeded `shutdownTimeoutMs` |
|
|
784
|
+
| `cache.invalid_scope` | 500 | `/admin` scope allowlist invalid at wiring |
|
|
785
|
+
| `cache.scope_not_found` | 500 | `/admin` call named an undeclared scope id |
|
|
786
|
+
| `cache.scope_not_readable` | 500 | Reserved; not currently thrown (unreadable scopes return `withheld`) |
|
|
787
|
+
| `cache.key_not_in_scope` | 500 | `/admin` call named a key outside the named scope |
|
|
752
788
|
|
|
753
789
|
Full catalog and HTTP status mapping: [`docs/technical_specification.md §12`](./docs/technical_specification.md).
|
|
754
790
|
|
|
@@ -775,7 +811,7 @@ See §13 of the [technical specification](./docs/technical_specification.md) for
|
|
|
775
811
|
Contributions are welcome. Development follows the Bymax coding standards:
|
|
776
812
|
|
|
777
813
|
- TypeScript strict (`noImplicitAny`, `exactOptionalPropertyTypes`, `noUncheckedIndexedAccess`)
|
|
778
|
-
- TDD with a 100% coverage gate and a
|
|
814
|
+
- TDD with a 100% coverage gate and a 100% mutation break threshold
|
|
779
815
|
- Conventional Commits enforced by commitlint + husky
|
|
780
816
|
- No direct dependencies — peer deps only
|
|
781
817
|
- All boolean identifiers prefixed with `is / has / should / can`
|
|
@@ -801,7 +837,7 @@ pnpm typecheck
|
|
|
801
837
|
Run the full gate before opening a PR:
|
|
802
838
|
|
|
803
839
|
```bash
|
|
804
|
-
pnpm typecheck && pnpm lint && pnpm test:cov:all && pnpm build && pnpm size
|
|
840
|
+
pnpm typecheck && pnpm lint && pnpm check:admin-readonly && pnpm test:cov:all && pnpm build && pnpm size && pnpm check:exports
|
|
805
841
|
```
|
|
806
842
|
|
|
807
843
|
See [CONTRIBUTING.md](./CONTRIBUTING.md) and [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md) for the full process.
|
package/dist/server/index.cjs
CHANGED
|
@@ -711,7 +711,7 @@ exports.KeyBuilder = __decorateClass([
|
|
|
711
711
|
common.Injectable(),
|
|
712
712
|
__decorateParam(0, common.Inject(BYMAX_CACHE_OPTIONS))
|
|
713
713
|
], exports.KeyBuilder);
|
|
714
|
-
var
|
|
714
|
+
var DESERIALIZATION_ERROR = "stored value is not valid JSON";
|
|
715
715
|
var extractErrorMessage = (err) => err instanceof Error ? err.message : String(err);
|
|
716
716
|
exports.JsonSerializer = class JsonSerializer {
|
|
717
717
|
/**
|
|
@@ -753,17 +753,17 @@ exports.JsonSerializer = class JsonSerializer {
|
|
|
753
753
|
* @param raw - The JSON string to decode.
|
|
754
754
|
* @returns The decoded value, typed as `T`.
|
|
755
755
|
* @throws {CacheException} `DESERIALIZATION_FAILED` when `raw` is not valid
|
|
756
|
-
* JSON. `details
|
|
757
|
-
*
|
|
758
|
-
*
|
|
756
|
+
* JSON. `details` holds a fixed `error` text and the `length` of `raw`; no
|
|
757
|
+
* part of the stored value is echoed, as it may carry PII or a credential
|
|
758
|
+
* (CLAUDE.md §4).
|
|
759
759
|
*/
|
|
760
760
|
deserialize(raw) {
|
|
761
761
|
try {
|
|
762
762
|
return JSON.parse(raw);
|
|
763
|
-
} catch
|
|
763
|
+
} catch {
|
|
764
764
|
throw new CacheException(CACHE_ERROR_CODES.DESERIALIZATION_FAILED, {
|
|
765
|
-
error:
|
|
766
|
-
|
|
765
|
+
error: DESERIALIZATION_ERROR,
|
|
766
|
+
length: raw.length
|
|
767
767
|
});
|
|
768
768
|
}
|
|
769
769
|
}
|
package/dist/server/index.d.cts
CHANGED
|
@@ -971,9 +971,9 @@ declare class JsonSerializer implements ISerializer {
|
|
|
971
971
|
* @param raw - The JSON string to decode.
|
|
972
972
|
* @returns The decoded value, typed as `T`.
|
|
973
973
|
* @throws {CacheException} `DESERIALIZATION_FAILED` when `raw` is not valid
|
|
974
|
-
* JSON. `details
|
|
975
|
-
*
|
|
976
|
-
*
|
|
974
|
+
* JSON. `details` holds a fixed `error` text and the `length` of `raw`; no
|
|
975
|
+
* part of the stored value is echoed, as it may carry PII or a credential
|
|
976
|
+
* (CLAUDE.md §4).
|
|
977
977
|
*/
|
|
978
978
|
deserialize<T>(raw: string): T;
|
|
979
979
|
}
|
package/dist/server/index.d.ts
CHANGED
|
@@ -971,9 +971,9 @@ declare class JsonSerializer implements ISerializer {
|
|
|
971
971
|
* @param raw - The JSON string to decode.
|
|
972
972
|
* @returns The decoded value, typed as `T`.
|
|
973
973
|
* @throws {CacheException} `DESERIALIZATION_FAILED` when `raw` is not valid
|
|
974
|
-
* JSON. `details
|
|
975
|
-
*
|
|
976
|
-
*
|
|
974
|
+
* JSON. `details` holds a fixed `error` text and the `length` of `raw`; no
|
|
975
|
+
* part of the stored value is echoed, as it may carry PII or a credential
|
|
976
|
+
* (CLAUDE.md §4).
|
|
977
977
|
*/
|
|
978
978
|
deserialize<T>(raw: string): T;
|
|
979
979
|
}
|
package/dist/server/index.mjs
CHANGED
|
@@ -709,7 +709,7 @@ KeyBuilder = __decorateClass([
|
|
|
709
709
|
Injectable(),
|
|
710
710
|
__decorateParam(0, Inject(BYMAX_CACHE_OPTIONS))
|
|
711
711
|
], KeyBuilder);
|
|
712
|
-
var
|
|
712
|
+
var DESERIALIZATION_ERROR = "stored value is not valid JSON";
|
|
713
713
|
var extractErrorMessage = (err) => err instanceof Error ? err.message : String(err);
|
|
714
714
|
var JsonSerializer = class {
|
|
715
715
|
/**
|
|
@@ -751,17 +751,17 @@ var JsonSerializer = class {
|
|
|
751
751
|
* @param raw - The JSON string to decode.
|
|
752
752
|
* @returns The decoded value, typed as `T`.
|
|
753
753
|
* @throws {CacheException} `DESERIALIZATION_FAILED` when `raw` is not valid
|
|
754
|
-
* JSON. `details
|
|
755
|
-
*
|
|
756
|
-
*
|
|
754
|
+
* JSON. `details` holds a fixed `error` text and the `length` of `raw`; no
|
|
755
|
+
* part of the stored value is echoed, as it may carry PII or a credential
|
|
756
|
+
* (CLAUDE.md §4).
|
|
757
757
|
*/
|
|
758
758
|
deserialize(raw) {
|
|
759
759
|
try {
|
|
760
760
|
return JSON.parse(raw);
|
|
761
|
-
} catch
|
|
761
|
+
} catch {
|
|
762
762
|
throw new CacheException(CACHE_ERROR_CODES.DESERIALIZATION_FAILED, {
|
|
763
|
-
error:
|
|
764
|
-
|
|
763
|
+
error: DESERIALIZATION_ERROR,
|
|
764
|
+
length: raw.length
|
|
765
765
|
});
|
|
766
766
|
}
|
|
767
767
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bymax-one/nest-cache",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.3",
|
|
4
4
|
"description": "Typed Redis cache for NestJS based on ioredis 6, with namespace strategy, Pub/Sub and Lua script management.",
|
|
5
5
|
"author": "Bymax One <support@bymax.one>",
|
|
6
6
|
"license": "MIT",
|