ts-cache-mongoose
Version:
Cache plugin for mongoose Queries and Aggregate (in-memory, redis)
425 lines (412 loc) • 11.8 kB
JavaScript
'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;