071faa482c
纳入 AGC Cargo workspace 的统一 rustfmt 检查与格式化入口 完成项目 TypeScript/Prettier 与 Rust 全量格式化 修复 Pingora expected executable 门禁的空白敏感误报 同步开发运维文档与 AGC skill pack 格式化忽略规则
273 lines
8.5 KiB
JavaScript
273 lines
8.5 KiB
JavaScript
#!/usr/bin/env node
|
||
|
||
import { createHash } from 'node:crypto';
|
||
import { pathToFileURL } from 'node:url';
|
||
|
||
import {
|
||
callSpacetimeProcedure,
|
||
callSpacetimeProcedureViaCli,
|
||
encodeSpacetimeCliOption,
|
||
ensureProcedureOk,
|
||
parsePositiveInteger,
|
||
} from './spacetime-migration-common.mjs';
|
||
|
||
const PROCEDURE_NAME = 'clean_editor_image_asset_kind_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 sha256(value) {
|
||
return createHash('sha256').update(value).digest('hex');
|
||
}
|
||
|
||
function usage() {
|
||
return `用法:
|
||
node scripts/spacetime-clean-editor-image-asset-kind.mjs \\
|
||
--database <name> --server <name-or-url> [--chunk-size <1-25>] [--apply]
|
||
|
||
默认按 asset、project-resource、showcase、canvas 的固定顺序执行全量 dry-run,不修改数据。
|
||
脚本只把业务 assetKind 精确等于 "image" 的旧值清为空;不会修改 MIME、媒体类型或 asset_object.asset_kind。
|
||
追加 --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 buildCleanupInput({
|
||
scope,
|
||
cursor = null,
|
||
limit,
|
||
dryRun,
|
||
expectedBatchSha256 = null,
|
||
}) {
|
||
if (!SCOPES.includes(scope)) {
|
||
throw new Error(`未知普通图片 assetKind 清理 scope: ${scope}`);
|
||
}
|
||
if (!Number.isInteger(limit) || limit < 1) {
|
||
throw new Error('普通图片 assetKind 清理 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_samples.length !== 0) {
|
||
throw new Error(`${scope} scope 存在 ${result.blocker_count} 个 blocker。`);
|
||
}
|
||
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,
|
||
cleaned_field_count: 0,
|
||
batches: 0,
|
||
};
|
||
do {
|
||
const limit = scopeLimit(scope, options.chunkSize);
|
||
const dryRun = await callProcedure(
|
||
options,
|
||
buildCleanupInput({ scope, cursor, limit, dryRun: true }),
|
||
);
|
||
assertSafeBatch(dryRun, scope);
|
||
summary.scanned_count += dryRun.scanned_count;
|
||
summary.matched_count += dryRun.matched_count;
|
||
summary.cleaned_field_count += dryRun.cleaned_field_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,
|
||
buildCleanupInput({
|
||
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} 行。`,
|
||
);
|
||
}
|
||
if (applied.cleaned_field_count !== dryRun.cleaned_field_count) {
|
||
throw new Error(
|
||
`${scope} scope apply 返回的清理字段数与 dry-run 不一致。`,
|
||
);
|
||
}
|
||
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(SHA-256: ${sha256(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-image-asset-kind:clean] ${
|
||
error instanceof Error ? error.message : String(error)
|
||
}`,
|
||
);
|
||
process.exitCode = 1;
|
||
});
|
||
}
|