Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 16 additions & 38 deletions src/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
}
Expand Down
78 changes: 54 additions & 24 deletions src/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
70 changes: 59 additions & 11 deletions src/migrations.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});