From f31bb7e7e50e3b7cb2cc770fcd7212b2567df75d Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 15 May 2026 01:25:56 +0800 Subject: [PATCH] Add SpacetimeDB schema guard --- .hermes/shared-memory/development-workflow.md | 2 + .hermes/shared-memory/pitfalls.md | 4 +- AGENTS.md | 2 + .../SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md | 10 + package.json | 5 +- scripts/check-spacetime-schema-guard.mjs | 647 ++++++++++++++++++ 6 files changed, 666 insertions(+), 4 deletions(-) create mode 100644 scripts/check-spacetime-schema-guard.mjs diff --git a/.hermes/shared-memory/development-workflow.md b/.hermes/shared-memory/development-workflow.md index 5f73294a..2134f80b 100644 --- a/.hermes/shared-memory/development-workflow.md +++ b/.hermes/shared-memory/development-workflow.md @@ -159,6 +159,8 @@ npm run check:server-rs-ddd - 检查 `/healthz`。 - 执行对应自动测试。 - 涉及 SpacetimeDB 表、reducer、procedure、row shape 或绑定变化时,同步更新 `migration.rs`、表目录和生成绑定。 +- SpacetimeDB 已有表新增字段必须放在 Rust 表结构体最后,并设置明确默认值;需要修改字段名时,先询问用户并确认迁移计划,再同步更新 `server-rs/crates/spacetime-module/src/migration.rs`、表目录和生成绑定。 +- 修改 SpacetimeDB schema 后运行 `npm run check:spacetime-schema`,用自动检查拦截缺 default、插入中间、字段删除/改名/重排/改类型,以及漏改迁移、表目录或绑定。 关键文档: diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 17303bc9..1ae31a0d 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -250,8 +250,8 @@ - 现象:发布时 schema 冲突、自动迁移拒绝、旧客户端调用 reducer 失败、private 表数据迁移遗漏。 - 原因:SpacetimeDB 对字段删除、类型变化、索引/主键/RLS/reducer 变化有不同自动迁移边界。 -- 处理:变更前阅读 `SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md`;涉及表变化时同步 `migration.rs`、`SPACETIMEDB_TABLE_CATALOG.md` 和 bindings;必要时走 JSON 导入导出与分片导入迁移流程。 -- 验证:发布前完成 schema 检查、bindings 生成、表目录更新和相关 smoke。 +- 处理:变更前阅读 `SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md`;已有表新增字段必须放在 Rust 表结构体最后并设置明确默认值;需要修改字段名时,先询问用户并确认迁移计划;涉及表变化时同步 `migration.rs`、`SPACETIMEDB_TABLE_CATALOG.md` 和 bindings;必要时走 JSON 导入导出与分片导入迁移流程。 +- 验证:发布前运行 `npm run check:spacetime-schema`,完成 schema 检查、bindings 生成、表目录更新和相关 smoke。 - 关联:`docs/technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md`、`docs/technical/SPACETIMEDB_TABLE_CATALOG.md`。 ## SpacetimeDB publish 报 wasm-bindgen 时先查 shared-contracts feature diff --git a/AGENTS.md b/AGENTS.md index 4a13fb06..48d21424 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,6 +50,8 @@ Single-context layout: read root `CONTEXT.md` when present and architecture deci - 后端最新技术约束以 [`docs/technical/SERVER_RS_DDD_FULL_REFACTOR_2026-04-28.md`](docs/technical/SERVER_RS_DDD_FULL_REFACTOR_2026-04-28.md) 为总纲;执行和收口状态以 [`docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md`](docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md) 为准。 - 契约、路由、DTO 去留和 breaking change 以 [`docs/technical/SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md`](docs/technical/SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md) 为准;不得在前端、`api-server` 或临时兼容层中重新发明旧接口。 - SpacetimeDB 表结构、自动迁移限制和冲突处理以 [`docs/technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md`](docs/technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md) 为准;涉及 table、reducer、procedure、row shape 或绑定变化时,必须同步 `migration.rs`、表目录和生成绑定。 +- SpacetimeDB 已有表新增字段时,字段必须放在 Rust 表结构体最后,并设置明确默认值(例如 `#[default(...)]`);需要修改字段名时,必须先询问用户并确认迁移计划,再改代码,同时更新 `server-rs/crates/spacetime-module/src/migration.rs`、表目录和生成绑定。 +- 修改 SpacetimeDB schema 后必须运行 `npm run check:spacetime-schema`;该检查会拦截新增字段缺 default、字段不在末尾、字段删除/改名/重排/改类型,以及漏改 `migration.rs`、表目录或生成绑定。 - 后端路线固定为 `server-rs + Axum + SpacetimeDB`。旧 `server-node`、Express、PostgreSQL 不再作为兼容目标;历史实现只能作为迁移参考,若旧文档与 DDD 约束冲突,先修正文档和方案再编码。 - DDD 分层边界按总纲执行:领域规则沉到 `module-*`,SpacetimeDB 表和事务编排留在 `spacetime-module`,后端访问 SpacetimeDB 统一经 `spacetime-client` facade,HTTP/SSE/BFF 留在 `api-server`,外部副作用留在 `platform-*`,前后端 DTO 留在 `shared-contracts`。 - 前端只做表现、交互和临时 UI 状态,不承接正式业务真相,不绕过后端投影或后端 API 直接实现业务规则。 diff --git a/docs/technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md b/docs/technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md index 6a332482..6fdee664 100644 --- a/docs/technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md +++ b/docs/technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md @@ -58,6 +58,14 @@ host 会比较新模块声明的 schema 和旧数据库 schema,然后尝试自 4. 等数据迁移完成、旧客户端完成升级、旧表数据清空后,再移除旧表。 5. 开发环境可以使用 `--delete-data` 重建数据库,生产环境不要用它作为数据迁移方案。 +## 字段级硬性约束 + +- 对已有 SpacetimeDB 表新增字段时,必须把新字段追加在 Rust 表结构体的最后,不能插入已有字段中间。 +- 新增字段必须设置明确默认值,例如 Rust `#[default(...)]`;复杂集合默认值如果无法作为编译期常量表达,应优先使用 `Option` 加 `#[default(None::)]`,并在业务层归一化。 +- 修改已有字段名属于高风险 schema 变更。编码前必须先询问用户,确认旧字段名、新字段名、数据保留方式、客户端兼容窗口和发布顺序,并让用户准备或确认迁移计划。 +- 字段改名或任何 row shape 迁移都必须同步更新 `server-rs/crates/spacetime-module/src/migration.rs`、`SPACETIMEDB_TABLE_CATALOG.md` 和生成绑定;不要只改表结构体。 +- 修改 SpacetimeDB schema 后必须运行 `npm run check:spacetime-schema`。该检查会对比当前工作区与基准分支的 Rust table 字段,自动拦截新增字段缺 default、字段插入中间、字段删除/改名/重排/改类型,以及漏改 `migration.rs`、表目录或生成绑定。 + ## 通常安全的变更 这些变更一般可以自动迁移,并且通常不会破坏现有客户端: @@ -223,6 +231,8 @@ fn migrate_character_batch(ctx: &ReducerContext, limit: u32) { - 是否删除、改名、重排或修改了已有列。 - 新增列是否位于表定义末尾,并且是否有 default value。 +- 如需改字段名,是否已先询问用户并确认迁移计划,且已同步更新 `migration.rs`。 +- 是否已运行 `npm run check:spacetime-schema` 并通过。 - 是否给已有表新增了 unique 或 primary key 约束。 - 是否删除了非空表。 - 是否修改了 event table、schedule table、RLS 或 view。 diff --git a/package.json b/package.json index 0f77d5f0..ea50ba07 100644 --- a/package.json +++ b/package.json @@ -23,17 +23,18 @@ "preview": "node scripts/vite-cli.mjs preview", "clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"", "check:encoding": "node scripts/check-encoding.mjs", + "check:spacetime-schema": "node scripts/check-spacetime-schema-guard.mjs", "assets:child-motion-demo": "node scripts/generate-child-motion-demo-assets.mjs", "assets:match3d-style-references": "node scripts/generate-match3d-style-references.mjs", "check:visual-novel-vn11": "node scripts/check-visual-novel-vn11-negative-scan.mjs", "check:visual-novel-vn12": "node scripts/check-visual-novel-vn12-acceptance.mjs", "check:wechat-miniprogram-auth": "node scripts/check-wechat-miniprogram-auth-smoke.mjs", - "check:server-rs-ddd": "node scripts/check-server-rs-ddd-boundaries.mjs", + "check:server-rs-ddd": "npm run check:spacetime-schema && node scripts/check-server-rs-ddd-boundaries.mjs", "lint:eslint": "eslint . --ext .ts,.tsx,.js,.mjs,.cjs --max-warnings 0", "lint:guardrails": "npm run lint:eslint", "typecheck": "tsc -p tsconfig.typecheck-guardrails.json --noEmit", "typecheck:guardrails": "npm run typecheck", - "lint": "npm run check:encoding && npm run lint:eslint && npm run typecheck", + "lint": "npm run check:encoding && npm run check:spacetime-schema && npm run lint:eslint && npm run typecheck", "lint:fix": "eslint . --ext .ts,.tsx,.js,.mjs,.cjs --fix && prettier --write .", "format": "prettier --write .", "format:check": "prettier --check .", diff --git a/scripts/check-spacetime-schema-guard.mjs b/scripts/check-spacetime-schema-guard.mjs new file mode 100644 index 00000000..301ac967 --- /dev/null +++ b/scripts/check-spacetime-schema-guard.mjs @@ -0,0 +1,647 @@ +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();