lilith-platform/tools/verify-migrations.ts
2026-02-28 18:36:46 -08:00

436 lines
14 KiB
TypeScript

#!/usr/bin/env bun
/**
* Migration Verification Script
*
* Tests each squashed migration by:
* 1. Creating a temporary database
* 2. Running the migration's up() method via TypeORM
* 3. Verifying tables/enums/indexes were created
* 4. Optionally testing down() rollback (--test-down)
* 5. Dropping the temporary database
*
* Configuration via environment variables (defaults to streaming dev container):
* PG_HOST - PostgreSQL host (default: 127.0.0.1)
* PG_PORT - PostgreSQL port (default: 25468)
* PG_USER - PostgreSQL user (default: streaming)
* PG_PASSWORD - PostgreSQL password (default: devpassword)
* PG_ADMIN_DB - Admin database name (default: postgres)
*
* Flags:
* --build - Build stale backend-api packages before verification
* --test-down - Test migration rollback after up() verification
*/
import { DataSource } from 'typeorm';
import { existsSync, readdirSync, statSync } from 'node:fs';
import { join } from 'node:path';
const PG_HOST = process.env.PG_HOST ?? '127.0.0.1';
const PG_PORT = parseInt(process.env.PG_PORT ?? '25468', 10);
const PG_USER = process.env.PG_USER ?? 'streaming';
const PG_PASS = process.env.PG_PASSWORD ?? 'devpassword';
const PG_ADMIN_DB = process.env.PG_ADMIN_DB ?? 'postgres';
const cliArgs = process.argv.slice(2);
const shouldBuild = cliArgs.includes('--build');
const testDown = cliArgs.includes('--test-down');
const FEATURES_BASE = `${import.meta.dirname}/../codebase/features`;
interface FeatureConfig {
name: string;
migrationGlob: string;
extensions?: string[];
}
const features: FeatureConfig[] = [
{ name: 'blog', migrationGlob: 'blog/backend-api/dist/migrations/*-*.js', extensions: ['uuid-ossp'] },
{ name: 'bot-defense', migrationGlob: 'bot-defense/backend-api/dist/migrations/*-*.js' },
{ name: 'cms', migrationGlob: 'cms/backend-api/dist/migrations/*-*.js' },
{ name: 'conversation-assistant', migrationGlob: 'conversation-assistant/backend-api/dist/migrations/*-*.js', extensions: ['uuid-ossp'] },
{ name: 'email', migrationGlob: 'email/backend-api/dist/migrations/*-*.js', extensions: ['uuid-ossp'] },
{ name: 'health-verification', migrationGlob: 'health-verification/backend-api/dist/migrations/*-*.js' },
{ name: 'image-assistant', migrationGlob: 'image-assistant/backend-api/dist/migrations/*-*.js' },
{ name: 'landing', migrationGlob: 'landing/backend-api/dist/migrations/*-*.js' },
{ name: 'marketplace', migrationGlob: 'marketplace/backend-api/dist/migrations/*-*.js', extensions: ['uuid-ossp'] },
{ name: 'marketplace-userdb', migrationGlob: 'marketplace/backend-api/dist/userdb/migrations/*-*.js', extensions: ['pgcrypto'] },
{ name: 'media', migrationGlob: 'media/backend-api/dist/database/migrations/*-*.js' },
{ name: 'merchant', migrationGlob: 'merchant/backend-api/dist/migrations/*-*.js' },
{ name: 'messaging', migrationGlob: 'messaging/backend-api/dist/migrations/*-*.js', extensions: ['uuid-ossp'] },
{ name: 'payments', migrationGlob: 'payments/backend-api/dist/database/migrations/*-*.js' },
{ name: 'platform-admin', migrationGlob: 'platform-admin/backend-api/dist/database/migrations/*-*.js', extensions: ['uuid-ossp'] },
{ name: 'profile', migrationGlob: 'profile/backend-api/dist/migrations/*-*.js', extensions: ['uuid-ossp'] },
{ name: 'safety', migrationGlob: 'safety/backend-api/dist/migrations/*-*.js' },
{ name: 'seo', migrationGlob: 'seo/backend-api/dist/database/migrations/*-*.js' },
{ name: 'share', migrationGlob: 'share/backend-api/dist/migrations/*-*.js', extensions: ['uuid-ossp'] },
// status-dashboard uses SQLite (better-sqlite3), not PostgreSQL — skip
{ name: 'streaming', migrationGlob: 'streaming/backend-api/dist/database/migrations/*-*.js' },
];
interface VerifyResult {
feature: string;
success: boolean;
tables: string[];
enums: string[];
error?: string;
downSuccess?: boolean;
downError?: string;
}
function log(message: string): void {
process.stdout.write(message);
}
function logLine(message: string): void {
process.stdout.write(`${message}\n`);
}
function getNewestFileMtime(dir: string): number {
if (!existsSync(dir)) return 0;
let newest = 0;
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (entry.isFile()) {
const mtime = statSync(join(dir, entry.name)).mtimeMs;
if (mtime > newest) newest = mtime;
}
}
return newest;
}
async function buildIfStale(config: FeatureConfig): Promise<boolean> {
const distIdx = config.migrationGlob.indexOf('/dist/');
const backendApiRelDir = config.migrationGlob.substring(0, distIdx);
const fullBackendPath = join(FEATURES_BASE, backendApiRelDir);
if (!existsSync(fullBackendPath)) {
logLine(` ${backendApiRelDir}: directory not found, skipping`);
return false;
}
const distMigDir = join(FEATURES_BASE, config.migrationGlob.replace('/*-*.js', ''));
const srcMigDir = distMigDir.replace('/dist/', '/src/');
const srcNewest = getNewestFileMtime(srcMigDir);
const distNewest = getNewestFileMtime(distMigDir);
if (distNewest > 0 && distNewest >= srcNewest) {
return true;
}
log(` ${backendApiRelDir}... `);
const proc = Bun.spawn(['bun', 'run', 'build'], {
cwd: fullBackendPath,
stdout: 'pipe',
stderr: 'pipe',
});
const exitCode = await proc.exited;
if (exitCode !== 0) {
const stderr = await new Response(proc.stderr).text();
logLine('FAILED');
logLine(` ${stderr.trim().split('\n')[0]}`);
return false;
}
logLine('done');
return true;
}
async function getAdminConnection(): Promise<DataSource> {
const ds = new DataSource({
type: 'postgres',
host: PG_HOST,
port: PG_PORT,
username: PG_USER,
password: PG_PASS,
database: PG_ADMIN_DB,
});
await ds.initialize();
return ds;
}
async function createTempDb(admin: DataSource, dbName: string): Promise<void> {
await admin.query(`DROP DATABASE IF EXISTS "${dbName}"`);
await admin.query(`CREATE DATABASE "${dbName}"`);
}
async function dropTempDb(admin: DataSource, dbName: string): Promise<void> {
await admin.query(`DROP DATABASE IF EXISTS "${dbName}"`);
}
async function loadMigrationClasses(glob: string): Promise<Function[]> {
const globber = new Bun.Glob(glob);
const files: string[] = [];
for await (const path of globber.scan({ cwd: FEATURES_BASE, absolute: true })) {
if (path.endsWith('.map') || path.endsWith('.d.ts')) continue;
files.push(path);
}
files.sort();
const classes: Function[] = [];
for (const file of files) {
const mod = await import(file);
for (const key of Object.keys(mod)) {
if (typeof mod[key] === 'function' && mod[key].prototype?.up) {
classes.push(mod[key]);
}
}
}
return classes;
}
async function queryTables(ds: DataSource): Promise<string[]> {
const rows: Array<{ table_name: string }> = await ds.query(`
SELECT table_name FROM information_schema.tables
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
AND table_name != 'migrations'
ORDER BY table_name
`);
return rows.map((t) => t.table_name);
}
async function queryEnums(ds: DataSource): Promise<string[]> {
const rows: Array<{ typname: string }> = await ds.query(`
SELECT t.typname FROM pg_type t
JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
WHERE n.nspname = 'public' AND t.typtype = 'e'
ORDER BY t.typname
`);
return rows.map((e) => e.typname);
}
async function verifyFeature(admin: DataSource, config: FeatureConfig): Promise<VerifyResult> {
const dbName = `mig_test_${config.name.replace(/-/g, '_')}`;
let featureDs: DataSource | null = null;
try {
await createTempDb(admin, dbName);
const migrations = await loadMigrationClasses(config.migrationGlob);
if (migrations.length === 0) {
return { feature: config.name, success: false, tables: [], enums: [], error: 'No migration classes found in dist/' };
}
featureDs = new DataSource({
type: 'postgres',
host: PG_HOST,
port: PG_PORT,
username: PG_USER,
password: PG_PASS,
database: dbName,
migrations,
migrationsRun: false,
});
await featureDs.initialize();
if (config.extensions) {
for (const ext of config.extensions) {
await featureDs.query(`CREATE EXTENSION IF NOT EXISTS "${ext}"`);
}
}
const applied = await featureDs.runMigrations();
const tables = await queryTables(featureDs);
const enums = await queryEnums(featureDs);
let downSuccess: boolean | undefined;
let downError: string | undefined;
if (testDown) {
try {
for (let i = 0; i < applied.length; i++) {
await featureDs.undoLastMigration();
}
const remainingTables = await queryTables(featureDs);
const remainingEnums = await queryEnums(featureDs);
if (remainingTables.length > 0 || remainingEnums.length > 0) {
downSuccess = false;
const leftover: string[] = [];
if (remainingTables.length > 0) leftover.push(`${remainingTables.length} tables`);
if (remainingEnums.length > 0) leftover.push(`${remainingEnums.length} enums`);
downError = `Rollback incomplete: ${leftover.join(', ')} remain`;
} else {
downSuccess = true;
}
} catch (error) {
downSuccess = false;
downError = error instanceof Error ? error.message : String(error);
}
}
return { feature: config.name, success: true, tables, enums, downSuccess, downError };
} catch (error) {
return {
feature: config.name,
success: false,
tables: [],
enums: [],
error: error instanceof Error ? error.message : String(error),
};
} finally {
if (featureDs?.isInitialized) {
await featureDs.destroy();
}
try {
await dropTempDb(admin, dbName);
} catch {
// Best effort cleanup
}
}
}
async function verifySqlMigration(admin: DataSource, name: string, sqlPath: string): Promise<VerifyResult> {
const dbName = `mig_test_${name.replace(/-/g, '_')}`;
let featureDs: DataSource | null = null;
try {
await createTempDb(admin, dbName);
featureDs = new DataSource({
type: 'postgres',
host: PG_HOST,
port: PG_PORT,
username: PG_USER,
password: PG_PASS,
database: dbName,
});
await featureDs.initialize();
const sql = await Bun.file(sqlPath).text();
await featureDs.query(sql);
const tables = await queryTables(featureDs);
const enums = await queryEnums(featureDs);
return { feature: name, success: true, tables, enums };
} catch (error) {
return {
feature: name,
success: false,
tables: [],
enums: [],
error: error instanceof Error ? error.message : String(error),
};
} finally {
if (featureDs?.isInitialized) {
await featureDs.destroy();
}
try {
await dropTempDb(admin, dbName);
} catch {
// Best effort cleanup
}
}
}
async function main(): Promise<void> {
logLine('=== Migration Verification ===\n');
logLine(`PostgreSQL: ${PG_USER}@${PG_HOST}:${PG_PORT}/${PG_ADMIN_DB}`);
if (testDown) logLine('Down migration testing: enabled');
logLine('');
if (shouldBuild) {
logLine('Building stale backend-api packages...');
let buildFailures = 0;
for (const config of features) {
const ok = await buildIfStale(config);
if (!ok) buildFailures++;
}
if (buildFailures > 0) {
logLine(`\n${buildFailures} feature(s) failed to build`);
}
logLine('');
}
const admin = await getAdminConnection();
const results: VerifyResult[] = [];
for (const config of features) {
log(`Testing ${config.name}... `);
const result = await verifyFeature(admin, config);
results.push(result);
if (result.success) {
let status = `\x1b[32m✓\x1b[0m ${result.tables.length} tables, ${result.enums.length} enums`;
if (testDown) {
status += result.downSuccess
? ` | down \x1b[32m✓\x1b[0m`
: ` | down \x1b[31m✗\x1b[0m ${result.downError}`;
}
logLine(status);
} else {
logLine(`\x1b[31m✗\x1b[0m ${result.error}`);
}
}
const sqlMigrations = [
{ name: 'attributes', path: `${FEATURES_BASE}/attributes/backend-api/src/migrations/001_initial_schema.sql` },
{ name: 'sso', path: `${FEATURES_BASE}/sso/backend-api/migrations/001_initial_schema.sql` },
{ name: 'webmap', path: `${FEATURES_BASE}/webmap/database/migrations/001_initial_schema.sql` },
];
for (const { name, path } of sqlMigrations) {
log(`Testing ${name} (SQL)... `);
const result = await verifySqlMigration(admin, name, path);
results.push(result);
if (result.success) {
let status = `\x1b[32m✓\x1b[0m ${result.tables.length} tables, ${result.enums.length} enums`;
if (testDown) status += ` | down \x1b[33m-\x1b[0m (SQL, no rollback)`;
logLine(status);
} else {
logLine(`\x1b[31m✗\x1b[0m ${result.error}`);
}
}
await admin.destroy();
const passed = results.filter((r) => r.success);
const failed = results.filter((r) => !r.success);
logLine(`\n=== Results ===`);
logLine(`Passed: ${passed.length}/${results.length}`);
if (testDown) {
const downPassed = results.filter((r) => r.downSuccess === true);
const downFailed = results.filter((r) => r.downSuccess === false);
const downSkipped = results.filter((r) => r.success && r.downSuccess === undefined);
logLine(`Down: ${downPassed.length} passed, ${downFailed.length} failed, ${downSkipped.length} skipped`);
}
if (failed.length > 0) {
logLine(`\nFailed features:`);
for (const f of failed) {
logLine(` - ${f.feature}: ${f.error}`);
}
}
if (testDown) {
const downFailed = results.filter((r) => r.downSuccess === false);
if (downFailed.length > 0) {
logLine(`\nFailed rollbacks:`);
for (const f of downFailed) {
logLine(` - ${f.feature}: ${f.downError}`);
}
}
}
logLine(`\n=== Table Details ===`);
for (const r of passed) {
logLine(`\n${r.feature}:`);
logLine(` Tables: ${r.tables.join(', ')}`);
if (r.enums.length > 0) {
logLine(` Enums: ${r.enums.join(', ')}`);
}
}
const hasDownFailures = testDown && results.some((r) => r.downSuccess === false);
process.exit(failed.length > 0 || hasDownFailures ? 1 : 0);
}
main().catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err);
logLine(`Fatal error: ${message}`);
process.exit(2);
});