kythia-core 0.9.4-beta.3 → 0.10.1-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.
@@ -4,7 +4,7 @@
4
4
  * @file src/database/KythiaModel.js
5
5
  * @copyright © 2025 kenndeclouv
6
6
  * @assistant chaa & graa
7
- * @version 0.9.4-beta.3
7
+ * @version 0.10.0-beta
8
8
  *
9
9
  * @description
10
10
  * Caching layer for Sequelize Models, now sharding-aware. When config.db.redis.shard === true,
@@ -20,8 +20,9 @@
20
20
  */
21
21
 
22
22
  const jsonStringify = require('json-stable-stringify');
23
- const { Model } = require('sequelize');
23
+ const { Model, DataTypes } = require('sequelize');
24
24
  const { LRUCache } = require('lru-cache');
25
+ const Utils = require('sequelize').Utils;
25
26
 
26
27
  const NEGATIVE_CACHE_PLACEHOLDER = '__KYTHIA_NEGATIVE_CACHE__';
27
28
  const RECONNECT_DELAY_MINUTES = 3;
@@ -30,1014 +31,1528 @@ const REDIS_ERROR_TOLERANCE_COUNT = 3;
30
31
  const REDIS_ERROR_TOLERANCE_INTERVAL_MS = 10 * 1000;
31
32
 
32
33
  function safeStringify(obj, logger) {
33
- try {
34
- return JSON.stringify(obj, (key, value) => (typeof value === 'bigint' ? value.toString() : value));
35
- } catch (err) {
36
- (logger || console).error(`❌ [SAFE STRINGIFY] Failed: ${err.message}`);
37
- return '{}';
38
- }
34
+ try {
35
+ return JSON.stringify(obj, (value) =>
36
+ typeof value === 'bigint' ? value.toString() : value,
37
+ );
38
+ } catch (err) {
39
+ (logger || console).error(`❌ [SAFE STRINGIFY] Failed: ${err.message}`);
40
+ return '{}';
41
+ }
39
42
  }
40
43
 
41
44
  function safeParse(str, logger) {
42
- try {
43
- return JSON.parse(str);
44
- } catch {
45
- (logger || console).warn('⚠️ [SAFE PARSE] Invalid JSON data, returning null');
46
- return null;
47
- }
45
+ try {
46
+ return JSON.parse(str);
47
+ } catch {
48
+ (logger || console).warn(
49
+ '⚠️ [SAFE PARSE] Invalid JSON data, returning null',
50
+ );
51
+ return null;
52
+ }
48
53
  }
49
54
 
50
55
  class KythiaModel extends Model {
51
- static client;
52
- static redis;
53
- static isRedisConnected = false;
54
- static logger = console;
55
- static config = {};
56
- static CACHE_VERSION = '1.0.0';
57
-
58
- static localCache = new LRUCache({ max: 1000 });
59
- static localNegativeCache = new Set();
60
- static MAX_LOCAL_CACHE_SIZE = 1000;
61
- static DEFAULT_TTL = 60 * 60 * 1000;
62
-
63
- static lastRedisOpts = null;
64
- static reconnectTimeout = null;
65
- static lastAutoReconnectTs = 0;
66
-
67
- static pendingQueries = new Map();
68
- static cacheStats = { redisHits: 0, mapHits: 0, misses: 0, sets: 0, clears: 0, errors: 0 };
69
-
70
- static redisErrorTimestamps = [];
71
-
72
- static isShardMode = false;
73
-
74
- static _redisFallbackURLs = [];
75
- static _redisCurrentIndex = 0;
76
- static _redisFailedIndexes = new Set();
77
- static _justFailedOver = false;
78
-
79
- /**
80
- * 💉 Injects core dependencies into the KythiaModel class.
81
- * This must be called once at application startup before any models are loaded.
82
- * @param {Object} dependencies - The dependencies to inject
83
- * @param {Object} dependencies.logger - The logger instance
84
- * @param {Object} dependencies.config - The application config object
85
- * @param {Object} [dependencies.redis] - Optional Redis client instance
86
- * @param {Object|Array|string} [dependencies.redisOptions] - Redis connection options if not providing a client.
87
- * Can now be string (URL), object (ioredis options), or array of URLs/options for fallback.
88
- */
89
- static setDependencies({ logger, config, redis, redisOptions }) {
90
- if (!logger || !config) {
91
- throw new Error('KythiaModel.setDependencies requires logger and config');
92
- }
93
-
94
- this.logger = logger;
95
- this.config = config;
96
- this.CACHE_VERSION = config.db?.redisCacheVersion || '1.0.0';
97
-
98
- this.isShardMode = !!config?.db?.redis?.shard || false;
99
- if (this.isShardMode) {
100
- this.logger.info('🟣 [REDIS][SHARD] Detected redis sharding mode (shard: true). Local fallback cache DISABLED!');
101
- }
102
-
103
- if (Array.isArray(redisOptions)) {
104
- this._redisFallbackURLs = redisOptions.slice();
105
- } else if (typeof redisOptions === 'string') {
106
- this._redisFallbackURLs = redisOptions.split(',').map((url) => url.trim());
107
- } else if (redisOptions && typeof redisOptions === 'object' && Array.isArray(redisOptions.urls)) {
108
- this._redisFallbackURLs = redisOptions.urls.slice();
109
- } else if (redisOptions) {
110
- this._redisFallbackURLs = [redisOptions];
111
- } else {
112
- this._redisFallbackURLs = [];
113
- }
114
-
115
- this._redisCurrentIndex = 0;
116
-
117
- if (redis) {
118
- this.redis = redis;
119
- this.isRedisConnected = redis.status === 'ready';
120
- } else if (this._redisFallbackURLs.length > 0) {
121
- this.initializeRedis();
122
- } else {
123
- if (this.isShardMode) {
124
- this.logger.error('❌ [REDIS][SHARD] No Redis client/options, but shard:true. Application will work WITHOUT caching!');
125
- this.isRedisConnected = false;
126
- } else {
127
- this.logger.warn('🟠 [REDIS] No Redis client or options provided. Operating in In-Memory Cache mode only.');
128
- this.isRedisConnected = false;
129
- }
130
- }
131
- }
132
-
133
- /**
134
- * Helper: Track redis error timestamp, and check if error count in interval exceeds tolerance.
135
- * Jika error yang terjadi >= REDIS_ERROR_TOLERANCE_COUNT dalam REDIS_ERROR_TOLERANCE_INTERVAL_MS,
136
- * barulah coba connect ke redis berikutnya (multi redis), jika tidak ada, baru fallback ke In-Memory (isRedisConnected = false)
137
- * -- KECUALI jika shard: true.
138
- */
139
- static _trackRedisError(err) {
140
- const now = Date.now();
141
-
142
- this.redisErrorTimestamps = (this.redisErrorTimestamps || []).filter((ts) => now - ts < REDIS_ERROR_TOLERANCE_INTERVAL_MS);
143
- this.redisErrorTimestamps.push(now);
144
-
145
- if (this.redisErrorTimestamps.length >= REDIS_ERROR_TOLERANCE_COUNT) {
146
- if (this.isRedisConnected) {
147
- const triedFallback = this._tryRedisFailover();
148
- if (triedFallback) {
149
- this.logger.warn(`[REDIS] Error tolerance reached, switching to NEXT Redis failover...`);
150
- } else if (this.isShardMode) {
151
- this.logger.error(
152
- `❌ [REDIS][SHARD] ${this.redisErrorTimestamps.length} consecutive errors in ${
153
- REDIS_ERROR_TOLERANCE_INTERVAL_MS / 1000
154
- }s. SHARD MODE: Disabling cache (NO fallback), all queries go to DB. (Last error: ${err?.message})`
155
- );
156
- this.isRedisConnected = false;
157
- this._scheduleReconnect();
158
- } else {
159
- this.logger.error(
160
- `❌ [REDIS] ${this.redisErrorTimestamps.length} consecutive errors in ${
161
- REDIS_ERROR_TOLERANCE_INTERVAL_MS / 1000
162
- }s. All Redis exhausted, fallback to In-Memory Cache! (Last error: ${err?.message})`
163
- );
164
- this.isRedisConnected = false;
165
- this._scheduleReconnect();
166
- }
167
- }
168
-
169
- this.redisErrorTimestamps = [];
170
- } else {
171
- this.logger.warn(
172
- `🟠 [REDIS] Error #${this.redisErrorTimestamps.length}/${REDIS_ERROR_TOLERANCE_COUNT} tolerated. (${err?.message})`
173
- );
174
- }
175
- }
176
-
177
- /**
178
- * Coba switch ke redis URL berikutnya jika ada. Return true jika switching, false jika tidak ada lagi.
179
- * PRIVATE.
180
- */
181
- static _tryRedisFailover() {
182
- if (!Array.isArray(this._redisFallbackURLs) || this._redisFallbackURLs.length < 2) {
183
- return false;
184
- }
185
- const prevIndex = this._redisCurrentIndex;
186
- if (this._redisCurrentIndex + 1 < this._redisFallbackURLs.length) {
187
- this._redisCurrentIndex++;
188
- this.logger.warn(
189
- `[REDIS][FAILOVER] Trying to switch Redis connection from url index ${prevIndex} to ${this._redisCurrentIndex}`
190
- );
191
-
192
- this._justFailedOver = true;
193
-
194
- this._closeCurrentRedis();
195
- this.initializeRedis();
196
- return true;
197
- }
198
- return false;
199
- }
200
-
201
- /**
202
- * Close the current Redis (if exists).
203
- * PRIVATE.
204
- */
205
- static _closeCurrentRedis() {
206
- if (this.redis && typeof this.redis.quit === 'function') {
207
- try {
208
- this.redis.quit();
209
- } catch (e) {}
210
- }
211
- this.redis = undefined;
212
- this.isRedisConnected = false;
213
- }
214
-
215
- /**
216
- * 🔌 Initializes the Redis connection if not already initialized.
217
- * (Versi ini MENGHAPUS lazyConnect dan _attemptConnection untuk fix race condition)
218
- */
219
- static initializeRedis(redisOptions) {
220
- if (redisOptions) {
221
- if (Array.isArray(redisOptions)) {
222
- this._redisFallbackURLs = redisOptions.slice();
223
- this._redisCurrentIndex = 0;
224
- } else if (redisOptions && typeof redisOptions === 'object' && Array.isArray(redisOptions.urls)) {
225
- this._redisFallbackURLs = redisOptions.urls.slice();
226
- this._redisCurrentIndex = 0;
227
- } else {
228
- this._redisFallbackURLs = [redisOptions];
229
- this._redisCurrentIndex = 0;
230
- }
231
- }
232
-
233
- if (!Array.isArray(this._redisFallbackURLs) || this._redisFallbackURLs.length === 0) {
234
- if (this.isShardMode) {
235
- this.logger.error(' [REDIS][SHARD] No Redis URL/options provided but shard:true. Will run without caching!');
236
- this.isRedisConnected = false;
237
- } else {
238
- this.logger.warn('🟠 [REDIS] No Redis client or options provided. Operating in In-Memory Cache mode only.');
239
- this.isRedisConnected = false;
240
- }
241
- return null;
242
- }
243
-
244
- const Redis = require('ioredis');
245
- this.lastRedisOpts = Array.isArray(this._redisFallbackURLs) ? this._redisFallbackURLs.slice() : [this._redisFallbackURLs];
246
-
247
- if (this.redis) return this.redis;
248
-
249
- const opt = this._redisFallbackURLs[this._redisCurrentIndex];
250
-
251
- if (opt && typeof opt === 'object' && opt.shard) {
252
- this.isShardMode = true;
253
- }
254
-
255
- let redisOpt;
256
- if (typeof opt === 'string') {
257
- redisOpt = { url: opt, retryStrategy: this._makeRetryStrategy() };
258
- } else if (opt && typeof opt === 'object') {
259
- redisOpt = {
260
- maxRetriesPerRequest: 2,
261
- enableReadyCheck: true,
262
- retryStrategy: this._makeRetryStrategy(),
263
- ...opt,
264
- };
265
- } else {
266
- this.logger.error('❌ [REDIS] Invalid redis config detected in list');
267
- this.isRedisConnected = false;
268
- return null;
269
- }
270
-
271
- this.logger.info(
272
- `[REDIS][INIT] Connecting to Redis fallback #${this._redisCurrentIndex + 1}/${this._redisFallbackURLs.length}: ${
273
- typeof opt === 'string' ? opt : redisOpt.url || '(object)'
274
- }`
275
- );
276
-
277
- this.redis = new Redis(redisOpt.url || redisOpt);
278
-
279
- this._setupRedisEventHandlers();
280
-
281
- return this.redis;
282
- }
283
-
284
- /**
285
- * Internal: Makes retry strategy function which wraps the fallback failover logic if all failed.
286
- * Used by initializeRedis.
287
- */
288
- static _makeRetryStrategy() {
289
- return (times) => {
290
- if (times > 5) {
291
- this.logger.error(`❌ [REDIS] Could not connect after ${times - 1} retries for Redis #${this._redisCurrentIndex + 1}.`);
292
- return null;
293
- }
294
- const delay = Math.min(times * 500, 2000);
295
- this.logger.warn(
296
- `🟠 [REDIS] Connection failed for Redis #${this._redisCurrentIndex + 1}. Retrying in ${delay}ms (Attempt ${times})...`
297
- );
298
- return delay;
299
- };
300
- }
301
-
302
- /**
303
- * 🔌 Sets up Redis event handlers
304
- * @private
305
- */
306
- static _setupRedisEventHandlers() {
307
- this.redis.on('connect', async () => {
308
- if (!this.isRedisConnected) {
309
- this.logger.info('✅ [REDIS] Connection established. Switching to Redis Cache mode.');
310
- }
311
- this.isRedisConnected = true;
312
- this.redisErrorTimestamps = [];
313
- if (this.reconnectTimeout) {
314
- clearTimeout(this.reconnectTimeout);
315
- this.reconnectTimeout = null;
316
- }
317
- this._redisFailedIndexes.delete(this._redisCurrentIndex);
318
-
319
- if (this._justFailedOver) {
320
- this.logger.warn(`[REDIS][FAILOVER] Connected to new server, flushing potentially stale cache...`);
321
- try {
322
- await this.redis.flushdb();
323
- this.logger.info(`[REDIS][FAILOVER] Stale cache flushed successfully.`);
324
- } catch (err) {
325
- this.logger.error(`[REDIS][FAILOVER] FAILED TO FLUSH CACHE:`, err);
326
- }
327
- this._justFailedOver = false;
328
- }
329
- });
330
-
331
- this.redis.on('error', (err) => {
332
- if (err && (err.code === 'ECONNREFUSED' || err.message)) {
333
- this.logger.warn(`🟠 [REDIS] Connection error: ${err.message}`);
334
- }
335
- });
336
-
337
- this.redis.on('close', () => {
338
- if (this.isRedisConnected) {
339
- if (this.isShardMode) {
340
- this.logger.error('❌ [REDIS][SHARD] Connection closed. Cache DISABLED (no fallback).');
341
- } else {
342
- this.logger.error('❌ [REDIS] Connection closed. Fallback/failover will be attempted.');
343
- }
344
- }
345
- this.isRedisConnected = false;
346
-
347
- this._redisFailedIndexes.add(this._redisCurrentIndex);
348
-
349
- this.logger.warn(`[REDIS] Connection #${this._redisCurrentIndex + 1} closed. Attempting immediate failover...`);
350
- const triedFailover = this._tryRedisFailover();
351
-
352
- if (!triedFailover) {
353
- this.logger.warn(`[REDIS] Failover exhausted. Scheduling full reconnect...`);
354
- this._scheduleReconnect();
355
- }
356
- });
357
- }
358
-
359
- /**
360
- * ⏱️ Schedules a reconnection attempt
361
- * @private
362
- */
363
- static _scheduleReconnect() {
364
- if (this.reconnectTimeout) return;
365
-
366
- const sinceLast = Date.now() - this.lastAutoReconnectTs;
367
- if (sinceLast < RECONNECT_DELAY_MINUTES * 60 * 1000) return;
368
-
369
- this.lastAutoReconnectTs = Date.now();
370
- if (this.isShardMode) {
371
- this.logger.warn(`[REDIS][SHARD] Attempting auto-reconnect after ${RECONNECT_DELAY_MINUTES}min downtime...`);
372
- } else {
373
- this.logger.warn(`🟢 [REDIS] Attempting auto-reconnect after ${RECONNECT_DELAY_MINUTES}min downtime...`);
374
- }
375
-
376
- this.reconnectTimeout = setTimeout(() => {
377
- this.reconnectTimeout = null;
378
-
379
- this._redisCurrentIndex = 0;
380
- this._redisFailedIndexes.clear();
381
- this._closeCurrentRedis();
382
- this.initializeRedis();
383
- }, RECONNECT_DELAY_MINUTES * 60 * 1000);
384
- }
385
-
386
- /**
387
- * 🔑 Generates a consistent, model-specific cache key from a query identifier.
388
- * This ensures that the same query always produces the same key, preventing collisions.
389
- * @param {string|Object} queryIdentifier - A unique string or a Sequelize query object.
390
- * @returns {string} The final cache key, prefixed with the model's name (e.g., "User:{\"id\":1}").
391
- */
392
- static getCacheKey(queryIdentifier) {
393
- const keyBody =
394
- typeof queryIdentifier === 'string' ? queryIdentifier : jsonStringify(this.normalizeQueryOptions(queryIdentifier), this.logger);
395
- return `${this.CACHE_VERSION}:${this.name}:${keyBody}`;
396
- }
397
-
398
- /**
399
- * 🧽 Recursively normalizes a query options object to ensure deterministic key generation.
400
- * It sorts keys alphabetically and handles Sequelize's Symbol-based operators to produce
401
- * a consistent string representation for any given query.
402
- * @param {*} data - The query options or part of the options to normalize.
403
- * @returns {*} The normalized data.
404
- */
405
- static normalizeQueryOptions(data) {
406
- if (!data || typeof data !== 'object') return data;
407
- if (Array.isArray(data)) return data.map((item) => this.normalizeQueryOptions(item));
408
- const normalized = {};
409
- Object.keys(data)
410
- .sort()
411
- .forEach((key) => (normalized[key] = this.normalizeQueryOptions(data[key])));
412
- Object.getOwnPropertySymbols(data).forEach((symbol) => {
413
- const key = `$${symbol.toString().slice(7, -1)}`;
414
- normalized[key] = this.normalizeQueryOptions(data[symbol]);
415
- });
416
- return normalized;
417
- }
418
-
419
- /**
420
- * 📥 [HYBRID/SHARD ROUTER] Sets a value in the currently active cache engine.
421
- * In shard mode, if Redis down, nothing is cached.
422
- * @param {string|Object} cacheKeyOrQuery - The key or query object to store the data under.
423
- * @param {*} data - The data to cache. Use `null` for negative caching.
424
- * @param {number} [ttl=this.DEFAULT_TTL] - The time-to-live for the entry in milliseconds.
425
- * @param {string[]} [tags=[]] - Cache tags (for sniper tag-based invalidation)
426
- */
427
- static async setCacheEntry(cacheKeyOrQuery, data, ttl, tags = []) {
428
- const cacheKey = typeof cacheKeyOrQuery === 'string' ? cacheKeyOrQuery : this.getCacheKey(cacheKeyOrQuery);
429
- const finalTtl = ttl || this.CACHE_TTL || this.DEFAULT_TTL;
430
-
431
- if (this.isRedisConnected) {
432
- await this._redisSetCacheEntry(cacheKey, data, finalTtl, tags);
433
- } else if (!this.isShardMode) {
434
- this._mapSetCacheEntry(cacheKey, data, finalTtl);
435
- }
436
- }
437
-
438
- /**
439
- * 📤 [HYBRID/SHARD ROUTER] Retrieves a value from the currently active cache engine.
440
- * If in shard mode and Redis is down, always miss (direct to DB).
441
- * @param {string|Object} cacheKeyOrQuery - The key or query object of the item to retrieve.
442
- * @returns {Promise<{hit: boolean, data: *|undefined}>} An object indicating if the cache was hit and the retrieved data.
443
- */
444
- static async getCachedEntry(cacheKeyOrQuery, includeOptions) {
445
- const cacheKey = typeof cacheKeyOrQuery === 'string' ? cacheKeyOrQuery : this.getCacheKey(cacheKeyOrQuery);
446
- if (this.isRedisConnected) {
447
- return this._redisGetCachedEntry(cacheKey, includeOptions);
448
- } else if (!this.isShardMode) {
449
- return this._mapGetCachedEntry(cacheKey, includeOptions);
450
- }
451
-
452
- return { hit: false, data: undefined };
453
- }
454
-
455
- /**
456
- * 🗑️ [HYBRID/SHARD ROUTER] Deletes an entry from the currently active cache engine.
457
- * In shard mode, if Redis down, delete does nothing (cache already dead).
458
- * @param {string|Object} keys - The query identifier used to generate the key to delete.
459
- */
460
- static async clearCache(keys) {
461
- const cacheKey = typeof keys === 'string' ? keys : this.getCacheKey(keys);
462
- if (this.isRedisConnected) {
463
- await this._redisClearCache(cacheKey);
464
- } else if (!this.isShardMode) {
465
- this._mapClearCache(cacheKey);
466
- }
467
- }
468
-
469
- /**
470
- * 🔴 (Private) Sets a cache entry specifically in Redis, supporting tags for sniper invalidation.
471
- */
472
- static async _redisSetCacheEntry(cacheKey, data, ttl, tags = []) {
473
- try {
474
- let plainData = data;
475
- if (data && typeof data.toJSON === 'function') {
476
- plainData = data.toJSON();
477
- } else if (Array.isArray(data)) {
478
- plainData = data.map((item) => (item && typeof item.toJSON === 'function' ? item.toJSON() : item));
479
- }
480
-
481
- const valueToStore = plainData === null ? NEGATIVE_CACHE_PLACEHOLDER : safeStringify(plainData, this.logger);
482
-
483
- const multi = this.redis.multi();
484
- multi.set(cacheKey, valueToStore, 'PX', ttl);
485
- for (const tag of tags) {
486
- multi.sadd(tag, cacheKey);
487
- }
488
- await multi.exec();
489
- this.cacheStats.sets++;
490
- } catch (err) {
491
- this._trackRedisError(err);
492
- }
493
- }
494
-
495
- /**
496
- * 🔴 (Private) Retrieves and deserializes an entry specifically from Redis.
497
- */
498
- static async _redisGetCachedEntry(cacheKey, includeOptions) {
499
- try {
500
- const result = await this.redis.get(cacheKey);
501
- if (result === null || result === undefined) return { hit: false, data: undefined };
502
-
503
- this.cacheStats.redisHits++;
504
- if (result === NEGATIVE_CACHE_PLACEHOLDER) return { hit: true, data: null };
505
-
506
- const parsedData = safeParse(result, this.logger);
507
-
508
- if (typeof parsedData !== 'object' || parsedData === null) {
509
- return { hit: true, data: parsedData };
510
- }
511
-
512
- const includeAsArray = includeOptions ? (Array.isArray(includeOptions) ? includeOptions : [includeOptions]) : null;
513
-
514
- if (Array.isArray(parsedData)) {
515
- const instances = this.bulkBuild(parsedData, {
516
- isNewRecord: false,
517
- include: includeAsArray,
518
- });
519
- return { hit: true, data: instances };
520
- } else {
521
- const instance = this.build(parsedData, {
522
- isNewRecord: false,
523
- include: includeAsArray,
524
- });
525
- return { hit: true, data: instance };
526
- }
527
- } catch (err) {
528
- this._trackRedisError(err);
529
- return { hit: false, data: undefined };
530
- }
531
- }
532
-
533
- /**
534
- * 🔴 (Private) Deletes an entry specifically from Redis.
535
- */
536
- static async _redisClearCache(cacheKey) {
537
- try {
538
- await this.redis.del(cacheKey);
539
- this.cacheStats.clears++;
540
- } catch (err) {
541
- this._trackRedisError(err);
542
- }
543
- }
544
-
545
- /**
546
- * 🎯 [SNIPER] Invalidates cache entries by tags in Redis.
547
- */
548
- static async invalidateByTags(tags) {
549
- if (!this.isRedisConnected || !Array.isArray(tags) || tags.length === 0) return;
550
-
551
- try {
552
- const keysToDelete = await this.redis.sunion(tags);
553
-
554
- if (keysToDelete && keysToDelete.length > 0) {
555
- this.logger.info(`🎯 [SNIPER] Invalidating ${keysToDelete.length} keys for tags: ${tags.join(', ')}`);
556
-
557
- await this.redis.multi().del(keysToDelete).del(tags).exec();
558
- } else {
559
- await this.redis.del(tags);
560
- }
561
- } catch (err) {
562
- this._trackRedisError(err);
563
- }
564
- }
565
-
566
- /**
567
- * 🗺️ (Private) Sets a cache entry specifically in the in-memory Map.
568
- * DISABLED in shard mode.
569
- */
570
- static _mapSetCacheEntry(cacheKey, data, ttl) {
571
- if (this.isShardMode) return;
572
-
573
- if (data === null) {
574
- this.localNegativeCache.add(cacheKey);
575
- this.localCache.delete(cacheKey);
576
- } else {
577
- let plainData = data;
578
- if (data && typeof data.toJSON === 'function') {
579
- plainData = data.toJSON();
580
- } else if (Array.isArray(data)) {
581
- plainData = data.map((item) => (item && typeof item.toJSON === 'function' ? item.toJSON() : item));
582
- }
583
-
584
- const dataCopy = plainData === null ? NEGATIVE_CACHE_PLACEHOLDER : safeStringify(plainData, this.logger);
585
-
586
- this.localCache.set(cacheKey, { data: dataCopy, expires: Date.now() + ttl });
587
- this.localNegativeCache.delete(cacheKey);
588
- }
589
- this.cacheStats.sets++;
590
- }
591
-
592
- /**
593
- * 🗺️ (Private) Retrieves an entry specifically from the in-memory Map.
594
- * DISABLED in shard mode.
595
- */
596
- static _mapGetCachedEntry(cacheKey, includeOptions) {
597
- if (this.isShardMode) return { hit: false, data: undefined };
598
-
599
- if (this.localNegativeCache.has(cacheKey)) {
600
- this.cacheStats.mapHits++;
601
- return { hit: true, data: null };
602
- }
603
-
604
- const entry = this.localCache.get(cacheKey);
605
- if (entry && entry.expires > Date.now()) {
606
- this.cacheStats.mapHits++;
607
-
608
- const dataRaw = entry.data;
609
-
610
- let parsedData;
611
- if (typeof dataRaw === 'string') {
612
- parsedData = safeParse(dataRaw, this.logger);
613
- } else {
614
- parsedData = dataRaw;
615
- }
616
-
617
- if (typeof parsedData !== 'object' || parsedData === null) {
618
- return { hit: true, data: parsedData };
619
- }
620
-
621
- const includeAsArray = includeOptions ? (Array.isArray(includeOptions) ? includeOptions : [includeOptions]) : null;
622
-
623
- if (Array.isArray(parsedData)) {
624
- const instances = this.bulkBuild(parsedData, {
625
- isNewRecord: false,
626
- include: includeAsArray,
627
- });
628
- return { hit: true, data: instances };
629
- } else {
630
- const instance = this.build(parsedData, {
631
- isNewRecord: false,
632
- include: includeAsArray,
633
- });
634
- return { hit: true, data: instance };
635
- }
636
- }
637
-
638
- if (entry) this.localCache.delete(cacheKey);
639
- return { hit: false, data: undefined };
640
- }
641
-
642
- /**
643
- * 🗺️ (Private) Deletes an entry specifically from the in-memory Map.
644
- * DISABLED in shard mode.
645
- */
646
- static _mapClearCache(cacheKey) {
647
- if (this.isShardMode) return;
648
- this.localCache.delete(cacheKey);
649
- this.localNegativeCache.delete(cacheKey);
650
- this.cacheStats.clears++;
651
- }
652
-
653
- /**
654
- * 🗺️ (Private) Clears all in-memory cache entries for this model.
655
- * Used as a fallback when Redis is disconnected.
656
- * DISABLED in shard mode.
657
- */
658
- static _mapClearAllModelCache() {
659
- if (this.isShardMode) return;
660
- const prefix = `${this.CACHE_VERSION}:${this.name}:`;
661
- let cleared = 0;
662
-
663
- for (const key of this.localCache.keys()) {
664
- if (key.startsWith(prefix)) {
665
- this.localCache.delete(key);
666
- cleared++;
667
- }
668
- }
669
- for (const key of this.localNegativeCache.keys()) {
670
- if (key.startsWith(prefix)) {
671
- this.localNegativeCache.delete(key);
672
- cleared++;
673
- }
674
- }
675
-
676
- if (cleared > 0) {
677
- this.logger.info(`♻️ [MAP CACHE] Cleared ${cleared} in-memory entries for ${this.name} (Redis fallback).`);
678
- }
679
- }
680
-
681
- /**
682
- * 🔄 (Internal) Standardizes various query object formats into a consistent Sequelize options object.
683
- * This helper ensures that `getCache({ id: 1 })` and `getCache({ where: { id: 1 } })` are treated identically.
684
- */
685
- static _normalizeFindOptions(options) {
686
- if (!options || typeof options !== 'object' || Object.keys(options).length === 0) return { where: {} };
687
- if (options.where) {
688
- const sequelizeOptions = { ...options };
689
- delete sequelizeOptions.cacheTags;
690
- delete sequelizeOptions.noCache;
691
- return sequelizeOptions;
692
- }
693
- const knownOptions = ['order', 'limit', 'attributes', 'include', 'group', 'having'];
694
-
695
- const cacheSpecificOptions = ['cacheTags', 'noCache'];
696
- const whereClause = {};
697
- const otherOptions = {};
698
- for (const key in options) {
699
- if (cacheSpecificOptions.includes(key)) {
700
- continue;
701
- }
702
- if (knownOptions.includes(key)) otherOptions[key] = options[key];
703
- else whereClause[key] = options[key];
704
- }
705
- return { where: whereClause, ...otherOptions };
706
- }
707
-
708
- /**
709
- * 📦 fetches a single record from the cache, falling back to the database on a miss.
710
- * In SHARD mode/fallback, cache miss triggers instant DB query, no in-memory caching.
711
- */
712
- static async getCache(keys, options = {}) {
713
- if (options.noCache) {
714
- const filteredOpts = { ...options };
715
- delete filteredOpts.cacheTags;
716
- return this.findOne(this._normalizeFindOptions(keys));
717
- }
718
- if (!keys || Array.isArray(keys)) {
719
- if (Array.isArray(keys)) {
720
- const pk = this.primaryKeyAttribute;
721
- return this.findAll({ where: { [pk]: keys.map((m) => m[pk]) } });
722
- }
723
- return null;
724
- }
725
- const normalizedOptions = this._normalizeFindOptions(keys);
726
- if (!normalizedOptions.where || Object.keys(normalizedOptions.where).length === 0) return null;
727
- const cacheKey = this.getCacheKey(normalizedOptions);
728
-
729
- const cacheResult = await this.getCachedEntry(cacheKey, normalizedOptions.include);
730
- if (cacheResult.hit) {
731
- return cacheResult.data;
732
- }
733
-
734
- this.cacheStats.misses++;
735
-
736
- if (this.pendingQueries.has(cacheKey)) {
737
- return this.pendingQueries.get(cacheKey);
738
- }
739
-
740
- const queryPromise = this.findOne(normalizedOptions)
741
- .then((record) => {
742
- if (this.isRedisConnected || !this.isShardMode) {
743
- const tags = [`${this.name}`];
744
- if (record) {
745
- const pk = this.primaryKeyAttribute;
746
- tags.push(`${this.name}:${pk}:${record[pk]}`);
747
- }
748
- this.setCacheEntry(cacheKey, record, undefined, tags);
749
- }
750
- return record;
751
- })
752
- .finally(() => {
753
- this.pendingQueries.delete(cacheKey);
754
- });
755
-
756
- this.pendingQueries.set(cacheKey, queryPromise);
757
- return queryPromise;
758
- }
759
-
760
- /**
761
- * 📦 Fetches an array of records from the cache, falling back to the database.
762
- */
763
- static async getAllCache(options = {}) {
764
- const { cacheTags, noCache, ...queryOptions } = options || {};
765
-
766
- if (noCache) {
767
- return this.findAll(this._normalizeFindOptions(queryOptions));
768
- }
769
- const normalizedOptions = this._normalizeFindOptions(queryOptions);
770
- const cacheKey = this.getCacheKey(normalizedOptions);
771
-
772
- const cacheResult = await this.getCachedEntry(cacheKey, normalizedOptions.include);
773
- if (cacheResult.hit) {
774
- return cacheResult.data;
775
- }
776
-
777
- this.cacheStats.misses++;
778
-
779
- if (this.pendingQueries.has(cacheKey)) {
780
- return this.pendingQueries.get(cacheKey);
781
- }
782
-
783
- const queryPromise = this.findAll(normalizedOptions)
784
- .then((records) => {
785
- if (this.isRedisConnected || !this.isShardMode) {
786
- const tags = [`${this.name}`];
787
-
788
- if (Array.isArray(cacheTags)) {
789
- tags.push(...cacheTags);
790
- }
791
- this.setCacheEntry(cacheKey, records, undefined, tags);
792
- }
793
- return records;
794
- })
795
- .finally(() => {
796
- this.pendingQueries.delete(cacheKey);
797
- });
798
-
799
- this.pendingQueries.set(cacheKey, queryPromise);
800
- return queryPromise;
801
- }
802
-
803
- /**
804
- * 📦 Attempts to find a record based on `options.where`. If found, it returns the cached or DB record.
805
- */
806
- static async findOrCreateWithCache(options) {
807
- if (!options || !options.where) {
808
- throw new Error("findOrCreateWithCache requires a 'where' option.");
809
- }
810
-
811
- const { cacheTags, noCache, ...findOrCreateOptions } = options;
812
-
813
- const cacheKey = this.getCacheKey(options.where);
814
- const cacheResult = await this.getCachedEntry(cacheKey);
815
- if (cacheResult.hit && cacheResult.data) {
816
- return [cacheResult.data, false];
817
- }
818
- this.cacheStats.misses++;
819
- if (this.pendingQueries.has(cacheKey)) {
820
- return this.pendingQueries.get(cacheKey);
821
- }
822
- const findOrCreatePromise = this.findOrCreate(findOrCreateOptions)
823
- .then(([instance, created]) => {
824
- if (this.isRedisConnected || !this.isShardMode) {
825
- const tags = [`${this.name}`];
826
- if (instance) {
827
- const pk = this.primaryKeyAttribute;
828
- tags.push(`${this.name}:${pk}:${instance[pk]}`);
829
- }
830
- this.setCacheEntry(cacheKey, instance, undefined, tags);
831
- }
832
- return [instance, created];
833
- })
834
- .finally(() => {
835
- this.pendingQueries.delete(cacheKey);
836
- });
837
-
838
- this.pendingQueries.set(cacheKey, findOrCreatePromise);
839
- return findOrCreatePromise;
840
- }
841
-
842
- /**
843
- * 📦 Fetches the count of records matching the query from the cache, falling back to the database.
844
- */
845
- static async countWithCache(options = {}, ttl = 5 * 60 * 1000) {
846
- const { cacheTags, noCache, ...countOptions } = options || {};
847
-
848
- const cacheKeyOptions = { queryType: 'count', ...countOptions };
849
- const cacheKey = this.getCacheKey(cacheKeyOptions);
850
- const cacheResult = await this.getCachedEntry(cacheKey);
851
- if (cacheResult.hit) {
852
- return cacheResult.data;
853
- }
854
- this.cacheStats.misses++;
855
- const count = await this.count(countOptions);
856
-
857
- if (this.isRedisConnected || !this.isShardMode) {
858
- const tags = [`${this.name}`];
859
- this.setCacheEntry(cacheKey, count, ttl, tags);
860
- }
861
- return count;
862
- }
863
-
864
- /**
865
- * 📦 An instance method that saves the current model instance to the database and then
866
- * intelligently updates its corresponding entry in the active cache.
867
- */
868
- async saveAndUpdateCache() {
869
- const savedInstance = await this.save();
870
- const pk = this.constructor.primaryKeyAttribute;
871
- const pkValue = this[pk];
872
- if (pkValue && (this.constructor.isRedisConnected || !this.constructor.isShardMode)) {
873
- const cacheKey = this.constructor.getCacheKey({ [pk]: pkValue });
874
- const tags = [`${this.constructor.name}`, `${this.constructor.name}:${pk}:${pkValue}`];
875
- await this.constructor.setCacheEntry(cacheKey, savedInstance, undefined, tags);
876
- }
877
- return savedInstance;
878
- }
879
-
880
- /**
881
- * 📦 A convenience alias for `clearCache`. In the hybrid system, positive and negative
882
- * cache entries for the same key are managed together, so clearing one clears the other.
883
- */
884
- static async clearNegativeCache(keys) {
885
- return this.clearCache(keys);
886
- }
887
-
888
- /**
889
- * 📦 Fetches a raw aggregate result from the cache, falling back to the database.
890
- */
891
- static async aggregateWithCache(options = {}, cacheOptions = {}) {
892
- const { cacheTags, noCache, ...queryOptions } = options || {};
893
- const { ttl = 5 * 60 * 1000 } = cacheOptions || {};
894
- const cacheKeyOptions = { queryType: 'aggregate', ...queryOptions };
895
- const cacheKey = this.getCacheKey(cacheKeyOptions);
896
-
897
- const cacheResult = await this.getCachedEntry(cacheKey);
898
- if (cacheResult.hit) {
899
- return cacheResult.data;
900
- }
901
-
902
- this.cacheStats.misses++;
903
-
904
- const result = await this.findAll(queryOptions);
905
-
906
- if (this.isRedisConnected || !this.isShardMode) {
907
- const tags = [`${this.name}`];
908
- if (Array.isArray(cacheTags)) tags.push(...cacheTags);
909
- this.setCacheEntry(cacheKey, result, ttl, tags);
910
- }
911
-
912
- return result;
913
- }
914
-
915
- /**
916
- * 🪝 Attaches Sequelize lifecycle hooks (`afterSave`, `afterDestroy`, etc.) to this model.
917
- * In shard mode, fallback invalidation does nothing.
918
- */
919
- static initializeCacheHooks() {
920
- if (!this.redis) {
921
- this.logger.warn(`❌ Redis not initialized for model ${this.name}. Cache hooks will not be attached.`);
922
- return;
923
- }
924
-
925
- /**
926
- * Logika setelah data disimpan (Create atau Update)
927
- */
928
- const afterSaveLogic = async (instance) => {
929
- const modelClass = instance.constructor;
930
-
931
- if (modelClass.isRedisConnected) {
932
- const tagsToInvalidate = [`${modelClass.name}`];
933
- const pk = modelClass.primaryKeyAttribute;
934
- tagsToInvalidate.push(`${modelClass.name}:${pk}:${instance[pk]}`);
935
-
936
- if (Array.isArray(modelClass.customInvalidationTags)) {
937
- tagsToInvalidate.push(...modelClass.customInvalidationTags);
938
- }
939
- await modelClass.invalidateByTags(tagsToInvalidate);
940
- } else if (!modelClass.isShardMode) {
941
- modelClass._mapClearAllModelCache();
942
- }
943
- };
944
-
945
- /**
946
- * Logika setelah data dihapus
947
- */
948
- const afterDestroyLogic = async (instance) => {
949
- const modelClass = instance.constructor;
950
-
951
- if (modelClass.isRedisConnected) {
952
- const tagsToInvalidate = [`${modelClass.name}`];
953
- const pk = modelClass.primaryKeyAttribute;
954
- tagsToInvalidate.push(`${modelClass.name}:${pk}:${instance[pk]}`);
955
-
956
- if (Array.isArray(modelClass.customInvalidationTags)) {
957
- tagsToInvalidate.push(...modelClass.customInvalidationTags);
958
- }
959
- await modelClass.invalidateByTags(tagsToInvalidate);
960
- } else if (!modelClass.isShardMode) {
961
- modelClass._mapClearAllModelCache();
962
- }
963
- };
964
-
965
- const afterBulkLogic = async () => {
966
- if (this.isRedisConnected) {
967
- await this.invalidateByTags([`${this.name}`]);
968
- } else if (!this.isShardMode) {
969
- this._mapClearAllModelCache();
970
- }
971
- };
972
-
973
- this.addHook('afterSave', afterSaveLogic);
974
- this.addHook('afterDestroy', afterDestroyLogic);
975
- this.addHook('afterBulkCreate', afterBulkLogic);
976
- this.addHook('afterBulkUpdate', afterBulkLogic);
977
- this.addHook('afterBulkDestroy', afterBulkLogic);
978
- }
979
-
980
- /**
981
- * 🪝 Iterates through all registered Sequelize models and attaches the cache hooks
982
- * to any model that extends `KythiaModel`. This should be called once after all models
983
- * have been defined and loaded.
984
- */
985
- static attachHooksToAllModels(sequelizeInstance, client) {
986
- if (!this.redis) {
987
- this.logger.error('❌ Cannot attach hooks because Redis is not initialized.');
988
- return;
989
- }
990
-
991
- for (const modelName in sequelizeInstance.models) {
992
- const model = sequelizeInstance.models[modelName];
993
- if (model.prototype instanceof KythiaModel) {
994
- model.client = client;
995
- this.logger.info(`⚙️ Attaching hooks to ${model.name}`);
996
- model.initializeCacheHooks();
997
- }
998
- }
999
- }
1000
-
1001
- /**
1002
- * 🔄 Touches (updates the timestamp of) a parent model instance.
1003
- */
1004
- static async touchParent(childInstance, foreignKeyField, ParentModel, timestampField = 'updatedAt') {
1005
- if (!childInstance || !childInstance[foreignKeyField]) {
1006
- return;
1007
- }
1008
-
1009
- try {
1010
- const parentPk = ParentModel.primaryKeyAttribute;
1011
- const parent = await ParentModel.findByPk(childInstance[foreignKeyField]);
1012
-
1013
- if (parent) {
1014
- parent.changed(timestampField, true);
1015
- await parent.save({ fields: [timestampField] });
1016
- this.logger.info(`🔄 Touched parent ${ParentModel.name} #${parent[parentPk]} due to change in ${this.name}.`);
1017
- }
1018
- } catch (e) {
1019
- this.logger.error(`🔄 Failed to touch parent ${ParentModel.name}`, e);
1020
- }
1021
- }
1022
-
1023
- /**
1024
- * 🔄 Configures automatic parent touching on model hooks.
1025
- */
1026
- static setupParentTouch(foreignKeyField, ParentModel, timestampField = 'updatedAt') {
1027
- const touchHandler = (instance) => {
1028
- return this.touchParent(instance, foreignKeyField, ParentModel, timestampField);
1029
- };
1030
-
1031
- const bulkTouchHandler = (instances) => {
1032
- if (instances && instances.length > 0) {
1033
- return this.touchParent(instances[0], foreignKeyField, ParentModel, timestampField);
1034
- }
1035
- };
1036
-
1037
- this.addHook('afterSave', touchHandler);
1038
- this.addHook('afterDestroy', touchHandler);
1039
- this.addHook('afterBulkCreate', bulkTouchHandler);
1040
- }
56
+ static client;
57
+ static redis;
58
+ static isRedisConnected = false;
59
+ static logger = console;
60
+ static config = {};
61
+ static CACHE_VERSION = '1.0.0';
62
+
63
+ static localCache = new LRUCache({ max: 1000 });
64
+ static localNegativeCache = new Set();
65
+ static MAX_LOCAL_CACHE_SIZE = 1000;
66
+ static DEFAULT_TTL = 60 * 60 * 1000;
67
+
68
+ static lastRedisOpts = null;
69
+ static reconnectTimeout = null;
70
+ static lastAutoReconnectTs = 0;
71
+
72
+ static pendingQueries = new Map();
73
+ static cacheStats = {
74
+ redisHits: 0,
75
+ mapHits: 0,
76
+ misses: 0,
77
+ sets: 0,
78
+ clears: 0,
79
+ errors: 0,
80
+ };
81
+
82
+ static redisErrorTimestamps = [];
83
+
84
+ static isShardMode = false;
85
+
86
+ static _redisFallbackURLs = [];
87
+ static _redisCurrentIndex = 0;
88
+ static _redisFailedIndexes = new Set();
89
+ static _justFailedOver = false;
90
+
91
+ /**
92
+ * 🛡️ LARAVEL STYLE: MASS ASSIGNMENT PROTECTION
93
+ *
94
+ * Kita inject Hook global pas model di-init.
95
+ * Hook ini bakal ngecek 'fillable' atau 'guarded' sebelum data diproses.
96
+ */
97
+ static init(attributes, options) {
98
+ const model = super.init(attributes, options);
99
+
100
+ model.addHook('beforeValidate', (instance) => {
101
+ const ModelClass = instance.constructor;
102
+
103
+ if (ModelClass.fillable && Array.isArray(ModelClass.fillable)) {
104
+ const allowedFields = ModelClass.fillable;
105
+ Object.keys(instance.dataValues).forEach((key) => {
106
+ if (!allowedFields.includes(key)) {
107
+ delete instance.dataValues[key];
108
+ if (instance.changed()) instance.changed(key, false);
109
+ }
110
+ });
111
+ } else if (ModelClass.guarded && Array.isArray(ModelClass.guarded)) {
112
+ const forbiddenFields = ModelClass.guarded;
113
+
114
+ if (forbiddenFields.includes('*')) {
115
+ instance.dataValues = {};
116
+ return;
117
+ }
118
+
119
+ Object.keys(instance.dataValues).forEach((key) => {
120
+ if (forbiddenFields.includes(key)) {
121
+ delete instance.dataValues[key];
122
+ if (instance.changed()) instance.changed(key, false);
123
+ }
124
+ });
125
+ }
126
+ });
127
+
128
+ return model;
129
+ }
130
+
131
+ /**
132
+ * 🎩 MAGIC BOOTSTRAPPER (FIXED MERGE)
133
+ */
134
+ static async autoBoot(sequelize) {
135
+ let tableName = this.table;
136
+
137
+ if (!tableName) {
138
+ const modelName = this.name;
139
+ const snakeCase = Utils.underscoredIf(modelName, true);
140
+ tableName = Utils.pluralize(snakeCase);
141
+ }
142
+
143
+ let manualAttributes = {};
144
+ let manualOptions = {};
145
+
146
+ if (this.structure) {
147
+ manualAttributes = this.structure.attributes || {};
148
+ manualOptions = this.structure.options || {};
149
+ }
150
+
151
+ const queryInterface = sequelize.getQueryInterface();
152
+ let tableSchema;
153
+
154
+ try {
155
+ tableSchema = await queryInterface.describeTable(tableName);
156
+ } catch (error) {
157
+ console.warn(
158
+ `⚠️ [KythiaModel] Table '${tableName}' not found for model '${this.name}'. Skipping auto-boot. err ${error}`,
159
+ );
160
+ return;
161
+ }
162
+
163
+ const dbAttributes = {};
164
+
165
+ for (const [colName, colInfo] of Object.entries(tableSchema)) {
166
+ dbAttributes[colName] = {
167
+ type: this._mapDbTypeToSequelize(colInfo.type),
168
+ allowNull: colInfo.allowNull,
169
+ defaultValue: colInfo.defaultValue,
170
+ primaryKey: colInfo.primaryKey,
171
+ autoIncrement: colInfo.autoIncrement,
172
+ };
173
+ }
174
+
175
+ const finalAttributes = { ...dbAttributes, ...manualAttributes };
176
+
177
+ super.init(finalAttributes, {
178
+ sequelize,
179
+ modelName: this.name,
180
+ tableName: tableName,
181
+
182
+ timestamps:
183
+ manualOptions.timestamps !== undefined
184
+ ? manualOptions.timestamps
185
+ : !!finalAttributes.createdAt,
186
+ paranoid:
187
+ manualOptions.paranoid !== undefined
188
+ ? manualOptions.paranoid
189
+ : !!finalAttributes.deletedAt,
190
+ ...manualOptions,
191
+ });
192
+
193
+ this._setupLaravelHooks();
194
+
195
+ return this;
196
+ }
197
+
198
+ /**
199
+ * Helper: Translate DB Types (VARCHAR) to Sequelize (DataTypes.STRING)
200
+ */
201
+ static _mapDbTypeToSequelize(dbType) {
202
+ const type = dbType.toUpperCase();
203
+
204
+ if (type.startsWith('BOOLEAN') || type.startsWith('TINYINT(1)'))
205
+ return DataTypes.BOOLEAN;
206
+
207
+ if (
208
+ type.startsWith('INT') ||
209
+ type.startsWith('TINYINT') ||
210
+ type.startsWith('BIGINT')
211
+ )
212
+ return DataTypes.INTEGER;
213
+
214
+ if (
215
+ type.startsWith('VARCHAR') ||
216
+ type.startsWith('TEXT') ||
217
+ type.startsWith('CHAR')
218
+ )
219
+ return DataTypes.STRING;
220
+ if (type.startsWith('DATETIME') || type.startsWith('TIMESTAMP'))
221
+ return DataTypes.DATE;
222
+ if (type.startsWith('JSON')) return DataTypes.JSON;
223
+ if (
224
+ type.startsWith('FLOAT') ||
225
+ type.startsWith('DOUBLE') ||
226
+ type.startsWith('DECIMAL')
227
+ )
228
+ return DataTypes.FLOAT;
229
+
230
+ if (type.startsWith('ENUM')) return DataTypes.STRING;
231
+
232
+ return DataTypes.STRING;
233
+ }
234
+
235
+ /**
236
+ * Helper: Pasang hook fillable/guarded
237
+ * (Pindahkan logic dari method static init() kamu ke sini)
238
+ */
239
+ static _setupLaravelHooks() {
240
+ this.addHook('beforeValidate', (instance) => {
241
+ const ModelClass = instance.constructor;
242
+
243
+ if (ModelClass.fillable && Array.isArray(ModelClass.fillable)) {
244
+ const allowedFields = ModelClass.fillable;
245
+ Object.keys(instance.dataValues).forEach((key) => {
246
+ if (!allowedFields.includes(key)) {
247
+ delete instance.dataValues[key];
248
+ if (instance.changed()) instance.changed(key, false);
249
+ }
250
+ });
251
+ } else if (ModelClass.guarded && Array.isArray(ModelClass.guarded)) {
252
+ const forbiddenFields = ModelClass.guarded;
253
+ if (forbiddenFields.includes('*')) {
254
+ instance.dataValues = {};
255
+ return;
256
+ }
257
+ Object.keys(instance.dataValues).forEach((key) => {
258
+ if (forbiddenFields.includes(key)) {
259
+ delete instance.dataValues[key];
260
+ if (instance.changed()) instance.changed(key, false);
261
+ }
262
+ });
263
+ }
264
+ });
265
+ }
266
+
267
+ /**
268
+ * 💉 Injects core dependencies into the KythiaModel class.
269
+ * This must be called once at application startup before any models are loaded.
270
+ * @param {Object} dependencies - The dependencies to inject
271
+ * @param {Object} dependencies.logger - The logger instance
272
+ * @param {Object} dependencies.config - The application config object
273
+ * @param {Object} [dependencies.redis] - Optional Redis client instance
274
+ * @param {Object|Array|string} [dependencies.redisOptions] - Redis connection options if not providing a client.
275
+ * Can now be string (URL), object (ioredis options), or array of URLs/options for fallback.
276
+ */
277
+ static setDependencies({ logger, config, redis, redisOptions }) {
278
+ if (!logger || !config) {
279
+ throw new Error('KythiaModel.setDependencies requires logger and config');
280
+ }
281
+
282
+ this.logger = logger;
283
+ this.config = config;
284
+ this.CACHE_VERSION = config.db?.redisCacheVersion || '1.0.0';
285
+
286
+ this.isShardMode = !!config?.db?.redis?.shard || false;
287
+ if (this.isShardMode) {
288
+ this.logger.info(
289
+ '🟣 [REDIS][SHARD] Detected redis sharding mode (shard: true). Local fallback cache DISABLED!',
290
+ );
291
+ }
292
+
293
+ this._redisFallbackURLs = [];
294
+
295
+ if (Array.isArray(redisOptions)) {
296
+ this._redisFallbackURLs = redisOptions.filter(
297
+ (opt) => opt && (typeof opt !== 'string' || opt.trim().length > 0),
298
+ );
299
+ } else if (typeof redisOptions === 'string') {
300
+ if (redisOptions.trim().length > 0) {
301
+ this._redisFallbackURLs = redisOptions
302
+ .split(',')
303
+ .map((url) => url.trim())
304
+ .filter((url) => url.length > 0);
305
+ }
306
+ } else if (
307
+ redisOptions &&
308
+ typeof redisOptions === 'object' &&
309
+ Array.isArray(redisOptions.urls)
310
+ ) {
311
+ this._redisFallbackURLs = redisOptions.urls.slice();
312
+ } else if (
313
+ redisOptions &&
314
+ typeof redisOptions === 'object' &&
315
+ Object.keys(redisOptions).length > 0
316
+ ) {
317
+ this._redisFallbackURLs = [redisOptions];
318
+ }
319
+
320
+ this._redisCurrentIndex = 0;
321
+
322
+ if (redis) {
323
+ this.redis = redis;
324
+ this.isRedisConnected = redis.status === 'ready';
325
+ } else if (this._redisFallbackURLs.length > 0) {
326
+ this.initializeRedis();
327
+ } else {
328
+ if (this.isShardMode) {
329
+ this.logger.error(
330
+ '❌ [REDIS][SHARD] No Redis client/options, but shard:true. Application will work WITHOUT caching!',
331
+ );
332
+ this.isRedisConnected = false;
333
+ } else {
334
+ this.logger.warn(
335
+ '🟠 [REDIS] No Redis provided. Switching to In-Memory Cache mode.',
336
+ );
337
+ this.isRedisConnected = false;
338
+ }
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Helper: Track redis error timestamp, and check if error count in interval exceeds tolerance.
344
+ * Jika error yang terjadi >= REDIS_ERROR_TOLERANCE_COUNT dalam REDIS_ERROR_TOLERANCE_INTERVAL_MS,
345
+ * barulah coba connect ke redis berikutnya (multi redis), jika tidak ada, baru fallback ke In-Memory (isRedisConnected = false)
346
+ * -- KECUALI jika shard: true.
347
+ */
348
+ static _trackRedisError(err) {
349
+ const now = Date.now();
350
+
351
+ this.redisErrorTimestamps = (this.redisErrorTimestamps || []).filter(
352
+ (ts) => now - ts < REDIS_ERROR_TOLERANCE_INTERVAL_MS,
353
+ );
354
+ this.redisErrorTimestamps.push(now);
355
+
356
+ if (this.redisErrorTimestamps.length >= REDIS_ERROR_TOLERANCE_COUNT) {
357
+ if (this.isRedisConnected) {
358
+ const triedFallback = this._tryRedisFailover();
359
+ if (triedFallback) {
360
+ this.logger.warn(
361
+ `[REDIS] Error tolerance reached, switching to NEXT Redis failover...`,
362
+ );
363
+ } else if (this.isShardMode) {
364
+ this.logger.error(
365
+ `❌ [REDIS][SHARD] ${
366
+ this.redisErrorTimestamps.length
367
+ } consecutive errors in ${
368
+ REDIS_ERROR_TOLERANCE_INTERVAL_MS / 1000
369
+ }s. SHARD MODE: Disabling cache (NO fallback), all queries go to DB. (Last error: ${
370
+ err?.message
371
+ })`,
372
+ );
373
+ this.isRedisConnected = false;
374
+ this._scheduleReconnect();
375
+ } else {
376
+ this.logger.error(
377
+ `❌ [REDIS] ${
378
+ this.redisErrorTimestamps.length
379
+ } consecutive errors in ${
380
+ REDIS_ERROR_TOLERANCE_INTERVAL_MS / 1000
381
+ }s. All Redis exhausted, fallback to In-Memory Cache! (Last error: ${
382
+ err?.message
383
+ })`,
384
+ );
385
+ this.isRedisConnected = false;
386
+ this._scheduleReconnect();
387
+ }
388
+ }
389
+
390
+ this.redisErrorTimestamps = [];
391
+ } else {
392
+ this.logger.warn(
393
+ `🟠 [REDIS] Error #${this.redisErrorTimestamps.length}/${REDIS_ERROR_TOLERANCE_COUNT} tolerated. (${err?.message})`,
394
+ );
395
+ }
396
+ }
397
+
398
+ /**
399
+ * Coba switch ke redis URL berikutnya jika ada. Return true jika switching, false jika tidak ada lagi.
400
+ * PRIVATE.
401
+ */
402
+ static _tryRedisFailover() {
403
+ if (
404
+ !Array.isArray(this._redisFallbackURLs) ||
405
+ this._redisFallbackURLs.length < 2
406
+ ) {
407
+ return false;
408
+ }
409
+ const prevIndex = this._redisCurrentIndex;
410
+ if (this._redisCurrentIndex + 1 < this._redisFallbackURLs.length) {
411
+ this._redisCurrentIndex++;
412
+ this.logger.warn(
413
+ `[REDIS][FAILOVER] Trying to switch Redis connection from url index ${prevIndex} to ${this._redisCurrentIndex}`,
414
+ );
415
+
416
+ this._justFailedOver = true;
417
+
418
+ this._closeCurrentRedis();
419
+ this.initializeRedis();
420
+ return true;
421
+ }
422
+ return false;
423
+ }
424
+
425
+ /**
426
+ * Close the current Redis (if exists).
427
+ * PRIVATE.
428
+ */
429
+ static _closeCurrentRedis() {
430
+ if (this.redis && typeof this.redis.quit === 'function') {
431
+ try {
432
+ this.redis.quit();
433
+ } catch (e) {
434
+ console.log(e);
435
+ }
436
+ }
437
+ this.redis = undefined;
438
+ this.isRedisConnected = false;
439
+ }
440
+
441
+ /**
442
+ * 🔌 Initializes the Redis connection if not already initialized.
443
+ * (Versi ini MENGHAPUS lazyConnect dan _attemptConnection untuk fix race condition)
444
+ */
445
+ static initializeRedis(redisOptions) {
446
+ if (redisOptions) {
447
+ if (Array.isArray(redisOptions)) {
448
+ this._redisFallbackURLs = redisOptions.slice();
449
+ this._redisCurrentIndex = 0;
450
+ } else if (
451
+ redisOptions &&
452
+ typeof redisOptions === 'object' &&
453
+ Array.isArray(redisOptions.urls)
454
+ ) {
455
+ this._redisFallbackURLs = redisOptions.urls.slice();
456
+ this._redisCurrentIndex = 0;
457
+ } else {
458
+ this._redisFallbackURLs = [redisOptions];
459
+ this._redisCurrentIndex = 0;
460
+ }
461
+ }
462
+
463
+ if (
464
+ !Array.isArray(this._redisFallbackURLs) ||
465
+ this._redisFallbackURLs.length === 0
466
+ ) {
467
+ if (this.isShardMode) {
468
+ this.logger.error(
469
+ '❌ [REDIS][SHARD] No Redis URL/options provided but shard:true. Will run without caching!',
470
+ );
471
+ this.isRedisConnected = false;
472
+ } else {
473
+ this.logger.warn(
474
+ '🟠 [REDIS] No Redis client or options provided. Operating in In-Memory Cache mode only.',
475
+ );
476
+ this.isRedisConnected = false;
477
+ }
478
+ return null;
479
+ }
480
+
481
+ const Redis = require('ioredis');
482
+ this.lastRedisOpts = Array.isArray(this._redisFallbackURLs)
483
+ ? this._redisFallbackURLs.slice()
484
+ : [this._redisFallbackURLs];
485
+
486
+ if (this.redis) return this.redis;
487
+
488
+ const opt = this._redisFallbackURLs[this._redisCurrentIndex];
489
+
490
+ if (opt && typeof opt === 'object' && opt.shard) {
491
+ this.isShardMode = true;
492
+ }
493
+
494
+ let redisOpt;
495
+ if (typeof opt === 'string') {
496
+ redisOpt = { url: opt, retryStrategy: this._makeRetryStrategy() };
497
+ } else if (opt && typeof opt === 'object') {
498
+ redisOpt = {
499
+ maxRetriesPerRequest: 2,
500
+ enableReadyCheck: true,
501
+ retryStrategy: this._makeRetryStrategy(),
502
+ ...opt,
503
+ };
504
+ } else {
505
+ this.logger.error('❌ [REDIS] Invalid redis config detected in list');
506
+ this.isRedisConnected = false;
507
+ return null;
508
+ }
509
+
510
+ this.logger.info(
511
+ `[REDIS][INIT] Connecting to Redis fallback #${
512
+ this._redisCurrentIndex + 1
513
+ }/${this._redisFallbackURLs.length}: ${
514
+ typeof opt === 'string' ? opt : redisOpt.url || '(object)'
515
+ }`,
516
+ );
517
+
518
+ this.redis = new Redis(redisOpt.url || redisOpt);
519
+
520
+ this._setupRedisEventHandlers();
521
+
522
+ return this.redis;
523
+ }
524
+
525
+ /**
526
+ * Internal: Makes retry strategy function which wraps the fallback failover logic if all failed.
527
+ * Used by initializeRedis.
528
+ */
529
+ static _makeRetryStrategy() {
530
+ return (times) => {
531
+ if (times > 5) {
532
+ this.logger.error(
533
+ `❌ [REDIS] Could not connect after ${times - 1} retries for Redis #${
534
+ this._redisCurrentIndex + 1
535
+ }.`,
536
+ );
537
+ return null;
538
+ }
539
+ const delay = Math.min(times * 500, 2000);
540
+ this.logger.warn(
541
+ `🟠 [REDIS] Connection failed for Redis #${
542
+ this._redisCurrentIndex + 1
543
+ }. Retrying in ${delay}ms (Attempt ${times})...`,
544
+ );
545
+ return delay;
546
+ };
547
+ }
548
+
549
+ /**
550
+ * 🔌 Sets up Redis event handlers
551
+ * @private
552
+ */
553
+ static _setupRedisEventHandlers() {
554
+ this.redis.on('connect', async () => {
555
+ if (!this.isRedisConnected) {
556
+ this.logger.info(
557
+ '✅ [REDIS] Connection established. Switching to Redis Cache mode.',
558
+ );
559
+ }
560
+ this.isRedisConnected = true;
561
+ this.redisErrorTimestamps = [];
562
+ if (this.reconnectTimeout) {
563
+ clearTimeout(this.reconnectTimeout);
564
+ this.reconnectTimeout = null;
565
+ }
566
+ this._redisFailedIndexes.delete(this._redisCurrentIndex);
567
+
568
+ if (this._justFailedOver) {
569
+ this.logger.warn(
570
+ `[REDIS][FAILOVER] Connected to new server, flushing potentially stale cache...`,
571
+ );
572
+ try {
573
+ await this.redis.flushdb();
574
+ this.logger.info(
575
+ `[REDIS][FAILOVER] Stale cache flushed successfully.`,
576
+ );
577
+ } catch (err) {
578
+ this.logger.error(`[REDIS][FAILOVER] FAILED TO FLUSH CACHE:`, err);
579
+ }
580
+ this._justFailedOver = false;
581
+ }
582
+ });
583
+
584
+ this.redis.on('error', (err) => {
585
+ if (err && (err.code === 'ECONNREFUSED' || err.message)) {
586
+ this.logger.warn(`🟠 [REDIS] Connection error: ${err.message}`);
587
+ }
588
+ });
589
+
590
+ this.redis.on('close', () => {
591
+ if (this.isRedisConnected) {
592
+ if (this.isShardMode) {
593
+ this.logger.error(
594
+ '❌ [REDIS][SHARD] Connection closed. Cache DISABLED (no fallback).',
595
+ );
596
+ } else {
597
+ this.logger.error(
598
+ '❌ [REDIS] Connection closed. Fallback/failover will be attempted.',
599
+ );
600
+ }
601
+ }
602
+ this.isRedisConnected = false;
603
+
604
+ this._redisFailedIndexes.add(this._redisCurrentIndex);
605
+
606
+ this.logger.warn(
607
+ `[REDIS] Connection #${
608
+ this._redisCurrentIndex + 1
609
+ } closed. Attempting immediate failover...`,
610
+ );
611
+ const triedFailover = this._tryRedisFailover();
612
+
613
+ if (!triedFailover) {
614
+ this.logger.warn(
615
+ `[REDIS] Failover exhausted. Scheduling full reconnect...`,
616
+ );
617
+ this._scheduleReconnect();
618
+ }
619
+ });
620
+ }
621
+
622
+ /**
623
+ * ⏱️ Schedules a reconnection attempt
624
+ * @private
625
+ */
626
+ static _scheduleReconnect() {
627
+ if (this.reconnectTimeout) return;
628
+
629
+ const sinceLast = Date.now() - this.lastAutoReconnectTs;
630
+ if (sinceLast < RECONNECT_DELAY_MINUTES * 60 * 1000) return;
631
+
632
+ this.lastAutoReconnectTs = Date.now();
633
+ if (this.isShardMode) {
634
+ this.logger.warn(
635
+ `[REDIS][SHARD] Attempting auto-reconnect after ${RECONNECT_DELAY_MINUTES}min downtime...`,
636
+ );
637
+ } else {
638
+ this.logger.warn(
639
+ `🟢 [REDIS] Attempting auto-reconnect after ${RECONNECT_DELAY_MINUTES}min downtime...`,
640
+ );
641
+ }
642
+
643
+ this.reconnectTimeout = setTimeout(
644
+ () => {
645
+ this.reconnectTimeout = null;
646
+
647
+ this._redisCurrentIndex = 0;
648
+ this._redisFailedIndexes.clear();
649
+ this._closeCurrentRedis();
650
+ this.initializeRedis();
651
+ },
652
+ RECONNECT_DELAY_MINUTES * 60 * 1000,
653
+ );
654
+ }
655
+
656
+ /**
657
+ * 🔑 Generates a consistent, model-specific cache key from a query identifier.
658
+ * This ensures that the same query always produces the same key, preventing collisions.
659
+ * @param {string|Object} queryIdentifier - A unique string or a Sequelize query object.
660
+ * @returns {string} The final cache key, prefixed with the model's name (e.g., "User:{\"id\":1}").
661
+ */
662
+ /**
663
+ * 🔑 Generates a consistent, model-specific cache key from a query identifier.
664
+ * FIXED: Handle BigInt in json-stable-stringify
665
+ */
666
+ static getCacheKey(queryIdentifier) {
667
+ let dataToHash = queryIdentifier;
668
+
669
+ if (
670
+ dataToHash &&
671
+ typeof dataToHash === 'object' &&
672
+ !dataToHash.where &&
673
+ !dataToHash.include
674
+ ) {
675
+ dataToHash = { where: dataToHash };
676
+ }
677
+
678
+ const opts = {
679
+ replacer: (value) =>
680
+ typeof value === 'bigint' ? value.toString() : value,
681
+ };
682
+
683
+ const keyBody =
684
+ typeof queryIdentifier === 'string'
685
+ ? queryIdentifier
686
+ : jsonStringify(this.normalizeQueryOptions(dataToHash), opts);
687
+
688
+ return `${this.CACHE_VERSION}:${this.name}:${keyBody}`;
689
+ }
690
+
691
+ /**
692
+ * 🧽 Recursively normalizes a query options object to ensure deterministic key generation.
693
+ * It sorts keys alphabetically and handles Sequelize's Symbol-based operators to produce
694
+ * a consistent string representation for any given query.
695
+ * @param {*} data - The query options or part of the options to normalize.
696
+ * @returns {*} The normalized data.
697
+ */
698
+ static normalizeQueryOptions(data) {
699
+ if (!data || typeof data !== 'object') return data;
700
+ if (Array.isArray(data))
701
+ return data.map((item) => this.normalizeQueryOptions(item));
702
+ const normalized = {};
703
+ Object.keys(data)
704
+ .sort()
705
+ .forEach((key) => {
706
+ normalized[key] = this.normalizeQueryOptions(data[key]);
707
+ });
708
+ Object.getOwnPropertySymbols(data).forEach((symbol) => {
709
+ const key = `$${symbol.toString().slice(7, -1)}`;
710
+ normalized[key] = this.normalizeQueryOptions(data[symbol]);
711
+ });
712
+ return normalized;
713
+ }
714
+
715
+ /**
716
+ * 📥 [HYBRID/SHARD ROUTER] Sets a value in the currently active cache engine.
717
+ * In shard mode, if Redis down, nothing is cached.
718
+ * @param {string|Object} cacheKeyOrQuery - The key or query object to store the data under.
719
+ * @param {*} data - The data to cache. Use `null` for negative caching.
720
+ * @param {number} [ttl=this.DEFAULT_TTL] - The time-to-live for the entry in milliseconds.
721
+ * @param {string[]} [tags=[]] - Cache tags (for sniper tag-based invalidation)
722
+ */
723
+ static async setCacheEntry(cacheKeyOrQuery, data, ttl, tags = []) {
724
+ const cacheKey =
725
+ typeof cacheKeyOrQuery === 'string'
726
+ ? cacheKeyOrQuery
727
+ : this.getCacheKey(cacheKeyOrQuery);
728
+ const finalTtl = ttl || this.CACHE_TTL || this.DEFAULT_TTL;
729
+
730
+ if (this.isRedisConnected) {
731
+ await this._redisSetCacheEntry(cacheKey, data, finalTtl, tags);
732
+ } else if (!this.isShardMode) {
733
+ this._mapSetCacheEntry(cacheKey, data, finalTtl);
734
+ }
735
+ }
736
+
737
+ /**
738
+ * 📤 [HYBRID/SHARD ROUTER] Retrieves a value from the currently active cache engine.
739
+ * If in shard mode and Redis is down, always miss (direct to DB).
740
+ * @param {string|Object} cacheKeyOrQuery - The key or query object of the item to retrieve.
741
+ * @returns {Promise<{hit: boolean, data: *|undefined}>} An object indicating if the cache was hit and the retrieved data.
742
+ */
743
+ static async getCachedEntry(cacheKeyOrQuery, includeOptions) {
744
+ const cacheKey =
745
+ typeof cacheKeyOrQuery === 'string'
746
+ ? cacheKeyOrQuery
747
+ : this.getCacheKey(cacheKeyOrQuery);
748
+ if (this.isRedisConnected) {
749
+ return this._redisGetCachedEntry(cacheKey, includeOptions);
750
+ } else if (!this.isShardMode) {
751
+ return this._mapGetCachedEntry(cacheKey, includeOptions);
752
+ }
753
+
754
+ return { hit: false, data: undefined };
755
+ }
756
+
757
+ /**
758
+ * 🗑️ [HYBRID/SHARD ROUTER] Deletes an entry from the currently active cache engine.
759
+ * In shard mode, if Redis down, delete does nothing (cache already dead).
760
+ * @param {string|Object} keys - The query identifier used to generate the key to delete.
761
+ */
762
+ static async clearCache(keys) {
763
+ const cacheKey = typeof keys === 'string' ? keys : this.getCacheKey(keys);
764
+ if (this.isRedisConnected) {
765
+ await this._redisClearCache(cacheKey);
766
+ } else if (!this.isShardMode) {
767
+ this._mapClearCache(cacheKey);
768
+ }
769
+ }
770
+
771
+ /**
772
+ * 🔴 (Private) Sets a cache entry specifically in Redis, supporting tags for sniper invalidation.
773
+ */
774
+ static async _redisSetCacheEntry(cacheKey, data, ttl, tags = []) {
775
+ try {
776
+ let plainData = data;
777
+ if (data && typeof data.toJSON === 'function') {
778
+ plainData = data.toJSON();
779
+ } else if (Array.isArray(data)) {
780
+ plainData = data.map((item) =>
781
+ item && typeof item.toJSON === 'function' ? item.toJSON() : item,
782
+ );
783
+ }
784
+
785
+ const valueToStore =
786
+ plainData === null
787
+ ? NEGATIVE_CACHE_PLACEHOLDER
788
+ : safeStringify(plainData, this.logger);
789
+
790
+ const multi = this.redis.multi();
791
+ multi.set(cacheKey, valueToStore, 'PX', ttl);
792
+ for (const tag of tags) {
793
+ multi.sadd(tag, cacheKey);
794
+ }
795
+ await multi.exec();
796
+ this.cacheStats.sets++;
797
+ } catch (err) {
798
+ this._trackRedisError(err);
799
+ }
800
+ }
801
+
802
+ /**
803
+ * 🔴 (Private) Retrieves and deserializes an entry specifically from Redis.
804
+ */
805
+ static async _redisGetCachedEntry(cacheKey, includeOptions) {
806
+ try {
807
+ const result = await this.redis.get(cacheKey);
808
+ if (result === null || result === undefined)
809
+ return { hit: false, data: undefined };
810
+
811
+ this.cacheStats.redisHits++;
812
+ if (result === NEGATIVE_CACHE_PLACEHOLDER)
813
+ return { hit: true, data: null };
814
+
815
+ const parsedData = safeParse(result, this.logger);
816
+
817
+ if (typeof parsedData !== 'object' || parsedData === null) {
818
+ return { hit: true, data: parsedData };
819
+ }
820
+
821
+ const includeAsArray = includeOptions
822
+ ? Array.isArray(includeOptions)
823
+ ? includeOptions
824
+ : [includeOptions]
825
+ : null;
826
+
827
+ if (Array.isArray(parsedData)) {
828
+ const instances = this.bulkBuild(parsedData, {
829
+ isNewRecord: false,
830
+ include: includeAsArray,
831
+ });
832
+ return { hit: true, data: instances };
833
+ } else {
834
+ const instance = this.build(parsedData, {
835
+ isNewRecord: false,
836
+ include: includeAsArray,
837
+ });
838
+ return { hit: true, data: instance };
839
+ }
840
+ } catch (err) {
841
+ this._trackRedisError(err);
842
+ return { hit: false, data: undefined };
843
+ }
844
+ }
845
+
846
+ /**
847
+ * 🔴 (Private) Deletes an entry specifically from Redis.
848
+ */
849
+ static async _redisClearCache(cacheKey) {
850
+ try {
851
+ await this.redis.del(cacheKey);
852
+ this.cacheStats.clears++;
853
+ } catch (err) {
854
+ this._trackRedisError(err);
855
+ }
856
+ }
857
+
858
+ /**
859
+ * 🎯 [SNIPER] Invalidates cache entries by tags in Redis.
860
+ */
861
+ static async invalidateByTags(tags) {
862
+ if (!this.isRedisConnected || !Array.isArray(tags) || tags.length === 0)
863
+ return;
864
+
865
+ try {
866
+ const keysToDelete = await this.redis.sunion(tags);
867
+
868
+ if (keysToDelete && keysToDelete.length > 0) {
869
+ this.logger.info(
870
+ `🎯 [SNIPER] Invalidating ${
871
+ keysToDelete.length
872
+ } keys for tags: ${tags.join(', ')}`,
873
+ );
874
+
875
+ await this.redis.multi().del(keysToDelete).del(tags).exec();
876
+ } else {
877
+ await this.redis.del(tags);
878
+ }
879
+ } catch (err) {
880
+ this._trackRedisError(err);
881
+ }
882
+ }
883
+
884
+ /**
885
+ * 🗺️ (Private) Sets a cache entry specifically in the in-memory Map.
886
+ * DISABLED in shard mode.
887
+ */
888
+ static _mapSetCacheEntry(cacheKey, data, ttl) {
889
+ if (this.isShardMode) return;
890
+
891
+ if (data === null) {
892
+ this.localNegativeCache.add(cacheKey);
893
+ this.localCache.delete(cacheKey);
894
+ } else {
895
+ let plainData = data;
896
+ if (data && typeof data.toJSON === 'function') {
897
+ plainData = data.toJSON();
898
+ } else if (Array.isArray(data)) {
899
+ plainData = data.map((item) =>
900
+ item && typeof item.toJSON === 'function' ? item.toJSON() : item,
901
+ );
902
+ }
903
+
904
+ const dataCopy =
905
+ plainData === null
906
+ ? NEGATIVE_CACHE_PLACEHOLDER
907
+ : safeStringify(plainData, this.logger);
908
+
909
+ this.localCache.set(cacheKey, {
910
+ data: dataCopy,
911
+ expires: Date.now() + ttl,
912
+ });
913
+ this.localNegativeCache.delete(cacheKey);
914
+ }
915
+ this.cacheStats.sets++;
916
+ }
917
+
918
+ /**
919
+ * 🗺️ (Private) Retrieves an entry specifically from the in-memory Map.
920
+ * DISABLED in shard mode.
921
+ */
922
+ static _mapGetCachedEntry(cacheKey, includeOptions) {
923
+ if (this.isShardMode) return { hit: false, data: undefined };
924
+
925
+ if (this.localNegativeCache.has(cacheKey)) {
926
+ this.cacheStats.mapHits++;
927
+ return { hit: true, data: null };
928
+ }
929
+
930
+ const entry = this.localCache.get(cacheKey);
931
+ if (entry && entry.expires > Date.now()) {
932
+ this.cacheStats.mapHits++;
933
+
934
+ const dataRaw = entry.data;
935
+
936
+ let parsedData;
937
+ if (typeof dataRaw === 'string') {
938
+ parsedData = safeParse(dataRaw, this.logger);
939
+ } else {
940
+ parsedData = dataRaw;
941
+ }
942
+
943
+ if (typeof parsedData !== 'object' || parsedData === null) {
944
+ return { hit: true, data: parsedData };
945
+ }
946
+
947
+ const includeAsArray = includeOptions
948
+ ? Array.isArray(includeOptions)
949
+ ? includeOptions
950
+ : [includeOptions]
951
+ : null;
952
+
953
+ if (Array.isArray(parsedData)) {
954
+ const instances = this.bulkBuild(parsedData, {
955
+ isNewRecord: false,
956
+ include: includeAsArray,
957
+ });
958
+ return { hit: true, data: instances };
959
+ } else {
960
+ const instance = this.build(parsedData, {
961
+ isNewRecord: false,
962
+ include: includeAsArray,
963
+ });
964
+ return { hit: true, data: instance };
965
+ }
966
+ }
967
+
968
+ if (entry) this.localCache.delete(cacheKey);
969
+ return { hit: false, data: undefined };
970
+ }
971
+
972
+ /**
973
+ * 🗺️ (Private) Deletes an entry specifically from the in-memory Map.
974
+ * DISABLED in shard mode.
975
+ */
976
+ static _mapClearCache(cacheKey) {
977
+ if (this.isShardMode) return;
978
+ this.localCache.delete(cacheKey);
979
+ this.localNegativeCache.delete(cacheKey);
980
+ this.cacheStats.clears++;
981
+ }
982
+
983
+ /**
984
+ * 🗺️ (Private) Clears all in-memory cache entries for this model.
985
+ * Used as a fallback when Redis is disconnected.
986
+ * DISABLED in shard mode.
987
+ */
988
+ static _mapClearAllModelCache() {
989
+ if (this.isShardMode) return;
990
+ const prefix = `${this.CACHE_VERSION}:${this.name}:`;
991
+ let cleared = 0;
992
+
993
+ for (const key of this.localCache.keys()) {
994
+ if (key.startsWith(prefix)) {
995
+ this.localCache.delete(key);
996
+ cleared++;
997
+ }
998
+ }
999
+ for (const key of this.localNegativeCache.keys()) {
1000
+ if (key.startsWith(prefix)) {
1001
+ this.localNegativeCache.delete(key);
1002
+ cleared++;
1003
+ }
1004
+ }
1005
+
1006
+ if (cleared > 0) {
1007
+ this.logger.info(
1008
+ `♻️ [MAP CACHE] Cleared ${cleared} in-memory entries for ${this.name} (Redis fallback).`,
1009
+ );
1010
+ }
1011
+ }
1012
+
1013
+ /**
1014
+ * 🔄 (Internal) Standardizes various query object formats into a consistent Sequelize options object.
1015
+ * This helper ensures that `getCache({ id: 1 })` and `getCache({ where: { id: 1 } })` are treated identically.
1016
+ */
1017
+ static _normalizeFindOptions(options) {
1018
+ if (
1019
+ !options ||
1020
+ typeof options !== 'object' ||
1021
+ Object.keys(options).length === 0
1022
+ )
1023
+ return { where: {} };
1024
+ if (options.where) {
1025
+ const sequelizeOptions = { ...options };
1026
+ delete sequelizeOptions.cacheTags;
1027
+ delete sequelizeOptions.noCache;
1028
+ return sequelizeOptions;
1029
+ }
1030
+ const knownOptions = [
1031
+ 'order',
1032
+ 'limit',
1033
+ 'attributes',
1034
+ 'include',
1035
+ 'group',
1036
+ 'having',
1037
+ ];
1038
+
1039
+ const cacheSpecificOptions = ['cacheTags', 'noCache'];
1040
+ const whereClause = {};
1041
+ const otherOptions = {};
1042
+ for (const key in options) {
1043
+ if (cacheSpecificOptions.includes(key)) {
1044
+ continue;
1045
+ }
1046
+ if (knownOptions.includes(key)) otherOptions[key] = options[key];
1047
+ else whereClause[key] = options[key];
1048
+ }
1049
+ return { where: whereClause, ...otherOptions };
1050
+ }
1051
+
1052
+ /**
1053
+ * 📦 fetches a single record from the cache, falling back to the database on a miss.
1054
+ * FIXED: Logic merging options vs keys & TTL extraction
1055
+ */
1056
+ static async getCache(keys, options = {}) {
1057
+ const { noCache, customCacheKey, ttl, ...explicitQueryOptions } = options;
1058
+
1059
+ if (noCache) {
1060
+ const queryToRun = {
1061
+ ...this._normalizeFindOptions(keys),
1062
+ ...explicitQueryOptions,
1063
+ };
1064
+ return this.findOne(queryToRun);
1065
+ }
1066
+
1067
+ if (Array.isArray(keys)) {
1068
+ const pk = this.primaryKeyAttribute;
1069
+ return this.findAll({ where: { [pk]: keys.map((m) => m[pk]) } });
1070
+ }
1071
+
1072
+ const normalizedKeys = this._normalizeFindOptions(keys);
1073
+
1074
+ const finalQuery = {
1075
+ ...normalizedKeys,
1076
+ ...explicitQueryOptions,
1077
+ where: {
1078
+ ...normalizedKeys.where,
1079
+ ...(explicitQueryOptions.where || {}),
1080
+ },
1081
+ };
1082
+
1083
+ if (!finalQuery.where || Object.keys(finalQuery.where).length === 0) {
1084
+ return null;
1085
+ }
1086
+
1087
+ const cacheKey = customCacheKey || this.getCacheKey(finalQuery);
1088
+
1089
+ const cacheResult = await this.getCachedEntry(cacheKey, finalQuery.include);
1090
+ if (cacheResult.hit) return cacheResult.data;
1091
+
1092
+ this.cacheStats.misses++;
1093
+
1094
+ if (this.pendingQueries.has(cacheKey))
1095
+ return this.pendingQueries.get(cacheKey);
1096
+
1097
+ const queryPromise = this.findOne(finalQuery)
1098
+ .then((record) => {
1099
+ if (this.isRedisConnected || !this.isShardMode) {
1100
+ const tags = [`${this.name}`];
1101
+ if (record)
1102
+ tags.push(
1103
+ `${this.name}:${this.primaryKeyAttribute}:${
1104
+ record[this.primaryKeyAttribute]
1105
+ }`,
1106
+ );
1107
+
1108
+ this.setCacheEntry(cacheKey, record, ttl, tags);
1109
+ }
1110
+ return record;
1111
+ })
1112
+ .finally(() => this.pendingQueries.delete(cacheKey));
1113
+
1114
+ this.pendingQueries.set(cacheKey, queryPromise);
1115
+ return queryPromise;
1116
+ }
1117
+
1118
+ /**
1119
+ * 📦 Fetches multiple records.
1120
+ * FIXED: Logic merging options vs keys & TTL extraction
1121
+ */
1122
+ static async getAllCache(options = {}) {
1123
+ const { cacheTags, noCache, customCacheKey, ttl, ...explicitQueryOptions } =
1124
+ options || {};
1125
+
1126
+ const normalizedOptions = this._normalizeFindOptions(explicitQueryOptions);
1127
+
1128
+ if (noCache) {
1129
+ return this.findAll(normalizedOptions);
1130
+ }
1131
+
1132
+ const cacheKey = customCacheKey || this.getCacheKey(normalizedOptions);
1133
+
1134
+ const cacheResult = await this.getCachedEntry(
1135
+ cacheKey,
1136
+ normalizedOptions.include,
1137
+ );
1138
+ if (cacheResult.hit) return cacheResult.data;
1139
+
1140
+ this.cacheStats.misses++;
1141
+
1142
+ if (this.pendingQueries.has(cacheKey))
1143
+ return this.pendingQueries.get(cacheKey);
1144
+
1145
+ const queryPromise = this.findAll(normalizedOptions)
1146
+ .then((records) => {
1147
+ if (this.isRedisConnected || !this.isShardMode) {
1148
+ const tags = [`${this.name}`];
1149
+ if (Array.isArray(cacheTags)) tags.push(...cacheTags);
1150
+
1151
+ this.setCacheEntry(cacheKey, records, ttl, tags);
1152
+ }
1153
+ return records;
1154
+ })
1155
+ .finally(() => this.pendingQueries.delete(cacheKey));
1156
+
1157
+ this.pendingQueries.set(cacheKey, queryPromise);
1158
+ return queryPromise;
1159
+ }
1160
+
1161
+ /**
1162
+ * 🕒 Tambah item ke Scheduler (Redis Sorted Set)
1163
+ * @param {string} keySuffix - Suffix key (misal: 'active_schedule')
1164
+ * @param {number} score - Timestamp/Score
1165
+ * @param {string} value - Value (biasanya ID)
1166
+ */
1167
+ static async scheduleAdd(keySuffix, score, value) {
1168
+ if (!this.isRedisConnected) return;
1169
+ const key = `${this.name}:${keySuffix}`;
1170
+ try {
1171
+ await this.redis.zadd(key, score, value);
1172
+ } catch (e) {
1173
+ this._trackRedisError(e);
1174
+ }
1175
+ }
1176
+
1177
+ /**
1178
+ * 🕒 Hapus item dari Scheduler
1179
+ */
1180
+ static async scheduleRemove(keySuffix, value) {
1181
+ if (!this.isRedisConnected) return;
1182
+ const key = `${this.name}:${keySuffix}`;
1183
+ try {
1184
+ await this.redis.zrem(key, value);
1185
+ } catch (e) {
1186
+ this._trackRedisError(e);
1187
+ }
1188
+ }
1189
+
1190
+ /**
1191
+ * 🕒 Ambil item yang sudah expired (Score <= Now)
1192
+ * @returns {Promise<string[]>} Array of IDs
1193
+ */
1194
+ static async scheduleGetExpired(keySuffix, scoreLimit = Date.now()) {
1195
+ if (!this.isRedisConnected) return [];
1196
+ const key = `${this.name}:${keySuffix}`;
1197
+ try {
1198
+ return await this.redis.zrangebyscore(key, 0, scoreLimit);
1199
+ } catch (e) {
1200
+ this._trackRedisError(e);
1201
+ return [];
1202
+ }
1203
+ }
1204
+
1205
+ /**
1206
+ * 🕒 Bersihkan Scheduler (Flush Key)
1207
+ */
1208
+ static async scheduleClear(keySuffix) {
1209
+ if (!this.isRedisConnected) return;
1210
+ const key = `${this.name}:${keySuffix}`;
1211
+ try {
1212
+ await this.redis.del(key);
1213
+ } catch (e) {
1214
+ this._trackRedisError(e);
1215
+ }
1216
+ }
1217
+
1218
+ /**
1219
+ * 📦 Finds a record by the specified where condition, using cache if available; if not found, creates it and caches the result.
1220
+ * Will update an existing cached instance with defaults (if necessary) and save any new/changed data to both DB and cache.
1221
+ */
1222
+ static async findOrCreateWithCache(options) {
1223
+ if (!options || !options.where) {
1224
+ throw new Error("findOrCreateWithCache requires a 'where' option.");
1225
+ }
1226
+
1227
+ const { where, defaults, noCache, ...otherOptions } = options;
1228
+
1229
+ if (noCache) {
1230
+ return this.findOrCreate(options);
1231
+ }
1232
+
1233
+ const normalizedWhere = this._normalizeFindOptions(where).where;
1234
+ const cacheKey = this.getCacheKey(normalizedWhere);
1235
+
1236
+ const cacheResult = await this.getCachedEntry(
1237
+ cacheKey,
1238
+ otherOptions.include,
1239
+ );
1240
+
1241
+ if (cacheResult.hit && cacheResult.data) {
1242
+ const instance = cacheResult.data;
1243
+ let needsUpdate = false;
1244
+
1245
+ if (defaults && typeof defaults === 'object') {
1246
+ for (const key in defaults) {
1247
+ if (
1248
+ instance[key] === undefined ||
1249
+ String(instance[key]) !== String(defaults[key])
1250
+ ) {
1251
+ instance[key] = defaults[key];
1252
+ needsUpdate = true;
1253
+ }
1254
+ }
1255
+ }
1256
+
1257
+ if (needsUpdate) {
1258
+ await instance.saveAndUpdateCache();
1259
+ }
1260
+
1261
+ return [instance, false];
1262
+ }
1263
+
1264
+ this.cacheStats.misses++;
1265
+ if (this.pendingQueries.has(cacheKey)) {
1266
+ return this.pendingQueries.get(cacheKey);
1267
+ }
1268
+
1269
+ const findPromise = this.findOne({ where, ...otherOptions })
1270
+ .then(async (instance) => {
1271
+ if (instance) {
1272
+ let needsUpdate = false;
1273
+ if (defaults && typeof defaults === 'object') {
1274
+ for (const key in defaults) {
1275
+ if (
1276
+ instance[key] === undefined ||
1277
+ String(instance[key]) !== String(defaults[key])
1278
+ ) {
1279
+ instance[key] = defaults[key];
1280
+ needsUpdate = true;
1281
+ }
1282
+ }
1283
+ }
1284
+
1285
+ if (needsUpdate) {
1286
+ await instance.saveAndUpdateCache();
1287
+ } else {
1288
+ const tags = [
1289
+ `${this.name}`,
1290
+ `${this.name}:${this.primaryKeyAttribute}:${
1291
+ instance[this.primaryKeyAttribute]
1292
+ }`,
1293
+ ];
1294
+ await this.setCacheEntry(cacheKey, instance, undefined, tags);
1295
+ }
1296
+
1297
+ return [instance, false];
1298
+ } else {
1299
+ const createData = { ...where, ...defaults };
1300
+ const newInstance = await this.create(createData);
1301
+
1302
+ const tags = [
1303
+ `${this.name}`,
1304
+ `${this.name}:${this.primaryKeyAttribute}:${
1305
+ newInstance[this.primaryKeyAttribute]
1306
+ }`,
1307
+ ];
1308
+ await this.setCacheEntry(cacheKey, newInstance, undefined, tags);
1309
+
1310
+ return [newInstance, true];
1311
+ }
1312
+ })
1313
+ .finally(() => {
1314
+ this.pendingQueries.delete(cacheKey);
1315
+ });
1316
+
1317
+ this.pendingQueries.set(cacheKey, findPromise);
1318
+ return findPromise;
1319
+ }
1320
+
1321
+ /**
1322
+ * 📦 Fetches the count of records matching the query from the cache, falling back to the database.
1323
+ */
1324
+ static async countWithCache(options = {}, ttl = 5 * 60 * 1000) {
1325
+ const { ...countOptions } = options || {};
1326
+
1327
+ const cacheKeyOptions = { queryType: 'count', ...countOptions };
1328
+ const cacheKey = this.getCacheKey(cacheKeyOptions);
1329
+ const cacheResult = await this.getCachedEntry(cacheKey);
1330
+ if (cacheResult.hit) {
1331
+ return cacheResult.data;
1332
+ }
1333
+ this.cacheStats.misses++;
1334
+ const count = await this.count(countOptions);
1335
+
1336
+ if (this.isRedisConnected || !this.isShardMode) {
1337
+ const tags = [`${this.name}`];
1338
+ this.setCacheEntry(cacheKey, count, ttl, tags);
1339
+ }
1340
+ return count;
1341
+ }
1342
+
1343
+ /**
1344
+ * 📦 An instance method that saves the current model instance to the database and then
1345
+ * intelligently updates its corresponding entry in the active cache.
1346
+ */
1347
+ async saveAndUpdateCache() {
1348
+ const savedInstance = await this.save();
1349
+ const pk = this.constructor.primaryKeyAttribute;
1350
+ const pkValue = this[pk];
1351
+ if (
1352
+ pkValue &&
1353
+ (this.constructor.isRedisConnected || !this.constructor.isShardMode)
1354
+ ) {
1355
+ const cacheKey = this.constructor.getCacheKey({ [pk]: pkValue });
1356
+ const tags = [
1357
+ `${this.constructor.name}`,
1358
+ `${this.constructor.name}:${pk}:${pkValue}`,
1359
+ ];
1360
+ await this.constructor.setCacheEntry(
1361
+ cacheKey,
1362
+ savedInstance,
1363
+ undefined,
1364
+ tags,
1365
+ );
1366
+ }
1367
+ return savedInstance;
1368
+ }
1369
+
1370
+ /**
1371
+ * 📦 A convenience alias for `clearCache`. In the hybrid system, positive and negative
1372
+ * cache entries for the same key are managed together, so clearing one clears the other.
1373
+ */
1374
+ static async clearNegativeCache(keys) {
1375
+ return this.clearCache(keys);
1376
+ }
1377
+
1378
+ /**
1379
+ * 📦 Fetches a raw aggregate result from the cache, falling back to the database.
1380
+ */
1381
+ static async aggregateWithCache(options = {}, cacheOptions = {}) {
1382
+ const { cacheTags, ...queryOptions } = options || {};
1383
+ const { ttl = 5 * 60 * 1000 } = cacheOptions || {};
1384
+ const cacheKeyOptions = { queryType: 'aggregate', ...queryOptions };
1385
+ const cacheKey = this.getCacheKey(cacheKeyOptions);
1386
+
1387
+ const cacheResult = await this.getCachedEntry(cacheKey);
1388
+ if (cacheResult.hit) {
1389
+ return cacheResult.data;
1390
+ }
1391
+
1392
+ this.cacheStats.misses++;
1393
+
1394
+ const result = await this.findAll(queryOptions);
1395
+
1396
+ if (this.isRedisConnected || !this.isShardMode) {
1397
+ const tags = [`${this.name}`];
1398
+ if (Array.isArray(cacheTags)) tags.push(...cacheTags);
1399
+ this.setCacheEntry(cacheKey, result, ttl, tags);
1400
+ }
1401
+
1402
+ return result;
1403
+ }
1404
+
1405
+ /**
1406
+ * 🪝 Attaches Sequelize lifecycle hooks (`afterSave`, `afterDestroy`, etc.) to this model.
1407
+ * In shard mode, fallback invalidation does nothing.
1408
+ */
1409
+ static initializeCacheHooks() {
1410
+ if (!this.redis) {
1411
+ this.logger.warn(
1412
+ `❌ Redis not initialized for model ${this.name}. Cache hooks will not be attached.`,
1413
+ );
1414
+ return;
1415
+ }
1416
+
1417
+ /**
1418
+ * Logika setelah data disimpan (Create atau Update)
1419
+ */
1420
+ const afterSaveLogic = async (instance) => {
1421
+ const modelClass = instance.constructor;
1422
+
1423
+ if (modelClass.isRedisConnected) {
1424
+ const tagsToInvalidate = [`${modelClass.name}`];
1425
+ const pk = modelClass.primaryKeyAttribute;
1426
+ tagsToInvalidate.push(`${modelClass.name}:${pk}:${instance[pk]}`);
1427
+
1428
+ if (Array.isArray(modelClass.customInvalidationTags)) {
1429
+ tagsToInvalidate.push(...modelClass.customInvalidationTags);
1430
+ }
1431
+ await modelClass.invalidateByTags(tagsToInvalidate);
1432
+ } else if (!modelClass.isShardMode) {
1433
+ modelClass._mapClearAllModelCache();
1434
+ }
1435
+ };
1436
+
1437
+ /**
1438
+ * Logika setelah data dihapus
1439
+ */
1440
+ const afterDestroyLogic = async (instance) => {
1441
+ const modelClass = instance.constructor;
1442
+
1443
+ if (modelClass.isRedisConnected) {
1444
+ const tagsToInvalidate = [`${modelClass.name}`];
1445
+ const pk = modelClass.primaryKeyAttribute;
1446
+ tagsToInvalidate.push(`${modelClass.name}:${pk}:${instance[pk]}`);
1447
+
1448
+ if (Array.isArray(modelClass.customInvalidationTags)) {
1449
+ tagsToInvalidate.push(...modelClass.customInvalidationTags);
1450
+ }
1451
+ await modelClass.invalidateByTags(tagsToInvalidate);
1452
+ } else if (!modelClass.isShardMode) {
1453
+ modelClass._mapClearAllModelCache();
1454
+ }
1455
+ };
1456
+
1457
+ const afterBulkLogic = async () => {
1458
+ if (this.isRedisConnected) {
1459
+ await this.invalidateByTags([`${this.name}`]);
1460
+ } else if (!this.isShardMode) {
1461
+ this._mapClearAllModelCache();
1462
+ }
1463
+ };
1464
+
1465
+ this.addHook('afterSave', afterSaveLogic);
1466
+ this.addHook('afterDestroy', afterDestroyLogic);
1467
+ this.addHook('afterBulkCreate', afterBulkLogic);
1468
+ this.addHook('afterBulkUpdate', afterBulkLogic);
1469
+ this.addHook('afterBulkDestroy', afterBulkLogic);
1470
+ }
1471
+
1472
+ /**
1473
+ * 🪝 Iterates through all registered Sequelize models and attaches the cache hooks
1474
+ * to any model that extends `KythiaModel`. This should be called once after all models
1475
+ * have been defined and loaded.
1476
+ */
1477
+ static attachHooksToAllModels(sequelizeInstance, client) {
1478
+ if (!this.redis) {
1479
+ this.logger.error(
1480
+ '❌ Cannot attach hooks because Redis is not initialized.',
1481
+ );
1482
+ return;
1483
+ }
1484
+
1485
+ for (const modelName in sequelizeInstance.models) {
1486
+ const model = sequelizeInstance.models[modelName];
1487
+ if (model.prototype instanceof KythiaModel) {
1488
+ model.client = client;
1489
+ this.logger.info(`⚙️ Attaching hooks to ${model.name}`);
1490
+ model.initializeCacheHooks();
1491
+ }
1492
+ }
1493
+ }
1494
+
1495
+ /**
1496
+ * 🔄 Touches (updates the timestamp of) a parent model instance.
1497
+ */
1498
+ static async touchParent(
1499
+ childInstance,
1500
+ foreignKeyField,
1501
+ ParentModel,
1502
+ timestampField = 'updatedAt',
1503
+ ) {
1504
+ if (!childInstance || !childInstance[foreignKeyField]) {
1505
+ return;
1506
+ }
1507
+
1508
+ try {
1509
+ const parentPk = ParentModel.primaryKeyAttribute;
1510
+ const parent = await ParentModel.findByPk(childInstance[foreignKeyField]);
1511
+
1512
+ if (parent) {
1513
+ parent.changed(timestampField, true);
1514
+ await parent.save({ fields: [timestampField] });
1515
+ this.logger.info(
1516
+ `🔄 Touched parent ${ParentModel.name} #${parent[parentPk]} due to change in ${this.name}.`,
1517
+ );
1518
+ }
1519
+ } catch (e) {
1520
+ this.logger.error(`🔄 Failed to touch parent ${ParentModel.name}`, e);
1521
+ }
1522
+ }
1523
+
1524
+ /**
1525
+ * 🔄 Configures automatic parent touching on model hooks.
1526
+ */
1527
+ static setupParentTouch(
1528
+ foreignKeyField,
1529
+ ParentModel,
1530
+ timestampField = 'updatedAt',
1531
+ ) {
1532
+ const touchHandler = (instance) => {
1533
+ return this.touchParent(
1534
+ instance,
1535
+ foreignKeyField,
1536
+ ParentModel,
1537
+ timestampField,
1538
+ );
1539
+ };
1540
+
1541
+ const bulkTouchHandler = (instances) => {
1542
+ if (instances && instances.length > 0) {
1543
+ return this.touchParent(
1544
+ instances[0],
1545
+ foreignKeyField,
1546
+ ParentModel,
1547
+ timestampField,
1548
+ );
1549
+ }
1550
+ };
1551
+
1552
+ this.addHook('afterSave', touchHandler);
1553
+ this.addHook('afterDestroy', touchHandler);
1554
+ this.addHook('afterBulkCreate', bulkTouchHandler);
1555
+ }
1041
1556
  }
1042
1557
 
1043
1558
  module.exports = KythiaModel;