All skills
waynesutton avatar

/convex-schema-validator

@82d1ce2

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.

Use this Skill: https://skilld.dev/gh/waynesutton/convexskills/convex-schema-validator

This session only. Nothing lands on disk.

SKILL.md

≈70 tokens always: the name and description. ≈2.3k when used: this file. ≈23 more on demand in 1 file.

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 via a join table
postTags: defineTable({
  postId: v.id("posts"),
  tagId: v.id("tags"),
})
  .index("by_postId", ["postId"])
  .index("by_tagId", ["tagId"]),

Embed with v.object or a small v.array only when the data is bounded, always loaded with the parent, and updated together. A user's settings object is a good embed. A user's posts array is not: it hits the 8192 item cap and every post edit rewrites the user document.

System fields

_id: Id<"table"> and _creationTime: number (ms since epoch) exist on every document. Include them in return validators when a function returns whole documents:

returns: v.array(
  v.object({
    _id: v.id("tasks"),
    _creationTime: v.number(),
    userId: v.id("users"),
    title: v.string(),
    completed: v.boolean(),
  }),
),

Do not add your own createdAt unless you need a value that differs from insertion time.

Search and vector indexes

Declared on the table like regular indexes. filterFields must be top level fields.

articles: defineTable({
  title: v.string(),
  body: v.string(),
  category: v.string(),
  embedding: v.array(v.number()),
})
  .searchIndex("search_body", {
    searchField: "body",
    filterFields: ["category"],
  })
  .vectorIndex("by_embedding", {
    vectorField: "embedding",
    dimensions: 1536,
    filterFields: ["category"],
  }),

Query search indexes with withSearchIndex in queries. Vector search runs only in actions via ctx.vectorSearch.

Common mistakes

Mistake Why it breaks Do instead
withIndex("by_userId") with no matching .index() Deploy fails Declare the index in the schema first
Index named by_user on ["userId", "status"] Hides what it covers, easy to misuse by_userId_and_status
Querying status on by_userId_and_status without userId Index prefix rule Add by_status or include userId
Adding newField: v.string() to a table with rows Existing documents fail validation v.optional, backfill, then require
posts: v.array(v.object(...)) on users 8192 cap, write conflicts on every edit Separate posts table with by_authorId
v.bigint() Deprecated v.int64()
Declaring _id or _creationTime in defineTable Rejected They are automatic
v.any() to move fast No validation, no types Model the shape, or a v.union of the real cases
v.union(v.string(), v.null()) for a new field Old documents lack the key entirely v.optional(v.string())
Storing a Date Not a Convex value v.number() ms timestamp

Checklist

  • Schema lives in convex/schema.ts and exports defineSchema(...) as default
  • Every table has explicit field validators, no v.any() unless justified
  • Every withIndex call in convex/ has a matching .index() with fields in the name
  • Foreign keys are v.id("table") with an index on the child table
  • No unbounded arrays of objects embedded in a parent document
  • Fields added to tables with data are v.optional
  • Enums and polymorphic shapes use v.union of v.literal or v.object members
  • Return validators include _id and _creationTime when returning whole documents
  • npx convex dev pushes without a schema validation error

Docs

Source: SKILL.md on GitHub

No alerts3d5 checks · Risk SAFE
  • Gen Agent Trust Hub3d

    The skill is a set of documentation and guidelines for building database schemas with the Convex framework. It provides examples of validators, indexes, and relationships. No security risks were identified.

  • Socket3d

    No alerts

  • Snyk3d

    Risk: LOW · No issues

  • Runlayer7mo

    2 files scanned · No issues

  • ZeroLeaks5mo

    Score: 93/100 · 2 sections analyzed

Signed by skilld at 82d1ce2. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 2 days ago.

Activeupdated 4 days ago

README badge

README badge for waynesutton/convexskills/convex-schema-validator

Defines and validates Convex database schemas with TypeScript types, index configuration, optional fields, unions, and migration strategies. Covers table structure, single and compound indexes, full-text search, discriminated unions, and safe schema changes like adding optional fields and backfilling data.

Generated from the current SKILL.md.

Does this skill cover full-text search indexes?
Yes. The skill includes examples of searchIndex configuration with searchField and filterFields for text search on Convex tables.
How do I handle schema migrations when adding required fields?
Add the field as optional first, backfill existing documents with a migration function, then update the schema to required in a second step.
What's the difference between optional and nullable fields in Convex?
Optional fields may not exist on the document (v.optional), while nullable fields exist but can hold null values (v.union with v.null()). You can combine both.
Does this cover discriminated unions for polymorphic data?
Yes. The skill includes examples of discriminated unions with type literals, useful for storing different event types or document variants in a single table.
How should I name indexes?
Use descriptive names that include all indexed fields, like 'by_author_and_category' for a compound index on those two fields.

Generated from the current SKILL.md. These answers refresh after source changes.