@break-limits/mongoose-cache 0.1.0
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 -0
- package/README.md +344 -0
- package/dist/index.cjs +953 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +296 -0
- package/dist/index.d.ts +296 -0
- package/dist/index.js +911 -0
- package/dist/index.js.map +1 -0
- package/package.json +74 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,911 @@
|
|
|
1
|
+
// src/plugins/createCache.ts
|
|
2
|
+
import { EventEmitter } from "events";
|
|
3
|
+
|
|
4
|
+
// src/cache/RedisCacheStore.ts
|
|
5
|
+
var RedisCacheStore = class {
|
|
6
|
+
constructor(redis, versionPrefix = "ver:") {
|
|
7
|
+
this.redis = redis;
|
|
8
|
+
this.versionPrefix = versionPrefix;
|
|
9
|
+
}
|
|
10
|
+
redis;
|
|
11
|
+
versionPrefix;
|
|
12
|
+
async get(key) {
|
|
13
|
+
return this.redis.getBuffer(key);
|
|
14
|
+
}
|
|
15
|
+
async set(key, value, ttlMs) {
|
|
16
|
+
if (ttlMs !== void 0) {
|
|
17
|
+
await this.redis.set(key, value, "PX", ttlMs);
|
|
18
|
+
} else {
|
|
19
|
+
await this.redis.set(key, value);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
async del(keys) {
|
|
23
|
+
if (keys.length === 0) return;
|
|
24
|
+
await this.redis.del(...keys);
|
|
25
|
+
}
|
|
26
|
+
async getVersion(model) {
|
|
27
|
+
const raw = await this.redis.get(this.versionKey(model));
|
|
28
|
+
return raw === null ? 0 : Number(raw);
|
|
29
|
+
}
|
|
30
|
+
async bumpVersion(model) {
|
|
31
|
+
return this.redis.incr(this.versionKey(model));
|
|
32
|
+
}
|
|
33
|
+
async addToSet(setKey, member) {
|
|
34
|
+
await this.redis.sadd(setKey, member);
|
|
35
|
+
}
|
|
36
|
+
async drainSet(setKey) {
|
|
37
|
+
const members = await this.redis.smembers(setKey);
|
|
38
|
+
if (members.length > 0) await this.redis.del(setKey);
|
|
39
|
+
return members;
|
|
40
|
+
}
|
|
41
|
+
versionKey(model) {
|
|
42
|
+
return `${this.versionPrefix}${model}`;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// src/cache/Serializer.ts
|
|
47
|
+
import { BSON } from "bson";
|
|
48
|
+
function serialize(value) {
|
|
49
|
+
const encoded = BSON.serialize({ v: value });
|
|
50
|
+
return Buffer.from(encoded);
|
|
51
|
+
}
|
|
52
|
+
function deserialize(buffer) {
|
|
53
|
+
const decoded = BSON.deserialize(buffer, {
|
|
54
|
+
promoteBuffers: true,
|
|
55
|
+
promoteValues: true
|
|
56
|
+
});
|
|
57
|
+
return decoded.v;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// src/invalidation/RedisDependencyIndex.ts
|
|
61
|
+
var RedisDependencyIndex = class {
|
|
62
|
+
constructor(redis, prefix = "idx:") {
|
|
63
|
+
this.redis = redis;
|
|
64
|
+
this.prefix = prefix;
|
|
65
|
+
}
|
|
66
|
+
redis;
|
|
67
|
+
prefix;
|
|
68
|
+
async addQuery(model, queryKey, meta) {
|
|
69
|
+
await this.redis.sadd(this.setKey(model), queryKey);
|
|
70
|
+
await this.redis.set(this.metaKey(model, queryKey), serialize(meta));
|
|
71
|
+
}
|
|
72
|
+
async removeQuery(model, queryKey) {
|
|
73
|
+
await this.redis.srem(this.setKey(model), queryKey);
|
|
74
|
+
await this.redis.del(this.metaKey(model, queryKey));
|
|
75
|
+
}
|
|
76
|
+
async getQueries(model) {
|
|
77
|
+
const queryKeys = await this.redis.smembers(this.setKey(model));
|
|
78
|
+
const queries = [];
|
|
79
|
+
for (const queryKey of queryKeys) {
|
|
80
|
+
const raw = await this.redis.getBuffer(this.metaKey(model, queryKey));
|
|
81
|
+
if (raw === null) continue;
|
|
82
|
+
const meta = deserialize(raw);
|
|
83
|
+
queries.push({ queryKey, ...meta });
|
|
84
|
+
}
|
|
85
|
+
return queries;
|
|
86
|
+
}
|
|
87
|
+
async clearModel(model) {
|
|
88
|
+
const queryKeys = await this.redis.smembers(this.setKey(model));
|
|
89
|
+
if (queryKeys.length === 0) return [];
|
|
90
|
+
await this.redis.del(
|
|
91
|
+
this.setKey(model),
|
|
92
|
+
...queryKeys.map((qk) => this.metaKey(model, qk))
|
|
93
|
+
);
|
|
94
|
+
return queryKeys;
|
|
95
|
+
}
|
|
96
|
+
setKey(model) {
|
|
97
|
+
return `${this.prefix}qset:${model}`;
|
|
98
|
+
}
|
|
99
|
+
metaKey(model, queryKey) {
|
|
100
|
+
return `${this.prefix}qmeta:${model}:${queryKey}`;
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
// src/cache/fingerprint.ts
|
|
105
|
+
function normalizeQuery(value) {
|
|
106
|
+
if (value === null || typeof value !== "object") {
|
|
107
|
+
return value;
|
|
108
|
+
}
|
|
109
|
+
if (isObjectIdLike(value)) {
|
|
110
|
+
return { $oid: value.toHexString() };
|
|
111
|
+
}
|
|
112
|
+
if (value instanceof Date) {
|
|
113
|
+
return { $date: value.toISOString() };
|
|
114
|
+
}
|
|
115
|
+
if (value instanceof RegExp) {
|
|
116
|
+
return { $regex: value.source, $options: value.flags };
|
|
117
|
+
}
|
|
118
|
+
if (Array.isArray(value)) {
|
|
119
|
+
return value.map((el) => normalizeQuery(el));
|
|
120
|
+
}
|
|
121
|
+
const out = {};
|
|
122
|
+
for (const key of Object.keys(value).sort()) {
|
|
123
|
+
out[key] = normalizeQuery(value[key]);
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
function isObjectIdLike(value) {
|
|
128
|
+
return value._bsontype === "ObjectId" && typeof value.toHexString === "function";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// src/invalidation/PredicateMatcher.ts
|
|
132
|
+
var LOGICAL_OPERATORS = /* @__PURE__ */ new Set(["$and", "$or", "$nor"]);
|
|
133
|
+
var FIELD_OPERATORS = /* @__PURE__ */ new Set([
|
|
134
|
+
"$eq",
|
|
135
|
+
"$ne",
|
|
136
|
+
"$gt",
|
|
137
|
+
"$gte",
|
|
138
|
+
"$lt",
|
|
139
|
+
"$lte",
|
|
140
|
+
"$in",
|
|
141
|
+
"$nin",
|
|
142
|
+
"$exists",
|
|
143
|
+
"$not"
|
|
144
|
+
]);
|
|
145
|
+
function matches(filter, doc) {
|
|
146
|
+
for (const [key, condition] of Object.entries(filter)) {
|
|
147
|
+
if (key === "$and") {
|
|
148
|
+
if (!condition.every((sub) => matches(sub, doc))) return false;
|
|
149
|
+
} else if (key === "$or") {
|
|
150
|
+
if (!condition.some((sub) => matches(sub, doc))) return false;
|
|
151
|
+
} else if (key === "$nor") {
|
|
152
|
+
if (condition.some((sub) => matches(sub, doc))) return false;
|
|
153
|
+
} else {
|
|
154
|
+
const resolved = resolvePath(doc, key);
|
|
155
|
+
if (!fieldMatches(resolved, condition)) return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
function resolvePath(doc, path) {
|
|
161
|
+
const segments = path.split(".");
|
|
162
|
+
let current = doc;
|
|
163
|
+
for (const seg of segments) {
|
|
164
|
+
if (current === null || typeof current !== "object" || Array.isArray(current)) {
|
|
165
|
+
return { exists: false, value: void 0 };
|
|
166
|
+
}
|
|
167
|
+
if (!(seg in current)) {
|
|
168
|
+
return { exists: false, value: void 0 };
|
|
169
|
+
}
|
|
170
|
+
current = current[seg];
|
|
171
|
+
}
|
|
172
|
+
return { exists: true, value: current };
|
|
173
|
+
}
|
|
174
|
+
function isOperatorObject(value) {
|
|
175
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Date) && Object.keys(value).length > 0 && Object.keys(value).every((k) => k.startsWith("$"));
|
|
176
|
+
}
|
|
177
|
+
function fieldMatches(resolved, condition) {
|
|
178
|
+
if (isOperatorObject(condition)) {
|
|
179
|
+
return Object.entries(condition).every(
|
|
180
|
+
([op, operand]) => operatorMatches(resolved, op, operand)
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
return equals(resolved, condition);
|
|
184
|
+
}
|
|
185
|
+
function operatorMatches(resolved, op, operand) {
|
|
186
|
+
switch (op) {
|
|
187
|
+
case "$eq":
|
|
188
|
+
return equals(resolved, operand);
|
|
189
|
+
case "$ne":
|
|
190
|
+
return !equals(resolved, operand);
|
|
191
|
+
case "$in":
|
|
192
|
+
return operand.some((o) => equals(resolved, o));
|
|
193
|
+
case "$nin":
|
|
194
|
+
return !operand.some((o) => equals(resolved, o));
|
|
195
|
+
case "$exists":
|
|
196
|
+
return resolved.exists === operand;
|
|
197
|
+
case "$not":
|
|
198
|
+
return !fieldMatches(resolved, operand);
|
|
199
|
+
case "$gt":
|
|
200
|
+
case "$gte":
|
|
201
|
+
case "$lt":
|
|
202
|
+
case "$lte":
|
|
203
|
+
return compare(resolved.value, op, operand);
|
|
204
|
+
default:
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
function equals(resolved, target) {
|
|
209
|
+
if (target === null) {
|
|
210
|
+
return !resolved.exists || resolved.value === null;
|
|
211
|
+
}
|
|
212
|
+
if (!resolved.exists) return false;
|
|
213
|
+
const fv = resolved.value;
|
|
214
|
+
if (Array.isArray(fv) && fv.some((el) => scalarEquals(el, target))) {
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
return scalarEquals(fv, target);
|
|
218
|
+
}
|
|
219
|
+
function scalarEquals(a, b) {
|
|
220
|
+
if (a === b) return true;
|
|
221
|
+
return canonical(a) === canonical(b);
|
|
222
|
+
}
|
|
223
|
+
function canonical(value) {
|
|
224
|
+
return JSON.stringify(normalizeQuery(value));
|
|
225
|
+
}
|
|
226
|
+
function compare(fieldValue, op, operand) {
|
|
227
|
+
if (Array.isArray(fieldValue)) {
|
|
228
|
+
return fieldValue.some((el) => compareScalar(el, op, operand));
|
|
229
|
+
}
|
|
230
|
+
return compareScalar(fieldValue, op, operand);
|
|
231
|
+
}
|
|
232
|
+
function compareScalar(a, op, b) {
|
|
233
|
+
const left = toComparable(a);
|
|
234
|
+
const right = toComparable(b);
|
|
235
|
+
if (left === null || right === null || typeof left !== typeof right) {
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
switch (op) {
|
|
239
|
+
case "$gt":
|
|
240
|
+
return left > right;
|
|
241
|
+
case "$gte":
|
|
242
|
+
return left >= right;
|
|
243
|
+
case "$lt":
|
|
244
|
+
return left < right;
|
|
245
|
+
case "$lte":
|
|
246
|
+
return left <= right;
|
|
247
|
+
default:
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
function toComparable(value) {
|
|
252
|
+
if (typeof value === "number" || typeof value === "string") return value;
|
|
253
|
+
if (value instanceof Date) return value.getTime();
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
function isSupportedPredicate(filter) {
|
|
257
|
+
if (typeof filter !== "object" || filter === null || Array.isArray(filter)) {
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
for (const [key, condition] of Object.entries(filter)) {
|
|
261
|
+
if (LOGICAL_OPERATORS.has(key)) {
|
|
262
|
+
if (!Array.isArray(condition)) return false;
|
|
263
|
+
if (!condition.every((sub) => isSupportedPredicate(sub))) return false;
|
|
264
|
+
} else if (key.startsWith("$")) {
|
|
265
|
+
return false;
|
|
266
|
+
} else if (!isSupportedCondition(condition)) {
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
function isSupportedCondition(condition) {
|
|
273
|
+
if (condition instanceof RegExp) return false;
|
|
274
|
+
if (isOperatorObject(condition)) {
|
|
275
|
+
return Object.entries(condition).every(([op, operand]) => {
|
|
276
|
+
if (!FIELD_OPERATORS.has(op)) return false;
|
|
277
|
+
if (op === "$not") return isOperatorObject(operand) && isSupportedCondition(operand);
|
|
278
|
+
if (op === "$in" || op === "$nin") {
|
|
279
|
+
return Array.isArray(operand) && !operand.some((el) => el instanceof RegExp);
|
|
280
|
+
}
|
|
281
|
+
if (op === "$exists") return typeof operand === "boolean";
|
|
282
|
+
return true;
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// src/utils/id.ts
|
|
289
|
+
function idToString(id) {
|
|
290
|
+
if (id === null || id === void 0) return void 0;
|
|
291
|
+
if (typeof id === "string") return id;
|
|
292
|
+
if (typeof id === "number") return String(id);
|
|
293
|
+
if (id instanceof Date) return id.toISOString();
|
|
294
|
+
if (typeof id === "object" && id._bsontype === "ObjectId" && typeof id.toHexString === "function") {
|
|
295
|
+
return id.toHexString();
|
|
296
|
+
}
|
|
297
|
+
return String(id);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// src/invalidation/InvalidationEngine.ts
|
|
301
|
+
var InvalidationEngine = class {
|
|
302
|
+
constructor(index) {
|
|
303
|
+
this.index = index;
|
|
304
|
+
}
|
|
305
|
+
index;
|
|
306
|
+
async registerQuery(model, queryKey, meta) {
|
|
307
|
+
await this.index.addQuery(model, queryKey, meta);
|
|
308
|
+
}
|
|
309
|
+
async onWrite(model, before, after) {
|
|
310
|
+
const docId = idToString((after ?? before)?._id);
|
|
311
|
+
const queries = await this.index.getQueries(model);
|
|
312
|
+
const invalidated = [];
|
|
313
|
+
for (const q of queries) {
|
|
314
|
+
const directHit = docId !== void 0 && q.resultDocIds.includes(docId);
|
|
315
|
+
const wasMatch = before !== null && matches(q.predicate, before);
|
|
316
|
+
const nowMatch = after !== null && matches(q.predicate, after);
|
|
317
|
+
const transition = wasMatch !== nowMatch;
|
|
318
|
+
const limitedHit = q.limited && (wasMatch || nowMatch);
|
|
319
|
+
if (directHit || transition || limitedHit) {
|
|
320
|
+
invalidated.push(q.queryKey);
|
|
321
|
+
await this.index.removeQuery(model, q.queryKey);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return { invalidatedQueryKeys: invalidated };
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Remove every registered query for a model and return their cache keys.
|
|
328
|
+
* Used as a conservative fallback (bulkWrite, upserts) where per-document
|
|
329
|
+
* before/after images aren't available.
|
|
330
|
+
*/
|
|
331
|
+
async clearQueries(model) {
|
|
332
|
+
return this.index.clearModel(model);
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
// src/utils/hash.ts
|
|
337
|
+
import { createHash } from "crypto";
|
|
338
|
+
function stableHash(value) {
|
|
339
|
+
const canonical2 = JSON.stringify(normalizeQuery(value));
|
|
340
|
+
return createHash("sha256").update(canonical2 ?? "undefined").digest("hex").slice(0, 32);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// src/cache/CacheKey.ts
|
|
344
|
+
function tenantPrefix(tenant) {
|
|
345
|
+
return tenant ? `tenant:${tenant}:` : "";
|
|
346
|
+
}
|
|
347
|
+
function buildQueryKey(input) {
|
|
348
|
+
const hash = stableHash({
|
|
349
|
+
op: input.op,
|
|
350
|
+
filter: input.filter ?? null,
|
|
351
|
+
projection: input.projection ?? null,
|
|
352
|
+
sort: input.sort ?? null,
|
|
353
|
+
skip: input.skip ?? null,
|
|
354
|
+
limit: input.limit ?? null,
|
|
355
|
+
populate: input.populate ?? null,
|
|
356
|
+
collation: input.collation ?? null,
|
|
357
|
+
distinct: input.distinct ?? null,
|
|
358
|
+
schemaVersion: input.schemaVersion ?? null
|
|
359
|
+
});
|
|
360
|
+
return `${tenantPrefix(input.tenant)}q:${input.model}:${hash}`;
|
|
361
|
+
}
|
|
362
|
+
function buildDocKey(model, id, tenant) {
|
|
363
|
+
return `${tenantPrefix(tenant)}doc:${model}:${id}`;
|
|
364
|
+
}
|
|
365
|
+
function buildTagKey(collection) {
|
|
366
|
+
return `tag:coll:${collection}`;
|
|
367
|
+
}
|
|
368
|
+
function buildAggregateKey(model, pipeline, tenant) {
|
|
369
|
+
return `${tenantPrefix(tenant)}agg:${model}:${stableHash(pipeline)}`;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// src/cache/CacheManager.ts
|
|
373
|
+
var CacheManager = class {
|
|
374
|
+
constructor(store, engine, events) {
|
|
375
|
+
this.store = store;
|
|
376
|
+
this.engine = engine;
|
|
377
|
+
this.events = events;
|
|
378
|
+
}
|
|
379
|
+
store;
|
|
380
|
+
engine;
|
|
381
|
+
events;
|
|
382
|
+
inflight = /* @__PURE__ */ new Map();
|
|
383
|
+
/**
|
|
384
|
+
* Return the cached value for a query, or load it via `loader`, cache it
|
|
385
|
+
* (when safe), and register it for precise invalidation.
|
|
386
|
+
*
|
|
387
|
+
* @param extractIds maps a loaded result to the stable string ids of the
|
|
388
|
+
* documents it contains — required for T1 direct-membership invalidation.
|
|
389
|
+
*/
|
|
390
|
+
async getOrLoad(query, loader, extractIds2 = () => []) {
|
|
391
|
+
if (query.tier === "T4") return loader();
|
|
392
|
+
let cached = null;
|
|
393
|
+
try {
|
|
394
|
+
cached = await this.store.get(query.key);
|
|
395
|
+
} catch (err) {
|
|
396
|
+
this.emitError(err);
|
|
397
|
+
return loader();
|
|
398
|
+
}
|
|
399
|
+
if (cached !== null) {
|
|
400
|
+
this.events?.emit("hit", { key: query.key, model: query.model });
|
|
401
|
+
return deserialize(cached);
|
|
402
|
+
}
|
|
403
|
+
this.events?.emit("miss", { key: query.key, model: query.model });
|
|
404
|
+
const existing = this.inflight.get(query.key);
|
|
405
|
+
if (existing) return existing;
|
|
406
|
+
const work = this.loadAndCache(query, loader, extractIds2);
|
|
407
|
+
this.inflight.set(query.key, work);
|
|
408
|
+
try {
|
|
409
|
+
return await work;
|
|
410
|
+
} finally {
|
|
411
|
+
this.inflight.delete(query.key);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
async loadAndCache(query, loader, extractIds2) {
|
|
415
|
+
let versionBefore;
|
|
416
|
+
try {
|
|
417
|
+
versionBefore = await this.store.getVersion(query.model);
|
|
418
|
+
} catch (err) {
|
|
419
|
+
this.emitError(err);
|
|
420
|
+
return loader();
|
|
421
|
+
}
|
|
422
|
+
const result = await loader();
|
|
423
|
+
let versionAfter;
|
|
424
|
+
try {
|
|
425
|
+
versionAfter = await this.store.getVersion(query.model);
|
|
426
|
+
} catch (err) {
|
|
427
|
+
this.emitError(err);
|
|
428
|
+
return result;
|
|
429
|
+
}
|
|
430
|
+
if (versionBefore !== versionAfter) {
|
|
431
|
+
return result;
|
|
432
|
+
}
|
|
433
|
+
try {
|
|
434
|
+
await this.store.set(query.key, serialize(result), query.ttlMs);
|
|
435
|
+
if (query.predicate) {
|
|
436
|
+
await this.engine.registerQuery(query.model, query.key, {
|
|
437
|
+
predicate: query.predicate,
|
|
438
|
+
limited: query.limited ?? false,
|
|
439
|
+
resultDocIds: extractIds2(result)
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
for (const tag of query.tags ?? []) {
|
|
443
|
+
await this.store.addToSet(buildTagKey(tag), query.key);
|
|
444
|
+
}
|
|
445
|
+
} catch (err) {
|
|
446
|
+
this.emitError(err);
|
|
447
|
+
}
|
|
448
|
+
return result;
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Apply a write: bump the model version (closing the race window), run the
|
|
452
|
+
* invalidation engine, and delete every affected cache key.
|
|
453
|
+
*/
|
|
454
|
+
async onWrite(model, before, after, extraKeys = []) {
|
|
455
|
+
try {
|
|
456
|
+
await this.store.bumpVersion(model);
|
|
457
|
+
const report = await this.engine.onWrite(model, before, after);
|
|
458
|
+
const keys = [...report.invalidatedQueryKeys, ...extraKeys];
|
|
459
|
+
if (keys.length > 0) {
|
|
460
|
+
await this.store.del(keys);
|
|
461
|
+
this.events?.emit("invalidate", { model, keys });
|
|
462
|
+
}
|
|
463
|
+
return keys;
|
|
464
|
+
} catch (err) {
|
|
465
|
+
this.emitError(err);
|
|
466
|
+
return [];
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Conservative invalidation: drain a collection's tag and delete every
|
|
471
|
+
* entry tagged with it (T2/T3/distinct/populate). Degrade-safe.
|
|
472
|
+
*/
|
|
473
|
+
async invalidateCollection(collection) {
|
|
474
|
+
try {
|
|
475
|
+
const keys = await this.store.drainSet(buildTagKey(collection));
|
|
476
|
+
if (keys.length > 0) {
|
|
477
|
+
await this.store.del(keys);
|
|
478
|
+
this.events?.emit("invalidate", { collection, keys });
|
|
479
|
+
}
|
|
480
|
+
return keys;
|
|
481
|
+
} catch (err) {
|
|
482
|
+
this.emitError(err);
|
|
483
|
+
return [];
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Nuclear precise fallback: remove every registered T1 query for a model and
|
|
488
|
+
* delete its cache keys. Used for writes we cannot image (bulkWrite, upserts).
|
|
489
|
+
*/
|
|
490
|
+
async flushModelPrecise(model) {
|
|
491
|
+
try {
|
|
492
|
+
const keys = await this.engine.clearQueries(model);
|
|
493
|
+
if (keys.length > 0) {
|
|
494
|
+
await this.store.del(keys);
|
|
495
|
+
this.events?.emit("invalidate", { model, keys });
|
|
496
|
+
}
|
|
497
|
+
return keys;
|
|
498
|
+
} catch (err) {
|
|
499
|
+
this.emitError(err);
|
|
500
|
+
return [];
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Emit an `error` event without crashing: a bare `error` emit on an
|
|
505
|
+
* EventEmitter with no listeners would itself throw.
|
|
506
|
+
*/
|
|
507
|
+
emitError(err) {
|
|
508
|
+
if (this.events && this.events.listenerCount("error") > 0) {
|
|
509
|
+
this.events.emit("error", err);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
};
|
|
513
|
+
|
|
514
|
+
// src/invalidation/TierClassifier.ts
|
|
515
|
+
function classifyQuery(input) {
|
|
516
|
+
if (input.hasSession || input.isCursor) return "T4";
|
|
517
|
+
if (input.op === "aggregate") return "T3";
|
|
518
|
+
if (input.op === "estimatedDocumentCount") return "T2";
|
|
519
|
+
if (input.op === "findById") return "T0";
|
|
520
|
+
const filter = input.filter ?? {};
|
|
521
|
+
if ((input.op === "find" || input.op === "findOne") && isPointRead(filter)) {
|
|
522
|
+
return "T0";
|
|
523
|
+
}
|
|
524
|
+
return isSupportedPredicate(filter) ? "T1" : "T2";
|
|
525
|
+
}
|
|
526
|
+
function isPointRead(filter) {
|
|
527
|
+
const keys = Object.keys(filter);
|
|
528
|
+
if (keys.length !== 1 || keys[0] !== "_id") return false;
|
|
529
|
+
const value = filter._id;
|
|
530
|
+
if (isOperatorObject2(value)) {
|
|
531
|
+
const opKeys = Object.keys(value);
|
|
532
|
+
return opKeys.length === 1 && opKeys[0] === "$in" && Array.isArray(value.$in);
|
|
533
|
+
}
|
|
534
|
+
return true;
|
|
535
|
+
}
|
|
536
|
+
function isOperatorObject2(value) {
|
|
537
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Date) && Object.keys(value).length > 0 && Object.keys(value).every((k) => k.startsWith("$"));
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// src/plugins/createCache.ts
|
|
541
|
+
var CACHE_OPS = /* @__PURE__ */ new Set([
|
|
542
|
+
"find",
|
|
543
|
+
"findOne",
|
|
544
|
+
"countDocuments",
|
|
545
|
+
"distinct",
|
|
546
|
+
"estimatedDocumentCount"
|
|
547
|
+
]);
|
|
548
|
+
var UPDATE_OPS = /* @__PURE__ */ new Set([
|
|
549
|
+
"updateOne",
|
|
550
|
+
"updateMany",
|
|
551
|
+
"findOneAndUpdate",
|
|
552
|
+
"replaceOne",
|
|
553
|
+
"findOneAndReplace"
|
|
554
|
+
]);
|
|
555
|
+
var DELETE_OPS = /* @__PURE__ */ new Set([
|
|
556
|
+
"deleteOne",
|
|
557
|
+
"deleteMany",
|
|
558
|
+
"findOneAndDelete"
|
|
559
|
+
]);
|
|
560
|
+
function createCache(options) {
|
|
561
|
+
const { mongoose } = options;
|
|
562
|
+
const emitter = new EventEmitter();
|
|
563
|
+
const store = options.store ?? new RedisCacheStore(options.redis);
|
|
564
|
+
const index = new RedisDependencyIndex(options.redis);
|
|
565
|
+
const engine = new InvalidationEngine(index);
|
|
566
|
+
const manager = new CacheManager(store, engine, emitter);
|
|
567
|
+
const registry = /* @__PURE__ */ new Map();
|
|
568
|
+
const modelNames = options.models ? Object.keys(options.models) : mongoose.modelNames();
|
|
569
|
+
for (const name of modelNames) {
|
|
570
|
+
registry.set(name, options.models?.[name] ?? {});
|
|
571
|
+
}
|
|
572
|
+
const bypass = /* @__PURE__ */ new WeakSet();
|
|
573
|
+
const tenant = () => options.tenant?.();
|
|
574
|
+
const ttlFor = (config) => config.ttlMs ?? options.defaults?.ttlMs;
|
|
575
|
+
const collectionOf = (model) => model.collection.collectionName;
|
|
576
|
+
async function fetchMatching(model, filter) {
|
|
577
|
+
const q = model.find(filter).lean();
|
|
578
|
+
bypass.add(q);
|
|
579
|
+
return await q.exec();
|
|
580
|
+
}
|
|
581
|
+
async function fetchById(model, id) {
|
|
582
|
+
const q = model.findOne({ _id: id }).lean();
|
|
583
|
+
bypass.add(q);
|
|
584
|
+
return await q.exec();
|
|
585
|
+
}
|
|
586
|
+
async function invalidateWrite(model, pairs, opts = {}) {
|
|
587
|
+
const modelName = model.modelName;
|
|
588
|
+
if (pairs.length === 0) {
|
|
589
|
+
await manager.onWrite(modelName, null, null);
|
|
590
|
+
} else {
|
|
591
|
+
for (const { before, after } of pairs) {
|
|
592
|
+
await manager.onWrite(modelName, before, after);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
if (opts.flushPrecise) await manager.flushModelPrecise(modelName);
|
|
596
|
+
await manager.invalidateCollection(collectionOf(model));
|
|
597
|
+
}
|
|
598
|
+
async function handleRead(query, op, config) {
|
|
599
|
+
const filter = query.getFilter() ?? {};
|
|
600
|
+
const opts = query.getOptions() ?? {};
|
|
601
|
+
const tier = classifyQuery({ op, filter, hasSession: !!opts.session });
|
|
602
|
+
if (tier === "T4") return originalExec.apply(query, []);
|
|
603
|
+
const model = query.model;
|
|
604
|
+
const collection = collectionOf(model);
|
|
605
|
+
const populatePaths = Object.keys(query._mongooseOptions?.populate ?? {});
|
|
606
|
+
const hasPopulate = populatePaths.length > 0;
|
|
607
|
+
const conservative = op === "distinct" || op === "estimatedDocumentCount" || hasPopulate || tier === "T2";
|
|
608
|
+
let tags;
|
|
609
|
+
if (conservative) {
|
|
610
|
+
tags = [collection];
|
|
611
|
+
if (hasPopulate) {
|
|
612
|
+
const popCols = resolvePopulateCollections(query, populatePaths);
|
|
613
|
+
if (popCols === null) return originalExec.apply(query, []);
|
|
614
|
+
tags = [collection, ...popCols];
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
const lean = !!query._mongooseOptions?.lean;
|
|
618
|
+
const key = buildQueryKey({
|
|
619
|
+
model: model.modelName,
|
|
620
|
+
op,
|
|
621
|
+
filter,
|
|
622
|
+
projection: query.projection?.() ?? null,
|
|
623
|
+
sort: opts.sort ?? null,
|
|
624
|
+
skip: opts.skip,
|
|
625
|
+
limit: opts.limit,
|
|
626
|
+
populate: hasPopulate ? populatePaths : null,
|
|
627
|
+
distinct: query._distinct ?? null,
|
|
628
|
+
tenant: tenant()
|
|
629
|
+
});
|
|
630
|
+
const cached = {
|
|
631
|
+
key,
|
|
632
|
+
model: model.modelName,
|
|
633
|
+
tier,
|
|
634
|
+
predicate: conservative ? void 0 : filter,
|
|
635
|
+
limited: opts.limit !== void 0 && opts.limit !== null,
|
|
636
|
+
tags,
|
|
637
|
+
ttlMs: ttlFor(config)
|
|
638
|
+
};
|
|
639
|
+
const plain = await manager.getOrLoad(
|
|
640
|
+
cached,
|
|
641
|
+
async () => toPlain(await originalExec.apply(query, [])),
|
|
642
|
+
extractIds
|
|
643
|
+
);
|
|
644
|
+
return lean || hasPopulate ? plain : rehydrate(model, op, plain);
|
|
645
|
+
}
|
|
646
|
+
async function handleWrite(query, op) {
|
|
647
|
+
const model = query.model;
|
|
648
|
+
const filter = query.getFilter() ?? {};
|
|
649
|
+
const opts = query.getOptions() ?? {};
|
|
650
|
+
const beforeDocs = await fetchMatching(model, filter);
|
|
651
|
+
const result = await originalExec.apply(query, []);
|
|
652
|
+
const isDelete = DELETE_OPS.has(op);
|
|
653
|
+
const pairs = [];
|
|
654
|
+
for (const before of beforeDocs) {
|
|
655
|
+
const after = isDelete ? null : await fetchById(model, before._id);
|
|
656
|
+
pairs.push({ before, after });
|
|
657
|
+
}
|
|
658
|
+
const flushPrecise = !!opts.upsert && beforeDocs.length === 0;
|
|
659
|
+
await invalidateWrite(model, pairs, { flushPrecise });
|
|
660
|
+
return result;
|
|
661
|
+
}
|
|
662
|
+
const queryProto = mongoose.Query.prototype;
|
|
663
|
+
const originalExec = queryProto.exec;
|
|
664
|
+
function wrappedExec(...args) {
|
|
665
|
+
if (bypass.has(this)) return originalExec.apply(this, args);
|
|
666
|
+
const config = this.model ? registry.get(this.model.modelName) : void 0;
|
|
667
|
+
if (!config || config.enabled === false) return originalExec.apply(this, args);
|
|
668
|
+
const op = this.op;
|
|
669
|
+
if (CACHE_OPS.has(op)) return handleRead(this, op, config);
|
|
670
|
+
if (UPDATE_OPS.has(op) || DELETE_OPS.has(op)) return handleWrite(this, op);
|
|
671
|
+
return originalExec.apply(this, args);
|
|
672
|
+
}
|
|
673
|
+
queryProto.exec = wrappedExec;
|
|
674
|
+
const restores = [
|
|
675
|
+
() => {
|
|
676
|
+
queryProto.exec = originalExec;
|
|
677
|
+
}
|
|
678
|
+
];
|
|
679
|
+
const aggProto = mongoose.Aggregate.prototype;
|
|
680
|
+
const originalAggExec = aggProto.exec;
|
|
681
|
+
async function handleAggregate(agg, config) {
|
|
682
|
+
const model = agg._model;
|
|
683
|
+
const pipeline = typeof agg.pipeline === "function" ? agg.pipeline() : agg._pipeline ?? [];
|
|
684
|
+
if (hasWriteStage(pipeline) || agg.options?.session) {
|
|
685
|
+
return originalAggExec.apply(agg, []);
|
|
686
|
+
}
|
|
687
|
+
const tags = collectionsInPipeline(model, pipeline);
|
|
688
|
+
const cached = {
|
|
689
|
+
key: buildAggregateKey(model.modelName, pipeline, tenant()),
|
|
690
|
+
model: model.modelName,
|
|
691
|
+
tier: "T3",
|
|
692
|
+
tags,
|
|
693
|
+
ttlMs: ttlFor(config)
|
|
694
|
+
};
|
|
695
|
+
return manager.getOrLoad(cached, async () => originalAggExec.apply(agg, []));
|
|
696
|
+
}
|
|
697
|
+
aggProto.exec = function wrappedAggExec(...args) {
|
|
698
|
+
const config = this._model ? registry.get(this._model.modelName) : void 0;
|
|
699
|
+
if (!config || config.enabled === false) return originalAggExec.apply(this, args);
|
|
700
|
+
return handleAggregate(this, config);
|
|
701
|
+
};
|
|
702
|
+
restores.push(() => {
|
|
703
|
+
aggProto.exec = originalAggExec;
|
|
704
|
+
});
|
|
705
|
+
for (const name of registry.keys()) {
|
|
706
|
+
const model = mongoose.models[name];
|
|
707
|
+
if (!model) continue;
|
|
708
|
+
const proto = model.prototype;
|
|
709
|
+
const saving = /* @__PURE__ */ new WeakSet();
|
|
710
|
+
const makePatched = (original) => async function patchedSave(...a) {
|
|
711
|
+
if (saving.has(this)) return original.apply(this, a);
|
|
712
|
+
saving.add(this);
|
|
713
|
+
try {
|
|
714
|
+
const isNew = this.isNew;
|
|
715
|
+
const before = isNew ? null : await fetchById(model, this._id);
|
|
716
|
+
const res = await original.apply(this, a);
|
|
717
|
+
const after = this.toObject();
|
|
718
|
+
await invalidateWrite(model, [{ before, after }]);
|
|
719
|
+
return res;
|
|
720
|
+
} finally {
|
|
721
|
+
saving.delete(this);
|
|
722
|
+
}
|
|
723
|
+
};
|
|
724
|
+
const originalSave = proto.save;
|
|
725
|
+
proto.save = makePatched(originalSave);
|
|
726
|
+
restores.push(() => {
|
|
727
|
+
proto.save = originalSave;
|
|
728
|
+
});
|
|
729
|
+
if (typeof proto.$save === "function") {
|
|
730
|
+
const original$save = proto.$save;
|
|
731
|
+
proto.$save = makePatched(original$save);
|
|
732
|
+
restores.push(() => {
|
|
733
|
+
proto.$save = original$save;
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
const originalInsertMany = model.insertMany.bind(model);
|
|
737
|
+
model.insertMany = async function patchedInsertMany(...a) {
|
|
738
|
+
const res = await originalInsertMany(...a);
|
|
739
|
+
const docs = Array.isArray(res) ? res : [res];
|
|
740
|
+
await invalidateWrite(
|
|
741
|
+
model,
|
|
742
|
+
docs.map((d) => ({
|
|
743
|
+
before: null,
|
|
744
|
+
after: typeof d?.toObject === "function" ? d.toObject() : d
|
|
745
|
+
}))
|
|
746
|
+
);
|
|
747
|
+
return res;
|
|
748
|
+
};
|
|
749
|
+
restores.push(() => {
|
|
750
|
+
model.insertMany = originalInsertMany;
|
|
751
|
+
});
|
|
752
|
+
const originalBulkWrite = model.bulkWrite.bind(model);
|
|
753
|
+
model.bulkWrite = async function patchedBulkWrite(...a) {
|
|
754
|
+
const res = await originalBulkWrite(...a);
|
|
755
|
+
await invalidateWrite(model, [], { flushPrecise: true });
|
|
756
|
+
return res;
|
|
757
|
+
};
|
|
758
|
+
restores.push(() => {
|
|
759
|
+
model.bulkWrite = originalBulkWrite;
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
emitter.close = () => {
|
|
763
|
+
for (const restore of restores) restore();
|
|
764
|
+
};
|
|
765
|
+
return emitter;
|
|
766
|
+
}
|
|
767
|
+
function hasWriteStage(pipeline) {
|
|
768
|
+
return pipeline.some((s) => s && (s.$out !== void 0 || s.$merge !== void 0));
|
|
769
|
+
}
|
|
770
|
+
function collectionsInPipeline(model, pipeline) {
|
|
771
|
+
const cols = /* @__PURE__ */ new Set([model.collection.collectionName]);
|
|
772
|
+
for (const stage of pipeline) {
|
|
773
|
+
if (!stage) continue;
|
|
774
|
+
if (stage.$lookup?.from) cols.add(stage.$lookup.from);
|
|
775
|
+
if (stage.$graphLookup?.from) cols.add(stage.$graphLookup.from);
|
|
776
|
+
if (stage.$unionWith) {
|
|
777
|
+
cols.add(
|
|
778
|
+
typeof stage.$unionWith === "string" ? stage.$unionWith : stage.$unionWith.coll
|
|
779
|
+
);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
return [...cols];
|
|
783
|
+
}
|
|
784
|
+
function resolvePopulateCollections(query, paths) {
|
|
785
|
+
const pop = query._mongooseOptions?.populate ?? {};
|
|
786
|
+
const cols = [];
|
|
787
|
+
for (const path of paths) {
|
|
788
|
+
const opt = pop[path] ?? {};
|
|
789
|
+
let ref = opt.model;
|
|
790
|
+
if (typeof ref !== "string") {
|
|
791
|
+
const sp = query.model.schema.path(path);
|
|
792
|
+
ref = sp?.options?.ref ?? sp?.caster?.options?.ref;
|
|
793
|
+
}
|
|
794
|
+
if (typeof ref !== "string") return null;
|
|
795
|
+
const refModel = query.model.db.models[ref];
|
|
796
|
+
if (!refModel) return null;
|
|
797
|
+
cols.push(refModel.collection.collectionName);
|
|
798
|
+
}
|
|
799
|
+
return cols;
|
|
800
|
+
}
|
|
801
|
+
function toPlain(result) {
|
|
802
|
+
if (Array.isArray(result)) return result.map((d) => toPlainDoc(d));
|
|
803
|
+
return toPlainDoc(result);
|
|
804
|
+
}
|
|
805
|
+
function toPlainDoc(doc) {
|
|
806
|
+
if (doc && typeof doc.toObject === "function") {
|
|
807
|
+
return doc.toObject();
|
|
808
|
+
}
|
|
809
|
+
return doc;
|
|
810
|
+
}
|
|
811
|
+
function rehydrate(model, op, plain) {
|
|
812
|
+
if (op === "find") return plain.map((o) => model.hydrate(o));
|
|
813
|
+
if (op === "findOne") return plain ? model.hydrate(plain) : null;
|
|
814
|
+
return plain;
|
|
815
|
+
}
|
|
816
|
+
function extractIds(plain) {
|
|
817
|
+
const collect = (d) => d && typeof d === "object" && "_id" in d ? idToString(d._id) : void 0;
|
|
818
|
+
if (Array.isArray(plain)) {
|
|
819
|
+
return plain.map(collect).filter((s) => s !== void 0);
|
|
820
|
+
}
|
|
821
|
+
const single = collect(plain);
|
|
822
|
+
return single ? [single] : [];
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// src/cache/CacheStore.ts
|
|
826
|
+
var InMemoryCacheStore = class {
|
|
827
|
+
data = /* @__PURE__ */ new Map();
|
|
828
|
+
versions = /* @__PURE__ */ new Map();
|
|
829
|
+
sets = /* @__PURE__ */ new Map();
|
|
830
|
+
async get(key) {
|
|
831
|
+
return this.data.get(key) ?? null;
|
|
832
|
+
}
|
|
833
|
+
async set(key, value, _ttlMs) {
|
|
834
|
+
this.data.set(key, value);
|
|
835
|
+
}
|
|
836
|
+
async del(keys) {
|
|
837
|
+
for (const key of keys) this.data.delete(key);
|
|
838
|
+
}
|
|
839
|
+
async getVersion(model) {
|
|
840
|
+
return this.versions.get(model) ?? 0;
|
|
841
|
+
}
|
|
842
|
+
async bumpVersion(model) {
|
|
843
|
+
const next = (this.versions.get(model) ?? 0) + 1;
|
|
844
|
+
this.versions.set(model, next);
|
|
845
|
+
return next;
|
|
846
|
+
}
|
|
847
|
+
async addToSet(setKey, member) {
|
|
848
|
+
let set = this.sets.get(setKey);
|
|
849
|
+
if (!set) {
|
|
850
|
+
set = /* @__PURE__ */ new Set();
|
|
851
|
+
this.sets.set(setKey, set);
|
|
852
|
+
}
|
|
853
|
+
set.add(member);
|
|
854
|
+
}
|
|
855
|
+
async drainSet(setKey) {
|
|
856
|
+
const set = this.sets.get(setKey);
|
|
857
|
+
if (!set) return [];
|
|
858
|
+
this.sets.delete(setKey);
|
|
859
|
+
return [...set];
|
|
860
|
+
}
|
|
861
|
+
};
|
|
862
|
+
|
|
863
|
+
// src/invalidation/DependencyIndex.ts
|
|
864
|
+
var InMemoryDependencyIndex = class {
|
|
865
|
+
byModel = /* @__PURE__ */ new Map();
|
|
866
|
+
async addQuery(model, queryKey, meta) {
|
|
867
|
+
let queries = this.byModel.get(model);
|
|
868
|
+
if (!queries) {
|
|
869
|
+
queries = /* @__PURE__ */ new Map();
|
|
870
|
+
this.byModel.set(model, queries);
|
|
871
|
+
}
|
|
872
|
+
queries.set(queryKey, meta);
|
|
873
|
+
}
|
|
874
|
+
async removeQuery(model, queryKey) {
|
|
875
|
+
this.byModel.get(model)?.delete(queryKey);
|
|
876
|
+
}
|
|
877
|
+
async getQueries(model) {
|
|
878
|
+
const queries = this.byModel.get(model);
|
|
879
|
+
if (!queries) return [];
|
|
880
|
+
return [...queries.entries()].map(([queryKey, meta]) => ({
|
|
881
|
+
queryKey,
|
|
882
|
+
...meta
|
|
883
|
+
}));
|
|
884
|
+
}
|
|
885
|
+
async clearModel(model) {
|
|
886
|
+
const queries = this.byModel.get(model);
|
|
887
|
+
if (!queries) return [];
|
|
888
|
+
const keys = [...queries.keys()];
|
|
889
|
+
this.byModel.delete(model);
|
|
890
|
+
return keys;
|
|
891
|
+
}
|
|
892
|
+
};
|
|
893
|
+
export {
|
|
894
|
+
CacheManager,
|
|
895
|
+
InMemoryCacheStore,
|
|
896
|
+
InMemoryDependencyIndex,
|
|
897
|
+
InvalidationEngine,
|
|
898
|
+
RedisCacheStore,
|
|
899
|
+
RedisDependencyIndex,
|
|
900
|
+
buildDocKey,
|
|
901
|
+
buildQueryKey,
|
|
902
|
+
classifyQuery,
|
|
903
|
+
createCache,
|
|
904
|
+
deserialize,
|
|
905
|
+
isSupportedPredicate,
|
|
906
|
+
matches,
|
|
907
|
+
normalizeQuery,
|
|
908
|
+
serialize,
|
|
909
|
+
stableHash
|
|
910
|
+
};
|
|
911
|
+
//# sourceMappingURL=index.js.map
|