masterrecord 0.3.5 → 0.3.6

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,175 @@
1
+ /**
2
+ * Production-Grade Query Result Cache
3
+ * Similar to EF Core's EFCoreSecondLevelCacheInterceptor
4
+ */
5
+ class QueryCache {
6
+ constructor(options = {}) {
7
+ this.cache = new Map();
8
+ this.ttl = options.ttl || 5 * 60 * 1000; // 5 minutes default
9
+ this.maxSize = options.maxSize || 1000; // LRU eviction
10
+ this.enabled = options.enabled !== false;
11
+ this.hitCount = 0;
12
+ this.missCount = 0;
13
+
14
+ // Start cleanup timer
15
+ this._startCleanupTimer();
16
+ }
17
+
18
+ /**
19
+ * Generate deterministic cache key from query + params
20
+ * Like Hibernate's query cache regions
21
+ */
22
+ generateKey(query, params, tableName) {
23
+ const crypto = require('crypto');
24
+ const normalizedQuery = query.trim().toLowerCase().replace(/\s+/g, ' ');
25
+ const keyData = {
26
+ query: normalizedQuery,
27
+ params: params || [],
28
+ table: tableName
29
+ };
30
+ return crypto.createHash('sha256')
31
+ .update(JSON.stringify(keyData))
32
+ .digest('hex');
33
+ }
34
+
35
+ /**
36
+ * Get cached query result
37
+ */
38
+ get(cacheKey) {
39
+ if (!this.enabled) return null;
40
+
41
+ const entry = this.cache.get(cacheKey);
42
+ if (!entry) {
43
+ this.missCount++;
44
+ return null;
45
+ }
46
+
47
+ // Check TTL expiration
48
+ if (Date.now() - entry.timestamp > this.ttl) {
49
+ this.cache.delete(cacheKey);
50
+ this.missCount++;
51
+ return null;
52
+ }
53
+
54
+ // Update LRU metadata
55
+ entry.hits++;
56
+ entry.lastAccess = Date.now();
57
+ this.hitCount++;
58
+
59
+ // Log cache hit in dev
60
+ if (process.env.NODE_ENV !== 'production') {
61
+ console.debug(`[QueryCache HIT] Key: ${cacheKey.substring(0, 8)}... (${entry.hits} hits)`);
62
+ }
63
+
64
+ return entry.data;
65
+ }
66
+
67
+ /**
68
+ * Store query result in cache
69
+ */
70
+ set(cacheKey, data, tableName) {
71
+ if (!this.enabled) return;
72
+
73
+ // LRU eviction if at capacity
74
+ if (this.cache.size >= this.maxSize) {
75
+ this._evictLRU();
76
+ }
77
+
78
+ this.cache.set(cacheKey, {
79
+ data: data,
80
+ timestamp: Date.now(),
81
+ lastAccess: Date.now(),
82
+ hits: 0,
83
+ tableName: tableName
84
+ });
85
+
86
+ // Log cache set in dev
87
+ if (process.env.NODE_ENV !== 'production') {
88
+ console.debug(`[QueryCache SET] Key: ${cacheKey.substring(0, 8)}... Table: ${tableName}`);
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Invalidate all cache entries for a table
94
+ * Called automatically on INSERT/UPDATE/DELETE
95
+ */
96
+ invalidateTable(tableName) {
97
+ let invalidated = 0;
98
+ for (const [key, entry] of this.cache) {
99
+ if (entry.tableName === tableName) {
100
+ this.cache.delete(key);
101
+ invalidated++;
102
+ }
103
+ }
104
+
105
+ if (process.env.NODE_ENV !== 'production') {
106
+ console.debug(`[QueryCache INVALIDATE] Table: ${tableName} (${invalidated} entries)`);
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Clear all cache entries
112
+ */
113
+ clear() {
114
+ this.cache.clear();
115
+ this.hitCount = 0;
116
+ this.missCount = 0;
117
+ }
118
+
119
+ /**
120
+ * Get cache statistics
121
+ */
122
+ getStats() {
123
+ const total = this.hitCount + this.missCount;
124
+ return {
125
+ size: this.cache.size,
126
+ maxSize: this.maxSize,
127
+ hits: this.hitCount,
128
+ misses: this.missCount,
129
+ hitRate: total > 0 ? (this.hitCount / total * 100).toFixed(2) + '%' : '0%',
130
+ enabled: this.enabled
131
+ };
132
+ }
133
+
134
+ /**
135
+ * LRU eviction - remove least recently used entry
136
+ */
137
+ _evictLRU() {
138
+ let oldestKey = null;
139
+ let oldestTime = Infinity;
140
+
141
+ for (const [key, entry] of this.cache) {
142
+ if (entry.lastAccess < oldestTime) {
143
+ oldestTime = entry.lastAccess;
144
+ oldestKey = key;
145
+ }
146
+ }
147
+
148
+ if (oldestKey) {
149
+ this.cache.delete(oldestKey);
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Background cleanup of expired entries
155
+ */
156
+ _startCleanupTimer() {
157
+ setInterval(() => {
158
+ const now = Date.now();
159
+ let cleaned = 0;
160
+
161
+ for (const [key, entry] of this.cache) {
162
+ if (now - entry.timestamp > this.ttl) {
163
+ this.cache.delete(key);
164
+ cleaned++;
165
+ }
166
+ }
167
+
168
+ if (cleaned > 0 && process.env.NODE_ENV !== 'production') {
169
+ console.debug(`[QueryCache CLEANUP] Removed ${cleaned} expired entries`);
170
+ }
171
+ }, 60000); // Run every minute
172
+ }
173
+ }
174
+
175
+ module.exports = QueryCache;
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Distributed Query Cache using Redis
3
+ * For multi-process/clustered deployments
4
+ */
5
+ class RedisQueryCache {
6
+ constructor(redisClient, options = {}) {
7
+ this.redis = redisClient;
8
+ this.localCache = new Map(); // L1 cache (in-memory)
9
+ this.ttl = options.ttl || 5 * 60; // Redis uses seconds
10
+ this.enabled = options.enabled !== false;
11
+ this.prefix = options.prefix || 'qcache:';
12
+
13
+ // Subscribe to invalidation events
14
+ this._setupInvalidationSubscription();
15
+ }
16
+
17
+ generateKey(query, params, tableName) {
18
+ const crypto = require('crypto');
19
+ const hash = crypto.createHash('sha256')
20
+ .update(JSON.stringify({query, params}))
21
+ .digest('hex');
22
+ return `${this.prefix}${tableName}:${hash}`;
23
+ }
24
+
25
+ async get(cacheKey) {
26
+ if (!this.enabled) return null;
27
+
28
+ // L1: Check local cache first (fast)
29
+ if (this.localCache.has(cacheKey)) {
30
+ const entry = this.localCache.get(cacheKey);
31
+ if (Date.now() - entry.timestamp < 30000) { // 30s local TTL
32
+ return entry.data;
33
+ }
34
+ this.localCache.delete(cacheKey);
35
+ }
36
+
37
+ // L2: Check Redis (distributed)
38
+ try {
39
+ const cached = await this.redis.get(cacheKey);
40
+ if (cached) {
41
+ const data = JSON.parse(cached);
42
+
43
+ // Populate L1
44
+ this.localCache.set(cacheKey, {
45
+ data: data,
46
+ timestamp: Date.now()
47
+ });
48
+
49
+ return data;
50
+ }
51
+ } catch (error) {
52
+ console.error('[RedisQueryCache] Get error:', error);
53
+ }
54
+
55
+ return null;
56
+ }
57
+
58
+ async set(cacheKey, data, tableName) {
59
+ if (!this.enabled) return;
60
+
61
+ try {
62
+ // Store in Redis with TTL
63
+ await this.redis.setex(cacheKey, this.ttl, JSON.stringify(data));
64
+
65
+ // Store in L1
66
+ this.localCache.set(cacheKey, {
67
+ data: data,
68
+ timestamp: Date.now()
69
+ });
70
+ } catch (error) {
71
+ console.error('[RedisQueryCache] Set error:', error);
72
+ }
73
+ }
74
+
75
+ async invalidateTable(tableName) {
76
+ try {
77
+ // Find all keys for this table
78
+ const pattern = `${this.prefix}${tableName}:*`;
79
+ const keys = await this.redis.keys(pattern);
80
+
81
+ if (keys.length > 0) {
82
+ await this.redis.del(...keys);
83
+ }
84
+
85
+ // Clear L1
86
+ for (const [key] of this.localCache) {
87
+ if (key.includes(tableName)) {
88
+ this.localCache.delete(key);
89
+ }
90
+ }
91
+
92
+ // Publish invalidation to other processes
93
+ await this.redis.publish('cache-invalidate', JSON.stringify({
94
+ table: tableName,
95
+ timestamp: Date.now()
96
+ }));
97
+
98
+ } catch (error) {
99
+ console.error('[RedisQueryCache] Invalidate error:', error);
100
+ }
101
+ }
102
+
103
+ _setupInvalidationSubscription() {
104
+ // Subscribe to invalidation messages from other processes
105
+ const subscriber = this.redis.duplicate();
106
+ subscriber.subscribe('cache-invalidate', (message) => {
107
+ try {
108
+ const event = JSON.parse(message);
109
+
110
+ // Clear local cache for this table
111
+ for (const [key] of this.localCache) {
112
+ if (key.includes(event.table)) {
113
+ this.localCache.delete(key);
114
+ }
115
+ }
116
+ } catch (error) {
117
+ console.error('[RedisQueryCache] Subscription error:', error);
118
+ }
119
+ });
120
+ }
121
+
122
+ async clear() {
123
+ try {
124
+ const keys = await this.redis.keys(`${this.prefix}*`);
125
+ if (keys.length > 0) {
126
+ await this.redis.del(...keys);
127
+ }
128
+ this.localCache.clear();
129
+ } catch (error) {
130
+ console.error('[RedisQueryCache] Clear error:', error);
131
+ }
132
+ }
133
+
134
+ async getStats() {
135
+ try {
136
+ const keys = await this.redis.keys(`${this.prefix}*`);
137
+ return {
138
+ size: keys.length,
139
+ localSize: this.localCache.size,
140
+ enabled: this.enabled,
141
+ ttl: this.ttl
142
+ };
143
+ } catch (error) {
144
+ console.error('[RedisQueryCache] GetStats error:', error);
145
+ return {
146
+ size: 0,
147
+ localSize: this.localCache.size,
148
+ enabled: this.enabled,
149
+ ttl: this.ttl
150
+ };
151
+ }
152
+ }
153
+ }
154
+
155
+ module.exports = RedisQueryCache;
@@ -10,6 +10,7 @@ class queryMethods{
10
10
  this.__entity = entity;
11
11
  this.__context = context;
12
12
  this.__queryObject = new queryScript();
13
+ this.__useCache = true; // Enable caching by default
13
14
  }
14
15
 
15
16
  // build a single entity
@@ -87,6 +88,15 @@ class queryMethods{
87
88
  return this;
88
89
  }
89
90
 
91
+ /**
92
+ * Disable query caching for this query
93
+ * Use for queries that should always hit database
94
+ */
95
+ noCache() {
96
+ this.__useCache = false;
97
+ return this;
98
+ }
99
+
90
100
  raw(query){
91
101
  this.__queryObject.raw(query);
92
102
  return this;
@@ -321,47 +331,84 @@ class queryMethods{
321
331
  this.__queryObject.script.take = 1;
322
332
  }
323
333
 
334
+ // Generate cache key
335
+ const tableName = this.__entity.__name;
336
+ const queryString = JSON.stringify(this.__queryObject.script);
337
+ const params = this.__queryObject.script.parameters ? this.__queryObject.script.parameters.getParams() : [];
338
+ const cacheKey = this.__context._queryCache.generateKey(queryString, params, tableName);
339
+
340
+ // Check cache first (if enabled for this query)
341
+ if (this.__useCache) {
342
+ const cached = this.__context._queryCache.get(cacheKey);
343
+ if (cached) {
344
+ this.__reset();
345
+ return cached;
346
+ }
347
+ }
348
+
349
+ // Cache miss - execute query
350
+ var result = null;
324
351
  if(this.__context.isSQLite){
325
352
  var entityValue = this.__context._SQLEngine.get(this.__queryObject.script, this.__entity, this.__context);
326
- var sing = this.__singleEntityBuilder(entityValue);
327
- this.__reset();
328
- return sing;
353
+ result = this.__singleEntityBuilder(entityValue);
329
354
  }
330
-
355
+
331
356
  if(this.__context.isMySQL){
332
357
  var entityValue = this.__context._SQLEngine.get(this.__queryObject.script, this.__entity, this.__context);
333
- var sing = this.__singleEntityBuilder(entityValue[0]);
334
- this.__reset();
335
- return sing;
358
+ result = this.__singleEntityBuilder(entityValue[0]);
336
359
  }
360
+
361
+ // Store in cache
362
+ if (this.__useCache && result) {
363
+ this.__context._queryCache.set(cacheKey, result, tableName);
364
+ }
365
+
366
+ this.__reset();
367
+ return result;
337
368
  }
338
369
 
339
370
  toList(){
340
- if(this.__context.isSQLite){
341
- if(this.__queryObject.script.entityMap.length === 0){
342
- this.__queryObject.skipClause( this.__entity.__name);
343
- if(!this.__queryObject.script.take || this.__queryObject.script.take === 0){
344
- this.__queryObject.script.take = 1000;
345
- }
371
+ if(this.__queryObject.script.entityMap.length === 0){
372
+ this.__queryObject.skipClause( this.__entity.__name);
373
+ if(!this.__queryObject.script.take || this.__queryObject.script.take === 0){
374
+ this.__queryObject.script.take = 1000;
375
+ }
376
+ }
377
+
378
+ // Generate cache key
379
+ const tableName = this.__entity.__name;
380
+ const queryString = JSON.stringify(this.__queryObject.script);
381
+ const params = this.__queryObject.script.parameters ? this.__queryObject.script.parameters.getParams() : [];
382
+ const cacheKey = this.__context._queryCache.generateKey(queryString, params, tableName);
383
+
384
+ // Check cache first (if enabled for this query)
385
+ if (this.__useCache) {
386
+ const cached = this.__context._queryCache.get(cacheKey);
387
+ if (cached) {
388
+ this.__reset();
389
+ return cached;
346
390
  }
391
+ }
392
+
393
+ // Cache miss - execute query
394
+ var result = [];
395
+ if(this.__context.isSQLite){
347
396
  var entityValue = this.__context._SQLEngine.all(this.__queryObject.script, this.__entity, this.__context);
348
- var toLi = this.__multipleEntityBuilder(entityValue);
349
- this.__reset();
350
- return toLi;
397
+ result = this.__multipleEntityBuilder(entityValue);
351
398
  }
352
399
 
353
400
  if(this.__context.isMySQL){
354
- if(this.__queryObject.script.entityMap.length === 0){
355
- this.__queryObject.skipClause( this.__entity.__name);
356
- if(!this.__queryObject.script.take || this.__queryObject.script.take === 0){
357
- this.__queryObject.script.take = 1000;
358
- }
359
- }
360
401
  var entityValue = this.__context._SQLEngine.all(this.__queryObject.script, this.__entity, this.__context);
361
- var toLi = this.__multipleEntityBuilder(entityValue);
362
- this.__reset();
363
- return toLi;
402
+ result = this.__multipleEntityBuilder(entityValue);
364
403
  }
404
+
405
+ // Store in cache
406
+ if (this.__useCache && result) {
407
+ this.__context._queryCache.set(cacheKey, result, tableName);
408
+ }
409
+
410
+ this.__reset();
411
+ return result;
365
412
  }
366
413
 
367
414
  // ------------------------------- FUNCTIONS THAT UPDATE SQL START FROM HERE -----------------------------------------------------
package/context.js CHANGED
@@ -14,6 +14,7 @@ var path = require('path');
14
14
  const appRoot = require('app-root-path');
15
15
  const MySQLClient = require('masterrecord/mySQLSyncConnect');
16
16
  const PostgresClient = require('masterrecord/postgresSyncConnect');
17
+ const QueryCache = require('./Cache/QueryCache');
17
18
 
18
19
  class context {
19
20
  _isModelValid = {
@@ -37,6 +38,13 @@ class context {
37
38
  this.__name = this.constructor.name;
38
39
  this._SQLEngine = "";
39
40
  this.__trackedEntitiesMap = new Map(); // Initialize Map for O(1) lookups
41
+
42
+ // Initialize query cache
43
+ this._queryCache = new QueryCache({
44
+ ttl: process.env.QUERY_CACHE_TTL || 5 * 60 * 1000, // 5 min default
45
+ maxSize: process.env.QUERY_CACHE_SIZE || 1000,
46
+ enabled: process.env.QUERY_CACHE_ENABLED !== 'false'
47
+ });
40
48
  }
41
49
 
42
50
  /*
@@ -549,6 +557,15 @@ class context {
549
557
  const tracked = this.__trackedEntities;
550
558
 
551
559
  if(tracked.length > 0){
560
+ // Collect affected tables for cache invalidation
561
+ const affectedTables = new Set();
562
+ for (let i = 0; i < tracked.length; i++) {
563
+ const entity = tracked[i];
564
+ if (entity.__entity && entity.__entity.__name) {
565
+ affectedTables.add(entity.__entity.__name);
566
+ }
567
+ }
568
+
552
569
  // Handle transactions based on database type
553
570
  if(this.isSQLite){
554
571
  this._SQLEngine.startTransaction();
@@ -568,6 +585,11 @@ class context {
568
585
  this._processTrackedEntities(tracked);
569
586
  this.__clearErrorHandler();
570
587
  }
588
+
589
+ // Invalidate query cache for affected tables
590
+ for (const tableName of affectedTables) {
591
+ this._queryCache.invalidateTable(tableName);
592
+ }
571
593
  }
572
594
  else{
573
595
  console.log("save changes has no tracked entities");
@@ -593,6 +615,27 @@ class context {
593
615
  this._SQLEngine._execute(query);
594
616
  }
595
617
 
618
+ /**
619
+ * Get query cache statistics
620
+ */
621
+ getCacheStats() {
622
+ return this._queryCache.getStats();
623
+ }
624
+
625
+ /**
626
+ * Clear query cache manually
627
+ */
628
+ clearQueryCache() {
629
+ this._queryCache.clear();
630
+ }
631
+
632
+ /**
633
+ * Enable/disable query caching
634
+ */
635
+ setQueryCacheEnabled(enabled) {
636
+ this._queryCache.enabled = enabled;
637
+ }
638
+
596
639
  // __track(model){
597
640
  // this.__trackedEntities.push(model);
598
641
  // return model;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "masterrecord",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
4
4
  "description": "An Object-relational mapping for the Master framework. Master Record connects classes to relational database tables to establish a database with almost zero-configuration ",
5
5
  "main": "MasterRecord.js",
6
6
  "bin": {