Enable offline edge split sync and stabilize local edge state
This commit is contained in:
@@ -122,10 +122,10 @@ Compare-Node hat zusätzlich Handle-spezifische Farben (`left` → Blau, `right`
|
||||
|
||||
- Key-Schema: `lemonspace.canvas:snapshot:v1:<canvasId>` und `lemonspace.canvas:ops:v1:<canvasId>`
|
||||
- Snapshot = letzter bekannter State (Nodes + Edges) für schnellen initialen Render
|
||||
- Ops-Queue ist aktiv für: `createNode*`, `createEdge`, `moveNode`, `resizeNode`, `updateData`, `removeEdge`, `batchRemoveNodes`.
|
||||
- Ops-Queue ist aktiv für: `createNode*` (inkl. `createNodeWithEdgeSplit`), `createEdge`, `splitEdgeAtExistingNode`, `moveNode`, `resizeNode`, `updateData`, `removeEdge`, `batchRemoveNodes`.
|
||||
- Reconnect synchronisiert als `createEdge + removeEdge` (statt rein lokalem UI-Umbiegen).
|
||||
- ID-Handover `optimistic_* → realId` remappt Folge-Operationen in Queue + localStorage-Mirror, damit Verbindungen während/ nach Replay stabil bleiben.
|
||||
- Unsupported offline (weiterhin online-only): `createWithEdgeSplit`, Datei-Upload/Storage-Mutations, AI-Generierung.
|
||||
- Unsupported offline (weiterhin online-only): Datei-Upload/Storage-Mutations, AI-Generierung.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -366,6 +366,43 @@ function isBatchMoveNodesOpPayload(
|
||||
return record.moves.every(isMoveNodeOpPayload);
|
||||
}
|
||||
|
||||
function isSplitEdgeAtExistingNodeOpPayload(
|
||||
payload: unknown,
|
||||
): payload is {
|
||||
middleNodeId: Id<"nodes">;
|
||||
positionX?: number;
|
||||
positionY?: number;
|
||||
} {
|
||||
if (typeof payload !== "object" || payload === null) return false;
|
||||
const record = payload as Record<string, unknown>;
|
||||
if (typeof record.middleNodeId !== "string") return false;
|
||||
const hasPositionX =
|
||||
record.positionX === undefined || typeof record.positionX === "number";
|
||||
const hasPositionY =
|
||||
record.positionY === undefined || typeof record.positionY === "number";
|
||||
return hasPositionX && hasPositionY;
|
||||
}
|
||||
|
||||
function isRemoveEdgeOpPayload(
|
||||
payload: unknown,
|
||||
): payload is {
|
||||
edgeId: Id<"edges">;
|
||||
} {
|
||||
if (typeof payload !== "object" || payload === null) return false;
|
||||
const record = payload as Record<string, unknown>;
|
||||
return typeof record.edgeId === "string";
|
||||
}
|
||||
|
||||
function isSplitEdgeOpPayload(
|
||||
payload: unknown,
|
||||
): payload is {
|
||||
splitEdgeId: Id<"edges">;
|
||||
} {
|
||||
if (typeof payload !== "object" || payload === null) return false;
|
||||
const record = payload as Record<string, unknown>;
|
||||
return typeof record.splitEdgeId === "string";
|
||||
}
|
||||
|
||||
export function getPendingMovePinsFromLocalOps(
|
||||
canvasId: string,
|
||||
): Map<string, { x: number; y: number }> {
|
||||
@@ -385,11 +422,43 @@ export function getPendingMovePinsFromLocalOps(
|
||||
y: move.positionY,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
op.type === "splitEdgeAtExistingNode" &&
|
||||
isSplitEdgeAtExistingNodeOpPayload(op.payload) &&
|
||||
op.payload.positionX !== undefined &&
|
||||
op.payload.positionY !== undefined
|
||||
) {
|
||||
pins.set(op.payload.middleNodeId as string, {
|
||||
x: op.payload.positionX,
|
||||
y: op.payload.positionY,
|
||||
});
|
||||
}
|
||||
}
|
||||
return pins;
|
||||
}
|
||||
|
||||
export function getPendingRemovedEdgeIdsFromLocalOps(
|
||||
canvasId: string,
|
||||
): Set<string> {
|
||||
const edgeIds = new Set<string>();
|
||||
for (const op of readCanvasOps(canvasId)) {
|
||||
if (op.type === "removeEdge" && isRemoveEdgeOpPayload(op.payload)) {
|
||||
edgeIds.add(op.payload.edgeId as string);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
(op.type === "createNodeWithEdgeSplit" ||
|
||||
op.type === "splitEdgeAtExistingNode") &&
|
||||
isSplitEdgeOpPayload(op.payload)
|
||||
) {
|
||||
edgeIds.add(op.payload.splitEdgeId as string);
|
||||
}
|
||||
}
|
||||
return edgeIds;
|
||||
}
|
||||
|
||||
export function mergeNodesPreservingLocalState(
|
||||
previousNodes: RFNode[],
|
||||
incomingNodes: RFNode[],
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useStore, type Edge as RFEdge } from "@xyflow/react";
|
||||
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
import { NODE_DEFAULTS, NODE_HANDLE_MAP } from "@/lib/canvas-utils";
|
||||
import { isOptimisticEdgeId } from "./canvas-helpers";
|
||||
|
||||
type CreateNodeArgs = {
|
||||
canvasId: Id<"canvases">;
|
||||
@@ -40,6 +41,7 @@ type CreateNodeWithEdgeSplitArgs = {
|
||||
newNodeSourceHandle?: string;
|
||||
splitSourceHandle?: string;
|
||||
splitTargetHandle?: string;
|
||||
clientRequestId?: string;
|
||||
};
|
||||
|
||||
type CreateNodeWithEdgeFromSourceArgs = CreateNodeArgs & {
|
||||
@@ -143,7 +145,9 @@ function getIntersectedPersistedEdge(
|
||||
if (!edgeId) return undefined;
|
||||
|
||||
const edge = edges.find((candidate) => candidate.id === edgeId);
|
||||
if (!edge || edge.className === "temp") return undefined;
|
||||
if (!edge || edge.className === "temp" || isOptimisticEdgeId(edge.id)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return edge;
|
||||
}
|
||||
@@ -253,16 +257,11 @@ export function CanvasPlacementProvider({
|
||||
newNodeSourceHandle: normalizeHandle(handles.source),
|
||||
splitSourceHandle: normalizeHandle(hitEdge.sourceHandle),
|
||||
splitTargetHandle: normalizeHandle(hitEdge.targetHandle),
|
||||
...(clientRequestId !== undefined ? { clientRequestId } : {}),
|
||||
});
|
||||
notifySettled(realId);
|
||||
return realId;
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message === "offline-unsupported"
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
console.error("[Canvas placement] edge split failed", {
|
||||
edgeId: hitEdge.id,
|
||||
type,
|
||||
|
||||
@@ -105,6 +105,7 @@ import {
|
||||
getMiniMapNodeStrokeColor,
|
||||
getNodeCenterClientPosition,
|
||||
getIntersectedEdgeId,
|
||||
getPendingRemovedEdgeIdsFromLocalOps,
|
||||
getPendingMovePinsFromLocalOps,
|
||||
hasHandleKey,
|
||||
inferPendingConnectionNodeHandoff,
|
||||
@@ -408,7 +409,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
]);
|
||||
});
|
||||
|
||||
const createNodeWithEdgeSplit = useMutation(api.nodes.createWithEdgeSplit);
|
||||
const createNodeWithEdgeSplitMut = useMutation(api.nodes.createWithEdgeSplit);
|
||||
|
||||
const createEdge = useMutation(api.edges.create).withOptimisticUpdate(
|
||||
(localStore, args) => {
|
||||
@@ -445,12 +446,18 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
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<RFNode[]>([]);
|
||||
const [edges, setEdges] = useState<RFEdge[]>([]);
|
||||
const edgesRef = useRef(edges);
|
||||
edgesRef.current = edges;
|
||||
const [pendingSyncCount, setPendingSyncCount] = useState(0);
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
const [isBrowserOnline, setIsBrowserOnline] = useState(
|
||||
@@ -534,6 +541,84 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
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;
|
||||
@@ -555,8 +640,13 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
}
|
||||
|
||||
if (args.removeEdge) {
|
||||
const optimisticEdgePrefix = `${optimisticEdgeId}_`;
|
||||
setEdges((current) =>
|
||||
current.filter((edge) => edge.id !== optimisticEdgeId),
|
||||
current.filter(
|
||||
(edge) =>
|
||||
edge.id !== optimisticEdgeId &&
|
||||
!edge.id.startsWith(optimisticEdgePrefix),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -702,14 +792,47 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
);
|
||||
|
||||
const runCreateNodeWithEdgeSplitOnlineOnly = useCallback(
|
||||
async (args: Parameters<typeof createNodeWithEdgeSplit>[0]) => {
|
||||
if (!isSyncOnline) {
|
||||
notifyOfflineUnsupported("Kanten-Split");
|
||||
throw new Error("offline-unsupported");
|
||||
async (args: Parameters<typeof createNodeWithEdgeSplitMut>[0]) => {
|
||||
const clientRequestId = args.clientRequestId ?? crypto.randomUUID();
|
||||
const payload = { ...args, clientRequestId };
|
||||
|
||||
if (isSyncOnline) {
|
||||
return await createNodeWithEdgeSplitMut(payload);
|
||||
}
|
||||
return await createNodeWithEdgeSplit(args);
|
||||
|
||||
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;
|
||||
},
|
||||
[createNodeWithEdgeSplit, isSyncOnline, notifyOfflineUnsupported],
|
||||
[addOptimisticNodeLocally, applyEdgeSplitLocally, createNodeWithEdgeSplitMut, isSyncOnline],
|
||||
);
|
||||
|
||||
const refreshPendingSyncCount = useCallback(async () => {
|
||||
@@ -771,12 +894,23 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
realId,
|
||||
);
|
||||
setEdgeSyncNonce((value) => value + 1);
|
||||
} else if (op.type === "createNodeWithEdgeSplit") {
|
||||
const realId = await createNodeWithEdgeSplitRaw(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 === "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") {
|
||||
@@ -814,11 +948,24 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
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);
|
||||
}
|
||||
await ackCanvasSyncOp(op.id);
|
||||
resolveCanvasOp(canvasId as string, op.id);
|
||||
@@ -842,6 +989,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
createEdgeRaw,
|
||||
createNodeRaw,
|
||||
createNodeWithEdgeFromSourceRaw,
|
||||
createNodeWithEdgeSplitRaw,
|
||||
createNodeWithEdgeToTargetRaw,
|
||||
isSyncOnline,
|
||||
moveNode,
|
||||
@@ -850,6 +998,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
removeEdgeRaw,
|
||||
removeOptimisticCreateLocally,
|
||||
resizeNode,
|
||||
splitEdgeAtExistingNodeRaw,
|
||||
updateNodeData,
|
||||
]);
|
||||
|
||||
@@ -1124,13 +1273,34 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
|
||||
const runSplitEdgeAtExistingNodeMutation = useCallback(
|
||||
async (args: Parameters<typeof splitEdgeAtExistingNodeMut>[0]) => {
|
||||
if (!isSyncOnline) {
|
||||
notifyOfflineUnsupported("Kanten-Split");
|
||||
const clientRequestId = args.clientRequestId ?? crypto.randomUUID();
|
||||
const payload = { ...args, clientRequestId };
|
||||
if (isSyncOnline) {
|
||||
await splitEdgeAtExistingNodeMut(payload);
|
||||
return;
|
||||
}
|
||||
await splitEdgeAtExistingNodeMut(args);
|
||||
|
||||
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);
|
||||
},
|
||||
[isSyncOnline, notifyOfflineUnsupported, splitEdgeAtExistingNodeMut],
|
||||
[
|
||||
applyEdgeSplitLocally,
|
||||
enqueueSyncMutation,
|
||||
isSyncOnline,
|
||||
splitEdgeAtExistingNodeMut,
|
||||
],
|
||||
);
|
||||
|
||||
/** Freepik-Panel: State canvas-weit, damit es den optimistic_… → Real-ID-Wechsel überlebt. */
|
||||
@@ -1334,8 +1504,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
return { flowPanOnDrag: panMiddleRight, flowSelectionOnDrag: true };
|
||||
}, [scissorsMode, navTool]);
|
||||
|
||||
const edgesRef = useRef(edges);
|
||||
edgesRef.current = edges;
|
||||
const scissorsModeRef = useRef(scissorsMode);
|
||||
scissorsModeRef.current = scissorsMode;
|
||||
|
||||
@@ -1408,6 +1576,9 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
}
|
||||
|
||||
const tempEdges = prev.filter((e) => e.className === "temp");
|
||||
const pendingRemovedEdgeIds = getPendingRemovedEdgeIdsFromLocalOps(
|
||||
canvasId as string,
|
||||
);
|
||||
const sourceTypeByNodeId =
|
||||
convexNodes !== undefined
|
||||
? new Map<string, string>(
|
||||
@@ -1415,7 +1586,9 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
)
|
||||
: undefined;
|
||||
const glowMode = resolvedTheme === "dark" ? "dark" : "light";
|
||||
const mapped = convexEdges.map((edge: Doc<"edges">) =>
|
||||
const mapped = convexEdges
|
||||
.filter((edge: Doc<"edges">) => !pendingRemovedEdgeIds.has(edge._id as string))
|
||||
.map((edge: Doc<"edges">) =>
|
||||
sourceTypeByNodeId
|
||||
? convexEdgeToRFWithSourceGlow(
|
||||
edge,
|
||||
@@ -1562,7 +1735,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
|
||||
return [...mapped, ...carriedOptimistic, ...tempEdges];
|
||||
});
|
||||
}, [convexEdges, convexNodes, resolvedTheme, edgeSyncNonce]);
|
||||
}, [canvasId, convexEdges, convexNodes, resolvedTheme, edgeSyncNonce]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!convexNodes || isResizing.current) return;
|
||||
@@ -1776,7 +1949,10 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
}
|
||||
|
||||
const intersectedEdge = edges.find(
|
||||
(edge) => edge.id === intersectedEdgeId && edge.className !== "temp",
|
||||
(edge) =>
|
||||
edge.id === intersectedEdgeId &&
|
||||
edge.className !== "temp" &&
|
||||
!isOptimisticEdgeId(edge.id),
|
||||
);
|
||||
if (!intersectedEdge) {
|
||||
overlappedEdgeRef.current = null;
|
||||
@@ -1836,7 +2012,9 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
const intersectedEdge = intersectedEdgeId
|
||||
? edges.find(
|
||||
(edge) =>
|
||||
edge.id === intersectedEdgeId && edge.className !== "temp",
|
||||
edge.id === intersectedEdgeId &&
|
||||
edge.className !== "temp" &&
|
||||
!isOptimisticEdgeId(edge.id),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
|
||||
@@ -123,8 +123,9 @@ Wirft bei unauthentifiziertem Zugriff. Wird von allen Queries und Mutations genu
|
||||
|
||||
### Idempotente Canvas-Mutations
|
||||
|
||||
- `nodes.create`, `nodes.createWithEdgeFromSource`, `nodes.createWithEdgeToTarget` sind über `clientRequestId` idempotent.
|
||||
- `nodes.create`, `nodes.createWithEdgeSplit`, `nodes.createWithEdgeFromSource`, `nodes.createWithEdgeToTarget` sind über `clientRequestId` idempotent.
|
||||
- `edges.create` ist über `clientRequestId` idempotent.
|
||||
- `nodes.splitEdgeAtExistingNode` ist über `clientRequestId` idempotent (Replay wird als No-op behandelt).
|
||||
- `nodes.batchRemove` ist idempotent tolerant: wenn alle angefragten Nodes bereits entfernt sind, wird die Mutation als No-op beendet.
|
||||
|
||||
---
|
||||
|
||||
@@ -36,6 +36,7 @@ async function getCanvasIfAuthorized(
|
||||
|
||||
type NodeCreateMutationName =
|
||||
| "nodes.create"
|
||||
| "nodes.createWithEdgeSplit"
|
||||
| "nodes.createWithEdgeFromSource"
|
||||
| "nodes.createWithEdgeToTarget";
|
||||
|
||||
@@ -248,11 +249,22 @@ export const createWithEdgeSplit = mutation({
|
||||
newNodeSourceHandle: v.optional(v.string()),
|
||||
splitSourceHandle: v.optional(v.string()),
|
||||
splitTargetHandle: v.optional(v.string()),
|
||||
clientRequestId: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const user = await requireAuth(ctx);
|
||||
await getCanvasOrThrow(ctx, args.canvasId, user.userId);
|
||||
|
||||
const existingNodeId = await getIdempotentNodeCreateResult(ctx, {
|
||||
userId: user.userId,
|
||||
mutation: "nodes.createWithEdgeSplit",
|
||||
clientRequestId: args.clientRequestId,
|
||||
canvasId: args.canvasId,
|
||||
});
|
||||
if (existingNodeId) {
|
||||
return existingNodeId;
|
||||
}
|
||||
|
||||
const edge = await ctx.db.get(args.splitEdgeId);
|
||||
if (!edge || edge.canvasId !== args.canvasId) {
|
||||
throw new Error("Edge not found");
|
||||
@@ -290,6 +302,13 @@ export const createWithEdgeSplit = mutation({
|
||||
|
||||
await ctx.db.delete(args.splitEdgeId);
|
||||
await ctx.db.patch(args.canvasId, { updatedAt: Date.now() });
|
||||
await rememberIdempotentNodeCreateResult(ctx, {
|
||||
userId: user.userId,
|
||||
mutation: "nodes.createWithEdgeSplit",
|
||||
clientRequestId: args.clientRequestId,
|
||||
canvasId: args.canvasId,
|
||||
nodeId,
|
||||
});
|
||||
|
||||
return nodeId;
|
||||
},
|
||||
@@ -310,11 +329,34 @@ export const splitEdgeAtExistingNode = mutation({
|
||||
newNodeTargetHandle: v.optional(v.string()),
|
||||
positionX: v.optional(v.number()),
|
||||
positionY: v.optional(v.number()),
|
||||
clientRequestId: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const user = await requireAuth(ctx);
|
||||
await getCanvasOrThrow(ctx, args.canvasId, user.userId);
|
||||
|
||||
const existingMutationRecord =
|
||||
args.clientRequestId === undefined
|
||||
? null
|
||||
: await ctx.db
|
||||
.query("mutationRequests")
|
||||
.withIndex("by_user_mutation_request", (q) =>
|
||||
q
|
||||
.eq("userId", user.userId)
|
||||
.eq("mutation", "nodes.splitEdgeAtExistingNode")
|
||||
.eq("clientRequestId", args.clientRequestId!),
|
||||
)
|
||||
.first();
|
||||
if (existingMutationRecord) {
|
||||
if (
|
||||
existingMutationRecord.canvasId &&
|
||||
existingMutationRecord.canvasId !== args.canvasId
|
||||
) {
|
||||
throw new Error("Client request conflict");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const edge = await ctx.db.get(args.splitEdgeId);
|
||||
if (!edge || edge.canvasId !== args.canvasId) {
|
||||
throw new Error("Edge not found");
|
||||
@@ -360,6 +402,18 @@ export const splitEdgeAtExistingNode = mutation({
|
||||
|
||||
await ctx.db.delete(args.splitEdgeId);
|
||||
await ctx.db.patch(args.canvasId, { updatedAt: Date.now() });
|
||||
|
||||
if (args.clientRequestId) {
|
||||
await ctx.db.insert("mutationRequests", {
|
||||
userId: user.userId,
|
||||
mutation: "nodes.splitEdgeAtExistingNode",
|
||||
clientRequestId: args.clientRequestId,
|
||||
canvasId: args.canvasId,
|
||||
nodeId: args.middleNodeId,
|
||||
edgeId: args.splitEdgeId,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -190,7 +190,8 @@ function opTouchesNodeId(op: CanvasPendingOp, nodeIdSet: ReadonlySet<string>): b
|
||||
(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))
|
||||
(typeof payload.parentId === "string" && nodeIdSet.has(payload.parentId)) ||
|
||||
(typeof payload.middleNodeId === "string" && nodeIdSet.has(payload.middleNodeId))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
@@ -227,8 +228,10 @@ function opHasClientRequestId(
|
||||
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)
|
||||
(typeof op.payload.edgeId === "string" &&
|
||||
edgeIdSet.has(op.payload.edgeId)) ||
|
||||
(typeof op.payload.splitEdgeId === "string" &&
|
||||
edgeIdSet.has(op.payload.splitEdgeId))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -291,6 +294,10 @@ function remapNodeIdInPayload(
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (nextPayload.middleNodeId === fromNodeId) {
|
||||
nextPayload.middleNodeId = toNodeId;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const moves = nextPayload.moves;
|
||||
if (Array.isArray(moves)) {
|
||||
|
||||
@@ -49,6 +49,23 @@ export type CanvasSyncOpPayloadByType = {
|
||||
sourceHandle?: string;
|
||||
targetHandle?: string;
|
||||
};
|
||||
createNodeWithEdgeSplit: {
|
||||
canvasId: Id<"canvases">;
|
||||
type: string;
|
||||
positionX: number;
|
||||
positionY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
data: unknown;
|
||||
parentId?: Id<"nodes">;
|
||||
zIndex?: number;
|
||||
splitEdgeId: Id<"edges">;
|
||||
newNodeTargetHandle?: string;
|
||||
newNodeSourceHandle?: string;
|
||||
splitSourceHandle?: string;
|
||||
splitTargetHandle?: string;
|
||||
clientRequestId: string;
|
||||
};
|
||||
createEdge: {
|
||||
canvasId: Id<"canvases">;
|
||||
sourceNodeId: Id<"nodes">;
|
||||
@@ -63,6 +80,18 @@ export type CanvasSyncOpPayloadByType = {
|
||||
batchRemoveNodes: {
|
||||
nodeIds: Id<"nodes">[];
|
||||
};
|
||||
splitEdgeAtExistingNode: {
|
||||
canvasId: Id<"canvases">;
|
||||
splitEdgeId: Id<"edges">;
|
||||
middleNodeId: Id<"nodes">;
|
||||
splitSourceHandle?: string;
|
||||
splitTargetHandle?: string;
|
||||
newNodeSourceHandle?: string;
|
||||
newNodeTargetHandle?: string;
|
||||
positionX?: number;
|
||||
positionY?: number;
|
||||
clientRequestId: string;
|
||||
};
|
||||
moveNode: { nodeId: Id<"nodes">; positionX: number; positionY: number };
|
||||
resizeNode: { nodeId: Id<"nodes">; width: number; height: number };
|
||||
updateData: { nodeId: Id<"nodes">; data: unknown };
|
||||
@@ -215,9 +244,11 @@ function normalizeOp(raw: unknown): CanvasSyncOp | null {
|
||||
type !== "createNode" &&
|
||||
type !== "createNodeWithEdgeFromSource" &&
|
||||
type !== "createNodeWithEdgeToTarget" &&
|
||||
type !== "createNodeWithEdgeSplit" &&
|
||||
type !== "createEdge" &&
|
||||
type !== "removeEdge" &&
|
||||
type !== "batchRemoveNodes" &&
|
||||
type !== "splitEdgeAtExistingNode" &&
|
||||
type !== "moveNode" &&
|
||||
type !== "resizeNode" &&
|
||||
type !== "updateData"
|
||||
@@ -368,6 +399,61 @@ function normalizeOp(raw: unknown): CanvasSyncOp | null {
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
type === "createNodeWithEdgeSplit" &&
|
||||
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.splitEdgeId === "string" &&
|
||||
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,
|
||||
splitEdgeId: payload.splitEdgeId as Id<"edges">,
|
||||
newNodeTargetHandle:
|
||||
typeof payload.newNodeTargetHandle === "string"
|
||||
? payload.newNodeTargetHandle
|
||||
: undefined,
|
||||
newNodeSourceHandle:
|
||||
typeof payload.newNodeSourceHandle === "string"
|
||||
? payload.newNodeSourceHandle
|
||||
: undefined,
|
||||
splitSourceHandle:
|
||||
typeof payload.splitSourceHandle === "string"
|
||||
? payload.splitSourceHandle
|
||||
: undefined,
|
||||
splitTargetHandle:
|
||||
typeof payload.splitTargetHandle === "string"
|
||||
? payload.splitTargetHandle
|
||||
: undefined,
|
||||
clientRequestId: payload.clientRequestId,
|
||||
},
|
||||
enqueuedAt,
|
||||
attemptCount,
|
||||
nextRetryAt,
|
||||
expiresAt,
|
||||
lastError,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
type === "createEdge" &&
|
||||
typeof payload.canvasId === "string" &&
|
||||
@@ -440,6 +526,49 @@ function normalizeOp(raw: unknown): CanvasSyncOp | null {
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
type === "splitEdgeAtExistingNode" &&
|
||||
typeof payload.canvasId === "string" &&
|
||||
typeof payload.splitEdgeId === "string" &&
|
||||
typeof payload.middleNodeId === "string" &&
|
||||
typeof payload.clientRequestId === "string"
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
canvasId,
|
||||
type,
|
||||
payload: {
|
||||
canvasId: payload.canvasId as Id<"canvases">,
|
||||
splitEdgeId: payload.splitEdgeId as Id<"edges">,
|
||||
middleNodeId: payload.middleNodeId as Id<"nodes">,
|
||||
splitSourceHandle:
|
||||
typeof payload.splitSourceHandle === "string"
|
||||
? payload.splitSourceHandle
|
||||
: undefined,
|
||||
splitTargetHandle:
|
||||
typeof payload.splitTargetHandle === "string"
|
||||
? payload.splitTargetHandle
|
||||
: undefined,
|
||||
newNodeSourceHandle:
|
||||
typeof payload.newNodeSourceHandle === "string"
|
||||
? payload.newNodeSourceHandle
|
||||
: undefined,
|
||||
newNodeTargetHandle:
|
||||
typeof payload.newNodeTargetHandle === "string"
|
||||
? payload.newNodeTargetHandle
|
||||
: undefined,
|
||||
positionX: typeof payload.positionX === "number" ? payload.positionX : undefined,
|
||||
positionY: typeof payload.positionY === "number" ? payload.positionY : undefined,
|
||||
clientRequestId: payload.clientRequestId,
|
||||
},
|
||||
enqueuedAt,
|
||||
attemptCount,
|
||||
nextRetryAt,
|
||||
expiresAt,
|
||||
lastError,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
type === "moveNode" &&
|
||||
typeof payload.nodeId === "string" &&
|
||||
@@ -727,6 +856,18 @@ function remapNodeIdInPayload(
|
||||
return { ...op, payload: next };
|
||||
}
|
||||
}
|
||||
if (op.type === "createNodeWithEdgeSplit" && op.payload.parentId === fromNodeId) {
|
||||
return {
|
||||
...op,
|
||||
payload: { ...op.payload, parentId: toNodeId as Id<"nodes"> },
|
||||
};
|
||||
}
|
||||
if (op.type === "splitEdgeAtExistingNode" && op.payload.middleNodeId === fromNodeId) {
|
||||
return {
|
||||
...op,
|
||||
payload: { ...op.payload, middleNodeId: toNodeId as Id<"nodes"> },
|
||||
};
|
||||
}
|
||||
if (op.type === "moveNode" && op.payload.nodeId === fromNodeId) {
|
||||
return {
|
||||
...op,
|
||||
@@ -833,6 +974,12 @@ function opTouchesNodeId(op: CanvasSyncOp, nodeIdSet: ReadonlySet<string>): bool
|
||||
(op.payload.parentId !== undefined && nodeIdSet.has(op.payload.parentId))
|
||||
);
|
||||
}
|
||||
if (op.type === "createNodeWithEdgeSplit") {
|
||||
return op.payload.parentId !== undefined && nodeIdSet.has(op.payload.parentId);
|
||||
}
|
||||
if (op.type === "splitEdgeAtExistingNode") {
|
||||
return nodeIdSet.has(op.payload.middleNodeId);
|
||||
}
|
||||
if (op.type === "batchRemoveNodes") {
|
||||
return op.payload.nodeIds.some((nodeId) => nodeIdSet.has(nodeId));
|
||||
}
|
||||
@@ -852,6 +999,12 @@ function opHasClientRequestId(op: CanvasSyncOp, clientRequestIdSet: ReadonlySet<
|
||||
if (op.type === "createEdge") {
|
||||
return clientRequestIdSet.has(op.payload.clientRequestId);
|
||||
}
|
||||
if (op.type === "createNodeWithEdgeSplit") {
|
||||
return clientRequestIdSet.has(op.payload.clientRequestId);
|
||||
}
|
||||
if (op.type === "splitEdgeAtExistingNode") {
|
||||
return clientRequestIdSet.has(op.payload.clientRequestId);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -859,6 +1012,12 @@ function opTouchesEdgeId(op: CanvasSyncOp, edgeIdSet: ReadonlySet<string>): bool
|
||||
if (op.type === "removeEdge") {
|
||||
return edgeIdSet.has(op.payload.edgeId);
|
||||
}
|
||||
if (op.type === "createNodeWithEdgeSplit") {
|
||||
return edgeIdSet.has(op.payload.splitEdgeId);
|
||||
}
|
||||
if (op.type === "splitEdgeAtExistingNode") {
|
||||
return edgeIdSet.has(op.payload.splitEdgeId);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user