Add SpacetimeDB schema guard
This commit is contained in:
@@ -159,6 +159,8 @@ npm run check:server-rs-ddd
|
|||||||
- 检查 `/healthz`。
|
- 检查 `/healthz`。
|
||||||
- 执行对应自动测试。
|
- 执行对应自动测试。
|
||||||
- 涉及 SpacetimeDB 表、reducer、procedure、row shape 或绑定变化时,同步更新 `migration.rs`、表目录和生成绑定。
|
- 涉及 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、插入中间、字段删除/改名/重排/改类型,以及漏改迁移、表目录或绑定。
|
||||||
|
|
||||||
关键文档:
|
关键文档:
|
||||||
|
|
||||||
|
|||||||
@@ -250,8 +250,8 @@
|
|||||||
|
|
||||||
- 现象:发布时 schema 冲突、自动迁移拒绝、旧客户端调用 reducer 失败、private 表数据迁移遗漏。
|
- 现象:发布时 schema 冲突、自动迁移拒绝、旧客户端调用 reducer 失败、private 表数据迁移遗漏。
|
||||||
- 原因:SpacetimeDB 对字段删除、类型变化、索引/主键/RLS/reducer 变化有不同自动迁移边界。
|
- 原因:SpacetimeDB 对字段删除、类型变化、索引/主键/RLS/reducer 变化有不同自动迁移边界。
|
||||||
- 处理:变更前阅读 `SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md`;涉及表变化时同步 `migration.rs`、`SPACETIMEDB_TABLE_CATALOG.md` 和 bindings;必要时走 JSON 导入导出与分片导入迁移流程。
|
- 处理:变更前阅读 `SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md`;已有表新增字段必须放在 Rust 表结构体最后并设置明确默认值;需要修改字段名时,先询问用户并确认迁移计划;涉及表变化时同步 `migration.rs`、`SPACETIMEDB_TABLE_CATALOG.md` 和 bindings;必要时走 JSON 导入导出与分片导入迁移流程。
|
||||||
- 验证:发布前完成 schema 检查、bindings 生成、表目录更新和相关 smoke。
|
- 验证:发布前运行 `npm run check:spacetime-schema`,完成 schema 检查、bindings 生成、表目录更新和相关 smoke。
|
||||||
- 关联:`docs/technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md`、`docs/technical/SPACETIMEDB_TABLE_CATALOG.md`。
|
- 关联:`docs/technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md`、`docs/technical/SPACETIMEDB_TABLE_CATALOG.md`。
|
||||||
|
|
||||||
## SpacetimeDB publish 报 wasm-bindgen 时先查 shared-contracts feature
|
## SpacetimeDB publish 报 wasm-bindgen 时先查 shared-contracts feature
|
||||||
|
|||||||
@@ -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) 为准。
|
- 后端最新技术约束以 [`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` 或临时兼容层中重新发明旧接口。
|
- 契约、路由、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 表结构、自动迁移限制和冲突处理以 [`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 约束冲突,先修正文档和方案再编码。
|
- 后端路线固定为 `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`。
|
- DDD 分层边界按总纲执行:领域规则沉到 `module-*`,SpacetimeDB 表和事务编排留在 `spacetime-module`,后端访问 SpacetimeDB 统一经 `spacetime-client` facade,HTTP/SSE/BFF 留在 `api-server`,外部副作用留在 `platform-*`,前后端 DTO 留在 `shared-contracts`。
|
||||||
- 前端只做表现、交互和临时 UI 状态,不承接正式业务真相,不绕过后端投影或后端 API 直接实现业务规则。
|
- 前端只做表现、交互和临时 UI 状态,不承接正式业务真相,不绕过后端投影或后端 API 直接实现业务规则。
|
||||||
|
|||||||
@@ -58,6 +58,14 @@ host 会比较新模块声明的 schema 和旧数据库 schema,然后尝试自
|
|||||||
4. 等数据迁移完成、旧客户端完成升级、旧表数据清空后,再移除旧表。
|
4. 等数据迁移完成、旧客户端完成升级、旧表数据清空后,再移除旧表。
|
||||||
5. 开发环境可以使用 `--delete-data` 重建数据库,生产环境不要用它作为数据迁移方案。
|
5. 开发环境可以使用 `--delete-data` 重建数据库,生产环境不要用它作为数据迁移方案。
|
||||||
|
|
||||||
|
## 字段级硬性约束
|
||||||
|
|
||||||
|
- 对已有 SpacetimeDB 表新增字段时,必须把新字段追加在 Rust 表结构体的最后,不能插入已有字段中间。
|
||||||
|
- 新增字段必须设置明确默认值,例如 Rust `#[default(...)]`;复杂集合默认值如果无法作为编译期常量表达,应优先使用 `Option<T>` 加 `#[default(None::<T>)]`,并在业务层归一化。
|
||||||
|
- 修改已有字段名属于高风险 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。
|
- 新增列是否位于表定义末尾,并且是否有 default value。
|
||||||
|
- 如需改字段名,是否已先询问用户并确认迁移计划,且已同步更新 `migration.rs`。
|
||||||
|
- 是否已运行 `npm run check:spacetime-schema` 并通过。
|
||||||
- 是否给已有表新增了 unique 或 primary key 约束。
|
- 是否给已有表新增了 unique 或 primary key 约束。
|
||||||
- 是否删除了非空表。
|
- 是否删除了非空表。
|
||||||
- 是否修改了 event table、schedule table、RLS 或 view。
|
- 是否修改了 event table、schedule table、RLS 或 view。
|
||||||
|
|||||||
@@ -23,17 +23,18 @@
|
|||||||
"preview": "node scripts/vite-cli.mjs preview",
|
"preview": "node scripts/vite-cli.mjs preview",
|
||||||
"clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"",
|
"clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"",
|
||||||
"check:encoding": "node scripts/check-encoding.mjs",
|
"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:child-motion-demo": "node scripts/generate-child-motion-demo-assets.mjs",
|
||||||
"assets:match3d-style-references": "node scripts/generate-match3d-style-references.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-vn11": "node scripts/check-visual-novel-vn11-negative-scan.mjs",
|
||||||
"check:visual-novel-vn12": "node scripts/check-visual-novel-vn12-acceptance.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: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:eslint": "eslint . --ext .ts,.tsx,.js,.mjs,.cjs --max-warnings 0",
|
||||||
"lint:guardrails": "npm run lint:eslint",
|
"lint:guardrails": "npm run lint:eslint",
|
||||||
"typecheck": "tsc -p tsconfig.typecheck-guardrails.json --noEmit",
|
"typecheck": "tsc -p tsconfig.typecheck-guardrails.json --noEmit",
|
||||||
"typecheck:guardrails": "npm run typecheck",
|
"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 .",
|
"lint:fix": "eslint . --ext .ts,.tsx,.js,.mjs,.cjs --fix && prettier --write .",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"format:check": "prettier --check .",
|
"format:check": "prettier --check .",
|
||||||
|
|||||||
647
scripts/check-spacetime-schema-guard.mjs
Normal file
647
scripts/check-spacetime-schema-guard.mjs
Normal file
@@ -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();
|
||||||
Reference in New Issue
Block a user