diff --git a/src/db/index.ts b/src/db/index.ts index b7daeb68..d20e234e 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -3,6 +3,7 @@ import { drizzle } from 'drizzle-orm/better-sqlite3'; import * as schema from './schema.js'; import { readFileSync } from 'fs'; import { join } from 'path'; +import { applyMigrations, validateSchemaState } from '../migrate.js'; const logger = console; let sqliteClosed = false; @@ -20,46 +21,23 @@ export const db = drizzle(sqlite, { schema }); // Simple migration runner export async function initializeDb() { try { - // Check if migration has already been run - const tableExists = sqlite.prepare(` - SELECT name FROM sqlite_master - WHERE type='table' AND name='apis' - `).get(); - - if (!tableExists) { - logger.info('Running initial migration...'); - const migrationSQL = readFileSync( - join(process.cwd(), 'migrations', '0000_initial_apis_tables.sql'), - 'utf8' - ); - const statements = migrationSQL.split(';').filter(stmt => stmt.trim()); - sqlite.exec('BEGIN TRANSACTION'); - for (const statement of statements) { - if (statement.trim()) sqlite.exec(statement); - } - sqlite.exec('COMMIT'); - logger.info('✅ Initial migration completed'); - } - - const developersExists = sqlite.prepare(` - SELECT name FROM sqlite_master WHERE type='table' AND name='developers' - `).get(); - if (!developersExists) { - logger.info('Running developers migration...'); - const devSQL = readFileSync( - join(process.cwd(), 'migrations', '0004_create_developers.sql'), - 'utf8' - ); - const statements = devSQL.split(';').filter(stmt => stmt.trim()); - sqlite.exec('BEGIN TRANSACTION'); - for (const statement of statements) { - if (statement.trim()) sqlite.exec(statement); - } - sqlite.exec('COMMIT'); - logger.info('✅ Developers migration completed'); + const migrationDir = join(process.cwd(), 'migrations'); + + // In production, we just want to validate the schema is up-to-date. + // In dev/test environments, we automatically apply pending migrations. + const isProd = process.env.NODE_ENV === 'production'; + + if (isProd) { + logger.info('Validating schema state...'); + validateSchemaState(sqlite, migrationDir); + logger.info('✅ Schema validation successful'); + } else { + logger.info('Applying database migrations...'); + applyMigrations(sqlite, migrationDir); + logger.info('✅ Migrations completed'); } } catch (error) { - logger.error('Failed to run database migrations:', error); + logger.error('Failed to initialize database schema:', error); throw error; } } diff --git a/src/migrate.ts b/src/migrate.ts index 3ead5066..4acbe171 100644 --- a/src/migrate.ts +++ b/src/migrate.ts @@ -131,34 +131,64 @@ function ensureSchemaVersionsTable(db: Database.Database): void { `); } +export function applyMigrations(db: Database.Database, migrationDir: string): void { + ensureMigrationsTable(db); + ensureSchemaVersionsTable(db); + const available = discoverMigrations(migrationDir); + + for (const filename of available) { + const isExecuted = db.prepare('SELECT id FROM _migrations WHERE name = ?').get(filename); + if (isExecuted) continue; + + logger.info('Running migration: ' + filename); + const sql = readFileSync(path.join(migrationDir, filename), 'utf8'); + const checksum = computeChecksum(path.join(migrationDir, filename)); + const prefix = extractPrefix(filename)!; + + const run = db.transaction(() => { + db.exec(sql); + db.prepare('INSERT INTO _migrations (name, checksum) VALUES (?, ?)').run(filename, checksum); + db.prepare( + 'INSERT INTO schema_versions (version, filename, checksum) VALUES (?, ?, ?)', + ).run(prefix, filename, checksum); + }); + + run(); + logger.info('Finished ' + filename + ' (checksum: ' + checksum.slice(0, 12) + '...)'); + } +} + +/** + * Validates that all migrations present on disk have been applied to the database. + * Throws an error if there are pending migrations, ensuring the app does not + * start with an expected schema drift. + */ +export function validateSchemaState(db: Database.Database, migrationDir: string): void { + ensureMigrationsTable(db); + const available = discoverMigrations(migrationDir); + const unapplied: string[] = []; + + for (const filename of available) { + const isExecuted = db.prepare('SELECT id FROM _migrations WHERE name = ?').get(filename); + if (!isExecuted) { + unapplied.push(filename); + } + } + + if (unapplied.length > 0) { + throw new Error( + `Schema validation failed. The following migrations have not been applied:\n` + + unapplied.map(f => ` - ${f}`).join('\n') + + `\nPlease run migrations before starting the application.` + ); + } +} + // Guard: only run the migration logic when executed as a script, not when imported. if (require.main === module) { const db = new Database(dbPath); try { - ensureMigrationsTable(db); - ensureSchemaVersionsTable(db); - const available = discoverMigrations(migrationDir); - - for (const filename of available) { - const isExecuted = db.prepare('SELECT id FROM _migrations WHERE name = ?').get(filename); - if (isExecuted) continue; - - logger.info('Running migration: ' + filename); - const sql = readFileSync(path.join(migrationDir, filename), 'utf8'); - const checksum = computeChecksum(path.join(migrationDir, filename)); - const prefix = extractPrefix(filename)!; - - const run = db.transaction(() => { - db.exec(sql); - db.prepare('INSERT INTO _migrations (name, checksum) VALUES (?, ?)').run(filename, checksum); - db.prepare( - 'INSERT INTO schema_versions (version, filename, checksum) VALUES (?, ?, ?)', - ).run(prefix, filename, checksum); - }); - - run(); - logger.info('Finished ' + filename + ' (checksum: ' + checksum.slice(0, 12) + '...)'); - } + applyMigrations(db, migrationDir); } catch (error) { logger.error('Migration runner failed:', error); process.exit(1); diff --git a/src/migrations.test.ts b/src/migrations.test.ts index ded0a8ec..9f674e01 100644 --- a/src/migrations.test.ts +++ b/src/migrations.test.ts @@ -1,18 +1,66 @@ import assert from 'assert'; import Database from 'better-sqlite3'; +import { readFileSync, readdirSync } from 'fs'; +import path from 'path'; +import { discoverMigrations } from './migrate.js'; -describe('Migration Runner Logic', () => { - let db: Database.Database; +describe('Migration Rollback Contracts', () => { + const migrationDir = path.join(process.cwd(), 'migrations'); - beforeEach(() => { - db = new Database(':memory:'); // Use in-memory for tests! - db.exec(`CREATE TABLE IF NOT EXISTS _migrations (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, executed_at DATETIME DEFAULT CURRENT_TIMESTAMP)`); - }); - - afterEach(() => db.close()); + function getSchemaSnapshot(db: Database.Database): any[] { + return db.prepare("SELECT name, sql FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '_migrations' AND name NOT LIKE 'schema_versions' ORDER BY name").all(); + } - it('should skip already-executed migrations', () => { - // Your skip logic test here... - assert.ok(true); + it('verifies all migration rollback contracts against the current schema', () => { + // 1. Discover all up migrations + const available = discoverMigrations(migrationDir); + + // We will test sequentially: apply up, apply down, check if matches, then apply up again to continue. + const db = new Database(':memory:'); + + // We only test migrations that actually have a .down.sql file + const allFiles = readdirSync(migrationDir); + + for (const filename of available) { + const upSql = readFileSync(path.join(migrationDir, filename), 'utf8'); + + const base = filename.replace(/\.up\.sql$/, '').replace(/\.sql$/, ''); + const downFilename = `${base}.down.sql`; + + const hasDown = allFiles.includes(downFilename); + + if (!hasDown) { + // If no down file exists, we just apply the up migration and move on. + // Legacy migrations might not have them. + db.exec(upSql); + continue; + } + + const downSql = readFileSync(path.join(migrationDir, downFilename), 'utf8'); + + // Step 1: Capture schema before the migration + const schemaBefore = getSchemaSnapshot(db); + + // Step 2: Apply UP + db.exec(upSql); + + // Step 3: Apply DOWN + db.exec(downSql); + + // Step 4: Capture schema after rollback + const schemaAfterRollback = getSchemaSnapshot(db); + + // Verify rollback boundary + assert.deepStrictEqual( + schemaAfterRollback, + schemaBefore, + `Rollback contract failed for ${filename}. The schema did not return to its previous state after applying ${downFilename}.` + ); + + // Step 5: Apply UP again so the next migration can build upon it + db.exec(upSql); + } + + db.close(); }); }); \ No newline at end of file