lumisjs 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 +274 -0
- package/SECURITY.md +160 -0
- package/index.js +90 -0
- package/package.json +68 -0
- package/src/cache/CacheManager.js +393 -0
- package/src/cache/MemoryAdapter.js +259 -0
- package/src/cache/MultiLevelCache.js +362 -0
- package/src/cache/RedisAdapter.js +329 -0
- package/src/cache/SQLiteAdapter.js +280 -0
- package/src/cache/index.js +15 -0
- package/src/client/Client.js +554 -0
- package/src/client/ShardingManager.js +274 -0
- package/src/config/ConfigValidator.js +172 -0
- package/src/config/index.js +7 -0
- package/src/dashboard/DashboardManager.js +362 -0
- package/src/datagen/DataGenerator.js +47 -0
- package/src/datagen/apis/GraphqlMocker.js +16 -0
- package/src/datagen/apis/OpenApiMocker.js +18 -0
- package/src/datagen/cli.js +130 -0
- package/src/datagen/formatters/csv.js +45 -0
- package/src/datagen/formatters/index.js +15 -0
- package/src/datagen/formatters/json.js +33 -0
- package/src/datagen/formatters/mongo.js +22 -0
- package/src/datagen/formatters/sql.js +32 -0
- package/src/datagen/index.js +17 -0
- package/src/datagen/plugin/PluginManager.js +43 -0
- package/src/datagen/plugins/example-plugin.js +21 -0
- package/src/datagen/schema/SchemaGenerator.js +172 -0
- package/src/datagen/schema/loadSchema.js +14 -0
- package/src/datagen/utils/seed.js +26 -0
- package/src/di/ServiceContainer.js +229 -0
- package/src/errors/ErrorCodes.js +110 -0
- package/src/errors/LumisError.js +85 -0
- package/src/game/EconomyManager.js +161 -0
- package/src/game/GameSessionManager.js +76 -0
- package/src/game/GuildManager.js +197 -0
- package/src/game/InventoryManager.js +140 -0
- package/src/game/LevelingSystem.js +194 -0
- package/src/game/MusicManager.js +587 -0
- package/src/health/HealthChecker.js +170 -0
- package/src/health/index.js +7 -0
- package/src/performance/PerformanceMonitor.js +244 -0
- package/src/performance/index.js +7 -0
- package/src/security/InputSanitizer.js +185 -0
- package/src/security/SecurityMiddleware.js +231 -0
- package/src/security/index.js +9 -0
- package/src/shutdown/GracefulShutdown.js +159 -0
- package/src/shutdown/index.js +7 -0
- package/src/utils/StructuredLogger.js +118 -0
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { EventEmitter } = require('node:events');
|
|
4
|
+
const Logger = require('../utils/Logger');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Multi-Level Cache (MLC)
|
|
8
|
+
* Implements a tiered caching strategy with L1 (memory), L2 (Redis), and L3 (persistent storage).
|
|
9
|
+
* Automatically promotes/demotes data between levels based on access patterns.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
class MultiLevelCache extends EventEmitter {
|
|
13
|
+
/**
|
|
14
|
+
* @param {object} options
|
|
15
|
+
* @param {object} options.l1 - Memory cache options
|
|
16
|
+
* @param {object} options.l2 - Redis cache options
|
|
17
|
+
* @param {object} options.l3 - Persistent storage options
|
|
18
|
+
* @param {number} [options.promoteThreshold=3] - Access count to promote to L1
|
|
19
|
+
* @param {number} [options.demoteThreshold=10] - Time without access to demote from L1 (seconds)
|
|
20
|
+
*/
|
|
21
|
+
constructor(options = {}) {
|
|
22
|
+
super();
|
|
23
|
+
|
|
24
|
+
this.options = {
|
|
25
|
+
promoteThreshold: options.promoteThreshold ?? 3,
|
|
26
|
+
demoteThreshold: options.demoteThreshold ?? 10,
|
|
27
|
+
...options,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
this.logger = new Logger({ prefix: 'MLCache', level: 'info' });
|
|
31
|
+
|
|
32
|
+
// Access tracking
|
|
33
|
+
this._accessCounts = new Map();
|
|
34
|
+
this._lastAccess = new Map();
|
|
35
|
+
|
|
36
|
+
// Initialize cache levels
|
|
37
|
+
this._initL1(options.l1);
|
|
38
|
+
this._initL2(options.l2);
|
|
39
|
+
this._initL3(options.l3);
|
|
40
|
+
|
|
41
|
+
// Start cleanup interval
|
|
42
|
+
this._cleanupInterval = setInterval(() => this._cleanup(), 60000);
|
|
43
|
+
if (this._cleanupInterval.unref) this._cleanupInterval.unref();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
_initL1(options) {
|
|
47
|
+
const MemoryAdapter = require('./MemoryAdapter');
|
|
48
|
+
this.l1 = new MemoryAdapter({
|
|
49
|
+
maxSize: options?.maxSize ?? 1000,
|
|
50
|
+
ttl: options?.ttl ?? 300,
|
|
51
|
+
cleanupIntervalSec: 60,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
_initL2(options) {
|
|
56
|
+
if (!options) {
|
|
57
|
+
this.l2 = null;
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const RedisAdapter = require('./RedisAdapter');
|
|
63
|
+
this.l2 = new RedisAdapter({
|
|
64
|
+
...options,
|
|
65
|
+
enablePubSub: true,
|
|
66
|
+
});
|
|
67
|
+
this.logger.info('L2 cache (Redis) initialized');
|
|
68
|
+
} catch (error) {
|
|
69
|
+
this.logger.warn('L2 cache initialization failed:', error.message);
|
|
70
|
+
this.l2 = null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
_initL3(options) {
|
|
75
|
+
if (!options) {
|
|
76
|
+
this.l3 = null;
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const SQLiteAdapter = require('./SQLiteAdapter');
|
|
82
|
+
this.l3 = new SQLiteAdapter({
|
|
83
|
+
...options,
|
|
84
|
+
});
|
|
85
|
+
this.logger.info('L3 cache (SQLite) initialized');
|
|
86
|
+
} catch (error) {
|
|
87
|
+
this.logger.warn('L3 cache initialization failed:', error.message);
|
|
88
|
+
this.l3 = null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Get a value from the cache, checking all levels.
|
|
94
|
+
* @param {string} key
|
|
95
|
+
* @returns {Promise<any>}
|
|
96
|
+
*/
|
|
97
|
+
async get(key) {
|
|
98
|
+
this._trackAccess(key);
|
|
99
|
+
|
|
100
|
+
// Check L1 (fastest)
|
|
101
|
+
const l1Value = await this.l1.get(key);
|
|
102
|
+
if (l1Value !== undefined) {
|
|
103
|
+
this.emit('hit', { level: 'L1', key });
|
|
104
|
+
return l1Value;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Check L2
|
|
108
|
+
if (this.l2) {
|
|
109
|
+
const l2Value = await this.l2.get(key);
|
|
110
|
+
if (l2Value !== undefined) {
|
|
111
|
+
this.emit('hit', { level: 'L2', key });
|
|
112
|
+
// Promote to L1 if accessed frequently
|
|
113
|
+
await this._maybePromote(key, l2Value);
|
|
114
|
+
return l2Value;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Check L3
|
|
119
|
+
if (this.l3) {
|
|
120
|
+
const l3Value = await this.l3.get(key);
|
|
121
|
+
if (l3Value !== undefined) {
|
|
122
|
+
this.emit('hit', { level: 'L3', key });
|
|
123
|
+
// Promote to L2 and L1
|
|
124
|
+
if (this.l2) await this.l2.set(key, l3Value, this.options.l2?.ttl);
|
|
125
|
+
await this._maybePromote(key, l3Value);
|
|
126
|
+
return l3Value;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
this.emit('miss', { key });
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Set a value in all cache levels.
|
|
136
|
+
* @param {string} key
|
|
137
|
+
* @param {any} value
|
|
138
|
+
* @param {number} [ttl]
|
|
139
|
+
* @returns {Promise<void>}
|
|
140
|
+
*/
|
|
141
|
+
async set(key, value, ttl) {
|
|
142
|
+
this._trackAccess(key);
|
|
143
|
+
|
|
144
|
+
// Set in L1
|
|
145
|
+
await this.l1.set(key, value, ttl ?? this.options.l1?.ttl);
|
|
146
|
+
|
|
147
|
+
// Set in L2 if available
|
|
148
|
+
if (this.l2) {
|
|
149
|
+
await this.l2.set(key, value, ttl ?? this.options.l2?.ttl);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Set in L3 if available
|
|
153
|
+
if (this.l3) {
|
|
154
|
+
await this.l3.set(key, value, ttl ?? this.options.l3?.ttl);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
this.emit('set', { key, ttl });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Delete a value from all cache levels.
|
|
162
|
+
* @param {string} key
|
|
163
|
+
* @returns {Promise<boolean>}
|
|
164
|
+
*/
|
|
165
|
+
async delete(key) {
|
|
166
|
+
this._accessCounts.delete(key);
|
|
167
|
+
this._lastAccess.delete(key);
|
|
168
|
+
|
|
169
|
+
const results = await Promise.all([
|
|
170
|
+
this.l1.delete(key),
|
|
171
|
+
this.l2 ? this.l2.delete(key) : Promise.resolve(false),
|
|
172
|
+
this.l3 ? this.l3.delete(key) : Promise.resolve(false),
|
|
173
|
+
]);
|
|
174
|
+
|
|
175
|
+
this.emit('delete', { key, existed: results.some(r => r) });
|
|
176
|
+
return results.some(r => r);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Clear all cache levels.
|
|
181
|
+
* @returns {Promise<void>}
|
|
182
|
+
*/
|
|
183
|
+
async clear() {
|
|
184
|
+
this._accessCounts.clear();
|
|
185
|
+
this._lastAccess.clear();
|
|
186
|
+
|
|
187
|
+
await Promise.all([
|
|
188
|
+
this.l1.clear(),
|
|
189
|
+
this.l2 ? this.l2.clear() : Promise.resolve(),
|
|
190
|
+
this.l3 ? this.l3.clear() : Promise.resolve(),
|
|
191
|
+
]);
|
|
192
|
+
|
|
193
|
+
this.emit('clear');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Get cache statistics for all levels.
|
|
198
|
+
* @returns {Promise<object>}
|
|
199
|
+
*/
|
|
200
|
+
async getStats() {
|
|
201
|
+
const stats = {
|
|
202
|
+
levels: {},
|
|
203
|
+
accessTracking: {
|
|
204
|
+
totalKeys: this._accessCounts.size,
|
|
205
|
+
totalAccesses: Array.from(this._accessCounts.values()).reduce((a, b) => a + b, 0),
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
stats.levels.L1 = {
|
|
210
|
+
size: await this.l1.size(),
|
|
211
|
+
adapter: 'Memory',
|
|
212
|
+
stats: this.l1.getStats?.() || null,
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
if (this.l2) {
|
|
216
|
+
stats.levels.L2 = {
|
|
217
|
+
size: await this.l2.size(),
|
|
218
|
+
adapter: 'Redis',
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (this.l3) {
|
|
223
|
+
stats.levels.L3 = {
|
|
224
|
+
size: await this.l3.size(),
|
|
225
|
+
adapter: 'SQLite',
|
|
226
|
+
stats: await this.l3.getStats?.() || null,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return stats;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Warm up the cache with data.
|
|
235
|
+
* @param {Map<string, any>} data
|
|
236
|
+
* @param {number} [ttl]
|
|
237
|
+
* @returns {Promise<void>}
|
|
238
|
+
*/
|
|
239
|
+
async warmup(data, ttl) {
|
|
240
|
+
const entries = Array.from(data.entries()).map(([key, value]) => [key, value, ttl]);
|
|
241
|
+
|
|
242
|
+
// Load into L3 first (persistent)
|
|
243
|
+
if (this.l3) {
|
|
244
|
+
await this.l3.setMany(entries);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Then L2
|
|
248
|
+
if (this.l2) {
|
|
249
|
+
await this.l2.setMany(entries);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Finally L1 (memory)
|
|
253
|
+
await this.l1.setMany(entries);
|
|
254
|
+
|
|
255
|
+
this.emit('warmup', { count: entries.length });
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Invalidate keys matching a pattern across all levels.
|
|
260
|
+
* @param {string} pattern
|
|
261
|
+
* @returns {Promise<number>}
|
|
262
|
+
*/
|
|
263
|
+
async invalidatePattern(pattern) {
|
|
264
|
+
let totalDeleted = 0;
|
|
265
|
+
|
|
266
|
+
// Invalidate in L1
|
|
267
|
+
const l1Keys = await this.l1.keys();
|
|
268
|
+
const regex = new RegExp('^' + pattern.replace(/\*/g, '.*').replace(/\?/g, '.') + '$');
|
|
269
|
+
const l1ToDelete = l1Keys.filter(key => regex.test(key));
|
|
270
|
+
totalDeleted += l1ToDelete.length;
|
|
271
|
+
for (const key of l1ToDelete) {
|
|
272
|
+
await this.l1.delete(key);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Invalidate in L2
|
|
276
|
+
if (this.l2) {
|
|
277
|
+
const l2Keys = await this.l2.scan(pattern);
|
|
278
|
+
totalDeleted += l2Keys.length;
|
|
279
|
+
for (const key of l2Keys) {
|
|
280
|
+
await this.l2.delete(key);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Invalidate in L3
|
|
285
|
+
if (this.l3) {
|
|
286
|
+
const l3Keys = await this.l3.keys();
|
|
287
|
+
const l3ToDelete = l3Keys.filter(key => regex.test(key));
|
|
288
|
+
totalDeleted += l3ToDelete.length;
|
|
289
|
+
for (const key of l3ToDelete) {
|
|
290
|
+
await this.l3.delete(key);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
this.emit('invalidatePattern', { pattern, count: totalDeleted });
|
|
295
|
+
return totalDeleted;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** @private */
|
|
299
|
+
_trackAccess(key) {
|
|
300
|
+
const count = (this._accessCounts.get(key) || 0) + 1;
|
|
301
|
+
this._accessCounts.set(key, count);
|
|
302
|
+
this._lastAccess.set(key, Date.now());
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** @private */
|
|
306
|
+
async _maybePromote(key, value) {
|
|
307
|
+
const count = this._accessCounts.get(key) || 0;
|
|
308
|
+
|
|
309
|
+
if (count >= this.options.promoteThreshold) {
|
|
310
|
+
// Promote to L1
|
|
311
|
+
await this.l1.set(key, value, this.options.l1?.ttl);
|
|
312
|
+
this.emit('promote', { key, from: 'L2/L3', to: 'L1' });
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** @private */
|
|
317
|
+
async _cleanup() {
|
|
318
|
+
const now = Date.now();
|
|
319
|
+
const demoteThresholdMs = this.options.demoteThreshold * 1000;
|
|
320
|
+
|
|
321
|
+
for (const [key, lastAccess] of this._lastAccess) {
|
|
322
|
+
if (now - lastAccess > demoteThresholdMs) {
|
|
323
|
+
// Demote from L1
|
|
324
|
+
const value = await this.l1.peek(key);
|
|
325
|
+
if (value !== undefined) {
|
|
326
|
+
if (this.l2) {
|
|
327
|
+
await this.l2.set(key, value, this.options.l2?.ttl);
|
|
328
|
+
}
|
|
329
|
+
await this.l1.delete(key);
|
|
330
|
+
this.emit('demote', { key, from: 'L1', to: 'L2/L3' });
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
this._lastAccess.delete(key);
|
|
334
|
+
this._accessCounts.delete(key);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Destroy all cache levels.
|
|
341
|
+
* @returns {Promise<void>}
|
|
342
|
+
*/
|
|
343
|
+
async destroy() {
|
|
344
|
+
if (this._cleanupInterval) {
|
|
345
|
+
clearInterval(this._cleanupInterval);
|
|
346
|
+
this._cleanupInterval = null;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
this._accessCounts.clear();
|
|
350
|
+
this._lastAccess.clear();
|
|
351
|
+
|
|
352
|
+
await Promise.all([
|
|
353
|
+
this.l1.destroy(),
|
|
354
|
+
this.l2 ? this.l2.destroy() : Promise.resolve(),
|
|
355
|
+
this.l3 ? this.l3.destroy() : Promise.resolve(),
|
|
356
|
+
]);
|
|
357
|
+
|
|
358
|
+
this.removeAllListeners();
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
module.exports = MultiLevelCache;
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { EventEmitter } = require('node:events');
|
|
4
|
+
const Logger = require('../utils/Logger');
|
|
5
|
+
|
|
6
|
+
class RedisAdapter extends EventEmitter {
|
|
7
|
+
|
|
8
|
+
constructor(options = {}) {
|
|
9
|
+
super();
|
|
10
|
+
this.prefix = options.prefix || 'lumis:';
|
|
11
|
+
this.defaultTTL = options.ttl || 0;
|
|
12
|
+
this.logger = new Logger({ prefix: 'RedisCache', level: 'info' });
|
|
13
|
+
this.enablePubSub = options.enablePubSub !== false;
|
|
14
|
+
this.pubSubChannel = options.pubSubChannel || 'lumis:cache:invalidations';
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
const Redis = require('ioredis');
|
|
18
|
+
this.client = new Redis(options.redis || {});
|
|
19
|
+
this.logger.info('Redis connection established.');
|
|
20
|
+
|
|
21
|
+
// Setup pub/sub for distributed cache invalidation
|
|
22
|
+
if (this.enablePubSub) {
|
|
23
|
+
this.subscriber = new Redis(options.redis || {});
|
|
24
|
+
this._setupPubSub();
|
|
25
|
+
}
|
|
26
|
+
} catch (error) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
'Redis adapter requires "ioredis" package. Installation: npm install ioredis'
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
_key(key) {
|
|
34
|
+
return `${this.prefix}${key}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async get(key) {
|
|
38
|
+
const data = await this.client.get(this._key(key));
|
|
39
|
+
if (data === null) return undefined;
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(data);
|
|
42
|
+
} catch {
|
|
43
|
+
return data;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async set(key, value, ttl) {
|
|
48
|
+
const effectiveTTL = ttl ?? this.defaultTTL;
|
|
49
|
+
const serialized = JSON.stringify(value);
|
|
50
|
+
|
|
51
|
+
if (effectiveTTL > 0) {
|
|
52
|
+
await this.client.setex(this._key(key), effectiveTTL, serialized);
|
|
53
|
+
} else {
|
|
54
|
+
await this.client.set(this._key(key), serialized);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async delete(key) {
|
|
59
|
+
const result = await this.client.del(this._key(key));
|
|
60
|
+
|
|
61
|
+
// Publish invalidation event
|
|
62
|
+
if (this.enablePubSub && result > 0) {
|
|
63
|
+
await this._publishInvalidation(key);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return result > 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async has(key) {
|
|
70
|
+
const exists = await this.client.exists(this._key(key));
|
|
71
|
+
return exists === 1;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async clear() {
|
|
75
|
+
const keys = await this.client.keys(`${this.prefix}*`);
|
|
76
|
+
if (keys.length > 0) {
|
|
77
|
+
await this.client.del(...keys);
|
|
78
|
+
|
|
79
|
+
// Publish clear event
|
|
80
|
+
if (this.enablePubSub) {
|
|
81
|
+
await this._publishInvalidation('*');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async size() {
|
|
87
|
+
const keys = await this.client.keys(`${this.prefix}*`);
|
|
88
|
+
return keys.length;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async values() {
|
|
92
|
+
const keys = await this.client.keys(`${this.prefix}*`);
|
|
93
|
+
if (keys.length === 0) return [];
|
|
94
|
+
const values = await this.client.mget(...keys);
|
|
95
|
+
return values.map((v) => {
|
|
96
|
+
try { return JSON.parse(v); } catch { return v; }
|
|
97
|
+
}).filter((v) => v !== null);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async keys() {
|
|
101
|
+
const keys = await this.client.keys(`${this.prefix}*`);
|
|
102
|
+
return keys.map((k) => k.slice(this.prefix.length));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async filter(fn) {
|
|
106
|
+
const allKeys = await this.keys();
|
|
107
|
+
const results = [];
|
|
108
|
+
for (const key of allKeys) {
|
|
109
|
+
const value = await this.get(key);
|
|
110
|
+
if (value !== undefined && fn(value, key)) {
|
|
111
|
+
results.push(value);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return results;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async destroy() {
|
|
118
|
+
if (this.subscriber) {
|
|
119
|
+
await this.subscriber.quit();
|
|
120
|
+
}
|
|
121
|
+
if (this.client) {
|
|
122
|
+
await this.client.quit();
|
|
123
|
+
this.logger.info('Redis connection closed.');
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── Pub/Sub for Distributed Cache ─────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
_setupPubSub() {
|
|
130
|
+
this.subscriber.subscribe(this.pubSubChannel, (err) => {
|
|
131
|
+
if (err) {
|
|
132
|
+
this.logger.error('Failed to subscribe to pub/sub channel:', err);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
this.logger.info('Subscribed to cache invalidation channel');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
this.subscriber.on('message', (channel, message) => {
|
|
139
|
+
if (channel === this.pubSubChannel) {
|
|
140
|
+
try {
|
|
141
|
+
const data = JSON.parse(message);
|
|
142
|
+
this.emit('invalidation', data);
|
|
143
|
+
} catch (err) {
|
|
144
|
+
this.logger.error('Failed to parse invalidation message:', err);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async _publishInvalidation(key) {
|
|
151
|
+
try {
|
|
152
|
+
const message = JSON.stringify({
|
|
153
|
+
key,
|
|
154
|
+
timestamp: Date.now(),
|
|
155
|
+
type: key === '*' ? 'clear' : 'delete',
|
|
156
|
+
});
|
|
157
|
+
await this.client.publish(this.pubSubChannel, message);
|
|
158
|
+
} catch (err) {
|
|
159
|
+
this.logger.error('Failed to publish invalidation:', err);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── Advanced Redis Features ────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Get multiple keys using MGET for better performance.
|
|
167
|
+
* @param {string[]} keys
|
|
168
|
+
* @returns {Promise<Map<string, any>>}
|
|
169
|
+
*/
|
|
170
|
+
async getMany(keys) {
|
|
171
|
+
if (keys.length === 0) return new Map();
|
|
172
|
+
|
|
173
|
+
const redisKeys = keys.map(k => this._key(k));
|
|
174
|
+
const values = await this.client.mget(...redisKeys);
|
|
175
|
+
|
|
176
|
+
const result = new Map();
|
|
177
|
+
keys.forEach((key, i) => {
|
|
178
|
+
const value = values[i];
|
|
179
|
+
if (value !== null) {
|
|
180
|
+
try {
|
|
181
|
+
result.set(key, JSON.parse(value));
|
|
182
|
+
} catch {
|
|
183
|
+
result.set(key, value);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
return result;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Set multiple keys using MSET for better performance.
|
|
193
|
+
* @param {Array<[string, any, number?]>} entries
|
|
194
|
+
* @returns {Promise<void>}
|
|
195
|
+
*/
|
|
196
|
+
async setMany(entries) {
|
|
197
|
+
if (entries.length === 0) return;
|
|
198
|
+
|
|
199
|
+
const pipeline = this.client.pipeline();
|
|
200
|
+
|
|
201
|
+
for (const [key, value, ttl] of entries) {
|
|
202
|
+
const serialized = JSON.stringify(value);
|
|
203
|
+
const effectiveTTL = ttl ?? this.defaultTTL;
|
|
204
|
+
|
|
205
|
+
if (effectiveTTL > 0) {
|
|
206
|
+
pipeline.setex(this._key(key), effectiveTTL, serialized);
|
|
207
|
+
} else {
|
|
208
|
+
pipeline.set(this._key(key), serialized);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
await pipeline.exec();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Increment a numeric value.
|
|
217
|
+
* @param {string} key
|
|
218
|
+
* @param {number} [amount=1]
|
|
219
|
+
* @returns {Promise<number>}
|
|
220
|
+
*/
|
|
221
|
+
async increment(key, amount = 1) {
|
|
222
|
+
const redisKey = this._key(key);
|
|
223
|
+
if (amount === 1) {
|
|
224
|
+
return this.client.incr(redisKey);
|
|
225
|
+
}
|
|
226
|
+
return this.client.incrby(redisKey, amount);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Decrement a numeric value.
|
|
231
|
+
* @param {string} key
|
|
232
|
+
* @param {number} [amount=1]
|
|
233
|
+
* @returns {Promise<number>}
|
|
234
|
+
*/
|
|
235
|
+
async decrement(key, amount = 1) {
|
|
236
|
+
const redisKey = this._key(key);
|
|
237
|
+
if (amount === 1) {
|
|
238
|
+
return this.client.decr(redisKey);
|
|
239
|
+
}
|
|
240
|
+
return this.client.decrby(redisKey, amount);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Get TTL of a key.
|
|
245
|
+
* @param {string} key
|
|
246
|
+
* @returns {Promise<number>} TTL in seconds, -1 if no expiry, -2 if key doesn't exist
|
|
247
|
+
*/
|
|
248
|
+
async getTTL(key) {
|
|
249
|
+
return this.client.ttl(this._key(key));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Set TTL on existing key.
|
|
254
|
+
* @param {string} key
|
|
255
|
+
* @param {number} ttl TTL in seconds
|
|
256
|
+
* @returns {Promise<boolean>}
|
|
257
|
+
*/
|
|
258
|
+
async setTTL(key, ttl) {
|
|
259
|
+
const result = await this.client.expire(this._key(key), ttl);
|
|
260
|
+
return result === 1;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Check if key exists and set if not (SETNX).
|
|
265
|
+
* @param {string} key
|
|
266
|
+
* @param {any} value
|
|
267
|
+
* @param {number} [ttl]
|
|
268
|
+
* @returns {Promise<boolean>} True if key was set, false if it already existed
|
|
269
|
+
*/
|
|
270
|
+
async setIfNotExists(key, value, ttl) {
|
|
271
|
+
const redisKey = this._key(key);
|
|
272
|
+
const serialized = JSON.stringify(value);
|
|
273
|
+
|
|
274
|
+
const result = await this.client.setnx(redisKey, serialized);
|
|
275
|
+
|
|
276
|
+
if (result === 1 && ttl && ttl > 0) {
|
|
277
|
+
await this.client.expire(redisKey, ttl);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return result === 1;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Get and set atomically.
|
|
285
|
+
* @param {string} key
|
|
286
|
+
* @param {any} value
|
|
287
|
+
* @param {number} [ttl]
|
|
288
|
+
* @returns {Promise<any>} Old value
|
|
289
|
+
*/
|
|
290
|
+
async getAndSet(key, value, ttl) {
|
|
291
|
+
const redisKey = this._key(key);
|
|
292
|
+
const serialized = JSON.stringify(value);
|
|
293
|
+
|
|
294
|
+
const oldValue = await this.client.getset(redisKey, serialized);
|
|
295
|
+
|
|
296
|
+
if (ttl && ttl > 0) {
|
|
297
|
+
await this.client.expire(redisKey, ttl);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (oldValue === null) return undefined;
|
|
301
|
+
|
|
302
|
+
try {
|
|
303
|
+
return JSON.parse(oldValue);
|
|
304
|
+
} catch {
|
|
305
|
+
return oldValue;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Scan keys with pattern (more efficient than KEYS for large datasets).
|
|
311
|
+
* @param {string} [pattern='*']
|
|
312
|
+
* @param {number} [count=100]
|
|
313
|
+
* @returns {Promise<string[]>}
|
|
314
|
+
*/
|
|
315
|
+
async scan(pattern = '*', count = 100) {
|
|
316
|
+
const keys = [];
|
|
317
|
+
let cursor = '0';
|
|
318
|
+
|
|
319
|
+
do {
|
|
320
|
+
const result = await this.client.scan(cursor, 'MATCH', `${this.prefix}${pattern}`, 'COUNT', count);
|
|
321
|
+
cursor = result[0];
|
|
322
|
+
keys.push(...result[1].map(k => k.slice(this.prefix.length)));
|
|
323
|
+
} while (cursor !== '0');
|
|
324
|
+
|
|
325
|
+
return keys;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
module.exports = RedisAdapter;
|