name: convex-migrations displayName: Convex 迁移 描述: 用于演化应用程序的模式迁移策略,包括添加新字段、回填数据、移除过时字段、索引迁移和零停机迁移模式 版本: 1.0.0 作者: Convex 标签: [convex, 迁移, 模式, 数据库, 数据建模]
Convex 迁移
安全地演化您的 Convex 数据库模式,使用模式来添加字段、回填数据、移除过时字段,并保持零停机部署。
文档来源
在实施之前,请勿假设;获取最新文档:
- 主要: https://docs.convex.dev/database/schemas
- 模式概述: https://docs.convex.dev/database
- 迁移模式: https://stack.convex.dev/migrate-data-postgres-to-convex
- 更广泛背景: https://docs.convex.dev/llms.txt
指令
迁移理念
Convex 以不同于传统数据库的方式处理模式演化:
- 没有显式迁移文件或命令
- 模式更改通过
npx convex dev即时部署 - 现有数据不会自动转换
- 使用可选字段和回填突变进行安全迁移
添加新字段
从可选字段开始,然后回填:
// 步骤 1: 在模式中添加可选字段
// convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
users: defineTable({
name: v.string(),
email: v.string(),
// 新字段 - 从可选开始
avatarUrl: v.optional(v.string()),
}),
});
// 步骤 2: 更新代码以处理两种情况
// convex/users.ts
import { query } from "./_generated/server";
import { v } from "convex/values";
export const getUser = query({
args: { userId: v.id("users") },
returns: v.union(
v.object({
_id: v.id("users"),
name: v.string(),
email: v.string(),
avatarUrl: v.union(v.string(), v.null()),
}),
v.null()
),
handler: async (ctx, args) => {
const user = await ctx.db.get(args.userId);
if (!user) return null;
return {
_id: user._id,
name: user.name,
email: user.email,
// 优雅处理缺失字段
avatarUrl: user.avatarUrl ?? null,
};
},
});
// 步骤 3: 回填现有文档
// convex/migrations.ts
import { internalMutation } from "./_generated/server";
import { internal } from "./_generated/api";
import { v } from "convex/values";
const BATCH_SIZE = 100;
export const backfillAvatarUrl = internalMutation({
args: {
cursor: v.optional(v.string()),
},
returns: v.object({
processed: v.number(),
hasMore: v.boolean(),
}),
handler: async (ctx, args) => {
const result = await ctx.db
.query("users")
.paginate({ numItems: BATCH_SIZE, cursor: args.cursor ?? null });
let processed = 0;
for (const user of result.page) {
// 仅当字段缺失时更新
if (user.avatarUrl === undefined) {
await ctx.db.patch(user._id, {
avatarUrl: generateDefaultAvatar(user.name),
});
processed++;
}
}
// 如果需要,安排下一批
if (!result.isDone) {
await ctx.scheduler.runAfter(0, internal.migrations.backfillAvatarUrl, {
cursor: result.continueCursor,
});
}
return {
processed,
hasMore: !result.isDone,
};
},
});
function generateDefaultAvatar(name: string): string {
return `https://api.dicebear.com/7.x/initials/svg?seed=${encodeURIComponent(name)}`;
}
// 步骤 4: 回填完成后,使字段变为必需
// convex/schema.ts
export default defineSchema({
users: defineTable({
name: v.string(),
email: v.string(),
avatarUrl: v.string(), // 现在必需
}),
});
移除字段
在从模式中移除之前停止使用字段:
// 步骤 1: 在查询和突变中停止使用该字段
// 在代码注释中标记为已弃用
// 步骤 2: 从模式中移除字段(如果需要,先使其可选)
// convex/schema.ts
export default defineSchema({
posts: defineTable({
title: v.string(),
content: v.string(),
authorId: v.id("users"),
// legacyField: v.optional(v.string()), // 删除此行
}),
});
// 步骤 3: 可选地清理现有数据
// convex/migrations.ts
export const removeDeprecatedField = internalMutation({
args: {
cursor: v.optional(v.string()),
},
returns: v.null(),
handler: async (ctx, args) => {
const result = await ctx.db
.query("posts")
.paginate({ numItems: 100, cursor: args.cursor ?? null });
for (const post of result.page) {
// 使用替换来完全移除字段
const { legacyField, ...rest } = post as typeof post & { legacyField?: string };
if (legacyField !== undefined) {
await ctx.db.replace(post._id, rest);
}
}
if (!result.isDone) {
await ctx.scheduler.runAfter(0, internal.migrations.removeDeprecatedField, {
cursor: result.continueCursor,
});
}
return null;
},
});
重命名字段
重命名需要将数据复制到新字段,然后移除旧字段:
// 步骤 1: 添加新字段作为可选
// convex/schema.ts
export default defineSchema({
users: defineTable({
userName: v.string(), // 旧字段
displayName: v.optional(v.string()), // 新字段
}),
});
// 步骤 2: 更新代码以从新字段读取,并设置回退
export const getUser = query({
args: { userId: v.id("users") },
returns: v.object({
_id: v.id("users"),
displayName: v.string(),
}),
handler: async (ctx, args) => {
const user = await ctx.db.get(args.userId);
if (!user) throw new Error("用户未找到");
return {
_id: user._id,
// 读取新字段,回退到旧字段
displayName: user.displayName ?? user.userName,
};
},
});
// 步骤 3: 回填以复制数据
export const backfillDisplayName = internalMutation({
args: { cursor: v.optional(v.string()) },
returns: v.null(),
handler: async (ctx, args) => {
const result = await ctx.db
.query("users")
.paginate({ numItems: 100, cursor: args.cursor ?? null });
for (const user of result.page) {
if (user.displayName === undefined) {
await ctx.db.patch(user._id, {
displayName: user.userName,
});
}
}
if (!result.isDone) {
await ctx.scheduler.runAfter(0, internal.migrations.backfillDisplayName, {
cursor: result.continueCursor,
});
}
return null;
},
});
// 步骤 4: 回填后,更新模式使新字段变为必需
// 并移除旧字段
export default defineSchema({
users: defineTable({
// userName 已移除
displayName: v.string(),
}),
});
添加索引
在使用查询之前添加索引:
// 步骤 1: 在模式中添加索引
// convex/schema.ts
export default defineSchema({
posts: defineTable({
title: v.string(),
authorId: v.id("users"),
publishedAt: v.optional(v.number()),
status: v.string(),
})
.index("by_author", ["authorId"])
// 新索引
.index("by_status_and_published", ["status", "publishedAt"]),
});
// 步骤 2: 部署模式更改
// 运行: npx convex dev
// 步骤 3: 现在在查询中使用索引
export const getPublishedPosts = query({
args: {},
returns: v.array(v.object({
_id: v.id("posts"),
title: v.string(),
publishedAt: v.number(),
})),
handler: async (ctx) => {
const posts = await ctx.db
.query("posts")
.withIndex("by_status_and_published", (q) =>
q.eq("status", "published")
)
.order("desc")
.take(10);
return posts
.filter((p) => p.publishedAt !== undefined)
.map((p) => ({
_id: p._id,
title: p.title,
publishedAt: p.publishedAt!,
}));
},
});
更改字段类型
类型更改需要小心迁移:
// 示例: 将 "priority" 字段从字符串更改为数字
// 步骤 1: 添加新类型的新字段
// convex/schema.ts
export default defineSchema({
tasks: defineTable({
title: v.string(),
priority: v.string(), // 旧: "low", "medium", "high"
priorityLevel: v.optional(v.number()), // 新: 1, 2, 3
}),
});
// 步骤 2: 回填并转换类型
export const migratePriorityToNumber = internalMutation({
args: { cursor: v.optional(v.string()) },
returns: v.null(),
handler: async (ctx, args) => {
const result = await ctx.db
.query("tasks")
.paginate({ numItems: 100, cursor: args.cursor ?? null });
const priorityMap: Record<string, number> = {
low: 1,
medium: 2,
high: 3,
};
for (const task of result.page) {
if (task.priorityLevel === undefined) {
await ctx.db.patch(task._id, {
priorityLevel: priorityMap[task.priority] ?? 1,
});
}
}
if (!result.isDone) {
await ctx.scheduler.runAfter(0, internal.migrations.migratePriorityToNumber, {
cursor: result.continueCursor,
});
}
return null;
},
});
// 步骤 3: 更新代码以使用新字段
export const getTask = query({
args: { taskId: v.id("tasks") },
returns: v.object({
_id: v.id("tasks"),
title: v.string(),
priorityLevel: v.number(),
}),
handler: async (ctx, args) => {
const task = await ctx.db.get(args.taskId);
if (!task) throw new Error("任务未找到");
const priorityMap: Record<string, number> = {
low: 1,
medium: 2,
high: 3,
};
return {
_id: task._id,
title: task.title,
priorityLevel: task.priorityLevel ?? priorityMap[task.priority] ?? 1,
};
},
});
// 步骤 4: 回填后,更新模式
export default defineSchema({
tasks: defineTable({
title: v.string(),
// priority 字段已移除
priorityLevel: v.number(),
}),
});
迁移运行器模式
创建可重用的迁移系统:
// convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
migrations: defineTable({
name: v.string(),
startedAt: v.number(),
completedAt: v.optional(v.number()),
status: v.union(
v.literal("running"),
v.literal("completed"),
v.literal("failed")
),
error: v.optional(v.string()),