@nestjs-redisx/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/LICENSE +21 -0
- package/README.md +50 -0
- package/dist/cache/api/decorators/cached.decorator.d.ts +152 -0
- package/dist/cache/api/decorators/cached.decorator.d.ts.map +1 -0
- package/dist/cache/api/decorators/invalidate-tags.decorator.d.ts +44 -0
- package/dist/cache/api/decorators/invalidate-tags.decorator.d.ts.map +1 -0
- package/dist/cache/application/ports/cache-service.port.d.ts +120 -0
- package/dist/cache/application/ports/cache-service.port.d.ts.map +1 -0
- package/dist/cache/application/ports/l1-cache-store.port.d.ts +56 -0
- package/dist/cache/application/ports/l1-cache-store.port.d.ts.map +1 -0
- package/dist/cache/application/ports/l2-cache-store.port.d.ts +98 -0
- package/dist/cache/application/ports/l2-cache-store.port.d.ts.map +1 -0
- package/dist/cache/application/services/cache-decorator-initializer.service.d.ts +25 -0
- package/dist/cache/application/services/cache-decorator-initializer.service.d.ts.map +1 -0
- package/dist/cache/application/services/cache.service.d.ts +106 -0
- package/dist/cache/application/services/cache.service.d.ts.map +1 -0
- package/dist/cache/application/services/warmup.service.d.ts +25 -0
- package/dist/cache/application/services/warmup.service.d.ts.map +1 -0
- package/dist/cache/domain/services/serializer.service.d.ts +29 -0
- package/dist/cache/domain/services/serializer.service.d.ts.map +1 -0
- package/dist/cache/domain/value-objects/cache-entry.vo.d.ts +69 -0
- package/dist/cache/domain/value-objects/cache-entry.vo.d.ts.map +1 -0
- package/dist/cache/domain/value-objects/cache-key.vo.d.ts +45 -0
- package/dist/cache/domain/value-objects/cache-key.vo.d.ts.map +1 -0
- package/dist/cache/domain/value-objects/tag.vo.d.ts +36 -0
- package/dist/cache/domain/value-objects/tag.vo.d.ts.map +1 -0
- package/dist/cache/domain/value-objects/tags.vo.d.ts +57 -0
- package/dist/cache/domain/value-objects/tags.vo.d.ts.map +1 -0
- package/dist/cache/domain/value-objects/ttl.vo.d.ts +58 -0
- package/dist/cache/domain/value-objects/ttl.vo.d.ts.map +1 -0
- package/dist/cache/infrastructure/adapters/l1-memory-store.adapter.d.ts +36 -0
- package/dist/cache/infrastructure/adapters/l1-memory-store.adapter.d.ts.map +1 -0
- package/dist/cache/infrastructure/adapters/l2-redis-store.adapter.d.ts +41 -0
- package/dist/cache/infrastructure/adapters/l2-redis-store.adapter.d.ts.map +1 -0
- package/dist/cache.plugin.d.ts +17 -0
- package/dist/cache.plugin.d.ts.map +1 -0
- package/dist/cache.service.d.ts +234 -0
- package/dist/cache.service.d.ts.map +1 -0
- package/dist/decorators/cache-evict.decorator.d.ts +97 -0
- package/dist/decorators/cache-evict.decorator.d.ts.map +1 -0
- package/dist/decorators/cache-put.decorator.d.ts +95 -0
- package/dist/decorators/cache-put.decorator.d.ts.map +1 -0
- package/dist/decorators/cache.interceptor.d.ts +63 -0
- package/dist/decorators/cache.interceptor.d.ts.map +1 -0
- package/dist/decorators/cacheable.decorator.d.ts +88 -0
- package/dist/decorators/cacheable.decorator.d.ts.map +1 -0
- package/dist/decorators/key-generator.util.d.ts +69 -0
- package/dist/decorators/key-generator.util.d.ts.map +1 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4199 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +4152 -0
- package/dist/index.mjs.map +1 -0
- package/dist/invalidation/application/ports/event-invalidation.port.d.ts +28 -0
- package/dist/invalidation/application/ports/event-invalidation.port.d.ts.map +1 -0
- package/dist/invalidation/application/ports/invalidation-registry.port.d.ts +36 -0
- package/dist/invalidation/application/ports/invalidation-registry.port.d.ts.map +1 -0
- package/dist/invalidation/application/services/event-invalidation.service.d.ts +30 -0
- package/dist/invalidation/application/services/event-invalidation.service.d.ts.map +1 -0
- package/dist/invalidation/application/services/invalidation-registry.service.d.ts +17 -0
- package/dist/invalidation/application/services/invalidation-registry.service.d.ts.map +1 -0
- package/dist/invalidation/domain/entities/invalidation-rule.entity.d.ts +60 -0
- package/dist/invalidation/domain/entities/invalidation-rule.entity.d.ts.map +1 -0
- package/dist/invalidation/domain/value-objects/event-pattern.vo.d.ts +27 -0
- package/dist/invalidation/domain/value-objects/event-pattern.vo.d.ts.map +1 -0
- package/dist/invalidation/domain/value-objects/tag-template.vo.d.ts +34 -0
- package/dist/invalidation/domain/value-objects/tag-template.vo.d.ts.map +1 -0
- package/dist/invalidation/infrastructure/adapters/amqp-event-source.adapter.d.ts +51 -0
- package/dist/invalidation/infrastructure/adapters/amqp-event-source.adapter.d.ts.map +1 -0
- package/dist/invalidation/infrastructure/decorators/invalidate-on.decorator.d.ts +69 -0
- package/dist/invalidation/infrastructure/decorators/invalidate-on.decorator.d.ts.map +1 -0
- package/dist/key-builder.d.ts +199 -0
- package/dist/key-builder.d.ts.map +1 -0
- package/dist/serializers/index.d.ts +19 -0
- package/dist/serializers/index.d.ts.map +1 -0
- package/dist/serializers/json.serializer.d.ts +51 -0
- package/dist/serializers/json.serializer.d.ts.map +1 -0
- package/dist/serializers/msgpack.serializer.d.ts +67 -0
- package/dist/serializers/msgpack.serializer.d.ts.map +1 -0
- package/dist/serializers/serializer.interface.d.ts +36 -0
- package/dist/serializers/serializer.interface.d.ts.map +1 -0
- package/dist/shared/constants/index.d.ts +73 -0
- package/dist/shared/constants/index.d.ts.map +1 -0
- package/dist/shared/errors/index.d.ts +46 -0
- package/dist/shared/errors/index.d.ts.map +1 -0
- package/dist/shared/types/context-provider.interface.d.ts +58 -0
- package/dist/shared/types/context-provider.interface.d.ts.map +1 -0
- package/dist/shared/types/index.d.ts +259 -0
- package/dist/shared/types/index.d.ts.map +1 -0
- package/dist/stampede/application/ports/stampede-protection.port.d.ts +33 -0
- package/dist/stampede/application/ports/stampede-protection.port.d.ts.map +1 -0
- package/dist/stampede/infrastructure/stampede-protection.service.d.ts +30 -0
- package/dist/stampede/infrastructure/stampede-protection.service.d.ts.map +1 -0
- package/dist/strategies/eviction-strategy.interface.d.ts +39 -0
- package/dist/strategies/eviction-strategy.interface.d.ts.map +1 -0
- package/dist/strategies/fifo.strategy.d.ts +86 -0
- package/dist/strategies/fifo.strategy.d.ts.map +1 -0
- package/dist/strategies/index.d.ts +19 -0
- package/dist/strategies/index.d.ts.map +1 -0
- package/dist/strategies/lfu.strategy.d.ts +87 -0
- package/dist/strategies/lfu.strategy.d.ts.map +1 -0
- package/dist/strategies/lru.strategy.d.ts +78 -0
- package/dist/strategies/lru.strategy.d.ts.map +1 -0
- package/dist/swr/application/ports/swr-manager.port.d.ts +83 -0
- package/dist/swr/application/ports/swr-manager.port.d.ts.map +1 -0
- package/dist/swr/infrastructure/swr-manager.service.d.ts +30 -0
- package/dist/swr/infrastructure/swr-manager.service.d.ts.map +1 -0
- package/dist/tags/application/ports/tag-index.port.d.ts +55 -0
- package/dist/tags/application/ports/tag-index.port.d.ts.map +1 -0
- package/dist/tags/infrastructure/repositories/tag-index.repository.d.ts +37 -0
- package/dist/tags/infrastructure/repositories/tag-index.repository.d.ts.map +1 -0
- package/dist/tags/infrastructure/scripts/lua-scripts.d.ts +25 -0
- package/dist/tags/infrastructure/scripts/lua-scripts.d.ts.map +1 -0
- package/dist/tags/infrastructure/services/lua-script-loader.service.d.ts +44 -0
- package/dist/tags/infrastructure/services/lua-script-loader.service.d.ts.map +1 -0
- package/package.json +79 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,4199 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var core$1 = require('@nestjs/core');
|
|
4
|
+
var common = require('@nestjs/common');
|
|
5
|
+
require('reflect-metadata');
|
|
6
|
+
var core = require('@nestjs-redisx/core');
|
|
7
|
+
var crypto = require('crypto');
|
|
8
|
+
var events = require('events');
|
|
9
|
+
var rxjs = require('rxjs');
|
|
10
|
+
var operators = require('rxjs/operators');
|
|
11
|
+
|
|
12
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
13
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
14
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
15
|
+
}) : x)(function(x) {
|
|
16
|
+
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
17
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
18
|
+
});
|
|
19
|
+
var __decorateClass = (decorators, target, key, kind) => {
|
|
20
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
|
|
21
|
+
for (var i = decorators.length - 1, decorator; i >= 0; i--)
|
|
22
|
+
if (decorator = decorators[i])
|
|
23
|
+
result = (decorator(result)) || result;
|
|
24
|
+
return result;
|
|
25
|
+
};
|
|
26
|
+
var __decorateParam = (index, decorator) => (target, key) => decorator(target, key, index);
|
|
27
|
+
|
|
28
|
+
// src/shared/constants/index.ts
|
|
29
|
+
var CACHE_PLUGIN_OPTIONS = /* @__PURE__ */ Symbol.for("CACHE_PLUGIN_OPTIONS");
|
|
30
|
+
var CACHE_SERVICE = /* @__PURE__ */ Symbol.for("CACHE_SERVICE");
|
|
31
|
+
var L1_CACHE_STORE = /* @__PURE__ */ Symbol.for("L1_CACHE_STORE");
|
|
32
|
+
var L2_CACHE_STORE = /* @__PURE__ */ Symbol.for("L2_CACHE_STORE");
|
|
33
|
+
var STAMPEDE_PROTECTION = /* @__PURE__ */ Symbol.for("STAMPEDE_PROTECTION");
|
|
34
|
+
var TAG_INDEX = /* @__PURE__ */ Symbol.for("TAG_INDEX");
|
|
35
|
+
var SWR_MANAGER = /* @__PURE__ */ Symbol.for("SWR_MANAGER");
|
|
36
|
+
var SERIALIZER = /* @__PURE__ */ Symbol.for("SERIALIZER");
|
|
37
|
+
var INVALIDATION_REGISTRY = /* @__PURE__ */ Symbol.for("INVALIDATION_REGISTRY");
|
|
38
|
+
var EVENT_INVALIDATION_SERVICE = /* @__PURE__ */ Symbol.for("EVENT_INVALIDATION_SERVICE");
|
|
39
|
+
var LUA_SCRIPT_LOADER = /* @__PURE__ */ Symbol.for("LUA_SCRIPT_LOADER");
|
|
40
|
+
var INVALIDATION_RULES_INIT = /* @__PURE__ */ Symbol.for("INVALIDATION_RULES_INIT");
|
|
41
|
+
var AMQP_CONNECTION = /* @__PURE__ */ Symbol.for("AMQP_CONNECTION");
|
|
42
|
+
var CACHE_OPTIONS_KEY = "cache:options";
|
|
43
|
+
var INVALIDATE_TAGS_KEY = "cache:invalidate:tags";
|
|
44
|
+
var DEFAULT_CACHE_CONFIG = {
|
|
45
|
+
l1: {
|
|
46
|
+
enabled: true,
|
|
47
|
+
maxSize: 1e3,
|
|
48
|
+
ttl: 60,
|
|
49
|
+
evictionPolicy: "lru"
|
|
50
|
+
},
|
|
51
|
+
l2: {
|
|
52
|
+
enabled: true,
|
|
53
|
+
defaultTtl: 3600,
|
|
54
|
+
maxTtl: 86400,
|
|
55
|
+
keyPrefix: "cache:",
|
|
56
|
+
clientName: "default"
|
|
57
|
+
},
|
|
58
|
+
stampede: {
|
|
59
|
+
enabled: true,
|
|
60
|
+
lockTimeout: 5e3,
|
|
61
|
+
waitTimeout: 1e4,
|
|
62
|
+
fallback: "load"
|
|
63
|
+
},
|
|
64
|
+
swr: {
|
|
65
|
+
enabled: false,
|
|
66
|
+
defaultStaleTime: 60
|
|
67
|
+
},
|
|
68
|
+
tags: {
|
|
69
|
+
enabled: true,
|
|
70
|
+
indexPrefix: "_tag:",
|
|
71
|
+
maxTagsPerKey: 10
|
|
72
|
+
},
|
|
73
|
+
warmup: {
|
|
74
|
+
enabled: false,
|
|
75
|
+
concurrency: 10
|
|
76
|
+
},
|
|
77
|
+
keys: {
|
|
78
|
+
maxLength: 1024,
|
|
79
|
+
version: "v1",
|
|
80
|
+
separator: ":"
|
|
81
|
+
},
|
|
82
|
+
invalidation: {
|
|
83
|
+
enabled: true,
|
|
84
|
+
source: "internal",
|
|
85
|
+
deduplicationTtl: 60
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// src/cache/api/decorators/cached.decorator.ts
|
|
90
|
+
var logger = new common.Logger("Cached");
|
|
91
|
+
var globalCacheServiceGetter = null;
|
|
92
|
+
var globalPluginOptions = null;
|
|
93
|
+
function registerCacheServiceGetter(getter) {
|
|
94
|
+
globalCacheServiceGetter = getter;
|
|
95
|
+
}
|
|
96
|
+
function registerCachePluginOptions(options) {
|
|
97
|
+
globalPluginOptions = options;
|
|
98
|
+
}
|
|
99
|
+
function getCacheService() {
|
|
100
|
+
return globalCacheServiceGetter ? globalCacheServiceGetter() : null;
|
|
101
|
+
}
|
|
102
|
+
function Cached(options = {}) {
|
|
103
|
+
return (target, propertyKey, descriptor) => {
|
|
104
|
+
const originalMethod = descriptor.value;
|
|
105
|
+
descriptor.value = async function(...args) {
|
|
106
|
+
if (!globalCacheServiceGetter) {
|
|
107
|
+
logger.warn(`@Cached: CacheService not yet available, executing method without cache`);
|
|
108
|
+
return originalMethod.apply(this, args);
|
|
109
|
+
}
|
|
110
|
+
const cacheService = globalCacheServiceGetter();
|
|
111
|
+
if (!cacheService) {
|
|
112
|
+
logger.warn(`@Cached: CacheService getter returned null, executing method without cache`);
|
|
113
|
+
return originalMethod.apply(this, args);
|
|
114
|
+
}
|
|
115
|
+
if (options.condition && !options.condition(...args)) {
|
|
116
|
+
return originalMethod.apply(this, args);
|
|
117
|
+
}
|
|
118
|
+
const key = buildCacheKey(this, propertyKey.toString(), args, options);
|
|
119
|
+
try {
|
|
120
|
+
const cached = await cacheService.get(key);
|
|
121
|
+
if (cached !== null) {
|
|
122
|
+
return cached;
|
|
123
|
+
}
|
|
124
|
+
} catch (error) {
|
|
125
|
+
logger.error(`@Cached: Cache get error for key ${key}:`, error);
|
|
126
|
+
}
|
|
127
|
+
const result = await originalMethod.apply(this, args);
|
|
128
|
+
if (options.unless?.(result, ...args)) {
|
|
129
|
+
return result;
|
|
130
|
+
}
|
|
131
|
+
await cacheResult(cacheService, key, result, options, args);
|
|
132
|
+
return result;
|
|
133
|
+
};
|
|
134
|
+
Object.defineProperty(descriptor.value, "name", {
|
|
135
|
+
value: originalMethod.name,
|
|
136
|
+
writable: false
|
|
137
|
+
});
|
|
138
|
+
Reflect.defineMetadata(CACHE_OPTIONS_KEY, options, descriptor.value);
|
|
139
|
+
return descriptor;
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
function buildCacheKey(instance, methodName, args, options) {
|
|
143
|
+
const className = instance.constructor.name;
|
|
144
|
+
let baseKey;
|
|
145
|
+
if (options.key) {
|
|
146
|
+
baseKey = interpolateKey(options.key, args);
|
|
147
|
+
} else {
|
|
148
|
+
const argKeys = args.map((arg) => serializeArg(arg)).join(":");
|
|
149
|
+
baseKey = `${className}:${methodName}:${argKeys}`;
|
|
150
|
+
}
|
|
151
|
+
return enrichWithContext(baseKey, options);
|
|
152
|
+
}
|
|
153
|
+
function enrichWithContext(key, options) {
|
|
154
|
+
if (options.skipContext) return key;
|
|
155
|
+
const pluginOpts = globalPluginOptions;
|
|
156
|
+
if (!pluginOpts?.contextProvider) return key;
|
|
157
|
+
const separator = pluginOpts.keys?.separator ?? ":";
|
|
158
|
+
const marker = `${separator}_ctx_${separator}`;
|
|
159
|
+
const contextMap = /* @__PURE__ */ new Map();
|
|
160
|
+
const contextKeys = options.contextKeys ?? pluginOpts.contextKeys ?? [];
|
|
161
|
+
for (const ctxKey of contextKeys) {
|
|
162
|
+
const value = pluginOpts.contextProvider.get(ctxKey);
|
|
163
|
+
if (value !== void 0 && value !== null) {
|
|
164
|
+
contextMap.set(ctxKey, String(value));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (options.varyBy) {
|
|
168
|
+
for (const name of options.varyBy) {
|
|
169
|
+
if (!contextMap.has(name)) {
|
|
170
|
+
const value = pluginOpts.contextProvider.get(name);
|
|
171
|
+
if (value !== void 0 && value !== null) {
|
|
172
|
+
contextMap.set(name, String(value));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (contextMap.size === 0) return key;
|
|
178
|
+
const sortedEntries = [...contextMap.entries()].sort(([a], [b]) => a.localeCompare(b));
|
|
179
|
+
const suffix = sortedEntries.map(([k, v]) => `${sanitizeForKey(k)}.${sanitizeForKey(v)}`).join(separator);
|
|
180
|
+
return `${key}${marker}${suffix}`;
|
|
181
|
+
}
|
|
182
|
+
function sanitizeForKey(value) {
|
|
183
|
+
return String(value).replace(/[^a-zA-Z0-9\-_]/g, "_");
|
|
184
|
+
}
|
|
185
|
+
function interpolateKey(template, args) {
|
|
186
|
+
return template.replace(/\{(\d+)\}/g, (match, index) => {
|
|
187
|
+
const argIndex = parseInt(index, 10);
|
|
188
|
+
if (argIndex < args.length) {
|
|
189
|
+
return serializeArg(args[argIndex]);
|
|
190
|
+
}
|
|
191
|
+
return match;
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
function serializeArg(arg) {
|
|
195
|
+
if (arg === null || arg === void 0) {
|
|
196
|
+
return "null";
|
|
197
|
+
}
|
|
198
|
+
if (typeof arg === "string" || typeof arg === "number" || typeof arg === "boolean") {
|
|
199
|
+
return String(arg);
|
|
200
|
+
}
|
|
201
|
+
if (typeof arg === "object") {
|
|
202
|
+
try {
|
|
203
|
+
return JSON.stringify(arg);
|
|
204
|
+
} catch {
|
|
205
|
+
return "object";
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return "unknown";
|
|
209
|
+
}
|
|
210
|
+
async function cacheResult(cacheService, key, value, options, args) {
|
|
211
|
+
try {
|
|
212
|
+
const tags = typeof options.tags === "function" ? options.tags(...args) : options.tags;
|
|
213
|
+
if (options.swr?.enabled) {
|
|
214
|
+
await cacheService.getOrSet(
|
|
215
|
+
key,
|
|
216
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
217
|
+
async () => value,
|
|
218
|
+
{
|
|
219
|
+
ttl: options.ttl,
|
|
220
|
+
tags,
|
|
221
|
+
strategy: options.strategy,
|
|
222
|
+
swr: options.swr
|
|
223
|
+
}
|
|
224
|
+
);
|
|
225
|
+
} else {
|
|
226
|
+
await cacheService.set(key, value, {
|
|
227
|
+
ttl: options.ttl,
|
|
228
|
+
tags,
|
|
229
|
+
strategy: options.strategy
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
} catch (error) {
|
|
233
|
+
logger.error(`@Cached: Failed to cache result for key ${key}:`, error);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// src/invalidation/infrastructure/decorators/invalidate-on.decorator.ts
|
|
238
|
+
var INVALIDATE_ON_OPTIONS = /* @__PURE__ */ Symbol.for("INVALIDATE_ON_OPTIONS");
|
|
239
|
+
var globalEventInvalidationServiceGetter = null;
|
|
240
|
+
function registerEventInvalidationServiceGetter(getter) {
|
|
241
|
+
globalEventInvalidationServiceGetter = getter;
|
|
242
|
+
}
|
|
243
|
+
function getEventInvalidationService() {
|
|
244
|
+
return globalEventInvalidationServiceGetter ? globalEventInvalidationServiceGetter() : null;
|
|
245
|
+
}
|
|
246
|
+
function InvalidateOn(options) {
|
|
247
|
+
return (target, propertyKey, descriptor) => {
|
|
248
|
+
const originalMethod = descriptor.value;
|
|
249
|
+
descriptor.value = async function(...args) {
|
|
250
|
+
const result = await originalMethod.apply(this, args);
|
|
251
|
+
const cacheService = getCacheService();
|
|
252
|
+
if (cacheService) {
|
|
253
|
+
try {
|
|
254
|
+
if (options.condition && !options.condition(result, args)) {
|
|
255
|
+
return result;
|
|
256
|
+
}
|
|
257
|
+
const tags = resolveTags(options.tags, result, args);
|
|
258
|
+
const keys = resolveKeys(options.keys, result, args);
|
|
259
|
+
if (tags.length > 0) {
|
|
260
|
+
await cacheService.invalidateTags(tags);
|
|
261
|
+
}
|
|
262
|
+
if (keys.length > 0) {
|
|
263
|
+
await cacheService.deleteMany(keys);
|
|
264
|
+
}
|
|
265
|
+
if (options.publish) {
|
|
266
|
+
const eventInvalidationService = getEventInvalidationService();
|
|
267
|
+
if (eventInvalidationService) {
|
|
268
|
+
for (const event of options.events) {
|
|
269
|
+
await eventInvalidationService.emit(event, {
|
|
270
|
+
result,
|
|
271
|
+
args,
|
|
272
|
+
tags,
|
|
273
|
+
keys,
|
|
274
|
+
timestamp: Date.now()
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
} catch (error) {
|
|
280
|
+
console.error("@InvalidateOn: Invalidation failed:", error);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return result;
|
|
284
|
+
};
|
|
285
|
+
Object.defineProperty(descriptor.value, "name", {
|
|
286
|
+
value: originalMethod.name,
|
|
287
|
+
writable: false
|
|
288
|
+
});
|
|
289
|
+
Reflect.defineMetadata(INVALIDATE_ON_OPTIONS, options, descriptor.value);
|
|
290
|
+
return descriptor;
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
function resolveTags(tags, result, args) {
|
|
294
|
+
if (!tags) {
|
|
295
|
+
return [];
|
|
296
|
+
}
|
|
297
|
+
if (typeof tags === "function") {
|
|
298
|
+
return tags(result, args);
|
|
299
|
+
}
|
|
300
|
+
return tags;
|
|
301
|
+
}
|
|
302
|
+
function resolveKeys(keys, result, args) {
|
|
303
|
+
if (!keys) {
|
|
304
|
+
return [];
|
|
305
|
+
}
|
|
306
|
+
if (typeof keys === "function") {
|
|
307
|
+
return keys(result, args);
|
|
308
|
+
}
|
|
309
|
+
return keys;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// src/cache/application/services/cache-decorator-initializer.service.ts
|
|
313
|
+
var CacheDecoratorInitializerService = class {
|
|
314
|
+
constructor(moduleRef, cacheService, pluginOptions, eventInvalidationService) {
|
|
315
|
+
this.moduleRef = moduleRef;
|
|
316
|
+
this.cacheService = cacheService;
|
|
317
|
+
this.pluginOptions = pluginOptions;
|
|
318
|
+
this.eventInvalidationService = eventInvalidationService;
|
|
319
|
+
}
|
|
320
|
+
logger = new common.Logger(CacheDecoratorInitializerService.name);
|
|
321
|
+
/**
|
|
322
|
+
* Called after all modules are initialized.
|
|
323
|
+
* Registers cache service getter and plugin options for @Cached decorator.
|
|
324
|
+
*/
|
|
325
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
326
|
+
async onModuleInit() {
|
|
327
|
+
this.logger.debug("Registering CacheService getter for @Cached decorator");
|
|
328
|
+
registerCacheServiceGetter(() => this.cacheService);
|
|
329
|
+
registerCachePluginOptions(this.pluginOptions);
|
|
330
|
+
this.logger.log("@Cached decorator initialized and ready to use");
|
|
331
|
+
if (this.eventInvalidationService) {
|
|
332
|
+
this.logger.debug("Registering EventInvalidationService getter for @InvalidateOn decorator");
|
|
333
|
+
registerEventInvalidationServiceGetter(() => this.eventInvalidationService);
|
|
334
|
+
this.logger.log("@InvalidateOn decorator event publishing initialized");
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
CacheDecoratorInitializerService = __decorateClass([
|
|
339
|
+
common.Injectable(),
|
|
340
|
+
__decorateParam(1, common.Inject(CACHE_SERVICE)),
|
|
341
|
+
__decorateParam(2, common.Inject(CACHE_PLUGIN_OPTIONS)),
|
|
342
|
+
__decorateParam(3, common.Optional()),
|
|
343
|
+
__decorateParam(3, common.Inject(EVENT_INVALIDATION_SERVICE))
|
|
344
|
+
], CacheDecoratorInitializerService);
|
|
345
|
+
var CacheError = class extends core.RedisXError {
|
|
346
|
+
constructor(message, code = core.ErrorCode.OPERATION_FAILED, cause) {
|
|
347
|
+
super(message, code, cause);
|
|
348
|
+
this.name = "CacheError";
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
var CacheKeyError = class extends CacheError {
|
|
352
|
+
constructor(key, message) {
|
|
353
|
+
super(`Invalid cache key "${key}": ${message}`, core.ErrorCode.CACHE_KEY_INVALID);
|
|
354
|
+
this.key = key;
|
|
355
|
+
this.name = "CacheKeyError";
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
var SerializationError = class extends CacheError {
|
|
359
|
+
constructor(message, cause) {
|
|
360
|
+
super(`Serialization error: ${message}`, core.ErrorCode.SERIALIZATION_FAILED, cause);
|
|
361
|
+
this.name = "SerializationError";
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
var LoaderError = class extends CacheError {
|
|
365
|
+
constructor(key, cause) {
|
|
366
|
+
super(`Loader failed for key "${key}": ${cause.message}`, core.ErrorCode.OPERATION_FAILED, cause);
|
|
367
|
+
this.key = key;
|
|
368
|
+
this.name = "LoaderError";
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
var StampedeError = class extends CacheError {
|
|
372
|
+
constructor(key, timeout) {
|
|
373
|
+
super(`Stampede protection timeout for key "${key}" after ${timeout}ms`, core.ErrorCode.OPERATION_TIMEOUT);
|
|
374
|
+
this.key = key;
|
|
375
|
+
this.timeout = timeout;
|
|
376
|
+
this.name = "StampedeError";
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
var TagInvalidationError = class extends CacheError {
|
|
380
|
+
constructor(tag, message, cause) {
|
|
381
|
+
super(`Tag invalidation failed for "${tag}": ${message}`, core.ErrorCode.OPERATION_FAILED, cause);
|
|
382
|
+
this.tag = tag;
|
|
383
|
+
this.name = "TagInvalidationError";
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
// src/cache/domain/value-objects/cache-entry.vo.ts
|
|
388
|
+
var CacheEntry = class _CacheEntry {
|
|
389
|
+
constructor(value, cachedAt, ttl, tags) {
|
|
390
|
+
this.value = value;
|
|
391
|
+
this.cachedAt = cachedAt;
|
|
392
|
+
this.ttl = ttl;
|
|
393
|
+
this.tags = tags;
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Creates a cache entry.
|
|
397
|
+
*
|
|
398
|
+
* @param value - Value to cache
|
|
399
|
+
* @param ttl - TTL in seconds
|
|
400
|
+
* @param tags - Optional tags
|
|
401
|
+
* @returns CacheEntry instance
|
|
402
|
+
*/
|
|
403
|
+
static create(value, ttl, tags) {
|
|
404
|
+
return new _CacheEntry(value, Date.now(), ttl, tags);
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Checks if entry is expired.
|
|
408
|
+
*
|
|
409
|
+
* @returns true if expired, false otherwise
|
|
410
|
+
*/
|
|
411
|
+
isExpired() {
|
|
412
|
+
const expiresAt = this.cachedAt + this.ttl * 1e3;
|
|
413
|
+
return Date.now() > expiresAt;
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Gets time until expiration in milliseconds.
|
|
417
|
+
*
|
|
418
|
+
* @returns Milliseconds until expiration (0 if expired)
|
|
419
|
+
*/
|
|
420
|
+
getTimeToLive() {
|
|
421
|
+
const expiresAt = this.cachedAt + this.ttl * 1e3;
|
|
422
|
+
const remaining = expiresAt - Date.now();
|
|
423
|
+
return Math.max(0, remaining);
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Gets age of entry in milliseconds.
|
|
427
|
+
*
|
|
428
|
+
* @returns Age in milliseconds
|
|
429
|
+
*/
|
|
430
|
+
getAge() {
|
|
431
|
+
return Date.now() - this.cachedAt;
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Checks if entry has a specific tag.
|
|
435
|
+
*
|
|
436
|
+
* @param tag - Tag to check
|
|
437
|
+
* @returns true if entry has the tag
|
|
438
|
+
*/
|
|
439
|
+
hasTag(tag) {
|
|
440
|
+
return this.tags?.includes(tag) ?? false;
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Serializes entry to JSON.
|
|
444
|
+
*
|
|
445
|
+
* @returns JSON representation
|
|
446
|
+
*/
|
|
447
|
+
toJSON() {
|
|
448
|
+
return {
|
|
449
|
+
value: this.value,
|
|
450
|
+
cachedAt: this.cachedAt,
|
|
451
|
+
ttl: this.ttl,
|
|
452
|
+
tags: this.tags
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Deserializes entry from JSON.
|
|
457
|
+
*
|
|
458
|
+
* @param json - JSON representation
|
|
459
|
+
* @returns CacheEntry instance
|
|
460
|
+
*/
|
|
461
|
+
static fromJSON(json) {
|
|
462
|
+
return new _CacheEntry(json.value, json.cachedAt, json.ttl, json.tags);
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
// src/cache/domain/value-objects/cache-key.vo.ts
|
|
467
|
+
var DEFAULT_OPTIONS = {
|
|
468
|
+
maxLength: 512,
|
|
469
|
+
prefix: "",
|
|
470
|
+
version: "",
|
|
471
|
+
separator: ":"
|
|
472
|
+
};
|
|
473
|
+
var CacheKey = class _CacheKey {
|
|
474
|
+
constructor(rawKey, options) {
|
|
475
|
+
this.rawKey = rawKey;
|
|
476
|
+
this.options = options;
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Creates a validated cache key.
|
|
480
|
+
*
|
|
481
|
+
* @param key - Raw key
|
|
482
|
+
* @param options - Key options
|
|
483
|
+
* @returns CacheKey instance
|
|
484
|
+
* @throws CacheKeyError if validation fails
|
|
485
|
+
*/
|
|
486
|
+
static create(key, options = {}) {
|
|
487
|
+
const opts = {
|
|
488
|
+
...DEFAULT_OPTIONS,
|
|
489
|
+
...options
|
|
490
|
+
};
|
|
491
|
+
const normalizedKey = key.trim();
|
|
492
|
+
if (!normalizedKey || normalizedKey.length === 0) {
|
|
493
|
+
throw new CacheKeyError(key, "Key cannot be empty");
|
|
494
|
+
}
|
|
495
|
+
if (/\s/.test(normalizedKey)) {
|
|
496
|
+
throw new CacheKeyError(key, "Key cannot contain whitespace");
|
|
497
|
+
}
|
|
498
|
+
if (!/^[a-zA-Z0-9\-_:.]+$/.test(normalizedKey)) {
|
|
499
|
+
throw new CacheKeyError(key, "Invalid characters in key. Only alphanumeric, hyphens, underscores, colons, and dots allowed");
|
|
500
|
+
}
|
|
501
|
+
const fullKey = opts.prefix + opts.version + (opts.version ? opts.separator : "") + normalizedKey;
|
|
502
|
+
if (fullKey.length > opts.maxLength) {
|
|
503
|
+
throw new CacheKeyError(normalizedKey, `Key exceeds maximum length (${fullKey.length} > ${opts.maxLength})`);
|
|
504
|
+
}
|
|
505
|
+
return new _CacheKey(normalizedKey, opts);
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Returns the full cache key with prefix and version.
|
|
509
|
+
*/
|
|
510
|
+
toString() {
|
|
511
|
+
return this.options.prefix + this.options.version + (this.options.version ? this.options.separator : "") + this.rawKey;
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Returns the raw key without prefix/version.
|
|
515
|
+
*/
|
|
516
|
+
getRaw() {
|
|
517
|
+
return this.rawKey;
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Returns the prefix.
|
|
521
|
+
*/
|
|
522
|
+
getPrefix() {
|
|
523
|
+
return this.options.prefix;
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Returns the version.
|
|
527
|
+
*/
|
|
528
|
+
getVersion() {
|
|
529
|
+
return this.options.version;
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Checks equality with another CacheKey.
|
|
533
|
+
*/
|
|
534
|
+
equals(other) {
|
|
535
|
+
return this.toString() === other.toString();
|
|
536
|
+
}
|
|
537
|
+
};
|
|
538
|
+
var DEFAULT_MAX_LENGTH = 128;
|
|
539
|
+
var Tag = class _Tag {
|
|
540
|
+
constructor(value) {
|
|
541
|
+
this.value = value;
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Creates a validated tag.
|
|
545
|
+
*
|
|
546
|
+
* @param value - Tag value
|
|
547
|
+
* @param maxLength - Maximum tag length (default: 128)
|
|
548
|
+
* @returns Tag instance
|
|
549
|
+
* @throws CacheError if validation fails
|
|
550
|
+
*
|
|
551
|
+
* @example
|
|
552
|
+
* ```typescript
|
|
553
|
+
* const tag = Tag.create('users');
|
|
554
|
+
* const productTag = Tag.create('product:123');
|
|
555
|
+
* ```
|
|
556
|
+
*/
|
|
557
|
+
static create(value, maxLength = DEFAULT_MAX_LENGTH) {
|
|
558
|
+
if (!value || value.length === 0) {
|
|
559
|
+
throw new CacheError("Tag cannot be empty", core.ErrorCode.CACHE_KEY_INVALID);
|
|
560
|
+
}
|
|
561
|
+
const normalized = value.trim().toLowerCase();
|
|
562
|
+
if (normalized.length === 0) {
|
|
563
|
+
throw new CacheError("Tag cannot be empty after normalization", core.ErrorCode.CACHE_KEY_INVALID);
|
|
564
|
+
}
|
|
565
|
+
if (/\s/.test(normalized)) {
|
|
566
|
+
throw new CacheError("Tag cannot contain whitespace", core.ErrorCode.CACHE_KEY_INVALID);
|
|
567
|
+
}
|
|
568
|
+
if (!/^[a-z0-9\-_:.]+$/.test(normalized)) {
|
|
569
|
+
throw new CacheError("Invalid tag characters. Only lowercase alphanumeric, hyphens, underscores, colons, and dots allowed", core.ErrorCode.CACHE_KEY_INVALID);
|
|
570
|
+
}
|
|
571
|
+
if (normalized.length > maxLength) {
|
|
572
|
+
throw new CacheError(`Tag exceeds maximum length (${normalized.length} > ${maxLength})`, core.ErrorCode.CACHE_KEY_INVALID);
|
|
573
|
+
}
|
|
574
|
+
return new _Tag(normalized);
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* Returns the tag value.
|
|
578
|
+
*/
|
|
579
|
+
toString() {
|
|
580
|
+
return this.value;
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* Returns the raw tag value.
|
|
584
|
+
*/
|
|
585
|
+
getRaw() {
|
|
586
|
+
return this.value;
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Checks equality with another tag.
|
|
590
|
+
*/
|
|
591
|
+
equals(other) {
|
|
592
|
+
return this.value === other.value;
|
|
593
|
+
}
|
|
594
|
+
};
|
|
595
|
+
var DEFAULT_MAX_TAGS = 10;
|
|
596
|
+
var Tags = class _Tags {
|
|
597
|
+
constructor(tags) {
|
|
598
|
+
this.tags = tags;
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Creates a validated tags collection.
|
|
602
|
+
*
|
|
603
|
+
* @param values - Array of tag values
|
|
604
|
+
* @param maxTags - Maximum number of tags (default: 10)
|
|
605
|
+
* @returns Tags instance
|
|
606
|
+
* @throws CacheError if validation fails
|
|
607
|
+
*
|
|
608
|
+
* @example
|
|
609
|
+
* ```typescript
|
|
610
|
+
* const tags = Tags.create(['users', 'product:123']);
|
|
611
|
+
* const singleTag = Tags.create(['cache']);
|
|
612
|
+
* ```
|
|
613
|
+
*/
|
|
614
|
+
static create(values, maxTags = DEFAULT_MAX_TAGS) {
|
|
615
|
+
if (values.length > maxTags) {
|
|
616
|
+
throw new CacheError(`Too many tags (${values.length} > ${maxTags})`, core.ErrorCode.CACHE_KEY_INVALID);
|
|
617
|
+
}
|
|
618
|
+
const tagObjects = values.map((v) => Tag.create(v));
|
|
619
|
+
const uniqueValues = Array.from(new Set(tagObjects.map((t) => t.toString())));
|
|
620
|
+
const uniqueTags = uniqueValues.map((v) => Tag.create(v));
|
|
621
|
+
return new _Tags(uniqueTags);
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* Creates empty tags collection.
|
|
625
|
+
*/
|
|
626
|
+
static empty() {
|
|
627
|
+
return new _Tags([]);
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Returns array of tag strings.
|
|
631
|
+
*/
|
|
632
|
+
toStrings() {
|
|
633
|
+
return this.tags.map((t) => t.toString());
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Returns array of Tag objects.
|
|
637
|
+
*/
|
|
638
|
+
toArray() {
|
|
639
|
+
return [...this.tags];
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* Returns number of tags.
|
|
643
|
+
*/
|
|
644
|
+
size() {
|
|
645
|
+
return this.tags.length;
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Checks if collection contains a specific tag.
|
|
649
|
+
*/
|
|
650
|
+
has(tag) {
|
|
651
|
+
return this.tags.some((t) => t.equals(tag));
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* Checks if collection is empty.
|
|
655
|
+
*/
|
|
656
|
+
isEmpty() {
|
|
657
|
+
return this.tags.length === 0;
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* Iterates over tags.
|
|
661
|
+
*/
|
|
662
|
+
forEach(callback) {
|
|
663
|
+
this.tags.forEach(callback);
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Maps over tags.
|
|
667
|
+
*/
|
|
668
|
+
map(callback) {
|
|
669
|
+
return this.tags.map(callback);
|
|
670
|
+
}
|
|
671
|
+
};
|
|
672
|
+
var DEFAULT_MAX_TTL_SECONDS = 86400;
|
|
673
|
+
var TTL = class _TTL {
|
|
674
|
+
constructor(seconds) {
|
|
675
|
+
this.seconds = seconds;
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* Creates a TTL value object.
|
|
679
|
+
*
|
|
680
|
+
* @param seconds - TTL in seconds
|
|
681
|
+
* @param maxTtl - Maximum allowed TTL
|
|
682
|
+
* @returns TTL instance
|
|
683
|
+
* @throws CacheError if validation fails
|
|
684
|
+
*/
|
|
685
|
+
static create(seconds, maxTtl = DEFAULT_MAX_TTL_SECONDS) {
|
|
686
|
+
if (seconds <= 0) {
|
|
687
|
+
throw new CacheError(`TTL must be positive (got ${seconds})`, core.ErrorCode.CACHE_KEY_INVALID);
|
|
688
|
+
}
|
|
689
|
+
if (seconds > maxTtl) {
|
|
690
|
+
throw new CacheError(`TTL exceeds maximum (${seconds} > ${maxTtl})`, core.ErrorCode.CACHE_KEY_INVALID);
|
|
691
|
+
}
|
|
692
|
+
const rounded = Math.round(seconds);
|
|
693
|
+
return new _TTL(rounded);
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* Creates TTL from milliseconds.
|
|
697
|
+
*
|
|
698
|
+
* @param milliseconds - TTL in milliseconds
|
|
699
|
+
* @param maxTtl - Maximum allowed TTL in seconds
|
|
700
|
+
* @returns TTL instance
|
|
701
|
+
*/
|
|
702
|
+
static fromMilliseconds(milliseconds, maxTtl = DEFAULT_MAX_TTL_SECONDS) {
|
|
703
|
+
return _TTL.create(Math.ceil(milliseconds / 1e3), maxTtl);
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* Returns TTL in seconds.
|
|
707
|
+
*/
|
|
708
|
+
toSeconds() {
|
|
709
|
+
return this.seconds;
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* Returns TTL in milliseconds.
|
|
713
|
+
*/
|
|
714
|
+
toMilliseconds() {
|
|
715
|
+
return this.seconds * 1e3;
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* Checks if TTL is less than another TTL.
|
|
719
|
+
*/
|
|
720
|
+
isLessThan(other) {
|
|
721
|
+
return this.seconds < other.seconds;
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* Checks if TTL is greater than another TTL.
|
|
725
|
+
*/
|
|
726
|
+
isGreaterThan(other) {
|
|
727
|
+
return this.seconds > other.seconds;
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* Returns the minimum of two TTLs.
|
|
731
|
+
*/
|
|
732
|
+
static min(a, b) {
|
|
733
|
+
return a.isLessThan(b) ? a : b;
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* Returns the maximum of two TTLs.
|
|
737
|
+
*/
|
|
738
|
+
static max(a, b) {
|
|
739
|
+
return a.isGreaterThan(b) ? a : b;
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* Checks equality with another TTL.
|
|
743
|
+
*/
|
|
744
|
+
equals(other) {
|
|
745
|
+
return this.seconds === other.seconds;
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* Returns string representation.
|
|
749
|
+
*/
|
|
750
|
+
toString() {
|
|
751
|
+
return `${this.seconds}s`;
|
|
752
|
+
}
|
|
753
|
+
};
|
|
754
|
+
|
|
755
|
+
// src/cache/application/services/cache.service.ts
|
|
756
|
+
var METRICS_SERVICE = /* @__PURE__ */ Symbol.for("METRICS_SERVICE");
|
|
757
|
+
var TRACING_SERVICE = /* @__PURE__ */ Symbol.for("TRACING_SERVICE");
|
|
758
|
+
var CacheService = class {
|
|
759
|
+
constructor(driver, l1Store, l2Store, stampede, tagIndex, swrManager, options, metrics, tracing) {
|
|
760
|
+
this.driver = driver;
|
|
761
|
+
this.l1Store = l1Store;
|
|
762
|
+
this.l2Store = l2Store;
|
|
763
|
+
this.stampede = stampede;
|
|
764
|
+
this.tagIndex = tagIndex;
|
|
765
|
+
this.swrManager = swrManager;
|
|
766
|
+
this.options = options;
|
|
767
|
+
this.metrics = metrics;
|
|
768
|
+
this.tracing = tracing;
|
|
769
|
+
this.l1Enabled = options.l1?.enabled ?? true;
|
|
770
|
+
this.l2Enabled = options.l2?.enabled ?? true;
|
|
771
|
+
this.keyPrefix = options.l2?.keyPrefix ?? "cache:";
|
|
772
|
+
this.stampedeEnabled = options.stampede?.enabled ?? true;
|
|
773
|
+
this.swrEnabled = options.swr?.enabled ?? false;
|
|
774
|
+
this.tagsEnabled = options.tags?.enabled ?? true;
|
|
775
|
+
}
|
|
776
|
+
logger = new common.Logger(CacheService.name);
|
|
777
|
+
l1Enabled;
|
|
778
|
+
l2Enabled;
|
|
779
|
+
stampedeEnabled;
|
|
780
|
+
swrEnabled;
|
|
781
|
+
tagsEnabled;
|
|
782
|
+
keyPrefix;
|
|
783
|
+
async get(key) {
|
|
784
|
+
const span = this.tracing?.startSpan("cache.get", {
|
|
785
|
+
kind: "INTERNAL",
|
|
786
|
+
attributes: { "cache.key": key }
|
|
787
|
+
});
|
|
788
|
+
try {
|
|
789
|
+
const normalizedKey = this.validateAndNormalizeKey(key);
|
|
790
|
+
const enrichedKey = this.enrichKeyWithContext(normalizedKey);
|
|
791
|
+
if (this.l1Enabled) {
|
|
792
|
+
const l1Entry = await this.l1Store.get(enrichedKey);
|
|
793
|
+
if (l1Entry) {
|
|
794
|
+
this.logger.debug(`L1 hit for key: ${key}`);
|
|
795
|
+
this.metrics?.incrementCounter("redisx_cache_hits_total", { layer: "l1" });
|
|
796
|
+
span?.setAttribute("cache.hit", true);
|
|
797
|
+
span?.setAttribute("cache.layer", "l1");
|
|
798
|
+
span?.setStatus("OK");
|
|
799
|
+
return l1Entry.value;
|
|
800
|
+
}
|
|
801
|
+
this.metrics?.incrementCounter("redisx_cache_misses_total", { layer: "l1" });
|
|
802
|
+
}
|
|
803
|
+
if (this.l2Enabled) {
|
|
804
|
+
const l2Entry = await this.l2Store.get(enrichedKey);
|
|
805
|
+
if (l2Entry) {
|
|
806
|
+
this.logger.debug(`L2 hit for key: ${key}`);
|
|
807
|
+
this.metrics?.incrementCounter("redisx_cache_hits_total", { layer: "l2" });
|
|
808
|
+
span?.setAttribute("cache.hit", true);
|
|
809
|
+
span?.setAttribute("cache.layer", "l2");
|
|
810
|
+
span?.setStatus("OK");
|
|
811
|
+
if (this.l1Enabled) {
|
|
812
|
+
await this.l1Store.set(enrichedKey, l2Entry, this.options.l1?.ttl);
|
|
813
|
+
}
|
|
814
|
+
return l2Entry.value;
|
|
815
|
+
}
|
|
816
|
+
this.metrics?.incrementCounter("redisx_cache_misses_total", { layer: "l2" });
|
|
817
|
+
}
|
|
818
|
+
span?.setAttribute("cache.hit", false);
|
|
819
|
+
span?.setStatus("OK");
|
|
820
|
+
return null;
|
|
821
|
+
} catch (error) {
|
|
822
|
+
if (error instanceof CacheKeyError) {
|
|
823
|
+
this.logger.warn(`Invalid cache key "${key}": ${error.message}`);
|
|
824
|
+
} else {
|
|
825
|
+
this.logger.error(`Cache get failed for key ${key}:`, error);
|
|
826
|
+
}
|
|
827
|
+
span?.recordException(error);
|
|
828
|
+
span?.setStatus("ERROR");
|
|
829
|
+
return null;
|
|
830
|
+
} finally {
|
|
831
|
+
span?.end();
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
async set(key, value, options = {}) {
|
|
835
|
+
const span = this.tracing?.startSpan("cache.set", {
|
|
836
|
+
kind: "INTERNAL",
|
|
837
|
+
attributes: { "cache.key": key, "cache.ttl": options.ttl }
|
|
838
|
+
});
|
|
839
|
+
try {
|
|
840
|
+
const normalizedKey = this.validateAndNormalizeKey(key);
|
|
841
|
+
const enrichedKey = this.enrichKeyWithContext(normalizedKey, options.varyBy);
|
|
842
|
+
const ttlSeconds = options.ttl ?? this.options.l2?.defaultTtl ?? 3600;
|
|
843
|
+
const maxTtl = this.options.l2?.maxTtl ?? 86400;
|
|
844
|
+
const ttl = TTL.create(ttlSeconds, maxTtl);
|
|
845
|
+
const entry = CacheEntry.create(value, ttl.toSeconds());
|
|
846
|
+
const strategy = options.strategy ?? "l1-l2";
|
|
847
|
+
span?.setAttribute("cache.strategy", strategy);
|
|
848
|
+
if (this.l2Enabled && strategy !== "l1-only") {
|
|
849
|
+
await this.l2Store.set(enrichedKey, entry, ttl.toSeconds());
|
|
850
|
+
}
|
|
851
|
+
if (this.l1Enabled && strategy !== "l2-only") {
|
|
852
|
+
const l1MaxTtl = TTL.create(this.options.l1?.ttl ?? 60, maxTtl);
|
|
853
|
+
const l1Ttl = TTL.min(ttl, l1MaxTtl);
|
|
854
|
+
await this.l1Store.set(enrichedKey, entry, l1Ttl.toSeconds());
|
|
855
|
+
}
|
|
856
|
+
if (this.tagsEnabled && options.tags && options.tags.length > 0 && strategy !== "l1-only") {
|
|
857
|
+
const fullKey = `${this.keyPrefix}${enrichedKey}`;
|
|
858
|
+
await this.tagIndex.addKeyToTags(fullKey, options.tags);
|
|
859
|
+
span?.setAttribute("cache.tags", options.tags.join(","));
|
|
860
|
+
}
|
|
861
|
+
span?.setStatus("OK");
|
|
862
|
+
} catch (error) {
|
|
863
|
+
span?.recordException(error);
|
|
864
|
+
span?.setStatus("ERROR");
|
|
865
|
+
if (error instanceof CacheKeyError || error instanceof CacheError) {
|
|
866
|
+
throw error;
|
|
867
|
+
}
|
|
868
|
+
throw new CacheError(`Failed to set cache for key "${key}": ${error.message}`, core.ErrorCode.CACHE_SET_FAILED, error);
|
|
869
|
+
} finally {
|
|
870
|
+
span?.end();
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
async getOrSet(key, loader, options = {}) {
|
|
874
|
+
const normalizedKey = this.validateAndNormalizeKey(key);
|
|
875
|
+
const enrichedKey = this.enrichKeyWithContext(normalizedKey, options.varyBy);
|
|
876
|
+
const swrEnabled = this.swrEnabled && (options.swr?.enabled ?? true);
|
|
877
|
+
if (swrEnabled) {
|
|
878
|
+
const swrEntry = await this.l2Store.getSwr(enrichedKey);
|
|
879
|
+
if (swrEntry) {
|
|
880
|
+
const isExpired = this.swrManager.isExpired(swrEntry);
|
|
881
|
+
if (!isExpired) {
|
|
882
|
+
const isStale = this.swrManager.isStale(swrEntry);
|
|
883
|
+
if (isStale && this.swrManager.shouldRevalidate(enrichedKey)) {
|
|
884
|
+
void this.swrManager.scheduleRevalidation(
|
|
885
|
+
enrichedKey,
|
|
886
|
+
loader,
|
|
887
|
+
async (freshValue) => {
|
|
888
|
+
await this.set(enrichedKey, freshValue, {
|
|
889
|
+
ttl: options.ttl,
|
|
890
|
+
tags: options.tags,
|
|
891
|
+
strategy: options.strategy
|
|
892
|
+
});
|
|
893
|
+
},
|
|
894
|
+
(error) => {
|
|
895
|
+
this.logger.error(`SWR revalidation failed for key ${enrichedKey}:`, error);
|
|
896
|
+
}
|
|
897
|
+
);
|
|
898
|
+
}
|
|
899
|
+
return swrEntry.value;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
const value = await this.loadWithStampede(enrichedKey, loader, options);
|
|
903
|
+
const staleTime = options.swr?.staleTime ?? this.options.swr?.defaultStaleTime ?? 60;
|
|
904
|
+
const ttl = options.ttl ?? this.options.l2?.defaultTtl ?? 3600;
|
|
905
|
+
const swrEntryNew = this.swrManager.createSwrEntry(value, ttl, staleTime);
|
|
906
|
+
await this.l2Store.setSwr(enrichedKey, swrEntryNew);
|
|
907
|
+
return value;
|
|
908
|
+
}
|
|
909
|
+
const cached = await this.get(enrichedKey);
|
|
910
|
+
if (cached !== null) {
|
|
911
|
+
return cached;
|
|
912
|
+
}
|
|
913
|
+
return this.loadWithStampede(enrichedKey, loader, options);
|
|
914
|
+
}
|
|
915
|
+
/**
|
|
916
|
+
* Loads value with stampede protection if enabled.
|
|
917
|
+
*
|
|
918
|
+
* @param key - Normalized cache key
|
|
919
|
+
* @param loader - Function to load value
|
|
920
|
+
* @param options - Cache options
|
|
921
|
+
* @returns Loaded value
|
|
922
|
+
* @private
|
|
923
|
+
*/
|
|
924
|
+
async loadWithStampede(key, loader, options) {
|
|
925
|
+
if (this.stampedeEnabled && !options.skipStampede) {
|
|
926
|
+
const result = await this.stampede.protect(key, loader);
|
|
927
|
+
if (result.cached) {
|
|
928
|
+
this.metrics?.incrementCounter("redisx_cache_stampede_prevented_total");
|
|
929
|
+
return result.value;
|
|
930
|
+
}
|
|
931
|
+
await this.set(key, result.value, {
|
|
932
|
+
ttl: options.ttl,
|
|
933
|
+
tags: options.tags,
|
|
934
|
+
strategy: options.strategy
|
|
935
|
+
});
|
|
936
|
+
return result.value;
|
|
937
|
+
}
|
|
938
|
+
const value = await loader();
|
|
939
|
+
await this.set(key, value, {
|
|
940
|
+
ttl: options.ttl,
|
|
941
|
+
tags: options.tags,
|
|
942
|
+
strategy: options.strategy
|
|
943
|
+
});
|
|
944
|
+
return value;
|
|
945
|
+
}
|
|
946
|
+
async delete(key) {
|
|
947
|
+
try {
|
|
948
|
+
const normalizedKey = this.validateAndNormalizeKey(key);
|
|
949
|
+
const enrichedKey = this.enrichKeyWithContext(normalizedKey);
|
|
950
|
+
let deleted = false;
|
|
951
|
+
if (this.l1Enabled) {
|
|
952
|
+
const l1Deleted = await this.l1Store.delete(enrichedKey);
|
|
953
|
+
deleted = deleted || l1Deleted;
|
|
954
|
+
}
|
|
955
|
+
if (this.l2Enabled) {
|
|
956
|
+
const l2Deleted = await this.l2Store.delete(enrichedKey);
|
|
957
|
+
deleted = deleted || l2Deleted;
|
|
958
|
+
}
|
|
959
|
+
return deleted;
|
|
960
|
+
} catch (error) {
|
|
961
|
+
if (error instanceof CacheKeyError) {
|
|
962
|
+
throw error;
|
|
963
|
+
}
|
|
964
|
+
throw new CacheError(`Failed to delete cache for key "${key}": ${error.message}`, core.ErrorCode.CACHE_DELETE_FAILED, error);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
async deleteMany(keys) {
|
|
968
|
+
if (keys.length === 0) {
|
|
969
|
+
return 0;
|
|
970
|
+
}
|
|
971
|
+
try {
|
|
972
|
+
const normalizedKeys = keys.map((key) => {
|
|
973
|
+
try {
|
|
974
|
+
const normalized = this.validateAndNormalizeKey(key);
|
|
975
|
+
return this.enrichKeyWithContext(normalized);
|
|
976
|
+
} catch (error) {
|
|
977
|
+
if (error instanceof CacheKeyError) {
|
|
978
|
+
this.logger.warn(`Invalid key in deleteMany "${key}": ${error.message}`);
|
|
979
|
+
}
|
|
980
|
+
return null;
|
|
981
|
+
}
|
|
982
|
+
}).filter((k) => k !== null);
|
|
983
|
+
if (normalizedKeys.length === 0) {
|
|
984
|
+
return 0;
|
|
985
|
+
}
|
|
986
|
+
let deletedCount = 0;
|
|
987
|
+
if (this.l1Enabled) {
|
|
988
|
+
for (const key of normalizedKeys) {
|
|
989
|
+
const deleted = await this.l1Store.delete(key);
|
|
990
|
+
if (deleted) deletedCount++;
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
if (this.l2Enabled && normalizedKeys.length > 0) {
|
|
994
|
+
const fullKeys = normalizedKeys.map((k) => `${this.keyPrefix}${k}`);
|
|
995
|
+
const pipeline = this.driver.pipeline();
|
|
996
|
+
for (const fullKey of fullKeys) {
|
|
997
|
+
pipeline.del(fullKey);
|
|
998
|
+
}
|
|
999
|
+
const results = await pipeline.exec();
|
|
1000
|
+
let l2Count = 0;
|
|
1001
|
+
if (results) {
|
|
1002
|
+
for (const [error, result] of results) {
|
|
1003
|
+
if (!error && typeof result === "number" && result > 0) {
|
|
1004
|
+
l2Count++;
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
deletedCount = Math.max(deletedCount, l2Count);
|
|
1009
|
+
}
|
|
1010
|
+
return deletedCount;
|
|
1011
|
+
} catch (error) {
|
|
1012
|
+
throw new CacheError(`Failed to delete multiple keys: ${error.message}`, core.ErrorCode.CACHE_DELETE_FAILED, error);
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
async clear() {
|
|
1016
|
+
try {
|
|
1017
|
+
if (this.l1Enabled) {
|
|
1018
|
+
await this.l1Store.clear();
|
|
1019
|
+
}
|
|
1020
|
+
if (this.l2Enabled) {
|
|
1021
|
+
await this.l2Store.clear();
|
|
1022
|
+
}
|
|
1023
|
+
if (this.tagsEnabled) {
|
|
1024
|
+
await this.tagIndex.clearAllTags();
|
|
1025
|
+
}
|
|
1026
|
+
} catch (error) {
|
|
1027
|
+
throw new CacheError(`Failed to clear cache: ${error.message}`, core.ErrorCode.CACHE_CLEAR_FAILED, error);
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
async has(key) {
|
|
1031
|
+
try {
|
|
1032
|
+
const normalizedKey = this.validateAndNormalizeKey(key);
|
|
1033
|
+
const enrichedKey = this.enrichKeyWithContext(normalizedKey);
|
|
1034
|
+
if (this.l1Enabled) {
|
|
1035
|
+
const l1Has = await this.l1Store.has(enrichedKey);
|
|
1036
|
+
if (l1Has) return true;
|
|
1037
|
+
}
|
|
1038
|
+
if (this.l2Enabled) {
|
|
1039
|
+
return await this.l2Store.has(enrichedKey);
|
|
1040
|
+
}
|
|
1041
|
+
return false;
|
|
1042
|
+
} catch (error) {
|
|
1043
|
+
if (error instanceof CacheKeyError) {
|
|
1044
|
+
this.logger.warn(`Invalid cache key "${key}": ${error.message}`);
|
|
1045
|
+
}
|
|
1046
|
+
return false;
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
async invalidateTag(tag) {
|
|
1050
|
+
if (!this.tagsEnabled) {
|
|
1051
|
+
return 0;
|
|
1052
|
+
}
|
|
1053
|
+
try {
|
|
1054
|
+
const keysWithPrefix = await this.tagIndex.getKeysByTag(tag);
|
|
1055
|
+
if (this.l1Enabled) {
|
|
1056
|
+
const keysWithoutPrefix = keysWithPrefix.map((key) => key.startsWith(this.keyPrefix) ? key.slice(this.keyPrefix.length) : key);
|
|
1057
|
+
await Promise.all(keysWithoutPrefix.map((key) => this.l1Store.delete(key)));
|
|
1058
|
+
}
|
|
1059
|
+
return await this.tagIndex.invalidateTag(tag);
|
|
1060
|
+
} catch (error) {
|
|
1061
|
+
throw new CacheError(`Failed to invalidate tag "${tag}": ${error.message}`, core.ErrorCode.CACHE_TAG_INVALIDATION_FAILED, error);
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
async invalidateTags(tags) {
|
|
1065
|
+
if (!this.tagsEnabled || tags.length === 0) {
|
|
1066
|
+
return 0;
|
|
1067
|
+
}
|
|
1068
|
+
try {
|
|
1069
|
+
let total = 0;
|
|
1070
|
+
for (const tag of tags) {
|
|
1071
|
+
const count = await this.invalidateTag(tag);
|
|
1072
|
+
total += count;
|
|
1073
|
+
}
|
|
1074
|
+
return total;
|
|
1075
|
+
} catch (error) {
|
|
1076
|
+
throw new CacheError(`Failed to invalidate tags: ${error.message}`, core.ErrorCode.CACHE_TAG_INVALIDATION_FAILED, error);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
async getKeysByTag(tag) {
|
|
1080
|
+
if (!this.tagsEnabled) {
|
|
1081
|
+
return [];
|
|
1082
|
+
}
|
|
1083
|
+
try {
|
|
1084
|
+
const validTag = Tag.create(tag);
|
|
1085
|
+
const keysWithPrefix = await this.tagIndex.getKeysByTag(validTag.toString());
|
|
1086
|
+
return keysWithPrefix.map((key) => key.startsWith(this.keyPrefix) ? key.slice(this.keyPrefix.length) : key);
|
|
1087
|
+
} catch (error) {
|
|
1088
|
+
this.logger.error(`Failed to get keys for tag ${tag}:`, error);
|
|
1089
|
+
return [];
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
async getMany(keys) {
|
|
1093
|
+
if (keys.length === 0) {
|
|
1094
|
+
return [];
|
|
1095
|
+
}
|
|
1096
|
+
try {
|
|
1097
|
+
const normalizedKeys = keys.map((key) => {
|
|
1098
|
+
try {
|
|
1099
|
+
const normalized = this.validateAndNormalizeKey(key);
|
|
1100
|
+
return this.enrichKeyWithContext(normalized);
|
|
1101
|
+
} catch (error) {
|
|
1102
|
+
if (error instanceof CacheKeyError) {
|
|
1103
|
+
this.logger.warn(`Invalid cache key in getMany "${key}": ${error.message}`);
|
|
1104
|
+
}
|
|
1105
|
+
return null;
|
|
1106
|
+
}
|
|
1107
|
+
});
|
|
1108
|
+
if (!this.l2Enabled) {
|
|
1109
|
+
return keys.map(() => null);
|
|
1110
|
+
}
|
|
1111
|
+
const validKeys = [];
|
|
1112
|
+
const indexMap = /* @__PURE__ */ new Map();
|
|
1113
|
+
normalizedKeys.forEach((key, index) => {
|
|
1114
|
+
if (key !== null) {
|
|
1115
|
+
indexMap.set(validKeys.length, index);
|
|
1116
|
+
validKeys.push(key);
|
|
1117
|
+
}
|
|
1118
|
+
});
|
|
1119
|
+
if (validKeys.length === 0) {
|
|
1120
|
+
return keys.map(() => null);
|
|
1121
|
+
}
|
|
1122
|
+
const entries = await this.l2Store.getMany(validKeys);
|
|
1123
|
+
const result = keys.map(() => null);
|
|
1124
|
+
entries.forEach((entry, validIndex) => {
|
|
1125
|
+
const originalIndex = indexMap.get(validIndex);
|
|
1126
|
+
if (originalIndex !== void 0) {
|
|
1127
|
+
result[originalIndex] = entry ? entry.value : null;
|
|
1128
|
+
}
|
|
1129
|
+
});
|
|
1130
|
+
return result;
|
|
1131
|
+
} catch (error) {
|
|
1132
|
+
this.logger.error("Failed to getMany:", error);
|
|
1133
|
+
return keys.map(() => null);
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
async setMany(entries) {
|
|
1137
|
+
if (entries.length === 0) {
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
try {
|
|
1141
|
+
if (!this.l2Enabled) {
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
const maxTtl = this.options.l2?.maxTtl ?? 86400;
|
|
1145
|
+
const defaultTtl = this.options.l2?.defaultTtl ?? 3600;
|
|
1146
|
+
const cacheEntries = entries.map(({ key, value, ttl }) => {
|
|
1147
|
+
const entryTtl = ttl ?? defaultTtl;
|
|
1148
|
+
return {
|
|
1149
|
+
key: this.enrichKeyWithContext(this.validateAndNormalizeKey(key)),
|
|
1150
|
+
entry: CacheEntry.create(value, Math.min(entryTtl, maxTtl)),
|
|
1151
|
+
ttl: entryTtl
|
|
1152
|
+
};
|
|
1153
|
+
});
|
|
1154
|
+
await this.l2Store.setMany(cacheEntries);
|
|
1155
|
+
if (this.tagsEnabled) {
|
|
1156
|
+
for (let i = 0; i < entries.length; i++) {
|
|
1157
|
+
const entry = entries[i];
|
|
1158
|
+
const { tags } = entry;
|
|
1159
|
+
if (tags && tags.length > 0) {
|
|
1160
|
+
const enrichedKey = cacheEntries[i].key;
|
|
1161
|
+
const fullKey = `${this.keyPrefix}${enrichedKey}`;
|
|
1162
|
+
const validatedTags = Tags.create(tags).toStrings();
|
|
1163
|
+
await this.tagIndex.addKeyToTags(fullKey, validatedTags);
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
} catch (error) {
|
|
1168
|
+
if (error instanceof CacheKeyError) {
|
|
1169
|
+
throw error;
|
|
1170
|
+
}
|
|
1171
|
+
throw new CacheError(`Failed to setMany: ${error.message}`, core.ErrorCode.CACHE_SET_FAILED, error);
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
async ttl(key) {
|
|
1175
|
+
if (!this.l2Enabled) {
|
|
1176
|
+
return -1;
|
|
1177
|
+
}
|
|
1178
|
+
try {
|
|
1179
|
+
const normalizedKey = this.validateAndNormalizeKey(key);
|
|
1180
|
+
const enrichedKey = this.enrichKeyWithContext(normalizedKey);
|
|
1181
|
+
return await this.l2Store.ttl(enrichedKey);
|
|
1182
|
+
} catch (error) {
|
|
1183
|
+
if (error instanceof CacheKeyError) {
|
|
1184
|
+
this.logger.warn(`Invalid cache key "${key}": ${error.message}`);
|
|
1185
|
+
}
|
|
1186
|
+
return -1;
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
async getStats() {
|
|
1190
|
+
const l1Stats = this.l1Enabled ? this.l1Store.getStats() : { hits: 0, misses: 0, size: 0 };
|
|
1191
|
+
const l2Stats = this.l2Enabled ? await this.l2Store.getStats() : { hits: 0, misses: 0 };
|
|
1192
|
+
const stampedeStats = this.stampedeEnabled ? this.stampede.getStats() : { prevented: 0 };
|
|
1193
|
+
return {
|
|
1194
|
+
l1: l1Stats,
|
|
1195
|
+
l2: l2Stats,
|
|
1196
|
+
stampedePrevented: stampedeStats.prevented
|
|
1197
|
+
};
|
|
1198
|
+
}
|
|
1199
|
+
async invalidateByPattern(pattern) {
|
|
1200
|
+
if (!this.l2Enabled) {
|
|
1201
|
+
return 0;
|
|
1202
|
+
}
|
|
1203
|
+
try {
|
|
1204
|
+
const result = await this.l2Store.scan(pattern);
|
|
1205
|
+
const keys = result.keys;
|
|
1206
|
+
if (keys.length === 0) {
|
|
1207
|
+
return 0;
|
|
1208
|
+
}
|
|
1209
|
+
if (this.l1Enabled) {
|
|
1210
|
+
const keysWithoutPrefix = keys.map((key) => key.startsWith(this.keyPrefix) ? key.slice(this.keyPrefix.length) : key);
|
|
1211
|
+
await Promise.all(keysWithoutPrefix.map((key) => this.l1Store.delete(key)));
|
|
1212
|
+
}
|
|
1213
|
+
let deleted = 0;
|
|
1214
|
+
for (const key of keys) {
|
|
1215
|
+
const wasDeleted = await this.l2Store.delete(key);
|
|
1216
|
+
if (wasDeleted) {
|
|
1217
|
+
deleted++;
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
return deleted;
|
|
1221
|
+
} catch (error) {
|
|
1222
|
+
throw new CacheError(`Failed to invalidate by pattern "${pattern}": ${error.message}`, core.ErrorCode.CACHE_DELETE_FAILED, error);
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
/**
|
|
1226
|
+
* Validates and normalizes cache key using CacheKey value object.
|
|
1227
|
+
*
|
|
1228
|
+
* @param rawKey - Raw key string
|
|
1229
|
+
* @returns Normalized key string (without prefix - prefix added by L2 store)
|
|
1230
|
+
* @throws CacheKeyError if validation fails
|
|
1231
|
+
*
|
|
1232
|
+
* @private
|
|
1233
|
+
*/
|
|
1234
|
+
validateAndNormalizeKey(rawKey) {
|
|
1235
|
+
try {
|
|
1236
|
+
const keyOptions = {
|
|
1237
|
+
maxLength: this.options.keys?.maxLength ?? 1024,
|
|
1238
|
+
version: this.options.keys?.version,
|
|
1239
|
+
separator: this.options.keys?.separator ?? ":",
|
|
1240
|
+
// Don't include prefix here - it's added by L2 store
|
|
1241
|
+
prefix: ""
|
|
1242
|
+
};
|
|
1243
|
+
const cacheKey = CacheKey.create(rawKey, keyOptions);
|
|
1244
|
+
return cacheKey.getRaw();
|
|
1245
|
+
} catch (error) {
|
|
1246
|
+
if (error instanceof CacheKeyError) {
|
|
1247
|
+
throw error;
|
|
1248
|
+
}
|
|
1249
|
+
throw new CacheKeyError(rawKey, `Invalid cache key: ${error.message}`);
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
/**
|
|
1253
|
+
* Enriches a normalized key with context values.
|
|
1254
|
+
* Appends global context keys and per-call varyBy values as key suffix.
|
|
1255
|
+
* Uses a marker (_ctx_) to prevent double-enrichment in internal call chains.
|
|
1256
|
+
*
|
|
1257
|
+
* @param normalizedKey - Already validated and normalized cache key
|
|
1258
|
+
* @param varyBy - Optional per-call context overrides
|
|
1259
|
+
* @returns Enriched key with context suffix, or original key if no context
|
|
1260
|
+
* @private
|
|
1261
|
+
*/
|
|
1262
|
+
enrichKeyWithContext(normalizedKey, varyBy) {
|
|
1263
|
+
const separator = this.options.keys?.separator ?? ":";
|
|
1264
|
+
const marker = `${separator}_ctx_${separator}`;
|
|
1265
|
+
if (normalizedKey.includes(marker)) {
|
|
1266
|
+
return normalizedKey;
|
|
1267
|
+
}
|
|
1268
|
+
const contextProvider = this.options.contextProvider;
|
|
1269
|
+
const contextKeys = this.options.contextKeys;
|
|
1270
|
+
const contextMap = /* @__PURE__ */ new Map();
|
|
1271
|
+
if (contextProvider && contextKeys && contextKeys.length > 0) {
|
|
1272
|
+
for (const ctxKey of contextKeys) {
|
|
1273
|
+
const value = contextProvider.get(ctxKey);
|
|
1274
|
+
if (value !== void 0 && value !== null) {
|
|
1275
|
+
contextMap.set(ctxKey, String(value));
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
if (varyBy) {
|
|
1280
|
+
for (const [k, v] of Object.entries(varyBy)) {
|
|
1281
|
+
contextMap.set(k, v);
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
if (contextMap.size === 0) return normalizedKey;
|
|
1285
|
+
const sortedEntries = [...contextMap.entries()].sort(([a], [b]) => a.localeCompare(b));
|
|
1286
|
+
const suffix = sortedEntries.map(([k, v]) => `${this.sanitizeForKey(k)}.${this.sanitizeForKey(v)}`).join(separator);
|
|
1287
|
+
return `${normalizedKey}${marker}${suffix}`;
|
|
1288
|
+
}
|
|
1289
|
+
/**
|
|
1290
|
+
* Sanitizes a value for use in cache key (removes non-allowed characters).
|
|
1291
|
+
* @private
|
|
1292
|
+
*/
|
|
1293
|
+
sanitizeForKey(value) {
|
|
1294
|
+
return String(value).replace(/[^a-zA-Z0-9\-_]/g, "_");
|
|
1295
|
+
}
|
|
1296
|
+
};
|
|
1297
|
+
CacheService = __decorateClass([
|
|
1298
|
+
common.Injectable(),
|
|
1299
|
+
__decorateParam(0, common.Inject(core.REDIS_DRIVER)),
|
|
1300
|
+
__decorateParam(1, common.Inject(L1_CACHE_STORE)),
|
|
1301
|
+
__decorateParam(2, common.Inject(L2_CACHE_STORE)),
|
|
1302
|
+
__decorateParam(3, common.Inject(STAMPEDE_PROTECTION)),
|
|
1303
|
+
__decorateParam(4, common.Inject(TAG_INDEX)),
|
|
1304
|
+
__decorateParam(5, common.Inject(SWR_MANAGER)),
|
|
1305
|
+
__decorateParam(6, common.Inject(CACHE_PLUGIN_OPTIONS)),
|
|
1306
|
+
__decorateParam(7, common.Optional()),
|
|
1307
|
+
__decorateParam(7, common.Inject(METRICS_SERVICE)),
|
|
1308
|
+
__decorateParam(8, common.Optional()),
|
|
1309
|
+
__decorateParam(8, common.Inject(TRACING_SERVICE))
|
|
1310
|
+
], CacheService);
|
|
1311
|
+
var WarmupService = class {
|
|
1312
|
+
constructor(cacheService, options) {
|
|
1313
|
+
this.cacheService = cacheService;
|
|
1314
|
+
this.options = options;
|
|
1315
|
+
this.enabled = options.warmup?.enabled ?? false;
|
|
1316
|
+
this.keys = options.warmup?.keys ?? [];
|
|
1317
|
+
this.concurrency = options.warmup?.concurrency ?? 10;
|
|
1318
|
+
}
|
|
1319
|
+
logger = new common.Logger(WarmupService.name);
|
|
1320
|
+
enabled;
|
|
1321
|
+
keys;
|
|
1322
|
+
concurrency;
|
|
1323
|
+
async onModuleInit() {
|
|
1324
|
+
if (!this.enabled || this.keys.length === 0) {
|
|
1325
|
+
return;
|
|
1326
|
+
}
|
|
1327
|
+
this.logger.log(`Starting cache warmup for ${this.keys.length} keys...`);
|
|
1328
|
+
const startTime = Date.now();
|
|
1329
|
+
const chunks = [];
|
|
1330
|
+
for (let i = 0; i < this.keys.length; i += this.concurrency) {
|
|
1331
|
+
chunks.push(this.keys.slice(i, i + this.concurrency));
|
|
1332
|
+
}
|
|
1333
|
+
let succeeded = 0;
|
|
1334
|
+
let failed = 0;
|
|
1335
|
+
for (const chunk of chunks) {
|
|
1336
|
+
const results = await Promise.allSettled(chunk.map((warmupKey) => this.warmupKey(warmupKey)));
|
|
1337
|
+
succeeded += results.filter((r) => r.status === "fulfilled").length;
|
|
1338
|
+
failed += results.filter((r) => r.status === "rejected").length;
|
|
1339
|
+
}
|
|
1340
|
+
const duration = Date.now() - startTime;
|
|
1341
|
+
this.logger.log(`Cache warmup completed: ${succeeded} succeeded, ${failed} failed (${duration}ms)`);
|
|
1342
|
+
}
|
|
1343
|
+
/**
|
|
1344
|
+
* Warms up a single cache key.
|
|
1345
|
+
*
|
|
1346
|
+
* @param warmupKey - Warmup key configuration
|
|
1347
|
+
* @private
|
|
1348
|
+
*/
|
|
1349
|
+
async warmupKey(warmupKey) {
|
|
1350
|
+
try {
|
|
1351
|
+
this.logger.debug(`Warming up key: ${warmupKey.key}`);
|
|
1352
|
+
await this.cacheService.getOrSet(warmupKey.key, warmupKey.loader, {
|
|
1353
|
+
ttl: warmupKey.ttl,
|
|
1354
|
+
tags: warmupKey.tags
|
|
1355
|
+
});
|
|
1356
|
+
this.logger.debug(`Successfully warmed up key: ${warmupKey.key}`);
|
|
1357
|
+
} catch (error) {
|
|
1358
|
+
this.logger.error(`Failed to warm up key ${warmupKey.key}:`, error);
|
|
1359
|
+
throw error;
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
};
|
|
1363
|
+
WarmupService = __decorateClass([
|
|
1364
|
+
common.Injectable(),
|
|
1365
|
+
__decorateParam(0, common.Inject(CACHE_SERVICE)),
|
|
1366
|
+
__decorateParam(1, common.Inject(CACHE_PLUGIN_OPTIONS))
|
|
1367
|
+
], WarmupService);
|
|
1368
|
+
var Serializer = class {
|
|
1369
|
+
/**
|
|
1370
|
+
* Serializes value to string.
|
|
1371
|
+
*
|
|
1372
|
+
* @param value - Value to serialize
|
|
1373
|
+
* @returns Serialized string
|
|
1374
|
+
* @throws SerializationError if serialization fails
|
|
1375
|
+
*/
|
|
1376
|
+
serialize(value) {
|
|
1377
|
+
try {
|
|
1378
|
+
return JSON.stringify(value);
|
|
1379
|
+
} catch (error) {
|
|
1380
|
+
throw new SerializationError(`Failed to serialize value: ${error.message}`, error);
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
/**
|
|
1384
|
+
* Deserializes string to value.
|
|
1385
|
+
*
|
|
1386
|
+
* @param serialized - Serialized string
|
|
1387
|
+
* @returns Deserialized value
|
|
1388
|
+
* @throws SerializationError if deserialization fails
|
|
1389
|
+
*/
|
|
1390
|
+
deserialize(serialized) {
|
|
1391
|
+
try {
|
|
1392
|
+
return JSON.parse(serialized);
|
|
1393
|
+
} catch (error) {
|
|
1394
|
+
throw new SerializationError(`Failed to deserialize value: ${error.message}`, error);
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
/**
|
|
1398
|
+
* Safely tries to deserialize, returns null on error.
|
|
1399
|
+
*
|
|
1400
|
+
* @param serialized - Serialized string
|
|
1401
|
+
* @returns Deserialized value or null
|
|
1402
|
+
*/
|
|
1403
|
+
tryDeserialize(serialized) {
|
|
1404
|
+
try {
|
|
1405
|
+
return this.deserialize(serialized);
|
|
1406
|
+
} catch {
|
|
1407
|
+
return null;
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
};
|
|
1411
|
+
Serializer = __decorateClass([
|
|
1412
|
+
common.Injectable()
|
|
1413
|
+
], Serializer);
|
|
1414
|
+
var L1MemoryStoreAdapter = class {
|
|
1415
|
+
constructor(options) {
|
|
1416
|
+
this.options = options;
|
|
1417
|
+
this.maxSize = options.l1?.maxSize ?? 1e3;
|
|
1418
|
+
this.defaultTtl = (options.l1?.ttl ?? 60) * 1e3;
|
|
1419
|
+
this.evictionPolicy = options.l1?.evictionPolicy ?? "lru";
|
|
1420
|
+
}
|
|
1421
|
+
cache = /* @__PURE__ */ new Map();
|
|
1422
|
+
head = null;
|
|
1423
|
+
tail = null;
|
|
1424
|
+
maxSize;
|
|
1425
|
+
defaultTtl;
|
|
1426
|
+
evictionPolicy;
|
|
1427
|
+
hits = 0;
|
|
1428
|
+
misses = 0;
|
|
1429
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
1430
|
+
async get(key) {
|
|
1431
|
+
const node = this.cache.get(key);
|
|
1432
|
+
if (!node) {
|
|
1433
|
+
this.misses++;
|
|
1434
|
+
return null;
|
|
1435
|
+
}
|
|
1436
|
+
if (Date.now() > node.expiresAt) {
|
|
1437
|
+
void this.delete(key);
|
|
1438
|
+
this.misses++;
|
|
1439
|
+
return null;
|
|
1440
|
+
}
|
|
1441
|
+
if (this.evictionPolicy === "lru") {
|
|
1442
|
+
this.moveToFront(node);
|
|
1443
|
+
} else {
|
|
1444
|
+
node.frequency++;
|
|
1445
|
+
}
|
|
1446
|
+
this.hits++;
|
|
1447
|
+
return node.entry;
|
|
1448
|
+
}
|
|
1449
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
1450
|
+
async set(key, entry, ttl) {
|
|
1451
|
+
const existingNode = this.cache.get(key);
|
|
1452
|
+
if (existingNode) {
|
|
1453
|
+
existingNode.entry = entry;
|
|
1454
|
+
existingNode.expiresAt = Date.now() + (ttl ?? this.defaultTtl);
|
|
1455
|
+
if (this.evictionPolicy === "lru") {
|
|
1456
|
+
this.moveToFront(existingNode);
|
|
1457
|
+
} else {
|
|
1458
|
+
existingNode.frequency++;
|
|
1459
|
+
}
|
|
1460
|
+
} else {
|
|
1461
|
+
if (this.cache.size >= this.maxSize) {
|
|
1462
|
+
this.evict();
|
|
1463
|
+
}
|
|
1464
|
+
const node = {
|
|
1465
|
+
key,
|
|
1466
|
+
entry,
|
|
1467
|
+
prev: null,
|
|
1468
|
+
next: this.head,
|
|
1469
|
+
expiresAt: Date.now() + (ttl ?? this.defaultTtl),
|
|
1470
|
+
frequency: 1
|
|
1471
|
+
};
|
|
1472
|
+
if (this.head) {
|
|
1473
|
+
this.head.prev = node;
|
|
1474
|
+
}
|
|
1475
|
+
this.head = node;
|
|
1476
|
+
if (!this.tail) {
|
|
1477
|
+
this.tail = node;
|
|
1478
|
+
}
|
|
1479
|
+
this.cache.set(key, node);
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
1483
|
+
async delete(key) {
|
|
1484
|
+
const node = this.cache.get(key);
|
|
1485
|
+
if (!node) {
|
|
1486
|
+
return false;
|
|
1487
|
+
}
|
|
1488
|
+
this.removeNode(node);
|
|
1489
|
+
this.cache.delete(key);
|
|
1490
|
+
return true;
|
|
1491
|
+
}
|
|
1492
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
1493
|
+
async clear() {
|
|
1494
|
+
this.cache.clear();
|
|
1495
|
+
this.head = null;
|
|
1496
|
+
this.tail = null;
|
|
1497
|
+
}
|
|
1498
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
1499
|
+
async has(key) {
|
|
1500
|
+
const node = this.cache.get(key);
|
|
1501
|
+
if (!node) {
|
|
1502
|
+
return false;
|
|
1503
|
+
}
|
|
1504
|
+
if (Date.now() > node.expiresAt) {
|
|
1505
|
+
void this.delete(key);
|
|
1506
|
+
return false;
|
|
1507
|
+
}
|
|
1508
|
+
return true;
|
|
1509
|
+
}
|
|
1510
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
1511
|
+
async size() {
|
|
1512
|
+
const now = Date.now();
|
|
1513
|
+
for (const [key, node] of this.cache.entries()) {
|
|
1514
|
+
if (now > node.expiresAt) {
|
|
1515
|
+
void this.delete(key);
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
return this.cache.size;
|
|
1519
|
+
}
|
|
1520
|
+
moveToFront(node) {
|
|
1521
|
+
if (node === this.head) {
|
|
1522
|
+
return;
|
|
1523
|
+
}
|
|
1524
|
+
this.removeNode(node);
|
|
1525
|
+
node.prev = null;
|
|
1526
|
+
node.next = this.head;
|
|
1527
|
+
if (this.head) {
|
|
1528
|
+
this.head.prev = node;
|
|
1529
|
+
}
|
|
1530
|
+
this.head = node;
|
|
1531
|
+
if (!this.tail) {
|
|
1532
|
+
this.tail = node;
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
removeNode(node) {
|
|
1536
|
+
if (node.prev) {
|
|
1537
|
+
node.prev.next = node.next;
|
|
1538
|
+
} else {
|
|
1539
|
+
this.head = node.next;
|
|
1540
|
+
}
|
|
1541
|
+
if (node.next) {
|
|
1542
|
+
node.next.prev = node.prev;
|
|
1543
|
+
} else {
|
|
1544
|
+
this.tail = node.prev;
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
evict() {
|
|
1548
|
+
if (this.evictionPolicy === "lfu") {
|
|
1549
|
+
this.evictLFU();
|
|
1550
|
+
} else {
|
|
1551
|
+
this.evictLRU();
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
evictLRU() {
|
|
1555
|
+
if (!this.tail) {
|
|
1556
|
+
return;
|
|
1557
|
+
}
|
|
1558
|
+
const key = this.tail.key;
|
|
1559
|
+
this.removeNode(this.tail);
|
|
1560
|
+
this.cache.delete(key);
|
|
1561
|
+
}
|
|
1562
|
+
evictLFU() {
|
|
1563
|
+
if (this.cache.size === 0) {
|
|
1564
|
+
return;
|
|
1565
|
+
}
|
|
1566
|
+
let victim = null;
|
|
1567
|
+
let current = this.tail;
|
|
1568
|
+
while (current) {
|
|
1569
|
+
if (!victim || current.frequency < victim.frequency) {
|
|
1570
|
+
victim = current;
|
|
1571
|
+
}
|
|
1572
|
+
current = current.prev;
|
|
1573
|
+
}
|
|
1574
|
+
if (victim) {
|
|
1575
|
+
this.removeNode(victim);
|
|
1576
|
+
this.cache.delete(victim.key);
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
getStats() {
|
|
1580
|
+
return {
|
|
1581
|
+
hits: this.hits,
|
|
1582
|
+
misses: this.misses,
|
|
1583
|
+
size: this.cache.size
|
|
1584
|
+
};
|
|
1585
|
+
}
|
|
1586
|
+
};
|
|
1587
|
+
L1MemoryStoreAdapter = __decorateClass([
|
|
1588
|
+
common.Injectable(),
|
|
1589
|
+
__decorateParam(0, common.Inject(CACHE_PLUGIN_OPTIONS))
|
|
1590
|
+
], L1MemoryStoreAdapter);
|
|
1591
|
+
var DEFAULT_BATCH_SIZE = 100;
|
|
1592
|
+
var L2RedisStoreAdapter = class {
|
|
1593
|
+
constructor(driver, options, serializer) {
|
|
1594
|
+
this.driver = driver;
|
|
1595
|
+
this.options = options;
|
|
1596
|
+
this.serializer = serializer;
|
|
1597
|
+
this.keyPrefix = options.l2?.keyPrefix ?? "cache:";
|
|
1598
|
+
this.defaultTtl = options.l2?.defaultTtl ?? 3600;
|
|
1599
|
+
}
|
|
1600
|
+
keyPrefix;
|
|
1601
|
+
defaultTtl;
|
|
1602
|
+
hits = 0;
|
|
1603
|
+
misses = 0;
|
|
1604
|
+
async get(key) {
|
|
1605
|
+
try {
|
|
1606
|
+
const fullKey = this.buildKey(key);
|
|
1607
|
+
const value = await this.driver.get(fullKey);
|
|
1608
|
+
if (!value) {
|
|
1609
|
+
this.misses++;
|
|
1610
|
+
return null;
|
|
1611
|
+
}
|
|
1612
|
+
this.hits++;
|
|
1613
|
+
return this.serializer.deserialize(value);
|
|
1614
|
+
} catch {
|
|
1615
|
+
this.misses++;
|
|
1616
|
+
return null;
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
async set(key, entry, ttl) {
|
|
1620
|
+
try {
|
|
1621
|
+
const fullKey = this.buildKey(key);
|
|
1622
|
+
const serialized = this.serializer.serialize(entry);
|
|
1623
|
+
const ttlSeconds = ttl ?? this.defaultTtl;
|
|
1624
|
+
await this.driver.setex(fullKey, ttlSeconds, serialized);
|
|
1625
|
+
} catch (error) {
|
|
1626
|
+
throw new CacheError(`Failed to set cache entry for key "${key}": ${error.message}`, core.ErrorCode.CACHE_SET_FAILED, error);
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
async delete(key) {
|
|
1630
|
+
try {
|
|
1631
|
+
const fullKey = this.buildKey(key);
|
|
1632
|
+
const result = await this.driver.del(fullKey);
|
|
1633
|
+
return result > 0;
|
|
1634
|
+
} catch (error) {
|
|
1635
|
+
throw new CacheError(`Failed to delete cache entry for key "${key}": ${error.message}`, core.ErrorCode.CACHE_DELETE_FAILED, error);
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
async clear() {
|
|
1639
|
+
try {
|
|
1640
|
+
const pattern = `${this.keyPrefix}*`;
|
|
1641
|
+
const keys = await this.scanKeys(pattern);
|
|
1642
|
+
if (keys.length === 0) {
|
|
1643
|
+
return;
|
|
1644
|
+
}
|
|
1645
|
+
const batchSize = DEFAULT_BATCH_SIZE;
|
|
1646
|
+
for (let i = 0; i < keys.length; i += batchSize) {
|
|
1647
|
+
const batch = keys.slice(i, i + batchSize);
|
|
1648
|
+
await Promise.all(batch.map((key) => this.driver.del(key)));
|
|
1649
|
+
}
|
|
1650
|
+
} catch (error) {
|
|
1651
|
+
throw new CacheError(`Failed to clear cache: ${error.message}`, core.ErrorCode.CACHE_CLEAR_FAILED, error);
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
async has(key) {
|
|
1655
|
+
try {
|
|
1656
|
+
const fullKey = this.buildKey(key);
|
|
1657
|
+
const exists = await this.driver.exists(fullKey);
|
|
1658
|
+
return exists > 0;
|
|
1659
|
+
} catch {
|
|
1660
|
+
return false;
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
async ttl(key) {
|
|
1664
|
+
try {
|
|
1665
|
+
const fullKey = this.buildKey(key);
|
|
1666
|
+
const ttl = await this.driver.ttl(fullKey);
|
|
1667
|
+
return ttl;
|
|
1668
|
+
} catch {
|
|
1669
|
+
return -1;
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
async expire(key, ttl) {
|
|
1673
|
+
try {
|
|
1674
|
+
const fullKey = this.buildKey(key);
|
|
1675
|
+
const result = await this.driver.expire(fullKey, ttl);
|
|
1676
|
+
return result === 1;
|
|
1677
|
+
} catch (error) {
|
|
1678
|
+
throw new CacheError(`Failed to set expiration for key "${key}": ${error.message}`, core.ErrorCode.CACHE_OPERATION_FAILED, error);
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
async scan(pattern, count = DEFAULT_BATCH_SIZE) {
|
|
1682
|
+
try {
|
|
1683
|
+
const fullPattern = `${this.keyPrefix}${pattern}`;
|
|
1684
|
+
const keys = await this.scanKeys(fullPattern, count);
|
|
1685
|
+
const strippedKeys = keys.map((key) => key.startsWith(this.keyPrefix) ? key.slice(this.keyPrefix.length) : key);
|
|
1686
|
+
return {
|
|
1687
|
+
keys: strippedKeys,
|
|
1688
|
+
cursor: "0"
|
|
1689
|
+
// Simplified: full scan completed
|
|
1690
|
+
};
|
|
1691
|
+
} catch (error) {
|
|
1692
|
+
throw new CacheError(`Failed to scan keys with pattern "${pattern}": ${error.message}`, core.ErrorCode.CACHE_OPERATION_FAILED, error);
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
async getMany(keys) {
|
|
1696
|
+
try {
|
|
1697
|
+
if (keys.length === 0) {
|
|
1698
|
+
return [];
|
|
1699
|
+
}
|
|
1700
|
+
const fullKeys = keys.map((key) => this.buildKey(key));
|
|
1701
|
+
const values = await this.driver.mget(...fullKeys);
|
|
1702
|
+
return values.map((value) => {
|
|
1703
|
+
if (!value) {
|
|
1704
|
+
this.misses++;
|
|
1705
|
+
return null;
|
|
1706
|
+
}
|
|
1707
|
+
this.hits++;
|
|
1708
|
+
return this.serializer.tryDeserialize(value);
|
|
1709
|
+
});
|
|
1710
|
+
} catch {
|
|
1711
|
+
return keys.map(() => null);
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
async setMany(entries) {
|
|
1715
|
+
try {
|
|
1716
|
+
if (entries.length === 0) {
|
|
1717
|
+
return;
|
|
1718
|
+
}
|
|
1719
|
+
await Promise.all(
|
|
1720
|
+
entries.map(async ({ key, entry, ttl }) => {
|
|
1721
|
+
const fullKey = this.buildKey(key);
|
|
1722
|
+
const serialized = this.serializer.serialize(entry);
|
|
1723
|
+
const ttlSeconds = ttl ?? this.defaultTtl;
|
|
1724
|
+
await this.driver.setex(fullKey, ttlSeconds, serialized);
|
|
1725
|
+
})
|
|
1726
|
+
);
|
|
1727
|
+
} catch (error) {
|
|
1728
|
+
throw new CacheError(`Failed to set multiple cache entries: ${error.message}`, core.ErrorCode.CACHE_SET_FAILED, error);
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
buildKey(key) {
|
|
1732
|
+
return `${this.keyPrefix}${key}`;
|
|
1733
|
+
}
|
|
1734
|
+
async scanKeys(pattern, count = DEFAULT_BATCH_SIZE) {
|
|
1735
|
+
const keys = [];
|
|
1736
|
+
let cursor = 0;
|
|
1737
|
+
do {
|
|
1738
|
+
const result = await this.driver.scan(cursor, {
|
|
1739
|
+
match: pattern,
|
|
1740
|
+
count
|
|
1741
|
+
});
|
|
1742
|
+
cursor = parseInt(result[0], 10);
|
|
1743
|
+
keys.push(...result[1]);
|
|
1744
|
+
} while (cursor !== 0);
|
|
1745
|
+
return keys;
|
|
1746
|
+
}
|
|
1747
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
1748
|
+
async getStats() {
|
|
1749
|
+
return {
|
|
1750
|
+
hits: this.hits,
|
|
1751
|
+
misses: this.misses
|
|
1752
|
+
};
|
|
1753
|
+
}
|
|
1754
|
+
async getSwr(key) {
|
|
1755
|
+
try {
|
|
1756
|
+
const fullKey = this.buildKey(key);
|
|
1757
|
+
const value = await this.driver.get(fullKey);
|
|
1758
|
+
if (!value) {
|
|
1759
|
+
this.misses++;
|
|
1760
|
+
return null;
|
|
1761
|
+
}
|
|
1762
|
+
this.hits++;
|
|
1763
|
+
return this.serializer.deserialize(value);
|
|
1764
|
+
} catch {
|
|
1765
|
+
this.misses++;
|
|
1766
|
+
return null;
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
async setSwr(key, swrEntry) {
|
|
1770
|
+
try {
|
|
1771
|
+
const fullKey = this.buildKey(key);
|
|
1772
|
+
const serialized = this.serializer.serialize(swrEntry);
|
|
1773
|
+
const now = Date.now();
|
|
1774
|
+
const ttlMs = swrEntry.expiresAt - now;
|
|
1775
|
+
if (ttlMs <= 0) {
|
|
1776
|
+
return;
|
|
1777
|
+
}
|
|
1778
|
+
const ttlSeconds = Math.max(1, Math.ceil(ttlMs / 1e3));
|
|
1779
|
+
await this.driver.setex(fullKey, ttlSeconds, serialized);
|
|
1780
|
+
} catch (error) {
|
|
1781
|
+
throw new CacheError(`Failed to set SWR entry for key "${key}": ${error.message}`, core.ErrorCode.CACHE_SET_FAILED, error);
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
};
|
|
1785
|
+
L2RedisStoreAdapter = __decorateClass([
|
|
1786
|
+
common.Injectable(),
|
|
1787
|
+
__decorateParam(0, common.Inject(core.REDIS_DRIVER)),
|
|
1788
|
+
__decorateParam(1, common.Inject(CACHE_PLUGIN_OPTIONS)),
|
|
1789
|
+
__decorateParam(2, common.Inject(SERIALIZER))
|
|
1790
|
+
], L2RedisStoreAdapter);
|
|
1791
|
+
exports.CacheService = class CacheService2 {
|
|
1792
|
+
constructor(internalCache) {
|
|
1793
|
+
this.internalCache = internalCache;
|
|
1794
|
+
}
|
|
1795
|
+
/**
|
|
1796
|
+
* Gets value from cache.
|
|
1797
|
+
*
|
|
1798
|
+
* @param key - Cache key
|
|
1799
|
+
* @returns Cached value or null if not found
|
|
1800
|
+
*
|
|
1801
|
+
* @example
|
|
1802
|
+
* ```typescript
|
|
1803
|
+
* const user = await cacheService.get<User>('user:123');
|
|
1804
|
+
* ```
|
|
1805
|
+
*/
|
|
1806
|
+
async get(key) {
|
|
1807
|
+
return this.internalCache.get(key);
|
|
1808
|
+
}
|
|
1809
|
+
/**
|
|
1810
|
+
* Sets value in cache with optional TTL and tags.
|
|
1811
|
+
*
|
|
1812
|
+
* @param key - Cache key
|
|
1813
|
+
* @param value - Value to cache
|
|
1814
|
+
* @param options - Cache options (ttl, tags, strategy)
|
|
1815
|
+
*
|
|
1816
|
+
* @example
|
|
1817
|
+
* ```typescript
|
|
1818
|
+
* await cacheService.set('user:123', user, {
|
|
1819
|
+
* ttl: 3600,
|
|
1820
|
+
* tags: ['users', 'user:123']
|
|
1821
|
+
* });
|
|
1822
|
+
* ```
|
|
1823
|
+
*/
|
|
1824
|
+
async set(key, value, options) {
|
|
1825
|
+
return this.internalCache.set(key, value, options);
|
|
1826
|
+
}
|
|
1827
|
+
/**
|
|
1828
|
+
* Deletes key from cache.
|
|
1829
|
+
*
|
|
1830
|
+
* @param key - Cache key
|
|
1831
|
+
* @returns True if key was deleted, false if not found
|
|
1832
|
+
*
|
|
1833
|
+
* @example
|
|
1834
|
+
* ```typescript
|
|
1835
|
+
* const deleted = await cacheService.del('user:123');
|
|
1836
|
+
* ```
|
|
1837
|
+
*/
|
|
1838
|
+
async del(key) {
|
|
1839
|
+
return this.internalCache.delete(key);
|
|
1840
|
+
}
|
|
1841
|
+
/**
|
|
1842
|
+
* Gets multiple values from cache.
|
|
1843
|
+
*
|
|
1844
|
+
* @param keys - Array of cache keys
|
|
1845
|
+
* @returns Array of values (null for missing keys)
|
|
1846
|
+
*
|
|
1847
|
+
* @example
|
|
1848
|
+
* ```typescript
|
|
1849
|
+
* const users = await cacheService.getMany<User>(['user:1', 'user:2', 'user:3']);
|
|
1850
|
+
* ```
|
|
1851
|
+
*/
|
|
1852
|
+
async getMany(keys) {
|
|
1853
|
+
return this.internalCache.getMany(keys);
|
|
1854
|
+
}
|
|
1855
|
+
/**
|
|
1856
|
+
* Sets multiple values in cache.
|
|
1857
|
+
*
|
|
1858
|
+
* @param entries - Array of key-value-ttl tuples
|
|
1859
|
+
*
|
|
1860
|
+
* @example
|
|
1861
|
+
* ```typescript
|
|
1862
|
+
* await cacheService.setMany([
|
|
1863
|
+
* { key: 'user:1', value: user1, ttl: 3600 },
|
|
1864
|
+
* { key: 'user:2', value: user2, ttl: 3600 }
|
|
1865
|
+
* ]);
|
|
1866
|
+
* ```
|
|
1867
|
+
*/
|
|
1868
|
+
async setMany(entries) {
|
|
1869
|
+
return this.internalCache.setMany(entries);
|
|
1870
|
+
}
|
|
1871
|
+
/**
|
|
1872
|
+
* Gets value from cache or loads it using the provided loader function.
|
|
1873
|
+
* Implements cache-aside pattern with anti-stampede protection.
|
|
1874
|
+
*
|
|
1875
|
+
* @param key - Cache key
|
|
1876
|
+
* @param loader - Function to load value if not cached
|
|
1877
|
+
* @param options - Cache options
|
|
1878
|
+
* @returns Cached or loaded value
|
|
1879
|
+
*
|
|
1880
|
+
* @example
|
|
1881
|
+
* ```typescript
|
|
1882
|
+
* const user = await cacheService.getOrSet(
|
|
1883
|
+
* 'user:123',
|
|
1884
|
+
* async () => {
|
|
1885
|
+
* return await userRepository.findById('123');
|
|
1886
|
+
* },
|
|
1887
|
+
* { ttl: 3600, tags: ['users'] }
|
|
1888
|
+
* );
|
|
1889
|
+
* ```
|
|
1890
|
+
*/
|
|
1891
|
+
async getOrSet(key, loader, options) {
|
|
1892
|
+
return this.internalCache.getOrSet(key, loader, options);
|
|
1893
|
+
}
|
|
1894
|
+
/**
|
|
1895
|
+
* Wraps a function with caching logic.
|
|
1896
|
+
* Helper for creating cached functions.
|
|
1897
|
+
*
|
|
1898
|
+
* @param fn - Function to wrap
|
|
1899
|
+
* @param options - Cache options or key builder function
|
|
1900
|
+
* @returns Wrapped function with caching
|
|
1901
|
+
*
|
|
1902
|
+
* @example
|
|
1903
|
+
* ```typescript
|
|
1904
|
+
* const getCachedUser = cacheService.wrap(
|
|
1905
|
+
* async (id: string) => userRepository.findById(id),
|
|
1906
|
+
* {
|
|
1907
|
+
* key: (id: string) => `user:${id}`,
|
|
1908
|
+
* ttl: 3600,
|
|
1909
|
+
* tags: (id: string) => [`user:${id}`, 'users']
|
|
1910
|
+
* }
|
|
1911
|
+
* );
|
|
1912
|
+
*
|
|
1913
|
+
* const user = await getCachedUser('123');
|
|
1914
|
+
* ```
|
|
1915
|
+
*/
|
|
1916
|
+
wrap(fn, options) {
|
|
1917
|
+
return async (...args) => {
|
|
1918
|
+
const key = options.key(...args);
|
|
1919
|
+
const tags = typeof options.tags === "function" ? options.tags(...args) : options.tags;
|
|
1920
|
+
return this.getOrSet(key, () => fn(...args), {
|
|
1921
|
+
ttl: options.ttl,
|
|
1922
|
+
tags
|
|
1923
|
+
});
|
|
1924
|
+
};
|
|
1925
|
+
}
|
|
1926
|
+
/**
|
|
1927
|
+
* Deletes multiple keys from cache.
|
|
1928
|
+
*
|
|
1929
|
+
* @param keys - Array of cache keys
|
|
1930
|
+
* @returns Number of keys deleted
|
|
1931
|
+
*
|
|
1932
|
+
* @example
|
|
1933
|
+
* ```typescript
|
|
1934
|
+
* const count = await cacheService.deleteMany(['user:1', 'user:2', 'user:3']);
|
|
1935
|
+
* console.log(`Deleted ${count} keys`);
|
|
1936
|
+
* ```
|
|
1937
|
+
*/
|
|
1938
|
+
async deleteMany(keys) {
|
|
1939
|
+
return this.internalCache.deleteMany(keys);
|
|
1940
|
+
}
|
|
1941
|
+
/**
|
|
1942
|
+
* Gets all cache keys associated with a tag.
|
|
1943
|
+
*
|
|
1944
|
+
* @param tag - Tag name
|
|
1945
|
+
* @returns Array of cache keys
|
|
1946
|
+
*
|
|
1947
|
+
* @example
|
|
1948
|
+
* ```typescript
|
|
1949
|
+
* const keys = await cacheService.getKeysByTag('users');
|
|
1950
|
+
* console.log(`Found ${keys.length} cached user keys`);
|
|
1951
|
+
* ```
|
|
1952
|
+
*/
|
|
1953
|
+
async getKeysByTag(tag) {
|
|
1954
|
+
return this.internalCache.getKeysByTag(tag);
|
|
1955
|
+
}
|
|
1956
|
+
/**
|
|
1957
|
+
* Invalidates cache by tag.
|
|
1958
|
+
*
|
|
1959
|
+
* @param tag - Tag to invalidate
|
|
1960
|
+
* @returns Number of keys invalidated
|
|
1961
|
+
*
|
|
1962
|
+
* @example
|
|
1963
|
+
* ```typescript
|
|
1964
|
+
* // Invalidate all user caches
|
|
1965
|
+
* const count = await cacheService.invalidate('users');
|
|
1966
|
+
* console.log(`Invalidated ${count} keys`);
|
|
1967
|
+
* ```
|
|
1968
|
+
*/
|
|
1969
|
+
async invalidate(tag) {
|
|
1970
|
+
return this.internalCache.invalidateTag(tag);
|
|
1971
|
+
}
|
|
1972
|
+
/**
|
|
1973
|
+
* Invalidates multiple tags.
|
|
1974
|
+
*
|
|
1975
|
+
* @param tags - Array of tags to invalidate
|
|
1976
|
+
* @returns Total number of keys invalidated
|
|
1977
|
+
*
|
|
1978
|
+
* @example
|
|
1979
|
+
* ```typescript
|
|
1980
|
+
* const count = await cacheService.invalidate(['users', 'products']);
|
|
1981
|
+
* ```
|
|
1982
|
+
*/
|
|
1983
|
+
async invalidateTags(tags) {
|
|
1984
|
+
return this.internalCache.invalidateTags(tags);
|
|
1985
|
+
}
|
|
1986
|
+
/**
|
|
1987
|
+
* Invalidates cache keys matching a pattern.
|
|
1988
|
+
* Uses Redis SCAN for safe iteration.
|
|
1989
|
+
*
|
|
1990
|
+
* @param pattern - Redis pattern (supports * and ?)
|
|
1991
|
+
* @returns Number of keys deleted
|
|
1992
|
+
*
|
|
1993
|
+
* @example
|
|
1994
|
+
* ```typescript
|
|
1995
|
+
* // Delete all user-related caches
|
|
1996
|
+
* await cacheService.invalidateByPattern('user:*');
|
|
1997
|
+
*
|
|
1998
|
+
* // Delete specific locale caches
|
|
1999
|
+
* await cacheService.invalidateByPattern('*:en_US');
|
|
2000
|
+
* ```
|
|
2001
|
+
*/
|
|
2002
|
+
async invalidateByPattern(pattern) {
|
|
2003
|
+
return this.internalCache.invalidateByPattern(pattern);
|
|
2004
|
+
}
|
|
2005
|
+
/**
|
|
2006
|
+
* Checks if key exists in cache.
|
|
2007
|
+
*
|
|
2008
|
+
* @param key - Cache key
|
|
2009
|
+
* @returns True if key exists
|
|
2010
|
+
*/
|
|
2011
|
+
async has(key) {
|
|
2012
|
+
return this.internalCache.has(key);
|
|
2013
|
+
}
|
|
2014
|
+
/**
|
|
2015
|
+
* Gets TTL for a cached key.
|
|
2016
|
+
*
|
|
2017
|
+
* @param key - Cache key
|
|
2018
|
+
* @returns TTL in seconds, -1 if no TTL, -2 if key doesn't exist
|
|
2019
|
+
*/
|
|
2020
|
+
async ttl(key) {
|
|
2021
|
+
return this.internalCache.ttl(key);
|
|
2022
|
+
}
|
|
2023
|
+
/**
|
|
2024
|
+
* Clears all cache entries.
|
|
2025
|
+
* Use with caution in production.
|
|
2026
|
+
*
|
|
2027
|
+
* @example
|
|
2028
|
+
* ```typescript
|
|
2029
|
+
* await cacheService.clear();
|
|
2030
|
+
* ```
|
|
2031
|
+
*/
|
|
2032
|
+
async clear() {
|
|
2033
|
+
return this.internalCache.clear();
|
|
2034
|
+
}
|
|
2035
|
+
/**
|
|
2036
|
+
* Gets cache statistics.
|
|
2037
|
+
*
|
|
2038
|
+
* @returns Cache stats (hits, misses, size)
|
|
2039
|
+
*
|
|
2040
|
+
* @example
|
|
2041
|
+
* ```typescript
|
|
2042
|
+
* const stats = await cacheService.getStats();
|
|
2043
|
+
* console.log(`L1 Hit Rate: ${stats.l1.hits / (stats.l1.hits + stats.l1.misses) * 100}%`);
|
|
2044
|
+
* ```
|
|
2045
|
+
*/
|
|
2046
|
+
async getStats() {
|
|
2047
|
+
return this.internalCache.getStats();
|
|
2048
|
+
}
|
|
2049
|
+
};
|
|
2050
|
+
exports.CacheService = __decorateClass([
|
|
2051
|
+
common.Injectable(),
|
|
2052
|
+
__decorateParam(0, common.Inject(CACHE_SERVICE))
|
|
2053
|
+
], exports.CacheService);
|
|
2054
|
+
exports.EventInvalidationService = class EventInvalidationService {
|
|
2055
|
+
constructor(registry, cacheService, driver, config) {
|
|
2056
|
+
this.registry = registry;
|
|
2057
|
+
this.cacheService = cacheService;
|
|
2058
|
+
this.driver = driver;
|
|
2059
|
+
this.config = config;
|
|
2060
|
+
}
|
|
2061
|
+
logger = new common.Logger(exports.EventInvalidationService.name);
|
|
2062
|
+
handlers = /* @__PURE__ */ new Set();
|
|
2063
|
+
eventEmitter = new events.EventEmitter();
|
|
2064
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
2065
|
+
async onModuleInit() {
|
|
2066
|
+
const source = this.config.invalidation?.source ?? "internal";
|
|
2067
|
+
if (source === "internal") {
|
|
2068
|
+
this.setupInternalSource();
|
|
2069
|
+
this.logger.log("Event invalidation initialized with internal source");
|
|
2070
|
+
}
|
|
2071
|
+
}
|
|
2072
|
+
async processEvent(event, payload) {
|
|
2073
|
+
const startTime = Date.now();
|
|
2074
|
+
try {
|
|
2075
|
+
const eventId = this.generateEventId(event, payload);
|
|
2076
|
+
const isDuplicate = await this.checkDuplicate(eventId);
|
|
2077
|
+
if (isDuplicate) {
|
|
2078
|
+
this.logger.debug(`Skipping duplicate event "${event}"`);
|
|
2079
|
+
return {
|
|
2080
|
+
event,
|
|
2081
|
+
tagsInvalidated: [],
|
|
2082
|
+
keysInvalidated: [],
|
|
2083
|
+
totalKeysDeleted: 0,
|
|
2084
|
+
duration: Date.now() - startTime,
|
|
2085
|
+
skipped: true,
|
|
2086
|
+
skipReason: "duplicate"
|
|
2087
|
+
};
|
|
2088
|
+
}
|
|
2089
|
+
const resolved = this.registry.resolve(event, payload);
|
|
2090
|
+
if (resolved.tags.length === 0 && resolved.keys.length === 0) {
|
|
2091
|
+
this.logger.debug(`No matching rules for event "${event}"`);
|
|
2092
|
+
return {
|
|
2093
|
+
event,
|
|
2094
|
+
tagsInvalidated: [],
|
|
2095
|
+
keysInvalidated: [],
|
|
2096
|
+
totalKeysDeleted: 0,
|
|
2097
|
+
duration: Date.now() - startTime,
|
|
2098
|
+
skipped: true,
|
|
2099
|
+
skipReason: "no_matching_rules"
|
|
2100
|
+
};
|
|
2101
|
+
}
|
|
2102
|
+
let totalDeleted = 0;
|
|
2103
|
+
if (resolved.tags.length > 0) {
|
|
2104
|
+
this.logger.debug(`Invalidating tags: ${resolved.tags.join(", ")} for event "${event}"`);
|
|
2105
|
+
totalDeleted += await this.cacheService.invalidateTags(resolved.tags);
|
|
2106
|
+
}
|
|
2107
|
+
if (resolved.keys.length > 0) {
|
|
2108
|
+
this.logger.debug(`Invalidating keys: ${resolved.keys.join(", ")} for event "${event}"`);
|
|
2109
|
+
totalDeleted += await this.cacheService.deleteMany(resolved.keys);
|
|
2110
|
+
}
|
|
2111
|
+
await this.markProcessed(eventId);
|
|
2112
|
+
const result = {
|
|
2113
|
+
event,
|
|
2114
|
+
tagsInvalidated: resolved.tags,
|
|
2115
|
+
keysInvalidated: resolved.keys,
|
|
2116
|
+
totalKeysDeleted: totalDeleted,
|
|
2117
|
+
duration: Date.now() - startTime,
|
|
2118
|
+
skipped: false
|
|
2119
|
+
};
|
|
2120
|
+
this.logger.log(`Processed event "${event}": invalidated ${resolved.tags.length} tags, ${resolved.keys.length} keys, deleted ${totalDeleted} cache entries (${result.duration}ms)`);
|
|
2121
|
+
await this.notifyHandlers(event, payload, result);
|
|
2122
|
+
return result;
|
|
2123
|
+
} catch (error) {
|
|
2124
|
+
this.logger.error(`Failed to process event "${event}":`, error);
|
|
2125
|
+
throw error;
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
2129
|
+
async emit(event, payload) {
|
|
2130
|
+
this.eventEmitter.emit("invalidation", { event, payload });
|
|
2131
|
+
}
|
|
2132
|
+
subscribe(handler) {
|
|
2133
|
+
this.handlers.add(handler);
|
|
2134
|
+
return () => this.handlers.delete(handler);
|
|
2135
|
+
}
|
|
2136
|
+
setupInternalSource() {
|
|
2137
|
+
this.eventEmitter.on("invalidation", async ({ event, payload }) => {
|
|
2138
|
+
try {
|
|
2139
|
+
await this.processEvent(event, payload);
|
|
2140
|
+
} catch (error) {
|
|
2141
|
+
this.logger.error(`Internal event processing failed for "${event}":`, error);
|
|
2142
|
+
}
|
|
2143
|
+
});
|
|
2144
|
+
}
|
|
2145
|
+
async checkDuplicate(eventId) {
|
|
2146
|
+
try {
|
|
2147
|
+
const key = `_invalidation:processed:${eventId}`;
|
|
2148
|
+
const exists = await this.driver.exists(key);
|
|
2149
|
+
return exists > 0;
|
|
2150
|
+
} catch (error) {
|
|
2151
|
+
this.logger.warn("Deduplication check failed:", error);
|
|
2152
|
+
return false;
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
async markProcessed(eventId) {
|
|
2156
|
+
try {
|
|
2157
|
+
const key = `_invalidation:processed:${eventId}`;
|
|
2158
|
+
const ttl = this.config.invalidation?.deduplicationTtl ?? 60;
|
|
2159
|
+
await this.driver.setex(key, ttl, "1");
|
|
2160
|
+
} catch (error) {
|
|
2161
|
+
this.logger.warn("Failed to mark event as processed:", error);
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
generateEventId(event, payload) {
|
|
2165
|
+
const hash = crypto.createHash("sha256").update(event).update(JSON.stringify(payload ?? {})).digest("hex").slice(0, 16);
|
|
2166
|
+
return hash;
|
|
2167
|
+
}
|
|
2168
|
+
async notifyHandlers(event, payload, result) {
|
|
2169
|
+
for (const handler of this.handlers) {
|
|
2170
|
+
try {
|
|
2171
|
+
await handler(event, payload, result);
|
|
2172
|
+
} catch (error) {
|
|
2173
|
+
this.logger.error("Invalidation handler error:", error);
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
};
|
|
2178
|
+
exports.EventInvalidationService = __decorateClass([
|
|
2179
|
+
common.Injectable(),
|
|
2180
|
+
__decorateParam(0, common.Inject(INVALIDATION_REGISTRY)),
|
|
2181
|
+
__decorateParam(1, common.Inject(CACHE_SERVICE)),
|
|
2182
|
+
__decorateParam(2, common.Inject(core.REDIS_DRIVER)),
|
|
2183
|
+
__decorateParam(3, common.Inject(CACHE_PLUGIN_OPTIONS))
|
|
2184
|
+
], exports.EventInvalidationService);
|
|
2185
|
+
exports.InvalidationRegistryService = class InvalidationRegistryService {
|
|
2186
|
+
logger = new common.Logger(exports.InvalidationRegistryService.name);
|
|
2187
|
+
rules = [];
|
|
2188
|
+
register(rule) {
|
|
2189
|
+
this.rules.push(rule);
|
|
2190
|
+
this.rules.sort((a, b) => b.getPriority() - a.getPriority());
|
|
2191
|
+
this.logger.debug(`Registered invalidation rule for event "${rule.getEventPattern()}" with priority ${rule.getPriority()}`);
|
|
2192
|
+
}
|
|
2193
|
+
registerMany(rules) {
|
|
2194
|
+
for (const rule of rules) {
|
|
2195
|
+
this.register(rule);
|
|
2196
|
+
}
|
|
2197
|
+
}
|
|
2198
|
+
unregister(event) {
|
|
2199
|
+
const initialLength = this.rules.length;
|
|
2200
|
+
this.rules = this.rules.filter((r) => r.getEventPattern() !== event);
|
|
2201
|
+
const removed = initialLength - this.rules.length;
|
|
2202
|
+
if (removed > 0) {
|
|
2203
|
+
this.logger.debug(`Unregistered ${removed} rule(s) for event "${event}"`);
|
|
2204
|
+
}
|
|
2205
|
+
}
|
|
2206
|
+
findRules(event) {
|
|
2207
|
+
return this.rules.filter((rule) => rule.matches(event));
|
|
2208
|
+
}
|
|
2209
|
+
resolve(event, payload) {
|
|
2210
|
+
const matchedRules = this.findRules(event);
|
|
2211
|
+
const tagsSet = /* @__PURE__ */ new Set();
|
|
2212
|
+
const keysSet = /* @__PURE__ */ new Set();
|
|
2213
|
+
const applicableRules = [];
|
|
2214
|
+
for (const rule of matchedRules) {
|
|
2215
|
+
if (!rule.testCondition(payload)) {
|
|
2216
|
+
this.logger.debug(`Rule for event "${rule.getEventPattern()}" skipped - condition not met`);
|
|
2217
|
+
continue;
|
|
2218
|
+
}
|
|
2219
|
+
applicableRules.push(rule);
|
|
2220
|
+
if (rule.hasTags()) {
|
|
2221
|
+
const tags = rule.resolveTags(payload);
|
|
2222
|
+
for (const tag of tags) {
|
|
2223
|
+
if (!tag.includes("{")) {
|
|
2224
|
+
tagsSet.add(tag);
|
|
2225
|
+
} else {
|
|
2226
|
+
this.logger.warn(`Tag template "${tag}" has unresolved placeholders for event "${event}"`);
|
|
2227
|
+
}
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
if (rule.hasKeys()) {
|
|
2231
|
+
const keys = rule.resolveKeys(payload);
|
|
2232
|
+
for (const key of keys) {
|
|
2233
|
+
if (!key.includes("{")) {
|
|
2234
|
+
keysSet.add(key);
|
|
2235
|
+
} else {
|
|
2236
|
+
this.logger.warn(`Key template "${key}" has unresolved placeholders for event "${event}"`);
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
return {
|
|
2242
|
+
tags: Array.from(tagsSet),
|
|
2243
|
+
keys: Array.from(keysSet),
|
|
2244
|
+
matchedRules: applicableRules
|
|
2245
|
+
};
|
|
2246
|
+
}
|
|
2247
|
+
getRules() {
|
|
2248
|
+
return [...this.rules];
|
|
2249
|
+
}
|
|
2250
|
+
};
|
|
2251
|
+
exports.InvalidationRegistryService = __decorateClass([
|
|
2252
|
+
common.Injectable()
|
|
2253
|
+
], exports.InvalidationRegistryService);
|
|
2254
|
+
var EventPattern = class _EventPattern {
|
|
2255
|
+
pattern;
|
|
2256
|
+
regex;
|
|
2257
|
+
constructor(pattern, regex) {
|
|
2258
|
+
this.pattern = pattern;
|
|
2259
|
+
this.regex = regex;
|
|
2260
|
+
}
|
|
2261
|
+
/**
|
|
2262
|
+
* Creates EventPattern from string pattern.
|
|
2263
|
+
* @param pattern - AMQP-style pattern (e.g., 'user.*', 'order.#', 'product.updated')
|
|
2264
|
+
*/
|
|
2265
|
+
static create(pattern) {
|
|
2266
|
+
if (!pattern || pattern.trim().length === 0) {
|
|
2267
|
+
throw new CacheError("Event pattern cannot be empty", core.ErrorCode.VALIDATION_FAILED);
|
|
2268
|
+
}
|
|
2269
|
+
const normalized = pattern.trim();
|
|
2270
|
+
if (!/^[a-z0-9*#._-]+$/i.test(normalized)) {
|
|
2271
|
+
throw new CacheError(`Invalid event pattern "${normalized}". Only alphanumeric, dots, dashes, underscores, *, and # are allowed`, core.ErrorCode.VALIDATION_FAILED);
|
|
2272
|
+
}
|
|
2273
|
+
let regexStr = normalized.replace(/\./g, "\\.").replace(/\*/g, "[^.]+").replace(/#/g, ".*");
|
|
2274
|
+
regexStr = regexStr.replace(/\\\.\.\*$/, "(?:\\..*)?");
|
|
2275
|
+
const regex = new RegExp(`^${regexStr}$`);
|
|
2276
|
+
return new _EventPattern(normalized, regex);
|
|
2277
|
+
}
|
|
2278
|
+
/**
|
|
2279
|
+
* Tests if this pattern matches the given event.
|
|
2280
|
+
*/
|
|
2281
|
+
matches(event) {
|
|
2282
|
+
return this.regex.test(event);
|
|
2283
|
+
}
|
|
2284
|
+
/**
|
|
2285
|
+
* Returns the raw pattern string.
|
|
2286
|
+
*/
|
|
2287
|
+
toString() {
|
|
2288
|
+
return this.pattern;
|
|
2289
|
+
}
|
|
2290
|
+
/**
|
|
2291
|
+
* Checks equality with another pattern.
|
|
2292
|
+
*/
|
|
2293
|
+
equals(other) {
|
|
2294
|
+
return this.pattern === other.pattern;
|
|
2295
|
+
}
|
|
2296
|
+
};
|
|
2297
|
+
var TagTemplate = class _TagTemplate {
|
|
2298
|
+
template;
|
|
2299
|
+
placeholders;
|
|
2300
|
+
constructor(template, placeholders) {
|
|
2301
|
+
this.template = template;
|
|
2302
|
+
this.placeholders = placeholders;
|
|
2303
|
+
}
|
|
2304
|
+
/**
|
|
2305
|
+
* Creates TagTemplate from template string.
|
|
2306
|
+
* @param template - Template string with placeholders (e.g., 'user:{userId}')
|
|
2307
|
+
*/
|
|
2308
|
+
static create(template) {
|
|
2309
|
+
if (!template || template.trim().length === 0) {
|
|
2310
|
+
throw new CacheError("Tag template cannot be empty", core.ErrorCode.VALIDATION_FAILED);
|
|
2311
|
+
}
|
|
2312
|
+
const normalized = template.trim();
|
|
2313
|
+
const placeholders = [];
|
|
2314
|
+
const placeholderRegex = /\{([^}]+)\}/g;
|
|
2315
|
+
let match;
|
|
2316
|
+
while ((match = placeholderRegex.exec(normalized)) !== null) {
|
|
2317
|
+
placeholders.push(match[1]);
|
|
2318
|
+
}
|
|
2319
|
+
return new _TagTemplate(normalized, placeholders);
|
|
2320
|
+
}
|
|
2321
|
+
/**
|
|
2322
|
+
* Resolves template with given payload.
|
|
2323
|
+
* @param payload - Data object to resolve placeholders from
|
|
2324
|
+
* @returns Resolved tag string
|
|
2325
|
+
*/
|
|
2326
|
+
resolve(payload) {
|
|
2327
|
+
return this.template.replace(/\{([^}]+)\}/g, (_, path) => {
|
|
2328
|
+
const value = this.getNestedValue(payload, path);
|
|
2329
|
+
if (value === void 0 || value === null) {
|
|
2330
|
+
return `{${path}}`;
|
|
2331
|
+
}
|
|
2332
|
+
return String(value);
|
|
2333
|
+
});
|
|
2334
|
+
}
|
|
2335
|
+
/**
|
|
2336
|
+
* Returns the raw template string.
|
|
2337
|
+
*/
|
|
2338
|
+
toString() {
|
|
2339
|
+
return this.template;
|
|
2340
|
+
}
|
|
2341
|
+
/**
|
|
2342
|
+
* Returns all placeholders in the template.
|
|
2343
|
+
*/
|
|
2344
|
+
getPlaceholders() {
|
|
2345
|
+
return [...this.placeholders];
|
|
2346
|
+
}
|
|
2347
|
+
/**
|
|
2348
|
+
* Checks if template has any placeholders.
|
|
2349
|
+
*/
|
|
2350
|
+
hasPlaceholders() {
|
|
2351
|
+
return this.placeholders.length > 0;
|
|
2352
|
+
}
|
|
2353
|
+
getNestedValue(obj, path) {
|
|
2354
|
+
if (obj === null || obj === void 0) {
|
|
2355
|
+
return void 0;
|
|
2356
|
+
}
|
|
2357
|
+
const parts = path.split(".");
|
|
2358
|
+
let current = obj;
|
|
2359
|
+
for (const part of parts) {
|
|
2360
|
+
if (current === null || current === void 0) {
|
|
2361
|
+
return void 0;
|
|
2362
|
+
}
|
|
2363
|
+
if (typeof current !== "object") {
|
|
2364
|
+
return void 0;
|
|
2365
|
+
}
|
|
2366
|
+
current = current[part];
|
|
2367
|
+
}
|
|
2368
|
+
return current;
|
|
2369
|
+
}
|
|
2370
|
+
};
|
|
2371
|
+
|
|
2372
|
+
// src/invalidation/domain/entities/invalidation-rule.entity.ts
|
|
2373
|
+
var InvalidationRule = class _InvalidationRule {
|
|
2374
|
+
eventPattern;
|
|
2375
|
+
tagTemplates;
|
|
2376
|
+
keyTemplates;
|
|
2377
|
+
condition;
|
|
2378
|
+
priority;
|
|
2379
|
+
constructor(eventPattern, tagTemplates, keyTemplates, condition, priority) {
|
|
2380
|
+
this.eventPattern = eventPattern;
|
|
2381
|
+
this.tagTemplates = tagTemplates;
|
|
2382
|
+
this.keyTemplates = keyTemplates;
|
|
2383
|
+
this.condition = condition;
|
|
2384
|
+
this.priority = priority;
|
|
2385
|
+
}
|
|
2386
|
+
/**
|
|
2387
|
+
* Creates InvalidationRule from props.
|
|
2388
|
+
*/
|
|
2389
|
+
static create(props) {
|
|
2390
|
+
const eventPattern = EventPattern.create(props.event);
|
|
2391
|
+
const tagTemplates = (props.tags ?? []).map((tag) => TagTemplate.create(tag));
|
|
2392
|
+
const keyTemplates = (props.keys ?? []).map((key) => TagTemplate.create(key));
|
|
2393
|
+
const priority = props.priority ?? 0;
|
|
2394
|
+
return new _InvalidationRule(eventPattern, tagTemplates, keyTemplates, props.condition, priority);
|
|
2395
|
+
}
|
|
2396
|
+
/**
|
|
2397
|
+
* Tests if this rule matches the given event.
|
|
2398
|
+
*/
|
|
2399
|
+
matches(event) {
|
|
2400
|
+
return this.eventPattern.matches(event);
|
|
2401
|
+
}
|
|
2402
|
+
/**
|
|
2403
|
+
* Tests if condition passes for the given payload.
|
|
2404
|
+
*/
|
|
2405
|
+
testCondition(payload) {
|
|
2406
|
+
if (!this.condition) {
|
|
2407
|
+
return true;
|
|
2408
|
+
}
|
|
2409
|
+
try {
|
|
2410
|
+
return this.condition(payload);
|
|
2411
|
+
} catch {
|
|
2412
|
+
return false;
|
|
2413
|
+
}
|
|
2414
|
+
}
|
|
2415
|
+
/**
|
|
2416
|
+
* Resolves tags for the given payload.
|
|
2417
|
+
*/
|
|
2418
|
+
resolveTags(payload) {
|
|
2419
|
+
return this.tagTemplates.map((template) => template.resolve(payload));
|
|
2420
|
+
}
|
|
2421
|
+
/**
|
|
2422
|
+
* Resolves keys for the given payload.
|
|
2423
|
+
*/
|
|
2424
|
+
resolveKeys(payload) {
|
|
2425
|
+
return this.keyTemplates.map((template) => template.resolve(payload));
|
|
2426
|
+
}
|
|
2427
|
+
/**
|
|
2428
|
+
* Gets rule priority (higher = processed first).
|
|
2429
|
+
*/
|
|
2430
|
+
getPriority() {
|
|
2431
|
+
return this.priority;
|
|
2432
|
+
}
|
|
2433
|
+
/**
|
|
2434
|
+
* Gets the event pattern string.
|
|
2435
|
+
*/
|
|
2436
|
+
getEventPattern() {
|
|
2437
|
+
return this.eventPattern.toString();
|
|
2438
|
+
}
|
|
2439
|
+
/**
|
|
2440
|
+
* Checks if rule has any tags.
|
|
2441
|
+
*/
|
|
2442
|
+
hasTags() {
|
|
2443
|
+
return this.tagTemplates.length > 0;
|
|
2444
|
+
}
|
|
2445
|
+
/**
|
|
2446
|
+
* Checks if rule has any keys.
|
|
2447
|
+
*/
|
|
2448
|
+
hasKeys() {
|
|
2449
|
+
return this.keyTemplates.length > 0;
|
|
2450
|
+
}
|
|
2451
|
+
/**
|
|
2452
|
+
* Checks if rule has a condition.
|
|
2453
|
+
*/
|
|
2454
|
+
hasCondition() {
|
|
2455
|
+
return this.condition !== void 0;
|
|
2456
|
+
}
|
|
2457
|
+
};
|
|
2458
|
+
var AMQPEventSourceAdapter = class {
|
|
2459
|
+
constructor(invalidationService, config, amqpConnection) {
|
|
2460
|
+
this.invalidationService = invalidationService;
|
|
2461
|
+
this.config = config;
|
|
2462
|
+
this.amqpConnection = amqpConnection;
|
|
2463
|
+
}
|
|
2464
|
+
logger = new common.Logger(AMQPEventSourceAdapter.name);
|
|
2465
|
+
amqpConnection;
|
|
2466
|
+
async onModuleInit() {
|
|
2467
|
+
const source = this.config.invalidation?.source;
|
|
2468
|
+
if (source !== "amqp") {
|
|
2469
|
+
return;
|
|
2470
|
+
}
|
|
2471
|
+
if (!this.amqpConnection) {
|
|
2472
|
+
this.logger.warn("AMQP source configured but @golevelup/nestjs-rabbitmq is not available. Install it with: npm install @golevelup/nestjs-rabbitmq");
|
|
2473
|
+
return;
|
|
2474
|
+
}
|
|
2475
|
+
const amqpConfig = this.config.invalidation?.amqp;
|
|
2476
|
+
if (!amqpConfig) {
|
|
2477
|
+
this.logger.warn("AMQP source configured but amqp config is missing");
|
|
2478
|
+
return;
|
|
2479
|
+
}
|
|
2480
|
+
const exchange = amqpConfig.exchange ?? "cache.invalidation";
|
|
2481
|
+
const queue = amqpConfig.queue ?? `${this.getServiceName()}.cache.invalidation`;
|
|
2482
|
+
const routingKeys = amqpConfig.routingKeys ?? ["#"];
|
|
2483
|
+
try {
|
|
2484
|
+
await this.amqpConnection.createSubscriber(
|
|
2485
|
+
async (msg, rawMsg) => {
|
|
2486
|
+
const routingKey = rawMsg.fields.routingKey;
|
|
2487
|
+
this.logger.debug(`Received AMQP invalidation event: ${routingKey}`);
|
|
2488
|
+
await this.invalidationService.processEvent(routingKey, msg.payload);
|
|
2489
|
+
},
|
|
2490
|
+
{
|
|
2491
|
+
exchange,
|
|
2492
|
+
queue,
|
|
2493
|
+
routingKey: routingKeys,
|
|
2494
|
+
queueOptions: {
|
|
2495
|
+
durable: true
|
|
2496
|
+
}
|
|
2497
|
+
}
|
|
2498
|
+
);
|
|
2499
|
+
this.logger.log(`AMQP event source initialized: exchange=${exchange}, queue=${queue}, routingKeys=${routingKeys.join(",")}`);
|
|
2500
|
+
} catch (error) {
|
|
2501
|
+
this.logger.error("Failed to setup AMQP event source:", error);
|
|
2502
|
+
throw error;
|
|
2503
|
+
}
|
|
2504
|
+
}
|
|
2505
|
+
getServiceName() {
|
|
2506
|
+
return process.env.SERVICE_NAME ?? "app";
|
|
2507
|
+
}
|
|
2508
|
+
};
|
|
2509
|
+
AMQPEventSourceAdapter = __decorateClass([
|
|
2510
|
+
common.Injectable(),
|
|
2511
|
+
__decorateParam(0, common.Inject(EVENT_INVALIDATION_SERVICE)),
|
|
2512
|
+
__decorateParam(1, common.Inject(CACHE_PLUGIN_OPTIONS)),
|
|
2513
|
+
__decorateParam(2, common.Optional()),
|
|
2514
|
+
__decorateParam(2, common.Inject(AMQP_CONNECTION))
|
|
2515
|
+
], AMQPEventSourceAdapter);
|
|
2516
|
+
var RELEASE_LOCK_SCRIPT = `
|
|
2517
|
+
if redis.call("get", KEYS[1]) == ARGV[1] then
|
|
2518
|
+
return redis.call("del", KEYS[1])
|
|
2519
|
+
else
|
|
2520
|
+
return 0
|
|
2521
|
+
end
|
|
2522
|
+
`.trim();
|
|
2523
|
+
var LOCK_PREFIX = "_stampede:";
|
|
2524
|
+
var StampedeProtectionService = class {
|
|
2525
|
+
constructor(options, driver) {
|
|
2526
|
+
this.options = options;
|
|
2527
|
+
this.driver = driver;
|
|
2528
|
+
this.lockTimeout = options.stampede?.lockTimeout ?? 5e3;
|
|
2529
|
+
this.waitTimeout = options.stampede?.waitTimeout ?? 1e4;
|
|
2530
|
+
}
|
|
2531
|
+
logger = new common.Logger(StampedeProtectionService.name);
|
|
2532
|
+
flights = /* @__PURE__ */ new Map();
|
|
2533
|
+
lockTimeout;
|
|
2534
|
+
waitTimeout;
|
|
2535
|
+
prevented = 0;
|
|
2536
|
+
async protect(key, loader) {
|
|
2537
|
+
const existingFlight = this.flights.get(key);
|
|
2538
|
+
if (existingFlight) {
|
|
2539
|
+
existingFlight.waiters++;
|
|
2540
|
+
this.prevented++;
|
|
2541
|
+
const value = await this.waitForFlight(existingFlight, key);
|
|
2542
|
+
return { value, cached: true, waited: true };
|
|
2543
|
+
}
|
|
2544
|
+
let resolveFunc;
|
|
2545
|
+
let rejectFunc;
|
|
2546
|
+
const promise = new Promise((resolve, reject) => {
|
|
2547
|
+
resolveFunc = resolve;
|
|
2548
|
+
rejectFunc = reject;
|
|
2549
|
+
});
|
|
2550
|
+
const flight = {
|
|
2551
|
+
promise,
|
|
2552
|
+
resolve: resolveFunc,
|
|
2553
|
+
reject: rejectFunc,
|
|
2554
|
+
waiters: 0,
|
|
2555
|
+
timestamp: Date.now()
|
|
2556
|
+
};
|
|
2557
|
+
this.flights.set(key, flight);
|
|
2558
|
+
let lock;
|
|
2559
|
+
try {
|
|
2560
|
+
const lockKey = `${LOCK_PREFIX}${key}`;
|
|
2561
|
+
const lockValue = this.generateLockValue();
|
|
2562
|
+
const lockTtlSeconds = Math.ceil(this.lockTimeout / 1e3);
|
|
2563
|
+
const acquired = await this.tryAcquireLock(lockKey, lockValue, lockTtlSeconds);
|
|
2564
|
+
if (acquired) {
|
|
2565
|
+
lock = { lockKey, lockValue };
|
|
2566
|
+
}
|
|
2567
|
+
} catch {
|
|
2568
|
+
}
|
|
2569
|
+
try {
|
|
2570
|
+
const value = await this.executeLoader(loader, key);
|
|
2571
|
+
flight.resolve(value);
|
|
2572
|
+
return { value, cached: false, waited: false };
|
|
2573
|
+
} catch (error) {
|
|
2574
|
+
if (flight.waiters > 0) {
|
|
2575
|
+
flight.reject(error);
|
|
2576
|
+
}
|
|
2577
|
+
throw error;
|
|
2578
|
+
} finally {
|
|
2579
|
+
setTimeout(() => {
|
|
2580
|
+
this.flights.delete(key);
|
|
2581
|
+
}, 100);
|
|
2582
|
+
if (lock) {
|
|
2583
|
+
this.releaseLock(lock.lockKey, lock.lockValue).catch((err) => {
|
|
2584
|
+
this.logger.warn(`Failed to release lock for "${key}": ${err.message}`);
|
|
2585
|
+
});
|
|
2586
|
+
}
|
|
2587
|
+
}
|
|
2588
|
+
}
|
|
2589
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
2590
|
+
async clearKey(key) {
|
|
2591
|
+
const flight = this.flights.get(key);
|
|
2592
|
+
if (flight) {
|
|
2593
|
+
flight.reject(new Error("Flight cancelled"));
|
|
2594
|
+
this.flights.delete(key);
|
|
2595
|
+
}
|
|
2596
|
+
}
|
|
2597
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
2598
|
+
async clearAll() {
|
|
2599
|
+
for (const [, flight] of this.flights.entries()) {
|
|
2600
|
+
flight.reject(new Error("All flights cancelled"));
|
|
2601
|
+
}
|
|
2602
|
+
this.flights.clear();
|
|
2603
|
+
}
|
|
2604
|
+
getStats() {
|
|
2605
|
+
const stats = {
|
|
2606
|
+
activeFlights: this.flights.size,
|
|
2607
|
+
totalWaiters: 0,
|
|
2608
|
+
oldestFlight: 0,
|
|
2609
|
+
prevented: this.prevented
|
|
2610
|
+
};
|
|
2611
|
+
const now = Date.now();
|
|
2612
|
+
let oldestTimestamp = now;
|
|
2613
|
+
for (const flight of this.flights.values()) {
|
|
2614
|
+
stats.totalWaiters += flight.waiters;
|
|
2615
|
+
if (flight.timestamp < oldestTimestamp) {
|
|
2616
|
+
oldestTimestamp = flight.timestamp;
|
|
2617
|
+
}
|
|
2618
|
+
}
|
|
2619
|
+
stats.oldestFlight = stats.activeFlights > 0 ? now - oldestTimestamp : 0;
|
|
2620
|
+
return stats;
|
|
2621
|
+
}
|
|
2622
|
+
async waitForFlight(flight, key) {
|
|
2623
|
+
const age = Date.now() - flight.timestamp;
|
|
2624
|
+
if (age > this.lockTimeout) {
|
|
2625
|
+
throw new StampedeError(key, age);
|
|
2626
|
+
}
|
|
2627
|
+
let timeoutId;
|
|
2628
|
+
let timeoutCancelled = false;
|
|
2629
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
2630
|
+
timeoutId = setTimeout(() => {
|
|
2631
|
+
if (!timeoutCancelled) {
|
|
2632
|
+
reject(new StampedeError(key, this.waitTimeout));
|
|
2633
|
+
}
|
|
2634
|
+
}, this.waitTimeout);
|
|
2635
|
+
});
|
|
2636
|
+
try {
|
|
2637
|
+
const result = await Promise.race([flight.promise, timeoutPromise]);
|
|
2638
|
+
timeoutCancelled = true;
|
|
2639
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
2640
|
+
return result;
|
|
2641
|
+
} catch (error) {
|
|
2642
|
+
timeoutCancelled = true;
|
|
2643
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
2644
|
+
throw error;
|
|
2645
|
+
}
|
|
2646
|
+
}
|
|
2647
|
+
async executeLoader(loader, key) {
|
|
2648
|
+
let timeoutId;
|
|
2649
|
+
let timeoutCancelled = false;
|
|
2650
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
2651
|
+
timeoutId = setTimeout(() => {
|
|
2652
|
+
if (!timeoutCancelled) {
|
|
2653
|
+
reject(new StampedeError(key, this.lockTimeout));
|
|
2654
|
+
}
|
|
2655
|
+
}, this.lockTimeout);
|
|
2656
|
+
});
|
|
2657
|
+
try {
|
|
2658
|
+
const result = await Promise.race([loader(), timeoutPromise]);
|
|
2659
|
+
timeoutCancelled = true;
|
|
2660
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
2661
|
+
return result;
|
|
2662
|
+
} catch (error) {
|
|
2663
|
+
timeoutCancelled = true;
|
|
2664
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
2665
|
+
if (error instanceof StampedeError) {
|
|
2666
|
+
throw error;
|
|
2667
|
+
}
|
|
2668
|
+
throw new LoaderError(key, error);
|
|
2669
|
+
}
|
|
2670
|
+
}
|
|
2671
|
+
async tryAcquireLock(lockKey, lockValue, ttlSeconds) {
|
|
2672
|
+
try {
|
|
2673
|
+
const result = await this.driver.set(lockKey, lockValue, { ex: ttlSeconds, nx: true });
|
|
2674
|
+
return result === "OK";
|
|
2675
|
+
} catch (error) {
|
|
2676
|
+
this.logger.warn(`Failed to acquire distributed lock: ${error.message}`);
|
|
2677
|
+
return false;
|
|
2678
|
+
}
|
|
2679
|
+
}
|
|
2680
|
+
async releaseLock(lockKey, lockValue) {
|
|
2681
|
+
try {
|
|
2682
|
+
await this.driver.eval(RELEASE_LOCK_SCRIPT, [lockKey], [lockValue]);
|
|
2683
|
+
} catch (error) {
|
|
2684
|
+
this.logger.warn(`Failed to release distributed lock: ${error.message}`);
|
|
2685
|
+
}
|
|
2686
|
+
}
|
|
2687
|
+
generateLockValue() {
|
|
2688
|
+
return `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
2689
|
+
}
|
|
2690
|
+
};
|
|
2691
|
+
StampedeProtectionService = __decorateClass([
|
|
2692
|
+
common.Injectable(),
|
|
2693
|
+
__decorateParam(0, common.Inject(CACHE_PLUGIN_OPTIONS)),
|
|
2694
|
+
__decorateParam(1, common.Inject(core.REDIS_DRIVER))
|
|
2695
|
+
], StampedeProtectionService);
|
|
2696
|
+
var SwrManagerService = class {
|
|
2697
|
+
constructor(options) {
|
|
2698
|
+
this.options = options;
|
|
2699
|
+
this.enabled = options.swr?.enabled ?? false;
|
|
2700
|
+
this.staleTtl = options.swr?.defaultStaleTime ?? 60;
|
|
2701
|
+
}
|
|
2702
|
+
logger = new common.Logger(SwrManagerService.name);
|
|
2703
|
+
jobs = /* @__PURE__ */ new Map();
|
|
2704
|
+
staleTtl;
|
|
2705
|
+
enabled;
|
|
2706
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
2707
|
+
async get(_key) {
|
|
2708
|
+
return null;
|
|
2709
|
+
}
|
|
2710
|
+
async set(_key, _value, _staleTimeSeconds) {
|
|
2711
|
+
}
|
|
2712
|
+
async delete(_key) {
|
|
2713
|
+
}
|
|
2714
|
+
isStale(entry) {
|
|
2715
|
+
if (!this.enabled) {
|
|
2716
|
+
return false;
|
|
2717
|
+
}
|
|
2718
|
+
const now = Date.now();
|
|
2719
|
+
return now > entry.staleAt;
|
|
2720
|
+
}
|
|
2721
|
+
isExpired(entry) {
|
|
2722
|
+
const now = Date.now();
|
|
2723
|
+
return now > entry.expiresAt;
|
|
2724
|
+
}
|
|
2725
|
+
shouldRevalidate(key) {
|
|
2726
|
+
return !this.jobs.has(key);
|
|
2727
|
+
}
|
|
2728
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
2729
|
+
async scheduleRevalidation(key, loader, onSuccess, onError) {
|
|
2730
|
+
if (this.jobs.has(key)) {
|
|
2731
|
+
this.logger.debug(`Revalidation already scheduled for key: ${key}`);
|
|
2732
|
+
return;
|
|
2733
|
+
}
|
|
2734
|
+
const job = {
|
|
2735
|
+
key,
|
|
2736
|
+
loader,
|
|
2737
|
+
onSuccess,
|
|
2738
|
+
onError: onError ?? ((error) => this.logger.error(`Revalidation failed for ${key}:`, error)),
|
|
2739
|
+
timestamp: Date.now()
|
|
2740
|
+
};
|
|
2741
|
+
this.jobs.set(key, job);
|
|
2742
|
+
setImmediate(() => {
|
|
2743
|
+
void this.executeRevalidation(job);
|
|
2744
|
+
});
|
|
2745
|
+
}
|
|
2746
|
+
createSwrEntry(value, freshTtl, staleTtl) {
|
|
2747
|
+
const now = Date.now();
|
|
2748
|
+
const freshTtlMs = freshTtl * 1e3;
|
|
2749
|
+
const staleTtlMs = (staleTtl ?? this.staleTtl) * 1e3;
|
|
2750
|
+
return {
|
|
2751
|
+
value,
|
|
2752
|
+
cachedAt: now,
|
|
2753
|
+
staleAt: now + freshTtlMs,
|
|
2754
|
+
expiresAt: now + freshTtlMs + staleTtlMs
|
|
2755
|
+
};
|
|
2756
|
+
}
|
|
2757
|
+
getStats() {
|
|
2758
|
+
return {
|
|
2759
|
+
activeRevalidations: this.jobs.size,
|
|
2760
|
+
enabled: this.enabled,
|
|
2761
|
+
staleTtl: this.staleTtl
|
|
2762
|
+
};
|
|
2763
|
+
}
|
|
2764
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
2765
|
+
async clearRevalidations() {
|
|
2766
|
+
this.jobs.clear();
|
|
2767
|
+
}
|
|
2768
|
+
async executeRevalidation(job) {
|
|
2769
|
+
try {
|
|
2770
|
+
this.logger.debug(`Starting revalidation for key: ${job.key}`);
|
|
2771
|
+
const value = await job.loader();
|
|
2772
|
+
this.logger.debug(`Revalidation successful for key: ${job.key}`);
|
|
2773
|
+
await job.onSuccess(value);
|
|
2774
|
+
} catch (error) {
|
|
2775
|
+
this.logger.error(`Revalidation failed for key: ${job.key}`, error);
|
|
2776
|
+
job.onError(error);
|
|
2777
|
+
} finally {
|
|
2778
|
+
this.jobs.delete(job.key);
|
|
2779
|
+
}
|
|
2780
|
+
}
|
|
2781
|
+
};
|
|
2782
|
+
SwrManagerService = __decorateClass([
|
|
2783
|
+
common.Injectable(),
|
|
2784
|
+
__decorateParam(0, common.Inject(CACHE_PLUGIN_OPTIONS))
|
|
2785
|
+
], SwrManagerService);
|
|
2786
|
+
var TAG_BATCH_SIZE = 100;
|
|
2787
|
+
var TagIndexRepository = class {
|
|
2788
|
+
constructor(driver, options, luaLoader) {
|
|
2789
|
+
this.driver = driver;
|
|
2790
|
+
this.options = options;
|
|
2791
|
+
this.luaLoader = luaLoader;
|
|
2792
|
+
const l2Prefix = options.l2?.keyPrefix ?? "cache:";
|
|
2793
|
+
const tagIndexPrefix = options.tags?.indexPrefix ?? "_tag:";
|
|
2794
|
+
this.tagPrefix = `${l2Prefix}${tagIndexPrefix}`;
|
|
2795
|
+
}
|
|
2796
|
+
tagPrefix;
|
|
2797
|
+
async addKeyToTags(key, tags) {
|
|
2798
|
+
if (tags.length === 0) {
|
|
2799
|
+
return;
|
|
2800
|
+
}
|
|
2801
|
+
try {
|
|
2802
|
+
const validatedTags = this.validateTags(tags);
|
|
2803
|
+
const tagTtl = this.options.tags?.ttl ?? this.options.l2?.maxTtl ?? 86400;
|
|
2804
|
+
const operations = validatedTags.map(async (tag) => {
|
|
2805
|
+
const tagKey = this.buildTagKey(tag);
|
|
2806
|
+
await this.driver.sadd(tagKey, key);
|
|
2807
|
+
await this.driver.expire(tagKey, tagTtl);
|
|
2808
|
+
});
|
|
2809
|
+
await Promise.all(operations);
|
|
2810
|
+
} catch (error) {
|
|
2811
|
+
throw new TagInvalidationError(tags[0] ?? "unknown", `Failed to add key "${key}" to tags [${tags.join(", ")}]: ${error.message}`, error);
|
|
2812
|
+
}
|
|
2813
|
+
}
|
|
2814
|
+
async removeKeyFromTags(key, tags) {
|
|
2815
|
+
if (tags.length === 0) {
|
|
2816
|
+
return;
|
|
2817
|
+
}
|
|
2818
|
+
try {
|
|
2819
|
+
const validatedTags = this.validateTags(tags);
|
|
2820
|
+
await Promise.all(
|
|
2821
|
+
validatedTags.map(async (tag) => {
|
|
2822
|
+
const tagKey = this.buildTagKey(tag);
|
|
2823
|
+
await this.driver.srem(tagKey, key);
|
|
2824
|
+
})
|
|
2825
|
+
);
|
|
2826
|
+
} catch (error) {
|
|
2827
|
+
throw new TagInvalidationError(tags[0] ?? "unknown", `Failed to remove key "${key}" from tags [${tags.join(", ")}]: ${error.message}`, error);
|
|
2828
|
+
}
|
|
2829
|
+
}
|
|
2830
|
+
async getKeysByTag(tag) {
|
|
2831
|
+
try {
|
|
2832
|
+
const validTag = Tag.create(tag);
|
|
2833
|
+
const tagKey = this.buildTagKey(validTag.toString());
|
|
2834
|
+
const keys = await this.driver.smembers(tagKey);
|
|
2835
|
+
return keys;
|
|
2836
|
+
} catch (error) {
|
|
2837
|
+
throw new TagInvalidationError(tag, `Failed to get keys for tag: ${error.message}`, error);
|
|
2838
|
+
}
|
|
2839
|
+
}
|
|
2840
|
+
async invalidateTag(tag) {
|
|
2841
|
+
try {
|
|
2842
|
+
const validTag = Tag.create(tag);
|
|
2843
|
+
const tagKey = this.buildTagKey(validTag.toString());
|
|
2844
|
+
const cacheKeys = await this.driver.smembers(tagKey);
|
|
2845
|
+
if (cacheKeys.length === 0) {
|
|
2846
|
+
await this.driver.del(tagKey);
|
|
2847
|
+
return 0;
|
|
2848
|
+
}
|
|
2849
|
+
let deletedCount = 0;
|
|
2850
|
+
const batchSize = TAG_BATCH_SIZE;
|
|
2851
|
+
for (let i = 0; i < cacheKeys.length; i += batchSize) {
|
|
2852
|
+
const batch = cacheKeys.slice(i, i + batchSize);
|
|
2853
|
+
const results = await Promise.all(batch.map((key) => this.driver.del(key)));
|
|
2854
|
+
deletedCount += results.reduce((sum, result) => sum + result, 0);
|
|
2855
|
+
}
|
|
2856
|
+
await this.driver.del(tagKey);
|
|
2857
|
+
return deletedCount;
|
|
2858
|
+
} catch (error) {
|
|
2859
|
+
throw new TagInvalidationError(tag, `Failed to invalidate tag: ${error.message}`, error);
|
|
2860
|
+
}
|
|
2861
|
+
}
|
|
2862
|
+
async invalidateTags(tags) {
|
|
2863
|
+
if (tags.length === 0) {
|
|
2864
|
+
return 0;
|
|
2865
|
+
}
|
|
2866
|
+
try {
|
|
2867
|
+
const validatedTags = this.validateTags(tags);
|
|
2868
|
+
let totalInvalidated = 0;
|
|
2869
|
+
for (const tag of validatedTags) {
|
|
2870
|
+
const count = await this.invalidateTag(tag);
|
|
2871
|
+
totalInvalidated += count;
|
|
2872
|
+
}
|
|
2873
|
+
return totalInvalidated;
|
|
2874
|
+
} catch (error) {
|
|
2875
|
+
throw new TagInvalidationError(tags[0] ?? "unknown", `Failed to invalidate tags [${tags.join(", ")}]: ${error.message}`, error);
|
|
2876
|
+
}
|
|
2877
|
+
}
|
|
2878
|
+
async clearAllTags() {
|
|
2879
|
+
try {
|
|
2880
|
+
const pattern = `${this.tagPrefix}*`;
|
|
2881
|
+
const keys = await this.scanKeys(pattern);
|
|
2882
|
+
if (keys.length === 0) {
|
|
2883
|
+
return;
|
|
2884
|
+
}
|
|
2885
|
+
const batchSize = TAG_BATCH_SIZE;
|
|
2886
|
+
for (let i = 0; i < keys.length; i += batchSize) {
|
|
2887
|
+
const batch = keys.slice(i, i + batchSize);
|
|
2888
|
+
await Promise.all(batch.map((key) => this.driver.del(key)));
|
|
2889
|
+
}
|
|
2890
|
+
} catch (error) {
|
|
2891
|
+
throw new TagInvalidationError("_all", `Failed to clear all tags: ${error.message}`, error);
|
|
2892
|
+
}
|
|
2893
|
+
}
|
|
2894
|
+
async getTagStats(tag) {
|
|
2895
|
+
try {
|
|
2896
|
+
const validTag = Tag.create(tag);
|
|
2897
|
+
const tagKey = this.buildTagKey(validTag.toString());
|
|
2898
|
+
const exists = await this.driver.exists(tagKey) > 0;
|
|
2899
|
+
const keyCount = exists ? await this.driver.scard(tagKey) : 0;
|
|
2900
|
+
return { keyCount, exists };
|
|
2901
|
+
} catch {
|
|
2902
|
+
return { keyCount: 0, exists: false };
|
|
2903
|
+
}
|
|
2904
|
+
}
|
|
2905
|
+
/**
|
|
2906
|
+
* Validates tags using Tags value object.
|
|
2907
|
+
*
|
|
2908
|
+
* @param tags - Array of tag strings
|
|
2909
|
+
* @returns Array of validated tag strings
|
|
2910
|
+
* @throws CacheError if validation fails
|
|
2911
|
+
* @private
|
|
2912
|
+
*/
|
|
2913
|
+
validateTags(tags) {
|
|
2914
|
+
const maxTags = this.options.tags?.maxTagsPerKey ?? 10;
|
|
2915
|
+
const tagsVo = Tags.create(tags, maxTags);
|
|
2916
|
+
return tagsVo.toStrings();
|
|
2917
|
+
}
|
|
2918
|
+
buildTagKey(tag) {
|
|
2919
|
+
return `${this.tagPrefix}${tag}`;
|
|
2920
|
+
}
|
|
2921
|
+
async scanKeys(pattern) {
|
|
2922
|
+
const keys = [];
|
|
2923
|
+
let cursor = 0;
|
|
2924
|
+
do {
|
|
2925
|
+
const result = await this.driver.scan(cursor, {
|
|
2926
|
+
match: pattern,
|
|
2927
|
+
count: TAG_BATCH_SIZE
|
|
2928
|
+
});
|
|
2929
|
+
cursor = parseInt(result[0], 10);
|
|
2930
|
+
keys.push(...result[1]);
|
|
2931
|
+
} while (cursor !== 0);
|
|
2932
|
+
return keys;
|
|
2933
|
+
}
|
|
2934
|
+
};
|
|
2935
|
+
TagIndexRepository = __decorateClass([
|
|
2936
|
+
common.Injectable(),
|
|
2937
|
+
__decorateParam(0, common.Inject(core.REDIS_DRIVER)),
|
|
2938
|
+
__decorateParam(1, common.Inject(CACHE_PLUGIN_OPTIONS)),
|
|
2939
|
+
__decorateParam(2, common.Inject(LUA_SCRIPT_LOADER))
|
|
2940
|
+
], TagIndexRepository);
|
|
2941
|
+
|
|
2942
|
+
// src/tags/infrastructure/scripts/lua-scripts.ts
|
|
2943
|
+
var ADD_KEY_TO_TAGS_SCRIPT = `
|
|
2944
|
+
local cache_key = ARGV[1]
|
|
2945
|
+
local tag_ttl = tonumber(ARGV[2])
|
|
2946
|
+
|
|
2947
|
+
for i = 1, #KEYS do
|
|
2948
|
+
local tag_key = KEYS[i]
|
|
2949
|
+
redis.call('SADD', tag_key, cache_key)
|
|
2950
|
+
redis.call('EXPIRE', tag_key, tag_ttl)
|
|
2951
|
+
end
|
|
2952
|
+
|
|
2953
|
+
return #KEYS
|
|
2954
|
+
`.trim();
|
|
2955
|
+
var INVALIDATE_TAG_SCRIPT = `
|
|
2956
|
+
local tag_key = KEYS[1]
|
|
2957
|
+
|
|
2958
|
+
-- Get all cache keys for this tag
|
|
2959
|
+
local cache_keys = redis.call('SMEMBERS', tag_key)
|
|
2960
|
+
|
|
2961
|
+
if #cache_keys == 0 then
|
|
2962
|
+
-- Delete empty tag set and return 0
|
|
2963
|
+
redis.call('DEL', tag_key)
|
|
2964
|
+
return 0
|
|
2965
|
+
end
|
|
2966
|
+
|
|
2967
|
+
-- Delete all cache keys
|
|
2968
|
+
local deleted = 0
|
|
2969
|
+
for i = 1, #cache_keys do
|
|
2970
|
+
local result = redis.call('DEL', cache_keys[i])
|
|
2971
|
+
deleted = deleted + result
|
|
2972
|
+
end
|
|
2973
|
+
|
|
2974
|
+
-- Delete the tag set itself
|
|
2975
|
+
redis.call('DEL', tag_key)
|
|
2976
|
+
|
|
2977
|
+
return deleted
|
|
2978
|
+
`.trim();
|
|
2979
|
+
|
|
2980
|
+
// src/tags/infrastructure/services/lua-script-loader.service.ts
|
|
2981
|
+
var SCRIPTS = {
|
|
2982
|
+
"invalidate-tag": INVALIDATE_TAG_SCRIPT,
|
|
2983
|
+
"add-key-to-tags": ADD_KEY_TO_TAGS_SCRIPT
|
|
2984
|
+
};
|
|
2985
|
+
var LuaScriptLoader = class {
|
|
2986
|
+
constructor(driver) {
|
|
2987
|
+
this.driver = driver;
|
|
2988
|
+
}
|
|
2989
|
+
logger = new common.Logger(LuaScriptLoader.name);
|
|
2990
|
+
scriptShas = /* @__PURE__ */ new Map();
|
|
2991
|
+
async onModuleInit() {
|
|
2992
|
+
await this.loadScripts();
|
|
2993
|
+
}
|
|
2994
|
+
/**
|
|
2995
|
+
* Loads all Lua scripts into Redis.
|
|
2996
|
+
*/
|
|
2997
|
+
async loadScripts() {
|
|
2998
|
+
for (const [scriptName, scriptContent] of Object.entries(SCRIPTS)) {
|
|
2999
|
+
try {
|
|
3000
|
+
const sha = await this.driver.scriptLoad(scriptContent);
|
|
3001
|
+
this.scriptShas.set(scriptName, sha);
|
|
3002
|
+
this.logger.debug(`Loaded Lua script: ${scriptName} (SHA: ${sha})`);
|
|
3003
|
+
} catch (error) {
|
|
3004
|
+
this.logger.error(`Failed to load script ${scriptName}:`, error);
|
|
3005
|
+
throw error;
|
|
3006
|
+
}
|
|
3007
|
+
}
|
|
3008
|
+
this.logger.log(`Successfully loaded ${this.scriptShas.size} Lua scripts`);
|
|
3009
|
+
}
|
|
3010
|
+
/**
|
|
3011
|
+
* Executes a Lua script by name using EVALSHA.
|
|
3012
|
+
*
|
|
3013
|
+
* @param scriptName - Name of the script
|
|
3014
|
+
* @param keys - Redis keys to pass to the script
|
|
3015
|
+
* @param args - Arguments to pass to the script
|
|
3016
|
+
* @returns Script execution result
|
|
3017
|
+
*/
|
|
3018
|
+
async evalSha(scriptName, keys, args) {
|
|
3019
|
+
const sha = this.scriptShas.get(scriptName);
|
|
3020
|
+
if (!sha) {
|
|
3021
|
+
throw new Error(`Lua script not loaded: ${scriptName}`);
|
|
3022
|
+
}
|
|
3023
|
+
return this.driver.evalsha(sha, keys, args);
|
|
3024
|
+
}
|
|
3025
|
+
/**
|
|
3026
|
+
* Gets the SHA hash of a loaded script.
|
|
3027
|
+
*
|
|
3028
|
+
* @param scriptName - Name of the script
|
|
3029
|
+
* @returns SHA hash or undefined if not loaded
|
|
3030
|
+
*/
|
|
3031
|
+
getSha(scriptName) {
|
|
3032
|
+
return this.scriptShas.get(scriptName);
|
|
3033
|
+
}
|
|
3034
|
+
/**
|
|
3035
|
+
* Checks if a script is loaded.
|
|
3036
|
+
*
|
|
3037
|
+
* @param scriptName - Name of the script
|
|
3038
|
+
* @returns True if script is loaded
|
|
3039
|
+
*/
|
|
3040
|
+
isLoaded(scriptName) {
|
|
3041
|
+
return this.scriptShas.has(scriptName);
|
|
3042
|
+
}
|
|
3043
|
+
};
|
|
3044
|
+
LuaScriptLoader = __decorateClass([
|
|
3045
|
+
common.Injectable(),
|
|
3046
|
+
__decorateParam(0, common.Inject(core.REDIS_DRIVER))
|
|
3047
|
+
], LuaScriptLoader);
|
|
3048
|
+
|
|
3049
|
+
// src/cache.plugin.ts
|
|
3050
|
+
var CachePlugin = class {
|
|
3051
|
+
constructor(options = {}) {
|
|
3052
|
+
this.options = options;
|
|
3053
|
+
}
|
|
3054
|
+
name = "cache";
|
|
3055
|
+
version = "0.1.0";
|
|
3056
|
+
description = "Advanced caching with L1+L2, anti-stampede, SWR, and tag invalidation";
|
|
3057
|
+
getProviders() {
|
|
3058
|
+
const config = {
|
|
3059
|
+
l1: { ...DEFAULT_CACHE_CONFIG.l1, ...this.options.l1 },
|
|
3060
|
+
l2: { ...DEFAULT_CACHE_CONFIG.l2, ...this.options.l2 },
|
|
3061
|
+
stampede: { ...DEFAULT_CACHE_CONFIG.stampede, ...this.options.stampede },
|
|
3062
|
+
swr: { ...DEFAULT_CACHE_CONFIG.swr, ...this.options.swr },
|
|
3063
|
+
tags: { ...DEFAULT_CACHE_CONFIG.tags, ...this.options.tags },
|
|
3064
|
+
warmup: { ...DEFAULT_CACHE_CONFIG.warmup, ...this.options.warmup },
|
|
3065
|
+
keys: { ...DEFAULT_CACHE_CONFIG.keys, ...this.options.keys },
|
|
3066
|
+
invalidation: {
|
|
3067
|
+
...DEFAULT_CACHE_CONFIG.invalidation,
|
|
3068
|
+
...this.options.invalidation
|
|
3069
|
+
}
|
|
3070
|
+
};
|
|
3071
|
+
return [
|
|
3072
|
+
// Configuration
|
|
3073
|
+
{
|
|
3074
|
+
provide: CACHE_PLUGIN_OPTIONS,
|
|
3075
|
+
useValue: config
|
|
3076
|
+
},
|
|
3077
|
+
// Domain services
|
|
3078
|
+
{
|
|
3079
|
+
provide: SERIALIZER,
|
|
3080
|
+
useClass: Serializer
|
|
3081
|
+
},
|
|
3082
|
+
// Infrastructure adapters
|
|
3083
|
+
{
|
|
3084
|
+
provide: L1_CACHE_STORE,
|
|
3085
|
+
useClass: L1MemoryStoreAdapter
|
|
3086
|
+
},
|
|
3087
|
+
{
|
|
3088
|
+
provide: L2_CACHE_STORE,
|
|
3089
|
+
useClass: L2RedisStoreAdapter
|
|
3090
|
+
},
|
|
3091
|
+
// Application services
|
|
3092
|
+
{
|
|
3093
|
+
provide: CACHE_SERVICE,
|
|
3094
|
+
useClass: CacheService
|
|
3095
|
+
},
|
|
3096
|
+
{
|
|
3097
|
+
provide: STAMPEDE_PROTECTION,
|
|
3098
|
+
useClass: StampedeProtectionService
|
|
3099
|
+
},
|
|
3100
|
+
{
|
|
3101
|
+
provide: TAG_INDEX,
|
|
3102
|
+
useClass: TagIndexRepository
|
|
3103
|
+
},
|
|
3104
|
+
{
|
|
3105
|
+
provide: SWR_MANAGER,
|
|
3106
|
+
useClass: SwrManagerService
|
|
3107
|
+
},
|
|
3108
|
+
{
|
|
3109
|
+
provide: LUA_SCRIPT_LOADER,
|
|
3110
|
+
useClass: LuaScriptLoader
|
|
3111
|
+
},
|
|
3112
|
+
// Invalidation services
|
|
3113
|
+
{
|
|
3114
|
+
provide: INVALIDATION_REGISTRY,
|
|
3115
|
+
useClass: exports.InvalidationRegistryService
|
|
3116
|
+
},
|
|
3117
|
+
{
|
|
3118
|
+
provide: EVENT_INVALIDATION_SERVICE,
|
|
3119
|
+
useClass: exports.EventInvalidationService
|
|
3120
|
+
},
|
|
3121
|
+
// Invalidation adapters (optional)
|
|
3122
|
+
AMQPEventSourceAdapter,
|
|
3123
|
+
// Public API
|
|
3124
|
+
exports.CacheService,
|
|
3125
|
+
// @Cached decorator initialization
|
|
3126
|
+
CacheDecoratorInitializerService,
|
|
3127
|
+
// Cache warmup (runs on OnModuleInit if enabled)
|
|
3128
|
+
WarmupService,
|
|
3129
|
+
// Reflector is needed for decorator metadata
|
|
3130
|
+
core$1.Reflector,
|
|
3131
|
+
// Factory for registering static invalidation rules
|
|
3132
|
+
{
|
|
3133
|
+
provide: INVALIDATION_RULES_INIT,
|
|
3134
|
+
useFactory: (registry, config2) => {
|
|
3135
|
+
if (config2.invalidation?.rules && config2.invalidation.rules.length > 0) {
|
|
3136
|
+
const rules = config2.invalidation.rules.map((ruleProps) => InvalidationRule.create(ruleProps));
|
|
3137
|
+
registry.registerMany(rules);
|
|
3138
|
+
}
|
|
3139
|
+
return true;
|
|
3140
|
+
},
|
|
3141
|
+
inject: [INVALIDATION_REGISTRY, CACHE_PLUGIN_OPTIONS]
|
|
3142
|
+
}
|
|
3143
|
+
];
|
|
3144
|
+
}
|
|
3145
|
+
getExports() {
|
|
3146
|
+
return [CACHE_PLUGIN_OPTIONS, CACHE_SERVICE, exports.CacheService, INVALIDATION_REGISTRY, EVENT_INVALIDATION_SERVICE];
|
|
3147
|
+
}
|
|
3148
|
+
};
|
|
3149
|
+
var logger2 = new common.Logger("InvalidateTags");
|
|
3150
|
+
function InvalidateTags(options) {
|
|
3151
|
+
return (target, propertyKey, descriptor) => {
|
|
3152
|
+
const originalMethod = descriptor.value;
|
|
3153
|
+
const when = options.when ?? "after";
|
|
3154
|
+
descriptor.value = async function(...args) {
|
|
3155
|
+
const cacheService = getCacheService();
|
|
3156
|
+
const tags = typeof options.tags === "function" ? options.tags(...args) : options.tags;
|
|
3157
|
+
if (when === "before" && cacheService && tags.length > 0) {
|
|
3158
|
+
try {
|
|
3159
|
+
await cacheService.invalidateTags(tags);
|
|
3160
|
+
} catch (error) {
|
|
3161
|
+
logger2.error(`@InvalidateTags: Failed to invalidate tags before method:`, error);
|
|
3162
|
+
}
|
|
3163
|
+
}
|
|
3164
|
+
const result = await originalMethod.apply(this, args);
|
|
3165
|
+
if (when === "after" && cacheService && tags.length > 0) {
|
|
3166
|
+
try {
|
|
3167
|
+
await cacheService.invalidateTags(tags);
|
|
3168
|
+
} catch (error) {
|
|
3169
|
+
logger2.error(`@InvalidateTags: Failed to invalidate tags after method:`, error);
|
|
3170
|
+
}
|
|
3171
|
+
}
|
|
3172
|
+
return result;
|
|
3173
|
+
};
|
|
3174
|
+
Object.defineProperty(descriptor.value, "name", {
|
|
3175
|
+
value: originalMethod.name,
|
|
3176
|
+
writable: false
|
|
3177
|
+
});
|
|
3178
|
+
Reflect.defineMetadata(INVALIDATE_TAGS_KEY, options, descriptor.value);
|
|
3179
|
+
return descriptor;
|
|
3180
|
+
};
|
|
3181
|
+
}
|
|
3182
|
+
var CACHEABLE_METADATA_KEY = "cache:cacheable";
|
|
3183
|
+
function Cacheable(options) {
|
|
3184
|
+
return common.SetMetadata(CACHEABLE_METADATA_KEY, options);
|
|
3185
|
+
}
|
|
3186
|
+
var CACHE_PUT_METADATA_KEY = "cache:put";
|
|
3187
|
+
function CachePut(options) {
|
|
3188
|
+
return common.SetMetadata(CACHE_PUT_METADATA_KEY, options);
|
|
3189
|
+
}
|
|
3190
|
+
var CACHE_EVICT_METADATA_KEY = "cache:evict";
|
|
3191
|
+
function CacheEvict(options = {}) {
|
|
3192
|
+
return common.SetMetadata(CACHE_EVICT_METADATA_KEY, options);
|
|
3193
|
+
}
|
|
3194
|
+
|
|
3195
|
+
// src/decorators/key-generator.util.ts
|
|
3196
|
+
function getParameterNames(method) {
|
|
3197
|
+
const fnStr = method.toString().replace(/\/\*[\s\S]*?\*\//g, "");
|
|
3198
|
+
const match = fnStr.match(/\(([^)]*)\)/);
|
|
3199
|
+
if (!match?.[1]) {
|
|
3200
|
+
return [];
|
|
3201
|
+
}
|
|
3202
|
+
const params = match[1];
|
|
3203
|
+
return params.split(",").map((param) => param.trim().split("=")[0]?.trim() ?? "").filter((param) => param && param !== "");
|
|
3204
|
+
}
|
|
3205
|
+
function getNestedValue(obj, path) {
|
|
3206
|
+
if (!obj || typeof obj !== "object") {
|
|
3207
|
+
return void 0;
|
|
3208
|
+
}
|
|
3209
|
+
const keys = path.split(".");
|
|
3210
|
+
let value = obj;
|
|
3211
|
+
for (const key of keys) {
|
|
3212
|
+
if (value === null || value === void 0) {
|
|
3213
|
+
return void 0;
|
|
3214
|
+
}
|
|
3215
|
+
value = value[key];
|
|
3216
|
+
}
|
|
3217
|
+
return value;
|
|
3218
|
+
}
|
|
3219
|
+
function generateKey(template, method, args, namespace) {
|
|
3220
|
+
const paramNames = getParameterNames(method);
|
|
3221
|
+
const paramMap = /* @__PURE__ */ new Map();
|
|
3222
|
+
paramNames.forEach((name, index) => {
|
|
3223
|
+
if (index < args.length) {
|
|
3224
|
+
paramMap.set(name, args[index]);
|
|
3225
|
+
}
|
|
3226
|
+
});
|
|
3227
|
+
let key = template;
|
|
3228
|
+
const placeholderRegex = /\{([^}]+)\}/g;
|
|
3229
|
+
const matches = Array.from(template.matchAll(placeholderRegex));
|
|
3230
|
+
if (matches.length === 0) {
|
|
3231
|
+
return namespace ? `${namespace}:${template}` : template;
|
|
3232
|
+
}
|
|
3233
|
+
for (const match of matches) {
|
|
3234
|
+
const placeholder = match[0];
|
|
3235
|
+
const path = match[1];
|
|
3236
|
+
if (!path) {
|
|
3237
|
+
continue;
|
|
3238
|
+
}
|
|
3239
|
+
let value;
|
|
3240
|
+
if (path.includes(".")) {
|
|
3241
|
+
const [rootParam, ...nestedPath] = path.split(".");
|
|
3242
|
+
if (!rootParam) {
|
|
3243
|
+
throw new CacheKeyError(template, "Invalid nested property path");
|
|
3244
|
+
}
|
|
3245
|
+
const rootValue = paramMap.get(rootParam);
|
|
3246
|
+
if (rootValue === void 0) {
|
|
3247
|
+
throw new CacheKeyError(template, `Parameter '${rootParam}' not found in method signature`);
|
|
3248
|
+
}
|
|
3249
|
+
value = getNestedValue(rootValue, nestedPath.join("."));
|
|
3250
|
+
if (value === void 0) {
|
|
3251
|
+
throw new CacheKeyError(template, `Nested property '${path}' not found or is undefined`);
|
|
3252
|
+
}
|
|
3253
|
+
} else {
|
|
3254
|
+
if (!paramMap.has(path)) {
|
|
3255
|
+
throw new CacheKeyError(template, `Parameter '${path}' not found in method signature`);
|
|
3256
|
+
}
|
|
3257
|
+
value = paramMap.get(path);
|
|
3258
|
+
if (value === void 0 || value === null) {
|
|
3259
|
+
throw new CacheKeyError(template, `Parameter '${path}' is null or undefined`);
|
|
3260
|
+
}
|
|
3261
|
+
}
|
|
3262
|
+
const stringValue = String(value);
|
|
3263
|
+
if (stringValue.includes(":") || stringValue.includes("{") || stringValue.includes("}")) {
|
|
3264
|
+
throw new CacheKeyError(template, `Parameter value '${stringValue}' contains invalid characters (:, {, })`);
|
|
3265
|
+
}
|
|
3266
|
+
key = key.replace(placeholder, stringValue);
|
|
3267
|
+
}
|
|
3268
|
+
if (namespace) {
|
|
3269
|
+
key = `${namespace}:${key}`;
|
|
3270
|
+
}
|
|
3271
|
+
return key;
|
|
3272
|
+
}
|
|
3273
|
+
function generateKeys(templates, method, args, namespace) {
|
|
3274
|
+
return templates.map((template) => generateKey(template, method, args, namespace));
|
|
3275
|
+
}
|
|
3276
|
+
function evaluateTags(tags, args) {
|
|
3277
|
+
if (!tags) {
|
|
3278
|
+
return [];
|
|
3279
|
+
}
|
|
3280
|
+
if (typeof tags === "function") {
|
|
3281
|
+
return tags(...args);
|
|
3282
|
+
}
|
|
3283
|
+
return tags;
|
|
3284
|
+
}
|
|
3285
|
+
function evaluateCondition(condition, args) {
|
|
3286
|
+
if (!condition) {
|
|
3287
|
+
return true;
|
|
3288
|
+
}
|
|
3289
|
+
return condition(...args);
|
|
3290
|
+
}
|
|
3291
|
+
exports.DeclarativeCacheInterceptor = class CacheInterceptor {
|
|
3292
|
+
constructor(cacheService, reflector) {
|
|
3293
|
+
this.cacheService = cacheService;
|
|
3294
|
+
this.reflector = reflector;
|
|
3295
|
+
}
|
|
3296
|
+
logger = new common.Logger(exports.DeclarativeCacheInterceptor.name);
|
|
3297
|
+
intercept(context, next) {
|
|
3298
|
+
const handler = context.getHandler();
|
|
3299
|
+
context.getClass();
|
|
3300
|
+
const cacheableOptions = this.reflector.get(CACHEABLE_METADATA_KEY, handler);
|
|
3301
|
+
const cachePutOptions = this.reflector.get(CACHE_PUT_METADATA_KEY, handler);
|
|
3302
|
+
const cacheEvictOptions = this.reflector.get(CACHE_EVICT_METADATA_KEY, handler);
|
|
3303
|
+
const args = context.getArgByIndex(context.getArgs().length - 1) ? context.getArgs() : [];
|
|
3304
|
+
const method = handler;
|
|
3305
|
+
if (cacheableOptions) {
|
|
3306
|
+
return this.handleCacheable(cacheableOptions, method, args, next);
|
|
3307
|
+
}
|
|
3308
|
+
if (cachePutOptions) {
|
|
3309
|
+
return this.handleCachePut(cachePutOptions, method, args, next);
|
|
3310
|
+
}
|
|
3311
|
+
if (cacheEvictOptions) {
|
|
3312
|
+
return this.handleCacheEvict(cacheEvictOptions, method, args, next);
|
|
3313
|
+
}
|
|
3314
|
+
return next.handle();
|
|
3315
|
+
}
|
|
3316
|
+
/**
|
|
3317
|
+
* Handles @Cacheable decorator.
|
|
3318
|
+
* Returns cached value or executes method and caches result.
|
|
3319
|
+
*/
|
|
3320
|
+
handleCacheable(options, method, args, next) {
|
|
3321
|
+
if (!evaluateCondition(options.condition, args)) {
|
|
3322
|
+
this.logger.debug("Cacheable condition not met, executing method");
|
|
3323
|
+
return next.handle();
|
|
3324
|
+
}
|
|
3325
|
+
try {
|
|
3326
|
+
const key = options.keyGenerator ? options.keyGenerator(...args) : generateKey(options.key, method, args, options.namespace);
|
|
3327
|
+
this.logger.debug(`Cacheable: checking cache for key: ${key}`);
|
|
3328
|
+
return rxjs.from(this.cacheService.get(key)).pipe(
|
|
3329
|
+
operators.switchMap((cachedValue) => {
|
|
3330
|
+
if (cachedValue !== null) {
|
|
3331
|
+
this.logger.debug(`Cacheable: cache hit for key: ${key}`);
|
|
3332
|
+
return rxjs.of(cachedValue);
|
|
3333
|
+
}
|
|
3334
|
+
this.logger.debug(`Cacheable: cache miss for key: ${key}, executing method`);
|
|
3335
|
+
return next.handle().pipe(
|
|
3336
|
+
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
|
3337
|
+
operators.tap(async (result) => {
|
|
3338
|
+
const tags = evaluateTags(options.tags, args);
|
|
3339
|
+
const ttl = options.ttl ?? 3600;
|
|
3340
|
+
try {
|
|
3341
|
+
await this.cacheService.set(key, result, { ttl, tags });
|
|
3342
|
+
this.logger.debug(`Cacheable: cached result for key: ${key}`);
|
|
3343
|
+
} catch (error) {
|
|
3344
|
+
this.logger.error(`Cacheable: failed to cache result for key ${key}: ${error.message}`);
|
|
3345
|
+
}
|
|
3346
|
+
})
|
|
3347
|
+
);
|
|
3348
|
+
})
|
|
3349
|
+
);
|
|
3350
|
+
} catch (error) {
|
|
3351
|
+
this.logger.error(`Cacheable: error processing cache: ${error.message}`);
|
|
3352
|
+
return next.handle();
|
|
3353
|
+
}
|
|
3354
|
+
}
|
|
3355
|
+
/**
|
|
3356
|
+
* Handles @CachePut decorator.
|
|
3357
|
+
* Always executes method and caches the result.
|
|
3358
|
+
*/
|
|
3359
|
+
handleCachePut(options, method, args, next) {
|
|
3360
|
+
if (!evaluateCondition(options.condition, args)) {
|
|
3361
|
+
this.logger.debug("CachePut condition not met, executing method without caching");
|
|
3362
|
+
return next.handle();
|
|
3363
|
+
}
|
|
3364
|
+
try {
|
|
3365
|
+
const key = options.keyGenerator ? options.keyGenerator(...args) : generateKey(options.key, method, args, options.namespace);
|
|
3366
|
+
this.logger.debug(`CachePut: executing method for key: ${key}`);
|
|
3367
|
+
return next.handle().pipe(
|
|
3368
|
+
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
|
3369
|
+
operators.tap(async (result) => {
|
|
3370
|
+
if ((result === null || result === void 0) && !options.cacheNullValues) {
|
|
3371
|
+
this.logger.debug(`CachePut: skipping null/undefined result for key: ${key}`);
|
|
3372
|
+
return;
|
|
3373
|
+
}
|
|
3374
|
+
const tags = evaluateTags(options.tags, args);
|
|
3375
|
+
const ttl = options.ttl ?? 3600;
|
|
3376
|
+
try {
|
|
3377
|
+
await this.cacheService.set(key, result, { ttl, tags });
|
|
3378
|
+
this.logger.debug(`CachePut: cached result for key: ${key}`);
|
|
3379
|
+
} catch (error) {
|
|
3380
|
+
this.logger.error(`CachePut: failed to cache result for key ${key}: ${error.message}`);
|
|
3381
|
+
}
|
|
3382
|
+
})
|
|
3383
|
+
);
|
|
3384
|
+
} catch (error) {
|
|
3385
|
+
this.logger.error(`CachePut: error processing cache: ${error.message}`);
|
|
3386
|
+
return next.handle();
|
|
3387
|
+
}
|
|
3388
|
+
}
|
|
3389
|
+
/**
|
|
3390
|
+
* Handles @CacheEvict decorator.
|
|
3391
|
+
* Evicts cache entries before or after method execution.
|
|
3392
|
+
*/
|
|
3393
|
+
handleCacheEvict(options, method, args, next) {
|
|
3394
|
+
if (!evaluateCondition(options.condition, args)) {
|
|
3395
|
+
this.logger.debug("CacheEvict condition not met, executing method without eviction");
|
|
3396
|
+
return next.handle();
|
|
3397
|
+
}
|
|
3398
|
+
const evictFn = async () => {
|
|
3399
|
+
try {
|
|
3400
|
+
if (options.allEntries) {
|
|
3401
|
+
this.logger.debug("CacheEvict: clearing all cache entries");
|
|
3402
|
+
await this.cacheService.clear();
|
|
3403
|
+
return;
|
|
3404
|
+
}
|
|
3405
|
+
if (options.tags && options.tags.length > 0) {
|
|
3406
|
+
this.logger.debug(`CacheEvict: invalidating tags: ${options.tags.join(", ")}`);
|
|
3407
|
+
await this.cacheService.invalidateTags(options.tags);
|
|
3408
|
+
}
|
|
3409
|
+
if (options.keys && options.keys.length > 0) {
|
|
3410
|
+
const keys = options.keyGenerator ? options.keyGenerator(...args) : generateKeys(options.keys, method, args, options.namespace);
|
|
3411
|
+
this.logger.debug(`CacheEvict: evicting keys: ${keys.join(", ")}`);
|
|
3412
|
+
for (const key of keys) {
|
|
3413
|
+
if (key.includes("*")) {
|
|
3414
|
+
this.logger.warn(`CacheEvict: wildcard keys not supported: ${key}. Use tags instead.`);
|
|
3415
|
+
} else {
|
|
3416
|
+
await this.cacheService.del(key);
|
|
3417
|
+
}
|
|
3418
|
+
}
|
|
3419
|
+
}
|
|
3420
|
+
} catch (error) {
|
|
3421
|
+
this.logger.error(`CacheEvict: error evicting cache: ${error.message}`);
|
|
3422
|
+
}
|
|
3423
|
+
};
|
|
3424
|
+
if (options.beforeInvocation) {
|
|
3425
|
+
return rxjs.from(evictFn()).pipe(operators.switchMap(() => next.handle()));
|
|
3426
|
+
}
|
|
3427
|
+
return next.handle().pipe(
|
|
3428
|
+
operators.tap(() => {
|
|
3429
|
+
void evictFn();
|
|
3430
|
+
})
|
|
3431
|
+
);
|
|
3432
|
+
}
|
|
3433
|
+
};
|
|
3434
|
+
exports.DeclarativeCacheInterceptor = __decorateClass([
|
|
3435
|
+
common.Injectable()
|
|
3436
|
+
], exports.DeclarativeCacheInterceptor);
|
|
3437
|
+
|
|
3438
|
+
// src/strategies/lru.strategy.ts
|
|
3439
|
+
var LruStrategy = class {
|
|
3440
|
+
/**
|
|
3441
|
+
* Map maintaining keys in LRU order.
|
|
3442
|
+
* JavaScript Map maintains insertion order, so we use delete+set to move to end.
|
|
3443
|
+
*/
|
|
3444
|
+
keyOrder;
|
|
3445
|
+
/**
|
|
3446
|
+
* Timestamp counter for tracking access order
|
|
3447
|
+
*/
|
|
3448
|
+
timestamp;
|
|
3449
|
+
constructor() {
|
|
3450
|
+
this.keyOrder = /* @__PURE__ */ new Map();
|
|
3451
|
+
this.timestamp = 0;
|
|
3452
|
+
}
|
|
3453
|
+
/**
|
|
3454
|
+
* Records access to a key, moving it to most recently used position.
|
|
3455
|
+
*
|
|
3456
|
+
* @param key - Key that was accessed
|
|
3457
|
+
*/
|
|
3458
|
+
recordAccess(key) {
|
|
3459
|
+
this.timestamp++;
|
|
3460
|
+
this.keyOrder.set(key, this.timestamp);
|
|
3461
|
+
}
|
|
3462
|
+
/**
|
|
3463
|
+
* Records insertion of a new key.
|
|
3464
|
+
*
|
|
3465
|
+
* @param key - Key that was inserted
|
|
3466
|
+
*/
|
|
3467
|
+
recordInsert(key) {
|
|
3468
|
+
this.timestamp++;
|
|
3469
|
+
this.keyOrder.set(key, this.timestamp);
|
|
3470
|
+
}
|
|
3471
|
+
/**
|
|
3472
|
+
* Records deletion of a key.
|
|
3473
|
+
*
|
|
3474
|
+
* @param key - Key that was deleted
|
|
3475
|
+
*/
|
|
3476
|
+
recordDelete(key) {
|
|
3477
|
+
this.keyOrder.delete(key);
|
|
3478
|
+
}
|
|
3479
|
+
/**
|
|
3480
|
+
* Selects the least recently used key for eviction.
|
|
3481
|
+
*
|
|
3482
|
+
* @returns Least recently used key, or undefined if empty
|
|
3483
|
+
*/
|
|
3484
|
+
selectVictim() {
|
|
3485
|
+
if (this.keyOrder.size === 0) {
|
|
3486
|
+
return void 0;
|
|
3487
|
+
}
|
|
3488
|
+
let oldestKey;
|
|
3489
|
+
let oldestTimestamp = Infinity;
|
|
3490
|
+
for (const [key, timestamp] of this.keyOrder.entries()) {
|
|
3491
|
+
if (timestamp < oldestTimestamp) {
|
|
3492
|
+
oldestTimestamp = timestamp;
|
|
3493
|
+
oldestKey = key;
|
|
3494
|
+
}
|
|
3495
|
+
}
|
|
3496
|
+
return oldestKey;
|
|
3497
|
+
}
|
|
3498
|
+
/**
|
|
3499
|
+
* Clears all tracking data.
|
|
3500
|
+
*/
|
|
3501
|
+
clear() {
|
|
3502
|
+
this.keyOrder.clear();
|
|
3503
|
+
this.timestamp = 0;
|
|
3504
|
+
}
|
|
3505
|
+
/**
|
|
3506
|
+
* Gets current number of tracked keys.
|
|
3507
|
+
*
|
|
3508
|
+
* @returns Number of keys
|
|
3509
|
+
*/
|
|
3510
|
+
size() {
|
|
3511
|
+
return this.keyOrder.size;
|
|
3512
|
+
}
|
|
3513
|
+
/**
|
|
3514
|
+
* Gets all keys in LRU order (oldest to newest).
|
|
3515
|
+
*
|
|
3516
|
+
* @returns Array of keys sorted by access time
|
|
3517
|
+
*/
|
|
3518
|
+
getKeys() {
|
|
3519
|
+
return Array.from(this.keyOrder.entries()).sort(([, a], [, b]) => a - b).map(([key]) => key);
|
|
3520
|
+
}
|
|
3521
|
+
/**
|
|
3522
|
+
* Gets keys that should be evicted to reach target size.
|
|
3523
|
+
*
|
|
3524
|
+
* @param targetSize - Desired size after eviction
|
|
3525
|
+
* @returns Array of keys to evict
|
|
3526
|
+
*/
|
|
3527
|
+
getVictims(targetSize) {
|
|
3528
|
+
const currentSize = this.keyOrder.size;
|
|
3529
|
+
if (currentSize <= targetSize) {
|
|
3530
|
+
return [];
|
|
3531
|
+
}
|
|
3532
|
+
const numToEvict = currentSize - targetSize;
|
|
3533
|
+
const sortedKeys = this.getKeys();
|
|
3534
|
+
return sortedKeys.slice(0, numToEvict);
|
|
3535
|
+
}
|
|
3536
|
+
};
|
|
3537
|
+
|
|
3538
|
+
// src/strategies/fifo.strategy.ts
|
|
3539
|
+
var FifoStrategy = class {
|
|
3540
|
+
/**
|
|
3541
|
+
* Queue maintaining keys in insertion order.
|
|
3542
|
+
* First element is oldest (next to evict).
|
|
3543
|
+
*/
|
|
3544
|
+
queue;
|
|
3545
|
+
/**
|
|
3546
|
+
* Set for O(1) existence checks
|
|
3547
|
+
*/
|
|
3548
|
+
keySet;
|
|
3549
|
+
constructor() {
|
|
3550
|
+
this.queue = [];
|
|
3551
|
+
this.keySet = /* @__PURE__ */ new Set();
|
|
3552
|
+
}
|
|
3553
|
+
/**
|
|
3554
|
+
* Records access to a key.
|
|
3555
|
+
* In FIFO, access doesn't affect eviction order, so this is a no-op.
|
|
3556
|
+
*
|
|
3557
|
+
* @param _key - Key that was accessed
|
|
3558
|
+
*/
|
|
3559
|
+
recordAccess(_key) {
|
|
3560
|
+
}
|
|
3561
|
+
/**
|
|
3562
|
+
* Records insertion of a new key.
|
|
3563
|
+
*
|
|
3564
|
+
* @param key - Key that was inserted
|
|
3565
|
+
*/
|
|
3566
|
+
recordInsert(key) {
|
|
3567
|
+
if (!this.keySet.has(key)) {
|
|
3568
|
+
this.queue.push(key);
|
|
3569
|
+
this.keySet.add(key);
|
|
3570
|
+
}
|
|
3571
|
+
}
|
|
3572
|
+
/**
|
|
3573
|
+
* Records deletion of a key.
|
|
3574
|
+
*
|
|
3575
|
+
* @param key - Key that was deleted
|
|
3576
|
+
*/
|
|
3577
|
+
recordDelete(key) {
|
|
3578
|
+
if (this.keySet.has(key)) {
|
|
3579
|
+
const index = this.queue.indexOf(key);
|
|
3580
|
+
if (index !== -1) {
|
|
3581
|
+
this.queue.splice(index, 1);
|
|
3582
|
+
}
|
|
3583
|
+
this.keySet.delete(key);
|
|
3584
|
+
}
|
|
3585
|
+
}
|
|
3586
|
+
/**
|
|
3587
|
+
* Selects the oldest inserted key for eviction.
|
|
3588
|
+
*
|
|
3589
|
+
* @returns Oldest key (first in queue), or undefined if empty
|
|
3590
|
+
*/
|
|
3591
|
+
selectVictim() {
|
|
3592
|
+
return this.queue[0];
|
|
3593
|
+
}
|
|
3594
|
+
/**
|
|
3595
|
+
* Clears all tracking data.
|
|
3596
|
+
*/
|
|
3597
|
+
clear() {
|
|
3598
|
+
this.queue.length = 0;
|
|
3599
|
+
this.keySet.clear();
|
|
3600
|
+
}
|
|
3601
|
+
/**
|
|
3602
|
+
* Gets current number of tracked keys.
|
|
3603
|
+
*
|
|
3604
|
+
* @returns Number of keys
|
|
3605
|
+
*/
|
|
3606
|
+
size() {
|
|
3607
|
+
return this.queue.length;
|
|
3608
|
+
}
|
|
3609
|
+
/**
|
|
3610
|
+
* Gets all keys in FIFO order (oldest to newest).
|
|
3611
|
+
*
|
|
3612
|
+
* @returns Array of keys in insertion order
|
|
3613
|
+
*/
|
|
3614
|
+
getKeys() {
|
|
3615
|
+
return [...this.queue];
|
|
3616
|
+
}
|
|
3617
|
+
/**
|
|
3618
|
+
* Gets keys that should be evicted to reach target size.
|
|
3619
|
+
*
|
|
3620
|
+
* @param targetSize - Desired size after eviction
|
|
3621
|
+
* @returns Array of keys to evict (oldest first)
|
|
3622
|
+
*/
|
|
3623
|
+
getVictims(targetSize) {
|
|
3624
|
+
const currentSize = this.queue.length;
|
|
3625
|
+
if (currentSize <= targetSize) {
|
|
3626
|
+
return [];
|
|
3627
|
+
}
|
|
3628
|
+
const numToEvict = currentSize - targetSize;
|
|
3629
|
+
return this.queue.slice(0, numToEvict);
|
|
3630
|
+
}
|
|
3631
|
+
/**
|
|
3632
|
+
* Checks if a key is tracked.
|
|
3633
|
+
*
|
|
3634
|
+
* @param key - Key to check
|
|
3635
|
+
* @returns True if key is tracked
|
|
3636
|
+
*/
|
|
3637
|
+
has(key) {
|
|
3638
|
+
return this.keySet.has(key);
|
|
3639
|
+
}
|
|
3640
|
+
};
|
|
3641
|
+
|
|
3642
|
+
// src/strategies/lfu.strategy.ts
|
|
3643
|
+
var LfuStrategy = class {
|
|
3644
|
+
entries;
|
|
3645
|
+
insertCounter;
|
|
3646
|
+
constructor() {
|
|
3647
|
+
this.entries = /* @__PURE__ */ new Map();
|
|
3648
|
+
this.insertCounter = 0;
|
|
3649
|
+
}
|
|
3650
|
+
/**
|
|
3651
|
+
* Records access to a key, incrementing its frequency counter.
|
|
3652
|
+
*
|
|
3653
|
+
* @param key - Key that was accessed
|
|
3654
|
+
*/
|
|
3655
|
+
recordAccess(key) {
|
|
3656
|
+
const entry = this.entries.get(key);
|
|
3657
|
+
if (entry) {
|
|
3658
|
+
entry.frequency++;
|
|
3659
|
+
}
|
|
3660
|
+
}
|
|
3661
|
+
/**
|
|
3662
|
+
* Records insertion of a new key with initial frequency of 1.
|
|
3663
|
+
*
|
|
3664
|
+
* @param key - Key that was inserted
|
|
3665
|
+
*/
|
|
3666
|
+
recordInsert(key) {
|
|
3667
|
+
if (!this.entries.has(key)) {
|
|
3668
|
+
this.insertCounter++;
|
|
3669
|
+
this.entries.set(key, {
|
|
3670
|
+
key,
|
|
3671
|
+
frequency: 1,
|
|
3672
|
+
insertOrder: this.insertCounter
|
|
3673
|
+
});
|
|
3674
|
+
}
|
|
3675
|
+
}
|
|
3676
|
+
/**
|
|
3677
|
+
* Records deletion of a key.
|
|
3678
|
+
*
|
|
3679
|
+
* @param key - Key that was deleted
|
|
3680
|
+
*/
|
|
3681
|
+
recordDelete(key) {
|
|
3682
|
+
this.entries.delete(key);
|
|
3683
|
+
}
|
|
3684
|
+
/**
|
|
3685
|
+
* Selects the least frequently used key for eviction.
|
|
3686
|
+
* When frequencies are equal, the oldest inserted key is selected.
|
|
3687
|
+
*
|
|
3688
|
+
* @returns Least frequently used key, or undefined if empty
|
|
3689
|
+
*/
|
|
3690
|
+
selectVictim() {
|
|
3691
|
+
if (this.entries.size === 0) {
|
|
3692
|
+
return void 0;
|
|
3693
|
+
}
|
|
3694
|
+
let victim;
|
|
3695
|
+
for (const entry of this.entries.values()) {
|
|
3696
|
+
if (!victim || entry.frequency < victim.frequency || entry.frequency === victim.frequency && entry.insertOrder < victim.insertOrder) {
|
|
3697
|
+
victim = entry;
|
|
3698
|
+
}
|
|
3699
|
+
}
|
|
3700
|
+
return victim?.key;
|
|
3701
|
+
}
|
|
3702
|
+
/**
|
|
3703
|
+
* Clears all tracking data.
|
|
3704
|
+
*/
|
|
3705
|
+
clear() {
|
|
3706
|
+
this.entries.clear();
|
|
3707
|
+
this.insertCounter = 0;
|
|
3708
|
+
}
|
|
3709
|
+
/**
|
|
3710
|
+
* Gets current number of tracked keys.
|
|
3711
|
+
*
|
|
3712
|
+
* @returns Number of keys
|
|
3713
|
+
*/
|
|
3714
|
+
size() {
|
|
3715
|
+
return this.entries.size;
|
|
3716
|
+
}
|
|
3717
|
+
/**
|
|
3718
|
+
* Gets all keys sorted by frequency (lowest first), then by insertion order.
|
|
3719
|
+
*
|
|
3720
|
+
* @returns Array of keys sorted by eviction priority
|
|
3721
|
+
*/
|
|
3722
|
+
getKeys() {
|
|
3723
|
+
return Array.from(this.entries.values()).sort((a, b) => a.frequency - b.frequency || a.insertOrder - b.insertOrder).map((entry) => entry.key);
|
|
3724
|
+
}
|
|
3725
|
+
/**
|
|
3726
|
+
* Gets keys that should be evicted to reach target size.
|
|
3727
|
+
*
|
|
3728
|
+
* @param targetSize - Desired size after eviction
|
|
3729
|
+
* @returns Array of keys to evict
|
|
3730
|
+
*/
|
|
3731
|
+
getVictims(targetSize) {
|
|
3732
|
+
const currentSize = this.entries.size;
|
|
3733
|
+
if (currentSize <= targetSize) {
|
|
3734
|
+
return [];
|
|
3735
|
+
}
|
|
3736
|
+
const numToEvict = currentSize - targetSize;
|
|
3737
|
+
const sortedKeys = this.getKeys();
|
|
3738
|
+
return sortedKeys.slice(0, numToEvict);
|
|
3739
|
+
}
|
|
3740
|
+
/**
|
|
3741
|
+
* Checks if a key is tracked.
|
|
3742
|
+
*
|
|
3743
|
+
* @param key - Key to check
|
|
3744
|
+
* @returns True if key is tracked
|
|
3745
|
+
*/
|
|
3746
|
+
has(key) {
|
|
3747
|
+
return this.entries.has(key);
|
|
3748
|
+
}
|
|
3749
|
+
/**
|
|
3750
|
+
* Gets the frequency count for a key.
|
|
3751
|
+
*
|
|
3752
|
+
* @param key - Key to check
|
|
3753
|
+
* @returns Frequency count, or 0 if key not tracked
|
|
3754
|
+
*/
|
|
3755
|
+
getFrequency(key) {
|
|
3756
|
+
return this.entries.get(key)?.frequency ?? 0;
|
|
3757
|
+
}
|
|
3758
|
+
};
|
|
3759
|
+
|
|
3760
|
+
// src/serializers/json.serializer.ts
|
|
3761
|
+
var JsonSerializer = class {
|
|
3762
|
+
/**
|
|
3763
|
+
* Serializes value to JSON string.
|
|
3764
|
+
*
|
|
3765
|
+
* @param value - Value to serialize
|
|
3766
|
+
* @returns JSON string
|
|
3767
|
+
* @throws SerializationError if serialization fails
|
|
3768
|
+
*/
|
|
3769
|
+
serialize(value) {
|
|
3770
|
+
try {
|
|
3771
|
+
return JSON.stringify(value);
|
|
3772
|
+
} catch (error) {
|
|
3773
|
+
throw new SerializationError(`JSON serialization failed: ${error.message}`, error);
|
|
3774
|
+
}
|
|
3775
|
+
}
|
|
3776
|
+
/**
|
|
3777
|
+
* Deserializes JSON string back to value.
|
|
3778
|
+
*
|
|
3779
|
+
* @param data - JSON string or buffer
|
|
3780
|
+
* @returns Deserialized value
|
|
3781
|
+
* @throws SerializationError if deserialization fails
|
|
3782
|
+
*/
|
|
3783
|
+
deserialize(data) {
|
|
3784
|
+
try {
|
|
3785
|
+
const str = Buffer.isBuffer(data) ? data.toString("utf8") : data;
|
|
3786
|
+
return JSON.parse(str);
|
|
3787
|
+
} catch (error) {
|
|
3788
|
+
throw new SerializationError(`JSON deserialization failed: ${error.message}`, error);
|
|
3789
|
+
}
|
|
3790
|
+
}
|
|
3791
|
+
/**
|
|
3792
|
+
* Safely tries to deserialize, returns null on error.
|
|
3793
|
+
*
|
|
3794
|
+
* @param data - JSON string or buffer
|
|
3795
|
+
* @returns Deserialized value or null
|
|
3796
|
+
*/
|
|
3797
|
+
tryDeserialize(data) {
|
|
3798
|
+
try {
|
|
3799
|
+
return this.deserialize(data);
|
|
3800
|
+
} catch {
|
|
3801
|
+
return null;
|
|
3802
|
+
}
|
|
3803
|
+
}
|
|
3804
|
+
/**
|
|
3805
|
+
* Gets content type for JSON.
|
|
3806
|
+
*
|
|
3807
|
+
* @returns Content type string
|
|
3808
|
+
*/
|
|
3809
|
+
getContentType() {
|
|
3810
|
+
return "application/json";
|
|
3811
|
+
}
|
|
3812
|
+
};
|
|
3813
|
+
|
|
3814
|
+
// src/serializers/msgpack.serializer.ts
|
|
3815
|
+
var MsgpackSerializer = class {
|
|
3816
|
+
encoder;
|
|
3817
|
+
decoder;
|
|
3818
|
+
constructor() {
|
|
3819
|
+
try {
|
|
3820
|
+
const msgpackr = __require("msgpackr");
|
|
3821
|
+
this.encoder = new msgpackr.Encoder({
|
|
3822
|
+
useRecords: false,
|
|
3823
|
+
// Don't use msgpack extension records
|
|
3824
|
+
structuredClone: true
|
|
3825
|
+
// Deep clone objects
|
|
3826
|
+
});
|
|
3827
|
+
this.decoder = new msgpackr.Decoder({
|
|
3828
|
+
useRecords: false
|
|
3829
|
+
});
|
|
3830
|
+
} catch {
|
|
3831
|
+
throw new Error("msgpackr package is required for MsgpackSerializer. Install with: npm install msgpackr");
|
|
3832
|
+
}
|
|
3833
|
+
}
|
|
3834
|
+
/**
|
|
3835
|
+
* Serializes value to MessagePack buffer.
|
|
3836
|
+
*
|
|
3837
|
+
* @param value - Value to serialize
|
|
3838
|
+
* @returns MessagePack buffer
|
|
3839
|
+
* @throws SerializationError if serialization fails
|
|
3840
|
+
*/
|
|
3841
|
+
serialize(value) {
|
|
3842
|
+
try {
|
|
3843
|
+
return this.encoder.encode(value);
|
|
3844
|
+
} catch (error) {
|
|
3845
|
+
throw new SerializationError(`MessagePack serialization failed: ${error.message}`, error);
|
|
3846
|
+
}
|
|
3847
|
+
}
|
|
3848
|
+
/**
|
|
3849
|
+
* Deserializes MessagePack buffer back to value.
|
|
3850
|
+
*
|
|
3851
|
+
* @param data - MessagePack buffer or string
|
|
3852
|
+
* @returns Deserialized value
|
|
3853
|
+
* @throws SerializationError if deserialization fails
|
|
3854
|
+
*/
|
|
3855
|
+
deserialize(data) {
|
|
3856
|
+
try {
|
|
3857
|
+
const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data, "binary");
|
|
3858
|
+
return this.decoder.decode(buffer);
|
|
3859
|
+
} catch (error) {
|
|
3860
|
+
throw new SerializationError(`MessagePack deserialization failed: ${error.message}`, error);
|
|
3861
|
+
}
|
|
3862
|
+
}
|
|
3863
|
+
/**
|
|
3864
|
+
* Safely tries to deserialize, returns null on error.
|
|
3865
|
+
*
|
|
3866
|
+
* @param data - MessagePack buffer or string
|
|
3867
|
+
* @returns Deserialized value or null
|
|
3868
|
+
*/
|
|
3869
|
+
tryDeserialize(data) {
|
|
3870
|
+
try {
|
|
3871
|
+
return this.deserialize(data);
|
|
3872
|
+
} catch {
|
|
3873
|
+
return null;
|
|
3874
|
+
}
|
|
3875
|
+
}
|
|
3876
|
+
/**
|
|
3877
|
+
* Gets content type for MessagePack.
|
|
3878
|
+
*
|
|
3879
|
+
* @returns Content type string
|
|
3880
|
+
*/
|
|
3881
|
+
getContentType() {
|
|
3882
|
+
return "application/msgpack";
|
|
3883
|
+
}
|
|
3884
|
+
/**
|
|
3885
|
+
* Compares serialized size with JSON.
|
|
3886
|
+
*
|
|
3887
|
+
* @param value - Value to compare
|
|
3888
|
+
* @returns Object with sizes and compression ratio
|
|
3889
|
+
*/
|
|
3890
|
+
compareWithJson(value) {
|
|
3891
|
+
const jsonSize = Buffer.from(JSON.stringify(value), "utf8").length;
|
|
3892
|
+
const msgpackSize = this.serialize(value).length;
|
|
3893
|
+
return {
|
|
3894
|
+
jsonSize,
|
|
3895
|
+
msgpackSize,
|
|
3896
|
+
compressionRatio: jsonSize / msgpackSize
|
|
3897
|
+
};
|
|
3898
|
+
}
|
|
3899
|
+
};
|
|
3900
|
+
|
|
3901
|
+
// src/key-builder.ts
|
|
3902
|
+
var KeyBuilder = class _KeyBuilder {
|
|
3903
|
+
keySegments = [];
|
|
3904
|
+
options;
|
|
3905
|
+
constructor(options = {}) {
|
|
3906
|
+
this.options = {
|
|
3907
|
+
separator: options.separator ?? ":",
|
|
3908
|
+
maxLength: options.maxLength ?? 512,
|
|
3909
|
+
validate: options.validate ?? true,
|
|
3910
|
+
lowercase: options.lowercase ?? false
|
|
3911
|
+
};
|
|
3912
|
+
}
|
|
3913
|
+
/**
|
|
3914
|
+
* Creates a new KeyBuilder instance.
|
|
3915
|
+
*
|
|
3916
|
+
* @param options - Builder options
|
|
3917
|
+
* @returns New KeyBuilder
|
|
3918
|
+
*/
|
|
3919
|
+
static create(options) {
|
|
3920
|
+
return new _KeyBuilder(options);
|
|
3921
|
+
}
|
|
3922
|
+
/**
|
|
3923
|
+
* Creates a key from a template string with placeholders.
|
|
3924
|
+
*
|
|
3925
|
+
* @param template - Template string (e.g., 'user:{id}:post:{postId}')
|
|
3926
|
+
* @param params - Parameter values
|
|
3927
|
+
* @param options - Builder options
|
|
3928
|
+
* @returns Generated key
|
|
3929
|
+
*
|
|
3930
|
+
* @example
|
|
3931
|
+
* ```typescript
|
|
3932
|
+
* KeyBuilder.fromTemplate('user:{id}', { id: '123' })
|
|
3933
|
+
* // 'user:123'
|
|
3934
|
+
*
|
|
3935
|
+
* KeyBuilder.fromTemplate('user:{userId}:post:{postId}', {
|
|
3936
|
+
* userId: '123',
|
|
3937
|
+
* postId: '456',
|
|
3938
|
+
* })
|
|
3939
|
+
* // 'user:123:post:456'
|
|
3940
|
+
* ```
|
|
3941
|
+
*/
|
|
3942
|
+
static fromTemplate(template, params, options) {
|
|
3943
|
+
let result = template;
|
|
3944
|
+
const placeholderRegex = /\{([^}]+)\}/g;
|
|
3945
|
+
const matches = Array.from(template.matchAll(placeholderRegex));
|
|
3946
|
+
for (const match of matches) {
|
|
3947
|
+
const placeholder = match[0];
|
|
3948
|
+
const paramName = match[1];
|
|
3949
|
+
if (!paramName) {
|
|
3950
|
+
continue;
|
|
3951
|
+
}
|
|
3952
|
+
if (!(paramName in params)) {
|
|
3953
|
+
throw new CacheKeyError(template, `Parameter '${paramName}' not found in params`);
|
|
3954
|
+
}
|
|
3955
|
+
const value = params[paramName];
|
|
3956
|
+
if (value === null || value === void 0) {
|
|
3957
|
+
throw new CacheKeyError(template, `Parameter '${paramName}' is null or undefined`);
|
|
3958
|
+
}
|
|
3959
|
+
result = result.replace(placeholder, String(value));
|
|
3960
|
+
}
|
|
3961
|
+
const builder = new _KeyBuilder(options);
|
|
3962
|
+
builder.validateKey(result);
|
|
3963
|
+
return result;
|
|
3964
|
+
}
|
|
3965
|
+
/**
|
|
3966
|
+
* Creates a key from an array of segments.
|
|
3967
|
+
*
|
|
3968
|
+
* @param segments - Array of key segments
|
|
3969
|
+
* @param options - Builder options
|
|
3970
|
+
* @returns Generated key
|
|
3971
|
+
*/
|
|
3972
|
+
static fromSegments(segments, options) {
|
|
3973
|
+
const builder = new _KeyBuilder(options);
|
|
3974
|
+
segments.forEach((segment) => builder.segment(segment));
|
|
3975
|
+
return builder.build();
|
|
3976
|
+
}
|
|
3977
|
+
/**
|
|
3978
|
+
* Adds a namespace segment (first segment).
|
|
3979
|
+
*
|
|
3980
|
+
* @param ns - Namespace value
|
|
3981
|
+
* @returns This builder for chaining
|
|
3982
|
+
*/
|
|
3983
|
+
namespace(ns) {
|
|
3984
|
+
this.keySegments.unshift(this.normalizeSegment(ns));
|
|
3985
|
+
return this;
|
|
3986
|
+
}
|
|
3987
|
+
/**
|
|
3988
|
+
* Adds a prefix segment.
|
|
3989
|
+
*
|
|
3990
|
+
* @param prefix - Prefix value
|
|
3991
|
+
* @returns This builder for chaining
|
|
3992
|
+
*/
|
|
3993
|
+
prefix(prefix) {
|
|
3994
|
+
return this.segment(prefix);
|
|
3995
|
+
}
|
|
3996
|
+
/**
|
|
3997
|
+
* Adds a version segment.
|
|
3998
|
+
*
|
|
3999
|
+
* @param version - Version value (e.g., 'v1', 'v2')
|
|
4000
|
+
* @returns This builder for chaining
|
|
4001
|
+
*/
|
|
4002
|
+
version(version) {
|
|
4003
|
+
return this.segment(version);
|
|
4004
|
+
}
|
|
4005
|
+
/**
|
|
4006
|
+
* Adds a segment to the key.
|
|
4007
|
+
*
|
|
4008
|
+
* @param value - Segment value
|
|
4009
|
+
* @returns This builder for chaining
|
|
4010
|
+
*/
|
|
4011
|
+
segment(value) {
|
|
4012
|
+
this.keySegments.push(this.normalizeSegment(String(value)));
|
|
4013
|
+
return this;
|
|
4014
|
+
}
|
|
4015
|
+
/**
|
|
4016
|
+
* Adds multiple segments at once.
|
|
4017
|
+
*
|
|
4018
|
+
* @param values - Array of segment values
|
|
4019
|
+
* @returns This builder for chaining
|
|
4020
|
+
*/
|
|
4021
|
+
segments(...values) {
|
|
4022
|
+
values.forEach((value) => this.segment(value));
|
|
4023
|
+
return this;
|
|
4024
|
+
}
|
|
4025
|
+
/**
|
|
4026
|
+
* Adds a tag segment (useful for grouping).
|
|
4027
|
+
*
|
|
4028
|
+
* @param tag - Tag value
|
|
4029
|
+
* @returns This builder for chaining
|
|
4030
|
+
*/
|
|
4031
|
+
tag(tag) {
|
|
4032
|
+
this.segment("tag");
|
|
4033
|
+
this.segment(tag);
|
|
4034
|
+
return this;
|
|
4035
|
+
}
|
|
4036
|
+
/**
|
|
4037
|
+
* Adds timestamp segment.
|
|
4038
|
+
*
|
|
4039
|
+
* @param timestamp - Unix timestamp (default: now)
|
|
4040
|
+
* @returns This builder for chaining
|
|
4041
|
+
*/
|
|
4042
|
+
timestamp(timestamp) {
|
|
4043
|
+
return this.segment(timestamp ?? Date.now());
|
|
4044
|
+
}
|
|
4045
|
+
/**
|
|
4046
|
+
* Adds a hash segment from an object.
|
|
4047
|
+
* Useful for cache keys based on complex objects.
|
|
4048
|
+
*
|
|
4049
|
+
* @param obj - Object to hash
|
|
4050
|
+
* @returns This builder for chaining
|
|
4051
|
+
*/
|
|
4052
|
+
hash(obj) {
|
|
4053
|
+
const hash = this.simpleHash(JSON.stringify(obj));
|
|
4054
|
+
return this.segment(hash);
|
|
4055
|
+
}
|
|
4056
|
+
/**
|
|
4057
|
+
* Builds and returns the final key.
|
|
4058
|
+
*
|
|
4059
|
+
* @returns Generated cache key
|
|
4060
|
+
* @throws CacheKeyError if key is invalid
|
|
4061
|
+
*/
|
|
4062
|
+
build() {
|
|
4063
|
+
if (this.keySegments.length === 0) {
|
|
4064
|
+
throw new CacheKeyError("", "Cannot build key with no segments");
|
|
4065
|
+
}
|
|
4066
|
+
const key = this.keySegments.join(this.options.separator);
|
|
4067
|
+
this.validateKey(key);
|
|
4068
|
+
return key;
|
|
4069
|
+
}
|
|
4070
|
+
/**
|
|
4071
|
+
* Resets the builder to initial state.
|
|
4072
|
+
*
|
|
4073
|
+
* @returns This builder for chaining
|
|
4074
|
+
*/
|
|
4075
|
+
reset() {
|
|
4076
|
+
this.keySegments = [];
|
|
4077
|
+
return this;
|
|
4078
|
+
}
|
|
4079
|
+
/**
|
|
4080
|
+
* Gets current segments.
|
|
4081
|
+
*
|
|
4082
|
+
* @returns Array of segments
|
|
4083
|
+
*/
|
|
4084
|
+
getSegments() {
|
|
4085
|
+
return [...this.keySegments];
|
|
4086
|
+
}
|
|
4087
|
+
/**
|
|
4088
|
+
* Normalizes a segment value.
|
|
4089
|
+
*
|
|
4090
|
+
* @param value - Segment value
|
|
4091
|
+
* @returns Normalized value
|
|
4092
|
+
*/
|
|
4093
|
+
normalizeSegment(value) {
|
|
4094
|
+
let normalized = value.trim();
|
|
4095
|
+
if (this.options.lowercase) {
|
|
4096
|
+
normalized = normalized.toLowerCase();
|
|
4097
|
+
}
|
|
4098
|
+
if (this.options.validate) {
|
|
4099
|
+
const invalidChars = [this.options.separator, "{", "}", " ", "\n", "\r", " "];
|
|
4100
|
+
for (const char of invalidChars) {
|
|
4101
|
+
if (normalized.includes(char)) {
|
|
4102
|
+
throw new CacheKeyError(normalized, `Segment contains invalid character: '${char}'`);
|
|
4103
|
+
}
|
|
4104
|
+
}
|
|
4105
|
+
if (normalized.length === 0) {
|
|
4106
|
+
throw new CacheKeyError(normalized, "Segment cannot be empty");
|
|
4107
|
+
}
|
|
4108
|
+
}
|
|
4109
|
+
return normalized;
|
|
4110
|
+
}
|
|
4111
|
+
/**
|
|
4112
|
+
* Validates a complete key.
|
|
4113
|
+
*
|
|
4114
|
+
* @param key - Key to validate
|
|
4115
|
+
* @throws CacheKeyError if key is invalid
|
|
4116
|
+
*/
|
|
4117
|
+
validateKey(key) {
|
|
4118
|
+
if (!this.options.validate) {
|
|
4119
|
+
return;
|
|
4120
|
+
}
|
|
4121
|
+
if (key.length === 0) {
|
|
4122
|
+
throw new CacheKeyError(key, "Key cannot be empty");
|
|
4123
|
+
}
|
|
4124
|
+
if (key.length > this.options.maxLength) {
|
|
4125
|
+
throw new CacheKeyError(key, `Key length ${key.length} exceeds maximum ${this.options.maxLength}`);
|
|
4126
|
+
}
|
|
4127
|
+
if (key.startsWith(this.options.separator) || key.endsWith(this.options.separator)) {
|
|
4128
|
+
throw new CacheKeyError(key, "Key cannot start or end with separator");
|
|
4129
|
+
}
|
|
4130
|
+
const consecutiveSeparators = this.options.separator + this.options.separator;
|
|
4131
|
+
if (key.includes(consecutiveSeparators)) {
|
|
4132
|
+
throw new CacheKeyError(key, "Key cannot contain consecutive separators");
|
|
4133
|
+
}
|
|
4134
|
+
}
|
|
4135
|
+
/**
|
|
4136
|
+
* Simple hash function for objects.
|
|
4137
|
+
*
|
|
4138
|
+
* @param str - String to hash
|
|
4139
|
+
* @returns Hash string
|
|
4140
|
+
*/
|
|
4141
|
+
simpleHash(str) {
|
|
4142
|
+
let hash = 0;
|
|
4143
|
+
for (let i = 0; i < str.length; i++) {
|
|
4144
|
+
const char = str.charCodeAt(i);
|
|
4145
|
+
hash = (hash << 5) - hash + char;
|
|
4146
|
+
hash = hash & hash;
|
|
4147
|
+
}
|
|
4148
|
+
return Math.abs(hash).toString(36);
|
|
4149
|
+
}
|
|
4150
|
+
};
|
|
4151
|
+
|
|
4152
|
+
exports.AMQP_CONNECTION = AMQP_CONNECTION;
|
|
4153
|
+
exports.CACHE_OPTIONS_KEY = CACHE_OPTIONS_KEY;
|
|
4154
|
+
exports.CACHE_PLUGIN_OPTIONS = CACHE_PLUGIN_OPTIONS;
|
|
4155
|
+
exports.CACHE_SERVICE = CACHE_SERVICE;
|
|
4156
|
+
exports.CacheEntry = CacheEntry;
|
|
4157
|
+
exports.CacheError = CacheError;
|
|
4158
|
+
exports.CacheEvict = CacheEvict;
|
|
4159
|
+
exports.CacheKey = CacheKey;
|
|
4160
|
+
exports.CacheKeyError = CacheKeyError;
|
|
4161
|
+
exports.CachePlugin = CachePlugin;
|
|
4162
|
+
exports.CachePut = CachePut;
|
|
4163
|
+
exports.Cacheable = Cacheable;
|
|
4164
|
+
exports.Cached = Cached;
|
|
4165
|
+
exports.DEFAULT_CACHE_CONFIG = DEFAULT_CACHE_CONFIG;
|
|
4166
|
+
exports.EVENT_INVALIDATION_SERVICE = EVENT_INVALIDATION_SERVICE;
|
|
4167
|
+
exports.EventPattern = EventPattern;
|
|
4168
|
+
exports.FifoStrategy = FifoStrategy;
|
|
4169
|
+
exports.INVALIDATE_TAGS_KEY = INVALIDATE_TAGS_KEY;
|
|
4170
|
+
exports.INVALIDATION_REGISTRY = INVALIDATION_REGISTRY;
|
|
4171
|
+
exports.InvalidateOn = InvalidateOn;
|
|
4172
|
+
exports.InvalidateTags = InvalidateTags;
|
|
4173
|
+
exports.InvalidationRule = InvalidationRule;
|
|
4174
|
+
exports.JsonSerializer = JsonSerializer;
|
|
4175
|
+
exports.KeyBuilder = KeyBuilder;
|
|
4176
|
+
exports.L1_CACHE_STORE = L1_CACHE_STORE;
|
|
4177
|
+
exports.L2_CACHE_STORE = L2_CACHE_STORE;
|
|
4178
|
+
exports.LUA_SCRIPT_LOADER = LUA_SCRIPT_LOADER;
|
|
4179
|
+
exports.LfuStrategy = LfuStrategy;
|
|
4180
|
+
exports.LoaderError = LoaderError;
|
|
4181
|
+
exports.LruStrategy = LruStrategy;
|
|
4182
|
+
exports.MsgpackSerializer = MsgpackSerializer;
|
|
4183
|
+
exports.SERIALIZER = SERIALIZER;
|
|
4184
|
+
exports.STAMPEDE_PROTECTION = STAMPEDE_PROTECTION;
|
|
4185
|
+
exports.SWR_MANAGER = SWR_MANAGER;
|
|
4186
|
+
exports.SerializationError = SerializationError;
|
|
4187
|
+
exports.StampedeError = StampedeError;
|
|
4188
|
+
exports.TAG_INDEX = TAG_INDEX;
|
|
4189
|
+
exports.TTL = TTL;
|
|
4190
|
+
exports.TagInvalidationError = TagInvalidationError;
|
|
4191
|
+
exports.TagTemplate = TagTemplate;
|
|
4192
|
+
exports.evaluateCondition = evaluateCondition;
|
|
4193
|
+
exports.evaluateTags = evaluateTags;
|
|
4194
|
+
exports.generateKey = generateKey;
|
|
4195
|
+
exports.generateKeys = generateKeys;
|
|
4196
|
+
exports.getNestedValue = getNestedValue;
|
|
4197
|
+
exports.getParameterNames = getParameterNames;
|
|
4198
|
+
//# sourceMappingURL=index.js.map
|
|
4199
|
+
//# sourceMappingURL=index.js.map
|