diff --git a/package.json b/package.json index f05b7bb..939bc2a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ztimson/utils", - "version": "0.24.7", + "version": "0.24.8", "description": "Utility library", "author": "Zak Timson", "license": "MIT", diff --git a/src/cache.ts b/src/cache.ts index 743d599..062a873 100644 --- a/src/cache.ts +++ b/src/cache.ts @@ -7,16 +7,20 @@ export type CacheOptions = { storage?: Storage; /** Key cache will be stored under */ storageKey?: string; + /** Keep or delete cached items once expired, defaults to delete */ + expiryPolicy?: 'delete' | 'keep'; } +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 = >{}; + private store: Record = {}; /** Support index lookups */ - [key: string | number | symbol]: T | any; + [key: string | number | symbol]: CachedValue | any; /** Whether cache is complete */ complete = false; @@ -38,7 +42,7 @@ export class Cache { return new Proxy(this, { get: (target: this, prop: string | symbol) => { if(prop in target) return (target as any)[prop]; - return deepCopy(target.store[prop as K]); + return this.get(prop as K, true); }, set: (target: this, prop: string | symbol, value: any) => { if(prop in target) (target as any)[prop] = value; @@ -57,8 +61,9 @@ export class Cache { * Get all cached items * @return {T[]} Array of items */ - all(): T[] { - return deepCopy(Object.values(this.store)); + all(expired?: boolean): CachedValue[] { + return deepCopy(Object.values(this.store) + .filter((v: any) => expired || !v._expired)); } /** @@ -90,7 +95,8 @@ export class Cache { * Remove all keys from cache */ clear() { - this.store = >{}; + this.complete = false; + this.store = {}; } /** @@ -107,8 +113,9 @@ export class Cache { * Return cache as an array of key-value pairs * @return {[K, T][]} Key-value pairs array */ - entries(): [K, T][] { - return <[K, T][]>Object.entries(this.store); + entries(expired?: boolean): [K, CachedValue][] { + return deepCopy(Object.entries(this.store) + .filter((v: any) => expired || !v._expired)); } /** @@ -116,24 +123,31 @@ export class Cache { * @param {K} key Key to lookup * @return {T} Cached item */ - get(key: K): T { - return deepCopy(this.store[key]); + get(key: K, expired?: boolean): T | null { + const cached = deepCopy(this.store[key] ?? null); + if(expired || !cached._expired) return cached; + return null; } /** * Get a list of cached keys * @return {K[]} Array of keys */ - keys(): K[] { - return Object.keys(this.store); + keys(expired?: boolean): K[] { + return Object.keys(this.store) + .filter(k => expired || !(this.store)[k]._expired); } /** * Get map of cached items * @return {Record} */ - map(): Record { - return deepCopy(this.store); + map(expired?: boolean): Record> { + const copy: any = deepCopy(this.store); + if(!expired) Object.keys(copy).forEach(k => { + if(copy[k]._expired) delete copy[k] + }); + return copy; } /** @@ -144,12 +158,16 @@ export class Cache { * @return {this} */ set(key: K, value: T, ttl = this.options.ttl): this { + if(this.options.expiryPolicy == 'keep') delete (this.store[key])._expired; this.store[key] = value; if(this.options.storageKey && this.options.storage) this.options.storage.setItem(this.options.storageKey, JSON.stringify(this.store)); if(ttl) setTimeout(() => { this.complete = false; - this.delete(key); + if(this.options.expiryPolicy == 'keep') (this.store[key])._expired = true; + else this.delete(key); + if(this.options.storageKey && this.options.storage) + this.options.storage.setItem(this.options.storageKey, JSON.stringify(this.store)); }, ttl * 1000); return this; } diff --git a/src/path-events.ts b/src/path-events.ts index e973d35..94f4180 100644 --- a/src/path-events.ts +++ b/src/path-events.ts @@ -223,7 +223,7 @@ export class PathEvent { } /** - * Create event string from its components + * Create event string from its components * * @return {string} String representation of Event */ @@ -235,11 +235,12 @@ export class PathEvent { export type PathListener = (event: PathEvent, ...args: any[]) => any; export type PathUnsubscribe = () => void; +export type Event = string | PathEvent; export interface IPathEventEmitter { - emit(event: string, ...args: any[]): void; + emit(event: Event, ...args: any[]): void; off(listener: PathListener): void; - on(event: string, listener: PathListener): PathUnsubscribe; - once(event: string, listener?: PathListener): Promise; + on(event: Event | Event[], listener: PathListener): PathUnsubscribe; + once(event: Event | Event[], listener?: PathListener): Promise; relayEvents(emitter: PathEventEmitter): void; } @@ -249,8 +250,10 @@ export interface IPathEventEmitter { export class PathEventEmitter implements IPathEventEmitter{ private listeners: [PathEvent, PathListener][] = []; - emit(event: string | PathEvent, ...args: any[]) { - const parsed = new PathEvent(event); + constructor(public readonly prefix: string = '') { } + + emit(event: Event, ...args: any[]) { + const parsed = new PathEvent(`${this.prefix}/${typeof event == 'string' ? event : event.toString()}`); this.listeners.filter(l => PathEvent.has(l[0], event)) .forEach(async l => l[1](parsed, ...args)); }; @@ -259,12 +262,15 @@ export class PathEventEmitter implements IPathEventEmitter{ this.listeners = this.listeners.filter(l => l[1] != listener); } - on(event: string | string[], listener: PathListener): PathUnsubscribe { - makeArray(event).forEach(e => this.listeners.push([new PathEvent(e), listener])); + on(event: Event | Event[], listener: PathListener): PathUnsubscribe { + makeArray(event).forEach(e => this.listeners.push([ + new PathEvent(`${this.prefix}/${typeof e == 'string' ? event : event.toString()}`), + listener + ])); return () => this.off(listener); } - once(event: string | string[], listener?: PathListener): Promise { + once(event: Event | Event[], listener?: PathListener): Promise { return new Promise(res => { const unsubscribe = this.on(event, (event: PathEvent, ...args: any[]) => { res(args.length < 2 ? args[0] : args); diff --git a/tests/path-events.spec.ts b/tests/path-events.spec.ts new file mode 100644 index 0000000..03af2b9 --- /dev/null +++ b/tests/path-events.spec.ts @@ -0,0 +1,43 @@ +import {PathEvent} from '../src'; + +describe('Path Events', () => { + describe('malformed', () => { + test('starting slash', async () => + expect(new PathEvent('/module').toString()).toEqual('module:*')); + test('trailing slash', async () => + expect(new PathEvent('module/').toString()).toEqual('module:*')); + test('double slash', async () => + expect(new PathEvent('module////path').toString()).toEqual('module/path:*')); + }); + + describe('methods', () => { + test('custom', async () => { + expect(new PathEvent('module:t').methods.includes('t')).toBeTruthy(); + expect(new PathEvent('module:t').methods.includes('z')).toBeFalsy(); + }); + test('create', async () => + expect(new PathEvent('module:crud').create).toBeTruthy()); + test('read', async () => + expect(new PathEvent('module:crud').read).toBeTruthy()); + test('update', async () => + expect(new PathEvent('module:crud').update).toBeTruthy()); + test('delete', async () => + expect(new PathEvent('module:crud').delete).toBeTruthy()); + test('none', async () => { + const event = new PathEvent('module:n'); + expect(event.none).toBeTruthy(); + expect(event.create).toBeFalsy(); + expect(event.read).toBeFalsy(); + expect(event.update).toBeFalsy(); + expect(event.delete).toBeFalsy() + }); + test('wildcard', async () => { + const event = new PathEvent('module:*'); + expect(event.none).toBeFalsy(); + expect(event.create).toBeTruthy(); + expect(event.read).toBeTruthy(); + expect(event.update).toBeTruthy(); + expect(event.delete).toBeTruthy() + }); + }); +});