458 lines
23 KiB
JavaScript
458 lines
23 KiB
JavaScript
import mysql from 'mysql2/promise';
|
|
import { randomUUID } from 'node:crypto';
|
|
import { mkdir, open, readFile, rename } from 'node:fs/promises';
|
|
import { dirname, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import {
|
|
USER_COLUMNS, FRIEND_COLUMNS, GROUP_MEMBER_COLUMNS, assertSame, createPlan,
|
|
expectedIm, fingerprint, imPart, rollbackChanges, summary, temporaryName, validatePlan,
|
|
} from './core.mjs';
|
|
|
|
const TASK_COLUMNS = ['tenant_id', 'profile_json', 'member_state', 'version', 'synced_version',
|
|
'initialized', 'retry_count', 'last_error'];
|
|
const NEW_COLUMNS = ['member_state', 'sync_version', 'auth_version', 'access_disabled'];
|
|
|
|
function argumentsFor(argv) {
|
|
const [command, ...args] = argv;
|
|
const options = {};
|
|
for (let index = 0; index < args.length; index++) {
|
|
const key = args[index];
|
|
if (key === '--maintenance' || key === '--ready') {
|
|
options[key.slice(2)] = true;
|
|
} else if (['--config', '--plan', '--out'].includes(key) && args[index + 1]) {
|
|
options[key.slice(2)] = args[++index];
|
|
} else {
|
|
throw new Error(`无法识别的参数 ${key}`);
|
|
}
|
|
}
|
|
if (!['plan', 'apply', 'verify', 'rollback'].includes(command) || !options.config) {
|
|
throw new Error('用法为 node repair-im-users.mjs plan|apply|verify|rollback --config <配置文件> [--plan <清单>] [--out <清单>] [--maintenance] [--ready]');
|
|
}
|
|
if (command !== 'plan' && !options.plan) throw new Error('请使用 --plan 指定已生成的修复清单');
|
|
if (['apply', 'rollback'].includes(command) && !options.maintenance) {
|
|
throw new Error('执行前须暂停业务写入、所有同步任务和 IM 服务,再指定 --maintenance');
|
|
}
|
|
return { command, options };
|
|
}
|
|
|
|
async function atomicJson(path, value) {
|
|
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
const temporary = `${path}.tmp-${process.pid}`;
|
|
const handle = await open(temporary, 'w', 0o600);
|
|
try {
|
|
await handle.writeFile(JSON.stringify(value, null, 2) + '\n');
|
|
await handle.sync();
|
|
} finally {
|
|
await handle.close();
|
|
}
|
|
await rename(temporary, path);
|
|
}
|
|
|
|
async function loadJson(path, fallback) {
|
|
try {
|
|
return JSON.parse(await readFile(path, 'utf8'));
|
|
} catch (error) {
|
|
if (error.code === 'ENOENT' && fallback !== undefined) return fallback;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function connect(config) {
|
|
if (!config?.host || !config.user || !config.database || !config.passwordEnv) {
|
|
throw new Error('每个数据库连接均须明确填写 host、user、database、passwordEnv');
|
|
}
|
|
const password = process.env[config.passwordEnv];
|
|
if (password === undefined) throw new Error(`缺少密码环境变量 ${config.passwordEnv}`);
|
|
return mysql.createConnection({
|
|
host: config.host, port: config.port ?? 3306, user: config.user,
|
|
database: config.database, password, ssl: config.ssl,
|
|
connectTimeout: 10000, supportBigNumbers: true, bigNumberStrings: true,
|
|
dateStrings: true, charset: 'utf8mb4', multipleStatements: false,
|
|
});
|
|
}
|
|
|
|
function normalize(row) {
|
|
const output = { ...row };
|
|
for (const key of ['id', 'member_id', 'tenant_id', 'friend_id', 'user_id', 'group_id', 'owner_id',
|
|
'version', 'synced_version', 'sync_version', 'auth_version']) {
|
|
if (output[key] != null) output[key] = String(output[key]);
|
|
}
|
|
for (const key of ['deleted', 'is_banned', 'access_disabled', 'initialized']) {
|
|
if (key in output) {
|
|
const value = output[key];
|
|
output[key] = Buffer.isBuffer(value) ? value[0] !== 0 : Boolean(Number(value));
|
|
}
|
|
}
|
|
return output;
|
|
}
|
|
|
|
async function rows(connection, sql, parameters = []) {
|
|
const [result] = await connection.query(sql, parameters);
|
|
return result.map(normalize);
|
|
}
|
|
|
|
async function identity(connection) {
|
|
const [row] = await rows(connection, 'SELECT DATABASE() AS db, @@server_uuid AS serverUuid, @@hostname AS serverName, VERSION() AS version');
|
|
return row;
|
|
}
|
|
|
|
async function inspectSchema(business, im) {
|
|
const columns = await rows(im, 'SELECT COLUMN_NAME AS name, CHARACTER_MAXIMUM_LENGTH AS maximum, COLLATION_NAME AS collation FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?', ['im_user']);
|
|
const names = new Set(columns.map(row => row.name));
|
|
const [indexes] = await im.query('SHOW INDEX FROM im_user');
|
|
const usernameIndexes = indexes.filter(row => Number(row.Non_unique) === 0 && row.Column_name === 'user_name' && row.Sub_part == null);
|
|
const hasUniqueUsername = usernameIndexes.some(index => indexes.filter(row => row.Key_name === index.Key_name).length === 1);
|
|
if (!hasUniqueUsername) throw new Error('IM 用户表须具备完整 user_name 唯一索引');
|
|
const username = columns.find(row => row.name === 'user_name');
|
|
if (Number(username?.maximum) < 20) throw new Error('IM 用户名字段长度须至少为20字符');
|
|
const [tables] = await business.query('SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?', ['member_chat_sync']);
|
|
const [sequences] = await business.query('SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?', ['member_chat_sync_sequence']);
|
|
for (const [connection, tables] of [[business, ['member_user', 'member_chat_sync', 'member_chat_sync_sequence']],
|
|
[im, ['im_user', 'im_friend', 'im_group_member']]]) {
|
|
const definitions = await rows(connection, 'SELECT TABLE_NAME AS name,ENGINE AS engine FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN (?)', [tables]);
|
|
for (const table of definitions) {
|
|
if (table.engine !== 'InnoDB') throw new Error(`表 ${table.name} 必须使用 InnoDB 才能保证修复事务完整性`);
|
|
}
|
|
}
|
|
const capacities = await rows(im, 'SELECT TABLE_NAME AS tableName,COLUMN_NAME AS columnName,CHARACTER_MAXIMUM_LENGTH AS maximum FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN (?)',
|
|
[['im_user', 'im_friend', 'im_group_member']]);
|
|
return { imColumns: [...names], usernameCollation: username.collation,
|
|
missingImColumns: NEW_COLUMNS.filter(name => !names.has(name)), taskTableExists: tables.length === 1,
|
|
sequenceTableExists: sequences.length === 1, capacities };
|
|
}
|
|
|
|
async function validateTargets(im, schema, plan) {
|
|
const tables = { im_user: plan.userChanges, im_friend: plan.friendChanges, im_group_member: plan.groupMemberChanges };
|
|
for (const definition of schema.capacities) {
|
|
if (definition.maximum == null) continue;
|
|
for (const row of tables[definition.tableName]) {
|
|
const value = row[definition.columnName];
|
|
if (value != null && Array.from(String(value)).length > Number(definition.maximum)) {
|
|
throw new Error(`表 ${definition.tableName} 编号 ${row.id} 的 ${definition.columnName} 超出字段长度 ${definition.maximum}`);
|
|
}
|
|
}
|
|
}
|
|
const temporary = plan.userChanges.map(user => temporaryName(user.id, plan.runId));
|
|
const finalNames = [...new Set([...plan.userChanges.map(user => user.user_name), ...plan.taskChanges.map(task => `u${task.member_id}`)])];
|
|
for (const [names, condition] of [[temporary, ''], [finalNames, 'id < 0 AND ']]) {
|
|
for (let offset = 0; offset < names.length; offset += 500) {
|
|
const conflicts = await rows(im, `SELECT id,user_name FROM im_user WHERE ${condition}user_name IN (?)`, [names.slice(offset, offset + 500)]);
|
|
if (conflicts.length) throw new Error(`数据库排序规则下存在保留用户名或临时用户名占用 ${JSON.stringify(conflicts)}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function readMembers(connection, lock = false) {
|
|
return rows(connection, 'SELECT id,tenant_id,nickname,avatar,mobile,status,deleted FROM member_user ORDER BY id' + (lock ? ' FOR UPDATE' : ''));
|
|
}
|
|
|
|
async function readTasks(connection, exists, lock = false) {
|
|
if (!exists) return [];
|
|
return rows(connection, `SELECT member_id,${TASK_COLUMNS.join(',')},deleted FROM member_chat_sync ORDER BY member_id` + (lock ? ' FOR UPDATE' : ''));
|
|
}
|
|
|
|
async function readIm(connection, schema, lock = false) {
|
|
const defaults = NEW_COLUMNS.map(name => schema.imColumns.includes(name) ? name : `0 AS ${name}`);
|
|
const suffix = lock ? ' FOR UPDATE' : '';
|
|
const users = await rows(connection, 'SELECT id,user_name,nick_name,head_image,head_image_thumb,is_banned,'
|
|
+ defaults.join(',') + ',SHA2(password,256) AS password_hash FROM im_user ORDER BY id' + suffix);
|
|
const friends = await rows(connection, 'SELECT id,user_id,friend_id,friend_nick_name,friend_head_image FROM im_friend ORDER BY id' + suffix);
|
|
const groupMembers = await rows(connection, 'SELECT id,group_id,user_id,user_nick_name,head_image,remark_nick_name,remark_group_name,quit FROM im_group_member ORDER BY id' + suffix);
|
|
const groups = await rows(connection, 'SELECT id,owner_id,dissolve FROM im_group ORDER BY id' + suffix);
|
|
const messages = {};
|
|
for (const table of ['im_private_message', 'im_group_message']) {
|
|
const receiver = table === 'im_private_message' ? ',MAX(recv_id) AS maximumReceiver' : '';
|
|
const [count] = await rows(connection, `SELECT COUNT(*) AS count,MAX(id) AS maximumId,MAX(send_id) AS maximumSender${receiver} FROM ${table}`);
|
|
messages[table] = { count: String(count.count), maximumId: count.maximumId == null ? null : String(count.maximumId),
|
|
maximumSender: count.maximumSender == null ? null : String(count.maximumSender),
|
|
maximumReceiver: count.maximumReceiver == null ? null : String(count.maximumReceiver) };
|
|
}
|
|
return { users, friends, groupMembers, groups, messages };
|
|
}
|
|
|
|
async function readSnapshot(business, im, schema) {
|
|
return { members: await readMembers(business), tasks: await readTasks(business, schema.taskTableExists),
|
|
...await readIm(im, schema) };
|
|
}
|
|
|
|
async function updateRows(connection, table, columns, changes) {
|
|
for (let offset = 0; offset < changes.length; offset += 100) {
|
|
const batch = changes.slice(offset, offset + 100);
|
|
const parameters = [];
|
|
const assignments = columns.map(column => {
|
|
const cases = batch.map(row => {
|
|
parameters.push(row.id, row[column]);
|
|
return 'WHEN ? THEN ?';
|
|
});
|
|
return `\`${column}\` = CASE id ${cases.join(' ')} ELSE \`${column}\` END`;
|
|
});
|
|
parameters.push(...batch.map(row => row.id));
|
|
const placeholders = batch.map(() => '?').join(',');
|
|
await connection.query(`UPDATE ${table} SET ${assignments.join(',')} WHERE id IN (${placeholders})`, parameters);
|
|
}
|
|
}
|
|
|
|
async function renameUsers(connection, users, runId) {
|
|
await updateRows(connection, 'im_user', ['user_name'], users.map(user => ({
|
|
id: user.id, user_name: temporaryName(user.id, runId),
|
|
})));
|
|
await updateRows(connection, 'im_user', USER_COLUMNS, users);
|
|
}
|
|
|
|
async function seedTasks(connection, changes) {
|
|
for (const task of changes) {
|
|
const columns = ['member_id', ...TASK_COLUMNS];
|
|
const values = columns.map(column => task[column]);
|
|
const assignments = TASK_COLUMNS.map(column => `${column} = VALUES(${column})`);
|
|
await connection.query(`INSERT INTO member_chat_sync (${columns.join(',')},next_retry_time,deleted)
|
|
VALUES (${columns.map(() => '?').join(',')},CURRENT_TIMESTAMP,b'0')
|
|
ON DUPLICATE KEY UPDATE ${assignments.join(',')},next_retry_time=CURRENT_TIMESTAMP,deleted=b'0'`, values);
|
|
}
|
|
}
|
|
|
|
async function requireSchemas(schema) {
|
|
if (schema.missingImColumns.length || !schema.taskTableExists || !schema.sequenceTableExists) {
|
|
throw new Error('请先在维护窗口执行两侧结构迁移,再重新生成清单');
|
|
}
|
|
}
|
|
|
|
async function executeApply(context, plan, journal, saveJournal) {
|
|
const { business, im, schema } = context;
|
|
await requireSchemas(schema);
|
|
if (journal.phase === 'rolledBack') throw new Error('该批次已回滚,请重新生成修复清单');
|
|
if (journal.phase === 'complete') return;
|
|
assertSame(await readMembers(business), plan.before.members, '业务会员资料');
|
|
const current = await readIm(im, schema);
|
|
const after = expectedIm(plan);
|
|
if (fingerprint(current) !== fingerprint(after)) {
|
|
assertSame(current, imPart(plan.before), 'IM 原始资料');
|
|
journal.phase = 'imApplying';
|
|
await saveJournal();
|
|
await im.beginTransaction();
|
|
try {
|
|
assertSame(await readIm(im, schema, true), imPart(plan.before), 'IM 锁定资料');
|
|
await renameUsers(im, plan.userChanges, plan.runId);
|
|
await updateRows(im, 'im_friend', FRIEND_COLUMNS, plan.friendChanges);
|
|
await updateRows(im, 'im_group_member', GROUP_MEMBER_COLUMNS, plan.groupMemberChanges);
|
|
assertSame(await readIm(im, schema), after, 'IM 修复结果');
|
|
await im.commit();
|
|
} catch (error) {
|
|
await im.rollback();
|
|
throw error;
|
|
}
|
|
}
|
|
journal.phase = 'imCommitted';
|
|
await saveJournal();
|
|
await business.beginTransaction();
|
|
try {
|
|
assertSame(await readMembers(business, true), plan.before.members, '业务锁定资料');
|
|
const existing = await readTasks(business, true, true);
|
|
const desired = plan.taskChanges.map(task => ({ ...task, deleted: false }));
|
|
const expectedTasks = new Map(plan.before.tasks.map(task => [task.member_id, task]));
|
|
for (const task of desired) expectedTasks.set(task.member_id, task);
|
|
const expected = [...expectedTasks.values()].sort((left, right) => BigInt(left.member_id) < BigInt(right.member_id) ? -1 : 1);
|
|
if (fingerprint(existing) !== fingerprint(expected)) {
|
|
assertSame(existing, plan.before.tasks, '业务同步任务');
|
|
await seedTasks(business, plan.taskChanges);
|
|
}
|
|
await business.query('UPDATE member_chat_sync_sequence SET last_id = GREATEST(last_id, ?) WHERE id = 1',
|
|
[(BigInt(plan.nextMemberId) - 1n).toString()]);
|
|
await business.commit();
|
|
} catch (error) {
|
|
await business.rollback();
|
|
throw error;
|
|
}
|
|
journal.phase = 'businessCommitted';
|
|
await saveJournal();
|
|
// AUTO_INCREMENT is DDL and is deliberately a separately resumable phase.
|
|
const [definition] = await rows(business, 'SHOW CREATE TABLE member_user');
|
|
const nextId = definition['Create Table'].match(/\bAUTO_INCREMENT=(\d+)/)?.[1] ?? '1';
|
|
if (BigInt(nextId) < BigInt(plan.nextMemberId)) {
|
|
await business.query(`ALTER TABLE member_user AUTO_INCREMENT = ${plan.nextMemberId}`);
|
|
}
|
|
journal.phase = 'complete';
|
|
await saveJournal();
|
|
}
|
|
|
|
async function executeRollback(context, plan, journal, saveJournal) {
|
|
const { business, im, schema } = context;
|
|
await requireSchemas(schema);
|
|
if (journal.phase === 'rolledBack') return;
|
|
if (!['imApplying', 'imCommitted', 'businessCommitted', 'complete', 'rollingBack', 'imRolledBack'].includes(journal.phase)) {
|
|
throw new Error('该批次尚未开始执行');
|
|
}
|
|
assertSame(await readMembers(business), plan.before.members, '业务会员资料');
|
|
if (journal.phase !== 'imRolledBack') {
|
|
await im.beginTransaction();
|
|
try {
|
|
const current = await readIm(im, schema, true);
|
|
if (journal.rollbackUsers && fingerprint(current.users) === fingerprint(journal.rollbackUsers)) {
|
|
// A preceding commit may have succeeded before its acknowledgement was written.
|
|
} else {
|
|
const changes = rollbackChanges(plan, current);
|
|
journal.phase = 'rollingBack';
|
|
const byId = new Map(changes.users.map(user => [String(user.id), user]));
|
|
journal.rollbackUsers = current.users.map(user => byId.get(String(user.id)) ?? user);
|
|
await saveJournal();
|
|
await renameUsers(im, changes.users, randomUUID().replaceAll('-', ''));
|
|
await updateRows(im, 'im_friend', FRIEND_COLUMNS, changes.friends);
|
|
await updateRows(im, 'im_group_member', GROUP_MEMBER_COLUMNS, changes.groupMembers);
|
|
}
|
|
await im.commit();
|
|
} catch (error) {
|
|
await im.rollback();
|
|
throw error;
|
|
}
|
|
journal.phase = 'imRolledBack';
|
|
await saveJournal();
|
|
}
|
|
await business.beginTransaction();
|
|
try {
|
|
// Keep versions monotonic while restoring the pre-repair desired profiles.
|
|
const currentTasks = new Map((await readTasks(business, true, true)).map(task => [task.member_id, task]));
|
|
const restored = [];
|
|
const previousTasks = new Map(plan.before.tasks.map(task => [task.member_id, task]));
|
|
const users = new Map(journal.rollbackUsers.map(user => [String(user.id), user]));
|
|
for (const task of plan.taskChanges) {
|
|
const current = currentTasks.get(task.member_id);
|
|
if (current && current.version !== task.version && current.version !== previousTasks.get(task.member_id)?.version
|
|
&& current.version !== journal.restoredVersions?.[task.member_id]) {
|
|
throw new Error(`会员 ${task.member_id} 的同步任务已变化,停止回滚`);
|
|
}
|
|
const base = BigInt(users.get(task.member_id)?.sync_version ?? task.version);
|
|
const version = journal.restoredVersions?.[task.member_id] ?? (base + 1n).toString();
|
|
restored.push({ ...task, member_id: task.member_id, version, synced_version: '0', retry_count: 0, last_error: '' });
|
|
}
|
|
journal.restoredVersions = Object.fromEntries(restored.map(task => [task.member_id, task.version]));
|
|
await saveJournal();
|
|
await seedTasks(business, restored);
|
|
await business.commit();
|
|
} catch (error) {
|
|
await business.rollback();
|
|
throw error;
|
|
}
|
|
journal.phase = 'rolledBack';
|
|
await saveJournal();
|
|
}
|
|
|
|
async function verify(context, plan, ready) {
|
|
const { business, im, schema } = context;
|
|
await requireSchemas(schema);
|
|
const live = await readIm(im, schema);
|
|
const users = new Map(live.users.map(user => [user.id, user]));
|
|
const tasks = new Map((await readTasks(business, true)).map(task => [task.member_id, task]));
|
|
assertSame(await readMembers(business), plan.before.members, '业务会员资料');
|
|
for (const expected of plan.userChanges) {
|
|
const actual = users.get(expected.id);
|
|
if (!actual) throw new Error(`IM 会员 ${expected.id} 缺失`);
|
|
const original = plan.before.users.find(user => user.id === expected.id);
|
|
assertSame(actual.password_hash, original.password_hash, `会员 ${expected.id} 的原密码校验值`);
|
|
for (const column of USER_COLUMNS) {
|
|
if (column === 'auth_version') {
|
|
if (BigInt(actual[column]) < BigInt(expected[column])) throw new Error('登录撤销版本发生倒退');
|
|
} else {
|
|
assertSame(actual[column], expected[column], `会员 ${expected.id} 的 ${column}`);
|
|
}
|
|
}
|
|
}
|
|
let pending = 0;
|
|
let pendingInitialization = 0;
|
|
for (const expected of plan.taskChanges) {
|
|
const task = tasks.get(expected.member_id);
|
|
if (!task || task.version !== expected.version) throw new Error(`会员 ${expected.member_id} 的任务版本不匹配`);
|
|
if (BigInt(task.synced_version) < BigInt(task.version)) pending++;
|
|
if (task.member_state === 0 && !task.initialized) pendingInitialization++;
|
|
if (ready && !users.has(expected.member_id)) throw new Error(`会员 ${expected.member_id} 尚未完成 IM 补建`);
|
|
if (ready) {
|
|
const desired = JSON.parse(expected.profile_json);
|
|
const user = users.get(expected.member_id);
|
|
assertSame(user.user_name, desired.userName, `会员 ${expected.member_id} 的用户名`);
|
|
assertSame(user.member_state, desired.memberState, `会员 ${expected.member_id} 的可用状态`);
|
|
assertSame(user.sync_version, expected.version, `会员 ${expected.member_id} 的应用版本`);
|
|
}
|
|
}
|
|
const expected = expectedIm(plan);
|
|
const liveFriends = new Map(live.friends.map(row => [row.id, row]));
|
|
const liveGroupMembers = new Map(live.groupMembers.map(row => [row.id, row]));
|
|
for (const friend of expected.friends) assertSame(liveFriends.get(friend.id), friend, `好友关系 ${friend.id}`);
|
|
for (const member of expected.groupMembers) assertSame(liveGroupMembers.get(member.id), member, `群成员关系 ${member.id}`);
|
|
assertSame(live.groups, plan.before.groups, '群关系');
|
|
if (!ready) assertSame(live.messages, plan.before.messages, '消息数量');
|
|
const [counter] = await rows(business, 'SELECT last_id FROM member_chat_sync_sequence WHERE id = 1');
|
|
if (BigInt(counter?.last_id ?? 0) < BigInt(plan.nextMemberId) - 1n) throw new Error('业务持久化编号水位未提升至安全起点');
|
|
if (ready && (pending || pendingInitialization)) throw new Error(`仍有 ${pending} 个待同步账号、${pendingInitialization} 个待初始化账号`);
|
|
if (ready) {
|
|
for (const [table, before] of Object.entries(plan.before.messages)) {
|
|
if (BigInt(live.messages[table].count) < BigInt(before.count)) throw new Error(`表 ${table} 的消息数量减少`);
|
|
}
|
|
for (const id of ['-100', '-200']) {
|
|
const user = users.get(id);
|
|
if (!user || user.member_state !== 0 || user.is_banned) throw new Error(`系统账号 ${id} 尚未正常初始化`);
|
|
}
|
|
}
|
|
return { ...summary(plan), verified: true, pending, pendingInitialization, ready: Boolean(ready) };
|
|
}
|
|
|
|
export async function main(argv = process.argv.slice(2)) {
|
|
const { command, options } = argumentsFor(argv);
|
|
const config = await loadJson(resolve(options.config));
|
|
const business = await connect(config.business);
|
|
let im;
|
|
try {
|
|
im = await connect(config.im);
|
|
const identities = { business: await identity(business), im: await identity(im) };
|
|
const schema = await inspectSchema(business, im);
|
|
const context = { business, im, schema };
|
|
if (command === 'plan' || command === 'verify') {
|
|
await business.query('SET SESSION TRANSACTION READ ONLY');
|
|
await im.query('SET SESSION TRANSACTION READ ONLY');
|
|
await business.query('START TRANSACTION READ ONLY');
|
|
await im.query('START TRANSACTION READ ONLY');
|
|
}
|
|
if (command === 'plan') {
|
|
const before = await readSnapshot(business, im, schema);
|
|
const plan = createPlan(before, identities, randomUUID().replaceAll('-', ''));
|
|
await validateTargets(im, schema, plan);
|
|
const path = resolve(options.out ?? `runs/${plan.runId}.plan.json`);
|
|
await atomicJson(path, plan);
|
|
const report = { ...summary(plan), createdAt: plan.createdAt,
|
|
schemaReady: !schema.missingImColumns.length && schema.taskTableExists && schema.sequenceTableExists,
|
|
missingBusinessTables: [!schema.taskTableExists ? 'member_chat_sync' : null,
|
|
!schema.sequenceTableExists ? 'member_chat_sync_sequence' : null].filter(Boolean),
|
|
missingImColumns: schema.missingImColumns, usernameCollation: schema.usernameCollation, plan: path };
|
|
await atomicJson(`${path}.summary.json`, report);
|
|
console.log(JSON.stringify(report, null, 2));
|
|
return;
|
|
}
|
|
const path = resolve(options.plan);
|
|
const plan = await loadJson(path);
|
|
validatePlan(plan);
|
|
assertSame(identities, plan.identities, '数据库身份');
|
|
if (command === 'verify') {
|
|
const report = await verify(context, plan, options.ready);
|
|
await atomicJson(`${path}.verification.json`, report);
|
|
console.log(JSON.stringify(report, null, 2));
|
|
return;
|
|
}
|
|
const journalPath = `${path}.journal.json`;
|
|
const journal = await loadJson(journalPath, { runId: plan.runId, planHash: plan.hash, phase: 'created' });
|
|
if (journal.planHash !== plan.hash) throw new Error('执行记录与清单不匹配');
|
|
const saveJournal = () => atomicJson(journalPath, { ...journal, updatedAt: new Date().toISOString() });
|
|
if (command === 'apply') await executeApply(context, plan, journal, saveJournal);
|
|
if (command === 'rollback') await executeRollback(context, plan, journal, saveJournal);
|
|
console.log(JSON.stringify({ ...summary(plan), phase: journal.phase, journal: journalPath }, null, 2));
|
|
} finally {
|
|
await business.rollback().catch(() => {});
|
|
await im?.rollback().catch(() => {});
|
|
await business.end();
|
|
await im?.end();
|
|
}
|
|
}
|
|
|
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
main().catch(error => {
|
|
console.error(error.message);
|
|
process.exitCode = 1;
|
|
});
|
|
}
|