├── example ├── src │ ├── vite-env.d.ts │ ├── App.tsx │ ├── main.tsx │ ├── App.css │ └── index.css ├── convex │ ├── tsconfig.json │ ├── convex.config.ts │ ├── schema.ts │ ├── setup.test.ts │ ├── _generated │ │ ├── api.js │ │ ├── api.d.ts │ │ ├── dataModel.d.ts │ │ ├── server.js │ │ └── server.d.ts │ ├── example.test.ts │ └── example.ts ├── README.md ├── vite.config.ts ├── index.html └── tsconfig.json ├── .prettierrc.json ├── dashboard_screenshot.png ├── src ├── component │ ├── convex.config.ts │ ├── setup.test.ts │ ├── schema.ts │ ├── _generated │ │ ├── api.ts │ │ ├── dataModel.ts │ │ ├── component.ts │ │ └── server.ts │ ├── lib.test.ts │ └── lib.ts ├── client │ ├── index.test.ts │ ├── log.ts │ └── index.ts ├── test.ts └── shared.ts ├── convex.json ├── vitest.config.ts ├── tsconfig.build.json ├── tsconfig.test.json ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── renovate.json ├── tsconfig.json ├── .github └── workflows │ └── test.yml ├── eslint.config.js ├── package.json ├── LICENSE └── README.md /example/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "proseWrap": "always" 4 | } 5 | -------------------------------------------------------------------------------- /dashboard_screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/get-convex/migrations/HEAD/dashboard_screenshot.png -------------------------------------------------------------------------------- /example/convex/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": ["."], 4 | "exclude": ["_generated"] 5 | } 6 | -------------------------------------------------------------------------------- /src/component/convex.config.ts: -------------------------------------------------------------------------------- 1 | import { defineComponent } from "convex/server"; 2 | 3 | export default defineComponent("migrations"); 4 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # Example app 2 | 3 | Components need an app that uses them in order to run codegen. An example app is 4 | also useful for testing and documentation. 5 | -------------------------------------------------------------------------------- /convex.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/convex/schemas/convex.schema.json", 3 | "functions": "example/convex", 4 | "codegen": { 5 | "legacyComponentApi": false 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/component/setup.test.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { test } from "vitest"; 3 | export const modules = import.meta.glob("./**/*.*s"); 4 | 5 | test("setup", () => {}); 6 | -------------------------------------------------------------------------------- /example/convex/convex.config.ts: -------------------------------------------------------------------------------- 1 | import { defineApp } from "convex/server"; 2 | import migrations from "@convex-dev/migrations/convex.config"; 3 | 4 | const app = defineApp(); 5 | app.use(migrations); 6 | 7 | export default app; 8 | -------------------------------------------------------------------------------- /example/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | envDir: "../", 7 | plugins: [react()], 8 | }); 9 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | environment: "edge-runtime", 6 | typecheck: { 7 | tsconfig: "./tsconfig.test.json", 8 | }, 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src/**/*"], 4 | "exclude": ["src/**/*.test.*", "./src/test.ts"], 5 | "compilerOptions": { 6 | "module": "ESNext", 7 | "moduleResolution": "Bundler" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": [ 4 | "src/**/*.ts", 5 | "src/**/*.tsx", 6 | "example/src/**/*.ts", 7 | "example/src/**/*.tsx", 8 | "example/convex/**/*.ts" 9 | ], 10 | "exclude": ["node_modules", "dist", "**/_generated"] 11 | } 12 | -------------------------------------------------------------------------------- /example/src/App.tsx: -------------------------------------------------------------------------------- 1 | import "./App.css"; 2 | 3 | function App() { 4 | return ( 5 | <> 6 |

Convex Migrations Component Example

7 |
8 |

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 | [![npm version](https://badge.fury.io/js/@convex-dev%2Fmigrations.svg)](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 | ![Dashboard screenshot](./dashboard_screenshot.png) 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 | --------------------------------------------------------------------------------