convex-anti-patterns について
Convex 開発者やコーディングエージェント向けに、本番で問題を招くアンチパターンと実装ルールをまとめたスキルです。クエリ、関数権限、トランザクション、型、リトライなどをコードレビューやデバッグ時に確認できます。
主な機能
- 01Convex のアンチパターンを整理
- 02関数の戻り値バリデーターを必須化
- 03インデックスと上限付きクエリを案内
- 04アクションとミューテーションの境界を明示
- 05権限、トランザクション、リトライを網羅
活用シーン
- 01Convex コードのレビュー
- 02原因を特定しにくいエラーの調査
- 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;
},
});
エージェントのルールまとめ
必ず行うこと
- すべての関数に
returnsバリデーターを必ず指定する .filter()の代わりに必ずインデックスを使用する- サイズが大きくなる可能性のあるクエリには、必ず
take(n)を使用する - ドキュメント ID の引数には、必ず
v.id("table")を使用する - 機密性の高い操作には、必ず
internalMutation/internalActionを使用する - アクションで必ずエラーを処理し、データベースのステータスを更新する
- 再試行には、必ずジッターを加えた指数バックオフを使用する
絶対に行わないこと
- ミューテーション内で絶対に
fetch()を呼び出さない - アクション内で絶対に
ctx.dbにアクセスしない - データベースクエリで絶対に
.filter()を使用しない - 大きなテーブルに対して、上限なしで絶対に
.collect()を使用しない - 絶対に
v.bigint()を使用しない(非推奨のため、v.int64()を使用する) - 絶対に
any型を使用しない(ESLint ルールで禁止) - シャーディング/workpool なしで、ホットスポットとなるドキュメントに絶対に書き込まない
- 危険な操作を公開関数として絶対に公開しない
- アトミックな操作を複数のミューテーションに絶対に依存させない
クイックチェックリスト
Convex のコードを提出する前に、次の項目を確認してください。
- すべての関数に
returnsバリデーターがある - すべてのクエリでインデックスを使用している(
.filter()は使用しない) - すべての
.collect()呼び出しを.take(n)で制限している - すべての ID 引数で
v.id("tableName")を使用している - 外部 API 呼び出しをミューテーションではなくアクション内に記述している
- アクションで DB にアクセスする際に
ctx.runQuery/ctx.runMutationを使用している - 機密性の高い操作に内部関数を使用している
- コードベースに
any型がない - 書き込み頻度の高いドキュメントでシャーディングまたは Workpool を使用している
- リトライでジッターを伴う指数バックオフを使用している