研发协作
在 Convex 中安全演进…
当你需要为 Convex 应用添加、重命名、删除字段,修改字段类型或新增索引,并希望避免线上数据中断时使用。
🧪 尚未被验证(还没有小呱复用过的记录,不代表不好)
GDI C 38/100
进化评分(GDI)
综合排序依据 —— 四个维度的加权结果,让你看出「为什么这条排在前面」
内在质量 55/100 ×0.35
使用度 0/100 ×0.30
口碑 50/100 ×0.20
新鲜度 59/100 ×0.15
适用场景
在 Convex 中安全演进 schem特定
解决思路
- 实施前先获取最新文档,不要凭空假设:主文档 https://docs.convex.dev/database/schemas、Schema 概览 https://docs.convex.dev/database、迁移模式 https://stack.convex.dev/migrate-data-postgres-to-convex、更广上下文 https://docs.convex.dev/llms.txt。
- 理解 Convex 的 schema 演进方式:没有显式迁移文件或命令;schema 更改通过 `npx convex dev` 即时部署;现有数据不会自动转换;使用 optional 字段和 backfill mutations 做安全迁移。
- 添加新字段时,先在 `convex/schema.ts` 中把字段定义为 `v.optional(...)`。
- 更新查询/变更代码以同时处理字段存在和缺失两种情况;在返回值中把缺失值转换为 `null` 或默认值(例如 `user.avatarUrl ?? null`)。
- 在 `convex/migrations.ts` 等文件中编写 `internalMutation` 回填字段;使用 `ctx.db.query(...).paginate({ numItems: BATCH_SIZE, cursor })` 分批处理,`BATCH_SIZE` 例如 100。
- 回填时只更新字段为 `undefined` 的文档;用 `ctx.db.patch(user._id, { ... })` 设置默认值或转换值。
- 每批处理后若 `!result.isDone`,用 `ctx.scheduler.runAfter(0, internal.migrations.<name>, { cursor: result.continueCursor })` 调度下一批。
- 回填完成后,把 schema 中的新字段从 optional 改为 required。
- 删除字段时,先在查询和变更中停止使用该字段,并在代码注释中标记为 deprecated。
- 从 `convex/schema.ts` 中移除该字段(必要时先把它设为 optional);不要先删数据再改代码。
- 可选地清理现有数据:编写 `internalMutation` 分页查询,使用 `ctx.db.replace(post._id, rest)` 移除字段;跳过字段已不存在的文档。
- 清理时每批处理后若 `!result.isDone`,用 `ctx.scheduler.runAfter(0, internal.migrations.removeDeprecatedField, { cursor: result.continueCursor })` 调度下一批。
- 重命名字段时,先在 schema 中添加新字段为 optional(旧字段保留)。
- 更新读取代码优先读新字段,并在缺失时回退到旧字段(例如 `user.displayName ?? user.userName`)。
- 编写 `internalMutation` 分批回填,把旧字段值复制到新字段;只处理新字段为 `undefined` 的文档;使用 `ctx.db.patch` 更新。
- 复制回填中每批处理后若 `!result.isDone`,用 `ctx.scheduler.runAfter(0, ...)` 调度下一批。
- 复制完成后,更新 schema:把新字段改为 required,并移除旧字段。
- 添加索引时,先在 `convex/schema.ts` 的表定义上调用 `.index("index_name", ["field1", ...])` 添加索引。
- 运行 `npx convex dev` 部署 schema 变更。
- 部署后,再在查询中使用 `.withIndex("index_name", q => q.eq(...))` 等基于新索引查询。
- 修改字段类型时,先在 schema 中添加新类型的新字段为 optional(旧字段保留)。
- 编写 `internalMutation` 分批回填,把旧字段值通过映射转换成新类型写入新字段;只处理新字段为 `undefined` 的文档。
- 类型转换回填中每批处理后若 `!result.isDone`,用 `ctx.scheduler.runAfter(0, ...)` 调度下一批。
- 更新读取代码,优先使用新字段,并在缺失时用映射从旧字段计算回退值。
- 回填完成后,更新 schema:移除旧字段并把新字段设为 required。
- 创建可复用迁移系统:在 `convex/schema.ts` 中定义 `migrations` 表,包含 `name`、`startedAt`、`completedAt`、`status`(`running`/`completed`/`failed`)、`error`、`processed`,并添加 `.index("by_name", ["name"])`。
- 在 `convex/migrations.ts` 中实现 `hasMigrationRun` internalQuery:按 `name` 查询 `migrations`,返回 `migration?.status === "completed"`。
- 实现 `startMigration` internalMutation:按 `name` 查询已有迁移;若已完成则抛错 `Migration ${args.name} already completed`;若正在运行则抛错 `Migration ${args.name} already running`;若失败则 patch 重置为 `running`、更新 `startedAt`、清空 `error`、`processed: 0` 并返回 `_id`;否则 insert 新迁移记录并返回 `_id`。
- 实现 `updateMigrationProgress` internalMutation:获取 migration,patch `processed` 为当前 `processed + args.processed`。
- 实现 `completeMigration` internalMutation:patch `status: "completed"`、`completedAt: Date.now()`。
- 实现 `failMigration` internalMutation:patch `status: "failed"`、`error: args.error`。
- 为每个具体迁移创建 `run` internalMutation(例如 `convex/migrations/addUserTimestamps.ts`),参数包含可选的 `migrationId` 和 `cursor`;首次运行时调用 `hasMigrationRun`,若已完成则 console.log 并返回 `null`;否则调用 `startMigration` 获取 `migrationId`。
- 在具体 `run` 中按 `BATCH_SIZE`(例如 100)分页查询目标表;只处理需要迁移的文档;用 `ctx.db.patch` 更新;统计 `processed`。
- 调用 `updateMigrationProgress` 累加进度。
- 若 `!result.isDone`,用 `ctx.scheduler.runAfter(0, internal.migrations.<migration>.run, { migrationId, cursor: result.continueCursor })` 调度下一批;否则调用 `completeMigration` 并 console.log 完成。
- 用 try/catch 包裹迁移逻辑;捕获错误时调用 `failMigration` 记录 `String(error)`,然后重新抛出。
- 参考示例 schema:`migrations` 表用于跟踪;`users` 表按迁移版本逐步加入 optional 字段(如 `createdAt`、`updatedAt`、`avatarUrl`、`settings`)并添加索引;`posts` 表添加常用查询索引。
- 最佳实践:除非明确指示,绝不运行 `npx convex deploy`。
- 最佳实践:除非明确指示,绝不运行任何 git 命令。
- 最佳实践:添加新数据时总是从 optional 字段开始。
- 最佳实践:分批回填数据以避免超时。
- 最佳实践:在生产前先在开发环境测试迁移。
- 最佳实践:跟踪已完成迁移以避免重复运行。
- 最佳实践:过渡期间更新代码以同时处理旧数据和新数据。
- 最佳实践:仅在所有代码停止使用废弃字段后再删除废弃字段。
- 最佳实践:对大数据集使用分页。
- 最佳实践:在基于新字段运行查询前添加适当索引。
- 避免常见陷阱:不要立即将新字段设为 required,否则会破坏现有文档。
- 避免常见陷阱:不要不处理 `undefined` 值,否则会导致运行时错误。
- 避免常见陷阱:不要使用过大批量,否则会导致函数超时。
- 避免常见陷阱:不要忘记更新索引,否则查询会失败或性能差。
- 避免常见陷阱:不要在无跟踪的情况下运行迁移,否则可能多次运行。
- 避免常见陷阱:不要在代码更新前删除字段,否则会破坏现有功能。
- 避免常见陷阱:不要不在开发环境测试,否则会造成生产数据问题。
适用边界 · 注意事项
- 不适用于非 Convex 数据库或不需要 Convex 部署模型的场景。
- 在无法分批处理、无法在开发环境测试、无法跟踪迁移状态时,不要直接用于生产。
- 当需求要求立即破坏性变更且不允许过渡期兼容旧数据/新数据时,不适用。