Compare commits
	
		
			9 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 057528b0c5 | |||
| 1595aea529 | |||
| 08503de552 | |||
| a9a9b04a5a | |||
| a7b19900da | |||
| 34227e5c4b | |||
| 7e8352ed2a | |||
| 751177c981 | |||
| 5849a97c21 | 
@@ -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
 | 
				
			||||||
							
								
								
									
										96
									
								
								.github/workflows/build.yaml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										96
									
								
								.github/workflows/build.yaml
									
									
									
									
										vendored
									
									
								
							@@ -11,26 +11,26 @@ jobs:
 | 
				
			|||||||
    runs-on: ubuntu-latest
 | 
					    runs-on: ubuntu-latest
 | 
				
			||||||
    container: node:20-alpine
 | 
					    container: node:20-alpine
 | 
				
			||||||
    steps:
 | 
					    steps:
 | 
				
			||||||
      -   name: Clone Repository
 | 
					      - name: Clone Repository
 | 
				
			||||||
          uses: ztimson/actions/clone@develop
 | 
					        uses: ztimson/actions/clone@develop
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      -   name: Install & Build
 | 
					      - name: Install & Build
 | 
				
			||||||
          run: |
 | 
					        run: |
 | 
				
			||||||
            npm i
 | 
					          npm i
 | 
				
			||||||
            npm run build
 | 
					          npm run build
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      -   name: Test
 | 
					      - name: Test
 | 
				
			||||||
          run: npm run test:coverage
 | 
					        run: npm run test:coverage
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      -   name: Upload to Registry
 | 
					      - name: Upload to Registry
 | 
				
			||||||
          uses: ztimson/actions/npm/publish@develop
 | 
					        uses: ztimson/actions/npm/publish@develop
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      -   name: Upload to NPM
 | 
					      - name: Upload to NPM
 | 
				
			||||||
          uses: ztimson/actions/npm/publish@develop
 | 
					        uses: ztimson/actions/npm/publish@develop
 | 
				
			||||||
          with:
 | 
					        with:
 | 
				
			||||||
              owner: ztimson
 | 
					          owner: ztimson
 | 
				
			||||||
              registry: https://registry.npmjs.org/
 | 
					          registry: https://registry.npmjs.org/
 | 
				
			||||||
              token: ${{secrets.NPM_TOKEN}}
 | 
					          token: ${{secrets.NPM_TOKEN}}
 | 
				
			||||||
  tag:
 | 
					  tag:
 | 
				
			||||||
    name: Tag Version
 | 
					    name: Tag Version
 | 
				
			||||||
    needs: build
 | 
					    needs: build
 | 
				
			||||||
@@ -38,23 +38,53 @@ jobs:
 | 
				
			|||||||
    runs-on: ubuntu-latest
 | 
					    runs-on: ubuntu-latest
 | 
				
			||||||
    container: node
 | 
					    container: node
 | 
				
			||||||
    steps:
 | 
					    steps:
 | 
				
			||||||
      -   name: Clone Repository
 | 
					      - name: Clone Repository
 | 
				
			||||||
          uses: ztimson/actions/clone@develop
 | 
					        uses: ztimson/actions/clone@develop
 | 
				
			||||||
 | 
					 | 
				
			||||||
      -   name: Get Version Number
 | 
					 | 
				
			||||||
          run: echo "VERSION=$(cat package.json | grep version | grep -Eo ':.+' | grep -Eo '[[:alnum:]\.\/\-]+')" >> $GITHUB_ENV
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      -   name: Tag Version
 | 
					 | 
				
			||||||
          uses: ztimson/actions/tag@develop
 | 
					 | 
				
			||||||
          with:
 | 
					 | 
				
			||||||
            tag: ${{env.VERSION}}
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      - name: Get Version Number
 | 
				
			||||||
 | 
					        run: echo "VERSION=$(cat package.json | grep version | grep -Eo ':.+' | grep -Eo '[[:alnum:]\.\/\-]+')" >> $GITHUB_ENV
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      - name: Tag Version
 | 
				
			||||||
 | 
					        uses: ztimson/actions/tag@develop
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          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
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										101
									
								
								index.html
									
									
									
									
									
								
							
							
						
						
									
										101
									
								
								index.html
									
									
									
									
									
								
							@@ -1,11 +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 {formatDate} from './dist/index.mjs';
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
			const dt = new Date('2021-03-03T09:00:00Z');
 | 
							<script>
 | 
				
			||||||
			const result = formatDate('Do MMMM dddd', dt, 0);
 | 
								// Console Styling Playground
 | 
				
			||||||
			console.log(result);
 | 
								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.27.3",
 | 
						"version": "0.27.9",
 | 
				
			||||||
	"description": "Utility library",
 | 
						"description": "Utility library",
 | 
				
			||||||
	"author": "Zak Timson",
 | 
						"author": "Zak Timson",
 | 
				
			||||||
	"license": "MIT",
 | 
						"license": "MIT",
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										133
									
								
								src/cache.ts
									
									
									
									
									
								
							
							
						
						
									
										133
									
								
								src/cache.ts
									
									
									
									
									
								
							@@ -1,5 +1,6 @@
 | 
				
			|||||||
import {Database, 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 */
 | 
				
			||||||
@@ -8,6 +9,8 @@ export type CacheOptions = {
 | 
				
			|||||||
	persistentStorage?: {storage: Storage | Database, key: string} | string;
 | 
						persistentStorage?: {storage: Storage | Database, key: string} | 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};
 | 
				
			||||||
@@ -16,14 +19,15 @@ export type CachedValue<T> = T & {_expired?: boolean};
 | 
				
			|||||||
 * Map of data which tracks whether it is a complete collection & offers optional expiry of cached values
 | 
					 * 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;
 | 
				
			||||||
 | 
					 | 
				
			||||||
	private _loading!: Function;
 | 
					 | 
				
			||||||
	/** Await initial loading */
 | 
						/** Await initial loading */
 | 
				
			||||||
	loading = new Promise<void>(r => this._loading = r);
 | 
						loading = new Promise<void>(r => this._loading = r);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -43,12 +47,18 @@ export class Cache<K extends string | number | symbol, T> {
 | 
				
			|||||||
					const persists: any = this.options.persistentStorage;
 | 
										const persists: any = this.options.persistentStorage;
 | 
				
			||||||
					const table: Table<any, any> = await persists.storage.createTable({name: persists.key, key: this.key});
 | 
										const table: Table<any, any> = await persists.storage.createTable({name: persists.key, key: this.key});
 | 
				
			||||||
					const rows = await table.getAll();
 | 
										const rows = await table.getAll();
 | 
				
			||||||
					Object.assign(this.store, rows.reduce((acc, row) => ({...acc, [this.getKey(row)]: row}), {}));
 | 
										for(const row of rows) this.store.set(this.getKey(row), row);
 | 
				
			||||||
					this._loading();
 | 
										this._loading();
 | 
				
			||||||
				})();
 | 
									})();
 | 
				
			||||||
			} else if((<any>this.options.persistentStorage?.storage)?.getItem != undefined) {
 | 
								} else if((<any>this.options.persistentStorage?.storage)?.getItem != undefined) {
 | 
				
			||||||
				const stored = (<Storage>this.options.persistentStorage.storage).getItem(this.options.persistentStorage.key);
 | 
									const {storage, key} = <{storage: Storage, key: string}>this.options.persistentStorage;
 | 
				
			||||||
				if(stored != null) try { Object.assign(this.store, JSON.parse(stored)); } catch { }
 | 
									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();
 | 
									this._loading();
 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
		} else {
 | 
							} else {
 | 
				
			||||||
@@ -71,8 +81,8 @@ 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) {
 | 
				
			||||||
@@ -80,28 +90,50 @@ export class Cache<K extends string | number | symbol, T> {
 | 
				
			|||||||
		if(!!persists?.storage) {
 | 
							if(!!persists?.storage) {
 | 
				
			||||||
			if(persists.storage?.database != undefined) {
 | 
								if(persists.storage?.database != undefined) {
 | 
				
			||||||
				(<Database>persists.storage).createTable({name: persists.key, key: <string>this.key}).then(table => {
 | 
									(<Database>persists.storage).createTable({name: persists.key, key: <string>this.key}).then(table => {
 | 
				
			||||||
					if(key) {
 | 
										if(key !== undefined) {
 | 
				
			||||||
						const value = this.get(key);
 | 
											const value = this.get(key, true);
 | 
				
			||||||
						if(value != null) table.set(value, key);
 | 
											if(value != null) table.set(value, key);
 | 
				
			||||||
						else table.delete(key);
 | 
											else table.delete(key);
 | 
				
			||||||
					} else {
 | 
										} else {
 | 
				
			||||||
						table.clear();
 | 
											table.clear();
 | 
				
			||||||
						this.all().forEach(row => table.add(row));
 | 
											this.all(true).forEach(row => table.add(row));
 | 
				
			||||||
					}
 | 
										}
 | 
				
			||||||
				});
 | 
									});
 | 
				
			||||||
			} else if(persists.storage?.setItem != undefined) {
 | 
								} else if(persists.storage?.setItem != undefined) {
 | 
				
			||||||
				persists.storage.setItem(persists.storage.key, JSONSanitize(this.all(true)));
 | 
									const obj: Record<any, any> = {};
 | 
				
			||||||
 | 
									for(const [k, v] of this.store.entries()) obj[k as any] = v;
 | 
				
			||||||
 | 
									persists.storage.setItem(persists.key, JSONSanitize(obj));
 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						private clearTimer(key: K) {
 | 
				
			||||||
 | 
							const t = this.timers.get(key);
 | 
				
			||||||
 | 
							if(t) { clearTimeout(t); this.timers.delete(key); }
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						private touchLRU(key: K) {
 | 
				
			||||||
 | 
							if(!this.options.sizeLimit || this.options.sizeLimit <= 0) return;
 | 
				
			||||||
 | 
							const idx = this.lruOrder.indexOf(key);
 | 
				
			||||||
 | 
							if(idx >= 0) this.lruOrder.splice(idx, 1);
 | 
				
			||||||
 | 
							this.lruOrder.push(key);
 | 
				
			||||||
 | 
							while(this.lruOrder.length > (this.options.sizeLimit || 0)) {
 | 
				
			||||||
 | 
								const lru = this.lruOrder.shift();
 | 
				
			||||||
 | 
								if(lru !== undefined) this.delete(lru);
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	/**
 | 
						/**
 | 
				
			||||||
	 * Get all cached items
 | 
						 * Get all cached items
 | 
				
			||||||
	 * @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;
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	/**
 | 
						/**
 | 
				
			||||||
@@ -134,7 +166,10 @@ 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();
 | 
							this.save();
 | 
				
			||||||
		return this;
 | 
							return this;
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -144,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;
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -154,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;
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	/**
 | 
						/**
 | 
				
			||||||
@@ -165,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);
 | 
				
			||||||
			this.save(key);
 | 
								if(v) {
 | 
				
			||||||
 | 
									v._expired = true;
 | 
				
			||||||
 | 
									this.store.set(key, v);
 | 
				
			||||||
 | 
									this.save(key);
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
		} else this.delete(key);
 | 
							} else this.delete(key);
 | 
				
			||||||
		return this;
 | 
							return this;
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -178,7 +224,11 @@ export class Cache<K extends string | number | symbol, T> {
 | 
				
			|||||||
	 * @returns {T | undefined} Cached item or undefined if nothing matched
 | 
						 * @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;
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	/**
 | 
						/**
 | 
				
			||||||
@@ -188,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;
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -198,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;
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	/**
 | 
						/**
 | 
				
			||||||
@@ -207,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;
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -223,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) {
 | 
				
			||||||
			this.expire(key);
 | 
								const t = setTimeout(() => {
 | 
				
			||||||
			this.save(key);
 | 
									this.expire(key);
 | 
				
			||||||
		}, (ttl || 0) * 1000);
 | 
									this.save(key);
 | 
				
			||||||
 | 
								}, (ttl || 0) * 1000);
 | 
				
			||||||
 | 
								this.timers.set(key, t);
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
		return this;
 | 
							return this;
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -236,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,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';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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,
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										29
									
								
								src/misc.ts
									
									
									
									
									
								
							
							
						
						
									
										29
									
								
								src/misc.ts
									
									
									
									
									
								
							@@ -1,3 +1,4 @@
 | 
				
			|||||||
 | 
					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';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -14,6 +15,34 @@ export function compareVersions(target: string, vs: string): -1 | 0 | 1 {
 | 
				
			|||||||
		(tMajor < vMajor || tMinor < vMinor || tPatch < vPatch) ? -1 : 0;
 | 
							(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
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,5 +1,6 @@
 | 
				
			|||||||
export type Delta = { [key: string]: any | Delta | null };
 | 
					import {JSONSanitize} from './json.ts';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export type Delta = { [key: string]: any | Delta | null };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 * Applies deltas to `target`.
 | 
					 * Applies deltas to `target`.
 | 
				
			||||||
@@ -294,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);
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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.
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										85
									
								
								src/time.ts
									
									
									
									
									
								
							
							
						
						
									
										85
									
								
								src/time.ts
									
									
									
									
									
								
							@@ -1,5 +1,3 @@
 | 
				
			|||||||
import {numSuffix} from './math.ts';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 * Like setInterval but will adjust the timeout value to account for runtime
 | 
					 * Like setInterval but will adjust the timeout value to account for runtime
 | 
				
			||||||
 * @param {Function} cb Callback function that will be ran
 | 
					 * @param {Function} cb Callback function that will be ran
 | 
				
			||||||
@@ -34,6 +32,50 @@ export function dayOfYear(date: Date): number {
 | 
				
			|||||||
/**
 | 
					/**
 | 
				
			||||||
 * 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
 | 
				
			||||||
@@ -55,23 +97,46 @@ export function formatDate(format: string = 'YYYY-MM-DD H:mm', date: Date | numb
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	let zonedDate = new Date(date);
 | 
						let zonedDate = new Date(date);
 | 
				
			||||||
 | 
						let get: (fn: 'FullYear' | 'Month' | 'Date' | 'Day' | 'Hours' | 'Minutes' | 'Seconds' | 'Milliseconds') => number;
 | 
				
			||||||
 | 
						const partsMap: Record<string, string> = {};
 | 
				
			||||||
	if (!numericTz && tzName !== 'UTC') {
 | 
						if (!numericTz && tzName !== 'UTC') {
 | 
				
			||||||
		const parts = new Intl.DateTimeFormat('en-US', {
 | 
							const parts = new Intl.DateTimeFormat('en-US', {
 | 
				
			||||||
			timeZone: tzName,
 | 
								timeZone: tzName,
 | 
				
			||||||
			year: 'numeric', month: '2-digit', day: '2-digit',
 | 
								year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'long',
 | 
				
			||||||
			hour: '2-digit', minute: '2-digit', second: '2-digit',
 | 
								hour: '2-digit', minute: '2-digit', second: '2-digit',
 | 
				
			||||||
			hour12: false
 | 
								hour12: false
 | 
				
			||||||
		}).formatToParts(date);
 | 
							}).formatToParts(date);
 | 
				
			||||||
		const get = (type: string) => parts.find(p => p.type === type)?.value;
 | 
							parts.forEach(p => {
 | 
				
			||||||
		const build = `${get('year')}-${get('month')}-${get('day')}T${get('hour')}:${get('minute')}:${get('second')}Z`;
 | 
								partsMap[p.type] = p.value;
 | 
				
			||||||
		zonedDate = new Date(build);
 | 
							});
 | 
				
			||||||
	} else if (numericTz || tzName === 'UTC') {
 | 
					
 | 
				
			||||||
 | 
							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;
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							};
 | 
				
			||||||
 | 
						} else {
 | 
				
			||||||
		const offset = numericTz ? tz as number : 0;
 | 
							const offset = numericTz ? tz as number : 0;
 | 
				
			||||||
		zonedDate = new Date(date.getTime() + offset * 60 * 60 * 1000);
 | 
							zonedDate = new Date(date.getTime() + offset * 60 * 60 * 1000);
 | 
				
			||||||
 | 
							get = (fn: 'FullYear' | 'Month' | 'Date' | 'Day' | 'Hours' | 'Minutes' | 'Seconds' | 'Milliseconds'): number => zonedDate[`getUTC${fn}`]();
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	const get = (fn: 'FullYear' | 'Month' | 'Date' | 'Day' | 'Hours' | 'Minutes' | 'Seconds' | 'Milliseconds') =>
 | 
						function numSuffix(n: number): string {
 | 
				
			||||||
		(numericTz || tzName === 'UTC') ? zonedDate[`getUTC${fn}`]() : zonedDate[`get${fn}`]();
 | 
							const s = ["th", "st", "nd", "rd"];
 | 
				
			||||||
 | 
							const v = n % 100;
 | 
				
			||||||
 | 
							return n + (s[(v - 20) % 10] || s[v] || s[0]);
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	function getTZOffset(): string {
 | 
						function getTZOffset(): string {
 | 
				
			||||||
		if (numericTz) {
 | 
							if (numericTz) {
 | 
				
			||||||
@@ -119,7 +184,7 @@ export function formatDate(format: string = 'YYYY-MM-DD H:mm', date: Date | numb
 | 
				
			|||||||
		m: get('Minutes').toString(),
 | 
							m: get('Minutes').toString(),
 | 
				
			||||||
		ss: get('Seconds').toString().padStart(2, '0'),
 | 
							ss: get('Seconds').toString().padStart(2, '0'),
 | 
				
			||||||
		s: get('Seconds').toString(),
 | 
							s: get('Seconds').toString(),
 | 
				
			||||||
		SSS: get('Milliseconds').toString().padStart(3, '0'),
 | 
							SSS: (zonedDate[`getUTC${'Milliseconds'}`]()).toString().padStart(3, '0'),
 | 
				
			||||||
		A: get('Hours') >= 12 ? 'PM' : 'AM',
 | 
							A: get('Hours') >= 12 ? 'PM' : 'AM',
 | 
				
			||||||
		a: get('Hours') >= 12 ? 'pm' : 'am',
 | 
							a: get('Hours') >= 12 ? 'pm' : 'am',
 | 
				
			||||||
		ZZ: getTZOffset().replace(':', ''),
 | 
							ZZ: getTZOffset().replace(':', ''),
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -3,14 +3,16 @@ 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},
 | 
								constructor: { name: 'Storage' as any },
 | 
				
			||||||
			getItem: jest.fn(),
 | 
								getItem: jest.fn(),
 | 
				
			||||||
			setItem: jest.fn(),
 | 
								setItem: jest.fn(),
 | 
				
			||||||
			removeItem: jest.fn(),
 | 
								removeItem: jest.fn(),
 | 
				
			||||||
@@ -23,11 +25,15 @@ describe('Cache', () => {
 | 
				
			|||||||
		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', {
 | 
							cache = new Cache<string | symbol, TestItem>('id', {
 | 
				
			||||||
			persistentStorage: { storage: storageMock, key: 'cache' },
 | 
								persistentStorage: { storage: storageMock, key: 'cache' },
 | 
				
			||||||
		});
 | 
							});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		jest.clearAllMocks();
 | 
							jest.clearAllMocks();
 | 
				
			||||||
		jest.useFakeTimers();
 | 
						});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						afterEach(() => {
 | 
				
			||||||
 | 
							jest.useRealTimers();
 | 
				
			||||||
	});
 | 
						});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	it('adds and gets an item', () => {
 | 
						it('adds and gets an item', () => {
 | 
				
			||||||
@@ -58,10 +64,12 @@ describe('Cache', () => {
 | 
				
			|||||||
		expect(storageSetItemSpy).toHaveBeenCalled();
 | 
							expect(storageSetItemSpy).toHaveBeenCalled();
 | 
				
			||||||
	});
 | 
						});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	it('clears the cache', () => {
 | 
						it('clears the cache & cancels TTL timers', () => {
 | 
				
			||||||
		cache.add({ id: '1', value: 'test' });
 | 
							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);
 | 
				
			||||||
	});
 | 
						});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -75,7 +83,7 @@ describe('Cache', () => {
 | 
				
			|||||||
		expect(cache.complete).toBe(true);
 | 
							expect(cache.complete).toBe(true);
 | 
				
			||||||
	});
 | 
						});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	it('returns correct keys, entries, and map', () => {
 | 
						it('returns correct keys, entries, map, and values()', () => {
 | 
				
			||||||
		cache.add({ id: 'x', value: 'foo' });
 | 
							cache.add({ id: 'x', value: 'foo' });
 | 
				
			||||||
		cache.add({ id: 'y', value: 'bar' });
 | 
							cache.add({ id: 'y', value: 'bar' });
 | 
				
			||||||
		expect(cache.keys().sort()).toEqual(['x', 'y']);
 | 
							expect(cache.keys().sort()).toEqual(['x', 'y']);
 | 
				
			||||||
@@ -83,6 +91,8 @@ describe('Cache', () => {
 | 
				
			|||||||
		const m = cache.map();
 | 
							const m = cache.map();
 | 
				
			||||||
		expect(Object.keys(m)).toEqual(expect.arrayContaining(['x', 'y']));
 | 
							expect(Object.keys(m)).toEqual(expect.arrayContaining(['x', 'y']));
 | 
				
			||||||
		expect(m['x'].value).toBe('foo');
 | 
							expect(m['x'].value).toBe('foo');
 | 
				
			||||||
 | 
							// CHANGE: values() was a snapshot field before; now it’s a method
 | 
				
			||||||
 | 
							expect(cache.values().map(v => v.id).sort()).toEqual(['x', 'y']);
 | 
				
			||||||
	});
 | 
						});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	it('persists and restores from storage', () => {
 | 
						it('persists and restores from storage', () => {
 | 
				
			||||||
@@ -93,6 +103,8 @@ describe('Cache', () => {
 | 
				
			|||||||
			persistentStorage: { storage: storageMock, key: 'cache' },
 | 
								persistentStorage: { storage: storageMock, key: 'cache' },
 | 
				
			||||||
		});
 | 
							});
 | 
				
			||||||
		expect(c.get('z')).toEqual({ id: 'z', value: 'from-storage' });
 | 
							expect(c.get('z')).toEqual({ id: 'z', value: 'from-storage' });
 | 
				
			||||||
 | 
							// ensure it used the right storage key
 | 
				
			||||||
 | 
							expect(storageGetItemSpy).toHaveBeenCalledWith('cache');
 | 
				
			||||||
	});
 | 
						});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	it('expiryPolicy "delete" removes expired items completely', () => {
 | 
						it('expiryPolicy "delete" removes expired items completely', () => {
 | 
				
			||||||
@@ -112,20 +124,71 @@ describe('Cache', () => {
 | 
				
			|||||||
		expect(val && val._expired).toBe(true);
 | 
							expect(val && val._expired).toBe(true);
 | 
				
			||||||
	});
 | 
						});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Uncomment and adapt this test if TTL/expiry timers are supported by your implementation
 | 
						it('TTL expiration deletes by default', () => {
 | 
				
			||||||
	// it('expires and deletes items after TTL', () => {
 | 
							cache.add({ id: 'ttl1', value: 'tick' }, 1);
 | 
				
			||||||
	//   jest.useFakeTimers();
 | 
							jest.advanceTimersByTime(999);
 | 
				
			||||||
	//   cache = new Cache<string, TestItem>('id', { ttl: 0.01 });
 | 
							expect(cache.get('ttl1')).toEqual({ id: 'ttl1', value: 'tick' });
 | 
				
			||||||
	//   cache.add({ id: 'ttl1', value: 'temp' });
 | 
							jest.advanceTimersByTime(2);
 | 
				
			||||||
	//   jest.advanceTimersByTime(100);
 | 
							expect(cache.get('ttl1')).toBeNull();
 | 
				
			||||||
	//   expect(cache.get('ttl1')).toBeNull();
 | 
						});
 | 
				
			||||||
	// });
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Edge: add error handling test
 | 
						it('TTL overwrite cancels previous timer', () => {
 | 
				
			||||||
	it('throws if instantiating with invalid key property', () => {
 | 
							cache.add({ id: 'ow', value: 'v1' }, 1);
 | 
				
			||||||
		expect(() => {
 | 
							jest.advanceTimersByTime(500);
 | 
				
			||||||
			const invalid = new Cache<'string', TestItem>('id');
 | 
							cache.add({ id: 'ow', value: 'v2' }, 1);
 | 
				
			||||||
			// try invalid.add({id: 'z', value: 'fail'}) if needed
 | 
							jest.advanceTimersByTime(600);
 | 
				
			||||||
		}).not.toThrow();
 | 
							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();
 | 
					 | 
				
			||||||
		});
 | 
					 | 
				
			||||||
	});
 | 
					 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user