masterrecord 0.3.6 → 0.3.8

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.
package/readme.md CHANGED
@@ -785,25 +785,51 @@ MasterRecord includes a **production-grade two-level caching system** similar to
785
785
  └─────────────────────────────────────────────────────┘
786
786
  ```
787
787
 
788
- #### Basic Usage (Default Behavior)
788
+ #### Basic Usage (Opt-In, Request-Scoped)
789
789
 
790
- Caching is **enabled by default** and requires zero configuration:
790
+ Caching is **opt-in** and **request-scoped** like Active Record. Use `.cache()` to enable caching, and call `endRequest()` to clear:
791
791
 
792
792
  ```javascript
793
793
  const db = new AppContext();
794
794
 
795
- // First query hits database (cache miss)
796
- const user = db.User.where(u => u.id == $$, 1).single();
795
+ // DEFAULT: No caching (always hits database)
796
+ const user = db.User.findById(1); // DB query
797
+ const user2 = db.User.findById(1); // DB query again (no cache)
797
798
 
798
- // Second identical query hits cache (99%+ faster)
799
- const user2 = db.User.where(u => u.id == $$, 1).single();
799
+ // OPT-IN: Enable caching with .cache()
800
+ const categories = db.Categories.cache().toList(); // DB query, cached
801
+ const categories2 = db.Categories.cache().toList(); // Cache hit! (instant)
800
802
 
801
803
  // Update invalidates cache automatically
802
- user2.name = "Updated";
803
- db.saveChanges(); // Cache for User table cleared
804
+ const cat = db.Categories.findById(1);
805
+ cat.name = "Updated";
806
+ db.saveChanges(); // Cache for Categories table cleared
804
807
 
805
- // Next query hits database again (cache miss)
806
- const user3 = db.User.where(u => u.id == $$, 1).single();
808
+ // End request (clears cache - like Active Record)
809
+ db.endRequest(); // Cache cleared for next request
810
+ ```
811
+
812
+ **Web Application Pattern (Recommended):**
813
+ ```javascript
814
+ // Express middleware - automatic request-scoped caching
815
+ app.use((req, res, next) => {
816
+ req.db = new AppContext();
817
+
818
+ // Clear cache when response finishes (like Active Record)
819
+ res.on('finish', () => {
820
+ req.db.endRequest(); // Clears query cache
821
+ });
822
+
823
+ next();
824
+ });
825
+
826
+ // In your routes
827
+ app.get('/categories', (req, res) => {
828
+ // Cache is fresh for this request
829
+ const categories = req.db.Categories.cache().toList();
830
+ res.json(categories);
831
+ // Cache auto-cleared after response
832
+ });
807
833
  ```
808
834
 
809
835
  #### Configuration
@@ -812,29 +838,43 @@ Configure caching via environment variables:
812
838
 
813
839
  ```bash
814
840
  # Development (.env)
815
- QUERY_CACHE_ENABLED=true # Enable/disable (default: true)
816
- QUERY_CACHE_TTL=300000 # TTL in milliseconds (default: 5 minutes)
841
+ QUERY_CACHE_TTL=5000 # TTL in milliseconds (default: 5 seconds - request-scoped)
817
842
  QUERY_CACHE_SIZE=1000 # Max cache entries (default: 1000)
843
+ QUERY_CACHE_ENABLED=true # Enable/disable globally (default: true)
818
844
 
819
845
  # Production (.env)
820
- QUERY_CACHE_ENABLED=true
821
- QUERY_CACHE_TTL=300 # Redis uses seconds
846
+ QUERY_CACHE_TTL=5 # Redis uses seconds (5 seconds default)
822
847
  REDIS_URL=redis://localhost:6379 # Use Redis for distributed caching
823
848
  ```
824
849
 
825
- #### Disable Caching for Specific Queries
850
+ **Note:**
851
+ - Cache is **opt-in per query** using `.cache()`
852
+ - Default TTL is **5 seconds** (request-scoped like Active Record)
853
+ - Call `db.endRequest()` to clear cache manually (recommended in middleware)
854
+ - Environment variables control the cache system globally
855
+
856
+ #### Enable Caching for Specific Queries
826
857
 
827
- Use `.noCache()` for real-time data that shouldn't be cached:
858
+ Use `.cache()` for frequently accessed, rarely changed data:
828
859
 
829
860
  ```javascript
830
- // Always hit database (never cached)
861
+ // DEFAULT: Always hits database (safe)
831
862
  const liveData = db.Analytics
832
863
  .where(a => a.date == $$, today)
833
- .noCache() // Skip cache
834
- .toList();
835
-
836
- // Reference data (highly cacheable)
837
- const categories = db.Categories.toList(); // Cached for 5 minutes
864
+ .toList(); // No caching (default)
865
+
866
+ // OPT-IN: Cache reference data
867
+ const categories = db.Categories.cache().toList(); // Cached for 5 minutes
868
+ const settings = db.Settings.cache().toList(); // Cached
869
+ const countries = db.Countries.cache().toList(); // Cached
870
+
871
+ // When to use .cache():
872
+ // ✅ Reference data (categories, settings, countries)
873
+ // ✅ Rarely changing data (roles, permissions)
874
+ // ✅ Expensive aggregations with stable results
875
+ // ❌ User-specific data
876
+ // ❌ Real-time data
877
+ // ❌ Financial/critical data
838
878
  ```
839
879
 
840
880
  #### Manual Cache Control
@@ -901,19 +941,22 @@ class AppContext extends context {
901
941
  MasterRecord automatically invalidates cache entries when data changes:
902
942
 
903
943
  ```javascript
904
- // Query is cached
905
- const users = db.User.where(u => u.active == true).toList();
944
+ // Query with caching enabled
945
+ const categories = db.Categories.cache().toList(); // DB query, cached
946
+
947
+ // Any modification to Categories table invalidates ALL cached Category queries
948
+ const cat = db.Categories.findById(1);
949
+ cat.name = "Updated";
950
+ db.saveChanges(); // Invalidates all cached Categories queries
906
951
 
907
- // Any modification to User table invalidates ALL User queries
908
- const user = db.User.findById(1);
909
- user.name = "Updated";
910
- db.saveChanges(); // Invalidates all cached User queries
952
+ // Next cached query hits database (fresh data)
953
+ const categoriesAgain = db.Categories.cache().toList(); // DB query (cache cleared)
911
954
 
912
- // Next query hits database (fresh data)
913
- const usersAgain = db.User.where(u => u.active == true).toList();
955
+ // Non-cached queries are unaffected (always fresh)
956
+ const users = db.User.toList(); // No .cache() = always DB query
914
957
 
915
- // Queries for OTHER tables are unaffected
916
- const posts = db.Post.toList(); // Still cached
958
+ // Queries for OTHER tables' caches are unaffected
959
+ const settings = db.Settings.cache().toList(); // Still cached (different table)
917
960
  ```
918
961
 
919
962
  **Invalidation rules:**
@@ -937,40 +980,39 @@ Expected performance improvements:
937
980
 
938
981
  #### Best Practices
939
982
 
940
- **DO cache:**
983
+ **DO use .cache():**
941
984
  ```javascript
942
985
  // Reference data (rarely changes)
943
- const categories = db.Categories.toList();
944
- const settings = db.Settings.toList();
945
-
946
- // Read-heavy data (user profiles)
947
- const user = db.User.findById(userId);
948
-
949
- // Expensive aggregations
950
- const stats = db.Orders
951
- .where(o => o.status == $$, 'completed')
986
+ const categories = db.Categories.cache().toList();
987
+ const settings = db.Settings.cache().toList();
988
+ const countries = db.Countries.cache().toList();
989
+
990
+ // Expensive aggregations (stable results)
991
+ const totalRevenue = db.Orders
992
+ .where(o => o.year == $$, 2024)
993
+ .cache()
952
994
  .count();
953
995
  ```
954
996
 
955
- **DON'T cache:**
997
+ **DON'T use .cache():**
956
998
  ```javascript
957
- // Real-time data (always needs fresh results)
999
+ // User-specific data (default is safe - no caching)
1000
+ const user = db.User.findById(userId); // Always fresh
1001
+
1002
+ // Real-time data (default is safe)
958
1003
  const liveOrders = db.Orders
959
1004
  .where(o => o.status == $$, 'pending')
960
- .noCache()
961
- .toList();
1005
+ .toList(); // Always fresh
962
1006
 
963
- // Financial transactions (critical accuracy)
1007
+ // Financial transactions (default is safe)
964
1008
  const balance = db.Transactions
965
1009
  .where(t => t.user_id == $$, userId)
966
- .noCache()
967
- .toList();
1010
+ .toList(); // Always fresh
968
1011
 
969
- // User-specific sensitive data (security concern)
1012
+ // User-specific sensitive data (default is safe)
970
1013
  const permissions = db.UserPermissions
971
1014
  .where(p => p.user_id == $$, userId)
972
- .noCache()
973
- .toList();
1015
+ .toList(); // Always fresh
974
1016
  ```
975
1017
 
976
1018
  #### Monitoring Cache Performance
@@ -988,6 +1030,74 @@ if (parseFloat(stats.hitRate) < 50) {
988
1030
  }
989
1031
  ```
990
1032
 
1033
+ #### Request-Scoped Caching (Like Active Record)
1034
+
1035
+ MasterRecord's caching is designed to work like Active Record - **cache within a request, clear after**:
1036
+
1037
+ ```javascript
1038
+ // Express middleware pattern (recommended)
1039
+ app.use((req, res, next) => {
1040
+ req.db = new AppContext();
1041
+
1042
+ // Automatically clear cache when request ends
1043
+ res.on('finish', () => {
1044
+ req.db.endRequest(); // Like Active Record's cache clearing
1045
+ });
1046
+
1047
+ next();
1048
+ });
1049
+
1050
+ // In routes - cache is fresh per request
1051
+ app.get('/api/categories', (req, res) => {
1052
+ // First call in this request - DB query
1053
+ const categories = req.db.Categories.cache().toList();
1054
+
1055
+ // Second call in same request - cache hit
1056
+ const categoriesAgain = req.db.Categories.cache().toList();
1057
+
1058
+ res.json(categories);
1059
+ // After response, cache is automatically cleared
1060
+ });
1061
+
1062
+ // Next request starts with empty cache (fresh)
1063
+ ```
1064
+
1065
+ **Why request-scoped?**
1066
+ - ✅ Like Active Record - familiar pattern
1067
+ - ✅ No stale data across requests
1068
+ - ✅ Cache only lives during request processing
1069
+ - ✅ Automatic cleanup
1070
+
1071
+ #### Important: Shared Cache Behavior
1072
+
1073
+ **The cache is shared across all context instances of the same class.** This ensures consistency within a request:
1074
+
1075
+ ```javascript
1076
+ const db1 = new AppContext();
1077
+ const db2 = new AppContext();
1078
+
1079
+ // Context 1: Cache data with .cache()
1080
+ const categories1 = db1.Categories.cache().toList(); // DB query, cached
1081
+
1082
+ // Context 2: Sees cached data
1083
+ const categories2 = db2.Categories.cache().toList(); // Cache hit!
1084
+
1085
+ // Context 2: Updates invalidate cache for BOTH contexts
1086
+ const cat = db2.Categories.findById(1);
1087
+ cat.name = "Updated";
1088
+ db2.saveChanges(); // Invalidates shared cache
1089
+
1090
+ // Context 1: Sees fresh data
1091
+ const categories3 = db1.Categories.cache().toList(); // Cache miss, fresh data
1092
+ console.log(categories3[0].name); // "Updated"
1093
+ ```
1094
+
1095
+ **Why shared cache?**
1096
+ - ✅ Prevents stale data across multiple context instances
1097
+ - ✅ Ensures all parts of your application see consistent data
1098
+ - ✅ Reduces memory usage (one cache instead of many)
1099
+ - ✅ Correct behavior for single-database applications (most use cases)
1100
+
991
1101
  ### Multi-Context Applications
992
1102
 
993
1103
  Manage multiple databases in one application:
@@ -1065,6 +1175,7 @@ context.remove(entity)
1065
1175
  // Cache management
1066
1176
  context.getCacheStats() // Get cache statistics
1067
1177
  context.clearQueryCache() // Clear all cached queries
1178
+ context.endRequest() // End request and clear cache (like Active Record)
1068
1179
  context.setQueryCacheEnabled(bool) // Enable/disable caching
1069
1180
  ```
1070
1181
 
@@ -1079,7 +1190,7 @@ context.setQueryCacheEnabled(bool) // Enable/disable caching
1079
1190
  .skip(number) // Skip N records
1080
1191
  .take(number) // Limit to N records
1081
1192
  .include(relationship) // Eager load
1082
- .noCache() // Disable caching for this query
1193
+ .cache() // Enable caching for this query (opt-in)
1083
1194
 
1084
1195
  // Terminal methods (execute query)
1085
1196
  .toList() // Return array
@@ -1249,18 +1360,22 @@ console.log(`${author.name} has ${posts.length} posts`);
1249
1360
 
1250
1361
  ## Performance Tips
1251
1362
 
1252
- ### 1. Leverage Query Caching
1363
+ ### 1. Use Query Caching Selectively
1253
1364
 
1254
1365
  ```javascript
1255
- // ✅ GOOD: Cache reference data
1256
- const categories = db.Categories.toList(); // Cached automatically
1257
-
1258
- // ✅ GOOD: Reuse queries (cache hits)
1259
- const user1 = db.User.findById(123); // DB query
1260
- const user2 = db.User.findById(123); // Cache hit (instant)
1261
-
1262
- // ✅ GOOD: Disable cache for real-time data
1263
- const liveOrders = db.Orders.where(o => o.status == 'pending').noCache().toList();
1366
+ // ✅ GOOD: Cache reference data that rarely changes
1367
+ const categories = db.Categories.cache().toList(); // Opt-in caching
1368
+ const settings = db.Settings.cache().toList();
1369
+
1370
+ // GOOD: Queries without .cache() are always fresh (safe default)
1371
+ const user1 = db.User.findById(123); // Always DB query (no cache)
1372
+ const user2 = db.User.findById(123); // Always DB query (no cache)
1373
+
1374
+ // GOOD: Cache expensive queries with stable results
1375
+ const revenue2024 = db.Orders
1376
+ .where(o => o.year == $$, 2024)
1377
+ .cache() // Historical data doesn't change
1378
+ .count();
1264
1379
 
1265
1380
  // Monitor cache performance
1266
1381
  const stats = db.getCacheStats();
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Test: Multi-Context Cache Sharing
3
+ *
4
+ * Verifies that cache is shared across context instances
5
+ * so invalidation in one context affects all contexts
6
+ */
7
+
8
+ const context = require('../context');
9
+ const QueryCache = require('../Cache/QueryCache');
10
+
11
+ console.log("╔════════════════════════════════════════════════════════════════╗");
12
+ console.log("║ Multi-Context Cache Sharing Test ║");
13
+ console.log("╚════════════════════════════════════════════════════════════════╝\n");
14
+
15
+ let passed = 0;
16
+ let failed = 0;
17
+
18
+ // Test 1: Multiple context instances share the same cache
19
+ console.log("📝 Test 1: Multiple contexts share same cache instance");
20
+ console.log("──────────────────────────────────────────────────");
21
+
22
+ try {
23
+ class TestContext extends context {
24
+ constructor() {
25
+ super();
26
+ }
27
+ }
28
+
29
+ const db1 = new TestContext();
30
+ const db2 = new TestContext();
31
+
32
+ const areSameInstance = db1._queryCache === db2._queryCache;
33
+ const isSharedCache = db1._queryCache === context._sharedQueryCache;
34
+
35
+ if(areSameInstance && isSharedCache) {
36
+ console.log(" ✓ Both context instances share the same cache");
37
+ console.log(" ✓ Cache is stored in static property");
38
+ passed++;
39
+ } else {
40
+ console.log(` ✗ Contexts have separate caches (BUG!)`);
41
+ console.log(` ✗ db1._queryCache === db2._queryCache: ${areSameInstance}`);
42
+ console.log(` ✗ Shared cache exists: ${isSharedCache}`);
43
+ failed++;
44
+ }
45
+ } catch(err) {
46
+ console.log(` ✗ Error: ${err.message}`);
47
+ failed++;
48
+ }
49
+
50
+ // Test 2: Cache operations in one context affect another context
51
+ console.log("\n📝 Test 2: Cache operations are shared");
52
+ console.log("──────────────────────────────────────────────────");
53
+
54
+ try {
55
+ class TestContext extends context {
56
+ constructor() {
57
+ super();
58
+ }
59
+ }
60
+
61
+ const db1 = new TestContext();
62
+ const db2 = new TestContext();
63
+
64
+ // Context 1: Add to cache
65
+ const key = db1._queryCache.generateKey('SELECT * FROM users', [], 'users');
66
+ db1._queryCache.set(key, [{ id: 1, name: 'John' }], 'users');
67
+
68
+ // Context 2: Should see the same cached data
69
+ const cached = db2._queryCache.get(key);
70
+
71
+ if(cached && cached[0].name === 'John') {
72
+ console.log(" ✓ Cache data visible across contexts");
73
+ passed++;
74
+ } else {
75
+ console.log(` ✗ Cache data not shared across contexts`);
76
+ failed++;
77
+ }
78
+ } catch(err) {
79
+ console.log(` ✗ Error: ${err.message}`);
80
+ failed++;
81
+ }
82
+
83
+ // Test 3: Cache invalidation in one context affects another
84
+ console.log("\n📝 Test 3: Cache invalidation is shared");
85
+ console.log("──────────────────────────────────────────────────");
86
+
87
+ try {
88
+ class TestContext extends context {
89
+ constructor() {
90
+ super();
91
+ }
92
+ }
93
+
94
+ const db1 = new TestContext();
95
+ const db2 = new TestContext();
96
+
97
+ // Context 1: Add multiple entries to cache
98
+ const key1 = db1._queryCache.generateKey('SELECT * FROM users WHERE id=1', [], 'users');
99
+ const key2 = db1._queryCache.generateKey('SELECT * FROM users WHERE id=2', [], 'users');
100
+ db1._queryCache.set(key1, { id: 1, name: 'John' }, 'users');
101
+ db1._queryCache.set(key2, { id: 2, name: 'Jane' }, 'users');
102
+
103
+ // Verify both are cached
104
+ const beforeCached1 = db1._queryCache.get(key1);
105
+ const beforeCached2 = db1._queryCache.get(key2);
106
+
107
+ // Context 2: Invalidate User table
108
+ db2._queryCache.invalidateTable('users');
109
+
110
+ // Context 1: Should see invalidation
111
+ const afterCached1 = db1._queryCache.get(key1);
112
+ const afterCached2 = db1._queryCache.get(key2);
113
+
114
+ if(beforeCached1 !== null && beforeCached2 !== null && afterCached1 === null && afterCached2 === null) {
115
+ console.log(" ✓ Data was cached in context 1");
116
+ console.log(" ✓ Invalidation in context 2 affected context 1");
117
+ console.log(" ✓ Cache properly shared across contexts");
118
+ passed++;
119
+ } else {
120
+ console.log(` ✗ Invalidation not shared properly`);
121
+ console.log(` ✗ Before: cached1=${beforeCached1 !== null}, cached2=${beforeCached2 !== null}`);
122
+ console.log(` ✗ After: cached1=${afterCached1 !== null}, cached2=${afterCached2 !== null}`);
123
+ failed++;
124
+ }
125
+ } catch(err) {
126
+ console.log(` ✗ Error: ${err.message}`);
127
+ failed++;
128
+ }
129
+
130
+ // Test 4: Cache statistics are shared
131
+ console.log("\n📝 Test 4: Cache statistics are shared");
132
+ console.log("──────────────────────────────────────────────────");
133
+
134
+ try {
135
+ // Clear existing cache for clean test
136
+ context._sharedQueryCache.clear();
137
+
138
+ class TestContext extends context {
139
+ constructor() {
140
+ super();
141
+ }
142
+ }
143
+
144
+ const db1 = new TestContext();
145
+ const db2 = new TestContext();
146
+
147
+ // Context 1: Generate hits/misses
148
+ const key = db1._queryCache.generateKey('query', [], 'users');
149
+ db1._queryCache.set(key, 'data', 'users');
150
+ db1._queryCache.get(key); // Hit
151
+ db1._queryCache.get('nonexistent'); // Miss
152
+
153
+ // Context 2: Should see same stats
154
+ const stats1 = db1.getCacheStats();
155
+ const stats2 = db2.getCacheStats();
156
+
157
+ if(stats1.hits === 1 && stats2.hits === 1 && stats1.misses === 1 && stats2.misses === 1) {
158
+ console.log(" ✓ Cache statistics shared across contexts");
159
+ console.log(` ✓ Both contexts see: ${stats1.hits} hit, ${stats1.misses} miss`);
160
+ passed++;
161
+ } else {
162
+ console.log(` ✗ Statistics not shared`);
163
+ console.log(` ✗ db1 stats: ${JSON.stringify(stats1)}`);
164
+ console.log(` ✗ db2 stats: ${JSON.stringify(stats2)}`);
165
+ failed++;
166
+ }
167
+ } catch(err) {
168
+ console.log(` ✗ Error: ${err.message}`);
169
+ failed++;
170
+ }
171
+
172
+ // Test 5: Clear cache from one context affects all
173
+ console.log("\n📝 Test 5: Clear cache affects all contexts");
174
+ console.log("──────────────────────────────────────────────────");
175
+
176
+ try {
177
+ class TestContext extends context {
178
+ constructor() {
179
+ super();
180
+ }
181
+ }
182
+
183
+ const db1 = new TestContext();
184
+ const db2 = new TestContext();
185
+
186
+ // Context 1: Add data
187
+ const key = db1._queryCache.generateKey('query', [], 'users');
188
+ db1._queryCache.set(key, 'data', 'users');
189
+
190
+ // Verify cached in both
191
+ const before1 = db1._queryCache.get(key);
192
+ const before2 = db2._queryCache.get(key);
193
+
194
+ // Context 2: Clear cache
195
+ db2.clearQueryCache();
196
+
197
+ // Both contexts should see empty cache
198
+ const after1 = db1._queryCache.get(key);
199
+ const after2 = db2._queryCache.get(key);
200
+
201
+ if(before1 !== null && before2 !== null && after1 === null && after2 === null) {
202
+ console.log(" ✓ Data cached in both contexts initially");
203
+ console.log(" ✓ Clear from context 2 affected context 1");
204
+ passed++;
205
+ } else {
206
+ console.log(` ✗ Clear not shared across contexts`);
207
+ failed++;
208
+ }
209
+ } catch(err) {
210
+ console.log(` ✗ Error: ${err.message}`);
211
+ failed++;
212
+ }
213
+
214
+ // Summary
215
+ console.log("\n╔════════════════════════════════════════════════════════════════╗");
216
+ console.log("║ Test Summary ║");
217
+ console.log("╚════════════════════════════════════════════════════════════════╝");
218
+ console.log(`\n ✓ Passed: ${passed}`);
219
+ console.log(` ✗ Failed: ${failed}`);
220
+ console.log(` 📊 Total: ${passed + failed}\n`);
221
+
222
+ if(failed === 0) {
223
+ console.log(" 🎉 All tests passed!\n");
224
+ console.log(" ✅ Cache is properly shared across context instances");
225
+ console.log(" ✅ Bug fix verified: Multi-context cache invalidation works\n");
226
+ process.exit(0);
227
+ } else {
228
+ console.log(" ❌ Some tests failed\n");
229
+ process.exit(1);
230
+ }