This commit is contained in:
Zakary Timson 2023-12-19 22:32:47 -05:00
commit 041558b83b
11 changed files with 4326 additions and 0 deletions

16
.editorconfig Normal file
View 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
View 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

1
README.md Normal file
View File

@ -0,0 +1 @@
# Persist

16
jest.config.js Normal file
View 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

File diff suppressed because it is too large Load Diff

36
package.json Normal file
View 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
View File

@ -0,0 +1,2 @@
export * from './persist';
export * from './memory-storage';

22
src/memory-storage.ts Normal file
View 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
View 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
View 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
View 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/**/*"
]
}