9 | See example/convex/example.ts for examples.
10 |
11 |
12 | >
13 | );
14 | }
15 |
16 | export default App;
17 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | .idea
3 | *.local
4 | *.log
5 | /.vscode/
6 | /docs/.vitepress/cache
7 | dist
8 | dist-ssr
9 | explorations
10 | node_modules
11 | .eslintcache
12 |
13 | # this is a package-json-redirect stub dir, see https://github.com/andrewbranch/example-subpath-exports-ts-compat?tab=readme-ov-file
14 | frontend/package.json
15 | # npm pack output
16 | *.tgz
17 | *.tsbuildinfo
18 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## 0.3.1
4 |
5 | - Adds `runToCompletion` which can run a migration synchronously from an action,
6 | stopping if the action times out or fails.
7 |
8 | ## 0.3.0
9 |
10 | - Adds /test and /\_generated/component.js entrypoints
11 | - Drops commonjs support
12 | - Improves source mapping for generated files
13 | - Changes to a statically generated component API
14 |
--------------------------------------------------------------------------------
/example/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Vite + React + TS
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/example/convex/schema.ts:
--------------------------------------------------------------------------------
1 | import { v } from "convex/values";
2 | import { defineSchema, defineTable } from "convex/server";
3 |
4 | export default defineSchema({
5 | // Any tables used by the example app go here.
6 | myTable: defineTable({
7 | requiredField: v.string(),
8 | optionalField: v.optional(v.string()),
9 | unionField: v.union(v.string(), v.number()),
10 | }).index("by_requiredField", ["requiredField"]),
11 | });
12 |
--------------------------------------------------------------------------------
/example/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
5 | "skipLibCheck": true,
6 | "allowSyntheticDefaultImports": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "module": "ESNext",
10 | "moduleResolution": "Bundler",
11 | "resolveJsonModule": true,
12 | "isolatedModules": true,
13 | "noEmit": true,
14 | "jsx": "react-jsx"
15 | },
16 | "include": ["./src", "vite.config.ts"]
17 | }
18 |
--------------------------------------------------------------------------------
/example/src/main.tsx:
--------------------------------------------------------------------------------
1 | import { StrictMode } from "react";
2 | import { createRoot } from "react-dom/client";
3 | import { ConvexProvider, ConvexReactClient } from "convex/react";
4 | import App from "./App.jsx";
5 | import "./index.css";
6 |
7 | const address = import.meta.env.VITE_CONVEX_URL;
8 |
9 | const convex = new ConvexReactClient(address);
10 |
11 | createRoot(document.getElementById("root")!).render(
12 |
13 |
14 |
15 |
16 | ,
17 | );
18 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Developing guide
2 |
3 | ## Running locally
4 |
5 | ```sh
6 | npm i
7 | npm run dev
8 | ```
9 |
10 | ## Testing
11 |
12 | ```sh
13 | npm run clean
14 | npm run build
15 | npm run typecheck
16 | npm run lint
17 | npm run test
18 | ```
19 |
20 | ## Deploying
21 |
22 | ### Building a one-off package
23 |
24 | ```sh
25 | npm run clean
26 | npm ci
27 | npm pack
28 | ```
29 |
30 | ### Deploying a new version
31 |
32 | ```sh
33 | npm run release
34 | ```
35 |
36 | or for alpha release:
37 |
38 | ```sh
39 | npm run alpha
40 | ```
41 |
--------------------------------------------------------------------------------
/example/convex/setup.test.ts:
--------------------------------------------------------------------------------
1 | ///
2 | import { test } from "vitest";
3 | import { convexTest } from "convex-test";
4 | import schema from "./schema.js";
5 | import component from "@convex-dev/migrations/test";
6 |
7 | const modules = import.meta.glob("./**/*.*s");
8 | // When users want to write tests that use your component, they need to
9 | // explicitly register it with its schema and modules.
10 | export function initConvexTest() {
11 | const t = convexTest(schema, modules);
12 | component.register(t);
13 | return t;
14 | }
15 |
16 | test("setup", () => {});
17 |
--------------------------------------------------------------------------------
/src/client/index.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, test, expect } from "vitest";
2 | import { Migrations, DEFAULT_BATCH_SIZE } from "./index.js";
3 | import type { ComponentApi } from "../component/_generated/component.js";
4 |
5 | describe("Migrations class", () => {
6 | test("can instantiate without error", () => {
7 | const dummyComponent = {} as ComponentApi;
8 | expect(() => new Migrations(dummyComponent)).not.toThrow();
9 | });
10 | });
11 |
12 | describe("DEFAULT_BATCH_SIZE", () => {
13 | test("should equal 100", () => {
14 | expect(DEFAULT_BATCH_SIZE).toBe(100);
15 | });
16 | });
17 |
--------------------------------------------------------------------------------
/example/convex/_generated/api.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /**
3 | * Generated `api` utility.
4 | *
5 | * THIS CODE IS AUTOMATICALLY GENERATED.
6 | *
7 | * To regenerate, run `npx convex dev`.
8 | * @module
9 | */
10 |
11 | import { anyApi, componentsGeneric } from "convex/server";
12 |
13 | /**
14 | * A utility for referencing Convex functions in your app's API.
15 | *
16 | * Usage:
17 | * ```js
18 | * const myFunctionReference = api.myModule.myFunction;
19 | * ```
20 | */
21 | export const api = anyApi;
22 | export const internal = anyApi;
23 | export const components = componentsGeneric();
24 |
--------------------------------------------------------------------------------
/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3 | "extends": ["config:best-practices"],
4 | "schedule": ["* 0-4 * * 1"],
5 | "timezone": "America/Los_Angeles",
6 | "prConcurrentLimit": 1,
7 | "packageRules": [
8 | {
9 | "groupName": "Routine updates",
10 | "matchUpdateTypes": ["minor", "patch", "pin", "digest"],
11 | "automerge": true
12 | },
13 | {
14 | "groupName": "Major updates",
15 | "matchUpdateTypes": ["major"],
16 | "automerge": false
17 | },
18 | {
19 | "matchDepTypes": ["devDependencies"],
20 | "automerge": true
21 | }
22 | ]
23 | }
24 |
--------------------------------------------------------------------------------
/src/component/schema.ts:
--------------------------------------------------------------------------------
1 | import { defineSchema, defineTable } from "convex/server";
2 | import { v } from "convex/values";
3 |
4 | export default defineSchema({
5 | migrations: defineTable({
6 | name: v.string(), // Defaults to the function name.
7 | cursor: v.union(v.string(), v.null()),
8 | isDone: v.boolean(),
9 | workerId: v.optional(v.id("_scheduled_functions")),
10 | error: v.optional(v.string()),
11 | // The number of documents processed so far.
12 | processed: v.number(),
13 | latestStart: v.number(),
14 | latestEnd: v.optional(v.number()),
15 | })
16 | .index("name", ["name"])
17 | .index("isDone", ["isDone"]),
18 | });
19 |
--------------------------------------------------------------------------------
/src/test.ts:
--------------------------------------------------------------------------------
1 | ///
2 | import type { TestConvex } from "convex-test";
3 | import type { GenericSchema, SchemaDefinition } from "convex/server";
4 | import schema from "./component/schema.js";
5 | const modules = import.meta.glob("./component/**/*.ts");
6 |
7 | /**
8 | * Register the component with the test convex instance.
9 | * @param t - The test convex instance, e.g. from calling `convexTest`.
10 | * @param name - The name of the component, as registered in convex.config.ts.
11 | */
12 | export function register(
13 | t: TestConvex>,
14 | name: string = "migrations",
15 | ) {
16 | t.registerComponent(name, schema, modules);
17 | }
18 | export default { register, schema, modules };
19 |
--------------------------------------------------------------------------------
/example/src/App.css:
--------------------------------------------------------------------------------
1 | #root {
2 | max-width: 1280px;
3 | margin: 0 auto;
4 | padding: 2rem;
5 | text-align: center;
6 | }
7 |
8 | .logo {
9 | height: 6em;
10 | padding: 1.5em;
11 | will-change: filter;
12 | transition: filter 300ms;
13 | }
14 | .logo:hover {
15 | filter: drop-shadow(0 0 2em #646cffaa);
16 | }
17 | .logo.react:hover {
18 | filter: drop-shadow(0 0 2em #61dafbaa);
19 | }
20 |
21 | @keyframes logo-spin {
22 | from {
23 | transform: rotate(0deg);
24 | }
25 | to {
26 | transform: rotate(360deg);
27 | }
28 | }
29 |
30 | @media (prefers-reduced-motion: no-preference) {
31 | a:nth-of-type(2) .logo {
32 | animation: logo-spin infinite 20s linear;
33 | }
34 | }
35 |
36 | .card {
37 | padding: 2em;
38 | }
39 |
40 | .read-the-docs {
41 | color: #888;
42 | }
43 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "allowJs": true,
4 | "checkJs": true,
5 | "strict": true,
6 |
7 | "target": "ESNext",
8 | "lib": ["ES2021", "dom"],
9 | "forceConsistentCasingInFileNames": true,
10 | "allowSyntheticDefaultImports": true,
11 | // We enforce stricter module resolution for Node16 compatibility
12 | // But when building we use Bundler & ESNext for ESM
13 | "module": "Node16",
14 | "moduleResolution": "NodeNext",
15 |
16 | "composite": true,
17 | "isolatedModules": true,
18 | "declaration": true,
19 | "declarationMap": true,
20 | "sourceMap": true,
21 | "rootDir": "./src",
22 | "outDir": "./dist",
23 | "verbatimModuleSyntax": true,
24 | "skipLibCheck": true
25 | },
26 | "include": ["./src/**/*"]
27 | }
28 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Test and lint
2 | concurrency:
3 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
4 | cancel-in-progress: true
5 |
6 | on:
7 | push:
8 | branches: [main]
9 | pull_request:
10 | branches: ["**"]
11 |
12 | jobs:
13 | check:
14 | name: Test and lint
15 | runs-on: ubuntu-latest
16 | timeout-minutes: 30
17 |
18 | steps:
19 | - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
20 |
21 | - name: Node setup
22 | uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
23 | with:
24 | cache-dependency-path: package.json
25 | node-version: "20.x"
26 | cache: "npm"
27 |
28 | - name: Install and build
29 | run: |
30 | npm i
31 | npm run build
32 | - name: Publish package for testing branch
33 | run: npx pkg-pr-new publish || echo "Have you set up pkg-pr-new for this repo?"
34 | - name: Test
35 | run: |
36 | npm run test
37 | npm run typecheck
38 | npm run lint
39 |
--------------------------------------------------------------------------------
/src/shared.ts:
--------------------------------------------------------------------------------
1 | import { type Infer, type ObjectType, v } from "convex/values";
2 |
3 | export const migrationArgs = {
4 | fn: v.optional(v.string()),
5 | cursor: v.optional(v.union(v.string(), v.null())),
6 | batchSize: v.optional(v.number()),
7 | dryRun: v.optional(v.boolean()),
8 | next: v.optional(v.array(v.string())),
9 | };
10 | export type MigrationArgs = ObjectType;
11 |
12 | export type MigrationResult = {
13 | continueCursor: string;
14 | isDone: boolean;
15 | processed: number;
16 | };
17 |
18 | export const migrationStatus = v.object({
19 | name: v.string(),
20 | cursor: v.optional(v.union(v.string(), v.null())),
21 | processed: v.number(),
22 | isDone: v.boolean(),
23 | error: v.optional(v.string()),
24 | state: v.union(
25 | v.literal("inProgress"),
26 | v.literal("success"),
27 | v.literal("failed"),
28 | v.literal("canceled"),
29 | v.literal("unknown"),
30 | ),
31 | latestStart: v.number(),
32 | latestEnd: v.optional(v.number()),
33 | batchSize: v.optional(v.number()),
34 | next: v.optional(v.array(v.string())),
35 | });
36 | export type MigrationStatus = Infer;
37 |
--------------------------------------------------------------------------------
/src/component/_generated/api.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /**
3 | * Generated `api` utility.
4 | *
5 | * THIS CODE IS AUTOMATICALLY GENERATED.
6 | *
7 | * To regenerate, run `npx convex dev`.
8 | * @module
9 | */
10 |
11 | import type * as lib from "../lib.js";
12 |
13 | import type {
14 | ApiFromModules,
15 | FilterApi,
16 | FunctionReference,
17 | } from "convex/server";
18 | import { anyApi, componentsGeneric } from "convex/server";
19 |
20 | const fullApi: ApiFromModules<{
21 | lib: typeof lib;
22 | }> = anyApi as any;
23 |
24 | /**
25 | * A utility for referencing Convex functions in your app's public API.
26 | *
27 | * Usage:
28 | * ```js
29 | * const myFunctionReference = api.myModule.myFunction;
30 | * ```
31 | */
32 | export const api: FilterApi<
33 | typeof fullApi,
34 | FunctionReference
35 | > = anyApi as any;
36 |
37 | /**
38 | * A utility for referencing Convex functions in your app's internal API.
39 | *
40 | * Usage:
41 | * ```js
42 | * const myFunctionReference = internal.myModule.myFunction;
43 | * ```
44 | */
45 | export const internal: FilterApi<
46 | typeof fullApi,
47 | FunctionReference
48 | > = anyApi as any;
49 |
50 | export const components = componentsGeneric() as unknown as {};
51 |
--------------------------------------------------------------------------------
/example/convex/_generated/api.d.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /**
3 | * Generated `api` utility.
4 | *
5 | * THIS CODE IS AUTOMATICALLY GENERATED.
6 | *
7 | * To regenerate, run `npx convex dev`.
8 | * @module
9 | */
10 |
11 | import type * as example from "../example.js";
12 |
13 | import type {
14 | ApiFromModules,
15 | FilterApi,
16 | FunctionReference,
17 | } from "convex/server";
18 |
19 | declare const fullApi: ApiFromModules<{
20 | example: typeof example;
21 | }>;
22 |
23 | /**
24 | * A utility for referencing Convex functions in your app's public API.
25 | *
26 | * Usage:
27 | * ```js
28 | * const myFunctionReference = api.myModule.myFunction;
29 | * ```
30 | */
31 | export declare const api: FilterApi<
32 | typeof fullApi,
33 | FunctionReference
34 | >;
35 |
36 | /**
37 | * A utility for referencing Convex functions in your app's internal API.
38 | *
39 | * Usage:
40 | * ```js
41 | * const myFunctionReference = internal.myModule.myFunction;
42 | * ```
43 | */
44 | export declare const internal: FilterApi<
45 | typeof fullApi,
46 | FunctionReference
47 | >;
48 |
49 | export declare const components: {
50 | migrations: import("@convex-dev/migrations/_generated/component.js").ComponentApi<"migrations">;
51 | };
52 |
--------------------------------------------------------------------------------
/example/src/index.css:
--------------------------------------------------------------------------------
1 | :root {
2 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
3 | line-height: 1.5;
4 | font-weight: 400;
5 |
6 | color-scheme: light dark;
7 | color: rgba(255, 255, 255, 0.87);
8 | background-color: #242424;
9 |
10 | font-synthesis: none;
11 | text-rendering: optimizeLegibility;
12 | -webkit-font-smoothing: antialiased;
13 | -moz-osx-font-smoothing: grayscale;
14 | }
15 |
16 | a {
17 | font-weight: 500;
18 | color: #646cff;
19 | text-decoration: inherit;
20 | }
21 | a:hover {
22 | color: #535bf2;
23 | }
24 |
25 | body {
26 | margin: 0;
27 | display: flex;
28 | place-items: center;
29 | min-width: 320px;
30 | min-height: 100vh;
31 | }
32 |
33 | h1 {
34 | font-size: 3.2em;
35 | line-height: 1.1;
36 | }
37 |
38 | button {
39 | border-radius: 8px;
40 | border: 1px solid transparent;
41 | padding: 0.6em 1.2em;
42 | font-size: 1em;
43 | font-weight: 500;
44 | font-family: inherit;
45 | background-color: #1a1a1a;
46 | cursor: pointer;
47 | transition: border-color 0.25s;
48 | }
49 | button:hover {
50 | border-color: #646cff;
51 | }
52 | button:focus,
53 | button:focus-visible {
54 | outline: 4px auto -webkit-focus-ring-color;
55 | }
56 |
57 | @media (prefers-color-scheme: light) {
58 | :root {
59 | color: #213547;
60 | background-color: #ffffff;
61 | }
62 | a:hover {
63 | color: #747bff;
64 | }
65 | button {
66 | background-color: #f9f9f9;
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/src/component/_generated/dataModel.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /**
3 | * Generated data model types.
4 | *
5 | * THIS CODE IS AUTOMATICALLY GENERATED.
6 | *
7 | * To regenerate, run `npx convex dev`.
8 | * @module
9 | */
10 |
11 | import type {
12 | DataModelFromSchemaDefinition,
13 | DocumentByName,
14 | TableNamesInDataModel,
15 | SystemTableNames,
16 | } from "convex/server";
17 | import type { GenericId } from "convex/values";
18 | import schema from "../schema.js";
19 |
20 | /**
21 | * The names of all of your Convex tables.
22 | */
23 | export type TableNames = TableNamesInDataModel;
24 |
25 | /**
26 | * The type of a document stored in Convex.
27 | *
28 | * @typeParam TableName - A string literal type of the table name (like "users").
29 | */
30 | export type Doc = DocumentByName<
31 | DataModel,
32 | TableName
33 | >;
34 |
35 | /**
36 | * An identifier for a document in Convex.
37 | *
38 | * Convex documents are uniquely identified by their `Id`, which is accessible
39 | * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids).
40 | *
41 | * Documents can be loaded using `db.get(id)` in query and mutation functions.
42 | *
43 | * IDs are just strings at runtime, but this type can be used to distinguish them from other
44 | * strings when type checking.
45 | *
46 | * @typeParam TableName - A string literal type of the table name (like "users").
47 | */
48 | export type Id =
49 | GenericId;
50 |
51 | /**
52 | * A type describing your Convex data model.
53 | *
54 | * This type includes information about what tables you have, the type of
55 | * documents stored in those tables, and the indexes defined on them.
56 | *
57 | * This type is used to parameterize methods like `queryGeneric` and
58 | * `mutationGeneric` to make them type-safe.
59 | */
60 | export type DataModel = DataModelFromSchemaDefinition;
61 |
--------------------------------------------------------------------------------
/example/convex/_generated/dataModel.d.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /**
3 | * Generated data model types.
4 | *
5 | * THIS CODE IS AUTOMATICALLY GENERATED.
6 | *
7 | * To regenerate, run `npx convex dev`.
8 | * @module
9 | */
10 |
11 | import type {
12 | DataModelFromSchemaDefinition,
13 | DocumentByName,
14 | TableNamesInDataModel,
15 | SystemTableNames,
16 | } from "convex/server";
17 | import type { GenericId } from "convex/values";
18 | import schema from "../schema.js";
19 |
20 | /**
21 | * The names of all of your Convex tables.
22 | */
23 | export type TableNames = TableNamesInDataModel;
24 |
25 | /**
26 | * The type of a document stored in Convex.
27 | *
28 | * @typeParam TableName - A string literal type of the table name (like "users").
29 | */
30 | export type Doc = DocumentByName<
31 | DataModel,
32 | TableName
33 | >;
34 |
35 | /**
36 | * An identifier for a document in Convex.
37 | *
38 | * Convex documents are uniquely identified by their `Id`, which is accessible
39 | * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids).
40 | *
41 | * Documents can be loaded using `db.get(id)` in query and mutation functions.
42 | *
43 | * IDs are just strings at runtime, but this type can be used to distinguish them from other
44 | * strings when type checking.
45 | *
46 | * @typeParam TableName - A string literal type of the table name (like "users").
47 | */
48 | export type Id =
49 | GenericId;
50 |
51 | /**
52 | * A type describing your Convex data model.
53 | *
54 | * This type includes information about what tables you have, the type of
55 | * documents stored in those tables, and the indexes defined on them.
56 | *
57 | * This type is used to parameterize methods like `queryGeneric` and
58 | * `mutationGeneric` to make them type-safe.
59 | */
60 | export type DataModel = DataModelFromSchemaDefinition;
61 |
--------------------------------------------------------------------------------
/src/client/log.ts:
--------------------------------------------------------------------------------
1 | import type { MigrationStatus } from "../shared.js";
2 |
3 | export function logStatusAndInstructions(
4 | name: string,
5 | status: MigrationStatus,
6 | args: {
7 | fn?: string;
8 | cursor?: string | null;
9 | batchSize?: number;
10 | dryRun?: boolean;
11 | },
12 | ) {
13 | const output: Record = {};
14 | if (status.isDone) {
15 | if (status.latestEnd! < Date.now()) {
16 | output["Status"] = "Migration already done.";
17 | } else if (status.latestStart === status.latestEnd) {
18 | output["Status"] = "Migration was started and finished in one batch.";
19 | } else {
20 | output["Status"] = "Migration completed with this batch.";
21 | }
22 | } else {
23 | if (status.state === "failed") {
24 | output["Status"] = `Migration failed: ${status.error}`;
25 | } else if (status.state === "canceled") {
26 | output["Status"] = "Migration canceled.";
27 | } else if (status.latestStart >= Date.now()) {
28 | output["Status"] = "Migration started.";
29 | } else {
30 | output["Status"] = "Migration running.";
31 | }
32 | }
33 | if (args.dryRun) {
34 | output["DryRun"] = "No changes were committed.";
35 | output["Status"] = "DRY RUN: " + output["Status"];
36 | }
37 | output["Name"] = name;
38 | output["lastStarted"] = new Date(status.latestStart).toISOString();
39 | if (status.latestEnd) {
40 | output["lastFinished"] = new Date(status.latestEnd).toISOString();
41 | }
42 | output["processed"] = status.processed;
43 | if (status.next?.length) {
44 | if (status.isDone) {
45 | output["nowUp"] = status.next;
46 | } else {
47 | output["nextUp"] = status.next;
48 | }
49 | }
50 | const nextArgs = (status.next || []).map((n) => `"${n}"`).join(", ");
51 | const run = `npx convex run --component migrations`;
52 | if (!args.dryRun) {
53 | if (status.state === "inProgress") {
54 | output["toCancel"] = {
55 | cmd: `${run} lib:cancel`,
56 | args: `{"name": "${name}"}`,
57 | prod: `--prod`,
58 | };
59 | output["toMonitorStatus"] = {
60 | cmd: `${run} --watch lib:getStatus`,
61 | args: `{"names": ["${name}"${status.next?.length ? ", " + nextArgs : ""}]}`,
62 | prod: `--prod`,
63 | };
64 | } else {
65 | output["toStartOver"] = JSON.stringify({ ...args, cursor: null });
66 | if (status.next?.length) {
67 | output["toMonitorStatus"] = {
68 | cmd: `${run} --watch lib:getStatus`,
69 | args: `{"names": [${nextArgs}]}`,
70 | prod: `--prod`,
71 | };
72 | }
73 | }
74 | }
75 | return output;
76 | }
77 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | import globals from "globals";
2 | import pluginJs from "@eslint/js";
3 | import tseslint from "typescript-eslint";
4 | import reactHooks from "eslint-plugin-react-hooks";
5 | import reactRefresh from "eslint-plugin-react-refresh";
6 |
7 | export default [
8 | {
9 | ignores: [
10 | "dist/**",
11 | "example/dist/**",
12 | "*.config.{js,mjs,cjs,ts,tsx}",
13 | "example/**/*.config.{js,mjs,cjs,ts,tsx}",
14 | "**/_generated/",
15 | "stubs.mjs",
16 | ],
17 | },
18 | {
19 | files: ["src/**/*.{js,mjs,cjs,ts,tsx}", "example/**/*.{js,mjs,cjs,ts,tsx}"],
20 | languageOptions: {
21 | parser: tseslint.parser,
22 | parserOptions: {
23 | project: [
24 | "./tsconfig.json",
25 | "./example/tsconfig.json",
26 | "./example/convex/tsconfig.json",
27 | ],
28 | tsconfigRootDir: import.meta.dirname,
29 | },
30 | },
31 | },
32 | pluginJs.configs.recommended,
33 | ...tseslint.configs.recommended,
34 | // Convex code - Worker environment
35 | {
36 | files: ["src/**/*.{ts,tsx}", "example/convex/**/*.{ts,tsx}"],
37 | ignores: ["src/react/**"],
38 | languageOptions: {
39 | globals: globals.worker,
40 | },
41 | rules: {
42 | "@typescript-eslint/no-floating-promises": "error",
43 | "@typescript-eslint/no-explicit-any": "off",
44 | "no-unused-vars": "off",
45 | "@typescript-eslint/no-unused-vars": [
46 | "warn",
47 | {
48 | argsIgnorePattern: "^_",
49 | varsIgnorePattern: "^_",
50 | },
51 | ],
52 | "@typescript-eslint/no-unused-expressions": [
53 | "error",
54 | {
55 | allowShortCircuit: true,
56 | allowTernary: true,
57 | allowTaggedTemplates: true,
58 | },
59 | ],
60 | },
61 | },
62 | // React app code - Browser environment
63 | {
64 | files: ["src/react/**/*.{ts,tsx}", "example/src/**/*.{ts,tsx}"],
65 | languageOptions: {
66 | ecmaVersion: 2020,
67 | globals: globals.browser,
68 | },
69 | plugins: {
70 | "react-hooks": reactHooks,
71 | "react-refresh": reactRefresh,
72 | },
73 | rules: {
74 | ...reactHooks.configs.recommended.rules,
75 | "react-refresh/only-export-components": [
76 | "warn",
77 | { allowConstantExport: true },
78 | ],
79 | "@typescript-eslint/no-explicit-any": "off",
80 | "no-unused-vars": "off",
81 | "@typescript-eslint/no-unused-vars": [
82 | "warn",
83 | {
84 | argsIgnorePattern: "^_",
85 | varsIgnorePattern: "^_",
86 | },
87 | ],
88 | },
89 | },
90 | ];
91 |
--------------------------------------------------------------------------------
/example/convex/example.test.ts:
--------------------------------------------------------------------------------
1 | import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
2 | import { initConvexTest } from "./setup.test";
3 | import { components, internal } from "./_generated/api";
4 | import { runToCompletion } from "@convex-dev/migrations";
5 | import { createFunctionHandle, getFunctionName } from "convex/server";
6 |
7 | describe("example", () => {
8 | beforeEach(async () => {
9 | vi.useFakeTimers();
10 | });
11 |
12 | afterEach(async () => {
13 | vi.useRealTimers();
14 | });
15 |
16 | test("test setDefaultValue migration", async () => {
17 | const t = initConvexTest();
18 | await t.mutation(internal.example.seed, { count: 10 });
19 | await t.run(async (ctx) => {
20 | const docs = await ctx.db.query("myTable").collect();
21 | expect(docs).toHaveLength(10);
22 | expect(docs.some((doc) => doc.optionalField === undefined)).toBe(true);
23 | });
24 | await t.run(async (ctx) => {
25 | await runToCompletion(
26 | ctx,
27 | components.migrations,
28 | internal.example.setDefaultValue,
29 | { batchSize: 2 },
30 | );
31 | });
32 | await t.run(async (ctx) => {
33 | const after = await ctx.db.query("myTable").collect();
34 | expect(after).toHaveLength(10);
35 | expect(after.every((doc) => doc.optionalField !== undefined)).toBe(true);
36 | });
37 | });
38 |
39 | test("test failingMigration", async () => {
40 | const t = initConvexTest();
41 | await t.mutation(internal.example.seed, { count: 10 });
42 | await expect(
43 | t.run(async (ctx) => {
44 | await runToCompletion(
45 | ctx,
46 | components.migrations,
47 | internal.example.failingMigration,
48 | );
49 | }),
50 | ).rejects.toThrow("This migration fails after the first");
51 | });
52 |
53 | test("test migrating with function handle", async () => {
54 | const t = initConvexTest();
55 | await t.mutation(internal.example.seed, { count: 10 });
56 | await t.run(async (ctx) => {
57 | const docs = await ctx.db.query("myTable").collect();
58 | expect(docs).toHaveLength(10);
59 | expect(docs.some((doc) => doc.optionalField === undefined)).toBe(true);
60 | });
61 | await t.run(async (ctx) => {
62 | const fnHandle = await createFunctionHandle(
63 | internal.example.setDefaultValue,
64 | );
65 | await runToCompletion(ctx, components.migrations, fnHandle, {
66 | name: getFunctionName(internal.example.setDefaultValue),
67 | batchSize: 2,
68 | });
69 | });
70 | await t.run(async (ctx) => {
71 | const after = await ctx.db.query("myTable").collect();
72 | expect(after).toHaveLength(10);
73 | expect(after.every((doc) => doc.optionalField !== undefined)).toBe(true);
74 | });
75 | });
76 | });
77 |
--------------------------------------------------------------------------------
/src/component/_generated/component.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /**
3 | * Generated `ComponentApi` utility.
4 | *
5 | * THIS CODE IS AUTOMATICALLY GENERATED.
6 | *
7 | * To regenerate, run `npx convex dev`.
8 | * @module
9 | */
10 |
11 | import type { FunctionReference } from "convex/server";
12 |
13 | /**
14 | * A utility for referencing a Convex component's exposed API.
15 | *
16 | * Useful when expecting a parameter like `components.myComponent`.
17 | * Usage:
18 | * ```ts
19 | * async function myFunction(ctx: QueryCtx, component: ComponentApi) {
20 | * return ctx.runQuery(component.someFile.someQuery, { ...args });
21 | * }
22 | * ```
23 | */
24 | export type ComponentApi =
25 | {
26 | lib: {
27 | cancel: FunctionReference<
28 | "mutation",
29 | "internal",
30 | { name: string },
31 | {
32 | batchSize?: number;
33 | cursor?: string | null;
34 | error?: string;
35 | isDone: boolean;
36 | latestEnd?: number;
37 | latestStart: number;
38 | name: string;
39 | next?: Array;
40 | processed: number;
41 | state: "inProgress" | "success" | "failed" | "canceled" | "unknown";
42 | },
43 | Name
44 | >;
45 | cancelAll: FunctionReference<
46 | "mutation",
47 | "internal",
48 | { sinceTs?: number },
49 | Array<{
50 | batchSize?: number;
51 | cursor?: string | null;
52 | error?: string;
53 | isDone: boolean;
54 | latestEnd?: number;
55 | latestStart: number;
56 | name: string;
57 | next?: Array;
58 | processed: number;
59 | state: "inProgress" | "success" | "failed" | "canceled" | "unknown";
60 | }>,
61 | Name
62 | >;
63 | clearAll: FunctionReference<
64 | "mutation",
65 | "internal",
66 | { before?: number },
67 | null,
68 | Name
69 | >;
70 | getStatus: FunctionReference<
71 | "query",
72 | "internal",
73 | { limit?: number; names?: Array },
74 | Array<{
75 | batchSize?: number;
76 | cursor?: string | null;
77 | error?: string;
78 | isDone: boolean;
79 | latestEnd?: number;
80 | latestStart: number;
81 | name: string;
82 | next?: Array;
83 | processed: number;
84 | state: "inProgress" | "success" | "failed" | "canceled" | "unknown";
85 | }>,
86 | Name
87 | >;
88 | migrate: FunctionReference<
89 | "mutation",
90 | "internal",
91 | {
92 | batchSize?: number;
93 | cursor?: string | null;
94 | dryRun: boolean;
95 | fnHandle: string;
96 | name: string;
97 | next?: Array<{ fnHandle: string; name: string }>;
98 | oneBatchOnly?: boolean;
99 | },
100 | {
101 | batchSize?: number;
102 | cursor?: string | null;
103 | error?: string;
104 | isDone: boolean;
105 | latestEnd?: number;
106 | latestStart: number;
107 | name: string;
108 | next?: Array;
109 | processed: number;
110 | state: "inProgress" | "success" | "failed" | "canceled" | "unknown";
111 | },
112 | Name
113 | >;
114 | };
115 | };
116 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@convex-dev/migrations",
3 | "description": "A migrations component for Convex. Define, run, and track your database migrations. Run from a CLI or Convex server function.",
4 | "repository": "github:get-convex/migrations",
5 | "homepage": "https://github.com/get-convex/migrations#readme",
6 | "bugs": {
7 | "email": "support@convex.dev",
8 | "url": "https://github.com/get-convex/migrations/issues"
9 | },
10 | "version": "0.3.1",
11 | "license": "Apache-2.0",
12 | "keywords": [
13 | "convex",
14 | "component"
15 | ],
16 | "type": "module",
17 | "scripts": {
18 | "dev": "run-p -r 'dev:*'",
19 | "dev:backend": "convex dev --typecheck-components",
20 | "dev:frontend": "cd example && vite --clearScreen false",
21 | "dev:build": "chokidar 'tsconfig*.json' 'src/**/*.ts' -i '**/*.test.ts' -c 'npm run build:codegen' --initial",
22 | "predev": "path-exists .env.local dist || (npm run build && convex dev --once)",
23 | "build": "tsc --project ./tsconfig.build.json",
24 | "build:codegen": "npx convex codegen --component-dir ./src/component && npm run build",
25 | "build:clean": "rm -rf dist *.tsbuildinfo && npm run build:codegen",
26 | "typecheck": "tsc --noEmit && tsc -p example && tsc -p example/convex",
27 | "lint": "eslint .",
28 | "all": "run-p -r 'dev:*' 'test:watch'",
29 | "test": "vitest run --typecheck",
30 | "test:watch": "vitest --typecheck --clearScreen false",
31 | "test:debug": "vitest --inspect-brk --no-file-parallelism",
32 | "test:coverage": "vitest run --coverage --coverage.reporter=text",
33 | "preversion": "npm ci && npm run build:clean && run-p test lint typecheck",
34 | "alpha": "npm version prerelease --preid alpha && npm publish --tag alpha && git push --follow-tags",
35 | "release": "npm version patch && npm publish && git push --follow-tags",
36 | "version": "vim -c 'normal o' -c 'normal o## '$npm_package_version CHANGELOG.md && prettier -w CHANGELOG.md && git add CHANGELOG.md"
37 | },
38 | "files": [
39 | "dist",
40 | "src"
41 | ],
42 | "exports": {
43 | "./package.json": "./package.json",
44 | ".": {
45 | "types": "./dist/client/index.d.ts",
46 | "default": "./dist/client/index.js"
47 | },
48 | "./test": "./src/test.ts",
49 | "./_generated/component.js": {
50 | "types": "./dist/component/_generated/component.d.ts"
51 | },
52 | "./convex.config": {
53 | "types": "./dist/component/convex.config.d.ts",
54 | "default": "./dist/component/convex.config.js"
55 | },
56 | "./convex.config.js": {
57 | "types": "./dist/component/convex.config.d.ts",
58 | "default": "./dist/component/convex.config.js"
59 | }
60 | },
61 | "peerDependencies": {
62 | "convex": "^1.24.8"
63 | },
64 | "devDependencies": {
65 | "@edge-runtime/vm": "5.0.0",
66 | "@eslint/eslintrc": "3.3.1",
67 | "@eslint/js": "9.39.1",
68 | "@types/node": "20.19.24",
69 | "@types/react": "18.3.26",
70 | "@types/react-dom": "18.3.7",
71 | "@vitejs/plugin-react": "5.0.4",
72 | "chokidar-cli": "3.0.0",
73 | "convex": "1.30.0",
74 | "convex-test": "0.0.41",
75 | "cpy-cli": "6.0.0",
76 | "eslint": "9.39.1",
77 | "eslint-plugin-react": "7.37.5",
78 | "eslint-plugin-react-hooks": "5.2.0",
79 | "eslint-plugin-react-refresh": "0.4.24",
80 | "globals": "15.14.0",
81 | "npm-run-all2": "7.0.2",
82 | "path-exists-cli": "2.0.0",
83 | "pkg-pr-new": "0.0.60",
84 | "prettier": "3.6.2",
85 | "react": "18.3.1",
86 | "react-dom": "18.3.1",
87 | "typescript": "5.9.3",
88 | "typescript-eslint": "8.46.4",
89 | "vite": "^7.1.5",
90 | "vitest": "3.2.4"
91 | },
92 | "types": "./dist/client/index.d.ts",
93 | "module": "./dist/client/index.js"
94 | }
95 |
--------------------------------------------------------------------------------
/src/component/lib.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, test, expect } from "vitest";
2 | import {
3 | type ApiFromModules,
4 | anyApi,
5 | createFunctionHandle,
6 | } from "convex/server";
7 | import { convexTest } from "convex-test";
8 | import { modules } from "./setup.test.js";
9 | import { api } from "./_generated/api.js";
10 | import type { MigrationArgs, MigrationResult } from "../client/index.js";
11 | import { mutation } from "./_generated/server.js";
12 | import schema from "./schema.js";
13 |
14 | export const doneMigration = mutation({
15 | handler: async (_, _args: MigrationArgs): Promise => {
16 | return {
17 | isDone: true,
18 | continueCursor: "foo",
19 | processed: 1,
20 | };
21 | },
22 | });
23 |
24 | const testApi: ApiFromModules<{
25 | fns: { doneMigration: typeof doneMigration };
26 | }>["fns"] = anyApi["lib.test"] as any;
27 |
28 | describe("migrate", () => {
29 | test("runs a simple migration in one go", async () => {
30 | const t = convexTest(schema, modules);
31 | const fnHandle = await createFunctionHandle(testApi.doneMigration);
32 | const result = await t.mutation(api.lib.migrate, {
33 | name: "testMigration",
34 | fnHandle: fnHandle,
35 | dryRun: false,
36 | });
37 | expect(result.isDone).toBe(true);
38 | expect(result.cursor).toBe("foo");
39 | expect(result.processed).toBe(1);
40 | expect(result.error).toBeUndefined();
41 | expect(result.batchSize).toBeUndefined();
42 | expect(result.next).toBeUndefined();
43 | expect(result.latestEnd).toBeTypeOf("number");
44 | expect(result.state).toBe("success");
45 | });
46 |
47 | test("throws error for batchSize <= 0", async () => {
48 | const args = {
49 | name: "testMigration",
50 | fnHandle: "function://dummy",
51 | cursor: null,
52 | batchSize: 0,
53 | next: [],
54 | dryRun: false,
55 | };
56 | const t = convexTest(schema, modules);
57 | // Assumes testApi has shape matching api.lib – adjust per actual ConvexTest usage
58 | await expect(t.mutation(api.lib.migrate, args)).rejects.toThrow(
59 | "Batch size must be greater than 0",
60 | );
61 | });
62 |
63 | test("throws error for invalid fnHandle", async () => {
64 | const args = {
65 | name: "testMigration",
66 | fnHandle: "invalid_handle",
67 | cursor: null,
68 | batchSize: 10,
69 | next: [],
70 | dryRun: false,
71 | };
72 | const t = convexTest(schema, modules);
73 | await expect(t.mutation(api.lib.migrate, args)).rejects.toThrow(
74 | "Invalid fnHandle",
75 | );
76 | });
77 | });
78 |
79 | describe("cancel", () => {
80 | test("throws error if migration not found", async () => {
81 | // For cancel, ConvexTest-like patterns would be similar – this code demonstrates minimal direct call
82 | const t = convexTest(schema, modules);
83 | await expect(
84 | t.mutation(api.lib.cancel, { name: "nonexistent" }),
85 | ).rejects.toThrow();
86 | });
87 | });
88 |
89 | describe("It doesn't attempt a migration if it's already done", () => {
90 | test("runs a simple migration in one go", async () => {
91 | const t = convexTest(schema, modules);
92 | const fnHandle = "function://invalid";
93 | await t.run((ctx) =>
94 | ctx.db.insert("migrations", {
95 | name: "testMigration",
96 | latestStart: Date.now(),
97 | isDone: true,
98 | cursor: "foo",
99 | processed: 1,
100 | }),
101 | );
102 | // It'd throw if it tried to run the migration.
103 | const result = await t.mutation(api.lib.migrate, {
104 | name: "testMigration",
105 | fnHandle: fnHandle,
106 | dryRun: false,
107 | });
108 | expect(result.isDone).toBe(true);
109 | });
110 | });
111 |
--------------------------------------------------------------------------------
/example/convex/_generated/server.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /**
3 | * Generated utilities for implementing server-side Convex query and mutation functions.
4 | *
5 | * THIS CODE IS AUTOMATICALLY GENERATED.
6 | *
7 | * To regenerate, run `npx convex dev`.
8 | * @module
9 | */
10 |
11 | import {
12 | actionGeneric,
13 | httpActionGeneric,
14 | queryGeneric,
15 | mutationGeneric,
16 | internalActionGeneric,
17 | internalMutationGeneric,
18 | internalQueryGeneric,
19 | } from "convex/server";
20 |
21 | /**
22 | * Define a query in this Convex app's public API.
23 | *
24 | * This function will be allowed to read your Convex database and will be accessible from the client.
25 | *
26 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument.
27 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
28 | */
29 | export const query = queryGeneric;
30 |
31 | /**
32 | * Define a query that is only accessible from other Convex functions (but not from the client).
33 | *
34 | * This function will be allowed to read from your Convex database. It will not be accessible from the client.
35 | *
36 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument.
37 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
38 | */
39 | export const internalQuery = internalQueryGeneric;
40 |
41 | /**
42 | * Define a mutation in this Convex app's public API.
43 | *
44 | * This function will be allowed to modify your Convex database and will be accessible from the client.
45 | *
46 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
47 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
48 | */
49 | export const mutation = mutationGeneric;
50 |
51 | /**
52 | * Define a mutation that is only accessible from other Convex functions (but not from the client).
53 | *
54 | * This function will be allowed to modify your Convex database. It will not be accessible from the client.
55 | *
56 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
57 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
58 | */
59 | export const internalMutation = internalMutationGeneric;
60 |
61 | /**
62 | * Define an action in this Convex app's public API.
63 | *
64 | * An action is a function which can execute any JavaScript code, including non-deterministic
65 | * code and code with side-effects, like calling third-party services.
66 | * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
67 | * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
68 | *
69 | * @param func - The action. It receives an {@link ActionCtx} as its first argument.
70 | * @returns The wrapped action. Include this as an `export` to name it and make it accessible.
71 | */
72 | export const action = actionGeneric;
73 |
74 | /**
75 | * Define an action that is only accessible from other Convex functions (but not from the client).
76 | *
77 | * @param func - The function. It receives an {@link ActionCtx} as its first argument.
78 | * @returns The wrapped function. Include this as an `export` to name it and make it accessible.
79 | */
80 | export const internalAction = internalActionGeneric;
81 |
82 | /**
83 | * Define an HTTP action.
84 | *
85 | * The wrapped function will be used to respond to HTTP requests received
86 | * by a Convex deployment if the requests matches the path and method where
87 | * this action is routed. Be sure to route your httpAction in `convex/http.js`.
88 | *
89 | * @param func - The function. It receives an {@link ActionCtx} as its first argument
90 | * and a Fetch API `Request` object as its second.
91 | * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
92 | */
93 | export const httpAction = httpActionGeneric;
94 |
--------------------------------------------------------------------------------
/example/convex/example.ts:
--------------------------------------------------------------------------------
1 | import { Migrations, type MigrationStatus } from "@convex-dev/migrations";
2 | import { v } from "convex/values";
3 | import { components, internal } from "./_generated/api.js";
4 | import type { DataModel } from "./_generated/dataModel.js";
5 | import { internalMutation, internalQuery } from "./_generated/server.js";
6 |
7 | export const migrations = new Migrations(components.migrations);
8 |
9 | // Allows you to run `npx convex run example:run '{"fn":"example:setDefaultValue"}'`
10 | export const run = migrations.runner();
11 |
12 | // This allows you to just run `npx convex run example:runIt`
13 | export const runIt = migrations.runner(internal.example.setDefaultValue);
14 |
15 | export const setDefaultValue = migrations.define({
16 | table: "myTable",
17 | batchSize: 2,
18 | migrateOne: async (_ctx, doc) => {
19 | if (doc.optionalField === undefined) {
20 | return { optionalField: "default" };
21 | }
22 | },
23 | parallelize: true,
24 | });
25 |
26 | export const clearField = migrations.define({
27 | table: "myTable",
28 | migrateOne: () => ({ optionalField: undefined }),
29 | });
30 |
31 | export const validateRequiredField = migrations.define({
32 | table: "myTable",
33 | // Specify a custom range to only include documents that need to change.
34 | // This is useful if you have a large dataset and only a small percentage of
35 | // documents need to be migrated.
36 | customRange: (query) =>
37 | query.withIndex("by_requiredField", (q) => q.eq("requiredField", "")),
38 | migrateOne: async (_ctx, doc) => {
39 | console.log("Needs fixup: " + doc._id);
40 | // Shorthand for patching
41 | return { requiredField: "" };
42 | },
43 | });
44 |
45 | // If you prefer the old-style migration definition, you can define `migration`:
46 | const migration = migrations.define.bind(migrations);
47 | // Then use it like this:
48 | export const convertUnionField = migration({
49 | table: "myTable",
50 | migrateOne: async (ctx, doc) => {
51 | if (typeof doc.unionField === "number") {
52 | await ctx.db.patch(doc._id, { unionField: doc.unionField.toString() });
53 | }
54 | },
55 | });
56 |
57 | export const failingMigration = migrations.define({
58 | table: "myTable",
59 | batchSize: 1,
60 | migrateOne: async (ctx, doc) => {
61 | if (doc._id !== (await ctx.db.query("myTable").first())?._id) {
62 | throw new Error("This migration fails after the first");
63 | }
64 | },
65 | });
66 |
67 | export const runOneAtATime = internalMutation({
68 | args: {},
69 | handler: async (ctx) => {
70 | await migrations.runOne(ctx, internal.example.failingMigration, {
71 | batchSize: 1,
72 | });
73 | },
74 | });
75 |
76 | // It's handy to have a list of all migrations that folks should run in order.
77 | const allMigrations = [
78 | internal.example.setDefaultValue,
79 | internal.example.validateRequiredField,
80 | internal.example.convertUnionField,
81 | internal.example.failingMigration,
82 | ];
83 |
84 | export const runAll = migrations.runner(allMigrations);
85 |
86 | // Call this from a deploy script to run them after pushing code.
87 | export const postDeploy = internalMutation({
88 | args: {},
89 | handler: async (ctx) => {
90 | // Do other post-deploy things...
91 | await migrations.runSerially(ctx, allMigrations);
92 | },
93 | });
94 |
95 | // Handy for checking the status from the CLI / dashboard.
96 | export const getStatus = internalQuery({
97 | args: {},
98 | handler: async (ctx): Promise => {
99 | return migrations.getStatus(ctx, {
100 | migrations: allMigrations,
101 | });
102 | },
103 | });
104 |
105 | export const cancel = internalMutation({
106 | args: { name: v.optional(v.string()) },
107 | handler: async (ctx, args) => {
108 | if (args.name) {
109 | await migrations.cancel(ctx, args.name);
110 | } else {
111 | await migrations.cancelAll(ctx);
112 | }
113 | },
114 | });
115 |
116 | export const seed = internalMutation({
117 | args: { count: v.optional(v.number()) },
118 | handler: async (ctx, args) => {
119 | for (let i = 0; i < (args.count ?? 10); i++) {
120 | await ctx.db.insert("myTable", {
121 | requiredField: "seed " + i,
122 | optionalField: i % 2 ? "optionalValue" : undefined,
123 | unionField: i % 2 ? "1" : 1,
124 | });
125 | }
126 | },
127 | });
128 |
129 | // Alternatively, you can specify a prefix.
130 | export const migrationsWithPrefix = new Migrations(components.migrations, {
131 | // Specifying the internalMutation means you don't need the type parameter.
132 | // Also, if you have a custom internalMutation, you can specify it here.
133 | internalMutation,
134 | migrationsLocationPrefix: "example:",
135 | });
136 |
137 | // Allows you to run `npx convex run example:runWithPrefix '{"fn":"setDefaultValue"}'`
138 | export const runWithPrefix = migrationsWithPrefix.runner();
139 |
--------------------------------------------------------------------------------
/example/convex/_generated/server.d.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /**
3 | * Generated utilities for implementing server-side Convex query and mutation functions.
4 | *
5 | * THIS CODE IS AUTOMATICALLY GENERATED.
6 | *
7 | * To regenerate, run `npx convex dev`.
8 | * @module
9 | */
10 |
11 | import {
12 | ActionBuilder,
13 | HttpActionBuilder,
14 | MutationBuilder,
15 | QueryBuilder,
16 | GenericActionCtx,
17 | GenericMutationCtx,
18 | GenericQueryCtx,
19 | GenericDatabaseReader,
20 | GenericDatabaseWriter,
21 | } from "convex/server";
22 | import type { DataModel } from "./dataModel.js";
23 |
24 | /**
25 | * Define a query in this Convex app's public API.
26 | *
27 | * This function will be allowed to read your Convex database and will be accessible from the client.
28 | *
29 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument.
30 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
31 | */
32 | export declare const query: QueryBuilder;
33 |
34 | /**
35 | * Define a query that is only accessible from other Convex functions (but not from the client).
36 | *
37 | * This function will be allowed to read from your Convex database. It will not be accessible from the client.
38 | *
39 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument.
40 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
41 | */
42 | export declare const internalQuery: QueryBuilder;
43 |
44 | /**
45 | * Define a mutation in this Convex app's public API.
46 | *
47 | * This function will be allowed to modify your Convex database and will be accessible from the client.
48 | *
49 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
50 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
51 | */
52 | export declare const mutation: MutationBuilder;
53 |
54 | /**
55 | * Define a mutation that is only accessible from other Convex functions (but not from the client).
56 | *
57 | * This function will be allowed to modify your Convex database. It will not be accessible from the client.
58 | *
59 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
60 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
61 | */
62 | export declare const internalMutation: MutationBuilder;
63 |
64 | /**
65 | * Define an action in this Convex app's public API.
66 | *
67 | * An action is a function which can execute any JavaScript code, including non-deterministic
68 | * code and code with side-effects, like calling third-party services.
69 | * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
70 | * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
71 | *
72 | * @param func - The action. It receives an {@link ActionCtx} as its first argument.
73 | * @returns The wrapped action. Include this as an `export` to name it and make it accessible.
74 | */
75 | export declare const action: ActionBuilder;
76 |
77 | /**
78 | * Define an action that is only accessible from other Convex functions (but not from the client).
79 | *
80 | * @param func - The function. It receives an {@link ActionCtx} as its first argument.
81 | * @returns The wrapped function. Include this as an `export` to name it and make it accessible.
82 | */
83 | export declare const internalAction: ActionBuilder;
84 |
85 | /**
86 | * Define an HTTP action.
87 | *
88 | * The wrapped function will be used to respond to HTTP requests received
89 | * by a Convex deployment if the requests matches the path and method where
90 | * this action is routed. Be sure to route your httpAction in `convex/http.js`.
91 | *
92 | * @param func - The function. It receives an {@link ActionCtx} as its first argument
93 | * and a Fetch API `Request` object as its second.
94 | * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
95 | */
96 | export declare const httpAction: HttpActionBuilder;
97 |
98 | /**
99 | * A set of services for use within Convex query functions.
100 | *
101 | * The query context is passed as the first argument to any Convex query
102 | * function run on the server.
103 | *
104 | * This differs from the {@link MutationCtx} because all of the services are
105 | * read-only.
106 | */
107 | export type QueryCtx = GenericQueryCtx;
108 |
109 | /**
110 | * A set of services for use within Convex mutation functions.
111 | *
112 | * The mutation context is passed as the first argument to any Convex mutation
113 | * function run on the server.
114 | */
115 | export type MutationCtx = GenericMutationCtx;
116 |
117 | /**
118 | * A set of services for use within Convex action functions.
119 | *
120 | * The action context is passed as the first argument to any Convex action
121 | * function run on the server.
122 | */
123 | export type ActionCtx = GenericActionCtx;
124 |
125 | /**
126 | * An interface to read from the database within Convex query functions.
127 | *
128 | * The two entry points are {@link DatabaseReader.get}, which fetches a single
129 | * document by its {@link Id}, or {@link DatabaseReader.query}, which starts
130 | * building a query.
131 | */
132 | export type DatabaseReader = GenericDatabaseReader;
133 |
134 | /**
135 | * An interface to read from and write to the database within Convex mutation
136 | * functions.
137 | *
138 | * Convex guarantees that all writes within a single mutation are
139 | * executed atomically, so you never have to worry about partial writes leaving
140 | * your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control)
141 | * for the guarantees Convex provides your functions.
142 | */
143 | export type DatabaseWriter = GenericDatabaseWriter;
144 |
--------------------------------------------------------------------------------
/src/component/_generated/server.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /**
3 | * Generated utilities for implementing server-side Convex query and mutation functions.
4 | *
5 | * THIS CODE IS AUTOMATICALLY GENERATED.
6 | *
7 | * To regenerate, run `npx convex dev`.
8 | * @module
9 | */
10 |
11 | import type {
12 | ActionBuilder,
13 | HttpActionBuilder,
14 | MutationBuilder,
15 | QueryBuilder,
16 | GenericActionCtx,
17 | GenericMutationCtx,
18 | GenericQueryCtx,
19 | GenericDatabaseReader,
20 | GenericDatabaseWriter,
21 | } from "convex/server";
22 | import {
23 | actionGeneric,
24 | httpActionGeneric,
25 | queryGeneric,
26 | mutationGeneric,
27 | internalActionGeneric,
28 | internalMutationGeneric,
29 | internalQueryGeneric,
30 | } from "convex/server";
31 | import type { DataModel } from "./dataModel.js";
32 |
33 | /**
34 | * Define a query in this Convex app's public API.
35 | *
36 | * This function will be allowed to read your Convex database and will be accessible from the client.
37 | *
38 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument.
39 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
40 | */
41 | export const query: QueryBuilder = queryGeneric;
42 |
43 | /**
44 | * Define a query that is only accessible from other Convex functions (but not from the client).
45 | *
46 | * This function will be allowed to read from your Convex database. It will not be accessible from the client.
47 | *
48 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument.
49 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
50 | */
51 | export const internalQuery: QueryBuilder =
52 | internalQueryGeneric;
53 |
54 | /**
55 | * Define a mutation in this Convex app's public API.
56 | *
57 | * This function will be allowed to modify your Convex database and will be accessible from the client.
58 | *
59 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
60 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
61 | */
62 | export const mutation: MutationBuilder = mutationGeneric;
63 |
64 | /**
65 | * Define a mutation that is only accessible from other Convex functions (but not from the client).
66 | *
67 | * This function will be allowed to modify your Convex database. It will not be accessible from the client.
68 | *
69 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
70 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
71 | */
72 | export const internalMutation: MutationBuilder =
73 | internalMutationGeneric;
74 |
75 | /**
76 | * Define an action in this Convex app's public API.
77 | *
78 | * An action is a function which can execute any JavaScript code, including non-deterministic
79 | * code and code with side-effects, like calling third-party services.
80 | * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
81 | * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
82 | *
83 | * @param func - The action. It receives an {@link ActionCtx} as its first argument.
84 | * @returns The wrapped action. Include this as an `export` to name it and make it accessible.
85 | */
86 | export const action: ActionBuilder = actionGeneric;
87 |
88 | /**
89 | * Define an action that is only accessible from other Convex functions (but not from the client).
90 | *
91 | * @param func - The function. It receives an {@link ActionCtx} as its first argument.
92 | * @returns The wrapped function. Include this as an `export` to name it and make it accessible.
93 | */
94 | export const internalAction: ActionBuilder =
95 | internalActionGeneric;
96 |
97 | /**
98 | * Define an HTTP action.
99 | *
100 | * The wrapped function will be used to respond to HTTP requests received
101 | * by a Convex deployment if the requests matches the path and method where
102 | * this action is routed. Be sure to route your httpAction in `convex/http.js`.
103 | *
104 | * @param func - The function. It receives an {@link ActionCtx} as its first argument
105 | * and a Fetch API `Request` object as its second.
106 | * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
107 | */
108 | export const httpAction: HttpActionBuilder = httpActionGeneric;
109 |
110 | type GenericCtx =
111 | | GenericActionCtx
112 | | GenericMutationCtx
113 | | GenericQueryCtx;
114 |
115 | /**
116 | * A set of services for use within Convex query functions.
117 | *
118 | * The query context is passed as the first argument to any Convex query
119 | * function run on the server.
120 | *
121 | * If you're using code generation, use the `QueryCtx` type in `convex/_generated/server.d.ts` instead.
122 | */
123 | export type QueryCtx = GenericQueryCtx;
124 |
125 | /**
126 | * A set of services for use within Convex mutation functions.
127 | *
128 | * The mutation context is passed as the first argument to any Convex mutation
129 | * function run on the server.
130 | *
131 | * If you're using code generation, use the `MutationCtx` type in `convex/_generated/server.d.ts` instead.
132 | */
133 | export type MutationCtx = GenericMutationCtx;
134 |
135 | /**
136 | * A set of services for use within Convex action functions.
137 | *
138 | * The action context is passed as the first argument to any Convex action
139 | * function run on the server.
140 | */
141 | export type ActionCtx = GenericActionCtx;
142 |
143 | /**
144 | * An interface to read from the database within Convex query functions.
145 | *
146 | * The two entry points are {@link DatabaseReader.get}, which fetches a single
147 | * document by its {@link Id}, or {@link DatabaseReader.query}, which starts
148 | * building a query.
149 | */
150 | export type DatabaseReader = GenericDatabaseReader;
151 |
152 | /**
153 | * An interface to read from and write to the database within Convex mutation
154 | * functions.
155 | *
156 | * Convex guarantees that all writes within a single mutation are
157 | * executed atomically, so you never have to worry about partial writes leaving
158 | * your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control)
159 | * for the guarantees Convex provides your functions.
160 | */
161 | export type DatabaseWriter = GenericDatabaseWriter;
162 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "[]"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright [yyyy] [name of copyright owner]
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------
/src/component/lib.ts:
--------------------------------------------------------------------------------
1 | import type { FunctionHandle, WithoutSystemFields } from "convex/server";
2 | import { ConvexError, type ObjectType, v } from "convex/values";
3 | import {
4 | type MigrationArgs,
5 | type MigrationResult,
6 | type MigrationStatus,
7 | migrationStatus,
8 | } from "../shared.js";
9 | import { api } from "./_generated/api.js";
10 | import type { Doc } from "./_generated/dataModel.js";
11 | import {
12 | mutation,
13 | type MutationCtx,
14 | query,
15 | type QueryCtx,
16 | } from "./_generated/server.js";
17 |
18 | export type MigrationFunctionHandle = FunctionHandle<
19 | "mutation",
20 | MigrationArgs,
21 | MigrationResult
22 | >;
23 |
24 | const runMigrationArgs = {
25 | name: v.string(),
26 | fnHandle: v.string(),
27 | cursor: v.optional(v.union(v.string(), v.null())),
28 |
29 | batchSize: v.optional(v.number()),
30 | oneBatchOnly: v.optional(v.boolean()),
31 | next: v.optional(
32 | v.array(
33 | v.object({
34 | name: v.string(),
35 | fnHandle: v.string(),
36 | }),
37 | ),
38 | ),
39 | dryRun: v.boolean(),
40 | };
41 |
42 | export const migrate = mutation({
43 | args: runMigrationArgs,
44 | returns: migrationStatus,
45 | handler: async (ctx, args) => {
46 | // Step 1: Get or create the state.
47 | const { fnHandle, batchSize, next: next_, dryRun, name } = args;
48 | if (batchSize !== undefined && batchSize <= 0) {
49 | throw new Error("Batch size must be greater than 0");
50 | }
51 | if (!fnHandle.startsWith("function://")) {
52 | throw new Error(
53 | "Invalid fnHandle.\n" +
54 | "Do not call this from the CLI or dashboard directly.\n" +
55 | "Instead use the `migrations.runner` function to run migrations." +
56 | "See https://www.convex.dev/components/migrations",
57 | );
58 | }
59 | const state =
60 | (await ctx.db
61 | .query("migrations")
62 | .withIndex("name", (q) => q.eq("name", name))
63 | .unique()) ??
64 | (await ctx.db.get(
65 | await ctx.db.insert("migrations", {
66 | name,
67 | cursor: args.cursor ?? null,
68 | isDone: false,
69 | processed: 0,
70 | latestStart: Date.now(),
71 | }),
72 | ))!;
73 |
74 | // Update the state if the cursor arg differs.
75 | if (state.cursor !== args.cursor) {
76 | // This happens if:
77 | // 1. The migration is being started/resumed (args.cursor unset).
78 | // 2. The migration is being resumed at a different cursor.
79 | // 3. There are two instances of the same migration racing.
80 | const worker =
81 | state.workerId && (await ctx.db.system.get(state.workerId));
82 | if (
83 | worker &&
84 | (worker.state.kind === "pending" || worker.state.kind === "inProgress")
85 | ) {
86 | // Case 3. The migration is already in progress.
87 | console.debug({ state, worker });
88 | return getMigrationState(ctx, state);
89 | }
90 | // Case 2. Update the cursor.
91 | if (args.cursor !== undefined) {
92 | state.cursor = args.cursor;
93 | state.isDone = false;
94 | state.latestStart = Date.now();
95 | state.latestEnd = undefined;
96 | state.processed = 0;
97 | }
98 | // For Case 1, Step 2 will take the right action.
99 | }
100 |
101 | function updateState(result: MigrationResult) {
102 | state.cursor = result.continueCursor;
103 | state.isDone = result.isDone;
104 | state.processed += result.processed;
105 | if (result.isDone && state.latestEnd === undefined) {
106 | state.latestEnd = Date.now();
107 | }
108 | }
109 |
110 | try {
111 | // Step 2: Run the migration.
112 | if (!state.isDone) {
113 | const result = await ctx.runMutation(
114 | fnHandle as MigrationFunctionHandle,
115 | {
116 | cursor: state.cursor,
117 | batchSize,
118 | dryRun,
119 | },
120 | );
121 | updateState(result);
122 | state.error = undefined;
123 | }
124 |
125 | // Step 3: Schedule the next batch or next migration.
126 | if (args.oneBatchOnly) {
127 | state.workerId = undefined;
128 | } else if (!state.isDone) {
129 | // Recursively schedule the next batch.
130 | state.workerId = await ctx.scheduler.runAfter(0, api.lib.migrate, {
131 | ...args,
132 | cursor: state.cursor,
133 | });
134 | } else {
135 | state.workerId = undefined;
136 | // Schedule the next migration in the series.
137 | const next = next_ ?? [];
138 | // Find the next migration that hasn't been done.
139 | let i = 0;
140 | for (; i < next.length; i++) {
141 | const doc = await ctx.db
142 | .query("migrations")
143 | .withIndex("name", (q) => q.eq("name", next[i]!.name))
144 | .unique();
145 | if (!doc || !doc.isDone) {
146 | const [nextFn, ...rest] = next.slice(i);
147 | if (nextFn) {
148 | await ctx.scheduler.runAfter(0, api.lib.migrate, {
149 | name: nextFn.name,
150 | fnHandle: nextFn.fnHandle,
151 | next: rest,
152 | batchSize,
153 | dryRun,
154 | });
155 | }
156 | break;
157 | }
158 | }
159 | if (args.cursor === undefined) {
160 | if (next.length && i === next.length) {
161 | console.info(`Migration${i > 0 ? "s" : ""} up next already done.`);
162 | }
163 | } else {
164 | console.info(
165 | `Migration ${name} is done.` +
166 | (i < next.length ? ` Next: ${next[i]!.name}` : ""),
167 | );
168 | }
169 | }
170 | } catch (e) {
171 | state.workerId = undefined;
172 | if (dryRun && e instanceof ConvexError && e.data.kind === "DRY RUN") {
173 | // Add the state to the error to bubble up.
174 | updateState(e.data.result);
175 | } else {
176 | state.error = e instanceof Error ? e.message : String(e);
177 | console.error(`Migration ${name} failed: ${state.error}`);
178 | }
179 | if (dryRun) {
180 | const status = await getMigrationState(ctx, state);
181 | status.batchSize = batchSize;
182 | status.next = next_?.map((n) => n.name);
183 | throw new ConvexError({
184 | kind: "DRY RUN",
185 | status,
186 | });
187 | }
188 | }
189 |
190 | // Step 4: Update the state
191 | await ctx.db.patch(state._id, state);
192 | if (args.dryRun) {
193 | // By throwing an error, the transaction will be rolled back and nothing
194 | // will be scheduled.
195 | console.debug({ args, state });
196 | throw new Error(
197 | "Error: Dry run attempted to update state - rolling back transaction.",
198 | );
199 | }
200 | return getMigrationState(ctx, state);
201 | },
202 | });
203 |
204 | export const getStatus = query({
205 | args: {
206 | names: v.optional(v.array(v.string())),
207 | limit: v.optional(v.number()),
208 | },
209 | returns: v.array(migrationStatus),
210 | handler: async (ctx, args) => {
211 | const docs = args.names
212 | ? await Promise.all(
213 | args.names.map(
214 | async (m) =>
215 | (await ctx.db
216 | .query("migrations")
217 | .withIndex("name", (q) => q.eq("name", m))
218 | .unique()) ?? {
219 | name: m,
220 | processed: 0,
221 | cursor: null,
222 | latestStart: 0,
223 | workerId: undefined,
224 | isDone: false as const,
225 | },
226 | ),
227 | )
228 | : await ctx.db
229 | .query("migrations")
230 | .order("desc")
231 | .take(args.limit ?? 10);
232 |
233 | return Promise.all(
234 | docs
235 | .reverse()
236 | .map(async (migration) => getMigrationState(ctx, migration)),
237 | );
238 | },
239 | });
240 |
241 | async function getMigrationState(
242 | ctx: QueryCtx,
243 | migration: WithoutSystemFields>,
244 | ): Promise {
245 | const worker =
246 | migration.workerId && (await ctx.db.system.get(migration.workerId));
247 | const args = worker?.args[0] as
248 | | ObjectType
249 | | undefined;
250 | const state = migration.isDone
251 | ? "success"
252 | : migration.error || worker?.state.kind === "failed"
253 | ? "failed"
254 | : worker?.state.kind === "canceled"
255 | ? "canceled"
256 | : worker?.state.kind === "inProgress" ||
257 | worker?.state.kind === "pending"
258 | ? "inProgress"
259 | : "unknown";
260 | return {
261 | name: migration.name,
262 | cursor: migration.cursor,
263 | processed: migration.processed,
264 | isDone: migration.isDone,
265 | latestStart: migration.latestStart,
266 | latestEnd: migration.latestEnd,
267 | error: migration.error,
268 | state,
269 | batchSize: args?.batchSize,
270 | next: args?.next?.map((n: { name: string }) => n.name),
271 | };
272 | }
273 |
274 | export const cancel = mutation({
275 | args: { name: v.string() },
276 | returns: migrationStatus,
277 | handler: async (ctx, args) => {
278 | const migration = await ctx.db
279 | .query("migrations")
280 | .withIndex("name", (q) => q.eq("name", args.name))
281 | .unique();
282 |
283 | if (!migration) {
284 | throw new Error(`Migration ${args.name} not found`);
285 | }
286 | const state = await cancelMigration(ctx, migration);
287 | if (state.state !== "canceled") {
288 | console.log(
289 | `Did not cancel migration ${migration.name}. Status was ${state.state}`,
290 | );
291 | }
292 | return state;
293 | },
294 | });
295 |
296 | async function cancelMigration(ctx: MutationCtx, migration: Doc<"migrations">) {
297 | const state = await getMigrationState(ctx, migration);
298 | if (state.isDone) {
299 | return state;
300 | }
301 | if (state.state === "inProgress") {
302 | if (!migration.workerId) {
303 | await ctx.scheduler.cancel(migration.workerId!);
304 | }
305 | console.log(`Canceled migration ${migration.name}`);
306 | return { ...state, state: "canceled" as const };
307 | }
308 | return state;
309 | }
310 |
311 | export const cancelAll = mutation({
312 | // Paginating with creation time for now
313 | args: { sinceTs: v.optional(v.number()) },
314 | returns: v.array(migrationStatus),
315 | handler: async (ctx, args) => {
316 | const results = await ctx.db
317 | .query("migrations")
318 | .withIndex("isDone", (q) =>
319 | args.sinceTs
320 | ? q.eq("isDone", false).gte("_creationTime", args.sinceTs)
321 | : q.eq("isDone", false),
322 | )
323 | .take(100);
324 | if (results.length === 100) {
325 | await ctx.scheduler.runAfter(0, api.lib.cancelAll, {
326 | sinceTs: results[results.length - 1]!._creationTime,
327 | });
328 | }
329 | return Promise.all(results.map((m) => cancelMigration(ctx, m)));
330 | },
331 | });
332 |
333 | export const clearAll = mutation({
334 | args: { before: v.optional(v.number()) },
335 | returns: v.null(),
336 | handler: async (ctx, args) => {
337 | const results = await ctx.db
338 | .query("migrations")
339 | .withIndex("by_creation_time", (q) =>
340 | q.lte("_creationTime", args.before ?? Date.now()),
341 | )
342 | .order("desc")
343 | .take(100);
344 | for (const m of results) {
345 | await ctx.db.delete(m._id);
346 | }
347 | if (results.length === 100) {
348 | await ctx.scheduler.runAfter(0, api.lib.clearAll, {
349 | before: results[99]._creationTime,
350 | });
351 | }
352 | },
353 | });
354 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Convex Stateful Migrations Component
2 |
3 | [](https://badge.fury.io/js/@convex-dev%2Fmigrations)
4 |
5 |
6 |
7 | Define and run migrations, like this one setting a default value for users:
8 |
9 | ```ts
10 | export const setDefaultValue = migrations.define({
11 | table: "users",
12 | migrateOne: async (ctx, user) => {
13 | if (user.optionalField === undefined) {
14 | await ctx.db.patch(user._id, { optionalField: "default" });
15 | }
16 | },
17 | });
18 | ```
19 |
20 | You can then run it programmatically or from the CLI. See
21 | [below](#running-migrations-one-at-a-time).
22 |
23 | Migrations allow you to define functions that run on all documents in a table
24 | (or a specified subset). They run in batches asynchronously (online migration).
25 |
26 | The component tracks the migrations state so it can avoid running twice, pick up
27 | where it left off (in the case of a bug or failure along the way), and expose
28 | the migration state in realtime via Convex queries.
29 |
30 | See the [migration primer post](https://stack.convex.dev/intro-to-migrations)
31 | for a conceptual overview of online vs. offline migrations. If your migration is
32 | trivial and you're moving fast, also check out
33 | [lightweight migrations in the dashboard](https://stack.convex.dev/lightweight-zero-downtime-migrations).
34 |
35 | Typical steps for doing a migration:
36 |
37 | 1. Modify your schema to allow old and new values. Typically this is adding a
38 | new optional field or marking a field as optional so it can be deleted. As
39 | part of this, update your code to handle both versions.
40 | 2. Define a migration to change the data to the new schema.
41 | 3. Push the migration and schema changes.
42 | 4. Run the migration(s) to completion.
43 | 5. Modify your schema and code to assume the new value. Pushing this change will
44 | only succeed once all the data matches the new schema. This is the default
45 | behavior for Convex, unless you disable schema validation.
46 |
47 | See [this Stack post](https://stack.convex.dev/migrating-data-with-mutations)
48 | for walkthroughs of common use cases.
49 |
50 | ## Pre-requisite: Convex
51 |
52 | You'll need an existing Convex project to use the component. Convex is a hosted
53 | backend platform, including a database, serverless functions, and a ton more you
54 | can learn about [here](https://docs.convex.dev/get-started).
55 |
56 | Run `npm create convex` or follow any of the
57 | [quickstarts](https://docs.convex.dev/home) to set one up.
58 |
59 | ## Installation
60 |
61 | Install the component package:
62 |
63 | ```ts
64 | npm install @convex-dev/migrations
65 | ```
66 |
67 | Create a `convex.config.ts` file in your app's `convex/` folder and install the
68 | component by calling `use`:
69 |
70 | ```ts
71 | // convex/convex.config.ts
72 | import { defineApp } from "convex/server";
73 | import migrations from "@convex-dev/migrations/convex.config.js";
74 |
75 | const app = defineApp();
76 | app.use(migrations);
77 |
78 | export default app;
79 | ```
80 |
81 | ## Initialization
82 |
83 | Examples below are assuming the code is in `convex/migrations.ts`. This is not a
84 | requirement. If you want to use a different file, make sure to change the
85 | examples below from `internal.migrations.*` to your new file name, like
86 | `internal.myFolder.myMigrationsFile.*` or CLI arguments like `migrations:*` to
87 | `myFolder/myMigrationsFile:*`.
88 |
89 | ```ts
90 | import { Migrations } from "@convex-dev/migrations";
91 | import { components } from "./_generated/api.js";
92 | import { DataModel } from "./_generated/dataModel.js";
93 |
94 | export const migrations = new Migrations(components.migrations);
95 | export const run = migrations.runner();
96 | ```
97 |
98 | The type parameter `DataModel` is optional. It provides type safety for
99 | migration definitions. As always, database operations in migrations will abide
100 | by your schema definition at runtime. **Note**: if you use
101 | [custom functions](https://stack.convex.dev/custom-functions) to override
102 | `internalMutation`, see
103 | [below](#override-the-internalmutation-to-apply-custom-db-behavior).
104 |
105 | ## Define migrations
106 |
107 | Within the `migrateOne` function, you can write code to modify a single document
108 | in the specified table. Making changes is optional, and you can also read and
109 | write to other tables from this function.
110 |
111 | ```ts
112 | export const setDefaultValue = migrations.define({
113 | table: "myTable",
114 | migrateOne: async (ctx, doc) => {
115 | if (doc.optionalField === undefined) {
116 | await ctx.db.patch(doc._id, { optionalField: "default" });
117 | }
118 | },
119 | });
120 | ```
121 |
122 | ### Shorthand syntax
123 |
124 | Since the most common migration involves patching each document, if you return
125 | an object, it will be applied as a patch automatically.
126 |
127 | ```ts
128 | export const clearField = migrations.define({
129 | table: "myTable",
130 | migrateOne: () => ({ optionalField: undefined }),
131 | });
132 | // is equivalent to `await ctx.db.patch(doc._id, { optionalField: undefined })`
133 | ```
134 |
135 | ### Migrating a subset of a table using an index
136 |
137 | If you only want to migrate a range of documents, you can avoid processing the
138 | whole table by specifying a `customRange`. You can use any existing index you
139 | have on the table, or the built-in `by_creation_time` index.
140 |
141 | ```ts
142 | export const validateRequiredField = migrations.define({
143 | table: "myTable",
144 | customRange: (query) =>
145 | query.withIndex("by_requiredField", (q) => q.eq("requiredField", "")),
146 | migrateOne: async (_ctx, doc) => {
147 | console.log("Needs fixup: " + doc._id);
148 | // Shorthand for patching
149 | return { requiredField: "" };
150 | },
151 | });
152 | ```
153 |
154 | ## Running migrations one at a time
155 |
156 | ### Using the Dashboard or CLI
157 |
158 | To define a one-off function to run a single migration, pass a reference to it:
159 |
160 | ```ts
161 | export const runIt = migrations.runner(internal.migrations.setDefaultValue);
162 | ```
163 |
164 | To run it from the CLI:
165 |
166 | ```sh
167 | npx convex run convex/migrations.ts:runIt # or shorthand: migrations:runIt
168 | ```
169 |
170 | **Note**: pass the `--prod` argument to run this and below commands in
171 | production
172 |
173 | Running it from the dashboard:
174 |
175 | 
176 |
177 | You can also expose a general-purpose function to run your migrations. For
178 | example, in `convex/migrations.ts` add:
179 |
180 | ```ts
181 | export const run = migrations.runner();
182 | ```
183 |
184 | Then run it with the
185 | [function name](https://docs.convex.dev/functions/query-functions#query-names):
186 |
187 | ```sh
188 | npx convex run migrations:run '{fn: "migrations:setDefaultValue"}'
189 | ```
190 |
191 | See [below](#shorthand-running-syntax) for a way to just pass `setDefaultValue`.
192 |
193 | ### Programmatically
194 |
195 | You can also run migrations from other Convex mutations or actions:
196 |
197 | ```ts
198 | await migrations.runOne(ctx, internal.example.setDefaultValue);
199 | ```
200 |
201 | ### Behavior
202 |
203 | - If it is already running it will refuse to start another duplicate worker.
204 | - If it had previously failed on some batch, it will continue from that batch
205 | unless you manually specify `cursor`.
206 | - If you provide an explicit `cursor` (`null` means to start at the beginning),
207 | it will start from there.
208 | - If you pass `true` for `dryRun` then it will run one batch and then throw, so
209 | no changes are committed, and you can see what it would have done. See
210 | [below](#test-a-migration-with-dryrun) This is good for validating it does
211 | what you expect.
212 |
213 | ## Running migrations serially
214 |
215 | You can run a series of migrations in order. This is useful if some migrations
216 | depend on previous ones, or if you keep a running list of all migrations that
217 | should run on the next deployment.
218 |
219 | ### Using the Dashboard or CLI
220 |
221 | You can also pass a list of migrations to `runner` to have it run a series of
222 | migrations instead of just one:
223 |
224 | ```ts
225 | export const runAll = migrations.runner([
226 | internal.migrations.setDefaultValue,
227 | internal.migrations.validateRequiredField,
228 | internal.migrations.convertUnionField,
229 | ]);
230 | ```
231 |
232 | Then just run:
233 |
234 | ```sh
235 | npx convex run migrations:runAll # migrations:runAll is equivalent to convex/migrations.ts:runAll on the CLI
236 | ```
237 |
238 | With the `runner` functions, you can pass a "next" argument to run a series of
239 | migrations after the first:
240 |
241 | ```sh
242 | npx convex run migrations:runIt '{next:["migrations:clearField"]}'
243 | # OR
244 | npx convex run migrations:run '{fn: "migrations:setDefaultValue", next:["migrations:clearField"]}'
245 | ```
246 |
247 | ### Programmatically
248 |
249 | ```ts
250 | await migrations.runSerially(ctx, [
251 | internal.migrations.setDefaultValue,
252 | internal.migrations.validateRequiredField,
253 | internal.migrations.convertUnionField,
254 | ]);
255 | ```
256 |
257 | ### Behavior
258 |
259 | - If a migration is already in progress when attempted, it will no-op.
260 | - If a migration had already completed, it will skip it.
261 | - If a migration had partial progress, it will resume from where it left off.
262 | - If a migration fails or is canceled, it will not continue on, in case you had
263 | some dependencies between the migrations. Call the series again to retry.
264 |
265 | Note: if you start multiple serial migrations, the behavior is:
266 |
267 | - If they don't overlap on functions, they will happily run in parallel.
268 | - If they have a function in common and one completes before the other attempts
269 | it, the second will just skip it.
270 | - If they have a function in common and one is in progress, the second will
271 | no-op and not run any further migrations in its series.
272 |
273 | ## Operations
274 |
275 | ### Test a migration with dryRun
276 |
277 | Before running a migration that may irreversibly change data, you can validate a
278 | batch by passing `dryRun` to any `runner` or `runOne` command:
279 |
280 | ```sh
281 | npx convex run migrations:runIt '{dryRun: true}'
282 | ```
283 |
284 | ### Restart a migration
285 |
286 | Pass `null` for the `cursor` to force a migration to start over.
287 |
288 | ```sh
289 | npx convex run migrations:runIt '{cursor: null}'
290 | ```
291 |
292 | You can also pass in any valid cursor to start from. You can find valid cursors
293 | in the response of calls to `getStatus`. This can allow retrying a migration
294 | from a known good point as you iterate on the code.
295 |
296 | ### Stop a migration
297 |
298 | You can stop a migration from the CLI or dashboard, calling the component API
299 | directly:
300 |
301 | ```sh
302 | npx convex run --component migrations lib:cancel '{name: "migrations:myMigration"}'
303 | ```
304 |
305 | Or via `migrations.cancel` programatically.
306 |
307 | ```ts
308 | await migrations.cancel(ctx, internal.migrations.myMigration);
309 | ```
310 |
311 | ### Get the status of migrations
312 |
313 | To see the live status of migrations as they progress, you can query it via the
314 | CLI:
315 |
316 | ```sh
317 | npx convex run --component migrations lib:getStatus --watch
318 | ```
319 |
320 | The `--watch` will live-update the status as it changes. Or programmatically:
321 |
322 | ```ts
323 | const status: MigrationStatus[] = await migrations.getStatus(ctx, {
324 | limit: 10,
325 | });
326 | // or
327 | const status: MigrationStatus[] = await migrations.getStatus(ctx, {
328 | migrations: [
329 | internal.migrations.setDefaultValue,
330 | internal.migrations.validateRequiredField,
331 | internal.migrations.convertUnionField,
332 | ],
333 | });
334 | ```
335 |
336 | The type is annotated to avoid circular type dependencies, for instance if you
337 | are returning the result from a query that is defined in the same file as the
338 | referenced migrations.
339 |
340 | ### Running migrations as part of a production deploy
341 |
342 | As part of your build and deploy command, you can chain the corresponding
343 | `npx convex run` command, such as:
344 |
345 | ```sh
346 | npx convex deploy --cmd 'npm run build' && npx convex run convex/migrations.ts:runAll --prod
347 | ```
348 |
349 | ## Configuration options
350 |
351 | ### Override the internalMutation to apply custom DB behavior
352 |
353 | You can customize which `internalMutation` implementation the underly migration
354 | should use.
355 |
356 | This might be important if you use
357 | [custom functions](https://stack.convex.dev/custom-functions) to intercept
358 | database writes to apply validation or
359 | [trigger operations on changes](https://stack.convex.dev/triggers).
360 |
361 | Assuming you define your own `internalMutation` in `convex/functions.ts`:
362 |
363 | ```ts
364 | import { internalMutation } from "./functions";
365 | import { Migrations } from "@convex-dev/migrations";
366 | import { components } from "./_generated/api";
367 |
368 | export const migrations = new Migrations(components.migrations, {
369 | internalMutation,
370 | });
371 | ```
372 |
373 | See [this article](https://stack.convex.dev/migrating-data-with-mutations) for
374 | more information on usage and advanced patterns.
375 |
376 | ### Custom batch size
377 |
378 | The component will fetch your data in batches of 100, and call your function on
379 | each document in a batch. If you want to change the batch size, you can specify
380 | it. This can be useful if your documents are large, to avoid running over the
381 | [transaction limit](https://docs.convex.dev/production/state/limits#transactions),
382 | or if your documents are updating frequently and you are seeing OCC conflicts
383 | while migrating.
384 |
385 | ```ts
386 | export const clearField = migrations.define({
387 | table: "myTable",
388 | batchSize: 10,
389 | migrateOne: () => ({ optionalField: undefined }),
390 | });
391 | ```
392 |
393 | You can also override this batch size for an individual invocation:
394 |
395 | ```ts
396 | await migrations.runOne(ctx, internal.migrations.clearField, {
397 | batchSize: 1,
398 | });
399 | ```
400 |
401 | ### Parallelizing batches
402 |
403 | Each batch is processed serially, but within a batch you can have each
404 | `migrateOne` call run in parallel if you pass `parallelize: true`. If you do so,
405 | ensure your callback doesn't assume that each call is isolated. For instance, if
406 | each call reads then updates the same counter, then multiple functions in the
407 | same batch could read the same counter value, and get off by one. As a result,
408 | migrations are run serially by default.
409 |
410 | ```ts
411 | export const clearField = migrations.define({
412 | table: "myTable",
413 | parallelize: true,
414 | migrateOne: () => ({ optionalField: undefined }),
415 | });
416 | ```
417 |
418 | ### Shorthand running syntax:
419 |
420 | For those that don't want to type out `migrations:myNewMigration` every time
421 | they run a migration from the CLI or dashboard, especially if you define your
422 | migrations elsewhere like `ops/db/migrations:myNewMigration`, you can configure
423 | a prefix:
424 |
425 | ```ts
426 | export const migrations = new Migrations(components.migrations, {
427 | internalMigration,
428 | migrationsLocationPrefix: "migrations:",
429 | });
430 | ```
431 |
432 | And then just call:
433 |
434 | ```sh
435 | npx convex run migrations:run '{fn: "myNewMutation", next: ["myNextMutation"]}'
436 | ```
437 |
438 | Or in code:
439 |
440 | ```ts
441 | await migrations.getStatus(ctx, { migrations: ["myNewMutation"] });
442 | await migrations.cancel(ctx, "myNewMutation");
443 | ```
444 |
445 | ## Running migrations synchronously
446 |
447 | If you want to run a migration synchronously from a test or action, you can use
448 | `runToCompletion`. Note that if the action crashes or is canceled, it will not
449 | continue migrating in the background.
450 |
451 | From an action:
452 |
453 | ```ts
454 | import { components, internal } from "./_generated/api";
455 | import { internalAction } from "./_generated/server";
456 | import { runToCompletion } from "@convex-dev/migrations";
457 |
458 | export const myAction = internalAction({
459 | args: {},
460 | handler: async (ctx) => {
461 | //...
462 | const toRun = internal.example.setDefaultValue;
463 | await runToCompletion(ctx, components.migrations, toRun);
464 | },
465 | });
466 | ```
467 |
468 | In a test:
469 |
470 | ```ts
471 | import { test } from "vitest";
472 | import { convexTest } from "convex-test";
473 | import component from "@convex-dev/migrations/test";
474 | import { runToCompletion } from "@convex-dev/migrations";
475 | import { components, internal } from "./_generated/api";
476 | import schema from "./schema";
477 |
478 | test("test setDefaultValue migration", async () => {
479 | const t = convexTest(schema);
480 | // Register the component in the test instance
481 | component.register(t);
482 |
483 | await t.run(async (ctx) => {
484 | // Add sample data to migrate
485 | await ctx.db.insert("myTable", { optionalField: undefined });
486 |
487 | // Run the migration to completion
488 | const migrationToTest = internal.example.setDefaultValue;
489 | await runToCompletion(ctx, components.migrations, migrationToTest);
490 |
491 | // Assert that the migration was successful by checking the data
492 | const docs = await ctx.db.query("myTable").collect();
493 | expect(docs.every((doc) => doc.optionalField !== undefined)).toBe(true);
494 | });
495 | });
496 | ```
497 |
498 |
499 |
--------------------------------------------------------------------------------
/src/client/index.ts:
--------------------------------------------------------------------------------
1 | import {
2 | createFunctionHandle,
3 | type DocumentByName,
4 | type FunctionReference,
5 | type GenericActionCtx,
6 | type GenericDataModel,
7 | type GenericMutationCtx,
8 | type GenericQueryCtx,
9 | getFunctionAddress,
10 | getFunctionName,
11 | internalMutationGeneric,
12 | makeFunctionReference,
13 | type MutationBuilder,
14 | type NamedTableInfo,
15 | type OrderedQuery,
16 | type QueryInitializer,
17 | type RegisteredMutation,
18 | type TableNamesInDataModel,
19 | } from "convex/server";
20 | import {
21 | type MigrationArgs,
22 | migrationArgs,
23 | type MigrationResult,
24 | type MigrationStatus,
25 | } from "../shared.js";
26 | export type { MigrationArgs, MigrationResult, MigrationStatus };
27 |
28 | import { ConvexError, type GenericId } from "convex/values";
29 | import type { ComponentApi } from "../component/_generated/component.js";
30 | import { logStatusAndInstructions } from "./log.js";
31 | import type { MigrationFunctionHandle } from "../component/lib.js";
32 |
33 | // Note: this value is hard-coded in the docstring below. Please keep in sync.
34 | export const DEFAULT_BATCH_SIZE = 100;
35 |
36 | export class Migrations {
37 | /**
38 | * Makes the migration wrapper, with types for your own tables.
39 | *
40 | * It will keep track of migration state.
41 | * Add in convex/migrations.ts for example:
42 | * ```ts
43 | * import { Migrations } from "@convex-dev/migrations";
44 | * import { components } from "./_generated/api.js";
45 | * import { internalMutation } from "./_generated/server";
46 | *
47 | * export const migrations = new Migrations(components.migrations, { internalMutation });
48 | * // the private mutation to run migrations.
49 | * export const run = migrations.runner();
50 | *
51 | * export const myMigration = migrations.define({
52 | * table: "users",
53 | * migrateOne: async (ctx, doc) => {
54 | * await ctx.db.patch(doc._id, { someField: "value" });
55 | * }
56 | * });
57 | * ```
58 | * You can then run it from the CLI or dashboard:
59 | * ```sh
60 | * npx convex run migrations:run '{"fn": "migrations:myMigration"}'
61 | * ```
62 | * For starting a migration from code, see {@link runOne}/{@link runSerially}.
63 | * @param component - The migrations component. It will be on components.migrations
64 | * after being configured in in convex.config.js.
65 | * @param options - Configure options and set the internalMutation to use.
66 | */
67 | constructor(
68 | public component: ComponentApi,
69 | public options?: {
70 | /**
71 | * Uses the internal mutation to run the migration.
72 | * This also provides the types for your tables.
73 | * ```ts
74 | * import { internalMutation } from "./_generated/server.js";
75 | * ```
76 | */
77 | internalMutation?: MutationBuilder;
78 | /**
79 | * How many documents to process in a batch.
80 | * Your migrateOne function will be called for each document in a batch in
81 | * a single transaction.
82 | */
83 | defaultBatchSize?: number;
84 | /**
85 | * Prefix to add to the function name when running migrations.
86 | * For example, if you have a function named "foo" in a file
87 | * "convex/bar/baz.ts", you can set {migrationsLocationPrefix: "bar/baz:"}
88 | * and then run:
89 | * ```sh
90 | * npx convex run migrations:run '{"fn": "foo"}'
91 | * ```
92 | */
93 | migrationsLocationPrefix?: string;
94 | },
95 | ) {}
96 |
97 | /**
98 | * Creates a migration runner that can be called from the CLI or dashboard.
99 | *
100 | * For starting a migration from code, see {@link runOne}/{@link runSerially}.
101 | *
102 | * It can be created for a specific migration:
103 | * ```ts
104 | * export const runMyMigration = runner(internal.migrations.myMigration);
105 | * ```
106 | * CLI: `npx convex run migrations:runMyMigration`
107 | *
108 | * Or for any migration:
109 | * ```ts
110 | * export const run = runner();
111 | * ```
112 | * CLI: `npx convex run migrations:run '{"fn": "migrations:myMigration"}'`
113 | *
114 | * Where `myMigration` is the name of the migration function, defined in
115 | * "convex/migrations.ts" along with the run function.
116 | *
117 | * @param specificMigration If you want a migration runner for one migration,
118 | * pass in the migration function reference like `internal.migrations.foo`.
119 | * Otherwise it will be a generic runner that requires the migration name.
120 | * @returns An internal mutation,
121 | */
122 | runner(
123 | specificMigrationOrSeries?:
124 | | MigrationFunctionReference
125 | | MigrationFunctionReference[],
126 | ) {
127 | return internalMutationGeneric({
128 | args: migrationArgs,
129 | handler: async (ctx, args) => {
130 | const [specificMigration, next] = Array.isArray(
131 | specificMigrationOrSeries,
132 | )
133 | ? [
134 | specificMigrationOrSeries[0],
135 | await Promise.all(
136 | specificMigrationOrSeries.slice(1).map(async (fnRef) => ({
137 | name: getFunctionName(fnRef),
138 | fnHandle: await createFunctionHandle(fnRef),
139 | })),
140 | ),
141 | ]
142 | : [specificMigrationOrSeries, undefined];
143 | if (args.fn && specificMigration) {
144 | throw new Error("Specify only one of fn or specificMigration");
145 | }
146 | if (!args.fn && !specificMigration) {
147 | throw new Error(
148 | `Specify the migration: '{"fn": "migrations:foo"}'\n` +
149 | "Or initialize a `runner` runner specific to the migration like\n" +
150 | "`export const runMyMigration = runner(internal.migrations.myMigration)`",
151 | );
152 | }
153 | return await this._runInteractive(ctx, args, specificMigration, next);
154 | },
155 | });
156 | }
157 |
158 | private async _runInteractive(
159 | ctx: MutationCtx | ActionCtx,
160 | args: MigrationArgs,
161 | fnRef?: MigrationFunctionReference,
162 | next?: { name: string; fnHandle: string }[],
163 | ) {
164 | const name = args.fn ? this.prefixedName(args.fn) : getFunctionName(fnRef!);
165 | async function makeFn(fn: string) {
166 | try {
167 | return await createFunctionHandle(
168 | makeFunctionReference<"mutation">(fn),
169 | );
170 | } catch {
171 | throw new Error(
172 | `Can't find function ${fn}\n` +
173 | "The name should match the folder/file:method\n" +
174 | "See https://docs.convex.dev/functions/query-functions#query-names",
175 | );
176 | }
177 | }
178 | const fnHandle = args.fn
179 | ? await makeFn(name)
180 | : await createFunctionHandle(fnRef!);
181 | if (args.next) {
182 | next = (next ?? []).concat(
183 | await Promise.all(
184 | args.next.map(async (nextFn) => ({
185 | name: this.prefixedName(nextFn),
186 | fnHandle: await makeFn(this.prefixedName(nextFn)),
187 | })),
188 | ),
189 | );
190 | }
191 | let status: MigrationStatus;
192 | try {
193 | status = await ctx.runMutation(this.component.lib.migrate, {
194 | name,
195 | fnHandle,
196 | cursor: args.cursor,
197 | batchSize: args.batchSize,
198 | next,
199 | dryRun: args.dryRun ?? false,
200 | });
201 | } catch (e) {
202 | if (
203 | args.dryRun &&
204 | e instanceof ConvexError &&
205 | e.data.kind === "DRY RUN"
206 | ) {
207 | status = e.data.status;
208 | } else {
209 | throw e;
210 | }
211 | }
212 |
213 | return logStatusAndInstructions(name, status, args);
214 | }
215 |
216 | /**
217 | * Use this to wrap a mutation that will be run over all documents in a table.
218 | * Your mutation only needs to handle changing one document at a time,
219 | * passed into migrateOne.
220 | * Optionally specify a custom batch size to override the default (100).
221 | *
222 | * In convex/migrations.ts for example:
223 | * ```ts
224 | * export const foo = migrations.define({
225 | * table: "users",
226 | * migrateOne: async (ctx, doc) => {
227 | * await ctx.db.patch(doc._id, { someField: "value" });
228 | * },
229 | * });
230 | * ```
231 | *
232 | * You can run this manually from the CLI or dashboard:
233 | * ```sh
234 | * # Start or resume a migration. No-ops if it's already done:
235 | * npx convex run migrations:run '{"fn": "migrations:foo"}'
236 | *
237 | * # Restart a migration from a cursor (null is from the beginning):
238 | * npx convex run migrations:run '{"fn": "migrations:foo", "cursor": null }'
239 | *
240 | * # Dry run - runs one batch but doesn't schedule or commit changes.
241 | * # so you can see what it would do without committing the transaction.
242 | * npx convex run migrations:run '{"fn": "migrations:foo", "dryRun": true}'
243 | * # or
244 | * npx convex run migrations:myMigration '{"dryRun": true}'
245 | *
246 | * # Run many migrations serially:
247 | * npx convex run migrations:run '{"fn": "migrations:foo", "next": ["migrations:bar", "migrations:baz"] }'
248 | * ```
249 | *
250 | * The fn is the string form of the function reference. See:
251 | * https://docs.convex.dev/functions/query-functions#query-names
252 | *
253 | * See {@link runOne} and {@link runSerially} for programmatic use.
254 | *
255 | * @param table - The table to run the migration over.
256 | * @param migrateOne - The function to run on each document.
257 | * @param batchSize - The number of documents to process in a batch.
258 | * If not set, defaults to the value passed to makeMigration,
259 | * or {@link DEFAULT_BATCH_SIZE}. Overriden by arg at runtime if supplied.
260 | * @param parallelize - If true, each migration batch will be run in parallel.
261 | * @returns An internal mutation that runs the migration.
262 | */
263 | define>({
264 | table,
265 | migrateOne,
266 | customRange,
267 | batchSize: functionDefaultBatchSize,
268 | parallelize,
269 | }: {
270 | table: TableName;
271 | migrateOne: (
272 | ctx: GenericMutationCtx,
273 | doc: DocumentByName & { _id: GenericId },
274 | ) =>
275 | | void
276 | | Partial>
277 | | Promise> | void>;
278 | customRange?: (
279 | q: QueryInitializer>,
280 | ) => OrderedQuery>;
281 | batchSize?: number;
282 | parallelize?: boolean;
283 | }) {
284 | const defaultBatchSize =
285 | functionDefaultBatchSize ??
286 | this.options?.defaultBatchSize ??
287 | DEFAULT_BATCH_SIZE;
288 | // Under the hood it's an internal mutation that calls the migrateOne
289 | // function for every document in a page, recursively scheduling batches.
290 | return (
291 | (this.options?.internalMutation as MutationBuilder<
292 | DataModel,
293 | "internal"
294 | >) ?? (internalMutationGeneric as MutationBuilder)
295 | )({
296 | args: migrationArgs,
297 | handler: async (ctx, args) => {
298 | if (args.fn) {
299 | // This is a one-off execution from the CLI or dashboard.
300 | // While not the recommended appproach, it's helpful for one-offs and
301 | // compatibility with the old way of running migrations.
302 |
303 | return (await this._runInteractive(ctx, args)) as any;
304 | } else if (args.next?.length) {
305 | throw new Error("You can only pass next if you also provide fn");
306 | } else if (
307 | args.cursor === undefined ||
308 | args.cursor === "" ||
309 | args.dryRun === undefined ||
310 | args.batchSize === 0
311 | ) {
312 | console.warn(
313 | "Running this from the CLI or dashboard? Here's some args to use:",
314 | );
315 | console.warn({
316 | "Dry run": '{ "dryRun": true, "cursor": null }',
317 | "For real": '{ "fn": "path/to/migrations:yourFnName" }',
318 | });
319 | }
320 |
321 | const numItems = args.batchSize || defaultBatchSize;
322 | if (args.cursor === undefined || args.cursor === "") {
323 | if (args.dryRun === undefined) {
324 | console.warn(
325 | "No cursor or dryRun specified - doing a dry run on the next batch.",
326 | );
327 | args.cursor = null;
328 | args.dryRun = true;
329 | } else if (args.dryRun) {
330 | console.warn("Setting cursor to null for dry run");
331 | args.cursor = null;
332 | } else {
333 | throw new Error(`Cursor must be specified for a one-off execution.
334 | Use null to start from the beginning.
335 | Use the value in the migrations database to pick up from where it left off.`);
336 | }
337 | }
338 |
339 | const q = ctx.db.query(table);
340 | const range = customRange ? customRange(q) : q;
341 | let continueCursor: string;
342 | let page: DocumentByName[];
343 | let isDone: boolean;
344 | try {
345 | ({ continueCursor, page, isDone } = await range.paginate({
346 | cursor: args.cursor,
347 | numItems,
348 | }));
349 | } catch (e) {
350 | console.error(
351 | "Error paginating. This can happen if the migration " +
352 | "was initially run on a different table, different custom range, " +
353 | "or you upgraded convex-helpers with in-progress migrations. " +
354 | "This creates an invalid pagination cursor. " +
355 | "Run all migrations to completion on the old cursor, or re-run " +
356 | "them explicitly with the cursor set to null. " +
357 | "If the problem persists, contact support@convex.dev",
358 | );
359 | throw e;
360 | }
361 | async function doOne(doc: DocumentByName) {
362 | try {
363 | const next = await migrateOne(
364 | ctx,
365 | doc as { _id: GenericId },
366 | );
367 | if (next && Object.keys(next).length > 0) {
368 | await ctx.db.patch(doc._id as GenericId, next);
369 | }
370 | } catch (error) {
371 | console.error(`Document failed: ${doc._id}`);
372 | throw error;
373 | }
374 | }
375 | if (parallelize) {
376 | await Promise.all(page.map(doOne));
377 | } else {
378 | for (const doc of page) {
379 | await doOne(doc);
380 | }
381 | }
382 | const result = {
383 | continueCursor,
384 | isDone,
385 | processed: page.length,
386 | };
387 | if (args.dryRun) {
388 | // Throwing an error rolls back the transaction
389 | let anyChanges = false;
390 | let printedChanges = 0;
391 | for (const before of page) {
392 | const after = await ctx.db.get(before._id as GenericId);
393 | if (JSON.stringify(after) !== JSON.stringify(before)) {
394 | anyChanges = true;
395 | printedChanges++;
396 | if (printedChanges > 10) {
397 | console.debug(
398 | "DRY RUN: More than 10 changes were found in the first page. Skipping the rest.",
399 | );
400 | break;
401 | }
402 | console.debug("DRY RUN: Example change", {
403 | before,
404 | after,
405 | });
406 | }
407 | }
408 | if (!anyChanges) {
409 | console.debug(
410 | "DRY RUN: No changes were found in the first page. " +
411 | `Try {"dryRun": true, "cursor": "${continueCursor}"}`,
412 | );
413 | }
414 | throw new ConvexError({
415 | kind: "DRY RUN",
416 | result,
417 | });
418 | }
419 | if (args.dryRun === undefined) {
420 | // We are running it in a one-off mode.
421 | // The component will always provide dryRun.
422 | // A bit of a hack / implicit, but non-critical logging.
423 | console.debug(`Next cursor: ${continueCursor}`);
424 | }
425 | return result;
426 | },
427 | }) satisfies RegisteredMutation<
428 | "internal",
429 | MigrationArgs,
430 | Promise
431 | >;
432 | }
433 |
434 | /**
435 | * Start a migration from a server function via a function reference.
436 | *
437 | * ```ts
438 | * const migrations = new Migrations(components.migrations, { internalMutation });
439 | *
440 | * // in a mutation or action:
441 | * await migrations.runOne(ctx, internal.migrations.myMigration, {
442 | * cursor: null, // optional override
443 | * batchSize: 10, // optional override
444 | * });
445 | * ```
446 | *
447 | * Overrides any options you passed in, such as resetting the cursor.
448 | * If it's already in progress, it will no-op.
449 | * If you run a migration that had previously failed which was part of a series,
450 | * it will not resume the series.
451 | * To resume a series, call the series again: {@link Migrations.runSerially}.
452 | *
453 | * Note: It's up to you to determine if it's safe to run a migration while
454 | * others are in progress. It won't run multiple instance of the same migration
455 | * but it currently allows running multiple migrations on the same table.
456 | *
457 | * @param ctx Context from a mutation or action. Needs `runMutation`.
458 | * @param fnRef The migration function to run. Like `internal.migrations.foo`.
459 | * @param opts Options to start the migration.
460 | * @param opts.cursor The cursor to start from.
461 | * null: start from the beginning.
462 | * undefined: start or resume from where it failed. If done, it won't restart.
463 | * @param opts.batchSize The number of documents to process in a batch.
464 | * @param opts.dryRun If true, it will run a batch and then throw an error.
465 | * It's helpful to see what it would do without committing the transaction.
466 | */
467 | async runOne(
468 | ctx: MutationCtx | ActionCtx,
469 | fnRef: MigrationFunctionReference,
470 | opts?: {
471 | cursor?: string | null;
472 | batchSize?: number;
473 | dryRun?: boolean;
474 | },
475 | ) {
476 | return ctx.runMutation(this.component.lib.migrate, {
477 | name: getFunctionName(fnRef),
478 | fnHandle: await createFunctionHandle(fnRef),
479 | cursor: opts?.cursor,
480 | batchSize: opts?.batchSize,
481 | dryRun: opts?.dryRun ?? false,
482 | });
483 | }
484 |
485 | /**
486 | * Start a series of migrations, running one a time. Each call starts a series.
487 | *
488 | * ```ts
489 | * const migrations = new Migrations(components.migrations, { internalMutation });
490 | *
491 | * // in a mutation or action:
492 | * await migrations.runSerially(ctx, [
493 | * internal.migrations.myMigration,
494 | * internal.migrations.myOtherMigration,
495 | * ]);
496 | * ```
497 | *
498 | * It runs one batch at a time currently.
499 | * If a migration has previously completed it will skip it.
500 | * If a migration had partial progress, it will resume from where it left off.
501 | * If a migration is already in progress when attempted, it will no-op.
502 | * If a migration fails or is canceled, it will stop executing and NOT execute
503 | * any subsequent migrations in the series. Call the series again to retry.
504 | *
505 | * This is useful to run as an post-deploy script where you specify all the
506 | * live migrations that should be run.
507 | *
508 | * Note: if you start multiple serial migrations, the behavior is:
509 | * - If they don't overlap on functions, they will happily run in parallel.
510 | * - If they have a function in common and one completes before the other
511 | * attempts it, the second will just skip it.
512 | * - If they have a function in common and one is in progress, the second will
513 | * no-op and not run any further migrations in its series.
514 | *
515 | * To stop a migration in progress, see {@link cancelMigration}.
516 | *
517 | * @param ctx Context from a mutation or action. Needs `runMutation`.
518 | * @param fnRefs The migrations to run in order. Like [internal.migrations.foo].
519 | */
520 | async runSerially(
521 | ctx: MutationCtx | ActionCtx,
522 | fnRefs: MigrationFunctionReference[],
523 | ) {
524 | if (fnRefs.length === 0) return;
525 | const [fnRef, ...rest] = fnRefs;
526 | const next = await Promise.all(
527 | rest.map(async (fnRef) => ({
528 | name: getFunctionName(fnRef),
529 | fnHandle: await createFunctionHandle(fnRef),
530 | })),
531 | );
532 | return ctx.runMutation(this.component.lib.migrate, {
533 | name: getFunctionName(fnRef),
534 | fnHandle: await createFunctionHandle(fnRef),
535 | next,
536 | dryRun: false,
537 | });
538 | }
539 |
540 | /**
541 | * Get the status of a migration or all migrations.
542 | * @param ctx Context from a query, mutation or action. Needs `runQuery`.
543 | * @param migrations The migrations to get the status of. Defaults to all.
544 | * @param limit How many migrations to fetch, if not specified by name.
545 | * @returns The status of the migrations, in the order of the input.
546 | */
547 | async getStatus(
548 | ctx: QueryCtx | MutationCtx | ActionCtx,
549 | {
550 | migrations,
551 | limit,
552 | }: {
553 | migrations?: (string | MigrationFunctionReference)[];
554 | limit?: number;
555 | },
556 | ): Promise {
557 | const names = migrations?.map((m) =>
558 | typeof m === "string" ? this.prefixedName(m) : getFunctionName(m),
559 | );
560 | return ctx.runQuery(this.component.lib.getStatus, {
561 | names,
562 | limit,
563 | });
564 | }
565 |
566 | /**
567 | * Cancels a migration if it's in progress.
568 | * You can resume it later by calling the migration without an explicit cursor.
569 | * If the migration had "next" migrations, e.g. from {@link runSerially},
570 | * they will not run. To resume, call the series again or manually pass "next".
571 | * @param ctx Context from a mutation or action. Needs `runMutation`.
572 | * @param migration Migration to cancel. Either the name like "migrations:foo"
573 | * or the function reference like `internal.migrations.foo`.
574 | * @returns The status of the migration after attempting to cancel it.
575 | */
576 | async cancel(
577 | ctx: MutationCtx | ActionCtx,
578 | migration: MigrationFunctionReference | string,
579 | ): Promise {
580 | const name =
581 | typeof migration === "string"
582 | ? this.prefixedName(migration)
583 | : getFunctionName(migration);
584 | return ctx.runMutation(this.component.lib.cancel, { name });
585 | }
586 |
587 | /**
588 | * Cancels all migrations that are in progress.
589 | * You can resume it later by calling the migration without an explicit cursor.
590 | * If the migration had "next" migrations, e.g. from {@link runSerially},
591 | * they will not run. To resume, call the series again or manually pass "next".
592 | * @param ctx Context from a mutation or action. Needs `runMutation`.
593 | * @returns The status of up to 100 of the canceled migrations.
594 | */
595 | async cancelAll(ctx: MutationCtx | ActionCtx) {
596 | return ctx.runMutation(this.component.lib.cancelAll, {});
597 | }
598 |
599 | // Helper to prefix the name with the location.
600 | // migrationsLocationPrefix of "bar/baz:" and name "foo" => "bar/baz:foo"
601 | private prefixedName(name: string) {
602 | return this.options?.migrationsLocationPrefix && !name.includes(":")
603 | ? `${this.options.migrationsLocationPrefix}${name}`
604 | : name;
605 | }
606 | }
607 |
608 | export type MigrationFunctionReference = FunctionReference<
609 | "mutation",
610 | "internal",
611 | MigrationArgs
612 | >;
613 |
614 | /**
615 | * Start a migration and run it synchronously until it's done.
616 | * If this function crashes, the migration will not continue.
617 | *
618 | * ```ts
619 | * // In an action
620 | * const toRun = internal.migrations.myMigration;
621 | * await runToCompletion(ctx, components.migrations, toRun);
622 | * ```
623 | *
624 | * If it's already in progress, it will no-op.
625 | * If you run a migration that had previously failed which was part of a series,
626 | * it will not resume the series.
627 | * To resume a series, call the series again: {@link Migrations.runSerially}.
628 | *
629 | * Note: It's up to you to determine if it's safe to run a migration while
630 | * others are in progress. It won't run multiple instances of the same migration
631 | * but it currently allows running multiple migrations on the same table.
632 | *
633 | * @param ctx Context from an action.
634 | * @param component The migrations component, usually `components.migrations`.
635 | * @param fnRef The migration function to run. Like `internal.migrations.foo`.
636 | * @param opts Options to start the migration.
637 | * It's helpful to see what it would do without committing the transaction.
638 | */
639 | export async function runToCompletion(
640 | ctx: ActionCtx,
641 | component: ComponentApi,
642 | fnRef: MigrationFunctionReference | MigrationFunctionHandle,
643 | opts?: {
644 | /**
645 | * The name of the migration function, generated with getFunctionName.
646 | */
647 | name?: string;
648 | /**
649 | * The cursor to start from.
650 | * null: start from the beginning.
651 | * undefined: start, or resume from where it failed. No-ops if already done.
652 | */
653 | cursor?: string | null;
654 | /**
655 | * The number of documents to process in a batch.
656 | * Overrides the migrations's configured batch size.
657 | */
658 | batchSize?: number;
659 | /**
660 | * If true, it will run a batch and then throw an error.
661 | * It's helpful to see what it would do without committing the transaction.
662 | */
663 | dryRun?: boolean;
664 | },
665 | ): Promise {
666 | let cursor = opts?.cursor;
667 | const {
668 | name = getFunctionName(fnRef),
669 | batchSize,
670 | dryRun = false,
671 | } = opts ?? {};
672 | const address = getFunctionAddress(fnRef);
673 | const fnHandle =
674 | address.functionHandle ?? (await createFunctionHandle(fnRef));
675 | while (true) {
676 | const status = await ctx.runMutation(component.lib.migrate, {
677 | name,
678 | fnHandle,
679 | cursor,
680 | batchSize,
681 | dryRun,
682 | oneBatchOnly: true,
683 | });
684 | if (status.isDone) {
685 | return status;
686 | }
687 | if (status.error) {
688 | throw new Error(status.error);
689 | }
690 | if (!status.cursor || status.cursor === cursor) {
691 | throw new Error(
692 | "Invariant violation: Migration did not make progress." +
693 | `\nStatus: ${JSON.stringify(status)}`,
694 | );
695 | }
696 | cursor = status.cursor;
697 | }
698 | }
699 |
700 | /* Type utils follow */
701 |
702 | type QueryCtx = Pick, "runQuery">;
703 | type MutationCtx = Pick<
704 | GenericMutationCtx,
705 | "runQuery" | "runMutation"
706 | >;
707 | type ActionCtx = Pick<
708 | GenericActionCtx,
709 | "runQuery" | "runMutation" | "storage"
710 | >;
711 |
--------------------------------------------------------------------------------