diff --git a/docs/technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md b/docs/technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md index 51b56153..6f1274a4 100644 --- a/docs/technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md +++ b/docs/technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md @@ -8,12 +8,12 @@ SpacetimeDB reducer 必须保持确定性,不能访问文件系统和网络。 1. `spacetime-module` 内的导出 procedure 读取迁移白名单表,并直接返回迁移 JSON 字符串。 2. Node 运维脚本默认通过 `spacetime call` 调用导出 procedure,把返回的 JSON 字符串写入本地文件。 -3. Node 运维脚本读取本地 JSON 文件内容,并通过 HTTP request body 作为字符串参数传给导入 procedure。 +3. Node 运维脚本读取本地 JSON 文件内容。导入时默认先通过 `POST /v1/identity` 创建临时 Web API identity/token,再用当前 CLI 登录态把该 identity 授权为迁移操作员,最后通过 HTTP request body 把 JSON 字符串传给导入 procedure。 4. 导入 procedure 校验 JSON 与表白名单后,在事务中写入目标数据库。 procedure 不再访问 HTTP 文件桥,也不接收部署机本地文件路径。这样可以避开 SpacetimeDB 对 private/special-purpose 地址的 HTTP 访问限制,并避免把 private 表内容通过临时 HTTP 服务转发。 -`spacetime login show --token` 输出的是 CLI 登录 token,不是 HTTP `/v1/database/.../call` 所需的数据库连接 token。运维脚本默认走 CLI 登录态,迁移时不要把 CLI token 传给 `--token`;只有显式传 `--use-http` 时才需要数据库连接 token。 +`spacetime login show --token` 输出的是 CLI 登录 token,不是 HTTP `/v1/database/.../call` 所需的数据库连接 token。导入脚本如果没有显式传 `--token`,会自动调用 `POST /v1/identity` 获取 Web API token;迁移时不要把 CLI token 传给 `--token`。 ## 接口 @@ -145,16 +145,26 @@ node scripts/spacetime-authorize-migration-operator.mjs \ --note "server import" ``` -导入脚本负责读取服务器本地文件并把 JSON 字符串传入目标库 procedure: +导入脚本负责读取服务器本地文件并把 JSON 字符串通过 Web API request body 传入目标库 procedure。因为 JSON 不再放进 `spacetime call` 命令行参数,所以不会触发 Linux `spawn E2BIG`: ```bash node scripts/spacetime-import-migration-json.mjs \ --server maincloud \ --database xushi-p4wfr \ + --bootstrap-secret <服务器目标库发布时输出的随机密钥> \ --in tmp/spacetime-migrations/source-2026-04-27.json \ --replace-existing ``` +默认情况下,脚本会自动完成三步: + +1. `POST /v1/identity` 创建临时 Web API identity/token。 +2. 使用当前机器 `spacetime` CLI 登录态调用 `authorize_database_migration_operator`,授权这个临时 identity。 +3. 使用 `Authorization: Bearer <临时 token>` 调用 `import_database_migration_from_file`,把完整迁移 JSON 放在 HTTP body 中。 +4. 导入请求结束后,脚本会用同一个临时 Web API token 调用 `revoke_database_migration_operator`,撤销该临时 identity。 + +如果你已经有可用的数据库连接 token,也可以显式传 `--token `。这种情况下脚本不会自动授权;该 token 对应的 identity 必须已经是迁移操作员。 + 正式导入前建议先加 `--dry-run`,确认 JSON 可解析、版本匹配、表名都在迁移白名单内。 如需分批迁移,可用逗号分隔表名: @@ -166,7 +176,7 @@ node scripts/spacetime-export-migration-json.mjs \ --include ai_task,ai_task_stage,ai_text_chunk,ai_result_reference ``` -`--server` 支持 `dev`、`local`、`maincloud`,也可以直接传 SpacetimeDB 服务器 URL。脚本默认走 `spacetime call`,使用当前机器的 CLI 登录态。数据库名可通过 `--database`、`GENARRATIVE_SPACETIME_MAINCLOUD_DATABASE` 或 `GENARRATIVE_SPACETIME_DATABASE` 提供。 +`--server` 支持 `dev`、`local`、`maincloud`,也可以直接传 SpacetimeDB 服务器 URL。导出、授权、撤销默认走 `spacetime call`,使用当前机器的 CLI 登录态;导入默认走 Web API request body,避免大 JSON 触发命令行长度限制。数据库名可通过 `--database`、`GENARRATIVE_SPACETIME_MAINCLOUD_DATABASE` 或 `GENARRATIVE_SPACETIME_DATABASE` 提供。 授权脚本额外支持: @@ -193,4 +203,4 @@ node scripts/spacetime-export-migration-json.mjs \ 迁移 JSON 作为 procedure 返回值和 HTTP request body 传递,会受 SpacetimeDB 调用响应体、请求体以及中间代理大小限制。数据量较大时,先按 `include_tables` 分批迁移;若单表本身过大,再补充分片 procedure,而不是恢复 HTTP 文件桥。 -`spacetime call` 在 PowerShell 中手写 JSON 容易被剥掉双引号。推荐使用仓库里的 Node 脚本,由脚本直接走 HTTP API,避免 shell 二次处理和命令行长度限制。 +`spacetime call` 在 PowerShell 中手写 JSON 容易被剥掉双引号。导入大文件时也不能把完整 JSON 放进命令行参数,否则 Linux 会在启动子进程时返回 `spawn E2BIG`。推荐使用仓库里的 Node 脚本,由脚本直接走 Web API request body,避免 shell 二次处理和命令行长度限制。 diff --git a/scripts/spacetime-import-migration-json.mjs b/scripts/spacetime-import-migration-json.mjs index 2b3b25e7..1f47fd74 100644 --- a/scripts/spacetime-import-migration-json.mjs +++ b/scripts/spacetime-import-migration-json.mjs @@ -4,7 +4,9 @@ import { readFile } from 'node:fs/promises'; import path from 'node:path'; import { assertReadableFile, - callSpacetimeProcedureAuto, + callSpacetimeProcedure, + callSpacetimeProcedureViaCli, + createSpacetimeWebIdentity, ensureProcedureOk, parseArgs, } from './spacetime-migration-common.mjs'; @@ -22,17 +24,13 @@ try { 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, - ); + const webOptions = await prepareWebImportOptions(options); + let result; + try { + result = await importMigrationJsonDirect(webOptions, migrationJson); + } finally { + await revokeTemporaryWebIdentity(webOptions); + } ensureProcedureOk(result); console.log( @@ -46,6 +44,68 @@ try { process.exit(1); } +async function prepareWebImportOptions(options) { + if (options.token) { + return { ...options, useHttp: true }; + } + + const identity = await createSpacetimeWebIdentity(options); + console.log( + `[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); + console.log(`[spacetime:migration:import] 已授权临时 Web API identity`); + + return { + ...options, + token: identity.token, + temporaryWebIdentity: identity.identity, + useHttp: true, + }; +} + +async function importMigrationJsonDirect(options, migrationJson) { + const input = { + migration_json: migrationJson, + include_tables: options.includeTables, + replace_existing: options.replaceExisting === true, + dry_run: options.dryRun === true, + }; + return callSpacetimeProcedure(options, 'import_database_migration_from_file', input); +} + +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:import] 已撤销临时 Web API identity`); + } catch (error) { + console.warn( + `[spacetime:migration:import] 撤销临时 Web API identity 失败: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } +} + function printTableStats(tableStats) { if (!Array.isArray(tableStats) || tableStats.length === 0) { return; diff --git a/scripts/spacetime-migration-common.mjs b/scripts/spacetime-migration-common.mjs index 44f4bb78..b65550c8 100644 --- a/scripts/spacetime-migration-common.mjs +++ b/scripts/spacetime-migration-common.mjs @@ -133,6 +133,42 @@ export async function callSpacetimeProcedure(options, procedureName, input) { return parseProcedureResult(text); } +export async function createSpacetimeWebIdentity(options) { + const serverUrl = resolveServerUrl(options).replace(/\/+$/u, ''); + const url = `${serverUrl}/v1/identity`; + let response; + try { + response = await fetch(url, { method: 'POST' }); + } catch (error) { + throw new Error( + `SpacetimeDB identity 请求失败: ${url}; ${error instanceof Error ? error.message : String(error)}`, + ); + } + + const text = await response.text(); + if (!response.ok) { + throw new Error(`SpacetimeDB identity HTTP ${response.status}: ${trimPreview(text)}`); + } + + let payload; + try { + payload = JSON.parse(text); + } catch (error) { + throw new Error( + `SpacetimeDB identity 响应不是合法 JSON: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + const identity = + payload.identity ?? payload.Identity ?? payload.identity_hex ?? payload.identityHex; + const token = payload.token ?? payload.Token; + if (typeof identity !== 'string' || typeof token !== 'string') { + throw new Error(`SpacetimeDB identity 响应缺少 identity/token: ${trimPreview(text)}`); + } + + return { identity, token }; +} + export async function callSpacetimeProcedureAuto(options, procedureName, input) { if (options.useHttp) { return callSpacetimeProcedure(options, procedureName, input); @@ -266,7 +302,7 @@ function normalizeTableStats(value) { }); } -function resolveServerUrl(options) { +export function resolveServerUrl(options) { if (options.serverUrl) { return options.serverUrl; }