Files
Genarrative/scripts/spacetime-normalize-editor-character-actions.mjs
T
k88936 74d4972ecc 修复角色动作元数据迁移与画布归一化
迁移按计划态分类排除同任务预览视频并区分零候选与多候选。

历史画布迁移忽略复制的来源链,正式生成继续保持严格血缘校验。

逐帧核验登记对象并补齐对象路径与对象 ID,支持前置 scope 的安全 dry-run。

补充迁移 HTTP 调用、撤权能力、回归测试和长期数据契约。
2026-08-04 19:05:21 +08:00

246 lines
7.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 <name> --server <name-or-url> [--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 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;
}
cursor = dryRun.has_more ? dryRun.next_cursor : null;
if (dryRun.has_more && !cursor) {
throw new Error(`${scope} scope 声明 has_more 但未返回 next_cursor。`);
}
} 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;
});
}