Enable offline delete and reconnect queue sync

This commit is contained in:
Matthias
2026-04-01 10:37:20 +02:00
parent da576c1400
commit b6187210c7
7 changed files with 441 additions and 77 deletions

View File

@@ -12,11 +12,10 @@ import { computeBridgeCreatesForDeletedNodes } from "@/lib/canvas-utils";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import { msg, type CanvasNodeDeleteBlockReason } from "@/lib/toast-messages"; import { msg, type CanvasNodeDeleteBlockReason } from "@/lib/toast-messages";
import { getNodeDeleteBlockReason, isOptimisticEdgeId } from "./canvas-helpers"; import { getNodeDeleteBlockReason } from "./canvas-helpers";
type UseCanvasDeleteHandlersParams = { type UseCanvasDeleteHandlersParams = {
canvasId: Id<"canvases">; canvasId: Id<"canvases">;
isOffline: boolean;
nodes: RFNode[]; nodes: RFNode[];
edges: RFEdge[]; edges: RFEdge[];
deletingNodeIds: MutableRefObject<Set<string>>; deletingNodeIds: MutableRefObject<Set<string>>;
@@ -34,7 +33,6 @@ type UseCanvasDeleteHandlersParams = {
export function useCanvasDeleteHandlers({ export function useCanvasDeleteHandlers({
canvasId, canvasId,
isOffline,
nodes, nodes,
edges, edges,
deletingNodeIds, deletingNodeIds,
@@ -55,14 +53,6 @@ export function useCanvasDeleteHandlers({
nodes: RFNode[]; nodes: RFNode[];
edges: RFEdge[]; 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) { if (matchingNodes.length === 0) {
return true; return true;
} }
@@ -100,7 +90,7 @@ export function useCanvasDeleteHandlers({
return true; return true;
}, },
[isOffline], [],
); );
const onNodesDelete = useCallback( const onNodesDelete = useCallback(
@@ -171,9 +161,6 @@ export function useCanvasDeleteHandlers({
if (edge.className === "temp") { if (edge.className === "temp") {
continue; continue;
} }
if (isOptimisticEdgeId(edge.id)) {
continue;
}
void runRemoveEdgeMutation({ edgeId: edge.id as Id<"edges"> }).catch( void runRemoveEdgeMutation({ edgeId: edge.id as Id<"edges"> }).catch(
(error) => { (error) => {

View File

@@ -51,7 +51,7 @@ export function rfEdgeConnectionSignature(edge: RFEdge): string {
export function getNodeDeleteBlockReason( export function getNodeDeleteBlockReason(
node: RFNode, node: RFNode,
): CanvasNodeDeleteBlockReason | null { ): CanvasNodeDeleteBlockReason | null {
if (isOptimisticNodeId(node.id)) return "optimistic"; void node;
return null; return null;
} }

View File

@@ -1,36 +1,52 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useRef } from "react";
import type { Dispatch, MutableRefObject, SetStateAction } from "react"; import type { Dispatch, MutableRefObject, SetStateAction } from "react";
import { reconnectEdge, type Connection, type Edge as RFEdge } from "@xyflow/react"; import { reconnectEdge, type Connection, type Edge as RFEdge } from "@xyflow/react";
import type { Id } from "@/convex/_generated/dataModel"; import type { Id } from "@/convex/_generated/dataModel";
import { isOptimisticEdgeId } from "./canvas-helpers";
type UseCanvasReconnectHandlersParams = { type UseCanvasReconnectHandlersParams = {
canvasId: Id<"canvases">;
edgeReconnectSuccessful: MutableRefObject<boolean>; edgeReconnectSuccessful: MutableRefObject<boolean>;
isReconnectDragActiveRef: MutableRefObject<boolean>; isReconnectDragActiveRef: MutableRefObject<boolean>;
setEdges: Dispatch<SetStateAction<RFEdge[]>>; setEdges: Dispatch<SetStateAction<RFEdge[]>>;
runCreateEdgeMutation: (args: {
canvasId: Id<"canvases">;
sourceNodeId: Id<"nodes">;
targetNodeId: Id<"nodes">;
sourceHandle?: string;
targetHandle?: string;
}) => Promise<unknown>;
runRemoveEdgeMutation: (args: { edgeId: Id<"edges"> }) => Promise<unknown>; runRemoveEdgeMutation: (args: { edgeId: Id<"edges"> }) => Promise<unknown>;
}; };
export function useCanvasReconnectHandlers({ export function useCanvasReconnectHandlers({
canvasId,
edgeReconnectSuccessful, edgeReconnectSuccessful,
isReconnectDragActiveRef, isReconnectDragActiveRef,
setEdges, setEdges,
runCreateEdgeMutation,
runRemoveEdgeMutation, runRemoveEdgeMutation,
}: UseCanvasReconnectHandlersParams): { }: UseCanvasReconnectHandlersParams): {
onReconnectStart: () => void; onReconnectStart: () => void;
onReconnect: (oldEdge: RFEdge, newConnection: Connection) => void; onReconnect: (oldEdge: RFEdge, newConnection: Connection) => void;
onReconnectEnd: (_: MouseEvent | TouchEvent, edge: RFEdge) => void; onReconnectEnd: (_: MouseEvent | TouchEvent, edge: RFEdge) => void;
} { } {
const pendingReconnectRef = useRef<{
oldEdge: RFEdge;
newConnection: Connection;
} | null>(null);
const onReconnectStart = useCallback(() => { const onReconnectStart = useCallback(() => {
edgeReconnectSuccessful.current = false; edgeReconnectSuccessful.current = false;
isReconnectDragActiveRef.current = true; isReconnectDragActiveRef.current = true;
pendingReconnectRef.current = null;
}, [edgeReconnectSuccessful, isReconnectDragActiveRef]); }, [edgeReconnectSuccessful, isReconnectDragActiveRef]);
const onReconnect = useCallback( const onReconnect = useCallback(
(oldEdge: RFEdge, newConnection: Connection) => { (oldEdge: RFEdge, newConnection: Connection) => {
edgeReconnectSuccessful.current = true; edgeReconnectSuccessful.current = true;
pendingReconnectRef.current = { oldEdge, newConnection };
setEdges((currentEdges) => reconnectEdge(oldEdge, newConnection, currentEdges)); setEdges((currentEdges) => reconnectEdge(oldEdge, newConnection, currentEdges));
}, },
[edgeReconnectSuccessful, setEdges], [edgeReconnectSuccessful, setEdges],
@@ -40,6 +56,7 @@ export function useCanvasReconnectHandlers({
(_: MouseEvent | TouchEvent, edge: RFEdge) => { (_: MouseEvent | TouchEvent, edge: RFEdge) => {
try { try {
if (!edgeReconnectSuccessful.current) { if (!edgeReconnectSuccessful.current) {
pendingReconnectRef.current = null;
setEdges((currentEdges) => setEdges((currentEdges) =>
currentEdges.filter((candidate) => candidate.id !== edge.id), currentEdges.filter((candidate) => candidate.id !== edge.id),
); );
@@ -48,10 +65,6 @@ export function useCanvasReconnectHandlers({
return; return;
} }
if (isOptimisticEdgeId(edge.id)) {
return;
}
void runRemoveEdgeMutation({ edgeId: edge.id as Id<"edges"> }).catch( void runRemoveEdgeMutation({ edgeId: edge.id as Id<"edges"> }).catch(
(error) => { (error) => {
console.error("[Canvas edge remove failed] reconnect end", { 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; edgeReconnectSuccessful.current = true;
} finally { } finally {
isReconnectDragActiveRef.current = false; isReconnectDragActiveRef.current = false;
} }
}, },
[edgeReconnectSuccessful, isReconnectDragActiveRef, runRemoveEdgeMutation, setEdges], [
canvasId,
edgeReconnectSuccessful,
isReconnectDragActiveRef,
runCreateEdgeMutation,
runRemoveEdgeMutation,
setEdges,
],
); );
return { onReconnectStart, onReconnect, onReconnectEnd }; return { onReconnectStart, onReconnect, onReconnectEnd };

View File

@@ -32,6 +32,9 @@ import "@xyflow/react/dist/style.css";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import { msg } from "@/lib/toast-messages"; import { msg } from "@/lib/toast-messages";
import { import {
dropCanvasOpsByClientRequestIds,
dropCanvasOpsByEdgeIds,
dropCanvasOpsByNodeIds,
enqueueCanvasOp, enqueueCanvasOp,
readCanvasSnapshot, readCanvasSnapshot,
remapCanvasOpNodeId, remapCanvasOpNodeId,
@@ -43,6 +46,9 @@ import {
ackCanvasSyncOp, ackCanvasSyncOp,
type CanvasSyncOpPayloadByType, type CanvasSyncOpPayloadByType,
countCanvasSyncOps, countCanvasSyncOps,
dropCanvasSyncOpsByClientRequestIds,
dropCanvasSyncOpsByEdgeIds,
dropCanvasSyncOpsByNodeIds,
dropExpiredCanvasSyncOps, dropExpiredCanvasSyncOps,
enqueueCanvasSyncOp, enqueueCanvasSyncOp,
listCanvasSyncOps, listCanvasSyncOps,
@@ -404,31 +410,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
const createNodeWithEdgeSplit = useMutation(api.nodes.createWithEdgeSplit); 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<string>(
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( const createEdge = useMutation(api.edges.create).withOptimisticUpdate(
(localStore, args) => { (localStore, args) => {
const edgeList = localStore.getQuery(api.edges.list, { const edgeList = localStore.getQuery(api.edges.list, {
@@ -465,18 +446,8 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
api.nodes.createWithEdgeToTarget, api.nodes.createWithEdgeToTarget,
); );
const createEdgeRaw = useMutation(api.edges.create); const createEdgeRaw = useMutation(api.edges.create);
const batchRemoveNodesRaw = useMutation(api.nodes.batchRemove);
const removeEdge = useMutation(api.edges.remove).withOptimisticUpdate( const removeEdgeRaw = useMutation(api.edges.remove);
(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 [nodes, setNodes] = useState<RFNode[]>([]); const [nodes, setNodes] = useState<RFNode[]>([]);
const [edges, setEdges] = useState<RFEdge[]>([]); const [edges, setEdges] = useState<RFEdge[]>([]);
@@ -802,6 +773,10 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
setEdgeSyncNonce((value) => value + 1); setEdgeSyncNonce((value) => value + 1);
} else if (op.type === "createEdge") { } else if (op.type === "createEdge") {
await createEdgeRaw(op.payload); 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") { } else if (op.type === "moveNode") {
await moveNode(op.payload); await moveNode(op.payload);
} else if (op.type === "resizeNode") { } else if (op.type === "resizeNode") {
@@ -862,6 +837,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
await refreshPendingSyncCount(); await refreshPendingSyncCount();
} }
}, [ }, [
batchRemoveNodesRaw,
canvasId, canvasId,
createEdgeRaw, createEdgeRaw,
createNodeRaw, createNodeRaw,
@@ -871,6 +847,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
moveNode, moveNode,
refreshPendingSyncCount, refreshPendingSyncCount,
remapOptimisticNodeLocally, remapOptimisticNodeLocally,
removeEdgeRaw,
removeOptimisticCreateLocally, removeOptimisticCreateLocally,
resizeNode, resizeNode,
updateNodeData, updateNodeData,
@@ -968,14 +945,61 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
); );
const runBatchRemoveNodesMutation = useCallback( const runBatchRemoveNodesMutation = useCallback(
async (args: Parameters<typeof batchRemoveNodes>[0]) => { async (args: { nodeIds: Id<"nodes">[] }) => {
if (!isSyncOnline) { const ids = args.nodeIds.map((id) => id as string);
notifyOfflineUnsupported("Löschen"); 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; 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( const runCreateEdgeMutation = useCallback(
@@ -1001,14 +1025,37 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
); );
const runRemoveEdgeMutation = useCallback( const runRemoveEdgeMutation = useCallback(
async (args: Parameters<typeof removeEdge>[0]) => { async (args: { edgeId: Id<"edges"> }) => {
if (!isSyncOnline) { const edgeId = args.edgeId as string;
notifyOfflineUnsupported("Kante entfernen"); 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; 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( const splitEdgeAtExistingNodeMut = useMutation(
@@ -1322,7 +1369,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
const { onBeforeDelete, onNodesDelete, onEdgesDelete } = useCanvasDeleteHandlers({ const { onBeforeDelete, onNodesDelete, onEdgesDelete } = useCanvasDeleteHandlers({
canvasId, canvasId,
isOffline: !isSyncOnline,
nodes, nodes,
edges, edges,
deletingNodeIds, deletingNodeIds,
@@ -1333,9 +1379,11 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
}); });
const { onReconnectStart, onReconnect, onReconnectEnd } = useCanvasReconnectHandlers({ const { onReconnectStart, onReconnect, onReconnectEnd } = useCanvasReconnectHandlers({
canvasId,
edgeReconnectSuccessful, edgeReconnectSuccessful,
isReconnectDragActiveRef, isReconnectDragActiveRef,
setEdges, setEdges,
runCreateEdgeMutation,
runRemoveEdgeMutation, runRemoveEdgeMutation,
}); });
@@ -1349,7 +1397,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
if (!convexEdges) return; if (!convexEdges) return;
setEdges((prev) => { setEdges((prev) => {
const prevConvexSnap = convexNodeIdsSnapshotForEdgeCarryRef.current; const prevConvexSnap = convexNodeIdsSnapshotForEdgeCarryRef.current;
const currentConvexIdList = const currentConvexIdList: string[] =
convexNodes !== undefined convexNodes !== undefined
? convexNodes.map((n: Doc<"nodes">) => n._id as string) ? 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 tempEdges = prev.filter((e) => e.className === "temp");
const sourceTypeByNodeId = const sourceTypeByNodeId =
convexNodes !== undefined convexNodes !== undefined
? new Map( ? new Map<string, string>(
convexNodes.map((n: Doc<"nodes">) => [n._id as string, n.type]), convexNodes.map((n: Doc<"nodes">) => [n._id as string, n.type as string]),
) )
: undefined; : undefined;
const glowMode = resolvedTheme === "dark" ? "dark" : "light"; const glowMode = resolvedTheme === "dark" ? "dark" : "light";

View File

@@ -742,9 +742,18 @@ export const batchRemove = mutation({
const user = await requireAuth(ctx); const user = await requireAuth(ctx);
if (nodeIds.length === 0) return; if (nodeIds.length === 0) return;
// Canvas-Zugriff über den ersten Node prüfen // Idempotent: wenn alle Nodes bereits weg sind, no-op.
const firstNode = await ctx.db.get(nodeIds[0]); const firstExistingNode = await (async () => {
if (!firstNode) throw new Error("Node not found"); 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); await getCanvasOrThrow(ctx, firstNode.canvasId, user.userId);
for (const nodeId of nodeIds) { for (const nodeId of nodeIds) {

View File

@@ -182,6 +182,99 @@ export function readCanvasOps(canvasId: string): CanvasPendingOp[] {
return readOpsPayload(canvasId).ops; return readOpsPayload(canvasId).ops;
} }
function opTouchesNodeId(op: CanvasPendingOp, nodeIdSet: ReadonlySet<string>): 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<string>,
): boolean {
if (!isRecord(op.payload)) return false;
return (
typeof op.payload.clientRequestId === "string" &&
clientRequestIdSet.has(op.payload.clientRequestId)
);
}
function opTouchesEdgeId(op: CanvasPendingOp, edgeIdSet: ReadonlySet<string>): 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( function remapNodeIdInPayload(
payload: unknown, payload: unknown,
fromNodeId: string, fromNodeId: string,

View File

@@ -57,6 +57,12 @@ export type CanvasSyncOpPayloadByType = {
targetHandle?: string; targetHandle?: string;
clientRequestId: string; clientRequestId: string;
}; };
removeEdge: {
edgeId: Id<"edges">;
};
batchRemoveNodes: {
nodeIds: Id<"nodes">[];
};
moveNode: { nodeId: Id<"nodes">; positionX: number; positionY: number }; moveNode: { nodeId: Id<"nodes">; positionX: number; positionY: number };
resizeNode: { nodeId: Id<"nodes">; width: number; height: number }; resizeNode: { nodeId: Id<"nodes">; width: number; height: number };
updateData: { nodeId: Id<"nodes">; data: unknown }; updateData: { nodeId: Id<"nodes">; data: unknown };
@@ -210,6 +216,8 @@ function normalizeOp(raw: unknown): CanvasSyncOp | null {
type !== "createNodeWithEdgeFromSource" && type !== "createNodeWithEdgeFromSource" &&
type !== "createNodeWithEdgeToTarget" && type !== "createNodeWithEdgeToTarget" &&
type !== "createEdge" && type !== "createEdge" &&
type !== "removeEdge" &&
type !== "batchRemoveNodes" &&
type !== "moveNode" && type !== "moveNode" &&
type !== "resizeNode" && type !== "resizeNode" &&
type !== "updateData" 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 ( if (
type === "moveNode" && type === "moveNode" &&
typeof payload.nodeId === "string" && typeof payload.nodeId === "string" &&
@@ -713,6 +760,20 @@ function remapNodeIdInPayload(
return { ...op, payload: next }; 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; return op;
} }
@@ -747,3 +808,114 @@ export async function remapCanvasSyncNodeId(
await txDone(tx); await txDone(tx);
return changed; return changed;
} }
function opTouchesNodeId(op: CanvasSyncOp, nodeIdSet: ReadonlySet<string>): 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<string>): 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<string>): boolean {
if (op.type === "removeEdge") {
return edgeIdSet.has(op.payload.edgeId);
}
return false;
}
async function dropCanvasSyncOpsByPredicate(
canvasId: string,
predicate: (op: CanvasSyncOp) => boolean,
): Promise<string[]> {
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<string[]> {
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<string[]> {
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<string[]> {
if (edgeIds.length === 0) return [];
const edgeIdSet = new Set(edgeIds);
return await dropCanvasSyncOpsByPredicate(canvasId, (op) =>
opTouchesEdgeId(op, edgeIdSet),
);
}