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

@@ -631,14 +631,14 @@ export function CanvasAssistantPanel({
setLocalSessions(next);
setLocalActiveSessionId(localActiveSessionId && ids.includes(localActiveSessionId) ? next[0].id : localActiveSessionId);
}
cleanupImages({ sessions: next });
void cleanupImages({ sessions: next });
};
const clearSessions = () => {
const session = createSession();
setLocalSessions([session]);
setLocalActiveSessionId(session.id);
cleanupImages({ sessions: [session] });
void cleanupImages({ sessions: [session] });
};
const sendMessage = async (text: string, history: CanvasAssistantMessage[], savedReferences?: CanvasAssistantReference[]) => {

View File

@@ -16,7 +16,7 @@ export function CanvasDeleteProjectsDialog() {
setDeleting(true);
try {
await deleteCanvasProjectsWithRemoteSync(ids);
cleanupImages();
void cleanupImages();
removeSelectedIds(ids);
setDeleteIds([]);
} catch (error) {

View File

@@ -8,12 +8,12 @@ import { ASSET_STORE_KEY, flushAssetStorePersistence, useAssetStore } from "@/st
import { CONFIG_STORE_KEY, PUBLIC_MODEL_CATALOG_ID, defaultConfig, normalizeConfigSnapshot, useConfigStore, type ModelCapability, type ModelChannel } from "@/stores/use-config-store";
import { defaultModelCapabilityConfig, STANDARD_IMAGE_SIZE_VALUES, type ModelCapabilityConfig } from "@/lib/model-capabilities";
import { useUserStore } from "@/stores/use-user-store";
import { installRemoteUserDataAutoSync, resetRemoteUserDataSync, syncRemoteUserData, withRemoteUserDataSyncPaused } from "@/services/user-data-sync";
import { installRemoteUserDataAutoSync, resetRemoteUserDataSync, syncRemoteUserData, withRemoteUserDataSyncExclusive } from "@/services/user-data-sync";
import { withGenerationConsumersPaused } from "@/services/generation-consumer-lifecycle";
export async function switchUserStorageScope(userId?: string | null) {
await withGenerationConsumersPaused(async () => {
await withRemoteUserDataSyncPaused(async () => {
await withRemoteUserDataSyncExclusive(async () => {
await Promise.all([flushCanvasStorePersistence(), flushAssetStorePersistence()]);
resetRemoteUserDataSync();
setActiveUserScope(userId);
@@ -69,8 +69,9 @@ export async function applyUserSession(payload: AuthSessionPayload) {
}
installRemoteUserDataAutoSync();
if (payload.user?.id) {
// 认证状态先完成,云端数据在后台合并;远端同步失败不能伪装成登录失败。
void syncRemoteUserData(payload.user.id).catch((error) => console.warn("登录后云端数据同步失败,保留本地数据等待重试", error));
// 登录后的服务端快照是实体基线;基线完成前不开放工作区写操作。
// 拉取失败时保留本地缓存供只读降级,但远端写入口会明确拒绝,不能把旧缓存上传成真相。
await syncRemoteUserData(payload.user.id).catch((error) => console.warn("登录后云端数据基线建立失败,已停止远端写入", error));
} else resetRemoteUserDataSync();
} finally {
useUserStore.getState().setHydrated(true);

View File

@@ -15,6 +15,7 @@ import {
rebasePendingCanvasStorePersistenceAfterGenerationCommitLocked,
reconcileCanvasGenerationFailure,
recordCanvasStorageDocument,
registerCanvasGenerationPersistenceAttempt,
useCanvasStore,
withCanvasStorePersistenceLock,
withCanvasStorePersistenceSuppressed,
@@ -445,9 +446,17 @@ export async function persistCanvasGenerationEffect(input: CanvasGenerationEffec
const delta = generationProjectDelta(input, memoryProject);
if (!delta.stamped) throw new Error("生成副作用缺少持久幂等标记");
return withCanvasStorePersistenceLock(
scope,
async () => {
const unregisterAttempt = registerCanvasGenerationPersistenceAttempt(scope, input.projectId, input.effectKey, {
previousNodes: input.previousNodes,
nodes: input.nodes,
previousChatSessions: input.previousChatSessions,
chatSessions: input.chatSessions,
});
try {
return await withCanvasStorePersistenceLock(
scope,
async () => {
let latestDurable: ReturnType<typeof parseCanvasStorageDocument> | undefined;
let generationCommittedProject: CanvasProject | undefined;
let reconcileLiveOnFailure = false;
@@ -519,7 +528,10 @@ export async function persistCanvasGenerationEffect(input: CanvasGenerationEffec
}
throw error;
}
},
{ requireCrossRealmLock: true },
);
},
{ requireCrossRealmLock: true },
);
} finally {
unregisterAttempt();
}
}

View File

@@ -8,47 +8,46 @@ import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
import { flushCanvasStorePersistence, useCanvasStore } from "@/stores/canvas/use-canvas-store";
let activeRemoteUserId = "";
let applyingRemoteState = false;
type RemoteUserDataPhase = "inactive" | "hydrating" | "ready" | "failed";
let remoteUserDataPhase: RemoteUserDataPhase = "inactive";
let syncTimer: number | null = null;
let syncPromise: Promise<void> | null = null;
let syncQueued = false;
let syncPauseCount = 0;
let syncPauseTail: Promise<void> = Promise.resolve();
let resumeSync: (() => void) | null = null;
let syncResumed: Promise<void> | null = null;
const activeRemoteSyncOperations = new Set<Promise<void>>();
let remoteOperationTail: Promise<void> = Promise.resolve();
let subscriptionsInstalled = false;
let remoteAssetVersions = new Map<string, string>();
let remoteProjectVersions = new Map<string, string>();
let acknowledgedAssets = new Map<string, Asset>();
let acknowledgedProjects = new Map<string, CanvasProject>();
const LOCAL_STORAGE_KEY_PATTERN = /^(image|video|audio|file|video-reference|audio-reference):/;
export async function syncRemoteUserData(userId?: string | null) {
await runRemoteUserDataSyncOperation(async () => {
await withRemoteUserDataSyncExclusive(async () => {
activeRemoteUserId = userId || "";
if (!activeRemoteUserId) return;
applyingRemoteState = true;
acknowledgedProjects.clear();
acknowledgedAssets.clear();
if (!activeRemoteUserId) {
remoteUserDataPhase = "inactive";
return;
}
remoteUserDataPhase = "hydrating";
try {
// 登录只拉一次聚合快照。摘要列表再逐条请求详情会把 N 条数据放大成 2N+2 个请求,
// 并且会在登录阶段同时触发大量媒体解析,任何一项失败都会污染登录结果。
const snapshot = await getRemoteUserDataSnapshot();
remoteProjectVersions = versionMap(snapshot.projects);
remoteAssetVersions = versionMap(snapshot.assets);
// 登录时服务端是实体真相。浏览器 IndexedDB 只作为首屏缓存,不能把服务端已删除的记录补回去。
// 这里只替换结构化记录,不在登录阶段解析图片/视频/音频 URL;媒体由实际使用方按需解析。
useCanvasStore.getState().replaceProjects(snapshot.projects);
useAssetStore.getState().replaceAssets(snapshot.assets);
} finally {
applyingRemoteState = false;
await Promise.all([flushCanvasStorePersistence(), flushAssetStorePersistence()]);
acknowledgedProjects = new Map(snapshot.projects.map((project) => [project.id, project]));
acknowledgedAssets = new Map(snapshot.assets.map((asset) => [asset.id, asset]));
remoteUserDataPhase = "ready";
} catch (error) {
remoteUserDataPhase = "failed";
throw error;
}
});
// 首次登录可能带有尚未创建到云端的本地画布;先完成一次 upsert,避免详情页保存/分享先于项目创建。
try {
await saveRemoteUserDataNow();
} catch (error) {
console.warn("登录后画布首次同步失败,保留本地项目等待重试", error);
}
scheduleRemoteUserDataSync();
}
export function installRemoteUserDataAutoSync() {
@@ -64,8 +63,9 @@ export function installRemoteUserDataAutoSync() {
export function resetRemoteUserDataSync() {
activeRemoteUserId = "";
remoteAssetVersions.clear();
remoteProjectVersions.clear();
remoteUserDataPhase = "inactive";
acknowledgedAssets.clear();
acknowledgedProjects.clear();
if (syncTimer) {
window.clearTimeout(syncTimer);
syncTimer = null;
@@ -73,73 +73,17 @@ export function resetRemoteUserDataSync() {
syncQueued = false;
}
function waitForRemoteUserDataSyncResume() {
if (!syncPauseCount) return Promise.resolve();
if (!syncResumed) {
syncResumed = new Promise<void>((resolve) => {
resumeSync = resolve;
});
}
return syncResumed;
}
async function runRemoteUserDataSyncOperation<T>(operation: () => Promise<T>): Promise<T> {
while (syncPauseCount) await waitForRemoteUserDataSyncResume();
let finish!: () => void;
const active = new Promise<void>((resolve) => {
finish = resolve;
});
activeRemoteSyncOperations.add(active);
try {
return await operation();
} finally {
activeRemoteSyncOperations.delete(active);
finish();
}
}
export async function withRemoteUserDataSyncPaused<T>(operation: () => Promise<T>): Promise<T> {
syncPauseCount += 1;
if (syncTimer) {
window.clearTimeout(syncTimer);
syncTimer = null;
syncQueued = true;
}
let releaseTurn!: () => void;
const previousTurn = syncPauseTail;
syncPauseTail = new Promise<void>((resolve) => {
releaseTurn = resolve;
});
await previousTurn;
try {
if (activeRemoteSyncOperations.size) {
await Promise.allSettled([...activeRemoteSyncOperations]);
}
if (syncPromise) await syncPromise;
return await operation();
} finally {
releaseTurn();
syncPauseCount -= 1;
if (!syncPauseCount) {
const resolve = resumeSync;
resumeSync = null;
syncResumed = null;
resolve?.();
if (syncQueued) scheduleRemoteUserDataSync();
}
}
export function withRemoteUserDataSyncExclusive<T>(operation: () => Promise<T>): Promise<T> {
const pending = remoteOperationTail.catch(() => undefined).then(operation);
remoteOperationTail = pending.then(
() => undefined,
() => undefined,
);
return pending;
}
export function scheduleRemoteUserDataSync() {
if (!activeRemoteUserId || applyingRemoteState) return;
if (syncPauseCount) {
syncQueued = true;
if (syncTimer) {
window.clearTimeout(syncTimer);
syncTimer = null;
}
return;
}
if (!activeRemoteUserId || remoteUserDataPhase !== "ready") return;
if (syncPromise) {
syncQueued = true;
return;
@@ -165,12 +109,15 @@ export async function createCanvasProjectWithRemoteSync(title: string, projectId
}
export async function deleteAssetWithRemoteSync(id: string) {
await runRemoteUserDataSyncOperation(async () => {
const assetId = id.trim();
if (!assetId) throw new Error("素材 ID 不能为空");
await withRemoteUserDataSyncExclusive(async () => {
if (activeRemoteUserId) {
await deleteRemoteAsset(id);
remoteAssetVersions.delete(id);
requireRemoteUserDataBaseline();
await deleteRemoteAsset(assetId);
acknowledgedAssets.delete(assetId);
}
useAssetStore.getState().removeAsset(id);
await useAssetStore.getState().removeAsset(assetId);
await flushAssetStorePersistence();
});
}
@@ -178,31 +125,31 @@ export async function deleteAssetWithRemoteSync(id: string) {
export async function deleteCanvasProjectsWithRemoteSync(ids: string[]) {
const projectIds = [...new Set(ids.map((id) => id.trim()).filter(Boolean))];
if (!projectIds.length) return;
await withRemoteUserDataSyncPaused(async () => {
if (activeRemoteUserId) {
for (const id of projectIds) {
await withRemoteUserDataSyncExclusive(async () => {
if (activeRemoteUserId) requireRemoteUserDataBaseline();
for (const id of projectIds) {
if (activeRemoteUserId) {
await deleteRemoteCanvasProject(id);
remoteProjectVersions.delete(id);
acknowledgedProjects.delete(id);
}
useCanvasStore.getState().deleteProjects([id]);
// 批量删除允许部分成功;每个已成功远端删除的实体都立即落实到本地 durable cache。
await flushCanvasStorePersistence();
}
useCanvasStore.getState().deleteProjects(projectIds);
// 删除完成后再返回,确保刷新不会读到尚未写入 IndexedDB 的旧快照。
await flushCanvasStorePersistence();
});
}
export async function saveRemoteUserDataNow() {
if (!activeRemoteUserId) return;
if (syncPauseCount) {
syncQueued = true;
await waitForRemoteUserDataSyncResume();
return saveRemoteUserDataNow();
}
requireRemoteUserDataBaseline();
if (syncPromise) {
syncQueued = true;
return syncPromise;
}
syncPromise = drainRemoteUserDataChanges();
syncPromise = withRemoteUserDataSyncExclusive(async () => {
requireRemoteUserDataBaseline();
await drainRemoteUserDataChanges();
});
try {
await syncPromise;
} finally {
@@ -218,53 +165,37 @@ async function drainRemoteUserDataChanges() {
}
async function saveRemoteUserDataBatch() {
try {
const currentProjects = useCanvasStore.getState().projects;
const currentAssets = useAssetStore.getState().assets;
const dirtyProjects = currentProjects.filter((item) => !sameVersion(remoteProjectVersions.get(item.id), item.updatedAt));
const dirtyAssets = currentAssets.filter((item) => !sameVersion(remoteAssetVersions.get(item.id), item.updatedAt));
const deletedProjectIds = missingIds(remoteProjectVersions, currentProjects);
const deletedAssetIds = missingIds(remoteAssetVersions, currentAssets);
if (!dirtyProjects.length && !dirtyAssets.length && !deletedProjectIds.length && !deletedAssetIds.length) return;
const uploaded = new Map<string, string>();
const projects = await prepareRemoteCanvasProjects(dirtyProjects, uploaded);
const assets = await prepareRemoteAssets(dirtyAssets, uploaded);
applyingRemoteState = true;
if (projects.length) useCanvasStore.getState().replaceProjects(replaceById(currentProjects, projects));
if (assets.length) useAssetStore.getState().replaceAssets(replaceById(currentAssets, assets));
applyingRemoteState = false;
// SQLite 和接口频控都要求写入保持有界;逐项提交还能准确记录已完成版本。
for (const project of projects) {
await upsertRemoteCanvasProject(project);
remoteProjectVersions.set(project.id, project.updatedAt);
}
for (const asset of assets) {
await upsertRemoteAsset(asset);
remoteAssetVersions.set(asset.id, asset.updatedAt);
}
for (const id of deletedProjectIds) {
await deleteRemoteCanvasProject(id);
remoteProjectVersions.delete(id);
}
for (const id of deletedAssetIds) {
await deleteRemoteAsset(id);
remoteAssetVersions.delete(id);
}
} finally {
applyingRemoteState = false;
const currentProjects = useCanvasStore.getState().projects;
const currentAssets = useAssetStore.getState().assets;
const dirtyProjects = currentProjects.filter((project) => !sameEntitySnapshot(acknowledgedProjects.get(project.id), project));
const dirtyAssets = currentAssets.filter((asset) => !sameEntitySnapshot(acknowledgedAssets.get(asset.id), asset));
const currentProjectIds = new Set(currentProjects.map((project) => project.id));
const currentAssetIds = new Set(currentAssets.map((asset) => asset.id));
const deletedProjectIds = [...acknowledgedProjects.keys()].filter((id) => !currentProjectIds.has(id));
const deletedAssetIds = [...acknowledgedAssets.keys()].filter((id) => !currentAssetIds.has(id));
if (!dirtyProjects.length && !dirtyAssets.length && !deletedProjectIds.length && !deletedAssetIds.length) return;
const uploaded = new Map<string, string>();
// 转换后的 resource: 引用只属于发往服务端的 payload,不能反写整份实时 store。
// 已确认快照记录的是本次上传所依据的本地实体;上传期间的新编辑会在下一轮继续提交。
for (const source of dirtyProjects) {
const remotePayload = await ensureRemoteResourceReferences(source, uploaded);
await upsertRemoteCanvasProject(remotePayload);
acknowledgedProjects.set(source.id, source);
}
for (const source of dirtyAssets) {
const remotePayload = await ensureRemoteResourceReferences(source, uploaded);
await upsertRemoteAsset(remotePayload);
acknowledgedAssets.set(source.id, source);
}
for (const id of deletedProjectIds) {
await deleteRemoteCanvasProject(id);
acknowledgedProjects.delete(id);
}
for (const id of deletedAssetIds) {
await deleteRemoteAsset(id);
acknowledgedAssets.delete(id);
}
}
async function prepareRemoteAssets(assets: Asset[], uploaded: Map<string, string>) {
const result: Asset[] = [];
for (const asset of assets) result.push(await ensureRemoteResourceReferences(asset, uploaded));
return result;
}
async function prepareRemoteCanvasProjects(projects: CanvasProject[], uploaded: Map<string, string>) {
const result: CanvasProject[] = [];
for (const project of projects) result.push(await ensureRemoteResourceReferences(project, uploaded));
return result;
}
async function ensureRemoteResourceReferences<T>(value: T, uploaded = new Map<string, string>()): Promise<T> {
@@ -287,13 +218,12 @@ async function ensureRemoteResourceReferences<T>(value: T, uploaded = new Map<st
if (!isLocalStorageKey(storageKey)) {
const inline = inlineMediaDataUrl(next);
if (!inline) return next as T;
const resourceStorage = await uploadInlineDataUrl(inline).catch(() => "");
return (resourceStorage ? applyResourceReference(next, resourceStorage) : next) as T;
const resourceStorage = await uploadInlineDataUrl(inline);
return applyResourceReference(next, resourceStorage) as T;
}
const cached = uploaded.get(storageKey);
const resourceStorage = cached || (await uploadLocalStorageKey(storageKey, next).catch(() => ""));
if (!resourceStorage) return next as T;
const resourceStorage = cached || (await uploadLocalStorageKey(storageKey, next));
uploaded.set(storageKey, resourceStorage);
return applyResourceReference(next, resourceStorage) as T;
}
@@ -316,7 +246,9 @@ function inlineMediaDataUrl(payload: Record<string, unknown>) {
}
async function uploadInlineDataUrl(dataUrl: string) {
const blob = await (await fetch(dataUrl)).blob();
const response = await fetch(dataUrl);
if (!response.ok) throw new Error("内嵌媒体读取失败");
const blob = await response.blob();
const kind: "image" | "video" | "audio" | "file" = blob.type.startsWith("image/") ? "image" : blob.type.startsWith("video/") ? "video" : blob.type.startsWith("audio/") ? "audio" : "file";
const resource = await uploadResourceFile(blob, kind);
return resourceStorageKey(resource.id);
@@ -324,7 +256,7 @@ async function uploadInlineDataUrl(dataUrl: string) {
async function uploadLocalStorageKey(storageKey: string, payload: Record<string, unknown>) {
const blob = storageKey.startsWith("image:") ? await getImageBlob(storageKey) : await getMediaBlob(storageKey);
if (!blob) return "";
if (!blob) throw new Error(`本地媒体不存在:${storageKey}`);
const kind = blob.type.startsWith("image/") ? "image" : blob.type.startsWith("video/") ? "video" : blob.type.startsWith("audio/") ? "audio" : "file";
const resource = await uploadResourceFile(blob, kind, {
width: numberValue(payload.naturalWidth) || numberValue(payload.width),
@@ -334,27 +266,12 @@ async function uploadLocalStorageKey(storageKey: string, payload: Record<string,
return resourceStorageKey(resource.id);
}
function versionMap(items: Array<{ id: string; updatedAt?: string }>) {
return new Map<string, string>(items.map((item) => [item.id, item.updatedAt || ""]));
function requireRemoteUserDataBaseline() {
if (remoteUserDataPhase !== "ready") throw new Error("云端数据基线尚未建立,已停止写入");
}
function missingIds<T extends { id: string }>(remote: Map<string, string>, local: T[]) {
const localIds = new Set(local.map((item) => item.id));
return Array.from(remote.keys()).filter((id) => !localIds.has(id));
}
function replaceById<T extends { id: string }>(current: T[], changed: T[]) {
const changedById = new Map(changed.map((item) => [item.id, item]));
return current.map((item) => changedById.get(item.id) || item);
}
function timeValue(value?: string) {
const time = value ? Date.parse(value) : 0;
return Number.isFinite(time) ? time : 0;
}
function sameVersion(remote?: string, local?: string) {
return Boolean(remote && local) && timeValue(remote) === timeValue(local);
function sameEntitySnapshot<T>(acknowledged: T | undefined, current: T) {
return acknowledged !== undefined && (acknowledged === current || JSON.stringify(acknowledged) === JSON.stringify(current));
}
function isLocalStorageKey(value: string) {

View File

@@ -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);

View File

@@ -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),
);
});
},
}),
{

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 });
}
});