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();
|