Merge remote-tracking branch 'origin/master' into codex/ddd
# Conflicts: # docs/technical/README.md # docs/technical/RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md # docs/technical/SPACETIMEDB_TABLE_CATALOG.md # scripts/generate-spacetime-bindings.mjs # server-rs/crates/api-server/src/app.rs # server-rs/crates/api-server/src/assets.rs # server-rs/crates/api-server/src/big_fish.rs # server-rs/crates/api-server/src/custom_world_ai.rs # server-rs/crates/api-server/src/llm.rs # server-rs/crates/api-server/src/main.rs # server-rs/crates/api-server/src/puzzle.rs # server-rs/crates/api-server/src/runtime_profile.rs # server-rs/crates/api-server/src/runtime_story/compat/ai.rs # server-rs/crates/api-server/src/runtime_story/compat/npc_actions.rs # server-rs/crates/api-server/src/runtime_story/compat/presentation.rs # server-rs/crates/api-server/src/runtime_story/compat/tests.rs # server-rs/crates/api-server/src/state.rs # server-rs/crates/module-auth/src/lib.rs # server-rs/crates/module-big-fish/src/lib.rs # server-rs/crates/module-custom-world/src/lib.rs # server-rs/crates/module-puzzle/src/lib.rs # server-rs/crates/module-runtime/src/lib.rs # server-rs/crates/spacetime-client/src/big_fish.rs # server-rs/crates/spacetime-client/src/lib.rs # server-rs/crates/spacetime-client/src/mapper.rs # server-rs/crates/spacetime-client/src/module_bindings/admin_disable_profile_redeem_code_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_redeem_code_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/advance_puzzle_next_level_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/append_ai_text_chunk_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/apply_chapter_progression_ledger_entry_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/attach_ai_result_reference_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/authorize_database_migration_operator_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/begin_story_session_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/big_fish_runtime_run_type.rs # server-rs/crates/spacetime-client/src/module_bindings/bind_asset_object_to_entity_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/cancel_ai_task_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/clear_platform_browse_history_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/compile_big_fish_draft_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/compile_custom_world_published_profile_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/compile_puzzle_agent_draft_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/complete_ai_stage_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/complete_ai_task_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/confirm_asset_object_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/consume_profile_wallet_points_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/continue_story_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/create_ai_task_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/create_battle_state_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/create_big_fish_session_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/create_custom_world_agent_session_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/create_profile_recharge_order_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/create_puzzle_agent_session_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/delete_big_fish_work_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/delete_custom_world_agent_session_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/delete_custom_world_profile_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/delete_puzzle_work_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/delete_runtime_snapshot_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/drag_puzzle_piece_or_group_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/execute_custom_world_agent_action_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/export_auth_store_snapshot_from_tables_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/export_database_migration_to_file_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/fail_ai_task_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/finalize_big_fish_agent_message_turn_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/finalize_custom_world_agent_message_turn_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/finalize_puzzle_agent_message_turn_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/generate_big_fish_asset_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_auth_store_snapshot_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_battle_state_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_big_fish_session_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_chapter_progression_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_agent_card_detail_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_agent_operation_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_agent_session_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_gallery_detail_by_code_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_gallery_detail_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_library_detail_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_player_progression_or_default_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_profile_dashboard_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_profile_play_stats_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_profile_recharge_center_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_profile_referral_invite_center_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_puzzle_agent_session_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_puzzle_gallery_detail_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_puzzle_run_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_puzzle_work_detail_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_runtime_inventory_state_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_runtime_setting_or_default_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_runtime_snapshot_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_story_session_state_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/grant_player_progression_experience_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/import_auth_store_snapshot_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/import_database_migration_from_file_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/import_database_migration_incremental_from_file_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/list_asset_history_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/list_big_fish_works_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/list_custom_world_gallery_entries_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/list_custom_world_profiles_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/list_custom_world_works_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/list_platform_browse_history_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/list_profile_save_archives_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/list_profile_wallet_ledger_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/list_puzzle_gallery_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/list_puzzle_works_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/mod.rs # server-rs/crates/spacetime-client/src/module_bindings/publish_big_fish_game_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/publish_custom_world_profile_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/publish_custom_world_world_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/publish_puzzle_work_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/record_big_fish_play_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/redeem_profile_referral_invite_code_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/redeem_profile_reward_code_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/refund_profile_wallet_points_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/resolve_combat_action_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_battle_interaction_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_interaction_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_social_action_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/resolve_treasure_interaction_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/resume_profile_save_archive_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/revoke_database_migration_operator_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/save_puzzle_generated_images_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/select_puzzle_cover_image_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/start_puzzle_run_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/submit_big_fish_message_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/submit_custom_world_agent_message_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/submit_puzzle_agent_message_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/submit_puzzle_leaderboard_entry_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/swap_puzzle_pieces_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/unpublish_custom_world_profile_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/update_puzzle_work_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/upsert_auth_store_snapshot_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/upsert_chapter_progression_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/upsert_custom_world_agent_operation_progress_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/upsert_custom_world_profile_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/upsert_npc_state_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/upsert_platform_browse_history_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/upsert_runtime_setting_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/upsert_runtime_snapshot_and_return_procedure.rs # server-rs/crates/spacetime-module/src/auth/procedures.rs # server-rs/crates/spacetime-module/src/custom_world/mod.rs # server-rs/crates/spacetime-module/src/lib.rs # server-rs/crates/spacetime-module/src/migration.rs # server-rs/crates/spacetime-module/src/puzzle.rs # server-rs/crates/spacetime-module/src/runtime/profile.rs # src/components/platform-entry/PlatformEntryFlowShellImpl.tsx # src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx # src/services/aiService.ts # src/services/puzzle-runtime/puzzleRuntimeClient.ts
This commit is contained in:
73
scripts/admin-web-build.mjs
Normal file
73
scripts/admin-web-build.mjs
Normal file
@@ -0,0 +1,73 @@
|
||||
import {spawnSync} from 'node:child_process';
|
||||
import {dirname, resolve} from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(scriptDir, '..');
|
||||
const adminWebDir = resolve(repoRoot, 'apps/admin-web');
|
||||
const adminTsconfigPath = resolve(adminWebDir, 'tsconfig.json');
|
||||
const adminViteConfigPath = resolve(adminWebDir, 'vite.config.ts');
|
||||
const tscBinPath = resolve(repoRoot, 'node_modules/typescript/bin/tsc');
|
||||
const viteCliPath = resolve(scriptDir, 'vite-cli.mjs');
|
||||
|
||||
const command = process.argv[2] ?? 'build';
|
||||
const extraArgs = process.argv.slice(3);
|
||||
|
||||
function usage() {
|
||||
console.error('用法: node scripts/admin-web-build.mjs <typecheck|build> [vite-build-args...]');
|
||||
}
|
||||
|
||||
function runNodeScript(label, args) {
|
||||
console.log(`[admin-web] ${label}`);
|
||||
const result = spawnSync(process.execPath, args, {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
if (result.stdout) {
|
||||
process.stdout.write(result.stdout);
|
||||
}
|
||||
|
||||
if (result.stderr) {
|
||||
process.stderr.write(result.stderr);
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
console.error(`[admin-web] ${label} failed to start: ${result.error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (result.signal) {
|
||||
console.error(`[admin-web] ${label} was terminated by signal ${result.signal}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if ((result.status ?? 0) !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
}
|
||||
|
||||
function runTypecheck() {
|
||||
runNodeScript('typecheck', [
|
||||
tscBinPath,
|
||||
'--noEmit',
|
||||
'-p',
|
||||
adminTsconfigPath,
|
||||
]);
|
||||
}
|
||||
|
||||
if (command === 'typecheck') {
|
||||
runTypecheck();
|
||||
} else if (command === 'build') {
|
||||
runTypecheck();
|
||||
runNodeScript('vite build', [
|
||||
viteCliPath,
|
||||
'build',
|
||||
'--config',
|
||||
adminViteConfigPath,
|
||||
...extraArgs,
|
||||
]);
|
||||
} else {
|
||||
usage();
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -2,39 +2,60 @@ import {spawnSync} from 'node:child_process';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
|
||||
const viteCliPath = fileURLToPath(new URL('./vite-cli.mjs', import.meta.url));
|
||||
const args = [viteCliPath, 'build', ...process.argv.slice(2)];
|
||||
const adminWebBuildPath = fileURLToPath(new URL('./admin-web-build.mjs', import.meta.url));
|
||||
const forwardedArgs = process.argv.slice(2);
|
||||
|
||||
const result = spawnSync(process.execPath, args, {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
if (result.stdout) {
|
||||
process.stdout.write(result.stdout);
|
||||
}
|
||||
|
||||
if (result.stderr) {
|
||||
process.stderr.write(result.stderr);
|
||||
}
|
||||
|
||||
if ((result.status ?? 0) !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
const warningPattern = /\bwarn(?:ing)?\b/i;
|
||||
const ignoredWarningPatterns = [
|
||||
/ExperimentalWarning/u,
|
||||
const results = [
|
||||
runBuildStep('web', [viteCliPath, 'build', ...forwardedArgs]),
|
||||
runBuildStep('admin-web', [adminWebBuildPath, 'build']),
|
||||
];
|
||||
|
||||
const warningLines = `${result.stdout ?? ''}\n${result.stderr ?? ''}`
|
||||
.split(/\r?\n/u)
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0)
|
||||
.filter(line => warningPattern.test(line))
|
||||
.filter(line => !ignoredWarningPatterns.some(pattern => pattern.test(line)));
|
||||
const failedResult = results.find(result => result.error || result.signal || (result.status ?? 0) !== 0);
|
||||
if (failedResult) {
|
||||
if (failedResult.error) {
|
||||
console.error(`Build gate failed to start a build step: ${failedResult.error.message}`);
|
||||
} else if (failedResult.signal) {
|
||||
console.error(`Build gate step was terminated by signal ${failedResult.signal}`);
|
||||
}
|
||||
process.exit(failedResult.status ?? 1);
|
||||
}
|
||||
|
||||
const warningLines = results.flatMap((result) => collectWarningLines(result));
|
||||
|
||||
if (warningLines.length > 0) {
|
||||
console.error('Build gate failed because warnings were emitted:');
|
||||
[...new Set(warningLines)].forEach(line => console.error(`- ${line}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function runBuildStep(label, args) {
|
||||
console.log(`[build-gate] ${label}`);
|
||||
const result = spawnSync(process.execPath, args, {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
if (result.stdout) {
|
||||
process.stdout.write(result.stdout);
|
||||
}
|
||||
|
||||
if (result.stderr) {
|
||||
process.stderr.write(result.stderr);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function collectWarningLines(result) {
|
||||
const warningPattern = /\bwarn(?:ing)?\b/i;
|
||||
const ignoredWarningPatterns = [
|
||||
/ExperimentalWarning/u,
|
||||
];
|
||||
|
||||
return `${result.stdout ?? ''}\n${result.stderr ?? ''}`
|
||||
.split(/\r?\n/u)
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0)
|
||||
.filter(line => warningPattern.test(line))
|
||||
.filter(line => !ignoredWarningPatterns.some(pattern => pattern.test(line)));
|
||||
}
|
||||
|
||||
@@ -11,7 +11,10 @@ const spacetimeTableCatalogPath = join(
|
||||
'technical',
|
||||
'SPACETIMEDB_TABLE_CATALOG.md',
|
||||
);
|
||||
const migrationExcludedTables = new Set(['database_migration_operator']);
|
||||
const migrationExcludedTables = new Set([
|
||||
'database_migration_operator',
|
||||
'database_migration_import_chunk',
|
||||
]);
|
||||
const requiredModuleFiles = [
|
||||
'domain.rs',
|
||||
'commands.rs',
|
||||
|
||||
@@ -71,6 +71,31 @@ normalize_env_file() {
|
||||
cp "${temp_file}" "${env_file}"
|
||||
}
|
||||
|
||||
write_env_override() {
|
||||
local env_file="$1"
|
||||
local key="$2"
|
||||
local value="$3"
|
||||
local temp_file="${env_file}.tmp.$$"
|
||||
|
||||
mkdir -p "$(dirname "${env_file}")"
|
||||
if [[ -f "${env_file}" ]]; then
|
||||
# 发布包参数是本次构建的权威值,必须覆盖从 Jenkins 工作区复制进来的旧 .env.local。
|
||||
awk -v target_key="${key}" '
|
||||
BEGIN {
|
||||
pattern = "^[[:space:]]*(export[[:space:]]+)?" target_key "="
|
||||
}
|
||||
$0 !~ pattern {
|
||||
print
|
||||
}
|
||||
' "${env_file}" >"${temp_file}"
|
||||
else
|
||||
: >"${temp_file}"
|
||||
fi
|
||||
|
||||
printf "%s=%s\n" "${key}" "${value}" >>"${temp_file}"
|
||||
cp "${temp_file}" "${env_file}"
|
||||
}
|
||||
|
||||
copy_optional_file() {
|
||||
local source_path="$1"
|
||||
local target_path_a="$2"
|
||||
@@ -166,7 +191,7 @@ BUILD_NAME="$(date +%Y%m%d-%H%M%S)"
|
||||
DATABASE="xushi-p4wfr"
|
||||
API_HOST="127.0.0.1"
|
||||
API_PORT="8082"
|
||||
WEB_HOST="0.0.0.0"
|
||||
WEB_HOST="127.0.0.1"
|
||||
WEB_PORT="25001"
|
||||
SPACETIME_HOST="127.0.0.1"
|
||||
SPACETIME_PORT="3101"
|
||||
@@ -270,6 +295,13 @@ if [[ ! "${BUILD_NAME}" =~ ^[0-9A-Za-z._-]+$ ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! "${DATABASE}" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then
|
||||
echo "[deploy:rust] --database 必须匹配 SpacetimeDB 数据库名规则 ^[a-z0-9]+(-[a-z0-9]+)*$,只能使用小写字母、数字,并用单个短横线分隔: ${DATABASE}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[deploy:rust] SpacetimeDB 发布数据库: ${DATABASE}"
|
||||
|
||||
if [[ "${SKIP_SPACETIME_BUILD}" -eq 1 && "${MIGRATION_BOOTSTRAP_SECRET_MODE}" != "disabled" ]]; then
|
||||
echo "[deploy:rust] --skip-spacetime-build 无法把迁移引导密钥注入 wasm。" >&2
|
||||
echo "[deploy:rust] 请移除 --skip-spacetime-build,或同时传 --no-migration-bootstrap-secret。" >&2
|
||||
@@ -278,6 +310,7 @@ fi
|
||||
|
||||
TARGET_DIR="${BUILD_ROOT}/${BUILD_NAME}"
|
||||
WEB_DIR="${TARGET_DIR}/web"
|
||||
ADMIN_WEB_DIR="${WEB_DIR}/admin"
|
||||
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"
|
||||
|
||||
@@ -323,6 +356,8 @@ echo "[deploy:rust] 发布包目录: ${TARGET_DIR}"
|
||||
|
||||
copy_optional_file "${REPO_ROOT}/.env" "${TARGET_DIR}/.env" "${WEB_DIR}/.env" ".env"
|
||||
copy_optional_file "${REPO_ROOT}/.env.local" "${TARGET_DIR}/.env.local" "${WEB_DIR}/.env.local" ".env.local"
|
||||
write_env_override "${TARGET_DIR}/.env.local" "GENARRATIVE_SPACETIME_DATABASE" "${DATABASE}"
|
||||
write_env_override "${WEB_DIR}/.env.local" "GENARRATIVE_SPACETIME_DATABASE" "${DATABASE}"
|
||||
|
||||
if [[ "${SKIP_WEB_BUILD}" -ne 1 ]]; then
|
||||
echo "[deploy:rust] 构建 Vite release -> ${WEB_DIR}"
|
||||
@@ -330,6 +365,12 @@ if [[ "${SKIP_WEB_BUILD}" -ne 1 ]]; then
|
||||
cd "${REPO_ROOT}"
|
||||
node scripts/vite-cli.mjs build --outDir "${WEB_DIR}" --emptyOutDir
|
||||
)
|
||||
|
||||
echo "[deploy:rust] 构建后台 Vite release -> ${ADMIN_WEB_DIR}"
|
||||
(
|
||||
cd "${REPO_ROOT}"
|
||||
MSYS2_ARG_CONV_EXCL="--base=" node scripts/admin-web-build.mjs build --base=/admin/ --outDir "${ADMIN_WEB_DIR}" --emptyOutDir
|
||||
)
|
||||
fi
|
||||
|
||||
if [[ "${SKIP_API_BUILD}" -ne 1 ]]; then
|
||||
@@ -366,6 +407,19 @@ if [[ "${MIGRATION_BOOTSTRAP_SECRET_MODE}" != "disabled" ]]; then
|
||||
chmod 600 "${TARGET_DIR}/migration-bootstrap-secret.txt"
|
||||
fi
|
||||
|
||||
mkdir -p "${TARGET_DIR}/scripts"
|
||||
for migration_script in \
|
||||
spacetime-migration-common.mjs \
|
||||
spacetime-export-migration-json.mjs \
|
||||
spacetime-import-migration-json.mjs \
|
||||
spacetime-authorize-migration-operator.mjs \
|
||||
spacetime-revoke-migration-operator.mjs; do
|
||||
copy_required_file \
|
||||
"${SCRIPT_DIR}/${migration_script}" \
|
||||
"${TARGET_DIR}/scripts/${migration_script}" \
|
||||
"SpacetimeDB 迁移脚本 ${migration_script}"
|
||||
done
|
||||
|
||||
cat >"${TARGET_DIR}/web-server.mjs" <<'WEB_SERVER'
|
||||
import http from 'node:http';
|
||||
import fs from 'node:fs';
|
||||
@@ -374,11 +428,14 @@ import {fileURLToPath} from 'node:url';
|
||||
|
||||
const releaseDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const webRoot = path.join(releaseDir, 'web');
|
||||
const webHost = process.env.GENARRATIVE_WEB_HOST || '0.0.0.0';
|
||||
const adminWebRoot = path.join(webRoot, 'admin');
|
||||
const webHost = process.env.GENARRATIVE_WEB_HOST || '127.0.0.1';
|
||||
const webPort = Number(process.env.GENARRATIVE_WEB_PORT || '3000');
|
||||
const apiTarget = new URL(process.env.GENARRATIVE_API_TARGET || 'http://127.0.0.1:8082');
|
||||
const indexPath = path.join(webRoot, 'index.html');
|
||||
const adminIndexPath = path.join(adminWebRoot, 'index.html');
|
||||
const proxyPrefixes = [
|
||||
'/admin/api',
|
||||
'/api/',
|
||||
'/api',
|
||||
'/healthz',
|
||||
@@ -413,11 +470,11 @@ function sendFile(response, filePath) {
|
||||
.pipe(response);
|
||||
}
|
||||
|
||||
function serveStatic(request, response, pathname) {
|
||||
function serveStaticFromRoot(response, pathname, rootDir, fallbackIndexPath) {
|
||||
const decodedPath = decodeURIComponent(pathname);
|
||||
const relativePath = decodedPath === '/' ? '/index.html' : decodedPath;
|
||||
const filePath = path.normalize(path.join(webRoot, relativePath));
|
||||
const safeRelativePath = path.relative(webRoot, filePath);
|
||||
const filePath = path.normalize(path.join(rootDir, relativePath));
|
||||
const safeRelativePath = path.relative(rootDir, filePath);
|
||||
|
||||
if (safeRelativePath.startsWith('..') || path.isAbsolute(safeRelativePath)) {
|
||||
response.writeHead(403, {'content-type': 'text/plain; charset=utf-8'});
|
||||
@@ -427,12 +484,21 @@ function serveStatic(request, response, pathname) {
|
||||
|
||||
const resolvedFilePath = fs.existsSync(filePath) && fs.statSync(filePath).isFile()
|
||||
? filePath
|
||||
: indexPath;
|
||||
: fallbackIndexPath;
|
||||
|
||||
response.writeHead(200, {'content-type': contentTypeFor(resolvedFilePath)});
|
||||
sendFile(response, resolvedFilePath);
|
||||
}
|
||||
|
||||
function serveStatic(request, response, pathname) {
|
||||
serveStaticFromRoot(response, pathname, webRoot, indexPath);
|
||||
}
|
||||
|
||||
function serveAdminStatic(response, pathname) {
|
||||
const adminPath = pathname === '/admin/' ? '/' : pathname.replace(/^\/admin/u, '');
|
||||
serveStaticFromRoot(response, adminPath, adminWebRoot, adminIndexPath);
|
||||
}
|
||||
|
||||
function proxyToApi(request, response) {
|
||||
const targetUrl = new URL(request.url || '/', apiTarget);
|
||||
const proxyRequest = http.request(
|
||||
@@ -469,6 +535,17 @@ const server = http.createServer((request, response) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/admin') {
|
||||
response.writeHead(301, {location: '/admin/'});
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/admin/' || url.pathname.startsWith('/admin/')) {
|
||||
serveAdminStatic(response, url.pathname);
|
||||
return;
|
||||
}
|
||||
|
||||
serveStatic(request, response, url.pathname);
|
||||
});
|
||||
|
||||
@@ -477,6 +554,15 @@ 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}" ]]; 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'
|
||||
#!/usr/bin/env bash
|
||||
|
||||
@@ -543,7 +629,8 @@ usage() {
|
||||
说明:
|
||||
1. 启动当前发布包内的静态网站、SpacetimeDB 与 api-server。
|
||||
2. 默认发布 spacetime_module.wasm 到 GENARRATIVE_SPACETIME_DATABASE,但不清库。
|
||||
3. 只有显式传入 --clear-database 时才会在 schema 冲突时清理旧模块数据后重发。
|
||||
3. 默认遇到 schema 冲突时自动导出旧库、清库发布新模块并导入回灌。
|
||||
4. 显式传入 --clear-database 时代表人工确认清库,不执行自动回灌。
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -569,17 +656,28 @@ 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}}"
|
||||
SPACETIME_DATABASE="${GENARRATIVE_SPACETIME_DATABASE:-__GENARRATIVE_DEFAULT_SPACETIME_DATABASE__}"
|
||||
SPACETIME_TIMEOUT_SECONDS="${GENARRATIVE_SPACETIME_TIMEOUT_SECONDS:-60}"
|
||||
SPACETIME_MIGRATE_ON_CONFLICT="${GENARRATIVE_SPACETIME_MIGRATE_ON_CONFLICT:-true}"
|
||||
SPACETIME_MIGRATION_DIR="${GENARRATIVE_SPACETIME_MIGRATION_DIR:-}"
|
||||
SPACETIME_MIGRATION_EXPORT_TOKEN="${GENARRATIVE_SPACETIME_MIGRATION_EXPORT_TOKEN:-}"
|
||||
SPACETIME_MIGRATION_IMPORT_TOKEN="${GENARRATIVE_SPACETIME_MIGRATION_IMPORT_TOKEN:-}"
|
||||
API_HOST="${GENARRATIVE_API_HOST:-__GENARRATIVE_DEFAULT_API_HOST__}"
|
||||
API_PORT="${GENARRATIVE_API_PORT:-__GENARRATIVE_DEFAULT_API_PORT__}"
|
||||
API_LOG="${GENARRATIVE_API_LOG:-info,tower_http=info}"
|
||||
WEB_HOST="${GENARRATIVE_WEB_HOST:-__GENARRATIVE_DEFAULT_WEB_HOST__}"
|
||||
WEB_PORT="${GENARRATIVE_WEB_PORT:-__GENARRATIVE_DEFAULT_WEB_PORT__}"
|
||||
MIGRATION_BOOTSTRAP_SECRET_FILE="${SCRIPT_DIR}/migration-bootstrap-secret.txt"
|
||||
PREVIOUS_MIGRATION_BOOTSTRAP_SECRET_FILE="${SCRIPT_DIR}/deploy-state/migration-bootstrap-secret.previous.txt"
|
||||
MIGRATION_SCRIPT_DIR="${SCRIPT_DIR}/scripts"
|
||||
MIGRATION_EXPORT_SCRIPT="${MIGRATION_SCRIPT_DIR}/spacetime-export-migration-json.mjs"
|
||||
MIGRATION_IMPORT_SCRIPT="${MIGRATION_SCRIPT_DIR}/spacetime-import-migration-json.mjs"
|
||||
|
||||
# 日志默认落文件,显式关闭 ANSI 颜色码,避免控制字符写入 *.log。
|
||||
export NO_COLOR="${NO_COLOR:-1}"
|
||||
@@ -594,6 +692,178 @@ require_command() {
|
||||
fi
|
||||
}
|
||||
|
||||
is_truthy() {
|
||||
local normalized
|
||||
|
||||
normalized="$(printf "%s" "${1:-}" | tr '[:upper:]' '[:lower:]')"
|
||||
case "${normalized}" in
|
||||
1|true|yes|y|on)
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
timestamp_slug() {
|
||||
date -u +%Y-%m-%dT%H-%M-%SZ
|
||||
}
|
||||
|
||||
sanitize_path_segment() {
|
||||
printf "%s" "$1" | tr -c 'A-Za-z0-9._-' '_'
|
||||
}
|
||||
|
||||
validate_spacetime_database_name() {
|
||||
local database="$1"
|
||||
|
||||
if [[ ! "${database}" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then
|
||||
echo "[start] GENARRATIVE_SPACETIME_DATABASE 必须匹配 SpacetimeDB 数据库名规则 ^[a-z0-9]+(-[a-z0-9]+)*$,只能使用小写字母、数字,并用单个短横线分隔: ${database}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
is_publish_conflict_output() {
|
||||
local output="$1"
|
||||
local normalized
|
||||
|
||||
normalized="$(printf "%s" "${output}" | tr '[:upper:]' '[:lower:]')"
|
||||
[[ "${normalized}" == *"requires a manual migration"* ]] \
|
||||
|| [[ "${normalized}" == *"manual migration"* ]] \
|
||||
|| [[ "${normalized}" == *"schema"* && "${normalized}" == *"conflict"* ]] \
|
||||
|| [[ "${normalized}" == *"clear-database"* ]] \
|
||||
|| [[ "${normalized}" == *"clear database"* && "${normalized}" == *"publish"* ]]
|
||||
}
|
||||
|
||||
read_migration_bootstrap_secret() {
|
||||
local secret_file="$1"
|
||||
local label="$2"
|
||||
local secret=""
|
||||
|
||||
if [[ ! -f "${secret_file}" ]]; then
|
||||
echo "[start] schema 冲突自动迁移需要${label}: ${secret_file}" >&2
|
||||
echo "[start] 请使用默认带迁移引导密钥的发布包,或设置 GENARRATIVE_SPACETIME_MIGRATE_ON_CONFLICT=false 后人工处理。" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
secret="$(tr -d '\r\n' <"${secret_file}")"
|
||||
if [[ -z "${secret}" ]]; then
|
||||
echo "[start] 迁移引导密钥为空${label}: ${secret_file}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
printf "%s" "${secret}"
|
||||
}
|
||||
|
||||
read_export_migration_bootstrap_secret() {
|
||||
if [[ -f "${PREVIOUS_MIGRATION_BOOTSTRAP_SECRET_FILE}" ]]; then
|
||||
read_migration_bootstrap_secret "${PREVIOUS_MIGRATION_BOOTSTRAP_SECRET_FILE}" "(旧模块导出)"
|
||||
return
|
||||
fi
|
||||
|
||||
read_migration_bootstrap_secret "${MIGRATION_BOOTSTRAP_SECRET_FILE}" "(当前模块导出兜底)"
|
||||
}
|
||||
|
||||
read_import_migration_bootstrap_secret() {
|
||||
read_migration_bootstrap_secret "${MIGRATION_BOOTSTRAP_SECRET_FILE}" "(新模块导入)"
|
||||
}
|
||||
|
||||
require_migration_script() {
|
||||
local script_path="$1"
|
||||
|
||||
if [[ ! -f "${script_path}" ]]; then
|
||||
echo "[start] 发布包缺少 SpacetimeDB 迁移脚本: ${script_path}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
run_publish() {
|
||||
local output_file="$1"
|
||||
shift
|
||||
|
||||
set +e
|
||||
spacetime --root-dir="${SPACETIME_ROOT_DIR}" "$@" >"${output_file}" 2>&1
|
||||
local status=$?
|
||||
set -e
|
||||
cat "${output_file}"
|
||||
return "${status}"
|
||||
}
|
||||
|
||||
run_conflict_migration_publish() {
|
||||
local export_bootstrap_secret=""
|
||||
local import_bootstrap_secret=""
|
||||
local export_auth_args=()
|
||||
local import_auth_args=()
|
||||
local migration_database_slug=""
|
||||
local migration_root=""
|
||||
local migration_file=""
|
||||
local publish_log=""
|
||||
|
||||
if [[ -n "${SPACETIME_MIGRATION_EXPORT_TOKEN}" ]]; then
|
||||
echo "[start] 使用 GENARRATIVE_SPACETIME_MIGRATION_EXPORT_TOKEN 导出旧库"
|
||||
export_auth_args=(--token "${SPACETIME_MIGRATION_EXPORT_TOKEN}")
|
||||
else
|
||||
export_bootstrap_secret="$(read_export_migration_bootstrap_secret)"
|
||||
export_auth_args=(--bootstrap-secret "${export_bootstrap_secret}")
|
||||
fi
|
||||
|
||||
if [[ -n "${SPACETIME_MIGRATION_IMPORT_TOKEN}" ]]; then
|
||||
echo "[start] 使用 GENARRATIVE_SPACETIME_MIGRATION_IMPORT_TOKEN 导入新库"
|
||||
import_auth_args=(--token "${SPACETIME_MIGRATION_IMPORT_TOKEN}")
|
||||
else
|
||||
import_bootstrap_secret="$(read_import_migration_bootstrap_secret)"
|
||||
import_auth_args=(--bootstrap-secret "${import_bootstrap_secret}")
|
||||
fi
|
||||
require_migration_script "${MIGRATION_EXPORT_SCRIPT}"
|
||||
require_migration_script "${MIGRATION_IMPORT_SCRIPT}"
|
||||
|
||||
migration_database_slug="$(sanitize_path_segment "${SPACETIME_DATABASE}")"
|
||||
migration_root="${SPACETIME_MIGRATION_DIR:-${SCRIPT_DIR}/database-migrations/${migration_database_slug}}"
|
||||
mkdir -p "${migration_root}"
|
||||
migration_file="${migration_root}/$(timestamp_slug).json"
|
||||
|
||||
echo "[start] 检测到 SpacetimeDB schema 冲突,开始导出旧库迁移 JSON: ${migration_file}"
|
||||
node "${MIGRATION_EXPORT_SCRIPT}" \
|
||||
--server "${SPACETIME_SERVER_URL}" \
|
||||
--server-url "${SPACETIME_SERVER_URL}" \
|
||||
--root-dir "${SPACETIME_ROOT_DIR}" \
|
||||
--database "${SPACETIME_DATABASE}" \
|
||||
"${export_auth_args[@]}" \
|
||||
--out "${migration_file}" \
|
||||
--note "deploy conflict export $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
|
||||
echo "[start] 清库发布新 SpacetimeDB wasm"
|
||||
publish_log="$(mktemp)"
|
||||
if ! run_publish "${publish_log}" \
|
||||
publish \
|
||||
"${SPACETIME_DATABASE}" \
|
||||
--server "${SPACETIME_SERVER_URL}" \
|
||||
--bin-path "${SCRIPT_DIR}/spacetime_module.wasm" \
|
||||
--clear-database \
|
||||
--yes; then
|
||||
echo "[start] 清库发布失败,迁移 JSON 已保留: ${migration_file}" >&2
|
||||
rm -f "${publish_log}"
|
||||
exit 1
|
||||
fi
|
||||
rm -f "${publish_log}"
|
||||
|
||||
echo "[start] 导入迁移 JSON 回灌数据"
|
||||
if ! node "${MIGRATION_IMPORT_SCRIPT}" \
|
||||
--server "${SPACETIME_SERVER_URL}" \
|
||||
--server-url "${SPACETIME_SERVER_URL}" \
|
||||
--root-dir "${SPACETIME_ROOT_DIR}" \
|
||||
--database "${SPACETIME_DATABASE}" \
|
||||
"${import_auth_args[@]}" \
|
||||
--in "${migration_file}" \
|
||||
--replace-existing \
|
||||
--note "deploy conflict import $(date -u +%Y-%m-%dT%H:%M:%SZ)"; then
|
||||
echo "[start] 导入失败,迁移 JSON 已保留: ${migration_file}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[start] schema 冲突自动迁移完成,迁移 JSON: ${migration_file}"
|
||||
}
|
||||
|
||||
wait_for_spacetime() {
|
||||
local process_pid="${1:-}"
|
||||
local deadline=$((SECONDS + SPACETIME_TIMEOUT_SECONDS))
|
||||
@@ -781,10 +1051,17 @@ 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}"
|
||||
}
|
||||
|
||||
validate_spacetime_database_name "${SPACETIME_DATABASE}"
|
||||
|
||||
echo "[start] SpacetimeDB 发布配置:"
|
||||
echo "[start] - database: ${SPACETIME_DATABASE}"
|
||||
echo "[start] - server: ${SPACETIME_SERVER_URL}"
|
||||
echo "[start] - root-dir: ${SPACETIME_ROOT_DIR}"
|
||||
|
||||
require_command node
|
||||
require_command spacetime
|
||||
|
||||
@@ -834,14 +1111,28 @@ if [[ -f "${MIGRATION_BOOTSTRAP_SECRET_FILE}" ]]; then
|
||||
else
|
||||
echo "[start] 未启用迁移引导密钥。"
|
||||
fi
|
||||
if ! spacetime --root-dir="${SPACETIME_ROOT_DIR}" "${PUBLISH_ARGS[@]}"; then
|
||||
echo "[start] SpacetimeDB 发布失败。" >&2
|
||||
echo "[start] 如果错误包含 403 Forbidden 或 is not authorized,通常是当前 CLI 身份无权更新目标数据库。" >&2
|
||||
echo "[start] 当前 start.sh 使用的 CLI root: ${SPACETIME_ROOT_DIR}" >&2
|
||||
spacetime --root-dir="${SPACETIME_ROOT_DIR}" login show >&2 || true
|
||||
echo "[start] 如果目标是本地库且可以清空数据:先执行 ./stop.sh,备份或删除 ${SPACETIME_ROOT_DIR},再重新执行 ./start.sh。" >&2
|
||||
echo "[start] 如果目标是 Maincloud 或必须保留数据:请切换到创建该数据库的 SpacetimeDB 身份,或把 GENARRATIVE_SPACETIME_DATABASE 改为当前身份有权限的库。" >&2
|
||||
exit 1
|
||||
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 ]] \
|
||||
&& is_truthy "${SPACETIME_MIGRATE_ON_CONFLICT}" \
|
||||
&& is_publish_conflict_output "${PUBLISH_OUTPUT}"; then
|
||||
run_conflict_migration_publish
|
||||
else
|
||||
if [[ "${CLEAR_DATABASE}" -eq 0 ]] && ! is_truthy "${SPACETIME_MIGRATE_ON_CONFLICT}"; then
|
||||
echo "[start] 已禁用 schema 冲突自动迁移: GENARRATIVE_SPACETIME_MIGRATE_ON_CONFLICT=${SPACETIME_MIGRATE_ON_CONFLICT}" >&2
|
||||
fi
|
||||
echo "[start] SpacetimeDB 发布失败。" >&2
|
||||
echo "[start] 如果错误包含 403 Forbidden 或 is not authorized,通常是当前 CLI 身份无权更新目标数据库。" >&2
|
||||
echo "[start] 当前 start.sh 使用的 CLI root: ${SPACETIME_ROOT_DIR}" >&2
|
||||
spacetime --root-dir="${SPACETIME_ROOT_DIR}" login show >&2 || true
|
||||
echo "[start] 如果目标是本地库且可以清空数据:先执行 ./stop.sh,备份或删除 ${SPACETIME_ROOT_DIR},再重新执行 ./start.sh --clear-database。" >&2
|
||||
echo "[start] 如果目标是 Maincloud 或必须保留数据:请切换到创建该数据库的 SpacetimeDB 身份,或把 GENARRATIVE_SPACETIME_DATABASE 改为当前身份有权限的库。" >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
rm -f "${PUBLISH_LOG}"
|
||||
fi
|
||||
|
||||
export GENARRATIVE_API_HOST="${API_HOST}"
|
||||
@@ -860,6 +1151,7 @@ echo "[start] 完成"
|
||||
echo "[start] Web: http://${WEB_HOST}:${WEB_PORT}"
|
||||
echo "[start] API: http://${API_HOST}:${API_PORT}"
|
||||
echo "[start] SpacetimeDB: ${SPACETIME_SERVER_URL}"
|
||||
echo "[start] SpacetimeDB database: ${SPACETIME_DATABASE}"
|
||||
START_SCRIPT
|
||||
|
||||
replace_placeholder_in_file "${TARGET_DIR}/start.sh" "__GENARRATIVE_DEFAULT_SPACETIME_HOST__" "${SPACETIME_HOST}"
|
||||
@@ -913,18 +1205,19 @@ STOP_SCRIPT
|
||||
|
||||
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
|
||||
|
||||
构建时间:\`${BUILD_NAME}\`
|
||||
构建时间:`__GENARRATIVE_BUILD_NAME__`
|
||||
|
||||
## 内容
|
||||
|
||||
- \`.env\` / \`.env.local\`:从仓库根目录复制的环境文件,同时各保留一份到 \`web/\`
|
||||
- \`web/\`:Vite release 静态资源
|
||||
- \`web/\`:主前端 Vite release 静态资源,\`web/admin/\` 为后台管理前端静态资源
|
||||
- \`api-server\`:x86_64-unknown-linux-gnu release 可执行文件
|
||||
- \`spacetime_module.wasm\`:wasm32-unknown-unknown release 模块
|
||||
- \`migration-bootstrap-secret.txt\`:本发布包 wasm 编译时注入的迁移引导密钥;服务器 \`start.sh\` 发布时会显示,迁移授权完成后可删除
|
||||
- \`scripts/spacetime-*.mjs\`:部署时 schema 冲突自动导出、导入回灌使用的 SpacetimeDB 迁移脚本
|
||||
- \`web-server.mjs\`:静态网站与 API 反代入口
|
||||
- \`start.sh\` / \`stop.sh\`:目标服务器启动与停止脚本
|
||||
|
||||
@@ -940,21 +1233,31 @@ cat >"${TARGET_DIR}/README.md" <<EOF
|
||||
./start.sh --clear-database
|
||||
\`\`\`
|
||||
|
||||
默认启动会先尝试无清库发布;如果 SpacetimeDB 返回 schema 冲突,\`start.sh\` 会把旧库导出到 \`database-migrations/<database>/\`,随后清库发布新 wasm,并用 \`--replace-existing\` 导入回灌。
|
||||
|
||||
## 入口
|
||||
|
||||
- 主站:\`http://<web-host>:<web-port>/\`
|
||||
- 后台:\`http://<web-host>:<web-port>/admin/\`
|
||||
|
||||
## 环境变量
|
||||
|
||||
- 启动时会先加载发布目录根部的 \`.env\` 与 \`.env.local\`,再回退到脚本内默认值。
|
||||
- 环境文件复制进发布包时会移除 UTF-8 BOM 与 CRLF;启动时也会按 \`KEY=value\` 子集解析,跳过不合法行。
|
||||
- 脚本内默认值来自构建时的 `--database`、`--api-port`、`--web-port`、`--spacetime-host`、`--spacetime-port` 参数。
|
||||
- 脚本内默认值来自构建时的 `--database`、`--api-port`、`--web-host`、`--web-port`、`--spacetime-host`、`--spacetime-port` 参数;Web 默认只监听 `127.0.0.1`。
|
||||
- 默认导出 \`NO_COLOR=1\` 与 \`CARGO_TERM_COLOR=never\`,避免 ANSI 颜色控制码写入日志文件;如确有需要可在启动前显式覆盖。
|
||||
- \`GENARRATIVE_WEB_HOST\` / \`GENARRATIVE_WEB_PORT\`
|
||||
- \`GENARRATIVE_API_HOST\` / \`GENARRATIVE_API_PORT\` / \`GENARRATIVE_API_LOG\`
|
||||
- \`GENARRATIVE_SPACETIME_HOST\` / \`GENARRATIVE_SPACETIME_PORT\`
|
||||
- \`GENARRATIVE_SPACETIME_SERVER_URL\` / \`GENARRATIVE_SPACETIME_DATABASE\`
|
||||
- \`GENARRATIVE_SPACETIME_SERVER_URL\` / \`GENARRATIVE_SPACETIME_DATABASE\` / \`GENARRATIVE_SPACETIME_TOKEN\`
|
||||
- \`GENARRATIVE_SPACETIME_ROOT_DIR\`:默认使用发布目录下的 \`.spacetimedb/\`,同时承载本地 SpacetimeDB 运行数据与 CLI 身份。
|
||||
- \`GENARRATIVE_SPACETIME_TIMEOUT_SECONDS\`:等待 SpacetimeDB 就绪的秒数,默认 \`60\`。
|
||||
- OSS、LLM、短信、微信等业务密钥仍通过目标服务器环境变量或同目录 \`.env.local\` 管理。
|
||||
- \`GENARRATIVE_SPACETIME_MIGRATE_ON_CONFLICT\`:默认 \`true\`,普通发布遇到 schema 冲突时自动导出、清库发布、导入回灌;设为 \`false\` 时保留原始发布失败。
|
||||
- \`GENARRATIVE_SPACETIME_MIGRATION_DIR\`:自动迁移 JSON 输出目录,默认 \`database-migrations/<database>/\`。
|
||||
- OSS、LLM、短信、微信、SpacetimeDB owner token 等业务密钥仍通过目标服务器环境变量或同目录 \`.env.local\` 管理;后台表统计读取 private 表时需要 \`GENARRATIVE_SPACETIME_TOKEN\` 对目标库有 owner 权限。
|
||||
- 迁移引导密钥由构建发布包时随机生成,构建日志和服务器 \`start.sh\` 发布日志都会显示同一份密钥。
|
||||
EOF
|
||||
replace_placeholder_in_file "${TARGET_DIR}/README.md" "__GENARRATIVE_BUILD_NAME__" "${BUILD_NAME}"
|
||||
|
||||
BUILD_COMPLETED=1
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@ wait_for_spacetime() {
|
||||
while ((SECONDS < deadline)); do
|
||||
if [[ -n "${process_pid}" ]] && ! kill -0 "${process_pid}" 2>/dev/null; then
|
||||
echo "[dev:rust] SpacetimeDB 进程在就绪前退出。" >&2
|
||||
print_spacetime_start_failure_diagnostics "${root_dir}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -88,6 +89,7 @@ wait_for_spacetime() {
|
||||
done
|
||||
|
||||
echo "[dev:rust] 等待 SpacetimeDB 就绪超时: ${server}" >&2
|
||||
print_spacetime_start_failure_diagnostics "${root_dir}"
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -96,12 +98,45 @@ is_spacetime_ready() {
|
||||
local root_dir="$2"
|
||||
local output
|
||||
|
||||
if ! output="$(spacetime --root-dir="${root_dir}" server ping "${server}" 2>&1)"; then
|
||||
return 1
|
||||
if output="$(spacetime --root-dir="${root_dir}" server ping "${server}" 2>&1)" &&
|
||||
[[ "${output}" == *"Server is online:"* ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
# SpacetimeDB CLI 2.1.0 在 502 Bad Gateway 时仍可能返回 0,不能只依赖退出码。
|
||||
[[ "${output}" == *"Server is online:"* ]]
|
||||
# SpacetimeDB CLI 2.1.0 在 Windows 下可能对已监听的 standalone 返回 502;
|
||||
# 直接探测 HTTP 健康端点,避免 npm run dev:rust 卡在“等待 SpacetimeDB 就绪”。
|
||||
node -e '
|
||||
const target = new URL("/v1/ping", process.argv[1]);
|
||||
const client = target.protocol === "https:" ? require("https") : require("http");
|
||||
const request = client.get(target, { timeout: 1000 }, (response) => {
|
||||
response.resume();
|
||||
process.exit(response.statusCode >= 200 && response.statusCode < 300 ? 0 : 1);
|
||||
});
|
||||
request.on("timeout", () => request.destroy(new Error("timeout")));
|
||||
request.on("error", () => process.exit(1));
|
||||
' "${server}" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
print_spacetime_start_failure_diagnostics() {
|
||||
local root_dir="$1"
|
||||
local log_file="${root_dir}/data/logs/spacetime-standalone.log"
|
||||
|
||||
echo "[dev:rust] SpacetimeDB root-dir: ${root_dir}" >&2
|
||||
|
||||
if [[ ! -f "${log_file}" ]]; then
|
||||
echo "[dev:rust] 未找到 SpacetimeDB standalone 日志: ${log_file}" >&2
|
||||
return
|
||||
fi
|
||||
|
||||
echo "[dev:rust] 最近 SpacetimeDB standalone 日志: ${log_file}" >&2
|
||||
tail -n 80 "${log_file}" >&2 || true
|
||||
|
||||
if grep -q "mismatched database identity" "${log_file}" 2>/dev/null; then
|
||||
echo "[dev:rust] 检测到本地 replica 与当前数据库 identity 不一致。" >&2
|
||||
echo "[dev:rust] 常见原因是同一个 root-dir 保留了旧库 data/replicas/1,但 control-db 已指向新库。" >&2
|
||||
echo "[dev:rust] 若这是可丢弃的本地开发库,请先停止 SpacetimeDB,再备份或移走 ${root_dir}/data 后重新启动。" >&2
|
||||
echo "[dev:rust] 若需要保留数据,不要清理目录;请改回创建旧库的 database/root-dir,或先走迁移导出。" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
describe_spacetime_root_owner() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {spawn} from 'node:child_process';
|
||||
import {spawn} from 'node:child_process';
|
||||
import {existsSync} from 'node:fs';
|
||||
import {cp, mkdir, readdir, rm, stat} from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
@@ -100,8 +100,15 @@ async function recreateTempDir(dir) {
|
||||
async function replaceGeneratedDir(fromDir, toDir) {
|
||||
assertInside(toDir, REPO_ROOT, '仓库生成目录');
|
||||
await rm(toDir, {recursive: true, force: true});
|
||||
await mkdir(path.dirname(toDir), {recursive: true});
|
||||
await cp(fromDir, toDir, {recursive: true});
|
||||
await mkdir(toDir, {recursive: true});
|
||||
const entries = await readdir(fromDir, {withFileTypes: true});
|
||||
|
||||
for (const entry of entries) {
|
||||
await cp(path.join(fromDir, entry.name), path.join(toDir, entry.name), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function assertInside(candidate, parent, label) {
|
||||
@@ -246,12 +253,13 @@ function run(command, commandArgs, options = {}) {
|
||||
const generatedFormatFailed = output.includes('Could not format generated files');
|
||||
|
||||
if (generatedFormatFailed && options.allowGeneratedFormatFailure) {
|
||||
console.warn(`[spacetime:generate] ${command} generated files but formatting failed; continuing with validation.`);
|
||||
resolve({generatedFormatFailed});
|
||||
return;
|
||||
}
|
||||
|
||||
if (generatedFormatFailed) {
|
||||
reject(new Error(`${command} 生成后格式化失败。`));
|
||||
reject(new Error(`${command} generated files but formatting failed.`));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,20 +5,27 @@ set -euo pipefail
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
用法:
|
||||
./scripts/jenkins-deploy-release.sh --source-dir /path/to/build/123 --deploy-dir /var/lib/jenkins/deploy/Genarrative --web-port 25001 [--clear-database] [--hook-with-sudo]
|
||||
./scripts/jenkins-deploy-release.sh --source-dir /path/to/build/123 --deploy-dir /var/lib/jenkins/deploy/Genarrative --web-port 25001 [--clear-database] [--no-migrate-on-conflict] [--migration-dir /path/to/migrations] [--migration-export-token <token>] [--migration-import-token <token>] [--hook-with-sudo]
|
||||
|
||||
说明:
|
||||
1. 如果部署目录已有旧版本且存在 stop.sh,则先执行旧版本 stop.sh。
|
||||
2. 仅删除并替换发布产物文件或目录,保留部署目录中的运行数据目录。
|
||||
3. 把指定发布目录中的白名单产物复制覆盖到部署目录。
|
||||
3. 把指定发布目录中的白名单产物复制覆盖到部署目录,后台前端随 web/admin/ 一并覆盖。
|
||||
4. 如指定 --clear-database,则以清库模式执行新版本 start.sh。
|
||||
5. 最后执行新版本 start.sh。
|
||||
5. 默认允许新版本 start.sh 在 schema 冲突时自动导出、清库发布、导入回灌。
|
||||
6. 覆盖 .env.local 时保留目标机已有 SpacetimeDB 运行 token,供 api-server 后台概览读取 private 表统计。
|
||||
7. 最后执行新版本 start.sh。
|
||||
|
||||
参数:
|
||||
--source-dir <path> 必填,待部署的发布目录,例如 build/123
|
||||
--deploy-dir <path> 必填,固定部署目录,例如 /var/lib/jenkins/deploy/Genarrative
|
||||
--web-port <port> 必填,本次部署后静态网站监听端口
|
||||
--clear-database 可选,启动新版本时追加 --clear-database
|
||||
--migrate-on-conflict 可选,普通发布遇到 schema 冲突时自动迁移,默认启用
|
||||
--no-migrate-on-conflict 可选,禁用 schema 冲突自动迁移
|
||||
--migration-dir <path> 可选,自动迁移 JSON 输出目录,默认部署目录内 database-migrations/<database>
|
||||
--migration-export-token <token> 可选,旧库已授权迁移操作员 token,仅用于 schema 冲突导出
|
||||
--migration-import-token <token> 可选,新库已授权迁移操作员 token,仅用于 schema 冲突导入
|
||||
--hook-with-sudo 可选,仅对 start.sh/stop.sh 使用 sudo -n 执行
|
||||
EOF
|
||||
}
|
||||
@@ -55,6 +62,15 @@ validate_port() {
|
||||
fi
|
||||
}
|
||||
|
||||
validate_spacetime_database_name() {
|
||||
local database="$1"
|
||||
|
||||
if [[ ! "${database}" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then
|
||||
echo "[jenkins-deploy] GENARRATIVE_SPACETIME_DATABASE 必须匹配 SpacetimeDB 数据库名规则 ^[a-z0-9]+(-[a-z0-9]+)*$,只能使用小写字母、数字,并用单个短横线分隔: ${database}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
normalize_env_file() {
|
||||
local env_file="$1"
|
||||
local temp_file="${env_file}.tmp.$$"
|
||||
@@ -68,6 +84,57 @@ normalize_env_file() {
|
||||
cp "${temp_file}" "${env_file}"
|
||||
}
|
||||
|
||||
read_env_value() {
|
||||
local key="$1"
|
||||
shift
|
||||
local env_file=""
|
||||
local line=""
|
||||
local line_number=0
|
||||
local parsed_key=""
|
||||
local parsed_value=""
|
||||
local value=""
|
||||
local utf8_bom=$'\xef\xbb\xbf'
|
||||
|
||||
for env_file in "$@"; do
|
||||
if [[ ! -f "${env_file}" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
line_number=0
|
||||
while IFS= read -r line || [[ -n "${line}" ]]; do
|
||||
line_number=$((line_number + 1))
|
||||
if [[ "${line_number}" -eq 1 ]]; then
|
||||
line="${line#"${utf8_bom}"}"
|
||||
fi
|
||||
line="${line%$'\r'}"
|
||||
|
||||
if [[ "${line}" =~ ^[[:space:]]*$ || "${line}" =~ ^[[:space:]]*# ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ ! "${line}" =~ ^[[:space:]]*(export[[:space:]]+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
parsed_key="${BASH_REMATCH[2]}"
|
||||
parsed_value="${BASH_REMATCH[3]}"
|
||||
if [[ "${parsed_key}" != "${key}" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
value="${parsed_value}"
|
||||
if [[ "${#value}" -ge 2 && "${value:0:1}" == '"' && "${value: -1}" == '"' ]]; then
|
||||
value="${value:1:${#value}-2}"
|
||||
value="${value//\\\"/\"}"
|
||||
elif [[ "${#value}" -ge 2 && "${value:0:1}" == "'" && "${value: -1}" == "'" ]]; then
|
||||
value="${value:1:${#value}-2}"
|
||||
fi
|
||||
done <"${env_file}"
|
||||
done
|
||||
|
||||
printf "%s" "${value}"
|
||||
}
|
||||
|
||||
normalize_release_env_files() {
|
||||
local release_dir="$1"
|
||||
|
||||
@@ -106,18 +173,31 @@ SOURCE_DIR=""
|
||||
DEPLOY_DIR=""
|
||||
WEB_PORT=""
|
||||
CLEAR_DATABASE="0"
|
||||
MIGRATE_ON_CONFLICT="true"
|
||||
MIGRATION_DIR=""
|
||||
HOOK_WITH_SUDO="0"
|
||||
MIGRATION_EXPORT_TOKEN=""
|
||||
MIGRATION_IMPORT_TOKEN=""
|
||||
PRESERVED_MIGRATION_EXPORT_TOKEN=""
|
||||
PRESERVED_MIGRATION_IMPORT_TOKEN=""
|
||||
PRESERVED_SPACETIME_TOKEN=""
|
||||
PRESERVED_SPACETIME_MAINCLOUD_TOKEN=""
|
||||
DEPLOY_COMPLETED="0"
|
||||
RESTORE_PREVIOUS_MIGRATION_BOOTSTRAP_SECRET_ON_FAILURE="0"
|
||||
DEPLOY_ITEMS=(
|
||||
".env"
|
||||
".env.local"
|
||||
"README.md"
|
||||
"api-server"
|
||||
"migration-bootstrap-secret.txt"
|
||||
"spacetime_module.wasm"
|
||||
"scripts"
|
||||
"start.sh"
|
||||
"stop.sh"
|
||||
"web"
|
||||
"web-server.mjs"
|
||||
)
|
||||
PREVIOUS_MIGRATION_BOOTSTRAP_SECRET_NAME="migration-bootstrap-secret.previous.txt"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
@@ -141,6 +221,26 @@ while [[ $# -gt 0 ]]; do
|
||||
CLEAR_DATABASE="1"
|
||||
shift
|
||||
;;
|
||||
--migrate-on-conflict)
|
||||
MIGRATE_ON_CONFLICT="true"
|
||||
shift
|
||||
;;
|
||||
--no-migrate-on-conflict)
|
||||
MIGRATE_ON_CONFLICT="false"
|
||||
shift
|
||||
;;
|
||||
--migration-dir)
|
||||
MIGRATION_DIR="${2:?缺少 --migration-dir 的值}"
|
||||
shift 2
|
||||
;;
|
||||
--migration-export-token)
|
||||
MIGRATION_EXPORT_TOKEN="${2:?缺少 --migration-export-token 的值}"
|
||||
shift 2
|
||||
;;
|
||||
--migration-import-token)
|
||||
MIGRATION_IMPORT_TOKEN="${2:?缺少 --migration-import-token 的值}"
|
||||
shift 2
|
||||
;;
|
||||
--hook-with-sudo)
|
||||
HOOK_WITH_SUDO="1"
|
||||
shift
|
||||
@@ -188,6 +288,101 @@ run_hook() {
|
||||
)
|
||||
}
|
||||
|
||||
previous_migration_bootstrap_secret_file() {
|
||||
printf "%s/deploy-state/%s" "${DEPLOY_DIR}" "${PREVIOUS_MIGRATION_BOOTSTRAP_SECRET_NAME}"
|
||||
}
|
||||
|
||||
save_previous_migration_bootstrap_secret() {
|
||||
local source_file="${DEPLOY_DIR}/migration-bootstrap-secret.txt"
|
||||
local state_dir="${DEPLOY_DIR}/deploy-state"
|
||||
local target_file
|
||||
|
||||
target_file="$(previous_migration_bootstrap_secret_file)"
|
||||
mkdir -p "${state_dir}" || {
|
||||
echo "[jenkins-deploy] 创建部署状态目录失败: ${state_dir}" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 旧迁移密钥属于部署阶段要维护的状态,不再写入 run/,避免 sudo 启停生成的 root 私有 pid 目录阻断覆盖部署。
|
||||
cp "${source_file}" "${target_file}" || {
|
||||
echo "[jenkins-deploy] 保存旧模块迁移引导密钥失败: ${target_file}" >&2
|
||||
exit 1
|
||||
}
|
||||
chmod 600 "${target_file}" 2>/dev/null || true
|
||||
RESTORE_PREVIOUS_MIGRATION_BOOTSTRAP_SECRET_ON_FAILURE="1"
|
||||
echo "[jenkins-deploy] 已保存旧模块迁移引导密钥,用于 schema 冲突时导出旧库。"
|
||||
}
|
||||
|
||||
restore_previous_migration_bootstrap_secret_on_failure() {
|
||||
local exit_code=$?
|
||||
local source_file=""
|
||||
local target_file=""
|
||||
|
||||
if [[ "${exit_code}" -eq 0 || "${DEPLOY_COMPLETED}" == "1" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "${RESTORE_PREVIOUS_MIGRATION_BOOTSTRAP_SECRET_ON_FAILURE}" != "1" ]]; then
|
||||
exit "${exit_code}"
|
||||
fi
|
||||
|
||||
source_file="$(previous_migration_bootstrap_secret_file)"
|
||||
target_file="${DEPLOY_DIR}/migration-bootstrap-secret.txt"
|
||||
if [[ ! -f "${source_file}" ]]; then
|
||||
echo "[jenkins-deploy] 部署失败,但未找到旧迁移引导密钥快照,无法恢复: ${source_file}" >&2
|
||||
exit "${exit_code}"
|
||||
fi
|
||||
|
||||
if cp "${source_file}" "${target_file}"; then
|
||||
chmod 600 "${target_file}" 2>/dev/null || true
|
||||
echo "[jenkins-deploy] 部署失败,已恢复旧迁移引导密钥: ${target_file}" >&2
|
||||
else
|
||||
echo "[jenkins-deploy] 部署失败,且恢复旧迁移引导密钥失败: ${target_file}" >&2
|
||||
fi
|
||||
|
||||
exit "${exit_code}"
|
||||
}
|
||||
|
||||
clear_previous_migration_bootstrap_secret() {
|
||||
local target_file
|
||||
|
||||
target_file="$(previous_migration_bootstrap_secret_file)"
|
||||
if [[ ! -e "${target_file}" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
rm -f "${target_file}" || {
|
||||
echo "[jenkins-deploy] 清理旧迁移引导密钥快照失败: ${target_file}" >&2
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
normalize_start_previous_secret_path() {
|
||||
local start_file="${DEPLOY_DIR}/start.sh"
|
||||
local legacy_line='PREVIOUS_MIGRATION_BOOTSTRAP_SECRET_FILE="${SCRIPT_DIR}/run/migration-bootstrap-secret.previous.txt"'
|
||||
local state_line='PREVIOUS_MIGRATION_BOOTSTRAP_SECRET_FILE="${SCRIPT_DIR}/deploy-state/migration-bootstrap-secret.previous.txt"'
|
||||
local temp_file="${start_file}.tmp.$$"
|
||||
|
||||
if [[ ! -f "${start_file}" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
if grep -Fq "${legacy_line}" "${start_file}"; then
|
||||
# 兼容已经构建出的旧发布包:部署阶段统一让 start.sh 从 Jenkins 可写的部署状态目录读取旧密钥。
|
||||
awk -v legacy="${legacy_line}" -v state="${state_line}" '
|
||||
$0 == legacy {
|
||||
print state
|
||||
next
|
||||
}
|
||||
{
|
||||
print
|
||||
}
|
||||
' "${start_file}" >"${temp_file}"
|
||||
cp "${temp_file}" "${start_file}"
|
||||
rm -f "${temp_file}"
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ ! -d "${SOURCE_DIR}" ]]; then
|
||||
echo "[jenkins-deploy] 发布目录不存在: ${SOURCE_DIR}" >&2
|
||||
exit 1
|
||||
@@ -196,6 +391,7 @@ fi
|
||||
SOURCE_DIR="$(cd "${SOURCE_DIR}" && pwd)"
|
||||
mkdir -p "${DEPLOY_DIR}"
|
||||
DEPLOY_DIR="$(cd "${DEPLOY_DIR}" && pwd)"
|
||||
trap restore_previous_migration_bootstrap_secret_on_failure EXIT
|
||||
|
||||
if [[ ! -f "${SOURCE_DIR}/start.sh" ]]; then
|
||||
echo "[jenkins-deploy] 发布目录缺少 start.sh: ${SOURCE_DIR}" >&2
|
||||
@@ -203,6 +399,10 @@ if [[ ! -f "${SOURCE_DIR}/start.sh" ]]; then
|
||||
fi
|
||||
|
||||
normalize_release_env_files "${SOURCE_DIR}"
|
||||
PRESERVED_MIGRATION_EXPORT_TOKEN="$(read_env_value "GENARRATIVE_SPACETIME_MIGRATION_EXPORT_TOKEN" "${DEPLOY_DIR}/.env" "${DEPLOY_DIR}/.env.local")"
|
||||
PRESERVED_MIGRATION_IMPORT_TOKEN="$(read_env_value "GENARRATIVE_SPACETIME_MIGRATION_IMPORT_TOKEN" "${DEPLOY_DIR}/.env" "${DEPLOY_DIR}/.env.local")"
|
||||
PRESERVED_SPACETIME_TOKEN="$(read_env_value "GENARRATIVE_SPACETIME_TOKEN" "${DEPLOY_DIR}/.env" "${DEPLOY_DIR}/.env.local")"
|
||||
PRESERVED_SPACETIME_MAINCLOUD_TOKEN="$(read_env_value "GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN" "${DEPLOY_DIR}/.env" "${DEPLOY_DIR}/.env.local")"
|
||||
|
||||
if [[ -x "${DEPLOY_DIR}/stop.sh" ]]; then
|
||||
echo "[jenkins-deploy] 先停止旧版本: ${DEPLOY_DIR}"
|
||||
@@ -211,6 +411,12 @@ else
|
||||
echo "[jenkins-deploy] 部署目录无可执行 stop.sh,跳过停服"
|
||||
fi
|
||||
|
||||
if [[ -f "${DEPLOY_DIR}/migration-bootstrap-secret.txt" ]]; then
|
||||
save_previous_migration_bootstrap_secret
|
||||
else
|
||||
clear_previous_migration_bootstrap_secret
|
||||
fi
|
||||
|
||||
echo "[jenkins-deploy] 清空部署目录: ${DEPLOY_DIR}"
|
||||
for item in "${DEPLOY_ITEMS[@]}"; do
|
||||
if [[ -e "${DEPLOY_DIR}/${item}" ]]; then
|
||||
@@ -233,6 +439,8 @@ for item in "${DEPLOY_ITEMS[@]}"; do
|
||||
fi
|
||||
done
|
||||
|
||||
normalize_start_previous_secret_path
|
||||
|
||||
chmod +x "${DEPLOY_DIR}/start.sh"
|
||||
|
||||
if [[ -f "${DEPLOY_DIR}/stop.sh" ]]; then
|
||||
@@ -241,6 +449,38 @@ fi
|
||||
|
||||
normalize_release_env_files "${DEPLOY_DIR}"
|
||||
write_env_override "${DEPLOY_DIR}/.env.local" "GENARRATIVE_WEB_PORT" "${WEB_PORT}"
|
||||
write_env_override "${DEPLOY_DIR}/.env.local" "GENARRATIVE_SPACETIME_MIGRATE_ON_CONFLICT" "${MIGRATE_ON_CONFLICT}"
|
||||
write_env_override "${DEPLOY_DIR}/.env.local" "GENARRATIVE_SPACETIME_MIGRATION_DIR" "${MIGRATION_DIR}"
|
||||
if [[ -n "${MIGRATION_EXPORT_TOKEN}" ]]; then
|
||||
write_env_override "${DEPLOY_DIR}/.env.local" "GENARRATIVE_SPACETIME_MIGRATION_EXPORT_TOKEN" "${MIGRATION_EXPORT_TOKEN}"
|
||||
elif [[ -n "${PRESERVED_MIGRATION_EXPORT_TOKEN}" ]] \
|
||||
&& [[ -z "$(read_env_value "GENARRATIVE_SPACETIME_MIGRATION_EXPORT_TOKEN" "${DEPLOY_DIR}/.env" "${DEPLOY_DIR}/.env.local")" ]]; then
|
||||
write_env_override "${DEPLOY_DIR}/.env.local" "GENARRATIVE_SPACETIME_MIGRATION_EXPORT_TOKEN" "${PRESERVED_MIGRATION_EXPORT_TOKEN}"
|
||||
fi
|
||||
if [[ -n "${MIGRATION_IMPORT_TOKEN}" ]]; then
|
||||
write_env_override "${DEPLOY_DIR}/.env.local" "GENARRATIVE_SPACETIME_MIGRATION_IMPORT_TOKEN" "${MIGRATION_IMPORT_TOKEN}"
|
||||
elif [[ -n "${PRESERVED_MIGRATION_IMPORT_TOKEN}" ]] \
|
||||
&& [[ -z "$(read_env_value "GENARRATIVE_SPACETIME_MIGRATION_IMPORT_TOKEN" "${DEPLOY_DIR}/.env" "${DEPLOY_DIR}/.env.local")" ]]; then
|
||||
write_env_override "${DEPLOY_DIR}/.env.local" "GENARRATIVE_SPACETIME_MIGRATION_IMPORT_TOKEN" "${PRESERVED_MIGRATION_IMPORT_TOKEN}"
|
||||
fi
|
||||
if [[ -n "${PRESERVED_SPACETIME_TOKEN}" ]] \
|
||||
&& [[ -z "$(read_env_value "GENARRATIVE_SPACETIME_TOKEN" "${DEPLOY_DIR}/.env" "${DEPLOY_DIR}/.env.local")" ]] \
|
||||
&& [[ -z "$(read_env_value "GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN" "${DEPLOY_DIR}/.env" "${DEPLOY_DIR}/.env.local")" ]]; then
|
||||
write_env_override "${DEPLOY_DIR}/.env.local" "GENARRATIVE_SPACETIME_TOKEN" "${PRESERVED_SPACETIME_TOKEN}"
|
||||
fi
|
||||
if [[ -n "${PRESERVED_SPACETIME_MAINCLOUD_TOKEN}" ]] \
|
||||
&& [[ -z "$(read_env_value "GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN" "${DEPLOY_DIR}/.env" "${DEPLOY_DIR}/.env.local")" ]] \
|
||||
&& [[ -z "$(read_env_value "GENARRATIVE_SPACETIME_TOKEN" "${DEPLOY_DIR}/.env" "${DEPLOY_DIR}/.env.local")" ]]; then
|
||||
write_env_override "${DEPLOY_DIR}/.env.local" "GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN" "${PRESERVED_SPACETIME_MAINCLOUD_TOKEN}"
|
||||
fi
|
||||
|
||||
DEPLOY_DATABASE="$(read_env_value "GENARRATIVE_SPACETIME_DATABASE" "${DEPLOY_DIR}/.env" "${DEPLOY_DIR}/.env.local")"
|
||||
if [[ -z "${DEPLOY_DATABASE}" ]]; then
|
||||
echo "[jenkins-deploy] 部署包未显式写入 GENARRATIVE_SPACETIME_DATABASE;将由 start.sh 使用构建时默认值。" >&2
|
||||
else
|
||||
validate_spacetime_database_name "${DEPLOY_DATABASE}"
|
||||
echo "[jenkins-deploy] SpacetimeDB 发布数据库: ${DEPLOY_DATABASE}"
|
||||
fi
|
||||
|
||||
echo "[jenkins-deploy] 启动新版本: ${DEPLOY_DIR}"
|
||||
if [[ "${CLEAR_DATABASE}" == "1" ]]; then
|
||||
@@ -251,3 +491,4 @@ else
|
||||
fi
|
||||
|
||||
echo "[jenkins-deploy] 完成"
|
||||
DEPLOY_COMPLETED="1"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import {
|
||||
callSpacetimeProcedure,
|
||||
callSpacetimeProcedureViaCli,
|
||||
ensureProcedureOk,
|
||||
parseArgs,
|
||||
@@ -17,11 +18,16 @@ try {
|
||||
operator_identity_hex: options.operatorIdentity,
|
||||
note: options.note || '',
|
||||
};
|
||||
const result = await callSpacetimeProcedureViaCli(
|
||||
options,
|
||||
'authorize_database_migration_operator',
|
||||
input,
|
||||
);
|
||||
if (options.useHttp && !options.token) {
|
||||
throw new Error('--use-http 需要同时传入 --token。');
|
||||
}
|
||||
const result = options.useHttp
|
||||
? await callSpacetimeProcedure(options, 'authorize_database_migration_operator', input)
|
||||
: await callSpacetimeProcedureViaCli(
|
||||
options,
|
||||
'authorize_database_migration_operator',
|
||||
input,
|
||||
);
|
||||
ensureProcedureOk(result);
|
||||
|
||||
console.log(
|
||||
|
||||
@@ -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,66 @@ 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}`,
|
||||
);
|
||||
|
||||
try {
|
||||
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);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`授权临时 Web API identity 失败。当前 spacetime CLI identity 必须已经是迁移操作员;如果旧库迁移操作员表不为空,bootstrap secret 不会越权授权新的操作员。可先用已有迁移操作员授权当前部署机 identity,或为导出脚本提供已有迁移操作员的 --token。原始错误: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
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 +118,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,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
@@ -11,6 +12,8 @@ import {
|
||||
parseArgs,
|
||||
} from './spacetime-migration-common.mjs';
|
||||
|
||||
const DEFAULT_MIGRATION_IMPORT_CHUNK_SIZE = 512 * 1024;
|
||||
|
||||
try {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (!options.in) {
|
||||
@@ -30,7 +33,7 @@ try {
|
||||
const webOptions = await prepareWebImportOptions(options);
|
||||
let result;
|
||||
try {
|
||||
result = await importMigrationJsonDirect(webOptions, migrationJson);
|
||||
result = await importMigrationJsonWithFallback(webOptions, migrationJson);
|
||||
} finally {
|
||||
await revokeTemporaryWebIdentity(webOptions);
|
||||
}
|
||||
@@ -40,6 +43,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)}`,
|
||||
@@ -57,16 +61,24 @@ async function prepareWebImportOptions(options) {
|
||||
`[spacetime:migration:import] 已通过 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 import',
|
||||
},
|
||||
);
|
||||
ensureProcedureOk(authorizeResult);
|
||||
try {
|
||||
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 import',
|
||||
},
|
||||
);
|
||||
ensureProcedureOk(authorizeResult);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`授权临时 Web API identity 失败。当前 spacetime CLI identity 必须已经是迁移操作员;如果目标库迁移操作员表不为空,bootstrap secret 不会越权授权新的操作员。可先用已有迁移操作员授权当前部署机 identity,或为导入脚本提供已有迁移操作员的 --token。原始错误: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
console.log(`[spacetime:migration:import] 已授权临时 Web API identity`);
|
||||
|
||||
return {
|
||||
@@ -77,6 +89,25 @@ async function prepareWebImportOptions(options) {
|
||||
};
|
||||
}
|
||||
|
||||
async function importMigrationJsonWithFallback(options, migrationJson) {
|
||||
const chunkSize = resolveChunkSize(options);
|
||||
if (Buffer.byteLength(migrationJson, 'utf8') > chunkSize) {
|
||||
return importMigrationJsonChunked(options, migrationJson, chunkSize);
|
||||
}
|
||||
|
||||
try {
|
||||
return await importMigrationJsonDirect(options, migrationJson);
|
||||
} catch (error) {
|
||||
if (!isRequestBodyTooLargeError(error)) {
|
||||
throw error;
|
||||
}
|
||||
console.warn(
|
||||
`[spacetime:migration:import] 直接导入触发 HTTP 413,改用 ${chunkSize} bytes 分片上传。`,
|
||||
);
|
||||
return importMigrationJsonChunked(options, migrationJson, chunkSize);
|
||||
}
|
||||
}
|
||||
|
||||
async function importMigrationJsonDirect(options, migrationJson) {
|
||||
const includeTables = resolveImportIncludeTables(options, migrationJson);
|
||||
const procedureName =
|
||||
@@ -99,6 +130,60 @@ async function importMigrationJsonDirect(options, migrationJson) {
|
||||
return callSpacetimeProcedure(options, procedureName, input);
|
||||
}
|
||||
|
||||
async function importMigrationJsonChunked(options, migrationJson, chunkSize) {
|
||||
const includeTables = resolveImportIncludeTables(options, migrationJson);
|
||||
const procedureName =
|
||||
options.incremental === true
|
||||
? 'import_database_migration_incremental_from_chunks'
|
||||
: 'import_database_migration_from_chunks';
|
||||
const uploadId = `migration-${Date.now()}-${randomUUID()}`;
|
||||
const chunks = splitStringByUtf8Bytes(migrationJson, chunkSize);
|
||||
console.log(
|
||||
`[spacetime:migration:import] 使用分片导入: upload_id=${uploadId}, chunks=${chunks.length}, chunk_size=${chunkSize}`,
|
||||
);
|
||||
if (options.replaceExisting === true) {
|
||||
console.log(
|
||||
`[spacetime:migration:import] replace-existing 仅覆盖本次文件内的表: ${includeTables.join(', ') || '无'}`,
|
||||
);
|
||||
} else if (options.incremental === true) {
|
||||
console.log(`[spacetime:migration:import] 使用增量模式,已存在或冲突的行会跳过`);
|
||||
}
|
||||
|
||||
let committed = false;
|
||||
try {
|
||||
for (let index = 0; index < chunks.length; index += 1) {
|
||||
const chunkResult = await callSpacetimeProcedure(
|
||||
options,
|
||||
'put_database_migration_import_chunk',
|
||||
{
|
||||
upload_id: uploadId,
|
||||
chunk_index: index,
|
||||
chunk_count: chunks.length,
|
||||
chunk: chunks[index],
|
||||
},
|
||||
);
|
||||
ensureProcedureOk(chunkResult);
|
||||
console.log(
|
||||
`[spacetime:migration:import] 已上传迁移分片 ${index + 1}/${chunks.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await callSpacetimeProcedure(options, procedureName, {
|
||||
upload_id: uploadId,
|
||||
include_tables: includeTables,
|
||||
replace_existing: options.replaceExisting === true,
|
||||
dry_run: options.dryRun === true,
|
||||
});
|
||||
ensureProcedureOk(result);
|
||||
committed = true;
|
||||
return result;
|
||||
} finally {
|
||||
if (!committed) {
|
||||
await clearMigrationChunksBestEffort(options, uploadId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveImportIncludeTables(options, migrationJson) {
|
||||
if (options.replaceExisting !== true) {
|
||||
return options.includeTables;
|
||||
@@ -143,6 +228,63 @@ function readMigrationTableNames(migrationJson) {
|
||||
return tableNames;
|
||||
}
|
||||
|
||||
function resolveChunkSize(options) {
|
||||
const chunkSize = options.chunkSize || DEFAULT_MIGRATION_IMPORT_CHUNK_SIZE;
|
||||
if (chunkSize > 1024 * 1024) {
|
||||
throw new Error('--chunk-size 不能超过 1048576,避免触发迁移分片 procedure 单片限制。');
|
||||
}
|
||||
return chunkSize;
|
||||
}
|
||||
|
||||
function splitStringByUtf8Bytes(value, maxBytes) {
|
||||
const chunks = [];
|
||||
let current = '';
|
||||
let currentBytes = 0;
|
||||
for (const character of value) {
|
||||
const characterBytes = Buffer.byteLength(character, 'utf8');
|
||||
if (characterBytes > maxBytes) {
|
||||
throw new Error(`单个字符超过 chunk-size,当前 chunk-size: ${maxBytes}`);
|
||||
}
|
||||
if (currentBytes + characterBytes > maxBytes && current) {
|
||||
chunks.push(current);
|
||||
current = '';
|
||||
currentBytes = 0;
|
||||
}
|
||||
current += character;
|
||||
currentBytes += characterBytes;
|
||||
}
|
||||
if (current) {
|
||||
chunks.push(current);
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function isRequestBodyTooLargeError(error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return (
|
||||
message.includes('HTTP 413') ||
|
||||
message.toLowerCase().includes('length limit exceeded')
|
||||
);
|
||||
}
|
||||
|
||||
async function clearMigrationChunksBestEffort(options, uploadId) {
|
||||
try {
|
||||
const result = await callSpacetimeProcedure(
|
||||
options,
|
||||
'clear_database_migration_import_chunks',
|
||||
{ upload_id: uploadId },
|
||||
);
|
||||
ensureProcedureOk(result);
|
||||
console.warn(`[spacetime:migration:import] 已清理失败导入的临时分片: ${uploadId}`);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[spacetime:migration:import] 清理临时迁移分片失败: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeTemporaryWebIdentity(options) {
|
||||
if (!options.temporaryWebIdentity) {
|
||||
return;
|
||||
@@ -177,3 +319,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,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,10 @@ import path from 'node:path';
|
||||
|
||||
export function parseArgs(argv) {
|
||||
const options = {
|
||||
chunkSize: parseOptionalPositiveInteger(
|
||||
process.env.GENARRATIVE_SPACETIME_MIGRATION_CHUNK_SIZE,
|
||||
'GENARRATIVE_SPACETIME_MIGRATION_CHUNK_SIZE',
|
||||
),
|
||||
database:
|
||||
process.env.GENARRATIVE_SPACETIME_MAINCLOUD_DATABASE ||
|
||||
process.env.GENARRATIVE_SPACETIME_DATABASE ||
|
||||
@@ -48,6 +52,8 @@ export function parseArgs(argv) {
|
||||
options.token = readValue(arg);
|
||||
} else if (arg === '--bootstrap-secret') {
|
||||
options.bootstrapSecret = readValue(arg);
|
||||
} else if (arg === '--chunk-size') {
|
||||
options.chunkSize = parsePositiveInteger(readValue(arg), arg);
|
||||
} else if (arg === '--operator-identity') {
|
||||
options.operatorIdentity = readValue(arg);
|
||||
} else if (arg === '--note') {
|
||||
@@ -81,10 +87,30 @@ export function parseArgs(argv) {
|
||||
return options;
|
||||
}
|
||||
|
||||
export function parsePositiveInteger(value, name) {
|
||||
if (!/^[1-9][0-9]*$/u.test(String(value).trim())) {
|
||||
throw new Error(`${name} 必须是正整数。`);
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(String(value).trim(), 10);
|
||||
if (!Number.isSafeInteger(parsed)) {
|
||||
throw new Error(`${name} 超出安全整数范围。`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseOptionalPositiveInteger(value, name) {
|
||||
if (!value) {
|
||||
return 0;
|
||||
}
|
||||
return parsePositiveInteger(value, name);
|
||||
}
|
||||
|
||||
export function buildSpacetimeCallArgs(options, procedureName, input) {
|
||||
if (!options.database) {
|
||||
throw new Error('必须传入 --database。');
|
||||
}
|
||||
validateSpacetimeDatabaseName(options.database);
|
||||
|
||||
const args = [];
|
||||
if (options.rootDir) {
|
||||
@@ -93,8 +119,13 @@ 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);
|
||||
if (!options.passthrough.includes('--no-config')) {
|
||||
args.push('--no-config');
|
||||
}
|
||||
args.push(options.database, procedureName, JSON.stringify(input), '-y');
|
||||
return args;
|
||||
}
|
||||
@@ -103,6 +134,7 @@ export async function callSpacetimeProcedure(options, procedureName, input) {
|
||||
if (!options.database) {
|
||||
throw new Error('必须传入 --database,或设置 GENARRATIVE_SPACETIME_DATABASE。');
|
||||
}
|
||||
validateSpacetimeDatabaseName(options.database);
|
||||
|
||||
const serverUrl = resolveServerUrl(options).replace(/\/+$/u, '');
|
||||
const url = `${serverUrl}/v1/database/${encodeURIComponent(options.database)}/call/${encodeURIComponent(procedureName)}`;
|
||||
@@ -190,6 +222,14 @@ export async function callSpacetimeProcedureViaCli(options, procedureName, input
|
||||
return parseProcedureResult(output);
|
||||
}
|
||||
|
||||
export function validateSpacetimeDatabaseName(database) {
|
||||
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/u.test(database)) {
|
||||
throw new Error(
|
||||
`SpacetimeDB 数据库名必须匹配 ^[a-z0-9]+(-[a-z0-9]+)*$,只能使用小写字母、数字,并用单个短横线分隔: ${database}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseProcedureResult(output) {
|
||||
const candidates = [];
|
||||
const trimmed = output.trim();
|
||||
@@ -250,12 +290,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 +361,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;
|
||||
|
||||
@@ -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,84 @@ prepare_migration_bootstrap_secret() {
|
||||
echo "[spacetime:maincloud] 迁移引导密钥: ${MIGRATION_BOOTSTRAP_SECRET}"
|
||||
}
|
||||
|
||||
timestamp_slug() {
|
||||
node -e 'process.stdout.write(new Date().toISOString().replace(/[:.]/g, "-"));'
|
||||
}
|
||||
|
||||
validate_spacetime_database_name() {
|
||||
local database="$1"
|
||||
|
||||
if [[ ! "${database}" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then
|
||||
echo "[spacetime:maincloud] --database 必须匹配 SpacetimeDB 数据库名规则 ^[a-z0-9]+(-[a-z0-9]+)*$,只能使用小写字母、数字,并用单个短横线分隔: ${database}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
is_publish_conflict_output() {
|
||||
local output="$1"
|
||||
[[ "${output}" == *"conflict"* ]] \
|
||||
|| [[ "${output}" == *"schema"* && "${output}" == *"clear"* ]] \
|
||||
|| [[ "${output}" == *"manual migration"* ]] \
|
||||
|| [[ "${output}" == *"default value annotation"* ]] \
|
||||
|| [[ "${output}" == *"delete-data"* ]]
|
||||
}
|
||||
|
||||
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 +186,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"
|
||||
@@ -128,6 +218,11 @@ if [[ -z "${SPACETIME_DATABASE}" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
validate_spacetime_database_name "${SPACETIME_DATABASE}"
|
||||
|
||||
echo "[spacetime:maincloud] SpacetimeDB 发布数据库: ${SPACETIME_DATABASE}"
|
||||
echo "[spacetime:maincloud] SpacetimeDB server: ${SPACETIME_SERVER_ALIAS} (${SPACETIME_SERVER_URL})"
|
||||
|
||||
if ! command -v cargo >/dev/null 2>&1; then
|
||||
echo "[spacetime:maincloud] 缺少 cargo 命令。" >&2
|
||||
exit 1
|
||||
@@ -166,7 +261,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 可使用以下环境:
|
||||
|
||||
Reference in New Issue
Block a user