Install
Terminal · npx$
npx skills add https://github.com/waynesutton/convexskills --skill convex-migrationsWorks with Paperclip
How Convex Migrations fits into a Paperclip company.
Convex Migrations drops into any Paperclip agent that handles convex and migrations work. Assign it to a specialist inside a pre-configured PaperclipOrg company and the skill becomes available on every heartbeat — no prompt engineering, no tool wiring.
S
SaaS FactoryPaired
Pre-configured AI company — 18 agents, 18 skills, one-time purchase.
$27$59
Explore packSource file
SKILL.md712 linesExpandCollapse
---name: convex-migrationsdisplayName: Convex Migrationsdescription: Schema migration strategies for evolving applications including adding new fields, backfilling data, removing deprecated fields, index migrations, and zero-downtime migration patternsversion: 1.0.0author: Convextags: [convex, migrations, schema, database, data-modeling]--- # Convex Migrations Evolve your Convex database schema safely with patterns for adding fields, backfilling data, removing deprecated fields, and maintaining zero-downtime deployments. ## Documentation Sources Before implementing, do not assume; fetch the latest documentation: - Primary: https://docs.convex.dev/database/schemas- Schema Overview: https://docs.convex.dev/database- Migration Patterns: https://stack.convex.dev/migrate-data-postgres-to-convex- For broader context: https://docs.convex.dev/llms.txt ## Instructions ### Migration Philosophy Convex handles schema evolution differently than traditional databases: - No explicit migration files or commands- Schema changes deploy instantly with `npx convex dev`- Existing data is not automatically transformed- Use optional fields and backfill mutations for safe migrations ### Adding New Fields Start with optional fields, then backfill: ```typescript// Step 1: Add optional field to schema// convex/schema.tsimport { defineSchema, defineTable } from "convex/server";import { v } from "convex/values"; export default defineSchema({ users: defineTable({ name: v.string(), email: v.string(), // New field - start as optional avatarUrl: v.optional(v.string()), }),});``` ```typescript// Step 2: Update code to handle both cases// convex/users.tsimport { 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, // Handle missing field gracefully avatarUrl: user.avatarUrl ?? null, }; },});``` ```typescript// Step 3: Backfill existing documents// convex/migrations.tsimport { 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) { // Only update if field is missing if (user.avatarUrl === undefined) { await ctx.db.patch(user._id, { avatarUrl: generateDefaultAvatar(user.name), }); processed++; } } // Schedule next batch if needed 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)}`;}``` ```typescript// Step 4: After backfill completes, make field required// convex/schema.tsexport default defineSchema({ users: defineTable({ name: v.string(), email: v.string(), avatarUrl: v.string(), // Now required }),});``` ### Removing Fields Remove field usage before removing from schema: ```typescript// Step 1: Stop using the field in queries and mutations// Mark as deprecated in code comments // Step 2: Remove field from schema (make optional first if needed)// convex/schema.tsexport default defineSchema({ posts: defineTable({ title: v.string(), content: v.string(), authorId: v.id("users"), // legacyField: v.optional(v.string()), // Remove this line }),}); // Step 3: Optionally clean up existing data// convex/migrations.tsexport 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) { // Use replace to remove the field entirely 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; },});``` ### Renaming Fields Renaming requires copying data to new field, then removing old: ```typescript// Step 1: Add new field as optional// convex/schema.tsexport default defineSchema({ users: defineTable({ userName: v.string(), // Old field displayName: v.optional(v.string()), // New field }),}); // Step 2: Update code to read from new field with fallbackexport 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("User not found"); return { _id: user._id, // Read new field, fall back to old displayName: user.displayName ?? user.userName, }; },}); // Step 3: Backfill to copy dataexport 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; },}); // Step 4: After backfill, update schema to make new field required// and remove old fieldexport default defineSchema({ users: defineTable({ // userName removed displayName: v.string(), }),});``` ### Adding Indexes Add indexes before using them in queries: ```typescript// Step 1: Add index to schema// convex/schema.tsexport default defineSchema({ posts: defineTable({ title: v.string(), authorId: v.id("users"), publishedAt: v.optional(v.number()), status: v.string(), }) .index("by_author", ["authorId"]) // New index .index("by_status_and_published", ["status", "publishedAt"]),}); // Step 2: Deploy schema change// Run: npx convex dev // Step 3: Now use the index in queriesexport 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!, })); },});``` ### Changing Field Types Type changes require careful migration: ```typescript// Example: Change from string to number for a "priority" field // Step 1: Add new field with new type// convex/schema.tsexport default defineSchema({ tasks: defineTable({ title: v.string(), priority: v.string(), // Old: "low", "medium", "high" priorityLevel: v.optional(v.number()), // New: 1, 2, 3 }),}); // Step 2: Backfill with type conversionexport 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; },}); // Step 3: Update code to use new fieldexport 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("Task not found"); 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, }; },}); // Step 4: After backfill, update schemaexport default defineSchema({ tasks: defineTable({ title: v.string(), // priority field removed priorityLevel: v.number(), }),});``` ### Migration Runner Pattern Create a reusable migration system: ```typescript// convex/schema.tsimport { 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()), processed: v.number(), }).index("by_name", ["name"]), // Your other tables...});``` ```typescript// convex/migrations.tsimport { internalMutation, internalQuery } from "./_generated/server";import { internal } from "./_generated/api";import { v } from "convex/values"; // Check if migration has runexport const hasMigrationRun = internalQuery({ args: { name: v.string() }, returns: v.boolean(), handler: async (ctx, args) => { const migration = await ctx.db .query("migrations") .withIndex("by_name", (q) => q.eq("name", args.name)) .first(); return migration?.status === "completed"; },}); // Start a migrationexport const startMigration = internalMutation({ args: { name: v.string() }, returns: v.id("migrations"), handler: async (ctx, args) => { // Check if already exists const existing = await ctx.db .query("migrations") .withIndex("by_name", (q) => q.eq("name", args.name)) .first(); if (existing) { if (existing.status === "completed") { throw new Error(`Migration ${args.name} already completed`); } if (existing.status === "running") { throw new Error(`Migration ${args.name} already running`); } // Reset failed migration await ctx.db.patch(existing._id, { status: "running", startedAt: Date.now(), error: undefined, processed: 0, }); return existing._id; } return await ctx.db.insert("migrations", { name: args.name, startedAt: Date.now(), status: "running", processed: 0, }); },}); // Update migration progressexport const updateMigrationProgress = internalMutation({ args: { migrationId: v.id("migrations"), processed: v.number(), }, returns: v.null(), handler: async (ctx, args) => { const migration = await ctx.db.get(args.migrationId); if (!migration) return null; await ctx.db.patch(args.migrationId, { processed: migration.processed + args.processed, }); return null; },}); // Complete a migrationexport const completeMigration = internalMutation({ args: { migrationId: v.id("migrations") }, returns: v.null(), handler: async (ctx, args) => { await ctx.db.patch(args.migrationId, { status: "completed", completedAt: Date.now(), }); return null; },}); // Fail a migrationexport const failMigration = internalMutation({ args: { migrationId: v.id("migrations"), error: v.string(), }, returns: v.null(), handler: async (ctx, args) => { await ctx.db.patch(args.migrationId, { status: "failed", error: args.error, }); return null; },});``` ```typescript// convex/migrations/addUserTimestamps.tsimport { internalMutation } from "../_generated/server";import { internal } from "../_generated/api";import { v } from "convex/values"; const MIGRATION_NAME = "add_user_timestamps_v1";const BATCH_SIZE = 100; export const run = internalMutation({ args: { migrationId: v.optional(v.id("migrations")), cursor: v.optional(v.string()), }, returns: v.null(), handler: async (ctx, args) => { // Initialize migration on first run let migrationId = args.migrationId; if (!migrationId) { const hasRun = await ctx.runQuery(internal.migrations.hasMigrationRun, { name: MIGRATION_NAME, }); if (hasRun) { console.log(`Migration ${MIGRATION_NAME} already completed`); return null; } migrationId = await ctx.runMutation(internal.migrations.startMigration, { name: MIGRATION_NAME, }); } try { 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.createdAt === undefined) { await ctx.db.patch(user._id, { createdAt: user._creationTime, updatedAt: user._creationTime, }); processed++; } } // Update progress await ctx.runMutation(internal.migrations.updateMigrationProgress, { migrationId, processed, }); // Continue or complete if (!result.isDone) { await ctx.scheduler.runAfter(0, internal.migrations.addUserTimestamps.run, { migrationId, cursor: result.continueCursor, }); } else { await ctx.runMutation(internal.migrations.completeMigration, { migrationId, }); console.log(`Migration ${MIGRATION_NAME} completed`); } } catch (error) { await ctx.runMutation(internal.migrations.failMigration, { migrationId, error: String(error), }); throw error; } return null; },});``` ## Examples ### Schema with Migration Support ```typescript// convex/schema.tsimport { defineSchema, defineTable } from "convex/server";import { v } from "convex/values"; export default defineSchema({ // Migration tracking 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()), processed: v.number(), }).index("by_name", ["name"]), // Users table with evolved schema users: defineTable({ // Original fields name: v.string(), email: v.string(), // Added in migration v1 createdAt: v.optional(v.number()), updatedAt: v.optional(v.number()), // Added in migration v2 avatarUrl: v.optional(v.string()), // Added in migration v3 settings: v.optional(v.object({ theme: v.string(), notifications: v.boolean(), })), }) .index("by_email", ["email"]) .index("by_createdAt", ["createdAt"]), // Posts table with indexes for common queries posts: defineTable({ title: v.string(), content: v.string(), authorId: v.id("users"), status: v.union( v.literal("draft"), v.literal("published"), v.literal("archived") ), publishedAt: v.optional(v.number()), createdAt: v.number(), updatedAt: v.number(), }) .index("by_author", ["authorId"]) .index("by_status", ["status"]) .index("by_author_and_status", ["authorId", "status"]) .index("by_publishedAt", ["publishedAt"]),});``` ## Best Practices - Never run `npx convex deploy` unless explicitly instructed- Never run any git commands unless explicitly instructed- Always start with optional fields when adding new data- Backfill data in batches to avoid timeouts- Test migrations on development before production- Keep track of completed migrations to avoid re-running- Update code to handle both old and new data during transition- Remove deprecated fields only after all code stops using them- Use pagination for large datasets- Add appropriate indexes before running queries on new fields ## Common Pitfalls 1. **Making new fields required immediately** - Breaks existing documents2. **Not handling undefined values** - Causes runtime errors3. **Large batch sizes** - Causes function timeouts4. **Forgetting to update indexes** - Queries fail or perform poorly5. **Running migrations without tracking** - May run multiple times6. **Removing fields before code update** - Breaks existing functionality7. **Not testing on development** - Production data issues ## References - Convex Documentation: https://docs.convex.dev/- Convex LLMs.txt: https://docs.convex.dev/llms.txt- Schemas: https://docs.convex.dev/database/schemas- Database Overview: https://docs.convex.dev/database- Migration Patterns: https://stack.convex.dev/migrate-data-postgres-to-convexRelated skills
Avoid Feature Creep
Install Avoid Feature Creep skill for Claude Code from waynesutton/convexskills.
Convex
This is an umbrella skill that routes you to 11 specialized Convex development skills instead of trying to cram everything into one giant prompt. Hit `/convex-f
Convex Agents
Install Convex Agents skill for Claude Code from waynesutton/convexskills.