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

767 lines
27 KiB
JavaScript
Raw 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';
const DEVELOPMENT_DATABASE = 'a_share_analysis_dev';
const REQUIRED_CHARSET = 'utf8mb4';
const REQUIRED_COLLATION = 'utf8mb4_general_ci';
const BASELINE_FORMAT_VERSION = 1;
const TOOL_VERSION = '1.0.0';
const VERSION_PATTERN = /^v\d+\.\d+\.\d+$/;
const DEVELOPMENT_HOSTS = new Set(['127.0.0.1', 'localhost', '::1']);
const OBJECT_KEYS = Object.freeze(['tables', 'views', 'triggers', 'routines', 'events']);
const CAPTURED_FILES = Object.freeze(['checksum.sha256', 'manifest.yaml', 'schema.sql']);
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 工具拥有比普通应用启动更窄的权限边界:即使连接地址看起来是本机,
* 环境名和数据库名也必须同时命中固定开发值,避免通过参数误切到生产库。
*/
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 || '(空)'};远程开发库必须先建立本地 SSH 隧道`,
);
}
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 8 客户端生成带有 5.7 无法稳定恢复的语法,
* 同时核对数据库自身字符规则,而不是信任 env 中的声明值。
*/
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 buildManifest(fields) {
// JSON 是 YAML 1.2 的严格子集;使用标准解析器生成和读取,避免手写 YAML
// 对缩进、引号和同名嵌套字段产生歧义,同时仍保留约定的 manifest.yaml 文件名。
return `${JSON.stringify(
{
format_version: BASELINE_FORMAT_VERSION,
status: fields.status,
application_version: fields.applicationVersion,
git_commit: fields.gitCommit,
source_environment: 'development',
database_name: DEVELOPMENT_DATABASE,
mysql_client_version: fields.mysqlClientVersion,
mysqldump_client_version: fields.mysqldumpClientVersion,
mysql_server_version: fields.serverVersion,
charset: REQUIRED_CHARSET,
collation: REQUIRED_COLLATION,
generated_at: fields.generatedAt,
generator: `schema-baseline/${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,
},
},
null,
2,
)}\n`;
}
export function buildCapturedArtifacts(metadata, sql) {
assertMysql57Metadata(metadata);
if (!VERSION_PATTERN.test(metadata.applicationVersion ?? '')) {
throw new Error(`应用版本格式无效:${metadata.applicationVersion || '(空)'}`);
}
const normalizedSql = normalizeSchemaSql(sql);
if (!normalizedSql.trim()) {
throw new Error('真实 Schema SQL 不能为空');
}
const schemaSha256 = sha256(normalizedSql);
const manifest = buildManifest({
...metadata,
status: 'captured',
captured: true,
schemaSha256,
});
return Object.freeze({
status: 'captured',
schemaSha256,
files: Object.freeze({
'manifest.yaml': manifest,
'schema.sql': normalizedSql,
'checksum.sha256': `${schemaSha256} schema.sql\n`,
}),
});
}
export function buildNotApplicableArtifacts(metadata) {
if (!VERSION_PATTERN.test(metadata.applicationVersion ?? '')) {
throw new Error(`应用版本格式无效:${metadata.applicationVersion || '(空)'}`);
}
const manifest = buildManifest({
...metadata,
mysqlClientVersion: 'not_enabled',
mysqldumpClientVersion: 'not_enabled',
serverVersion: 'not_enabled',
status: 'not_applicable',
reason: 'mysql_disabled',
captured: false,
});
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],
);
}
/**
* 先在目标父目录写入临时目录,再原子改名。这样异常不会留下半份基线;
* 已发布版本目录只允许逐字节相同的幂等重跑,任何差异都必须人工留痕修正。
*/
export function persistBaselineArtifacts(targetDirectory, artifacts) {
if (existsSync(targetDirectory)) {
if (compareExistingArtifacts(targetDirectory, artifacts)) {
return { reused: true };
}
throw new Error(`目标版本基线已存在且内容不同,禁止覆盖:${targetDirectory}`);
}
const parentDirectory = path.dirname(targetDirectory);
mkdirSync(parentDirectory, { recursive: true });
const temporaryDirectory = path.join(
parentDirectory,
`.${path.basename(targetDirectory)}.tmp-${process.pid}-${Date.now()}`,
);
mkdirSync(temporaryDirectory);
try {
for (const [file, content] of Object.entries(artifacts.files)) {
writeFileSync(path.join(temporaryDirectory, file), content, 'utf8');
}
renameSync(temporaryDirectory, targetDirectory);
} catch (error) {
rmSync(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 ?? '');
if (
manifest.format_version !== BASELINE_FORMAT_VERSION ||
manifest.generator !== `schema-baseline/${TOOL_VERSION}` ||
!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 不适用原因无效');
if (
manifest.mysql_client_version !== 'not_enabled' ||
manifest.mysqldump_client_version !== 'not_enabled' ||
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)
) {
throw new Error('Schema 不适用清单必须证明 MySQL 未启用且所有对象均未捕获');
}
return { status, applicationVersion, reason };
}
if (status !== 'captured') {
throw new Error(`Schema 基线状态无效:${status}`);
}
assertExactFileSet(targetDirectory, CAPTURED_FILES);
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}`);
}
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 校验值不一致');
}
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 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);
persistBaselineArtifacts(targetDirectory, artifacts);
return verifyBaselineDirectory(targetDirectory, options.applicationVersion);
}
const executeFile = options.executeFile ?? execFileSync;
const childEnvironment = { ...process.env, MYSQL_PWD: config.MYSQL_PASSWORD ?? '' };
const executionOptions = {
cwd: options.repositoryRoot,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
env: childEnvironment,
};
const mysqlClientVersion = extractClientVersion(
executeFile('mysql', ['--version'], executionOptions),
);
const mysqldumpClientVersion = extractClientVersion(
executeFile('mysqldump', ['--version'], executionOptions),
);
assertMysql57Metadata({
mysqlClientVersion,
mysqldumpClientVersion,
serverVersion: '5.7.0-pending-server-check',
databaseCharset: REQUIRED_CHARSET,
databaseCollation: REQUIRED_COLLATION,
});
const serverMetadata = String(
executeFile('mysql', buildMysqlMetadataArguments(config), executionOptions),
)
.trim()
.split('\t');
if (serverMetadata.length !== 3) {
throw new Error('无法从固定开发库读取 MySQL 版本、字符集和排序规则');
}
const metadata = {
...commonMetadata,
mysqlClientVersion,
mysqldumpClientVersion,
serverVersion: serverMetadata[0],
databaseCharset: serverMetadata[1],
databaseCollation: serverMetadata[2],
};
assertMysql57Metadata(metadata);
const rawSql = executeFile('mysqldump', buildMysqldumpArguments(config), executionOptions);
const artifacts = buildCapturedArtifacts(metadata, rawSql);
persistBaselineArtifacts(targetDirectory, artifacts);
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]),
};
}
/**
* not_applicable 清单只记录生成当时 MySQL 未启用,不能永久替代真实结构基线。
* 每次校验都重新读取固定开发环境配置;一旦开发库启用,旧清单立即失效,强制
* 下一版本开发开始前先从开发库生成 captured 基线。
*/
export function assertNotApplicableMatchesDevelopmentConfiguration(repositoryRoot, result) {
if (result.status !== 'not_applicable') return result;
const config = assertSafeDevelopmentConfiguration(
parseEnvironmentFile(readFileSync(resolveFixedDevelopmentEnv(repositoryRoot), 'utf8')),
);
if (config.MYSQL_ENABLED) {
throw new Error('当前开发环境已启用 MySQL,不能继续使用 not_applicable Schema 基线');
}
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;
}
function resolveFixedDevelopmentEnv(repositoryRoot) {
const realDevelopmentEnv = path.join(repositoryRoot, 'deploy', 'env', 'development', 'api.env');
if (existsSync(realDevelopmentEnv)) return realDevelopmentEnv;
return path.join(repositoryRoot, 'deploy', 'env', 'development', 'api.env.example');
}
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'
? 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)) {
runCommandLine();
}