205 lines
6.8 KiB
JavaScript
205 lines
6.8 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* ═══════════════════════════════════════════════════════════
|
||
* 🗄️ AGE OS · 数据库迁移运行器
|
||
* ═══════════════════════════════════════════════════════════
|
||
*
|
||
* 签发: 铸渊 · ICE-GL-ZY001
|
||
* 版权: 国作登字-2026-A-00037559
|
||
*
|
||
* 按编号顺序执行 schema/*.sql 文件。
|
||
* 使用 schema_migrations 表跟踪已执行的迁移,
|
||
* 确保每个 SQL 文件只执行一次(幂等)。
|
||
*
|
||
* 用法:
|
||
* node scripts/db-migrate.js # 执行所有未执行的迁移
|
||
* node scripts/db-migrate.js --status # 查看迁移状态
|
||
* node scripts/db-migrate.js --force 003 # 强制重跑指定编号
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const crypto = require('crypto');
|
||
|
||
// ─── 数据库连接(复用 MCP db 模块的配置) ───
|
||
const { Pool } = require('pg');
|
||
|
||
const pool = new Pool({
|
||
host: process.env.ZY_DB_HOST || '127.0.0.1',
|
||
port: parseInt(process.env.ZY_DB_PORT || '5432', 10),
|
||
user: process.env.ZY_DB_USER || 'zy_admin',
|
||
password: process.env.ZY_DB_PASS || '',
|
||
database: process.env.ZY_DB_NAME || 'age_os',
|
||
max: 3,
|
||
connectionTimeoutMillis: 10000
|
||
});
|
||
|
||
const SCHEMA_DIR = path.resolve(__dirname, '..', 'schema');
|
||
|
||
// ─── 确保迁移跟踪表存在 ───
|
||
async function ensureMigrationTable() {
|
||
await pool.query(`
|
||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||
id SERIAL PRIMARY KEY,
|
||
filename VARCHAR(500) NOT NULL UNIQUE,
|
||
checksum VARCHAR(64),
|
||
executed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
duration_ms INT,
|
||
success BOOLEAN NOT NULL DEFAULT true,
|
||
error_msg TEXT
|
||
);
|
||
`);
|
||
}
|
||
|
||
// ─── 获取已执行的迁移列表 ───
|
||
async function getExecutedMigrations() {
|
||
const result = await pool.query(
|
||
'SELECT filename, executed_at, success FROM schema_migrations WHERE success = true ORDER BY filename'
|
||
);
|
||
return new Set(result.rows.map(r => r.filename));
|
||
}
|
||
|
||
// ─── 获取所有 schema SQL 文件(按编号排序) ───
|
||
function getSchemaFiles() {
|
||
if (!fs.existsSync(SCHEMA_DIR)) {
|
||
console.error(`[Migrate] Schema目录不存在: ${SCHEMA_DIR}`);
|
||
return [];
|
||
}
|
||
|
||
return fs.readdirSync(SCHEMA_DIR)
|
||
.filter(f => f.endsWith('.sql'))
|
||
.sort() // 按文件名排序(001, 002, 003...)
|
||
.map(f => ({
|
||
filename: f,
|
||
filepath: path.join(SCHEMA_DIR, f)
|
||
}));
|
||
}
|
||
|
||
// ─── 计算简单校验和 ───
|
||
function computeChecksum(content) {
|
||
return crypto.createHash('sha256').update(content).digest('hex').substring(0, 16);
|
||
}
|
||
|
||
// ─── 执行单个迁移 ───
|
||
async function executeMigration(file) {
|
||
const content = fs.readFileSync(file.filepath, 'utf8');
|
||
const checksum = computeChecksum(content);
|
||
const startTime = Date.now();
|
||
|
||
console.log(`[Migrate] ▶ 执行: ${file.filename}`);
|
||
|
||
try {
|
||
await pool.query(content);
|
||
const duration = Date.now() - startTime;
|
||
|
||
await pool.query(
|
||
`INSERT INTO schema_migrations (filename, checksum, duration_ms, success)
|
||
VALUES ($1, $2, $3, true)
|
||
ON CONFLICT (filename) DO UPDATE SET
|
||
checksum = $2, executed_at = NOW(), duration_ms = $3, success = true, error_msg = NULL`,
|
||
[file.filename, checksum, duration]
|
||
);
|
||
|
||
console.log(`[Migrate] ✅ ${file.filename} 完成 (${duration}ms)`);
|
||
return { success: true, duration };
|
||
} catch (err) {
|
||
const duration = Date.now() - startTime;
|
||
|
||
// 记录失败
|
||
await pool.query(
|
||
`INSERT INTO schema_migrations (filename, checksum, duration_ms, success, error_msg)
|
||
VALUES ($1, $2, $3, false, $4)
|
||
ON CONFLICT (filename) DO UPDATE SET
|
||
checksum = $2, executed_at = NOW(), duration_ms = $3, success = false, error_msg = $4`,
|
||
[file.filename, checksum, duration, err.message]
|
||
).catch(() => {});
|
||
|
||
console.error(`[Migrate] ❌ ${file.filename} 失败 (${duration}ms): ${err.message}`);
|
||
return { success: false, duration, error: err.message };
|
||
}
|
||
}
|
||
|
||
// ─── 主入口 ───
|
||
async function main() {
|
||
const args = process.argv.slice(2);
|
||
const isStatus = args.includes('--status');
|
||
const forceIndex = args.indexOf('--force');
|
||
const forcePrefix = forceIndex >= 0 ? args[forceIndex + 1] : null;
|
||
|
||
console.log('═══════════════════════════════════════════════');
|
||
console.log(' 🗄️ AGE OS · 数据库迁移运行器');
|
||
console.log(' 铸渊 · ICE-GL-ZY001');
|
||
console.log('═══════════════════════════════════════════════');
|
||
|
||
try {
|
||
// 测试连接
|
||
await pool.query('SELECT 1');
|
||
console.log('[Migrate] 数据库连接成功');
|
||
|
||
// 确保迁移表存在
|
||
await ensureMigrationTable();
|
||
|
||
const schemaFiles = getSchemaFiles();
|
||
const executed = await getExecutedMigrations();
|
||
|
||
if (isStatus) {
|
||
console.log('\n迁移状态:');
|
||
for (const file of schemaFiles) {
|
||
const status = executed.has(file.filename) ? '✅ 已执行' : '⏳ 待执行';
|
||
console.log(` ${status} ${file.filename}`);
|
||
}
|
||
console.log(`\n总计: ${schemaFiles.length} 个迁移文件, ${executed.size} 个已执行`);
|
||
return;
|
||
}
|
||
|
||
// 筛选待执行的迁移
|
||
let pending;
|
||
if (forcePrefix) {
|
||
pending = schemaFiles.filter(f => f.filename.startsWith(forcePrefix));
|
||
if (pending.length === 0) {
|
||
console.log(`[Migrate] 未找到前缀为 ${forcePrefix} 的迁移文件`);
|
||
return;
|
||
}
|
||
console.log(`[Migrate] 强制重跑 ${pending.length} 个迁移`);
|
||
} else {
|
||
pending = schemaFiles.filter(f => !executed.has(f.filename));
|
||
}
|
||
|
||
if (pending.length === 0) {
|
||
console.log('[Migrate] ✅ 所有迁移已是最新状态');
|
||
return;
|
||
}
|
||
|
||
console.log(`[Migrate] 发现 ${pending.length} 个待执行迁移\n`);
|
||
|
||
let successCount = 0;
|
||
let failCount = 0;
|
||
|
||
for (const file of pending) {
|
||
const result = await executeMigration(file);
|
||
if (result.success) {
|
||
successCount++;
|
||
} else {
|
||
failCount++;
|
||
// SQL 文件使用 IF NOT EXISTS,继续执行后续迁移
|
||
console.warn(`[Migrate] ⚠️ ${file.filename} 失败,继续执行后续迁移...`);
|
||
}
|
||
}
|
||
|
||
console.log(`\n[Migrate] 完成: ${successCount} 成功, ${failCount} 失败`);
|
||
|
||
if (failCount > 0) {
|
||
process.exitCode = 1;
|
||
}
|
||
} catch (err) {
|
||
console.error('[Migrate] 严重错误:', err.message);
|
||
process.exitCode = 1;
|
||
} finally {
|
||
await pool.end();
|
||
}
|
||
}
|
||
|
||
main();
|