@bymax-one/nest-cache 1.2.3 → 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 CHANGED
@@ -6,6 +6,72 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
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
+
9
75
  ## [1.2.3] - 2026-10-30
10
76
 
11
77
  ### Changed
@@ -394,7 +460,8 @@ type or export moved.
394
460
  - Published with npm OIDC provenance — no long-lived tokens
395
461
  - Zero direct runtime dependencies (`dependencies: {}`) — `ioredis` and NestJS via peer deps
396
462
 
397
- [Unreleased]: https://github.com/bymaxone/nest-cache/compare/v1.2.3...HEAD
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
398
465
  [1.2.3]: https://github.com/bymaxone/nest-cache/compare/v1.2.2...v1.2.3
399
466
  [1.2.2]: https://github.com/bymaxone/nest-cache/compare/v1.2.1...v1.2.2
400
467
  [1.2.1]: https://github.com/bymaxone/nest-cache/compare/v1.2.0...v1.2.1
package/README.md CHANGED
@@ -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:
@@ -420,6 +429,9 @@ export class HealthController {
420
429
 
421
430
  A read-only surface for an operator-facing console: is the cache answering, what is it doing, what is in it.
422
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
+
423
435
  ```ts
424
436
  import { BymaxCacheModule } from '@bymax-one/nest-cache'
425
437
  import { BymaxCacheAdminModule } from '@bymax-one/nest-cache/admin'
@@ -471,6 +483,21 @@ await this.admin.listKeys('cache', { includeSize: true })
471
483
  await this.admin.revealValue('auth', 'auth:sess:1') // { status: 'withheld', origin }
472
484
  ```
473
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
+
474
501
  ### What the library owns, and what you own
475
502
 
476
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.
@@ -646,7 +673,7 @@ Scripts are declared up front — through `options.scripts` or `ScriptManagerSer
646
673
 
647
674
  ### Cluster mode refuses commands it cannot honor safely
648
675
 
649
- `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.
650
677
 
651
678
  ### Security Checklist
652
679
 
@@ -680,7 +707,7 @@ When integrating `@bymax-one/nest-cache` in production, verify each of the follo
680
707
  | Error Payloads | Connection URLs omitted (may embed credentials); a stored value is never echoed (a fixed text and its length only) |
681
708
  | Destructive Ops | `flushNamespace()` blocked under `NODE_ENV=production` unless `allowFlushInProduction`; `SCAN` + `UNLINK`, namespace-scoped |
682
709
  | 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 |
710
+ | Cluster Safety | `scan` / `keys` / `flushNamespace` / `getClient` throw `UNSUPPORTED_IN_CLUSTER` instead of acting on a single node |
684
711
  | Transport | `rediss://` sets TLS on the client; explicit `connection.tls` supported for custom CA / mTLS |
685
712
  | Connection Resilience | Bounded `maxRetriesPerRequest`, `READONLY`-failover reconnect, graceful `quit()` with a shutdown timeout |
686
713
  | Supply Chain | `"dependencies": {}` — no transitive runtime packages of the library's own choosing; published with npm provenance |
@@ -746,6 +773,8 @@ pnpm lint # ESLint
746
773
  | Namespace | `flushNamespace` (prod-guarded) |
747
774
  | Health | `isHealthy` · `ping` · `info` |
748
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
+
749
778
  ### `PubSubService`
750
779
 
751
780
  `publish<T>(channel, message)` · `subscribe<T>(channel, handler)` · `psubscribe<T>(pattern, handler)`
@@ -758,33 +787,54 @@ pnpm lint # ESLint
758
787
 
759
788
  `CacheException` (extends `HttpException`) + `CACHE_ERROR_CODES` (namespaced `cache.*`).
760
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
+
761
810
  ---
762
811
 
763
812
  ## 🪪 Default Error Codes
764
813
 
765
814
  All errors are instances of `CacheException` and carry a stable `code` string from `CACHE_ERROR_CODES`:
766
815
 
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 |
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 |
788
838
 
789
839
  Full catalog and HTTP status mapping: [`docs/technical_specification.md §12`](./docs/technical_specification.md).
790
840
 
@@ -132,6 +132,9 @@ declare class BymaxCacheAdminModule {
132
132
  }
133
133
 
134
134
  /** Administration options after defaults are merged and validated. */
135
+ /**
136
+ * @deprecated Implementation helper; will leave the public surface in 2.0.0. Do not depend on it.
137
+ */
135
138
  interface ResolvedAdminOptions {
136
139
  readonly scopes: readonly CacheScope[];
137
140
  readonly degradedAboveMs: number;
@@ -150,6 +153,8 @@ interface ResolvedAdminOptions {
150
153
  * @returns The frozen resolved options.
151
154
  * @throws {CacheException} `INVALID_SCOPE` when a scope is malformed or a
152
155
  * threshold is not a positive integer.
156
+ *
157
+ * @deprecated Implementation helper; will leave the public surface in 2.0.0. Do not depend on it.
153
158
  */
154
159
  declare function resolveAdminOptions(options: BymaxCacheAdminModuleOptions): Readonly<ResolvedAdminOptions>;
155
160
 
@@ -370,6 +375,8 @@ interface RedisStats {
370
375
  * the concrete `CacheService`, while a test supplies a plain object with two
371
376
  * methods and no cast. A cast would be a blocking finding under this
372
377
  * repository's suppression policy, so the narrow type is not a convenience.
378
+ *
379
+ * @deprecated Implementation helper; will leave the public surface in 2.0.0. Do not depend on it.
373
380
  */
374
381
  type ICacheProbe = Pick<CacheService, 'ping' | 'info'>;
375
382
  /** Reports whether the cache is answering, what it is doing, and how it is wired. */
@@ -432,8 +439,14 @@ declare class CacheStatusService {
432
439
  * this subpath.
433
440
  */
434
441
  /** One entry of a pipeline's result: an error, or a value. */
442
+ /**
443
+ * @deprecated Implementation helper; will leave the public surface in 2.0.0. Do not depend on it.
444
+ */
435
445
  type PipelineReply = [Error | null, unknown];
436
446
  /** A pipeline being composed, chainable the way ioredis's commander is. */
447
+ /**
448
+ * @deprecated Implementation helper; will leave the public surface in 2.0.0. Do not depend on it.
449
+ */
437
450
  interface IRedisPipeline {
438
451
  /** Queues `TYPE`. */
439
452
  type(key: string): IRedisPipeline;
@@ -445,6 +458,9 @@ interface IRedisPipeline {
445
458
  exec(): Promise<PipelineReply[] | null>;
446
459
  }
447
460
  /** The subset of the Redis client this surface reads through. */
461
+ /**
462
+ * @deprecated Implementation helper; will leave the public surface in 2.0.0. Do not depend on it.
463
+ */
448
464
  interface IRedisReader {
449
465
  /** Cursor-based key iteration. */
450
466
  scan(cursor: string | number, matchToken: 'MATCH', pattern: string, countToken: 'COUNT', count: number | string): Promise<[string, string[]]>;
@@ -478,6 +494,9 @@ interface IRedisReader {
478
494
  call(command: string, ...args: (string | number)[]): Promise<unknown>;
479
495
  }
480
496
  /** How the administration surface obtains its reader. */
497
+ /**
498
+ * @deprecated Implementation helper; will leave the public surface in 2.0.0. Do not depend on it.
499
+ */
481
500
  interface IRedisReaderSource {
482
501
  /**
483
502
  * Returns the client.
@@ -770,6 +789,8 @@ declare class CacheAdminService {
770
789
  *
771
790
  * @param pattern - A pattern that already satisfies {@link SCOPE_PATTERN_SHAPE}.
772
791
  * @returns The literal prefix, and whether a trailing `*` allows a suffix.
792
+ *
793
+ * @deprecated Implementation helper; will leave the public surface in 2.0.0. Do not depend on it.
773
794
  */
774
795
  declare function readScopePattern(pattern: string): {
775
796
  readonly prefix: string;
@@ -784,6 +805,8 @@ declare function readScopePattern(pattern: string): {
784
805
  * @param pattern - The scope's validated match pattern.
785
806
  * @param key - The key a caller named.
786
807
  * @returns `true` when the key belongs to the scope.
808
+ *
809
+ * @deprecated Implementation helper; will leave the public surface in 2.0.0. Do not depend on it.
787
810
  */
788
811
  declare function isKeyInScope(pattern: string, key: string): boolean;
789
812
  /**
@@ -799,6 +822,8 @@ declare function isKeyInScope(pattern: string, key: string): boolean;
799
822
  * @returns A frozen list of frozen scopes, in declaration order.
800
823
  * @throws {CacheException} `INVALID_SCOPE` when the list is empty, a required
801
824
  * field is empty, an id repeats, or a pattern has no literal prefix.
825
+ *
826
+ * @deprecated Implementation helper; will leave the public surface in 2.0.0. Do not depend on it.
802
827
  */
803
828
  declare function validateScopes(scopes: readonly CacheScope[]): readonly CacheScope[];
804
829
  /**
@@ -811,6 +836,8 @@ declare function validateScopes(scopes: readonly CacheScope[]): readonly CacheSc
811
836
  * @param id - The identifier from the request.
812
837
  * @returns The scope.
813
838
  * @throws {CacheException} `SCOPE_NOT_FOUND` when no declaration has that id.
839
+ *
840
+ * @deprecated Implementation helper; will leave the public surface in 2.0.0. Do not depend on it.
814
841
  */
815
842
  declare function findScope(scopes: readonly CacheScope[], id: string): CacheScope;
816
843
 
@@ -842,6 +869,8 @@ declare function findScope(scopes: readonly CacheScope[], id: string): CacheScop
842
869
  *
843
870
  * @param text - The raw `INFO` output.
844
871
  * @returns Field name to raw text value.
872
+ *
873
+ * @deprecated Implementation helper; will leave the public surface in 2.0.0. Do not depend on it.
845
874
  */
846
875
  declare function parseInfoFields(text: string): ReadonlyMap<string, string>;
847
876
  /**
@@ -850,6 +879,8 @@ declare function parseInfoFields(text: string): ReadonlyMap<string, string>;
850
879
  * @param infoText - The raw `INFO` output.
851
880
  * @param readAt - When the reading was taken, by the reading host's clock.
852
881
  * @returns The parsed statistics.
882
+ *
883
+ * @deprecated Implementation helper; will leave the public surface in 2.0.0. Do not depend on it.
853
884
  */
854
885
  declare function readRedisStats(infoText: string, readAt: Date): RedisStats;
855
886
 
@@ -867,6 +898,8 @@ declare function readRedisStats(infoText: string, readAt: Date): RedisStats;
867
898
  *
868
899
  * @param reply - Whatever the server answered.
869
900
  * @returns The recognised type, or `other`.
901
+ *
902
+ * @deprecated Implementation helper; will leave the public surface in 2.0.0. Do not depend on it.
870
903
  */
871
904
  declare function readKeyType(reply: unknown): RedisKeyType;
872
905
  /**
@@ -878,6 +911,8 @@ declare function readKeyType(reply: unknown): RedisKeyType;
878
911
  *
879
912
  * @param reply - Whatever the server answered.
880
913
  * @returns Whether the key expires, is persistent, or is gone.
914
+ *
915
+ * @deprecated Implementation helper; will leave the public surface in 2.0.0. Do not depend on it.
881
916
  */
882
917
  declare function readKeyTtl(reply: unknown): KeyTtl;
883
918
  /**
@@ -889,6 +924,8 @@ declare function readKeyTtl(reply: unknown): KeyTtl;
889
924
  *
890
925
  * @param reply - Whatever the server answered.
891
926
  * @returns The size in bytes, or `null` when no size was reported.
927
+ *
928
+ * @deprecated Implementation helper; will leave the public surface in 2.0.0. Do not depend on it.
892
929
  */
893
930
  declare function readSizeBytes(reply: unknown): number | null;
894
931
 
@@ -132,6 +132,9 @@ declare class BymaxCacheAdminModule {
132
132
  }
133
133
 
134
134
  /** Administration options after defaults are merged and validated. */
135
+ /**
136
+ * @deprecated Implementation helper; will leave the public surface in 2.0.0. Do not depend on it.
137
+ */
135
138
  interface ResolvedAdminOptions {
136
139
  readonly scopes: readonly CacheScope[];
137
140
  readonly degradedAboveMs: number;
@@ -150,6 +153,8 @@ interface ResolvedAdminOptions {
150
153
  * @returns The frozen resolved options.
151
154
  * @throws {CacheException} `INVALID_SCOPE` when a scope is malformed or a
152
155
  * threshold is not a positive integer.
156
+ *
157
+ * @deprecated Implementation helper; will leave the public surface in 2.0.0. Do not depend on it.
153
158
  */
154
159
  declare function resolveAdminOptions(options: BymaxCacheAdminModuleOptions): Readonly<ResolvedAdminOptions>;
155
160
 
@@ -370,6 +375,8 @@ interface RedisStats {
370
375
  * the concrete `CacheService`, while a test supplies a plain object with two
371
376
  * methods and no cast. A cast would be a blocking finding under this
372
377
  * repository's suppression policy, so the narrow type is not a convenience.
378
+ *
379
+ * @deprecated Implementation helper; will leave the public surface in 2.0.0. Do not depend on it.
373
380
  */
374
381
  type ICacheProbe = Pick<CacheService, 'ping' | 'info'>;
375
382
  /** Reports whether the cache is answering, what it is doing, and how it is wired. */
@@ -432,8 +439,14 @@ declare class CacheStatusService {
432
439
  * this subpath.
433
440
  */
434
441
  /** One entry of a pipeline's result: an error, or a value. */
442
+ /**
443
+ * @deprecated Implementation helper; will leave the public surface in 2.0.0. Do not depend on it.
444
+ */
435
445
  type PipelineReply = [Error | null, unknown];
436
446
  /** A pipeline being composed, chainable the way ioredis's commander is. */
447
+ /**
448
+ * @deprecated Implementation helper; will leave the public surface in 2.0.0. Do not depend on it.
449
+ */
437
450
  interface IRedisPipeline {
438
451
  /** Queues `TYPE`. */
439
452
  type(key: string): IRedisPipeline;
@@ -445,6 +458,9 @@ interface IRedisPipeline {
445
458
  exec(): Promise<PipelineReply[] | null>;
446
459
  }
447
460
  /** The subset of the Redis client this surface reads through. */
461
+ /**
462
+ * @deprecated Implementation helper; will leave the public surface in 2.0.0. Do not depend on it.
463
+ */
448
464
  interface IRedisReader {
449
465
  /** Cursor-based key iteration. */
450
466
  scan(cursor: string | number, matchToken: 'MATCH', pattern: string, countToken: 'COUNT', count: number | string): Promise<[string, string[]]>;
@@ -478,6 +494,9 @@ interface IRedisReader {
478
494
  call(command: string, ...args: (string | number)[]): Promise<unknown>;
479
495
  }
480
496
  /** How the administration surface obtains its reader. */
497
+ /**
498
+ * @deprecated Implementation helper; will leave the public surface in 2.0.0. Do not depend on it.
499
+ */
481
500
  interface IRedisReaderSource {
482
501
  /**
483
502
  * Returns the client.
@@ -770,6 +789,8 @@ declare class CacheAdminService {
770
789
  *
771
790
  * @param pattern - A pattern that already satisfies {@link SCOPE_PATTERN_SHAPE}.
772
791
  * @returns The literal prefix, and whether a trailing `*` allows a suffix.
792
+ *
793
+ * @deprecated Implementation helper; will leave the public surface in 2.0.0. Do not depend on it.
773
794
  */
774
795
  declare function readScopePattern(pattern: string): {
775
796
  readonly prefix: string;
@@ -784,6 +805,8 @@ declare function readScopePattern(pattern: string): {
784
805
  * @param pattern - The scope's validated match pattern.
785
806
  * @param key - The key a caller named.
786
807
  * @returns `true` when the key belongs to the scope.
808
+ *
809
+ * @deprecated Implementation helper; will leave the public surface in 2.0.0. Do not depend on it.
787
810
  */
788
811
  declare function isKeyInScope(pattern: string, key: string): boolean;
789
812
  /**
@@ -799,6 +822,8 @@ declare function isKeyInScope(pattern: string, key: string): boolean;
799
822
  * @returns A frozen list of frozen scopes, in declaration order.
800
823
  * @throws {CacheException} `INVALID_SCOPE` when the list is empty, a required
801
824
  * field is empty, an id repeats, or a pattern has no literal prefix.
825
+ *
826
+ * @deprecated Implementation helper; will leave the public surface in 2.0.0. Do not depend on it.
802
827
  */
803
828
  declare function validateScopes(scopes: readonly CacheScope[]): readonly CacheScope[];
804
829
  /**
@@ -811,6 +836,8 @@ declare function validateScopes(scopes: readonly CacheScope[]): readonly CacheSc
811
836
  * @param id - The identifier from the request.
812
837
  * @returns The scope.
813
838
  * @throws {CacheException} `SCOPE_NOT_FOUND` when no declaration has that id.
839
+ *
840
+ * @deprecated Implementation helper; will leave the public surface in 2.0.0. Do not depend on it.
814
841
  */
815
842
  declare function findScope(scopes: readonly CacheScope[], id: string): CacheScope;
816
843
 
@@ -842,6 +869,8 @@ declare function findScope(scopes: readonly CacheScope[], id: string): CacheScop
842
869
  *
843
870
  * @param text - The raw `INFO` output.
844
871
  * @returns Field name to raw text value.
872
+ *
873
+ * @deprecated Implementation helper; will leave the public surface in 2.0.0. Do not depend on it.
845
874
  */
846
875
  declare function parseInfoFields(text: string): ReadonlyMap<string, string>;
847
876
  /**
@@ -850,6 +879,8 @@ declare function parseInfoFields(text: string): ReadonlyMap<string, string>;
850
879
  * @param infoText - The raw `INFO` output.
851
880
  * @param readAt - When the reading was taken, by the reading host's clock.
852
881
  * @returns The parsed statistics.
882
+ *
883
+ * @deprecated Implementation helper; will leave the public surface in 2.0.0. Do not depend on it.
853
884
  */
854
885
  declare function readRedisStats(infoText: string, readAt: Date): RedisStats;
855
886
 
@@ -867,6 +898,8 @@ declare function readRedisStats(infoText: string, readAt: Date): RedisStats;
867
898
  *
868
899
  * @param reply - Whatever the server answered.
869
900
  * @returns The recognised type, or `other`.
901
+ *
902
+ * @deprecated Implementation helper; will leave the public surface in 2.0.0. Do not depend on it.
870
903
  */
871
904
  declare function readKeyType(reply: unknown): RedisKeyType;
872
905
  /**
@@ -878,6 +911,8 @@ declare function readKeyType(reply: unknown): RedisKeyType;
878
911
  *
879
912
  * @param reply - Whatever the server answered.
880
913
  * @returns Whether the key expires, is persistent, or is gone.
914
+ *
915
+ * @deprecated Implementation helper; will leave the public surface in 2.0.0. Do not depend on it.
881
916
  */
882
917
  declare function readKeyTtl(reply: unknown): KeyTtl;
883
918
  /**
@@ -889,6 +924,8 @@ declare function readKeyTtl(reply: unknown): KeyTtl;
889
924
  *
890
925
  * @param reply - Whatever the server answered.
891
926
  * @returns The size in bytes, or `null` when no size was reported.
927
+ *
928
+ * @deprecated Implementation helper; will leave the public surface in 2.0.0. Do not depend on it.
892
929
  */
893
930
  declare function readSizeBytes(reply: unknown): number | null;
894
931
 
@@ -67,7 +67,8 @@ var CACHE_ERROR_CODES = {
67
67
  INVALID_SCOPE: "cache.invalid_scope",
68
68
  SCOPE_NOT_FOUND: "cache.scope_not_found",
69
69
  SCOPE_NOT_READABLE: "cache.scope_not_readable",
70
- KEY_NOT_IN_SCOPE: "cache.key_not_in_scope"
70
+ KEY_NOT_IN_SCOPE: "cache.key_not_in_scope",
71
+ INVALID_TTL: "cache.invalid_ttl"
71
72
  };
72
73
 
73
74
  // src/server/errors/cache-error-codes.ts
@@ -108,12 +109,17 @@ var CACHE_ERROR_MESSAGES = /* @__PURE__ */ new Map([
108
109
  [
109
110
  CACHE_ERROR_CODES.KEY_NOT_IN_SCOPE,
110
111
  "That key does not belong to the named administration scope."
112
+ ],
113
+ [
114
+ CACHE_ERROR_CODES.INVALID_TTL,
115
+ "A TTL in seconds must be a safe integer, and at least 1 for a write that sets one."
111
116
  ]
112
117
  ]);
113
118
  var CACHE_ERROR_STATUS = /* @__PURE__ */ new Map([
114
119
  [CACHE_ERROR_CODES.COMMAND_TIMEOUT, common.HttpStatus.GATEWAY_TIMEOUT],
115
120
  [CACHE_ERROR_CODES.CONNECTION_LOST, common.HttpStatus.SERVICE_UNAVAILABLE],
116
121
  [CACHE_ERROR_CODES.INVALID_KEY, common.HttpStatus.BAD_REQUEST],
122
+ [CACHE_ERROR_CODES.INVALID_TTL, common.HttpStatus.BAD_REQUEST],
117
123
  [CACHE_ERROR_CODES.FLUSH_DISABLED_IN_PRODUCTION, common.HttpStatus.FORBIDDEN]
118
124
  ]);
119
125
  var CacheException = class extends common.HttpException {
@@ -779,6 +785,17 @@ var resolveSerializer = (options, injected) => (
779
785
  options.serializer ?? injected ?? new exports.JsonSerializer()
780
786
  );
781
787
 
788
+ // src/server/utils/ttl.ts
789
+ var MIN_WRITE_TTL_SECONDS = 1;
790
+ function assertTtlSeconds(ttlSeconds, minimum) {
791
+ if (!Number.isSafeInteger(ttlSeconds)) {
792
+ throw new CacheException(CACHE_ERROR_CODES.INVALID_TTL, { reason: "not_a_safe_integer" });
793
+ }
794
+ if (minimum !== void 0 && ttlSeconds < minimum) {
795
+ throw new CacheException(CACHE_ERROR_CODES.INVALID_TTL, { reason: "below_minimum", minimum });
796
+ }
797
+ }
798
+
782
799
  // src/server/services/cache.service.ts
783
800
  var PRODUCTION_ENV = "production";
784
801
  var SCAN_COUNT = 100;
@@ -839,14 +856,17 @@ exports.CacheService = class CacheService {
839
856
  * @param prefix - Entity-group prefix.
840
857
  * @param id - Entity id.
841
858
  * @param value - The value to store (passed through the serializer).
842
- * @param ttlSeconds - Optional expiry in seconds; omit for no expiration.
859
+ * @param ttlSeconds - Optional expiry in seconds, a safe integer of at least 1; omit for
860
+ * no expiration.
843
861
  * @returns Resolves once the write completes.
844
862
  * @throws {CacheException} `SERIALIZATION_FAILED` when `value` cannot be encoded.
863
+ * @throws {CacheException} `INVALID_TTL` when `ttlSeconds` is not a safe integer of at least 1.
845
864
  */
846
865
  async set(prefix, id, value, ttlSeconds) {
847
866
  const key = this.keyBuilder.build(prefix, id);
848
867
  const raw = this.serializer.serialize(value);
849
868
  if (ttlSeconds !== void 0) {
869
+ assertTtlSeconds(ttlSeconds, MIN_WRITE_TTL_SECONDS);
850
870
  await this.connection.getClient().set(key, raw, "EX", ttlSeconds);
851
871
  } else {
852
872
  await this.connection.getClient().set(key, raw);
@@ -858,12 +878,15 @@ exports.CacheService = class CacheService {
858
878
  * @param prefix - Entity-group prefix.
859
879
  * @param id - Entity id.
860
880
  * @param value - The raw string to store as-is.
861
- * @param ttlSeconds - Optional expiry in seconds; omit for no expiration.
881
+ * @param ttlSeconds - Optional expiry in seconds, a safe integer of at least 1; omit for
882
+ * no expiration.
862
883
  * @returns Resolves once the write completes.
884
+ * @throws {CacheException} `INVALID_TTL` when `ttlSeconds` is not a safe integer of at least 1.
863
885
  */
864
886
  async setRaw(prefix, id, value, ttlSeconds) {
865
887
  const key = this.keyBuilder.build(prefix, id);
866
888
  if (ttlSeconds !== void 0) {
889
+ assertTtlSeconds(ttlSeconds, MIN_WRITE_TTL_SECONDS);
867
890
  await this.connection.getClient().set(key, value, "EX", ttlSeconds);
868
891
  } else {
869
892
  await this.connection.getClient().set(key, value);
@@ -876,13 +899,16 @@ exports.CacheService = class CacheService {
876
899
  * @param prefix - Entity-group prefix.
877
900
  * @param id - Entity id.
878
901
  * @param value - The value to store (passed through the serializer).
879
- * @param ttlSeconds - Optional expiry in seconds applied on the same atomic write.
902
+ * @param ttlSeconds - Optional expiry in seconds applied on the same atomic write, a safe
903
+ * integer of at least 1.
880
904
  * @returns `true` when the value was stored, `false` when the key already existed.
881
905
  * @throws {CacheException} `SERIALIZATION_FAILED` when `value` cannot be encoded.
906
+ * @throws {CacheException} `INVALID_TTL` when `ttlSeconds` is not a safe integer of at least 1.
882
907
  */
883
908
  async setNx(prefix, id, value, ttlSeconds) {
884
909
  const key = this.keyBuilder.build(prefix, id);
885
910
  const raw = this.serializer.serialize(value);
911
+ if (ttlSeconds !== void 0) assertTtlSeconds(ttlSeconds, MIN_WRITE_TTL_SECONDS);
886
912
  const result = ttlSeconds !== void 0 ? await this.connection.getClient().set(key, raw, "EX", ttlSeconds, "NX") : await this.connection.getClient().set(key, raw, "NX");
887
913
  return result === "OK";
888
914
  }
@@ -954,10 +980,13 @@ exports.CacheService = class CacheService {
954
980
  *
955
981
  * @param prefix - Entity-group prefix.
956
982
  * @param id - Entity id.
957
- * @param ttlSeconds - Expiry in seconds.
983
+ * @param ttlSeconds - Expiry in seconds, a safe integer. As in Redis, zero or a negative
984
+ * value deletes the key.
958
985
  * @returns `true` when the timeout was set, `false` when the key does not exist.
986
+ * @throws {CacheException} `INVALID_TTL` when `ttlSeconds` is not a safe integer.
959
987
  */
960
988
  async expire(prefix, id, ttlSeconds) {
989
+ assertTtlSeconds(ttlSeconds, void 0);
961
990
  const result = await this.connection.getClient().expire(this.keyBuilder.build(prefix, id), ttlSeconds);
962
991
  return result === 1;
963
992
  }
@@ -1165,6 +1194,9 @@ exports.CacheService = class CacheService {
1165
1194
  * @param prefix - Entity-group prefix.
1166
1195
  * @param pattern - Glob pattern for the id segment, e.g. `'*'`.
1167
1196
  * @returns The matching fully-namespaced keys.
1197
+ * @throws {CacheException} `UNSUPPORTED_IN_CLUSTER` when called in cluster mode:
1198
+ * `KEYS` carries no key, so the cluster client sends it to one sampled node and the
1199
+ * reply would cover only that node's share of the keyspace.
1168
1200
  * @example
1169
1201
  * ```ts
1170
1202
  * await cache.keys('users', '*') // ['app:users:u_1', 'app:users:u_2']
@@ -1172,7 +1204,11 @@ exports.CacheService = class CacheService {
1172
1204
  */
1173
1205
  async keys(prefix, pattern) {
1174
1206
  const fullPattern = this.keyBuilder.build(prefix, pattern);
1175
- return this.connection.getClient().keys(fullPattern);
1207
+ const client = this.connection.getClient();
1208
+ if (!isScannableClient(client)) {
1209
+ throw new CacheException(CACHE_ERROR_CODES.UNSUPPORTED_IN_CLUSTER, { operation: "keys" });
1210
+ }
1211
+ return client.keys(fullPattern);
1176
1212
  }
1177
1213
  /**
1178
1214
  * Iterates keys matching a pattern under a prefix using a non-blocking cursor.
@@ -591,9 +591,11 @@ declare class CacheService {
591
591
  * @param prefix - Entity-group prefix.
592
592
  * @param id - Entity id.
593
593
  * @param value - The value to store (passed through the serializer).
594
- * @param ttlSeconds - Optional expiry in seconds; omit for no expiration.
594
+ * @param ttlSeconds - Optional expiry in seconds, a safe integer of at least 1; omit for
595
+ * no expiration.
595
596
  * @returns Resolves once the write completes.
596
597
  * @throws {CacheException} `SERIALIZATION_FAILED` when `value` cannot be encoded.
598
+ * @throws {CacheException} `INVALID_TTL` when `ttlSeconds` is not a safe integer of at least 1.
597
599
  */
598
600
  set<T>(prefix: string, id: string, value: T, ttlSeconds?: number): Promise<void>;
599
601
  /**
@@ -602,8 +604,10 @@ declare class CacheService {
602
604
  * @param prefix - Entity-group prefix.
603
605
  * @param id - Entity id.
604
606
  * @param value - The raw string to store as-is.
605
- * @param ttlSeconds - Optional expiry in seconds; omit for no expiration.
607
+ * @param ttlSeconds - Optional expiry in seconds, a safe integer of at least 1; omit for
608
+ * no expiration.
606
609
  * @returns Resolves once the write completes.
610
+ * @throws {CacheException} `INVALID_TTL` when `ttlSeconds` is not a safe integer of at least 1.
607
611
  */
608
612
  setRaw(prefix: string, id: string, value: string, ttlSeconds?: number): Promise<void>;
609
613
  /**
@@ -613,9 +617,11 @@ declare class CacheService {
613
617
  * @param prefix - Entity-group prefix.
614
618
  * @param id - Entity id.
615
619
  * @param value - The value to store (passed through the serializer).
616
- * @param ttlSeconds - Optional expiry in seconds applied on the same atomic write.
620
+ * @param ttlSeconds - Optional expiry in seconds applied on the same atomic write, a safe
621
+ * integer of at least 1.
617
622
  * @returns `true` when the value was stored, `false` when the key already existed.
618
623
  * @throws {CacheException} `SERIALIZATION_FAILED` when `value` cannot be encoded.
624
+ * @throws {CacheException} `INVALID_TTL` when `ttlSeconds` is not a safe integer of at least 1.
619
625
  */
620
626
  setNx<T>(prefix: string, id: string, value: T, ttlSeconds?: number): Promise<boolean>;
621
627
  /**
@@ -667,8 +673,10 @@ declare class CacheService {
667
673
  *
668
674
  * @param prefix - Entity-group prefix.
669
675
  * @param id - Entity id.
670
- * @param ttlSeconds - Expiry in seconds.
676
+ * @param ttlSeconds - Expiry in seconds, a safe integer. As in Redis, zero or a negative
677
+ * value deletes the key.
671
678
  * @returns `true` when the timeout was set, `false` when the key does not exist.
679
+ * @throws {CacheException} `INVALID_TTL` when `ttlSeconds` is not a safe integer.
672
680
  */
673
681
  expire(prefix: string, id: string, ttlSeconds: number): Promise<boolean>;
674
682
  /**
@@ -809,6 +817,9 @@ declare class CacheService {
809
817
  * @param prefix - Entity-group prefix.
810
818
  * @param pattern - Glob pattern for the id segment, e.g. `'*'`.
811
819
  * @returns The matching fully-namespaced keys.
820
+ * @throws {CacheException} `UNSUPPORTED_IN_CLUSTER` when called in cluster mode:
821
+ * `KEYS` carries no key, so the cluster client sends it to one sampled node and the
822
+ * reply would cover only that node's share of the keyspace.
812
823
  * @example
813
824
  * ```ts
814
825
  * await cache.keys('users', '*') // ['app:users:u_1', 'app:users:u_2']
@@ -1199,6 +1210,7 @@ declare const CACHE_ERROR_CODES: {
1199
1210
  readonly SCOPE_NOT_FOUND: "cache.scope_not_found";
1200
1211
  readonly SCOPE_NOT_READABLE: "cache.scope_not_readable";
1201
1212
  readonly KEY_NOT_IN_SCOPE: "cache.key_not_in_scope";
1213
+ readonly INVALID_TTL: "cache.invalid_ttl";
1202
1214
  };
1203
1215
  /** Union of every cache error code string value. */
1204
1216
  type CacheErrorCode = (typeof CACHE_ERROR_CODES)[keyof typeof CACHE_ERROR_CODES];
@@ -591,9 +591,11 @@ declare class CacheService {
591
591
  * @param prefix - Entity-group prefix.
592
592
  * @param id - Entity id.
593
593
  * @param value - The value to store (passed through the serializer).
594
- * @param ttlSeconds - Optional expiry in seconds; omit for no expiration.
594
+ * @param ttlSeconds - Optional expiry in seconds, a safe integer of at least 1; omit for
595
+ * no expiration.
595
596
  * @returns Resolves once the write completes.
596
597
  * @throws {CacheException} `SERIALIZATION_FAILED` when `value` cannot be encoded.
598
+ * @throws {CacheException} `INVALID_TTL` when `ttlSeconds` is not a safe integer of at least 1.
597
599
  */
598
600
  set<T>(prefix: string, id: string, value: T, ttlSeconds?: number): Promise<void>;
599
601
  /**
@@ -602,8 +604,10 @@ declare class CacheService {
602
604
  * @param prefix - Entity-group prefix.
603
605
  * @param id - Entity id.
604
606
  * @param value - The raw string to store as-is.
605
- * @param ttlSeconds - Optional expiry in seconds; omit for no expiration.
607
+ * @param ttlSeconds - Optional expiry in seconds, a safe integer of at least 1; omit for
608
+ * no expiration.
606
609
  * @returns Resolves once the write completes.
610
+ * @throws {CacheException} `INVALID_TTL` when `ttlSeconds` is not a safe integer of at least 1.
607
611
  */
608
612
  setRaw(prefix: string, id: string, value: string, ttlSeconds?: number): Promise<void>;
609
613
  /**
@@ -613,9 +617,11 @@ declare class CacheService {
613
617
  * @param prefix - Entity-group prefix.
614
618
  * @param id - Entity id.
615
619
  * @param value - The value to store (passed through the serializer).
616
- * @param ttlSeconds - Optional expiry in seconds applied on the same atomic write.
620
+ * @param ttlSeconds - Optional expiry in seconds applied on the same atomic write, a safe
621
+ * integer of at least 1.
617
622
  * @returns `true` when the value was stored, `false` when the key already existed.
618
623
  * @throws {CacheException} `SERIALIZATION_FAILED` when `value` cannot be encoded.
624
+ * @throws {CacheException} `INVALID_TTL` when `ttlSeconds` is not a safe integer of at least 1.
619
625
  */
620
626
  setNx<T>(prefix: string, id: string, value: T, ttlSeconds?: number): Promise<boolean>;
621
627
  /**
@@ -667,8 +673,10 @@ declare class CacheService {
667
673
  *
668
674
  * @param prefix - Entity-group prefix.
669
675
  * @param id - Entity id.
670
- * @param ttlSeconds - Expiry in seconds.
676
+ * @param ttlSeconds - Expiry in seconds, a safe integer. As in Redis, zero or a negative
677
+ * value deletes the key.
671
678
  * @returns `true` when the timeout was set, `false` when the key does not exist.
679
+ * @throws {CacheException} `INVALID_TTL` when `ttlSeconds` is not a safe integer.
672
680
  */
673
681
  expire(prefix: string, id: string, ttlSeconds: number): Promise<boolean>;
674
682
  /**
@@ -809,6 +817,9 @@ declare class CacheService {
809
817
  * @param prefix - Entity-group prefix.
810
818
  * @param pattern - Glob pattern for the id segment, e.g. `'*'`.
811
819
  * @returns The matching fully-namespaced keys.
820
+ * @throws {CacheException} `UNSUPPORTED_IN_CLUSTER` when called in cluster mode:
821
+ * `KEYS` carries no key, so the cluster client sends it to one sampled node and the
822
+ * reply would cover only that node's share of the keyspace.
812
823
  * @example
813
824
  * ```ts
814
825
  * await cache.keys('users', '*') // ['app:users:u_1', 'app:users:u_2']
@@ -1199,6 +1210,7 @@ declare const CACHE_ERROR_CODES: {
1199
1210
  readonly SCOPE_NOT_FOUND: "cache.scope_not_found";
1200
1211
  readonly SCOPE_NOT_READABLE: "cache.scope_not_readable";
1201
1212
  readonly KEY_NOT_IN_SCOPE: "cache.key_not_in_scope";
1213
+ readonly INVALID_TTL: "cache.invalid_ttl";
1202
1214
  };
1203
1215
  /** Union of every cache error code string value. */
1204
1216
  type CacheErrorCode = (typeof CACHE_ERROR_CODES)[keyof typeof CACHE_ERROR_CODES];
@@ -65,7 +65,8 @@ var CACHE_ERROR_CODES = {
65
65
  INVALID_SCOPE: "cache.invalid_scope",
66
66
  SCOPE_NOT_FOUND: "cache.scope_not_found",
67
67
  SCOPE_NOT_READABLE: "cache.scope_not_readable",
68
- KEY_NOT_IN_SCOPE: "cache.key_not_in_scope"
68
+ KEY_NOT_IN_SCOPE: "cache.key_not_in_scope",
69
+ INVALID_TTL: "cache.invalid_ttl"
69
70
  };
70
71
 
71
72
  // src/server/errors/cache-error-codes.ts
@@ -106,12 +107,17 @@ var CACHE_ERROR_MESSAGES = /* @__PURE__ */ new Map([
106
107
  [
107
108
  CACHE_ERROR_CODES.KEY_NOT_IN_SCOPE,
108
109
  "That key does not belong to the named administration scope."
110
+ ],
111
+ [
112
+ CACHE_ERROR_CODES.INVALID_TTL,
113
+ "A TTL in seconds must be a safe integer, and at least 1 for a write that sets one."
109
114
  ]
110
115
  ]);
111
116
  var CACHE_ERROR_STATUS = /* @__PURE__ */ new Map([
112
117
  [CACHE_ERROR_CODES.COMMAND_TIMEOUT, HttpStatus.GATEWAY_TIMEOUT],
113
118
  [CACHE_ERROR_CODES.CONNECTION_LOST, HttpStatus.SERVICE_UNAVAILABLE],
114
119
  [CACHE_ERROR_CODES.INVALID_KEY, HttpStatus.BAD_REQUEST],
120
+ [CACHE_ERROR_CODES.INVALID_TTL, HttpStatus.BAD_REQUEST],
115
121
  [CACHE_ERROR_CODES.FLUSH_DISABLED_IN_PRODUCTION, HttpStatus.FORBIDDEN]
116
122
  ]);
117
123
  var CacheException = class extends HttpException {
@@ -777,6 +783,17 @@ var resolveSerializer = (options, injected) => (
777
783
  options.serializer ?? injected ?? new JsonSerializer()
778
784
  );
779
785
 
786
+ // src/server/utils/ttl.ts
787
+ var MIN_WRITE_TTL_SECONDS = 1;
788
+ function assertTtlSeconds(ttlSeconds, minimum) {
789
+ if (!Number.isSafeInteger(ttlSeconds)) {
790
+ throw new CacheException(CACHE_ERROR_CODES.INVALID_TTL, { reason: "not_a_safe_integer" });
791
+ }
792
+ if (minimum !== void 0 && ttlSeconds < minimum) {
793
+ throw new CacheException(CACHE_ERROR_CODES.INVALID_TTL, { reason: "below_minimum", minimum });
794
+ }
795
+ }
796
+
780
797
  // src/server/services/cache.service.ts
781
798
  var PRODUCTION_ENV = "production";
782
799
  var SCAN_COUNT = 100;
@@ -837,14 +854,17 @@ var CacheService = class {
837
854
  * @param prefix - Entity-group prefix.
838
855
  * @param id - Entity id.
839
856
  * @param value - The value to store (passed through the serializer).
840
- * @param ttlSeconds - Optional expiry in seconds; omit for no expiration.
857
+ * @param ttlSeconds - Optional expiry in seconds, a safe integer of at least 1; omit for
858
+ * no expiration.
841
859
  * @returns Resolves once the write completes.
842
860
  * @throws {CacheException} `SERIALIZATION_FAILED` when `value` cannot be encoded.
861
+ * @throws {CacheException} `INVALID_TTL` when `ttlSeconds` is not a safe integer of at least 1.
843
862
  */
844
863
  async set(prefix, id, value, ttlSeconds) {
845
864
  const key = this.keyBuilder.build(prefix, id);
846
865
  const raw = this.serializer.serialize(value);
847
866
  if (ttlSeconds !== void 0) {
867
+ assertTtlSeconds(ttlSeconds, MIN_WRITE_TTL_SECONDS);
848
868
  await this.connection.getClient().set(key, raw, "EX", ttlSeconds);
849
869
  } else {
850
870
  await this.connection.getClient().set(key, raw);
@@ -856,12 +876,15 @@ var CacheService = class {
856
876
  * @param prefix - Entity-group prefix.
857
877
  * @param id - Entity id.
858
878
  * @param value - The raw string to store as-is.
859
- * @param ttlSeconds - Optional expiry in seconds; omit for no expiration.
879
+ * @param ttlSeconds - Optional expiry in seconds, a safe integer of at least 1; omit for
880
+ * no expiration.
860
881
  * @returns Resolves once the write completes.
882
+ * @throws {CacheException} `INVALID_TTL` when `ttlSeconds` is not a safe integer of at least 1.
861
883
  */
862
884
  async setRaw(prefix, id, value, ttlSeconds) {
863
885
  const key = this.keyBuilder.build(prefix, id);
864
886
  if (ttlSeconds !== void 0) {
887
+ assertTtlSeconds(ttlSeconds, MIN_WRITE_TTL_SECONDS);
865
888
  await this.connection.getClient().set(key, value, "EX", ttlSeconds);
866
889
  } else {
867
890
  await this.connection.getClient().set(key, value);
@@ -874,13 +897,16 @@ var CacheService = class {
874
897
  * @param prefix - Entity-group prefix.
875
898
  * @param id - Entity id.
876
899
  * @param value - The value to store (passed through the serializer).
877
- * @param ttlSeconds - Optional expiry in seconds applied on the same atomic write.
900
+ * @param ttlSeconds - Optional expiry in seconds applied on the same atomic write, a safe
901
+ * integer of at least 1.
878
902
  * @returns `true` when the value was stored, `false` when the key already existed.
879
903
  * @throws {CacheException} `SERIALIZATION_FAILED` when `value` cannot be encoded.
904
+ * @throws {CacheException} `INVALID_TTL` when `ttlSeconds` is not a safe integer of at least 1.
880
905
  */
881
906
  async setNx(prefix, id, value, ttlSeconds) {
882
907
  const key = this.keyBuilder.build(prefix, id);
883
908
  const raw = this.serializer.serialize(value);
909
+ if (ttlSeconds !== void 0) assertTtlSeconds(ttlSeconds, MIN_WRITE_TTL_SECONDS);
884
910
  const result = ttlSeconds !== void 0 ? await this.connection.getClient().set(key, raw, "EX", ttlSeconds, "NX") : await this.connection.getClient().set(key, raw, "NX");
885
911
  return result === "OK";
886
912
  }
@@ -952,10 +978,13 @@ var CacheService = class {
952
978
  *
953
979
  * @param prefix - Entity-group prefix.
954
980
  * @param id - Entity id.
955
- * @param ttlSeconds - Expiry in seconds.
981
+ * @param ttlSeconds - Expiry in seconds, a safe integer. As in Redis, zero or a negative
982
+ * value deletes the key.
956
983
  * @returns `true` when the timeout was set, `false` when the key does not exist.
984
+ * @throws {CacheException} `INVALID_TTL` when `ttlSeconds` is not a safe integer.
957
985
  */
958
986
  async expire(prefix, id, ttlSeconds) {
987
+ assertTtlSeconds(ttlSeconds, void 0);
959
988
  const result = await this.connection.getClient().expire(this.keyBuilder.build(prefix, id), ttlSeconds);
960
989
  return result === 1;
961
990
  }
@@ -1163,6 +1192,9 @@ var CacheService = class {
1163
1192
  * @param prefix - Entity-group prefix.
1164
1193
  * @param pattern - Glob pattern for the id segment, e.g. `'*'`.
1165
1194
  * @returns The matching fully-namespaced keys.
1195
+ * @throws {CacheException} `UNSUPPORTED_IN_CLUSTER` when called in cluster mode:
1196
+ * `KEYS` carries no key, so the cluster client sends it to one sampled node and the
1197
+ * reply would cover only that node's share of the keyspace.
1166
1198
  * @example
1167
1199
  * ```ts
1168
1200
  * await cache.keys('users', '*') // ['app:users:u_1', 'app:users:u_2']
@@ -1170,7 +1202,11 @@ var CacheService = class {
1170
1202
  */
1171
1203
  async keys(prefix, pattern) {
1172
1204
  const fullPattern = this.keyBuilder.build(prefix, pattern);
1173
- return this.connection.getClient().keys(fullPattern);
1205
+ const client = this.connection.getClient();
1206
+ if (!isScannableClient(client)) {
1207
+ throw new CacheException(CACHE_ERROR_CODES.UNSUPPORTED_IN_CLUSTER, { operation: "keys" });
1208
+ }
1209
+ return client.keys(fullPattern);
1174
1210
  }
1175
1211
  /**
1176
1212
  * Iterates keys matching a pattern under a prefix using a non-blocking cursor.
@@ -20,7 +20,8 @@ var CACHE_ERROR_CODES = {
20
20
  INVALID_SCOPE: "cache.invalid_scope",
21
21
  SCOPE_NOT_FOUND: "cache.scope_not_found",
22
22
  SCOPE_NOT_READABLE: "cache.scope_not_readable",
23
- KEY_NOT_IN_SCOPE: "cache.key_not_in_scope"
23
+ KEY_NOT_IN_SCOPE: "cache.key_not_in_scope",
24
+ INVALID_TTL: "cache.invalid_ttl"
24
25
  };
25
26
 
26
27
  // src/shared/constants/event-names.ts
@@ -97,6 +97,7 @@ declare const CACHE_ERROR_CODES: {
97
97
  readonly SCOPE_NOT_FOUND: "cache.scope_not_found";
98
98
  readonly SCOPE_NOT_READABLE: "cache.scope_not_readable";
99
99
  readonly KEY_NOT_IN_SCOPE: "cache.key_not_in_scope";
100
+ readonly INVALID_TTL: "cache.invalid_ttl";
100
101
  };
101
102
  /** Union of every cache error code string value. */
102
103
  type CacheErrorCode = (typeof CACHE_ERROR_CODES)[keyof typeof CACHE_ERROR_CODES];
@@ -97,6 +97,7 @@ declare const CACHE_ERROR_CODES: {
97
97
  readonly SCOPE_NOT_FOUND: "cache.scope_not_found";
98
98
  readonly SCOPE_NOT_READABLE: "cache.scope_not_readable";
99
99
  readonly KEY_NOT_IN_SCOPE: "cache.key_not_in_scope";
100
+ readonly INVALID_TTL: "cache.invalid_ttl";
100
101
  };
101
102
  /** Union of every cache error code string value. */
102
103
  type CacheErrorCode = (typeof CACHE_ERROR_CODES)[keyof typeof CACHE_ERROR_CODES];
@@ -18,7 +18,8 @@ var CACHE_ERROR_CODES = {
18
18
  INVALID_SCOPE: "cache.invalid_scope",
19
19
  SCOPE_NOT_FOUND: "cache.scope_not_found",
20
20
  SCOPE_NOT_READABLE: "cache.scope_not_readable",
21
- KEY_NOT_IN_SCOPE: "cache.key_not_in_scope"
21
+ KEY_NOT_IN_SCOPE: "cache.key_not_in_scope",
22
+ INVALID_TTL: "cache.invalid_ttl"
22
23
  };
23
24
 
24
25
  // src/shared/constants/event-names.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bymax-one/nest-cache",
3
- "version": "1.2.3",
3
+ "version": "1.2.4",
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",