init
This commit is contained in:
commit
041558b83b
16
.editorconfig
Normal file
16
.editorconfig
Normal file
@ -0,0 +1,16 @@
|
||||
# Editor configuration, see https://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.ts]
|
||||
quote_type = single
|
||||
|
||||
[*.md]
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
20
.gitignore
vendored
Normal file
20
.gitignore
vendored
Normal file
@ -0,0 +1,20 @@
|
||||
# IDEs and editors
|
||||
.c9
|
||||
.classpath
|
||||
.idea
|
||||
.project
|
||||
.settings
|
||||
.vscode
|
||||
*.launch
|
||||
*.sublime-workspace
|
||||
/.sass-cache
|
||||
/connect.lock
|
||||
/coverage
|
||||
/libpeerconnection.log
|
||||
/typings
|
||||
dist
|
||||
npm-debug.log
|
||||
node_modules
|
||||
testem.log
|
||||
junit.xml
|
||||
docs
|
16
jest.config.js
Normal file
16
jest.config.js
Normal file
@ -0,0 +1,16 @@
|
||||
module.exports = {
|
||||
"reporters": ["default", "jest-junit"],
|
||||
"roots": [
|
||||
"<rootDir>/tests"
|
||||
],
|
||||
"testMatch": [
|
||||
"**/?(*.)+(spec|test).+(ts|tsx|js)"
|
||||
],
|
||||
"transform": {
|
||||
".+\\.(ts)$": "ts-jest"
|
||||
},
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.ts',
|
||||
'!src/**/*.d.ts'
|
||||
],
|
||||
};
|
3957
package-lock.json
generated
Normal file
3957
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
package.json
Normal file
36
package.json
Normal file
@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "perstistance",
|
||||
"version": "1.0.0",
|
||||
"description": "Sync variables with the local/session storage using proxy objects & decorators",
|
||||
"repository": "https://git.zakscode.com/ztimson/persistance",
|
||||
"author": "Zak Timson",
|
||||
"license": "Apache 2.0",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc && npm run build:docs",
|
||||
"build:docs": "npx typedoc --readme none --out docs src/index.ts",
|
||||
"test": "npx jest --verbose",
|
||||
"test:coverage": "npx jest --verbose --coverage",
|
||||
"watch": "npm run build && npx tsc --watch"
|
||||
},
|
||||
"keywords": [
|
||||
"Decorator",
|
||||
"LocalStorage",
|
||||
"Persistance",
|
||||
"SessionStorage",
|
||||
"WebStorage"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.11",
|
||||
"jest": "^29.7.0",
|
||||
"jest-junit": "^16.0.0",
|
||||
"ts-jest": "^29.1.1",
|
||||
"typedoc": "^0.25.4",
|
||||
"typedoc-plugin-markdown": "^3.17.1",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
2
src/index.ts
Normal file
2
src/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './persist';
|
||||
export * from './memory-storage';
|
22
src/memory-storage.ts
Normal file
22
src/memory-storage.ts
Normal file
@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Example storage backend. By default, persist will use localStorage however,
|
||||
* by implementing the Storage interface you can provide a custom backend to
|
||||
* store data anywhere you desire such as an external database or redis.
|
||||
*
|
||||
* @ignore
|
||||
*/
|
||||
export class MemoryStorage implements Storage {
|
||||
[name: string]: any;
|
||||
|
||||
get length() { return Object.keys(this).length; }
|
||||
|
||||
clear(): void { Object.keys(this).forEach(k => this.removeItem(k)); }
|
||||
|
||||
getItem(key: string): string | null { return this[key]; }
|
||||
|
||||
key(index: number): string | null { return Object.keys(this)[index]; }
|
||||
|
||||
removeItem(key: string): void { delete this[key]; }
|
||||
|
||||
setItem(key: string, value: string): void { this[key] = value; }
|
||||
}
|
144
src/persist.ts
Normal file
144
src/persist.ts
Normal file
@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Configurations persistence behaviour
|
||||
*
|
||||
* @group Options
|
||||
*/
|
||||
export type PersistOptions<T> = {
|
||||
/** Default/Initial value if undefined */
|
||||
default?: T,
|
||||
/** Storage implementation, defaults to LocalStorage */
|
||||
storage?: Storage,
|
||||
/** Force value to have prototype */
|
||||
type?: any,
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync variable's value with persistent storage (LocalStorage by default)
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const theme = new Persist('theme.current', {default: 'os'});
|
||||
* console.log(theme.value); // Output: os
|
||||
*
|
||||
* theme.value = 'light'; // Any changes to `.value` will automatically sync with localStorage
|
||||
*
|
||||
* location.reload(); // Simulate refresh
|
||||
* console.log(theme.value); // Output: light
|
||||
* ```
|
||||
*/
|
||||
export class Persist<T> {
|
||||
/** Private value field */
|
||||
private _value!: T;
|
||||
|
||||
/** Current value or default if undefined */
|
||||
get value(): T { return this._value !== undefined ? this._value : <T>this.options?.default; }
|
||||
|
||||
/** Set value with proxy object wrapper to sync future changes */
|
||||
set value(v: T | undefined) {
|
||||
if(v == null || typeof v != 'object') this._value = <T>v;
|
||||
// @ts-ignore
|
||||
else this._value = new Proxy<T>(v, {
|
||||
get: (target: T, p: string | symbol): any => {
|
||||
const f = typeof (<any>target)[p] == 'function';
|
||||
if(!f) return (<any>target)[p];
|
||||
return (...args: any[]) => {
|
||||
const value = (<any>target)[p](...args);
|
||||
this.save();
|
||||
return value;
|
||||
};
|
||||
},
|
||||
set: (target: T, p: string | symbol, newValue: any): boolean => {
|
||||
(<any>target)[p] = newValue;
|
||||
this.save();
|
||||
return true;
|
||||
}
|
||||
});
|
||||
this.save();
|
||||
}
|
||||
|
||||
/** Where data gets physically stored */
|
||||
private readonly storage!: Storage;
|
||||
/** Listeners which should be notified on changes */
|
||||
private watches: { [key: string]: Function } = {};
|
||||
|
||||
/**
|
||||
* @param {string} key Unique key value will be stored under
|
||||
* @param {PersistOptions<T>} options Configure using {@link PersistOptions}
|
||||
*/
|
||||
constructor(public readonly key: string, public options: PersistOptions<T> = {}) {
|
||||
this.storage = options.storage || localStorage;
|
||||
this.load();
|
||||
}
|
||||
|
||||
/** Delete value from storage */
|
||||
clear() {
|
||||
this.storage.removeItem(this.key);
|
||||
}
|
||||
|
||||
/** Save current value to storage */
|
||||
save() {
|
||||
if(this._value === undefined) this.clear();
|
||||
else this.storage.setItem(this.key, JSON.stringify(this._value));
|
||||
this.notify(this.value);
|
||||
}
|
||||
|
||||
/** Load value from storage */
|
||||
load() {
|
||||
if(this.storage[this.key] != undefined) {
|
||||
let value = JSON.parse(<string>this.storage.getItem(this.key));
|
||||
if(value != null && typeof value == 'object' && this.options.type) value.__proto__ = this.options.type.prototype;
|
||||
this.value = value;
|
||||
} else this.value = this.options.default || <T>undefined;
|
||||
}
|
||||
|
||||
/** Callback to listen for changes */
|
||||
watch(fn: (value: T) => any): () => void {
|
||||
const index = Object.keys(this.watches).length;
|
||||
this.watches[index] = fn;
|
||||
return () => { delete this.watches[index]; };
|
||||
}
|
||||
|
||||
/** Return value as JSON string */
|
||||
toString() { return JSON.stringify(this.value); }
|
||||
|
||||
/** Return raw value */
|
||||
valueOf() { return this.value?.valueOf(); }
|
||||
|
||||
/** Notify listeners of change */
|
||||
private notify(value: T) {
|
||||
Object.values(this.watches).forEach(watch => watch(value));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync class property with persistent storage (LocalStorage by default)
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* class ThemeEngine {
|
||||
* @persist({default: 'os'}) current!: string;
|
||||
* }
|
||||
*
|
||||
* const theme = new ThemeEngine();
|
||||
* console.log(theme.current) // Output: os
|
||||
*
|
||||
* theme.current = 'light'; //Any changes will be automatically saved to localStorage
|
||||
*
|
||||
* location.reload(); // Simulate refresh
|
||||
* console.log(theme.current) // Output: light
|
||||
* ```
|
||||
*
|
||||
* @group Decorators
|
||||
* @param {PersistOptions<T> & {key?: string}} options Configure using {@link PersistOptions}
|
||||
* @returns Decorator function
|
||||
*/
|
||||
export function persist<T>(options?: PersistOptions<T> & { key?: string }) {
|
||||
return (target: any, prop: any) => {
|
||||
const key = options?.key || `${target.constructor.name}.${prop.toString()}`;
|
||||
const wrapper = new Persist(key, options);
|
||||
Object.defineProperty(target, prop, {
|
||||
get: function() { return wrapper.value; },
|
||||
set: function(v) { wrapper.value = v; }
|
||||
});
|
||||
};
|
||||
}
|
93
tests/persist.spec.ts
Normal file
93
tests/persist.spec.ts
Normal file
@ -0,0 +1,93 @@
|
||||
import {MemoryStorage, Persist, persist} from "../src";
|
||||
|
||||
const storage = new MemoryStorage();
|
||||
(<any>global).localStorage = storage;
|
||||
|
||||
describe('Persistence Library', () => {
|
||||
beforeEach(() => storage.clear());
|
||||
|
||||
describe('Proxy Object', () => {
|
||||
test('Null Values', () => {
|
||||
let persist = new Persist('test');
|
||||
expect(persist.value).toBeUndefined();
|
||||
persist.value = null;
|
||||
persist = new Persist('test');
|
||||
expect(persist.value).toBeNull();
|
||||
persist.value = 0;
|
||||
persist = new Persist('test');
|
||||
expect(persist.value).toBe(0);
|
||||
persist.value = false;
|
||||
persist = new Persist('test');
|
||||
expect(persist.value).toBeFalsy();
|
||||
persist.value = undefined;
|
||||
persist = new Persist('test');
|
||||
expect(persist.value).toBeUndefined();
|
||||
});
|
||||
test('Number Value', () => {
|
||||
const value = 0;
|
||||
let persist = new Persist('test');
|
||||
persist.value = value;
|
||||
persist = new Persist('test');
|
||||
expect(persist.value).toBe(value);
|
||||
});
|
||||
test('Boolean Value', () => {
|
||||
const value = true;
|
||||
let persist = new Persist('test');
|
||||
persist.value = value;
|
||||
persist = new Persist('test');
|
||||
expect(persist.value).toStrictEqual(value);
|
||||
});
|
||||
test('String Value', () => {
|
||||
const value = 'abc';
|
||||
let persist = new Persist('test');
|
||||
persist.value = value;
|
||||
persist = new Persist('test');
|
||||
expect(persist.value).toBe(value);
|
||||
});
|
||||
test('Array Value', () => {
|
||||
const value = [9, 8, 7];
|
||||
let persist = new Persist('test');
|
||||
persist.value = value;
|
||||
persist = new Persist('test');
|
||||
expect(persist.value).toEqual(value);
|
||||
});
|
||||
test('Object Value', () => {
|
||||
const value = {a: 0, b: 'c'};
|
||||
let persist = new Persist<any>('test');
|
||||
persist.value = value;
|
||||
persist = new Persist('test');
|
||||
expect(persist.value).toEqual(value);
|
||||
persist.value.b = 'test';
|
||||
persist = new Persist('test');
|
||||
expect(persist.value).toEqual({...value, b: 'test'});
|
||||
});
|
||||
test('Default Value', () => {
|
||||
let persist = new Persist('test');
|
||||
persist.value = undefined;
|
||||
persist = new Persist('test', {default: true});
|
||||
expect(persist.value).toBeTruthy();
|
||||
});
|
||||
test('Type/Prototype', () => {
|
||||
class TestObj {
|
||||
constructor(public a: number, public b: number) { }
|
||||
|
||||
sum() { return this.a + this.b; }
|
||||
}
|
||||
|
||||
const a = 1, b = 3;
|
||||
const value = new TestObj(a, b);
|
||||
let persist = new Persist<TestObj>('test');
|
||||
persist.value = value;
|
||||
persist = new Persist<TestObj>('test', {type: TestObj});
|
||||
expect(persist.value).toEqual(value);
|
||||
expect(persist.value.sum()).toEqual(a + b);
|
||||
});
|
||||
test('Impure Functions', () => {
|
||||
let persist = new Persist<number[]>('test');
|
||||
persist.value = [1];
|
||||
persist.value.push(2);
|
||||
persist = new Persist('test');
|
||||
expect(persist.value).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
});
|
19
tsconfig.json
Normal file
19
tsconfig.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"experimentalDecorators": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": [
|
||||
"ESNext",
|
||||
"DOM"
|
||||
],
|
||||
"module": "commonjs",
|
||||
"outDir": "./dist",
|
||||
"strict": true,
|
||||
"target": "ESNext"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
]
|
||||
}
|
Loading…
Reference in New Issue
Block a user