convex-schema-validator

Type-safe database schema definition with indexes, validation, and migration strategies for Convex. Supports 13+ validator types including strings, numbers, booleans, document references, arrays, objects, unions, and discriminated unions for flexible data modeling Enables single-field and compound indexes plus full-text search indexes for optimized query performance Provides optional and nullable field patterns with clear migration paths for adding required fields and backfilling data Includes nested objects, arrays of objects, and record types for complex data structures with full TypeScript type inference

INSTALLATION
npx skills add https://github.com/waynesutton/convexskills --skill convex-schema-validator
Run in your project or agent environment. Adjust flags if your CLI version differs.

SKILL.md

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

Validator

TypeScript Type

Example

v.string()

string

"hello"

v.number()

number

42, 3.14

v.boolean()

boolean

true, false

v.null()

null

null

v.int64()

bigint

9007199254740993n

v.bytes()

ArrayBuffer

Binary data

v.id("table")

Id<"table">

Document reference

v.array(v)

T[]

[1, 2, 3]

v.object({})

{ ... }

{ name: "..." }

v.optional(v)

T | undefined

Optional field

v.union(...)

T1 | T2

Multiple types

v.literal(x)

"x"

Exact value

v.any()

any

Any 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

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

References

BrowserAct

Let your agent run on any real-world website

Bypass CAPTCHA & anti-bot for free. Start local, scale to cloud.

Explore BrowserAct Skills →

Stop writing automation&scrapers

Install the CLI. Run your first Skill in 30 seconds. Scale when you're ready.

Start free
free · no credit card