Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Plan and execute Convex schema migrations safely, including adding fields, creating tables, and data transformations. Use when schema changes affect existing data.
.claude/skills/kunanonj-cursor-plugin-convex-migration-helper/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-07 | ✗→✓ | ▲ Improved | 110% | 0% |
| case-02 | ✓→✓ | = Same ✓ | 109% | 0% |
| case-08 | ✓→✓ | = Same ✓ | 75% | 0% |
| case-01 | ✓→✓ | = Same ✓ | 81% | 0% |
| case-03 | ✓→✓ | = Same ✓ | 147% | 0% |
Safely migrate Convex schemas and data when making breaking changes.
typescript// Before users: defineTable({ name: v.string(), }) // After - Safe! New field is optional users: defineTable({ name: v.string(), bio: v.optional(v.string()), })
typescript// Safe to add completely new tables posts: defineTable({ userId: v.id("users"), title: v.string(), }).index("by_user", ["userId"])
typescript// Safe to add indexes at any time users: defineTable({ name: v.string(), email: v.string(), }) .index("by_email", ["email"]) // New index
Problem: Existing documents won't have the new field.
Solution: Add as optional first, backfill data, then make required.
typescript// Step 1: Add as optional users: defineTable({ name: v.string(), email: v.optional(v.string()), // Start optional }) // Step 2: Create migration import { internalMutation } from "./_generated/server"; import { v } from "convex/values"; export const backfillEmails = internalMutation({ args: {}, handler: async (ctx) => { const users = await ctx.db.query("users").collect(); for (const user of users) { if (!user.email) { await ctx.db.patch(user._id, { email: `user-${user._id}@example.com`, // Default value }); } } }, }); // Step 3: Run migration via dashboard or CLI // npx convex run migrations:backfillEmails // Step 4: Make field required (after all data migrated) users: defineTable({ name: v.string(), email: v.string(), // Now required })
Example: Change tags: v.array(v.string()) to separate table
typescript// Step 1: Create new structure (additive) tags: defineTable({ name: v.string(), }).index("by_name", ["name"]), postTags: defineTable({ postId: v.id("posts"), tagId: v.id("tags"), }) .index("by_post", ["postId"]) .index("by_tag", ["tagId"]), // Keep old field as optional during migration posts: defineTable({ title: v.string(), tags: v.optional(v.array(v.string())), // Keep temporarily }) // Step 2: Write migration export const migrateTags = internalMutation({ args: { batchSize: v.optional(v.number()) }, handler: async (ctx, args) => { const batchSize = args.batchSize ?? 100; const posts = await ctx.db .query("posts") .filter(q => q.neq(q.field("tags"), undefined)) .take(batchSize); for (const post of posts) { if (!post.tags || post.tags.length === 0) { await ctx.db.patch(post._id, { tags: undefined }); continue; } // Create tags and relationships for (const tagName of post.tags) { // Get or create tag let tag = await ctx.db .query("tags") .withIndex("by_name", q => q.eq("name", tagName)) .unique(); if (!tag) { const tagId = await ctx.db.insert("tags", { name: tagName }); tag = { _id: tagId, name: tagName }; } // Create relationship const existing = await ctx.db .query("postTags") .withIndex("by_post", q => q.eq("postId", post._id)) .filter(q => q.eq(q.field("tagId"), tag._id)) .unique(); if (!existing) { await ctx.db.insert("postTags", { postId: post._id, tagId: tag._id, }); } } // Remove old field await ctx.db.patch(post._id, { tags: undefined }); } return { migrated: posts.length }; }, }); // Step 3: Run in batches via cron or manually // Run multiple times until all migrated // Step 4: Remove old field from schema posts: defineTable({ title: v.string(), // tags field removed })
typescript// Step 1: Add new field (optional) users: defineTable({ name: v.string(), displayName: v.optional(v.string()), // New name }) // Step 2: Copy data export const renameField = internalMutation({ handler: async (ctx) => { const users = await ctx.db.query("users").collect(); for (const user of users) { await ctx.db.patch(user._id, { displayName: user.name, }); } }, }); // Step 3: Update schema (remove old field) users: defineTable({ displayName: v.string(), }) // Step 4: Update all code to use new field name
For large tables, process in batches:
typescriptexport const migrateBatch = internalMutation({ args: { cursor: v.optional(v.string()), batchSize: v.number(), }, handler: async (ctx, args) => { const batchSize = args.batchSize; let query = ctx.db.query("largeTable"); // Use cursor for pagination if needed const items = await query.take(batchSize); for (const item of items) { await ctx.db.patch(item._id, { // migration logic }); } return { processed: items.length, hasMore: items.length === batchSize, }; }, });
Use cron jobs for gradual migration:
typescript// convex/crons.ts import { cronJobs } from "convex/server"; import { internal } from "./_generated/api"; const crons = cronJobs(); crons.interval( "migrate-batch", { minutes: 5 }, // Every 5 minutes internal.migrations.migrateBatch, { batchSize: 100 } ); export default crons;
For zero-downtime migrations:
typescript// Write to both old and new structure during transition export const createPost = mutation({ args: { title: v.string(), tags: v.array(v.string()) }, handler: async (ctx, args) => { const user = await getCurrentUser(ctx); // Create post const postId = await ctx.db.insert("posts", { userId: user._id, title: args.title, // Keep writing old field during migration tags: args.tags, }); // ALSO write to new structure for (const tagName of args.tags) { let tag = await ctx.db .query("tags") .withIndex("by_name", q => q.eq("name", tagName)) .unique(); if (!tag) { const tagId = await ctx.db.insert("tags", { name: tagName }); tag = { _id: tagId }; } await ctx.db.insert("postTags", { postId, tagId: tag._id, }); } return postId; }, }); // After migration complete, remove old writes
typescriptexport const verifyMigration = query({ args: {}, handler: async (ctx) => { const total = (await ctx.db.query("users").collect()).length; const migrated = (await ctx.db .query("users") .filter(q => q.neq(q.field("newField"), undefined)) .collect() ).length; return { total, migrated, remaining: total - migrated, percentComplete: (migrated / total) * 100, }; }, });
typescript// 1. Current schema export default defineSchema({ users: defineTable({ name: v.string(), }), }); // 2. Add optional field export default defineSchema({ users: defineTable({ name: v.string(), role: v.optional(v.union( v.literal("user"), v.literal("admin") )), }), }); // 3. Migration function export const addDefaultRoles = internalMutation({ handler: async (ctx) => { const users = await ctx.db.query("users").collect(); for (const user of users) { if (!user.role) { await ctx.db.patch(user._id, { role: "user" }); } } }, }); // 4. Run migration: npx convex run migrations:addDefaultRoles // 5. Verify: Check all users have role // 6. Make required export default defineSchema({ users: defineTable({ name: v.string(), role: v.union( v.literal("user"), v.literal("admin") ), }), });
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-02 | pass→pass | 8,827 | 6,469 | -27% | 1 | 1 | 0% | 2,019 | 4,217 | +109% | 0 | 0 | — |
case-08 | pass→pass | 11,097 | 7,310 | -34% | 1 | 1 | 0% | 2,483 | 4,336 | +75% | 0 | 0 | — |
case-01 | pass→pass | 12,108 | 8,432 | -30% | 1 | 1 | 0% | 2,661 | 4,828 | +81% | 0 | 0 | — |
case-03 | pass→pass | 5,674 | 2,494 | -56% | 1 | 1 | 0% | 1,272 | 3,144 | +147% | 0 | 0 | — |
case-04 | pass→pass | 8,046 | 6,524 | -19% | 1 | 1 | 0% | 1,637 | 4,267 | +161% | 0 | 0 | — |
case-05 | fail→fail | 17,330 | 10,948 | -37% | 1 | 1 | 0% | 3,684 | 5,282 | +43% | 0 | 0 | — |
case-06 | pass→pass | 11,387 | 7,768 | -32% | 1 | 1 | 0% | 2,566 | 4,317 | +68% | 0 | 0 | — |
case-07 | fail→pass | 9,873 | 8,949 | -9% | 1 | 1 | 0% | 2,168 | 4,549 | +110% | 0 | 0 | — |
case-09 | pass→pass | 12,643 | 7,901 | -38% | 1 | 1 | 0% | 2,720 | 4,508 | +66% | 0 | 0 | — |
case-10 | pass→pass | 5,718 | 3,307 | -42% | 1 | 1 | 0% | 1,131 | 3,300 | +192% | 0 | 0 | — |
case-11 | pass→pass | 10,547 | 5,556 | -47% | 1 | 1 | 0% | 2,131 | 3,897 | +83% | 0 | 0 | — |
case-12 | pass→pass | 12,437 | 10,798 | -13% | 1 | 1 | 0% | 2,432 | 5,316 | +119% | 0 | 0 | — |
case-13 | pass→pass | 11,177 | 9,472 | -15% | 1 | 1 | 0% | 2,356 | 4,832 | +105% | 0 | 0 | — |
case-14 | pass→pass | 10,002 | 5,290 | -47% | 1 | 1 | 0% | 2,255 | 3,907 | +73% | 0 | 0 | — |
case-15 | pass→pass | 6,512 | 3,416 | -48% | 1 | 1 | 0% | 1,235 | 3,370 | +173% | 0 | 0 | — |
case-16 | fail→fail | 8,054 | 8,718 | +8% | 1 | 1 | 0% | 1,574 | 4,564 | +190% | 0 | 0 | — |
case-17 | pass→pass | 6,453 | 7,853 | +22% | 1 | 1 | 0% | 1,289 | 3,975 | +208% | 0 | 0 | — |
case-18 | pass→pass | 9,618 | 5,883 | -39% | 1 | 1 | 0% | 1,843 | 3,569 | +94% | 0 | 0 | — |
case-19 | pass→pass | 6,447 | 3,812 | -41% | 1 | 1 | 0% | 1,283 | 3,476 | +171% | 0 | 0 | — |
case-20 | pass→pass | 9,458 | 8,508 | -10% | 1 | 1 | 0% | 2,190 | 4,745 | +117% | 0 | 0 | — |
case-21 | pass→pass | 11,552 | 11,198 | -3% | 1 | 1 | 0% | 2,277 | 4,894 | +115% | 0 | 0 | — |
case-22 | pass→pass | 10,226 | 6,480 | -37% | 1 | 1 | 0% | 2,122 | 4,177 | +97% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +5 percentage points is the difference between those two pass rates over the 22 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.