关于 convex-helpers-patterns
面向 Convex 开发者的模式指南,介绍如何使用 convex-helpers 实现常见数据库与后端需求,涵盖访问控制、关系查询、触发器、限流和并发管理。
主要功能
- 01文档变更自动触发器
- 02数据库级行级安全
- 03文档关系查询辅助工具
- 04可复用的自定义函数包装器
- 05限流与并发控制
- 06带状态跟踪的架构迁移
适用场景
- 01在文档变更后运行副作用
- 02实施读写访问规则
- 03遍历相关文档
- 04限制并发任务与写入
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ op0ai/ claude-skills --skill convex-helpers-patterns安装后在 Claude Code 中输入/convex-helpers-patterns确认已加载。
详细介绍
Convex 辅助库模式
概览
convex-helpers 库提供了经过实战验证的模式,适用于常见的 Convex 开发需求。本指南涵盖触发器(自动副作用)、行级安全性、关系辅助工具、自定义函数、速率限制,以及用于并发控制的 Workpool。
安装
npm install convex-helpers @convex-dev/workpool
TypeScript:绝不使用 any 类型
关键规则: 此代码库启用了 @typescript-eslint/no-explicit-any。使用 any 会导致构建失败。
何时使用本指南
在以下情况下使用本指南:
- 在文档变更时实现自动副作用(触发器)
- 添加声明式访问控制(行级安全性)
- 遍历文档之间的关系
- 创建可复用的身份验证函数包装器
- 实现速率限制
- 使用 Workpool 管理并发写入
- 构建自定义函数构造器
关键模式概览
| 模式 | 用例 |
|---|---|
| 触发器 | 在文档变更时自动运行代码 |
| 行级安全性 | 在数据库层实现声明式访问控制 |
| 关系辅助工具 | 简化文档关系的遍历 |
| 自定义函数 | 使用身份验证、日志记录等包装查询/变更 |
| 速率限制器 | 应用层速率限制 |
| Workpool | 扇出并行作业、串行执行冲突的变更 |
| 迁移 | 带状态跟踪的架构迁移 |
触发器(自动副作用)
文档变更时,触发器会自动运行代码。它们与变更在同一事务中以原子方式执行。
设置触发器
// convex/functions.ts
import { mutation as rawMutation } from "./_generated/server";
import { Triggers } from "convex-helpers/server/triggers";
import {
customCtx,
customMutation,
} from "convex-helpers/server/customFunctions";
import { DataModel } from "./_generated/dataModel";
const triggers = new Triggers<DataModel>();
// 1. Compute fullName on every user change
triggers.register("users", async (ctx, change) => {
if (change.newDoc) {
const fullName = `${change.newDoc.firstName} ${change.newDoc.lastName}`;
if (change.newDoc.fullName !== fullName) {
await ctx.db.patch(change.id, { fullName });
}
}
});
// 2. Keep denormalized count (careful: single doc = write contention)
triggers.register("users", async (ctx, change) => {
const countDoc = (await ctx.db.query("userCount").unique())!;
if (change.operation === "insert") {
await ctx.db.patch(countDoc._id, { count: countDoc.count + 1 });
} else if (change.operation === "delete") {
await ctx.db.patch(countDoc._id, { count: countDoc.count - 1 });
}
});
// 3. Cascading deletes
triggers.register("users", async (ctx, change) => {
if (change.operation === "delete") {
const messages = await ctx.db
.query("messages")
.withIndex("by_author", (q) => q.eq("authorId", change.id))
.collect();
for (const msg of messages) {
await ctx.db.delete(msg._id);
}
}
});
// Export wrapped mutation that runs triggers
export const mutation = customMutation(rawMutation, customCtx(triggers.wrapDB));
触发器变更对象
interface Change<Doc> {
id: Id<TableName>;
operation: "insert" | "update" | "delete";
oldDoc: Doc | null; // null for inserts
newDoc: Doc | null; // null for deletes
}
触发器警告
警告: 触发器与变更在同一事务中运行。在触发器中写入热点文档(例如全局计数器)会在高负载下导致 OCC 冲突。对于高争用写入,请使用分片或 Workpool。
行级安全性(RLS)
在数据库层实现声明式访问控制。RLS 会包装数据库上下文,以便在每次读取和写入时强制执行规则。
设置 RLS
// convex/functions.ts
import {
Rules,
wrapDatabaseReader,
wrapDatabaseWriter,
} from "convex-helpers/server/rowLevelSecurity";
import {
customCtx,
customQuery,
customMutation,
} from "convex-helpers/server/customFunctions";
import { query, mutation } from "./_generated/server";
import { QueryCtx } from "./_generated/server";
import { DataModel } from "./_generated/dataModel";
async function rlsRules(ctx: QueryCtx) {
const identity = await ctx.auth.getUserIdentity();
return {
users: {
read: async (_, user) => {
// Unauthenticated users can only read users over 18
if (!identity && user.age < 18) return false;
return true;
},
insert: async () => true,
modify: async (_, user) => {
if (!identity) throw new Error("Must be authenticated");
// Users can only modify their own record
return user.tokenIdentifier === identity.tokenIdentifier;
},
},
messages: {
read: async (_, message) => {
// Only read messages in conversations you're a member of
const conversation = await ctx.db.get(message.conversationId);
return conversation?.members.includes(identity?.subject ?? "") ?? false;
},
modify: async (_, message) => {
// Only modify your own messages
return message.authorId === identity?.subject;
},
},
// Table with no restrictions
publicPosts: {
read: async () => true,
insert: async () => true,
modify: async () => true,
},
} satisfies Rules<QueryCtx, DataModel>;
}
// Wrap query/mutation with RLS
export const queryWithRLS = customQuery(
query,
customCtx(async (ctx) => ({
db: wrapDatabaseReader(ctx, ctx.db, await rlsRules(ctx)),
}))
);
export const mutationWithRLS = customMutation(
mutation,
customCtx(async (ctx) => ({
db: wrapDatabaseWriter(ctx, ctx.db, await rlsRules(ctx)),
}))
);
使用 RLS 包装函数
// convex/messages.ts
import { queryWithRLS, mutationWithRLS } from "./functions";
import { v } from "convex/values";
// This query automatically enforces RLS rules
export const list = queryWithRLS({
args: { conversationId: v.id("conversations") },
returns: v.array(
v.object({
_id: v.id("messages"),
_creationTime: v.number(),
content: v.string(),
authorId: v.string(),
})
),
handler: async (ctx, args) => {
// RLS automatically filters out unauthorized messages
return await ctx.db
.query("messages")
.withIndex("by_conversation", (q) =>
q.eq("conversationId", args.conversationId)
)
.collect();
},
});
// This mutation automatically enforces RLS rules
export const update = mutationWithRLS({
args: { messageId: v.id("messages"), content: v.string() },
returns: v.null(),
handler: async (ctx, args) => {
// RLS checks if user can modify this message
await ctx.db.patch(args.messageId, { content: args.content });
return null;
},
});
关系辅助工具
无需手动查找即可简化关系遍历。
可用辅助工具
import {
getAll,
getOneFrom,
getManyFrom,
getManyVia,
} from "convex-helpers/server/relationships";
一对一关系
// Get single related document via back reference
const profile = await getOneFrom(
ctx.db,
"profiles", // target table
"userId", // index field
user._id // value to match
);
一对多(按 ID 数组)
// Load multiple documents by IDs
const users = await getAll(ctx.db, userIds);
// Returns array of documents in same order as IDs (null for missing)
一对多(通过索引)
// Get all posts by author
const posts = await getManyFrom(
ctx.db,
"posts", // target table
"by_authorId", // index name
author._id // value to match
);
多对多(通过联接表)
// Schema:
// posts: { title: v.string() }
// categories: { name: v.string() }
// postCategories: { postId: v.id("posts"), categoryId: v.id("categories") }
// .index("by_post", ["postId"])
// .index("by_category", ["categoryId"])
// Get all categories for a post
const categories = await getManyVia(
ctx.db,
"postCategories", // join table
"categoryId", // field pointing to target
"by_post", // index to query join table
post._id // source ID
);
// Get all posts in a category
const posts = await getManyVia(
ctx.db,
"postCategories",
"postId",
"by_category",
category._id
);
完整示例
// convex/posts.ts
import { query } from "./_generated/server";
import { v } from "convex/values";
import {
getOneFrom,
getManyFrom,
getManyVia,
} from "convex-helpers/server/relationships";
export const getPostWithDetails = query({
args: { postId: v.id("posts") },
returns: v.union(
v.object({
post: v.object({
_id: v.id("posts"),
title: v.string(),
body: v.string(),
}),
author: v.union(
v.object({
_id: v.id("users"),
name: v.string(),
}),
v.null()
),
comments: v.array(
v.object({
_id: v.id("comments"),
body: v.string(),
})
),
categories: v.array(
v.object({
_id: v.id("categories"),
name: v.string(),
})
),
}),
v.null()
),
handler: async (ctx, args) => {
const post = await ctx.db.get(args.postId);
if (!post) return null;
const [author, comments, categories] = await Promise.all([
// One-to-one: post -> author
ctx.db.get(post.authorId),
// One-to-many: post -> comments
getManyFrom(ctx.db, "comments", "by_post", post._id),
// Many-to-many: post -> categories (via join table)
getManyVia(ctx.db, "postCategories", "categoryId", "by_post", post._id),
]);
return {
post: { _id: post._id, title: post.title, body: post.body },
author: author ? { _id: author._id, name: author.name } : null,
comments: comments.map((c) => ({ _id: c._id, body: c.body })),
categories: categories
.filter((c): c is NonNullable<typeof c> => c !== null)
.map((c) => ({ _id: c._id, name: c.name })),
};
},
});
自定义函数(身份验证包装器)
创建内置身份验证功能的可复用函数包装器。
基本身份验证包装器
// convex/functions.ts
import {
customQuery,
customMutation,
} from "convex-helpers/server/customFunctions";
import { query, mutation } from "./_generated/server";
import { Doc } from "./_generated/dataModel";
// Query that requires authentication
export const authedQuery = customQuery(query, {
args: {},
input: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Unauthorized");
const user = await ctx.db
.query("users")
.withIndex("by_token", (q) =>
q.eq("tokenIdentifier", identity.tokenIdentifier)
)
.unique();
if (!user) throw new Error("User not found");
return { ctx: { ...ctx, user }, args };
},
});
// Mutation that requires authentication
export const authedMutation = customMutation(mutation, {
args: {},
input: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Unauthorized");
const user = await ctx.db
.query("users")
.withIndex("by_token", (q) =>
q.eq("tokenIdentifier", identity.tokenIdentifier)
)
.unique();
if (!user) throw new Error("User not found");
return { ctx: { ...ctx, user }, args };
},
});
使用已验证函数
// convex/profile.ts
import { authedQuery, authedMutation } from "./functions";
import { v } from "convex/values";
// ctx.user is guaranteed to exist
export const getMyProfile = authedQuery({
args: {},
returns: v.object({
_id: v.id("users"),
name: v.string(),
email: v.string(),
}),
handler: async (ctx) => {
// ctx.user is typed and guaranteed to exist!
return {
_id: ctx.user._id,
name: ctx.user.name,
email: ctx.user.email,
};
},
});
export const updateMyName = authedMutation({
args: { name: v.string() },
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.patch(ctx.user._id, { name: args.name });
return null;
},
});
基于角色的身份验证包装器
// convex/functions.ts
export const adminQuery = customQuery(query, {
args: {},
input: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Unauthorized");
const user = await ctx.db
.query("users")
.withIndex("by_token", (q) =>
q.eq("tokenIdentifier", identity.tokenIdentifier)
)
.unique();
if (!user) throw new Error("User not found");
if (user.role !== "admin") throw new Error("Admin access required");
return { ctx: { ...ctx, user }, args };
},
});
// Usage
export const listAllUsers = adminQuery({
args: {},
returns: v.array(
v.object({
_id: v.id("users"),
name: v.string(),
role: v.string(),
})
),
handler: async (ctx) => {
const users = await ctx.db.query("users").collect();
return users.map((u) => ({ _id: u._id, name: u.name, role: u.role }));
},
});
Workpool(并发控制)
Workpool 可管理并发执行并限制并行度,适用于:
- 串行执行写入以避免 OCC 冲突
- 在限制范围内进行扇出并行处理
- 对外部 API 调用进行速率限制
设置 Workpool
首先,安装并配置 Workpool 组件:
// convex/convex.config.ts
import { defineApp } from "convex/server";
import workpool from "@convex-dev/workpool/convex.config";
const app = defineApp();
app.use(workpool, { name: "workpool" });
export default app;
使用 Workpool
// convex/counters.ts
import { Workpool } from "@convex-dev/workpool";
import { components, internal } from "./_generated/api";
import { mutation, internalMutation } from "./_generated/server";
import { v } from "convex/values";
// Create workpool with parallelism limit
const counterPool = new Workpool(components.workpool, {
maxParallelism: 1, // Serialize all counter updates
});
// Public mutation enqueues work
export const incrementCounter = mutation({
args: {},
returns: v.null(),
handler: async (ctx) => {
await counterPool.enqueueMutation(ctx, internal.counters.doIncrement, {});
return null;
},
});
// Internal mutation does the actual work
export const doIncrement = internalMutation({
args: {},
returns: v.null(),
handler: async (ctx) => {
const counter = await ctx.db.query("counters").unique();
if (counter) {
await ctx.db.patch(counter._id, { count: counter.count + 1 });
}
return null;
},
});
带限制的并行处理
// Process many items with limited concurrency
const processingPool = new Workpool(components.workpool, {
maxParallelism: 5, // Process 5 items at a time
});
export const processAll = mutation({
args: { itemIds: v.array(v.id("items")) },
returns: v.null(),
handler: async (ctx, args) => {
for (const itemId of args.itemIds) {
await processingPool.enqueueAction(ctx, internal.items.processOne, {
itemId,
});
}
return null;
},
});
速率限制
使用 convex-helpers 实现应用层速率限制。
设置速率限制器
// convex/rateLimit.ts
import { RateLimiter } from "convex-helpers/server/rateLimit";
import { components } from "./_generated/api";
export const rateLimiter = new RateLimiter(components.rateLimit, {
// Global rate limit
global: {
kind: "token bucket",
rate: 100, // 100 requests
period: 60000, // per minute
},
// Per-user rate limit
perUser: {
kind: "token bucket",
rate: 10,
period: 60000,
},
});
使用速率限制器
// convex/api.ts
import { mutation } from "./_generated/server";
import { v } from "convex/values";
import { rateLimiter } from "./rateLimit";
export const createPost = mutation({
args: { title: v.string(), body: v.string() },
returns: v.union(
v.id("posts"),
v.object({
error: v.string(),
retryAfter: v.number(),
})
),
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Unauthorized");
// Check rate limit
const { ok, retryAfter } = await rateLimiter.limit(ctx, "perUser", {
key: identity.subject,
});
if (!ok) {
// Add jitter to prevent thundering herd
const jitter = Math.random() * 1000;
return {
error: "Rate limit exceeded",
retryAfter: retryAfter + jitter,
};
}
const postId = await ctx.db.insert("posts", {
title: args.title,
body: args.body,
authorId: identity.subject,
});
return postId;
},
});
组合模式
触发器 + RLS + 自定义函数
// convex/functions.ts
import {
mutation as rawMutation,
query as rawQuery,
} from "./_generated/server";
import { Triggers } from "convex-helpers/server/triggers";
import {
wrapDatabaseReader,
wrapDatabaseWriter,
} from "convex-helpers/server/rowLevelSecurity";
import {
customCtx,
customQuery,
customMutation,
} from "convex-helpers/server/customFunctions";
// Set up triggers
const triggers = new Triggers<DataModel>();
triggers.register("posts", async (ctx, change) => {
if (change.operation === "insert") {
// Update author's post count
const author = await ctx.db.get(change.newDoc!.authorId);
if (author) {
await ctx.db.patch(author._id, {
postCount: (author.postCount ?? 0) + 1,
});
}
}
});
// Set up RLS rules
async function rlsRules(ctx: QueryCtx) {
const identity = await ctx.auth.getUserIdentity();
return {
posts: {
read: async () => true,
modify: async (_, post) => post.authorId === identity?.subject,
},
};
}
// Combine everything into authenticated, RLS-protected, trigger-enabled functions
export const authedMutation = customMutation(rawMutation, {
args: {},
input: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Unauthorized");
const user = await ctx.db
.query("users")
.withIndex("by_token", (q) =>
q.eq("tokenIdentifier", identity.tokenIdentifier)
)
.unique();
if (!user) throw new Error("User not found");
// Wrap DB with triggers and RLS
const wrappedDb = wrapDatabaseWriter(
ctx,
triggers.wrapDB(ctx).db,
await rlsRules(ctx)
);
return { ctx: { ...ctx, user, db: wrappedDb }, args };
},
});
常见问题
问题 1:触发器导致 OCC 冲突
❌ 错误:
// This trigger updates a single global counter - will cause OCC under load
triggers.register("posts", async (ctx, change) => {
if (change.operation === "insert") {
const stats = await ctx.db.query("globalStats").unique();
await ctx.db.patch(stats!._id, { postCount: stats!.postCount + 1 });
}
});
✅ 正确:
// Use sharding or Workpool for high-contention updates
triggers.register("posts", async (ctx, change) => {
if (change.operation === "insert") {
const shardId = Math.floor(Math.random() * 10);
await ctx.db.insert("postCountShards", { shardId, delta: 1 });
}
});
问题 2:RLS 规则遗漏表
❌ 错误:
// Missing rules for some tables - they'll be unprotected!
async function rlsRules(ctx: QueryCtx) {
return {
users: { read: async () => true, modify: async () => false },
// Missing posts, messages, etc.!
};
}
✅ 正确:
// Define rules for ALL tables
async function rlsRules(ctx: QueryCtx) {
return {
users: { read: async () => true, modify: async () => false },
posts: { read: async () => true, modify: async () => true },
messages: { read: async () => true, modify: async () => true },
// ... all other tables
} satisfies Rules<QueryCtx, DataModel>;
}
快速参考
导入模式
// Triggers
import { Triggers } from "convex-helpers/server/triggers";
// RLS
import {
Rules,
wrapDatabaseReader,
wrapDatabaseWriter,
} from "convex-helpers/server/rowLevelSecurity";
// Custom Functions
import {
customCtx,
customQuery,
customMutation,
} from "convex-helpers/server/customFunctions";
// Relationships
import {
getAll,
getOneFrom,
getManyFrom,
getManyVia,
} from "convex-helpers/server/relationships";
// Workpool
import { Workpool } from "@convex-dev/workpool";
// Rate Limiter
import { RateLimiter } from "convex-helpers/server/rateLimit";
评论
支持 Markdown