@onlineapps/conn-base-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/src/index.js ADDED
@@ -0,0 +1,923 @@
1
+ /**
2
+ * @module @onlineapps/conn-base-cache
3
+ * @description Redis cache connector with TTL, invalidation, and namespace support for OA Drive microservices.
4
+ * This connector provides a high-level abstraction over Redis operations with automatic key prefixing,
5
+ * statistics tracking, and namespace isolation.
6
+ *
7
+ * IMPORTANT: This connector uses "cache:" prefix for all keys.
8
+ * Never accesses "obs:" namespace (reserved for monitoring).
9
+ *
10
+ * @see {@link https://github.com/onlineapps/oa-drive/tree/main/shared/connector/conn-base-cache|GitHub Repository}
11
+ * @author OA Drive Team
12
+ * @license MIT
13
+ * @since 1.0.0
14
+ */
15
+
16
+ const Redis = require('ioredis');
17
+ const crypto = require('crypto');
18
+
19
+ /**
20
+ * Redis cache connector providing caching operations with automatic key management
21
+ *
22
+ * @class CacheConnector
23
+ *
24
+ * @example <caption>Basic Usage</caption>
25
+ * const cache = new CacheConnector({
26
+ * host: 'localhost',
27
+ * port: 6379,
28
+ * defaultTTL: 3600
29
+ * });
30
+ * await cache.connect();
31
+ * await cache.set('user:123', { name: 'John' }, 600);
32
+ * const user = await cache.get('user:123');
33
+ *
34
+ * @example <caption>With Namespace</caption>
35
+ * const cache = new CacheConnector({
36
+ * namespace: 'invoice-service'
37
+ * });
38
+ * await cache.set('inv:123', data); // Actual key: cache:invoice-service:inv:123
39
+ */
40
+ class CacheConnector {
41
+ /**
42
+ * Creates a new CacheConnector instance
43
+ *
44
+ * @constructor
45
+ * @param {Object} [config={}] - Configuration options
46
+ * @param {string} [config.host='localhost'] - Redis server host
47
+ * @param {number} [config.port=6379] - Redis server port
48
+ * @param {string} [config.password] - Redis password for authentication
49
+ * @param {number} [config.db=0] - Redis database number to use
50
+ * @param {number} [config.defaultTTL=3600] - Default TTL in seconds for cached items
51
+ * @param {string} [config.namespace=''] - Namespace prefix for all keys
52
+ * @param {number} [config.maxRetries=3] - Maximum connection retry attempts
53
+ * @param {number} [config.retryDelay=100] - Initial retry delay in milliseconds
54
+ * @param {boolean} [config.enableOfflineQueue=true] - Queue commands when disconnected
55
+ * @param {boolean} [config.lazyConnect=false] - Delay connection until first operation
56
+ *
57
+ * @throws {TypeError} If configuration is invalid
58
+ *
59
+ * @example <caption>Full Configuration</caption>
60
+ * const cache = new CacheConnector({
61
+ * host: 'redis.example.com',
62
+ * port: 6380,
63
+ * password: 'secret',
64
+ * namespace: 'my-service',
65
+ * defaultTTL: 1800,
66
+ * maxRetries: 5,
67
+ * enableOfflineQueue: false
68
+ * });
69
+ */
70
+ constructor(config = {}) {
71
+ // Configuration
72
+ this.config = {
73
+ host: config.host || process.env.REDIS_HOST || 'localhost',
74
+ port: config.port || process.env.REDIS_PORT || 6379,
75
+ password: config.password || process.env.REDIS_PASSWORD,
76
+ db: config.db || process.env.REDIS_DB || 0,
77
+ keyPrefix: 'cache:', // ALWAYS use cache: prefix
78
+ defaultTTL: config.defaultTTL || 3600, // 1 hour default
79
+ maxRetries: config.maxRetries || 3,
80
+ retryDelay: config.retryDelay || 100,
81
+ enableOfflineQueue: config.enableOfflineQueue !== false,
82
+ lazyConnect: config.lazyConnect || false
83
+ };
84
+
85
+ // Namespace configuration
86
+ this.namespace = config.namespace || '';
87
+
88
+ // Redis client
89
+ this.client = null;
90
+
91
+ // Connection state
92
+ this.connected = false;
93
+ this.connecting = false;
94
+
95
+ // Statistics
96
+ this.stats = {
97
+ hits: 0,
98
+ misses: 0,
99
+ sets: 0,
100
+ deletes: 0,
101
+ errors: 0
102
+ };
103
+ }
104
+
105
+ /**
106
+ * Establishes connection to Redis server
107
+ *
108
+ * @async
109
+ * @method connect
110
+ * @returns {Promise<boolean>} Returns true when connection is established
111
+ *
112
+ * @throws {Error} Throws error if connection fails after all retries
113
+ *
114
+ * @fires CacheConnector#connect
115
+ * @fires CacheConnector#error
116
+ * @fires CacheConnector#close
117
+ *
118
+ * @example <caption>Simple Connection</caption>
119
+ * try {
120
+ * await cache.connect();
121
+ * console.log('Connected to Redis');
122
+ * } catch (error) {
123
+ * console.error('Failed to connect:', error);
124
+ * }
125
+ *
126
+ * @example <caption>With Event Handlers</caption>
127
+ * cache.client.on('connect', () => {
128
+ * console.log('Redis connected');
129
+ * });
130
+ *
131
+ * cache.client.on('error', (err) => {
132
+ * console.error('Redis error:', err);
133
+ * });
134
+ *
135
+ * await cache.connect();
136
+ */
137
+ async connect() {
138
+ if (this.connected || this.connecting) {
139
+ return true;
140
+ }
141
+
142
+ this.connecting = true;
143
+
144
+ try {
145
+ this.client = new Redis({
146
+ host: this.config.host,
147
+ port: this.config.port,
148
+ password: this.config.password,
149
+ db: this.config.db,
150
+ keyPrefix: this.config.keyPrefix,
151
+ retryStrategy: (times) => {
152
+ if (times > this.config.maxRetries) {
153
+ return null; // Stop retrying
154
+ }
155
+ return Math.min(times * this.config.retryDelay, 2000);
156
+ },
157
+ enableOfflineQueue: this.config.enableOfflineQueue,
158
+ lazyConnect: this.config.lazyConnect
159
+ });
160
+
161
+ // Handle events
162
+ this.client.on('connect', () => {
163
+ this.connected = true;
164
+ this.connecting = false;
165
+ console.log('Redis cache connected');
166
+ });
167
+
168
+ this.client.on('error', (err) => {
169
+ this.stats.errors++;
170
+ console.error('Redis cache error:', err);
171
+ });
172
+
173
+ this.client.on('close', () => {
174
+ this.connected = false;
175
+ console.log('Redis cache connection closed');
176
+ });
177
+
178
+ // Wait for connection if not lazy
179
+ if (!this.config.lazyConnect) {
180
+ await this.client.connect();
181
+ }
182
+
183
+ this.connected = true;
184
+ this.connecting = false;
185
+
186
+ return true;
187
+ } catch (error) {
188
+ this.connecting = false;
189
+ throw new Error(`Failed to connect to Redis: ${error.message}`);
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Disconnect from Redis server gracefully
195
+ *
196
+ * @async
197
+ * @method disconnect
198
+ * @returns {Promise<void>}
199
+ *
200
+ * @example
201
+ * await cache.disconnect();
202
+ * console.log('Disconnected from Redis');
203
+ */
204
+ async disconnect() {
205
+ if (this.client) {
206
+ await this.client.quit();
207
+ this.client = null;
208
+ this.connected = false;
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Build cache key with namespace
214
+ * @private
215
+ */
216
+ buildKey(key) {
217
+ if (this.namespace) {
218
+ return `${this.namespace}:${key}`;
219
+ }
220
+ return key;
221
+ }
222
+
223
+ /**
224
+ * Retrieves a value from cache by key
225
+ *
226
+ * @async
227
+ * @method get
228
+ * @param {string} key - Cache key to retrieve
229
+ * @returns {Promise<*|null>} The cached value (parsed from JSON if applicable) or null if not found
230
+ *
231
+ * @throws {Error} Throws error if Redis operation fails
232
+ *
233
+ * @fires CacheConnector#hit - When cache hit occurs
234
+ * @fires CacheConnector#miss - When cache miss occurs
235
+ *
236
+ * @example <caption>Get Simple Value</caption>
237
+ * const value = await cache.get('user:123');
238
+ * if (value) {
239
+ * console.log('User found:', value);
240
+ * }
241
+ *
242
+ * @example <caption>Get with Fallback</caption>
243
+ * let user = await cache.get('user:123');
244
+ * if (!user) {
245
+ * user = await fetchFromDatabase(123);
246
+ * await cache.set('user:123', user, 600);
247
+ * }
248
+ */
249
+ async get(key) {
250
+ try {
251
+ const fullKey = this.buildKey(key);
252
+ const value = await this.client.get(fullKey);
253
+
254
+ if (value === null) {
255
+ this.stats.misses++;
256
+ return null;
257
+ }
258
+
259
+ this.stats.hits++;
260
+
261
+ // Try to parse JSON
262
+ try {
263
+ return JSON.parse(value);
264
+ } catch {
265
+ // Return as string if not JSON
266
+ return value;
267
+ }
268
+ } catch (error) {
269
+ this.stats.errors++;
270
+ throw new Error(`Cache get failed: ${error.message}`);
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Stores a value in cache with optional TTL
276
+ *
277
+ * @async
278
+ * @method set
279
+ * @param {string} key - Cache key
280
+ * @param {*} value - Value to cache (will be JSON stringified if not a string)
281
+ * @param {number} [ttl] - Time to live in seconds (uses defaultTTL if not specified)
282
+ * @returns {Promise<boolean>} Returns true on successful storage
283
+ *
284
+ * @throws {Error} Throws error if Redis operation fails
285
+ *
286
+ * @example <caption>Set with Default TTL</caption>
287
+ * await cache.set('config', { theme: 'dark' });
288
+ *
289
+ * @example <caption>Set with Custom TTL</caption>
290
+ * await cache.set('session:abc', sessionData, 1800); // 30 minutes
291
+ *
292
+ * @example <caption>Set Permanent (no expiry)</caption>
293
+ * await cache.set('permanent:data', data, 0);
294
+ */
295
+ async set(key, value, ttl) {
296
+ try {
297
+ const fullKey = this.buildKey(key);
298
+ const serialized = typeof value === 'string' ? value : JSON.stringify(value);
299
+ const expiry = ttl || this.config.defaultTTL;
300
+
301
+ if (expiry > 0) {
302
+ await this.client.setex(fullKey, expiry, serialized);
303
+ } else {
304
+ await this.client.set(fullKey, serialized);
305
+ }
306
+
307
+ this.stats.sets++;
308
+ return true;
309
+ } catch (error) {
310
+ this.stats.errors++;
311
+ throw new Error(`Cache set failed: ${error.message}`);
312
+ }
313
+ }
314
+
315
+ /**
316
+ * Delete a single key from cache
317
+ *
318
+ * @async
319
+ * @method delete
320
+ * @param {string} key - Cache key to delete
321
+ * @returns {Promise<boolean>} Returns true if key was deleted
322
+ *
323
+ * @throws {Error} Throws error if Redis operation fails
324
+ *
325
+ * @example
326
+ * const deleted = await cache.delete('user:123');
327
+ * if (deleted) {
328
+ * console.log('Key deleted');
329
+ * }
330
+ */
331
+ async delete(key) {
332
+ try {
333
+ const fullKey = this.buildKey(key);
334
+ const result = await this.client.del(fullKey);
335
+
336
+ this.stats.deletes++;
337
+ return result > 0;
338
+ } catch (error) {
339
+ this.stats.errors++;
340
+ throw new Error(`Cache delete failed: ${error.message}`);
341
+ }
342
+ }
343
+
344
+ /**
345
+ * Deletes keys matching a pattern
346
+ *
347
+ * @async
348
+ * @method deleteByPattern
349
+ * @param {string} pattern - Redis key pattern (e.g., "user:*", "session:*")
350
+ * @returns {Promise<number>} Number of deleted keys
351
+ *
352
+ * @throws {Error} Throws error if pattern deletion fails
353
+ *
354
+ * @warning This operation can be expensive on large datasets
355
+ *
356
+ * @example <caption>Delete All User Cache Entries</caption>
357
+ * const deleted = await cache.deleteByPattern('user:*');
358
+ * console.log(`Deleted ${deleted} user entries`);
359
+ *
360
+ * @example <caption>Delete Specific Session Pattern</caption>
361
+ * await cache.deleteByPattern('session:user:123:*');
362
+ */
363
+ async deleteByPattern(pattern) {
364
+ try {
365
+ const fullPattern = this.buildKey(pattern);
366
+ const keys = await this.client.keys(`${this.config.keyPrefix}${fullPattern}`);
367
+
368
+ if (keys.length === 0) {
369
+ return 0;
370
+ }
371
+
372
+ // Remove key prefix for del command
373
+ const cleanKeys = keys.map(k => k.replace(this.config.keyPrefix, ''));
374
+ const result = await this.client.del(...cleanKeys);
375
+
376
+ this.stats.deletes += result;
377
+ return result;
378
+ } catch (error) {
379
+ this.stats.errors++;
380
+ throw new Error(`Pattern delete failed: ${error.message}`);
381
+ }
382
+ }
383
+
384
+ /**
385
+ * Check if key exists in cache
386
+ *
387
+ * @async
388
+ * @method exists
389
+ * @param {string} key - Cache key to check
390
+ * @returns {Promise<boolean>} Returns true if key exists
391
+ *
392
+ * @throws {Error} Throws error if Redis operation fails
393
+ *
394
+ * @example
395
+ * if (await cache.exists('user:123')) {
396
+ * console.log('User is cached');
397
+ * }
398
+ */
399
+ async exists(key) {
400
+ try {
401
+ const fullKey = this.buildKey(key);
402
+ const result = await this.client.exists(fullKey);
403
+ return result === 1;
404
+ } catch (error) {
405
+ this.stats.errors++;
406
+ throw new Error(`Cache exists check failed: ${error.message}`);
407
+ }
408
+ }
409
+
410
+ /**
411
+ * Get remaining TTL for key
412
+ *
413
+ * @async
414
+ * @method ttl
415
+ * @param {string} key - Cache key
416
+ * @returns {Promise<number>} TTL in seconds, -1 if no TTL, -2 if not exists
417
+ *
418
+ * @throws {Error} Throws error if Redis operation fails
419
+ *
420
+ * @example
421
+ * const ttl = await cache.ttl('session:abc');
422
+ * if (ttl > 0) {
423
+ * console.log(`Session expires in ${ttl} seconds`);
424
+ * } else if (ttl === -1) {
425
+ * console.log('Session has no expiry');
426
+ * } else {
427
+ * console.log('Session does not exist');
428
+ * }
429
+ */
430
+ async ttl(key) {
431
+ try {
432
+ const fullKey = this.buildKey(key);
433
+ return await this.client.ttl(fullKey);
434
+ } catch (error) {
435
+ this.stats.errors++;
436
+ throw new Error(`TTL check failed: ${error.message}`);
437
+ }
438
+ }
439
+
440
+ /**
441
+ * Update TTL for existing key
442
+ *
443
+ * @async
444
+ * @method expire
445
+ * @param {string} key - Cache key
446
+ * @param {number} ttl - New TTL in seconds
447
+ * @returns {Promise<boolean>} Returns true if TTL was set
448
+ *
449
+ * @throws {Error} Throws error if Redis operation fails
450
+ *
451
+ * @example
452
+ * // Extend session by 30 minutes
453
+ * await cache.expire('session:abc', 1800);
454
+ */
455
+ async expire(key, ttl) {
456
+ try {
457
+ const fullKey = this.buildKey(key);
458
+ const result = await this.client.expire(fullKey, ttl);
459
+ return result === 1;
460
+ } catch (error) {
461
+ this.stats.errors++;
462
+ throw new Error(`Expire failed: ${error.message}`);
463
+ }
464
+ }
465
+
466
+ /**
467
+ * Increment numeric value atomically
468
+ *
469
+ * @async
470
+ * @method incr
471
+ * @param {string} key - Cache key
472
+ * @param {number} [increment=1] - Increment value
473
+ * @returns {Promise<number>} New value after increment
474
+ *
475
+ * @throws {Error} Throws error if Redis operation fails
476
+ *
477
+ * @example <caption>Simple Counter</caption>
478
+ * const count = await cache.incr('page:views');
479
+ * console.log(`Page views: ${count}`);
480
+ *
481
+ * @example <caption>Increment by Value</caption>
482
+ * const score = await cache.incr('user:score', 10);
483
+ */
484
+ async incr(key, increment = 1) {
485
+ try {
486
+ const fullKey = this.buildKey(key);
487
+
488
+ if (increment === 1) {
489
+ return await this.client.incr(fullKey);
490
+ } else {
491
+ return await this.client.incrby(fullKey, increment);
492
+ }
493
+ } catch (error) {
494
+ this.stats.errors++;
495
+ throw new Error(`Increment failed: ${error.message}`);
496
+ }
497
+ }
498
+
499
+ /**
500
+ * Decrement numeric value atomically
501
+ *
502
+ * @async
503
+ * @method decr
504
+ * @param {string} key - Cache key
505
+ * @param {number} [decrement=1] - Decrement value
506
+ * @returns {Promise<number>} New value after decrement
507
+ *
508
+ * @throws {Error} Throws error if Redis operation fails
509
+ *
510
+ * @example
511
+ * const remaining = await cache.decr('inventory:count', 1);
512
+ * if (remaining < 0) {
513
+ * console.log('Out of stock');
514
+ * }
515
+ */
516
+ async decr(key, decrement = 1) {
517
+ try {
518
+ const fullKey = this.buildKey(key);
519
+
520
+ if (decrement === 1) {
521
+ return await this.client.decr(fullKey);
522
+ } else {
523
+ return await this.client.decrby(fullKey, decrement);
524
+ }
525
+ } catch (error) {
526
+ this.stats.errors++;
527
+ throw new Error(`Decrement failed: ${error.message}`);
528
+ }
529
+ }
530
+
531
+ /**
532
+ * Get multiple values at once
533
+ *
534
+ * @async
535
+ * @method mget
536
+ * @param {string[]} keys - Array of cache keys
537
+ * @returns {Promise<Object>} Key-value object with found values
538
+ *
539
+ * @throws {Error} Throws error if Redis operation fails
540
+ *
541
+ * @example
542
+ * const result = await cache.mget(['user:1', 'user:2', 'user:3']);
543
+ * // Returns: { 'user:1': {...}, 'user:2': {...} }
544
+ * // Missing keys are not included in result
545
+ */
546
+ async mget(keys) {
547
+ try {
548
+ const fullKeys = keys.map(k => this.buildKey(k));
549
+ const values = await this.client.mget(...fullKeys);
550
+
551
+ const result = {};
552
+ keys.forEach((key, index) => {
553
+ if (values[index] !== null) {
554
+ try {
555
+ result[key] = JSON.parse(values[index]);
556
+ this.stats.hits++;
557
+ } catch {
558
+ result[key] = values[index];
559
+ this.stats.hits++;
560
+ }
561
+ } else {
562
+ this.stats.misses++;
563
+ }
564
+ });
565
+
566
+ return result;
567
+ } catch (error) {
568
+ this.stats.errors++;
569
+ throw new Error(`Multi-get failed: ${error.message}`);
570
+ }
571
+ }
572
+
573
+ /**
574
+ * Set multiple values at once
575
+ *
576
+ * @async
577
+ * @method mset
578
+ * @param {Object} keyValues - Key-value pairs to set
579
+ * @param {number} [ttl] - TTL in seconds for all keys
580
+ * @returns {Promise<boolean>} Returns true on success
581
+ *
582
+ * @throws {Error} Throws error if Redis operation fails
583
+ *
584
+ * @example
585
+ * await cache.mset({
586
+ * 'user:1': { name: 'Alice' },
587
+ * 'user:2': { name: 'Bob' },
588
+ * 'user:3': { name: 'Charlie' }
589
+ * }, 3600);
590
+ */
591
+ async mset(keyValues, ttl) {
592
+ try {
593
+ const pipeline = this.client.pipeline();
594
+
595
+ for (const [key, value] of Object.entries(keyValues)) {
596
+ const fullKey = this.buildKey(key);
597
+ const serialized = typeof value === 'string' ? value : JSON.stringify(value);
598
+ const expiry = ttl || this.config.defaultTTL;
599
+
600
+ if (expiry > 0) {
601
+ pipeline.setex(fullKey, expiry, serialized);
602
+ } else {
603
+ pipeline.set(fullKey, serialized);
604
+ }
605
+
606
+ this.stats.sets++;
607
+ }
608
+
609
+ await pipeline.exec();
610
+ return true;
611
+ } catch (error) {
612
+ this.stats.errors++;
613
+ throw new Error(`Multi-set failed: ${error.message}`);
614
+ }
615
+ }
616
+
617
+ /**
618
+ * Flush all cache keys (dangerous!)
619
+ *
620
+ * @async
621
+ * @method flush
622
+ * @param {boolean} [confirm=false] - Must be true to execute
623
+ * @returns {Promise<boolean>} Returns true on success
624
+ *
625
+ * @throws {Error} If confirmation not provided or flush fails
626
+ *
627
+ * @warning This will delete ALL cache keys with the configured prefix
628
+ *
629
+ * @example
630
+ * // Safety check required
631
+ * await cache.flush(true);
632
+ * console.log('All cache cleared');
633
+ */
634
+ async flush(confirm = false) {
635
+ if (!confirm) {
636
+ throw new Error('Flush requires confirmation parameter to be true');
637
+ }
638
+
639
+ try {
640
+ // Only flush keys with our prefix
641
+ const keys = await this.client.keys(`${this.config.keyPrefix}*`);
642
+
643
+ if (keys.length > 0) {
644
+ const cleanKeys = keys.map(k => k.replace(this.config.keyPrefix, ''));
645
+ await this.client.del(...cleanKeys);
646
+ }
647
+
648
+ return true;
649
+ } catch (error) {
650
+ this.stats.errors++;
651
+ throw new Error(`Flush failed: ${error.message}`);
652
+ }
653
+ }
654
+
655
+ /**
656
+ * Get cache statistics
657
+ *
658
+ * @method getStats
659
+ * @returns {CacheStats} Current statistics
660
+ * @returns {number} stats.hits - Number of cache hits
661
+ * @returns {number} stats.misses - Number of cache misses
662
+ * @returns {number} stats.sets - Number of set operations
663
+ * @returns {number} stats.deletes - Number of delete operations
664
+ * @returns {number} stats.errors - Number of errors
665
+ * @returns {string} stats.hitRate - Hit rate percentage
666
+ *
667
+ * @example
668
+ * const stats = cache.getStats();
669
+ * console.log(`Hit rate: ${stats.hitRate}`);
670
+ * console.log(`Total operations: ${stats.hits + stats.misses}`);
671
+ */
672
+ getStats() {
673
+ return {
674
+ ...this.stats,
675
+ hitRate: this.stats.hits + this.stats.misses > 0
676
+ ? (this.stats.hits / (this.stats.hits + this.stats.misses) * 100).toFixed(2) + '%'
677
+ : '0%'
678
+ };
679
+ }
680
+
681
+ /**
682
+ * Reset statistics counters
683
+ *
684
+ * @method resetStats
685
+ * @returns {void}
686
+ *
687
+ * @example
688
+ * cache.resetStats();
689
+ * // All counters set to 0
690
+ */
691
+ resetStats() {
692
+ this.stats = {
693
+ hits: 0,
694
+ misses: 0,
695
+ sets: 0,
696
+ deletes: 0,
697
+ errors: 0
698
+ };
699
+ }
700
+
701
+ /**
702
+ * Creates a new CacheConnector instance with additional namespace
703
+ *
704
+ * @method withNamespace
705
+ * @param {string} namespace - Additional namespace to append
706
+ * @returns {CacheConnector} New instance with combined namespace
707
+ *
708
+ * @example
709
+ * const baseCache = new CacheConnector({ namespace: 'app' });
710
+ * const userCache = baseCache.withNamespace('users');
711
+ * const sessionCache = baseCache.withNamespace('sessions');
712
+ *
713
+ * // userCache keys: cache:app:users:*
714
+ * // sessionCache keys: cache:app:sessions:*
715
+ */
716
+ withNamespace(namespace) {
717
+ return new CacheConnector({
718
+ ...this.config,
719
+ namespace: this.namespace ? `${this.namespace}:${namespace}` : namespace
720
+ });
721
+ }
722
+
723
+ /**
724
+ * Wraps a function with caching (memoization)
725
+ *
726
+ * @method wrap
727
+ * @param {Function} fn - Async function to wrap
728
+ * @param {Object} [options={}] - Wrapping options
729
+ * @param {number} [options.ttl] - Cache TTL in seconds
730
+ * @param {string} [options.keyPrefix] - Prefix for cache keys
731
+ * @param {Function} [options.keyGenerator] - Custom key generation function
732
+ * @returns {Function} Wrapped function with caching
733
+ *
734
+ * @example <caption>Basic Function Wrapping</caption>
735
+ * const expensiveOperation = async (userId) => {
736
+ * // Complex database query
737
+ * return await db.query('SELECT * FROM users WHERE id = ?', [userId]);
738
+ * };
739
+ *
740
+ * const cachedOperation = cache.wrap(expensiveOperation, {
741
+ * ttl: 600,
742
+ * keyPrefix: 'expensive-op'
743
+ * });
744
+ *
745
+ * // First call hits database
746
+ * const result1 = await cachedOperation(123);
747
+ *
748
+ * // Second call returns from cache
749
+ * const result2 = await cachedOperation(123);
750
+ *
751
+ * @example <caption>Custom Key Generation</caption>
752
+ * const searchUsers = cache.wrap(async (filters) => {
753
+ * return await db.searchUsers(filters);
754
+ * }, {
755
+ * ttl: 300,
756
+ * keyGenerator: (filters) => `search:${JSON.stringify(filters)}`
757
+ * });
758
+ */
759
+ wrap(fn, options = {}) {
760
+ const ttl = options.ttl || this.config.defaultTTL;
761
+ const keyPrefix = options.keyPrefix || fn.name || 'wrapped';
762
+ const keyGenerator = options.keyGenerator || ((...args) => {
763
+ const hash = crypto.createHash('sha256');
764
+ hash.update(JSON.stringify(args));
765
+ return `${keyPrefix}:${hash.digest('hex')}`;
766
+ });
767
+
768
+ return async (...args) => {
769
+ const key = keyGenerator(...args);
770
+
771
+ // Try to get from cache
772
+ const cached = await this.get(key);
773
+ if (cached !== null) {
774
+ return cached;
775
+ }
776
+
777
+ // Execute function
778
+ const result = await fn(...args);
779
+
780
+ // Cache result
781
+ await this.set(key, result, ttl);
782
+
783
+ return result;
784
+ };
785
+ }
786
+
787
+ /**
788
+ * Perform health check on Redis connection
789
+ *
790
+ * @async
791
+ * @method healthCheck
792
+ * @returns {Promise<boolean>} Returns true if healthy
793
+ *
794
+ * @example
795
+ * if (await cache.healthCheck()) {
796
+ * console.log('Redis is healthy');
797
+ * } else {
798
+ * console.error('Redis is down');
799
+ * }
800
+ */
801
+ async healthCheck() {
802
+ try {
803
+ await this.client.ping();
804
+ return true;
805
+ } catch {
806
+ return false;
807
+ }
808
+ }
809
+ }
810
+
811
+ /**
812
+ * @typedef {Object} CacheConfig
813
+ * @property {string} [host='localhost'] - Redis host
814
+ * @property {number} [port=6379] - Redis port
815
+ * @property {string} [password] - Redis password
816
+ * @property {number} [db=0] - Redis database
817
+ * @property {number} [defaultTTL=3600] - Default TTL in seconds
818
+ * @property {string} [namespace=''] - Key namespace
819
+ * @property {number} [maxRetries=3] - Max connection retries
820
+ * @property {number} [retryDelay=100] - Retry delay in ms
821
+ * @property {boolean} [enableOfflineQueue=true] - Queue commands when disconnected
822
+ * @property {boolean} [lazyConnect=false] - Delay connection
823
+ */
824
+
825
+ /**
826
+ * @typedef {Object} CacheStats
827
+ * @property {number} hits - Number of cache hits
828
+ * @property {number} misses - Number of cache misses
829
+ * @property {number} sets - Number of set operations
830
+ * @property {number} deletes - Number of delete operations
831
+ * @property {number} errors - Number of errors
832
+ * @property {string} hitRate - Hit rate percentage
833
+ */
834
+
835
+ /**
836
+ * Connection established event
837
+ *
838
+ * @event CacheConnector#connect
839
+ * @type {void}
840
+ */
841
+
842
+ /**
843
+ * Error event
844
+ *
845
+ * @event CacheConnector#error
846
+ * @type {Error}
847
+ */
848
+
849
+ /**
850
+ * Connection closed event
851
+ *
852
+ * @event CacheConnector#close
853
+ * @type {void}
854
+ */
855
+
856
+ // Export main class
857
+ module.exports = CacheConnector;
858
+
859
+ /**
860
+ * Factory function to create cache instance
861
+ *
862
+ * @function create
863
+ * @param {CacheConfig} config - Configuration object
864
+ * @returns {CacheConnector} New cache connector instance
865
+ *
866
+ * @example
867
+ * const cache = CacheConnector.create({
868
+ * host: 'localhost',
869
+ * namespace: 'my-service'
870
+ * });
871
+ */
872
+ module.exports.create = (config) => new CacheConnector(config);
873
+
874
+ /**
875
+ * Current version
876
+ * @constant {string}
877
+ */
878
+ module.exports.VERSION = '1.0.0';
879
+
880
+ // Export mock for testing
881
+ module.exports.MockCacheConnector = class MockCacheConnector {
882
+ constructor() {
883
+ this.cache = new Map();
884
+ this.stats = { hits: 0, misses: 0, sets: 0, deletes: 0, errors: 0 };
885
+ }
886
+
887
+ async connect() { return true; }
888
+ async disconnect() { return true; }
889
+
890
+ async get(key) {
891
+ if (this.cache.has(key)) {
892
+ this.stats.hits++;
893
+ return this.cache.get(key);
894
+ }
895
+ this.stats.misses++;
896
+ return null;
897
+ }
898
+
899
+ async set(key, value, ttl) {
900
+ this.cache.set(key, value);
901
+ this.stats.sets++;
902
+ // Simulate TTL
903
+ if (ttl > 0) {
904
+ setTimeout(() => this.cache.delete(key), ttl * 1000);
905
+ }
906
+ return true;
907
+ }
908
+
909
+ async delete(key) {
910
+ this.stats.deletes++;
911
+ return this.cache.delete(key);
912
+ }
913
+
914
+ async flush() {
915
+ this.cache.clear();
916
+ return true;
917
+ }
918
+
919
+ getStats() { return this.stats; }
920
+ resetStats() {
921
+ this.stats = { hits: 0, misses: 0, sets: 0, deletes: 0, errors: 0 };
922
+ }
923
+ };