@bymax-one/nest-cache 1.0.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.
- package/CHANGELOG.md +38 -0
- package/LICENSE +21 -0
- package/README.md +677 -0
- package/dist/server/index.cjs +1653 -0
- package/dist/server/index.d.cts +1296 -0
- package/dist/server/index.d.ts +1296 -0
- package/dist/server/index.mjs +1642 -0
- package/dist/shared/index.cjs +33 -0
- package/dist/shared/index.d.cts +114 -0
- package/dist/shared/index.d.ts +114 -0
- package/dist/shared/index.mjs +30 -0
- package/package.json +123 -0
|
@@ -0,0 +1,1642 @@
|
|
|
1
|
+
import { ConfigurableModuleBuilder, HttpStatus, Injectable, Inject, Optional, Module, HttpException } from '@nestjs/common';
|
|
2
|
+
import { Redis, Cluster } from 'ioredis';
|
|
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/server/bymax-cache.constants.ts
|
|
15
|
+
var BYMAX_CACHE_OPTIONS = /* @__PURE__ */ Symbol("BYMAX_CACHE_OPTIONS");
|
|
16
|
+
var BYMAX_CACHE_CONNECTION = /* @__PURE__ */ Symbol("BYMAX_CACHE_CONNECTION");
|
|
17
|
+
var BYMAX_CACHE_SCRIPT_REGISTRY = /* @__PURE__ */ Symbol("BYMAX_CACHE_SCRIPT_REGISTRY");
|
|
18
|
+
var BYMAX_CACHE_EVENTS = /* @__PURE__ */ Symbol("BYMAX_CACHE_EVENTS");
|
|
19
|
+
var BYMAX_CACHE_SERIALIZER = /* @__PURE__ */ Symbol("BYMAX_CACHE_SERIALIZER");
|
|
20
|
+
var BYMAX_CACHE_KEY_BUILDER = /* @__PURE__ */ Symbol("BYMAX_CACHE_KEY_BUILDER");
|
|
21
|
+
var { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN, OPTIONS_TYPE, ASYNC_OPTIONS_TYPE } = new ConfigurableModuleBuilder({ moduleName: "BymaxCache" }).setClassMethodName("forRoot").setExtras({ isGlobal: true }, (definition, extras) => ({
|
|
22
|
+
...definition,
|
|
23
|
+
global: extras.isGlobal
|
|
24
|
+
})).build();
|
|
25
|
+
|
|
26
|
+
// src/server/constants/default-namespace.ts
|
|
27
|
+
var DEFAULT_NAMESPACE = "app";
|
|
28
|
+
var DEFAULT_KEY_SEPARATOR = ":";
|
|
29
|
+
|
|
30
|
+
// src/server/constants/default-timeouts.ts
|
|
31
|
+
var DEFAULT_CONNECT_TIMEOUT_MS = 1e4;
|
|
32
|
+
var DEFAULT_COMMAND_TIMEOUT_MS = 5e3;
|
|
33
|
+
var DEFAULT_SHUTDOWN_TIMEOUT_MS = 5e3;
|
|
34
|
+
var DEFAULT_MAX_RETRIES_PER_REQUEST = 3;
|
|
35
|
+
var DEFAULT_RETRY_BASE_MS = 50;
|
|
36
|
+
var DEFAULT_RETRY_MAX_MS = 2e3;
|
|
37
|
+
var MIN_SHUTDOWN_TIMEOUT_MS = 100;
|
|
38
|
+
var MIN_CONNECT_TIMEOUT_MS = 100;
|
|
39
|
+
|
|
40
|
+
// src/shared/constants/error-codes.ts
|
|
41
|
+
var CACHE_ERROR_CODES = {
|
|
42
|
+
CONNECTION_FAILED: "cache.connection_failed",
|
|
43
|
+
COMMAND_TIMEOUT: "cache.command_timeout",
|
|
44
|
+
CONNECTION_LOST: "cache.connection_lost",
|
|
45
|
+
SERIALIZATION_FAILED: "cache.serialization_failed",
|
|
46
|
+
DESERIALIZATION_FAILED: "cache.deserialization_failed",
|
|
47
|
+
INVALID_NAMESPACE: "cache.invalid_namespace",
|
|
48
|
+
INVALID_KEY: "cache.invalid_key",
|
|
49
|
+
SCRIPT_NOT_REGISTERED: "cache.script_not_registered",
|
|
50
|
+
SCRIPT_EXECUTION_FAILED: "cache.script_execution_failed",
|
|
51
|
+
SCRIPT_REGISTRY_MISSING: "cache.script_registry_missing",
|
|
52
|
+
FLUSH_DISABLED_IN_PRODUCTION: "cache.flush_disabled_in_production",
|
|
53
|
+
CLUSTER_MISCONFIGURED: "cache.cluster_misconfigured",
|
|
54
|
+
SENTINEL_MISCONFIGURED: "cache.sentinel_misconfigured",
|
|
55
|
+
SHUTDOWN_TIMEOUT: "cache.shutdown_timeout",
|
|
56
|
+
UNSUPPORTED_IN_CLUSTER: "cache.unsupported_in_cluster"
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// src/server/errors/cache-error-codes.ts
|
|
60
|
+
var CACHE_ERROR_MESSAGES = /* @__PURE__ */ new Map([
|
|
61
|
+
[CACHE_ERROR_CODES.CONNECTION_FAILED, "Could not connect to Redis after retries."],
|
|
62
|
+
[CACHE_ERROR_CODES.COMMAND_TIMEOUT, "Redis command timed out."],
|
|
63
|
+
[CACHE_ERROR_CODES.CONNECTION_LOST, "Redis connection was lost during the operation."],
|
|
64
|
+
[CACHE_ERROR_CODES.SERIALIZATION_FAILED, "Failed to serialize the value."],
|
|
65
|
+
[CACHE_ERROR_CODES.DESERIALIZATION_FAILED, "Failed to deserialize the cached value."],
|
|
66
|
+
[CACHE_ERROR_CODES.INVALID_NAMESPACE, "Namespace is empty or contains the reserved separator."],
|
|
67
|
+
[CACHE_ERROR_CODES.INVALID_KEY, "Cache key prefix or id is empty."],
|
|
68
|
+
[CACHE_ERROR_CODES.SCRIPT_NOT_REGISTERED, "Tried to execute an unregistered Lua script."],
|
|
69
|
+
[CACHE_ERROR_CODES.SCRIPT_EXECUTION_FAILED, "Lua script returned a Redis error."],
|
|
70
|
+
[CACHE_ERROR_CODES.SCRIPT_REGISTRY_MISSING, "eval called without registering any scripts."],
|
|
71
|
+
[CACHE_ERROR_CODES.FLUSH_DISABLED_IN_PRODUCTION, "flushNamespace is disabled in production."],
|
|
72
|
+
[CACHE_ERROR_CODES.CLUSTER_MISCONFIGURED, "Cluster mode requires cluster.nodes."],
|
|
73
|
+
[
|
|
74
|
+
CACHE_ERROR_CODES.SENTINEL_MISCONFIGURED,
|
|
75
|
+
"Sentinel mode requires sentinel.sentinels and sentinel.name."
|
|
76
|
+
],
|
|
77
|
+
[CACHE_ERROR_CODES.SHUTDOWN_TIMEOUT, "Graceful shutdown exceeded the timeout."],
|
|
78
|
+
[
|
|
79
|
+
CACHE_ERROR_CODES.UNSUPPORTED_IN_CLUSTER,
|
|
80
|
+
"This operation requires standalone or sentinel mode; it is not supported in cluster mode."
|
|
81
|
+
]
|
|
82
|
+
]);
|
|
83
|
+
var CACHE_ERROR_STATUS = /* @__PURE__ */ new Map([
|
|
84
|
+
[CACHE_ERROR_CODES.COMMAND_TIMEOUT, HttpStatus.GATEWAY_TIMEOUT],
|
|
85
|
+
[CACHE_ERROR_CODES.CONNECTION_LOST, HttpStatus.SERVICE_UNAVAILABLE],
|
|
86
|
+
[CACHE_ERROR_CODES.INVALID_KEY, HttpStatus.BAD_REQUEST],
|
|
87
|
+
[CACHE_ERROR_CODES.FLUSH_DISABLED_IN_PRODUCTION, HttpStatus.FORBIDDEN]
|
|
88
|
+
]);
|
|
89
|
+
var CacheException = class extends HttpException {
|
|
90
|
+
/**
|
|
91
|
+
* @param code - One of {@link CACHE_ERROR_CODES}.
|
|
92
|
+
* @param details - Optional structured context. Never include secret values.
|
|
93
|
+
* @param statusCode - HTTP status override. When omitted, defaults to the
|
|
94
|
+
* canonical status for `code` (§12.2), or 500 for codes whose canonical
|
|
95
|
+
* status is 500.
|
|
96
|
+
*/
|
|
97
|
+
constructor(code, details, statusCode) {
|
|
98
|
+
super(
|
|
99
|
+
{
|
|
100
|
+
error: {
|
|
101
|
+
code,
|
|
102
|
+
message: CACHE_ERROR_MESSAGES.get(code) ?? "Cache error",
|
|
103
|
+
details: details ?? null
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
statusCode ?? CACHE_ERROR_STATUS.get(code) ?? HttpStatus.INTERNAL_SERVER_ERROR
|
|
107
|
+
);
|
|
108
|
+
this.code = code;
|
|
109
|
+
this.details = details ?? null;
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// src/server/utils/parse-redis-url.ts
|
|
114
|
+
function parseRedisUrl(url) {
|
|
115
|
+
const parsed = new URL(url);
|
|
116
|
+
if (parsed.protocol !== "redis:" && parsed.protocol !== "rediss:") {
|
|
117
|
+
throw new Error(`Unsupported Redis protocol: ${parsed.protocol}`);
|
|
118
|
+
}
|
|
119
|
+
if (parsed.hostname === "") {
|
|
120
|
+
throw new Error("Redis URL is missing a host");
|
|
121
|
+
}
|
|
122
|
+
const result = {
|
|
123
|
+
host: parsed.hostname,
|
|
124
|
+
port: parsed.port ? Number.parseInt(parsed.port, 10) : 6379
|
|
125
|
+
};
|
|
126
|
+
if (parsed.username) {
|
|
127
|
+
result.username = decodeURIComponent(parsed.username);
|
|
128
|
+
}
|
|
129
|
+
if (parsed.password) {
|
|
130
|
+
result.password = decodeURIComponent(parsed.password);
|
|
131
|
+
}
|
|
132
|
+
const dbSegment = parsed.pathname.slice(1);
|
|
133
|
+
if (dbSegment && /^\d+$/.test(dbSegment)) {
|
|
134
|
+
result.db = Number.parseInt(dbSegment, 10);
|
|
135
|
+
}
|
|
136
|
+
if (parsed.protocol === "rediss:") {
|
|
137
|
+
result.tls = {};
|
|
138
|
+
}
|
|
139
|
+
return result;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// src/server/config/default-options.ts
|
|
143
|
+
function validateOptions(options) {
|
|
144
|
+
const mode = options.mode ?? "standalone";
|
|
145
|
+
if (mode === "sentinel") {
|
|
146
|
+
if (!options.sentinel || !options.sentinel.sentinels?.length || !options.sentinel.name) {
|
|
147
|
+
throw new CacheException(CACHE_ERROR_CODES.SENTINEL_MISCONFIGURED, { mode });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (mode === "cluster") {
|
|
151
|
+
if (!options.cluster || !options.cluster.nodes?.length) {
|
|
152
|
+
throw new CacheException(CACHE_ERROR_CODES.CLUSTER_MISCONFIGURED, { mode });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (mode === "standalone") {
|
|
156
|
+
const connection = options.connection;
|
|
157
|
+
if (!connection || !connection.url && !connection.host) {
|
|
158
|
+
throw new CacheException(CACHE_ERROR_CODES.CONNECTION_FAILED, {
|
|
159
|
+
reason: "missing connection.url or connection.host"
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
if (connection.url) {
|
|
163
|
+
try {
|
|
164
|
+
parseRedisUrl(connection.url);
|
|
165
|
+
} catch {
|
|
166
|
+
throw new CacheException(CACHE_ERROR_CODES.CONNECTION_FAILED, {
|
|
167
|
+
reason: "invalid connection.url"
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const namespace = options.namespace ?? DEFAULT_NAMESPACE;
|
|
173
|
+
const separator = options.keySeparator ?? DEFAULT_KEY_SEPARATOR;
|
|
174
|
+
if (!namespace || namespace.trim() === "") {
|
|
175
|
+
throw new CacheException(CACHE_ERROR_CODES.INVALID_NAMESPACE, { namespace });
|
|
176
|
+
}
|
|
177
|
+
if (namespace.includes(separator)) {
|
|
178
|
+
throw new CacheException(CACHE_ERROR_CODES.INVALID_NAMESPACE, {
|
|
179
|
+
reason: "namespace contains key separator",
|
|
180
|
+
namespace,
|
|
181
|
+
separator
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
const shutdown = options.shutdownTimeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS;
|
|
185
|
+
if (shutdown < MIN_SHUTDOWN_TIMEOUT_MS) {
|
|
186
|
+
throw new CacheException(CACHE_ERROR_CODES.CONNECTION_FAILED, {
|
|
187
|
+
reason: "shutdownTimeoutMs too low",
|
|
188
|
+
value: shutdown,
|
|
189
|
+
min: MIN_SHUTDOWN_TIMEOUT_MS
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
const connectTimeout = options.connection?.connectTimeout;
|
|
193
|
+
if (connectTimeout !== void 0 && connectTimeout < MIN_CONNECT_TIMEOUT_MS) {
|
|
194
|
+
throw new CacheException(CACHE_ERROR_CODES.CONNECTION_FAILED, {
|
|
195
|
+
reason: "connectTimeout too low",
|
|
196
|
+
value: connectTimeout,
|
|
197
|
+
min: MIN_CONNECT_TIMEOUT_MS
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
function applyDefaults(options) {
|
|
202
|
+
const resolved = {
|
|
203
|
+
mode: options.mode ?? "standalone",
|
|
204
|
+
connection: options.connection,
|
|
205
|
+
sentinel: options.sentinel,
|
|
206
|
+
cluster: options.cluster,
|
|
207
|
+
namespace: options.namespace ?? DEFAULT_NAMESPACE,
|
|
208
|
+
keySeparator: options.keySeparator ?? DEFAULT_KEY_SEPARATOR,
|
|
209
|
+
serializer: options.serializer,
|
|
210
|
+
events: options.events,
|
|
211
|
+
shutdownTimeoutMs: options.shutdownTimeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS,
|
|
212
|
+
allowFlushInProduction: options.allowFlushInProduction ?? false,
|
|
213
|
+
isGlobal: options.isGlobal ?? true,
|
|
214
|
+
scripts: options.scripts
|
|
215
|
+
};
|
|
216
|
+
return Object.freeze(resolved);
|
|
217
|
+
}
|
|
218
|
+
var ConnectionManager = class {
|
|
219
|
+
/**
|
|
220
|
+
* @param options - Resolved module options (frozen).
|
|
221
|
+
* @param events - Optional consumer event callbacks; `@Optional()` so the
|
|
222
|
+
* module can provide `null` when the consumer omits `events`.
|
|
223
|
+
*/
|
|
224
|
+
constructor(options, events) {
|
|
225
|
+
this.options = options;
|
|
226
|
+
this.events = events;
|
|
227
|
+
this.client = null;
|
|
228
|
+
/** Default backoff: grow 50 ms per attempt, capped at 2 s. */
|
|
229
|
+
this.defaultRetryStrategy = (times) => Math.min(times * DEFAULT_RETRY_BASE_MS, DEFAULT_RETRY_MAX_MS);
|
|
230
|
+
/** Default reconnect policy: reconnect only on a `READONLY` replica failover. */
|
|
231
|
+
this.defaultReconnectOnError = (err) => err.message.includes("READONLY");
|
|
232
|
+
this.redisOptionsResolved = this.buildRedisOptions(options);
|
|
233
|
+
}
|
|
234
|
+
/** Opens the main client and waits for readiness unless `lazyConnect`. */
|
|
235
|
+
async onModuleInit() {
|
|
236
|
+
this.client = this.createClient();
|
|
237
|
+
this.registerListeners(this.client, "main");
|
|
238
|
+
if (!this.options.connection?.lazyConnect) {
|
|
239
|
+
await this.waitUntilReady(this.client);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Returns the singleton main client, creating it on first access if the
|
|
244
|
+
* module init has not run yet.
|
|
245
|
+
*
|
|
246
|
+
* @returns The shared main client.
|
|
247
|
+
*/
|
|
248
|
+
getClient() {
|
|
249
|
+
if (!this.client) {
|
|
250
|
+
this.client = this.createClient();
|
|
251
|
+
this.registerListeners(this.client, "main");
|
|
252
|
+
}
|
|
253
|
+
return this.client;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Creates a brand-new dedicated connection for subscriber mode (a subscriber
|
|
257
|
+
* connection cannot run normal commands), inheriting the main options.
|
|
258
|
+
*
|
|
259
|
+
* Ownership of the returned client transfers to the caller: `onModuleDestroy`
|
|
260
|
+
* quits only the main client, so a subscriber client must be quit/disconnected
|
|
261
|
+
* by its owner (the Pub/Sub service).
|
|
262
|
+
*
|
|
263
|
+
* The subscriber is a control-plane connection (only SUBSCRIBE / UNSUBSCRIBE),
|
|
264
|
+
* so its offline queue is enabled — a subscribe issued before the socket is
|
|
265
|
+
* ready buffers until connected instead of failing fast like the data-plane
|
|
266
|
+
* main client (whose offline queue stays disabled to avoid silent buffering).
|
|
267
|
+
* This override applies to standalone/sentinel modes only; in cluster mode
|
|
268
|
+
* {@link createClient} ignores it (cluster Pub/Sub is an experimental passthrough).
|
|
269
|
+
*
|
|
270
|
+
* @returns A fresh client wired with `subscriber`-role event listeners.
|
|
271
|
+
*/
|
|
272
|
+
createSubscriberClient() {
|
|
273
|
+
const client = this.createClient({ enableOfflineQueue: true });
|
|
274
|
+
this.registerListeners(client, "subscriber");
|
|
275
|
+
return client;
|
|
276
|
+
}
|
|
277
|
+
/** Quits the main client gracefully, forcing `disconnect()` on timeout. */
|
|
278
|
+
async onModuleDestroy() {
|
|
279
|
+
const client = this.client;
|
|
280
|
+
if (!client) {
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
let timer;
|
|
284
|
+
try {
|
|
285
|
+
await Promise.race([
|
|
286
|
+
client.quit(),
|
|
287
|
+
new Promise((_resolve, reject) => {
|
|
288
|
+
timer = setTimeout(
|
|
289
|
+
() => reject(
|
|
290
|
+
new CacheException(CACHE_ERROR_CODES.SHUTDOWN_TIMEOUT, {
|
|
291
|
+
timeoutMs: this.options.shutdownTimeoutMs
|
|
292
|
+
})
|
|
293
|
+
),
|
|
294
|
+
this.options.shutdownTimeoutMs
|
|
295
|
+
);
|
|
296
|
+
})
|
|
297
|
+
]);
|
|
298
|
+
} catch {
|
|
299
|
+
this.emit("error", {
|
|
300
|
+
role: "main",
|
|
301
|
+
reason: "forced_disconnect",
|
|
302
|
+
shutdownTimeoutMs: this.options.shutdownTimeoutMs
|
|
303
|
+
});
|
|
304
|
+
client.disconnect();
|
|
305
|
+
} finally {
|
|
306
|
+
clearTimeout(timer);
|
|
307
|
+
this.client = null;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
// ─── Private ──────────────────────────────────────────────────────────────
|
|
311
|
+
/**
|
|
312
|
+
* Instantiates the client matching the configured mode.
|
|
313
|
+
*
|
|
314
|
+
* @param overrides - Extra `RedisOptions` merged over the resolved defaults
|
|
315
|
+
* (used to enable the subscriber's offline queue). Ignored in cluster mode,
|
|
316
|
+
* where Pub/Sub has different semantics and is out of scope.
|
|
317
|
+
*/
|
|
318
|
+
createClient(overrides) {
|
|
319
|
+
if (this.options.mode === "sentinel") {
|
|
320
|
+
const sentinel = this.options.sentinel;
|
|
321
|
+
if (!sentinel) {
|
|
322
|
+
throw new CacheException(CACHE_ERROR_CODES.SENTINEL_MISCONFIGURED, { mode: "sentinel" });
|
|
323
|
+
}
|
|
324
|
+
return new Redis({
|
|
325
|
+
...this.redisOptionsResolved,
|
|
326
|
+
...overrides,
|
|
327
|
+
sentinels: sentinel.sentinels,
|
|
328
|
+
name: sentinel.name,
|
|
329
|
+
...sentinel.sentinelPassword !== void 0 && {
|
|
330
|
+
sentinelPassword: sentinel.sentinelPassword
|
|
331
|
+
},
|
|
332
|
+
...sentinel.password !== void 0 && { password: sentinel.password },
|
|
333
|
+
// Normalize 'replica' → 'slave' — ioredis 5 only accepts 'slave' at the
|
|
334
|
+
// wire level; our public interface accepts 'replica' per Redis 7 naming.
|
|
335
|
+
...sentinel.role !== void 0 && {
|
|
336
|
+
role: sentinel.role === "replica" ? "slave" : sentinel.role
|
|
337
|
+
},
|
|
338
|
+
...sentinel.natMap !== void 0 && { natMap: sentinel.natMap }
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
if (this.options.mode === "cluster") {
|
|
342
|
+
const cluster = this.options.cluster;
|
|
343
|
+
if (!cluster) {
|
|
344
|
+
throw new CacheException(CACHE_ERROR_CODES.CLUSTER_MISCONFIGURED, { mode: "cluster" });
|
|
345
|
+
}
|
|
346
|
+
return new Cluster(cluster.nodes, cluster.options ?? {});
|
|
347
|
+
}
|
|
348
|
+
return new Redis({ ...this.redisOptionsResolved, ...overrides });
|
|
349
|
+
}
|
|
350
|
+
/** Merges connection options with defaults; URL fields take precedence. */
|
|
351
|
+
buildRedisOptions(opts) {
|
|
352
|
+
const c = opts.connection ?? {};
|
|
353
|
+
const fromUrl = c.url ? parseRedisUrl(c.url) : {};
|
|
354
|
+
return {
|
|
355
|
+
lazyConnect: c.lazyConnect ?? false,
|
|
356
|
+
connectTimeout: c.connectTimeout ?? DEFAULT_CONNECT_TIMEOUT_MS,
|
|
357
|
+
commandTimeout: c.commandTimeout ?? DEFAULT_COMMAND_TIMEOUT_MS,
|
|
358
|
+
maxRetriesPerRequest: c.maxRetriesPerRequest ?? DEFAULT_MAX_RETRIES_PER_REQUEST,
|
|
359
|
+
enableReadyCheck: c.enableReadyCheck ?? true,
|
|
360
|
+
enableOfflineQueue: c.enableOfflineQueue ?? false,
|
|
361
|
+
retryStrategy: c.retryStrategy ?? this.defaultRetryStrategy,
|
|
362
|
+
reconnectOnError: c.reconnectOnError ?? this.defaultReconnectOnError,
|
|
363
|
+
keepAlive: c.keepAlive ?? 0,
|
|
364
|
+
noDelay: c.noDelay ?? true,
|
|
365
|
+
family: c.family ?? 4,
|
|
366
|
+
...c.host !== void 0 && { host: c.host },
|
|
367
|
+
...c.port !== void 0 && { port: c.port },
|
|
368
|
+
...c.password !== void 0 && { password: c.password },
|
|
369
|
+
...c.db !== void 0 && { db: c.db },
|
|
370
|
+
...c.username !== void 0 && { username: c.username },
|
|
371
|
+
...c.tls !== void 0 && { tls: c.tls },
|
|
372
|
+
...fromUrl
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
/** Forwards a lifecycle event to `events.onEvent`, swallowing consumer throws. */
|
|
376
|
+
emit(event, data) {
|
|
377
|
+
try {
|
|
378
|
+
this.events?.onEvent?.(event, data);
|
|
379
|
+
} catch {
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
/** Wires lifecycle listeners that forward to `events.onEvent`, swallowing throws. */
|
|
383
|
+
registerListeners(client, role) {
|
|
384
|
+
client.on("connect", () => this.emit("connect", { role }));
|
|
385
|
+
client.on("ready", () => this.emit("ready", { role }));
|
|
386
|
+
client.on("error", (err) => this.emit("error", { role, error: err.message }));
|
|
387
|
+
client.on("close", () => this.emit("close", { role }));
|
|
388
|
+
client.on("reconnecting", (delay) => this.emit("reconnecting", { role, delay }));
|
|
389
|
+
client.on("end", () => this.emit("end", { role }));
|
|
390
|
+
}
|
|
391
|
+
/** Resolves once the client is ready; rejects (wrapped) on connection error. */
|
|
392
|
+
async waitUntilReady(client) {
|
|
393
|
+
if (client.status === "ready") {
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
await new Promise((resolve, reject) => {
|
|
397
|
+
const cleanup = () => {
|
|
398
|
+
client.off("ready", onReady);
|
|
399
|
+
client.off("error", onError);
|
|
400
|
+
};
|
|
401
|
+
const onReady = () => {
|
|
402
|
+
cleanup();
|
|
403
|
+
resolve();
|
|
404
|
+
};
|
|
405
|
+
const onError = (err) => {
|
|
406
|
+
cleanup();
|
|
407
|
+
reject(new CacheException(CACHE_ERROR_CODES.CONNECTION_FAILED, { error: err.message }));
|
|
408
|
+
};
|
|
409
|
+
client.once("ready", onReady);
|
|
410
|
+
client.once("error", onError);
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
ConnectionManager = __decorateClass([
|
|
415
|
+
Injectable(),
|
|
416
|
+
__decorateParam(0, Inject(BYMAX_CACHE_OPTIONS)),
|
|
417
|
+
__decorateParam(1, Optional()),
|
|
418
|
+
__decorateParam(1, Inject(BYMAX_CACHE_EVENTS))
|
|
419
|
+
], ConnectionManager);
|
|
420
|
+
var SCRIPT_LOAD = "LOAD";
|
|
421
|
+
var NOSCRIPT_MARKER = "NOSCRIPT";
|
|
422
|
+
var toErrorMessage = (err) => err instanceof Error ? err.message : String(err);
|
|
423
|
+
var ScriptManagerService = class {
|
|
424
|
+
/**
|
|
425
|
+
* @param options - Resolved module options; `options.scripts` seeds the registry.
|
|
426
|
+
* @param connection - Owns the client used for `SCRIPT LOAD` / `EVALSHA`.
|
|
427
|
+
* Explicit `@Inject` — the published bundle is built without
|
|
428
|
+
* emitDecoratorMetadata, so type-only DI cannot resolve a class provider
|
|
429
|
+
* (CLAUDE.md §5).
|
|
430
|
+
*/
|
|
431
|
+
constructor(options, connection) {
|
|
432
|
+
this.options = options;
|
|
433
|
+
this.connection = connection;
|
|
434
|
+
/** Registry of script name → `{ lua, sha? }`. */
|
|
435
|
+
this.scripts = /* @__PURE__ */ new Map();
|
|
436
|
+
for (const definition of options.scripts ?? []) {
|
|
437
|
+
this.scripts.set(definition.name, { lua: definition.lua });
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Pre-loads every registered script once the application has bootstrapped,
|
|
442
|
+
* unless `lazyConnect` is set — in which case loading is deferred to the first
|
|
443
|
+
* {@link eval}.
|
|
444
|
+
*
|
|
445
|
+
* Runs in `onApplicationBootstrap` (not `onModuleInit`) deliberately: NestJS
|
|
446
|
+
* invokes `onModuleInit` hooks concurrently, so loading here would race the
|
|
447
|
+
* {@link ConnectionManager} connect and fail fast against a not-yet-writable
|
|
448
|
+
* socket (offline queue is disabled). `onApplicationBootstrap` is guaranteed to
|
|
449
|
+
* run after every `onModuleInit` resolved — i.e. once the connection is ready.
|
|
450
|
+
*
|
|
451
|
+
* @returns Resolves once all eager scripts are loaded.
|
|
452
|
+
*/
|
|
453
|
+
async onApplicationBootstrap() {
|
|
454
|
+
if (this.options.connection?.lazyConnect) {
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
await Promise.all([...this.scripts.keys()].map((name) => this.load(name)));
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Registers a script under `name`, or overrides an existing one. The new
|
|
461
|
+
* script is loaded lazily on its next {@link eval} / {@link load}.
|
|
462
|
+
*
|
|
463
|
+
* @param name - Lookup name used to invoke the script.
|
|
464
|
+
* @param lua - The Lua source. Never build this from untrusted input (CLAUDE.md §4).
|
|
465
|
+
*/
|
|
466
|
+
register(name, lua) {
|
|
467
|
+
this.scripts.set(name, { lua });
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Loads a registered script into Redis (if not already cached) and returns its
|
|
471
|
+
* SHA1. Idempotent — a cached SHA is reused without a second `SCRIPT LOAD`.
|
|
472
|
+
*
|
|
473
|
+
* @param name - The registered script name.
|
|
474
|
+
* @returns The script's SHA1.
|
|
475
|
+
* @throws {CacheException} `SCRIPT_NOT_REGISTERED` when `name` is unknown.
|
|
476
|
+
*/
|
|
477
|
+
async load(name) {
|
|
478
|
+
const entry = this.scripts.get(name);
|
|
479
|
+
if (!entry) {
|
|
480
|
+
throw new CacheException(CACHE_ERROR_CODES.SCRIPT_NOT_REGISTERED, { name });
|
|
481
|
+
}
|
|
482
|
+
if (!entry.sha) {
|
|
483
|
+
const client = this.connection.getClient();
|
|
484
|
+
entry.sha = await client.script(SCRIPT_LOAD, entry.lua);
|
|
485
|
+
}
|
|
486
|
+
return entry.sha;
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Executes a registered Lua script.
|
|
490
|
+
*
|
|
491
|
+
* Standalone / sentinel use `EVALSHA`; on `NOSCRIPT` the script is reloaded once
|
|
492
|
+
* and the call retried. CLUSTER uses `EVAL` (the full body): `EVALSHA` routes to
|
|
493
|
+
* the key's slot owner while `SCRIPT LOAD` is keyless (lands on an arbitrary
|
|
494
|
+
* node), so the owner could `NOSCRIPT` and a keyless reload would not fix it —
|
|
495
|
+
* `EVAL` ships the body and routes by key to the slot owner; a keyless `EVAL`
|
|
496
|
+
* would execute on an arbitrary node — this method throws
|
|
497
|
+
* `SCRIPT_EXECUTION_FAILED` when called in cluster mode with zero keys.
|
|
498
|
+
*
|
|
499
|
+
* Keys must already be namespaced — {@link CacheService.eval} handles that for
|
|
500
|
+
* consumer-facing usage. In cluster mode all keys of a single call must hash to
|
|
501
|
+
* the same slot (use a hash tag), per Redis cluster semantics.
|
|
502
|
+
*
|
|
503
|
+
* @param name - The registered script name.
|
|
504
|
+
* @param keys - `KEYS[]` for the script (already namespaced).
|
|
505
|
+
* @param args - `ARGV[]` for the script.
|
|
506
|
+
* @returns The script's return value, typed `unknown` (Redis Lua is dynamic).
|
|
507
|
+
* @throws {CacheException} `SCRIPT_NOT_REGISTERED` when `name` is unknown.
|
|
508
|
+
* @throws {CacheException} `SCRIPT_EXECUTION_FAILED` on a non-`NOSCRIPT` error,
|
|
509
|
+
* a failed reload-and-retry, or any cluster `EVAL` failure. The Lua source is
|
|
510
|
+
* never echoed in the error.
|
|
511
|
+
*/
|
|
512
|
+
async eval(name, keys, args) {
|
|
513
|
+
const entry = this.scripts.get(name);
|
|
514
|
+
if (!entry) {
|
|
515
|
+
throw new CacheException(CACHE_ERROR_CODES.SCRIPT_NOT_REGISTERED, { name });
|
|
516
|
+
}
|
|
517
|
+
const client = this.connection.getClient();
|
|
518
|
+
if (this.options.mode === "cluster") {
|
|
519
|
+
if (keys.length === 0) {
|
|
520
|
+
throw new CacheException(CACHE_ERROR_CODES.SCRIPT_EXECUTION_FAILED, {
|
|
521
|
+
name,
|
|
522
|
+
reason: "cluster EVAL requires at least one key for slot routing"
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
try {
|
|
526
|
+
return await client.eval(entry.lua, keys.length, ...keys, ...args);
|
|
527
|
+
} catch (err) {
|
|
528
|
+
throw new CacheException(CACHE_ERROR_CODES.SCRIPT_EXECUTION_FAILED, {
|
|
529
|
+
name,
|
|
530
|
+
originalError: toErrorMessage(err)
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
try {
|
|
535
|
+
const sha = entry.sha ?? await this.load(name);
|
|
536
|
+
return await client.evalsha(sha, keys.length, ...keys, ...args);
|
|
537
|
+
} catch (err) {
|
|
538
|
+
if (!toErrorMessage(err).includes(NOSCRIPT_MARKER)) {
|
|
539
|
+
throw new CacheException(CACHE_ERROR_CODES.SCRIPT_EXECUTION_FAILED, {
|
|
540
|
+
name,
|
|
541
|
+
originalError: toErrorMessage(err)
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
try {
|
|
545
|
+
const reloadedSha = await client.script(SCRIPT_LOAD, entry.lua);
|
|
546
|
+
entry.sha = reloadedSha;
|
|
547
|
+
return await client.evalsha(reloadedSha, keys.length, ...keys, ...args);
|
|
548
|
+
} catch (retryErr) {
|
|
549
|
+
throw new CacheException(CACHE_ERROR_CODES.SCRIPT_EXECUTION_FAILED, {
|
|
550
|
+
name,
|
|
551
|
+
originalError: toErrorMessage(retryErr)
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
};
|
|
557
|
+
ScriptManagerService = __decorateClass([
|
|
558
|
+
Injectable(),
|
|
559
|
+
__decorateParam(0, Inject(BYMAX_CACHE_OPTIONS)),
|
|
560
|
+
__decorateParam(1, Inject(ConnectionManager))
|
|
561
|
+
], ScriptManagerService);
|
|
562
|
+
var KeyBuilder = class {
|
|
563
|
+
/**
|
|
564
|
+
* @param options - Resolved module options supplying the namespace + separator.
|
|
565
|
+
*/
|
|
566
|
+
constructor(options) {
|
|
567
|
+
this.namespace = options.namespace;
|
|
568
|
+
this.separator = options.keySeparator;
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Builds the full namespaced key.
|
|
572
|
+
*
|
|
573
|
+
* @param prefix - The entity-group prefix (e.g. `'users'`).
|
|
574
|
+
* @param id - The entity id.
|
|
575
|
+
* @returns `{namespace}{sep}{prefix}{sep}{id}`.
|
|
576
|
+
* @throws {CacheException} `INVALID_KEY` when `prefix` or `id` is empty.
|
|
577
|
+
*/
|
|
578
|
+
build(prefix, id) {
|
|
579
|
+
if (!prefix) {
|
|
580
|
+
throw new CacheException(CACHE_ERROR_CODES.INVALID_KEY, { reason: "empty_prefix" });
|
|
581
|
+
}
|
|
582
|
+
if (!id) {
|
|
583
|
+
throw new CacheException(CACHE_ERROR_CODES.INVALID_KEY, { reason: "empty_id" });
|
|
584
|
+
}
|
|
585
|
+
return `${this.namespace}${this.separator}${prefix}${this.separator}${id}`;
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Applies only the namespace to an already-composed key. Used by the Pub/Sub
|
|
589
|
+
* and script services for channel/key namespacing.
|
|
590
|
+
*
|
|
591
|
+
* @param keyWithoutNamespace - The bare key to namespace.
|
|
592
|
+
* @returns `{namespace}{sep}{keyWithoutNamespace}`.
|
|
593
|
+
* @throws {CacheException} `INVALID_KEY` when the key is empty.
|
|
594
|
+
*/
|
|
595
|
+
applyNamespace(keyWithoutNamespace) {
|
|
596
|
+
if (!keyWithoutNamespace) {
|
|
597
|
+
throw new CacheException(CACHE_ERROR_CODES.INVALID_KEY, { reason: "empty_key" });
|
|
598
|
+
}
|
|
599
|
+
return `${this.namespace}${this.separator}${keyWithoutNamespace}`;
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Returns the `{namespace}{separator}` prefix string used to build `SCAN`
|
|
603
|
+
* match patterns scoped to this namespace.
|
|
604
|
+
*
|
|
605
|
+
* @returns The namespace prefix, e.g. `'app:'`.
|
|
606
|
+
*/
|
|
607
|
+
getNamespacePrefix() {
|
|
608
|
+
return `${this.namespace}${this.separator}`;
|
|
609
|
+
}
|
|
610
|
+
};
|
|
611
|
+
KeyBuilder = __decorateClass([
|
|
612
|
+
Injectable(),
|
|
613
|
+
__decorateParam(0, Inject(BYMAX_CACHE_OPTIONS))
|
|
614
|
+
], KeyBuilder);
|
|
615
|
+
var MAX_PREVIEW_LENGTH = 100;
|
|
616
|
+
var extractErrorMessage = (err) => err instanceof Error ? err.message : String(err);
|
|
617
|
+
var JsonSerializer = class {
|
|
618
|
+
/**
|
|
619
|
+
* Encodes a value as a JSON string.
|
|
620
|
+
*
|
|
621
|
+
* @typeParam T - The value's static type.
|
|
622
|
+
* @param value - The value to encode.
|
|
623
|
+
* @returns The JSON string representation.
|
|
624
|
+
* @throws {CacheException} `SERIALIZATION_FAILED` when the value cannot be
|
|
625
|
+
* stringified. This covers `JSON.stringify` throwing (circular reference,
|
|
626
|
+
* `BigInt`) AND the silent cases where a top-level `undefined`, function, or
|
|
627
|
+
* `symbol` would make `JSON.stringify` return the JS value `undefined`
|
|
628
|
+
* instead of a string. The original message is attached under
|
|
629
|
+
* `details.error`; the value itself is never echoed, as it may carry secrets
|
|
630
|
+
* (CLAUDE.md §4).
|
|
631
|
+
*/
|
|
632
|
+
serialize(value) {
|
|
633
|
+
if (value === void 0 || typeof value === "function" || typeof value === "symbol") {
|
|
634
|
+
throw new CacheException(CACHE_ERROR_CODES.SERIALIZATION_FAILED, {
|
|
635
|
+
error: "Cannot serialize a top-level undefined, function, or symbol value"
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
try {
|
|
639
|
+
return JSON.stringify(value);
|
|
640
|
+
} catch (err) {
|
|
641
|
+
throw new CacheException(CACHE_ERROR_CODES.SERIALIZATION_FAILED, {
|
|
642
|
+
error: extractErrorMessage(err)
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* Decodes a JSON string back into a value.
|
|
648
|
+
*
|
|
649
|
+
* Fails closed: a malformed payload throws instead of returning `undefined`
|
|
650
|
+
* or a partial value, so a corrupted cache entry can never masquerade as a
|
|
651
|
+
* valid `T` (security invariant — CLAUDE.md §4).
|
|
652
|
+
*
|
|
653
|
+
* @typeParam T - The expected decoded type.
|
|
654
|
+
* @param raw - The JSON string to decode.
|
|
655
|
+
* @returns The decoded value, typed as `T`.
|
|
656
|
+
* @throws {CacheException} `DESERIALIZATION_FAILED` when `raw` is not valid
|
|
657
|
+
* JSON. `details.preview` carries at most {@link MAX_PREVIEW_LENGTH}
|
|
658
|
+
* characters of `raw` (truncated with an ellipsis) to aid debugging without
|
|
659
|
+
* leaking a large payload that may contain PII.
|
|
660
|
+
*/
|
|
661
|
+
deserialize(raw) {
|
|
662
|
+
try {
|
|
663
|
+
return JSON.parse(raw);
|
|
664
|
+
} catch (err) {
|
|
665
|
+
throw new CacheException(CACHE_ERROR_CODES.DESERIALIZATION_FAILED, {
|
|
666
|
+
error: extractErrorMessage(err),
|
|
667
|
+
preview: raw.length > MAX_PREVIEW_LENGTH ? `${raw.substring(0, MAX_PREVIEW_LENGTH)}...` : raw
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
};
|
|
672
|
+
JsonSerializer = __decorateClass([
|
|
673
|
+
Injectable()
|
|
674
|
+
], JsonSerializer);
|
|
675
|
+
|
|
676
|
+
// src/server/utils/resolve-serializer.ts
|
|
677
|
+
var resolveSerializer = (options, injected) => (
|
|
678
|
+
// JsonSerializer has no constructor arguments; manual instantiation is intentional
|
|
679
|
+
// here so this utility has no NestJS DI dependency and stays importable anywhere.
|
|
680
|
+
options.serializer ?? injected ?? new JsonSerializer()
|
|
681
|
+
);
|
|
682
|
+
|
|
683
|
+
// src/server/services/cache.service.ts
|
|
684
|
+
var PRODUCTION_ENV = "production";
|
|
685
|
+
var SCAN_COUNT = 100;
|
|
686
|
+
var FLUSH_SCAN_COUNT = 1e3;
|
|
687
|
+
var isScannableClient = (client) => typeof client.scanStream === "function";
|
|
688
|
+
var CacheService = class {
|
|
689
|
+
/**
|
|
690
|
+
* @param options - Resolved module options (frozen). Supplies `serializer`
|
|
691
|
+
* and `allowFlushInProduction`.
|
|
692
|
+
* @param connection - Owns the singleton ioredis client every command runs on.
|
|
693
|
+
* @param keyBuilder - Composes every namespaced key.
|
|
694
|
+
* @param injectedSerializer - Optional `BYMAX_CACHE_SERIALIZER` provider; used
|
|
695
|
+
* only when `options.serializer` is absent. `@Optional()` so the token may be
|
|
696
|
+
* unprovided in tests / minimal wirings.
|
|
697
|
+
* @param scriptRegistry - Optional `ScriptManagerService`; required only for
|
|
698
|
+
* {@link CacheService.eval}. `@Optional()` so the cache works without scripts.
|
|
699
|
+
*/
|
|
700
|
+
constructor(options, connection, keyBuilder, injectedSerializer, scriptRegistry) {
|
|
701
|
+
this.options = options;
|
|
702
|
+
this.connection = connection;
|
|
703
|
+
this.keyBuilder = keyBuilder;
|
|
704
|
+
this.scriptRegistry = scriptRegistry;
|
|
705
|
+
this.serializer = resolveSerializer(options, injectedSerializer);
|
|
706
|
+
}
|
|
707
|
+
// ─── String / value commands ───────────────────────────────────────────────
|
|
708
|
+
/**
|
|
709
|
+
* Reads a value and deserializes it through the configured serializer.
|
|
710
|
+
*
|
|
711
|
+
* @typeParam T - The expected decoded type.
|
|
712
|
+
* @param prefix - Entity-group prefix (e.g. `'users'`).
|
|
713
|
+
* @param id - Entity id.
|
|
714
|
+
* @returns The decoded value, or `null` when the key does not exist.
|
|
715
|
+
* @throws {CacheException} `DESERIALIZATION_FAILED` when the stored payload is
|
|
716
|
+
* not decodable as `T`.
|
|
717
|
+
*/
|
|
718
|
+
async get(prefix, id) {
|
|
719
|
+
const key = this.keyBuilder.build(prefix, id);
|
|
720
|
+
const raw = await this.connection.getClient().get(key);
|
|
721
|
+
if (raw === null) {
|
|
722
|
+
return null;
|
|
723
|
+
}
|
|
724
|
+
return this.serializer.deserialize(raw);
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* Reads the raw stored string without deserialization.
|
|
728
|
+
*
|
|
729
|
+
* @param prefix - Entity-group prefix.
|
|
730
|
+
* @param id - Entity id.
|
|
731
|
+
* @returns The raw string, or `null` when the key does not exist.
|
|
732
|
+
*/
|
|
733
|
+
async getRaw(prefix, id) {
|
|
734
|
+
return this.connection.getClient().get(this.keyBuilder.build(prefix, id));
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* Serializes and writes a value, optionally with a TTL.
|
|
738
|
+
*
|
|
739
|
+
* @typeParam T - The value's static type.
|
|
740
|
+
* @param prefix - Entity-group prefix.
|
|
741
|
+
* @param id - Entity id.
|
|
742
|
+
* @param value - The value to store (passed through the serializer).
|
|
743
|
+
* @param ttlSeconds - Optional expiry in seconds; omit for no expiration.
|
|
744
|
+
* @returns Resolves once the write completes.
|
|
745
|
+
* @throws {CacheException} `SERIALIZATION_FAILED` when `value` cannot be encoded.
|
|
746
|
+
*/
|
|
747
|
+
async set(prefix, id, value, ttlSeconds) {
|
|
748
|
+
const key = this.keyBuilder.build(prefix, id);
|
|
749
|
+
const raw = this.serializer.serialize(value);
|
|
750
|
+
if (ttlSeconds !== void 0) {
|
|
751
|
+
await this.connection.getClient().set(key, raw, "EX", ttlSeconds);
|
|
752
|
+
} else {
|
|
753
|
+
await this.connection.getClient().set(key, raw);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Writes a raw string without serialization, optionally with a TTL.
|
|
758
|
+
*
|
|
759
|
+
* @param prefix - Entity-group prefix.
|
|
760
|
+
* @param id - Entity id.
|
|
761
|
+
* @param value - The raw string to store as-is.
|
|
762
|
+
* @param ttlSeconds - Optional expiry in seconds; omit for no expiration.
|
|
763
|
+
* @returns Resolves once the write completes.
|
|
764
|
+
*/
|
|
765
|
+
async setRaw(prefix, id, value, ttlSeconds) {
|
|
766
|
+
const key = this.keyBuilder.build(prefix, id);
|
|
767
|
+
if (ttlSeconds !== void 0) {
|
|
768
|
+
await this.connection.getClient().set(key, value, "EX", ttlSeconds);
|
|
769
|
+
} else {
|
|
770
|
+
await this.connection.getClient().set(key, value);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* Atomically writes a value only if the key does not already exist (`SET NX`).
|
|
775
|
+
*
|
|
776
|
+
* @typeParam T - The value's static type.
|
|
777
|
+
* @param prefix - Entity-group prefix.
|
|
778
|
+
* @param id - Entity id.
|
|
779
|
+
* @param value - The value to store (passed through the serializer).
|
|
780
|
+
* @param ttlSeconds - Optional expiry in seconds applied on the same atomic write.
|
|
781
|
+
* @returns `true` when the value was stored, `false` when the key already existed.
|
|
782
|
+
* @throws {CacheException} `SERIALIZATION_FAILED` when `value` cannot be encoded.
|
|
783
|
+
*/
|
|
784
|
+
async setNx(prefix, id, value, ttlSeconds) {
|
|
785
|
+
const key = this.keyBuilder.build(prefix, id);
|
|
786
|
+
const raw = this.serializer.serialize(value);
|
|
787
|
+
const result = ttlSeconds !== void 0 ? await this.connection.getClient().set(key, raw, "EX", ttlSeconds, "NX") : await this.connection.getClient().set(key, raw, "NX");
|
|
788
|
+
return result === "OK";
|
|
789
|
+
}
|
|
790
|
+
/**
|
|
791
|
+
* Deletes a single key.
|
|
792
|
+
*
|
|
793
|
+
* @param prefix - Entity-group prefix.
|
|
794
|
+
* @param id - Entity id.
|
|
795
|
+
* @returns The number of keys removed (`0` or `1`).
|
|
796
|
+
*/
|
|
797
|
+
async del(prefix, id) {
|
|
798
|
+
return this.connection.getClient().del(this.keyBuilder.build(prefix, id));
|
|
799
|
+
}
|
|
800
|
+
/**
|
|
801
|
+
* Deletes many keys under the same prefix in one round trip.
|
|
802
|
+
*
|
|
803
|
+
* @param prefix - Entity-group prefix shared by every id.
|
|
804
|
+
* @param ids - Entity ids to delete. An empty list is a no-op (no Redis call).
|
|
805
|
+
* @returns The number of keys actually removed.
|
|
806
|
+
*/
|
|
807
|
+
async delMany(prefix, ids) {
|
|
808
|
+
if (ids.length === 0) {
|
|
809
|
+
return 0;
|
|
810
|
+
}
|
|
811
|
+
const keys = ids.map((id) => this.keyBuilder.build(prefix, id));
|
|
812
|
+
return this.connection.getClient().del(...keys);
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* Reports whether a key exists.
|
|
816
|
+
*
|
|
817
|
+
* @param prefix - Entity-group prefix.
|
|
818
|
+
* @param id - Entity id.
|
|
819
|
+
* @returns `true` when the key exists, otherwise `false`.
|
|
820
|
+
*/
|
|
821
|
+
async exists(prefix, id) {
|
|
822
|
+
const count = await this.connection.getClient().exists(this.keyBuilder.build(prefix, id));
|
|
823
|
+
return count > 0;
|
|
824
|
+
}
|
|
825
|
+
// ─── Numeric commands ──────────────────────────────────────────────────────
|
|
826
|
+
/**
|
|
827
|
+
* Atomically increments a counter.
|
|
828
|
+
*
|
|
829
|
+
* @param prefix - Entity-group prefix.
|
|
830
|
+
* @param id - Entity id.
|
|
831
|
+
* @param by - Increment step. Defaults to `1` (uses `INCR`); any other value
|
|
832
|
+
* uses `INCRBY`.
|
|
833
|
+
* @returns The value after the increment.
|
|
834
|
+
*/
|
|
835
|
+
async incr(prefix, id, by = 1) {
|
|
836
|
+
const key = this.keyBuilder.build(prefix, id);
|
|
837
|
+
return by === 1 ? this.connection.getClient().incr(key) : this.connection.getClient().incrby(key, by);
|
|
838
|
+
}
|
|
839
|
+
/**
|
|
840
|
+
* Atomically decrements a counter.
|
|
841
|
+
*
|
|
842
|
+
* @param prefix - Entity-group prefix.
|
|
843
|
+
* @param id - Entity id.
|
|
844
|
+
* @param by - Decrement step. Defaults to `1` (uses `DECR`); any other value
|
|
845
|
+
* uses `DECRBY`.
|
|
846
|
+
* @returns The value after the decrement.
|
|
847
|
+
*/
|
|
848
|
+
async decr(prefix, id, by = 1) {
|
|
849
|
+
const key = this.keyBuilder.build(prefix, id);
|
|
850
|
+
return by === 1 ? this.connection.getClient().decr(key) : this.connection.getClient().decrby(key, by);
|
|
851
|
+
}
|
|
852
|
+
// ─── Expiration commands ───────────────────────────────────────────────────
|
|
853
|
+
/**
|
|
854
|
+
* Sets a TTL on an existing key.
|
|
855
|
+
*
|
|
856
|
+
* @param prefix - Entity-group prefix.
|
|
857
|
+
* @param id - Entity id.
|
|
858
|
+
* @param ttlSeconds - Expiry in seconds.
|
|
859
|
+
* @returns `true` when the timeout was set, `false` when the key does not exist.
|
|
860
|
+
*/
|
|
861
|
+
async expire(prefix, id, ttlSeconds) {
|
|
862
|
+
const result = await this.connection.getClient().expire(this.keyBuilder.build(prefix, id), ttlSeconds);
|
|
863
|
+
return result === 1;
|
|
864
|
+
}
|
|
865
|
+
/**
|
|
866
|
+
* Reads the remaining TTL of a key.
|
|
867
|
+
*
|
|
868
|
+
* @param prefix - Entity-group prefix.
|
|
869
|
+
* @param id - Entity id.
|
|
870
|
+
* @returns TTL in seconds; `-2` when the key does not exist, `-1` when it
|
|
871
|
+
* exists with no expiration.
|
|
872
|
+
*/
|
|
873
|
+
async ttl(prefix, id) {
|
|
874
|
+
return this.connection.getClient().ttl(this.keyBuilder.build(prefix, id));
|
|
875
|
+
}
|
|
876
|
+
/**
|
|
877
|
+
* Removes the TTL of a key, making it persistent.
|
|
878
|
+
*
|
|
879
|
+
* @param prefix - Entity-group prefix.
|
|
880
|
+
* @param id - Entity id.
|
|
881
|
+
* @returns `true` when a timeout was removed, `false` when the key has no TTL
|
|
882
|
+
* or does not exist.
|
|
883
|
+
*/
|
|
884
|
+
async persist(prefix, id) {
|
|
885
|
+
const result = await this.connection.getClient().persist(this.keyBuilder.build(prefix, id));
|
|
886
|
+
return result === 1;
|
|
887
|
+
}
|
|
888
|
+
// ─── Batch commands ────────────────────────────────────────────────────────
|
|
889
|
+
/**
|
|
890
|
+
* Reads many keys under the same prefix and deserializes each present value.
|
|
891
|
+
*
|
|
892
|
+
* @typeParam T - The expected decoded type of every value.
|
|
893
|
+
* @param prefix - Entity-group prefix shared by every id.
|
|
894
|
+
* @param ids - Entity ids to read. An empty list returns `[]` with no Redis call.
|
|
895
|
+
* @returns Values positionally aligned with `ids`; `null` for missing keys.
|
|
896
|
+
* @throws {CacheException} `DESERIALIZATION_FAILED` when any present payload is
|
|
897
|
+
* not decodable as `T`.
|
|
898
|
+
*/
|
|
899
|
+
async mget(prefix, ids) {
|
|
900
|
+
if (ids.length === 0) {
|
|
901
|
+
return [];
|
|
902
|
+
}
|
|
903
|
+
const keys = ids.map((id) => this.keyBuilder.build(prefix, id));
|
|
904
|
+
const values = await this.connection.getClient().mget(...keys);
|
|
905
|
+
return values.map((value) => value === null ? null : this.serializer.deserialize(value));
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* Writes many `[id, value]` pairs under the same prefix in one round trip.
|
|
909
|
+
*
|
|
910
|
+
* @typeParam T - The values' static type.
|
|
911
|
+
* @param prefix - Entity-group prefix shared by every entry.
|
|
912
|
+
* @param entries - `[id, value]` tuples. An empty list is a no-op (no Redis call).
|
|
913
|
+
* @returns Resolves once the write completes.
|
|
914
|
+
* @throws {CacheException} `SERIALIZATION_FAILED` when any value cannot be encoded.
|
|
915
|
+
*/
|
|
916
|
+
async mset(prefix, entries) {
|
|
917
|
+
if (entries.length === 0) {
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
const pairs = [];
|
|
921
|
+
for (const [id, value] of entries) {
|
|
922
|
+
pairs.push(this.keyBuilder.build(prefix, id), this.serializer.serialize(value));
|
|
923
|
+
}
|
|
924
|
+
await this.connection.getClient().mset(...pairs);
|
|
925
|
+
}
|
|
926
|
+
// ─── Hash commands ─────────────────────────────────────────────────────────
|
|
927
|
+
/**
|
|
928
|
+
* Reads one hash field and deserializes its value.
|
|
929
|
+
*
|
|
930
|
+
* @typeParam T - The expected decoded type.
|
|
931
|
+
* @param prefix - Entity-group prefix.
|
|
932
|
+
* @param id - Entity id (the hash key).
|
|
933
|
+
* @param field - Hash field name (kept raw, never serialized).
|
|
934
|
+
* @returns The decoded field value, or `null` when the field does not exist.
|
|
935
|
+
* @throws {CacheException} `DESERIALIZATION_FAILED` when the field payload is
|
|
936
|
+
* not decodable as `T`.
|
|
937
|
+
*/
|
|
938
|
+
async hget(prefix, id, field) {
|
|
939
|
+
const key = this.keyBuilder.build(prefix, id);
|
|
940
|
+
const raw = await this.connection.getClient().hget(key, field);
|
|
941
|
+
if (raw === null) {
|
|
942
|
+
return null;
|
|
943
|
+
}
|
|
944
|
+
return this.serializer.deserialize(raw);
|
|
945
|
+
}
|
|
946
|
+
/**
|
|
947
|
+
* Serializes and writes one hash field.
|
|
948
|
+
*
|
|
949
|
+
* @typeParam T - The value's static type.
|
|
950
|
+
* @param prefix - Entity-group prefix.
|
|
951
|
+
* @param id - Entity id (the hash key).
|
|
952
|
+
* @param field - Hash field name (kept raw, never serialized).
|
|
953
|
+
* @param value - The value to store (passed through the serializer).
|
|
954
|
+
* @returns `1` when the field is new, `0` when it overwrote an existing field.
|
|
955
|
+
* @throws {CacheException} `SERIALIZATION_FAILED` when `value` cannot be encoded.
|
|
956
|
+
*/
|
|
957
|
+
async hset(prefix, id, field, value) {
|
|
958
|
+
const key = this.keyBuilder.build(prefix, id);
|
|
959
|
+
return this.connection.getClient().hset(key, field, this.serializer.serialize(value));
|
|
960
|
+
}
|
|
961
|
+
/**
|
|
962
|
+
* Reads every field of a hash and deserializes each value.
|
|
963
|
+
*
|
|
964
|
+
* @typeParam T - The expected decoded type of every field value.
|
|
965
|
+
* @param prefix - Entity-group prefix.
|
|
966
|
+
* @param id - Entity id (the hash key).
|
|
967
|
+
* @returns A record of field → decoded value; `{}` when the hash does not exist.
|
|
968
|
+
* @throws {CacheException} `DESERIALIZATION_FAILED` when any field payload is
|
|
969
|
+
* not decodable as `T`.
|
|
970
|
+
*/
|
|
971
|
+
async hgetall(prefix, id) {
|
|
972
|
+
const key = this.keyBuilder.build(prefix, id);
|
|
973
|
+
const all = await this.connection.getClient().hgetall(key);
|
|
974
|
+
const entries = Object.entries(all).map(([field, raw]) => [
|
|
975
|
+
field,
|
|
976
|
+
this.serializer.deserialize(raw)
|
|
977
|
+
]);
|
|
978
|
+
return Object.fromEntries(entries);
|
|
979
|
+
}
|
|
980
|
+
/**
|
|
981
|
+
* Deletes one or more hash fields.
|
|
982
|
+
*
|
|
983
|
+
* @param prefix - Entity-group prefix.
|
|
984
|
+
* @param id - Entity id (the hash key).
|
|
985
|
+
* @param fields - Field names to delete. No fields is a no-op (no Redis call).
|
|
986
|
+
* @returns The number of fields actually removed.
|
|
987
|
+
*/
|
|
988
|
+
async hdel(prefix, id, ...fields) {
|
|
989
|
+
if (fields.length === 0) {
|
|
990
|
+
return 0;
|
|
991
|
+
}
|
|
992
|
+
const key = this.keyBuilder.build(prefix, id);
|
|
993
|
+
return this.connection.getClient().hdel(key, ...fields);
|
|
994
|
+
}
|
|
995
|
+
// ─── Set commands ──────────────────────────────────────────────────────────
|
|
996
|
+
/**
|
|
997
|
+
* Adds members to a set.
|
|
998
|
+
*
|
|
999
|
+
* Members are stored as raw strings — sets hold ids, not serialized objects,
|
|
1000
|
+
* so the serializer is intentionally not applied here.
|
|
1001
|
+
*
|
|
1002
|
+
* @param prefix - Entity-group prefix.
|
|
1003
|
+
* @param id - Entity id (the set key).
|
|
1004
|
+
* @param members - String members to add. No members is a no-op (no Redis call).
|
|
1005
|
+
* @returns The number of members newly added (excludes ones already present).
|
|
1006
|
+
*/
|
|
1007
|
+
async sadd(prefix, id, ...members) {
|
|
1008
|
+
if (members.length === 0) {
|
|
1009
|
+
return 0;
|
|
1010
|
+
}
|
|
1011
|
+
return this.connection.getClient().sadd(this.keyBuilder.build(prefix, id), ...members);
|
|
1012
|
+
}
|
|
1013
|
+
/**
|
|
1014
|
+
* Removes members from a set.
|
|
1015
|
+
*
|
|
1016
|
+
* @param prefix - Entity-group prefix.
|
|
1017
|
+
* @param id - Entity id (the set key).
|
|
1018
|
+
* @param members - String members to remove. No members is a no-op (no Redis call).
|
|
1019
|
+
* @returns The number of members actually removed.
|
|
1020
|
+
*/
|
|
1021
|
+
async srem(prefix, id, ...members) {
|
|
1022
|
+
if (members.length === 0) {
|
|
1023
|
+
return 0;
|
|
1024
|
+
}
|
|
1025
|
+
return this.connection.getClient().srem(this.keyBuilder.build(prefix, id), ...members);
|
|
1026
|
+
}
|
|
1027
|
+
/**
|
|
1028
|
+
* Reads every member of a set.
|
|
1029
|
+
*
|
|
1030
|
+
* @param prefix - Entity-group prefix.
|
|
1031
|
+
* @param id - Entity id (the set key).
|
|
1032
|
+
* @returns The raw string members; `[]` when the set does not exist.
|
|
1033
|
+
*/
|
|
1034
|
+
async smembers(prefix, id) {
|
|
1035
|
+
return this.connection.getClient().smembers(this.keyBuilder.build(prefix, id));
|
|
1036
|
+
}
|
|
1037
|
+
/**
|
|
1038
|
+
* Reports whether a member belongs to a set.
|
|
1039
|
+
*
|
|
1040
|
+
* @param prefix - Entity-group prefix.
|
|
1041
|
+
* @param id - Entity id (the set key).
|
|
1042
|
+
* @param member - The member to test.
|
|
1043
|
+
* @returns `true` when the member is present, otherwise `false`.
|
|
1044
|
+
*/
|
|
1045
|
+
async sismember(prefix, id, member) {
|
|
1046
|
+
const result = await this.connection.getClient().sismember(this.keyBuilder.build(prefix, id), member);
|
|
1047
|
+
return result === 1;
|
|
1048
|
+
}
|
|
1049
|
+
/**
|
|
1050
|
+
* Reads the cardinality of a set.
|
|
1051
|
+
*
|
|
1052
|
+
* @param prefix - Entity-group prefix.
|
|
1053
|
+
* @param id - Entity id (the set key).
|
|
1054
|
+
* @returns The member count; `0` when the set does not exist.
|
|
1055
|
+
*/
|
|
1056
|
+
async scard(prefix, id) {
|
|
1057
|
+
return this.connection.getClient().scard(this.keyBuilder.build(prefix, id));
|
|
1058
|
+
}
|
|
1059
|
+
// ─── Iteration ─────────────────────────────────────────────────────────────
|
|
1060
|
+
/**
|
|
1061
|
+
* Lists keys matching a pattern under a prefix.
|
|
1062
|
+
*
|
|
1063
|
+
* WARNING: `KEYS` is O(N) and BLOCKS the Redis server for the whole scan —
|
|
1064
|
+
* prefer {@link CacheService.scan} in production.
|
|
1065
|
+
*
|
|
1066
|
+
* @param prefix - Entity-group prefix.
|
|
1067
|
+
* @param pattern - Glob pattern for the id segment, e.g. `'*'`.
|
|
1068
|
+
* @returns The matching fully-namespaced keys.
|
|
1069
|
+
* @example
|
|
1070
|
+
* ```ts
|
|
1071
|
+
* await cache.keys('users', '*') // ['app:users:u_1', 'app:users:u_2']
|
|
1072
|
+
* ```
|
|
1073
|
+
*/
|
|
1074
|
+
async keys(prefix, pattern) {
|
|
1075
|
+
const fullPattern = this.keyBuilder.build(prefix, pattern);
|
|
1076
|
+
return this.connection.getClient().keys(fullPattern);
|
|
1077
|
+
}
|
|
1078
|
+
/**
|
|
1079
|
+
* Iterates keys matching a pattern under a prefix using a non-blocking cursor.
|
|
1080
|
+
*
|
|
1081
|
+
* Safe for production: `SCAN` never blocks the server. Standalone / sentinel
|
|
1082
|
+
* only — Cluster exposes different scan semantics and is rejected.
|
|
1083
|
+
*
|
|
1084
|
+
* @param prefix - Entity-group prefix.
|
|
1085
|
+
* @param pattern - Glob pattern for the id segment, e.g. `'*'`.
|
|
1086
|
+
* @param count - Per-batch hint passed to `SCAN` (not a hard limit).
|
|
1087
|
+
* @returns An async iterable of fully-namespaced keys.
|
|
1088
|
+
* @throws {CacheException} `UNSUPPORTED_IN_CLUSTER` when called in cluster mode
|
|
1089
|
+
* (no usable top-level `scanStream`).
|
|
1090
|
+
* @example
|
|
1091
|
+
* ```ts
|
|
1092
|
+
* for await (const key of cache.scan('users', '*')) {
|
|
1093
|
+
* // key === 'app:users:u_1'
|
|
1094
|
+
* }
|
|
1095
|
+
* ```
|
|
1096
|
+
*/
|
|
1097
|
+
async *scan(prefix, pattern, count = SCAN_COUNT) {
|
|
1098
|
+
const fullPattern = this.keyBuilder.build(prefix, pattern);
|
|
1099
|
+
const client = this.connection.getClient();
|
|
1100
|
+
if (!isScannableClient(client)) {
|
|
1101
|
+
throw new CacheException(CACHE_ERROR_CODES.UNSUPPORTED_IN_CLUSTER, { operation: "scan" });
|
|
1102
|
+
}
|
|
1103
|
+
const stream = client.scanStream({ match: fullPattern, count });
|
|
1104
|
+
for await (const chunk of stream) {
|
|
1105
|
+
for (const key of chunk) {
|
|
1106
|
+
yield key;
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
// ─── Pipeline / escape hatch ───────────────────────────────────────────────
|
|
1111
|
+
/**
|
|
1112
|
+
* Opens an ioredis pipeline for batching arbitrary commands.
|
|
1113
|
+
*
|
|
1114
|
+
* NOTE: keys passed to pipeline commands are NOT auto-namespaced — compose
|
|
1115
|
+
* them through {@link KeyBuilder} yourself.
|
|
1116
|
+
*
|
|
1117
|
+
* @returns A chainable commander; call `.exec()` to flush.
|
|
1118
|
+
* @example
|
|
1119
|
+
* ```ts
|
|
1120
|
+
* const pipe = cache.pipeline()
|
|
1121
|
+
* pipe.set(keyBuilder.build('p', 'a'), '1')
|
|
1122
|
+
* pipe.set(keyBuilder.build('p', 'b'), '2')
|
|
1123
|
+
* await pipe.exec()
|
|
1124
|
+
* ```
|
|
1125
|
+
*/
|
|
1126
|
+
pipeline() {
|
|
1127
|
+
return this.connection.getClient().pipeline();
|
|
1128
|
+
}
|
|
1129
|
+
/**
|
|
1130
|
+
* Returns the raw ioredis client (escape hatch).
|
|
1131
|
+
*
|
|
1132
|
+
* Keys used through the returned client are NOT auto-namespaced. Reach for
|
|
1133
|
+
* this only to run a command this facade does not expose.
|
|
1134
|
+
*
|
|
1135
|
+
* @returns The singleton ioredis client.
|
|
1136
|
+
* @throws {CacheException} `UNSUPPORTED_IN_CLUSTER` when called in cluster
|
|
1137
|
+
* mode — `Cluster` does not share the full `Redis` API surface.
|
|
1138
|
+
*/
|
|
1139
|
+
getClient() {
|
|
1140
|
+
const client = this.connection.getClient();
|
|
1141
|
+
if (!isScannableClient(client)) {
|
|
1142
|
+
throw new CacheException(CACHE_ERROR_CODES.UNSUPPORTED_IN_CLUSTER, { operation: "getClient" });
|
|
1143
|
+
}
|
|
1144
|
+
return client;
|
|
1145
|
+
}
|
|
1146
|
+
// ─── Destructive maintenance ───────────────────────────────────────────────
|
|
1147
|
+
/**
|
|
1148
|
+
* Deletes EVERY key under the configured namespace via `SCAN` + `UNLINK`.
|
|
1149
|
+
*
|
|
1150
|
+
* Uses `UNLINK` (asynchronous reclaim) rather than `DEL` so a large keyset
|
|
1151
|
+
* does not block the server. The `SCAN` pattern is scoped to
|
|
1152
|
+
* `{namespace}{sep}*`, so keys of other namespaces are never touched.
|
|
1153
|
+
*
|
|
1154
|
+
* SAFETY: throws {@link CacheException} `FLUSH_DISABLED_IN_PRODUCTION` when
|
|
1155
|
+
* `NODE_ENV === 'production'` unless `options.allowFlushInProduction` is `true`.
|
|
1156
|
+
* Intended for tests and tooling — in production prefer
|
|
1157
|
+
* {@link CacheService.del} / {@link CacheService.delMany}.
|
|
1158
|
+
*
|
|
1159
|
+
* @returns The total number of keys removed.
|
|
1160
|
+
* @throws {CacheException} `FLUSH_DISABLED_IN_PRODUCTION` under the production guard.
|
|
1161
|
+
* @throws {CacheException} `UNSUPPORTED_IN_CLUSTER` when called in cluster mode
|
|
1162
|
+
* (no usable top-level `scanStream`).
|
|
1163
|
+
*/
|
|
1164
|
+
async flushNamespace() {
|
|
1165
|
+
if (process.env["NODE_ENV"] === PRODUCTION_ENV && !this.options.allowFlushInProduction) {
|
|
1166
|
+
throw new CacheException(CACHE_ERROR_CODES.FLUSH_DISABLED_IN_PRODUCTION);
|
|
1167
|
+
}
|
|
1168
|
+
const client = this.connection.getClient();
|
|
1169
|
+
if (!isScannableClient(client)) {
|
|
1170
|
+
throw new CacheException(CACHE_ERROR_CODES.UNSUPPORTED_IN_CLUSTER, {
|
|
1171
|
+
operation: "flushNamespace"
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
const pattern = `${this.keyBuilder.getNamespacePrefix()}*`;
|
|
1175
|
+
const stream = client.scanStream({ match: pattern, count: FLUSH_SCAN_COUNT });
|
|
1176
|
+
let total = 0;
|
|
1177
|
+
for await (const chunk of stream) {
|
|
1178
|
+
const keys = chunk;
|
|
1179
|
+
if (keys.length > 0) {
|
|
1180
|
+
total += await client.unlink(...keys);
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
return total;
|
|
1184
|
+
}
|
|
1185
|
+
// ─── Lua scripts ───────────────────────────────────────────────────────────
|
|
1186
|
+
/**
|
|
1187
|
+
* Executes a Lua script registered with the {@link ScriptManagerService}. The
|
|
1188
|
+
* `keys` are namespaced before reaching Redis (the same isolation guarantee as
|
|
1189
|
+
* every other command); `args` are passed through untouched.
|
|
1190
|
+
*
|
|
1191
|
+
* @param scriptName - Name the script was registered under (via `options.scripts`
|
|
1192
|
+
* or `ScriptManagerService.register`).
|
|
1193
|
+
* @param keys - `KEYS[]` (bare; namespaced here before execution).
|
|
1194
|
+
* @param args - `ARGV[]` for the script.
|
|
1195
|
+
* @returns The script's return value, typed `unknown` (Redis Lua is dynamic).
|
|
1196
|
+
* @throws {CacheException} `SCRIPT_REGISTRY_MISSING` when no script manager is
|
|
1197
|
+
* wired (the module always wires one; this guards manual instantiations).
|
|
1198
|
+
* @throws {CacheException} `SCRIPT_NOT_REGISTERED` / `SCRIPT_EXECUTION_FAILED`
|
|
1199
|
+
* propagated from the script manager.
|
|
1200
|
+
* @example
|
|
1201
|
+
* ```ts
|
|
1202
|
+
* const swapped = (await cache.eval('compareAndSet', ['session:abc'], ['v1', 'v2'])) as number
|
|
1203
|
+
* ```
|
|
1204
|
+
*/
|
|
1205
|
+
async eval(scriptName, keys, args) {
|
|
1206
|
+
if (!this.scriptRegistry) {
|
|
1207
|
+
throw new CacheException(CACHE_ERROR_CODES.SCRIPT_REGISTRY_MISSING);
|
|
1208
|
+
}
|
|
1209
|
+
const namespacedKeys = keys.map((key) => this.keyBuilder.applyNamespace(key));
|
|
1210
|
+
return this.scriptRegistry.eval(scriptName, namespacedKeys, args);
|
|
1211
|
+
}
|
|
1212
|
+
// ─── Health ────────────────────────────────────────────────────────────────
|
|
1213
|
+
/**
|
|
1214
|
+
* Reports whether Redis answers `PING`. Never throws — a connection failure
|
|
1215
|
+
* resolves to `false`, making it safe to wire directly into a health endpoint
|
|
1216
|
+
* (e.g. `@nestjs/terminus`).
|
|
1217
|
+
*
|
|
1218
|
+
* @returns `true` when the server replies `PONG`, otherwise `false`.
|
|
1219
|
+
*/
|
|
1220
|
+
async isHealthy() {
|
|
1221
|
+
try {
|
|
1222
|
+
const pong = await this.connection.getClient().ping();
|
|
1223
|
+
return pong === "PONG";
|
|
1224
|
+
} catch {
|
|
1225
|
+
return false;
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
/**
|
|
1229
|
+
* Sends a raw `PING`. Unlike {@link CacheService.isHealthy}, this propagates a
|
|
1230
|
+
* connection failure — use it when the caller wants to handle the error.
|
|
1231
|
+
*
|
|
1232
|
+
* @returns `'PONG'` on a healthy connection.
|
|
1233
|
+
* @throws The underlying ioredis error when the connection is down.
|
|
1234
|
+
*/
|
|
1235
|
+
async ping() {
|
|
1236
|
+
return this.connection.getClient().ping();
|
|
1237
|
+
}
|
|
1238
|
+
/**
|
|
1239
|
+
* Returns the Redis `INFO` output, optionally scoped to a single section.
|
|
1240
|
+
*
|
|
1241
|
+
* @param section - Optional section name (e.g. `'memory'`, `'clients'`,
|
|
1242
|
+
* `'replication'`); omit for the full report.
|
|
1243
|
+
* @returns The `INFO` text.
|
|
1244
|
+
*/
|
|
1245
|
+
async info(section) {
|
|
1246
|
+
const client = this.connection.getClient();
|
|
1247
|
+
return section === void 0 ? client.info() : client.info(section);
|
|
1248
|
+
}
|
|
1249
|
+
};
|
|
1250
|
+
CacheService = __decorateClass([
|
|
1251
|
+
Injectable(),
|
|
1252
|
+
__decorateParam(0, Inject(BYMAX_CACHE_OPTIONS)),
|
|
1253
|
+
__decorateParam(1, Inject(ConnectionManager)),
|
|
1254
|
+
__decorateParam(2, Inject(KeyBuilder)),
|
|
1255
|
+
__decorateParam(3, Optional()),
|
|
1256
|
+
__decorateParam(3, Inject(BYMAX_CACHE_SERIALIZER)),
|
|
1257
|
+
__decorateParam(4, Optional()),
|
|
1258
|
+
__decorateParam(4, Inject(ScriptManagerService))
|
|
1259
|
+
], CacheService);
|
|
1260
|
+
var PubSubService = class {
|
|
1261
|
+
/**
|
|
1262
|
+
* @param options - Resolved module options. Supplies the serializer.
|
|
1263
|
+
* @param connection - Owns the main client and mints subscriber connections.
|
|
1264
|
+
* Explicit `@Inject` — the published bundle is built without
|
|
1265
|
+
* emitDecoratorMetadata, so type-only DI cannot resolve a class provider
|
|
1266
|
+
* (CLAUDE.md §5).
|
|
1267
|
+
* @param keyBuilder - Namespaces every channel/pattern.
|
|
1268
|
+
* @param injectedSerializer - Optional `BYMAX_CACHE_SERIALIZER` provider.
|
|
1269
|
+
* @param events - Optional consumer observability callback bag; a swallowed
|
|
1270
|
+
* handler/deserialization failure is forwarded to it instead of vanishing.
|
|
1271
|
+
*/
|
|
1272
|
+
constructor(options, connection, keyBuilder, injectedSerializer, events) {
|
|
1273
|
+
this.connection = connection;
|
|
1274
|
+
this.keyBuilder = keyBuilder;
|
|
1275
|
+
this.events = events;
|
|
1276
|
+
/** Lazily-created dedicated subscriber connection (null until first subscribe). */
|
|
1277
|
+
this.subscriber = null;
|
|
1278
|
+
/** Live-listener ref-count per namespaced channel; UNSUBSCRIBE fires on the last. */
|
|
1279
|
+
this.channelRefs = /* @__PURE__ */ new Map();
|
|
1280
|
+
/** Live-listener ref-count per namespaced pattern; PUNSUBSCRIBE fires on the last. */
|
|
1281
|
+
this.patternRefs = /* @__PURE__ */ new Map();
|
|
1282
|
+
this.serializer = resolveSerializer(options, injectedSerializer);
|
|
1283
|
+
}
|
|
1284
|
+
/**
|
|
1285
|
+
* Publishes a serialized message to a namespaced channel via the main client.
|
|
1286
|
+
*
|
|
1287
|
+
* @typeParam T - The message payload type.
|
|
1288
|
+
* @param channel - Bare channel name (namespaced before publishing).
|
|
1289
|
+
* @param message - Payload, encoded through the configured serializer.
|
|
1290
|
+
* @returns The number of subscribers that received the message.
|
|
1291
|
+
* @throws {CacheException} `SERIALIZATION_FAILED` when `message` cannot be encoded.
|
|
1292
|
+
*/
|
|
1293
|
+
async publish(channel, message) {
|
|
1294
|
+
const fullChannel = this.keyBuilder.applyNamespace(channel);
|
|
1295
|
+
const raw = this.serializer.serialize(message);
|
|
1296
|
+
return this.connection.getClient().publish(fullChannel, raw);
|
|
1297
|
+
}
|
|
1298
|
+
/**
|
|
1299
|
+
* Subscribes to a namespaced channel. Opens the subscriber connection lazily;
|
|
1300
|
+
* subsequent subscriptions reuse the same connection.
|
|
1301
|
+
*
|
|
1302
|
+
* The handler receives the deserialized message and the full namespaced
|
|
1303
|
+
* channel. A throw inside the handler (or a malformed payload) is swallowed so
|
|
1304
|
+
* it cannot tear down the shared subscriber.
|
|
1305
|
+
*
|
|
1306
|
+
* @typeParam T - The expected message payload type.
|
|
1307
|
+
* @param channel - Bare channel name (namespaced before subscribing).
|
|
1308
|
+
* @param handler - Invoked per message with `(message, channel)`.
|
|
1309
|
+
* @returns An {@link Unsubscribe} that detaches THIS listener; the channel is
|
|
1310
|
+
* only UNSUBSCRIBE'd once its last listener is removed, so unsubscribing one
|
|
1311
|
+
* handler never breaks others subscribed to the same channel.
|
|
1312
|
+
*/
|
|
1313
|
+
async subscribe(channel, handler) {
|
|
1314
|
+
const fullChannel = this.keyBuilder.applyNamespace(channel);
|
|
1315
|
+
const subscriber = this.ensureSubscriber();
|
|
1316
|
+
const ref = await this.retainSubscription(
|
|
1317
|
+
this.channelRefs,
|
|
1318
|
+
fullChannel,
|
|
1319
|
+
() => subscriber.subscribe(fullChannel)
|
|
1320
|
+
);
|
|
1321
|
+
const listener = (incoming, raw) => {
|
|
1322
|
+
if (incoming !== fullChannel) {
|
|
1323
|
+
return;
|
|
1324
|
+
}
|
|
1325
|
+
Promise.resolve().then(() => handler(this.serializer.deserialize(raw), incoming)).catch((error) => {
|
|
1326
|
+
this.emitHandlerError(incoming, error);
|
|
1327
|
+
});
|
|
1328
|
+
};
|
|
1329
|
+
subscriber.on("message", listener);
|
|
1330
|
+
return this.makeUnsubscribe(
|
|
1331
|
+
() => subscriber.off("message", listener),
|
|
1332
|
+
() => this.releaseSubscription(
|
|
1333
|
+
this.channelRefs,
|
|
1334
|
+
fullChannel,
|
|
1335
|
+
ref,
|
|
1336
|
+
() => subscriber.unsubscribe(fullChannel)
|
|
1337
|
+
)
|
|
1338
|
+
);
|
|
1339
|
+
}
|
|
1340
|
+
/**
|
|
1341
|
+
* Pattern-subscribes to a namespaced glob (e.g. `'users:*'`). Lazily opens the
|
|
1342
|
+
* subscriber connection, shared with {@link PubSubService.subscribe}.
|
|
1343
|
+
*
|
|
1344
|
+
* @typeParam T - The expected message payload type.
|
|
1345
|
+
* @param pattern - Bare glob pattern (namespaced before subscribing).
|
|
1346
|
+
* @param handler - Invoked per message with `(message, channel, pattern)`,
|
|
1347
|
+
* both in their full namespaced form.
|
|
1348
|
+
* @returns An {@link Unsubscribe} that detaches THIS listener; the pattern is
|
|
1349
|
+
* only PUNSUBSCRIBE'd once its last listener is removed.
|
|
1350
|
+
*/
|
|
1351
|
+
async psubscribe(pattern, handler) {
|
|
1352
|
+
const fullPattern = this.keyBuilder.applyNamespace(pattern);
|
|
1353
|
+
const subscriber = this.ensureSubscriber();
|
|
1354
|
+
const ref = await this.retainSubscription(
|
|
1355
|
+
this.patternRefs,
|
|
1356
|
+
fullPattern,
|
|
1357
|
+
() => subscriber.psubscribe(fullPattern)
|
|
1358
|
+
);
|
|
1359
|
+
const listener = (matchedPattern, channel, raw) => {
|
|
1360
|
+
if (matchedPattern !== fullPattern) {
|
|
1361
|
+
return;
|
|
1362
|
+
}
|
|
1363
|
+
Promise.resolve().then(() => handler(this.serializer.deserialize(raw), channel, matchedPattern)).catch((error) => {
|
|
1364
|
+
this.emitHandlerError(channel, error);
|
|
1365
|
+
});
|
|
1366
|
+
};
|
|
1367
|
+
subscriber.on("pmessage", listener);
|
|
1368
|
+
return this.makeUnsubscribe(
|
|
1369
|
+
() => subscriber.off("pmessage", listener),
|
|
1370
|
+
() => this.releaseSubscription(
|
|
1371
|
+
this.patternRefs,
|
|
1372
|
+
fullPattern,
|
|
1373
|
+
ref,
|
|
1374
|
+
() => subscriber.punsubscribe(fullPattern)
|
|
1375
|
+
)
|
|
1376
|
+
);
|
|
1377
|
+
}
|
|
1378
|
+
/** Closes the subscriber connection gracefully, forcing disconnect on failure. */
|
|
1379
|
+
async onModuleDestroy() {
|
|
1380
|
+
if (!this.subscriber) {
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1383
|
+
const subscriber = this.subscriber;
|
|
1384
|
+
this.subscriber = null;
|
|
1385
|
+
try {
|
|
1386
|
+
await subscriber.quit();
|
|
1387
|
+
} catch {
|
|
1388
|
+
subscriber.disconnect();
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
// ─── Private ───────────────────────────────────────────────────────────────
|
|
1392
|
+
/**
|
|
1393
|
+
* Returns the dedicated subscriber connection, creating it on first use.
|
|
1394
|
+
*
|
|
1395
|
+
* Reused for the lifetime of the module; ioredis transparently reconnects and
|
|
1396
|
+
* re-subscribes, so a single connection is kept rather than recreated. Typed as
|
|
1397
|
+
* the `Redis | Cluster` union the connection manager mints — `subscribe` /
|
|
1398
|
+
* `psubscribe` exist on both, so no cast is needed (cluster Pub/Sub is an
|
|
1399
|
+
* experimental passthrough per the spec).
|
|
1400
|
+
*/
|
|
1401
|
+
ensureSubscriber() {
|
|
1402
|
+
if (!this.subscriber) {
|
|
1403
|
+
this.subscriber = this.connection.createSubscriberClient();
|
|
1404
|
+
}
|
|
1405
|
+
return this.subscriber;
|
|
1406
|
+
}
|
|
1407
|
+
/**
|
|
1408
|
+
* Subscribes the target (channel or pattern) only when it gains its FIRST
|
|
1409
|
+
* listener, then increments and returns its shared {@link SubscriptionRef} so a
|
|
1410
|
+
* later release knows when to issue the matching UNSUBSCRIBE / PUNSUBSCRIBE.
|
|
1411
|
+
*
|
|
1412
|
+
* @param refs - The channel or pattern ref-count map.
|
|
1413
|
+
* @param target - The full namespaced channel/pattern.
|
|
1414
|
+
* @param subscribe - Issues the SUBSCRIBE / PSUBSCRIBE for `target`.
|
|
1415
|
+
* @returns The shared ref for `target` (captured by the unsubscribe closure).
|
|
1416
|
+
*/
|
|
1417
|
+
async retainSubscription(refs, target, subscribe) {
|
|
1418
|
+
let ref = refs.get(target);
|
|
1419
|
+
if (!ref) {
|
|
1420
|
+
ref = { count: 0 };
|
|
1421
|
+
refs.set(target, ref);
|
|
1422
|
+
await subscribe();
|
|
1423
|
+
}
|
|
1424
|
+
ref.count += 1;
|
|
1425
|
+
return ref;
|
|
1426
|
+
}
|
|
1427
|
+
/**
|
|
1428
|
+
* Decrements the (closure-captured) ref and issues the UNSUBSCRIBE / PUNSUBSCRIBE
|
|
1429
|
+
* only when the LAST listener is removed — so unsubscribing one handler never
|
|
1430
|
+
* silently stops delivery to the others on the same channel/pattern.
|
|
1431
|
+
*
|
|
1432
|
+
* @param refs - The channel or pattern ref-count map.
|
|
1433
|
+
* @param target - The full namespaced channel/pattern.
|
|
1434
|
+
* @param ref - The shared ref returned by {@link retainSubscription}.
|
|
1435
|
+
* @param unsubscribe - Issues the UNSUBSCRIBE / PUNSUBSCRIBE for `target`.
|
|
1436
|
+
*/
|
|
1437
|
+
async releaseSubscription(refs, target, ref, unsubscribe) {
|
|
1438
|
+
ref.count -= 1;
|
|
1439
|
+
if (ref.count === 0) {
|
|
1440
|
+
refs.delete(target);
|
|
1441
|
+
await unsubscribe();
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
/**
|
|
1445
|
+
* Builds an idempotent {@link Unsubscribe}: the first call detaches the listener
|
|
1446
|
+
* and releases the subscription; later calls are no-ops, so a double unsubscribe
|
|
1447
|
+
* cannot over-decrement a shared channel/pattern.
|
|
1448
|
+
*
|
|
1449
|
+
* @param detach - Removes this subscription's event listener.
|
|
1450
|
+
* @param release - Decrements the reference count (see {@link releaseSubscription}).
|
|
1451
|
+
*/
|
|
1452
|
+
makeUnsubscribe(detach, release) {
|
|
1453
|
+
let released = false;
|
|
1454
|
+
return async () => {
|
|
1455
|
+
if (released) {
|
|
1456
|
+
return;
|
|
1457
|
+
}
|
|
1458
|
+
released = true;
|
|
1459
|
+
detach();
|
|
1460
|
+
await release();
|
|
1461
|
+
};
|
|
1462
|
+
}
|
|
1463
|
+
/**
|
|
1464
|
+
* Forwards a swallowed handler / deserialization failure to the optional
|
|
1465
|
+
* observability callback, itself swallowing any throw from `onEvent` — a
|
|
1466
|
+
* faulty consumer (handler OR callback) must never tear down the subscriber.
|
|
1467
|
+
* The error surfaces as an `'error'` event with `reason: 'handler_error'`.
|
|
1468
|
+
*
|
|
1469
|
+
* @param channel - The full namespaced channel the failed message arrived on.
|
|
1470
|
+
* @param error - The caught handler / deserialization failure.
|
|
1471
|
+
*/
|
|
1472
|
+
emitHandlerError(channel, error) {
|
|
1473
|
+
try {
|
|
1474
|
+
this.events?.onEvent?.("error", {
|
|
1475
|
+
role: "subscriber",
|
|
1476
|
+
reason: "handler_error",
|
|
1477
|
+
channel,
|
|
1478
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1479
|
+
});
|
|
1480
|
+
} catch {
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
};
|
|
1484
|
+
PubSubService = __decorateClass([
|
|
1485
|
+
Injectable(),
|
|
1486
|
+
__decorateParam(0, Inject(BYMAX_CACHE_OPTIONS)),
|
|
1487
|
+
__decorateParam(1, Inject(ConnectionManager)),
|
|
1488
|
+
__decorateParam(2, Inject(KeyBuilder)),
|
|
1489
|
+
__decorateParam(3, Optional()),
|
|
1490
|
+
__decorateParam(3, Inject(BYMAX_CACHE_SERIALIZER)),
|
|
1491
|
+
__decorateParam(4, Optional()),
|
|
1492
|
+
__decorateParam(4, Inject(BYMAX_CACHE_EVENTS))
|
|
1493
|
+
], PubSubService);
|
|
1494
|
+
|
|
1495
|
+
// src/server/bymax-cache.module.ts
|
|
1496
|
+
var BymaxCacheModule = class extends ConfigurableModuleClass {
|
|
1497
|
+
/**
|
|
1498
|
+
* Registers the cache module synchronously.
|
|
1499
|
+
*
|
|
1500
|
+
* @param options - Consumer options; validated and defaulted at registration.
|
|
1501
|
+
* @returns The configured {@link DynamicModule}.
|
|
1502
|
+
* @throws {import('./errors/cache.exception').CacheException} When the options
|
|
1503
|
+
* fail bootstrap validation (e.g. missing connection, misconfigured mode).
|
|
1504
|
+
*/
|
|
1505
|
+
static forRoot(options) {
|
|
1506
|
+
validateOptions(options);
|
|
1507
|
+
const resolved = applyDefaults(options);
|
|
1508
|
+
const providers = [
|
|
1509
|
+
{ provide: MODULE_OPTIONS_TOKEN, useValue: options },
|
|
1510
|
+
{ provide: BYMAX_CACHE_OPTIONS, useValue: resolved },
|
|
1511
|
+
{ provide: BYMAX_CACHE_EVENTS, useValue: resolved.events ?? null },
|
|
1512
|
+
resolved.serializer ? { provide: BYMAX_CACHE_SERIALIZER, useValue: resolved.serializer } : { provide: BYMAX_CACHE_SERIALIZER, useClass: JsonSerializer },
|
|
1513
|
+
...BymaxCacheModule.buildCommonProviders()
|
|
1514
|
+
];
|
|
1515
|
+
return {
|
|
1516
|
+
module: BymaxCacheModule,
|
|
1517
|
+
global: resolved.isGlobal,
|
|
1518
|
+
providers,
|
|
1519
|
+
exports: BymaxCacheModule.buildCommonExports()
|
|
1520
|
+
};
|
|
1521
|
+
}
|
|
1522
|
+
/**
|
|
1523
|
+
* Registers the cache module asynchronously, resolving its options through a
|
|
1524
|
+
* consumer-supplied factory (e.g. reading from `ConfigService`).
|
|
1525
|
+
*
|
|
1526
|
+
* @remarks
|
|
1527
|
+
* Delegates the async-options plumbing (the `MODULE_OPTIONS_TOKEN` factory,
|
|
1528
|
+
* `inject`, `imports`, and the `global` flag) to the {@link ConfigurableModuleClass}
|
|
1529
|
+
* base, then augments the produced module with the cache providers. Options are
|
|
1530
|
+
* validated and defaulted INSIDE the `BYMAX_CACHE_OPTIONS` factory — which
|
|
1531
|
+
* injects the base `MODULE_OPTIONS_TOKEN` — so a misconfiguration surfaces during
|
|
1532
|
+
* bootstrap exactly as it does for {@link BymaxCacheModule.forRoot}. The events
|
|
1533
|
+
* and serializer providers are factories deriving from the resolved options,
|
|
1534
|
+
* since those values are not known until the async factory runs.
|
|
1535
|
+
* @param options - Async registration options: a `useFactory` returning the
|
|
1536
|
+
* module options, plus optional `inject`, `imports`, and `isGlobal` (the
|
|
1537
|
+
* builder defaults `isGlobal` to `true`).
|
|
1538
|
+
* @returns The configured {@link DynamicModule}.
|
|
1539
|
+
* @throws {import('./errors/cache.exception').CacheException} During bootstrap,
|
|
1540
|
+
* when the factory-resolved options fail validation.
|
|
1541
|
+
*/
|
|
1542
|
+
static forRootAsync(options) {
|
|
1543
|
+
const base = super.forRootAsync(options);
|
|
1544
|
+
const baseProviders = base.providers ?? [];
|
|
1545
|
+
return {
|
|
1546
|
+
...base,
|
|
1547
|
+
providers: [...baseProviders, ...BymaxCacheModule.buildAsyncProviders()],
|
|
1548
|
+
exports: BymaxCacheModule.buildCommonExports()
|
|
1549
|
+
};
|
|
1550
|
+
}
|
|
1551
|
+
/**
|
|
1552
|
+
* The cache providers layered onto the base async module: the resolved-options
|
|
1553
|
+
* provider (which validates + defaults the raw factory result read from
|
|
1554
|
+
* `MODULE_OPTIONS_TOKEN`), the derived events and serializer providers, and the
|
|
1555
|
+
* topology-independent {@link BymaxCacheModule.buildCommonProviders}. Mirrors the
|
|
1556
|
+
* options/events/serializer wiring `forRoot` performs synchronously.
|
|
1557
|
+
*
|
|
1558
|
+
* @returns The async-only provider list.
|
|
1559
|
+
*/
|
|
1560
|
+
static buildAsyncProviders() {
|
|
1561
|
+
return [
|
|
1562
|
+
{
|
|
1563
|
+
provide: BYMAX_CACHE_OPTIONS,
|
|
1564
|
+
useFactory: (raw) => {
|
|
1565
|
+
validateOptions(raw);
|
|
1566
|
+
return applyDefaults(raw);
|
|
1567
|
+
},
|
|
1568
|
+
inject: [MODULE_OPTIONS_TOKEN]
|
|
1569
|
+
},
|
|
1570
|
+
{
|
|
1571
|
+
provide: BYMAX_CACHE_EVENTS,
|
|
1572
|
+
useFactory: (resolved) => resolved.events ?? null,
|
|
1573
|
+
inject: [BYMAX_CACHE_OPTIONS]
|
|
1574
|
+
},
|
|
1575
|
+
// Let Nest own the default serializer's lifecycle — matching forRoot's
|
|
1576
|
+
// `useClass: JsonSerializer` — so the factory selects a consumer-supplied
|
|
1577
|
+
// serializer over a container-managed default rather than `new`-ing one.
|
|
1578
|
+
JsonSerializer,
|
|
1579
|
+
{
|
|
1580
|
+
provide: BYMAX_CACHE_SERIALIZER,
|
|
1581
|
+
useFactory: (resolved, defaultSerializer) => resolved.serializer ?? defaultSerializer,
|
|
1582
|
+
inject: [BYMAX_CACHE_OPTIONS, JsonSerializer]
|
|
1583
|
+
},
|
|
1584
|
+
...BymaxCacheModule.buildCommonProviders()
|
|
1585
|
+
];
|
|
1586
|
+
}
|
|
1587
|
+
/**
|
|
1588
|
+
* Topology-independent providers shared by `forRoot` and `forRootAsync` — the
|
|
1589
|
+
* connection manager, key builder, cache services, and the `useExisting`
|
|
1590
|
+
* aliases for the key-builder and script-registry tokens. The options, events,
|
|
1591
|
+
* and serializer providers are NOT here: they differ between the sync
|
|
1592
|
+
* (`useValue`) and async (`useFactory`) entry points.
|
|
1593
|
+
*
|
|
1594
|
+
* @returns The common provider list.
|
|
1595
|
+
*/
|
|
1596
|
+
static buildCommonProviders() {
|
|
1597
|
+
return [
|
|
1598
|
+
ConnectionManager,
|
|
1599
|
+
KeyBuilder,
|
|
1600
|
+
CacheService,
|
|
1601
|
+
PubSubService,
|
|
1602
|
+
ScriptManagerService,
|
|
1603
|
+
{ provide: BYMAX_CACHE_KEY_BUILDER, useExisting: KeyBuilder },
|
|
1604
|
+
{ provide: BYMAX_CACHE_SCRIPT_REGISTRY, useExisting: ScriptManagerService },
|
|
1605
|
+
{ provide: BYMAX_CACHE_CONNECTION, useExisting: ConnectionManager }
|
|
1606
|
+
];
|
|
1607
|
+
}
|
|
1608
|
+
/**
|
|
1609
|
+
* Tokens and services exported by both `forRoot` and `forRootAsync`.
|
|
1610
|
+
*
|
|
1611
|
+
* @returns The common export list.
|
|
1612
|
+
*/
|
|
1613
|
+
static buildCommonExports() {
|
|
1614
|
+
return [
|
|
1615
|
+
BYMAX_CACHE_OPTIONS,
|
|
1616
|
+
BYMAX_CACHE_CONNECTION,
|
|
1617
|
+
BYMAX_CACHE_KEY_BUILDER,
|
|
1618
|
+
BYMAX_CACHE_SCRIPT_REGISTRY,
|
|
1619
|
+
BYMAX_CACHE_SERIALIZER,
|
|
1620
|
+
ConnectionManager,
|
|
1621
|
+
KeyBuilder,
|
|
1622
|
+
CacheService,
|
|
1623
|
+
PubSubService,
|
|
1624
|
+
ScriptManagerService
|
|
1625
|
+
];
|
|
1626
|
+
}
|
|
1627
|
+
};
|
|
1628
|
+
BymaxCacheModule = __decorateClass([
|
|
1629
|
+
Module({})
|
|
1630
|
+
], BymaxCacheModule);
|
|
1631
|
+
|
|
1632
|
+
// src/shared/constants/event-names.ts
|
|
1633
|
+
var CACHE_EVENT_NAMES = {
|
|
1634
|
+
CONNECT: "connect",
|
|
1635
|
+
READY: "ready",
|
|
1636
|
+
ERROR: "error",
|
|
1637
|
+
CLOSE: "close",
|
|
1638
|
+
RECONNECTING: "reconnecting",
|
|
1639
|
+
END: "end"
|
|
1640
|
+
};
|
|
1641
|
+
|
|
1642
|
+
export { BYMAX_CACHE_CONNECTION, BYMAX_CACHE_EVENTS, BYMAX_CACHE_KEY_BUILDER, BYMAX_CACHE_OPTIONS, BYMAX_CACHE_SCRIPT_REGISTRY, BYMAX_CACHE_SERIALIZER, BymaxCacheModule, CACHE_ERROR_CODES, CACHE_ERROR_MESSAGES, CACHE_EVENT_NAMES, CacheException, CacheService, ConnectionManager, JsonSerializer, KeyBuilder, PubSubService, ScriptManagerService };
|