@bymax-one/nest-cache 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,967 @@
1
+ import { DynamicModule, InjectionToken, OptionalFactoryDependency } from '@nestjs/common';
2
+ import { CacheService, ResolvedOptions, ISerializer } from '@bymax-one/nest-cache';
3
+
4
+ /**
5
+ * The keyspaces an administration surface is allowed to read, and what may be
6
+ * read from each.
7
+ *
8
+ * Layer: admin. A scope is declared by the *application*, not by the library and
9
+ * not by a caller. The library owns the mechanism — validating the declarations,
10
+ * scanning against them, withholding what they mark unreadable — while which
11
+ * keyspaces exist is a fact about a deployment's wiring that no library can
12
+ * learn. A cache configured with `namespace: 'app'` may still share its Redis
13
+ * with keys another library writes at root through `CacheService.getClient()`,
14
+ * and only the application knows that.
15
+ *
16
+ * @see `docs/technical_specification.md` §7 — Namespace Strategy
17
+ */
18
+ /**
19
+ * One keyspace an administration surface may read.
20
+ *
21
+ * Declared once at module wiring and frozen. A caller names a scope by
22
+ * {@link CacheScope.id} and never supplies a pattern — a pattern composed from
23
+ * request input is the vulnerability class this type exists to close.
24
+ */
25
+ interface CacheScope {
26
+ /** The identifier a caller names. Unique within a deployment; never a pattern. */
27
+ readonly id: string;
28
+ /** Human-readable name for an operator-facing surface. */
29
+ readonly label: string;
30
+ /**
31
+ * The `SCAN` match pattern this scope resolves to.
32
+ *
33
+ * Composed by the application and validated here: it must carry at least one
34
+ * literal character before its first glob metacharacter, so a scope can never
35
+ * resolve to the whole keyspace. That is an anchoring guarantee, not a
36
+ * narrowness one — the library cannot judge whether `a*` is too broad for a
37
+ * given deployment, only that it is anchored somewhere.
38
+ */
39
+ readonly pattern: string;
40
+ /**
41
+ * Whether a key's **value** may be returned from this scope.
42
+ *
43
+ * `false` means the value alone is withheld. Listing, types, TTLs and sizes
44
+ * stay available, and that split is the whole point: a surface that renders an
45
+ * unreadable keyspace as empty tells an operator the region holds nothing when
46
+ * it is full — the same defect as a blank log page during an outage, where a
47
+ * reading meaning "I may not tell you" is drawn identically to one meaning
48
+ * "there is nothing here".
49
+ */
50
+ readonly isReadable: boolean;
51
+ /**
52
+ * Why this keyspace exists, in the application's own words.
53
+ *
54
+ * Rendered **verbatim**, as plain text — the library imposes no length limit
55
+ * and interprets no markup. An application with one scope wants a sentence and
56
+ * one with eight wants a phrase, and a truncation rule imposed here would cut
57
+ * an explanation mid-clause on the screen where the explanation is the point.
58
+ */
59
+ readonly origin: string;
60
+ }
61
+
62
+ /**
63
+ * Options for the administration module.
64
+ *
65
+ * Layer: admin. The application declares which keyspaces exist and how patient
66
+ * this deployment is; the library owns everything else.
67
+ */
68
+
69
+ /** Consumer-supplied options for `BymaxCacheAdminModule`. */
70
+ interface BymaxCacheAdminModuleOptions {
71
+ /**
72
+ * The keyspaces this deployment exposes, in the order a surface should offer
73
+ * them. Validated and frozen at wiring — see `validateScopes`.
74
+ */
75
+ scopes: readonly CacheScope[];
76
+ /** Round trip above which health reports `degraded`. Default: 250 ms. */
77
+ degradedAboveMs?: number;
78
+ /** How long a health probe waits before reporting `down`. Default: 2000 ms. */
79
+ probeTimeoutMs?: number;
80
+ /** Most keys one listing reads before reporting the page incomplete. Default: 500. */
81
+ scanLimit?: number;
82
+ /** Most commands one pipeline flush sends. Default: 100. */
83
+ commandBatchLimit?: number;
84
+ /** Most members or hash fields one value reveal returns. Default: 200. */
85
+ revealLimit?: number;
86
+ /** Most characters one revealed string value carries. Default: 4096. */
87
+ revealStringLimit?: number;
88
+ /** Register the module globally. Default: `false` — an admin surface is wired where it is used. */
89
+ isGlobal?: boolean;
90
+ }
91
+
92
+ /**
93
+ * Async wiring for {@link BymaxCacheAdminModule.forRootAsync}.
94
+ *
95
+ * `isGlobal` is a top-level field rather than part of the factory's result
96
+ * because Nest needs the `global` flag while building the module definition,
97
+ * which is before any async factory has resolved.
98
+ */
99
+ interface BymaxCacheAdminModuleAsyncOptions {
100
+ /** Modules exporting whatever the factory injects. */
101
+ imports?: DynamicModule['imports'];
102
+ /** Providers injected into the factory. */
103
+ inject?: (InjectionToken | OptionalFactoryDependency)[];
104
+ /** Builds the administration options. */
105
+ useFactory: (...args: never[]) => BymaxCacheAdminModuleOptions | Promise<BymaxCacheAdminModuleOptions>;
106
+ /** Register globally. Decided synchronously; default `false`. */
107
+ isGlobal?: boolean;
108
+ }
109
+ /** Read-only administration for `@bymax-one/nest-cache`. */
110
+ declare class BymaxCacheAdminModule {
111
+ /**
112
+ * Registers the administration surface with statically known options.
113
+ *
114
+ * Options are validated here, at wiring — a malformed scope or a non-positive
115
+ * threshold fails the boot rather than the first request.
116
+ *
117
+ * @param options - The scopes this deployment exposes, plus any thresholds.
118
+ * @returns The dynamic module.
119
+ * @throws {CacheException} `INVALID_SCOPE` when the declaration is malformed.
120
+ */
121
+ static forRoot(options: BymaxCacheAdminModuleOptions): DynamicModule;
122
+ /**
123
+ * Registers the administration surface with options built asynchronously.
124
+ *
125
+ * Validation runs inside the factory, so the same rules apply on both paths
126
+ * and neither can drift into accepting what the other rejects.
127
+ *
128
+ * @param options - Factory wiring plus the synchronous `isGlobal` flag.
129
+ * @returns The dynamic module.
130
+ */
131
+ static forRootAsync(options: BymaxCacheAdminModuleAsyncOptions): DynamicModule;
132
+ }
133
+
134
+ /** Administration options after defaults are merged and validated. */
135
+ interface ResolvedAdminOptions {
136
+ readonly scopes: readonly CacheScope[];
137
+ readonly degradedAboveMs: number;
138
+ readonly probeTimeoutMs: number;
139
+ readonly scanLimit: number;
140
+ readonly commandBatchLimit: number;
141
+ readonly revealLimit: number;
142
+ readonly revealStringLimit: number;
143
+ readonly isGlobal: boolean;
144
+ }
145
+ /**
146
+ * Merges administration options with defaults, validates them, and freezes the
147
+ * result.
148
+ *
149
+ * @param options - The raw consumer options.
150
+ * @returns The frozen resolved options.
151
+ * @throws {CacheException} `INVALID_SCOPE` when a scope is malformed or a
152
+ * threshold is not a positive integer.
153
+ */
154
+ declare function resolveAdminOptions(options: BymaxCacheAdminModuleOptions): Readonly<ResolvedAdminOptions>;
155
+
156
+ /**
157
+ * What an administration surface reads to decide what it can say about the cache.
158
+ *
159
+ * Layer: admin.
160
+ */
161
+ /** How this deployment connects to Redis. */
162
+ type CacheMode = 'standalone' | 'sentinel' | 'cluster';
163
+ /**
164
+ * Why a probe did not answer.
165
+ *
166
+ * A closed union rather than the underlying error's text: an error message is
167
+ * free-form and may carry connection detail the library is careful never to
168
+ * echo (CLAUDE.md §4). The syscall {@link CacheProbeDown.code} carries the part
169
+ * an operator acts on; the full error reaches the application through the
170
+ * `ICacheEvents` hooks the module already provides.
171
+ */
172
+ type CacheProbeFailure = 'timeout' | 'error';
173
+ /** A probe that answered within the degraded threshold. */
174
+ interface CacheProbeUp {
175
+ readonly status: 'up';
176
+ readonly latencyMs: number;
177
+ }
178
+ /** A probe that answered, but slowly. */
179
+ interface CacheProbeDegraded {
180
+ readonly status: 'degraded';
181
+ readonly latencyMs: number;
182
+ }
183
+ /** A probe that did not answer. */
184
+ interface CacheProbeDown {
185
+ readonly status: 'down';
186
+ readonly reason: CacheProbeFailure;
187
+ /** The syscall code when the driver reported one (`ECONNREFUSED`), else `null`. */
188
+ readonly code: string | null;
189
+ }
190
+ /**
191
+ * The result of pinging Redis.
192
+ *
193
+ * **Three states, not two.** A server answering `PING` slowly is neither up nor
194
+ * down, and collapsing it into either hides the state an operator most wants to
195
+ * catch early — by the time a slow cache becomes an unreachable one, the choice
196
+ * has been made for them.
197
+ *
198
+ * **A union, so `latencyMs` cannot exist without a measurement.** The invariant
199
+ * is that a latency is reported if and only if the ping answered; expressed as
200
+ * `latencyMs: number | null` it would be a convention, and a handler that caught
201
+ * a throwing ping and fell back to a default shape would hand a surface a
202
+ * confident status that really means "I did not ask" — undetectable from the
203
+ * payload. Here that shape does not typecheck.
204
+ */
205
+ type CacheProbe = CacheProbeUp | CacheProbeDegraded | CacheProbeDown;
206
+ /**
207
+ * The cache's health, as an administration surface reads it.
208
+ *
209
+ * `mode`, `isScanSupported` and `degradedAboveMs` sit OUTSIDE the discriminated
210
+ * part on purpose: a cluster deployment that is down should still report that
211
+ * scanning was never going to work, and folding those into the answering
212
+ * branches would make the `down` payload quieter about facts that have nothing
213
+ * to do with being down.
214
+ */
215
+ type CacheHealth = CacheProbe & {
216
+ readonly mode: CacheMode;
217
+ /**
218
+ * Whether this deployment's mode supports `SCAN`.
219
+ *
220
+ * On the wire beside `mode` deliberately. The library refuses `scan` under
221
+ * cluster, and applying that rule is the library's job — a surface
222
+ * re-deriving it from `mode` would hold a copy of a rule it cannot see change.
223
+ */
224
+ readonly isScanSupported: boolean;
225
+ /** The round trip above which this deployment calls the cache degraded. */
226
+ readonly degradedAboveMs: number;
227
+ };
228
+ /** The resolved wiring, with every credential-bearing field withheld. */
229
+ interface CacheConfig {
230
+ readonly mode: CacheMode;
231
+ readonly namespace: string;
232
+ readonly keySeparator: string;
233
+ readonly shutdownTimeoutMs: number;
234
+ readonly isFlushAllowedInProduction: boolean;
235
+ /** The serializer's constructor name, or `null` when it does not report one. */
236
+ readonly serializer: string | null;
237
+ /** The Lua scripts registered at wiring, by name only — never their bodies. */
238
+ readonly scripts: readonly string[];
239
+ /**
240
+ * Where this deployment connects, with the connection URL withheld.
241
+ *
242
+ * `ResolvedOptions.connection` is `{ url, password, ... }`, so serving the
243
+ * resolved options directly — the obvious implementation — ships a Redis URL
244
+ * with its password to whatever reads this. Host, port and a TLS flag carry
245
+ * everything an operator-facing panel needs and none of the credential.
246
+ *
247
+ * `host` and `port` are `null` under sentinel and cluster, where there is no
248
+ * single endpoint to name; `mode` alongside says why.
249
+ */
250
+ readonly connection: {
251
+ readonly host: string | null;
252
+ readonly port: number | null;
253
+ readonly isTls: boolean;
254
+ };
255
+ }
256
+
257
+ /**
258
+ * The `INFO` subset an administration surface reads.
259
+ *
260
+ * Layer: admin. Every field here is reported, never defaulted: a value this
261
+ * server does not publish arrives as `null` and a surface renders "not
262
+ * reported". Substituting a zero would draw a reading that means "I could not
263
+ * measure" identically to one that means "I measured, and it is nothing".
264
+ *
265
+ * Where a single `null` would carry two different meanings, the type is a
266
+ * discriminated union instead — see {@link MaxMemory}.
267
+ */
268
+ /**
269
+ * The configured memory ceiling.
270
+ *
271
+ * A union rather than `number | null` because `maxmemory` has three states and
272
+ * only two of them are a number. Redis spells "no ceiling" as `maxmemory:0`,
273
+ * which read as a literal ceiling makes a saturation bar show full on the least
274
+ * constrained server there is; and a server that does not publish the field at
275
+ * all has told us nothing. Collapsing "unbounded" and "unreported" into one
276
+ * `null` leaves a surface unable to tell an operator which of those it is
277
+ * looking at, and they call for opposite actions.
278
+ */
279
+ type MaxMemory =
280
+ /** A ceiling is configured. */
281
+ {
282
+ readonly kind: 'limited';
283
+ readonly bytes: number;
284
+ }
285
+ /** `maxmemory:0` — the server will grow until the host stops it. */
286
+ | {
287
+ readonly kind: 'unbounded';
288
+ }
289
+ /** The server does not publish `maxmemory`. */
290
+ | {
291
+ readonly kind: 'unreported';
292
+ };
293
+ /** The outcome of the most recent background save, or `null` when unreported. */
294
+ type BgsaveStatus = 'ok' | 'err' | null;
295
+ /** Whether this server is a primary or a replica. */
296
+ type ReplicationRole = 'master' | 'replica';
297
+ /** A parsed reading of Redis `INFO`. */
298
+ interface RedisStats {
299
+ /**
300
+ * When this reading was taken, by the **reading host's** clock — not the
301
+ * Redis server's.
302
+ *
303
+ * Named for the side that measured it. Under clock skew between the
304
+ * application host and the Redis host the two differ, and this value is used
305
+ * to place a reading on an incident timeline, which is exactly where the
306
+ * difference shows.
307
+ */
308
+ readonly readAt: string;
309
+ readonly server: {
310
+ readonly redisVersion: string | null;
311
+ readonly mode: string | null;
312
+ readonly uptimeSeconds: number | null;
313
+ };
314
+ readonly clients: {
315
+ readonly connected: number | null;
316
+ readonly blocked: number | null;
317
+ };
318
+ readonly memory: {
319
+ readonly usedBytes: number | null;
320
+ readonly peakBytes: number | null;
321
+ /** Three-state: a ceiling, no ceiling, or no answer. See {@link MaxMemory}. */
322
+ readonly max: MaxMemory;
323
+ /**
324
+ * Allocator fragmentation, reported raw.
325
+ *
326
+ * Interpretation is deliberately left to the caller: on an instance holding
327
+ * very little, allocator and copy-on-write overhead dominate and this reads
328
+ * far above 1 without indicating a problem — 9.07 was measured on an
329
+ * instance holding 1.1 MiB. A threshold that turns this into a health
330
+ * verdict is deployment policy, not a property of the reading.
331
+ */
332
+ readonly fragmentationRatio: number | null;
333
+ readonly evictionPolicy: string | null;
334
+ };
335
+ readonly stats: {
336
+ readonly keyspaceHits: number | null;
337
+ readonly keyspaceMisses: number | null;
338
+ readonly expiredKeys: number | null;
339
+ readonly evictedKeys: number | null;
340
+ readonly instantaneousOpsPerSec: number | null;
341
+ readonly totalCommandsProcessed: number | null;
342
+ readonly totalConnectionsReceived: number | null;
343
+ readonly rejectedConnections: number | null;
344
+ };
345
+ readonly persistence: {
346
+ readonly rdbLastSaveAt: string | null;
347
+ readonly rdbChangesSinceLastSave: number | null;
348
+ readonly rdbLastBgsaveStatus: BgsaveStatus;
349
+ /**
350
+ * Whether append-only persistence is on, or `null` when the server does not
351
+ * publish `aof_enabled`.
352
+ *
353
+ * Nullable rather than defaulting to `false`, because `false` here is a
354
+ * claim about durability made in the absence of evidence — "AOF is off" and
355
+ * "I could not determine whether AOF is on" are different sentences to
356
+ * someone deciding whether a restart loses writes.
357
+ */
358
+ readonly aofEnabled: boolean | null;
359
+ };
360
+ readonly replication: {
361
+ readonly role: ReplicationRole;
362
+ readonly connectedReplicas: number | null;
363
+ };
364
+ }
365
+
366
+ /**
367
+ * The cache capabilities this service reads through.
368
+ *
369
+ * A `Pick` of the real facade rather than the class itself: Nest still injects
370
+ * the concrete `CacheService`, while a test supplies a plain object with two
371
+ * methods and no cast. A cast would be a blocking finding under this
372
+ * repository's suppression policy, so the narrow type is not a convenience.
373
+ */
374
+ type ICacheProbe = Pick<CacheService, 'ping' | 'info'>;
375
+ /** Reports whether the cache is answering, what it is doing, and how it is wired. */
376
+ declare class CacheStatusService {
377
+ private readonly cache;
378
+ private readonly options;
379
+ private readonly adminOptions;
380
+ private readonly serializer;
381
+ /**
382
+ * @param cache - The cache facade this service probes through.
383
+ * @param options - The resolved cache module options.
384
+ * @param adminOptions - The resolved administration options.
385
+ * @param serializer - The wired serializer, named on the config payload.
386
+ */
387
+ constructor(cache: ICacheProbe, options: ResolvedOptions, adminOptions: ResolvedAdminOptions, serializer: ISerializer);
388
+ /**
389
+ * Pings Redis and reports the result in three states.
390
+ *
391
+ * @returns The probe outcome, plus the mode facts that hold whether or not it
392
+ * answered.
393
+ */
394
+ health(): Promise<CacheHealth>;
395
+ /**
396
+ * Reads Redis `INFO` into a typed reading.
397
+ *
398
+ * @returns The parsed statistics, stamped with the reading host's clock.
399
+ */
400
+ stats(): Promise<RedisStats>;
401
+ /**
402
+ * Reports how this deployment is wired, with every credential withheld.
403
+ *
404
+ * @returns The resolved configuration, safe to serve to an operator surface.
405
+ */
406
+ config(): CacheConfig;
407
+ /**
408
+ * Measures one `PING`.
409
+ *
410
+ * `latencyMs` is produced on exactly one path — a reply that arrived. Every
411
+ * other outcome returns the `down` branch, which has no latency field to fill
412
+ * in, so a confident status without a measurement cannot be constructed here.
413
+ *
414
+ * @returns The probe outcome.
415
+ */
416
+ private probe;
417
+ }
418
+
419
+ /**
420
+ * The Redis commands the administration surface issues.
421
+ *
422
+ * Layer: admin. Declared as capabilities rather than taken as ioredis's `Redis`
423
+ * so a unit test can supply a plain object with no cast — a cast would be a
424
+ * blocking finding under this repository's suppression policy, making the narrow
425
+ * interface a correctness requirement rather than a convenience. The real client
426
+ * satisfies these structurally, which a compile-time check in the service pins.
427
+ *
428
+ * **Every command named here is read-only.** No `DEL`, no `SET`, no `EXPIRE`, no
429
+ * `UNLINK`, no `FLUSHDB`. That is worth stating because the client this surface
430
+ * holds can do all of them — and it is enforced rather than asserted: the
431
+ * `check:admin-readonly` gate fails the build if a mutating command appears in
432
+ * this subpath.
433
+ */
434
+ /** One entry of a pipeline's result: an error, or a value. */
435
+ type PipelineReply = [Error | null, unknown];
436
+ /** A pipeline being composed, chainable the way ioredis's commander is. */
437
+ interface IRedisPipeline {
438
+ /** Queues `TYPE`. */
439
+ type(key: string): IRedisPipeline;
440
+ /** Queues `TTL`. */
441
+ ttl(key: string): IRedisPipeline;
442
+ /** Queues `MEMORY USAGE`. */
443
+ memory(subcommand: 'USAGE', key: string): IRedisPipeline;
444
+ /** Sends the batch. */
445
+ exec(): Promise<PipelineReply[] | null>;
446
+ }
447
+ /** The subset of the Redis client this surface reads through. */
448
+ interface IRedisReader {
449
+ /** Cursor-based key iteration. */
450
+ scan(cursor: string | number, matchToken: 'MATCH', pattern: string, countToken: 'COUNT', count: number | string): Promise<[string, string[]]>;
451
+ /** Opens a batch. */
452
+ pipeline(): IRedisPipeline;
453
+ /** Reads one key's type. */
454
+ type(key: string): Promise<string>;
455
+ /** Reads one key's remaining life in seconds. */
456
+ ttl(key: string): Promise<number>;
457
+ /** Reads a string's value. */
458
+ get(key: string): Promise<string | null>;
459
+ /** Reads a hash's fields. */
460
+ hgetall(key: string): Promise<Record<string, string>>;
461
+ /** Reads a set's members. */
462
+ smembers(key: string): Promise<string[]>;
463
+ /** Reads a slice of a list. */
464
+ lrange(key: string, start: number, stop: number): Promise<string[]>;
465
+ /**
466
+ * Sends a command by name.
467
+ *
468
+ * Present for the one read this interface cannot express as a method:
469
+ * ioredis's `zrange` overload set cannot be narrowed to a four-argument
470
+ * signature (measured — TypeScript resolves the assignment against a nine
471
+ * argument overload and rejects it), so the sorted-set read goes through here
472
+ * as `ZRANGE key start stop WITHSCORES`.
473
+ *
474
+ * It is a general escape hatch, which is exactly why the `check:admin-readonly`
475
+ * gate inspects the command names this subpath passes to it rather than
476
+ * trusting that only reads are sent.
477
+ */
478
+ call(command: string, ...args: (string | number)[]): Promise<unknown>;
479
+ }
480
+ /** How the administration surface obtains its reader. */
481
+ interface IRedisReaderSource {
482
+ /**
483
+ * Returns the client.
484
+ *
485
+ * The library's own `CacheService.getClient()` refuses cluster mode with
486
+ * `cache.unsupported_in_cluster`, so the cluster refusal this surface needs
487
+ * comes from the same rule the rest of the library applies rather than a
488
+ * second copy of it.
489
+ */
490
+ getClient(): IRedisReader;
491
+ }
492
+
493
+ /**
494
+ * The shapes an administration surface reads about keys.
495
+ *
496
+ * Layer: admin. As in {@link ../types/redis-stats.types}, a value that would
497
+ * carry two meanings under one `null` is a discriminated union instead.
498
+ */
499
+ /**
500
+ * The Redis data types this surface names, plus an escape hatch.
501
+ *
502
+ * `other` is what makes the union safe to declare closed. Redis may report a
503
+ * type this build has never heard of — a module type, or one added by a later
504
+ * server — and mapping the unrecognised case to a named member means a
505
+ * consumer's exhaustive switch can never be wrong about a real reply. Passing
506
+ * the raw string through instead would put an open set into a closed type.
507
+ */
508
+ type RedisKeyType = 'string' | 'hash' | 'set' | 'list' | 'zset' | 'stream' | 'other';
509
+ /**
510
+ * How much life a key has left.
511
+ *
512
+ * A union rather than `number | null`, because Redis answers `TTL` with three
513
+ * distinct facts and two of them are not a number: `-1` means the key exists
514
+ * with no expiry, and `-2` means the key does not exist. Collapsing those into
515
+ * one `null` forces the caller to recover the difference from somewhere else —
516
+ * and the case where it matters most is a key that expired between the scan and
517
+ * the read, which is exactly the key an operator is staring at when they care.
518
+ */
519
+ type KeyTtl =
520
+ /** The key expires in `seconds`. */
521
+ {
522
+ readonly kind: 'expiring';
523
+ readonly seconds: number;
524
+ }
525
+ /** The key exists and has no expiry set (`TTL` → `-1`). */
526
+ | {
527
+ readonly kind: 'persistent';
528
+ }
529
+ /** The key does not exist (`TTL` → `-2`). */
530
+ | {
531
+ readonly kind: 'missing';
532
+ };
533
+ /** One key as it appears in a listing. */
534
+ interface KeyEntry {
535
+ /** The full key, exactly as Redis stores it. */
536
+ readonly key: string;
537
+ readonly type: RedisKeyType;
538
+ readonly ttl: KeyTtl;
539
+ /**
540
+ * Serialized size in bytes, or `null` when not measured.
541
+ *
542
+ * `null` covers two cases the caller can already tell apart from the request
543
+ * it made: sizing was not requested, or the server declined to answer for this
544
+ * key. Reporting `0` instead would make a key of unknown size and a key
545
+ * occupying nothing render identically, and only one of them is a measurement.
546
+ */
547
+ readonly sizeBytes: number | null;
548
+ }
549
+ /** One page of a keyspace listing. */
550
+ interface KeyspacePage {
551
+ /** The scope this page was read from. */
552
+ readonly scopeId: string;
553
+ readonly entries: readonly KeyEntry[];
554
+ /**
555
+ * How many keys this page actually read.
556
+ *
557
+ * Named `sampled` rather than `count` because it is a sum over a capped
558
+ * `SCAN`, not a measurement of the keyspace. {@link KeyspacePage.isComplete}
559
+ * is the fact; the name is the guard — a caller reaching for one of these does
560
+ * not necessarily read the other, and a bare `count` would look measured.
561
+ */
562
+ readonly sampledCount: number;
563
+ /**
564
+ * Total serialized bytes across the keys whose size was measured, or `null`
565
+ * when sizing was not requested.
566
+ *
567
+ * Sampled for the same reason as {@link KeyspacePage.sampledCount}, and
568
+ * renamed with it or not at all — moving only one of the pair relocates the
569
+ * trap instead of closing it.
570
+ */
571
+ readonly sampledBytes: number | null;
572
+ /** Whether the scan reached the end of the scope rather than the cap. */
573
+ readonly isComplete: boolean;
574
+ /** The cap this page was read under, so a caller can say why it stopped. */
575
+ readonly scanLimit: number;
576
+ /** Cursor to continue from, or `null` when the scope was exhausted. */
577
+ readonly cursor: string | null;
578
+ }
579
+ /** One key, described without reading its value. */
580
+ interface KeyDetail extends KeyEntry {
581
+ /** The scope the key was resolved through. */
582
+ readonly scopeId: string;
583
+ /**
584
+ * Whether this scope permits reading the key's value.
585
+ *
586
+ * Present on the detail so a surface can render the listing honestly and
587
+ * disable only the reveal, rather than discovering the refusal by attempting it.
588
+ */
589
+ readonly isReadable: boolean;
590
+ }
591
+ /** A revealed value, shaped by the key's Redis type. */
592
+ type RevealedValue = {
593
+ readonly kind: 'string';
594
+ readonly value: string;
595
+ readonly isComplete: boolean;
596
+ } | {
597
+ readonly kind: 'hash';
598
+ readonly fields: readonly {
599
+ readonly name: string;
600
+ readonly value: string;
601
+ }[];
602
+ readonly isComplete: boolean;
603
+ }
604
+ /** Sets and lists: a flat member list. The enclosing `type` says which it is. */
605
+ | {
606
+ readonly kind: 'members';
607
+ readonly members: readonly string[];
608
+ readonly isComplete: boolean;
609
+ } | {
610
+ readonly kind: 'scored';
611
+ readonly members: readonly {
612
+ readonly member: string;
613
+ readonly score: string;
614
+ }[];
615
+ readonly isComplete: boolean;
616
+ }
617
+ /** A type this surface does not render — streams, module types. */
618
+ | {
619
+ readonly kind: 'unsupported';
620
+ readonly type: RedisKeyType;
621
+ };
622
+ /**
623
+ * The outcome of asking for a key's value.
624
+ *
625
+ * A discriminated union rather than a nullable value, because `withheld` and
626
+ * `missing` are different answers and rendering them identically is the defect
627
+ * this whole surface exists to avoid: one means "I may not tell you", the other
628
+ * means "there is nothing here".
629
+ */
630
+ type RevealResult = {
631
+ readonly status: 'revealed';
632
+ readonly key: string;
633
+ readonly type: RedisKeyType;
634
+ readonly value: RevealedValue;
635
+ }
636
+ /**
637
+ * The scope withholds values. Listing, types, TTLs and sizes remain available
638
+ * for this key — only the value is refused, and `origin` says why in the
639
+ * application's own words.
640
+ */
641
+ | {
642
+ readonly status: 'withheld';
643
+ readonly key: string;
644
+ readonly scopeId: string;
645
+ readonly origin: string;
646
+ }
647
+ /** The key does not exist. */
648
+ | {
649
+ readonly status: 'missing';
650
+ readonly key: string;
651
+ };
652
+
653
+ /** Options for one keyspace listing. */
654
+ interface ListKeysOptions {
655
+ /** Cursor from a previous page; omit to start at the beginning of the scope. */
656
+ cursor?: string;
657
+ /**
658
+ * Measure each key's serialized size.
659
+ *
660
+ * Opt-in because it is not free: it adds a `MEMORY USAGE` per key, and Redis
661
+ * is single-threaded, so the cost lands on every other client waiting behind
662
+ * the batch — on a server an operator is inspecting precisely because it is
663
+ * unwell.
664
+ */
665
+ includeSize?: boolean;
666
+ }
667
+ /** Reads the keyspace an administration surface is allowed to see. */
668
+ declare class CacheAdminService {
669
+ private readonly cache;
670
+ private readonly options;
671
+ /**
672
+ * @param cache - Supplies the client. `CacheService.getClient()` refuses
673
+ * cluster mode, so this surface inherits that rule rather than restating it.
674
+ * @param options - The resolved administration options.
675
+ */
676
+ constructor(cache: IRedisReaderSource, options: ResolvedAdminOptions);
677
+ /**
678
+ * Returns the declared scopes.
679
+ *
680
+ * **Never touches the connection**, and that is a property rather than an
681
+ * accident of the current implementation. A surface reads this to decide what
682
+ * it may offer at all, so it must answer during an outage — an operator whose
683
+ * cache is down is exactly the person who needs to be told what the inspector
684
+ * covers. The tempting change is a liveness check to make the list "more
685
+ * accurate"; it would turn a declaration into a measurement, and a scope that
686
+ * vanished during an outage reads as a scope that was removed.
687
+ *
688
+ * @returns The frozen allowlist, in declaration order.
689
+ */
690
+ listScopes(): readonly CacheScope[];
691
+ /**
692
+ * Lists one page of the keys in a scope.
693
+ *
694
+ * The page may carry slightly MORE than `scanLimit` entries: the limit stops
695
+ * the scan loop, and the batch that crosses it is kept whole rather than
696
+ * trimmed, because the cursor has already moved past those keys and trimming
697
+ * would drop them from every subsequent page too.
698
+ *
699
+ * @param scopeId - The scope a caller named.
700
+ * @param listOptions - Cursor and whether to measure sizes.
701
+ * @returns The page, with the sample it read and whether it reached the end.
702
+ * @throws {CacheException} `SCOPE_NOT_FOUND` for an undeclared id, or
703
+ * `UNSUPPORTED_IN_CLUSTER` in cluster mode.
704
+ */
705
+ listKeys(scopeId: string, listOptions?: ListKeysOptions): Promise<KeyspacePage>;
706
+ /**
707
+ * Describes one key without reading its value.
708
+ *
709
+ * Works on an unreadable scope: only the value is withheld, never the
710
+ * description. {@link KeyDetail.isReadable} lets a surface disable the reveal
711
+ * rather than discovering the refusal by attempting it.
712
+ *
713
+ * @param scopeId - The scope a caller named.
714
+ * @param key - The key, which must belong to that scope.
715
+ * @returns The key's type, remaining life, and whether its value may be read.
716
+ * @throws {CacheException} `SCOPE_NOT_FOUND` or `KEY_NOT_IN_SCOPE`.
717
+ */
718
+ describeKey(scopeId: string, key: string): Promise<KeyDetail>;
719
+ /**
720
+ * Reads a key's value, or explains why it will not.
721
+ *
722
+ * @param scopeId - The scope a caller named.
723
+ * @param key - The key, which must belong to that scope.
724
+ * @returns The value, a refusal carrying the scope's `origin`, or `missing`.
725
+ * @throws {CacheException} `SCOPE_NOT_FOUND` or `KEY_NOT_IN_SCOPE`.
726
+ */
727
+ revealValue(scopeId: string, key: string): Promise<RevealResult>;
728
+ /**
729
+ * Resolves a scope and confirms the key belongs to it.
730
+ *
731
+ * The membership check is the reason scope patterns are restricted to a
732
+ * literal prefix: it makes this decidable exactly, rather than by a matcher
733
+ * that could be more permissive than the server's.
734
+ *
735
+ * @param scopeId - The scope a caller named.
736
+ * @param key - The key a caller named.
737
+ * @returns The resolved scope.
738
+ * @throws {CacheException} `SCOPE_NOT_FOUND` or `KEY_NOT_IN_SCOPE`.
739
+ */
740
+ private resolve;
741
+ /**
742
+ * Describes every key on a page, in batches bounded by command count.
743
+ *
744
+ * The bound is in commands rather than keys because that is the resource
745
+ * actually spent: describing a key costs two commands, or three when sized,
746
+ * and Redis works through a flush single-threaded while every other client
747
+ * waits. At least one key goes per batch, so a limit below one key's cost
748
+ * still makes progress instead of dividing to nothing.
749
+ *
750
+ * @param client - The reader.
751
+ * @param keys - The page's keys.
752
+ * @param includeSize - Whether to add `MEMORY USAGE`.
753
+ * @returns One entry per key, in page order.
754
+ */
755
+ private describeAll;
756
+ /**
757
+ * Reads a value according to its Redis type, truncating to the configured caps.
758
+ *
759
+ * @param client - The reader.
760
+ * @param key - The key to read.
761
+ * @param type - The key's type.
762
+ * @returns The value, or `null` when the key vanished between reads.
763
+ */
764
+ private readValue;
765
+ }
766
+
767
+ /**
768
+ * Splits a validated pattern into the literal prefix a key must carry and
769
+ * whether that prefix may be extended.
770
+ *
771
+ * @param pattern - A pattern that already satisfies {@link SCOPE_PATTERN_SHAPE}.
772
+ * @returns The literal prefix, and whether a trailing `*` allows a suffix.
773
+ */
774
+ declare function readScopePattern(pattern: string): {
775
+ readonly prefix: string;
776
+ readonly isPrefixMatch: boolean;
777
+ };
778
+ /**
779
+ * Reports whether a key falls inside a scope's declared region.
780
+ *
781
+ * Exact by construction rather than by matching — see {@link SCOPE_PATTERN_SHAPE}
782
+ * for why the pattern shape is restricted to make this possible.
783
+ *
784
+ * @param pattern - The scope's validated match pattern.
785
+ * @param key - The key a caller named.
786
+ * @returns `true` when the key belongs to the scope.
787
+ */
788
+ declare function isKeyInScope(pattern: string, key: string): boolean;
789
+ /**
790
+ * Validates an administration scope allowlist and returns a frozen copy.
791
+ *
792
+ * Every rule here closes a way the allowlist could authorise more than it
793
+ * appears to: an unanchored pattern reaches the whole instance, a duplicate id
794
+ * makes one declaration silently shadow another, and an empty field produces a
795
+ * scope a surface can name but not explain. Freezing the result keeps the
796
+ * decision made at wiring from being edited by anything that later holds it.
797
+ *
798
+ * @param scopes - The scopes the application declares.
799
+ * @returns A frozen list of frozen scopes, in declaration order.
800
+ * @throws {CacheException} `INVALID_SCOPE` when the list is empty, a required
801
+ * field is empty, an id repeats, or a pattern has no literal prefix.
802
+ */
803
+ declare function validateScopes(scopes: readonly CacheScope[]): readonly CacheScope[];
804
+ /**
805
+ * Finds a declared scope by the identifier a caller named.
806
+ *
807
+ * Exact string equality against the frozen list, never interpolation — the
808
+ * caller's input selects a declaration, it never contributes to one.
809
+ *
810
+ * @param scopes - The validated allowlist.
811
+ * @param id - The identifier from the request.
812
+ * @returns The scope.
813
+ * @throws {CacheException} `SCOPE_NOT_FOUND` when no declaration has that id.
814
+ */
815
+ declare function findScope(scopes: readonly CacheScope[], id: string): CacheScope;
816
+
817
+ /**
818
+ * Reads Redis `INFO` text into a typed reading.
819
+ *
820
+ * Layer: admin. `INFO` is section headers (`# Memory`), `field:value` lines and
821
+ * blank lines separated by CRLF. Every value arrives as text — a count, a ratio
822
+ * and a Unix timestamp are indistinguishable until something decides which is
823
+ * which, and that decision is this module's whole content.
824
+ *
825
+ * Parsed here rather than by each consumer: the format is a wire detail, and
826
+ * interpreting it once on the side that can be tested against a real server
827
+ * beats every surface holding its own copy of the rules.
828
+ *
829
+ * The field names are asserted against a real Redis in the E2E suite rather than
830
+ * taken from documentation. A Redis test double returns whatever its fixture
831
+ * decided, so every name here would pass a unit suite while being wrong — a
832
+ * double cannot falsify an assumption about the thing it stands in for.
833
+ */
834
+
835
+ /**
836
+ * Splits `INFO` text into its fields.
837
+ *
838
+ * Section headers are dropped rather than used to namespace the fields: `INFO`
839
+ * field names are already unique across sections, and keying by section would
840
+ * make this depend on header spellings that vary between versions while the
841
+ * field names do not.
842
+ *
843
+ * @param text - The raw `INFO` output.
844
+ * @returns Field name to raw text value.
845
+ */
846
+ declare function parseInfoFields(text: string): ReadonlyMap<string, string>;
847
+ /**
848
+ * Turns `INFO` text into a typed reading.
849
+ *
850
+ * @param infoText - The raw `INFO` output.
851
+ * @param readAt - When the reading was taken, by the reading host's clock.
852
+ * @returns The parsed statistics.
853
+ */
854
+ declare function readRedisStats(infoText: string, readAt: Date): RedisStats;
855
+
856
+ /**
857
+ * Narrowing of raw Redis replies into the admin surface's typed readings.
858
+ *
859
+ * Layer: admin. ioredis hands back `unknown` for the commands this surface
860
+ * issues through a pipeline, and every reply that reaches a caller passes
861
+ * through here first. Each helper reports absence rather than substituting a
862
+ * value that would read as a measurement.
863
+ */
864
+
865
+ /**
866
+ * Narrows a `TYPE` reply to the closed union.
867
+ *
868
+ * @param reply - Whatever the server answered.
869
+ * @returns The recognised type, or `other`.
870
+ */
871
+ declare function readKeyType(reply: unknown): RedisKeyType;
872
+ /**
873
+ * Narrows a `TTL` reply to its three distinct meanings.
874
+ *
875
+ * A reply that is not a number, or is a negative value Redis does not document,
876
+ * is reported as `missing` rather than as a remaining life — a negative
877
+ * countdown on a screen is a reading no server produced.
878
+ *
879
+ * @param reply - Whatever the server answered.
880
+ * @returns Whether the key expires, is persistent, or is gone.
881
+ */
882
+ declare function readKeyTtl(reply: unknown): KeyTtl;
883
+ /**
884
+ * Narrows a `MEMORY USAGE` reply.
885
+ *
886
+ * Reports `null` rather than zero when the server declines to answer: a key
887
+ * whose size is genuinely unknown and one that occupies nothing render the same
888
+ * under a zero, and only one of them is a measurement.
889
+ *
890
+ * @param reply - Whatever the server answered.
891
+ * @returns The size in bytes, or `null` when no size was reported.
892
+ */
893
+ declare function readSizeBytes(reply: unknown): number | null;
894
+
895
+ /**
896
+ * NestJS injection tokens for the `@bymax-one/nest-cache/admin` subpath.
897
+ *
898
+ * Layer: admin. `Symbol`-based for the same reason as the server tokens: two
899
+ * consumers cannot collide by accident, and a string typo cannot resolve a
900
+ * foreign provider.
901
+ */
902
+ /** Resolved administration options (scopes, thresholds, limits). */
903
+ declare const BYMAX_CACHE_ADMIN_OPTIONS: unique symbol;
904
+
905
+ /**
906
+ * Defaults for the administration surface.
907
+ *
908
+ * Layer: admin. Each value is a threshold a deployment may override; they live
909
+ * together so a consumer reading one can see what else is tunable.
910
+ */
911
+ /**
912
+ * Round trip above which the cache is answering but not well.
913
+ *
914
+ * A round number rather than a measured percentile, and deliberately so: this is
915
+ * the threshold at which a surface changes what it reports, not a service-level
916
+ * objective. A cache taking this long has stopped being the thing that makes
917
+ * requests fast.
918
+ */
919
+ declare const DEFAULT_DEGRADED_ABOVE_MS = 250;
920
+ /**
921
+ * How long a health probe waits before reporting the cache down.
922
+ *
923
+ * Without this, a `PING` against a wedged connection never settles and a health
924
+ * route hangs instead of answering — the failure mode a health route exists to
925
+ * report becomes the failure mode it exhibits.
926
+ */
927
+ declare const DEFAULT_PROBE_TIMEOUT_MS = 2000;
928
+ /**
929
+ * Most keys a single listing reads before reporting the page incomplete.
930
+ *
931
+ * A cap rather than a full scan: an administration surface asks about keyspaces
932
+ * whose size is exactly what it does not know yet.
933
+ */
934
+ declare const DEFAULT_SCAN_LIMIT = 500;
935
+ /**
936
+ * Most commands one pipeline flush sends.
937
+ *
938
+ * Bounds the BATCH, in commands, rather than the key count — that is the
939
+ * resource actually spent. Redis is single-threaded, so a pipeline converts a
940
+ * network cost into a server-blocking one: describing N keys is two or three
941
+ * commands each, and sending them as one flush blocks every other client for
942
+ * the whole burst — on a server someone is inspecting precisely because it is
943
+ * unwell. Pipelining is the aggravating factor here, not the mitigation.
944
+ *
945
+ * Every batch chunks to this size regardless of how many keys the page allows,
946
+ * so a permitted maximum arrives as several small blocks rather than one large
947
+ * one.
948
+ */
949
+ declare const DEFAULT_COMMAND_BATCH_LIMIT = 100;
950
+ /**
951
+ * Most members or hash fields one value reveal returns.
952
+ *
953
+ * A reveal is a debugging read, not an export. Returning a million-member set
954
+ * would block the server assembling it and hand a surface a payload it cannot
955
+ * draw; the truncation is reported so nobody mistakes the page for the whole.
956
+ */
957
+ declare const DEFAULT_REVEAL_LIMIT = 200;
958
+ /**
959
+ * Most characters one revealed string value carries.
960
+ *
961
+ * Same reasoning as {@link DEFAULT_REVEAL_LIMIT}: a cached HTML document or a
962
+ * serialized blob is measured in megabytes, and a surface asking "what is in
963
+ * this key" does not need all of it to answer the question.
964
+ */
965
+ declare const DEFAULT_REVEAL_STRING_LIMIT = 4096;
966
+
967
+ export { BYMAX_CACHE_ADMIN_OPTIONS, type BgsaveStatus, BymaxCacheAdminModule, type BymaxCacheAdminModuleAsyncOptions, type BymaxCacheAdminModuleOptions, CacheAdminService, type CacheConfig, type CacheHealth, type CacheMode, type CacheProbe, type CacheProbeDegraded, type CacheProbeDown, type CacheProbeFailure, type CacheProbeUp, type CacheScope, CacheStatusService, DEFAULT_COMMAND_BATCH_LIMIT, DEFAULT_DEGRADED_ABOVE_MS, DEFAULT_PROBE_TIMEOUT_MS, DEFAULT_REVEAL_LIMIT, DEFAULT_REVEAL_STRING_LIMIT, DEFAULT_SCAN_LIMIT, type ICacheProbe, type IRedisPipeline, type IRedisReader, type IRedisReaderSource, type KeyDetail, type KeyEntry, type KeyTtl, type KeyspacePage, type ListKeysOptions, type MaxMemory, type PipelineReply, type RedisKeyType, type RedisStats, type ReplicationRole, type ResolvedAdminOptions, type RevealResult, type RevealedValue, findScope, isKeyInScope, parseInfoFields, readKeyTtl, readKeyType, readRedisStats, readScopePattern, readSizeBytes, resolveAdminOptions, validateScopes };