TinyKit Pro Docs

Convex Best Practices & AI Development Ruleset

This document serves as a comprehensive ruleset for AI assistants and developers working with Convex in TinyKit Pro. It combines the philosophy of Convex dev...

This document serves as a comprehensive ruleset for AI assistants and developers working with Convex in TinyKit Pro. It combines the philosophy of Convex development with practical best practices for building scalable, type-safe applications.

Table of Contents

  1. Core Philosophy
  2. Function Design Rules
  3. Database Optimization
  4. TypeScript Integration
  5. Security & Validation
  6. Performance Guidelines
  7. Code Organization
  8. Anti-Patterns to Avoid

Core Philosophy

The Zen of Convex

🎯 Primary Rule: Center your application around the deterministic, reactive database.

Essential Principles

DO:

  • ✅ Use queries for almost all app reads
  • ✅ Keep sync engine functions lightweight (under 100ms, few hundred records)
  • ✅ Leverage built-in Convex caching and consistency controls
  • ✅ Build custom solutions using Convex's primitive functions
  • ✅ Process work in smaller, incremental batches

DON'T:

  • ❌ Create unnecessary local cache layers
  • ❌ Use mutation return values for complex state changes
  • ❌ Invoke actions directly from browsers
  • ❌ Treat actions as background jobs

Development Workflow Philosophy

  1. Utilize the Dashboard: Actively use the Convex dashboard for development and debugging
  2. Community-First: Seek help from Convex community and Discord before creating custom solutions
  3. "Pit of Success": Follow Convex's opinionated design to naturally write performant code

Function Design Rules

🔧 Query Functions

// ✅ GOOD: Lightweight, indexed query
export const getTeamMessages = query({
  args: { teamId: v.id("teams") },
  handler: async (ctx, args) => {
    return await ctx.db
      .query("messages")
      .withIndex("by_team", (q) => q.eq("teamId", args.teamId))
      .order("desc")
      .take(50);
  },
});

// ❌ BAD: Heavy computation in query
export const getComplexAnalytics = query({
  handler: async (ctx) => {
    const allData = await ctx.db.query("data").collect(); // Too much data
    // Heavy computation here...
  },
});

Query Rules:

  • ✅ Use argument validators for all public queries
  • ✅ Implement access control checks
  • ✅ Keep under 100ms execution time
  • ✅ Limit result sets (use .take() or pagination)
  • ✅ Use indexes with .withIndex() instead of .filter()

🔄 Mutation Functions

// ✅ GOOD: Granular, validated mutation
export const updateTeamSettings = mutation({
  args: {
    teamId: v.id("teams"),
    settings: v.object({
      name: v.optional(v.string()),
      description: v.optional(v.string()),
    }),
  },
  handler: async (ctx, args) => {
    // Access control first
    const hasPermission = await hasTeamRole(ctx, args.teamId, [
      "owner",
      "admin",
    ]);
    if (!hasPermission) {
      throw new ConvexError("Insufficient permissions");
    }

    // Granular update
    await ctx.db.patch(args.teamId, args.settings);
  },
});

// ❌ BAD: Broad, unvalidated mutation
export const updateEverything = mutation({
  args: { data: v.any() }, // Never use v.any()
  handler: async (ctx, args) => {
    // No access control
    // Broad, dangerous updates
  },
});

Mutation Rules:

  • ✅ Use granular functions over broad update functions
  • ✅ Implement argument validation with v validators
  • ✅ Check permissions before any data modification
  • ✅ Use helper functions for shared logic
  • ✅ Await all Promises to prevent unexpected behavior

🚀 Action Functions

// ✅ GOOD: Action for external API calls
export const sendEmailNotification = action({
  args: { userId: v.string(), message: v.string() },
  handler: async (ctx, args) => {
    // Get user data via mutation/query
    const user = await ctx.runQuery(api.users.queries.getById, {
      id: args.userId,
    });

    // External API call
    await sendEmail(user.email, args.message);

    // Update database via mutation
    await ctx.runMutation(api.notifications.mutations.markSent, {
      userId: args.userId,
      type: "email",
    });
  },
});

Action Rules:

  • ✅ Use for external API calls and non-deterministic operations
  • ✅ Chain actions with mutations for trackable progress
  • ✅ Use ctx.runQuery and ctx.runMutation sparingly
  • ✅ Avoid sequential database calls when possible
  • ❌ Don't invoke directly from browser clients

Database Optimization

🗄️ Query Optimization Rules

Critical Performance Rules:

// ✅ GOOD: Use indexes for efficient querying
export const getTeamMembers = query({
  args: { teamId: v.id("teams") },
  handler: async (ctx, args) => {
    return await ctx.db
      .query("teamMembers")
      .withIndex("by_team", (q) => q.eq("teamId", args.teamId))
      .collect();
  },
});

// ❌ BAD: Using filter instead of index
export const getTeamMembersBad = query({
  args: { teamId: v.id("teams") },
  handler: async (ctx, args) => {
    return await ctx.db
      .query("teamMembers")
      .filter((q) => q.eq(q.field("teamId"), args.teamId)) // Slow!
      .collect();
  },
});

Index Strategy

DO:

  • ✅ Create indexes for all common query patterns
  • ✅ Use compound indexes for multi-field queries
  • ✅ Remove redundant indexes after analysis

DON'T:

  • ❌ Use .filter() on database queries for primary access patterns
  • ❌ Use .collect() with large result sets
  • ❌ Create unnecessary indexes that slow down writes

Data Modeling Rules

// ✅ GOOD: Denormalized for read performance
teams: defineTable({
  name: v.string(),
  slug: v.string(),
  memberCount: v.number(), // Denormalized for quick access
  ownerName: v.string(), // Denormalized for listings
  // ... other fields
}).index("by_slug", ["slug"]);

// ✅ GOOD: Separate table for frequently queried relations
teamMembers: defineTable({
  teamId: v.id("teams"),
  userId: v.string(),
  role: v.union(UserRoleValidator),
  joinedAt: v.number(),
})
  .index("by_team", ["teamId"])
  .index("by_user", ["userId"])
  .index("by_team_and_user", ["teamId", "userId"]);

TypeScript Integration

🏗️ Type Safety Rules

Essential TypeScript Patterns:

// ✅ GOOD: Proper typing with generated types
import type { Doc, Id } from "@/convex/_generated/dataModel";
import type { QueryCtx, MutationCtx } from "@/convex/_generated/server";

// Helper function with proper context typing
export async function getTeamWithPermissions(
  ctx: QueryCtx,
  teamId: Id<"teams">,
  userId: Id<"users">,
): Promise<Doc<"teams"> & { userRole: string | null }> {
  const team = await ctx.db.get(teamId);
  if (!team) throw new ConvexError("Team not found");

  const membership = await ctx.db
    .query("teamMembers")
    .withIndex("by_team_and_user", (q) =>
      q.eq("teamId", teamId).eq("userId", userId),
    )
    .first();

  return { ...team, userRole: membership?.role ?? null };
}

// ✅ GOOD: Using Infer for type conversion
const createTeamArgs = {
  name: v.string(),
  description: v.optional(v.string()),
};
type CreateTeamArgs = Infer<typeof createTeamArgs>;

Schema and Validation Rules

DO:

  • ✅ Define comprehensive schemas for type inference
  • ✅ Use generated types (Doc, Id) consistently
  • ✅ Implement argument validators for all public functions
  • ✅ Use WithoutSystemFields for create/update operations
  • ✅ Leverage context types (QueryCtx, MutationCtx, ActionCtx)

DON'T:

  • ❌ Use any type (forbidden in TinyKit Pro)
  • ❌ Skip type annotations on complex internal functions
  • ❌ Ignore TypeScript errors or warnings

Client-Side Integration

// ✅ GOOD: Type-safe React component
import type { Id } from "@/convex/_generated/dataModel";
import { useQuery } from "convex/react";
import { api } from "@/convex/_generated/api";

interface TeamDashboardProps {
  teamId: Id<"teams">;
}

export function TeamDashboard({ teamId }: TeamDashboardProps) {
  const team = useQuery(api.teams.queries.getById, { id: teamId });
  const messages = useQuery(api.messages.queries.getTeamMessages, { teamId });

  if (!team) return <LoadingSpinner />;

  return (
    <div>
      <h1>{team.name}</h1>
      {/* Type-safe access to team properties */}
    </div>
  );
}

Security & Validation

🔒 Security Rules (Critical Priority)

Modern Access Control with requireAccess:

// ✅ GOOD: Comprehensive permission checking with requireAccess
// In notifications/private/mutations.ts
import { mutation } from "../../_generated/server";
import { requireAccess, requireRateLimit } from "../../lib/access";
import { v } from "convex/values";

export const updateNotificationSettings = mutation({
  args: {
    emailEnabled: v.boolean(),
    pushEnabled: v.boolean(),
  },
  handler: async (ctx, args) => {
    // 1. Enforce authentication and get user context
    const { userId } = await requireAccess(ctx, {
      userRole: ["user"],
    });

    // 2. Rate limit to prevent abuse (5 updates/min with burst capacity of 10)
    await requireRateLimit(ctx, "profileUpdates", { key: userId });

    // 3. Validate input
    if (args.emailEnabled === undefined || args.pushEnabled === undefined) {
      throw new Error("Invalid settings");
    }

    // 4. Perform update
    await ctx.db.patch(userId, {
      emailNotifications: args.emailEnabled,
      pushNotifications: args.pushEnabled,
    });

    return { success: true };
  },
});

// ✅ GOOD: Organization-scoped permission checking
// In orgs/private/mutations.ts
export const updateOrganizationSettings = mutation({
  args: {
    orgId: v.string(),
    name: v.optional(v.string()),
    description: v.optional(v.string()),
  },
  handler: async (ctx, args) => {
    // Check user has org admin/owner role
    const { userId, orgRole } = await requireAccess(ctx, {
      orgId: args.orgId,
      orgRole: ["owner", "admin"], // Array allows either role
    });

    // Validate org exists
    const org = await ctx.db.get(args.orgId);
    if (!org) {
      throw new Error("Organization not found");
    }

    // Build update object
    const updates: Partial<{ name: string; description: string }> = {};
    if (args.name !== undefined) updates.name = args.name;
    if (args.description !== undefined) updates.description = args.description;

    if (Object.keys(updates).length > 0) {
      await ctx.db.patch(args.orgId, updates);
    }

    return { success: true };
  },
});

// ✅ GOOD: Custom condition for complex requirements
// In billing/private/mutations.ts
export const cancelSubscription = mutation({
  args: { subscriptionId: v.string() },
  handler: async (ctx, args) => {
    const { userId } = await requireAccess(ctx, {
      userRole: ["user"],
      condition: async (accessCtx) => {
        // Custom validation: user owns this subscription
        const sub = await ctx.db.get(args.subscriptionId);
        return sub?.userId === accessCtx.userId;
      },
    });

    // Proceed with cancellation
    await ctx.db.patch(args.subscriptionId, {
      cancelAtPeriodEnd: true,
    });

    return { success: true };
  },
});

Validation Rules

Required Validations:

  • ✅ Authentication check using requireAccess() for all protected functions
  • ✅ Authorization check based on roles/ownership/permissions
  • ✅ Input validation using v validators in args
  • ✅ Business logic validation (length limits, format checks)
  • ✅ Cross-reference validation (entity relationships)
  • ✅ Rate limiting using requireRateLimit() for mutations

Rate Limiting Pattern

Protect mutations from abuse with rate limiting:

import { requireAccess, requireRateLimit } from "../../lib/access";

// ✅ GOOD: Rate-limited mutation
export const createPost = mutation({
  args: {
    title: v.string(),
    content: v.string(),
  },
  handler: async (ctx, args) => {
    const { userId } = await requireAccess(ctx, { userRole: ["user"] });

    // Rate limit: 20 posts per minute with burst capacity of 30
    await requireRateLimit(ctx, "contentCreation", { key: userId });

    // Proceed with post creation
    return await ctx.db.insert("posts", {
      title: args.title,
      content: args.content,
      userId,
    });
  },
});

// ✅ GOOD: Custom token consumption for variable-cost operations
export const generateAIContent = mutation({
  args: {
    prompt: v.string(),
    complexity: v.number(), // 1-10 scale
  },
  handler: async (ctx, args) => {
    const { userId } = await requireAccess(ctx, { userRole: ["user"] });

    // Consume tokens based on complexity (50/hour with burst of 100)
    await requireRateLimit(ctx, "externalApiCalls", {
      key: userId,
      count: args.complexity, // Variable token consumption
    });

    // Call external AI API
    return await generateContent(args.prompt);
  },
});

// ✅ GOOD: Check rate limit without throwing
export const checkUploadAvailability = query({
  handler: async (ctx) => {
    const { userId } = await requireAccess(ctx, { userRole: ["user"] });

    // Check if user can upload without consuming tokens
    const { ok, retryAfter } = await requireRateLimit(ctx, "fileUploads", {
      key: userId,
      throws: false, // Don't throw on limit exceeded
    });

    return {
      canUpload: ok,
      retryAfter: retryAfter || null,
    };
  },
});

Pre-configured Rate Limits:

  • authAttempts - 10/hour (fixed window) - Authentication operations
  • profileUpdates - 5/min, burst 10 (token bucket) - Profile changes
  • contentCreation - 20/min, burst 30 (token bucket) - Posts, comments, messages
  • adminOperations - 100/hour, burst 150 (token bucket) - Admin actions
  • externalApiCalls - 50/hour, burst 100 (token bucket) - Third-party APIs
  • fileUploads - 10/hour, burst 20 (token bucket) - File storage
  • searchQueries - 30/min, burst 50 (token bucket) - Search operations

Security Checklist

For Every Private Function:

  • Use requireAccess() for authentication verification
  • Specify appropriate role requirements (userRole or orgRole)
  • Add rate limiting for mutations with requireRateLimit()
  • Input validation with proper v validators
  • Business logic validation (length, format, existence checks)
  • Audit logging for sensitive operations (optional)

Modular Access Control System

🎯 Core Access Control Functions

TinyKit Pro uses a modular, policy-based RBAC system:

import { requireAccess, hasAccess } from "../../lib/access";

// Primary functions:
// - requireAccess() - Enforces access requirements, throws on failure
// - hasAccess() - Checks access requirements, returns boolean

Access Control Patterns

1. User Role-Based Access:

// Single role requirement
const { userId } = await requireAccess(ctx, {
  userRole: "admin",
});

// Multiple allowed roles
const { userId } = await requireAccess(ctx, {
  userRole: ["admin"], // Admin role required
});

// Role hierarchy: admin >= user

2. Organization Role-Based Access:

// Organization-scoped access
const { userId, orgRole } = await requireAccess(ctx, {
  orgId: args.orgId,
  orgRole: ["owner", "admin"],
});

// Org role hierarchy: owner >= admin >= member

3. Permission-Based Access:

// Check specific permission
const { userId } = await requireAccess(ctx, {
  permission: "users:delete", // Format: "resource:action"
});

// Permissions are defined in convex/lib/permissions.ts

4. Access Level-Based Access (Subscription Tiers):

// Personal subscription access level
const { userId } = await requireAccess(ctx, {
  minPersonalAccessLevel: 2, // 0=free, 1=basic, 2=pro, 3=enterprise
});

// Organization subscription access level
const { userId } = await requireAccess(ctx, {
  orgId: args.orgId,
  minOrgAccessLevel: 2, // Pro tier or higher
});

5. Custom Condition-Based Access:

// Complex business logic validation
const { userId } = await requireAccess(ctx, {
  userRole: ["user"],
  condition: async (accessCtx) => {
    // Custom validation logic
    const resource = await ctx.db.get(args.resourceId);
    return resource?.userId === accessCtx.userId;
  },
});

6. Combined Requirements:

// Multiple requirements (all must pass)
const { userId, orgRole } = await requireAccess(ctx, {
  userRole: ["user"], // Must be authenticated
  orgId: args.orgId, // Must be org member
  orgRole: ["admin", "owner"], // Must have admin/owner role
  minOrgAccessLevel: 1, // Org must have paid subscription
  permission: "orgs:settings:update", // Must have permission
});

Access Context Return Value

requireAccess returns comprehensive context:

interface AccessContext {
  userId: Id<"users">; // Guaranteed non-null
  user: Doc<"users">; // Full user document
  userRole: string; // User's site-wide role
  userPermissions: string[]; // User's permission array
  personalAccessLevel: number; // User's subscription level

  // Organization context (if orgId provided)
  orgRole?: string | null; // User's role in this org
  orgPermissions?: string[]; // Org role permissions
  orgAccessLevel?: number | null; // Org subscription level
}

// Usage example:
const { userId, user, userRole, orgRole, personalAccessLevel } =
  await requireAccess(ctx, {
    userRole: ["user"],
    orgId: args.orgId,
  });

Boolean Access Checking

Use hasAccess for conditional logic:

// Check without throwing
const isAdmin = await hasAccess(ctx, {
  userRole: ["admin"],
});

if (isAdmin) {
  // Show admin features
} else {
  // Show regular user features
}

// Useful for optional features
const canAccessPremium = await hasAccess(ctx, {
  minPersonalAccessLevel: 2,
});

Import Patterns

// In Convex backend functions
import { requireAccess, hasAccess } from "../../lib/access";
import { requireRateLimit } from "../../lib/access"; // Rate limiting

// In frontend (utility functions only)
import {
  getUserRolePermissions,
  getOrgRolePermissions,
  checkPermission,
} from "@/convex/lib/access";

Performance Guidelines

⚡ Performance Optimization Rules

Database Performance:

// ✅ GOOD: Efficient pagination
export const getTeamMessagesPaginated = query({
  args: {
    teamId: v.id("teams"),
    cursor: v.optional(v.string()),
    limit: v.optional(v.number()),
  },
  handler: async (ctx, args) => {
    const limit = Math.min(args.limit ?? 20, 100); // Cap at 100

    let query = ctx.db
      .query("messages")
      .withIndex("by_team_and_time", (q) => q.eq("teamId", args.teamId));

    if (args.cursor) {
      query = query.filter((q) => q.lt(q.field("_creationTime"), args.cursor));
    }

    const messages = await query.order("desc").take(limit + 1);
    const hasMore = messages.length > limit;

    return {
      messages: messages.slice(0, limit),
      nextCursor: hasMore ? messages[limit]._creationTime : null,
    };
  },
});

// ❌ BAD: Loading everything at once
export const getAllMessages = query({
  handler: async (ctx) => {
    return await ctx.db.query("messages").collect(); // Potentially huge!
  },
});

Runtime Optimization:

  • ✅ Use runAction only when requiring different runtime
  • ✅ Batch database operations when possible
  • ✅ Minimize sequential ctx.runMutation/ctx.runQuery calls
  • ✅ Cache expensive computations in helper functions
  • ✅ Use appropriate data structures (Maps, Sets) for lookups

Code Organization

📁 File Structure Patterns

Follow TinyKit Pro Three-Tier Domain Organization:

convex/
├── users/                   # User management module
│   ├── public/             # Public endpoints (no auth required)
│   │   └── queries.ts      # Public user queries
│   ├── private/            # Private endpoints (auth required)
│   │   ├── queries.ts      # Authenticated user queries
│   │   └── mutations.ts    # Authenticated user mutations
│   ├── internal/           # Internal-only functions
│   │   ├── queries.ts      # Backend-only queries
│   │   ├── mutations.ts    # Backend-only mutations
│   │   └── actions.ts      # Backend-only actions
│   ├── schema.ts           # Module-specific schema
│   ├── validators.ts       # Validation schemas
│   └── helpers.ts          # Shared helper functions
├── orgs/                    # Organization management
│   ├── public/
│   ├── private/
│   ├── internal/
│   ├── schema.ts
│   └── helpers.ts
├── billing/                 # Stripe integration
│   ├── public/
│   ├── private/
│   └── internal/
├── notifications/           # Notification system
│   ├── private/
│   └── internal/
├── lib/                     # Shared utilities
│   ├── access/             # Modular access control system
│   │   ├── index.ts        # Main exports
│   │   ├── requireAccess.ts # Core access functions
│   │   ├── types.ts        # Type definitions
│   │   └── utils.ts        # Helper utilities
│   ├── triggers/           # Database triggers
│   ├── rateLimiter.ts      # Rate limiting system
│   ├── permissions.ts      # Permission definitions
│   ├── logger.ts           # Logging utilities
│   ├── resend.ts           # Email utilities
│   └── timezone.ts         # Timezone helpers
└── schema.ts               # Main database schema

Three-Tier Access Pattern Benefits:

  • Clear Authentication Boundaries: Folder structure immediately shows auth requirements
  • Enhanced Security: Reduced attack surface with minimal public API exposure
  • Improved Auditing: Easy to identify and track all public vs authenticated endpoints
  • Better Organization: Functions grouped by access level rather than type
  • Developer Clarity: Import path indicates authentication requirements

Function Organization Rules

Three-Tier Pattern with Modular Access Control:

// ✅ GOOD: Private mutation with requireAccess
// In orgs/private/mutations.ts
import { mutation } from "../../_generated/server";
import { requireAccess } from "../../lib/access";
import { v } from "convex/values";

export const createOrganization = mutation({
  args: {
    name: v.string(),
    slug: v.optional(v.string()),
  },
  handler: async (ctx, args) => {
    // Enforce authentication and authorization
    const { userId } = await requireAccess(ctx, {
      userRole: ["user"], // Any authenticated user can create org
    });

    return await createOrganizationHelper(ctx, userId, args);
  },
});

// ✅ GOOD: Public query (no auth required)
// In orgs/public/queries.ts
import { query } from "../../_generated/server";
import { v } from "convex/values";

export const getOrganizationBySlug = query({
  args: { slug: v.string() },
  handler: async (ctx, args) => {
    // No requireAccess - this is public data
    return await ctx.db
      .query("orgs")
      .withIndex("by_slug", (q) => q.eq("slug", args.slug))
      .first();
  },
});

// ✅ GOOD: Internal action (backend only)
// In orgs/internal/actions.ts
import { internalAction } from "../../_generated/server";
import { v } from "convex/values";

export const sendOrganizationWelcomeEmail = internalAction({
  args: {
    orgId: v.string(),
    userId: v.string(),
  },
  handler: async (ctx, args) => {
    // Only callable from other Convex functions
    // No requireAccess needed - already internal
    const org = await ctx.runQuery(internal.orgs.getOrgById, {
      orgId: args.orgId,
    });

    // Send email via external API
    await sendEmail(/* ... */);
  },
});

// In orgs/helpers.ts
import { MutationCtx } from "../_generated/server";
import { Id } from "../_generated/dataModel";

export async function createOrganizationHelper(
  ctx: MutationCtx,
  userId: Id<"users">,
  args: { name: string; slug?: string },
): Promise<Id<"orgs">> {
  // Business logic extracted to helper
  const slug = args.slug || generateSlug(args.name);

  // Check slug availability
  const existingOrg = await ctx.db
    .query("orgs")
    .withIndex("by_slug", (q) => q.eq("slug", slug))
    .first();

  if (existingOrg) {
    throw new Error("Organization name already taken");
  }

  // Create organization
  const orgId = await ctx.db.insert("orgs", {
    name: args.name,
    slug,
    createdBy: userId,
  });

  // Add creator as owner
  await ctx.db.insert("orgMembers", {
    orgId,
    userId,
    orgRole: "owner",
  });

  return orgId;
}

Admin Function Pattern

Admin functions use the modular access control system:

// ✅ GOOD: Admin query with role hierarchy
// In users/private/queries.ts
import { query } from "../../_generated/server";
import { requireAccess } from "../../lib/access";
import { v } from "convex/values";

export const listUsers = query({
  args: {
    paginationOpts: v.optional(
      v.object({
        numItems: v.number(),
        cursor: v.union(v.string(), v.null()),
      }),
    ),
  },
  handler: async (ctx, args) => {
    // Role hierarchy: admin >= user
    await requireAccess(ctx, {
      userRole: ["admin"],
    });

    return await ctx.db
      .query("users")
      .order("desc")
      .paginate(args.paginationOpts ?? { numItems: 10, cursor: null });
  },
});

// ✅ GOOD: Admin only mutation
// In users/private/mutations.ts
export const updateUserRole = mutation({
  args: {
    userId: v.string(),
    newRole: v.union(v.literal("admin"), v.literal("user")),
  },
  handler: async (ctx, args) => {
    // Only admin can modify roles
    await requireAccess(ctx, {
      userRole: ["admin"],
    });

    await ctx.db.patch(args.userId, {
      userRole: args.newRole,
    });

    return { success: true };
  },
});

// ✅ GOOD: Permission-based access
// In users/private/mutations.ts
export const deleteUser = mutation({
  args: { userId: v.string() },
  handler: async (ctx, args) => {
    // Check specific permission
    await requireAccess(ctx, {
      permission: "users:delete",
    });

    // Soft delete pattern
    await ctx.db.patch(args.userId, {
      deletedAt: Date.now(),
    });

    return { success: true };
  },
});

Anti-Patterns to Avoid

🚫 Critical Anti-Patterns

Database Anti-Patterns:

// ❌ NEVER: Sequential database calls in loops
for (const userId of userIds) {
  const user = await ctx.db.get(userId); // Bad!
  // Process user...
}

// ✅ GOOD: Batch operations
const users = await Promise.all(userIds.map((id) => ctx.db.get(id)));

Type Safety Anti-Patterns:

// ❌ NEVER: Using any type
args: {
  data: v.any();
}

// ❌ NEVER: Ignoring null checks
const user = await ctx.db.get(userId);
user.name; // Potential runtime error!

// ✅ GOOD: Proper null handling
const user = await ctx.db.get(userId);
if (!user) throw new ConvexError("User not found");

Security Anti-Patterns:

// ❌ NEVER: Missing access control checks
export const deleteUser = mutation({
  args: { userId: v.string() },
  handler: async (ctx, args) => {
    await ctx.db.delete(args.userId); // Anyone can delete!
  },
});

// ✅ GOOD: Always use requireAccess
export const deleteUser = mutation({
  args: { userId: v.string() },
  handler: async (ctx, args) => {
    await requireAccess(ctx, {
      userRole: ["admin"],
      permission: "users:delete",
    });

    await ctx.db.delete(args.userId);
  },
});

// ❌ NEVER: Manual authentication checks
export const badAuth = mutation({
  args: {},
  handler: async (ctx, args) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) throw new Error("Not authenticated");
    // Manual, error-prone approach
  },
});

// ✅ GOOD: Use requireAccess for all auth
export const goodAuth = mutation({
  args: {},
  handler: async (ctx, args) => {
    const { userId } = await requireAccess(ctx, { userRole: ["user"] });
    // Clean, consistent, and comprehensive
  },
});

// ❌ NEVER: Client-side security
// Relying on UI to hide admin features without server-side checks

// ❌ NEVER: Missing rate limiting on mutations
export const createPost = mutation({
  args: { content: v.string() },
  handler: async (ctx, args) => {
    const { userId } = await requireAccess(ctx, { userRole: ["user"] });
    // Missing rate limit - vulnerable to spam!
    await ctx.db.insert("posts", { userId, content: args.content });
  },
});

// ✅ GOOD: Always rate limit mutations
export const createPost = mutation({
  args: { content: v.string() },
  handler: async (ctx, args) => {
    const { userId } = await requireAccess(ctx, { userRole: ["user"] });
    await requireRateLimit(ctx, "contentCreation", { key: userId });
    await ctx.db.insert("posts", { userId, content: args.content });
  },
});

Performance Anti-Patterns:

// ❌ NEVER: Heavy computation in queries
export const expensiveCalculation = query({
  handler: async (ctx) => {
    const data = await ctx.db.query("largeTable").collect();
    return data.reduce((acc, item) => {
      // Heavy computation...
    }, {});
  },
});

// ❌ NEVER: Unnecessary external API calls in queries
export const getWeatherData = query({
  handler: async (ctx) => {
    const response = await fetch("https://api.weather.com"); // Wrong!
    return response.json();
  },
});

Quick Reference Checklist

Before Writing Any Function

  • Determine correct access tier (public/private/internal)
  • Choose correct function type (query/mutation/action)
  • Add proper v argument validators
  • Use requireAccess() for all private functions
  • Add requireRateLimit() for mutations that modify data
  • Consider database index requirements
  • Plan for type safety and error handling

Three-Tier Function Placement

Decision Tree

  • NO AUTH REQUIREDmodule/public/ - Landing pages, public data, sign-up flows
  • AUTH REQUIREDmodule/private/ - User operations, dashboard, settings
  • BACKEND ONLYmodule/internal/ - Email sending, webhooks, cross-module calls

Security Checklist (Every Private Function)

  • Use requireAccess() at the start of handler
  • Specify appropriate role requirements (userRole or orgRole)
  • Add requireRateLimit() for mutations
  • Validate all inputs with v validators
  • Check ownership/permissions for resource access
  • Never trust client-provided data

Before Deploying Changes

  • Run bun lint (must pass with zero errors)
  • Run bun typecheck (must pass with zero errors)
  • Test all permission scenarios (user, admin, org roles)
  • Verify rate limits work correctly
  • Verify database query performance (check indexes)
  • Check function execution times in Convex dashboard
  • Test with production-like data volumes

Code Review Checklist

  • No usage of any type
  • All functions have v argument validators
  • All private functions use requireAccess()
  • Mutations use requireRateLimit() where appropriate
  • Functions organized in correct access tier (public/private/internal)
  • Efficient database queries with .withIndex()
  • Proper error handling with descriptive messages
  • Follows three-tier architecture pattern
  • Uses modular access control system
  • Imports from correct paths (../../lib/access)

On this page

Ship your startup faster. In minutes.

Get TinyKit Pro