refactor(*): 稳定期治理 - 收敛核心架构、错误语义并修复跨项目数据竞态
This commit is contained in:
@@ -95,9 +95,15 @@ function runWithBrowserCanvasStorageLock<T>(scope: string, operation: () => Prom
|
||||
return operation();
|
||||
}
|
||||
|
||||
/**
|
||||
* 串行化同一用户作用域的画布持久化,并在一次失败后允许队列继续前进。
|
||||
*
|
||||
* `pending` 必须把当前写入的真实结果返回给调用方;只有前一个 tail 的失败被
|
||||
* 转换成已处理的 void,才不会让一次旧失败永久毒化后续保存队列。
|
||||
*/
|
||||
export function withCanvasStorePersistenceLock<T>(scope: string, operation: () => Promise<T>, options: CanvasStorageLockOptions = {}): Promise<T> {
|
||||
const previous = canvasStorageTails.get(scope) ?? Promise.resolve();
|
||||
const pending = previous.catch(() => undefined).then(() => runWithBrowserCanvasStorageLock(scope, operation, options));
|
||||
const pending = previous.then(() => undefined, () => undefined).then(() => runWithBrowserCanvasStorageLock(scope, operation, options));
|
||||
const tail = pending.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
@@ -409,7 +415,10 @@ const canvasStorage: PersistStorage<CanvasStore> = {
|
||||
clearCanvasSaveTimer(scope);
|
||||
const timer = setTimeout(() => {
|
||||
if (canvasSaveTimers.get(scope) === timer) canvasSaveTimers.delete(scope);
|
||||
void writeQueuedCanvasPersist(scope, token).catch(() => undefined);
|
||||
void writeQueuedCanvasPersist(scope, token).catch((error) => {
|
||||
// 自动保存无法把异常返回给原始状态更新调用方,但失败队列仍会保留给下一次写入或显式 flush 重试。
|
||||
console.error("画布本地持久化失败,已保留待写队列", { scope, error });
|
||||
});
|
||||
}, 400);
|
||||
canvasSaveTimers.set(scope, timer);
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// 编辑器状态机(ADR-0002):命令唯一入口 + 有界快照撤销 + 1.5s 防抖保存。
|
||||
// - dispatch:同步 apply 命令,失败(fail-closed)不改状态、错误进 saveError 供 UI 上报
|
||||
// - 保存层依赖注入(saveTimeline),M4 前不绑定后端;保存串行化防止旧快照覆盖新状态
|
||||
// - 保存层依赖注入(saveTimeline),当前由页面绑定本地存储;保存串行化防止旧快照覆盖新状态
|
||||
// - previewGesture/commitGesture/cancelGesture:拖拽等连续手势期间只改渲染态,
|
||||
// 松手提交时一次性入历史(手势不逐帧污染撤销栈)
|
||||
|
||||
@@ -58,6 +58,13 @@ export type EditorStoreOptions = {
|
||||
debounceMs?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建一个项目级时间线状态容器。
|
||||
*
|
||||
* 状态容器不负责服务端持久化:saveTimeline 是调用方注入的本地存储适配器,
|
||||
* 这里仅保证命令、撤销历史和保存队列的一致性。保存失败必须保留 isDirty,
|
||||
* 让 UI 能提示未落盘并在下一次变更或卸载冲刷时重试。
|
||||
*/
|
||||
export function createEditorStore(options: EditorStoreOptions = {}): UseBoundStore<StoreApi<EditorStore>> {
|
||||
const registry = options.registry ?? getEditorCommandRegistry();
|
||||
const saveTimeline = options.saveTimeline;
|
||||
|
||||
@@ -3,7 +3,8 @@ import { persist, type PersistStorage, type StorageValue } from "zustand/middlew
|
||||
|
||||
import { nanoid } from "nanoid";
|
||||
import type { AssetCategory } from "@/lib/asset-category";
|
||||
import { normalizeAssetRecord, parseAssetStorageDocument, rebaseAssetSnapshot, serializeAssetStorageDocument, type AssetStorageDocument } from "@/lib/asset-storage-revision";
|
||||
import { parseAssetRecord } from "@/lib/asset-record";
|
||||
import { parseAssetStorageDocumentRecovering, rebaseAssetSnapshot, serializeAssetStorageDocument, type AssetStorageDocument } from "@/lib/asset-storage-revision";
|
||||
import { parseCanvasStorageDocument } from "@/lib/canvas/canvas-storage-revision";
|
||||
import { localForageStorageForScope } from "@/lib/localforage-storage";
|
||||
import { getActiveUserScope } from "@/lib/user-scope";
|
||||
@@ -108,7 +109,16 @@ async function commitPendingAssetStorePersistenceLocked(scope: string) {
|
||||
const queued = queuedAssetPersists.get(scope);
|
||||
if (!queued) return committed;
|
||||
|
||||
const durable = parseAssetStorageDocument(await storage.getItem(queued.name), queued.baseAssets);
|
||||
// 读取旧版本时允许隔离历史坏记录;真正写回前,queued.assets 已经由 persistAssetState 严格校验。
|
||||
// 这样既不会用伪造默认值掩盖坏数据,也不会让一条旧记录阻断整库的后续写入。
|
||||
const recovery = parseAssetStorageDocumentRecovering(await storage.getItem(queued.name), queued.baseAssets);
|
||||
if (recovery.invalid.length) {
|
||||
console.warn("素材本地缓存包含无法恢复的历史记录,写回时将隔离这些记录", {
|
||||
scope,
|
||||
invalid: recovery.invalid,
|
||||
});
|
||||
}
|
||||
const durable = recovery.document;
|
||||
const rebased = rebaseAssetSnapshot({
|
||||
document: durable,
|
||||
baseAssets: queued.baseAssets,
|
||||
@@ -135,18 +145,33 @@ async function writeQueuedAssetPersist(scope: string, _token: number) {
|
||||
}
|
||||
|
||||
async function readPersistedAssetDocumentForScope(scope: string) {
|
||||
return parseAssetStorageDocument(await localForageStorageForScope(scope).getItem(ASSET_STORE_KEY));
|
||||
const recovery = parseAssetStorageDocumentRecovering(await localForageStorageForScope(scope).getItem(ASSET_STORE_KEY));
|
||||
if (recovery.invalid.length) {
|
||||
console.warn("素材本地缓存包含无法恢复的历史记录,已隔离并保留其诊断信息", {
|
||||
scope,
|
||||
invalid: recovery.invalid,
|
||||
});
|
||||
}
|
||||
return recovery.document;
|
||||
}
|
||||
|
||||
/**
|
||||
* 登记一个素材写操作,供登出、切换账号和页面卸载前统一冲刷。
|
||||
*
|
||||
* 这里不能直接对 finally 返回的 Promise 放任不管:原操作失败时,finally 派生的
|
||||
* Promise 也会拒绝并产生未处理拒绝。用 then 的成功/失败分支做同一个清理动作,
|
||||
* 既保留原 Promise 给调用方观察真实错误,也不会制造第二条未处理错误链。
|
||||
*/
|
||||
function trackAssetOperation<T>(operation: Promise<T>) {
|
||||
assetOperations.add(operation);
|
||||
void operation.finally(() => assetOperations.delete(operation)).catch(() => undefined);
|
||||
const cleanup = () => assetOperations.delete(operation);
|
||||
void operation.then(cleanup, cleanup);
|
||||
return operation;
|
||||
}
|
||||
|
||||
function persistAssetState(name: string, value: StorageValue<AssetStore>) {
|
||||
const scope = getActiveUserScope();
|
||||
const nextAssets = value.state.assets.map(normalizeAssetRecord);
|
||||
const nextAssets = value.state.assets.map(parseAssetRecord);
|
||||
const queued = queuedAssetPersists.get(scope);
|
||||
const observed = observedAssetPersists.get(scope);
|
||||
const baseAssets = assetMemoryStates.get(scope)?.assets ?? observed?.assets ?? [];
|
||||
@@ -215,7 +240,14 @@ const assetStorage: PersistStorage<AssetStore> = {
|
||||
observedAssetPersists.set(scope, { assets: [], revision: 0 });
|
||||
return null;
|
||||
}
|
||||
const document = parseAssetStorageDocument(value);
|
||||
const recovery = parseAssetStorageDocumentRecovering(value);
|
||||
if (recovery.invalid.length) {
|
||||
console.warn("素材本地缓存包含无法恢复的历史记录,已隔离并保留其诊断信息", {
|
||||
scope,
|
||||
invalid: recovery.invalid,
|
||||
});
|
||||
}
|
||||
const document = recovery.document;
|
||||
// 持久化恢复只恢复结构化记录,不能为每个 resource: key 逐条读取资源元数据或 Blob。
|
||||
// 远程资源直接使用受鉴权的 file URL;本地 legacy key 仍恢复为 Blob URL,避免兼容性回归。
|
||||
const assets = await Promise.all(document.state.assets.map((asset) => normalizePersistedAsset(asset)));
|
||||
@@ -232,7 +264,7 @@ const assetStorage: PersistStorage<AssetStore> = {
|
||||
};
|
||||
|
||||
async function normalizePersistedAsset(asset: Asset): Promise<Asset> {
|
||||
asset = normalizeAssetRecord(asset);
|
||||
asset = parseAssetRecord(asset);
|
||||
const storageKey = "data" in asset && asset.data && "storageKey" in asset.data ? asset.data.storageKey : undefined;
|
||||
const resourceId = resourceIdFromStorageKey(storageKey);
|
||||
if (resourceId) {
|
||||
@@ -272,7 +304,7 @@ export const useAssetStore = create<AssetStore>()(
|
||||
addAsset: (asset) => {
|
||||
const now = new Date().toISOString();
|
||||
const id = nanoid();
|
||||
set((state) => ({ assets: [normalizeAssetRecord({ ...asset, id, createdAt: now, updatedAt: now } as Asset), ...state.assets] }));
|
||||
set((state) => ({ assets: [parseAssetRecord({ ...asset, id, createdAt: now, updatedAt: now }), ...state.assets] }));
|
||||
return id;
|
||||
},
|
||||
addGenerationAsset: (effectKey, asset, signal) => {
|
||||
@@ -290,13 +322,13 @@ export const useAssetStore = create<AssetStore>()(
|
||||
assetId: id,
|
||||
createAsset: () => {
|
||||
const now = new Date().toISOString();
|
||||
return normalizeAssetRecord({
|
||||
return parseAssetRecord({
|
||||
...asset,
|
||||
id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
metadata: { ...asset.metadata, generationEffectKey: effectKey },
|
||||
} as Asset);
|
||||
});
|
||||
},
|
||||
updateAssets: (updater) => {
|
||||
withAssetStorePersistenceSuppressed(() => {
|
||||
@@ -356,7 +388,7 @@ export const useAssetStore = create<AssetStore>()(
|
||||
},
|
||||
updateAsset: (id, patch) =>
|
||||
set((state) => ({
|
||||
assets: state.assets.map((asset) => (asset.id === id ? normalizeAssetRecord({ ...asset, ...patch, updatedAt: new Date().toISOString() } as Asset) : asset)),
|
||||
assets: state.assets.map((asset) => (asset.id === id ? parseAssetRecord({ ...asset, ...patch, updatedAt: new Date().toISOString() }) : asset)),
|
||||
})),
|
||||
removeAsset: async (id) => {
|
||||
let remainingAssets: Asset[] = [];
|
||||
@@ -372,7 +404,7 @@ export const useAssetStore = create<AssetStore>()(
|
||||
if (!removedAsset || (!collectImageStorageKeys(removedAsset).size && !collectMediaStorageKeys(removedAsset).size)) return;
|
||||
await get().cleanupImages({ assets: remainingAssets });
|
||||
},
|
||||
replaceAssets: (assets) => set({ assets: assets.map(normalizeAssetRecord) }),
|
||||
replaceAssets: (assets) => set({ assets: assets.map(parseAssetRecord) }),
|
||||
cleanupImages: async (extra) => {
|
||||
const scope = getActiveUserScope();
|
||||
const frozenExtraImageKeys = collectImageStorageKeys(extra);
|
||||
|
||||
Reference in New Issue
Block a user