feat(M01-B): 增加迁移执行器与旧库只读兼容层
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { MySqlPool } from './mysql.js';
|
||||
|
||||
export type MigrationDirection = 'up' | 'verify' | 'down';
|
||||
|
||||
export interface MigrationPlan {
|
||||
direction: MigrationDirection;
|
||||
file: string;
|
||||
checksum: string;
|
||||
statements: readonly string[];
|
||||
}
|
||||
|
||||
export interface MigrationExecutionResult extends MigrationPlan {
|
||||
executed: boolean;
|
||||
affectedRows: number;
|
||||
}
|
||||
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||
const migrationFiles: Record<MigrationDirection, string> = {
|
||||
up: 'database/migrations/2026061601_m01b_core_schema.up.sql',
|
||||
verify: 'database/migrations/2026061601_m01b_core_schema.verify.sql',
|
||||
down: 'database/migrations/2026061601_m01b_core_schema.down.sql'
|
||||
};
|
||||
|
||||
export function splitSqlStatements(sql: string): string[] {
|
||||
const statements: string[] = [];
|
||||
let current = '';
|
||||
let quote: "'" | '"' | '`' | null = null;
|
||||
let escaped = false;
|
||||
let lineComment = false;
|
||||
let blockComment = false;
|
||||
|
||||
for (let index = 0; index < sql.length; index += 1) {
|
||||
const character = sql[index];
|
||||
const next = sql[index + 1];
|
||||
|
||||
if (lineComment) {
|
||||
if (character === '\n') {
|
||||
lineComment = false;
|
||||
current += character;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (blockComment) {
|
||||
if (character === '*' && next === '/') {
|
||||
blockComment = false;
|
||||
index += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!quote && character === '-' && next === '-' && (index === 0 || /\s/.test(sql[index - 1]))) {
|
||||
lineComment = true;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!quote && character === '/' && next === '*') {
|
||||
blockComment = true;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
current += character;
|
||||
|
||||
if (quote) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
} else if (character === '\\') {
|
||||
escaped = true;
|
||||
} else if (character === quote) {
|
||||
if (next === quote) {
|
||||
current += next;
|
||||
index += 1;
|
||||
} else {
|
||||
quote = null;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === "'" || character === '"' || character === '`') {
|
||||
quote = character;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === ';') {
|
||||
const statement = current.slice(0, -1).trim();
|
||||
if (statement) {
|
||||
statements.push(statement);
|
||||
}
|
||||
current = '';
|
||||
}
|
||||
}
|
||||
|
||||
const trailing = current.trim();
|
||||
if (trailing) {
|
||||
statements.push(trailing);
|
||||
}
|
||||
|
||||
if (quote || blockComment) {
|
||||
throw new Error('Migration SQL contains an unterminated quote or comment.');
|
||||
}
|
||||
|
||||
return statements;
|
||||
}
|
||||
|
||||
export async function loadMigrationPlan(direction: MigrationDirection): Promise<MigrationPlan> {
|
||||
const relativeFile = migrationFiles[direction];
|
||||
const sql = await readFile(resolve(repoRoot, relativeFile), 'utf8');
|
||||
|
||||
return {
|
||||
direction,
|
||||
file: relativeFile,
|
||||
checksum: createHash('sha256').update(sql).digest('hex'),
|
||||
statements: splitSqlStatements(sql)
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeMigrationPlan(
|
||||
pool: Pick<MySqlPool, 'query'>,
|
||||
plan: MigrationPlan,
|
||||
dryRun = false
|
||||
): Promise<MigrationExecutionResult> {
|
||||
if (dryRun) {
|
||||
return { ...plan, executed: false, affectedRows: 0 };
|
||||
}
|
||||
|
||||
let affectedRows = 0;
|
||||
for (const [index, statement] of plan.statements.entries()) {
|
||||
const [result] = await pool.query(statement);
|
||||
if (plan.direction === 'verify') {
|
||||
const minimumRows = [10, 26, 1][index] ?? 1;
|
||||
if (!Array.isArray(result) || result.length < minimumRows) {
|
||||
throw new Error(
|
||||
`Migration verification statement ${index + 1} returned fewer than ${minimumRows} rows.`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (result && typeof result === 'object' && 'affectedRows' in result) {
|
||||
const value = Reflect.get(result, 'affectedRows');
|
||||
if (typeof value === 'number') {
|
||||
affectedRows += value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { ...plan, executed: true, affectedRows };
|
||||
}
|
||||
Reference in New Issue
Block a user