合并远端主线并解决游戏创作冲突
融合主线 Runtime、安全修复与提示词资源 保留可运行版本、资源替换和数值微调能力 修复资源画布布局、焦点与依赖关系融合 补齐 manifest 并发保护与 Tetris 场景兼容 同步前端测试、Rust 回归测试与权威文档
This commit is contained in:
@@ -558,6 +558,7 @@ copy_required_file "${SCRIPT_DIR}/spacetime-export-migration-json.mjs" "${TARGET
|
||||
copy_required_file "${SCRIPT_DIR}/spacetime-import-migration-json.mjs" "${TARGET_DIR}/scripts/database-import.mjs" "数据库导入脚本"
|
||||
copy_required_file "${SCRIPT_DIR}/spacetime-migration-common.mjs" "${TARGET_DIR}/scripts/spacetime-migration-common.mjs" "数据库迁移公共脚本"
|
||||
copy_required_file "${SCRIPT_DIR}/spacetime-maintain-external-generation-jobs.mjs" "${TARGET_DIR}/scripts/spacetime-maintain-external-generation-jobs.mjs" "外部生成任务维护脚本"
|
||||
copy_required_file "${SCRIPT_DIR}/spacetime-normalize-editor-character-actions.mjs" "${TARGET_DIR}/scripts/spacetime-normalize-editor-character-actions.mjs" "角色动作元数据规范化脚本"
|
||||
copy_required_file "${SCRIPT_DIR}/spacetime-authorize-migration-operator.mjs" "${TARGET_DIR}/scripts/spacetime-authorize-migration-operator.mjs" "数据库迁移授权脚本"
|
||||
copy_required_file "${SCRIPT_DIR}/spacetime-revoke-migration-operator.mjs" "${TARGET_DIR}/scripts/spacetime-revoke-migration-operator.mjs" "数据库迁移撤权脚本"
|
||||
copy_required_file "${SCRIPT_DIR}/database-backup-to-oss.mjs" "${TARGET_DIR}/scripts/database-backup-to-oss.mjs" "数据库 OSS 备份脚本"
|
||||
|
||||
@@ -4,11 +4,15 @@ import { spawnSync } from 'node:child_process';
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
symlinkSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import os from 'node:os';
|
||||
@@ -141,6 +145,74 @@ function validateRuntimePageLifecycle() {
|
||||
if (missingPage.status === 0 || existsSync(markerFile)) {
|
||||
fail('不存在的 --page-file 必须在创建 marker 前失败。');
|
||||
}
|
||||
|
||||
const linkedPageTarget = path.join(tempRoot, 'linked-page-target');
|
||||
mkdirSync(linkedPageTarget);
|
||||
symlinkSync(
|
||||
linkedPageTarget,
|
||||
runtimePageFile,
|
||||
process.platform === 'win32' ? 'junction' : 'dir',
|
||||
);
|
||||
const linkedPageEnable = runScript(
|
||||
onScript,
|
||||
['--page-file', sourcePageFile, 'linked page target'],
|
||||
env,
|
||||
);
|
||||
if (linkedPageEnable.status === 0) {
|
||||
fail('maintenance-on 必须拒绝指向目录的公告页符号链接。');
|
||||
}
|
||||
if (!lstatSync(runtimePageFile).isSymbolicLink()) {
|
||||
fail('拒绝公告页符号链接后不得替换链接本身。');
|
||||
}
|
||||
if (readdirSync(linkedPageTarget).length > 0) {
|
||||
fail('拒绝公告页符号链接后不得把临时文件移入链接目标目录。');
|
||||
}
|
||||
if (
|
||||
readdirSync(path.dirname(runtimePageFile)).some((entry) =>
|
||||
entry.startsWith(`${path.basename(runtimePageFile)}.tmp.`),
|
||||
)
|
||||
) {
|
||||
fail('公告页符号链接校验失败后不得残留 page.html.tmp.* 临时文件。');
|
||||
}
|
||||
if (existsSync(markerFile)) {
|
||||
fail('公告页符号链接校验失败时不得创建维护 marker。');
|
||||
}
|
||||
unlinkSync(runtimePageFile);
|
||||
|
||||
const linkedMarkerTarget = path.join(tempRoot, 'linked-marker-target');
|
||||
mkdirSync(linkedMarkerTarget);
|
||||
symlinkSync(
|
||||
linkedMarkerTarget,
|
||||
markerFile,
|
||||
process.platform === 'win32' ? 'junction' : 'dir',
|
||||
);
|
||||
const linkedMarkerEnable = runScript(
|
||||
onScript,
|
||||
['linked marker target'],
|
||||
env,
|
||||
);
|
||||
if (linkedMarkerEnable.status === 0) {
|
||||
fail('maintenance-on 必须拒绝指向目录的 marker 符号链接。');
|
||||
}
|
||||
if (!lstatSync(markerFile).isSymbolicLink()) {
|
||||
fail('拒绝 marker 符号链接后不得替换链接本身。');
|
||||
}
|
||||
if (readdirSync(linkedMarkerTarget).length > 0) {
|
||||
fail('拒绝 marker 符号链接后不得把临时文件移入链接目标目录。');
|
||||
}
|
||||
if (
|
||||
readdirSync(path.dirname(markerFile)).some((entry) =>
|
||||
entry.startsWith(`${path.basename(markerFile)}.tmp.`),
|
||||
)
|
||||
) {
|
||||
fail('marker 符号链接校验失败后不得残留 enabled.tmp.* 临时文件。');
|
||||
}
|
||||
if (
|
||||
linkedMarkerEnable.stdout.includes('已进入维护模式') ||
|
||||
linkedMarkerEnable.stderr.includes('已进入维护模式')
|
||||
) {
|
||||
fail('marker 符号链接校验失败时不得打印维护模式成功信息。');
|
||||
}
|
||||
} finally {
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -152,8 +152,15 @@ function parseArchiveObjectMembers(artifact) {
|
||||
longNameTable = artifact.subarray(contentStart, contentEnd);
|
||||
} else if (/^\/\d+$/u.test(rawName) && longNameTable) {
|
||||
const nameOffset = Number.parseInt(rawName.slice(1), 10);
|
||||
const nameEnd = longNameTable.indexOf(0x0a, nameOffset);
|
||||
const resolvedEnd = nameEnd >= 0 ? nameEnd : longNameTable.length;
|
||||
// GNU archives use newline-terminated names; MSVC COFF archives use NUL.
|
||||
const candidateNameEnds = [
|
||||
longNameTable.indexOf(0x00, nameOffset),
|
||||
longNameTable.indexOf(0x0a, nameOffset),
|
||||
].filter((nameEnd) => nameEnd >= 0);
|
||||
const resolvedEnd =
|
||||
candidateNameEnds.length > 0
|
||||
? Math.min(...candidateNameEnds)
|
||||
: longNameTable.length;
|
||||
memberName = longNameTable
|
||||
.subarray(nameOffset, resolvedEnd)
|
||||
.toString('utf8')
|
||||
|
||||
@@ -4915,12 +4915,12 @@ function assertDesktopReleaseBinaryArtifact() {
|
||||
const machMagic = header.readUInt32BE(0);
|
||||
const isMachO =
|
||||
machMagic === 0xcafebabe ||
|
||||
machMagic === 0xcafebabf ||
|
||||
machMagic === 0xbebafeca ||
|
||||
machMagic === 0xcafebabf ||
|
||||
machMagic === 0xbfbafeca ||
|
||||
machMagic === 0xfeedface ||
|
||||
machMagic === 0xfeedfacf ||
|
||||
machMagic === 0xcefaedfe ||
|
||||
machMagic === 0xfeedfacf ||
|
||||
machMagic === 0xcffaedfe;
|
||||
if (!isMachO || (stat.mode & 0o111) === 0) {
|
||||
throw new Error(
|
||||
|
||||
@@ -614,6 +614,21 @@ const checks = [
|
||||
includes: 'dry_run: !options.apply',
|
||||
reason: '图片画布存量迁移必须默认 dry-run,只有显式 --apply 才写入。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/spacetime-normalize-editor-character-actions.mjs',
|
||||
includes: "const SCOPES = ['asset', 'project-resource', 'showcase', 'canvas']",
|
||||
reason: '角色动作规范化必须按固定依赖顺序执行四个 scope。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/spacetime-normalize-editor-character-actions.mjs',
|
||||
includes: 'expectedBatchSha256: dryRun.batch_sha256',
|
||||
reason: '角色动作规范化 apply 必须绑定同批 dry-run 返回的摘要。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/spacetime-normalize-editor-character-actions.mjs',
|
||||
includes: "await scanScopes(options, { verifyZero: true })",
|
||||
reason: '角色动作规范化 apply 后必须执行全量零匹配复核。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/spacetime-repair-editor-canvas-resources.mjs',
|
||||
includes: 'buildProcedureInput(canvas, updatedAtMicros, !options.apply)',
|
||||
@@ -634,11 +649,21 @@ const checks = [
|
||||
includes: 'scripts/spacetime-migrate-editor-canvas-layout.mjs',
|
||||
reason: 'Stdb Build 必须归档图片画布存量迁移脚本。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-stdb-module-build',
|
||||
includes: 'scripts/spacetime-normalize-editor-character-actions.mjs',
|
||||
reason: 'Stdb Build 必须归档角色动作元数据规范化脚本。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-stdb-module-publish',
|
||||
includes: 'scripts/spacetime-migrate-editor-canvas-layout.mjs',
|
||||
reason: 'Stdb Publish 必须从同一上游制品复制图片画布存量迁移脚本。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-stdb-module-publish',
|
||||
includes: 'scripts/spacetime-normalize-editor-character-actions.mjs',
|
||||
reason: 'Stdb Publish 必须从同一上游制品复制角色动作元数据规范化脚本。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-stdb-module-build',
|
||||
includes: 'scripts/spacetime-repair-editor-canvas-resources.mjs',
|
||||
|
||||
@@ -6,6 +6,36 @@ MAINTENANCE_FILE="${GENARRATIVE_MAINTENANCE_FILE:-/var/lib/genarrative/maintenan
|
||||
MAINTENANCE_PAGE_FILE="${GENARRATIVE_MAINTENANCE_PAGE_FILE:-/var/lib/genarrative/maintenance/page.html}"
|
||||
PAGE_SOURCE=""
|
||||
REASON_PARTS=()
|
||||
page_temp=""
|
||||
marker_temp=""
|
||||
|
||||
cleanup_temps() {
|
||||
if [[ -n "${page_temp}" ]]; then
|
||||
rm -f -- "${page_temp}"
|
||||
fi
|
||||
if [[ -n "${marker_temp}" ]]; then
|
||||
rm -f -- "${marker_temp}"
|
||||
fi
|
||||
}
|
||||
|
||||
trap cleanup_temps EXIT
|
||||
|
||||
replace_file_atomically() {
|
||||
local source_file="$1"
|
||||
local target_file="$2"
|
||||
|
||||
if [[ -L "${target_file}" ]]; then
|
||||
echo "[maintenance] 原子替换目标不能是符号链接: ${target_file}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -d "${target_file}" ]]; then
|
||||
echo "[maintenance] 原子替换目标不能是目录: ${target_file}" >&2
|
||||
exit 1
|
||||
fi
|
||||
# 源文件与目标文件位于同一目录,POSIX rename 语义即可保证原子替换。
|
||||
# 不使用 GNU mv 专属的 -T,确保 macOS/BSD 本地门禁也能执行。
|
||||
mv -f "${source_file}" "${target_file}"
|
||||
}
|
||||
|
||||
replace_file_atomically() {
|
||||
local source_file="$1"
|
||||
@@ -52,7 +82,6 @@ mkdir -p "$(dirname "${MAINTENANCE_FILE}")" "$(dirname "${MAINTENANCE_PAGE_FILE}
|
||||
|
||||
if [[ -n "${PAGE_SOURCE}" ]]; then
|
||||
page_temp="$(mktemp "${MAINTENANCE_PAGE_FILE}.tmp.XXXXXX")"
|
||||
trap 'rm -f "${page_temp:-}" "${marker_temp:-}"' EXIT
|
||||
install -m 0644 -- "${PAGE_SOURCE}" "${page_temp}"
|
||||
replace_file_atomically "${page_temp}" "${MAINTENANCE_PAGE_FILE}"
|
||||
page_temp=""
|
||||
|
||||
@@ -281,7 +281,7 @@ export async function assertReadableFile(filePath) {
|
||||
|
||||
function normalizeProcedureResult(value, procedureName) {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return value;
|
||||
return normalizeSatsObject(value, procedureName);
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
@@ -291,7 +291,53 @@ function normalizeProcedureResult(value, procedureName) {
|
||||
throw new Error('procedure 返回值不是对象。');
|
||||
}
|
||||
|
||||
function normalizeSatsObject(value, procedureName) {
|
||||
const normalized = normalizeSatsValue(value);
|
||||
if (
|
||||
procedureName !==
|
||||
'normalize_editor_character_animation_metadata_and_return' ||
|
||||
!normalized ||
|
||||
typeof normalized !== 'object' ||
|
||||
Array.isArray(normalized)
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const { batch_sha_256: batchSha256, ...result } = normalized;
|
||||
return {
|
||||
...result,
|
||||
batch_sha256: result.batch_sha256 ?? batchSha256,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSatsProduct(value, procedureName) {
|
||||
if (
|
||||
procedureName === 'normalize_editor_character_animation_metadata_and_return' &&
|
||||
value.length === 19
|
||||
) {
|
||||
return {
|
||||
ok: normalizeSatsValue(value[0]),
|
||||
scope: normalizeSatsValue(value[1]),
|
||||
dry_run: normalizeSatsValue(value[2]),
|
||||
scanned_count: normalizeSatsValue(value[3]),
|
||||
matched_count: normalizeSatsValue(value[4]),
|
||||
updated_count: normalizeSatsValue(value[5]),
|
||||
backfilled_frames_count: normalizeSatsValue(value[6]),
|
||||
backfilled_duration_count: normalizeSatsValue(value[7]),
|
||||
reclassified_preview_count: normalizeSatsValue(value[8]),
|
||||
cleaned_generation_inputs_count: normalizeSatsValue(value[9]),
|
||||
cleaned_frame_index_count: normalizeSatsValue(value[10]),
|
||||
cleaned_canvas_layer_count: normalizeSatsValue(value[11]),
|
||||
materialized_resource_count: normalizeSatsValue(value[12]),
|
||||
blocker_count: normalizeSatsValue(value[13]),
|
||||
blocker_ids: normalizeSatsValue(value[14]),
|
||||
next_cursor: normalizeSatsOption(value[15]),
|
||||
has_more: normalizeSatsValue(value[16]),
|
||||
batch_sha256: normalizeSatsValue(value[17]),
|
||||
error_message: normalizeSatsOption(value[18]),
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
procedureName === 'repair_editor_canvas_resources_and_return' &&
|
||||
value.length === 3
|
||||
|
||||
@@ -99,4 +99,79 @@ describe('SpacetimeDB CLI SATS option encoding', () => {
|
||||
error_message: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes character action metadata normalization results', () => {
|
||||
const result = parseProcedureResult(
|
||||
JSON.stringify([
|
||||
true,
|
||||
'asset',
|
||||
true,
|
||||
25,
|
||||
3,
|
||||
0,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
3,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
['asset-blocked'],
|
||||
[0, 'asset-25'],
|
||||
true,
|
||||
'a'.repeat(64),
|
||||
[1],
|
||||
]),
|
||||
'normalize_editor_character_animation_metadata_and_return',
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
scope: 'asset',
|
||||
dry_run: true,
|
||||
scanned_count: 25,
|
||||
matched_count: 3,
|
||||
updated_count: 0,
|
||||
backfilled_frames_count: 2,
|
||||
backfilled_duration_count: 1,
|
||||
reclassified_preview_count: 0,
|
||||
cleaned_generation_inputs_count: 3,
|
||||
cleaned_frame_index_count: 2,
|
||||
cleaned_canvas_layer_count: 0,
|
||||
materialized_resource_count: 0,
|
||||
blocker_count: 1,
|
||||
blocker_ids: ['asset-blocked'],
|
||||
next_cursor: 'asset-25',
|
||||
has_more: true,
|
||||
batch_sha256: 'a'.repeat(64),
|
||||
error_message: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes object-shaped character action procedure results', () => {
|
||||
const result = parseProcedureResult(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
scope: 'asset',
|
||||
dry_run: true,
|
||||
scanned_count: 25,
|
||||
matched_count: 3,
|
||||
updated_count: 0,
|
||||
blocker_count: 0,
|
||||
blocker_ids: [],
|
||||
next_cursor: 'asset-25',
|
||||
has_more: true,
|
||||
batch_sha_256: 'b'.repeat(64),
|
||||
error_message: null,
|
||||
}),
|
||||
'normalize_editor_character_animation_metadata_and_return',
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
batch_sha256: 'b'.repeat(64),
|
||||
next_cursor: 'asset-25',
|
||||
});
|
||||
expect(result).not.toHaveProperty('batch_sha_256');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
#!/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 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;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildNormalizationInput,
|
||||
parseOptions,
|
||||
scanScopes,
|
||||
} from './spacetime-normalize-editor-character-actions.mjs';
|
||||
|
||||
describe('角色动作元数据规范化脚本', () => {
|
||||
it('默认 dry-run 并要求调用方显式选择 apply', () => {
|
||||
expect(
|
||||
parseOptions(
|
||||
['--database', 'genarrative-prod', '--server', 'prod'],
|
||||
{},
|
||||
),
|
||||
).toMatchObject({
|
||||
apply: false,
|
||||
chunkSize: 25,
|
||||
database: 'genarrative-prod',
|
||||
server: 'prod',
|
||||
});
|
||||
});
|
||||
|
||||
it('支持使用环境 token 的 HTTP 调用模式', () => {
|
||||
expect(
|
||||
parseOptions(
|
||||
[
|
||||
'--database',
|
||||
'genarrative-dev',
|
||||
'--server-url',
|
||||
'http://127.0.0.1:3101',
|
||||
'--use-http',
|
||||
],
|
||||
{ GENARRATIVE_SPACETIME_TOKEN: 'dev-migration-token' },
|
||||
),
|
||||
).toMatchObject({
|
||||
database: 'genarrative-dev',
|
||||
serverUrl: 'http://127.0.0.1:3101',
|
||||
token: 'dev-migration-token',
|
||||
useHttp: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('编码 dry-run cursor 并禁止 canvas 超过五行', () => {
|
||||
expect(
|
||||
buildNormalizationInput({
|
||||
scope: 'asset',
|
||||
cursor: 'asset-25',
|
||||
limit: 25,
|
||||
dryRun: true,
|
||||
}),
|
||||
).toEqual({
|
||||
scope: 'asset',
|
||||
cursor: [0, 'asset-25'],
|
||||
limit: 25,
|
||||
dry_run: true,
|
||||
expected_batch_sha_256: null,
|
||||
});
|
||||
expect(() =>
|
||||
buildNormalizationInput({
|
||||
scope: 'canvas',
|
||||
limit: 6,
|
||||
dryRun: true,
|
||||
}),
|
||||
).toThrow('canvas scope limit');
|
||||
});
|
||||
|
||||
it('apply 必须绑定 dry-run 返回的批次哈希', () => {
|
||||
expect(() =>
|
||||
buildNormalizationInput({
|
||||
scope: 'showcase',
|
||||
limit: 25,
|
||||
dryRun: false,
|
||||
}),
|
||||
).toThrow('batch SHA-256');
|
||||
|
||||
expect(
|
||||
buildNormalizationInput({
|
||||
scope: 'showcase',
|
||||
cursor: null,
|
||||
limit: 25,
|
||||
dryRun: false,
|
||||
expectedBatchSha256: 'a'.repeat(64),
|
||||
}),
|
||||
).toEqual({
|
||||
scope: 'showcase',
|
||||
cursor: null,
|
||||
limit: 25,
|
||||
dry_run: false,
|
||||
expected_batch_sha_256: [0, 'a'.repeat(64)],
|
||||
});
|
||||
});
|
||||
|
||||
it('按固定 scope 顺序为每批执行 hash 绑定的 dry-run 与 apply', async () => {
|
||||
const calls: Array<Record<string, unknown>> = [];
|
||||
const callProcedure = async (
|
||||
_options: Record<string, unknown>,
|
||||
input: Record<string, unknown>,
|
||||
) => {
|
||||
calls.push(input);
|
||||
const dryRun = input.dry_run === true;
|
||||
const scope = String(input.scope);
|
||||
const hashDigit = {
|
||||
asset: 'a',
|
||||
'project-resource': 'b',
|
||||
showcase: 'c',
|
||||
canvas: 'd',
|
||||
}[scope]!;
|
||||
const batchSha256 = hashDigit.repeat(64);
|
||||
return {
|
||||
ok: true,
|
||||
scope,
|
||||
dry_run: dryRun,
|
||||
scanned_count: 1,
|
||||
matched_count: 1,
|
||||
updated_count: dryRun ? 0 : 1,
|
||||
blocker_count: 0,
|
||||
blocker_ids: [],
|
||||
next_cursor: null,
|
||||
has_more: false,
|
||||
batch_sha256: batchSha256,
|
||||
error_message: null,
|
||||
};
|
||||
};
|
||||
|
||||
const summaries = await scanScopes(
|
||||
{ chunkSize: 25 },
|
||||
{ apply: true, callProcedure },
|
||||
);
|
||||
|
||||
expect(summaries.map((summary) => summary.scope)).toEqual([
|
||||
'asset',
|
||||
'project-resource',
|
||||
'showcase',
|
||||
'canvas',
|
||||
]);
|
||||
expect(calls.map((call) => `${call.scope}:${call.dry_run}`)).toEqual([
|
||||
'asset:true',
|
||||
'asset:false',
|
||||
'project-resource:true',
|
||||
'project-resource:false',
|
||||
'showcase:true',
|
||||
'showcase:false',
|
||||
'canvas:true',
|
||||
'canvas:false',
|
||||
]);
|
||||
for (let index = 1; index < calls.length; index += 2) {
|
||||
const apply = calls[index]!;
|
||||
const scope = String(apply.scope);
|
||||
const hashDigit = {
|
||||
asset: 'a',
|
||||
'project-resource': 'b',
|
||||
showcase: 'c',
|
||||
canvas: 'd',
|
||||
}[scope]!;
|
||||
expect(apply.expected_batch_sha_256).toEqual([
|
||||
0,
|
||||
hashDigit.repeat(64),
|
||||
]);
|
||||
}
|
||||
expect(calls.at(-2)?.limit).toBe(5);
|
||||
});
|
||||
|
||||
it('拒绝 procedure 返回已访问过的 cursor', async () => {
|
||||
let callCount = 0;
|
||||
const callProcedure = async () => {
|
||||
callCount += 1;
|
||||
const nextCursor = ['asset-25', 'asset-50', 'asset-25'][callCount - 1];
|
||||
return {
|
||||
ok: true,
|
||||
scope: 'asset',
|
||||
dry_run: true,
|
||||
scanned_count: 1,
|
||||
matched_count: 0,
|
||||
updated_count: 0,
|
||||
blocker_count: 0,
|
||||
blocker_ids: [],
|
||||
next_cursor: nextCursor,
|
||||
has_more: true,
|
||||
batch_sha256: 'a'.repeat(64),
|
||||
error_message: null,
|
||||
};
|
||||
};
|
||||
|
||||
await expect(
|
||||
scanScopes({ chunkSize: 25 }, { callProcedure }),
|
||||
).rejects.toThrow('重复的 next_cursor: asset-25');
|
||||
expect(callCount).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import {
|
||||
callSpacetimeProcedure,
|
||||
callSpacetimeProcedureViaCli,
|
||||
ensureProcedureOk,
|
||||
parseArgs,
|
||||
@@ -15,11 +16,20 @@ try {
|
||||
const input = {
|
||||
operator_identity_hex: options.operatorIdentity,
|
||||
};
|
||||
const result = await callSpacetimeProcedureViaCli(
|
||||
options,
|
||||
'revoke_database_migration_operator',
|
||||
input,
|
||||
);
|
||||
if (options.useHttp && !options.token) {
|
||||
throw new Error('--use-http 需要同时传入 --token。');
|
||||
}
|
||||
const result = options.useHttp
|
||||
? await callSpacetimeProcedure(
|
||||
options,
|
||||
'revoke_database_migration_operator',
|
||||
input,
|
||||
)
|
||||
: await callSpacetimeProcedureViaCli(
|
||||
options,
|
||||
'revoke_database_migration_operator',
|
||||
input,
|
||||
);
|
||||
ensureProcedureOk(result);
|
||||
|
||||
console.log(
|
||||
|
||||
@@ -87,7 +87,7 @@ const tests = [
|
||||
body: {
|
||||
model: 'gpt-5.4-mini',
|
||||
messages: [{ role: 'user', content: '回复 ok,不要解释' }],
|
||||
max_tokens: 10,
|
||||
max_completion_tokens: 10,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -112,7 +112,7 @@ const tests = [
|
||||
body: {
|
||||
model: 'gpt-5.4-mini',
|
||||
messages: [{ role: 'user', content: '回复 ok' }],
|
||||
max_tokens: 10,
|
||||
max_completion_tokens: 10,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -127,7 +127,7 @@ const tests = [
|
||||
{ role: 'system', content: '你是抓大鹅游戏编辑,只返回 JSON。' },
|
||||
{ role: 'user', content: '题材:水果。请生成 JSON:{"gameName":"水果切切乐","items":[{"name":"苹果","itemSize":"中"},{"name":"西瓜","itemSize":"大"}]}' },
|
||||
],
|
||||
max_tokens: 200,
|
||||
max_completion_tokens: 200,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user