Compare commits
	
		
			15 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 057528b0c5 | |||
| 1595aea529 | |||
| 08503de552 | |||
| a9a9b04a5a | |||
| a7b19900da | |||
| 34227e5c4b | |||
| 7e8352ed2a | |||
| 751177c981 | |||
| 5849a97c21 | |||
| fcae9e107e | |||
| 5bb5067abc | |||
| 2064880673 | |||
| fdefdf354d | |||
| 31998c01d6 | |||
| c5270fbd7e | 
@@ -13,3 +13,7 @@ quote_type = single
 | 
			
		||||
[*.md]
 | 
			
		||||
max_line_length = off
 | 
			
		||||
trim_trailing_whitespace = false
 | 
			
		||||
 | 
			
		||||
[*.{yaml,yml}]
 | 
			
		||||
indent_style = space
 | 
			
		||||
indent_size = 2
 | 
			
		||||
							
								
								
									
										48
									
								
								.github/workflows/build.yaml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										48
									
								
								.github/workflows/build.yaml
									
									
									
									
										vendored
									
									
								
							@@ -48,13 +48,43 @@ jobs:
 | 
			
		||||
        uses: ztimson/actions/tag@develop
 | 
			
		||||
        with:
 | 
			
		||||
          tag: ${{env.VERSION}}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
  docs:
 | 
			
		||||
    name: Publish Documentation
 | 
			
		||||
    needs: build
 | 
			
		||||
    uses: ztimson/actions/.github/workflows/docker.yaml@develop
 | 
			
		||||
    with:
 | 
			
		||||
        name: ztimson/utils
 | 
			
		||||
        repository: ${{github.server_url}}/${{github.repository}}.git
 | 
			
		||||
        pass: ${{secrets.DEPLOY_TOKEN}}
 | 
			
		||||
    name: Publish Docs
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    container: docker:dind
 | 
			
		||||
    steps:
 | 
			
		||||
      - name: Clone Repository
 | 
			
		||||
        uses: ztimson/actions/clone@develop
 | 
			
		||||
 | 
			
		||||
      - name: Build Image
 | 
			
		||||
        run: |
 | 
			
		||||
          REGISTRY=$(echo ${{github.server_url}} | sed s%http://%% | sed s%https://%%)
 | 
			
		||||
          docker build -t "$REGISTRY/${{github.repository}}:${{github.ref_name}}" .
 | 
			
		||||
 | 
			
		||||
      - name: Build Image
 | 
			
		||||
        run: |
 | 
			
		||||
          echo "CHECKSUM: ${{env.CHECKSUM}}"
 | 
			
		||||
          REGISTRY=$(echo ${{github.server_url}} | sed s%http://%% | sed s%https://%%)
 | 
			
		||||
          docker build -t "$REGISTRY/${{github.repository}}:${{github.ref_name}}" --build-arg LICENSE_KEY="${{secrets.LICENSE_KEY}}" --build-arg UPDATE_TOKEN="${{secrets.UPDATE_TOKEN}}" .
 | 
			
		||||
 | 
			
		||||
      - name: Publish Branch Tag
 | 
			
		||||
        run: |
 | 
			
		||||
          REGISTRY=$(echo ${{github.server_url}} | sed s%http://%% | sed s%https://%%)
 | 
			
		||||
          docker login -u ${{github.repository_owner}} -p ${{secrets.DEPLOY_TOKEN}} $REGISTRY
 | 
			
		||||
          docker push "$REGISTRY/${{github.repository}}:${{github.ref_name}}"
 | 
			
		||||
 | 
			
		||||
      - name: Publish Version Tag
 | 
			
		||||
        run: |
 | 
			
		||||
          if [ "${{github.ref_name}}" = "master" ]; then
 | 
			
		||||
            REGISTRY=$(echo ${{github.server_url}} | sed s%http://%% | sed s%https://%%)
 | 
			
		||||
            docker tag "$REGISTRY/${{github.repository}}:${{github.ref_name}}" "$REGISTRY/${{github.repository}}:${{env.VERSION}}"
 | 
			
		||||
            docker push "$REGISTRY/${{github.repository}}:${{env.VERSION}}"
 | 
			
		||||
          fi
 | 
			
		||||
 | 
			
		||||
      - name: Publish Latest Tag
 | 
			
		||||
        run: |
 | 
			
		||||
          if [ "${{github.ref_name}}" = "master" ]; then
 | 
			
		||||
            REGISTRY=$(echo ${{github.server_url}} | sed s%http://%% | sed s%https://%%)
 | 
			
		||||
            docker tag "$REGISTRY/${{github.repository}}:${{github.ref_name}}" "$REGISTRY/${{github.repository}}:latest"
 | 
			
		||||
            docker push "$REGISTRY/${{github.repository}}:latest"
 | 
			
		||||
          fi
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										99
									
								
								index.html
									
									
									
									
									
								
							
							
						
						
									
										99
									
								
								index.html
									
									
									
									
									
								
							@@ -1,9 +1,102 @@
 | 
			
		||||
<!DOCTYPE html>
 | 
			
		||||
<html>
 | 
			
		||||
	<head>
 | 
			
		||||
		<title>Console Styling Playground</title>
 | 
			
		||||
	</head>
 | 
			
		||||
	<body>
 | 
			
		||||
		<script type="module">
 | 
			
		||||
			import {PE} from './dist/index.mjs';
 | 
			
		||||
		<h1>Open Chrome DevTools Console to see magic! (Ctrl+Shift+J or Cmd+Option+J)</h1>
 | 
			
		||||
 | 
			
		||||
			console.log('data/Ts:n', PE`${'data/Ts'}:n`.methods);
 | 
			
		||||
		<script>
 | 
			
		||||
			// Console Styling Playground
 | 
			
		||||
			class ConsoleStyler {
 | 
			
		||||
				// Basic badge with customizable colors
 | 
			
		||||
				badge(text, bgColor = '#007bff', textColor = 'white') {
 | 
			
		||||
					console.log(
 | 
			
		||||
						`%c ${text} `,
 | 
			
		||||
						`background-color: ${bgColor};
 | 
			
		||||
                 color: ${textColor};
 | 
			
		||||
                 border-radius: 12px;
 | 
			
		||||
                 padding: 2px 8px;
 | 
			
		||||
                 font-weight: bold;`
 | 
			
		||||
					);
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				// Multi-style log with different sections
 | 
			
		||||
				richLog() {
 | 
			
		||||
					console.log(
 | 
			
		||||
						'%cSystem%c Operation %cDetails',
 | 
			
		||||
						'background-color: #f0f0f0; color: black; padding: 2px 5px; border-radius: 3px;',
 | 
			
		||||
						'color: blue; font-weight: bold;',
 | 
			
		||||
						'color: green;'
 | 
			
		||||
					);
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				// Grouped logs with nested information
 | 
			
		||||
				groupedLog(title, items) {
 | 
			
		||||
					console.group(title);
 | 
			
		||||
					items.forEach(item => {
 | 
			
		||||
						console.log(item);
 | 
			
		||||
					});
 | 
			
		||||
					console.groupEnd();
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				// Table view for structured data
 | 
			
		||||
				tableLog(data) {
 | 
			
		||||
					console.table(data);
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				// Performance tracking
 | 
			
		||||
				timeTrack(label, operation) {
 | 
			
		||||
					console.time(label);
 | 
			
		||||
					operation();
 | 
			
		||||
					console.timeEnd(label);
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				// Demonstration method
 | 
			
		||||
				demo() {
 | 
			
		||||
					// Different styled badges
 | 
			
		||||
					this.badge('NEW', '#28a745');  // Green
 | 
			
		||||
					this.badge('WARNING', '#ffc107', 'black');  // Yellow
 | 
			
		||||
					this.badge('ERROR', '#dc3545');  // Red
 | 
			
		||||
					this.badge('CUSTOM', '#6f42c1');  // Purple
 | 
			
		||||
 | 
			
		||||
					// Rich multi-style log
 | 
			
		||||
					this.richLog();
 | 
			
		||||
 | 
			
		||||
					// Grouped logs
 | 
			
		||||
					this.groupedLog('User Details', [
 | 
			
		||||
						{ name: 'John Doe', age: 30, role: 'Admin' },
 | 
			
		||||
						{ name: 'Jane Smith', age: 25, role: 'User' }
 | 
			
		||||
					]);
 | 
			
		||||
 | 
			
		||||
					// Table logging
 | 
			
		||||
					this.tableLog([
 | 
			
		||||
						{ name: 'John', age: 30, active: true },
 | 
			
		||||
						{ name: 'Jane', age: 25, active: false }
 | 
			
		||||
					]);
 | 
			
		||||
 | 
			
		||||
					// Performance tracking
 | 
			
		||||
					this.timeTrack('Complex Operation', () => {
 | 
			
		||||
						let sum = 0;
 | 
			
		||||
						for(let i = 0; i < 1000000; i++) {
 | 
			
		||||
							sum += i;
 | 
			
		||||
						}
 | 
			
		||||
						console.log('Sum:', sum);
 | 
			
		||||
					});
 | 
			
		||||
 | 
			
		||||
					// Advanced: Conditional styling
 | 
			
		||||
					const logLevel = 'warn';
 | 
			
		||||
					console.log(
 | 
			
		||||
						`%c[${logLevel.toUpperCase()}]%c Detailed message`,
 | 
			
		||||
						`color: ${logLevel === 'warn' ? 'orange' : 'red'}; font-weight: bold;`,
 | 
			
		||||
						'color: black;'
 | 
			
		||||
					);
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			// Create instance and run demo
 | 
			
		||||
			const styler = new ConsoleStyler();
 | 
			
		||||
			styler.demo();
 | 
			
		||||
		</script>
 | 
			
		||||
	</body>
 | 
			
		||||
</html>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,6 @@
 | 
			
		||||
{
 | 
			
		||||
	"name": "@ztimson/utils",
 | 
			
		||||
	"version": "0.26.26",
 | 
			
		||||
	"version": "0.27.9",
 | 
			
		||||
	"description": "Utility library",
 | 
			
		||||
	"author": "Zak Timson",
 | 
			
		||||
	"license": "MIT",
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										125
									
								
								src/cache.ts
									
									
									
									
									
								
							
							
						
						
									
										125
									
								
								src/cache.ts
									
									
									
									
									
								
							@@ -1,5 +1,6 @@
 | 
			
		||||
import {Database, Table} from './database.ts';
 | 
			
		||||
import {deepCopy, includes, JSONSanitize} from './objects.ts';
 | 
			
		||||
import {JSONSanitize} from './json.ts';
 | 
			
		||||
import {deepCopy, includes} from './objects.ts';
 | 
			
		||||
 | 
			
		||||
export type CacheOptions = {
 | 
			
		||||
	/** Delete keys automatically after x amount of seconds */
 | 
			
		||||
@@ -8,6 +9,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> = T & {_expired?: boolean};
 | 
			
		||||
@@ -16,14 +19,15 @@ export type CachedValue<T> = T & {_expired?: boolean};
 | 
			
		||||
 * 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> {
 | 
			
		||||
	private store: Record<K, T> = <any>{};
 | 
			
		||||
	private _loading!: Function;
 | 
			
		||||
	private store: Map<K, T> = new Map();
 | 
			
		||||
	private timers: Map<K, NodeJS.Timeout> = new Map();
 | 
			
		||||
	private lruOrder: K[] = [];
 | 
			
		||||
 | 
			
		||||
	/** Support index lookups */
 | 
			
		||||
	[key: string | number | symbol]: CachedValue<T> | any;
 | 
			
		||||
	/** Whether cache is complete */
 | 
			
		||||
	complete = false;
 | 
			
		||||
 | 
			
		||||
	private _loading!: Function;
 | 
			
		||||
	/** Await initial loading */
 | 
			
		||||
	loading = new Promise<void>(r => this._loading = r);
 | 
			
		||||
 | 
			
		||||
@@ -43,12 +47,18 @@ export class Cache<K extends string | number | symbol, T> {
 | 
			
		||||
					const persists: any = this.options.persistentStorage;
 | 
			
		||||
					const table: Table<any, any> = 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((<any>this.options.persistentStorage?.storage)?.getItem != undefined) {
 | 
			
		||||
				const stored = (<Storage>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(<any>k, obj[k]);
 | 
			
		||||
					} catch { /* ignore */ }
 | 
			
		||||
				}
 | 
			
		||||
				this._loading();
 | 
			
		||||
			}
 | 
			
		||||
		} else {
 | 
			
		||||
@@ -71,8 +81,8 @@ export class Cache<K extends string | number | symbol, T> {
 | 
			
		||||
 | 
			
		||||
	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 <K>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 <K>(value as any)[this.key];
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	private save(key?: K) {
 | 
			
		||||
@@ -80,28 +90,50 @@ export class Cache<K extends string | number | symbol, T> {
 | 
			
		||||
		if(!!persists?.storage) {
 | 
			
		||||
			if(persists.storage?.database != undefined) {
 | 
			
		||||
				(<Database>persists.storage).createTable({name: persists.key, key: <string>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<any, any> = {};
 | 
			
		||||
				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<T>[] {
 | 
			
		||||
		return deepCopy<any>(Object.values(this.store)
 | 
			
		||||
			.filter((v: any) => expired || !v._expired));
 | 
			
		||||
		const out: CachedValue<T>[] = [];
 | 
			
		||||
		for(const v of this.store.values()) {
 | 
			
		||||
			const val: any = v;
 | 
			
		||||
			if(expired || !val?._expired) out.push(deepCopy<any>(val));
 | 
			
		||||
		}
 | 
			
		||||
		return out;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	/**
 | 
			
		||||
@@ -134,7 +166,10 @@ export class Cache<K extends string | number | symbol, T> {
 | 
			
		||||
	 */
 | 
			
		||||
	clear(): this {
 | 
			
		||||
		this.complete = false;
 | 
			
		||||
		this.store = <any>{};
 | 
			
		||||
		for (const [k, t] of this.timers) clearTimeout(t);
 | 
			
		||||
		this.timers.clear();
 | 
			
		||||
		this.lruOrder = [];
 | 
			
		||||
		this.store.clear();
 | 
			
		||||
		this.save();
 | 
			
		||||
		return this;
 | 
			
		||||
	}
 | 
			
		||||
@@ -144,7 +179,10 @@ export class Cache<K extends string | number | symbol, T> {
 | 
			
		||||
	 * @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 +192,12 @@ export class Cache<K extends string | number | symbol, T> {
 | 
			
		||||
	 * @return {[K, T][]} Key-value pairs array
 | 
			
		||||
	 */
 | 
			
		||||
	entries(expired?: boolean): [K, CachedValue<T>][] {
 | 
			
		||||
		return deepCopy<any>(Object.entries(this.store)
 | 
			
		||||
			.filter((v: any) => expired || !v?._expired));
 | 
			
		||||
		const out: [K, CachedValue<T>][] = [];
 | 
			
		||||
		for(const [k, v] of this.store.entries()) {
 | 
			
		||||
			const val: any = v;
 | 
			
		||||
			if(expired || !val?._expired) out.push([k, deepCopy<any>(val)]);
 | 
			
		||||
		}
 | 
			
		||||
		return out;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	/**
 | 
			
		||||
@@ -165,8 +207,12 @@ export class Cache<K extends string | number | symbol, T> {
 | 
			
		||||
	expire(key: K): this {
 | 
			
		||||
		this.complete = false;
 | 
			
		||||
		if(this.options.expiryPolicy == 'keep') {
 | 
			
		||||
			(<any>this.store[key])._expired = true;
 | 
			
		||||
			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 +224,11 @@ export class Cache<K extends string | number | symbol, T> {
 | 
			
		||||
	 * @returns {T | undefined} Cached item or undefined if nothing matched
 | 
			
		||||
	 */
 | 
			
		||||
	find(filter: Partial<T>, expired?: boolean): T | undefined {
 | 
			
		||||
		return <T>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<any>(row);
 | 
			
		||||
		}
 | 
			
		||||
		return undefined;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	/**
 | 
			
		||||
@@ -188,7 +238,10 @@ export class Cache<K extends string | number | symbol, T> {
 | 
			
		||||
	 * @return {T} Cached item
 | 
			
		||||
	 */
 | 
			
		||||
	get(key: K, expired?: boolean): CachedValue<T> | null {
 | 
			
		||||
		const cached = deepCopy<any>(this.store[key] ?? null);
 | 
			
		||||
		const raw = this.store.get(key);
 | 
			
		||||
		if(raw == null) return null;
 | 
			
		||||
		const cached: any = deepCopy<any>(raw);
 | 
			
		||||
		this.touchLRU(key);
 | 
			
		||||
		if(expired || !cached?._expired) return cached;
 | 
			
		||||
		return null;
 | 
			
		||||
	}
 | 
			
		||||
@@ -198,8 +251,12 @@ export class Cache<K extends string | number | symbol, T> {
 | 
			
		||||
	 * @return {K[]} Array of keys
 | 
			
		||||
	 */
 | 
			
		||||
	keys(expired?: boolean): K[] {
 | 
			
		||||
		return <K[]>Object.keys(this.store)
 | 
			
		||||
			.filter(k => expired || !(<any>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 +264,11 @@ export class Cache<K extends string | number | symbol, T> {
 | 
			
		||||
	 * @return {Record<K, T>}
 | 
			
		||||
	 */
 | 
			
		||||
	map(expired?: boolean): Record<K, CachedValue<T>> {
 | 
			
		||||
		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<any>(val);
 | 
			
		||||
		}
 | 
			
		||||
		return copy;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@@ -223,12 +281,17 @@ export class Cache<K extends string | number | symbol, T> {
 | 
			
		||||
	 */
 | 
			
		||||
	set(key: K, value: T, ttl = this.options.ttl): this {
 | 
			
		||||
		if(this.options.expiryPolicy == 'keep') delete (<any>value)._expired;
 | 
			
		||||
		this.store[key] = value;
 | 
			
		||||
		this.clearTimer(key);
 | 
			
		||||
		this.store.set(key, value);
 | 
			
		||||
		this.touchLRU(key);
 | 
			
		||||
		this.save(key);
 | 
			
		||||
		if(ttl) setTimeout(() => {
 | 
			
		||||
		if(ttl) {
 | 
			
		||||
			const t = setTimeout(() => {
 | 
			
		||||
				this.expire(key);
 | 
			
		||||
				this.save(key);
 | 
			
		||||
			}, (ttl || 0) * 1000);
 | 
			
		||||
			this.timers.set(key, t);
 | 
			
		||||
		}
 | 
			
		||||
		return this;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@@ -236,5 +299,5 @@ export class Cache<K extends string | number | symbol, T> {
 | 
			
		||||
	 * Get all cached items
 | 
			
		||||
	 * @return {T[]} Array of items
 | 
			
		||||
	 */
 | 
			
		||||
	values = this.all();
 | 
			
		||||
	values = this.all
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,7 @@
 | 
			
		||||
import {makeArray} from './array.ts';
 | 
			
		||||
import {ASet} from './aset.ts';
 | 
			
		||||
import {dotNotation, flattenObj, JSONSanitize} from './objects.ts';
 | 
			
		||||
import {JSONSanitize} from './json.ts';
 | 
			
		||||
import {dotNotation, flattenObj} from './objects.ts';
 | 
			
		||||
import {LETTER_LIST} from './string.ts';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
import {makeArray} from './array.ts';
 | 
			
		||||
import {JSONAttemptParse} from './objects.ts';
 | 
			
		||||
import {JSONAttemptParse} from './json.ts';
 | 
			
		||||
import {PromiseProgress} from './promise-progress';
 | 
			
		||||
import {formatDate} from './time.ts';
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -9,6 +9,7 @@ export * from './files';
 | 
			
		||||
export * from './emitter';
 | 
			
		||||
export * from './errors';
 | 
			
		||||
export * from './http';
 | 
			
		||||
export * from './json';
 | 
			
		||||
export * from './jwt';
 | 
			
		||||
export * from './logger';
 | 
			
		||||
export * from './math';
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										40
									
								
								src/json.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								src/json.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,40 @@
 | 
			
		||||
/**
 | 
			
		||||
 * Parse JSON but return the original string if it fails
 | 
			
		||||
 *
 | 
			
		||||
 * @param {any} json JSON string to parse
 | 
			
		||||
 * @param fallback Fallback value if unable to parse, defaults to original string
 | 
			
		||||
 * @return {string | T} Object if successful, original string otherwise
 | 
			
		||||
 */
 | 
			
		||||
export function JSONAttemptParse<T1, T2>(json: T2, fallback?: T1): T1 | T2 {
 | 
			
		||||
	try { return JSON.parse(<any>json); }
 | 
			
		||||
	catch { return fallback ?? json; }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Stringifies objects & skips primitives
 | 
			
		||||
 *
 | 
			
		||||
 * @param {any} obj Object to convert to serializable value
 | 
			
		||||
 * @return {string | T} Serialized value
 | 
			
		||||
 */
 | 
			
		||||
export function JSONSerialize<T1>(obj: T1): T1 | string {
 | 
			
		||||
	if(typeof obj == 'object' && obj != null) return JSONSanitize(obj);
 | 
			
		||||
	return obj;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Convert an object to a JSON string avoiding any circular references.
 | 
			
		||||
 *
 | 
			
		||||
 * @param obj Object to convert to JSON
 | 
			
		||||
 * @param {number} space Format the JSON with spaces
 | 
			
		||||
 * @return {string} JSON string
 | 
			
		||||
 */
 | 
			
		||||
export function JSONSanitize(obj: any, space?: number): string {
 | 
			
		||||
	const cache: any[] = [];
 | 
			
		||||
	return JSON.stringify(obj, (key, value) => {
 | 
			
		||||
		if(typeof value === 'object' && value !== null) {
 | 
			
		||||
			if(cache.includes(value)) return '[Circular]';
 | 
			
		||||
			cache.push(value);
 | 
			
		||||
		}
 | 
			
		||||
		return value;
 | 
			
		||||
	}, space);
 | 
			
		||||
}
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
import {JSONAttemptParse} from './objects.ts';
 | 
			
		||||
import {JSONAttemptParse} from './json.ts';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Creates a JSON Web Token (JWT) using the provided payload.
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
import {TypedEmitter, TypedEvents} from './emitter';
 | 
			
		||||
import {JSONSanitize} from './objects.ts';
 | 
			
		||||
import {JSONSanitize} from './json.ts';
 | 
			
		||||
 | 
			
		||||
export const CliEffects = {
 | 
			
		||||
	CLEAR: "\x1b[0m",
 | 
			
		||||
@@ -42,6 +42,8 @@ export const CliBackground = {
 | 
			
		||||
	GREY: "\x1b[100m",
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type LogLevels = 'debug' | 'log' | 'info' | 'warn' | 'error';
 | 
			
		||||
 | 
			
		||||
export enum LOG_LEVEL {
 | 
			
		||||
	ERROR = 0,
 | 
			
		||||
	WARN = 1,
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										14
									
								
								src/math.ts
									
									
									
									
									
								
							
							
						
						
									
										14
									
								
								src/math.ts
									
									
									
									
									
								
							@@ -48,3 +48,17 @@ export function fracToDec(frac: string) {
 | 
			
		||||
	split = (<string>split.pop()).split('/');
 | 
			
		||||
	return whole + (Number(split[0]) / Number(split[1]));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Add a suffix to a number:
 | 
			
		||||
 * 1 = 1st
 | 
			
		||||
 * 2 = 2nd
 | 
			
		||||
 * 3 = 3rd
 | 
			
		||||
 * N = Nth
 | 
			
		||||
 * @param {number} n
 | 
			
		||||
 * @returns {string}
 | 
			
		||||
 */
 | 
			
		||||
export function numSuffix(n: number): string {
 | 
			
		||||
	const s = ['th', 'st', 'nd', 'rd'], v = n % 100;
 | 
			
		||||
	return `${n}${s[(v - 20) % 10] || s[v] || s[0]}`;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										29
									
								
								src/misc.ts
									
									
									
									
									
								
							
							
						
						
									
										29
									
								
								src/misc.ts
									
									
									
									
									
								
							@@ -1,3 +1,4 @@
 | 
			
		||||
import {LogLevels} from './logger.ts';
 | 
			
		||||
import {PathEvent} from './path-events.ts';
 | 
			
		||||
import {md5} from './string';
 | 
			
		||||
 | 
			
		||||
@@ -14,6 +15,34 @@ export function compareVersions(target: string, vs: string): -1 | 0 | 1 {
 | 
			
		||||
		(tMajor < vMajor || tMinor < vMinor || tPatch < vPatch) ? -1 : 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Create a console object to intercept logs with optional passthrough
 | 
			
		||||
 * @param {null | {debug: Function, log: Function, info: Function, warn: Function, error: Function}} out Passthrough logs, null to silence
 | 
			
		||||
 * @param {{[K in LogLevels]?: LogLevels | "none"}} map Map log levels: {log: 'debug', warn: 'error'} = Suppress debug logs, elevate warnings
 | 
			
		||||
 * @returns {{debug: Function, log: Function, info: Function, warn: Function, error: Function, stderr: string[], stdout: string[]}}
 | 
			
		||||
 */
 | 
			
		||||
export function consoleInterceptor(
 | 
			
		||||
	out: null | {debug: Function, log: Function, info: Function, warn: Function, error: Function} = console,
 | 
			
		||||
	map?: {[K in LogLevels]?: LogLevels | 'none'}
 | 
			
		||||
): {debug: Function, log: Function, info: Function, warn: Function, error: Function, output: {debug: any[], log: any[], info: any[], warn: any[], error: any[], stderr: any[], stdout: any[]}} {
 | 
			
		||||
	const logs: any = {debug: [], log: [], info: [], warn: [], error: [], stderr: [], stdout: [],}
 | 
			
		||||
	const cWrapper = (type: 'debug' | 'log' | 'info' | 'warn' | 'error') => ((...args: any[]) => {
 | 
			
		||||
		if(out) out[type](...args);
 | 
			
		||||
		logs[type].push(...args);
 | 
			
		||||
		if(type == 'error') logs.stderr.push(...args);
 | 
			
		||||
		else logs.stdout.push(...args);
 | 
			
		||||
	});
 | 
			
		||||
	return {
 | 
			
		||||
		debug: map?.debug != 'none' ? cWrapper(map?.debug || 'debug') : () => {},
 | 
			
		||||
		log: map?.log != 'none' ? cWrapper(map?.log || 'log') : () => {},
 | 
			
		||||
		info: map?.info != 'none' ? cWrapper(map?.info || 'info') : () => {},
 | 
			
		||||
		warn: map?.warn != 'none' ? cWrapper(map?.warn || 'warn') : () => {},
 | 
			
		||||
		error: map?.error != 'none' ? cWrapper(map?.error || 'error') : () => {},
 | 
			
		||||
		output: logs
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Escape any regex special characters to avoid misinterpretation during search
 | 
			
		||||
 * @param {string} value String which should be escaped
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										115
									
								
								src/objects.ts
									
									
									
									
									
								
							
							
						
						
									
										115
									
								
								src/objects.ts
									
									
									
									
									
								
							@@ -1,56 +1,52 @@
 | 
			
		||||
import {JSONSanitize} from './json.ts';
 | 
			
		||||
 | 
			
		||||
export type Delta = { [key: string]: any | Delta | null };
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Applies deltas in order to modify `target`.
 | 
			
		||||
 * @param target - Object to mutate
 | 
			
		||||
 * @param deltas - List of deltas to apply
 | 
			
		||||
 * Applies deltas to `target`.
 | 
			
		||||
 * @param base starting point
 | 
			
		||||
 * @param deltas List of deltas to apply
 | 
			
		||||
 * @returns Mutated target
 | 
			
		||||
 */
 | 
			
		||||
export function applyDeltas(target: any, deltas: Delta[]): any {
 | 
			
		||||
	for(const delta of deltas) {
 | 
			
		||||
		for(const [key, value] of Object.entries(delta)) {
 | 
			
		||||
			if(value === null) {
 | 
			
		||||
				delete target[key]; // Remove
 | 
			
		||||
			} else if(Array.isArray(value)) {
 | 
			
		||||
				target[key] = [...value]; // Array
 | 
			
		||||
			} else if(typeof value === 'object') {
 | 
			
		||||
				if(typeof target[key] !== 'object' || Array.isArray(target[key]) || !target[key])
 | 
			
		||||
					target[key] = {}; // Nested
 | 
			
		||||
				applyDeltas(target[key], [value]); // Recurse
 | 
			
		||||
			} else {
 | 
			
		||||
				target[key] = value; // Primitive
 | 
			
		||||
export function applyDeltas(base: any, ...deltas: any[]): any {
 | 
			
		||||
	function applyDelta(base: any, delta: any): any {
 | 
			
		||||
		if(delta === null) return null;
 | 
			
		||||
		if(typeof base !== 'object' || base === null) return delta === undefined ? base : delta;
 | 
			
		||||
		const result = Array.isArray(base) ? [...base] : { ...base };
 | 
			
		||||
		for(const key in delta) {
 | 
			
		||||
			const val = delta[key];
 | 
			
		||||
			if(val === undefined) delete result[key];
 | 
			
		||||
			else if(typeof val === 'object' && val !== null && !Array.isArray(val)) result[key] = applyDelta(result[key], val);
 | 
			
		||||
			else result[key] = val;
 | 
			
		||||
		}
 | 
			
		||||
		return result;
 | 
			
		||||
	}
 | 
			
		||||
	}
 | 
			
		||||
	return target;
 | 
			
		||||
 | 
			
		||||
	for(const d of deltas.flat()) base = applyDelta(base, d?.delta ?? d);
 | 
			
		||||
	return base;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Creates a nested delta that reverts `target` back to `old`.
 | 
			
		||||
 * @param old - Original object
 | 
			
		||||
 * @param target - Modified object
 | 
			
		||||
 * @returns Delta to revert changes
 | 
			
		||||
 * @param updated - Modified object
 | 
			
		||||
 * @returns New changes
 | 
			
		||||
 */
 | 
			
		||||
export function calcDelta(old: any, target: any): Delta {
 | 
			
		||||
	const delta: Delta = {};
 | 
			
		||||
	const keys = new Set([...Object.keys(old || {}), ...Object.keys(target || {})]);
 | 
			
		||||
	for(const key of keys) {
 | 
			
		||||
		const val1 = old?.[key];
 | 
			
		||||
		const val2 = target?.[key];
 | 
			
		||||
		if(!(key in target)) {
 | 
			
		||||
			delta[key] = val1; // Removed
 | 
			
		||||
		} else if(!(key in old)) {
 | 
			
		||||
			delta[key] = null; // Added
 | 
			
		||||
		} else if(Array.isArray(val1) || Array.isArray(val2)) {
 | 
			
		||||
			if(JSON.stringify(val1) !== JSON.stringify(val2)) delta[key] = val1; // Array
 | 
			
		||||
		} else if(typeof val1 === 'object' && typeof val2 === 'object' && val1 && val2) {
 | 
			
		||||
			const nested = calcDelta(val1, val2);
 | 
			
		||||
			if(Object.keys(nested).length) delta[key] = nested; // Nested
 | 
			
		||||
		} else if(val1 !== val2) {
 | 
			
		||||
			delta[key] = val1; // Modified
 | 
			
		||||
export function calcDelta(old: any, updated: any): any {
 | 
			
		||||
	if(updated == null) return null; // full delete
 | 
			
		||||
	const delta: any = {};
 | 
			
		||||
	const isObj = (v: any) => v && typeof v === 'object' && !Array.isArray(v);
 | 
			
		||||
	for (const key of new Set([...(old ? Object.keys(old) : []), ...(updated ? Object.keys(updated) : [])])) {
 | 
			
		||||
		const oldVal = old?.[key];
 | 
			
		||||
		const newVal = updated?.[key];
 | 
			
		||||
		if(isObj(oldVal) && isObj(newVal)) {
 | 
			
		||||
			const nested = calcDelta(oldVal, newVal);
 | 
			
		||||
			if(nested !== null && Object.keys(nested).length > 0) delta[key] = nested;
 | 
			
		||||
		} else if(JSON.stringify(oldVal) !== JSON.stringify(newVal)) {
 | 
			
		||||
			delta[key] = newVal;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	return delta;
 | 
			
		||||
	return Object.keys(delta).length === 0 ? {} : delta;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
@@ -299,41 +295,12 @@ export function mixin(target: any, constructors: any[]) {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Parse JSON but return the original string if it fails
 | 
			
		||||
 *
 | 
			
		||||
 * @param {any} json JSON string to parse
 | 
			
		||||
 * @return {string | T} Object if successful, original string otherwise
 | 
			
		||||
 * Run a map function on each property
 | 
			
		||||
 * @param obj Object that will be iterated
 | 
			
		||||
 * @param {(key: string, value: any) => any} fn Transformer function - receives key & value
 | 
			
		||||
 * @returns {{}}
 | 
			
		||||
 */
 | 
			
		||||
export function JSONAttemptParse<T1, T2>(json: T2): T1 | T2 {
 | 
			
		||||
	try { return JSON.parse(<any>json); }
 | 
			
		||||
	catch { return json; }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Stringifies objects & skips primitives
 | 
			
		||||
 *
 | 
			
		||||
 * @param {any} obj Object to convert to serializable value
 | 
			
		||||
 * @return {string | T} Serialized value
 | 
			
		||||
 */
 | 
			
		||||
export function JSONSerialize<T1>(obj: T1): T1 | string {
 | 
			
		||||
	if(typeof obj == 'object' && obj != null) return JSONSanitize(obj);
 | 
			
		||||
	return obj;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Convert an object to a JSON string avoiding any circular references.
 | 
			
		||||
 *
 | 
			
		||||
 * @param obj Object to convert to JSON
 | 
			
		||||
 * @param {number} space Format the JSON with spaces
 | 
			
		||||
 * @return {string} JSON string
 | 
			
		||||
 */
 | 
			
		||||
export function JSONSanitize(obj: any, space?: number): string {
 | 
			
		||||
	const cache: any[] = [];
 | 
			
		||||
	return JSON.stringify(obj, (key, value) => {
 | 
			
		||||
		if (typeof value === 'object' && value !== null) {
 | 
			
		||||
			if (cache.includes(value)) return '[Circular]';
 | 
			
		||||
			cache.push(value);
 | 
			
		||||
		}
 | 
			
		||||
		return value;
 | 
			
		||||
	}, space);
 | 
			
		||||
export function objectMap<T>(obj: any, fn: (key: string, value: any) => any): T {
 | 
			
		||||
	return <any>Object.entries(obj).map(([key, value]: [string, any]) => [key, fn(key, value)])
 | 
			
		||||
		.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {});
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,5 @@
 | 
			
		||||
import {dotNotation, JSONAttemptParse, JSONSerialize} from './objects.ts';
 | 
			
		||||
import {JSONAttemptParse, JSONSerialize} from './json.ts';
 | 
			
		||||
import {dotNotation} from './objects.ts';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Filters an array of objects based on a search term and optional regex checking.
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										261
									
								
								src/time.ts
									
									
									
									
									
								
							
							
						
						
									
										261
									
								
								src/time.ts
									
									
									
									
									
								
							@@ -20,119 +20,178 @@ export function adjustedInterval(cb: Function, ms: number) {
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function dayOfWeek(d: number): string {
 | 
			
		||||
	return ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][d];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function dayOfYear(date: Date): number {
 | 
			
		||||
	const start = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
 | 
			
		||||
	return Math.ceil((date.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Format date
 | 
			
		||||
 *
 | 
			
		||||
 * Year:
 | 
			
		||||
 * - YYYY = 2025 (4-digit year)
 | 
			
		||||
 * - YY = 25 (2-digit year)
 | 
			
		||||
 *
 | 
			
		||||
 * Month:
 | 
			
		||||
 * - MMMM = January (full month name)
 | 
			
		||||
 * - MMM = Jan (abbreviated month name)
 | 
			
		||||
 * - MM = 01 (zero-padded month number)
 | 
			
		||||
 * - M = 1 (month number)
 | 
			
		||||
 *
 | 
			
		||||
 * Day:
 | 
			
		||||
 * - DDD = 123 (day of year)
 | 
			
		||||
 * - DD = 01 (zero-padded day)
 | 
			
		||||
 * - Do = 1st (day with ordinal suffix)
 | 
			
		||||
 * - D = 1 (day number)
 | 
			
		||||
 * - dddd = Monday (full day name)
 | 
			
		||||
 * - ddd = Mon (abbreviated day name)
 | 
			
		||||
 *
 | 
			
		||||
 * Hour:
 | 
			
		||||
 * - HH = 13 (24-hour format, zero-padded)
 | 
			
		||||
 * - H = 13 (24-hour format)
 | 
			
		||||
 * - hh = 01 (12-hour format, zero-padded)
 | 
			
		||||
 * - h = 1 (12-hour format)
 | 
			
		||||
 *
 | 
			
		||||
 * Minute:
 | 
			
		||||
 * - mm = 05 (zero-padded minutes)
 | 
			
		||||
 * - m = 5 (minutes)
 | 
			
		||||
 *
 | 
			
		||||
 * Second:
 | 
			
		||||
 * - ss = 09 (zero-padded seconds)
 | 
			
		||||
 * - s = 9 (seconds)
 | 
			
		||||
 *
 | 
			
		||||
 * Millisecond:
 | 
			
		||||
 * - SSS = 123 (3-digit milliseconds)
 | 
			
		||||
 *
 | 
			
		||||
 * AM/PM:
 | 
			
		||||
 * - A = AM/PM (uppercase)
 | 
			
		||||
 * - a = am/pm (lowercase)
 | 
			
		||||
 *
 | 
			
		||||
 * Timezone:
 | 
			
		||||
 * - ZZ = +0500 (timezone offset without colon)
 | 
			
		||||
 * - Z = +05:00 (timezone offset with colon)
 | 
			
		||||
 * - z = EST (timezone abbreviation)
 | 
			
		||||
 *
 | 
			
		||||
 * @param {string} format How date string will be formatted, default: `YYYY-MM-DD H:mm A`
 | 
			
		||||
 * @param {Date | number | string} date Date or timestamp, defaults to now
 | 
			
		||||
 * @param tz Set timezone offset
 | 
			
		||||
 * @return {string} Formated date
 | 
			
		||||
 */
 | 
			
		||||
export function formatDate(format = 'YYYY-MM-DD H:mm', date: Date | number | string = new Date(), tz?: string | number): string {
 | 
			
		||||
	const timezones = [
 | 
			
		||||
		['IDLW', -12],
 | 
			
		||||
		['SST', -11],
 | 
			
		||||
		['HST', -10],
 | 
			
		||||
		['AKST', -9],
 | 
			
		||||
		['PST', -8],
 | 
			
		||||
		['MST', -7],
 | 
			
		||||
		['CST', -6],
 | 
			
		||||
		['EST', -5],
 | 
			
		||||
		['AST', -4],
 | 
			
		||||
		['BRT', -3],
 | 
			
		||||
		['MAT', -2],
 | 
			
		||||
		['AZOT', -1],
 | 
			
		||||
		['UTC', 0],
 | 
			
		||||
		['CET', 1],
 | 
			
		||||
		['EET', 2],
 | 
			
		||||
		['MSK', 3],
 | 
			
		||||
		['AST', 4],
 | 
			
		||||
		['PKT', 5],
 | 
			
		||||
		['IST', 5.5],
 | 
			
		||||
		['BST', 6],
 | 
			
		||||
		['ICT', 7],
 | 
			
		||||
		['CST', 8],
 | 
			
		||||
		['JST', 9],
 | 
			
		||||
		['AEST', 10],
 | 
			
		||||
		['SBT', 11],
 | 
			
		||||
		['FJT', 12],
 | 
			
		||||
		['TOT', 13],
 | 
			
		||||
		['LINT', 14]
 | 
			
		||||
	];
 | 
			
		||||
export function formatDate(format: string = 'YYYY-MM-DD H:mm', date: Date | number | string = new Date(), tz: string | number = 'local'): string {
 | 
			
		||||
	if (typeof date === 'number' || typeof date === 'string') date = new Date(date);
 | 
			
		||||
	if (isNaN(date.getTime())) throw new Error('Invalid date input');
 | 
			
		||||
	const numericTz = typeof tz === 'number';
 | 
			
		||||
	const localTz = tz === 'local' || (!numericTz && tz.toLowerCase?.() === 'local');
 | 
			
		||||
	const tzName = localTz ? Intl.DateTimeFormat().resolvedOptions().timeZone : numericTz ? 'UTC' : tz;
 | 
			
		||||
 | 
			
		||||
	function adjustTz(date: Date, gmt: number) {
 | 
			
		||||
		const currentOffset = date.getTimezoneOffset();
 | 
			
		||||
		const adjustedOffset = gmt * 60;
 | 
			
		||||
		return new Date(date.getTime() + (adjustedOffset + currentOffset) * 60000);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	function day(num: number): string {
 | 
			
		||||
		return ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][num] || 'Unknown';
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	function doy(date: Date) {
 | 
			
		||||
		const start = new Date(`${date.getFullYear()}-01-01 0:00:00`);
 | 
			
		||||
		return Math.ceil((date.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	function month(num: number): string {
 | 
			
		||||
		return ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'][num] || 'Unknown';
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	function suffix(num: number) {
 | 
			
		||||
		if (num % 100 >= 11 && num % 100 <= 13) return `${num}th`;
 | 
			
		||||
		switch (num % 10) {
 | 
			
		||||
			case 1: return `${num}st`;
 | 
			
		||||
			case 2: return `${num}nd`;
 | 
			
		||||
			case 3: return `${num}rd`;
 | 
			
		||||
			default: return `${num}th`;
 | 
			
		||||
	if (!numericTz && tzName !== 'UTC') {
 | 
			
		||||
		try {
 | 
			
		||||
			new Intl.DateTimeFormat('en-US', { timeZone: tzName }).format();
 | 
			
		||||
		} catch {
 | 
			
		||||
			throw new Error(`Invalid timezone: ${tzName}`);
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	function tzOffset(offset: number) {
 | 
			
		||||
		const hours = ~~(offset / 60);
 | 
			
		||||
		const minutes = offset % 60;
 | 
			
		||||
		return (offset > 0 ? '-' : '') + `${hours}:${minutes.toString().padStart(2, '0')}`;
 | 
			
		||||
	let zonedDate = new Date(date);
 | 
			
		||||
	let get: (fn: 'FullYear' | 'Month' | 'Date' | 'Day' | 'Hours' | 'Minutes' | 'Seconds' | 'Milliseconds') => number;
 | 
			
		||||
	const partsMap: Record<string, string> = {};
 | 
			
		||||
	if (!numericTz && tzName !== 'UTC') {
 | 
			
		||||
		const parts = new Intl.DateTimeFormat('en-US', {
 | 
			
		||||
			timeZone: tzName,
 | 
			
		||||
			year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'long',
 | 
			
		||||
			hour: '2-digit', minute: '2-digit', second: '2-digit',
 | 
			
		||||
			hour12: false
 | 
			
		||||
		}).formatToParts(date);
 | 
			
		||||
		parts.forEach(p => {
 | 
			
		||||
			partsMap[p.type] = p.value;
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		const monthValue = parseInt(partsMap.month) - 1;
 | 
			
		||||
		const dayOfWeekValue = new Date(`${partsMap.year}-${partsMap.month}-${partsMap.day}`).getDay();
 | 
			
		||||
		const hourValue = parseInt(partsMap.hour);
 | 
			
		||||
 | 
			
		||||
		get = (fn: 'FullYear' | 'Month' | 'Date' | 'Day' | 'Hours' | 'Minutes' | 'Seconds' | 'Milliseconds'): number => {
 | 
			
		||||
			switch (fn) {
 | 
			
		||||
				case 'FullYear': return parseInt(partsMap.year);
 | 
			
		||||
				case 'Month': return monthValue;
 | 
			
		||||
				case 'Date': return parseInt(partsMap.day);
 | 
			
		||||
				case 'Day': return dayOfWeekValue;
 | 
			
		||||
				case 'Hours': return hourValue;
 | 
			
		||||
				case 'Minutes': return parseInt(partsMap.minute);
 | 
			
		||||
				case 'Seconds': return parseInt(partsMap.second);
 | 
			
		||||
				default: return 0;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
	if(typeof date == 'number' || typeof date == 'string' || date == null) date = new Date(date);
 | 
			
		||||
 | 
			
		||||
	// Handle timezones
 | 
			
		||||
	let t!: [string, number];
 | 
			
		||||
	if(tz == null) tz = -(date.getTimezoneOffset() / 60);
 | 
			
		||||
	t = <any>timezones.find(t => isNaN(<any>tz) ? t[0] == tz : t[1] == tz);
 | 
			
		||||
	if(!t) throw new Error(`Unknown timezone: ${tz}`);
 | 
			
		||||
	date = adjustTz(date, t[1]);
 | 
			
		||||
 | 
			
		||||
	// Token mapping
 | 
			
		||||
	const tokens: Record<string, string> = {
 | 
			
		||||
		'YYYY': date.getFullYear().toString(),
 | 
			
		||||
		'YY': date.getFullYear().toString().slice(2),
 | 
			
		||||
		'MMMM': month(date.getMonth()),
 | 
			
		||||
		'MMM': month(date.getMonth()).slice(0, 3),
 | 
			
		||||
		'MM': (date.getMonth() + 1).toString().padStart(2, '0'),
 | 
			
		||||
		'M': (date.getMonth() + 1).toString(),
 | 
			
		||||
		'DDD': doy(date).toString(),
 | 
			
		||||
		'DD': date.getDate().toString().padStart(2, '0'),
 | 
			
		||||
		'Do': suffix(date.getDate()),
 | 
			
		||||
		'D': date.getDate().toString(),
 | 
			
		||||
		'dddd': day(date.getDay()),
 | 
			
		||||
		'ddd': day(date.getDay()).slice(0, 3),
 | 
			
		||||
		'HH': date.getHours().toString().padStart(2, '0'),
 | 
			
		||||
		'H': date.getHours().toString(),
 | 
			
		||||
		'hh': (date.getHours() % 12 || 12).toString().padStart(2, '0'),
 | 
			
		||||
		'h': (date.getHours() % 12 || 12).toString(),
 | 
			
		||||
		'mm': date.getMinutes().toString().padStart(2, '0'),
 | 
			
		||||
		'm': date.getMinutes().toString(),
 | 
			
		||||
		'ss': date.getSeconds().toString().padStart(2, '0'),
 | 
			
		||||
		's': date.getSeconds().toString(),
 | 
			
		||||
		'SSS': date.getMilliseconds().toString().padStart(3, '0'),
 | 
			
		||||
		'A': date.getHours() >= 12 ? 'PM' : 'AM',
 | 
			
		||||
		'a': date.getHours() >= 12 ? 'pm' : 'am',
 | 
			
		||||
		'ZZ': tzOffset(t[1] * 60).replace(':', ''),
 | 
			
		||||
		'Z': tzOffset(t[1] * 60),
 | 
			
		||||
		'z': typeof tz == 'string' ? tz : (<any>t)[0]
 | 
			
		||||
		};
 | 
			
		||||
	} else {
 | 
			
		||||
		const offset = numericTz ? tz as number : 0;
 | 
			
		||||
		zonedDate = new Date(date.getTime() + offset * 60 * 60 * 1000);
 | 
			
		||||
		get = (fn: 'FullYear' | 'Month' | 'Date' | 'Day' | 'Hours' | 'Minutes' | 'Seconds' | 'Milliseconds'): number => zonedDate[`getUTC${fn}`]();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	function numSuffix(n: number): string {
 | 
			
		||||
		const s = ["th", "st", "nd", "rd"];
 | 
			
		||||
		const v = n % 100;
 | 
			
		||||
		return n + (s[(v - 20) % 10] || s[v] || s[0]);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	function getTZOffset(): string {
 | 
			
		||||
		if (numericTz) {
 | 
			
		||||
			const total = (tz as number) * 60;
 | 
			
		||||
			const hours = Math.floor(Math.abs(total) / 60);
 | 
			
		||||
			const mins = Math.abs(total) % 60;
 | 
			
		||||
			return `${tz >= 0 ? '+' : '-'}${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
 | 
			
		||||
		}
 | 
			
		||||
		try {
 | 
			
		||||
			const offset = new Intl.DateTimeFormat('en-US', {timeZone: tzName, timeZoneName: 'longOffset', hour: '2-digit', minute: '2-digit',})
 | 
			
		||||
				.formatToParts(<Date>date).find(p => p.type === 'timeZoneName')?.value.match(/([+-]\d{2}:\d{2})/)?.[1];
 | 
			
		||||
			if (offset) return offset;
 | 
			
		||||
		} catch {}
 | 
			
		||||
		return '+00:00';
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	function getTZAbbr(): string {
 | 
			
		||||
		if (numericTz && tz === 0) return 'UTC';
 | 
			
		||||
		try {
 | 
			
		||||
			return new Intl.DateTimeFormat('en-US', { timeZone: tzName, timeZoneName: 'short' })
 | 
			
		||||
				.formatToParts(<Date>date).find(p => p.type === 'timeZoneName')?.value || '';
 | 
			
		||||
		} catch {
 | 
			
		||||
			return tzName;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	const tokens: Record<string, string> = {
 | 
			
		||||
		YYYY: get('FullYear').toString(),
 | 
			
		||||
		YY: get('FullYear').toString().slice(2),
 | 
			
		||||
		MMMM: month(get('Month')),
 | 
			
		||||
		MMM: month(get('Month')).slice(0, 3),
 | 
			
		||||
		MM: (get('Month') + 1).toString().padStart(2, '0'),
 | 
			
		||||
		M: (get('Month') + 1).toString(),
 | 
			
		||||
		DDD: dayOfYear(zonedDate).toString(),
 | 
			
		||||
		DD: get('Date').toString().padStart(2, '0'),
 | 
			
		||||
		Do: numSuffix(get('Date')),
 | 
			
		||||
		D: get('Date').toString(),
 | 
			
		||||
		dddd: dayOfWeek(get('Day')),
 | 
			
		||||
		ddd: dayOfWeek(get('Day')).slice(0, 3),
 | 
			
		||||
		HH: get('Hours').toString().padStart(2, '0'),
 | 
			
		||||
		H: get('Hours').toString(),
 | 
			
		||||
		hh: (get('Hours') % 12 || 12).toString().padStart(2, '0'),
 | 
			
		||||
		h: (get('Hours') % 12 || 12).toString(),
 | 
			
		||||
		mm: get('Minutes').toString().padStart(2, '0'),
 | 
			
		||||
		m: get('Minutes').toString(),
 | 
			
		||||
		ss: get('Seconds').toString().padStart(2, '0'),
 | 
			
		||||
		s: get('Seconds').toString(),
 | 
			
		||||
		SSS: (zonedDate[`getUTC${'Milliseconds'}`]()).toString().padStart(3, '0'),
 | 
			
		||||
		A: get('Hours') >= 12 ? 'PM' : 'AM',
 | 
			
		||||
		a: get('Hours') >= 12 ? 'pm' : 'am',
 | 
			
		||||
		ZZ: getTZOffset().replace(':', ''),
 | 
			
		||||
		Z: getTZOffset(),
 | 
			
		||||
		z: getTZAbbr(),
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	return format.replace(/YYYY|YY|MMMM|MMM|MM|M|DDD|DD|Do|D|dddd|ddd|HH|H|hh|h|mm|m|ss|s|SSS|A|a|ZZ|Z|z/g, token => tokens[token]);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -148,6 +207,10 @@ export function instantInterval(fn: () => any, interval: number) {
 | 
			
		||||
	return setInterval(fn, interval);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function month(m: number): string {
 | 
			
		||||
	return ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'][m];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Use in conjunction with `await` to pause an async script
 | 
			
		||||
 *
 | 
			
		||||
 
 | 
			
		||||
@@ -3,12 +3,14 @@ import { Cache } from '../src';
 | 
			
		||||
describe('Cache', () => {
 | 
			
		||||
	type TestItem = { id: string; value: string };
 | 
			
		||||
 | 
			
		||||
	let cache: Cache<string, TestItem>;
 | 
			
		||||
	let cache: Cache<string | symbol, TestItem>;
 | 
			
		||||
	let storageMock: Storage;
 | 
			
		||||
	let storageGetItemSpy: jest.SpyInstance;
 | 
			
		||||
	let storageSetItemSpy: jest.SpyInstance;
 | 
			
		||||
 | 
			
		||||
	beforeEach(() => {
 | 
			
		||||
		jest.useFakeTimers();
 | 
			
		||||
 | 
			
		||||
		storageMock = {
 | 
			
		||||
			constructor: { name: 'Storage' as any },
 | 
			
		||||
			getItem: jest.fn(),
 | 
			
		||||
@@ -23,11 +25,15 @@ describe('Cache', () => {
 | 
			
		||||
		storageGetItemSpy = jest.spyOn(storageMock, 'getItem');
 | 
			
		||||
		storageSetItemSpy = jest.spyOn(storageMock, 'setItem');
 | 
			
		||||
 | 
			
		||||
		cache = new Cache<string, TestItem>('id', {
 | 
			
		||||
		cache = new Cache<string | symbol, TestItem>('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<string, TestItem>('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<string, TestItem>('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<string, TestItem>('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/);
 | 
			
		||||
	});
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										33
									
								
								tests/json.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								tests/json.spec.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,33 @@
 | 
			
		||||
import {JSONAttemptParse, JSONSanitize, JSONSerialize} from '../src';
 | 
			
		||||
 | 
			
		||||
describe('JSON Utilities', () => {
 | 
			
		||||
	describe('JSONAttemptParse', () => {
 | 
			
		||||
		it('parses valid JSON', () => {
 | 
			
		||||
			expect(JSONAttemptParse('{"a":1}')).toEqual({ a: 1 });
 | 
			
		||||
		});
 | 
			
		||||
		it('returns original string on error', () => {
 | 
			
		||||
			expect(JSONAttemptParse('not json')).toBe('not json');
 | 
			
		||||
		});
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	describe('JSONSerialize', () => {
 | 
			
		||||
		it('serializes objects', () => {
 | 
			
		||||
			expect(JSONSerialize({ a: 1 })).toBe(JSON.stringify({ a: 1 }));
 | 
			
		||||
		});
 | 
			
		||||
		it('leaves primitives as is', () => {
 | 
			
		||||
			expect(JSONSerialize('test')).toBe('test');
 | 
			
		||||
			expect(JSONSerialize(123)).toBe(123);
 | 
			
		||||
		});
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	describe('JSONSanitize', () => {
 | 
			
		||||
		it('stringifies objects', () => {
 | 
			
		||||
			expect(JSONSanitize({ a: 1 })).toBe(JSON.stringify({ a: 1 }));
 | 
			
		||||
		});
 | 
			
		||||
		it('does not throw on circular refs', () => {
 | 
			
		||||
			const obj: any = {};
 | 
			
		||||
			obj.self = obj;
 | 
			
		||||
			expect(() => JSONSanitize(obj)).not.toThrow();
 | 
			
		||||
		});
 | 
			
		||||
	});
 | 
			
		||||
});
 | 
			
		||||
@@ -1,6 +1,5 @@
 | 
			
		||||
import {
 | 
			
		||||
	clean, deepCopy, deepMerge, dotNotation, encodeQuery, flattenObj, formData, includes, isEqual, mixin,
 | 
			
		||||
	JSONAttemptParse, JSONSerialize, JSONSanitize
 | 
			
		||||
} from '../src';
 | 
			
		||||
 | 
			
		||||
describe('Object utilities', () => {
 | 
			
		||||
@@ -131,34 +130,4 @@ describe('Object utilities', () => {
 | 
			
		||||
			expect(c.bar()).toBe(2);
 | 
			
		||||
		});
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	describe('JSONAttemptParse', () => {
 | 
			
		||||
		it('parses valid JSON', () => {
 | 
			
		||||
			expect(JSONAttemptParse('{"a":1}')).toEqual({ a: 1 });
 | 
			
		||||
		});
 | 
			
		||||
		it('returns original string on error', () => {
 | 
			
		||||
			expect(JSONAttemptParse('not json')).toBe('not json');
 | 
			
		||||
		});
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	describe('JSONSerialize', () => {
 | 
			
		||||
		it('serializes objects', () => {
 | 
			
		||||
			expect(JSONSerialize({ a: 1 })).toBe(JSON.stringify({ a: 1 }));
 | 
			
		||||
		});
 | 
			
		||||
		it('leaves primitives as is', () => {
 | 
			
		||||
			expect(JSONSerialize('test')).toBe('test');
 | 
			
		||||
			expect(JSONSerialize(123)).toBe(123);
 | 
			
		||||
		});
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	describe('JSONSanitize', () => {
 | 
			
		||||
		it('stringifies objects', () => {
 | 
			
		||||
			expect(JSONSanitize({ a: 1 })).toBe(JSON.stringify({ a: 1 }));
 | 
			
		||||
		});
 | 
			
		||||
		it('does not throw on circular refs', () => {
 | 
			
		||||
			const obj: any = {};
 | 
			
		||||
			obj.self = obj;
 | 
			
		||||
			expect(() => JSONSanitize(obj)).not.toThrow();
 | 
			
		||||
		});
 | 
			
		||||
	});
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -31,11 +31,11 @@ describe('Time Utilities', () => {
 | 
			
		||||
		it('handles formatting for given timestamp', () => {
 | 
			
		||||
			const timestamp = Date.UTC(2023, 1, 1, 18, 5, 5, 123); // Feb 1, 2023 18:05:05.123 UTC
 | 
			
		||||
			const formatted = formatDate('YYYY MM DD HH mm ss SSS A Z', timestamp, 'UTC');
 | 
			
		||||
			expect(formatted).toMatch(/^2023 02 01 18 05 05 123 PM \+?0:00$/i);
 | 
			
		||||
			expect(formatted).toMatch(/^2023 02 01 18 05 05 123 PM \+00:00/i);
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		it('throws for unknown timezone', () => {
 | 
			
		||||
			expect(() => formatDate('YYYY', new Date(), '???')).toThrowError(/Unknown timezone/);
 | 
			
		||||
			expect(() => formatDate('YYYY', new Date(), '???')).toThrowError(/Invalid timezone/);
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		it('handles timezone by offset number', () => {
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user