convex-schema-validator
Skill405 starsChanged 46 days agoApache-2.0View on GitHub
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.
2 other projects use a version of this.
Adopted by
Most recently taken up by
- waynesutton/builder-skillsword for word, first seen Sep 28, 2026
- StillScripts/cursor-agents-managertheir own version, first seen Jan 17, 2026
Other versions2 projects use a version of this.
Word for word · 1
- waynesutton/builder-skills★ 404100% same
Edited · 0
None found.
Wrote their own version · 1
- StillScripts/cursor-agents-manager★ 1same name
How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.
In this repository19 other files it gives its agents.
- AGENTS.mdAGENTS.md
- templates/AGENTS.mdAGENTS.md
- CLAUDE.mdCLAUDE.md
- avoid-feature-creepSkill
- convex-agentsSkill
- convex-best-practicesSkill
- convex-component-authoringSkill
- convex-cron-jobsSkill
- convex-file-storageSkill
- convex-functionsSkill
- convex-http-actionsSkill
- convex-migrationsSkill
- convex-realtimeSkill
- convex-security-auditSkill
- convex-security-checkSkill
- convexSkill
- git-safetySkill
- project-docsSkill
- project-workflowSkill
The file
We split this skill into the rules it gives an agent
7 rules. About 0 are common, 1 is distinctive, and 6 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈1
Distinctive · estimate
This author's own choices. Worth a look.
6
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 76
Use v.optional for fields added after the table had data.
- Very specific: line 100
Put the discriminant (kind) first in any index on a union table so queries can scope to one shape.
- Very specific: line 137
Every foreign key (userId, channelId, orgId) on the child table
- Distinctive: line 139
Not for fields you only read after fetching the document
- Very specific: line 140
Not for tiny tables where a .collect() then in memory filter is fine
- Very specific: line 142
If a query uses .filter(), that is the signal to add an index and switch to withIndex.
- Very specific: line 188
Do not add your own createdAt unless you need a value that differs from insertion time.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
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 devfails 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.tsand exportsdefineSchema(...)as default - Every table has explicit field validators, no
v.any()unless justified - Every
withIndexcall inconvex/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.unionofv.literalorv.objectmembers - Return validators include
_idand_creationTimewhen returning whole documents npx convex devpushes without a schema validation error
Docs
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

