关于 convex-schema-validator
面向 Convex 开发者的 schema 建模参考,说明字段验证器、文档关系与索引规则,帮助 schema 通过现有数据验证并匹配查询路径。
主要功能
- 01字段验证器与类型映射
- 02可选值与 null 的区别
- 03联合类型建模
- 04按查询字段定义索引
- 05表关系与嵌入取舍
- 06常见 schema 错误检查
适用场景
- 01新建表或为现有表添加字段
- 02为 withIndex 查询补充索引
- 03选择嵌入对象或使用 v.id
- 04排查 npx convex dev 验证错误
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ waynesutton/ builder-skills --skill convex-schema-validator安装后在 Claude Code 中输入/convex-schema-validator确认已加载。
附带文件agents/openai.yamlassets/small-logo.svg
详细介绍
Convex 架构验证器
生成 convex/schema.ts,为每个文档指定类型、为每条查询路径建立索引,并通过对数据库现有数据的验证。规则只有一条:函数中的每个 withIndex 都需要在此处有匹配的 .index(),索引名根据字段命名,查询字段顺序必须一致。
何时使用
- 创建新表或为现有表添加字段
- 查询使用了
.filter(),需要改用索引 - 决定是嵌入对象还是使用
v.id关联 - 为存在多种形态的文档建模
npx convex dev因架构验证错误而失败
架构骨架
// 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 接收字段验证器对象,或单个由 v.object 验证器组成的 v.union(参见判别联合)。每张表都会自动获得 _id 和 _creationTime。不要声明它们。
验证器
| 验证器 | TypeScript 类型 | 备注 |
|---|---|---|
v.string() | string | UTF-8,长度小于 1 MB |
v.number() | number | Float64。用于时间戳和计数 |
v.boolean() | boolean | |
v.null() | null | undefined 不是 Convex 值。请改为返回 null |
v.int64() | bigint | 不是已弃用的 v.bigint() |
v.bytes() | ArrayBuffer | 长度小于 1 MB |
v.id("table") | Id<"table"> | 类型化引用。Convex 不会验证目标是否存在 |
v.array(t) | T[] | 最多 8192 个项目 |
v.object({...}) | {...} | 最多 1024 个条目。键不能以 $ 或 _ 开头 |
v.record(k, t) | Record<K, T> | 动态 ASCII 键。不支持 v.map 或 v.set |
v.union(a, b) | A | B | 枚举成员请使用 v.literal |
v.literal("x") | "x" | |
v.optional(t) | T | undefined | 字段可以缺失 |
v.any() | any | 最后手段。会失去类型安全和验证 |
可选与可空
v.optional 表示文档中可以缺少该键。v.union(t, v.null()) 表示该键始终存在,且其值可以是 null。两者在验证时并不相同。
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
}),
对于表已有数据后添加的字段,请使用 v.optional。如果“明确清空”本身具有意义,请使用 v.union(..., v.null())。
判别联合
对于文档存在多种形态的表,将由 v.object 验证器组成的 v.union 传给 defineTable。每个成员都有相同的字面量键,TypeScript 会据此进行类型收窄。
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"]),
在联合表的任何索引中,都要将判别字段(kind)放在首位,以便查询限定为一种形态。相比使用一个充满 v.optional 字段的宽泛对象,优先采用这种方式。
索引
命名和字段顺序
按字段顺序命名索引:by_field1_and_field2。查询也必须遵循相同顺序:对字段前缀进行相等比较,然后至多对下一个字段进行一次范围比较。
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);
使用该索引时,不能跳过 channelId,只按 sentAt 进行筛选。如果存在这种查询,请添加 by_sentAt。每个索引都会自动追加 _creationTime,因此具有相同字段前缀的结果会按创建时间排序。
保留名称:by_id 和 by_creation_time。限制:每张表最多 32 个索引,每个索引最多 16 个字段。
何时添加索引
- 函数传给
withIndex、.eq或范围比较的任何字段 - 子表上的每个外键(
userId、channelId、orgId) - 分页列表的排序字段,并在其前面加上范围限定字段
- 仅在获取文档后读取的字段不需要索引
- 对于
.collect()后在内存中筛选即可的小表,不需要索引
如果查询使用了 .filter(),这就表明应添加索引并改用 withIndex。
关系
在子文档上使用 v.id("table") 关联文档。不要在父文档中嵌套会不断增长的对象数组。
// 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"]),
只有当数据有界、始终与父文档一起加载且一同更新时,才使用 v.object 或较小的 v.array 嵌入数据。用户的 settings 对象适合嵌入。用户的 posts 数组则不适合:它会触及 8192 个项目的上限,而且每次编辑帖子都会重写用户文档。
系统字段
每个文档都包含 _id: Id<"table"> 和 _creationTime: number(自 epoch 起的毫秒数)。如果函数返回完整文档,请在返回值验证器中包含这些字段:
returns: v.array(
v.object({
_id: v.id("tasks"),
_creationTime: v.number(),
userId: v.id("users"),
title: v.string(),
completed: v.boolean(),
}),
),
除非你需要与插入时间不同的值,否则不要自行添加 createdAt。
搜索和向量索引
像常规索引一样在表上声明。filterFields 必须是顶层字段。
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"],
}),
在查询中使用 withSearchIndex 查询搜索索引。向量搜索只能在操作中通过 ctx.vectorSearch 运行。
常见错误
| 错误 | 原因 | 应改为 |
|---|---|---|
withIndex("by_userId") 没有匹配的 .index() | 部署失败 | 先在架构中声明索引 |
在 ["userId", "status"] 上将索引命名为 by_user | 无法明确索引覆盖范围,容易误用 | by_userId_and_status |
在 by_userId_and_status 上查询 status,却未指定 userId | 索引前缀规则 | 添加 by_status 或包含 userId |
为含有行的表添加 newField: v.string() | 现有文档无法通过验证 | 使用 v.optional,回填数据,然后设为必填 |
在 users 上使用 posts: v.array(v.object(...)) | 达到 8192 上限,每次编辑都会产生写入冲突 | 拆分为独立的 posts 表,并添加 by_authorId |
v.bigint() | 已弃用 | v.int64() |
在 defineTable 中声明 _id 或 _creationTime | 不允许 | 这些字段会自动添加 |
为了快速开发而使用 v.any() | 没有验证,也没有类型 | 定义数据结构,或使用由实际情况组成的 v.union |
为新字段使用 v.union(v.string(), v.null()) | 旧文档完全没有这个键 | 使用 v.optional(v.string()) |
存储 Date | 不是 Convex 值 | 使用 v.number() 毫秒时间戳 |
检查清单
- 架构位于
convex/schema.ts,并将defineSchema(...)作为默认导出 - 每个表都有显式的字段验证器,除非有充分理由,否则不使用
v.any() -
convex/中的每个withIndex调用都有匹配的.index(),且名称包含字段 - 外键使用
v.id("table"),并在子表上建立索引 - 父文档中没有无界的对象数组
- 为含有数据的表添加的字段使用
v.optional - 枚举和多态数据结构使用由
v.literal或v.object成员组成的v.union - 返回完整文档时,返回值验证器包含
_id和_creationTime -
npx convex dev推送成功,且没有架构验证错误