diff --git a/scripts/spacetime-migration-common.mjs b/scripts/spacetime-migration-common.mjs index d1366a0a3..e2f628ade 100644 --- a/scripts/spacetime-migration-common.mjs +++ b/scripts/spacetime-migration-common.mjs @@ -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,6 +291,25 @@ 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' && diff --git a/scripts/spacetime-migration-common.test.ts b/scripts/spacetime-migration-common.test.ts index 5a89d09f5..3ee503215 100644 --- a/scripts/spacetime-migration-common.test.ts +++ b/scripts/spacetime-migration-common.test.ts @@ -148,4 +148,30 @@ describe('SpacetimeDB CLI SATS option encoding', () => { 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'); + }); }); diff --git a/scripts/spacetime-normalize-editor-character-actions.mjs b/scripts/spacetime-normalize-editor-character-actions.mjs index 9548780c9..fe6927690 100644 --- a/scripts/spacetime-normalize-editor-character-actions.mjs +++ b/scripts/spacetime-normalize-editor-character-actions.mjs @@ -140,6 +140,7 @@ export async function scanScopes( const summaries = []; for (const scope of SCOPES) { let cursor = null; + const seenCursors = new Set(); const summary = { scope, scanned_count: 0, @@ -185,10 +186,17 @@ export async function scanScopes( } summary.updated_count += applied.updated_count; } - cursor = dryRun.has_more ? dryRun.next_cursor : null; - if (dryRun.has_more && !cursor) { + 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); } diff --git a/scripts/spacetime-normalize-editor-character-actions.test.ts b/scripts/spacetime-normalize-editor-character-actions.test.ts index c464d8deb..ed3259201 100644 --- a/scripts/spacetime-normalize-editor-character-actions.test.ts +++ b/scripts/spacetime-normalize-editor-character-actions.test.ts @@ -160,4 +160,31 @@ describe('角色动作元数据规范化脚本', () => { } 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); + }); });