Enable offline canvas create sync with optimistic ID remapping
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
109
convex/nodes.ts
109
convex/nodes.ts
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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
|
||||
// ==========================================================================
|
||||
|
||||
Reference in New Issue
Block a user