convex-fundamentals
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseConvex Fundamentals for TypeScript Agents
面向TypeScript开发者的Convex基础教程
Overview
概述
Convex is a reactive backend-as-a-service that provides real-time data synchronization, ACID transactions, and TypeScript-first development. This skill covers the core mental model, function types, layered architecture patterns, and HTTP action implementation.
Convex是一款响应式后端即服务(BaaS),提供实时数据同步、ACID事务以及TypeScript优先的开发体验。本技能涵盖核心思维模型、函数类型、分层架构模式以及HTTP action的实现方法。
TypeScript: NEVER Use any
Type
anyTypeScript:禁止使用any
类型
anyCRITICAL RULE: This codebase has enabled. Using will cause build failures.
@typescript-eslint/no-explicit-anyany❌ WRONG:
typescript
function handleData(data: any) { ... }
const items: any[] = [];✅ CORRECT:
typescript
function handleData(data: { id: string; name: string }) { ... }
const items: Doc<"items">[] = [];【重要规则】:本代码库已启用规则,使用会导致构建失败。
@typescript-eslint/no-explicit-anyany❌ 错误示例:
typescript
function handleData(data: any) { ... }
const items: any[] = [];✅ 正确示例:
typescript
function handleData(data: { id: string; name: string }) { ... }
const items: Doc<"items">[] = [];When to Use This Skill
适用场景
Use this skill when:
- Building Convex backend applications from scratch
- Creating queries, mutations, or actions
- Understanding the difference between function types
- Implementing layered architecture patterns
- Setting up HTTP webhooks with route handlers
- Migrating from other backends to Convex
- Understanding Convex's reactive data model
在以下场景中使用本技能:
- 从零开始构建Convex后端应用
- 创建query、mutation或action
- 理解不同函数类型的差异
- 实现分层架构模式
- 通过路由处理器设置HTTP Webhook
- 从其他后端迁移至Convex
- 理解Convex的响应式数据模型
Philosophy: Why Convex Feels "Wrong" (And Why That's Right)
设计理念:为何Convex看似“反直觉”(实则合理)
Convex intentionally constrains you. When something feels like an anti-pattern, it's usually Convex telling you to rethink the approach.
Convex会刻意对开发者加以约束。当你觉得某种写法是反模式时,通常是Convex在提示你重新思考实现方式。
The Intentional Friction
刻意设计的约束
| What feels wrong | What Convex is telling you |
|---|---|
"I can't use | Define an index. Queries should be O(log n), not O(n). |
"Actions can't access | Side effects and transactions don't mix. Route through mutations. |
| "My mutation keeps failing with OCC errors" | You're writing too often to the same document. Redesign your data model or use Workpool. |
"I can't call | Mutations must be deterministic. Schedule an action instead. |
| "Queries re-run on every document change" | You're collecting too much data. Narrow your index or denormalize. |
| "I can't do joins" | Denormalize. Embed related data or use lookup tables. |
| 看似反直觉的操作 | Convex的深层提示 |
|---|---|
“我无法在query中使用 | 请定义索引。Query应保证O(log n)的时间复杂度,而非O(n)。 |
“Action无法访问 | 副作用与事务不能混合使用,请通过mutation处理。 |
| “我的mutation频繁出现OCC错误” | 你过于频繁地写入同一文档。请重新设计数据模型或使用Workpool。 |
“我无法在mutation中调用 | Mutation必须是确定性的,请改用action执行。 |
| “每次文档变更时Query都会重新运行” | 你查询的数据范围过大,请缩小索引范围或使用反范式设计。 |
| “我无法执行关联查询” | 使用反范式设计,嵌入关联数据或使用查找表。 |
Core Invariants
核心不变原则
- Mutations are ACID transactions — All writes succeed together or fail together.
- Queries are reactive — They re-run when any read document changes.
- Actions are non-transactional orchestration — No reactivity, no . Can do external I/O and must call queries/mutations via
ctx.db.ctx.run* - Scheduling is atomic with mutations — If mutation fails, scheduled functions don't run.
- Mutation是ACID事务 — 所有写入操作要么全部成功,要么全部失败。
- Query是响应式的 — 当任何读取的文档发生变更时,Query会重新运行。
- Action是非事务性的编排工具 — 无响应式特性,无法访问,可执行外部I/O操作,必须通过
ctx.db调用query/mutation。ctx.run* - 调度与mutation原子性绑定 — 如果mutation执行失败,已调度的函数不会运行。
Core Mental Model
核心思维模型
Everything in Convex falls into these categories:
| Kind | Visibility | Purpose |
|---|---|---|
| public | Read from DB, reactive subscriptions |
| public | Write to DB, ACID transactions |
| public | External APIs, non-deterministic work |
| public (HTTP) | Webhooks, third-party integrations |
| private | Read primitives for internal use |
| private | Write primitives for internal use |
| private | Orchestration, external calls |
Rule: Export a thin public surface. All real logic lives in internal primitives.
variants have identical execution semantics to their public counterparts. The only difference is visibility — internal functions appear oninternal*, notinternal.*, and cannot be called from clients.api.*
Convex中的所有功能可分为以下类别:
| 类型 | 可见性 | 用途 |
|---|---|---|
| 公开 | 读取数据库,响应式订阅 |
| 公开 | 写入数据库,ACID事务 |
| 公开 | 调用外部API,执行非确定性任务 |
| 公开(HTTP) | Webhook、第三方集成 |
| 私有 | 供内部使用的读取基础组件 |
| 私有 | 供内部使用的写入基础组件 |
| 私有 | 任务编排、外部API调用 |
规则:仅对外暴露精简的公共接口,所有核心逻辑均实现为内部基础组件。
系列组件的执行语义与对应的公共组件完全一致,唯一区别是可见性——内部函数挂载在internal*下,而非internal.*,且无法被客户端直接调用。api.*
Layered Architecture
分层架构
Layer 1: Client Surface (Public API)
第一层:客户端接口(公共API)
Only entrypoints live here. Thin wrappers that validate, auth-check, and delegate.
typescript
// convex/jobs.ts — PUBLIC SURFACE
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { internal } from "./_generated/api";
// Public mutation: client entrypoint
export const startGeneration = mutation({
args: { prompt: v.string() },
returns: v.id("jobs"),
handler: async (ctx, args) => {
// 1. Auth check
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Unauthorized");
// 2. Delegate to internal mutation
const jobId = await ctx.runMutation(internal.jobs.createJob, {
userId: identity.subject,
prompt: args.prompt,
});
// 3. Schedule background work
await ctx.scheduler.runAfter(0, internal.jobs.processJob, { jobId });
return jobId;
},
});
// Public query: reactive subscription
export const getJob = query({
args: { jobId: v.id("jobs") },
returns: v.union(
v.object({
_id: v.id("jobs"),
_creationTime: v.number(),
status: v.string(),
prompt: v.string(),
result: v.optional(v.string()),
}),
v.null()
),
handler: async (ctx, args) => {
return await ctx.db.get(args.jobId);
},
});仅包含入口点,是用于验证、权限检查和委托调用的轻量级包装器。
typescript
// convex/jobs.ts — 公共接口
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { internal } from "./_generated/api";
// 公开mutation:客户端入口
export const startGeneration = mutation({
args: { prompt: v.string() },
returns: v.id("jobs"),
handler: async (ctx, args) => {
// 1. 权限检查
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Unauthorized");
// 2. 委托给内部mutation
const jobId = await ctx.runMutation(internal.jobs.createJob, {
userId: identity.subject,
prompt: args.prompt,
});
// 3. 调度后台任务
await ctx.scheduler.runAfter(0, internal.jobs.processJob, { jobId });
return jobId;
},
});
// 公开query:响应式订阅
export const getJob = query({
args: { jobId: v.id("jobs") },
returns: v.union(
v.object({
_id: v.id("jobs"),
_creationTime: v.number(),
status: v.string(),
prompt: v.string(),
result: v.optional(v.string()),
}),
v.null()
),
handler: async (ctx, args) => {
return await ctx.db.get(args.jobId);
},
});Layer 2: Domain Logic (Internal Primitives)
第二层:领域逻辑(内部基础组件)
Where the real logic lives. These are your backend's private API.
typescript
// convex/jobs.ts — INTERNAL PRIMITIVES (same file, different exports)
import { internalMutation, internalQuery } from "./_generated/server";
import { v } from "convex/values";
export const createJob = internalMutation({
args: {
userId: v.string(),
prompt: v.string(),
},
returns: v.id("jobs"),
handler: async (ctx, args) => {
return await ctx.db.insert("jobs", {
userId: args.userId,
prompt: args.prompt,
status: "pending",
});
},
});
export const markComplete = internalMutation({
args: {
jobId: v.id("jobs"),
result: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.patch(args.jobId, {
status: "completed",
result: args.result,
});
return null;
},
});
export const markFailed = internalMutation({
args: {
jobId: v.id("jobs"),
error: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.patch(args.jobId, {
status: "failed",
error: args.error,
});
return null;
},
});
export const getById = internalQuery({
args: { jobId: v.id("jobs") },
returns: v.union(
v.object({
_id: v.id("jobs"),
_creationTime: v.number(),
userId: v.string(),
prompt: v.string(),
status: v.string(),
result: v.optional(v.string()),
error: v.optional(v.string()),
}),
v.null()
),
handler: async (ctx, args) => {
return await ctx.db.get(args.jobId);
},
});核心逻辑实现层,是后端的私有API。
typescript
// convex/jobs.ts — 内部基础组件(同一文件,不同导出)
import { internalMutation, internalQuery } from "./_generated/server";
import { v } from "convex/values";
export const createJob = internalMutation({
args: {
userId: v.string(),
prompt: v.string(),
},
returns: v.id("jobs"),
handler: async (ctx, args) => {
return await ctx.db.insert("jobs", {
userId: args.userId,
prompt: args.prompt,
status: "pending",
});
},
});
export const markComplete = internalMutation({
args: {
jobId: v.id("jobs"),
result: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.patch(args.jobId, {
status: "completed",
result: args.result,
});
return null;
},
});
export const markFailed = internalMutation({
args: {
jobId: v.id("jobs"),
error: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.patch(args.jobId, {
status: "failed",
error: args.error,
});
return null;
},
});
export const getById = internalQuery({
args: { jobId: v.id("jobs") },
returns: v.union(
v.object({
_id: v.id("jobs"),
_creationTime: v.number(),
userId: v.string(),
prompt: v.string(),
status: v.string(),
result: v.optional(v.string()),
error: v.optional(v.string()),
}),
v.null()
),
handler: async (ctx, args) => {
return await ctx.db.get(args.jobId);
},
});Layer 3: Orchestration (Actions + Scheduler)
第三层:任务编排(Action + 调度器)
For multi-step, external, or long-running work.
typescript
// convex/jobs.ts — ORCHESTRATION
import { internalAction } from "./_generated/server";
import { v } from "convex/values";
import { internal } from "./_generated/api";
export const processJob = internalAction({
args: { jobId: v.id("jobs") },
returns: v.null(),
handler: async (ctx, args) => {
// 1. Read current state
const job = await ctx.runQuery(internal.jobs.getById, {
jobId: args.jobId,
});
if (!job || job.status !== "pending") return null;
try {
// 2. External API call (non-deterministic)
const result = await fetch("https://api.example.com/generate", {
method: "POST",
body: JSON.stringify({ prompt: job.prompt }),
});
const data = await result.json();
// 3. Write result via mutation
await ctx.runMutation(internal.jobs.markComplete, {
jobId: args.jobId,
result: data.output,
});
} catch (e) {
await ctx.runMutation(internal.jobs.markFailed, {
jobId: args.jobId,
error: String(e),
});
}
return null;
},
});用于处理多步骤、外部调用或长时间运行的任务。
typescript
// convex/jobs.ts — 任务编排
import { internalAction } from "./_generated/server";
import { v } from "convex/values";
import { internal } from "./_generated/api";
export const processJob = internalAction({
args: { jobId: v.id("jobs") },
returns: v.null(),
handler: async (ctx, args) => {
// 1. 读取当前状态
const job = await ctx.runQuery(internal.jobs.getById, {
jobId: args.jobId,
});
if (!job || job.status !== "pending") return null;
try {
// 2. 调用外部API(非确定性操作)
const result = await fetch("https://api.example.com/generate", {
method: "POST",
body: JSON.stringify({ prompt: job.prompt }),
});
const data = await result.json();
// 3. 通过mutation写入结果
await ctx.runMutation(internal.jobs.markComplete, {
jobId: args.jobId,
result: data.output,
});
} catch (e) {
await ctx.runMutation(internal.jobs.markFailed, {
jobId: args.jobId,
error: String(e),
});
}
return null;
},
});HTTP Actions (Webhooks)
HTTP Action(Webhook)
HTTP actions must be in with :
convex/http.tshttpRoutertypescript
// convex/http.ts
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
import { internal } from "./_generated/api";
const http = httpRouter();
http.route({
path: "/webhooks/stripe",
method: "POST",
handler: httpAction(async (ctx, req) => {
// 1. Validate signature
const signature = req.headers.get("stripe-signature");
if (!signature) {
return new Response("Missing signature", { status: 401 });
}
// 2. Parse body
const body = await req.text();
// 3. Delegate to internal mutation
await ctx.runMutation(internal.billing.handleStripeWebhook, {
signature,
body,
});
return new Response(null, { status: 200 });
}),
});
export default http;HTTP action必须定义在中,并使用:
convex/http.tshttpRoutertypescript
// convex/http.ts
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
import { internal } from "./_generated/api";
const http = httpRouter();
http.route({
path: "/webhooks/stripe",
method: "POST",
handler: httpAction(async (ctx, req) => {
// 1. 验证签名
const signature = req.headers.get("stripe-signature");
if (!signature) {
return new Response("Missing signature", { status: 401 });
}
// 2. 解析请求体
const body = await req.text();
// 3. 委托给内部mutation
await ctx.runMutation(internal.billing.handleStripeWebhook, {
signature,
body,
});
return new Response(null, { status: 200 });
}),
});
export default http;Model Layer Pattern
模型层模式
Extract business logic into reusable model functions. Functions stay thin; models do the work.
将业务逻辑提取为可复用的模型函数。保持函数轻量化,核心工作由模型完成。
Why Model Layer?
为何使用模型层?
- Reuse — Same logic across queries, mutations, actions
- Testability — Pure functions, easy to unit test
- Separation — Auth checks in one place, business logic in another
- Type Safety — and
QueryCtxtypes for contextMutationCtx
- 复用性 — 同一逻辑可在query、mutation、action中共享
- 可测试性 — 纯函数,易于单元测试
- 关注点分离 — 权限检查集中处理,业务逻辑独立实现
- 类型安全 — 使用和
QueryCtx类型约束上下文MutationCtx
Pattern: Model Functions
模式:模型函数
typescript
// convex/model/users.ts — MODEL LAYER
import { QueryCtx, MutationCtx } from "../_generated/server";
import { Id } from "../_generated/dataModel";
// Read helper: reusable across queries
export async function getCurrentUser(ctx: QueryCtx) {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Unauthorized");
const user = await ctx.db
.query("users")
.withIndex("by_tokenIdentifier", (q) =>
q.eq("tokenIdentifier", identity.tokenIdentifier)
)
.unique();
if (!user) throw new Error("User not found");
return user;
}
// Access control helper
export async function ensureHasAccess(
ctx: QueryCtx,
{ conversationId }: { conversationId: Id<"conversations"> }
) {
const user = await getCurrentUser(ctx);
const conversation = await ctx.db.get(conversationId);
if (!conversation || !conversation.members.includes(user._id)) {
throw new Error("Unauthorized");
}
return { user, conversation };
}
// Write helper: encapsulates business logic
export async function addMessage(
ctx: MutationCtx,
{
conversationId,
content,
}: { conversationId: Id<"conversations">; content: string }
) {
const { user } = await ensureHasAccess(ctx, { conversationId });
const messageId = await ctx.db.insert("messages", {
conversationId,
authorId: user._id,
content,
createdAt: Date.now(),
});
// Update denormalized lastMessageAt
await ctx.db.patch(conversationId, { lastMessageAt: Date.now() });
return messageId;
}typescript
// convex/model/users.ts — 模型层
import { QueryCtx, MutationCtx } from "../_generated/server";
import { Id } from "../_generated/dataModel";
// 读取助手:可在多个query中复用
export async function getCurrentUser(ctx: QueryCtx) {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Unauthorized");
const user = await ctx.db
.query("users")
.withIndex("by_tokenIdentifier", (q) =>
q.eq("tokenIdentifier", identity.tokenIdentifier)
)
.unique();
if (!user) throw new Error("User not found");
return user;
}
// 权限控制助手
export async function ensureHasAccess(
ctx: QueryCtx,
{ conversationId }: { conversationId: Id<"conversations"> }
) {
const user = await getCurrentUser(ctx);
const conversation = await ctx.db.get(conversationId);
if (!conversation || !conversation.members.includes(user._id)) {
throw new Error("Unauthorized");
}
return { user, conversation };
}
// 写入助手:封装业务逻辑
export async function addMessage(
ctx: MutationCtx,
{
conversationId,
content,
}: { conversationId: Id<"conversations">; content: string }
) {
const { user } = await ensureHasAccess(ctx, { conversationId });
const messageId = await ctx.db.insert("messages", {
conversationId,
authorId: user._id,
content,
createdAt: Date.now(),
});
// 更新反范式存储的lastMessageAt字段
await ctx.db.patch(conversationId, { lastMessageAt: Date.now() });
return messageId;
}Pattern: Thin Function Wrappers
模式:轻量化函数包装器
typescript
// convex/conversations.ts — THIN WRAPPERS
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import * as Conversations from "./model/conversations";
// Query: delegates to model
export const listMessages = query({
args: { conversationId: v.id("conversations") },
returns: v.array(
v.object({
_id: v.id("messages"),
content: v.string(),
authorId: v.id("users"),
createdAt: v.number(),
})
),
handler: async (ctx, args) => {
return Conversations.listMessages(ctx, args);
},
});
// Mutation: delegates to model
export const sendMessage = mutation({
args: { conversationId: v.id("conversations"), content: v.string() },
returns: v.id("messages"),
handler: async (ctx, args) => {
return Conversations.addMessage(ctx, args);
},
});typescript
// convex/conversations.ts — 轻量化包装器
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import * as Conversations from "./model/conversations";
// Query:委托给模型处理
export const listMessages = query({
args: { conversationId: v.id("conversations") },
returns: v.array(
v.object({
_id: v.id("messages"),
content: v.string(),
authorId: v.id("users"),
createdAt: v.number(),
})
),
handler: async (ctx, args) => {
return Conversations.listMessages(ctx, args);
},
});
// Mutation:委托给模型处理
export const sendMessage = mutation({
args: { conversationId: v.id("conversations"), content: v.string() },
returns: v.id("messages"),
handler: async (ctx, args) => {
return Conversations.addMessage(ctx, args);
},
});File Structure
文件结构
Flat-first. Convex uses file-based routing:
convex/
schema.ts # Database schema
http.ts # HTTP actions (webhooks)
auth.ts # Auth helpers
crons.ts # Cron job definitions
# Domain files (public + internal in same file)
users.ts # api.users.* + internal.users.*
jobs.ts
billing.ts
audio.ts
# Model layer (business logic)
model/
users.ts # User business logic helpers
conversations.ts # Conversation logic
billing.ts # Billing logic
# Shared utilities (NOT Convex functions)
_lib/
validators.ts # Shared validators
constants.ts # ConstantsRouting:
- →
convex/users.ts/api.users.functionNameinternal.users.functionName - →
convex/audio/stems.tsapi.audio.stems.functionName - → NOT routed (helper imports only)
convex/model/*.ts
优先使用扁平化结构,Convex采用基于文件的路由机制:
convex/
schema.ts # 数据库 schema
http.ts # HTTP action(Webhook)
auth.ts # 权限验证助手
crons.ts # Cron 任务定义
# 领域文件(公共与内部组件同文件)
users.ts # api.users.* + internal.users.*
jobs.ts
billing.ts
audio.ts
# 模型层(业务逻辑)
model/
users.ts # 用户业务逻辑助手
conversations.ts # 会话逻辑
billing.ts # 账单逻辑
# 共享工具库(非Convex函数)
_lib/
validators.ts # 共享验证器
constants.ts # 常量定义路由规则:
- →
convex/users.ts/api.users.functionNameinternal.users.functionName - →
convex/audio/stems.tsapi.audio.stems.functionName - → 不参与路由(仅作为助手导入使用)
convex/model/*.ts
Function Types Quick Reference
函数类型速查
Import Patterns
导入方式
typescript
// Public (client-callable)
import { query, mutation, action } from "./_generated/server";
// Private (internal only)
import {
internalQuery,
internalMutation,
internalAction,
} from "./_generated/server";
// HTTP
import { httpAction } from "./_generated/server";
import { httpRouter } from "convex/server";typescript
// 公共组件(客户端可调用)
import { query, mutation, action } from "./_generated/server";
// 私有组件(仅内部使用)
import {
internalQuery,
internalMutation,
internalAction,
} from "./_generated/server";
// HTTP组件
import { httpAction } from "./_generated/server";
import { httpRouter } from "convex/server";Function References
函数引用方式
typescript
import { api, internal } from "./_generated/api";
// Public: api.fileName.functionName
await ctx.runQuery(api.users.getUser, { userId });
// Internal: internal.fileName.functionName
await ctx.runMutation(internal.jobs.createJob, { data });typescript
import { api, internal } from "./_generated/api";
// 公共组件:api.fileName.functionName
await ctx.runQuery(api.users.getUser, { userId });
// 私有组件:internal.fileName.functionName
await ctx.runMutation(internal.jobs.createJob, { data });Database Operations
数据库操作
typescript
// Read
const doc = await ctx.db.get(id);
const docs = await ctx.db
.query("table")
.withIndex("by_x", (q) => q.eq("x", value))
.collect();
const single = await ctx.db
.query("table")
.withIndex("by_x", (q) => q.eq("x", value))
.unique();
// Write
const id = await ctx.db.insert("table", { field: value });
await ctx.db.patch(id, { field: newValue });
await ctx.db.replace(id, { ...fullDocument });
await ctx.db.delete(id);typescript
// 读取
const doc = await ctx.db.get(id);
const docs = await ctx.db
.query("table")
.withIndex("by_x", (q) => q.eq("x", value))
.collect();
const single = await ctx.db
.query("table")
.withIndex("by_x", (q) => q.eq("x", value))
.unique();
// 写入
const id = await ctx.db.insert("table", { field: value });
await ctx.db.patch(id, { field: newValue });
await ctx.db.replace(id, { ...fullDocument });
await ctx.db.delete(id);Common Pitfalls
常见陷阱
Pitfall 1: Calling fetch() in Mutations
陷阱1:在Mutation中调用fetch()
❌ WRONG:
typescript
export const createOrder = mutation({
args: { productId: v.string() },
returns: v.null(),
handler: async (ctx, args) => {
// ❌ Mutations must be deterministic - no external calls!
const price = await fetch(
"https://api.stripe.com/prices/" + args.productId
);
await ctx.db.insert("orders", { productId: args.productId, price });
return null;
},
});✅ CORRECT:
typescript
// Mutation creates order, schedules action for external call
export const createOrder = mutation({
args: { productId: v.string() },
returns: v.id("orders"),
handler: async (ctx, args) => {
const orderId = await ctx.db.insert("orders", {
productId: args.productId,
status: "pending",
});
await ctx.scheduler.runAfter(0, internal.orders.fetchPrice, { orderId });
return orderId;
},
});
// Action handles external API call
export const fetchPrice = internalAction({
args: { orderId: v.id("orders") },
returns: v.null(),
handler: async (ctx, args) => {
const order = await ctx.runQuery(internal.orders.getById, {
orderId: args.orderId,
});
const price = await fetch(
"https://api.stripe.com/prices/" + order.productId
);
await ctx.runMutation(internal.orders.updatePrice, {
orderId: args.orderId,
price: await price.json(),
});
return null;
},
});❌ 错误示例:
typescript
export const createOrder = mutation({
args: { productId: v.string() },
returns: v.null(),
handler: async (ctx, args) => {
// ❌ Mutation必须是确定性的,禁止调用外部API!
const price = await fetch(
"https://api.stripe.com/prices/" + args.productId
);
await ctx.db.insert("orders", { productId: args.productId, price });
return null;
},
});✅ 正确示例:
typescript
// Mutation创建订单,调度Action执行外部调用
export const createOrder = mutation({
args: { productId: v.string() },
returns: v.id("orders"),
handler: async (ctx, args) => {
const orderId = await ctx.db.insert("orders", {
productId: args.productId,
status: "pending",
});
await ctx.scheduler.runAfter(0, internal.orders.fetchPrice, { orderId });
return orderId;
},
});
// Action处理外部API调用
export const fetchPrice = internalAction({
args: { orderId: v.id("orders") },
returns: v.null(),
handler: async (ctx, args) => {
const order = await ctx.runQuery(internal.orders.getById, {
orderId: args.orderId,
});
const price = await fetch(
"https://api.stripe.com/prices/" + order.productId
);
await ctx.runMutation(internal.orders.updatePrice, {
orderId: args.orderId,
price: await price.json(),
});
return null;
},
});Pitfall 2: Accessing ctx.db in Actions
陷阱2:在Action中访问ctx.db
❌ WRONG:
typescript
export const processData = action({
args: { id: v.string() },
returns: v.null(),
handler: async (ctx, args) => {
// ❌ Actions don't have ctx.db!
const data = await ctx.db.get(args.id);
return null;
},
});✅ CORRECT:
typescript
export const processData = action({
args: { id: v.id("data") },
returns: v.null(),
handler: async (ctx, args) => {
// ✅ Use ctx.runQuery to read data
const data = await ctx.runQuery(internal.data.getById, { id: args.id });
// Do external work...
const result = await fetch("...");
// ✅ Use ctx.runMutation to write data
await ctx.runMutation(internal.data.update, {
id: args.id,
result: await result.json(),
});
return null;
},
});❌ 错误示例:
typescript
export const processData = action({
args: { id: v.string() },
returns: v.null(),
handler: async (ctx, args) => {
// ❌ Action无法访问ctx.db!
const data = await ctx.db.get(args.id);
return null;
},
});✅ 正确示例:
typescript
export const processData = action({
args: { id: v.id("data") },
returns: v.null(),
handler: async (ctx, args) => {
// ✅ 使用ctx.runQuery读取数据
const data = await ctx.runQuery(internal.data.getById, { id: args.id });
// 执行外部操作...
const result = await fetch("...");
// ✅ 使用ctx.runMutation写入数据
await ctx.runMutation(internal.data.update, {
id: args.id,
result: await result.json(),
});
return null;
},
});Pitfall 3: Missing returns Validator
陷阱3:缺少返回值验证器
❌ WRONG:
typescript
export const foo = mutation({
args: {},
handler: async (ctx) => {
// implicitly returns undefined
},
});✅ CORRECT:
typescript
export const foo = mutation({
args: {},
returns: v.null(),
handler: async (ctx) => {
return null;
},
});❌ 错误示例:
typescript
export const foo = mutation({
args: {},
handler: async (ctx) => {
// 隐式返回undefined
},
});✅ 正确示例:
typescript
export const foo = mutation({
args: {},
returns: v.null(),
handler: async (ctx) => {
return null;
},
});Decision Guide
决策指南
- Does this function read from DB? → Use or
queryinternalQuery - Does this function write to DB? → Use or
mutationinternalMutation - Does this function call external APIs? → Use or
actioninternalAction - Is this called by clients? → Use public variants (,
query,mutation)action - Is this internal only? → Use variants
internal* - Is this a webhook endpoint? → Use in
httpActionconvex/http.ts
- 该函数是否需要读取数据库? → 使用或
queryinternalQuery - 该函数是否需要写入数据库? → 使用或
mutationinternalMutation - 该函数是否需要调用外部API? → 使用或
actioninternalAction - 该函数是否供客户端调用? → 使用公共组件(,
query,mutation)action - 该函数是否仅内部使用? → 使用系列组件
internal* - 该函数是否为Webhook端点? → 在中使用
convex/http.tshttpAction