convex-component-authoring について
convex-component-authoring は、独自のスキーマ、関数、型付きクライアントラッパーを備えた Convex コンポーネントを作るスキルです。コンポーネントの境界と、親アプリからの登録・呼び出し方を説明します。
主な機能
- 01独自スキーマと関数
- 02型付きクライアントラッパー
- 03親アプリへの登録
- 04境界と ID の扱い方
- 05公開手順の案内
活用シーン
- 01再利用可能なバックエンド機能をパッケージ化
- 02Convex ディレクトリ向けコンポーネントの作成
- 03複数アプリでのインテグレーション共有
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ waynesutton/ builder-skills --skill convex-component-authoringインストール後、Claude Code で/convex-component-authoringを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
agents/openai.yamlassets/small-logo.svgreferences/publishing.md詳細
Convex コンポーネントの作成
独自の convex.config.ts、スキーマ、関数、親アプリから呼び出す型付きクライアントラッパーを備えた、自己完結型のコンポーネントを作成します。ルールはひとつです。コンポーネントは親アプリのテーブル、ctx.auth、process.env を読み取れません。必要な情報はすべて、アプリから引数として渡します。
どんな場合に使うか
- カウンター、レート制限、監査ログ、通知などの機能をパッケージに切り出す
- Convex コンポーネントディレクトリ向けのものを構築する
- 親アプリの
components.<name>にコンポーネントの関数が表示されない - サードパーティとの連携で、独自のテーブルとバックグラウンドジョブを管理する必要がある
- 同じバックエンドモジュールを複数のアプリで使う
コンポーネントかヘルパー関数か
| 必要なもの | 使うもの |
|---|---|
| アプリ独自のテーブルに対する共有ロジック | convex/lib/ の通常の TypeScript ヘルパー |
| アプリから触れない独自のスキーマを持つテーブル | コンポーネント |
| 独自のトランザクションで実行する分離された関数 | コンポーネント |
| ひとつのインストール手順で複数のアプリから再利用 | コンポーネント |
| React フックまたはクライアントユーティリティのみ | npm ライブラリ。コンポーネントは不要 |
API 境界の背後に永続状態が不要な機能なら、ヘルパー関数のほうが手間が少なく、型付けも簡単です。
フォルダー構成
my-component/
package.json
src/
component/
convex.config.ts # defineComponent("counter")
schema.ts # tables only this component can see
public.ts # queries, mutations, actions
_generated/ # created by npx convex codegen
client/
index.ts # wrapper class the app imports
example/
convex/
convex.config.ts # app.use(counter) for local testing
ローカル専用のコンポーネントでは、component/ の内容を convex/components/<name>/ に配置し、client/ と package.json は省略します。
コンポーネントを定義する
// src/component/convex.config.ts
import { defineComponent } from "convex/server";
const component = defineComponent("counter");
export default component;
// src/component/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
counters: defineTable({
name: v.string(),
value: v.number(),
}).index("by_name", ["name"]),
});
関数では、query と mutation をアプリ側ではなく、コンポーネント独自の _generated/server からインポートします。すべての公開関数に args と returns のバリデーターが必要です。指定しないと、親アプリでは any として扱われます。
// src/component/public.ts
import { v } from "convex/values";
import { mutation, query } from "./_generated/server.js";
export const add = mutation({
args: { name: v.string(), amount: v.number() },
returns: v.number(),
handler: async (ctx, args) => {
const existing = await ctx.db
.query("counters")
.withIndex("by_name", (q) => q.eq("name", args.name))
.unique();
if (!existing) {
await ctx.db.insert("counters", { name: args.name, value: args.amount });
return args.amount;
}
const value = existing.value + args.amount;
await ctx.db.patch(existing._id, { value });
return value;
},
});
export const get = query({
args: { name: v.string() },
returns: v.number(),
handler: async (ctx, args) => {
const counter = await ctx.db
.query("counters")
.withIndex("by_name", (q) => q.eq("name", args.name))
.unique();
return counter?.value ?? 0;
},
});
親アプリに登録する
// convex/convex.config.ts (parent app)
import { defineApp } from "convex/server";
import counter from "@acme/counter/convex.config.js";
// local component: import counter from "./components/counter/convex.config.js";
const app = defineApp();
app.use(counter);
// A second instance under a different name:
// app.use(counter, { name: "pageViews" });
export default app;
npx convex dev を実行します。アプリの convex/_generated/api に components.counter が生成され、コンポーネント独自の _generated/ フォルダーも生成されます。参照パスはファイルのパスを反映します。たとえば、public.ts にある関数は components.counter.public.add です。
コンポーネントの関数は、親アプリでは内部参照として扱われます。ctx.runQuery と ctx.runMutation で呼び出してください。クライアントから直接呼び出すことはできません。
クライアントラッパークラス
ラッパーはアプリの環境で実行されるため、ctx.auth と process.env を読み取り、必要な情報をコンポーネントに渡せます。引数には、最初にコンポーネント参照、次にオプションを指定します。
// src/client/index.ts
import type {
GenericDataModel,
GenericMutationCtx,
GenericQueryCtx,
} from "convex/server";
import type { ComponentApi } from "../component/_generated/component.js";
// Pick only the capabilities each method needs so any ctx with runQuery works
type QueryCtx = Pick<GenericQueryCtx<GenericDataModel>, "runQuery">;
type MutationCtx = Pick<GenericMutationCtx<GenericDataModel>, "runMutation">;
export class Counter {
constructor(
public component: ComponentApi,
private options: { prefix?: string } = {},
) {}
private key(name: string): string {
return this.options.prefix ? `${this.options.prefix}:${name}` : name;
}
async add(ctx: MutationCtx, name: string, amount = 1): Promise<number> {
return await ctx.runMutation(this.component.public.add, {
name: this.key(name),
amount,
});
}
async get(ctx: QueryCtx, name: string): Promise<number> {
return await ctx.runQuery(this.component.public.get, { name: this.key(name) });
}
}
// convex/counter.ts (parent app)
import { v } from "convex/values";
import { mutation, query } from "./_generated/server";
import { components } from "./_generated/api";
import { Counter } from "@acme/counter";
const counter = new Counter(components.counter, { prefix: "app" });
// The app owns auth. The component only sees the string it is handed.
export const incrementMine = mutation({
args: {},
returns: v.number(),
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Not authenticated");
return await counter.add(ctx, identity.subject);
},
});
export const getMine = query({
args: {},
returns: v.number(),
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Not authenticated");
return await counter.get(ctx, identity.subject);
},
});
React から api.counter.incrementMine 経由で呼び出すのは、これらのアプリ側の関数です。認証とレート制限はアプリ側で決定し、コンポーネントは汎用のままにします。
境界に関するルール
- 親アプリのテーブルにはアクセスできません。 コンポーネント内の
v.id("users")は、アプリのテーブルではなく、コンポーネントのusersテーブルを参照します。親アプリの ID はv.string()で受け取ります。 - ID は文字列になります。 コンポーネント内のすべての
Id<"counters">は、ComponentApiではstringです。意図的に文字列として返してください。 ctx.authは使用できません。 ユーザーはアプリ側で特定し、userIdを渡します。- アプリの
process.envは使用できません。defineComponent("name", { env: { API_KEY: v.string() } })で型付き環境変数を宣言し、アプリからapp.use(c, { env: { API_KEY: ... } })で渡すか、関数の引数として値を渡します。 - 境界をまたいだ
.paginate()は機能しません。 コンポーネント内でconvex-helpersのpaginatorを使用してください。 - コンポーネントの
http.tsにある HTTP ルートは、アプリ側でhttpPrefixを使ってマウントします。コンポーネントの HTTP アクションではctx.authを利用できません。 - アプリへのコールバックは関数ハンドルとして渡します。アプリ側で
createFunctionHandle(internal.x.y)を使い、v.string()として保存し、コンポーネント内でFunctionHandle<"mutation">にキャストします。
npm への公開(exports マップ、ビルド順序、README、バージョン管理)については、references/publishing.md を参照してください。ローカルフォルダーではなくパッケージからコンポーネントをインストールする場合に開いてください。
よくある間違い
| 間違い | 問題が発生する理由 | 代わりに行うこと |
|---|---|---|
components.<name> に関数がない | app.use(...) が追加されていないか、その後に npx convex dev を実行していません | convex.config.ts に登録し、開発環境を実行して、参照パスがファイル名と一致していることを確認します |
アプリの _generated/server から mutation をインポートする | 関数がコンポーネントではなくアプリに登録されます | コンポーネント独自の ./_generated/server.js からインポートします |
React から api.counter.add を呼び出す | コンポーネント関数は親からは内部参照です | ctx.runMutation(components.counter.public.add) を呼び出すアプリのミューテーションを作成します |
コンポーネントの引数に v.id("users") を使う | テーブル番号はコンポーネントごとに異なるため、検証に失敗します | 境界では v.string() を使います |
コンポーネント内で ctx.auth.getUserIdentity() を呼び出す | 常にユーザーが存在しない状態を返します | アプリで認証し、userId を渡します |
コンポーネント内で process.env.MY_KEY を使う | 実行時に未定義になります | defineComponent で環境変数を宣言するか、引数として渡します |
returns のないパブリック関数 | 親では型が any になり、型安全性が失われます | すべてのパブリック関数にバリデーターを追加します |
| モジュールスコープでコンポーネントの環境変数を読み取る | デプロイ分析中は未定義になります | ハンドラー内で env.X を読み取ります |
チェックリスト
- ヘルパー関数ではなく、コンポーネントが必要だと判断した
-
convex.config.tsでdefineComponent("<name>")を呼び出し、エクスポートしている - スキーマはコンポーネント内にあり、親テーブルに対する
v.id()をどこにも使っていない - 関数はコンポーネント独自の
_generated/serverからインポートしている - すべてのパブリック関数に
argsとreturnsのバリデーターがある - 親が
app.use(...)で登録され、npx convex devが問題なく実行される - クライアントラッパーは最初に
ComponentApi、次にオプションを受け取り、Pickの ctx 型を使っている - アプリ側のラッパー関数が認証を処理し、IDを文字列として渡している
- コンポーネント内で
ctx.auth、process.env、.paginate()を使っていない - コンポーネントをインストールするサンプルアプリでテストした