Skip to content
Backend
Skill

/convex-schema-validator

Designs convex/schema.ts tables, validators, indexes, and relationships, and keeps the schema honest as data evolves. Use when creating tables, adding fields, choosing index fields, modeling relationships, or when a validator error appears at deploy time.

BOOST
From plugin
builder-skills
40619 skills
Install
$ npx -y skills add waynesutton/builder-skills --skill convex-schema-validator --agent claude-code

How it fires

How this skill gets triggered: by you, by Claude, or both.

  • Fires itselfAuto-invocation. Claude auto-loads it when your prompt matches the work.Auto-invocation is when the right skill fires by itself at the right moment, driven by a FLOW.md router and a hook, instead of you invoking it by name. It is the difference between a skill being installed and a skill actually getting used.Read the full definition →
  • You can call itInvoke it directly when you want it.
  • Slash command/convex-schema-validator

Context preview

The summary Claude sees to decide when to auto-load this skill.

Designs convex/schema.ts tables, validators, indexes, and relationships, and keeps the schema honest as data evolves. Use when creating tables, adding fields, choosing index fields, modeling relationships, or when a validator error appears at deploy time.

SKILL.md

convex-schema-validator.SKILL.md
name: convex-schema-validator
description: Designs convex/schema.ts tables, validators, indexes, and relationships, and keeps the schema honest as data evolves. Use when creating tables, adding fields, choosing index fields, modeling relationships, or when a validator error appears at deploy time.

Convex schema validator

Produces a `convex/schema.ts` that types every document, indexes every query path, and passes validation against the data already in the database. The one rule: every `withIndex` in a function needs a matching `.index()` here, named after its fields, queried in field order.

When to reach for this

  • Creating a new table or adding a field to an existing one
  • A query uses `.filter()` and needs an index instead
  • Deciding whether to embed an object or link with `v.id`
  • Modeling a document that comes in several shapes
  • `npx convex dev` fails with a schema validation error

Schema skeleton

// 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()),
  }).index("by_email", ["email"]),

  tasks: defineTable({
    userId: v.id("users"),
    title: v.string(),
    completed: v.boolean(),
    priority: v.union(v.literal("low"), v.literal("medium"), v.literal("high")),
  })
    .index("by_userId", ["userId"])
    .index("by_userId_and_completed", ["userId", "completed"]),
});

`defineTable` takes either an object of field validators or a single `v.union` of `v.object` validators (see discriminated unions). Every table gets `_id` and `_creationTime` for free. Do not declare them.

Validators

| Validator | TypeScript type | Note | | --- | --- | --- | | `v.string()` | `string` | UTF-8, under 1 MB | | `v.number()` | `number` | Float64. Use for timestamps and counts | | `v.boolean()` | `boolean` | | | `v.null()` | `null` | `undefined` is not a Convex value. Return `null` instead | | `v.int64()` | `bigint` | Not `v.bigint()`, which is deprecated | | `v.bytes()` | `ArrayBuffer` | Under 1 MB | | `v.id("table")` | `Id<"table">` | Typed reference. Convex does not enforce that the target exists | | `v.array(t)` | `T[]` | At most 8192 items | | `v.object({...})` | `{...}` | At most 1024 entries. Keys cannot start with `$` or `_` | | `v.record(k, t)` | `Record<K, T>` | Dynamic ASCII keys. No `v.map` or `v.set` | | `v.union(a, b)` | `A \| B` | Use `v.literal` members for enums | | `v.literal("x")` | `"x"` | | | `v.optional(t)` | `T \| undefined` | Field may be absent | | `v.any()` | `any` | Last resort. Loses type safety and validation |

Optional versus nullable

`v.optional` means the key may be missing from the document. `v.union(t, v.null())` means the key is always present and may hold `null`. They are different at validation time.

items: defineTable({
  description: v.optional(v.string()),           // may be absent
  deletedAt: v.union(v.number(), v.null()),       // always present, may be null
  notes: v.optional(v.union(v.string(), v.null())), // either
}),

Use `v.optional` for fields added after the table had data. Use `v.union(..., v.null())` when "explicitly cleared" carries meaning.

Discriminated unions

For a table whose documents come in several shapes, pass a `v.union` of `v.object` validators to `defineTable`. Each member has the same literal key so TypeScript narrows on it.

events: defineTable(
  v.union(
    v.object({
      kind: v.literal("signup"),
      userId: v.id("users"),
      email: v.string(),
    }),
    v.object({
      kind: v.literal("purchase"),
      userId: v.id("users"),
      orderId: v.id("orders"),
      amount: v.number(),
    }),
  ),
).index("by_kind", ["kind"]),

Put the discriminant (`kind`) first in any index on a union table so queries can scope to one shape. Prefer this over one wide object full of `v.optional` fields.

Indexes

Naming and field order

Name the index after its fields in order: `by_field1_and_field2`. Querying must follow the same order: equality on a prefix of the fields, then at most one range on the next field.

messages: defineTable({
  channelId: v.id("channels"),
  authorId: v.id("users"),
  sentAt: v.number(),
})
  .index("by_channelId", ["channelId"])
  .index("by_channelId_and_authorId", ["channelId", "authorId"])
  .index("by_channelId_and_sentAt", ["channelId", "sentAt"]),
// Valid: equality on channelId, range on sentAt
await ctx.db
  .query("messages")
  .withIndex("by_channelId_and_sentAt", (q) =>
    q.eq("channelId", args.channelId).gt("sentAt", args.since),
  )
  .order("desc")
  .take(50);

You cannot skip `channelId` and filter on `sentAt` alone with that index. Add `by_sentAt` if that query exists. `_creationTime` is appended to every index automatically, so results within an equal prefix sort by creation time.

Reserved names: `by_id` and `by_creation_time`. Limits: 32 indexes per table, 16 fields per index.

When to add one

  • Any field a function passes to `withIndex`, `.eq`, or a range comparison
  • Every foreign key (`userId`, `channelId`, `orgId`) on the child table
  • The sort field for a paginated list, prefixed by the scoping field
  • Not for fields you only read after fetching the document
  • Not for tiny tables where a `.collect()` then in memory filter is fine

If a query uses `.filter()`, that is the signal to add an index and switch to `withIndex`.

Relationships

Link documents with `v.id("table")` on the child. Do not nest growing arrays of objects inside the parent.

// Good: one to many via a foreign key
posts: defineTable({
  authorId: v.id("users"),
  title: v.string(),
}).index("by_authorId", ["authorId"]),

comments: defineTable({
  postId: v.id("posts"),
  authorId: v.id("users"),
  body: v.string(),
}).index("by_postId", ["postId"]),

// Many to many v
Read more
Ships withbuilder-skills

Agent skills for builders shipping Convex apps. By Wayne Sutton. Seventeen skills.

Get the whole plugin

Other skills on builder-skills.