@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
JavaScript
/**
* 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