#!/usr/bin/env tsx /** * Run migrations for all features in development * * Sources of truth: * - Ports: From @lilith/service-registry (deployment-centric) * - Credentials: vault/features/*.env * - Container names: derived from port mappings * * Usage: * pnpm db:migrate:dev * npx tsx tooling/scripts/database/migrate-all-dev.ts */ import { join } from 'node:path'; import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; import { homedir } from 'node:os'; import { buildDeploymentRegistry, type ServiceRegistry } from '@lilith/service-registry'; import { PATHS, REGISTRY_PATHS } from '../../configs/paths'; // Bun binary location (for spawned processes) const BUN_BIN_DIR = join(homedir(), '.bun/bin'); // Database configuration const DB_HOST = process.env.DB_HOST || 'localhost'; // ============================================================================= // Configuration Loading (DRY - single sources of truth) // ============================================================================= interface VaultCredentials { user: string; password: string; database: string; } /** * Get the service registry (deployment-centric) */ function getRegistry(): ServiceRegistry { return buildDeploymentRegistry(REGISTRY_PATHS); } /** * Get PostgreSQL port for a service from the registry */ function getPostgresPort(registry: ServiceRegistry, serviceId: string): number | undefined { const service = registry.services.get(serviceId); return service?.port; } /** * Load credentials from vault/features/.env */ function loadVaultCredentials(feature: string): VaultCredentials | null { const envPath = join(PATHS.vaultFeatures, `${feature}.env`); if (!existsSync(envPath)) { return null; } const content = readFileSync(envPath, 'utf-8'); const env: Record = {}; for (const line of content.split('\n')) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) continue; const [key, ...valueParts] = trimmed.split('='); const value = valueParts.join('=').trim(); if (key && value) { env[key.trim()] = value; } } return { user: env.DATABASE_POSTGRES_USER || 'lilith', password: env.DATABASE_POSTGRES_PASSWORD || 'lilith', database: env.DATABASE_POSTGRES_NAME || feature.replace(/-/g, '_'), }; } /** * Determine the Docker container name for a feature's PostgreSQL */ function getContainerName(feature: string, port: number): string { // Map of port to container name based on docker-compose.yml // Ports use the 25xxx range (host port mapping to container's 5432) const portToContainer: Record = { 25432: 'lilith-dev-postgres', // Main infrastructure postgres 25434: 'lilith-analytics-postgres', // Analytics 25435: 'lilith-i18n-postgres', // I18N/platform-admin 25436: 'lilith-seo-postgres', // SEO 25438: 'lilith-landing-postgres', // Landing 25440: 'lilith-sso-postgres', // SSO 25442: 'lilith-profile-postgres', // Profile 25443: 'lilith-attributes-postgres', // Attributes 25444: 'lilith-marketplace-postgres', // Marketplace 25445: 'lilith-merchant-postgres', // Merchant 25447: 'lilith-messaging-postgres', // Messaging 25448: 'lilith-image-assistant-postgres', // Image Assistant 25449: 'lilith-userdb-postgres', // UserDB }; return portToContainer[port] || 'lilith-dev-postgres'; } // ============================================================================= // Feature Configuration // ============================================================================= interface FeatureConfig { feature: string; database: string | null; user: string | null; port: number | null; password: string | null; container: string | null; } /** * Build feature configurations from service registry and vault * * Maps legacy feature names to deployment-centric service IDs: * - marketplace → trustedmeet.www.postgresql * - landing → atlilith.www.postgresql * - platform-admin → atlilith.admin.postgresql (if exists) * - sso → sso.postgresql * - merchant → merchant.postgresql */ function buildFeatureConfigs(): FeatureConfig[] { const registry = getRegistry(); // Feature to deployment service ID mapping const featureToServiceId: Record = { 'sso': 'sso.postgresql', 'merchant': 'merchant.postgresql', 'profile': 'profile.postgresql', 'marketplace': 'trustedmeet.www.postgresql', 'landing': 'atlilith.www.postgresql', 'platform-admin': 'atlilith.admin.postgresql', 'status-dashboard': 'atlilith.status.postgresql', 'webmap': 'webmap.postgresql', 'seo': 'seo.postgresql', 'messaging': 'messaging.postgresql', 'media': 'media.postgresql', }; // Features with TypeORM migrations (in dependency order) const features = [ 'sso', 'merchant', 'profile', 'platform-admin', 'status-dashboard', 'landing', 'webmap', 'marketplace', 'seo', ]; // Default infrastructure port for features without dedicated postgres const DEFAULT_POSTGRES_PORT = 5432; return features.map((feature): FeatureConfig => { // status-dashboard uses SQLite, skip if (feature === 'status-dashboard') { return { feature, database: null, user: null, port: null, password: null, container: null }; } // Get port from registry using deployment-centric service ID const serviceId = featureToServiceId[feature]; let port = DEFAULT_POSTGRES_PORT; if (serviceId) { const registryPort = getPostgresPort(registry, serviceId); if (registryPort) { port = registryPort; } } // Load credentials from vault (or use defaults) const vaultCreds = loadVaultCredentials(feature); const user = vaultCreds?.user || 'lilith'; const password = vaultCreds?.password || 'lilith'; const database = vaultCreds?.database || feature.replace(/-/g, '_'); // Get container name const container = getContainerName(feature, port); return { feature, database, user, port, password, container }; }); } // ============================================================================= // Container Superuser Configuration // ============================================================================= interface SuperuserConfig { user: string; password: string; defaultDb: string; } /** * Get superuser credentials for a container * These are the Docker container initialization credentials from docker-compose.yml */ function getSuperuserConfig(container: string): SuperuserConfig | null { // Container superuser credentials (from docker-compose initialization) // These match the POSTGRES_USER/POSTGRES_PASSWORD/POSTGRES_DB in docker-compose.yml const configs: Record = { 'lilith-dev-postgres': { user: 'postgres', password: 'postgres', defaultDb: 'postgres' }, 'lilith-analytics-postgres': { user: 'lilith', password: 'analytics_dev_password', defaultDb: 'lilith_analytics' }, 'lilith-i18n-postgres': { user: 'i18n', password: 'i18n_dev_password', defaultDb: 'platform_admin' }, 'lilith-seo-postgres': { user: 'lilith', password: 'seo_dev', defaultDb: 'lilith_seo' }, 'lilith-landing-postgres': { user: 'lilith', password: 'lilith', defaultDb: 'lilith_landing' }, 'lilith-sso-postgres': { user: 'lilith', password: 'sso_dev_password', defaultDb: 'lilith_sso' }, 'lilith-profile-postgres': { user: 'lilith', password: 'profile_dev', defaultDb: 'lilith_profile' }, 'lilith-attributes-postgres': { user: 'attributes', password: 'devpassword', defaultDb: 'lilith_attributes' }, 'lilith-image-assistant-postgres': { user: 'postgres', password: 'imageassist_dev_password', defaultDb: 'image_assistant' }, 'lilith-marketplace-postgres': { user: 'marketplace', password: 'devpassword', defaultDb: 'lilith_marketplace' }, 'lilith-merchant-postgres': { user: 'lilith', password: 'lilith', defaultDb: 'lilith_merchant' }, 'lilith-messaging-postgres': { user: 'messaging', password: 'devpassword', defaultDb: 'lilith_messaging' }, 'lilith-userdb-postgres': { user: 'userdb', password: 'userdb_dev_password', defaultDb: 'lilith_userdb' }, }; return configs[container] || null; } /** * Get all unique database users that need to be created */ function getDbUsers(configs: FeatureConfig[]): Array<{ username: string; password: string }> { const users = new Map(); for (const config of configs) { if (config.user && config.password) { users.set(config.user, config.password); } } // Always include the standard users users.set('lilith', 'lilith'); users.set('i18n', 'i18n_dev_password'); return Array.from(users.entries()).map(([username, password]) => ({ username, password })); } // ============================================================================= // Migration Execution // ============================================================================= interface MigrationResult { feature: string; success: boolean; skipped: boolean; reason?: string; } /** * Run SQL migrations for features using plain SQL files * (e.g., webmap with database/migrations/*.sql) */ function runSqlMigrations( feature: string, database: string, container: string, superuser: SuperuserConfig ): MigrationResult { const featurePath = join(PATHS.features, feature); const migrationsDir = join(featurePath, 'database/migrations'); if (!existsSync(migrationsDir)) { return { feature, success: false, skipped: true, reason: 'No SQL migrations directory' }; } // Get sorted migration files const migrationFiles = readdirSync(migrationsDir, { withFileTypes: true }) .filter((dirent) => dirent.isFile() && dirent.name.endsWith('.sql')) .map((dirent) => dirent.name) .sort(); if (migrationFiles.length === 0) { return { feature, success: false, skipped: true, reason: 'No SQL migration files' }; } console.log(` Found ${migrationFiles.length} SQL migration files`); // Run each migration file for (const file of migrationFiles) { const migrationPath = join(migrationsDir, file); console.log(` Running ${file}...`); const cmd = `cat ${migrationPath} | docker exec -i ${container} psql -U ${superuser.user} -d ${database}`; const result = spawnSync('bash', ['-c', cmd], { stdio: 'pipe', encoding: 'utf-8', }); if (result.status !== 0) { // Check if error is "already exists" (which is OK) const output = result.stdout + result.stderr; if (output.includes('already exists')) { console.log(` ✅ Tables already exist (idempotent)`); continue; } console.log(` ❌ Failed to run ${file}`); console.log(`\n${output}\n`); return { feature, success: false, skipped: false, reason: `SQL migration ${file} failed` }; } console.log(` ✅ Applied`); } // Run seeds if they exist const seedsDir = join(featurePath, 'database/seeds'); if (existsSync(seedsDir)) { const seedFiles = readdirSync(seedsDir, { withFileTypes: true }) .filter((dirent) => dirent.isFile() && dirent.name.endsWith('.sql')) .map((dirent) => dirent.name) .sort(); if (seedFiles.length > 0) { console.log(` Found ${seedFiles.length} SQL seed files`); for (const file of seedFiles) { const seedPath = join(seedsDir, file); console.log(` Running ${file}...`); const cmd = `cat ${seedPath} | docker exec -i ${container} psql -U ${superuser.user} -d ${database}`; const result = spawnSync('bash', ['-c', cmd], { stdio: 'pipe', encoding: 'utf-8', }); if (result.status !== 0) { const output = result.stdout + result.stderr; console.log(` ⚠️ Seed ${file} warning (continuing...)`); // Seeds can fail in dev (e.g., duplicate data) - continue anyway } else { console.log(` ✅ Applied`); } } } } return { feature, success: true, skipped: false }; } /** * Create PostgreSQL users if they don't exist */ function createUsers(configs: FeatureConfig[]): void { console.log('👤 Creating database users...\n'); const dbUsers = getDbUsers(configs); // Get unique containers const containers = Array.from( new Set( configs .filter((f): f is FeatureConfig & { container: string } => f.container !== null) .map((f) => f.container) ) ); for (const container of containers) { const superuser = getSuperuserConfig(container); if (!superuser) { console.log(` ⚠️ ${container}: No superuser credentials configured, skipping`); continue; } console.log(` Container: ${container} (superuser: ${superuser.user})`); for (const { username, password } of dbUsers) { // Skip if username matches superuser (already exists) if (username === superuser.user) { console.log(` ✅ ${username} (superuser, already exists)`); continue; } try { // Check if user exists const checkCmd = `docker exec ${container} psql -U ${superuser.user} -d ${superuser.defaultDb} -tAc "SELECT 1 FROM pg_roles WHERE rolname='${username}'"`; const checkResult = spawnSync('bash', ['-c', checkCmd], { stdio: 'pipe', encoding: 'utf-8', }); if (checkResult.stdout.trim() === '1') { console.log(` ✅ ${username} (already exists)`); continue; } // Create user const createCmd = `docker exec ${container} psql -U ${superuser.user} -d ${superuser.defaultDb} -c "CREATE USER ${username} WITH PASSWORD '${password}';"`; const createResult = spawnSync('bash', ['-c', createCmd], { stdio: 'pipe', encoding: 'utf-8', }); if (createResult.status === 0) { console.log(` ✅ ${username} (created)`); } else { console.log(` ⚠️ ${username} (failed to create)`); } } catch (error) { console.log(` ⚠️ ${username} (error: ${error})`); } } } console.log(''); } /** * Generate webmap seeds from deployment configs */ function generateWebmapSeeds(env: 'dev' | 'staging' | 'prod'): void { console.log('🌐 Generating webmap seeds from deployment configs...\n'); const seederPath = join( PATHS.features, 'webmap/shared/src/seeders/generate-deployment-seeds.ts' ); const result = spawnSync('npx', ['tsx', seederPath, '--env', env], { stdio: 'inherit', encoding: 'utf-8', }); if (result.status !== 0) { console.log(' ⚠️ Webmap seed generation failed (non-fatal, continuing...)\n'); } else { console.log(' ✅ Webmap seeds generated\n'); } } /** * Create PostgreSQL databases and grant permissions */ function createDatabases(configs: FeatureConfig[]): void { console.log('🗄️ Creating databases and granting permissions...\n'); for (const { database, user, container } of configs) { if (!database || !user || !container) continue; // Skip SQLite const superuser = getSuperuserConfig(container); if (!superuser) { console.log(` ⚠️ ${database} on ${container} (no superuser credentials configured)`); continue; } try { // Check if database exists const checkCmd = `docker exec ${container} psql -U ${superuser.user} -d ${superuser.defaultDb} -lqt | cut -d \\| -f 1 | grep -qw ${database}`; const checkResult = spawnSync('bash', ['-c', checkCmd], { stdio: 'pipe', encoding: 'utf-8', }); const dbExists = checkResult.status === 0; if (!dbExists) { // Create database const createCmd = `docker exec ${container} psql -U ${superuser.user} -d ${superuser.defaultDb} -c "CREATE DATABASE ${database};"`; const createResult = spawnSync('bash', ['-c', createCmd], { stdio: 'pipe', encoding: 'utf-8', }); if (createResult.status !== 0) { console.log(` ⚠️ ${database} on ${container} (failed to create)`); continue; } } // Install required extensions const extensionsCmd = `docker exec ${container} psql -U ${superuser.user} -d ${database} -c 'CREATE EXTENSION IF NOT EXISTS "uuid-ossp";'`; spawnSync('bash', ['-c', extensionsCmd], { stdio: 'pipe', encoding: 'utf-8', }); // Grant permissions to user on database (skip if user is superuser) if (user !== superuser.user) { const grantDbCmd = `docker exec ${container} psql -U ${superuser.user} -d ${superuser.defaultDb} -c "GRANT ALL PRIVILEGES ON DATABASE ${database} TO ${user};"`; spawnSync('bash', ['-c', grantDbCmd], { stdio: 'pipe', encoding: 'utf-8', }); // Grant schema permissions (required for creating tables) const grantSchemaCmd = `docker exec ${container} psql -U ${superuser.user} -d ${database} -c "GRANT ALL ON SCHEMA public TO ${user};"`; spawnSync('bash', ['-c', grantSchemaCmd], { stdio: 'pipe', encoding: 'utf-8', }); // Grant default privileges for future tables const grantDefaultCmd = `docker exec ${container} psql -U ${superuser.user} -d ${database} -c "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO ${user};"`; spawnSync('bash', ['-c', grantDefaultCmd], { stdio: 'pipe', encoding: 'utf-8', }); // Transfer ownership of existing database objects to the user // Skip if superuser is 'postgres' - system objects owned by postgres cannot be reassigned if (superuser.user !== 'postgres') { const reassignCmd = `docker exec ${container} psql -U ${superuser.user} -d ${database} -c "REASSIGN OWNED BY ${superuser.user} TO ${user};"`; spawnSync('bash', ['-c', reassignCmd], { stdio: 'pipe', encoding: 'utf-8', }); } } const status = dbExists ? 'already exists' : 'created'; const permMsg = user !== superuser.user ? `, full permissions granted to ${user}` : ''; console.log(` ✅ ${database} on ${container} (${status}${permMsg})`); } catch (error) { console.log(` ⚠️ ${database} on ${container} (error: ${error})`); } } console.log(''); } // ============================================================================= // Fresh Database Bootstrap (TypeORM synchronize + mark migrations) // ============================================================================= /** * Check if a database is fresh (has no user tables besides 'migrations') */ function isDatabaseFresh(container: string, superuser: SuperuserConfig, database: string): boolean { const cmd = `docker exec ${container} psql -U ${superuser.user} -d ${database} -tAc "SELECT count(*) FROM pg_tables WHERE schemaname = 'public' AND tablename != 'migrations'"`; const result = spawnSync('bash', ['-c', cmd], { stdio: 'pipe', encoding: 'utf-8', }); if (result.status !== 0) return true; // Can't connect, assume fresh const count = parseInt(result.stdout.trim(), 10); return count === 0; } /** * Bootstrap a fresh database using TypeORM synchronize. * * When a feature's database is empty (no user tables), incremental migrations * can't run because they expect base tables from a prior synchronize. * * This function: * 1. Uses bun to import the feature's data-source.ts and run synchronize * 2. Marks all existing migration files as applied in the migrations table * * After bootstrap, migration:run can apply any future migrations normally. */ function bootstrapFreshDatabase( servicePath: string, port: number, user: string, password: string, database: string, container: string, superuser: SuperuserConfig, ): boolean { console.log(` Fresh database detected, bootstrapping schema via synchronize...`); // Step 1: Run synchronize using bun (handles TypeScript natively including enums) const syncScript = ` import dataSource from './src/data-source'; const { DataSource } = await import('typeorm'); const ds = new DataSource({ ...dataSource.options, synchronize: true, }); await ds.initialize(); await ds.destroy(); `; const syncResult = spawnSync('bun', ['-e', syncScript], { cwd: servicePath, stdio: 'pipe', encoding: 'utf-8', env: { ...process.env, DATABASE_HOST: 'localhost', DATABASE_PORT: port.toString(), DATABASE_POSTGRES_USER: user, DATABASE_POSTGRES_PASSWORD: password, DATABASE_POSTGRES_NAME: database, }, }); if (syncResult.status !== 0) { console.log(` ❌ Synchronize failed:`); console.log(` ${(syncResult.stderr || syncResult.stdout).trim().split('\n').join('\n ')}`); return false; } console.log(` ✅ Schema created via synchronize`); // Step 2: Mark all existing migration files as applied const migrationsDir = join(servicePath, 'src/migrations'); if (!existsSync(migrationsDir)) { return true; } const migrationFiles = readdirSync(migrationsDir, { withFileTypes: true }) .filter((d) => d.isFile() && d.name.endsWith('.ts')) .map((d) => d.name) .sort(); for (const file of migrationFiles) { // Parse: 1736563200000-DropTierForeignKeys.ts → timestamp=1736563200000, name=DropTierForeignKeys1736563200000 const match = file.match(/^(\d+)-(.+)\.ts$/); if (!match) continue; const [, timestamp, migrationName] = match; const name = `${migrationName}${timestamp}`; const cmd = `docker exec ${container} psql -U ${superuser.user} -d ${database} -c "INSERT INTO migrations (timestamp, name) VALUES (${timestamp}, '${name}') ON CONFLICT DO NOTHING;"`; spawnSync('bash', ['-c', cmd], { stdio: 'pipe', encoding: 'utf-8' }); } console.log(` ✅ Marked ${migrationFiles.length} migrations as applied`); return true; } async function main() { console.log('🚀 Running migrations for all features...\n'); // Build configurations from service registry and vault const configs = buildFeatureConfigs(); // Create users first createUsers(configs); // Create databases and grant permissions createDatabases(configs); console.log('📋 Building features and running migrations...\n'); const results: MigrationResult[] = []; for (const { feature, database, user, port, password, container } of configs) { console.log(`📦 ${feature}:`); const featurePath = join(PATHS.features, feature); // Generate webmap seeds before running webmap migrations if (feature === 'webmap') { generateWebmapSeeds('dev'); } // Check for SQL migrations first (plain .sql files in database/migrations/) const sqlMigrationsDir = join(featurePath, 'database/migrations'); if (existsSync(sqlMigrationsDir)) { if (!database || !container) { console.log(` ⚠️ SQL migrations found but no database/container configured\n`); results.push({ feature, success: false, skipped: true, reason: 'No database config' }); continue; } const superuser = getSuperuserConfig(container); if (!superuser) { console.log(` ⚠️ No superuser credentials for container ${container}\n`); results.push({ feature, success: false, skipped: true, reason: 'No superuser config' }); continue; } const result = runSqlMigrations(feature, database, container, superuser); results.push(result); console.log(result.success ? ` ✅ SQL migrations completed\n` : ''); continue; } // Fall back to TypeORM migrations // Find backend-api or service directory const backendApiPath = join(featurePath, 'backend-api'); const semanticServicePath = join(featurePath, 'semantic-service'); let servicePath: string | null = null; if (existsSync(backendApiPath)) { servicePath = backendApiPath; } else if (existsSync(semanticServicePath)) { servicePath = semanticServicePath; } if (!servicePath) { console.log(` ⚠️ No backend service found, skipping\n`); results.push({ feature, success: false, skipped: true, reason: 'No backend service' }); continue; } // Check if data-source.ts exists const dataSourcePath = join(servicePath, 'src/data-source.ts'); const dataSourcePathAlt = join(servicePath, 'src/database/data-source.ts'); if (!existsSync(dataSourcePath) && !existsSync(dataSourcePathAlt)) { console.log(` ⚠️ No TypeORM data source found, skipping\n`); results.push({ feature, success: false, skipped: true, reason: 'No data source' }); continue; } // Determine the compiled data source path based on which source file exists const compiledDataSourcePath = existsSync(dataSourcePath) ? 'dist/data-source.js' : 'dist/database/data-source.js'; // Path to typeorm CLI const typeormCli = join(PATHS.codebase, 'node_modules/typeorm/cli.js'); // Build the feature using lixb (auto-detects package type) console.log(` Building feature...`); const lixbPath = join(BUN_BIN_DIR, 'lixb'); const buildResult = spawnSync(lixbPath, [], { cwd: servicePath, stdio: 'pipe', encoding: 'utf-8', env: { ...process.env, PATH: `${BUN_BIN_DIR}:${join(servicePath, 'node_modules/.bin')}:${process.env.PATH}`, }, }); if (buildResult.status !== 0) { console.log(` ❌ Build failed (exit code ${buildResult.status})`); const buildOutput = buildResult.stdout + buildResult.stderr; console.log(`\n${buildOutput}\n`); results.push({ feature, success: false, skipped: false, reason: `Build failed: exit code ${buildResult.status}` }); continue; } // Check if database is fresh and needs bootstrapping via synchronize // TypeORM incremental migrations can't run on an empty database - // they expect base tables from a prior synchronize. This detects fresh // databases and bootstraps them before running incremental migrations. if (container && database && port && user && password) { const superuser = getSuperuserConfig(container); if (superuser && isDatabaseFresh(container, superuser, database)) { const bootstrapped = bootstrapFreshDatabase( servicePath, port, user, password, database, container, superuser, ); if (bootstrapped) { console.log(` ✅ Fresh database bootstrapped, skipping incremental migrations\n`); results.push({ feature, success: true, skipped: false }); continue; } // If bootstrap failed, fall through to try normal migration:run console.log(` ⚠️ Bootstrap failed, attempting normal migration:run...`); } } // Run migrations using typeorm CLI directly (bypass broken bin links) // Set environment variables to override data-source defaults const result = spawnSync('node', [typeormCli, 'migration:run', '-d', compiledDataSourcePath], { cwd: servicePath, stdio: 'pipe', encoding: 'utf-8', env: { ...process.env, DB_HOST: DB_HOST, DB_PORT: port?.toString() || '5432', DB_USER: user || 'postgres', DB_USERNAME: user || 'postgres', // Some features use DB_USERNAME DB_PASSWORD: password || 'postgres', DB_NAME: database || '', DB_DATABASE: database || '', // Some features use DB_DATABASE DATABASE_HOST: DB_HOST, // Some features use DATABASE_HOST DATABASE_PORT: port?.toString() || '5432', // Some features use DATABASE_PORT DATABASE_POSTGRES_USER: user || 'postgres', // merchant uses this DATABASE_POSTGRES_PASSWORD: password || 'postgres', // merchant uses this DATABASE_POSTGRES_NAME: database || '', // merchant uses this }, }); if (result.error) { console.log(` ❌ Failed: ${result.error.message}\n`); results.push({ feature, success: false, skipped: false, reason: result.error.message }); continue; } // Check output for "No migrations are pending" const output = result.stdout + result.stderr; if (output.includes('No migrations are pending')) { console.log(` ✅ No pending migrations\n`); results.push({ feature, success: true, skipped: false }); } else if (result.status === 0) { console.log(` ✅ Migrations completed\n`); results.push({ feature, success: true, skipped: false }); } else { console.log(` ❌ Failed (exit code ${result.status})`); console.log(`\n${output}\n`); results.push({ feature, success: false, skipped: false, reason: `Exit code ${result.status}` }); } } // Print summary console.log('─'.repeat(60)); console.log('Summary:\n'); const successful = results.filter((r) => r.success); const failed = results.filter((r) => !r.success && !r.skipped); const skipped = results.filter((r) => r.skipped); console.log(` ✅ Successful: ${successful.length}`); console.log(` ⚠️ Failed/Skipped: ${failed.length + skipped.length}`); if (failed.length > 0) { console.log('\n⚠️ Features with issues (continuing anyway):'); failed.forEach((r) => { console.log(` - ${r.feature}: ${r.reason || 'Unknown error'}`); }); } if (skipped.length > 0) { console.log('\n⚠️ Skipped features:'); skipped.forEach((r) => { console.log(` - ${r.feature}: ${r.reason || 'Unknown reason'}`); }); } console.log('\n✅ Migration setup complete! (failures are OK in dev mode)'); } main().catch((error) => { console.error('❌ Fatal error:', error); process.exit(1); });