platform-tooling/scripts/database/migrate-all-dev.ts
2026-03-02 21:06:53 -08:00

840 lines
29 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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/<feature>.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<string, string> = {};
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<number, string> = {
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<string, string> = {
'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<string, SuperuserConfig> = {
'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<string, string>();
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);
});