Merge branch 'master' of http://82.157.175.59:3000/GenarrativeAI/Genarrative
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-29 20:57:09 +08:00
10 changed files with 360 additions and 26 deletions

View File

@@ -13,12 +13,14 @@
重点补充RPG 创作与运行时脚本职责地图见 [RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md](./reference/RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md)。 重点补充RPG 创作与运行时脚本职责地图见 [RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md](./reference/RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md)。
- [PRD](./prd):产品需求与阶段计划;新增 RPG 开场动画方案见 [AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md](./prd/AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md)。 - [PRD](./prd):产品需求与阶段计划;新增 RPG 开场动画方案见 [AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md](./prd/AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md)。
SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md)。
## 推荐阅读顺序 ## 推荐阅读顺序
1. 先看 [经验沉淀](./experience/README.md),快速建立这个项目的开发共识。 1. 先看 [经验沉淀](./experience/README.md),快速建立这个项目的开发共识。
2. 再看 [工程审查总览](./audits/engineering/README.md) 和 [文本审计总览](./audits/text/README.md),了解当前风险。 2. 再看 [工程审查总览](./audits/engineering/README.md) 和 [文本审计总览](./audits/text/README.md),了解当前风险。
3. 需要排期时看 [规划与优先级](./planning/README.md)。 3. 需要排期时看 [规划与优先级](./planning/README.md)。
4. 需要补方案时进入 [系统设计](./design/README.md) / [技术方案](./technical/README.md);涉及后端先看 [当前后端实现基线](./technical/CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md)。 4. 需要补方案时进入 [系统设计](./design/README.md) / [技术方案](./technical/README.md);涉及后端先看 [当前后端实现基线](./technical/CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md),涉及 SpacetimeDB 表结构变更时再看 [表结构变更约束](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md)
5. 需要对齐目标边界时再进入 [PRD](./prd)。 5. 需要对齐目标边界时再进入 [PRD](./prd)。
## 分类规则 ## 分类规则

View File

@@ -41,10 +41,11 @@ Genarrative-Database-Export
1. `DATABASE`:目标 SpacetimeDB 数据库名;留空时读取仓库环境变量。 1. `DATABASE`:目标 SpacetimeDB 数据库名;留空时读取仓库环境变量。
2. `SERVER`SpacetimeDB server 别名,默认 `maincloud` 2. `SERVER`SpacetimeDB server 别名,默认 `maincloud`
3. `SERVER_URL`:显式服务地址;填写后优先于 `SERVER` 3. `SERVER_URL`:显式服务地址;填写后优先于 `SERVER`
4. `ROOT_DIR`:可选,透传给 `spacetime --root-dir` 4. `DEPLOY_DIRECTORY`:固定部署目录,默认 `/var/lib/jenkins/deploy/Genarrative`
5. `INCLUDE_TABLES`:可选,逗号分隔的表名白名单 5. `ROOT_DIR`:可选,透传给 `spacetime --root-dir`;为空时使用 `<DEPLOY_DIRECTORY>/.spacetimedb`
6. `OUTPUT_DIRECTORY`:导出文件目录,默认 `database-exports` 6. `INCLUDE_TABLES`:可选,逗号分隔的表名白名单
7. `EXPORT_NAME`:导出文件名;留空时使用 `spacetime-migration-<BUILD_NUMBER>.json` 7. `OUTPUT_DIRECTORY`:导出文件目录,默认 `database-exports`
8. `EXPORT_NAME`:导出文件名;留空时使用 `spacetime-migration-<BUILD_NUMBER>.json`
导出成功后Jenkins 归档: 导出成功后Jenkins 归档:
@@ -69,7 +70,7 @@ Genarrative-Database-Import
关键参数: 关键参数:
1. `INPUT_FILE`:必填,迁移 JSON 文件路径。 1. `INPUT_FILE`:必填,迁移 JSON 文件路径。
2. `DATABASE``SERVER``SERVER_URL``ROOT_DIR`:与导出流水线一致。 2. `DATABASE``SERVER``SERVER_URL``DEPLOY_DIRECTORY``ROOT_DIR`:与导出流水线一致。
3. `INCLUDE_TABLES`:可选,只导入指定表。 3. `INCLUDE_TABLES`:可选,只导入指定表。
4. `DRY_RUN`:默认 `true`,只校验不写入。 4. `DRY_RUN`:默认 `true`,只校验不写入。
5. `INCREMENTAL`:默认 `true`,跳过已存在或冲突的行。 5. `INCREMENTAL`:默认 `true`,跳过已存在或冲突的行。
@@ -85,7 +86,27 @@ Genarrative-Database-Import
3. Jenkinsfile 不打印 token生产环境应通过 Jenkins 凭据或目标机器环境变量传入敏感值。 3. Jenkinsfile 不打印 token生产环境应通过 Jenkins 凭据或目标机器环境变量传入敏感值。
4. 如果不传 `TOKEN`,导入脚本会创建临时 Web API identity并调用迁移授权/撤销 procedure 收敛权限窗口。 4. 如果不传 `TOKEN`,导入脚本会创建临时 Web API identity并调用迁移授权/撤销 procedure 收敛权限窗口。
## 5. 文件清单 ## 5. 本地部署测试参数
`Genarrative-Build-And-Deploy` 增加以下本地发布包参数,便于在 Jenkins 中测试本地 SpacetimeDB不依赖 Maincloud
1. `DATABASE`:发布包默认数据库名,默认 `genarrative_pipeline_local_test`
2. `API_PORT`:发布包内 api-server 端口,默认 `8082`
3. `WEB_PORT`:发布包内静态网站端口,默认 `25001`
4. `SPACETIME_PORT`:发布包内本地 SpacetimeDB 端口,默认 `3101`
5. `DEPLOY_DIRECTORY`:固定部署目录,继续透传给 `Genarrative-Deploy`
数据库导入导出流水线在本地测试时应显式填写:
```text
DATABASE=genarrative_pipeline_local_test
SERVER_URL=http://127.0.0.1:3101
DEPLOY_DIRECTORY=/var/lib/jenkins/deploy/Genarrative
```
这样脚本会自动使用 `/var/lib/jenkins/deploy/Genarrative/.spacetimedb` 作为 `spacetime --root-dir`,避免回退到 Jenkins 用户全局 CLI 登录态,也避免误连 Maincloud。
## 6. 文件清单
```text ```text
jenkins/Jenkinsfile.database-export jenkins/Jenkinsfile.database-export

View File

@@ -4,13 +4,7 @@
## 文档列表 ## 文档列表
- [RPG_HOME_CUSTOM_WORLD_LIBRARY_TIMEOUT_FIX_2026-04-29.md](./RPG_HOME_CUSTOM_WORLD_LIBRARY_TIMEOUT_FIX_2026-04-29.md):记录首页 `custom-world-library` 首屏列表 SpacetimeDB procedure 超时的根因,冻结列表读模型轻量化与 procedure 等待窗口配置化的修复口径 - [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md):冻结 SpacetimeDB 表结构变更约束、自动迁移可接受范围、冲突后的系统行为,以及保留旧数据的增量迁移流程;凡涉及 `spacetime publish`、表字段调整或 `migration.rs` 对齐时优先参考
- [PRODUCT_NAMING_TAONI_RENAME_2026-04-29.md](./PRODUCT_NAMING_TAONI_RENAME_2026-04-29.md):记录本轮产品中文名调整为“陶泥”,以及陶泥币、陶泥号、陶泥主三类对外称谓替换的落地边界。
- [PUZZLE_FORM_CREATION_FLOW_2026-04-29.md](./PUZZLE_FORM_CREATION_FLOW_2026-04-29.md):记录拼图创作入口从 Agent 对话改为标题与画面描述填表、参考图直达首图生成,以及结果页合并为单列表的落地边界。
- [PUZZLE_IMAGE_AND_RUNTIME_9_16_ALIGNMENT_2026-04-29.md](./PUZZLE_IMAGE_AND_RUNTIME_9_16_ALIGNMENT_2026-04-29.md):记录拼图生成图片、结果页预览、历史素材缩略和运行时棋盘统一为 9:16 竖屏的落地边界。
- [RPG_NPC_BATTLE_ENTRY_QUEST_AND_TARGET_FIX_2026-04-29.md](./RPG_NPC_BATTLE_ENTRY_QUEST_AND_TARGET_FIX_2026-04-29.md):记录 NPC 进入战斗时不再自动补章节任务、pending 委托不被误接取,以及战斗目标缺少 encounter 时仍可渲染的修复边界。
- [RPG_RUNTIME_PANEL_CLOSE_BUTTON_FIX_2026-04-29.md](./RPG_RUNTIME_PANEL_CLOSE_BUTTON_FIX_2026-04-29.md):记录 RPG 运行态历史手写弹窗右上关闭按钮点击失效的统一修复边界,收口像素风关闭按钮的事件传播、层级和点击面积。
- [RPG_RUNTIME_PARTY_INVENTORY_PANEL_UI_SIMPLIFICATION_2026-04-29.md](./RPG_RUNTIME_PARTY_INVENTORY_PANEL_UI_SIMPLIFICATION_2026-04-29.md):记录 RPG 运行态队伍面板删除成员列表上方任务信息、背包面板删除顶部旅程回顾的展示边界,保持辅助面板首屏聚焦成员与物品。
- [RPG_PROMPT_FRONTEND_REMOVAL_AND_SERVER_RS_MIGRATION_2026-04-28.md](./RPG_PROMPT_FRONTEND_REMOVAL_AND_SERVER_RS_MIGRATION_2026-04-28.md):冻结 RPG 提示词禁止存在前端的边界,明确前端只保留 API client角色私聊/NPC 对话/剧情续写等 prompt 统一收口到 `server-rs` - [RPG_PROMPT_FRONTEND_REMOVAL_AND_SERVER_RS_MIGRATION_2026-04-28.md](./RPG_PROMPT_FRONTEND_REMOVAL_AND_SERVER_RS_MIGRATION_2026-04-28.md):冻结 RPG 提示词禁止存在前端的边界,明确前端只保留 API client角色私聊/NPC 对话/剧情续写等 prompt 统一收口到 `server-rs`
- [RPG_CREATION_RESULT_VIEW_BACKEND_TRUTH_MIGRATION_2026-04-28.md](./RPG_CREATION_RESULT_VIEW_BACKEND_TRUTH_MIGRATION_2026-04-28.md):冻结 RPG 创作结果页保存、Agent session/result preview 真相优先级和结果页入口裁决迁移到后端 result-view 的落地边界。 - [RPG_CREATION_RESULT_VIEW_BACKEND_TRUTH_MIGRATION_2026-04-28.md](./RPG_CREATION_RESULT_VIEW_BACKEND_TRUTH_MIGRATION_2026-04-28.md):冻结 RPG 创作结果页保存、Agent session/result preview 真相优先级和结果页入口裁决迁移到后端 result-view 的落地边界。
- [RPG_CREATION_PROFILE_GENERATION_BACKEND_MIGRATION_2026-04-28.md](./RPG_CREATION_PROFILE_GENERATION_BACKEND_MIGRATION_2026-04-28.md):记录 RPG 创作 profile 生成移除非浏览器 legacy AI 回退,统一通过 `server-rs``/api/runtime/custom-world/profile` 生成世界底稿。 - [RPG_CREATION_PROFILE_GENERATION_BACKEND_MIGRATION_2026-04-28.md](./RPG_CREATION_PROFILE_GENERATION_BACKEND_MIGRATION_2026-04-28.md):记录 RPG 创作 profile 生成移除非浏览器 legacy AI 回退,统一通过 `server-rs``/api/runtime/custom-world/profile` 生成世界底稿。
@@ -196,5 +190,5 @@
## 使用建议 ## 使用建议
- 做实现选型时,优先看这一组。 - 做实现选型时,优先看这一组。
- 做后端实现前,先看 `CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md`,再进入具体 Rust / SpacetimeDB 方案。 - 做后端实现前,先看 `CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md`;涉及 SpacetimeDB 表结构、发布或迁移时,再看 `SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md`,最后进入具体 Rust / SpacetimeDB 方案。
- 做阶段排期时,把这一组和 `docs/planning/``docs/prd/` 一起看,更容易判断先后顺序。 - 做阶段排期时,把这一组和 `docs/planning/``docs/prd/` 一起看,更容易判断先后顺序。

View File

@@ -0,0 +1,235 @@
# SpacetimeDB 表结构变更约束
本文档总结 SpacetimeDB 开发过程中修改表结构时需要遵守的约束,以及发生迁移冲突后如何在保留旧数据的前提下手动迁移。
## 背景
当对已有数据库重新执行 `spacetime publish`SpacetimeDB 会尝试根据旧 module schema 和新 module schema 生成自动迁移计划。当前实现的重点是自动 schema migration不是通用的脚本式数据迁移框架。
相关实现入口:
- 迁移计划生成:`crates/schema/src/auto_migrate.rs`
- 迁移执行:`crates/core/src/db/update.rs`
- 发布前预检查:`crates/client-api/src/routes/database.rs`
- CLI 发布确认逻辑:`crates/cli/src/subcommands/publish.rs`
## 与 PostgreSQL 迁移模型的差别
PostgreSQL 的常见迁移模型是脚本驱动的 DDL migration。开发者显式写出每一步迁移动作例如
```sql
ALTER TABLE users DROP COLUMN old_name;
CREATE INDEX CONCURRENTLY idx_users_email ON users(email);
DROP TABLE old_table;
```
也就是说PG 里能做什么主要取决于 PostgreSQL DDL 能力、锁风险、现有数据是否满足约束,以及 migration 工具如何组织执行。
SpacetimeDB 的迁移模型不同。schema 来自模块代码里的 `#[table]`、reducers 和类型定义。开发者修改模块代码后执行:
```sh
spacetime publish <DATABASE_NAME>
```
host 会比较新模块声明的 schema 和旧数据库 schema然后尝试自动迁移。自动迁移能力是有限的SpacetimeDB 更像是“模块代码声明世界publish 时数据库尝试跟上,但只走安全路径”。
对常见变更,可以按下面方式理解:
| 变更 | PostgreSQL | SpacetimeDB |
| --- | --- | --- |
| 加表 | `CREATE TABLE` migration | 自动允许 |
| 删表 | `DROP TABLE`,生产需谨慎 | 高风险。当前代码只有限支持删除空表;非空表会失败 |
| 加字段 | `ALTER TABLE ADD COLUMN`,可带默认值 | 只允许加在表定义末尾,并且必须有 default例如 Rust `#[default(...)]` |
| 删字段 | `ALTER TABLE DROP COLUMN` | 自动迁移禁止 |
| 改字段类型、重命名、调顺序 | 可用 DDL 或拆成多步 migration | 自动迁移通常禁止 |
| 加普通索引 | `CREATE INDEX` / `CREATE INDEX CONCURRENTLY` | 自动允许 |
| 删索引 | `DROP INDEX` | 允许,但可能破坏某些 subscription query |
| 加唯一约束或主键 | 可先清理数据再加 | 对已有表自动迁移禁止 |
| 删除唯一约束 | 可做 | 自动允许移除 `#[unique]` |
| 删除主键注解 | 可做 | 允许,但可能破坏旧客户端缓存行为 |
一句话概括PG 迁移更自由,但风险由开发者和 migration 流程管理SpacetimeDB 迁移更保守,系统只自动接受它能证明相对安全、客户端不容易坏的 schema 演进。
## 总体原则
1. 对已有表做变更时,应优先选择向后兼容的增量式变更。
2. 不要直接删除、改名、重排或重定义已有列。
3. 对复杂结构变更,应新增目标表,通过 reducer 或后台批处理把旧数据搬到新表。
4. 等数据迁移完成、旧客户端完成升级、旧表数据清空后,再移除旧表。
5. 开发环境可以使用 `--delete-data` 重建数据库,生产环境不要用它作为数据迁移方案。
## 通常安全的变更
这些变更一般可以自动迁移,并且通常不会破坏现有客户端:
- 新增表。
- 新增索引。
- 添加或移除 auto-increment/sequence 类注解,但新增 sequence 会有额外数据范围预检查。
- 将表从 private 改为 public。
- 新增 reducer。
- 移除 unique constraint。
- 新增 view或在兼容范围内更新 view。
## 可能破坏客户端但可确认后发布的变更
这些变更可以生成自动迁移计划但可能使旧客户端不兼容。CLI 会要求用户确认,并通过 `BreakClients` policy 携带 token 继续发布。
- 给已有表末尾新增带 default 的列,例如 Rust `#[default(...)]`
- 删除空表。
- 删除或改变 view 的返回列、参数、上下文等不兼容部分。
- 将 public 表改为 private。
- 移除 primary key 注解。
- 移除索引,尤其是旧客户端订阅查询依赖该索引时。
- RLS 规则增加、删除或变化。
- 删除或修改 reducer使旧客户端继续调用旧 reducer 时失败。
## 会触发冲突或拒绝自动迁移的变更
以下变更通常会在自动迁移规划阶段失败,服务端返回 `AutoMigrateError`CLI 表现为需要 manual migration
- 删除已有列。
- 重命名已有列。
- 重排已有列。
- 在已有表中间新增列。
- 给已有表新增没有 default value 的列。
- 修改已有列类型,除非是布局兼容的受限变更。
- 修改 product/sum 类型时减少字段或 variant。
- 修改 product/sum 类型时重命名字段或 variant。
- 修改类型导致 layout size 或 alignment 不兼容。
- 给已有表新增 unique constraint。
- 修改已有 unique constraint。
- 修改 table type。
- 修改 event flag。
- 修改 index accessor name。
## 规划通过但执行时仍会失败的情况
有些变更可以生成迁移计划,但执行阶段仍可能失败:
- 删除非空表:当前代码会生成 `RemoveTable`,但执行时会检查 row count。只有空表可以删除非空表会失败并回滚。
- schedule 相关变更:规划层可能生成 `AddSchedule``RemoveSchedule`,但执行层目前仍返回 not implemented。
- 新增或修改 sequence 时,如果已有数据落在新 sequence 的分配范围内,预检查会失败。
- 新增索引、RLS、view 重算等操作如果底层校验或执行失败,也会导致本次更新回滚。
## 冲突后的系统行为
发生冲突时SpacetimeDB 默认不会自动修改旧数据,也不会执行用户自定义迁移脚本。
常见结果:
- 发布前预检查发现冲突CLI 打印原因并中止发布。
- 服务端迁移规划失败API 返回 `400 Database update rejected: ...`
- 迁移执行中失败:当前事务 rollback旧数据库和旧 module 继续运行。
- 只有显式使用 `--delete-data``--delete-data=on-conflict` 时,才会清空旧数据并用新 module 重建数据库。
## 保留旧数据的手动迁移方式
生产环境推荐使用增量迁移,而不是直接修改旧表结构。
建议步骤:
1. 保留旧表。
不要直接删除或修改旧表的关键字段。例如保留 `character`
2. 新增目标表。
新建 `character_v2`,结构定义为目标 schema。新增表属于自动迁移支持范围。
3. 发布兼容中间版本。
中间版本同时包含旧表和新表,并添加迁移 helper/reducer
- 读数据时,优先读新表。
- 新表没有对应行时,从旧表读取,转换后写入新表。
- 写数据时,优先写新表。
- 如果仍需兼容旧客户端,同步写旧表。
- 大量历史数据通过 `migrate_batch(limit)` 之类的 reducer 分批迁移。
4. 验证迁移完成。
检查新表行数、主键覆盖、关键字段一致性和客户端升级情况。
5. 清空旧表数据。
通过 reducer 分批删除旧表数据。因为当前实现只允许删除空表。
6. 删除旧表定义。
旧表为空后,再从 schema 中移除旧表并发布最终版本。
## 增量迁移示例
下面示例展示从旧表 `character` 迁移到新表 `character_v2` 的基本模式:
```rust
fn get_character(ctx: &ReducerContext, player: Identity) -> CharacterV2 {
if let Some(row) = ctx.db.character_v2().player_id().find(player) {
return row;
}
let old = ctx
.db
.character()
.player_id()
.find(player)
.expect("character not found");
let migrated = CharacterV2 {
player_id: old.player_id,
nickname: old.nickname,
level: old.level,
class: old.class,
alliance: Alliance::Neutral,
};
ctx.db.character_v2().insert(migrated.clone());
migrated
}
```
批量迁移时,应限制每次处理的行数,避免单个事务过大:
```rust
#[spacetimedb::reducer]
fn migrate_character_batch(ctx: &ReducerContext, limit: u32) {
let mut migrated = 0;
for old in ctx.db.character().iter() {
if migrated >= limit {
break;
}
if ctx.db.character_v2().player_id().find(old.player_id).is_some() {
continue;
}
ctx.db.character_v2().insert(CharacterV2 {
player_id: old.player_id,
nickname: old.nickname,
level: old.level,
class: old.class,
alliance: Alliance::Neutral,
});
migrated += 1;
}
}
```
## 发布前检查清单
发布涉及表结构变更的 module 前,至少检查:
- 是否删除、改名、重排或修改了已有列。
- 新增列是否位于表定义末尾,并且是否有 default value。
- 是否给已有表新增了 unique 或 primary key 约束。
- 是否删除了非空表。
- 是否修改了 event table、schedule table、RLS 或 view。
- 是否会破坏旧客户端订阅、reducer 调用或本地缓存假设。
- 是否需要先发布中间版本做增量迁移。
- 是否已在 staging 数据库用真实规模样本测试过发布。
## 结论
SpacetimeDB 的表结构变更策略应以“新增、兼容、分阶段”为核心。实际开发时应尽量只做 additive change加表、加索引、在末尾加带默认值的字段。遇到自动迁移冲突时不要直接清库或强行修改旧表应保留旧数据新增目标 schema通过 reducer 分批迁移数据,逐步切换客户端,最后在旧数据清空后移除旧表。

View File

@@ -10,7 +10,10 @@ pipeline {
string(name: 'AGENT_LABEL', defaultValue: 'built-in', description: '构建节点标签') string(name: 'AGENT_LABEL', defaultValue: 'built-in', description: '构建节点标签')
string(name: 'GENARRATIVE_WORKSPACE_ROOT', defaultValue: '', description: '源码根目录,留空则使用当前 Jenkins 工作区') string(name: 'GENARRATIVE_WORKSPACE_ROOT', defaultValue: '', description: '源码根目录,留空则使用当前 Jenkins 工作区')
string(name: 'BUILD_VERSION', defaultValue: '', description: '发布版本号,留空则使用 Jenkins BUILD_NUMBER') string(name: 'BUILD_VERSION', defaultValue: '', description: '发布版本号,留空则使用 Jenkins BUILD_NUMBER')
string(name: 'DATABASE', defaultValue: 'genarrative_pipeline_local_test', description: '发布包默认 SpacetimeDB database')
string(name: 'API_PORT', defaultValue: '8082', description: '发布包内 api-server 端口')
string(name: 'WEB_PORT', defaultValue: '25001', description: '发布包内静态网站端口,默认 25001') string(name: 'WEB_PORT', defaultValue: '25001', description: '发布包内静态网站端口,默认 25001')
string(name: 'SPACETIME_PORT', defaultValue: '3101', description: '发布包内本地 SpacetimeDB 端口')
booleanParam(name: 'CLEAR_DATABASE', defaultValue: false, description: '部署时是否清空 SpacetimeDB 数据后再发布 wasm') booleanParam(name: 'CLEAR_DATABASE', defaultValue: false, description: '部署时是否清空 SpacetimeDB 数据后再发布 wasm')
booleanParam(name: 'RUN_NPM_CI', defaultValue: false, description: '构建前是否执行 npm ci') booleanParam(name: 'RUN_NPM_CI', defaultValue: false, description: '构建前是否执行 npm ci')
string(name: 'DEPLOY_JOB_NAME', defaultValue: 'Genarrative-Deploy', description: '部署流水线作业名') string(name: 'DEPLOY_JOB_NAME', defaultValue: 'Genarrative-Deploy', description: '部署流水线作业名')
@@ -30,6 +33,29 @@ pipeline {
env.EFFECTIVE_BUILD_VERSION = params.BUILD_VERSION?.trim() ? params.BUILD_VERSION.trim() : env.BUILD_NUMBER env.EFFECTIVE_BUILD_VERSION = params.BUILD_VERSION?.trim() ? params.BUILD_VERSION.trim() : env.BUILD_NUMBER
// 允许 Jenkins Job 直接指定固定源码目录,未指定时回退到当前工作区。 // 允许 Jenkins Job 直接指定固定源码目录,未指定时回退到当前工作区。
env.WORKSPACE_ROOT = params.GENARRATIVE_WORKSPACE_ROOT?.trim() ? params.GENARRATIVE_WORKSPACE_ROOT.trim() : pwd() env.WORKSPACE_ROOT = params.GENARRATIVE_WORKSPACE_ROOT?.trim() ? params.GENARRATIVE_WORKSPACE_ROOT.trim() : pwd()
def database = params.DATABASE?.trim()
if (!database) {
error('DATABASE 不能为空。')
}
if (!(database ==~ /^[0-9A-Za-z._-]+$/)) {
error("DATABASE 只能包含数字、字母、点、下划线和短横线,当前值: ${database}")
}
env.EFFECTIVE_DATABASE = database
def apiPort = params.API_PORT?.trim()
if (!apiPort) {
error('API_PORT 不能为空。')
}
if (!(apiPort ==~ /^[0-9]+$/)) {
error("API_PORT 必须是数字端口,当前值: ${apiPort}")
}
if (apiPort.length() > 5) {
error("API_PORT 必须在 1-65535 之间,当前值: ${apiPort}")
}
def parsedApiPort = apiPort.toInteger()
if (parsedApiPort < 1 || parsedApiPort > 65535) {
error("API_PORT 必须在 1-65535 之间,当前值: ${apiPort}")
}
env.EFFECTIVE_API_PORT = apiPort
def webPort = params.WEB_PORT?.trim() def webPort = params.WEB_PORT?.trim()
if (!webPort) { if (!webPort) {
error('WEB_PORT 不能为空。') error('WEB_PORT 不能为空。')
@@ -46,6 +72,21 @@ pipeline {
} }
// 后续构建与下游部署都使用校验后的同一端口值,避免参数空格导致上下游不一致。 // 后续构建与下游部署都使用校验后的同一端口值,避免参数空格导致上下游不一致。
env.EFFECTIVE_WEB_PORT = webPort env.EFFECTIVE_WEB_PORT = webPort
def spacetimePort = params.SPACETIME_PORT?.trim()
if (!spacetimePort) {
error('SPACETIME_PORT 不能为空。')
}
if (!(spacetimePort ==~ /^[0-9]+$/)) {
error("SPACETIME_PORT 必须是数字端口,当前值: ${spacetimePort}")
}
if (spacetimePort.length() > 5) {
error("SPACETIME_PORT 必须在 1-65535 之间,当前值: ${spacetimePort}")
}
def parsedSpacetimePort = spacetimePort.toInteger()
if (parsedSpacetimePort < 1 || parsedSpacetimePort > 65535) {
error("SPACETIME_PORT 必须在 1-65535 之间,当前值: ${spacetimePort}")
}
env.EFFECTIVE_SPACETIME_PORT = spacetimePort
// 记录当前构建节点名,部署阶段必须回到同一节点读取本地 build 目录。 // 记录当前构建节点名,部署阶段必须回到同一节点读取本地 build 目录。
env.SOURCE_NODE_NAME = env.NODE_NAME env.SOURCE_NODE_NAME = env.NODE_NAME
} }
@@ -60,6 +101,7 @@ pipeline {
# 这里不使用 -x避免删除 node_modules 等忽略目录后与 RUN_NPM_CI=false 的配置冲突。 # 这里不使用 -x避免删除 node_modules 等忽略目录后与 RUN_NPM_CI=false 的配置冲突。
git reset --hard HEAD git reset --hard HEAD
git clean -fd git clean -fd
rm -rf "build/${EFFECTIVE_BUILD_VERSION}"
' '
''' '''
@@ -73,8 +115,13 @@ pipeline {
sh """ sh """
bash -lc ' bash -lc '
set -euo pipefail set -euo pipefail
# 构建并部署流水线显式透传 Web 端口,确保部署包默认监听 25001同时允许 Jenkins 参数覆盖 # 构建并部署流水线显式透传本地测试参数,避免发布包回退到默认库名或端口
npm run deploy:rust:remote -- --skip-upload --name "${env.EFFECTIVE_BUILD_VERSION}" --web-port "${env.EFFECTIVE_WEB_PORT}" npm run deploy:rust:remote -- --skip-upload \
--name "${env.EFFECTIVE_BUILD_VERSION}" \
--database "${env.EFFECTIVE_DATABASE}" \
--api-port "${env.EFFECTIVE_API_PORT}" \
--web-port "${env.EFFECTIVE_WEB_PORT}" \
--spacetime-port "${env.EFFECTIVE_SPACETIME_PORT}"
test -d "build/${env.EFFECTIVE_BUILD_VERSION}" test -d "build/${env.EFFECTIVE_BUILD_VERSION}"
' '
""" """

View File

@@ -12,8 +12,10 @@ pipeline {
string(name: 'DATABASE', defaultValue: '', description: 'SpacetimeDB 数据库名,留空则读取环境变量') string(name: 'DATABASE', defaultValue: '', description: 'SpacetimeDB 数据库名,留空则读取环境变量')
string(name: 'SERVER', defaultValue: 'maincloud', description: 'SpacetimeDB server 别名,例如 maincloud/local/dev') string(name: 'SERVER', defaultValue: 'maincloud', description: 'SpacetimeDB server 别名,例如 maincloud/local/dev')
string(name: 'SERVER_URL', defaultValue: '', description: 'SpacetimeDB server URL填写后优先于 SERVER') string(name: 'SERVER_URL', defaultValue: '', description: 'SpacetimeDB server URL填写后优先于 SERVER')
string(name: 'ROOT_DIR', defaultValue: '', description: 'spacetime CLI root-dir可选') string(name: 'DEPLOY_DIRECTORY', defaultValue: '/var/lib/jenkins/deploy/Genarrative', description: '固定部署目录ROOT_DIR 为空时使用其 .spacetimedb')
string(name: 'ROOT_DIR', defaultValue: '', description: 'spacetime CLI root-dir可选优先于 DEPLOY_DIRECTORY')
string(name: 'INCLUDE_TABLES', defaultValue: '', description: '可选,逗号分隔的表名白名单') string(name: 'INCLUDE_TABLES', defaultValue: '', description: '可选,逗号分隔的表名白名单')
string(name: 'BOOTSTRAP_SECRET', defaultValue: '', description: '可选,授权临时导出 identity 的迁移引导密钥')
string(name: 'OUTPUT_DIRECTORY', defaultValue: 'database-exports', description: '导出文件目录,相对源码根目录或绝对路径') string(name: 'OUTPUT_DIRECTORY', defaultValue: 'database-exports', description: '导出文件目录,相对源码根目录或绝对路径')
string(name: 'EXPORT_NAME', defaultValue: '', description: '导出文件名,留空则自动使用构建号') string(name: 'EXPORT_NAME', defaultValue: '', description: '导出文件名,留空则自动使用构建号')
} }
@@ -28,6 +30,11 @@ pipeline {
script { script {
// 允许 Jenkins Job 指定固定源码目录;未指定时使用当前工作区,方便临时手工执行。 // 允许 Jenkins Job 指定固定源码目录;未指定时使用当前工作区,方便临时手工执行。
env.WORKSPACE_ROOT = params.GENARRATIVE_WORKSPACE_ROOT?.trim() ? params.GENARRATIVE_WORKSPACE_ROOT.trim() : pwd() env.WORKSPACE_ROOT = params.GENARRATIVE_WORKSPACE_ROOT?.trim() ? params.GENARRATIVE_WORKSPACE_ROOT.trim() : pwd()
def deployDirectory = params.DEPLOY_DIRECTORY?.trim()
if (!deployDirectory) {
error('DEPLOY_DIRECTORY 不能为空。')
}
env.EFFECTIVE_ROOT_DIR = params.ROOT_DIR?.trim() ? params.ROOT_DIR.trim() : "${deployDirectory}/.spacetimedb"
def exportName = params.EXPORT_NAME?.trim() def exportName = params.EXPORT_NAME?.trim()
if (!exportName) { if (!exportName) {
exportName = "spacetime-migration-${env.BUILD_NUMBER}.json" exportName = "spacetime-migration-${env.BUILD_NUMBER}.json"
@@ -60,12 +67,15 @@ pipeline {
if [[ -n "${params.SERVER_URL}" ]]; then if [[ -n "${params.SERVER_URL}" ]]; then
args+=(--server-url "${params.SERVER_URL}") args+=(--server-url "${params.SERVER_URL}")
fi fi
if [[ -n "${params.ROOT_DIR}" ]]; then if [[ -n "${env.EFFECTIVE_ROOT_DIR}" ]]; then
args+=(--root-dir "${params.ROOT_DIR}") args+=(--root-dir "${env.EFFECTIVE_ROOT_DIR}")
fi fi
if [[ -n "${params.INCLUDE_TABLES}" ]]; then if [[ -n "${params.INCLUDE_TABLES}" ]]; then
args+=(--include "${params.INCLUDE_TABLES}") args+=(--include "${params.INCLUDE_TABLES}")
fi fi
if [[ -n "${params.BOOTSTRAP_SECRET}" ]]; then
args+=(--bootstrap-secret "${params.BOOTSTRAP_SECRET}")
fi
# 复用后端迁移 procedure 导出 JSON避免 Jenkins 直接拼接表结构和 SQL。 # 复用后端迁移 procedure 导出 JSON避免 Jenkins 直接拼接表结构和 SQL。
node "\${args[@]}" node "\${args[@]}"
test -s "\${output_path}" test -s "\${output_path}"

View File

@@ -12,7 +12,8 @@ pipeline {
string(name: 'DATABASE', defaultValue: '', description: 'SpacetimeDB 数据库名,留空则读取环境变量') string(name: 'DATABASE', defaultValue: '', description: 'SpacetimeDB 数据库名,留空则读取环境变量')
string(name: 'SERVER', defaultValue: 'maincloud', description: 'SpacetimeDB server 别名,例如 maincloud/local/dev') string(name: 'SERVER', defaultValue: 'maincloud', description: 'SpacetimeDB server 别名,例如 maincloud/local/dev')
string(name: 'SERVER_URL', defaultValue: '', description: 'SpacetimeDB server URL填写后优先于 SERVER') string(name: 'SERVER_URL', defaultValue: '', description: 'SpacetimeDB server URL填写后优先于 SERVER')
string(name: 'ROOT_DIR', defaultValue: '', description: 'spacetime CLI root-dir可选') string(name: 'DEPLOY_DIRECTORY', defaultValue: '/var/lib/jenkins/deploy/Genarrative', description: '固定部署目录ROOT_DIR 为空时使用其 .spacetimedb')
string(name: 'ROOT_DIR', defaultValue: '', description: 'spacetime CLI root-dir可选优先于 DEPLOY_DIRECTORY')
string(name: 'INPUT_FILE', defaultValue: '', description: '必填,迁移 JSON 文件路径,相对源码根目录或绝对路径') string(name: 'INPUT_FILE', defaultValue: '', description: '必填,迁移 JSON 文件路径,相对源码根目录或绝对路径')
string(name: 'INCLUDE_TABLES', defaultValue: '', description: '可选,逗号分隔的表名白名单') string(name: 'INCLUDE_TABLES', defaultValue: '', description: '可选,逗号分隔的表名白名单')
booleanParam(name: 'DRY_RUN', defaultValue: true, description: '仅校验导入,不写入数据') booleanParam(name: 'DRY_RUN', defaultValue: true, description: '仅校验导入,不写入数据')
@@ -37,6 +38,11 @@ pipeline {
if (params.INCREMENTAL && params.REPLACE_EXISTING) { if (params.INCREMENTAL && params.REPLACE_EXISTING) {
error('INCREMENTAL 不能和 REPLACE_EXISTING 同时启用。') error('INCREMENTAL 不能和 REPLACE_EXISTING 同时启用。')
} }
def deployDirectory = params.DEPLOY_DIRECTORY?.trim()
if (!deployDirectory) {
error('DEPLOY_DIRECTORY 不能为空。')
}
env.EFFECTIVE_ROOT_DIR = params.ROOT_DIR?.trim() ? params.ROOT_DIR.trim() : "${deployDirectory}/.spacetimedb"
} }
} }
} }
@@ -68,8 +74,8 @@ pipeline {
if [[ -n "${params.SERVER_URL}" ]]; then if [[ -n "${params.SERVER_URL}" ]]; then
args+=(--server-url "${params.SERVER_URL}") args+=(--server-url "${params.SERVER_URL}")
fi fi
if [[ -n "${params.ROOT_DIR}" ]]; then if [[ -n "${env.EFFECTIVE_ROOT_DIR}" ]]; then
args+=(--root-dir "${params.ROOT_DIR}") args+=(--root-dir "${env.EFFECTIVE_ROOT_DIR}")
fi fi
if [[ -n "${params.INCLUDE_TABLES}" ]]; then if [[ -n "${params.INCLUDE_TABLES}" ]]; then
args+=(--include "${params.INCLUDE_TABLES}") args+=(--include "${params.INCLUDE_TABLES}")

View File

@@ -483,6 +483,15 @@ server.listen(webPort, webHost, () => {
}); });
WEB_SERVER WEB_SERVER
touch "${TARGET_DIR}/.env"
for env_file in "${TARGET_DIR}/.env" "${TARGET_DIR}/.env.local"; do
if [[ -f "${env_file}" ]]; then
grep -v '^GENARRATIVE_SPACETIME_ROOT_DIR=' "${env_file}" >"${env_file}.tmp" || true
mv "${env_file}.tmp" "${env_file}"
printf '\nGENARRATIVE_SPACETIME_ROOT_DIR=__GENARRATIVE_RUNTIME_SPACETIME_ROOT_DIR__\n' >>"${env_file}"
fi
done
cat >"${TARGET_DIR}/start.sh" <<'START_SCRIPT' cat >"${TARGET_DIR}/start.sh" <<'START_SCRIPT'
#!/usr/bin/env bash #!/usr/bin/env bash
@@ -575,6 +584,9 @@ load_env_file "${SCRIPT_DIR}/.env"
load_env_file "${SCRIPT_DIR}/.env.local" load_env_file "${SCRIPT_DIR}/.env.local"
SPACETIME_ROOT_DIR="${GENARRATIVE_SPACETIME_ROOT_DIR:-${SCRIPT_DIR}/.spacetimedb}" SPACETIME_ROOT_DIR="${GENARRATIVE_SPACETIME_ROOT_DIR:-${SCRIPT_DIR}/.spacetimedb}"
if [[ "${SPACETIME_ROOT_DIR}" == "__GENARRATIVE_RUNTIME_SPACETIME_ROOT_DIR__" ]]; then
SPACETIME_ROOT_DIR="${SCRIPT_DIR}/.spacetimedb"
fi
SPACETIME_HOST="${GENARRATIVE_SPACETIME_HOST:-__GENARRATIVE_DEFAULT_SPACETIME_HOST__}" SPACETIME_HOST="${GENARRATIVE_SPACETIME_HOST:-__GENARRATIVE_DEFAULT_SPACETIME_HOST__}"
SPACETIME_PORT="${GENARRATIVE_SPACETIME_PORT:-__GENARRATIVE_DEFAULT_SPACETIME_PORT__}" SPACETIME_PORT="${GENARRATIVE_SPACETIME_PORT:-__GENARRATIVE_DEFAULT_SPACETIME_PORT__}"
SPACETIME_SERVER_URL="${GENARRATIVE_SPACETIME_SERVER_URL:-http://${SPACETIME_HOST}:${SPACETIME_PORT}}" SPACETIME_SERVER_URL="${GENARRATIVE_SPACETIME_SERVER_URL:-http://${SPACETIME_HOST}:${SPACETIME_PORT}}"
@@ -787,7 +799,7 @@ start_process() {
fi fi
echo "[start] 启动 ${name}" echo "[start] 启动 ${name}"
nohup "$@" >"${log_file}" 2>&1 & JENKINS_NODE_COOKIE=dontKillMe BUILD_ID=dontKillMe nohup "$@" >"${log_file}" 2>&1 &
echo "$!" >"${pid_file}" echo "$!" >"${pid_file}"
} }
@@ -919,10 +931,10 @@ STOP_SCRIPT
chmod +x "${TARGET_DIR}/start.sh" "${TARGET_DIR}/stop.sh" chmod +x "${TARGET_DIR}/start.sh" "${TARGET_DIR}/stop.sh"
cat >"${TARGET_DIR}/README.md" <<EOF cat >"${TARGET_DIR}/README.md" <<'EOF'
# Genarrative Ubuntu Release # Genarrative Ubuntu Release
构建时间:\`${BUILD_NAME}\` 构建时间:`__GENARRATIVE_BUILD_NAME__`
## 内容 ## 内容
@@ -961,6 +973,7 @@ cat >"${TARGET_DIR}/README.md" <<EOF
- OSS、LLM、短信、微信等业务密钥仍通过目标服务器环境变量或同目录 \`.env.local\` 管理。 - OSS、LLM、短信、微信等业务密钥仍通过目标服务器环境变量或同目录 \`.env.local\` 管理。
- 迁移引导密钥由构建发布包时随机生成,构建日志和服务器 \`start.sh\` 发布日志都会显示同一份密钥。 - 迁移引导密钥由构建发布包时随机生成,构建日志和服务器 \`start.sh\` 发布日志都会显示同一份密钥。
EOF EOF
replace_placeholder_in_file "${TARGET_DIR}/README.md" "__GENARRATIVE_BUILD_NAME__" "${BUILD_NAME}"
BUILD_COMPLETED=1 BUILD_COMPLETED=1

View File

@@ -112,6 +112,7 @@ DEPLOY_ITEMS=(
".env.local" ".env.local"
"README.md" "README.md"
"api-server" "api-server"
"migration-bootstrap-secret.txt"
"spacetime_module.wasm" "spacetime_module.wasm"
"start.sh" "start.sh"
"stop.sh" "stop.sh"

View File

@@ -93,8 +93,13 @@ export function buildSpacetimeCallArgs(options, procedureName, input) {
args.push('call'); args.push('call');
if (options.server) { if (options.server) {
args.push('-s', options.server); args.push('-s', options.server);
} else if (options.serverUrl) {
args.push('-s', options.serverUrl);
} }
args.push(...options.passthrough); args.push(...options.passthrough);
if (!options.passthrough.includes('--no-config')) {
args.push('--no-config');
}
args.push(options.database, procedureName, JSON.stringify(input), '-y'); args.push(options.database, procedureName, JSON.stringify(input), '-y');
return args; return args;
} }