convex-fundamentals について
Convex のバックエンドアプリを構築する Agent 向けの TypeScript スキルです。Convex の関数種別、リアクティブデータとトランザクションの規則、レイヤー構成、HTTP Action、実装上の注意点を解説します。
主な機能
- 01query、mutation、action の種別を選択
- 02公開関数と内部関数を構成
- 03レイヤー化されたモデル構成を適用
- 04HTTP Webhook ルートを実装
- 05Convex と TypeScript のよくある問題を回避
活用シーン
- 01Convex バックエンドをゼロから構築
- 02query、mutation、action を設計
- 03HTTP Webhook ハンドラーを追加
- 04アプリケーションを Convex に移行
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ op0ai/ claude-skills --skill convex-fundamentalsインストール後、Claude Code で/convex-fundamentalsを入力して読み込まれたか確認します。
詳細
TypeScript エージェント向け Convex の基礎
概要
Convex は、リアルタイムのデータ同期、ACID トランザクション、TypeScript を第一に考えた開発を提供するリアクティブなバックエンド・アズ・ア・サービスです。このスキルでは、基本的な考え方、関数の種類、階層化アーキテクチャのパターン、HTTP アクションの実装について説明します。
TypeScript:any 型は絶対に使用しないでください
重要なルール: このコードベースでは @typescript-eslint/no-explicit-any が有効になっています。any を使用すると、ビルドに失敗します。
❌ 誤り:
function handleData(data: any) { ... }
const items: any[] = [];
✅ 正しい例:
function handleData(data: { id: string; name: string }) { ... }
const items: Doc<"items">[] = [];
このスキルを使う場面
次のような場合に、このスキルを使用してください。
- Convex のバックエンドアプリケーションをゼロから構築する
- クエリ、ミューテーション、アクションを作成する
- 関数の種類による違いを理解する
- 階層化アーキテクチャのパターンを実装する
- ルートハンドラーを使って HTTP ウェブフックを設定する
- 他のバックエンドから Convex に移行する
- Convex のリアクティブなデータモデルを理解する
考え方:Convex が「間違っている」と感じる理由(そして、それが正しい理由)
Convex には意図的な制約があります。アンチパターンのように感じるときは、たいてい Convex がアプローチを見直すよう促しています。
意図的な摩擦
| 間違っているように感じること | Convex が伝えていること |
|---|---|
「クエリで .filter() を使えない」 | インデックスを定義してください。クエリは O(n) ではなく O(log n) にするべきです。 |
「アクションから ctx.db にアクセスできない」 | 副作用とトランザクションは混在させられません。ミューテーションを経由してください。 |
| 「ミューテーションが OCC エラーで失敗し続ける」 | 同じドキュメントへの書き込み頻度が高すぎます。データモデルを再設計するか、Workpool を使用してください。 |
「ミューテーションで fetch() を呼び出せない」 | ミューテーションは決定論的でなければなりません。代わりにアクションをスケジュールしてください。 |
| 「ドキュメントが変更されるたびにクエリが再実行される」 | 取得するデータが多すぎます。インデックスの範囲を絞るか、非正規化してください。 |
| 「結合を実行できない」 | 非正規化してください。関連データを埋め込むか、ルックアップテーブルを使用してください。 |
コアとなる不変条件
- ミューテーションは ACID トランザクションです — すべての書き込みがまとめて成功するか、まとめて失敗します。
- クエリはリアクティブです — 読み取ったドキュメントが変更されると、再実行されます。
- アクションは非トランザクションのオーケストレーションです — リアクティビティも
ctx.dbもありません。外部 I/O を実行でき、ctx.run*を介してクエリやミューテーションを呼び出す必要があります。 - スケジューリングはミューテーションとアトミックです — ミューテーションが失敗すると、スケジュールされた関数は実行されません。
基本的な考え方
Convex のすべては、次のカテゴリに分類されます。
| 種類 | 公開範囲 | 目的 |
|---|---|---|
query | 公開 | DB の読み取り、リアクティブなサブスクリプション |
mutation | 公開 | DB への書き込み、ACID トランザクション |
action | 公開 | 外部 API、非決定論的な処理 |
httpAction | 公開(HTTP) | ウェブフック、サードパーティとの連携 |
internalQuery | 非公開 | 内部利用向けの読み取りプリミティブ |
internalMutation | 非公開 | 内部利用向けの書き込みプリミティブ |
internalAction | 非公開 | オーケストレーション、外部呼び出し |
ルール:公開する API は薄く保ち、実際のロジックはすべて内部プリミティブに配置してください。
internal*版の実行セマンティクスは、対応する公開版と同一です。違いは公開範囲のみです。内部関数はapi.*ではなくinternal.*に現れ、クライアントから呼び出すことはできません。
階層化アーキテクチャ
レイヤー 1:クライアント向けサーフェス(公開 API)
ここにはエントリーポイントだけを配置します。検証、認証チェック、処理の委譲を行う薄いラッパーです。
// convex/jobs.ts — PUBLIC SURFACE
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { internal } from "./_generated/api";
// Public mutation: client entrypoint
export const startGeneration = mutation({
args: { prompt: v.string() },
returns: v.id("jobs"),
handler: async (ctx, args) => {
// 1. Auth check
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Unauthorized");
// 2. Delegate to internal mutation
const jobId = await ctx.runMutation(internal.jobs.createJob, {
userId: identity.subject,
prompt: args.prompt,
});
// 3. Schedule background work
await ctx.scheduler.runAfter(0, internal.jobs.processJob, { jobId });
return jobId;
},
});
// Public query: reactive subscription
export const getJob = query({
args: { jobId: v.id("jobs") },
returns: v.union(
v.object({
_id: v.id("jobs"),
_creationTime: v.number(),
status: v.string(),
prompt: v.string(),
result: v.optional(v.string()),
}),
v.null()
),
handler: async (ctx, args) => {
return await ctx.db.get(args.jobId);
},
});
レイヤー 2:ドメインロジック(内部プリミティブ)
実際のロジックが置かれる場所です。バックエンドの非公開 API にあたります。
// convex/jobs.ts — INTERNAL PRIMITIVES (same file, different exports)
import { internalMutation, internalQuery } from "./_generated/server";
import { v } from "convex/values";
export const createJob = internalMutation({
args: {
userId: v.string(),
prompt: v.string(),
},
returns: v.id("jobs"),
handler: async (ctx, args) => {
return await ctx.db.insert("jobs", {
userId: args.userId,
prompt: args.prompt,
status: "pending",
});
},
});
export const markComplete = internalMutation({
args: {
jobId: v.id("jobs"),
result: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.patch(args.jobId, {
status: "completed",
result: args.result,
});
return null;
},
});
export const markFailed = internalMutation({
args: {
jobId: v.id("jobs"),
error: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.patch(args.jobId, {
status: "failed",
error: args.error,
});
return null;
},
});
export const getById = internalQuery({
args: { jobId: v.id("jobs") },
returns: v.union(
v.object({
_id: v.id("jobs"),
_creationTime: v.number(),
userId: v.string(),
prompt: v.string(),
status: v.string(),
result: v.optional(v.string()),
error: v.optional(v.string()),
}),
v.null()
),
handler: async (ctx, args) => {
return await ctx.db.get(args.jobId);
},
});
レイヤー 3:オーケストレーション(アクション+スケジューラー)
複数ステップにわたる処理、外部との連携、または長時間実行される処理に使用します。
// convex/jobs.ts — ORCHESTRATION
import { internalAction } from "./_generated/server";
import { v } from "convex/values";
import { internal } from "./_generated/api";
export const processJob = internalAction({
args: { jobId: v.id("jobs") },
returns: v.null(),
handler: async (ctx, args) => {
// 1. Read current state
const job = await ctx.runQuery(internal.jobs.getById, {
jobId: args.jobId,
});
if (!job || job.status !== "pending") return null;
try {
// 2. External API call (non-deterministic)
const result = await fetch("https://api.example.com/generate", {
method: "POST",
body: JSON.stringify({ prompt: job.prompt }),
});
const data = await result.json();
// 3. Write result via mutation
await ctx.runMutation(internal.jobs.markComplete, {
jobId: args.jobId,
result: data.output,
});
} catch (e) {
await ctx.runMutation(internal.jobs.markFailed, {
jobId: args.jobId,
error: String(e),
});
}
return null;
},
});
HTTP アクション(Webhook)
HTTP アクションは、httpRouter とともに convex/http.ts に記述する必要があります。
// convex/http.ts
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
import { internal } from "./_generated/api";
const http = httpRouter();
http.route({
path: "/webhooks/stripe",
method: "POST",
handler: httpAction(async (ctx, req) => {
// 1. Validate signature
const signature = req.headers.get("stripe-signature");
if (!signature) {
return new Response("Missing signature", { status: 401 });
}
// 2. Parse body
const body = await req.text();
// 3. Delegate to internal mutation
await ctx.runMutation(internal.billing.handleStripeWebhook, {
signature,
body,
});
return new Response(null, { status: 200 });
}),
});
export default http;
モデルレイヤーのパターン
ビジネスロジックを再利用可能なモデル関数に切り出します。関数は薄く保ち、処理はモデルに任せます。
モデルレイヤーを使う理由
- 再利用性 — クエリ、ミューテーション、アクションで同じロジックを使用できます
- テスト容易性 — 純粋関数なので、ユニットテストが簡単です
- 分離 — 認証チェックとビジネスロジックを別々の場所にまとめられます
- 型安全性 — コンテキストには
QueryCtx型とMutationCtx型を使用できます
パターン:モデル関数
// convex/model/users.ts — MODEL LAYER
import { QueryCtx, MutationCtx } from "../_generated/server";
import { Id } from "../_generated/dataModel";
// Read helper: reusable across queries
export async function getCurrentUser(ctx: QueryCtx) {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Unauthorized");
const user = await ctx.db
.query("users")
.withIndex("by_tokenIdentifier", (q) =>
q.eq("tokenIdentifier", identity.tokenIdentifier)
)
.unique();
if (!user) throw new Error("User not found");
return user;
}
// Access control helper
export async function ensureHasAccess(
ctx: QueryCtx,
{ conversationId }: { conversationId: Id<"conversations"> }
) {
const user = await getCurrentUser(ctx);
const conversation = await ctx.db.get(conversationId);
if (!conversation || !conversation.members.includes(user._id)) {
throw new Error("Unauthorized");
}
return { user, conversation };
}
// Write helper: encapsulates business logic
export async function addMessage(
ctx: MutationCtx,
{
conversationId,
content,
}: { conversationId: Id<"conversations">; content: string }
) {
const { user } = await ensureHasAccess(ctx, { conversationId });
const messageId = await ctx.db.insert("messages", {
conversationId,
authorId: user._id,
content,
createdAt: Date.now(),
});
// Update denormalized lastMessageAt
await ctx.db.patch(conversationId, { lastMessageAt: Date.now() });
return messageId;
}
パターン:薄い関数ラッパー
// convex/conversations.ts — THIN WRAPPERS
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import * as Conversations from "./model/conversations";
// Query: delegates to model
export const listMessages = query({
args: { conversationId: v.id("conversations") },
returns: v.array(
v.object({
_id: v.id("messages"),
content: v.string(),
authorId: v.id("users"),
createdAt: v.number(),
})
),
handler: async (ctx, args) => {
return Conversations.listMessages(ctx, args);
},
});
// Mutation: delegates to model
export const sendMessage = mutation({
args: { conversationId: v.id("conversations"), content: v.string() },
returns: v.id("messages"),
handler: async (ctx, args) => {
return Conversations.addMessage(ctx, args);
},
});
ファイル構成
まずはフラットな構成にします。Convex はファイルベースのルーティングを使用します。
convex/
schema.ts # Database schema
http.ts # HTTP actions (webhooks)
auth.ts # Auth helpers
crons.ts # Cron job definitions
# Domain files (public + internal in same file)
users.ts # api.users.* + internal.users.*
jobs.ts
billing.ts
audio.ts
# Model layer (business logic)
model/
users.ts # User business logic helpers
conversations.ts # Conversation logic
billing.ts # Billing logic
# Shared utilities (NOT Convex functions)
_lib/
validators.ts # Shared validators
constants.ts # Constants
ルーティング:
convex/users.ts→api.users.functionName/internal.users.functionNameconvex/audio/stems.ts→api.audio.stems.functionNameconvex/model/*.ts→ ルーティングされません(ヘルパーのインポート専用)
関数の種類:クイックリファレンス
インポートパターン
// Public (client-callable)
import { query, mutation, action } from "./_generated/server";
// Private (internal only)
import {
internalQuery,
internalMutation,
internalAction,
} from "./_generated/server";
// HTTP
import { httpAction } from "./_generated/server";
import { httpRouter } from "convex/server";
関数参照
import { api, internal } from "./_generated/api";
// Public: api.fileName.functionName
await ctx.runQuery(api.users.getUser, { userId });
// Internal: internal.fileName.functionName
await ctx.runMutation(internal.jobs.createJob, { data });
データベース操作
// Read
const doc = await ctx.db.get(id);
const docs = await ctx.db
.query("table")
.withIndex("by_x", (q) => q.eq("x", value))
.collect();
const single = await ctx.db
.query("table")
.withIndex("by_x", (q) => q.eq("x", value))
.unique();
// Write
const id = await ctx.db.insert("table", { field: value });
await ctx.db.patch(id, { field: newValue });
await ctx.db.replace(id, { ...fullDocument });
await ctx.db.delete(id);
よくある落とし穴
落とし穴 1:ミューテーション内での fetch() の呼び出し
❌ 誤り:
export const createOrder = mutation({
args: { productId: v.string() },
returns: v.null(),
handler: async (ctx, args) => {
// ❌ Mutations must be deterministic - no external calls!
const price = await fetch(
"https://api.stripe.com/prices/" + args.productId
);
await ctx.db.insert("orders", { productId: args.productId, price });
return null;
},
});
✅ 正しい:
// Mutation creates order, 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,
});
const price = await fetch(
"https://api.stripe.com/prices/" + order.productId
);
await ctx.runMutation(internal.orders.updatePrice, {
orderId: args.orderId,
price: await price.json(),
});
return null;
},
});
落とし穴 2:アクション内での ctx.db へのアクセス
❌ 誤り:
export const processData = action({
args: { id: v.string() },
returns: v.null(),
handler: async (ctx, args) => {
// ❌ Actions don't have ctx.db!
const data = await ctx.db.get(args.id);
return null;
},
});
✅ 正しい:
export const processData = action({
args: { id: v.id("data") },
returns: v.null(),
handler: async (ctx, args) => {
// ✅ Use ctx.runQuery to read data
const data = await ctx.runQuery(internal.data.getById, { id: args.id });
// Do external work...
const result = await fetch("...");
// ✅ Use ctx.runMutation to write data
await ctx.runMutation(internal.data.update, {
id: args.id,
result: await result.json(),
});
return null;
},
});
落とし穴 3:returns バリデーターの指定漏れ
❌ 誤り:
export const foo = mutation({
args: {},
handler: async (ctx) => {
// implicitly returns undefined
},
});
✅ 正しい:
export const foo = mutation({
args: {},
returns: v.null(),
handler: async (ctx) => {
return null;
},
});
判断ガイド
- この関数は DB から読み取りますか? →
queryまたはinternalQueryを使用します - この関数は DB に書き込みますか? →
mutationまたはinternalMutationを使用します - この関数は外部 API を呼び出しますか? →
actionまたはinternalActionを使用します - クライアントから呼び出されますか? → 公開バリアント(
query、mutation、action)を使用します - 内部専用ですか? →
internal*バリアントを使用します - これは Webhook エンドポイントですか? →
convex/http.tsでhttpActionを使用します