Just-Use-Convex Docs
Convex Fundamentals

Schema Design

Define your database schema with Convex validators

Schema Design

Convex uses a schema to define your database structure. This template uses Convex Ents for enhanced type safety and relationships, combined with Zod schemas for validation.

Schema Structure

The schema is organized with each table defined in its own file under tables/:

// schema.ts
import { defineEntSchema, getEntDefinitions } from "convex-ents";
import { chatsEnt } from "./tables/chats";
import { sandboxesEnt } from "./tables/sandboxes";
import { todosEnt } from "./tables/todos";
import { todoAssignedMembersEnt } from "./tables/todoAssignedMembers";
import { globalAttachmentsEnt } from "./tables/globalAttachments";
import { orgMemberAttachmentsEnt } from "./tables/orgMemberAttachments";

const schema = defineEntSchema({
  chats: chatsEnt,
  sandboxes: sandboxesEnt,
  todos: todosEnt,
  todoAssignedMembers: todoAssignedMembersEnt,
  globalAttachments: globalAttachmentsEnt,
  orgMemberAttachments: orgMemberAttachmentsEnt,
});

export default schema;
export const entDefinitions = getEntDefinitions(schema);

Table Overview

TablePurpose
chatsAI chat sessions scoped to org + member
sandboxesDaytona sandbox environments linked to chats
todosTask items with status, priority, and team assignment
todoAssignedMembersJunction table for many-to-many todo-member assignment
globalAttachmentsDeduplicated file storage (keyed by content hash)
orgMemberAttachmentsPer-org-member references to global attachments

Validators

Convex provides built-in validators for type safety:

ValidatorTypeScript TypeDescription
v.string()stringString value
v.number()numberNumber (int or float)
v.boolean()booleanBoolean value
v.null()nullNull value
v.id("tableName")Id<"tableName">Reference to another document
v.array(v.string())string[]Array of values
v.object({...}){...}Nested object
v.optional(v.string())string | undefinedOptional field
v.union(v.literal("a"), v.literal("b"))"a" | "b"Union type

Indexes

Indexes are crucial for query performance. Define them on your table:

const todosTable = Todos.table
  // Composite indexes with sort field last
  .index("organizationId_memberId", ["organizationId", "memberId", "updatedAt"])
  .index("organizationId", ["organizationId", "updatedAt"])
  .index("organizationId_teamId", ["organizationId", "teamId", "updatedAt"])
  // Single-field index
  .index("memberId", ["memberId"]);

Search Indexes

For full-text search, use searchIndex:

const chatsTable = Chats.table
  .index("organizationId_memberId_isPinned", ["organizationId", "memberId", "isPinned", "updatedAt"])
  .index("organizationId", ["organizationId", "updatedAt"])
  .index("memberId", ["memberId"])
  .searchIndex("title", {
    searchField: "title",
    filterFields: ["organizationId", "memberId", "isPinned"],
  });

Index Usage Guidelines

  1. Query by exact match: Index fields in the order you filter them
  2. Range queries: Put the range field last in the index
  3. Sorting: Include sort fields (like updatedAt) after filter fields for efficient ordering
// This query uses "organizationId" index with updatedAt for sorting
ctx.db.query("todos")
  .withIndex("organizationId", (q) =>
    q.eq("organizationId", orgId)
  )
  .order("desc") // Orders by updatedAt (last field in index)
  .collect();

Defining Tables with Zod

This template uses Zod schemas with convex-helpers to define fields, providing both runtime validation and type inference.

Chats Table

The chats table demonstrates an optional edge to sandboxes and a search index:

// tables/chats.ts
import { z } from "zod";
import { defineEntFromTable } from "convex-ents";
import { Table } from "convex-helpers/server";
import { convexToZodFields, zodToConvexFields } from "convex-helpers/server/zod4";
import { sandboxesWithSystemFields } from "./sandboxes";

export const chatsZodSchema = {
  organizationId: z.string(),
  memberId: z.string(),
  title: z.string(),
  isPinned: z.boolean(),
  updatedAt: z.number(),
  sandboxId: sandboxesWithSystemFields._id.optional(),
};

export const chatsFields = {
  ...zodToConvexFields(chatsZodSchema),
};

export const Chats = Table("chats", chatsFields);

const chatsTable = Chats.table
  .index("organizationId_memberId_isPinned", ["organizationId", "memberId", "isPinned", "updatedAt"])
  .index("organizationId", ["organizationId", "updatedAt"])
  .index("memberId", ["memberId"])
  .searchIndex("title", {
    searchField: "title",
    filterFields: ["organizationId", "memberId", "isPinned"],
  });

// Many chats belong to one sandbox (optional relationship)
export const chatsEnt = defineEntFromTable(chatsTable)
  .edge("sandbox", { to: "sandboxes", field: "sandboxId", optional: true });

Note how sandboxId references the sandboxes table using the Zod system fields from the sandboxes table definition. The .edge() call declares the relationship so you can traverse it with Ents.

Todos Table

// tables/todos.ts
import { z } from "zod";
import { defineEntFromTable } from "convex-ents";
import { Table } from "convex-helpers/server";
import { zodToConvexFields } from "convex-helpers/server/zod4";

export const todosZodSchema = {
  organizationId: z.string(),
  memberId: z.string(),
  teamId: z.string().optional(),
  title: z.string(),
  description: z.string().optional(),
  status: z.enum(["todo", "in_progress", "done"]).default("todo"),
  dueDate: z.number().nullable().optional(),
  startTime: z.number().nullable().optional(),
  endTime: z.number().nullable().optional(),
  priority: z.enum(["low", "medium", "high"]).default("medium"),
  updatedAt: z.number(),
};

export const todosFields = zodToConvexFields(todosZodSchema);

export const Todos = Table("todos", todosFields);

const todosTable = Todos.table
  .index("organizationId_memberId", ["organizationId", "memberId", "updatedAt"])
  .index("organizationId", ["organizationId", "updatedAt"])
  .index("organizationId_teamId", ["organizationId", "teamId", "updatedAt"])
  .index("memberId", ["memberId"]);

export const todosEnt = defineEntFromTable(todosTable)
  .edges("assignedMembers", { to: "todoAssignedMembers", ref: "todoId" });

Sandboxes and Lifecycle Triggers

The sandboxes table uses a Convex trigger to automatically provision and destroy Daytona sandbox environments when documents are inserted or deleted:

// tables/sandboxes.ts
import { z } from "zod";
import { defineEntFromTable } from "convex-ents";
import { Table } from "convex-helpers/server";
import { zodToConvexFields } from "convex-helpers/server/zod4";
import type { Trigger } from "convex-helpers/server/triggers";
import type { GenericMutationCtx } from "convex/server";
import { internal } from "../_generated/api";
import type { DataModel } from "../_generated/dataModel";

export const sandboxesZodSchema = {
  organizationId: z.string(),
  userId: z.string(),
  name: z.string(),
  description: z.string().optional(),
  updatedAt: z.number(),
};

export const sandboxesFields = zodToConvexFields(sandboxesZodSchema);

export const Sandboxes = Table("sandboxes", sandboxesFields);

const sandboxesTable = Sandboxes.table
  .index("organizationId_userId", ["organizationId", "userId", "updatedAt"])
  .index("organizationId", ["organizationId", "updatedAt"])
  .index("userId", ["userId"])
  .searchIndex("name", {
    searchField: "name",
    filterFields: ["organizationId", "userId"],
  });

// 1:many relationship - one sandbox has many chats
export const sandboxesEnt = defineEntFromTable(sandboxesTable)
  .edges("chats", { to: "chats", ref: "sandboxId" });

// Lifecycle trigger: provision on insert, destroy on delete
type MutationCtx = GenericMutationCtx<DataModel>;

export const sandboxDaytonaTrigger: Trigger<MutationCtx, DataModel, "sandboxes"> = async (ctx, change) => {
  if (change.operation === "insert") {
    await ctx.scheduler.runAfter(0, internal.sandboxes.nodeFunctions.provision, {
      sandboxId: change.id,
    });
    return;
  }

  if (change.operation === "delete") {
    await ctx.scheduler.runAfter(0, internal.sandboxes.nodeFunctions.destroy, {
      sandboxId: change.id,
    });
  }
};

The trigger is registered in functions.ts:

const triggers = new Triggers<DataModel>();
triggers.register("sandboxes", sandboxDaytonaTrigger);

This pattern ensures every sandbox document maps 1:1 to a real Daytona sandbox, with provisioning and cleanup handled automatically via the scheduler.

Junction Tables

For many-to-many relationships, define a junction table:

// tables/todoAssignedMembers.ts
import { z } from "zod";
import { defineEntFromTable } from "convex-ents";
import { Table } from "convex-helpers/server";
import { zodToConvexFields } from "convex-helpers/server/zod4";
import { v } from "convex/values";

export const todoAssignedMembersZodSchema = {
  memberId: z.string(),
  assignedByMemberId: z.string(),
};

export const todoAssignedMembersFields = {
  ...zodToConvexFields(todoAssignedMembersZodSchema),
  todoId: v.id("todos"),
};

export const TodoAssignedMembers = Table("todoAssignedMembers", todoAssignedMembersFields);

const todoAssignedMembersTable = TodoAssignedMembers.table
  .index("memberId", ["memberId"])
  .index("todoId_memberId", ["todoId", "memberId"]);

export const todoAssignedMembersEnt = defineEntFromTable(todoAssignedMembersTable)
  .edge("todo", { to: "todos", field: "todoId" });

Attachment Tables (Deduplication Pattern)

Attachments use a two-table pattern for content-addressable storage:

Global Attachments

Stores the actual file once, keyed by content hash:

// tables/globalAttachments.ts
export const globalAttachmentsZodSchema = {
  hash: z.string(),
  storageId: zid("_storage"),
  size: z.number(),
  contentType: z.string().optional(),
};

const globalAttachmentsTable = GlobalAttachments.table
  .index("hash", ["hash"]);

export const globalAttachmentsEnt = defineEntFromTable(globalAttachmentsTable)
  .edges("orgMemberAttachments", { to: "orgMemberAttachments", ref: "globalAttachmentId" });

Org Member Attachments

Per-org-member references to global attachments, so the same file uploaded by different members only stores once:

// tables/orgMemberAttachments.ts
export const orgMemberAttachmentsZodSchema = {
  organizationId: z.string(),
  memberId: z.string(),
  globalAttachmentId: globalAttachmentsWithSystemFields._id,
  fileName: z.string(),
  contentType: z.string().optional(),
  size: z.number(),
  updatedAt: z.number(),
};

const orgMemberAttachmentsTable = OrgMemberAttachments.table
  .index("organizationId_memberId", ["organizationId", "memberId", "updatedAt"])
  .index("organizationId_memberId_globalAttachmentId", ["organizationId", "memberId", "globalAttachmentId"])
  .index("organizationId", ["organizationId", "updatedAt"])
  .index("memberId", ["memberId"]);

export const orgMemberAttachmentsEnt = defineEntFromTable(orgMemberAttachmentsTable)
  .edge("globalAttachment", { to: "globalAttachments", field: "globalAttachmentId" });

Generated Types

After defining your schema, Convex generates TypeScript types:

// _generated/dataModel.d.ts
export type Doc<TableName extends TableNames> = {
  chats: {
    _id: Id<"chats">;
    _creationTime: number;
    organizationId: string;
    memberId: string;
    title: string;
    isPinned: boolean;
    updatedAt: number;
    sandboxId?: Id<"sandboxes">;
  };
  todos: {
    _id: Id<"todos">;
    _creationTime: number;
    title: string;
    status: "todo" | "in_progress" | "done";
    priority: "low" | "medium" | "high";
    // ... other fields
  };
};

Use these types in your code:

import { Doc, Id } from "./_generated/dataModel";

type Chat = Doc<"chats">;
type ChatId = Id<"chats">;
type Todo = Doc<"todos">;
type TodoId = Id<"todos">;

Schema Migrations

Convex enforces that your schema must always match existing data. Use the @convex-dev/migrations component for safe, resumable migrations.

Safe Schema Changes

  1. Adding fields: Add with v.optional() first, backfill data, then make required
  2. Removing fields: Mark as v.optional(), remove data from documents, then remove from schema
  3. Changing types: Use v.union() of old and new types, migrate data, then update to new type only

Using the Migrations Component

// convex/migrations.ts
import { Migrations } from "@convex-dev/migrations";
import { components } from "./_generated/api";

export const migrations = new Migrations(components.migrations);

// Define a migration to backfill updatedAt
export const backfillUpdatedAt = migrations.define({
  table: "todos",
  migrateOne: (ctx, doc) => {
    if (!doc.updatedAt) {
      return { updatedAt: doc._creationTime };
    }
  },
});

// Define a migration to clear an optional field
export const clearField = migrations.define({
  table: "todos",
  batchSize: 10, // Customize batch size for large documents
  migrateOne: () => ({ optionalField: undefined }),
});

Running Migrations

// Run a single migration
await migrations.runOne(ctx, internal.migrations.backfillUpdatedAt);

// Override batch size for specific runs
await migrations.runOne(ctx, internal.migrations.clearField, {
  batchSize: 1,
});

The migrations component handles batching, resumption from failures, and dry runs for validation.

Next, learn about Zod Validation.