diff --git a/README.md b/README.md index 9423ad9..25fb5fa 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Some general-purpose utilities used elsewhere in paravel. - `LRUCache` is an implementation of an LRU cache. - `Worker` is an implementation of an asynchronous queue. - `Tools` is a collection of general-purpose utility functions. +- `Store` is an implementation of svelte-like subscribable stores with extra features. ## @coracle.social/util diff --git a/package-lock.json b/package-lock.json index 88d6afe..a04ef00 100644 --- a/package-lock.json +++ b/package-lock.json @@ -391,6 +391,12 @@ "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==", "dev": true }, + "node_modules/@types/throttle-debounce": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@types/throttle-debounce/-/throttle-debounce-5.0.2.tgz", + "integrity": "sha512-pDzSNulqooSKvSNcksnV72nk8p7gRqN8As71Sp28nov1IgmPKWbOEIwAWvBME5pPTtaXJAvG3O4oc76HlQ4kqQ==", + "dev": true + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "5.62.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz", @@ -3029,6 +3035,14 @@ "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true }, + "node_modules/throttle-debounce": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.0.tgz", + "integrity": "sha512-2iQTSgkkc1Zyk0MeVrt/3BvuOXYPl/R8Z0U2xxo9rjwNciaHDG3R+Lm6dh4EeUci49DanvBnuqI6jshoQQRGEg==", + "engines": { + "node": ">=12.22" + } + }, "node_modules/through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", @@ -3323,10 +3337,12 @@ "version": "0.0.1", "license": "MIT", "dependencies": { - "@scure/base": "^1.1.6" + "@scure/base": "^1.1.6", + "throttle-debounce": "^5.0.0" }, "devDependencies": { "@types/events": "^3.0.3", + "@types/throttle-debounce": "^5.0.2", "gts": "^5.0.1", "tsc-multi": "^1.1.0", "typescript": "~5.1.6" diff --git a/packages/lib/Fluent.ts b/packages/lib/Fluent.ts index 4d1e747..238d87e 100644 --- a/packages/lib/Fluent.ts +++ b/packages/lib/Fluent.ts @@ -55,6 +55,8 @@ export class Fluent { forEach = (f: (t: T, i: number) => void) => this.xs.forEach(f) + join = (s: string) => this.valueOf().join(s) + set = (i: number, x: T) => this.clone([...this.xs.slice(0, i), x, ...this.xs.slice(i + 1)]) concat = (xs: T[]) => this.clone(this.xs.concat(xs)) diff --git a/packages/lib/Store.ts b/packages/lib/Store.ts new file mode 100644 index 0000000..7a4f9e3 --- /dev/null +++ b/packages/lib/Store.ts @@ -0,0 +1,329 @@ +import {throttle} from "throttle-debounce" +import {ensurePlural, identity} from "./Tools" + +type Invalidator = (value?: T) => void +type Derivable = Readable | Readable[] +type Subscriber = (value: T) => void +type Unsubscriber = () => void +type R = Record +type M = Map + +export interface Readable { + get: () => T + subscribe(this: void, run: Subscriber, invalidate?: Invalidator): Unsubscriber + derived: (f: (v: T) => U) => Readable + throttle(t: number): Readable +} + +export class Writable implements Readable { + private value: T + private subs: Subscriber[] = [] + + constructor(defaultValue: T, t?: number) { + this.value = defaultValue + + if (t) { + this.notify = throttle(t, this.notify) + } + } + + notify = () => { + for (const sub of this.subs) { + sub(this.value) + } + } + + get() { + return this.value + } + + set(newValue: T) { + this.value = newValue + this.notify() + } + + update(f: (v: T) => T) { + this.set(f(this.value)) + } + + async updateAsync(f: (v: T) => Promise) { + this.set(await f(this.value)) + } + + subscribe(f: Subscriber) { + this.subs.push(f) + + f(this.value) + + return () => { + this.subs.splice(this.subs.findIndex(x => x === f), 1) + } + } + + derived(f: (v: T) => U): Derived { + return new Derived(this, f) + } + + throttle = (t: number): Derived => { + return new Derived(this, identity, t) + } +} + +export class Derived implements Readable { + private callerSubs: Subscriber[] = [] + private mySubs: Unsubscriber[] = [] + private stores: Derivable + private getValue: (values: any) => T + private latestValue: T | undefined + + constructor(stores: Derivable, getValue: (values: any) => T, t = 0) { + this.stores = stores + this.getValue = getValue + + if (t) { + this.notify = throttle(t, this.notify) + } + } + + notify = () => { + this.latestValue = undefined + this.callerSubs.forEach(f => f(this.get())) + } + + getInput() { + if (Array.isArray(this.stores)) { + return this.stores.map(s => s.get()) + } else { + return this.stores.get() + } + } + + get = (): T => { + // Recalculate if we're not subscribed, because we won't get notified when deps change + if (this.latestValue === undefined || this.mySubs.length === 0) { + this.latestValue = this.getValue(this.getInput()) + } + + return this.latestValue + } + + subscribe(f: Subscriber) { + if (this.callerSubs.length === 0) { + for (const s of ensurePlural(this.stores)) { + this.mySubs.push(s.subscribe(this.notify)) + } + } + + this.callerSubs.push(f) + + f(this.get()) + + return () => { + this.callerSubs.splice(this.callerSubs.findIndex(x => x === f), 1) + + if (this.callerSubs.length === 0) { + for (const unsub of this.mySubs.splice(0)) { + unsub() + } + } + } + } + + derived(f: (v: T) => U): Readable { + return new Derived(this, f) as Readable + } + + throttle = (t: number): Readable => { + return new Derived(this, identity, t) + } +} + +export class Key implements Readable { + readonly pk: string + readonly key: string + private base: Writable> + private store: Readable + + constructor(base: Writable>, pk: string, key: string) { + if (!(base.get() instanceof Map)) { + throw new Error("`key` can only be used on map collections") + } + + this.pk = pk + this.key = key + this.base = base + this.store = base.derived(m => m.get(key) as T) + } + + get = () => this.base.get().get(this.key) as T + + subscribe = (f: Subscriber) => this.store.subscribe(f) + + derived = (f: (v: T) => U) => this.store.derived(f) + + throttle = (t: number) => this.store.throttle(t) + + exists = () => this.base.get().has(this.key) + + update = (f: (v: T) => T) => + this.base.update((m: M) => { + if (!this.key) { + throw new Error(`Cannot set key: "${this.key}"`) + } + + // Make sure the pk always get set on the record + const {pk, key} = this + const oldValue = {...m.get(key), [pk]: key} as T + const newValue = {...f(oldValue), [pk]: key} + + m.set(this.key, newValue) + + return m + }) + + set = (v: T) => this.update(() => v) + + merge = (d: Partial) => this.update(v => ({...v, ...d})) + + remove = () => + this.base.update(m => { + m.delete(this.key) + + return m + }) + + pop = () => { + const v = this.get() + + this.remove() + + return v + } +} + +export class DerivedKey implements Readable { + readonly pk: string + readonly key: string + private base: Readable> + private store: Readable + + constructor(base: Readable>, pk: string, key: string) { + if (!(base.get() instanceof Map)) { + throw new Error("`key` can only be used on map collections") + } + + this.pk = pk + this.key = key + this.base = base + this.store = base.derived(m => m.get(key) as T) + } + + get = () => this.base.get().get(this.key) as T + + subscribe = (f: Subscriber) => this.store.subscribe(f) + + derived = (f: (v: T) => U) => this.store.derived(f) + + throttle = (t: number) => this.store.throttle(t) + + exists = () => this.base.get().has(this.key) +} + +export class Collection implements Readable { + readonly pk: string + readonly mapStore: Writable> + readonly listStore: Readable + + constructor(pk: string, t?: number) { + this.pk = pk + this.mapStore = writable(new Map()) + this.listStore = this.mapStore.derived((m: M) => Array.from(m.values())) + + if (t) { + this.mapStore.notify = throttle(t, this.mapStore.notify) + } + } + + get = () => this.listStore.get() + + getMap = () => this.mapStore.get() + + subscribe = (f: Subscriber) => this.listStore.subscribe(f) + + derived = (f: (v: T[]) => U) => this.listStore.derived(f) + + throttle = (t: number) => this.listStore.throttle(t) + + key = (k: string) => new Key(this.mapStore, this.pk, k) + + set = (xs: T[]) => { + const m = new Map() + + for (const x of xs) { + if (!x) { + console.error("Empty value passed to collection store") + } else if (!x[this.pk]) { + console.error(`Value with empty ${this.pk} passed to collection store`, x) + } else { + m.set(x[this.pk], x) + } + } + + this.mapStore.set(m) + } + + update = (f: (v: T[]) => T[]) => this.set(f(this.get())) + + updateAsync = async (f: (v: T[]) => Promise) => this.set(await f(this.get())) + + reject = (f: (v: T) => boolean) => this.update((xs: T[]) => xs.filter(x => !f(x))) + + filter = (f: (v: T) => boolean) => this.update((xs: T[]) => xs.filter(f)) + + map = (f: (v: T) => T) => this.update((xs: T[]) => xs.map(f)) +} + +export class DerivedCollection implements Readable { + readonly listStore: Derived + readonly mapStore: Readable> + + constructor( + readonly pk: string, + stores: Derivable, + getValue: (values: any) => T[], + t = 0, + ) { + this.listStore = new Derived(stores, getValue, t) + this.mapStore = new Derived(this.listStore, xs => new Map(xs.map((x: T) => [x[pk], x]))) + } + + get = () => this.listStore.get() + + getMap = () => this.mapStore.get() + + subscribe = (f: Subscriber) => this.listStore.subscribe(f) + + derived = (f: (v: T[]) => U) => this.listStore.derived(f) + + throttle = (t: number) => this.listStore.throttle(t) + + key = (k: string) => new DerivedKey(this.mapStore, this.pk, k) +} + +export const writable = (v: T) => new Writable(v) + +export const derived = (stores: Derivable, getValue: (values: any) => T) => + new Derived(stores, getValue) + +export const readable = (v: T) => derived(new Writable(v), identity) as Readable + +export const derivedCollection = ( + pk: string, + stores: Derivable, + getValue: (values: any) => T[], +) => new DerivedCollection(pk, stores, getValue) + +export const key = (base: Writable>, pk: string, key: string) => + new Key(base, pk, key) + +export const collection = (pk: string) => new Collection(pk) diff --git a/packages/lib/Tools.ts b/packages/lib/Tools.ts index fcff182..7dee934 100644 --- a/packages/lib/Tools.ts +++ b/packages/lib/Tools.ts @@ -28,6 +28,8 @@ export const toIterable = (x: any) => isIterable(x) ? x : [x] export const stripProtocol = (url: string) => url.replace(/.*:\/\//, "") +export const ensurePlural = (x: T | T[]) => (x instanceof Array ? x : [x]) + export const groupBy = (f: (x: T) => string, xs: T[]) => { const r: Record = {} diff --git a/packages/lib/index.ts b/packages/lib/index.ts index 4e5566a..9f0a840 100644 --- a/packages/lib/index.ts +++ b/packages/lib/index.ts @@ -3,6 +3,7 @@ export * from './Deferred' export * from './Emitter' export * from './Fluent' export * from './LRUCache' -export * from './Worker' +export * from './Store' export * from './Tools' +export * from './Worker' export {default as normalizeUrl} from './normalize-url' diff --git a/packages/lib/package.json b/packages/lib/package.json index 66abdd2..c6e8ef5 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -27,11 +27,13 @@ }, "devDependencies": { "@types/events": "^3.0.3", + "@types/throttle-debounce": "^5.0.2", "gts": "^5.0.1", "tsc-multi": "^1.1.0", "typescript": "~5.1.6" }, "dependencies": { - "@scure/base": "^1.1.6" + "@scure/base": "^1.1.6", + "throttle-debounce": "^5.0.0" } } diff --git a/packages/util/Events.ts b/packages/util/Events.ts index 6d41438..f736eee 100644 --- a/packages/util/Events.ts +++ b/packages/util/Events.ts @@ -68,4 +68,3 @@ export const isChildOf = (child: EventTemplate, parent: Rumor) => { return getIdAndAddress(parent).some(x => parentIds.includes(x)) } -