From b6187210c7096de46a4465a1098df77336434c55 Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 1 Apr 2026 10:37:20 +0200 Subject: [PATCH] Enable offline delete and reconnect queue sync --- components/canvas/canvas-delete-handlers.ts | 17 +- components/canvas/canvas-helpers.ts | 2 +- components/canvas/canvas-reconnect.ts | 69 +++++++- components/canvas/canvas.tsx | 150 +++++++++++------ convex/nodes.ts | 15 +- lib/canvas-local-persistence.ts | 93 +++++++++++ lib/canvas-op-queue.ts | 172 ++++++++++++++++++++ 7 files changed, 441 insertions(+), 77 deletions(-) diff --git a/components/canvas/canvas-delete-handlers.ts b/components/canvas/canvas-delete-handlers.ts index da68c5d..00cdc25 100644 --- a/components/canvas/canvas-delete-handlers.ts +++ b/components/canvas/canvas-delete-handlers.ts @@ -12,11 +12,10 @@ import { computeBridgeCreatesForDeletedNodes } from "@/lib/canvas-utils"; import { toast } from "@/lib/toast"; import { msg, type CanvasNodeDeleteBlockReason } from "@/lib/toast-messages"; -import { getNodeDeleteBlockReason, isOptimisticEdgeId } from "./canvas-helpers"; +import { getNodeDeleteBlockReason } from "./canvas-helpers"; type UseCanvasDeleteHandlersParams = { canvasId: Id<"canvases">; - isOffline: boolean; nodes: RFNode[]; edges: RFEdge[]; deletingNodeIds: MutableRefObject>; @@ -34,7 +33,6 @@ type UseCanvasDeleteHandlersParams = { export function useCanvasDeleteHandlers({ canvasId, - isOffline, nodes, edges, deletingNodeIds, @@ -55,14 +53,6 @@ export function useCanvasDeleteHandlers({ nodes: RFNode[]; edges: RFEdge[]; }) => { - if (isOffline && (matchingNodes.length > 0 || matchingEdges.length > 0)) { - toast.warning( - "Offline aktuell nicht unterstützt", - "Löschen ist in Stufe 1 nur online verfügbar.", - ); - return false; - } - if (matchingNodes.length === 0) { return true; } @@ -100,7 +90,7 @@ export function useCanvasDeleteHandlers({ return true; }, - [isOffline], + [], ); const onNodesDelete = useCallback( @@ -171,9 +161,6 @@ export function useCanvasDeleteHandlers({ if (edge.className === "temp") { continue; } - if (isOptimisticEdgeId(edge.id)) { - continue; - } void runRemoveEdgeMutation({ edgeId: edge.id as Id<"edges"> }).catch( (error) => { diff --git a/components/canvas/canvas-helpers.ts b/components/canvas/canvas-helpers.ts index 5974e3b..75645f9 100644 --- a/components/canvas/canvas-helpers.ts +++ b/components/canvas/canvas-helpers.ts @@ -51,7 +51,7 @@ export function rfEdgeConnectionSignature(edge: RFEdge): string { export function getNodeDeleteBlockReason( node: RFNode, ): CanvasNodeDeleteBlockReason | null { - if (isOptimisticNodeId(node.id)) return "optimistic"; + void node; return null; } diff --git a/components/canvas/canvas-reconnect.ts b/components/canvas/canvas-reconnect.ts index e58b80a..6c3a8ee 100644 --- a/components/canvas/canvas-reconnect.ts +++ b/components/canvas/canvas-reconnect.ts @@ -1,36 +1,52 @@ import { useCallback } from "react"; +import { useRef } from "react"; import type { Dispatch, MutableRefObject, SetStateAction } from "react"; import { reconnectEdge, type Connection, type Edge as RFEdge } from "@xyflow/react"; import type { Id } from "@/convex/_generated/dataModel"; -import { isOptimisticEdgeId } from "./canvas-helpers"; - type UseCanvasReconnectHandlersParams = { + canvasId: Id<"canvases">; edgeReconnectSuccessful: MutableRefObject; isReconnectDragActiveRef: MutableRefObject; setEdges: Dispatch>; + runCreateEdgeMutation: (args: { + canvasId: Id<"canvases">; + sourceNodeId: Id<"nodes">; + targetNodeId: Id<"nodes">; + sourceHandle?: string; + targetHandle?: string; + }) => Promise; runRemoveEdgeMutation: (args: { edgeId: Id<"edges"> }) => Promise; }; export function useCanvasReconnectHandlers({ + canvasId, edgeReconnectSuccessful, isReconnectDragActiveRef, setEdges, + runCreateEdgeMutation, runRemoveEdgeMutation, }: UseCanvasReconnectHandlersParams): { onReconnectStart: () => void; onReconnect: (oldEdge: RFEdge, newConnection: Connection) => void; onReconnectEnd: (_: MouseEvent | TouchEvent, edge: RFEdge) => void; } { + const pendingReconnectRef = useRef<{ + oldEdge: RFEdge; + newConnection: Connection; + } | null>(null); + const onReconnectStart = useCallback(() => { edgeReconnectSuccessful.current = false; isReconnectDragActiveRef.current = true; + pendingReconnectRef.current = null; }, [edgeReconnectSuccessful, isReconnectDragActiveRef]); const onReconnect = useCallback( (oldEdge: RFEdge, newConnection: Connection) => { edgeReconnectSuccessful.current = true; + pendingReconnectRef.current = { oldEdge, newConnection }; setEdges((currentEdges) => reconnectEdge(oldEdge, newConnection, currentEdges)); }, [edgeReconnectSuccessful, setEdges], @@ -40,6 +56,7 @@ export function useCanvasReconnectHandlers({ (_: MouseEvent | TouchEvent, edge: RFEdge) => { try { if (!edgeReconnectSuccessful.current) { + pendingReconnectRef.current = null; setEdges((currentEdges) => currentEdges.filter((candidate) => candidate.id !== edge.id), ); @@ -48,10 +65,6 @@ export function useCanvasReconnectHandlers({ return; } - if (isOptimisticEdgeId(edge.id)) { - return; - } - void runRemoveEdgeMutation({ edgeId: edge.id as Id<"edges"> }).catch( (error) => { console.error("[Canvas edge remove failed] reconnect end", { @@ -64,12 +77,54 @@ export function useCanvasReconnectHandlers({ }, ); } + + const pendingReconnect = pendingReconnectRef.current; + pendingReconnectRef.current = null; + if ( + pendingReconnect && + pendingReconnect.newConnection.source && + pendingReconnect.newConnection.target + ) { + void runCreateEdgeMutation({ + canvasId, + sourceNodeId: pendingReconnect.newConnection.source as Id<"nodes">, + targetNodeId: pendingReconnect.newConnection.target as Id<"nodes">, + sourceHandle: pendingReconnect.newConnection.sourceHandle ?? undefined, + targetHandle: pendingReconnect.newConnection.targetHandle ?? undefined, + }).catch((error) => { + console.error("[Canvas edge reconnect failed] create edge", { + oldEdgeId: pendingReconnect.oldEdge.id, + source: pendingReconnect.newConnection.source, + target: pendingReconnect.newConnection.target, + error: String(error), + }); + }); + + if (pendingReconnect.oldEdge.className !== "temp") { + void runRemoveEdgeMutation({ + edgeId: pendingReconnect.oldEdge.id as Id<"edges">, + }).catch((error) => { + console.error("[Canvas edge reconnect failed] remove old edge", { + oldEdgeId: pendingReconnect.oldEdge.id, + error: String(error), + }); + }); + } + } + edgeReconnectSuccessful.current = true; } finally { isReconnectDragActiveRef.current = false; } }, - [edgeReconnectSuccessful, isReconnectDragActiveRef, runRemoveEdgeMutation, setEdges], + [ + canvasId, + edgeReconnectSuccessful, + isReconnectDragActiveRef, + runCreateEdgeMutation, + runRemoveEdgeMutation, + setEdges, + ], ); return { onReconnectStart, onReconnect, onReconnectEnd }; diff --git a/components/canvas/canvas.tsx b/components/canvas/canvas.tsx index 14b4cb1..405ede6 100644 --- a/components/canvas/canvas.tsx +++ b/components/canvas/canvas.tsx @@ -32,6 +32,9 @@ import "@xyflow/react/dist/style.css"; import { toast } from "@/lib/toast"; import { msg } from "@/lib/toast-messages"; import { + dropCanvasOpsByClientRequestIds, + dropCanvasOpsByEdgeIds, + dropCanvasOpsByNodeIds, enqueueCanvasOp, readCanvasSnapshot, remapCanvasOpNodeId, @@ -43,6 +46,9 @@ import { ackCanvasSyncOp, type CanvasSyncOpPayloadByType, countCanvasSyncOps, + dropCanvasSyncOpsByClientRequestIds, + dropCanvasSyncOpsByEdgeIds, + dropCanvasSyncOpsByNodeIds, dropExpiredCanvasSyncOps, enqueueCanvasSyncOp, listCanvasSyncOps, @@ -404,31 +410,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { const createNodeWithEdgeSplit = useMutation(api.nodes.createWithEdgeSplit); - const batchRemoveNodes = useMutation(api.nodes.batchRemove).withOptimisticUpdate( - (localStore, args) => { - const nodeList = localStore.getQuery(api.nodes.list, { canvasId }); - const edgeList = localStore.getQuery(api.edges.list, { canvasId }); - if (nodeList === undefined || edgeList === undefined) return; - - const removeSet = new Set( - args.nodeIds.map((id: Id<"nodes">) => id as string), - ); - localStore.setQuery( - api.nodes.list, - { canvasId }, - nodeList.filter((n: Doc<"nodes">) => !removeSet.has(n._id)), - ); - localStore.setQuery( - api.edges.list, - { canvasId }, - edgeList.filter( - (e: Doc<"edges">) => - !removeSet.has(e.sourceNodeId) && !removeSet.has(e.targetNodeId), - ), - ); - }, - ); - const createEdge = useMutation(api.edges.create).withOptimisticUpdate( (localStore, args) => { const edgeList = localStore.getQuery(api.edges.list, { @@ -465,18 +446,8 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { api.nodes.createWithEdgeToTarget, ); const createEdgeRaw = useMutation(api.edges.create); - - const removeEdge = useMutation(api.edges.remove).withOptimisticUpdate( - (localStore, args) => { - const edgeList = localStore.getQuery(api.edges.list, { canvasId }); - if (edgeList === undefined) return; - localStore.setQuery( - api.edges.list, - { canvasId }, - edgeList.filter((e: Doc<"edges">) => e._id !== args.edgeId), - ); - }, - ); + const batchRemoveNodesRaw = useMutation(api.nodes.batchRemove); + const removeEdgeRaw = useMutation(api.edges.remove); const [nodes, setNodes] = useState([]); const [edges, setEdges] = useState([]); @@ -802,6 +773,10 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { 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 === "moveNode") { await moveNode(op.payload); } else if (op.type === "resizeNode") { @@ -862,6 +837,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { await refreshPendingSyncCount(); } }, [ + batchRemoveNodesRaw, canvasId, createEdgeRaw, createNodeRaw, @@ -871,6 +847,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { moveNode, refreshPendingSyncCount, remapOptimisticNodeLocally, + removeEdgeRaw, removeOptimisticCreateLocally, resizeNode, updateNodeData, @@ -968,14 +945,61 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { ); const runBatchRemoveNodesMutation = useCallback( - async (args: Parameters[0]) => { - if (!isSyncOnline) { - notifyOfflineUnsupported("Löschen"); + 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) { + 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; } - await batchRemoveNodes(args); + + 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">[], + }); }, - [batchRemoveNodes, isSyncOnline, notifyOfflineUnsupported], + [ + canvasId, + enqueueSyncMutation, + refreshPendingSyncCount, + removeOptimisticCreateLocally, + ], ); const runCreateEdgeMutation = useCallback( @@ -1001,14 +1025,37 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { ); const runRemoveEdgeMutation = useCallback( - async (args: Parameters[0]) => { - if (!isSyncOnline) { - notifyOfflineUnsupported("Kante entfernen"); + 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; } - await removeEdge(args); + + 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">, + }); }, - [isSyncOnline, notifyOfflineUnsupported, removeEdge], + [canvasId, enqueueSyncMutation, refreshPendingSyncCount], ); const splitEdgeAtExistingNodeMut = useMutation( @@ -1322,7 +1369,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { const { onBeforeDelete, onNodesDelete, onEdgesDelete } = useCanvasDeleteHandlers({ canvasId, - isOffline: !isSyncOnline, nodes, edges, deletingNodeIds, @@ -1333,9 +1379,11 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { }); const { onReconnectStart, onReconnect, onReconnectEnd } = useCanvasReconnectHandlers({ + canvasId, edgeReconnectSuccessful, isReconnectDragActiveRef, setEdges, + runCreateEdgeMutation, runRemoveEdgeMutation, }); @@ -1349,7 +1397,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { if (!convexEdges) return; setEdges((prev) => { const prevConvexSnap = convexNodeIdsSnapshotForEdgeCarryRef.current; - const currentConvexIdList = + const currentConvexIdList: string[] = convexNodes !== undefined ? convexNodes.map((n: Doc<"nodes">) => n._id as string) : []; @@ -1362,8 +1410,8 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { const tempEdges = prev.filter((e) => e.className === "temp"); const sourceTypeByNodeId = convexNodes !== undefined - ? new Map( - convexNodes.map((n: Doc<"nodes">) => [n._id as string, n.type]), + ? new Map( + convexNodes.map((n: Doc<"nodes">) => [n._id as string, n.type as string]), ) : undefined; const glowMode = resolvedTheme === "dark" ? "dark" : "light"; diff --git a/convex/nodes.ts b/convex/nodes.ts index 464ae0e..7a1bc39 100644 --- a/convex/nodes.ts +++ b/convex/nodes.ts @@ -742,9 +742,18 @@ export const batchRemove = mutation({ const user = await requireAuth(ctx); if (nodeIds.length === 0) return; - // Canvas-Zugriff über den ersten Node prüfen - const firstNode = await ctx.db.get(nodeIds[0]); - if (!firstNode) throw new Error("Node not found"); + // Idempotent: wenn alle Nodes bereits weg sind, no-op. + const firstExistingNode = await (async () => { + for (const nodeId of nodeIds) { + const node = await ctx.db.get(nodeId); + if (node) return node; + } + return null; + })(); + if (!firstExistingNode) return; + + // Canvas-Zugriff über den ersten vorhandenen Node prüfen + const firstNode = firstExistingNode; await getCanvasOrThrow(ctx, firstNode.canvasId, user.userId); for (const nodeId of nodeIds) { diff --git a/lib/canvas-local-persistence.ts b/lib/canvas-local-persistence.ts index c7ae67d..ee472e7 100644 --- a/lib/canvas-local-persistence.ts +++ b/lib/canvas-local-persistence.ts @@ -182,6 +182,99 @@ export function readCanvasOps(canvasId: string): CanvasPendingOp[] { return readOpsPayload(canvasId).ops; } +function opTouchesNodeId(op: CanvasPendingOp, nodeIdSet: ReadonlySet): boolean { + if (!isRecord(op.payload)) return false; + const payload = op.payload; + + if ( + (typeof payload.nodeId === "string" && nodeIdSet.has(payload.nodeId)) || + (typeof payload.sourceNodeId === "string" && nodeIdSet.has(payload.sourceNodeId)) || + (typeof payload.targetNodeId === "string" && nodeIdSet.has(payload.targetNodeId)) || + (typeof payload.parentId === "string" && nodeIdSet.has(payload.parentId)) + ) { + return true; + } + + if (Array.isArray(payload.nodeIds)) { + return payload.nodeIds.some( + (entry) => typeof entry === "string" && nodeIdSet.has(entry), + ); + } + + if (Array.isArray(payload.moves)) { + return payload.moves.some( + (move) => + isRecord(move) && + typeof move.nodeId === "string" && + nodeIdSet.has(move.nodeId), + ); + } + + return false; +} + +function opHasClientRequestId( + op: CanvasPendingOp, + clientRequestIdSet: ReadonlySet, +): boolean { + if (!isRecord(op.payload)) return false; + return ( + typeof op.payload.clientRequestId === "string" && + clientRequestIdSet.has(op.payload.clientRequestId) + ); +} + +function opTouchesEdgeId(op: CanvasPendingOp, edgeIdSet: ReadonlySet): boolean { + if (!isRecord(op.payload)) return false; + return ( + typeof op.payload.edgeId === "string" && + edgeIdSet.has(op.payload.edgeId) + ); +} + +function dropCanvasOpsByPredicate( + canvasId: string, + predicate: (op: CanvasPendingOp) => boolean, +): string[] { + const payload = readOpsPayload(canvasId); + const idsToDrop = payload.ops.filter(predicate).map((op) => op.id); + if (idsToDrop.length === 0) return []; + const idSet = new Set(idsToDrop); + payload.ops = payload.ops.filter((op) => !idSet.has(op.id)); + payload.updatedAt = Date.now(); + writePayload(opsKey(canvasId), payload); + return idsToDrop; +} + +export function dropCanvasOpsByNodeIds( + canvasId: string, + nodeIds: string[], +): string[] { + if (nodeIds.length === 0) return []; + const nodeIdSet = new Set(nodeIds); + return dropCanvasOpsByPredicate(canvasId, (op) => opTouchesNodeId(op, nodeIdSet)); +} + +export function dropCanvasOpsByClientRequestIds( + canvasId: string, + clientRequestIds: string[], +): string[] { + if (clientRequestIds.length === 0) return []; + const clientRequestIdSet = new Set(clientRequestIds); + return dropCanvasOpsByPredicate(canvasId, (op) => + opHasClientRequestId(op, clientRequestIdSet), + ); +} + +export function dropCanvasOpsByEdgeIds( + canvasId: string, + edgeIds: string[], +): string[] { + if (edgeIds.length === 0) return []; + const edgeIdSet = new Set(edgeIds); + return dropCanvasOpsByPredicate(canvasId, (op) => opTouchesEdgeId(op, edgeIdSet)); +} + function remapNodeIdInPayload( payload: unknown, fromNodeId: string, diff --git a/lib/canvas-op-queue.ts b/lib/canvas-op-queue.ts index 3d4b7d4..866b0cc 100644 --- a/lib/canvas-op-queue.ts +++ b/lib/canvas-op-queue.ts @@ -57,6 +57,12 @@ export type CanvasSyncOpPayloadByType = { targetHandle?: string; clientRequestId: string; }; + removeEdge: { + edgeId: Id<"edges">; + }; + batchRemoveNodes: { + nodeIds: Id<"nodes">[]; + }; moveNode: { nodeId: Id<"nodes">; positionX: number; positionY: number }; resizeNode: { nodeId: Id<"nodes">; width: number; height: number }; updateData: { nodeId: Id<"nodes">; data: unknown }; @@ -210,6 +216,8 @@ function normalizeOp(raw: unknown): CanvasSyncOp | null { type !== "createNodeWithEdgeFromSource" && type !== "createNodeWithEdgeToTarget" && type !== "createEdge" && + type !== "removeEdge" && + type !== "batchRemoveNodes" && type !== "moveNode" && type !== "resizeNode" && type !== "updateData" @@ -393,6 +401,45 @@ function normalizeOp(raw: unknown): CanvasSyncOp | null { }; } + if ( + type === "removeEdge" && + typeof payload.edgeId === "string" + ) { + return { + id, + canvasId, + type, + payload: { + edgeId: payload.edgeId as Id<"edges">, + }, + enqueuedAt, + attemptCount, + nextRetryAt, + expiresAt, + lastError, + }; + } + + if ( + type === "batchRemoveNodes" && + Array.isArray(payload.nodeIds) && + payload.nodeIds.every((entry) => typeof entry === "string") + ) { + return { + id, + canvasId, + type, + payload: { + nodeIds: payload.nodeIds as Id<"nodes">[], + }, + enqueuedAt, + attemptCount, + nextRetryAt, + expiresAt, + lastError, + }; + } + if ( type === "moveNode" && typeof payload.nodeId === "string" && @@ -713,6 +760,20 @@ function remapNodeIdInPayload( return { ...op, payload: next }; } } + if (op.type === "batchRemoveNodes") { + if (!op.payload.nodeIds.includes(fromNodeId as Id<"nodes">)) { + return op; + } + return { + ...op, + payload: { + ...op.payload, + nodeIds: op.payload.nodeIds.map((nodeId) => + nodeId === fromNodeId ? (toNodeId as Id<"nodes">) : nodeId, + ), + }, + }; + } return op; } @@ -747,3 +808,114 @@ export async function remapCanvasSyncNodeId( await txDone(tx); return changed; } + +function opTouchesNodeId(op: CanvasSyncOp, nodeIdSet: ReadonlySet): boolean { + if (op.type === "moveNode" || op.type === "resizeNode" || op.type === "updateData") { + return nodeIdSet.has(op.payload.nodeId); + } + if (op.type === "createEdge") { + return ( + nodeIdSet.has(op.payload.sourceNodeId) || nodeIdSet.has(op.payload.targetNodeId) + ); + } + if (op.type === "createNode") { + return op.payload.parentId !== undefined && nodeIdSet.has(op.payload.parentId); + } + if (op.type === "createNodeWithEdgeFromSource") { + return ( + nodeIdSet.has(op.payload.sourceNodeId) || + (op.payload.parentId !== undefined && nodeIdSet.has(op.payload.parentId)) + ); + } + if (op.type === "createNodeWithEdgeToTarget") { + return ( + nodeIdSet.has(op.payload.targetNodeId) || + (op.payload.parentId !== undefined && nodeIdSet.has(op.payload.parentId)) + ); + } + if (op.type === "batchRemoveNodes") { + return op.payload.nodeIds.some((nodeId) => nodeIdSet.has(nodeId)); + } + return false; +} + +function opHasClientRequestId(op: CanvasSyncOp, clientRequestIdSet: ReadonlySet): boolean { + if (op.type === "createNode") { + return clientRequestIdSet.has(op.payload.clientRequestId); + } + if (op.type === "createNodeWithEdgeFromSource") { + return clientRequestIdSet.has(op.payload.clientRequestId); + } + if (op.type === "createNodeWithEdgeToTarget") { + return clientRequestIdSet.has(op.payload.clientRequestId); + } + if (op.type === "createEdge") { + return clientRequestIdSet.has(op.payload.clientRequestId); + } + return false; +} + +function opTouchesEdgeId(op: CanvasSyncOp, edgeIdSet: ReadonlySet): boolean { + if (op.type === "removeEdge") { + return edgeIdSet.has(op.payload.edgeId); + } + return false; +} + +async function dropCanvasSyncOpsByPredicate( + canvasId: string, + predicate: (op: CanvasSyncOp) => boolean, +): Promise { + const all = await listCanvasSyncOps(canvasId); + const idsToDrop = all.filter(predicate).map((entry) => entry.id); + if (idsToDrop.length === 0) return []; + + const idSet = new Set(idsToDrop); + const db = await openDb(); + if (!db) { + const fallback = readFallbackOps().filter((entry) => !idSet.has(entry.id)); + writeFallbackOps(fallback); + return idsToDrop; + } + + const tx = db.transaction(STORE_NAME, "readwrite"); + const store = tx.objectStore(STORE_NAME); + for (const id of idsToDrop) { + store.delete(id); + } + await txDone(tx); + return idsToDrop; +} + +export async function dropCanvasSyncOpsByNodeIds( + canvasId: string, + nodeIds: string[], +): Promise { + if (nodeIds.length === 0) return []; + const nodeIdSet = new Set(nodeIds); + return await dropCanvasSyncOpsByPredicate(canvasId, (op) => + opTouchesNodeId(op, nodeIdSet), + ); +} + +export async function dropCanvasSyncOpsByClientRequestIds( + canvasId: string, + clientRequestIds: string[], +): Promise { + if (clientRequestIds.length === 0) return []; + const idSet = new Set(clientRequestIds); + return await dropCanvasSyncOpsByPredicate(canvasId, (op) => + opHasClientRequestId(op, idSet), + ); +} + +export async function dropCanvasSyncOpsByEdgeIds( + canvasId: string, + edgeIds: string[], +): Promise { + if (edgeIds.length === 0) return []; + const edgeIdSet = new Set(edgeIds); + return await dropCanvasSyncOpsByPredicate(canvasId, (op) => + opTouchesEdgeId(op, edgeIdSet), + ); +}