Compare commits

...

7 Commits

Author SHA1 Message Date
76b570b3fe * Fixed cache expire checks on uncached
All checks were successful
Build / Build NPM Project (push) Successful in 39s
Build / Tag Version (push) Successful in 8s
Build / Publish Documentation (push) Successful in 35s
2025-05-13 11:10:23 -04:00
4fecf10d11 * Fixed cache value type
All checks were successful
Build / Build NPM Project (push) Successful in 1m16s
Build / Tag Version (push) Successful in 14s
Build / Publish Documentation (push) Successful in 54s
2025-05-13 10:53:17 -04:00
028b9c0f4c + Caching manually expire
All checks were successful
Build / Build NPM Project (push) Successful in 41s
Build / Tag Version (push) Successful in 8s
Build / Publish Documentation (push) Successful in 36s
2025-05-12 20:29:29 -04:00
d938996a66 + Caching expiry strategies
Some checks failed
Build / Build NPM Project (push) Failing after 34s
Build / Publish Documentation (push) Has been skipped
Build / Tag Version (push) Has been skipped
+ Prefix PathEvents
2025-05-12 19:46:23 -04:00
cdcaeda67c + Added writable
All checks were successful
Build / Build NPM Project (push) Successful in 40s
Build / Tag Version (push) Successful in 8s
Build / Publish Documentation (push) Successful in 35s
2025-05-12 18:00:12 -04:00
482c90b53b + Added writable
All checks were successful
Build / Build NPM Project (push) Successful in 1m13s
Build / Tag Version (push) Successful in 15s
Build / Publish Documentation (push) Successful in 49s
2025-05-12 16:28:10 -04:00
7500ba502f + Added clear function to ASet
All checks were successful
Build / Build NPM Project (push) Successful in 44s
Build / Tag Version (push) Successful in 8s
Build / Publish Documentation (push) Successful in 37s
* fixed pathedEvent fullPath casing
2025-05-11 14:13:01 -04:00
6 changed files with 129 additions and 37 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "@ztimson/utils", "name": "@ztimson/utils",
"version": "0.24.4", "version": "0.24.11",
"description": "Utility library", "description": "Utility library",
"author": "Zak Timson", "author": "Zak Timson",
"license": "MIT", "license": "MIT",

View File

@ -29,6 +29,14 @@ export class ASet<T> extends Array {
return this; return this;
} }
/**
* Remove all elements
*/
clear() {
this.splice(0, this.length);
return this;
}
/** /**
* Delete elements from set * Delete elements from set
* @param items Elements that will be deleted * @param items Elements that will be deleted

View File

@ -7,16 +7,20 @@ export type CacheOptions = {
storage?: Storage; storage?: Storage;
/** Key cache will be stored under */ /** Key cache will be stored under */
storageKey?: string; storageKey?: string;
/** Keep or delete cached items once expired, defaults to delete */
expiryPolicy?: 'delete' | 'keep';
} }
export type CachedValue<T> = T & {_expired?: boolean};
/** /**
* Map of data which tracks whether it is a complete collection & offers optional expiry of cached values * Map of data which tracks whether it is a complete collection & offers optional expiry of cached values
*/ */
export class Cache<K extends string | number | symbol, T> { export class Cache<K extends string | number | symbol, T> {
private store = <Record<K, T>>{}; private store: Record<K, T> = <any>{};
/** Support index lookups */ /** Support index lookups */
[key: string | number | symbol]: T | any; [key: string | number | symbol]: CachedValue<T> | any;
/** Whether cache is complete */ /** Whether cache is complete */
complete = false; complete = false;
@ -38,7 +42,7 @@ export class Cache<K extends string | number | symbol, T> {
return new Proxy(this, { return new Proxy(this, {
get: (target: this, prop: string | symbol) => { get: (target: this, prop: string | symbol) => {
if(prop in target) return (target as any)[prop]; 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) => { set: (target: this, prop: string | symbol, value: any) => {
if(prop in target) (target as any)[prop] = value; if(prop in target) (target as any)[prop] = value;
@ -53,12 +57,18 @@ export class Cache<K extends string | number | symbol, T> {
return <K>value[this.key]; return <K>value[this.key];
} }
private save() {
if(this.options.storageKey && this.options.storage)
this.options.storage.setItem(this.options.storageKey, JSON.stringify(this.store));
}
/** /**
* Get all cached items * Get all cached items
* @return {T[]} Array of items * @return {T[]} Array of items
*/ */
all(): T[] { all(expired?: boolean): CachedValue<T>[] {
return deepCopy(Object.values(this.store)); return deepCopy<any>(Object.values(this.store)
.filter((v: any) => expired || !v._expired));
} }
/** /**
@ -90,7 +100,8 @@ export class Cache<K extends string | number | symbol, T> {
* Remove all keys from cache * Remove all keys from cache
*/ */
clear() { clear() {
this.store = <Record<K, T>>{}; this.complete = false;
this.store = <any>{};
} }
/** /**
@ -99,16 +110,26 @@ export class Cache<K extends string | number | symbol, T> {
*/ */
delete(key: K) { delete(key: K) {
delete this.store[key]; delete this.store[key];
if(this.options.storageKey && this.options.storage) this.save();
this.options.storage.setItem(this.options.storageKey, JSON.stringify(this.store));
} }
/** /**
* Return cache as an array of key-value pairs * Return cache as an array of key-value pairs
* @return {[K, T][]} Key-value pairs array * @return {[K, T][]} Key-value pairs array
*/ */
entries(): [K, T][] { entries(expired?: boolean): [K, CachedValue<T>][] {
return <[K, T][]>Object.entries(this.store); return deepCopy<any>(Object.entries(this.store)
.filter((v: any) => expired || !v?._expired));
}
/**
* Manually expire a cached item
* @param {K} key Key to expire
*/
expire(key: K) {
this.complete = false;
if(this.options.expiryPolicy == 'keep') (<any>this.store[key])._expired = true;
else this.delete(key);
} }
/** /**
@ -116,24 +137,31 @@ export class Cache<K extends string | number | symbol, T> {
* @param {K} key Key to lookup * @param {K} key Key to lookup
* @return {T} Cached item * @return {T} Cached item
*/ */
get(key: K): T { get(key: K, expired?: boolean): T | null {
return deepCopy(this.store[key]); const cached = deepCopy<any>(this.store[key] ?? null);
if(expired || !cached?._expired) return cached;
return null;
} }
/** /**
* Get a list of cached keys * Get a list of cached keys
* @return {K[]} Array of keys * @return {K[]} Array of keys
*/ */
keys(): K[] { keys(expired?: boolean): K[] {
return <K[]>Object.keys(this.store); return <K[]>Object.keys(this.store)
.filter(k => expired || !(<any>this.store)[k]._expired);
} }
/** /**
* Get map of cached items * Get map of cached items
* @return {Record<K, T>} * @return {Record<K, T>}
*/ */
map(): Record<K, T> { map(expired?: boolean): Record<K, CachedValue<T>> {
return deepCopy(this.store); const copy: any = deepCopy(this.store);
if(!expired) Object.keys(copy).forEach(k => {
if(copy[k]._expired) delete copy[k]
});
return copy;
} }
/** /**
@ -144,12 +172,12 @@ export class Cache<K extends string | number | symbol, T> {
* @return {this} * @return {this}
*/ */
set(key: K, value: T, ttl = this.options.ttl): this { set(key: K, value: T, ttl = this.options.ttl): this {
if(this.options.expiryPolicy == 'keep') delete (<any>this.store[key])._expired;
this.store[key] = value; this.store[key] = value;
if(this.options.storageKey && this.options.storage) this.save();
this.options.storage.setItem(this.options.storageKey, JSON.stringify(this.store));
if(ttl) setTimeout(() => { if(ttl) setTimeout(() => {
this.complete = false; this.expire(key);
this.delete(key); this.save();
}, ttl * 1000); }, ttl * 1000);
return this; return this;
} }

View File

@ -90,9 +90,9 @@ export class PathEvent {
get delete(): boolean { return !this.methods.has('n') && (this.methods.has('*') || this.methods.has('d')) } get delete(): boolean { return !this.methods.has('n') && (this.methods.has('*') || this.methods.has('d')) }
set delete(v: boolean) { v ? this.methods.delete('n').delete('*').add('d') : this.methods.delete('d'); } set delete(v: boolean) { v ? this.methods.delete('n').delete('*').add('d') : this.methods.delete('d'); }
constructor(Event: string | PathEvent) { constructor(e: string | PathEvent) {
if(typeof Event == 'object') return Object.assign(this, Event); if(typeof e == 'object') return Object.assign(this, e);
let [p, scope, method] = Event.replaceAll(/\/{2,}/g, '/').split(':'); let [p, scope, method] = e.replaceAll(/\/{2,}/g, '/').split(':');
if(!method) method = scope || '*'; if(!method) method = scope || '*';
if(p == '*' || !p && method == '*') { if(p == '*' || !p && method == '*') {
p = ''; p = '';
@ -100,8 +100,8 @@ export class PathEvent {
} }
let temp = p.split('/').filter(p => !!p); let temp = p.split('/').filter(p => !!p);
this.module = temp.splice(0, 1)[0]?.toLowerCase() || ''; this.module = temp.splice(0, 1)[0]?.toLowerCase() || '';
this.fullPath = p;
this.path = temp.join('/'); this.path = temp.join('/');
this.fullPath = `${this.module}${this.module && this.path ? '/' : ''}${this.path}`;
this.name = temp.pop() || ''; this.name = temp.pop() || '';
this.methods = new ASet(<any>method.split('')); this.methods = new ASet(<any>method.split(''));
} }
@ -206,7 +206,7 @@ export class PathEvent {
* @return {string} String representation of Event * @return {string} String representation of Event
*/ */
static toString(path: string | string[], methods: Method | Method[]): string { static toString(path: string | string[], methods: Method | Method[]): string {
let p = makeArray(path).filter(p => p != null).join('/'); let p = makeArray(path).filter(p => !!p).join('/');
p = p?.trim().replaceAll(/\/{2,}/g, '/').replaceAll(/(^\/|\/$)/g, ''); p = p?.trim().replaceAll(/\/{2,}/g, '/').replaceAll(/(^\/|\/$)/g, '');
if(methods?.length) p += `:${makeArray(methods).map(m => m.toLowerCase()).join('')}`; if(methods?.length) p += `:${makeArray(methods).map(m => m.toLowerCase()).join('')}`;
return p; return p;
@ -235,11 +235,12 @@ export class PathEvent {
export type PathListener = (event: PathEvent, ...args: any[]) => any; export type PathListener = (event: PathEvent, ...args: any[]) => any;
export type PathUnsubscribe = () => void; export type PathUnsubscribe = () => void;
export type Event = string | PathEvent;
export interface IPathEventEmitter { export interface IPathEventEmitter {
emit(event: string, ...args: any[]): void; emit(event: Event, ...args: any[]): void;
off(listener: PathListener): void; off(listener: PathListener): void;
on(event: string, listener: PathListener): PathUnsubscribe; on(event: Event | Event[], listener: PathListener): PathUnsubscribe;
once(event: string, listener?: PathListener): Promise<any>; once(event: Event | Event[], listener?: PathListener): Promise<any>;
relayEvents(emitter: PathEventEmitter): void; relayEvents(emitter: PathEventEmitter): void;
} }
@ -249,8 +250,10 @@ export interface IPathEventEmitter {
export class PathEventEmitter implements IPathEventEmitter{ export class PathEventEmitter implements IPathEventEmitter{
private listeners: [PathEvent, PathListener][] = []; private listeners: [PathEvent, PathListener][] = [];
emit(event: string | PathEvent, ...args: any[]) { constructor(public readonly prefix: string = '') { }
const parsed = new PathEvent(event);
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)) this.listeners.filter(l => PathEvent.has(l[0], event))
.forEach(async l => l[1](parsed, ...args)); .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); this.listeners = this.listeners.filter(l => l[1] != listener);
} }
on(event: string | string[], listener: PathListener): PathUnsubscribe { on(event: Event | Event[], listener: PathListener): PathUnsubscribe {
makeArray(event).forEach(e => this.listeners.push([new PathEvent(e), listener])); makeArray(event).forEach(e => this.listeners.push([
new PathEvent(`${this.prefix}/${typeof e == 'string' ? event : event.toString()}`),
listener
]));
return () => this.off(listener); return () => this.off(listener);
} }
once(event: string | string[], listener?: PathListener): Promise<any> { once(event: Event | Event[], listener?: PathListener): Promise<any> {
return new Promise(res => { return new Promise(res => {
const unsubscribe = this.on(event, (event: PathEvent, ...args: any[]) => { const unsubscribe = this.on(event, (event: PathEvent, ...args: any[]) => {
res(args.length < 2 ? args[0] : args); res(args.length < 2 ? args[0] : args);

View File

@ -18,3 +18,10 @@
export function typeKeys<T extends object>() { export function typeKeys<T extends object>() {
return Object.keys(<T>{}) as Array<keyof T>; return Object.keys(<T>{}) as Array<keyof T>;
} }
/**
* Mark all properties as writable
*/
export type Writable<T> = {
-readonly [P in keyof T]: T[P]
};

43
tests/path-events.spec.ts Normal file
View File

@ -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()
});
});
});