Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Plan and execute safe Convex schema migrations: add fields, change types, split/merge tables, backfill data.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
references/migration-patterns.md
1# Migration Patterns Reference23Common migration patterns, zero-downtime strategies, and verification techniques4for Convex schema and data migrations.56## Adding a Required Field78```typescript9// Deploy 1: Schema allows both states10users: defineTable({11name: v.string(),12role: v.optional(v.union(v.literal("user"), v.literal("admin"))),13});1415// Migration: backfill the field16export const addDefaultRole = migrations.define({17table: "users",18migrateOne: async (ctx, user) => {19if (user.role === undefined) {20await ctx.db.patch(user._id, { role: "user" });21}22},23});2425// Deploy 2: After migration completes, make it required26users: defineTable({27name: v.string(),28role: v.union(v.literal("user"), v.literal("admin")),29});30```3132## Deleting a Field3334Mark the field optional first, migrate data to remove it, then remove from35schema:3637```typescript38// Deploy 1: Make optional39// isPro: v.boolean() --> isPro: v.optional(v.boolean())4041// Migration42export const removeIsPro = migrations.define({43table: "teams",44migrateOne: async (ctx, team) => {45if (team.isPro !== undefined) {46await ctx.db.patch(team._id, { isPro: undefined });47}48},49});5051// Deploy 2: Remove isPro from schema entirely52```5354## Changing a Field Type5556Prefer creating a new field. You can combine adding and deleting in one57migration:5859```typescript60// Deploy 1: Add new field, keep old field optional61// isPro: v.boolean() --> isPro: v.optional(v.boolean()), plan: v.optional(...)6263// Migration: convert old field to new field64export const convertToEnum = migrations.define({65table: "teams",66migrateOne: async (ctx, team) => {67if (team.plan === undefined) {68await ctx.db.patch(team._id, {69plan: team.isPro ? "pro" : "basic",70isPro: undefined,71});72}73},74});7576// Deploy 2: Remove isPro from schema, make plan required77```7879## Splitting Nested Data Into a Separate Table8081```typescript82export const extractPreferences = migrations.define({83table: "users",84migrateOne: async (ctx, user) => {85if (user.preferences === undefined) return;8687const existing = await ctx.db88.query("userPreferences")89.withIndex("by_user", (q) => q.eq("userId", user._id))90.first();9192if (!existing) {93await ctx.db.insert("userPreferences", {94userId: user._id,95...user.preferences,96});97}9899await ctx.db.patch(user._id, { preferences: undefined });100},101});102```103104Make sure your code is already writing to the new `userPreferences` table for105new users before running this migration, so you don't miss documents created106during the migration window.107108## Cleaning Up Orphaned Documents109110```typescript111export const deleteOrphanedEmbeddings = migrations.define({112table: "embeddings",113migrateOne: async (ctx, doc) => {114const chunk = await ctx.db115.query("chunks")116.withIndex("by_embedding", (q) => q.eq("embeddingId", doc._id))117.first();118119if (!chunk) {120await ctx.db.delete(doc._id);121}122},123});124```125126## Zero-Downtime Strategies127128During the migration window, your app must handle both old and new data formats.129There are two main strategies.130131### Dual Write (Preferred)132133Write to both old and new structures. Read from the old structure until134migration is complete.1351361. Deploy code that writes both formats, reads old format1372. Run migration on existing data1383. Deploy code that reads new format, still writes both1394. Deploy code that only reads and writes new format140141This is preferred because you can safely roll back at any point, the old format142is always up to date.143144```typescript145// Bad: only writing to new structure before migration is done146export const createTeam = mutation({147args: { name: v.string(), isPro: v.boolean() },148handler: async (ctx, args) => {149await ctx.db.insert("teams", {150name: args.name,151plan: args.isPro ? "pro" : "basic",152});153},154});155156// Good: writing to both structures during migration157export const createTeam = mutation({158args: { name: v.string(), isPro: v.boolean() },159handler: async (ctx, args) => {160const plan = args.isPro ? "pro" : "basic";161await ctx.db.insert("teams", {162name: args.name,163isPro: args.isPro,164plan,165});166},167});168```169170### Dual Read171172Read both formats. Write only the new format.1731741. Deploy code that reads both formats (preferring new), writes only new format1752. Run migration on existing data1763. Deploy code that reads and writes only new format177178This avoids duplicating writes, which is useful when having two copies of data179could cause inconsistencies. The downside is that rolling back to before step 1180is harder, since new documents only have the new format.181182```typescript183// Good: reading both formats, preferring new184function getTeamPlan(team: Doc<"teams">): "basic" | "pro" {185if (team.plan !== undefined) return team.plan;186return team.isPro ? "pro" : "basic";187}188```189190## Small Table Shortcut191192For small tables (a few thousand documents at most), you can migrate in a single193`internalMutation` without the component:194195```typescript196import { internalMutation } from "./_generated/server";197198export const backfillSmallTable = internalMutation({199handler: async (ctx) => {200const docs = await ctx.db.query("smallConfig").collect();201for (const doc of docs) {202if (doc.newField === undefined) {203await ctx.db.patch(doc._id, { newField: "default" });204}205}206},207});208```209210```bash211npx convex run migrations:backfillSmallTable212```213214Only use `.collect()` when you are certain the table is small. For anything215larger, use the migrations component.216217## Verifying a Migration218219Query to check remaining unmigrated documents:220221```typescript222import { query } from "./_generated/server";223224export const verifyMigration = query({225handler: async (ctx) => {226const remaining = await ctx.db227.query("users")228.filter((q) => q.eq(q.field("role"), undefined))229.take(10);230231return {232complete: remaining.length === 0,233sampleRemaining: remaining.map((u) => u._id),234};235},236});237```238239Or use the component's built-in status monitoring:240241```bash242npx convex run --component migrations lib:getStatus --watch243```244