Skip to main content
Answer Overflow uses Convex as its database and backend platform. This guide covers the key patterns and best practices used in the codebase.

Schema Definition

Schemas are defined using Effect Schema instead of the standard Convex validators. This provides better type safety and validation.

Defining Tables

Schemas are located in packages/database/convex/schema.ts:
import { defineSchema, defineTable } from "@packages/confect/server";
import { Schema } from "effect";

const MessageSchema = Schema.Struct({
  id: Schema.BigIntFromSelf,
  authorId: Schema.BigIntFromSelf,
  serverId: Schema.BigIntFromSelf,
  channelId: Schema.BigIntFromSelf,
  content: Schema.String,
  flags: Schema.optional(Schema.Number),
  type: Schema.optional(Schema.Number),
  embeds: Schema.optional(Schema.Array(EmbedSchema).pipe(Schema.mutable)),
});

export const confectSchema = defineSchema({
  messages: defineTable(MessageSchema)
    .index("by_messageId", ["id"])
    .index("by_channelId", ["channelId"])
    .searchIndex("search_content", {
      searchField: "content",
      filterFields: ["serverId", "channelId"],
    }),
});

Index Best Practices

Always create indexes for fields you’ll query frequently. Use compound indexes for multi-field queries.
// Single field index
.index("by_serverId", ["serverId"])

// Compound index for filtering
.index("by_serverId_and_indexingEnabled", ["serverId", "indexingEnabled"])

// Search index for full-text search
.searchIndex("search_content", {
  searchField: "content",
  filterFields: ["serverId", "channelId"],
})

Mutations

Mutations modify database state. Answer Overflow uses tiered access levels: public, authenticated, private, and internal.

Writing Mutations

Example from packages/database/convex/private/messages.ts:
import { privateMutation } from "../client";
import { messageSchema, attachmentSchema } from "../schema";
import { v } from "convex/values";

export const upsertMessage = privateMutation({
  args: {
    message: messageSchema,
    attachments: v.optional(v.array(attachmentSchema)),
    ignoreChecks: v.optional(v.boolean()),
  },
  handler: async (ctx, args) => {
    // Validation logic
    if (!args.ignoreChecks) {
      const ignored = await isIgnoredAccount(ctx, args.message.authorId);
      if (ignored) {
        throw new Error("Message author is deleted");
      }
    }

    // Database operation
    await upsertMessageInternalLogic(ctx, {
      message: args.message,
      attachments: args.attachments,
    });

    return null;
  },
});

Batch Operations

For better performance when inserting multiple records:
export const upsertManyMessages = privateMutation({
  args: {
    messages: v.array(
      v.object({
        message: messageSchema,
        attachments: v.optional(v.array(attachmentSchema)),
      })
    ),
  },
  handler: async (ctx, args) => {
    await upsertManyMessagesOptimized(ctx, args.messages);
    return { count: args.messages.length };
  },
});

Queries

Queries read data from the database without modifying it.

Pagination

Example from packages/database/convex/public/messages.ts:
import { paginationOptsValidator } from "convex/server";
import { publicQuery } from "./custom_functions";

export const getMessages = publicQuery({
  args: {
    channelId: v.int64(),
    after: v.int64(),
    paginationOpts: paginationOptsValidator,
  },
  handler: async (ctx, args) => {
    const query = ctx.db
      .query("messages")
      .withIndex("by_channelId_and_id", (q) =>
        q.eq("channelId", args.channelId).gt("id", args.after)
      );

    const paginatedResult = await query
      .order("asc")
      .paginate(args.paginationOpts);

    return {
      page: paginatedResult.page,
      isDone: paginatedResult.isDone,
      continueCursor: paginatedResult.continueCursor,
    };
  },
});

Using Relationships

Convex helpers make relationships easier:
import { getManyFrom } from "convex-helpers/server/relationships";

// Get all attachments for a message
const attachments = await getManyFrom(
  ctx.db,
  "attachments",
  "by_messageId",
  message._id,
  "messageId"
);

Actions

Actions can call third-party APIs and other non-deterministic operations.

Example Action

From packages/database/convex/chat/actions.ts:
import { generateText } from "ai";
import { internalAction } from "../client";
import { internal } from "../_generated/api";

export const generateTitle = internalAction({
  args: {
    threadId: v.string(),
  },
  handler: async (ctx, args) => {
    // Fetch data via query
    const messages = await ctx.runQuery(
      internal.chat.queries.getRecentMessages,
      { threadId: args.threadId, limit: 4 }
    );

    // Call external API
    const { text } = await generateText({
      model: gateway("google/gemini-2.5-flash"),
      prompt: `Generate a title for this conversation.\n\n${conversationContext}`,
    });

    // Update via mutation
    await ctx.runMutation(internal.chat.queries.updateThreadTitle, {
      threadId: args.threadId,
      title: text.trim(),
    });

    return null;
  },
});
Actions cannot directly read/write to the database. Use ctx.runQuery and ctx.runMutation instead.

HTTP Endpoints

Convex supports HTTP endpoints for webhooks and API routes.

Setting Up Routes

From packages/database/convex/http.ts:
import { httpRouter } from "convex/server";
import { httpAction } from "./client";

const http = httpRouter();

http.route({
  path: "/stripe/webhook",
  method: "POST",
  handler: httpAction(async (ctx, req) => {
    const signature = req.headers.get("stripe-signature");
    const payload = await req.text();

    const result = await ctx.runAction(
      internal.authenticated.stripe_actions.handleStripeWebhook,
      { payload, signature }
    );

    return new Response(JSON.stringify(result), {
      status: 200,
      headers: { "Content-Type": "application/json" },
    });
  }),
});

export default http;

Access Control Tiers

Answer Overflow uses four access levels:
1

Public

Anyone can call these functions. Used for publicly accessible data.
import { publicQuery } from "./custom_functions";

export const getPublicServer = publicQuery({ ... });
2

Authenticated

Requires user authentication via Better Auth.
import { authenticatedQuery } from "../client/authenticated";

export const getUserData = authenticatedQuery({ ... });
3

Private

Only callable by Discord bot or internal services.
import { privateMutation } from "../client";

export const syncDiscordData = privateMutation({ ... });
4

Internal

Only callable from other Convex functions.
import { internalMutation } from "../client";

export const cleanupStaleData = internalMutation({ ... });

Testing

Convex functions can be tested using convex-test:
import { convexTest } from "convex-test";
import { expect, test } from "vitest";
import schema from "./schema";
import { upsertMessage } from "./private/messages";

test("upserts a message", async () => {
  const t = convexTest(schema);
  
  await t.mutation(upsertMessage, {
    message: {
      id: 123n,
      authorId: 456n,
      serverId: 789n,
      channelId: 101112n,
      content: "Test message",
    },
  });
  
  const messages = await t.query(getMessages, {
    channelId: 101112n,
  });
  
  expect(messages).toHaveLength(1);
  expect(messages[0].content).toBe("Test message");
});

Troubleshooting

Common Issues

Schema validation errors Make sure your Effect Schema types match what you’re inserting:
// Wrong: number instead of bigint
const message = { id: 123, ... };

// Correct: bigint
const message = { id: 123n, ... };
Index not found errors Ensure indexes exist before querying:
cd packages/database
bun run codegen
Rate limiting Convex has built-in rate limits. For batch operations, add delays:
import { Duration, Effect } from "effect";

for (const batch of batches) {
  await processBatch(batch);
  await Effect.sleep(Duration.millis(200)).pipe(Effect.runPromise);
}

Next Steps