v1.3.6: 운영 DB 마이그레이션 적용 이력 및 NAS 명령 추가
schema_migrations로 적용 파일을 추적하고, 기존 운영 DB는 001부터 자동 실행하지 않도록 baseline 흐름을 둔다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
331
scripts/migrate-database.js
Normal file
331
scripts/migrate-database.js
Normal file
@@ -0,0 +1,331 @@
|
||||
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<string, string>} 환경 변수 맵
|
||||
*/
|
||||
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<string>} 실행 결과
|
||||
*/
|
||||
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<string>} 적용 완료 파일명 집합
|
||||
*/
|
||||
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()
|
||||
}
|
||||
@@ -1,123 +1 @@
|
||||
import { readdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
const rootDir = process.cwd()
|
||||
const envFile = process.env.ENV_FILE || '.env.development'
|
||||
const serviceName = process.env.DB_SERVICE || 'sori-studio-db'
|
||||
const databaseName = process.env.POSTGRES_DB || 'sori_studio'
|
||||
const databaseUser = process.env.POSTGRES_USER || 'sori_studio'
|
||||
const migrationsDir = join(rootDir, 'db', 'migrations')
|
||||
const containerMigrationsDir = '/docker-entrypoint-initdb.d'
|
||||
|
||||
/**
|
||||
* 명령 실행 결과 확인
|
||||
* @param {string} command - 실행할 명령
|
||||
* @param {string[]} args - 명령 인자
|
||||
* @param {Object} options - 실행 옵션
|
||||
* @returns {void}
|
||||
*/
|
||||
const runCommand = (command, args, options = {}) => {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: rootDir,
|
||||
env: {
|
||||
...process.env,
|
||||
ENV_FILE: envFile
|
||||
},
|
||||
stdio: 'inherit',
|
||||
...options
|
||||
})
|
||||
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status || 1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 마이그레이션 파일 목록 조회
|
||||
* @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', [
|
||||
'compose',
|
||||
'--env-file',
|
||||
envFile,
|
||||
'up',
|
||||
'-d',
|
||||
serviceName
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* 개발 DB 준비 상태 확인
|
||||
* @returns {void}
|
||||
*/
|
||||
const waitForDatabase = () => {
|
||||
for (let attempt = 1; attempt <= 20; attempt += 1) {
|
||||
const result = spawnSync('docker', [
|
||||
'exec',
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL 마이그레이션 파일 실행
|
||||
* @param {string} fileName - 실행할 SQL 파일명
|
||||
* @returns {void}
|
||||
*/
|
||||
const runMigrationFile = (fileName) => {
|
||||
runCommand('docker', [
|
||||
'exec',
|
||||
serviceName,
|
||||
'psql',
|
||||
'-v',
|
||||
'ON_ERROR_STOP=1',
|
||||
'-U',
|
||||
databaseUser,
|
||||
'-d',
|
||||
databaseName,
|
||||
'-f',
|
||||
`${containerMigrationsDir}/${fileName}`
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* 개발 DB 마이그레이션 실행
|
||||
* @returns {void}
|
||||
*/
|
||||
const migrateDevelopmentDatabase = () => {
|
||||
startDatabase()
|
||||
waitForDatabase()
|
||||
|
||||
for (const fileName of getMigrationFiles()) {
|
||||
runMigrationFile(fileName)
|
||||
}
|
||||
}
|
||||
|
||||
migrateDevelopmentDatabase()
|
||||
import './migrate-database.js'
|
||||
|
||||
Reference in New Issue
Block a user