436 lines
14 KiB
TypeScript
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);
|
|
});
|