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

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