Files
a-share-analysis/tools/database/schema-baseline.mjs
T

988 lines
36 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { execFileSync } from 'node:child_process';
import {
existsSync,
mkdirSync,
readFileSync,
readdirSync,
renameSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { createHash } from 'node:crypto';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
MYSQL2_EXPORTER_METADATA,
assertMysql57ServerMetadata as assertMysql57ServerMetadataV3,
exportMysql57Schema,
} from './mysql2-schema-exporter.mjs';
const DEVELOPMENT_DATABASE = 'a_share_analysis_dev';
const REQUIRED_CHARSET = 'utf8mb4';
const REQUIRED_COLLATION = 'utf8mb4_general_ci';
const BASELINE_FORMAT_VERSION = 3;
const PREVIOUS_BASELINE_FORMAT_VERSION = 2;
const LEGACY_BASELINE_FORMAT_VERSION = 1;
const TOOL_VERSION = '3.0.0';
const PREVIOUS_TOOL_VERSION = '2.0.0';
const LEGACY_TOOL_VERSION = '1.0.0';
const VERSION_PATTERN = /^v\d+\.\d+\.\d+$/;
const DEVELOPMENT_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '112.192.20.11']);
const OBJECT_KEYS = Object.freeze(['tables', 'views', 'triggers', 'routines', 'events']);
const CAPTURED_FILES = Object.freeze(['checksum.sha256', 'manifest.yaml', 'schema.sql']);
const CAPTURED_EMPTY_FILES = Object.freeze(['manifest.yaml']);
const NOT_APPLICABLE_FILES = Object.freeze(['manifest.yaml']);
function requireExactValue(actual, expected, message) {
if (actual !== expected) {
throw new Error(`${message},要求 ${expected},实际 ${actual || '(空)'}`);
}
}
function isEnabled(value) {
return value === true || value === 'true';
}
/**
* Schema 工具拥有比普通应用启动更窄的权限边界:环境名、数据库名和主机必须
* 同时命中代码内固定值。远程开发库不能通过 env 或命令参数动态扩展白名单,
* 避免把一次临时连接授权意外变成生产数据库访问能力。
*/
export function assertSafeDevelopmentConfiguration(config) {
requireExactValue(config.APP_ENV, 'development', 'Schema 基线只允许 development 环境');
requireExactValue(
config.MYSQL_DATABASE,
DEVELOPMENT_DATABASE,
'Schema 基线只允许固定开发库 a_share_analysis_dev',
);
requireExactValue(config.MYSQL_CHARSET, REQUIRED_CHARSET, '数据库字符集不符合要求');
requireExactValue(
config.MYSQL_COLLATION,
REQUIRED_COLLATION,
'数据库排序规则不符合要求',
);
if (isEnabled(config.MYSQL_ENABLED)) {
if (!DEVELOPMENT_HOSTS.has(config.MYSQL_HOST)) {
throw new Error(
`Schema 基线只允许固定开发数据库主机白名单,实际 ${config.MYSQL_HOST || '(空)'}`,
);
}
for (const key of ['MYSQL_HOST', 'MYSQL_PORT', 'MYSQL_USERNAME', 'MYSQL_PASSWORD']) {
if (!String(config[key] ?? '').trim()) {
throw new Error(`MySQL 已启用,${key} 不能为空`);
}
}
} else if (config.MYSQL_ENABLED !== false && config.MYSQL_ENABLED !== 'false') {
throw new Error('MYSQL_ENABLED 必须显式为 true 或 false');
}
return Object.freeze({ ...config, MYSQL_ENABLED: isEnabled(config.MYSQL_ENABLED) });
}
/**
* mysql、mysqldump 与服务端统一接受完整 5.7.x;字符集和排序规则仍按精确值
* 校验,避免放宽补丁版本时意外放宽数据库结构兼容边界。
*/
export function assertMysql57Metadata(metadata) {
if (!/^5\.7\.\d+(?:[-+].*)?$/.test(metadata.mysqlClientVersion ?? '')) {
throw new Error(`mysql 客户端必须为 5.7.x,实际 ${metadata.mysqlClientVersion || '(空)'}`);
}
if (!/^5\.7\.\d+(?:[-+].*)?$/.test(metadata.mysqldumpClientVersion ?? '')) {
throw new Error(
`mysqldump 客户端必须为 5.7.x,实际 ${metadata.mysqldumpClientVersion || '(空)'}`,
);
}
if (!/^5\.7\.\d+(?:[-+].*)?$/.test(metadata.serverVersion ?? '')) {
throw new Error(`MySQL 服务端必须为 5.7.x,实际 ${metadata.serverVersion || '(空)'}`);
}
requireExactValue(metadata.databaseCharset, REQUIRED_CHARSET, '开发数据库字符集不符合要求');
requireExactValue(
metadata.databaseCollation,
REQUIRED_COLLATION,
'开发数据库排序规则不符合要求',
);
return metadata;
}
export function buildMysqldumpArguments(config) {
assertSafeDevelopmentConfiguration(config);
return [
`--host=${config.MYSQL_HOST}`,
`--port=${config.MYSQL_PORT}`,
`--user=${config.MYSQL_USERNAME}`,
`--default-character-set=${REQUIRED_CHARSET}`,
'--no-data',
'--routines',
'--triggers',
'--events',
'--single-transaction',
'--skip-lock-tables',
'--skip-comments',
DEVELOPMENT_DATABASE,
];
}
function buildMysqlMetadataArguments(config) {
return [
`--host=${config.MYSQL_HOST}`,
`--port=${config.MYSQL_PORT}`,
`--user=${config.MYSQL_USERNAME}`,
`--default-character-set=${REQUIRED_CHARSET}`,
'--batch',
'--skip-column-names',
DEVELOPMENT_DATABASE,
'--execute=SELECT VERSION(), DEFAULT_CHARACTER_SET_NAME, DEFAULT_COLLATION_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = DATABASE()',
];
}
function assertSchemaHasNoSensitiveOrDataStatements(sql) {
// 安全扫描只忽略 SQL 字符串字面量,标识符反引号仍参与检查。否则存储过程
// 合法返回“DEFINER=”等说明文字时会被误判,而真正的对象 DEFINER 仍可检出。
const sqlWithoutStringLiterals = sql.replace(/'(?:''|\\.|[^'\\])*'/gs, "''");
const prohibitedPatterns = [
[sqlWithoutStringLiterals, /\bDEFINER\s*=/i, 'DEFINER'],
[sql, /^--\s*MySQL dump\b/im, 'MySQL dump 头'],
[sql, /--\s*Dump completed on/i, '导出完成时间'],
[sql, /--\s*Host:/i, '连接主机注释'],
[sql, /\b(?:password|passwd|pwd|api[_-]?key|api[_-]?token|access[_-]?token|client[_-]?secret|secret)\s*[:=]/i, '疑似凭据'],
[sql, /\b(?:mysql|mariadb):\/\/[^\s'"`]+/i, '数据库连接串'],
[sql, /\b(?:production|prod)[-_\s]*(?:host|db|database)?\s*[:=]\s*[^\s'"`]+/i, '疑似生产主机'],
];
for (const [content, pattern, label] of prohibitedPatterns) {
if (pattern.test(content)) {
throw new Error(`Schema SQL 包含禁止内容:${label}`);
}
}
for (const statement of splitSqlStatements(sql)) {
let executableSql = statement
.replace(
/^(?:\s*--[^\n]*(?:\n|$)|\s*#[^\n]*(?:\n|$)|\s*\/\*(?!\!)[\s\S]*?\*\/\s*)+/g,
'',
)
.trim();
const candidates = [executableSql];
const executableComment = executableSql.match(/^\/\*!\d{5}\s+([\s\S]*?)\*\/\s*/);
if (executableComment) {
candidates.push(
executableComment[1].trim(),
executableSql.slice(executableComment[0].length).trim(),
);
}
const dataStatement = candidates.find((candidate) =>
/^(?:INSERT\s+INTO|REPLACE\s+INTO|UPDATE\b|DELETE\s+FROM\b|TRUNCATE(?:\s+TABLE)?\b|LOAD\s+(?:DATA|XML)\b)/i.test(
candidate,
),
);
if (dataStatement) {
const operation = dataStatement.match(
/^(?:INSERT\s+INTO|REPLACE\s+INTO|UPDATE|DELETE\s+FROM|TRUNCATE(?:\s+TABLE)?|LOAD\s+(?:DATA|XML))/i,
)[0];
throw new Error(
`Schema SQL 包含禁止的数据操作:${operation}`,
);
}
}
}
function findDelimiterOutsideStrings(content, delimiter) {
let quote = null;
let escaped = false;
for (let index = 0; index <= content.length - delimiter.length; index += 1) {
const character = content[index];
if (quote) {
if (escaped) {
escaped = false;
} else if (character === '\\') {
escaped = true;
} else if (character === quote) {
if (content[index + 1] === quote && quote !== '`') {
index += 1;
} else {
quote = null;
}
}
continue;
}
if (character === "'" || character === '"' || character === '`') {
quote = character;
continue;
}
if (content.startsWith(delimiter, index)) return index;
}
return -1;
}
/**
* mysqldump 用 DELIMITER 包住例程、触发器和事件。按真实分隔符切分后,
* 只有顶层以 INSERT/REPLACE 开头的语句代表数据;对象体内部 DML 必须保留。
*/
function splitSqlStatements(sql) {
const statements = [];
let delimiter = ';';
let buffer = '';
for (const line of String(sql).split(/(?<=\n)/)) {
const directive = line.trim().match(/^DELIMITER\s+(\S+)$/i);
if (directive) {
if (buffer.trim()) statements.push(buffer.trim());
buffer = '';
delimiter = directive[1];
continue;
}
buffer += line;
let delimiterIndex;
while ((delimiterIndex = findDelimiterOutsideStrings(buffer, delimiter)) >= 0) {
const statement = buffer.slice(0, delimiterIndex).trim();
if (statement) statements.push(statement);
buffer = buffer.slice(delimiterIndex + delimiter.length);
}
}
if (buffer.trim()) statements.push(buffer.trim());
return statements;
}
/**
* 规范化只删除 mysqldump 的环境噪声,不重排或重新格式化业务定义。
* 表选项中的当前 AUTO_INCREMENT 值会随数据增长变化;列定义的自增属性不匹配
* “等号加数字”的模式,因此会原样保留。
*/
export function normalizeSchemaSql(rawSql) {
const normalized = String(rawSql)
.replace(/\r\n?/g, '\n')
.replace(/^-- MySQL dump .*\n/gm, '')
.replace(/^-- Host:.*\n/gm, '')
.replace(/^-- -+\n/gm, '')
.replace(/^--\s*\n/gm, '')
.replace(/^-- Server version.*\n/gm, '')
.replace(/^-- Dump completed on .*\n?/gm, '')
.replace(/\/\*!\d{5}\s+DEFINER=`[^`]+`@`[^`]+`\*\//g, '')
.replace(
/^(\/\*!\d{5}\s+)DEFINER=`[^`]+`@`[^`]+`\s+(SQL SECURITY\s+(?:DEFINER|INVOKER)\s+\*\/)$/gm,
'$1$2',
)
// 普通视图的 DEFINER 只允许出现在版本注释外、语句行首的 CREATE 前缀中;
// 限定在行首可避免误删例程字符串或注释里的业务文本。
.replace(/^(\/\*!\d{5}\s+CREATE\s+[^\n]*?)\bDEFINER=`[^`]+`@`[^`]+`\s*/gm, '$1')
.replace(/^(\) ENGINE=[^\n;]*?) AUTO_INCREMENT=\d+(?= )/gm, '$1')
.replace(/^\s+/, '')
.replace(/\s+$/, '');
const result = normalized.length > 0 ? `${normalized}\n` : '';
assertSchemaHasNoSensitiveOrDataStatements(result);
return result;
}
function sha256(content) {
return createHash('sha256').update(content, 'utf8').digest('hex');
}
function countSchemaObjects(sql) {
const definitions = String(sql);
return Object.freeze({
tables: (definitions.match(/\bCREATE\s+TABLE\b/gi) ?? []).length,
views: (definitions.match(/\b(?:CREATE\s+)?VIEW\s+`/gi) ?? []).length,
triggers: (definitions.match(/\bTRIGGER\s+`/gi) ?? []).length,
routines: (definitions.match(/\b(?:PROCEDURE|FUNCTION)\s+`/gi) ?? []).length,
events: (definitions.match(/\bEVENT\s+`/gi) ?? []).length,
});
}
function buildManifest(fields) {
// JSON 是 YAML 1.2 的严格子集;使用标准解析器生成和读取,避免手写 YAML
// 对缩进、引号和同名嵌套字段产生歧义,同时仍保留约定的 manifest.yaml 文件名。
const common = {
format_version: fields.formatVersion,
status: fields.status,
application_version: fields.applicationVersion,
git_commit: fields.gitCommit,
source_environment: 'development',
database_name: DEVELOPMENT_DATABASE,
charset: REQUIRED_CHARSET,
collation: REQUIRED_COLLATION,
generated_at: fields.generatedAt,
generator: `schema-baseline/${fields.formatVersion === 3 ? TOOL_VERSION : fields.formatVersion === 2 ? PREVIOUS_TOOL_VERSION : LEGACY_TOOL_VERSION}`,
reason: fields.reason ?? 'none',
schema_sha256: fields.schemaSha256 ?? 'not_applicable',
objects: {
tables: fields.captured,
views: fields.captured,
triggers: fields.captured,
routines: fields.captured,
events: fields.captured,
},
object_counts: fields.objectCounts,
};
if (fields.formatVersion === 3) {
common.exporter_name = fields.exporterName;
common.exporter_version = fields.exporterVersion;
common.database_driver = fields.driverName;
common.database_driver_version = fields.driverVersion;
common.mysql_server_version = fields.serverVersion;
} else {
common.mysql_client_version = fields.mysqlClientVersion;
common.mysqldump_client_version = fields.mysqldumpClientVersion;
common.mysql_server_version = fields.serverVersion;
}
return `${JSON.stringify(common, null, 2)}\n`;
}
function assertNodeMetadata(metadata) {
if (metadata.formatVersion !== 3) {
throw new Error('Node Schema 基线必须使用 format v3');
}
for (const key of ['exporterName', 'exporterVersion', 'driverName', 'driverVersion']) {
if (!String(metadata[key] ?? '').trim()) {
throw new Error(`Node Schema 元数据缺少 ${key}`);
}
}
assertMysql57ServerMetadataV3(metadata);
}
export function buildCapturedArtifacts(metadata, sql, objectCounts) {
if (metadata.formatVersion === 3) {
assertNodeMetadata(metadata);
if (!objectCounts || Object.keys(objectCounts).sort().join(',') !== [...OBJECT_KEYS].sort().join(',')) {
throw new Error('Node Schema 对象计数字段无效');
}
const normalizedSql = normalizeSchemaSql(sql);
const derivedCounts = countSchemaObjects(normalizedSql);
for (const key of OBJECT_KEYS) {
if (!Number.isInteger(objectCounts[key]) || objectCounts[key] < 0 || objectCounts[key] !== derivedCounts[key]) {
throw new Error(`Node Schema 对象计数与 SQL 不一致:${key}`);
}
}
if (String(sql).trim() && Object.values(objectCounts).every((count) => count === 0)) {
throw new Error('Node Schema SQL 非空但对象计数全为零');
}
if (Object.values(objectCounts).every((count) => count === 0)) {
return buildCapturedEmptyArtifacts(metadata);
}
const schemaSha256 = sha256(normalizedSql);
const manifest = buildManifest({
...metadata,
status: 'captured',
captured: true,
objectCounts,
schemaSha256,
});
return Object.freeze({
status: 'captured',
schemaSha256,
files: Object.freeze({
'manifest.yaml': manifest,
'schema.sql': normalizedSql,
'checksum.sha256': `${schemaSha256} schema.sql\n`,
}),
});
}
assertMysql57Metadata(metadata);
if (!VERSION_PATTERN.test(metadata.applicationVersion ?? '')) {
throw new Error(`应用版本格式无效:${metadata.applicationVersion || '(空)'}`);
}
const normalizedSql = normalizeSchemaSql(sql);
const derivedCounts = countSchemaObjects(normalizedSql);
// mysqldump 对空库仍会输出会话 SET 与头尾注释;结构对象计数为零才是空库判据。
if (Object.values(derivedCounts).every((count) => count === 0)) {
return buildCapturedEmptyArtifacts(metadata);
}
const schemaSha256 = sha256(normalizedSql);
const manifest = buildManifest({
...metadata,
formatVersion: metadata.formatVersion ?? PREVIOUS_BASELINE_FORMAT_VERSION,
status: 'captured',
captured: true,
objectCounts: derivedCounts,
schemaSha256,
});
return Object.freeze({
status: 'captured',
schemaSha256,
files: Object.freeze({
'manifest.yaml': manifest,
'schema.sql': normalizedSql,
'checksum.sha256': `${schemaSha256} schema.sql\n`,
}),
});
}
export function buildCapturedEmptyArtifacts(metadata) {
if (metadata.formatVersion === 3) {
assertNodeMetadata(metadata);
const schemaSha256 = sha256('');
const objectCounts = Object.freeze(Object.fromEntries(OBJECT_KEYS.map((key) => [key, 0])));
const manifest = buildManifest({
...metadata,
status: 'captured_empty',
captured: false,
objectCounts,
schemaSha256,
});
return Object.freeze({
status: 'captured_empty',
schemaSha256,
files: Object.freeze({ 'manifest.yaml': manifest }),
});
}
assertMysql57Metadata(metadata);
if (!VERSION_PATTERN.test(metadata.applicationVersion ?? '')) {
throw new Error(`应用版本格式无效:${metadata.applicationVersion || '(空)'}`);
}
const schemaSha256 = sha256('');
const objectCounts = Object.freeze(Object.fromEntries(OBJECT_KEYS.map((key) => [key, 0])));
const manifest = buildManifest({
...metadata,
formatVersion: metadata.formatVersion ?? PREVIOUS_BASELINE_FORMAT_VERSION,
status: 'captured_empty',
captured: false,
objectCounts,
schemaSha256,
});
return Object.freeze({
status: 'captured_empty',
schemaSha256,
files: Object.freeze({ 'manifest.yaml': manifest }),
});
}
export function buildNotApplicableArtifacts(metadata) {
if (!VERSION_PATTERN.test(metadata.applicationVersion ?? '')) {
throw new Error(`应用版本格式无效:${metadata.applicationVersion || '(空)'}`);
}
const nodeMetadata = metadata.formatVersion === 3 ? metadata : {
...metadata,
formatVersion: PREVIOUS_BASELINE_FORMAT_VERSION,
};
const manifest = buildManifest({
...nodeMetadata,
...metadata,
formatVersion: nodeMetadata.formatVersion,
mysqlClientVersion: 'not_enabled',
mysqldumpClientVersion: 'not_enabled',
serverVersion: 'not_enabled',
status: 'not_applicable',
reason: 'mysql_disabled',
captured: false,
objectCounts: Object.fromEntries(OBJECT_KEYS.map((key) => [key, 0])),
});
return Object.freeze({
status: 'not_applicable',
files: Object.freeze({ 'manifest.yaml': manifest }),
});
}
function compareExistingArtifacts(targetDirectory, artifacts) {
const actualFiles = readdirSync(targetDirectory).sort();
const expectedFiles = Object.keys(artifacts.files).sort();
if (
actualFiles.length !== expectedFiles.length ||
actualFiles.some((file, index) => file !== expectedFiles[index])
) {
return false;
}
return expectedFiles.every(
(file) => readFileSync(path.join(targetDirectory, file), 'utf8') === artifacts.files[file],
);
}
/**
* 先在目标父目录写入临时目录,再原子改名。这样异常不会留下半份基线;
* 已发布版本目录只允许逐字节相同的幂等重跑,任何差异都必须人工留痕修正。
*/
const DEFAULT_FILE_OPERATIONS = Object.freeze({
existsSync,
mkdirSync,
readFileSync,
readdirSync,
renameSync,
rmSync,
writeFileSync,
});
export function persistBaselineArtifacts(
targetDirectory,
artifacts,
fileOperations = DEFAULT_FILE_OPERATIONS,
) {
const operations = { ...DEFAULT_FILE_OPERATIONS, ...fileOperations };
const {
existsSync: fileExists,
mkdirSync: makeDirectory,
readFileSync: readFile,
readdirSync: readDirectory,
renameSync: renameFile,
rmSync: removePath,
writeFileSync: writeFile,
} = operations;
const compare = () => {
const actualFiles = readDirectory(targetDirectory).sort();
const expectedFiles = Object.keys(artifacts.files).sort();
if (
actualFiles.length !== expectedFiles.length ||
actualFiles.some((file, index) => file !== expectedFiles[index])
) {
return false;
}
return expectedFiles.every(
(file) => readFile(path.join(targetDirectory, file), 'utf8') === artifacts.files[file],
);
};
if (fileExists(targetDirectory)) {
if (compare()) {
return { reused: true };
}
throw new Error(`目标版本基线已存在且内容不同,禁止覆盖:${targetDirectory}`);
}
const parentDirectory = path.dirname(targetDirectory);
makeDirectory(parentDirectory, { recursive: true });
const temporaryDirectory = path.join(
parentDirectory,
`.${path.basename(targetDirectory)}.tmp-${process.pid}-${Date.now()}`,
);
makeDirectory(temporaryDirectory);
try {
for (const [file, content] of Object.entries(artifacts.files)) {
writeFile(path.join(temporaryDirectory, file), content, 'utf8');
}
renameFile(temporaryDirectory, targetDirectory);
} catch (error) {
removePath(temporaryDirectory, { recursive: true, force: true });
throw error;
}
return { reused: false };
}
function parseManifest(content) {
try {
return JSON.parse(content);
} catch {
throw new Error('manifest.yaml 不是有效的结构化 YAML/JSON 清单');
}
}
function assertExactFileSet(targetDirectory, expectedFiles) {
const actualFiles = readdirSync(targetDirectory).sort();
const sortedExpected = [...expectedFiles].sort();
if (
actualFiles.length !== sortedExpected.length ||
actualFiles.some((file, index) => file !== sortedExpected[index])
) {
throw new Error(
`Schema 基线文件集合无效,要求 ${sortedExpected.join(', ')},实际 ${actualFiles.join(', ')}`,
);
}
}
export function verifyBaselineDirectory(targetDirectory, expectedRelease, expectedCommit) {
if (!VERSION_PATTERN.test(expectedRelease ?? '')) {
throw new Error(`待验证版本格式无效:${expectedRelease || '(空)'}`);
}
if (!existsSync(targetDirectory)) {
throw new Error(`当前已发布版本缺少 Schema 基线:${targetDirectory}`);
}
const manifest = parseManifest(
readFileSync(path.join(targetDirectory, 'manifest.yaml'), 'utf8'),
);
const status = manifest.status;
const applicationVersion = manifest.application_version;
const generatedAt = Date.parse(manifest.generated_at ?? '');
const supportedFormat =
(manifest.format_version === LEGACY_BASELINE_FORMAT_VERSION &&
manifest.generator === `schema-baseline/${LEGACY_TOOL_VERSION}`) ||
(manifest.format_version === PREVIOUS_BASELINE_FORMAT_VERSION &&
manifest.generator === `schema-baseline/${PREVIOUS_TOOL_VERSION}`) ||
(manifest.format_version === BASELINE_FORMAT_VERSION &&
manifest.generator === `schema-baseline/${TOOL_VERSION}`);
if (
!supportedFormat ||
!Number.isFinite(generatedAt) ||
!/^[0-9a-f]{40}$/.test(manifest.git_commit ?? '')
) {
throw new Error('Schema 清单格式、生成器、时间或 Git 提交无效');
}
requireExactValue(applicationVersion, expectedRelease, 'Schema 基线版本与发布版本不一致');
if (expectedCommit !== undefined) {
requireExactValue(
manifest.git_commit,
expectedCommit,
'Schema 基线提交与发布标签不一致',
);
}
requireExactValue(
manifest.source_environment,
'development',
'Schema 基线来源环境无效',
);
requireExactValue(
manifest.database_name,
DEVELOPMENT_DATABASE,
'Schema 基线数据库名无效',
);
requireExactValue(manifest.charset, REQUIRED_CHARSET, 'Schema 字符集无效');
requireExactValue(
manifest.collation,
REQUIRED_COLLATION,
'Schema 排序规则无效',
);
if (status === 'not_applicable') {
assertExactFileSet(targetDirectory, NOT_APPLICABLE_FILES);
const reason = manifest.reason;
requireExactValue(reason, 'mysql_disabled', 'Schema 不适用原因无效');
const commonInvalid =
manifest.mysql_server_version !== 'not_enabled' ||
manifest.schema_sha256 !== 'not_applicable' ||
Object.keys(manifest.objects ?? {}).sort().join(',') !==
[...OBJECT_KEYS].sort().join(',') ||
OBJECT_KEYS.some((key) => manifest.objects[key] !== false);
const formatInvalid =
manifest.format_version === BASELINE_FORMAT_VERSION
? !String(manifest.exporter_name ?? '').trim() ||
!String(manifest.exporter_version ?? '').trim() ||
!String(manifest.database_driver ?? '').trim() ||
!String(manifest.database_driver_version ?? '').trim() ||
'mysql_client_version' in manifest ||
'mysqldump_client_version' in manifest
: manifest.mysql_client_version !== 'not_enabled' ||
manifest.mysqldump_client_version !== 'not_enabled';
if (commonInvalid || formatInvalid) {
throw new Error('Schema 不适用清单必须证明 MySQL 未启用且所有对象均未捕获');
}
return { status, applicationVersion, reason };
}
if (status === 'captured_empty') {
assertExactFileSet(targetDirectory, CAPTURED_EMPTY_FILES);
if (manifest.format_version === BASELINE_FORMAT_VERSION) {
assertNodeMetadata({
formatVersion: 3,
exporterName: manifest.exporter_name,
exporterVersion: manifest.exporter_version,
driverName: manifest.database_driver,
driverVersion: manifest.database_driver_version,
serverVersion: manifest.mysql_server_version,
databaseCharset: manifest.charset,
databaseCollation: manifest.collation,
});
} else {
assertMysql57Metadata({
mysqlClientVersion: manifest.mysql_client_version,
mysqldumpClientVersion: manifest.mysqldump_client_version,
serverVersion: manifest.mysql_server_version,
databaseCharset: manifest.charset,
databaseCollation: manifest.collation,
});
}
const expectedEmptyChecksum = sha256('');
if (
manifest.schema_sha256 !== expectedEmptyChecksum ||
Object.keys(manifest.object_counts ?? {}).sort().join(',') !==
[...OBJECT_KEYS].sort().join(',') ||
OBJECT_KEYS.some((key) => manifest.object_counts[key] !== 0)
) {
throw new Error('空库 Schema 清单必须证明所有对象计数为零');
}
return { status, applicationVersion, schemaSha256: expectedEmptyChecksum };
}
if (status !== 'captured') {
throw new Error(`Schema 基线状态无效:${status}`);
}
assertExactFileSet(targetDirectory, CAPTURED_FILES);
if (manifest.format_version === BASELINE_FORMAT_VERSION) {
assertNodeMetadata({
formatVersion: 3,
exporterName: manifest.exporter_name,
exporterVersion: manifest.exporter_version,
driverName: manifest.database_driver,
driverVersion: manifest.database_driver_version,
serverVersion: manifest.mysql_server_version,
databaseCharset: manifest.charset,
databaseCollation: manifest.collation,
});
} else {
assertMysql57Metadata({
mysqlClientVersion: manifest.mysql_client_version,
mysqldumpClientVersion: manifest.mysqldump_client_version,
serverVersion: manifest.mysql_server_version,
databaseCharset: manifest.charset,
databaseCollation: manifest.collation,
});
}
if (Object.keys(manifest.objects ?? {}).sort().join(',') !== [...OBJECT_KEYS].sort().join(',')) {
throw new Error('Schema 对象范围字段无效');
}
for (const objectName of OBJECT_KEYS) {
requireExactValue(manifest.objects?.[objectName], true, `Schema 对象范围缺少 ${objectName}`);
}
if (
manifest.format_version === BASELINE_FORMAT_VERSION &&
(Object.keys(manifest.object_counts ?? {}).sort().join(',') !==
[...OBJECT_KEYS].sort().join(',') ||
OBJECT_KEYS.some(
(key) => !Number.isInteger(manifest.object_counts[key]) || manifest.object_counts[key] < 0,
))
) {
throw new Error('Schema 对象计数字段无效');
}
const sql = readFileSync(path.join(targetDirectory, 'schema.sql'), 'utf8');
if (!sql.trim()) {
throw new Error('真实 Schema SQL 不能为空');
}
assertSchemaHasNoSensitiveOrDataStatements(sql);
if (/^(\) ENGINE=[^\n;]*?) AUTO_INCREMENT=\d+(?= )/m.test(sql)) {
throw new Error('Schema SQL 仍包含当前 AUTO_INCREMENT 值');
}
const schemaSha256 = sha256(sql);
const manifestChecksum = manifest.schema_sha256;
const checksumFile = readFileSync(path.join(targetDirectory, 'checksum.sha256'), 'utf8');
if (manifestChecksum !== schemaSha256 || checksumFile !== `${schemaSha256} schema.sql\n`) {
throw new Error('Schema SQL、manifest 和 checksum 校验值不一致');
}
if (manifest.format_version === BASELINE_FORMAT_VERSION) {
const derivedCounts = countSchemaObjects(sql);
for (const key of OBJECT_KEYS) {
if (manifest.object_counts[key] !== derivedCounts[key]) {
throw new Error(`Schema 对象计数与 SQL 不一致:${key}`);
}
}
}
return { status, applicationVersion, schemaSha256 };
}
export function parseEnvironmentFile(content) {
const config = {};
for (const rawLine of String(content).replace(/\r\n?/g, '\n').split('\n')) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const separator = line.indexOf('=');
if (separator <= 0) {
throw new Error('开发环境配置包含无效行');
}
const key = line.slice(0, separator).trim();
const value = line.slice(separator + 1).trim();
if (!/^[A-Z][A-Z0-9_]*$/.test(key)) {
throw new Error(`开发环境配置键无效:${key}`);
}
config[key] = value;
}
return config;
}
function extractClientVersion(output) {
const match = String(output).match(/\b(?:Distrib\s+)?(5\.7\.\d+(?:[-+][^\s,]+)?)/);
return match?.[1] ?? String(output).trim();
}
/**
* 真实执行器可注入是为了让测试证明“关闭时零数据库调用”和命令边界;默认执行器
* 仍使用 execFileSync 参数数组。密码仅放入子进程环境 MYSQL_PWD,不会进入 argv。
*/
export async function runBaselineGeneration(options) {
if ((options.commandArguments ?? []).length > 0) {
throw new Error('Schema 基线命令不接受命令参数,不能覆盖环境、数据库或 env 路径');
}
const config = assertSafeDevelopmentConfiguration(
parseEnvironmentFile(readFileSync(options.envFile, 'utf8')),
);
const targetDirectory = path.join(
options.repositoryRoot,
'database',
'schema-baselines',
options.applicationVersion,
);
if (existsSync(targetDirectory)) {
return verifyBaselineDirectory(
targetDirectory,
options.applicationVersion,
options.gitCommit,
);
}
const commonMetadata = {
applicationVersion: options.applicationVersion,
gitCommit: options.gitCommit,
generatedAt: options.generatedAt ?? new Date().toISOString(),
};
if (!config.MYSQL_ENABLED) {
const artifacts = buildNotApplicableArtifacts({
...commonMetadata,
formatVersion: BASELINE_FORMAT_VERSION,
exporterName: MYSQL2_EXPORTER_METADATA.name,
exporterVersion: MYSQL2_EXPORTER_METADATA.version,
driverName: MYSQL2_EXPORTER_METADATA.driverName,
driverVersion: MYSQL2_EXPORTER_METADATA.driverVersion,
serverVersion: 'not_enabled',
databaseCharset: REQUIRED_CHARSET,
databaseCollation: REQUIRED_COLLATION,
});
persistBaselineArtifacts(targetDirectory, artifacts, options.fileOperations);
return verifyBaselineDirectory(targetDirectory, options.applicationVersion);
}
const exported = await (options.exportSchema ?? exportMysql57Schema)({ config });
const exportedMetadata = exported.metadata ?? {};
const metadata = {
...commonMetadata,
...exportedMetadata,
applicationVersion: exportedMetadata.applicationVersion ?? commonMetadata.applicationVersion,
gitCommit: exportedMetadata.gitCommit ?? commonMetadata.gitCommit,
generatedAt: exportedMetadata.generatedAt ?? commonMetadata.generatedAt,
formatVersion: BASELINE_FORMAT_VERSION,
};
const artifacts = buildCapturedArtifacts(metadata, exported.rawSql, exported.objectCounts);
persistBaselineArtifacts(targetDirectory, artifacts, options.fileOperations);
return verifyBaselineDirectory(targetDirectory, options.applicationVersion);
}
function runGit(repositoryRoot, args) {
return execFileSync('git', args, {
cwd: repositoryRoot,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
}
export function getCurrentReleasedVersion(repositoryRoot) {
const applicationVersion = runGit(repositoryRoot, [
'describe',
'--tags',
'--abbrev=0',
'--match',
'v[0-9]*',
]);
if (!VERSION_PATTERN.test(applicationVersion)) {
throw new Error(`最近发布标签格式无效:${applicationVersion}`);
}
return {
applicationVersion,
gitCommit: runGit(repositoryRoot, ['rev-list', '-n', '1', applicationVersion]),
};
}
/** 已发布清单是不可变历史;当前开发配置只决定下一次基线生成结果。 */
export function assertNotApplicableMatchesDevelopmentConfiguration(repositoryRoot, result) {
// 已发布清单记录的是发布当时的数据库启用状态。当前开发配置只能决定下一份
// 基线如何生成,不能反向否定或改写已发布版本的历史事实。
void repositoryRoot;
return result;
}
export function assertCurrentReleasedBaseline(repositoryRoot) {
const { applicationVersion, gitCommit } = getCurrentReleasedVersion(repositoryRoot);
const result = verifyBaselineDirectory(
path.join(repositoryRoot, 'database', 'schema-baselines', applicationVersion),
applicationVersion,
gitCommit,
);
return assertNotApplicableMatchesDevelopmentConfiguration(repositoryRoot, result);
}
function runGitLines(repositoryRoot, args) {
const output = runGit(repositoryRoot, args);
return output ? output.split('\n').filter(Boolean) : [];
}
/**
* 开始下一版本前使用严格门禁:本地校验只证明内容正确,不能证明它已经进入
* 可协作历史。这里进一步要求基线已跟踪、工作区无相关改动、当前 HEAD 已推送,
* 且 origin 上的发布标签仍指向清单记录的提交。
*/
export function assertCurrentReleasedBaselinePublished(repositoryRoot) {
const release = getCurrentReleasedVersion(repositoryRoot);
const relativeDirectory = path.posix.join(
'database',
'schema-baselines',
release.applicationVersion,
);
const result = verifyBaselineDirectory(
path.join(repositoryRoot, relativeDirectory),
release.applicationVersion,
release.gitCommit,
);
assertNotApplicableMatchesDevelopmentConfiguration(repositoryRoot, result);
const trackedFiles = new Set(
runGitLines(repositoryRoot, ['ls-files', '--', relativeDirectory]),
);
const expectedFiles =
result.status === 'captured' ? CAPTURED_FILES : NOT_APPLICABLE_FILES;
for (const file of expectedFiles) {
const relativeFile = path.posix.join(relativeDirectory, file);
if (!trackedFiles.has(relativeFile)) {
throw new Error(`Schema 基线尚未提交到 Git:${relativeFile}`);
}
}
const localChanges = runGitLines(repositoryRoot, [
'status',
'--porcelain',
'--',
relativeDirectory,
]);
if (localChanges.length > 0) {
throw new Error(`Schema 基线存在未提交改动:${localChanges.join(', ')}`);
}
const branch = runGit(repositoryRoot, ['branch', '--show-current']);
if (!branch) {
throw new Error('Schema 发布门禁要求当前位于可推送分支');
}
const localHead = runGit(repositoryRoot, ['rev-parse', 'HEAD']);
assertRemoteBranchMatches(repositoryRoot, branch, runGit, localHead);
const remoteTag = runGit(repositoryRoot, [
'ls-remote',
'--tags',
'origin',
`refs/tags/${release.applicationVersion}^{}`,
]);
const remoteTagCommit = remoteTag.split(/\s+/)[0];
requireExactValue(remoteTagCommit, release.gitCommit, '远程发布标签与基线提交不一致');
return result;
}
export function assertRemoteBranchMatches(repositoryRoot, branch, gitRunner = runGit, localHead) {
const expectedHead = localHead ?? gitRunner(repositoryRoot, ['rev-parse', 'HEAD']);
const remoteOutput = gitRunner(repositoryRoot, ['ls-remote', 'origin', `refs/heads/${branch}`]);
const remoteHead = remoteOutput.split(/\s+/)[0];
requireExactValue(remoteHead, expectedHead, '当前分支尚未完整推送到 origin');
return true;
}
export function resolveFixedDevelopmentEnv(repositoryRoot) {
const realDevelopmentEnv = path.join(
repositoryRoot,
'deploy',
'env',
'development',
'api.env.local',
);
if (existsSync(realDevelopmentEnv)) return realDevelopmentEnv;
return path.join(repositoryRoot, 'deploy', 'env', 'development', 'api.env.example');
}
export async function runCommandLine() {
const [command, ...commandArguments] = process.argv.slice(2);
if (!['generate', 'verify'].includes(command) || commandArguments.length > 0) {
throw new Error('用法:node tools/database/schema-baseline.mjs <generate|verify>(不接受额外参数)');
}
const repositoryRoot = process.cwd();
const release = getCurrentReleasedVersion(repositoryRoot);
const result =
command === 'generate'
? await runBaselineGeneration({
repositoryRoot,
envFile: resolveFixedDevelopmentEnv(repositoryRoot),
...release,
commandArguments,
})
: assertCurrentReleasedBaseline(repositoryRoot);
process.stdout.write(
`Schema 基线验证通过:${release.applicationVersion} (${result.status})\n`,
);
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
await runCommandLine();
}