From c312a0141c882a965bea7b14542a2e1ea44b8d58 Mon Sep 17 00:00:00 2001 From: Joshua Eubanks Date: Thu, 5 Feb 2026 10:21:03 -0600 Subject: [PATCH] Add more LiquidCache tests --- README.md | 22 +++++- src/index.ts | 151 ++++++++++++++++++++++++++++++--------- test/LiquidCache.spec.ts | 78 ++++++++++++++++++++ 3 files changed, 214 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 84ccc21..5b8ffd1 100644 --- a/README.md +++ b/README.md @@ -34,8 +34,8 @@ Here's how you can use LiquidCache in your TypeScript code: ```ts import { LiquidCache } from 'liquidcache'; -// Create a new cache instance -const cache = new LiquidCache(); +// Create a new cache instance with a default TTL of 60 seconds +const cache = new LiquidCache(60000); // Set a value in the cache cache.set('name', 'FlaringPhoenix'); @@ -52,6 +52,24 @@ cache.set('name', 'FlaringPhoenix', 5000); // After 5 seconds, 'name' will be automatically removed from the cache ``` +### Options + +You can also pass an options object for more control: + +```ts +const cache = new LiquidCache({ + defaultTTL: 60000, // null disables expiration by default + autoRenew: true, // refresh TTL on successful get() + useUnref: true, // allow Node.js process to exit if only timers remain + onExpire: (key, value) => { + console.log(`expired ${key}`, value); + }, +}); + +cache.set('session', 'active', 30000); +const remaining = cache.getRemainingTTL('session'); +``` + ## Support If you need help with configuring or using LiquidCache, please open an issue [here](https://github.com/FlaringPhoenix/LiquidCache/issues/new). diff --git a/src/index.ts b/src/index.ts index 4ab5ef3..c905eaf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,44 +1,125 @@ +export interface LiquidCacheOptions { + defaultTTL?: number | null; + autoRenew?: boolean; + onExpire?: (key: K, value: V) => void; + useUnref?: boolean; +} + +type ExpirationEntry = { + timeout: NodeJS.Timeout; + expiresAt: number; + ttl: number; +}; + class LiquidCache extends Map { - private expirationMap = new Map(); - - constructor(private defaultExpiration: number = 60000) { - super(); + private readonly expirationMap = new Map(); + private readonly defaultTTL: number | null; + private readonly autoRenew: boolean; + private readonly onExpire?: (key: K, value: V) => void; + private readonly useUnref: boolean; + + constructor(defaultTTL?: number | null); + constructor(options?: LiquidCacheOptions); + constructor(defaultTTLOrOptions: number | null | LiquidCacheOptions = 60000) { + super(); + + if (typeof defaultTTLOrOptions === 'object' && defaultTTLOrOptions !== null) { + this.defaultTTL = defaultTTLOrOptions.defaultTTL ?? 60000; + this.autoRenew = defaultTTLOrOptions.autoRenew ?? false; + this.onExpire = defaultTTLOrOptions.onExpire; + this.useUnref = defaultTTLOrOptions.useUnref ?? false; + } else { + this.defaultTTL = defaultTTLOrOptions ?? 60000; + this.autoRenew = false; + this.useUnref = false; } - - set(key: K, value: V, expiration?: number): this { - // If the key already exists, clear the previous timeout - if (this.expirationMap.has(key)) { - clearTimeout(this.expirationMap.get(key)!); + } + + set(key: K, value: V, ttl?: number | null): this { + this.clearExpiration(key); + super.set(key, value); + this.scheduleExpiration(key, ttl); + return this; + } + + get(key: K): V | undefined { + const value = super.get(key); + if (value !== undefined || super.has(key)) { + if (this.autoRenew) { + const entry = this.expirationMap.get(key); + if (entry) { + this.clearExpiration(key); + this.scheduleExpiration(key, entry.ttl); + } } - - super.set(key, value); - - const expirationTime = expiration ?? this.defaultExpiration; - const timeout = setTimeout(() => this.delete(key), expirationTime); - this.expirationMap.set(key, timeout); - - return this; } - - delete(key: K): boolean { - const timeout = this.expirationMap.get(key); - if (timeout) { - clearTimeout(timeout); - this.expirationMap.delete(key); - } - - return super.delete(key); + return value; + } + + delete(key: K): boolean { + this.clearExpiration(key); + return super.delete(key); + } + + clear(): void { + for (const entry of this.expirationMap.values()) { + clearTimeout(entry.timeout); } - - // Optionally, you can clear the entire cache and all timeouts - clear(): void { - for (const timeout of this.expirationMap.values()) { - clearTimeout(timeout); + this.expirationMap.clear(); + super.clear(); + } + + getRemainingTTL(key: K): number | null { + const entry = this.expirationMap.get(key); + if (!entry) { + return null; + } + return Math.max(0, entry.expiresAt - Date.now()); + } + + private scheduleExpiration(key: K, ttl?: number | null): void { + const resolvedTTL = ttl ?? this.defaultTTL; + if (resolvedTTL === null || resolvedTTL === undefined) { + return; + } + + const expiresAt = Date.now() + resolvedTTL; + const timeout = setTimeout(() => this.expireKey(key), resolvedTTL); + if (this.useUnref && typeof timeout.unref === 'function') { + timeout.unref(); + } + this.expirationMap.set(key, { + timeout, + expiresAt, + ttl: resolvedTTL, + }); + } + + private clearExpiration(key: K): void { + const entry = this.expirationMap.get(key); + if (entry) { + clearTimeout(entry.timeout); + this.expirationMap.delete(key); + } + } + + private expireKey(key: K): void { + const entry = this.expirationMap.get(key); + if (!entry) { + return; + } + + this.expirationMap.delete(key); + const hadKey = super.has(key); + const value = super.get(key); + if (hadKey) { + super.delete(key); + if (this.onExpire) { + this.onExpire(key, value as V); } - this.expirationMap.clear(); - super.clear(); } } +} + +export default LiquidCache; - export default LiquidCache; - \ No newline at end of file diff --git a/test/LiquidCache.spec.ts b/test/LiquidCache.spec.ts index 347d4cf..3d90e8f 100644 --- a/test/LiquidCache.spec.ts +++ b/test/LiquidCache.spec.ts @@ -22,6 +22,14 @@ describe('LiquidCache', () => { }, 70); }); + it('should support disabling expiration with null ttl', (done) => { + cache.set('key', 'value', null); + setTimeout(() => { + expect(cache.has('key')).to.be.true; + done(); + }, 70); + }); + it('should override an existing key', (done) => { cache.set('key', 'value1'); cache.set('key', 'value2'); @@ -43,4 +51,74 @@ describe('LiquidCache', () => { expect(cache.size).to.equal(0); done(); }); + + it('should allow reading remaining ttl', (done) => { + cache.set('key', 'value', 100); + const remaining = cache.getRemainingTTL('key'); + expect(remaining).to.be.a('number'); + expect(remaining).to.be.greaterThan(0); + done(); + }); + + it('should return null remaining ttl for missing keys', (done) => { + expect(cache.getRemainingTTL('missing')).to.equal(null); + done(); + }); + + it('should return null remaining ttl when expiration is disabled', (done) => { + cache.set('key', 'value', null); + expect(cache.getRemainingTTL('key')).to.equal(null); + done(); + }); + + it('should not call onExpire for manual deletes', (done) => { + const events: string[] = []; + const customCache = new LiquidCache({ + defaultTTL: 50, + onExpire: (key) => events.push(key), + }); + customCache.set('key', 'value'); + customCache.delete('key'); + setTimeout(() => { + expect(events).to.deep.equal([]); + done(); + }, 70); + }); + + it('should clear existing ttl when updating with null', (done) => { + cache.set('key', 'value', 50); + cache.set('key', 'value', null); + setTimeout(() => { + expect(cache.has('key')).to.be.true; + done(); + }, 80); + }); + + it('should call onExpire callback', (done) => { + const events: string[] = []; + const customCache = new LiquidCache({ + defaultTTL: 30, + onExpire: (key) => events.push(key), + }); + customCache.set('key', 'value'); + setTimeout(() => { + expect(events).to.deep.equal(['key']); + done(); + }, 60); + }); + + it('should auto-renew ttl on get when enabled', (done) => { + const customCache = new LiquidCache({ + defaultTTL: 80, + autoRenew: true, + }); + customCache.set('key', 'value'); + setTimeout(() => { + expect(customCache.get('key')).to.equal('value'); + }, 50); + setTimeout(() => { + expect(customCache.has('key')).to.be.true; + done(); + }, 130); + }); });