import { Hashery } from "hashery"; import { Keyv } from "keyv"; //#region src/shorthand-time.ts /** * Converts a shorthand time string or number into milliseconds. * The shorthand can be a string like '1s', '2m', '3h', '4d', or a number representing milliseconds. * If the input is undefined, it returns undefined. * If the input is a string that does not match the expected format, it throws an error. * @param shorthand - A shorthand time string or number representing milliseconds. * @returns The equivalent time in milliseconds or undefined. */ const shorthandToMilliseconds = (shorthand) => { let milliseconds; if (shorthand === void 0) return; if (typeof shorthand === "number") milliseconds = shorthand; else { if (typeof shorthand !== "string") return; shorthand = shorthand.trim(); if (Number.isNaN(Number(shorthand))) { const match = /^([\d.]+)\s*(ms|s|m|h|hr|d)$/i.exec(shorthand); if (!match) throw new Error(`Unsupported time format: "${shorthand}". Use 'ms', 's', 'm', 'h', 'hr', or 'd'.`); const [, value, unit] = match; const numericValue = Number.parseFloat(value); switch (unit.toLowerCase()) { case "ms": milliseconds = numericValue; break; case "s": milliseconds = numericValue * 1e3; break; case "m": milliseconds = numericValue * 1e3 * 60; break; case "h": milliseconds = numericValue * 1e3 * 60 * 60; break; case "hr": milliseconds = numericValue * 1e3 * 60 * 60; break; case "d": milliseconds = numericValue * 1e3 * 60 * 60 * 24; break; /* v8 ignore next -- @preserve */ default: milliseconds = Number(shorthand); } } else milliseconds = Number(shorthand); } return milliseconds; }; /** * Converts a shorthand time string or number into a timestamp. * If the shorthand is undefined, it returns the current date's timestamp. * If the shorthand is a valid time format, it adds that duration to the current date's timestamp. * @param shorthand - A shorthand time string or number representing milliseconds. * @param fromDate - An optional Date object to calculate from. Defaults to the current date if not provided. * @returns The timestamp in milliseconds since epoch. */ const shorthandToTime = (shorthand, fromDate) => { fromDate ??= /* @__PURE__ */ new Date(); const milliseconds = shorthandToMilliseconds(shorthand); if (milliseconds === void 0) return fromDate.getTime(); return fromDate.getTime() + milliseconds; }; //#endregion //#region src/cache-tags.ts /** * Prefix applied to every store key written by the service so its metadata cannot collide with * user-supplied cache keys. */ const RESERVED_PREFIX = "--cacheable--tags--"; /** Namespace used when none is supplied to the constructor. */ const DEFAULT_NAMESPACE = "default"; /** * Provides tag-based cache invalidation on top of any {@link Keyv} store. It is store-agnostic and * requires no adapter changes. * * The service uses a lazy invalidation model rather than scanning and deleting keys. Each tag has a * monotonically increasing version counter; {@link CacheTags.invalidateTag} simply increments * it. When a key is tagged via {@link CacheTags.setKeyTags}, a snapshot of its tags' current * versions is stored alongside it. {@link CacheTags.isKeyFresh} compares that snapshot against * the live versions — if any tag has been incremented since, the key is considered stale. Stale * entries are not deleted explicitly; they are expected to fall out of the cache via their TTL. * * This keeps invalidation constant-time regardless of how many keys reference a tag, at the cost of * one additional `isKeyFresh` read per cache lookup. * * The service can be disabled via the `enabled` option or property so integrations pay no cost for * untagged workloads: while disabled, every method is a no-op — reads return their neutral value * and writes are skipped. The service must be explicitly enabled to use tags; it never enables * itself, which keeps behavior consistent across distributed instances sharing a store. * * All metadata is written under a reserved prefix so it cannot collide with user keys: * - `--cacheable--tags--::tag:` → integer version counter (stored without TTL). * - `--cacheable--tags--::key:` → the {@link KeyTagEntry} snapshot. * * Note: the read-version-then-write-snapshot sequence in `setKeyTags` is not atomic across * processes. A concurrent `invalidateTag` running between the read and the write can leave a freshly * written key referencing a stale version. * * @example * ```typescript * const cacheTags = new CacheTags({ store: new Keyv(), namespace: 'app' }); * await cacheTags.setKeyTags('user:42', ['users', 'org:7'], { ttl: 3600000 }); * await cacheTags.isKeyFresh('user:42'); // true * await cacheTags.invalidateTag('users'); * await cacheTags.isKeyFresh('user:42'); // false * ``` */ var CacheTags = class { _store; _namespace; _enabled; _onError; /** * Creates a new tag service. * @param {CacheTagsOptions} options - The store, optional namespace, enabled state, and * non-blocking error handler to use. */ constructor(options) { this._store = options.store; this._namespace = options.namespace ?? DEFAULT_NAMESPACE; this._enabled = options.enabled ?? true; this._onError = options.onError; } /** * The Keyv store backing this service. * @returns {Keyv} The store provided to the constructor. */ get store() { return this._store; } /** * The namespace isolating this service's tags and keys within the store. * @returns {string} The configured namespace, or `"default"` if none was provided. */ get namespace() { return this._namespace; } /** * Whether the service is enabled. While disabled, every method is a no-op — read methods * return their neutral value and writes are skipped — so integrations pay no extra store * reads for untagged workloads. The service must be explicitly enabled to use tags; it never * enables itself. * @returns {boolean} Whether the service is enabled. */ get enabled() { return this._enabled; } /** * Sets whether the service is enabled. * @param {boolean} enabled Whether the service is enabled. */ set enabled(enabled) { this._enabled = enabled; } /** * Builds the reserved store key under which a tag's version counter is stored. * @param tag - The tag name. * @returns {string} The namespaced store key for the tag's version. */ tagKey(tag) { return `${RESERVED_PREFIX}:${this._namespace}:tag:${tag}`; } /** * Builds the reserved store key under which a cache key's tag snapshot is stored. * @param key - The cache key being tagged. * @returns {string} The namespaced store key for the key's snapshot. */ keyEntryKey(key) { return `${RESERVED_PREFIX}:${this._namespace}:key:${key}`; } /** * Builds the common prefix shared by every key-snapshot entry in this namespace. Used to filter * key entries when iterating the store. * @returns {string} The namespaced key-entry prefix. */ keyPrefix() { return `${RESERVED_PREFIX}:${this._namespace}:key:`; } /** * Reads the current version of a single tag. * @param tag - The tag name. * @returns {Promise} The tag's version, or `0` if it has never been invalidated. */ async getTagVersion(tag) { const version = await this._store.get(this.tagKey(tag)); return typeof version === "number" ? version : 0; } /** * Reads the current versions of multiple tags in a single batched store read. * @param tags - The tag names to look up. * @returns {Promise} The versions in the same order as `tags`; entries that have never * been invalidated resolve to `0`. Returns an empty array when `tags` is empty. */ async getTagVersions(tags) { if (tags.length === 0) return []; const tagKeys = tags.map((tag) => this.tagKey(tag)); const raw = await this._store.get(tagKeys); return tags.map((_, i) => { const value = raw?.[i]; return typeof value === "number" ? value : 0; }); } /** * Reports a fire-and-forget failure to the `onError` handler, if one was provided. * @param error - The error raised by the non-blocking operation. */ handleNonBlockingError(error) { this._onError?.(error); } /** * Reads the version snapshot of each tag and writes the key's tag snapshot to the store. * @param key - The cache key to tag. * @param tags - The tags to associate with the key. * @param ttl - Time-to-live in milliseconds for the snapshot. * @returns {Promise} Resolves once the snapshot has been written. */ async writeKeyTags(key, tags, ttl) { const uniqueTags = [...new Set(tags)]; const versions = await this.getTagVersions(uniqueTags); const snapshot = {}; for (let i = 0; i < uniqueTags.length; i++) snapshot[uniqueTags[i]] = versions[i]; const entry = { tags: snapshot }; await this._store.set(this.keyEntryKey(key), entry, ttl); } /** * Associates a cache key with a set of tags by recording a snapshot of each tag's current * version. Call this whenever you write a fresh value to the cache. Duplicate tags are ignored. * No-op while the service is disabled. * @param key - The cache key to tag. * @param tags - The tags to associate with the key. * @param {SetKeyTagsOptions} [options] - Optional settings, such as a `ttl` for the snapshot or * `nonBlocking` to fire-and-forget the write. * @returns {Promise} Resolves once the snapshot has been written, or immediately when * `nonBlocking` is set. */ async setKeyTags(key, tags, options) { if (!this._enabled) return; const work = this.writeKeyTags(key, tags, options?.ttl); if (options?.nonBlocking) { work.catch((error) => { this.handleNonBlockingError(error); }); return; } await work; } /** * Removes a key's tag snapshot. After this, {@link CacheTags.isKeyFresh} returns `false` * for the key. Use when the cached value itself is deleted. No-op while the service is * disabled. * @param key - The cache key whose snapshot should be removed. * @param {RemoveKeysOptions} [options] - Optional settings, such as `nonBlocking` to * fire-and-forget the removal. * @returns {Promise} Resolves once the snapshot has been deleted, or immediately when * `nonBlocking` is set. */ async removeKey(key, options) { await this.removeKeys([key], options); } /** * Removes multiple keys' tag snapshots in a single batched store delete. After this, * {@link CacheTags.isKeyFresh} returns `false` for each key. An empty list is a no-op, as is * the entire call while the service is disabled. * @param keys - The cache keys whose snapshots should be removed. * @param {RemoveKeysOptions} [options] - Optional settings, such as `nonBlocking` to * fire-and-forget the removal. * @returns {Promise} Resolves once the snapshots have been deleted, or immediately when * `nonBlocking` is set. */ async removeKeys(keys, options) { if (!this._enabled || keys.length === 0) return; const entryKeys = keys.map((key) => this.keyEntryKey(key)); const work = this._store.deleteMany(entryKeys); if (options?.nonBlocking) { work.catch((error) => { this.handleNonBlockingError(error); }); return; } await work; } /** * Determines whether a key's cached value can still be trusted. A key is fresh only when a * snapshot exists for it and every tag in that snapshot still has the version it had at set time. * A key with no tags is trivially fresh. Call this before returning a value from your cache. * Always returns `true` while the service is disabled. * @param key - The cache key to check. * @returns {Promise} `true` if the key is still fresh; `false` if it is unknown or any of * its tags has been invalidated since the snapshot was taken. */ async isKeyFresh(key) { if (!this._enabled) return true; const entry = await this._store.get(this.keyEntryKey(key)); if (!entry?.tags) return false; const tags = Object.keys(entry.tags); const currentVersions = await this.getTagVersions(tags); for (let i = 0; i < tags.length; i++) if (currentVersions[i] !== entry.tags[tags[i]]) return false; return true; } /** * Determines whether a key's cached value is known to be stale due to tag invalidation. This is * the complement of {@link CacheTags.isKeyFresh} for tagged keys, but treats keys without a * snapshot as not stale — making it safe to call for every cache lookup, including keys that were * never tagged. Always returns `false` while the service is disabled. * @param key - The cache key to check. * @returns {Promise} `true` only when a snapshot exists for the key and at least one of * its tags has been invalidated since the snapshot was taken; `false` otherwise (including when * the key has no snapshot). */ async isKeyStale(key) { if (!this._enabled) return false; return (await this.getStaleKeys([key])).length > 0; } /** * Determines which of the given keys are known to be stale due to tag invalidation, using two * batched store reads regardless of how many keys are checked: one for the snapshots and one for * the union of their tag versions. Keys without a snapshot are not considered stale. Returns an * empty array while the service is disabled. * @param keys - The cache keys to check. * @returns {Promise} The subset of `keys` whose snapshot references at least one tag * that has been invalidated since the snapshot was taken. */ async getStaleKeys(keys) { if (!this._enabled || keys.length === 0) return []; const entryKeys = keys.map((key) => this.keyEntryKey(key)); const entries = await this._store.get(entryKeys); const tagSet = /* @__PURE__ */ new Set(); for (const entry of entries) if (entry?.tags) for (const tag of Object.keys(entry.tags)) tagSet.add(tag); const tags = [...tagSet]; const versions = await this.getTagVersions(tags); const currentVersions = /* @__PURE__ */ new Map(); for (let i = 0; i < tags.length; i++) currentVersions.set(tags[i], versions[i]); const staleKeys = []; for (const [i, entry] of entries.entries()) { if (!entry?.tags) continue; for (const [tag, version] of Object.entries(entry.tags)) if (currentVersions.get(tag) !== version) { staleKeys.push(keys[i]); break; } } return staleKeys; } /** * Returns the tags currently associated with a key. Returns `undefined` while the service is * disabled. * @param key - The cache key to look up. * @returns {Promise} The tag names from the key's snapshot, or `undefined` * if the key has no snapshot. */ async getTags(key) { if (!this._enabled) return; const entry = await this._store.get(this.keyEntryKey(key)); if (!entry?.tags) return; return Object.keys(entry.tags); } /** * Returns all cache keys whose snapshot references the given tag. This scans every key entry in * the namespace via the Keyv iterator, making it an `O(N)` operation intended for debugging and * tests rather than hot paths. Returns an empty array if the underlying store exposes no iterator * or while the service is disabled. * @param tag - The tag to search for. * @returns {Promise} The cache keys (with the reserved prefix stripped) referencing the tag. */ async getKeysByTag(tag) { const result = []; if (!this._enabled) return result; const prefix = this.keyPrefix(); const iterator = this._store.iterator?.(this._store.namespace); if (!iterator) return result; for await (const [storedKey, value] of iterator) { if (typeof storedKey !== "string" || !storedKey.startsWith(prefix)) continue; const entry = value; if (entry?.tags && Object.hasOwn(entry.tags, tag)) result.push(storedKey.slice(prefix.length)); } return result; } /** * Invalidates a single tag by incrementing its version counter. Every key whose snapshot * references this tag becomes stale immediately. Runs in constant time regardless of how many * keys reference the tag. No-op while the service is disabled. * @param tag - The tag to invalidate. * @returns {Promise} A single-element array containing the invalidated tag, or an * empty array while the service is disabled. */ async invalidateTag(tag) { if (!this._enabled) return []; const current = await this.getTagVersion(tag); await this._store.set(this.tagKey(tag), current + 1); return [tag]; } /** * Invalidates multiple tags by incrementing each of their version counters in a single batched * store write. Duplicate tags are bumped once. An empty list is a no-op, as is the entire call * while the service is disabled. * @param tags - The tags to invalidate. * @returns {Promise} The `tags` argument as provided (including any duplicates), or * an empty array while the service is disabled. */ async invalidateTags(tags) { if (!this._enabled) return []; const uniqueTags = [...new Set(tags)]; if (uniqueTags.length === 0) return tags; const versions = await this.getTagVersions(uniqueTags); const kvPairs = []; for (let i = 0; i < uniqueTags.length; i++) kvPairs.push({ key: this.tagKey(uniqueTags[i]), value: versions[i] + 1 }); await this._store.setMany(kvPairs); return tags; } }; //#endregion //#region src/coalesce-async.ts const callbacks = /* @__PURE__ */ new Map(); function hasKey(key) { return callbacks.has(key); } function addKey(key) { callbacks.set(key, []); } function removeKey(key) { callbacks.delete(key); } function addCallbackToKey(key, callback) { const stash = getCallbacksByKey(key); stash.push(callback); callbacks.set(key, stash); } function getCallbacksByKey(key) { /* v8 ignore next -- @preserve */ return callbacks.get(key) ?? []; } async function enqueue(key) { return new Promise((resolve, reject) => { addCallbackToKey(key, { resolve, reject }); }); } function dequeue(key) { const stash = getCallbacksByKey(key); removeKey(key); return stash; } function coalesce(options) { const { key, error, result } = options; for (const callback of dequeue(key)) /* c8 ignore next 1 */ if (error) /* c8 ignore next 3 */ callback.reject(error); else callback.resolve(result); } /** * Enqueue a promise for the group identified by `key`. * * All requests received for the same key while a request for that key * is already being executed will wait. Once the running request settles * then all the waiting requests in the group will settle, too. * This minimizes how many times the function itself runs at the same time. * This function resolves or rejects according to the given function argument. * * @url https://github.com/douglascayers/promise-coalesce */ async function coalesceAsync(key, fnc) { if (!hasKey(key)) { addKey(key); try { const result = await Promise.resolve(fnc()); coalesce({ key, result }); return result; } catch (error) { /* c8 ignore next 5 */ coalesce({ key, error }); throw error; } } return enqueue(key); } //#endregion //#region src/hash.ts let HashAlgorithm = /* @__PURE__ */ function(HashAlgorithm) { HashAlgorithm["SHA256"] = "SHA-256"; HashAlgorithm["SHA384"] = "SHA-384"; HashAlgorithm["SHA512"] = "SHA-512"; HashAlgorithm["DJB2"] = "djb2"; HashAlgorithm["FNV1"] = "fnv1"; HashAlgorithm["MURMER"] = "murmer"; HashAlgorithm["CRC32"] = "crc32"; return HashAlgorithm; }({}); /** * Hashes an object asynchronously using the specified cryptographic algorithm. * This method should be used for cryptographic algorithms (SHA-256, SHA-384, SHA-512). * For non-cryptographic algorithms, use hashSync() for better performance. * @param object The object to hash * @param options The hash options to use * @returns {Promise} The hash of the object */ async function hash(object, options = { algorithm: "SHA-256", serialize: JSON.stringify }) { const algorithm = options?.algorithm ?? "SHA-256"; const objectString = (options?.serialize ?? JSON.stringify)(object); return new Hashery().toHash(objectString, { algorithm }); } /** * Hashes an object synchronously using the specified non-cryptographic algorithm. * This method should be used for non-cryptographic algorithms (DJB2, FNV1, MURMER, CRC32). * For cryptographic algorithms, use hash() instead. * @param object The object to hash * @param options The hash options to use * @returns {string} The hash of the object */ function hashSync(object, options = { algorithm: "djb2", serialize: JSON.stringify }) { const algorithm = options?.algorithm ?? "djb2"; const objectString = (options?.serialize ?? JSON.stringify)(object); return new Hashery().toHashSync(objectString, { algorithm }); } /** * Hashes an object asynchronously and converts it to a number within a specified range. * This method should be used for cryptographic algorithms (SHA-256, SHA-384, SHA-512). * For non-cryptographic algorithms, use hashToNumberSync() for better performance. * @param object The object to hash * @param options The hash options to use including min/max range * @returns {Promise} A number within the specified range */ async function hashToNumber(object, options = { min: 0, max: 10, algorithm: "SHA-256", serialize: JSON.stringify }) { const min = options?.min ?? 0; const max = options?.max ?? 10; const algorithm = options?.algorithm ?? "SHA-256"; const serialize = options?.serialize ?? JSON.stringify; const hashLength = options?.hashLength ?? 16; if (min >= max) throw new Error(`Invalid range: min (${min}) must be less than max (${max})`); const objectString = serialize(object); return new Hashery().toNumber(objectString, { algorithm, min, max, hashLength }); } /** * Hashes an object synchronously and converts it to a number within a specified range. * This method should be used for non-cryptographic algorithms (DJB2, FNV1, MURMER, CRC32). * For cryptographic algorithms, use hashToNumber() instead. * @param object The object to hash * @param options The hash options to use including min/max range * @returns {number} A number within the specified range */ function hashToNumberSync(object, options = { min: 0, max: 10, algorithm: "djb2", serialize: JSON.stringify }) { const min = options?.min ?? 0; const max = options?.max ?? 10; const algorithm = options?.algorithm ?? "djb2"; const serialize = options?.serialize ?? JSON.stringify; const hashLength = options?.hashLength ?? 16; if (min >= max) throw new Error(`Invalid range: min (${min}) must be less than max (${max})`); const objectString = serialize(object); return new Hashery().toNumberSync(objectString, { algorithm, min, max, hashLength }); } //#endregion //#region src/is-keyv-instance.ts function isKeyvInstance(keyv) { if (keyv === null || keyv === void 0) return false; if (keyv instanceof Keyv) return true; return [ "generateIterator", "get", "getMany", "set", "setMany", "delete", "deleteMany", "has", "hasMany", "clear", "disconnect", "serialize", "deserialize" ].every((method) => typeof keyv[method] === "function"); } //#endregion //#region src/is-object.ts function isObject(value) { return value !== null && typeof value === "object" && !Array.isArray(value); } //#endregion //#region src/less-than.ts function lessThan(number1, number2) { return typeof number1 === "number" && typeof number2 === "number" ? number1 < number2 : false; } //#endregion //#region src/memoize.ts function wrapSync(function_, options) { const { ttl, keyPrefix, cache, serialize } = options; return (...arguments_) => { let cacheKey = createWrapKey(function_, arguments_, { keyPrefix, serialize }); if (options.createKey) cacheKey = options.createKey(function_, arguments_, options); let value = cache.get(cacheKey); if (value === void 0) try { value = function_(...arguments_); cache.set(cacheKey, value, ttl); } catch (error) { cache.emit("error", error); if (options.cacheErrors) cache.set(cacheKey, error, ttl); } return value; }; } async function getOrSet(key, function_, options) { const keyString = typeof key === "function" ? key(options) : key; let value; try { value = await options.cache.get(keyString); } catch (error) { options.cache.emit("error", error); if (options.throwErrors === true || options.throwErrors === "store") throw error; } if (value === void 0) value = await coalesceAsync(`${options.cacheId ?? "default"}::${keyString}`, async () => { let result; try { try { result = await function_(); } catch (error) { throw new ErrorEnvelope(error, "function"); } try { await options.cache.set(keyString, result, options.ttl); } catch (error) { throw new ErrorEnvelope(error, "store"); } return result; } catch (caught) { const errorType = caught instanceof ErrorEnvelope ? caught.context : void 0; const error = caught instanceof ErrorEnvelope ? caught.error : caught; options.cache.emit("error", error); if (options.cacheErrors && errorType === "function") try { await options.cache.set(keyString, error, options.ttl); } catch (storeError) { options.cache.emit("error", storeError); } if (options.throwErrors === true || options.throwErrors === errorType) throw error; } return result; }); return value; } /** * Synchronous counterpart to {@link getOrSet}. Reads `key` from the cache and, on a miss, computes * the value with `function_`, stores it, and returns it. * * Unlike {@link getOrSet} there is no request coalescing: synchronous code runs to completion * without interleaving, so concurrent callers cannot stampede the setter the way they can with an * async cache. * * Error handling mirrors {@link getOrSet}: errors are emitted on the cache's `error` event, can be * cached when `cacheErrors` is set, and can be rethrown selectively via `throwErrors` (`true` for * any error, `"function"` for setter errors, `"store"` for cache read/write errors). * * @param key - The cache key, or a function that derives it from the resolved options. * @param function_ - The setter invoked on a cache miss to compute the value. * @param options - The {@link GetOrSetSyncOptions} including the target synchronous cache. * @returns The cached or freshly computed value, or `undefined`. */ function getOrSetSync(key, function_, options) { const keyString = typeof key === "function" ? key(options) : key; let value; try { value = options.cache.get(keyString); } catch (error) { options.cache.emit("error", error); if (options.throwErrors === true || options.throwErrors === "store") throw error; } if (value === void 0) try { try { value = function_(); } catch (error) { throw new ErrorEnvelope(error, "function"); } try { options.cache.set(keyString, value, options.ttl); } catch (error) { throw new ErrorEnvelope(error, "store"); } } catch (caught) { const errorType = caught instanceof ErrorEnvelope ? caught.context : void 0; const error = caught instanceof ErrorEnvelope ? caught.error : caught; options.cache.emit("error", error); if (options.cacheErrors && errorType === "function") try { options.cache.set(keyString, error, options.ttl); } catch (storeError) { options.cache.emit("error", storeError); } if (options.throwErrors === true || options.throwErrors === errorType) throw error; } return value; } function wrap(function_, options) { const { keyPrefix, serialize } = options; return async (...arguments_) => { let cacheKey = createWrapKey(function_, arguments_, { keyPrefix, serialize }); if (options.createKey) cacheKey = options.createKey(function_, arguments_, options); return getOrSet(cacheKey, async () => function_(...arguments_), options); }; } function createWrapKey(function_, arguments_, options) { const { keyPrefix, serialize } = options || {}; if (!keyPrefix) return `${function_.name}::${hashSync(arguments_, { serialize })}`; return `${keyPrefix}::${function_.name}::${hashSync(arguments_, { serialize })}`; } var ErrorEnvelope = class { error; context; constructor(error, context) { this.error = error; this.context = context; } }; //#endregion //#region src/run-if-fn.ts function runIfFn(valueOrFunction, ...arguments_) { return typeof valueOrFunction === "function" ? valueOrFunction(...arguments_) : valueOrFunction; } //#endregion //#region src/sleep.ts const sleep = async (ms) => new Promise((resolve) => setTimeout(resolve, ms)); //#endregion //#region src/stats.ts /** * Event map for `@cacheable/node-cache` instances. node-cache emits with * positional arguments (e.g. `set(key, value)`), and emits each lifecycle * event exactly once, so the counts map cleanly. `flush` clears the cache data * and `flush_stats` resets the stats counters, mirroring node-cache's * `flushAll()` / `flushStats()` lifecycle. * * Presets for `cacheable` and `cache-manager` are intentionally not provided: * their event streams emit per-store probes (and, for cache-manager, do not * emit an event on a normal miss), so a simple map cannot faithfully reproduce * their imperative stats. Wire those up with a custom map or imperative calls. */ const nodeCacheStatsEventMap = { set: (stats, key) => { stats.increment("sets"); if (typeof key === "string" || typeof key === "number") stats.recordKey(String(key), "sets"); }, del: (stats, key) => { stats.increment("deletes"); if (typeof key === "string" || typeof key === "number") stats.recordKey(String(key), "deletes"); }, flush: "clears", flush_stats: (stats) => { stats.reset(); } }; var Stats = class { _counters = { hits: 0, misses: 0, gets: 0, sets: 0, deletes: 0, clears: 0, count: 0 }; _vsize = 0; _ksize = 0; _enabled = false; _lastUpdated; _lastReset; _subscriptions = []; /** Backing store for the public {@link trackedKeys} read-only view. */ _trackedKeys = /* @__PURE__ */ new Map(); _trackKeys = false; _maxTrackedKeys; constructor(options) { if (options?.enabled) this._enabled = options.enabled; if (options?.trackKeys) this._trackKeys = options.trackKeys; if (options?.maxTrackedKeys !== void 0) this._maxTrackedKeys = options.maxTrackedKeys; if (options?.emitter && options?.eventMap) this.subscribe(options.emitter, options.eventMap); } /** * @returns {boolean} - Whether the stats are enabled */ get enabled() { return this._enabled; } /** * @param {boolean} enabled - Whether to enable the stats */ set enabled(enabled) { this._enabled = enabled; } /** * @returns {boolean} - Whether per-key statistics are tracked */ get trackKeys() { return this._trackKeys; } /** * @param {boolean} trackKeys - Whether to track per-key statistics */ set trackKeys(trackKeys) { this._trackKeys = trackKeys; } /** * @returns {number | undefined} - The cap on unique keys tracked, or * `undefined` when unbounded */ get maxTrackedKeys() { return this._maxTrackedKeys; } /** * @param {number | undefined} maxTrackedKeys - The cap on unique keys * tracked. Set `undefined` for unbounded. */ set maxTrackedKeys(maxTrackedKeys) { this._maxTrackedKeys = maxTrackedKeys; } /** * Per-key statistics, keyed by cache key, holding each key's raw * `hits`/`misses`/`gets`/`sets`/`deletes` counters. Populated by * {@link recordKey} when {@link trackKeys} is enabled; read `trackedKeys.size` * for the number of unique keys currently tracked. The returned map is a * read-only view — mutate per-key stats via {@link recordKey} / * {@link clearKeys} / {@link reset}. * @returns {ReadonlyMap>} * @readonly */ get trackedKeys() { return this._trackedKeys; } /** * @returns {number} - The number of hits * @readonly */ get hits() { return this._counters.hits; } /** * @returns {number} - The number of misses * @readonly */ get misses() { return this._counters.misses; } /** * @returns {number} - The number of gets * @readonly */ get gets() { return this._counters.gets; } /** * @returns {number} - The number of sets * @readonly */ get sets() { return this._counters.sets; } /** * @returns {number} - The number of deletes * @readonly */ get deletes() { return this._counters.deletes; } /** * @returns {number} - The number of clears * @readonly */ get clears() { return this._counters.clears; } /** * @returns {number} - The vsize (value size) of the cache instance * @readonly */ get vsize() { return this._vsize; } /** * @returns {number} - The ksize (key size) of the cache instance * @readonly */ get ksize() { return this._ksize; } /** * @returns {number} - The count of the cache instance * @readonly */ get count() { return this._counters.count; } /** * The ratio of hits to total lookups (hits + misses). Returns `0` when there * have been no lookups. * @returns {number} - A value between 0 and 1 * @readonly */ get hitRate() { const total = this._counters.hits + this._counters.misses; return total === 0 ? 0 : this._counters.hits / total; } /** * The ratio of misses to total lookups (hits + misses). Returns `0` when * there have been no lookups. * @returns {number} - A value between 0 and 1 * @readonly */ get missRate() { const total = this._counters.hits + this._counters.misses; return total === 0 ? 0 : this._counters.misses / total; } /** * The timestamp (ms since epoch) of the last mutation while enabled, or * `undefined` if there have been none since the last reset. * @returns {number | undefined} * @readonly */ get lastUpdated() { return this._lastUpdated; } /** * The timestamp (ms since epoch) of the last {@link reset}/{@link clear}, or * `undefined` if it has never been reset. * @returns {number | undefined} * @readonly */ get lastReset() { return this._lastReset; } /** * Increment a counter field by `amount` (default `1`). No-op when disabled. * @param {StatField} field - The counter to increment * @param {number} amount - The amount to add (default 1) */ increment(field, amount = 1) { if (!this._enabled) return; this._counters[field] += amount; this.touch(); } /** * Decrement a counter field by `amount` (default `1`). No-op when disabled. * @param {StatField} field - The counter to decrement * @param {number} amount - The amount to subtract (default 1) */ decrement(field, amount = 1) { if (!this._enabled) return; this._counters[field] -= amount; this.touch(); } incrementHits(amount = 1) { this.increment("hits", amount); } incrementMisses(amount = 1) { this.increment("misses", amount); } incrementGets(amount = 1) { this.increment("gets", amount); } incrementSets(amount = 1) { this.increment("sets", amount); } incrementDeletes(amount = 1) { this.increment("deletes", amount); } incrementClears(amount = 1) { this.increment("clears", amount); } incrementVSize(value) { if (!this._enabled) return; this._vsize += this.roughSizeOfObject(value); this.touch(); } decreaseVSize(value) { if (!this._enabled) return; this._vsize = Math.max(0, this._vsize - this.roughSizeOfObject(value)); this.touch(); } incrementKSize(key) { if (!this._enabled) return; this._ksize += this.roughSizeOfString(key); this.touch(); } decreaseKSize(key) { if (!this._enabled) return; this._ksize = Math.max(0, this._ksize - this.roughSizeOfString(key)); this.touch(); } incrementCount(amount = 1) { this.increment("count", amount); } decreaseCount(amount = 1) { if (!this._enabled) return; this._counters.count = Math.max(0, this._counters.count - amount); this.touch(); } setCount(count) { if (!this._enabled) return; this._counters.count = count; this.touch(); } roughSizeOfString(value) { return value.length * 2; } roughSizeOfObject(object) { const objectList = []; const stack = [object]; let bytes = 0; while (stack.length > 0) { const value = stack.pop(); if (typeof value === "boolean") bytes += 4; else if (typeof value === "string") bytes += value.length * 2; else if (typeof value === "number") bytes += 8; else { if (value === null || value === void 0) { bytes += 4; continue; } if (objectList.includes(value)) continue; objectList.push(value); for (const key in value) { bytes += key.length * 2; stack.push(value[key]); } } } return bytes; } /** * Enable stat tracking. Equivalent to setting {@link enabled} to `true`. */ enable() { this._enabled = true; } /** * Disable stat tracking. Equivalent to setting {@link enabled} to `false`. */ disable() { this._enabled = false; } /** * Reset all counters to zero and record the reset timestamp. Alias of * {@link reset}. */ clear() { this.reset(); } reset() { this._counters = { hits: 0, misses: 0, gets: 0, sets: 0, deletes: 0, clears: 0, count: 0 }; this._vsize = 0; this._ksize = 0; this._trackedKeys.clear(); this._lastReset = Date.now(); this._lastUpdated = void 0; } resetStoreValues() { this._vsize = 0; this._ksize = 0; this._counters.count = 0; } /** * @returns {StatsSnapshot} - A plain-object snapshot of the current stats, * including computed `hitRate`/`missRate` and timestamps. */ toJSON() { return { enabled: this._enabled, hits: this._counters.hits, misses: this._counters.misses, gets: this._counters.gets, sets: this._counters.sets, deletes: this._counters.deletes, clears: this._counters.clears, vsize: this._vsize, ksize: this._ksize, count: this._counters.count, hitRate: this.hitRate, missRate: this.missRate, trackedKeys: this._trackedKeys.size, lastUpdated: this._lastUpdated, lastReset: this._lastReset }; } /** * @returns {StatsSnapshot} - A plain-object snapshot of the current stats. * Alias of {@link toJSON}. */ snapshot() { return this.toJSON(); } /** * Record an operation against a specific key for per-key statistics. No-op * unless both {@link enabled} and {@link trackKeys} are `true`. * @param {string} key - The cache key the operation touched * @param {KeyStatField} field - The per-key counter to increment * @param {number} amount - The amount to add (default 1) */ recordKey(key, field, amount = 1) { if (!this._enabled || !this._trackKeys) return; let counters = this._trackedKeys.get(key); if (!counters) { counters = { hits: 0, misses: 0, gets: 0, sets: 0, deletes: 0 }; this._trackedKeys.set(key, counters); this.pruneTrackedKeys(key); } counters[field] += amount; this.touch(); } /** * The most-used keys, sorted descending. Sorts by total recorded operations, * or by a single field when `field` is provided. Ties order by key. * @param {number} limit - Maximum entries to return (default 100) * @param {KeyStatField} [field] - Optionally rank by one counter (e.g. "hits") * @returns {StatsKeyEntry[]} */ mostUsedKeys(limit = 100, field) { return this.sortedKeyEntries(field, "desc").slice(0, limit); } /** * The least-used keys, sorted ascending. Sorts by total recorded operations, * or by a single field when `field` is provided. Ties order by key. Note: * only keys that have been recorded at least once can be ranked, and when * {@link maxTrackedKeys} pruning has occurred the true least-used keys may * have been evicted. * @param {number} limit - Maximum entries to return (default 100) * @param {KeyStatField} [field] - Optionally rank by one counter (e.g. "gets") * @returns {StatsKeyEntry[]} */ leastUsedKeys(limit = 100, field) { return this.sortedKeyEntries(field, "asc").slice(0, limit); } /** * @param {string} key - The key to look up * @returns {StatsKeyEntry | undefined} - The per-key statistics, or * `undefined` if the key has not been recorded */ keyStats(key) { const counters = this._trackedKeys.get(key); return counters ? this.toKeyEntry(key, counters) : void 0; } /** * Clear all per-key statistics without touching the aggregate counters. */ clearKeys() { this._trackedKeys.clear(); } totalOf(counters) { return counters.hits + counters.misses + counters.gets + counters.sets + counters.deletes; } toKeyEntry(key, counters) { const lookups = counters.hits + counters.misses; return { key, count: this.totalOf(counters), hits: counters.hits, misses: counters.misses, gets: counters.gets, sets: counters.sets, deletes: counters.deletes, hitRate: lookups === 0 ? 0 : counters.hits / lookups }; } sortedKeyEntries(field, direction) { const entries = []; for (const [key, counters] of this._trackedKeys) entries.push(this.toKeyEntry(key, counters)); const sign = direction === "asc" ? 1 : -1; entries.sort((a, b) => { const valueA = field ? a[field] : a.count; const valueB = field ? b[field] : b.count; if (valueA !== valueB) return (valueA - valueB) * sign; return a.key < b.key ? -1 : 1; }); return entries; } /** * When over {@link maxTrackedKeys}, prune the lowest-count keys down to 90% * of the cap (batched so the sort cost amortizes across inserts). The key * that was just recorded is never pruned. */ pruneTrackedKeys(protectedKey) { if (this._maxTrackedKeys === void 0 || this._trackedKeys.size <= this._maxTrackedKeys) return; const target = Math.max(1, Math.floor(this._maxTrackedKeys * .9)); const sorted = [...this._trackedKeys.entries()].sort((a, b) => this.totalOf(a[1]) - this.totalOf(b[1])); for (const [key] of sorted) { if (this._trackedKeys.size <= target) break; if (key === protectedKey) continue; this._trackedKeys.delete(key); } } /** * Subscribe to an emitter so that matching events automatically update the * stats. Counting is gated by {@link enabled}, so you may subscribe first and * toggle enablement later. Call {@link unsubscribe} to detach. * @param {StatsEmitter} emitter - The emitter to listen on * @param {StatsEventMap} eventMap - The event-to-stat mapping (e.g. * {@link nodeCacheStatsEventMap} or a custom map) */ subscribe(emitter, eventMap) { for (const [event, action] of Object.entries(eventMap)) { const listener = (...args) => { this.applyEvent(action, args); }; emitter.on(event, listener); this._subscriptions.push({ emitter, event, listener }); } } /** * Detach listeners previously attached via {@link subscribe}. When `emitter` * is provided, only that emitter's listeners are removed; otherwise all are. * @param {StatsEmitter} [emitter] - The emitter to detach from */ unsubscribe(emitter) { const remaining = []; for (const sub of this._subscriptions) { if (emitter && sub.emitter !== emitter) { remaining.push(sub); continue; } (sub.emitter.off ?? sub.emitter.removeListener)?.call(sub.emitter, sub.event, sub.listener); } this._subscriptions = remaining; } applyEvent(action, args) { if (!this._enabled) return; if (typeof action === "function") { action(this, ...args); return; } if (Array.isArray(action)) { for (const field of action) this.increment(field); return; } this.increment(action); } touch() { this._lastUpdated = Date.now(); } }; //#endregion //#region src/ttl.ts /** * Normalizes a TTL input into per-store milliseconds. When given an object it resolves the * `primary` and `secondary` fields independently; when given a number or shorthand string it * applies the same value to both stores. Undefined fields (or undefined input) resolve to * `undefined` so the caller can fall back to its own default TTL. * @param ttl - The TTL input: a number (ms), a shorthand string, or a {@link PerStoreTtl} object. * @returns {{ primary?: number; secondary?: number }} The resolved per-store TTLs in milliseconds. */ function resolvePerStoreTtl(ttl) { if (ttl === void 0 || ttl === null) return { primary: void 0, secondary: void 0 }; if (typeof ttl === "object") return { primary: shorthandToMilliseconds(ttl.primary), secondary: shorthandToMilliseconds(ttl.secondary) }; const milliseconds = shorthandToMilliseconds(ttl); return { primary: milliseconds, secondary: milliseconds }; } /** * Converts a exspires value to a TTL value. * @param expires - The expires value to convert. * @returns {number | undefined} The TTL value in milliseconds, or undefined if the expires value is not valid. */ function getTtlFromExpires(expires) { if (expires === void 0 || expires === null) return; const now = Date.now(); if (expires < now) return; return expires - now; } /** * Get the TTL value from the cacheableTtl, primaryTtl, and secondaryTtl values. * @param cacheableTtl - The cacheableTtl value to use. * @param primaryTtl - The primaryTtl value to use. * @param secondaryTtl - The secondaryTtl value to use. * @returns {number | undefined} The TTL value in milliseconds, or undefined if all values are undefined. */ function getCascadingTtl(cacheableTtl, primaryTtl, secondaryTtl) { return secondaryTtl ?? primaryTtl ?? shorthandToMilliseconds(cacheableTtl); } /** * Calculate the TTL value from the expires value. If the ttl is undefined, it will be set to the expires value. If the * expires value is undefined, it will be set to the ttl value. If both values are defined, the smaller of the two will be used. * @param ttl * @param expires * @returns */ function calculateTtlFromExpiration(ttl, expires) { const ttlFromExpires = getTtlFromExpires(expires); const expiresFromTtl = ttl ? Date.now() + ttl : void 0; if (ttlFromExpires === void 0) return ttl; if (expiresFromTtl === void 0) return ttlFromExpires; if (expires && expires > expiresFromTtl) return ttl; return ttlFromExpires; } //#endregion export { CacheTags, HashAlgorithm, Stats, calculateTtlFromExpiration, coalesceAsync, createWrapKey, getCascadingTtl, getOrSet, getOrSetSync, getTtlFromExpires, hash, hashSync, hashToNumber, hashToNumberSync, isKeyvInstance, isObject, lessThan, nodeCacheStatsEventMap, resolvePerStoreTtl, runIfFn, shorthandToMilliseconds, shorthandToTime, sleep, wrap, wrapSync };