diff --git a/apps/server/convex/chats.ts b/apps/server/convex/chats.ts index 4ce9c08c..f85f6d81 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,28 @@ 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 now = Date.now(); + // Soft delete: mark chat as deleted instead of hard delete + await ctx.db.patch(args.chatId, { + deletedAt: now, + }); + // 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.delete(message._id))); - await ctx.db.delete(args.chatId); + await Promise.all( + messages + .filter((message) => !message.deletedAt) + .map((message) => + ctx.db.patch(message._id, { + deletedAt: now, + }), + ), + ); return { ok: true } as const; }, }); @@ -86,7 +102,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..c2d2375e 100644 --- a/apps/server/convex/messages.ts +++ b/apps/server/convex/messages.ts @@ -13,6 +13,8 @@ const messageDoc = v.object({ content: v.string(), createdAt: v.number(), status: v.optional(v.string()), + userId: v.optional(v.id("users")), + deletedAt: v.optional(v.number()), }); export const list = query({ @@ -24,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); }, }); @@ -68,6 +72,7 @@ export const send = mutation({ createdAt: userCreatedAt, clientMessageId: args.userMessage.clientMessageId, status: "completed", + userId: args.userId, }); let assistantMessageId: Id<"messages"> | null = null; @@ -81,6 +86,7 @@ export const send = mutation({ createdAt: assistantCreatedAt, clientMessageId: args.assistantMessage.clientMessageId, status: "completed", + userId: args.userId, }); } @@ -126,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")) { @@ -140,6 +147,8 @@ export const streamUpsert = mutation({ }, }); +const MAX_MESSAGE_CONTENT_LENGTH = 100 * 1024; // 100KB + async function insertOrUpdateMessage( ctx: MutationCtx, args: { @@ -150,8 +159,16 @@ async function insertOrUpdateMessage( status: string; clientMessageId?: string | null; overrideId?: Id<"messages">; + userId?: Id<"users">; }, ) { + // 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`, + ); + } let targetId = args.overrideId; if (!targetId && args.clientMessageId) { const existing = await ctx.db @@ -160,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; } } @@ -172,6 +190,7 @@ async function insertOrUpdateMessage( content: args.content, createdAt: args.createdAt, status: args.status, + userId: args.userId ?? undefined, }); } else { await ctx.db.patch(targetId, { @@ -180,6 +199,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 a067bc89..dbe8861c 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")), + deletedAt: v.optional(v.number()), }) .index("by_chat", ["chatId", "createdAt"]) - .index("by_client_id", ["chatId", "clientMessageId"]), + .index("by_client_id", ["chatId", "clientMessageId"]) + .index("by_user", ["userId"]), });