convex-best-practices について
Convex 開発者向けのベストプラクティススキルです。インデックスを使った読み取り、バリデーター、冪等な書き込みなどの本番向けパターンに加え、競合とページネーションも扱います。
主な機能
- 01引数と戻り値のバリデーターを必須化
- 02テーブルフィルターよりインデックスを使用
- 03再試行に安全なミューテーション
- 04競合とページネーションの方針を説明
- 05ESLint プラグインの設定を案内
活用シーン
- 01Convex 関数のデータアクセスをレビュー
- 02頻繁な更新による書き込み競合を抑制
- 03増加するデータ一覧をページネーション
- 04よくあるミスを検出する lint ルールの設定
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ waynesutton/ builder-skills --skill convex-best-practicesインストール後、Claude Code で/convex-best-practicesを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
agents/openai.yamlassets/small-logo.svgreferences/eslint-setup.md詳細
Convex のベストプラクティス
Convex アプリを本番環境で高速かつ正確に保つパターンです。最も重要なルールは、書き込む前に必要最小限のデータだけを読み、インデックスを通じて読み取ることです。
特に重要なルール
- すべての関数にバリデーターを指定します。
argsとreturnsを設定し、何も返さない場合はreturns: v.null()を指定します。 - フィルターではなく、インデックスを使います。すべてのテーブル読み取りで、
convex/schema.tsのインデックスに対してwithIndexを使用します。 - ミューテーションをべき等にします。ドキュメントがすでに目的の状態になっている場合は早期に返し、再試行しても安全なようにします。
- 先に読み取らずにパッチします。
ctx.db.patch(id, fields)はドキュメントが存在しない場合にエラーをスローします。以前の値が必要になることはほとんどありません。 - 独立した書き込みには
Promise.allを使います。1つずつawaitしないでください。 internal.*のみをスケジュールします。クライアントなしで実行される cron とctx.schedulerでは、公開ターゲットは認証をスキップします。- ラッパーは薄く保ちます。認証とビジネスロジックは、
ctxを受け取る通常のヘルパーに置きます。 - クライアントに見せるエラーには
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;
},
});
このインデックスの背後にあるスキーマです。
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 は楽観的同時実行制御のもとでミューテーションを実行します。ミューテーションは読み取ったデータを記録します。別のミューテーションがそのデータを先に変更してコミットすると、Convex はそのミューテーションを再試行します。再試行が一定回数を超えると失敗し、クライアントには書き込み競合エラーが表示されます。
競合の原因は次の3つです。
- 2つのミューテーションが同時に同じドキュメントを書き換える場合:カウンター、「最終確認」フィールド、共有設定ドキュメントなど
.collect()でテーブル全体を取得するなど、ミューテーションが広い範囲を読み取る場合。その範囲内で変更があると競合します- クライアントが、コミットが完了するより速く同じミューテーションを呼び出す場合:入力、ドラッグ、ポーリングなど
べき等にし、先にパッチする
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 は読み取りを一切行いません。
カウンターではなくイベントレコードを使う
1つのドキュメントにカウンターフィールドを置くのは、最もよくある競合の原因です。イベントごとに1行を挿入し、クエリで件数を数えます。
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ミリ秒、ハートビートには数秒を設定します。
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 がキャッシュし、データの変更時に再実行できるよう、決定的である必要があります。時刻はクライアントから渡すか、スケジュールされたミューテーションが更新するステータスフィールドを保存します。
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);
},
});
ミューテーションとアクションでは Date.now() を呼び出せます。
ESLint プラグイン
@convex-dev/eslint-plugin は、古い関数構文、引数バリデーターの不足、クエリ内の .filter()、毎時ちょうどに実行される cron を lint 時に検出します。すべての 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 に対応した設定、パッケージスクリプト、カスタムの convex/ ディレクトリについては、references/eslint-setup.md を参照してください。
よくある間違い
| 間違い | 問題が起きる理由 | 代わりに行うこと |
|---|---|---|
テーブルクエリでの .filter() | すべての行を読み込んでから、大半を除外します | インデックスを追加し、withIndex を使用します |
上限のないテーブルでの .collect() | 日ごとに遅くなり、読み取り制限に達し、OCC の競合範囲が広がります | .paginate() または .take(n) を使用します |
| 共有ドキュメントを読み取り、計算してからパッチする | 2 つのクライアントが同じバージョンを読み取り、両方が書き込みます | 直接パッチするか、イベント行に分割します |
| イベントごとにカウンターフィールドをインクリメントする | インクリメントのたびに、ほかのすべてのインクリメントと競合します | イベントレコードまたはシャーディングされたカウンターコンポーネントを使用します |
| 早期リターンのないミューテーション | リトライやダブルクリックにより、変更が 2 回適用されます | 状態を確認し、すでに完了している場合は 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が成功する