mcpField
C
convex-best-practices

convex-best-practices

github.com/waynesutton开发4053.9k 次安装4 周前更新
代码仓库

关于 convex-best-practices

面向 Convex 应用开发者的最佳实践技能,聚焦索引读取、验证器和幂等写入等生产代码模式,并说明并发冲突与分页处理。

主要功能

  1. 01每个函数声明参数和返回验证器
  2. 02使用索引查询而非表过滤
  3. 03通过幂等变更降低重试风险
  4. 04介绍并发冲突与分页策略
  5. 05提供 ESLint 插件配置建议

适用场景

  1. 01审查 Convex 函数的数据读写方式
  2. 02减少高频更新造成的写入冲突
  3. 03为不断增长的数据列表实现分页
  4. 04配置 lint 规则捕捉常见错误

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/waynesutton/builder-skills --skill convex-best-practices

安装后在 Claude Code 中输入/convex-best-practices确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

agents/
openai.yaml
assets/
small-logo.svg
references/
eslint-setup.md

详细介绍

Convex 最佳实践

让 Convex 应用在生产环境中保持快速且正确的模式。最重要的规则:写入前尽可能少读,并通过索引读取。

最重要的规则

  1. 为每个函数添加验证器。使用 args 和 returns;没有返回值时使用 returns: v.null()。
  2. 使用索引,而非过滤器。每次读取表都要通过 withIndex,并使用 convex/schema.ts 中定义的索引。
  3. 让 mutation 保持幂等。如果文档已经处于目标状态,就提前返回,确保重试安全。
  4. 先 patch,不必先读取。ctx.db.patch(id, fields) 在文档不存在时会抛出异常;通常无需获取旧值。
  5. 对彼此独立的写入使用 Promise.all。不要逐个等待。
  6. 只调度 internal.*。Cron 和 ctx.scheduler 不会携带客户端,因此公开目标会跳过身份验证。
  7. 使用精简的封装。身份验证和业务逻辑放在接收 ctx 的普通辅助函数中。
  8. 客户端需要读取的错误都使用 ConvexError。生产环境会隐藏普通 Error 消息。
// convex/tasks.ts
import { query, mutation } from "./_generated/server";
import { v, ConvexError } from "convex/values";

const taskValidator = v.object({
  _id: v.id("tasks"),
  _creationTime: v.number(),
  userId: v.id("users"),
  title: v.string(),
  status: v.union(v.literal("open"), v.literal("done")),
});

export const listOpen = query({
  args: { userId: v.id("users") },
  returns: v.array(taskValidator),
  handler: async (ctx, args) => {
    return await ctx.db
      .query("tasks")
      .withIndex("by_user_and_status", (q) =>
        q.eq("userId", args.userId).eq("status", "open"),
      )
      .order("desc")
      .take(100);
  },
});

export const rename = mutation({
  args: { taskId: v.id("tasks"), title: v.string() },
  returns: v.null(),
  handler: async (ctx, args) => {
    if (args.title.trim().length === 0) {
      throw new ConvexError("Title cannot be empty");
    }
    await ctx.db.patch(args.taskId, { title: args.title.trim() });
    return null;
  },
});

该索引对应的 schema:

tasks: defineTable({
  userId: v.id("users"),
  title: v.string(),
  status: v.union(v.literal("open"), v.literal("done")),
})
  .index("by_user", ["userId"])
  .index("by_user_and_status", ["userId", "status"]),

索引名称按字段顺序命名,查询字段也按相同顺序排列。

OCC 和写入冲突

Convex 在乐观并发控制下运行 mutations。mutation 会记录读取过的数据。如果另一个 mutation 先提交了对这些数据的更改,Convex 就会重试该 mutation。重试次数过多后,操作会失败,客户端会看到写入冲突错误。

冲突有以下三种来源:

  • 两个 mutations 同时写入同一文档:计数器、“上次查看时间”字段、共享设置文档
  • mutation 读取了较大范围的数据,例如对整张表调用 .collect(),因此该范围内的任何更改都会与之冲突
  • 客户端调用同一个 mutation 的速度超过其提交速度:输入、拖动、轮询
保持幂等并优先 patch
export const complete = mutation({
  args: { taskId: v.id("tasks") },
  returns: v.null(),
  handler: async (ctx, args) => {
    const task = await ctx.db.get(args.taskId);
    if (!task || task.status === "done") {
      return null;
    }
    await ctx.db.patch(args.taskId, { status: "done" });
    return null;
  },
});

export const reorder = mutation({
  args: { itemIds: v.array(v.id("items")) },
  returns: v.null(),
  handler: async (ctx, args) => {
    await Promise.all(
      args.itemIds.map((id, index) => ctx.db.patch(id, { order: index })),
    );
    return null;
  },
});

complete 中的读取没问题:只读取一个文档,并提前返回。reorder 完全不读取数据。

用事件记录代替计数器

单个文档上的计数器字段是最常见的冲突来源。每个事件插入一行,并在查询中计数。

export const trackView = mutation({
  args: { pageId: v.id("pages") },
  returns: v.null(),
  handler: async (ctx, args) => {
    await ctx.db.insert("pageViews", { pageId: args.pageId });
    return null;
  },
});

export const viewCount = query({
  args: { pageId: v.id("pages") },
  returns: v.number(),
  handler: async (ctx, args) => {
    const views = await ctx.db
      .query("pageViews")
      .withIndex("by_page", (q) => q.eq("pageId", args.pageId))
      .collect();
    return views.length;
  },
});

事件表变大后,请改用 @convex-dev/sharded-counter 或 @convex-dev/aggregate 组件来计数,而不是收集所有行。

去重时间窗口

对于心跳和在线状态,如果上次写入时间距今很近,就跳过本次写入。再配合客户端防抖:输入操作使用 300 到 500 ms,心跳使用几秒。

const DEDUP_MS = 10_000;

export const heartbeat = mutation({
  args: { sessionId: v.string(), path: v.string() },
  returns: v.null(),
  handler: async (ctx, args) => {
    const now = Date.now();
    const existing = await ctx.db
      .query("sessions")
      .withIndex("by_session", (q) => q.eq("sessionId", args.sessionId))
      .unique();
    if (!existing) {
      await ctx.db.insert("sessions", { ...args, lastSeen: now });
      return null;
    }
    if (existing.path === args.path && now - existing.lastSeen < DEDUP_MS) {
      return null;
    }
    await ctx.db.patch(existing._id, { path: args.path, lastSeen: now });
    return null;
  },
});

将 lastSeen 等高频更新字段放在单独的表中,避免这些写入与对稳定文档的读取发生冲突。

使用分页代替 collect

对没有上限的表调用 .collect() 会一天比一天慢,最终还会触及读取限制。凡是用户可以不断增加内容的地方,都应使用分页。下面的 postValidator 是像上文 taskValidator 一样提取到外部定义的文档验证器。

import { paginationOptsValidator } from "convex/server";

export const feed = query({
  args: { userId: v.id("users"), paginationOpts: paginationOptsValidator },
  returns: v.object({
    page: v.array(postValidator),
    isDone: v.boolean(),
    continueCursor: v.string(),
    splitCursor: v.optional(v.union(v.string(), v.null())),
    pageStatus: v.optional(
      v.union(v.literal("SplitRecommended"), v.literal("SplitRequired"), v.null()),
    ),
  }),
  handler: async (ctx, args) => {
    return await ctx.db
      .query("posts")
      .withIndex("by_user", (q) => q.eq("userId", args.userId))
      .order("desc")
      .paginate(args.paginationOpts);
  },
});

在客户端,convex/react 中的 usePaginatedQuery 会驱动 loadMore。

查询中不要使用 Date.now()

查询必须具有确定性,以便 Convex 能够缓存它们,并在数据更改时重新运行。将时间从客户端传入,或存储一个由定时 mutation 更新的状态字段。

export const dueBefore = query({
  args: { userId: v.id("users"), now: v.number() },
  returns: v.array(taskValidator),
  handler: async (ctx, args) => {
    return await ctx.db
      .query("tasks")
      .withIndex("by_user_and_due", (q) =>
        q.eq("userId", args.userId).lt("dueAt", args.now),
      )
      .take(100);
  },
});

Mutations 和 actions 可以调用 Date.now()。

ESLint 插件

@convex-dev/eslint-plugin 会在 lint 时捕获旧版函数语法、缺少参数验证器、查询中的 .filter(),以及整点触发的 cron。请在每个 Convex 项目中安装它。

npm i --save-dev @convex-dev/eslint-plugin
// eslint.config.js
import { defineConfig } from "eslint/config";
import convexPlugin from "@convex-dev/eslint-plugin";

export default defineConfig([...convexPlugin.configs.recommended]);

需要完整规则列表、TypeScript 感知配置、package 脚本或自定义 convex/ 目录时,请查看 references/eslint-setup.md。

常见错误

错误为什么会出问题应该怎么做
在表查询上使用 .filter()读取所有行,然后丢弃大部分添加索引,使用 withIndex
对无界表使用 .collect()每天都会变慢,触及读取限制,并扩大 OCC 影响范围使用 .paginate() 或 .take(n)
读取、计算,然后更新共享文档两个客户端读取相同版本并同时写入直接更新,或拆分为事件行
每个事件都递增计数器字段每次递增都会与其他递增操作冲突使用事件记录或分片计数器组件
修改操作没有提前返回重试和重复点击会使变更应用两次检查状态;如果已完成,则返回 null
对不相关的写入依次执行 await速度慢,而且每次读取都会扩大冲突窗口使用 Promise.all
在查询中使用 Date.now()结果每毫秒都会变化,导致缓存和订阅失效将 now 作为参数传入
调度 api.*公共函数运行时没有客户端身份验证调度 internal.*
用普通 Error 显示用户消息在生产环境中会被隐藏为“Server Error”使用 ConvexError
在处理函数中编写业务逻辑难以测试,且会在多个函数中重复编写接收 ctx 的普通辅助函数

检查清单

  • 每个函数都有 args 和 returns
  • 每次表读取都使用 withIndex,绝不使用 .filter()
  • 索引按字段顺序命名
  • 如果文档已处于目标状态,修改操作会提前返回
  • 除非需要旧值,否则修改操作无需先读取便可更新
  • 不相关的写入通过 Promise.all 并行执行
  • 高频计数使用事件行或计数器组件
  • 无界列表使用 .paginate()
  • 查询中不使用 Date.now()
  • 计划任务和 cron 的目标为 internal.*
  • 已安装 @convex-dev/eslint-plugin,且 npm run lint 通过

文档

评论

支持 Markdown