合并最新master的历史表清理与聊天优化
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled

同步master历史表清理、生成绑定和客户端优化,保留埋点实现。
This commit is contained in:
2026-09-22 12:49:42 +00:00
407 changed files with 1738 additions and 28808 deletions
+78 -1
View File
@@ -12,6 +12,80 @@ 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';
// 本次历史表退役的一次性迁移白名单。该清单只允许这些已确认表从基线消失;
// 待删除结果进入后续主线基线后必须删除本清单,其他表仍按既有破坏性变更路径失败。
export const APPROVED_RETIRED_TABLE_DELETIONS = Object.freeze([
'player_progression',
'chapter_progression',
'npc_state',
'story_session',
'story_event',
'inventory_slot',
'battle_state',
'treasure_record',
'quest_record',
'quest_log',
'custom_world_profile',
'custom_world_session',
'custom_world_agent_session',
'custom_world_agent_message',
'custom_world_agent_operation',
'custom_world_draft_card',
'custom_world_gallery_entry',
'puzzle_agent_session',
'puzzle_background_compile_task',
'puzzle_agent_message',
'puzzle_work_profile',
'puzzle_event',
'puzzle_runtime_run',
'puzzle_leaderboard_entry',
'puzzle_clear_agent_session',
'puzzle_clear_work_profile',
'puzzle_clear_runtime_run',
'puzzle_clear_event',
'bark_battle_draft_config',
'bark_battle_published_config',
'bark_battle_runtime_run',
'bark_battle_score_record',
'bark_battle_leaderboard_entry',
'bark_battle_work_stats_projection',
'bark_battle_personal_best_projection',
'match3d_agent_session',
'match3d_agent_message',
'match_3_d_work_profile',
'match3d_runtime_run',
'jump_hop_agent_session',
'jump_hop_work_profile',
'jump_hop_runtime_run',
'jump_hop_event',
'jump_hop_leaderboard_entry',
'wooden_fish_agent_session',
'wooden_fish_work_profile',
'wooden_fish_runtime_run',
'wooden_fish_event',
'square_hole_agent_session',
'square_hole_agent_message',
'square_hole_work_profile',
'square_hole_runtime_run',
'visual_novel_agent_session',
'visual_novel_agent_message',
'visual_novel_work_profile',
'visual_novel_runtime_run',
'visual_novel_runtime_history_entry',
'visual_novel_runtime_event',
'big_fish_creation_session',
'big_fish_agent_message',
'big_fish_asset_slot',
'big_fish_runtime_run',
'big_fish_event',
]);
const approvedRetiredTableDeletions = new Set(APPROVED_RETIRED_TABLE_DELETIONS);
export function isApprovedRetiredTableDeletion(accessor) {
return approvedRetiredTableDeletions.has(accessor);
}
function normalizePath(path) {
return path.replace(/\\/gu, '/');
}
@@ -639,7 +713,7 @@ function fieldDescription(field) {
return `${field.name}: ${field.type}`;
}
function compareTables(baseTables, currentTables) {
export function compareTables(baseTables, currentTables) {
const failures = [];
let schemaChanged = false;
let breakingChanged = false;
@@ -648,6 +722,9 @@ function compareTables(baseTables, currentTables) {
const currentTable = currentTables.get(accessor);
if (!currentTable) {
schemaChanged = true;
if (isApprovedRetiredTableDeletion(accessor)) {
continue;
}
breakingChanged = true;
failures.push(
`${baseTable.path}:${baseTable.line}: SpacetimeDB 表 ${accessor} 被删除或改名。表删除/改名必须先询问用户并确认迁移计划。`,
@@ -1,7 +1,9 @@
import { describe, expect, it } from 'vitest';
import {
APPROVED_RETIRED_TABLE_DELETIONS,
collectTablesFromSources,
compareTables,
isFormalGateEnvironment,
listReachableRustFiles,
resolveBaseRef,
@@ -36,6 +38,15 @@ function createGitResolver(entries: Record<string, string | null>) {
return (args: string[]) => entries[args.join(' ')] ?? null;
}
function collectTables(accessors: string[]) {
return collectTablesFromSources(
accessors.map((accessor, index) => ({
path: `${sourceRoot}/schema.rs`,
text: tableSource(`Table${index}`, accessor),
})),
).tables;
}
describe('SpacetimeDB schema guard base resolution', () => {
it('prefers an explicit Jenkins-provided base ref', () => {
expect(
@@ -125,3 +136,61 @@ describe('SpacetimeDB schema guard module reachability', () => {
expect(result.failures[0]).toMatch(/table accessor example /u);
});
});
describe('SpacetimeDB schema guard retired table deletion allowlist', () => {
it('allows exactly the 63 approved historical tables to disappear', () => {
expect(APPROVED_RETIRED_TABLE_DELETIONS).toHaveLength(63);
expect(new Set(APPROVED_RETIRED_TABLE_DELETIONS).size).toBe(63);
const result = compareTables(
collectTables([...APPROVED_RETIRED_TABLE_DELETIONS]),
new Map(),
);
expect(result.failures).toEqual([]);
expect(result.schemaChanged).toBe(true);
expect(result.breakingChanged).toBe(false);
});
it('still rejects deletion of a table outside the approved list', () => {
const result = compareTables(
collectTables([APPROVED_RETIRED_TABLE_DELETIONS[0], 'active_table']),
new Map(),
);
expect(result.failures).toHaveLength(1);
expect(result.failures[0]).toMatch(/active_table.*/u);
expect(result.schemaChanged).toBe(true);
expect(result.breakingChanged).toBe(true);
});
it('does not suppress retained-table field deletion or rename checks', () => {
const baseTables = collectTablesFromSources([
{
path: `${sourceRoot}/ranks.rs`,
text: `#[spacetimedb::table(accessor = retained_deleted_field)]\npub struct RetainedDeletedField {\n pub id: u64,\n pub title: String,\n}\n`,
},
{
path: `${sourceRoot}/titles.rs`,
text: `#[spacetimedb::table(accessor = retained_renamed_field)]\npub struct RetainedRenamedField {\n pub id: u64,\n pub title: String,\n}\n`,
},
]).tables;
const currentTables = collectTablesFromSources([
{
path: `${sourceRoot}/ranks.rs`,
text: `#[spacetimedb::table(accessor = retained_deleted_field)]\npub struct RetainedDeletedField {\n pub id: u64,\n}\n`,
},
{
path: `${sourceRoot}/titles.rs`,
text: `#[spacetimedb::table(accessor = retained_renamed_field)]\npub struct RetainedRenamedField {\n pub id: u64,\n pub name: String,\n}\n`,
},
]).tables;
const result = compareTables(baseTables, currentTables);
expect(result.failures).toHaveLength(2);
expect(result.failures.join('\n')).toMatch(//u);
expect(result.failures.join('\n')).toMatch(//u);
expect(result.breakingChanged).toBe(true);
});
});
-202
View File
@@ -1,202 +0,0 @@
#!/usr/bin/env node
import { readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
export const DEFAULT_ORPHAN_WORK_OWNER_USER_ID = 'wx-openid-placeholder';
export const WORK_OWNER_TABLES = [
'custom_world_profile',
'custom_world_gallery_entry',
'custom_world_session',
'custom_world_agent_session',
'custom_world_draft_card',
'puzzle_agent_session',
'puzzle_work_profile',
'bark_battle_draft_config',
'bark_battle_published_config',
'match3d_agent_session',
'match3d_work_profile',
'jump_hop_agent_session',
'jump_hop_work_profile',
'wooden_fish_agent_session',
'wooden_fish_work_profile',
'square_hole_agent_session',
'square_hole_work_profile',
'visual_novel_agent_session',
'visual_novel_work_profile',
'big_fish_creation_session',
];
const ROW_KEY_FIELDS = [
'profile_id',
'work_id',
'session_id',
'draft_id',
'gallery_entry_id',
'id',
];
if (isCliEntry()) {
runCli(process.argv.slice(2)).catch((error) => {
console.error(
`[rebind-orphan-work-owners] ${error instanceof Error ? error.message : String(error)}`,
);
process.exit(1);
});
}
export function rebindOrphanWorkOwnersInMigration(
migration,
{
placeholderUserId = DEFAULT_ORPHAN_WORK_OWNER_USER_ID,
validUserIds = [],
} = {},
) {
if (!migration || !Array.isArray(migration.tables)) {
throw new Error('迁移 JSON 必须包含 tables 数组。');
}
const normalizedPlaceholderUserId = placeholderUserId.trim();
const validUserIdSet = new Set(
(Array.isArray(validUserIds) ? validUserIds : [])
.map((value) => String(value).trim())
.filter(Boolean),
);
validUserIdSet.add(normalizedPlaceholderUserId);
const reboundRows = [];
for (const table of migration.tables) {
if (
!table ||
!WORK_OWNER_TABLES.includes(table.name) ||
!Array.isArray(table.rows)
) {
continue;
}
for (const row of table.rows) {
if (!row || typeof row !== 'object') {
continue;
}
const currentOwner =
typeof row.owner_user_id === 'string' ? row.owner_user_id.trim() : '';
if (
currentOwner === normalizedPlaceholderUserId ||
validUserIdSet.has(currentOwner)
) {
continue;
}
const originalOwner =
typeof row.owner_user_id === 'string' ? row.owner_user_id : '';
row.owner_user_id = normalizedPlaceholderUserId;
reboundRows.push({
table: table.name,
rowKey: resolveRowKey(row),
from: originalOwner,
to: normalizedPlaceholderUserId,
});
}
}
return { reboundRows, validUserCount: validUserIdSet.size };
}
function resolveRowKey(row) {
for (const field of ROW_KEY_FIELDS) {
const value = row[field];
if (typeof value === 'string' && value.trim()) {
return value;
}
}
return '<unknown>';
}
async function runCli(argv) {
const options = parseCliArgs(argv);
const inputPath = path.resolve(options.in);
const outputPath = path.resolve(options.out);
const migration = JSON.parse(await readFile(inputPath, 'utf8'));
const result = rebindOrphanWorkOwnersInMigration(migration, {
placeholderUserId: options.placeholderUserId,
validUserIds: collectValidUserIds(migration),
});
if (!options.dryRun) {
await writeFile(
outputPath,
`${JSON.stringify(migration, null, 2)}\n`,
'utf8',
);
}
console.log(
`[rebind-orphan-work-owners] ${options.dryRun ? 'dry-run' : `已写入 ${outputPath}`},回填 ${result.reboundRows.length}`,
);
}
function parseCliArgs(argv) {
const options = {
in: '',
out: '',
placeholderUserId: DEFAULT_ORPHAN_WORK_OWNER_USER_ID,
dryRun: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
const readValue = (name) => {
const value = argv[index + 1];
if (!value || value.startsWith('--')) {
throw new Error(`${name} 缺少参数值。`);
}
index += 1;
return value;
};
if (arg === '--in') {
options.in = readValue(arg);
} else if (arg === '--out') {
options.out = readValue(arg);
} else if (arg === '--placeholder-user-id') {
options.placeholderUserId = readValue(arg);
} else if (arg === '--dry-run') {
options.dryRun = true;
} else {
throw new Error(`未知参数: ${arg}`);
}
}
if (!options.in) {
throw new Error('必须传入 --in。');
}
if (!options.out && !options.dryRun) {
throw new Error('非 dry-run 必须传入 --out。');
}
return options;
}
function collectValidUserIds(migration) {
const result = new Set();
for (const table of migration.tables ?? []) {
if (!table || !Array.isArray(table.rows)) {
continue;
}
if (table.name === 'user_account') {
for (const row of table.rows) {
if (typeof row?.user_id === 'string' && row.user_id.trim()) {
result.add(row.user_id.trim());
}
}
}
}
return result;
}
function isCliEntry() {
const entry = process.argv[1];
return entry
? import.meta.url === `file://${entry.replace(/\\/gu, '/')}`
: false;
}
-60
View File
@@ -1,60 +0,0 @@
import { describe, expect, it } from 'vitest';
import { rebindOrphanWorkOwnersInMigration } from './rebind-orphan-work-owners.mjs';
const placeholderUserId = 'wx-openid-placeholder';
function table(name, rows) {
return { name, rows };
}
describe('rebindOrphanWorkOwnersInMigration', () => {
it('把作品表里认证表不存在的 owner_user_id 回填到占位用户', () => {
const migration = {
schema_version: 1,
exported_at_micros: 1,
tables: [
table('user_account', [
{ user_id: 'user_alive' },
{ user_id: placeholderUserId },
]),
table('puzzle_work_profile', [
{ profile_id: 'p1', owner_user_id: 'user_missing' },
{ profile_id: 'p2', owner_user_id: 'user_alive' },
{ profile_id: 'p3', owner_user_id: placeholderUserId },
]),
table('puzzle_agent_session', [
{ session_id: 'draft-1', owner_user_id: '' },
]),
table('tracking_event', [
{ event_id: 't1', owner_user_id: 'user_missing' },
]),
],
};
const result = rebindOrphanWorkOwnersInMigration(migration, {
placeholderUserId,
validUserIds: ['user_alive'],
});
expect(result.reboundRows).toEqual([
{
table: 'puzzle_work_profile',
rowKey: 'p1',
from: 'user_missing',
to: placeholderUserId,
},
{
table: 'puzzle_agent_session',
rowKey: 'draft-1',
from: '',
to: placeholderUserId,
},
]);
expect(migration.tables[1].rows[0].owner_user_id).toBe(placeholderUserId);
expect(migration.tables[1].rows[1].owner_user_id).toBe('user_alive');
expect(migration.tables[1].rows[2].owner_user_id).toBe(placeholderUserId);
expect(migration.tables[2].rows[0].owner_user_id).toBe(placeholderUserId);
expect(migration.tables[3].rows[0].owner_user_id).toBe('user_missing');
});
});