From 1423fee6a82568fdfe608777a2950921418ca37d Mon Sep 17 00:00:00 2001 From: Leo Date: Sun, 2 Nov 2025 20:00:45 -0500 Subject: [PATCH 1/3] fix(database): add critical indexes, validation, and soft delete support --- apps/server/convex/chats.ts | 21 +++++++++++---------- apps/server/convex/messages.ts | 9 +++++++++ apps/server/convex/schema.ts | 7 ++++++- 3 files changed, 26 insertions(+), 11 deletions(-) diff --git a/apps/server/convex/chats.ts b/apps/server/convex/chats.ts index 4ce9c08c..c71d1ff3 100644 --- a/apps/server/convex/chats.ts +++ b/apps/server/convex/chats.ts @@ -11,6 +11,7 @@ const chatDoc = v.object({ createdAt: v.number(), updatedAt: v.number(), lastMessageAt: v.optional(v.number()), + deletedAt: v.optional(v.number()), }); export const list = query({ @@ -19,11 +20,13 @@ export const list = query({ }, returns: v.array(chatDoc), handler: async (ctx, args) => { - return await ctx.db + const chats = await ctx.db .query("chats") .withIndex("by_user", (q) => q.eq("userId", args.userId)) .order("desc") .take(200); + // Filter out soft-deleted chats + return chats.filter((chat) => !chat.deletedAt); }, }); @@ -35,7 +38,7 @@ export const get = query({ returns: v.union(chatDoc, v.null()), handler: async (ctx, args) => { const chat = await ctx.db.get(args.chatId); - if (!chat || chat.userId !== args.userId) return null; + if (!chat || chat.userId !== args.userId || chat.deletedAt) return null; return chat; }, }); @@ -67,15 +70,13 @@ export const remove = mutation({ returns: v.object({ ok: v.boolean() }), handler: async (ctx, args) => { const chat = await ctx.db.get(args.chatId); - if (!chat || chat.userId !== args.userId) { + if (!chat || chat.userId !== args.userId || chat.deletedAt) { return { ok: false } as const; } - const messages = await ctx.db - .query("messages") - .withIndex("by_chat", (q) => q.eq("chatId", args.chatId)) - .collect(); - await Promise.all(messages.map((message) => ctx.db.delete(message._id))); - await ctx.db.delete(args.chatId); + // Soft delete: mark chat as deleted instead of hard delete + await ctx.db.patch(args.chatId, { + deletedAt: Date.now(), + }); return { ok: true } as const; }, }); @@ -86,7 +87,7 @@ export async function assertOwnsChat( userId: Id<"users">, ) { const chat = await ctx.db.get(chatId); - if (!chat || chat.userId !== userId) { + if (!chat || chat.userId !== userId || chat.deletedAt) { return null; } return chat; diff --git a/apps/server/convex/messages.ts b/apps/server/convex/messages.ts index 992d80f3..b9a09195 100644 --- a/apps/server/convex/messages.ts +++ b/apps/server/convex/messages.ts @@ -13,6 +13,7 @@ const messageDoc = v.object({ content: v.string(), createdAt: v.number(), status: v.optional(v.string()), + userId: v.optional(v.id("users")), }); export const list = query({ @@ -140,6 +141,8 @@ export const streamUpsert = mutation({ }, }); +const MAX_MESSAGE_CONTENT_LENGTH = 100 * 1024; // 100KB + async function insertOrUpdateMessage( ctx: MutationCtx, args: { @@ -152,6 +155,12 @@ async function insertOrUpdateMessage( overrideId?: Id<"messages">; }, ) { + // Validate message content length (100KB max) + if (args.content.length > MAX_MESSAGE_CONTENT_LENGTH) { + throw new Error( + `Message content exceeds maximum length of ${MAX_MESSAGE_CONTENT_LENGTH} bytes`, + ); + } let targetId = args.overrideId; if (!targetId && args.clientMessageId) { const existing = await ctx.db diff --git a/apps/server/convex/schema.ts b/apps/server/convex/schema.ts index a067bc89..564d896c 100644 --- a/apps/server/convex/schema.ts +++ b/apps/server/convex/schema.ts @@ -16,15 +16,20 @@ export default defineSchema({ createdAt: v.number(), updatedAt: v.number(), lastMessageAt: v.optional(v.number()), + deletedAt: v.optional(v.number()), }).index("by_user", ["userId", "updatedAt"]), messages: defineTable({ chatId: v.id("chats"), clientMessageId: v.optional(v.string()), role: v.string(), + // Max length: 100KB (102400 bytes) content: v.string(), createdAt: v.number(), status: v.optional(v.string()), + userId: v.optional(v.id("users")), }) .index("by_chat", ["chatId", "createdAt"]) - .index("by_client_id", ["chatId", "clientMessageId"]), + .index("by_client_id", ["chatId", "clientMessageId"]) + .index("by_user", ["userId"]) + .index("unique_client_message", ["chatId", "clientMessageId"]), }); From a3ecabb472fa9f6c03068e081dd8674dcde28fa0 Mon Sep 17 00:00:00 2001 From: Leo Date: Sun, 2 Nov 2025 20:06:20 -0500 Subject: [PATCH 2/3] fix: address AI review feedback - populate userId, fix byte validation, cascade soft delete --- apps/server/convex/chats.ts | 15 ++++++++++++++- apps/server/convex/messages.ts | 16 +++++++++++++--- apps/server/convex/schema.ts | 4 ++-- 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/apps/server/convex/chats.ts b/apps/server/convex/chats.ts index c71d1ff3..f592f8a9 100644 --- a/apps/server/convex/chats.ts +++ b/apps/server/convex/chats.ts @@ -73,10 +73,23 @@ export const remove = mutation({ if (!chat || chat.userId !== args.userId || chat.deletedAt) { return { ok: false } as const; } + const now = Date.now(); // Soft delete: mark chat as deleted instead of hard delete await ctx.db.patch(args.chatId, { - deletedAt: Date.now(), + deletedAt: now, }); + // Cascade soft delete to all messages in the chat + const messages = await ctx.db + .query("messages") + .withIndex("by_chat", (q) => q.eq("chatId", args.chatId)) + .collect(); + await Promise.all( + messages.map((message) => + ctx.db.patch(message._id, { + deletedAt: now, + }), + ), + ); return { ok: true } as const; }, }); diff --git a/apps/server/convex/messages.ts b/apps/server/convex/messages.ts index b9a09195..6ef50c4e 100644 --- a/apps/server/convex/messages.ts +++ b/apps/server/convex/messages.ts @@ -14,6 +14,7 @@ const messageDoc = v.object({ createdAt: v.number(), status: v.optional(v.string()), userId: v.optional(v.id("users")), + deletedAt: v.optional(v.number()), }); export const list = query({ @@ -25,11 +26,13 @@ export const list = query({ handler: async (ctx, args) => { const chat = await assertOwnsChat(ctx, args.chatId, args.userId); if (!chat) return []; - return await ctx.db + const messages = await ctx.db .query("messages") .withIndex("by_chat", (q) => q.eq("chatId", args.chatId)) .order("asc") .collect(); + // Filter out soft-deleted messages + return messages.filter((message) => !message.deletedAt); }, }); @@ -69,6 +72,7 @@ export const send = mutation({ createdAt: userCreatedAt, clientMessageId: args.userMessage.clientMessageId, status: "completed", + userId: args.userId, }); let assistantMessageId: Id<"messages"> | null = null; @@ -82,6 +86,7 @@ export const send = mutation({ createdAt: assistantCreatedAt, clientMessageId: args.assistantMessage.clientMessageId, status: "completed", + userId: args.userId, }); } @@ -127,6 +132,7 @@ export const streamUpsert = mutation({ status: args.status ?? "streaming", clientMessageId: args.clientMessageId, overrideId: args.messageId ?? undefined, + userId: args.userId, }); if (args.status === "completed" && (args.role === "assistant" || args.role === "user")) { @@ -153,10 +159,12 @@ async function insertOrUpdateMessage( status: string; clientMessageId?: string | null; overrideId?: Id<"messages">; + userId?: Id<"users">; }, ) { - // Validate message content length (100KB max) - if (args.content.length > MAX_MESSAGE_CONTENT_LENGTH) { + // Validate message content length (100KB max) - count actual bytes, not string length + const contentBytes = new TextEncoder().encode(args.content).length; + if (contentBytes > MAX_MESSAGE_CONTENT_LENGTH) { throw new Error( `Message content exceeds maximum length of ${MAX_MESSAGE_CONTENT_LENGTH} bytes`, ); @@ -181,6 +189,7 @@ async function insertOrUpdateMessage( content: args.content, createdAt: args.createdAt, status: args.status, + userId: args.userId ?? undefined, }); } else { await ctx.db.patch(targetId, { @@ -189,6 +198,7 @@ async function insertOrUpdateMessage( content: args.content, createdAt: args.createdAt, status: args.status, + userId: args.userId ?? undefined, }); } return targetId; diff --git a/apps/server/convex/schema.ts b/apps/server/convex/schema.ts index 564d896c..dbe8861c 100644 --- a/apps/server/convex/schema.ts +++ b/apps/server/convex/schema.ts @@ -27,9 +27,9 @@ export default defineSchema({ createdAt: v.number(), status: v.optional(v.string()), userId: v.optional(v.id("users")), + deletedAt: v.optional(v.number()), }) .index("by_chat", ["chatId", "createdAt"]) .index("by_client_id", ["chatId", "clientMessageId"]) - .index("by_user", ["userId"]) - .index("unique_client_message", ["chatId", "clientMessageId"]), + .index("by_user", ["userId"]), }); From 4f2c628c4e6b3d55eac953c083332b9f7f79be69 Mon Sep 17 00:00:00 2001 From: Leo Date: Sun, 2 Nov 2025 20:09:53 -0500 Subject: [PATCH 3/3] fix: prevent resurrection of soft-deleted messages, optimize cascade delete --- apps/server/convex/chats.ts | 14 ++++++++------ apps/server/convex/messages.ts | 3 ++- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/apps/server/convex/chats.ts b/apps/server/convex/chats.ts index f592f8a9..f85f6d81 100644 --- a/apps/server/convex/chats.ts +++ b/apps/server/convex/chats.ts @@ -78,17 +78,19 @@ export const remove = mutation({ await ctx.db.patch(args.chatId, { deletedAt: now, }); - // Cascade soft delete to all messages in the chat + // Cascade soft delete to all messages in the chat (skip already deleted messages) const messages = await ctx.db .query("messages") .withIndex("by_chat", (q) => q.eq("chatId", args.chatId)) .collect(); await Promise.all( - messages.map((message) => - ctx.db.patch(message._id, { - deletedAt: now, - }), - ), + messages + .filter((message) => !message.deletedAt) + .map((message) => + ctx.db.patch(message._id, { + deletedAt: now, + }), + ), ); return { ok: true } as const; }, diff --git a/apps/server/convex/messages.ts b/apps/server/convex/messages.ts index 6ef50c4e..c2d2375e 100644 --- a/apps/server/convex/messages.ts +++ b/apps/server/convex/messages.ts @@ -177,7 +177,8 @@ async function insertOrUpdateMessage( q.eq("chatId", args.chatId).eq("clientMessageId", args.clientMessageId!), ) .unique(); - if (existing) { + // Only reuse the message if it hasn't been soft-deleted + if (existing && !existing.deletedAt) { targetId = existing._id; } }