From 97cfbd3c2259332b79be5c3a208afa30fa9f0b16 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Mon, 8 Jun 2026 14:42:08 -0700 Subject: [PATCH] Add storage adapters --- package.json | 1 + pnpm-lock.yaml | 8 +++ src/{storage.ts => models.ts} | 62 ++++---------------- src/nostr.ts | 11 ++++ src/storage/adapter.ts | 10 ++++ src/storage/adapters/indexeddb.ts | 61 ++++++++++++++++++++ src/storage/adapters/localstorage.ts | 72 +++++++++++++++++++++++ src/storage/index.ts | 85 ++++++++++++++++++++++++++++ 8 files changed, 258 insertions(+), 52 deletions(-) rename src/{storage.ts => models.ts} (71%) create mode 100644 src/storage/adapter.ts create mode 100644 src/storage/adapters/indexeddb.ts create mode 100644 src/storage/adapters/localstorage.ts create mode 100644 src/storage/index.ts diff --git a/package.json b/package.json index 293ac31..5432e24 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "applesauce-relay": "^6.0.3", "applesauce-signers": "^6.0.1", "applesauce-solidjs": "^4.0.0", + "idb": "^8.0.3", "solid-js": "^1.9.13", "solid-toast": "^0.5.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 936e0b9..383f108 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: applesauce-solidjs: specifier: ^4.0.0 version: 4.0.0(typescript@6.0.3) + idb: + specifier: ^8.0.3 + version: 8.0.3 solid-js: specifier: ^1.9.13 version: 1.9.13 @@ -728,6 +731,9 @@ packages: html-entities@2.3.3: resolution: {integrity: sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==} + idb@8.0.3: + resolution: {integrity: sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==} + is-plain-obj@4.1.0: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} @@ -1876,6 +1882,8 @@ snapshots: html-entities@2.3.3: {} + idb@8.0.3: {} + is-plain-obj@4.1.0: {} is-what@4.1.16: {} diff --git a/src/storage.ts b/src/models.ts similarity index 71% rename from src/storage.ts rename to src/models.ts index 0a7df2b..d898f5d 100644 --- a/src/storage.ts +++ b/src/models.ts @@ -1,9 +1,7 @@ -import { createStore, produce } from "solid-js/store" import type { ISigner } from "applesauce-signers" -import { getJson, setJson } from "@welshman/lib" import type { Hex, QuorumMember } from "./protocol" -// ── Model types ─────────────────────────────────────────────────────────────── +// ── Quorum & shard records ──────────────────────────────────────────────────── /** A known quorum — no secret material */ export type QuorumRecord = { @@ -25,13 +23,16 @@ export type ShardRecord = { encryptedShard: string } +// ── Session helpers ─────────────────────────────────────────────────────────── + /** Proof-of-knowledge from a round-1 message */ export type PoKProof = { R: Hex; s: Hex } /** Round-1 broadcast from one participant */ export type Round1Data = { commitments: Hex[]; proof: PoKProof } -/** Phase of a DKG session */ +// ── DKG session ─────────────────────────────────────────────────────────────── + export type DkgPhase = "round1" | "round2" | "confirming" | "complete" /** In-progress DKG session, keyed by the kind 7050 inner event ID */ @@ -52,7 +53,8 @@ export type DkgSession = { declines: Record } -/** Phase of a resharing session */ +// ── Resharing session ───────────────────────────────────────────────────────── + export type ResharingPhase = "round1" | "round2" | "confirming" | "complete" /** In-progress resharing session, keyed by the kind 7054 inner event ID */ @@ -73,7 +75,8 @@ export type ResharingSession = { declines: Record } -/** Phase of a signing session */ +// ── Signing session ─────────────────────────────────────────────────────────── + export type SigningPhase = "round1" | "round2" | "complete" /** In-progress signing session, keyed by the kind 7058 inner event ID */ @@ -94,52 +97,7 @@ export type SigningSession = { declines: Record } -// ── Generic reactive store backed by localStorage ───────────────────────────── - -type RecordStore = Record - -type StoreActions = { - get(id: string): T | undefined - upsert(id: string, value: T): void - patch(id: string, partial: Partial): void - remove(id: string): void - values(): T[] -} - -function makeLocalStore(lsKey: string): [RecordStore, StoreActions] { - const [store, setStore] = createStore>(getJson(lsKey) ?? {}) - - const persist = () => setJson(lsKey, store) - - return [store, { - get: (id) => store[id], - upsert(id, value) { - setStore(produce(s => { s[id] = value })) - persist() - }, - patch(id, partial) { - setStore(produce(s => { - if (s[id]) { Object.assign(s[id] as object, partial) } - })) - persist() - }, - remove(id) { - setStore(produce(s => { delete s[id] })) - persist() - }, - values: () => Object.values(store), - }] -} - -// ── Per-model stores ────────────────────────────────────────────────────────── - -export const [quora, quorumStore] = makeLocalStore("nq:quora") -export const [shards, shardStore] = makeLocalStore("nq:shards") -export const [dkgSessions, dkgStore] = makeLocalStore("nq:dkg") -export const [resharingSessions, resharingStore] = makeLocalStore("nq:resharing") -export const [signingSessions, signingStore] = makeLocalStore("nq:signing") - -// ── Shard encryption helpers ────────────────────────────────────────────────── +// ── Shard / polynomial encryption ──────────────────────────────────────────── // Shards and polynomials are self-encrypted: NIP-44 encrypt(myPubkey, ...). // The signer derives the conversation key from (myPrivkey, myPubkey), which is // unique to the key and opaque to any other party. diff --git a/src/nostr.ts b/src/nostr.ts index 3816168..a7e2b59 100644 --- a/src/nostr.ts +++ b/src/nostr.ts @@ -3,14 +3,25 @@ import { EventStore } from "applesauce-core" import type { NostrEvent } from "applesauce-core/helpers/event" import type { ISigner } from "applesauce-signers" import { parseJson } from "@welshman/lib" +import { storeEvent, getAllEvents, demoteOldEvents } from "./storage" export const eventStore = new EventStore() // Rumors are unsigned inner events — disable sig verification so they store cleanly. eventStore.verifyEvent = undefined +// On startup: run demotion pass then load all stored protocol events into memory. +;(async () => { + await demoteOldEvents() + const events = await getAllEvents() + for (const event of events) { + eventStore.add(event) + } +})() + export function addEvent(event: NostrEvent): void { eventStore.add(event) + storeEvent(event) } // Shape of the object returned by Observable.subscribe() diff --git a/src/storage/adapter.ts b/src/storage/adapter.ts new file mode 100644 index 0000000..87ac162 --- /dev/null +++ b/src/storage/adapter.ts @@ -0,0 +1,10 @@ +import type { NostrEvent } from "applesauce-core/helpers/event" + +export interface EventAdapter { + put(event: NostrEvent): Promise + get(id: string): Promise + getAll(): Promise + getByKind(kind: number): Promise + getByTag(name: string, value: string): Promise + delete(id: string): Promise +} diff --git a/src/storage/adapters/indexeddb.ts b/src/storage/adapters/indexeddb.ts new file mode 100644 index 0000000..6a6687c --- /dev/null +++ b/src/storage/adapters/indexeddb.ts @@ -0,0 +1,61 @@ +import { openDB } from "idb" +import type { IDBPDatabase, DBSchema } from "idb" +import type { NostrEvent } from "applesauce-core/helpers/event" +import type { EventAdapter } from "../adapter" + +interface NQDBSchema extends DBSchema { + events: { + key: string + value: NostrEvent + indexes: { + "by-kind": number + } + } +} + +let dbPromise: Promise> | null = null + +function getDB(): Promise> { + if (!dbPromise) { + dbPromise = openDB("nq", 1, { + upgrade(db) { + const store = db.createObjectStore("events", { keyPath: "id" }) + store.createIndex("by-kind", "kind") + }, + }) + } + return dbPromise +} + +export const indexedDBAdapter: EventAdapter = { + async put(event) { + const db = await getDB() + await db.put("events", event) + }, + + async get(id) { + const db = await getDB() + return db.get("events", id) + }, + + async getAll() { + const db = await getDB() + return db.getAll("events") + }, + + async getByKind(kind) { + const db = await getDB() + return db.getAllFromIndex("events", "by-kind", kind) + }, + + async getByTag(name, value) { + const db = await getDB() + const all = await db.getAll("events") + return all.filter(ev => ev.tags.some(t => t[0] === name && t[1] === value)) + }, + + async delete(id) { + const db = await getDB() + await db.delete("events", id) + }, +} diff --git a/src/storage/adapters/localstorage.ts b/src/storage/adapters/localstorage.ts new file mode 100644 index 0000000..898fa19 --- /dev/null +++ b/src/storage/adapters/localstorage.ts @@ -0,0 +1,72 @@ +import type { NostrEvent } from "applesauce-core/helpers/event" +import type { EventAdapter } from "../adapter" + +const PREFIX_EV = "nq:ev:" +const PREFIX_KI = "nq:ki:" + +function readEvent(id: string): NostrEvent | undefined { + const raw = localStorage.getItem(PREFIX_EV + id) + return raw ? JSON.parse(raw) as NostrEvent : undefined +} + +function addToKindIndex(kind: number, id: string): void { + const key = PREFIX_KI + kind + const ids: string[] = JSON.parse(localStorage.getItem(key) ?? "[]") + if (!ids.includes(id)) { + ids.push(id) + localStorage.setItem(key, JSON.stringify(ids)) + } +} + +function removeFromKindIndex(kind: number, id: string): void { + const key = PREFIX_KI + kind + const ids: string[] = JSON.parse(localStorage.getItem(key) ?? "[]") + const filtered = ids.filter(x => x !== id) + localStorage.setItem(key, JSON.stringify(filtered)) +} + +export const localStorageAdapter: EventAdapter = { + async put(event) { + const existing = readEvent(event.id) + if (!existing) { + addToKindIndex(event.kind, event.id) + } + localStorage.setItem(PREFIX_EV + event.id, JSON.stringify(event)) + }, + + async get(id) { + return readEvent(id) + }, + + async getAll() { + const events: NostrEvent[] = [] + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i) + if (key?.startsWith(PREFIX_EV)) { + const raw = localStorage.getItem(key) + if (raw) { + events.push(JSON.parse(raw) as NostrEvent) + } + } + } + return events + }, + + async getByKind(kind) { + const ids: string[] = JSON.parse(localStorage.getItem(PREFIX_KI + kind) ?? "[]") + return ids.map(id => readEvent(id)).filter(Boolean) as NostrEvent[] + }, + + async getByTag(name, value) { + const all = await this.getAll() + return all.filter(ev => ev.tags.some(t => t[0] === name && t[1] === value)) + }, + + async delete(id) { + const event = readEvent(id) + if (event) { + removeFromKindIndex(event.kind, id) + localStorage.removeItem(PREFIX_EV + id) + } + }, +} diff --git a/src/storage/index.ts b/src/storage/index.ts new file mode 100644 index 0000000..a37d4a4 --- /dev/null +++ b/src/storage/index.ts @@ -0,0 +1,85 @@ +import type { NostrEvent } from "applesauce-core/helpers/event" +import { now } from "@welshman/lib" +import { localStorageAdapter } from "./adapters/localstorage" +import { indexedDBAdapter } from "./adapters/indexeddb" + +export type { EventAdapter } from "./adapter" + +// Only protocol inner events are worth persisting — all other kinds (profiles, +// relay lists, etc.) can be re-fetched from relays on demand. +const PROTOCOL_KIND_MIN = 7050 +const PROTOCOL_KIND_MAX = 7061 + +// Signing-related kinds are demoted to localStorage after 30 days. +// All other protocol events stay in IndexedDB indefinitely. +const SIGNING_KINDS = new Set([7058, 7059, 7060]) +const THIRTY_DAYS_S = 30 * 24 * 60 * 60 + +function isOld(event: NostrEvent): boolean { + return now() - event.created_at > THIRTY_DAYS_S +} + +function isLowPriority(event: NostrEvent): boolean { + return SIGNING_KINDS.has(event.kind) && isOld(event) +} + +function isHighPriority(event: NostrEvent): boolean { + return event.kind >= PROTOCOL_KIND_MIN && event.kind <= PROTOCOL_KIND_MAX +} + +export async function storeEvent(event: NostrEvent): Promise { + if (isLowPriority(event)) { + await localStorageAdapter.put(event) + } else if (isHighPriority(event)) { + await indexedDBAdapter.put(event) + } +} + +export async function getEvent(id: string): Promise { + return (await indexedDBAdapter.get(id)) ?? (await localStorageAdapter.get(id)) +} + +export async function getEventsByKind(kind: number): Promise { + const high = await indexedDBAdapter.getByKind(kind) + if (SIGNING_KINDS.has(kind)) { + const low = await localStorageAdapter.getByKind(kind) + return [...high, ...low] + } + return high +} + +export async function getEventsByTag(name: string, value: string): Promise { + const [high, low] = await Promise.all([ + indexedDBAdapter.getByTag(name, value), + localStorageAdapter.getByTag(name, value), + ]) + return [...high, ...low] +} + +export async function deleteEvent(id: string): Promise { + await Promise.all([ + indexedDBAdapter.delete(id), + localStorageAdapter.delete(id), + ]) +} + +export async function getAllEvents(): Promise { + const [high, low] = await Promise.all([ + indexedDBAdapter.getAll(), + localStorageAdapter.getAll(), + ]) + return [...high, ...low] +} + +/** Move signing events older than 30 days from IndexedDB to localStorage. Call at startup. */ +export async function demoteOldEvents(): Promise { + for (const kind of SIGNING_KINDS) { + const events = await indexedDBAdapter.getByKind(kind) + for (const event of events) { + if (isOld(event)) { + await localStorageAdapter.put(event) + await indexedDBAdapter.delete(event.id) + } + } + } +}