S
Solo Kit
DocumentationComponentsPricingChangelogRoadmapFAQContact
LoginGet Started
DocumentationComponentsPricing
LoginGet Started
Welcome to Solo Kit DocumentationIntroductionTech StackRoadmapFAQGetting Started
Database OverviewSchema DesignDatabase QueriesDatabase MigrationsDatabase SeedingDatabase PerformanceDatabase SecurityBackup & RestoreDatabase MonitoringAdvanced Database Features
Database

Database Queries

Database Queries

Master type-safe database operations with Convex. This guide covers everything from basic CRUD operations to complex queries and performance optimization.

Query Fundamentals

Basic Query Structure

Convex provides a fully typed API with TypeScript support:

// convex/users.ts
import { v } from 'convex/values';
import { query, mutation } from './_generated/server';

// Select all users
export const getAllUsers = query({
  handler: async (ctx) => {
    return await ctx.db.query('users').collect();
  },
});

// Select specific user by ID
export const getUserById = query({
  args: { id: v.id('users') },
  handler: async (ctx, args) => {
    return await ctx.db.get(args.id);
  },
});

// Select with conditions using index
export const getActiveAdmins = query({
  handler: async (ctx) => {
    return await ctx.db
      .query('users')
      .withIndex('by_role', (q) => q.eq('role', 'admin'))
      .filter((q) => q.eq(q.field('isActive'), true))
      .collect();
  },
});

Type Safety

Convex ensures complete type safety across all operations:

// Type-safe - TypeScript knows the exact shape
export const getUser = query({
  args: { userId: v.id('users') },
  handler: async (ctx, args) => {
    const user = await ctx.db.get(args.userId);
    // user: Doc<"users"> | null - fully typed!
    return user;
  },
});

// Compile-time error prevention
export const badQuery = query({
  args: { userId: v.id('users') },
  handler: async (ctx, args) => {
    const user = await ctx.db.get(args.userId);
    // TypeScript error: 'nonExistentField' does not exist
    // return user?.nonExistentField;
  },
});

CRUD Operations

Create (Insert)

// convex/users.ts
import { v } from 'convex/values';
import { mutation } from './_generated/server';

// Insert single record
export const createUser = mutation({
  args: {
    email: v.string(),
    name: v.string(),
    role: v.optional(v.union(v.literal('admin'), v.literal('user'))),
  },
  handler: async (ctx, args) => {
    const userId = await ctx.db.insert('users', {
      email: args.email,
      name: args.name,
      role: args.role ?? 'user',
      createdAt: Date.now(),
      updatedAt: Date.now(),
    });

    return userId; // Returns the new document ID
  },
});

// Insert with conflict handling (upsert pattern)
export const upsertUser = mutation({
  args: {
    email: v.string(),
    name: v.string(),
  },
  handler: async (ctx, args) => {
    const existing = await ctx.db
      .query('users')
      .withIndex('by_email', (q) => q.eq('email', args.email))
      .first();

    if (existing) {
      // Update existing user
      await ctx.db.patch(existing._id, {
        name: args.name,
        updatedAt: Date.now(),
      });
      return existing._id;
    } else {
      // Create new user
      return await ctx.db.insert('users', {
        email: args.email,
        name: args.name,
        role: 'user',
        createdAt: Date.now(),
        updatedAt: Date.now(),
      });
    }
  },
});

Read (Select)

// convex/users.ts
import { v } from 'convex/values';
import { query } from './_generated/server';

// Select all
export const getAllUsers = query({
  handler: async (ctx) => {
    return await ctx.db.query('users').collect();
  },
});

// Select with index condition
export const getAdminUsers = query({
  handler: async (ctx) => {
    return await ctx.db
      .query('users')
      .withIndex('by_role', (q) => q.eq('role', 'admin'))
      .collect();
  },
});

// Select with multiple conditions
export const getActiveAdmins = query({
  handler: async (ctx) => {
    return await ctx.db
      .query('users')
      .withIndex('by_role', (q) => q.eq('role', 'admin'))
      .filter((q) => q.eq(q.field('isActive'), true))
      .collect();
  },
});

// Select with ordering
export const getRecentUsers = query({
  args: { limit: v.optional(v.number()) },
  handler: async (ctx, args) => {
    return await ctx.db
      .query('users')
      .order('desc') // Orders by _creationTime
      .take(args.limit ?? 10);
  },
});

// Select with pagination
export const getPaginatedUsers = query({
  args: {
    page: v.number(),
    pageSize: v.optional(v.number()),
  },
  handler: async (ctx, args) => {
    const pageSize = args.pageSize ?? 20;
    const users = await ctx.db.query('users').order('desc').collect();

    const start = (args.page - 1) * pageSize;
    const end = start + pageSize;

    return {
      users: users.slice(start, end),
      totalPages: Math.ceil(users.length / pageSize),
      currentPage: args.page,
    };
  },
});

Update

// convex/users.ts
import { v } from 'convex/values';
import { mutation } from './_generated/server';

// Update single record
export const updateUser = mutation({
  args: {
    id: v.id('users'),
    name: v.optional(v.string()),
    email: v.optional(v.string()),
  },
  handler: async (ctx, args) => {
    const { id, ...updates } = args;

    await ctx.db.patch(id, {
      ...updates,
      updatedAt: Date.now(),
    });

    return await ctx.db.get(id);
  },
});

// Update multiple records
export const deactivateInactiveUsers = mutation({
  args: { inactiveDays: v.number() },
  handler: async (ctx, args) => {
    const cutoff = Date.now() - args.inactiveDays * 24 * 60 * 60 * 1000;

    const inactiveUsers = await ctx.db
      .query('users')
      .filter((q) => q.and(q.eq(q.field('isActive'), true), q.lt(q.field('lastLoginAt'), cutoff)))
      .collect();

    for (const user of inactiveUsers) {
      await ctx.db.patch(user._id, {
        isActive: false,
        updatedAt: Date.now(),
      });
    }

    return inactiveUsers.length;
  },
});

// Replace entire document
export const replaceUser = mutation({
  args: {
    id: v.id('users'),
    data: v.object({
      email: v.string(),
      name: v.string(),
      role: v.union(v.literal('admin'), v.literal('user')),
    }),
  },
  handler: async (ctx, args) => {
    await ctx.db.replace(args.id, {
      ...args.data,
      createdAt: Date.now(),
      updatedAt: Date.now(),
    });
  },
});

Delete

// convex/users.ts
import { v } from 'convex/values';
import { mutation } from './_generated/server';

// Delete single record
export const deleteUser = mutation({
  args: { id: v.id('users') },
  handler: async (ctx, args) => {
    await ctx.db.delete(args.id);
  },
});

// Delete with conditions
export const deleteOldUsers = mutation({
  args: { olderThanDays: v.number() },
  handler: async (ctx, args) => {
    const cutoff = Date.now() - args.olderThanDays * 24 * 60 * 60 * 1000;

    const oldUsers = await ctx.db
      .query('users')
      .filter((q) => q.and(q.eq(q.field('isActive'), false), q.lt(q.field('createdAt'), cutoff)))
      .collect();

    const deletedIds = [];
    for (const user of oldUsers) {
      await ctx.db.delete(user._id);
      deletedIds.push(user._id);
    }

    return { deletedCount: deletedIds.length, deletedIds };
  },
});

Advanced Queries

Related Data (Joins)

// convex/users.ts
// Fetch user with related data
export const getUserWithProfile = query({
  args: { userId: v.id('users') },
  handler: async (ctx, args) => {
    const user = await ctx.db.get(args.userId);
    if (!user) return null;

    const profile = await ctx.db
      .query('userProfiles')
      .withIndex('by_userId', (q) => q.eq('userId', args.userId))
      .first();

    const posts = await ctx.db
      .query('posts')
      .withIndex('by_userId', (q) => q.eq('userId', args.userId))
      .filter((q) => q.eq(q.field('status'), 'published'))
      .order('desc')
      .take(5);

    return {
      ...user,
      profile,
      recentPosts: posts,
    };
  },
});

// Multiple related records
export const getUsersWithSubscriptions = query({
  handler: async (ctx) => {
    const users = await ctx.db.query('users').collect();

    const usersWithSubs = await Promise.all(
      users.map(async (user) => {
        const subscription = await ctx.db
          .query('subscriptions')
          .withIndex('by_userId', (q) => q.eq('userId', user._id))
          .first();

        return {
          ...user,
          subscription,
        };
      })
    );

    return usersWithSubs;
  },
});

Aggregations

// convex/analytics.ts
import { query } from './_generated/server';

// Count records
export const getUserCount = query({
  handler: async (ctx) => {
    const users = await ctx.db.query('users').collect();
    return users.length;
  },
});

// Count with conditions
export const getActiveUserCount = query({
  handler: async (ctx) => {
    const activeUsers = await ctx.db
      .query('users')
      .filter((q) => q.eq(q.field('isActive'), true))
      .collect();

    return activeUsers.length;
  },
});

// Group by with aggregations
export const getPostsByStatus = query({
  handler: async (ctx) => {
    const posts = await ctx.db.query('posts').collect();

    const statusCounts = posts.reduce(
      (acc, post) => {
        acc[post.status] = (acc[post.status] || 0) + 1;
        return acc;
      },
      {} as Record<string, number>
    );

    const viewStats = {
      totalViews: posts.reduce((sum, p) => sum + (p.viewCount || 0), 0),
      avgViews:
        posts.length > 0 ? posts.reduce((sum, p) => sum + (p.viewCount || 0), 0) / posts.length : 0,
      maxViews: Math.max(...posts.map((p) => p.viewCount || 0)),
    };

    return { statusCounts, viewStats };
  },
});

// Complex aggregation
export const getProlificAuthors = query({
  args: { minPosts: v.number() },
  handler: async (ctx, args) => {
    const posts = await ctx.db.query('posts').collect();

    // Group posts by author
    const postsByAuthor = posts.reduce(
      (acc, post) => {
        const authorId = post.userId.toString();
        if (!acc[authorId]) {
          acc[authorId] = [];
        }
        acc[authorId].push(post);
        return acc;
      },
      {} as Record<string, typeof posts>
    );

    // Filter authors with minimum posts
    const prolificAuthorIds = Object.entries(postsByAuthor)
      .filter(([_, authorPosts]) => authorPosts.length >= args.minPosts)
      .map(([authorId]) => authorId);

    // Fetch author details
    const authors = await Promise.all(
      prolificAuthorIds.map(async (authorId) => {
        const user = await ctx.db.get(authorId as any);
        return {
          user,
          postCount: postsByAuthor[authorId].length,
        };
      })
    );

    return authors.filter((a) => a.user !== null);
  },
});

Complex Filtering

Multiple Conditions

// convex/search.ts
import { v } from 'convex/values';
import { query } from './_generated/server';

// Complex AND/OR conditions
export const searchUsers = query({
  args: {
    role: v.optional(v.union(v.literal('admin'), v.literal('user'))),
    isActive: v.optional(v.boolean()),
    searchTerm: v.optional(v.string()),
  },
  handler: async (ctx, args) => {
    let users = await ctx.db.query('users').collect();

    // Apply filters
    if (args.role) {
      users = users.filter((u) => u.role === args.role);
    }

    if (args.isActive !== undefined) {
      users = users.filter((u) => u.isActive === args.isActive);
    }

    if (args.searchTerm) {
      const term = args.searchTerm.toLowerCase();
      users = users.filter(
        (u) => u.name.toLowerCase().includes(term) || u.email.toLowerCase().includes(term)
      );
    }

    return users;
  },
});

// Date range queries
export const getRecentUsers = query({
  args: {
    startDate: v.number(),
    endDate: v.number(),
  },
  handler: async (ctx, args) => {
    return await ctx.db
      .query('users')
      .filter((q) =>
        q.and(
          q.gte(q.field('createdAt'), args.startDate),
          q.lte(q.field('createdAt'), args.endDate)
        )
      )
      .collect();
  },
});

// Null checks
export const getUsersWithoutProfiles = query({
  handler: async (ctx) => {
    const users = await ctx.db.query('users').collect();

    const usersWithoutProfiles = await Promise.all(
      users.map(async (user) => {
        const profile = await ctx.db
          .query('userProfiles')
          .withIndex('by_userId', (q) => q.eq('userId', user._id))
          .first();

        return profile === null ? user : null;
      })
    );

    return usersWithoutProfiles.filter((u) => u !== null);
  },
});

Text Search

// convex/search.ts
// Case-insensitive search
export const searchPosts = query({
  args: { searchTerm: v.string() },
  handler: async (ctx, args) => {
    const posts = await ctx.db.query('posts').collect();

    const term = args.searchTerm.toLowerCase();

    return posts.filter(
      (post) =>
        post.title.toLowerCase().includes(term) || post.content?.toLowerCase().includes(term)
    );
  },
});

// Multiple word search
export const searchByTags = query({
  args: { tags: v.array(v.string()) },
  handler: async (ctx, args) => {
    const posts = await ctx.db.query('posts').collect();

    return posts.filter((post) => {
      const content = (post.content || '').toLowerCase();
      return args.tags.every((tag) => content.includes(tag.toLowerCase()));
    });
  },
});

Query Builders and Helpers

Reusable Query Functions

// convex/helpers/userQueries.ts
import { v } from 'convex/values';
import { query } from '../_generated/server';

// Find user by email
export const findByEmail = query({
  args: { email: v.string() },
  handler: async (ctx, args) => {
    return await ctx.db
      .query('users')
      .withIndex('by_email', (q) => q.eq('email', args.email))
      .first();
  },
});

// Find active users
export const findActive = query({
  handler: async (ctx) => {
    return await ctx.db
      .query('users')
      .filter((q) => q.eq(q.field('isActive'), true))
      .collect();
  },
});

// Find users by role
export const findByRole = query({
  args: { role: v.union(v.literal('user'), v.literal('admin')) },
  handler: async (ctx, args) => {
    return await ctx.db
      .query('users')
      .withIndex('by_role', (q) => q.eq('role', args.role))
      .collect();
  },
});

// Paginated users
export const findPaginated = query({
  args: {
    page: v.number(),
    pageSize: v.optional(v.number()),
  },
  handler: async (ctx, args) => {
    const pageSize = args.pageSize ?? 20;
    const users = await ctx.db.query('users').order('desc').collect();

    const start = (args.page - 1) * pageSize;

    return users.slice(start, start + pageSize);
  },
});

// Search users
export const search = query({
  args: { term: v.string() },
  handler: async (ctx, args) => {
    const users = await ctx.db.query('users').collect();
    const searchTerm = args.term.toLowerCase();

    return users.filter(
      (user) =>
        user.name.toLowerCase().includes(searchTerm) ||
        user.email.toLowerCase().includes(searchTerm)
    );
  },
});

Dynamic Query Building

// convex/users.ts
export const findUsersWithFilters = query({
  args: {
    role: v.optional(v.union(v.literal('user'), v.literal('admin'))),
    isActive: v.optional(v.boolean()),
    search: v.optional(v.string()),
    createdAfter: v.optional(v.number()),
    createdBefore: v.optional(v.number()),
  },
  handler: async (ctx, args) => {
    let users = await ctx.db.query('users').order('desc').collect();

    // Apply role filter
    if (args.role) {
      users = users.filter((u) => u.role === args.role);
    }

    // Apply active filter
    if (args.isActive !== undefined) {
      users = users.filter((u) => u.isActive === args.isActive);
    }

    // Apply search filter
    if (args.search) {
      const term = args.search.toLowerCase();
      users = users.filter(
        (u) => u.name.toLowerCase().includes(term) || u.email.toLowerCase().includes(term)
      );
    }

    // Apply date range filters
    if (args.createdAfter) {
      users = users.filter((u) => u.createdAt >= args.createdAfter!);
    }

    if (args.createdBefore) {
      users = users.filter((u) => u.createdAt <= args.createdBefore!);
    }

    return users;
  },
});

Performance Optimization

Using Indexes Effectively

// GOOD: Uses index for fast lookup
export const getUserByEmail = query({
  args: { email: v.string() },
  handler: async (ctx, args) => {
    return await ctx.db
      .query('users')
      .withIndex('by_email', (q) => q.eq('email', args.email))
      .first();
  },
});

// GOOD: Prepared-style query pattern
export const getUserPosts = query({
  args: {
    userId: v.id('users'),
    limit: v.optional(v.number()),
  },
  handler: async (ctx, args) => {
    return await ctx.db
      .query('posts')
      .withIndex('by_userId', (q) => q.eq('userId', args.userId))
      .order('desc')
      .take(args.limit ?? 10);
  },
});

Efficient Data Fetching

// GOOD: Parallel data fetching
export const getDashboardData = query({
  args: { userId: v.id('users') },
  handler: async (ctx, args) => {
    const [user, posts, subscriptions] = await Promise.all([
      ctx.db.get(args.userId),
      ctx.db
        .query('posts')
        .withIndex('by_userId', (q) => q.eq('userId', args.userId))
        .take(5),
      ctx.db
        .query('subscriptions')
        .withIndex('by_userId', (q) => q.eq('userId', args.userId))
        .collect(),
    ]);

    return { user, recentPosts: posts, subscriptions };
  },
});

// Batch operations
export const batchInsertUsers = mutation({
  args: {
    users: v.array(
      v.object({
        email: v.string(),
        name: v.string(),
      })
    ),
  },
  handler: async (ctx, args) => {
    const insertedIds = [];

    for (const userData of args.users) {
      const id = await ctx.db.insert('users', {
        ...userData,
        role: 'user',
        isActive: true,
        createdAt: Date.now(),
        updatedAt: Date.now(),
      });
      insertedIds.push(id);
    }

    return insertedIds;
  },
});

Query Security

Input Validation

import { v } from 'convex/values';
import { query } from './_generated/server';

// Convex validates input automatically
export const searchUsers = query({
  args: {
    term: v.string(), // Validated as string
    role: v.optional(v.union(v.literal('user'), v.literal('admin'))), // Enum validation
    limit: v.optional(v.number()), // Validated as number
  },
  handler: async (ctx, args) => {
    // Additional validation if needed
    const limit = Math.min(args.limit ?? 20, 100); // Cap at 100

    let users = await ctx.db.query('users').take(limit);

    if (args.role) {
      users = users.filter((u) => u.role === args.role);
    }

    if (args.term) {
      const term = args.term.toLowerCase();
      users = users.filter(
        (u) => u.name.toLowerCase().includes(term) || u.email.toLowerCase().includes(term)
      );
    }

    return users;
  },
});

Permission Checks

// convex/users.ts
import { v } from 'convex/values';
import { query } from './_generated/server';
import { getCurrentUser } from './auth';

// Row-level security helper
export const getUserData = query({
  args: { targetUserId: v.id('users') },
  handler: async (ctx, args) => {
    const currentUser = await getCurrentUser(ctx);

    if (!currentUser) {
      throw new Error('Authentication required');
    }

    // Admin can access any user, users can only access their own data
    if (currentUser.role !== 'admin' && currentUser._id !== args.targetUserId) {
      throw new Error('Insufficient permissions');
    }

    return await ctx.db.get(args.targetUserId);
  },
});

Best Practices

1. Query Organization

// GOOD: Organized query functions by domain
// convex/users.ts - User queries
// convex/posts.ts - Post queries
// convex/subscriptions.ts - Subscription queries

// AVOID: Inline queries in components without proper abstraction

2. Error Handling

export const safeGetUser = query({
  args: { userId: v.id('users') },
  handler: async (ctx, args) => {
    try {
      const user = await ctx.db.get(args.userId);

      if (!user) {
        return { success: false, error: 'User not found' };
      }

      return { success: true, data: user };
    } catch (error) {
      console.error('Database query failed:', error);
      return { success: false, error: 'Database error' };
    }
  },
});

3. Type Safety

// GOOD: Explicit return types
export const getUsersWithPostCounts = query({
  handler: async (ctx) => {
    const users = await ctx.db.query('users').collect();

    const usersWithCounts = await Promise.all(
      users.map(async (user) => {
        const posts = await ctx.db
          .query('posts')
          .withIndex('by_userId', (q) => q.eq('userId', user._id))
          .collect();

        return {
          id: user._id,
          name: user.name,
          email: user.email,
          postCount: posts.length,
        };
      })
    );

    return usersWithCounts;
  },
});

Next Steps

Master database queries with these next guides:

  1. Migrations - Manage schema changes safely
  2. Performance - Optimize query performance
  3. Security - Secure database operations
  4. Advanced - Advanced query techniques

With these query patterns, you can build efficient, type-safe database operations that scale with your Solo Kit application.

Schema Design

Design robust, scalable database schemas with Convex, proper relationships, and best practices

Database Migrations

Manage database schema evolution safely with Convex, from development to production deployments

On this page

Database QueriesQuery FundamentalsBasic Query StructureType SafetyCRUD OperationsCreate (Insert)Read (Select)UpdateDeleteAdvanced QueriesRelated Data (Joins)AggregationsComplex FilteringMultiple ConditionsText SearchQuery Builders and HelpersReusable Query FunctionsDynamic Query BuildingPerformance OptimizationUsing Indexes EffectivelyEfficient Data FetchingQuery SecurityInput ValidationPermission ChecksBest Practices1. Query Organization2. Error Handling3. Type SafetyNext Steps