--- name: convex-helpers-guide description: Discover and use convex-helpers utilities for relationships, filtering, sessions, custom functions, and more. Use when you need pre-built Convex patterns. --- # Convex Helpers Guide Use convex-helpers to add common patterns and utilities to your Convex backend without reinventing the wheel. ## What is convex-helpers? `convex-helpers` is the official collection of utilities that complement Convex. It provides battle-tested patterns for common backend needs. **Installation:** ```bash npm install convex-helpers ``` ## Available Helpers ### 1. Relationship Helpers Traverse relationships between tables in a readable, type-safe way. **Use when:** - Loading related data across tables - Following foreign key relationships - Building nested data structures **Example:** ```typescript import { getOneFrom, getManyFrom } from "convex-helpers/server/relationships"; export const getTaskWithUser = query({ args: { taskId: v.id("tasks") }, handler: async (ctx, args) => { const task = await ctx.db.get(args.taskId); if (!task) return null; // Get related user const user = await getOneFrom( ctx.db, "users", "by_id", task.userId, "_id" ); // Get related comments const comments = await getManyFrom( ctx.db, "comments", "by_task", task._id, "taskId" ); return { ...task, user, comments }; }, }); ``` **Key Functions:** - `getOneFrom` - Get single related document - `getManyFrom` - Get multiple related documents - `getManyVia` - Get many-to-many relationships through junction table ### 2. Custom Functions (Data Protection) - MOST IMPORTANT **This is Convex's alternative to Row Level Security (RLS).** Instead of database-level policies, use custom function wrappers to automatically add auth and access control to all queries and mutations. Create wrapped versions of query/mutation/action with custom behavior. **Use when:** - **Data protection and access control** (PRIMARY USE CASE) - Want to add auth logic to all functions - Multi-tenant applications - Role-based access control (RBAC) - Need to inject common data into ctx - Building internal-only functions - Adding logging/monitoring to all functions **Why this instead of RLS:** - TypeScript, not SQL policies - Full type safety - Easy to test and debug - More flexible than database policies - Works across your entire backend **Example: Custom Query with Auto-Auth** ```typescript // convex/lib/customFunctions.ts import { customQuery } from "convex-helpers/server/customFunctions"; import { query } from "../_generated/server"; export const authenticatedQuery = customQuery( query, { args: {}, // No additional args required input: async (ctx, args) => { const identity = await ctx.auth.getUserIdentity(); if (!identity) { throw new Error("Not authenticated"); } const user = await ctx.db .query("users") .withIndex("by_token", q => q.eq("tokenIdentifier", identity.tokenIdentifier) ) .unique(); if (!user) throw new Error("User not found"); // Add user to context return { ctx: { ...ctx, user }, args }; }, } ); // Usage in your functions export const getMyTasks = authenticatedQuery({ handler: async (ctx) => { // ctx.user is automatically available! return await ctx.db .query("tasks") .withIndex("by_user", q => q.eq("userId", ctx.user._id)) .collect(); }, }); ``` **Example: Multi-Tenant Data Protection** ```typescript import { customQuery } from "convex-helpers/server/customFunctions"; import { query } from "../_generated/server"; // Organization-scoped query - automatic access control export const orgQuery = customQuery(query, { args: { orgId: v.id("organizations") }, input: async (ctx, args) => { const user = await getCurrentUser(ctx); // Verify user is a member of this organization const member = await ctx.db .query("organizationMembers") .withIndex("by_org_and_user", q => q.eq("orgId", args.orgId).eq("userId", user._id) ) .unique(); if (!member) { throw new Error("Not authorized for this organization"); } // Inject org context return { ctx: { ...ctx, user, orgId: args.orgId, role: member.role }, args }; }, }); // Usage - data automatically scoped to organization export const getOrgProjects = orgQuery({ args: { orgId: v.id("organizations") }, handler: async (ctx) => { // ctx.user and ctx.orgId automatically available and verified! return await ctx.db .query("projects") .withIndex("by_org", q => q.eq("orgId", ctx.orgId)) .collect(); }, }); ``` **Example: Role-Based Access Control** ```typescript import { customMutation } from "convex-helpers/server/customFunctions"; import { mutation } from "../_generated/server"; export const adminMutation = customMutation(mutation, { args: {}, input: async (ctx, args) => { const user = await getCurrentUser(ctx); if (user.role !== "admin") { throw new Error("Admin access required"); } return { ctx: { ...ctx, user }, args }; }, }); // Usage - only admins can call this export const deleteUser = adminMutation({ args: { userId: v.id("users") }, handler: async (ctx, args) => { // Only admins reach this code await ctx.db.delete(args.userId); }, }); ``` ### 3. Filter Helper Apply complex TypeScript filters to database queries. **Use when:** - Need to filter by computed values - Filtering logic is too complex for indexes - Working with small result sets **Example:** ```typescript import { filter } from "convex-helpers/server/filter"; export const getActiveTasks = query({ handler: async (ctx) => { const now = Date.now(); const threeDaysAgo = now - 3 * 24 * 60 * 60 * 1000; return await filter( ctx.db.query("tasks"), (task) => !task.completed && task.createdAt > threeDaysAgo && task.priority === "high" ).collect(); }, }); ``` **Note:** Still prefer indexes when possible! Use filter for complex logic that can't be indexed. ### 4. Sessions Track users across requests even when not logged in. **Use when:** - Need to track anonymous users - Building shopping cart for guests - Tracking user behavior before signup - A/B testing without auth **Setup:** ```typescript // convex/sessions.ts import { SessionIdArg } from "convex-helpers/server/sessions"; import { query } from "./_generated/server"; export const trackView = query({ args: { ...SessionIdArg, // Adds sessionId: v.string() pageUrl: v.string(), }, handler: async (ctx, args) => { await ctx.db.insert("pageViews", { sessionId: args.sessionId, pageUrl: args.pageUrl, timestamp: Date.now(), }); }, }); ``` **Client (React):** ```typescript import { useSessionId } from "convex-helpers/react/sessions"; import { useQuery } from "convex/react"; import { api } from "../convex/_generated/api"; function MyComponent() { const sessionId = useSessionId(); // Automatically includes sessionId in all requests useQuery(api.sessions.trackView, { sessionId, pageUrl: window.location.href, }); } ``` ### 5. Zod Validation Use Zod schemas instead of Convex validators. **Use when:** - Already using Zod in your project - Want more complex validation logic - Need custom error messages **Example:** ```typescript import { zCustomQuery } from "convex-helpers/server/zod"; import { z } from "zod"; import { query } from "./_generated/server"; const argsSchema = z.object({ email: z.string().email(), age: z.number().min(18).max(120), }); export const createUser = zCustomQuery(query, { args: argsSchema, handler: async (ctx, args) => { // args is typed from Zod schema return await ctx.db.insert("users", args); }, }); ``` ### 6. Alternative: Row-Level Security Helper **Note:** Convex recommends using **custom functions** (see #2 above) as the primary data protection pattern. This RLS helper is an alternative approach that mimics traditional RLS. **However, custom functions are usually better because:** - Type-safe at compile time (RLS is runtime) - More explicit (easy to see what auth is applied) - Better error messages - Easier to test ### 7. Migrations Run data migrations safely. **Use when:** - Backfilling new fields - Transforming existing data - Moving between schema versions **Example:** ```typescript import { makeMigration } from "convex-helpers/server/migrations"; export const addDefaultPriority = makeMigration({ table: "tasks", migrateOne: async (ctx, doc) => { if (doc.priority === undefined) { await ctx.db.patch(doc._id, { priority: "medium" }); } }, }); // Run: npx convex run migrations:addDefaultPriority ``` ### 8. Triggers Execute code automatically when data changes. **Use when:** - Sending notifications on data changes - Updating related records - Logging changes - Maintaining computed fields **Example:** ```typescript import { Triggers } from "convex-helpers/server/triggers"; const triggers = new Triggers(); triggers.register("tasks", "insert", async (ctx, task) => { // Send notification when task is created await ctx.db.insert("notifications", { userId: task.userId, type: "task_created", taskId: task._id, }); }); ``` ## Common Patterns ### Pattern 1: Authenticated Queries with User Context ```typescript import { customQuery } from "convex-helpers/server/customFunctions"; export const authedQuery = customQuery(query, { args: {}, input: async (ctx, args) => { const user = await getCurrentUser(ctx); return { ctx: { ...ctx, user }, args }; }, }); // Now all queries automatically have user in context export const getMyData = authedQuery({ handler: async (ctx) => { // ctx.user is typed and available! return await ctx.db .query("data") .withIndex("by_user", q => q.eq("userId", ctx.user._id)) .collect(); }, }); ``` ### Pattern 2: Loading Related Data ```typescript import { getOneFrom, getManyFrom } from "convex-helpers/server/relationships"; export const getPostWithDetails = query({ args: { postId: v.id("posts") }, handler: async (ctx, args) => { const post = await ctx.db.get(args.postId); if (!post) return null; const author = await getOneFrom(ctx.db, "users", "by_id", post.authorId, "_id"); const comments = await getManyFrom(ctx.db, "comments", "by_post", post._id, "postId"); const tagLinks = await getManyFrom(ctx.db, "postTags", "by_post", post._id, "postId"); const tags = await Promise.all( tagLinks.map(link => getOneFrom(ctx.db, "tags", "by_id", link.tagId, "_id") ) ); return { ...post, author, comments, tags }; }, }); ``` ### Pattern 3: Batch Operations with Error Handling ```typescript import { asyncMap } from "convex-helpers"; export const batchUpdateTasks = mutation({ args: { taskIds: v.array(v.id("tasks")), status: v.string(), }, handler: async (ctx, args) => { const results = await asyncMap(args.taskIds, async (taskId) => { try { const task = await ctx.db.get(taskId); if (task) { await ctx.db.patch(taskId, { status: args.status }); return { success: true, taskId }; } return { success: false, taskId, error: "Not found" }; } catch (error) { return { success: false, taskId, error: error.message }; } }); return results; }, }); ``` ## When to Use What | Need | Use | Import From | |------|-----|-------------| | Load related data | `getOneFrom`, `getManyFrom` | `convex-helpers/server/relationships` | | Auth in all functions | `customQuery` | `convex-helpers/server/customFunctions` | | Complex filters | `filter` | `convex-helpers/server/filter` | | Anonymous users | `useSessionId` | `convex-helpers/react/sessions` | | Zod validation | `zCustomQuery` | `convex-helpers/server/zod` | | Data migrations | `makeMigration` | `convex-helpers/server/migrations` | | Triggers | `Triggers` | `convex-helpers/server/triggers` | ## Checklist - [ ] Installed convex-helpers: `npm install convex-helpers` - [ ] Using relationship helpers for related data - [ ] Created custom functions for common auth patterns - [ ] Using sessions for anonymous tracking (if needed) - [ ] Prefer indexes over filter when possible - [ ] Check convex-helpers docs for new utilities