Bright ideas and techniques for building with Convex.
Profile image
Ian Macartney
2 months ago

Lightweight Zero-Downtime Migrations

Bulk edit documents with a patch to apply

Want to make a small change to every document in your database? For instance, setting some new field to some value, without breaking your app for users for "scheduled maintenance"? You'll need to run a migration. In this post we'll look at how to apply lightweight changes to all of your documents without downtime, and without writing and deploying code to run lightweight migrations.

What are migrations?

Migrations are the process to change data in a database from one format to another. Read here for an overview of migrations.

Online migrations recap

If your app has a ton of data, it becomes infeasible to run this migration atomically in one transaction. In order to stay "online" while changing data, you need to write code that handles the old and new format of data temporarily. For example, to add a new field you can add an "optional" field to your schema, and update your code to handle it being set or unset. In this post we discussed how to use mutations to paginate over your data, applying some transformation.

This can be tedious if you're trying to quickly iterate on your development instance. Can we do this without deploying mutations?

Using bulk edit on the dashboard to patch documents

The Convex dashboard allows you to bulk edit documents.1 You can manually select certain documents, or you can click the checkbox above all documents to select all documents. When you bulk edit, you are given a JSON editor for what "patch" to apply to each document in the database. For example, you can apply this patch:

{
  // Enter object fields here
  newField: ["some", "value", 123],
  fieldToUpdate: true,
  fieldToRemove: undefined
}

Let's say there's these documents already in the database:

{ fieldToUpdate: false, fieldToRemove: "foo", someField: 123 }
{ fieldToUpdate: true, fieldToRemove: "bar", someField: 456 }

Applying this patch will update the documents to:

{ fieldToUpdate: true, newField: ["some", "value", 123], someField: 123 }
{ fieldToUpdate: true, newField: ["some", "value", 123], someField: 456 }

Note that someField was not affected, since it was not specified in the patch.

Race conditions? Not a problem

Thanks to Convex's transactions, featuring serializable isolation, mean that if another mutation updates one of these documents at the same time as this update is being applied, one of the changes will be applied first, and the other mutation will be retried. This means you don't have to worry about some change being lost along the way.

You are still responsible for your code handling the case where newField hasn't been set yet, or fieldToUpdate is still false. However, you'll never see a document where newField is set and fieldToUpdate hasn't been set to true yet.

If your data can't all be updated in one transaction (if you have many thousands of documents), read this post for how to break up the change into a paginated mutation, and for best practices around dual-write vs. dual-read strategies while changing schemas. In these cases, you'll be responsible for handling the case where you fetch some documents that have been updated, and some that haven't yet been updated, so some documents might have the new field and some may not.

Invalid schema? Not a problem

If you try to apply the above patch but it doesn't match your defined schema (and you haven't turned schema validation off), the change will fail to apply to avoid writing invalid data. Here are some examples of schemas that would be invalid with the above patch:

newField is missing

// in convex/schema.ts
export default defineSchema({
  myTable: defineTable({
	  // newField needs to be specified to be set
		fieldToUpdate: v.boolean(),
		fieldToRemove: v.optional(v.string()),
	  someField: v.number(),
	});
});

newField isn't optional

// in convex/schema.ts
export default defineSchema({
  myTable: defineTable({
	  // newField needs to be optional when it's added to the schema
		// since none of the documents have the value set yet.
	  newField: v.array(v.union(v.string(), v.number())),
		fieldToUpdate: v.boolean(),
		fieldToRemove: v.optional(v.string()),
	  someField: v.number(),
	});
});

newField is the wrong type

// in convex/schema.ts
export default defineSchema({
  myTable: defineTable({
	  // newField doesn't allow numbers in the array.
		// The inserted value needs to match the schema definition.
	  newField: v.optional(v.array(v.string()))),
		fieldToUpdate: v.boolean(),
		fieldToRemove: v.optional(v.string()),
	  someField: v.number(),
	});
});

fieldToRemove isn't optional

// in convex/schema.ts
export default defineSchema({
  myTable: defineTable({
	  newField: v.optional(v.array(v.union(v.string(), v.number()))),
		fieldToUpdate: v.boolean(),
		fieldToRemove: v.optional(v.string()),
	  someField: v.number(),
	});
});

Precautions

I would recommend using this functionality primarily on your "Dev" instance for a few reasons:

  1. Accidental changes leading to data loss. If you accidentally set "email" to "" on all of your documents in production, you have just lost all of your email user data. The dashboard will warn you before applying changes to production to help reduce the chances of accidentally changing production data, but it won't prevent you from making the changes if you bypass the warning.
  2. Documenting changes. By committing the code to source control and deploying the code that you're running, you'll have a clear history of what changes you made to your data. Others can use this code to update other instances, such as any staging or other production-adjacent deployments.
  3. Testing changes. By using a migration helper you can print out intermediate status, and run a "dry run" first to test that you're making the expected changes (by throwing an exception in a mutation, the changes will be rolled back and won't apply).

Complicated migrations?

If you need to do more than set literal values - for instance if you're changing from having an pendingInvite: v.boolean() field to status: v.union(v.literal("active"), v.literal("invited")), you'll want to write code. Check out migration helper about writing migration helpers like:

export const updateActiveToStatus = migration({
  table: "users",
	migrateDoc: async (ctx, user) => {
	  await ctx.db.patch(user._id, {
		  status: user.pendingInvite ? "invited" : "active",
		  active: pendingInvite,
	  });
	},
});

Summary

You can patch all of your data in your database table with the bulk edit feature on the Convex dashboard, without writing migration code. If you're interested in more dashboard features, check out the docs or this post.

Footnotes

  1. It is currently limited to making changes in a single transaction, so if you have 8,192 documents or more, you'll want to follow the advice of this post. We may allow the dashboard to patch all data in batches soon.

Build in minutes, scale forever.

Convex is the backend application platform with everything you need to build your project. Cloud functions, a database, file storage, scheduling, search, and realtime updates fit together seamlessly.

Get started