import { execFileSync } from 'node:child_process'; import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; import { dirname, join, relative } 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 migrationPath = `${moduleSrcRoot}/migration.rs`; const tableCatalogPath = 'docs/technical/SPACETIMEDB_TABLE_CATALOG.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; } } function resolveBaseRef() { const explicitArgIndex = process.argv.indexOf('--base-ref'); if (explicitArgIndex >= 0 && process.argv[explicitArgIndex + 1]) { return process.argv[explicitArgIndex + 1]; } if (process.env.SPACETIME_SCHEMA_BASE_REF) { return process.env.SPACETIME_SCHEMA_BASE_REF; } const mergeBase = tryGit(['merge-base', 'HEAD', 'origin/master']); if (mergeBase) { return mergeBase; } const originMaster = tryGit(['rev-parse', '--verify', 'origin/master']); if (originMaster) { return originMaster; } return 'HEAD'; } function listCurrentRustFiles(dir) { const files = []; function walk(currentDir) { if (!existsSync(currentDir)) { return; } for (const name of readdirSync(currentDir)) { const fullPath = join(currentDir, name); const stat = statSync(fullPath); if (stat.isDirectory()) { walk(fullPath); continue; } if (name.endsWith('.rs')) { files.push(normalizePath(relative(repoRoot, fullPath))); } } } walk(join(repoRoot, dir)); return files.sort(); } function listBaseRustFiles(baseRef) { const output = tryGit(['ls-tree', '-r', '--name-only', baseRef, '--', moduleSrcRoot]); if (!output) { return []; } return output .split(/\r?\n/u) .map(normalizePath) .filter((path) => path.endsWith('.rs')) .sort(); } function readCurrentFile(path) { return readFileSync(join(repoRoot, path), 'utf8'); } function readBaseFile(baseRef, path) { const text = tryGit(['show', `${baseRef}:${path}`]); return text ?? ''; } 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; } 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 listCurrentRustFiles(moduleSrcRoot).map((path) => ({ path, text: readCurrentFile(path), })); } function loadBaseSources(baseRef) { return listBaseRustFiles(baseRef).map((path) => ({ path, text: readBaseFile(baseRef, path), })); } function getChangedFiles(baseRef) { const diffOutput = tryGit(['diff', '--name-only', baseRef, '--']) ?? ''; const untrackedOutput = tryGit(['ls-files', '--others', '--exclude-standard', moduleSrcRoot]) ?? ''; return new Set( [...diffOutput.split(/\r?\n/u), ...untrackedOutput.split(/\r?\n/u)] .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, table] of currentTables) { if (!baseTables.has(accessor)) { schemaChanged = true; failures.push( `${table.path}:${table.line}: 新增 SpacetimeDB 表 ${accessor}。请同步 migration.rs、表目录和生成绑定。`, ); } } 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() { const baseRef = resolveBaseRef(); 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 failures = [ ...currentResult.failures, ...baseResult.failures, ...compareResult.failures, ...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}.`, ); } main();