Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,16 +132,41 @@ export const clearField = migrations.define({
// is equivalent to `await ctx.db.patch(doc._id, { optionalField: undefined })`
```

### Runtime migration arguments

If you need to configure a migration at run time, define validated args and
consume them in `migrateOne`.

```ts
export const deleteByTag = migrations.define({
table: "events",
args: v.object({ tag: v.string() }),
migrateOne: async (ctx, doc, args) => {
if (doc.tags.includes(args.tag)) {
await ctx.db.delete(doc._id);
}
},
});
```

Then pass `args` when starting the migration:

```sh
npx convex run migrations:run '{"fn": "migrations:deleteByTag", "args": {"tag": "important"}}'
```

### Migrating a subset of a table using an index

If you only want to migrate a range of documents, you can avoid processing the
whole table by specifying a `customRange`. You can use any existing index you
have on the table, or the built-in `by_creation_time` index.
have on the table, or the built-in `by_creation_time` index. The `customRange`
callback receives `(query, args)` so you can parameterize the range using
migration args passed from the CLI or runner.

```ts
export const validateRequiredField = migrations.define({
table: "myTable",
customRange: (query) =>
customRange: (query, _args) =>
query.withIndex("by_requiredField", (q) => q.eq("requiredField", "")),
migrateOne: async (_ctx, doc) => {
console.log("Needs fixup: " + doc._id);
Expand Down
4 changes: 2 additions & 2 deletions convex.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "./node_modules/convex/schemas/convex.schema.json",
"functions": "example/convex",
"codegen": {
"legacyComponentApi": false
}
},
"$schema": "./node_modules/convex/schemas/convex.schema.json"
}
104 changes: 103 additions & 1 deletion example/convex/example.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
afterEach,
assertType,

Check warning on line 3 in example/convex/example.test.ts

View workflow job for this annotation

GitHub Actions / Test and lint

'assertType' is defined but never used. Allowed unused vars must match /^_/u
beforeEach,
describe,
expect,
test,
vi,
} from "vitest";
import { initConvexTest } from "./setup.test";
import { components, internal } from "./_generated/api";
import { runToCompletion } from "@convex-dev/migrations";
Expand Down Expand Up @@ -73,4 +81,98 @@
expect(after.every((doc) => doc.optionalField !== undefined)).toBe(true);
});
});
test("test migration with runtime args", async () => {
const t = initConvexTest();
await t.mutation(internal.example.seed, { count: 10 });
await t.run(async (ctx) => {
await runToCompletion(
ctx,
components.migrations,
internal.example.setConfiguredValue,
{ args: { value: "configured" } },
);
});
await t.run(async (ctx) => {
const after = await ctx.db.query("myTable").collect();
expect(after).toHaveLength(10);
expect(after.every((doc) => doc.optionalField === "configured")).toBe(
true,
);
});
});

test("same migration with different args runs independently", async () => {
const t = initConvexTest();
await t.mutation(internal.example.seed, { count: 10 });
// Run with first set of args
await t.run(async (ctx) => {
await runToCompletion(
ctx,
components.migrations,
internal.example.setConfiguredValue,
{ args: { value: "first" } },
);
});
await t.run(async (ctx) => {
const after = await ctx.db.query("myTable").collect();
expect(after.every((doc) => doc.optionalField === "first")).toBe(true);
});
// Run with second set of args — should NOT no-op
await t.run(async (ctx) => {
await runToCompletion(
ctx,
components.migrations,
internal.example.setConfiguredValue,
{ args: { value: "second" } },
);
});
await t.run(async (ctx) => {
const after = await ctx.db.query("myTable").collect();
expect(after.every((doc) => doc.optionalField === "second")).toBe(true);
});
});

test("runner with a series passes args to each migration", async () => {
const t = initConvexTest();
await t.mutation(internal.example.seed, { count: 10 });
// Run the series runner with args — setDefaultValue runs first, then
// setConfiguredValue should receive the args.
await t.mutation(internal.example.runSeriesWithArgs, {
args: { value: "from-series" },
});
// Process all scheduled batches until both migrations complete.
await t.finishAllScheduledFunctions(vi.runAllTimers);
await t.run(async (ctx) => {
const after = await ctx.db.query("myTable").collect();
expect(after).toHaveLength(10);
// setDefaultValue should have run (fills undefined optionalField with "default")
// then setConfiguredValue should have overwritten all to "from-series"
expect(after.every((doc) => doc.optionalField === "from-series")).toBe(
true,
);
});
});

test("args type is inferred from the migration definition", () => {
// Type-level only test: verify that args for setConfiguredValue is inferred
// as { value: string }, not `any`.
// We use a function that is never called to avoid runtime errors.
function _typeCheck(ctx: any) {
// Correct args — should type-check fine
void runToCompletion(
ctx,
components.migrations,
internal.example.setConfiguredValue,
{ args: { value: "test" } },
);

void runToCompletion(
ctx,
components.migrations,
internal.example.setConfiguredValue,
// @ts-expect-error — wrong args: `notAField` is not in { value: string }
{ args: { notAField: 123 } },
);
}
});
});
30 changes: 28 additions & 2 deletions example/convex/example.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,26 @@ export const setDefaultValue = migrations.define({
parallelize: true,
});

export const setConfiguredValue = migrations.define({
table: "myTable",
args: { value: v.string() },
migrateOne: async (_ctx, doc, args) => {
if (doc.optionalField !== args.value) {
return { optionalField: args.value };
}
},
});

export const setConfiguredValueWithHello = migrations.define({
table: "myTable",
args: { value: v.string() },
migrateOne: async (_ctx, doc, args) => {
if (doc.optionalField !== args.value) {
return { optionalField: args.value + "hello" };
}
},
});

export const clearField = migrations.define({
table: "myTable",
migrateOne: () => ({ optionalField: undefined }),
Expand All @@ -32,8 +52,8 @@ export const validateRequiredField = migrations.define({
table: "myTable",
// Specify a custom range to only include documents that need to change.
// This is useful if you have a large dataset and only a small percentage of
// documents need to be migrated.
customRange: (query) =>
// documents need to be migrated. The second argument is the migration args.
customRange: (query, _args) =>
query.withIndex("by_requiredField", (q) => q.eq("requiredField", "")),
migrateOne: async (_ctx, doc) => {
console.log("Needs fixup: " + doc._id);
Expand Down Expand Up @@ -136,3 +156,9 @@ export const migrationsWithPrefix = new Migrations(components.migrations, {

// Allows you to run `npx convex run example:runWithPrefix '{"fn":"setDefaultValue"}'`
export const runWithPrefix = migrationsWithPrefix.runner();

// A runner for a series that includes a migration with args.
export const runSeriesWithArgs = migrations.runner([
internal.example.setConfiguredValue,
internal.example.setConfiguredValueWithHello,
]);
6 changes: 6 additions & 0 deletions example/convex/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@convex-dev/migrations": ["../../src/client/index.ts"]
}
},
"include": ["."],
"exclude": ["_generated"]
}
Loading
Loading