kythia-core 0.9.3-beta

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,948 @@
1
+ /**
2
+ * 🚀 Caching Layer for Sequelize Models (Hybrid Redis + In-Memory Fallback Edition, Sniper Mode, Shard-aware)
3
+ *
4
+ * @file src/database/KythiaModel.js
5
+ * @copyright © 2025 kenndeclouv
6
+ * @assistant chaa & graa
7
+ * @version 0.9.3-beta
8
+ *
9
+ * @description
10
+ * Caching layer for Sequelize Models, now sharding-aware. When config.db.redis.shard === true,
11
+ * fallback to in-memory cache is DISABLED (dangerous with sharding). If sharding, then Redis is REQUIRED,
12
+ * and if Redis goes down, instant queries go directly to db.
13
+ * For shard: false/undefined, original hybrid fallback applies.
14
+ *
15
+ * ✨ Core Features:
16
+ * - Shard Mode: If using Redis sharding, disables Map fallback for strict consistency.
17
+ * - Hybrid Fallback: For non-shard setups, automatic fallback is preserved.
18
+ * - Fast, consistent, safe cache busting.
19
+ */
20
+
21
+ const jsonStringify = require('json-stable-stringify');
22
+ const { Model } = require('sequelize');
23
+ const { LRUCache } = require('lru-cache');
24
+
25
+ const NEGATIVE_CACHE_PLACEHOLDER = '__KYTHIA_NEGATIVE_CACHE__';
26
+ const RECONNECT_DELAY_MINUTES = 3;
27
+
28
+ const REDIS_ERROR_TOLERANCE_COUNT = 3;
29
+ const REDIS_ERROR_TOLERANCE_INTERVAL_MS = 10 * 1000;
30
+
31
+ function safeStringify(obj, logger) {
32
+ try {
33
+ return JSON.stringify(obj, (key, value) => (typeof value === 'bigint' ? value.toString() : value));
34
+ } catch (err) {
35
+ (logger || console).error(`❌ [SAFE STRINGIFY] Failed: ${err.message}`);
36
+ return '{}';
37
+ }
38
+ }
39
+
40
+ function safeParse(str, logger) {
41
+ try {
42
+ return JSON.parse(str);
43
+ } catch {
44
+ (logger || console).warn('⚠️ [SAFE PARSE] Invalid JSON data, returning null');
45
+ return null;
46
+ }
47
+ }
48
+
49
+ class KythiaModel extends Model {
50
+ static client;
51
+ static redis;
52
+ static isRedisConnected = false;
53
+ static logger = console;
54
+ static config = {};
55
+ static CACHE_VERSION = '1.0.0';
56
+
57
+ static localCache = new LRUCache({ max: 1000 });
58
+ static localNegativeCache = new Set();
59
+ static MAX_LOCAL_CACHE_SIZE = 1000;
60
+ static DEFAULT_TTL = 60 * 60 * 1000;
61
+
62
+ static lastRedisOpts = null;
63
+ static reconnectTimeout = null;
64
+ static lastAutoReconnectTs = 0;
65
+
66
+ static pendingQueries = new Map();
67
+ static cacheStats = { redisHits: 0, mapHits: 0, misses: 0, sets: 0, clears: 0, errors: 0 };
68
+
69
+ static redisErrorTimestamps = [];
70
+
71
+ static isShardMode = false; // when true, local fallback is disabled
72
+
73
+ /**
74
+ * 💉 Injects core dependencies into the KythiaModel class.
75
+ * This must be called once at application startup before any models are loaded.
76
+ * @param {Object} dependencies - The dependencies to inject
77
+ * @param {Object} dependencies.logger - The logger instance
78
+ * @param {Object} dependencies.config - The application config object
79
+ * @param {Object} [dependencies.redis] - Optional Redis client instance
80
+ * @param {Object} [dependencies.redisOptions] - Redis connection options if not providing a client
81
+ */
82
+ static setDependencies({ logger, config, redis, redisOptions }) {
83
+ if (!logger || !config) {
84
+ throw new Error('KythiaModel.setDependencies requires logger and config');
85
+ }
86
+
87
+ this.logger = logger;
88
+ this.config = config;
89
+ this.CACHE_VERSION = config.db?.redisCacheVersion || '1.0.0';
90
+
91
+ // Check for sharding
92
+ this.isShardMode = !!config?.db?.redis?.shard || false;
93
+
94
+ if (this.isShardMode) {
95
+ this.logger.info('🟣 [REDIS][SHARD] Detected redis sharding mode (shard: true). Local fallback cache DISABLED!');
96
+ }
97
+
98
+ if (redis) {
99
+ this.redis = redis;
100
+ this.isRedisConnected = redis.status === 'ready';
101
+ } else if (redisOptions) {
102
+ this.initializeRedis(redisOptions);
103
+ } else {
104
+ if (this.isShardMode) {
105
+ this.logger.error('❌ [REDIS][SHARD] No Redis client/options, but shard:true. Application will work WITHOUT caching!');
106
+ this.isRedisConnected = false;
107
+ } else {
108
+ this.logger.warn('🟠 [REDIS] No Redis client or options provided. Operating in In-Memory Cache mode only.');
109
+ this.isRedisConnected = false;
110
+ }
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Helper: Track redis error timestamp, and check if error count in interval exceeds tolerance.
116
+ * Jika error yang terjadi >= REDIS_ERROR_TOLERANCE_COUNT dalam REDIS_ERROR_TOLERANCE_INTERVAL_MS,
117
+ * barulah fallback ke In-Memory (isRedisConnected = false) -- KECUALI jika shard: true.
118
+ */
119
+ static _trackRedisError(err) {
120
+ const now = Date.now();
121
+
122
+ this.redisErrorTimestamps = (this.redisErrorTimestamps || []).filter((ts) => now - ts < REDIS_ERROR_TOLERANCE_INTERVAL_MS);
123
+ this.redisErrorTimestamps.push(now);
124
+
125
+ if (this.redisErrorTimestamps.length >= REDIS_ERROR_TOLERANCE_COUNT) {
126
+ if (this.isRedisConnected) {
127
+ // In shard mode, fallback is not allowed!
128
+ if (this.isShardMode) {
129
+ this.logger.error(
130
+ `❌ [REDIS][SHARD] ${this.redisErrorTimestamps.length} consecutive errors in ${
131
+ REDIS_ERROR_TOLERANCE_INTERVAL_MS / 1000
132
+ }s. SHARD MODE: Disabling cache (NO fallback), all queries go to DB. (Last error: ${err?.message})`
133
+ );
134
+ this.isRedisConnected = false;
135
+ // Do not schedule reconnect if redis is not supposed to fallback. Reconnect logic is fine.
136
+ this._scheduleReconnect();
137
+ } else {
138
+ this.logger.error(
139
+ `❌ [REDIS] ${this.redisErrorTimestamps.length} consecutive errors in ${
140
+ REDIS_ERROR_TOLERANCE_INTERVAL_MS / 1000
141
+ }s. Fallback to In-Memory Cache! (Last error: ${err?.message})`
142
+ );
143
+ this.isRedisConnected = false;
144
+ this._scheduleReconnect();
145
+ }
146
+ }
147
+
148
+ this.redisErrorTimestamps = [];
149
+ } else {
150
+ this.logger.warn(
151
+ `🟠 [REDIS] Error #${this.redisErrorTimestamps.length}/${REDIS_ERROR_TOLERANCE_COUNT} tolerated. (${err?.message})`
152
+ );
153
+ }
154
+ }
155
+
156
+ /**
157
+ * 🔌 Initializes the Redis connection if not already initialized.
158
+ * @param {string|Object} redisOptions - Redis connection string or options object
159
+ * @returns {Object} The Redis client instance
160
+ */
161
+ static initializeRedis(redisOptions) {
162
+ if (this.redis) return this.redis;
163
+
164
+ const Redis = require('ioredis');
165
+ this.lastRedisOpts = redisOptions;
166
+
167
+ // Check sharding now if not set yet (for runtime .initializeRedis case)
168
+ if (redisOptions && typeof redisOptions === 'object' && redisOptions.shard) {
169
+ this.isShardMode = true;
170
+ }
171
+
172
+ if (!redisOptions || (typeof redisOptions === 'string' && redisOptions.trim() === '')) {
173
+ if (this.isShardMode) {
174
+ this.logger.error('❌ [REDIS][SHARD] No Redis URL/options provided but shard:true. Will run without caching!');
175
+ this.isRedisConnected = false;
176
+ } else {
177
+ this.logger.warn('🟠 [REDIS] No Redis URL provided. Operating in In-Memory Cache mode only.');
178
+ this.isRedisConnected = false;
179
+ }
180
+ return null;
181
+ }
182
+
183
+ const retryStrategy = (times) => {
184
+ if (times > 5) {
185
+ if (this.isShardMode) {
186
+ this.logger.error(`❌ [REDIS][SHARD] Could not connect after ${times - 1} retries. Disabling cache (no fallback)!`);
187
+ } else {
188
+ this.logger.error(`❌ [REDIS] Could not connect after ${times - 1} retries. Falling back to In-Memory Cache.`);
189
+ }
190
+ return null;
191
+ }
192
+ const delay = Math.min(times * 500, 2000);
193
+ this.logger.warn(`🟠 [REDIS] Connection failed. Retrying in ${delay}ms (Attempt ${times})...`);
194
+ return delay;
195
+ };
196
+
197
+ const finalOptions =
198
+ typeof redisOptions === 'string'
199
+ ? { url: redisOptions, retryStrategy, lazyConnect: true }
200
+ : { maxRetriesPerRequest: 2, enableReadyCheck: true, retryStrategy, lazyConnect: true, ...redisOptions };
201
+
202
+ this.redis = new Redis(
203
+ typeof redisOptions === 'string' ? redisOptions : finalOptions,
204
+ typeof redisOptions === 'string' ? finalOptions : undefined
205
+ );
206
+
207
+ this.redis.connect().catch((err) => {
208
+ if (this.isShardMode) {
209
+ this.logger.error('❌ [REDIS][SHARD] Initial connection failed: ' + err.message);
210
+ } else {
211
+ this.logger.error('❌ [REDIS] Initial connection failed:', err.message);
212
+ }
213
+ });
214
+
215
+ this._setupRedisEventHandlers();
216
+ return this.redis;
217
+ }
218
+
219
+ /**
220
+ * 🔌 Sets up Redis event handlers
221
+ * @private
222
+ */
223
+ static _setupRedisEventHandlers() {
224
+ this.redis.on('connect', () => {
225
+ if (!this.isRedisConnected) {
226
+ this.logger.info('✅ [REDIS] Connection established. Switching to Redis Cache mode.');
227
+ }
228
+ this.isRedisConnected = true;
229
+
230
+ this.redisErrorTimestamps = [];
231
+
232
+ if (this.reconnectTimeout) {
233
+ clearTimeout(this.reconnectTimeout);
234
+ this.reconnectTimeout = null;
235
+ }
236
+ });
237
+
238
+ this.redis.on('error', (err) => {
239
+ if (err && (err.code === 'ECONNREFUSED' || err.message)) {
240
+ if (this.isShardMode) {
241
+ this.logger.warn(`🟠 [REDIS][SHARD] Error: ${err.message}`);
242
+ } else {
243
+ this.logger.warn(`🟠 [REDIS] Error: ${err.message}`);
244
+ }
245
+ }
246
+ });
247
+
248
+ this.redis.on('close', () => {
249
+ if (this.isRedisConnected) {
250
+ if (this.isShardMode) {
251
+ this.logger.error('❌ [REDIS][SHARD] Connection closed. Cache DISABLED (no fallback).');
252
+ } else {
253
+ this.logger.error('❌ [REDIS] Connection closed. Falling back to In-Memory Cache mode.');
254
+ }
255
+ }
256
+ this.isRedisConnected = false;
257
+ this._scheduleReconnect();
258
+ });
259
+ }
260
+
261
+ /**
262
+ * ⏱️ Schedules a reconnection attempt
263
+ * @private
264
+ */
265
+ static _scheduleReconnect() {
266
+ if (this.reconnectTimeout) return;
267
+
268
+ const sinceLast = Date.now() - this.lastAutoReconnectTs;
269
+ if (sinceLast < RECONNECT_DELAY_MINUTES * 60 * 1000) return;
270
+
271
+ this.lastAutoReconnectTs = Date.now();
272
+ if (this.isShardMode) {
273
+ this.logger.warn(`[REDIS][SHARD] Attempting auto-reconnect after ${RECONNECT_DELAY_MINUTES}min downtime...`);
274
+ } else {
275
+ this.logger.warn(`🟢 [REDIS] Attempting auto-reconnect after ${RECONNECT_DELAY_MINUTES}min downtime...`);
276
+ }
277
+
278
+ this.reconnectTimeout = setTimeout(() => {
279
+ this.reconnectTimeout = null;
280
+ this.initializeRedis(this.lastRedisOpts);
281
+ }, RECONNECT_DELAY_MINUTES * 60 * 1000);
282
+ }
283
+
284
+ /**
285
+ * 🔑 Generates a consistent, model-specific cache key from a query identifier.
286
+ * This ensures that the same query always produces the same key, preventing collisions.
287
+ * @param {string|Object} queryIdentifier - A unique string or a Sequelize query object.
288
+ * @returns {string} The final cache key, prefixed with the model's name (e.g., "User:{\"id\":1}").
289
+ */
290
+ static getCacheKey(queryIdentifier) {
291
+ const keyBody =
292
+ typeof queryIdentifier === 'string' ? queryIdentifier : jsonStringify(this.normalizeQueryOptions(queryIdentifier), this.logger);
293
+ return `${this.CACHE_VERSION}:${this.name}:${keyBody}`;
294
+ }
295
+
296
+ /**
297
+ * 🧽 Recursively normalizes a query options object to ensure deterministic key generation.
298
+ * It sorts keys alphabetically and handles Sequelize's Symbol-based operators to produce
299
+ * a consistent string representation for any given query.
300
+ * @param {*} data - The query options or part of the options to normalize.
301
+ * @returns {*} The normalized data.
302
+ */
303
+ static normalizeQueryOptions(data) {
304
+ if (!data || typeof data !== 'object') return data;
305
+ if (Array.isArray(data)) return data.map((item) => this.normalizeQueryOptions(item));
306
+ const normalized = {};
307
+ Object.keys(data)
308
+ .sort()
309
+ .forEach((key) => (normalized[key] = this.normalizeQueryOptions(data[key])));
310
+ Object.getOwnPropertySymbols(data).forEach((symbol) => {
311
+ const key = `$${symbol.toString().slice(7, -1)}`;
312
+ normalized[key] = this.normalizeQueryOptions(data[symbol]);
313
+ });
314
+ return normalized;
315
+ }
316
+
317
+ /**
318
+ * 📥 [HYBRID/SHARD ROUTER] Sets a value in the currently active cache engine.
319
+ * In shard mode, if Redis down, nothing is cached.
320
+ * @param {string|Object} cacheKeyOrQuery - The key or query object to store the data under.
321
+ * @param {*} data - The data to cache. Use `null` for negative caching.
322
+ * @param {number} [ttl=this.DEFAULT_TTL] - The time-to-live for the entry in milliseconds.
323
+ * @param {string[]} [tags=[]] - Cache tags (for sniper tag-based invalidation)
324
+ */
325
+ static async setCacheEntry(cacheKeyOrQuery, data, ttl, tags = []) {
326
+ const cacheKey = typeof cacheKeyOrQuery === 'string' ? cacheKeyOrQuery : this.getCacheKey(cacheKeyOrQuery);
327
+ const finalTtl = ttl || this.CACHE_TTL || this.DEFAULT_TTL;
328
+
329
+ if (this.isRedisConnected) {
330
+ await this._redisSetCacheEntry(cacheKey, data, finalTtl, tags);
331
+ } else if (!this.isShardMode) {
332
+ // NON-shard only
333
+ this._mapSetCacheEntry(cacheKey, data, finalTtl);
334
+ } // else: shard mode, Redis is down, DO NOT cache
335
+ }
336
+
337
+ /**
338
+ * 📤 [HYBRID/SHARD ROUTER] Retrieves a value from the currently active cache engine.
339
+ * If in shard mode and Redis is down, always miss (direct to DB).
340
+ * @param {string|Object} cacheKeyOrQuery - The key or query object of the item to retrieve.
341
+ * @returns {Promise<{hit: boolean, data: *|undefined}>} An object indicating if the cache was hit and the retrieved data.
342
+ */
343
+ static async getCachedEntry(cacheKeyOrQuery, includeOptions) {
344
+ const cacheKey = typeof cacheKeyOrQuery === 'string' ? cacheKeyOrQuery : this.getCacheKey(cacheKeyOrQuery);
345
+ if (this.isRedisConnected) {
346
+ return this._redisGetCachedEntry(cacheKey, includeOptions);
347
+ } else if (!this.isShardMode) {
348
+ // fallback only if not sharding
349
+ return this._mapGetCachedEntry(cacheKey, includeOptions);
350
+ }
351
+ // SHARD MODE: no local fallback
352
+ return { hit: false, data: undefined };
353
+ }
354
+
355
+ /**
356
+ * 🗑️ [HYBRID/SHARD ROUTER] Deletes an entry from the currently active cache engine.
357
+ * In shard mode, if Redis down, delete does nothing (cache already dead).
358
+ * @param {string|Object} keys - The query identifier used to generate the key to delete.
359
+ */
360
+ static async clearCache(keys) {
361
+ const cacheKey = typeof keys === 'string' ? keys : this.getCacheKey(keys);
362
+ if (this.isRedisConnected) {
363
+ await this._redisClearCache(cacheKey);
364
+ } else if (!this.isShardMode) {
365
+ this._mapClearCache(cacheKey);
366
+ }
367
+ }
368
+
369
+ /**
370
+ * 🔴 (Private) Sets a cache entry specifically in Redis, supporting tags for sniper invalidation.
371
+ */
372
+ static async _redisSetCacheEntry(cacheKey, data, ttl, tags = []) {
373
+ try {
374
+ let plainData = data;
375
+ if (data && typeof data.toJSON === 'function') {
376
+ plainData = data.toJSON();
377
+ } else if (Array.isArray(data)) {
378
+ plainData = data.map((item) => (item && typeof item.toJSON === 'function' ? item.toJSON() : item));
379
+ }
380
+
381
+ const valueToStore = plainData === null ? NEGATIVE_CACHE_PLACEHOLDER : safeStringify(plainData, this.logger);
382
+
383
+ const multi = this.redis.multi();
384
+ multi.set(cacheKey, valueToStore, 'PX', ttl);
385
+ for (const tag of tags) {
386
+ multi.sadd(tag, cacheKey);
387
+ }
388
+ await multi.exec();
389
+ this.cacheStats.sets++;
390
+ } catch (err) {
391
+ this._trackRedisError(err);
392
+ }
393
+ }
394
+
395
+ /**
396
+ * 🔴 (Private) Retrieves and deserializes an entry specifically from Redis.
397
+ */
398
+ static async _redisGetCachedEntry(cacheKey, includeOptions) {
399
+ try {
400
+ const result = await this.redis.get(cacheKey);
401
+ if (result === null || result === undefined) return { hit: false, data: undefined };
402
+
403
+ this.cacheStats.redisHits++;
404
+ if (result === NEGATIVE_CACHE_PLACEHOLDER) return { hit: true, data: null };
405
+
406
+ const parsedData = safeParse(result, this.logger);
407
+
408
+ if (typeof parsedData !== 'object' || parsedData === null) {
409
+ return { hit: true, data: parsedData };
410
+ }
411
+
412
+ const includeAsArray = includeOptions ? (Array.isArray(includeOptions) ? includeOptions : [includeOptions]) : null;
413
+
414
+ if (Array.isArray(parsedData)) {
415
+ const instances = this.bulkBuild(parsedData, {
416
+ isNewRecord: false,
417
+ include: includeAsArray,
418
+ });
419
+ return { hit: true, data: instances };
420
+ } else {
421
+ const instance = this.build(parsedData, {
422
+ isNewRecord: false,
423
+ include: includeAsArray,
424
+ });
425
+ return { hit: true, data: instance };
426
+ }
427
+ } catch (err) {
428
+ this._trackRedisError(err);
429
+ return { hit: false, data: undefined };
430
+ }
431
+ }
432
+
433
+ /**
434
+ * 🔴 (Private) Deletes an entry specifically from Redis.
435
+ */
436
+ static async _redisClearCache(cacheKey) {
437
+ try {
438
+ await this.redis.del(cacheKey);
439
+ this.cacheStats.clears++;
440
+ } catch (err) {
441
+ this._trackRedisError(err);
442
+ }
443
+ }
444
+
445
+ /**
446
+ * 🎯 [SNIPER] Invalidates cache entries by tags in Redis.
447
+ */
448
+ static async invalidateByTags(tags) {
449
+ if (!this.isRedisConnected || !Array.isArray(tags) || tags.length === 0) return;
450
+
451
+ try {
452
+ const keysToDelete = await this.redis.sunion(tags);
453
+
454
+ if (keysToDelete && keysToDelete.length > 0) {
455
+ this.logger.info(`🎯 [SNIPER] Invalidating ${keysToDelete.length} keys for tags: ${tags.join(', ')}`);
456
+
457
+ await this.redis.multi().del(keysToDelete).del(tags).exec();
458
+ } else {
459
+ await this.redis.del(tags);
460
+ }
461
+ } catch (err) {
462
+ this._trackRedisError(err);
463
+ }
464
+ }
465
+
466
+ /**
467
+ * 🗺️ (Private) Sets a cache entry specifically in the in-memory Map.
468
+ * DISABLED in shard mode.
469
+ */
470
+ static _mapSetCacheEntry(cacheKey, data, ttl) {
471
+ if (this.isShardMode) return;
472
+
473
+ if (data === null) {
474
+ this.localNegativeCache.add(cacheKey);
475
+ this.localCache.delete(cacheKey);
476
+ } else {
477
+ let plainData = data;
478
+ if (data && typeof data.toJSON === 'function') {
479
+ plainData = data.toJSON();
480
+ } else if (Array.isArray(data)) {
481
+ plainData = data.map((item) => (item && typeof item.toJSON === 'function' ? item.toJSON() : item));
482
+ }
483
+
484
+ const dataCopy = plainData === null ? NEGATIVE_CACHE_PLACEHOLDER : safeStringify(plainData, this.logger);
485
+
486
+ this.localCache.set(cacheKey, { data: dataCopy, expires: Date.now() + ttl });
487
+ this.localNegativeCache.delete(cacheKey);
488
+ }
489
+ this.cacheStats.sets++;
490
+ }
491
+
492
+ /**
493
+ * 🗺️ (Private) Retrieves an entry specifically from the in-memory Map.
494
+ * DISABLED in shard mode.
495
+ */
496
+ static _mapGetCachedEntry(cacheKey, includeOptions) {
497
+ if (this.isShardMode) return { hit: false, data: undefined }; // DISABLED in shard mode
498
+
499
+ if (this.localNegativeCache.has(cacheKey)) {
500
+ this.cacheStats.mapHits++;
501
+ return { hit: true, data: null };
502
+ }
503
+
504
+ const entry = this.localCache.get(cacheKey);
505
+ if (entry && entry.expires > Date.now()) {
506
+ this.cacheStats.mapHits++;
507
+
508
+ const dataRaw = entry.data;
509
+
510
+ let parsedData;
511
+ if (typeof dataRaw === 'string') {
512
+ parsedData = safeParse(dataRaw, this.logger);
513
+ } else {
514
+ parsedData = dataRaw;
515
+ }
516
+
517
+ if (typeof parsedData !== 'object' || parsedData === null) {
518
+ return { hit: true, data: parsedData };
519
+ }
520
+
521
+ const includeAsArray = includeOptions ? (Array.isArray(includeOptions) ? includeOptions : [includeOptions]) : null;
522
+
523
+ if (Array.isArray(parsedData)) {
524
+ const instances = this.bulkBuild(parsedData, {
525
+ isNewRecord: false,
526
+ include: includeAsArray,
527
+ });
528
+ return { hit: true, data: instances };
529
+ } else {
530
+ const instance = this.build(parsedData, {
531
+ isNewRecord: false,
532
+ include: includeAsArray,
533
+ });
534
+ return { hit: true, data: instance };
535
+ }
536
+ }
537
+
538
+ if (entry) this.localCache.delete(cacheKey);
539
+ return { hit: false, data: undefined };
540
+ }
541
+
542
+ /**
543
+ * 🗺️ (Private) Deletes an entry specifically from the in-memory Map.
544
+ * DISABLED in shard mode.
545
+ */
546
+ static _mapClearCache(cacheKey) {
547
+ if (this.isShardMode) return; // DISABLED in shard mode
548
+ this.localCache.delete(cacheKey);
549
+ this.localNegativeCache.delete(cacheKey);
550
+ this.cacheStats.clears++;
551
+ }
552
+
553
+ /**
554
+ * 🗺️ (Private) Clears all in-memory cache entries for this model.
555
+ * Used as a fallback when Redis is disconnected.
556
+ * DISABLED in shard mode.
557
+ */
558
+ static _mapClearAllModelCache() {
559
+ if (this.isShardMode) return; // DISABLED in shard mode
560
+ const prefix = `${this.CACHE_VERSION}:${this.name}:`;
561
+ let cleared = 0;
562
+
563
+ for (const key of this.localCache.keys()) {
564
+ if (key.startsWith(prefix)) {
565
+ this.localCache.delete(key);
566
+ cleared++;
567
+ }
568
+ }
569
+ for (const key of this.localNegativeCache.keys()) {
570
+ if (key.startsWith(prefix)) {
571
+ this.localNegativeCache.delete(key);
572
+ cleared++;
573
+ }
574
+ }
575
+
576
+ if (cleared > 0) {
577
+ this.logger.info(`♻️ [MAP CACHE] Cleared ${cleared} in-memory entries for ${this.name} (Redis fallback).`);
578
+ }
579
+ }
580
+
581
+ /**
582
+ * 🔄 (Internal) Standardizes various query object formats into a consistent Sequelize options object.
583
+ * This helper ensures that `getCache({ id: 1 })` and `getCache({ where: { id: 1 } })` are treated identically.
584
+ */
585
+ static _normalizeFindOptions(options) {
586
+ if (!options || typeof options !== 'object' || Object.keys(options).length === 0) return { where: {} };
587
+ if (options.where) {
588
+ const sequelizeOptions = { ...options };
589
+ delete sequelizeOptions.cacheTags;
590
+ delete sequelizeOptions.noCache;
591
+ return sequelizeOptions;
592
+ }
593
+ const knownOptions = ['order', 'limit', 'attributes', 'include', 'group', 'having'];
594
+
595
+ const cacheSpecificOptions = ['cacheTags', 'noCache'];
596
+ const whereClause = {};
597
+ const otherOptions = {};
598
+ for (const key in options) {
599
+ if (cacheSpecificOptions.includes(key)) {
600
+ continue;
601
+ }
602
+ if (knownOptions.includes(key)) otherOptions[key] = options[key];
603
+ else whereClause[key] = options[key];
604
+ }
605
+ return { where: whereClause, ...otherOptions };
606
+ }
607
+
608
+ /**
609
+ * 📦 fetches a single record from the cache, falling back to the database on a miss.
610
+ * In SHARD mode/fallback, cache miss triggers instant DB query, no in-memory caching.
611
+ */
612
+ static async getCache(keys, options = {}) {
613
+ if (options.noCache) {
614
+ const filteredOpts = { ...options };
615
+ delete filteredOpts.cacheTags;
616
+ return this.findOne(this._normalizeFindOptions(keys));
617
+ }
618
+ if (!keys || Array.isArray(keys)) {
619
+ if (Array.isArray(keys)) {
620
+ const pk = this.primaryKeyAttribute;
621
+ return this.findAll({ where: { [pk]: keys.map((m) => m[pk]) } });
622
+ }
623
+ return null;
624
+ }
625
+ const normalizedOptions = this._normalizeFindOptions(keys);
626
+ if (!normalizedOptions.where || Object.keys(normalizedOptions.where).length === 0) return null;
627
+ const cacheKey = this.getCacheKey(normalizedOptions);
628
+
629
+ const cacheResult = await this.getCachedEntry(cacheKey, normalizedOptions.include);
630
+ if (cacheResult.hit) {
631
+ return cacheResult.data;
632
+ }
633
+
634
+ this.cacheStats.misses++;
635
+
636
+ if (this.pendingQueries.has(cacheKey)) {
637
+ return this.pendingQueries.get(cacheKey);
638
+ }
639
+
640
+ const queryPromise = this.findOne(normalizedOptions)
641
+ .then((record) => {
642
+ // Only cache if allowed (no cache in shard/failover unless redis is up)
643
+ if (this.isRedisConnected || !this.isShardMode) {
644
+ const tags = [`${this.name}`];
645
+ if (record) {
646
+ const pk = this.primaryKeyAttribute;
647
+ tags.push(`${this.name}:${pk}:${record[pk]}`);
648
+ }
649
+ this.setCacheEntry(cacheKey, record, undefined, tags);
650
+ }
651
+ return record;
652
+ })
653
+ .finally(() => {
654
+ this.pendingQueries.delete(cacheKey);
655
+ });
656
+
657
+ this.pendingQueries.set(cacheKey, queryPromise);
658
+ return queryPromise;
659
+ }
660
+
661
+ /**
662
+ * 📦 Fetches an array of records from the cache, falling back to the database.
663
+ */
664
+ static async getAllCache(options = {}) {
665
+ const { cacheTags, noCache, ...queryOptions } = options || {};
666
+
667
+ if (noCache) {
668
+ return this.findAll(this._normalizeFindOptions(queryOptions));
669
+ }
670
+ const normalizedOptions = this._normalizeFindOptions(queryOptions);
671
+ const cacheKey = this.getCacheKey(normalizedOptions);
672
+
673
+ const cacheResult = await this.getCachedEntry(cacheKey, normalizedOptions.include);
674
+ if (cacheResult.hit) {
675
+ return cacheResult.data;
676
+ }
677
+
678
+ this.cacheStats.misses++;
679
+
680
+ if (this.pendingQueries.has(cacheKey)) {
681
+ return this.pendingQueries.get(cacheKey);
682
+ }
683
+
684
+ const queryPromise = this.findAll(normalizedOptions)
685
+ .then((records) => {
686
+ // Only cache if allowed
687
+ if (this.isRedisConnected || !this.isShardMode) {
688
+ const tags = [`${this.name}`];
689
+
690
+ if (Array.isArray(cacheTags)) {
691
+ tags.push(...cacheTags);
692
+ }
693
+ this.setCacheEntry(cacheKey, records, undefined, tags);
694
+ }
695
+ return records;
696
+ })
697
+ .finally(() => {
698
+ this.pendingQueries.delete(cacheKey);
699
+ });
700
+
701
+ this.pendingQueries.set(cacheKey, queryPromise);
702
+ return queryPromise;
703
+ }
704
+
705
+ /**
706
+ * 📦 Attempts to find a record based on `options.where`. If found, it returns the cached or DB record.
707
+ */
708
+ static async findOrCreateWithCache(options) {
709
+ if (!options || !options.where) {
710
+ throw new Error("findOrCreateWithCache requires a 'where' option.");
711
+ }
712
+
713
+ const { cacheTags, noCache, ...findOrCreateOptions } = options;
714
+
715
+ const cacheKey = this.getCacheKey(options.where);
716
+ const cacheResult = await this.getCachedEntry(cacheKey);
717
+ if (cacheResult.hit && cacheResult.data) {
718
+ return [cacheResult.data, false];
719
+ }
720
+ this.cacheStats.misses++;
721
+ if (this.pendingQueries.has(cacheKey)) {
722
+ return this.pendingQueries.get(cacheKey);
723
+ }
724
+ const findOrCreatePromise = this.findOrCreate(findOrCreateOptions)
725
+ .then(([instance, created]) => {
726
+ // Only cache if allowed
727
+ if (this.isRedisConnected || !this.isShardMode) {
728
+ const tags = [`${this.name}`];
729
+ if (instance) {
730
+ const pk = this.primaryKeyAttribute;
731
+ tags.push(`${this.name}:${pk}:${instance[pk]}`);
732
+ }
733
+ this.setCacheEntry(cacheKey, instance, undefined, tags);
734
+ }
735
+ return [instance, created];
736
+ })
737
+ .finally(() => {
738
+ this.pendingQueries.delete(cacheKey);
739
+ });
740
+
741
+ this.pendingQueries.set(cacheKey, findOrCreatePromise);
742
+ return findOrCreatePromise;
743
+ }
744
+
745
+ /**
746
+ * 📦 Fetches the count of records matching the query from the cache, falling back to the database.
747
+ */
748
+ static async countWithCache(options = {}, ttl = 5 * 60 * 1000) {
749
+ const { cacheTags, noCache, ...countOptions } = options || {};
750
+
751
+ const cacheKeyOptions = { queryType: 'count', ...countOptions };
752
+ const cacheKey = this.getCacheKey(cacheKeyOptions);
753
+ const cacheResult = await this.getCachedEntry(cacheKey);
754
+ if (cacheResult.hit) {
755
+ return cacheResult.data;
756
+ }
757
+ this.cacheStats.misses++;
758
+ const count = await this.count(countOptions);
759
+
760
+ // Only cache if allowed
761
+ if (this.isRedisConnected || !this.isShardMode) {
762
+ const tags = [`${this.name}`];
763
+ this.setCacheEntry(cacheKey, count, ttl, tags);
764
+ }
765
+ return count;
766
+ }
767
+
768
+ /**
769
+ * 📦 An instance method that saves the current model instance to the database and then
770
+ * intelligently updates its corresponding entry in the active cache.
771
+ */
772
+ async saveAndUpdateCache() {
773
+ const savedInstance = await this.save();
774
+ const pk = this.constructor.primaryKeyAttribute;
775
+ const pkValue = this[pk];
776
+ if (pkValue && (this.constructor.isRedisConnected || !this.constructor.isShardMode)) {
777
+ const cacheKey = this.constructor.getCacheKey({ [pk]: pkValue });
778
+ const tags = [`${this.constructor.name}`, `${this.constructor.name}:${pk}:${pkValue}`];
779
+ await this.constructor.setCacheEntry(cacheKey, savedInstance, undefined, tags);
780
+ }
781
+ return savedInstance;
782
+ }
783
+
784
+ /**
785
+ * 📦 A convenience alias for `clearCache`. In the hybrid system, positive and negative
786
+ * cache entries for the same key are managed together, so clearing one clears the other.
787
+ */
788
+ static async clearNegativeCache(keys) {
789
+ return this.clearCache(keys);
790
+ }
791
+
792
+ /**
793
+ * 📦 Fetches a raw aggregate result from the cache, falling back to the database.
794
+ */
795
+ static async aggregateWithCache(options = {}, cacheOptions = {}) {
796
+ const { cacheTags, noCache, ...queryOptions } = options || {};
797
+ const { ttl = 5 * 60 * 1000 } = cacheOptions || {};
798
+ const cacheKeyOptions = { queryType: 'aggregate', ...queryOptions };
799
+ const cacheKey = this.getCacheKey(cacheKeyOptions);
800
+
801
+ const cacheResult = await this.getCachedEntry(cacheKey);
802
+ if (cacheResult.hit) {
803
+ return cacheResult.data;
804
+ }
805
+
806
+ this.cacheStats.misses++;
807
+
808
+ const result = await this.findAll(queryOptions);
809
+
810
+ // Only cache if allowed
811
+ if (this.isRedisConnected || !this.isShardMode) {
812
+ const tags = [`${this.name}`];
813
+ if (Array.isArray(cacheTags)) tags.push(...cacheTags);
814
+ this.setCacheEntry(cacheKey, result, ttl, tags);
815
+ }
816
+
817
+ return result;
818
+ }
819
+
820
+ /**
821
+ * 🪝 Attaches Sequelize lifecycle hooks (`afterSave`, `afterDestroy`, etc.) to this model.
822
+ * In shard mode, fallback invalidation does nothing.
823
+ */
824
+ static initializeCacheHooks() {
825
+ if (!this.redis) {
826
+ this.logger.warn(`❌ Redis not initialized for model ${this.name}. Cache hooks will not be attached.`);
827
+ return;
828
+ }
829
+
830
+ /**
831
+ * Logika setelah data disimpan (Create atau Update)
832
+ */
833
+ const afterSaveLogic = async (instance) => {
834
+ const modelClass = instance.constructor;
835
+
836
+ if (modelClass.isRedisConnected) {
837
+ const tagsToInvalidate = [`${modelClass.name}`];
838
+ const pk = modelClass.primaryKeyAttribute;
839
+ tagsToInvalidate.push(`${modelClass.name}:${pk}:${instance[pk]}`);
840
+
841
+ if (Array.isArray(modelClass.customInvalidationTags)) {
842
+ tagsToInvalidate.push(...modelClass.customInvalidationTags);
843
+ }
844
+ await modelClass.invalidateByTags(tagsToInvalidate);
845
+ } else if (!modelClass.isShardMode) {
846
+ modelClass._mapClearAllModelCache();
847
+ }
848
+ };
849
+
850
+ /**
851
+ * Logika setelah data dihapus
852
+ */
853
+ const afterDestroyLogic = async (instance) => {
854
+ const modelClass = instance.constructor;
855
+
856
+ if (modelClass.isRedisConnected) {
857
+ const tagsToInvalidate = [`${modelClass.name}`];
858
+ const pk = modelClass.primaryKeyAttribute;
859
+ tagsToInvalidate.push(`${modelClass.name}:${pk}:${instance[pk]}`);
860
+
861
+ if (Array.isArray(modelClass.customInvalidationTags)) {
862
+ tagsToInvalidate.push(...modelClass.customInvalidationTags);
863
+ }
864
+ await modelClass.invalidateByTags(tagsToInvalidate);
865
+ } else if (!modelClass.isShardMode) {
866
+ modelClass._mapClearAllModelCache();
867
+ }
868
+ };
869
+
870
+ const afterBulkLogic = async () => {
871
+ if (this.isRedisConnected) {
872
+ await this.invalidateByTags([`${this.name}`]);
873
+ } else if (!this.isShardMode) {
874
+ this._mapClearAllModelCache();
875
+ }
876
+ };
877
+
878
+ this.addHook('afterSave', afterSaveLogic);
879
+ this.addHook('afterDestroy', afterDestroyLogic);
880
+ this.addHook('afterBulkCreate', afterBulkLogic);
881
+ this.addHook('afterBulkUpdate', afterBulkLogic);
882
+ this.addHook('afterBulkDestroy', afterBulkLogic);
883
+ }
884
+
885
+ /**
886
+ * 🪝 Iterates through all registered Sequelize models and attaches the cache hooks
887
+ * to any model that extends `KythiaModel`. This should be called once after all models
888
+ * have been defined and loaded.
889
+ */
890
+ static attachHooksToAllModels(sequelizeInstance, client) {
891
+ if (!this.redis) {
892
+ this.logger.error('❌ Cannot attach hooks because Redis is not initialized.');
893
+ return;
894
+ }
895
+
896
+ for (const modelName in sequelizeInstance.models) {
897
+ const model = sequelizeInstance.models[modelName];
898
+ if (model.prototype instanceof KythiaModel) {
899
+ model.client = client;
900
+ this.logger.info(`⚙️ Attaching hooks to ${model.name}`);
901
+ model.initializeCacheHooks();
902
+ }
903
+ }
904
+ }
905
+
906
+ /**
907
+ * 🔄 Touches (updates the timestamp of) a parent model instance.
908
+ */
909
+ static async touchParent(childInstance, foreignKeyField, ParentModel, timestampField = 'updatedAt') {
910
+ if (!childInstance || !childInstance[foreignKeyField]) {
911
+ return;
912
+ }
913
+
914
+ try {
915
+ const parentPk = ParentModel.primaryKeyAttribute;
916
+ const parent = await ParentModel.findByPk(childInstance[foreignKeyField]);
917
+
918
+ if (parent) {
919
+ parent.changed(timestampField, true);
920
+ await parent.save({ fields: [timestampField] });
921
+ this.logger.info(`🔄 Touched parent ${ParentModel.name} #${parent[parentPk]} due to change in ${this.name}.`);
922
+ }
923
+ } catch (e) {
924
+ this.logger.error(`🔄 Failed to touch parent ${ParentModel.name}`, e);
925
+ }
926
+ }
927
+
928
+ /**
929
+ * 🔄 Configures automatic parent touching on model hooks.
930
+ */
931
+ static setupParentTouch(foreignKeyField, ParentModel, timestampField = 'updatedAt') {
932
+ const touchHandler = (instance) => {
933
+ return this.touchParent(instance, foreignKeyField, ParentModel, timestampField);
934
+ };
935
+
936
+ const bulkTouchHandler = (instances) => {
937
+ if (instances && instances.length > 0) {
938
+ return this.touchParent(instances[0], foreignKeyField, ParentModel, timestampField);
939
+ }
940
+ };
941
+
942
+ this.addHook('afterSave', touchHandler);
943
+ this.addHook('afterDestroy', touchHandler);
944
+ this.addHook('afterBulkCreate', bulkTouchHandler);
945
+ }
946
+ }
947
+
948
+ module.exports = KythiaModel;