mcpField
C
convex-anti-patterns

convex-anti-patterns

github.com/op0ai開発1776 回インストール41 週間前に更新
リポジトリ

convex-anti-patterns について

Convex 開発者やコーディングエージェント向けに、本番で問題を招くアンチパターンと実装ルールをまとめたスキルです。クエリ、関数権限、トランザクション、型、リトライなどをコードレビューやデバッグ時に確認できます。

主な機能

  1. 01Convex のアンチパターンを整理
  2. 02関数の戻り値バリデーターを必須化
  3. 03インデックスと上限付きクエリを案内
  4. 04アクションとミューテーションの境界を明示
  5. 05権限、トランザクション、リトライを網羅

活用シーン

  1. 01Convex コードのレビュー
  2. 02原因を特定しにくいエラーの調査
  3. 03提出前のコードチェック

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/op0ai/claude-skills --skill convex-anti-patterns

インストール後、Claude Code で/convex-anti-patternsを入力して読み込まれたか確認します。

詳細

Convex のアンチパターンとエージェントのルール

概要

このスキルでは、Convex の開発で避けるべき重大なミスと、エージェントが従うべきルールを説明します。ここに記載されたパターンは、すべて実際に本番環境で問題を引き起こしています。

TypeScript:any 型は絶対に使用しない

重大なルール: このコードベースでは @typescript-eslint/no-explicit-any が有効になっています。any を使用すると、ビルドに失敗します。

❌ 誤り:

function handleData(data: any) { ... }
const items: any[] = [];
args: { data: v.any() }  // Also avoid!

✅ 正しい例:

function handleData(data: Doc<"items">) { ... }
const items: Doc<"items">[] = [];
args: { data: v.object({ field: v.string() }) }

このスキルを使う場面

次のような場合に、このスキルを使用してください。

  • Convex のコードに問題がないかレビューするとき
  • 原因不明のエラーをデバッグするとき
  • コードが期待どおりに動作しない理由を理解するとき
  • 反例を通じて Convex のベストプラクティスを学ぶとき
  • 既知のアンチパターンに照らしてコードを確認するとき

重大なアンチパターン

アンチパターン 1:ミューテーション内での fetch() の使用

ミューテーションは決定論的でなければなりません。外部呼び出しはこの保証を損ないます。

❌ 誤り:

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;
  },
});

✅ 正しい例:

// 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;
  },
});
アンチパターン 2:アクション内での ctx.db の使用

アクションからはデータベースにアクセスできません。これは 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;
  },
});

✅ 正しい例:

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;
  },
});
アンチパターン 3:returns バリデーターの欠落

すべての関数に、明示的な returns バリデーターを指定する必要があります。

❌ 誤り:

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
  },
});

✅ 正しい例:

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
  },
});
アンチパターン 4:クエリでの .filter() の使用

.filter() はテーブル全体をスキャンします。必ずインデックスを使用してください。

❌ 誤り:

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();
  },
});

✅ 正しい例:

// 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();
  },
});
アンチパターン 5:上限のない .collect()

サイズが大きくなる可能性のあるテーブルでは、上限を指定せずに収集しないでください。

❌ 誤り:

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();
  },
});

✅ 正しい例:

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);
  },
});
アンチパターン 6:件数の取得に .collect().length を使う

件数を数えるだけのために収集するのは無駄です。

❌ 誤り:

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;
  },
});

✅ 正しい例:

// 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;
  },
});
アンチパターン 7:N+1 クエリパターン

関連するドキュメントを 1 件ずつ読み込みます。

❌ 誤り:

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;
  },
});

✅ 正しい例:

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,
    }));
  },
});
アンチパターン 8:グローバルカウンター(ホットスポット)

単一のドキュメントを更新すると、負荷が高い状況で OCC の競合が発生します。

❌ 誤り:

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;
  },
});

✅ 正しい例:

// 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;
  },
});
アンチパターン 9:v.bigint() の使用(非推奨)

❌ 誤り:

export default defineSchema({
  counters: defineTable({
    value: v.bigint(), // ❌ Deprecated!
  }),
});

✅ 正しい例:

export default defineSchema({
  counters: defineTable({
    value: v.int64(), // ✅ Use v.int64()
  }),
});
アンチパターン 10:戻り値のバリデーターでのシステムフィールドの欠落

❌ 誤り:

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
  },
});

✅ 正しい例:

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);
  },
});
アンチパターン 11:内部ロジックに公開関数を使う

❌ 誤り:

// ❌ 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;
  },
});

✅ 正しい例:

// 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;
  },
});
アンチパターン 12:データの一貫性を保つために非トランザクションのアクションを使う

❌ 誤り:

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;
  },
});

✅ 正しい例:

// 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;
  },
});
アンチパターン 13:冗長なインデックス

❌ 誤り:

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"]),
});

✅ 正しい例:

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))
アンチパターン 14:ID に v.string() を使う

❌ 誤り:

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">);
  },
});

✅ 正しい例:

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);
  },
});
アンチパターン 15:バックオフやジッターなしの再試行

❌ 誤り:

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;
  },
});

✅ 正しい例:

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;
  },
});

エージェントのルールまとめ

必ず行うこと
  1. すべての関数に returns バリデーターを必ず指定する
  2. .filter() の代わりに必ずインデックスを使用する
  3. サイズが大きくなる可能性のあるクエリには、必ず take(n) を使用する
  4. ドキュメント ID の引数には、必ず v.id("table") を使用する
  5. 機密性の高い操作には、必ず internalMutation/internalAction を使用する
  6. アクションで必ずエラーを処理し、データベースのステータスを更新する
  7. 再試行には、必ずジッターを加えた指数バックオフを使用する
絶対に行わないこと
  1. ミューテーション内で絶対に fetch() を呼び出さない
  2. アクション内で絶対に ctx.db にアクセスしない
  3. データベースクエリで絶対に .filter() を使用しない
  4. 大きなテーブルに対して、上限なしで絶対に .collect() を使用しない
  5. 絶対に v.bigint() を使用しない(非推奨のため、v.int64() を使用する)
  6. 絶対に any 型を使用しない(ESLint ルールで禁止)
  7. シャーディング/workpool なしで、ホットスポットとなるドキュメントに絶対に書き込まない
  8. 危険な操作を公開関数として絶対に公開しない
  9. アトミックな操作を複数のミューテーションに絶対に依存させない
クイックチェックリスト

Convex のコードを提出する前に、次の項目を確認してください。

  • すべての関数に returns バリデーターがある
  • すべてのクエリでインデックスを使用している(.filter() は使用しない)
  • すべての .collect() 呼び出しを .take(n) で制限している
  • すべての ID 引数で v.id("tableName") を使用している
  • 外部 API 呼び出しをミューテーションではなくアクション内に記述している
  • アクションで DB にアクセスする際に ctx.runQuery/ctx.runMutation を使用している
  • 機密性の高い操作に内部関数を使用している
  • コードベースに any 型がない
  • 書き込み頻度の高いドキュメントでシャーディングまたは Workpool を使用している
  • リトライでジッターを伴う指数バックオフを使用している

コメント