Skip to content

Cleanup backend hotspots and apply code quality fixes - #701

Merged
leoisadev1 merged 2 commits into
mainfrom
codex/ai-review-20260318-1
Mar 18, 2026
Merged

Cleanup backend hotspots and apply code quality fixes#701
leoisadev1 merged 2 commits into
mainfrom
codex/ai-review-20260318-1

Conversation

@leoisadev1

@leoisadev1 leoisadev1 commented Mar 18, 2026

Copy link
Copy Markdown
Member

Automated PR for Greptile review

Note

Fix backend hotspots and apply code quality improvements across server and web

  • Replaces internal.* API references with typed FunctionReference constants across multiple Convex modules (streamExecution.ts, chatTitle.ts, userDelete.ts, etc.) to improve type safety
  • Extracts shared chat cleanup logic into chat_cleanup_helpers.ts and refactors messages.ts and chats.ts to use these helpers
  • Adds by_chat_not_deleted and by_command_not_deleted secondary indexes to improve query efficiency in files.ts and promptTemplates.ts
  • Fixes useFavoriteModels to track per-user state and reconcile server favorites with optimistic updates; fixes UserSyncProvider and OpenRouterKeyStatusProvider to run once per authenticated user and reset on logout
  • Hardens input validation in the typing API route, parseStreamMeta in redis.ts, and usePromptDraft to avoid unnecessary storage writes after draft restoration
  • Behavioral Change: messages.getActiveStream now uses the by_chat_status index without descending order, which may return a different message when multiple streaming messages exist simultaneously

Macroscope summarized bba3999.

@github-actions

github-actions Bot commented Mar 18, 2026

Copy link
Copy Markdown
Contributor

🚀 Preview Deployment Ready

Vercel is rebuilding the frontend with the new Convex backend URL.

Vercel will post the preview URL automatically.

Convex Preview Backend

  • Cloud URL: https://impartial-mongoose-199.convex.cloud
  • Site URL: https://impartial-mongoose-199.convex.site

ℹ️ Preview deployments support email/password auth only (GitHub/Vercel OAuth disabled).


🤖 Deployed automatically by GitHub Actions

Comment thread apps/web/src/lib/redis.ts
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
@vercel

vercel Bot commented Mar 18, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
osschat-web Ignored Ignored Preview Mar 18, 2026 6:40pm

const serverSet = new Set(serverFavorites ?? []);
const serverSnapshot = serializeFavorites(serverSet);
const userChanged = activeUserIdRef.current !== convexUserId;
const serverSnapshotChanged = serverSnapshot !== lastServerSnapshotRef.current;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium hooks/use-favorite-models.ts:74

When a user toggles a favorite while serverFavorites is still loading, the optimistic update is lost once the server data arrives. The condition at lines 76-80 uses serverSnapshotChanged (true because lastServerSnapshotRef.current is null when the user first authenticates), which overwrites the pending optimistic state with the stale server snapshot. Consider tracking whether an optimistic update is pending separately from snapshot comparisons, or ensure serverSnapshotChanged alone doesn't trigger a reset when updates are in flight.

-    const serverSnapshotChanged = serverSnapshot !== lastServerSnapshotRef.current;
+    const serverSnapshotChanged = lastServerSnapshotRef.current !== null && serverSnapshot !== lastServerSnapshotRef.current;
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/web/src/hooks/use-favorite-models.ts around line 74:

When a user toggles a favorite while `serverFavorites` is still loading, the optimistic update is lost once the server data arrives. The condition at lines 76-80 uses `serverSnapshotChanged` (true because `lastServerSnapshotRef.current` is `null` when the user first authenticates), which overwrites the pending optimistic state with the stale server snapshot. Consider tracking whether an optimistic update is pending separately from snapshot comparisons, or ensure `serverSnapshotChanged` alone doesn't trigger a reset when updates are in flight.

Evidence trail:
apps/web/src/hooks/use-favorite-models.ts lines 44-47 (applyFavorites sets hasPendingOptimisticUpdateRef.current = markOptimistic, default false), lines 60-67 (early return when serverFavorites undefined leaves lastServerSnapshotRef.current = null), lines 73-81 (the OR condition where serverSnapshotChanged alone triggers applyFavorites), lines 96-97 in toggleFavorite (sets hasPendingOptimisticUpdateRef.current = true).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High

handler: async (ctx, args) => {
const userId = await requireAuthUserId(ctx, args.userId);
const templates = await getActiveTemplateByCommand(ctx, userId, args.command);

getByCommand queries with the raw args.command (e.g., "TEST" or "test"), but templates are stored with sanitized commands like "/test". The by_command_not_deleted index lookup uses exact string matching, so queries with unformatted commands return null even when a matching template exists. Consider sanitizing the command with sanitizeCommand() before the lookup.

-       const templates = await getActiveTemplateByCommand(ctx, userId, args.command);
+       const templates = await getActiveTemplateByCommand(ctx, userId, sanitizeCommand(args.command));
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/server/convex/promptTemplates.ts around lines 172-174:

`getByCommand` queries with the raw `args.command` (e.g., `"TEST"` or `"test"`), but templates are stored with sanitized commands like `"/test"`. The `by_command_not_deleted` index lookup uses exact string matching, so queries with unformatted commands return `null` even when a matching template exists. Consider sanitizing the command with `sanitizeCommand()` before the lookup.

Evidence trail:
- apps/server/convex/promptTemplates.ts lines 16-30: `sanitizeCommand()` definition - transforms "TEST" to "/test"
- apps/server/convex/promptTemplates.ts line 174: `getByCommand` passes `args.command` directly to `getActiveTemplateByCommand` without sanitization
- apps/server/convex/promptTemplates.ts lines 80-89: `getActiveTemplateByCommand` uses exact string match `.eq("command", command)`
- apps/server/convex/promptTemplates.ts line 205: `create` mutation sanitizes command with `sanitizeCommand(args.command)`
- apps/server/convex/promptTemplates.ts line 237: `create` stores `command: sanitizedCommand` (the sanitized version)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High hooks/use-prompt-draft.ts:45

skipNextSaveRef.current is set to true unconditionally when chatId changes, but textInputController.setInput is only called when savedDraft is truthy. When navigating to a chat with no saved draft, skipNextSaveRef remains true even though no programmatic setInput occurred, causing the first user keystroke to be skipped and lost.

 	// 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) {
+			skipNextSaveRef.current = true;
 			textInputController.setInput(savedDraft);
 		}
 		hasRestoredRef.current = true;
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/web/src/hooks/use-prompt-draft.ts around lines 45-62:

`skipNextSaveRef.current` is set to `true` unconditionally when `chatId` changes, but `textInputController.setInput` is only called when `savedDraft` is truthy. When navigating to a chat with no saved draft, `skipNextSaveRef` remains `true` even though no programmatic `setInput` occurred, causing the first user keystroke to be skipped and lost.

Evidence trail:
apps/web/src/hooks/use-prompt-draft.ts lines 45-52 (first useEffect sets skipNextSaveRef unconditionally at line 47, but setInput is conditional on savedDraft at lines 50-52), lines 74-82 (second useEffect checks skipNextSaveRef and returns early if true, skipping the save). Commit: REVIEWED_COMMIT

@greptile-apps

greptile-apps Bot commented Mar 18, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR performs a broad code-quality sweep across the backend Convex modules and several frontend hooks. The core server-side changes replace all internal.* auto-generated API references with hand-written FunctionReference string casts to break circular import cycles, introduce shared helpers (completeActiveAndPendingStreams, softDeleteMessagesAfter, assertUserAndOwnedChat, getOwnedActiveTemplate) to eliminate repeated inline query/mutation patterns, add two new secondary indexes to the schema (fileUploads.by_chat_not_deleted, promptTemplates.by_command_not_deleted), and tighten the streamJobReturnShape options validator. On the frontend, useFavoriteModels, UserSyncProvider, and OpenRouterKeyStatusProvider are fixed to reset state per authenticated user ID, the /api/typing route gains strict input validation, and parseStreamMeta in redis.ts is hardened against malformed objects.

Key concerns to address before merging:

  • String-cast FunctionReference pattern — All 20+ "module:function" as unknown as FunctionReference<…> casts throughout the PR bypass the compiler's ability to catch renames or signature mismatches. The idiomatic Convex alternative, makeFunctionReference, should be used instead to retain type safety without circular imports.
  • getOwnedChat / assertUserAndOwnedChat in files.ts missing chat.deletedAt guard — Unlike the analogous assertOwnsChat helper in chats.ts, the new file-scoped helpers do not reject soft-deleted chats, allowing uploads and file queries against deleted chats.
  • streamJobReturnShape options validator tightening — Switching from a loose v.record(v.string(), v.any()) to strict streamOptionsValidator will silently strip any extra options keys in existing database records; confirm no production documents carry out-of-schema keys before deploying.
  • StreamJobOptions type duplication — Identical type is defined independently in both streamJobs.ts and streamExecution.ts; should be exported from one place.

Confidence Score: 3/5

  • Needs targeted fixes before merging — the FunctionReference cast pattern trades away compile-time safety, and the missing deletedAt guard in files.ts is a correctness gap.
  • The refactoring is well-structured and most individual changes are correct. However, using as unknown as FunctionReference across 20+ call sites creates a maintenance hazard where signature changes won't be caught at compile time. The missing chat.deletedAt check in the new files.ts helpers is a real logic gap (soft-deleted chats become accessible again). The streamJobReturnShape validator change is a breaking behavioral change that warrants a production-data check. These three issues are not show-stoppers but should be resolved before merging.
  • apps/server/convex/files.ts (missing deletedAt guard in helpers), apps/server/convex/streamJobs.ts (strict options validator + type duplication), and any file using the as unknown as FunctionReference pattern (benchmarks, chatTitle, cleanupAction, crons, http, lib/auth, streamExecution, streamWebSearch, userDelete).

Important Files Changed

Filename Overview
apps/server/convex/chat_cleanup_helpers.ts New shared helpers for bulk-completing streams and soft-deleting messages; logic is correct and uses Promise.all for efficient batching inside mutations.
apps/server/convex/files.ts Adds assertUserAndOwnedChat / getOwnedChat / getOwnedFileByStorageId helpers and uses the new by_chat_not_deleted index; getOwnedChat (and assertUserAndOwnedChat) do not check chat.deletedAt, unlike the analogous helper in chats.ts.
apps/server/convex/streamJobs.ts Tightens streamJobReturnShape options field from a loose record to strict streamOptionsValidator; introduces a StreamJobOptions type that is also defined in streamExecution.ts; strict validator may silently strip existing DB options data.
apps/server/convex/streamExecution.ts Large refactor replacing all internal.* calls with typed FunctionReference casts; introduces toStreamMessages / prependSystemMessages helpers; StreamJobOptions type is duplicated vs streamJobs.ts.
apps/server/convex/schema.ts Adds two new secondary indexes (fileUploads.by_chat_not_deleted, promptTemplates.by_command_not_deleted); both are well-formed and support the new query patterns in files.ts and promptTemplates.ts.
apps/server/convex/messages.ts Delegates stream-completion and message soft-deletion to the new shared helpers; logic is equivalent to the old inline code and correctly passes message.createdAt as the cutoff.
apps/server/convex/promptTemplates.ts Centralises ownership/deletion checks via getOwnedActiveTemplate and uses the new by_command_not_deleted index via getActiveTemplateByCommand; the previously flagged dead conditions in the update handler have been cleaned up.
apps/web/src/hooks/use-favorite-models.ts Adds per-user-ID tracking refs to prevent stale favorites on user switch; logic is sound but mixes 2-space and tab indentation throughout the file.
apps/web/src/providers/index.tsx UserSyncProvider and OpenRouterKeyStatusProvider now track the synced user ID rather than a simple boolean flag, correctly resetting and re-triggering on user switch.
apps/web/src/routes/api/typing.ts Adds parseChatId / parseTypingBody for strict input validation; hardens both POST and GET paths against missing or malformed chatId and isTyping fields.
apps/web/src/lib/redis.ts parseStreamMeta now validates every required field before constructing the return object and handles optional completedAt/error fields explicitly; recursive JSON-string parsing is a clean improvement.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[editAndRegenerate or retryMessage mutation] --> B[completeActiveAndPendingStreams]
    A --> C[softDeleteMessagesAfter]
    B --> D[(streamJobs: running/pending to completed)]
    C --> E[(messages: createdAt after cutoff gets deletedAt)]

    F[chats.remove or chats.removeBulk] --> G[assertOwnsChat]
    G --> H[softDeleteChatWithMessages]
    H --> I[softDeleteAllMessagesInChat]
    H --> J[(chats: deletedAt + messageCount=0)]
    I --> E

    K[streamExecution.executeStream action] -->|FunctionReference cast| L[backgroundStream.failStream]
    K -->|FunctionReference cast| M[backgroundStream.updateStreamContent]
    K -->|FunctionReference cast| N[backgroundStream.completeStream]
    K -->|FunctionReference cast| O[users.incrementAiUsage]

    P[typing API POST and GET] --> Q[parseTypingBody or parseChatId]
    Q --> R{valid input?}
    R -- no --> S[400 error]
    R -- yes --> T[chats.get ownership check]
    T --> U[redis typing set or get]

    V[useFavoriteModels] --> W{convexUserId changed?}
    W -- yes --> X[reset refs + applyFavorites empty set]
    W -- no --> Y{serverFavorites loaded?}
    Y -- yes --> Z[applyFavorites with server data]
    Y -- no --> AA[await next render]
Loading

Comments Outside Diff (3)

  1. apps/server/convex/benchmarks.ts, line 17-38 (link)

    P1 String-cast FunctionReference loses compile-time type safety

    Throughout this PR (benchmarks, chatTitle, cleanupAction, crons, http, auth, streamExecution, streamWebSearch, userDelete), the pattern "module:function" as unknown as FunctionReference<...> replaces the auto-generated internal.* imports.

    While the intent is to resolve circular-import bundling issues, the resulting references have weaker type safety than the originals:

    • The string literal "benchmarks:storeBenchmarks" is not verified by the compiler — a typo or rename would silently break at runtime.
    • The generic type annotations (args, return) are manually maintained and can drift from the actual function signature; TypeScript will never catch the mismatch because of the as unknown cast.

    The idiomatic Convex solution that still provides type checking is makeFunctionReference:

    import { makeFunctionReference } from "convex/server";
    
    const storeBenchmarksRef = makeFunctionReference<
      "mutation",
      { benchmarks: Array<{ openRouterModelId: string; /* … */ }> }
    >("benchmarks:storeBenchmarks");

    makeFunctionReference validates the string at call sites and keeps the type-parameter annotation without the unsafe double cast. Please apply this pattern across all affected files in the PR. (Score: 4/5)

  2. apps/server/convex/files.ts, line 501-514 (link)

    P1 getOwnedChat does not guard against soft-deleted chats

    The new helper omits a chat.deletedAt check:

    async function getOwnedChat(ctx, chatId, userId) {
      const chat = await ctx.db.get(chatId);
      if (!chat) throw new Error("Chat not found");
      if (chat.userId !== userId) throw new Error("Unauthorized…");
      return chat;  // ← no check for chat.deletedAt
    }

    By contrast, the shared assertOwnsChat helper in chats.ts returns null when chat.deletedAt is set, and generateUploadUrl / saveFileMetadata / getFilesByChat would now all succeed for a logically-deleted chat.

    Suggested fix:

    The same fix applies to assertUserAndOwnedChat (line ~484) — it should also reject deleted chats. (Score: 3/5)

  3. apps/web/src/hooks/use-favorite-models.ts, line 33-40 (link)

    P2 Mixed indentation makes the file harder to read

    The pre-existing code in this hook uses 2-space indentation, while the new code added in this PR uses tabs. Both styles are now interleaved, which violates the project's AGENTS.md consistency requirement and makes diffs noisy.

    Please normalise the entire file to whichever style the surrounding codebase uses (appears to be tabs based on the new additions). (Score: 2/5)

Last reviewed commit: "Apply AI review sugg..."

Comment thread apps/server/convex/promptTemplates.ts Outdated
Comment thread apps/web/src/hooks/use-favorite-models.ts Outdated
@tembo

tembo Bot commented Mar 18, 2026

Copy link
Copy Markdown
Contributor

✅ No security issues found — scanned commits: a16f4b4, bba3999

Comment on lines 71 to +74
status: v.string(),
model: v.string(),
provider: v.string(),
options: v.optional(streamJobOptionsReturnValidator),
options: v.optional(streamOptionsValidator),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Tightening streamJobReturnShape may silently strip existing options data

Before this PR, the options field was validated as v.record(v.string(), v.any()) — accepting any key/value map. It now uses the strict streamOptionsValidator, which only allows five specific keys.

Convex's return-type validator strips unknown fields rather than throwing. This means any streamJob document in the database whose options object contains keys not in streamOptionsValidator (e.g., a key added in a past release or from an earlier migration) will silently have those keys dropped when the record is read back through getJobInternal or similar queries.

Before landing this change, confirm that no production streamJob records carry options keys outside the five defined in streamOptionsValidator. A one-time migration query or a temporary v.record(v.string(), v.any()) union branch would allow a safe rollout. (Score: 3/5)

@leoisadev1
leoisadev1 merged commit 951aa14 into main Mar 18, 2026
13 checks passed
@leoisadev1
leoisadev1 deleted the codex/ai-review-20260318-1 branch March 18, 2026 18:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant