import type { CanvasProject } from "@/stores/canvas/use-canvas-store"; import type { CanvasAssistantSession, CanvasConnection, CanvasNodeData } from "@/types/canvas"; import { normalizeAssetCategory } from "@/lib/asset-category"; export type CanvasStorageTombstones = { projects: Record; nodes: Record>; connections: Record>; sessions: Record>; messages: Record>>; }; export type CanvasStorageDocument = { state: { projects: CanvasProject[] }; version: number; storageRevision: number; tombstones: CanvasStorageTombstones; }; export type CanvasStorageConflict = { kind: "project" | "node" | "connection" | "session" | "message"; id: string; projectId?: string; sessionId?: string; reason?: "concurrent-update"; }; const emptyTombstones = (): CanvasStorageTombstones => ({ projects: {}, nodes: {}, connections: {}, sessions: {}, messages: {}, }); function recordOfNumbers(value: unknown) { if (!value || typeof value !== "object" || Array.isArray(value)) return {}; return Object.fromEntries(Object.entries(value).filter((entry): entry is [string, number] => typeof entry[1] === "number" && Number.isFinite(entry[1]))); } function nestedRecords(value: unknown) { if (!value || typeof value !== "object" || Array.isArray(value)) return {}; return Object.fromEntries(Object.entries(value).map(([key, nested]) => [key, recordOfNumbers(nested)])); } function messageRecords(value: unknown) { if (!value || typeof value !== "object" || Array.isArray(value)) return {}; return Object.fromEntries(Object.entries(value).map(([projectId, sessions]) => [projectId, nestedRecords(sessions)])); } function normalizeTombstones(value: unknown): CanvasStorageTombstones { const source = value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; return { projects: recordOfNumbers(source.projects), nodes: nestedRecords(source.nodes), connections: nestedRecords(source.connections), sessions: nestedRecords(source.sessions), messages: messageRecords(source.messages), }; } export function parseCanvasStorageDocument(value: string | null, fallback: CanvasProject[] = []): CanvasStorageDocument { if (!value) { return { state: { projects: normalizeProjectAssetCategories(fallback) }, version: 0, storageRevision: 0, tombstones: emptyTombstones(), }; } const parsed = JSON.parse(value) as { state?: { projects?: unknown }; version?: unknown; storageRevision?: unknown; tombstones?: unknown; }; if (!Array.isArray(parsed.state?.projects)) throw new Error("画布持久状态无效"); return { state: { projects: normalizeProjectAssetCategories(parsed.state.projects as CanvasProject[]) }, version: typeof parsed.version === "number" ? parsed.version : 0, storageRevision: typeof parsed.storageRevision === "number" && Number.isFinite(parsed.storageRevision) ? parsed.storageRevision : 0, tombstones: normalizeTombstones(parsed.tombstones), }; } function normalizeProjectAssetCategories(projects: CanvasProject[]) { return projects.map((project) => { let changed = false; const nodes = project.nodes.map((node) => { const category = node.metadata?.assetCategory; if (!category) return node; const normalized = normalizeAssetCategory(category); if (normalized === category) return node; changed = true; return { ...node, metadata: { ...node.metadata, assetCategory: normalized } }; }); return changed ? { ...project, nodes } : project; }); } export function serializeCanvasStorageDocument(document: CanvasStorageDocument) { return JSON.stringify(document); } function deepEqual(left: unknown, right: unknown) { if (Object.is(left, right)) return true; return JSON.stringify(left) === JSON.stringify(right); } function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } function mergeValue(base: unknown, local: unknown, durable: unknown, key?: string, onConflict?: () => void): unknown { if (deepEqual(local, base)) return durable; if (deepEqual(durable, base)) return local; if (deepEqual(local, durable)) return local; if (key === "updatedAt") return durable; if (key === "generationEffectKeys" && Array.isArray(local) && Array.isArray(durable)) { return [...new Set([...durable, ...local])]; } if (isRecord(base) && isRecord(local) && isRecord(durable)) return mergeRecord(base, local, durable, new Set(), onConflict); if (!isRecord(base) && isRecord(local) && isRecord(durable)) return mergeRecord({}, local, durable, new Set(), onConflict); onConflict?.(); return durable; } function mergeRecord(base: Record, local: Record, durable: Record, excluded = new Set(), onConflict?: () => void) { const merged: Record = {}; for (const key of new Set([...Object.keys(base), ...Object.keys(local), ...Object.keys(durable)])) { if (excluded.has(key)) continue; const baseHas = Object.prototype.hasOwnProperty.call(base, key); const localHas = Object.prototype.hasOwnProperty.call(local, key); const durableHas = Object.prototype.hasOwnProperty.call(durable, key); if (!localHas && baseHas) { if (durableHas && !deepEqual(durable[key], base[key])) { onConflict?.(); merged[key] = durable[key]; } continue; } if (!localHas) { if (durableHas) merged[key] = durable[key]; continue; } if (!baseHas) { if (!durableHas) merged[key] = local[key]; else merged[key] = mergeValue(undefined, local[key], durable[key], key, onConflict); continue; } if (!durableHas) { if (!deepEqual(local[key], base[key])) { onConflict?.(); merged[key] = local[key]; } continue; } merged[key] = mergeValue(base[key], local[key], durable[key], key, onConflict); } return merged; } function concurrentConflictRecorder(conflicts: CanvasStorageConflict[], conflict: Omit) { let recorded = false; return () => { if (recorded) return; recorded = true; conflicts.push({ ...conflict, reason: "concurrent-update" }); }; } function nestedTombstones(parent: Record>, id: string) { return (parent[id] ||= {}); } function nestedMessageTombstones(parent: CanvasStorageTombstones["messages"], projectId: string, sessionId: string) { const sessions = (parent[projectId] ||= {}); return (sessions[sessionId] ||= {}); } function mergeEntities(input: { base: T[]; local: T[]; durable: T[]; tombstones: Record; baseRevision: number; nextRevision: number; merge: (base: T | undefined, local: T, durable: T) => T; conflict: (id: string) => CanvasStorageConflict; conflicts: CanvasStorageConflict[]; }) { const baseById = new Map(input.base.map((item) => [item.id, item])); const localById = new Map(input.local.map((item) => [item.id, item])); const durableById = new Map(input.durable.map((item) => [item.id, item])); const result = [...input.durable]; const resultPositions = new Map(result.map((item, index) => [item.id, index])); const remove = (id: string) => { const index = resultPositions.get(id); if (index === undefined) return; result.splice(index, 1); resultPositions.clear(); result.forEach((item, position) => resultPositions.set(item.id, position)); }; const set = (item: T) => { const index = resultPositions.get(item.id); if (index === undefined) { resultPositions.set(item.id, result.length); result.push(item); } else { result[index] = item; } }; for (const id of new Set([...baseById.keys(), ...localById.keys()])) { const base = baseById.get(id); const local = localById.get(id); const durable = durableById.get(id); if (base && !local) { remove(id); input.tombstones[id] = input.nextRevision; continue; } if (!local || (base && deepEqual(base, local))) continue; if (!durable) { if (base || (input.tombstones[id] ?? 0) > input.baseRevision) { input.conflicts.push(input.conflict(id)); continue; } delete input.tombstones[id]; set(local); continue; } delete input.tombstones[id]; set(input.merge(base, local, durable)); } return result; } function mergeMessages(input: { projectId: string; sessionId: string; base: CanvasAssistantSession["messages"]; local: CanvasAssistantSession["messages"]; durable: CanvasAssistantSession["messages"]; tombstones: CanvasStorageTombstones; baseRevision: number; nextRevision: number; conflicts: CanvasStorageConflict[]; }) { return mergeEntities({ ...input, tombstones: nestedMessageTombstones(input.tombstones.messages, input.projectId, input.sessionId), merge: (base, local, durable) => mergeRecord( (base || {}) as Record, local as unknown as Record, durable as unknown as Record, new Set(), concurrentConflictRecorder(input.conflicts, { kind: "message", id: local.id, projectId: input.projectId, sessionId: input.sessionId }), ) as CanvasAssistantSession["messages"][number], conflict: (id) => ({ kind: "message", id, projectId: input.projectId, sessionId: input.sessionId }), }); } function mergeSessions(input: { projectId: string; base: CanvasAssistantSession[]; local: CanvasAssistantSession[]; durable: CanvasAssistantSession[]; tombstones: CanvasStorageTombstones; baseRevision: number; nextRevision: number; conflicts: CanvasStorageConflict[]; }) { return mergeEntities({ ...input, tombstones: nestedTombstones(input.tombstones.sessions, input.projectId), merge: (base, local, durable) => { const merged = mergeRecord( (base || {}) as unknown as Record, local as unknown as Record, durable as unknown as Record, new Set(["messages"]), concurrentConflictRecorder(input.conflicts, { kind: "session", id: local.id, projectId: input.projectId }), ) as unknown as CanvasAssistantSession; merged.messages = mergeMessages({ ...input, sessionId: local.id, base: base?.messages || [], local: local.messages, durable: durable.messages, }); return merged; }, conflict: (id) => ({ kind: "session", id, projectId: input.projectId }), }); } function mergeProject(base: CanvasProject | undefined, local: CanvasProject, durable: CanvasProject, document: CanvasStorageDocument, baseRevision: number, nextRevision: number, conflicts: CanvasStorageConflict[]) { const common = { projectId: local.id, tombstones: document.tombstones, baseRevision, nextRevision, conflicts, }; const merged = mergeRecord( (base || {}) as unknown as Record, local as unknown as Record, durable as unknown as Record, new Set(["nodes", "connections", "chatSessions"]), concurrentConflictRecorder(conflicts, { kind: "project", id: local.id }), ) as unknown as CanvasProject; merged.nodes = mergeEntities({ ...common, base: base?.nodes || [], local: local.nodes, durable: durable.nodes, tombstones: nestedTombstones(document.tombstones.nodes, local.id), merge: (baseNode, localNode, durableNode) => { let conflictRecorded = false; return mergeRecord((baseNode || {}) as unknown as Record, localNode as unknown as Record, durableNode as unknown as Record, new Set(), () => { if (conflictRecorded) return; conflictRecorded = true; conflicts.push({ kind: "node", id: localNode.id, projectId: local.id, reason: "concurrent-update" }); }) as unknown as CanvasNodeData; }, conflict: (id) => ({ kind: "node", id, projectId: local.id }), }); merged.connections = mergeEntities({ ...common, base: base?.connections || [], local: local.connections, durable: durable.connections, tombstones: nestedTombstones(document.tombstones.connections, local.id), merge: (baseConnection, localConnection, durableConnection) => mergeRecord( (baseConnection || {}) as unknown as Record, localConnection as unknown as Record, durableConnection as unknown as Record, new Set(), concurrentConflictRecorder(conflicts, { kind: "connection", id: localConnection.id, projectId: local.id }), ) as unknown as CanvasConnection, conflict: (id) => ({ kind: "connection", id, projectId: local.id }), }); merged.chatSessions = mergeSessions({ ...common, base: base?.chatSessions || [], local: local.chatSessions, durable: durable.chatSessions, }); return merged; } export function rebaseCanvasProjects(input: { document: CanvasStorageDocument; baseProjects: CanvasProject[]; localProjects: CanvasProject[]; baseRevision: number }) { const document: CanvasStorageDocument = { ...input.document, state: { projects: input.document.state.projects }, storageRevision: input.document.storageRevision, tombstones: normalizeTombstones(input.document.tombstones), }; const nextRevision = document.storageRevision + 1; const conflicts: CanvasStorageConflict[] = []; document.state.projects = mergeEntities({ base: input.baseProjects, local: input.localProjects, durable: document.state.projects, tombstones: document.tombstones.projects, baseRevision: input.baseRevision, nextRevision, merge: (base, local, durable) => mergeProject(base, local, durable, document, input.baseRevision, nextRevision, conflicts), conflict: (id) => ({ kind: "project", id }), conflicts, }); document.storageRevision = nextRevision; return { document, conflicts }; }