Enable offline canvas create sync with optimistic ID remapping

This commit is contained in:
Matthias
2026-04-01 10:19:50 +02:00
parent 32bd188d89
commit da576c1400
9 changed files with 904 additions and 57 deletions

View File

@@ -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<string>());
const syncPendingMoveForClientRequestRef = useRef<
(clientRequestId: string | undefined, realId?: Id<"nodes">) => Promise<void>
>(async () => {});
const enqueueSyncMutationRef = useRef<
<TType extends keyof CanvasSyncOpPayloadByType>(
type: TType,
payload: CanvasSyncOpPayloadByType[TType],
) => Promise<void>
>(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<string>());
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<RFNode[]>([]);
const [edges, setEdges] = useState<RFEdge[]>([]);
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<typeof createNode>[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<void> => {
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<typeof createNode>[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<typeof createNodeWithEdgeFromSource>[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<typeof createNodeWithEdgeToTarget>[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 <TType extends keyof CanvasSyncOpPayloadByType>(
@@ -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<typeof createEdge>[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<RFNode[]>([]);
const [edges, setEdges] = useState<RFEdge[]>([]);
const nodesRef = useRef<RFNode[]>(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<ConnectionDropMenuState | null>(null);
const connectionDropMenuRef = useRef<ConnectionDropMenuState | null>(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<string>());
// 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,
],
);

View File

@@ -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;

View File

@@ -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))

View File

@@ -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<Id<"edges"> | 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;
},
});

View File

@@ -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<AuthUser | null> {
const user = await authComponent.safeGetAuthUser(ctx);
if (!user) {
return null;
}
const userId = user.userId ?? String(user._id);
if (!userId) {
return null;
}
return { ...user, userId };
}

View File

@@ -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<Id<"nodes"> | 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<void> {
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;
},

View File

@@ -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
// ==========================================================================

View File

@@ -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;
}

View File

@@ -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<number> {
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;
}