#!/usr/bin/env node import { pathToFileURL } from 'node:url'; import { callSpacetimeProcedure, callSpacetimeProcedureViaCli, encodeSpacetimeCliOption, ensureProcedureOk, parsePositiveInteger, } from './spacetime-migration-common.mjs'; const PROCEDURE_NAME = 'normalize_editor_character_animation_metadata_and_return'; const SCOPES = ['asset', 'project-resource', 'showcase', 'canvas']; const DEFAULT_CHUNK_SIZE = 25; const CANVAS_MAX_CHUNK_SIZE = 5; const SHA256_PATTERN = /^[0-9a-f]{64}$/u; function usage() { return `用法: node scripts/spacetime-normalize-editor-character-actions.mjs \\ --database --server [--chunk-size <1-25>] [--apply] 默认按 asset、project-resource、showcase、canvas 的固定顺序执行全量 dry-run,不修改数据。 追加 --apply 后,每批仍会先 dry-run;只有 blocker 为零,才携带该批返回的 SHA-256 立即 apply。 apply 完成后脚本会再次从头 dry-run,要求四个 scope 的 matched/blocker 均为零。 必须使用已授权 database migration operator 的 spacetime CLI 登录态,并显式指定 server。`; } export function parseOptions(argv, env = process.env) { const options = { apply: false, chunkSize: DEFAULT_CHUNK_SIZE, database: env.GENARRATIVE_SPACETIME_DATABASE || '', passthrough: [], server: env.GENARRATIVE_SPACETIME_SERVER || '', serverUrl: env.GENARRATIVE_SPACETIME_SERVER_URL || '', token: env.GENARRATIVE_SPACETIME_TOKEN || '', useHttp: false, }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; const readValue = () => { const value = argv[index + 1]; if (!value || value.startsWith('--')) { throw new Error(`${arg} 缺少参数值。`); } index += 1; return value.trim(); }; if (arg === '--database') { options.database = readValue(); } else if (arg === '--server') { options.server = readValue(); } else if (arg === '--server-url') { options.serverUrl = readValue(); } else if (arg === '--token') { options.token = readValue(); } else if (arg === '--chunk-size') { options.chunkSize = parsePositiveInteger(readValue(), arg); } else if (arg === '--apply') { options.apply = true; } else if (arg === '--use-http') { options.useHttp = true; } else if (arg === '--no-config' || arg === '--anonymous') { options.passthrough.push(arg); } else if (arg === '--help' || arg === '-h') { options.help = true; } else { throw new Error(`未知参数: ${arg}`); } } if (options.chunkSize > DEFAULT_CHUNK_SIZE) { throw new Error(`--chunk-size 不能超过 ${DEFAULT_CHUNK_SIZE}。`); } return options; } export function buildNormalizationInput({ scope, cursor = null, limit, dryRun, expectedBatchSha256 = null, }) { if (!SCOPES.includes(scope)) { throw new Error(`未知角色动作规范化 scope: ${scope}`); } if (!Number.isInteger(limit) || limit < 1) { throw new Error('角色动作规范化 limit 必须是正整数。'); } if (scope === 'canvas' && limit > CANVAS_MAX_CHUNK_SIZE) { throw new Error(`canvas scope limit 不能超过 ${CANVAS_MAX_CHUNK_SIZE}。`); } if (!dryRun && !SHA256_PATTERN.test(expectedBatchSha256 || '')) { throw new Error('apply 必须绑定 dry-run 返回的 64 位 batch SHA-256。'); } return { scope, cursor: encodeSpacetimeCliOption(cursor), limit, dry_run: dryRun, expected_batch_sha_256: encodeSpacetimeCliOption( dryRun ? null : expectedBatchSha256, ), }; } function scopeLimit(scope, chunkSize) { return scope === 'canvas' ? Math.min(chunkSize, CANVAS_MAX_CHUNK_SIZE) : chunkSize; } function assertSafeBatch(result, scope) { ensureProcedureOk(result); if (result.scope !== scope) { throw new Error(`procedure 返回 scope ${result.scope},预期为 ${scope}。`); } if (result.blocker_count !== 0 || result.blocker_ids.length !== 0) { throw new Error( `${scope} scope 存在 ${result.blocker_count} 个 blocker:${result.blocker_ids.join(', ')}`, ); } if (!SHA256_PATTERN.test(result.batch_sha256 || '')) { throw new Error(`${scope} scope 未返回有效的 batch SHA-256。`); } } async function callBatch(options, input) { return options.useHttp ? callSpacetimeProcedure(options, PROCEDURE_NAME, input) : callSpacetimeProcedureViaCli(options, PROCEDURE_NAME, input); } export async function scanScopes( options, { apply = false, verifyZero = false, callProcedure = callBatch } = {}, ) { const summaries = []; for (const scope of SCOPES) { let cursor = null; const seenCursors = new Set(); const summary = { scope, scanned_count: 0, matched_count: 0, updated_count: 0, batches: 0, }; do { const limit = scopeLimit(scope, options.chunkSize); const dryRun = await callProcedure( options, buildNormalizationInput({ scope, cursor, limit, dryRun: true }), ); assertSafeBatch(dryRun, scope); summary.scanned_count += dryRun.scanned_count; summary.matched_count += dryRun.matched_count; summary.batches += 1; if (verifyZero && dryRun.matched_count !== 0) { throw new Error( `${scope} scope apply 后复核仍有 ${dryRun.matched_count} 行待规范化。`, ); } if (apply && dryRun.matched_count > 0) { const applied = await callProcedure( options, buildNormalizationInput({ scope, cursor, limit, dryRun: false, expectedBatchSha256: dryRun.batch_sha256, }), ); assertSafeBatch(applied, scope); if (applied.batch_sha256 !== dryRun.batch_sha256) { throw new Error( `${scope} scope apply 返回的 batch SHA-256 与 dry-run 不一致。`, ); } if (applied.updated_count !== dryRun.matched_count) { throw new Error( `${scope} scope apply 更新 ${applied.updated_count} 行,dry-run 匹配 ${dryRun.matched_count} 行。`, ); } summary.updated_count += applied.updated_count; } const nextCursor = dryRun.has_more ? dryRun.next_cursor : null; if (dryRun.has_more && !nextCursor) { throw new Error(`${scope} scope 声明 has_more 但未返回 next_cursor。`); } if (nextCursor && seenCursors.has(nextCursor)) { throw new Error( `${scope} scope 返回了重复的 next_cursor: ${nextCursor}。`, ); } if (nextCursor) { seenCursors.add(nextCursor); } cursor = nextCursor; } while (cursor); summaries.push(summary); } return summaries; } export async function main(argv = process.argv.slice(2)) { const options = parseOptions(argv); if (options.help) { console.log(usage()); return; } if (!options.database) { throw new Error('必须显式传入 --database。'); } if (!options.server && !options.serverUrl) { throw new Error( '必须显式传入 --server / --server-url,不使用默认 cloud target。', ); } if (options.useHttp && !options.token) { throw new Error( '--use-http 需要通过 --token 或 GENARRATIVE_SPACETIME_TOKEN 提供身份。', ); } const migration = await scanScopes(options, { apply: options.apply }); const verification = options.apply ? await scanScopes(options, { verifyZero: true }) : null; console.log( JSON.stringify( { procedure: PROCEDURE_NAME, applied: options.apply, scope_order: SCOPES, migration, verification, }, null, 2, ), ); if (!options.apply) { console.log('全量 dry-run 已通过;确认输出后追加 --apply 重跑。'); } } if (import.meta.url === pathToFileURL(process.argv[1] || '').href) { main().catch((error) => { console.error( `[spacetime:editor-character-actions:normalize] ${ error instanceof Error ? error.message : String(error) }`, ); process.exitCode = 1; }); }