Add storage adapters

This commit is contained in:
Jon Staab
2026-06-08 14:42:08 -07:00
parent 19c741684c
commit 97cfbd3c22
8 changed files with 258 additions and 52 deletions
+10 -52
View File
@@ -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<Hex, string>
}
/** 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<Hex, string>
}
/** 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<Hex, string>
}
// ── Generic reactive store backed by localStorage ─────────────────────────────
type RecordStore<T> = Record<string, T>
type StoreActions<T> = {
get(id: string): T | undefined
upsert(id: string, value: T): void
patch(id: string, partial: Partial<T>): void
remove(id: string): void
values(): T[]
}
function makeLocalStore<T>(lsKey: string): [RecordStore<T>, StoreActions<T>] {
const [store, setStore] = createStore<RecordStore<T>>(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<QuorumRecord>("nq:quora")
export const [shards, shardStore] = makeLocalStore<ShardRecord>("nq:shards")
export const [dkgSessions, dkgStore] = makeLocalStore<DkgSession>("nq:dkg")
export const [resharingSessions, resharingStore] = makeLocalStore<ResharingSession>("nq:resharing")
export const [signingSessions, signingStore] = makeLocalStore<SigningSession>("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.
+11
View File
@@ -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()
+10
View File
@@ -0,0 +1,10 @@
import type { NostrEvent } from "applesauce-core/helpers/event"
export interface EventAdapter {
put(event: NostrEvent): Promise<void>
get(id: string): Promise<NostrEvent | undefined>
getAll(): Promise<NostrEvent[]>
getByKind(kind: number): Promise<NostrEvent[]>
getByTag(name: string, value: string): Promise<NostrEvent[]>
delete(id: string): Promise<void>
}
+61
View File
@@ -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<IDBPDatabase<NQDBSchema>> | null = null
function getDB(): Promise<IDBPDatabase<NQDBSchema>> {
if (!dbPromise) {
dbPromise = openDB<NQDBSchema>("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)
},
}
+72
View File
@@ -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)
}
},
}
+85
View File
@@ -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<void> {
if (isLowPriority(event)) {
await localStorageAdapter.put(event)
} else if (isHighPriority(event)) {
await indexedDBAdapter.put(event)
}
}
export async function getEvent(id: string): Promise<NostrEvent | undefined> {
return (await indexedDBAdapter.get(id)) ?? (await localStorageAdapter.get(id))
}
export async function getEventsByKind(kind: number): Promise<NostrEvent[]> {
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<NostrEvent[]> {
const [high, low] = await Promise.all([
indexedDBAdapter.getByTag(name, value),
localStorageAdapter.getByTag(name, value),
])
return [...high, ...low]
}
export async function deleteEvent(id: string): Promise<void> {
await Promise.all([
indexedDBAdapter.delete(id),
localStorageAdapter.delete(id),
])
}
export async function getAllEvents(): Promise<NostrEvent[]> {
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<void> {
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)
}
}
}
}