diff --git a/components/canvas/__tests__/use-canvas-sync-engine.test.ts b/components/canvas/__tests__/use-canvas-sync-engine.test.ts new file mode 100644 index 0000000..4c78c42 --- /dev/null +++ b/components/canvas/__tests__/use-canvas-sync-engine.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { Id } from "@/convex/_generated/dataModel"; +import { createCanvasSyncEngineController } from "@/components/canvas/use-canvas-sync-engine"; + +const asCanvasId = (id: string): Id<"canvases"> => id as Id<"canvases">; +const asNodeId = (id: string): Id<"nodes"> => id as Id<"nodes">; +describe("useCanvasSyncEngine", () => { + it("hands off an optimistic create to the real node id before replaying a deferred move", async () => { + const enqueueSyncMutation = vi.fn(async () => undefined); + const runBatchRemoveNodes = vi.fn(async () => undefined); + const runSplitEdgeAtExistingNode = vi.fn(async () => undefined); + + const controller = createCanvasSyncEngineController({ + canvasId: asCanvasId("canvas-1"), + isSyncOnline: true, + enqueueSyncMutation, + runBatchRemoveNodes, + runSplitEdgeAtExistingNode, + }); + + controller.pendingMoveAfterCreateRef.current.set("req-1", { + positionX: 320, + positionY: 180, + }); + + await controller.syncPendingMoveForClientRequest("req-1", asNodeId("node-real")); + + expect(enqueueSyncMutation).toHaveBeenCalledWith("moveNode", { + nodeId: asNodeId("node-real"), + positionX: 320, + positionY: 180, + }); + expect( + controller.pendingLocalPositionUntilConvexMatchesRef.current.get("node-real"), + ).toEqual({ x: 320, y: 180 }); + expect(controller.resolvedRealIdByClientRequestRef.current.get("req-1")).toBe( + asNodeId("node-real"), + ); + expect(controller.pendingMoveAfterCreateRef.current.has("req-1")).toBe(false); + expect(runBatchRemoveNodes).not.toHaveBeenCalled(); + expect(runSplitEdgeAtExistingNode).not.toHaveBeenCalled(); + }); + + it("defers resize and data updates for an optimistic node until the real id is known", async () => { + const enqueueSyncMutation = vi.fn(async () => undefined); + + const controller = createCanvasSyncEngineController({ + canvasId: asCanvasId("canvas-1"), + isSyncOnline: true, + enqueueSyncMutation, + runBatchRemoveNodes: vi.fn(async () => undefined), + runSplitEdgeAtExistingNode: vi.fn(async () => undefined), + }); + + await controller.queueNodeResize({ + nodeId: asNodeId("optimistic_req-2"), + width: 640, + height: 360, + }); + await controller.queueNodeDataUpdate({ + nodeId: asNodeId("optimistic_req-2"), + data: { label: "Updated" }, + }); + + expect(enqueueSyncMutation).not.toHaveBeenCalled(); + + await controller.syncPendingMoveForClientRequest("req-2", asNodeId("node-2")); + + expect(enqueueSyncMutation.mock.calls).toEqual([ + ["resizeNode", { nodeId: asNodeId("node-2"), width: 640, height: 360 }], + ["updateData", { nodeId: asNodeId("node-2"), data: { label: "Updated" } }], + ]); + expect(controller.pendingResizeAfterCreateRef.current.has("req-2")).toBe(false); + expect(controller.pendingDataAfterCreateRef.current.has("req-2")).toBe(false); + }); +}); diff --git a/components/canvas/canvas.tsx b/components/canvas/canvas.tsx index 120da05..8e9c151 100644 --- a/components/canvas/canvas.tsx +++ b/components/canvas/canvas.tsx @@ -37,33 +37,7 @@ import { validateCanvasConnectionPolicy, } from "@/lib/canvas-connection-policy"; import { showCanvasConnectionRejectedToast } from "@/lib/toast-messages"; -import { - dropCanvasOpsByClientRequestIds, - dropCanvasOpsByEdgeIds, - dropCanvasOpsByNodeIds, - enqueueCanvasOp, - remapCanvasOpNodeId, - resolveCanvasOp, - resolveCanvasOps, -} from "@/lib/canvas-local-persistence"; -import { - ackCanvasSyncOp, - type CanvasSyncOpPayloadByType, - countCanvasSyncOps, - dropCanvasSyncOpsByClientRequestIds, - dropCanvasSyncOpsByEdgeIds, - dropCanvasSyncOpsByNodeIds, - dropExpiredCanvasSyncOps, - enqueueCanvasSyncOp, - listCanvasSyncOps, - markCanvasSyncOpFailed, - remapCanvasSyncNodeId, -} from "@/lib/canvas-op-queue"; - -import { - useConvexConnectionState, - useMutation, -} from "convex/react"; +import { useMutation } from "convex/react"; import { api } from "@/convex/_generated/api"; import type { Doc, Id } from "@/convex/_generated/dataModel"; import { @@ -98,9 +72,7 @@ import CustomConnectionLine from "@/components/canvas/custom-connection-line"; import type { CanvasNodeTemplate } from "@/lib/canvas-node-templates"; import { CANVAS_MIN_ZOOM, - clientRequestIdFromOptimisticEdgeId, clientRequestIdFromOptimisticNodeId, - createCanvasOpId, DEFAULT_EDGE_OPTIONS, EDGE_INTERSECTION_HIGHLIGHT_STYLE, getConnectEndClientPoint, @@ -115,9 +87,6 @@ import { isOptimisticEdgeId, isOptimisticNodeId, normalizeHandle, - OPTIMISTIC_EDGE_PREFIX, - OPTIMISTIC_NODE_PREFIX, - type PendingEdgeSplit, withResolvedCompareData, } from "./canvas-helpers"; import { @@ -133,71 +102,12 @@ import { useCanvasScissors } from "./canvas-scissors"; import { CanvasSyncProvider } from "./canvas-sync-context"; import { useCanvasData } from "./use-canvas-data"; import { useCanvasLocalSnapshotPersistence } from "./use-canvas-local-snapshot-persistence"; +import { useCanvasSyncEngine } from "./use-canvas-sync-engine"; interface CanvasInnerProps { canvasId: Id<"canvases">; } -function getErrorMessage(error: unknown): string { - if (error instanceof Error && typeof error.message === "string") { - return error.message; - } - return String(error); -} - -function isLikelyTransientSyncError(error: unknown): boolean { - const message = getErrorMessage(error).toLowerCase(); - return ( - message.includes("network") || - message.includes("websocket") || - message.includes("fetch") || - message.includes("timeout") || - message.includes("temporarily") || - message.includes("connection") - ); -} - -function summarizeUpdateDataPayload(payload: unknown): Record { - if (typeof payload !== "object" || payload === null) { - return { payloadShape: "invalid" }; - } - - const p = payload as { nodeId?: unknown; data?: unknown }; - const data = - typeof p.data === "object" && p.data !== null - ? (p.data as Record) - : null; - - return { - nodeId: typeof p.nodeId === "string" ? p.nodeId : null, - hasData: Boolean(data), - hasStorageId: typeof data?.storageId === "string" && data.storageId.length > 0, - hasLastUploadStorageId: - typeof data?.lastUploadStorageId === "string" && - data.lastUploadStorageId.length > 0, - hasUrl: typeof data?.url === "string" && data.url.length > 0, - hasLastUploadUrl: - typeof data?.lastUploadUrl === "string" && data.lastUploadUrl.length > 0, - lastUploadedAt: - typeof data?.lastUploadedAt === "number" && Number.isFinite(data.lastUploadedAt) - ? data.lastUploadedAt - : null, - }; -} - -function summarizeResizePayload(payload: unknown): Record { - if (typeof payload !== "object" || payload === null) { - return { payloadShape: "invalid" }; - } - - const p = payload as { nodeId?: unknown; width?: unknown; height?: unknown }; - return { - nodeId: typeof p.nodeId === "string" ? p.nodeId : null, - width: typeof p.width === "number" && Number.isFinite(p.width) ? p.width : null, - height: typeof p.height === "number" && Number.isFinite(p.height) ? p.height : null, - }; -} - function validateCanvasConnection( connection: Connection, nodes: RFNode[], @@ -251,1559 +161,60 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { canvasId, }); - // ─── Future hook seam: sync engine ──────────────────────────── - // Convex mutations (exakte Signaturen aus nodes.ts / edges.ts) - const moveNode = useMutation(api.nodes.move); - const resizeNode = useMutation(api.nodes.resize); - const updateNodeData = useMutation(api.nodes.updateData); const generateUploadUrl = useMutation(api.storage.generateUploadUrl); - const connectionState = useConvexConnectionState(); - const pendingMoveAfterCreateRef = useRef( - new Map(), - ); - const pendingResizeAfterCreateRef = useRef( - new Map(), - ); - const pendingDataAfterCreateRef = useRef(new Map()); - const resolvedRealIdByClientRequestRef = useRef(new Map>()); - const pendingCreatePromiseByClientRequestRef = useRef( - new Map>>(), - ); - const pendingEdgeSplitByClientRequestRef = useRef( - new Map(), - ); - const pendingDeleteAfterCreateClientRequestIdsRef = useRef(new Set()); - /** Connection-Drop → neue Node: erlaubt Carry-over der Kante in der Rollback-Lücke (ohne Phantom nach Fehler). */ - const pendingConnectionCreatesRef = useRef(new Set()); - /** Nach create+drag: Convex liefert oft noch Erstellkoordinaten, bis `moveNode` committed — bis dahin Position pinnen. */ - const pendingLocalPositionUntilConvexMatchesRef = useRef( - new Map(), - ); - /** Vorheriger Stand von api.nodes.list-IDs — um genau die neu eingetretene Node-ID vor Mutation-.then zu erkennen. */ const convexNodeIdsSnapshotForEdgeCarryRef = useRef(new Set()); - const syncPendingMoveForClientRequestRef = useRef< - (clientRequestId: string | undefined, realId?: Id<"nodes">) => Promise - >(async () => {}); - const enqueueSyncMutationRef = useRef< - ( - type: TType, - payload: CanvasSyncOpPayloadByType[TType], - ) => Promise - >(async () => {}); const [assetBrowserTargetNodeId, setAssetBrowserTargetNodeId] = useState< string | null >(null); const [edgeSyncNonce, setEdgeSyncNonce] = useState(0); - /** Convex-Merge: Position nicht mit veraltetem Snapshot überschreiben (RF-`dragging` kommt oft verzögert). */ - const preferLocalPositionNodeIdsRef = useRef(new Set()); - - const createNode = useMutation(api.nodes.create).withOptimisticUpdate( - (localStore, args) => { - const current = localStore.getQuery(api.nodes.list, { - canvasId: args.canvasId, - }); - if (current === undefined) return; - - const tempId = ( - args.clientRequestId - ? `${OPTIMISTIC_NODE_PREFIX}${args.clientRequestId}` - : `${OPTIMISTIC_NODE_PREFIX}${Date.now()}_${Math.random().toString(36).slice(2, 11)}` - ) as Id<"nodes">; - - const synthetic: Doc<"nodes"> = { - _id: tempId, - _creationTime: Date.now(), - canvasId: args.canvasId, - type: args.type as Doc<"nodes">["type"], - positionX: args.positionX, - positionY: args.positionY, - width: args.width, - height: args.height, - status: "idle", - retryCount: 0, - data: args.data, - parentId: args.parentId, - zIndex: args.zIndex, - }; - - localStore.setQuery( - api.nodes.list, - { canvasId: args.canvasId }, - [...current, synthetic], - ); - }, - ); - - const createNodeWithEdgeFromSource = useMutation( - api.nodes.createWithEdgeFromSource, - ).withOptimisticUpdate((localStore, args) => { - const nodeList = localStore.getQuery(api.nodes.list, { - canvasId: args.canvasId, - }); - const edgeList = localStore.getQuery(api.edges.list, { - canvasId: args.canvasId, - }); - if (nodeList === undefined || edgeList === undefined) return; - - const tempNodeId = ( - args.clientRequestId - ? `${OPTIMISTIC_NODE_PREFIX}${args.clientRequestId}` - : `${OPTIMISTIC_NODE_PREFIX}${Date.now()}_${Math.random().toString(36).slice(2, 11)}` - ) as Id<"nodes">; - - const tempEdgeId = ( - args.clientRequestId - ? `${OPTIMISTIC_EDGE_PREFIX}${args.clientRequestId}` - : `${OPTIMISTIC_EDGE_PREFIX}${Date.now()}_${Math.random().toString(36).slice(2, 11)}` - ) as Id<"edges">; - - const syntheticNode: Doc<"nodes"> = { - _id: tempNodeId, - _creationTime: Date.now(), - canvasId: args.canvasId, - type: args.type as Doc<"nodes">["type"], - positionX: args.positionX, - positionY: args.positionY, - width: args.width, - height: args.height, - status: "idle", - retryCount: 0, - data: args.data, - parentId: args.parentId, - zIndex: args.zIndex, - }; - - const sourceNode = nodeList.find((node) => node._id === args.sourceNodeId); - if (!sourceNode) return; - - const syntheticEdge: Doc<"edges"> = { - _id: tempEdgeId, - _creationTime: Date.now(), - canvasId: args.canvasId, - sourceNodeId: sourceNode._id, - targetNodeId: tempNodeId, - sourceHandle: args.sourceHandle, - targetHandle: args.targetHandle, - }; - - localStore.setQuery(api.nodes.list, { canvasId: args.canvasId }, [ - ...nodeList, - syntheticNode, - ]); - localStore.setQuery(api.edges.list, { canvasId: args.canvasId }, [ - ...edgeList, - syntheticEdge, - ]); - }); - - const createNodeWithEdgeToTarget = useMutation( - api.nodes.createWithEdgeToTarget, - ).withOptimisticUpdate((localStore, args) => { - const nodeList = localStore.getQuery(api.nodes.list, { - canvasId: args.canvasId, - }); - const edgeList = localStore.getQuery(api.edges.list, { - canvasId: args.canvasId, - }); - if (nodeList === undefined || edgeList === undefined) return; - - const tempNodeId = ( - args.clientRequestId - ? `${OPTIMISTIC_NODE_PREFIX}${args.clientRequestId}` - : `${OPTIMISTIC_NODE_PREFIX}${Date.now()}_${Math.random().toString(36).slice(2, 11)}` - ) as Id<"nodes">; - - const tempEdgeId = ( - args.clientRequestId - ? `${OPTIMISTIC_EDGE_PREFIX}${args.clientRequestId}` - : `${OPTIMISTIC_EDGE_PREFIX}${Date.now()}_${Math.random().toString(36).slice(2, 11)}` - ) as Id<"edges">; - - const syntheticNode: Doc<"nodes"> = { - _id: tempNodeId, - _creationTime: Date.now(), - canvasId: args.canvasId, - type: args.type as Doc<"nodes">["type"], - positionX: args.positionX, - positionY: args.positionY, - width: args.width, - height: args.height, - status: "idle", - retryCount: 0, - data: args.data, - parentId: args.parentId, - zIndex: args.zIndex, - }; - - const targetNode = nodeList.find((node) => node._id === args.targetNodeId); - if (!targetNode) return; - - const syntheticEdge: Doc<"edges"> = { - _id: tempEdgeId, - _creationTime: Date.now(), - canvasId: args.canvasId, - sourceNodeId: tempNodeId, - targetNodeId: targetNode._id, - sourceHandle: args.sourceHandle, - targetHandle: args.targetHandle, - }; - - localStore.setQuery(api.nodes.list, { canvasId: args.canvasId }, [ - ...nodeList, - syntheticNode, - ]); - localStore.setQuery(api.edges.list, { canvasId: args.canvasId }, [ - ...edgeList, - syntheticEdge, - ]); - }); - - const createNodeWithEdgeSplitMut = useMutation(api.nodes.createWithEdgeSplit); - - const createEdge = useMutation(api.edges.create).withOptimisticUpdate( - (localStore, args) => { - const edgeList = localStore.getQuery(api.edges.list, { - canvasId: args.canvasId, - }); - const nodeList = localStore.getQuery(api.nodes.list, { - canvasId: args.canvasId, - }); - if (edgeList === undefined || nodeList === undefined) return; - - const sourceNode = nodeList.find((node) => node._id === args.sourceNodeId); - const targetNode = nodeList.find((node) => node._id === args.targetNodeId); - if (!sourceNode || !targetNode) return; - - const tempId = ( - args.clientRequestId - ? `${OPTIMISTIC_EDGE_PREFIX}${args.clientRequestId}` - : `${OPTIMISTIC_EDGE_PREFIX}${Date.now()}_${Math.random().toString(36).slice(2, 11)}` - ) as Id<"edges">; - const synthetic: Doc<"edges"> = { - _id: tempId, - _creationTime: Date.now(), - canvasId: args.canvasId, - sourceNodeId: sourceNode._id, - targetNodeId: targetNode._id, - sourceHandle: args.sourceHandle, - targetHandle: args.targetHandle, - }; - localStore.setQuery( - api.edges.list, - { canvasId: args.canvasId }, - [...edgeList, synthetic], - ); - }, - ); - const createNodeRaw = useMutation(api.nodes.create); - const createNodeWithEdgeFromSourceRaw = useMutation( - api.nodes.createWithEdgeFromSource, - ); - const createNodeWithEdgeToTargetRaw = useMutation( - api.nodes.createWithEdgeToTarget, - ); - const createNodeWithEdgeSplitRaw = useMutation(api.nodes.createWithEdgeSplit); - const createEdgeRaw = useMutation(api.edges.create); - const batchRemoveNodesRaw = useMutation(api.nodes.batchRemove); - const removeEdgeRaw = useMutation(api.edges.remove); - const splitEdgeAtExistingNodeRaw = useMutation( - api.nodes.splitEdgeAtExistingNode, - ); - const [nodes, setNodes] = useState([]); const [edges, setEdges] = useState([]); + const edgesRef = useRef(edges); + edgesRef.current = edges; + const deletingNodeIds = useRef>(new Set()); + + const { + status: { pendingSyncCount, isSyncing, isSyncOnline }, + refs: { + pendingMoveAfterCreateRef, + resolvedRealIdByClientRequestRef, + pendingEdgeSplitByClientRequestRef, + pendingConnectionCreatesRef, + pendingLocalPositionUntilConvexMatchesRef, + preferLocalPositionNodeIdsRef, + }, + actions: { + createNode: runCreateNodeOnlineOnly, + createNodeWithEdgeFromSource: runCreateNodeWithEdgeFromSourceOnlineOnly, + createNodeWithEdgeToTarget: runCreateNodeWithEdgeToTargetOnlineOnly, + createNodeWithEdgeSplit: runCreateNodeWithEdgeSplitOnlineOnly, + moveNode: runMoveNodeMutation, + batchMoveNodes: runBatchMoveNodesMutation, + resizeNode: runResizeNodeMutation, + updateNodeData: runUpdateNodeDataMutation, + batchRemoveNodes: runBatchRemoveNodesMutation, + createEdge: runCreateEdgeMutation, + removeEdge: runRemoveEdgeMutation, + splitEdgeAtExistingNode: runSplitEdgeAtExistingNodeMutation, + syncPendingMoveForClientRequest, + notifyOfflineUnsupported, + }, + } = useCanvasSyncEngine({ + canvasId, + setNodes, + setEdges, + edgesRef, + setAssetBrowserTargetNodeId, + setEdgeSyncNonce, + deletingNodeIds, + }); + const hasPresetAwareNodes = useMemo( () => nodes.some((node) => isAdjustmentPresetNodeType(node.type ?? "")) || (convexNodes ?? []).some((node) => isAdjustmentPresetNodeType(node.type)), [convexNodes, nodes], ); - const edgesRef = useRef(edges); - edgesRef.current = edges; - const [pendingSyncCount, setPendingSyncCount] = useState(0); - const [isSyncing, setIsSyncing] = useState(false); - const [isBrowserOnline, setIsBrowserOnline] = useState( - typeof navigator === "undefined" ? true : navigator.onLine, - ); - const syncInFlightRef = useRef(false); - const lastOfflineUnsupportedToastAtRef = useRef(0); - - const isSyncOnline = - isBrowserOnline === true && connectionState.isWebSocketConnected === true; - - const trackPendingNodeCreate = useCallback( - ( - clientRequestId: string, - createPromise: Promise>, - ): Promise> => { - const trackedPromise = createPromise - .then((realId) => { - resolvedRealIdByClientRequestRef.current.set(clientRequestId, realId); - return realId; - }) - .finally(() => { - pendingCreatePromiseByClientRequestRef.current.delete(clientRequestId); - }); - - pendingCreatePromiseByClientRequestRef.current.set( - clientRequestId, - trackedPromise, - ); - return trackedPromise; - }, - [], - ); - - useEffect(() => { - const handleOnline = () => setIsBrowserOnline(true); - const handleOffline = () => setIsBrowserOnline(false); - window.addEventListener("online", handleOnline); - window.addEventListener("offline", handleOffline); - return () => { - window.removeEventListener("online", handleOnline); - window.removeEventListener("offline", handleOffline); - }; - }, []); - - const notifyOfflineUnsupported = useCallback((label: string) => { - const now = Date.now(); - if (now - lastOfflineUnsupportedToastAtRef.current < 1500) return; - lastOfflineUnsupportedToastAtRef.current = now; - toast.warning( - "Offline aktuell nicht unterstützt", - `${label} ist aktuell nur online verfügbar.`, - ); - }, []); - - const addOptimisticNodeLocally = useCallback(( - args: Parameters[0] & { clientRequestId: string }, - ): Id<"nodes"> => { - const optimisticNodeId = `${OPTIMISTIC_NODE_PREFIX}${args.clientRequestId}`; - setNodes((current) => { - if (current.some((node) => node.id === optimisticNodeId)) { - return current; - } - return [ - ...current, - { - id: optimisticNodeId, - type: args.type, - position: { x: args.positionX, y: args.positionY }, - data: args.data, - style: { width: args.width, height: args.height }, - parentId: args.parentId as string | undefined, - zIndex: args.zIndex, - selected: false, - }, - ]; - }); - return optimisticNodeId as Id<"nodes">; - }, []); - - const addOptimisticEdgeLocally = useCallback((args: { - clientRequestId: string; - sourceNodeId: string; - targetNodeId: string; - sourceHandle?: string; - targetHandle?: string; - }): Id<"edges"> => { - const optimisticEdgeId = `${OPTIMISTIC_EDGE_PREFIX}${args.clientRequestId}`; - setEdges((current) => { - if (current.some((edge) => edge.id === optimisticEdgeId)) { - return current; - } - return [ - ...current, - { - id: optimisticEdgeId, - source: args.sourceNodeId as string, - target: args.targetNodeId as string, - sourceHandle: args.sourceHandle, - targetHandle: args.targetHandle, - }, - ]; - }); - return optimisticEdgeId as Id<"edges">; - }, []); - - const applyEdgeSplitLocally = useCallback((args: { - clientRequestId: string; - splitEdgeId: Id<"edges">; - middleNodeId: Id<"nodes">; - splitSourceHandle?: string; - splitTargetHandle?: string; - newNodeSourceHandle?: string; - newNodeTargetHandle?: string; - positionX?: number; - positionY?: number; - }): boolean => { - const splitEdgeId = args.splitEdgeId as string; - const splitEdge = edgesRef.current.find( - (edge) => - edge.id === splitEdgeId && - edge.className !== "temp" && - !isOptimisticEdgeId(edge.id), - ); - if (!splitEdge) { - return false; - } - - const optimisticSplitEdgeBase = `${OPTIMISTIC_EDGE_PREFIX}${args.clientRequestId}`; - const optimisticSplitEdgeAId = `${optimisticSplitEdgeBase}_split_a`; - const optimisticSplitEdgeBId = `${optimisticSplitEdgeBase}_split_b`; - - setEdges((current) => { - const existingSplitEdge = current.find((edge) => edge.id === splitEdgeId); - if (!existingSplitEdge) { - return current; - } - - const next = current.filter( - (edge) => - edge.id !== splitEdgeId && - edge.id !== optimisticSplitEdgeAId && - edge.id !== optimisticSplitEdgeBId, - ); - - next.push( - { - id: optimisticSplitEdgeAId, - source: existingSplitEdge.source, - target: args.middleNodeId as string, - sourceHandle: args.splitSourceHandle, - targetHandle: args.newNodeTargetHandle, - }, - { - id: optimisticSplitEdgeBId, - source: args.middleNodeId as string, - target: existingSplitEdge.target, - sourceHandle: args.newNodeSourceHandle, - targetHandle: args.splitTargetHandle, - }, - ); - - return next; - }); - - if (args.positionX !== undefined && args.positionY !== undefined) { - const x = args.positionX; - const y = args.positionY; - const middleNodeId = args.middleNodeId as string; - setNodes((current) => - current.map((node) => - node.id === middleNodeId - ? { - ...node, - position: { x, y }, - } - : node, - ), - ); - } - - return true; - }, []); - - const removeOptimisticCreateLocally = useCallback((args: { - clientRequestId: string; - removeNode?: boolean; - removeEdge?: boolean; - }): void => { - const optimisticNodeId = `${OPTIMISTIC_NODE_PREFIX}${args.clientRequestId}`; - const optimisticEdgeId = `${OPTIMISTIC_EDGE_PREFIX}${args.clientRequestId}`; - - if (args.removeNode) { - setNodes((current) => - current.filter((node) => node.id !== optimisticNodeId), - ); - setEdges((current) => - current.filter( - (edge) => - edge.source !== optimisticNodeId && edge.target !== optimisticNodeId, - ), - ); - } - - if (args.removeEdge) { - const optimisticEdgePrefix = `${optimisticEdgeId}_`; - setEdges((current) => - current.filter( - (edge) => - edge.id !== optimisticEdgeId && - !edge.id.startsWith(optimisticEdgePrefix), - ), - ); - } - - pendingMoveAfterCreateRef.current.delete(args.clientRequestId); - pendingResizeAfterCreateRef.current.delete(args.clientRequestId); - pendingDataAfterCreateRef.current.delete(args.clientRequestId); - pendingCreatePromiseByClientRequestRef.current.delete(args.clientRequestId); - pendingEdgeSplitByClientRequestRef.current.delete(args.clientRequestId); - pendingConnectionCreatesRef.current.delete(args.clientRequestId); - resolvedRealIdByClientRequestRef.current.delete(args.clientRequestId); - }, []); - - const remapOptimisticNodeLocally = useCallback(async ( - clientRequestId: string, - realId: Id<"nodes">, - ): Promise => { - const optimisticNodeId = `${OPTIMISTIC_NODE_PREFIX}${clientRequestId}`; - const realNodeId = realId as string; - - if ( - pendingDeleteAfterCreateClientRequestIdsRef.current.has(clientRequestId) - ) { - pendingDeleteAfterCreateClientRequestIdsRef.current.delete(clientRequestId); - removeOptimisticCreateLocally({ - clientRequestId, - removeNode: true, - removeEdge: true, - }); - deletingNodeIds.current.add(realNodeId); - await enqueueSyncMutationRef.current("batchRemoveNodes", { - nodeIds: [realId], - }); - return; - } - - setNodes((current) => - current.map((node) => { - const nextParentId = - node.parentId === optimisticNodeId ? realNodeId : node.parentId; - if (node.id !== optimisticNodeId && nextParentId === node.parentId) { - return node; - } - return { - ...node, - id: node.id === optimisticNodeId ? realNodeId : node.id, - parentId: nextParentId, - }; - }), - ); - setEdges((current) => - current.map((edge) => { - const nextSource = - edge.source === optimisticNodeId ? realNodeId : edge.source; - const nextTarget = - edge.target === optimisticNodeId ? realNodeId : edge.target; - if (nextSource === edge.source && nextTarget === edge.target) { - return edge; - } - return { - ...edge, - source: nextSource, - target: nextTarget, - }; - }), - ); - setAssetBrowserTargetNodeId((current) => - current === optimisticNodeId ? realNodeId : current, - ); - - const pinnedPos = - pendingLocalPositionUntilConvexMatchesRef.current.get(optimisticNodeId); - if (pinnedPos) { - pendingLocalPositionUntilConvexMatchesRef.current.delete(optimisticNodeId); - pendingLocalPositionUntilConvexMatchesRef.current.set(realNodeId, pinnedPos); - } - - if (preferLocalPositionNodeIdsRef.current.has(optimisticNodeId)) { - preferLocalPositionNodeIdsRef.current.delete(optimisticNodeId); - preferLocalPositionNodeIdsRef.current.add(realNodeId); - } - - resolvedRealIdByClientRequestRef.current.set(clientRequestId, realId); - await remapCanvasSyncNodeId(canvasId as string, optimisticNodeId, realNodeId); - remapCanvasOpNodeId(canvasId as string, optimisticNodeId, realNodeId); - }, [canvasId, removeOptimisticCreateLocally]); - - const runCreateNodeOnlineOnly = useCallback( - async (args: Parameters[0]) => { - const clientRequestId = args.clientRequestId ?? crypto.randomUUID(); - const payload = { ...args, clientRequestId }; - - if (isSyncOnline) { - return await trackPendingNodeCreate(clientRequestId, createNode(payload)); - } - - const optimisticNodeId = addOptimisticNodeLocally(payload); - await enqueueSyncMutationRef.current("createNode", payload); - return optimisticNodeId; - }, - [addOptimisticNodeLocally, createNode, isSyncOnline, trackPendingNodeCreate], - ); - - const runCreateNodeWithEdgeFromSourceOnlineOnly = useCallback( - async (args: Parameters[0]) => { - const clientRequestId = args.clientRequestId ?? crypto.randomUUID(); - const payload = { ...args, clientRequestId }; - const sourceNodeId = payload.sourceNodeId as string; - - pendingConnectionCreatesRef.current.add(clientRequestId); - if (isSyncOnline && !isOptimisticNodeId(sourceNodeId)) { - return await trackPendingNodeCreate( - clientRequestId, - createNodeWithEdgeFromSource(payload), - ); - } - - const optimisticNodeId = addOptimisticNodeLocally(payload); - addOptimisticEdgeLocally({ - clientRequestId, - sourceNodeId: payload.sourceNodeId, - targetNodeId: optimisticNodeId, - sourceHandle: payload.sourceHandle, - targetHandle: payload.targetHandle, - }); - - if (isSyncOnline) { - try { - const realId = await trackPendingNodeCreate(clientRequestId, createNodeWithEdgeFromSourceRaw({ - ...payload, - })); - await remapOptimisticNodeLocally(clientRequestId, realId); - return realId; - } catch (error) { - removeOptimisticCreateLocally({ - clientRequestId, - removeNode: true, - removeEdge: true, - }); - throw error; - } - } - - await enqueueSyncMutationRef.current( - "createNodeWithEdgeFromSource", - payload, - ); - return optimisticNodeId; - }, - [ - addOptimisticEdgeLocally, - addOptimisticNodeLocally, - createNodeWithEdgeFromSource, - createNodeWithEdgeFromSourceRaw, - isSyncOnline, - remapOptimisticNodeLocally, - removeOptimisticCreateLocally, - trackPendingNodeCreate, - ], - ); - - const runCreateNodeWithEdgeToTargetOnlineOnly = useCallback( - async (args: Parameters[0]) => { - const clientRequestId = args.clientRequestId ?? crypto.randomUUID(); - const payload = { ...args, clientRequestId }; - const targetNodeId = payload.targetNodeId as string; - - pendingConnectionCreatesRef.current.add(clientRequestId); - if (isSyncOnline && !isOptimisticNodeId(targetNodeId)) { - return await trackPendingNodeCreate( - clientRequestId, - createNodeWithEdgeToTarget(payload), - ); - } - - const optimisticNodeId = addOptimisticNodeLocally(payload); - addOptimisticEdgeLocally({ - clientRequestId, - sourceNodeId: optimisticNodeId, - targetNodeId: payload.targetNodeId, - sourceHandle: payload.sourceHandle, - targetHandle: payload.targetHandle, - }); - - if (isSyncOnline) { - try { - const realId = await trackPendingNodeCreate(clientRequestId, createNodeWithEdgeToTargetRaw({ - ...payload, - })); - await remapOptimisticNodeLocally(clientRequestId, realId); - return realId; - } catch (error) { - removeOptimisticCreateLocally({ - clientRequestId, - removeNode: true, - removeEdge: true, - }); - throw error; - } - } - - await enqueueSyncMutationRef.current("createNodeWithEdgeToTarget", payload); - return optimisticNodeId; - }, - [ - addOptimisticEdgeLocally, - addOptimisticNodeLocally, - createNodeWithEdgeToTarget, - createNodeWithEdgeToTargetRaw, - isSyncOnline, - remapOptimisticNodeLocally, - removeOptimisticCreateLocally, - trackPendingNodeCreate, - ], - ); - - const runCreateNodeWithEdgeSplitOnlineOnly = useCallback( - async (args: Parameters[0]) => { - const clientRequestId = args.clientRequestId ?? crypto.randomUUID(); - const payload = { ...args, clientRequestId }; - - if (isSyncOnline) { - return await createNodeWithEdgeSplitMut(payload); - } - - const optimisticNodeId = addOptimisticNodeLocally(payload); - const splitApplied = applyEdgeSplitLocally({ - clientRequestId, - splitEdgeId: payload.splitEdgeId, - middleNodeId: optimisticNodeId, - splitSourceHandle: payload.splitSourceHandle, - splitTargetHandle: payload.splitTargetHandle, - newNodeSourceHandle: payload.newNodeSourceHandle, - newNodeTargetHandle: payload.newNodeTargetHandle, - positionX: payload.positionX, - positionY: payload.positionY, - }); - - if (splitApplied) { - await enqueueSyncMutationRef.current("createNodeWithEdgeSplit", payload); - } else { - await enqueueSyncMutationRef.current("createNode", { - canvasId: payload.canvasId, - type: payload.type, - positionX: payload.positionX, - positionY: payload.positionY, - width: payload.width, - height: payload.height, - data: payload.data, - parentId: payload.parentId, - zIndex: payload.zIndex, - clientRequestId, - }); - } - - return optimisticNodeId; - }, - [addOptimisticNodeLocally, applyEdgeSplitLocally, createNodeWithEdgeSplitMut, isSyncOnline], - ); - - const refreshPendingSyncCount = useCallback(async () => { - const count = await countCanvasSyncOps(canvasId as string); - setPendingSyncCount(count); - }, [canvasId]); - - const flushCanvasSyncQueue = useCallback(async () => { - if (!isSyncOnline) return; - if (syncInFlightRef.current) return; - syncInFlightRef.current = true; - setIsSyncing(true); - - try { - const now = Date.now(); - const expiredIds = await dropExpiredCanvasSyncOps(canvasId as string, now); - if (expiredIds.length > 0) { - resolveCanvasOps(canvasId as string, expiredIds); - toast.info( - "Lokale Änderungen verworfen", - `${expiredIds.length} ältere Offline-Änderungen (älter als 24h) wurden entfernt.`, - ); - } - - let permanentFailures = 0; - let processedInThisPass = 0; - - while (processedInThisPass < 500) { - const nowLoop = Date.now(); - const queue = await listCanvasSyncOps(canvasId as string); - const op = queue.find( - (entry) => entry.expiresAt > nowLoop && entry.nextRetryAt <= nowLoop, - ); - if (!op) break; - processedInThisPass += 1; - - try { - if (op.type === "createNode") { - const realId = await createNodeRaw( - op.payload as Parameters[0], - ); - await remapOptimisticNodeLocally(op.payload.clientRequestId, realId); - await syncPendingMoveForClientRequestRef.current( - op.payload.clientRequestId, - realId, - ); - setEdgeSyncNonce((value) => value + 1); - } else if (op.type === "createNodeWithEdgeFromSource") { - const realId = await createNodeWithEdgeFromSourceRaw( - op.payload as Parameters[0], - ); - await remapOptimisticNodeLocally(op.payload.clientRequestId, realId); - await syncPendingMoveForClientRequestRef.current( - op.payload.clientRequestId, - realId, - ); - setEdgeSyncNonce((value) => value + 1); - } else if (op.type === "createNodeWithEdgeToTarget") { - const realId = await createNodeWithEdgeToTargetRaw( - op.payload as Parameters[0], - ); - await remapOptimisticNodeLocally(op.payload.clientRequestId, realId); - await syncPendingMoveForClientRequestRef.current( - op.payload.clientRequestId, - realId, - ); - setEdgeSyncNonce((value) => value + 1); - } else if (op.type === "createNodeWithEdgeSplit") { - const realId = await createNodeWithEdgeSplitRaw( - op.payload as Parameters[0], - ); - await remapOptimisticNodeLocally(op.payload.clientRequestId, realId); - await syncPendingMoveForClientRequestRef.current( - op.payload.clientRequestId, - realId, - ); - setEdgeSyncNonce((value) => value + 1); - } else if (op.type === "createEdge") { - await createEdgeRaw(op.payload); - } else if (op.type === "removeEdge") { - await removeEdgeRaw(op.payload); - } else if (op.type === "batchRemoveNodes") { - await batchRemoveNodesRaw(op.payload); - } else if (op.type === "splitEdgeAtExistingNode") { - await splitEdgeAtExistingNodeRaw(op.payload); - setEdgeSyncNonce((value) => value + 1); - } else if (op.type === "moveNode") { - await moveNode(op.payload); - } else if (op.type === "resizeNode") { - if (process.env.NODE_ENV !== "production") { - console.info("[Canvas sync debug] resizeNode enqueue->flush", { - opId: op.id, - attemptCount: op.attemptCount, - ...summarizeResizePayload(op.payload), - }); - } - await resizeNode(op.payload); - if (process.env.NODE_ENV !== "production") { - console.info("[Canvas sync debug] resizeNode flush success", { - opId: op.id, - ...summarizeResizePayload(op.payload), - }); - } - } else if (op.type === "updateData") { - if (process.env.NODE_ENV !== "production") { - console.info("[Canvas sync debug] updateData enqueue->flush", { - opId: op.id, - attemptCount: op.attemptCount, - ...summarizeUpdateDataPayload(op.payload), - }); - } - await updateNodeData(op.payload); - if (process.env.NODE_ENV !== "production") { - console.info("[Canvas sync debug] updateData flush success", { - opId: op.id, - ...summarizeUpdateDataPayload(op.payload), - }); - } - } - - await ackCanvasSyncOp(op.id); - resolveCanvasOp(canvasId as string, op.id); - } catch (error: unknown) { - const transient = - !isSyncOnline || isLikelyTransientSyncError(error); - if (op.type === "updateData" && process.env.NODE_ENV !== "production") { - console.warn("[Canvas sync debug] updateData flush failed", { - opId: op.id, - attemptCount: op.attemptCount, - transient, - error: getErrorMessage(error), - ...summarizeUpdateDataPayload(op.payload), - }); - } - if (op.type === "resizeNode" && process.env.NODE_ENV !== "production") { - const resizeNodeId = - typeof op.payload.nodeId === "string" ? op.payload.nodeId : null; - const resizeClientRequestId = resizeNodeId - ? clientRequestIdFromOptimisticNodeId(resizeNodeId) - : null; - const resizeResolvedRealId = resizeClientRequestId - ? resolvedRealIdByClientRequestRef.current.get(resizeClientRequestId) - : null; - console.warn("[Canvas sync debug] resizeNode flush failed", { - opId: op.id, - attemptCount: op.attemptCount, - transient, - error: getErrorMessage(error), - clientRequestId: resizeClientRequestId, - resolvedRealId: resizeResolvedRealId ?? null, - ...summarizeResizePayload(op.payload), - }); - } - if (transient) { - const backoffMs = Math.min(30_000, 1000 * 2 ** Math.min(op.attemptCount, 5)); - await markCanvasSyncOpFailed(op.id, { - nextRetryAt: Date.now() + backoffMs, - lastError: getErrorMessage(error), - }); - break; - } - - permanentFailures += 1; - if (op.type === "createNode") { - removeOptimisticCreateLocally({ - clientRequestId: op.payload.clientRequestId, - removeNode: true, - }); - } else if ( - op.type === "createNodeWithEdgeFromSource" || - op.type === "createNodeWithEdgeToTarget" - ) { - removeOptimisticCreateLocally({ - clientRequestId: op.payload.clientRequestId, - removeNode: true, - removeEdge: true, - }); - } else if (op.type === "createNodeWithEdgeSplit") { - removeOptimisticCreateLocally({ - clientRequestId: op.payload.clientRequestId, - removeNode: true, - removeEdge: true, - }); - setEdgeSyncNonce((value) => value + 1); - } else if (op.type === "createEdge") { - removeOptimisticCreateLocally({ - clientRequestId: op.payload.clientRequestId, - removeEdge: true, - }); - } else if (op.type === "splitEdgeAtExistingNode") { - removeOptimisticCreateLocally({ - clientRequestId: op.payload.clientRequestId, - removeEdge: true, - }); - setEdgeSyncNonce((value) => value + 1); - } else if (op.type === "batchRemoveNodes") { - for (const nodeId of op.payload.nodeIds) { - deletingNodeIds.current.delete(nodeId as string); - } - } - await ackCanvasSyncOp(op.id); - resolveCanvasOp(canvasId as string, op.id); - } - } - - if (permanentFailures > 0) { - toast.warning( - "Einige Änderungen konnten nicht synchronisiert werden", - `${permanentFailures} lokale Änderungen wurden übersprungen.`, - ); - } - } finally { - syncInFlightRef.current = false; - setIsSyncing(false); - await refreshPendingSyncCount(); - } - }, [ - batchRemoveNodesRaw, - canvasId, - createEdgeRaw, - createNodeRaw, - createNodeWithEdgeFromSourceRaw, - createNodeWithEdgeSplitRaw, - createNodeWithEdgeToTargetRaw, - isSyncOnline, - moveNode, - refreshPendingSyncCount, - remapOptimisticNodeLocally, - removeEdgeRaw, - removeOptimisticCreateLocally, - resizeNode, - splitEdgeAtExistingNodeRaw, - updateNodeData, - ]); - - const enqueueSyncMutation = useCallback( - async ( - type: TType, - payload: CanvasSyncOpPayloadByType[TType], - ) => { - const opId = createCanvasOpId(); - const now = Date.now(); - const result = await enqueueCanvasSyncOp({ - id: opId, - canvasId: canvasId as string, - type, - payload, - now, - }); - enqueueCanvasOp(canvasId as string, { - id: opId, - type, - payload, - enqueuedAt: now, - }); - resolveCanvasOps(canvasId as string, result.replacedIds); - await refreshPendingSyncCount(); - void flushCanvasSyncQueue(); - }, - [canvasId, flushCanvasSyncQueue, refreshPendingSyncCount], - ); - enqueueSyncMutationRef.current = enqueueSyncMutation; - - useEffect(() => { - void refreshPendingSyncCount(); - }, [refreshPendingSyncCount]); - - useEffect(() => { - if (!isSyncOnline) return; - void flushCanvasSyncQueue(); - }, [flushCanvasSyncQueue, isSyncOnline]); - - useEffect(() => { - if (!isSyncOnline || pendingSyncCount <= 0) return; - const interval = window.setInterval(() => { - void flushCanvasSyncQueue(); - }, 5000); - return () => window.clearInterval(interval); - }, [flushCanvasSyncQueue, isSyncOnline, pendingSyncCount]); - - useEffect(() => { - const handleVisibilityOrFocus = () => { - if (!isSyncOnline) return; - void flushCanvasSyncQueue(); - }; - - window.addEventListener("focus", handleVisibilityOrFocus); - document.addEventListener("visibilitychange", handleVisibilityOrFocus); - return () => { - window.removeEventListener("focus", handleVisibilityOrFocus); - document.removeEventListener("visibilitychange", handleVisibilityOrFocus); - }; - }, [flushCanvasSyncQueue, isSyncOnline]); - - const runMoveNodeMutation = useCallback( - async (args: { nodeId: Id<"nodes">; positionX: number; positionY: number }) => { - await enqueueSyncMutation("moveNode", args); - }, - [enqueueSyncMutation], - ); - - const runBatchMoveNodesMutation = useCallback( - async (args: { - moves: { nodeId: Id<"nodes">; positionX: number; positionY: number }[]; - }) => { - for (const move of args.moves) { - await enqueueSyncMutation("moveNode", move); - } - }, - [enqueueSyncMutation], - ); - - const flushPendingResizeForClientRequest = useCallback( - async (clientRequestId: string, realId: Id<"nodes">): Promise => { - const pendingResize = pendingResizeAfterCreateRef.current.get(clientRequestId); - if (!pendingResize) return; - pendingResizeAfterCreateRef.current.delete(clientRequestId); - await enqueueSyncMutation("resizeNode", { - nodeId: realId, - width: pendingResize.width, - height: pendingResize.height, - }); - }, - [enqueueSyncMutation], - ); - - const flushPendingDataForClientRequest = useCallback( - async (clientRequestId: string, realId: Id<"nodes">): Promise => { - if (!pendingDataAfterCreateRef.current.has(clientRequestId)) return; - const pendingData = pendingDataAfterCreateRef.current.get(clientRequestId); - pendingDataAfterCreateRef.current.delete(clientRequestId); - await enqueueSyncMutation("updateData", { - nodeId: realId, - data: pendingData, - }); - }, - [enqueueSyncMutation], - ); - - const runResizeNodeMutation = useCallback( - async (args: { nodeId: Id<"nodes">; width: number; height: number }) => { - const rawNodeId = args.nodeId as string; - if (!isOptimisticNodeId(rawNodeId)) { - await enqueueSyncMutation("resizeNode", args); - return; - } - - if (!isSyncOnline) { - await enqueueSyncMutation("resizeNode", args); - return; - } - - const clientRequestId = clientRequestIdFromOptimisticNodeId(rawNodeId); - const resolvedRealId = clientRequestId - ? resolvedRealIdByClientRequestRef.current.get(clientRequestId) - : undefined; - - if (resolvedRealId) { - await enqueueSyncMutation("resizeNode", { - nodeId: resolvedRealId, - width: args.width, - height: args.height, - }); - return; - } - - if (clientRequestId) { - pendingResizeAfterCreateRef.current.set(clientRequestId, { - width: args.width, - height: args.height, - }); - } - - if (process.env.NODE_ENV !== "production") { - console.info("[Canvas sync debug] deferred resize for optimistic node", { - nodeId: rawNodeId, - clientRequestId, - resolvedRealId: resolvedRealId ?? null, - width: args.width, - height: args.height, - }); - } - }, - [enqueueSyncMutation, isSyncOnline], - ); - - const runUpdateNodeDataMutation = useCallback( - async (args: { nodeId: Id<"nodes">; data: unknown }) => { - const rawNodeId = args.nodeId as string; - if (!isOptimisticNodeId(rawNodeId)) { - await enqueueSyncMutation("updateData", args); - return; - } - - if (!isSyncOnline) { - await enqueueSyncMutation("updateData", args); - return; - } - - const clientRequestId = clientRequestIdFromOptimisticNodeId(rawNodeId); - const resolvedRealId = clientRequestId - ? resolvedRealIdByClientRequestRef.current.get(clientRequestId) - : undefined; - - if (resolvedRealId) { - await enqueueSyncMutation("updateData", { - nodeId: resolvedRealId, - data: args.data, - }); - return; - } - - if (clientRequestId) { - pendingDataAfterCreateRef.current.set(clientRequestId, args.data); - } - - if (process.env.NODE_ENV !== "production") { - console.info("[Canvas sync debug] deferred updateData for optimistic node", { - nodeId: rawNodeId, - clientRequestId, - resolvedRealId: resolvedRealId ?? null, - hasData: args.data !== undefined, - }); - } - }, - [enqueueSyncMutation, isSyncOnline], - ); - - const runBatchRemoveNodesMutation = useCallback( - async (args: { nodeIds: Id<"nodes">[] }) => { - const ids = args.nodeIds.map((id) => id as string); - const optimisticNodeIds = ids.filter((id) => isOptimisticNodeId(id)); - const persistedNodeIds = ids.filter((id) => !isOptimisticNodeId(id)); - - const createClientRequestIds = optimisticNodeIds - .map((id) => clientRequestIdFromOptimisticNodeId(id)) - .filter((id): id is string => id !== null); - - if (createClientRequestIds.length > 0) { - if (isSyncOnline) { - for (const clientRequestId of createClientRequestIds) { - pendingDeleteAfterCreateClientRequestIdsRef.current.add( - clientRequestId, - ); - } - } - - const droppedSync = await dropCanvasSyncOpsByClientRequestIds( - canvasId as string, - createClientRequestIds, - ); - const droppedLocal = dropCanvasOpsByClientRequestIds( - canvasId as string, - createClientRequestIds, - ); - for (const clientRequestId of createClientRequestIds) { - removeOptimisticCreateLocally({ - clientRequestId, - removeNode: true, - removeEdge: true, - }); - } - resolveCanvasOps(canvasId as string, droppedSync); - resolveCanvasOps(canvasId as string, droppedLocal); - } - - if (persistedNodeIds.length === 0) { - await refreshPendingSyncCount(); - return; - } - - const droppedSyncByNode = await dropCanvasSyncOpsByNodeIds( - canvasId as string, - persistedNodeIds, - ); - const droppedLocalByNode = dropCanvasOpsByNodeIds( - canvasId as string, - persistedNodeIds, - ); - resolveCanvasOps(canvasId as string, droppedSyncByNode); - resolveCanvasOps(canvasId as string, droppedLocalByNode); - - await enqueueSyncMutation("batchRemoveNodes", { - nodeIds: persistedNodeIds as Id<"nodes">[], - }); - }, - [ - canvasId, - enqueueSyncMutation, - isSyncOnline, - refreshPendingSyncCount, - removeOptimisticCreateLocally, - ], - ); - - const runCreateEdgeMutation = useCallback( - async (args: Parameters[0]) => { - const clientRequestId = args.clientRequestId ?? crypto.randomUUID(); - const payload = { ...args, clientRequestId }; - - if (isSyncOnline) { - await createEdge(payload); - return; - } - - addOptimisticEdgeLocally({ - clientRequestId, - sourceNodeId: payload.sourceNodeId, - targetNodeId: payload.targetNodeId, - sourceHandle: payload.sourceHandle, - targetHandle: payload.targetHandle, - }); - await enqueueSyncMutation("createEdge", payload); - }, - [addOptimisticEdgeLocally, createEdge, enqueueSyncMutation, isSyncOnline], - ); - - const runRemoveEdgeMutation = useCallback( - async (args: { edgeId: Id<"edges"> }) => { - const edgeId = args.edgeId as string; - setEdges((current) => current.filter((edge) => edge.id !== edgeId)); - if (isOptimisticEdgeId(edgeId)) { - const clientRequestId = clientRequestIdFromOptimisticEdgeId(edgeId); - if (clientRequestId) { - const droppedSync = await dropCanvasSyncOpsByClientRequestIds( - canvasId as string, - [clientRequestId], - ); - const droppedLocal = dropCanvasOpsByClientRequestIds( - canvasId as string, - [clientRequestId], - ); - resolveCanvasOps(canvasId as string, droppedSync); - resolveCanvasOps(canvasId as string, droppedLocal); - } - await refreshPendingSyncCount(); - return; - } - - const droppedSync = await dropCanvasSyncOpsByEdgeIds(canvasId as string, [edgeId]); - const droppedLocal = dropCanvasOpsByEdgeIds(canvasId as string, [edgeId]); - resolveCanvasOps(canvasId as string, droppedSync); - resolveCanvasOps(canvasId as string, droppedLocal); - - await enqueueSyncMutation("removeEdge", { - edgeId: edgeId as Id<"edges">, - }); - }, - [canvasId, enqueueSyncMutation, refreshPendingSyncCount], - ); - - const splitEdgeAtExistingNodeMut = useMutation( - api.nodes.splitEdgeAtExistingNode, - ).withOptimisticUpdate((localStore, args) => { - const edgeList = localStore.getQuery(api.edges.list, { - canvasId: args.canvasId, - }); - const nodeList = localStore.getQuery(api.nodes.list, { - canvasId: args.canvasId, - }); - if (edgeList === undefined || nodeList === undefined) return; - - const removed = edgeList.find( - (e: Doc<"edges">) => e._id === args.splitEdgeId, - ); - if (!removed) return; - - const t1 = `${OPTIMISTIC_EDGE_PREFIX}s1_${Date.now()}_${Math.random().toString(36).slice(2, 10)}` as Id<"edges">; - const t2 = `${OPTIMISTIC_EDGE_PREFIX}s2_${Date.now()}_${Math.random().toString(36).slice(2, 10)}` as Id<"edges">; - const now = Date.now(); - - const nextEdges = edgeList.filter( - (e: Doc<"edges">) => e._id !== args.splitEdgeId, - ); - nextEdges.push( - { - _id: t1, - _creationTime: now, - canvasId: args.canvasId, - sourceNodeId: removed.sourceNodeId, - targetNodeId: args.middleNodeId, - sourceHandle: args.splitSourceHandle, - targetHandle: args.newNodeTargetHandle, - }, - { - _id: t2, - _creationTime: now, - canvasId: args.canvasId, - sourceNodeId: args.middleNodeId, - targetNodeId: removed.targetNodeId, - sourceHandle: args.newNodeSourceHandle, - targetHandle: args.splitTargetHandle, - }, - ); - localStore.setQuery(api.edges.list, { canvasId: args.canvasId }, nextEdges); - - if (args.positionX !== undefined && args.positionY !== undefined) { - const px = args.positionX; - const py = args.positionY; - localStore.setQuery( - api.nodes.list, - { canvasId: args.canvasId }, - nodeList.map((n: Doc<"nodes">) => - n._id === args.middleNodeId - ? { - ...n, - positionX: px, - positionY: py, - } - : n, - ), - ); - } - }); - - const runSplitEdgeAtExistingNodeMutation = useCallback( - async (args: Parameters[0]) => { - const clientRequestId = args.clientRequestId ?? crypto.randomUUID(); - const payload = { ...args, clientRequestId }; - if (isSyncOnline) { - await splitEdgeAtExistingNodeMut(payload); - return; - } - - const splitApplied = applyEdgeSplitLocally({ - clientRequestId, - splitEdgeId: payload.splitEdgeId, - middleNodeId: payload.middleNodeId, - splitSourceHandle: payload.splitSourceHandle, - splitTargetHandle: payload.splitTargetHandle, - newNodeSourceHandle: payload.newNodeSourceHandle, - newNodeTargetHandle: payload.newNodeTargetHandle, - positionX: payload.positionX, - positionY: payload.positionY, - }); - if (!splitApplied) return; - - await enqueueSyncMutation("splitEdgeAtExistingNode", payload); - }, - [ - applyEdgeSplitLocally, - enqueueSyncMutation, - isSyncOnline, - splitEdgeAtExistingNodeMut, - ], - ); - - /** Freepik-Panel: State canvas-weit, damit es den optimistic_… → Real-ID-Wechsel überlebt. */ - const assetBrowserTargetApi: AssetBrowserTargetApi = useMemo( - () => ({ - targetNodeId: assetBrowserTargetNodeId, - openForNode: (nodeId: string) => setAssetBrowserTargetNodeId(nodeId), - close: () => setAssetBrowserTargetNodeId(null), - }), - [assetBrowserTargetNodeId], - ); - - /** Pairing: create kann vor oder nach Drag-Ende fertig sein. Kanten-Split + Position in einem Convex-Roundtrip wenn split ansteht. */ - const syncPendingMoveForClientRequest = useCallback( - async ( - clientRequestId: string | undefined, - realId?: Id<"nodes">, - ): Promise => { - if (!clientRequestId) return; - - if (realId !== undefined) { - if (isOptimisticNodeId(realId as string)) { - return; - } - if ( - pendingDeleteAfterCreateClientRequestIdsRef.current.has(clientRequestId) - ) { - pendingDeleteAfterCreateClientRequestIdsRef.current.delete( - clientRequestId, - ); - pendingMoveAfterCreateRef.current.delete(clientRequestId); - pendingResizeAfterCreateRef.current.delete(clientRequestId); - pendingDataAfterCreateRef.current.delete(clientRequestId); - pendingEdgeSplitByClientRequestRef.current.delete(clientRequestId); - pendingConnectionCreatesRef.current.delete(clientRequestId); - resolvedRealIdByClientRequestRef.current.delete(clientRequestId); - - const realNodeId = realId as string; - deletingNodeIds.current.add(realNodeId); - setNodes((current) => - current.filter((node) => node.id !== realNodeId), - ); - setEdges((current) => - current.filter( - (edge) => - edge.source !== realNodeId && edge.target !== realNodeId, - ), - ); - await runBatchRemoveNodesMutation({ nodeIds: [realId] }); - return; - } - const optimisticNodeId = `${OPTIMISTIC_NODE_PREFIX}${clientRequestId}`; - setAssetBrowserTargetNodeId((current) => - current === optimisticNodeId ? (realId as string) : current, - ); - const pendingMove = pendingMoveAfterCreateRef.current.get(clientRequestId); - const splitPayload = - pendingEdgeSplitByClientRequestRef.current.get(clientRequestId); - - if (splitPayload) { - pendingEdgeSplitByClientRequestRef.current.delete(clientRequestId); - if (pendingMove) { - pendingMoveAfterCreateRef.current.delete(clientRequestId); - } - resolvedRealIdByClientRequestRef.current.delete(clientRequestId); - try { - await runSplitEdgeAtExistingNodeMutation({ - canvasId, - splitEdgeId: splitPayload.intersectedEdgeId, - middleNodeId: realId, - splitSourceHandle: splitPayload.intersectedSourceHandle, - splitTargetHandle: splitPayload.intersectedTargetHandle, - newNodeSourceHandle: splitPayload.middleSourceHandle, - newNodeTargetHandle: splitPayload.middleTargetHandle, - positionX: pendingMove?.positionX ?? splitPayload.positionX, - positionY: pendingMove?.positionY ?? splitPayload.positionY, - }); - } catch (error: unknown) { - console.error("[Canvas pending edge split failed]", { - clientRequestId, - realId, - error: String(error), - }); - } - await flushPendingResizeForClientRequest(clientRequestId, realId); - await flushPendingDataForClientRequest(clientRequestId, realId); - return; - } - - if (pendingMove) { - pendingMoveAfterCreateRef.current.delete(clientRequestId); - // Ref bewusst NICHT löschen: Edge-Sync braucht clientRequestId→realId für - // Remap/Carry-over, solange convexNodes/convexEdges nach Mutation kurz auseinanderlaufen. - resolvedRealIdByClientRequestRef.current.set(clientRequestId, realId); - pendingLocalPositionUntilConvexMatchesRef.current.set( - realId as string, - { - x: pendingMove.positionX, - y: pendingMove.positionY, - }, - ); - await runMoveNodeMutation({ - nodeId: realId, - positionX: pendingMove.positionX, - positionY: pendingMove.positionY, - }); - await flushPendingResizeForClientRequest(clientRequestId, realId); - await flushPendingDataForClientRequest(clientRequestId, realId); - return; - } - - resolvedRealIdByClientRequestRef.current.set(clientRequestId, realId); - await flushPendingResizeForClientRequest(clientRequestId, realId); - await flushPendingDataForClientRequest(clientRequestId, realId); - return; - } - - const r = resolvedRealIdByClientRequestRef.current.get(clientRequestId); - const p = pendingMoveAfterCreateRef.current.get(clientRequestId); - if (!r || !p) return; - pendingMoveAfterCreateRef.current.delete(clientRequestId); - resolvedRealIdByClientRequestRef.current.delete(clientRequestId); - - const splitPayload = - pendingEdgeSplitByClientRequestRef.current.get(clientRequestId); - if (splitPayload) { - pendingEdgeSplitByClientRequestRef.current.delete(clientRequestId); - try { - await runSplitEdgeAtExistingNodeMutation({ - canvasId, - splitEdgeId: splitPayload.intersectedEdgeId, - middleNodeId: r, - splitSourceHandle: splitPayload.intersectedSourceHandle, - splitTargetHandle: splitPayload.intersectedTargetHandle, - newNodeSourceHandle: splitPayload.middleSourceHandle, - newNodeTargetHandle: splitPayload.middleTargetHandle, - positionX: splitPayload.positionX ?? p.positionX, - positionY: splitPayload.positionY ?? p.positionY, - }); - } catch (error: unknown) { - console.error("[Canvas pending edge split failed]", { - clientRequestId, - realId: r, - error: String(error), - }); - } - await flushPendingDataForClientRequest(clientRequestId, r); - } else { - pendingLocalPositionUntilConvexMatchesRef.current.set(r as string, { - x: p.positionX, - y: p.positionY, - }); - await runMoveNodeMutation({ - nodeId: r, - positionX: p.positionX, - positionY: p.positionY, - }); - await flushPendingDataForClientRequest(clientRequestId, r); - } - }, - [ - canvasId, - runBatchRemoveNodesMutation, - flushPendingDataForClientRequest, - flushPendingResizeForClientRequest, - runMoveNodeMutation, - runSplitEdgeAtExistingNodeMutation, - ], - ); - syncPendingMoveForClientRequestRef.current = syncPendingMoveForClientRequest; // ─── Future hook seam: render composition + shared local flow state ───── const nodesRef = useRef(nodes); @@ -1827,6 +238,15 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { setEdges, }); + const assetBrowserTargetApi: AssetBrowserTargetApi = useMemo( + () => ({ + targetNodeId: assetBrowserTargetNodeId, + openForNode: (nodeId: string) => setAssetBrowserTargetNodeId(nodeId), + close: () => setAssetBrowserTargetNodeId(null), + }), + [assetBrowserTargetNodeId], + ); + const handleNavToolChange = useCallback((tool: CanvasNavTool) => { if (tool === "scissor") { setScissorsMode(true); @@ -1880,9 +300,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { // Resize-Lock: kein Convex→lokal während aktiver Größenänderung (veraltete Maße überschreiben sonst den Resize) const isResizing = useRef(false); - // Delete-Lock: Nodes die gerade gelöscht werden, nicht aus Convex-Sync wiederherstellen - const deletingNodeIds = useRef>(new Set()); - // Delete Edge on Drop const edgeReconnectSuccessful = useRef(true); const isReconnectDragActiveRef = useRef(false); diff --git a/components/canvas/use-canvas-sync-engine.ts b/components/canvas/use-canvas-sync-engine.ts new file mode 100644 index 0000000..07e23e7 --- /dev/null +++ b/components/canvas/use-canvas-sync-engine.ts @@ -0,0 +1,1760 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type Dispatch, + type MutableRefObject, + type SetStateAction, +} from "react"; +import type { Edge as RFEdge, Node as RFNode } from "@xyflow/react"; +import { useConvexConnectionState, useMutation } from "convex/react"; + +import { api } from "@/convex/_generated/api"; +import type { Doc, Id } from "@/convex/_generated/dataModel"; +import { + ackCanvasSyncOp, + countCanvasSyncOps, + type CanvasSyncOpPayloadByType, + dropCanvasSyncOpsByClientRequestIds, + dropCanvasSyncOpsByEdgeIds, + dropCanvasSyncOpsByNodeIds, + dropExpiredCanvasSyncOps, + enqueueCanvasSyncOp, + listCanvasSyncOps, + markCanvasSyncOpFailed, + remapCanvasSyncNodeId, +} from "@/lib/canvas-op-queue"; +import { + dropCanvasOpsByClientRequestIds, + dropCanvasOpsByEdgeIds, + dropCanvasOpsByNodeIds, + enqueueCanvasOp, + remapCanvasOpNodeId, + resolveCanvasOp, + resolveCanvasOps, +} from "@/lib/canvas-local-persistence"; +import { toast } from "@/lib/toast"; +import { + clientRequestIdFromOptimisticEdgeId, + clientRequestIdFromOptimisticNodeId, + createCanvasOpId, + isOptimisticEdgeId, + isOptimisticNodeId, + OPTIMISTIC_EDGE_PREFIX, + OPTIMISTIC_NODE_PREFIX, + type PendingEdgeSplit, +} from "./canvas-helpers"; + +type QueueSyncMutation = ( + type: TType, + payload: CanvasSyncOpPayloadByType[TType], +) => Promise; + +type RunMoveNodeMutation = (args: { + nodeId: Id<"nodes">; + positionX: number; + positionY: number; +}) => Promise; + +type RunBatchRemoveNodesMutation = (args: { + nodeIds: Id<"nodes">[]; +}) => Promise; + +type RunSplitEdgeAtExistingNodeMutation = (args: { + canvasId: Id<"canvases">; + splitEdgeId: Id<"edges">; + middleNodeId: Id<"nodes">; + splitSourceHandle?: string; + splitTargetHandle?: string; + newNodeSourceHandle?: string; + newNodeTargetHandle?: string; + positionX?: number; + positionY?: number; + clientRequestId?: string; +}) => Promise; + +type CanvasSyncEngineControllerParams = { + canvasId: Id<"canvases">; + isSyncOnline: boolean | (() => boolean); + enqueueSyncMutation: QueueSyncMutation; + runMoveNodeMutation?: RunMoveNodeMutation; + runBatchRemoveNodes?: RunBatchRemoveNodesMutation; + runSplitEdgeAtExistingNode?: RunSplitEdgeAtExistingNodeMutation; + setAssetBrowserTargetNodeId?: Dispatch>; + setNodes?: Dispatch>; + setEdges?: Dispatch>; + deletingNodeIds?: MutableRefObject>; +}; + +type UseCanvasSyncEngineParams = { + canvasId: Id<"canvases">; + setNodes: Dispatch>; + setEdges: Dispatch>; + edgesRef: MutableRefObject; + setAssetBrowserTargetNodeId: Dispatch>; + setEdgeSyncNonce: Dispatch>; + deletingNodeIds: MutableRefObject>; +}; + +export type CanvasSyncEngineController = ReturnType< + typeof createCanvasSyncEngineController +>; + +function getErrorMessage(error: unknown): string { + if (error instanceof Error && typeof error.message === "string") { + return error.message; + } + return String(error); +} + +function isLikelyTransientSyncError(error: unknown): boolean { + const message = getErrorMessage(error).toLowerCase(); + return ( + message.includes("network") || + message.includes("websocket") || + message.includes("fetch") || + message.includes("timeout") || + message.includes("temporarily") || + message.includes("connection") + ); +} + +function summarizeUpdateDataPayload(payload: unknown): Record { + if (typeof payload !== "object" || payload === null) { + return { payloadShape: "invalid" }; + } + + const p = payload as { nodeId?: unknown; data?: unknown }; + const data = + typeof p.data === "object" && p.data !== null + ? (p.data as Record) + : null; + + return { + nodeId: typeof p.nodeId === "string" ? p.nodeId : null, + hasData: Boolean(data), + hasStorageId: typeof data?.storageId === "string" && data.storageId.length > 0, + hasLastUploadStorageId: + typeof data?.lastUploadStorageId === "string" && + data.lastUploadStorageId.length > 0, + hasUrl: typeof data?.url === "string" && data.url.length > 0, + hasLastUploadUrl: + typeof data?.lastUploadUrl === "string" && data.lastUploadUrl.length > 0, + lastUploadedAt: + typeof data?.lastUploadedAt === "number" && Number.isFinite(data.lastUploadedAt) + ? data.lastUploadedAt + : null, + }; +} + +function summarizeResizePayload(payload: unknown): Record { + if (typeof payload !== "object" || payload === null) { + return { payloadShape: "invalid" }; + } + + const p = payload as { nodeId?: unknown; width?: unknown; height?: unknown }; + return { + nodeId: typeof p.nodeId === "string" ? p.nodeId : null, + width: typeof p.width === "number" && Number.isFinite(p.width) ? p.width : null, + height: + typeof p.height === "number" && Number.isFinite(p.height) ? p.height : null, + }; +} + +export function createCanvasSyncEngineController({ + canvasId, + isSyncOnline, + enqueueSyncMutation, + runMoveNodeMutation, + runBatchRemoveNodes, + runSplitEdgeAtExistingNode, + setAssetBrowserTargetNodeId, + setNodes, + setEdges, + deletingNodeIds, +}: CanvasSyncEngineControllerParams) { + const getIsSyncOnline = () => + typeof isSyncOnline === "function" ? isSyncOnline() : isSyncOnline; + + const pendingMoveAfterCreateRef = { + current: new Map(), + }; + const pendingResizeAfterCreateRef = { + current: new Map(), + }; + const pendingDataAfterCreateRef = { current: new Map() }; + const resolvedRealIdByClientRequestRef = { + current: new Map>(), + }; + const pendingEdgeSplitByClientRequestRef = { + current: new Map(), + }; + const pendingDeleteAfterCreateClientRequestIdsRef = { + current: new Set(), + }; + const pendingConnectionCreatesRef = { current: new Set() }; + const pendingLocalPositionUntilConvexMatchesRef = { + current: new Map(), + }; + const preferLocalPositionNodeIdsRef = { current: new Set() }; + + const flushPendingResizeForClientRequest = async ( + clientRequestId: string, + realId: Id<"nodes">, + ): Promise => { + const pendingResize = pendingResizeAfterCreateRef.current.get(clientRequestId); + if (!pendingResize) return; + pendingResizeAfterCreateRef.current.delete(clientRequestId); + await enqueueSyncMutation("resizeNode", { + nodeId: realId, + width: pendingResize.width, + height: pendingResize.height, + }); + }; + + const flushPendingDataForClientRequest = async ( + clientRequestId: string, + realId: Id<"nodes">, + ): Promise => { + if (!pendingDataAfterCreateRef.current.has(clientRequestId)) return; + const pendingData = pendingDataAfterCreateRef.current.get(clientRequestId); + pendingDataAfterCreateRef.current.delete(clientRequestId); + await enqueueSyncMutation("updateData", { + nodeId: realId, + data: pendingData, + }); + }; + + const queueNodeResize = async (args: { + nodeId: Id<"nodes">; + width: number; + height: number; + }): Promise => { + const rawNodeId = args.nodeId as string; + if (!isOptimisticNodeId(rawNodeId) || !getIsSyncOnline()) { + await enqueueSyncMutation("resizeNode", args); + return; + } + + const clientRequestId = clientRequestIdFromOptimisticNodeId(rawNodeId); + const resolvedRealId = clientRequestId + ? resolvedRealIdByClientRequestRef.current.get(clientRequestId) + : undefined; + + if (resolvedRealId) { + await enqueueSyncMutation("resizeNode", { + nodeId: resolvedRealId, + width: args.width, + height: args.height, + }); + return; + } + + if (clientRequestId) { + pendingResizeAfterCreateRef.current.set(clientRequestId, { + width: args.width, + height: args.height, + }); + } + }; + + const queueNodeDataUpdate = async (args: { + nodeId: Id<"nodes">; + data: unknown; + }): Promise => { + const rawNodeId = args.nodeId as string; + if (!isOptimisticNodeId(rawNodeId) || !getIsSyncOnline()) { + await enqueueSyncMutation("updateData", args); + return; + } + + const clientRequestId = clientRequestIdFromOptimisticNodeId(rawNodeId); + const resolvedRealId = clientRequestId + ? resolvedRealIdByClientRequestRef.current.get(clientRequestId) + : undefined; + + if (resolvedRealId) { + await enqueueSyncMutation("updateData", { + nodeId: resolvedRealId, + data: args.data, + }); + return; + } + + if (clientRequestId) { + pendingDataAfterCreateRef.current.set(clientRequestId, args.data); + } + }; + + const syncPendingMoveForClientRequest = async ( + clientRequestId: string | undefined, + realId?: Id<"nodes">, + ): Promise => { + if (!clientRequestId) return; + + if (realId !== undefined) { + if (isOptimisticNodeId(realId as string)) { + return; + } + + if (pendingDeleteAfterCreateClientRequestIdsRef.current.has(clientRequestId)) { + pendingDeleteAfterCreateClientRequestIdsRef.current.delete(clientRequestId); + pendingMoveAfterCreateRef.current.delete(clientRequestId); + pendingResizeAfterCreateRef.current.delete(clientRequestId); + pendingDataAfterCreateRef.current.delete(clientRequestId); + pendingEdgeSplitByClientRequestRef.current.delete(clientRequestId); + pendingConnectionCreatesRef.current.delete(clientRequestId); + resolvedRealIdByClientRequestRef.current.delete(clientRequestId); + + const realNodeId = realId as string; + deletingNodeIds?.current.add(realNodeId); + setNodes?.((current) => current.filter((node) => node.id !== realNodeId)); + setEdges?.((current) => + current.filter( + (edge) => edge.source !== realNodeId && edge.target !== realNodeId, + ), + ); + if (runBatchRemoveNodes) { + await runBatchRemoveNodes({ nodeIds: [realId] }); + } + return; + } + + const optimisticNodeId = `${OPTIMISTIC_NODE_PREFIX}${clientRequestId}`; + setAssetBrowserTargetNodeId?.((current) => + current === optimisticNodeId ? (realId as string) : current, + ); + + const pendingMove = pendingMoveAfterCreateRef.current.get(clientRequestId); + const splitPayload = + pendingEdgeSplitByClientRequestRef.current.get(clientRequestId); + + if (splitPayload) { + pendingEdgeSplitByClientRequestRef.current.delete(clientRequestId); + if (pendingMove) { + pendingMoveAfterCreateRef.current.delete(clientRequestId); + } + resolvedRealIdByClientRequestRef.current.delete(clientRequestId); + if (runSplitEdgeAtExistingNode) { + await runSplitEdgeAtExistingNode({ + canvasId, + splitEdgeId: splitPayload.intersectedEdgeId, + middleNodeId: realId, + splitSourceHandle: splitPayload.intersectedSourceHandle, + splitTargetHandle: splitPayload.intersectedTargetHandle, + newNodeSourceHandle: splitPayload.middleSourceHandle, + newNodeTargetHandle: splitPayload.middleTargetHandle, + positionX: pendingMove?.positionX ?? splitPayload.positionX, + positionY: pendingMove?.positionY ?? splitPayload.positionY, + }); + } + await flushPendingResizeForClientRequest(clientRequestId, realId); + await flushPendingDataForClientRequest(clientRequestId, realId); + return; + } + + if (pendingMove) { + pendingMoveAfterCreateRef.current.delete(clientRequestId); + resolvedRealIdByClientRequestRef.current.set(clientRequestId, realId); + pendingLocalPositionUntilConvexMatchesRef.current.set(realId as string, { + x: pendingMove.positionX, + y: pendingMove.positionY, + }); + if (runMoveNodeMutation) { + await runMoveNodeMutation({ + nodeId: realId, + positionX: pendingMove.positionX, + positionY: pendingMove.positionY, + }); + } else { + await enqueueSyncMutation("moveNode", { + nodeId: realId, + positionX: pendingMove.positionX, + positionY: pendingMove.positionY, + }); + } + await flushPendingResizeForClientRequest(clientRequestId, realId); + await flushPendingDataForClientRequest(clientRequestId, realId); + return; + } + + resolvedRealIdByClientRequestRef.current.set(clientRequestId, realId); + await flushPendingResizeForClientRequest(clientRequestId, realId); + await flushPendingDataForClientRequest(clientRequestId, realId); + return; + } + + const resolvedRealId = + resolvedRealIdByClientRequestRef.current.get(clientRequestId); + const pendingMove = pendingMoveAfterCreateRef.current.get(clientRequestId); + if (!resolvedRealId || !pendingMove) return; + + pendingMoveAfterCreateRef.current.delete(clientRequestId); + resolvedRealIdByClientRequestRef.current.delete(clientRequestId); + + const splitPayload = pendingEdgeSplitByClientRequestRef.current.get(clientRequestId); + if (splitPayload) { + pendingEdgeSplitByClientRequestRef.current.delete(clientRequestId); + if (runSplitEdgeAtExistingNode) { + await runSplitEdgeAtExistingNode({ + canvasId, + splitEdgeId: splitPayload.intersectedEdgeId, + middleNodeId: resolvedRealId, + splitSourceHandle: splitPayload.intersectedSourceHandle, + splitTargetHandle: splitPayload.intersectedTargetHandle, + newNodeSourceHandle: splitPayload.middleSourceHandle, + newNodeTargetHandle: splitPayload.middleTargetHandle, + positionX: splitPayload.positionX ?? pendingMove.positionX, + positionY: splitPayload.positionY ?? pendingMove.positionY, + }); + } + await flushPendingDataForClientRequest(clientRequestId, resolvedRealId); + return; + } + + pendingLocalPositionUntilConvexMatchesRef.current.set(resolvedRealId as string, { + x: pendingMove.positionX, + y: pendingMove.positionY, + }); + if (runMoveNodeMutation) { + await runMoveNodeMutation({ + nodeId: resolvedRealId, + positionX: pendingMove.positionX, + positionY: pendingMove.positionY, + }); + } else { + await enqueueSyncMutation("moveNode", { + nodeId: resolvedRealId, + positionX: pendingMove.positionX, + positionY: pendingMove.positionY, + }); + } + await flushPendingDataForClientRequest(clientRequestId, resolvedRealId); + }; + + return { + pendingMoveAfterCreateRef, + pendingResizeAfterCreateRef, + pendingDataAfterCreateRef, + resolvedRealIdByClientRequestRef, + pendingEdgeSplitByClientRequestRef, + pendingDeleteAfterCreateClientRequestIdsRef, + pendingConnectionCreatesRef, + pendingLocalPositionUntilConvexMatchesRef, + preferLocalPositionNodeIdsRef, + flushPendingResizeForClientRequest, + flushPendingDataForClientRequest, + queueNodeResize, + queueNodeDataUpdate, + syncPendingMoveForClientRequest, + }; +} + +export function useCanvasSyncEngine({ + canvasId, + setNodes, + setEdges, + edgesRef, + setAssetBrowserTargetNodeId, + setEdgeSyncNonce, + deletingNodeIds, +}: UseCanvasSyncEngineParams) { + const moveNode = useMutation(api.nodes.move); + const resizeNode = useMutation(api.nodes.resize); + const updateNodeData = useMutation(api.nodes.updateData); + const connectionState = useConvexConnectionState(); + const syncInFlightRef = useRef(false); + const lastOfflineUnsupportedToastAtRef = useRef(0); + const pendingCreatePromiseByClientRequestRef = useRef( + new Map>>(), + ); + const [pendingSyncCount, setPendingSyncCount] = useState(0); + const [isSyncing, setIsSyncing] = useState(false); + const [isBrowserOnline, setIsBrowserOnline] = useState( + typeof navigator === "undefined" ? true : navigator.onLine, + ); + + const isSyncOnline = + isBrowserOnline === true && connectionState.isWebSocketConnected === true; + const isSyncOnlineRef = useRef(isSyncOnline); + isSyncOnlineRef.current = isSyncOnline; + + const runBatchRemoveNodesMutationRef = useRef( + async () => {}, + ); + const runSplitEdgeAtExistingNodeMutationRef = + useRef(async () => {}); + + const refreshPendingSyncCount = useCallback(async () => { + const count = await countCanvasSyncOps(canvasId as string); + setPendingSyncCount(count); + }, [canvasId]); + + const enqueueSyncMutation = useCallback( + async (type, payload) => { + const opId = createCanvasOpId(); + const now = Date.now(); + const result = await enqueueCanvasSyncOp({ + id: opId, + canvasId: canvasId as string, + type, + payload, + now, + }); + enqueueCanvasOp(canvasId as string, { + id: opId, + type, + payload, + enqueuedAt: now, + }); + resolveCanvasOps(canvasId as string, result.replacedIds); + await refreshPendingSyncCount(); + void flushCanvasSyncQueueRef.current(); + }, + [canvasId, refreshPendingSyncCount], + ); + + const runMoveNodeMutation = useCallback( + async (args) => { + await enqueueSyncMutation("moveNode", args); + }, + [enqueueSyncMutation], + ); + + const runBatchMoveNodesMutation = useCallback( + async (args: { + moves: { nodeId: Id<"nodes">; positionX: number; positionY: number }[]; + }) => { + for (const move of args.moves) { + await enqueueSyncMutation("moveNode", move); + } + }, + [enqueueSyncMutation], + ); + + const createNode = useMutation(api.nodes.create).withOptimisticUpdate( + (localStore, args) => { + const current = localStore.getQuery(api.nodes.list, { + canvasId: args.canvasId, + }); + if (current === undefined) return; + + const tempId = ( + args.clientRequestId + ? `${OPTIMISTIC_NODE_PREFIX}${args.clientRequestId}` + : `${OPTIMISTIC_NODE_PREFIX}${Date.now()}_${Math.random().toString(36).slice(2, 11)}` + ) as Id<"nodes">; + + const synthetic: Doc<"nodes"> = { + _id: tempId, + _creationTime: Date.now(), + canvasId: args.canvasId, + type: args.type as Doc<"nodes">["type"], + positionX: args.positionX, + positionY: args.positionY, + width: args.width, + height: args.height, + status: "idle", + retryCount: 0, + data: args.data, + parentId: args.parentId, + zIndex: args.zIndex, + }; + + localStore.setQuery(api.nodes.list, { canvasId: args.canvasId }, [ + ...current, + synthetic, + ]); + }, + ); + + const createNodeWithEdgeFromSource = useMutation( + api.nodes.createWithEdgeFromSource, + ).withOptimisticUpdate((localStore, args) => { + const nodeList = localStore.getQuery(api.nodes.list, { + canvasId: args.canvasId, + }); + const edgeList = localStore.getQuery(api.edges.list, { + canvasId: args.canvasId, + }); + if (nodeList === undefined || edgeList === undefined) return; + + const tempNodeId = ( + args.clientRequestId + ? `${OPTIMISTIC_NODE_PREFIX}${args.clientRequestId}` + : `${OPTIMISTIC_NODE_PREFIX}${Date.now()}_${Math.random().toString(36).slice(2, 11)}` + ) as Id<"nodes">; + + const tempEdgeId = ( + args.clientRequestId + ? `${OPTIMISTIC_EDGE_PREFIX}${args.clientRequestId}` + : `${OPTIMISTIC_EDGE_PREFIX}${Date.now()}_${Math.random().toString(36).slice(2, 11)}` + ) as Id<"edges">; + + const syntheticNode: Doc<"nodes"> = { + _id: tempNodeId, + _creationTime: Date.now(), + canvasId: args.canvasId, + type: args.type as Doc<"nodes">["type"], + positionX: args.positionX, + positionY: args.positionY, + width: args.width, + height: args.height, + status: "idle", + retryCount: 0, + data: args.data, + parentId: args.parentId, + zIndex: args.zIndex, + }; + + const sourceNode = nodeList.find((node) => node._id === args.sourceNodeId); + if (!sourceNode) return; + + const syntheticEdge: Doc<"edges"> = { + _id: tempEdgeId, + _creationTime: Date.now(), + canvasId: args.canvasId, + sourceNodeId: sourceNode._id, + targetNodeId: tempNodeId, + sourceHandle: args.sourceHandle, + targetHandle: args.targetHandle, + }; + + localStore.setQuery(api.nodes.list, { canvasId: args.canvasId }, [ + ...nodeList, + syntheticNode, + ]); + localStore.setQuery(api.edges.list, { canvasId: args.canvasId }, [ + ...edgeList, + syntheticEdge, + ]); + }); + + const createNodeWithEdgeToTarget = useMutation( + api.nodes.createWithEdgeToTarget, + ).withOptimisticUpdate((localStore, args) => { + const nodeList = localStore.getQuery(api.nodes.list, { + canvasId: args.canvasId, + }); + const edgeList = localStore.getQuery(api.edges.list, { + canvasId: args.canvasId, + }); + if (nodeList === undefined || edgeList === undefined) return; + + const tempNodeId = ( + args.clientRequestId + ? `${OPTIMISTIC_NODE_PREFIX}${args.clientRequestId}` + : `${OPTIMISTIC_NODE_PREFIX}${Date.now()}_${Math.random().toString(36).slice(2, 11)}` + ) as Id<"nodes">; + + const tempEdgeId = ( + args.clientRequestId + ? `${OPTIMISTIC_EDGE_PREFIX}${args.clientRequestId}` + : `${OPTIMISTIC_EDGE_PREFIX}${Date.now()}_${Math.random().toString(36).slice(2, 11)}` + ) as Id<"edges">; + + const syntheticNode: Doc<"nodes"> = { + _id: tempNodeId, + _creationTime: Date.now(), + canvasId: args.canvasId, + type: args.type as Doc<"nodes">["type"], + positionX: args.positionX, + positionY: args.positionY, + width: args.width, + height: args.height, + status: "idle", + retryCount: 0, + data: args.data, + parentId: args.parentId, + zIndex: args.zIndex, + }; + + const targetNode = nodeList.find((node) => node._id === args.targetNodeId); + if (!targetNode) return; + + const syntheticEdge: Doc<"edges"> = { + _id: tempEdgeId, + _creationTime: Date.now(), + canvasId: args.canvasId, + sourceNodeId: tempNodeId, + targetNodeId: targetNode._id, + sourceHandle: args.sourceHandle, + targetHandle: args.targetHandle, + }; + + localStore.setQuery(api.nodes.list, { canvasId: args.canvasId }, [ + ...nodeList, + syntheticNode, + ]); + localStore.setQuery(api.edges.list, { canvasId: args.canvasId }, [ + ...edgeList, + syntheticEdge, + ]); + }); + + const createNodeWithEdgeSplitMut = useMutation(api.nodes.createWithEdgeSplit); + + const createEdge = useMutation(api.edges.create).withOptimisticUpdate( + (localStore, args) => { + const edgeList = localStore.getQuery(api.edges.list, { + canvasId: args.canvasId, + }); + const nodeList = localStore.getQuery(api.nodes.list, { + canvasId: args.canvasId, + }); + if (edgeList === undefined || nodeList === undefined) return; + + const sourceNode = nodeList.find((node) => node._id === args.sourceNodeId); + const targetNode = nodeList.find((node) => node._id === args.targetNodeId); + if (!sourceNode || !targetNode) return; + + const tempId = ( + args.clientRequestId + ? `${OPTIMISTIC_EDGE_PREFIX}${args.clientRequestId}` + : `${OPTIMISTIC_EDGE_PREFIX}${Date.now()}_${Math.random().toString(36).slice(2, 11)}` + ) as Id<"edges">; + const synthetic: Doc<"edges"> = { + _id: tempId, + _creationTime: Date.now(), + canvasId: args.canvasId, + sourceNodeId: sourceNode._id, + targetNodeId: targetNode._id, + sourceHandle: args.sourceHandle, + targetHandle: args.targetHandle, + }; + localStore.setQuery(api.edges.list, { canvasId: args.canvasId }, [ + ...edgeList, + synthetic, + ]); + }, + ); + + const createNodeRaw = useMutation(api.nodes.create); + const createNodeWithEdgeFromSourceRaw = useMutation( + api.nodes.createWithEdgeFromSource, + ); + const createNodeWithEdgeToTargetRaw = useMutation( + api.nodes.createWithEdgeToTarget, + ); + const createNodeWithEdgeSplitRaw = useMutation(api.nodes.createWithEdgeSplit); + const createEdgeRaw = useMutation(api.edges.create); + const batchRemoveNodesRaw = useMutation(api.nodes.batchRemove); + const removeEdgeRaw = useMutation(api.edges.remove); + const splitEdgeAtExistingNodeRaw = useMutation(api.nodes.splitEdgeAtExistingNode); + + const flushCanvasSyncQueueRef = useRef(async () => {}); + + const controllerRef = useRef(null); + if (controllerRef.current === null) { + controllerRef.current = createCanvasSyncEngineController({ + canvasId, + isSyncOnline: () => isSyncOnlineRef.current, + enqueueSyncMutation, + runMoveNodeMutation, + runBatchRemoveNodes: async (args) => { + await runBatchRemoveNodesMutationRef.current(args); + }, + runSplitEdgeAtExistingNode: async (args) => { + await runSplitEdgeAtExistingNodeMutationRef.current(args); + }, + setAssetBrowserTargetNodeId, + setNodes, + setEdges, + deletingNodeIds, + }); + } + const controller = controllerRef.current; + + const trackPendingNodeCreate = useCallback( + ( + clientRequestId: string, + createPromise: Promise>, + ): Promise> => { + const trackedPromise = createPromise + .then((realId) => { + controller.resolvedRealIdByClientRequestRef.current.set( + clientRequestId, + realId, + ); + return realId; + }) + .finally(() => { + pendingCreatePromiseByClientRequestRef.current.delete(clientRequestId); + }); + + pendingCreatePromiseByClientRequestRef.current.set( + clientRequestId, + trackedPromise, + ); + return trackedPromise; + }, + [controller.resolvedRealIdByClientRequestRef], + ); + + const addOptimisticNodeLocally = useCallback( + ( + args: Parameters[0] & { clientRequestId: string }, + ): Id<"nodes"> => { + const optimisticNodeId = `${OPTIMISTIC_NODE_PREFIX}${args.clientRequestId}`; + setNodes((current) => { + if (current.some((node) => node.id === optimisticNodeId)) { + return current; + } + return [ + ...current, + { + id: optimisticNodeId, + type: args.type, + position: { x: args.positionX, y: args.positionY }, + data: args.data, + style: { width: args.width, height: args.height }, + parentId: args.parentId as string | undefined, + zIndex: args.zIndex, + selected: false, + }, + ]; + }); + return optimisticNodeId as Id<"nodes">; + }, + [setNodes], + ); + + const addOptimisticEdgeLocally = useCallback( + (args: { + clientRequestId: string; + sourceNodeId: string; + targetNodeId: string; + sourceHandle?: string; + targetHandle?: string; + }): Id<"edges"> => { + const optimisticEdgeId = `${OPTIMISTIC_EDGE_PREFIX}${args.clientRequestId}`; + setEdges((current) => { + if (current.some((edge) => edge.id === optimisticEdgeId)) { + return current; + } + return [ + ...current, + { + id: optimisticEdgeId, + source: args.sourceNodeId, + target: args.targetNodeId, + sourceHandle: args.sourceHandle, + targetHandle: args.targetHandle, + }, + ]; + }); + return optimisticEdgeId as Id<"edges">; + }, + [setEdges], + ); + + const applyEdgeSplitLocally = useCallback( + (args: { + clientRequestId: string; + splitEdgeId: Id<"edges">; + middleNodeId: Id<"nodes">; + splitSourceHandle?: string; + splitTargetHandle?: string; + newNodeSourceHandle?: string; + newNodeTargetHandle?: string; + positionX?: number; + positionY?: number; + }): boolean => { + const splitEdgeId = args.splitEdgeId as string; + const splitEdge = edgesRef.current.find( + (edge) => + edge.id === splitEdgeId && + edge.className !== "temp" && + !isOptimisticEdgeId(edge.id), + ); + if (!splitEdge) { + return false; + } + + const optimisticSplitEdgeBase = `${OPTIMISTIC_EDGE_PREFIX}${args.clientRequestId}`; + const optimisticSplitEdgeAId = `${optimisticSplitEdgeBase}_split_a`; + const optimisticSplitEdgeBId = `${optimisticSplitEdgeBase}_split_b`; + + setEdges((current) => { + const existingSplitEdge = current.find((edge) => edge.id === splitEdgeId); + if (!existingSplitEdge) { + return current; + } + + const next = current.filter( + (edge) => + edge.id !== splitEdgeId && + edge.id !== optimisticSplitEdgeAId && + edge.id !== optimisticSplitEdgeBId, + ); + + next.push( + { + id: optimisticSplitEdgeAId, + source: existingSplitEdge.source, + target: args.middleNodeId as string, + sourceHandle: args.splitSourceHandle, + targetHandle: args.newNodeTargetHandle, + }, + { + id: optimisticSplitEdgeBId, + source: args.middleNodeId as string, + target: existingSplitEdge.target, + sourceHandle: args.newNodeSourceHandle, + targetHandle: args.splitTargetHandle, + }, + ); + + return next; + }); + + if (args.positionX !== undefined && args.positionY !== undefined) { + const x = args.positionX; + const y = args.positionY; + const middleNodeId = args.middleNodeId as string; + setNodes((current) => + current.map((node) => + node.id === middleNodeId + ? { + ...node, + position: { x, y }, + } + : node, + ), + ); + } + + return true; + }, + [edgesRef, setEdges, setNodes], + ); + + const removeOptimisticCreateLocally = useCallback( + (args: { + clientRequestId: string; + removeNode?: boolean; + removeEdge?: boolean; + }): void => { + const optimisticNodeId = `${OPTIMISTIC_NODE_PREFIX}${args.clientRequestId}`; + const optimisticEdgeId = `${OPTIMISTIC_EDGE_PREFIX}${args.clientRequestId}`; + + if (args.removeNode) { + setNodes((current) => + current.filter((node) => node.id !== optimisticNodeId), + ); + setEdges((current) => + current.filter( + (edge) => + edge.source !== optimisticNodeId && edge.target !== optimisticNodeId, + ), + ); + } + + if (args.removeEdge) { + const optimisticEdgePrefix = `${optimisticEdgeId}_`; + setEdges((current) => + current.filter( + (edge) => + edge.id !== optimisticEdgeId && + !edge.id.startsWith(optimisticEdgePrefix), + ), + ); + } + + controller.pendingMoveAfterCreateRef.current.delete(args.clientRequestId); + controller.pendingResizeAfterCreateRef.current.delete(args.clientRequestId); + controller.pendingDataAfterCreateRef.current.delete(args.clientRequestId); + pendingCreatePromiseByClientRequestRef.current.delete(args.clientRequestId); + controller.pendingEdgeSplitByClientRequestRef.current.delete( + args.clientRequestId, + ); + controller.pendingConnectionCreatesRef.current.delete(args.clientRequestId); + controller.resolvedRealIdByClientRequestRef.current.delete( + args.clientRequestId, + ); + }, + [controller, setEdges, setNodes], + ); + + const remapOptimisticNodeLocally = useCallback( + async (clientRequestId: string, realId: Id<"nodes">): Promise => { + const optimisticNodeId = `${OPTIMISTIC_NODE_PREFIX}${clientRequestId}`; + const realNodeId = realId as string; + + if ( + controller.pendingDeleteAfterCreateClientRequestIdsRef.current.has( + clientRequestId, + ) + ) { + controller.pendingDeleteAfterCreateClientRequestIdsRef.current.delete( + clientRequestId, + ); + removeOptimisticCreateLocally({ + clientRequestId, + removeNode: true, + removeEdge: true, + }); + deletingNodeIds.current.add(realNodeId); + await enqueueSyncMutation("batchRemoveNodes", { + nodeIds: [realId], + }); + return; + } + + setNodes((current) => + current.map((node) => { + const nextParentId = + node.parentId === optimisticNodeId ? realNodeId : node.parentId; + if (node.id !== optimisticNodeId && nextParentId === node.parentId) { + return node; + } + return { + ...node, + id: node.id === optimisticNodeId ? realNodeId : node.id, + parentId: nextParentId, + }; + }), + ); + setEdges((current) => + current.map((edge) => { + const nextSource = + edge.source === optimisticNodeId ? realNodeId : edge.source; + const nextTarget = + edge.target === optimisticNodeId ? realNodeId : edge.target; + if (nextSource === edge.source && nextTarget === edge.target) { + return edge; + } + return { + ...edge, + source: nextSource, + target: nextTarget, + }; + }), + ); + setAssetBrowserTargetNodeId((current) => + current === optimisticNodeId ? realNodeId : current, + ); + + const pinnedPos = + controller.pendingLocalPositionUntilConvexMatchesRef.current.get( + optimisticNodeId, + ); + if (pinnedPos) { + controller.pendingLocalPositionUntilConvexMatchesRef.current.delete( + optimisticNodeId, + ); + controller.pendingLocalPositionUntilConvexMatchesRef.current.set( + realNodeId, + pinnedPos, + ); + } + + if ( + controller.preferLocalPositionNodeIdsRef.current.has(optimisticNodeId) + ) { + controller.preferLocalPositionNodeIdsRef.current.delete(optimisticNodeId); + controller.preferLocalPositionNodeIdsRef.current.add(realNodeId); + } + + controller.resolvedRealIdByClientRequestRef.current.set(clientRequestId, realId); + await remapCanvasSyncNodeId(canvasId as string, optimisticNodeId, realNodeId); + remapCanvasOpNodeId(canvasId as string, optimisticNodeId, realNodeId); + }, + [ + canvasId, + controller, + deletingNodeIds, + enqueueSyncMutation, + removeOptimisticCreateLocally, + setAssetBrowserTargetNodeId, + setEdges, + setNodes, + ], + ); + + const splitEdgeAtExistingNodeMut = useMutation( + api.nodes.splitEdgeAtExistingNode, + ).withOptimisticUpdate((localStore, args) => { + const edgeList = localStore.getQuery(api.edges.list, { + canvasId: args.canvasId, + }); + const nodeList = localStore.getQuery(api.nodes.list, { + canvasId: args.canvasId, + }); + if (edgeList === undefined || nodeList === undefined) return; + + const removed = edgeList.find((e: Doc<"edges">) => e._id === args.splitEdgeId); + if (!removed) return; + + const t1 = `${OPTIMISTIC_EDGE_PREFIX}s1_${Date.now()}_${Math.random().toString(36).slice(2, 10)}` as Id<"edges">; + const t2 = `${OPTIMISTIC_EDGE_PREFIX}s2_${Date.now()}_${Math.random().toString(36).slice(2, 10)}` as Id<"edges">; + const now = Date.now(); + + const nextEdges = edgeList.filter( + (e: Doc<"edges">) => e._id !== args.splitEdgeId, + ); + nextEdges.push( + { + _id: t1, + _creationTime: now, + canvasId: args.canvasId, + sourceNodeId: removed.sourceNodeId, + targetNodeId: args.middleNodeId, + sourceHandle: args.splitSourceHandle, + targetHandle: args.newNodeTargetHandle, + }, + { + _id: t2, + _creationTime: now, + canvasId: args.canvasId, + sourceNodeId: args.middleNodeId, + targetNodeId: removed.targetNodeId, + sourceHandle: args.newNodeSourceHandle, + targetHandle: args.splitTargetHandle, + }, + ); + localStore.setQuery(api.edges.list, { canvasId: args.canvasId }, nextEdges); + + if (args.positionX !== undefined && args.positionY !== undefined) { + const px = args.positionX; + const py = args.positionY; + localStore.setQuery( + api.nodes.list, + { canvasId: args.canvasId }, + nodeList.map((n: Doc<"nodes">) => + n._id === args.middleNodeId + ? { + ...n, + positionX: px, + positionY: py, + } + : n, + ), + ); + } + }); + + const runSplitEdgeAtExistingNodeMutation = useCallback< + RunSplitEdgeAtExistingNodeMutation + >( + async (args) => { + const clientRequestId = args.clientRequestId ?? crypto.randomUUID(); + const payload = { ...args, clientRequestId }; + if (isSyncOnline) { + await splitEdgeAtExistingNodeMut(payload); + return; + } + + const splitApplied = applyEdgeSplitLocally({ + clientRequestId, + splitEdgeId: payload.splitEdgeId, + middleNodeId: payload.middleNodeId, + splitSourceHandle: payload.splitSourceHandle, + splitTargetHandle: payload.splitTargetHandle, + newNodeSourceHandle: payload.newNodeSourceHandle, + newNodeTargetHandle: payload.newNodeTargetHandle, + positionX: payload.positionX, + positionY: payload.positionY, + }); + if (!splitApplied) return; + + await enqueueSyncMutation("splitEdgeAtExistingNode", payload); + }, + [applyEdgeSplitLocally, enqueueSyncMutation, isSyncOnline, splitEdgeAtExistingNodeMut], + ); + + runSplitEdgeAtExistingNodeMutationRef.current = runSplitEdgeAtExistingNodeMutation; + + const runCreateNodeOnlineOnly = useCallback( + async (args: Parameters[0]) => { + const clientRequestId = args.clientRequestId ?? crypto.randomUUID(); + const payload = { ...args, clientRequestId }; + + if (isSyncOnline) { + return await trackPendingNodeCreate(clientRequestId, createNode(payload)); + } + + const optimisticNodeId = addOptimisticNodeLocally(payload); + await enqueueSyncMutation("createNode", payload); + return optimisticNodeId; + }, + [addOptimisticNodeLocally, createNode, enqueueSyncMutation, isSyncOnline, trackPendingNodeCreate], + ); + + const runCreateNodeWithEdgeFromSourceOnlineOnly = useCallback( + async (args: Parameters[0]) => { + const clientRequestId = args.clientRequestId ?? crypto.randomUUID(); + const payload = { ...args, clientRequestId }; + const sourceNodeId = payload.sourceNodeId as string; + + controller.pendingConnectionCreatesRef.current.add(clientRequestId); + if (isSyncOnline && !isOptimisticNodeId(sourceNodeId)) { + return await trackPendingNodeCreate( + clientRequestId, + createNodeWithEdgeFromSource(payload), + ); + } + + const optimisticNodeId = addOptimisticNodeLocally(payload); + addOptimisticEdgeLocally({ + clientRequestId, + sourceNodeId: payload.sourceNodeId, + targetNodeId: optimisticNodeId, + sourceHandle: payload.sourceHandle, + targetHandle: payload.targetHandle, + }); + + if (isSyncOnline) { + try { + const realId = await trackPendingNodeCreate( + clientRequestId, + createNodeWithEdgeFromSourceRaw({ ...payload }), + ); + await remapOptimisticNodeLocally(clientRequestId, realId); + return realId; + } catch (error) { + removeOptimisticCreateLocally({ + clientRequestId, + removeNode: true, + removeEdge: true, + }); + throw error; + } + } + + await enqueueSyncMutation("createNodeWithEdgeFromSource", payload); + return optimisticNodeId; + }, + [ + addOptimisticEdgeLocally, + addOptimisticNodeLocally, + controller.pendingConnectionCreatesRef, + createNodeWithEdgeFromSource, + createNodeWithEdgeFromSourceRaw, + enqueueSyncMutation, + isSyncOnline, + remapOptimisticNodeLocally, + removeOptimisticCreateLocally, + trackPendingNodeCreate, + ], + ); + + const runCreateNodeWithEdgeToTargetOnlineOnly = useCallback( + async (args: Parameters[0]) => { + const clientRequestId = args.clientRequestId ?? crypto.randomUUID(); + const payload = { ...args, clientRequestId }; + const targetNodeId = payload.targetNodeId as string; + + controller.pendingConnectionCreatesRef.current.add(clientRequestId); + if (isSyncOnline && !isOptimisticNodeId(targetNodeId)) { + return await trackPendingNodeCreate( + clientRequestId, + createNodeWithEdgeToTarget(payload), + ); + } + + const optimisticNodeId = addOptimisticNodeLocally(payload); + addOptimisticEdgeLocally({ + clientRequestId, + sourceNodeId: optimisticNodeId, + targetNodeId: payload.targetNodeId, + sourceHandle: payload.sourceHandle, + targetHandle: payload.targetHandle, + }); + + if (isSyncOnline) { + try { + const realId = await trackPendingNodeCreate( + clientRequestId, + createNodeWithEdgeToTargetRaw({ ...payload }), + ); + await remapOptimisticNodeLocally(clientRequestId, realId); + return realId; + } catch (error) { + removeOptimisticCreateLocally({ + clientRequestId, + removeNode: true, + removeEdge: true, + }); + throw error; + } + } + + await enqueueSyncMutation("createNodeWithEdgeToTarget", payload); + return optimisticNodeId; + }, + [ + addOptimisticEdgeLocally, + addOptimisticNodeLocally, + controller.pendingConnectionCreatesRef, + createNodeWithEdgeToTarget, + createNodeWithEdgeToTargetRaw, + enqueueSyncMutation, + isSyncOnline, + remapOptimisticNodeLocally, + removeOptimisticCreateLocally, + trackPendingNodeCreate, + ], + ); + + const runCreateNodeWithEdgeSplitOnlineOnly = useCallback( + async (args: Parameters[0]) => { + const clientRequestId = args.clientRequestId ?? crypto.randomUUID(); + const payload = { ...args, clientRequestId }; + + if (isSyncOnline) { + return await createNodeWithEdgeSplitMut(payload); + } + + const optimisticNodeId = addOptimisticNodeLocally(payload); + const splitApplied = applyEdgeSplitLocally({ + clientRequestId, + splitEdgeId: payload.splitEdgeId, + middleNodeId: optimisticNodeId, + splitSourceHandle: payload.splitSourceHandle, + splitTargetHandle: payload.splitTargetHandle, + newNodeSourceHandle: payload.newNodeSourceHandle, + newNodeTargetHandle: payload.newNodeTargetHandle, + positionX: payload.positionX, + positionY: payload.positionY, + }); + + if (splitApplied) { + await enqueueSyncMutation("createNodeWithEdgeSplit", payload); + } else { + await enqueueSyncMutation("createNode", { + canvasId: payload.canvasId, + type: payload.type, + positionX: payload.positionX, + positionY: payload.positionY, + width: payload.width, + height: payload.height, + data: payload.data, + parentId: payload.parentId, + zIndex: payload.zIndex, + clientRequestId, + }); + } + + return optimisticNodeId; + }, + [addOptimisticNodeLocally, applyEdgeSplitLocally, createNodeWithEdgeSplitMut, enqueueSyncMutation, isSyncOnline], + ); + + const runBatchRemoveNodesMutation = useCallback( + async (args) => { + const ids = args.nodeIds.map((id) => id as string); + const optimisticNodeIds = ids.filter((id) => isOptimisticNodeId(id)); + const persistedNodeIds = ids.filter((id) => !isOptimisticNodeId(id)); + + const createClientRequestIds = optimisticNodeIds + .map((id) => clientRequestIdFromOptimisticNodeId(id)) + .filter((id): id is string => id !== null); + + if (createClientRequestIds.length > 0) { + if (isSyncOnline) { + for (const clientRequestId of createClientRequestIds) { + controller.pendingDeleteAfterCreateClientRequestIdsRef.current.add( + clientRequestId, + ); + } + } + + const droppedSync = await dropCanvasSyncOpsByClientRequestIds( + canvasId as string, + createClientRequestIds, + ); + const droppedLocal = dropCanvasOpsByClientRequestIds( + canvasId as string, + createClientRequestIds, + ); + for (const clientRequestId of createClientRequestIds) { + removeOptimisticCreateLocally({ + clientRequestId, + removeNode: true, + removeEdge: true, + }); + } + resolveCanvasOps(canvasId as string, droppedSync); + resolveCanvasOps(canvasId as string, droppedLocal); + } + + if (persistedNodeIds.length === 0) { + await refreshPendingSyncCount(); + return; + } + + const droppedSyncByNode = await dropCanvasSyncOpsByNodeIds( + canvasId as string, + persistedNodeIds, + ); + const droppedLocalByNode = dropCanvasOpsByNodeIds( + canvasId as string, + persistedNodeIds, + ); + resolveCanvasOps(canvasId as string, droppedSyncByNode); + resolveCanvasOps(canvasId as string, droppedLocalByNode); + + await enqueueSyncMutation("batchRemoveNodes", { + nodeIds: persistedNodeIds as Id<"nodes">[], + }); + }, + [ + canvasId, + controller.pendingDeleteAfterCreateClientRequestIdsRef, + enqueueSyncMutation, + isSyncOnline, + refreshPendingSyncCount, + removeOptimisticCreateLocally, + ], + ); + runBatchRemoveNodesMutationRef.current = runBatchRemoveNodesMutation; + + const runCreateEdgeMutation = useCallback( + async (args: Parameters[0]) => { + const clientRequestId = args.clientRequestId ?? crypto.randomUUID(); + const payload = { ...args, clientRequestId }; + + if (isSyncOnline) { + await createEdge(payload); + return; + } + + addOptimisticEdgeLocally({ + clientRequestId, + sourceNodeId: payload.sourceNodeId, + targetNodeId: payload.targetNodeId, + sourceHandle: payload.sourceHandle, + targetHandle: payload.targetHandle, + }); + await enqueueSyncMutation("createEdge", payload); + }, + [addOptimisticEdgeLocally, createEdge, enqueueSyncMutation, isSyncOnline], + ); + + const runRemoveEdgeMutation = useCallback( + async (args: { edgeId: Id<"edges"> }) => { + const edgeId = args.edgeId as string; + setEdges((current) => current.filter((edge) => edge.id !== edgeId)); + if (isOptimisticEdgeId(edgeId)) { + const clientRequestId = clientRequestIdFromOptimisticEdgeId(edgeId); + if (clientRequestId) { + const droppedSync = await dropCanvasSyncOpsByClientRequestIds( + canvasId as string, + [clientRequestId], + ); + const droppedLocal = dropCanvasOpsByClientRequestIds(canvasId as string, [ + clientRequestId, + ]); + resolveCanvasOps(canvasId as string, droppedSync); + resolveCanvasOps(canvasId as string, droppedLocal); + } + await refreshPendingSyncCount(); + return; + } + + const droppedSync = await dropCanvasSyncOpsByEdgeIds(canvasId as string, [ + edgeId, + ]); + const droppedLocal = dropCanvasOpsByEdgeIds(canvasId as string, [edgeId]); + resolveCanvasOps(canvasId as string, droppedSync); + resolveCanvasOps(canvasId as string, droppedLocal); + + await enqueueSyncMutation("removeEdge", { + edgeId: edgeId as Id<"edges">, + }); + }, + [canvasId, enqueueSyncMutation, refreshPendingSyncCount, setEdges], + ); + + const flushCanvasSyncQueue = useCallback(async () => { + if (!isSyncOnline) return; + if (syncInFlightRef.current) return; + syncInFlightRef.current = true; + setIsSyncing(true); + + try { + const now = Date.now(); + const expiredIds = await dropExpiredCanvasSyncOps(canvasId as string, now); + if (expiredIds.length > 0) { + resolveCanvasOps(canvasId as string, expiredIds); + toast.info( + "Lokale Änderungen verworfen", + `${expiredIds.length} ältere Offline-Änderungen (älter als 24h) wurden entfernt.`, + ); + } + + let permanentFailures = 0; + let processedInThisPass = 0; + + while (processedInThisPass < 500) { + const nowLoop = Date.now(); + const queue = await listCanvasSyncOps(canvasId as string); + const op = queue.find( + (entry) => entry.expiresAt > nowLoop && entry.nextRetryAt <= nowLoop, + ); + if (!op) break; + processedInThisPass += 1; + + try { + if (op.type === "createNode") { + const realId = await createNodeRaw( + op.payload as Parameters[0], + ); + await remapOptimisticNodeLocally(op.payload.clientRequestId, realId); + await controller.syncPendingMoveForClientRequest( + op.payload.clientRequestId, + realId, + ); + setEdgeSyncNonce((value) => value + 1); + } else if (op.type === "createNodeWithEdgeFromSource") { + const realId = await createNodeWithEdgeFromSourceRaw( + op.payload as Parameters[0], + ); + await remapOptimisticNodeLocally(op.payload.clientRequestId, realId); + await controller.syncPendingMoveForClientRequest( + op.payload.clientRequestId, + realId, + ); + setEdgeSyncNonce((value) => value + 1); + } else if (op.type === "createNodeWithEdgeToTarget") { + const realId = await createNodeWithEdgeToTargetRaw( + op.payload as Parameters[0], + ); + await remapOptimisticNodeLocally(op.payload.clientRequestId, realId); + await controller.syncPendingMoveForClientRequest( + op.payload.clientRequestId, + realId, + ); + setEdgeSyncNonce((value) => value + 1); + } else if (op.type === "createNodeWithEdgeSplit") { + const realId = await createNodeWithEdgeSplitRaw( + op.payload as Parameters[0], + ); + await remapOptimisticNodeLocally(op.payload.clientRequestId, realId); + await controller.syncPendingMoveForClientRequest( + op.payload.clientRequestId, + realId, + ); + setEdgeSyncNonce((value) => value + 1); + } else if (op.type === "createEdge") { + await createEdgeRaw(op.payload); + } else if (op.type === "removeEdge") { + await removeEdgeRaw(op.payload); + } else if (op.type === "batchRemoveNodes") { + await batchRemoveNodesRaw(op.payload); + } else if (op.type === "splitEdgeAtExistingNode") { + await splitEdgeAtExistingNodeRaw(op.payload); + setEdgeSyncNonce((value) => value + 1); + } else if (op.type === "moveNode") { + await moveNode(op.payload); + } else if (op.type === "resizeNode") { + if (process.env.NODE_ENV !== "production") { + console.info("[Canvas sync debug] resizeNode enqueue->flush", { + opId: op.id, + attemptCount: op.attemptCount, + ...summarizeResizePayload(op.payload), + }); + } + await resizeNode(op.payload); + } else if (op.type === "updateData") { + if (process.env.NODE_ENV !== "production") { + console.info("[Canvas sync debug] updateData enqueue->flush", { + opId: op.id, + attemptCount: op.attemptCount, + ...summarizeUpdateDataPayload(op.payload), + }); + } + await updateNodeData(op.payload); + } + + await ackCanvasSyncOp(op.id); + resolveCanvasOp(canvasId as string, op.id); + } catch (error: unknown) { + const transient = !isSyncOnline || isLikelyTransientSyncError(error); + if (transient) { + const backoffMs = Math.min( + 30_000, + 1000 * 2 ** Math.min(op.attemptCount, 5), + ); + await markCanvasSyncOpFailed(op.id, { + nextRetryAt: Date.now() + backoffMs, + lastError: getErrorMessage(error), + }); + break; + } + + permanentFailures += 1; + if (op.type === "createNode") { + removeOptimisticCreateLocally({ + clientRequestId: op.payload.clientRequestId, + removeNode: true, + }); + } else if ( + op.type === "createNodeWithEdgeFromSource" || + op.type === "createNodeWithEdgeToTarget" + ) { + removeOptimisticCreateLocally({ + clientRequestId: op.payload.clientRequestId, + removeNode: true, + removeEdge: true, + }); + } else if (op.type === "createNodeWithEdgeSplit") { + removeOptimisticCreateLocally({ + clientRequestId: op.payload.clientRequestId, + removeNode: true, + removeEdge: true, + }); + setEdgeSyncNonce((value) => value + 1); + } else if (op.type === "createEdge") { + removeOptimisticCreateLocally({ + clientRequestId: op.payload.clientRequestId, + removeEdge: true, + }); + } else if (op.type === "splitEdgeAtExistingNode") { + removeOptimisticCreateLocally({ + clientRequestId: op.payload.clientRequestId, + removeEdge: true, + }); + setEdgeSyncNonce((value) => value + 1); + } else if (op.type === "batchRemoveNodes") { + for (const nodeId of op.payload.nodeIds) { + deletingNodeIds.current.delete(nodeId as string); + } + } + await ackCanvasSyncOp(op.id); + resolveCanvasOp(canvasId as string, op.id); + } + } + + if (permanentFailures > 0) { + toast.warning( + "Einige Änderungen konnten nicht synchronisiert werden", + `${permanentFailures} lokale Änderungen wurden übersprungen.`, + ); + } + } finally { + syncInFlightRef.current = false; + setIsSyncing(false); + await refreshPendingSyncCount(); + } + }, [ + batchRemoveNodesRaw, + canvasId, + controller, + createEdgeRaw, + createNodeRaw, + createNodeWithEdgeFromSourceRaw, + createNodeWithEdgeSplitRaw, + createNodeWithEdgeToTargetRaw, + deletingNodeIds, + isSyncOnline, + moveNode, + refreshPendingSyncCount, + remapOptimisticNodeLocally, + removeEdgeRaw, + removeOptimisticCreateLocally, + resizeNode, + setEdgeSyncNonce, + splitEdgeAtExistingNodeRaw, + updateNodeData, + ]); + flushCanvasSyncQueueRef.current = flushCanvasSyncQueue; + + useEffect(() => { + const handleOnline = () => setIsBrowserOnline(true); + const handleOffline = () => setIsBrowserOnline(false); + window.addEventListener("online", handleOnline); + window.addEventListener("offline", handleOffline); + return () => { + window.removeEventListener("online", handleOnline); + window.removeEventListener("offline", handleOffline); + }; + }, []); + + useEffect(() => { + void refreshPendingSyncCount(); + }, [refreshPendingSyncCount]); + + useEffect(() => { + if (!isSyncOnline) return; + void flushCanvasSyncQueue(); + }, [flushCanvasSyncQueue, isSyncOnline]); + + useEffect(() => { + if (!isSyncOnline || pendingSyncCount <= 0) return; + const interval = window.setInterval(() => { + void flushCanvasSyncQueue(); + }, 5000); + return () => window.clearInterval(interval); + }, [flushCanvasSyncQueue, isSyncOnline, pendingSyncCount]); + + useEffect(() => { + const handleVisibilityOrFocus = () => { + if (!isSyncOnline) return; + void flushCanvasSyncQueue(); + }; + + window.addEventListener("focus", handleVisibilityOrFocus); + document.addEventListener("visibilitychange", handleVisibilityOrFocus); + return () => { + window.removeEventListener("focus", handleVisibilityOrFocus); + document.removeEventListener("visibilitychange", handleVisibilityOrFocus); + }; + }, [flushCanvasSyncQueue, isSyncOnline]); + + const notifyOfflineUnsupported = useCallback((label: string) => { + const now = Date.now(); + if (now - lastOfflineUnsupportedToastAtRef.current < 1500) return; + lastOfflineUnsupportedToastAtRef.current = now; + toast.warning( + "Offline aktuell nicht unterstützt", + `${label} ist aktuell nur online verfügbar.`, + ); + }, []); + + return { + status: { + pendingSyncCount, + isSyncing, + isBrowserOnline, + isSyncOnline, + }, + refs: { + pendingMoveAfterCreateRef: controller.pendingMoveAfterCreateRef, + pendingResizeAfterCreateRef: controller.pendingResizeAfterCreateRef, + pendingDataAfterCreateRef: controller.pendingDataAfterCreateRef, + resolvedRealIdByClientRequestRef: + controller.resolvedRealIdByClientRequestRef, + pendingEdgeSplitByClientRequestRef: + controller.pendingEdgeSplitByClientRequestRef, + pendingDeleteAfterCreateClientRequestIdsRef: + controller.pendingDeleteAfterCreateClientRequestIdsRef, + pendingConnectionCreatesRef: controller.pendingConnectionCreatesRef, + pendingLocalPositionUntilConvexMatchesRef: + controller.pendingLocalPositionUntilConvexMatchesRef, + preferLocalPositionNodeIdsRef: controller.preferLocalPositionNodeIdsRef, + pendingCreatePromiseByClientRequestRef, + }, + actions: { + createNode: runCreateNodeOnlineOnly, + createNodeWithEdgeFromSource: runCreateNodeWithEdgeFromSourceOnlineOnly, + createNodeWithEdgeToTarget: runCreateNodeWithEdgeToTargetOnlineOnly, + createNodeWithEdgeSplit: runCreateNodeWithEdgeSplitOnlineOnly, + moveNode: runMoveNodeMutation, + batchMoveNodes: runBatchMoveNodesMutation, + resizeNode: controller.queueNodeResize, + updateNodeData: controller.queueNodeDataUpdate, + batchRemoveNodes: runBatchRemoveNodesMutation, + createEdge: runCreateEdgeMutation, + removeEdge: runRemoveEdgeMutation, + splitEdgeAtExistingNode: runSplitEdgeAtExistingNodeMutation, + syncPendingMoveForClientRequest: controller.syncPendingMoveForClientRequest, + notifyOfflineUnsupported, + flushCanvasSyncQueue, + refreshPendingSyncCount, + remapOptimisticNodeLocally, + }, + }; +} diff --git a/vitest.config.ts b/vitest.config.ts index 242fccb..090d874 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -12,6 +12,7 @@ export default defineConfig({ include: [ "tests/**/*.test.ts", "components/canvas/__tests__/canvas-flow-reconciliation-helpers.test.ts", + "components/canvas/__tests__/use-canvas-sync-engine.test.ts", ], }, });