@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
JavaScript
// 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
};