From 8ed0e6ffa867e5a5c354172706637bc62ae4cde8 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 21 Jul 2026 13:57:10 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20SpacetimeDB=20schema=20gua?= =?UTF-8?q?rd=20=E5=9F=BA=E7=BA=BF=E6=89=AB=E6=8F=8F=E8=AF=AF=E6=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一当前工作树与基线提交按 Cargo lib.path 扫描可达模块 补充保留旧源码误报与真实 accessor 重复回归测试 更新旧创作模板退役方案和项目排障记忆 --- docs/project-memory/shared-memory/pitfalls.md | 8 +++ ...构下线】旧创作模板业务退役方案-2026-07-17.md | 3 +- scripts/check-spacetime-schema-guard.mjs | 72 ++++++++++++------- scripts/check-spacetime-schema-guard.test.ts | 64 +++++++++++++++++ 4 files changed, 122 insertions(+), 25 deletions(-) create mode 100644 scripts/check-spacetime-schema-guard.test.ts diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index bf81fd136..dcdcd0fe2 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -3241,6 +3241,14 @@ - 处理:把仍在用的公共账号 / 钱包 / 设置能力迁到明确的现役 client 与 presentation model;Vite `pre` transform 对退役模块真实路径直接失败,ESLint 在现役源上增加 restricted imports。每次恢复公共 UI 后用 `tsc --listFilesOnly` 和全新浏览器 context 复核,不能用已有 HMR 会话判绿。 - 关联:`vite.config.ts`、`.eslintrc.cjs`、`src/services/platform-entry/`、`docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md`。 +## SpacetimeDB schema guard 的基线不能递归扫描保留源码 + +- 现象:旧业务按“数据壳保留、业务实现退役”落地后,`check:spacetime-schema` 报几十个 `legacy_schema` 与原路径 accessor 重复;同一提交对自身比较也失败,但 `cargo` 实际可以正常编译 module。 +- 原因:当前工作树按 `Cargo.toml [lib].path` 的 crate root 可达模块扫描,基线提交却通过 `git ls-tree -r` 扫描整个 `spacetime-module/src`。原 `src/lib.rs` 和旧业务源码只供追溯、不进入 active crate,但基线全目录扫描仍会把它们与 `#[path]` 引入的历史数据壳同时解析。 +- 处理:current 与 base 必须各自读取所在快照的 Cargo manifest,并沿各自 `mod` / `#[path]` 图扫描;base 文件存在性和内容从该 Git tree 读取,不能复用当前工作树。不要忽略 `legacy_schema`、删除历史源码或吞掉 base duplicate,因为历史数据壳正是正式 schema,真实可达重复仍须失败。 +- 验证:回归测试同时覆盖“不可达旧源码同 accessor 不报错”和“两个可达模块同 accessor 仍失败”;再运行 `npm run check:spacetime-schema -- --base-ref HEAD`,确认 self-base 按当前 136 张表通过。 +- 关联:`scripts/check-spacetime-schema-guard.mjs`、`scripts/check-spacetime-schema-guard.test.ts`、`server-rs/crates/spacetime-module/Cargo.toml`、`docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md`。 + ## VectorEngine 请求超时不能脱离 worker 绝对预算(2026-07-20) - 现象:VectorEngine 单次请求超时大于 worker job 执行预算时,worker 已停止续租,provider 才超时或开始重试;最终 lease 过期、任务失败并退款,上游却可能继续消耗资源或迟到成功。 diff --git a/docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md b/docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md index 722a2479e..de40cc649 100644 --- a/docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md +++ b/docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md @@ -1,6 +1,6 @@ # 旧创作模板业务退役方案 -更新时间:`2026-07-18` +更新时间:`2026-07-21` ## 目标 @@ -55,6 +55,7 @@ - 旧 `public_work_asset_read_grant` view 与十类旧作品授权计算退出 module;匿名资产读取只保留现役 editor showcase 授权。 - `spacetime-module` 与 `spacetime-client` 的 Cargo `lib.path` 固定指向各自的 `src/active.rs`;原 `src/lib.rs` 及旧业务源码继续原位保留,但不再作为 crate 根参与编译。 - 历史表最小定义集中在 `spacetime-module/src/legacy_schema/` 与 `spacetime-module/src/runtime/legacy_schema/`,混合 profile 表的在运数据壳位于 `spacetime-module/src/runtime/active/profile.rs`;这些目录只允许 schema 和必要兼容读取定义。 +- SpacetimeDB schema guard 比较当前工作树与基线提交时,两侧都必须分别读取各自 `Cargo.toml` 的 `lib.path`,再沿 `mod` / `#[path]` 只扫描该快照 crate root 可达的 schema;不得递归扫描整个 `src/`,否则原位保留的旧源码会与现役历史数据壳产生假 accessor 重复。 - `module-runtime` 仍是账号、钱包、公共设置、追踪和 feature gate 的现役领域 crate;其混合源码中的 `CreationEntry*`、旧公开作品、旧存档 / 浏览历史 / 游玩统计 DTO、command、mapper 和规则必须以编译条件退出,且不再依赖只为旧创作契约存在的 `shared-contracts`。历史 schema 只继续编译 `RuntimeBrowseHistoryThemeMode` 六个变体和完整保序的 `RuntimeProfileWalletLedgerSourceType` 等持久化 ABI,不保留围绕这些类型的旧业务实现。 - 纯模板 crate 和专属运行态 crate 不属于 workspace members、default members 或任何在运 crate 的依赖图;源码目录保持原样。 - `platform-agent` 及其专属 `langchainrust` 依赖同样退出 workspace 与 `api-server` 依赖图;现役编辑器 Agent 仅需的模型常量收口到 `platform-llm`,不再通过旧拼图 Phase 1 / Creative Agent 执行器 crate 复用。 diff --git a/scripts/check-spacetime-schema-guard.mjs b/scripts/check-spacetime-schema-guard.mjs index 618b0df9f..63dbbd9d4 100644 --- a/scripts/check-spacetime-schema-guard.mjs +++ b/scripts/check-spacetime-schema-guard.mjs @@ -55,8 +55,7 @@ function resolveBaseRef() { return 'HEAD'; } -function resolveCurrentCrateRoot() { - const manifest = readFileSync(join(repoRoot, moduleManifestPath), 'utf8'); +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( @@ -79,8 +78,9 @@ function childModuleDirectory(sourcePath, isCrateRoot) { return join(dirname(sourcePath), fileName.slice(0, -'.rs'.length)); } -function listReachableCurrentRustFiles() { - const crateRoot = resolveCurrentCrateRoot(); +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; @@ -91,13 +91,12 @@ function listReachableCurrentRustFiles() { continue; } - const absolutePath = join(repoRoot, current.path); - if (!existsSync(absolutePath)) { + const source = readSource(current.path); + if (source === null) { continue; } visited.add(current.path); - const source = readFileSync(absolutePath, 'utf8'); const defaultModuleDir = childModuleDirectory(current.path, current.isCrateRoot); let match; @@ -118,7 +117,7 @@ function listReachableCurrentRustFiles() { ]; const modulePath = candidates .map(normalizePath) - .find((candidate) => existsSync(join(repoRoot, candidate))); + .find((candidate) => readSource(candidate) !== null); if (modulePath && !visited.has(modulePath)) { pending.push({ path: modulePath, isCrateRoot: false }); @@ -129,26 +128,48 @@ function listReachableCurrentRustFiles() { return [...visited].sort(); } -function listBaseRustFiles(baseRef) { - const output = tryGit(['ls-tree', '-r', '--name-only', baseRef, '--', moduleSrcRoot]); +function listBaseSourcePaths(baseRef) { + const output = tryGit([ + 'ls-tree', + '-r', + '--name-only', + baseRef, + '--', + moduleManifestPath, + moduleSrcRoot, + ]); if (!output) { - return []; + return new Set(); } - return output - .split(/\r?\n/u) - .map(normalizePath) - .filter((path) => path.endsWith('.rs')) - .sort(); + 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) { - const text = tryGit(['show', `${baseRef}:${path}`]); - return text ?? ''; + 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) { @@ -486,7 +507,7 @@ function parseTablesFromFile(path, text) { return tables; } -function collectTablesFromSources(sources) { +export function collectTablesFromSources(sources) { const tables = new Map(); const failures = []; @@ -507,16 +528,17 @@ function collectTablesFromSources(sources) { } function loadCurrentSources() { - return listReachableCurrentRustFiles().map((path) => ({ + return listReachableRustFiles(readCurrentFile).map((path) => ({ path, - text: readCurrentFile(path), + text: readCurrentFile(path) ?? '', })); } function loadBaseSources(baseRef) { - return listBaseRustFiles(baseRef).map((path) => ({ + const readSource = createBaseSourceReader(baseRef); + return listReachableRustFiles(readSource).map((path) => ({ path, - text: readBaseFile(baseRef, path), + text: readSource(path) ?? '', })); } @@ -696,4 +718,6 @@ function main() { ); } -main(); +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main(); +} diff --git a/scripts/check-spacetime-schema-guard.test.ts b/scripts/check-spacetime-schema-guard.test.ts new file mode 100644 index 000000000..1ceb91956 --- /dev/null +++ b/scripts/check-spacetime-schema-guard.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; + +import { + collectTablesFromSources, + listReachableRustFiles, +} from './check-spacetime-schema-guard.mjs'; + +const manifestPath = 'server-rs/crates/spacetime-module/Cargo.toml'; +const sourceRoot = 'server-rs/crates/spacetime-module/src'; + +type SourceFiles = Record; + +function createSourceReader(files: SourceFiles) { + const sources = new Map(Object.entries(files)); + return (path: string) => sources.get(path) ?? null; +} + +function collectReachableTables(files: SourceFiles) { + const readSource = createSourceReader(files); + const paths = listReachableRustFiles(readSource); + return { + paths, + result: collectTablesFromSources( + paths.map((path: string) => ({ path, text: readSource(path) ?? '' })), + ), + }; +} + +function tableSource(structName: string, accessor: string) { + return `#[spacetimedb::table(accessor = ${accessor})]\npub struct ${structName} {\n pub id: u64,\n}\n`; +} + +describe('SpacetimeDB schema guard module reachability', () => { + it('ignores duplicate accessors in retained but unreachable legacy sources', () => { + const activePath = `${sourceRoot}/active.rs`; + const shellPath = `${sourceRoot}/legacy_schema/example.rs`; + const retiredPath = `${sourceRoot}/example.rs`; + const { paths, result } = collectReachableTables({ + [manifestPath]: '[lib]\npath = "src/active.rs"\n', + [activePath]: '#[path = "legacy_schema/example.rs"]\nmod example;\n', + [shellPath]: tableSource('ExampleSchemaShell', 'example'), + [retiredPath]: tableSource('RetiredExample', 'example'), + }); + + expect(paths).toEqual([activePath, shellPath]); + expect(result.failures).toEqual([]); + expect([...result.tables.keys()]).toEqual(['example']); + }); + + it('still rejects duplicate accessors when both definitions are reachable', () => { + const activePath = `${sourceRoot}/active.rs`; + const firstPath = `${sourceRoot}/first.rs`; + const secondPath = `${sourceRoot}/second.rs`; + const { result } = collectReachableTables({ + [manifestPath]: '[lib]\npath = "src/active.rs"\n', + [activePath]: 'mod first;\nmod second;\n', + [firstPath]: tableSource('FirstExample', 'example'), + [secondPath]: tableSource('SecondExample', 'example'), + }); + + expect(result.failures).toHaveLength(1); + expect(result.failures[0]).toMatch(/table accessor example 重复定义/u); + }); +});