Add storage adapters
This commit is contained in:
@@ -20,6 +20,7 @@
|
|||||||
"applesauce-relay": "^6.0.3",
|
"applesauce-relay": "^6.0.3",
|
||||||
"applesauce-signers": "^6.0.1",
|
"applesauce-signers": "^6.0.1",
|
||||||
"applesauce-solidjs": "^4.0.0",
|
"applesauce-solidjs": "^4.0.0",
|
||||||
|
"idb": "^8.0.3",
|
||||||
"solid-js": "^1.9.13",
|
"solid-js": "^1.9.13",
|
||||||
"solid-toast": "^0.5.0"
|
"solid-toast": "^0.5.0"
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+8
@@ -41,6 +41,9 @@ importers:
|
|||||||
applesauce-solidjs:
|
applesauce-solidjs:
|
||||||
specifier: ^4.0.0
|
specifier: ^4.0.0
|
||||||
version: 4.0.0(typescript@6.0.3)
|
version: 4.0.0(typescript@6.0.3)
|
||||||
|
idb:
|
||||||
|
specifier: ^8.0.3
|
||||||
|
version: 8.0.3
|
||||||
solid-js:
|
solid-js:
|
||||||
specifier: ^1.9.13
|
specifier: ^1.9.13
|
||||||
version: 1.9.13
|
version: 1.9.13
|
||||||
@@ -728,6 +731,9 @@ packages:
|
|||||||
html-entities@2.3.3:
|
html-entities@2.3.3:
|
||||||
resolution: {integrity: sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==}
|
resolution: {integrity: sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==}
|
||||||
|
|
||||||
|
idb@8.0.3:
|
||||||
|
resolution: {integrity: sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==}
|
||||||
|
|
||||||
is-plain-obj@4.1.0:
|
is-plain-obj@4.1.0:
|
||||||
resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
|
resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -1876,6 +1882,8 @@ snapshots:
|
|||||||
|
|
||||||
html-entities@2.3.3: {}
|
html-entities@2.3.3: {}
|
||||||
|
|
||||||
|
idb@8.0.3: {}
|
||||||
|
|
||||||
is-plain-obj@4.1.0: {}
|
is-plain-obj@4.1.0: {}
|
||||||
|
|
||||||
is-what@4.1.16: {}
|
is-what@4.1.16: {}
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { createStore, produce } from "solid-js/store"
|
|
||||||
import type { ISigner } from "applesauce-signers"
|
import type { ISigner } from "applesauce-signers"
|
||||||
import { getJson, setJson } from "@welshman/lib"
|
|
||||||
import type { Hex, QuorumMember } from "./protocol"
|
import type { Hex, QuorumMember } from "./protocol"
|
||||||
|
|
||||||
// ── Model types ───────────────────────────────────────────────────────────────
|
// ── Quorum & shard records ────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** A known quorum — no secret material */
|
/** A known quorum — no secret material */
|
||||||
export type QuorumRecord = {
|
export type QuorumRecord = {
|
||||||
@@ -25,13 +23,16 @@ export type ShardRecord = {
|
|||||||
encryptedShard: string
|
encryptedShard: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Session helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Proof-of-knowledge from a round-1 message */
|
/** Proof-of-knowledge from a round-1 message */
|
||||||
export type PoKProof = { R: Hex; s: Hex }
|
export type PoKProof = { R: Hex; s: Hex }
|
||||||
|
|
||||||
/** Round-1 broadcast from one participant */
|
/** Round-1 broadcast from one participant */
|
||||||
export type Round1Data = { commitments: Hex[]; proof: PoKProof }
|
export type Round1Data = { commitments: Hex[]; proof: PoKProof }
|
||||||
|
|
||||||
/** Phase of a DKG session */
|
// ── DKG session ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type DkgPhase = "round1" | "round2" | "confirming" | "complete"
|
export type DkgPhase = "round1" | "round2" | "confirming" | "complete"
|
||||||
|
|
||||||
/** In-progress DKG session, keyed by the kind 7050 inner event ID */
|
/** In-progress DKG session, keyed by the kind 7050 inner event ID */
|
||||||
@@ -52,7 +53,8 @@ export type DkgSession = {
|
|||||||
declines: Record<Hex, string>
|
declines: Record<Hex, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Phase of a resharing session */
|
// ── Resharing session ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type ResharingPhase = "round1" | "round2" | "confirming" | "complete"
|
export type ResharingPhase = "round1" | "round2" | "confirming" | "complete"
|
||||||
|
|
||||||
/** In-progress resharing session, keyed by the kind 7054 inner event ID */
|
/** In-progress resharing session, keyed by the kind 7054 inner event ID */
|
||||||
@@ -73,7 +75,8 @@ export type ResharingSession = {
|
|||||||
declines: Record<Hex, string>
|
declines: Record<Hex, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Phase of a signing session */
|
// ── Signing session ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type SigningPhase = "round1" | "round2" | "complete"
|
export type SigningPhase = "round1" | "round2" | "complete"
|
||||||
|
|
||||||
/** In-progress signing session, keyed by the kind 7058 inner event ID */
|
/** In-progress signing session, keyed by the kind 7058 inner event ID */
|
||||||
@@ -94,52 +97,7 @@ export type SigningSession = {
|
|||||||
declines: Record<Hex, string>
|
declines: Record<Hex, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Generic reactive store backed by localStorage ─────────────────────────────
|
// ── Shard / polynomial encryption ────────────────────────────────────────────
|
||||||
|
|
||||||
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 ──────────────────────────────────────────────────
|
|
||||||
// Shards and polynomials are self-encrypted: NIP-44 encrypt(myPubkey, ...).
|
// Shards and polynomials are self-encrypted: NIP-44 encrypt(myPubkey, ...).
|
||||||
// The signer derives the conversation key from (myPrivkey, myPubkey), which is
|
// The signer derives the conversation key from (myPrivkey, myPubkey), which is
|
||||||
// unique to the key and opaque to any other party.
|
// unique to the key and opaque to any other party.
|
||||||
@@ -3,14 +3,25 @@ import { EventStore } from "applesauce-core"
|
|||||||
import type { NostrEvent } from "applesauce-core/helpers/event"
|
import type { NostrEvent } from "applesauce-core/helpers/event"
|
||||||
import type { ISigner } from "applesauce-signers"
|
import type { ISigner } from "applesauce-signers"
|
||||||
import { parseJson } from "@welshman/lib"
|
import { parseJson } from "@welshman/lib"
|
||||||
|
import { storeEvent, getAllEvents, demoteOldEvents } from "./storage"
|
||||||
|
|
||||||
export const eventStore = new EventStore()
|
export const eventStore = new EventStore()
|
||||||
|
|
||||||
// Rumors are unsigned inner events — disable sig verification so they store cleanly.
|
// Rumors are unsigned inner events — disable sig verification so they store cleanly.
|
||||||
eventStore.verifyEvent = undefined
|
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 {
|
export function addEvent(event: NostrEvent): void {
|
||||||
eventStore.add(event)
|
eventStore.add(event)
|
||||||
|
storeEvent(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shape of the object returned by Observable.subscribe()
|
// Shape of the object returned by Observable.subscribe()
|
||||||
|
|||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user