关于 convex-components
用于构建可复用 Convex 后端组件的开发技能,组件拥有独立的 schema、函数和命名空间。内容涵盖本地及打包组件的编写、集成、测试与发布,并说明组件特有限制。
主要功能
- 01独立的 schema 与函数命名空间
- 02本地、打包及混合组件模式
- 03宿主应用集成与客户端封装
- 04使用 convex-test 测试
- 05构建与发布指南
- 06组件限制与常见问题
适用场景
- 01创建可复用的 Convex 后端模块
- 02将 Convex 功能打包发布到 NPM
- 03集成第三方 Convex 组件
- 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