StarterApp Docs
Database

Schema Updates

Database schema evolution with Convex

Convex handles schema evolution through optional fields and gradual migrations. This guide shows the actual patterns used in production code for safe schema updates.

Safe Schema Evolution

Using v.optional() for new fields maintains compatibility with existing records and enables zero-downtime schema updates.

Schema Evolution Strategy

Update Schema Definition

Modify /convex/schema.ts with backward-compatible changes

Run Codegen

Execute pnpm codegen to generate TypeScript types

Commit Generated Files

Commit _generated/* artifacts (CI relies on them, doesn't run codegen)

Deploy Migration

Run data migration functions if needed

Critical: Commit Generated Files

Always commit _generated/* artifacts. CI depends on these files and does not run codegen.

Adding Optional Fields

The safest way to evolve your schema is adding optional fields:

Before

supportTickets: defineTable({
  userId: v.id("user"),
  title: v.string(),
  description: v.string(),
  status: v.union(
    v.literal("Open"),
    v.literal("In Progress"),
    v.literal("Resolved")
  ),
  updatedAt: v.number(),
})

After

supportTickets: defineTable({
  userId: v.id("user"),
  title: v.string(),
  description: v.string(),
  status: v.union(
    v.literal("Open"),
    v.literal("In Progress"),
    v.literal("Resolved")
  ),
  updatedAt: v.number(),
  // New optional fields
  priority: v.optional(v.union(
    v.literal("low"),
    v.literal("medium"),
    v.literal("high")
  )),
  tags: v.optional(v.array(v.string())),
  assignedTo: v.optional(v.id("user")),
})

Why Optional Fields?

Using v.optional() maintains compatibility with existing records that don't have these fields.

Adding Indexes

Indexes optimize query performance for common access patterns:

Creating New Tables

Follow the established patterns from /convex/schema.ts:

Define Table Schema

// Add to your schema
comments: defineTable({
  userId: v.id("user"),
  ticketId: v.id("supportTickets"),
  content: v.string(),
  createdAt: v.number(),
  updatedAt: v.number(),
  deletedAt: v.optional(v.number()), // Soft delete
})
  .index("by_ticket", ["ticketId", "createdAt"])
  .index("by_user", ["userId"])
  .index("by_deleted", ["deletedAt"]),

Update Tables Constants

export const T = {
  user: "user",
  session: "session",
  account: "account",
  verification: "verification",
  billingCustomers: "billingCustomers",
  supportTickets: "supportTickets",
  rateLimits: "rateLimits",
  comments: "comments", // Add new table
} as const;

// Add type alias
export type CommentId = Id<typeof T.comments>;

// Add validator
export const V = {
  userId: v.id(T.user),
  // ... other validators
  commentId: v.id(T.comments), // Add validator
} as const;

Implement Functions

import { userMutation, userQuery } from "./_helpers/builders";
import { T, V, type UserId, type CommentId } from "./tables";

export const createComment = userMutation({
  args: {
    ticketId: V.supportTicketId,
    content: v.string(),
  },
  handler: async (ctx, { ticketId, content }) => {
    const userId = ctx.viewerId as UserId;

    return await ctx.db.insert(T.comments, {
      userId,
      ticketId,
      content,
      createdAt: Date.now(),
      updatedAt: Date.now(),
    });
  },
});

export const getTicketComments = userQuery({
  args: { ticketId: V.supportTicketId },
  handler: async (ctx, { ticketId }) => {
    return await ctx.db
      .query(T.comments)
      .withIndex("by_ticket", (q) => q.eq("ticketId", ticketId))
      .filter((q) => q.eq(q.field("deletedAt"), undefined))
      .collect();
  },
});

Data Migration Patterns

When you need to update existing data, use mutation functions:

Changing Field Types

When you need to change a field type, use a multi-step migration:

Add New Optional Field

supportTickets: defineTable({
  status: v.string(), // Old field
  statusV2: v.optional(v.union(
    v.literal("Open"),
    v.literal("In Progress"),
    v.literal("Resolved")
  )), // New typed field
})

Migrate Data

export const migrateStatusToEnum = internalMutation({
  args: {},
  handler: async (ctx) => {
    const tickets = await ctx.db
      .query(T.supportTickets)
      .filter((q) => q.eq(q.field("statusV2"), undefined))
      .collect();

    for (const ticket of tickets) {
      // Map old string to new enum
      const statusMap: Record<string, any> = {
        "open": "Open",
        "in_progress": "In Progress",
        "resolved": "Resolved",
      };

      await ctx.db.patch(ticket._id, {
        statusV2: statusMap[ticket.status] || "Open",
      });
    }
  },
});

Update Application Code

Update queries/mutations to use statusV2

Remove Old Field

Once all data is migrated:

supportTickets: defineTable({
  status: v.union(...), // Rename statusV2 to status
})

Tables Pattern Migration

When adding new tables, follow the existing pattern:

Never Hardcode Table Names

Always update convex/tables.ts when adding new tables to maintain type safety.

// 1. Add table name constant
export const T = {
  // ... existing tables
  newTable: "newTable",
} as const;

// 2. Add type alias
export type NewTableId = Id<typeof T.newTable>;

// 3. Add validator
export const V = {
  // ... existing validators
  newTableId: v.id(T.newTable),
} as const;

Then use in functions:

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

export const createItem = userMutation({
  args: { name: v.string() },
  handler: async (ctx, { name }) => {
    return await ctx.db.insert(T.newTable, {
      userId: ctx.viewerId,
      name,
      createdAt: Date.now(),
    });
  },
});

Soft Delete Pattern

Implement soft deletes for data retention:

supportTickets: defineTable({
  userId: v.id("user"),
  title: v.string(),
  description: v.string(),
  status: v.union(
    v.literal("Open"),
    v.literal("In Progress"),
    v.literal("Resolved")
  ),
  deletedAt: v.optional(v.number()),
  updatedAt: v.number(),
})
  .index("by_user_id", ["userId"])
  .index("by_deleted", ["deletedAt"]) // Index for filtering

Usage:

export const softDeleteTicket = userMutation({
  args: { ticketId: V.supportTicketId },
  handler: async (ctx, { ticketId }) => {
    const userId = ctx.viewerId as UserId;
    const ticket = await ctx.db.get(ticketId);

    assertOwnerByUserId(ticket, userId);

    await ctx.db.patch(ticketId, {
      deletedAt: Date.now(),
      updatedAt: Date.now(),
    });
  },
});

export const getActiveTickets = userQuery({
  args: {},
  handler: async (ctx) => {
    return await ctx.db
      .query(T.supportTickets)
      .filter((q) => q.eq(q.field("deletedAt"), undefined))
      .collect();
  },
});

Schema Validation

The actual schema from /convex/schema.ts shows validation patterns:

// Status enum validation
const supportStatusValues = ["Open", "In Progress", "Resolved"] as const;

supportTickets: defineTable({
  status: v.union(...supportStatusValues.map((status) => v.literal(status))),
})

// Type guard helper
export type SupportTicketStatus = (typeof supportStatusValues)[number];

export function isValidSupportStatus(
  status: string
): status is SupportTicketStatus {
  return (supportStatusValues as readonly string[]).includes(status);
}

Usage in mutations:

export const updateStatus = userMutation({
  args: {
    ticketId: V.supportTicketId,
    status: v.string(),
  },
  handler: async (ctx, { ticketId, status }) => {
    if (!isValidSupportStatus(status)) {
      throw new AppError("INVALID_INPUT", "Invalid status value");
    }

    await ctx.db.patch(ticketId, {
      status,
      updatedAt: Date.now(),
    });
  },
});

Testing Migrations

Test migration functions before running in production:

import { convexTest } from "convex-test";
import { expect, test } from "vitest";
import schema from "../schema";
import { backfillPriority } from "../migrations";

test("backfill priority adds default values", async () => {
  const t = convexTest(schema);

  // Insert test data without priority
  const ticketId = await t.mutation(api.support.createSupportTicket, {
    title: "Test",
    description: "Test ticket",
  });

  // Run migration
  const result = await t.mutation(backfillPriority, {});

  // Verify result
  const ticket = await t.query(api.support.getTicket, { id: ticketId });
  expect(ticket?.priority).toBe("medium");
  expect(result.updated).toBe(1);
});

Best Practices

Use Optional Fields

Add new fields as optional to maintain backward compatibility

Index Strategically

Create indexes for fields used in queries and filters

Batch Migrations

Process large datasets in batches to avoid timeouts

Test First

Always test migration functions before production deployment

Schema Evolution Checklist

  1. Update convex/schema.ts with backward-compatible changes
  2. Run pnpm codegen to generate types
  3. Update convex/tables.ts if adding new tables
  4. Commit _generated/* files
  5. Write migration function if needed
  6. Test migration thoroughly
  7. Deploy and monitor