diff --git a/package.json b/package.json index 34f6cf6..4ff7ef3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ztimson/utils", - "version": "0.27.4", + "version": "0.27.5", "description": "Utility library", "author": "Zak Timson", "license": "MIT", diff --git a/src/cache.ts b/src/cache.ts index 0c828a7..c30231b 100644 --- a/src/cache.ts +++ b/src/cache.ts @@ -8,6 +8,8 @@ export type CacheOptions = { persistentStorage?: {storage: Storage | Database, key: string} | string; /** Keep or delete cached items once expired, defaults to delete */ expiryPolicy?: 'delete' | 'keep'; + /** Least Recently Used size limit */ + sizeLimit?: number; } export type CachedValue = T & {_expired?: boolean}; @@ -16,14 +18,15 @@ export type CachedValue = T & {_expired?: boolean}; * Map of data which tracks whether it is a complete collection & offers optional expiry of cached values */ export class Cache { - private store: Record = {}; + private _loading!: Function; + private store: Map = new Map(); + private timers: Map = new Map(); + private lruOrder: K[] = []; /** Support index lookups */ [key: string | number | symbol]: CachedValue | any; /** Whether cache is complete */ complete = false; - - private _loading!: Function; /** Await initial loading */ loading = new Promise(r => this._loading = r); @@ -43,12 +46,18 @@ export class Cache { const persists: any = this.options.persistentStorage; const table: Table = await persists.storage.createTable({name: persists.key, key: this.key}); const rows = await table.getAll(); - Object.assign(this.store, rows.reduce((acc, row) => ({...acc, [this.getKey(row)]: row}), {})); + for(const row of rows) this.store.set(this.getKey(row), row); this._loading(); })(); } else if((this.options.persistentStorage?.storage)?.getItem != undefined) { - const stored = (this.options.persistentStorage.storage).getItem(this.options.persistentStorage.key); - if(stored != null) try { Object.assign(this.store, JSON.parse(stored)); } catch { } + const {storage, key} = <{storage: Storage, key: string}>this.options.persistentStorage; + const stored = storage.getItem(key); + if(stored != null) { + try { + const obj = JSON.parse(stored); + for(const k of Object.keys(obj)) this.store.set(k, obj[k]); + } catch { /* ignore */ } + } this._loading(); } } else { @@ -71,8 +80,8 @@ export class Cache { private getKey(value: T): K { if(!this.key) throw new Error('No key defined'); - if(value[this.key] === undefined) throw new Error(`${this.key.toString()} Doesn't exist on ${JSON.stringify(value, null, 2)}`); - return value[this.key]; + if((value as any)[this.key] === undefined) throw new Error(`${this.key.toString()} Doesn't exist on ${JSON.stringify(value, null, 2)}`); + return (value as any)[this.key]; } private save(key?: K) { @@ -80,28 +89,50 @@ export class Cache { if(!!persists?.storage) { if(persists.storage?.database != undefined) { (persists.storage).createTable({name: persists.key, key: this.key}).then(table => { - if(key) { - const value = this.get(key); + if(key !== undefined) { + const value = this.get(key, true); if(value != null) table.set(value, key); else table.delete(key); } else { table.clear(); - this.all().forEach(row => table.add(row)); + this.all(true).forEach(row => table.add(row)); } }); } else if(persists.storage?.setItem != undefined) { - persists.storage.setItem(persists.storage.key, JSONSanitize(this.all(true))); + const obj: Record = {}; + for(const [k, v] of this.store.entries()) obj[k as any] = v; + persists.storage.setItem(persists.key, JSONSanitize(obj)); } } } + private clearTimer(key: K) { + const t = this.timers.get(key); + if(t) { clearTimeout(t); this.timers.delete(key); } + } + + private touchLRU(key: K) { + if(!this.options.sizeLimit || this.options.sizeLimit <= 0) return; + const idx = this.lruOrder.indexOf(key); + if(idx >= 0) this.lruOrder.splice(idx, 1); + this.lruOrder.push(key); + while(this.lruOrder.length > (this.options.sizeLimit || 0)) { + const lru = this.lruOrder.shift(); + if(lru !== undefined) this.delete(lru); + } + } + /** * Get all cached items * @return {T[]} Array of items */ all(expired?: boolean): CachedValue[] { - return deepCopy(Object.values(this.store) - .filter((v: any) => expired || !v._expired)); + const out: CachedValue[] = []; + for(const v of this.store.values()) { + const val: any = v; + if(expired || !val?._expired) out.push(deepCopy(val)); + } + return out; } /** @@ -134,7 +165,10 @@ export class Cache { */ clear(): this { this.complete = false; - this.store = {}; + for (const [k, t] of this.timers) clearTimeout(t); + this.timers.clear(); + this.lruOrder = []; + this.store.clear(); this.save(); return this; } @@ -144,7 +178,10 @@ export class Cache { * @param {K} key Item's primary key */ delete(key: K): this { - delete this.store[key]; + this.clearTimer(key); + const idx = this.lruOrder.indexOf(key); + if(idx >= 0) this.lruOrder.splice(idx, 1); + this.store.delete(key); this.save(key); return this; } @@ -154,8 +191,12 @@ export class Cache { * @return {[K, T][]} Key-value pairs array */ entries(expired?: boolean): [K, CachedValue][] { - return deepCopy(Object.entries(this.store) - .filter((v: any) => expired || !v?._expired)); + const out: [K, CachedValue][] = []; + for(const [k, v] of this.store.entries()) { + const val: any = v; + if(expired || !val?._expired) out.push([k, deepCopy(val)]); + } + return out; } /** @@ -165,8 +206,12 @@ export class Cache { expire(key: K): this { this.complete = false; if(this.options.expiryPolicy == 'keep') { - (this.store[key])._expired = true; - this.save(key); + const v: any = this.store.get(key); + if(v) { + v._expired = true; + this.store.set(key, v); + this.save(key); + } } else this.delete(key); return this; } @@ -178,7 +223,11 @@ export class Cache { * @returns {T | undefined} Cached item or undefined if nothing matched */ find(filter: Partial, expired?: boolean): T | undefined { - return Object.values(this.store).find((row: any) => (expired || !row._expired) && includes(row, filter)); + for(const v of this.store.values()) { + const row: any = v; + if((expired || !row._expired) && includes(row, filter)) return deepCopy(row); + } + return undefined; } /** @@ -188,7 +237,10 @@ export class Cache { * @return {T} Cached item */ get(key: K, expired?: boolean): CachedValue | null { - const cached = deepCopy(this.store[key] ?? null); + const raw = this.store.get(key); + if(raw == null) return null; + const cached: any = deepCopy(raw); + this.touchLRU(key); if(expired || !cached?._expired) return cached; return null; } @@ -198,8 +250,12 @@ export class Cache { * @return {K[]} Array of keys */ keys(expired?: boolean): K[] { - return Object.keys(this.store) - .filter(k => expired || !(this.store)[k]._expired); + const out: K[] = []; + for(const [k, v] of this.store.entries()) { + const val: any = v; + if(expired || !val?._expired) out.push(k); + } + return out; } /** @@ -207,10 +263,11 @@ export class Cache { * @return {Record} */ map(expired?: boolean): Record> { - const copy: any = deepCopy(this.store); - if(!expired) Object.keys(copy).forEach(k => { - if(copy[k]._expired) delete copy[k] - }); + const copy: any = {}; + for(const [k, v] of this.store.entries()) { + const val: any = v; + if(expired || !val?._expired) copy[k as any] = deepCopy(val); + } return copy; } @@ -223,12 +280,17 @@ export class Cache { */ set(key: K, value: T, ttl = this.options.ttl): this { if(this.options.expiryPolicy == 'keep') delete (value)._expired; - this.store[key] = value; + this.clearTimer(key); + this.store.set(key, value); + this.touchLRU(key); this.save(key); - if(ttl) setTimeout(() => { - this.expire(key); - this.save(key); - }, (ttl || 0) * 1000); + if(ttl) { + const t = setTimeout(() => { + this.expire(key); + this.save(key); + }, (ttl || 0) * 1000); + this.timers.set(key, t); + } return this; } @@ -236,5 +298,5 @@ export class Cache { * Get all cached items * @return {T[]} Array of items */ - values = this.all(); + values = this.all } diff --git a/tests/cache.spec.ts b/tests/cache.spec.ts index 7ad5e27..96e29b3 100644 --- a/tests/cache.spec.ts +++ b/tests/cache.spec.ts @@ -3,14 +3,16 @@ import { Cache } from '../src'; describe('Cache', () => { type TestItem = { id: string; value: string }; - let cache: Cache; + let cache: Cache; let storageMock: Storage; let storageGetItemSpy: jest.SpyInstance; let storageSetItemSpy: jest.SpyInstance; beforeEach(() => { + jest.useFakeTimers(); + storageMock = { - constructor: {name: 'Storage' as any}, + constructor: { name: 'Storage' as any }, getItem: jest.fn(), setItem: jest.fn(), removeItem: jest.fn(), @@ -23,11 +25,15 @@ describe('Cache', () => { storageGetItemSpy = jest.spyOn(storageMock, 'getItem'); storageSetItemSpy = jest.spyOn(storageMock, 'setItem'); - cache = new Cache('id', { + cache = new Cache('id', { persistentStorage: { storage: storageMock, key: 'cache' }, }); + jest.clearAllMocks(); - jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); }); it('adds and gets an item', () => { @@ -58,10 +64,12 @@ describe('Cache', () => { expect(storageSetItemSpy).toHaveBeenCalled(); }); - it('clears the cache', () => { - cache.add({ id: '1', value: 'test' }); + it('clears the cache & cancels TTL timers', () => { + cache.set('1', { id: '1', value: 'x' }, 1); cache.clear(); - expect(cache.get('1')).toBeNull(); + // Even after timers advance, nothing should appear or throw + jest.advanceTimersByTime(1500); + expect(cache.get('1', true)).toBeNull(); expect(cache.complete).toBe(false); }); @@ -75,7 +83,7 @@ describe('Cache', () => { expect(cache.complete).toBe(true); }); - it('returns correct keys, entries, and map', () => { + it('returns correct keys, entries, map, and values()', () => { cache.add({ id: 'x', value: 'foo' }); cache.add({ id: 'y', value: 'bar' }); expect(cache.keys().sort()).toEqual(['x', 'y']); @@ -83,6 +91,8 @@ describe('Cache', () => { const m = cache.map(); expect(Object.keys(m)).toEqual(expect.arrayContaining(['x', 'y'])); expect(m['x'].value).toBe('foo'); + // CHANGE: values() was a snapshot field before; now it’s a method + expect(cache.values().map(v => v.id).sort()).toEqual(['x', 'y']); }); it('persists and restores from storage', () => { @@ -93,6 +103,8 @@ describe('Cache', () => { persistentStorage: { storage: storageMock, key: 'cache' }, }); expect(c.get('z')).toEqual({ id: 'z', value: 'from-storage' }); + // ensure it used the right storage key + expect(storageGetItemSpy).toHaveBeenCalledWith('cache'); }); it('expiryPolicy "delete" removes expired items completely', () => { @@ -112,20 +124,71 @@ describe('Cache', () => { expect(val && val._expired).toBe(true); }); - // Uncomment and adapt this test if TTL/expiry timers are supported by your implementation - // it('expires and deletes items after TTL', () => { - // jest.useFakeTimers(); - // cache = new Cache('id', { ttl: 0.01 }); - // cache.add({ id: 'ttl1', value: 'temp' }); - // jest.advanceTimersByTime(100); - // expect(cache.get('ttl1')).toBeNull(); - // }); + it('TTL expiration deletes by default', () => { + cache.add({ id: 'ttl1', value: 'tick' }, 1); + jest.advanceTimersByTime(999); + expect(cache.get('ttl1')).toEqual({ id: 'ttl1', value: 'tick' }); + jest.advanceTimersByTime(2); + expect(cache.get('ttl1')).toBeNull(); + }); - // Edge: add error handling test - it('throws if instantiating with invalid key property', () => { - expect(() => { - const invalid = new Cache<'string', TestItem>('id'); - // try invalid.add({id: 'z', value: 'fail'}) if needed - }).not.toThrow(); + it('TTL overwrite cancels previous timer', () => { + cache.add({ id: 'ow', value: 'v1' }, 1); + jest.advanceTimersByTime(500); + cache.add({ id: 'ow', value: 'v2' }, 1); + jest.advanceTimersByTime(600); + expect(cache.get('ow')).toEqual({ id: 'ow', value: 'v2' }); + jest.advanceTimersByTime(500); + expect(cache.get('ow')).toBeNull(); + }); + + it('find() returns first match respecting expired flag', () => { + cache.options.expiryPolicy = 'keep'; + cache.add({ id: 'f1', value: 'hello' }); + cache.add({ id: 'f2', value: 'world' }); + cache.expire('f1'); + expect(cache.find({ value: 'hello' })).toBeUndefined(); + expect(cache.find({ value: 'hello' }, true)).toEqual({ id: 'f1', value: 'hello', _expired: true }); + }); + + it('symbol keys are supported when using set/get directly', () => { + const s = Symbol('k'); + // NOTE: can’t use add() without a primary key; set works fine + cache.set(s, { id: 'sym', value: 'ok' }); + expect(cache.get(s as any)).toEqual({ id: 'sym', value: 'ok' }); + // ensure keys() includes the symbol + const keys = cache.keys(true); + expect(keys.includes(s as any)).toBe(true); + }); + + it('LRU: evicts least-recently-used when over capacity', () => { + const lruCache = new Cache('id', { + sizeLimit: 2, + persistentStorage: { storage: storageMock, key: 'cache' }, + }); + + lruCache.add({ id: 'A', value: '1' }); + lruCache.add({ id: 'B', value: '2' }); + // touch A to make it MRU + expect(lruCache.get('A')).toEqual({ id: 'A', value: '1' }); + // add C → should evict least-recently-used (B) + lruCache.add({ id: 'C', value: '3' }); + + expect(lruCache.get('B')).toBeNull(); + expect(lruCache.get('A')).toEqual({ id: 'A', value: '1' }); + expect(lruCache.get('C')).toEqual({ id: 'C', value: '3' }); + }); + + it('delete() removes from LRU and cancels timer', () => { + const lruCache = new Cache('id', { sizeLimit: 2 }); + lruCache.set('A', { id: 'A', value: '1' }, 1); + lruCache.delete('A'); + jest.advanceTimersByTime(1200); + expect(lruCache.get('A', true)).toBeNull(); + }); + + it('throws when adding item missing the primary key', () => { + const c = new Cache<'id', any>('id'); + expect(() => c.add({ nope: 'missing' } as any)).toThrow(/Doesn't exist/); }); });