feat: add spacetimedb json migration tooling
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
@@ -27,7 +27,8 @@ usage() {
|
||||
--skip-upload 只生成本地发布包,不上传服务器
|
||||
--skip-web-build 跳过 Vite 构建,仅用于调试
|
||||
--skip-api-build 跳过 api-server 构建,仅用于调试
|
||||
--skip-spacetime-build 跳过 wasm 构建,仅用于调试
|
||||
--skip-spacetime-build 跳过 wasm 构建,仅用于调试;此时必须同时传 --no-migration-bootstrap-secret
|
||||
--no-migration-bootstrap-secret 构建不带迁移引导密钥的 spacetime-module wasm
|
||||
|
||||
目标服务器要求:
|
||||
Ubuntu x86_64,已安装 node、spacetime CLI,并允许执行目标目录内的 start.sh / stop.sh。
|
||||
@@ -127,6 +128,36 @@ replace_placeholder_in_file() {
|
||||
sed -i "s|${placeholder}|${escaped_value}|g" "${file_path}"
|
||||
}
|
||||
|
||||
generate_migration_bootstrap_secret() {
|
||||
node -e 'const crypto = require("crypto"); process.stdout.write(crypto.randomBytes(32).toString("hex"));'
|
||||
}
|
||||
|
||||
prepare_migration_bootstrap_secret() {
|
||||
case "${MIGRATION_BOOTSTRAP_SECRET_MODE}" in
|
||||
auto)
|
||||
MIGRATION_BOOTSTRAP_SECRET="$(generate_migration_bootstrap_secret)"
|
||||
;;
|
||||
manual)
|
||||
if [[ "${#MIGRATION_BOOTSTRAP_SECRET}" -lt 16 ]]; then
|
||||
echo "[deploy:rust] 迁移引导密钥至少需要 16 个字符。" >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
disabled)
|
||||
unset GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET
|
||||
echo "[deploy:rust] 未启用迁移引导密钥。"
|
||||
return
|
||||
;;
|
||||
*)
|
||||
echo "[deploy:rust] 未知迁移引导密钥模式: ${MIGRATION_BOOTSTRAP_SECRET_MODE}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
export GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET="${MIGRATION_BOOTSTRAP_SECRET}"
|
||||
echo "[deploy:rust] 迁移引导密钥: ${MIGRATION_BOOTSTRAP_SECRET}"
|
||||
}
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
|
||||
SERVER_RS_DIR="${REPO_ROOT}/server-rs"
|
||||
@@ -147,6 +178,8 @@ SKIP_WEB_BUILD=0
|
||||
SKIP_API_BUILD=0
|
||||
SKIP_SPACETIME_BUILD=0
|
||||
BUILD_COMPLETED=0
|
||||
MIGRATION_BOOTSTRAP_SECRET=""
|
||||
MIGRATION_BOOTSTRAP_SECRET_MODE="auto"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
@@ -214,6 +247,16 @@ while [[ $# -gt 0 ]]; do
|
||||
SKIP_SPACETIME_BUILD=1
|
||||
shift
|
||||
;;
|
||||
--migration-bootstrap-secret)
|
||||
MIGRATION_BOOTSTRAP_SECRET="${2:?缺少 --migration-bootstrap-secret 的值}"
|
||||
MIGRATION_BOOTSTRAP_SECRET_MODE="manual"
|
||||
shift 2
|
||||
;;
|
||||
--no-migration-bootstrap-secret)
|
||||
MIGRATION_BOOTSTRAP_SECRET=""
|
||||
MIGRATION_BOOTSTRAP_SECRET_MODE="disabled"
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
echo "[deploy:rust] 未知参数: $1" >&2
|
||||
usage >&2
|
||||
@@ -227,6 +270,12 @@ if [[ ! "${BUILD_NAME}" =~ ^[0-9A-Za-z._-]+$ ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
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
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TARGET_DIR="${BUILD_ROOT}/${BUILD_NAME}"
|
||||
WEB_DIR="${TARGET_DIR}/web"
|
||||
API_BINARY_SOURCE="${SERVER_RS_DIR}/target/x86_64-unknown-linux-gnu/release/api-server"
|
||||
@@ -249,6 +298,8 @@ fi
|
||||
require_command node
|
||||
require_command cargo
|
||||
|
||||
prepare_migration_bootstrap_secret
|
||||
|
||||
if [[ "${SKIP_WEB_BUILD}" -ne 1 ]]; then
|
||||
require_command npm
|
||||
fi
|
||||
@@ -310,6 +361,11 @@ fi
|
||||
|
||||
copy_required_file "${WASM_SOURCE}" "${TARGET_DIR}/spacetime_module.wasm" "spacetime-module wasm"
|
||||
|
||||
if [[ "${MIGRATION_BOOTSTRAP_SECRET_MODE}" != "disabled" ]]; then
|
||||
printf "%s\n" "${MIGRATION_BOOTSTRAP_SECRET}" >"${TARGET_DIR}/migration-bootstrap-secret.txt"
|
||||
chmod 600 "${TARGET_DIR}/migration-bootstrap-secret.txt"
|
||||
fi
|
||||
|
||||
cat >"${TARGET_DIR}/web-server.mjs" <<'WEB_SERVER'
|
||||
import http from 'node:http';
|
||||
import fs from 'node:fs';
|
||||
@@ -529,6 +585,7 @@ 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"
|
||||
|
||||
# 日志默认落文件,显式关闭 ANSI 颜色码,避免控制字符写入 *.log。
|
||||
export NO_COLOR="${NO_COLOR:-1}"
|
||||
@@ -778,6 +835,11 @@ if [[ "${CLEAR_DATABASE}" -eq 1 ]]; then
|
||||
fi
|
||||
|
||||
echo "[start] 发布 SpacetimeDB wasm: ${SPACETIME_DATABASE}"
|
||||
if [[ -f "${MIGRATION_BOOTSTRAP_SECRET_FILE}" ]]; then
|
||||
echo "[start] 迁移引导密钥: $(cat "${MIGRATION_BOOTSTRAP_SECRET_FILE}")"
|
||||
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
|
||||
@@ -868,6 +930,7 @@ cat >"${TARGET_DIR}/README.md" <<EOF
|
||||
- \`web/\`:Vite release 静态资源
|
||||
- \`api-server\`:x86_64-unknown-linux-gnu release 可执行文件
|
||||
- \`spacetime_module.wasm\`:wasm32-unknown-unknown release 模块
|
||||
- \`migration-bootstrap-secret.txt\`:本发布包 wasm 编译时注入的迁移引导密钥;服务器 \`start.sh\` 发布时会显示,迁移授权完成后可删除
|
||||
- \`web-server.mjs\`:静态网站与 API 反代入口
|
||||
- \`start.sh\` / \`stop.sh\`:目标服务器启动与停止脚本
|
||||
|
||||
@@ -896,6 +959,7 @@ cat >"${TARGET_DIR}/README.md" <<EOF
|
||||
- \`GENARRATIVE_SPACETIME_ROOT_DIR\`:默认使用发布目录下的 \`.spacetimedb/\`,同时承载本地 SpacetimeDB 运行数据与 CLI 身份。
|
||||
- \`GENARRATIVE_SPACETIME_TIMEOUT_SECONDS\`:等待 SpacetimeDB 就绪的秒数,默认 \`60\`。
|
||||
- OSS、LLM、短信、微信等业务密钥仍通过目标服务器环境变量或同目录 \`.env.local\` 管理。
|
||||
- 迁移引导密钥由构建发布包时随机生成,构建日志和服务器 \`start.sh\` 发布日志都会显示同一份密钥。
|
||||
EOF
|
||||
|
||||
BUILD_COMPLETED=1
|
||||
|
||||
@@ -10,6 +10,7 @@ usage() {
|
||||
./scripts/dev-rust-stack.sh --api-timeout-seconds 600
|
||||
./scripts/dev-rust-stack.sh --skip-spacetime --skip-publish
|
||||
./scripts/dev-rust-stack.sh --preserve-database
|
||||
./scripts/dev-rust-stack.sh --no-migration-bootstrap-secret
|
||||
npm run dev:rust:logs -- --follow
|
||||
|
||||
说明:
|
||||
@@ -17,6 +18,7 @@ usage() {
|
||||
2. 当前开发阶段默认 publish server-rs/crates/spacetime-module 时追加 -c=on-conflict 在结构冲突时清理旧模块数据。
|
||||
3. 只有显式传入 --preserve-database 时,才会跳过 -c=on-conflict。
|
||||
4. SpacetimeDB 默认使用 server-rs/.spacetimedb/local 作为本地数据与日志目录。
|
||||
5. 默认在发布模块前随机生成迁移引导密钥,注入 GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET 并显示在控制台。
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -223,6 +225,36 @@ sync_local_spacetime_install() {
|
||||
fi
|
||||
}
|
||||
|
||||
generate_migration_bootstrap_secret() {
|
||||
node -e 'const crypto = require("crypto"); process.stdout.write(crypto.randomBytes(32).toString("hex"));'
|
||||
}
|
||||
|
||||
prepare_migration_bootstrap_secret() {
|
||||
case "${MIGRATION_BOOTSTRAP_SECRET_MODE}" in
|
||||
auto)
|
||||
MIGRATION_BOOTSTRAP_SECRET="$(generate_migration_bootstrap_secret)"
|
||||
;;
|
||||
manual)
|
||||
if [[ "${#MIGRATION_BOOTSTRAP_SECRET}" -lt 16 ]]; then
|
||||
echo "[dev:rust] 迁移引导密钥至少需要 16 个字符。" >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
disabled)
|
||||
unset GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET
|
||||
echo "[dev:rust] 未启用迁移引导密钥。"
|
||||
return
|
||||
;;
|
||||
*)
|
||||
echo "[dev:rust] 未知迁移引导密钥模式: ${MIGRATION_BOOTSTRAP_SECRET_MODE}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
export GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET="${MIGRATION_BOOTSTRAP_SECRET}"
|
||||
echo "[dev:rust] 迁移引导密钥: ${MIGRATION_BOOTSTRAP_SECRET}"
|
||||
}
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
|
||||
SERVER_RS_DIR="${REPO_ROOT}/server-rs"
|
||||
@@ -244,6 +276,8 @@ API_SERVER_TIMEOUT_SECONDS="300"
|
||||
SKIP_SPACETIME=0
|
||||
SKIP_PUBLISH=0
|
||||
PRESERVE_DATABASE=0
|
||||
MIGRATION_BOOTSTRAP_SECRET=""
|
||||
MIGRATION_BOOTSTRAP_SECRET_MODE="auto"
|
||||
PIDS=()
|
||||
NAMES=()
|
||||
|
||||
@@ -334,6 +368,16 @@ while [[ $# -gt 0 ]]; do
|
||||
PRESERVE_DATABASE=1
|
||||
shift
|
||||
;;
|
||||
--migration-bootstrap-secret)
|
||||
MIGRATION_BOOTSTRAP_SECRET="${2:?缺少 --migration-bootstrap-secret 的值}"
|
||||
MIGRATION_BOOTSTRAP_SECRET_MODE="manual"
|
||||
shift 2
|
||||
;;
|
||||
--no-migration-bootstrap-secret)
|
||||
MIGRATION_BOOTSTRAP_SECRET=""
|
||||
MIGRATION_BOOTSTRAP_SECRET_MODE="disabled"
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
echo "[dev:rust] 未知参数: $1" >&2
|
||||
usage >&2
|
||||
@@ -417,6 +461,7 @@ fi
|
||||
if [[ "${SKIP_PUBLISH}" -ne 1 ]]; then
|
||||
echo "[dev:rust] 等待 SpacetimeDB 就绪"
|
||||
wait_for_spacetime "${SPACETIME_SERVER}" "${SPACETIME_TIMEOUT_SECONDS}" "${SPACETIME_ROOT_DIR}" "${PIDS[0]:-}"
|
||||
prepare_migration_bootstrap_secret
|
||||
|
||||
PUBLISH_ARGS=(
|
||||
publish
|
||||
|
||||
35
scripts/spacetime-authorize-migration-operator.mjs
Normal file
35
scripts/spacetime-authorize-migration-operator.mjs
Normal file
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import {
|
||||
callSpacetimeProcedureViaCli,
|
||||
ensureProcedureOk,
|
||||
parseArgs,
|
||||
} from './spacetime-migration-common.mjs';
|
||||
|
||||
try {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (!options.operatorIdentity) {
|
||||
throw new Error('必须传入 --operator-identity。');
|
||||
}
|
||||
|
||||
const input = {
|
||||
bootstrap_secret: options.bootstrapSecret || '',
|
||||
operator_identity_hex: options.operatorIdentity,
|
||||
note: options.note || '',
|
||||
};
|
||||
const result = await callSpacetimeProcedureViaCli(
|
||||
options,
|
||||
'authorize_database_migration_operator',
|
||||
input,
|
||||
);
|
||||
ensureProcedureOk(result);
|
||||
|
||||
console.log(
|
||||
`[spacetime:migration:operator] 已授权 ${result.operator_identity_hex ?? options.operatorIdentity}`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[spacetime:migration:operator] ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
55
scripts/spacetime-export-migration-json.mjs
Normal file
55
scripts/spacetime-export-migration-json.mjs
Normal file
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
callSpacetimeProcedureAuto,
|
||||
ensureParentDir,
|
||||
ensureProcedureOk,
|
||||
parseArgs,
|
||||
} from './spacetime-migration-common.mjs';
|
||||
|
||||
try {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (!options.out) {
|
||||
throw new Error('必须传入 --out。');
|
||||
}
|
||||
|
||||
const input = {
|
||||
include_tables: options.includeTables,
|
||||
};
|
||||
const result = await callSpacetimeProcedureAuto(
|
||||
options,
|
||||
'export_database_migration_to_file',
|
||||
input,
|
||||
);
|
||||
ensureProcedureOk(result);
|
||||
|
||||
if (typeof result.migration_json !== 'string' || result.migration_json.trim() === '') {
|
||||
throw new Error('导出 procedure 没有返回 migration_json。');
|
||||
}
|
||||
|
||||
const outPath = path.resolve(options.out);
|
||||
await ensureParentDir(outPath);
|
||||
await writeFile(outPath, result.migration_json, 'utf8');
|
||||
|
||||
console.log(`[spacetime:migration:export] 已写入 ${outPath}`);
|
||||
printTableStats(result.table_stats);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[spacetime:migration:export] ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function printTableStats(tableStats) {
|
||||
if (!Array.isArray(tableStats) || tableStats.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = tableStats.map((stat) => ({
|
||||
table: stat.table_name,
|
||||
exported: stat.exported_row_count,
|
||||
}));
|
||||
console.table(rows);
|
||||
}
|
||||
60
scripts/spacetime-import-migration-json.mjs
Normal file
60
scripts/spacetime-import-migration-json.mjs
Normal file
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
assertReadableFile,
|
||||
callSpacetimeProcedureAuto,
|
||||
ensureProcedureOk,
|
||||
parseArgs,
|
||||
} from './spacetime-migration-common.mjs';
|
||||
|
||||
try {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (!options.in) {
|
||||
throw new Error('必须传入 --in。');
|
||||
}
|
||||
|
||||
const inPath = path.resolve(options.in);
|
||||
await assertReadableFile(inPath);
|
||||
const migrationJson = await readFile(inPath, 'utf8');
|
||||
if (!migrationJson.trim()) {
|
||||
throw new Error(`迁移文件为空: ${inPath}`);
|
||||
}
|
||||
|
||||
const input = {
|
||||
migration_json: migrationJson,
|
||||
include_tables: options.includeTables,
|
||||
replace_existing: options.replaceExisting === true,
|
||||
dry_run: options.dryRun === true,
|
||||
};
|
||||
const result = await callSpacetimeProcedureAuto(
|
||||
options,
|
||||
'import_database_migration_from_file',
|
||||
input,
|
||||
);
|
||||
ensureProcedureOk(result);
|
||||
|
||||
console.log(
|
||||
`[spacetime:migration:import] ${options.dryRun ? 'dry-run 完成' : '导入完成'}: ${inPath}`,
|
||||
);
|
||||
printTableStats(result.table_stats);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[spacetime:migration:import] ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function printTableStats(tableStats) {
|
||||
if (!Array.isArray(tableStats) || tableStats.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = tableStats.map((stat) => ({
|
||||
table: stat.table_name,
|
||||
imported: stat.imported_row_count,
|
||||
skipped: stat.skipped_row_count,
|
||||
}));
|
||||
console.table(rows);
|
||||
}
|
||||
337
scripts/spacetime-migration-common.mjs
Normal file
337
scripts/spacetime-migration-common.mjs
Normal file
@@ -0,0 +1,337 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { access, mkdir } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
export function parseArgs(argv) {
|
||||
const options = {
|
||||
database:
|
||||
process.env.GENARRATIVE_SPACETIME_MAINCLOUD_DATABASE ||
|
||||
process.env.GENARRATIVE_SPACETIME_DATABASE ||
|
||||
'',
|
||||
bootstrapSecret: process.env.GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET || '',
|
||||
includeTables: [],
|
||||
operatorIdentity: process.env.GENARRATIVE_SPACETIME_MIGRATION_OPERATOR_IDENTITY || '',
|
||||
passthrough: [],
|
||||
note: '',
|
||||
server:
|
||||
process.env.GENARRATIVE_SPACETIME_MAINCLOUD_SERVER ||
|
||||
process.env.GENARRATIVE_SPACETIME_SERVER ||
|
||||
'',
|
||||
serverUrl:
|
||||
process.env.GENARRATIVE_SPACETIME_MAINCLOUD_SERVER_URL ||
|
||||
process.env.GENARRATIVE_SPACETIME_SERVER_URL ||
|
||||
'',
|
||||
token:
|
||||
process.env.GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN ||
|
||||
process.env.GENARRATIVE_SPACETIME_TOKEN ||
|
||||
'',
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
const readValue = (name) => {
|
||||
const value = argv[index + 1];
|
||||
if (!value || value.startsWith('--')) {
|
||||
throw new Error(`${name} 缺少参数值。`);
|
||||
}
|
||||
index += 1;
|
||||
return value;
|
||||
};
|
||||
|
||||
if (arg === '--server') {
|
||||
options.server = readValue(arg);
|
||||
} else if (arg === '--use-http') {
|
||||
options.useHttp = true;
|
||||
} else if (arg === '--server-url') {
|
||||
options.serverUrl = readValue(arg);
|
||||
} else if (arg === '--token') {
|
||||
options.token = readValue(arg);
|
||||
} else if (arg === '--bootstrap-secret') {
|
||||
options.bootstrapSecret = readValue(arg);
|
||||
} else if (arg === '--operator-identity') {
|
||||
options.operatorIdentity = readValue(arg);
|
||||
} else if (arg === '--note') {
|
||||
options.note = readValue(arg);
|
||||
} else if (arg === '--root-dir') {
|
||||
options.rootDir = readValue(arg);
|
||||
} else if (arg === '--database') {
|
||||
options.database = readValue(arg);
|
||||
} else if (arg === '--out') {
|
||||
options.out = readValue(arg);
|
||||
} else if (arg === '--in') {
|
||||
options.in = readValue(arg);
|
||||
} else if (arg === '--include') {
|
||||
options.includeTables = readValue(arg)
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
} else if (arg === '--replace-existing') {
|
||||
options.replaceExisting = true;
|
||||
} else if (arg === '--dry-run') {
|
||||
options.dryRun = true;
|
||||
} else if (arg === '--anonymous' || arg === '--no-config') {
|
||||
options.passthrough.push(arg);
|
||||
} else {
|
||||
throw new Error(`未知参数: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
export function buildSpacetimeCallArgs(options, procedureName, input) {
|
||||
if (!options.database) {
|
||||
throw new Error('必须传入 --database。');
|
||||
}
|
||||
|
||||
const args = [];
|
||||
if (options.rootDir) {
|
||||
args.push(`--root-dir=${options.rootDir}`);
|
||||
}
|
||||
args.push('call');
|
||||
if (options.server) {
|
||||
args.push('-s', options.server);
|
||||
}
|
||||
args.push(...options.passthrough);
|
||||
args.push(options.database, procedureName, JSON.stringify(input), '-y');
|
||||
return args;
|
||||
}
|
||||
|
||||
export async function callSpacetimeProcedure(options, procedureName, input) {
|
||||
if (!options.database) {
|
||||
throw new Error('必须传入 --database,或设置 GENARRATIVE_SPACETIME_DATABASE。');
|
||||
}
|
||||
|
||||
const serverUrl = resolveServerUrl(options).replace(/\/+$/u, '');
|
||||
const url = `${serverUrl}/v1/database/${encodeURIComponent(options.database)}/call/${encodeURIComponent(procedureName)}`;
|
||||
const headers = {
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
};
|
||||
if (options.token) {
|
||||
headers.authorization = `Bearer ${options.token}`;
|
||||
}
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify([input]),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`SpacetimeDB HTTP 请求失败: ${url}; ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`SpacetimeDB HTTP ${response.status}: ${trimPreview(text)}${buildHttpAuthHint(text)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return parseProcedureResult(text);
|
||||
}
|
||||
|
||||
export async function callSpacetimeProcedureAuto(options, procedureName, input) {
|
||||
if (options.useHttp) {
|
||||
return callSpacetimeProcedure(options, procedureName, input);
|
||||
}
|
||||
|
||||
return callSpacetimeProcedureViaCli(options, procedureName, input);
|
||||
}
|
||||
|
||||
export async function callSpacetimeProcedureViaCli(options, procedureName, input) {
|
||||
const args = buildSpacetimeCallArgs(options, procedureName, input);
|
||||
const output = await runSpacetimeCli(args);
|
||||
return parseProcedureResult(output);
|
||||
}
|
||||
|
||||
export function parseProcedureResult(output) {
|
||||
const candidates = [];
|
||||
const trimmed = output.trim();
|
||||
if (trimmed) {
|
||||
candidates.push(trimmed);
|
||||
}
|
||||
|
||||
for (const line of output.split(/\r?\n/u)) {
|
||||
const value = line.trim();
|
||||
if (value.startsWith('{') || value.startsWith('[')) {
|
||||
candidates.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
return normalizeProcedureResult(JSON.parse(candidate));
|
||||
} catch {
|
||||
// SpacetimeDB CLI 在不同版本中可能附带说明文本,继续尝试后续候选。
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`无法解析 procedure 返回值: ${trimmed}`);
|
||||
}
|
||||
|
||||
export function ensureProcedureOk(result) {
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error_message ?? '迁移 procedure 返回失败。');
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureParentDir(filePath) {
|
||||
await mkdir(path.dirname(path.resolve(filePath)), { recursive: true });
|
||||
}
|
||||
|
||||
export async function assertReadableFile(filePath) {
|
||||
await access(path.resolve(filePath));
|
||||
}
|
||||
|
||||
function normalizeProcedureResult(value) {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return normalizeSatsProduct(value);
|
||||
}
|
||||
|
||||
throw new Error('procedure 返回值不是对象。');
|
||||
}
|
||||
|
||||
function normalizeSatsProduct(value) {
|
||||
if (value.length === 3) {
|
||||
return {
|
||||
ok: normalizeSatsValue(value[0]),
|
||||
operator_identity_hex: normalizeSatsOption(value[1]),
|
||||
error_message: normalizeSatsOption(value[2]),
|
||||
};
|
||||
}
|
||||
|
||||
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]),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSatsValue(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => normalizeSatsValue(item));
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, entry]) => [key, normalizeSatsValue(entry)]),
|
||||
);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeSatsOption(value) {
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 2 && value[0] === 0) {
|
||||
return normalizeSatsValue(value[1]);
|
||||
}
|
||||
if (value.length === 0 || value[0] === 1) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return normalizeSatsValue(value);
|
||||
}
|
||||
|
||||
function normalizeTableStats(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]),
|
||||
exported_row_count: normalizeSatsValue(entry[1]),
|
||||
imported_row_count: normalizeSatsValue(entry[2]),
|
||||
skipped_row_count: normalizeSatsValue(entry[3]),
|
||||
};
|
||||
}
|
||||
|
||||
return entry;
|
||||
});
|
||||
}
|
||||
|
||||
function resolveServerUrl(options) {
|
||||
if (options.serverUrl) {
|
||||
return options.serverUrl;
|
||||
}
|
||||
|
||||
const server = (options.server || 'maincloud').trim();
|
||||
if (server.startsWith('http://') || server.startsWith('https://')) {
|
||||
return server;
|
||||
}
|
||||
if (server === 'dev') {
|
||||
return 'http://127.0.0.1:3101';
|
||||
}
|
||||
if (server === 'local') {
|
||||
return 'http://127.0.0.1:3000';
|
||||
}
|
||||
if (!server || server === 'maincloud') {
|
||||
return 'https://maincloud.spacetimedb.com';
|
||||
}
|
||||
|
||||
throw new Error(`未知 SpacetimeDB server: ${server}。请改用 --server-url 显式传入地址。`);
|
||||
}
|
||||
|
||||
function trimPreview(text) {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.length <= 4000) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
return `${trimmed.slice(0, 4000)}...`;
|
||||
}
|
||||
|
||||
function buildHttpAuthHint(text) {
|
||||
if (!text.includes('InvalidSignature') && !text.includes('TokenError')) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return '。提示:这里需要 SpacetimeDB 客户端连接 token,不是 `spacetime login show --token` 输出的 CLI 登录 token;授权/撤销请直接使用 CLI 登录态,不要传 --token。';
|
||||
}
|
||||
|
||||
function runSpacetimeCli(args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn('spacetime', args, {
|
||||
cwd: process.cwd(),
|
||||
shell: false,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let output = '';
|
||||
|
||||
child.stdout.on('data', (chunk) => {
|
||||
output += chunk.toString();
|
||||
});
|
||||
child.stderr.on('data', (chunk) => {
|
||||
output += chunk.toString();
|
||||
});
|
||||
child.on('error', reject);
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
reject(new Error(`spacetime call 被信号中断: ${signal}`));
|
||||
return;
|
||||
}
|
||||
if (code !== 0) {
|
||||
reject(new Error(`spacetime call 失败,退出码 ${code}: ${trimPreview(output)}`));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(output);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
MIGRATION_BOOTSTRAP_SECRET=""
|
||||
MIGRATION_BOOTSTRAP_SECRET_MODE="auto"
|
||||
|
||||
load_env_file() {
|
||||
local env_file="$1"
|
||||
@@ -39,13 +41,45 @@ 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-migration-bootstrap-secret
|
||||
|
||||
说明:
|
||||
发布 server-rs/crates/spacetime-module 到 SpacetimeDB Maincloud。
|
||||
数据库名优先读取 --database,其次读取 GENARRATIVE_SPACETIME_MAINCLOUD_DATABASE。
|
||||
默认在构建 wasm 前随机生成迁移引导密钥,注入 GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET 并显示在控制台。
|
||||
EOF
|
||||
}
|
||||
|
||||
generate_migration_bootstrap_secret() {
|
||||
node -e 'const crypto = require("crypto"); process.stdout.write(crypto.randomBytes(32).toString("hex"));'
|
||||
}
|
||||
|
||||
prepare_migration_bootstrap_secret() {
|
||||
case "${MIGRATION_BOOTSTRAP_SECRET_MODE}" in
|
||||
auto)
|
||||
MIGRATION_BOOTSTRAP_SECRET="$(generate_migration_bootstrap_secret)"
|
||||
;;
|
||||
manual)
|
||||
if [[ "${#MIGRATION_BOOTSTRAP_SECRET}" -lt 16 ]]; then
|
||||
echo "[spacetime:maincloud] 迁移引导密钥至少需要 16 个字符。" >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
disabled)
|
||||
unset GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET
|
||||
echo "[spacetime:maincloud] 未启用迁移引导密钥。"
|
||||
return
|
||||
;;
|
||||
*)
|
||||
echo "[spacetime:maincloud] 未知迁移引导密钥模式: ${MIGRATION_BOOTSTRAP_SECRET_MODE}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
export GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET="${MIGRATION_BOOTSTRAP_SECRET}"
|
||||
echo "[spacetime:maincloud] 迁移引导密钥: ${MIGRATION_BOOTSTRAP_SECRET}"
|
||||
}
|
||||
|
||||
load_env_file "${REPO_ROOT}/.env"
|
||||
load_env_file "${REPO_ROOT}/.env.local"
|
||||
|
||||
@@ -70,6 +104,16 @@ while [[ $# -gt 0 ]]; do
|
||||
CLEAR_DATABASE=1
|
||||
shift
|
||||
;;
|
||||
--migration-bootstrap-secret)
|
||||
MIGRATION_BOOTSTRAP_SECRET="${2:?缺少 --migration-bootstrap-secret 的值}"
|
||||
MIGRATION_BOOTSTRAP_SECRET_MODE="manual"
|
||||
shift 2
|
||||
;;
|
||||
--no-migration-bootstrap-secret)
|
||||
MIGRATION_BOOTSTRAP_SECRET=""
|
||||
MIGRATION_BOOTSTRAP_SECRET_MODE="disabled"
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
echo "[spacetime:maincloud] 未知参数: $1" >&2
|
||||
usage >&2
|
||||
@@ -89,11 +133,18 @@ if ! command -v cargo >/dev/null 2>&1; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
echo "[spacetime:maincloud] 缺少 node 命令,无法生成迁移引导密钥。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v spacetime >/dev/null 2>&1; then
|
||||
echo "[spacetime:maincloud] 缺少 spacetime CLI,请先安装并登录 Maincloud。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
prepare_migration_bootstrap_secret
|
||||
|
||||
echo "[spacetime:maincloud] 构建 spacetime-module wasm"
|
||||
cargo build \
|
||||
--manifest-path "${SERVER_RS_DIR}/Cargo.toml" \
|
||||
|
||||
33
scripts/spacetime-revoke-migration-operator.mjs
Normal file
33
scripts/spacetime-revoke-migration-operator.mjs
Normal file
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import {
|
||||
callSpacetimeProcedureViaCli,
|
||||
ensureProcedureOk,
|
||||
parseArgs,
|
||||
} from './spacetime-migration-common.mjs';
|
||||
|
||||
try {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (!options.operatorIdentity) {
|
||||
throw new Error('必须传入 --operator-identity。');
|
||||
}
|
||||
|
||||
const input = {
|
||||
operator_identity_hex: options.operatorIdentity,
|
||||
};
|
||||
const result = await callSpacetimeProcedureViaCli(
|
||||
options,
|
||||
'revoke_database_migration_operator',
|
||||
input,
|
||||
);
|
||||
ensureProcedureOk(result);
|
||||
|
||||
console.log(
|
||||
`[spacetime:migration:operator] 已撤销 ${result.operator_identity_hex ?? options.operatorIdentity}`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[spacetime:migration:operator] ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
Reference in New Issue
Block a user