Convex数据库迁移技能Skill convex-migrations

这个技能涉及使用 Convex 数据库进行模式迁移,包括添加新字段、回填数据、删除过时字段、索引迁移和零停机部署模式。关键词:Convex, 数据库迁移, 模式演化, 零停机, 后端开发。

后端开发 0 次安装 0 次浏览 更新于 3/17/2026

name: convex-migrations displayName: Convex 迁移 描述: 用于演化应用程序的模式迁移策略,包括添加新字段、回填数据、移除过时字段、索引迁移和零停机迁移模式 版本: 1.0.0 作者: Convex 标签: [convex, 迁移, 模式, 数据库, 数据建模]

Convex 迁移

安全地演化您的 Convex 数据库模式,使用模式来添加字段、回填数据、移除过时字段,并保持零停机部署。

文档来源

在实施之前,请勿假设;获取最新文档:

指令

迁移理念

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()),