wip LocalState refactoring

This commit is contained in:
Martti Malmi 2023-08-28 12:37:30 +03:00
parent 583c8863fa
commit 53b3a47623
6 changed files with 271 additions and 213 deletions

View File

@ -0,0 +1,37 @@
import localForage from 'localforage';
import { Adapter, Callback } from '@/state/types.ts';
// Localforage returns null if an item is not found, so we represent null with this uuid instead.
// not foolproof, but good enough for now.
const LOCALFORAGE_NULL = 'c2fc1ad0-f76f-11ec-b939-0242ac120002';
localForage.config({
driver: [localForage.LOCALSTORAGE, localForage.INDEXEDDB, localForage.WEBSQL],
});
const unsub = () => {};
export default class LocalForageAdapter extends Adapter {
get(path: string, callback: Callback) {
localForage.getItem(path).then((result) => {
if (result === null) {
result = undefined;
} else if (result === LOCALFORAGE_NULL) {
result = null;
}
callback(result, path, unsub);
});
return unsub;
}
set(path: string, data: any) {
if (data === null) {
localForage.setItem(path, LOCALFORAGE_NULL);
} else if (data === undefined) {
localForage.removeItem(path);
} else {
localForage.setItem(path, data);
}
}
}

View File

@ -1,216 +1,4 @@
import localForage from 'localforage'; import Node from './Node';
import debounce from 'lodash/debounce';
import throttle from 'lodash/throttle';
export type Unsubscribe = () => void;
export type Callback = (data: any, path: string, unsubscribe: Unsubscribe) => void;
// Localforage returns null if an item is not found, so we represent null with this uuid instead.
// not foolproof, but good enough for now.
const LOCALFORAGE_NULL = 'c2fc1ad0-f76f-11ec-b939-0242ac120002';
const notInLocalForage = new Set();
localForage.config({
driver: [localForage.LOCALSTORAGE, localForage.INDEXEDDB, localForage.WEBSQL],
});
/**
Our very own implementation of the Gun (https://github.com/amark/gun) API. Used for local state management.
*/
export class Node {
id: string;
parent: Node | null;
children = new Map<string, Node>();
on_subscriptions = new Map();
map_subscriptions = new Map();
value: any = undefined;
counter = 0;
loaded = false;
/** */
constructor(id = '', parent: Node | null = null) {
this.id = id;
this.parent = parent;
}
saveLocalForage = throttle(async () => {
if (!this.loaded) {
await this.loadLocalForage();
}
if (this.children.size) {
const children = Array.from(this.children.keys());
await localForage.setItem(this.id, children);
} else if (this.value === undefined) {
await localForage.removeItem(this.id);
} else {
await localForage.setItem(this.id, this.value === null ? LOCALFORAGE_NULL : this.value);
}
}, 500);
loadLocalForage = throttle(async () => {
if (notInLocalForage.has(this.id)) {
return undefined;
}
// try to get the value from localforage
let result = await localForage.getItem(this.id);
// getItem returns null if not found
if (result === null) {
result = undefined;
notInLocalForage.add(this.id);
} else if (result === LOCALFORAGE_NULL) {
result = null;
} else if (Array.isArray(result)) {
// result is a list of children
const newResult = {};
await Promise.all(
result.map(async (key) => {
newResult[key] = await this.get(key).once();
}),
);
result = newResult;
} else {
// result is a value
this.value = result;
}
this.loaded = true;
return result;
}, 500);
doCallbacks = debounce(
() => {
for (const [id, callback] of this.on_subscriptions) {
const unsubscribe = () => this.on_subscriptions.delete(id);
this.once(callback, unsubscribe, false);
}
if (this.parent) {
this.parent.doCallbacks(); // maybe this shouldn't be recursive after all? in a file tree analogy, you wouldn't want
// a change in a subdirectory to trigger a callback in all parent directories.
// there could be a separate open() fn for recursive subscriptions.
for (const [id, callback] of this.parent.map_subscriptions) {
const unsubscribe = () => this.parent?.map_subscriptions.delete(id);
this.once(callback, unsubscribe, false);
}
}
},
20,
{ maxWait: 40 },
);
/**
*
* @param key
* @returns {Node}
* @example node.get('users').get('alice').put({name: 'Alice'})
*/
get(key) {
const existing = this.children.get(key);
if (existing) {
return existing;
}
const new_node = new Node(`${this.id}/${key}`, this);
this.children.set(key, new_node);
this.saveLocalForage();
return new_node;
}
/**
* Set a value to the node. If the value is an object, it will be converted to child nodes.
* @param value
* @example node.get('users').get('alice').put({name: 'Alice'})
*/
async put(value) {
// console.log('put', this.id, value);
if (Array.isArray(value)) {
throw new Error("Sorry, we don't deal with arrays");
}
if (typeof value === 'object' && value !== null) {
this.value = undefined;
await Promise.all(Object.entries(value).map(([key, val]) => this.get(key).put(val)));
} else {
this.children = new Map();
this.value = value;
}
this.doCallbacks();
return this.saveLocalForage();
}
// protip: the code would be a lot cleaner if you separated the Node API from storage adapters.
/**
* Return a value without subscribing to it
* @param callback
* @param event
* @param returnIfUndefined
* @returns {Promise<*>}
*/
async once(
callback?: Callback,
unsubscribe?: Unsubscribe,
returnIfUndefined = true,
): Promise<any> {
let result: any;
if (this.children.size) {
// return an object containing all children
result = {};
await Promise.all(
Array.from(this.children.keys()).map(async (key) => {
result[key] = await this.get(key).once(undefined, unsubscribe);
}),
);
} else if (this.value !== undefined) {
result = this.value;
} else {
result = await this.loadLocalForage();
}
if (result !== undefined || returnIfUndefined) {
callback &&
callback(
result,
this.id.slice(this.id.lastIndexOf('/') + 1),
unsubscribe ||
(() => {
/* do nothing */
}),
);
return result;
}
}
/**
* Subscribe to a value
* @param callback
*/
on(callback: Callback): Unsubscribe {
const id = this.counter++;
this.on_subscriptions.set(id, callback);
const unsubscribe = () => this.on_subscriptions.delete(id);
this.once(callback, unsubscribe, false);
return unsubscribe;
}
/**
* Subscribe to the children of a node. Callback is called separately for each child.
* @param callback
* @returns {Promise<void>}
*/
map(callback: Callback): Unsubscribe {
const id = this.counter++;
this.map_subscriptions.set(id, callback);
const unsubscribe = () => this.map_subscriptions.delete(id);
const go = () => {
for (const child of this.children.values()) {
child.once(callback, unsubscribe, false);
}
};
if (this.loaded) {
go();
} else {
// ensure that the list of children is loaded
this.loadLocalForage()?.then(go);
}
return unsubscribe;
}
}
const localState = new Node(); const localState = new Node();

50
src/js/state/Node.test.ts Normal file
View File

@ -0,0 +1,50 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { Callback, Unsubscribe } from '@/state/types.ts';
import Node from './Node';
describe('Node', () => {
beforeEach(() => {
vi.resetAllMocks();
});
it('should initialize with defaults', () => {
const node = new Node({ adapters: [] });
expect(node.id).toEqual('');
});
it('should subscribe and unsubscribe with on()', () => {
const node = new Node({ id: 'test', adapters: [] });
const mockCallback: Callback = vi.fn();
const unsubscribe: Unsubscribe = node.on(mockCallback);
expect(typeof unsubscribe).toBe('function');
node.put('someValue');
expect(mockCallback).toHaveBeenCalledWith('someValue', 'test', expect.any(Function));
unsubscribe();
node.put('someValue2');
expect(mockCallback).toHaveBeenCalledTimes(1);
});
it('should callback when subscribed after put()', () => {
const node = new Node({ id: 'test', adapters: [] });
const mockCallback: Callback = vi.fn();
node.put('someValue');
node.on(mockCallback);
expect(mockCallback).toHaveBeenCalledWith('someValue', 'test', expect.any(Function));
});
it('should trigger callback once when calling once()', async () => {
const node = new Node({ id: 'test', adapters: [] });
const mockCallback: Callback = vi.fn();
node.put('someValue');
const result = await node.once(mockCallback);
expect(result).toBe('someValue');
expect(mockCallback).toHaveBeenCalledWith('someValue', 'test', expect.any(Function));
});
});

158
src/js/state/Node.ts Normal file
View File

@ -0,0 +1,158 @@
import debounce from 'lodash/debounce';
import LocalForageAdapter from '@/state/LocalForageAdapter.ts';
import { Adapter, Callback, Unsubscribe } from '@/state/types.ts';
/**
Inspired by https://github.com/amark/gun
*/
type NodeProps = {
id?: string;
adapters?: Adapter[];
parent?: Node | null;
};
export default class Node {
id: string;
parent: Node | null;
children = new Map<string, Node>();
on_subscriptions = new Map();
map_subscriptions = new Map();
adapters: Adapter[];
value: any = undefined;
counter = 0;
constructor({ id = '', adapters, parent = null }: NodeProps = {}) {
this.id = id;
this.parent = parent;
this.adapters = adapters ?? parent?.adapters ?? [new LocalForageAdapter()];
}
doCallbacks = debounce(
() => {
for (const [id, callback] of this.on_subscriptions) {
const unsubscribe = () => this.on_subscriptions.delete(id);
this.once(callback, unsubscribe, false);
}
if (this.parent) {
this.parent.doCallbacks(); // maybe this shouldn't be recursive after all? in a file tree analogy, you wouldn't want
// a change in a subdirectory to trigger a callback in all parent directories.
// there could be a separate open() fn for recursive subscriptions.
for (const [id, callback] of this.parent.map_subscriptions) {
const unsubscribe = () => this.parent?.map_subscriptions.delete(id);
this.once(callback, unsubscribe, false);
}
}
},
20,
{ maxWait: 40, leading: true },
);
/**
*
* @param key
* @returns {Node}
* @example node.get('users').get('alice').put({name: 'Alice'})
*/
get(key) {
const existing = this.children.get(key);
if (existing) {
return existing;
}
const new_node = new Node({ id: `${this.id}/${key}`, parent: this });
this.children.set(key, new_node);
return new_node;
}
/**
* Set a value to the node. If the value is an object, it will be converted to child nodes.
* @param value
* @example node.get('users').get('alice').put({name: 'Alice'})
*/
async put(value) {
// console.log('put', this.id, value);
if (Array.isArray(value)) {
throw new Error("Sorry, we don't deal with arrays");
}
if (typeof value === 'object' && value !== null) {
this.value = undefined;
await Promise.all(Object.entries(value).map(([key, val]) => this.get(key).put(val)));
} else {
this.children = new Map();
this.value = value;
}
this.doCallbacks();
this.adapters.forEach((adapter) => adapter.set(this.id, this.value));
}
/**
* Return a value without subscribing to it
* @param callback
* @param event
* @param returnIfUndefined
* @returns {Promise<*>}
*/
async once(
callback?: Callback,
unsubscribe?: Unsubscribe,
returnIfUndefined = true,
): Promise<any> {
let result: any;
if (this.children.size) {
// return an object containing all children
result = {};
await Promise.all(
Array.from(this.children.keys()).map(async (key) => {
result[key] = await this.get(key).once(undefined, unsubscribe);
}),
);
} else if (this.value !== undefined) {
result = this.value;
}
if (result !== undefined || returnIfUndefined) {
callback &&
callback(
result,
this.id.slice(this.id.lastIndexOf('/') + 1),
unsubscribe ||
(() => {
/* do nothing */
}),
);
return result;
}
}
/**
* Subscribe to a value
* @param callback
*/
on(callback: Callback): Unsubscribe {
const subId = this.counter++;
this.on_subscriptions.set(subId, callback);
const adapterSubs = this.adapters.map((adapter) => adapter.get(this.id, callback));
const unsubscribe = () => {
this.on_subscriptions.delete(subId);
adapterSubs.forEach((unsub) => unsub());
};
this.once(callback, unsubscribe, false);
return unsubscribe;
}
/**
* Subscribe to the children of a node. Callback is called separately for each child.
* @param callback
* @returns {Promise<void>}
*/
map(callback: Callback): Unsubscribe {
const id = this.counter++;
this.map_subscriptions.set(id, callback);
const unsubscribe = () => this.map_subscriptions.delete(id);
for (const child of this.children.values()) {
child.once(callback, unsubscribe, false);
}
return unsubscribe;
}
}

6
src/js/state/types.ts Normal file
View File

@ -0,0 +1,6 @@
export type Unsubscribe = () => void;
export type Callback = (data: any, path: string, unsubscribe: Unsubscribe) => void;
export abstract class Adapter {
abstract get(path: string, callback: Callback): Unsubscribe;
abstract set(path: string, data: any): void;
}

View File

@ -0,0 +1,19 @@
import View from '@/views/View.tsx';
import useSubscribe from "@/nostr/hooks/useSubscribe.ts";
type Props = {
p?: string;
path: string;
};
const Explorer = ({ p }: Props) => {
const all = useSubscribe({ kinds: [30000], authors: [Key.getPubKey()] });
return (
<View hideSideBar={true}>
<div>{p}</div>
</View>
);
};
export default Explorer;