const Database = require('better-sqlite3'); const path = require('path'); const fs = require('fs'); /** * Migration System for BREEDR * Automatically runs on startup to ensure schema is correct */ class MigrationRunner { constructor(dbPath) { this.dbPath = dbPath; this.db = null; } connect() { this.db = new Database(this.dbPath); this.db.pragma('foreign_keys = ON'); } close() { if (this.db) { this.db.close(); } } // Get current schema version from database getSchemaVersion() { try { const result = this.db.prepare('SELECT version FROM schema_version ORDER BY version DESC LIMIT 1').get(); return result ? result.version : 0; } catch (error) { this.db.exec(` CREATE TABLE IF NOT EXISTS schema_version ( version INTEGER PRIMARY KEY, applied_at DATETIME DEFAULT CURRENT_TIMESTAMP, description TEXT ) `); return 0; } } // Record migration completion recordMigration(version, description) { this.db.prepare('INSERT OR IGNORE INTO schema_version (version, description) VALUES (?, ?)').run(version, description); } // Check if dogs table has old sire/dam columns hasOldSchema() { const columns = this.db.prepare("PRAGMA table_info(dogs)").all(); return columns.some(col => col.name === 'sire' || col.name === 'dam'); } // Check if litter_id column exists hasLitterIdColumn() { const columns = this.db.prepare("PRAGMA table_info(dogs)").all(); return columns.some(col => col.name === 'litter_id'); } // Check if health_records has the old restrictive CHECK constraint on record_type healthRecordsHasOldConstraint() { try { const row = this.db.prepare( "SELECT sql FROM sqlite_master WHERE type='table' AND name='health_records'" ).get(); if (!row) return false; return row.sql.includes("'test', 'vaccination', 'exam', 'treatment', 'certification'"); } catch (_) { return false; } } // Migration 1: Remove sire/dam columns, use parents table migration001_removeOldParentColumns() { console.log('[Migration 001] Checking for old sire/dam columns...'); if (!this.hasOldSchema()) { console.log('[Migration 001] Schema is already correct, skipping'); return; } console.log('[Migration 001] Found old schema with sire/dam columns'); console.log('[Migration 001] Migrating to parents table...'); this.db.exec('BEGIN TRANSACTION'); try { this.db.exec(` CREATE TABLE IF NOT EXISTS parents ( id INTEGER PRIMARY KEY AUTOINCREMENT, dog_id INTEGER NOT NULL, parent_id INTEGER NOT NULL, parent_type TEXT NOT NULL CHECK(parent_type IN ('sire', 'dam')), FOREIGN KEY (dog_id) REFERENCES dogs(id) ON DELETE CASCADE, FOREIGN KEY (parent_id) REFERENCES dogs(id) ON DELETE CASCADE, UNIQUE(dog_id, parent_type) ); CREATE INDEX IF NOT EXISTS idx_parents_dog ON parents(dog_id); CREATE INDEX IF NOT EXISTS idx_parents_parent ON parents(parent_id); `); this.db.exec('DROP TABLE IF EXISTS dogs_migration_backup'); this.db.exec('CREATE TABLE dogs_migration_backup AS SELECT * FROM dogs'); const backupCount = this.db.prepare('SELECT COUNT(*) as count FROM dogs_migration_backup').get(); console.log(`[Migration 001] Backed up ${backupCount.count} dogs`); const columns = this.db.prepare("PRAGMA table_info(dogs_migration_backup)").all(); const hasSire = columns.some(col => col.name === 'sire'); const hasDam = columns.some(col => col.name === 'dam'); const hasLitterId = columns.some(col => col.name === 'litter_id'); if (hasSire) { const sireResult = this.db.prepare(` INSERT OR IGNORE INTO parents (dog_id, parent_id, parent_type) SELECT id, sire, 'sire' FROM dogs_migration_backup WHERE sire IS NOT NULL `).run(); console.log(`[Migration 001] Migrated ${sireResult.changes} sire relationships`); } if (hasDam) { const damResult = this.db.prepare(` INSERT OR IGNORE INTO parents (dog_id, parent_id, parent_type) SELECT id, dam, 'dam' FROM dogs_migration_backup WHERE dam IS NOT NULL `).run(); console.log(`[Migration 001] Migrated ${damResult.changes} dam relationships`); } this.db.exec('DROP TABLE dogs'); console.log('[Migration 001] Dropped old dogs table'); this.db.exec(` CREATE TABLE dogs ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, registration_number TEXT, microchip TEXT, sex TEXT CHECK(sex IN ('male', 'female')), birth_date DATE, breed TEXT, color TEXT, weight REAL, height REAL, notes TEXT, litter_id INTEGER, photo_urls TEXT, is_active INTEGER DEFAULT 1, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (litter_id) REFERENCES litters(id) ON DELETE SET NULL ) `); console.log('[Migration 001] Created new dogs table'); const columnList = ['id', 'name', 'registration_number', 'microchip', 'sex', 'birth_date', 'breed', 'color', 'weight', 'height', 'notes', 'photo_urls', 'is_active', 'created_at', 'updated_at']; if (hasLitterId) { columnList.splice(11, 0, 'litter_id'); } const columnsStr = columnList.join(', '); this.db.exec(`INSERT INTO dogs (${columnsStr}) SELECT ${columnsStr} FROM dogs_migration_backup`); const restoredCount = this.db.prepare('SELECT COUNT(*) as count FROM dogs').get(); console.log(`[Migration 001] Restored ${restoredCount.count} dogs`); this.db.exec(` CREATE UNIQUE INDEX IF NOT EXISTS idx_dogs_microchip ON dogs(microchip) WHERE microchip IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_dogs_name ON dogs(name); CREATE INDEX IF NOT EXISTS idx_dogs_registration ON dogs(registration_number); `); this.db.exec('DROP TABLE dogs_migration_backup'); this.db.exec('COMMIT'); console.log('[Migration 001] ✓ Migration complete!'); } catch (error) { this.db.exec('ROLLBACK'); console.error('[Migration 001] ✗ Migration failed:', error.message); throw error; } } // Migration 2: Add litter_id column if missing migration002_addLitterIdColumn() { console.log('[Migration 002] Checking for litter_id column...'); if (this.hasLitterIdColumn()) { console.log('[Migration 002] litter_id column already exists, skipping'); return; } console.log('[Migration 002] Adding litter_id column...'); try { this.db.exec(` ALTER TABLE dogs ADD COLUMN litter_id INTEGER REFERENCES litters(id) ON DELETE SET NULL `); console.log('[Migration 002] ✓ litter_id column added'); } catch (error) { console.error('[Migration 002] ✗ Failed to add litter_id:', error.message); throw error; } } // Migration 3: Remove old restrictive CHECK constraint on health_records.record_type // Uses dynamic column detection so it works regardless of which columns exist in the old table migration003_removeHealthRecordTypeConstraint() { console.log('[Migration 003] Checking health_records.record_type constraint...'); if (!this.healthRecordsHasOldConstraint()) { console.log('[Migration 003] No old constraint found, skipping'); return; } console.log('[Migration 003] Rebuilding health_records table to remove old CHECK constraint...'); this.db.exec('BEGIN TRANSACTION'); try { // Backup existing records this.db.exec('DROP TABLE IF EXISTS health_records_migration_backup'); this.db.exec('CREATE TABLE health_records_migration_backup AS SELECT * FROM health_records'); const backupCount = this.db.prepare('SELECT COUNT(*) as count FROM health_records_migration_backup').get(); console.log(`[Migration 003] Backed up ${backupCount.count} health records`); // Dynamically get the columns that actually exist in the backup // This handles old DBs that may be missing newer columns like updated_at const existingCols = this.db.prepare('PRAGMA table_info(health_records_migration_backup)').all(); const existingColNames = existingCols.map(c => c.name); console.log(`[Migration 003] Existing columns: ${existingColNames.join(', ')}`); // Drop old constrained table this.db.exec('DROP TABLE health_records'); // Recreate WITHOUT the old CHECK constraint this.db.exec(` CREATE TABLE health_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, dog_id INTEGER NOT NULL, record_type TEXT NOT NULL, test_type TEXT, test_name TEXT, test_date TEXT NOT NULL, ofa_result TEXT, ofa_number TEXT, performed_by TEXT, expires_at TEXT, document_url TEXT, result TEXT, vet_name TEXT, next_due TEXT, notes TEXT, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')), FOREIGN KEY (dog_id) REFERENCES dogs(id) ON DELETE CASCADE ) `); // Only restore columns that existed in the backup — new columns get their DEFAULT values const newCols = ['id', 'dog_id', 'record_type', 'test_type', 'test_name', 'test_date', 'ofa_result', 'ofa_number', 'performed_by', 'expires_at', 'document_url', 'result', 'vet_name', 'next_due', 'notes', 'created_at', 'updated_at']; const colsToRestore = newCols.filter(c => existingColNames.includes(c)); const colList = colsToRestore.join(', '); console.log(`[Migration 003] Restoring columns: ${colList}`); this.db.exec(` INSERT INTO health_records (${colList}) SELECT ${colList} FROM health_records_migration_backup `); const restoredCount = this.db.prepare('SELECT COUNT(*) as count FROM health_records').get(); console.log(`[Migration 003] Restored ${restoredCount.count} health records`); this.db.exec('DROP TABLE health_records_migration_backup'); this.db.exec('COMMIT'); console.log('[Migration 003] ✓ health_records constraint removed successfully!'); } catch (error) { this.db.exec('ROLLBACK'); console.error('[Migration 003] ✗ Migration failed:', error.message); throw error; } } // Validate final schema validateSchema() { console.log('[Validation] Checking database schema...'); const checks = [ { name: 'Dogs table exists', test: () => { const tables = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='dogs'").all(); return tables.length > 0; } }, { name: 'Dogs table has no sire/dam columns', test: () => { const columns = this.db.prepare("PRAGMA table_info(dogs)").all(); return !columns.some(col => col.name === 'sire' || col.name === 'dam'); } }, { name: 'Parents table exists', test: () => { const tables = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='parents'").all(); return tables.length > 0; } }, { name: 'Litter_id column exists', test: () => this.hasLitterIdColumn() }, { name: 'Litters table exists', test: () => { const tables = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='litters'").all(); return tables.length > 0; } }, { name: 'health_records has no old record_type CHECK constraint', test: () => !this.healthRecordsHasOldConstraint() } ]; let allPassed = true; checks.forEach(check => { const passed = check.test(); const status = passed ? '✓' : '✗'; console.log(`[Validation] ${status} ${check.name}`); if (!passed) allPassed = false; }); if (allPassed) { console.log('[Validation] ✓ All schema checks passed!'); } else { console.warn('[Validation] ⚠ Some schema checks failed'); } return allPassed; } // Run all migrations runMigrations() { console.log('\n' + '='.repeat(60)); console.log('BREEDR Database Migration System'); console.log('='.repeat(60)); console.log(`Database: ${this.dbPath}\n`); this.connect(); try { const currentVersion = this.getSchemaVersion(); console.log(`Current schema version: ${currentVersion}\n`); if (currentVersion < 1) { this.migration001_removeOldParentColumns(); this.recordMigration(1, 'Migrate sire/dam columns to parents table'); } if (currentVersion < 2) { this.migration002_addLitterIdColumn(); this.recordMigration(2, 'Add litter_id column to dogs table'); } if (currentVersion < 3) { this.migration003_removeHealthRecordTypeConstraint(); this.recordMigration(3, 'Remove old record_type CHECK constraint from health_records'); } console.log(''); const isValid = this.validateSchema(); const finalVersion = this.getSchemaVersion(); console.log('\n' + '='.repeat(60)); console.log(`Schema version: ${currentVersion} → ${finalVersion}`); console.log('Migration system complete!'); console.log('='.repeat(60) + '\n'); return isValid; } catch (error) { console.error('\n✗ Migration system failed:', error.message); console.error(error.stack); throw error; } finally { this.close(); } } } function runMigrations(dbPath) { const runner = new MigrationRunner(dbPath); return runner.runMigrations(); } module.exports = { MigrationRunner, runMigrations }; if (require.main === module) { const dbPath = process.env.DB_PATH || path.join(__dirname, '../../data/breedr.db'); runMigrations(dbPath); }