StarterApp Docs
Database

Overview

Real-time database with Convex

StarterApp uses Convex for real-time data synchronization with TypeScript type safety, reactive queries, and server functions. This guide covers the actual implementation patterns used in production.

Quick Start

All database operations use centralized table definitions from convex/tables.ts and custom builders from convex/_helpers/builders.ts for automatic authentication and permissions.

Architecture

Convex provides a complete backend infrastructure:

Real-Time Sync

Reactive queries that update automatically when data changes

Type Safety

Generated TypeScript types from schema definitions

Server Functions

Backend logic with authentication and authorization

ACID Transactions

Reliable data operations with consistency guarantees

Tables Pattern (Single Source of Truth)

Never Hardcode Table Names

Always import from convex/tables.ts instead of using string literals or inline v.id() calls.

The codebase uses a centralized table definition system in /convex/tables.ts:

Import Table Constants

import { T, V, type UserId, type SupportTicketId } from "./tables";

Use Constants in Queries

// ✅ Good - uses T.supportTickets constant
export const getTicket = query({
  args: { id: V.supportTicketId },
  handler: async (ctx, { id }) => {
    return await ctx.db.get(id);
  },
});

// ❌ Bad - hardcoded string and type
export const getTicket = query({
  args: { id: v.id("supportTickets") },
  handler: async (ctx, { id }) => {
    return await ctx.db.get(id as Id<"supportTickets">);
  },
});

Type Safety Throughout

The pattern provides three exports:

  • T - Table name constants (T.user, T.supportTickets)
  • V - Validator helpers (V.userId, V.supportTicketId)
  • Types - Type aliases (UserId, SupportTicketId)

Why This Pattern?

Centralized table definitions prevent typos, enable refactoring, and ensure consistency across your entire codebase.

Builder Pattern (Auth + Guards)

All user-facing functions use custom builders from /convex/_helpers/builders.ts that auto-inject authentication and permissions:

userQuery

import { userQuery } from "./_helpers/builders";
import { T } from "./tables";

export const getUserSupportTickets = userQuery({
  args: {},
  handler: async (ctx) => {
    // ctx.viewerId is auto-injected and authenticated
    // ctx.db automatically scopes to current user
    return await ctx.db
      .query(T.supportTickets)
      .order("desc")
      .collect();
  },
});

userMutation

import { userMutation } from "./_helpers/builders";
import { T, type UserId } from "./tables";
import { assertLimitAction } from "./rateLimits";

export const createSupportTicket = userMutation({
  args: {
    title: v.string(),
    description: v.string(),
  },
  handler: async (ctx, { title, description }) => {
    const userId = ctx.viewerId as UserId;

    // Rate limiting: 10 tickets per minute
    await assertLimitAction(ctx, {
      scope: "support:create",
      viewerId: userId,
      max: 10,
      windowMs: 60_000,
    });

    return await ctx.db.insert(T.supportTickets, {
      userId,
      title,
      description,
      status: "Open",
      updatedAt: Date.now(),
    });
  },
});

userAction

import { userAction } from "./_helpers/builders";

export const premiumExport = userAction({
  args: { format: v.literal("csv") },
  handler: async (ctx, args) => {
    // Auto-injected Autumn billing integration
    await ctx.check({ featureId: "exports" });

    // Generate export...
    const url = await generateExport(ctx, args.format);

    // Track usage
    ctx.track({ featureId: "exports", amount: 1 });

    return { url };
  },
});

Always Use Builders

Never use raw query, mutation, or action for user-facing functions. Builders enforce authentication, rate limiting, and billing checks automatically.

Authentication & Authorization

The application uses identity helpers from /convex/lib/identity.ts:

import { getUserIdOrThrow, assertOwnerByUserId } from "./lib/identity";
import { T, type UserId } from "./tables";

export const getMyNote = query({
  args: { noteId: V.supportTicketId },
  handler: async (ctx, { noteId }) => {
    // Get authenticated user ID (throws UNAUTHENTICATED if not logged in)
    const userId = await getUserIdOrThrow(ctx);

    const note = await ctx.db.get(noteId);

    // Verify ownership (throws FORBIDDEN or NOT_FOUND)
    assertOwnerByUserId(note, userId, { concealExistence: true });

    return note;
  },
});

Security Best Practice

Use concealExistence: true to prevent leaking information about records that exist but don't belong to the current user.

Error Handling

The application uses typed errors from /convex/_helpers/errors.ts:

import { AppError } from "./_helpers/errors";

// Throw typed errors with proper HTTP status codes
throw new AppError("UNAUTHENTICATED");  // 401
throw new AppError("FORBIDDEN");         // 403
throw new AppError("NOT_FOUND");         // 404
throw new AppError("RATE_LIMITED");      // 429
throw new AppError("PLAN_REQUIRED");     // 402
throw new AppError("USAGE_EXCEEDED");    // 429

Each error code maps to the appropriate HTTP status in /convex/_helpers/errors.ts.

Rate Limiting

Durable rate limiting implementation in /convex/rateLimits.ts:

import { assertLimitAction } from "./rateLimits";

export const sendMessage = action({
  args: { content: v.string() },
  handler: async (ctx, { content }) => {
    const userId = await getUserIdOrThrow(ctx);

    // 10 messages per minute
    await assertLimitAction(ctx, {
      scope: "messages",
      viewerId: userId,
      max: 10,
      windowMs: 60_000,
    });

    // Send message logic here
  },
});

The rate limiter uses bucket-based counting with automatic cleanup of expired entries.

Project Structure

Schema Definition

/convex/schema.ts - Define your database structure

Table Constants

/convex/tables.ts - Export table names, validators, and types

Functions

/convex/*.ts - Implement queries, mutations, and actions

Helpers

/convex/_helpers/builders.ts - Custom function builders

/convex/_helpers/errors.ts - Typed error handling

/convex/lib/identity.ts - Authentication helpers

Naming Convention

Singular Table Names

This codebase uses SINGULAR table names (e.g., user not users). Convex uses the EXACT key you define - there's no pluralization magic.

Always:

  • Use user not users
  • Use supportTickets not supportTicket (for compound names)
  • Import from tables.ts to avoid typos

Available Guides