From a16f4b46c94d4269ac3114a68f21711f4a8788ed Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 18 Mar 2026 14:28:18 -0400 Subject: [PATCH 1/2] Cleanup backend hotspots and apply code quality fixes --- apps/server/convex/benchmarks.ts | 28 ++- apps/server/convex/chatTitle.ts | 43 +++- apps/server/convex/chat_cleanup_helpers.ts | 80 ++++++ apps/server/convex/chats.ts | 82 ++----- apps/server/convex/cleanupAction.ts | 21 +- apps/server/convex/crons.ts | 13 +- apps/server/convex/files.ts | 175 ++++++------- apps/server/convex/http.ts | 38 ++- apps/server/convex/lib/auth.ts | 12 +- apps/server/convex/message_queries.ts | 8 +- apps/server/convex/messages.ts | 79 ++---- apps/server/convex/polyfills.ts | 6 +- apps/server/convex/promptTemplates.ts | 74 +++--- apps/server/convex/schema.ts | 2 + apps/server/convex/streamExecution.ts | 231 ++++++++++++++---- apps/server/convex/streamJobs.ts | 12 +- apps/server/convex/streamWebSearch.ts | 14 +- apps/server/convex/userDelete.ts | 90 +++++-- .../ai-elements/prompt-input-speech.tsx | 22 +- .../src/components/chat/chat-interface.tsx | 8 +- apps/web/src/components/model-selector.tsx | 3 +- .../model-selector/use-model-filter.ts | 17 +- apps/web/src/hooks/use-favorite-models.ts | 59 ++++- apps/web/src/hooks/use-mobile.ts | 23 +- apps/web/src/hooks/use-prompt-draft.ts | 29 ++- apps/web/src/lib/redis.ts | 56 +++-- apps/web/src/providers/index.tsx | 80 ++++-- apps/web/src/routes/api/typing.ts | 44 +++- apps/web/src/stores/prompt-draft.ts | 14 -- 29 files changed, 924 insertions(+), 439 deletions(-) create mode 100644 apps/server/convex/chat_cleanup_helpers.ts diff --git a/apps/server/convex/benchmarks.ts b/apps/server/convex/benchmarks.ts index 345dc349..17c8feb8 100644 --- a/apps/server/convex/benchmarks.ts +++ b/apps/server/convex/benchmarks.ts @@ -1,6 +1,6 @@ +import type { FunctionReference } from "convex/server"; import { v } from "convex/values"; import { internalAction, internalMutation, query } from "./_generated/server"; -import { internal } from "./_generated/api"; import { buildMatchingMap, type AAModel } from "./lib/model_matching"; import { createLogger } from "./lib/logger"; @@ -33,6 +33,29 @@ const benchmarkValidator = v.object({ aime: v.optional(v.float64()), }); +const storeBenchmarksRef = + "benchmarks:storeBenchmarks" as unknown as FunctionReference< + "mutation", + "internal", + { + benchmarks: Array<{ + openRouterModelId: string; + aaSlug: string; + aaCreatorName: string; + intelligenceIndex?: number; + codingIndex?: number; + mathIndex?: number; + mmluPro?: number; + gpqa?: number; + scicode?: number; + livecodebench?: number; + math500?: number; + aime?: number; + }>; + }, + unknown + >; + export const fetchAndStoreBenchmarks = internalAction({ args: {}, handler: async (ctx) => { @@ -87,7 +110,7 @@ export const fetchAndStoreBenchmarks = internalAction({ }]; }); - await ctx.runMutation(internal.benchmarks.storeBenchmarks, { benchmarks }); + await ctx.runMutation(storeBenchmarksRef, { benchmarks }); } catch (error) { void logger.error("Failed to refresh Artificial Analysis benchmarks", error); } @@ -184,4 +207,3 @@ export const getAllBenchmarks = query({ }, }); - diff --git a/apps/server/convex/chatTitle.ts b/apps/server/convex/chatTitle.ts index 67adeda5..bd484e5d 100644 --- a/apps/server/convex/chatTitle.ts +++ b/apps/server/convex/chatTitle.ts @@ -1,3 +1,4 @@ +import type { FunctionReference } from "convex/server"; import type { Id } from "./_generated/dataModel"; import type { ActionCtx } from "./_generated/server"; import { action, internalAction, internalMutation, internalQuery, mutation } from "./_generated/server"; @@ -23,6 +24,38 @@ const TITLE_STYLE_PROMPTS: Record<"short" | "standard" | "long", string> = { type TitleLength = "short" | "standard" | "long"; type TitleProvider = "osschat" | "openrouter"; +const getOpenRouterKeyInternalRef = + "users:getOpenRouterKeyInternal" as unknown as FunctionReference< + "query", + "internal", + { userId: Id<"users"> }, + string | null + >; + +const enforceTitleRateLimitRef = + "chatTitle:enforceTitleRateLimit" as unknown as FunctionReference< + "mutation", + "internal", + { userId: Id<"users"> }, + null + >; + +const getChatForTitleGenerationInternalRef = + "chatTitle:getChatForTitleGenerationInternal" as unknown as FunctionReference< + "query", + "internal", + { chatId: Id<"chats">; userId: Id<"users"> }, + { title?: string | null } | null + >; + +const setGeneratedTitleInternalRef = + "chatTitle:setGeneratedTitleInternal" as unknown as FunctionReference< + "mutation", + "internal", + { chatId: Id<"chats">; userId: Id<"users">; title: string; force?: boolean }, + null + >; + async function resolveOpenRouterKey( ctx: ActionCtx, userId: Id<"users">, @@ -32,7 +65,7 @@ async function resolveOpenRouterKey( return process.env.OPENROUTER_API_KEY ?? null; } - const encryptedKey = await ctx.runQuery(internal.users.getOpenRouterKeyInternal, { + const encryptedKey = await ctx.runQuery(getOpenRouterKeyInternalRef, { userId, }); return encryptedKey ? await decryptSecret(encryptedKey) : null; @@ -111,7 +144,7 @@ export const generateTitle = action({ returns: v.union(v.string(), v.null()), handler: async (_ctx, args) => { const userId = await requireAuthUserIdFromAction(_ctx, args.userId); - await _ctx.runMutation(internal.chatTitle.enforceTitleRateLimit, { + await _ctx.runMutation(enforceTitleRateLimitRef, { userId, }); @@ -140,7 +173,7 @@ export const generateAndSetTitleInternal = internalAction({ reason: v.optional(v.string()), }), handler: async (ctx, args) => { - const chat = await ctx.runQuery(internal.chatTitle.getChatForTitleGenerationInternal, { + const chat = await ctx.runQuery(getChatForTitleGenerationInternalRef, { chatId: args.chatId, userId: args.userId, }); @@ -157,7 +190,7 @@ export const generateAndSetTitleInternal = internalAction({ return { saved: false, reason: "empty_seed" }; } - await ctx.runMutation(internal.chatTitle.enforceTitleRateLimit, { + await ctx.runMutation(enforceTitleRateLimitRef, { userId: args.userId, }); @@ -175,7 +208,7 @@ export const generateAndSetTitleInternal = internalAction({ return { saved: false, reason: "generation_failed" }; } - await ctx.runMutation(internal.chatTitle.setGeneratedTitleInternal, { + await ctx.runMutation(setGeneratedTitleInternalRef, { chatId: args.chatId, userId: args.userId, title: generatedTitle, diff --git a/apps/server/convex/chat_cleanup_helpers.ts b/apps/server/convex/chat_cleanup_helpers.ts new file mode 100644 index 00000000..c7b4f5bd --- /dev/null +++ b/apps/server/convex/chat_cleanup_helpers.ts @@ -0,0 +1,80 @@ +import type { Id } from "./_generated/dataModel"; +import type { MutationCtx } from "./_generated/server"; + +export async function completeActiveAndPendingStreams( + ctx: MutationCtx, + chatId: Id<"chats">, + now: number, +) { + const [activeStreams, pendingStreams] = await Promise.all([ + ctx.db + .query("streamJobs") + .withIndex("by_chat", (q) => q.eq("chatId", chatId).eq("status", "running")) + .collect(), + ctx.db + .query("streamJobs") + .withIndex("by_chat", (q) => q.eq("chatId", chatId).eq("status", "pending")) + .collect(), + ]); + + const streams = [...activeStreams, ...pendingStreams]; + await Promise.all( + streams.map((stream) => + ctx.db.patch(stream._id, { + status: "completed", + completedAt: now, + }), + ), + ); + + return streams.length; +} + +export async function softDeleteMessagesAfter( + ctx: MutationCtx, + chatId: Id<"chats">, + cutoffCreatedAt: number, + now: number, +) { + const messagesToDelete = await ctx.db + .query("messages") + .withIndex("by_chat_not_deleted", (q) => + q.eq("chatId", chatId).eq("deletedAt", undefined) + ) + .order("asc") + .filter((q) => q.gt(q.field("createdAt"), cutoffCreatedAt)) + .collect(); + + await Promise.all( + messagesToDelete.map((message) => + ctx.db.patch(message._id, { + deletedAt: now, + }), + ), + ); + + return messagesToDelete.length; +} + +export async function softDeleteAllMessagesInChat( + ctx: MutationCtx, + chatId: Id<"chats">, + now: number, +) { + const messages = await ctx.db + .query("messages") + .withIndex("by_chat_not_deleted", (q) => + q.eq("chatId", chatId).eq("deletedAt", undefined) + ) + .collect(); + + await Promise.all( + messages.map((message) => + ctx.db.patch(message._id, { + deletedAt: now, + }), + ), + ); + + return messages.length; +} diff --git a/apps/server/convex/chats.ts b/apps/server/convex/chats.ts index 3a9e53d0..d361d8f6 100644 --- a/apps/server/convex/chats.ts +++ b/apps/server/convex/chats.ts @@ -7,6 +7,7 @@ import { rateLimiter } from "./lib/rateLimiter"; import { throwRateLimitError } from "./lib/rateLimitUtils"; import { sanitizeTitle } from "./lib/sanitize"; import { requireAuthUserId } from "./lib/auth"; +import { softDeleteAllMessagesInChat } from "./chat_cleanup_helpers"; const chatDoc = v.object({ _id: v.id("chats"), @@ -165,34 +166,16 @@ export const remove = mutation({ throwRateLimitError("deletions", retryAfter); } - const chat = await ctx.db.get(args.chatId); - if (!chat || chat.userId !== userId || chat.deletedAt) { + const chat = await assertOwnsChat(ctx, args.chatId, userId); + if (!chat) { return { ok: false } as const; } const now = Date.now(); - const messages = await ctx.db - .query("messages") - .withIndex("by_chat_not_deleted", (q) => - q.eq("chatId", args.chatId).eq("deletedAt", undefined) - ) - .collect(); - - await Promise.all( - messages.map((message) => - ctx.db.patch(message._id, { - deletedAt: now, - }), - ), - ); - - await ctx.db.patch(args.chatId, { - deletedAt: now, - messageCount: 0, - }); + const deletedMessages = await softDeleteChatWithMessages(ctx, args.chatId, now); await incrementStat(ctx, STAT_KEYS.CHATS_SOFT_DELETED); - await incrementStat(ctx, STAT_KEYS.MESSAGES_SOFT_DELETED, messages.length); + await incrementStat(ctx, STAT_KEYS.MESSAGES_SOFT_DELETED, deletedMessages); return { ok: true } as const; }, @@ -238,51 +221,23 @@ export const removeBulk = mutation({ let totalMessages = 0; // First pass: validate all chats and collect valid ones - const validChats: Array<{ chatId: Id<"chats"> }> = []; + const validChats: Array> = []; for (const chatId of args.chatIds) { - const chat = await ctx.db.get(chatId); + const chat = await assertOwnsChat(ctx, chatId, userId); // Skip if chat doesn't exist, doesn't belong to user, or is already deleted - if (!chat || chat.userId !== userId || chat.deletedAt) { + if (!chat) { failed++; continue; } - validChats.push({ chatId }); + validChats.push(chatId); } - // Second pass: fetch all messages for valid chats in parallel - const messagesByChat = await Promise.all( - validChats.map(async ({ chatId }) => { - const messages = await ctx.db - .query("messages") - .withIndex("by_chat_not_deleted", (q) => - q.eq("chatId", chatId).eq("deletedAt", undefined) - ) - .collect(); - return { chatId, messages }; - }) - ); - - // Third pass: soft-delete all messages and chats - for (const { chatId, messages } of messagesByChat) { - // Soft-delete all messages for this chat - await Promise.all( - messages.map((message) => - ctx.db.patch(message._id, { - deletedAt: now, - }), - ), - ); - - // Soft-delete the chat - await ctx.db.patch(chatId, { - deletedAt: now, - messageCount: 0, - }); - + for (const chatId of validChats) { + const deletedMessages = await softDeleteChatWithMessages(ctx, chatId, now); deleted++; - totalMessages += messages.length; + totalMessages += deletedMessages; } // Update stats @@ -309,6 +264,19 @@ export async function assertOwnsChat( return chat; } +async function softDeleteChatWithMessages( + ctx: MutationCtx, + chatId: Id<"chats">, + now: number, +) { + const deletedMessages = await softDeleteAllMessagesInChat(ctx, chatId, now); + await ctx.db.patch(chatId, { + deletedAt: now, + messageCount: 0, + }); + return deletedMessages; +} + export const checkExportRateLimit = mutation({ args: { userId: v.id("users"), diff --git a/apps/server/convex/cleanupAction.ts b/apps/server/convex/cleanupAction.ts index 700a326e..fa39abf2 100644 --- a/apps/server/convex/cleanupAction.ts +++ b/apps/server/convex/cleanupAction.ts @@ -1,10 +1,27 @@ "use node"; import { createHmac, timingSafeEqual } from "node:crypto"; +import type { FunctionReference } from "convex/server"; import { internalAction } from "./_generated/server"; -import { internal } from "./_generated/api"; import { v } from "convex/values"; +const cleanupSoftDeletedRecordsRef = + "crons:cleanupSoftDeletedRecords" as unknown as FunctionReference< + "mutation", + "internal", + { + retentionDays?: number; + batchSize?: number; + dryRun?: boolean; + }, + { + success: boolean; + deleted: number; + dryRun: boolean; + cutoffDate: string; + } + >; + function safeCompare(a: string, b: string, hmacKey: string): boolean { const hmacA = createHmac("sha256", hmacKey).update(a).digest(); const hmacB = createHmac("sha256", hmacKey).update(b).digest(); @@ -43,7 +60,7 @@ export const runCleanupBatchForWorkflow = internalAction({ throw new Error("batchSize must be between 1 and 1000"); } - const result = await ctx.runMutation(internal.crons.cleanupSoftDeletedRecords, { + const result = await ctx.runMutation(cleanupSoftDeletedRecordsRef, { retentionDays, batchSize, dryRun: args.dryRun, diff --git a/apps/server/convex/crons.ts b/apps/server/convex/crons.ts index 820e9d43..40cfd71d 100644 --- a/apps/server/convex/crons.ts +++ b/apps/server/convex/crons.ts @@ -12,8 +12,7 @@ * 3. Configure the schedule in Convex dashboard under "Crons" */ -import { cronJobs } from "convex/server"; -import { internal } from "./_generated/api"; +import { cronJobs, type FunctionReference } from "convex/server"; import { internalMutation } from "./_generated/server"; import { v } from "convex/values"; import { decrementStat, getStats, STAT_KEYS } from "./lib/dbStats"; @@ -27,9 +26,17 @@ const ALERT_THRESHOLDS = { maxUsers: 1_000_000, } as const; +const fetchAndStoreBenchmarksRef = + "benchmarks:fetchAndStoreBenchmarks" as unknown as FunctionReference< + "action", + "internal", + Record, + unknown + >; + const crons = cronJobs(); -crons.interval("refresh benchmarks", { hours: 8 }, internal.benchmarks.fetchAndStoreBenchmarks); +crons.interval("refresh benchmarks", { hours: 8 }, fetchAndStoreBenchmarksRef); /** * Cleanup soft-deleted records diff --git a/apps/server/convex/files.ts b/apps/server/convex/files.ts index 20b474af..d38c6878 100644 --- a/apps/server/convex/files.ts +++ b/apps/server/convex/files.ts @@ -1,4 +1,4 @@ -import { mutation, query } from "./_generated/server"; +import { mutation, query, type MutationCtx, type QueryCtx } from "./_generated/server"; import { v } from "convex/values"; import type { Id } from "./_generated/dataModel"; import { createLogger } from "./lib/logger"; @@ -8,6 +8,88 @@ import { sanitizeFilename } from "./lib/sanitize"; import { requireAuthUserId } from "./lib/auth"; import { MAX_USER_FILES, validateFileType, validateFileSize } from "./file_validators"; +type FileSummary = { + _id: Id<"fileUploads">; + _creationTime: number; + storageId: Id<"_storage">; + filename: string; + contentType: string; + size: number; + uploadedAt: number; +}; + +type FileAccessCtx = MutationCtx | QueryCtx; + +function toFileSummary(file: FileSummary) { + return { + _id: file._id, + _creationTime: file._creationTime, + storageId: file.storageId, + filename: file.filename, + contentType: file.contentType, + size: file.size, + uploadedAt: file.uploadedAt, + }; +} + +async function assertUserAndOwnedChat( + ctx: MutationCtx, + userId: Id<"users">, + chatId: Id<"chats">, +) { + const [user, chat] = await Promise.all([ctx.db.get(userId), ctx.db.get(chatId)]); + + if (!user) { + throw new Error("User not found"); + } + + if (!chat) { + throw new Error("Chat not found"); + } + + if (chat.userId !== userId) { + throw new Error("Unauthorized: You do not own this chat"); + } + + return user; +} + +async function getOwnedChat( + ctx: FileAccessCtx, + chatId: Id<"chats">, + userId: Id<"users">, +) { + const chat = await ctx.db.get(chatId); + if (!chat) { + throw new Error("Chat not found"); + } + if (chat.userId !== userId) { + throw new Error("Unauthorized: You do not own this chat"); + } + return chat; +} + +async function getOwnedFileByStorageId( + ctx: FileAccessCtx, + storageId: Id<"_storage">, + userId: Id<"users">, +) { + const file = await ctx.db + .query("fileUploads") + .withIndex("by_storage", (q) => q.eq("storageId", storageId)) + .unique(); + + if (!file) { + return null; + } + + if (file.userId !== userId) { + throw new Error("Unauthorized: You do not own this file"); + } + + return file; +} + /** * Generates a URL for uploading a file to Convex storage. * Checks user ownership of chat, quota limits, and rate limits before allowing upload. @@ -34,25 +116,7 @@ export const generateUploadUrl = mutation({ throwRateLimitError("upload URL requests", retryAfter); } - // PERFORMANCE OPTIMIZATION: Fetch user and chat in parallel to reduce latency - // This reduces total wait time from T(user) + T(chat) to max(T(user), T(chat)) - const [user, chat] = await Promise.all([ - ctx.db.get(userId), - ctx.db.get(args.chatId), - ]); - - // Verify user exists - if (!user) { - throw new Error("User not found"); - } - - // Verify the chat exists and belongs to the user - if (!chat) { - throw new Error("Chat not found"); - } - if (chat.userId !== userId) { - throw new Error("Unauthorized: You do not own this chat"); - } + const user = await assertUserAndOwnedChat(ctx, userId, args.chatId); // Check if user has exceeded their file quota const currentFileCount = user.fileUploadCount || 0; @@ -111,25 +175,7 @@ export const saveFileMetadata = mutation({ // Validate file type validateFileType(args.contentType); - // PERFORMANCE OPTIMIZATION: Fetch user and chat in parallel - // This eliminates duplicate user lookup (was fetched again after file insert) - const [user, chat] = await Promise.all([ - ctx.db.get(userId), - ctx.db.get(args.chatId), - ]); - - // Verify user exists - if (!user) { - throw new Error("User not found"); - } - - // Verify the chat exists and belongs to the user - if (!chat) { - throw new Error("Chat not found"); - } - if (chat.userId !== userId) { - throw new Error("Unauthorized: You do not own this chat"); - } + const user = await assertUserAndOwnedChat(ctx, userId, args.chatId); // Sanitize the filename const sanitizedFilename = sanitizeFilename(args.filename); @@ -180,20 +226,13 @@ export const getFileUrl = query({ handler: async (ctx, args) => { const userId = await requireAuthUserId(ctx, args.userId); // Find the file by storage ID - const file = await ctx.db - .query("fileUploads") - .withIndex("by_storage", (q) => q.eq("storageId", args.storageId)) - .unique(); + const file = await getOwnedFileByStorageId(ctx, args.storageId, userId); // Verify file exists and user owns it if (!file) { return null; } - if (file.userId !== userId) { - throw new Error("Unauthorized: You do not own this file"); - } - // Check if file has been deleted if (file.deletedAt) { return null; @@ -307,20 +346,12 @@ export const deleteFile = mutation({ } // Find the file by storage ID - const file = await ctx.db - .query("fileUploads") - .withIndex("by_storage", (q) => q.eq("storageId", args.storageId)) - .unique(); + const file = await getOwnedFileByStorageId(ctx, args.storageId, userId); if (!file) { return { ok: false }; } - // Verify ownership - if (file.userId !== userId) { - throw new Error("Unauthorized: You do not own this file"); - } - // Check if already deleted if (file.deletedAt) { return { ok: false }; @@ -407,32 +438,18 @@ export const getFilesByChat = query({ ), handler: async (ctx, args) => { const userId = await requireAuthUserId(ctx, args.userId); - // Verify the chat exists and belongs to the user - const chat = await ctx.db.get(args.chatId); - if (!chat) { - throw new Error("Chat not found"); - } - if (chat.userId !== userId) { - throw new Error("Unauthorized: You do not own this chat"); - } + await getOwnedChat(ctx, args.chatId, userId); // Query all non-deleted files for this chat const files = await ctx.db .query("fileUploads") - .withIndex("by_chat", (q) => q.eq("chatId", args.chatId)) - .filter((q) => q.eq(q.field("deletedAt"), undefined)) + .withIndex("by_chat_not_deleted", (q) => + q.eq("chatId", args.chatId).eq("deletedAt", undefined) + ) .order("desc") .collect(); - return files.map((file) => ({ - _id: file._id, - _creationTime: file._creationTime, - storageId: file.storageId, - filename: file.filename, - contentType: file.contentType, - size: file.size, - uploadedAt: file.uploadedAt, - })); + return files.map(toFileSummary); }, }); @@ -470,14 +487,8 @@ export const getFilesByUser = query({ .collect(); return files.map((file) => ({ - _id: file._id, - _creationTime: file._creationTime, + ...toFileSummary(file), chatId: file.chatId, - storageId: file.storageId, - filename: file.filename, - contentType: file.contentType, - size: file.size, - uploadedAt: file.uploadedAt, })); }, }); diff --git a/apps/server/convex/http.ts b/apps/server/convex/http.ts index 5f15f148..c6b2a042 100644 --- a/apps/server/convex/http.ts +++ b/apps/server/convex/http.ts @@ -1,13 +1,45 @@ import "./polyfills"; +import type { FunctionReference } from "convex/server"; import { httpRouter } from "convex/server"; import { httpAction } from "./_generated/server"; -import { api, internal } from "./_generated/api"; import { authComponent, createAuth } from "./auth"; import { getAllowedOrigins, getCorsOrigin } from "./lib/origins"; import { createLogger } from "./lib/logger"; const logger = createLogger("http"); +type PublicStats = { + messages: number; + users: number; + chats: number; + stars: number; + models: number; +}; + +const getPublicStatsRef = "stats:getPublicStats" as unknown as FunctionReference< + "query", + "public", + Record, + PublicStats +>; + +const runCleanupBatchForWorkflowRef = "cleanupAction:runCleanupBatchForWorkflow" as unknown as FunctionReference< + "action", + "internal", + { + workflowToken: string; + retentionDays?: number; + batchSize?: number; + dryRun?: boolean; + }, + { + success: boolean; + deleted: number; + dryRun: boolean; + cutoffDate: string; + } +>; + const http = httpRouter(); // Register Better Auth routes with CORS enabled for client-side requests @@ -48,7 +80,7 @@ http.route({ const origin = request.headers.get("origin"); const allowedOrigin = getCorsOrigin(origin); - const stats = await ctx.runQuery(api.stats.getPublicStats, {}); + const stats = await ctx.runQuery(getPublicStatsRef, {}); const headers: Record = { "content-type": "application/json", @@ -113,7 +145,7 @@ http.route({ let result; try { - result = await ctx.runAction(internal.cleanupAction.runCleanupBatchForWorkflow, { + result = await ctx.runAction(runCleanupBatchForWorkflowRef, { workflowToken, retentionDays: typeof payload.retentionDays === "number" && Number.isFinite(payload.retentionDays) diff --git a/apps/server/convex/lib/auth.ts b/apps/server/convex/lib/auth.ts index ea85384e..610ee9a5 100644 --- a/apps/server/convex/lib/auth.ts +++ b/apps/server/convex/lib/auth.ts @@ -1,8 +1,16 @@ +import type { FunctionReference } from "convex/server"; import type { Id } from "../_generated/dataModel"; import type { ActionCtx, MutationCtx, QueryCtx } from "../_generated/server"; -import { api } from "../_generated/api"; type AuthCtx = QueryCtx | MutationCtx; +type ActionAuthUser = { _id: Id<"users"> } | null; + +const getByExternalIdRef = "users:getByExternalId" as unknown as FunctionReference< + "query", + "public", + { externalId: string }, + ActionAuthUser +>; export async function requireAuthUserId( ctx: AuthCtx, @@ -37,7 +45,7 @@ export async function requireAuthUserIdFromAction( throw new Error("Unauthorized"); } - const user = await ctx.runQuery(api.users.getByExternalId, { + const user = await ctx.runQuery(getByExternalIdRef, { externalId: identity.subject, }); if (!user) { diff --git a/apps/server/convex/message_queries.ts b/apps/server/convex/message_queries.ts index 9b759e54..8dfcc746 100644 --- a/apps/server/convex/message_queries.ts +++ b/apps/server/convex/message_queries.ts @@ -144,11 +144,11 @@ export const getActiveStream = query({ const streamingMessage = await ctx.db .query("messages") - .withIndex("by_chat_not_deleted", (q) => - q.eq("chatId", args.chatId).eq("deletedAt", undefined) + .withIndex("by_chat_status", (q) => + q.eq("chatId", args.chatId) + .eq("status", "streaming") + .eq("deletedAt", undefined) ) - .order("desc") - .filter((q) => q.eq(q.field("status"), "streaming")) .first(); return streamingMessage?.streamId ?? null; diff --git a/apps/server/convex/messages.ts b/apps/server/convex/messages.ts index 7f97b510..838ddce5 100644 --- a/apps/server/convex/messages.ts +++ b/apps/server/convex/messages.ts @@ -12,6 +12,10 @@ import { messageTypeValidator, } from "./message_validators"; import { insertOrUpdateMessage } from "./message_helpers"; +import { + completeActiveAndPendingStreams, + softDeleteMessagesAfter, +} from "./chat_cleanup_helpers"; export { list, getFirstUserMessage, getActiveStream } from "./message_queries"; @@ -181,41 +185,18 @@ export const editAndRegenerate = mutation({ } const now = Date.now(); - - const activeStreams = await ctx.db - .query("streamJobs") - .withIndex("by_chat", (q) => q.eq("chatId", args.chatId).eq("status", "running")) - .collect(); - const pendingStreams = await ctx.db - .query("streamJobs") - .withIndex("by_chat", (q) => q.eq("chatId", args.chatId).eq("status", "pending")) - .collect(); - - for (const stream of [...activeStreams, ...pendingStreams]) { - await ctx.db.patch(stream._id, { - status: "completed", - completedAt: now, - }); - } + await completeActiveAndPendingStreams(ctx, args.chatId, now); await ctx.db.patch(args.messageId, { content: newContent, }); - const messagesToDelete = await ctx.db - .query("messages") - .withIndex("by_chat_not_deleted", (q) => - q.eq("chatId", args.chatId).eq("deletedAt", undefined) - ) - .order("asc") - .filter((q) => q.gt(q.field("createdAt"), message.createdAt)) - .collect(); - - for (const msg of messagesToDelete) { - await ctx.db.patch(msg._id, { deletedAt: now }); - } - - const softDeletedCount = messagesToDelete.length; + const softDeletedCount = await softDeleteMessagesAfter( + ctx, + args.chatId, + message.createdAt, + now, + ); const currentCount = chat.messageCount ?? 0; await ctx.db.patch(args.chatId, { messageCount: Math.max(0, currentCount - softDeletedCount), @@ -263,37 +244,13 @@ export const retryMessage = mutation({ } const now = Date.now(); - - const activeStreams = await ctx.db - .query("streamJobs") - .withIndex("by_chat", (q) => q.eq("chatId", args.chatId).eq("status", "running")) - .collect(); - const pendingStreams = await ctx.db - .query("streamJobs") - .withIndex("by_chat", (q) => q.eq("chatId", args.chatId).eq("status", "pending")) - .collect(); - for (const stream of [...activeStreams, ...pendingStreams]) { - await ctx.db.patch(stream._id, { - status: "completed", - completedAt: now, - }); - } - - const allMessages = await ctx.db - .query("messages") - .withIndex("by_chat_not_deleted", (q) => - q.eq("chatId", args.chatId).eq("deletedAt", undefined) - ) - .order("asc") - .collect(); - - let softDeletedCount = 0; - for (const msg of allMessages) { - if (msg.createdAt > message.createdAt) { - await ctx.db.patch(msg._id, { deletedAt: now }); - softDeletedCount += 1; - } - } + await completeActiveAndPendingStreams(ctx, args.chatId, now); + const softDeletedCount = await softDeleteMessagesAfter( + ctx, + args.chatId, + message.createdAt, + now, + ); const currentCount = chat.messageCount ?? 0; await ctx.db.patch(args.chatId, { diff --git a/apps/server/convex/polyfills.ts b/apps/server/convex/polyfills.ts index 3d8c9a76..c552edfa 100644 --- a/apps/server/convex/polyfills.ts +++ b/apps/server/convex/polyfills.ts @@ -6,11 +6,11 @@ // polyfill MessageChannel without using node:events class MockMessagePort { - onmessage: ((this: MessagePort, ev: MessageEvent) => any) | null = null; - onmessageerror: ((this: MessagePort, ev: MessageEvent) => any) | null = null; + onmessage: ((this: MessagePort, ev: MessageEvent) => void) | null = null; + onmessageerror: ((this: MessagePort, ev: MessageEvent) => void) | null = null; close() {} - postMessage(_message: any, _transfer?: Transferable[]) {} + postMessage(_message: unknown, _transfer?: Transferable[]) {} start() {} addEventListener( _type: string, diff --git a/apps/server/convex/promptTemplates.ts b/apps/server/convex/promptTemplates.ts index 238937ef..59ddee96 100644 --- a/apps/server/convex/promptTemplates.ts +++ b/apps/server/convex/promptTemplates.ts @@ -1,5 +1,6 @@ -import { mutation, query } from "./_generated/server"; +import { mutation, query, type MutationCtx, type QueryCtx } from "./_generated/server"; import { v } from "convex/values"; +import type { Id } from "./_generated/dataModel"; import { rateLimiter } from "./lib/rateLimiter"; import { throwRateLimitError } from "./lib/rateLimitUtils"; import { sanitizeText } from "./lib/sanitize"; @@ -62,6 +63,33 @@ const promptTemplateListItemDoc = v.object({ const MAX_TEMPLATE_LIST_LIMIT = 200; const DEFAULT_TEMPLATE_LIST_LIMIT = 50; +type PromptTemplateCtx = MutationCtx | QueryCtx; + +async function getOwnedActiveTemplate( + ctx: PromptTemplateCtx, + templateId: Id<"promptTemplates">, + userId: Id<"users">, +) { + const template = await ctx.db.get(templateId); + if (!template || template.userId !== userId || template.deletedAt) { + return null; + } + return template; +} + +async function getActiveTemplateByCommand( + ctx: PromptTemplateCtx, + userId: Id<"users">, + command: string, +) { + return await ctx.db + .query("promptTemplates") + .withIndex("by_command_not_deleted", (q) => + q.eq("userId", userId).eq("command", command).eq("deletedAt", undefined) + ) + .first(); +} + export const list = query({ args: { userId: v.id("users"), @@ -131,11 +159,7 @@ export const get = query({ returns: v.union(promptTemplateDoc, v.null()), handler: async (ctx, args) => { const userId = await requireAuthUserId(ctx, args.userId); - const template = await ctx.db.get(args.templateId); - if (!template || template.userId !== userId || template.deletedAt) { - return null; - } - return template; + return await getOwnedActiveTemplate(ctx, args.templateId, userId); }, }); @@ -147,13 +171,7 @@ export const getByCommand = query({ returns: v.union(promptTemplateDoc, v.null()), handler: async (ctx, args) => { const userId = await requireAuthUserId(ctx, args.userId); - const templates = await ctx.db - .query("promptTemplates") - .withIndex("by_command", (q) => - q.eq("userId", userId).eq("command", args.command) - ) - .filter(q => q.eq(q.field("deletedAt"), undefined)) - .first(); + const templates = await getActiveTemplateByCommand(ctx, userId, args.command); if (!templates) return null; return templates; @@ -205,13 +223,7 @@ export const create = mutation({ } // Check for duplicate command - const existing = await ctx.db - .query("promptTemplates") - .withIndex("by_command", (q) => - q.eq("userId", userId).eq("command", sanitizedCommand) - ) - .filter(q => q.eq(q.field("deletedAt"), undefined)) - .first(); + const existing = await getActiveTemplateByCommand(ctx, userId, sanitizedCommand); if (existing) { throw new Error(`Command ${sanitizedCommand} already exists`); @@ -259,7 +271,7 @@ export const update = mutation({ throwRateLimitError("updates", retryAfter); } - const existing = await ctx.db.get(args.templateId); + const existing = await getOwnedActiveTemplate(ctx, args.templateId, userId); if (!existing || existing.userId !== userId || existing.deletedAt) { return { ok: false }; } @@ -274,13 +286,7 @@ export const update = mutation({ if (args.command !== undefined) { const sanitizedCommand = sanitizeCommand(args.command); // Check for duplicate command (excluding current template) - const duplicate = await ctx.db - .query("promptTemplates") - .withIndex("by_command", (q) => - q.eq("userId", userId).eq("command", sanitizedCommand) - ) - .filter(q => q.eq(q.field("deletedAt"), undefined)) - .first(); + const duplicate = await getActiveTemplateByCommand(ctx, userId, sanitizedCommand); if (duplicate && duplicate._id !== args.templateId) { throw new Error(`Command ${sanitizedCommand} already exists`); @@ -325,8 +331,8 @@ export const autoSave = mutation({ throwRateLimitError("auto-saves", retryAfter); } - const existing = await ctx.db.get(args.templateId); - if (!existing || existing.userId !== userId || existing.deletedAt) { + const existing = await getOwnedActiveTemplate(ctx, args.templateId, userId); + if (!existing) { return { ok: false }; } @@ -363,8 +369,8 @@ export const remove = mutation({ throwRateLimitError("deletions", retryAfter); } - const template = await ctx.db.get(args.templateId); - if (!template || template.userId !== userId || template.deletedAt) { + const template = await getOwnedActiveTemplate(ctx, args.templateId, userId); + if (!template) { return { ok: false }; } @@ -385,8 +391,8 @@ export const incrementUsage = mutation({ returns: v.object({ ok: v.boolean() }), handler: async (ctx, args) => { const userId = await requireAuthUserId(ctx, args.userId); - const template = await ctx.db.get(args.templateId); - if (!template || template.userId !== userId || template.deletedAt) { + const template = await getOwnedActiveTemplate(ctx, args.templateId, userId); + if (!template) { return { ok: false }; } diff --git a/apps/server/convex/schema.ts b/apps/server/convex/schema.ts index 75902b89..08d07572 100644 --- a/apps/server/convex/schema.ts +++ b/apps/server/convex/schema.ts @@ -195,6 +195,7 @@ export default defineSchema({ }) .index("by_user", ["userId", "uploadedAt"]) .index("by_chat", ["chatId", "uploadedAt"]) + .index("by_chat_not_deleted", ["chatId", "deletedAt", "uploadedAt"]) .index("by_storage", ["storageId"]) .index("by_user_not_deleted", ["userId", "deletedAt", "uploadedAt"]), // PERFORMANCE OPTIMIZATION: Database statistics table for efficient monitoring @@ -237,6 +238,7 @@ export default defineSchema({ }) .index("by_user", ["userId", "deletedAt", "updatedAt"]) .index("by_command", ["userId", "command"]) + .index("by_command_not_deleted", ["userId", "command", "deletedAt"]) .index("by_category", ["userId", "category", "deletedAt"]) .index("by_public", ["isPublic", "deletedAt"]) .index("by_draft", ["userId", "isDraft", "deletedAt"]), diff --git a/apps/server/convex/streamExecution.ts b/apps/server/convex/streamExecution.ts index 5b50e8e1..f012279c 100644 --- a/apps/server/convex/streamExecution.ts +++ b/apps/server/convex/streamExecution.ts @@ -1,8 +1,9 @@ +import type { FunctionReference } from "convex/server"; import { stepCountIs, streamText } from "ai"; import { createOpenRouter } from "@openrouter/ai-sdk-provider"; +import type { Id } from "./_generated/dataModel"; import { v } from "convex/values"; import { internalAction } from "./_generated/server"; -import { internal } from "./_generated/api"; import { createLogger } from "./lib/logger"; const logger = createLogger("streamExecution"); @@ -33,12 +34,165 @@ import { } from "./streamUtils"; import { executePrefetchedSearches } from "./streamWebSearch"; +type StreamJobOptions = { + enableReasoning?: boolean; + reasoningEffort?: string; + enableWebSearch?: boolean; + supportsToolCalls?: boolean; + maxSteps?: number; +}; + +type StreamExecutionJob = { + chatId: Id<"chats">; + userId: Id<"users">; + messageId: string; + model: string; + provider: string; + messages: Array<{ role: string; content: string }>; + options?: StreamJobOptions; + content: string; + createdAt: number; +} | null; + +type StreamExecutionMessage = { + role: "user" | "assistant" | "system"; + content: string; +}; + +type StreamUpdateArgs = { + jobId: Id<"streamJobs">; + content: string; + reasoning?: string; + chainOfThoughtParts?: unknown[]; + thinkingTimeMs?: number; + thinkingTimeSec?: number; + reasoningCharCount?: number; + reasoningChunkCount?: number; + reasoningTokenCount?: number; + reasoningRequested?: boolean; + webSearchUsed?: boolean; + webSearchCallCount?: number; + toolCallCount?: number; + status?: "pending" | "running" | "completed" | "error"; + error?: string; +}; + +type CompleteStreamArgs = { + jobId: Id<"streamJobs">; + content: string; + reasoning?: string; + chainOfThoughtParts?: unknown[]; + thinkingTimeMs?: number; + thinkingTimeSec?: number; + reasoningCharCount?: number; + reasoningChunkCount?: number; + reasoningTokenCount?: number; + reasoningRequested?: boolean; + webSearchUsed?: boolean; + webSearchCallCount?: number; + toolCallCount?: number; + tokensPerSecond?: number; + timeToFirstTokenMs?: number; + totalDurationMs?: number; + tokenUsage?: { + promptTokens: number; + completionTokens: number; + totalTokens: number; + }; +}; + +type FailStreamArgs = { + jobId: Id<"streamJobs">; + error: string; + partialContent?: string; +}; + +type SearchLimitResult = { + canSearch: boolean; + remaining: number; +}; + +const getJobInternalRef = "backgroundStream:getJobInternal" as unknown as FunctionReference< + "query", + "internal", + { jobId: Id<"streamJobs"> }, + StreamExecutionJob +>; + +const failStreamRef = "backgroundStream:failStream" as unknown as FunctionReference< + "mutation", + "internal", + FailStreamArgs, + null +>; + +const updateStreamContentRef = + "backgroundStream:updateStreamContent" as unknown as FunctionReference< + "mutation", + "internal", + StreamUpdateArgs, + null + >; + +const completeStreamRef = "backgroundStream:completeStream" as unknown as FunctionReference< + "mutation", + "internal", + CompleteStreamArgs, + null +>; + +const getOpenRouterKeyInternalRef = + "users:getOpenRouterKeyInternal" as unknown as FunctionReference< + "query", + "internal", + { userId: Id<"users"> }, + string | null + >; + +const checkSearchLimitInternalRef = + "search:checkSearchLimitInternal" as unknown as FunctionReference< + "query", + "internal", + { userId: Id<"users"> }, + SearchLimitResult + >; + +const incrementAiUsageRef = "users:incrementAiUsage" as unknown as FunctionReference< + "mutation", + "internal", + { userId: Id<"users">; usageCents: number }, + unknown +>; + +function toStreamMessages( + messages: Array<{ role: string; content: string }>, +): StreamExecutionMessage[] { + return messages.map((message) => ({ + role: message.role as StreamExecutionMessage["role"], + content: message.content, + })); +} + +function prependSystemMessages( + messages: StreamExecutionMessage[], + systemMessages: string[], +): StreamExecutionMessage[] { + if (systemMessages.length === 0) { + return messages; + } + + return [ + ...systemMessages.map((content) => ({ role: "system" as const, content })), + ...messages, + ]; +} + export const executeStream = internalAction({ args: { jobId: v.id("streamJobs"), }, handler: async (ctx, args) => { - const job = await ctx.runQuery(internal.backgroundStream.getJobInternal, { + const job = await ctx.runQuery(getJobInternalRef, { jobId: args.jobId, }); @@ -55,14 +209,14 @@ export const executeStream = internalAction({ reservedDateKey = currentDate; if (reservedTotal > DAILY_AI_LIMIT_CENTS) { await adjustDailyUsageInUpstash(job.userId, currentDate, -reservedUsageCents); - await ctx.runMutation(internal.backgroundStream.failStream, { + await ctx.runMutation(failStreamRef, { jobId: args.jobId, error: "Daily usage limit reached. Connect your OpenRouter account to continue.", }); return; } } else { - await ctx.runMutation(internal.backgroundStream.failStream, { + await ctx.runMutation(failStreamRef, { jobId: args.jobId, error: "Usage tracking temporarily unavailable. Please retry shortly.", }); @@ -70,7 +224,7 @@ export const executeStream = internalAction({ } } - await ctx.runMutation(internal.backgroundStream.updateStreamContent, { + await ctx.runMutation(updateStreamContentRef, { jobId: args.jobId, content: "", status: "running", @@ -84,14 +238,14 @@ export const executeStream = internalAction({ if (job.provider === "osschat") { apiKey = OPENROUTER_API_KEY ?? null; } else { - const encryptedKey = await ctx.runQuery(internal.users.getOpenRouterKeyInternal, { + const encryptedKey = await ctx.runQuery(getOpenRouterKeyInternalRef, { userId: job.userId, }); apiKey = encryptedKey ? await decryptSecret(encryptedKey) : null; } if (!apiKey) { - await ctx.runMutation(internal.backgroundStream.failStream, { + await ctx.runMutation(failStreamRef, { jobId: args.jobId, error: "No API key available", }); @@ -115,7 +269,7 @@ export const executeStream = internalAction({ if (!force && state.pendingUpdateCounter < UPDATE_INTERVAL) return; state.pendingUpdateCounter = 0; const toolMetrics = getToolMetrics(state); - await ctx.runMutation(internal.backgroundStream.updateStreamContent, { + await ctx.runMutation(updateStreamContentRef, { jobId: args.jobId, content: state.fullContent, reasoning: reasoningRequested ? state.fullReasoning || undefined : undefined, @@ -146,18 +300,7 @@ export const executeStream = internalAction({ job.options?.reasoningEffort, ); - const streamOptions: Parameters[0] = { - model: aiModel as Parameters[0]["model"], - messages: job.messages.map((message: { role: string; content: string }) => ({ - role: message.role as "user" | "assistant" | "system", - content: message.content, - })), - abortSignal: controller.signal, - maxOutputTokens, - providerOptions: { - openrouter: openRouterOptions, - }, - }; + let streamMessages = toStreamMessages(job.messages); const webSearchRequested = Boolean(job.options?.enableWebSearch); const supportsToolCalls = job.options?.supportsToolCalls !== false; @@ -167,7 +310,7 @@ export const executeStream = internalAction({ let availableSearches = 0; if (webSearchRequested) { - const searchLimit = await ctx.runQuery(internal.search.checkSearchLimitInternal, { + const searchLimit = await ctx.runQuery(checkSearchLimitInternalRef, { userId: job.userId, }); availableSearches = searchLimit.remaining; @@ -193,14 +336,9 @@ export const executeStream = internalAction({ unavailableToolPart.state = "output-error"; unavailableToolPart.errorText = webSearchUnavailableReason ?? "Web search is unavailable."; state.pendingUpdateCounter++; - streamOptions.messages = [ - { - role: "system", - content: - "Web search is unavailable for this request. Do not claim live web access; answer using existing knowledge only.", - }, - ...(streamOptions.messages as Array<{ role: "user" | "assistant" | "system"; content: string }>), - ]; + streamMessages = prependSystemMessages(streamMessages, [ + "Web search is unavailable for this request. Do not claim live web access; answer using existing knowledge only.", + ]); } if (webSearchMode === "tool" && valyuApiKey) { @@ -215,18 +353,10 @@ export const executeStream = internalAction({ ); if (contextChunks.length > 0) { - streamOptions.messages = [ - { - role: "system", - content: - "Use the following web search results for up-to-date facts. Cite source URLs in your answer when making factual claims.", - }, - { - role: "system", - content: `Web search results:\n${contextChunks.join("\n\n")}`, - }, - ...(streamOptions.messages as Array<{ role: "user" | "assistant" | "system"; content: string }>), - ]; + streamMessages = prependSystemMessages(streamMessages, [ + "Use the following web search results for up-to-date facts. Cite source URLs in your answer when making factual claims.", + `Web search results:\n${contextChunks.join("\n\n")}`, + ]); } } @@ -237,9 +367,16 @@ export const executeStream = internalAction({ ? Math.floor(job.options.maxSteps) : undefined; const stepLimit = Math.max(1, Math.min(configuredMaxSteps ?? 1, 10)); - streamOptions.stopWhen = stepCountIs(stepLimit); - - const result = streamText(streamOptions); + const result = streamText({ + model: aiModel, + messages: streamMessages, + abortSignal: controller.signal, + maxOutputTokens, + providerOptions: { + openrouter: openRouterOptions, + }, + stopWhen: stepCountIs(stepLimit), + }); for await (const part of result.fullStream) { switch (part.type) { @@ -400,7 +537,7 @@ export const executeStream = internalAction({ if (usageCents && usageCents > 0) { for (let attempt = 0; attempt < 2; attempt++) { try { - await ctx.runMutation(internal.users.incrementAiUsage, { + await ctx.runMutation(incrementAiUsageRef, { userId: job.userId, usageCents, }); @@ -441,7 +578,7 @@ export const executeStream = internalAction({ const thinkingTimeSec = getThinkingTimeSec(state); const toolMetrics = getToolMetrics(state); - await ctx.runMutation(internal.backgroundStream.completeStream, { + await ctx.runMutation(completeStreamRef, { jobId: args.jobId, content: state.fullContent, reasoning: reasoningRequested ? state.fullReasoning || undefined : undefined, @@ -475,7 +612,7 @@ export const executeStream = internalAction({ void logger.error("Upstash refund adjustment failed", adjustError); } } - await ctx.runMutation(internal.backgroundStream.failStream, { + await ctx.runMutation(failStreamRef, { jobId: args.jobId, error: "An error occurred while processing your request.", partialContent: state.fullContent, diff --git a/apps/server/convex/streamJobs.ts b/apps/server/convex/streamJobs.ts index aeed3138..7e6f97dc 100644 --- a/apps/server/convex/streamJobs.ts +++ b/apps/server/convex/streamJobs.ts @@ -23,7 +23,13 @@ export const streamOptionsValidator = v.object({ maxSteps: v.optional(v.number()), }); -const streamJobOptionsReturnValidator = v.record(v.string(), v.any()); +type StreamJobOptions = { + enableReasoning?: boolean; + reasoningEffort?: string; + enableWebSearch?: boolean; + supportsToolCalls?: boolean; + maxSteps?: number; +}; const executeStreamRef = "streamExecution:executeStream" as unknown as FunctionReference< "action", @@ -65,7 +71,7 @@ const streamJobReturnShape = v.union( status: v.string(), model: v.string(), provider: v.string(), - options: v.optional(streamJobOptionsReturnValidator), + options: v.optional(streamOptionsValidator), content: v.string(), reasoning: v.optional(v.string()), chainOfThoughtParts: v.optional(v.array(chainOfThoughtPartValidator)), @@ -89,7 +95,7 @@ function pickJobFields(job: { status: string; model: string; provider: string; - options?: Record; + options?: StreamJobOptions; content: string; reasoning?: string; chainOfThoughtParts?: ChainOfThoughtPart[]; diff --git a/apps/server/convex/streamWebSearch.ts b/apps/server/convex/streamWebSearch.ts index e237bfcc..9c7867d5 100644 --- a/apps/server/convex/streamWebSearch.ts +++ b/apps/server/convex/streamWebSearch.ts @@ -1,6 +1,6 @@ +import type { FunctionReference } from "convex/server"; import { webSearch } from "@valyu/ai-sdk"; import type { Id } from "./_generated/dataModel"; -import { internal } from "./_generated/api"; import { compactWebSearchOutput, searchOutputToContext, @@ -13,10 +13,18 @@ import { } from "./streamUtils"; import type { StreamState } from "./streamUtils"; +const incrementSearchUsageInternalRef = + "search:incrementSearchUsageInternal" as unknown as FunctionReference< + "mutation", + "internal", + { userId: Id<"users"> }, + unknown + >; + export async function executePrefetchedSearches( ctx: { runMutation: ( - fn: typeof internal.search.incrementSearchUsageInternal, + fn: typeof incrementSearchUsageInternalRef, args: { userId: Id<"users"> }, ) => Promise; }, @@ -58,7 +66,7 @@ export async function executePrefetchedSearches( // so it will throw if the limit is already reached. // This prevents the TOCTOU race where two concurrent requests // could both pass the initial check and exceed the limit. - await ctx.runMutation(internal.search.incrementSearchUsageInternal, { + await ctx.runMutation(incrementSearchUsageInternalRef, { userId, }); diff --git a/apps/server/convex/userDelete.ts b/apps/server/convex/userDelete.ts index 4bac5528..a95b593a 100644 --- a/apps/server/convex/userDelete.ts +++ b/apps/server/convex/userDelete.ts @@ -1,11 +1,73 @@ import { action, internalMutation, mutation } from "./_generated/server"; import { v } from "convex/values"; +import type { FunctionReference } from "convex/server"; import { decrementStat, STAT_KEYS } from "./lib/dbStats"; -import { components, internal } from "./_generated/api"; +import { components } from "./_generated/api"; import { requireAuthUserId, requireAuthUserIdFromAction } from "./lib/auth"; const MAX_DELETE_BATCH_LOOPS = 1_000; +type DeleteBatchResult = { + deleted: number; + hasMore: boolean; +}; + +const deleteUserStreamJobsRef = + "users:deleteUserStreamJobs" as unknown as FunctionReference< + "mutation", + "internal", + { userId: string; batchSize?: number }, + DeleteBatchResult + >; + +const deleteUserMessagesRef = + "users:deleteUserMessages" as unknown as FunctionReference< + "mutation", + "internal", + { userId: string; batchSize?: number }, + DeleteBatchResult + >; + +const deleteUserChatsRef = + "users:deleteUserChats" as unknown as FunctionReference< + "mutation", + "internal", + { userId: string; batchSize?: number }, + DeleteBatchResult + >; + +const deleteUserFilesRef = + "users:deleteUserFiles" as unknown as FunctionReference< + "mutation", + "internal", + { userId: string; batchSize?: number }, + DeleteBatchResult + >; + +const deleteUserChatReadStatusesRef = + "users:deleteUserChatReadStatuses" as unknown as FunctionReference< + "mutation", + "internal", + { userId: string }, + DeleteBatchResult + >; + +const deleteUserPromptTemplatesRef = + "users:deleteUserPromptTemplates" as unknown as FunctionReference< + "mutation", + "internal", + { userId: string }, + DeleteBatchResult + >; + +const deleteUserRecordRef = + "users:deleteUserRecord" as unknown as FunctionReference< + "mutation", + "internal", + { userId: string; externalId: string }, + { success: boolean } + >; + export const deleteUserRecord = internalMutation({ args: { userId: v.id("users"), @@ -90,36 +152,36 @@ export const deleteAccountWorkflowStep = action({ switch (args.step) { case "delete-stream-jobs": - return await ctx.runMutation(internal.users.deleteUserStreamJobs, { + return await ctx.runMutation(deleteUserStreamJobsRef, { userId, batchSize: args.batchSize, }); case "delete-messages": - return await ctx.runMutation(internal.users.deleteUserMessages, { + return await ctx.runMutation(deleteUserMessagesRef, { userId, batchSize: args.batchSize, }); case "delete-chats": - return await ctx.runMutation(internal.users.deleteUserChats, { + return await ctx.runMutation(deleteUserChatsRef, { userId, batchSize: args.batchSize, }); case "delete-files": - return await ctx.runMutation(internal.users.deleteUserFiles, { + return await ctx.runMutation(deleteUserFilesRef, { userId, batchSize: args.batchSize, }); case "delete-chat-read-statuses": - return await ctx.runMutation(internal.users.deleteUserChatReadStatuses, { + return await ctx.runMutation(deleteUserChatReadStatusesRef, { userId, }); case "delete-prompt-templates": - return await ctx.runMutation(internal.users.deleteUserPromptTemplates, { + return await ctx.runMutation(deleteUserPromptTemplatesRef, { userId, }); case "delete-user": { const result: { success: boolean } = await ctx.runMutation( - internal.users.deleteUserRecord, + deleteUserRecordRef, { userId, externalId: args.externalId, @@ -190,7 +252,7 @@ export const deleteAccount = mutation({ // 4. Delete streamJobs for (let batch = 0; batch < MAX_DELETE_BATCH_LOOPS; batch++) { - const result = await ctx.runMutation(internal.users.deleteUserStreamJobs, { + const result = await ctx.runMutation(deleteUserStreamJobsRef, { userId, }); if (!result.hasMore) break; @@ -198,7 +260,7 @@ export const deleteAccount = mutation({ // 5. Delete chatReadStatus for (let batch = 0; batch < MAX_DELETE_BATCH_LOOPS; batch++) { - const result = await ctx.runMutation(internal.users.deleteUserChatReadStatuses, { + const result = await ctx.runMutation(deleteUserChatReadStatusesRef, { userId, }); if (!result.hasMore) break; @@ -206,7 +268,7 @@ export const deleteAccount = mutation({ // 6. Delete fileUploads AND storage blobs for (let batch = 0; batch < MAX_DELETE_BATCH_LOOPS; batch++) { - const result = await ctx.runMutation(internal.users.deleteUserFiles, { + const result = await ctx.runMutation(deleteUserFilesRef, { userId, }); if (!result.hasMore) break; @@ -214,7 +276,7 @@ export const deleteAccount = mutation({ // 7. Delete messages (all messages for all user's chats) for (let batch = 0; batch < MAX_DELETE_BATCH_LOOPS; batch++) { - const result = await ctx.runMutation(internal.users.deleteUserMessages, { + const result = await ctx.runMutation(deleteUserMessagesRef, { userId, }); if (!result.hasMore) break; @@ -222,7 +284,7 @@ export const deleteAccount = mutation({ // 8. Delete chats for (let batch = 0; batch < MAX_DELETE_BATCH_LOOPS; batch++) { - const result = await ctx.runMutation(internal.users.deleteUserChats, { + const result = await ctx.runMutation(deleteUserChatsRef, { userId, }); if (!result.hasMore) break; @@ -230,7 +292,7 @@ export const deleteAccount = mutation({ // 9. Delete promptTemplates for (let batch = 0; batch < MAX_DELETE_BATCH_LOOPS; batch++) { - const result = await ctx.runMutation(internal.users.deleteUserPromptTemplates, { + const result = await ctx.runMutation(deleteUserPromptTemplatesRef, { userId, }); if (!result.hasMore) break; diff --git a/apps/web/src/components/ai-elements/prompt-input-speech.tsx b/apps/web/src/components/ai-elements/prompt-input-speech.tsx index a533cbee..e3ac9c06 100644 --- a/apps/web/src/components/ai-elements/prompt-input-speech.tsx +++ b/apps/web/src/components/ai-elements/prompt-input-speech.tsx @@ -10,10 +10,10 @@ interface SpeechRecognition extends EventTarget { lang: string; start: () => void; stop: () => void; - onstart: ((this: SpeechRecognition, ev: Event) => any) | null; - onend: ((this: SpeechRecognition, ev: Event) => any) | null; - onresult: ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => any) | null; - onerror: ((this: SpeechRecognition, ev: SpeechRecognitionErrorEvent) => any) | null; + onstart: ((this: SpeechRecognition, ev: Event) => void) | null; + onend: ((this: SpeechRecognition, ev: Event) => void) | null; + onresult: ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => void) | null; + onerror: ((this: SpeechRecognition, ev: SpeechRecognitionErrorEvent) => void) | null; } interface SpeechRecognitionEvent extends Event { @@ -43,14 +43,14 @@ interface SpeechRecognitionErrorEvent extends Event { error: string; } +interface SpeechRecognitionConstructor { + new (): SpeechRecognition; +} + declare global { interface Window { - SpeechRecognition: { - new (): SpeechRecognition; - }; - webkitSpeechRecognition: { - new (): SpeechRecognition; - }; + SpeechRecognition?: SpeechRecognitionConstructor; + webkitSpeechRecognition?: SpeechRecognitionConstructor; } } @@ -72,10 +72,8 @@ export const PromptInputSpeechButton = ({ useEffect(() => { if (typeof window === "undefined") return; - /* eslint-disable @typescript-eslint/no-unnecessary-condition */ const SpeechRecognitionClass = window.SpeechRecognition ?? window.webkitSpeechRecognition; if (SpeechRecognitionClass) { - /* eslint-enable @typescript-eslint/no-unnecessary-condition */ const speechRecognition = new SpeechRecognitionClass(); speechRecognition.continuous = true; diff --git a/apps/web/src/components/chat/chat-interface.tsx b/apps/web/src/components/chat/chat-interface.tsx index 8ce58eec..c667641a 100644 --- a/apps/web/src/components/chat/chat-interface.tsx +++ b/apps/web/src/components/chat/chat-interface.tsx @@ -26,11 +26,9 @@ import { PremiumPromptInputInner } from "./premium-prompt-input"; import type { UIDataTypes, UIMessagePart, UITools } from "ai"; function useIsMac() { - const [isMac, setIsMac] = useState(true); - - useEffect(() => { - setIsMac(navigator.platform.toLowerCase().includes("mac")); - }, []); + const [isMac] = useState(() => + typeof navigator === "undefined" ? true : navigator.platform.toLowerCase().includes("mac"), + ); return isMac; } diff --git a/apps/web/src/components/model-selector.tsx b/apps/web/src/components/model-selector.tsx index 68a24765..99596426 100644 --- a/apps/web/src/components/model-selector.tsx +++ b/apps/web/src/components/model-selector.tsx @@ -1,6 +1,7 @@ import { useCallback, useDeferredValue, useEffect, useLayoutEffect, useRef, useState } from "react"; import { createPortal, flushSync } from "react-dom"; import type { Model } from "@/stores/model"; +import { useIsMobile } from "@/hooks/use-mobile"; import { cn } from "@/lib/utils"; import { getModelById, useModelStore, useModels } from "@/stores/model"; import { useFavoriteModels } from "@/hooks/use-favorite-models"; @@ -13,7 +14,7 @@ import { ModelInfoPanel } from "@/components/model-info-panel"; import { ProviderLogo } from "./model-selector/provider-logo"; import { MobileDrawer } from "./model-selector/mobile-drawer"; import { DesktopDropdown } from "./model-selector/desktop-dropdown"; -import { useIsMobile, useUniqueProviders, useFilteredModels, useFlatList } from "./model-selector/use-model-filter"; +import { useUniqueProviders, useFilteredModels, useFlatList } from "./model-selector/use-model-filter"; interface ModelSelectorProps { value: string; diff --git a/apps/web/src/components/model-selector/use-model-filter.ts b/apps/web/src/components/model-selector/use-model-filter.ts index f6be2e36..9ee2a841 100644 --- a/apps/web/src/components/model-selector/use-model-filter.ts +++ b/apps/web/src/components/model-selector/use-model-filter.ts @@ -1,6 +1,8 @@ -import { useEffect, useState, useMemo } from "react"; +import { useMemo } from "react"; import type { Model } from "@/stores/model"; +export { useIsMobile } from "@/hooks/use-mobile"; + export type UniqueProvider = { id: string; name: string; @@ -9,19 +11,6 @@ export type UniqueProvider = { count: number; }; -export function useIsMobile() { - const [isMobile, setIsMobile] = useState(false); - - useEffect(() => { - const checkMobile = () => setIsMobile(window.innerWidth < 768); - checkMobile(); - window.addEventListener("resize", checkMobile); - return () => window.removeEventListener("resize", checkMobile); - }, []); - - return isMobile; -} - export function useUniqueProviders(models: Model[]): UniqueProvider[] { return useMemo(() => { const providerMap = new Map(); diff --git a/apps/web/src/hooks/use-favorite-models.ts b/apps/web/src/hooks/use-favorite-models.ts index a93dcdc1..e9e7ba96 100644 --- a/apps/web/src/hooks/use-favorite-models.ts +++ b/apps/web/src/hooks/use-favorite-models.ts @@ -15,10 +15,18 @@ const DEFAULT_FAVORITES = [ "x-ai/grok-4.1-fast", ]; +function serializeFavorites(favorites: Iterable) { + return JSON.stringify(Array.from(favorites).sort()); +} + export function useFavoriteModels() { const { user } = useAuth(); const [favorites, setFavorites] = useState>(new Set()); const isInitialized = useRef(false); + const activeUserIdRef = useRef(null); + const lastServerSnapshotRef = useRef(null); + const lastLocalSnapshotRef = useRef(serializeFavorites([])); + const hasPendingOptimisticUpdateRef = useRef(false); const convexUser = useQuery( api.users.getByExternalId, @@ -35,12 +43,49 @@ export function useFavoriteModels() { const toggleFavoriteMutation = useMutation(api.users.toggleFavoriteModel); const setFavoritesMutation = useMutation(api.users.setFavoriteModels); + const applyFavorites = useCallback((nextFavorites: Set, markOptimistic = false) => { + lastLocalSnapshotRef.current = serializeFavorites(nextFavorites); + hasPendingOptimisticUpdateRef.current = markOptimistic; + setFavorites(new Set(nextFavorites)); + }, []); + useEffect(() => { - if (serverFavorites !== undefined && !isInitialized.current) { - setFavorites(new Set(serverFavorites ?? [])); - isInitialized.current = true; + if (!convexUserId) { + activeUserIdRef.current = null; + lastServerSnapshotRef.current = null; + isInitialized.current = false; + applyFavorites(new Set()); + return; + } + + if (serverFavorites === undefined) { + if (activeUserIdRef.current !== convexUserId) { + activeUserIdRef.current = convexUserId; + lastServerSnapshotRef.current = null; + isInitialized.current = false; + applyFavorites(new Set()); + } + return; } - }, [serverFavorites]); + + const serverSet = new Set(serverFavorites ?? []); + const serverSnapshot = serializeFavorites(serverSet); + const userChanged = activeUserIdRef.current !== convexUserId; + const serverSnapshotChanged = serverSnapshot !== lastServerSnapshotRef.current; + + if ( + userChanged || + !hasPendingOptimisticUpdateRef.current || + serverSnapshot === lastLocalSnapshotRef.current || + serverSnapshotChanged + ) { + applyFavorites(serverSet); + } + + activeUserIdRef.current = convexUserId; + lastServerSnapshotRef.current = serverSnapshot; + isInitialized.current = true; + }, [convexUserId, serverFavorites, applyFavorites]); const toggleFavorite = useCallback( (modelId: string) => { @@ -53,6 +98,8 @@ export function useFavoriteModels() { } else { next.add(modelId); } + lastLocalSnapshotRef.current = serializeFavorites(next); + hasPendingOptimisticUpdateRef.current = true; return next; }); @@ -68,11 +115,11 @@ export function useFavoriteModels() { const addDefaults = useCallback(() => { const newFavorites = new Set([...favorites, ...DEFAULT_FAVORITES]); - setFavorites(newFavorites); + applyFavorites(newFavorites, true); if (convexUserId) { setFavoritesMutation({ userId: convexUserId, modelIds: Array.from(newFavorites) }); } - }, [convexUserId, setFavoritesMutation, favorites]); + }, [applyFavorites, convexUserId, setFavoritesMutation, favorites]); const missingDefaults = DEFAULT_FAVORITES.filter((id) => !favorites.has(id)); const missingDefaultsCount = missingDefaults.length; diff --git a/apps/web/src/hooks/use-mobile.ts b/apps/web/src/hooks/use-mobile.ts index 2b0fe1df..8e4f4901 100644 --- a/apps/web/src/hooks/use-mobile.ts +++ b/apps/web/src/hooks/use-mobile.ts @@ -6,13 +6,30 @@ export function useIsMobile() { const [isMobile, setIsMobile] = React.useState(undefined) React.useEffect(() => { - const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) const onChange = () => { setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) } - mql.addEventListener("change", onChange) + + const mql = + typeof window.matchMedia === "function" + ? window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) + : null + + if (mql) { + mql.addEventListener("change", onChange) + } else { + window.addEventListener("resize", onChange) + } + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) - return () => mql.removeEventListener("change", onChange) + + return () => { + if (mql) { + mql.removeEventListener("change", onChange) + } else { + window.removeEventListener("resize", onChange) + } + } }, []) return !!isMobile diff --git a/apps/web/src/hooks/use-prompt-draft.ts b/apps/web/src/hooks/use-prompt-draft.ts index 59ce5d62..424b102e 100644 --- a/apps/web/src/hooks/use-prompt-draft.ts +++ b/apps/web/src/hooks/use-prompt-draft.ts @@ -39,17 +39,19 @@ export function usePromptDraft({ chatId, textInputController }: UsePromptDraftOp // Track if we've already restored the draft (to avoid overwriting user input) const hasRestoredRef = useRef(false); + const skipNextSaveRef = useRef(false); // Restore draft on mount or when chatId changes useEffect(() => { // Reset restoration flag when chatId changes hasRestoredRef.current = false; + skipNextSaveRef.current = true; const savedDraft = getDraft(chatId); if (savedDraft) { - hasRestoredRef.current = true; textInputController.setInput(savedDraft); } + hasRestoredRef.current = true; // Cleanup debounce on unmount return () => { @@ -72,17 +74,24 @@ export function usePromptDraft({ chatId, textInputController }: UsePromptDraftOp // Using chatId and setDraft directly to avoid re-running on saveDraft recreation useEffect(() => { // Only save after initial restoration - if (hasRestoredRef.current) { - // Clear any existing timeout - if (debounceTimeoutRef.current) { - clearTimeout(debounceTimeoutRef.current); - } + if (!hasRestoredRef.current) { + return; + } - // Schedule a new save - debounceTimeoutRef.current = setTimeout(() => { - setDraft(chatId, textInputController.value); - }, DEBOUNCE_MS); + if (skipNextSaveRef.current) { + skipNextSaveRef.current = false; + return; } + + // Clear any existing timeout + if (debounceTimeoutRef.current) { + clearTimeout(debounceTimeoutRef.current); + } + + // Schedule a new save + debounceTimeoutRef.current = setTimeout(() => { + setDraft(chatId, textInputController.value); + }, DEBOUNCE_MS); }, [textInputController.value, chatId, setDraft]); return { diff --git a/apps/web/src/lib/redis.ts b/apps/web/src/lib/redis.ts index 89a21b16..784fdb5f 100644 --- a/apps/web/src/lib/redis.ts +++ b/apps/web/src/lib/redis.ts @@ -54,30 +54,56 @@ async function getConnectedClient() { return redisStore; } +function isStreamMetaStatus(value: unknown): value is StreamMeta["status"] { + return value === "streaming" || value === "completed" || value === "error"; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + function parseStreamMeta(value: unknown): StreamMeta | null { if (!value) return null; if (typeof value === "string") { try { - return JSON.parse(value) as StreamMeta; + return parseStreamMeta(JSON.parse(value)); } catch { return null; } } - if (typeof value === "object") { - const candidate = value as Partial; - if ( - (candidate.status === "streaming" || - candidate.status === "completed" || - candidate.status === "error") && - typeof candidate.chatId === "string" && - typeof candidate.userId === "string" && - typeof candidate.messageId === "string" && - typeof candidate.startedAt === "number" - ) { - return candidate as StreamMeta; - } + + if (!isRecord(value)) { + return null; + } + + const { status, chatId, userId, messageId, startedAt, completedAt, error } = value; + if ( + !isStreamMetaStatus(status) || + typeof chatId !== "string" || + typeof userId !== "string" || + typeof messageId !== "string" || + typeof startedAt !== "number" + ) { + return null; } - return null; + + if (completedAt !== undefined && typeof completedAt !== "number") { + return null; + } + + if (error !== undefined && typeof error !== "string") { + return null; + } + + return { + status, + chatId, + userId, + messageId, + startedAt, + ...(completedAt !== undefined ? { completedAt } : {}), + ...(error !== undefined ? { error } : {}), + }; } export async function initStream( diff --git a/apps/web/src/providers/index.tsx b/apps/web/src/providers/index.tsx index 63957ee7..0e76540c 100644 --- a/apps/web/src/providers/index.tsx +++ b/apps/web/src/providers/index.tsx @@ -81,33 +81,53 @@ function useStableConvexAuth() { function UserSyncProvider({ children }: { children: React.ReactNode }) { const { user, isAuthenticated, loading } = useAuth(); const ensureUser = useMutation(api.users.ensure); - const syncedRef = useRef(false); - const syncingRef = useRef(false); + const syncedUserIdRef = useRef(null); + const syncingUserIdRef = useRef(null); useEffect(() => { + if (!isAuthenticated || !user?.id) { + syncedUserIdRef.current = null; + syncingUserIdRef.current = null; + } + }, [isAuthenticated, user?.id]); + + useEffect(() => { + const userId = user?.id; + // Only sync once when user is authenticated and we have user data - if (loading || !isAuthenticated || !user?.id || syncedRef.current || syncingRef.current) { + if ( + loading || + !isAuthenticated || + !userId || + syncedUserIdRef.current === userId || + syncingUserIdRef.current === userId + ) { return; } - syncingRef.current = true; + syncingUserIdRef.current = userId; // Sync Better Auth user to Convex users table ensureUser({ - externalId: user.id, + externalId: userId, email: user.email, name: user.name, avatarUrl: user.image ?? undefined, }) .then(() => { - syncedRef.current = true; + syncedUserIdRef.current = userId; + if (syncingUserIdRef.current === userId) { + syncingUserIdRef.current = null; + } }) .catch((error) => { console.error("[UserSync] Failed to sync user to Convex:", error); // Reset so we can retry on next render - syncingRef.current = false; + if (syncingUserIdRef.current === userId) { + syncingUserIdRef.current = null; + } }); - }, [loading, isAuthenticated, user, ensureUser]); + }, [loading, isAuthenticated, user?.id, user?.email, user?.name, user?.image, ensureUser]); return <>{children}; } @@ -148,37 +168,49 @@ function UsageSyncProvider({ children }: { children: React.ReactNode }) { * This syncs the hasApiKey state without exposing the actual key to the client. */ function OpenRouterKeyStatusProvider({ children }: { children: React.ReactNode }) { - const { isAuthenticated, loading } = useAuth(); + const { user, isAuthenticated, loading } = useAuth(); const initialize = useOpenRouterStore((s) => s.initialize); - const checkedRef = useRef(false); - const checkingRef = useRef(false); + const checkedUserIdRef = useRef(null); + const checkingUserIdRef = useRef(null); useEffect(() => { + if (!isAuthenticated || !user?.id) { + checkedUserIdRef.current = null; + checkingUserIdRef.current = null; + } + }, [isAuthenticated, user?.id]); + + useEffect(() => { + const userId = user?.id; + // Only check once when user is authenticated - if (loading || !isAuthenticated || checkedRef.current || checkingRef.current) { + if ( + loading || + !isAuthenticated || + !userId || + checkedUserIdRef.current === userId || + checkingUserIdRef.current === userId + ) { return; } - checkingRef.current = true; + checkingUserIdRef.current = userId; initialize() .then(() => { - checkedRef.current = true; + checkedUserIdRef.current = userId; + if (checkingUserIdRef.current === userId) { + checkingUserIdRef.current = null; + } }) .catch((error: unknown) => { console.error("[OpenRouterKeyStatus] Failed to check API key status:", error); // Reset so we can retry on next render - checkingRef.current = false; + if (checkingUserIdRef.current === userId) { + checkingUserIdRef.current = null; + } }); - }, [loading, isAuthenticated, initialize]); - - // Reset checked state when user logs out - useEffect(() => { - if (!isAuthenticated && !loading) { - checkedRef.current = false; - checkingRef.current = false; - } - }, [isAuthenticated, loading]); + }, [loading, isAuthenticated, user?.id, initialize]); return <>{children}; } diff --git a/apps/web/src/routes/api/typing.ts b/apps/web/src/routes/api/typing.ts index 7d06df82..636e012d 100644 --- a/apps/web/src/routes/api/typing.ts +++ b/apps/web/src/routes/api/typing.ts @@ -10,6 +10,35 @@ import { isSameOrigin, } from "@/lib/server-auth"; +function parseChatId(value: unknown): Id<"chats"> | null { + if (typeof value !== "string") { + return null; + } + + const chatId = value.trim(); + return chatId ? (chatId as Id<"chats">) : null; +} + +function parseTypingBody(value: unknown): { chatId: Id<"chats">; isTyping: boolean } | null { + if (!value || typeof value !== "object") { + return null; + } + + const { chatId, isTyping } = value as { + chatId?: unknown; + isTyping?: unknown; + }; + const parsedChatId = parseChatId(chatId); + if (!parsedChatId) { + return null; + } + + return { + chatId: parsedChatId, + isTyping: Boolean(isTyping), + }; +} + export const Route = createFileRoute("/api/typing")({ server: { handlers: { @@ -31,16 +60,14 @@ export const Route = createFileRoute("/api/typing")({ return json({ error: "Unauthorized" }, { status: 401 }); } - const body = await request.json(); - const { chatId, isTyping } = body; - - if (!chatId) { + const parsedBody = parseTypingBody(await request.json()); + if (!parsedBody) { return json({ error: "chatId required" }, { status: 400 }); } // Verify user owns the chat before allowing typing status update const chat = await convexClient.query(api.chats.get, { - chatId: chatId as Id<"chats">, + chatId: parsedBody.chatId, userId: convexUserId, }); if (!chat) { @@ -51,7 +78,7 @@ export const Route = createFileRoute("/api/typing")({ return json({ ok: true }); } - await redis.typing.set(chatId, convexUserId, !!isTyping); + await redis.typing.set(parsedBody.chatId, convexUserId, parsedBody.isTyping); return json({ ok: true }); } catch (error) { console.error("[Typing API POST] Error:", error); @@ -78,15 +105,14 @@ export const Route = createFileRoute("/api/typing")({ } const url = new URL(request.url); - const chatId = url.searchParams.get("chatId"); - + const chatId = parseChatId(url.searchParams.get("chatId")); if (!chatId) { return json({ error: "chatId required" }, { status: 400 }); } // Verify user owns the chat before allowing typing status read const chat = await convexClient.query(api.chats.get, { - chatId: chatId as Id<"chats">, + chatId, userId: convexUserId, }); if (!chat) { diff --git a/apps/web/src/stores/prompt-draft.ts b/apps/web/src/stores/prompt-draft.ts index c075f2a6..4078d833 100644 --- a/apps/web/src/stores/prompt-draft.ts +++ b/apps/web/src/stores/prompt-draft.ts @@ -4,29 +4,15 @@ import { createJSONStorage, devtools, persist } from "zustand/middleware"; /** * Store for persisting prompt drafts within the current browser session. * -<<<<<<< HEAD - * Uses sessionStorage instead of localStorage to limit exposure of sensitive - * chat content — data is scoped to the tab/session and not accessible after - * the browser session ends. -||||||| 54e09ce - * Store for persisting prompt drafts across page reloads. -======= * Security: Uses sessionStorage instead of localStorage to limit exposure * of sensitive draft content. Drafts are automatically cleared when the * browser tab is closed, reducing the risk of exfiltration via XSS or * compromised browser profiles. ->>>>>>> main * * Non-annoying approach: * - Drafts are saved per-chat (or "global" for new chat input) * - Drafts are automatically cleared when a message is sent -<<<<<<< HEAD - * - Old drafts are cleaned up after 7 days to prevent storage bloat -||||||| 54e09ce - * - Old drafts are cleaned up after 7 days to prevent localStorage bloat -======= * - Old drafts are cleaned up after 24 hours as a defensive measure ->>>>>>> main */ const DRAFT_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours (session-scoped, defensive expiry) From bba39996d70087a25f674b8374a462eb2c71bbb1 Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 18 Mar 2026 14:40:13 -0400 Subject: [PATCH 2/2] Apply AI review suggestions --- apps/server/convex/promptTemplates.ts | 2 +- apps/web/src/hooks/use-favorite-models.ts | 39 +++++++++++------------ 2 files changed, 20 insertions(+), 21 deletions(-) diff --git a/apps/server/convex/promptTemplates.ts b/apps/server/convex/promptTemplates.ts index 59ddee96..cde2dd8f 100644 --- a/apps/server/convex/promptTemplates.ts +++ b/apps/server/convex/promptTemplates.ts @@ -272,7 +272,7 @@ export const update = mutation({ } const existing = await getOwnedActiveTemplate(ctx, args.templateId, userId); - if (!existing || existing.userId !== userId || existing.deletedAt) { + if (!existing) { return { ok: false }; } diff --git a/apps/web/src/hooks/use-favorite-models.ts b/apps/web/src/hooks/use-favorite-models.ts index e9e7ba96..21a8c3b7 100644 --- a/apps/web/src/hooks/use-favorite-models.ts +++ b/apps/web/src/hooks/use-favorite-models.ts @@ -87,26 +87,25 @@ export function useFavoriteModels() { isInitialized.current = true; }, [convexUserId, serverFavorites, applyFavorites]); - const toggleFavorite = useCallback( - (modelId: string) => { - if (!convexUserId) return; - - setFavorites((prev) => { - const next = new Set(prev); - if (next.has(modelId)) { - next.delete(modelId); - } else { - next.add(modelId); - } - lastLocalSnapshotRef.current = serializeFavorites(next); - hasPendingOptimisticUpdateRef.current = true; - return next; - }); - - toggleFavoriteMutation({ userId: convexUserId, modelId }); - }, - [convexUserId, toggleFavoriteMutation], - ); + const toggleFavorite = useCallback( + (modelId: string) => { + if (!convexUserId) return; + + const nextFavorites = new Set(favorites); + if (nextFavorites.has(modelId)) { + nextFavorites.delete(modelId); + } else { + nextFavorites.add(modelId); + } + + lastLocalSnapshotRef.current = serializeFavorites(nextFavorites); + hasPendingOptimisticUpdateRef.current = true; + setFavorites(nextFavorites); + + toggleFavoriteMutation({ userId: convexUserId, modelId }); + }, + [convexUserId, favorites, toggleFavoriteMutation], + ); const isFavorite = useCallback( (modelId: string) => favorites.has(modelId),