UNPKG

askexperts

Version:

AskExperts SDK: build and use AI experts - ask them questions and pay with bitcoin on an open protocol

650 lines 25.3 kB
import { DatabaseSync } from "node:sqlite"; import { debugDB, debugError } from "../common/debug.js"; import crypto from "crypto"; /** * SQLite implementation of the database for experts, wallets, and docstore servers */ export class DB { /** * Creates a new DB instance * @param dbPath - Path to the SQLite database file */ constructor(dbPath) { debugDB(`Initializing DB with database at: ${dbPath}`); this.db = new DatabaseSync(dbPath); this.initDatabase(); } /** * Initialize the database by creating required tables if they don't exist */ initDatabase() { // Allow concurrent readers this.db.exec("PRAGMA journal_mode = WAL;"); // Wait up to 3 seconds for locks this.db.exec("PRAGMA busy_timeout = 3000;"); // Create wallets table with all columns and indexes this.db.exec(` CREATE TABLE IF NOT EXISTS wallets ( id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, nwc TEXT NOT NULL, default_wallet BOOLEAN NOT NULL DEFAULT 0, user_id TEXT NOT NULL ) `); // Create index for wallets that can't be included in the CREATE TABLE this.db.exec("CREATE INDEX IF NOT EXISTS idx_wallets_default ON wallets (default_wallet)"); this.db.exec("CREATE INDEX IF NOT EXISTS idx_wallets_user_id ON wallets (user_id)"); // Create experts table with all columns and indexes this.db.exec(` CREATE TABLE IF NOT EXISTS experts ( pubkey TEXT PRIMARY KEY, wallet_id TEXT NOT NULL, type TEXT NOT NULL, nickname TEXT NOT NULL UNIQUE, env TEXT NOT NULL, docstores TEXT NOT NULL, privkey TEXT, disabled BOOLEAN NOT NULL DEFAULT 0, user_id TEXT NOT NULL, timestamp INTEGER ) `); // Create indexes for experts that can't be included in the CREATE TABLE this.db.exec("CREATE INDEX IF NOT EXISTS idx_experts_wallet_id ON experts (wallet_id)"); this.db.exec("CREATE INDEX IF NOT EXISTS idx_experts_type ON experts (type)"); this.db.exec("CREATE INDEX IF NOT EXISTS idx_experts_user_id ON experts (user_id)"); // Migration: Add timestamp column if it doesn't exist try { // Check if timestamp column exists const hasTimestampColumn = this.db .prepare("SELECT timestamp FROM experts LIMIT 1") .get(); } catch (error) { // Column doesn't exist, add it this.db.exec("ALTER TABLE experts ADD COLUMN timestamp INTEGER"); // Initialize timestamp for existing records to current time const currentTime = Date.now(); this.db.exec(`UPDATE experts SET timestamp = ${currentTime}`); // Add the index this.db.exec("CREATE INDEX IF NOT EXISTS idx_experts_timestamp ON experts (timestamp)"); } // Create users table with all columns and indexes this.db.exec(` CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, pubkey TEXT NOT NULL UNIQUE, privkey TEXT NOT NULL, user_id_ext TEXT DEFAULT '' ) `); // Create index for users this.db.exec("CREATE INDEX IF NOT EXISTS idx_users_pubkey ON users (pubkey)"); this.db.exec("CREATE INDEX IF NOT EXISTS idx_users_user_id_ext ON users (user_id_ext)"); // Migration: Add user_id_ext column if it doesn't exist try { // Check if user_id_ext column exists const hasUserIdExtColumn = this.db .prepare("SELECT user_id_ext FROM users LIMIT 1") .get(); } catch (error) { // Column doesn't exist, add it this.db.exec("ALTER TABLE users ADD COLUMN user_id_ext TEXT DEFAULT ''"); // Add the index this.db.exec("CREATE INDEX IF NOT EXISTS idx_users_user_id_ext ON users (user_id_ext)"); } } /** * List all wallets * @param user_id - Optional user ID to filter wallets by * @returns Array of wallet objects */ async listWallets(user_id) { let stmt; let rows; if (user_id) { stmt = this.db.prepare("SELECT id, name, nwc, default_wallet as 'default', user_id FROM wallets WHERE user_id = ? ORDER BY id ASC"); rows = stmt.all(user_id); } else { stmt = this.db.prepare("SELECT id, name, nwc, default_wallet as 'default', user_id FROM wallets ORDER BY id ASC"); rows = stmt.all(); } const wallets = rows.map((row) => ({ id: String(row.id || ""), name: String(row.name || ""), nwc: String(row.nwc || ""), default: Boolean(row.default || false), user_id: String(row.user_id || ""), })); return Promise.resolve(wallets); } /** * List wallets by specific IDs * @param ids - Array of wallet IDs to retrieve * @returns Promise resolving to an array of wallet objects matching the provided IDs */ async listWalletsByIds(ids) { if (!ids.length) { return Promise.resolve([]); } // Create placeholders for the SQL query (?, ?, ?, etc.) const placeholders = ids.map(() => "?").join(","); const stmt = this.db.prepare(`SELECT id, name, nwc, default_wallet as 'default', user_id FROM wallets WHERE id IN (${placeholders}) ORDER BY id ASC`); const rows = stmt.all(...ids); const wallets = rows.map((row) => ({ id: String(row.id || ""), name: String(row.name || ""), nwc: String(row.nwc || ""), default: Boolean(row.default || false), user_id: String(row.user_id || ""), })); return Promise.resolve(wallets); } /** * Get a wallet by ID * @param id - ID of the wallet to get * @param user_id - Optional user ID to filter by * @returns The wallet if found, null otherwise */ async getWallet(id, user_id) { let stmt; let row; if (user_id) { stmt = this.db.prepare("SELECT id, name, nwc, default_wallet as 'default', user_id FROM wallets WHERE id = ? AND user_id = ?"); row = stmt.get(id, user_id); } else { stmt = this.db.prepare("SELECT id, name, nwc, default_wallet as 'default', user_id FROM wallets WHERE id = ?"); row = stmt.get(id); } if (!row) { return Promise.resolve(null); } const wallet = { id: String(row.id || ""), name: String(row.name || ""), nwc: String(row.nwc || ""), default: Boolean(row.default || false), user_id: String(row.user_id || ""), }; return Promise.resolve(wallet); } /** * Get a wallet by name * @param name - Name of the wallet to get * @param user_id - Optional user ID to filter by * @returns The wallet if found, null otherwise */ async getWalletByName(name, user_id) { let stmt; let row; if (user_id) { stmt = this.db.prepare("SELECT id, name, nwc, default_wallet as 'default', user_id FROM wallets WHERE name = ? AND user_id = ?"); row = stmt.get(name, user_id); } else { stmt = this.db.prepare("SELECT id, name, nwc, default_wallet as 'default', user_id FROM wallets WHERE name = ?"); row = stmt.get(name); } if (!row) { return Promise.resolve(null); } const wallet = { id: String(row.id || ""), name: String(row.name || ""), nwc: String(row.nwc || ""), default: Boolean(row.default || false), user_id: String(row.user_id || ""), }; return Promise.resolve(wallet); } /** * Get the default wallet * @param user_id - Optional user ID to filter by * @returns The default wallet if found, null otherwise */ async getDefaultWallet(user_id) { let stmt; let row; if (user_id) { stmt = this.db.prepare("SELECT id, name, nwc, default_wallet as 'default', user_id FROM wallets WHERE default_wallet = 1 AND user_id = ? LIMIT 1"); row = stmt.get(user_id); } else { stmt = this.db.prepare("SELECT id, name, nwc, default_wallet as 'default', user_id FROM wallets WHERE default_wallet = 1 LIMIT 1"); row = stmt.get(); } if (!row) { return Promise.resolve(null); } const wallet = { id: String(row.id || ""), name: String(row.name || ""), nwc: String(row.nwc || ""), default: Boolean(row.default || false), user_id: String(row.user_id || ""), }; return Promise.resolve(wallet); } /** * Insert a new wallet * @param wallet - Wallet to insert (without id) * @returns ID of the inserted wallet */ async insertWallet(wallet) { // Check if this is the first wallet, if so mark it as default const walletCount = this.db .prepare("SELECT COUNT(*) as count FROM wallets WHERE user_id = ?") .get(wallet.user_id); const count = walletCount ? Number(walletCount.count) : 0; const isFirstWallet = count === 0; // If this wallet is set as default, unset any existing default wallet if (wallet.default || isFirstWallet) { this.db .prepare("UPDATE wallets SET default_wallet = 0 WHERE default_wallet = 1 AND user_id = ?") .run(wallet.user_id); wallet.default = true; } const stmt = this.db.prepare(` INSERT INTO wallets (id, name, nwc, default_wallet, user_id) VALUES (?, ?, ?, ?, ?) `); // Generate a unique string ID (UUID or similar) const id = crypto.randomUUID(); const result = stmt.run(id, wallet.name, wallet.nwc, wallet.default ? 1 : 0, wallet.user_id); return Promise.resolve(id); } /** * Update an existing wallet * @param wallet - Wallet to update * @returns true if wallet was updated, false otherwise */ async updateWallet(wallet) { // If this wallet is set as default, unset any existing default wallet if (wallet.default) { this.db .prepare("UPDATE wallets SET default_wallet = 0 WHERE default_wallet = 1 AND id != ? AND user_id = ?") .run(wallet.id, wallet.user_id); } const stmt = this.db.prepare(` UPDATE wallets SET name = ?, nwc = ?, default_wallet = ?, user_id = ? WHERE id = ? AND user_id = ? `); const result = stmt.run(wallet.name, wallet.nwc, wallet.default ? 1 : 0, wallet.user_id, wallet.id, wallet.user_id); return Promise.resolve(result.changes > 0); } /** * Delete a wallet * @param id - ID of the wallet to delete * @param user_id - Optional user ID to filter by * @returns true if wallet was deleted, false otherwise */ async deleteWallet(id, user_id) { // Check if there are any experts using this wallet const expertCount = this.db .prepare("SELECT COUNT(*) as count FROM experts WHERE wallet_id = ?") .get(id); const count = expertCount ? Number(expertCount.count) : 0; if (count > 0) { return Promise.reject(new Error(`Cannot delete wallet with ID ${id} because it is used by ${count} experts`)); } let stmt; let result; if (user_id) { stmt = this.db.prepare("DELETE FROM wallets WHERE id = ? AND user_id = ?"); result = stmt.run(id, user_id); } else { stmt = this.db.prepare("DELETE FROM wallets WHERE id = ?"); result = stmt.run(id); } return Promise.resolve(result.changes > 0); } /** * List all experts * @param user_id - Optional user ID to filter experts by * @returns Promise resolving to an array of expert objects */ async listExperts(user_id) { let stmt; let rows; if (user_id) { stmt = this.db.prepare("SELECT * FROM experts WHERE user_id = ? ORDER BY pubkey ASC"); rows = stmt.all(user_id); } else { stmt = this.db.prepare("SELECT * FROM experts ORDER BY pubkey ASC"); rows = stmt.all(); } const experts = rows.map((row) => ({ pubkey: String(row.pubkey || ""), wallet_id: String(row.wallet_id || ""), type: String(row.type || ""), nickname: String(row.nickname || ""), env: String(row.env || ""), docstores: String(row.docstores || ""), privkey: row.privkey ? String(row.privkey) : undefined, disabled: Boolean(row.disabled || false), user_id: String(row.user_id || ""), })); return Promise.resolve(experts); } /** * List experts with timestamp newer than the provided timestamp * @param timestamp - Only return experts with timestamp newer than this * @param limit - Maximum number of experts to return (default: 1000) * @param user_id - Optional user ID to filter experts by * @returns Promise resolving to an array of expert objects */ async listExpertsAfter(timestamp, limit = 1000, user_id) { let stmt; let rows; if (user_id) { stmt = this.db.prepare("SELECT * FROM experts WHERE timestamp > ? AND user_id = ? ORDER BY timestamp ASC LIMIT ?"); rows = stmt.all(timestamp, user_id, limit); } else { stmt = this.db.prepare("SELECT * FROM experts WHERE timestamp > ? ORDER BY timestamp ASC LIMIT ?"); rows = stmt.all(timestamp, limit); } const experts = rows.map((row) => ({ pubkey: String(row.pubkey || ""), wallet_id: String(row.wallet_id || ""), type: String(row.type || ""), nickname: String(row.nickname || ""), env: String(row.env || ""), docstores: String(row.docstores || ""), privkey: row.privkey ? String(row.privkey) : undefined, disabled: Boolean(row.disabled || false), user_id: String(row.user_id || ""), timestamp: row.timestamp ? Number(row.timestamp) : undefined, })); return Promise.resolve(experts); } /** * List experts by specific IDs * @param ids - Array of expert pubkeys to retrieve * @returns Promise resolving to an array of expert objects matching the provided IDs */ async listExpertsByIds(ids) { if (!ids.length) { return Promise.resolve([]); } // Create placeholders for the SQL query (?, ?, ?, etc.) const placeholders = ids.map(() => "?").join(","); const stmt = this.db.prepare(`SELECT * FROM experts WHERE pubkey IN (${placeholders}) ORDER BY pubkey ASC`); const rows = stmt.all(...ids); const experts = rows.map((row) => ({ pubkey: String(row.pubkey || ""), wallet_id: String(row.wallet_id || ""), type: String(row.type || ""), nickname: String(row.nickname || ""), env: String(row.env || ""), docstores: String(row.docstores || ""), privkey: row.privkey ? String(row.privkey) : undefined, disabled: Boolean(row.disabled || false), user_id: String(row.user_id || ""), })); return Promise.resolve(experts); } /** * Get an expert by pubkey * @param pubkey - Pubkey of the expert to get * @param user_id - Optional user ID to filter by * @returns Promise resolving to the expert if found, null otherwise */ async getExpert(pubkey, user_id) { let stmt; let row; if (user_id) { stmt = this.db.prepare("SELECT * FROM experts WHERE pubkey = ? AND user_id = ?"); row = stmt.get(pubkey, user_id); } else { stmt = this.db.prepare("SELECT * FROM experts WHERE pubkey = ?"); row = stmt.get(pubkey); } if (!row) { return Promise.resolve(null); } const expert = { pubkey: String(row.pubkey || ""), wallet_id: String(row.wallet_id || ""), type: String(row.type || ""), nickname: String(row.nickname || ""), env: String(row.env || ""), docstores: String(row.docstores || ""), privkey: row.privkey ? String(row.privkey) : undefined, disabled: Boolean(row.disabled || false), user_id: String(row.user_id || ""), }; return Promise.resolve(expert); } /** * Insert a new expert * @param expert - Expert to insert * @returns Promise resolving to true if expert was inserted, false otherwise */ async insertExpert(expert) { // Check if wallet exists - now using the async getWallet method const wallet = await this.getWallet(expert.wallet_id, expert.user_id); if (!wallet) { throw new Error(`Wallet with ID ${expert.wallet_id} does not exist`); } // Generate timestamp in the DB class const timestamp = Date.now(); const stmt = this.db.prepare(` INSERT INTO experts (pubkey, wallet_id, type, nickname, env, docstores, privkey, disabled, user_id, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); try { stmt.run(expert.pubkey, expert.wallet_id, expert.type, expert.nickname, expert.env, expert.docstores, expert.privkey || null, expert.disabled ? 1 : 0, expert.user_id || "", timestamp); return Promise.resolve(true); } catch (error) { debugError("Error inserting expert:", error); return Promise.resolve(false); } } /** * Update an existing expert * @param expert - Expert to update * @returns Promise resolving to true if expert was updated, false otherwise */ async updateExpert(expert) { // Check if wallet exists - now using the async getWallet method const wallet = await this.getWallet(expert.wallet_id, expert.user_id); if (!wallet) { throw new Error(`Wallet with ID ${expert.wallet_id} does not exist`); } // Generate timestamp in the DB class const timestamp = Date.now(); const stmt = this.db.prepare(` UPDATE experts SET wallet_id = ?, type = ?, nickname = ?, env = ?, docstores = ?, privkey = ?, disabled = ?, user_id = ?, timestamp = ? WHERE pubkey = ? `); const result = stmt.run(expert.wallet_id, expert.type, expert.nickname, expert.env, expert.docstores, expert.privkey || null, expert.disabled ? 1 : 0, expert.user_id || "", timestamp, expert.pubkey); return Promise.resolve(result.changes > 0); } /** * Set the disabled status of an expert * @param pubkey - Pubkey of the expert to update * @param disabled - Whether the expert should be disabled * @param user_id - Optional user ID to filter by * @returns Promise resolving to true if expert was updated, false otherwise */ async setExpertDisabled(pubkey, disabled, user_id) { // Generate timestamp in the DB class const timestamp = Date.now(); let stmt; let result; if (user_id) { stmt = this.db.prepare(` UPDATE experts SET disabled = ?, timestamp = ? WHERE pubkey = ? AND user_id = ? `); result = stmt.run(disabled ? 1 : 0, timestamp, pubkey, user_id); } else { stmt = this.db.prepare(` UPDATE experts SET disabled = ?, timestamp = ? WHERE pubkey = ? `); result = stmt.run(disabled ? 1 : 0, timestamp, pubkey); } return Promise.resolve(result.changes > 0); } /** * Delete an expert * @param pubkey - Pubkey of the expert to delete * @param user_id - Optional user ID to filter by * @returns Promise resolving to true if expert was deleted, false otherwise */ async deleteExpert(pubkey, user_id) { let stmt; let result; if (user_id) { stmt = this.db.prepare("DELETE FROM experts WHERE pubkey = ? AND user_id = ?"); result = stmt.run(pubkey, user_id); } else { stmt = this.db.prepare("DELETE FROM experts WHERE pubkey = ?"); result = stmt.run(pubkey); } return Promise.resolve(result.changes > 0); } /** * List all users * @returns Promise resolving to an array of user objects */ async listUsers() { const stmt = this.db.prepare("SELECT * FROM users ORDER BY id ASC"); const rows = stmt.all(); const users = rows.map((row) => ({ id: String(row.id || ""), pubkey: String(row.pubkey || ""), privkey: String(row.privkey || ""), user_id_ext: String(row.user_id_ext || ""), })); return Promise.resolve(users); } /** * Get a user by ID * @param id - ID of the user to get * @returns Promise resolving to the user if found, null otherwise */ async getUser(id) { const stmt = this.db.prepare("SELECT * FROM users WHERE id = ?"); const row = stmt.get(id); if (!row) { return Promise.resolve(null); } const user = { id: String(row.id || ""), pubkey: String(row.pubkey || ""), privkey: String(row.privkey || ""), user_id_ext: String(row.user_id_ext || ""), }; return Promise.resolve(user); } /** * Get a user by pubkey * @param pubkey - Pubkey of the user to get * @returns Promise resolving to the user if found, null otherwise */ async getUserByPubkey(pubkey) { const stmt = this.db.prepare("SELECT * FROM users WHERE pubkey = ?"); const row = stmt.get(pubkey); if (!row) { return Promise.resolve(null); } const user = { id: String(row.id || ""), pubkey: String(row.pubkey || ""), privkey: String(row.privkey || ""), user_id_ext: String(row.user_id_ext || ""), }; return Promise.resolve(user); } /** * Get a user by external ID * @param user_id_ext - External ID of the user to get * @returns Promise resolving to the user if found, null otherwise */ async getUserByExtId(user_id_ext) { if (!user_id_ext) { return Promise.resolve(null); } const stmt = this.db.prepare("SELECT * FROM users WHERE user_id_ext = ?"); const row = stmt.get(user_id_ext); if (!row) { return Promise.resolve(null); } const user = { id: String(row.id || ""), pubkey: String(row.pubkey || ""), privkey: String(row.privkey || ""), user_id_ext: String(row.user_id_ext || ""), }; return Promise.resolve(user); } /** * Insert a new user * @param user - User to insert (without id) * @returns Promise resolving to the ID of the inserted user */ async insertUser(user) { const stmt = this.db.prepare(` INSERT INTO users (id, pubkey, privkey, user_id_ext) VALUES (?, ?, ?, ?) `); // Generate a unique string ID (UUID) const id = crypto.randomUUID(); try { stmt.run(id, user.pubkey, user.privkey, user.user_id_ext || ""); return Promise.resolve(id); } catch (error) { debugError("Error inserting user:", error); return Promise.reject(error); } } /** * Update an existing user * @param user - User to update * @returns Promise resolving to true if user was updated, false otherwise */ async updateUser(user) { const stmt = this.db.prepare(` UPDATE users SET pubkey = ?, privkey = ?, user_id_ext = ? WHERE id = ? `); const result = stmt.run(user.pubkey, user.privkey, user.user_id_ext || "", user.id); return Promise.resolve(result.changes > 0); } /** * Delete a user * @param id - ID of the user to delete * @returns Promise resolving to true if user was deleted, false otherwise */ async deleteUser(id) { const stmt = this.db.prepare("DELETE FROM users WHERE id = ?"); const result = stmt.run(id); return Promise.resolve(result.changes > 0); } /** * Symbol.dispose method for releasing resources */ [Symbol.dispose]() { this.db.close(); } } //# sourceMappingURL=DB.js.map