46baebbc10
删除退役内容校验、视觉小说墓碑脚本和旧玩法门禁文档 移除死别名与退役分支触发并同步当前文档入口 为Gitea和Jenkins补齐可信SpacetimeDB schema比较基线 补充workflow、schema和生产运维防回归测试
812 lines
21 KiB
JavaScript
812 lines
21 KiB
JavaScript
import { execFileSync } from 'node:child_process';
|
|
import { existsSync, readFileSync } from 'node:fs';
|
|
import { basename, dirname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
|
const repoRoot = join(scriptDir, '..');
|
|
const moduleSrcRoot = 'server-rs/crates/spacetime-module/src';
|
|
const moduleManifestPath = 'server-rs/crates/spacetime-module/Cargo.toml';
|
|
const migrationPath = `${moduleSrcRoot}/migration.rs`;
|
|
const tableCatalogPath =
|
|
'docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md';
|
|
const bindingsRoot = 'server-rs/crates/spacetime-client/src/module_bindings/';
|
|
const allowBreaking = process.env.SPACETIME_SCHEMA_GUARD_ALLOW_BREAKING === '1';
|
|
function normalizePath(path) {
|
|
return path.replace(/\\/gu, '/');
|
|
}
|
|
|
|
function runGit(args, options = {}) {
|
|
return execFileSync('git', args, {
|
|
cwd: repoRoot,
|
|
encoding: 'utf8',
|
|
stdio: options.quiet
|
|
? ['ignore', 'pipe', 'ignore']
|
|
: ['ignore', 'pipe', 'pipe'],
|
|
maxBuffer: 32 * 1024 * 1024,
|
|
}).trim();
|
|
}
|
|
|
|
function tryGit(args) {
|
|
try {
|
|
return runGit(args, { quiet: true });
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function isFormalGateEnvironment(env = process.env) {
|
|
const enabled = (value) =>
|
|
typeof value === 'string' &&
|
|
value.trim() !== '' &&
|
|
!['0', 'false', 'no', 'off'].includes(value.trim().toLowerCase());
|
|
|
|
return [
|
|
env.CI,
|
|
env.JENKINS_URL,
|
|
env.JENKINS_HOME,
|
|
env.GITEA_ACTIONS,
|
|
env.GITHUB_ACTIONS,
|
|
env.BUILDKITE,
|
|
env.TEAMCITY_VERSION,
|
|
].some(enabled);
|
|
}
|
|
|
|
export function resolveBaseRef({
|
|
argv = process.argv,
|
|
env = process.env,
|
|
git = tryGit,
|
|
} = {}) {
|
|
const explicitArgIndex = argv.indexOf('--base-ref');
|
|
if (explicitArgIndex >= 0 && argv[explicitArgIndex + 1]) {
|
|
return argv[explicitArgIndex + 1];
|
|
}
|
|
|
|
if (env.SPACETIME_SCHEMA_BASE_REF) {
|
|
return env.SPACETIME_SCHEMA_BASE_REF;
|
|
}
|
|
|
|
const headCommit = git(['rev-parse', '--verify', 'HEAD^{commit}']);
|
|
const candidates = [
|
|
git(['merge-base', 'HEAD', 'origin/master']),
|
|
git(['rev-parse', '--verify', 'origin/master^{commit}']),
|
|
git(['rev-parse', '--verify', 'HEAD^']),
|
|
];
|
|
for (const candidate of candidates) {
|
|
if (candidate && candidate !== headCommit) {
|
|
return candidate;
|
|
}
|
|
}
|
|
|
|
if (isFormalGateEnvironment(env)) {
|
|
throw new Error(
|
|
'正式测试/构建环境无法取得与 HEAD 不同的 SpacetimeDB schema 比较基线。请获取待构建提交的父提交并设置 SPACETIME_SCHEMA_BASE_REF。',
|
|
);
|
|
}
|
|
|
|
return 'HEAD';
|
|
}
|
|
|
|
function validateBaseRef(baseRef) {
|
|
const baseCommit = tryGit(['rev-parse', '--verify', `${baseRef}^{commit}`]);
|
|
if (!baseCommit) {
|
|
throw new Error(
|
|
`SpacetimeDB schema 比较基线 ${baseRef} 不是可读取的 commit。`,
|
|
);
|
|
}
|
|
|
|
const headCommit = tryGit(['rev-parse', '--verify', 'HEAD^{commit}']);
|
|
if (isFormalGateEnvironment() && headCommit && baseCommit === headCommit) {
|
|
throw new Error(
|
|
`正式测试/构建环境的 SpacetimeDB schema 比较基线 ${baseRef} 与 HEAD 相同,拒绝无效自比。`,
|
|
);
|
|
}
|
|
}
|
|
|
|
export function resolveCrateRootFromManifest(manifest) {
|
|
const libSection =
|
|
/\[lib\]\s*\n([\s\S]*?)(?=\n\[|$)/u.exec(manifest)?.[1] ?? '';
|
|
const configuredPath = /^\s*path\s*=\s*"([^"]+)"/mu.exec(libSection)?.[1];
|
|
return normalizePath(
|
|
configuredPath
|
|
? join(dirname(moduleManifestPath), configuredPath)
|
|
: join(moduleSrcRoot, 'lib.rs'),
|
|
);
|
|
}
|
|
|
|
function childModuleDirectory(sourcePath, isCrateRoot) {
|
|
if (isCrateRoot) {
|
|
return dirname(sourcePath);
|
|
}
|
|
|
|
const fileName = basename(sourcePath);
|
|
if (fileName === 'mod.rs') {
|
|
return dirname(sourcePath);
|
|
}
|
|
|
|
return join(dirname(sourcePath), fileName.slice(0, -'.rs'.length));
|
|
}
|
|
|
|
export function listReachableRustFiles(readSource) {
|
|
const manifest = readSource(moduleManifestPath) ?? '';
|
|
const crateRoot = resolveCrateRootFromManifest(manifest);
|
|
const pending = [{ path: crateRoot, isCrateRoot: true }];
|
|
const visited = new Set();
|
|
const externalModulePattern =
|
|
/((?:[ \t]*#\[[^\]\r\n]*\][ \t]*\r?\n)*)[ \t]*(?:pub(?:\([^)]*\))?[ \t]+)?mod[ \t]+([A-Za-z_][A-Za-z0-9_]*)[ \t]*;/gmu;
|
|
|
|
while (pending.length > 0) {
|
|
const current = pending.pop();
|
|
if (!current || visited.has(current.path)) {
|
|
continue;
|
|
}
|
|
|
|
const source = readSource(current.path);
|
|
if (source === null) {
|
|
continue;
|
|
}
|
|
|
|
visited.add(current.path);
|
|
const defaultModuleDir = childModuleDirectory(
|
|
current.path,
|
|
current.isCrateRoot,
|
|
);
|
|
let match;
|
|
|
|
externalModulePattern.lastIndex = 0;
|
|
while ((match = externalModulePattern.exec(source)) !== null) {
|
|
const attributes = match[1] ?? '';
|
|
if (/cfg\s*\(\s*(?:any\s*\(\s*\)|test)\s*\)/u.test(attributes)) {
|
|
continue;
|
|
}
|
|
|
|
const moduleName = match[2];
|
|
const configuredPath = /#\[\s*path\s*=\s*"([^"]+)"\s*\]/u.exec(
|
|
attributes,
|
|
)?.[1];
|
|
const candidates = configuredPath
|
|
? [join(dirname(current.path), configuredPath)]
|
|
: [
|
|
join(defaultModuleDir, `${moduleName}.rs`),
|
|
join(defaultModuleDir, moduleName, 'mod.rs'),
|
|
];
|
|
const modulePath = candidates
|
|
.map(normalizePath)
|
|
.find((candidate) => readSource(candidate) !== null);
|
|
|
|
if (modulePath && !visited.has(modulePath)) {
|
|
pending.push({ path: modulePath, isCrateRoot: false });
|
|
}
|
|
}
|
|
}
|
|
|
|
return [...visited].sort();
|
|
}
|
|
|
|
function listBaseSourcePaths(baseRef) {
|
|
const output = tryGit([
|
|
'ls-tree',
|
|
'-r',
|
|
'--name-only',
|
|
baseRef,
|
|
'--',
|
|
moduleManifestPath,
|
|
moduleSrcRoot,
|
|
]);
|
|
if (!output) {
|
|
return new Set();
|
|
}
|
|
|
|
return new Set(output.split(/\r?\n/u).map(normalizePath).filter(Boolean));
|
|
}
|
|
|
|
function readCurrentFile(path) {
|
|
if (!existsSync(join(repoRoot, path))) {
|
|
return null;
|
|
}
|
|
return readFileSync(join(repoRoot, path), 'utf8');
|
|
}
|
|
|
|
function readBaseFile(baseRef, path) {
|
|
return tryGit(['show', `${baseRef}:${path}`]);
|
|
}
|
|
|
|
function createBaseSourceReader(baseRef) {
|
|
const sourcePaths = listBaseSourcePaths(baseRef);
|
|
const sourceCache = new Map();
|
|
|
|
return (path) => {
|
|
const normalizedPath = normalizePath(path);
|
|
if (!sourcePaths.has(normalizedPath)) {
|
|
return null;
|
|
}
|
|
if (!sourceCache.has(normalizedPath)) {
|
|
sourceCache.set(normalizedPath, readBaseFile(baseRef, normalizedPath));
|
|
}
|
|
return sourceCache.get(normalizedPath);
|
|
};
|
|
}
|
|
|
|
function lineNumberAt(text, index) {
|
|
let line = 1;
|
|
for (let i = 0; i < index; i += 1) {
|
|
if (text[i] === '\n') {
|
|
line += 1;
|
|
}
|
|
}
|
|
return line;
|
|
}
|
|
|
|
function findClosingBracket(text, start) {
|
|
let depth = 0;
|
|
let quote = null;
|
|
let escaped = false;
|
|
let lineComment = false;
|
|
let blockCommentDepth = 0;
|
|
|
|
for (let i = start; i < text.length; i += 1) {
|
|
const char = text[i];
|
|
const next = text[i + 1];
|
|
|
|
if (lineComment) {
|
|
if (char === '\n') {
|
|
lineComment = false;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (blockCommentDepth > 0) {
|
|
if (char === '/' && next === '*') {
|
|
blockCommentDepth += 1;
|
|
i += 1;
|
|
} else if (char === '*' && next === '/') {
|
|
blockCommentDepth -= 1;
|
|
i += 1;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (quote) {
|
|
if (escaped) {
|
|
escaped = false;
|
|
} else if (char === '\\') {
|
|
escaped = true;
|
|
} else if (char === quote) {
|
|
quote = null;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (char === '/' && next === '/') {
|
|
lineComment = true;
|
|
i += 1;
|
|
continue;
|
|
}
|
|
|
|
if (char === '/' && next === '*') {
|
|
blockCommentDepth = 1;
|
|
i += 1;
|
|
continue;
|
|
}
|
|
|
|
if (char === '"' || char === "'") {
|
|
quote = char;
|
|
continue;
|
|
}
|
|
|
|
if (char === '[') {
|
|
depth += 1;
|
|
} else if (char === ']') {
|
|
depth -= 1;
|
|
if (depth === 0) {
|
|
return i + 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
return -1;
|
|
}
|
|
|
|
function findClosingBrace(text, start) {
|
|
let depth = 0;
|
|
let quote = null;
|
|
let escaped = false;
|
|
let lineComment = false;
|
|
let blockCommentDepth = 0;
|
|
|
|
for (let i = start; i < text.length; i += 1) {
|
|
const char = text[i];
|
|
const next = text[i + 1];
|
|
|
|
if (lineComment) {
|
|
if (char === '\n') {
|
|
lineComment = false;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (blockCommentDepth > 0) {
|
|
if (char === '/' && next === '*') {
|
|
blockCommentDepth += 1;
|
|
i += 1;
|
|
} else if (char === '*' && next === '/') {
|
|
blockCommentDepth -= 1;
|
|
i += 1;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (quote) {
|
|
if (escaped) {
|
|
escaped = false;
|
|
} else if (char === '\\') {
|
|
escaped = true;
|
|
} else if (char === quote) {
|
|
quote = null;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (char === '/' && next === '/') {
|
|
lineComment = true;
|
|
i += 1;
|
|
continue;
|
|
}
|
|
|
|
if (char === '/' && next === '*') {
|
|
blockCommentDepth = 1;
|
|
i += 1;
|
|
continue;
|
|
}
|
|
|
|
if (char === '"' || char === "'") {
|
|
quote = char;
|
|
continue;
|
|
}
|
|
|
|
if (char === '{') {
|
|
depth += 1;
|
|
} else if (char === '}') {
|
|
depth -= 1;
|
|
if (depth === 0) {
|
|
return i;
|
|
}
|
|
}
|
|
}
|
|
|
|
return -1;
|
|
}
|
|
|
|
function splitTopLevelSegments(text) {
|
|
const segments = [];
|
|
let start = 0;
|
|
let parenDepth = 0;
|
|
let bracketDepth = 0;
|
|
let braceDepth = 0;
|
|
let angleDepth = 0;
|
|
let quote = null;
|
|
let escaped = false;
|
|
let lineComment = false;
|
|
let blockCommentDepth = 0;
|
|
|
|
for (let i = 0; i < text.length; i += 1) {
|
|
const char = text[i];
|
|
const next = text[i + 1];
|
|
|
|
if (lineComment) {
|
|
if (char === '\n') {
|
|
lineComment = false;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (blockCommentDepth > 0) {
|
|
if (char === '/' && next === '*') {
|
|
blockCommentDepth += 1;
|
|
i += 1;
|
|
} else if (char === '*' && next === '/') {
|
|
blockCommentDepth -= 1;
|
|
i += 1;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (quote) {
|
|
if (escaped) {
|
|
escaped = false;
|
|
} else if (char === '\\') {
|
|
escaped = true;
|
|
} else if (char === quote) {
|
|
quote = null;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (char === '/' && next === '/') {
|
|
lineComment = true;
|
|
i += 1;
|
|
continue;
|
|
}
|
|
|
|
if (char === '/' && next === '*') {
|
|
blockCommentDepth = 1;
|
|
i += 1;
|
|
continue;
|
|
}
|
|
|
|
if (char === '"' || char === "'") {
|
|
quote = char;
|
|
continue;
|
|
}
|
|
|
|
if (char === '(') {
|
|
parenDepth += 1;
|
|
} else if (char === ')') {
|
|
parenDepth = Math.max(0, parenDepth - 1);
|
|
} else if (char === '[') {
|
|
bracketDepth += 1;
|
|
} else if (char === ']') {
|
|
bracketDepth = Math.max(0, bracketDepth - 1);
|
|
} else if (char === '{') {
|
|
braceDepth += 1;
|
|
} else if (char === '}') {
|
|
braceDepth = Math.max(0, braceDepth - 1);
|
|
} else if (char === '<') {
|
|
angleDepth += 1;
|
|
} else if (char === '>') {
|
|
angleDepth = Math.max(0, angleDepth - 1);
|
|
} else if (
|
|
char === ',' &&
|
|
parenDepth === 0 &&
|
|
bracketDepth === 0 &&
|
|
braceDepth === 0 &&
|
|
angleDepth === 0
|
|
) {
|
|
segments.push({ text: text.slice(start, i), start });
|
|
start = i + 1;
|
|
}
|
|
}
|
|
|
|
segments.push({ text: text.slice(start), start });
|
|
return segments;
|
|
}
|
|
|
|
function normalizeRustText(text) {
|
|
return text.replace(/\s+/gu, ' ').trim();
|
|
}
|
|
|
|
function parseField(segment, fileText, bodyStartIndex) {
|
|
const withoutLineComments = segment.text.replace(/\/\/.*$/gmu, '').trim();
|
|
if (!withoutLineComments) {
|
|
return null;
|
|
}
|
|
|
|
const attrs = [...withoutLineComments.matchAll(/#\[[\s\S]*?\]/gu)].map(
|
|
(match) => normalizeRustText(match[0]),
|
|
);
|
|
const fieldText = withoutLineComments
|
|
.replace(/#\[[\s\S]*?\]\s*/gu, '')
|
|
.trim();
|
|
const fieldMatch =
|
|
/^(?:pub(?:\([^)]*\))?\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*:\s*([\s\S]+)$/u.exec(
|
|
fieldText,
|
|
);
|
|
|
|
if (!fieldMatch) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
name: fieldMatch[1],
|
|
type: normalizeRustText(fieldMatch[2]),
|
|
attrs,
|
|
hasDefault: attrs.some((attr) => /^#\[\s*default\b/u.test(attr)),
|
|
line: lineNumberAt(fileText, bodyStartIndex + segment.start),
|
|
};
|
|
}
|
|
|
|
function parseFields(body, fileText, bodyStartIndex) {
|
|
return splitTopLevelSegments(body)
|
|
.map((segment) => parseField(segment, fileText, bodyStartIndex))
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function parseTablesFromFile(path, text) {
|
|
const tables = [];
|
|
const tableAttrPattern = /#\[\s*(?:spacetimedb::)?table\s*\(/gu;
|
|
let match;
|
|
|
|
while ((match = tableAttrPattern.exec(text)) !== null) {
|
|
const attrStart = match.index;
|
|
const attrEnd = findClosingBracket(text, attrStart);
|
|
if (attrEnd < 0) {
|
|
continue;
|
|
}
|
|
|
|
const attrText = text.slice(attrStart, attrEnd);
|
|
const accessorMatch =
|
|
/accessor\s*=\s*(?:"([^"]+)"|([A-Za-z_][A-Za-z0-9_]*))/u.exec(attrText);
|
|
const accessor = accessorMatch?.[1] ?? accessorMatch?.[2];
|
|
if (!accessor) {
|
|
continue;
|
|
}
|
|
|
|
const afterAttr = text.slice(attrEnd, attrEnd + 4000);
|
|
const structMatch =
|
|
/(?:#\[[\s\S]*?\]\s*)*(?:pub(?:\([^)]*\))?\s+)?struct\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{/u.exec(
|
|
afterAttr,
|
|
);
|
|
if (!structMatch) {
|
|
continue;
|
|
}
|
|
|
|
const structStart = attrEnd + structMatch.index;
|
|
const structOpenBrace = structStart + structMatch[0].lastIndexOf('{');
|
|
const structCloseBrace = findClosingBrace(text, structOpenBrace);
|
|
if (structCloseBrace < 0) {
|
|
continue;
|
|
}
|
|
|
|
const bodyStartIndex = structOpenBrace + 1;
|
|
const body = text.slice(bodyStartIndex, structCloseBrace);
|
|
tables.push({
|
|
accessor,
|
|
structName: structMatch[1],
|
|
path,
|
|
line: lineNumberAt(text, structStart),
|
|
fields: parseFields(body, text, bodyStartIndex),
|
|
});
|
|
|
|
tableAttrPattern.lastIndex = structCloseBrace + 1;
|
|
}
|
|
|
|
return tables;
|
|
}
|
|
|
|
export function collectTablesFromSources(sources) {
|
|
const tables = new Map();
|
|
const failures = [];
|
|
|
|
for (const source of sources) {
|
|
for (const table of parseTablesFromFile(source.path, source.text)) {
|
|
const previous = tables.get(table.accessor);
|
|
if (previous) {
|
|
failures.push(
|
|
`${table.path}:${table.line}: SpacetimeDB table accessor ${table.accessor} 重复定义,首次定义在 ${previous.path}:${previous.line}`,
|
|
);
|
|
continue;
|
|
}
|
|
tables.set(table.accessor, table);
|
|
}
|
|
}
|
|
|
|
return { tables, failures };
|
|
}
|
|
|
|
function loadCurrentSources() {
|
|
return listReachableRustFiles(readCurrentFile).map((path) => ({
|
|
path,
|
|
text: readCurrentFile(path) ?? '',
|
|
}));
|
|
}
|
|
|
|
function loadBaseSources(baseRef) {
|
|
const readSource = createBaseSourceReader(baseRef);
|
|
return listReachableRustFiles(readSource).map((path) => ({
|
|
path,
|
|
text: readSource(path) ?? '',
|
|
}));
|
|
}
|
|
|
|
function getChangedFiles(baseRef) {
|
|
const diffOutput = tryGit(['diff', '--name-only', '-z', baseRef, '--']) ?? '';
|
|
const untrackedModuleOutput =
|
|
tryGit([
|
|
'ls-files',
|
|
'--others',
|
|
'--exclude-standard',
|
|
'-z',
|
|
moduleSrcRoot,
|
|
]) ?? '';
|
|
const untrackedBindingsOutput =
|
|
tryGit([
|
|
'ls-files',
|
|
'--others',
|
|
'--exclude-standard',
|
|
'-z',
|
|
bindingsRoot,
|
|
]) ?? '';
|
|
return new Set(
|
|
[
|
|
...diffOutput.split(String.fromCharCode(0)),
|
|
...untrackedModuleOutput.split(String.fromCharCode(0)),
|
|
...untrackedBindingsOutput.split(String.fromCharCode(0)),
|
|
]
|
|
.map(normalizePath)
|
|
.filter(Boolean),
|
|
);
|
|
}
|
|
|
|
function sameFieldSchema(left, right) {
|
|
return (
|
|
left.name === right.name &&
|
|
left.type === right.type &&
|
|
left.attrs.join('\n') === right.attrs.join('\n')
|
|
);
|
|
}
|
|
|
|
function fieldDescription(field) {
|
|
return `${field.name}: ${field.type}`;
|
|
}
|
|
|
|
function compareTables(baseTables, currentTables) {
|
|
const failures = [];
|
|
let schemaChanged = false;
|
|
let breakingChanged = false;
|
|
|
|
for (const [accessor, baseTable] of baseTables) {
|
|
const currentTable = currentTables.get(accessor);
|
|
if (!currentTable) {
|
|
schemaChanged = true;
|
|
breakingChanged = true;
|
|
failures.push(
|
|
`${baseTable.path}:${baseTable.line}: SpacetimeDB 表 ${accessor} 被删除或改名。表删除/改名必须先询问用户并确认迁移计划。`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const currentFieldNames = new Set(
|
|
currentTable.fields.map((field) => field.name),
|
|
);
|
|
if (currentTable.fields.length < baseTable.fields.length) {
|
|
schemaChanged = true;
|
|
breakingChanged = true;
|
|
failures.push(
|
|
`${currentTable.path}:${currentTable.line}: SpacetimeDB 表 ${accessor} 字段数量减少。删除或改名字段必须先询问用户并确认迁移计划。`,
|
|
);
|
|
}
|
|
|
|
for (let index = 0; index < baseTable.fields.length; index += 1) {
|
|
const baseField = baseTable.fields[index];
|
|
const currentField = currentTable.fields[index];
|
|
|
|
if (!currentField) {
|
|
continue;
|
|
}
|
|
|
|
if (sameFieldSchema(baseField, currentField)) {
|
|
continue;
|
|
}
|
|
|
|
schemaChanged = true;
|
|
breakingChanged = true;
|
|
|
|
if (baseField.name !== currentField.name) {
|
|
const baseFieldStillExists = currentFieldNames.has(baseField.name);
|
|
const reason = baseFieldStillExists
|
|
? '字段顺序被调整'
|
|
: '字段被删除或改名';
|
|
failures.push(
|
|
`${currentTable.path}:${currentField.line}: SpacetimeDB 表 ${accessor} 的第 ${
|
|
index + 1
|
|
} 个字段从 ${baseField.name} 变为 ${currentField.name},疑似${reason}。只能在结构体最后追加新字段;改名必须先询问用户并确认迁移计划。`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
failures.push(
|
|
`${currentTable.path}:${currentField.line}: SpacetimeDB 表 ${accessor}.${currentField.name} 的 schema 从 ${fieldDescription(
|
|
baseField,
|
|
)} 变为 ${fieldDescription(currentField)}。修改已有字段类型或属性必须先询问用户并确认迁移计划。`,
|
|
);
|
|
}
|
|
|
|
if (currentTable.fields.length > baseTable.fields.length) {
|
|
schemaChanged = true;
|
|
if (!breakingChanged) {
|
|
const addedFields = currentTable.fields.slice(baseTable.fields.length);
|
|
for (const field of addedFields) {
|
|
if (!field.hasDefault) {
|
|
failures.push(
|
|
`${currentTable.path}:${field.line}: SpacetimeDB 表 ${accessor} 新增字段 ${field.name} 必须放在结构体最后并添加 #[default(...)]。当前字段位于末尾但缺少默认值。`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const accessor of currentTables.keys()) {
|
|
if (!baseTables.has(accessor)) {
|
|
schemaChanged = true;
|
|
}
|
|
}
|
|
|
|
return { failures, schemaChanged, breakingChanged };
|
|
}
|
|
|
|
function checkSchemaSidecars(changedFiles, schemaChanged) {
|
|
if (!schemaChanged) {
|
|
return [];
|
|
}
|
|
|
|
const failures = [];
|
|
if (!changedFiles.has(migrationPath)) {
|
|
failures.push(
|
|
`SpacetimeDB schema 已变化,但 ${migrationPath} 没有同步变更。row shape 或表变化必须同步迁移导入导出口径。`,
|
|
);
|
|
}
|
|
|
|
if (!changedFiles.has(tableCatalogPath)) {
|
|
failures.push(
|
|
`SpacetimeDB schema 已变化,但 ${tableCatalogPath} 没有同步变更。表结构目录必须跟源码一致。`,
|
|
);
|
|
}
|
|
|
|
const bindingsChanged = [...changedFiles].some((path) =>
|
|
path.startsWith(bindingsRoot),
|
|
);
|
|
if (!bindingsChanged) {
|
|
failures.push(
|
|
`SpacetimeDB schema 已变化,但 ${bindingsRoot} 下没有生成绑定变更。请重新生成并提交绑定。`,
|
|
);
|
|
}
|
|
|
|
return failures;
|
|
}
|
|
|
|
function main() {
|
|
let baseRef;
|
|
try {
|
|
baseRef = resolveBaseRef();
|
|
validateBaseRef(baseRef);
|
|
} catch (error) {
|
|
console.error(
|
|
`[check:spacetime-schema] ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
const currentSources = loadCurrentSources();
|
|
const baseSources = loadBaseSources(baseRef);
|
|
const currentResult = collectTablesFromSources(currentSources);
|
|
const baseResult = collectTablesFromSources(baseSources);
|
|
const compareResult = compareTables(baseResult.tables, currentResult.tables);
|
|
const changedFiles = getChangedFiles(baseRef);
|
|
const sidecarFailures = checkSchemaSidecars(
|
|
changedFiles,
|
|
compareResult.schemaChanged,
|
|
);
|
|
const compareFailures =
|
|
compareResult.breakingChanged && allowBreaking
|
|
? []
|
|
: compareResult.failures;
|
|
const failures = [
|
|
...currentResult.failures,
|
|
...baseResult.failures,
|
|
...compareFailures,
|
|
...sidecarFailures,
|
|
];
|
|
|
|
if (compareResult.breakingChanged && !allowBreaking) {
|
|
failures.push(
|
|
'检测到 SpacetimeDB 字段删除、改名、重排、类型或属性修改。请先询问用户并确认迁移计划;确认后如确需继续,可在人工确认的迁移提交中设置 SPACETIME_SCHEMA_GUARD_ALLOW_BREAKING=1 运行本检查。',
|
|
);
|
|
}
|
|
|
|
if (failures.length > 0) {
|
|
console.error(`SpacetimeDB schema guard failed against ${baseRef}:`);
|
|
for (const failure of failures) {
|
|
console.error(`- ${failure}`);
|
|
}
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log(
|
|
`SpacetimeDB schema guard passed for ${currentResult.tables.size} table(s) against ${baseRef}.`,
|
|
);
|
|
}
|
|
|
|
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
main();
|
|
}
|