@bymax-one/nest-cache 1.0.6 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,723 @@
1
+ import { Injectable, Inject, Module } from '@nestjs/common';
2
+ import { CacheService, BYMAX_CACHE_OPTIONS, BYMAX_CACHE_SERIALIZER, CacheException, CACHE_ERROR_CODES, DEFAULT_REDIS_PORT } from '@bymax-one/nest-cache';
3
+
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __decorateClass = (decorators, target, key, kind) => {
6
+ var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
7
+ for (var i = decorators.length - 1, decorator; i >= 0; i--)
8
+ if (decorator = decorators[i])
9
+ result = (decorator(result)) || result;
10
+ return result;
11
+ };
12
+ var __decorateParam = (index, decorator) => (target, key) => decorator(target, key, index);
13
+
14
+ // src/admin/bymax-cache-admin.constants.ts
15
+ var BYMAX_CACHE_ADMIN_OPTIONS = /* @__PURE__ */ Symbol("BYMAX_CACHE_ADMIN_OPTIONS");
16
+ var SCOPE_PATTERN_SHAPE = /^[^*?[\\]+\*?$/;
17
+ var REQUIRED_FIELDS = ["id", "label", "pattern", "origin"];
18
+ function readScopePattern(pattern) {
19
+ const isPrefixMatch = pattern.endsWith("*");
20
+ return { prefix: isPrefixMatch ? pattern.slice(0, -1) : pattern, isPrefixMatch };
21
+ }
22
+ function isKeyInScope(pattern, key) {
23
+ const { prefix, isPrefixMatch } = readScopePattern(pattern);
24
+ return isPrefixMatch ? key.startsWith(prefix) : key === pattern;
25
+ }
26
+ function readField(scope, field) {
27
+ switch (field) {
28
+ case "id":
29
+ return scope.id;
30
+ case "label":
31
+ return scope.label;
32
+ case "pattern":
33
+ return scope.pattern;
34
+ default:
35
+ return scope.origin;
36
+ }
37
+ }
38
+ function validateScopes(scopes) {
39
+ if (scopes.length === 0) {
40
+ throw new CacheException(CACHE_ERROR_CODES.INVALID_SCOPE, { reason: "no scopes declared" });
41
+ }
42
+ const seen = /* @__PURE__ */ new Set();
43
+ const validated = [];
44
+ for (const scope of scopes) {
45
+ for (const field of REQUIRED_FIELDS) {
46
+ if (readField(scope, field) === "") {
47
+ throw new CacheException(CACHE_ERROR_CODES.INVALID_SCOPE, {
48
+ reason: "empty scope field",
49
+ field,
50
+ scopeId: scope.id
51
+ });
52
+ }
53
+ }
54
+ if (seen.has(scope.id)) {
55
+ throw new CacheException(CACHE_ERROR_CODES.INVALID_SCOPE, {
56
+ reason: "duplicate scope id",
57
+ scopeId: scope.id
58
+ });
59
+ }
60
+ if (!SCOPE_PATTERN_SHAPE.test(scope.pattern)) {
61
+ throw new CacheException(CACHE_ERROR_CODES.INVALID_SCOPE, {
62
+ reason: "pattern must be a literal prefix optionally followed by *",
63
+ scopeId: scope.id,
64
+ pattern: scope.pattern
65
+ });
66
+ }
67
+ seen.add(scope.id);
68
+ validated.push(
69
+ Object.freeze({
70
+ id: scope.id,
71
+ label: scope.label,
72
+ pattern: scope.pattern,
73
+ isReadable: scope.isReadable,
74
+ origin: scope.origin
75
+ })
76
+ );
77
+ }
78
+ return Object.freeze(validated);
79
+ }
80
+ function findScope(scopes, id) {
81
+ const found = scopes.find((scope) => scope.id === id);
82
+ if (!found) {
83
+ throw new CacheException(CACHE_ERROR_CODES.SCOPE_NOT_FOUND, { scopeId: id });
84
+ }
85
+ return found;
86
+ }
87
+
88
+ // src/admin/constants/admin-defaults.ts
89
+ var DEFAULT_DEGRADED_ABOVE_MS = 250;
90
+ var DEFAULT_PROBE_TIMEOUT_MS = 2e3;
91
+ var DEFAULT_SCAN_LIMIT = 500;
92
+ var DEFAULT_COMMAND_BATCH_LIMIT = 100;
93
+ var DEFAULT_REVEAL_LIMIT = 200;
94
+ var DEFAULT_REVEAL_STRING_LIMIT = 4096;
95
+
96
+ // src/admin/config/resolved-admin-options.ts
97
+ function requirePositiveInteger(option, value) {
98
+ if (!Number.isInteger(value) || value <= 0) {
99
+ throw new CacheException(CACHE_ERROR_CODES.INVALID_SCOPE, {
100
+ reason: "threshold must be positive",
101
+ option,
102
+ value
103
+ });
104
+ }
105
+ }
106
+ function resolveAdminOptions(options) {
107
+ const resolved = {
108
+ scopes: validateScopes(options.scopes),
109
+ degradedAboveMs: options.degradedAboveMs ?? DEFAULT_DEGRADED_ABOVE_MS,
110
+ probeTimeoutMs: options.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS,
111
+ scanLimit: options.scanLimit ?? DEFAULT_SCAN_LIMIT,
112
+ commandBatchLimit: options.commandBatchLimit ?? DEFAULT_COMMAND_BATCH_LIMIT,
113
+ revealLimit: options.revealLimit ?? DEFAULT_REVEAL_LIMIT,
114
+ revealStringLimit: options.revealStringLimit ?? DEFAULT_REVEAL_STRING_LIMIT,
115
+ isGlobal: options.isGlobal ?? false
116
+ };
117
+ requirePositiveInteger("degradedAboveMs", resolved.degradedAboveMs);
118
+ requirePositiveInteger("probeTimeoutMs", resolved.probeTimeoutMs);
119
+ requirePositiveInteger("scanLimit", resolved.scanLimit);
120
+ requirePositiveInteger("commandBatchLimit", resolved.commandBatchLimit);
121
+ requirePositiveInteger("revealLimit", resolved.revealLimit);
122
+ requirePositiveInteger("revealStringLimit", resolved.revealStringLimit);
123
+ return Object.freeze(resolved);
124
+ }
125
+
126
+ // src/admin/utils/read-replies.ts
127
+ var KNOWN_TYPES = /* @__PURE__ */ new Set([
128
+ "string",
129
+ "hash",
130
+ "set",
131
+ "list",
132
+ "zset",
133
+ "stream"
134
+ ]);
135
+ function isKnownType(reply) {
136
+ return typeof reply === "string" && KNOWN_TYPES.has(reply);
137
+ }
138
+ var TTL_PERSISTENT = -1;
139
+ function readKeyType(reply) {
140
+ return isKnownType(reply) ? reply : "other";
141
+ }
142
+ function readKeyTtl(reply) {
143
+ if (typeof reply !== "number" || !Number.isFinite(reply)) {
144
+ return { kind: "missing" };
145
+ }
146
+ if (reply === TTL_PERSISTENT) {
147
+ return { kind: "persistent" };
148
+ }
149
+ if (reply < 0) {
150
+ return { kind: "missing" };
151
+ }
152
+ return { kind: "expiring", seconds: reply };
153
+ }
154
+ function readSizeBytes(reply) {
155
+ return typeof reply === "number" && Number.isFinite(reply) ? reply : null;
156
+ }
157
+
158
+ // src/admin/services/cache-admin.service.ts
159
+ var SCAN_END_CURSOR = "0";
160
+ var DESCRIBE_COMMANDS_PER_KEY = 2;
161
+ var SIZING_COMMANDS_PER_KEY = 1;
162
+ var TYPE_NONE = "none";
163
+ function replyAt(replies, index) {
164
+ const slot = replies?.at(index);
165
+ if (!slot || slot[0] !== null) {
166
+ return null;
167
+ }
168
+ return slot[1];
169
+ }
170
+ function readScoredMembers(reply, limit) {
171
+ if (!Array.isArray(reply)) {
172
+ return { members: [], isComplete: true };
173
+ }
174
+ const members = [];
175
+ let pairCount = 0;
176
+ let pendingMember = null;
177
+ for (const element of reply) {
178
+ if (Array.isArray(element)) {
179
+ const [member, score] = element;
180
+ pairCount += 1;
181
+ if (members.length < limit) {
182
+ members.push({ member: String(member), score: String(score) });
183
+ }
184
+ continue;
185
+ }
186
+ if (pendingMember === null) {
187
+ pendingMember = String(element);
188
+ continue;
189
+ }
190
+ pairCount += 1;
191
+ if (members.length < limit) {
192
+ members.push({ member: pendingMember, score: String(element) });
193
+ }
194
+ pendingMember = null;
195
+ }
196
+ return { members, isComplete: members.length === pairCount };
197
+ }
198
+ var CacheAdminService = class {
199
+ /**
200
+ * @param cache - Supplies the client. `CacheService.getClient()` refuses
201
+ * cluster mode, so this surface inherits that rule rather than restating it.
202
+ * @param options - The resolved administration options.
203
+ */
204
+ constructor(cache, options) {
205
+ this.cache = cache;
206
+ this.options = options;
207
+ }
208
+ /**
209
+ * Returns the declared scopes.
210
+ *
211
+ * **Never touches the connection**, and that is a property rather than an
212
+ * accident of the current implementation. A surface reads this to decide what
213
+ * it may offer at all, so it must answer during an outage — an operator whose
214
+ * cache is down is exactly the person who needs to be told what the inspector
215
+ * covers. The tempting change is a liveness check to make the list "more
216
+ * accurate"; it would turn a declaration into a measurement, and a scope that
217
+ * vanished during an outage reads as a scope that was removed.
218
+ *
219
+ * @returns The frozen allowlist, in declaration order.
220
+ */
221
+ listScopes() {
222
+ return this.options.scopes;
223
+ }
224
+ /**
225
+ * Lists one page of the keys in a scope.
226
+ *
227
+ * The page may carry slightly MORE than `scanLimit` entries: the limit stops
228
+ * the scan loop, and the batch that crosses it is kept whole rather than
229
+ * trimmed, because the cursor has already moved past those keys and trimming
230
+ * would drop them from every subsequent page too.
231
+ *
232
+ * @param scopeId - The scope a caller named.
233
+ * @param listOptions - Cursor and whether to measure sizes.
234
+ * @returns The page, with the sample it read and whether it reached the end.
235
+ * @throws {CacheException} `SCOPE_NOT_FOUND` for an undeclared id, or
236
+ * `UNSUPPORTED_IN_CLUSTER` in cluster mode.
237
+ */
238
+ async listKeys(scopeId, listOptions = {}) {
239
+ const scope = findScope(this.options.scopes, scopeId);
240
+ const client = this.cache.getClient();
241
+ const limit = this.options.scanLimit;
242
+ const keys = [];
243
+ let cursor = listOptions.cursor ?? SCAN_END_CURSOR;
244
+ do {
245
+ const [nextCursor, batch] = await client.scan(cursor, "MATCH", scope.pattern, "COUNT", limit);
246
+ cursor = nextCursor;
247
+ keys.push(...batch);
248
+ } while (cursor !== SCAN_END_CURSOR && keys.length < limit);
249
+ const isComplete = cursor === SCAN_END_CURSOR;
250
+ const includeSize = listOptions.includeSize === true;
251
+ const entries = await this.describeAll(client, keys, includeSize);
252
+ return {
253
+ scopeId: scope.id,
254
+ entries,
255
+ sampledCount: entries.length,
256
+ sampledBytes: includeSize ? sumMeasured(entries) : null,
257
+ isComplete,
258
+ scanLimit: limit,
259
+ cursor: isComplete ? null : cursor
260
+ };
261
+ }
262
+ /**
263
+ * Describes one key without reading its value.
264
+ *
265
+ * Works on an unreadable scope: only the value is withheld, never the
266
+ * description. {@link KeyDetail.isReadable} lets a surface disable the reveal
267
+ * rather than discovering the refusal by attempting it.
268
+ *
269
+ * @param scopeId - The scope a caller named.
270
+ * @param key - The key, which must belong to that scope.
271
+ * @returns The key's type, remaining life, and whether its value may be read.
272
+ * @throws {CacheException} `SCOPE_NOT_FOUND` or `KEY_NOT_IN_SCOPE`.
273
+ */
274
+ async describeKey(scopeId, key) {
275
+ const scope = this.resolve(scopeId, key);
276
+ const client = this.cache.getClient();
277
+ const [type, ttl] = await Promise.all([client.type(key), client.ttl(key)]);
278
+ return {
279
+ scopeId: scope.id,
280
+ key,
281
+ type: readKeyType(type),
282
+ ttl: readKeyTtl(ttl),
283
+ sizeBytes: null,
284
+ isReadable: scope.isReadable
285
+ };
286
+ }
287
+ /**
288
+ * Reads a key's value, or explains why it will not.
289
+ *
290
+ * @param scopeId - The scope a caller named.
291
+ * @param key - The key, which must belong to that scope.
292
+ * @returns The value, a refusal carrying the scope's `origin`, or `missing`.
293
+ * @throws {CacheException} `SCOPE_NOT_FOUND` or `KEY_NOT_IN_SCOPE`.
294
+ */
295
+ async revealValue(scopeId, key) {
296
+ const scope = this.resolve(scopeId, key);
297
+ if (!scope.isReadable) {
298
+ return { status: "withheld", key, scopeId: scope.id, origin: scope.origin };
299
+ }
300
+ const client = this.cache.getClient();
301
+ const rawType = await client.type(key);
302
+ if (rawType === TYPE_NONE) {
303
+ return { status: "missing", key };
304
+ }
305
+ const type = readKeyType(rawType);
306
+ const value = await this.readValue(client, key, type);
307
+ return value === null ? { status: "missing", key } : { status: "revealed", key, type, value };
308
+ }
309
+ /**
310
+ * Resolves a scope and confirms the key belongs to it.
311
+ *
312
+ * The membership check is the reason scope patterns are restricted to a
313
+ * literal prefix: it makes this decidable exactly, rather than by a matcher
314
+ * that could be more permissive than the server's.
315
+ *
316
+ * @param scopeId - The scope a caller named.
317
+ * @param key - The key a caller named.
318
+ * @returns The resolved scope.
319
+ * @throws {CacheException} `SCOPE_NOT_FOUND` or `KEY_NOT_IN_SCOPE`.
320
+ */
321
+ resolve(scopeId, key) {
322
+ const scope = findScope(this.options.scopes, scopeId);
323
+ if (!isKeyInScope(scope.pattern, key)) {
324
+ throw new CacheException(CACHE_ERROR_CODES.KEY_NOT_IN_SCOPE, { scopeId: scope.id, key });
325
+ }
326
+ return scope;
327
+ }
328
+ /**
329
+ * Describes every key on a page, in batches bounded by command count.
330
+ *
331
+ * The bound is in commands rather than keys because that is the resource
332
+ * actually spent: describing a key costs two commands, or three when sized,
333
+ * and Redis works through a flush single-threaded while every other client
334
+ * waits. At least one key goes per batch, so a limit below one key's cost
335
+ * still makes progress instead of dividing to nothing.
336
+ *
337
+ * @param client - The reader.
338
+ * @param keys - The page's keys.
339
+ * @param includeSize - Whether to add `MEMORY USAGE`.
340
+ * @returns One entry per key, in page order.
341
+ */
342
+ async describeAll(client, keys, includeSize) {
343
+ const perKey = DESCRIBE_COMMANDS_PER_KEY + (includeSize ? SIZING_COMMANDS_PER_KEY : 0);
344
+ const chunkSize = Math.max(1, Math.floor(this.options.commandBatchLimit / perKey));
345
+ const entries = [];
346
+ for (let start = 0; start < keys.length; start += chunkSize) {
347
+ const chunk = keys.slice(start, start + chunkSize);
348
+ const pipeline = client.pipeline();
349
+ for (const key of chunk) {
350
+ pipeline.type(key).ttl(key);
351
+ if (includeSize) {
352
+ pipeline.memory("USAGE", key);
353
+ }
354
+ }
355
+ const replies = await pipeline.exec();
356
+ chunk.forEach((key, index) => {
357
+ const base = index * perKey;
358
+ entries.push({
359
+ key,
360
+ type: readKeyType(replyAt(replies, base)),
361
+ ttl: readKeyTtl(replyAt(replies, base + 1)),
362
+ sizeBytes: includeSize ? readSizeBytes(replyAt(replies, base + 2)) : null
363
+ });
364
+ });
365
+ }
366
+ return entries;
367
+ }
368
+ /**
369
+ * Reads a value according to its Redis type, truncating to the configured caps.
370
+ *
371
+ * @param client - The reader.
372
+ * @param key - The key to read.
373
+ * @param type - The key's type.
374
+ * @returns The value, or `null` when the key vanished between reads.
375
+ */
376
+ async readValue(client, key, type) {
377
+ const limit = this.options.revealLimit;
378
+ switch (type) {
379
+ case "string": {
380
+ const value = await client.get(key);
381
+ if (value === null) {
382
+ return null;
383
+ }
384
+ const capped = value.slice(0, this.options.revealStringLimit);
385
+ return { kind: "string", value: capped, isComplete: capped.length === value.length };
386
+ }
387
+ case "hash": {
388
+ const all = Object.entries(await client.hgetall(key));
389
+ const kept = all.slice(0, limit);
390
+ return {
391
+ kind: "hash",
392
+ fields: kept.map(([name, value]) => ({ name, value })),
393
+ isComplete: kept.length === all.length
394
+ };
395
+ }
396
+ case "set":
397
+ case "list": {
398
+ const all = type === "set" ? await client.smembers(key) : await client.lrange(key, 0, limit);
399
+ const kept = all.slice(0, limit);
400
+ return { kind: "members", members: kept, isComplete: kept.length === all.length };
401
+ }
402
+ case "zset": {
403
+ const reply = await client.call("ZRANGE", key, 0, limit, "WITHSCORES");
404
+ const { members, isComplete } = readScoredMembers(reply, limit);
405
+ return { kind: "scored", members, isComplete };
406
+ }
407
+ default:
408
+ return { kind: "unsupported", type };
409
+ }
410
+ }
411
+ };
412
+ CacheAdminService = __decorateClass([
413
+ Injectable(),
414
+ __decorateParam(0, Inject(CacheService)),
415
+ __decorateParam(1, Inject(BYMAX_CACHE_ADMIN_OPTIONS))
416
+ ], CacheAdminService);
417
+ function sumMeasured(entries) {
418
+ return entries.reduce((total, entry) => total + (entry.sizeBytes ?? 0), 0);
419
+ }
420
+
421
+ // src/admin/utils/parse-info.ts
422
+ var FIELD_SEPARATOR = ":";
423
+ var SECTION_PREFIX = "#";
424
+ var UNBOUNDED_MAXMEMORY = 0;
425
+ var MS_PER_SECOND = 1e3;
426
+ var BGSAVE_OK = "ok";
427
+ var BGSAVE_ERROR = "err";
428
+ var INFO_TRUE = "1";
429
+ var WIRE_REPLICA_ROLE = "slave";
430
+ var NUMERIC_VALUE = /^[+-]?\d+(\.\d+)?([eE][+-]?\d+)?$/;
431
+ function parseInfoFields(text2) {
432
+ const fields = /* @__PURE__ */ new Map();
433
+ for (const rawLine of text2.split("\n")) {
434
+ const line = rawLine.trim();
435
+ if (line.startsWith(SECTION_PREFIX)) {
436
+ continue;
437
+ }
438
+ const cut = line.indexOf(FIELD_SEPARATOR);
439
+ if (cut <= 0) {
440
+ continue;
441
+ }
442
+ fields.set(line.slice(0, cut), line.slice(cut + 1));
443
+ }
444
+ return fields;
445
+ }
446
+ function text(fields, name) {
447
+ return fields.get(name) ?? null;
448
+ }
449
+ function num(fields, name) {
450
+ const raw = fields.get(name)?.trim();
451
+ if (raw === void 0 || !NUMERIC_VALUE.test(raw)) {
452
+ return null;
453
+ }
454
+ const value = Number(raw);
455
+ return Number.isFinite(value) ? value : null;
456
+ }
457
+ function maxMemory(fields) {
458
+ const value = num(fields, "maxmemory");
459
+ if (value === null) {
460
+ return { kind: "unreported" };
461
+ }
462
+ return value === UNBOUNDED_MAXMEMORY ? { kind: "unbounded" } : { kind: "limited", bytes: value };
463
+ }
464
+ function lastSaveAt(fields) {
465
+ const seconds = num(fields, "rdb_last_save_time");
466
+ return seconds === null ? null : new Date(seconds * MS_PER_SECOND).toISOString();
467
+ }
468
+ function bgsaveStatus(fields) {
469
+ const raw = text(fields, "rdb_last_bgsave_status");
470
+ if (raw === BGSAVE_OK) {
471
+ return BGSAVE_OK;
472
+ }
473
+ return raw === BGSAVE_ERROR ? BGSAVE_ERROR : null;
474
+ }
475
+ function aofEnabled(fields) {
476
+ const raw = text(fields, "aof_enabled");
477
+ return raw === null ? null : raw === INFO_TRUE;
478
+ }
479
+ function role(fields) {
480
+ return text(fields, "role") === WIRE_REPLICA_ROLE ? "replica" : "master";
481
+ }
482
+ function readRedisStats(infoText, readAt) {
483
+ const fields = parseInfoFields(infoText);
484
+ return {
485
+ readAt: readAt.toISOString(),
486
+ server: {
487
+ redisVersion: text(fields, "redis_version"),
488
+ mode: text(fields, "redis_mode"),
489
+ uptimeSeconds: num(fields, "uptime_in_seconds")
490
+ },
491
+ clients: {
492
+ connected: num(fields, "connected_clients"),
493
+ blocked: num(fields, "blocked_clients")
494
+ },
495
+ memory: {
496
+ usedBytes: num(fields, "used_memory"),
497
+ peakBytes: num(fields, "used_memory_peak"),
498
+ max: maxMemory(fields),
499
+ fragmentationRatio: num(fields, "mem_fragmentation_ratio"),
500
+ evictionPolicy: text(fields, "maxmemory_policy")
501
+ },
502
+ stats: {
503
+ keyspaceHits: num(fields, "keyspace_hits"),
504
+ keyspaceMisses: num(fields, "keyspace_misses"),
505
+ expiredKeys: num(fields, "expired_keys"),
506
+ evictedKeys: num(fields, "evicted_keys"),
507
+ instantaneousOpsPerSec: num(fields, "instantaneous_ops_per_sec"),
508
+ totalCommandsProcessed: num(fields, "total_commands_processed"),
509
+ totalConnectionsReceived: num(fields, "total_connections_received"),
510
+ rejectedConnections: num(fields, "rejected_connections")
511
+ },
512
+ persistence: {
513
+ rdbLastSaveAt: lastSaveAt(fields),
514
+ rdbChangesSinceLastSave: num(fields, "rdb_changes_since_last_save"),
515
+ rdbLastBgsaveStatus: bgsaveStatus(fields),
516
+ aofEnabled: aofEnabled(fields)
517
+ },
518
+ replication: {
519
+ role: role(fields),
520
+ connectedReplicas: num(fields, "connected_slaves")
521
+ }
522
+ };
523
+ }
524
+
525
+ // src/admin/services/cache-status.service.ts
526
+ var PONG = "PONG";
527
+ var SCAN_UNSUPPORTED_MODE = "cluster";
528
+ var TLS_PROTOCOL = "rediss:";
529
+ var PROBE_TIMEOUT = /* @__PURE__ */ Symbol("probe-timeout");
530
+ async function raceDeadline(work, timeoutMs) {
531
+ let timer;
532
+ const deadline = new Promise((resolve) => {
533
+ timer = setTimeout(() => {
534
+ resolve(PROBE_TIMEOUT);
535
+ }, timeoutMs);
536
+ });
537
+ try {
538
+ return await Promise.race([work, deadline]);
539
+ } finally {
540
+ clearTimeout(timer);
541
+ }
542
+ }
543
+ function readErrorCode(error) {
544
+ if (typeof error !== "object" || error === null) {
545
+ return null;
546
+ }
547
+ const code = Reflect.get(error, "code");
548
+ return typeof code === "string" ? code : null;
549
+ }
550
+ function readEndpoint(options) {
551
+ const withheld = { host: null, port: null, isTls: false };
552
+ if (options.mode !== "standalone") {
553
+ return withheld;
554
+ }
555
+ const connection = options.connection;
556
+ if (!connection) {
557
+ return withheld;
558
+ }
559
+ if (connection.url !== void 0) {
560
+ try {
561
+ const parsed = new URL(connection.url);
562
+ return {
563
+ // No empty-host branch: a `redis:`/`rediss:` URL without a host is a
564
+ // parse error (measured — `new URL('redis://:6379')` throws), so it is
565
+ // caught below rather than reaching here as a blank hostname.
566
+ host: parsed.hostname,
567
+ // A URL that names no port still connects, on the default — reporting
568
+ // null here would hide the endpoint rather than describe it.
569
+ port: parsed.port === "" ? DEFAULT_REDIS_PORT : Number.parseInt(parsed.port, 10),
570
+ isTls: parsed.protocol === TLS_PROTOCOL
571
+ };
572
+ } catch {
573
+ return withheld;
574
+ }
575
+ }
576
+ return {
577
+ host: connection.host ?? null,
578
+ port: connection.port ?? null,
579
+ isTls: connection.tls !== void 0
580
+ };
581
+ }
582
+ var CacheStatusService = class {
583
+ /**
584
+ * @param cache - The cache facade this service probes through.
585
+ * @param options - The resolved cache module options.
586
+ * @param adminOptions - The resolved administration options.
587
+ * @param serializer - The wired serializer, named on the config payload.
588
+ */
589
+ constructor(cache, options, adminOptions, serializer) {
590
+ this.cache = cache;
591
+ this.options = options;
592
+ this.adminOptions = adminOptions;
593
+ this.serializer = serializer;
594
+ }
595
+ /**
596
+ * Pings Redis and reports the result in three states.
597
+ *
598
+ * @returns The probe outcome, plus the mode facts that hold whether or not it
599
+ * answered.
600
+ */
601
+ async health() {
602
+ return {
603
+ ...await this.probe(),
604
+ mode: this.options.mode,
605
+ isScanSupported: this.options.mode !== SCAN_UNSUPPORTED_MODE,
606
+ degradedAboveMs: this.adminOptions.degradedAboveMs
607
+ };
608
+ }
609
+ /**
610
+ * Reads Redis `INFO` into a typed reading.
611
+ *
612
+ * @returns The parsed statistics, stamped with the reading host's clock.
613
+ */
614
+ async stats() {
615
+ return readRedisStats(await this.cache.info(), /* @__PURE__ */ new Date());
616
+ }
617
+ /**
618
+ * Reports how this deployment is wired, with every credential withheld.
619
+ *
620
+ * @returns The resolved configuration, safe to serve to an operator surface.
621
+ */
622
+ config() {
623
+ return {
624
+ mode: this.options.mode,
625
+ namespace: this.options.namespace,
626
+ keySeparator: this.options.keySeparator,
627
+ shutdownTimeoutMs: this.options.shutdownTimeoutMs,
628
+ isFlushAllowedInProduction: this.options.allowFlushInProduction,
629
+ serializer: this.serializer.constructor.name,
630
+ // Names only. A Lua body is deployment logic and has no business on an
631
+ // operator-facing payload.
632
+ scripts: (this.options.scripts ?? []).map((script) => script.name),
633
+ connection: readEndpoint(this.options)
634
+ };
635
+ }
636
+ /**
637
+ * Measures one `PING`.
638
+ *
639
+ * `latencyMs` is produced on exactly one path — a reply that arrived. Every
640
+ * other outcome returns the `down` branch, which has no latency field to fill
641
+ * in, so a confident status without a measurement cannot be constructed here.
642
+ *
643
+ * @returns The probe outcome.
644
+ */
645
+ async probe() {
646
+ const started = performance.now();
647
+ let reply;
648
+ try {
649
+ reply = await raceDeadline(this.cache.ping(), this.adminOptions.probeTimeoutMs);
650
+ } catch (error) {
651
+ return { status: "down", reason: "error", code: readErrorCode(error) };
652
+ }
653
+ if (reply === PROBE_TIMEOUT) {
654
+ return { status: "down", reason: "timeout", code: null };
655
+ }
656
+ if (reply !== PONG) {
657
+ return { status: "down", reason: "error", code: null };
658
+ }
659
+ const latencyMs = Math.round(performance.now() - started);
660
+ return latencyMs > this.adminOptions.degradedAboveMs ? { status: "degraded", latencyMs } : { status: "up", latencyMs };
661
+ }
662
+ };
663
+ CacheStatusService = __decorateClass([
664
+ Injectable(),
665
+ __decorateParam(0, Inject(CacheService)),
666
+ __decorateParam(1, Inject(BYMAX_CACHE_OPTIONS)),
667
+ __decorateParam(2, Inject(BYMAX_CACHE_ADMIN_OPTIONS)),
668
+ __decorateParam(3, Inject(BYMAX_CACHE_SERIALIZER))
669
+ ], CacheStatusService);
670
+
671
+ // src/admin/bymax-cache-admin.module.ts
672
+ var ADMIN_SERVICES = [CacheStatusService, CacheAdminService];
673
+ var BymaxCacheAdminModule = class {
674
+ /**
675
+ * Registers the administration surface with statically known options.
676
+ *
677
+ * Options are validated here, at wiring — a malformed scope or a non-positive
678
+ * threshold fails the boot rather than the first request.
679
+ *
680
+ * @param options - The scopes this deployment exposes, plus any thresholds.
681
+ * @returns The dynamic module.
682
+ * @throws {CacheException} `INVALID_SCOPE` when the declaration is malformed.
683
+ */
684
+ static forRoot(options) {
685
+ const resolved = resolveAdminOptions(options);
686
+ return {
687
+ module: BymaxCacheAdminModule,
688
+ global: resolved.isGlobal,
689
+ providers: [{ provide: BYMAX_CACHE_ADMIN_OPTIONS, useValue: resolved }, ...ADMIN_SERVICES],
690
+ exports: [BYMAX_CACHE_ADMIN_OPTIONS, ...ADMIN_SERVICES]
691
+ };
692
+ }
693
+ /**
694
+ * Registers the administration surface with options built asynchronously.
695
+ *
696
+ * Validation runs inside the factory, so the same rules apply on both paths
697
+ * and neither can drift into accepting what the other rejects.
698
+ *
699
+ * @param options - Factory wiring plus the synchronous `isGlobal` flag.
700
+ * @returns The dynamic module.
701
+ */
702
+ static forRootAsync(options) {
703
+ return {
704
+ module: BymaxCacheAdminModule,
705
+ global: options.isGlobal ?? false,
706
+ imports: options.imports ?? [],
707
+ providers: [
708
+ {
709
+ provide: BYMAX_CACHE_ADMIN_OPTIONS,
710
+ inject: options.inject ?? [],
711
+ useFactory: async (...args) => resolveAdminOptions(await options.useFactory(...args))
712
+ },
713
+ ...ADMIN_SERVICES
714
+ ],
715
+ exports: [BYMAX_CACHE_ADMIN_OPTIONS, ...ADMIN_SERVICES]
716
+ };
717
+ }
718
+ };
719
+ BymaxCacheAdminModule = __decorateClass([
720
+ Module({})
721
+ ], BymaxCacheAdminModule);
722
+
723
+ export { BYMAX_CACHE_ADMIN_OPTIONS, BymaxCacheAdminModule, CacheAdminService, CacheStatusService, DEFAULT_COMMAND_BATCH_LIMIT, DEFAULT_DEGRADED_ABOVE_MS, DEFAULT_PROBE_TIMEOUT_MS, DEFAULT_REVEAL_LIMIT, DEFAULT_REVEAL_STRING_LIMIT, DEFAULT_SCAN_LIMIT, findScope, isKeyInScope, parseInfoFields, readKeyTtl, readKeyType, readRedisStats, readScopePattern, readSizeBytes, resolveAdminOptions, validateScopes };