UNPKG

ts-cache-mongoose

Version:

Cache plugin for mongoose Queries and Aggregate (in-memory, redis)

425 lines (412 loc) 11.8 kB
'use strict'; var node_v8 = require('node:v8'); var bson = require('bson'); var IORedis = require('ioredis'); var mongoose = require('mongoose'); var node_crypto = require('node:crypto'); const s = 1e3; const m = s * 60; const h = m * 60; const d = h * 24; const w = d * 7; const y = d * 365.25; const mo = y / 12; const UNITS = { milliseconds: 1, millisecond: 1, msecs: 1, msec: 1, ms: 1, seconds: s, second: s, secs: s, sec: s, s, minutes: m, minute: m, mins: m, min: m, m, hours: h, hour: h, hrs: h, hr: h, h, days: d, day: d, d, weeks: w, week: w, w, months: mo, month: mo, mo, years: y, year: y, yrs: y, yr: y, y }; const unitPattern = Object.keys(UNITS).sort((a, b) => b.length - a.length).join("|"); const RE = new RegExp(String.raw`^(-?(?:\d+)?\.?\d+)\s*(${unitPattern})?$`, "i"); const ms = (val) => { const str = String(val); if (str.length > 100) return Number.NaN; const match = RE.exec(str); if (!match) return Number.NaN; const n = Number.parseFloat(match[1] ?? ""); const type = (match[2] ?? "ms").toLowerCase(); return n * (UNITS[type] ?? 0); }; const defaultSizer = (value) => node_v8.serialize(value).byteLength; class MemoryCacheEngine { #cache; #maxEntries; #maxBytes; #sizeOf; #totalBytes; constructor(options) { this.#cache = /* @__PURE__ */ new Map(); this.#maxEntries = options?.maxEntries != null && options.maxEntries > 0 ? options.maxEntries : Number.POSITIVE_INFINITY; this.#maxBytes = options?.maxBytes != null && options.maxBytes > 0 ? options.maxBytes : Number.POSITIVE_INFINITY; this.#sizeOf = options?.sizeCalculation ?? defaultSizer; this.#totalBytes = 0; } get totalBytes() { return this.#totalBytes; } get size() { return this.#cache.size; } get(key) { const item = this.#cache.get(key); if (!item) return void 0; if (item.expiresAt < Date.now()) { this.#cache.delete(key); this.#totalBytes -= item.bytes; return void 0; } this.#cache.delete(key); this.#cache.set(key, item); return item.value; } set(key, value, ttl) { const givenTTL = ttl == null ? void 0 : ms(ttl); const actualTTL = givenTTL ?? Number.POSITIVE_INFINITY; const existing = this.#cache.get(key); if (existing) { this.#cache.delete(key); this.#totalBytes -= existing.bytes; } const bytes = this.#sizeOf(value); this.#cache.set(key, { value, expiresAt: Date.now() + actualTTL, bytes }); this.#totalBytes += bytes; while ((this.#cache.size > this.#maxEntries || this.#totalBytes > this.#maxBytes) && this.#cache.size > 1) { const oldestKey = this.#cache.keys().next().value; if (oldestKey === void 0 || oldestKey === key) break; const oldest = this.#cache.get(oldestKey); this.#cache.delete(oldestKey); if (oldest) this.#totalBytes -= oldest.bytes; } } del(key) { const item = this.#cache.get(key); if (!item) return; this.#cache.delete(key); this.#totalBytes -= item.bytes; } clear() { this.#cache.clear(); this.#totalBytes = 0; } close() { } } const isMongooseLessThan7 = Number.parseInt(mongoose.version, 10) < 7; const convertToObject = (value) => { if (isMongooseLessThan7) { if (value != null && typeof value === "object" && !Array.isArray(value) && value.toObject) { return value.toObject(); } if (Array.isArray(value)) { return value.map((doc) => convertToObject(doc)); } } return value; }; class RedisCacheEngine { #client; #onError; constructor(options, onError) { options.keyPrefix ??= "cache-mongoose:"; this.#client = new IORedis(options); this.#onError = onError; } async get(key) { try { const value = await this.#client.get(key); if (value === null) { return void 0; } return bson.EJSON.parse(value); } catch (err) { this.#onError(err); return void 0; } } async set(key, value, ttl) { try { const converted = convertToObject(value); if (converted === void 0) { return; } const givenTTL = ttl == null ? void 0 : ms(ttl); const actualTTL = givenTTL ?? Number.POSITIVE_INFINITY; const serializedValue = bson.EJSON.stringify(converted); await this.#client.setex(key, Math.ceil(actualTTL / 1e3), serializedValue); } catch (err) { this.#onError(err); } } async del(key) { await this.#client.del(key); } async clear() { await this.#client.flushdb(); } async close() { await this.#client.quit(); } } class Cache { #engine; #defaultTTL; #debug; #onError; #engines = ["memory", "redis"]; constructor(cacheOptions) { if (!this.#engines.includes(cacheOptions.engine)) { throw new Error(`Invalid engine name: ${cacheOptions.engine}`); } if (cacheOptions.engine === "redis" && !cacheOptions.engineOptions) { throw new Error(`Engine options are required for ${cacheOptions.engine} engine`); } cacheOptions.defaultTTL ??= "1 minute"; this.#defaultTTL = ms(cacheOptions.defaultTTL); this.#onError = cacheOptions.onError ?? console.error; if (cacheOptions.engine === "redis" && cacheOptions.engineOptions) { this.#engine = new RedisCacheEngine(cacheOptions.engineOptions, this.#onError); } if (cacheOptions.engine === "memory") { this.#engine = new MemoryCacheEngine({ maxEntries: cacheOptions.maxEntries, maxBytes: cacheOptions.maxBytes, sizeCalculation: cacheOptions.sizeCalculation }); } this.#debug = cacheOptions.debug === true; } get onError() { return this.#onError; } async get(key) { const cacheEntry = await this.#engine.get(key); if (this.#debug) { const cacheHit = cacheEntry == null ? "MISS" : "HIT"; console.log(`[ts-cache-mongoose] GET '${key}' - ${cacheHit}`); } return cacheEntry; } async set(key, value, ttl) { const givenTTL = ttl == null ? null : ms(ttl); const actualTTL = givenTTL ?? this.#defaultTTL; if (Number.isNaN(actualTTL) || actualTTL <= 0) { if (this.#debug) { console.log(`[ts-cache-mongoose] SET '${key}' - skipped (non-positive ttl: ${String(actualTTL)} ms)`); } return; } await this.#engine.set(key, value, actualTTL); if (this.#debug) { console.log(`[ts-cache-mongoose] SET '${key}' - ttl: ${actualTTL.toFixed(0)} ms`); } } async del(key) { await this.#engine.del(key); if (this.#debug) { console.log(`[ts-cache-mongoose] DEL '${key}'`); } } async clear() { await this.#engine.clear(); if (this.#debug) { console.log("[ts-cache-mongoose] CLEAR"); } } async close() { return this.#engine.close(); } } const isPlainObject = (value) => { if (typeof value !== "object" || value === null) return false; const proto = Object.getPrototypeOf(value); return proto === Object.prototype || proto === null; }; const sortKeys = (input) => { const seen = /* @__PURE__ */ new WeakSet(); const sortObject = (obj) => { if (seen.has(obj)) return obj; seen.add(obj); const sorted = {}; for (const key of Object.keys(obj).sort((a, b) => a.localeCompare(b))) { const value = obj[key]; if (Array.isArray(value)) { sorted[key] = sortArray(value); } else if (isPlainObject(value)) { sorted[key] = sortObject(value); } else { sorted[key] = value; } } return sorted; }; const sortArray = (arr) => { return arr.map((item) => { if (Array.isArray(item)) return sortArray(item); if (isPlainObject(item)) return sortObject(item); return item; }); }; if (Array.isArray(input)) return sortArray(input); return sortObject(input); }; function getKey(data) { const sortedObj = sortKeys(data); const sortedStr = JSON.stringify(sortedObj, (_, val) => { return val instanceof RegExp ? String(val) : val; }); return node_crypto.createHash("sha1").update(sortedStr).digest("hex"); } function extendAggregate(mongoose, cache) { const mongooseExec = mongoose.Aggregate.prototype.exec; mongoose.Aggregate.prototype.getCacheKey = function() { if (this._key != null) return this._key; return getKey({ pipeline: this.pipeline() }); }; mongoose.Aggregate.prototype.getDuration = function() { return this._ttl; }; mongoose.Aggregate.prototype.cache = function(ttl, customKey) { this._ttl = ttl ?? null; this._key = customKey ?? null; return this; }; mongoose.Aggregate.prototype.exec = async function(...args) { if (!Object.hasOwn(this, "_ttl")) { return mongooseExec.apply(this, args); } const key = this.getCacheKey(); const ttl = this.getDuration(); const resultCache = await cache.get(key).catch((err) => { cache.onError(err); }); if (resultCache) { return resultCache; } const result = await mongooseExec.call(this); await cache.set(key, result, ttl).catch((err) => { cache.onError(err); }); return result; }; } function extendQuery(mongoose, cache) { const mongooseExec = mongoose.Query.prototype.exec; mongoose.Query.prototype.getCacheKey = function() { if (this._key != null) return this._key; const filter = this.getFilter(); const update = this.getUpdate(); const options = this.getOptions(); const mongooseOptions = this.mongooseOptions(); return getKey({ model: this.model.modelName, op: this.op, filter, update, options, mongooseOptions, _path: this._path, _fields: this._fields, _distinct: this._distinct, _conditions: this._conditions }); }; mongoose.Query.prototype.getDuration = function() { return this._ttl; }; mongoose.Query.prototype.cache = function(ttl, customKey) { this._ttl = ttl ?? null; this._key = customKey ?? null; return this; }; mongoose.Query.prototype.exec = async function(...args) { if (!Object.hasOwn(this, "_ttl")) { return mongooseExec.apply(this, args); } const key = this.getCacheKey(); const ttl = this.getDuration(); const mongooseOptions = this.mongooseOptions(); const isCount = this.op?.includes("count") ?? false; const isDistinct = this.op === "distinct"; const model = this.model.modelName; const resultCache = await cache.get(key).catch((err) => { cache.onError(err); }); if (resultCache) { if (isCount || isDistinct || mongooseOptions.lean) { return resultCache; } const modelConstructor = mongoose.model(model); if (Array.isArray(resultCache)) { return resultCache.map((item) => { return modelConstructor.hydrate(item); }); } return modelConstructor.hydrate(resultCache); } const result = await mongooseExec.call(this); await cache.set(key, result, ttl).catch((err) => { cache.onError(err); }); return result; }; } class CacheMongoose { static #instance; cache; constructor() { } static init(mongoose, cacheOptions) { if (!CacheMongoose.#instance) { CacheMongoose.#instance = new CacheMongoose(); CacheMongoose.#instance.cache = new Cache(cacheOptions); const cache = CacheMongoose.#instance.cache; extendQuery(mongoose, cache); extendAggregate(mongoose, cache); } return CacheMongoose.#instance; } async clear(customKey) { if (customKey == null) { await this.cache.clear(); } else { await this.cache.del(customKey); } } async close() { await this.cache.close(); } } module.exports = CacheMongoose;