convex-anti-patterns

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Convex Anti-Patterns & Agent Rules

Convex反模式与Agent规则

Overview

概述

This skill documents critical mistakes to avoid in Convex development and rules that agents must follow. Every pattern here has caused real production issues.
本技能记录了Convex开发中需避免的严重错误,以及Agent必须遵循的规则。这里提到的每一种模式都曾引发过实际生产环境中的问题。

TypeScript: NEVER Use
any
Type

TypeScript:绝对禁止使用
any
类型

CRITICAL RULE: This codebase has
@typescript-eslint/no-explicit-any
enabled. Using
any
will cause build failures.
❌ WRONG:
typescript
function handleData(data: any) { ... }
const items: any[] = [];
args: { data: v.any() }  // Also avoid!
✅ CORRECT:
typescript
function handleData(data: Doc<"items">) { ... }
const items: Doc<"items">[] = [];
args: { data: v.object({ field: v.string() }) }
关键规则: 本代码库已启用
@typescript-eslint/no-explicit-any
规则。使用
any
类型会导致构建失败。
❌ 错误示例:
typescript
function handleData(data: any) { ... }
const items: any[] = [];
args: { data: v.any() }  // 同样要避免!
✅ 正确示例:
typescript
function handleData(data: Doc<"items">) { ... }
const items: Doc<"items">[] = [];
args: { data: v.object({ field: v.string() }) }

When to Use This Skill

何时使用本技能

Use this skill when:
  • Reviewing Convex code for issues
  • Debugging mysterious errors
  • Understanding why code doesn't work as expected
  • Learning Convex best practices by counter-example
  • Checking code against known anti-patterns
在以下场景中使用本技能:
  • 评审Convex代码以排查问题
  • 调试难以定位的错误
  • 理解代码未按预期运行的原因
  • 通过反例学习Convex最佳实践
  • 对照已知反模式检查代码

Critical Anti-Patterns

严重反模式

Anti-Pattern 1: fetch() in Mutations

反模式1:在Mutation中调用fetch()

Mutations must be deterministic. External calls break this guarantee.
❌ WRONG:
typescript
export const createOrder = mutation({
  args: { productId: v.string() },
  returns: v.null(),
  handler: async (ctx, args) => {
    // ❌ Mutations cannot make external HTTP calls!
    const price = await fetch(
      `https://api.stripe.com/prices/${args.productId}`
    );
    await ctx.db.insert("orders", {
      productId: args.productId,
      price: await price.json(),
    });
    return null;
  },
});
✅ CORRECT:
typescript
// Mutation creates record, schedules action for external call
export const createOrder = mutation({
  args: { productId: v.string() },
  returns: v.id("orders"),
  handler: async (ctx, args) => {
    const orderId = await ctx.db.insert("orders", {
      productId: args.productId,
      status: "pending",
    });
    await ctx.scheduler.runAfter(0, internal.orders.fetchPrice, { orderId });
    return orderId;
  },
});

// Action handles external API call
export const fetchPrice = internalAction({
  args: { orderId: v.id("orders") },
  returns: v.null(),
  handler: async (ctx, args) => {
    const order = await ctx.runQuery(internal.orders.getById, {
      orderId: args.orderId,
    });
    if (!order) return null;

    const response = await fetch(
      `https://api.stripe.com/prices/${order.productId}`
    );
    const priceData = await response.json();

    await ctx.runMutation(internal.orders.updatePrice, {
      orderId: args.orderId,
      price: priceData.unit_amount,
    });
    return null;
  },
});
Mutation必须是确定性的。外部调用会打破这一保证。
❌ 错误示例:
typescript
export const createOrder = mutation({
  args: { productId: v.string() },
  returns: v.null(),
  handler: async (ctx, args) => {
    // ❌ Mutation中不能发起外部HTTP调用!
    const price = await fetch(
      `https://api.stripe.com/prices/${args.productId}`
    );
    await ctx.db.insert("orders", {
      productId: args.productId,
      price: await price.json(),
    });
    return null;
  },
});
✅ 正确示例:
typescript
// Mutation创建记录,调度Action处理外部调用
export const createOrder = mutation({
  args: { productId: v.string() },
  returns: v.id("orders"),
  handler: async (ctx, args) => {
    const orderId = await ctx.db.insert("orders", {
      productId: args.productId,
      status: "pending",
    });
    await ctx.scheduler.runAfter(0, internal.orders.fetchPrice, { orderId });
    return orderId;
  },
});

// Action处理外部API调用
export const fetchPrice = internalAction({
  args: { orderId: v.id("orders") },
  returns: v.null(),
  handler: async (ctx, args) => {
    const order = await ctx.runQuery(internal.orders.getById, {
      orderId: args.orderId,
    });
    if (!order) return null;

    const response = await fetch(
      `https://api.stripe.com/prices/${order.productId}`
    );
    const priceData = await response.json();

    await ctx.runMutation(internal.orders.updatePrice, {
      orderId: args.orderId,
      price: priceData.unit_amount,
    });
    return null;
  },
});

Anti-Pattern 2: ctx.db in Actions

反模式2:在Action中使用ctx.db

Actions don't have database access. This is a common source of TypeScript errors.
❌ WRONG:
typescript
export const processData = action({
  args: { id: v.id("items") },
  returns: v.null(),
  handler: async (ctx, args) => {
    // ❌ Actions don't have ctx.db!
    const item = await ctx.db.get(args.id); // TypeScript Error!
    return null;
  },
});
✅ CORRECT:
typescript
export const processData = action({
  args: { id: v.id("items") },
  returns: v.null(),
  handler: async (ctx, args) => {
    // ✅ Use ctx.runQuery to read
    const item = await ctx.runQuery(internal.items.getById, { id: args.id });

    // Process with external APIs...
    const result = await fetch("https://api.example.com/process", {
      method: "POST",
      body: JSON.stringify(item),
    });

    // ✅ Use ctx.runMutation to write
    await ctx.runMutation(internal.items.updateResult, {
      id: args.id,
      result: await result.json(),
    });

    return null;
  },
});
Action没有数据库访问权限。这是TypeScript错误的常见来源。
❌ 错误示例:
typescript
export const processData = action({
  args: { id: v.id("items") },
  returns: v.null(),
  handler: async (ctx, args) => {
    // ❌ Action中没有ctx.db!
    const item = await ctx.db.get(args.id); // TypeScript错误!
    return null;
  },
});
✅ 正确示例:
typescript
export const processData = action({
  args: { id: v.id("items") },
  returns: v.null(),
  handler: async (ctx, args) => {
    // ✅ 使用ctx.runQuery读取数据
    const item = await ctx.runQuery(internal.items.getById, { id: args.id });

    // 调用外部API处理数据...
    const result = await fetch("https://api.example.com/process", {
      method: "POST",
      body: JSON.stringify(item),
    });

    // ✅ 使用ctx.runMutation写入数据
    await ctx.runMutation(internal.items.updateResult, {
      id: args.id,
      result: await result.json(),
    });

    return null;
  },
});

Anti-Pattern 3: Missing returns Validator

反模式3:缺少returns验证器

Every function must have an explicit
returns
validator.
❌ WRONG:
typescript
export const doSomething = mutation({
  args: { data: v.string() },
  // ❌ Missing returns!
  handler: async (ctx, args) => {
    await ctx.db.insert("items", { data: args.data });
    // Implicitly returns undefined
  },
});
✅ CORRECT:
typescript
export const doSomething = mutation({
  args: { data: v.string() },
  returns: v.null(), // ✅ Explicit returns validator
  handler: async (ctx, args) => {
    await ctx.db.insert("items", { data: args.data });
    return null; // ✅ Explicit return value
  },
});
每个函数都必须有显式的
returns
验证器。
❌ 错误示例:
typescript
export const doSomething = mutation({
  args: { data: v.string() },
  // ❌ 缺少returns!
  handler: async (ctx, args) => {
    await ctx.db.insert("items", { data: args.data });
    // 隐式返回undefined
  },
});
✅ 正确示例:
typescript
export const doSomething = mutation({
  args: { data: v.string() },
  returns: v.null(), // ✅ 显式的returns验证器
  handler: async (ctx, args) => {
    await ctx.db.insert("items", { data: args.data });
    return null; // ✅ 显式返回值
  },
});

Anti-Pattern 4: Using .filter() on Queries

反模式4:在Query中使用.filter()

.filter()
scans the entire table. Always use indexes.
❌ WRONG:
typescript
export const getActiveUsers = query({
  args: {},
  returns: v.array(v.object({ _id: v.id("users"), name: v.string() })),
  handler: async (ctx) => {
    // ❌ Full table scan!
    return await ctx.db
      .query("users")
      .filter((q) => q.eq(q.field("status"), "active"))
      .collect();
  },
});
✅ CORRECT:
typescript
// Schema: .index("by_status", ["status"])

export const getActiveUsers = query({
  args: {},
  returns: v.array(v.object({ _id: v.id("users"), name: v.string() })),
  handler: async (ctx) => {
    // ✅ Uses index
    return await ctx.db
      .query("users")
      .withIndex("by_status", (q) => q.eq("status", "active"))
      .collect();
  },
});
.filter()
会扫描整个表。应始终使用索引。
❌ 错误示例:
typescript
export const getActiveUsers = query({
  args: {},
  returns: v.array(v.object({ _id: v.id("users"), name: v.string() })),
  handler: async (ctx) => {
    // ❌ 全表扫描!
    return await ctx.db
      .query("users")
      .filter((q) => q.eq(q.field("status"), "active"))
      .collect();
  },
});
✅ 正确示例:
typescript
// 数据库Schema:.index("by_status", ["status"])

export const getActiveUsers = query({
  args: {},
  returns: v.array(v.object({ _id: v.id("users"), name: v.string() })),
  handler: async (ctx) => {
    // ✅ 使用索引
    return await ctx.db
      .query("users")
      .withIndex("by_status", (q) => q.eq("status", "active"))
      .collect();
  },
});

Anti-Pattern 5: Unbounded .collect()

反模式5:无限制的.collect()

Never collect without limits on potentially large tables.
❌ WRONG:
typescript
export const getAllMessages = query({
  args: { channelId: v.id("channels") },
  returns: v.array(v.object({ content: v.string() })),
  handler: async (ctx, args) => {
    // ❌ Could return millions of records!
    return await ctx.db
      .query("messages")
      .withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
      .collect();
  },
});
✅ CORRECT:
typescript
export const getRecentMessages = query({
  args: { channelId: v.id("channels") },
  returns: v.array(v.object({ content: v.string() })),
  handler: async (ctx, args) => {
    // ✅ Bounded with take()
    return await ctx.db
      .query("messages")
      .withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
      .order("desc")
      .take(50);
  },
});
对于可能很大的表,绝对不要无限制地使用.collect()。
❌ 错误示例:
typescript
export const getAllMessages = query({
  args: { channelId: v.id("channels") },
  returns: v.array(v.object({ content: v.string() })),
  handler: async (ctx, args) => {
    // ❌ 可能返回数百万条记录!
    return await ctx.db
      .query("messages")
      .withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
      .collect();
  },
});
✅ 正确示例:
typescript
export const getRecentMessages = query({
  args: { channelId: v.id("channels") },
  returns: v.array(v.object({ content: v.string() })),
  handler: async (ctx, args) => {
    // ✅ 使用take()限制数量
    return await ctx.db
      .query("messages")
      .withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
      .order("desc")
      .take(50);
  },
});

Anti-Pattern 6: .collect().length for Counts

反模式6:使用.collect().length统计数量

Collecting just to count is wasteful.
❌ WRONG:
typescript
export const getMessageCount = query({
  args: { channelId: v.id("channels") },
  returns: v.number(),
  handler: async (ctx, args) => {
    // ❌ Loads all messages just to count!
    const messages = await ctx.db
      .query("messages")
      .withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
      .collect();
    return messages.length;
  },
});
✅ CORRECT:
typescript
// Option 1: Bounded count with "99+" display
export const getMessageCount = query({
  args: { channelId: v.id("channels") },
  returns: v.string(),
  handler: async (ctx, args) => {
    const messages = await ctx.db
      .query("messages")
      .withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
      .take(100);
    return messages.length === 100 ? "99+" : String(messages.length);
  },
});

// Option 2: Denormalized counter (best for high traffic)
// Maintain messageCount field in channels table
export const getMessageCount = query({
  args: { channelId: v.id("channels") },
  returns: v.number(),
  handler: async (ctx, args) => {
    const channel = await ctx.db.get(args.channelId);
    return channel?.messageCount ?? 0;
  },
});
仅为了统计数量而调用.collect()是低效的。
❌ 错误示例:
typescript
export const getMessageCount = query({
  args: { channelId: v.id("channels") },
  returns: v.number(),
  handler: async (ctx, args) => {
    // ❌ 加载所有消息只是为了统计数量!
    const messages = await ctx.db
      .query("messages")
      .withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
      .collect();
    return messages.length;
  },
});
✅ 正确示例:
typescript
// 选项1:带限制的统计,显示“99+”
export const getMessageCount = query({
  args: { channelId: v.id("channels") },
  returns: v.string(),
  handler: async (ctx, args) => {
    const messages = await ctx.db
      .query("messages")
      .withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
      .take(100);
    return messages.length === 100 ? "99+" : String(messages.length);
  },
});

// 选项2:反规范化计数器(高流量场景最佳方案)
// 在channels表中维护messageCount字段
export const getMessageCount = query({
  args: { channelId: v.id("channels") },
  returns: v.number(),
  handler: async (ctx, args) => {
    const channel = await ctx.db.get(args.channelId);
    return channel?.messageCount ?? 0;
  },
});

Anti-Pattern 7: N+1 Query Pattern

反模式7:N+1查询模式

Loading related documents one by one.
❌ WRONG:
typescript
export const getPostsWithAuthors = query({
  args: {},
  returns: v.array(
    v.object({
      post: v.object({ title: v.string() }),
      author: v.object({ name: v.string() }),
    })
  ),
  handler: async (ctx) => {
    const posts = await ctx.db.query("posts").take(10);

    // ❌ N additional queries!
    const postsWithAuthors = await Promise.all(
      posts.map(async (post) => ({
        post: { title: post.title },
        author: await ctx.db
          .get(post.authorId)
          .then((a) => ({ name: a!.name })),
      }))
    );

    return postsWithAuthors;
  },
});
✅ CORRECT:
typescript
import { getAll } from "convex-helpers/server/relationships";

export const getPostsWithAuthors = query({
  args: {},
  returns: v.array(
    v.object({
      post: v.object({ title: v.string() }),
      author: v.union(v.object({ name: v.string() }), v.null()),
    })
  ),
  handler: async (ctx) => {
    const posts = await ctx.db.query("posts").take(10);

    // ✅ Batch fetch all authors
    const authorIds = [...new Set(posts.map((p) => p.authorId))];
    const authors = await getAll(ctx.db, authorIds);
    const authorMap = new Map(
      authors
        .filter((a): a is NonNullable<typeof a> => a !== null)
        .map((a) => [a._id, a])
    );

    return posts.map((post) => ({
      post: { title: post.title },
      author: authorMap.get(post.authorId)
        ? { name: authorMap.get(post.authorId)!.name }
        : null,
    }));
  },
});
逐个加载关联文档。
❌ 错误示例:
typescript
export const getPostsWithAuthors = query({
  args: {},
  returns: v.array(
    v.object({
      post: v.object({ title: v.string() }),
      author: v.object({ name: v.string() }),
    })
  ),
  handler: async (ctx) => {
    const posts = await ctx.db.query("posts").take(10);

    // ❌ 额外发起N次查询!
    const postsWithAuthors = await Promise.all(
      posts.map(async (post) => ({
        post: { title: post.title },
        author: await ctx.db
          .get(post.authorId)
          .then((a) => ({ name: a!.name })),
      }))
    );

    return postsWithAuthors;
  },
});
✅ 正确示例:
typescript
import { getAll } from "convex-helpers/server/relationships";

export const getPostsWithAuthors = query({
  args: {},
  returns: v.array(
    v.object({
      post: v.object({ title: v.string() }),
      author: v.union(v.object({ name: v.string() }), v.null()),
    })
  ),
  handler: async (ctx) => {
    const posts = await ctx.db.query("posts").take(10);

    // ✅ 批量获取所有作者
    const authorIds = [...new Set(posts.map((p) => p.authorId))];
    const authors = await getAll(ctx.db, authorIds);
    const authorMap = new Map(
      authors
        .filter((a): a is NonNullable<typeof a> => a !== null)
        .map((a) => [a._id, a])
    );

    return posts.map((post) => ({
      post: { title: post.title },
      author: authorMap.get(post.authorId)
        ? { name: authorMap.get(post.authorId)!.name }
        : null,
    }));
  },
});

Anti-Pattern 8: Global Counter (Hot Spot)

反模式8:全局计数器(热点)

Single document updates cause OCC conflicts under load.
❌ WRONG:
typescript
export const incrementPageViews = mutation({
  args: {},
  returns: v.null(),
  handler: async (ctx) => {
    // ❌ Every request writes to same document!
    const stats = await ctx.db.query("globalStats").unique();
    await ctx.db.patch(stats!._id, { views: stats!.views + 1 });
    return null;
  },
});
✅ CORRECT:
typescript
// Option 1: Sharding
export const incrementPageViews = mutation({
  args: {},
  returns: v.null(),
  handler: async (ctx) => {
    // ✅ Write to random shard
    const shardId = Math.floor(Math.random() * 10);
    await ctx.db.insert("viewShards", { shardId, delta: 1 });
    return null;
  },
});

// Read by aggregating shards
export const getPageViews = query({
  args: {},
  returns: v.number(),
  handler: async (ctx) => {
    const shards = await ctx.db.query("viewShards").collect();
    return shards.reduce((sum, s) => sum + s.delta, 0);
  },
});

// Option 2: Use Workpool to serialize
import { Workpool } from "@convex-dev/workpool";

const counterPool = new Workpool(components.workpool, { maxParallelism: 1 });

export const incrementPageViews = mutation({
  args: {},
  returns: v.null(),
  handler: async (ctx) => {
    await counterPool.enqueueMutation(ctx, internal.stats.doIncrement, {});
    return null;
  },
});
对单个文档的更新会在高负载下引发OCC冲突。
❌ 错误示例:
typescript
export const incrementPageViews = mutation({
  args: {},
  returns: v.null(),
  handler: async (ctx) => {
    // ❌ 每个请求都写入同一个文档!
    const stats = await ctx.db.query("globalStats").unique();
    await ctx.db.patch(stats!._id, { views: stats!.views + 1 });
    return null;
  },
});
✅ 正确示例:
typescript
// 选项1:分片处理
export const incrementPageViews = mutation({
  args: {},
  returns: v.null(),
  handler: async (ctx) => {
    // ✅ 写入随机分片
    const shardId = Math.floor(Math.random() * 10);
    await ctx.db.insert("viewShards", { shardId, delta: 1 });
    return null;
  },
});

// 通过聚合分片数据读取统计值
export const getPageViews = query({
  args: {},
  returns: v.number(),
  handler: async (ctx) => {
    const shards = await ctx.db.query("viewShards").collect();
    return shards.reduce((sum, s) => sum + s.delta, 0);
  },
});

// 选项2:使用Workpool序列化请求
import { Workpool } from "@convex-dev/workpool";

const counterPool = new Workpool(components.workpool, { maxParallelism: 1 });

export const incrementPageViews = mutation({
  args: {},
  returns: v.null(),
  handler: async (ctx) => {
    await counterPool.enqueueMutation(ctx, internal.stats.doIncrement, {});
    return null;
  },
});

Anti-Pattern 9: Using v.bigint() (Deprecated)

反模式9:使用v.bigint()(已废弃)

❌ WRONG:
typescript
export default defineSchema({
  counters: defineTable({
    value: v.bigint(), // ❌ Deprecated!
  }),
});
✅ CORRECT:
typescript
export default defineSchema({
  counters: defineTable({
    value: v.int64(), // ✅ Use v.int64()
  }),
});
❌ 错误示例:
typescript
export default defineSchema({
  counters: defineTable({
    value: v.bigint(), // ❌ 已废弃!
  }),
});
✅ 正确示例:
typescript
export default defineSchema({
  counters: defineTable({
    value: v.int64(), // ✅ 使用v.int64()
  }),
});

Anti-Pattern 10: Missing System Fields in Return Validators

反模式10:返回验证器中缺少系统字段

❌ WRONG:
typescript
export const getUser = query({
  args: { userId: v.id("users") },
  returns: v.object({
    // ❌ Missing _id and _creationTime!
    name: v.string(),
    email: v.string(),
  }),
  handler: async (ctx, args) => {
    return await ctx.db.get(args.userId); // Returns full doc including system fields
  },
});
✅ CORRECT:
typescript
export const getUser = query({
  args: { userId: v.id("users") },
  returns: v.union(
    v.object({
      _id: v.id("users"), // ✅ Include system fields
      _creationTime: v.number(),
      name: v.string(),
      email: v.string(),
    }),
    v.null()
  ),
  handler: async (ctx, args) => {
    return await ctx.db.get(args.userId);
  },
});
❌ 错误示例:
typescript
export const getUser = query({
  args: { userId: v.id("users") },
  returns: v.object({
    // ❌ 缺少_id和_creationTime!
    name: v.string(),
    email: v.string(),
  }),
  handler: async (ctx, args) => {
    return await ctx.db.get(args.userId); // 返回包含系统字段的完整文档
  },
});
✅ 正确示例:
typescript
export const getUser = query({
  args: { userId: v.id("users") },
  returns: v.union(
    v.object({
      _id: v.id("users"), // ✅ 包含系统字段
      _creationTime: v.number(),
      name: v.string(),
      email: v.string(),
    }),
    v.null()
  ),
  handler: async (ctx, args) => {
    return await ctx.db.get(args.userId);
  },
});

Anti-Pattern 11: Public Functions for Internal Logic

反模式11:将内部逻辑设为公共函数

❌ WRONG:
typescript
// ❌ This is callable by any client!
export const deleteUserData = mutation({
  args: { userId: v.id("users") },
  returns: v.null(),
  handler: async (ctx, args) => {
    // Dangerous operation exposed publicly
    await ctx.db.delete(args.userId);
    return null;
  },
});
✅ CORRECT:
typescript
// Internal mutation - not callable by clients
export const deleteUserData = internalMutation({
  args: { userId: v.id("users") },
  returns: v.null(),
  handler: async (ctx, args) => {
    await ctx.db.delete(args.userId);
    return null;
  },
});

// Public mutation with auth check
export const requestAccountDeletion = mutation({
  args: {},
  returns: v.null(),
  handler: async (ctx) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) throw new Error("Unauthorized");

    const user = await ctx.db
      .query("users")
      .withIndex("by_token", (q) =>
        q.eq("tokenIdentifier", identity.tokenIdentifier)
      )
      .unique();

    if (!user) throw new Error("User not found");

    // Schedule internal mutation
    await ctx.scheduler.runAfter(0, internal.users.deleteUserData, {
      userId: user._id,
    });

    return null;
  },
});
❌ 错误示例:
typescript
// ❌ 任何客户端都可以调用此函数!
export const deleteUserData = mutation({
  args: { userId: v.id("users") },
  returns: v.null(),
  handler: async (ctx, args) => {
    // 危险操作被公开暴露
    await ctx.db.delete(args.userId);
    return null;
  },
});
✅ 正确示例:
typescript
// 内部Mutation - 客户端无法直接调用
export const deleteUserData = internalMutation({
  args: { userId: v.id("users") },
  returns: v.null(),
  handler: async (ctx, args) => {
    await ctx.db.delete(args.userId);
    return null;
  },
});

// 带权限校验的公共Mutation
export const requestAccountDeletion = mutation({
  args: {},
  returns: v.null(),
  handler: async (ctx) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) throw new Error("未授权");

    const user = await ctx.db
      .query("users")
      .withIndex("by_token", (q) =>
        q.eq("tokenIdentifier", identity.tokenIdentifier)
      )
      .unique();

    if (!user) throw new Error("用户不存在");

    // 调度内部Mutation执行
    await ctx.scheduler.runAfter(0, internal.users.deleteUserData, {
      userId: user._id,
    });

    return null;
  },
});

Anti-Pattern 12: Non-Transactional Actions for Data Consistency

反模式12:使用非事务性Action保证数据一致性

❌ WRONG:
typescript
export const transferFunds = action({
  args: { from: v.id("accounts"), to: v.id("accounts"), amount: v.number() },
  returns: v.null(),
  handler: async (ctx, args) => {
    // ❌ These are separate transactions - could leave inconsistent state!
    await ctx.runMutation(internal.accounts.debit, {
      accountId: args.from,
      amount: args.amount,
    });

    // If this fails, money was debited but not credited!
    await ctx.runMutation(internal.accounts.credit, {
      accountId: args.to,
      amount: args.amount,
    });

    return null;
  },
});
✅ CORRECT:
typescript
// Single atomic mutation
export const transferFunds = mutation({
  args: { from: v.id("accounts"), to: v.id("accounts"), amount: v.number() },
  returns: v.null(),
  handler: async (ctx, args) => {
    // ✅ All in one transaction - all succeed or all fail
    const fromAccount = await ctx.db.get(args.from);
    const toAccount = await ctx.db.get(args.to);

    if (!fromAccount || !toAccount) throw new Error("Account not found");
    if (fromAccount.balance < args.amount)
      throw new Error("Insufficient funds");

    await ctx.db.patch(args.from, {
      balance: fromAccount.balance - args.amount,
    });
    await ctx.db.patch(args.to, { balance: toAccount.balance + args.amount });

    return null;
  },
});
❌ 错误示例:
typescript
export const transferFunds = action({
  args: { from: v.id("accounts"), to: v.id("accounts"), amount: v.number() },
  returns: v.null(),
  handler: async (ctx, args) => {
    // ❌ 这些是独立的事务 - 可能导致数据状态不一致!
    await ctx.runMutation(internal.accounts.debit, {
      accountId: args.from,
      amount: args.amount,
    });

    // 如果这一步失败,资金已被扣除但未转入目标账户!
    await ctx.runMutation(internal.accounts.credit, {
      accountId: args.to,
      amount: args.amount,
    });

    return null;
  },
});
✅ 正确示例:
typescript
// 单个原子性Mutation
export const transferFunds = mutation({
  args: { from: v.id("accounts"), to: v.id("accounts"), amount: v.number() },
  returns: v.null(),
  handler: async (ctx, args) => {
    // ✅ 所有操作在一个事务中 - 要么全部成功,要么全部失败
    const fromAccount = await ctx.db.get(args.from);
    const toAccount = await ctx.db.get(args.to);

    if (!fromAccount || !toAccount) throw new Error("账户不存在");
    if (fromAccount.balance < args.amount)
      throw new Error("余额不足");

    await ctx.db.patch(args.from, {
      balance: fromAccount.balance - args.amount,
    });
    await ctx.db.patch(args.to, { balance: toAccount.balance + args.amount });

    return null;
  },
});

Anti-Pattern 13: Redundant Indexes

反模式13:冗余索引

❌ WRONG:
typescript
export default defineSchema({
  messages: defineTable({
    channelId: v.id("channels"),
    authorId: v.id("users"),
    content: v.string(),
  })
    .index("by_channel", ["channelId"]) // ❌ Redundant!
    .index("by_channel_author", ["channelId", "authorId"]),
});
✅ CORRECT:
typescript
export default defineSchema({
  messages: defineTable({
    channelId: v.id("channels"),
    authorId: v.id("users"),
    content: v.string(),
  })
    // ✅ Single compound index serves both query patterns
    .index("by_channel_author", ["channelId", "authorId"]),
});

// Use prefix matching for channel-only queries:
// .withIndex("by_channel_author", (q) => q.eq("channelId", id))
❌ 错误示例:
typescript
export default defineSchema({
  messages: defineTable({
    channelId: v.id("channels"),
    authorId: v.id("users"),
    content: v.string(),
  })
    .index("by_channel", ["channelId"]) // ❌ 冗余!
    .index("by_channel_author", ["channelId", "authorId"]),
});
✅ 正确示例:
typescript
export default defineSchema({
  messages: defineTable({
    channelId: v.id("channels"),
    authorId: v.id("users"),
    content: v.string(),
  })
    // ✅ 单个复合索引可满足两种查询模式
    .index("by_channel_author", ["channelId", "authorId"]),
});

// 针对仅按channel查询的场景使用前缀匹配:
// .withIndex("by_channel_author", (q) => q.eq("channelId", id))

Anti-Pattern 14: Using v.string() for IDs

反模式14:使用v.string()表示ID

❌ WRONG:
typescript
export const getMessage = query({
  args: { messageId: v.string() }, // ❌ Should be v.id()
  returns: v.null(),
  handler: async (ctx, args) => {
    // Type error or runtime error
    return await ctx.db.get(args.messageId as Id<"messages">);
  },
});
✅ CORRECT:
typescript
export const getMessage = query({
  args: { messageId: v.id("messages") }, // ✅ Proper ID type
  returns: v.union(
    v.object({
      _id: v.id("messages"),
      _creationTime: v.number(),
      content: v.string(),
    }),
    v.null()
  ),
  handler: async (ctx, args) => {
    return await ctx.db.get(args.messageId);
  },
});
❌ 错误示例:
typescript
export const getMessage = query({
  args: { messageId: v.string() }, // ❌ 应该使用v.id()
  returns: v.null(),
  handler: async (ctx, args) => {
    // 类型错误或运行时错误
    return await ctx.db.get(args.messageId as Id<"messages">);
  },
});
✅ 正确示例:
typescript
export const getMessage = query({
  args: { messageId: v.id("messages") }, // ✅ 正确的ID类型
  returns: v.union(
    v.object({
      _id: v.id("messages"),
      _creationTime: v.number(),
      content: v.string(),
    }),
    v.null()
  ),
  handler: async (ctx, args) => {
    return await ctx.db.get(args.messageId);
  },
});

Anti-Pattern 15: Retry Without Backoff or Jitter

反模式15:重试时不使用退避算法或抖动

❌ WRONG:
typescript
export const processWithRetry = internalAction({
  args: { jobId: v.id("jobs"), attempt: v.number() },
  returns: v.null(),
  handler: async (ctx, args) => {
    try {
      // Process...
    } catch (error) {
      if (args.attempt < 5) {
        // ❌ Fixed delay causes thundering herd!
        await ctx.scheduler.runAfter(5000, internal.jobs.processWithRetry, {
          jobId: args.jobId,
          attempt: args.attempt + 1,
        });
      }
    }
    return null;
  },
});
✅ CORRECT:
typescript
export const processWithRetry = internalAction({
  args: { jobId: v.id("jobs"), attempt: v.number() },
  returns: v.null(),
  handler: async (ctx, args) => {
    try {
      // Process...
    } catch (error) {
      if (args.attempt < 5) {
        // ✅ Exponential backoff + jitter
        const baseDelay = Math.pow(2, args.attempt) * 1000;
        const jitter = Math.random() * 1000;
        await ctx.scheduler.runAfter(
          baseDelay + jitter,
          internal.jobs.processWithRetry,
          {
            jobId: args.jobId,
            attempt: args.attempt + 1,
          }
        );
      }
    }
    return null;
  },
});
❌ 错误示例:
typescript
export const processWithRetry = internalAction({
  args: { jobId: v.id("jobs"), attempt: v.number() },
  returns: v.null(),
  handler: async (ctx, args) => {
    try {
      // 处理任务...
    } catch (error) {
      if (args.attempt < 5) {
        // ❌ 固定延迟会导致惊群效应!
        await ctx.scheduler.runAfter(5000, internal.jobs.processWithRetry, {
          jobId: args.jobId,
          attempt: args.attempt + 1,
        });
      }
    }
    return null;
  },
});
✅ 正确示例:
typescript
export const processWithRetry = internalAction({
  args: { jobId: v.id("jobs"), attempt: v.number() },
  returns: v.null(),
  handler: async (ctx, args) => {
    try {
      // 处理任务...
    } catch (error) {
      if (args.attempt < 5) {
        // ✅ 指数退避 + 抖动
        const baseDelay = Math.pow(2, args.attempt) * 1000;
        const jitter = Math.random() * 1000;
        await ctx.scheduler.runAfter(
          baseDelay + jitter,
          internal.jobs.processWithRetry,
          {
            jobId: args.jobId,
            attempt: args.attempt + 1,
          }
        );
      }
    }
    return null;
  },
});

Agent Rules Summary

Agent规则总结

Must Do

必须遵守

  1. Always include
    returns
    validator
    on every function
  2. Always use indexes instead of
    .filter()
  3. Always use
    take(n)
    for potentially large queries
  4. Always use
    v.id("table")
    for document ID arguments
  5. Always use
    internalMutation
    /
    internalAction
    for sensitive operations
  6. Always handle errors in actions and update status in database
  7. Always use exponential backoff with jitter for retries
  1. 所有函数必须包含
    returns
    验证器
  2. 始终使用索引而非
    .filter()
  3. **始终使用
    take(n)
    **处理可能返回大量数据的查询
  4. **始终使用
    v.id("table")
    **定义文档ID类型的参数
  5. **始终使用
    internalMutation
    /
    internalAction
    **处理敏感操作
  6. 始终在Action中处理错误并在数据库中更新状态
  7. 重试时始终使用带抖动的指数退避算法

Must Not Do

绝对禁止

  1. Never call
    fetch()
    in mutations
  2. Never access
    ctx.db
    in actions
  3. Never use
    .filter()
    on database queries
  4. Never use
    .collect()
    without limits on large tables
  5. Never use
    v.bigint()
    (deprecated, use
    v.int64()
    )
  6. Never use
    any
    type
    (ESLint rule enforced)
  7. Never write to hot-spot documents without sharding/workpool
  8. Never expose dangerous operations as public functions
  9. Never rely on multiple mutations for atomic operations
  1. 绝对不要在Mutation中调用
    fetch()
  2. 绝对不要在Action中访问
    ctx.db
  3. 绝对不要在数据库查询中使用
    .filter()
  4. 绝对不要在大表上无限制地使用
    .collect()
  5. 绝对不要使用
    v.bigint()
    (已废弃,使用
    v.int64()
    替代)
  6. 绝对不要使用
    any
    类型
    (ESLint规则强制禁止)
  7. 绝对不要在热点文档上直接写入,需使用分片或Workpool
  8. 绝对不要将危险操作暴露为公共函数
  9. 绝对不要依赖多个Mutation保证原子性

Quick Checklist

快速检查清单

Before submitting Convex code, verify:
  • All functions have
    returns
    validators
  • All queries use indexes (no
    .filter()
    )
  • All
    .collect()
    calls are bounded with
    .take(n)
  • All ID arguments use
    v.id("tableName")
  • External API calls are in actions, not mutations
  • Actions use
    ctx.runQuery
    /
    ctx.runMutation
    for DB access
  • Sensitive operations use internal functions
  • No
    any
    types in the codebase
  • High-write documents use sharding or Workpool
  • Retries use exponential backoff with jitter
提交Convex代码前,请验证:
  • 所有函数都有
    returns
    验证器
  • 所有查询都使用索引(无
    .filter()
  • 所有
    .collect()
    调用都通过
    .take(n)
    限制数量
  • 所有ID类型的参数都使用
    v.id("tableName")
  • 外部API调用都在Action中执行,而非Mutation
  • Action通过
    ctx.runQuery
    /
    ctx.runMutation
    访问数据库
  • 敏感操作使用内部函数
  • 代码库中无
    any
    类型
  • 高写入量文档使用分片或Workpool
  • 重试逻辑使用带抖动的指数退避算法