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