@zero-server/sdk 0.9.1 → 0.9.2
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/LICENSE +21 -21
- package/README.md +460 -443
- package/index.js +414 -412
- package/lib/app.js +1172 -1172
- package/lib/auth/authorize.js +399 -399
- package/lib/auth/enrollment.js +367 -367
- package/lib/auth/index.js +57 -57
- package/lib/auth/jwt.js +731 -731
- package/lib/auth/oauth.js +362 -362
- package/lib/auth/session.js +588 -588
- package/lib/auth/trustedDevice.js +409 -409
- package/lib/auth/twoFactor.js +1150 -1150
- package/lib/auth/webauthn.js +946 -946
- package/lib/body/index.js +14 -14
- package/lib/body/json.js +109 -109
- package/lib/body/multipart.js +440 -440
- package/lib/body/raw.js +71 -71
- package/lib/body/rawBuffer.js +160 -160
- package/lib/body/sendError.js +25 -25
- package/lib/body/text.js +75 -75
- package/lib/body/typeMatch.js +41 -41
- package/lib/body/urlencoded.js +235 -235
- package/lib/cli.js +845 -845
- package/lib/cluster.js +666 -666
- package/lib/debug.js +372 -372
- package/lib/env/index.js +465 -465
- package/lib/errors.js +683 -683
- package/lib/fetch/index.js +256 -256
- package/lib/grpc/balancer.js +378 -378
- package/lib/grpc/call.js +708 -708
- package/lib/grpc/client.js +764 -764
- package/lib/grpc/codec.js +1221 -1221
- package/lib/grpc/credentials.js +398 -398
- package/lib/grpc/frame.js +262 -262
- package/lib/grpc/health.js +287 -287
- package/lib/grpc/index.js +121 -121
- package/lib/grpc/metadata.js +461 -461
- package/lib/grpc/proto.js +821 -821
- package/lib/grpc/reflection.js +590 -590
- package/lib/grpc/server.js +445 -445
- package/lib/grpc/status.js +118 -118
- package/lib/grpc/watch.js +173 -173
- package/lib/http/index.js +10 -10
- package/lib/http/request.js +727 -727
- package/lib/http/response.js +799 -799
- package/lib/lifecycle.js +557 -557
- package/lib/middleware/compress.js +230 -230
- package/lib/middleware/cookieParser.js +237 -237
- package/lib/middleware/cors.js +93 -93
- package/lib/middleware/csrf.js +137 -137
- package/lib/middleware/errorHandler.js +101 -101
- package/lib/middleware/helmet.js +175 -175
- package/lib/middleware/index.js +19 -17
- package/lib/middleware/logger.js +74 -74
- package/lib/middleware/rateLimit.js +88 -88
- package/lib/middleware/requestId.js +53 -53
- package/lib/middleware/static.js +326 -326
- package/lib/middleware/timeout.js +71 -71
- package/lib/middleware/validator.js +255 -255
- package/lib/observe/health.js +326 -326
- package/lib/observe/index.js +50 -50
- package/lib/observe/logger.js +359 -359
- package/lib/observe/metrics.js +805 -805
- package/lib/observe/tracing.js +592 -592
- package/lib/orm/adapters/json.js +290 -290
- package/lib/orm/adapters/memory.js +764 -764
- package/lib/orm/adapters/mongo.js +764 -764
- package/lib/orm/adapters/mysql.js +933 -933
- package/lib/orm/adapters/postgres.js +1144 -1144
- package/lib/orm/adapters/redis.js +1534 -1534
- package/lib/orm/adapters/sql-base.js +212 -212
- package/lib/orm/adapters/sqlite.js +858 -858
- package/lib/orm/audit.js +649 -649
- package/lib/orm/cache.js +394 -394
- package/lib/orm/geo.js +387 -387
- package/lib/orm/index.js +784 -784
- package/lib/orm/migrate.js +432 -432
- package/lib/orm/model.js +1706 -1706
- package/lib/orm/plugin.js +375 -375
- package/lib/orm/procedures.js +836 -836
- package/lib/orm/profiler.js +233 -233
- package/lib/orm/query.js +1772 -1772
- package/lib/orm/replicas.js +241 -241
- package/lib/orm/schema.js +307 -307
- package/lib/orm/search.js +380 -380
- package/lib/orm/seed/data/commerce.js +136 -136
- package/lib/orm/seed/data/internet.js +111 -111
- package/lib/orm/seed/data/locations.js +204 -204
- package/lib/orm/seed/data/names.js +338 -338
- package/lib/orm/seed/data/person.js +128 -128
- package/lib/orm/seed/data/phone.js +211 -211
- package/lib/orm/seed/data/words.js +134 -134
- package/lib/orm/seed/factory.js +178 -178
- package/lib/orm/seed/fake.js +1186 -1186
- package/lib/orm/seed/index.js +18 -18
- package/lib/orm/seed/rng.js +70 -70
- package/lib/orm/seed/seeder.js +124 -124
- package/lib/orm/seed/unique.js +68 -68
- package/lib/orm/snapshot.js +366 -366
- package/lib/orm/tenancy.js +605 -605
- package/lib/orm/views.js +350 -350
- package/lib/router/index.js +436 -436
- package/lib/sse/index.js +8 -8
- package/lib/sse/stream.js +349 -349
- package/lib/ws/connection.js +451 -451
- package/lib/ws/handshake.js +125 -125
- package/lib/ws/index.js +14 -14
- package/lib/ws/room.js +223 -223
- package/package.json +73 -73
- package/types/app.d.ts +223 -223
- package/types/auth.d.ts +520 -520
- package/types/cluster.d.ts +75 -75
- package/types/env.d.ts +80 -80
- package/types/errors.d.ts +316 -316
- package/types/fetch.d.ts +43 -43
- package/types/grpc.d.ts +432 -432
- package/types/index.d.ts +384 -384
- package/types/lifecycle.d.ts +60 -60
- package/types/middleware.d.ts +320 -320
- package/types/observe.d.ts +304 -304
- package/types/orm.d.ts +1887 -1887
- package/types/request.d.ts +109 -109
- package/types/response.d.ts +157 -157
- package/types/router.d.ts +78 -78
- package/types/sse.d.ts +78 -78
- package/types/websocket.d.ts +126 -126
package/lib/orm/cache.js
CHANGED
|
@@ -1,394 +1,394 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @module orm/cache
|
|
3
|
-
* @description Query caching layer for the zero-server ORM.
|
|
4
|
-
* Provides an in-memory LRU cache with TTL support.
|
|
5
|
-
* Can also delegate to a Redis adapter for distributed caching.
|
|
6
|
-
*
|
|
7
|
-
* @example
|
|
8
|
-
* const { Database, QueryCache } = require('@zero-server/sdk');
|
|
9
|
-
*
|
|
10
|
-
* const db = Database.connect('sqlite', { filename: './app.db' });
|
|
11
|
-
* const cache = new QueryCache({ maxEntries: 500, defaultTTL: 60 });
|
|
12
|
-
*
|
|
13
|
-
* // Attach cache to database
|
|
14
|
-
* db.cache = cache;
|
|
15
|
-
*
|
|
16
|
-
* // Use in queries (via Model.query().cache(ttl))
|
|
17
|
-
* const users = await User.query().where('active', true).cache(30).exec();
|
|
18
|
-
*
|
|
19
|
-
* // Manual cache operations
|
|
20
|
-
* cache.set('custom:key', { data: 'value' }, 120);
|
|
21
|
-
* const val = cache.get('custom:key');
|
|
22
|
-
* cache.invalidate('users'); // Clear all user-related caches
|
|
23
|
-
* cache.flush(); // Clear everything
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
const log = require('../debug')('zero:cache');
|
|
27
|
-
|
|
28
|
-
class QueryCache
|
|
29
|
-
{
|
|
30
|
-
/**
|
|
31
|
-
* @constructor
|
|
32
|
-
* @param {object} [options] - Configuration options.
|
|
33
|
-
* @param {number} [options.maxEntries=1000] - Maximum cache entries (LRU eviction).
|
|
34
|
-
* @param {number} [options.defaultTTL=60] - Default TTL in seconds (0 = no expiry).
|
|
35
|
-
* @param {string} [options.prefix='qc:'] - Key prefix for cache namespacing.
|
|
36
|
-
* @param {object} [options.redis] - Redis adapter instance for distributed caching.
|
|
37
|
-
*/
|
|
38
|
-
constructor(options = {})
|
|
39
|
-
{
|
|
40
|
-
this._maxEntries = Math.max(1, Math.floor(options.maxEntries != null ? options.maxEntries : 1000) || 1);
|
|
41
|
-
this._defaultTTL = Math.max(0, Number(options.defaultTTL != null ? options.defaultTTL : 60) || 0);
|
|
42
|
-
this._prefix = options.prefix || 'qc:';
|
|
43
|
-
this._redis = options.redis || null;
|
|
44
|
-
|
|
45
|
-
// In-memory LRU storage
|
|
46
|
-
/** @private Map of key → { value, expiresAt, accessedAt } */
|
|
47
|
-
this._store = new Map();
|
|
48
|
-
|
|
49
|
-
// In-flight request dedup (cache stampede prevention)
|
|
50
|
-
/** @private Map of key → Promise */
|
|
51
|
-
this._inflight = new Map();
|
|
52
|
-
|
|
53
|
-
// Stats
|
|
54
|
-
this._hits = 0;
|
|
55
|
-
this._misses = 0;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Generate a cache key from a query descriptor.
|
|
60
|
-
* @param {object} descriptor - Query builder descriptor.
|
|
61
|
-
* @returns {string} Deterministic cache key derived from the descriptor.
|
|
62
|
-
*/
|
|
63
|
-
static keyFromDescriptor(descriptor)
|
|
64
|
-
{
|
|
65
|
-
const parts = [
|
|
66
|
-
descriptor.table || '',
|
|
67
|
-
descriptor.action || 'select',
|
|
68
|
-
JSON.stringify(descriptor.where || []),
|
|
69
|
-
JSON.stringify(descriptor.orderBy || []),
|
|
70
|
-
JSON.stringify(descriptor.fields || []),
|
|
71
|
-
descriptor.limit || '',
|
|
72
|
-
descriptor.offset || '',
|
|
73
|
-
descriptor.distinct ? 'd' : '',
|
|
74
|
-
JSON.stringify(descriptor.groupBy || []),
|
|
75
|
-
JSON.stringify(descriptor.having || []),
|
|
76
|
-
JSON.stringify(descriptor.joins || []),
|
|
77
|
-
];
|
|
78
|
-
return parts.join('|');
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Get a cached value.
|
|
83
|
-
* @param {string} key - Cache key.
|
|
84
|
-
* @returns {*|undefined} Cached value, or undefined if miss.
|
|
85
|
-
*/
|
|
86
|
-
get(key)
|
|
87
|
-
{
|
|
88
|
-
const prefixedKey = this._prefix + key;
|
|
89
|
-
|
|
90
|
-
// Redis mode
|
|
91
|
-
if (this._redis) return this._getRedis(key);
|
|
92
|
-
|
|
93
|
-
const entry = this._store.get(prefixedKey);
|
|
94
|
-
if (!entry)
|
|
95
|
-
{
|
|
96
|
-
this._misses++;
|
|
97
|
-
return undefined;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// Check TTL
|
|
101
|
-
if (entry.expiresAt && Date.now() > entry.expiresAt)
|
|
102
|
-
{
|
|
103
|
-
this._store.delete(prefixedKey);
|
|
104
|
-
this._misses++;
|
|
105
|
-
return undefined;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// LRU: move to end (most recently used)
|
|
109
|
-
this._store.delete(prefixedKey);
|
|
110
|
-
entry.accessedAt = Date.now();
|
|
111
|
-
this._store.set(prefixedKey, entry);
|
|
112
|
-
|
|
113
|
-
this._hits++;
|
|
114
|
-
return entry.value;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* Set a cached value.
|
|
119
|
-
* @param {string} key - Cache key.
|
|
120
|
-
* @param {*} value - Value to cache.
|
|
121
|
-
* @param {number} [ttl] - TTL in seconds. Defaults to defaultTTL.
|
|
122
|
-
*/
|
|
123
|
-
set(key, value, ttl)
|
|
124
|
-
{
|
|
125
|
-
const prefixedKey = this._prefix + key;
|
|
126
|
-
const seconds = Math.max(0, Number(ttl !== undefined ? ttl : this._defaultTTL) || 0);
|
|
127
|
-
|
|
128
|
-
// Redis mode
|
|
129
|
-
if (this._redis) return this._setRedis(key, value, seconds);
|
|
130
|
-
|
|
131
|
-
// Evict LRU entries if at capacity
|
|
132
|
-
while (this._store.size >= this._maxEntries)
|
|
133
|
-
{
|
|
134
|
-
const firstKey = this._store.keys().next().value;
|
|
135
|
-
this._store.delete(firstKey);
|
|
136
|
-
log('LRU evict: %s', firstKey);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
this._store.set(prefixedKey, {
|
|
140
|
-
value,
|
|
141
|
-
expiresAt: seconds > 0 ? Date.now() + (seconds * 1000) : null,
|
|
142
|
-
accessedAt: Date.now(),
|
|
143
|
-
});
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* Delete a specific cache entry.
|
|
148
|
-
* @param {string} key - Cache key.
|
|
149
|
-
* @returns {boolean} True if the key existed.
|
|
150
|
-
*/
|
|
151
|
-
delete(key)
|
|
152
|
-
{
|
|
153
|
-
if (this._redis) return this._deleteRedis(key);
|
|
154
|
-
return this._store.delete(this._prefix + key);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* Check if a key exists in cache (and is not expired).
|
|
159
|
-
* @param {string} key - Cache key.
|
|
160
|
-
* @returns {boolean} True if the key exists and is not expired.
|
|
161
|
-
*/
|
|
162
|
-
has(key)
|
|
163
|
-
{
|
|
164
|
-
if (this._redis) return this._hasRedis(key);
|
|
165
|
-
|
|
166
|
-
const entry = this._store.get(this._prefix + key);
|
|
167
|
-
if (!entry) return false;
|
|
168
|
-
if (entry.expiresAt && Date.now() > entry.expiresAt)
|
|
169
|
-
{
|
|
170
|
-
this._store.delete(this._prefix + key);
|
|
171
|
-
return false;
|
|
172
|
-
}
|
|
173
|
-
return true;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
* Invalidate all cache entries for a specific table/model.
|
|
178
|
-
* Removes any cache key that contains the table name.
|
|
179
|
-
* @param {string} table - Table name to invalidate.
|
|
180
|
-
* @returns {number} Number of entries removed.
|
|
181
|
-
*/
|
|
182
|
-
invalidate(table)
|
|
183
|
-
{
|
|
184
|
-
if (this._redis) return this._invalidateRedis(table);
|
|
185
|
-
|
|
186
|
-
let count = 0;
|
|
187
|
-
for (const key of this._store.keys())
|
|
188
|
-
{
|
|
189
|
-
// Check if the key's descriptor contains this table
|
|
190
|
-
if (key.includes(table))
|
|
191
|
-
{
|
|
192
|
-
this._store.delete(key);
|
|
193
|
-
count++;
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
log('Invalidated %d entries for "%s"', count, table);
|
|
197
|
-
return count;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* Clear the entire cache.
|
|
202
|
-
* @returns {number} Number of entries flushed.
|
|
203
|
-
*/
|
|
204
|
-
flush()
|
|
205
|
-
{
|
|
206
|
-
if (this._redis) return this._flushRedis();
|
|
207
|
-
|
|
208
|
-
const count = this._store.size;
|
|
209
|
-
this._store.clear();
|
|
210
|
-
this._hits = 0;
|
|
211
|
-
this._misses = 0;
|
|
212
|
-
log('Flushed %d entries', count);
|
|
213
|
-
return count;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* Get cache statistics.
|
|
218
|
-
* @returns {{ size: number, hits: number, misses: number, hitRate: number, maxEntries: number }}
|
|
219
|
-
*/
|
|
220
|
-
stats()
|
|
221
|
-
{
|
|
222
|
-
const total = this._hits + this._misses;
|
|
223
|
-
return {
|
|
224
|
-
size: this._store.size,
|
|
225
|
-
hits: this._hits,
|
|
226
|
-
misses: this._misses,
|
|
227
|
-
hitRate: total > 0 ? (this._hits / total) : 0,
|
|
228
|
-
maxEntries: this._maxEntries,
|
|
229
|
-
};
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
/**
|
|
233
|
-
* Remove expired entries (garbage collection).
|
|
234
|
-
* Called automatically but can be triggered manually.
|
|
235
|
-
* @returns {number} Number of expired entries removed.
|
|
236
|
-
*/
|
|
237
|
-
prune()
|
|
238
|
-
{
|
|
239
|
-
let count = 0;
|
|
240
|
-
const now = Date.now();
|
|
241
|
-
for (const [key, entry] of this._store)
|
|
242
|
-
{
|
|
243
|
-
if (entry.expiresAt && now > entry.expiresAt)
|
|
244
|
-
{
|
|
245
|
-
this._store.delete(key);
|
|
246
|
-
count++;
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
if (count > 0) log('Pruned %d expired entries', count);
|
|
250
|
-
return count;
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
/**
|
|
254
|
-
* Get or set: return cached value if available, otherwise call fn() and cache the result.
|
|
255
|
-
* @param {string} key - Cache key.
|
|
256
|
-
* @param {Function} fn - Async function to compute the value.
|
|
257
|
-
* @param {number} [ttl] - TTL in seconds.
|
|
258
|
-
* @returns {Promise<*>} Resolved value.
|
|
259
|
-
*
|
|
260
|
-
* @example
|
|
261
|
-
* const users = await cache.remember('active-users', async () => {
|
|
262
|
-
* return User.find({ active: true });
|
|
263
|
-
* }, 60);
|
|
264
|
-
*/
|
|
265
|
-
async remember(key, fn, ttl)
|
|
266
|
-
{
|
|
267
|
-
const cached = this._redis ? await this.get(key) : this.get(key);
|
|
268
|
-
if (cached !== undefined) return cached;
|
|
269
|
-
|
|
270
|
-
// Deduplicate concurrent calls for the same key (cache stampede prevention)
|
|
271
|
-
if (this._inflight.has(key))
|
|
272
|
-
{
|
|
273
|
-
return this._inflight.get(key);
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
const promise = fn().then(value =>
|
|
277
|
-
{
|
|
278
|
-
if (this._redis) this.set(key, value, ttl);
|
|
279
|
-
else this.set(key, value, ttl);
|
|
280
|
-
return value;
|
|
281
|
-
}).finally(() =>
|
|
282
|
-
{
|
|
283
|
-
this._inflight.delete(key);
|
|
284
|
-
});
|
|
285
|
-
|
|
286
|
-
this._inflight.set(key, promise);
|
|
287
|
-
return promise;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
/**
|
|
291
|
-
* Wrap a query execution with caching.
|
|
292
|
-
* Used internally by the Query builder's `.cache()` method.
|
|
293
|
-
*
|
|
294
|
-
* @param {object} descriptor - Query descriptor.
|
|
295
|
-
* @param {Function} executor - Function that executes the query.
|
|
296
|
-
* @param {number} [ttl] - TTL in seconds.
|
|
297
|
-
* @returns {Promise<*>} Resolved value.
|
|
298
|
-
*/
|
|
299
|
-
async wrap(descriptor, executor, ttl)
|
|
300
|
-
{
|
|
301
|
-
const key = QueryCache.keyFromDescriptor(descriptor);
|
|
302
|
-
return this.remember(key, executor, ttl);
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
// -- Redis-Backed Methods -----------------------------
|
|
306
|
-
|
|
307
|
-
/** @private */
|
|
308
|
-
async _getRedis(key)
|
|
309
|
-
{
|
|
310
|
-
try
|
|
311
|
-
{
|
|
312
|
-
const val = await this._redis.get(this._prefix + key);
|
|
313
|
-
if (val === null)
|
|
314
|
-
{
|
|
315
|
-
this._misses++;
|
|
316
|
-
return undefined;
|
|
317
|
-
}
|
|
318
|
-
this._hits++;
|
|
319
|
-
try { return JSON.parse(val); }
|
|
320
|
-
catch (_) { return val; }
|
|
321
|
-
}
|
|
322
|
-
catch (_)
|
|
323
|
-
{
|
|
324
|
-
this._misses++;
|
|
325
|
-
return undefined;
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
/** @private */
|
|
330
|
-
async _setRedis(key, value, ttl)
|
|
331
|
-
{
|
|
332
|
-
const v = JSON.stringify(value);
|
|
333
|
-
if (ttl > 0) await this._redis.set(this._prefix + key, v, ttl);
|
|
334
|
-
else await this._redis.set(this._prefix + key, v);
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
/** @private */
|
|
338
|
-
async _deleteRedis(key)
|
|
339
|
-
{
|
|
340
|
-
const result = await this._redis.del(this._prefix + key);
|
|
341
|
-
return result > 0;
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
/** @private */
|
|
345
|
-
async _hasRedis(key)
|
|
346
|
-
{
|
|
347
|
-
return await this._redis.exists(this._prefix + key);
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
/** @private */
|
|
351
|
-
async _invalidateRedis(table)
|
|
352
|
-
{
|
|
353
|
-
// Use SCAN to find matching keys
|
|
354
|
-
let count = 0;
|
|
355
|
-
let cursor = '0';
|
|
356
|
-
const pattern = this._prefix + '*' + table + '*';
|
|
357
|
-
const client = this._redis._client || this._redis;
|
|
358
|
-
do
|
|
359
|
-
{
|
|
360
|
-
const [nextCursor, keys] = await client.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
|
|
361
|
-
cursor = nextCursor;
|
|
362
|
-
if (keys.length > 0)
|
|
363
|
-
{
|
|
364
|
-
await client.del(...keys);
|
|
365
|
-
count += keys.length;
|
|
366
|
-
}
|
|
367
|
-
} while (cursor !== '0');
|
|
368
|
-
return count;
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
/** @private */
|
|
372
|
-
async _flushRedis()
|
|
373
|
-
{
|
|
374
|
-
let count = 0;
|
|
375
|
-
let cursor = '0';
|
|
376
|
-
const pattern = this._prefix + '*';
|
|
377
|
-
const client = this._redis._client || this._redis;
|
|
378
|
-
do
|
|
379
|
-
{
|
|
380
|
-
const [nextCursor, keys] = await client.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
|
|
381
|
-
cursor = nextCursor;
|
|
382
|
-
if (keys.length > 0)
|
|
383
|
-
{
|
|
384
|
-
await client.del(...keys);
|
|
385
|
-
count += keys.length;
|
|
386
|
-
}
|
|
387
|
-
} while (cursor !== '0');
|
|
388
|
-
this._hits = 0;
|
|
389
|
-
this._misses = 0;
|
|
390
|
-
return count;
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
module.exports = { QueryCache };
|
|
1
|
+
/**
|
|
2
|
+
* @module orm/cache
|
|
3
|
+
* @description Query caching layer for the zero-server ORM.
|
|
4
|
+
* Provides an in-memory LRU cache with TTL support.
|
|
5
|
+
* Can also delegate to a Redis adapter for distributed caching.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* const { Database, QueryCache } = require('@zero-server/sdk');
|
|
9
|
+
*
|
|
10
|
+
* const db = Database.connect('sqlite', { filename: './app.db' });
|
|
11
|
+
* const cache = new QueryCache({ maxEntries: 500, defaultTTL: 60 });
|
|
12
|
+
*
|
|
13
|
+
* // Attach cache to database
|
|
14
|
+
* db.cache = cache;
|
|
15
|
+
*
|
|
16
|
+
* // Use in queries (via Model.query().cache(ttl))
|
|
17
|
+
* const users = await User.query().where('active', true).cache(30).exec();
|
|
18
|
+
*
|
|
19
|
+
* // Manual cache operations
|
|
20
|
+
* cache.set('custom:key', { data: 'value' }, 120);
|
|
21
|
+
* const val = cache.get('custom:key');
|
|
22
|
+
* cache.invalidate('users'); // Clear all user-related caches
|
|
23
|
+
* cache.flush(); // Clear everything
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const log = require('../debug')('zero:cache');
|
|
27
|
+
|
|
28
|
+
class QueryCache
|
|
29
|
+
{
|
|
30
|
+
/**
|
|
31
|
+
* @constructor
|
|
32
|
+
* @param {object} [options] - Configuration options.
|
|
33
|
+
* @param {number} [options.maxEntries=1000] - Maximum cache entries (LRU eviction).
|
|
34
|
+
* @param {number} [options.defaultTTL=60] - Default TTL in seconds (0 = no expiry).
|
|
35
|
+
* @param {string} [options.prefix='qc:'] - Key prefix for cache namespacing.
|
|
36
|
+
* @param {object} [options.redis] - Redis adapter instance for distributed caching.
|
|
37
|
+
*/
|
|
38
|
+
constructor(options = {})
|
|
39
|
+
{
|
|
40
|
+
this._maxEntries = Math.max(1, Math.floor(options.maxEntries != null ? options.maxEntries : 1000) || 1);
|
|
41
|
+
this._defaultTTL = Math.max(0, Number(options.defaultTTL != null ? options.defaultTTL : 60) || 0);
|
|
42
|
+
this._prefix = options.prefix || 'qc:';
|
|
43
|
+
this._redis = options.redis || null;
|
|
44
|
+
|
|
45
|
+
// In-memory LRU storage
|
|
46
|
+
/** @private Map of key → { value, expiresAt, accessedAt } */
|
|
47
|
+
this._store = new Map();
|
|
48
|
+
|
|
49
|
+
// In-flight request dedup (cache stampede prevention)
|
|
50
|
+
/** @private Map of key → Promise */
|
|
51
|
+
this._inflight = new Map();
|
|
52
|
+
|
|
53
|
+
// Stats
|
|
54
|
+
this._hits = 0;
|
|
55
|
+
this._misses = 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Generate a cache key from a query descriptor.
|
|
60
|
+
* @param {object} descriptor - Query builder descriptor.
|
|
61
|
+
* @returns {string} Deterministic cache key derived from the descriptor.
|
|
62
|
+
*/
|
|
63
|
+
static keyFromDescriptor(descriptor)
|
|
64
|
+
{
|
|
65
|
+
const parts = [
|
|
66
|
+
descriptor.table || '',
|
|
67
|
+
descriptor.action || 'select',
|
|
68
|
+
JSON.stringify(descriptor.where || []),
|
|
69
|
+
JSON.stringify(descriptor.orderBy || []),
|
|
70
|
+
JSON.stringify(descriptor.fields || []),
|
|
71
|
+
descriptor.limit || '',
|
|
72
|
+
descriptor.offset || '',
|
|
73
|
+
descriptor.distinct ? 'd' : '',
|
|
74
|
+
JSON.stringify(descriptor.groupBy || []),
|
|
75
|
+
JSON.stringify(descriptor.having || []),
|
|
76
|
+
JSON.stringify(descriptor.joins || []),
|
|
77
|
+
];
|
|
78
|
+
return parts.join('|');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Get a cached value.
|
|
83
|
+
* @param {string} key - Cache key.
|
|
84
|
+
* @returns {*|undefined} Cached value, or undefined if miss.
|
|
85
|
+
*/
|
|
86
|
+
get(key)
|
|
87
|
+
{
|
|
88
|
+
const prefixedKey = this._prefix + key;
|
|
89
|
+
|
|
90
|
+
// Redis mode
|
|
91
|
+
if (this._redis) return this._getRedis(key);
|
|
92
|
+
|
|
93
|
+
const entry = this._store.get(prefixedKey);
|
|
94
|
+
if (!entry)
|
|
95
|
+
{
|
|
96
|
+
this._misses++;
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Check TTL
|
|
101
|
+
if (entry.expiresAt && Date.now() > entry.expiresAt)
|
|
102
|
+
{
|
|
103
|
+
this._store.delete(prefixedKey);
|
|
104
|
+
this._misses++;
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// LRU: move to end (most recently used)
|
|
109
|
+
this._store.delete(prefixedKey);
|
|
110
|
+
entry.accessedAt = Date.now();
|
|
111
|
+
this._store.set(prefixedKey, entry);
|
|
112
|
+
|
|
113
|
+
this._hits++;
|
|
114
|
+
return entry.value;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Set a cached value.
|
|
119
|
+
* @param {string} key - Cache key.
|
|
120
|
+
* @param {*} value - Value to cache.
|
|
121
|
+
* @param {number} [ttl] - TTL in seconds. Defaults to defaultTTL.
|
|
122
|
+
*/
|
|
123
|
+
set(key, value, ttl)
|
|
124
|
+
{
|
|
125
|
+
const prefixedKey = this._prefix + key;
|
|
126
|
+
const seconds = Math.max(0, Number(ttl !== undefined ? ttl : this._defaultTTL) || 0);
|
|
127
|
+
|
|
128
|
+
// Redis mode
|
|
129
|
+
if (this._redis) return this._setRedis(key, value, seconds);
|
|
130
|
+
|
|
131
|
+
// Evict LRU entries if at capacity
|
|
132
|
+
while (this._store.size >= this._maxEntries)
|
|
133
|
+
{
|
|
134
|
+
const firstKey = this._store.keys().next().value;
|
|
135
|
+
this._store.delete(firstKey);
|
|
136
|
+
log('LRU evict: %s', firstKey);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
this._store.set(prefixedKey, {
|
|
140
|
+
value,
|
|
141
|
+
expiresAt: seconds > 0 ? Date.now() + (seconds * 1000) : null,
|
|
142
|
+
accessedAt: Date.now(),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Delete a specific cache entry.
|
|
148
|
+
* @param {string} key - Cache key.
|
|
149
|
+
* @returns {boolean} True if the key existed.
|
|
150
|
+
*/
|
|
151
|
+
delete(key)
|
|
152
|
+
{
|
|
153
|
+
if (this._redis) return this._deleteRedis(key);
|
|
154
|
+
return this._store.delete(this._prefix + key);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Check if a key exists in cache (and is not expired).
|
|
159
|
+
* @param {string} key - Cache key.
|
|
160
|
+
* @returns {boolean} True if the key exists and is not expired.
|
|
161
|
+
*/
|
|
162
|
+
has(key)
|
|
163
|
+
{
|
|
164
|
+
if (this._redis) return this._hasRedis(key);
|
|
165
|
+
|
|
166
|
+
const entry = this._store.get(this._prefix + key);
|
|
167
|
+
if (!entry) return false;
|
|
168
|
+
if (entry.expiresAt && Date.now() > entry.expiresAt)
|
|
169
|
+
{
|
|
170
|
+
this._store.delete(this._prefix + key);
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Invalidate all cache entries for a specific table/model.
|
|
178
|
+
* Removes any cache key that contains the table name.
|
|
179
|
+
* @param {string} table - Table name to invalidate.
|
|
180
|
+
* @returns {number} Number of entries removed.
|
|
181
|
+
*/
|
|
182
|
+
invalidate(table)
|
|
183
|
+
{
|
|
184
|
+
if (this._redis) return this._invalidateRedis(table);
|
|
185
|
+
|
|
186
|
+
let count = 0;
|
|
187
|
+
for (const key of this._store.keys())
|
|
188
|
+
{
|
|
189
|
+
// Check if the key's descriptor contains this table
|
|
190
|
+
if (key.includes(table))
|
|
191
|
+
{
|
|
192
|
+
this._store.delete(key);
|
|
193
|
+
count++;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
log('Invalidated %d entries for "%s"', count, table);
|
|
197
|
+
return count;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Clear the entire cache.
|
|
202
|
+
* @returns {number} Number of entries flushed.
|
|
203
|
+
*/
|
|
204
|
+
flush()
|
|
205
|
+
{
|
|
206
|
+
if (this._redis) return this._flushRedis();
|
|
207
|
+
|
|
208
|
+
const count = this._store.size;
|
|
209
|
+
this._store.clear();
|
|
210
|
+
this._hits = 0;
|
|
211
|
+
this._misses = 0;
|
|
212
|
+
log('Flushed %d entries', count);
|
|
213
|
+
return count;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Get cache statistics.
|
|
218
|
+
* @returns {{ size: number, hits: number, misses: number, hitRate: number, maxEntries: number }}
|
|
219
|
+
*/
|
|
220
|
+
stats()
|
|
221
|
+
{
|
|
222
|
+
const total = this._hits + this._misses;
|
|
223
|
+
return {
|
|
224
|
+
size: this._store.size,
|
|
225
|
+
hits: this._hits,
|
|
226
|
+
misses: this._misses,
|
|
227
|
+
hitRate: total > 0 ? (this._hits / total) : 0,
|
|
228
|
+
maxEntries: this._maxEntries,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Remove expired entries (garbage collection).
|
|
234
|
+
* Called automatically but can be triggered manually.
|
|
235
|
+
* @returns {number} Number of expired entries removed.
|
|
236
|
+
*/
|
|
237
|
+
prune()
|
|
238
|
+
{
|
|
239
|
+
let count = 0;
|
|
240
|
+
const now = Date.now();
|
|
241
|
+
for (const [key, entry] of this._store)
|
|
242
|
+
{
|
|
243
|
+
if (entry.expiresAt && now > entry.expiresAt)
|
|
244
|
+
{
|
|
245
|
+
this._store.delete(key);
|
|
246
|
+
count++;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (count > 0) log('Pruned %d expired entries', count);
|
|
250
|
+
return count;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Get or set: return cached value if available, otherwise call fn() and cache the result.
|
|
255
|
+
* @param {string} key - Cache key.
|
|
256
|
+
* @param {Function} fn - Async function to compute the value.
|
|
257
|
+
* @param {number} [ttl] - TTL in seconds.
|
|
258
|
+
* @returns {Promise<*>} Resolved value.
|
|
259
|
+
*
|
|
260
|
+
* @example
|
|
261
|
+
* const users = await cache.remember('active-users', async () => {
|
|
262
|
+
* return User.find({ active: true });
|
|
263
|
+
* }, 60);
|
|
264
|
+
*/
|
|
265
|
+
async remember(key, fn, ttl)
|
|
266
|
+
{
|
|
267
|
+
const cached = this._redis ? await this.get(key) : this.get(key);
|
|
268
|
+
if (cached !== undefined) return cached;
|
|
269
|
+
|
|
270
|
+
// Deduplicate concurrent calls for the same key (cache stampede prevention)
|
|
271
|
+
if (this._inflight.has(key))
|
|
272
|
+
{
|
|
273
|
+
return this._inflight.get(key);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const promise = fn().then(value =>
|
|
277
|
+
{
|
|
278
|
+
if (this._redis) this.set(key, value, ttl);
|
|
279
|
+
else this.set(key, value, ttl);
|
|
280
|
+
return value;
|
|
281
|
+
}).finally(() =>
|
|
282
|
+
{
|
|
283
|
+
this._inflight.delete(key);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
this._inflight.set(key, promise);
|
|
287
|
+
return promise;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Wrap a query execution with caching.
|
|
292
|
+
* Used internally by the Query builder's `.cache()` method.
|
|
293
|
+
*
|
|
294
|
+
* @param {object} descriptor - Query descriptor.
|
|
295
|
+
* @param {Function} executor - Function that executes the query.
|
|
296
|
+
* @param {number} [ttl] - TTL in seconds.
|
|
297
|
+
* @returns {Promise<*>} Resolved value.
|
|
298
|
+
*/
|
|
299
|
+
async wrap(descriptor, executor, ttl)
|
|
300
|
+
{
|
|
301
|
+
const key = QueryCache.keyFromDescriptor(descriptor);
|
|
302
|
+
return this.remember(key, executor, ttl);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// -- Redis-Backed Methods -----------------------------
|
|
306
|
+
|
|
307
|
+
/** @private */
|
|
308
|
+
async _getRedis(key)
|
|
309
|
+
{
|
|
310
|
+
try
|
|
311
|
+
{
|
|
312
|
+
const val = await this._redis.get(this._prefix + key);
|
|
313
|
+
if (val === null)
|
|
314
|
+
{
|
|
315
|
+
this._misses++;
|
|
316
|
+
return undefined;
|
|
317
|
+
}
|
|
318
|
+
this._hits++;
|
|
319
|
+
try { return JSON.parse(val); }
|
|
320
|
+
catch (_) { return val; }
|
|
321
|
+
}
|
|
322
|
+
catch (_)
|
|
323
|
+
{
|
|
324
|
+
this._misses++;
|
|
325
|
+
return undefined;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** @private */
|
|
330
|
+
async _setRedis(key, value, ttl)
|
|
331
|
+
{
|
|
332
|
+
const v = JSON.stringify(value);
|
|
333
|
+
if (ttl > 0) await this._redis.set(this._prefix + key, v, ttl);
|
|
334
|
+
else await this._redis.set(this._prefix + key, v);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** @private */
|
|
338
|
+
async _deleteRedis(key)
|
|
339
|
+
{
|
|
340
|
+
const result = await this._redis.del(this._prefix + key);
|
|
341
|
+
return result > 0;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** @private */
|
|
345
|
+
async _hasRedis(key)
|
|
346
|
+
{
|
|
347
|
+
return await this._redis.exists(this._prefix + key);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** @private */
|
|
351
|
+
async _invalidateRedis(table)
|
|
352
|
+
{
|
|
353
|
+
// Use SCAN to find matching keys
|
|
354
|
+
let count = 0;
|
|
355
|
+
let cursor = '0';
|
|
356
|
+
const pattern = this._prefix + '*' + table + '*';
|
|
357
|
+
const client = this._redis._client || this._redis;
|
|
358
|
+
do
|
|
359
|
+
{
|
|
360
|
+
const [nextCursor, keys] = await client.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
|
|
361
|
+
cursor = nextCursor;
|
|
362
|
+
if (keys.length > 0)
|
|
363
|
+
{
|
|
364
|
+
await client.del(...keys);
|
|
365
|
+
count += keys.length;
|
|
366
|
+
}
|
|
367
|
+
} while (cursor !== '0');
|
|
368
|
+
return count;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** @private */
|
|
372
|
+
async _flushRedis()
|
|
373
|
+
{
|
|
374
|
+
let count = 0;
|
|
375
|
+
let cursor = '0';
|
|
376
|
+
const pattern = this._prefix + '*';
|
|
377
|
+
const client = this._redis._client || this._redis;
|
|
378
|
+
do
|
|
379
|
+
{
|
|
380
|
+
const [nextCursor, keys] = await client.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
|
|
381
|
+
cursor = nextCursor;
|
|
382
|
+
if (keys.length > 0)
|
|
383
|
+
{
|
|
384
|
+
await client.del(...keys);
|
|
385
|
+
count += keys.length;
|
|
386
|
+
}
|
|
387
|
+
} while (cursor !== '0');
|
|
388
|
+
this._hits = 0;
|
|
389
|
+
this._misses = 0;
|
|
390
|
+
return count;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
module.exports = { QueryCache };
|