Files
welshman/packages/app/src/storage.ts
T
2024-12-11 16:41:31 -08:00

248 lines
6.7 KiB
TypeScript

import {openDB, deleteDB} from "idb"
import type {IDBPDatabase} from "idb"
import {writable} from "svelte/store"
import type {Unsubscriber, Writable} from "svelte/store"
import {indexBy, throttle, fromPairs} from "@welshman/lib"
import type {TrustedEvent, Repository} from "@welshman/util"
import type {Tracker} from "@welshman/net"
import {withGetter, adapter, throttled, custom} from "@welshman/store"
export type IndexedDbAdapter = {
keyPath: string
store: Writable<any[]>
}
export let db: IDBPDatabase
export const dead = withGetter(writable(false))
export const subs: Unsubscriber[] = []
export const getAll = async (name: string) => {
const tx = db.transaction(name, "readwrite")
const store = tx.objectStore(name)
const result = await store.getAll()
await tx.done
return result
}
export const bulkPut = async (name: string, data: any[]) => {
const tx = db.transaction(name, "readwrite")
const store = tx.objectStore(name)
await Promise.all(data.map(item => store.put(item)))
await tx.done
}
export const bulkDelete = async (name: string, ids: string[]) => {
const tx = db.transaction(name, "readwrite")
const store = tx.objectStore(name)
await Promise.all(ids.map(id => store.delete(id)))
await tx.done
}
export const initIndexedDbAdapter = async (name: string, adapter: IndexedDbAdapter) => {
let prevRecords = await getAll(name)
adapter.store.set(prevRecords)
adapter.store.subscribe(
async (currentRecords: any[]) => {
if (dead.get()) {
return
}
const currentIds = new Set(currentRecords.map(item => item[adapter.keyPath]))
const removedRecords = prevRecords.filter(r => !currentIds.has(r[adapter.keyPath]))
const prevRecordsById = indexBy(item => item[adapter.keyPath], prevRecords)
const updatedRecords = currentRecords.filter(r => r !== prevRecordsById.get(r[adapter.keyPath]))
prevRecords = currentRecords
if (updatedRecords.length > 0) {
await bulkPut(name, updatedRecords)
}
if (removedRecords.length > 0) {
await bulkDelete(
name,
removedRecords.map(item => item[adapter.keyPath]),
)
}
},
)
}
export const initStorage = async (name: string, version: number, adapters: Record<string, IndexedDbAdapter>) => {
if (!window.indexedDB) return
window.addEventListener("beforeunload", () => closeStorage())
if (db) {
throw new Error("Db initialized multiple times")
}
db = await openDB(name, version, {
upgrade(db: IDBPDatabase) {
const names = Object.keys(adapters)
for (const name of db.objectStoreNames) {
if (!names.includes(name)) {
db.deleteObjectStore(name)
}
}
for (const [name, {keyPath}] of Object.entries(adapters)) {
try {
db.createObjectStore(name, {keyPath})
} catch (e) {
console.warn(e)
}
}
},
})
await Promise.all(
Object.entries(adapters).map(([name, config]) => initIndexedDbAdapter(name, config)),
)
}
export const closeStorage = async () => {
dead.set(true)
subs.forEach(unsub => unsub())
await db?.close()
}
export const clearStorage = async () => {
await closeStorage()
await deleteDB(db.name)
}
export type StorageAdapterOptions = {
throttle?: number
migrate?: (items: any[]) => any[]
}
const migrate = (data: any[], options: StorageAdapterOptions) =>
options.migrate ? options.migrate(data) : data
export const storageAdapters = {
fromObjectStore: <T>(store: Writable<Record<string, T>>, options: StorageAdapterOptions = {}) => ({
options,
keyPath: "key",
store: adapter({
store: throttled(options.throttle || 0, store),
forward: (data: Record<string, T>) =>
migrate(Object.entries(data).map(([key, value]) => ({key, value})), options),
backward: (data: {key: string, value: T}[]) =>
fromPairs(data.map(({key, value}) => [key, value])),
}),
}),
fromMapStore: <T>(store: Writable<Map<string, T>>, options: StorageAdapterOptions = {}) => ({
options,
keyPath: "key",
store: adapter({
store: throttled(options.throttle || 0, store),
forward: (data: Map<string, T>) =>
migrate(Array.from(data.entries()).map(([key, value]) => ({key, value})), options),
backward: (data: {key: string, value: T}[]) =>
new Map(data.map(({key, value}) => [key, value])),
}),
}),
fromTracker: (tracker: Tracker, options: StorageAdapterOptions = {}) => ({
options,
keyPath: 'key',
store: custom(setter => {
let onUpdate = () =>
setter(
migrate(
Array.from(tracker.relaysById.entries())
.map(([key, urls]) => ({key, value: Array.from(urls)})),
options
)
)
if (options.throttle) {
onUpdate = throttle(options.throttle, onUpdate)
}
onUpdate()
tracker.on('update', onUpdate)
return () => tracker.off('update', onUpdate)
}, {
set: (data: {key: string, value: string[]}[]) =>
tracker.load(new Map(data.map(({key, value}) => [key, new Set(value)]))),
}),
}),
fromRepository: (repository: Repository, options: StorageAdapterOptions = {}) => ({
options,
keyPath: 'id',
store: custom(setter => {
let onUpdate = () => setter(migrate(repository.dump(), options))
if (options.throttle) {
onUpdate = throttle(options.throttle, onUpdate)
}
onUpdate()
repository.on('update', onUpdate)
return () => repository.off('update', onUpdate)
}, {
set: (events: TrustedEvent[]) => repository.load(events),
}),
}),
fromRepositoryAndTracker: (
repository: Repository,
tracker: Tracker,
options: StorageAdapterOptions = {}
) => ({
options,
keyPath: 'id',
store: custom(setter => {
let onUpdate = () => {
const events = migrate(repository.dump(), options)
setter(
events.map(event => {
const relays = Array.from(tracker.getRelays(event.id))
return {id: event.id, event, relays}
})
)
}
if (options.throttle) {
onUpdate = throttle(options.throttle, onUpdate)
}
onUpdate()
tracker.on('update', onUpdate)
repository.on('update', onUpdate)
return () => {
tracker.off('update', onUpdate)
}
}, {
set: (items: {event: TrustedEvent, relays: string[]}[]) => {
const events: TrustedEvent[] = []
const relaysById = new Map<string, Set<string>>()
for (const {event, relays} of items) {
events.push(event)
relaysById.set(event.id, new Set(relays))
}
repository.load(events)
tracker.load(relaysById)
},
}),
})
}