244 lines
6.0 KiB
Markdown
244 lines
6.0 KiB
Markdown
# Migration Patterns Reference
|
|
|
|
Common migration patterns, zero-downtime strategies, and verification techniques
|
|
for Convex schema and data migrations.
|
|
|
|
## Adding a Required Field
|
|
|
|
```typescript
|
|
// Deploy 1: Schema allows both states
|
|
users: defineTable({
|
|
name: v.string(),
|
|
role: v.optional(v.union(v.literal("user"), v.literal("admin"))),
|
|
});
|
|
|
|
// Migration: backfill the field
|
|
export const addDefaultRole = migrations.define({
|
|
table: "users",
|
|
migrateOne: async (ctx, user) => {
|
|
if (user.role === undefined) {
|
|
await ctx.db.patch(user._id, { role: "user" });
|
|
}
|
|
},
|
|
});
|
|
|
|
// Deploy 2: After migration completes, make it required
|
|
users: defineTable({
|
|
name: v.string(),
|
|
role: v.union(v.literal("user"), v.literal("admin")),
|
|
});
|
|
```
|
|
|
|
## Deleting a Field
|
|
|
|
Mark the field optional first, migrate data to remove it, then remove from
|
|
schema:
|
|
|
|
```typescript
|
|
// Deploy 1: Make optional
|
|
// isPro: v.boolean() --> isPro: v.optional(v.boolean())
|
|
|
|
// Migration
|
|
export const removeIsPro = migrations.define({
|
|
table: "teams",
|
|
migrateOne: async (ctx, team) => {
|
|
if (team.isPro !== undefined) {
|
|
await ctx.db.patch(team._id, { isPro: undefined });
|
|
}
|
|
},
|
|
});
|
|
|
|
// Deploy 2: Remove isPro from schema entirely
|
|
```
|
|
|
|
## Changing a Field Type
|
|
|
|
Prefer creating a new field. You can combine adding and deleting in one
|
|
migration:
|
|
|
|
```typescript
|
|
// Deploy 1: Add new field, keep old field optional
|
|
// isPro: v.boolean() --> isPro: v.optional(v.boolean()), plan: v.optional(...)
|
|
|
|
// Migration: convert old field to new field
|
|
export const convertToEnum = migrations.define({
|
|
table: "teams",
|
|
migrateOne: async (ctx, team) => {
|
|
if (team.plan === undefined) {
|
|
await ctx.db.patch(team._id, {
|
|
plan: team.isPro ? "pro" : "basic",
|
|
isPro: undefined,
|
|
});
|
|
}
|
|
},
|
|
});
|
|
|
|
// Deploy 2: Remove isPro from schema, make plan required
|
|
```
|
|
|
|
## Splitting Nested Data Into a Separate Table
|
|
|
|
```typescript
|
|
export const extractPreferences = migrations.define({
|
|
table: "users",
|
|
migrateOne: async (ctx, user) => {
|
|
if (user.preferences === undefined) return;
|
|
|
|
const existing = await ctx.db
|
|
.query("userPreferences")
|
|
.withIndex("by_user", (q) => q.eq("userId", user._id))
|
|
.first();
|
|
|
|
if (!existing) {
|
|
await ctx.db.insert("userPreferences", {
|
|
userId: user._id,
|
|
...user.preferences,
|
|
});
|
|
}
|
|
|
|
await ctx.db.patch(user._id, { preferences: undefined });
|
|
},
|
|
});
|
|
```
|
|
|
|
Make sure your code is already writing to the new `userPreferences` table for
|
|
new users before running this migration, so you don't miss documents created
|
|
during the migration window.
|
|
|
|
## Cleaning Up Orphaned Documents
|
|
|
|
```typescript
|
|
export const deleteOrphanedEmbeddings = migrations.define({
|
|
table: "embeddings",
|
|
migrateOne: async (ctx, doc) => {
|
|
const chunk = await ctx.db
|
|
.query("chunks")
|
|
.withIndex("by_embedding", (q) => q.eq("embeddingId", doc._id))
|
|
.first();
|
|
|
|
if (!chunk) {
|
|
await ctx.db.delete(doc._id);
|
|
}
|
|
},
|
|
});
|
|
```
|
|
|
|
## Zero-Downtime Strategies
|
|
|
|
During the migration window, your app must handle both old and new data formats.
|
|
There are two main strategies.
|
|
|
|
### Dual Write (Preferred)
|
|
|
|
Write to both old and new structures. Read from the old structure until
|
|
migration is complete.
|
|
|
|
1. Deploy code that writes both formats, reads old format
|
|
2. Run migration on existing data
|
|
3. Deploy code that reads new format, still writes both
|
|
4. Deploy code that only reads and writes new format
|
|
|
|
This is preferred because you can safely roll back at any point, the old format
|
|
is always up to date.
|
|
|
|
```typescript
|
|
// Bad: only writing to new structure before migration is done
|
|
export const createTeam = mutation({
|
|
args: { name: v.string(), isPro: v.boolean() },
|
|
handler: async (ctx, args) => {
|
|
await ctx.db.insert("teams", {
|
|
name: args.name,
|
|
plan: args.isPro ? "pro" : "basic",
|
|
});
|
|
},
|
|
});
|
|
|
|
// Good: writing to both structures during migration
|
|
export const createTeam = mutation({
|
|
args: { name: v.string(), isPro: v.boolean() },
|
|
handler: async (ctx, args) => {
|
|
const plan = args.isPro ? "pro" : "basic";
|
|
await ctx.db.insert("teams", {
|
|
name: args.name,
|
|
isPro: args.isPro,
|
|
plan,
|
|
});
|
|
},
|
|
});
|
|
```
|
|
|
|
### Dual Read
|
|
|
|
Read both formats. Write only the new format.
|
|
|
|
1. Deploy code that reads both formats (preferring new), writes only new format
|
|
2. Run migration on existing data
|
|
3. Deploy code that reads and writes only new format
|
|
|
|
This avoids duplicating writes, which is useful when having two copies of data
|
|
could cause inconsistencies. The downside is that rolling back to before step 1
|
|
is harder, since new documents only have the new format.
|
|
|
|
```typescript
|
|
// Good: reading both formats, preferring new
|
|
function getTeamPlan(team: Doc<"teams">): "basic" | "pro" {
|
|
if (team.plan !== undefined) return team.plan;
|
|
return team.isPro ? "pro" : "basic";
|
|
}
|
|
```
|
|
|
|
## Small Table Shortcut
|
|
|
|
For small tables (a few thousand documents at most), you can migrate in a single
|
|
`internalMutation` without the component:
|
|
|
|
```typescript
|
|
import { internalMutation } from "./_generated/server";
|
|
|
|
export const backfillSmallTable = internalMutation({
|
|
handler: async (ctx) => {
|
|
const docs = await ctx.db.query("smallConfig").collect();
|
|
for (const doc of docs) {
|
|
if (doc.newField === undefined) {
|
|
await ctx.db.patch(doc._id, { newField: "default" });
|
|
}
|
|
}
|
|
},
|
|
});
|
|
```
|
|
|
|
```bash
|
|
npx convex run migrations:backfillSmallTable
|
|
```
|
|
|
|
Only use `.collect()` when you are certain the table is small. For anything
|
|
larger, use the migrations component.
|
|
|
|
## Verifying a Migration
|
|
|
|
Query to check remaining unmigrated documents:
|
|
|
|
```typescript
|
|
import { query } from "./_generated/server";
|
|
|
|
export const verifyMigration = query({
|
|
handler: async (ctx) => {
|
|
const remaining = await ctx.db
|
|
.query("users")
|
|
.filter((q) => q.eq(q.field("role"), undefined))
|
|
.take(10);
|
|
|
|
return {
|
|
complete: remaining.length === 0,
|
|
sampleRemaining: remaining.map((u) => u._id),
|
|
};
|
|
},
|
|
});
|
|
```
|
|
|
|
Or use the component's built-in status monitoring:
|
|
|
|
```bash
|
|
npx convex run --component migrations lib:getStatus --watch
|
|
```
|