Skip to content

Instantly share code, notes, and snippets.

@zbeyens
Last active July 17, 2025 20:40
Show Gist options
  • Save zbeyens/943a5da9b5f7188eb6f41e6021e0094c to your computer and use it in GitHub Desktop.
Save zbeyens/943a5da9b5f7188eb6f41e6021e0094c to your computer and use it in GitHub Desktop.
Convex rules (optimized)
---
description: Guidelines and best practices for building Convex projects with Convex Ents, including database schema design, queries, mutations, and real-world examples
globs: **/*.ts,**/*.tsx
alwaysApply: false
---
# Convex guidelines (with Convex Ents)
## Authentication & Functions
**Edit:** `convex/functions.ts`, `convex/*.ts` files
### Function wrappers (NEVER use raw query/mutation/action)
```typescript
import {
createAuthQuery,
createPublicQuery,
createAuthMutation,
} from './functions';
import { z } from 'zod';
import { zid } from 'convex-helpers/server/zod';
// Public query - auth optional (ctx.userId available if authenticated)
export const getItem = createPublicQuery()({
args: { id: zid('items') },
returns: z.object({ name: z.string() }).nullable(),
handler: async (ctx, args) => {
return await ctx.table('items').get(args.id);
},
});
// Auth query - requires authentication (ctx.userId guaranteed)
export const myItems = createAuthQuery()({
args: {},
returns: z.array(z.object({ id: zid('items'), name: z.string() })),
handler: async (ctx) => {
return await ctx
.table('items')
.query()
.withIndex('userId', (q) => q.eq('userId', ctx.userId))
.collect();
},
});
// Auth mutation with rate limiting
export const createItem = createAuthMutation({
rateLimit: 'character/create', // Uses tier limits (free/premium)
})({
args: { name: z.string().min(1).max(100) },
returns: zid('items'),
handler: async (ctx, args) => {
return await ctx.table('items').insert({
name: args.name,
userId: ctx.userId,
});
},
});
// Admin-only function
export const adminData = createAuthQuery({ role: 'ADMIN' })({
returns: z.array(z.object({ id: zid('users'), email: z.string() })),
handler: async (ctx) => ctx.table('users').query().collect(),
});
```
**Always throw ConvexError instead of Error:** `throw new ConvexError({ code: 'UNAUTHENTICATED', message: 'Not authenticated' });`
### Rate Limiting
**Edit:** `convex/helpers/rateLimiter.ts`
Rate limits are defined in `convex/helpers/rateLimiter.ts` and automatically applied when specified in mutations:
```typescript
// Define rate limits in rateLimiter.ts
export const rateLimiter = new RateLimiter(components.rateLimiter, {
'comment/create:free': { kind: 'fixed window', period: MINUTE, rate: 10 },
'comment/create:premium': { kind: 'fixed window', period: MINUTE, rate: 30 },
// ... other limits
});
// Use in mutations
export const createComment = createAuthMutation({
rateLimit: 'comment/create', // Automatically adds :free/:premium suffix based on user tier
})({
// ... mutation implementation
});
```
### Internal functions
**Edit:** `convex/*.ts` files
```typescript
import {
createInternalQuery,
createInternalMutation,
createInternalAction,
} from './functions';
// Internal functions also use Zod
export const processData = createInternalQuery()({
args: { id: zid('items') },
returns: z.null(),
handler: async (ctx, args) => {
// Only callable by other Convex functions
return null;
},
});
```
### HTTP endpoints
**Edit:** `convex/http.ts`
For HTTP endpoints and webhooks, see [convex-http.mdc](mdc:.cursor/rules/convex-http.mdc).
### Validators
**Edit:** `convex/schema.ts` (schema files ONLY, we use zod for args/returns)
```typescript
import { defineEnt, defineEntSchema, getEntDefinitions } from 'convex-ents';
import { v } from 'convex/values';
// Schema validators (v.) - ONLY in schema.ts
const schema = defineEntSchema(
{
users: defineEnt({
name: v.string(),
age: v.number(),
role: v.union(v.literal('user'), v.literal('admin')),
})
.index('name', ['name'])
.field('email', v.string(), { unique: true }), // unique field example
// Relationship example
userFollows: defineEnt({
followingId: v.id('users'),
followerId: v.id('users'),
})
.index('following_follower', ['followingId', 'followerId'])
.edge('following', { to: 'users', field: 'followingId' })
.edge('follower', { to: 'users', field: 'followerId' }),
},
{
schemaValidation: true,
}
);
export default schema;
// Export ent definitions for use throughout the app
export const entDefinitions = getEntDefinitions(schema);
```
**Quick Reference:**
- `v.id('table')` - Document IDs
- `v.string()`, `v.number()`, `v.boolean()` - Basic types
- `v.null()` - Use instead of undefined
- `v.array(v.string())` - Arrays (max 8192)
- `v.object({...})` - Objects (max 1024 fields)
- `v.record(v.string(), v.number())` - Dynamic keys
- `v.union(...)`, `v.literal(...)` - Union types
- `v.optional(...)` - Optional fields
- `v.int64()` - BigInt support
### Zod validators (Functions)
**Edit:** `convex/*.ts` files (all function files)
```typescript
import { createAuthMutation } from './functions';
import { z } from 'zod';
import { zid } from 'convex-helpers/server/zod';
// Function validators (z.) - ALL functions
export const createUser = createAuthMutation()({
args: {
// CRITICAL: Always use zid() for IDs, not z.string()
userId: zid('users'),
name: z.string().min(1).max(100),
email: z.string().email(),
age: z.number().positive().int(),
tags: z.array(z.string()).max(10),
status: z.enum(['draft', 'published']),
bio: z.string().optional(), // Use .optional(), not .nullable()
},
returns: zid('users'),
handler: async (ctx, args) => {
/* ... */
},
});
```
**Quick Reference:**
- `zid('table')` - ALWAYS for document IDs
- `z.string().email()/.url()/.min()/.max()` - String validation
- `z.number().positive()/.int()/.min()/.max()` - Number validation
- `z.boolean().default(true)` - Booleans with defaults
- `z.array(z.string()).max(10)` - Arrays
- `z.object({...}).strict()` - Objects
- `z.enum(['a', 'b'])` - Enums
- `z.union([z.string(), z.number()])` - Unions
- `.optional()` - Optional fields (NOT .nullable())
- `z.record(z.string(), z.number())` - Dynamic keys
### Null vs undefined
```typescript
// ✅ CORRECT patterns
email: z.string().optional(); // Optional field
return null; // Functions return null
updates.email = args.email || undefined; // Patch: undefined removes field
// ❌ WRONG patterns
email: z.string().nullable(); // Don't use nullable for optional
return undefined; // Functions can't return undefined
```
### Document IDs
- **Function args/returns:** ALWAYS use `zid('tableName')`, never `z.string()`
- **TypeScript:** Use `Id<'tableName'>` type
- **External IDs:** Cast with `as Id<'tasks'>` when needed
### Function types summary
**Public functions** (exposed to internet):
- `createPublicQuery()` - auth optional
- `createAuthQuery()` - requires auth
- `createPublicMutation()` - auth optional
- `createAuthMutation()` - requires auth
- `createPublicPaginatedQuery()` - with pagination
- `createAuthPaginatedQuery()` - auth + pagination
- `createAction()` - public actions
**Internal functions** (only callable by Convex, NOT even by NextJS server):
- `createInternalQuery/Mutation/Action()`
**Rules:**
- CRITICAL: NEVER use raw `query`, `mutation`, `action` from `./_generated/server`
- ALWAYS include `args` and `returns` validators
- Use `returns: z.null()` when no return value
### Function calling
```typescript
// From query/mutation/action
ctx.runQuery(api.example.getData, args);
ctx.runMutation(api.example.update, args);
ctx.runAction(internal.example.process, args);
// Same-file calls need type annotation
const result: string = await ctx.runQuery(api.example.f, { name: 'Bob' });
```
**Rules:**
- Use FunctionReference (api/internal), not function directly
- Minimize action→query/mutation calls (race conditions)
- Share code via helper functions instead of action chains
### Function references
**Import:** `import { api, internal } from './_generated/api'`
- **Public:** `api.filename.functionName` (e.g., `api.users.create`)
- **Internal:** `internal.filename.functionName` (e.g., `internal.users.process`)
- **Nested:** `api.folder.file.function` (e.g., `api.messages.access.check`)
### File organization
**Edit:** `convex/` directory
- File-based routing: `convex/users.ts` → `api.users.*`
- Group by feature: `users.ts`, `messages.ts`, `auth.ts`
- Use internal functions for sensitive operations
### Pagination
- Use `paginationOptsValidator` from Convex (not Zod) for pagination:
```ts
import { z } from 'zod';
import { createAuthQuery } from './functions';
import { paginationOptsValidator } from 'convex/server';
export const listWithExtraArg = createAuthQuery()({
args: {
paginationOpts: paginationOptsValidator,
author: z.string(),
},
handler: async (ctx, args) => {
return await ctx
.table('messages')
.query()
.withIndex('author', (q) => q.eq('author', args.author))
.order('desc')
.paginate(args.paginationOpts);
},
});
```
Note: `paginationOpts` is an object with the following properties:
- `numItems`: the maximum number of documents to return
- `cursor`: the cursor to use to fetch the next page of documents (string | null)
- A query that ends in `.paginate()` returns an object with:
- `page`: Array of documents fetched
- `isDone`: Boolean indicating if this is the last page
- `continueCursor`: String cursor for the next page
### Paginated Queries
**OPTIMIZATION:** Always use pagination for user-facing lists that could grow large. See [convex-optimize.mdc](mdc:.cursor/rules/convex-optimize.mdc).
- Use `createPublicPaginatedQuery()` or `createAuthPaginatedQuery()` (paginationOpts auto-injected):
```typescript
// Basic
export const list = createPublicPaginatedQuery()({
handler: async (ctx, args) => {
return await ctx
.table('messages')
.query()
.order('desc')
.paginate(args.paginationOpts);
},
});
// With args + transform
export const listByAuthor = createPublicPaginatedQuery()({
args: { author: z.string() },
handler: async (ctx, args) => {
const results = await ctx
.table('messages')
.query()
.withIndex('author', (q) => q.eq('author', args.author))
.order('desc')
.paginate(args.paginationOpts);
// Batch fetch users by IDs
const userIds = results.page.map((m) => m.userId);
const users = await Promise.all(
userIds.map((id) => ctx.table('users').get(id))
);
return {
...results,
page: results.page.map((msg) => ({
...msg,
user: users.find((u) => u?._id === msg.userId) || null,
})),
};
},
});
// For complex filters with pagination, use streams
// See [convex-streams.mdc](mdc:.cursor/rules/convex-streams.mdc) for consistent page sizes
```
- **React hooks**: `usePublicPaginatedQuery`, `useAuthPaginatedQuery` (NEVER use `usePaginatedQuery` directly)
```typescript
import { usePublicPaginatedQuery, useAuthPaginatedQuery } from '@/lib/convex/hooks';
// Public paginated query
const { pages, hasNextPage, isLoading, isFetchingNextPage, fetchNextPage } = usePublicPaginatedQuery(
api.messages.list,
{ author: 'alice' }, // args (excluding paginationOpts)
{ initialNumItems: 10 }
);
// Authenticated paginated query (skips if not authenticated)
const { pages, hasNextPage, isLoading, isFetchingNextPage, fetchNextPage } = useAuthPaginatedQuery(
api.userFollows.getFollowers,
{ username },
{ initialNumItems: 20 }
);
// UI example
<button onClick={() => fetchNextPage(5)} disabled={!hasNextPage}>
Load More
</button>
```
**Return values**:
- `pages`: Array of all loaded items (accumulates across all pages)
- `hasNextPage`: Boolean indicating if more pages can be loaded
- `isLoading`: Boolean indicating if the first page is loading
- `isFetchingNextPage`: Boolean indicating if a subsequent page is loading
- `fetchNextPage(numItems)`: Function to load the next page with specified number of items
**Notes**:
- `pages` accumulates all items from all loaded pages
- `fetchNextPage(n)` only works when `hasNextPage === true`
- `useAuthPaginatedQuery` returns empty pages array if not authenticated
## Validator summary
- **Schema files (`convex/schema.ts`):** Use `v.` validators ONLY
- **Function files (`convex/*.ts`):** Use `z.` validators ONLY
- **Document IDs:** `zid('table')` in functions, `v.id('table')` in schemas
- **Records:** `z.record()` in functions, `v.record()` in schemas
## Schema guidelines
**Edit:** `convex/schema.ts`
```typescript
import { defineEnt, defineEntSchema, getEntDefinitions } from 'convex-ents';
import { v } from 'convex/values';
const schema = defineEntSchema(
{
users: defineEnt({
name: v.string(),
})
.field('email', v.string(), { unique: true }) // Unique field
.field('role', v.string(), { default: 'user' }) // Default value
.index('name_email', ['name', 'email']) // Compound index
.edge('profile', { ref: true }) // 1:1 edge (optional side)
.edges('messages', { ref: true }), // 1:many edge ("one" side)
profiles: defineEnt({
bio: v.string(),
}).edge('user'), // 1:1 edge (required side)
messages: defineEnt({
text: v.string(),
})
.index('userId', ['userId'])
.edge('user') // 1:many edge ("many" side)
.edge('author', { field: 'authorId', optional: true }) // Optional edge
.edges('tags'), // many:many edge
tags: defineEnt({
name: v.string(),
}).edges('messages'), // many:many edge
},
{
schemaValidation: true,
}
);
export default schema;
export const entDefinitions = getEntDefinitions(schema);
```
**Field options:**
- `.field('email', v.string(), { unique: true })` - Unique constraint
- `.field('type', v.string(), { default: 'text' })` - Default value
**Edge patterns:**
- 1:1: `.edge('profile', { ref: true })` on optional side
- 1:many: `.edges('messages', { ref: true })` on "one" side, `.edge('user')` on "many" side
- many:many: `.edges('tags')` on both sides
- Optional: `.edge('user', { optional: true })`
**System fields:** `_id: v.id('table')`, `_creationTime: v.number()`
**Index naming:** `field1_field2` for compound indexes
**OPTIMIZATION:** Create indexes for all equality filters - see [convex-optimize.mdc](mdc:.cursor/rules/convex-optimize.mdc)
## Import guidelines
**From Next.js (`src/`):**
```typescript
import { api, internal } from '@convex/_generated/api';
import { Id, Doc } from '@convex/_generated/dataModel';
import type { Ent, EntWriter } from '@convex/shared/types'; // Ent types
```
**From Convex (`convex/`):**
```typescript
import { api, internal } from './_generated/api';
import { Id, Doc } from './_generated/dataModel';
import { entDefinitions } from './schema'; // Import ent definitions
import { entsTableFactory } from 'convex-ents'; // For function context
```
## React Integration guidelines
### Query hooks
**Edit:** `src/components/**/*.tsx`
```typescript
import { usePublicQuery, useAuthQuery } from '@/lib/convex/hooks';
// Public query (auth optional) - ALWAYS pass {} for no args
const { data, isPending } = usePublicQuery(api.items.list, {});
const { data: item } = usePublicQuery(api.items.get, { id: itemId });
// Auth query (skips if not authenticated)
const { data: profile } = useAuthQuery(api.user.getProfile, {});
// Skip conditionally
const { data } = usePublicQuery(
api.items.get,
itemId ? { id: itemId } : 'skip'
);
// Loading state
if (isPending) return <Skeleton />;
```
**Never use** `useQuery` or `usePaginatedQuery` directly
### Skeleton Loading with Convex
- **ALWAYS use `WithSkeleton` + `useMockQuery`** for consistent skeleton UI:
```typescript
import { WithSkeleton } from '@/components/ui/skeleton';
import { useMockQuery } from '@/hooks/useLoading';
// Basic usage
const characterQuery = useAuthQuery(api.character.list, {});
const { data: { characters }, isLoading } = useMockQuery(characterQuery, {
characters: [
{ id: '1', name: 'Character 1', description: 'Description text' },
{ id: '2', name: 'Character 2', description: 'Description text' },
],
});
return (
<div>
{characters.map((char, index) => (
<WithSkeleton key={index} isLoading={isLoading}>
<Card>
<h3>{char.name}</h3>
<p>{char.description}</p>
</Card>
</WithSkeleton>
))}
</div>
);
```
**Rules**:
- NEVER use random values in mock data (causes hydration errors)
- Keep mock data properties alphabetically ordered
- Use static, predictable mock data matching expected structure
- Use index as key for mock data items when mapping
### Mutations
**Edit:** `src/components/**/*.tsx`
```typescript
import { usePublicMutation, useAuthMutation } from '@/lib/convex/hooks';
// Don't destructure to avoid naming conflicts
const createItem = usePublicMutation(api.items.create);
const updateSettings = useAuthMutation(api.user.updateSettings);
// Option 1: toast.promise (with loading state)
toast.promise(updateSettings.mutateAsync({ name: 'New' }), {
loading: 'Updating...',
success: 'Updated!',
error: (e) => e.data?.message ?? 'Failed',
});
// Option 2: callbacks (no loading toast)
const updateSettings = useAuthMutation(api.user.updateSettings, {
onSuccess: () => toast.success('Updated!'),
onError: () => toast.error('Failed'),
});
<Button disabled={updateSettings.isPending}>Save</Button>
```
### Other hooks
```typescript
// Actions
import { useAction } from 'convex/react';
const generateReport = useAction(api.reports.generate);
// One-off queries
import { useConvex } from 'convex/react';
const convex = useConvex();
await convex.query(api.items.validate, { id });
// Auth hooks
import { useIsAuth, useCurrentUser } from '@/lib/convex/hooks';
const isAuth = useIsAuth();
const { isLoading, ...user } = useCurrentUser();
```
### Server-side auth (RSC)
```typescript
import {
getSessionToken,
getSessionUser,
isAuth,
isUnauth,
fetchAuthQuery,
fetchAuthQueryOrThrow,
} from '@/lib/convex/server';
// Get session token/user in server components
const token = await getSessionToken(); // Returns string | null
const user = await getSessionUser(); // Returns SessionUser & { token } | null
// Check auth status
if (await isAuth()) {
/* authenticated */
}
if (await isUnauth()) {
/* not authenticated */
}
// Fetch authenticated queries in server components
const data = await fetchAuthQuery(api.user.getData, { id: userId });
// Returns null if not authenticated
const data = await fetchAuthQueryOrThrow(api.user.getData, { id: userId });
// Throws error if not authenticated
```
### Auth guards
**Server-side guards (RSC)**:
```typescript
import { authGuard, adminGuard, superAdminGuard } from '@/lib/convex/rsc';
// In server components/pages
await authGuard(); // Redirects to login if not authenticated
await adminGuard(); // Returns 404 if not admin
await superAdminGuard(); // Returns 404 if not superadmin
```
**Client-side guards (hooks)**:
```typescript
import { useAuthGuard, usePremiumGuard } from '@/lib/convex/hooks';
// Auth guard - shows login modal if not authenticated
const authGuard = useAuthGuard();
if (authGuard(() => console.info('authenticated'))) return; // Blocked
// Premium guards - shows subscription modal if not premium
const premiumGuard = usePremiumGuard();
if (premiumGuard()) return; // Blocked
```
### Relationship patterns with Ents
**Edit:** `convex/*.ts` files
```typescript
import { asyncMap } from 'convex-helpers';
// Get entity by ID
const user = await ctx.table('users').get(userId);
const userX = await ctx.table('users').getX(userId); // Throws if not found
// Get by unique field or index
const userByEmail = await ctx.table('users').get('email', '[email protected]');
const userByEmailX = await ctx.table('users').getX('email', '[email protected]');
const follow = await ctx
.table('userFollows')
.get('following_follower', userId1, userId2);
// Batch operations
const users = await ctx.table('users').getMany([id1, id2, id3]); // (Doc | null)[]
const usersX = await ctx.table('users').getManyX([id1, id2, id3]); // Doc[] or throws
// Unique document
const settings = await ctx.table('settings').unique(); // null if 0, error if 2+
const settingsX = await ctx.table('settings').uniqueX(); // throws if not exactly 1
// Edge traversal
const profile = await user.edge('profile'); // may return null for optional
const profileX = await user.edgeX('profile'); // throws if missing
const messages = await user.edge('messages'); // returns array
const messages = await user.edge('messages').take(5); // limit results
// Paginated edge traversal
const followers = await user
.edge('followers')
.order('desc')
.paginate(args.paginationOpts);
// Query with filters
const activePosts = await ctx
.table('posts')
.query()
.withIndex('authorId', (q) => q.eq('authorId', authorId))
.filter((q) => q.eq(q.field('status'), 'active'))
.take(50);
// Map patterns
const ids = results.map((r) => r.id); // ✅ Sync: use regular map
const enriched = await asyncMap(results, async (r) => {
// ✅ Async: use asyncMap
const user = await ctx.table('users').get(r.userId);
return { ...r, user };
});
// Count patterns - AVOID fetching all just to count
// ❌ INEFFICIENT: const count = (await ctx.table('posts').query().collect()).length;
// ✅ EFFICIENT: Use aggregates for counts - see [convex-aggregate.mdc](mdc:.cursor/rules/convex-aggregate.mdc)
```
**Relationship patterns:**
```typescript
// One-to-many (scalable)
defineTable({ authorId: v.id('users') }).index('authorId', ['authorId']);
// Many-to-many (join table)
defineTable({
userId: v.id('users'),
channelId: v.id('channels'),
})
.index('userId', ['userId'])
.index('channelId', ['channelId'])
.index('user_channel', ['userId', 'channelId']);
```
**Performance:** N+1 is fast (~1ms/doc) | Arrays max 10 items | Use indexes
**Count Operations:** For counting documents, use [convex-aggregate.mdc](mdc:.cursor/rules/convex-aggregate.mdc) instead of `.length` on collections
## Typescript guidelines
### Core type patterns
```typescript
// Document IDs
type UserId = Id<'users'>; // Use Id<T> for document IDs
const userId = localStorage.getItem('id') as Id<'users'>; // Cast external IDs
// Infer complex types (NEVER manually type)
const mainCharacter = await (async () => {
if (!condition) return null;
return { id: char._id, image: char.image ?? null }; // Auto-inferred
})();
// Record with IDs
const idToUsername: Record<Id<'users'>, string> = {};
// Arrays and unions
const array: Array<string> = ['a', 'b'];
const status = 'active' as const; // Use 'as const' for literals
```
### Helper types
```typescript
// Auth-aware context (from convex/functions.ts), NEVER use ctx: any
AuthQueryCtx, PublicQueryCtx, AuthMutationCtx, PublicMutationCtx;
// Return type inference (client-side)
import { FunctionReturnType } from 'convex/server';
type MyData = FunctionReturnType<typeof api.users.get>;
// ❌ NEVER use: Awaited<ReturnType<typeof api.function>>
```
**Rules:** No `any` | No manual complex types | Strict ID types
## Full text search guidelines
**Edit:** `convex/schema.ts` for search indexes
### Define search indexes
```typescript
// convex/schema.ts
export default defineSchema({
messages: defineTable({
body: v.string(),
channel: v.string(),
userId: v.id('users'),
}).searchIndex('search_body', {
searchField: 'body',
filterFields: ['channel', 'userId'], // Fast equality filters
}),
});
```
### Run search queries
```typescript
// Basic search with filters
const messages = await ctx
.table('messages')
.query()
.withSearchIndex(
'search_body',
(q) =>
q
.search('body', 'hello world') // Full-text search
.eq('channel', '#general') // Filter field
.eq('userId', userId) // Multiple filters
)
.take(10); // Always limit results
// Search with post-filtering (less efficient)
const recent = await ctx
.table('messages')
.query()
.withSearchIndex('search_body', (q) => q.search('body', 'hello'))
.filter((q) => q.gt(q.field('_creationTime'), Date.now() - 3600000))
.take(10);
```
For advanced patterns, see [convex-search.mdc](mdc:.cursor/rules/convex-search.mdc).
## Query guidelines
- **CRITICAL: NEVER fetch ALL documents when you only need a subset**:
- Use `.take(n)` to limit results: `ctx.table('table').query().take(100)` (default is 100 if not specified)
- Use `.first()` for single document: `ctx.table('table').query().first()`
- Use `.get()` for fetching by ID or unique field: `ctx.table('table').get(id)` or `ctx.table('table').get('email', email)`
- Use `.paginate()` for user-facing lists: see [convex-optimize.mdc](mdc:.cursor/rules/convex-optimize.mdc)
- Only use `.collect()` when you truly need ALL documents (rare, <1000 docs)
- **For counts:** Use [convex-aggregate.mdc](mdc:.cursor/rules/convex-aggregate.mdc) instead of `.collect().length`
- **OPTIMIZATION:** Always use indexes first for filtering:
- **Use indexes:** Define an index in schema and use `withIndex` for equality filters
- **Get by index:** Use `.get('indexName', value)` for unique lookups
- **Simple cases:** Use built-in `.filter()` for simple field comparisons after indexing
- **Complex + Paginated:** Use streams from `convex-helpers/server/stream` for consistent page sizes (streams require `ctx.db` in first param only)
- See [convex-optimize.mdc](mdc:.cursor/rules/convex-optimize.mdc) and [convex-streams.mdc](mdc:.cursor/rules/convex-streams.mdc)
- Ents queries support chaining: `.query().withIndex().filter().take()`
- Use `.unique()` to get a single document from a query. This method will throw an error if there are multiple documents that match the query.
- When using async iteration, don't use `.collect()` or `.take(n)` on the result of a query. Instead, use the `for await (const row of query)` syntax.
### Query Filtering Guidelines
**⚠️ CRITICAL: Use the Right Tool for Filtering**
1. **For simple filters (paginated or not):** Use built-in `.filter()` - maintains full page sizes!
2. **For complex filters WITHOUT pagination:** Use `filter` helper from `convex-helpers/server/filter`
3. **For complex filters WITH pagination:** Use streams from `convex-helpers/server/stream`
#### Simple Filtering (With or Without Pagination)
```typescript
// ✅ GOOD: Built-in .filter() for simple cases
const activeUsers = await ctx
.table('users')
.query()
.withIndex('status', (q) => q.eq('status', 'active'))
.filter((q) => q.gt(q.field('lastSeen'), Date.now() - 3600000))
.take(10);
// Built-in .filter() supports:
// - eq, neq, gt, gte, lt, lte
// - and, or, not
// - Field comparisons only
```
#### Complex Filtering (Arrays, Strings, Async)
**Problem:** Built-in `.filter()` is limited to simple field comparisons.
**Solutions:**
**1. Without Pagination - Use Filter Helper:**
```typescript
import { filter } from 'convex-helpers/server/filter';
// ✅ GOOD: Filter helper for complex filters without pagination
const featuredPosts = await filter(
ctx
.table('posts')
.query()
.withIndex('author', (q) => q.eq('author', authorId)),
(post) => post.tags.includes('featured') && post.views > 100
).take(10);
// Works with: .first(), .unique(), .take(), .collect()
// DON'T use with: .paginate() (causes variable page sizes)
```
**2. With Pagination - Use Streams:**
```typescript
import { stream } from 'convex-helpers/server/stream';
import schema from './schema';
// ✅ BEST: Streams filter BEFORE pagination - consistent page sizes!
export const searchCharacters = createPublicPaginatedQuery()({
args: {
category: z.string().optional(),
tags: z.array(z.string()).optional(),
},
handler: async (ctx, args) => {
return await stream(ctx.db, schema) // ⚠️ Stream requires ctx.db here ONLY
.query('characters')
.withIndex('private', (q) => q.eq('private', false))
.filterWith(async (char) => {
// Full TypeScript power: arrays, strings, async lookups
if (args.category && !char.categories?.includes(args.category)) {
return false;
}
if (args.tags && !args.tags.some((tag) => char.tags?.includes(tag))) {
return false;
}
// Inside stream operations, use ctx.table()!
const author = await ctx.table('users').get(char.userId);
return author && !author.isBanned;
})
.paginate(args.paginationOpts); // Always returns requested items!
},
});
```
**When to Use Each:**
| Use Case | Tool | Why |
| ------------------------------------ | -------------------- | ------------------------------ |
| Simple field comparisons | Built-in `.filter()` | Maintains full page sizes |
| Complex filters + `.take()/.first()` | `filter` helper | Full TypeScript, no pagination |
| Complex filters + `.paginate()` | Streams | Consistent page sizes |
| Array/string operations + paginate | Streams | Filter before pagination |
| Async lookups in filter | `filter` or Streams | Both support async |
For complete stream patterns including unions, joins, and more, see [convex-streams.mdc](mdc:.cursor/rules/convex-streams.mdc).
## 🚨 CRITICAL: Never Use ctx.db
**FORBIDDEN:** `ctx.db` is banned in all Convex functions. Always use `ctx.table()` for database operations.
**ONLY EXCEPTION:** Streams from `convex-helpers/server/stream` still require `ctx.db` in the first parameter only:
```typescript
// ❌ NEVER: ctx.db.query('users')
// ✅ ALWAYS: ctx.table('users').query()
// ⚠️ Exception: Stream initialization ONLY
import { stream } from 'convex-helpers/server/stream';
// Stream requires ctx.db in first parameter
const myStream = stream(ctx.db, schema).query('posts');
// BUT: Inside stream operations, you CAN and SHOULD use ctx.table()!
.filterWith(async (post) => {
// ✅ Use ctx.table() inside stream operations
const author = await ctx.table('users').get(post.authorId);
return author && !author.isBanned;
})
```
### Ordering
- By default Convex always returns documents in ascending `_creationTime` order.
- You can use `.order('asc')` or `.order('desc')` to pick whether a query is in ascending or descending order. If the order isn't specified, it defaults to ascending.
- Document queries that use indexes will be ordered based on the columns in the index and can avoid slow table scans.
## Mutation guidelines
- Use `.replace()` to fully replace an existing document: `await ctx.table('users').getX(id).replace(data)`
- Use `.patch()` to shallow merge updates: `await ctx.table('users').getX(id).patch({ name: 'New' })`
- Use `.delete()` to remove a document: `await ctx.table('users').getX(id).delete()`
- Use `.insert()` to create new documents: `await ctx.table('users').insert(data)`
- `.getX()` throws if document not found, `.get()` returns null
- Isolate frequently-changing data (timestamps, counters) in separate tables - see [convex-optimize.mdc](mdc:.cursor/rules/convex-optimize.mdc)
### Writing with Ents
```typescript
// Insert and get entity
const task = await ctx.table('tasks').insert({ text: 'Build feature' }).get();
// Insert with 1:1 or 1:many edges
await ctx.table('messages').insert({
text: 'Hello world',
userId, // Creates edge to user
});
// Insert with many:many edges
await ctx.table('messages').insert({
text: 'Tagged message',
tags: [tagId1, tagId2], // Creates edges to tags
});
// Update many:many edges
await ctx
.table('messages')
.getX(messageId)
.patch({
tags: { add: [tagId3], remove: [tagId1] },
});
// Replace many:many edges (replaces all)
await ctx
.table('messages')
.getX(messageId)
.replace({
text: 'Updated text',
tags: [tagId2, tagId3], // Replaces all tag edges
});
// Edge mutations
await ctx
.table('users')
.getX(userId)
.edgeX('profile')
.patch({ bio: 'New bio' });
```
### Bulk operations
- **OPTIMIZATION: Loop inserts/updates are efficient** - Convex batches all database changes in a single transaction, no need for `Promise.all()`
- **Limit bulk operations:** Process in batches of 100-1000 to avoid timeout
```typescript
// ✅ Efficient - all inserts execute in one transaction
for (const item of items) {
await ctx.table('table').insert(item);
}
// ❌ Unnecessary - adds complexity without performance benefit
await Promise.all(items.map((item) => ctx.table('table').insert(item)));
// ✅ For large datasets, process in batches
const batchSize = 500;
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize);
for (const item of batch) {
await ctx.table('table').insert(item);
}
}
// ✅ Bulk delete example
const toDelete = await ctx
.table('posts')
.query()
.withIndex('authorId', (q) => q.eq('authorId', authorId))
.collect();
for (const post of toDelete) {
await post.delete(); // Entity method
}
```
## Action guidelines
- Always add `"use node";` to the top of files containing actions that use Node.js built-in modules.
- Never use `ctx.table` or `ctx.db` inside of an action. Actions don't have access to the database.
- Use `createAction()` for public actions and `createInternalAction()` for internal actions:
```ts
'use node';
import { createAction } from './functions';
import { z } from 'zod';
// Public action
export const exampleAction = createAction()({
args: {},
returns: z.null(),
handler: async (ctx, args) => {
console.info('This action does not return anything');
return null;
},
});
```
## Scheduling guidelines
For cron jobs and scheduling patterns, see [convex-scheduling.mdc](mdc:.cursor/rules/convex-scheduling.mdc).
## Examples
For complete examples including chat applications and other patterns, see [convex-examples.mdc](mdc:.cursor/rules/convex-examples.mdc).
## Deletion Behaviors
**Edit:** `convex/schema.ts`
Convex Ents automatically handles cascading deletes. **Hard deletion is now the default behavior** - when you delete an ent, related ents are automatically hard deleted based on edge relationships:
```typescript
defineEnt({
name: v.string(),
})
.deletion('soft') // Soft delete with deletionTime field
.deletion('scheduled', { delayMs: 24 * 60 * 60 * 1000 }) // Delete after 24h
.edge('profile') // Profile is automatically deleted when user is deleted (default: hard)
.edge('files', { to: '_storage' }); // Files are automatically deleted (default: hard)
// Soft delete operations
await ctx.table('users').getX(userId).delete(); // Sets deletionTime
await ctx.table('users').getX(userId).patch({ deletionTime: undefined }); // Undelete
```
For detailed deletion patterns and rules, see [convex-ents.mdc](mdc:.cursor/rules/convex-ents.mdc).
## Triggers
**Edit:** `convex/triggers.ts`, `convex/functions.ts`
Triggers automatically run code when data changes. For complete trigger documentation, see [convex-trigger.mdc](mdc:.cursor/rules/convex-trigger.mdc).
```typescript
// Setup in functions.ts
import { Triggers } from 'convex-helpers/server/triggers';
export const triggers = new Triggers<DataModel>();
import './triggers'; // Import trigger registrations
// Common patterns in triggers.ts
// Aggregate maintenance - ALWAYS use .trigger()
triggers.register('characterWorks', aggregateCharacterWorks.trigger());
// Data validation
triggers.register('users', async (ctx, change) => {
if (change.newDoc && !change.newDoc.email.includes('@')) {
throw new Error('Invalid email');
}
});
```
## Convex Aggregate
**Edit:** `convex/aggregates.ts`
**CRITICAL for Scale:** Use aggregates instead of `.collect().length` for counts. O(log n) vs O(n) performance.
**🚨 CRITICAL: ALWAYS use triggers to keep aggregates in sync. NEVER update aggregates manually in mutations.**
**⚠️ IMPORTANT: All aggregate components must be registered in `convex/convex.config.ts` before they can be used.**
For complete aggregate documentation, see [convex-aggregate.mdc](mdc:.cursor/rules/convex-aggregate.mdc).
```typescript
// ❌ INEFFICIENT: Fetches all documents just to count
const count = (await ctx.table('scores').query().collect()).length;
// ✅ EFFICIENT: O(log n) count operation with triggers
const leaderboard = new TableAggregate<...>(components.leaderboard, {
namespace: (doc) => doc.gameId,
sortKey: (doc) => doc.score,
});
// STEP 1: Register component in convex/convex.config.ts
// app.use(aggregate, { name: 'leaderboard' });
// STEP 2: Set up automatic aggregate updates in triggers.ts
triggers.register('scores', leaderboard.trigger());
// STEP 3: In mutations - Just do normal operations, triggers handle aggregates!
await ctx.table('scores').insert(data); // That's it! Aggregate updates automatically
await ctx.table('scores').getX(scoreId).delete(); // Triggers fire automatically!
// STEP 4: Read O(log n) operations
const count = await leaderboard.count(ctx, { namespace: gameId });
const rank = await leaderboard.indexOf(ctx, score);
// ❌ NEVER do manual updates
await leaderboard.insert(ctx, doc); // WRONG!
await leaderboard.delete(ctx, doc); // WRONG!
```
## Shared Code Organization
- **Shared helpers**: ALWAYS move shared code from `src/` to `convex/shared/` instead of duplicating
- **Model exception**: `[modelName]Shared.ts` can be next to `[modelName].ts`, not in `convex/shared/` folder
- **Import paths**:
- From Convex: `import { helper } from './shared/filename'` or `import { helper } from './characterShared'`
- From Next.js: `import { helper } from '@convex/shared/filename'` or `import { helper } from '@convex/characterShared'`
## See Also
For specific patterns and detailed examples:
- [convex-aggregate.mdc](mdc:.cursor/rules/convex-aggregate.mdc) - Efficient aggregation operations
- [convex-examples.mdc](mdc:.cursor/rules/convex-examples.mdc) - Complete application examples
- [convex-scheduling.mdc](mdc:.cursor/rules/convex-scheduling.mdc) - Cron jobs and scheduled functions
- [convex-http.mdc](mdc:.cursor/rules/convex-http.mdc) - HTTP endpoints and webhooks
- [convex-search.mdc](mdc:.cursor/rules/convex-search.mdc) - Full-text search implementation
- [convex-optimize.mdc](mdc:.cursor/rules/convex-optimize.mdc) - Query optimization for scale
- [convex-trigger.mdc](mdc:.cursor/rules/convex-trigger.mdc) - Database triggers and cascade operations
- [convex-doc.mdc](mdc:.cursor/rules/convex-doc.mdc) - Documentation maintenance guidelines
- [convex-streams.mdc](mdc:.cursor/rules/convex-streams.mdc) - Advanced query patterns with streams
---
description:
globs:
alwaysApply: false
---
# Convex Ents
Convex Ents are an ergonomic layer on top of the [Convex](https://convex.dev)
built-in [`ctx.db`](https://docs.convex.dev/database) API for reading from and
writing to the database.
Convex Ents:
1. Build upon the relational capabilities of the database to provide an easier
way to query related documents.
2. Allow defining default values for easier document shape evolution.
3. Simplify backend code by collocating common authorization rules in a single
place.
4. And more!
Convex Ents provide similar capabilities to
[Prisma ORM](https://www.prisma.io/client), but target only Convex, and use
neither proprietary schema language nor SQL concepts nor code generation. Convex
Ents are a pure TypeScript/JavaScript library.
## Examples
Check out [these code snippets](https://labs.convex.dev/convex-vs-prisma) for
comparison of Prisma, Convex and Convex Ents.
[SaaS Starter](https://github.com/xixixao/saas-starter) is a full project
template built out using Convex Ents.
<Tabs items={["Example query", "mutation", "schema"]}>
<Tabs.Tab>
```ts filename="convex/teams.ts"
export const listTeamInvites = query({
args: { teamId: v.id('teams') },
async handler(ctx, { teamId }) {
return await ctx
.table('teams')
.getX(teamId)
.edge('invites')
.map(async (invite) => ({
_id: invite._id,
email: invite.email,
role: (await invite.edge('role')).name,
})); // `{ _id: Id<"invites">, email: string, role: string }[]`
},
});
```
</Tabs.Tab>
<Tabs.Tab>
```ts filename="convex/teams.ts"
export const acceptInvite = mutation({
args: { inviteId: v.id('invites') },
async handler(ctx, { inviteId }) {
const invite = await ctx.table('invites').getX(inviteId);
await ctx.table('members').insert({
teamId: invite.teamId,
userId: ctx.viewerId,
roleId: invite.roleId,
});
await invite.delete();
return (await invite.edge('team')).slug;
},
});
```
</Tabs.Tab>
<Tabs.Tab>
```ts filename="convex/schema.ts"
const schema = defineEntSchema({
teams: defineEnt({
name: v.string(),
})
.field('slug', v.string(), { unique: true })
.edges('members', { ref: true })
.edges('invites', { ref: true }),
members: defineEnt({}).edge('team').edge('user').edge('role'),
invites: defineEnt({})
.field('email', v.string(), { unique: true })
.edge('team')
.edge('role'),
roles: defineEnt({
isDefault: v.boolean(),
}).field('name', v.union(v.literal('Admin'), v.literal('Member')), {
unique: true,
}),
users: defineEnt({}).edges('members', { ref: true }),
});
```
</Tabs.Tab>
</Tabs>
## I'm intrigued, what now?
Read the [Ent Schema](/schema) page to understand the improved data modeling
that Convex Ents enable, and the [Reading Ents](/read) and
[Writing Ents](/write) to see the more powerful interface to the database.
If you're sold, head over to [Setup](/setup) to get started.
### Create a schema
Create your schema file, for example:
```ts filename="convex/schema.ts"
import { v } from 'convex/values';
import { defineEnt, defineEntSchema, getEntDefinitions } from 'convex-ents';
const schema = defineEntSchema({
messages: defineEnt({
text: v.string(),
})
.edge('user')
.edges('tags'),
users: defineEnt({
name: v.string(),
}).edges('messages', { ref: true }),
tags: defineEnt({
name: v.string(),
}).edges('messages'),
});
export default schema;
export const entDefinitions = getEntDefinitions(schema);
```
### Modify your schema
Update your schema file. These changes will have no effect on your existing code
using `ctx.db`.
- Replace `defineSchema` with `defineEntSchema`
- Replace all `defineTable` with `defineEnt`
- Add `entDefinitions` as shown
Example changes:
```diff filename="convex/schema.ts"
- import { defineSchema, defineTable } from "convex/server";
+ import { defineEnt, defineEntSchema, getEntDefinitions } from "convex-ents";
import { v } from "convex/values";
- const schema = defineSchema({
+ const schema = defineEntSchema({
- messages: defineTable({
+ messages: defineEnt({
text: v.string(),
}),
- users: defineTable({
+ users: defineEnt({
name: v.string(),
}),
});
export default schema;
+ export const entDefinitions = getEntDefinitions(schema);
```
For more details on declaring the schema see [Ent Schema](/schema).
### Add helper types
Add a `types.ts` file with the following contents:
<Aside title="Click to show">
```ts filename="convex/types.ts"
import { GenericEnt, GenericEntWriter } from 'convex-ents';
import { CustomCtx } from 'convex-helpers/server/customFunctions';
import { TableNames } from './_generated/dataModel';
import { mutation, query } from './functions';
import { entDefinitions } from './schema';
export type QueryCtx = CustomCtx<typeof query>;
export type MutationCtx = CustomCtx<typeof mutation>;
export type Ent<TableName extends TableNames> = GenericEnt<
typeof entDefinitions,
TableName
>;
export type EntWriter<TableName extends TableNames> = GenericEntWriter<
typeof entDefinitions,
TableName
>;
```
</Aside>
### Use custom functions to read and write ents
You can now replace function constructors from `_generated` with the custom ones
you just set up. This will have no impact on your existing functions:
```diff filename="convex/messages.ts"
import { v } from "convex/values";
- import { mutation, query } from "./_generated/server";
+ import { mutation, query } from "./functions";
export const list = query({
args: {},
handler: async (ctx) => {
return await ctx.db.query("messages");
+ // You can now use ctx.table as well:
+ return await ctx.table("messages");
},
});
```
Similarly replace `QueryCtx` and `MutationCtx` with imports from `./types`.
You can now use `ctx.table` for new code and to replace existing code.
1. The `table` API comes with an [additional level of security](/read#security)
2. The `table` API preserves invariants, such as:
- fields having unique values
- 1:1 edges being unique on each end of the edge
- deleting ents deletes corresponding edges
# Ent Schema
Ents (short for entity) are Convex documents, which allow explicitly declaring
edges (relationships) to other documents. The simplest ent has no fields besides
the built-in `_id` and `_creationTime` fields, and no declared edges. Ents can
contain all the same field types Convex documents can. Ents are stored in tables
in the Convex database.
Unlike bare documents, ents require a schema. Here's a minimal example of the
`convex/schema.ts` file using Ents:
```ts filename="convex/schema.ts"
import { v } from 'convex/values';
import { defineEnt, defineEntSchema, getEntDefinitions } from 'convex-ents';
const schema = defineEntSchema({
messages: defineEnt({
text: v.string(),
}).edge('user'),
users: defineEnt({
name: v.string(),
}).edges('messages', { ref: true }),
});
export default schema;
export const entDefinitions = getEntDefinitions(schema);
```
Compared to a [vanilla schema file](https://docs.convex.dev/database/schemas):
- `defineEntSchema` replaces `defineSchema` from `convex/server`
- `defineEnt` replaces `defineTable` from `convex/server`
- Besides exporting the `schema`, which is used by Convex for schema validation,
you also export `entDefinitions`, which include the runtime information needed
to enable retrieving Ents via edges and other features.
## Fields
An Ent field is a field in its backing document. Some types of [edges](#edges)
add additional fields not directly specified in the schema.
Ents add field configurations beyond vanilla Convex.
### Indexed fields
`defineEnt` provides a shortcut for declaring a field and a simple index over
the field. Indexes allow efficient point and range lookups and efficient sorting
of the results by the indexed field. The following schema:
```ts
defineEntSchema({
users: defineEnt({}).field('email', v.string(), { index: true }),
});
```
declares that "users" ents have one field, `"email"` of type `string`, and one
index called `"email"` over the `"email"` field. It is exactly equivalent to the
following schema:
```ts
defineEntSchema({
users: defineEnt({
email: v.string(),
}).index('email', ['email']),
});
```
### Unique fields
Similar to an indexed field, you can declare that a field must be unique in the
table. This comes at a cost: On every write to the backing table, it must be
checked for an existing document with the same field value. Every unique field
is also an indexed field (as the index is used for an efficient lookup).
```ts
defineEntSchema({
users: defineEnt({}).field('email', v.string(), { unique: true }),
});
```
### Field defaults
When evolving a schema, especially in production, the simplest way to modify the
shape of documents in the database is to add an optional field. Having an
optional field means that your code either always has to handle the "missing"
value case (the value is `undefined`), or you need to perform a careful
migration to backfill all the documents and set the field value.
Ents simplify this shape evolution by allowing to specify a default for a field.
The following schema:
```ts
defineEntSchema({
posts: defineEnt({}).field(
'contentType',
v.union(v.literal('text'), v.literal('video')),
{ default: 'text' }
),
});
```
declares that "posts" ents have one **required** field `"contentType"`,
containing a string, either `"text"` or `"video"`. When the value is missing
from the backing document, the default value `"text"` is returned. Without
specifying the default value, the schema could look like:
```ts
defineEntSchema({
posts: defineEnt({
contentType: v.optional(v.union(v.literal('text'), v.literal('video'))),
}),
});
```
but for this schema the `contentType` field missing must be handled by the code
reading the ent.
### Adding fields to all union variants
If you use a [union](https://docs.convex.dev/database/schemas#unions) as an
ent's schema, you can add a field to all the variants:
```ts
defineEntSchema({
posts: defineEnt(
v.union(
v.object({
type: v.literal('text'),
content: v.string(),
}),
v.object({
type: v.literal('video'),
link: v.string(),
})
)
).field('author', v.id('users')),
});
```
adds an `author` field to both text and video posts.
## Edges
An edge is a representation of some business logic modeled by the database. Some
examples are:
- User A liking post X
- User B authoring post Y
- User C is friends with user D
- Folder F is a child of folder G
Every edge has two "ends", each being an ent. Those ents can be stored in the
same or in 2 different tables. Edges can represent symmetrical relationships.
The "friends with" edge is an example. If user C is friends with user D, then
user D is friends with user C. Symmetrical edge only make sense if both ends of
the edge point to Ents in the same table. Edges which are not symmetrical have
two names, one for each direction. For the user liking a post example, one
direction can be called "likedPosts" (from users to posts), the other "likers"
(from posts to users).
Edges can also declare how many ents can be connected through the same edge. For
each end, there can be 0, 1 or many ents connected to the same ent on the other
side of the edge. For example, a user (represented by an ent) has a profile
(represented by an ent). In this case we call this a 1:1 edge. If we ask "how
many profiles does a user X have?" the answer is always 1. Similarly there can
be 1:many edges, such as a user with the messages they authored, when each
message has only a single author; and many:many edges, such as messages to tags,
where each message can have many tags, and each tag can be attached to many
messages.
Now that you understand all the properties of edges, here's how you can declare
them: Edges are always declared on the ents that constitute its ends. Let's take
the example of users authoring messages:
```ts
defineEntSchema({
users: defineEnt({
name: v.string(),
}).edges('messages', { ref: true }),
messages: defineEnt({
text: v.string(),
}).edge('user'),
});
```
In this example, the edge between "users" and "messages" is 1:many, each user
can have many associated messages, but each message has only a single associated
user. The syntax is explained below.
### Understanding how edges are stored
To further understand the ways in which edges can be declared, we need to
understand the difference in how they are stored. There are two ways edges are
stored in the database:
1. _Field edges_ are stored as a single foreign key column in one of the two
connected tables. All 1:1 and 1:many edges are field edges.
2. _Table edges_ are stored as documents in a separate table. All many:many
edges are table edges.
In the example above the edge is stored as an `Id<"users>` on the "messages"
document.
### 1:1 edges
1:1 edges are in a way a special case of 1:many edges. In Convex, one end of the
edge must be optional, because there is no way to "allocate" IDs before
documents are created (see
[circular references](https://docs.convex.dev/database/schemas#circular-references)).
Here's a basic example of a 1:1 edge, defined for each ent using the `edge`
(singular) method:
<Graphic src={edge} dark={edgeDark} alt="1:1 edges pictogram" />
```ts
defineEntSchema({
users: defineEnt({
name: v.string(),
}).edge('profile', { ref: true }),
profiles: defineEnt({
bio: v.string(),
}).edge('user'),
});
```
In this case, each user can have 1 profile, and each profile must have 1
associated user. This is a field edge stored on the "profiles" table as a
foreign key. The "users" table's documents do not store the edge, because the
`ref: true` option specifies that the edge a "refers" to the field on the other
end of the edge.
The syntax shown is actually a shortcut for the following declaration:
```ts
defineEntSchema({
users: defineEnt({
name: v.string(),
}).edge('profile', { to: 'profiles', ref: 'userId' }),
profiles: defineEnt({
bio: v.string(),
}).edge('user', { to: 'users', field: 'userId' }),
});
```
The available options are:
- `to` is the table storing ents on the other end of the edge. It defaults to
edge name suffixed with `s` (edge `profile` -> table `"profiles"`). You'll
want to specify it when this simple pluralization doesn't work (like edge
`category` and table `"categories"`).
- `ref` signifies that the edge is stored in a field on the other ent. It can
either be the literal `true`, or the actual field's name. You must specify the
name when you want to have another field edge between the same pair of tables.
- `field` is the name of the field that stores the foreign key. It defaults to
the edge name suffixed with `Id` (edge `user` -> field `userId`).
The edge names are used when querying the edges, but they are not stored in the
database (the field name is, as part of each document that stores its value).
#### Optional 1:1 edges
You can make the field storing the edge optional with the `optional` option.
Shortcut syntax:
```ts
defineEntSchema({
users: defineEnt({
name: v.string(),
}).edge('profile', { ref: true }),
profiles: defineEnt({
bio: v.string(),
}).edge('user', { field: 'userId', optional: true }),
});
```
You must specify the `field` name when using `optional`.
Fully specified:
```ts
defineEntSchema({
users: defineEnt({
name: v.string(),
}).edge('profile', { to: 'profiles', ref: 'userId' }),
profiles: defineEnt({
bio: v.string(),
}).edge('user', { to: 'users', field: 'userId', optional: true }),
});
```
In this example a profile can be created without a set `userId`.
#### 1:1 edges to system tables
You can connect ents to documents in system tables via 1:1 edges, see
[File Storage](/schema/files) and [Scheduled Functions](/schema/schedule) for
details.
### 1:many edges
1:many edges are very common, and map clearly to foreign keys. Take this
example, where the edge is defined via the `edge` (singular) and `edges`
(plural) method:
```ts
defineEntSchema({
users: defineEnt({
name: v.string(),
}).edges('messages', { ref: true }),
messages: defineEnt({
text: v.string(),
}).edge('user'),
});
```
<Graphic src={edgesRef} dark={edgesRefDark} alt="1:many edge pictogram" />
This is a 1:many edge because the `edges` (plural) method is used on the "users"
ent and the `ref: true` option is specified. The `ref` option declares that the
edges are stored on a field in the other table. In this example each user can
have multiple associated messages.
The syntax shown is actually a shortcut for the following declaration:
```ts
defineEntSchema({
users: defineEnt({
name: v.string(),
}).edges('messages', { to: 'messages', ref: 'userId' }),
messages: defineEnt({
text: v.string(),
}).edge('user', { to: 'users', field: 'userId' }),
});
```
The available options are:
- `to` is the table storing ents on the other end of the edge.
- for the `edges` method, it defaults to the edge name (edges `messages` ->
table `"messages"`)
- for the `edge` method, it defaults to the edge name suffixed with `s` (edge
`user` -> table `"users"`).
- You'll need to specify `to` when the defaults don't match your table names.
- `ref` signifies that the edge is stored in a field on the other ent. It can
either be the literal `true`, or the actual field's name. You must specify the
name when you want to have another field edge between the same pair of tables
(to identify the inverse edge).
- `field` is the name of the field that stores the foreign key. It defaults to
the edge name suffixed with `Id` (edge `user` -> field `userId`).
#### Optional 1:many edges
You can make the field storing the edge optional with the `optional` option.
Shortcut syntax:
```ts
defineEntSchema({
users: defineEnt({
name: v.string(),
}).edges('messages', { ref: true }),
messages: defineEnt({
text: v.string(),
}).edge('user', { field: 'userId', optional: true }),
});
```
You must specify the `field` name when using `optional`.
Fully specified:
```ts
defineEntSchema({
users: defineEnt({
name: v.string(),
}).edges('messages', { to: 'messages', ref: 'userId' }),
messages: defineEnt({
text: v.string(),
}).edge('user', { to: 'users', field: 'userId', optional: true }),
});
```
In this case a message can be created without a set `userId`.
### many:many edges
Many:many edges are always stored in a separate table, and both ends of the edge
use the `edges` (plural) method:
```ts
defineEntSchema({
messages: defineEnt({
name: v.string(),
}).edges('tags'),
tags: defineEnt({
text: v.string(),
}).edges('messages'),
});
```
<Graphic src={edges} dark={edgesDark} alt="many:many edge pictogram" />
In this case the table storing the edge is called `messages_to_tags`, based on
the tables storing each end of the edge.
The syntax shown is actually a shortcut for the following declaration:
```ts
defineEntSchema({
messages: defineEnt({
name: v.string(),
}).edges('tags', {
to: 'tags',
table: 'tags_to_messages',
field: 'tagsId',
}),
tags: defineEnt({
text: v.string(),
}).edges('messages', {
to: 'messages',
table: 'tags_to_messages',
field: 'messagesId',
}),
});
```
The available options are:
- `to` is the table storing ents on the other end of the edge. It defaults to
the edge name (edges `tags` -> table `"tags"`). You can specify it if you want
to call the edge something more specific.
- `table` is the name of the table storing the edges. This table will have two
ID fields, one for each end of the edge. You must specify the name when you
want to have multiple different edges connecting the same pair of tables.
These tables are only used by the framework under the hood, and won't appear
in your code.
- `field` is the name of the field on `table` that stores the ID of the ent on
this end of the edge. It defaults to the edge name with suffixed with `Id`
(edge `tags` -> field `tagsId`). These fields will only be used by the
framework under the hood, and won't appear in your code.
#### Asymmetrical self-directed many:many edges
Self-directed edges have the ents on both ends of the edge stored in the same
table.
```ts
defineEntSchema({
users: defineEnt({
name: v.string(),
}).edges('followers', { to: 'users', inverse: 'followees' }),
});
```
<Graphic
src={edgesSelfAsymmetric}
dark={edgesSelfAsymmetricDark}
alt="many:many edge pictogram"
/>
Self-directed edges point to the same table on which they are defined via the
`to` option. For the edge to be asymmetrical, it has to specify the `inverse`
name. In this example, if this edge is between user A and user B, B is a
"followee" of A (is being followed by A), and A is a "follower" of B.
The table storing the edges is named after the edges, and so are its fields. You
can also specify the `table`, `field` and `inverseField` options to control how
the edge is stored and to allow multiple self-directed edges.
#### Symmetrical self-directed many:many edges
Symmetrical edges also have the ents on both ends of the edge stored in the same
table, but additionally they "double-write" the edge for both directions:
```ts
defineEntSchema({
users: defineEnt({
name: v.string(),
}).edges('friends', { to: 'users' }),
});
```
<Graphic
src={edgesSelfSymmetric}
dark={edgesSelfSymmetricDark}
alt="many:many edge pictogram"
/>
By not specifying the `inverse` name, you're declaring the edge as symmetrical.
You can also specify the `table`, `field` and `inverseField` options to control
how the edge is stored and to allow multiple symmetrical self-directed edges.
Other kinds of edges are possible, but less common.
## Rules
Rules allow collocating the logic for when an ent can be created, read, updated
or deleted.
See the [Rules page](/schema/rules).
# Helper Types
These types are helpful when you want to break down your backend code and pass
IDs and ents around.
## Ent ID type
Use the built-in `Id` type:
```ts filename=convex/myFunctions.ts
import { Id } from './_generated/dataModel';
// Note that a `MutationCtx` also satisfies the `QueryCtx` interface
export function myReadHelper(ctx: QueryCtx, id: Id<'tasks'>) {
/* ... */
}
```
## Ent types & `ctx` types
```ts filename=convex/myFunctions.ts
import { Ent, EntWriter } from './shared/types';
export function myReadHelper(ctx: PublicQueryCtx, task: Ent<'tasks'>) {
/* ... */
}
export function myWriteHelper(
ctx: PublicMutationCtx,
task: EntWriter<'tasks'>
) {
/* ... */
}
```
# Cascading Deletes
Convex Ents are designed to simplify the creation of interconnected graphs of
documents in the database. Deleting ents connected through edges poses three
main challenges:
1. Propagating deletion across edges. When an ent is required on one end of an
edge, and it is deleted, the edge and potentially the ent on the other end
must be deleted as well.
> Example: Consider an app with "teams" of "users". When a team is deleted,
> its members, projects and other data belonging to the team should be
> deleted as well.
2. Handling the volume of deleted documents. It is not possible to instantly
erase a very large number of documents, from any database. Eventually there
can be too many documents to delete, especially inside a single transaction.
> Example: A team can have thousands of members and tens of thousands of
> projects. These cannot all be deleted instantly.
3. Soft deleting and retaining data before final deletion. Often the data should
not be immediately erased from the database.
> Example: A team admin can delete a team, but you want to have the ability
> to easily reinstate their data, in case the admin changes their mind, or
> the request was fradulent.
> Example: A user can leave a team and later rejoin it, reacquiring
> attribution to data that was previously connected to them.
## Default deletion behavior
Without any additional configuration, ents and their edges are deleted
immediately. We'll also refer to this as "hard" deleted.
If the edge is required, as is the case for 1:many and 1:1 edges for the
[ents storing the edge as a field](/schema#understanding-how-edges-are-stored),
the ents on the other side of the edge are deleted as well.
The following scenarios are currently supported:
- 1:1 edge between ent A and ent B, ents A store the edge.
- When ent A is deleted, only _it_ is deleted.
- When ent B is deleted, the ent A connected to it is deleted as well (which
might cause more edge and ent deletions).
- 1:many edge between ent A and ent B, ents A store the edge.
- When ent A is deleted, only _it_ is deleted.
- When ent B is deleted, ents A connected to it are deleted as well (which
might cause more edge and ent deletions).
- many:many edge between ent A and ent B.
- When ent A is deleted, the documents storing the edges to ents B are all
deleted, but ents B are _not_ deleted.
- When ent B is deleted, the documents storing the edges to ents A are all
deleted, but ents A are _not_ deleted.
### Overriding deletion direction for 1:1 edges
For 1:1 edges, you can additionally configure deletion to propagate from the ent
that stores the edge to the other ent, by setting the `deletion` option on the
edge declaration:
```ts
defineEntSchema({
users: defineEnt({
name: v.string(),
}).edge('profile', { optional: true }),
profiles: defineEnt({
bio: v.string(),
}).edge('user', { deletion: 'hard' }),
});
```
In this example, when a user is deleted, their profile is deleted as well, which
is the default behavior, but also when a profile is deleted, the user is
deleted.
## Soft deletion behavior
You can configure an ent to use the `"soft"` deletion behavior with the
`deletion` method in your schema:
```ts
defineEntSchema({
users: defineEnt({
name: v.string(),
}).deletion('soft'),
});
```
This behavior adds a `deletionTime` field to the ent. When the ent itself is
"deleted", via the `delete` method, the `deletionTime` field is set to the
current server time.
If the ent is being deleted as a result of cascading hard deletion, it is hard
deleted.
### Filtering soft deleted ents
You can include or exclude soft deleted ents from results by filtering:
```ts
const notDeletedUsers = await ctx
.table('users')
.filter((q) => q.eq(q.field('deletionTime'), undefined));
```
### Undeleting soft deleted ents
The ents can be "undeleted" by unsetting the `deletionTime` field, for example:
```ts
await ctx.table('users').getX(userId).patch({ deletionTime: undefined });
```
### Soft edge deletion
#### 1:1 and 1:many edges
By default soft deletion doesn't propagate. You can configure cascading deletes
for soft deletions for individual 1:1 and 1:many edges via the `deletion` option
on edge declarations:
```ts
defineEntSchema({
users: defineEnt({
email: v.string(),
})
.deletion('soft')
.edges('profiles', { ref: true, deletion: 'soft' }),
profiles: defineEnt({
name: v.string(),
})
.deletion('soft')
.edge('user'),
});
```
The ent on the other end of the edge has to have the `"soft"` deletion behavior.
In this example, when a user is deleted, it is soft deleted, and all its
profiles are also soft deleted immediately. When a profile itself is deleted, it
is also only soft deleted.
Soft deletion of edges happens immediately, within the same transaction.
<Aside title="What if an ent has too many 1:many edges to soft delete immediately?">
If an ent is connected to a large number of other ents, such that propagating
soft deletion to them all could fail the mutation, you should instead filter out
the soft deleted ents after traversing the edge.
</Aside>
#### many:many edges
Soft deletion doesn't affect many:many edges.
## Scheduled deletion behavior
The scheduled deletion behavior expands on the soft deletion behavior. The ent
is first immediately soft deleted. An actual hard deletion is then scheduled.
### Additional configuration
To enable scheduled ent deletion you need to add two lines of code to your
[`functions.ts` file](/setup/config):
```ts
// Add this import
import { scheduledDeleteFactory } from 'convex-ents';
// Add this export
export const scheduledDelete = scheduledDeleteFactory(entDefinitions);
```
This will expose an internal Convex mutation used by `ctx.table` when scheduling
deletions.
<Aside title="Alternatively, you can configure the mutation explicitly">
Expose the mutation somewhere in your `convex` folder, and its reference to the
factory function:
```ts filename="convex/someFileName.ts"
import { scheduledDeleteFactory } from 'convex-ents';
import { entDefinitions } from './schema';
export const myNameForScheduledDelete = scheduledDeleteFactory(entDefinitions, {
scheduledDelete: internal.someFileName.myNameForScheduledDelete,
});
```
Also pass the function's reference in `functions.ts` file, wherever you set up
your custom `mutation` and `internalMutation` function constructors:
```ts filename="convex/functions.ts" {14, 40-42, 52-54}
export const mutation = customMutation(
baseMutation,
customCtx(async (ctx) => {
return {
table: entsTableFactory(ctx, entDefinitions, {
scheduledDelete: internal.someFileName.myNameForScheduledDelete,
}),
};
})
);
export const internalMutation = customMutation(
baseInternalMutation,
customCtx(async (ctx) => {
return {
table: entsTableFactory(ctx, entDefinitions, {
scheduledDelete: internal.someFileName.myNameForScheduledDelete,
}),
};
})
);
```
</Aside>
### Defining scheduled deletion behavior
You can configure an ent to use the `"scheduled"` deletion behavior with the
`deletion` method in your schema:
```ts
defineEntSchema({
users: defineEnt({
email: v.string(),
})
.deletion('scheduled')
.edges('profiles', { ref: true }),
profiles: defineEnt({
name: v.string(),
}).edge('user'),
});
```
When the ent is deleted, it is first soft deleted.
[Soft edge deletion](#soft-edge-deletion) can apply as well. This all happens
within the same mutation.
The hard deletion is scheduled to a separate mutation/mutations. Cascading
deletes are performed first, then the ent itself is hard deleted. There is no
guarantee on how long this can take, as it depends on the number of documents
that need to be deleted to finish the cascading deletion.
The hard deletion can be delayed into the future with the `delayMs` option:
```ts
defineEntSchema({
users: defineEnt({
email: v.string(),
})
.deletion('scheduled', { delayMs: 24 * 60 * 60 * 1000 })
.edges('profiles', { ref: true }),
profiles: defineEnt({
name: v.string(),
}).edge('user'),
});
```
In this example the user ent is soft deleted first, then after 24 hours its
profiles and the user itself are hard deleted.
The delay is only applied if the ent is itself being deleted, not when it is
being deleted as a result of a cascading delete.
### Canceling scheduled deletion
You can cancel a scheduled deletion by unsetting the `deletionTime` field, for
example:
```ts
await ctx.table('users').getX(userId).patch({ deletionTime: undefined });
```
### Correctness
You should make sure that while a scheduled hard deletion is running, there are
no new ents being inserted that would also be eligible for the same cascading
deletion.
You can do this by checking for the soft deletion state of the deleted ent in
your code.
# Scheduled Functions
You can connect
[scheduled functions](https://docs.convex.dev/scheduling/scheduled-functions) to
your ents.
The basic way to do this is the same as in vanilla Convex, adding a scheduled
function ID field:
```ts
defineEntSchema({
answers: defineEnt({
question: v.string(),
actionId: v.id('_scheduled_functions'),
}),
});
```
You can then retrieve the scheduled function status and cancel it. For example,
to retrieve all answers with the status of their action:
```ts
return ctx.table('answers').map(async ({ question, actionId }) => ({
question,
status:
(await ctx.table('_scheduled_functions').get(actionId)?.state.kind) ??
'stale',
}));
```
## Using edges for connecting scheduled functions
You can simplify the code above by declaring a 1:1 edge to scheduled functions:
```ts
defineEntSchema({
answers: defineEnt({
question: v.string(),
}).edge('action', { to: '_scheduled_functions' }),
});
```
The field name is derived from the edge name (here `actionId`), you can also
specify it with the `field` option.
You can then retrieve scheduled function status via the edge:
```ts
return ctx.table('answers').map(async (answer) => ({
question: answer.question,
status: (await answer.edge('action')?.state.kind) ?? 'stale',
}));
```
### Automatic cancelation during cascading deletion
You can automatically cancel a connected scheduled function via the `deletion`
option:
```ts
defineEntSchema({
answers: defineEnt({
question: v.string(),
}).edge('action', { to: '_scheduled_functions', deletion: 'hard' }),
});
```
Only the `"hard"` value is valid, since there is no way to mark soft deletion on
a scheduled function.
In this example when an answer is deleted, its action, if pending or
in-progress, is canceled.
Note that if you use `ctx.storage.delete` to delete a file that is referenced in
other ents, Ents will not cascade that deletion. Ideally do not use
`ctx.storage.delete`, or handle any other required deletions manually.
# Rules
The ents in your database are only accessible via server-side functions, and so
you can rely on their implementation to enforce authorization rules (also known
as "row level security").
But you might have multiple functions accessing the same data, and you might be
using the different methods provided by Convex Ents to access them:
- To read: `get`, `getX`, `edge`, `edgeX`, `unique`, `uniqueX`, `first`,
`firstX`, `take`, etc.
- To write: `insert`, `insertMany`, `patch`, `replace`, `delete`
Enforcing rules about when an ent can be read, created, updated or deleted at
every callsite can be onerous and error-prone.
For this reason you can optionally define a set of "rules" implementations that
are automatically enforced by the `ctx.table` API. This is an advanced feature,
and so it requires a bit more setup.
## Setup
Before setting up rules, make sure you understand how Convex Ents are configured
via custom functions, see [Configuring Functions](/setup/config).
<Steps>
### Define your rules
Add a `rules.ts` file with the following contents:
```ts filename="convex/rules.ts" {8-16}
import { addEntRules } from 'convex-ents';
import { entDefinitions } from './schema';
import { QueryCtx } from './types';
export function getEntDefinitionsWithRules(
ctx: QueryCtx
): typeof entDefinitions {
return addEntRules(entDefinitions, {
// "secrets" is one of our tables
secrets: {
read: async (secret) => {
// Example: Only the viewer can see their secret
return ctx.viewerId === secret.userId;
},
},
});
}
// Example: Retrieve viewer ID using `ctx.auth`:
export async function getViewerId(
ctx: Omit<QueryCtx, 'table' | 'viewerId' | 'viewer' | 'viewerX'>
): Promise<Id<'users'> | null> {
const user = await ctx.auth.getUserIdentity();
if (user === null) {
return null;
}
const viewer = await ctx.skipRules
.table('users')
.get('tokenIdentifier', user.tokenIdentifier);
return viewer?._id;
}
```
The rules are defined in the second argument to `addEntRules`, which takes
`entDefinitions` from our schema, adds any rules you specify and returns
augmented `entDefinitions`.
Authorization commonly has a concept of a viewer, although this is totally up to
your use case. The `rules.ts` file is a good place for defining how to retrieve
the viewer ID.
### Apply rules
Replace your `functions.ts` file with the following code, which uses your
implementations from `rules.ts`:
```ts filename="convex/functions.ts" {15}
export const query = customQuery(
baseQuery,
customCtx(async (baseCtx) => {
return await queryCtx(baseCtx);
})
);
export const internalQuery = customQuery(
baseInternalQuery,
customCtx(async (baseCtx) => {
return await queryCtx(baseCtx);
})
);
export const mutation = customMutation(
baseMutation,
customCtx(async (baseCtx) => {
return await mutationCtx(baseCtx);
})
);
export const internalMutation = customMutation(
baseInternalMutation,
customCtx(async (baseCtx) => {
return await mutationCtx(baseCtx);
})
);
async function queryCtx(baseCtx: QueryCtx) {
const ctx = {
db: baseCtx.db as unknown as undefined,
skipRules: { table: entsTableFactory(baseCtx, entDefinitions) },
};
const entDefinitionsWithRules = getEntDefinitionsWithRules(ctx as any);
const viewerId = await getViewerId({ ...baseCtx, ...ctx });
(ctx as any).viewerId = viewerId;
const table = entsTableFactory(baseCtx, entDefinitionsWithRules);
(ctx as any).table = table;
// Example: add `viewer` and `viewerX` helpers to `ctx`:
const viewer = async () =>
viewerId !== null ? await table('users').get(viewerId) : null;
(ctx as any).viewer = viewer;
const viewerX = async () => {
const ent = await viewer();
if (ent === null) {
throw new Error('Expected authenticated viewer');
}
return ent;
};
(ctx as any).viewerX = viewerX;
return { ...ctx, table, viewer, viewerX, viewerId };
}
async function mutationCtx(baseCtx: MutationCtx) {
const ctx = {
db: baseCtx.db as unknown as undefined,
skipRules: { table: entsTableFactory(baseCtx, entDefinitions) },
};
const entDefinitionsWithRules = getEntDefinitionsWithRules(ctx as any);
const viewerId = await getViewerId({ ...baseCtx, ...ctx });
(ctx as any).viewerId = viewerId;
const table = entsTableFactory(baseCtx, entDefinitionsWithRules);
(ctx as any).table = table;
// Example: add `viewer` and `viewerX` helpers to `ctx`:
const viewer = async () =>
viewerId !== null ? await table('users').get(viewerId) : null;
(ctx as any).viewer = viewer;
const viewerX = async () => {
const ent = await viewer();
if (ent === null) {
throw new Error('Expected authenticated viewer');
}
return ent;
};
(ctx as any).viewerX = viewerX;
return { ...ctx, table, viewer, viewerX, viewerId };
}
```
In this example we pulled out the logic for defining query and mutation `ctx`
into helper functions, so we don't have to duplicate the code between public and
internal constructors (but you can inline this code if you actually need
different setup for each).
The logic for setting up the query and mutation `ctx`s is the same, but we
define them separately to get the right types inferred by TypeScript.
<Aside title="Here's an annotated version of the code with explanation of each step:">
```ts
// The `ctx` object is mutated as we build it out.
// It starts off with `ctx.skipRules.table`, a version `ctx.table`
// that doesn't use the rules we defined in `rules.ts`:
const ctx = {
db: undefined,
skipRules: { table: entsTableFactory(baseCtx, entDefinitions) },
};
// We bind our rule implementations to this `ctx` object:
const entDefinitionsWithRules = getEntDefinitionsWithRules(ctx as any);
// We retrieve the viewer ID, without using rules (as our rules
// depend on having the viewer loaded), and add it to `ctx`:
const viewerId = await getViewerId({ ...baseCtx, ...ctx });
(ctx as any).viewerId = viewerId;
// We get a `ctx.table` using rules and add it to `ctx`:
const table = entsTableFactory(baseCtx, entDefinitionsWithRules);
(ctx as any).table = table;
// As an example we define helpers that allow retrieving
// the viewer as an ent. These have to be functions, to allow
// our rule implementations to use them as well.
// Anything that we want our rule implementations to have
// access to has to be added to the `ctx`.
const viewer = async () =>
viewerId !== null ? await table('users').get(viewerId) : null;
(ctx as any).viewer = viewer;
const viewerX = async () => {
const ent = await viewer();
if (ent === null) {
throw new Error('Expected authenticated viewer');
}
return ent;
};
(ctx as any).viewerX = viewerX;
// Finally we again list everything we want our
// functions to have access to. We have to do this
// for TypeScript to correctly infer the `ctx` type.
return { ...ctx, table, viewer, viewerX, viewerId };
```
</Aside>
</Steps>
## Read rules
For each table storing ents you can define a `read` rule implementation. The
implementation is given the ent that is being retrieved, and should return a
`boolean` of whether the ent is readable. This code runs before ents are
returned by `ctx.table`:
- If the retrieval method can return `null`, and the rule returns `false`, then
`null` is returned. Examples: `get`, `first`, `unique` etc.
- If the retrieval method throws when the ent does not exist, it will also throw
when the ent cannot be read. Examples: `getX`, `firstX`, `uniqueX`
- If the retrieval method returns a list of ents, then any ents that cannot be
read will be filtered out.
- except for `getManyX`, which will throw an `Error`
### Understanding read rules performance
A read rule is essentially a filter, performed in the Convex runtime running
your query or mutation. This means that adding a read rule to a table
fundamentally changes the way methods like `first`, `unique` and `take` are
implemented. These methods need to paginate through the underlying table (or
index range), on top of the scanning that is performed by the built-in `db` API.
You should be mindful of how many ents your read rules might filter out for a
given query.
<Aside title="How exactly do `first`, `unique` and `take` paginate?">
The methods first try to load the requested number of ents (`1`, `2` or `n`
respectively). If the ents loaded first get filtered out, the method loads 2
times more documents, performs the filtering, and if again there aren't enough
ents, it doubles the number again, and so on, for a maximum of 64 ents being
evaluated at a time.
</Aside>
### Common read rule patterns
#### Delegating to another ent
Example: _When the user connected to the profile can be read, the profile can be
read_:
```ts
return addEntRules(entDefinitions, {
profiles: {
read: async (profile) => {
return (await profile.edge('user')) !== null;
},
},
});
```
Watch out for infinite loops between read rules, and break them up using
`ctx.skipRules`.
#### Testing for an edge
Example: _A user ent can be read when it is the viewer or when there is a
`"friends"` edge between the viewer and the user_:
```ts
return addEntRules(entDefinitions, {
users: {
read: async (user) => {
return (
ctx.viewerId !== null &&
(ctx.viewerId === user._id ||
(await user.edge('friends').has(ctx.viewerId)))
);
},
},
});
```
## Write rules
Write rules determine whether ents can be created, updated or deleted. They can
be specified using the `write` key:
```ts
return addEntRules(entDefinitions, {
// "secrets" is one of our tables
secrets: {
// Note: The read rule is always checked for existing ents
// for any updates or deletions
read: async (secret) => {
return ctx.viewerId === secret.userId;
},
write: async ({ operation, ent: secret, value }) => {
if (operation === 'delete') {
// Example: No one is allowed to delete secrets
return false;
}
if (operation === 'create') {
// Example: Only the viewer can create secrets
return ctx.viewerId === value.ownerId;
}
// Example: secret's user edge is immutable
return value.ownerId === undefined || value.ownerId === secret.ownerId;
},
},
});
```
If defined, the `read` rule is always checked first before ents are updated or
deleted.
The `write` rule is given an object with
- `operation`, one of `"create"`, `"update"` or `"delete"`
- `ent`, the existing ent if this is an update or delete
- `value`, the value provided to `.insert()`, `.replace()` or `.patch()`.
The methods `insert`, `insertMany`, `patch`, `replace` and `delete` throw an
`Error` if the `write` rule returns `false`.
## Ignoring rules
Sometimes you might want to read from or write to the database without abiding
by the rules you defined. Perhaps you are running with `ctx` that isn't
authenticated, or your code needs to perform some operation on behalf of a user
who isn't the current viewer.
For this purpose the [Setup](#setup) section above defines
`ctx.skipRules.table`, which is a version of `ctx.table` that can read and write
to the database without checking the rules.
**Remember that methods called on ents retrieved using `ctx.skipRules.table`
also ignore rules!** For this reason it's best to return plain documents or IDs
when using `ctx.skipRules.table`:
```ts
// Avoid!!!
return await ctx.skipRules.table('foos').get(someId);
// Return an ID instead:
return (await ctx.skipRules.table('foos').get(someId))._id;
```
It is preferable to still use Convex Ents over using the built-in `ctx.db` API
for this purpose, to maintain invariants around edges and unique fields. See
[Exposing built-in `db`](/setup/config#exposing-built-in-db).
import { Aside } from "../components/Aside.tsx";
# Reading Ents from the Database
Convex Ents provide a `ctx.table` method which replaces the built-in `ctx.db`
object in Convex [queries](https://docs.convex.dev/functions/query-functions).
The result of calling the method is a custom object which acts as a lazy
`Promise`. If you `await` it, you will get a list of results. But you can
instead call another method on it which will return a different lazy `Promise`,
and so on. This enables a powerful and efficient fluent API.
## Security
The Convex Ents API was designed to add an additional level of security to your
backend code. The built-in `ctx.db` object allows reading data from and writing
data to any table. Therefore in vanilla Convex you must correctly use both
[argument validation](https://docs.convex.dev/functions/args-validation) and
[strict schema validation](https://docs.convex.dev/database/schemas) to avoid
exposing a function which could be misused by an attacker to act on a table it
was not designed to act on.
All Convex Ents APIs require specifying a table up-front. If you need to read
data from an arbitrary table, you must the
[use the built-in `ctx.db`](/setup/config) object.
## Reading a single ent by ID
```ts
const task = await ctx.table('tasks').get(taskId);
```
<Aside title="This is equivalent to the built-in:">
```ts
const id = ctx.db.normalize('tasks', taskId);
if (id === null) {
return null;
}
const task = await ctx.db.get(id);
```
with the addition of checking that `taskId` belongs to `"tasks"`.
</Aside>
## Reading a single ent by indexed field
```ts
const task = await ctx.table('users').get('email', '[email protected]');
```
<Aside title="This is equivalent to the built-in:">
```ts
const task = await ctx.db
.query('users')
.withIndex('email', (q) => q.eq('email', '[email protected]'))
.unique();
```
</Aside>
## Reading a single ent via a compound index
```ts
const task = await ctx.table('users').get('nameAndRank', 'Steve', 10);
```
<Aside title="This is equivalent to the built-in:">
```ts
const task = await ctx.db
.query('users')
.withIndex('nameAndRank', (q) => q.eq('name', 'Steve').eq('rank', 10))
.unique();
```
</Aside>
## Reading a single ent or throwing
The `getX` method (pronounced "get or throw") throws an `Error` if the read
produced no ents:
```ts
const task = await ctx.table('tasks').getX(taskId);
```
```ts
const task = await ctx.table('users').getX('email', '[email protected]');
```
## Reading multiple ents by IDs
Retrieve a list of ents or nulls:
```ts
const tasks = await ctx.table('tasks').getMany([taskId1, taskId2]);
```
<Aside title="This is equivalent to the built-in:">
```ts
const task = await Promise.all([taskId1, taskId2].map(ctx.db.get));
```
</Aside>
## Reading multiple ents by IDs or throwing
Retrieve a list of ents or throw an `Error` if any of the IDs didn't map to an
existing ent:
```ts
const tasks = await ctx.table('tasks').getManyX([taskId1, taskId2]);
```
Also throws if any of the ents fail a [read rule](/schema/rules).
## Listing all ents
To list all ents backed by a single table, simply `await` the `ctx.table` call:
```ts
const tasks = await ctx.table('users');
```
<Aside title="This is equivalent to the built-in:">
```ts
const tasks = await ctx.db.query('users').collect();
```
</Aside>
## Listing ents filtered by index
To list ents from a table and efficiently filter them by an index, pass the
index name and filter callback to `ctx.table`:
```ts
const posts = await ctx.table('posts', 'numLikes', (q) =>
q.gt('numLikes', 100)
);
```
<Aside title="This is equivalent to the built-in:">
```ts
const posts = await ctx.db
.query('posts')
.withIndex('numLikes', (q) => q.gt('numLikes', 100))
.collect();
```
</Aside>
## Searching ents via text search
To use [text search](https://docs.convex.dev/search) call the `search` method:
```ts
const awesomeVideoPosts = await ctx
.table('posts')
.search('text', (q) => q.search('text', 'awesome').eq('type', 'video'));
```
<Aside title="This is equivalent to the built-in:">
```ts
const awesomeVideoPosts = await ctx.db
.query('posts')
.withSearchIndex('text', (q) =>
q.search('text', 'awesome').eq('type', 'video')
)
.collect();
```
</Aside>
## Filtering
Use the `filter` method, which works the same as the
[built-in `filter` method](https://docs.convex.dev/database/reading-data#filtering):
```ts
const posts = await ctx
.table('posts')
.filter((q) => q.gt(q.field('numLikes'), 100));
```
<Aside title="This is equivalent to the built-in:">
```ts
const posts = await ctx.db
.query('posts')
.filter((q) => q.gt(q.field('numLikes'), 100))
.collect();
```
</Aside>
## Ordering
Use the `order` method. The default sort is by `_creationTime` in ascending
order (from oldest created to newest created):
```ts
const posts = await ctx.table('posts').order('desc');
```
<Aside title="This is equivalent to the built-in:">
```ts
const posts = await ctx.db.query('posts').order('desc').collect();
```
</Aside>
## Ordering by index
The `order` method takes an index name as a shortcut to sorting by a given
index:
```ts
const posts = await ctx.table('posts').order('desc', 'numLikes');
```
<Aside title="This is equivalent to the built-in:">
```ts
const posts = await ctx
.table('posts')
.withIndex('numLikes')
.order('desc')
.collect();
```
</Aside>
## Limiting results
### Taking first `n` ents
```ts
const users = await ctx.table('users').take(5);
```
### Loading a page of ents
The `paginate` method returns a page of ents. It takes the same argument object
as the [the built-in pagination](https://docs.convex.dev/database/pagination)
method.
```ts
const result = await ctx.table('posts').paginate(paginationOpts);
```
### Finding the first ent
Useful chained, either to a `ctx.table` call using an index, or after `filter`,
`order`, `edge`.
```ts
const latestUser = await ctx.table('users').order('desc').first();
```
### Finding the first ent or throwing
The `firstX` method (pronounced "first or throw") throws an `Error` if the read
produced no ents:
```ts
const latestUser = await ctx.table('users').order('desc').firstX();
```
### Finding a unique ent
Throws if the listing produced more than 1 result, returns `null` if there were
no results:
```ts
const counter = await ctx.table('counters').unique();
```
### Finding a unique ent or throwing
The `uniqueX` method (pronounced "unique or throw") throws if the listing
produced more or less than 1 result:
```ts
const counter = await ctx.table('counters').uniqueX();
```
## Accessing system tables
[System tables](https://docs.convex.dev/database/advanced/system-tables) can be
read with the `ctx.table.system` method:
```ts
const filesMetadata = await ctx.table.system('_storage');
const scheduledFunctions = await ctx.table.system('_scheduled_functions');
```
All the previously listed methods are also supported by `ctx.table.system`.
You might also access documents in system tables through edges, see
[File Storage](/schema/files) and [Scheduled Functions](/schema/schedule) for
details.
## Traversing edges
Convex Ents allow easily traversing edges declared in the
[schema](/schema#edges) with the `edge` method.
### Traversing 1:1 edge
For 1:1 edges, the `edge` method returns:
- The ent required on the other end of the edge (ex: given a `profile`, load the
`user`)
- The ent or `null` on the optional end of the edge (ex: given a `user`, load
their `profile`)
```ts
const user = await ctx.table('profiles').getX(profileId).edge('user');
const profileOrNull = await ctx.table('users').getX(userId).edge('profile');
```
<Aside title="This is equivalent to the built-in:">
```ts
const user = await ctx.db
.query('users')
.withIndex('profileId', (q) => q.eq('profileId', profileId))
.unique();
```
</Aside>
The `edgeX` method (pronounced "edge or throw") throws if the edge does not
exist:
```ts
const profile = await ctx.table('users').getX(userId).edgeX('profile');
```
### Traversing 1:many edges
For 1:many edges, the `edge` method returns:
- The ent required on the other end of the edge (ex: given a `message`, load the
`user`)
- A list of ents on the end of the multiple edges (ex: given a `user`, load all
the `messages`)
```ts
const user = await ctx.table('message').getX(messageId).edge('user');
const messages = await ctx.table('users').getX(userId).edge('messages');
```
<Aside title="This is equivalent to the built-in:">
```ts
const messages = await ctx.db
.query('messages')
.withIndex('userId', (q) => q.eq('userId', userId))
.collect();
```
</Aside>
In the case where a list is returned, [filtering](#filtering),
[ordering](#ordering) and [limiting results](#limiting-results) applies and can
be used.
### Traversing many:many edges
For many:many edges, the `edge` method returns a list of ents on the other end
of the multiple edges:
```ts
const tags = await ctx.table('messages').getX(messageId).edge('tags');
const messages = await ctx.table('tags').getX(tagId).edge('messages');
```
<Aside title="This is equivalent to the built-in:">
```ts
const tags = await Promise.all(
(
await ctx.db
.query('messages_to_tags')
.withIndex('messagesId', (q) => q.eq('messagesId', messageId))
.collect()
).map((edge) => ctx.db.get(edge.tagsId))
);
```
</Aside>
The results are ordered by `_creationTime` of the edge, from oldest created to
newest created. The order can be changed with the [`order` method](#ordering)
and [the results can be limited](#limiting-results).
### Testing many:many edge presence
You can efficiently test whether two ents are connected by a many:many edge
using the `has` method:
```ts
const hasTag = ctx.table('messages').getX(messageId).edge('tags').has(tagId);
```
<Aside title="This is equivalent to the built-in:">
```ts
const hasTag =
(await ctx.db
.query('messages_to_tags')
.withIndex('messagesId_tagsId', (q) =>
q.eq('messagesId', message._id).eq('tagsId', tagId)
)
.first()) !== null;
```
</Aside>
## Retrieving related ents
Sometimes you want to retrieve both the ents from one table and the related ents
from another table. Other database systems refer to these as a _joins_ or
_nested reads_.
### Traversing edges from individual retrieved ents
All the methods for reading ents return the underlying document(s), enriched
with the `edge` and `edgeX` methods. This allows traversing edges after loading
the ent from the database:
```ts
const user = await ctx.table('users').firstX();
const profile = user.edgeX('profile');
```
### Mapping ents to include edges
The `map` method can be used to perform arbitrary transformation for each
returned ent. It can be used to add related ents via the `edge` and `edgeX`
methods:
```ts
const usersWithMessages = await ctx.table('users').map(async (user) => {
return {
name: user.name,
messages: await user.edge('messages').take(5),
};
});
```
```ts
const usersWithProfileAndMessages = await ctx.table("users").map(async (user) => {
const [messages, profile] = await Promise.all([
profile: user.edgeX("profile"),
user.edge("messages").take(5),
])
return {
name: user.name,
profile,
messages,
};
});
```
<Aside title="This code is equivalent to this code using only built-in Convex and JavaScript
functionality:">
```ts
const usersWithMessagesAndProfile = await Promise.all(
(await ctx.db.query('users').collect()).map(async (user) => {
const [posts, profile] = Promise.all([
ctx.db
.query('messages')
.withIndex('userId', (q) => q.eq('userId', user._id))
.collect(),
(async () => {
const profile = await ctx.db
.query('profiles')
.withIndex('userId', (q) => q.eq('userId', user._id))
.unique();
if (profile === null) {
throw new Error(
`Edge "profile" does not exist for document with ID "${user._id}"`
);
}
return profile;
})(),
]);
return { name: user.name, posts, profile };
})
);
```
</Aside>
As shown in this example, `map` can be used to transform the results by
selecting or excluding the returned fields, limiting the related ents, or
requiring that related ents exist.
The `map` method can beused to load ents by traversing more than one edge:
```ts
const usersWithMessagesAndTags = await ctx.table('users').map(async (user) => ({
...user,
messages: await user.edge('messages').map(async (message) => ({
text: message.text,
tags: await message.edge('tags'),
})),
}));
```
## Returning documents from functions
Consider the following query:
```ts filename="convex/messages.ts"
import { query } from './functions';
export const list = query({
args: {},
handler: async (ctx) => {
return await ctx.table('messages');
},
});
```
It returns all ents stored in the `messages` table. Ents include methods which
are not preserved when they are returned to the client. While this works fine at
runtime, TypeScript will think that the methods are still available even on the
client.
Note that it is generally not a good idea to return full documents directly to
clients. If you add a new field to an ent, you might not want that field to be
sent to clients. For this reason, and for backwards compatibility in general,
it's a good idea to pick specifically which fields to send:
```ts filename="convex/messages.ts"
import { query } from './functions';
export const list = query({
args: {},
handler: async (ctx) => {
return await ctx.table('messages').map((message) => ({
_id: message._id,
_creationTime: message._creationTime,
text: message.text,
userId: message.userId,
}));
},
});
```
That said, you can easily retrieve raw documents, instead of ents with methods,
from the database using the `docs` and `doc` methods:
```ts filename="convex/messages.ts"
import { query } from './functions';
export const list = query({
args: {},
handler: async (ctx) => {
return await ctx.table('messages').docs();
},
});
```
```ts filename="convex/users.ts"
import { query } from './functions';
export const list = query({
args: {},
handler: async (ctx) => {
const usersWithMessagesAndProfile = await ctx
.table('users')
.map(async (user) => ({
...user,
profile: await user.edgeX('profile').doc(),
}));
},
});
```
You can also call the `doc` method on a retrieved ent, which returns the same
object, but is typed as a plain Convex document.
import { Aside } from "../components/Aside.tsx";
# Writing Ents to the Database
Just like for [reading](/read) ents from the database, for writing Convex Ents
provide a `ctx.table` method which replaces the built-in `ctx.db` object in
[Convex mutations](https://docs.convex.dev/functions/mutation-functions).
## Security
The same [added level of security](/read#security) applies to the writing ents
as it does to reading them.
## Inserting a new ent
You can insert new ents into the database with the `insert` method chained to
the result of calling `ctx.table`:
```ts
const taskId = await ctx.table('tasks').insert({ text: 'Win at life' });
```
You can retrieve the just created ent with the `get` method:
```ts
const task = await ctx.table('tasks').insert({ text: 'Win at life' }).get();
```
<Aside title="This is equivalent to the built-in:">
```ts
const taskId = await ctx.db.insert('tasks', { text: 'Win at life' });
const task = (await ctx.db.get(taskId))!;
```
</Aside>
## Inserting many new ents
```ts
const taskIds = await ctx
.table('tasks')
.insertMany({ text: 'Buy socks' }, { text: 'Buy socks' });
```
## Updating existing ents
To update an existing ent, call the `patch` or `replace` method on a
[lazy `Promise`](/read) of an ent, or on an already retrieved ent:
```ts
await ctx.table('tasks').getX(taskId).patch({ text: 'Changed text' });
await ctx.table('tasks').getX(taskId).replace({ text: 'Changed text' });
```
```ts
const task = await ctx.table('tasks').getX(taskId);
await task.patch({ text: 'Changed text' });
await task.replace({ text: 'Changed text' });
```
See the
[docs for the built-in `patch` and `replace` methods](https://docs.convex.dev/database/writing-data#updating-existing-documents)
for the difference between them.
## Deleting ents
To delete an ent, call the `delete` method on a [lazy `Promise`](/read) of an
ent, or on an already retrieved ent:
```ts
await ctx.table('tasks').getX(taskId).delete();
```
```ts
const task = await ctx.table('tasks').getX(taskId);
await task.delete();
```
### Cascading deletes
See the [Cascading Deletes](/schema/deletes) page for how to configure how
deleting an ent affects its edges and other ents connected to it.
## Writing edges
Edges can be created together with ents using the `insert` and `insertMany`
methods, or they can be created and deleted for two existing ents using the
`replace` and `patch` methods.
### Writing 1:1 and 1:many edges
A 1:1 or 1:many edge can be created by specifying the ID of the other ent on
[the ent which stores the edge](/schema#understanding-how-edges-are-stored),
either when inserting:
```ts
// First we need a user, which can have an optional profile edge
const userId = await ctx.table('users').insert({ name: 'Alice' });
// Now we can create a profile with the 1:1 edge to the user
const profileId = await ctx
.table('profiles')
.insert({ bio: 'In Wonderland', userId });
```
or when updating:
```ts
const profileId = await ctx.table('profiles').getX(profileId).patch({ userId });
```
<Aside title="This is equivalent to the built-in:">
```ts
const posts = await ctx.db.patch(profileId, { userId });
```
with the addition of checking that `profileId` belongs to `"profiles"`.
</Aside>
### Writing many:many edges
Many:many edges can be created by listing the IDs of the other ents when
inserting ents on either side of the edge:
```ts
// First we need a tag, which can have many:many edge to messages
const tagId = await ctx.table('tags').insert({ name: 'Blue' });
// Now we can create a message with a many:many edge to the tag
const messageId = await ctx
.table('messages')
.insert({ text: 'Hello world', tags: [tagId] });
```
But we could have equally created a message first, and then created a tag with a
list of message IDs.
The `replace` method can be used to create and delete many:many edges:
```ts
await ctx
.table('messages')
.getX(messageId)
.replace({ text: 'Changed message', tags: [tagID, otherTagID] });
```
If a list is specified, the edges that need to be created are created, and all
other existing edges are deleted. If the edge name is ommited entirely, the
edges are left unchanged:
```ts
await ctx
.table('messages')
.getX(messageId)
.replace({ text: 'Changed message' /* no `tags:`, so tags don't change */ });
```
The `patch` method on the other hand expects a description of the changes that
should be made, a list of IDs to `add` and `remove` edges for:
```ts
const message = await ctx.table('messages').getX(messageId);
await message.patch({ tags: { add: [tagID] } });
await message.patch({
tags: { add: [tagID, otherTagID], remove: [tagToDeleteID] },
});
```
Any edges in the `add` list that didn't exist are created, and any edges in the
`remove` list that did exist are deleted. Edges to ents with ID not listed in
either list are not affected by `patch`.
## Updating ents connected by edges
The `patch`, `replace` and `delete` methods can be chained after `edge` calls to
update the ent on the other end of an edge:
```ts
await ctx
.table('users')
.getX(userId)
.edgeX('profile')
.patch({ bio: "I'm the first user" });
```
<Aside title="This is equivalent to the built-in:">
```ts
const profile = await ctx.db
.query('profiles')
.withIndex('userId', (q) => q.eq('userId', userId))
.unique();
if (profile === null) {
throw new Error(
`Edge "profile" does not exist for document wit ID "${userId}"`
);
}
await ctx.db.patch(profile._id, { bio: "I'm the first user" });
```
</Aside>
<Aside
title={
<>
<b>Limitation:</b>&nbsp;<code>edge</code>&nbsp;called on a loaded ent
</>
}
>
The following code does not typecheck [currently](https://github.com/xixixao/convex-ents/issues/8):
```ts
const user = await ctx.table('users').getX(userId);
await user.edgeX('profile').patch({ bio: "I'm the first user" });
```
You can either disable typechecking via `// @ts-expect-error` or preferably
start with `ctx.table`:
```ts
const user = ... // loaded or passed via `map`
await ctx
.table("users")
.getX(user._id).edgeX("profile").patch({ bio: "I'm the first user" });
```
</Aside>
---
description: Query optimization patterns for scaling Convex applications
globs: **/*.ts,**/*.tsx
alwaysApply: false
---
# Convex Query Optimization
This document covers optimization patterns for Convex queries at scale. Don't prematurely optimize - these patterns are for when you have thousands of documents.
## 1. Use Indexes Instead of Scanning
**Edit:** `convex/schema.ts` for indexes, `convex/*.ts` for queries
### Problem: Full table scans
```typescript
// ❌ WRONG: Scans every document looking for a match
const members = await ctx
.table('members')
.filter((q) => q.eq(q.field('teamId'), args.teamId))
.collect();
// ✅ CORRECT: Uses index to jump to matching documents
const members = await ctx
.table('members')
.query()
.withIndex('teamId', (q) => q.eq('teamId', args.teamId))
.collect();
```
### Schema with indexes
````typescript
// convex/schema.ts
import { defineEnt, defineEntSchema } from 'convex-ents';
import { v } from 'convex/values';
const schema = defineEntSchema({
members: defineEnt({
teamId: v.id('teams'),
status: v.union(v.literal('invited'), v.literal('active')),
// ...
})
.index('teamId', ['teamId'])
.index('teamId_status', ['teamId', 'status']), // Multi-field index
});
export default schema;
### Multi-field indexes for complex queries
```typescript
// Use multi-field index when filtering by multiple fields
const activeMembers = await ctx
.table('members')
.query()
.withIndex('teamId_status', (q) =>
q.eq('teamId', args.teamId).eq('status', 'active')
)
.collect();
````
### Codebase Audit: Search for `.filter(q =>`
Look at every `.filter()` call:
- Is the table always small? (< 1000 docs)
- Are you using an index first?
- Search for `q.eq(q.field(` - these should use indexes
**Rule:** If you see `.filter(q => q.eq(q.field("fieldName"), value))`, create an index on `fieldName`
## 2. Limit Documents with Pagination
**CRITICAL:** NEVER use `.collect()` without knowing the table size. Always use `ctx.table()` instead of `ctx.db`.
### Problem: Fetching unlimited documents
```typescript
// ❌ WRONG: Will break with thousands of posts
return await ctx.table('posts').query().order('desc').collect();
// ✅ OPTIMAL: Aggregates for counting with millions of documents (O(log n))
const count = await aggregatePosts.count(ctx, {
namespace: categoryId,
bounds: {} as any,
});
// ✅ GOOD: Pagination for user-facing lists
return await ctx.table('posts').query().order('desc').paginate(args.paginationOpts);
// ✅ ACCEPTABLE: Limits to 50 documents (ONLY for small bounded datasets)
return await ctx.table('posts').query().order('desc').take(50);
```
### When to use each pattern
**🚨 CRITICAL FOR SCALE: Aggregates FIRST for counting operations**
**Aggregates** - PRIMARY solution for counting with millions of documents (O(log n))
```typescript
// ✅ OPTIMAL: O(log n) count scales to millions of documents
const count = await aggregateMessages.count(ctx, {
namespace: channelId,
bounds: {} as any,
});
```
### Aggregates vs Denormalization for Small Lookups
**IMPORTANT**: Use aggregates even for ≤100 page size lookups. Here's why:
**Aggregates are preferred because:**
- **Flexibility with bounds**: Can query ranges, prefixes, and complex conditions
- **No schema changes**: Add new aggregates without modifying tables
- **Easier maintenance**: Triggers handle everything automatically
- **Still fast**: 100 O(log n) operations are very efficient
- **Dynamic filtering**: Supports complex queries without pre-computing every combination
- Only denormalize when aggregate can't be used
### When to Keep Separate Tables vs Using Aggregates
**Keep a separate table (like `characterActivity`) when:**
- **Domain relationship exists**: The table represents a meaningful business relationship
- **Additional metadata needed**: May add fields like `interaction_count`, `last_action_type`
- **Query patterns require it**: Need to query by multiple dimensions (userId + characterId)
- **Normalized design**: Avoids duplicating data across multiple aggregates
**Use aggregates when:**
- **Pure statistics**: Just counting or summing values
- **No domain meaning**: It's purely a performance optimization
- **Single-purpose**: Only used for one type of query
**Example: Why `characterActivity` is a good separate table:**
```typescript
// ✅ GOOD: Separate table for domain relationship
characterActivity: defineTable({
userId: v.id('users'),
characterId: v.id('characters'),
lastMessageAt: v.number(),
// Could extend with:
// interactionCount: v.number(),
// lastActionType: v.string(), // 'chat', 'star', 'view'
// favoriteQuotes: v.array(v.string()),
})
.index('userId', ['userId'])
.index('userId_characterId', ['userId', 'characterId']);
// This represents "user's history with character" - a real domain concept
// Not just a cache or performance optimization
```
**`.paginate()`** - User-facing lists with "load more" functionality
```typescript
// Use with createPublicPaginatedQuery() or createAuthPaginatedQuery()
export const listPosts = createPublicPaginatedQuery()({
handler: async (ctx, args) => {
return await ctx.table('posts').query().order('desc').paginate(args.paginationOpts);
},
});
```
**`.take(n)`** - ONLY for small bounded datasets where you don't need total count
```typescript
// Show "25+ messages" in UI (ONLY for bounded channels with enforced limits)
const recentMessages = await ctx
.table('messages')
.query()
.withIndex('channelId', (q) => q.eq('channelId', channelId))
.order('desc')
.take(25);
```
**`.first()` / `.unique()`** - Single document
```typescript
const latestPost = await ctx.table('posts').query().order('desc').first();
const user = await ctx.table('users').get('email', email); // Unique lookup by indexed field
const singleDoc = await ctx.table('counters').query().unique(); // Throws if multiple matches
```
### Enforce limits at insert time
Instead of paginating everything, consider:
- Max 100 teams per user
- Max 10 email addresses per account
- Max 100 items per cart
```typescript
// ✅ OPTIMAL: Use aggregates for counting at scale (O(log n))
const teamCount = await aggregateTeamMembers.count(ctx, {
namespace: userId,
bounds: {} as any,
});
if (teamCount >= 100) {
throw new ConvexError('Maximum 100 teams per user');
}
// ❌ AVOID: Fetching all to count (O(n) operation - breaks with millions)
const teamMembers = await ctx
.table('teamMembers')
.query()
.withIndex('userId', (q) => q.eq('userId', userId))
.collect();
if (teamMembers.length >= 100) {
throw new ConvexError('Maximum 100 teams per user');
}
```
### Codebase Audit: Search for `.collect()`
Every `.collect()` call must either:
1. Use an index to limit documents
2. Be on a table with enforced size limits
3. Have a comment explaining why unlimited is safe
4. **PREFERRED**: Use aggregates for counting operations (O(log n) vs O(n))
**🚨 CRITICAL Rules for Scale:**
- **Counting (PRIMARY)**: Use aggregates from [convex-aggregate.mdc](mdc:.cursor/rules/convex-aggregate.mdc) for O(log n) performance with millions of documents
- **User-facing lists**: Use `.paginate()` for "load more" functionality
- **Small bounded datasets**: Use `.take(n)` ONLY when you don't need total count and table has enforced size limits
- **Never**: Use `.collect()` or `.collect().length` on unbounded tables - will break with millions of documents
## 3. Optimize for Query Caching
Convex automatically caches queries and invalidates when referenced documents change. Optimize by isolating frequently-changing data.
### Problem: Frequent cache invalidation
```typescript
// ❌ WRONG: User document changes every 10 seconds
// convex/schema.ts
users: defineEnt({
name: v.string(),
profilePicUrl: v.string(),
lastSeen: v.number(), // Updates every heartbeat
});
// Every query reading users gets invalidated on heartbeat
const profile = await ctx.table('users').get(userId); // Invalidated every 10s!
```
### Solution: Isolate frequently-changing data
```typescript
// ✅ CORRECT: Separate frequently-changing data
// convex/schema.ts
users: defineEnt({
name: v.string(),
profilePicUrl: v.string(),
}).edge('heartbeat', { ref: true }), // 1:1 edge to heartbeat
heartbeats: defineEnt({
lastSeen: v.number(),
}).edge('user'),
// Profile query not invalidated by heartbeats
const profile = await ctx.table('users').get(userId);
// Only queries that need online status read heartbeat
const heartbeat = profile && await profile.edge('heartbeat');
const isOnline = heartbeat && (Date.now() - heartbeat.lastSeen < 30000);
```
### Codebase Audit: Search for `.patch()` and `.replace()`
Look for frequently updated fields:
- Timestamps (lastSeen, lastActive)
- Counters (viewCount, messageCount)
- Status fields that change often
If these are in widely-referenced documents, move to separate tables.
## Performance Best Practices
### Index Guidelines
- **Use indexes for equality filters** - Replace `.filter()` with `.withIndex()`
- **Multi-field indexes don't add overhead** - Prefer one `["teamId", "status"]` over two separate indexes
- **Index limit** - Too many indexes slow inserts (16 indexes ≈ 17x insert cost)
### Query Limits
- **Read limit** - Convex limits documents read per transaction
- **Default take** - `.take()` defaults to 100 if not specified
- **Pagination** - Use for unbounded data sets
### Document Size
- **Arrays** - Keep to 10 elements max
- **Nested objects** - Avoid deep nesting
- **Large data** - Use separate tables with references
## Common Patterns
```typescript
// ✅ OPTIMAL: Use aggregates for counting at scale
const memberCount = await aggregateMembers.count(ctx, {
namespace: teamId,
bounds: {} as any,
});
// ✅ GOOD: Paginated list with filters
export const listPosts = createAuthPaginatedQuery()({
args: { status: z.optional(z.string()) },
handler: async (ctx, args) => {
if (args.status) {
return await ctx
.table('posts')
.query()
.withIndex('status', (q) => q.eq('status', args.status))
.order('desc')
.paginate(args.paginationOpts);
}
return await ctx.table('posts').query().order('desc').paginate(args.paginationOpts);
},
});
// ✅ ACCEPTABLE: Small bounded collection with enforced limits
const items = await ctx
.table('cartItems')
.query()
.withIndex('cartId', (q) => q.eq('cartId', cartId))
.collect();
// Safe: enforced max 100 at insert
// ❌ AVOID: Fetching all members to count or process
const members = await ctx
.table('members')
.query()
.withIndex('teamId', (q) => q.eq('teamId', teamId))
.collect();
// Dangerous: could be millions - use pagination instead
```
## 4. Full Text Search Optimization
**Edit:** `convex/schema.ts` for search indexes, `convex/*.ts` for queries
### When to Use Search Indexes vs Regular Indexes
**Use Regular Indexes** when:
- Exact matching on fields (`status === 'active'`)
- Range queries on numbers/dates
- Sorting by specific fields
- You need custom ordering
**Use Search Indexes** when:
- Text search within string fields
- Typeahead/autocomplete features
- Finding keywords in content
- Relevance-based results
### Search Index Best Practices
```typescript
// ✅ GOOD: Filter fields for common filters
characters: defineEnt({
name: v.string(),
label: v.string().optional(),
userId: v.id('users'),
private: v.boolean(),
}).searchIndex('search_name', {
searchField: 'name',
filterFields: ['userId', 'private'], // Indexed equality filters
});
// ❌ BAD: No filter fields = post-filtering required
// .searchIndex('search_name', {
// searchField: 'name',
// // Missing filterFields - all filtering happens in memory!
// });
```
### Efficient Search Queries
```typescript
// ✅ EFFICIENT: Use filter fields in search expression
const results = await ctx
.table('characters')
.search(
'search_name',
(q) =>
q
.search('name', args.query)
.eq('private', false) // Uses index
.eq('userId', excludeUserId) // Uses index
)
.paginate(args.paginationOpts);
// ❌ INEFFICIENT: Post-filtering after search
const results = await ctx
.table('characters')
.search('search_name', (q) => q.search('name', args.query))
.filter((q) => q.eq(q.field('private'), false)) // Scans all results!
.paginate(args.paginationOpts);
```
### Search vs Filter Performance
```typescript
// Problem: Can't search on multiple fields
// ❌ This doesn't work - only one search field allowed
// .searchIndex('search_name_label', {
// searchField: ['name', 'label'], // NOT SUPPORTED
// })
// Solution 1: Search primary field, filter secondary
const results = await ctx
.table('characters')
.search('search_name', (q) => q.search('name', query))
.filter((q) =>
q.or(q.eq(q.field('name'), query), q.eq(q.field('label'), query))
)
.take(50);
// Solution 2: Concatenated search field
characters: defineEnt({
name: v.string(),
label: v.string().optional(),
searchText: v.string(), // name + ' ' + label
}).searchIndex('search_all', {
searchField: 'searchText',
filterFields: ['private', 'userId'],
});
```
### Key Limitations & Workarounds
1. **1024 Document Scan Limit**
```typescript
// Search can only scan 1024 documents
// Use filter fields to narrow results BEFORE scanning
ctx.table('users').search(
'search_name',
(q) => q.search('name', 'john').eq('teamId', teamId) // Narrow to team first
);
```
2. **No Custom Ordering**
```typescript
// ❌ Can't order search results by date
ctx.table('posts').search(...).order('desc') // NOT SUPPORTED
// ✅ Post-sort in memory for small result sets
const results = await ctx.table('posts').search(...).take(50);
results.sort((a, b) => b._creationTime - a._creationTime);
```
3. **Single Search Field**
```typescript
// For multi-field search, consider:
// 1. Denormalized search field (concatenated)
// 2. Multiple search indexes (query separately)
// 3. Post-filtering for secondary matches
```
### Search Index Patterns
```typescript
// Pattern 1: Optional search with fallback to regular index
export const listCharacters = createPublicPaginatedQuery()({
args: { query: z.string().optional() },
handler: async (ctx, args) => {
if (args.query) {
// Use search index when query provided
return await ctx
.table('characters')
.search('search_name', (q) =>
q.search('name', args.query!).eq('private', false)
)
.paginate(args.paginationOpts);
} else {
// Use regular index when no search
return await ctx
.table('characters')
.query()
.withIndex('private', (q) => q.eq('private', false))
.order('desc')
.paginate(args.paginationOpts);
}
},
});
// Pattern 2: For complex filters with search, use streams
// See [convex-streams.mdc](mdc:.cursor/rules/convex-streams.mdc)
```
## 5. Pagination with Filtering
**Edit:** `convex/*.ts` files using `.filter()` + `.paginate()`
### Important: Choose the Right Filtering Approach
1. **Simple filters:** Use built-in `.filter()` - maintains full page sizes with pagination!
2. **Complex filters WITHOUT pagination:** Use `filter` helper from `convex-helpers/server/filter`
3. **Complex filters WITH pagination:** Use streams from `convex-helpers/server/stream`
### Built-in `.filter()` Maintains Full Page Sizes
**Good news:** Built-in `.filter()` with `.paginate()` returns the requested number of items!
```typescript
// ✅ GOOD: Always returns 10 items (if available)
export const exploreCharacters = createPublicPaginatedQuery()({
args: { status: z.string().optional() },
handler: async (ctx, args) => {
return await ctx
.table('characters')
.filter((q) => q.eq(q.field('status'), args.status))
.paginate(args.paginationOpts);
// Convex keeps fetching until it finds 10 matching items!
},
});
```
### Complex Filtering Options
**Limitation:** Built-in `.filter()` only supports simple field comparisons (eq, neq, gt, lt).
```typescript
// ❌ CAN'T DO with built-in .filter():
// - Array operations: categories.includes('strategy')
// - String operations: name.toLowerCase().includes('john')
// - Async lookups: Check if author is premium
// - Complex logic: Multiple conditions with custom logic
```
#### Option 1: Filter Helper (No Pagination)
```typescript
import { filter } from 'convex-helpers/server/filter';
// ✅ GOOD: Complex filtering without pagination
const topPosts = await filter(
ctx
.table('posts')
.query()
.withIndex('status', (q) => q.eq('status', 'published')),
(post) => {
// Full TypeScript power
return (
post.tags.includes('featured') &&
post.title.toLowerCase().includes(searchTerm) &&
post.views > 1000
);
}
).take(10); // Works with .take(), .first(), unique()
```
#### Option 2: Streams (With Pagination)
```typescript
import { stream } from 'convex-helpers/server/stream';
import schema from './schema';
// ✅ BEST: Complex filtering with consistent pagination
export const searchCharacters = createPublicPaginatedQuery()({
args: { category: z.string().optional() },
handler: async (ctx, args) => {
return await stream(ctx.db, schema) // ⚠️ Stream still uses ctx.db
.query('characters')
.withIndex('private', (q) => q.eq('private', false))
.filterWith(async (char) => {
// Full TypeScript power + consistent page sizes!
return char.categories?.includes(args.category);
})
.paginate(args.paginationOpts);
},
});
```
**Key Difference:** Filter helper with `.paginate()` returns variable page sizes. Streams always return the requested number of items.
### How It Works
1. Convex fetches documents up to the requested page size
2. The filter predicate is applied to each document
3. Non-matching documents are discarded
4. Result: Variable number of items per page
```typescript
// Example flow:
// 1. User requests: { numItems: 10 }
// 2. Convex fetches: 10 documents from the table
// 3. Filter applies: 3 match, 7 discarded
// 4. User receives: { page: [3 items], isDone: false }
```
### Solutions
#### Solution 1: Use Indexes for Filtering (Preferred)
```typescript
// ✅ BEST: Move filterable fields to indexes
characters: defineEnt({
name: v.string(),
category: v.string(), // Denormalized for indexing
private: v.boolean(),
})
.index('category', ['category'])
.index('private_category', ['private', 'category']);
// Now filter at index level - consistent page sizes
export const exploreCharacters = createPublicPaginatedQuery()({
args: { category: z.string().optional() },
handler: async (ctx, args) => {
if (args.category) {
return await ctx
.table('characters')
.query()
.withIndex('category', (q) => q.eq('category', args.category))
.paginate(args.paginationOpts);
}
return await ctx.table('characters').query().paginate(args.paginationOpts);
// Always returns requested number of items (if available)
},
});
```
#### Solution 2: Denormalize Data (Use Sparingly)
**⚠️ CAUTION**: Only denormalize when aggregate can't be used.
```typescript
// ⚠️ ONLY IF: Shown in lists AND no complex queries needed
characters: defineEnt({
name: v.string(),
// Only denormalize simple booleans for index filtering
hasStrategyCategory: v.boolean(), // ONLY if no bounds queries needed
totalStarCount: v.number(), // ONLY if always shown in character lists
});
// ✅ BETTER: Use aggregates for counts and complex queries
const starCount = await aggregateCharacterStars.count(ctx, {
namespace: characterId,
bounds: {} as any, // Can add bounds for date ranges, user filtering, etc.
});
```
#### Solution 3: Streams for Consistent Page Sizes (Advanced)
```typescript
// ✅ BEST FOR COMPLEX CASES: Use streams from convex-helpers
import { stream } from 'convex-helpers/server/stream';
import schema from './schema';
export const searchCharacters = createPublicPaginatedQuery()({
args: {
category: z.string().optional(),
minLevel: z.number().optional(),
tags: z.array(z.string()).optional(),
},
handler: async (ctx, args) => {
// Stream with filterWith - filters BEFORE pagination
// ⚠️ IMPORTANT: Streams still use ctx.db, not yet migrated to Ents
return await stream(ctx.db, schema) // Must use ctx.db here
.query('characters')
.withIndex('private', (q) => q.eq('private', false))
// Complex filtering happens BEFORE pagination
.filterWith(async (char) => {
if (args.category && !char.categories?.includes(args.category)) {
return false;
}
if (args.minLevel && char.level < args.minLevel) {
return false;
}
if (args.tags && !args.tags.some((tag) => char.tags?.includes(tag))) {
return false;
}
// Can even do async lookups
const author = await ctx.table('users').get(char.userId);
return author && !author.isBanned;
})
// Pagination happens AFTER filtering - consistent page sizes!
.paginate(args.paginationOpts);
},
});
```
**Key Differences: `filter` helper vs Streams**
| Feature | `filter` helper | Streams `filterWith` |
| --------------------- | --------------------------- | ------------------------------- |
| Filtering timing | During pagination | Before pagination |
| Page size consistency | Variable (0-N items) | Consistent (N items) |
| Performance | Scans requested page size | Scans until page is full |
| Complexity | Simple, drop-in replacement | Requires schema import |
| Async predicates | ✅ Supported | ✅ Supported |
| Database lookups | ✅ Supported | ✅ Supported |
| Best for | Simple cases, prototyping | Production with complex filters |
**Stream Additional Features:**
- **Merge streams**: Combine multiple queries (UNION)
- **Joins**: Use `flatMap` for complex joins
- **Distinct**: Get unique values efficiently
- **Map**: Transform documents in the stream
```typescript
// Example: Merge multiple filtered streams
import { mergedStream } from 'convex-helpers/server/stream';
// ⚠️ Streams still use ctx.db
const activeUsers = stream(ctx.db, schema)
.query('users')
.withIndex('status', (q) => q.eq('status', 'active'));
const premiumUsers = stream(ctx.db, schema)
.query('users')
.withIndex('plan', (q) => q.eq('plan', 'premium'));
// Merge and get distinct users
const merged = mergedStream([activeUsers, premiumUsers], ['_creationTime'])
.distinct(['_id'])
.paginate(args.paginationOpts);
```
**When to Use Streams:**
1. Need consistent page sizes with complex filters
2. Combining data from multiple queries (UNION)
3. Complex joins that need pagination
4. Need distinct values with pagination
5. Building SQL-like query patterns
**Trade-offs:**
- More complex API (requires schema)
- Potentially more documents scanned
- Pagination cursors are more fragile
- Better for read-heavy operations
### Best Practices
1. **Prefer indexes** over post-query filtering
2. **Use aggregates** for counts and lookups (even ≤100 items)
3. **Document** variable page size behavior
4. **Design UI** to handle variable results gracefully
5. **Monitor** filter effectiveness (track avg results per page)
6. **Consider streams** for production apps with complex filtering needs
7. **Denormalize sparingly** - only when aggregate can't be used
### Common Patterns to Audit
```typescript
// Search for these patterns in your codebase:
// 1. filter(...).paginate()
// 2. Complex predicates in filter()
// 3. Array operations in filter predicates
// 4. Multiple conditions in filter predicates
```
#### Solution 3: Streams for Consistent Page Sizes (Advanced)
```typescript
// ✅ BEST FOR COMPLEX CASES: Use streams from convex-helpers
import { stream } from 'convex-helpers/server/stream';
import schema from './schema';
export const searchCharacters = createPublicPaginatedQuery()({
args: {
category: z.string().optional(),
minLevel: z.number().optional(),
tags: z.array(z.string()).optional(),
},
handler: async (ctx, args) => {
// Stream with filterWith - filters BEFORE pagination
// ⚠️ IMPORTANT: Streams still use ctx.db, not yet migrated to Ents
const characters = stream(ctx.db, schema) // Must use ctx.db here
.query('characters')
.withIndex('private', (q) => q.eq('private', false));
// Complex filtering happens BEFORE pagination
const filtered = characters.filterWith(async (char) => {
if (args.category && !char.categories?.includes(args.category)) {
return false;
}
if (args.minLevel && char.level < args.minLevel) {
return false;
}
if (args.tags && !args.tags.some((tag) => char.tags?.includes(tag))) {
return false;
}
// Can even do async lookups
const author = await ctx.table('users').get(char.userId);
return author && !author.isBanned;
});
// Pagination happens AFTER filtering - consistent page sizes!
return await filtered.paginate(args.paginationOpts);
},
});
```
**Key Differences: `filter` helper vs Streams**
| Feature | `filter` helper | Streams `filterWith` |
| --------------------- | --------------------------- | ------------------------------- |
| Filtering timing | During pagination | Before pagination |
| Page size consistency | Variable (0-N items) | Consistent (N items) |
| Performance | Scans requested page size | Scans until page is full |
| Complexity | Simple, drop-in replacement | Requires schema import |
| Async predicates | ✅ Supported | ✅ Supported |
| Database lookups | ✅ Supported | ✅ Supported |
| Best for | Simple cases, prototyping | Production with complex filters |
**Stream Additional Features:**
- **Merge streams**: Combine multiple queries (UNION)
- **Joins**: Use `flatMap` for complex joins
- **Distinct**: Get unique values efficiently
- **Map**: Transform documents in the stream
```typescript
// Example: Merge multiple filtered streams
import { mergedStream } from 'convex-helpers/server/stream';
// ⚠️ Streams still use ctx.db
const activeUsers = stream(ctx.db, schema)
.query('users')
.withIndex('status', (q) => q.eq('status', 'active'));
const premiumUsers = stream(ctx.db, schema)
.query('users')
.withIndex('plan', (q) => q.eq('plan', 'premium'));
// Merge and get distinct users
const merged = mergedStream([activeUsers, premiumUsers], ['_creationTime'])
.distinct(['_id'])
.paginate(args.paginationOpts);
```
**When to Use Streams:**
1. Need consistent page sizes with complex filters
2. Combining data from multiple queries (UNION)
3. Complex joins that need pagination
4. Need distinct values with pagination
5. Building SQL-like query patterns
**Trade-offs:**
- More complex API (requires schema)
- Potentially more documents scanned
- Pagination cursors are more fragile
- Better for read-heavy operations
### Best Practices
1. **Prefer indexes** over post-query filtering
2. **Use aggregates** for counts and lookups (even ≤100 items)
3. **Document** variable page size behavior
4. **Design UI** to handle variable results gracefully
5. **Monitor** filter effectiveness (track avg results per page)
6. **Consider streams** for production apps with complex filtering needs
7. **Denormalize sparingly** - only when aggregate can't be used
## 6. Batch Operations & Relationship Helpers
**Edit:** `convex/*.ts` files using batch operations
### Use convex-helpers and Ents for Readable Batch Operations
With Convex Ents, batch operations are simplified, but you can still use helpers from `convex-helpers`:
```typescript
import { asyncMap } from 'convex-helpers';
```
### asyncMap vs Promise.all
**Always prefer `asyncMap` over `Promise.all` for async operations:**
```typescript
// ❌ HARDER TO READ: Raw Promise.all
const results = await Promise.all(ids.map((id) => someAsyncOperation(id)));
// ✅ CLEANER: Use asyncMap from convex-helpers
const results = await asyncMap(ids, someAsyncOperation);
// Real example with aggregates
// ❌ Raw Promise.all
const starCounts = await Promise.all(
characterIds.map((characterId) =>
aggregateCharacterStarsByUser.count(ctx, {
bounds: { key: characterId, inclusive: true },
namespace: userId,
})
)
);
// ✅ Better with asyncMap
const starCounts = await asyncMap(characterIds, (characterId) =>
aggregateCharacterStarsByUser.count(ctx, {
bounds: { key: characterId, inclusive: true },
namespace: userId,
})
);
```
### getMany for Batch ID Lookups with Ents
**Use `getMany` or `getManyX` with Ents for fetching multiple documents by ID:**
```typescript
// ❌ Manual Promise.all pattern
const userPromises = [];
for (const userId of userIds) {
userPromises.push(ctx.table('users').get(userId));
}
const users = await Promise.all(userPromises);
// ✅ Clean with Ents getMany
const users = await ctx.table('users').getMany(userIds);
// Returns (Doc | null)[] for each ID
// ✅ Or use getManyX to throw if any ID doesn't exist
const users = await ctx.table('users').getManyX(userIds);
// Returns Doc[] or throws if any missing
```
### WARNING: Fetching All Related Documents
**⚠️ CRITICAL**: With Ents, fetching all related documents via edges is still like `.collect()`!
```typescript
// ❌ DANGEROUS: Could return millions of documents!
const user = await ctx.table('users').get(userId);
const allUserPosts = await user.edge('posts'); // Fetches ALL posts!
// ✅ SAFE: Use pagination for unbounded data
const recentPosts = await ctx
.table('posts')
.query()
.withIndex('userId', (q) => q.eq('userId', userId))
.order('desc')
.paginate(args.paginationOpts);
// ✅ ACCEPTABLE: Limit results when traversing edges
const user = await ctx.table('users').get(userId);
const recentPosts = await user.edge('posts').order('desc').take(5);
// Safe: limited to 5 posts
```
### When to Use Each Helper
| Helper | Use When | Warning |
| ------------------------------- | ------------------------------ | ----------------------------------- |
| `ctx.table().getMany(ids)` | Batch fetching by IDs | Returns (Doc \| null)[] for each ID |
| `ctx.table().getManyX(ids)` | Batch fetching, must all exist | Throws if any ID missing |
| `asyncMap(items, fn)` | Any async operation over array | Better than Promise.all |
| `ent.edge('relation')` | Traverse 1:1 or 1:many edge | ⚠️ Fetches ALL for 1:many |
| `ent.edge('relation').take(n)` | Limited edge traversal | Safe with limit |
| `ent.edge('relation').paginate` | Paginated edge traversal | Best for unbounded relations |
### Safe Patterns for Unbounded Relationships
```typescript
// ✅ PATTERN 1: Use aggregates for counts
const postCount = await aggregatePosts.count(ctx, {
namespace: userId,
bounds: {} as any,
});
// ✅ PATTERN 2: Use pagination for lists
const posts = await ctx
.table('posts')
.query()
.withIndex('userId', (q) => q.eq('userId', userId))
.order('desc')
.paginate(args.paginationOpts);
// ✅ PATTERN 3: Use .take() for small previews
const user = await ctx.table('users').getX(userId);
const recentPosts = await user.edge('posts').order('desc').take(5);
// ✅ PATTERN 4: Batch operations on current page only
const posts = await ctx.table('posts').query().paginate(args.paginationOpts);
const authorIds = posts.page.map((p) => p.authorId);
const authors = await ctx.table('users').getMany(authorIds); // Only current page!
```
### Best Practices
1. **Always use `asyncMap`** instead of raw Promise.all for readability
2. **Use `getMany/getManyX`** for batch ID lookups with Ents
3. **NEVER fetch all** via edges on tables that could grow unbounded
4. **Always limit edge traversal** with `.take()` or `.paginate()`
5. **For large relationships**, use pagination or aggregates instead
6. **Document edge cardinality** in your schema (1:1, 1:many, many:many)
### Common Anti-Patterns
```typescript
// ❌ Fetching all to filter/map/count
const user = await ctx.table('users').get(userId);
const allPosts = await user.edge('posts');
const publishedPosts = allPosts.filter((p) => p.status === 'published');
const count = allPosts.length;
// ✅ Use index + aggregate instead
const publishedPosts = await ctx
.table('posts')
.query()
.withIndex('userId_status', (q) =>
q.eq('userId', userId).eq('status', 'published')
)
.paginate(args.paginationOpts);
const count = await aggregatePosts.count(ctx, {
namespace: userId,
bounds: {} as any,
});
```
## 7. Convex Platform Limits
**Edit:** Understand these limits when designing your schema and queries
### Index Limits
- **Indexes per table:** 32 max
- **Fields per index:** 16 max (no duplicate fields)
- **Index name length:** 64 characters max
- **Reserved fields:** Cannot index `_id`, `_creationTime` (automatically indexed)
- **Search indexes per table:** 4 max
- **Vector indexes per table:** 4 max
- **Filters per search/vector index:** 16 max
### Query & Mutation Limits
- **Documents scanned per query/mutation:** 16,384 max
- **Data scanned per query/mutation:** 8 MiB max
- **db.get/db.query calls per function:** 4,096 max
- **JS execution time per function:** 1 second max
- **Documents written per mutation:** 8,192 max
- **Data written per mutation:** 8 MiB max
### Document Limits
- **Document size:** 1 MiB max
- **Fields per document:** 1,024 max
- **Field name length:** 64 characters max (nested keys up to 1,024)
- **Field nesting depth:** 16 max
- **Array elements:** 8,192 max per array
### Search Limits
- **Search terms per query:** 16 max
- **Filters per search query:** 8 max (full text), 64 max (vector)
- **Term length:** 32 bytes max
- **Result set:** 1,024 max (full text), 256 max (vector, defaults to 10)
- **Vector dimensions:** 2-4096 range
### Action Limits
- **Action timeout:** 10 minutes max
- **Memory limit:** 64 MB (Convex runtime), 512 MB (Node.js runtime)
- **Concurrent operations per action:** 1,000 max
- **Function argument/return size:** 16 MiB max
- **HTTP action response size:** 20 MiB max
### Design Implications
```typescript
// ❌ BAD: Too many indexes (approaching 32 limit)
defineEnt({
// 20+ fields...
})
.index('field1', ['field1'])
.index('field2', ['field2']);
// ... 30+ indexes - SLOW INSERTS!
// ✅ GOOD: Consolidate with compound indexes
defineEnt({
// fields...
})
.index('user_status', ['userId', 'status'])
.index('user_date', ['userId', 'createdAt']);
// Reuse compound indexes for single-field queries too
```
```typescript
// ❌ BAD: Large arrays in documents
const character = {
followers: ['userId1', 'userId2', ...], // Could hit 8,192 limit!
}
// ✅ GOOD: Use many:many edge for unbounded relationships
characters: defineEnt({
name: v.string(),
// ...
}).edges('followers', { to: 'users' }), // many:many edge
users: defineEnt({
name: v.string(),
// ...
}).edges('followingCharacters', { to: 'characters' })
```
```typescript
// ❌ BAD: Deep nesting (approaching 16 levels)
const data = {
level1: {
level2: {
level3: {
// ... up to level 16
}
}
}
}
// ✅ GOOD: Flatten structure or use edges
const mainEntity = defineEnt({
mainData: v.object({ ... }),
}).edge('details'), // 1:1 edge to details
const details = defineEnt({
// Details stored in separate table
}).edge('mainEntity', { ref: true })
```
## Summary
1. **Always use indexes** for equality filters with `ctx.table()`
2. **Use aggregates for counting** - O(log n) vs O(n) performance with millions of documents
3. **Never fetch all documents** - use `.paginate()` for user-facing lists
4. **Isolate frequently-changing data** with separate tables and edges
5. **Enforce limits at insert time** when possible, using aggregates for counting
6. **Use search indexes** for text search with proper filter fields
7. **Understand filter + paginate behavior** - filtering happens during, not after
8. **Know platform limits** - design within Convex's boundaries for optimal performance
9. **Limit edge traversals** - use `.take()` or `.paginate()` on 1:many edges
10. **Streams still use ctx.db** - not yet migrated to Ents API
**Critical for Scale**: Replace fetching all documents with aggregates from [convex-aggregate.mdc](mdc:.cursor/rules/convex-aggregate.mdc). These patterns are essential for millions of documents.
## Decision Tree: Which Pattern to Use?
```
Need to COUNT documents?
✅ Use AGGREGATES (O(log n) - scales to millions)
- await aggregateItems.count(ctx, { bounds: {} as any })
- Set up once with triggers, works automatically
Need a USER-FACING LIST with "load more"?
✅ Use PAGINATION (.paginate())
- createPublicPaginatedQuery() or createAuthPaginatedQuery()
- With Ents: ctx.table('items').query().paginate(args.paginationOpts)
- Consistent page sizes, good UX
Need a SMALL PREVIEW of items (< 100) from BOUNDED table?
✅ Use TAKE (.take(n))
- With Ents: ctx.table('items').query().take(5) or ent.edge('items').take(5)
- ONLY when table has enforced size limits
- ONLY when you don't need total count
- Example: "Show 5 most recent notifications"
❌ NEVER fetch all documents without limits on production tables
❌ NEVER traverse 1:many edges without .take() or .paginate()
❌ NEVER count by fetching all documents - use aggregates
```
---
description: Compact checklist for reviewing Convex queries/mutations for performance and best practices at scale (millions of docs per table)
globs: convex/**/*.ts
alwaysApply: false
---
**User Request:** $ARGUMENTS
# Convex Performance Review Checklist
Review each query/mutation in `convex/*.ts` files assuming millions of documents per table.
**Related Documentation:**
- [convex.mdc](mdc:.cursor/rules/convex.mdc) - Core Convex usage patterns
- [convex-optimize.mdc](mdc:.cursor/rules/convex-optimize.mdc) - Detailed optimization strategies
- [convex-aggregate.mdc](mdc:.cursor/rules/convex-aggregate.mdc) - Aggregation best practices
- [convex-streams.mdc](mdc:.cursor/rules/convex-streams.mdc) - Complex query patterns
- [convex-trigger.mdc](mdc:.cursor/rules/convex-trigger.mdc) - Database triggers
## Critical Rule: No ctx.db Usage
**🚨 FORBIDDEN:** Never use `ctx.db` in any Convex functions. Always use `ctx.table()` instead.
**⚠️ ONLY EXCEPTION:** Streams from `convex-helpers/server/stream` still require `ctx.db`.
## Query Review Checklist
### 1. Index Usage ⚡
- [ ] **Using index for equality filters?** Replace `.filter(q => q.eq(q.field('x'), y))` with `.withIndex('x', q => q.eq('x', y))`
- [ ] **Index exists in schema?** Check `convex/schema.ts` for `.index('field', ['field'])`
- [ ] **Compound index for multiple filters?** Use `.index('x_y', ['x', 'y'])` for `.eq('x', a).eq('y', b)`
- [ ] **Search index has filterFields?** Add common filters to `filterFields: ['userId', 'private']`
### 2. Document Limits 🚫
- [ ] **Never using `.collect()` without limit?** Use aggregates for counting, `.paginate()` for lists
- [ ] **Using aggregates for counting?** O(log n) vs O(n) performance with millions of docs
- [ ] **Using `.paginate()` for user-facing lists?** Required for unbounded data
- [ ] **Using `.collect()` on indexed queries?** Consider aggregates or pagination for 1000+ docs
### 3. Counting & Aggregation 📊
- [ ] **Never using `.collect().length`?** Use aggregates for O(log n) counts
- [ ] **Aggregate registered in triggers?** Check `convex/triggers.ts` for `triggers.register('table', aggregate.trigger())`
- [ ] **Using `aggregate.count()` not array.length?** For all count operations
### 4. Aggregate Lookup Guidelines 🔄
- [ ] **N+1 aggregate lookups are FINE for bounded data** (≤100 items per page)
- [ ] **Aggregate lookups in paginated queries?** ✅ GOOD - provides flexibility
- [ ] **Aggregate lookups in stream filters?** ✅ GOOD - each page is bounded
- [ ] **Only flag if looping over 1000+ of items** - That's when it becomes a problem
### 5. Filtering Best Practices 🔍
- [ ] **Simple filters: Using built-in `.filter()`?** Maintains full page sizes
- [ ] **Complex filters + pagination: Using streams?** For arrays/strings/async filters
- [ ] **Complex filters without pagination: Using filter helper?** For `.take()/.first()`
### 6. Query Optimization 🎯
- [ ] **Frequently-changing fields isolated?** Move timestamps/counters to separate tables
- [ ] **Using `.first()` not `.collect()[0]`?** More efficient for single doc
- [ ] **Batch operations use `getAll()`?** For fetching by multiple IDs
- [ ] **Using `asyncMap()` for async operations?** Not regular `map()` with await inside
- [ ] **Avoiding `.collect()` on large tables?** Even with indexes, it fetches ALL matching docs
### 7. Computed Data Patterns 🔄
- [ ] **Computing derived data on read?** Look for functions that scan many docs to compute per-entity values
- [ ] **Finding last/first/max/min across docs?** Common pattern: `getLastMessageMap`, `getLatestActivity`, etc.
- [ ] **O(n) aggregations in queries?** Use aggregates with proper bounds (O(log n))
- [ ] **Repeatedly calculating same values?** Pre-compute with triggers instead
## Mutation Review Checklist
### 1. Function Wrappers ✅
- [ ] **Using `zid()` for all IDs?** Never `z.string()` for document IDs
- [ ] **Rate limiting configured?** Add `{ rateLimit: 'feature/action' }` for user-facing mutations
- [ ] **Rate limit defined in `rateLimiter.ts`?** Check if the rate limit key exists with `:free` and `:premium` variants
### 2. Write Patterns 📝
- [ ] **Using `.patch()` for partial updates?** Not `.replace()` unless needed
- [ ] **Bulk inserts use loop not Promise.all?** Convex batches automatically mutations (not queries)
- [ ] **Large batches processed in chunks?** 500-1000 items per batch
- [ ] **Edge definitions handle cascades?** Check schema for proper edge relationships (hard deletion is default)
- [ ] **No manual cascade deletes needed?** Convex Ents handles cascade deletes automatically via edges
### 3. Error Handling 🛡️
- [ ] **Throwing `ConvexError` not `Error`?** With proper error codes
- [ ] **Checking document exists before update?** Handle null from `.get()`
- [ ] **Transaction safety considered?** All operations atomic within mutation
## Common Anti-Patterns to Flag 🚨
```typescript
// ❌ BAD: Full table scan
.filter(q => q.eq(q.field('userId'), userId))
// ✅ GOOD: Use index
.withIndex('userId', q => q.eq('userId', userId))
// ❌ BAD: Unbounded collection
const all = await ctx.table('items').query().collect();
// ✅ GOOD: Use aggregates for counting
const count = await aggregateItems.count(ctx, { bounds: {} as any });
// ✅ GOOD: Paginated for lists
const items = await ctx.table('items').query().paginate(paginationOpts);
// ❌ BAD: Count with collect
const count = (await ctx.table('items').query().withIndex('x', q => q.eq('x', x)).collect()).length;
// ✅ GOOD: Use aggregate
const count = await aggregateItems.count(ctx, { namespace: x });
// ❌ BAD: Manual aggregate update
await aggregate.insert(ctx, doc);
// ✅ GOOD: Let trigger handle it
await ctx.table('items').insert(doc); // Trigger fires automatically
// ❌ BAD: Raw function
export const myQuery = query({...});
// ✅ GOOD: Wrapped function
export const myQuery = createPublicQuery()({...});
// ⚠️ IMPORTANT: N+1 aggregate lookups are FINE for bounded data!
// ✅ GOOD: Aggregate lookups for paginated/bounded data (≤100 items)
for (const message of messages) { // 10-100 messages from paginated query
const voteCount = await aggregateVotes.count(ctx, {
namespace: chatId,
bounds: { /* can filter by date, user, etc. */ }
});
}
// ✅ 100 O(log n) operations are still very efficient
// ✅ GOOD: Aggregate lookups in stream filters (bounded by pagination)
// Note: streams still use ctx.db, not yet migrated to Ents
await stream(ctx.db, schema)
.query('characters')
.filterWith(async (char) => {
// This is FINE - each page is limited (e.g., 10-50 items)
const isStarred = await aggregateStars.count(ctx, {
namespace: userId,
bounds: { lower: { key: char._id }, upper: { key: char._id } }
});
return isStarred > 0;
})
.paginate(args.paginationOpts);
// ❌ BAD: Only when dealing with unbounded/thousands of items
const allUsers = await ctx.table('users').query().collect(); // Could be millions!
for (const user of allUsers) { // ❌ Looping over unbounded data
const stats = await aggregateUserStats.count(ctx, { namespace: user._id });
}
// ❌ BAD: Computing derived data on every query
async function getLastMessageMap(ctx, userId, characterIds) {
const chats = await ctx.table('chats')
.query()
.withIndex('userId', q => q.eq('userId', userId))
.take(1000); // Scans up to 1000 docs every time!
// O(n) computation on every call
const map = new Map();
for (const chat of chats) {
if (characterIds.includes(chat.characterId)) {
const existing = map.get(chat.characterId);
if (!existing || chat.lastMessageAt > existing) {
map.set(chat.characterId, chat.lastMessageAt);
}
}
}
return map;
}
// ✅ GOOD: Use aggregates for flexibility
// Even for 100 lookups, aggregates provide bounds queries
for (const characterId of characterIds) {
const lastActivity = await aggregateCharacterActivity.max(ctx, {
namespace: userId,
bounds: {
lower: { key: characterId, inclusive: true },
upper: { key: characterId, inclusive: true }
} as any
});
lastMessageMap.set(characterId, lastActivity);
}
// Still efficient and supports date ranges, filtering, etc.
// ❌ BAD: Raw Promise.all for batch operations
const results = await Promise.all(
ids.map(id => someAsyncOperation(id))
);
// ✅ GOOD: Use asyncMap from convex-helpers
import { asyncMap } from 'convex-helpers';
const results = await asyncMap(ids, someAsyncOperation);
// ❌ DANGEROUS: getManyFrom on unbounded table
const allUserPosts = await ctx.table('posts').query().withIndex('userId', q => q.eq('userId', userId)).collect();
// Could return millions of posts!
// ✅ SAFE: Use pagination or take a limited number
const recentPosts = await ctx
.table('posts')
.query()
.withIndex('userId', q => q.eq('userId', userId))
.order('desc')
.take(10);
```
## Performance Red Flags 🚩
1. **Missing indexes**: Any `.filter()` without preceding `.withIndex()`
2. **Unbounded queries**: `.collect()` without enforced size limits
3. **Manual counting**: Any `.length` on collections (use aggregates)
4. **Missing aggregates**: Count operations without O(log n) aggregates
5. **Cache thrashing**: Frequently-updated fields in widely-referenced docs
6. **Complex filters + paginate**: Not using streams for consistent page sizes
7. **Missing triggers**: Aggregates updated manually in mutations
8. **Manual cascade deletes**: Redundant deletes in mutations when Convex Ents handles them automatically
9. **Computed data patterns**: Functions that scan many docs to compute per-entity values
10. **O(n) aggregations**: Computing last/first/max/min across unbounded collections
11. **Raw Promise.all**: Not using `asyncMap` from convex-helpers for async operations
12. **Unbounded indexed queries**: Using `.collect()` that could match millions of documents
## ✅ Performance Green Flags (Don't Flag These!)
1. **Aggregate lookups in loops**: Fine for ≤100 items (paginated data)
2. **Aggregate lookups in stream filters**: Each page is bounded, so it's efficient
3. **Multiple aggregate queries**: O(log n) × 100 is still very fast
4. **asyncMap with aggregates**: Proper pattern for parallel aggregate lookups
5. **Starred/favorited checks**: Using aggregates for user preferences is correct
## Domain Modeling vs Performance Optimization
### When to Keep Separate Tables (like `characterActivity`)
```typescript
// ✅ GOOD: Separate table for domain relationships
// characterActivity represents user's interaction history with characters
characterActivity: defineEnt({
userId: v.id('users'),
characterId: v.id('characters'),
lastMessageAt: v.number(),
// Could extend with:
// interactionCount: v.number(),
// lastActionType: v.string(), // 'chat', 'star', 'view'
// favoriteLevel: v.number(),
})
.index('userId', ['userId'])
.index('characterId', ['characterId'])
.index('user_character', ['userId', 'characterId']);
// ✅ Appropriate because:
// - Represents meaningful domain relationship
// - May need additional metadata
// - Enables queries by userId AND characterId
// - Not just a performance cache
```
### When to Use Aggregates Instead
```typescript
// ✅ GOOD: Aggregates for pure statistics
const aggregateMessages = new TableAggregate<{
Namespace: Id<'chats'>;
Key: null;
DataModel: DataModel;
TableName: 'messages';
}>(components.aggregateMessages, {
namespace: (doc) => doc.chatId,
sortKey: () => null,
});
// ✅ Appropriate because:
// - Just counting messages
// - No domain meaning
// - Single-purpose optimization
// - No additional metadata needed
```
## Convex Platform Limits 📏
### Quick Reference
- **Indexes per table:** 32 (too many = slow inserts)
- **Documents per query:** 16,384 max scanned
- **Query time limit:** 1 second JS execution
- **Document size:** 1 MiB max
- **Array elements:** 8,192 max
- **Search results:** 1,024 max
**Full limits documentation:** [convex-optimize.mdc#7](mdc:.cursor/rules/convex-optimize.mdc)
## Quick Wins 🏆
1. **Add index**: For every equality filter
2. **Setup aggregates**: For all count operations (O(log n) vs O(n))
3. **Use pagination**: Replace `.collect()` with `.paginate()` for lists
4. **Use streams**: For complex filtered pagination
5. **Register triggers**: For automatic aggregate maintenance
6. **Isolate counters**: Move to separate tables
7. **Use aggregates for computed data**: Replace O(n) scans with O(log n) aggregates
## Review Example
```typescript
// BEFORE: Multiple issues
export const getTeamMembers = query({
args: { teamId: v.id('teams') },
handler: async (ctx, args) => {
const members = await ctx
.table('members')
.query()
.filter((q) => q.eq(q.field('teamId'), args.teamId)) // 🚩 No index
.collect(); // 🚩 Unbounded
const count = members.length; // 🚩 O(n) count
return { members, count };
},
});
// AFTER: Optimized for millions of documents
export const getTeamMembers = createPublicPaginatedQuery()({
args: { teamId: zid('teams') }, // ✅ zid for IDs
handler: async (ctx, args) => {
// ✅ Using index + pagination
const results = await ctx
.table('members')
.query()
.withIndex('teamId', (q) => q.eq('teamId', args.teamId))
.paginate(args.paginationOpts);
// ✅ O(log n) count with aggregate - scales to millions
const count = await aggregateMembers.count(ctx, {
namespace: args.teamId,
bounds: {} as any,
});
return { ...results, totalCount: count };
},
});
// In triggers.ts:
triggers.register('members', aggregateMembers.trigger()); // ✅ Automatic maintenance
```
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment