修复画布历史资源空来源修复

允许资源 remap 精确匹配显式 null 的 sourceResourceId
修正 SpacetimeDB CLI procedure 的 SHA 字段与 Option wire 编码
补充 Rust 与 Node 回归测试并重新生成 Rust bindings
更新画布迁移方案和共享排障约定
This commit is contained in:
2026-07-27 22:28:00 +08:00
parent 47f00dae4d
commit a8b44f5100
6 changed files with 202 additions and 26 deletions
@@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url';
import {
callSpacetimeProcedureViaCli,
encodeSpacetimeCliOption,
} from './spacetime-migration-common.mjs';
const PROCEDURE_NAME = 'repair_editor_canvas_resources_and_return';
@@ -304,11 +305,6 @@ function validateRepairAction(action, label) {
`${label}.replacement_resource_id 为复用资源动作的必填项。`,
);
}
if (!normalized.expected_layout_source_resource_id) {
throw new Error(
`${label}.expected_layout_source_resource_id 为复用资源动作的必填项。`,
);
}
if (
normalized.replacement_resource_id === normalized.expected_resource_id
) {
@@ -327,7 +323,7 @@ function validateRepairAction(action, label) {
`${label} 的补资源动作不能携带 replacement_resource_id。`,
);
}
if (normalized.expected_layout_source_resource_id) {
if (normalized.expected_layout_source_resource_id !== null) {
throw new Error(
`${label} 的补资源动作不能携带 expected_layout_source_resource_id。`,
);
@@ -415,16 +411,17 @@ export function buildProcedureInput(canvas, updatedAtMicros, dryRun) {
project_id: canvas.project_id,
owner_user_id: canvas.owner_user_id,
expected_revision: canvas.expected_revision,
expected_canvas_layout_sha256: canvas.expected_canvas_layout_sha256,
expected_project_layout_sha256: canvas.expected_project_layout_sha256,
expected_canvas_layout_sha_256: canvas.expected_canvas_layout_sha256,
expected_project_layout_sha_256: canvas.expected_project_layout_sha256,
remaps: canvas.actions
.filter((action) => action.action_kind === 'reuse_project_resource')
.map((action) => ({
layer_id: action.layer_id,
expected_missing_resource_id: action.expected_resource_id,
replacement_resource_id: action.replacement_resource_id,
expected_layout_source_resource_id:
expected_layout_source_resource_id: encodeSpacetimeCliOption(
action.expected_layout_source_resource_id,
),
})),
restores: canvas.actions
.filter((action) => action.action_kind === 'restore_project_resource')
@@ -166,6 +166,39 @@ describe('spacetime editor canvas resource repair plan', () => {
]);
});
it('accepts an explicit null layout source only for reuse actions', () => {
const plan = validateRepairPlan(
planValue([
canvas({
actions: [
action({ expected_layout_source_resource_id: null }),
],
}),
]),
);
expect(
plan.canvases[0].actions[0].expected_layout_source_resource_id,
).toBeNull();
expect(() =>
validateRepairPlan(
planValue([
canvas({
actions: [
action({
action_kind: 'restore_project_resource',
replacement_resource_id: null,
expected_layout_source_resource_id: 'unexpected-source',
asset_object_id: 'private-asset-object-id',
asset_kind: 'sound-effect',
}),
],
}),
]),
),
).toThrow('不能携带 expected_layout_source_resource_id');
});
it('rejects count mismatches, duplicate layers, and invalid kind-specific fields', () => {
expect(() =>
validateRepairPlan({ ...planValue(), expected_action_count: 2 }),
@@ -263,7 +296,10 @@ describe('spacetime editor canvas resource repair execution', () => {
layer_id: 'private-layer-id',
expected_missing_resource_id: 'missing-private-resource-id',
replacement_resource_id: 'replacement-private-resource-id',
expected_layout_source_resource_id: 'source-private-resource-id',
expected_layout_source_resource_id: [
0,
'source-private-resource-id',
],
},
]);
expect(input.restores).toEqual([
@@ -275,10 +311,37 @@ describe('spacetime editor canvas resource repair execution', () => {
},
]);
expect(input.canvas_id).toBe('private-canvas-id');
expect(input.expected_canvas_layout_sha_256).toBe(SHA_A);
expect(input.expected_project_layout_sha_256).toBe(SHA_B);
expect(input).not.toHaveProperty('expected_canvas_layout_sha256');
expect(input).not.toHaveProperty('expected_project_layout_sha256');
expect(input.updated_at_micros).toBe(123);
expect(input.dry_run).toBe(true);
});
it('encodes an explicit null expected layout source as a CLI Option null', () => {
const plan = validateRepairPlan(
planValue([
canvas({
actions: [
action({ expected_layout_source_resource_id: null }),
],
}),
]),
);
const input = buildProcedureInput(plan.canvases[0], 123, true);
expect(input.remaps).toEqual([
{
layer_id: 'private-layer-id',
expected_missing_resource_id: 'missing-private-resource-id',
replacement_resource_id: 'replacement-private-resource-id',
expected_layout_source_resource_id: null,
},
]);
});
it('calls one procedure per canvas and emits only anonymous aggregate output', async () => {
const plan = validateRepairPlan(
planValue([