Files
cloud-server/scripts/migrate.ts
kappa 5a9362bf43 feat: P1 보안/성능 개선 및 마이그레이션 자동화
Security fixes:
- migrate.ts: SQL/Command Injection 방지 (spawnSync 사용)
- migrate.ts: Path Traversal 검증 추가
- api-tester.ts: API 키 마스킹 (4자만 노출)
- api-tester.ts: 최소 16자 키 길이 검증
- cache.ts: ReDoS 방지 (패턴 길이/와일드카드 제한)

Performance improvements:
- cache.ts: 순차 삭제 → 병렬 배치 처리 (50개씩)
- cache.ts: KV 등록 fire-and-forget (non-blocking)
- cache.ts: 메모리 제한 (5000키)
- cache.ts: 25초 실행 시간 가드
- cache.ts: 패턴 매칭 prefix 최적화

New features:
- 마이그레이션 자동화 시스템 (scripts/migrate.ts)
- KV 기반 캐시 인덱스 (invalidatePattern, clearAll)
- 글로벌 CacheService 싱글톤

Other:
- .env.example 추가, API 키 환경변수 처리
- CACHE_TTL.RECOMMENDATIONS (10분) 분리
- e2e-tester.ts JSON 파싱 에러 핸들링 개선

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 00:23:13 +09:00

405 lines
12 KiB
TypeScript

#!/usr/bin/env tsx
/**
* Database Migration Runner
* Automatically detects and executes unapplied SQL migrations
*
* Usage:
* npm run db:migrate # Run on local database
* npm run db:migrate:remote # Run on remote database
* npm run db:migrate:status # Show migration status
*/
import { spawnSync } from 'child_process';
import { readdirSync, readFileSync, existsSync } from 'fs';
import { join, basename, resolve } from 'path';
// ============================================================
// Configuration
// ============================================================
const DB_NAME = 'cloud-instances-db';
const MIGRATIONS_DIR = join(process.cwd(), 'migrations');
const MIGRATION_HISTORY_TABLE = 'migration_history';
type Environment = 'local' | 'remote';
type Command = 'migrate' | 'status';
// ============================================================
// Utility Functions
// ============================================================
/**
* Sanitize migration name (prevent SQL injection)
* Only allows alphanumeric characters, underscores, and hyphens
*/
function sanitizeMigrationName(name: string): string {
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
throw new Error(`Invalid migration name: ${name} (only alphanumeric, underscore, and hyphen allowed)`);
}
return name;
}
/**
* Validate file path (prevent path traversal)
* Ensures the file is within the migrations directory
*/
function validateFilePath(filename: string): string {
// Check filename format
if (!/^[a-zA-Z0-9_-]+\.sql$/.test(filename)) {
throw new Error(`Invalid filename format: ${filename}`);
}
const filePath = join(MIGRATIONS_DIR, filename);
const resolvedPath = resolve(filePath);
const resolvedMigrationsDir = resolve(MIGRATIONS_DIR);
// Ensure resolved path is within migrations directory
if (!resolvedPath.startsWith(resolvedMigrationsDir + '/') && resolvedPath !== resolvedMigrationsDir) {
throw new Error(`Path traversal detected: ${filename}`);
}
return filePath;
}
/**
* Execute wrangler d1 command and return output
* Uses spawnSync to prevent command injection
*/
function executeD1Command(sql: string, env: Environment): string {
const envFlag = env === 'remote' ? '--remote' : '--local';
const args = ['d1', 'execute', DB_NAME, envFlag, '--command', sql];
try {
const result = spawnSync('wrangler', args, {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe']
});
if (result.error) {
throw new Error(`Failed to execute wrangler: ${result.error.message}`);
}
if (result.status !== 0) {
throw new Error(`D1 command failed with exit code ${result.status}: ${result.stderr}`);
}
return result.stdout;
} catch (error: any) {
throw new Error(`D1 command failed: ${error.message}`);
}
}
/**
* Execute SQL file using wrangler d1
* Uses spawnSync to prevent command injection
*/
function executeD1File(filePath: string, env: Environment): void {
const envFlag = env === 'remote' ? '--remote' : '--local';
const args = ['d1', 'execute', DB_NAME, envFlag, '--file', filePath];
try {
const result = spawnSync('wrangler', args, {
encoding: 'utf-8',
stdio: 'inherit'
});
if (result.error) {
throw new Error(`Failed to execute wrangler: ${result.error.message}`);
}
if (result.status !== 0) {
throw new Error(`Migration file execution failed with exit code ${result.status}`);
}
} catch (error: any) {
throw new Error(`Migration file execution failed: ${error.message}`);
}
}
/**
* Get list of applied migrations from database
*/
function getAppliedMigrations(env: Environment): Set<string> {
try {
const sql = `SELECT migration_name FROM ${MIGRATION_HISTORY_TABLE} WHERE success = 1`;
const output = executeD1Command(sql, env);
const migrations = new Set<string>();
const lines = output.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (trimmed && !trimmed.includes('migration_name') && !trimmed.includes('─')) {
migrations.add(trimmed);
}
}
return migrations;
} catch (error: any) {
// If table doesn't exist, return empty set
if (error.message.includes('no such table')) {
return new Set<string>();
}
throw error;
}
}
/**
* Get list of migration files from migrations directory
*/
function getMigrationFiles(): string[] {
if (!existsSync(MIGRATIONS_DIR)) {
throw new Error(`Migrations directory not found: ${MIGRATIONS_DIR}`);
}
const files = readdirSync(MIGRATIONS_DIR)
.filter(f => f.endsWith('.sql'))
.filter(f => f !== 'README.md')
.sort(); // Alphabetical sort ensures numeric order (000, 002, 003, 004)
return files;
}
/**
* Extract migration name from filename (without .sql extension)
*/
function getMigrationName(filename: string): string {
return basename(filename, '.sql');
}
/**
* Record migration execution in history table
*/
function recordMigration(
migrationName: string,
executionTimeMs: number,
success: boolean,
errorMessage: string | null,
env: Environment
): void {
// Sanitize migration name to prevent SQL injection
const safeMigrationName = sanitizeMigrationName(migrationName);
// Escape error message (if any) to prevent SQL injection
const safeErrorMessage = errorMessage
? `'${errorMessage.replace(/'/g, "''")}'`
: 'NULL';
const sql = `
INSERT INTO ${MIGRATION_HISTORY_TABLE}
(migration_name, execution_time_ms, success, error_message)
VALUES
('${safeMigrationName}', ${executionTimeMs}, ${success ? 1 : 0}, ${safeErrorMessage})
`;
try {
executeD1Command(sql, env);
} catch (error: any) {
console.error(`[ERROR] Failed to record migration: ${error.message}`);
}
}
/**
* Execute a single migration file
*/
function executeMigration(filename: string, env: Environment): boolean {
const migrationName = getMigrationName(filename);
// Validate file path to prevent path traversal attacks
const filePath = validateFilePath(filename);
console.log(`\n[Migrate] Executing: ${migrationName}`);
console.log(`[Migrate] File: ${filename}`);
const startTime = Date.now();
try {
executeD1File(filePath, env);
const executionTime = Date.now() - startTime;
console.log(`[Migrate] ✅ Success (${executionTime}ms)`);
// Record success (only if not 000_migration_history itself)
if (migrationName !== '000_migration_history') {
recordMigration(migrationName, executionTime, true, null, env);
}
return true;
} catch (error: any) {
const executionTime = Date.now() - startTime;
const errorMessage = error.message || 'Unknown error';
console.error(`[Migrate] ❌ Failed (${executionTime}ms)`);
console.error(`[Migrate] Error: ${errorMessage}`);
// Record failure (only if not 000_migration_history itself)
if (migrationName !== '000_migration_history') {
recordMigration(migrationName, executionTime, false, errorMessage, env);
}
return false;
}
}
// ============================================================
// Main Migration Logic
// ============================================================
/**
* Run all pending migrations
*/
function runMigrations(env: Environment): void {
console.log(`\n${'='.repeat(60)}`);
console.log(`[Migrate] Database Migration Runner`);
console.log(`[Migrate] Environment: ${env}`);
console.log(`[Migrate] Database: ${DB_NAME}`);
console.log(`${'='.repeat(60)}\n`);
const allMigrations = getMigrationFiles();
console.log(`[Migrate] Found ${allMigrations.length} migration files`);
// Ensure migration history table exists first
const historyMigration = allMigrations.find(f => f.startsWith('000_migration_history'));
if (!historyMigration) {
console.error('[Migrate] ❌ Migration history table file (000_migration_history.sql) not found!');
process.exit(1);
}
// Execute history table migration first
console.log(`[Migrate] Ensuring migration tracking table exists...`);
if (!executeMigration(historyMigration, env)) {
console.error('[Migrate] ❌ Failed to create migration history table');
process.exit(1);
}
// Get applied migrations
const appliedMigrations = getAppliedMigrations(env);
console.log(`[Migrate] ${appliedMigrations.size} migrations already applied`);
// Filter pending migrations (excluding 000_migration_history)
const pendingMigrations = allMigrations
.filter(f => !f.startsWith('000_migration_history'))
.filter(f => !appliedMigrations.has(getMigrationName(f)));
if (pendingMigrations.length === 0) {
console.log('\n[Migrate] ✅ All migrations are up to date. Nothing to do.');
return;
}
console.log(`\n[Migrate] ${pendingMigrations.length} pending migrations to execute:`);
pendingMigrations.forEach(f => console.log(` - ${getMigrationName(f)}`));
// Execute each pending migration
let successCount = 0;
let failureCount = 0;
for (const filename of pendingMigrations) {
const success = executeMigration(filename, env);
if (success) {
successCount++;
} else {
failureCount++;
console.error(`\n[Migrate] ❌ Migration failed: ${filename}`);
console.error(`[Migrate] Stopping migration process.`);
break; // Stop on first failure
}
}
// Summary
console.log(`\n${'='.repeat(60)}`);
console.log(`[Migrate] Migration Summary:`);
console.log(`[Migrate] ✅ Successful: ${successCount}`);
if (failureCount > 0) {
console.log(`[Migrate] ❌ Failed: ${failureCount}`);
}
console.log(`${'='.repeat(60)}\n`);
if (failureCount > 0) {
process.exit(1);
}
}
/**
* Show migration status
*/
function showStatus(env: Environment): void {
console.log(`\n${'='.repeat(60)}`);
console.log(`[Migrate] Migration Status`);
console.log(`[Migrate] Environment: ${env}`);
console.log(`[Migrate] Database: ${DB_NAME}`);
console.log(`${'='.repeat(60)}\n`);
const allMigrations = getMigrationFiles();
console.log(`[Migrate] Total migration files: ${allMigrations.length}`);
try {
const appliedMigrations = getAppliedMigrations(env);
console.log(`[Migrate] Applied migrations: ${appliedMigrations.size}`);
// Show detailed status
console.log(`\n[Migrate] Detailed Status:\n`);
for (const filename of allMigrations) {
const migrationName = getMigrationName(filename);
const isApplied = appliedMigrations.has(migrationName);
const status = isApplied ? '✅' : '⏳';
const label = isApplied ? 'Applied' : 'Pending';
console.log(` ${status} ${migrationName.padEnd(40)} [${label}]`);
}
const pendingCount = allMigrations.length - appliedMigrations.size;
if (pendingCount > 0) {
console.log(`\n[Migrate] ⚠️ ${pendingCount} migrations pending execution`);
console.log(`[Migrate] Run 'npm run db:migrate' to apply pending migrations`);
} else {
console.log(`\n[Migrate] ✅ All migrations are up to date`);
}
} catch (error: any) {
console.error(`[Migrate] ❌ Error reading migration status: ${error.message}`);
console.error(`[Migrate] The migration_history table may not exist yet.`);
console.error(`[Migrate] Run 'npm run db:migrate' to initialize the system.`);
process.exit(1);
}
console.log(`\n${'='.repeat(60)}\n`);
}
// ============================================================
// CLI Entry Point
// ============================================================
function main(): void {
const args = process.argv.slice(2);
// Parse command
let command: Command = 'migrate';
if (args.includes('--status')) {
command = 'status';
}
// Parse environment
let env: Environment = 'local';
if (args.includes('--remote')) {
env = 'remote';
}
// Execute command
try {
if (command === 'status') {
showStatus(env);
} else {
runMigrations(env);
}
} catch (error: any) {
console.error(`\n[Migrate] ❌ Fatal error: ${error.message}`);
process.exit(1);
}
}
// Run if executed directly
if (require.main === module) {
main();
}