@memberjunction/redis-provider 0.0.1 → 5.9.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.
@@ -0,0 +1,740 @@
1
+ /**
2
+ * @fileoverview Redis-backed implementation of {@link ILocalStorageProvider}.
3
+ *
4
+ * This module provides a drop-in replacement for `InMemoryLocalStorageProvider`
5
+ * that persists data in Redis, enabling:
6
+ *
7
+ * - **Shared caching** across multiple MJAPI server instances (horizontal scaling)
8
+ * - **Persistence** across process restarts
9
+ * - **Native TTL** via Redis `EXPIRE` — expired keys are automatically reclaimed
10
+ *
11
+ * Compatible with any Redis-protocol service: self-hosted Redis, Azure Managed Redis,
12
+ * AWS ElastiCache, Redis Cloud, Upstash, etc.
13
+ *
14
+ * @module @memberjunction/redis-provider
15
+ */
16
+ import Redis from 'ioredis';
17
+ import { EventEmitter } from 'events';
18
+ import { LogStatus, LogError } from '@memberjunction/core';
19
+ import { MJGlobal } from '@memberjunction/global';
20
+ /**
21
+ * Default category used when none is specified in storage operations.
22
+ * @internal
23
+ */
24
+ const DEFAULT_CATEGORY = 'default';
25
+ /**
26
+ * Redis-backed implementation of the MemberJunction {@link ILocalStorageProvider} interface.
27
+ *
28
+ * Provides persistent, shared caching for server-side environments using Redis.
29
+ * This is a drop-in replacement for `InMemoryLocalStorageProvider` — all consumers
30
+ * (like `LocalCacheManager`, `ProviderBase` metadata caching, etc.) work without
31
+ * any code changes.
32
+ *
33
+ * ### Key Structure
34
+ *
35
+ * All keys follow the pattern: `{prefix}:{category}:{key}`
36
+ *
37
+ * - **prefix** — configurable, defaults to `"mj"` to isolate MJ data in shared Redis instances
38
+ * - **category** — maps to the MJ cache category (`RunViewCache`, `Metadata`, `DatasetCache`, etc.)
39
+ * - **key** — the original key from the caller
40
+ *
41
+ * Categories are tracked in a Redis Set at `{prefix}:__categories__:{category}` so that
42
+ * `ClearCategory()` and `GetCategoryKeys()` operations are efficient.
43
+ *
44
+ * ### TTL Support
45
+ *
46
+ * Redis has native key expiration. The provider supports TTL at two levels:
47
+ * 1. **`defaultTTLSeconds`** in config — applied to all `SetItem` calls
48
+ * 2. **`ttlSeconds` parameter** on `SetItem` — overrides the default per-call
49
+ *
50
+ * ### Error Handling
51
+ *
52
+ * Redis operations are wrapped in try/catch blocks. Connection errors are logged
53
+ * via `LogError()` but do not throw — the provider gracefully returns `null` for
54
+ * reads and silently skips writes. This prevents a Redis outage from crashing the
55
+ * application. The `ioredis` client handles automatic reconnection.
56
+ *
57
+ * @example
58
+ * ```typescript
59
+ * import { RedisLocalStorageProvider } from '@memberjunction/redis-provider';
60
+ *
61
+ * const provider = new RedisLocalStorageProvider({
62
+ * url: 'redis://localhost:6379',
63
+ * defaultTTLSeconds: 300 // 5-minute default TTL
64
+ * });
65
+ *
66
+ * await provider.SetItem('user:123', JSON.stringify(userData), 'UserCache');
67
+ * const cached = await provider.GetItem('user:123', 'UserCache');
68
+ * ```
69
+ */
70
+ export class RedisLocalStorageProvider {
71
+ /**
72
+ * Creates a new Redis local storage provider and establishes a connection.
73
+ *
74
+ * The constructor sets up the `ioredis` client with automatic reconnection,
75
+ * error handling, and optional logging. The client connects lazily on the
76
+ * first command, so construction itself does not block.
77
+ *
78
+ * @param config - Redis connection and behavior configuration.
79
+ * At minimum, provide either `url` or `options`.
80
+ * If neither is provided, connects to `localhost:6379`.
81
+ *
82
+ * @example
83
+ * ```typescript
84
+ * // Connect to local Redis
85
+ * const provider = new RedisLocalStorageProvider({});
86
+ *
87
+ * // Connect to Azure Managed Redis with TLS
88
+ * const provider = new RedisLocalStorageProvider({
89
+ * url: 'rediss://default:ACCESS_KEY@myredis.redis.cache.windows.net:6380',
90
+ * defaultTTLSeconds: 600
91
+ * });
92
+ *
93
+ * // Connect to AWS ElastiCache
94
+ * const provider = new RedisLocalStorageProvider({
95
+ * options: {
96
+ * host: 'my-cluster.abc123.use1.cache.amazonaws.com',
97
+ * port: 6379,
98
+ * tls: {}
99
+ * }
100
+ * });
101
+ * ```
102
+ */
103
+ constructor(config = {}) {
104
+ this._connected = false;
105
+ this._subscriber = null;
106
+ this._eventEmitter = new EventEmitter();
107
+ this._subscriberConnected = false;
108
+ this._config = config;
109
+ this._keyPrefix = config.keyPrefix ?? 'mj';
110
+ this._defaultTTLSeconds = config.defaultTTLSeconds;
111
+ this._enableLogging = config.enableLogging ?? true;
112
+ this._enablePubSub = config.enablePubSub ?? false;
113
+ this._pubSubChannel = `${this._keyPrefix}:__pubsub__`;
114
+ const maxRetries = config.maxRetries ?? 10;
115
+ if (config.url) {
116
+ this._client = new Redis(config.url, {
117
+ maxRetriesPerRequest: null,
118
+ retryStrategy: (times) => this.retryStrategy(times, maxRetries),
119
+ lazyConnect: false,
120
+ });
121
+ }
122
+ else {
123
+ this._client = new Redis({
124
+ host: 'localhost',
125
+ port: 6379,
126
+ maxRetriesPerRequest: null,
127
+ retryStrategy: (times) => this.retryStrategy(times, maxRetries),
128
+ lazyConnect: false,
129
+ ...config.options,
130
+ });
131
+ }
132
+ this.setupEventHandlers();
133
+ }
134
+ /**
135
+ * Exponential backoff retry strategy for Redis connections.
136
+ * Doubles the delay on each attempt (capped at 30 seconds) and gives up
137
+ * after `maxRetries` attempts.
138
+ *
139
+ * @param times - Current retry attempt number (1-based)
140
+ * @param maxRetries - Maximum number of retries before giving up
141
+ * @returns Delay in milliseconds, or `null` to stop retrying
142
+ * @internal
143
+ */
144
+ retryStrategy(times, maxRetries) {
145
+ if (times > maxRetries) {
146
+ if (this._enableLogging) {
147
+ LogError(`Redis: max retries (${maxRetries}) exceeded, giving up`);
148
+ }
149
+ return null;
150
+ }
151
+ // Exponential backoff: 200ms, 400ms, 800ms, ... capped at 30s
152
+ const delay = Math.min(times * 200, 30000);
153
+ if (this._enableLogging) {
154
+ LogStatus(`Redis: reconnecting in ${delay}ms (attempt ${times}/${maxRetries})`);
155
+ }
156
+ return delay;
157
+ }
158
+ /**
159
+ * Registers event handlers on the ioredis client for logging connection
160
+ * lifecycle events (connect, ready, close, error, reconnecting).
161
+ * @internal
162
+ */
163
+ setupEventHandlers() {
164
+ this._client.on('connect', () => {
165
+ this._connected = true;
166
+ if (this._enableLogging) {
167
+ LogStatus('Redis: connected');
168
+ }
169
+ });
170
+ this._client.on('ready', () => {
171
+ this._connected = true;
172
+ if (this._enableLogging) {
173
+ LogStatus('Redis: ready to accept commands');
174
+ }
175
+ });
176
+ this._client.on('close', () => {
177
+ this._connected = false;
178
+ if (this._enableLogging) {
179
+ LogStatus('Redis: connection closed');
180
+ }
181
+ });
182
+ this._client.on('error', (err) => {
183
+ if (this._enableLogging) {
184
+ LogError(`Redis: ${err.message}`);
185
+ }
186
+ });
187
+ this._client.on('reconnecting', () => {
188
+ if (this._enableLogging) {
189
+ LogStatus('Redis: reconnecting...');
190
+ }
191
+ });
192
+ }
193
+ /**
194
+ * Builds the full Redis key from a category and key name.
195
+ *
196
+ * Format: `{prefix}:{category}:{key}`
197
+ *
198
+ * @param key - The storage key
199
+ * @param category - The category for key isolation
200
+ * @returns The fully-qualified Redis key string
201
+ * @internal
202
+ */
203
+ buildKey(key, category) {
204
+ return `${this._keyPrefix}:${category}:${key}`;
205
+ }
206
+ /**
207
+ * Builds the Redis Set key used to track all keys in a category.
208
+ *
209
+ * Format: `{prefix}:__categories__:{category}`
210
+ *
211
+ * @param category - The category name
212
+ * @returns The Redis key for the category's membership set
213
+ * @internal
214
+ */
215
+ buildCategorySetKey(category) {
216
+ return `${this._keyPrefix}:__categories__:${category}`;
217
+ }
218
+ /**
219
+ * Retrieves a value from Redis by key and optional category.
220
+ *
221
+ * @param key - The key to look up
222
+ * @param category - Optional category for key isolation (defaults to `"default"`)
223
+ * @returns The stored string value, or `null` if the key doesn't exist or Redis is unavailable
224
+ *
225
+ * @example
226
+ * ```typescript
227
+ * const value = await provider.GetItem('entity-metadata', 'Metadata');
228
+ * if (value) {
229
+ * const metadata = JSON.parse(value);
230
+ * }
231
+ * ```
232
+ */
233
+ async GetItem(key, category) {
234
+ try {
235
+ const redisKey = this.buildKey(key, category ?? DEFAULT_CATEGORY);
236
+ return await this._client.get(redisKey);
237
+ }
238
+ catch (err) {
239
+ if (this._enableLogging) {
240
+ LogError(`Redis GetItem failed for key "${key}": ${err.message}`);
241
+ }
242
+ return null;
243
+ }
244
+ }
245
+ /**
246
+ * Stores a value in Redis under the given key and optional category.
247
+ *
248
+ * If a `ttlSeconds` is provided, the key will automatically expire after that
249
+ * duration. Otherwise, the configured `defaultTTLSeconds` is used. If neither
250
+ * is set, the key persists indefinitely.
251
+ *
252
+ * The key is also added to a Redis Set that tracks all keys in the category,
253
+ * enabling efficient `ClearCategory()` and `GetCategoryKeys()` operations.
254
+ *
255
+ * @param key - The key to store under
256
+ * @param value - The string value to store
257
+ * @param category - Optional category for key isolation (defaults to `"default"`)
258
+ * @param ttlSeconds - Optional time-to-live in seconds. Overrides `defaultTTLSeconds` from config.
259
+ *
260
+ * @example
261
+ * ```typescript
262
+ * // Store with default TTL
263
+ * await provider.SetItem('view:users', JSON.stringify(results), 'RunViewCache');
264
+ *
265
+ * // Store with explicit 10-minute TTL
266
+ * await provider.SetItem('view:users', JSON.stringify(results), 'RunViewCache', 600);
267
+ * ```
268
+ */
269
+ async SetItem(key, value, category, ttlSeconds) {
270
+ try {
271
+ const cat = category ?? DEFAULT_CATEGORY;
272
+ const redisKey = this.buildKey(key, cat);
273
+ const categorySetKey = this.buildCategorySetKey(cat);
274
+ const effectiveTTL = ttlSeconds ?? this._defaultTTLSeconds;
275
+ // Use pipeline for atomic set + category tracking
276
+ const pipeline = this._client.pipeline();
277
+ if (effectiveTTL && effectiveTTL > 0) {
278
+ pipeline.setex(redisKey, effectiveTTL, value);
279
+ }
280
+ else {
281
+ pipeline.set(redisKey, value);
282
+ }
283
+ // Track this key in the category set for ClearCategory/GetCategoryKeys
284
+ pipeline.sadd(categorySetKey, key);
285
+ await pipeline.exec();
286
+ // Publish cache change event for cross-server invalidation
287
+ this.publishChange(key, cat, 'set', value);
288
+ }
289
+ catch (err) {
290
+ if (this._enableLogging) {
291
+ LogError(`Redis SetItem failed for key "${key}": ${err.message}`);
292
+ }
293
+ }
294
+ }
295
+ /**
296
+ * Removes a key from Redis and from its category tracking set.
297
+ *
298
+ * @param key - The key to remove
299
+ * @param category - Optional category for key isolation (defaults to `"default"`)
300
+ *
301
+ * @example
302
+ * ```typescript
303
+ * await provider.Remove('view:users', 'RunViewCache');
304
+ * ```
305
+ */
306
+ async Remove(key, category) {
307
+ try {
308
+ const cat = category ?? DEFAULT_CATEGORY;
309
+ const redisKey = this.buildKey(key, cat);
310
+ const categorySetKey = this.buildCategorySetKey(cat);
311
+ const pipeline = this._client.pipeline();
312
+ pipeline.del(redisKey);
313
+ pipeline.srem(categorySetKey, key);
314
+ await pipeline.exec();
315
+ // Publish cache change event for cross-server invalidation
316
+ this.publishChange(key, cat, 'removed');
317
+ }
318
+ catch (err) {
319
+ if (this._enableLogging) {
320
+ LogError(`Redis Remove failed for key "${key}": ${err.message}`);
321
+ }
322
+ }
323
+ }
324
+ /**
325
+ * Clears all keys belonging to a specific category.
326
+ *
327
+ * Uses the category tracking Set to find all member keys, deletes them
328
+ * in a single pipeline call, then removes the tracking Set itself.
329
+ *
330
+ * @param category - The category to clear. If empty, clears the `"default"` category.
331
+ *
332
+ * @example
333
+ * ```typescript
334
+ * // Clear all cached RunView results
335
+ * await provider.ClearCategory('RunViewCache');
336
+ * ```
337
+ */
338
+ async ClearCategory(category) {
339
+ try {
340
+ const cat = category || DEFAULT_CATEGORY;
341
+ const categorySetKey = this.buildCategorySetKey(cat);
342
+ // Get all keys in this category
343
+ const keys = await this._client.smembers(categorySetKey);
344
+ if (keys.length > 0) {
345
+ const pipeline = this._client.pipeline();
346
+ // Delete each key
347
+ for (const key of keys) {
348
+ pipeline.del(this.buildKey(key, cat));
349
+ }
350
+ // Delete the category set itself
351
+ pipeline.del(categorySetKey);
352
+ await pipeline.exec();
353
+ }
354
+ else {
355
+ // Category set might still exist even if empty
356
+ await this._client.del(categorySetKey);
357
+ }
358
+ // Publish category-level change event
359
+ this.publishChange(cat, cat, 'category_cleared');
360
+ }
361
+ catch (err) {
362
+ if (this._enableLogging) {
363
+ LogError(`Redis ClearCategory failed for "${category}": ${err.message}`);
364
+ }
365
+ }
366
+ }
367
+ /**
368
+ * Returns all keys belonging to a specific category.
369
+ *
370
+ * Reads from the category tracking Set, so the result reflects keys
371
+ * that were added via `SetItem` (some may have expired via TTL but
372
+ * will still appear in the set until cleaned up).
373
+ *
374
+ * @param category - The category to list keys from
375
+ * @returns Array of original key names (without the Redis prefix/category prefix)
376
+ *
377
+ * @example
378
+ * ```typescript
379
+ * const keys = await provider.GetCategoryKeys('RunViewCache');
380
+ * console.log(`${keys.length} cached views`);
381
+ * ```
382
+ */
383
+ async GetCategoryKeys(category) {
384
+ try {
385
+ const cat = category || DEFAULT_CATEGORY;
386
+ const categorySetKey = this.buildCategorySetKey(cat);
387
+ return await this._client.smembers(categorySetKey);
388
+ }
389
+ catch (err) {
390
+ if (this._enableLogging) {
391
+ LogError(`Redis GetCategoryKeys failed for "${category}": ${err.message}`);
392
+ }
393
+ return [];
394
+ }
395
+ }
396
+ /**
397
+ * Whether the Redis client currently has an active connection.
398
+ *
399
+ * Note: `ioredis` automatically reconnects on failure, so a `false` value
400
+ * here is usually transient. The provider continues to accept commands
401
+ * (they queue until reconnection succeeds or retry limit is hit).
402
+ */
403
+ get IsConnected() {
404
+ return this._connected;
405
+ }
406
+ /**
407
+ * Returns the underlying `ioredis` client instance for advanced operations.
408
+ *
409
+ * Use with caution — direct client access bypasses key prefixing and
410
+ * category tracking. Prefer the `ILocalStorageProvider` methods for
411
+ * standard operations.
412
+ *
413
+ * @example
414
+ * ```typescript
415
+ * // Use for Redis-specific commands like pub/sub, streams, etc.
416
+ * const client = provider.Client;
417
+ * await client.publish('cache-invalidation', 'entity:Users');
418
+ * ```
419
+ */
420
+ get Client() {
421
+ return this._client;
422
+ }
423
+ /**
424
+ * Gracefully disconnects from Redis.
425
+ *
426
+ * Sends a `QUIT` command and waits for pending replies. After calling this
427
+ * method, the provider should not be used for further operations.
428
+ *
429
+ * Call this during application shutdown to ensure clean disconnection.
430
+ *
431
+ * @example
432
+ * ```typescript
433
+ * // During application shutdown
434
+ * process.on('SIGTERM', async () => {
435
+ * await redisProvider.Disconnect();
436
+ * process.exit(0);
437
+ * });
438
+ * ```
439
+ */
440
+ async Disconnect() {
441
+ // Disconnect subscriber first if active
442
+ if (this._subscriber) {
443
+ try {
444
+ await this._subscriber.quit();
445
+ }
446
+ catch {
447
+ this._subscriber.disconnect();
448
+ }
449
+ this._subscriber = null;
450
+ this._subscriberConnected = false;
451
+ }
452
+ // Remove all event listeners
453
+ this._eventEmitter.removeAllListeners();
454
+ try {
455
+ await this._client.quit();
456
+ this._connected = false;
457
+ if (this._enableLogging) {
458
+ LogStatus('Redis: disconnected gracefully');
459
+ }
460
+ }
461
+ catch (err) {
462
+ if (this._enableLogging) {
463
+ LogError(`Redis disconnect error: ${err.message}`);
464
+ }
465
+ // Force disconnect if graceful quit fails
466
+ this._client.disconnect();
467
+ this._connected = false;
468
+ }
469
+ }
470
+ /**
471
+ * Checks if a key exists in the specified category.
472
+ *
473
+ * This is more efficient than `GetItem()` when you only need to check
474
+ * existence without retrieving the value (avoids transferring the value
475
+ * over the network).
476
+ *
477
+ * @param key - The key to check
478
+ * @param category - Optional category for key isolation (defaults to `"default"`)
479
+ * @returns `true` if the key exists and has not expired
480
+ *
481
+ * @example
482
+ * ```typescript
483
+ * if (await provider.Exists('view:users', 'RunViewCache')) {
484
+ * // Use cached value
485
+ * }
486
+ * ```
487
+ */
488
+ async Exists(key, category) {
489
+ try {
490
+ const redisKey = this.buildKey(key, category ?? DEFAULT_CATEGORY);
491
+ const result = await this._client.exists(redisKey);
492
+ return result === 1;
493
+ }
494
+ catch (err) {
495
+ if (this._enableLogging) {
496
+ LogError(`Redis Exists failed for key "${key}": ${err.message}`);
497
+ }
498
+ return false;
499
+ }
500
+ }
501
+ /**
502
+ * Returns the remaining time-to-live (in seconds) for a key.
503
+ *
504
+ * @param key - The key to check
505
+ * @param category - Optional category for key isolation (defaults to `"default"`)
506
+ * @returns TTL in seconds, `-1` if no expiration is set, `-2` if the key doesn't exist,
507
+ * or `null` if Redis is unavailable
508
+ *
509
+ * @example
510
+ * ```typescript
511
+ * const ttl = await provider.GetTTL('view:users', 'RunViewCache');
512
+ * if (ttl !== null && ttl > 0) {
513
+ * console.log(`Key expires in ${ttl} seconds`);
514
+ * }
515
+ * ```
516
+ */
517
+ async GetTTL(key, category) {
518
+ try {
519
+ const redisKey = this.buildKey(key, category ?? DEFAULT_CATEGORY);
520
+ return await this._client.ttl(redisKey);
521
+ }
522
+ catch (err) {
523
+ if (this._enableLogging) {
524
+ LogError(`Redis GetTTL failed for key "${key}": ${err.message}`);
525
+ }
526
+ return null;
527
+ }
528
+ }
529
+ /**
530
+ * Pings the Redis server to verify connectivity.
531
+ *
532
+ * Useful for health checks and connection validation.
533
+ *
534
+ * @returns `true` if the server responds with `PONG`, `false` otherwise
535
+ *
536
+ * @example
537
+ * ```typescript
538
+ * const healthy = await provider.Ping();
539
+ * if (!healthy) {
540
+ * console.error('Redis is unreachable');
541
+ * }
542
+ * ```
543
+ */
544
+ async Ping() {
545
+ try {
546
+ const result = await this._client.ping();
547
+ return result === 'PONG';
548
+ }
549
+ catch {
550
+ return false;
551
+ }
552
+ }
553
+ // ========================================================================
554
+ // PUB/SUB — Cross-Server Cache Invalidation
555
+ // ========================================================================
556
+ /**
557
+ * Starts listening for cache change events from other server instances.
558
+ * Creates a dedicated Redis connection for pub/sub (required by Redis protocol —
559
+ * a client in subscribe mode cannot execute other commands).
560
+ *
561
+ * Must be called explicitly after construction. No-op if `enablePubSub` is `false`
562
+ * in the config, or if already listening.
563
+ *
564
+ * @example
565
+ * ```typescript
566
+ * const provider = new RedisLocalStorageProvider({
567
+ * url: 'redis://localhost:6379',
568
+ * enablePubSub: true
569
+ * });
570
+ * await provider.StartListening();
571
+ *
572
+ * // Register for change events
573
+ * provider.OnCacheChanged((event) => {
574
+ * console.log(`Key "${event.CacheKey}" changed by server ${event.SourceServerId}`);
575
+ * });
576
+ * ```
577
+ */
578
+ async StartListening() {
579
+ if (!this._enablePubSub) {
580
+ if (this._enableLogging) {
581
+ LogStatus('Redis pub/sub: not enabled (set enablePubSub: true in config)');
582
+ }
583
+ return;
584
+ }
585
+ if (this._subscriber) {
586
+ // Already listening
587
+ return;
588
+ }
589
+ this._subscriber = this.createSubscriberClient();
590
+ this.setupSubscriberEventHandlers();
591
+ await this._subscriber.subscribe(this._pubSubChannel);
592
+ if (this._enableLogging) {
593
+ LogStatus(`Redis pub/sub: subscribed to channel "${this._pubSubChannel}"`);
594
+ }
595
+ this._subscriber.on('message', (channel, message) => {
596
+ if (channel !== this._pubSubChannel) {
597
+ return;
598
+ }
599
+ this.handlePubSubMessage(message);
600
+ });
601
+ }
602
+ /**
603
+ * Creates the subscriber Redis client, mirroring the main client's connection config.
604
+ * @internal
605
+ */
606
+ createSubscriberClient() {
607
+ const maxRetries = this._config.maxRetries ?? 10;
608
+ if (this._config.url) {
609
+ return new Redis(this._config.url, {
610
+ maxRetriesPerRequest: null,
611
+ retryStrategy: (times) => this.retryStrategy(times, maxRetries),
612
+ lazyConnect: false,
613
+ });
614
+ }
615
+ return new Redis({
616
+ host: 'localhost',
617
+ port: 6379,
618
+ maxRetriesPerRequest: null,
619
+ retryStrategy: (times) => this.retryStrategy(times, maxRetries),
620
+ lazyConnect: false,
621
+ ...this._config.options,
622
+ });
623
+ }
624
+ /**
625
+ * Sets up event handlers on the subscriber client for logging.
626
+ * @internal
627
+ */
628
+ setupSubscriberEventHandlers() {
629
+ if (!this._subscriber)
630
+ return;
631
+ this._subscriber.on('connect', () => {
632
+ this._subscriberConnected = true;
633
+ if (this._enableLogging) {
634
+ LogStatus('Redis pub/sub subscriber: connected');
635
+ }
636
+ });
637
+ this._subscriber.on('close', () => {
638
+ this._subscriberConnected = false;
639
+ });
640
+ this._subscriber.on('error', (err) => {
641
+ if (this._enableLogging) {
642
+ LogError(`Redis pub/sub subscriber: ${err.message}`);
643
+ }
644
+ });
645
+ }
646
+ /**
647
+ * Handles an incoming pub/sub message. Parses the {@link CacheChangedEvent},
648
+ * filters out self-originated events, and emits to local listeners.
649
+ * @internal
650
+ */
651
+ handlePubSubMessage(message) {
652
+ try {
653
+ const event = JSON.parse(message);
654
+ // Skip events from this server instance
655
+ if (event.SourceServerId === MJGlobal.Instance.ProcessUUID) {
656
+ return;
657
+ }
658
+ if (this._enableLogging) {
659
+ const sourceShort = event.SourceServerId ? event.SourceServerId.substring(0, 8) : 'unknown';
660
+ LogStatus(`Redis pub/sub: received ${event.Action} event for key "${event.CacheKey}" from server ${sourceShort}`);
661
+ }
662
+ // Emit to local listeners
663
+ this._eventEmitter.emit('cacheChanged', event);
664
+ }
665
+ catch (err) {
666
+ if (this._enableLogging) {
667
+ LogError(`Redis pub/sub: failed to parse message: ${err.message}`);
668
+ }
669
+ }
670
+ }
671
+ /**
672
+ * Publishes a cache change event to Redis pub/sub. Called internally by
673
+ * `SetItem`, `Remove`, and `ClearCategory`. No-op if pub/sub is disabled.
674
+ *
675
+ * @param cacheKey - The cache key that changed
676
+ * @param category - The storage category
677
+ * @param action - What happened ('set', 'removed', 'category_cleared')
678
+ * @param data - The new value (only for 'set' actions)
679
+ * @internal
680
+ */
681
+ publishChange(cacheKey, category, action, data) {
682
+ if (!this._enablePubSub) {
683
+ return;
684
+ }
685
+ const event = {
686
+ CacheKey: cacheKey,
687
+ Category: category,
688
+ Action: action,
689
+ Timestamp: Date.now(),
690
+ SourceServerId: MJGlobal.Instance.ProcessUUID,
691
+ Data: data,
692
+ };
693
+ // Publish fire-and-forget — don't await, don't block the caller
694
+ const payload = JSON.stringify(event);
695
+ this._client.publish(this._pubSubChannel, payload).then(() => {
696
+ if (this._enableLogging) {
697
+ LogStatus(`Redis pub/sub: published ${action} event for key "${cacheKey}" on channel "${this._pubSubChannel}"`);
698
+ }
699
+ }).catch((err) => {
700
+ if (this._enableLogging) {
701
+ LogError(`Redis pub/sub publish failed: ${err.message}`);
702
+ }
703
+ });
704
+ }
705
+ /**
706
+ * Registers a callback for cache change events from other servers.
707
+ * The callback fires whenever another server instance modifies a cached entry
708
+ * (via `SetItem`, `Remove`, or `ClearCategory`).
709
+ *
710
+ * Events from this server instance (identified by {@link MJGlobal.ProcessUUID})
711
+ * are automatically filtered out.
712
+ *
713
+ * @param callback - Function invoked with the {@link CacheChangedEvent}
714
+ * @returns A function that, when called, removes this callback registration
715
+ *
716
+ * @example
717
+ * ```typescript
718
+ * const unsubscribe = provider.OnCacheChanged((event) => {
719
+ * // Dispatch to LocalCacheManager for callback routing
720
+ * LocalCacheManager.Instance.DispatchCacheChange(event);
721
+ * });
722
+ *
723
+ * // Later, on shutdown:
724
+ * unsubscribe();
725
+ * ```
726
+ */
727
+ OnCacheChanged(callback) {
728
+ this._eventEmitter.on('cacheChanged', callback);
729
+ return () => {
730
+ this._eventEmitter.off('cacheChanged', callback);
731
+ };
732
+ }
733
+ /**
734
+ * Whether the pub/sub subscriber connection is currently active.
735
+ */
736
+ get IsSubscriberConnected() {
737
+ return this._subscriberConnected;
738
+ }
739
+ }
740
+ //# sourceMappingURL=RedisLocalStorageProvider.js.map