420 lines
12 KiB
JavaScript
420 lines
12 KiB
JavaScript
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 === '--incremental') {
|
||
options.incremental = 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);
|
||
} 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;
|
||
}
|
||
|
||
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 = {
|
||
Accept: 'application/json',
|
||
'Content-Type': 'application/json',
|
||
};
|
||
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 createSpacetimeWebIdentity(options) {
|
||
const serverUrl = resolveServerUrl(options).replace(/\/+$/u, '');
|
||
const url = `${serverUrl}/v1/identity`;
|
||
const headers = {
|
||
Accept: 'application/json',
|
||
'Content-Type': 'application/json',
|
||
};
|
||
let response;
|
||
try {
|
||
response = await fetch(url, { method: 'POST', headers });
|
||
} 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);
|
||
}
|
||
|
||
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]),
|
||
};
|
||
}
|
||
|
||
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]),
|
||
warnings: normalizeMigrationWarnings(value[4]),
|
||
error_message: normalizeSatsOption(value[5]),
|
||
};
|
||
}
|
||
|
||
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 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;
|
||
}
|
||
|
||
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);
|
||
});
|
||
});
|
||
}
|