mcpField
C
convex-components

convex-components

github.com/op0ai开发1749 次安装42 周前更新
代码仓库

关于 convex-components

用于构建可复用 Convex 后端组件的开发技能,组件拥有独立的 schema、函数和命名空间。内容涵盖本地及打包组件的编写、集成、测试与发布,并说明组件特有限制。

主要功能

  1. 01独立的 schema 与函数命名空间
  2. 02本地、打包及混合组件模式
  3. 03宿主应用集成与客户端封装
  4. 04使用 convex-test 测试
  5. 05构建与发布指南
  6. 06组件限制与常见问题

适用场景

  1. 01创建可复用的 Convex 后端模块
  2. 02将 Convex 功能打包发布到 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"> 类型否(使用字符串)
分页游标仅限字符串

评论

支持 Markdown