Files
admin b723e2bd7d Reorganize: Move all skills to skills/ folder
- Created skills/ directory
- Moved 272 skills to skills/ subfolder
- Kept agents/ at root level
- Kept installation scripts and docs at root level

Repository structure:
- skills/           - All 272 skills from skills.sh
- agents/           - Agent definitions
- *.sh, *.ps1       - Installation scripts
- README.md, etc.   - Documentation

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 18:05:17 +00:00

712 lines
18 KiB
Markdown

---
name: Convex Migrations
description: Schema migration strategies for evolving applications including adding new fields, backfilling data, removing deprecated fields, index migrations, and zero-downtime migration patterns
version: 1.0.0
author: Convex
tags: [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.ts
import { 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.ts
import { 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.ts
import { 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.ts
export 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.ts
export 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.ts
export 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.ts
export 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 fallback
export 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 data
export 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 field
export 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.ts
export 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 queries
export 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.ts
export 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 conversion
export 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 field
export 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 schema
export default defineSchema({
tasks: defineTable({
title: v.string(),
// priority field removed
priorityLevel: v.number(),
}),
});
```
### Migration Runner Pattern
Create a reusable migration system:
```typescript
// convex/schema.ts
import { 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.ts
import { internalMutation, internalQuery } from "./_generated/server";
import { internal } from "./_generated/api";
import { v } from "convex/values";
// Check if migration has run
export 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 migration
export 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 progress
export 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 migration
export 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 migration
export 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.ts
import { 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.ts
import { 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 documents
2. **Not handling undefined values** - Causes runtime errors
3. **Large batch sizes** - Causes function timeouts
4. **Forgetting to update indexes** - Queries fail or perform poorly
5. **Running migrations without tracking** - May run multiple times
6. **Removing fields before code update** - Breaks existing functionality
7. **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-convex