mcpField
C
convex-fundamentals

convex-fundamentals

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

convex-fundamentals について

Convex のバックエンドアプリを構築する Agent 向けの TypeScript スキルです。Convex の関数種別、リアクティブデータとトランザクションの規則、レイヤー構成、HTTP Action、実装上の注意点を解説します。

主な機能

  1. 01query、mutation、action の種別を選択
  2. 02公開関数と内部関数を構成
  3. 03レイヤー化されたモデル構成を適用
  4. 04HTTP Webhook ルートを実装
  5. 05Convex と TypeScript のよくある問題を回避

活用シーン

  1. 01Convex バックエンドをゼロから構築
  2. 02query、mutation、action を設計
  3. 03HTTP Webhook ハンドラーを追加
  4. 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() を呼び出せない」ミューテーションは決定論的でなければなりません。代わりにアクションをスケジュールしてください。
「ドキュメントが変更されるたびにクエリが再実行される」取得するデータが多すぎます。インデックスの範囲を絞るか、非正規化してください。
「結合を実行できない」非正規化してください。関連データを埋め込むか、ルックアップテーブルを使用してください。
コアとなる不変条件
  1. ミューテーションは ACID トランザクションです — すべての書き込みがまとめて成功するか、まとめて失敗します。
  2. クエリはリアクティブです — 読み取ったドキュメントが変更されると、再実行されます。
  3. アクションは非トランザクションのオーケストレーションです — リアクティビティも ctx.db もありません。外部 I/O を実行でき、ctx.run* を介してクエリやミューテーションを呼び出す必要があります。
  4. スケジューリングはミューテーションとアトミックです — ミューテーションが失敗すると、スケジュールされた関数は実行されません。

基本的な考え方

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;

モデルレイヤーのパターン

ビジネスロジックを再利用可能なモデル関数に切り出します。関数は薄く保ち、処理はモデルに任せます。

モデルレイヤーを使う理由
  1. 再利用性 — クエリ、ミューテーション、アクションで同じロジックを使用できます
  2. テスト容易性 — 純粋関数なので、ユニットテストが簡単です
  3. 分離 — 認証チェックとビジネスロジックを別々の場所にまとめられます
  4. 型安全性 — コンテキストには 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.functionName
  • convex/audio/stems.ts → api.audio.stems.functionName
  • convex/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;
  },
});

判断ガイド

  1. この関数は DB から読み取りますか? → query または internalQuery を使用します
  2. この関数は DB に書き込みますか? → mutation または internalMutation を使用します
  3. この関数は外部 API を呼び出しますか? → action または internalAction を使用します
  4. クライアントから呼び出されますか? → 公開バリアント(query、mutation、action)を使用します
  5. 内部専用ですか? → internal* バリアントを使用します
  6. これは Webhook エンドポイントですか? → convex/http.ts で httpAction を使用します

コメント