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.
74
93%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
Passed
No findings from the security scan
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.
.filter() and needs an index insteadv.idnpx convex dev fails with a schema validation error// 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.
| 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 |
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.
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.
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.
withIndex, .eq, or a range comparisonuserId, channelId, orgId) on the child table.collect() then in memory filter is fineIf a query uses .filter(), that is the signal to add an index and switch to withIndex.
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.
_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.
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.
| 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 |
convex/schema.ts and exports defineSchema(...) as defaultv.any() unless justifiedwithIndex call in convex/ has a matching .index() with fields in the namev.id("table") with an index on the child tablev.optionalv.union of v.literal or v.object members_id and _creationTime when returning whole documentsnpx convex dev pushes without a schema validation errorwaynesutton/convexskills
82d1ce2
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.