platform-tooling/scripts/database/migrate-all-dev.ts
Quinn Ftw 85621b287e chore: snapshot before monorepo consolidation
Capture current working state before converting platform-tooling
into a submodule of the lilith-platform monorepo.
2026-01-29 07:04:39 -08:00

691 lines
24 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
const portToContainer: Record<number, string> = {
5432: 'lilith-dev-postgres', // Main infrastructure postgres
5435: 'lilith-i18n-postgres', // I18N/platform-admin
5438: 'lilith-landing-postgres', // Landing
5444: 'lilith-marketplace-postgres', // Marketplace
5445: 'lilith-merchant-postgres', // Merchant
5448: 'lilith-image-assistant-postgres', // Image Assistant
};
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-i18n-postgres': { user: 'i18n', password: 'i18n_dev_password', defaultDb: 'platform_admin' },
'lilith-landing-postgres': { user: 'lilith', password: 'lilith', defaultDb: 'lilith_landing' },
'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' },
};
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.container).map((f) => f.container as string))
);
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('');
}
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'); // TODO: detect environment from process.env or CLI arg
}
// 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;
}
// 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);
});