v1.3.6: 운영 DB 마이그레이션 적용 이력 및 NAS 명령 추가
schema_migrations로 적용 파일을 추적하고, 기존 운영 DB는 001부터 자동 실행하지 않도록 baseline 흐름을 둔다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -137,16 +137,14 @@ cp .env.example .env.production
|
|||||||
# Docker 빌드 및 실행
|
# Docker 빌드 및 실행
|
||||||
docker compose --env-file .env.production up -d --build
|
docker compose --env-file .env.production up -d --build
|
||||||
|
|
||||||
# 기존 운영 DB를 유지한 채 새 버전을 올릴 때 추천 글·네비 location 마이그레이션 적용
|
# 운영 DB 마이그레이션 상태 확인
|
||||||
docker compose --env-file .env.production exec sori-studio-db psql -U sori_studio -d sori_studio -f /docker-entrypoint-initdb.d/023_add_post_featured.sql
|
npm run db:migrate:prod:status
|
||||||
docker compose --env-file .env.production exec sori-studio-db psql -U sori_studio -d sori_studio -f /docker-entrypoint-initdb.d/024_navigation_recommended_location.sql
|
|
||||||
docker compose --env-file .env.production exec sori-studio-db psql -U sori_studio -d sori_studio -f /docker-entrypoint-initdb.d/025_posts_status_no_private.sql
|
# schema_migrations 도입 전 운영 DB가 이미 최신이면 최초 1회 기준점 기록(실제 SQL 실행 없음)
|
||||||
docker compose --env-file .env.production exec sori-studio-db psql -U sori_studio -d sori_studio -f /docker-entrypoint-initdb.d/026_site_settings_show_post_updated_at.sql
|
npm run db:migrate:prod:baseline
|
||||||
docker compose --env-file .env.production exec sori-studio-db psql -U sori_studio -d sori_studio -f /docker-entrypoint-initdb.d/027_site_settings_home_cover.sql
|
|
||||||
docker compose --env-file .env.production exec sori-studio-db psql -U sori_studio -d sori_studio -f /docker-entrypoint-initdb.d/028_site_settings_announcement.sql
|
# 이후 배포에서는 아직 적용되지 않은 SQL만 순서대로 실행
|
||||||
docker compose --env-file .env.production exec sori-studio-db psql -U sori_studio -d sori_studio -f /docker-entrypoint-initdb.d/029_site_settings_signup_blocked_usernames.sql
|
npm run db:migrate:prod
|
||||||
docker compose --env-file .env.production exec sori-studio-db psql -U sori_studio -d sori_studio -f /docker-entrypoint-initdb.d/030_analytics_daily_stats.sql
|
|
||||||
docker compose --env-file .env.production exec sori-studio-db psql -U sori_studio -d sori_studio -f /docker-entrypoint-initdb.d/031_analytics_engagement_and_realtime.sql
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Docker 네트워크 충돌 대응
|
### Docker 네트워크 충돌 대응
|
||||||
@@ -200,6 +198,8 @@ docker compose --env-file .env.production up -d --build
|
|||||||
- NAS Docker 배포 시 PostgreSQL 초기 스키마는 `db/migrations/`의 SQL로 생성
|
- NAS Docker 배포 시 PostgreSQL 초기 스키마는 `db/migrations/`의 SQL로 생성
|
||||||
- 로컬 개발 Docker Compose 실행 시 `ENV_FILE=.env.development`와 `--env-file .env.development`를 함께 사용
|
- 로컬 개발 Docker Compose 실행 시 `ENV_FILE=.env.development`와 `--env-file .env.development`를 함께 사용
|
||||||
- 로컬 개발 DB 마이그레이션은 `npm run db:migrate:dev`로 실행
|
- 로컬 개발 DB 마이그레이션은 `npm run db:migrate:dev`로 실행
|
||||||
|
- NAS 운영 DB 마이그레이션은 `npm run db:migrate:prod:status`로 적용 상태를 확인하고, `npm run db:migrate:prod`로 미적용 파일만 실행한다.
|
||||||
|
- `schema_migrations`가 없는 기존 운영 DB에서 `posts` 테이블이 감지되면 `db:migrate:prod`는 001부터 자동 실행하지 않는다. 현재 코드 기준 최신 DB라면 최초 1회 `npm run db:migrate:prod:baseline`으로 기존 파일을 적용 완료로 기록한다. 특정 번호까지만 기록하려면 예: `npm run db:migrate:prod:baseline -- --to=031`.
|
||||||
- 네비게이션 계층(`parent_id`, `is_folder`)은 `017_navigation_hierarchy.sql` 적용 후 저장 API가 정상 동작한다(미적용 시 `INSERT` 컬럼 불일치).
|
- 네비게이션 계층(`parent_id`, `is_folder`)은 `017_navigation_hierarchy.sql` 적용 후 저장 API가 정상 동작한다(미적용 시 `INSERT` 컬럼 불일치).
|
||||||
- 회원 마지막 로그인 표시(`previous_last_seen_at`, `previous_last_seen_ip`)는 `021_add_member_previous_login.sql` 적용 후 정상 동작한다.
|
- 회원 마지막 로그인 표시(`previous_last_seen_at`, `previous_last_seen_ip`)는 `021_add_member_previous_login.sql` 적용 후 정상 동작한다.
|
||||||
- 사이트 로고와 파비콘 저장(`logo_url`, `favicon_url`)은 `022_add_site_logo_urls.sql` 적용 후 정상 동작한다.
|
- 사이트 로고와 파비콘 저장(`logo_url`, `favicon_url`)은 `022_add_site_logo_urls.sql` 적용 후 정상 동작한다.
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 2026-05-20 v1.3.6
|
||||||
|
|
||||||
|
### NAS 운영 마이그레이션 적용 이력 도입
|
||||||
|
|
||||||
|
운영 NAS에서 몇 번 SQL까지 적용했는지 파일명만으로 추적하면 누락과 중복 실행을 사람이 기억해야 한다. `schema_migrations` 테이블에 적용 완료 파일을 기록하고, 운영용 `db:migrate:prod`는 기록이 없는 파일만 실행하도록 했다. 이미 운영 중인 DB에는 과거 적용 기록이 없으므로, 기존 스키마가 감지되면 001부터 자동 실행하지 않고 baseline 기록을 요구해 데이터 변경 SQL의 의도치 않은 재실행을 막는다.
|
||||||
|
|
||||||
## 2026-05-20 v1.3.5
|
## 2026-05-20 v1.3.5
|
||||||
|
|
||||||
### 관리자 로그인·대시보드 차트·통계 보관 후속
|
### 관리자 로그인·대시보드 차트·통계 보관 후속
|
||||||
|
|||||||
@@ -291,7 +291,8 @@
|
|||||||
| composables/useThemeMode.js | 사용자 화면 라이트/다크 테마 상태 관리 |
|
| composables/useThemeMode.js | 사용자 화면 라이트/다크 테마 상태 관리 |
|
||||||
| middleware/admin-auth.global.js | 관리자 페이지 접근 인증 |
|
| middleware/admin-auth.global.js | 관리자 페이지 접근 인증 |
|
||||||
| scripts/dev-server.js | 로컬 개발 서버 링크 요약 출력 |
|
| scripts/dev-server.js | 로컬 개발 서버 링크 요약 출력 |
|
||||||
| scripts/migrate-development-db.js | 로컬 개발 DB 마이그레이션 실행 |
|
| scripts/migrate-database.js | 로컬·NAS DB 마이그레이션 적용/상태/baseline 실행 |
|
||||||
|
| scripts/migrate-development-db.js | 기존 로컬 개발 DB 마이그레이션 명령 호환 래퍼 |
|
||||||
| .env.example | 환경 변수 예시 |
|
| .env.example | 환경 변수 예시 |
|
||||||
| Dockerfile | NAS 운영 이미지 빌드 |
|
| Dockerfile | NAS 운영 이미지 빌드 |
|
||||||
| docker-compose.yml | NAS 컨테이너 실행 초안 |
|
| docker-compose.yml | NAS 컨테이너 실행 초안 |
|
||||||
|
|||||||
@@ -213,6 +213,14 @@ components/content/
|
|||||||
- 운영 DB 접속 정보는 로컬 기본 `.env`에 기록하지 않음
|
- 운영 DB 접속 정보는 로컬 기본 `.env`에 기록하지 않음
|
||||||
- DB 관리 도구는 CloudBeaver 등을 사용할 수 있도록 접속 정보를 환경별로 분리
|
- DB 관리 도구는 CloudBeaver 등을 사용할 수 있도록 접속 정보를 환경별로 분리
|
||||||
|
|
||||||
|
### 마이그레이션 적용 이력
|
||||||
|
|
||||||
|
- `schema_migrations` 테이블은 적용 완료된 SQL 파일명을 `file_name` 기준으로 기록한다.
|
||||||
|
- `npm run db:migrate:dev`와 `npm run db:migrate:prod`는 `db/migrations/*.sql` 중 `schema_migrations`에 없는 파일만 순서대로 실행한다.
|
||||||
|
- `npm run db:migrate:prod:status`는 NAS 운영 DB의 적용/대기 파일 목록을 출력한다.
|
||||||
|
- 기존 운영 DB에 `posts` 테이블은 있지만 `schema_migrations`가 없으면 `npm run db:migrate:prod`는 데이터 보호를 위해 001부터 자동 실행하지 않고 중단한다.
|
||||||
|
- 기존 운영 DB가 현재 코드 기준으로 이미 최신이면 `npm run db:migrate:prod:baseline`으로 현재 마이그레이션 파일들을 실행 없이 적용 완료로 기록한 뒤 이후 새 파일만 적용한다.
|
||||||
|
|
||||||
### Posts (블로그 글)
|
### Posts (블로그 글)
|
||||||
|
|
||||||
| 필드 | 타입 | 설명 |
|
| 필드 | 타입 | 설명 |
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# 업데이트 이력
|
# 업데이트 이력
|
||||||
|
|
||||||
|
## v1.3.6
|
||||||
|
|
||||||
|
- DB 마이그레이션: `schema_migrations` 적용 이력 관리와 `db:migrate:prod`, `db:migrate:prod:status`, `db:migrate:prod:baseline` 명령 추가.
|
||||||
|
- 운영 보호: 기존 운영 스키마가 있는데 적용 이력이 없으면 001부터 자동 실행하지 않고 baseline 안내 후 중단.
|
||||||
|
|
||||||
## v1.3.5
|
## v1.3.5
|
||||||
|
|
||||||
- 관리자 로그인: 자동완성 값 동기화·제출 중만 버튼 비활성. 운영 HTTP에서 Secure 쿠키 미저장으로 로그인 루프되던 문제 수정. `server/utils/session-cookie.js`로 path `/`·`x-forwarded-proto` Secure 통일.
|
- 관리자 로그인: 자동완성 값 동기화·제출 중만 버튼 비활성. 운영 HTTP에서 Secure 쿠키 미저장으로 로그인 루프되던 문제 수정. `server/utils/session-cookie.js`로 path `/`·`x-forwarded-proto` Secure 통일.
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.3.4",
|
"version": "1.3.6",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.3.4",
|
"version": "1.3.6",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.3.5",
|
"version": "1.3.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"imports": {
|
"imports": {
|
||||||
@@ -16,7 +16,12 @@
|
|||||||
"test": "npm run build",
|
"test": "npm run build",
|
||||||
"verify": "npm run lint && npm run test",
|
"verify": "npm run lint && npm run test",
|
||||||
"preview": "nuxt preview --dotenv .env.development --host 127.0.0.1 --port 43117",
|
"preview": "nuxt preview --dotenv .env.development --host 127.0.0.1 --port 43117",
|
||||||
"db:migrate:dev": "node scripts/migrate-development-db.js",
|
"db:migrate": "node scripts/migrate-database.js migrate",
|
||||||
|
"db:migrate:dev": "MIGRATION_ENV=development ENV_FILE=.env.development node scripts/migrate-database.js migrate",
|
||||||
|
"db:migrate:dev:status": "MIGRATION_ENV=development ENV_FILE=.env.development node scripts/migrate-database.js status",
|
||||||
|
"db:migrate:prod": "MIGRATION_ENV=production ENV_FILE=.env.production node scripts/migrate-database.js migrate",
|
||||||
|
"db:migrate:prod:status": "MIGRATION_ENV=production ENV_FILE=.env.production node scripts/migrate-database.js status",
|
||||||
|
"db:migrate:prod:baseline": "MIGRATION_ENV=production ENV_FILE=.env.production node scripts/migrate-database.js baseline",
|
||||||
"postinstall": "nuxt prepare"
|
"postinstall": "nuxt prepare"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
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 './migrate-database.js'
|
||||||
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()
|
|
||||||
|
|||||||
Reference in New Issue
Block a user