From 15f455387d9545bbb9c463db6fd43f0d102827ad Mon Sep 17 00:00:00 2001 From: jason Date: Mon, 9 Mar 2026 00:58:19 -0500 Subject: [PATCH] Add automatic migration system with schema validation --- server/db/migrations.js | 321 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 321 insertions(+) create mode 100644 server/db/migrations.js diff --git a/server/db/migrations.js b/server/db/migrations.js new file mode 100644 index 0000000..5ff9c37 --- /dev/null +++ b/server/db/migrations.js @@ -0,0 +1,321 @@ +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) { + // schema_version table doesn't exist, create it + 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'); + } + + // 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 { + // Ensure parents table exists + 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); + `); + + // Backup current dogs table + 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`); + + // Migrate parent relationships to parents table + 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`); + } + + // Drop old dogs table + this.db.exec('DROP TABLE dogs'); + console.log('[Migration 001] Dropped old dogs table'); + + // Create new dogs table with correct schema + 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'); + + // Restore data (excluding sire/dam columns) + 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'); // Insert after notes + } + + 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`); + + // Create indexes + 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); + `); + + // Clean up backup + 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; + } + } + + // 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; + } + } + ]; + + 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`); + + // Run migrations in order + 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'); + } + + // Validate final schema + 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 to run migrations +function runMigrations(dbPath) { + const runner = new MigrationRunner(dbPath); + return runner.runMigrations(); +} + +module.exports = { MigrationRunner, runMigrations }; + +// Run migrations if called directly +if (require.main === module) { + const dbPath = process.env.DB_PATH || path.join(__dirname, '../../data/breedr.db'); + runMigrations(dbPath); +}