From 756d4fd418a3e5d6678a4e493474b262c968cce4 Mon Sep 17 00:00:00 2001 From: Michal Srb Date: Wed, 11 Mar 2026 16:58:25 +0100 Subject: [PATCH 1/9] Revert vitest aliases and use local imports in example tests --- README.md | 23 +++++++++++++++++++++++ example/convex/example.test.ts | 21 ++++++++++++++++++++- example/convex/example.ts | 12 +++++++++++- example/convex/setup.test.ts | 2 +- src/client/index.ts | 26 ++++++++++++++++++++++++-- src/component/lib.ts | 2 ++ src/shared.ts | 1 + 7 files changed, 82 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 38b0a9c..1603579 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,29 @@ 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 deleteBeforeDate = migrations.define({ + table: "events", + args: v.object({ beforeTs: v.number() }), + migrateOne: async (ctx, doc, args) => { + if (doc.createdAt < args.beforeTs) { + await ctx.db.delete(doc._id); + } + }, +}); +``` + +Then pass `args` when starting the migration: + +```sh +npx convex run migrations:run '{"fn": "migrations:deleteBeforeDate", "args": {"beforeTs": 1735689600000}}' +``` + ### Migrating a subset of a table using an index If you only want to migrate a range of documents, you can avoid processing the diff --git a/example/convex/example.test.ts b/example/convex/example.test.ts index 45da5c8..3bd2635 100644 --- a/example/convex/example.test.ts +++ b/example/convex/example.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { initConvexTest } from "./setup.test"; import { components, internal } from "./_generated/api"; -import { runToCompletion } from "@convex-dev/migrations"; +import { runToCompletion } from "../../src/client/index.ts"; import { createFunctionHandle, getFunctionName } from "convex/server"; describe("example", () => { @@ -73,4 +73,23 @@ describe("example", () => { 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, + ); + }); + }); }); diff --git a/example/convex/example.ts b/example/convex/example.ts index 040b76b..3c0cab8 100644 --- a/example/convex/example.ts +++ b/example/convex/example.ts @@ -1,4 +1,4 @@ -import { Migrations, type MigrationStatus } from "@convex-dev/migrations"; +import { Migrations, type MigrationStatus } from "../../src/client/index.ts"; import { v } from "convex/values"; import { components, internal } from "./_generated/api.js"; import type { DataModel } from "./_generated/dataModel.js"; @@ -23,6 +23,16 @@ export const setDefaultValue = migrations.define({ parallelize: true, }); +export const setConfiguredValue = migrations.define({ + table: "myTable", + args: v.object({ value: v.string() }), + migrateOne: async (_ctx, doc, args) => { + if (doc.optionalField !== args.value) { + return { optionalField: args.value }; + } + }, +}); + export const clearField = migrations.define({ table: "myTable", migrateOne: () => ({ optionalField: undefined }), diff --git a/example/convex/setup.test.ts b/example/convex/setup.test.ts index 9a982e7..d1174ab 100644 --- a/example/convex/setup.test.ts +++ b/example/convex/setup.test.ts @@ -2,7 +2,7 @@ import { test } from "vitest"; import { convexTest } from "convex-test"; import schema from "./schema.js"; -import component from "@convex-dev/migrations/test"; +import component from "../../src/test.ts"; const modules = import.meta.glob("./**/*.*s"); // When users want to write tests that use your component, they need to diff --git a/src/client/index.ts b/src/client/index.ts index 3d1accc..ee31d54 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -25,7 +25,14 @@ import { } from "../shared.js"; export type { MigrationArgs, MigrationResult, MigrationStatus }; -import { ConvexError, type GenericId } from "convex/values"; +import { + ConvexError, + type GenericId, + type GenericValidator, + type PropertyValidators, + type Validator, + v, +} from "convex/values"; import type { ComponentApi } from "../component/_generated/component.js"; import { logStatusAndInstructions } from "./log.js"; import type { MigrationFunctionHandle } from "../component/lib.js"; @@ -197,6 +204,7 @@ export class Migrations { batchSize: args.batchSize, next, dryRun: args.dryRun ?? false, + args: args.args, }); } catch (e) { if ( @@ -266,11 +274,13 @@ export class Migrations { customRange, batchSize: functionDefaultBatchSize, parallelize, + args: defineArgs, }: { table: TableName; migrateOne: ( ctx: GenericMutationCtx, doc: DocumentByName & { _id: GenericId }, + args: any, ) => | void | Partial> @@ -280,6 +290,7 @@ export class Migrations { ) => OrderedQuery>; batchSize?: number; parallelize?: boolean; + args?: GenericValidator | PropertyValidators; }) { const defaultBatchSize = functionDefaultBatchSize ?? @@ -293,7 +304,12 @@ export class Migrations { "internal" >) ?? (internalMutationGeneric as MutationBuilder) )({ - args: migrationArgs, + args: { + ...migrationArgs, + args: v.optional( + (defineArgs ?? v.any()) as Validator, + ), + }, handler: async (ctx, args) => { if (args.fn) { // This is a one-off execution from the CLI or dashboard. @@ -363,6 +379,7 @@ export class Migrations { const next = await migrateOne( ctx, doc as { _id: GenericId }, + args.args as any, ); if (next && Object.keys(next).length > 0) { await ctx.db.patch(doc._id as GenericId, next); @@ -471,6 +488,7 @@ export class Migrations { cursor?: string | null; batchSize?: number; dryRun?: boolean; + args?: any; }, ) { return ctx.runMutation(this.component.lib.migrate, { @@ -479,6 +497,7 @@ export class Migrations { cursor: opts?.cursor, batchSize: opts?.batchSize, dryRun: opts?.dryRun ?? false, + args: opts?.args, }); } @@ -661,6 +680,7 @@ export async function runToCompletion( * It's helpful to see what it would do without committing the transaction. */ dryRun?: boolean; + args?: any; }, ): Promise { let cursor = opts?.cursor; @@ -668,6 +688,7 @@ export async function runToCompletion( name = getFunctionName(fnRef), batchSize, dryRun = false, + args, } = opts ?? {}; const address = getFunctionAddress(fnRef); const fnHandle = @@ -679,6 +700,7 @@ export async function runToCompletion( cursor, batchSize, dryRun, + args, oneBatchOnly: true, }); if (status.isDone) { diff --git a/src/component/lib.ts b/src/component/lib.ts index c987300..32ec3af 100644 --- a/src/component/lib.ts +++ b/src/component/lib.ts @@ -37,6 +37,7 @@ const runMigrationArgs = { ), ), dryRun: v.boolean(), + args: v.optional(v.any()), }; export const migrate = mutation({ @@ -116,6 +117,7 @@ export const migrate = mutation({ cursor: state.cursor, batchSize, dryRun, + args: args.args, }, ); updateState(result); diff --git a/src/shared.ts b/src/shared.ts index 75b8dd7..82f2f83 100644 --- a/src/shared.ts +++ b/src/shared.ts @@ -6,6 +6,7 @@ export const migrationArgs = { batchSize: v.optional(v.number()), dryRun: v.optional(v.boolean()), next: v.optional(v.array(v.string())), + args: v.optional(v.any()), }; export type MigrationArgs = ObjectType; From cc71747d4516a0a654951683bec0ab31bb76366c Mon Sep 17 00:00:00 2001 From: xixixao Date: Wed, 11 Mar 2026 17:37:04 +0100 Subject: [PATCH 2/9] Trying to add support for args to custom range --- README.md | 14 ++++++++------ example/convex/example.test.ts | 2 +- example/convex/example.ts | 6 +++--- example/convex/setup.test.ts | 2 +- example/convex/tsconfig.json | 6 ++++++ src/client/index.ts | 3 ++- tsconfig.test.json | 6 ++++++ vitest.config.ts | 6 ++++++ 8 files changed, 33 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 1603579..ea5def8 100644 --- a/README.md +++ b/README.md @@ -138,11 +138,11 @@ If you need to configure a migration at run time, define validated args and consume them in `migrateOne`. ```ts -export const deleteBeforeDate = migrations.define({ +export const deleteByTag = migrations.define({ table: "events", - args: v.object({ beforeTs: v.number() }), + args: v.object({ tag: v.string() }), migrateOne: async (ctx, doc, args) => { - if (doc.createdAt < args.beforeTs) { + if (doc.tags.includes(args.tag)) { await ctx.db.delete(doc._id); } }, @@ -152,19 +152,21 @@ export const deleteBeforeDate = migrations.define({ Then pass `args` when starting the migration: ```sh -npx convex run migrations:run '{"fn": "migrations:deleteBeforeDate", "args": {"beforeTs": 1735689600000}}' +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); diff --git a/example/convex/example.test.ts b/example/convex/example.test.ts index 3bd2635..5676356 100644 --- a/example/convex/example.test.ts +++ b/example/convex/example.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { initConvexTest } from "./setup.test"; import { components, internal } from "./_generated/api"; -import { runToCompletion } from "../../src/client/index.ts"; +import { runToCompletion } from "@convex-dev/migrations"; import { createFunctionHandle, getFunctionName } from "convex/server"; describe("example", () => { diff --git a/example/convex/example.ts b/example/convex/example.ts index 3c0cab8..bd7a523 100644 --- a/example/convex/example.ts +++ b/example/convex/example.ts @@ -1,4 +1,4 @@ -import { Migrations, type MigrationStatus } from "../../src/client/index.ts"; +import { Migrations, type MigrationStatus } from "@convex-dev/migrations"; import { v } from "convex/values"; import { components, internal } from "./_generated/api.js"; import type { DataModel } from "./_generated/dataModel.js"; @@ -42,8 +42,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); diff --git a/example/convex/setup.test.ts b/example/convex/setup.test.ts index d1174ab..9a982e7 100644 --- a/example/convex/setup.test.ts +++ b/example/convex/setup.test.ts @@ -2,7 +2,7 @@ import { test } from "vitest"; import { convexTest } from "convex-test"; import schema from "./schema.js"; -import component from "../../src/test.ts"; +import component from "@convex-dev/migrations/test"; const modules = import.meta.glob("./**/*.*s"); // When users want to write tests that use your component, they need to diff --git a/example/convex/tsconfig.json b/example/convex/tsconfig.json index 891d309..830b19b 100644 --- a/example/convex/tsconfig.json +++ b/example/convex/tsconfig.json @@ -1,5 +1,11 @@ { "extends": "../tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@convex-dev/migrations": ["../../src/client/index.ts"] + } + }, "include": ["."], "exclude": ["_generated"] } diff --git a/src/client/index.ts b/src/client/index.ts index ee31d54..0219d69 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -287,6 +287,7 @@ export class Migrations { | Promise> | void>; customRange?: ( q: QueryInitializer>, + args: any, ) => OrderedQuery>; batchSize?: number; parallelize?: boolean; @@ -353,7 +354,7 @@ export class Migrations { } const q = ctx.db.query(table); - const range = customRange ? customRange(q) : q; + const range = customRange ? customRange(q, args.args) : q; let continueCursor: string; let page: DocumentByName[]; let isDone: boolean; diff --git a/tsconfig.test.json b/tsconfig.test.json index aff5230..326a56c 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -1,5 +1,11 @@ { "extends": "./tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@convex-dev/migrations": ["./src/client/index.ts"] + } + }, "include": [ "src/**/*.ts", "src/**/*.tsx", diff --git a/vitest.config.ts b/vitest.config.ts index e9408f8..a211552 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,6 +1,12 @@ +import { resolve } from "path"; import { defineConfig } from "vitest/config"; export default defineConfig({ + resolve: { + alias: { + "@convex-dev/migrations": resolve(__dirname, "src/client/index.ts"), + }, + }, test: { environment: "edge-runtime", typecheck: { From 50ed2681c8a42b43f66de3b61c69ff463028f34b Mon Sep 17 00:00:00 2001 From: xixixao Date: Wed, 11 Mar 2026 18:32:42 +0100 Subject: [PATCH 3/9] Built --- convex.json | 4 ++-- src/component/_generated/component.ts | 1 + vitest.config.ts | 8 +++++++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/convex.json b/convex.json index 8cd4cfd..350a498 100644 --- a/convex.json +++ b/convex.json @@ -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" } diff --git a/src/component/_generated/component.ts b/src/component/_generated/component.ts index 9d92acc..110d072 100644 --- a/src/component/_generated/component.ts +++ b/src/component/_generated/component.ts @@ -89,6 +89,7 @@ export type ComponentApi = "mutation", "internal", { + args?: any; batchSize?: number; cursor?: string | null; dryRun: boolean; diff --git a/vitest.config.ts b/vitest.config.ts index a211552..8dd9ca1 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,10 +1,16 @@ import { resolve } from "path"; import { defineConfig } from "vitest/config"; +// This sucks but import.meta.url error in TypeScript without a way to fix it +// and __dirname is not available in ESM. This relies on where the tests are run from. +const root = resolve(process.cwd()); + export default defineConfig({ resolve: { alias: { - "@convex-dev/migrations": resolve(__dirname, "src/client/index.ts"), + // More specific alias first so "@convex-dev/migrations/test" matches correctly + "@convex-dev/migrations/test": resolve(root, "src/test.ts"), + "@convex-dev/migrations": resolve(root, "src/client/index.ts"), }, }, test: { From 00d09293b44ef9fea8918c10a4bfc19075ce208b Mon Sep 17 00:00:00 2001 From: xixixao Date: Wed, 11 Mar 2026 21:55:34 +0100 Subject: [PATCH 4/9] Fix state and allow running multiple --- example/convex/example.test.ts | 31 +++++++++++++++++++++++++++++++ src/client/index.ts | 21 ++++++++++++++++++--- src/component/lib.ts | 1 + src/component/schema.ts | 2 ++ 4 files changed, 52 insertions(+), 3 deletions(-) diff --git a/example/convex/example.test.ts b/example/convex/example.test.ts index 5676356..e91250a 100644 --- a/example/convex/example.test.ts +++ b/example/convex/example.test.ts @@ -92,4 +92,35 @@ describe("example", () => { ); }); }); + + 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); + }); + }); }); diff --git a/src/client/index.ts b/src/client/index.ts index 0219d69..68b0bc6 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -40,6 +40,16 @@ import type { MigrationFunctionHandle } from "../component/lib.js"; // Note: this value is hard-coded in the docstring below. Please keep in sync. export const DEFAULT_BATCH_SIZE = 100; +/** + * When args are provided, append a deterministic serialization to the migration + * name so that each unique set of args is tracked as a separate migration run. + */ +function migrationNameWithArgs(baseName: string, args?: unknown): string { + if (args === undefined || args === null) return baseName; + const serialized = JSON.stringify(args, Object.keys(args as any).sort()); + return `${baseName}(${serialized})`; +} + export class Migrations { /** * Makes the migration wrapper, with types for your own tables. @@ -168,7 +178,10 @@ export class Migrations { fnRef?: MigrationFunctionReference, next?: { name: string; fnHandle: string }[], ) { - const name = args.fn ? this.prefixedName(args.fn) : getFunctionName(fnRef!); + const baseName = args.fn + ? this.prefixedName(args.fn) + : getFunctionName(fnRef!); + const name = migrationNameWithArgs(baseName, args.args); async function makeFn(fn: string) { try { return await createFunctionHandle( @@ -492,8 +505,9 @@ export class Migrations { args?: any; }, ) { + const baseName = getFunctionName(fnRef); return ctx.runMutation(this.component.lib.migrate, { - name: getFunctionName(fnRef), + name: migrationNameWithArgs(baseName, opts?.args), fnHandle: await createFunctionHandle(fnRef), cursor: opts?.cursor, batchSize: opts?.batchSize, @@ -686,11 +700,12 @@ export async function runToCompletion( ): Promise { let cursor = opts?.cursor; const { - name = getFunctionName(fnRef), + name: nameOverride, batchSize, dryRun = false, args, } = opts ?? {}; + const name = nameOverride ?? migrationNameWithArgs(getFunctionName(fnRef), args); const address = getFunctionAddress(fnRef); const fnHandle = address.functionHandle ?? (await createFunctionHandle(fnRef)); diff --git a/src/component/lib.ts b/src/component/lib.ts index 32ec3af..0dd817d 100644 --- a/src/component/lib.ts +++ b/src/component/lib.ts @@ -69,6 +69,7 @@ export const migrate = mutation({ isDone: false, processed: 0, latestStart: Date.now(), + args: args.args, }), ))!; diff --git a/src/component/schema.ts b/src/component/schema.ts index f3ec0eb..6d67728 100644 --- a/src/component/schema.ts +++ b/src/component/schema.ts @@ -12,6 +12,8 @@ export default defineSchema({ processed: v.number(), latestStart: v.number(), latestEnd: v.optional(v.number()), + // Runtime args passed to the migration. + args: v.optional(v.any()), }) .index("name", ["name"]) .index("isDone", ["isDone"]), From 2d462e4ebec84f39350b0653f450c2b7e78689c5 Mon Sep 17 00:00:00 2001 From: xixixao Date: Wed, 11 Mar 2026 22:11:50 +0100 Subject: [PATCH 5/9] Make the args type inferred --- src/client/index.ts | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/src/client/index.ts b/src/client/index.ts index 68b0bc6..4730ac4 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -29,6 +29,8 @@ import { ConvexError, type GenericId, type GenericValidator, + type Infer, + type ObjectType, type PropertyValidators, type Validator, v, @@ -40,6 +42,18 @@ import type { MigrationFunctionHandle } from "../component/lib.js"; // Note: this value is hard-coded in the docstring below. Please keep in sync. export const DEFAULT_BATCH_SIZE = 100; +/** + * Infer the TypeScript type from an args validator definition. + * - If a single Validator (e.g. `v.object({...})`), use `Infer`. + * - If PropertyValidators (e.g. `{ tag: v.string() }`), use `ObjectType`. + * - If void/undefined (no args defined), the args type is `undefined`. + */ +type InferMigrationArgs = T extends Validator + ? Infer + : T extends PropertyValidators + ? ObjectType + : undefined; + /** * When args are provided, append a deterministic serialization to the migration * name so that each unique set of args is tracked as a separate migration run. @@ -281,7 +295,10 @@ export class Migrations { * @param parallelize - If true, each migration batch will be run in parallel. * @returns An internal mutation that runs the migration. */ - define>({ + define< + TableName extends TableNamesInDataModel, + ArgsValidator extends GenericValidator | PropertyValidators | void = void, + >({ table, migrateOne, customRange, @@ -293,18 +310,18 @@ export class Migrations { migrateOne: ( ctx: GenericMutationCtx, doc: DocumentByName & { _id: GenericId }, - args: any, + args: InferMigrationArgs, ) => | void | Partial> | Promise> | void>; customRange?: ( q: QueryInitializer>, - args: any, + args: InferMigrationArgs, ) => OrderedQuery>; batchSize?: number; parallelize?: boolean; - args?: GenericValidator | PropertyValidators; + args?: ArgsValidator; }) { const defaultBatchSize = functionDefaultBatchSize ?? From 998e8a022c9f8a0cb1b572f01ff0639c23c9b986 Mon Sep 17 00:00:00 2001 From: xixixao Date: Wed, 11 Mar 2026 22:51:17 +0100 Subject: [PATCH 6/9] Typing of runOne with args and fixed runtime implementation for define --- example/convex/example.test.ts | 34 +++++++++++++++- src/client/index.ts | 72 +++++++++++++++++++++------------- 2 files changed, 78 insertions(+), 28 deletions(-) diff --git a/example/convex/example.test.ts b/example/convex/example.test.ts index e91250a..7fd93d9 100644 --- a/example/convex/example.test.ts +++ b/example/convex/example.test.ts @@ -1,4 +1,12 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + afterEach, + assertType, + beforeEach, + describe, + expect, + test, + vi, +} from "vitest"; import { initConvexTest } from "./setup.test"; import { components, internal } from "./_generated/api"; import { runToCompletion } from "@convex-dev/migrations"; @@ -123,4 +131,28 @@ describe("example", () => { expect(after.every((doc) => doc.optionalField === "second")).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" } }, + ); + + // @ts-expect-error — wrong args: `notAField` is not in { value: string } + void runToCompletion( + ctx, + components.migrations, + internal.example.setConfiguredValue, + { args: { notAField: 123 } }, + ); + } + _typeCheck; + }); }); diff --git a/src/client/index.ts b/src/client/index.ts index 4730ac4..9ab775d 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -28,16 +28,13 @@ export type { MigrationArgs, MigrationResult, MigrationStatus }; import { ConvexError, type GenericId, - type GenericValidator, - type Infer, type ObjectType, type PropertyValidators, - type Validator, v, } from "convex/values"; import type { ComponentApi } from "../component/_generated/component.js"; -import { logStatusAndInstructions } from "./log.js"; import type { MigrationFunctionHandle } from "../component/lib.js"; +import { logStatusAndInstructions } from "./log.js"; // Note: this value is hard-coded in the docstring below. Please keep in sync. export const DEFAULT_BATCH_SIZE = 100; @@ -48,11 +45,27 @@ export const DEFAULT_BATCH_SIZE = 100; * - If PropertyValidators (e.g. `{ tag: v.string() }`), use `ObjectType`. * - If void/undefined (no args defined), the args type is `undefined`. */ -type InferMigrationArgs = T extends Validator - ? Infer - : T extends PropertyValidators - ? ObjectType - : undefined; +type InferMigrationArgs = T extends PropertyValidators + ? ObjectType + : undefined; + +/** + * MigrationArgs with the `args` field typed to a specific type. + */ +type MigrationArgsWithTypedArgs = Omit & { + args?: Args; +}; + +/** + * Extract the migration-specific args type from a function reference. + * Falls back to `any` for untyped migration references. + */ +type ExtractMigrationArgs = + Ref extends FunctionReference + ? A extends { args?: infer MArgs } + ? MArgs + : undefined + : any; /** * When args are provided, append a deterministic serialization to the migration @@ -297,7 +310,7 @@ export class Migrations { */ define< TableName extends TableNamesInDataModel, - ArgsValidator extends GenericValidator | PropertyValidators | void = void, + ArgsValidator extends PropertyValidators | void = void, >({ table, migrateOne, @@ -337,9 +350,10 @@ export class Migrations { )({ args: { ...migrationArgs, - args: v.optional( - (defineArgs ?? v.any()) as Validator, - ), + args: + defineArgs == null || Object.keys(defineArgs).length === 0 + ? v.optional(v.object({})) + : v.object(defineArgs), }, handler: async (ctx, args) => { if (args.fn) { @@ -384,7 +398,9 @@ export class Migrations { } const q = ctx.db.query(table); - const range = customRange ? customRange(q, args.args) : q; + const range = customRange + ? customRange(q, args.args as InferMigrationArgs) + : q; let continueCursor: string; let page: DocumentByName[]; let isDone: boolean; @@ -476,6 +492,10 @@ export class Migrations { "internal", MigrationArgs, Promise + > as RegisteredMutation< + "internal", + MigrationArgsWithTypedArgs>, + Promise >; } @@ -512,14 +532,14 @@ export class Migrations { * @param opts.dryRun If true, it will run a batch and then throw an error. * It's helpful to see what it would do without committing the transaction. */ - async runOne( + async runOne( ctx: MutationCtx | ActionCtx, - fnRef: MigrationFunctionReference, + fnRef: Ref, opts?: { cursor?: string | null; batchSize?: number; dryRun?: boolean; - args?: any; + args?: ExtractMigrationArgs; }, ) { const baseName = getFunctionName(fnRef); @@ -687,10 +707,12 @@ export type MigrationFunctionReference = FunctionReference< * @param opts Options to start the migration. * It's helpful to see what it would do without committing the transaction. */ -export async function runToCompletion( +export async function runToCompletion< + Ref extends MigrationFunctionReference | MigrationFunctionHandle, +>( ctx: ActionCtx, component: ComponentApi, - fnRef: MigrationFunctionReference | MigrationFunctionHandle, + fnRef: Ref, opts?: { /** * The name of the migration function, generated with getFunctionName. @@ -712,17 +734,13 @@ export async function runToCompletion( * It's helpful to see what it would do without committing the transaction. */ dryRun?: boolean; - args?: any; + args?: ExtractMigrationArgs; }, ): Promise { let cursor = opts?.cursor; - const { - name: nameOverride, - batchSize, - dryRun = false, - args, - } = opts ?? {}; - const name = nameOverride ?? migrationNameWithArgs(getFunctionName(fnRef), args); + const { name: nameOverride, batchSize, dryRun = false, args } = opts ?? {}; + const name = + nameOverride ?? migrationNameWithArgs(getFunctionName(fnRef), args); const address = getFunctionAddress(fnRef); const fnHandle = address.functionHandle ?? (await createFunctionHandle(fnRef)); From c02ae5aa0a09b67ea602fe3f76e25f0b1d8ea749 Mon Sep 17 00:00:00 2001 From: xixixao Date: Wed, 11 Mar 2026 23:24:05 +0100 Subject: [PATCH 7/9] Support args in runner(array) --- example/convex/example.test.ts | 3 +-- example/convex/example.ts | 2 +- src/client/index.ts | 1 + src/component/_generated/component.ts | 2 +- src/component/lib.ts | 2 ++ 5 files changed, 6 insertions(+), 4 deletions(-) diff --git a/example/convex/example.test.ts b/example/convex/example.test.ts index 7fd93d9..5e095d8 100644 --- a/example/convex/example.test.ts +++ b/example/convex/example.test.ts @@ -145,14 +145,13 @@ describe("example", () => { { args: { value: "test" } }, ); - // @ts-expect-error — wrong args: `notAField` is not in { value: string } void runToCompletion( ctx, components.migrations, internal.example.setConfiguredValue, + // @ts-expect-error — wrong args: `notAField` is not in { value: string } { args: { notAField: 123 } }, ); } - _typeCheck; }); }); diff --git a/example/convex/example.ts b/example/convex/example.ts index bd7a523..f15ca94 100644 --- a/example/convex/example.ts +++ b/example/convex/example.ts @@ -25,7 +25,7 @@ export const setDefaultValue = migrations.define({ export const setConfiguredValue = migrations.define({ table: "myTable", - args: v.object({ value: v.string() }), + args: { value: v.string() }, migrateOne: async (_ctx, doc, args) => { if (doc.optionalField !== args.value) { return { optionalField: args.value }; diff --git a/src/client/index.ts b/src/client/index.ts index 9ab775d..a060f33 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -180,6 +180,7 @@ export class Migrations { specificMigrationOrSeries.slice(1).map(async (fnRef) => ({ name: getFunctionName(fnRef), fnHandle: await createFunctionHandle(fnRef), + args: args.args, })), ), ] diff --git a/src/component/_generated/component.ts b/src/component/_generated/component.ts index 110d072..9630202 100644 --- a/src/component/_generated/component.ts +++ b/src/component/_generated/component.ts @@ -95,7 +95,7 @@ export type ComponentApi = dryRun: boolean; fnHandle: string; name: string; - next?: Array<{ fnHandle: string; name: string }>; + next?: Array<{ args?: any; fnHandle: string; name: string }>; oneBatchOnly?: boolean; }, { diff --git a/src/component/lib.ts b/src/component/lib.ts index 0dd817d..13a88bd 100644 --- a/src/component/lib.ts +++ b/src/component/lib.ts @@ -33,6 +33,7 @@ const runMigrationArgs = { v.object({ name: v.string(), fnHandle: v.string(), + args: v.optional(v.any()), }), ), ), @@ -151,6 +152,7 @@ export const migrate = mutation({ await ctx.scheduler.runAfter(0, api.lib.migrate, { name: nextFn.name, fnHandle: nextFn.fnHandle, + args: nextFn.args, next: rest, batchSize, dryRun, From 0d3849c3fa3c1f1647e4b2cc9e66cf18918e1c60 Mon Sep 17 00:00:00 2001 From: xixixao Date: Wed, 11 Mar 2026 23:35:02 +0100 Subject: [PATCH 8/9] Fix running multiple migrations with args --- src/client/index.ts | 15 ++++----------- src/component/lib.ts | 17 ++++++++++++++++- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/client/index.ts b/src/client/index.ts index a060f33..a6f2dd1 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -33,7 +33,10 @@ import { v, } from "convex/values"; import type { ComponentApi } from "../component/_generated/component.js"; -import type { MigrationFunctionHandle } from "../component/lib.js"; +import { + migrationNameWithArgs, + type MigrationFunctionHandle, +} from "../component/lib.js"; import { logStatusAndInstructions } from "./log.js"; // Note: this value is hard-coded in the docstring below. Please keep in sync. @@ -67,16 +70,6 @@ type ExtractMigrationArgs = : undefined : any; -/** - * When args are provided, append a deterministic serialization to the migration - * name so that each unique set of args is tracked as a separate migration run. - */ -function migrationNameWithArgs(baseName: string, args?: unknown): string { - if (args === undefined || args === null) return baseName; - const serialized = JSON.stringify(args, Object.keys(args as any).sort()); - return `${baseName}(${serialized})`; -} - export class Migrations { /** * Makes the migration wrapper, with types for your own tables. diff --git a/src/component/lib.ts b/src/component/lib.ts index 13a88bd..16f90dc 100644 --- a/src/component/lib.ts +++ b/src/component/lib.ts @@ -144,7 +144,9 @@ export const migrate = mutation({ for (; i < next.length; i++) { const doc = await ctx.db .query("migrations") - .withIndex("name", (q) => q.eq("name", next[i]!.name)) + .withIndex("name", (q) => + q.eq("name", migrationNameWithArgs(next[i]!.name, next[i]!.args)), + ) .unique(); if (!doc || !doc.isDone) { const [nextFn, ...rest] = next.slice(i); @@ -356,3 +358,16 @@ export const clearAll = mutation({ } }, }); + +/** + * When args are provided, append a deterministic serialization to the migration + * name so that each unique set of args is tracked as a separate migration run. + */ +export function migrationNameWithArgs( + baseName: string, + args?: unknown, +): string { + if (args === undefined || args === null) return baseName; + const serialized = JSON.stringify(args, Object.keys(args as any).sort()); + return `${baseName}(${serialized})`; +} From d442bf63d9c3dc67c9aa61686de7b525a46e2b96 Mon Sep 17 00:00:00 2001 From: xixixao Date: Wed, 11 Mar 2026 23:50:50 +0100 Subject: [PATCH 9/9] Fix and test series with args --- example/convex/example.test.ts | 21 +++++++++++++++++++++ example/convex/example.ts | 16 ++++++++++++++++ src/client/index.ts | 2 +- src/component/lib.ts | 2 +- 4 files changed, 39 insertions(+), 2 deletions(-) diff --git a/example/convex/example.test.ts b/example/convex/example.test.ts index 5e095d8..60aa375 100644 --- a/example/convex/example.test.ts +++ b/example/convex/example.test.ts @@ -132,6 +132,27 @@ describe("example", () => { }); }); + 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`. diff --git a/example/convex/example.ts b/example/convex/example.ts index f15ca94..14e05e1 100644 --- a/example/convex/example.ts +++ b/example/convex/example.ts @@ -33,6 +33,16 @@ export const setConfiguredValue = migrations.define({ }, }); +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 }), @@ -146,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, +]); diff --git a/src/client/index.ts b/src/client/index.ts index a6f2dd1..db1d48c 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -197,7 +197,7 @@ export class Migrations { ctx: MutationCtx | ActionCtx, args: MigrationArgs, fnRef?: MigrationFunctionReference, - next?: { name: string; fnHandle: string }[], + next?: { name: string; fnHandle: string; args?: unknown }[], ) { const baseName = args.fn ? this.prefixedName(args.fn) diff --git a/src/component/lib.ts b/src/component/lib.ts index 16f90dc..62c0286 100644 --- a/src/component/lib.ts +++ b/src/component/lib.ts @@ -152,7 +152,7 @@ export const migrate = mutation({ const [nextFn, ...rest] = next.slice(i); if (nextFn) { await ctx.scheduler.runAfter(0, api.lib.migrate, { - name: nextFn.name, + name: migrationNameWithArgs(nextFn.name, nextFn.args), fnHandle: nextFn.fnHandle, args: nextFn.args, next: rest,