AI Patterns
How AI agents implement database features in this codebase
This guide documents patterns enabling AI agents to implement Convex database features correctly on first attempt. Focus: How AI uses these patterns, not general Convex documentation.
For AI Agents
This page explains the conventions you must follow when implementing database features. The patterns in convex/tables.ts
and convex/_helpers/builders.ts
are mandatory—not suggestions.
AI Capabilities
What AI agents can implement using these patterns:
Full CRUD Operations
Create, read, update, delete with automatic auth, rate limiting, and error handling
Real-Time Features
Reactive queries, subscriptions, presence, optimistic updates via builder wrappers
Billing Integration
Feature gates and usage tracking via ctx.check()
and ctx.track()
in userAction
Security by Default
Auto-scoped queries, ownership verification, typed errors, concealExistence pattern
Context Engineering Architecture
AI agents have access to these discoverable patterns:
Tables Pattern
convex/tables.ts
- Never hardcode table names or validators
Builder Functions
convex/_helpers/builders.ts
- Auto-inject auth and guards
Type Safety
Generated types from schema ensure compile-time correctness
Error Handling
convex/_helpers/errors.ts
- Typed errors with HTTP semantics
The Tables Pattern (Critical)
AI Rule #1: Never hardcode table names. Always import from convex/tables.ts
:
import { T, V, type UserId, type SupportTicketId } from "./tables";
- T - Table name constants (
T.supportTickets
) - V - Validator helpers (
V.supportTicketId
) - Types - Type aliases (
UserId
,SupportTicketId
)
Usage:
// ✅ Correct
args: { id: V.supportTicketId }
await ctx.db.insert(T.supportTickets, { ... })
// ❌ Wrong
args: { id: v.id("supportTickets") }
await ctx.db.insert("supportTickets", { ... })
When Adding New Tables
Update convex/tables.ts
with: (1) table constant in T, (2) validator in V, (3) type alias. Then run pnpm codegen
.
Builder Pattern (Auth Guards)
AI Rule #2: Always use builders. Never use raw query
, mutation
, or action
:
import { userQuery, userMutation, userAction } from "./_helpers/builders";
What builders provide:
- ✅ Automatic authentication (
ctx.viewerId
injected) - ✅ Auto-scoped
ctx.db
for user-owned tables - ✅ Billing gates (
ctx.check()
) and tracking (ctx.track()
) - ✅ Typed errors (throws
UNAUTHENTICATED
,FORBIDDEN
, etc.)
When to use each:
- userQuery - Read operations (no external calls)
- userMutation - Write operations (insert, patch, delete)
- userAction - External APIs, billing gates, heavy compute
Example:
export const createTicket = userMutation({
args: { title: v.string() },
handler: async (ctx, { title }) => {
// ctx.viewerId is authenticated and available
return await ctx.db.insert(T.supportTickets, {
userId: ctx.viewerId as UserId,
title,
status: "Open",
updatedAt: Date.now(),
});
},
});
Schema Conventions
AI Rule #3: Follow schema patterns. Standard conventions:
myTable: defineTable({
userId: v.id("user"), // Always link to user
status: v.union( // Enums via union of literals
v.literal("Active"),
v.literal("Inactive")
),
updatedAt: v.number(), // Unix timestamp for updates
// Use _creationTime instead of createdAt
})
.index("by_user_id", ["userId"]) // Index for common queries
.searchIndex("search_field", { // Full-text search if needed
searchField: "title",
filterFields: ["userId"],
})
Key patterns:
- Singular table names (
user
notusers
) - Required fields (avoid
v.optional()
unless necessary) updatedAt
for mutations,_creationTime
for created timestamp- Indexes for foreign keys and common filters
- Search indexes for text queries
Error Handling & Authorization
AI Rule #4: Use typed errors. Import from convex/_helpers/errors.ts
:
import { AppError } from "./_helpers/errors";
import { assertOwnerByUserId } from "./lib/identity";
export const updateTicket = userMutation({
args: { ticketId: V.supportTicketId, title: v.string() },
handler: async (ctx, { ticketId, title }) => {
const ticket = await ctx.db.get(ticketId);
// Security: Conceal resource existence
assertOwnerByUserId(ticket, ctx.viewerId as UserId, {
concealExistence: true // Throws NOT_FOUND instead of FORBIDDEN
});
await ctx.db.patch(ticketId, { title, updatedAt: Date.now() });
},
});
Available error codes:
UNAUTHENTICATED
(401) - Not logged in (auto-thrown by builders)FORBIDDEN
(403) - Not authorizedNOT_FOUND
(404) - Resource not foundRATE_LIMITED
(429) - Too many requestsPLAN_REQUIRED
(402) - Billing gate failed
Security Pattern
Always use concealExistence: true
to prevent information leakage about resource existence.
Rate Limiting
AI Rule #5: Add rate limits to mutations. Import from convex/rateLimits.ts
:
import { assertLimitAction } from "./rateLimits";
export const createTicket = userMutation({
args: { title: v.string() },
handler: async (ctx, { title }) => {
// 10 requests per minute
await assertLimitAction(ctx, {
scope: "support:create",
viewerId: ctx.viewerId as UserId,
max: 10,
windowMs: 60_000,
});
// Rest of mutation...
},
});
When to apply:
- Create operations (prevent spam)
- Update operations (prevent abuse)
- External API calls (protect quota)
- Expensive operations (protect resources)
Naming Conventions
Critical: Singular Table Names
Use SINGULAR table names: user
not users
, supportTickets
not support_tickets
.
AI must follow:
- ✅ Singular:
user
,session
,supportTickets
- ✅ camelCase for compound words:
billingCustomers
- ❌ Never plural:
users
,sessions
- ❌ Never snake_case:
billing_customers
AI Implementation Checklist
When implementing new database features, follow this exact order:
newTable: defineTable({
userId: v.id("user"),
title: v.string(),
updatedAt: v.number(),
}).index("by_user_id", ["userId"])
// Add to T object
newTable: "newTable",
// Add type alias
export type NewTableId = Id<typeof T.newTable>;
// Add to V object
newTableId: v.id(T.newTable),
import { userQuery, userMutation } from "./_helpers/builders";
import { T, V, type UserId, type NewTableId } from "./tables";
export const create = userMutation({
args: { title: v.string() },
handler: async (ctx, { title }) => {
return await ctx.db.insert(T.newTable, {
userId: ctx.viewerId as UserId,
title,
updatedAt: Date.now(),
});
},
});
export const list = userQuery({
args: {},
handler: async (ctx) => {
return await ctx.db.query(T.newTable).collect();
},
});
pnpm codegen
Common Patterns AI Can Implement
Testing Pattern
AI can write tests using convex-test
:
import { convexTest } from "convex-test";
import { expect, test } from "vitest";
import schema from "../schema";
import { api } from "../_generated/api";
test("create and query", async () => {
const t = convexTest(schema);
t.setAuth({ subject: "user123" }); // Mock auth
const id = await t.mutation(api.feature.create, {
title: "Test",
});
const results = await t.query(api.feature.list, {});
expect(results).toHaveLength(1);
expect(results[0]._id).toBe(id);
});
AI Context Files
AI agents read these files to understand patterns:
File | Purpose |
---|---|
llms/CONVEX.md | Core patterns, conventions, implementation rules |
convex/schema.ts | Current tables, indexes, validation patterns |
convex/tables.ts | Table constants (T), validators (V), type aliases |
convex/_helpers/builders.ts | Builder implementations, auto-scoping logic |
convex/support.ts | Working example of all patterns in production |
For AI: Before Implementing
Always read llms/CONVEX.md
and examine convex/tables.ts
before implementing new database features.
Anti-Patterns (What NOT to Do)
These Will Break the Codebase
AI must avoid these common mistakes:
// ❌ Hardcoded table names
args: { id: v.id("supportTickets") }
await ctx.db.query("supportTickets")
// ✅ Use constants from tables.ts
args: { id: V.supportTicketId }
await ctx.db.query(T.supportTickets)
// ❌ Bypassing builders
export const myQuery = query({ ... })
// ✅ Always use builders
export const myQuery = userQuery({ ... })
// ❌ Plural table names
users: defineTable({ ... })
// ✅ Singular names
user: defineTable({ ... })
// ❌ Forgetting to update tables.ts
// When adding new tables to schema.ts
// ✅ Update T, V, and types
T.newTable = "newTable"
type NewTableId = Id<typeof T.newTable>
V.newTableId = v.id(T.newTable)
// ❌ Skipping codegen
// After schema changes
// ✅ Always run
pnpm codegen