diff --git a/package.json b/package.json index 66726c5..ce5cc15 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ztimson/utils", - "version": "0.25.25", + "version": "0.25.26", "description": "Utility library", "author": "Zak Timson", "license": "MIT", diff --git a/src/cache.ts b/src/cache.ts index 4545285..8e14031 100644 --- a/src/cache.ts +++ b/src/cache.ts @@ -1,13 +1,11 @@ -import {Table} from './database.ts'; +import {Database, Table} from './database.ts'; import {deepCopy, includes, JSONSanitize} from './objects.ts'; export type CacheOptions = { /** Delete keys automatically after x amount of seconds */ ttl?: number; /** Storage to persist cache */ - storage?: Storage | Table; - /** Key cache will be stored under */ - storageKey?: string; + persistentStorage?: {storage: Storage | Database, key: string} | string; /** Keep or delete cached items once expired, defaults to delete */ expiryPolicy?: 'delete' | 'keep'; } @@ -33,21 +31,30 @@ export class Cache { * @param options */ constructor(public readonly key?: keyof T, public readonly options: CacheOptions = {}) { - let resolve: any; - this.loading = new Promise(r => resolve = r); - if(options.storageKey && !options.storage && typeof(Storage) !== 'undefined') options.storage = localStorage; - if(options.storage) { - if(options.storage instanceof Table) { + let done!: Function; + this.loading = new Promise(r => done = r); + + // Persistent storage + if(this.options.persistentStorage != null) { + if(typeof this.options.persistentStorage == 'string') + this.options.persistentStorage = {storage: localStorage, key: this.options.persistentStorage}; + + if(this.options.persistentStorage?.storage instanceof Database) { (async () => { - this.addAll(await options.storage?.getAll(), false); - resolve(); - })() - } else if(options.storageKey) { - const stored = options.storage?.getItem(options.storageKey); + 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}), {})); + done(); + })(); + } else { + const stored = this.options.persistentStorage.storage.getItem(this.options.persistentStorage.key); if(stored != null) try { Object.assign(this.store, JSON.parse(stored)); } catch { } - resolve(); + done(); } - } else resolve(); + } + + // Handle index lookups return new Proxy(this, { get: (target: this, prop: string | symbol) => { if(prop in target) return (target as any)[prop]; @@ -68,19 +75,19 @@ export class Cache { } private save(key?: K) { - if(this.options.storage) { - if(this.options.storage instanceof Table) { - if(key == null) { - let rows: any = this.entries(); - rows.forEach(([k, v]: [string, any]) => this.options.storage?.put(k, v)); - rows = rows.map(([k]: [string]) => k); - this.options.storage.getAllKeys().then(keys => - keys.filter(k => !rows.includes(k)) - .forEach(k => this.options.storage?.delete(k))); - } else if(this.store[key] === undefined) this.options.storage.delete(key); - else this.options.storage.put(key, this.store[key]); - } else if(this.options.storageKey) { - this.options.storage.setItem(this.options.storageKey, JSONSanitize(this.store)); + const persists: any = this.options.persistentStorage; + if(!!persists?.storage) { + if(persists.storage instanceof Database) { + (persists.storage).createTable({name: persists.storage.key, key: this.key}).then(table => { + if(key) { + table.set(key, this.get(key)); + } else { + table.clear(); + this.all().forEach(row => table.add(row)); + } + }); + } else { + persists.storage.setItem(persists.storage.key, JSONSanitize(this.all(true))); } } } diff --git a/src/database.ts b/src/database.ts index c7ff4fc..7437c2b 100644 --- a/src/database.ts +++ b/src/database.ts @@ -79,26 +79,30 @@ export class Database { async createTable(table: string | TableOptions): Promise> { return this.schemaLock.run(async () => { - if(typeof table == 'string') table = { name: table }; + if (typeof table == 'string') table = { name: table }; const conn = await this.connection; - if(!this.includes(table.name)) { + if (!this.includes(table.name)) { + const newDb = new Database(this.database, [...this.tables, table], (this.version ?? 0) + 1); conn.close(); - Object.assign(this, new Database(this.database, [...this.tables, table], (this.version ?? 0) + 1)); + Object.assign(this, newDb); await this.connection; } return this.table(table.name); }); } + async deleteTable(table: string | TableOptions): Promise { return this.schemaLock.run(async () => { - if(typeof table == 'string') table = { name: table }; - if(!this.includes(table.name)) return; + if (typeof table == 'string') table = { name: table }; + if (!this.includes(table.name)) return; const conn = await this.connection; + const newDb = new Database(this.database, this.tables.filter(t => t.name != (table).name), (this.version ?? 0) + 1); conn.close(); - Object.assign(this, new Database(this.database, this.tables.filter(t => t.name != (table).name), (this.version ?? 0) + 1)); + Object.assign(this, newDb); await this.connection; }); + } includes(name: any): boolean { diff --git a/tests/cache.spec.ts b/tests/cache.spec.ts index e4c8385..f960156 100644 --- a/tests/cache.spec.ts +++ b/tests/cache.spec.ts @@ -1,7 +1,7 @@ -import {Cache} from '../src'; +import { Cache } from '../src'; describe('Cache', () => { - type TestItem = { id: string; value: string; }; + type TestItem = { id: string; value: string }; let cache: Cache; let storageMock: Storage; @@ -17,93 +17,114 @@ describe('Cache', () => { key: jest.fn(), length: 0, }; + + // Spies storageGetItemSpy = jest.spyOn(storageMock, 'getItem'); storageSetItemSpy = jest.spyOn(storageMock, 'setItem'); - cache = new Cache('id', {storage: storageMock, storageKey: 'cache'}); + + cache = new Cache('id', { + persistentStorage: { storage: storageMock, key: 'cache' }, + }); jest.clearAllMocks(); jest.useFakeTimers(); }); - test('should add and get an item', () => { - const item = {id: '1', value: 'a'}; + it('adds and gets an item', () => { + const item = { id: '1', value: 'a' }; cache.add(item); expect(cache.get('1')).toEqual(item); }); - test('should not get an expired item when expired option not set', () => { - const item = {id: '1', value: 'a'}; - cache.set('1', item); + it('skips expired items by default but fetches if requested', () => { + const item = { id: '2', value: 'b' }; + cache.set('2', item); cache.options.expiryPolicy = 'keep'; - cache.expire('1'); - expect(cache.get('1')).toBeNull(); - expect(cache.get('1', true)).toEqual({...item, _expired: true}); + cache.expire('2'); + expect(cache.get('2')).toBeNull(); + expect(cache.get('2', true)).toEqual({ ...item, _expired: true }); }); - test('should set and get via property access (proxy)', () => { - (cache as any)['2'] = {id: '2', value: 'b'}; - expect((cache as any)['2']).toEqual({id: '2', value: 'b'}); + it('supports property access and setting via Proxy', () => { + (cache as any)['3'] = { id: '3', value: 'c' }; + expect((cache as any)['3']).toEqual({ id: '3', value: 'c' }); + expect(cache.get('3')).toEqual({ id: '3', value: 'c' }); }); - test('should remove an item', () => { - cache.set('1', {id: '1', value: 'a'}); - cache.delete('1'); - expect(cache.get('1')).toBeNull(); + it('removes an item and persists', () => { + cache.add({ id: '4', value: 'd' }); + cache.delete('4'); + expect(cache.get('4')).toBeNull(); expect(storageSetItemSpy).toHaveBeenCalled(); }); - test('should clear the cache', () => { - cache.add({id: '1', value: 'a'}); + it('clears the cache', () => { + cache.add({ id: '1', value: 'test' }); cache.clear(); expect(cache.get('1')).toBeNull(); expect(cache.complete).toBe(false); }); - test('should add multiple items and mark complete', () => { - const rows = [ - {id: 'a', value: '1'}, - {id: 'b', value: '2'}, + it('bulk adds, marks complete', () => { + const items = [ + { id: 'a', value: '1' }, + { id: 'b', value: '2' }, ]; - cache.addAll(rows); + cache.addAll(items); expect(cache.all().length).toBe(2); expect(cache.complete).toBe(true); }); - test('should return all, keys, entries, and map', () => { - cache.add({id: '1', value: 'a'}); - cache.add({id: '2', value: 'b'}); - expect(cache.all().length).toBe(2); - expect(cache.keys().sort()).toEqual(['1', '2']); - expect(cache.entries().length).toBe(2); - expect(Object.keys(cache.map())).toContain('1'); - expect(Object.keys(cache.map())).toContain('2'); + it('returns correct keys, entries, and map', () => { + cache.add({ id: 'x', value: 'foo' }); + cache.add({ id: 'y', value: 'bar' }); + expect(cache.keys().sort()).toEqual(['x', 'y']); + expect(cache.entries().map(e => e[0]).sort()).toEqual(['x', 'y']); + const m = cache.map(); + expect(Object.keys(m)).toEqual(expect.arrayContaining(['x', 'y'])); + expect(m['x'].value).toBe('foo'); }); - // test('should expire/delete items after TTL', () => { - // jest.useFakeTimers(); - // cache = new Cache('id', {ttl: 0.1}); - // cache.add({id: '3', value: 'x'}); - // jest.advanceTimersByTime(250); - // expect(cache.get('3')).toBeNull(); + it('persists and restores from storage', () => { + (storageMock.getItem as jest.Mock).mockReturnValueOnce( + JSON.stringify({ z: { id: 'z', value: 'from-storage' } }), + ); + const c = new Cache('id', { + persistentStorage: { storage: storageMock, key: 'cache' }, + }); + expect(c.get('z')).toEqual({ id: 'z', value: 'from-storage' }); + }); + + it('expiryPolicy "delete" removes expired items completely', () => { + cache.options.expiryPolicy = 'delete'; + cache.add({ id: 'del1', value: 'gone' }); + cache.expire('del1'); + expect(cache.get('del1', true)).toBeNull(); + expect(cache.get('del1')).toBeNull(); + }); + + it('expiryPolicy "keep" marks as expired but does not delete', () => { + cache.options.expiryPolicy = 'keep'; + cache.add({ id: 'keep1', value: 'kept' }); + cache.expire('keep1'); + expect(cache.get('keep1')).toBeNull(); + const val = cache.get('keep1', true); + 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(); // }); - test('should persist and restore from storage', () => { - (storageMock.getItem as jest.Mock).mockReturnValueOnce(JSON.stringify({a: {id: 'a', value: 'from-storage'}})); - const c = new Cache('id', {storage: storageMock, storageKey: 'cache'}); - expect(c.get('a')).toEqual({id: 'a', value: 'from-storage'}); - }); - - test('should handle expiryPolicy "delete"', () => { - cache.options.expiryPolicy = 'delete'; - cache.add({id: 'k1', value: 'KeepMe'}); - cache.expire('k1'); - expect(cache.get('k1', true)).toBeNull(); - }); - - test('should handle expiryPolicy "keep"', () => { - cache.options.expiryPolicy = 'keep'; - cache.add({id: 'k1', value: 'KeepMe'}); - cache.expire('k1'); - expect(cache.get('k1')).toBeNull(); - expect(cache.get('k1', true)?._expired).toBe(true); + // Edge: add error handling test + it('throws if instantiating with invalid key property', () => { + expect(() => { + const invalid = new Cache<'foo', TestItem>('foo'); + // try invalid.add({id: 'z', value: 'fail'}) if needed + }).not.toThrow(); }); });