| name | convex-patterns |
| description | Convex backend patterns for this project. Query/mutation/action structure, TypeScript recursion workarounds, auth patterns, resilient generation (10min actions), normalized schema design. Triggers on "convex", "mutation", "query", "action", "internalQuery", "internalMutation", "internalAction". |
Convex Backend Patterns
94+ Convex modules power the backend. Real-time queries, mutations for writes, actions for LLM calls (10min limit). Normalized schema design (no nested documents).
Query/Mutation/Action Structure
Three function types:
export const getUser = query({
args: { userId: v.id("users") },
handler: async (ctx, args) => {
return await ctx.db.get(args.userId);
},
});
export const updateUser = mutation({
args: { userId: v.id("users"), name: v.string() },
handler: async (ctx, args) => {
await ctx.db.patch(args.userId, { name: args.name });
},
});
export const generateResponse = action({
args: { conversationId: v.id("conversations"), modelId: v.string() },
handler: async (ctx, args) => {
const result = await streamText({ model, messages });
await ctx.runMutation(internal.messages.create, { ... });
},
});
When to use:
- Query: Real-time data fetch (conversations, messages, users)
- Mutation: DB writes (create message, update status)
- Action: LLM streaming, embeddings, external HTTP calls
TypeScript Recursion Workaround
With 94+ modules, TypeScript hits depth limits on internal.* and api.* types. Use @ts-ignore + cast pattern:
const costBias = await (ctx.runQuery as any)(
api.users.getUserPreferenceByUserId,
{ userId: args.userId, key: "autoRouterCostBias" },
) as Promise<number | null>;
const conversationId = await ctx.runMutation(
internal.conversations.createInternal,
{
userId: user._id,
model: modelsToUse[0],
title: "New Chat",
},
);
Pattern: (ctx.runX as any) + @ts-ignore on reference + as ReturnType. Bypasses parameter inference, keeps return type safety.
Auth Pattern (Defense-in-Depth)
Every query/mutation verifies user via internal helper:
export async function getCurrentUserOrCreate(ctx: MutationCtx) {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Not authenticated");
const user = await ctx.db
.query("users")
.withIndex("by_clerk_id", (q) => q.eq("clerkId", identity.subject))
.first();
if (!user) throw new Error("User not found");
return user;
}
const user = await getCurrentUserOrCreate(ctx);
Actions use internal queries:
export const getCurrentUser = internalQuery({
args: {},
handler: async (ctx): Promise<Doc<"users"> | null> => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) return null;
return await ctx.db
.query("users")
.withIndex("by_clerk_id", (q) => q.eq("clerkId", identity.subject))
.first();
},
});
const user = await ctx.runQuery(internal.lib.helpers.getCurrentUser, {});
Normalized Schema Design
NO nested documents. Use junction tables for M:N relationships.
Bad (nested):
defineTable("messages", {
attachments: v.optional(v.array(v.object({
name: v.string(),
storageId: v.string(),
size: v.number(),
}))),
})
Good (normalized):
attachments: defineTable({
messageId: v.id("messages"),
conversationId: v.id("conversations"),
userId: v.id("users"),
type: v.union(v.literal("image"), v.literal("file"), v.literal("audio")),
name: v.string(),
storageId: v.id("_storage"),
mimeType: v.string(),
size: v.number(),
createdAt: v.number(),
})
.index("by_message", ["messageId"])
.index("by_conversation", ["conversationId"])
.index("by_user", ["userId"])
Junction tables for M:N:
projectConversations: defineTable({
projectId: v.id("projects"),
conversationId: v.id("conversations"),
addedAt: v.number(),
addedBy: v.id("users"),
})
.index("by_project", ["projectId"])
.index("by_conversation", ["conversationId"])
.index("by_project_conversation", ["projectId", "conversationId"])
Benefits: 40% smaller docs, 10x faster cascade deletes, queryable relationships, no data drift.
Resilient Generation Pattern
LLM calls MUST survive page refresh. Use 10min actions + DB persistence.
Flow (from convex/chat.ts + generation.ts):
const assistantMessageId = await ctx.runMutation(internal.messages.create, {
conversationId,
userId: user._id,
role: "assistant",
content: "",
status: "pending",
model,
});
await ctx.scheduler.runAfter(0, internal.generation.generateResponse, {
conversationId,
existingMessageId: assistantMessageId,
modelId,
userId: user._id,
});
await ctx.runMutation(internal.messages.updateStatus, {
messageId: assistantMessageId,
status: "generating",
generationStartedAt: Date.now(),
});
for await (const chunk of stream) {
accumulatedContent += chunk;
await ctx.runMutation(internal.messages.updatePartialContent, {
messageId: assistantMessageId,
: accumulatedContent,
});
}
ctx.(internal.., {
: assistantMessageId,
: ,
: finalContent,
: ,
: .(),
});
Message states: pending | generating | complete | stopped | error
Client subscribes:
const message = useQuery(api.messages.get, { id: messageId });
On refresh: Client sees partialContent from DB, streaming continues server-side.
Index Requirements
Every foreign key needs index. Composite indexes for common queries.
messages: defineTable({ ... })
.index("by_conversation", ["conversationId"])
.index("by_user", ["userId"])
.index("by_conversation_status", ["conversationId", "status"])
.index("by_conversation_created", ["conversationId", "createdAt"])
.vectorIndex("by_embedding", {
vectorField: "embedding",
dimensions: 1536,
filterFields: ["conversationId", "userId"],
})
.searchIndex("search_content", {
searchField: "content",
filterFields: ["conversationId", "userId", "role"],
})
Index types:
- Simple:
["userId"] - Foreign keys
- Composite:
["userId", "status"] - Filtered queries
- Vector: Semantic search (1536d embeddings)
- Search: Full-text search (Convex native)
Internal Helpers Pattern
Avoid type recursion by extracting helpers:
export const getConversationMessages = internalQuery({
args: { conversationId: v.id("conversations") },
handler: async (ctx, args): Promise<Doc<"messages">[]> => {
return await ctx.db
.query("messages")
.withIndex("by_conversation", (q) =>
q.eq("conversationId", args.conversationId))
.order("asc")
.collect();
},
});
Usage in actions:
const messages = await ctx.runQuery(
internal.lib.helpers.getConversationMessages,
{ conversationId }
);
All helpers: getCurrentUser, getConversation, getConversationMessages, getMemoriesByIds, getMessage, getMessageAttachments, etc.
Testing Patterns
Use convex-test with factories:
import { convexTest } from "../../__tests__/testSetup";
import { createMockIdentity, createTestUserData } from "@/lib/test/factories";
import schema from "../schema";
it("returns user for authenticated identity", async () => {
const t = convexTest(schema);
const identity = createMockIdentity();
await t.run(async (ctx) => {
await ctx.db.insert("users", createTestUserData({
clerkId: identity.subject,
email: "test@example.com",
}));
});
const asUser = t.withIdentity(identity);
const result = await asUser.query(api.users.getCurrentUser, {});
expect(result?.email).toBe("test@example.com");
});
Factories (src/lib/test/factories.ts):
createMockIdentity() - Auth identity
createTestUserData() - User record
createTestConversationData() - Conversation
createTestMessageData() - Message
Pattern: t.run() for DB setup, t.withIdentity() for auth context, @ts-ignore on type-deep queries.
Key Files
convex/schema.ts - Normalized schema (1546 lines)
convex/chat.ts - Message sending, regeneration
convex/generation.ts - LLM streaming action (resilient)
convex/lib/helpers.ts - Internal query helpers (avoid recursion)
convex/__tests__/users.test.ts - Testing patterns
convex/lib/userSync.ts - Auth helpers (getCurrentUserOrCreate)
convex/messages.ts - Message mutations (status, partialContent)
Avoid
- Nested documents in schema (use junction tables)
- Client-only streaming (loses data on refresh)
- Direct
ctx.runQuery(api.*) in actions without cast (type errors)
- Missing indexes on foreign keys (slow queries)
- Queries in actions without internal helper (type depth errors)
- Mutations for LLM calls (10s timeout, use actions)