diff --git a/components/canvas/canvas.tsx b/components/canvas/canvas.tsx index 3c0d86f..14b4cb1 100644 --- a/components/canvas/canvas.tsx +++ b/components/canvas/canvas.tsx @@ -34,6 +34,7 @@ import { msg } from "@/lib/toast-messages"; import { enqueueCanvasOp, readCanvasSnapshot, + remapCanvasOpNodeId, resolveCanvasOp, resolveCanvasOps, writeCanvasSnapshot, @@ -46,6 +47,7 @@ import { enqueueCanvasSyncOp, listCanvasSyncOps, markCanvasSyncOpFailed, + remapCanvasSyncNodeId, } from "@/lib/canvas-op-queue"; import { @@ -229,6 +231,21 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { ); /** 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) => { @@ -419,7 +436,11 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { }); if (edgeList === undefined) return; - const tempId = `${OPTIMISTIC_EDGE_PREFIX}${Date.now()}_${Math.random().toString(36).slice(2, 11)}` as Id<"edges">; + 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(), @@ -436,6 +457,14 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { ); }, ); + const createNodeRaw = useMutation(api.nodes.create); + const createNodeWithEdgeFromSourceRaw = useMutation( + api.nodes.createWithEdgeFromSource, + ); + const createNodeWithEdgeToTargetRaw = useMutation( + api.nodes.createWithEdgeToTarget, + ); + const createEdgeRaw = useMutation(api.edges.create); const removeEdge = useMutation(api.edges.remove).withOptimisticUpdate( (localStore, args) => { @@ -449,6 +478,8 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { }, ); + const [nodes, setNodes] = useState([]); + const [edges, setEdges] = useState([]); const [pendingSyncCount, setPendingSyncCount] = useState(0); const [isSyncing, setIsSyncing] = useState(false); const [isBrowserOnline, setIsBrowserOnline] = useState( @@ -477,41 +508,226 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { lastOfflineUnsupportedToastAtRef.current = now; toast.warning( "Offline aktuell nicht unterstützt", - `${label} ist in Stufe 1 nur online verfügbar.`, + `${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: Id<"nodes">; + targetNodeId: Id<"nodes">; + 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 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) { + setEdges((current) => + current.filter((edge) => edge.id !== optimisticEdgeId), + ); + } + + pendingMoveAfterCreateRef.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; + + 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]); + const runCreateNodeOnlineOnly = useCallback( async (args: Parameters[0]) => { - if (!isSyncOnline) { - notifyOfflineUnsupported("Node erstellen"); - throw new Error("offline-unsupported"); + const clientRequestId = args.clientRequestId ?? crypto.randomUUID(); + const payload = { ...args, clientRequestId }; + + if (isSyncOnline) { + return await createNode(payload); } - return await createNode(args); + + const optimisticNodeId = addOptimisticNodeLocally(payload); + await enqueueSyncMutationRef.current("createNode", payload); + return optimisticNodeId; }, - [createNode, isSyncOnline, notifyOfflineUnsupported], + [addOptimisticNodeLocally, createNode, isSyncOnline], ); const runCreateNodeWithEdgeFromSourceOnlineOnly = useCallback( async (args: Parameters[0]) => { - if (!isSyncOnline) { - notifyOfflineUnsupported("Node mit Verbindung erstellen"); - throw new Error("offline-unsupported"); + const clientRequestId = args.clientRequestId ?? crypto.randomUUID(); + const payload = { ...args, clientRequestId }; + + pendingConnectionCreatesRef.current.add(clientRequestId); + if (isSyncOnline) { + return await createNodeWithEdgeFromSource(payload); } - return await createNodeWithEdgeFromSource(args); + + const optimisticNodeId = addOptimisticNodeLocally(payload); + addOptimisticEdgeLocally({ + clientRequestId, + sourceNodeId: payload.sourceNodeId, + targetNodeId: optimisticNodeId, + sourceHandle: payload.sourceHandle, + targetHandle: payload.targetHandle, + }); + await enqueueSyncMutationRef.current( + "createNodeWithEdgeFromSource", + payload, + ); + return optimisticNodeId; }, - [createNodeWithEdgeFromSource, isSyncOnline, notifyOfflineUnsupported], + [ + addOptimisticEdgeLocally, + addOptimisticNodeLocally, + createNodeWithEdgeFromSource, + isSyncOnline, + ], ); const runCreateNodeWithEdgeToTargetOnlineOnly = useCallback( async (args: Parameters[0]) => { - if (!isSyncOnline) { - notifyOfflineUnsupported("Node mit Verbindung erstellen"); - throw new Error("offline-unsupported"); + const clientRequestId = args.clientRequestId ?? crypto.randomUUID(); + const payload = { ...args, clientRequestId }; + + pendingConnectionCreatesRef.current.add(clientRequestId); + if (isSyncOnline) { + return await createNodeWithEdgeToTarget(payload); } - return await createNodeWithEdgeToTarget(args); + + const optimisticNodeId = addOptimisticNodeLocally(payload); + addOptimisticEdgeLocally({ + clientRequestId, + sourceNodeId: optimisticNodeId, + targetNodeId: payload.targetNodeId, + sourceHandle: payload.sourceHandle, + targetHandle: payload.targetHandle, + }); + await enqueueSyncMutationRef.current("createNodeWithEdgeToTarget", payload); + return optimisticNodeId; }, - [createNodeWithEdgeToTarget, isSyncOnline, notifyOfflineUnsupported], + [ + addOptimisticEdgeLocally, + addOptimisticNodeLocally, + createNodeWithEdgeToTarget, + isSyncOnline, + ], ); const runCreateNodeWithEdgeSplitOnlineOnly = useCallback( @@ -547,15 +763,46 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { ); } - const queue = await listCanvasSyncOps(canvasId as string); let permanentFailures = 0; + let processedInThisPass = 0; - for (const op of queue) { - if (op.expiresAt <= now) continue; - if (op.nextRetryAt > now) continue; + 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 === "moveNode") { + if (op.type === "createNode") { + const realId = await createNodeRaw(op.payload); + 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); + 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); + 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 === "moveNode") { await moveNode(op.payload); } else if (op.type === "resizeNode") { await resizeNode(op.payload); @@ -578,6 +825,26 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { } 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 === "createEdge") { + removeOptimisticCreateLocally({ + clientRequestId: op.payload.clientRequestId, + removeEdge: true, + }); + } await ackCanvasSyncOp(op.id); resolveCanvasOp(canvasId as string, op.id); } @@ -594,7 +861,20 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { setIsSyncing(false); await refreshPendingSyncCount(); } - }, [canvasId, isSyncOnline, moveNode, refreshPendingSyncCount, resizeNode, updateNodeData]); + }, [ + canvasId, + createEdgeRaw, + createNodeRaw, + createNodeWithEdgeFromSourceRaw, + createNodeWithEdgeToTargetRaw, + isSyncOnline, + moveNode, + refreshPendingSyncCount, + remapOptimisticNodeLocally, + removeOptimisticCreateLocally, + resizeNode, + updateNodeData, + ]); const enqueueSyncMutation = useCallback( async ( @@ -622,6 +902,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { }, [canvasId, flushCanvasSyncQueue, refreshPendingSyncCount], ); + enqueueSyncMutationRef.current = enqueueSyncMutation; useEffect(() => { void refreshPendingSyncCount(); @@ -699,13 +980,24 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { const runCreateEdgeMutation = useCallback( async (args: Parameters[0]) => { - if (!isSyncOnline) { - notifyOfflineUnsupported("Kante erstellen"); + const clientRequestId = args.clientRequestId ?? crypto.randomUUID(); + const payload = { ...args, clientRequestId }; + + if (isSyncOnline) { + await createEdge(payload); return; } - await createEdge(args); + + addOptimisticEdgeLocally({ + clientRequestId, + sourceNodeId: payload.sourceNodeId, + targetNodeId: payload.targetNodeId, + sourceHandle: payload.sourceHandle, + targetHandle: payload.targetHandle, + }); + await enqueueSyncMutation("createEdge", payload); }, - [createEdge, isSyncOnline, notifyOfflineUnsupported], + [addOptimisticEdgeLocally, createEdge, enqueueSyncMutation, isSyncOnline], ); const runRemoveEdgeMutation = useCallback( @@ -795,9 +1087,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { ); /** Freepik-Panel: State canvas-weit, damit es den optimistic_… → Real-ID-Wechsel überlebt. */ - const [assetBrowserTargetNodeId, setAssetBrowserTargetNodeId] = useState< - string | null - >(null); const assetBrowserTargetApi: AssetBrowserTargetApi = useMemo( () => ({ targetNodeId: assetBrowserTargetNodeId, @@ -816,6 +1105,9 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { if (!clientRequestId) return; if (realId !== undefined) { + if (isOptimisticNodeId(realId as string)) { + return; + } const optimisticNodeId = `${OPTIMISTIC_NODE_PREFIX}${clientRequestId}`; setAssetBrowserTargetNodeId((current) => current === optimisticNodeId ? (realId as string) : current, @@ -919,15 +1211,12 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { }, [canvasId, runMoveNodeMutation, runSplitEdgeAtExistingNodeMutation], ); + syncPendingMoveForClientRequestRef.current = syncPendingMoveForClientRequest; // ─── Lokaler State (für flüssiges Dragging) ─────────────────── - const [nodes, setNodes] = useState([]); - const [edges, setEdges] = useState([]); const nodesRef = useRef(nodes); nodesRef.current = nodes; const [hasHydratedLocalSnapshot, setHasHydratedLocalSnapshot] = useState(false); - /** Erzwingt Edge-Merge nach Mutation, falls clientRequestId→realId-Ref erst im Promise gesetzt wird. */ - const [edgeSyncNonce, setEdgeSyncNonce] = useState(0); const [connectionDropMenu, setConnectionDropMenu] = useState(null); const connectionDropMenuRef = useRef(null); @@ -1005,8 +1294,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { // Drag-Lock: während des Drags kein Convex-Override const isDragging = useRef(false); - /** Convex-Merge: Position nicht mit veraltetem Snapshot überschreiben (RF-`dragging` kommt oft verzögert). */ - const preferLocalPositionNodeIdsRef = useRef(new Set()); // Resize-Lock: kein Convex→lokal während aktiver Größenänderung (veraltete Maße überschreiben sonst den Resize) const isResizing = useRef(false); @@ -1717,10 +2004,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { const handleConnectionDropPick = useCallback( (template: CanvasNodeTemplate) => { - if (!isSyncOnline) { - notifyOfflineUnsupported("Node mit Verbindung erstellen"); - return; - } const ctx = connectionDropMenuRef.current; if (!ctx) return; @@ -1767,6 +2050,9 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { targetHandle: handles?.target ?? undefined, }) .then((realId) => { + if (isOptimisticNodeId(realId as string)) { + return; + } resolvedRealIdByClientRequestRef.current.set( clientRequestId, realId, @@ -1786,6 +2072,9 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { targetHandle: ctx.fromHandleId, }) .then((realId) => { + if (isOptimisticNodeId(realId as string)) { + return; + } resolvedRealIdByClientRequestRef.current.set( clientRequestId, realId, @@ -1801,8 +2090,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { }, [ canvasId, - isSyncOnline, - notifyOfflineUnsupported, runCreateNodeWithEdgeFromSourceOnlineOnly, runCreateNodeWithEdgeToTargetOnlineOnly, syncPendingMoveForClientRequest, @@ -1818,10 +2105,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { const onDrop = useCallback( async (event: React.DragEvent) => { event.preventDefault(); - if (!isSyncOnline) { - notifyOfflineUnsupported("Node erstellen"); - return; - } const rawData = event.dataTransfer.getData( "application/lemonspace-node-type", @@ -1829,6 +2112,10 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { if (!rawData) { const hasFiles = event.dataTransfer.files && event.dataTransfer.files.length > 0; if (hasFiles) { + if (!isSyncOnline) { + notifyOfflineUnsupported("Upload per Drag-and-drop"); + return; + } const file = event.dataTransfer.files[0]; if (file.type.startsWith("image/")) { try { @@ -1944,8 +2231,8 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { canvasId, generateUploadUrl, isSyncOnline, - notifyOfflineUnsupported, runCreateNodeOnlineOnly, + notifyOfflineUnsupported, syncPendingMoveForClientRequest, ], ); diff --git a/convex/canvases.ts b/convex/canvases.ts index 7221223..760366a 100644 --- a/convex/canvases.ts +++ b/convex/canvases.ts @@ -1,6 +1,6 @@ import { query, mutation } from "./_generated/server"; import { v } from "convex/values"; -import { requireAuth } from "./helpers"; +import { optionalAuth, requireAuth } from "./helpers"; // ============================================================================ // Queries @@ -27,7 +27,10 @@ export const list = query({ export const get = query({ args: { canvasId: v.id("canvases") }, handler: async (ctx, { canvasId }) => { - const user = await requireAuth(ctx); + const user = await optionalAuth(ctx); + if (!user) { + return null; + } const canvas = await ctx.db.get(canvasId); if (!canvas || canvas.ownerId !== user.userId) { return null; diff --git a/convex/credits.ts b/convex/credits.ts index 7de8bdb..3bb5e8b 100644 --- a/convex/credits.ts +++ b/convex/credits.ts @@ -1,6 +1,6 @@ import { query, mutation, internalMutation } from "./_generated/server"; import { v } from "convex/values"; -import { requireAuth } from "./helpers"; +import { optionalAuth, requireAuth } from "./helpers"; import { internal } from "./_generated/api"; // ============================================================================ @@ -58,7 +58,10 @@ export type Tier = keyof typeof TIER_CONFIG; export const getBalance = query({ args: {}, handler: async (ctx) => { - const user = await requireAuth(ctx); + const user = await optionalAuth(ctx); + if (!user) { + return { balance: 0, reserved: 0, available: 0, monthlyAllocation: 0 }; + } const balance = await ctx.db .query("creditBalances") .withIndex("by_user", (q) => q.eq("userId", user.userId)) diff --git a/convex/edges.ts b/convex/edges.ts index 3159b28..ad6e97d 100644 --- a/convex/edges.ts +++ b/convex/edges.ts @@ -1,6 +1,7 @@ import { query, mutation } from "./_generated/server"; import { v } from "convex/values"; import { requireAuth } from "./helpers"; +import type { Id } from "./_generated/dataModel"; // ============================================================================ // Queries @@ -39,6 +40,7 @@ export const create = mutation({ targetNodeId: v.id("nodes"), sourceHandle: v.optional(v.string()), targetHandle: v.optional(v.string()), + clientRequestId: v.optional(v.string()), }, handler: async (ctx, args) => { const user = await requireAuth(ctx); @@ -47,6 +49,31 @@ export const create = mutation({ throw new Error("Canvas not found"); } + const getExistingEdge = async (): Promise | null> => { + const clientRequestId = args.clientRequestId; + if (!clientRequestId) return null; + const existing = await ctx.db + .query("mutationRequests") + .withIndex("by_user_mutation_request", (q) => + q + .eq("userId", user.userId) + .eq("mutation", "edges.create") + .eq("clientRequestId", clientRequestId), + ) + .first(); + if (!existing) return null; + if (existing.canvasId && existing.canvasId !== args.canvasId) { + throw new Error("Client request conflict"); + } + if (!existing.edgeId) return null; + return existing.edgeId; + }; + + const existingEdgeId = await getExistingEdge(); + if (existingEdgeId) { + return existingEdgeId; + } + // Prüfen ob beide Nodes existieren und zum gleichen Canvas gehören const source = await ctx.db.get(args.sourceNodeId); const target = await ctx.db.get(args.targetNodeId); @@ -71,6 +98,16 @@ export const create = mutation({ }); await ctx.db.patch(args.canvasId, { updatedAt: Date.now() }); + if (args.clientRequestId) { + await ctx.db.insert("mutationRequests", { + userId: user.userId, + mutation: "edges.create", + clientRequestId: args.clientRequestId, + canvasId: args.canvasId, + edgeId, + createdAt: Date.now(), + }); + } return edgeId; }, }); diff --git a/convex/helpers.ts b/convex/helpers.ts index ad4c1c0..4db3984 100644 --- a/convex/helpers.ts +++ b/convex/helpers.ts @@ -38,6 +38,16 @@ export async function requireAuth( /** * Gibt den User zurück oder null — für optionale Auth-Checks (z.B. public Queries). */ -export async function optionalAuth(ctx: QueryCtx | MutationCtx) { - return await authComponent.safeGetAuthUser(ctx); +export async function optionalAuth( + ctx: QueryCtx | MutationCtx +): Promise { + const user = await authComponent.safeGetAuthUser(ctx); + if (!user) { + return null; + } + const userId = user.userId ?? String(user._id); + if (!userId) { + return null; + } + return { ...user, userId }; } diff --git a/convex/nodes.ts b/convex/nodes.ts index 3fdbc3c..464ae0e 100644 --- a/convex/nodes.ts +++ b/convex/nodes.ts @@ -34,6 +34,62 @@ async function getCanvasIfAuthorized( return canvas; } +type NodeCreateMutationName = + | "nodes.create" + | "nodes.createWithEdgeFromSource" + | "nodes.createWithEdgeToTarget"; + +async function getIdempotentNodeCreateResult( + ctx: MutationCtx, + args: { + userId: string; + mutation: NodeCreateMutationName; + clientRequestId?: string; + canvasId: Id<"canvases">; + }, +): Promise | null> { + const clientRequestId = args.clientRequestId; + if (!clientRequestId) return null; + + const existing = await ctx.db + .query("mutationRequests") + .withIndex("by_user_mutation_request", (q) => + q + .eq("userId", args.userId) + .eq("mutation", args.mutation) + .eq("clientRequestId", clientRequestId), + ) + .first(); + + if (!existing) return null; + if (existing.canvasId && existing.canvasId !== args.canvasId) { + throw new Error("Client request conflict"); + } + if (!existing.nodeId) return null; + return existing.nodeId; +} + +async function rememberIdempotentNodeCreateResult( + ctx: MutationCtx, + args: { + userId: string; + mutation: NodeCreateMutationName; + clientRequestId?: string; + canvasId: Id<"canvases">; + nodeId: Id<"nodes">; + }, +): Promise { + if (!args.clientRequestId) return; + await ctx.db.insert("mutationRequests", { + userId: args.userId, + mutation: args.mutation, + clientRequestId: args.clientRequestId, + canvasId: args.canvasId, + nodeId: args.nodeId, + createdAt: Date.now(), + }); +} + // ============================================================================ // Queries // ============================================================================ @@ -135,7 +191,15 @@ export const create = mutation({ const user = await requireAuth(ctx); await getCanvasOrThrow(ctx, args.canvasId, user.userId); - void args.clientRequestId; + const existingNodeId = await getIdempotentNodeCreateResult(ctx, { + userId: user.userId, + mutation: "nodes.create", + clientRequestId: args.clientRequestId, + canvasId: args.canvasId, + }); + if (existingNodeId) { + return existingNodeId; + } const nodeId = await ctx.db.insert("nodes", { canvasId: args.canvasId, @@ -153,6 +217,13 @@ export const create = mutation({ // Canvas updatedAt aktualisieren await ctx.db.patch(args.canvasId, { updatedAt: Date.now() }); + await rememberIdempotentNodeCreateResult(ctx, { + userId: user.userId, + mutation: "nodes.create", + clientRequestId: args.clientRequestId, + canvasId: args.canvasId, + nodeId, + }); return nodeId; }, @@ -315,7 +386,16 @@ export const createWithEdgeFromSource = mutation({ handler: async (ctx, args) => { const user = await requireAuth(ctx); await getCanvasOrThrow(ctx, args.canvasId, user.userId); - void args.clientRequestId; + + const existingNodeId = await getIdempotentNodeCreateResult(ctx, { + userId: user.userId, + mutation: "nodes.createWithEdgeFromSource", + clientRequestId: args.clientRequestId, + canvasId: args.canvasId, + }); + if (existingNodeId) { + return existingNodeId; + } const source = await ctx.db.get(args.sourceNodeId); if (!source || source.canvasId !== args.canvasId) { @@ -345,6 +425,13 @@ export const createWithEdgeFromSource = mutation({ }); await ctx.db.patch(args.canvasId, { updatedAt: Date.now() }); + await rememberIdempotentNodeCreateResult(ctx, { + userId: user.userId, + mutation: "nodes.createWithEdgeFromSource", + clientRequestId: args.clientRequestId, + canvasId: args.canvasId, + nodeId, + }); return nodeId; }, @@ -373,7 +460,16 @@ export const createWithEdgeToTarget = mutation({ handler: async (ctx, args) => { const user = await requireAuth(ctx); await getCanvasOrThrow(ctx, args.canvasId, user.userId); - void args.clientRequestId; + + const existingNodeId = await getIdempotentNodeCreateResult(ctx, { + userId: user.userId, + mutation: "nodes.createWithEdgeToTarget", + clientRequestId: args.clientRequestId, + canvasId: args.canvasId, + }); + if (existingNodeId) { + return existingNodeId; + } const target = await ctx.db.get(args.targetNodeId); if (!target || target.canvasId !== args.canvasId) { @@ -403,6 +499,13 @@ export const createWithEdgeToTarget = mutation({ }); await ctx.db.patch(args.canvasId, { updatedAt: Date.now() }); + await rememberIdempotentNodeCreateResult(ctx, { + userId: user.userId, + mutation: "nodes.createWithEdgeToTarget", + clientRequestId: args.clientRequestId, + canvasId: args.canvasId, + nodeId, + }); return nodeId; }, diff --git a/convex/schema.ts b/convex/schema.ts index 5501054..1360215 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -214,6 +214,20 @@ export default defineSchema({ .index("by_source", ["sourceNodeId"]) .index("by_target", ["targetNodeId"]), + mutationRequests: defineTable({ + userId: v.string(), + mutation: v.string(), + clientRequestId: v.string(), + canvasId: v.optional(v.id("canvases")), + nodeId: v.optional(v.id("nodes")), + edgeId: v.optional(v.id("edges")), + createdAt: v.number(), + }).index("by_user_mutation_request", [ + "userId", + "mutation", + "clientRequestId", + ]), + // ========================================================================== // Credit-System // ========================================================================== diff --git a/lib/canvas-local-persistence.ts b/lib/canvas-local-persistence.ts index 489f4ba..c7ae67d 100644 --- a/lib/canvas-local-persistence.ts +++ b/lib/canvas-local-persistence.ts @@ -181,3 +181,64 @@ export function resolveCanvasOps(canvasId: string, opIds: string[]): void { export function readCanvasOps(canvasId: string): CanvasPendingOp[] { return readOpsPayload(canvasId).ops; } + +function remapNodeIdInPayload( + payload: unknown, + fromNodeId: string, + toNodeId: string, +): { payload: unknown; changed: boolean } { + if (!isRecord(payload)) return { payload, changed: false }; + + let changed = false; + const nextPayload: JsonRecord = { ...payload }; + + for (const key of ["nodeId", "sourceNodeId", "targetNodeId", "parentId"] as const) { + if (nextPayload[key] === fromNodeId) { + nextPayload[key] = toNodeId; + changed = true; + } + } + + const moves = nextPayload.moves; + if (Array.isArray(moves)) { + const remappedMoves = moves.map((move) => { + if (!isRecord(move)) return move; + if (move.nodeId !== fromNodeId) return move; + changed = true; + return { + ...move, + nodeId: toNodeId, + }; + }); + nextPayload.moves = remappedMoves; + } + + return { payload: changed ? nextPayload : payload, changed }; +} + +export function remapCanvasOpNodeId( + canvasId: string, + fromNodeId: string, + toNodeId: string, +): number { + if (fromNodeId === toNodeId) return 0; + + const payload = readOpsPayload(canvasId); + let changedCount = 0; + + payload.ops = payload.ops.map((op) => { + const remapped = remapNodeIdInPayload(op.payload, fromNodeId, toNodeId); + if (!remapped.changed) return op; + changedCount += 1; + return { + ...op, + payload: remapped.payload, + }; + }); + + if (changedCount === 0) return 0; + + payload.updatedAt = Date.now(); + writePayload(opsKey(canvasId), payload); + return changedCount; +} diff --git a/lib/canvas-op-queue.ts b/lib/canvas-op-queue.ts index 18d06ff..3d4b7d4 100644 --- a/lib/canvas-op-queue.ts +++ b/lib/canvas-op-queue.ts @@ -7,6 +7,56 @@ const FALLBACK_STORAGE_KEY = "lemonspace.canvas:sync-fallback:v1"; export const CANVAS_SYNC_RETENTION_MS = 24 * 60 * 60 * 1000; export type CanvasSyncOpPayloadByType = { + createNode: { + canvasId: Id<"canvases">; + type: string; + positionX: number; + positionY: number; + width: number; + height: number; + data: unknown; + parentId?: Id<"nodes">; + zIndex?: number; + clientRequestId: string; + }; + createNodeWithEdgeFromSource: { + canvasId: Id<"canvases">; + type: string; + positionX: number; + positionY: number; + width: number; + height: number; + data: unknown; + parentId?: Id<"nodes">; + zIndex?: number; + clientRequestId: string; + sourceNodeId: Id<"nodes">; + sourceHandle?: string; + targetHandle?: string; + }; + createNodeWithEdgeToTarget: { + canvasId: Id<"canvases">; + type: string; + positionX: number; + positionY: number; + width: number; + height: number; + data: unknown; + parentId?: Id<"nodes">; + zIndex?: number; + clientRequestId: string; + targetNodeId: Id<"nodes">; + sourceHandle?: string; + targetHandle?: string; + }; + createEdge: { + canvasId: Id<"canvases">; + sourceNodeId: Id<"nodes">; + targetNodeId: Id<"nodes">; + sourceHandle?: string; + targetHandle?: string; + clientRequestId: string; + }; moveNode: { nodeId: Id<"nodes">; positionX: number; positionY: number }; resizeNode: { nodeId: Id<"nodes">; width: number; height: number }; updateData: { nodeId: Id<"nodes">; data: unknown }; @@ -156,7 +206,13 @@ function normalizeOp(raw: unknown): CanvasSyncOp | null { !id || typeof canvasId !== "string" || !canvasId || - (type !== "moveNode" && type !== "resizeNode" && type !== "updateData") + type !== "createNode" && + type !== "createNodeWithEdgeFromSource" && + type !== "createNodeWithEdgeToTarget" && + type !== "createEdge" && + type !== "moveNode" && + type !== "resizeNode" && + type !== "updateData" ) { return null; } @@ -173,6 +229,170 @@ function normalizeOp(raw: unknown): CanvasSyncOp | null { if (!isRecord(payload)) return null; + if ( + type === "createNode" && + typeof payload.canvasId === "string" && + typeof payload.type === "string" && + typeof payload.positionX === "number" && + typeof payload.positionY === "number" && + typeof payload.width === "number" && + typeof payload.height === "number" && + typeof payload.clientRequestId === "string" + ) { + return { + id, + canvasId, + type, + payload: { + canvasId: payload.canvasId as Id<"canvases">, + type: payload.type, + positionX: payload.positionX, + positionY: payload.positionY, + width: payload.width, + height: payload.height, + data: payload.data, + parentId: + typeof payload.parentId === "string" + ? (payload.parentId as Id<"nodes">) + : undefined, + zIndex: typeof payload.zIndex === "number" ? payload.zIndex : undefined, + clientRequestId: payload.clientRequestId, + }, + enqueuedAt, + attemptCount, + nextRetryAt, + expiresAt, + lastError, + }; + } + + if ( + type === "createNodeWithEdgeFromSource" && + typeof payload.canvasId === "string" && + typeof payload.type === "string" && + typeof payload.positionX === "number" && + typeof payload.positionY === "number" && + typeof payload.width === "number" && + typeof payload.height === "number" && + typeof payload.clientRequestId === "string" && + typeof payload.sourceNodeId === "string" + ) { + return { + id, + canvasId, + type, + payload: { + canvasId: payload.canvasId as Id<"canvases">, + type: payload.type, + positionX: payload.positionX, + positionY: payload.positionY, + width: payload.width, + height: payload.height, + data: payload.data, + parentId: + typeof payload.parentId === "string" + ? (payload.parentId as Id<"nodes">) + : undefined, + zIndex: typeof payload.zIndex === "number" ? payload.zIndex : undefined, + clientRequestId: payload.clientRequestId, + sourceNodeId: payload.sourceNodeId as Id<"nodes">, + sourceHandle: + typeof payload.sourceHandle === "string" + ? payload.sourceHandle + : undefined, + targetHandle: + typeof payload.targetHandle === "string" + ? payload.targetHandle + : undefined, + }, + enqueuedAt, + attemptCount, + nextRetryAt, + expiresAt, + lastError, + }; + } + + if ( + type === "createNodeWithEdgeToTarget" && + typeof payload.canvasId === "string" && + typeof payload.type === "string" && + typeof payload.positionX === "number" && + typeof payload.positionY === "number" && + typeof payload.width === "number" && + typeof payload.height === "number" && + typeof payload.clientRequestId === "string" && + typeof payload.targetNodeId === "string" + ) { + return { + id, + canvasId, + type, + payload: { + canvasId: payload.canvasId as Id<"canvases">, + type: payload.type, + positionX: payload.positionX, + positionY: payload.positionY, + width: payload.width, + height: payload.height, + data: payload.data, + parentId: + typeof payload.parentId === "string" + ? (payload.parentId as Id<"nodes">) + : undefined, + zIndex: typeof payload.zIndex === "number" ? payload.zIndex : undefined, + clientRequestId: payload.clientRequestId, + targetNodeId: payload.targetNodeId as Id<"nodes">, + sourceHandle: + typeof payload.sourceHandle === "string" + ? payload.sourceHandle + : undefined, + targetHandle: + typeof payload.targetHandle === "string" + ? payload.targetHandle + : undefined, + }, + enqueuedAt, + attemptCount, + nextRetryAt, + expiresAt, + lastError, + }; + } + + if ( + type === "createEdge" && + typeof payload.canvasId === "string" && + typeof payload.sourceNodeId === "string" && + typeof payload.targetNodeId === "string" && + typeof payload.clientRequestId === "string" + ) { + return { + id, + canvasId, + type, + payload: { + canvasId: payload.canvasId as Id<"canvases">, + sourceNodeId: payload.sourceNodeId as Id<"nodes">, + targetNodeId: payload.targetNodeId as Id<"nodes">, + sourceHandle: + typeof payload.sourceHandle === "string" + ? payload.sourceHandle + : undefined, + targetHandle: + typeof payload.targetHandle === "string" + ? payload.targetHandle + : undefined, + clientRequestId: payload.clientRequestId, + }, + enqueuedAt, + attemptCount, + nextRetryAt, + expiresAt, + lastError, + }; + } + if ( type === "moveNode" && typeof payload.nodeId === "string" && @@ -418,3 +638,112 @@ export async function dropExpiredCanvasSyncOps( await txDone(tx); return expiredIds; } + +function remapNodeIdInPayload( + op: CanvasSyncOp, + fromNodeId: string, + toNodeId: string, +): CanvasSyncOp { + if (op.type === "createNode" && op.payload.parentId === fromNodeId) { + return { + ...op, + payload: { ...op.payload, parentId: toNodeId as Id<"nodes"> }, + }; + } + if (op.type === "createNodeWithEdgeFromSource") { + let changed = false; + const next = { ...op.payload }; + if (next.parentId === fromNodeId) { + next.parentId = toNodeId as Id<"nodes">; + changed = true; + } + if (next.sourceNodeId === fromNodeId) { + next.sourceNodeId = toNodeId as Id<"nodes">; + changed = true; + } + if (changed) { + return { ...op, payload: next }; + } + } + if (op.type === "createNodeWithEdgeToTarget") { + let changed = false; + const next = { ...op.payload }; + if (next.parentId === fromNodeId) { + next.parentId = toNodeId as Id<"nodes">; + changed = true; + } + if (next.targetNodeId === fromNodeId) { + next.targetNodeId = toNodeId as Id<"nodes">; + changed = true; + } + if (changed) { + return { ...op, payload: next }; + } + } + if (op.type === "moveNode" && op.payload.nodeId === fromNodeId) { + return { + ...op, + payload: { ...op.payload, nodeId: toNodeId as Id<"nodes"> }, + }; + } + if (op.type === "resizeNode" && op.payload.nodeId === fromNodeId) { + return { + ...op, + payload: { ...op.payload, nodeId: toNodeId as Id<"nodes"> }, + }; + } + if (op.type === "updateData" && op.payload.nodeId === fromNodeId) { + return { + ...op, + payload: { ...op.payload, nodeId: toNodeId as Id<"nodes"> }, + }; + } + if (op.type === "createEdge") { + let changed = false; + const next = { ...op.payload }; + if (next.sourceNodeId === fromNodeId) { + next.sourceNodeId = toNodeId as Id<"nodes">; + changed = true; + } + if (next.targetNodeId === fromNodeId) { + next.targetNodeId = toNodeId as Id<"nodes">; + changed = true; + } + if (changed) { + return { ...op, payload: next }; + } + } + return op; +} + +export async function remapCanvasSyncNodeId( + canvasId: string, + fromNodeId: string, + toNodeId: string, +): Promise { + const queue = await listCanvasSyncOps(canvasId); + let changed = 0; + const nextOps = queue.map((entry) => { + const next = remapNodeIdInPayload(entry, fromNodeId, toNodeId); + if (next !== entry) changed += 1; + return next; + }); + if (changed === 0) return 0; + + const db = await openDb(); + if (!db) { + const fallback = readFallbackOps() + .filter((entry) => entry.canvasId !== canvasId) + .concat(nextOps); + writeFallbackOps(fallback); + return changed; + } + + const tx = db.transaction(STORE_NAME, "readwrite"); + const store = tx.objectStore(STORE_NAME); + for (const op of nextOps) { + store.put(op); + } + await txDone(tx); + return changed; +}