lrufy
Version:
A feature-rich LRU cache implementation with TTL support, custom sizing, and event hooks
308 lines (307 loc) • 10.1 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.LRUCache = exports.EvictionReason = void 0;
const linked_list_1 = require("./linked-list");
/**
* Reason an item was evicted from the cache
*/
var EvictionReason;
(function (EvictionReason) {
/**
* Item was manually deleted
*/
EvictionReason["DELETED"] = "deleted";
/**
* Item was evicted due to capacity constraints
*/
EvictionReason["EVICTED"] = "evicted";
/**
* Item expired due to TTL
*/
EvictionReason["EXPIRED"] = "expired";
/**
* Item was overwritten with a new value
*/
EvictionReason["OVERWRITTEN"] = "overwritten";
/**
* Cache was manually cleared
*/
EvictionReason["CACHE_CLEAR"] = "clear";
})(EvictionReason || (exports.EvictionReason = EvictionReason = {}));
/**
* A fully-featured Least Recently Used (LRU) cache implementation
* with optional TTL, custom sizing, and event hooks
*/
class LRUCache {
/**
* Creates a new LRU cache instance
* @param options - Configuration options for the cache
*/
constructor(options = {}) {
var _a, _b, _c, _d, _e, _f, _g, _h, _j;
this.totalSize = 0;
this.pruneTimer = null;
this.hits = 0;
this.misses = 0;
this.options = {
max: (_a = options.max) !== null && _a !== void 0 ? _a : Infinity,
maxSize: (_b = options.maxSize) !== null && _b !== void 0 ? _b : Infinity,
ttl: (_c = options.ttl) !== null && _c !== void 0 ? _c : 0,
sizeCalculation: (_d = options.sizeCalculation) !== null && _d !== void 0 ? _d : (() => 1),
dispose: (_e = options.dispose) !== null && _e !== void 0 ? _e : null,
noDisposeOnSet: (_f = options.noDisposeOnSet) !== null && _f !== void 0 ? _f : false,
allowStale: (_g = options.allowStale) !== null && _g !== void 0 ? _g : false,
pruneInterval: (_h = options.pruneInterval) !== null && _h !== void 0 ? _h : 0,
asyncDispose: (_j = options.asyncDispose) !== null && _j !== void 0 ? _j : false,
};
this.cache = new Map();
this.list = new linked_list_1.DoublyLinkedList();
if (this.options.pruneInterval > 0) {
this.startPruneTimer();
}
}
/**
* Gets the current size of the cache
*/
get size() {
return this.cache.size;
}
/**
* Retrieves an item from the cache
* @param key - The key to retrieve
* @returns The cached value or undefined if not found
*/
get(key) {
const node = this.cache.get(key);
if (!node) {
this.misses++;
return undefined;
}
const now = Date.now();
if (node.expiry !== null && node.expiry < now) {
// Item is expired
if (this.options.allowStale) {
this.hits++;
return node.value;
}
else {
this.delete(key, EvictionReason.EXPIRED);
this.misses++;
return undefined;
}
}
// Move to front of list to mark as most recently used
this.list.moveToFront(node);
this.hits++;
return node.value;
}
/**
* Checks if a key exists in the cache without updating its recency
* @param key - The key to check
* @returns True if the key exists and is not expired
*/
has(key) {
const node = this.cache.get(key);
if (!node)
return false;
if (node.expiry !== null && node.expiry < Date.now()) {
if (!this.options.allowStale) {
return false;
}
}
return true;
}
/**
* Gets an item without updating its recency
* @param key - The key to retrieve
* @returns The cached value or undefined if not found
*/
peek(key) {
const node = this.cache.get(key);
if (!node)
return undefined;
if (node.expiry !== null && node.expiry < Date.now()) {
if (!this.options.allowStale) {
return undefined;
}
}
return node.value;
}
/**
* Adds or updates an item in the cache
* @param key - The key to set
* @param value - The value to cache
* @param options - Options for this specific item
* @returns The cache instance for chaining
*/
set(key, value, options = {}) {
var _a, _b;
const existing = this.cache.get(key);
if (existing) {
// Handle disposing of the old value if being overwritten
if (!this.options.noDisposeOnSet) {
this.disposeItem(existing.key, existing.value, EvictionReason.OVERWRITTEN);
}
// Remove from the linked list and update totalSize
this.list.remove(existing);
this.totalSize -= existing.size;
}
// Calculate TTL for this item
const ttl = (_a = options.ttl) !== null && _a !== void 0 ? _a : this.options.ttl;
const expiry = ttl > 0 ? Date.now() + ttl : null;
// Calculate size for this item
const size = (_b = options.size) !== null && _b !== void 0 ? _b : this.options.sizeCalculation(value, key);
// Create and add the new node
const node = new linked_list_1.Node(key, value);
node.size = size;
node.expiry = expiry;
this.list.addToFront(node);
this.cache.set(key, node);
this.totalSize += size;
// Prune if needed
this.prune();
return this;
}
/**
* Removes an item from the cache
* @param key - The key to remove
* @param reason - The reason for removal (for dispose callback)
* @returns True if the item was found and removed
*/
delete(key, reason = EvictionReason.DELETED) {
const node = this.cache.get(key);
if (!node)
return false;
this.disposeItem(node.key, node.value, reason);
this.cache.delete(key);
this.list.remove(node);
this.totalSize -= node.size;
return true;
}
/**
* Clears all items from the cache
*/
clear() {
// Call dispose on all items if needed
if (this.options.dispose) {
for (const [key, node] of this.cache.entries()) {
this.disposeItem(key, node.value, EvictionReason.CACHE_CLEAR);
}
}
this.cache.clear();
this.list.clear();
this.totalSize = 0;
}
/**
* Retrieves cache statistics
* @returns An object with cache statistics
*/
getStats() {
const totalAccesses = this.hits + this.misses;
return {
size: this.cache.size,
hits: this.hits,
misses: this.misses,
totalSize: this.totalSize,
hitRate: totalAccesses > 0 ? this.hits / totalAccesses : 0,
};
}
/**
* Removes all expired items from the cache
* @returns Number of items pruned
*/
prune() {
const now = Date.now();
let pruned = 0;
// First, remove expired items
for (const [key, node] of this.cache.entries()) {
if (node.expiry !== null && node.expiry <= now) {
this.delete(key, EvictionReason.EXPIRED);
pruned++;
}
}
// Then enforce size constraints
while ((this.options.max < Infinity && this.cache.size > this.options.max) ||
(this.options.maxSize < Infinity && this.totalSize > this.options.maxSize)) {
const node = this.list.removeTail();
if (!node)
break;
this.cache.delete(node.key);
this.disposeItem(node.key, node.value, EvictionReason.EVICTED);
this.totalSize -= node.size;
pruned++;
}
return pruned;
}
/**
* Serializes the cache to a JSON-friendly format
* @returns An array of entries that can be used to reconstruct the cache
*/
serialize() {
const result = [];
for (const [key, node] of this.cache.entries()) {
const options = {
size: node.size,
};
if (node.expiry !== null) {
const ttl = node.expiry - Date.now();
if (ttl > 0) {
options.ttl = ttl;
}
else if (!this.options.allowStale) {
continue; // Skip expired items
}
}
result.push([key, node.value, options]);
}
return result;
}
/**
* Loads serialized data into the cache
* @param data - Serialized cache data from serialize()
* @returns The cache instance for chaining
*/
deserialize(data) {
this.clear();
for (const [key, value, options] of data) {
this.set(key, value, options);
}
return this;
}
/**
* Starts the automatic pruning timer
*/
startPruneTimer() {
if (this.pruneTimer) {
clearInterval(this.pruneTimer);
}
this.pruneTimer = setInterval(() => {
this.prune();
}, this.options.pruneInterval);
// Prevent the timer from keeping the process alive
if (this.pruneTimer.unref) {
this.pruneTimer.unref();
}
}
/**
* Handles disposing of an item
*/
disposeItem(key, value, reason) {
if (this.options.dispose === null)
return;
try {
if (this.options.asyncDispose) {
Promise.resolve(this.options.dispose(value, key, reason)).catch((err) => {
console.error("Async dispose error:", err);
});
}
else {
this.options.dispose(value, key, reason);
}
}
catch (e) {
console.error("Error in dispose callback:", e);
}
}
}
exports.LRUCache = LRUCache;