Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 22 additions & 6 deletions apps/server/convex/chats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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);
},
});

Expand All @@ -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;
},
});
Expand Down Expand Up @@ -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;
},
});
Expand All @@ -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;
Expand Down
24 changes: 22 additions & 2 deletions apps/server/convex/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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);
},
});

Expand Down Expand Up @@ -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;
Expand All @@ -81,6 +86,7 @@ export const send = mutation({
createdAt: assistantCreatedAt,
clientMessageId: args.assistantMessage.clientMessageId,
status: "completed",
userId: args.userId,
});
}

Expand Down Expand Up @@ -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")) {
Expand All @@ -140,6 +147,8 @@ export const streamUpsert = mutation({
},
});

const MAX_MESSAGE_CONTENT_LENGTH = 100 * 1024; // 100KB

async function insertOrUpdateMessage(
ctx: MutationCtx,
args: {
Expand All @@ -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
Expand All @@ -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;
}
}
Expand All @@ -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, {
Expand All @@ -180,6 +199,7 @@ async function insertOrUpdateMessage(
content: args.content,
createdAt: args.createdAt,
status: args.status,
userId: args.userId ?? undefined,
});
}
return targetId;
Expand Down
7 changes: 6 additions & 1 deletion apps/server/convex/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]),
});
Loading