@bymax-one/nest-cache 1.0.6 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +115 -1
- package/README.md +118 -9
- package/dist/admin/index.cjs +741 -0
- package/dist/admin/index.d.cts +967 -0
- package/dist/admin/index.d.ts +967 -0
- package/dist/admin/index.mjs +723 -0
- package/dist/server/index.cjs +82 -28
- package/dist/server/index.d.cts +25 -1
- package/dist/server/index.d.ts +25 -1
- package/dist/server/index.mjs +80 -29
- package/dist/shared/index.cjs +5 -1
- package/dist/shared/index.d.cts +4 -0
- package/dist/shared/index.d.ts +4 -0
- package/dist/shared/index.mjs +5 -1
- package/package.json +20 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,7 +4,119 @@ 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
|
-
## [
|
|
7
|
+
## [1.2.0] - 2026-08-21
|
|
8
|
+
|
|
9
|
+
### Security
|
|
10
|
+
|
|
11
|
+
- **`validateOptions` now rejects a namespace containing a Redis glob metacharacter
|
|
12
|
+
(`*`, `?`, `[`, `\`).** The namespace is this library's isolation boundary and it was
|
|
13
|
+
composed unvalidated into `flushNamespace`'s destructive match pattern
|
|
14
|
+
(`{namespace}{separator}*`), so a metacharacter changed which keys `UNLINK` reached.
|
|
15
|
+
Measured against Redis 8.10.0, each one broke isolation differently: `*` and `?` **widen**
|
|
16
|
+
the pattern (namespace `ten*ant` matches every other tenant's keys, turning a scoped flush
|
|
17
|
+
into a cross-tenant delete); `\` **escapes** the next character (namespace `ten\ant`
|
|
18
|
+
matches `tenant:*` — a different keyspace — while sparing its own keys); and `[` opens a
|
|
19
|
+
character class that never closes, so the pattern matches **nothing** and `flushNamespace`
|
|
20
|
+
removes none of the namespace's keys while returning `0`, which reads as a successful flush.
|
|
21
|
+
Triggering it required a misconfigured namespace, so no default configuration was exposed;
|
|
22
|
+
the case to worry about is a namespace derived from input, such as multi-tenant wiring using
|
|
23
|
+
a tenant slug. `]` is deliberately still accepted — measured to be a literal that neither
|
|
24
|
+
widens nor silences the pattern.
|
|
25
|
+
- An **empty `keySeparator`** now fails with its own message. It was already rejected, but by
|
|
26
|
+
coincidence: the next guard is `namespace.includes(separator)` and `'anything'.includes('')`
|
|
27
|
+
is `true` for every string, so it reported _"namespace contains key separator"_ — something
|
|
28
|
+
the consumer had not done.
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
|
|
32
|
+
- **New subpath `@bymax-one/nest-cache/admin`** — a privileged, read-only administration
|
|
33
|
+
surface: health, parsed `INFO` statistics, resolved configuration, keyspace listing, key
|
|
34
|
+
inspection and value reveal. Kept out of the main entry deliberately: importing it is a
|
|
35
|
+
greppable, reviewable act; a consumer who never wires it cannot resolve a reveal service
|
|
36
|
+
from DI by accident and does not pay for it in the main bundle.
|
|
37
|
+
- `BymaxCacheAdminModule.forRoot()` / `.forRootAsync()`, `CacheStatusService`,
|
|
38
|
+
`CacheAdminService`, and the scope model (`CacheScope`, `validateScopes`, `findScope`,
|
|
39
|
+
`isKeyInScope`).
|
|
40
|
+
- New error codes: `cache.invalid_scope`, `cache.scope_not_found`, `cache.scope_not_readable`,
|
|
41
|
+
`cache.key_not_in_scope`.
|
|
42
|
+
- `ResolvedOptions` and `DEFAULT_REDIS_PORT` are now exported from the main entry. The former
|
|
43
|
+
is the shape stored under the already-exported `BYMAX_CACHE_OPTIONS` token, which previously
|
|
44
|
+
had no public type.
|
|
45
|
+
- `pnpm check:admin-readonly` — a build gate that fails if the admin subpath declares a method
|
|
46
|
+
named after a mutating Redis command, sends a non-allowlisted command through the `call`
|
|
47
|
+
escape hatch, or imports `ioredis` as a value. Wired into `prepublishOnly`.
|
|
48
|
+
|
|
49
|
+
### Administration surface — shapes chosen deliberately
|
|
50
|
+
|
|
51
|
+
- **The application declares which keyspaces exist; the library validates and serves them.**
|
|
52
|
+
A cache library cannot know that another library writes at Redis root through
|
|
53
|
+
`getClient()`, and must not depend on that library to learn it.
|
|
54
|
+
- **`isReadable: false` withholds the value only.** Listing, types, TTLs and sizes stay
|
|
55
|
+
available. A surface that renders an unreadable keyspace as empty tells an operator the
|
|
56
|
+
region holds nothing when it is full — the same defect as a blank log page during an outage.
|
|
57
|
+
- **Scope patterns are restricted to a literal prefix with at most one trailing `*`.** A
|
|
58
|
+
caller names a key, so the library must decide whether that key belongs to the named scope.
|
|
59
|
+
Deciding that for arbitrary globs means reimplementing Redis's `stringmatchlen`, and a
|
|
60
|
+
matcher even slightly _more_ permissive than the server's is a silent cross-scope leak that
|
|
61
|
+
no happy-path test would show. With this shape, membership is exact by construction — and it
|
|
62
|
+
is checked differentially against a real server in the E2E suite.
|
|
63
|
+
- **Health is three states and `latencyMs` cannot exist without a measurement.** The type is
|
|
64
|
+
a union, so a handler that caught a throwing ping and returned a confident status does not
|
|
65
|
+
compile. `mode` and `isScanSupported` sit outside the union: a cluster deployment that is
|
|
66
|
+
down should still report that scanning was never going to work.
|
|
67
|
+
- **Every reading that would carry two meanings under one `null` is a union.**
|
|
68
|
+
`maxmemory` is `unbounded | limited | unreported` (Redis spells "no ceiling" as
|
|
69
|
+
`maxmemory:0`, which read literally draws a full saturation bar on the least constrained
|
|
70
|
+
server there is); `TTL` is `expiring | persistent | missing` (`-1` and `-2` are different
|
|
71
|
+
facts, and the key that expired mid-listing is the one an operator is watching);
|
|
72
|
+
`aofEnabled` is nullable, because `false` for an absent field is a durability claim made
|
|
73
|
+
without evidence.
|
|
74
|
+
- **`connection.url` is never on the wire.** The config payload carries host, port and a TLS
|
|
75
|
+
flag; the URL and its password are never read into this subpath at all.
|
|
76
|
+
- **Sampled figures are named `sampledCount` / `sampledBytes`.** They are sums over a capped
|
|
77
|
+
`SCAN`, not measurements of the keyspace. `isComplete` is the fact; the names are the guard,
|
|
78
|
+
because a caller reaching for one does not necessarily read the other.
|
|
79
|
+
- **Pipeline batches are bounded in commands, not keys.** Redis is single-threaded, so a
|
|
80
|
+
pipeline converts a network cost into a server-blocking one — describing N keys is two or
|
|
81
|
+
three commands each, and one flush blocks every other client for the whole burst, on a
|
|
82
|
+
server someone is inspecting precisely because it is unwell. Sizing is opt-in for the same
|
|
83
|
+
reason.
|
|
84
|
+
|
|
85
|
+
### Internal
|
|
86
|
+
|
|
87
|
+
- Bundle-size budgets recalibrated: server `14.50` → `15.00` kB, admin added at `7.25` kB
|
|
88
|
+
against a measured `6.60` kB. The admin entry marks `@bymax-one/nest-cache` **external** —
|
|
89
|
+
bundling the server modules would give it its own copies of `CacheService` and the DI
|
|
90
|
+
tokens, so `@Inject(CacheService)` in an admin provider would name a different class object
|
|
91
|
+
than the one `BymaxCacheModule` registered and DI would fail at a consumer's runtime.
|
|
92
|
+
- E2E coverage for the admin subpath against a real Redis, asserting all twenty-four `INFO`
|
|
93
|
+
field names the parser reads, real `MEMORY USAGE` sizing, and the differential scope-membership
|
|
94
|
+
check. `ioredis-mock` supports none of those three (measured), so a unit suite alone could not
|
|
95
|
+
have verified them.
|
|
96
|
+
|
|
97
|
+
## [1.1.0] - 2026-08-11
|
|
98
|
+
|
|
99
|
+
### Changed
|
|
100
|
+
|
|
101
|
+
- **BREAKING: peer dependency `ioredis` migrated `^5` → `^6`.** A consumer must move to
|
|
102
|
+
ioredis 6, which aligns this package with `@bymax-one/nest-queue` so a single ioredis copy
|
|
103
|
+
resolves across a workspace that uses both. ioredis 6 negotiates **RESP3** on the wire by
|
|
104
|
+
default — the protocol changes at runtime — but its `'legacy'` reply mapping preserves every
|
|
105
|
+
reply shape this cache relies on (GET/SET/TTL, EVALSHA, Pub/Sub message events, cluster and
|
|
106
|
+
sentinel commands), so observable behaviour is unchanged; the only source change is the
|
|
107
|
+
compile-time typing narrowing below. ioredis 6 requires Node.js ≥ 20, already covered by
|
|
108
|
+
this package's `engines` (Node ≥ 24).
|
|
109
|
+
|
|
110
|
+
### Internal
|
|
111
|
+
|
|
112
|
+
- `ConnectionManager` narrows the options handed to the `Redis` and `Cluster` constructors
|
|
113
|
+
to a local shape (`OwnedRedisOptions` / `OwnedClusterOptions`). ioredis 6's constructor
|
|
114
|
+
overloads intersect `replyMapping` with a non-`undefined` variant to infer the
|
|
115
|
+
reply-mapping generic, which makes a plain `RedisOptions`/`ClusterOptions` value
|
|
116
|
+
unassignable under `exactOptionalPropertyTypes`. The narrowing drops only that `undefined`;
|
|
117
|
+
the runtime object is untouched.
|
|
118
|
+
- Mutation gate tightened: Stryker `break`/`high`/`low` raised to **100** (the run is at
|
|
119
|
+
100%, 0 survivors).
|
|
8
120
|
|
|
9
121
|
## [1.0.6] - 2026-08-06
|
|
10
122
|
|
|
@@ -168,6 +280,8 @@ type or export moved.
|
|
|
168
280
|
- Published with npm OIDC provenance — no long-lived tokens
|
|
169
281
|
- Zero direct runtime dependencies (`dependencies: {}`) — `ioredis` and NestJS via peer deps
|
|
170
282
|
|
|
283
|
+
[1.2.0]: https://github.com/bymaxone/nest-cache/compare/v1.1.0...v1.2.0
|
|
284
|
+
[1.1.0]: https://github.com/bymaxone/nest-cache/compare/v1.0.6...v1.1.0
|
|
171
285
|
[1.0.6]: https://github.com/bymaxone/nest-cache/compare/v1.0.5...v1.0.6
|
|
172
286
|
[1.0.5]: https://github.com/bymaxone/nest-cache/compare/v1.0.4...v1.0.5
|
|
173
287
|
[1.0.4]: https://github.com/bymaxone/nest-cache/compare/v1.0.3...v1.0.4
|
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
<p align="center">
|
|
8
8
|
<strong>Typed Redis cache for NestJS</strong><br />
|
|
9
|
-
<sub>ioredis
|
|
9
|
+
<sub>ioredis 6 · Namespacing · Pub/Sub · Lua Scripts · Multi-Tenant · Zero Runtime Dependencies</sub>
|
|
10
10
|
</p>
|
|
11
11
|
|
|
12
12
|
<p align="center">
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
<a href="https://www.npmjs.com/package/@bymax-one/nest-cache"><img src="https://img.shields.io/npm/dm/@bymax-one/nest-cache?style=flat-square&colorA=000000&colorB=000000" alt="npm downloads" /></a>
|
|
15
15
|
<a href="https://github.com/bymaxone/nest-cache/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/bymaxone/nest-cache/ci.yml?branch=main&style=flat-square&colorA=000000&label=CI" alt="CI status" /></a>
|
|
16
16
|
<a href="https://github.com/bymaxone/nest-cache/actions/workflows/ci.yml"><img src="https://img.shields.io/badge/coverage-100%25-brightgreen?style=flat-square&colorA=000000" alt="coverage" /></a>
|
|
17
|
-
<a href="https://github.com/bymaxone/nest-cache/blob/main/docs/mutation_testing_results.md"><img src="https://img.shields.io/badge/mutation-
|
|
17
|
+
<a href="https://github.com/bymaxone/nest-cache/blob/main/docs/mutation_testing_results.md"><img src="https://img.shields.io/badge/mutation-100%25-brightgreen?style=flat-square&colorA=000000" alt="mutation score" /></a>
|
|
18
18
|
<a href="https://scorecard.dev/viewer/?uri=github.com/bymaxone/nest-cache"><img src="https://api.scorecard.dev/projects/github.com/bymaxone/nest-cache/badge?style=flat-square" alt="OpenSSF Scorecard" /></a>
|
|
19
19
|
<a href="https://github.com/bymaxone/nest-cache/blob/main/LICENSE"><img src="https://img.shields.io/github/license/bymaxone/nest-cache?style=flat-square&colorA=000000&colorB=000000" alt="license" /></a>
|
|
20
20
|
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-strict-3178C6?style=flat-square&logo=typescript&logoColor=white" alt="TypeScript" /></a>
|
|
@@ -97,19 +97,22 @@ pnpm add @bymax-one/nest-cache ioredis
|
|
|
97
97
|
|
|
98
98
|
## 📦 Subpath Exports
|
|
99
99
|
|
|
100
|
-
One package,
|
|
100
|
+
One package, three entry points — import only what your app needs:
|
|
101
101
|
|
|
102
102
|
| Subpath | Import | Purpose | Dependencies |
|
|
103
103
|
| ---------- | ------------------------------ | -------------------------------------------------------------------------------------------------------- | :------------------------------------: |
|
|
104
|
-
| **Server** | `@bymax-one/nest-cache` | `BymaxCacheModule`, `CacheService`, `PubSubService`, `ScriptManagerService`, DI tokens, `CacheException` | NestJS 11, ioredis
|
|
104
|
+
| **Server** | `@bymax-one/nest-cache` | `BymaxCacheModule`, `CacheService`, `PubSubService`, `ScriptManagerService`, DI tokens, `CacheException` | NestJS 11, ioredis 6, reflect-metadata |
|
|
105
|
+
| **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 |
|
|
105
106
|
| **Shared** | `@bymax-one/nest-cache/shared` | Types + constants — `CACHE_ERROR_CODES`, `CacheEventName`, config types | None |
|
|
106
107
|
|
|
107
108
|
```
|
|
108
109
|
shared (zero deps)
|
|
109
110
|
↑
|
|
110
|
-
server
|
|
111
|
+
server ← admin
|
|
111
112
|
```
|
|
112
113
|
|
|
114
|
+
`/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.
|
|
115
|
+
|
|
113
116
|
The `/shared` subpath is safe to import in isomorphic code, test helpers, CLI scripts, or shared packages that must not pull in NestJS or ioredis.
|
|
114
117
|
|
|
115
118
|
---
|
|
@@ -386,6 +389,104 @@ export class HealthController {
|
|
|
386
389
|
|
|
387
390
|
---
|
|
388
391
|
|
|
392
|
+
## 🔎 Administration surface (`/admin`)
|
|
393
|
+
|
|
394
|
+
A read-only surface for an operator-facing console: is the cache answering, what is it doing, what is in it.
|
|
395
|
+
|
|
396
|
+
```ts
|
|
397
|
+
import { BymaxCacheModule } from '@bymax-one/nest-cache'
|
|
398
|
+
import { BymaxCacheAdminModule } from '@bymax-one/nest-cache/admin'
|
|
399
|
+
|
|
400
|
+
// `config` is a ConfigService reachable where the module is declared; see
|
|
401
|
+
// Scenario 4 above for the forRootAsync form that injects it properly.
|
|
402
|
+
@Module({
|
|
403
|
+
imports: [
|
|
404
|
+
BymaxCacheModule.forRoot({
|
|
405
|
+
connection: { url: config.getOrThrow<string>('REDIS_URL') },
|
|
406
|
+
namespace: 'my-app'
|
|
407
|
+
}),
|
|
408
|
+
BymaxCacheAdminModule.forRoot({
|
|
409
|
+
scopes: [
|
|
410
|
+
{
|
|
411
|
+
id: 'cache',
|
|
412
|
+
label: 'Application cache',
|
|
413
|
+
pattern: 'my-app:*',
|
|
414
|
+
isReadable: true,
|
|
415
|
+
origin: "the application's own namespace, written through the typed API"
|
|
416
|
+
},
|
|
417
|
+
{
|
|
418
|
+
id: 'auth',
|
|
419
|
+
label: 'Authentication',
|
|
420
|
+
pattern: 'auth:*',
|
|
421
|
+
isReadable: false,
|
|
422
|
+
origin:
|
|
423
|
+
'written by another library through the un-namespaced client, so it sits at Redis ' +
|
|
424
|
+
'root. Values are refused: this keyspace holds session records.'
|
|
425
|
+
}
|
|
426
|
+
]
|
|
427
|
+
})
|
|
428
|
+
]
|
|
429
|
+
})
|
|
430
|
+
export class AppModule {}
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
```ts
|
|
434
|
+
constructor(
|
|
435
|
+
@Inject(CacheStatusService) private readonly status: CacheStatusService,
|
|
436
|
+
@Inject(CacheAdminService) private readonly admin: CacheAdminService
|
|
437
|
+
) {}
|
|
438
|
+
|
|
439
|
+
await this.status.health() // { status: 'up', latencyMs: 3, mode, isScanSupported, degradedAboveMs }
|
|
440
|
+
await this.status.stats() // parsed INFO
|
|
441
|
+
this.status.config() // resolved wiring, connection URL withheld
|
|
442
|
+
this.admin.listScopes() // never touches the connection
|
|
443
|
+
await this.admin.listKeys('cache', { includeSize: true })
|
|
444
|
+
await this.admin.revealValue('auth', 'auth:sess:1') // { status: 'withheld', origin }
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
### What the library owns, and what you own
|
|
448
|
+
|
|
449
|
+
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.
|
|
450
|
+
|
|
451
|
+
### `isReadable: false` withholds the value — and nothing else
|
|
452
|
+
|
|
453
|
+
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.
|
|
454
|
+
|
|
455
|
+
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"_.
|
|
456
|
+
|
|
457
|
+
### Scope patterns: a literal prefix, optionally ending in `*`
|
|
458
|
+
|
|
459
|
+
`auth:*`, `my-app:*` and exact keys are accepted. `app:*:v1`, `*`, `a?b` and `a[bc]` are refused at wiring.
|
|
460
|
+
|
|
461
|
+
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`.
|
|
462
|
+
|
|
463
|
+
Do not relax this to be helpful. Widening it later is compatible; a leak is not un-shippable.
|
|
464
|
+
|
|
465
|
+
### Readings that would carry two meanings are unions, not nullables
|
|
466
|
+
|
|
467
|
+
| Reading | Type | Why |
|
|
468
|
+
| ------------ | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
469
|
+
| `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" |
|
|
470
|
+
| `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 |
|
|
471
|
+
| `aofEnabled` | `boolean \| null` | `false` for an absent field is a durability claim made without evidence |
|
|
472
|
+
| 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 |
|
|
473
|
+
|
|
474
|
+
`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.
|
|
475
|
+
|
|
476
|
+
### Costs the surface does not hide
|
|
477
|
+
|
|
478
|
+
- **`sampledCount` / `sampledBytes`** are sums over a capped `SCAN`, not measurements of the keyspace. `isComplete` is the fact; the names are the guard.
|
|
479
|
+
- **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.
|
|
480
|
+
- **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.
|
|
481
|
+
- **`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.
|
|
482
|
+
- **`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.
|
|
483
|
+
|
|
484
|
+
### Cluster
|
|
485
|
+
|
|
486
|
+
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`.
|
|
487
|
+
|
|
488
|
+
---
|
|
489
|
+
|
|
389
490
|
## 🏗️ Architecture
|
|
390
491
|
|
|
391
492
|
The package runs **inside** your NestJS application as a dynamic module — not as a separate service:
|
|
@@ -467,6 +568,10 @@ Error `details` are built to be safe to log:
|
|
|
467
568
|
|
|
468
569
|
Scripts are declared up front — through `options.scripts` or `ScriptManagerService.register(name, lua)` — and executed **by name**. A call site passes `eval(scriptName, keys, args)`; it has no way to pass a script body. Keys are namespaced before execution and arguments arrive as Redis `ARGV[]`, which Lua treats as data, so request input cannot become script source. Standalone and Sentinel use `EVALSHA` with a `NOSCRIPT` reload-and-retry; Cluster sends the full body via `EVAL`, because `EVALSHA` routes by key slot and a keyless reload would not reach the node that reported `NOSCRIPT`.
|
|
469
570
|
|
|
571
|
+
### The namespace cannot widen a destructive pattern
|
|
572
|
+
|
|
573
|
+
`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.
|
|
574
|
+
|
|
470
575
|
### Cluster mode refuses commands it cannot honor safely
|
|
471
576
|
|
|
472
577
|
`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.
|
|
@@ -483,6 +588,8 @@ When integrating `@bymax-one/nest-cache` in production, verify each of the follo
|
|
|
483
588
|
- Values that must not be readable by whoever can read Redis are encrypted by the application (or a custom `ISerializer`) before they are cached — the library stores what you hand it
|
|
484
589
|
- Cached entries carry a TTL sized to your data-retention policy; namespacing bounds who can read an entry, not how long it exists
|
|
485
590
|
- Custom `ISerializer` implementations throw on malformed input rather than returning a fallback value
|
|
591
|
+
- 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
|
|
592
|
+
- 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
|
|
486
593
|
|
|
487
594
|
---
|
|
488
595
|
|
|
@@ -491,7 +598,9 @@ When integrating `@bymax-one/nest-cache` in production, verify each of the follo
|
|
|
491
598
|
| Layer | Implementation |
|
|
492
599
|
| --------------------- | --------------------------------------------------------------------------------------------------------------------------- |
|
|
493
600
|
| Tenant Isolation | Every key and channel composed by `KeyBuilder` as `{namespace}{sep}{prefix}{sep}{id}` — no bare-key path in the API |
|
|
494
|
-
| Namespace Validation | Empty
|
|
601
|
+
| Namespace Validation | Empty, separator-containing, or glob-metacharacter namespace rejected at bootstrap (`INVALID_NAMESPACE`) |
|
|
602
|
+
| Admin Scope Allowlist | Scopes declared at wiring, validated and frozen; a caller names a scope by id and can never supply a match pattern |
|
|
603
|
+
| Admin Read-Only | The `/admin` subpath issues no mutating command — enforced by the `check:admin-readonly` build gate, not by convention |
|
|
495
604
|
| Key Validation | Empty `prefix` / `id` rejected before the command is issued (`INVALID_KEY`) |
|
|
496
605
|
| Deserialization | Fails closed — `DESERIALIZATION_FAILED`; never a partial or wrongly-typed value |
|
|
497
606
|
| Serialization | Top-level `undefined` / function / symbol rejected; the value is never echoed into `details` |
|
|
@@ -511,7 +620,7 @@ When integrating `@bymax-one/nest-cache` in production, verify each of the follo
|
|
|
511
620
|
## 🧱 Tech Stack
|
|
512
621
|
|
|
513
622
|
[](https://nestjs.com)
|
|
514
|
-
[](https://github.com/redis/ioredis)
|
|
515
624
|
[](https://www.typescriptlang.org)
|
|
516
625
|
[](https://nodejs.org)
|
|
517
626
|
[](https://jestjs.io)
|
|
@@ -526,8 +635,8 @@ When integrating `@bymax-one/nest-cache` in production, verify each of the follo
|
|
|
526
635
|
A cache is consulted on the hot path of every request that touches it, so the suite is held to a bar beyond "it runs" — every behavior is pinned so that a regression **fails a test**.
|
|
527
636
|
|
|
528
637
|
- ✅ **100% line coverage** — statements, branches, functions, and lines, enforced by `jest.coverage.config.ts` as a pre-publish gate, not a target
|
|
529
|
-
- ✅ **
|
|
530
|
-
- ✅ **
|
|
638
|
+
- ✅ **100% mutation score** — verified with [Stryker](https://stryker-mutator.io/) at `break: 100` and `ignoreStatic: false`; 441 killed, 6 timed out, **0 survived**, and [documented in full](./docs/mutation_testing_results.md)
|
|
639
|
+
- ✅ **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
|
|
531
640
|
- ✅ **No real Redis in unit tests** — `ioredis-mock` throughout; e2e tests exercise the wired module through `@nestjs/testing` and Testcontainers against a real Redis for connection lifecycle, Pub/Sub, and Lua scripts
|
|
532
641
|
- ✅ **Published-package smoke test** — `scripts/dogfood-smoke-test.mjs` validates exports, tarball shape, and a consumer install before tagging
|
|
533
642
|
|