@bymax-one/nest-cache 1.2.2 → 1.2.4
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 +113 -0
- package/README.md +144 -58
- package/dist/admin/index.d.cts +37 -0
- package/dist/admin/index.d.ts +37 -0
- package/dist/server/index.cjs +49 -13
- package/dist/server/index.d.cts +19 -7
- package/dist/server/index.d.ts +19 -7
- package/dist/server/index.mjs +49 -13
- package/dist/shared/index.cjs +2 -1
- package/dist/shared/index.d.cts +1 -0
- package/dist/shared/index.d.ts +1 -0
- package/dist/shared/index.mjs +2 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,116 @@ 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.4] - 2026-10-31
|
|
10
|
+
|
|
11
|
+
Two calls now refuse input they could not honor, one error code is added, and the documentation catches up with the code.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **`CACHE_ERROR_CODES.INVALID_TTL` (`cache.invalid_ttl`, HTTP 400)**, thrown for a TTL the library refuses. See Changed.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- **`CacheService.keys()` is refused in cluster mode.** `KEYS` carries no key, so the cluster client sends
|
|
20
|
+
it to one sampled node and the reply listed only that node's share of the keyspace, with no sign that it
|
|
21
|
+
was partial. It now throws `UNSUPPORTED_IN_CLUSTER` with `details.operation: 'keys'`, like `scan`,
|
|
22
|
+
`getClient` and `flushNamespace`. **Apply to a consumer.** A cluster deployment that called `keys()`
|
|
23
|
+
must iterate with `scan` on each node through its own client, or avoid listing keys; standalone and
|
|
24
|
+
sentinel are unchanged.
|
|
25
|
+
- **`ttlSeconds` is validated, with a new error code, `cache.invalid_ttl` (HTTP 400).** `set`, `setRaw`
|
|
26
|
+
and `setNx` need a safe integer of at least 1, or none for no expiry; `expire` needs a safe integer,
|
|
27
|
+
and zero or a negative one still deletes the key, as in Redis. Anything else throws `CacheException`
|
|
28
|
+
with `INVALID_TTL` and `details.reason` set to `not_a_safe_integer` or `below_minimum`, before a command
|
|
29
|
+
is sent. **Apply to a consumer.** What now throws `INVALID_TTL`: `set`, `setRaw`, `setNx` with `0`, a negative number, a fraction, `NaN`, `Infinity` or an integer above 2^53; `expire` with a fraction, `NaN`, `Infinity` or an integer above 2^53. Redis documents `SET ... EX` as a positive integer and `EXPIRE` as an
|
|
30
|
+
integer, and answers anything else with a raw reply error that carries no `cache.*` code, so the values
|
|
31
|
+
refused here already failed. The one exception is an integer above 2^53 seconds, a TTL of about 285
|
|
32
|
+
million years, which this refuses and Redis may accept. Code that caught the raw error by its message
|
|
33
|
+
should switch to `error.code === 'cache.invalid_ttl'`.
|
|
34
|
+
|
|
35
|
+
### Deprecated
|
|
36
|
+
|
|
37
|
+
- **Implementation helpers exported from `@bymax-one/nest-cache/admin`** are marked `@deprecated` and will
|
|
38
|
+
leave the public surface in 2.0.0: `validateScopes`, `findScope`, `isKeyInScope`, `readScopePattern`,
|
|
39
|
+
`resolveAdminOptions`, `ResolvedAdminOptions`, `parseInfoFields`, `readRedisStats`, `readKeyTtl`,
|
|
40
|
+
`readKeyType`, `readSizeBytes`, `IRedisReader`, `IRedisReaderSource`, `IRedisPipeline`,
|
|
41
|
+
`PipelineReply` and `ICacheProbe`. They keep working in 1.x. Do not depend on them.
|
|
42
|
+
|
|
43
|
+
### Documentation
|
|
44
|
+
|
|
45
|
+
- The README error-code table described `cache.shutdown_timeout` as thrown when `quit()` exceeds
|
|
46
|
+
`shutdownTimeoutMs`. It never reaches the caller: the library forces `disconnect()` and emits an
|
|
47
|
+
`error` event with `reason: 'forced_disconnect'`. The row now says so.
|
|
48
|
+
- The README states that `app.enableShutdownHooks()` is required for the graceful shutdown to run, and
|
|
49
|
+
carries a warning that `/admin` has no authentication of its own. It documents the `/admin` options
|
|
50
|
+
and their defaults and the exported types, constants and tokens it left out, lists `keys` among the
|
|
51
|
+
cluster-refused calls, and marks the three codes the library never throws as reserved.
|
|
52
|
+
- The sentinel example passed `process.env.REDIS_PASSWORD` as `password`, which does not compile
|
|
53
|
+
for a consumer that enables `exactOptionalPropertyTypes`. It now falls back to an empty string,
|
|
54
|
+
which ioredis treats like no password.
|
|
55
|
+
|
|
56
|
+
### Tests
|
|
57
|
+
|
|
58
|
+
- `src/readme.spec.ts` parses the README's option-defaults table and error-code table and asserts them
|
|
59
|
+
against the resolved defaults, the minimum timeouts, `CACHE_ERROR_CODES` and the status each
|
|
60
|
+
`CacheException` carries, so a README that drifts from the code fails CI.
|
|
61
|
+
|
|
62
|
+
### Internal
|
|
63
|
+
|
|
64
|
+
- `check:published` compiles every README snippet, not only the ones that import the package, through the
|
|
65
|
+
TypeScript API so a syntax error in one snippet no longer hides the type errors in the others. A
|
|
66
|
+
snippet that omits its import is given the package's public names, and the names a snippet takes from
|
|
67
|
+
the surrounding prose are typed in `scripts/readme-prelude.d.ts`, so a wrong method, option or field is
|
|
68
|
+
an error instead of a call on `any`.
|
|
69
|
+
- The same gate no longer excuses a wrong method on an imported type or on a union-typed value as a
|
|
70
|
+
class the README abbreviates, and a relative import in a snippet that resolves to nothing fails it.
|
|
71
|
+
- The same gate now checks the typed names the snippets rely on, so a name in that declaration file that
|
|
72
|
+
stops resolving is an error instead of silently becoming `any`, and it tolerates a parse error only
|
|
73
|
+
where the parse tree shows a `...` elision, so a genuine syntax error in a snippet fails it.
|
|
74
|
+
|
|
75
|
+
## [1.2.3] - 2026-10-30
|
|
76
|
+
|
|
77
|
+
### Changed
|
|
78
|
+
|
|
79
|
+
- `JsonSerializer.deserialize` throws `DESERIALIZATION_FAILED` with `details` of a fixed `error` text and the
|
|
80
|
+
`length` of the stored value. `details.preview` and the parser's own message, which quoted the start of the
|
|
81
|
+
input, are gone. The error code is unchanged; a consumer that read `details.preview` must stop.
|
|
82
|
+
- **Apply to a consumer.** Do not read `details.preview` or `details.error` to debug a decoding
|
|
83
|
+
failure. Log the error on the server by its code, `cache.deserialization_failed`; the call site already
|
|
84
|
+
knows the prefix and id it asked for. Use `details.length` when you need a size signal. A backend that
|
|
85
|
+
sent `details` to its clients needs no other change: the response no longer carries any part of the
|
|
86
|
+
stored value.
|
|
87
|
+
- Apart from that, nothing you execute changes.
|
|
88
|
+
|
|
89
|
+
### Documentation
|
|
90
|
+
|
|
91
|
+
- `README.md`, `docs/technical_specification.md` and the contributor documents state that
|
|
92
|
+
`KeyBuilder` validates `prefix` and `id` only for emptiness. A separator inside either
|
|
93
|
+
collides (`build('a', 'b:c')` and `build('a:b', 'c')` both give `app:a:b:c`), and a glob
|
|
94
|
+
metacharacter in `prefix` widens `keys()` and `scan()` within the namespace. Behaviour is
|
|
95
|
+
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 `':'`.
|
|
96
|
+
- Contributor and security documents match the current tooling: pnpm 11, the admin
|
|
97
|
+
read-only check in the verification chain, mutation testing after merge and weekly, and
|
|
98
|
+
how dependency advisories are handled.
|
|
99
|
+
|
|
100
|
+
### Security
|
|
101
|
+
|
|
102
|
+
- **A non-JSON stored value no longer appears in the exception body.** `CacheException` extends
|
|
103
|
+
`HttpException`, so a default Nest filter sends `details` to the HTTP client. A cached value that was
|
|
104
|
+
not JSON (written by another writer, or by another serializer) leaked up to 100 characters of itself
|
|
105
|
+
through `details.preview`, and the start of it through `details.error`. It could carry PII or a token.
|
|
106
|
+
Only a malformed entry triggers it; a valid JSON value never does.
|
|
107
|
+
- Dependency advisories in development-only transitive dependencies are fixed by raising the
|
|
108
|
+
`overrides` floors in `pnpm-workspace.yaml`. None of them ships: the package
|
|
109
|
+
publishes with `"dependencies": {}`, so no consumer was exposed to these advisories.
|
|
110
|
+
- `GHSA-gcqx-w48m-w2h4` (`deepmerge`, reached only through `jest-config`) has no patched
|
|
111
|
+
release. It is suppressed in `osv-scanner.toml` until 2027-01-27 and nowhere else.
|
|
112
|
+
|
|
113
|
+
### Internal
|
|
114
|
+
|
|
115
|
+
- Development dependencies updated within their ranges; GitHub Actions bumped.
|
|
116
|
+
|
|
7
117
|
## [1.2.2] - 2026-09-04
|
|
8
118
|
|
|
9
119
|
### Changed
|
|
@@ -350,6 +460,9 @@ type or export moved.
|
|
|
350
460
|
- Published with npm OIDC provenance — no long-lived tokens
|
|
351
461
|
- Zero direct runtime dependencies (`dependencies: {}`) — `ioredis` and NestJS via peer deps
|
|
352
462
|
|
|
463
|
+
[Unreleased]: https://github.com/bymaxone/nest-cache/compare/v1.2.4...HEAD
|
|
464
|
+
[1.2.4]: https://github.com/bymaxone/nest-cache/compare/v1.2.3...v1.2.4
|
|
465
|
+
[1.2.3]: https://github.com/bymaxone/nest-cache/compare/v1.2.2...v1.2.3
|
|
353
466
|
[1.2.2]: https://github.com/bymaxone/nest-cache/compare/v1.2.1...v1.2.2
|
|
354
467
|
[1.2.1]: https://github.com/bymaxone/nest-cache/compare/v1.2.0...v1.2.1
|
|
355
468
|
[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
|
|
@@ -171,7 +171,7 @@ BymaxCacheModule.forRoot({
|
|
|
171
171
|
{ host: 'sentinel2.example.com', port: 26379 }
|
|
172
172
|
],
|
|
173
173
|
name: 'mymaster',
|
|
174
|
-
password: process.env.REDIS_PASSWORD
|
|
174
|
+
password: process.env.REDIS_PASSWORD ?? ''
|
|
175
175
|
},
|
|
176
176
|
namespace: 'app'
|
|
177
177
|
})
|
|
@@ -219,6 +219,15 @@ import { BymaxCacheModule } from '@bymax-one/nest-cache'
|
|
|
219
219
|
export class AppModule {}
|
|
220
220
|
```
|
|
221
221
|
|
|
222
|
+
> [!IMPORTANT]
|
|
223
|
+
> **Call `app.enableShutdownHooks()` in `main.ts`.** Nest runs `OnModuleDestroy` on `SIGTERM` / `SIGINT` only when shutdown hooks are enabled. Without it the process exits on a signal without quitting the Redis connection gracefully, and `shutdownTimeoutMs` never comes into play.
|
|
224
|
+
|
|
225
|
+
```typescript
|
|
226
|
+
const app = await NestFactory.create(AppModule)
|
|
227
|
+
app.enableShutdownHooks()
|
|
228
|
+
await app.listen(3000)
|
|
229
|
+
```
|
|
230
|
+
|
|
222
231
|
### 3. Inject `CacheService`
|
|
223
232
|
|
|
224
233
|
The module is global by default, so no re-import is needed in feature modules:
|
|
@@ -248,18 +257,25 @@ Keys resolve to `app:user-profile:<userId>` — namespaced automatically.
|
|
|
248
257
|
|
|
249
258
|
## ⚙️ Configuration
|
|
250
259
|
|
|
251
|
-
| Option
|
|
252
|
-
|
|
|
253
|
-
| `mode`
|
|
254
|
-
| `connection.url`
|
|
255
|
-
| `connection.tls`
|
|
256
|
-
| `namespace`
|
|
257
|
-
| `serializer`
|
|
258
|
-
| `events.onEvent`
|
|
259
|
-
| `scripts`
|
|
260
|
-
| `shutdownTimeoutMs`
|
|
261
|
-
|
|
262
|
-
|
|
260
|
+
| Option | Type | Default | Description |
|
|
261
|
+
| ------------------------ | ----------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
262
|
+
| `mode` | `'standalone' \| 'sentinel' \| 'cluster'` | `'standalone'` | Redis topology |
|
|
263
|
+
| `connection.url` | `string` | — | `redis://` / `rediss://` URL (overrides discrete host/port fields) |
|
|
264
|
+
| `connection.tls` | `tls.ConnectionOptions` | — | TLS options for `rediss://` |
|
|
265
|
+
| `namespace` | `string` | `'app'` | Key prefix for tenant/feature isolation |
|
|
266
|
+
| `serializer` | `ISerializer` | `JsonSerializer` | Value encoding/decoding (plug MsgPack, CBOR, etc.) |
|
|
267
|
+
| `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 |
|
|
268
|
+
| `scripts` | `IScriptDefinition[]` | — | Lua scripts to preload on init |
|
|
269
|
+
| `shutdownTimeoutMs` | `number` | `5000` | Graceful `quit()` timeout before forced `disconnect()` (minimum `100`) |
|
|
270
|
+
| `keySeparator` | `string` | `':'` | Separator between namespace, prefix and id segments |
|
|
271
|
+
| `allowFlushInProduction` | `boolean` | `false` | Allows `flushNamespace()` when `NODE_ENV=production` |
|
|
272
|
+
| `isGlobal` | `boolean` | `true` | Register the module globally |
|
|
273
|
+
| `sentinel` | `{ sentinels, name, ... }` | — | Sentinel connection block; required when `mode: 'sentinel'` |
|
|
274
|
+
| `cluster` | `{ nodes, options? }` | — | Cluster connection block; required when `mode: 'cluster'` |
|
|
275
|
+
|
|
276
|
+
`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.
|
|
277
|
+
|
|
278
|
+
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
279
|
|
|
264
280
|
---
|
|
265
281
|
|
|
@@ -271,6 +287,26 @@ keeps tenants and features isolated and makes `flushNamespace()` surgical. Reach
|
|
|
271
287
|
`getClient()` to set raw, un-namespaced keys is supported as an escape hatch but documented as an
|
|
272
288
|
anti-pattern.
|
|
273
289
|
|
|
290
|
+
`prefix` and `id` are supplied by the caller and the library validates them only for emptiness
|
|
291
|
+
(`INVALID_KEY`); it does **not** reject the separator or glob characters inside them. Two
|
|
292
|
+
consequences follow:
|
|
293
|
+
|
|
294
|
+
- **Separator collision.** `build('a', 'b:c')` and `build('a:b', 'c')` both produce `app:a:b:c`, so
|
|
295
|
+
two different `(prefix, id)` pairs address the same key.
|
|
296
|
+
- **Glob widening.** `keys(prefix, pattern)` and `scan(prefix, pattern)` pass the composed key to
|
|
297
|
+
Redis as a match pattern, so a glob metacharacter (`*`, `?`, `[`, `\`) in `prefix` can match keys
|
|
298
|
+
outside that prefix, still within the namespace. Single-key commands (`get`, `set`, `del`, ...)
|
|
299
|
+
treat the key literally and are not affected; `flushNamespace()` uses only the validated namespace.
|
|
300
|
+
|
|
301
|
+
This is documentation only; behavior is unchanged. When a `prefix` or `id` comes from untrusted
|
|
302
|
+
input, encode each field, for example `cache.get(encodeURIComponent(prefix), encodeURIComponent(id))`.
|
|
303
|
+
Keys are unambiguous only when `keySeparator` is a single character that appears in neither `prefix`
|
|
304
|
+
nor `id`. Per-field encoding guarantees that for a separator `encodeURIComponent` escapes and never
|
|
305
|
+
emits: the default `':'` qualifies; `%` and `- _ . ! ~ * ' ( )` do not, so for them reject the
|
|
306
|
+
separator in the fields. A multi-character separator can straddle a field boundary (with `'aa'`,
|
|
307
|
+
`('ba', 'c')` and `('b', 'ac')` build the same key), so keep a single character. A `prefix` used with
|
|
308
|
+
`keys()` / `scan()` must also avoid glob characters (`*`, `?`, `[`, `\`).
|
|
309
|
+
|
|
274
310
|
---
|
|
275
311
|
|
|
276
312
|
## 📡 Pub/Sub
|
|
@@ -393,6 +429,9 @@ export class HealthController {
|
|
|
393
429
|
|
|
394
430
|
A read-only surface for an operator-facing console: is the cache answering, what is it doing, what is in it.
|
|
395
431
|
|
|
432
|
+
> [!WARNING]
|
|
433
|
+
> **`/admin` has no authentication or authorization of its own.** The library registers services, not routes: `CacheStatusService` and `CacheAdminService` list keys, report `INFO` statistics and, for a readable scope, return stored values to whoever calls them. Put every route you build on them behind your own guard (an authenticated operator role), and never on a public route.
|
|
434
|
+
|
|
396
435
|
```ts
|
|
397
436
|
import { BymaxCacheModule } from '@bymax-one/nest-cache'
|
|
398
437
|
import { BymaxCacheAdminModule } from '@bymax-one/nest-cache/admin'
|
|
@@ -444,6 +483,21 @@ await this.admin.listKeys('cache', { includeSize: true })
|
|
|
444
483
|
await this.admin.revealValue('auth', 'auth:sess:1') // { status: 'withheld', origin }
|
|
445
484
|
```
|
|
446
485
|
|
|
486
|
+
### Options
|
|
487
|
+
|
|
488
|
+
| Option | Type | Default | Description |
|
|
489
|
+
| ------------------- | ----------------------- | ------- | ------------------------------------------------------------------------ |
|
|
490
|
+
| `scopes` | `readonly CacheScope[]` | — | The keyspaces this deployment exposes; required |
|
|
491
|
+
| `degradedAboveMs` | `number` | `250` | Probe latency above which `health()` reports `degraded` |
|
|
492
|
+
| `probeTimeoutMs` | `number` | `2000` | How long `health()` waits for `PING` before reporting `down` |
|
|
493
|
+
| `scanLimit` | `number` | `500` | Entries one `listKeys` page may carry (a page can slightly exceed it) |
|
|
494
|
+
| `commandBatchLimit` | `number` | `100` | Commands in one pipeline batch |
|
|
495
|
+
| `revealLimit` | `number` | `200` | Members or fields `revealValue` returns before `isComplete` is `false` |
|
|
496
|
+
| `revealStringLimit` | `number` | `4096` | Characters of a string value `revealValue` returns |
|
|
497
|
+
| `isGlobal` | `boolean` | `false` | Register the admin module globally. The cache module's default is `true` |
|
|
498
|
+
|
|
499
|
+
Every threshold must be a positive integer; one that is not fails the boot with `cache.invalid_scope`. `CacheAdminService` also has `listScopes()` and `describeKey(scope, key)`, which returns a key's type, TTL and whether its value is readable, without the value.
|
|
500
|
+
|
|
447
501
|
### What the library owns, and what you own
|
|
448
502
|
|
|
449
503
|
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.
|
|
@@ -575,7 +629,7 @@ A cache sits between your application and every value it has ever computed, so t
|
|
|
575
629
|
|
|
576
630
|
### Namespace isolation is structural
|
|
577
631
|
|
|
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')`
|
|
632
|
+
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
633
|
|
|
580
634
|
The namespace itself is validated at module bootstrap, not at call time:
|
|
581
635
|
|
|
@@ -583,10 +637,14 @@ The namespace itself is validated at module bootstrap, not at call time:
|
|
|
583
637
|
| --------------------------------------- | ---------------------------------------------------------------- |
|
|
584
638
|
| Empty or whitespace-only namespace | `CacheException(INVALID_NAMESPACE)` — the module fails to start |
|
|
585
639
|
| Namespace containing the key separator | `CacheException(INVALID_NAMESPACE)` — prevents prefix collisions |
|
|
640
|
+
| Empty key separator | `CacheException(INVALID_NAMESPACE)` — the module fails to start |
|
|
641
|
+
| Namespace containing `*`, `?`, `[`, `\` | `CacheException(INVALID_NAMESPACE)` — see the glob section below |
|
|
586
642
|
| Empty `prefix` or `id` at the call site | `CacheException(INVALID_KEY)` — no key is sent to Redis |
|
|
587
643
|
|
|
588
644
|
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
645
|
|
|
646
|
+
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`.
|
|
647
|
+
|
|
590
648
|
### Deserialization fails closed
|
|
591
649
|
|
|
592
650
|
`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 +657,7 @@ Error `details` are built to be safe to log:
|
|
|
599
657
|
|
|
600
658
|
- A malformed `connection.url` throws `CONNECTION_FAILED` with `reason: 'invalid connection.url'` — the URL is omitted, because it may embed a password.
|
|
601
659
|
- `SERIALIZATION_FAILED` carries the encoder's message, never the value being encoded.
|
|
602
|
-
- `DESERIALIZATION_FAILED` carries a `
|
|
660
|
+
- `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
661
|
|
|
604
662
|
### Destructive operations are guarded in production
|
|
605
663
|
|
|
@@ -615,7 +673,7 @@ Scripts are declared up front — through `options.scripts` or `ScriptManagerSer
|
|
|
615
673
|
|
|
616
674
|
### Cluster mode refuses commands it cannot honor safely
|
|
617
675
|
|
|
618
|
-
`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.
|
|
676
|
+
`scan()`, `keys()`, `flushNamespace()`, and `getClient()` throw `UNSUPPORTED_IN_CLUSTER` under `mode: 'cluster'` rather than silently operating on one node. `KEYS` carries no key, so the cluster client sends it to a single node and the reply would list only that node's share of the keyspace. A partial flush that reports success is worse than an error.
|
|
619
677
|
|
|
620
678
|
### Security Checklist
|
|
621
679
|
|
|
@@ -624,6 +682,7 @@ When integrating `@bymax-one/nest-cache` in production, verify each of the follo
|
|
|
624
682
|
- `namespace` is distinct per tenant or per application — it is the isolation boundary, and a shared value makes every other guarantee moot
|
|
625
683
|
- `allowFlushInProduction` stays unset; if it is on, the reason belongs in a security review
|
|
626
684
|
- `getClient()` and `pipeline()` call sites are audited — they are the only paths that bypass namespacing
|
|
685
|
+
- `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
686
|
- Connection credentials come from the environment (`config.getOrThrow('REDIS_URL')`), never from a URL literal in module options in source control
|
|
628
687
|
- `rediss://` (or an explicit `connection.tls`) is used for any Redis reachable off-host
|
|
629
688
|
- 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 +695,22 @@ When integrating `@bymax-one/nest-cache` in production, verify each of the follo
|
|
|
636
695
|
|
|
637
696
|
## 🛡️ Security Table
|
|
638
697
|
|
|
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
|
|
698
|
+
| Layer | Implementation |
|
|
699
|
+
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
700
|
+
| Tenant Isolation | Every key and channel composed by `KeyBuilder` as `{namespace}{sep}{prefix}{sep}{id}` — no bare-key path in the API |
|
|
701
|
+
| Namespace Validation | Empty, separator-containing, or glob-metacharacter namespace rejected at bootstrap (`INVALID_NAMESPACE`) |
|
|
702
|
+
| Admin Scope Allowlist | Scopes declared at wiring, validated and frozen; a caller names a scope by id and can never supply a match pattern |
|
|
703
|
+
| Admin Read-Only | The `/admin` subpath issues no mutating command — enforced by the `check:admin-readonly` build gate, not by convention |
|
|
704
|
+
| 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 |
|
|
705
|
+
| Deserialization | Fails closed — `DESERIALIZATION_FAILED`; never a partial or wrongly-typed value |
|
|
706
|
+
| Serialization | Top-level `undefined` / function / symbol rejected; the value is never echoed into `details` |
|
|
707
|
+
| Error Payloads | Connection URLs omitted (may embed credentials); a stored value is never echoed (a fixed text and its length only) |
|
|
708
|
+
| Destructive Ops | `flushNamespace()` blocked under `NODE_ENV=production` unless `allowFlushInProduction`; `SCAN` + `UNLINK`, namespace-scoped |
|
|
709
|
+
| Lua Execution | Scripts registered by name; call sites pass keys/args only — request input never reaches a script body |
|
|
710
|
+
| Cluster Safety | `scan` / `keys` / `flushNamespace` / `getClient` throw `UNSUPPORTED_IN_CLUSTER` instead of acting on a single node |
|
|
711
|
+
| Transport | `rediss://` sets TLS on the client; explicit `connection.tls` supported for custom CA / mTLS |
|
|
712
|
+
| Connection Resilience | Bounded `maxRetriesPerRequest`, `READONLY`-failover reconnect, graceful `quit()` with a shutdown timeout |
|
|
713
|
+
| Supply Chain | `"dependencies": {}` — no transitive runtime packages of the library's own choosing; published with npm provenance |
|
|
655
714
|
|
|
656
715
|
> [!IMPORTANT]
|
|
657
716
|
> 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 +725,7 @@ When integrating `@bymax-one/nest-cache` in production, verify each of the follo
|
|
|
666
725
|
[](https://nodejs.org)
|
|
667
726
|
[](https://jestjs.io)
|
|
668
727
|
[](https://stryker-mutator.io)
|
|
669
|
-
[](https://pnpm.io)
|
|
670
729
|
[](https://tsup.egoist.dev)
|
|
671
730
|
|
|
672
731
|
---
|
|
@@ -676,7 +735,7 @@ When integrating `@bymax-one/nest-cache` in production, verify each of the follo
|
|
|
676
735
|
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
736
|
|
|
678
737
|
- ✅ **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
|
|
738
|
+
- ✅ **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
739
|
- ✅ **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
740
|
- ✅ **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
741
|
- ✅ **Published-package smoke test** — `scripts/dogfood-smoke-test.mjs` validates exports, tarball shape, and a consumer install before tagging
|
|
@@ -685,7 +744,7 @@ A cache is consulted on the hot path of every request that touches it, so the su
|
|
|
685
744
|
pnpm test # unit tests (Jest)
|
|
686
745
|
pnpm test:e2e # end-to-end tests (@nestjs/testing + Testcontainers)
|
|
687
746
|
pnpm test:cov:all # full coverage gate (100% statements/branches/functions/lines)
|
|
688
|
-
pnpm mutation # Stryker mutation testing (
|
|
747
|
+
pnpm mutation # Stryker mutation testing (100% break gate)
|
|
689
748
|
pnpm typecheck # tsc strict check
|
|
690
749
|
pnpm lint # ESLint
|
|
691
750
|
```
|
|
@@ -714,6 +773,8 @@ pnpm lint # ESLint
|
|
|
714
773
|
| Namespace | `flushNamespace` (prod-guarded) |
|
|
715
774
|
| Health | `isHealthy` · `ping` · `info` |
|
|
716
775
|
|
|
776
|
+
**TTLs.** `ttlSeconds` on `set`, `setRaw` and `setNx` is a safe integer of at least `1`; omit it for no expiry. `expire` takes a safe integer, and as in Redis a zero or negative one deletes the key. Anything else throws `INVALID_TTL` before a command is sent.
|
|
777
|
+
|
|
717
778
|
### `PubSubService`
|
|
718
779
|
|
|
719
780
|
`publish<T>(channel, message)` · `subscribe<T>(channel, handler)` · `psubscribe<T>(pattern, handler)`
|
|
@@ -726,29 +787,54 @@ pnpm lint # ESLint
|
|
|
726
787
|
|
|
727
788
|
`CacheException` (extends `HttpException`) + `CACHE_ERROR_CODES` (namespaced `cache.*`).
|
|
728
789
|
|
|
790
|
+
### Types, constants and tokens
|
|
791
|
+
|
|
792
|
+
From `@bymax-one/nest-cache`:
|
|
793
|
+
|
|
794
|
+
| Export | What it is |
|
|
795
|
+
| ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
|
|
796
|
+
| `BymaxCacheModuleOptions`, `BymaxCacheModuleAsyncOptions` | The options of `forRoot()` and `forRootAsync()`; the Configuration table lists their fields |
|
|
797
|
+
| `BymaxCacheStandaloneConnection`, `BymaxCacheSentinelConnection`, `BymaxCacheClusterConnection` | The `connection`, `sentinel` and `cluster` blocks |
|
|
798
|
+
| `RedisOptions`, `ClusterOptions`, `ClusterNode`, `SentinelAddress` | The ioredis option types the blocks above refer to, re-exported |
|
|
799
|
+
| `ResolvedOptions` | The shape injected under `BYMAX_CACHE_OPTIONS`, with every default applied |
|
|
800
|
+
| `IPubSubHandler`, `IPubSubPatternHandler`, `Unsubscribe` | The handler signatures and the function `subscribe` / `psubscribe` return |
|
|
801
|
+
| `CACHE_ERROR_MESSAGES` | The default message of every code, keyed by code |
|
|
802
|
+
| `DEFAULT_NAMESPACE`, `DEFAULT_KEY_SEPARATOR`, `DEFAULT_REDIS_PORT` | `'app'`, `':'` and `6379` |
|
|
803
|
+
|
|
804
|
+
From `@bymax-one/nest-cache/shared` (also re-exported from the main entry): `CACHE_EVENT_NAMES`, `CacheEventName`, `CacheConnectionStatus`, `CacheErrorCode`, `CacheNamespace`, `CacheKeyPrefix` and `SerializableValue`.
|
|
805
|
+
|
|
806
|
+
From `@bymax-one/nest-cache/admin`: `BymaxCacheAdminModuleOptions` and `BymaxCacheAdminModuleAsyncOptions` (the Options table above), `CacheScope`, `ListKeysOptions`, the result types `CacheHealth`, `CacheConfig`, `CacheMode`, `RedisStats`, `KeyspacePage`, `KeyEntry`, `KeyDetail`, `KeyTtl`, `RevealResult` and `RevealedValue`, the token `BYMAX_CACHE_ADMIN_OPTIONS`, and the defaults `DEFAULT_DEGRADED_ABOVE_MS`, `DEFAULT_PROBE_TIMEOUT_MS`, `DEFAULT_SCAN_LIMIT`, `DEFAULT_COMMAND_BATCH_LIMIT`, `DEFAULT_REVEAL_LIMIT` and `DEFAULT_REVEAL_STRING_LIMIT`.
|
|
807
|
+
|
|
808
|
+
The other names `/admin` exports (the scope and `INFO` helpers such as `validateScopes` and `readRedisStats`, `resolveAdminOptions`, and the reader contracts) are implementation helpers, marked `@deprecated`: they will leave the public surface in 2.0.0, so do not depend on them.
|
|
809
|
+
|
|
729
810
|
---
|
|
730
811
|
|
|
731
812
|
## 🪪 Default Error Codes
|
|
732
813
|
|
|
733
814
|
All errors are instances of `CacheException` and carry a stable `code` string from `CACHE_ERROR_CODES`:
|
|
734
815
|
|
|
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.
|
|
744
|
-
| `cache.
|
|
745
|
-
| `cache.
|
|
746
|
-
| `cache.
|
|
747
|
-
| `cache.
|
|
748
|
-
| `cache.
|
|
749
|
-
| `cache.
|
|
750
|
-
| `cache.
|
|
751
|
-
| `cache.
|
|
816
|
+
| Code | HTTP | When thrown |
|
|
817
|
+
| ------------------------------------ | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
818
|
+
| `cache.connection_failed` | 500 | Cannot connect after retries; also bootstrap validation of `connection` and timeouts |
|
|
819
|
+
| `cache.command_timeout` | 504 | Reserved, never thrown today |
|
|
820
|
+
| `cache.connection_lost` | 503 | Reserved, never thrown today |
|
|
821
|
+
| `cache.deserialization_failed` | 500 | Malformed payload in `get<T>` |
|
|
822
|
+
| `cache.serialization_failed` | 500 | Unserializable value in `set<T>` |
|
|
823
|
+
| `cache.invalid_key` | 400 | Empty prefix or id (`build`), or empty key (`applyNamespace`) |
|
|
824
|
+
| `cache.invalid_ttl` | 400 | `ttlSeconds` is not a safe integer, or is below 1 for `set` / `setRaw` / `setNx` |
|
|
825
|
+
| `cache.invalid_namespace` | 500 | Empty or whitespace-only namespace, empty `keySeparator`, namespace containing the separator or a glob metacharacter (`*`, `?`, `[`, `\`) |
|
|
826
|
+
| `cache.script_not_registered` | 500 | `eval(name)` before `register(name)` |
|
|
827
|
+
| `cache.script_execution_failed` | 500 | Lua runtime error or NOSCRIPT retry failure |
|
|
828
|
+
| `cache.script_registry_missing` | 500 | `eval` called when no `ScriptManagerService` is wired |
|
|
829
|
+
| `cache.flush_disabled_in_production` | 403 | `flushNamespace()` in prod without `allowFlushInProduction` |
|
|
830
|
+
| `cache.unsupported_in_cluster` | 500 | `scan`, `keys`, `flushNamespace`, or `getClient` called in cluster mode |
|
|
831
|
+
| `cache.cluster_misconfigured` | 500 | `mode: 'cluster'` without `cluster.nodes` |
|
|
832
|
+
| `cache.sentinel_misconfigured` | 500 | `mode: 'sentinel'` without `sentinel.sentinels`/`name` |
|
|
833
|
+
| `cache.shutdown_timeout` | 500 | Internal, never thrown to the caller: `quit()` exceeded `shutdownTimeoutMs`, so the library forces `disconnect()` and emits `error` with `reason: 'forced_disconnect'` |
|
|
834
|
+
| `cache.invalid_scope` | 500 | `/admin` scope allowlist invalid at wiring |
|
|
835
|
+
| `cache.scope_not_found` | 500 | `/admin` call named an undeclared scope id |
|
|
836
|
+
| `cache.scope_not_readable` | 500 | Reserved, never thrown today (unreadable scopes return `withheld`) |
|
|
837
|
+
| `cache.key_not_in_scope` | 500 | `/admin` call named a key outside the named scope |
|
|
752
838
|
|
|
753
839
|
Full catalog and HTTP status mapping: [`docs/technical_specification.md §12`](./docs/technical_specification.md).
|
|
754
840
|
|
|
@@ -775,7 +861,7 @@ See §13 of the [technical specification](./docs/technical_specification.md) for
|
|
|
775
861
|
Contributions are welcome. Development follows the Bymax coding standards:
|
|
776
862
|
|
|
777
863
|
- TypeScript strict (`noImplicitAny`, `exactOptionalPropertyTypes`, `noUncheckedIndexedAccess`)
|
|
778
|
-
- TDD with a 100% coverage gate and a
|
|
864
|
+
- TDD with a 100% coverage gate and a 100% mutation break threshold
|
|
779
865
|
- Conventional Commits enforced by commitlint + husky
|
|
780
866
|
- No direct dependencies — peer deps only
|
|
781
867
|
- All boolean identifiers prefixed with `is / has / should / can`
|
|
@@ -801,7 +887,7 @@ pnpm typecheck
|
|
|
801
887
|
Run the full gate before opening a PR:
|
|
802
888
|
|
|
803
889
|
```bash
|
|
804
|
-
pnpm typecheck && pnpm lint && pnpm test:cov:all && pnpm build && pnpm size
|
|
890
|
+
pnpm typecheck && pnpm lint && pnpm check:admin-readonly && pnpm test:cov:all && pnpm build && pnpm size && pnpm check:exports
|
|
805
891
|
```
|
|
806
892
|
|
|
807
893
|
See [CONTRIBUTING.md](./CONTRIBUTING.md) and [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md) for the full process.
|