refactor(*): 数据同步与资源删除 - 收敛服务端真相并保证删除可恢复
This commit is contained in:
@@ -62,6 +62,13 @@ const observedCanvasPersists = new Map<string, ObservedCanvasPersist>();
|
||||
const queuedCanvasPersists = new Map<string, QueuedCanvasPersist>();
|
||||
const canvasSaveTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const canvasPersistTokens = new Map<string, number>();
|
||||
type CanvasGenerationPersistenceAttempt = {
|
||||
previousNodes?: CanvasNodeData[];
|
||||
nodes?: CanvasNodeData[];
|
||||
previousChatSessions?: CanvasAssistantSession[];
|
||||
chatSessions?: CanvasAssistantSession[];
|
||||
};
|
||||
const pendingCanvasGenerationAttempts = new Map<string, Map<string, CanvasGenerationPersistenceAttempt>>();
|
||||
|
||||
type AsyncCanvasStorageLock = {
|
||||
request<T>(name: string, callback: () => Promise<T>): Promise<T>;
|
||||
@@ -162,7 +169,7 @@ export function rebasePendingCanvasStorePersistenceAfterGenerationCommitLocked(s
|
||||
// Dedicated commit 已确认 generation stamp;用同 scope 最新内存重新投影队列,避免同节点普通编辑被旧 durable 快照替换。
|
||||
queued.baseProjects = committed.state.projects;
|
||||
queued.baseRevision = committed.storageRevision;
|
||||
queued.state = ordinaryCanvasPersistenceState({ projects: latestMemory }, committed.state.projects);
|
||||
queued.state = ordinaryCanvasPersistenceState(scope, { projects: latestMemory }, committed.state.projects);
|
||||
}
|
||||
|
||||
export function withCanvasStorePersistenceSuppressed<T>(operation: () => T) {
|
||||
@@ -174,11 +181,81 @@ export function withCanvasStorePersistenceSuppressed<T>(operation: () => T) {
|
||||
}
|
||||
}
|
||||
|
||||
export function registerCanvasGenerationPersistenceAttempt(scope: string, projectId: string, effectKey: string, attempt: CanvasGenerationPersistenceAttempt) {
|
||||
const projectKey = `${scope}\0${projectId}`;
|
||||
const attempts = pendingCanvasGenerationAttempts.get(projectKey) ?? new Map<string, CanvasGenerationPersistenceAttempt>();
|
||||
attempts.set(effectKey, attempt);
|
||||
pendingCanvasGenerationAttempts.set(projectKey, attempts);
|
||||
const queued = queuedCanvasPersists.get(scope);
|
||||
if (queued) {
|
||||
const latestMemory = canvasMemoryStates.get(scope)?.projects ?? queued.state.projects;
|
||||
const durableProjects = observedCanvasPersists.get(scope)?.projects ?? queued.baseProjects;
|
||||
queued.state = ordinaryCanvasPersistenceState(scope, { projects: latestMemory }, durableProjects);
|
||||
}
|
||||
return () => {
|
||||
if (attempts.get(effectKey) !== attempt) return;
|
||||
attempts.delete(effectKey);
|
||||
if (!attempts.size) pendingCanvasGenerationAttempts.delete(projectKey);
|
||||
};
|
||||
}
|
||||
|
||||
function hasUnconfirmedGenerationKey(localKeys?: string[], durableKeys?: string[]) {
|
||||
return Boolean(localKeys?.some((key) => !durableKeys?.includes(key)));
|
||||
}
|
||||
|
||||
function ordinaryCanvasProjectSnapshot(project: CanvasProject, durableProject: CanvasProject | undefined) {
|
||||
function samePersistenceValue(left: unknown, right: unknown) {
|
||||
return left === right || JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
function rollbackGenerationValue(previous: unknown, attempted: unknown, live: unknown, durable: unknown): unknown {
|
||||
if (samePersistenceValue(previous, attempted)) return live;
|
||||
if (samePersistenceValue(live, attempted)) return durable;
|
||||
if (!previous || !attempted || !live || !durable || Array.isArray(previous) || Array.isArray(attempted) || Array.isArray(live) || Array.isArray(durable) || typeof previous !== "object" || typeof attempted !== "object" || typeof live !== "object" || typeof durable !== "object") return live;
|
||||
|
||||
const previousRecord = previous as Record<string, unknown>;
|
||||
const attemptedRecord = attempted as Record<string, unknown>;
|
||||
const liveRecord = live as Record<string, unknown>;
|
||||
const durableRecord = durable as Record<string, unknown>;
|
||||
const result: Record<string, unknown> = { ...durableRecord };
|
||||
for (const key of new Set([...Object.keys(previousRecord), ...Object.keys(attemptedRecord), ...Object.keys(liveRecord)])) {
|
||||
const previousHasKey = Object.hasOwn(previousRecord, key);
|
||||
const attemptedHasKey = Object.hasOwn(attemptedRecord, key);
|
||||
const liveHasKey = Object.hasOwn(liveRecord, key);
|
||||
if (!previousHasKey && !attemptedHasKey) {
|
||||
if (liveHasKey) result[key] = liveRecord[key];
|
||||
continue;
|
||||
}
|
||||
if (!previousHasKey && attemptedHasKey) {
|
||||
if (!liveHasKey || samePersistenceValue(liveRecord[key], attemptedRecord[key])) delete result[key];
|
||||
else result[key] = liveRecord[key];
|
||||
continue;
|
||||
}
|
||||
if (previousHasKey && !attemptedHasKey) {
|
||||
if (liveHasKey) result[key] = liveRecord[key];
|
||||
continue;
|
||||
}
|
||||
if (!liveHasKey) {
|
||||
delete result[key];
|
||||
continue;
|
||||
}
|
||||
const value = rollbackGenerationValue(previousRecord[key], attemptedRecord[key], liveRecord[key], durableRecord[key]);
|
||||
if (value === undefined) delete result[key];
|
||||
else result[key] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function pendingGenerationAttempt(scope: string, projectId: string, effectKeys?: string[]) {
|
||||
const attempts = pendingCanvasGenerationAttempts.get(`${scope}\0${projectId}`);
|
||||
if (!attempts) return undefined;
|
||||
for (const effectKey of effectKeys || []) {
|
||||
const attempt = attempts.get(effectKey);
|
||||
if (attempt) return attempt;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function ordinaryCanvasProjectSnapshot(scope: string, project: CanvasProject, durableProject: CanvasProject | undefined) {
|
||||
const durableNodes = new Map((durableProject?.nodes || []).map((node) => [node.id, node]));
|
||||
const durableSessions = new Map((durableProject?.chatSessions || []).map((session) => [session.id, session]));
|
||||
let changed = false;
|
||||
@@ -191,7 +268,20 @@ function ordinaryCanvasProjectSnapshot(project: CanvasProject, durableProject: C
|
||||
if (durableProject && hasUnconfirmedGenerationKey(localKeys, durableKeys)) {
|
||||
hasUnconfirmedGeneration = true;
|
||||
changed = true;
|
||||
if (durableNode) nodes.push(durableNode);
|
||||
if (durableNode) {
|
||||
const attempt = pendingGenerationAttempt(scope, project.id, localKeys);
|
||||
const previousNode = attempt?.previousNodes?.find((candidate) => candidate.id === node.id);
|
||||
const attemptedNode = attempt?.nodes?.find((candidate) => candidate.id === node.id);
|
||||
// durableNode comes from the observed storage snapshot. Never mutate that snapshot while
|
||||
// rebuilding a failed generation, otherwise a later retry observes a partially rolled-back base.
|
||||
const rolledBack = previousNode && attemptedNode
|
||||
? (rollbackGenerationValue(previousNode, attemptedNode, node, durableNode) as CanvasNodeData)
|
||||
: { ...durableNode, metadata: durableNode.metadata ? { ...durableNode.metadata } : undefined };
|
||||
const metadata = { ...(rolledBack.metadata || {}) };
|
||||
if (durableKeys?.length) metadata.generationEffectKeys = [...durableKeys];
|
||||
else delete metadata.generationEffectKeys;
|
||||
nodes.push({ ...rolledBack, metadata });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (JSON.stringify(localKeys) === JSON.stringify(durableKeys)) {
|
||||
@@ -211,7 +301,17 @@ function ordinaryCanvasProjectSnapshot(project: CanvasProject, durableProject: C
|
||||
if (durableProject && hasUnconfirmedGenerationKey(session.generationEffectKeys, durableKeys)) {
|
||||
hasUnconfirmedGeneration = true;
|
||||
changed = true;
|
||||
if (durableSession) chatSessions.push(durableSession);
|
||||
if (durableSession) {
|
||||
const attempt = pendingGenerationAttempt(scope, project.id, session.generationEffectKeys);
|
||||
const previousSession = attempt?.previousChatSessions?.find((candidate) => candidate.id === session.id);
|
||||
const attemptedSession = attempt?.chatSessions?.find((candidate) => candidate.id === session.id);
|
||||
const rolledBack = previousSession && attemptedSession
|
||||
? (rollbackGenerationValue(previousSession, attemptedSession, session, durableSession) as CanvasAssistantSession)
|
||||
: { ...durableSession, generationEffectKeys: durableSession.generationEffectKeys ? [...durableSession.generationEffectKeys] : undefined };
|
||||
if (durableKeys?.length) rolledBack.generationEffectKeys = [...durableKeys];
|
||||
else delete rolledBack.generationEffectKeys;
|
||||
chatSessions.push(rolledBack);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (JSON.stringify(session.generationEffectKeys) === JSON.stringify(durableKeys)) {
|
||||
@@ -237,16 +337,16 @@ function ordinaryCanvasProjectSnapshot(project: CanvasProject, durableProject: C
|
||||
return changed ? { ...project, nodes, chatSessions } : project;
|
||||
}
|
||||
|
||||
function ordinaryCanvasPersistenceState(state: PersistedCanvasState, durableProjects: CanvasProject[]) {
|
||||
function ordinaryCanvasPersistenceState(scope: string, state: PersistedCanvasState, durableProjects: CanvasProject[]) {
|
||||
const durableById = new Map(durableProjects.map((project) => [project.id, project]));
|
||||
return {
|
||||
projects: state.projects.map((project) => ordinaryCanvasProjectSnapshot(project, durableById.get(project.id))),
|
||||
projects: state.projects.map((project) => ordinaryCanvasProjectSnapshot(scope, project, durableById.get(project.id))),
|
||||
};
|
||||
}
|
||||
|
||||
export function reconcileCanvasGenerationFailure(scope: string, durableProjects: CanvasProject[]) {
|
||||
if (getActiveUserScope() !== scope) return;
|
||||
const projects = ordinaryCanvasPersistenceState({ projects: useCanvasStore.getState().projects }, durableProjects).projects;
|
||||
const projects = ordinaryCanvasPersistenceState(scope, { projects: useCanvasStore.getState().projects }, durableProjects).projects;
|
||||
withCanvasStorePersistenceSuppressed(() => {
|
||||
useCanvasStore.setState({ projects });
|
||||
});
|
||||
@@ -285,7 +385,7 @@ const canvasStorage: PersistStorage<CanvasStore> = {
|
||||
baseProjects,
|
||||
baseRevision: queued?.baseRevision ?? observed?.revision ?? 0,
|
||||
// generationEffectKeys 由专用 generation durable commit 管理;普通队列只能携带已确认 stamp。
|
||||
state: ordinaryCanvasPersistenceState(nextState, observed?.projects ?? baseProjects),
|
||||
state: ordinaryCanvasPersistenceState(scope, nextState, observed?.projects ?? baseProjects),
|
||||
token,
|
||||
});
|
||||
clearCanvasSaveTimer(scope);
|
||||
|
||||
@@ -52,9 +52,9 @@ type AssetStore = {
|
||||
addAsset: (asset: NewAsset) => string;
|
||||
addGenerationAsset: (effectKey: string, asset: NewAsset, signal?: AbortSignal) => Promise<string>;
|
||||
updateAsset: (id: string, patch: Partial<Omit<Asset, "id" | "createdAt">>) => void;
|
||||
removeAsset: (id: string) => void;
|
||||
removeAsset: (id: string) => Promise<void>;
|
||||
replaceAssets: (assets: Asset[]) => void;
|
||||
cleanupImages: (extra?: unknown) => void;
|
||||
cleanupImages: (extra?: unknown) => Promise<void>;
|
||||
};
|
||||
|
||||
export const ASSET_STORE_KEY = "infinite-canvas:asset_store";
|
||||
@@ -355,42 +355,52 @@ export const useAssetStore = create<AssetStore>()(
|
||||
set((state) => ({
|
||||
assets: state.assets.map((asset) => (asset.id === id ? ({ ...asset, ...patch, updatedAt: new Date().toISOString() } as Asset) : asset)),
|
||||
})),
|
||||
removeAsset: (id) =>
|
||||
removeAsset: async (id) => {
|
||||
let remainingAssets: Asset[] = [];
|
||||
let removedAsset: Asset | undefined;
|
||||
set((state) => {
|
||||
removedAsset = state.assets.find((asset) => asset.id === id);
|
||||
const assets = state.assets.filter((asset) => asset.id !== id);
|
||||
get().cleanupImages({ assets });
|
||||
remainingAssets = assets;
|
||||
return { assets };
|
||||
}),
|
||||
});
|
||||
// 没有本地媒体定位时没有需要由该删除动作回收的 Blob;跳过全库扫描,
|
||||
// 避免纯文本/远程资源删除依赖浏览器 IndexedDB 驱动。
|
||||
if (!removedAsset || (!collectImageStorageKeys(removedAsset).size && !collectMediaStorageKeys(removedAsset).size)) return;
|
||||
await get().cleanupImages({ assets: remainingAssets });
|
||||
},
|
||||
replaceAssets: (assets) => set({ assets }),
|
||||
cleanupImages: (extra) => {
|
||||
cleanupImages: async (extra) => {
|
||||
const scope = getActiveUserScope();
|
||||
const frozenExtraImageKeys = collectImageStorageKeys(extra);
|
||||
const frozenExtraMediaKeys = collectMediaStorageKeys(extra);
|
||||
window.setTimeout(async () => {
|
||||
await withGenerationArtifactCommitLock(scope, async () => {
|
||||
// 固定锁序:artifact -> Canvas(释放)-> Asset,避免跨 store 锁重入。
|
||||
const canvasProjects = await withCanvasStorePersistenceLock(scope, async () => {
|
||||
await commitPendingCanvasStorePersistenceLocked(scope);
|
||||
const durableCanvas = parseCanvasStorageDocument(await localForageStorageForScope(scope).getItem(CANVAS_STORE_KEY));
|
||||
return pendingCanvasStorePersistence(scope)?.projects ?? durableCanvas.state.projects;
|
||||
});
|
||||
await withGenerationAssetStorageLock(scope, async () => {
|
||||
await commitPendingAssetStorePersistenceLocked(scope);
|
||||
const durableAssets = (await readPersistedAssetDocumentForScope(scope)).state.assets;
|
||||
const references = { projects: canvasProjects, assets: durableAssets };
|
||||
const imageKeys = new Set([...frozenExtraImageKeys, ...collectImageStorageKeys(references)]);
|
||||
const mediaKeys = new Set([...frozenExtraMediaKeys, ...collectMediaStorageKeys(references)]);
|
||||
await cleanupUnusedImages(
|
||||
[...imageKeys].map((storageKey) => ({ storageKey })),
|
||||
scope,
|
||||
);
|
||||
await cleanupUnusedMedia(
|
||||
[...mediaKeys].map((storageKey) => ({ storageKey })),
|
||||
scope,
|
||||
);
|
||||
});
|
||||
});
|
||||
}, 0);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
window.setTimeout(() =>
|
||||
withGenerationArtifactCommitLock(scope, async () => {
|
||||
// 固定锁序:artifact -> Canvas(释放)-> Asset,避免跨 store 锁重入。
|
||||
const canvasProjects = await withCanvasStorePersistenceLock(scope, async () => {
|
||||
await commitPendingCanvasStorePersistenceLocked(scope);
|
||||
const durableCanvas = parseCanvasStorageDocument(await localForageStorageForScope(scope).getItem(CANVAS_STORE_KEY));
|
||||
return pendingCanvasStorePersistence(scope)?.projects ?? durableCanvas.state.projects;
|
||||
});
|
||||
await withGenerationAssetStorageLock(scope, async () => {
|
||||
await commitPendingAssetStorePersistenceLocked(scope);
|
||||
const durableAssets = (await readPersistedAssetDocumentForScope(scope)).state.assets;
|
||||
const references = { projects: canvasProjects, assets: durableAssets };
|
||||
const imageKeys = new Set([...frozenExtraImageKeys, ...collectImageStorageKeys(references)]);
|
||||
const mediaKeys = new Set([...frozenExtraMediaKeys, ...collectMediaStorageKeys(references)]);
|
||||
await cleanupUnusedImages(
|
||||
[...imageKeys].map((storageKey) => ({ storageKey })),
|
||||
scope,
|
||||
);
|
||||
await cleanupUnusedMedia(
|
||||
[...mediaKeys].map((storageKey) => ({ storageKey })),
|
||||
scope,
|
||||
);
|
||||
});
|
||||
}).then(resolve, reject),
|
||||
);
|
||||
});
|
||||
},
|
||||
}),
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user