PluginBench
Skill
Pass
Audit score 90

convex-schema-validator

waynesutton/convexskills

Define and validate Convex database schemas with typing, indexes, and migration strategies.

What is convex-schema-validator?

A skill for defining type-safe database schemas in Convex with proper field validation, index configuration, and strategies for handling schema migrations. Use this when building Convex applications that need structured, validated data with efficient querying.

  • Define tables with typed fields using Convex validators (string, number, boolean, id references, arrays, objects, unions)
  • Configure single-field and compound indexes for efficient queries, plus full-text search indexes
  • Handle optional fields, nullable fields, and discriminated unions for flexible data modeling
  • Implement schema migrations by adding optional fields, backfilling data, and promoting optional fields to required
  • Create nested objects and arrays of objects within table schemas
  • Use literal types and unions to enforce specific allowed values

How to install convex-schema-validator

npx skills add https://github.com/waynesutton/convexskills --skill convex-schema-validator
Prerequisites
  • Convex project initialized with convex/server package
  • Basic understanding of TypeScript types
Claude Code
Cursor
Windsurf
Cline

How to use convex-schema-validator

  1. 1.Create or open convex/schema.ts in your Convex project
  2. 2.Import defineSchema, defineTable from convex/server and v from convex/values
  3. 3.Define tables using defineTable() with field validators from the v namespace
  4. 4.Add indexes using .index() for query optimization and .searchIndex() for full-text search
  5. 5.For schema changes: add new fields as optional, backfill data if needed, then promote to required if necessary
  6. 6.Deploy schema changes to Convex backend

Use cases

Good for
  • Building an e-commerce schema with users, products, orders, and reviews with proper indexing
  • Adding a new optional field to an existing table and backfilling historical data
  • Creating a messaging system with compound indexes on channelId and timestamp for efficient range queries
  • Defining event tables with discriminated unions to store different event types with type-specific fields
  • Implementing full-text search on article content while filtering by category
Who it's for
  • Backend developers building Convex applications
  • Full-stack developers designing database schemas for TypeScript projects
  • Teams migrating existing schemas and needing safe migration strategies

convex-schema-validator FAQ

What's the difference between optional and nullable fields?

Optional fields (v.optional(v.string())) may not exist in the document at all. Nullable fields (v.union(v.string(), v.null())) always exist but can have a null value. Use optional for fields that may be added later; use nullable for fields that can explicitly be null.

How do I add a new required field to an existing table?

Add it as optional first (v.optional(v.string())), deploy, then backfill all documents with a value using an internal mutation, and finally change it to required and redeploy.

When should I use compound indexes?

Use compound indexes when you frequently filter or sort by multiple fields together, like querying messages by channelId and sentAt. Order matters: put filter fields first, then sort fields.

Can I use full-text search on multiple fields?

Full-text search indexes have one searchField for the indexed content. Use filterFields for additional fields to narrow results, but only one field is full-text indexed.

What validator should I use for document references?

Use v.id("tableName") to reference documents in another table. This creates a type-safe Id<"tableName"> reference that Convex validates.

Full instructions (SKILL.md)

Source of truth, from waynesutton/convexskills.


name: convex-schema-validator displayName: Convex Schema Validator description: Defining and validating database schemas with proper typing, index configuration, optional fields, unions, and migration strategies for schema changes version: 1.0.0 author: Convex tags: [convex, schema, validation, typescript, indexes, migrations]

Convex Schema Validator

Define and validate database schemas in Convex with proper typing, index configuration, optional fields, unions, and strategies for schema migrations.

Documentation Sources

Before implementing, do not assume; fetch the latest documentation:

Instructions

Basic Schema Definition

// convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";

export default defineSchema({
  users: defineTable({
    name: v.string(),
    email: v.string(),
    avatarUrl: v.optional(v.string()),
    createdAt: v.number(),
  }),
  
  tasks: defineTable({
    title: v.string(),
    description: v.optional(v.string()),
    completed: v.boolean(),
    userId: v.id("users"),
    priority: v.union(
      v.literal("low"),
      v.literal("medium"),
      v.literal("high")
    ),
  }),
});

Validator Types

ValidatorTypeScript TypeExample
v.string()string"hello"
v.number()number42, 3.14
v.boolean()booleantrue, false
v.null()nullnull
v.int64()bigint9007199254740993n
v.bytes()ArrayBufferBinary data
v.id("table")Id<"table">Document reference
v.array(v)T[][1, 2, 3]
v.object({}){ ... }{ name: "..." }
v.optional(v)T | undefinedOptional field
v.union(...)T1 | T2Multiple types
v.literal(x)"x"Exact value
v.any()anyAny value
v.record(k, v)Record<K, V>Dynamic keys

Index Configuration

export default defineSchema({
  messages: defineTable({
    channelId: v.id("channels"),
    authorId: v.id("users"),
    content: v.string(),
    sentAt: v.number(),
  })
    // Single field index
    .index("by_channel", ["channelId"])
    // Compound index
    .index("by_channel_and_author", ["channelId", "authorId"])
    // Index for sorting
    .index("by_channel_and_time", ["channelId", "sentAt"]),
    
  // Full-text search index
  articles: defineTable({
    title: v.string(),
    body: v.string(),
    category: v.string(),
  })
    .searchIndex("search_content", {
      searchField: "body",
      filterFields: ["category"],
    }),
});

Complex Types

export default defineSchema({
  // Nested objects
  profiles: defineTable({
    userId: v.id("users"),
    settings: v.object({
      theme: v.union(v.literal("light"), v.literal("dark")),
      notifications: v.object({
        email: v.boolean(),
        push: v.boolean(),
      }),
    }),
  }),

  // Arrays of objects
  orders: defineTable({
    customerId: v.id("users"),
    items: v.array(v.object({
      productId: v.id("products"),
      quantity: v.number(),
      price: v.number(),
    })),
    status: v.union(
      v.literal("pending"),
      v.literal("processing"),
      v.literal("shipped"),
      v.literal("delivered")
    ),
  }),

  // Record type for dynamic keys
  analytics: defineTable({
    date: v.string(),
    metrics: v.record(v.string(), v.number()),
  }),
});

Discriminated Unions

export default defineSchema({
  events: defineTable(
    v.union(
      v.object({
        type: v.literal("user_signup"),
        userId: v.id("users"),
        email: v.string(),
      }),
      v.object({
        type: v.literal("purchase"),
        userId: v.id("users"),
        orderId: v.id("orders"),
        amount: v.number(),
      }),
      v.object({
        type: v.literal("page_view"),
        sessionId: v.string(),
        path: v.string(),
      })
    )
  ).index("by_type", ["type"]),
});

Optional vs Nullable Fields

export default defineSchema({
  items: defineTable({
    // Optional: field may not exist
    description: v.optional(v.string()),
    
    // Nullable: field exists but can be null
    deletedAt: v.union(v.number(), v.null()),
    
    // Optional and nullable
    notes: v.optional(v.union(v.string(), v.null())),
  }),
});

Index Naming Convention

Always include all indexed fields in the index name:

export default defineSchema({
  posts: defineTable({
    authorId: v.id("users"),
    categoryId: v.id("categories"),
    publishedAt: v.number(),
    status: v.string(),
  })
    // Good: descriptive names
    .index("by_author", ["authorId"])
    .index("by_author_and_category", ["authorId", "categoryId"])
    .index("by_category_and_status", ["categoryId", "status"])
    .index("by_status_and_published", ["status", "publishedAt"]),
});

Schema Migration Strategies

Adding New Fields

// Before
users: defineTable({
  name: v.string(),
  email: v.string(),
})

// After - add as optional first
users: defineTable({
  name: v.string(),
  email: v.string(),
  avatarUrl: v.optional(v.string()), // New optional field
})

Backfilling Data

// convex/migrations.ts
import { internalMutation } from "./_generated/server";
import { v } from "convex/values";

export const backfillAvatars = internalMutation({
  args: {},
  returns: v.number(),
  handler: async (ctx) => {
    const users = await ctx.db
      .query("users")
      .filter((q) => q.eq(q.field("avatarUrl"), undefined))
      .take(100);

    for (const user of users) {
      await ctx.db.patch(user._id, {
        avatarUrl: `https://api.dicebear.com/7.x/initials/svg?seed=${user.name}`,
      });
    }

    return users.length;
  },
});

Making Optional Fields Required

// Step 1: Backfill all null values
// Step 2: Update schema to required
users: defineTable({
  name: v.string(),
  email: v.string(),
  avatarUrl: v.string(), // Now required after backfill
})

Examples

Complete E-commerce Schema

// convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";

export default defineSchema({
  users: defineTable({
    email: v.string(),
    name: v.string(),
    role: v.union(v.literal("customer"), v.literal("admin")),
    createdAt: v.number(),
  })
    .index("by_email", ["email"])
    .index("by_role", ["role"]),

  products: defineTable({
    name: v.string(),
    description: v.string(),
    price: v.number(),
    category: v.string(),
    inventory: v.number(),
    isActive: v.boolean(),
  })
    .index("by_category", ["category"])
    .index("by_active_and_category", ["isActive", "category"])
    .searchIndex("search_products", {
      searchField: "name",
      filterFields: ["category", "isActive"],
    }),

  orders: defineTable({
    userId: v.id("users"),
    items: v.array(v.object({
      productId: v.id("products"),
      quantity: v.number(),
      priceAtPurchase: v.number(),
    })),
    total: v.number(),
    status: v.union(
      v.literal("pending"),
      v.literal("paid"),
      v.literal("shipped"),
      v.literal("delivered"),
      v.literal("cancelled")
    ),
    shippingAddress: v.object({
      street: v.string(),
      city: v.string(),
      state: v.string(),
      zip: v.string(),
      country: v.string(),
    }),
    createdAt: v.number(),
    updatedAt: v.number(),
  })
    .index("by_user", ["userId"])
    .index("by_user_and_status", ["userId", "status"])
    .index("by_status", ["status"]),

  reviews: defineTable({
    productId: v.id("products"),
    userId: v.id("users"),
    rating: v.number(),
    comment: v.optional(v.string()),
    createdAt: v.number(),
  })
    .index("by_product", ["productId"])
    .index("by_user", ["userId"]),
});

Using Schema Types in Functions

// convex/products.ts
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
import { Doc, Id } from "./_generated/dataModel";

// Use Doc type for full documents
type Product = Doc<"products">;

// Use Id type for references
type ProductId = Id<"products">;

export const get = query({
  args: { productId: v.id("products") },
  returns: v.union(
    v.object({
      _id: v.id("products"),
      _creationTime: v.number(),
      name: v.string(),
      description: v.string(),
      price: v.number(),
      category: v.string(),
      inventory: v.number(),
      isActive: v.boolean(),
    }),
    v.null()
  ),
  handler: async (ctx, args): Promise<Product | null> => {
    return await ctx.db.get(args.productId);
  },
});

Best Practices

  • Never run npx convex deploy unless explicitly instructed
  • Never run any git commands unless explicitly instructed
  • Always define explicit schemas rather than relying on inference
  • Use descriptive index names that include all indexed fields
  • Start with optional fields when adding new columns
  • Use discriminated unions for polymorphic data
  • Validate data at the schema level, not just in functions
  • Plan index strategy based on query patterns

Common Pitfalls

  1. Missing indexes for queries - Every withIndex needs a corresponding schema index
  2. Wrong index field order - Fields must be queried in order defined
  3. Using v.any() excessively - Lose type safety benefits
  4. Not making new fields optional - Breaks existing data
  5. Forgetting system fields - _id and _creationTime are automatic

References