blob: ed97de3c50b47606c1b4caae282131a12f9c65e9 [file]
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--:<namespace>:tag:<tag>` → integer version counter (stored without TTL).
* - `--cacheable--tags--:<namespace>:key:<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<number>} 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<number[]>} 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<void>} 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<void>} 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<void>} 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<void>} 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<boolean>} `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<boolean>} `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<string[]>} 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<string[] | undefined>} 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<string[]>} 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<string[]>} 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<string[]>} 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<string>} 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<number>} 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<string, Readonly<KeyCounters>>}
* @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 };