mcpField
C
convex-components

convex-components

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

convex-components について

再利用可能なConvexバックエンドコンポーネントの設計を支援する開発スキルです。独立したスキーマ、関数、名前空間の構成から、ローカルおよびパッケージ化したコンポーネントの統合、テスト、公開までを扱い、固有の制約も説明します。

主な機能

  1. 01独立したスキーマと関数名前空間
  2. 02ローカル、パッケージ、ハイブリッドの構成
  3. 03ホストアプリ統合とクライアントラッパー
  4. 04convex-testによるテスト
  5. 05ビルドと公開の手順
  6. 06コンポーネントの制約と注意点

活用シーン

  1. 01再利用可能なConvexバックエンドモジュールの作成
  2. 02Convex機能のNPMパッケージ化
  3. 03サードパーティ製Convexコンポーネントの統合
  4. 04モックデータを使った関数テスト

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/op0ai/claude-skills --skill convex-components

インストール後、Claude Code で/convex-componentsを入力して読み込まれたか確認します。

詳細

Convex コンポーネントの作成

概要

Convex コンポーネントは、パッケージ化して共有できる、分離された再利用可能なバックエンドモジュールです。各コンポーネントは独自のスキーマ、関数、名前空間を持つため、モジュール型アーキテクチャを実現し、NPM を通じてライブラリを配布できます。

TypeScript:any 型は絶対に使用しない

重大なルール: このコードベースでは @typescript-eslint/no-explicit-any が有効になっています。any を使用するとビルドに失敗します。

このスキルを使用する場面

次のような場合に、このスキルを使用してください。

  • アプリ用の再利用可能なバックエンドモジュールを構築する
  • Convex の機能を NPM 配布用にパッケージ化する
  • 個別のスキーマを持つ分離されたサブシステムを作成する
  • サードパーティ製の Convex コンポーネントを統合する
  • Convex バックエンドを使用するライブラリ(レート制限機能、ワークプールなど)を構築する

コンポーネントの種類

種類用途場所
ローカルアプリ内の非公開モジュールconvex/myComponent/
パッケージ公開済み NPM パッケージnode_modules/@org/component/
ハイブリッド共有する内部パッケージpackages/my-component/

コンポーネントの構成

ディレクトリ構造
my-component/
├── package.json           # NPM package config
├── src/
│   └── component/
│       ├── convex.config.ts    # Component definition
│       ├── schema.ts           # Component schema
│       ├── public.ts           # Public API functions
│       ├── internal.ts         # Internal functions
│       └── _generated/         # Generated types (gitignored)
└── src/
    └── client/
        └── index.ts            # Client-side wrapper class
convex.config.ts

コンポーネント定義ファイル:

// src/component/convex.config.ts
import { defineComponent } from "convex/server";

export default defineComponent("myComponent");
コンポーネントのスキーマ
// src/component/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";

export default defineSchema({
  items: defineTable({
    key: v.string(),
    value: v.string(),
    expiresAt: v.optional(v.number()),
  }).index("by_key", ["key"]),
});
公開 API 関数
// src/component/public.ts
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";

export const set = mutation({
  args: {
    key: v.string(),
    value: v.string(),
    ttl: v.optional(v.number()),
  },
  returns: v.null(),
  handler: async (ctx, args) => {
    const existing = await ctx.db
      .query("items")
      .withIndex("by_key", (q) => q.eq("key", args.key))
      .unique();

    const data = {
      key: args.key,
      value: args.value,
      expiresAt: args.ttl ? Date.now() + args.ttl : undefined,
    };

    if (existing) {
      await ctx.db.replace(existing._id, data);
    } else {
      await ctx.db.insert("items", data);
    }

    return null;
  },
});

export const get = query({
  args: { key: v.string() },
  returns: v.union(v.string(), v.null()),
  handler: async (ctx, args) => {
    const item = await ctx.db
      .query("items")
      .withIndex("by_key", (q) => q.eq("key", args.key))
      .unique();

    if (!item) return null;
    if (item.expiresAt && item.expiresAt < Date.now()) return null;

    return item.value;
  },
});

ホストアプリへのコンポーネントのインストール

ホストアプリの convex.config.ts
// convex/convex.config.ts
import { defineApp } from "convex/server";
import myComponent from "@org/my-component/convex.config";

const app = defineApp();
app.use(myComponent, { name: "cache" }); // Mount with a name

export default app;
コンポーネント関数へのアクセス
// convex/myFunctions.ts
import { mutation } from "./_generated/server";
import { components } from "./_generated/api";
import { v } from "convex/values";

export const cacheValue = mutation({
  args: { key: v.string(), value: v.string() },
  returns: v.null(),
  handler: async (ctx, args) => {
    // Call component function via components.<name>.<module>.<function>
    await ctx.runMutation(components.cache.public.set, {
      key: args.key,
      value: args.value,
      ttl: 3600000, // 1 hour
    });
    return null;
  },
});

通常の Convex との主な違い

1. ID 型は不透明です

コンポーネントの ID は、Id<"table"> ではなくブランド付きの文字列です。

// ❌ WRONG: Can't use Id<"items"> from component
import { Id } from "./_generated/dataModel";
const id: Id<"items"> = ...;  // Error!

// ✅ CORRECT: Use string or branded type from component
export const processItem = mutation({
  args: { itemId: v.string() },  // Accept as string
  returns: v.null(),
  handler: async (ctx, args) => {
    // Pass to component functions as-is
    await ctx.runMutation(components.myComponent.public.process, {
      itemId: args.itemId,
    });
    return null;
  },
});
2. 環境変数は使用できません

コンポーネントから process.env にはアクセスできません。設定は明示的に渡してください。

// ✅ CORRECT: Component accepts config via function args
// src/component/public.ts
export const initialize = mutation({
  args: {
    apiKey: v.string(),
    endpoint: v.string(),
  },
  returns: v.null(),
  handler: async (ctx, args) => {
    await ctx.db.insert("config", {
      apiKey: args.apiKey,
      endpoint: args.endpoint,
    });
    return null;
  },
});

// Host app passes env vars
export const setup = mutation({
  args: {},
  returns: v.null(),
  handler: async (ctx) => {
    await ctx.runMutation(components.myComponent.public.initialize, {
      apiKey: process.env.API_KEY!,
      endpoint: process.env.API_ENDPOINT!,
    });
    return null;
  },
});
3. ctx.auth は使用できません

コンポーネントから認証コンテキストにはアクセスできません。ユーザー情報は明示的に渡してください。

// ❌ WRONG: Components can't access ctx.auth
export const createItem = mutation({
  args: { data: v.string() },
  returns: v.null(),
  handler: async (ctx, args) => {
    const identity = await ctx.auth.getUserIdentity(); // Error!
    return null;
  },
});

// ✅ CORRECT: Accept userId as argument
export const createItem = mutation({
  args: {
    userId: v.string(),
    data: v.string(),
  },
  returns: v.id("items"),
  handler: async (ctx, args) => {
    return await ctx.db.insert("items", {
      userId: args.userId,
      data: args.data,
    });
  },
});

// Host app handles auth and passes user info
export const createItemWithAuth = mutation({
  args: { data: v.string() },
  returns: v.string(),
  handler: async (ctx, args) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) throw new Error("Unauthorized");

    const itemId = await ctx.runMutation(
      components.myComponent.public.createItem,
      {
        userId: identity.subject,
        data: args.data,
      }
    );

    return itemId; // Return as string
  },
});
4. ページネーションカーソルは文字列です

コンポーネントのページネーションでは、文字列のカーソルを使用します。

// src/component/public.ts
export const list = query({
  args: {
    cursor: v.optional(v.string()),
    limit: v.number(),
  },
  returns: v.object({
    items: v.array(
      v.object({
        id: v.string(),
        data: v.string(),
      })
    ),
    nextCursor: v.union(v.string(), v.null()),
  }),
  handler: async (ctx, args) => {
    const results = await ctx.db
      .query("items")
      .paginate({ cursor: args.cursor ?? null, numItems: args.limit });

    return {
      items: results.page.map((item) => ({
        id: item._id,
        data: item.data,
      })),
      nextCursor: results.continueCursor,
    };
  },
});

クライアントコードのパターン

クラスベースのクライアントラッパー

ホストアプリで使いやすいクライアントクラスを用意します。

// src/client/index.ts
import {
  FunctionReference,
  GenericMutationCtx,
  GenericQueryCtx,
} from "convex/server";
import { api } from "../component/_generated/api";

// Re-export the component config
export { default } from "../component/convex.config";

// Type for the installed component
type ComponentApi = typeof api;

export class MyComponentClient {
  private component: ComponentApi;

  constructor(component: ComponentApi) {
    this.component = component;
  }

  // Wrapper methods for cleaner API
  async set(
    ctx: GenericMutationCtx<Record<string, never>>,
    key: string,
    value: string,
    ttl?: number
  ): Promise<void> {
    await ctx.runMutation(this.component.public.set, { key, value, ttl });
  }

  async get(
    ctx: GenericQueryCtx<Record<string, never>>,
    key: string
  ): Promise<string | null> {
    return await ctx.runQuery(this.component.public.get, { key });
  }
}
ホストアプリでの使用方法
// convex/cache.ts
import { MyComponentClient } from "@org/my-component";
import { components } from "./_generated/api";
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";

const cache = new MyComponentClient(components.cache);

export const setValue = mutation({
  args: { key: v.string(), value: v.string() },
  returns: v.null(),
  handler: async (ctx, args) => {
    await cache.set(ctx, args.key, args.value, 3600000);
    return null;
  },
});

export const getValue = query({
  args: { key: v.string() },
  returns: v.union(v.string(), v.null()),
  handler: async (ctx, args) => {
    return await cache.get(ctx, args.key);
  },
});

ビルドと公開

package.json の設定
{
  "name": "@org/my-component",
  "version": "1.0.0",
  "type": "module",
  "main": "./dist/client/index.js",
  "types": "./dist/client/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/client/index.d.ts",
      "import": "./dist/client/index.js"
    },
    "./convex.config": {
      "types": "./dist/component/convex.config.d.ts",
      "import": "./dist/component/convex.config.js"
    }
  },
  "files": ["dist", "src"],
  "scripts": {
    "build": "npm run build:esm && npm run build:cjs",
    "build:esm": "tsc --project tsconfig.json",
    "typecheck": "tsc --noEmit",
    "prepare": "npm run build"
  },
  "dependencies": {
    "convex": "^1.17.0"
  },
  "devDependencies": {
    "typescript": "^5.0.0"
  },
  "peerDependencies": {
    "convex": "^1.17.0"
  }
}
tsconfig.json
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}
公開前に型を生成する
# In component directory
cd src/component
npx convex codegen

# Build the package
cd ../..
npm run build

# Publish
npm publish

コンポーネントのテスト

convex-test の使用
// tests/component.test.ts
import { convexTest } from "convex-test";
import { describe, expect, it } from "vitest";
import schema from "../src/component/schema";
import { api } from "../src/component/_generated/api";

describe("MyComponent", () => {
  it("should set and get values", async () => {
    const t = convexTest(schema);

    // Set a value
    await t.mutation(api.public.set, {
      key: "test-key",
      value: "test-value",
    });

    // Get the value
    const result = await t.query(api.public.get, {
      key: "test-key",
    });

    expect(result).toBe("test-value");
  });

  it("should respect TTL expiration", async () => {
    const t = convexTest(schema);

    // Set with TTL of 0 (immediate expiry)
    await t.mutation(api.public.set, {
      key: "expiring-key",
      value: "expiring-value",
      ttl: -1000, // Already expired
    });

    // Should return null for expired item
    const result = await t.query(api.public.get, {
      key: "expiring-key",
    });

    expect(result).toBeNull();
  });
});
モックデータを使ったテスト
// tests/with-data.test.ts
import { convexTest } from "convex-test";
import { describe, expect, it } from "vitest";
import schema from "../src/component/schema";
import { api } from "../src/component/_generated/api";

describe("MyComponent with data", () => {
  it("should handle existing data", async () => {
    const t = convexTest(schema);

    // Seed data directly
    await t.run(async (ctx) => {
      await ctx.db.insert("items", {
        key: "seeded-key",
        value: "seeded-value",
      });
    });

    // Query should find seeded data
    const result = await t.query(api.public.get, {
      key: "seeded-key",
    });

    expect(result).toBe("seeded-value");
  });
});

ローカルコンポーネントのパターン

NPM で公開せず、アプリ内のモジュール性を高める場合に使用します。

convex/
├── convex.config.ts           # Main app config
├── schema.ts                  # Main app schema
├── myLocalComponent/          # Local component
│   ├── convex.config.ts       # Component definition
│   ├── schema.ts              # Component schema
│   └── functions.ts           # Component functions
└── functions.ts               # Main app functions
// convex/convex.config.ts
import { defineApp } from "convex/server";
import myLocalComponent from "./myLocalComponent/convex.config";

const app = defineApp();
app.use(myLocalComponent, { name: "localCache" });

export default app;

よくある落とし穴

落とし穴 1:コンポーネントの Id 型を使用する

❌ 誤り:

import { Id } from "./_generated/dataModel";

export const getItem = query({
  args: { itemId: v.id("items") }, // ❌ Not accessible!
  handler: async (ctx, args) => {
    return await ctx.db.get(args.itemId);
  },
});

✅ 正しい:

export const getItem = query({
  args: { itemId: v.string() }, // ✅ Accept as string
  returns: v.union(
    v.object({
      id: v.string(),
      data: v.string(),
    }),
    v.null()
  ),
  handler: async (ctx, args) => {
    // Look up by index or iterate
    const item = await ctx.db
      .query("items")
      .filter((q) => q.eq(q.field("_id"), args.itemId))
      .first();

    if (!item) return null;
    return { id: item._id, data: item.data };
  },
});
落とし穴 2:コンポーネントから process.env にアクセスする

❌ 誤り:

// src/component/functions.ts
export const callApi = action({
  args: {},
  returns: v.null(),
  handler: async () => {
    // ❌ Components can't access process.env!
    const apiKey = process.env.API_KEY;
    await fetch("...", { headers: { Authorization: apiKey } });
    return null;
  },
});

✅ 正しい:

// src/component/functions.ts
export const callApi = action({
  args: { apiKey: v.string() }, // ✅ Accept as argument
  returns: v.null(),
  handler: async (ctx, args) => {
    await fetch("...", { headers: { Authorization: args.apiKey } });
    return null;
  },
});

// Host app passes env var
await ctx.runAction(components.myComponent.functions.callApi, {
  apiKey: process.env.API_KEY!,
});
落とし穴 3:コンポーネントで ctx.auth が使えると想定する

❌ 誤り:

// src/component/functions.ts
export const userAction = mutation({
  args: { data: v.string() },
  returns: v.null(),
  handler: async (ctx, args) => {
    // ❌ Components don't have ctx.auth!
    const identity = await ctx.auth.getUserIdentity();
    return null;
  },
});

✅ 正しい:

// src/component/functions.ts
export const userAction = mutation({
  args: {
    userId: v.string(), // ✅ Accept user info as argument
    data: v.string(),
  },
  returns: v.null(),
  handler: async (ctx, args) => {
    await ctx.db.insert("userActions", {
      userId: args.userId,
      data: args.data,
    });
    return null;
  },
});

// Host app handles auth
export const doUserAction = mutation({
  args: { data: v.string() },
  returns: v.null(),
  handler: async (ctx, args) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) throw new Error("Unauthorized");

    await ctx.runMutation(components.myComponent.functions.userAction, {
      userId: identity.subject,
      data: args.data,
    });
    return null;
  },
});

クイックリファレンス

コンポーネントの定義
// convex.config.ts
import { defineComponent } from "convex/server";
export default defineComponent("componentName");
ホストアプリへのインストール
// convex/convex.config.ts
import { defineApp } from "convex/server";
import myComponent from "@org/my-component/convex.config";

const app = defineApp();
app.use(myComponent, { name: "mountName" });
export default app;
コンポーネント関数の呼び出し
import { components } from "./_generated/api";

// In mutations/queries/actions
await ctx.runMutation(components.mountName.module.function, args);
await ctx.runQuery(components.mountName.module.function, args);
await ctx.runAction(components.mountName.module.function, args);
コンポーネントの制限
機能コンポーネントで利用可能か
ctx.dbはい(独自のスキーマのみ)
ctx.authいいえ
process.envいいえ
ctx.schedulerはい
Id<"table"> 型いいえ(文字列を使用)
ページネーションカーソル文字列のみ

コメント