UNPKG

@kaminaduck/scryfall-mcp-server

Version:

A Model Context Protocol (MCP) server that provides access to the Scryfall API for Magic: The Gathering card data

385 lines 16.7 kB
/** * Database management for tracking downloaded Scryfall cards. */ import Database from 'better-sqlite3'; import { dirname } from 'node:path'; import { mkdir } from 'node:fs/promises'; import { v4 as uuidv4 } from 'uuid'; import { getDatabasePath } from './config.js'; import { logger } from './logger.js'; export class CardDatabase { db; dbPath; constructor(dbPath) { this.dbPath = dbPath || ''; this.db = null; // Will be initialized in init() } /** * Initialize the database connection and schema. */ async init(dbPath) { try { // Use provided path or get from config if (dbPath) { this.dbPath = dbPath; } else if (!this.dbPath) { this.dbPath = await getDatabasePath(); } // Ensure the directory exists await mkdir(dirname(this.dbPath), { recursive: true }); // Create database connection with better error handling try { this.db = new Database(this.dbPath); logger.info(`Database connection established: ${this.dbPath}`); } catch (dbError) { throw new Error(`Failed to create database connection at ${this.dbPath}: ${dbError.message || dbError}`); } // Initialize the database schema await this.initDb(); // Verify database integrity after initialization await this.checkDatabaseIntegrity(); } catch (error) { logger.error(`Database initialization failed: ${error.message}`); throw new Error(`Database initialization failed: ${error.message}`); } } /** * Create the necessary tables if they don't exist. */ async initDb() { try { // Create the main table with proper schema matching Python version this.db.exec(` CREATE TABLE IF NOT EXISTS downloaded_cards ( id INTEGER PRIMARY KEY AUTOINCREMENT, card_name TEXT NOT NULL, filename TEXT NOT NULL, download_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, card_id TEXT, set_code TEXT, image_url TEXT, file_id TEXT UNIQUE ) `); logger.info("Database table created/verified successfully"); // Check if we need to migrate the database to support multiple versions const tableInfo = this.db.prepare("PRAGMA table_info(downloaded_cards)").all(); let hasOldUniqueConstraint = false; // Check for old schema where card_name had unique constraint const indices = this.db.prepare("PRAGMA index_list(downloaded_cards)").all(); for (const index of indices) { if (index.unique === 1) { const indexInfo = this.db.prepare(`PRAGMA index_info(${index.name})`).all(); for (const col of indexInfo) { if (col.name === 'card_name') { hasOldUniqueConstraint = true; break; } } } } if (hasOldUniqueConstraint) { logger.info("Migrating database to support multiple card versions..."); try { // Create a new table without the card_name unique constraint this.db.exec(` CREATE TABLE IF NOT EXISTS downloaded_cards_new ( id INTEGER PRIMARY KEY AUTOINCREMENT, card_name TEXT NOT NULL, filename TEXT NOT NULL, download_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, card_id TEXT, set_code TEXT, image_url TEXT, file_id TEXT UNIQUE ) `); // Copy data from old table to new table this.db.exec(` INSERT INTO downloaded_cards_new (id, card_name, filename, download_date, card_id, set_code, image_url, file_id) SELECT id, card_name, filename, download_date, card_id, set_code, image_url, file_id FROM downloaded_cards `); // Drop old table and rename new table this.db.exec('DROP TABLE downloaded_cards'); this.db.exec('ALTER TABLE downloaded_cards_new RENAME TO downloaded_cards'); logger.info("Database migration completed successfully."); } catch (migrationError) { logger.error(`Database migration failed: ${migrationError.message}`); throw new Error(`Database migration failed: ${migrationError.message}`); } } // Check if file_id column exists, add if not const columns = tableInfo.map(col => col.name); if (!columns.includes('file_id')) { logger.info("Adding file_id column to database..."); try { this.db.exec('ALTER TABLE downloaded_cards ADD COLUMN file_id TEXT UNIQUE'); // Generate file_ids for existing records const existingCards = this.db.prepare('SELECT id FROM downloaded_cards WHERE file_id IS NULL').all(); const updateStmt = this.db.prepare('UPDATE downloaded_cards SET file_id = ? WHERE id = ?'); for (const card of existingCards) { const fileId = uuidv4(); updateStmt.run(fileId, card.id); } logger.info("file_id column added successfully."); } catch (columnError) { logger.error(`Failed to add file_id column: ${columnError.message}`); throw new Error(`Failed to add file_id column: ${columnError.message}`); } } // Ensure all existing records have file_ids const missingFileIds = this.db.prepare('SELECT id FROM downloaded_cards WHERE file_id IS NULL').all(); if (missingFileIds.length > 0) { logger.info(`Generating file_ids for ${missingFileIds.length} existing records...`); const updateStmt = this.db.prepare('UPDATE downloaded_cards SET file_id = ? WHERE id = ?'); for (const card of missingFileIds) { const fileId = uuidv4(); updateStmt.run(fileId, card.id); } } } catch (error) { logger.error(`Database schema initialization failed: ${error.message}`); throw new Error(`Database schema initialization failed: ${error.message}`); } } /** * Check database integrity and repair common issues. */ async checkDatabaseIntegrity() { try { // Run SQLite integrity check const integrityResult = this.db.prepare("PRAGMA integrity_check").get(); if (integrityResult.integrity_check !== 'ok') { logger.error(`Database integrity check failed: ${integrityResult.integrity_check}`); throw new Error(`Database integrity check failed: ${integrityResult.integrity_check}`); } // Check for duplicate file_ids const duplicateFileIds = this.db.prepare(` SELECT file_id, COUNT(*) as count FROM downloaded_cards WHERE file_id IS NOT NULL GROUP BY file_id HAVING COUNT(*) > 1 `).all(); if (duplicateFileIds.length > 0) { logger.warn(`Found ${duplicateFileIds.length} duplicate file_ids, fixing...`); for (const duplicate of duplicateFileIds) { const records = this.db.prepare('SELECT id FROM downloaded_cards WHERE file_id = ? ORDER BY id').all(duplicate.file_id); // Keep the first record, update others with new file_ids for (let i = 1; i < records.length; i++) { const record = records[i]; if (record) { const newFileId = uuidv4(); this.db.prepare('UPDATE downloaded_cards SET file_id = ? WHERE id = ?').run(newFileId, record.id); } } } logger.info("Duplicate file_ids resolved."); } // Check for missing file_ids const missingFileIds = this.db.prepare('SELECT COUNT(*) as count FROM downloaded_cards WHERE file_id IS NULL').get(); if (missingFileIds.count > 0) { logger.warn(`Found ${missingFileIds.count} records without file_ids, fixing...`); const records = this.db.prepare('SELECT id FROM downloaded_cards WHERE file_id IS NULL').all(); const updateStmt = this.db.prepare('UPDATE downloaded_cards SET file_id = ? WHERE id = ?'); for (const record of records) { const newFileId = uuidv4(); updateStmt.run(newFileId, record.id); } logger.info("Missing file_ids resolved."); } // Validate transform card consistency // await this.validateTransformCardConsistency(); logger.info("Database integrity check completed successfully."); } catch (error) { logger.error(`Database integrity check failed: ${error.message}`); throw new Error(`Database integrity check failed: ${error.message}`); } } /** * Repair database by removing orphaned records and fixing common issues. */ async repairDatabase() { try { let recordsRemoved = 0; let issuesFixed = 0; // Remove records with empty filenames const emptyFilenames = this.db.prepare('DELETE FROM downloaded_cards WHERE filename IS NULL OR filename = ""').run(); recordsRemoved += emptyFilenames.changes; // Remove records with empty card names const emptyCardNames = this.db.prepare('DELETE FROM downloaded_cards WHERE card_name IS NULL OR card_name = ""').run(); recordsRemoved += emptyCardNames.changes; // Fix missing download_date const missingDates = this.db.prepare('UPDATE downloaded_cards SET download_date = CURRENT_TIMESTAMP WHERE download_date IS NULL').run(); issuesFixed += missingDates.changes; // Vacuum database to reclaim space this.db.exec('VACUUM'); logger.info(`Database repair completed: ${recordsRemoved} records removed, ${issuesFixed} issues fixed`); return { recordsRemoved, issuesFixed }; } catch (error) { logger.error(`Database repair failed: ${error.message}`); throw new Error(`Database repair failed: ${error.message}`); } } cardExists(cardName, checkAllFaces = false) { if (checkAllFaces) { // Check if any face of the card exists (for transform cards) const baseName = this.extractBaseName(cardName); const stmt = this.db.prepare("SELECT 1 FROM downloaded_cards WHERE card_name LIKE ? OR card_name = ?"); const result = stmt.get(`${baseName}%`, cardName); return result !== undefined; } else { // Exact match (existing behavior) const stmt = this.db.prepare("SELECT 1 FROM downloaded_cards WHERE card_name = ?"); const result = stmt.get(cardName); return result !== undefined; } } /** * Add a card to the database after downloading. */ addCard(cardName, filename, cardId, setCode, imageUrl, fileId) { const finalFileId = fileId || uuidv4(); const stmt = this.db.prepare(` INSERT OR REPLACE INTO downloaded_cards (card_name, filename, card_id, set_code, image_url, file_id, download_date) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) `); stmt.run(cardName, filename, cardId || null, setCode || null, imageUrl || null, finalFileId); return finalFileId; } /** * Get information about a downloaded card. */ getCardInfo(cardName) { const stmt = this.db.prepare("SELECT * FROM downloaded_cards WHERE card_name = ?"); return stmt.get(cardName); } /** * Extract base card name from a potentially face-specific card name. * For transform cards, removes the face suffix. */ extractBaseName(cardName) { // Remove face information like " (Face 0: Docent of Perfection)" const facePattern = /\s+\(Face \d+: .+\)$/; return cardName.replace(facePattern, ''); } /** * Get all faces of a card by its base name. */ getCardsByBaseName(baseName) { const stmt = this.db.prepare("SELECT * FROM downloaded_cards WHERE card_name LIKE ? OR card_name = ? ORDER BY card_name"); return stmt.all(`${baseName} (Face %`, baseName); } /** * Get all faces of a specific card version. */ getTransformCardFaces(cardName, setCode, collectorNumber) { const baseName = this.extractBaseName(cardName); let query = "SELECT * FROM downloaded_cards WHERE (card_name LIKE ? OR card_name = ?)"; const params = [`${baseName} (Face %`, baseName]; if (setCode) { query += " AND set_code = ?"; params.push(setCode); } if (collectorNumber) { query += " AND card_name LIKE ?"; params.push(`%_${collectorNumber}%`); } query += " ORDER BY card_name"; const stmt = this.db.prepare(query); return stmt.all(...params); } /** * Check if a card has multiple faces in the database. */ isTransformCard(cardName) { const faces = this.getCardsByBaseName(this.extractBaseName(cardName)); return faces.length > 1; } /** * Add a transform card face with specific face information. */ addTransformCardFace(baseName, faceName, faceIndex, filename, cardId, setCode, imageUrl, fileId) { const finalFileId = fileId || uuidv4(); const faceCardName = `${baseName} (Face ${faceIndex}: ${faceName})`; const stmt = this.db.prepare(` INSERT OR REPLACE INTO downloaded_cards (card_name, filename, card_id, set_code, image_url, file_id, download_date) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) `); stmt.run(faceCardName, filename, cardId || null, setCode || null, imageUrl || null, finalFileId); return finalFileId; } /** * Get information about a specific face of a card. */ getCardFaceInfo(cardName, faceIndex) { const baseName = this.extractBaseName(cardName); const stmt = this.db.prepare("SELECT * FROM downloaded_cards WHERE card_name LIKE ?"); const pattern = `${baseName} (Face ${faceIndex}:%`; return stmt.get(pattern); } /** * Get a list of all downloaded cards. */ getAllCards() { const stmt = this.db.prepare("SELECT * FROM downloaded_cards ORDER BY download_date DESC"); return stmt.all(); } /** * Remove a card from the database. */ removeCard(cardName) { const stmt = this.db.prepare("DELETE FROM downloaded_cards WHERE card_name = ?"); const result = stmt.run(cardName); return result.changes > 0; } /** * Get card information by file ID. */ getCardByFileId(fileId) { const stmt = this.db.prepare("SELECT * FROM downloaded_cards WHERE file_id = ?"); return stmt.get(fileId); } /** * Get the file path for a given file ID. */ getFilePathById(fileId) { const stmt = this.db.prepare("SELECT filename FROM downloaded_cards WHERE file_id = ?"); const result = stmt.get(fileId); return result?.filename; } /** * Close the database connection. */ close() { if (this.db) { this.db.close(); } } /** * Get the underlying database instance for advanced operations. */ getDatabase() { return this.db; } } /** * Create a database instance with automatic initialization. */ export async function createCardDatabase(dbPath) { const db = new CardDatabase(dbPath); await db.init(); return db; } //# sourceMappingURL=database.js.map