import { existsSync, readFileSync, readdirSync } from 'node:fs' import { join } from 'node:path' import { spawnSync } from 'node:child_process' const rootDir = process.cwd() const migrationMode = process.argv[2] || 'migrate' const migrationEnvironment = process.env.MIGRATION_ENV || 'development' const isProductionMigration = migrationEnvironment === 'production' const envFile = process.env.ENV_FILE || (isProductionMigration ? '.env.production' : '.env.development') const serviceName = process.env.DB_SERVICE || 'sori-studio-db' const migrationsDir = join(rootDir, 'db', 'migrations') const containerMigrationsDir = '/docker-entrypoint-initdb.d' const schemaMigrationsTable = 'schema_migrations' /** * 환경 파일 값 조회 * @returns {Record} 환경 변수 맵 */ const readEnvFile = () => { const envPath = join(rootDir, envFile) if (!existsSync(envPath)) { return {} } return readFileSync(envPath, 'utf8') .split('\n') .reduce((envValues, line) => { const trimmedLine = line.trim() if (!trimmedLine || trimmedLine.startsWith('#')) { return envValues } const separatorIndex = trimmedLine.indexOf('=') if (separatorIndex < 0) { return envValues } const key = trimmedLine.slice(0, separatorIndex).trim() const value = trimmedLine.slice(separatorIndex + 1).trim().replace(/^["']|["']$/g, '') envValues[key] = value return envValues }, {}) } const envValues = readEnvFile() const databaseName = process.env.POSTGRES_DB || envValues.POSTGRES_DB || 'sori_studio' const databaseUser = process.env.POSTGRES_USER || envValues.POSTGRES_USER || 'sori_studio' /** * SQL 문자열 리터럴 이스케이프 * @param {string} value - SQL 문자열 값 * @returns {string} 이스케이프된 SQL 문자열 */ const escapeSqlLiteral = (value) => value.replaceAll("'", "''") /** * 기준 마이그레이션 번호 인자 조회 * @returns {string} 기준 번호 또는 파일명 */ const getBaselineTarget = () => { const toArgument = process.argv.find((argument) => argument.startsWith('--to=')) return toArgument ? toArgument.replace('--to=', '') : '' } /** * 명령 실행 결과 확인 * @param {string} command - 실행할 명령 * @param {string[]} args - 명령 인자 * @param {Object} options - 실행 옵션 * @returns {import('node:child_process').SpawnSyncReturns} 실행 결과 */ const runCommand = (command, args, options = {}) => { const result = spawnSync(command, args, { cwd: rootDir, env: { ...process.env, ENV_FILE: envFile }, encoding: 'utf8', stdio: options.stdio || 'inherit', ...options }) if (result.status !== 0) { process.exit(result.status || 1) } return result } /** * Docker compose 인자 생성 * @param {string[]} args - compose 하위 명령 인자 * @returns {string[]} 전체 docker compose 인자 */ const getComposeArgs = (args) => ['compose', '--env-file', envFile, ...args] /** * psql 실행 인자 생성 * @param {string[]} args - psql 인자 * @returns {string[]} docker compose exec psql 인자 */ const getPsqlArgs = (args) => getComposeArgs([ 'exec', '-T', serviceName, 'psql', '-v', 'ON_ERROR_STOP=1', '-U', databaseUser, '-d', databaseName, ...args ]) /** * SQL 실행 * @param {string} sql - 실행할 SQL * @returns {void} */ const runSql = (sql) => { runCommand('docker', getPsqlArgs(['-c', sql])) } /** * SQL 실행 결과 조회 * @param {string} sql - 조회할 SQL * @returns {string} 조회 결과 */ const querySql = (sql) => { const result = runCommand('docker', getPsqlArgs(['-At', '-c', sql]), { stdio: 'pipe' }) return (result.stdout || '').trim() } /** * 마이그레이션 파일 목록 조회 * @returns {string[]} SQL 파일 목록 */ const getMigrationFiles = () => readdirSync(migrationsDir) .filter((fileName) => fileName.endsWith('.sql')) .sort((firstFile, secondFile) => firstFile.localeCompare(secondFile)) /** * DB 컨테이너 시작 * @returns {void} */ const startDatabase = () => { runCommand('docker', getComposeArgs([ 'up', '-d', serviceName ])) } /** * DB 준비 상태 확인 * @returns {void} */ const waitForDatabase = () => { for (let attempt = 1; attempt <= 20; attempt += 1) { const result = spawnSync('docker', getComposeArgs([ 'exec', '-T', serviceName, 'pg_isready', '-U', databaseUser, '-d', databaseName ]), { cwd: rootDir, stdio: 'ignore' }) if (result.status === 0) { return } spawnSync('sleep', ['1']) } console.error('DB가 준비되지 않았습니다.') process.exit(1) } /** * 적용 이력 테이블 존재 여부 확인 * @returns {boolean} 존재 여부 */ const hasSchemaMigrationsTable = () => querySql(`SELECT to_regclass('public.${schemaMigrationsTable}') IS NOT NULL;`) === 't' /** * 기존 애플리케이션 스키마 존재 여부 확인 * @returns {boolean} 존재 여부 */ const hasApplicationSchema = () => querySql("SELECT to_regclass('public.posts') IS NOT NULL;") === 't' /** * 적용 이력 테이블 생성 * @returns {void} */ const ensureSchemaMigrationsTable = () => { runSql(` CREATE TABLE IF NOT EXISTS ${schemaMigrationsTable} ( file_name TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT now() ); `) } /** * 적용 완료 마이그레이션 목록 조회 * @returns {Set} 적용 완료 파일명 집합 */ const getAppliedMigrationFiles = () => { if (!hasSchemaMigrationsTable()) { return new Set() } const output = querySql(`SELECT file_name FROM ${schemaMigrationsTable} ORDER BY file_name;`) return new Set(output ? output.split('\n').filter(Boolean) : []) } /** * SQL 마이그레이션 파일 실행 * @param {string} fileName - 실행할 SQL 파일명 * @returns {void} */ const runMigrationFile = (fileName) => { runCommand('docker', getPsqlArgs(['-f', `${containerMigrationsDir}/${fileName}`])) runSql(`INSERT INTO ${schemaMigrationsTable} (file_name) VALUES ('${escapeSqlLiteral(fileName)}') ON CONFLICT (file_name) DO NOTHING;`) } /** * 마이그레이션 상태 출력 * @returns {void} */ const printMigrationStatus = () => { const migrationFiles = getMigrationFiles() const appliedFiles = getAppliedMigrationFiles() if (!hasSchemaMigrationsTable()) { console.log('schema_migrations 테이블이 없습니다.') console.log('기존 운영 DB라면 먼저 npm run db:migrate:prod:baseline 으로 현재 파일들을 적용 완료로 기록하세요.') } for (const fileName of migrationFiles) { const status = appliedFiles.has(fileName) ? 'applied' : 'pending' console.log(`${status} ${fileName}`) } } /** * 기준 파일까지 적용 완료로 기록 * @returns {void} */ const baselineMigrations = () => { ensureSchemaMigrationsTable() const migrationFiles = getMigrationFiles() const baselineTarget = getBaselineTarget() const targetIndex = baselineTarget ? migrationFiles.findIndex((fileName) => fileName === baselineTarget || fileName.startsWith(`${baselineTarget}_`)) : migrationFiles.length - 1 if (targetIndex < 0) { console.error(`기준 마이그레이션을 찾을 수 없습니다: ${baselineTarget}`) process.exit(1) } const baselineFiles = migrationFiles.slice(0, targetIndex + 1) for (const fileName of baselineFiles) { runSql(`INSERT INTO ${schemaMigrationsTable} (file_name) VALUES ('${escapeSqlLiteral(fileName)}') ON CONFLICT (file_name) DO NOTHING;`) console.log(`baseline ${fileName}`) } console.log(`baseline 완료: ${baselineFiles.length}개 파일 기록`) } /** * 마이그레이션 실행 * @returns {void} */ const migrateDatabase = () => { const trackingTableExists = hasSchemaMigrationsTable() if (isProductionMigration && !trackingTableExists && hasApplicationSchema()) { console.error('운영 DB에 기존 스키마가 있지만 schema_migrations 적용 이력이 없습니다.') console.error('데이터 보호를 위해 001부터 자동 실행하지 않습니다.') console.error('현재 운영 DB가 최신 상태라면 npm run db:migrate:prod:baseline 으로 기준점을 먼저 기록하세요.') console.error('상태 확인: npm run db:migrate:prod:status') process.exit(1) } ensureSchemaMigrationsTable() const migrationFiles = getMigrationFiles() const appliedFiles = getAppliedMigrationFiles() const pendingFiles = migrationFiles.filter((fileName) => !appliedFiles.has(fileName)) if (pendingFiles.length === 0) { console.log('적용할 마이그레이션이 없습니다.') return } for (const fileName of pendingFiles) { console.log(`apply ${fileName}`) runMigrationFile(fileName) } console.log(`마이그레이션 완료: ${pendingFiles.length}개 파일 적용`) } startDatabase() waitForDatabase() if (migrationMode === 'status') { printMigrationStatus() } else if (migrationMode === 'baseline') { baselineMigrations() } else { migrateDatabase() }