flotilla/src/app/storage.ts

484 lines
13 KiB
TypeScript
Raw Normal View History

2026-07-28 16:16:26 +00:00
import {writable} from "svelte/store"
import type {Unsubscriber} from "svelte/store"
import {deleteDB} from "idb"
import {SecureStorage} from "@aparajita/capacitor-secure-storage"
import {Preferences} from "@capacitor/preferences"
2026-08-17 23:10:00 +00:00
import {noop, on, throttle, batch, call, makeQueue} from "@welshman/lib"
2026-07-28 16:16:26 +00:00
import type {Maybe} from "@welshman/lib"
2025-09-30 23:11:49 +00:00
import {
2025-11-05 00:25:21 +00:00
ALERT_ANDROID,
ALERT_EMAIL,
ALERT_IOS,
ALERT_STATUS,
ALERT_WEB,
2025-10-24 20:36:59 +00:00
APP_DATA,
BLOSSOM_SERVERS,
2025-09-30 23:11:49 +00:00
FOLLOWS,
2025-11-20 23:12:16 +00:00
MESSAGING_RELAYS,
2025-09-30 23:11:49 +00:00
MUTES,
2025-10-24 20:36:59 +00:00
PROFILE,
RELAY_ADD_MEMBER,
RELAY_JOIN,
RELAY_LEAVE,
RELAY_MEMBERS,
RELAY_REMOVE_MEMBER,
2025-09-30 23:11:49 +00:00
RELAYS,
2025-10-24 20:36:59 +00:00
ROOM_ADD_MEMBER,
ROOM_CREATE_PERMISSION,
ROOM_MEMBERS,
2025-11-03 19:10:16 +00:00
ROOM_ADMINS,
2025-10-24 20:36:59 +00:00
ROOM_META,
2026-07-28 16:16:26 +00:00
ROOM_PINS,
2025-10-30 21:59:39 +00:00
ROOM_DELETE,
2025-10-24 20:36:59 +00:00
ROOM_REMOVE_MEMBER,
2025-09-30 23:11:49 +00:00
ROOMS,
2025-10-13 19:46:56 +00:00
verifiedSymbol,
2025-09-30 23:11:49 +00:00
} from "@welshman/util"
2026-07-28 16:16:26 +00:00
import type {Handle, TrustedEvent} from "@welshman/util"
import {withGetter} from "@welshman/store"
import type {RepositoryUpdate, WrapItem} from "@welshman/net"
2026-07-28 16:16:26 +00:00
import {Relay, Zapper} from "@welshman/domain"
import type {RelayInfo, ZapperValues} from "@welshman/domain"
import {Handles, Plaintext, Relays, RelayStats, User, Zappers} from "@welshman/app"
import type {AppPolicy, IApp, RelayStatsItem} from "@welshman/app"
import {IDB} from "@lib/indexeddb"
2026-07-28 16:16:26 +00:00
import {appPolicies} from "@app/core"
export const kv = call(() => {
2026-08-17 23:10:00 +00:00
const enqueue = makeQueue()
const get = async <T>(key: string): Promise<T | undefined> => {
const result = await Preferences.get({key})
if (!result.value) return undefined
try {
return JSON.parse(result.value)
} catch (e) {
return undefined
}
}
const set = async <T>(key: string, value: T): Promise<void> => {
2026-08-17 23:10:00 +00:00
await enqueue(() => Preferences.set({key, value: JSON.stringify(value)}))
}
const clear = async () => {
2026-08-17 23:10:00 +00:00
await enqueue(() => Preferences.clear())
}
return {get, set, clear}
})
export const ss = call(() => {
2026-08-17 23:10:00 +00:00
const enqueue = makeQueue()
const get = async <T>(key: string): Promise<T | undefined> => {
let value = await SecureStorage.getItem(key)
if (!value) {
const legacy = await Preferences.get({key})
if (legacy.value) {
value = legacy.value
await SecureStorage.setItem(key, legacy.value)
await Preferences.remove({key})
}
}
if (!value) return undefined
try {
return JSON.parse(value)
} catch (e) {
return undefined
}
}
2026-08-17 23:10:00 +00:00
// Android's SecureStorage rejects undefined
const set = async <T>(key: string, value: T): Promise<void> => {
2026-08-17 23:10:00 +00:00
await enqueue(() =>
value === undefined
? SecureStorage.removeItem(key)
: SecureStorage.setItem(key, JSON.stringify(value)),
)
}
const clear = async () => {
2026-08-17 23:10:00 +00:00
await enqueue(() => SecureStorage.clear())
}
return {get, set, clear}
})
2026-07-28 16:16:26 +00:00
const TABLES = [
{name: "events", keyPath: "id"},
{name: "tracker", keyPath: "id"},
{name: "relays", keyPath: "url"},
{name: "relayStats", keyPath: "url"},
{name: "handles", keyPath: "nip05"},
{name: "zappers", keyPath: "lnurl"},
{name: "plaintext", keyPath: "key"},
{name: "wrapManager", keyPath: "id"},
]
2025-09-30 23:11:49 +00:00
const FLUSH_INTERVAL = 3000
const idleWrite = <T>(f: (xs: T[]) => void): ((xs: T[]) => void) => {
if (typeof requestIdleCallback !== "undefined") {
return (xs: T[]) => requestIdleCallback(() => f(xs))
}
return f
}
2025-11-12 01:49:22 +00:00
const kinds = {
2025-11-20 23:12:16 +00:00
meta: [PROFILE, FOLLOWS, MUTES, RELAYS, BLOSSOM_SERVERS, MESSAGING_RELAYS, APP_DATA, ROOMS],
2025-11-12 01:49:22 +00:00
alert: [ALERT_STATUS, ALERT_EMAIL, ALERT_WEB, ALERT_IOS, ALERT_ANDROID],
space: [RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER, RELAY_MEMBERS, RELAY_JOIN, RELAY_LEAVE],
room: [
2025-10-24 20:36:59 +00:00
ROOM_META,
2025-10-30 21:59:39 +00:00
ROOM_DELETE,
2025-11-03 19:10:16 +00:00
ROOM_ADMINS,
2025-10-24 20:36:59 +00:00
ROOM_MEMBERS,
ROOM_ADD_MEMBER,
ROOM_REMOVE_MEMBER,
ROOM_CREATE_PERMISSION,
ROOM_PINS,
2025-11-12 01:49:22 +00:00
],
}
2026-03-12 18:33:04 +00:00
const shouldPersistEvent = (event: TrustedEvent) =>
kinds.meta.includes(event.kind) ||
kinds.alert.includes(event.kind) ||
kinds.space.includes(event.kind) ||
kinds.room.includes(event.kind)
type TrackerItem = {id: string; relays: string[]}
2025-11-12 01:49:22 +00:00
2026-03-12 18:33:04 +00:00
type PlaintextItem = {key: string; value: string}
2026-07-28 16:16:26 +00:00
/**
* Caches an app's repository, tracker and local collections in indexeddb. Everything stored
* here belongs to a single identity, so each gets its own database, named for the pubkey the
* policy below requires before it builds one.
*/
class Storage {
ready: Promise<void>
2025-11-12 01:49:22 +00:00
2026-07-28 16:16:26 +00:00
private db: IDB
private unsubscribers: Unsubscriber[] = []
private timeouts: ReturnType<typeof setTimeout>[] = []
private stopped = false
2025-11-12 01:49:22 +00:00
2026-07-28 16:16:26 +00:00
constructor(private readonly app: IApp) {
// Every identity used to share one database; drop it rather than leave it on disk.
void deleteDB("flotilla-9gl")
2025-11-12 01:49:22 +00:00
2026-07-28 16:16:26 +00:00
this.db = new IDB({name: `flotilla-9gl-${User.require(app).pubkey}`, stores: TABLES})
this.ready = this.start()
2026-03-12 18:33:04 +00:00
}
2026-07-28 16:16:26 +00:00
close = () => this.db.close()
2025-09-30 23:11:49 +00:00
2026-07-28 16:16:26 +00:00
clear = () => this.db.clear()
2025-09-30 23:11:49 +00:00
2026-07-28 16:16:26 +00:00
cleanup = () => {
this.stopped = true
this.timeouts.forEach(clearTimeout)
this.unsubscribers.forEach(call)
this.close()
}
2025-09-30 23:11:49 +00:00
2026-07-28 16:16:26 +00:00
private start = async () => {
await this.db.connect()
2026-07-28 16:16:26 +00:00
const [, unsubscribeRelays] = await Promise.all([this.loadCriticalEvents(), this.initRelays()])
2026-07-28 16:16:26 +00:00
await this.loadCriticalTracker()
2025-09-30 23:11:49 +00:00
2026-07-28 16:16:26 +00:00
this.addUnsubscriber(this.syncEvents())
this.addUnsubscriber(this.syncTracker())
this.addUnsubscriber(unsubscribeRelays)
2025-09-30 23:11:49 +00:00
2026-07-28 16:16:26 +00:00
const defer = (init: () => Promise<Unsubscriber>) => {
this.timeouts.push(
setTimeout(async () => {
if (!this.stopped) {
this.addUnsubscriber(await init())
}
}, 0),
)
}
2025-09-30 23:11:49 +00:00
2026-07-28 16:16:26 +00:00
defer(this.initRelayStats)
defer(this.initHandles)
defer(this.initZappers)
defer(this.initPlaintext)
defer(this.initWrapManager)
2026-03-12 18:33:04 +00:00
}
2025-09-30 23:11:49 +00:00
2026-07-28 16:16:26 +00:00
private addUnsubscriber = (unsubscriber: Unsubscriber) => {
if (this.stopped) {
unsubscriber()
} else {
this.unsubscribers.push(unsubscriber)
}
}
2025-09-30 23:11:49 +00:00
2026-07-28 16:16:26 +00:00
private loadCriticalEvents = async () => {
const table = this.db.table<TrustedEvent>("events")
const initialEvents = await table.getAll()
const keep: TrustedEvent[] = []
const drop: string[] = []
for (const event of initialEvents) {
if (shouldPersistEvent(event)) {
event[verifiedSymbol] = true
keep.push(event)
} else {
drop.push(event.id)
}
}
2026-07-28 16:16:26 +00:00
this.app.repository.load(keep)
2026-07-28 16:16:26 +00:00
if (drop.length > 0) {
void table.bulkDelete(drop)
}
}
2025-10-24 16:38:57 +00:00
2026-07-28 16:16:26 +00:00
private syncEvents = () => {
const eventsTable = this.db.table<TrustedEvent>("events")
const trackerTable = this.db.table<TrackerItem>("tracker")
2026-07-28 16:16:26 +00:00
return on(
this.app.repository,
"update",
batch(3000, async (updates: RepositoryUpdate[]) => {
const add: TrustedEvent[] = []
const remove = new Set<string>()
for (const update of updates) {
for (const event of update.added) {
if (shouldPersistEvent(event)) {
add.push(event)
remove.delete(event.id)
}
}
2025-10-24 16:38:57 +00:00
2026-07-28 16:16:26 +00:00
for (const id of update.removed) {
remove.add(id)
}
}
2026-03-12 18:33:04 +00:00
2026-07-28 16:16:26 +00:00
if (add.length > 0) {
// The ingest policy tracks an event before publishing it, so by the time the
// repository reports it, its provenance is already in the tracker — persist the
// two together so an event is never saved without the relays it came from.
const items: TrackerItem[] = []
for (const {id} of add) {
const relays = Array.from(this.app.tracker.getRelays(id))
if (relays.length > 0) {
items.push({id, relays})
}
}
await eventsTable.bulkPut(add)
await trackerTable.bulkPut(items)
2026-07-28 16:16:26 +00:00
}
2025-10-24 16:38:57 +00:00
2026-07-28 16:16:26 +00:00
if (remove.size > 0) {
await eventsTable.bulkDelete(remove)
await trackerTable.bulkDelete(remove)
2026-07-28 16:16:26 +00:00
}
}),
)
2026-03-12 18:33:04 +00:00
}
2026-07-28 16:16:26 +00:00
private loadCriticalTracker = async () => {
const table = this.db.table<TrackerItem>("tracker")
const relaysById = new Map<string, Set<string>>()
const stale: string[] = []
2026-03-12 18:33:04 +00:00
2026-07-28 16:16:26 +00:00
for (const {id, relays} of await table.getAll()) {
if (this.app.repository.getEvent(id)) {
relaysById.set(id, new Set(relays))
} else {
stale.push(id)
}
}
2026-03-12 18:33:04 +00:00
2026-07-28 16:16:26 +00:00
this.app.tracker.load(relaysById)
2025-09-30 23:11:49 +00:00
2026-07-28 16:16:26 +00:00
if (stale.length > 0) {
void table.bulkDelete(stale)
}
}
2026-07-28 16:16:26 +00:00
private syncTracker = () => {
const table = this.db.table<TrackerItem>("tracker")
2025-09-30 23:11:49 +00:00
2026-07-28 16:16:26 +00:00
const _onAdd = async (ids: Iterable<string>) => {
const items: TrackerItem[] = []
2025-09-30 23:11:49 +00:00
2026-07-28 16:16:26 +00:00
for (const id of ids) {
const event = this.app.repository.getEvent(id)
// A brand-new event is tracked before it's published, so it isn't queryable here —
// syncEvents persists its provenance along with the event itself. This pass only
// records additional relays for events we already have.
2026-07-28 16:16:26 +00:00
if (!event || !shouldPersistEvent(event)) continue
2025-09-30 23:11:49 +00:00
2026-07-28 16:16:26 +00:00
const relays = Array.from(this.app.tracker.getRelays(id))
2026-03-12 18:33:04 +00:00
2026-07-28 16:16:26 +00:00
if (relays.length === 0) continue
2026-07-28 16:16:26 +00:00
items.push({id, relays})
}
2026-07-28 16:16:26 +00:00
await table.bulkPut(items)
}
2025-09-30 23:11:49 +00:00
2026-07-28 16:16:26 +00:00
const _onRemove = async (ids: Iterable<string>) => {
await table.bulkDelete(Array.from(ids))
}
2026-03-12 18:33:04 +00:00
2026-07-28 16:16:26 +00:00
const onAdd = batch(3000, _onAdd)
const onRemove = batch(3000, _onRemove)
const onLoad = () => _onAdd(this.app.tracker.relaysById.keys())
const onClear = () => _onRemove(this.app.tracker.relaysById.keys())
this.app.tracker.on("add", onAdd)
this.app.tracker.on("remove", onRemove)
this.app.tracker.on("load", onLoad)
this.app.tracker.on("clear", onClear)
return () => {
this.app.tracker.off("add", onAdd)
this.app.tracker.off("remove", onRemove)
this.app.tracker.off("load", onLoad)
this.app.tracker.off("clear", onClear)
}
}
2025-09-30 23:11:49 +00:00
2026-07-28 16:16:26 +00:00
private initRelays = async () => {
const table = this.db.table<RelayInfo & {url: string}>("relays")
2026-03-12 18:33:04 +00:00
2026-07-28 16:16:26 +00:00
for (const row of await table.getAll()) {
this.app.use(Relays).set(row.url, new Relay(row.url, row))
}
2026-07-28 16:16:26 +00:00
const enqueue = batch(
FLUSH_INTERVAL,
idleWrite((relays: Relay[]) => table.bulkPut(relays.map(relay => ({...relay})))),
)
2026-07-28 16:16:26 +00:00
return this.app.use(Relays).onItem((_url, relay) => {
if (relay) enqueue(relay)
})
}
2026-03-12 18:33:04 +00:00
2026-07-28 16:16:26 +00:00
private initRelayStats = async () => {
const table = this.db.table<RelayStatsItem>("relayStats")
2025-09-30 23:11:49 +00:00
2026-07-28 16:16:26 +00:00
for (const row of await table.getAll()) {
this.app.use(RelayStats).set(row.url, row)
}
2026-03-12 18:33:04 +00:00
2026-07-28 16:16:26 +00:00
const enqueue = batch(FLUSH_INTERVAL, idleWrite(table.bulkPut))
2026-03-12 18:33:04 +00:00
2026-07-28 16:16:26 +00:00
return this.app.use(RelayStats).onItem((_url, stats) => {
if (stats) enqueue(stats)
})
2026-03-12 18:33:04 +00:00
}
2025-09-30 23:11:49 +00:00
2026-07-28 16:16:26 +00:00
private initHandles = async () => {
const table = this.db.table<Handle>("handles")
2026-03-12 18:33:04 +00:00
2026-07-28 16:16:26 +00:00
for (const row of await table.getAll()) {
this.app.use(Handles).set(row.nip05, row)
2026-03-12 18:33:04 +00:00
}
2026-07-28 16:16:26 +00:00
const enqueue = batch(FLUSH_INTERVAL, idleWrite(table.bulkPut))
2025-11-12 01:49:22 +00:00
2026-07-28 16:16:26 +00:00
return this.app.use(Handles).onItem((_nip05, handle) => {
if (handle) enqueue(handle)
})
2026-03-12 18:33:04 +00:00
}
2026-07-28 16:16:26 +00:00
private initZappers = async () => {
const table = this.db.table<ZapperValues>("zappers")
2026-07-28 16:16:26 +00:00
for (const row of await table.getAll()) {
// Validation is meaningless without these, and rows cached before they were required
// won't have them.
if (row.pubkey && row.nostrPubkey) {
this.app.use(Zappers).set(row.lnurl, new Zapper(row))
}
}
2026-03-12 18:33:04 +00:00
2026-07-28 16:16:26 +00:00
const enqueue = batch(
FLUSH_INTERVAL,
idleWrite((zappers: Zapper[]) => table.bulkPut(zappers.map(zapper => ({...zapper})))),
)
2026-03-12 18:33:04 +00:00
2026-07-28 16:16:26 +00:00
return this.app.use(Zappers).onItem((_lnurl, zapper) => {
if (zapper) enqueue(zapper)
2025-11-12 01:49:22 +00:00
})
2026-07-28 16:16:26 +00:00
}
2026-07-28 16:16:26 +00:00
private initPlaintext = async () => {
const table = this.db.table<PlaintextItem>("plaintext")
2025-11-03 22:43:27 +00:00
2026-07-28 16:16:26 +00:00
for (const {key, value} of await table.getAll()) {
this.app.use(Plaintext).set(key, value)
}
2025-11-03 22:43:27 +00:00
2026-07-28 16:16:26 +00:00
const enqueue = batch(FLUSH_INTERVAL, idleWrite(table.bulkPut))
2025-10-21 15:27:30 +00:00
2026-07-28 16:16:26 +00:00
return this.app.use(Plaintext).onItem((key, value) => {
if (value) enqueue({key, value})
2026-03-12 18:33:04 +00:00
})
2026-07-28 16:16:26 +00:00
}
private initWrapManager = async () => {
const table = this.db.table<WrapItem>("wrapManager")
this.app.wrapManager.load(await table.getAll())
2026-03-12 18:33:04 +00:00
2026-07-28 16:16:26 +00:00
const addOne = batch(3000, table.bulkPut)
const removeOne = throttle(3000, table.bulkDelete)
2025-11-12 01:49:22 +00:00
2026-07-28 16:16:26 +00:00
this.app.wrapManager.on("add", addOne)
this.app.wrapManager.on("remove", removeOne)
return () => {
this.app.wrapManager.off("add", addOne)
this.app.wrapManager.off("remove", removeOne)
2025-11-12 01:49:22 +00:00
}
2026-07-28 16:16:26 +00:00
}
}
2025-11-12 01:49:22 +00:00
2026-07-28 16:16:26 +00:00
// The current app's cache, which only exists once there's an identity to cache for.
export const storage = withGetter(writable<Maybe<Storage>>(undefined))
// Storage is scoped to one app's repository, tracker and caches, so it's built and torn down
// with the app rather than living on as a module-level singleton.
export const storagePolicy: AppPolicy = $app => {
if ($app.user) {
const $storage = new Storage($app)
storage.set($storage)
return () => {
$storage.cleanup()
storage.set(undefined)
}
2026-03-12 18:33:04 +00:00
}
2026-07-28 16:16:26 +00:00
return noop
2026-03-12 18:33:04 +00:00
}
2026-07-28 16:16:26 +00:00
appPolicies.push(storagePolicy)