Add SpacetimeDB conflict migration flow

This commit is contained in:
2026-04-28 12:58:01 +08:00
parent bb4100fca4
commit 7ffd75c29e
6 changed files with 395 additions and 28 deletions

View File

@@ -3,7 +3,9 @@
import { writeFile } from 'node:fs/promises';
import path from 'node:path';
import {
callSpacetimeProcedureAuto,
callSpacetimeProcedure,
callSpacetimeProcedureViaCli,
createSpacetimeWebIdentity,
ensureParentDir,
ensureProcedureOk,
parseArgs,
@@ -18,11 +20,13 @@ try {
const input = {
include_tables: options.includeTables,
};
const result = await callSpacetimeProcedureAuto(
options,
'export_database_migration_to_file',
input,
);
const webOptions = await prepareWebExportOptions(options);
let result;
try {
result = await callSpacetimeProcedure(webOptions, 'export_database_migration_to_file', input);
} finally {
await revokeTemporaryWebIdentity(webOptions);
}
ensureProcedureOk(result);
if (typeof result.migration_json !== 'string' || result.migration_json.trim() === '') {
@@ -35,6 +39,7 @@ try {
console.log(`[spacetime:migration:export] 已写入 ${outPath}`);
printTableStats(result.table_stats);
printMigrationWarnings(result.warnings);
} catch (error) {
console.error(
`[spacetime:migration:export] ${error instanceof Error ? error.message : String(error)}`,
@@ -42,6 +47,58 @@ try {
process.exit(1);
}
async function prepareWebExportOptions(options) {
if (options.token) {
return { ...options, useHttp: true };
}
const identity = await createSpacetimeWebIdentity(options);
console.log(
`[spacetime:migration:export] 已通过 Web API 创建临时 identity: ${identity.identity}`,
);
const authorizeResult = await callSpacetimeProcedureViaCli(
options,
'authorize_database_migration_operator',
{
bootstrap_secret: options.bootstrapSecret || '',
operator_identity_hex: identity.identity,
note: options.note || 'temporary web api migration export',
},
);
ensureProcedureOk(authorizeResult);
console.log(`[spacetime:migration:export] 已授权临时 Web API identity`);
return {
...options,
token: identity.token,
temporaryWebIdentity: identity.identity,
useHttp: true,
};
}
async function revokeTemporaryWebIdentity(options) {
if (!options.temporaryWebIdentity) {
return;
}
try {
const revokeResult = await callSpacetimeProcedure(
options,
'revoke_database_migration_operator',
{ operator_identity_hex: options.temporaryWebIdentity },
);
ensureProcedureOk(revokeResult);
console.log(`[spacetime:migration:export] 已撤销临时 Web API identity`);
} catch (error) {
console.warn(
`[spacetime:migration:export] 撤销临时 Web API identity 失败: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
function printTableStats(tableStats) {
if (!Array.isArray(tableStats) || tableStats.length === 0) {
return;
@@ -53,3 +110,18 @@ function printTableStats(tableStats) {
}));
console.table(rows);
}
function printMigrationWarnings(warnings) {
if (!Array.isArray(warnings) || warnings.length === 0) {
return;
}
console.warn('[spacetime:migration:export] 迁移告警:');
console.table(
warnings.map((warning) => ({
table: warning.table_name,
kind: warning.warning_kind,
message: warning.message,
})),
);
}

View File

@@ -40,6 +40,7 @@ try {
`[spacetime:migration:import] ${options.dryRun ? 'dry-run 完成' : '导入完成'}: ${inPath}`,
);
printTableStats(result.table_stats);
printMigrationWarnings(result.warnings);
} catch (error) {
console.error(
`[spacetime:migration:import] ${error instanceof Error ? error.message : String(error)}`,
@@ -177,3 +178,18 @@ function printTableStats(tableStats) {
}));
console.table(rows);
}
function printMigrationWarnings(warnings) {
if (!Array.isArray(warnings) || warnings.length === 0) {
return;
}
console.warn('[spacetime:migration:import] 迁移告警汇总:');
console.table(
warnings.map((warning) => ({
table: warning.table_name,
kind: warning.warning_kind,
message: warning.message,
})),
);
}

View File

@@ -250,12 +250,24 @@ function normalizeSatsProduct(value) {
};
}
if (value.length === 5) {
return {
ok: normalizeSatsValue(value[0]),
schema_version: normalizeSatsValue(value[1]),
migration_json: normalizeSatsOption(value[2]),
table_stats: normalizeTableStats(value[3]),
warnings: [],
error_message: normalizeSatsOption(value[4]),
};
}
return {
ok: normalizeSatsValue(value[0]),
schema_version: normalizeSatsValue(value[1]),
migration_json: normalizeSatsOption(value[2]),
table_stats: normalizeTableStats(value[3]),
error_message: normalizeSatsOption(value[4]),
warnings: normalizeMigrationWarnings(value[4]),
error_message: normalizeSatsOption(value[5]),
};
}
@@ -309,6 +321,28 @@ function normalizeTableStats(value) {
});
}
function normalizeMigrationWarnings(value) {
if (!Array.isArray(value)) {
return [];
}
return value.map((entry) => {
if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
return normalizeSatsValue(entry);
}
if (Array.isArray(entry)) {
return {
table_name: normalizeSatsValue(entry[0]),
warning_kind: normalizeSatsValue(entry[1]),
message: normalizeSatsValue(entry[2]),
};
}
return entry;
});
}
export function resolveServerUrl(options) {
if (options.serverUrl) {
return options.serverUrl;

View File

@@ -7,6 +7,8 @@ SERVER_RS_DIR="${REPO_ROOT}/server-rs"
MODULE_PATH="${SERVER_RS_DIR}/target/wasm32-unknown-unknown/release/spacetime_module.wasm"
SPACETIME_SERVER_ALIAS="maincloud"
CLEAR_DATABASE=0
MIGRATE_ON_CONFLICT=1
MIGRATION_DIR=""
MIGRATION_BOOTSTRAP_SECRET=""
MIGRATION_BOOTSTRAP_SECRET_MODE="auto"
@@ -41,11 +43,13 @@ usage() {
npm run spacetime:publish:maincloud
npm run spacetime:publish:maincloud -- --database <database>
npm run spacetime:publish:maincloud -- --clear-database
npm run spacetime:publish:maincloud -- --no-migrate-on-conflict
npm run spacetime:publish:maincloud -- --no-migration-bootstrap-secret
说明:
发布 server-rs/crates/spacetime-module 到 SpacetimeDB Maincloud。
数据库名优先读取 --database其次读取 GENARRATIVE_SPACETIME_MAINCLOUD_DATABASE。
默认遇到 schema 冲突时会先导出迁移 JSON再清库发布并导入回灌。
默认在构建 wasm 前随机生成迁移引导密钥,注入 GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET 并显示在控制台。
EOF
}
@@ -80,6 +84,71 @@ prepare_migration_bootstrap_secret() {
echo "[spacetime:maincloud] 迁移引导密钥: ${MIGRATION_BOOTSTRAP_SECRET}"
}
timestamp_slug() {
node -e 'process.stdout.write(new Date().toISOString().replace(/[:.]/g, "-"));'
}
is_publish_conflict_output() {
local output="$1"
[[ "${output}" == *"conflict"* ]] || [[ "${output}" == *"schema"* && "${output}" == *"clear"* ]]
}
run_publish() {
local output_file="$1"
shift
set +e
spacetime "$@" >"${output_file}" 2>&1
local status=$?
set -e
cat "${output_file}"
return "${status}"
}
run_conflict_migration_publish() {
local migration_root migration_file publish_log
if [[ "${MIGRATION_BOOTSTRAP_SECRET_MODE}" == "disabled" ]]; then
echo "[spacetime:maincloud] schema 冲突需要迁移引导密钥;请去掉 --no-migration-bootstrap-secret 后重试。" >&2
exit 1
fi
migration_root="${MIGRATION_DIR:-${REPO_ROOT}/tmp/spacetime-migrations/maincloud/${SPACETIME_DATABASE}}"
mkdir -p "${migration_root}"
migration_file="${migration_root}/$(timestamp_slug).json"
publish_log="$(mktemp)"
echo "[spacetime:maincloud] 检测到 schema 冲突,开始导出旧库迁移 JSON: ${migration_file}"
node "${REPO_ROOT}/scripts/spacetime-export-migration-json.mjs" \
--server "${SPACETIME_SERVER_ALIAS}" \
--server-url "${SPACETIME_SERVER_URL}" \
--database "${SPACETIME_DATABASE}" \
--bootstrap-secret "${MIGRATION_BOOTSTRAP_SECRET}" \
--out "${migration_file}" \
--note "publish conflict export $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "[spacetime:maincloud] 清库发布新 SpacetimeDB wasm"
if ! run_publish "${publish_log}" publish "${SPACETIME_DATABASE}" --server "${SPACETIME_SERVER_ALIAS}" --bin-path "${MODULE_PATH}" --clear-database --yes; then
echo "[spacetime:maincloud] 清库发布失败,迁移 JSON 已保留: ${migration_file}" >&2
rm -f "${publish_log}"
exit 1
fi
rm -f "${publish_log}"
echo "[spacetime:maincloud] 导入迁移 JSON 回灌数据"
if ! node "${REPO_ROOT}/scripts/spacetime-import-migration-json.mjs" \
--server "${SPACETIME_SERVER_ALIAS}" \
--server-url "${SPACETIME_SERVER_URL}" \
--database "${SPACETIME_DATABASE}" \
--bootstrap-secret "${MIGRATION_BOOTSTRAP_SECRET}" \
--in "${migration_file}" \
--note "publish conflict import $(date -u +%Y-%m-%dT%H:%M:%SZ)"; then
echo "[spacetime:maincloud] 导入失败,迁移 JSON 已保留: ${migration_file}" >&2
exit 1
fi
echo "[spacetime:maincloud] schema 冲突迁移完成,迁移 JSON: ${migration_file}"
}
load_env_file "${REPO_ROOT}/.env"
load_env_file "${REPO_ROOT}/.env.local"
@@ -104,6 +173,14 @@ while [[ $# -gt 0 ]]; do
CLEAR_DATABASE=1
shift
;;
--no-migrate-on-conflict)
MIGRATE_ON_CONFLICT=0
shift
;;
--migration-dir)
MIGRATION_DIR="${2:?缺少 --migration-dir 的值}"
shift 2
;;
--migration-bootstrap-secret)
MIGRATION_BOOTSTRAP_SECRET="${2:?缺少 --migration-bootstrap-secret 的值}"
MIGRATION_BOOTSTRAP_SECRET_MODE="manual"
@@ -166,7 +243,19 @@ if [[ "${CLEAR_DATABASE}" -eq 1 ]]; then
fi
echo "[spacetime:maincloud] 发布 SpacetimeDB wasm: ${SPACETIME_DATABASE} -> ${SPACETIME_SERVER_ALIAS}"
spacetime "${PUBLISH_ARGS[@]}"
PUBLISH_LOG="$(mktemp)"
if ! run_publish "${PUBLISH_LOG}" "${PUBLISH_ARGS[@]}"; then
PUBLISH_OUTPUT="$(cat "${PUBLISH_LOG}")"
rm -f "${PUBLISH_LOG}"
if [[ "${CLEAR_DATABASE}" -eq 0 && "${MIGRATE_ON_CONFLICT}" -eq 1 ]] && is_publish_conflict_output "${PUBLISH_OUTPUT}"; then
run_conflict_migration_publish
else
echo "[spacetime:maincloud] 发布失败。" >&2
exit 1
fi
else
rm -f "${PUBLISH_LOG}"
fi
cat <<EOF
[spacetime:maincloud] 发布完成。api-server 可使用以下环境: