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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +274 -0
  3. package/SECURITY.md +160 -0
  4. package/index.js +90 -0
  5. package/package.json +68 -0
  6. package/src/cache/CacheManager.js +393 -0
  7. package/src/cache/MemoryAdapter.js +259 -0
  8. package/src/cache/MultiLevelCache.js +362 -0
  9. package/src/cache/RedisAdapter.js +329 -0
  10. package/src/cache/SQLiteAdapter.js +280 -0
  11. package/src/cache/index.js +15 -0
  12. package/src/client/Client.js +554 -0
  13. package/src/client/ShardingManager.js +274 -0
  14. package/src/config/ConfigValidator.js +172 -0
  15. package/src/config/index.js +7 -0
  16. package/src/dashboard/DashboardManager.js +362 -0
  17. package/src/datagen/DataGenerator.js +47 -0
  18. package/src/datagen/apis/GraphqlMocker.js +16 -0
  19. package/src/datagen/apis/OpenApiMocker.js +18 -0
  20. package/src/datagen/cli.js +130 -0
  21. package/src/datagen/formatters/csv.js +45 -0
  22. package/src/datagen/formatters/index.js +15 -0
  23. package/src/datagen/formatters/json.js +33 -0
  24. package/src/datagen/formatters/mongo.js +22 -0
  25. package/src/datagen/formatters/sql.js +32 -0
  26. package/src/datagen/index.js +17 -0
  27. package/src/datagen/plugin/PluginManager.js +43 -0
  28. package/src/datagen/plugins/example-plugin.js +21 -0
  29. package/src/datagen/schema/SchemaGenerator.js +172 -0
  30. package/src/datagen/schema/loadSchema.js +14 -0
  31. package/src/datagen/utils/seed.js +26 -0
  32. package/src/di/ServiceContainer.js +229 -0
  33. package/src/errors/ErrorCodes.js +110 -0
  34. package/src/errors/LumisError.js +85 -0
  35. package/src/game/EconomyManager.js +161 -0
  36. package/src/game/GameSessionManager.js +76 -0
  37. package/src/game/GuildManager.js +197 -0
  38. package/src/game/InventoryManager.js +140 -0
  39. package/src/game/LevelingSystem.js +194 -0
  40. package/src/game/MusicManager.js +587 -0
  41. package/src/health/HealthChecker.js +170 -0
  42. package/src/health/index.js +7 -0
  43. package/src/performance/PerformanceMonitor.js +244 -0
  44. package/src/performance/index.js +7 -0
  45. package/src/security/InputSanitizer.js +185 -0
  46. package/src/security/SecurityMiddleware.js +231 -0
  47. package/src/security/index.js +9 -0
  48. package/src/shutdown/GracefulShutdown.js +159 -0
  49. package/src/shutdown/index.js +7 -0
  50. 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;