UNPKG

@nostr-dev-kit/cache-sqlite

Version:

SQLite cache adapter for NDK using better-sqlite3, compatible with Node.js environments.

689 lines (673 loc) 22 kB
// src/index.ts import { NDKEvent as NDKEvent3 } from "@nostr-dev-kit/ndk"; // src/db/database.ts import Database from "better-sqlite3"; import * as fs from "fs"; import * as path from "path"; // src/db/schema.ts var SCHEMA = { events: ` CREATE TABLE IF NOT EXISTS events ( id TEXT PRIMARY KEY, pubkey TEXT, created_at INTEGER, kind INTEGER, tags TEXT, content TEXT, sig TEXT, raw TEXT, deleted INTEGER DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_events_pubkey ON events(pubkey); CREATE INDEX IF NOT EXISTS idx_events_kind ON events(kind); CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at); `, profiles: ` CREATE TABLE IF NOT EXISTS profiles ( pubkey TEXT PRIMARY KEY, profile TEXT, updated_at INTEGER ); `, nutzap_monitor_state: ` CREATE TABLE IF NOT EXISTS nutzap_monitor_state ( id TEXT PRIMARY KEY, state TEXT ); `, decrypted_events: ` CREATE TABLE IF NOT EXISTS decrypted_events ( id TEXT PRIMARY KEY, event TEXT ); `, unpublished_events: ` CREATE TABLE IF NOT EXISTS unpublished_events ( id TEXT PRIMARY KEY, event TEXT, relays TEXT, lastTryAt INTEGER ); `, event_tags: ` CREATE TABLE IF NOT EXISTS event_tags ( event_id TEXT NOT NULL, tag TEXT NOT NULL, value TEXT, PRIMARY KEY (event_id, tag, value) ); CREATE INDEX IF NOT EXISTS idx_event_tags_event_id ON event_tags(event_id); CREATE INDEX IF NOT EXISTS idx_event_tags_tag ON event_tags(tag); `, relay_status: ` CREATE TABLE IF NOT EXISTS relay_status ( url TEXT PRIMARY KEY, last_connected_at INTEGER, dont_connect_before INTEGER, consecutive_failures INTEGER, last_failure_at INTEGER, nip11_data TEXT, nip11_fetched_at INTEGER, metadata TEXT ); `, event_relays: ` CREATE TABLE IF NOT EXISTS event_relays ( event_id TEXT NOT NULL, relay_url TEXT NOT NULL, seen_at INTEGER NOT NULL, PRIMARY KEY (event_id, relay_url) ); CREATE INDEX IF NOT EXISTS idx_event_relays_event_id ON event_relays(event_id); ` }; // src/db/migrations.ts function runMigrations(db) { db.exec(SCHEMA.events); db.exec(SCHEMA.profiles); db.exec(SCHEMA.nutzap_monitor_state); db.exec(SCHEMA.decrypted_events); db.exec(SCHEMA.unpublished_events); db.exec(SCHEMA.event_tags); db.exec(SCHEMA.relay_status); db.exec(SCHEMA.event_relays); } // src/db/database.ts var DatabaseWrapper = class { constructor(dbPath) { this.dbPath = dbPath; const dir = path.dirname(dbPath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } this.db = new Database(dbPath); this.db.pragma("journal_mode = WAL"); runMigrations(this.db); } /** * Get the underlying database instance */ getDatabase() { return this.db; } /** * Execute SQL statements (compatibility with WASM adapter) */ exec(sql) { this.db.exec(sql); } /** * Prepare a statement */ prepare(sql) { return this.db.prepare(sql); } /** * Execute a query and return results in WASM-compatible format */ queryExec(sql, params) { try { const stmt = this.db.prepare(sql); const rows = params ? stmt.all(...params) : stmt.all(); if (rows.length === 0) { return []; } const columns = Object.keys(rows[0]); const values = rows.map((row) => columns.map((col) => row[col])); return [ { columns, values } ]; } catch (error) { console.error("Query execution error:", error); return []; } } /** * Close the database connection */ close() { this.db.close(); } /** * Get database path */ getPath() { return this.dbPath; } }; function initializeDatabase(dbPath, dbName) { const finalPath = dbPath || path.join(process.cwd(), "data", `${dbName || "ndk-cache"}.db`); return new DatabaseWrapper(finalPath); } // src/functions/fetchProfile.ts async function fetchProfile(pubkey) { if (!this.db) throw new Error("Database not initialized"); const stmt = "SELECT profile, updated_at FROM profiles WHERE pubkey = ? LIMIT 1"; try { const prepared = this.db.getDatabase().prepare(stmt); const result = prepared.get(pubkey); if (result && result.profile) { try { const profile = JSON.parse(result.profile); return { ...profile, cachedAt: result.updated_at }; } catch { return null; } } return null; } catch (e) { console.error("Error fetching profile:", e); return null; } } // src/functions/getEvent.ts import { NDKEvent } from "@nostr-dev-kit/ndk"; async function getEvent(id) { const stmt = "SELECT raw FROM events WHERE id = ? AND deleted = 0 LIMIT 1"; if (!this.db) throw new Error("DB not initialized"); try { const prepared = this.db.getDatabase().prepare(stmt); const result = prepared.get(id); if (result && result.raw) { try { const eventData = JSON.parse(result.raw); return new NDKEvent(this.ndk, eventData); } catch { return null; } } return null; } catch (e) { console.error("Error retrieving event:", e); return null; } } // src/functions/getProfiles.ts async function getProfiles(filter) { if (!this.db) throw new Error("Database not initialized"); const stmt = "SELECT pubkey, profile FROM profiles"; try { const prepared = this.db.getDatabase().prepare(stmt); const rows = prepared.all(); const result = /* @__PURE__ */ new Map(); const filterFn = typeof filter === "function" ? filter : (pubkey, profile) => { const searchLower = filter.contains.toLowerCase(); const fields = filter.fields || (filter.field ? [filter.field] : ["name", "displayName", "nip05"]); return fields.some((field) => { const value = profile[field]; return typeof value === "string" && value.toLowerCase().includes(searchLower); }); }; for (const row of rows) { try { const profile = JSON.parse(row.profile); if (filterFn(row.pubkey, profile)) { result.set(row.pubkey, profile); } } catch (e) { console.error("Error parsing profile:", e); } } return result; } catch (e) { console.error("Error fetching profiles:", e); return void 0; } } // src/functions/getRelayStatus.ts function getRelayStatus(relayUrl) { if (!this.db) throw new Error("Database not initialized"); const stmt = ` SELECT last_connected_at, dont_connect_before, consecutive_failures, last_failure_at, nip11_data, nip11_fetched_at, metadata FROM relay_status WHERE url = ? LIMIT 1 `; try { const prepared = this.db.getDatabase().prepare(stmt); const result = prepared.get(relayUrl); if (result) { const info = { lastConnectedAt: result.last_connected_at || void 0, dontConnectBefore: result.dont_connect_before || void 0, consecutiveFailures: result.consecutive_failures || void 0, lastFailureAt: result.last_failure_at || void 0 }; if (result.nip11_data && result.nip11_fetched_at) { try { info.nip11 = { data: JSON.parse(result.nip11_data), fetchedAt: result.nip11_fetched_at }; } catch (e) { console.error("Error parsing NIP-11 data:", e); } } if (result.metadata) { try { info.metadata = JSON.parse(result.metadata); } catch (e) { console.error("Error parsing metadata:", e); } } return info; } return void 0; } catch (e) { console.error("Error getting relay status:", e); return void 0; } } // src/functions/query.ts import { matchFilter, NDKEvent as NDKEvent2 } from "@nostr-dev-kit/ndk"; // src/functions/getEventRelays.ts function getEventRelays(db, eventIds) { if (eventIds.length === 0) return /* @__PURE__ */ new Map(); const placeholders = eventIds.map(() => "?").join(","); const sql = ` SELECT event_id, relay_url, seen_at FROM event_relays WHERE event_id IN (${placeholders}) ORDER BY event_id, seen_at ASC `; const rows = db.getDatabase().prepare(sql).all(...eventIds); const map = /* @__PURE__ */ new Map(); for (const row of rows) { if (!map.has(row.event_id)) { map.set(row.event_id, []); } map.get(row.event_id).push({ url: row.relay_url, seenAt: row.seen_at }); } return map; } // src/functions/query.ts function query(subscription) { if (!this.db) throw new Error("Database not initialized"); const cacheFilters = filterForCache(subscription); const results = /* @__PURE__ */ new Map(); const addResults = (events) => { for (const event of events) { if (event && event.id) results.set(event.id, event); } }; for (const filter of cacheFilters) { const hasHashtagFilter = Object.keys(filter).some((key) => key.startsWith("#") && key.length === 2); if (hasHashtagFilter) { for (const key in filter) { if (key.startsWith("#") && key.length === 2) { const tagValues = Array.isArray(filter[key]) ? filter[key] : []; const placeholders = tagValues.map(() => "?").join(","); const sql = ` SELECT * FROM events INNER JOIN event_tags ON events.id = event_tags.event_id WHERE event_tags.tag = ? AND event_tags.value IN (${placeholders}) ORDER BY created_at DESC `; const params = [key[1], ...tagValues]; const stmt = this.db.getDatabase().prepare(sql); const rows = stmt.all(...params); if (rows && rows.length > 0) addResults(foundEvents(subscription, rows, filter)); break; } } } else if (filter.authors && filter.kinds) { const sql = ` SELECT * FROM events WHERE pubkey IN (${filter.authors.map(() => "?").join(",")}) AND kind IN (${filter.kinds.map(() => "?").join(",")}) ORDER BY created_at DESC `; const params = [...filter.authors, ...filter.kinds]; const stmt = this.db.getDatabase().prepare(sql); const rows = stmt.all(...params); if (rows && rows.length > 0) addResults(foundEvents(subscription, rows, filter)); } else if (filter.authors) { const sql = ` SELECT * FROM events WHERE pubkey IN (${filter.authors.map(() => "?").join(",")}) ORDER BY created_at DESC `; const params = filter.authors; const stmt = this.db.getDatabase().prepare(sql); const rows = stmt.all(...params); if (rows && rows.length > 0) addResults(foundEvents(subscription, rows, filter)); } else if (filter.kinds) { const sql = ` SELECT * FROM events WHERE kind IN (${filter.kinds.map(() => "?").join(",")}) ORDER BY created_at DESC `; const params = filter.kinds; const stmt = this.db.getDatabase().prepare(sql); const rows = stmt.all(...params); if (rows && rows.length > 0) addResults(foundEvents(subscription, rows, filter)); } else if (filter.ids) { const sql = ` SELECT * FROM events WHERE id IN (${filter.ids.map(() => "?").join(",")}) ORDER BY created_at DESC `; const params = filter.ids; const stmt = this.db.getDatabase().prepare(sql); const rows = stmt.all(...params); if (rows && rows.length > 0) addResults(foundEvents(subscription, rows, filter)); } } const eventIds = Array.from(results.keys()); const relayData = getEventRelays(this.db, eventIds); for (const [eventId, event] of results) { const relays = relayData.get(eventId) || []; restoreRelaysOnEvent(event, relays, subscription); } return Array.from(results.values()); } function filterForCache(subscription) { if (!subscription.cacheUnconstrainFilter) return subscription.filters; const filterCopy = subscription.filters.map((filter) => ({ ...filter })); return filterCopy.filter((filter) => { for (const key of subscription.cacheUnconstrainFilter) { delete filter[key]; } return Object.keys(filter).length > 0; }); } function foundEvents(subscription, records, filter) { const result = []; let now; for (const record of records) { const event = foundEvent(subscription, record, record.relay, filter); if (event) { const expiration = event.tagValue("expiration"); if (expiration) { now ?? (now = Math.floor(Date.now() / 1e3)); if (now > Number.parseInt(expiration)) continue; } result.push(event); if (filter?.limit && result.length >= filter.limit) break; } } return result; } function foundEvent(subscription, record, relayUrl, filter) { try { const deserializedEvent = JSON.parse(record.raw); if (filter) { const { limit, ...filterForMatch } = filter; if (!matchFilter(filterForMatch, deserializedEvent)) { return null; } } const ndkEvent = new NDKEvent2(subscription.ndk, deserializedEvent); return ndkEvent; } catch (e) { console.error("failed to deserialize event", e, record.raw); return null; } } function restoreRelaysOnEvent(event, relays, subscription) { if (relays.length === 0) return; const primaryRelay = subscription.pool.getRelay(relays[0].url, false); if (primaryRelay) { event.relay = primaryRelay; } if (subscription.ndk) { for (const relayData of relays) { const relay = subscription.pool.getRelay(relayData.url, false); if (relay) { subscription.ndk.subManager.seenEvent(event.id, relay); } } } } // src/functions/saveProfile.ts async function saveProfile(pubkey, profile) { if (!this.db) throw new Error("Database not initialized"); const stmt = ` INSERT OR REPLACE INTO profiles (pubkey, profile, updated_at) VALUES (?, ?, ?) `; const profileStr = JSON.stringify(profile); const updatedAt = Math.floor(Date.now() / 1e3); try { const prepared = this.db.getDatabase().prepare(stmt); prepared.run(pubkey, profileStr, updatedAt); } catch (e) { console.error("Error saving profile:", e); throw e; } } // src/functions/setEvent.ts async function setEvent(event, _filters, _relay) { if (!this.db) throw new Error("DB not initialized"); const stmt = ` INSERT OR REPLACE INTO events ( id, pubkey, created_at, kind, tags, content, sig, raw, deleted ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `; const tags = JSON.stringify(event.tags ?? []); const raw = JSON.stringify(event.rawEvent()); const values = [ event.id ?? "", event.pubkey ?? "", event.created_at ?? 0, event.kind ?? 0, tags, event.content ?? "", event.sig ?? "", raw, 0 ]; try { const db = this.db.getDatabase(); const prepared = db.prepare(stmt); prepared.run(...values); if (event.tags && event.tags.length > 0) { const deleteTagsStmt = db.prepare("DELETE FROM event_tags WHERE event_id = ?"); deleteTagsStmt.run(event.id); const insertTagStmt = db.prepare( "INSERT OR IGNORE INTO event_tags (event_id, tag, value) VALUES (?, ?, ?)" ); for (const tag of event.tags) { if (tag.length >= 2) { const tagName = tag[0]; const tagValue = tag[1]; insertTagStmt.run(event.id, tagName, tagValue); } } } if (_relay?.url) { const insertRelayStmt = db.prepare( "INSERT OR IGNORE INTO event_relays (event_id, relay_url, seen_at) VALUES (?, ?, ?)" ); insertRelayStmt.run(event.id, _relay.url, Date.now()); } } catch (e) { console.error("Error storing event:", e); throw e; } } // src/functions/updateRelayStatus.ts function updateRelayStatus(relayUrl, info) { if (!this.db) throw new Error("Database not initialized"); try { const existing = this.getRelayStatus(relayUrl); const mergedMetadata = { ...existing?.metadata, ...info.metadata }; const stmt = ` INSERT OR REPLACE INTO relay_status ( url, last_connected_at, dont_connect_before, consecutive_failures, last_failure_at, nip11_data, nip11_fetched_at, metadata ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) `; const prepared = this.db.getDatabase().prepare(stmt); prepared.run( relayUrl, info.lastConnectedAt ?? existing?.lastConnectedAt ?? null, info.dontConnectBefore ?? existing?.dontConnectBefore ?? null, info.consecutiveFailures ?? existing?.consecutiveFailures ?? null, info.lastFailureAt ?? existing?.lastFailureAt ?? null, info.nip11 ? JSON.stringify(info.nip11.data) : existing?.nip11 ? JSON.stringify(existing.nip11.data) : null, info.nip11?.fetchedAt ?? existing?.nip11?.fetchedAt ?? null, Object.keys(mergedMetadata).length > 0 ? JSON.stringify(mergedMetadata) : null ); } catch (e) { console.error("Error updating relay status:", e); } } // src/index.ts var NDKCacheAdapterSqlite = class { constructor(options = {}) { this.locking = false; // Modular method bindings (public field initializers) this.setEvent = setEvent.bind(this); this.setEventDup = this._setEventDup.bind(this); this.getEvent = getEvent.bind(this); this.fetchProfile = fetchProfile.bind(this); this.saveProfile = saveProfile.bind(this); this.query = query.bind(this); this.getProfiles = getProfiles.bind(this); this.updateRelayStatus = updateRelayStatus.bind(this); this.getRelayStatus = getRelayStatus.bind(this); this.getDecryptedEvent = (wrapperId) => { if (!this.db) throw new Error("Database not initialized"); try { const stmt = this.db.getDatabase().prepare("SELECT event FROM decrypted_events WHERE id = ?"); const row = stmt.get(wrapperId); if (row) { const eventData = JSON.parse(row.event); return new NDKEvent3(this.ndk, eventData); } return null; } catch (e) { console.error("Error getting decrypted event:", e); return null; } }; this.addDecryptedEvent = (wrapperId, decryptedEvent) => { if (!this.db) throw new Error("Database not initialized"); try { const stmt = this.db.getDatabase().prepare("INSERT OR REPLACE INTO decrypted_events (id, event) VALUES (?, ?)"); stmt.run(wrapperId, JSON.stringify(decryptedEvent.rawEvent())); } catch (e) { console.error("Error adding decrypted event:", e); } }; this.addUnpublishedEvent = (event, relayUrls) => { if (!this.db) throw new Error("Database not initialized"); try { const stmt = this.db.getDatabase().prepare( "INSERT OR REPLACE INTO unpublished_events (id, event, relays, lastTryAt) VALUES (?, ?, ?, ?)" ); const now = Math.floor(Date.now() / 1e3); stmt.run(event.id, JSON.stringify(event.rawEvent()), JSON.stringify(relayUrls), now); } catch (e) { console.error("Error adding unpublished event:", e); } }; this.getUnpublishedEvents = async () => { if (!this.db) throw new Error("Database not initialized"); try { const stmt = this.db.getDatabase().prepare("SELECT * FROM unpublished_events"); const rows = stmt.all(); return rows.map((row) => { const eventData = JSON.parse(row.event); const event = new NDKEvent3(this.ndk, eventData); const relays = JSON.parse(row.relays); return { event, relays, lastTryAt: row.lastTryAt }; }); } catch (e) { console.error("Error getting unpublished events:", e); return []; } }; this.discardUnpublishedEvent = (eventId) => { if (!this.db) throw new Error("Database not initialized"); try { const stmt = this.db.getDatabase().prepare("DELETE FROM unpublished_events WHERE id = ?"); stmt.run(eventId); } catch (e) { console.error("Error discarding unpublished event:", e); } }; this.dbPath = options.dbPath; this.dbName = options.dbName || "ndk-cache"; } /** * Initializes the database and runs migrations. */ async initializeAsync(ndk) { this.ndk = ndk; this.db = initializeDatabase(this.dbPath, this.dbName); } _setEventDup(event, relay) { if (!this.db) throw new Error("Database not initialized"); if (!relay?.url || !event.id) return; try { const stmt = this.db.getDatabase().prepare("INSERT OR IGNORE INTO event_relays (event_id, relay_url, seen_at) VALUES (?, ?, ?)"); stmt.run(event.id, relay.url, Date.now()); } catch (e) { console.error("Error storing duplicate event relay:", e); } } /** * Close the database connection */ close() { if (this.db) { this.db.close(); } } }; var index_default = NDKCacheAdapterSqlite; export { NDKCacheAdapterSqlite, index_default as default };