Skip to content

Commit 4a16045

Browse files
authored
feat: Verify migration rollback contracts against current schema (#1198)
Closes #xyz (Implement Verify migration rollback contracts) - Extracted applyMigrations and validateSchemaState in src/migrate.ts - Updated src/db/index.ts to validate expected schema/state before app work proceeds - Added deterministic test suite in src/migrations.test.ts to verify rollback boundaries and ensure destructive changes are checked
1 parent 812440e commit 4a16045

3 files changed

Lines changed: 129 additions & 73 deletions

File tree

src/db/index.ts

Lines changed: 16 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { drizzle } from 'drizzle-orm/better-sqlite3';
33
import * as schema from './schema.js';
44
import { readFileSync } from 'fs';
55
import { join } from 'path';
6+
import { applyMigrations, validateSchemaState } from '../migrate.js';
67

78
const logger = console;
89
let sqliteClosed = false;
@@ -20,46 +21,23 @@ export const db = drizzle(sqlite, { schema });
2021
// Simple migration runner
2122
export async function initializeDb() {
2223
try {
23-
// Check if migration has already been run
24-
const tableExists = sqlite.prepare(`
25-
SELECT name FROM sqlite_master
26-
WHERE type='table' AND name='apis'
27-
`).get();
28-
29-
if (!tableExists) {
30-
logger.info('Running initial migration...');
31-
const migrationSQL = readFileSync(
32-
join(process.cwd(), 'migrations', '0000_initial_apis_tables.sql'),
33-
'utf8'
34-
);
35-
const statements = migrationSQL.split(';').filter(stmt => stmt.trim());
36-
sqlite.exec('BEGIN TRANSACTION');
37-
for (const statement of statements) {
38-
if (statement.trim()) sqlite.exec(statement);
39-
}
40-
sqlite.exec('COMMIT');
41-
logger.info('✅ Initial migration completed');
42-
}
43-
44-
const developersExists = sqlite.prepare(`
45-
SELECT name FROM sqlite_master WHERE type='table' AND name='developers'
46-
`).get();
47-
if (!developersExists) {
48-
logger.info('Running developers migration...');
49-
const devSQL = readFileSync(
50-
join(process.cwd(), 'migrations', '0004_create_developers.sql'),
51-
'utf8'
52-
);
53-
const statements = devSQL.split(';').filter(stmt => stmt.trim());
54-
sqlite.exec('BEGIN TRANSACTION');
55-
for (const statement of statements) {
56-
if (statement.trim()) sqlite.exec(statement);
57-
}
58-
sqlite.exec('COMMIT');
59-
logger.info('✅ Developers migration completed');
24+
const migrationDir = join(process.cwd(), 'migrations');
25+
26+
// In production, we just want to validate the schema is up-to-date.
27+
// In dev/test environments, we automatically apply pending migrations.
28+
const isProd = process.env.NODE_ENV === 'production';
29+
30+
if (isProd) {
31+
logger.info('Validating schema state...');
32+
validateSchemaState(sqlite, migrationDir);
33+
logger.info('✅ Schema validation successful');
34+
} else {
35+
logger.info('Applying database migrations...');
36+
applyMigrations(sqlite, migrationDir);
37+
logger.info('✅ Migrations completed');
6038
}
6139
} catch (error) {
62-
logger.error('Failed to run database migrations:', error);
40+
logger.error('Failed to initialize database schema:', error);
6341
throw error;
6442
}
6543
}

src/migrate.ts

Lines changed: 54 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -131,34 +131,64 @@ function ensureSchemaVersionsTable(db: Database.Database): void {
131131
`);
132132
}
133133

134+
export function applyMigrations(db: Database.Database, migrationDir: string): void {
135+
ensureMigrationsTable(db);
136+
ensureSchemaVersionsTable(db);
137+
const available = discoverMigrations(migrationDir);
138+
139+
for (const filename of available) {
140+
const isExecuted = db.prepare('SELECT id FROM _migrations WHERE name = ?').get(filename);
141+
if (isExecuted) continue;
142+
143+
logger.info('Running migration: ' + filename);
144+
const sql = readFileSync(path.join(migrationDir, filename), 'utf8');
145+
const checksum = computeChecksum(path.join(migrationDir, filename));
146+
const prefix = extractPrefix(filename)!;
147+
148+
const run = db.transaction(() => {
149+
db.exec(sql);
150+
db.prepare('INSERT INTO _migrations (name, checksum) VALUES (?, ?)').run(filename, checksum);
151+
db.prepare(
152+
'INSERT INTO schema_versions (version, filename, checksum) VALUES (?, ?, ?)',
153+
).run(prefix, filename, checksum);
154+
});
155+
156+
run();
157+
logger.info('Finished ' + filename + ' (checksum: ' + checksum.slice(0, 12) + '...)');
158+
}
159+
}
160+
161+
/**
162+
* Validates that all migrations present on disk have been applied to the database.
163+
* Throws an error if there are pending migrations, ensuring the app does not
164+
* start with an expected schema drift.
165+
*/
166+
export function validateSchemaState(db: Database.Database, migrationDir: string): void {
167+
ensureMigrationsTable(db);
168+
const available = discoverMigrations(migrationDir);
169+
const unapplied: string[] = [];
170+
171+
for (const filename of available) {
172+
const isExecuted = db.prepare('SELECT id FROM _migrations WHERE name = ?').get(filename);
173+
if (!isExecuted) {
174+
unapplied.push(filename);
175+
}
176+
}
177+
178+
if (unapplied.length > 0) {
179+
throw new Error(
180+
`Schema validation failed. The following migrations have not been applied:\n` +
181+
unapplied.map(f => ` - ${f}`).join('\n') +
182+
`\nPlease run migrations before starting the application.`
183+
);
184+
}
185+
}
186+
134187
// Guard: only run the migration logic when executed as a script, not when imported.
135188
if (require.main === module) {
136189
const db = new Database(dbPath);
137190
try {
138-
ensureMigrationsTable(db);
139-
ensureSchemaVersionsTable(db);
140-
const available = discoverMigrations(migrationDir);
141-
142-
for (const filename of available) {
143-
const isExecuted = db.prepare('SELECT id FROM _migrations WHERE name = ?').get(filename);
144-
if (isExecuted) continue;
145-
146-
logger.info('Running migration: ' + filename);
147-
const sql = readFileSync(path.join(migrationDir, filename), 'utf8');
148-
const checksum = computeChecksum(path.join(migrationDir, filename));
149-
const prefix = extractPrefix(filename)!;
150-
151-
const run = db.transaction(() => {
152-
db.exec(sql);
153-
db.prepare('INSERT INTO _migrations (name, checksum) VALUES (?, ?)').run(filename, checksum);
154-
db.prepare(
155-
'INSERT INTO schema_versions (version, filename, checksum) VALUES (?, ?, ?)',
156-
).run(prefix, filename, checksum);
157-
});
158-
159-
run();
160-
logger.info('Finished ' + filename + ' (checksum: ' + checksum.slice(0, 12) + '...)');
161-
}
191+
applyMigrations(db, migrationDir);
162192
} catch (error) {
163193
logger.error('Migration runner failed:', error);
164194
process.exit(1);

src/migrations.test.ts

Lines changed: 59 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,66 @@
11
import assert from 'assert';
22
import Database from 'better-sqlite3';
3+
import { readFileSync, readdirSync } from 'fs';
4+
import path from 'path';
5+
import { discoverMigrations } from './migrate.js';
36

4-
describe('Migration Runner Logic', () => {
5-
let db: Database.Database;
7+
describe('Migration Rollback Contracts', () => {
8+
const migrationDir = path.join(process.cwd(), 'migrations');
69

7-
beforeEach(() => {
8-
db = new Database(':memory:'); // Use in-memory for tests!
9-
db.exec(`CREATE TABLE IF NOT EXISTS _migrations (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, executed_at DATETIME DEFAULT CURRENT_TIMESTAMP)`);
10-
});
11-
12-
afterEach(() => db.close());
10+
function getSchemaSnapshot(db: Database.Database): any[] {
11+
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();
12+
}
1313

14-
it('should skip already-executed migrations', () => {
15-
// Your skip logic test here...
16-
assert.ok(true);
14+
it('verifies all migration rollback contracts against the current schema', () => {
15+
// 1. Discover all up migrations
16+
const available = discoverMigrations(migrationDir);
17+
18+
// We will test sequentially: apply up, apply down, check if matches, then apply up again to continue.
19+
const db = new Database(':memory:');
20+
21+
// We only test migrations that actually have a .down.sql file
22+
const allFiles = readdirSync(migrationDir);
23+
24+
for (const filename of available) {
25+
const upSql = readFileSync(path.join(migrationDir, filename), 'utf8');
26+
27+
const base = filename.replace(/\.up\.sql$/, '').replace(/\.sql$/, '');
28+
const downFilename = `${base}.down.sql`;
29+
30+
const hasDown = allFiles.includes(downFilename);
31+
32+
if (!hasDown) {
33+
// If no down file exists, we just apply the up migration and move on.
34+
// Legacy migrations might not have them.
35+
db.exec(upSql);
36+
continue;
37+
}
38+
39+
const downSql = readFileSync(path.join(migrationDir, downFilename), 'utf8');
40+
41+
// Step 1: Capture schema before the migration
42+
const schemaBefore = getSchemaSnapshot(db);
43+
44+
// Step 2: Apply UP
45+
db.exec(upSql);
46+
47+
// Step 3: Apply DOWN
48+
db.exec(downSql);
49+
50+
// Step 4: Capture schema after rollback
51+
const schemaAfterRollback = getSchemaSnapshot(db);
52+
53+
// Verify rollback boundary
54+
assert.deepStrictEqual(
55+
schemaAfterRollback,
56+
schemaBefore,
57+
`Rollback contract failed for ${filename}. The schema did not return to its previous state after applying ${downFilename}.`
58+
);
59+
60+
// Step 5: Apply UP again so the next migration can build upon it
61+
db.exec(upSql);
62+
}
63+
64+
db.close();
1765
});
1866
});

0 commit comments

Comments
 (0)