Compare commits
	
		
			53 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 057528b0c5 | |||
| 1595aea529 | |||
| 08503de552 | |||
| a9a9b04a5a | |||
| a7b19900da | |||
| 34227e5c4b | |||
| 7e8352ed2a | |||
| 751177c981 | |||
| 5849a97c21 | |||
| fcae9e107e | |||
| 5bb5067abc | |||
| 2064880673 | |||
| fdefdf354d | |||
| 31998c01d6 | |||
| c5270fbd7e | |||
| bfe9493d23 | |||
| 1b6fe42f78 | |||
| c3d8d75ba3 | |||
| e2d756fe28 | |||
| 52f64f9e78 | |||
| 4caf0e5048 | |||
| 55b871f4c1 | |||
| fb077775b6 | |||
| 2d2b2b8216 | |||
| b473ade178 | |||
| b3223661dd | |||
| c36af83918 | |||
| 9ec70430a5 | |||
| 397b165e55 | |||
| c1577c2106 | |||
| 2b12916246 | |||
| 5efb045f22 | |||
| 7119390681 | |||
| fd95c0c697 | |||
| 1ab8e6424b | |||
| 035a1d35cb | |||
| e78120b067 | |||
| 71552aa243 | |||
| ce3878e18b | |||
| 388a09718b | |||
| 59bce9d28d | |||
| 387e6b2512 | |||
| d748d740ea | |||
| c8f44584a2 | |||
| 8b84d170a6 | |||
| 37df7e6090 | |||
| a8b6fdd5a6 | |||
| 3e899e6ae4 | |||
| 08fa5b6783 | |||
| 529f1e2b30 | |||
| 87ef246760 | |||
| 10ac6101f4 | |||
| b5d534dc8c | 
@@ -13,3 +13,7 @@ quote_type = single
 | 
				
			|||||||
[*.md]
 | 
					[*.md]
 | 
				
			||||||
max_line_length = off
 | 
					max_line_length = off
 | 
				
			||||||
trim_trailing_whitespace = false
 | 
					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
 | 
					        uses: ztimson/actions/tag@develop
 | 
				
			||||||
        with:
 | 
					        with:
 | 
				
			||||||
          tag: ${{env.VERSION}}
 | 
					          tag: ${{env.VERSION}}
 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  docs:
 | 
					  docs:
 | 
				
			||||||
    name: Publish Documentation
 | 
					    name: Publish Docs
 | 
				
			||||||
    needs: build
 | 
					    runs-on: ubuntu-latest
 | 
				
			||||||
    uses: ztimson/actions/.github/workflows/docker.yaml@develop
 | 
					    container: docker:dind
 | 
				
			||||||
    with:
 | 
					    steps:
 | 
				
			||||||
        name: ztimson/utils
 | 
					      - name: Clone Repository
 | 
				
			||||||
        repository: ${{github.server_url}}/${{github.repository}}.git
 | 
					        uses: ztimson/actions/clone@develop
 | 
				
			||||||
        pass: ${{secrets.DEPLOY_TOKEN}}
 | 
					
 | 
				
			||||||
 | 
					      - 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
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										2
									
								
								.gitmodules
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.gitmodules
									
									
									
									
										vendored
									
									
								
							@@ -1,5 +1,5 @@
 | 
				
			|||||||
[submodule "docs"]
 | 
					[submodule "docs"]
 | 
				
			||||||
	path = docs
 | 
						path = docs
 | 
				
			||||||
	url = git@git.zakscode.com:ztimson/utils.wiki.git
 | 
						url = ../utils.wiki.git
 | 
				
			||||||
	branch = master
 | 
						branch = master
 | 
				
			||||||
	ignore = all
 | 
						ignore = all
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										99
									
								
								index.html
									
									
									
									
									
								
							
							
						
						
									
										99
									
								
								index.html
									
									
									
									
									
								
							@@ -1,9 +1,102 @@
 | 
				
			|||||||
 | 
					<!DOCTYPE html>
 | 
				
			||||||
<html>
 | 
					<html>
 | 
				
			||||||
 | 
						<head>
 | 
				
			||||||
 | 
							<title>Console Styling Playground</title>
 | 
				
			||||||
 | 
						</head>
 | 
				
			||||||
	<body>
 | 
						<body>
 | 
				
			||||||
		<script type="module">
 | 
							<h1>Open Chrome DevTools Console to see magic! (Ctrl+Shift+J or Cmd+Option+J)</h1>
 | 
				
			||||||
			import {PES} from './dist/index.mjs';
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
			console.log(PES`storage${'Test/Test'}:d`);
 | 
							<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>
 | 
							</script>
 | 
				
			||||||
	</body>
 | 
						</body>
 | 
				
			||||||
</html>
 | 
					</html>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,6 +1,6 @@
 | 
				
			|||||||
{
 | 
					{
 | 
				
			||||||
	"name": "@ztimson/utils",
 | 
						"name": "@ztimson/utils",
 | 
				
			||||||
	"version": "0.25.21",
 | 
						"version": "0.27.9",
 | 
				
			||||||
	"description": "Utility library",
 | 
						"description": "Utility library",
 | 
				
			||||||
	"author": "Zak Timson",
 | 
						"author": "Zak Timson",
 | 
				
			||||||
	"license": "MIT",
 | 
						"license": "MIT",
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										168
									
								
								src/cache.ts
									
									
									
									
									
								
							
							
						
						
									
										168
									
								
								src/cache.ts
									
									
									
									
									
								
							@@ -1,15 +1,16 @@
 | 
				
			|||||||
import {Table} from './database.ts';
 | 
					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 = {
 | 
					export type CacheOptions = {
 | 
				
			||||||
	/** Delete keys automatically after x amount of seconds */
 | 
						/** Delete keys automatically after x amount of seconds */
 | 
				
			||||||
	ttl?: number;
 | 
						ttl?: number;
 | 
				
			||||||
	/** Storage to persist cache */
 | 
						/** Storage to persist cache */
 | 
				
			||||||
	storage?: Storage | Table<any, any>;
 | 
						persistentStorage?: {storage: Storage | Database, key: string} | string;
 | 
				
			||||||
	/** Key cache will be stored under */
 | 
					 | 
				
			||||||
	storageKey?: string;
 | 
					 | 
				
			||||||
	/** Keep or delete cached items once expired, defaults to delete */
 | 
						/** Keep or delete cached items once expired, defaults to delete */
 | 
				
			||||||
	expiryPolicy?: 'delete' | 'keep';
 | 
						expiryPolicy?: 'delete' | 'keep';
 | 
				
			||||||
 | 
						/** Least Recently Used size limit */
 | 
				
			||||||
 | 
						sizeLimit?: number;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export type CachedValue<T> = T & {_expired?: boolean};
 | 
					export type CachedValue<T> = T & {_expired?: boolean};
 | 
				
			||||||
@@ -18,12 +19,17 @@ 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> = <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 */
 | 
						/** Support index lookups */
 | 
				
			||||||
	[key: string | number | symbol]: CachedValue<T> | any;
 | 
						[key: string | number | symbol]: CachedValue<T> | any;
 | 
				
			||||||
	/** Whether cache is complete */
 | 
						/** Whether cache is complete */
 | 
				
			||||||
	complete = false;
 | 
						complete = false;
 | 
				
			||||||
 | 
						/** Await initial loading */
 | 
				
			||||||
 | 
						loading = new Promise<void>(r => this._loading = r);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	/**
 | 
						/**
 | 
				
			||||||
	 * Create new cache
 | 
						 * Create new cache
 | 
				
			||||||
@@ -31,15 +37,35 @@ export class Cache<K extends string | number | symbol, T> {
 | 
				
			|||||||
	 * @param options
 | 
						 * @param options
 | 
				
			||||||
	 */
 | 
						 */
 | 
				
			||||||
	constructor(public readonly key?: keyof T, public readonly options: CacheOptions = {}) {
 | 
						constructor(public readonly key?: keyof T, public readonly options: CacheOptions = {}) {
 | 
				
			||||||
		if(options.storageKey && !options.storage && typeof(Storage) !== 'undefined') options.storage = localStorage;
 | 
							// Persistent storage
 | 
				
			||||||
		if(options.storage) {
 | 
							if(this.options.persistentStorage != null) {
 | 
				
			||||||
			if(options.storage instanceof Table) {
 | 
								if(typeof this.options.persistentStorage == 'string')
 | 
				
			||||||
				(async () => this.addAll(await options.storage?.getAll(), false))()
 | 
									this.options.persistentStorage = {storage: localStorage, key: this.options.persistentStorage};
 | 
				
			||||||
			} else if(options.storageKey) {
 | 
					
 | 
				
			||||||
				const stored = options.storage?.getItem(options.storageKey);
 | 
								if(this.options.persistentStorage?.storage?.database != undefined) {
 | 
				
			||||||
				if(stored != null) try { Object.assign(this.store, JSON.parse(stored)); } catch { }
 | 
									(async () => {
 | 
				
			||||||
 | 
										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();
 | 
				
			||||||
 | 
										for(const row of rows) this.store.set(this.getKey(row), row);
 | 
				
			||||||
 | 
										this._loading();
 | 
				
			||||||
 | 
									})();
 | 
				
			||||||
 | 
								} else if((<any>this.options.persistentStorage?.storage)?.getItem != undefined) {
 | 
				
			||||||
 | 
									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 {
 | 
				
			||||||
 | 
								this._loading();
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							// Handle index lookups
 | 
				
			||||||
		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];
 | 
				
			||||||
@@ -55,17 +81,45 @@ export class Cache<K extends string | number | symbol, T> {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	private getKey(value: T): K {
 | 
						private getKey(value: T): K {
 | 
				
			||||||
		if(!this.key) throw new Error('No key defined');
 | 
							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)}`);
 | 
							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[this.key];
 | 
							return <K>(value as any)[this.key];
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	private save(key: K) {
 | 
						private save(key?: K) {
 | 
				
			||||||
		if(this.options.storage) {
 | 
							const persists: {storage: any, key: string} = <any>this.options.persistentStorage;
 | 
				
			||||||
			if(this.options.storage instanceof Table) {
 | 
							if(!!persists?.storage) {
 | 
				
			||||||
				this.options.storage.put(key, this.store[key]);
 | 
								if(persists.storage?.database != undefined) {
 | 
				
			||||||
			} else if(this.options.storageKey) {
 | 
									(<Database>persists.storage).createTable({name: persists.key, key: <string>this.key}).then(table => {
 | 
				
			||||||
				this.options.storage.setItem(this.options.storageKey, JSONSanitize(this.store));
 | 
										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(true).forEach(row => table.add(row));
 | 
				
			||||||
					}
 | 
										}
 | 
				
			||||||
 | 
									});
 | 
				
			||||||
 | 
								} else if(persists.storage?.setItem != undefined) {
 | 
				
			||||||
 | 
									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);
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -74,8 +128,12 @@ export class Cache<K extends string | number | symbol, T> {
 | 
				
			|||||||
	 * @return {T[]} Array of items
 | 
						 * @return {T[]} Array of items
 | 
				
			||||||
	 */
 | 
						 */
 | 
				
			||||||
	all(expired?: boolean): CachedValue<T>[] {
 | 
						all(expired?: boolean): CachedValue<T>[] {
 | 
				
			||||||
		return deepCopy<any>(Object.values(this.store)
 | 
							const out: CachedValue<T>[] = [];
 | 
				
			||||||
			.filter((v: any) => expired || !v._expired));
 | 
							for(const v of this.store.values()) {
 | 
				
			||||||
 | 
								const val: any = v;
 | 
				
			||||||
 | 
								if(expired || !val?._expired) out.push(deepCopy<any>(val));
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							return out;
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	/**
 | 
						/**
 | 
				
			||||||
@@ -108,7 +166,11 @@ export class Cache<K extends string | number | symbol, T> {
 | 
				
			|||||||
	 */
 | 
						 */
 | 
				
			||||||
	clear(): this {
 | 
						clear(): this {
 | 
				
			||||||
		this.complete = false;
 | 
							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;
 | 
							return this;
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -117,7 +179,10 @@ export class Cache<K extends string | number | symbol, T> {
 | 
				
			|||||||
	 * @param {K} key Item's primary key
 | 
						 * @param {K} key Item's primary key
 | 
				
			||||||
	 */
 | 
						 */
 | 
				
			||||||
	delete(key: K): this {
 | 
						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);
 | 
							this.save(key);
 | 
				
			||||||
		return this;
 | 
							return this;
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -127,8 +192,12 @@ export class Cache<K extends string | number | symbol, T> {
 | 
				
			|||||||
	 * @return {[K, T][]} Key-value pairs array
 | 
						 * @return {[K, T][]} Key-value pairs array
 | 
				
			||||||
	 */
 | 
						 */
 | 
				
			||||||
	entries(expired?: boolean): [K, CachedValue<T>][] {
 | 
						entries(expired?: boolean): [K, CachedValue<T>][] {
 | 
				
			||||||
		return deepCopy<any>(Object.entries(this.store)
 | 
							const out: [K, CachedValue<T>][] = [];
 | 
				
			||||||
			.filter((v: any) => expired || !v?._expired));
 | 
							for(const [k, v] of this.store.entries()) {
 | 
				
			||||||
 | 
								const val: any = v;
 | 
				
			||||||
 | 
								if(expired || !val?._expired) out.push([k, deepCopy<any>(val)]);
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							return out;
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	/**
 | 
						/**
 | 
				
			||||||
@@ -138,8 +207,12 @@ export class Cache<K extends string | number | symbol, T> {
 | 
				
			|||||||
	expire(key: K): this {
 | 
						expire(key: K): this {
 | 
				
			||||||
		this.complete = false;
 | 
							this.complete = false;
 | 
				
			||||||
		if(this.options.expiryPolicy == 'keep') {
 | 
							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);
 | 
									this.save(key);
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
		} else this.delete(key);
 | 
							} else this.delete(key);
 | 
				
			||||||
		return this;
 | 
							return this;
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -151,7 +224,11 @@ export class Cache<K extends string | number | symbol, T> {
 | 
				
			|||||||
	 * @returns {T | undefined} Cached item or undefined if nothing matched
 | 
						 * @returns {T | undefined} Cached item or undefined if nothing matched
 | 
				
			||||||
	 */
 | 
						 */
 | 
				
			||||||
	find(filter: Partial<T>, expired?: boolean): T | undefined {
 | 
						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;
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	/**
 | 
						/**
 | 
				
			||||||
@@ -161,7 +238,10 @@ export class Cache<K extends string | number | symbol, T> {
 | 
				
			|||||||
	 * @return {T} Cached item
 | 
						 * @return {T} Cached item
 | 
				
			||||||
	 */
 | 
						 */
 | 
				
			||||||
	get(key: K, expired?: boolean): CachedValue<T> | null {
 | 
						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;
 | 
							if(expired || !cached?._expired) return cached;
 | 
				
			||||||
		return null;
 | 
							return null;
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -171,8 +251,12 @@ export class Cache<K extends string | number | symbol, T> {
 | 
				
			|||||||
	 * @return {K[]} Array of keys
 | 
						 * @return {K[]} Array of keys
 | 
				
			||||||
	 */
 | 
						 */
 | 
				
			||||||
	keys(expired?: boolean): K[] {
 | 
						keys(expired?: boolean): K[] {
 | 
				
			||||||
		return <K[]>Object.keys(this.store)
 | 
							const out: K[] = [];
 | 
				
			||||||
			.filter(k => expired || !(<any>this.store)[k]._expired);
 | 
							for(const [k, v] of this.store.entries()) {
 | 
				
			||||||
 | 
								const val: any = v;
 | 
				
			||||||
 | 
								if(expired || !val?._expired) out.push(k);
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							return out;
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	/**
 | 
						/**
 | 
				
			||||||
@@ -180,10 +264,11 @@ export class Cache<K extends string | number | symbol, T> {
 | 
				
			|||||||
	 * @return {Record<K, T>}
 | 
						 * @return {Record<K, T>}
 | 
				
			||||||
	 */
 | 
						 */
 | 
				
			||||||
	map(expired?: boolean): Record<K, CachedValue<T>> {
 | 
						map(expired?: boolean): Record<K, CachedValue<T>> {
 | 
				
			||||||
		const copy: any = deepCopy(this.store);
 | 
							const copy: any = {};
 | 
				
			||||||
		if(!expired) Object.keys(copy).forEach(k => {
 | 
							for(const [k, v] of this.store.entries()) {
 | 
				
			||||||
			if(copy[k]._expired) delete copy[k]
 | 
								const val: any = v;
 | 
				
			||||||
		});
 | 
								if(expired || !val?._expired) copy[k as any] = deepCopy<any>(val);
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
		return copy;
 | 
							return copy;
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -196,12 +281,17 @@ export class Cache<K extends string | number | symbol, T> {
 | 
				
			|||||||
	 */
 | 
						 */
 | 
				
			||||||
	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>value)._expired;
 | 
							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);
 | 
							this.save(key);
 | 
				
			||||||
		if(ttl) setTimeout(() => {
 | 
							if(ttl) {
 | 
				
			||||||
 | 
								const t = setTimeout(() => {
 | 
				
			||||||
				this.expire(key);
 | 
									this.expire(key);
 | 
				
			||||||
				this.save(key);
 | 
									this.save(key);
 | 
				
			||||||
			}, (ttl || 0) * 1000);
 | 
								}, (ttl || 0) * 1000);
 | 
				
			||||||
 | 
								this.timers.set(key, t);
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
		return this;
 | 
							return this;
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -209,5 +299,5 @@ export class Cache<K extends string | number | symbol, T> {
 | 
				
			|||||||
	 * Get all cached items
 | 
						 * Get all cached items
 | 
				
			||||||
	 * @return {T[]} Array of items
 | 
						 * @return {T[]} Array of items
 | 
				
			||||||
	 */
 | 
						 */
 | 
				
			||||||
	values = this.all();
 | 
						values = this.all
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,6 +1,7 @@
 | 
				
			|||||||
import {makeArray} from './array.ts';
 | 
					import {makeArray} from './array.ts';
 | 
				
			||||||
import {ASet} from './aset.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';
 | 
					import {LETTER_LIST} from './string.ts';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,5 +1,6 @@
 | 
				
			|||||||
import {findByProp} from './array.ts';
 | 
					import {findByProp} from './array.ts';
 | 
				
			||||||
import {ASet} from './aset.ts';
 | 
					import {ASet} from './aset.ts';
 | 
				
			||||||
 | 
					import {sleepWhile} from './time.ts';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export type TableOptions = {
 | 
					export type TableOptions = {
 | 
				
			||||||
	name: string;
 | 
						name: string;
 | 
				
			||||||
@@ -7,14 +8,30 @@ export type TableOptions = {
 | 
				
			|||||||
	autoIncrement?: boolean;
 | 
						autoIncrement?: boolean;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class AsyncLock {
 | 
				
			||||||
 | 
						private p = Promise.resolve();
 | 
				
			||||||
 | 
						run<T>(fn: () => Promise<T>): Promise<T> {
 | 
				
			||||||
 | 
							const res = this.p.then(fn, fn);
 | 
				
			||||||
 | 
							this.p = res.then(() => {}, () => {});
 | 
				
			||||||
 | 
							return res;
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export class Database {
 | 
					export class Database {
 | 
				
			||||||
 | 
						private schemaLock = new AsyncLock();
 | 
				
			||||||
 | 
						private upgrading = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	connection!: Promise<IDBDatabase>;
 | 
						connection!: Promise<IDBDatabase>;
 | 
				
			||||||
	ready = false;
 | 
					 | 
				
			||||||
	tables!: TableOptions[];
 | 
						tables!: TableOptions[];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						get ready() { return !this.upgrading; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	constructor(public readonly database: string, tables?: (string | TableOptions)[], public version?: number) {
 | 
						constructor(public readonly database: string, tables?: (string | TableOptions)[], public version?: number) {
 | 
				
			||||||
		this.connection = new Promise((resolve, reject) => {
 | 
							this.connection = new Promise((resolve, reject) => {
 | 
				
			||||||
			const req = indexedDB.open(this.database, this.version);
 | 
								let req: IDBOpenDBRequest;
 | 
				
			||||||
 | 
								try { req = indexedDB.open(this.database, this.version); }
 | 
				
			||||||
 | 
								catch (err) { return reject(err); }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			this.tables = !tables ? [] : tables.map(t => {
 | 
								this.tables = !tables ? [] : tables.map(t => {
 | 
				
			||||||
				t = typeof t == 'object' ? t : {name: t};
 | 
									t = typeof t == 'object' ? t : {name: t};
 | 
				
			||||||
				return {...t, name: t.name.toString()};
 | 
									return {...t, name: t.name.toString()};
 | 
				
			||||||
@@ -23,13 +40,22 @@ export class Database {
 | 
				
			|||||||
			req.onerror = () => reject(req.error);
 | 
								req.onerror = () => reject(req.error);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			req.onsuccess = () => {
 | 
								req.onsuccess = () => {
 | 
				
			||||||
				const db = req.result;
 | 
									let db: IDBDatabase;
 | 
				
			||||||
 | 
									try { db = req.result; }
 | 
				
			||||||
 | 
									catch (err) { return reject(err); }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
				const existing = Array.from(db.objectStoreNames);
 | 
									const existing = Array.from(db.objectStoreNames);
 | 
				
			||||||
				if(!tables) this.tables = existing.map(t => {
 | 
									if(!tables) {
 | 
				
			||||||
					const tx = db.transaction(t, 'readonly', )
 | 
										this.tables = existing.map(t => {
 | 
				
			||||||
 | 
											try {
 | 
				
			||||||
 | 
												const tx = db.transaction(t, 'readonly');
 | 
				
			||||||
							const store = tx.objectStore(t);
 | 
												const store = tx.objectStore(t);
 | 
				
			||||||
							return {name: t, key: <string>store.keyPath};
 | 
												return {name: t, key: <string>store.keyPath};
 | 
				
			||||||
 | 
											} catch {
 | 
				
			||||||
 | 
												return {name: t}; // 🛡️ fallback
 | 
				
			||||||
 | 
											}
 | 
				
			||||||
					});
 | 
										});
 | 
				
			||||||
 | 
									}
 | 
				
			||||||
				const desired = new ASet((tables || []).map(t => typeof t == 'string' ? t : t.name));
 | 
									const desired = new ASet((tables || []).map(t => typeof t == 'string' ? t : t.name));
 | 
				
			||||||
				if(tables && desired.symmetricDifference(new ASet(existing)).length) {
 | 
									if(tables && desired.symmetricDifference(new ASet(existing)).length) {
 | 
				
			||||||
					db.close();
 | 
										db.close();
 | 
				
			||||||
@@ -39,11 +65,15 @@ export class Database {
 | 
				
			|||||||
					this.version = db.version;
 | 
										this.version = db.version;
 | 
				
			||||||
					resolve(db);
 | 
										resolve(db);
 | 
				
			||||||
				}
 | 
									}
 | 
				
			||||||
				this.ready = true;
 | 
									this.upgrading = false;
 | 
				
			||||||
			};
 | 
								};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			req.onupgradeneeded = () => {
 | 
								req.onupgradeneeded = () => {
 | 
				
			||||||
				const db = req.result;
 | 
									this.upgrading = true;
 | 
				
			||||||
 | 
									let db: IDBDatabase;
 | 
				
			||||||
 | 
									try { db = req.result; }
 | 
				
			||||||
 | 
									catch { return; }
 | 
				
			||||||
 | 
									try {
 | 
				
			||||||
					const existingTables = new ASet(Array.from(db.objectStoreNames));
 | 
										const existingTables = new ASet(Array.from(db.objectStoreNames));
 | 
				
			||||||
					if(tables) {
 | 
										if(tables) {
 | 
				
			||||||
						const desired = new ASet((tables || []).map(t => typeof t == 'string' ? t : t.name));
 | 
											const desired = new ASet((tables || []).map(t => typeof t == 'string' ? t : t.name));
 | 
				
			||||||
@@ -52,30 +82,43 @@ export class Database {
 | 
				
			|||||||
							const t = this.tables.find(findByProp('name', name));
 | 
												const t = this.tables.find(findByProp('name', name));
 | 
				
			||||||
							db.createObjectStore(name, {
 | 
												db.createObjectStore(name, {
 | 
				
			||||||
								keyPath: t?.key,
 | 
													keyPath: t?.key,
 | 
				
			||||||
							autoIncrement: t?.autoIncrement || !t?.key
 | 
													autoIncrement: t?.autoIncrement || !t?.key,
 | 
				
			||||||
							});
 | 
												});
 | 
				
			||||||
						});
 | 
											});
 | 
				
			||||||
					}
 | 
										}
 | 
				
			||||||
 | 
									} catch { }
 | 
				
			||||||
			};
 | 
								};
 | 
				
			||||||
		});
 | 
							});
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						waitForUpgrade = () => sleepWhile(() => this.upgrading);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	async createTable<K extends IDBValidKey = any, T = any>(table: string | TableOptions): Promise<Table<K, T>> {
 | 
						async createTable<K extends IDBValidKey = any, T = any>(table: string | TableOptions): Promise<Table<K, T>> {
 | 
				
			||||||
 | 
							return this.schemaLock.run(async () => {
 | 
				
			||||||
			if(typeof table == 'string') table = { name: table };
 | 
								if(typeof table == 'string') table = { name: table };
 | 
				
			||||||
			const conn = await this.connection;
 | 
								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();
 | 
									conn.close();
 | 
				
			||||||
			Object.assign(this, new Database(this.database, [...this.tables, table], (this.version ?? 0) + 1));
 | 
									this.connection = newDb.connection;
 | 
				
			||||||
 | 
									await this.connection;
 | 
				
			||||||
 | 
									Object.assign(this, newDb);
 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
			return this.table<K, T>(table.name);
 | 
								return this.table<K, T>(table.name);
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	async deleteTable(table: string | TableOptions): Promise<void> {
 | 
						async deleteTable(table: string | TableOptions): Promise<void> {
 | 
				
			||||||
 | 
							return this.schemaLock.run(async () => {
 | 
				
			||||||
			if(typeof table == 'string') table = { name: table };
 | 
								if(typeof table == 'string') table = { name: table };
 | 
				
			||||||
			if(!this.includes(table.name)) return;
 | 
								if(!this.includes(table.name)) return;
 | 
				
			||||||
			const conn = await this.connection;
 | 
								const conn = await this.connection;
 | 
				
			||||||
 | 
								const newDb = new Database(this.database, this.tables.filter(t => t.name != (<TableOptions>table).name), (this.version ?? 0) + 1);
 | 
				
			||||||
			conn.close();
 | 
								conn.close();
 | 
				
			||||||
		Object.assign(this, new Database(this.database, this.tables.filter(t => t.name != table.name), (this.version ?? 0) + 1));
 | 
								this.connection = newDb.connection;
 | 
				
			||||||
 | 
								await this.connection;
 | 
				
			||||||
 | 
								Object.assign(this, newDb);
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	includes(name: any): boolean {
 | 
						includes(name: any): boolean {
 | 
				
			||||||
@@ -96,12 +139,12 @@ export class Table<K extends IDBValidKey = any, T = any> {
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	async tx<R>(table: string, fn: (store: IDBObjectStore) => IDBRequest, readonly = false): Promise<R> {
 | 
						async tx<R>(table: string, fn: (store: IDBObjectStore) => IDBRequest, readonly = false): Promise<R> {
 | 
				
			||||||
 | 
							await this.database.waitForUpgrade();
 | 
				
			||||||
		const db = await this.database.connection;
 | 
							const db = await this.database.connection;
 | 
				
			||||||
		const tx = db.transaction(table, readonly ? 'readonly' : 'readwrite');
 | 
							const tx = db.transaction(table, readonly ? 'readonly' : 'readwrite');
 | 
				
			||||||
		const store = tx.objectStore(table);
 | 
					 | 
				
			||||||
		return new Promise<R>((resolve, reject) => {
 | 
							return new Promise<R>((resolve, reject) => {
 | 
				
			||||||
			const request = fn(store);
 | 
								const request = fn(tx.objectStore(table));
 | 
				
			||||||
			request.onsuccess = () => resolve(request.result as R); // ✅ explicit cast
 | 
								request.onsuccess = () => resolve(request.result as R);
 | 
				
			||||||
			request.onerror = () => reject(request.error);
 | 
								request.onerror = () => reject(request.error);
 | 
				
			||||||
		});
 | 
							});
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -138,8 +181,11 @@ export class Table<K extends IDBValidKey = any, T = any> {
 | 
				
			|||||||
		return this.tx(this.name, store => store.getAllKeys(), true);
 | 
							return this.tx(this.name, store => store.getAllKeys(), true);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	put(key: K, value: T): Promise<void> {
 | 
						put(value: T, key?: string): Promise<void> {
 | 
				
			||||||
		return this.tx(this.name, store => store.put(value, key));
 | 
							return this.tx(this.name, store => {
 | 
				
			||||||
 | 
								if(store.keyPath) return store.put(value);
 | 
				
			||||||
 | 
								return store.put(value, key);
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	read(): Promise<T[]>;
 | 
						read(): Promise<T[]>;
 | 
				
			||||||
@@ -149,8 +195,9 @@ export class Table<K extends IDBValidKey = any, T = any> {
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	set(value: T, key?: K): Promise<void> {
 | 
						set(value: T, key?: K): Promise<void> {
 | 
				
			||||||
		if(!key && !(<any>value)[this.key]) return this.add(value);
 | 
							if(key) (<any>value)[this.key] = key;
 | 
				
			||||||
		return this.put(key || (<any>value)[this.key], value);
 | 
							if(!(<any>value)[this.key]) return this.add(value);
 | 
				
			||||||
 | 
							return this.put(value);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	update = this.set;
 | 
						update = this.set;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,5 +1,5 @@
 | 
				
			|||||||
import {makeArray} from './array.ts';
 | 
					import {makeArray} from './array.ts';
 | 
				
			||||||
import {JSONAttemptParse} from './objects.ts';
 | 
					import {JSONAttemptParse} from './json.ts';
 | 
				
			||||||
import {PromiseProgress} from './promise-progress';
 | 
					import {PromiseProgress} from './promise-progress';
 | 
				
			||||||
import {formatDate} from './time.ts';
 | 
					import {formatDate} from './time.ts';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -78,7 +78,7 @@ export class Http {
 | 
				
			|||||||
		if(!this.url && !opts.url) throw new Error('URL needs to be set');
 | 
							if(!this.url && !opts.url) throw new Error('URL needs to be set');
 | 
				
			||||||
		let url = opts.url?.startsWith('http') ? opts.url : (this.url || '') + (opts.url || '');
 | 
							let url = opts.url?.startsWith('http') ? opts.url : (this.url || '') + (opts.url || '');
 | 
				
			||||||
		url = url.replaceAll(/([^:]\/)\/+/g, '$1');
 | 
							url = url.replaceAll(/([^:]\/)\/+/g, '$1');
 | 
				
			||||||
		if(opts.fragment) url.includes('#') ? url.replace(/#.*(\?|\n)/g, (match, arg1) => `#${opts.fragment}${arg1}`) : `${url}#${opts.fragment}`;
 | 
							if(opts.fragment) url.includes('#') ? url.replace(/#.*([?\n])/g, (match, arg1) => `#${opts.fragment}${arg1}`) : `${url}#${opts.fragment}`;
 | 
				
			||||||
		if(opts.query) {
 | 
							if(opts.query) {
 | 
				
			||||||
			const q = Array.isArray(opts.query) ? opts.query :
 | 
								const q = Array.isArray(opts.query) ? opts.query :
 | 
				
			||||||
				Object.keys(opts.query).map(k => ({key: k, value: (<any>opts.query)[k]}))
 | 
									Object.keys(opts.query).map(k => ({key: k, value: (<any>opts.query)[k]}))
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -9,6 +9,7 @@ export * from './files';
 | 
				
			|||||||
export * from './emitter';
 | 
					export * from './emitter';
 | 
				
			||||||
export * from './errors';
 | 
					export * from './errors';
 | 
				
			||||||
export * from './http';
 | 
					export * from './http';
 | 
				
			||||||
 | 
					export * from './json';
 | 
				
			||||||
export * from './jwt';
 | 
					export * from './jwt';
 | 
				
			||||||
export * from './logger';
 | 
					export * from './logger';
 | 
				
			||||||
export * from './math';
 | 
					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.
 | 
					 * Creates a JSON Web Token (JWT) using the provided payload.
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,5 +1,5 @@
 | 
				
			|||||||
import {TypedEmitter, TypedEvents} from './emitter';
 | 
					import {TypedEmitter, TypedEvents} from './emitter';
 | 
				
			||||||
import {JSONSanitize} from './objects.ts';
 | 
					import {JSONSanitize} from './json.ts';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const CliEffects = {
 | 
					export const CliEffects = {
 | 
				
			||||||
	CLEAR: "\x1b[0m",
 | 
						CLEAR: "\x1b[0m",
 | 
				
			||||||
@@ -42,6 +42,8 @@ export const CliBackground = {
 | 
				
			|||||||
	GREY: "\x1b[100m",
 | 
						GREY: "\x1b[100m",
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export type LogLevels = 'debug' | 'log' | 'info' | 'warn' | 'error';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export enum LOG_LEVEL {
 | 
					export enum LOG_LEVEL {
 | 
				
			||||||
	ERROR = 0,
 | 
						ERROR = 0,
 | 
				
			||||||
	WARN = 1,
 | 
						WARN = 1,
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										14
									
								
								src/math.ts
									
									
									
									
									
								
							
							
						
						
									
										14
									
								
								src/math.ts
									
									
									
									
									
								
							@@ -48,3 +48,17 @@ export function fracToDec(frac: string) {
 | 
				
			|||||||
	split = (<string>split.pop()).split('/');
 | 
						split = (<string>split.pop()).split('/');
 | 
				
			||||||
	return whole + (Number(split[0]) / Number(split[1]));
 | 
						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]}`;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										54
									
								
								src/misc.ts
									
									
									
									
									
								
							
							
						
						
									
										54
									
								
								src/misc.ts
									
									
									
									
									
								
							@@ -1,9 +1,50 @@
 | 
				
			|||||||
 | 
					import {LogLevels} from './logger.ts';
 | 
				
			||||||
import {PathEvent} from './path-events.ts';
 | 
					import {PathEvent} from './path-events.ts';
 | 
				
			||||||
import {md5} from './string';
 | 
					import {md5} from './string';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Compare version numbers
 | 
				
			||||||
 | 
					 * @param {string} target
 | 
				
			||||||
 | 
					 * @param {string} vs
 | 
				
			||||||
 | 
					 * @return {number} -1 = target is lower, 0 = equal, 1 = higher
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					export function compareVersions(target: string, vs: string): -1 | 0 | 1 {
 | 
				
			||||||
 | 
						const [tMajor, tMinor, tPatch] = target.split('.').map(v => +v.replace(/[^0-9]/g, ''));
 | 
				
			||||||
 | 
						const [vMajor, vMinor, vPatch] = vs.split('.').map(v => +v.replace(/[^0-9]/g, ''));
 | 
				
			||||||
 | 
						return (tMajor > vMajor || tMinor > vMinor || tPatch > vPatch) ? 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
 | 
					 * Escape any regex special characters to avoid misinterpretation during search
 | 
				
			||||||
 *
 | 
					 | 
				
			||||||
 * @param {string} value String which should be escaped
 | 
					 * @param {string} value String which should be escaped
 | 
				
			||||||
 * @return {string} New escaped sequence
 | 
					 * @return {string} New escaped sequence
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
@@ -47,6 +88,17 @@ export function ipV6ToV4(ip: string) {
 | 
				
			|||||||
	return ipv4;
 | 
						return ipv4;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Check if IP is reserved, e.g. localhost, private IPs, etc.
 | 
				
			||||||
 | 
					 * @param {string} ip
 | 
				
			||||||
 | 
					 * @returns {boolean}
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					export function reservedIp(ip: string): boolean {
 | 
				
			||||||
 | 
						if(ip == 'localhost' || ip == '127.0.0.1') return true;
 | 
				
			||||||
 | 
						return /\b(10\.(?:[0-9]{1,3}\.){2}[0-9]{1,3})\b|\b(172\.(?:1[6-9]|2[0-9]|3[0-1])\.(?:[0-9]{1,3}\.)[0-9]{1,3})\b|\b(192\.168\.(?:[0-9]{1,3}\.)[0-9]{1,3})\b/.test(ip);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 * Represents a function that listens for events and handles them accordingly.
 | 
					 * Represents a function that listens for events and handles them accordingly.
 | 
				
			||||||
 *
 | 
					 *
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										142
									
								
								src/objects.ts
									
									
									
									
									
								
							
							
						
						
									
										142
									
								
								src/objects.ts
									
									
									
									
									
								
							@@ -1,3 +1,54 @@
 | 
				
			|||||||
 | 
					import {JSONSanitize} from './json.ts';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export type Delta = { [key: string]: any | Delta | null };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Applies deltas to `target`.
 | 
				
			||||||
 | 
					 * @param base starting point
 | 
				
			||||||
 | 
					 * @param deltas List of deltas to apply
 | 
				
			||||||
 | 
					 * @returns Mutated target
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					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;
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						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 updated - Modified object
 | 
				
			||||||
 | 
					 * @returns New changes
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					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 Object.keys(delta).length === 0 ? {} : delta;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 * Removes any null values from an object in-place
 | 
					 * Removes any null values from an object in-place
 | 
				
			||||||
 *
 | 
					 *
 | 
				
			||||||
@@ -177,19 +228,51 @@ export function includes(target: any, values: any, allowMissing = false): boolea
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 * Deep check if two objects are equal
 | 
					 * Deep check if two items are equal.
 | 
				
			||||||
 | 
					 * Handles primitives, objects, arrays, functions, Date, RegExp, and circular references.
 | 
				
			||||||
 *
 | 
					 *
 | 
				
			||||||
 * @param {any} a - first item to compare
 | 
					 * @param {any} a - first item to compare
 | 
				
			||||||
 * @param {any} b - second item to compare
 | 
					 * @param {any} b - second item to compare
 | 
				
			||||||
 | 
					 * @param {WeakMap<object, object>} [seen] - Internal parameter to track circular references
 | 
				
			||||||
 * @returns {boolean} True if they match
 | 
					 * @returns {boolean} True if they match
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
export function isEqual(a: any, b: any): boolean {
 | 
					export function isEqual(a: any, b: any, seen = new WeakMap<object, object>()): boolean {
 | 
				
			||||||
	const ta = typeof a, tb = typeof b;
 | 
						// Simple cases
 | 
				
			||||||
	if((ta != 'object' || a == null) || (tb != 'object' || b == null))
 | 
						if(a === b) return true;
 | 
				
			||||||
		return ta == 'function' && tb == 'function' ? a.toString() == b.toString() : a === b;
 | 
						if(a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
 | 
				
			||||||
	const keys = Object.keys(a);
 | 
						if(a instanceof RegExp && b instanceof RegExp) return a.source === b.source && a.flags === b.flags;
 | 
				
			||||||
	if(keys.length != Object.keys(b).length) return false;
 | 
					
 | 
				
			||||||
	return Object.keys(a).every(key => isEqual(a[key], b[key]));
 | 
						// Null checks
 | 
				
			||||||
 | 
						if(typeof a !== 'object' || a === null || typeof b !== 'object' || b === null) {
 | 
				
			||||||
 | 
							if(Number.isNaN(a) && Number.isNaN(b)) return true;
 | 
				
			||||||
 | 
							if(typeof a === 'function' && typeof b === 'function') return a.toString() === b.toString()
 | 
				
			||||||
 | 
							return false;
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Circular references
 | 
				
			||||||
 | 
						if(seen.has(a)) return seen.get(a) === b;
 | 
				
			||||||
 | 
						seen.set(a, b);
 | 
				
			||||||
 | 
						const isArrayA = Array.isArray(a);
 | 
				
			||||||
 | 
						const isArrayB = Array.isArray(b);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Array checks
 | 
				
			||||||
 | 
						if(isArrayA && isArrayB) {
 | 
				
			||||||
 | 
							if(a.length !== b.length) return false;
 | 
				
			||||||
 | 
							for(let i = 0; i < a.length; i++) {
 | 
				
			||||||
 | 
								if(!isEqual(a[i], b[i], seen)) return false;
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							return true;
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if(isArrayA !== isArrayB) return false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Key & value deep comparison
 | 
				
			||||||
 | 
						const keysA = Object.keys(a);
 | 
				
			||||||
 | 
						const keysB = Object.keys(b);
 | 
				
			||||||
 | 
						if(keysA.length !== keysB.length) return false;
 | 
				
			||||||
 | 
						for(const key of keysA) {
 | 
				
			||||||
 | 
							if(!Object.prototype.hasOwnProperty.call(b, key) || !isEqual(a[key], b[key], seen)) return false;
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return true;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
@@ -212,41 +295,12 @@ export function mixin(target: any, constructors: any[]) {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 * Parse JSON but return the original string if it fails
 | 
					 * Run a map function on each property
 | 
				
			||||||
 *
 | 
					 * @param obj Object that will be iterated
 | 
				
			||||||
 * @param {any} json JSON string to parse
 | 
					 * @param {(key: string, value: any) => any} fn Transformer function - receives key & value
 | 
				
			||||||
 * @return {string | T} Object if successful, original string otherwise
 | 
					 * @returns {{}}
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
export function JSONAttemptParse<T1, T2>(json: T2): T1 | T2 {
 | 
					export function objectMap<T>(obj: any, fn: (key: string, value: any) => any): T {
 | 
				
			||||||
	try { return JSON.parse(<any>json); }
 | 
						return <any>Object.entries(obj).map(([key, value]: [string, any]) => [key, fn(key, value)])
 | 
				
			||||||
	catch { return json; }
 | 
							.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {});
 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * 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);
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -71,9 +71,12 @@ export class PathEvent {
 | 
				
			|||||||
	/** List of methods */
 | 
						/** List of methods */
 | 
				
			||||||
	methods!: ASet<Method>;
 | 
						methods!: ASet<Method>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						/** Internal cache for PathEvent instances to avoid redundant parsing */
 | 
				
			||||||
 | 
						private static pathEventCache: Map<string, PathEvent> = new Map();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	/** All/Wildcard specified */
 | 
						/** All/Wildcard specified */
 | 
				
			||||||
	get all(): boolean { return this.methods.has('*') }
 | 
						get all(): boolean { return this.methods.has('*') }
 | 
				
			||||||
	set all(v: boolean) { v ? new ASet<Method>(['*']) : this.methods.delete('*'); }
 | 
						set all(v: boolean) { v ? this.methods = new ASet<Method>(['*']) : this.methods.delete('*'); }
 | 
				
			||||||
	/** None specified */
 | 
						/** None specified */
 | 
				
			||||||
	get none(): boolean { return this.methods.has('n') }
 | 
						get none(): boolean { return this.methods.has('n') }
 | 
				
			||||||
	set none(v: boolean) { v ? this.methods = new ASet<Method>(['n']) : this.methods.delete('n'); }
 | 
						set none(v: boolean) { v ? this.methods = new ASet<Method>(['n']) : this.methods.delete('n'); }
 | 
				
			||||||
@@ -91,10 +94,20 @@ export class PathEvent {
 | 
				
			|||||||
	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(e: string | PathEvent) {
 | 
						constructor(e: string | PathEvent) {
 | 
				
			||||||
		if(typeof e == 'object') return Object.assign(this, e);
 | 
							if(typeof e == 'object') {
 | 
				
			||||||
 | 
								Object.assign(this, e);
 | 
				
			||||||
 | 
								return;
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							// Check cache first
 | 
				
			||||||
 | 
							if (PathEvent.pathEventCache.has(e)) {
 | 
				
			||||||
 | 
								Object.assign(this, PathEvent.pathEventCache.get(e)!);
 | 
				
			||||||
 | 
								return;
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		let [p, scope, method] = e.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 = '';
 | 
				
			||||||
			method = '*';
 | 
								method = '*';
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
@@ -104,6 +117,14 @@ export class PathEvent {
 | 
				
			|||||||
		this.fullPath = `${this.module}${this.module && this.path ? '/' : ''}${this.path}`;
 | 
							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(''));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							// Store in cache
 | 
				
			||||||
 | 
							PathEvent.pathEventCache.set(e, this);
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						/** Clear the cache of all PathEvents */
 | 
				
			||||||
 | 
						static clearCache(): void {
 | 
				
			||||||
 | 
							PathEvent.pathEventCache.clear();
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	/**
 | 
						/**
 | 
				
			||||||
@@ -115,7 +136,7 @@ export class PathEvent {
 | 
				
			|||||||
	 */
 | 
						 */
 | 
				
			||||||
	static combine(...paths: (string | PathEvent)[]): PathEvent {
 | 
						static combine(...paths: (string | PathEvent)[]): PathEvent {
 | 
				
			||||||
		let hitNone = false;
 | 
							let hitNone = false;
 | 
				
			||||||
		const combined = paths.map(p => new PathEvent(p))
 | 
							const combined = paths.map(p => p instanceof PathEvent ? p : new PathEvent(p))
 | 
				
			||||||
			.toSorted((p1, p2) => {
 | 
								.toSorted((p1, p2) => {
 | 
				
			||||||
				const l1 = p1.fullPath.length, l2 = p2.fullPath.length;
 | 
									const l1 = p1.fullPath.length, l2 = p2.fullPath.length;
 | 
				
			||||||
				return l1 < l2 ? 1 : (l1 > l2 ? -1 : 0);
 | 
									return l1 < l2 ? 1 : (l1 > l2 ? -1 : 0);
 | 
				
			||||||
@@ -124,10 +145,9 @@ export class PathEvent {
 | 
				
			|||||||
				if(p.none) hitNone = true;
 | 
									if(p.none) hitNone = true;
 | 
				
			||||||
				if(!acc) return p;
 | 
									if(!acc) return p;
 | 
				
			||||||
				if(hitNone) return acc;
 | 
									if(hitNone) return acc;
 | 
				
			||||||
				acc.methods = [...acc.methods, ...p.methods];
 | 
									acc.methods = new ASet([...acc.methods, ...p.methods]);
 | 
				
			||||||
				return acc;
 | 
									return acc;
 | 
				
			||||||
			}, <any>null);
 | 
								}, <any>null);
 | 
				
			||||||
		combined.methods = new ASet<Method>(combined.methods);
 | 
					 | 
				
			||||||
		return combined;
 | 
							return combined;
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -139,11 +159,12 @@ export class PathEvent {
 | 
				
			|||||||
	 * @return {boolean} Whether there is any overlap
 | 
						 * @return {boolean} Whether there is any overlap
 | 
				
			||||||
	 */
 | 
						 */
 | 
				
			||||||
	static filter(target: string | PathEvent | (string | PathEvent)[], ...filter: (string | PathEvent)[]): PathEvent[] {
 | 
						static filter(target: string | PathEvent | (string | PathEvent)[], ...filter: (string | PathEvent)[]): PathEvent[] {
 | 
				
			||||||
		const parsedTarget = makeArray(target).map(pe => new PathEvent(pe));
 | 
							const parsedTarget = makeArray(target).map(pe => pe instanceof PathEvent ? pe : new PathEvent(pe));
 | 
				
			||||||
		const parsedFilter = makeArray(filter).map(pe => new PathEvent(pe));
 | 
							const parsedFilter = makeArray(filter).map(pe => pe instanceof PathEvent ? pe : new PathEvent(pe));
 | 
				
			||||||
		return parsedTarget.filter(t => !!parsedFilter.find(r => {
 | 
							return parsedTarget.filter(t => !!parsedFilter.find(r => {
 | 
				
			||||||
			const wildcard = r.fullPath == '*' || t.fullPath == '*';
 | 
								const wildcard = r.fullPath == '*' || t.fullPath == '*';
 | 
				
			||||||
			const p1 = r.fullPath.slice(0, r.fullPath.indexOf('*')), p2 = t.fullPath.slice(0, t.fullPath.indexOf('*'))
 | 
								const p1 = r.fullPath.includes('*') ? r.fullPath.slice(0, r.fullPath.indexOf('*')) : r.fullPath;
 | 
				
			||||||
 | 
								const p2 = t.fullPath.includes('*') ? t.fullPath.slice(0, t.fullPath.indexOf('*')) : t.fullPath;
 | 
				
			||||||
			const scope = p1.startsWith(p2) || p2.startsWith(p1);
 | 
								const scope = p1.startsWith(p2) || p2.startsWith(p1);
 | 
				
			||||||
			const methods = r.all || t.all || r.methods.intersection(t.methods).length;
 | 
								const methods = r.all || t.all || r.methods.intersection(t.methods).length;
 | 
				
			||||||
			return (wildcard || scope) && methods;
 | 
								return (wildcard || scope) && methods;
 | 
				
			||||||
@@ -158,12 +179,13 @@ export class PathEvent {
 | 
				
			|||||||
	 * @return {boolean} Whether there is any overlap
 | 
						 * @return {boolean} Whether there is any overlap
 | 
				
			||||||
	 */
 | 
						 */
 | 
				
			||||||
	static has(target: string | PathEvent | (string | PathEvent)[], ...has: (string | PathEvent)[]): boolean {
 | 
						static has(target: string | PathEvent | (string | PathEvent)[], ...has: (string | PathEvent)[]): boolean {
 | 
				
			||||||
		const parsedTarget = makeArray(target).map(pe => new PathEvent(pe));
 | 
							const parsedTarget = makeArray(target).map(pe => pe instanceof PathEvent ? pe : new PathEvent(pe));
 | 
				
			||||||
		const parsedRequired = makeArray(has).map(pe => new PathEvent(pe));
 | 
							const parsedRequired = makeArray(has).map(pe => pe instanceof PathEvent ? pe : new PathEvent(pe));
 | 
				
			||||||
		return !!parsedRequired.find(r => !!parsedTarget.find(t => {
 | 
							return !!parsedRequired.find(r => !!parsedTarget.find(t => {
 | 
				
			||||||
			const wildcard = r.fullPath == '*' || t.fullPath == '*';
 | 
								const wildcard = r.fullPath == '*' || t.fullPath == '*';
 | 
				
			||||||
			const p1 = r.fullPath.slice(0, r.fullPath.indexOf('*')), p2 = t.fullPath.slice(0, t.fullPath.indexOf('*'))
 | 
								const p1 = r.fullPath.includes('*') ? r.fullPath.slice(0, r.fullPath.indexOf('*')) : r.fullPath;
 | 
				
			||||||
			const scope = p1.startsWith(p2);
 | 
								const p2 = t.fullPath.includes('*') ? t.fullPath.slice(0, t.fullPath.indexOf('*')) : t.fullPath;
 | 
				
			||||||
 | 
								const scope = p1.startsWith(p2); // Note: Original had || p2.startsWith(p1) here, but has implies target has required.
 | 
				
			||||||
			const methods = r.all || t.all || r.methods.intersection(t.methods).length;
 | 
								const methods = r.all || t.all || r.methods.intersection(t.methods).length;
 | 
				
			||||||
			return (wildcard || scope) && methods;
 | 
								return (wildcard || scope) && methods;
 | 
				
			||||||
		}));
 | 
							}));
 | 
				
			||||||
@@ -293,7 +315,7 @@ export class PathEventEmitter implements IPathEventEmitter{
 | 
				
			|||||||
	constructor(public readonly prefix: string = '') { }
 | 
						constructor(public readonly prefix: string = '') { }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	emit(event: Event, ...args: any[]) {
 | 
						emit(event: Event, ...args: any[]) {
 | 
				
			||||||
		const parsed = PE`${this.prefix}/${event}`;
 | 
							const parsed = event instanceof PathEvent ? event : new PathEvent(`${this.prefix}/${event}`);
 | 
				
			||||||
		this.listeners.filter(l => PathEvent.has(l[0], parsed))
 | 
							this.listeners.filter(l => PathEvent.has(l[0], parsed))
 | 
				
			||||||
			.forEach(async l => l[1](parsed, ...args));
 | 
								.forEach(async l => l[1](parsed, ...args));
 | 
				
			||||||
	};
 | 
						};
 | 
				
			||||||
@@ -303,10 +325,13 @@ export class PathEventEmitter implements IPathEventEmitter{
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	on(event: Event | Event[], listener: PathListener): PathUnsubscribe {
 | 
						on(event: Event | Event[], listener: PathListener): PathUnsubscribe {
 | 
				
			||||||
		makeArray(event).forEach(e => this.listeners.push([
 | 
							makeArray(event).forEach(e => {
 | 
				
			||||||
			new PathEvent(`${this.prefix}/${e}`),
 | 
								if(typeof e == 'string' && e[0] == '*' && this.prefix) e = e.slice(1);
 | 
				
			||||||
 | 
								this.listeners.push([
 | 
				
			||||||
 | 
									e instanceof PathEvent ? e : new PathEvent(`${this.prefix}/${e}`),
 | 
				
			||||||
				listener
 | 
									listener
 | 
				
			||||||
		]));
 | 
								])
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
		return () => this.off(listener);
 | 
							return () => this.off(listener);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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.
 | 
					 * Filters an array of objects based on a search term and optional regex checking.
 | 
				
			||||||
@@ -13,8 +14,8 @@ export function search(rows: any[], search: string, regex?: boolean, transform:
 | 
				
			|||||||
	if(!rows) return [];
 | 
						if(!rows) return [];
 | 
				
			||||||
	return rows.filter(r => {
 | 
						return rows.filter(r => {
 | 
				
			||||||
		// Empty search
 | 
							// Empty search
 | 
				
			||||||
		const value = transform(r);
 | 
					 | 
				
			||||||
		if(!search) return true;
 | 
							if(!search) return true;
 | 
				
			||||||
 | 
							const value = transform(r);
 | 
				
			||||||
		// Regex search
 | 
							// Regex search
 | 
				
			||||||
		if(regex) {
 | 
							if(regex) {
 | 
				
			||||||
			return !!Object.values(value).filter((v: any) => {
 | 
								return !!Object.values(value).filter((v: any) => {
 | 
				
			||||||
@@ -22,7 +23,7 @@ export function search(rows: any[], search: string, regex?: boolean, transform:
 | 
				
			|||||||
				catch { return false; }
 | 
									catch { return false; }
 | 
				
			||||||
			}).length
 | 
								}).length
 | 
				
			||||||
		} else {
 | 
							} else {
 | 
				
			||||||
			return logicTest(r, search);
 | 
								return logicTest(value, search);
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	});
 | 
						});
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -39,6 +40,8 @@ export function logicTest(target: object, condition: string): boolean {
 | 
				
			|||||||
			case '=':
 | 
								case '=':
 | 
				
			||||||
			case '==': return a == b;
 | 
								case '==': return a == b;
 | 
				
			||||||
			case '!=': return a != b;
 | 
								case '!=': return a != b;
 | 
				
			||||||
 | 
								case '+=': return a?.toString().includes(b);
 | 
				
			||||||
 | 
								case '-=': return !a?.toString().includes(b);
 | 
				
			||||||
			case '>': return a > b;
 | 
								case '>': return a > b;
 | 
				
			||||||
			case '>=': return a >= b;
 | 
								case '>=': return a >= b;
 | 
				
			||||||
			case '<': return a < b;
 | 
								case '<': return a < b;
 | 
				
			||||||
@@ -53,7 +56,7 @@ export function logicTest(target: object, condition: string): boolean {
 | 
				
			|||||||
		const and = p.split('&&').map(p => p.trim()).filter(p => !!p);
 | 
							const and = p.split('&&').map(p => p.trim()).filter(p => !!p);
 | 
				
			||||||
		return and.filter(p => {
 | 
							return and.filter(p => {
 | 
				
			||||||
			// Boolean operator
 | 
								// Boolean operator
 | 
				
			||||||
			const prop = /(\S+)\s*(==?|!=|>=|>|<=|<)\s*(\S+)/g.exec(p);
 | 
								const prop = /(\S+)\s*(==?|!=|\+=|-=|>=|>|<=|<)\s*(\S+)/g.exec(p);
 | 
				
			||||||
			if(prop) {
 | 
								if(prop) {
 | 
				
			||||||
				const key = Object.keys(target).find(k => k.toLowerCase() == prop[1].toLowerCase());
 | 
									const key = Object.keys(target).find(k => k.toLowerCase() == prop[1].toLowerCase());
 | 
				
			||||||
				return evalBoolean(dotNotation<any>(target, key || prop[1]),  prop[2], JSONAttemptParse(prop[3]));
 | 
									return evalBoolean(dotNotation<any>(target, key || prop[1]),  prop[2], JSONAttemptParse(prop[3]));
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -43,6 +43,24 @@ export function formatBytes(bytes: number, decimals = 2) {
 | 
				
			|||||||
	return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i];
 | 
						return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i];
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Convert milliseconds to human-readable duration
 | 
				
			||||||
 | 
					 * @param {string} ms milliseconds
 | 
				
			||||||
 | 
					 * @param {boolean} short Use unit initial instead of word
 | 
				
			||||||
 | 
					 * @return {string} formated duration
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					export function formatMs(ms: number, short = false): string {
 | 
				
			||||||
 | 
						if (isNaN(ms) || ms < 0) return "Invalid input";
 | 
				
			||||||
 | 
						const seconds = ms / 1000;
 | 
				
			||||||
 | 
						const minutes = seconds / 60;
 | 
				
			||||||
 | 
						const hours = minutes / 60;
 | 
				
			||||||
 | 
						const days = hours / 24;
 | 
				
			||||||
 | 
						if (days >= 1) return `${days.toFixed(1)} ${short ? 'd' : 'days'}`;
 | 
				
			||||||
 | 
						else if (hours >= 1) return `${hours.toFixed(1)} ${short ? 'h' : 'hours'}`;
 | 
				
			||||||
 | 
						else if (minutes >= 1) return `${minutes.toFixed(1)} ${short ? 'm' : 'minutes'}`;
 | 
				
			||||||
 | 
						else return `${seconds.toFixed(1)} ${short ? 's' : 'seconds'}`;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 * Extract numbers from a string & create a formated phone number: +1 (123) 456-7890
 | 
					 * Extract numbers from a string & create a formated phone number: +1 (123) 456-7890
 | 
				
			||||||
 *
 | 
					 *
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										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
 | 
					 * 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 {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 {Date | number | string} date Date or timestamp, defaults to now
 | 
				
			||||||
 * @param tz Set timezone offset
 | 
					 * @param tz Set timezone offset
 | 
				
			||||||
 * @return {string} Formated date
 | 
					 * @return {string} Formated date
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
export function formatDate(format = 'YYYY-MM-DD H:mm', date: Date | number | string = new Date(), tz?: string | number): string {
 | 
					export function formatDate(format: string = 'YYYY-MM-DD H:mm', date: Date | number | string = new Date(), tz: string | number = 'local'): string {
 | 
				
			||||||
	const timezones = [
 | 
						if (typeof date === 'number' || typeof date === 'string') date = new Date(date);
 | 
				
			||||||
		['IDLW', -12],
 | 
						if (isNaN(date.getTime())) throw new Error('Invalid date input');
 | 
				
			||||||
		['SST', -11],
 | 
						const numericTz = typeof tz === 'number';
 | 
				
			||||||
		['HST', -10],
 | 
						const localTz = tz === 'local' || (!numericTz && tz.toLowerCase?.() === 'local');
 | 
				
			||||||
		['AKST', -9],
 | 
						const tzName = localTz ? Intl.DateTimeFormat().resolvedOptions().timeZone : numericTz ? 'UTC' : tz;
 | 
				
			||||||
		['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]
 | 
					 | 
				
			||||||
	];
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
	function adjustTz(date: Date, gmt: number) {
 | 
						if (!numericTz && tzName !== 'UTC') {
 | 
				
			||||||
		const currentOffset = date.getTimezoneOffset();
 | 
							try {
 | 
				
			||||||
		const adjustedOffset = gmt * 60;
 | 
								new Intl.DateTimeFormat('en-US', { timeZone: tzName }).format();
 | 
				
			||||||
		return new Date(date.getTime() + (adjustedOffset + currentOffset) * 60000);
 | 
							} catch {
 | 
				
			||||||
	}
 | 
								throw new Error(`Invalid timezone: ${tzName}`);
 | 
				
			||||||
 | 
					 | 
				
			||||||
	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`;
 | 
					 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	function tzOffset(offset: number) {
 | 
						let zonedDate = new Date(date);
 | 
				
			||||||
		const hours = ~~(offset / 60);
 | 
						let get: (fn: 'FullYear' | 'Month' | 'Date' | 'Day' | 'Hours' | 'Minutes' | 'Seconds' | 'Milliseconds') => number;
 | 
				
			||||||
		const minutes = offset % 60;
 | 
						const partsMap: Record<string, string> = {};
 | 
				
			||||||
		return (offset > 0 ? '-' : '') + `${hours}:${minutes.toString().padStart(2, '0')}`;
 | 
						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]);
 | 
						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);
 | 
						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
 | 
					 * Use in conjunction with `await` to pause an async script
 | 
				
			||||||
 *
 | 
					 *
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,15 +1,18 @@
 | 
				
			|||||||
import { Cache } from '../src';
 | 
					import { Cache } from '../src';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
describe('Cache', () => {
 | 
					describe('Cache', () => {
 | 
				
			||||||
	type TestItem = { id: string; value: string; };
 | 
						type TestItem = { id: string; value: string };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	let cache: Cache<string, TestItem>;
 | 
						let cache: Cache<string | symbol, TestItem>;
 | 
				
			||||||
	let storageMock: Storage;
 | 
						let storageMock: Storage;
 | 
				
			||||||
	let storageGetItemSpy: jest.SpyInstance;
 | 
						let storageGetItemSpy: jest.SpyInstance;
 | 
				
			||||||
	let storageSetItemSpy: jest.SpyInstance;
 | 
						let storageSetItemSpy: jest.SpyInstance;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	beforeEach(() => {
 | 
						beforeEach(() => {
 | 
				
			||||||
 | 
							jest.useFakeTimers();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		storageMock = {
 | 
							storageMock = {
 | 
				
			||||||
 | 
								constructor: { name: 'Storage' as any },
 | 
				
			||||||
			getItem: jest.fn(),
 | 
								getItem: jest.fn(),
 | 
				
			||||||
			setItem: jest.fn(),
 | 
								setItem: jest.fn(),
 | 
				
			||||||
			removeItem: jest.fn(),
 | 
								removeItem: jest.fn(),
 | 
				
			||||||
@@ -17,93 +20,175 @@ describe('Cache', () => {
 | 
				
			|||||||
			key: jest.fn(),
 | 
								key: jest.fn(),
 | 
				
			||||||
			length: 0,
 | 
								length: 0,
 | 
				
			||||||
		};
 | 
							};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							// Spies
 | 
				
			||||||
		storageGetItemSpy = jest.spyOn(storageMock, 'getItem');
 | 
							storageGetItemSpy = jest.spyOn(storageMock, 'getItem');
 | 
				
			||||||
		storageSetItemSpy = jest.spyOn(storageMock, 'setItem');
 | 
							storageSetItemSpy = jest.spyOn(storageMock, 'setItem');
 | 
				
			||||||
		cache = new Cache<string, TestItem>('id', {storage: storageMock, storageKey: 'cache'});
 | 
					
 | 
				
			||||||
		jest.clearAllMocks();
 | 
							cache = new Cache<string | symbol, TestItem>('id', {
 | 
				
			||||||
		jest.useFakeTimers();
 | 
								persistentStorage: { storage: storageMock, key: 'cache' },
 | 
				
			||||||
		});
 | 
							});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	test('should add and get an item', () => {
 | 
							jest.clearAllMocks();
 | 
				
			||||||
 | 
						});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						afterEach(() => {
 | 
				
			||||||
 | 
							jest.useRealTimers();
 | 
				
			||||||
 | 
						});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						it('adds and gets an item', () => {
 | 
				
			||||||
		const item = { id: '1', value: 'a' };
 | 
							const item = { id: '1', value: 'a' };
 | 
				
			||||||
		cache.add(item);
 | 
							cache.add(item);
 | 
				
			||||||
		expect(cache.get('1')).toEqual(item);
 | 
							expect(cache.get('1')).toEqual(item);
 | 
				
			||||||
	});
 | 
						});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	test('should not get an expired item when expired option not set', () => {
 | 
						it('skips expired items by default but fetches if requested', () => {
 | 
				
			||||||
		const item = {id: '1', value: 'a'};
 | 
							const item = { id: '2', value: 'b' };
 | 
				
			||||||
		cache.set('1', item);
 | 
							cache.set('2', item);
 | 
				
			||||||
		cache.options.expiryPolicy = 'keep';
 | 
							cache.options.expiryPolicy = 'keep';
 | 
				
			||||||
		cache.expire('1');
 | 
							cache.expire('2');
 | 
				
			||||||
		expect(cache.get('1')).toBeNull();
 | 
							expect(cache.get('2')).toBeNull();
 | 
				
			||||||
		expect(cache.get('1', true)).toEqual({...item, _expired: true});
 | 
							expect(cache.get('2', true)).toEqual({ ...item, _expired: true });
 | 
				
			||||||
	});
 | 
						});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	test('should set and get via property access (proxy)', () => {
 | 
						it('supports property access and setting via Proxy', () => {
 | 
				
			||||||
		(cache as any)['2'] = {id: '2', value: 'b'};
 | 
							(cache as any)['3'] = { id: '3', value: 'c' };
 | 
				
			||||||
		expect((cache as any)['2']).toEqual({id: '2', value: 'b'});
 | 
							expect((cache as any)['3']).toEqual({ id: '3', value: 'c' });
 | 
				
			||||||
 | 
							expect(cache.get('3')).toEqual({ id: '3', value: 'c' });
 | 
				
			||||||
	});
 | 
						});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	test('should remove an item', () => {
 | 
						it('removes an item and persists', () => {
 | 
				
			||||||
		cache.set('1', {id: '1', value: 'a'});
 | 
							cache.add({ id: '4', value: 'd' });
 | 
				
			||||||
		cache.delete('1');
 | 
							cache.delete('4');
 | 
				
			||||||
		expect(cache.get('1')).toBeNull();
 | 
							expect(cache.get('4')).toBeNull();
 | 
				
			||||||
		expect(storageSetItemSpy).toHaveBeenCalled();
 | 
							expect(storageSetItemSpy).toHaveBeenCalled();
 | 
				
			||||||
	});
 | 
						});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	test('should clear the cache', () => {
 | 
						it('clears the cache & cancels TTL timers', () => {
 | 
				
			||||||
		cache.add({id: '1', value: 'a'});
 | 
							cache.set('1', { id: '1', value: 'x' }, 1);
 | 
				
			||||||
		cache.clear();
 | 
							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);
 | 
							expect(cache.complete).toBe(false);
 | 
				
			||||||
	});
 | 
						});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	test('should add multiple items and mark complete', () => {
 | 
						it('bulk adds, marks complete', () => {
 | 
				
			||||||
		const rows = [
 | 
							const items = [
 | 
				
			||||||
			{ id: 'a', value: '1' },
 | 
								{ id: 'a', value: '1' },
 | 
				
			||||||
			{ id: 'b', value: '2' },
 | 
								{ id: 'b', value: '2' },
 | 
				
			||||||
		];
 | 
							];
 | 
				
			||||||
		cache.addAll(rows);
 | 
							cache.addAll(items);
 | 
				
			||||||
		expect(cache.all().length).toBe(2);
 | 
							expect(cache.all().length).toBe(2);
 | 
				
			||||||
		expect(cache.complete).toBe(true);
 | 
							expect(cache.complete).toBe(true);
 | 
				
			||||||
	});
 | 
						});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	test('should return all, keys, entries, and map', () => {
 | 
						it('returns correct keys, entries, map, and values()', () => {
 | 
				
			||||||
		cache.add({id: '1', value: 'a'});
 | 
							cache.add({ id: 'x', value: 'foo' });
 | 
				
			||||||
		cache.add({id: '2', value: 'b'});
 | 
							cache.add({ id: 'y', value: 'bar' });
 | 
				
			||||||
		expect(cache.all().length).toBe(2);
 | 
							expect(cache.keys().sort()).toEqual(['x', 'y']);
 | 
				
			||||||
		expect(cache.keys().sort()).toEqual(['1', '2']);
 | 
							expect(cache.entries().map(e => e[0]).sort()).toEqual(['x', 'y']);
 | 
				
			||||||
		expect(cache.entries().length).toBe(2);
 | 
							const m = cache.map();
 | 
				
			||||||
		expect(Object.keys(cache.map())).toContain('1');
 | 
							expect(Object.keys(m)).toEqual(expect.arrayContaining(['x', 'y']));
 | 
				
			||||||
		expect(Object.keys(cache.map())).toContain('2');
 | 
							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']);
 | 
				
			||||||
	});
 | 
						});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// test('should expire/delete items after TTL', () => {
 | 
						it('persists and restores from storage', () => {
 | 
				
			||||||
	// 	jest.useFakeTimers();
 | 
							(storageMock.getItem as jest.Mock).mockReturnValueOnce(
 | 
				
			||||||
	// 	cache = new Cache<string, TestItem>('id', {ttl: 0.1});
 | 
								JSON.stringify({ z: { id: 'z', value: 'from-storage' } }),
 | 
				
			||||||
	// 	cache.add({id: '3', value: 'x'});
 | 
							);
 | 
				
			||||||
	// 	jest.advanceTimersByTime(250);
 | 
							const c = new Cache<string, TestItem>('id', {
 | 
				
			||||||
	// 	expect(cache.get('3')).toBeNull();
 | 
								persistentStorage: { storage: storageMock, key: 'cache' },
 | 
				
			||||||
	// });
 | 
							});
 | 
				
			||||||
 | 
							expect(c.get('z')).toEqual({ id: 'z', value: 'from-storage' });
 | 
				
			||||||
	test('should persist and restore from storage', () => {
 | 
							// ensure it used the right storage key
 | 
				
			||||||
		(storageMock.getItem as jest.Mock).mockReturnValueOnce(JSON.stringify({a: {id: 'a', value: 'from-storage'}}));
 | 
							expect(storageGetItemSpy).toHaveBeenCalledWith('cache');
 | 
				
			||||||
		const c = new Cache<string, TestItem>('id', {storage: storageMock, storageKey: 'cache'});
 | 
					 | 
				
			||||||
		expect(c.get('a')).toEqual({id: 'a', value: 'from-storage'});
 | 
					 | 
				
			||||||
	});
 | 
						});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	test('should handle expiryPolicy "delete"', () => {
 | 
						it('expiryPolicy "delete" removes expired items completely', () => {
 | 
				
			||||||
		cache.options.expiryPolicy = 'delete';
 | 
							cache.options.expiryPolicy = 'delete';
 | 
				
			||||||
		cache.add({id: 'k1', value: 'KeepMe'});
 | 
							cache.add({ id: 'del1', value: 'gone' });
 | 
				
			||||||
		cache.expire('k1');
 | 
							cache.expire('del1');
 | 
				
			||||||
		expect(cache.get('k1', true)).toBeNull();
 | 
							expect(cache.get('del1', true)).toBeNull();
 | 
				
			||||||
 | 
							expect(cache.get('del1')).toBeNull();
 | 
				
			||||||
	});
 | 
						});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	test('should handle expiryPolicy "keep"', () => {
 | 
						it('expiryPolicy "keep" marks as expired but does not delete', () => {
 | 
				
			||||||
		cache.options.expiryPolicy = 'keep';
 | 
							cache.options.expiryPolicy = 'keep';
 | 
				
			||||||
		cache.add({id: 'k1', value: 'KeepMe'});
 | 
							cache.add({ id: 'keep1', value: 'kept' });
 | 
				
			||||||
		cache.expire('k1');
 | 
							cache.expire('keep1');
 | 
				
			||||||
		expect(cache.get('k1')).toBeNull();
 | 
							expect(cache.get('keep1')).toBeNull();
 | 
				
			||||||
		expect(cache.get('k1', true)?._expired).toBe(true);
 | 
							const val = cache.get('keep1', true);
 | 
				
			||||||
 | 
							expect(val && val._expired).toBe(true);
 | 
				
			||||||
 | 
						});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						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();
 | 
				
			||||||
 | 
						});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						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 {
 | 
					import {
 | 
				
			||||||
	clean, deepCopy, deepMerge, dotNotation, encodeQuery, flattenObj, formData, includes, isEqual, mixin,
 | 
						clean, deepCopy, deepMerge, dotNotation, encodeQuery, flattenObj, formData, includes, isEqual, mixin,
 | 
				
			||||||
	JSONAttemptParse, JSONSerialize, JSONSanitize
 | 
					 | 
				
			||||||
} from '../src';
 | 
					} from '../src';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
describe('Object utilities', () => {
 | 
					describe('Object utilities', () => {
 | 
				
			||||||
@@ -131,34 +130,4 @@ describe('Object utilities', () => {
 | 
				
			|||||||
			expect(c.bar()).toBe(2);
 | 
								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();
 | 
					 | 
				
			||||||
		});
 | 
					 | 
				
			||||||
	});
 | 
					 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,6 +1,10 @@
 | 
				
			|||||||
import {PathError, PathEvent, PathEventEmitter, PE, PES} from '../src';
 | 
					import {PathError, PathEvent, PathEventEmitter, PE, PES} from '../src';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
describe('Path Events', () => {
 | 
					describe('Path Events', () => {
 | 
				
			||||||
 | 
						beforeEach(() => {
 | 
				
			||||||
 | 
							PathEvent.clearCache();
 | 
				
			||||||
 | 
						});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	describe('PE', () => {
 | 
						describe('PE', () => {
 | 
				
			||||||
		it('creates PathEvent from template string', () => {
 | 
							it('creates PathEvent from template string', () => {
 | 
				
			||||||
			const e = PE`users/system:cr`;
 | 
								const e = PE`users/system:cr`;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -4,6 +4,7 @@ const rows = [
 | 
				
			|||||||
	{id: 1, name: 'Alice', age: 30},
 | 
						{id: 1, name: 'Alice', age: 30},
 | 
				
			||||||
	{id: 2, name: 'Bob', age: 24},
 | 
						{id: 2, name: 'Bob', age: 24},
 | 
				
			||||||
	{id: 3, name: 'Carol', age: 30},
 | 
						{id: 3, name: 'Carol', age: 30},
 | 
				
			||||||
 | 
						{id: 4, name: 'David', age: 35},
 | 
				
			||||||
];
 | 
					];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
describe('Search Utilities', () => {
 | 
					describe('Search Utilities', () => {
 | 
				
			||||||
@@ -37,6 +38,35 @@ describe('Search Utilities', () => {
 | 
				
			|||||||
		it('returns all if search is falsy and regex enabled', () => {
 | 
							it('returns all if search is falsy and regex enabled', () => {
 | 
				
			||||||
			expect(search(rows, '', true)).toEqual(rows);
 | 
								expect(search(rows, '', true)).toEqual(rows);
 | 
				
			||||||
		});
 | 
							});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							it('handles regex search with special characters', () => {
 | 
				
			||||||
 | 
								expect(search(rows, '^[AC]', true)).toEqual([rows[0], rows[2]]);
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							it('ignores case when regex is applied', () => {
 | 
				
			||||||
 | 
								expect(search(rows, 'ALICE', true)).toEqual([]);
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							it('performs partial matches for properties when regex=false', () => {
 | 
				
			||||||
 | 
								expect(search(rows, 'Da')).toEqual([rows[3]]);
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							it('handles empty array input gracefully', () => {
 | 
				
			||||||
 | 
								expect(search([], 'test')).toEqual([]);
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							it('handles numeric values with comparison logic in strings', () => {
 | 
				
			||||||
 | 
								expect(search(rows, 'age < 31')).toEqual([rows[0], rows[1], rows[2]]);
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							// New test cases for `+` and `-` operators
 | 
				
			||||||
 | 
							it('filters rows using the + operator', () => {
 | 
				
			||||||
 | 
								expect(search(rows, 'name += Al')).toEqual([rows[0]]);
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							it('filters rows using the - operator', () => {
 | 
				
			||||||
 | 
								expect(search(rows, 'name -= Al')).toEqual([rows[1], rows[2], rows[3]]);
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
	});
 | 
						});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	describe('logicTest', () => {
 | 
						describe('logicTest', () => {
 | 
				
			||||||
@@ -67,5 +97,46 @@ describe('Search Utilities', () => {
 | 
				
			|||||||
		it('returns false for unsupported operators', () => {
 | 
							it('returns false for unsupported operators', () => {
 | 
				
			||||||
			expect(logicTest(obj, 'x === 10')).toBe(false);
 | 
								expect(logicTest(obj, 'x === 10')).toBe(false);
 | 
				
			||||||
		});
 | 
							});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							it('handles invalid condition strings gracefully', () => {
 | 
				
			||||||
 | 
								expect(logicTest(obj, 'invalid condition')).toBe(false);
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							it('supports numeric operations with ranges', () => {
 | 
				
			||||||
 | 
								expect(logicTest(obj, 'x > 5 && x < 15')).toBe(true);
 | 
				
			||||||
 | 
								expect(logicTest(obj, 'x > 15')).toBe(false);
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							it('handles mixed case keys gracefully', () => {
 | 
				
			||||||
 | 
								const mixedCaseObj = {TestKey: 123};
 | 
				
			||||||
 | 
								expect(logicTest(mixedCaseObj, 'TestKey == 123')).toBe(true);
 | 
				
			||||||
 | 
								expect(logicTest(mixedCaseObj, 'testkey == 123')).toBe(true);
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							it('returns false if condition operators are missing', () => {
 | 
				
			||||||
 | 
								expect(logicTest(obj, 'x 10')).toBe(false);
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							// New test cases for `+` and `-` operators
 | 
				
			||||||
 | 
							it('handles the + operator for inclusion', () => {
 | 
				
			||||||
 | 
								expect(logicTest(obj, 'name += Alpha')).toBe(true);
 | 
				
			||||||
 | 
								expect(logicTest(obj, 'name += Alp')).toBe(true);
 | 
				
			||||||
 | 
								expect(logicTest(obj, 'name += Bet')).toBe(false);
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							it('handles the - operator for exclusion', () => {
 | 
				
			||||||
 | 
								expect(logicTest(obj, 'name -= Alpha')).toBe(false);
 | 
				
			||||||
 | 
								expect(logicTest(obj, 'name -= Alp')).toBe(false);
 | 
				
			||||||
 | 
								expect(logicTest(obj, 'name -= Bet')).toBe(true);
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							it('includes partial matches correctly with +', () => {
 | 
				
			||||||
 | 
								expect(logicTest(obj, 'name += lph')).toBe(true);
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							it('excludes partial matches correctly with -', () => {
 | 
				
			||||||
 | 
								expect(logicTest(obj, 'name -= lph')).toBe(false);
 | 
				
			||||||
 | 
								expect(logicTest(obj, 'name -= xyz')).toBe(true);
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
	});
 | 
						});
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -31,11 +31,11 @@ describe('Time Utilities', () => {
 | 
				
			|||||||
		it('handles formatting for given timestamp', () => {
 | 
							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 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');
 | 
								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', () => {
 | 
							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', () => {
 | 
							it('handles timezone by offset number', () => {
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user