From 1dee5f72d7498437d25ffddade93d90ae03da57d Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 29 Apr 2026 12:03:22 +0800 Subject: [PATCH 01/11] Add Jenkins local deployment test parameters --- ...DATABASE_MIGRATION_PIPELINES_2026-04-29.md | 33 +++++++++--- jenkins/Jenkinsfile.build-and-deploy | 50 ++++++++++++++++++- jenkins/Jenkinsfile.database-export | 12 +++-- jenkins/Jenkinsfile.database-import | 12 +++-- 4 files changed, 93 insertions(+), 14 deletions(-) diff --git a/docs/technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md b/docs/technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md index 05d33663..d28faee3 100644 --- a/docs/technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md +++ b/docs/technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md @@ -41,10 +41,11 @@ Genarrative-Database-Export 1. `DATABASE`:目标 SpacetimeDB 数据库名;留空时读取仓库环境变量。 2. `SERVER`:SpacetimeDB server 别名,默认 `maincloud`。 3. `SERVER_URL`:显式服务地址;填写后优先于 `SERVER`。 -4. `ROOT_DIR`:可选,透传给 `spacetime --root-dir`。 -5. `INCLUDE_TABLES`:可选,逗号分隔的表名白名单。 -6. `OUTPUT_DIRECTORY`:导出文件目录,默认 `database-exports`。 -7. `EXPORT_NAME`:导出文件名;留空时使用 `spacetime-migration-.json`。 +4. `DEPLOY_DIRECTORY`:固定部署目录,默认 `/var/lib/jenkins/deploy/Genarrative`。 +5. `ROOT_DIR`:可选,透传给 `spacetime --root-dir`;为空时使用 `/.spacetimedb`。 +6. `INCLUDE_TABLES`:可选,逗号分隔的表名白名单。 +7. `OUTPUT_DIRECTORY`:导出文件目录,默认 `database-exports`。 +8. `EXPORT_NAME`:导出文件名;留空时使用 `spacetime-migration-.json`。 导出成功后,Jenkins 归档: @@ -69,7 +70,7 @@ Genarrative-Database-Import 关键参数: 1. `INPUT_FILE`:必填,迁移 JSON 文件路径。 -2. `DATABASE`、`SERVER`、`SERVER_URL`、`ROOT_DIR`:与导出流水线一致。 +2. `DATABASE`、`SERVER`、`SERVER_URL`、`DEPLOY_DIRECTORY`、`ROOT_DIR`:与导出流水线一致。 3. `INCLUDE_TABLES`:可选,只导入指定表。 4. `DRY_RUN`:默认 `true`,只校验不写入。 5. `INCREMENTAL`:默认 `true`,跳过已存在或冲突的行。 @@ -85,7 +86,27 @@ Genarrative-Database-Import 3. Jenkinsfile 不打印 token;生产环境应通过 Jenkins 凭据或目标机器环境变量传入敏感值。 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 jenkins/Jenkinsfile.database-export diff --git a/jenkins/Jenkinsfile.build-and-deploy b/jenkins/Jenkinsfile.build-and-deploy index 62554c07..3338e8f1 100644 --- a/jenkins/Jenkinsfile.build-and-deploy +++ b/jenkins/Jenkinsfile.build-and-deploy @@ -10,7 +10,10 @@ pipeline { string(name: 'AGENT_LABEL', defaultValue: 'built-in', description: '构建节点标签') string(name: 'GENARRATIVE_WORKSPACE_ROOT', defaultValue: '', description: '源码根目录,留空则使用当前 Jenkins 工作区') 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: 'SPACETIME_PORT', defaultValue: '3101', description: '发布包内本地 SpacetimeDB 端口') booleanParam(name: 'CLEAR_DATABASE', defaultValue: false, description: '部署时是否清空 SpacetimeDB 数据后再发布 wasm') booleanParam(name: 'RUN_NPM_CI', defaultValue: false, description: '构建前是否执行 npm ci') 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 // 允许 Jenkins Job 直接指定固定源码目录,未指定时回退到当前工作区。 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() if (!webPort) { error('WEB_PORT 不能为空。') @@ -46,6 +72,21 @@ pipeline { } // 后续构建与下游部署都使用校验后的同一端口值,避免参数空格导致上下游不一致。 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 目录。 env.SOURCE_NODE_NAME = env.NODE_NAME } @@ -73,8 +114,13 @@ pipeline { sh """ bash -lc ' 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}" ' """ diff --git a/jenkins/Jenkinsfile.database-export b/jenkins/Jenkinsfile.database-export index ee3ebc48..6f5830e3 100644 --- a/jenkins/Jenkinsfile.database-export +++ b/jenkins/Jenkinsfile.database-export @@ -12,7 +12,8 @@ pipeline { string(name: 'DATABASE', defaultValue: '', description: 'SpacetimeDB 数据库名,留空则读取环境变量') string(name: 'SERVER', defaultValue: 'maincloud', description: 'SpacetimeDB server 别名,例如 maincloud/local/dev') 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: 'OUTPUT_DIRECTORY', defaultValue: 'database-exports', description: '导出文件目录,相对源码根目录或绝对路径') string(name: 'EXPORT_NAME', defaultValue: '', description: '导出文件名,留空则自动使用构建号') @@ -28,6 +29,11 @@ pipeline { script { // 允许 Jenkins Job 指定固定源码目录;未指定时使用当前工作区,方便临时手工执行。 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() if (!exportName) { exportName = "spacetime-migration-${env.BUILD_NUMBER}.json" @@ -60,8 +66,8 @@ pipeline { if [[ -n "${params.SERVER_URL}" ]]; then args+=(--server-url "${params.SERVER_URL}") fi - if [[ -n "${params.ROOT_DIR}" ]]; then - args+=(--root-dir "${params.ROOT_DIR}") + if [[ -n "${env.EFFECTIVE_ROOT_DIR}" ]]; then + args+=(--root-dir "${env.EFFECTIVE_ROOT_DIR}") fi if [[ -n "${params.INCLUDE_TABLES}" ]]; then args+=(--include "${params.INCLUDE_TABLES}") diff --git a/jenkins/Jenkinsfile.database-import b/jenkins/Jenkinsfile.database-import index 80347d88..df93bb96 100644 --- a/jenkins/Jenkinsfile.database-import +++ b/jenkins/Jenkinsfile.database-import @@ -12,7 +12,8 @@ pipeline { string(name: 'DATABASE', defaultValue: '', description: 'SpacetimeDB 数据库名,留空则读取环境变量') string(name: 'SERVER', defaultValue: 'maincloud', description: 'SpacetimeDB server 别名,例如 maincloud/local/dev') 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: 'INCLUDE_TABLES', defaultValue: '', description: '可选,逗号分隔的表名白名单') booleanParam(name: 'DRY_RUN', defaultValue: true, description: '仅校验导入,不写入数据') @@ -37,6 +38,11 @@ pipeline { if (params.INCREMENTAL && params.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 args+=(--server-url "${params.SERVER_URL}") fi - if [[ -n "${params.ROOT_DIR}" ]]; then - args+=(--root-dir "${params.ROOT_DIR}") + if [[ -n "${env.EFFECTIVE_ROOT_DIR}" ]]; then + args+=(--root-dir "${env.EFFECTIVE_ROOT_DIR}") fi if [[ -n "${params.INCLUDE_TABLES}" ]]; then args+=(--include "${params.INCLUDE_TABLES}") From 1ca22b17ccb09b423d0d51c224ad78a0a5d3af69 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 29 Apr 2026 13:58:45 +0800 Subject: [PATCH 02/11] Fix release README heredoc execution --- scripts/deploy-rust-remote.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/deploy-rust-remote.sh b/scripts/deploy-rust-remote.sh index 954bde1d..37dff37b 100644 --- a/scripts/deploy-rust-remote.sh +++ b/scripts/deploy-rust-remote.sh @@ -919,10 +919,10 @@ STOP_SCRIPT chmod +x "${TARGET_DIR}/start.sh" "${TARGET_DIR}/stop.sh" -cat >"${TARGET_DIR}/README.md" <"${TARGET_DIR}/README.md" <<'EOF' # Genarrative Ubuntu Release -构建时间:\`${BUILD_NAME}\` +构建时间:`__GENARRATIVE_BUILD_NAME__` ## 内容 @@ -961,6 +961,7 @@ cat >"${TARGET_DIR}/README.md" < Date: Wed, 29 Apr 2026 14:00:13 +0800 Subject: [PATCH 03/11] Clean fixed Jenkins build output before rebuild --- jenkins/Jenkinsfile.build-and-deploy | 1 + 1 file changed, 1 insertion(+) diff --git a/jenkins/Jenkinsfile.build-and-deploy b/jenkins/Jenkinsfile.build-and-deploy index 3338e8f1..b2b46034 100644 --- a/jenkins/Jenkinsfile.build-and-deploy +++ b/jenkins/Jenkinsfile.build-and-deploy @@ -101,6 +101,7 @@ pipeline { # 这里不使用 -x,避免删除 node_modules 等忽略目录后与 RUN_NPM_CI=false 的配置冲突。 git reset --hard HEAD git clean -fd + rm -rf "build/${EFFECTIVE_BUILD_VERSION}" ' ''' From b9cb6a9a731fdefcffb7dc0e35cbb53c0b09253f Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 29 Apr 2026 14:07:43 +0800 Subject: [PATCH 04/11] Pin local SpacetimeDB root dir in release env --- scripts/deploy-rust-remote.sh | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/scripts/deploy-rust-remote.sh b/scripts/deploy-rust-remote.sh index 37dff37b..1d949f16 100644 --- a/scripts/deploy-rust-remote.sh +++ b/scripts/deploy-rust-remote.sh @@ -278,6 +278,7 @@ fi TARGET_DIR="${BUILD_ROOT}/${BUILD_NAME}" WEB_DIR="${TARGET_DIR}/web" +ROOT_DIR_PLACEHOLDER="__GENARRATIVE_EMPTY_SPACETIME_ROOT_DIR__" API_BINARY_SOURCE="${SERVER_RS_DIR}/target/x86_64-unknown-linux-gnu/release/api-server" WASM_SOURCE="${SERVER_RS_DIR}/target/wasm32-unknown-unknown/release/spacetime_module.wasm" @@ -483,6 +484,13 @@ server.listen(webPort, webHost, () => { }); WEB_SERVER +touch "${TARGET_DIR}/.env" +for env_file in "${TARGET_DIR}/.env" "${TARGET_DIR}/.env.local"; do + if [[ -f "${env_file}" ]] && ! grep -q '^GENARRATIVE_SPACETIME_ROOT_DIR=' "${env_file}"; then + printf '\nGENARRATIVE_SPACETIME_ROOT_DIR=%s\n' "${ROOT_DIR_PLACEHOLDER}" >>"${env_file}" + fi +done + cat >"${TARGET_DIR}/start.sh" <<'START_SCRIPT' #!/usr/bin/env bash @@ -875,6 +883,8 @@ replace_placeholder_in_file "${TARGET_DIR}/start.sh" "__GENARRATIVE_DEFAULT_API_ replace_placeholder_in_file "${TARGET_DIR}/start.sh" "__GENARRATIVE_DEFAULT_API_PORT__" "${API_PORT}" replace_placeholder_in_file "${TARGET_DIR}/start.sh" "__GENARRATIVE_DEFAULT_WEB_HOST__" "${WEB_HOST}" replace_placeholder_in_file "${TARGET_DIR}/start.sh" "__GENARRATIVE_DEFAULT_WEB_PORT__" "${WEB_PORT}" +replace_placeholder_in_file "${TARGET_DIR}/.env" "${ROOT_DIR_PLACEHOLDER}" "${TARGET_DIR}/.spacetimedb" +replace_placeholder_in_file "${TARGET_DIR}/.env.local" "${ROOT_DIR_PLACEHOLDER}" "${TARGET_DIR}/.spacetimedb" cat >"${TARGET_DIR}/stop.sh" <<'STOP_SCRIPT' #!/usr/bin/env bash From 165674a865c2ce769688f0f0d9357851910ac7b5 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 29 Apr 2026 14:13:38 +0800 Subject: [PATCH 05/11] Keep Jenkins local deploy services alive --- scripts/deploy-rust-remote.sh | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/scripts/deploy-rust-remote.sh b/scripts/deploy-rust-remote.sh index 1d949f16..74f32a77 100644 --- a/scripts/deploy-rust-remote.sh +++ b/scripts/deploy-rust-remote.sh @@ -278,7 +278,6 @@ fi TARGET_DIR="${BUILD_ROOT}/${BUILD_NAME}" WEB_DIR="${TARGET_DIR}/web" -ROOT_DIR_PLACEHOLDER="__GENARRATIVE_EMPTY_SPACETIME_ROOT_DIR__" API_BINARY_SOURCE="${SERVER_RS_DIR}/target/x86_64-unknown-linux-gnu/release/api-server" WASM_SOURCE="${SERVER_RS_DIR}/target/wasm32-unknown-unknown/release/spacetime_module.wasm" @@ -487,7 +486,7 @@ WEB_SERVER touch "${TARGET_DIR}/.env" for env_file in "${TARGET_DIR}/.env" "${TARGET_DIR}/.env.local"; do if [[ -f "${env_file}" ]] && ! grep -q '^GENARRATIVE_SPACETIME_ROOT_DIR=' "${env_file}"; then - printf '\nGENARRATIVE_SPACETIME_ROOT_DIR=%s\n' "${ROOT_DIR_PLACEHOLDER}" >>"${env_file}" + printf '\nGENARRATIVE_SPACETIME_ROOT_DIR=${SCRIPT_DIR}/.spacetimedb\n' >>"${env_file}" fi done @@ -795,7 +794,7 @@ start_process() { fi echo "[start] 启动 ${name}" - nohup "$@" >"${log_file}" 2>&1 & + JENKINS_NODE_COOKIE=dontKillMe BUILD_ID=dontKillMe nohup "$@" >"${log_file}" 2>&1 & echo "$!" >"${pid_file}" } @@ -883,8 +882,6 @@ replace_placeholder_in_file "${TARGET_DIR}/start.sh" "__GENARRATIVE_DEFAULT_API_ replace_placeholder_in_file "${TARGET_DIR}/start.sh" "__GENARRATIVE_DEFAULT_API_PORT__" "${API_PORT}" replace_placeholder_in_file "${TARGET_DIR}/start.sh" "__GENARRATIVE_DEFAULT_WEB_HOST__" "${WEB_HOST}" replace_placeholder_in_file "${TARGET_DIR}/start.sh" "__GENARRATIVE_DEFAULT_WEB_PORT__" "${WEB_PORT}" -replace_placeholder_in_file "${TARGET_DIR}/.env" "${ROOT_DIR_PLACEHOLDER}" "${TARGET_DIR}/.spacetimedb" -replace_placeholder_in_file "${TARGET_DIR}/.env.local" "${ROOT_DIR_PLACEHOLDER}" "${TARGET_DIR}/.spacetimedb" cat >"${TARGET_DIR}/stop.sh" <<'STOP_SCRIPT' #!/usr/bin/env bash From 8280cb96e72f2547a8a1bd7f8f8593ffbdd8e634 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 29 Apr 2026 14:19:37 +0800 Subject: [PATCH 06/11] Normalize release SpacetimeDB root dir override --- scripts/deploy-rust-remote.sh | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/scripts/deploy-rust-remote.sh b/scripts/deploy-rust-remote.sh index 74f32a77..f68fc78a 100644 --- a/scripts/deploy-rust-remote.sh +++ b/scripts/deploy-rust-remote.sh @@ -485,8 +485,10 @@ WEB_SERVER touch "${TARGET_DIR}/.env" for env_file in "${TARGET_DIR}/.env" "${TARGET_DIR}/.env.local"; do - if [[ -f "${env_file}" ]] && ! grep -q '^GENARRATIVE_SPACETIME_ROOT_DIR=' "${env_file}"; then - printf '\nGENARRATIVE_SPACETIME_ROOT_DIR=${SCRIPT_DIR}/.spacetimedb\n' >>"${env_file}" + if [[ -f "${env_file}" ]]; then + grep -v '^GENARRATIVE_SPACETIME_ROOT_DIR=' "${env_file}" >"${env_file}.tmp" + mv "${env_file}.tmp" "${env_file}" + printf '\nGENARRATIVE_SPACETIME_ROOT_DIR=__GENARRATIVE_RUNTIME_SPACETIME_ROOT_DIR__\n' >>"${env_file}" fi done @@ -582,6 +584,9 @@ load_env_file "${SCRIPT_DIR}/.env" load_env_file "${SCRIPT_DIR}/.env.local" 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_PORT="${GENARRATIVE_SPACETIME_PORT:-__GENARRATIVE_DEFAULT_SPACETIME_PORT__}" SPACETIME_SERVER_URL="${GENARRATIVE_SPACETIME_SERVER_URL:-http://${SPACETIME_HOST}:${SPACETIME_PORT}}" From cff631d0c5867b05813399fae880e0a3e7049999 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 29 Apr 2026 14:23:11 +0800 Subject: [PATCH 07/11] Handle empty release env root override --- scripts/deploy-rust-remote.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/deploy-rust-remote.sh b/scripts/deploy-rust-remote.sh index f68fc78a..30be0696 100644 --- a/scripts/deploy-rust-remote.sh +++ b/scripts/deploy-rust-remote.sh @@ -486,7 +486,7 @@ 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" + 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 From 17bfa25f7347563c925aa0d190c9973d4268c49b Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 29 Apr 2026 14:43:48 +0800 Subject: [PATCH 08/11] Use explicit server URL for migration CLI calls --- scripts/spacetime-migration-common.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/spacetime-migration-common.mjs b/scripts/spacetime-migration-common.mjs index 6af7ec39..31f5a56a 100644 --- a/scripts/spacetime-migration-common.mjs +++ b/scripts/spacetime-migration-common.mjs @@ -93,6 +93,8 @@ export function buildSpacetimeCallArgs(options, procedureName, input) { args.push('call'); if (options.server) { args.push('-s', options.server); + } else if (options.serverUrl) { + args.push('-s', options.serverUrl); } args.push(...options.passthrough); args.push(options.database, procedureName, JSON.stringify(input), '-y'); From 973f67b6ba96d716711ed0363c0a5ac33f8ab1ce Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 29 Apr 2026 14:46:26 +0800 Subject: [PATCH 09/11] Ignore project config in migration CLI calls --- scripts/spacetime-migration-common.mjs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/spacetime-migration-common.mjs b/scripts/spacetime-migration-common.mjs index 31f5a56a..1f8abf29 100644 --- a/scripts/spacetime-migration-common.mjs +++ b/scripts/spacetime-migration-common.mjs @@ -97,6 +97,9 @@ export function buildSpacetimeCallArgs(options, procedureName, input) { args.push('-s', options.serverUrl); } args.push(...options.passthrough); + if (!options.passthrough.includes('--no-config')) { + args.push('--no-config'); + } args.push(options.database, procedureName, JSON.stringify(input), '-y'); return args; } From 5bf2058f8be77fcbf403508a1a9741077dc2a5cb Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 29 Apr 2026 14:50:21 +0800 Subject: [PATCH 10/11] Expose migration bootstrap secret for export job --- jenkins/Jenkinsfile.database-export | 4 ++++ scripts/jenkins-deploy-release.sh | 1 + 2 files changed, 5 insertions(+) diff --git a/jenkins/Jenkinsfile.database-export b/jenkins/Jenkinsfile.database-export index 6f5830e3..e9f8a653 100644 --- a/jenkins/Jenkinsfile.database-export +++ b/jenkins/Jenkinsfile.database-export @@ -15,6 +15,7 @@ pipeline { 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: 'BOOTSTRAP_SECRET', defaultValue: '', description: '可选,授权临时导出 identity 的迁移引导密钥') string(name: 'OUTPUT_DIRECTORY', defaultValue: 'database-exports', description: '导出文件目录,相对源码根目录或绝对路径') string(name: 'EXPORT_NAME', defaultValue: '', description: '导出文件名,留空则自动使用构建号') } @@ -72,6 +73,9 @@ pipeline { if [[ -n "${params.INCLUDE_TABLES}" ]]; then args+=(--include "${params.INCLUDE_TABLES}") fi + if [[ -n "${params.BOOTSTRAP_SECRET}" ]]; then + args+=(--bootstrap-secret "${params.BOOTSTRAP_SECRET}") + fi # 复用后端迁移 procedure 导出 JSON,避免 Jenkins 直接拼接表结构和 SQL。 node "\${args[@]}" test -s "\${output_path}" diff --git a/scripts/jenkins-deploy-release.sh b/scripts/jenkins-deploy-release.sh index 9fdc7032..b4090107 100644 --- a/scripts/jenkins-deploy-release.sh +++ b/scripts/jenkins-deploy-release.sh @@ -112,6 +112,7 @@ DEPLOY_ITEMS=( ".env.local" "README.md" "api-server" + "migration-bootstrap-secret.txt" "spacetime_module.wasm" "start.sh" "stop.sh" From 7cea26cf6659db0ff10d51ad457ee44a6325a783 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 29 Apr 2026 15:31:53 +0800 Subject: [PATCH 11/11] docs: add spacetimedb schema change constraints --- docs/README.md | 4 +- docs/technical/README.md | 3 +- .../SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md | 235 ++++++++++++++++++ 3 files changed, 240 insertions(+), 2 deletions(-) create mode 100644 docs/technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md diff --git a/docs/README.md b/docs/README.md index daa437c6..1af49af1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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)。 - [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),快速建立这个项目的开发共识。 2. 再看 [工程审查总览](./audits/engineering/README.md) 和 [文本审计总览](./audits/text/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)。 ## 分类规则 diff --git a/docs/technical/README.md b/docs/technical/README.md index f1e92a4c..b0302183 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -4,6 +4,7 @@ ## 文档列表 +- [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md):冻结 SpacetimeDB 表结构变更约束、自动迁移可接受范围、冲突后的系统行为,以及保留旧数据的增量迁移流程;凡涉及 `spacetime publish`、表字段调整或 `migration.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_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` 生成世界底稿。 @@ -189,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/` 一起看,更容易判断先后顺序。 diff --git a/docs/technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md b/docs/technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md new file mode 100644 index 00000000..6a332482 --- /dev/null +++ b/docs/technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md @@ -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 +``` + +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 分批迁移数据,逐步切换客户端,最后在旧数据清空后移除旧表。