legacy 内联账本一次性迁入本机

账本移出布局后,hydrate 仍认布局里的 legacy 内联快照,但没有任何路径把它
写进本机账本;而序列化会在下一次保存时把内联快照剥成 perfectPixelOperationId
标记。先前提交声称「滚动部署期间的在途操作不会被一次性判死」只对了一半:
第一次 hydrate 活下来,第二次就变成 failed + invalid,永久失去 exact retry
的 identity。

在 applyProjectSnapshot 读账本、splitCanvasLayoutItems 之后补一次性迁移。
三个条件都必要:只补写本机缺失的(本机那份可能刚在 pre-POST flush 之后被
重新锚定过,比布局里的新);只补写未收口的(收口态本就不需要账本)。

未采纳「删除后立即 flush 压缩竞态窗口」:核查发现删除已经触发既有的 450ms
防抖自动保存,该改动只能在由服务端处理耗时(数秒)主导的竞态里省下 450 毫秒,
代价是让高频操作绕过防抖。

未采纳「重做时弹确认框」:完美像素免费,最坏是素材库多一份;在常用路径上加
确认属于制造摩擦,且账本只能按 sourceResourceId 匹配同源,纯本地图层匹配不到,
覆盖不全的提醒比没有提醒更容易让人误以为安全。

Undo 复活占位在当前会话内不再对账(刷新即自愈)记为已知限制,连同根因与四种
修法各自的硬伤一并写入专题文档与决策日志。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 09:06:28 +00:00
parent ef1807e350
commit 11a2c5afe0
4 changed files with 153 additions and 2 deletions
@@ -6428,3 +6428,14 @@
- 保留为已知缺口而非静默修正:「普通按钮不得创建第二个 operation」原文是绝对断言,但该保证只由 `existingOperation` 闸提供,而它只扫描内存 dialog 列表;占位可删之后,删掉再从源图发起会产生第二个 identity。文档改为如实描述现状并标注缺口与闭合方向(让本机账本参与防重),代码侧不在本次范围。文档的职责是描述系统实际行为,写一条做不到的保证比留一个标注清楚的缺口更糟。
- 影响范围:仅文档。不改代码、不改测试。
- 验证方式:`npm run check:encoding`;核对文档中不再残留「布局保存成功确认」「strict revision ACK」「从快照写入起算」等已推翻表述。
## 2026-08-05 legacy 内联账本一次性迁入本机;Undo 复活占位记为已知限制
- 背景:账本移出布局后,`hydrateCanvasGenerationDialog` 仍然认布局里的 legacy 内联快照,但**没有任何路径把它写进本机账本**;而 `serializeDialogReferences` 会在下一次保存时把内联快照剥成 `perfectPixelOperationId` 标记。先前提交声称「滚动部署期间的在途操作不会被一次性判死」只对了一半:**第一次 hydrate 活下来,第二次就变成 `failed + invalid`**,永久失去 exact retry 的 identity。
- 决策:在 `applyProjectSnapshot` 读账本、`splitCanvasLayoutItems` 之后补一次性迁移——把带内联账本、本机却读不到、且**仍未收口**(`generating` / `pending-confirmation`)的 operation 写进本机账本。三个条件都必要:只补写缺失的(本机那份可能刚在 pre-POST flush 之后被重新锚定过,比布局里的新,不能覆盖);只补写未收口的(收口态本就不需要账本,迁移只会造出立刻被裁剪的垃圾条目)。影响范围一次性且有界,仅限部署那一刻仍在途的历史操作。
- 未采纳:「删除占位后立即 flush 布局,压缩『被放弃的 operation 仍可能往画布插入图层』的竞态窗口」。核查后发现删除**已经**触发既有的 450ms 防抖自动保存(布局自动保存 effect 的依赖里就有 `canvasGenerationDialogs`),所以该改动只能在一个由服务端处理耗时(数秒)主导的竞态里省下 450 毫秒,代价却是让一个高频操作绕过防抖、增加 PATCH 量。收益与代价不成比例,不做。
- 未采纳:「用户删除未收口占位后从源图重做时弹确认框」。完美像素免费,重复的最坏后果是素材库多一份;为此在常用路径上加一次确认属于给用户制造摩擦。另外账本里能用来匹配同源的只有 `request.sourceResourceId`,纯本地图层根本匹配不到——一个覆盖不全的提醒比没有提醒更容易让人误以为安全。
- 记为已知限制而非缺陷:删除仍在处理中的占位、待原请求收口后再 `Ctrl+Z` 撤销删除,复活的占位在**当前会话内**不再被对账,会一直显示处理中;刷新即自愈,用户也可以再删一次。根因是 `observedPerfectPixelRecoveryKeysRef` 同时承担「并发保护」和「本会话已驱动过」两种语义。已推演的四种修法各有硬伤:删除瞬间剪 key 会被同一轮的 claim 检查重新标记;改成「重新出现时剪」会被 `applyProjectSnapshot` 的整批替换误触发;由删除路径显式清观察记录需要向四个删除入口铺跨 hook 通路;不把未收口 operation 放进可撤销历史则直接砍掉「误删可撤销」。为一个刷新即愈的限制付上述任一代价都不划算,留到重构该记账时一并解决。
- 影响范围:`useImageCanvasProjectPersistence.ts``applyProjectSnapshot`。不修改服务端、SpacetimeDB schema 或对外契约。
- 验证方式:新增「legacy 内联账本在加载后被迁入本机,且剥离内联快照后仍能凭本机账本往返回有效的 `pending-confirmation` 占位」用例,已实证:回退迁移后报 `expected +0 to be 1`。运行 `npx vitest run src/components/image-editor src/components/platform-entry``npm run typecheck``npm run lint:eslint``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`(已同步 legacy 迁移要求与 Undo 已知限制)。
File diff suppressed because one or more lines are too long
@@ -12,12 +12,15 @@ import type {
import {
DEFAULT_CANVAS_BACKGROUND_COLOR,
normalizeCanvasBackgroundHex,
serializeCanvasLayout,
splitCanvasLayoutItems,
} from './ImageCanvasEditorModel';
import type {
CanvasGenerationDialogState,
CanvasLayer,
CanvasViewport,
} from './ImageCanvasEditorTypes';
import { readPerfectPixelOperations } from './perfectPixelOperationStore';
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
import {
mergeAuthoritativeCanvasLayoutWithPendingLocalLayout,
@@ -1023,6 +1026,120 @@ describe('useImageCanvasProjectPersistence', () => {
});
});
it('migrates a legacy inline perfect-pixel ledger into local storage on load', async () => {
// 中文注释:布局内联账本是账本移出布局之前的 legacy 形状。第一次 hydrate 还能认它,但
// 下一次保存会把它剥成 perfectPixelOperationId 标记,此后再没有路径能补写本机账本——
// 不迁移的话,部署那一刻仍在途的操作会在第二次加载变成 failed + invalid,永久失去
// exact retry 的 identity。
window.localStorage.clear();
const dialogId = 'generation-dialog-legacy-inflight';
const submittedAt = Date.now();
const legacyOperation = {
version: 1 as const,
kind: 'perfect-pixel' as const,
operationId: dialogId,
taskId: `pixel-art-snap-${dialogId}`,
request: {
sourceImageSrc: 'generated-images/editor/legacy-source.png',
projectId: 'editor-project-default',
assetLabel: '源图 · 完美像素',
canvasCompletion: {
dialogId,
title: '源图 · 完美像素',
placeholder: { ...STRICT_PLACEHOLDER },
},
},
submittedAt,
reconcileUntil: submittedAt + 75_000,
};
const legacyDialogItem = {
itemType: 'generation-dialog',
layerId: `generation-dialog:${dialogId}`,
resourceId: `generation-dialog:${dialogId}`,
dialog: {
id: dialogId,
mode: 'quick-edit',
prompt: '完美像素',
status: 'pending-confirmation',
composerOpen: false,
sourceLayerId: 'layer-source',
perfectPixelOperation: legacyOperation,
placeholder: { ...STRICT_PLACEHOLDER },
},
} as unknown as EditorProjectLayerSnapshot;
loadOrCreateRecentEditorProjectMock.mockResolvedValue({
projectId: 'editor-project-default',
title: '空画布项目',
canvas: {
canvasId: 'editor-project-default:canvas:default',
projectId: 'editor-project-default',
title: '默认画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [legacyDialogItem],
revision: 3,
layoutStorageVersion: 0,
updatedAt: '2026-08-05T00:00:00.000Z',
},
viewport: { x: 0, y: 0, scale: 1 },
layers: [legacyDialogItem],
resources: [],
updatedAt: '2026-08-05T00:00:00.000Z',
});
render(<PerfectPixelProjectPersistenceHarness />);
await waitFor(() => {
expect(screen.getByTestId('strict-project-id').textContent).toBe(
'editor-project-default',
);
});
await waitFor(() => {
expect(
readPerfectPixelOperations('user-test', 'editor-project-default').size,
).toBe(1);
});
const migrated = readPerfectPixelOperations(
'user-test',
'editor-project-default',
).get(dialogId);
expect(migrated).toEqual(legacyOperation);
// 中文注释:往返验证——布局再保存一次只剩标记,此时只有本机账本能把它救回来。
const strippedLayout = serializeCanvasLayout({
layers: [],
canvasGenerationDialogs: [
{
id: dialogId,
mode: 'quick-edit',
prompt: '完美像素',
status: 'pending-confirmation',
composerOpen: false,
perfectPixelOperationId: dialogId,
perfectPixelOperation: migrated,
} as unknown as CanvasGenerationDialogState,
],
});
expect(JSON.stringify(strippedLayout)).not.toContain(
'"perfectPixelOperation"',
);
const { generationDialogs } = splitCanvasLayoutItems(
strippedLayout,
new Map(),
'user-test',
readPerfectPixelOperations('user-test', 'editor-project-default'),
);
expect(generationDialogs[0]).toMatchObject({
id: dialogId,
status: 'pending-confirmation',
});
expect(generationDialogs[0]?.perfectPixelOperation).toEqual(
legacyOperation,
);
expect(generationDialogs[0]).not.toHaveProperty(
'perfectPixelOperationInvalid',
);
});
it('persists the perfect-pixel marker without leaking the request ledger into the layout', async () => {
render(<PerfectPixelProjectPersistenceHarness />);
await waitFor(() => {
@@ -49,7 +49,10 @@ import {
firstSelectedLayerId,
normalizeCanvasSelectionIds,
} from './ImageCanvasSelectionModel';
import { readPerfectPixelOperations } from './perfectPixelOperationStore';
import {
readPerfectPixelOperations,
savePerfectPixelOperation,
} from './perfectPixelOperationStore';
type ProjectResourceOptions = {
onCreated?: (resourceId: string) => void;
@@ -1343,6 +1346,26 @@ export function useImageCanvasProjectPersistence({
currentUserId,
localPerfectPixelOperations,
);
// 中文注释:legacy 内联账本一次性迁到本机。布局里的内联快照会在下一次保存时被剥成
// `perfectPixelOperationId` 标记,此后再没有任何路径能把它补写进本机账本——不迁移
// 的话,部署那一刻仍在途的操作会在第二次加载变成 `failed + invalid`,永久失去 exact
// retry 的 identity。只补写缺失的(本机那份可能刚被重新锚定过,比布局里的新),也只
// 补写未收口的(收口态本就不需要账本)。
for (const dialog of generationDialogs) {
const operation = dialog.perfectPixelOperation;
if (
operation &&
!localPerfectPixelOperations.has(dialog.id) &&
(dialog.status === 'generating' ||
dialog.status === 'pending-confirmation')
) {
savePerfectPixelOperation(
currentUserId,
project.projectId,
operation,
);
}
}
const hydratedLayers = layerItems
.map((layer) => hydrateLayer(layer, resourcesById))
.filter((layer): layer is CanvasLayer => Boolean(layer));