Enable offline canvas create sync with optimistic ID remapping
This commit is contained in:
@@ -34,6 +34,7 @@ import { msg } from "@/lib/toast-messages";
|
|||||||
import {
|
import {
|
||||||
enqueueCanvasOp,
|
enqueueCanvasOp,
|
||||||
readCanvasSnapshot,
|
readCanvasSnapshot,
|
||||||
|
remapCanvasOpNodeId,
|
||||||
resolveCanvasOp,
|
resolveCanvasOp,
|
||||||
resolveCanvasOps,
|
resolveCanvasOps,
|
||||||
writeCanvasSnapshot,
|
writeCanvasSnapshot,
|
||||||
@@ -46,6 +47,7 @@ import {
|
|||||||
enqueueCanvasSyncOp,
|
enqueueCanvasSyncOp,
|
||||||
listCanvasSyncOps,
|
listCanvasSyncOps,
|
||||||
markCanvasSyncOpFailed,
|
markCanvasSyncOpFailed,
|
||||||
|
remapCanvasSyncNodeId,
|
||||||
} from "@/lib/canvas-op-queue";
|
} from "@/lib/canvas-op-queue";
|
||||||
|
|
||||||
import {
|
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. */
|
/** 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 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(
|
const createNode = useMutation(api.nodes.create).withOptimisticUpdate(
|
||||||
(localStore, args) => {
|
(localStore, args) => {
|
||||||
@@ -419,7 +436,11 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
});
|
});
|
||||||
if (edgeList === undefined) return;
|
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"> = {
|
const synthetic: Doc<"edges"> = {
|
||||||
_id: tempId,
|
_id: tempId,
|
||||||
_creationTime: Date.now(),
|
_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(
|
const removeEdge = useMutation(api.edges.remove).withOptimisticUpdate(
|
||||||
(localStore, args) => {
|
(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 [pendingSyncCount, setPendingSyncCount] = useState(0);
|
||||||
const [isSyncing, setIsSyncing] = useState(false);
|
const [isSyncing, setIsSyncing] = useState(false);
|
||||||
const [isBrowserOnline, setIsBrowserOnline] = useState(
|
const [isBrowserOnline, setIsBrowserOnline] = useState(
|
||||||
@@ -477,41 +508,226 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
lastOfflineUnsupportedToastAtRef.current = now;
|
lastOfflineUnsupportedToastAtRef.current = now;
|
||||||
toast.warning(
|
toast.warning(
|
||||||
"Offline aktuell nicht unterstützt",
|
"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(
|
const runCreateNodeOnlineOnly = useCallback(
|
||||||
async (args: Parameters<typeof createNode>[0]) => {
|
async (args: Parameters<typeof createNode>[0]) => {
|
||||||
if (!isSyncOnline) {
|
const clientRequestId = args.clientRequestId ?? crypto.randomUUID();
|
||||||
notifyOfflineUnsupported("Node erstellen");
|
const payload = { ...args, clientRequestId };
|
||||||
throw new Error("offline-unsupported");
|
|
||||||
|
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(
|
const runCreateNodeWithEdgeFromSourceOnlineOnly = useCallback(
|
||||||
async (args: Parameters<typeof createNodeWithEdgeFromSource>[0]) => {
|
async (args: Parameters<typeof createNodeWithEdgeFromSource>[0]) => {
|
||||||
if (!isSyncOnline) {
|
const clientRequestId = args.clientRequestId ?? crypto.randomUUID();
|
||||||
notifyOfflineUnsupported("Node mit Verbindung erstellen");
|
const payload = { ...args, clientRequestId };
|
||||||
throw new Error("offline-unsupported");
|
|
||||||
|
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(
|
const runCreateNodeWithEdgeToTargetOnlineOnly = useCallback(
|
||||||
async (args: Parameters<typeof createNodeWithEdgeToTarget>[0]) => {
|
async (args: Parameters<typeof createNodeWithEdgeToTarget>[0]) => {
|
||||||
if (!isSyncOnline) {
|
const clientRequestId = args.clientRequestId ?? crypto.randomUUID();
|
||||||
notifyOfflineUnsupported("Node mit Verbindung erstellen");
|
const payload = { ...args, clientRequestId };
|
||||||
throw new Error("offline-unsupported");
|
|
||||||
|
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(
|
const runCreateNodeWithEdgeSplitOnlineOnly = useCallback(
|
||||||
@@ -547,15 +763,46 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const queue = await listCanvasSyncOps(canvasId as string);
|
|
||||||
let permanentFailures = 0;
|
let permanentFailures = 0;
|
||||||
|
let processedInThisPass = 0;
|
||||||
|
|
||||||
for (const op of queue) {
|
while (processedInThisPass < 500) {
|
||||||
if (op.expiresAt <= now) continue;
|
const nowLoop = Date.now();
|
||||||
if (op.nextRetryAt > now) continue;
|
const queue = await listCanvasSyncOps(canvasId as string);
|
||||||
|
const op = queue.find(
|
||||||
|
(entry) => entry.expiresAt > nowLoop && entry.nextRetryAt <= nowLoop,
|
||||||
|
);
|
||||||
|
if (!op) break;
|
||||||
|
processedInThisPass += 1;
|
||||||
|
|
||||||
try {
|
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);
|
await moveNode(op.payload);
|
||||||
} else if (op.type === "resizeNode") {
|
} else if (op.type === "resizeNode") {
|
||||||
await resizeNode(op.payload);
|
await resizeNode(op.payload);
|
||||||
@@ -578,6 +825,26 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
permanentFailures += 1;
|
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);
|
await ackCanvasSyncOp(op.id);
|
||||||
resolveCanvasOp(canvasId as string, op.id);
|
resolveCanvasOp(canvasId as string, op.id);
|
||||||
}
|
}
|
||||||
@@ -594,7 +861,20 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
setIsSyncing(false);
|
setIsSyncing(false);
|
||||||
await refreshPendingSyncCount();
|
await refreshPendingSyncCount();
|
||||||
}
|
}
|
||||||
}, [canvasId, isSyncOnline, moveNode, refreshPendingSyncCount, resizeNode, updateNodeData]);
|
}, [
|
||||||
|
canvasId,
|
||||||
|
createEdgeRaw,
|
||||||
|
createNodeRaw,
|
||||||
|
createNodeWithEdgeFromSourceRaw,
|
||||||
|
createNodeWithEdgeToTargetRaw,
|
||||||
|
isSyncOnline,
|
||||||
|
moveNode,
|
||||||
|
refreshPendingSyncCount,
|
||||||
|
remapOptimisticNodeLocally,
|
||||||
|
removeOptimisticCreateLocally,
|
||||||
|
resizeNode,
|
||||||
|
updateNodeData,
|
||||||
|
]);
|
||||||
|
|
||||||
const enqueueSyncMutation = useCallback(
|
const enqueueSyncMutation = useCallback(
|
||||||
async <TType extends keyof CanvasSyncOpPayloadByType>(
|
async <TType extends keyof CanvasSyncOpPayloadByType>(
|
||||||
@@ -622,6 +902,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
},
|
},
|
||||||
[canvasId, flushCanvasSyncQueue, refreshPendingSyncCount],
|
[canvasId, flushCanvasSyncQueue, refreshPendingSyncCount],
|
||||||
);
|
);
|
||||||
|
enqueueSyncMutationRef.current = enqueueSyncMutation;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refreshPendingSyncCount();
|
void refreshPendingSyncCount();
|
||||||
@@ -699,13 +980,24 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
|
|
||||||
const runCreateEdgeMutation = useCallback(
|
const runCreateEdgeMutation = useCallback(
|
||||||
async (args: Parameters<typeof createEdge>[0]) => {
|
async (args: Parameters<typeof createEdge>[0]) => {
|
||||||
if (!isSyncOnline) {
|
const clientRequestId = args.clientRequestId ?? crypto.randomUUID();
|
||||||
notifyOfflineUnsupported("Kante erstellen");
|
const payload = { ...args, clientRequestId };
|
||||||
|
|
||||||
|
if (isSyncOnline) {
|
||||||
|
await createEdge(payload);
|
||||||
return;
|
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(
|
const runRemoveEdgeMutation = useCallback(
|
||||||
@@ -795,9 +1087,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
/** Freepik-Panel: State canvas-weit, damit es den optimistic_… → Real-ID-Wechsel überlebt. */
|
/** Freepik-Panel: State canvas-weit, damit es den optimistic_… → Real-ID-Wechsel überlebt. */
|
||||||
const [assetBrowserTargetNodeId, setAssetBrowserTargetNodeId] = useState<
|
|
||||||
string | null
|
|
||||||
>(null);
|
|
||||||
const assetBrowserTargetApi: AssetBrowserTargetApi = useMemo(
|
const assetBrowserTargetApi: AssetBrowserTargetApi = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
targetNodeId: assetBrowserTargetNodeId,
|
targetNodeId: assetBrowserTargetNodeId,
|
||||||
@@ -816,6 +1105,9 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
if (!clientRequestId) return;
|
if (!clientRequestId) return;
|
||||||
|
|
||||||
if (realId !== undefined) {
|
if (realId !== undefined) {
|
||||||
|
if (isOptimisticNodeId(realId as string)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const optimisticNodeId = `${OPTIMISTIC_NODE_PREFIX}${clientRequestId}`;
|
const optimisticNodeId = `${OPTIMISTIC_NODE_PREFIX}${clientRequestId}`;
|
||||||
setAssetBrowserTargetNodeId((current) =>
|
setAssetBrowserTargetNodeId((current) =>
|
||||||
current === optimisticNodeId ? (realId as string) : current,
|
current === optimisticNodeId ? (realId as string) : current,
|
||||||
@@ -919,15 +1211,12 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
},
|
},
|
||||||
[canvasId, runMoveNodeMutation, runSplitEdgeAtExistingNodeMutation],
|
[canvasId, runMoveNodeMutation, runSplitEdgeAtExistingNodeMutation],
|
||||||
);
|
);
|
||||||
|
syncPendingMoveForClientRequestRef.current = syncPendingMoveForClientRequest;
|
||||||
|
|
||||||
// ─── Lokaler State (für flüssiges Dragging) ───────────────────
|
// ─── Lokaler State (für flüssiges Dragging) ───────────────────
|
||||||
const [nodes, setNodes] = useState<RFNode[]>([]);
|
|
||||||
const [edges, setEdges] = useState<RFEdge[]>([]);
|
|
||||||
const nodesRef = useRef<RFNode[]>(nodes);
|
const nodesRef = useRef<RFNode[]>(nodes);
|
||||||
nodesRef.current = nodes;
|
nodesRef.current = nodes;
|
||||||
const [hasHydratedLocalSnapshot, setHasHydratedLocalSnapshot] = useState(false);
|
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] =
|
const [connectionDropMenu, setConnectionDropMenu] =
|
||||||
useState<ConnectionDropMenuState | null>(null);
|
useState<ConnectionDropMenuState | null>(null);
|
||||||
const connectionDropMenuRef = useRef<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
|
// Drag-Lock: während des Drags kein Convex-Override
|
||||||
const isDragging = useRef(false);
|
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)
|
// Resize-Lock: kein Convex→lokal während aktiver Größenänderung (veraltete Maße überschreiben sonst den Resize)
|
||||||
const isResizing = useRef(false);
|
const isResizing = useRef(false);
|
||||||
|
|
||||||
@@ -1717,10 +2004,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
|
|
||||||
const handleConnectionDropPick = useCallback(
|
const handleConnectionDropPick = useCallback(
|
||||||
(template: CanvasNodeTemplate) => {
|
(template: CanvasNodeTemplate) => {
|
||||||
if (!isSyncOnline) {
|
|
||||||
notifyOfflineUnsupported("Node mit Verbindung erstellen");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const ctx = connectionDropMenuRef.current;
|
const ctx = connectionDropMenuRef.current;
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
@@ -1767,6 +2050,9 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
targetHandle: handles?.target ?? undefined,
|
targetHandle: handles?.target ?? undefined,
|
||||||
})
|
})
|
||||||
.then((realId) => {
|
.then((realId) => {
|
||||||
|
if (isOptimisticNodeId(realId as string)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
resolvedRealIdByClientRequestRef.current.set(
|
resolvedRealIdByClientRequestRef.current.set(
|
||||||
clientRequestId,
|
clientRequestId,
|
||||||
realId,
|
realId,
|
||||||
@@ -1786,6 +2072,9 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
targetHandle: ctx.fromHandleId,
|
targetHandle: ctx.fromHandleId,
|
||||||
})
|
})
|
||||||
.then((realId) => {
|
.then((realId) => {
|
||||||
|
if (isOptimisticNodeId(realId as string)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
resolvedRealIdByClientRequestRef.current.set(
|
resolvedRealIdByClientRequestRef.current.set(
|
||||||
clientRequestId,
|
clientRequestId,
|
||||||
realId,
|
realId,
|
||||||
@@ -1801,8 +2090,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
},
|
},
|
||||||
[
|
[
|
||||||
canvasId,
|
canvasId,
|
||||||
isSyncOnline,
|
|
||||||
notifyOfflineUnsupported,
|
|
||||||
runCreateNodeWithEdgeFromSourceOnlineOnly,
|
runCreateNodeWithEdgeFromSourceOnlineOnly,
|
||||||
runCreateNodeWithEdgeToTargetOnlineOnly,
|
runCreateNodeWithEdgeToTargetOnlineOnly,
|
||||||
syncPendingMoveForClientRequest,
|
syncPendingMoveForClientRequest,
|
||||||
@@ -1818,10 +2105,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
const onDrop = useCallback(
|
const onDrop = useCallback(
|
||||||
async (event: React.DragEvent) => {
|
async (event: React.DragEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!isSyncOnline) {
|
|
||||||
notifyOfflineUnsupported("Node erstellen");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawData = event.dataTransfer.getData(
|
const rawData = event.dataTransfer.getData(
|
||||||
"application/lemonspace-node-type",
|
"application/lemonspace-node-type",
|
||||||
@@ -1829,6 +2112,10 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
if (!rawData) {
|
if (!rawData) {
|
||||||
const hasFiles = event.dataTransfer.files && event.dataTransfer.files.length > 0;
|
const hasFiles = event.dataTransfer.files && event.dataTransfer.files.length > 0;
|
||||||
if (hasFiles) {
|
if (hasFiles) {
|
||||||
|
if (!isSyncOnline) {
|
||||||
|
notifyOfflineUnsupported("Upload per Drag-and-drop");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const file = event.dataTransfer.files[0];
|
const file = event.dataTransfer.files[0];
|
||||||
if (file.type.startsWith("image/")) {
|
if (file.type.startsWith("image/")) {
|
||||||
try {
|
try {
|
||||||
@@ -1944,8 +2231,8 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
canvasId,
|
canvasId,
|
||||||
generateUploadUrl,
|
generateUploadUrl,
|
||||||
isSyncOnline,
|
isSyncOnline,
|
||||||
notifyOfflineUnsupported,
|
|
||||||
runCreateNodeOnlineOnly,
|
runCreateNodeOnlineOnly,
|
||||||
|
notifyOfflineUnsupported,
|
||||||
syncPendingMoveForClientRequest,
|
syncPendingMoveForClientRequest,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { query, mutation } from "./_generated/server";
|
import { query, mutation } from "./_generated/server";
|
||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
import { requireAuth } from "./helpers";
|
import { optionalAuth, requireAuth } from "./helpers";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Queries
|
// Queries
|
||||||
@@ -27,7 +27,10 @@ export const list = query({
|
|||||||
export const get = query({
|
export const get = query({
|
||||||
args: { canvasId: v.id("canvases") },
|
args: { canvasId: v.id("canvases") },
|
||||||
handler: async (ctx, { canvasId }) => {
|
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);
|
const canvas = await ctx.db.get(canvasId);
|
||||||
if (!canvas || canvas.ownerId !== user.userId) {
|
if (!canvas || canvas.ownerId !== user.userId) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { query, mutation, internalMutation } from "./_generated/server";
|
import { query, mutation, internalMutation } from "./_generated/server";
|
||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
import { requireAuth } from "./helpers";
|
import { optionalAuth, requireAuth } from "./helpers";
|
||||||
import { internal } from "./_generated/api";
|
import { internal } from "./_generated/api";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -58,7 +58,10 @@ export type Tier = keyof typeof TIER_CONFIG;
|
|||||||
export const getBalance = query({
|
export const getBalance = query({
|
||||||
args: {},
|
args: {},
|
||||||
handler: async (ctx) => {
|
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
|
const balance = await ctx.db
|
||||||
.query("creditBalances")
|
.query("creditBalances")
|
||||||
.withIndex("by_user", (q) => q.eq("userId", user.userId))
|
.withIndex("by_user", (q) => q.eq("userId", user.userId))
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { query, mutation } from "./_generated/server";
|
import { query, mutation } from "./_generated/server";
|
||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
import { requireAuth } from "./helpers";
|
import { requireAuth } from "./helpers";
|
||||||
|
import type { Id } from "./_generated/dataModel";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Queries
|
// Queries
|
||||||
@@ -39,6 +40,7 @@ export const create = mutation({
|
|||||||
targetNodeId: v.id("nodes"),
|
targetNodeId: v.id("nodes"),
|
||||||
sourceHandle: v.optional(v.string()),
|
sourceHandle: v.optional(v.string()),
|
||||||
targetHandle: v.optional(v.string()),
|
targetHandle: v.optional(v.string()),
|
||||||
|
clientRequestId: v.optional(v.string()),
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const user = await requireAuth(ctx);
|
const user = await requireAuth(ctx);
|
||||||
@@ -47,6 +49,31 @@ export const create = mutation({
|
|||||||
throw new Error("Canvas not found");
|
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
|
// Prüfen ob beide Nodes existieren und zum gleichen Canvas gehören
|
||||||
const source = await ctx.db.get(args.sourceNodeId);
|
const source = await ctx.db.get(args.sourceNodeId);
|
||||||
const target = await ctx.db.get(args.targetNodeId);
|
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() });
|
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;
|
return edgeId;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,6 +38,16 @@ export async function requireAuth(
|
|||||||
/**
|
/**
|
||||||
* Gibt den User zurück oder null — für optionale Auth-Checks (z.B. public Queries).
|
* Gibt den User zurück oder null — für optionale Auth-Checks (z.B. public Queries).
|
||||||
*/
|
*/
|
||||||
export async function optionalAuth(ctx: QueryCtx | MutationCtx) {
|
export async function optionalAuth(
|
||||||
return await authComponent.safeGetAuthUser(ctx);
|
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 };
|
||||||
}
|
}
|
||||||
|
|||||||
109
convex/nodes.ts
109
convex/nodes.ts
@@ -34,6 +34,62 @@ async function getCanvasIfAuthorized(
|
|||||||
return canvas;
|
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
|
// Queries
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -135,7 +191,15 @@ export const create = mutation({
|
|||||||
const user = await requireAuth(ctx);
|
const user = await requireAuth(ctx);
|
||||||
await getCanvasOrThrow(ctx, args.canvasId, user.userId);
|
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", {
|
const nodeId = await ctx.db.insert("nodes", {
|
||||||
canvasId: args.canvasId,
|
canvasId: args.canvasId,
|
||||||
@@ -153,6 +217,13 @@ export const create = mutation({
|
|||||||
|
|
||||||
// Canvas updatedAt aktualisieren
|
// Canvas updatedAt aktualisieren
|
||||||
await ctx.db.patch(args.canvasId, { updatedAt: Date.now() });
|
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;
|
return nodeId;
|
||||||
},
|
},
|
||||||
@@ -315,7 +386,16 @@ export const createWithEdgeFromSource = mutation({
|
|||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const user = await requireAuth(ctx);
|
const user = await requireAuth(ctx);
|
||||||
await getCanvasOrThrow(ctx, args.canvasId, user.userId);
|
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);
|
const source = await ctx.db.get(args.sourceNodeId);
|
||||||
if (!source || source.canvasId !== args.canvasId) {
|
if (!source || source.canvasId !== args.canvasId) {
|
||||||
@@ -345,6 +425,13 @@ export const createWithEdgeFromSource = mutation({
|
|||||||
});
|
});
|
||||||
|
|
||||||
await ctx.db.patch(args.canvasId, { updatedAt: Date.now() });
|
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;
|
return nodeId;
|
||||||
},
|
},
|
||||||
@@ -373,7 +460,16 @@ export const createWithEdgeToTarget = mutation({
|
|||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const user = await requireAuth(ctx);
|
const user = await requireAuth(ctx);
|
||||||
await getCanvasOrThrow(ctx, args.canvasId, user.userId);
|
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);
|
const target = await ctx.db.get(args.targetNodeId);
|
||||||
if (!target || target.canvasId !== args.canvasId) {
|
if (!target || target.canvasId !== args.canvasId) {
|
||||||
@@ -403,6 +499,13 @@ export const createWithEdgeToTarget = mutation({
|
|||||||
});
|
});
|
||||||
|
|
||||||
await ctx.db.patch(args.canvasId, { updatedAt: Date.now() });
|
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;
|
return nodeId;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -214,6 +214,20 @@ export default defineSchema({
|
|||||||
.index("by_source", ["sourceNodeId"])
|
.index("by_source", ["sourceNodeId"])
|
||||||
.index("by_target", ["targetNodeId"]),
|
.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
|
// Credit-System
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
|
|||||||
@@ -181,3 +181,64 @@ export function resolveCanvasOps(canvasId: string, opIds: string[]): void {
|
|||||||
export function readCanvasOps(canvasId: string): CanvasPendingOp[] {
|
export function readCanvasOps(canvasId: string): CanvasPendingOp[] {
|
||||||
return readOpsPayload(canvasId).ops;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,56 @@ const FALLBACK_STORAGE_KEY = "lemonspace.canvas:sync-fallback:v1";
|
|||||||
export const CANVAS_SYNC_RETENTION_MS = 24 * 60 * 60 * 1000;
|
export const CANVAS_SYNC_RETENTION_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
export type CanvasSyncOpPayloadByType = {
|
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 };
|
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 };
|
||||||
@@ -156,7 +206,13 @@ function normalizeOp(raw: unknown): CanvasSyncOp | null {
|
|||||||
!id ||
|
!id ||
|
||||||
typeof canvasId !== "string" ||
|
typeof canvasId !== "string" ||
|
||||||
!canvasId ||
|
!canvasId ||
|
||||||
(type !== "moveNode" && type !== "resizeNode" && type !== "updateData")
|
type !== "createNode" &&
|
||||||
|
type !== "createNodeWithEdgeFromSource" &&
|
||||||
|
type !== "createNodeWithEdgeToTarget" &&
|
||||||
|
type !== "createEdge" &&
|
||||||
|
type !== "moveNode" &&
|
||||||
|
type !== "resizeNode" &&
|
||||||
|
type !== "updateData"
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -173,6 +229,170 @@ function normalizeOp(raw: unknown): CanvasSyncOp | null {
|
|||||||
|
|
||||||
if (!isRecord(payload)) return 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 (
|
if (
|
||||||
type === "moveNode" &&
|
type === "moveNode" &&
|
||||||
typeof payload.nodeId === "string" &&
|
typeof payload.nodeId === "string" &&
|
||||||
@@ -418,3 +638,112 @@ export async function dropExpiredCanvasSyncOps(
|
|||||||
await txDone(tx);
|
await txDone(tx);
|
||||||
return expiredIds;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user