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:
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user