diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 7c590d5ae..d992c339f 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -15,6 +15,15 @@ - 关联文档:相关 PRD、技术文档、提交或 Issue ``` +## 2026-09-02 旧玩法表采用两阶段退役清理 + +- 背景:旧创作模板的业务代码已退出现役编译链,但 SpacetimeDB 中的历史表仍需先完成数据清理;直接删除表定义会扩大 schema 迁移和客户端兼容风险。 +- 决策:阶段一只在 `spacetime-module/src/migration.rs` 增加受 `database_migration_operator` 保护的 `clear_retired_database_tables` procedure。procedure 使用固定的 63 张旧玩法表清单,不接受动态表名;`dry_run=true` 只返回逐表行数统计,`dry_run=false` 在同一事务内逐表清空,任一失败整体回滚。阶段一不删除表定义、不修改 `legacy_schema/**`、migration 导入导出白名单或生成 bindings。 +- 阶段边界:清理清单包含旧 gameplay、`custom_world`、Puzzle / Puzzle Clear、Bark Battle、Match3D、Jump Hop、Wooden Fish、Square Hole、Visual Novel 和 Big Fish 表;`runtime_setting`、`runtime_snapshot`、`user_browse_history`、`creation_entry_config` 等现役表明确排除。阶段二只有在备份、客户端兼容性和运行态确认完成后,才评估从 module 定义与 migration 白名单移除空表,并按 schema / bindings 流程发布;固定清单旁保留 TODO。 +- 影响范围:SpacetimeDB migration procedure、`spacetime-client` 生成 bindings、后端数据契约和本决策记录;禁止新增 SQL `DROP TABLE`、`--delete-data=always` 或直接写系统表的实现。 +- 验证方式:固定清单测试确认数量为 63 且不含现役表;本地数据库以已授权 operator 执行 dry-run,确认 63 张表均返回 0 行且未写入;apply 的单事务回滚由 procedure 实现,实际 apply 仅在另行授权的维护窗口执行。另运行 bindings 生成、SpacetimeDB schema / runtime 检查、编码和 diff 门禁。 +- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`server-rs/crates/spacetime-module/src/migration.rs`。 + ## 2026-08-31 DirectProject 客户端扩展按独立 Skill/MCP 导入 - 背景:DirectProject 需要使用用户在 AGC 客户端导入的市面原生 Skill、MCP 和 Plugin 内容,但第三方内容不应直接安装到运行时 Codex,也不应要求用户转换为 AGC 自定义格式。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index b54869a1d..ed9d7019a 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -186,7 +186,8 @@ npm run check:server-rs-ddd 8. 多列索引按 SpacetimeDB 绑定生成的元组参数直接传入,例如 `.filter((source_type, profile_id, played_day))`;前缀查询只传前缀元组,例如 `.filter((scope_kind, scope_id.as_str()))`。不要为了绕过类型问题退回整表遍历。 9. procedure result 必须返回 typed snapshot / typed value。`spacetime-client` mapper 不得再通过 `row_json/session_json/work_json/items_json/run_json/event_json/feedback_json: Option` 做跨层 JSON 字符串传输,也不得在 mapper 里反序列化旧 `*JsonRecord` 兼容结构。业务内部持久化字段如 `profile_payload_json`、`levels_json` 等不属于 procedure result 载荷例外,仍按各自表契约处理。 10. procedure 需要按调用者 identity 鉴权时,必须先从外层 `ProcedureContext::sender()` 捕获 caller,再把 caller 显式传入 `try_with_tx` 闭包内的事务函数。当前 workspace 锁定 SpacetimeDB `2.8.3`;鉴权边界不得依赖事务上下文的隐式 sender 语义,即使 SDK 升级也继续保持显式 caller 参数。 -11. 修改后运行: +11. 旧创作模板历史表采用两阶段退役:阶段一只允许 migration operator 调用固定范围的 `clear_retired_database_tables` procedure 清空 63 张旧玩法表,输入仅有 `dry_run`;`dry_run=true` 只返回逐表行数统计,`dry_run=false` 在单一事务内逐表删除全部行,任一失败整体回滚。清单必须在 `spacetime-module/src/migration.rs` 中静态维护,不接受调用方传入任意表名;`runtime_setting`、`runtime_snapshot`、`user_browse_history`、`creation_entry_config` 等现役表不属于清理范围。阶段一保留所有旧表定义、`legacy_schema/**`、migration 导入导出白名单和生成 bindings,不执行 `DROP TABLE`、`--delete-data=always` 或系统表写入。阶段二待备份、客户端兼容性和运行态确认完成后,另行评估从 module 定义与 migration 白名单移除空表,并同步 schema / bindings;当前在固定清单旁保留 TODO,不提前改动表定义。 +12. 修改后运行: ```bash npm run spacetime:generate diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index f6c9c3b9d..c00777cfa 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -178,6 +178,7 @@ pub mod claim_profile_recharge_order_expiration_schedule_and_return_procedure; pub mod claim_profile_task_reward_and_return_procedure; pub mod clean_editor_image_asset_kind_and_return_procedure; pub mod clear_database_migration_import_chunks_procedure; +pub mod clear_retired_database_tables_procedure; pub mod close_profile_recharge_order_and_return_procedure; pub mod combat_outcome_type; pub mod compact_external_generation_job_payloads_and_return_procedure; @@ -223,6 +224,9 @@ pub mod custom_world_session_table; pub mod custom_world_session_type; pub mod custom_world_theme_mode_type; pub mod database_migration_authorize_operator_input_type; +pub mod database_migration_clear_retired_tables_input_type; +pub mod database_migration_clear_retired_tables_result_type; +pub mod database_migration_clear_table_stat_type; pub mod database_migration_export_input_type; pub mod database_migration_import_chunk_input_type; pub mod database_migration_import_chunk_table; @@ -1052,6 +1056,7 @@ pub use claim_profile_recharge_order_expiration_schedule_and_return_procedure::c pub use claim_profile_task_reward_and_return_procedure::claim_profile_task_reward_and_return; pub use clean_editor_image_asset_kind_and_return_procedure::clean_editor_image_asset_kind_and_return; pub use clear_database_migration_import_chunks_procedure::clear_database_migration_import_chunks; +pub use clear_retired_database_tables_procedure::clear_retired_database_tables; pub use close_profile_recharge_order_and_return_procedure::close_profile_recharge_order_and_return; pub use combat_outcome_type::CombatOutcome; pub use compact_external_generation_job_payloads_and_return_procedure::compact_external_generation_job_payloads_and_return; @@ -1097,6 +1102,9 @@ pub use custom_world_session_table::*; pub use custom_world_session_type::CustomWorldSession; pub use custom_world_theme_mode_type::CustomWorldThemeMode; pub use database_migration_authorize_operator_input_type::DatabaseMigrationAuthorizeOperatorInput; +pub use database_migration_clear_retired_tables_input_type::DatabaseMigrationClearRetiredTablesInput; +pub use database_migration_clear_retired_tables_result_type::DatabaseMigrationClearRetiredTablesResult; +pub use database_migration_clear_table_stat_type::DatabaseMigrationClearTableStat; pub use database_migration_export_input_type::DatabaseMigrationExportInput; pub use database_migration_import_chunk_input_type::DatabaseMigrationImportChunkInput; pub use database_migration_import_chunk_table::*; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/clear_retired_database_tables_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/clear_retired_database_tables_procedure.rs new file mode 100644 index 000000000..3d7e1b5d6 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/clear_retired_database_tables_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::database_migration_clear_retired_tables_input_type::DatabaseMigrationClearRetiredTablesInput; +use super::database_migration_clear_retired_tables_result_type::DatabaseMigrationClearRetiredTablesResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ClearRetiredDatabaseTablesArgs { + pub input: DatabaseMigrationClearRetiredTablesInput, +} + +impl __sdk::InModule for ClearRetiredDatabaseTablesArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `clear_retired_database_tables`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait clear_retired_database_tables { + fn clear_retired_database_tables(&self, input: DatabaseMigrationClearRetiredTablesInput) { + self.clear_retired_database_tables_then(input, |_, _| {}); + } + + fn clear_retired_database_tables_then( + &self, + input: DatabaseMigrationClearRetiredTablesInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl clear_retired_database_tables for super::RemoteProcedures { + fn clear_retired_database_tables_then( + &self, + input: DatabaseMigrationClearRetiredTablesInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, DatabaseMigrationClearRetiredTablesResult>( + "clear_retired_database_tables", + ClearRetiredDatabaseTablesArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/database_migration_clear_retired_tables_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/database_migration_clear_retired_tables_input_type.rs new file mode 100644 index 000000000..0e8efa42f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/database_migration_clear_retired_tables_input_type.rs @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct DatabaseMigrationClearRetiredTablesInput { + pub dry_run: bool, +} + +impl __sdk::InModule for DatabaseMigrationClearRetiredTablesInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/database_migration_clear_retired_tables_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/database_migration_clear_retired_tables_result_type.rs new file mode 100644 index 000000000..2507b80d2 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/database_migration_clear_retired_tables_result_type.rs @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::database_migration_clear_table_stat_type::DatabaseMigrationClearTableStat; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct DatabaseMigrationClearRetiredTablesResult { + pub ok: bool, + pub dry_run: bool, + pub table_stats: Vec, + pub error_message: Option, +} + +impl __sdk::InModule for DatabaseMigrationClearRetiredTablesResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/database_migration_clear_table_stat_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/database_migration_clear_table_stat_type.rs new file mode 100644 index 000000000..2b83ab354 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/database_migration_clear_table_stat_type.rs @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct DatabaseMigrationClearTableStat { + pub table_name: String, + pub row_count_before: u64, + pub cleared_row_count: u64, +} + +impl __sdk::InModule for DatabaseMigrationClearTableStat { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index 32e02063c..a99acec78 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -361,6 +361,163 @@ macro_rules! clear_migration_table { }; } +// 阶段一固定清空清单:只覆盖已经退役的玩法表,不接受调用方动态指定表名。 +// TODO(phase 2): 完成数据备份、客户端兼容性和运行态确认后,再从 active.rs、legacy_schema、 +// migration 白名单和生成绑定中移除这些表定义;本阶段只清空行,不改变 schema。 +macro_rules! retired_migration_tables { + ($macro_name:ident $(, $arg:expr)*) => { + $macro_name! { + $($arg,)* + 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, + } + }; +} + +macro_rules! retired_migration_table_name_list { + ($($table:ident),+ $(,)?) => { + &[$(stringify!($table)),+] + }; +} + +const RETIRED_MIGRATION_TABLE_NAMES: &[&str] = + retired_migration_tables!(retired_migration_table_name_list); + +macro_rules! clear_retired_migration_table { + ($ctx:expr, $dry_run:expr, $stats:expr, $($table:ident),+ $(,)?) => { + $( + let rows = $ctx.db.$table().iter().collect::>(); + let row_count_before = rows.len() as u64; + if !$dry_run { + for row in rows { + $ctx.db.$table().delete(row); + } + } + $stats.push(DatabaseMigrationClearTableStat { + table_name: stringify!($table).to_string(), + row_count_before, + cleared_row_count: if $dry_run { 0 } else { row_count_before }, + }); + )+ + }; +} + +fn clear_retired_database_tables_tx( + ctx: &ReducerContext, + dry_run: bool, +) -> Vec { + let mut stats = Vec::with_capacity(RETIRED_MIGRATION_TABLE_NAMES.len()); + retired_migration_tables!(clear_retired_migration_table, ctx, dry_run, stats); + stats +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct DatabaseMigrationClearRetiredTablesInput { + pub dry_run: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct DatabaseMigrationClearTableStat { + pub table_name: String, + pub row_count_before: u64, + pub cleared_row_count: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct DatabaseMigrationClearRetiredTablesResult { + pub ok: bool, + pub dry_run: bool, + pub table_stats: Vec, + pub error_message: Option, +} + +#[spacetimedb::procedure] +pub fn clear_retired_database_tables( + ctx: &mut ProcedureContext, + input: DatabaseMigrationClearRetiredTablesInput, +) -> DatabaseMigrationClearRetiredTablesResult { + let dry_run = input.dry_run; + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + require_migration_operator(tx, caller)?; + Ok::<_, String>(clear_retired_database_tables_tx(tx, dry_run)) + }) { + Ok(table_stats) => DatabaseMigrationClearRetiredTablesResult { + ok: true, + dry_run, + table_stats, + error_message: None, + }, + Err(error) => DatabaseMigrationClearRetiredTablesResult { + ok: false, + dry_run, + table_stats: Vec::new(), + error_message: Some(error), + }, + } +} + // 迁移权限独立存表,避免把 private 表导出能力开放给任意登录身份。 #[spacetimedb::procedure] pub fn authorize_database_migration_operator( @@ -1713,6 +1870,23 @@ fn is_supported_migration_table(table_name: &str) -> bool { mod migration_bootstrap_secret_tests { use super::*; + #[test] + fn retired_migration_table_list_is_fixed_and_excludes_active_tables() { + assert_eq!(RETIRED_MIGRATION_TABLE_NAMES.len(), 63); + let mut unique_names = RETIRED_MIGRATION_TABLE_NAMES.to_vec(); + unique_names.sort_unstable(); + unique_names.dedup(); + assert_eq!(unique_names.len(), RETIRED_MIGRATION_TABLE_NAMES.len()); + for active_table in [ + "runtime_setting", + "runtime_snapshot", + "user_browse_history", + "creation_entry_config", + ] { + assert!(!RETIRED_MIGRATION_TABLE_NAMES.contains(&active_table)); + } + } + #[test] fn old_profile_redeem_code_rows_default_to_open_validity_window() { let normalized = normalize_migration_row(