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