2026-03-09 00:58:19 -05:00
|
|
|
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');
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-10 13:06:42 -05:00
|
|
|
// 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;
|
|
|
|
|
// Old constraint lists only the 5 legacy types
|
|
|
|
|
return row.sql.includes("'test', 'vaccination', 'exam', 'treatment', 'certification'");
|
|
|
|
|
} catch (_) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 00:58:19 -05:00
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-10 13:06:42 -05:00
|
|
|
// Migration 3: Remove old restrictive CHECK constraint on health_records.record_type
|
|
|
|
|
// The old schema only allowed: 'test','vaccination','exam','treatment','certification'
|
|
|
|
|
// The new schema allows any free-form text (ofa_clearance, etc.)
|
|
|
|
|
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 health 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`);
|
|
|
|
|
|
|
|
|
|
// Drop old table (constraint is baked in and cannot be altered)
|
|
|
|
|
this.db.exec('DROP TABLE health_records');
|
|
|
|
|
|
|
|
|
|
// Recreate WITHOUT the old CHECK constraint on record_type
|
|
|
|
|
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)
|
|
|
|
|
)
|
|
|
|
|
`);
|
|
|
|
|
|
|
|
|
|
// Restore all existing records
|
|
|
|
|
this.db.exec(`
|
|
|
|
|
INSERT INTO health_records
|
|
|
|
|
(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)
|
|
|
|
|
SELECT
|
|
|
|
|
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
|
|
|
|
|
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`);
|
|
|
|
|
|
|
|
|
|
// Clean up backup
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 00:58:19 -05:00
|
|
|
// 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;
|
|
|
|
|
}
|
2026-03-10 13:06:42 -05:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: 'health_records has no old record_type CHECK constraint',
|
|
|
|
|
test: () => !this.healthRecordsHasOldConstraint()
|
2026-03-09 00:58:19 -05:00
|
|
|
}
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-10 13:06:42 -05:00
|
|
|
if (currentVersion < 3) {
|
|
|
|
|
this.migration003_removeHealthRecordTypeConstraint();
|
|
|
|
|
this.recordMigration(3, 'Remove old record_type CHECK constraint from health_records');
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 00:58:19 -05:00
|
|
|
// 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);
|
|
|
|
|
}
|