UNPKG

lrufy

Version:

A feature-rich LRU cache implementation with TTL support, custom sizing, and event hooks

308 lines (307 loc) 10.1 kB
"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;