refactor(*): 数据同步与资源删除 - 收敛服务端真相并保证删除可恢复

This commit is contained in:
ddCat
2026-08-25 20:10:11 +08:00
parent d2748243d8
commit 48dca6c796
21 changed files with 970 additions and 1784 deletions

View File

@@ -4,7 +4,7 @@ import localforage from "localforage";
import { rebaseCanvasProjects } from "../src/lib/canvas/canvas-storage-revision";
import { localForageStorageForScope } from "../src/lib/localforage-storage";
import { getActiveUserScope, setActiveUserScope } from "../src/lib/user-scope";
import { switchUserStorageScope } from "../src/lib/user-session";
import { applyUserSession, switchUserStorageScope } from "../src/lib/user-session";
import { canvasCinematicContinuationEntryAdapters } from "../src/components/canvas/canvas-assistant-panel";
import { activeGenerationConsumerController, beginGenerationConsumer, runGenerationConsumer } from "../src/services/generation-consumer-lifecycle";
import { createCanvasGenerationLiveProjectAdapter, persistCanvasCinematicSessionContinuationEffect, persistCanvasGenerationEffect, registerCanvasGenerationLiveProject } from "../src/services/canvas-generation-consumer";
@@ -14,8 +14,9 @@ import { ASSET_STORE_KEY, flushAssetStorePersistence, useAssetStore, type Asset,
import { withGenerationAssetStorageLock } from "../src/services/generation-asset-repository";
import { CANVAS_STORE_KEY, flushCanvasStorePersistence, useCanvasStore, withCanvasStorePersistenceLock, withCanvasStorePersistenceSuppressed, type CanvasProject } from "../src/stores/canvas/use-canvas-store";
import { CanvasNodeType, type CanvasAssistantSession, type CanvasConnection, type CanvasNodeData } from "../src/types/canvas";
import { deleteAssetWithRemoteSync, deleteCanvasProjectsWithRemoteSync, resetRemoteUserDataSync, syncRemoteUserData, withRemoteUserDataSyncPaused } from "../src/services/user-data-sync";
import { deleteAssetWithRemoteSync, deleteCanvasProjectsWithRemoteSync, installRemoteUserDataAutoSync, resetRemoteUserDataSync, saveRemoteUserDataNow, syncRemoteUserData, withRemoteUserDataSyncExclusive } from "../src/services/user-data-sync";
import { apiClient } from "../src/services/api/request";
import { useUserStore } from "../src/stores/use-user-store";
test("creation recovery observes streaming text tasks after reload", () => {
const conversations = [{
@@ -271,7 +272,7 @@ test("ordinary Asset add update and remove survive a failed catalog flush and re
failWrites = true;
useAssetStore.getState().updateAsset("asset-keep", { title: "A1" });
const addedId = useAssetStore.getState().addAsset(generatedAsset("new asset"));
useAssetStore.getState().removeAsset("asset-remove");
void useAssetStore.getState().removeAsset("asset-remove");
await expect(flushAssetStorePersistence()).rejects.toThrow("asset catalog write failed");
const afterFailure = JSON.parse(durableValues.get(key)!) as { state: { assets: Asset[] }; storageRevision: number };
expect(afterFailure.state.assets.map((asset) => asset.id).sort()).toEqual(["asset-keep", "asset-remove"]);
@@ -2065,6 +2066,87 @@ test("dedicated Canvas conflict reconciles the mounted live page generation stat
}
});
test("failed Canvas generation rolls back its fields without losing a concurrent edit on the same node", async () => {
const originalWindow = (globalThis as { window?: unknown }).window;
const originalGetItem = localforage.getItem.bind(localforage);
const originalSetItem = localforage.setItem.bind(localforage);
const previousScope = getActiveUserScope();
const previousProjects = useCanvasStore.getState().projects;
const values = new Map<string, string>();
const localStorageValues = new Map<string, string>();
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
localStorage: {
getItem: (key: string) => localStorageValues.get(key) ?? null,
setItem: (key: string, value: string) => localStorageValues.set(key, value),
removeItem: (key: string) => localStorageValues.delete(key),
},
},
});
localforage.getItem = (async (key: string) => values.get(key) ?? null) as typeof localforage.getItem;
localforage.setItem = (async (key: string, value: string) => {
values.set(key, value);
return value;
}) as typeof localforage.setItem;
const scope = "canvas-generation-same-node-edit";
const storageKey = `${CANVAS_STORE_KEY}:user:${scope}`;
const effectKey = "canvas-effect:same-node-edit";
const node = (title: string, content: string, stamped = false): CanvasNodeData => ({
id: "node-same-edit",
type: CanvasNodeType.Text,
title,
position: { x: 0, y: 0 },
width: 320,
height: 180,
metadata: { content, ...(stamped ? { generationEffectKeys: [effectKey] } : {}) },
});
const project = (currentNode: CanvasNodeData): CanvasProject => ({ ...storedCanvasProject("canvas-same-node-edit", "same node edit"), nodes: [currentNode] });
try {
setActiveUserScope(scope);
const previousNode = node("原始标题", "原始内容");
const attemptedNode = node("原始标题", "生成内容", true);
useCanvasStore.setState({ projects: [project(previousNode)] });
await flushCanvasStorePersistence();
localforage.setItem = (async (key: string, value: string) => {
if (value.includes(effectKey)) throw new Error("forced generation durable failure");
values.set(key, value);
return value;
}) as typeof localforage.setItem;
useCanvasStore.getState().updateProject("canvas-same-node-edit", {
nodes: [{ ...attemptedNode, title: "生成期间修改的标题" }],
});
await expect(
persistCanvasGenerationEffect({
projectId: "canvas-same-node-edit",
effectKey,
previousNodes: [previousNode],
nodes: [attemptedNode],
}),
).rejects.toThrow("forced generation durable failure");
const liveNode = useCanvasStore.getState().projects[0]?.nodes[0];
expect(liveNode?.title).toBe("生成期间修改的标题");
expect(liveNode?.metadata?.content).toBe("原始内容");
expect(liveNode?.metadata?.generationEffectKeys).toBeUndefined();
const durable = JSON.parse(values.get(storageKey)!) as { state: { projects: CanvasProject[] } };
expect(durable.state.projects[0]?.nodes[0]?.title).toBe("生成期间修改的标题");
expect(durable.state.projects[0]?.nodes[0]?.metadata?.content).toBe("原始内容");
} finally {
setActiveUserScope(previousScope);
withCanvasStorePersistenceSuppressed(() => useCanvasStore.setState({ projects: previousProjects }));
await flushCanvasStorePersistence();
localforage.getItem = originalGetItem;
localforage.setItem = originalSetItem;
if (originalWindow === undefined) delete (globalThis as { window?: unknown }).window;
else Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow });
}
});
test("cinematic continuation durable ack failure restores mounted pending sessions across all production entry modes", async () => {
const originalWindow = (globalThis as { window?: unknown }).window;
const originalGetItem = localforage.getItem.bind(localforage);
@@ -3953,7 +4035,7 @@ test("account scope transition can hold remote user-data sync paused for its ful
releaseResolve = resolve;
});
let entered = false;
const transition = withRemoteUserDataSyncPaused(async () => {
const transition = withRemoteUserDataSyncExclusive(async () => {
entered = true;
await release;
});
@@ -3974,6 +4056,7 @@ test("account scope transition drains an active remote deletion before entering
const originalWindow = (globalThis as { window?: unknown }).window;
const originalGetItem = localforage.getItem.bind(localforage);
const originalSetItem = localforage.setItem.bind(localforage);
const originalCreateInstance = localforage.createInstance.bind(localforage);
const indexedValues = new Map<string, string>();
const localStorageValues = new Map<string, string>();
const previousAdapter = apiClient.defaults.adapter;
@@ -3998,7 +4081,10 @@ test("account scope transition drains an active remote deletion before entering
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
setTimeout: () => 1,
setTimeout: (handler: () => void, delay = 0) => {
if (delay === 0) queueMicrotask(handler);
return 1;
},
clearTimeout: () => undefined,
localStorage: {
getItem: (key: string) => localStorageValues.get(key) ?? null,
@@ -4012,6 +4098,12 @@ test("account scope transition drains an active remote deletion before entering
indexedValues.set(key, value);
return value;
}) as typeof localforage.setItem;
localforage.createInstance = (() => ({
iterate: async () => undefined,
removeItem: async () => undefined,
getItem: async () => null,
setItem: async (_key: string, value: unknown) => value,
})) as typeof localforage.createInstance;
try {
useAssetStore.getState().replaceAssets([]);
await syncRemoteUserData("account-A");
@@ -4034,7 +4126,7 @@ test("account scope transition drains an active remote deletion before entering
await new Promise<void>((resolve) => queueMicrotask(resolve));
expect(deleteStarted).toBe(true);
let transitionEntered = false;
const transition = withRemoteUserDataSyncPaused(async () => {
const transition = withRemoteUserDataSyncExclusive(async () => {
transitionEntered = true;
});
await new Promise<void>((resolve) => queueMicrotask(resolve));
@@ -4052,6 +4144,7 @@ test("account scope transition drains an active remote deletion before entering
await flushAssetStorePersistence();
localforage.getItem = originalGetItem;
localforage.setItem = originalSetItem;
localforage.createInstance = originalCreateInstance;
apiClient.defaults.adapter = previousAdapter;
if (originalWindow === undefined) delete (globalThis as { window?: unknown }).window;
else Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow });
@@ -4149,3 +4242,232 @@ test("login replaces stale local entities instead of resurrecting remote deletio
else Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow });
}
});
test("remote resource preparation never overwrites an edit made while upload is in flight", async () => {
const originalWindow = (globalThis as { window?: unknown }).window;
const originalGetItem = localforage.getItem.bind(localforage);
const originalSetItem = localforage.setItem.bind(localforage);
const previousAdapter = apiClient.defaults.adapter;
const previousAssets = useAssetStore.getState().assets;
const indexedValues = new Map<string, string>();
const remoteWrites: Asset[] = [];
let releaseUpload!: () => void;
const uploadReleased = new Promise<void>((resolve) => {
releaseUpload = resolve;
});
let uploadStartedResolve!: () => void;
const uploadStarted = new Promise<void>((resolve) => {
uploadStartedResolve = resolve;
});
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
setTimeout: () => 1,
clearTimeout: () => undefined,
localStorage: { getItem: () => null, setItem: () => undefined, removeItem: () => undefined },
},
});
localforage.getItem = (async (key: string) => indexedValues.get(key) ?? null) as typeof localforage.getItem;
localforage.setItem = (async (key: string, value: string) => {
indexedValues.set(key, value);
return value;
}) as typeof localforage.setItem;
apiClient.defaults.adapter = async (config) => {
const url = String(config.url || "");
const method = String(config.method || "get").toLowerCase();
if (url.includes("user-data/snapshot")) {
return { data: { code: 0, data: { projects: [], assets: [] }, msg: "" }, status: 200, statusText: "OK", headers: {}, config };
}
if (method === "post" && url === "/resources") {
uploadStartedResolve();
await uploadReleased;
return {
data: {
code: 0,
data: {
resource: {
id: "resource-uploaded",
userId: "account-sync-edit",
kind: "image",
status: "ready",
provider: "local",
endpoint: "",
bucket: "",
objectKey: "users/account-sync-edit/image.png",
publicUrl: "",
mimeType: "image/png",
size: 1,
createdAt: "2026-08-25T00:00:00.000Z",
updatedAt: "2026-08-25T00:00:00.000Z",
},
},
msg: "",
},
status: 200,
statusText: "OK",
headers: {},
config,
};
}
if (method === "put" && url.startsWith("/assets/")) {
const body = typeof config.data === "string" ? JSON.parse(config.data) : config.data;
remoteWrites.push(body.asset as Asset);
return { data: { code: 0, data: { asset: body.asset }, msg: "" }, status: 200, statusText: "OK", headers: {}, config };
}
throw new Error(`unexpected request: ${method} ${url}`);
};
try {
resetRemoteUserDataSync();
installRemoteUserDataAutoSync();
await syncRemoteUserData("account-sync-edit");
const asset = {
...storedAsset("asset-sync-edit", "before upload"),
coverUrl: "data:image/png;base64,iVBORw0KGgo=",
data: {
dataUrl: "data:image/png;base64,iVBORw0KGgo=",
width: 1,
height: 1,
bytes: 1,
mimeType: "image/png",
},
} as Asset;
useAssetStore.getState().replaceAssets([asset]);
const saving = saveRemoteUserDataNow();
await uploadStarted;
useAssetStore.getState().updateAsset(asset.id, { title: "edited during upload" });
releaseUpload();
await saving;
expect(useAssetStore.getState().assets.find((item) => item.id === asset.id)?.title).toBe("edited during upload");
expect(remoteWrites.at(-1)?.title).toBe("edited during upload");
} finally {
releaseUpload();
resetRemoteUserDataSync();
useAssetStore.getState().replaceAssets(previousAssets);
await flushAssetStorePersistence();
localforage.getItem = originalGetItem;
localforage.setItem = originalSetItem;
apiClient.defaults.adapter = previousAdapter;
if (originalWindow === undefined) delete (globalThis as { window?: unknown }).window;
else Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow });
}
});
test("failed remote baseline cannot upload stale local cache", async () => {
const originalWindow = (globalThis as { window?: unknown }).window;
const previousAdapter = apiClient.defaults.adapter;
const previousProjects = useCanvasStore.getState().projects;
const requests: string[] = [];
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
setTimeout: () => 1,
clearTimeout: () => undefined,
localStorage: { getItem: () => null, setItem: () => undefined, removeItem: () => undefined },
},
});
apiClient.defaults.adapter = async (config) => {
const request = `${String(config.method || "get").toLowerCase()} ${String(config.url || "")}`;
requests.push(request);
if (String(config.url || "").includes("user-data/snapshot")) throw new Error("snapshot unavailable");
return { data: { code: 0, data: {}, msg: "" }, status: 200, statusText: "OK", headers: {}, config };
};
try {
resetRemoteUserDataSync();
useCanvasStore.setState({ projects: [storedCanvasProject("stale-local-project", "stale cache")] });
await expect(syncRemoteUserData("account-baseline-failed")).rejects.toThrow("snapshot unavailable");
useCanvasStore.getState().renameProject("stale-local-project", "edited without baseline");
await expect(saveRemoteUserDataNow()).rejects.toThrow("云端数据基线尚未建立");
expect(requests.some((request) => request.startsWith("put "))).toBe(false);
} finally {
resetRemoteUserDataSync();
useCanvasStore.setState({ projects: previousProjects });
apiClient.defaults.adapter = previousAdapter;
if (originalWindow === undefined) delete (globalThis as { window?: unknown }).window;
else Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow });
}
});
test("user session stays unhydrated until the remote baseline is durable", async () => {
const originalWindow = (globalThis as { window?: unknown }).window;
const originalGetItem = localforage.getItem.bind(localforage);
const originalSetItem = localforage.setItem.bind(localforage);
const previousAdapter = apiClient.defaults.adapter;
const previousUserState = useUserStore.getState();
const localValues = new Map<string, string>();
let releaseSnapshot!: () => void;
const snapshotReleased = new Promise<void>((resolve) => {
releaseSnapshot = resolve;
});
let snapshotStartedResolve!: () => void;
const snapshotStarted = new Promise<void>((resolve) => {
snapshotStartedResolve = resolve;
});
const localStorageValues = new Map<string, string>();
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
setTimeout: () => 1,
clearTimeout: () => undefined,
localStorage: {
getItem: (key: string) => localStorageValues.get(key) ?? null,
setItem: (key: string, value: string) => localStorageValues.set(key, value),
removeItem: (key: string) => localStorageValues.delete(key),
},
},
});
localforage.getItem = (async (key: string) => localValues.get(key) ?? null) as typeof localforage.getItem;
localforage.setItem = (async (key: string, value: string) => {
localValues.set(key, value);
return value;
}) as typeof localforage.setItem;
apiClient.defaults.adapter = async (config) => {
const url = String(config.url || "");
if (url === "/model-catalog") {
return { data: { code: 0, data: { source: "frontend", models: [] }, msg: "" }, status: 200, statusText: "OK", headers: {}, config };
}
if (url.includes("user-data/snapshot")) {
snapshotStartedResolve();
await snapshotReleased;
return { data: { code: 0, data: { projects: [], assets: [] }, msg: "" }, status: 200, statusText: "OK", headers: {}, config };
}
throw new Error(`unexpected request: ${String(config.method || "get")} ${url}`);
};
let applying: Promise<void> | undefined;
try {
resetRemoteUserDataSync();
useUserStore.setState({ user: null, hydrated: true });
applying = applyUserSession({
user: {
id: "account-baseline-gate",
username: "baseline-gate",
displayName: "Baseline Gate",
role: "user",
status: "active",
createdAt: "2026-08-25T00:00:00.000Z",
updatedAt: "2026-08-25T00:00:00.000Z",
},
});
await snapshotStarted;
expect(useUserStore.getState().hydrated).toBe(false);
releaseSnapshot();
await applying;
expect(useUserStore.getState().hydrated).toBe(true);
} finally {
releaseSnapshot();
await applying?.catch(() => undefined);
resetRemoteUserDataSync();
useUserStore.setState(previousUserState);
localforage.getItem = originalGetItem;
localforage.setItem = originalSetItem;
apiClient.defaults.adapter = previousAdapter;
if (originalWindow === undefined) delete (globalThis as { window?: unknown }).window;
else Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow });
}
});