mcpField
C
convex-helpers-guide

convex-helpers-guide

github.com/get-convexDevelopment117179 installsUpdated 2d ago
Repository

About convex-helpers-guide

convex-helpers-guide is a practical guide for developers extending Convex backends with utilities from convex-helpers. It covers typed relationship access, access-control wrappers, sessions, validation, migrations, triggers, and aggregations.

Key features

  1. 01Traverse table relationships with typed helpers
  2. 02Wrap functions with authentication and access control
  3. 03Filter query results with TypeScript logic
  4. 04Track anonymous sessions
  5. 05Use Zod validation and data migrations
  6. 06Run triggers and compute aggregations

Use cases

  1. 01Add shared authorization to backend functions
  2. 02Load related records across tables
  3. 03Track guest carts before signup
  4. 04Backfill fields or react to data changes

Install

Copy the command and run it in a terminal.

$npx skills add https://github.com/get-convex/convex-agent-plugins --skill convex-helpers-guide

After installing, type/convex-helpers-guidein Claude Code to confirm it loaded.

Details

From the project README

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:

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:

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

// 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

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

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:

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:

// 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):

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:

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.

Implement fine-grained access control with RLS-style rules.

Use when:

  • Prefer RLS-style patterns from PostgreSQL
  • Need to apply same rules across many functions
  • Want centralized access control rules

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

Example (if you prefer RLS style):

import { RowLevelSecurity } from "convex-helpers/server/rowLevelSecurity";

const rules = new RowLevelSecurity();

rules.addRule("tasks", async (ctx, task) => {
  const user = await getCurrentUser(ctx);
  // Users can only see their own tasks
  return task.userId === user._id;
});

export const getTasks = query({
  handler: async (ctx) => {
    return await rules.applyRules(
      ctx,
      ctx.db.query("tasks").collect()
    );
  },
});

Recommended instead: Custom functions

export const myQuery = authedQuery({
  handler: async (ctx) => {
    // More explicit, type-safe, better errors
    return await ctx.db
      .query("tasks")
      .withIndex("by_user", q => q.eq("userId", ctx.user._id))
      .collect();
  },
});
7. Migrations

Run data migrations safely.

Use when:

  • Backfilling new fields
  • Transforming existing data
  • Moving between schema versions

Example:

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:

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,
  });
});
9. Aggregations

Compute aggregates efficiently.

Example:

import { aggregation } from "convex-helpers/server/aggregation";

export const getTaskStats = query({
  handler: async (ctx) => {
    const stats = await aggregation(
      ctx.db.query("tasks"),
      {
        total: "count",
        completed: (task) => task.completed ? 1 : 0,
        totalPriority: (task) =>
          task.priority === "high" ? 3 : task.priority === "medium" ? 2 : 1,
      }
    );

    return {
      total: stats.total,
      completed: stats.completed,
      avgPriority: stats.totalPriority / stats.total,
    };
  },
});

Common Patterns

Pattern 1: Authenticated Queries with User Context
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
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;

    // Load author
    const author = await getOneFrom(
      ctx.db,
      "users",
      "by_id",
      post.authorId,
      "_id"
    );

    // Load comments
    const comments = await getManyFrom(
      ctx.db,
      "comments",
      "by_post",
      post._id,
      "postId"
    );

    // Load tags (many-to-many)
    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
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;
  },
});

Best Practices

  1. Start with convex-helpers

    • Don't reinvent common patterns
    • Use battle-tested utilities
    • Contribute back if you build something useful
  2. Custom Functions for Auth

    • Create authedQuery, authedMutation, etc.
    • Inject user context automatically
    • Reduces boilerplate
  3. Relationships Over Nesting

    • Use relationship helpers
    • Keep data normalized
    • Load related data as needed
  4. Filter Sparingly

    • Prefer indexes when possible
    • Use filter for complex computed logic
    • Good for small result sets
  5. Sessions for Anonymous Users

    • Track before signup
    • Migrate to user account later
    • Great for cart, preferences, etc.

Documentation

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

When to Use What

NeedUseImport From
Load related datagetOneFrom, getManyFromconvex-helpers/server/relationships
Auth in all functionscustomQueryconvex-helpers/server/customFunctions
Complex filtersfilterconvex-helpers/server/filter
Anonymous usersuseSessionIdconvex-helpers/react/sessions
Zod validationzCustomQueryconvex-helpers/server/zod
Data migrationsmakeMigrationconvex-helpers/server/migrations
TriggersTriggersconvex-helpers/server/triggers

Comments

Markdown supported