masterrecord 0.3.5 → 0.3.7

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 = {
@@ -32,11 +33,26 @@ class context {
32
33
  isMySQL = false;
33
34
  isPostgres = false;
34
35
 
36
+ // Static shared cache - all context instances share the same cache
37
+ static _sharedQueryCache = null;
38
+
35
39
  constructor(){
36
40
  this. __environment = process.env.master;
37
41
  this.__name = this.constructor.name;
38
42
  this._SQLEngine = "";
39
43
  this.__trackedEntitiesMap = new Map(); // Initialize Map for O(1) lookups
44
+
45
+ // Initialize shared query cache (only once across all instances)
46
+ if (!context._sharedQueryCache) {
47
+ context._sharedQueryCache = new QueryCache({
48
+ ttl: process.env.QUERY_CACHE_TTL || 5 * 60 * 1000, // 5 min default
49
+ maxSize: process.env.QUERY_CACHE_SIZE || 1000,
50
+ enabled: process.env.QUERY_CACHE_ENABLED !== 'false'
51
+ });
52
+ }
53
+
54
+ // Reference the shared cache
55
+ this._queryCache = context._sharedQueryCache;
40
56
  }
41
57
 
42
58
  /*
@@ -549,6 +565,15 @@ class context {
549
565
  const tracked = this.__trackedEntities;
550
566
 
551
567
  if(tracked.length > 0){
568
+ // Collect affected tables for cache invalidation
569
+ const affectedTables = new Set();
570
+ for (let i = 0; i < tracked.length; i++) {
571
+ const entity = tracked[i];
572
+ if (entity.__entity && entity.__entity.__name) {
573
+ affectedTables.add(entity.__entity.__name);
574
+ }
575
+ }
576
+
552
577
  // Handle transactions based on database type
553
578
  if(this.isSQLite){
554
579
  this._SQLEngine.startTransaction();
@@ -568,6 +593,11 @@ class context {
568
593
  this._processTrackedEntities(tracked);
569
594
  this.__clearErrorHandler();
570
595
  }
596
+
597
+ // Invalidate query cache for affected tables
598
+ for (const tableName of affectedTables) {
599
+ this._queryCache.invalidateTable(tableName);
600
+ }
571
601
  }
572
602
  else{
573
603
  console.log("save changes has no tracked entities");
@@ -593,6 +623,27 @@ class context {
593
623
  this._SQLEngine._execute(query);
594
624
  }
595
625
 
626
+ /**
627
+ * Get query cache statistics
628
+ */
629
+ getCacheStats() {
630
+ return this._queryCache.getStats();
631
+ }
632
+
633
+ /**
634
+ * Clear query cache manually
635
+ */
636
+ clearQueryCache() {
637
+ this._queryCache.clear();
638
+ }
639
+
640
+ /**
641
+ * Enable/disable query caching
642
+ */
643
+ setQueryCacheEnabled(enabled) {
644
+ this._queryCache.enabled = enabled;
645
+ }
646
+
596
647
  // __track(model){
597
648
  // this.__trackedEntities.push(model);
598
649
  // 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.7",
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": {