diff --git a/.agents/skills/mops-cli/SKILL.md b/.agents/skills/mops-cli/SKILL.md index 7c91c1c7..d1d443ce 100644 --- a/.agents/skills/mops-cli/SKILL.md +++ b/.agents/skills/mops-cli/SKILL.md @@ -31,7 +31,12 @@ args = ["--default-persistent-actors", "-W=M0223,M0236,M0237"] [canisters.backend] main = "src/backend/main.mo" -args = ["--enhanced-migration=src/backend/migrations", "-A=M0254"] + +[canisters.backend.migrations] +chain = "src/backend/migrations" +next = "src/backend/next-migration" # optional — needed for `mops migrate new/freeze` +check-limit = 1 +build-limit = 100 [canisters.backend.check-stable] path = ".old/src/backend/dist/backend.most" @@ -62,8 +67,9 @@ Flags are applied in this order (later overrides earlier): 1. `[moc].args` — global, all commands (check, build, test, etc.) 2. `[build].args` — build only (e.g. `--release`) -3. `[canisters.].args` — per-canister (e.g. `--enhanced-migration=...`) -4. CLI `-- ` — one-off overrides +3. `[canisters..migrations]` — auto-injected `--enhanced-migration` (managed by mops) +4. `[canisters.].args` — per-canister +5. CLI `-- ` — one-off overrides ## Core Commands @@ -125,6 +131,21 @@ mops toolchain bin moc # print path to binary **Agent note**: `toolchain use ` without a version opens an interactive picker — do not use in scripts or agents. Always pass a version or `latest`. `toolchain update` only works when the tool already has a `[toolchain]` entry. +### `mops migrate` + +Manage enhanced migration chains: + +```bash +mops migrate new AddEmail # create a new migration file in next-migration/ +mops migrate new AddEmail backend # specify canister explicitly +mops migrate freeze # move next-migration to the permanent chain +mops migrate freeze backend # specify canister explicitly +``` + +When `[canisters..migrations]` is configured, `mops check`, `mops build`, and `mops check-stable` automatically inject `--enhanced-migration`. Do not add `--enhanced-migration` to `[canisters.].args` when using managed migrations — mops will error. + +Typical workflow: make a breaking change → `mops check` fails with a hint → `mops migrate new Name` → edit migration → `mops check` passes → `mops build` → deploy → `mops migrate freeze`. + ### `mops remove ` ```bash diff --git a/AGENTS.md b/AGENTS.md index 17c8a278..e05535fa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,7 @@ This file provides guidance to AI coding agents when working with code in this r - **Update the changelog.** Add entries under `## Next` in `cli/CHANGELOG.md` for any user-facing CLI changes. - **Keep skills up to date.** When changing CLI commands or workflows, update `.agents/skills/mops-cli/SKILL.md` to match. - **Pre-commit hook** runs `lint-staged + npm run check` via husky — fix TypeScript/lint errors before committing. +- **Snapshot testing strategy**: Use Jest snapshots (`cliSnapshot` / `toMatchSnapshot`) for the main use cases so the full CLI output is committed and reviewable. Corner-case and error-path tests should use targeted assertions (`toMatch`, `toBe`) without snapshots to avoid cluttering the snapshot file. ## What this repo is diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index be23f2fd..48276719 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -1,6 +1,11 @@ # Mops CLI Changelog ## Next +- Add `mops migrate new ` and `mops migrate freeze` commands for managing enhanced migration chains +- Add `[canisters..migrations]` config section with `chain`, `next`, `check-limit`, and `build-limit` fields +- `mops check`, `mops build`, and `mops check-stable` now auto-inject `--enhanced-migration` when `[migrations]` is configured +- `mops check` and `mops check-stable` emit a hint to create a migration when a stable compatibility check fails and `[migrations]` is configured +- Migration chain trimming: only the last N migrations are passed to `moc` based on `check-limit`/`build-limit` settings ## 2.10.0 - `mops check` and `mops check-stable` now apply per-canister `[canisters.].args` (previously only `mops build` applied them) diff --git a/cli/cli.ts b/cli/cli.ts index 38b1f537..c02f5c1b 100755 --- a/cli/cli.ts +++ b/cli/cli.ts @@ -43,6 +43,7 @@ import { importPem, setUserProp, } from "./commands/user.js"; +import { migrateNew, migrateFreeze } from "./commands/migrate.js"; import { watch } from "./commands/watch/watch.js"; import { apiVersion, @@ -707,6 +708,29 @@ toolchainCommand program.addCommand(toolchainCommand); +// migrate +const migrateCommand = new Command("migrate").description( + "Manage enhanced migration chains", +); + +migrateCommand + .command("new [canister]") + .description("Create a new migration file in the next-migration directory") + .action(async (name, canister) => { + checkConfigFile(true); + await migrateNew(name, canister); + }); + +migrateCommand + .command("freeze [canister]") + .description("Move the next migration into the frozen chain") + .action(async (canister) => { + checkConfigFile(true); + await migrateFreeze(canister); + }); + +program.addCommand(migrateCommand); + // self const selfCommand = new Command("self").description("Mops CLI management"); diff --git a/cli/commands/build.ts b/cli/commands/build.ts index a5ca3b70..249c95f7 100644 --- a/cli/commands/build.ts +++ b/cli/commands/build.ts @@ -11,6 +11,7 @@ import { resolveCanisterConfigs, validateCanisterArgs, } from "../helpers/resolve-canisters.js"; +import { prepareMigrationArgs } from "../helpers/migrations.js"; import { CanisterConfig, Config } from "../types.js"; import { CustomSection, getWasmBindings } from "../wasm.js"; import { getGlobalMocArgs, readConfig, resolveConfigPath } from "../mops.js"; @@ -87,6 +88,12 @@ export async function build( }; process.on("exit", exitCleanup); + const migration = await prepareMigrationArgs( + canister.migrations, + canisterName, + "build", + options.verbose, + ); try { let args = [ "-c", @@ -97,6 +104,7 @@ export async function build( motokoPath, ...(await sourcesArgs()).flat(), ...getGlobalMocArgs(config), + ...migration.migrationArgs, ]; args.push( ...collectExtraArgs(config, canister, canisterName, options.extraArgs), @@ -199,6 +207,7 @@ export async function build( ); } } finally { + await migration.cleanup(); process.removeListener("exit", exitCleanup); try { await release?.(); @@ -238,7 +247,7 @@ function collectExtraArgs( args.push(...config.build.args); } if (canister.args) { - validateCanisterArgs(canister, canisterName); + validateCanisterArgs(canister, canisterName, config); args.push(...canister.args); } if (extraArgs) { diff --git a/cli/commands/check-stable.ts b/cli/commands/check-stable.ts index 8056be32..04e7c707 100644 --- a/cli/commands/check-stable.ts +++ b/cli/commands/check-stable.ts @@ -4,6 +4,7 @@ import { rm } from "node:fs/promises"; import chalk from "chalk"; import { execa } from "execa"; import { cliError } from "../error.js"; +import { prepareMigrationArgs } from "../helpers/migrations.js"; import { getGlobalMocArgs, readConfig, resolveConfigPath } from "../mops.js"; import { CanisterConfig } from "../types.js"; import { @@ -70,17 +71,28 @@ export async function checkStable( cliError(`No main file specified for canister '${name}' in mops.toml`); } - validateCanisterArgs(canister, name); + validateCanisterArgs(canister, name, config); - await runStableCheck({ - oldFile, - canisterMain: resolveConfigPath(canister.main), - canisterName: name, - mocPath, - globalMocArgs, - canisterArgs: canister.args ?? [], - options, - }); + const migration = await prepareMigrationArgs( + canister.migrations, + name, + "check", + options.verbose, + ); + try { + await runStableCheck({ + oldFile, + canisterMain: resolveConfigPath(canister.main), + canisterName: name, + mocPath, + globalMocArgs, + canisterArgs: [...migration.migrationArgs, ...(canister.args ?? [])], + options, + hasMigrations: !!canister.migrations, + }); + } finally { + await migration.cleanup(); + } return; } @@ -95,7 +107,7 @@ export async function checkStable( cliError(`No main file specified for canister '${name}' in mops.toml`); } - validateCanisterArgs(canister, name); + validateCanisterArgs(canister, name, config); const stablePath = resolveStablePath(canister, name, { required: !!canisterNames, }); @@ -103,16 +115,27 @@ export async function checkStable( continue; } - await runStableCheck({ - oldFile: stablePath, - canisterMain: resolveConfigPath(canister.main), - canisterName: name, - mocPath, - globalMocArgs, - canisterArgs: canister.args ?? [], - sources, - options, - }); + const migration = await prepareMigrationArgs( + canister.migrations, + name, + "check", + options.verbose, + ); + try { + await runStableCheck({ + oldFile: stablePath, + canisterMain: resolveConfigPath(canister.main), + canisterName: name, + mocPath, + globalMocArgs, + canisterArgs: [...migration.migrationArgs, ...(canister.args ?? [])], + sources, + options, + hasMigrations: !!canister.migrations, + }); + } finally { + await migration.cleanup(); + } checked++; } @@ -136,6 +159,7 @@ export interface RunStableCheckParams { canisterArgs: string[]; sources?: string[]; options?: Partial; + hasMigrations?: boolean; } export async function runStableCheck( @@ -204,6 +228,13 @@ export async function runStableCheck( if (result.stderr) { console.error(result.stderr); } + if (params.hasMigrations) { + console.error( + chalk.yellow( + "Hint: You may need a migration. Run `mops migrate new ` to create one.", + ), + ); + } cliError( `✗ Stable compatibility check failed for canister '${canisterName}'`, ); diff --git a/cli/commands/check.ts b/cli/commands/check.ts index ad77876f..11652b56 100644 --- a/cli/commands/check.ts +++ b/cli/commands/check.ts @@ -16,6 +16,7 @@ import { resolveCanisterConfigs, validateCanisterArgs, } from "../helpers/resolve-canisters.js"; +import { prepareMigrationArgs } from "../helpers/migrations.js"; import { CanisterConfig, Config } from "../types.js"; import { resolveStablePath, runStableCheck } from "./check-stable.js"; import { sourcesArgs } from "./sources.js"; @@ -144,70 +145,82 @@ async function checkCanisters( ); } - validateCanisterArgs(canister, canisterName); + validateCanisterArgs(canister, canisterName, config); const motokoPath = resolveConfigPath(canister.main); - const mocArgs = [ - "--check", - ...(allLibs ? ["--all-libs"] : []), - ...sources, - ...globalMocArgs, - ...(canister.args ?? []), - ...(options.extraArgs ?? []), - ]; + const migration = await prepareMigrationArgs( + canister.migrations, + canisterName, + "check", + options.verbose, + ); + try { + const mocArgs = [ + "--check", + ...(allLibs ? ["--all-libs"] : []), + ...sources, + ...globalMocArgs, + ...migration.migrationArgs, + ...(canister.args ?? []), + ...(options.extraArgs ?? []), + ]; - if (options.fix) { - if (options.verbose) { - console.log( - chalk.blue("check"), - chalk.gray(`Attempting to fix ${canisterName}`), - ); + if (options.fix) { + if (options.verbose) { + console.log( + chalk.blue("check"), + chalk.gray(`Attempting to fix ${canisterName}`), + ); + } + + const fixResult = await autofixMotoko(mocPath, [motokoPath], mocArgs); + logAutofixResult(fixResult, options.verbose); } - const fixResult = await autofixMotoko(mocPath, [motokoPath], mocArgs); - logAutofixResult(fixResult, options.verbose); - } + try { + const args = [motokoPath, ...mocArgs]; + if (options.verbose) { + console.log( + chalk.blue("check"), + chalk.gray(`Checking canister ${canisterName}:`), + ); + console.log(chalk.gray(mocPath, JSON.stringify(args))); + } - try { - const args = [motokoPath, ...mocArgs]; - if (options.verbose) { - console.log( - chalk.blue("check"), - chalk.gray(`Checking canister ${canisterName}:`), - ); - console.log(chalk.gray(mocPath, JSON.stringify(args))); - } + const result = await execa(mocPath, args, { + stdio: "inherit", + reject: false, + }); - const result = await execa(mocPath, args, { - stdio: "inherit", - reject: false, - }); + if (result.exitCode !== 0) { + cliError( + `✗ Check failed for canister ${canisterName} (exit code: ${result.exitCode})`, + ); + } - if (result.exitCode !== 0) { + console.log(chalk.green(`✓ ${canisterName}`)); + } catch (err: any) { cliError( - `✗ Check failed for canister ${canisterName} (exit code: ${result.exitCode})`, + `Error while checking canister ${canisterName}${err?.message ? `\n${err.message}` : ""}`, ); } - console.log(chalk.green(`✓ ${canisterName}`)); - } catch (err: any) { - cliError( - `Error while checking canister ${canisterName}${err?.message ? `\n${err.message}` : ""}`, - ); - } - - const stablePath = resolveStablePath(canister, canisterName); - if (stablePath) { - await runStableCheck({ - oldFile: stablePath, - canisterMain: motokoPath, - canisterName, - mocPath, - globalMocArgs, - canisterArgs: canister.args ?? [], - sources, - options: { verbose: options.verbose, extraArgs: options.extraArgs }, - }); + const stablePath = resolveStablePath(canister, canisterName); + if (stablePath) { + await runStableCheck({ + oldFile: stablePath, + canisterMain: motokoPath, + canisterName, + mocPath, + globalMocArgs, + canisterArgs: [...migration.migrationArgs, ...(canister.args ?? [])], + sources, + options: { verbose: options.verbose, extraArgs: options.extraArgs }, + hasMigrations: !!canister.migrations, + }); + } + } finally { + await migration.cleanup(); } } } diff --git a/cli/commands/migrate.ts b/cli/commands/migrate.ts new file mode 100644 index 00000000..4a521e86 --- /dev/null +++ b/cli/commands/migrate.ts @@ -0,0 +1,165 @@ +import { existsSync, mkdirSync, renameSync } from "node:fs"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import chalk from "chalk"; +import { cliError } from "../error.js"; +import { + getNextMigrationFile, + validateMigrationsConfig, + validateNextMigrationOrder, +} from "../helpers/migrations.js"; +import { resolveCanisterConfigs } from "../helpers/resolve-canisters.js"; +import { readConfig, resolveConfigPath } from "../mops.js"; +import { CanisterConfig } from "../types.js"; + +function resolveMigrationCanister(canisterName?: string): { + name: string; + canister: CanisterConfig; +} { + const config = readConfig(); + const canisters = resolveCanisterConfigs(config); + const withMigrations = Object.entries(canisters).filter( + ([, c]) => c.migrations, + ); + + if (withMigrations.length === 0) { + cliError( + "No canisters with [migrations] config found in mops.toml.\n" + + "Add a [canisters..migrations] section first:\n\n" + + " [canisters.backend.migrations]\n" + + ' chain = "migrations"\n' + + ' next = "next-migration" # required for migrate new/freeze', + ); + } + + if (canisterName) { + const canister = canisters[canisterName]; + if (!canister) { + cliError( + `Canister '${canisterName}' not found in mops.toml. Available: ${Object.keys(canisters).join(", ")}`, + ); + } + if (!canister.migrations) { + cliError( + `Canister '${canisterName}' has no [canisters.${canisterName}.migrations] config in mops.toml`, + ); + } + return { name: canisterName, canister }; + } + + if (withMigrations.length > 1) { + cliError( + `Multiple canisters with [migrations] config. Please specify one: ${withMigrations.map(([n]) => n).join(", ")}`, + ); + } + + return { name: withMigrations[0]![0], canister: withMigrations[0]![1] }; +} + +const VALID_NAME_RE = /^[A-Za-z][A-Za-z0-9_]*$/; + +function generateTimestamp(): string { + const now = new Date(); + const pad = (n: number) => String(n).padStart(2, "0"); + return ( + `${now.getUTCFullYear()}${pad(now.getUTCMonth() + 1)}${pad(now.getUTCDate())}` + + `_${pad(now.getUTCHours())}${pad(now.getUTCMinutes())}${pad(now.getUTCSeconds())}` + ); +} + +const MIGRATION_TEMPLATE = `module { + public func migration(old : {}) : {} { + {} + } +} +`; + +export async function migrateNew( + name: string, + canisterName?: string, +): Promise { + if (!VALID_NAME_RE.test(name)) { + cliError( + `Invalid migration name: "${name}"\n` + + "Name must start with a letter and contain only letters, digits, and underscores.", + ); + } + + const { name: resolvedName, canister } = + resolveMigrationCanister(canisterName); + const migrations = canister.migrations!; + validateMigrationsConfig(migrations, resolvedName); + + if (!migrations.next) { + cliError( + `[canisters.${resolvedName}.migrations] is missing the "next" field.\n` + + 'Add next = "next-migration" to use `mops migrate new/freeze`.', + ); + } + + const chainDir = resolveConfigPath(migrations.chain); + const nextDir = resolveConfigPath(migrations.next); + + const existingNext = existsSync(nextDir) + ? getNextMigrationFile(nextDir) + : null; + if (existingNext) { + cliError( + `A next migration already exists: ${existingNext}\n` + + "Freeze it first with `mops migrate freeze`.", + ); + } + + const timestamp = generateTimestamp(); + const fileName = `${timestamp}_${name}.mo`; + + validateNextMigrationOrder(chainDir, fileName); + + if (!existsSync(chainDir)) { + mkdirSync(chainDir, { recursive: true }); + } + if (!existsSync(nextDir)) { + mkdirSync(nextDir, { recursive: true }); + } + + const filePath = join(nextDir, fileName); + await writeFile(filePath, MIGRATION_TEMPLATE); + + console.log(chalk.green(`✓ Created migration: ${filePath}`)); +} + +export async function migrateFreeze(canisterName?: string): Promise { + const { name: resolvedName, canister } = + resolveMigrationCanister(canisterName); + const migrations = canister.migrations!; + validateMigrationsConfig(migrations, resolvedName); + + if (!migrations.next) { + cliError( + `[canisters.${resolvedName}.migrations] is missing the "next" field.\n` + + 'Add next = "next-migration" to use `mops migrate new/freeze`.', + ); + } + + const chainDir = resolveConfigPath(migrations.chain); + const nextDir = resolveConfigPath(migrations.next); + + const nextFile = existsSync(nextDir) ? getNextMigrationFile(nextDir) : null; + if (!nextFile) { + cliError( + "No next migration to freeze. Create one with `mops migrate new `.", + ); + } + + validateNextMigrationOrder(chainDir, nextFile); + + if (!existsSync(chainDir)) { + mkdirSync(chainDir, { recursive: true }); + } + + const src = join(nextDir, nextFile); + const dest = join(chainDir, nextFile); + renameSync(src, dest); + + console.log(chalk.green(`✓ Frozen migration: ${nextFile} → ${chainDir}/`)); +} diff --git a/cli/helpers/migrations.ts b/cli/helpers/migrations.ts new file mode 100644 index 00000000..43792c8b --- /dev/null +++ b/cli/helpers/migrations.ts @@ -0,0 +1,166 @@ +import { existsSync, mkdirSync, readdirSync, symlinkSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { rm } from "node:fs/promises"; +import chalk from "chalk"; +import { cliError } from "../error.js"; +import { resolveConfigPath } from "../mops.js"; +import { MigrationsConfig } from "../types.js"; + +const MIGRATIONS_TEMP_DIR = ".mops/.migrations"; + +export interface MigrationArgsResult { + migrationArgs: string[]; + cleanup: () => Promise; +} + +export function getMigrationFiles(dir: string): string[] { + if (!existsSync(dir)) { + return []; + } + return readdirSync(dir) + .filter((f) => f.endsWith(".mo")) + .sort(); +} + +export function getNextMigrationFile(nextDir: string): string | null { + if (!existsSync(nextDir)) { + return null; + } + const files = readdirSync(nextDir).filter((f) => f.endsWith(".mo")); + if (files.length > 1) { + cliError( + `next-migration directory must contain at most 1 .mo file, found ${files.length} in ${nextDir}`, + ); + } + return files[0] ?? null; +} + +export function validateNextMigrationOrder( + chainDirOrFiles: string | string[], + nextFile: string, +): void { + const chainFiles = + typeof chainDirOrFiles === "string" + ? getMigrationFiles(chainDirOrFiles) + : chainDirOrFiles; + const lastChainFile = chainFiles[chainFiles.length - 1]; + if (lastChainFile && nextFile <= lastChainFile) { + cliError( + `Next migration "${nextFile}" must sort after all files in the chain.\n` + + `Last chain file: "${lastChainFile}".\n` + + "Use a timestamp prefix (e.g. YYYYMMDD_HHMMSS_Name.mo) to ensure correct ordering.", + ); + } +} + +export function validateMigrationsConfig( + migrations: MigrationsConfig, + canisterName: string, +): void { + if (!migrations.chain) { + cliError( + `[canisters.${canisterName}.migrations] is missing required field "chain"`, + ); + } + for (const field of ["check-limit", "build-limit"] as const) { + const value = migrations[field]; + if (value !== undefined && (!Number.isInteger(value) || value <= 0)) { + cliError( + `[canisters.${canisterName}.migrations] ${field} must be a positive integer`, + ); + } + } +} + +export async function prepareMigrationArgs( + migrations: MigrationsConfig | undefined, + canisterName: string, + mode: "check" | "build", + verbose?: boolean, +): Promise { + const noOp: MigrationArgsResult = { + migrationArgs: [], + cleanup: async () => {}, + }; + + if (!migrations) { + return noOp; + } + + validateMigrationsConfig(migrations, canisterName); + + const chainDir = resolveConfigPath(migrations.chain); + const nextDir = migrations.next + ? resolveConfigPath(migrations.next) + : undefined; + const nextFile = nextDir ? getNextMigrationFile(nextDir) : null; + + if (!existsSync(chainDir) && !nextFile) { + cliError( + `Migration chain directory not found: ${chainDir}\n` + + "Run `mops migrate new ` to initialize the migration chain.", + ); + } + + const chainFiles = getMigrationFiles(chainDir); + + if (nextFile) { + validateNextMigrationOrder(chainFiles, nextFile); + } + + // Treat chain + next as one virtual merged list + type MigrationEntry = { file: string; dir: string }; + const allMigrations: MigrationEntry[] = chainFiles.map((f) => ({ + file: f, + dir: chainDir, + })); + if (nextFile && nextDir) { + allMigrations.push({ file: nextFile, dir: nextDir }); + } + + const limit = + mode === "check" ? migrations["check-limit"] : migrations["build-limit"]; + const isTrimming = limit !== undefined && limit < allMigrations.length; + const needsTempDir = nextFile !== null || isTrimming; + + if (!needsTempDir) { + return { + migrationArgs: [`--enhanced-migration=${chainDir}`], + cleanup: async () => {}, + }; + } + + const tempDir = join(MIGRATIONS_TEMP_DIR, canisterName); + await rm(tempDir, { recursive: true, force: true }); + mkdirSync(tempDir, { recursive: true }); + + const filesToInclude = isTrimming + ? allMigrations.slice(-limit) + : allMigrations; + + for (const { file, dir } of filesToInclude) { + symlinkSync(resolve(dir, file), join(tempDir, file)); + } + + if (verbose) { + console.log( + chalk.blue("migrations"), + chalk.gray( + `Prepared ${filesToInclude.length} migration(s) for ${canisterName}` + + (isTrimming ? ` (trimmed from ${allMigrations.length})` : ""), + ), + ); + } + + const migrationArgs = [`--enhanced-migration=${tempDir}`]; + if (isTrimming) { + migrationArgs.push("-A=M0254"); + } + + return { + migrationArgs, + cleanup: async () => { + await rm(tempDir, { recursive: true, force: true }); + }, + }; +} diff --git a/cli/helpers/resolve-canisters.ts b/cli/helpers/resolve-canisters.ts index 3cdabfd8..03e6bb7d 100644 --- a/cli/helpers/resolve-canisters.ts +++ b/cli/helpers/resolve-canisters.ts @@ -74,10 +74,27 @@ export function looksLikeFile(arg: string): boolean { export function validateCanisterArgs( canister: CanisterConfig, canisterName: string, + config?: Config, ): void { if (canister.args && typeof canister.args === "string") { cliError( `Canister config 'args' should be an array of strings for canister ${canisterName}`, ); } + if (!canister.migrations) { + return; + } + const flagSources: [string, string[] | undefined][] = [ + [`[canisters.${canisterName}].args`, canister.args], + ["[moc].args", config?.moc?.args], + ["[build].args", config?.build?.args], + ]; + for (const [section, args] of flagSources) { + if (args?.some((a) => a.startsWith("--enhanced-migration"))) { + cliError( + `Canister '${canisterName}' has [migrations] config but --enhanced-migration in ${section}.\n` + + "Remove --enhanced-migration — it is managed automatically when [migrations] is configured.", + ); + } + } } diff --git a/cli/tests/__snapshots__/migrate.test.ts.snap b/cli/tests/__snapshots__/migrate.test.ts.snap new file mode 100644 index 00000000..314d4683 --- /dev/null +++ b/cli/tests/__snapshots__/migrate.test.ts.snap @@ -0,0 +1,119 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`migrate build build produces .most with full migration chain 1`] = ` +"// Version: 4.0.0 +{ + "20250101_000000_Init" : {} -> {a : Nat}; + "20250201_000000_AddName" : (old : {a : Nat}) -> {a : Nat; name : Text}; + "20250301_000000_AddEmail" : + (old : {a : Nat; name : Text}) -> {a : Nat; email : Text; name : Text} +} +actor { + stable a : Nat; + stable email : Text; + stable name : Text +}; +" +`; + +exports[`migrate build build with build-limit produces trimmed .most 1`] = ` +"// Version: 4.0.0 +{ + "20250201_000000_AddName" : (old : {a : Nat}) -> {a : Nat; name : Text}; + "20250301_000000_AddEmail" : + (old : {a : Nat; name : Text}) -> {a : Nat; email : Text; name : Text} +} +actor { + stable a : Nat; + stable email : Text; + stable name : Text +}; +" +`; + +exports[`migrate build build-limit counts next migration as part of the chain 1`] = ` +"// Version: 4.0.0 +{ + "20250301_000000_AddEmail" : + (old : {a : Nat; name : Text}) -> {a : Nat; email : Text; name : Text}; + "20250401_000000_RenameId" : + (old : {a : Nat; email : Text; name : Text}) -> + {email : Text; id : Nat; name : Text} +} +actor { + stable email : Text; + stable id : Nat; + stable name : Text +}; +" +`; + +exports[`migrate check check fails without next migration, passes with it 1`] = ` +{ + "exitCode": 1, + "stderr": "src/main.mo:3.1-11.2: Compatibility error [M0169], the stable variable \`a\` of version \`20250301_000000_AddEmail\` cannot be implicitly discarded. The variable can only be dropped by an explicit migration function, please see https://internetcomputer.org/docs/motoko/fundamentals/actors/compatibility#explicit-migration-using-a-migration-function +migrations/20250101_000000_Init.mo:0.1: warning [M0254], initial actor requires field \`id\` of type + Nat +✗ Check failed for canister backend (exit code: 1)", + "stdout": "", +} +`; + +exports[`migrate check check fails without next migration, passes with it 2`] = ` +{ + "exitCode": 0, + "stderr": "", + "stdout": "✓ backend +✓ Stable compatibility check passed for canister 'backend'", +} +`; + +exports[`migrate check check with trimming shows reduced chain 1`] = ` +{ + "exitCode": 0, + "stderr": "", + "stdout": "check Using --all-libs for richer diagnostics +migrations Prepared 2 migration(s) for backend (trimmed from 3) +check Checking canister backend: +moc-wrapper ["src/main.mo","--check","--all-libs","--default-persistent-actors","--enhanced-migration=.mops/.migrations/backend","-A=M0254"] +✓ backend +check-stable Generating stable types for src/main.mo +moc-wrapper ["--stable-types","-o",".mops/.check-stable/new.wasm","src/main.mo","--default-persistent-actors","--enhanced-migration=.mops/.migrations/backend","-A=M0254"] +check-stable Comparing deployed.most ↔ .mops/.check-stable/new.most +moc-wrapper ["--stable-compatible","deployed.most",".mops/.check-stable/new.most"] +✓ Stable compatibility check passed for canister 'backend'", +} +`; + +exports[`migrate migrate freeze moves the file from next to chain 1`] = ` +{ + "exitCode": 0, + "stderr": "", + "stdout": "✓ Frozen migration: 20250401_000000_RenameId.mo → migrations/", +} +`; + +exports[`migrate migrate new creates a migration file with timestamp and template 1`] = ` +{ + "exitCode": 0, + "stderr": "", + "stdout": "✓ Created migration: next-migration/_AddPhone.mo", + "template": "module { + public func migration(old : {}) : {} { + {} + } +} +", +} +`; + +exports[`migrate stable check hint stable check fails with hint when deployed.most is incompatible 1`] = ` +{ + "exitCode": 1, + "stderr": "(unknown location): Compatibility error [M0169], the stable variable \`a\` of the previous version cannot be implicitly discarded. The variable can only be dropped by an explicit migration function, please see https://internetcomputer.org/docs/motoko/fundamentals/actors/compatibility#explicit-migration-using-a-migration-function +(unknown location): Compatibility error [M0169], the stable variable \`name\` of the previous version cannot be implicitly discarded. The variable can only be dropped by an explicit migration function, please see https://internetcomputer.org/docs/motoko/fundamentals/actors/compatibility#explicit-migration-using-a-migration-function +Hint: You may need a migration. Run \`mops migrate new \` to create one. +✗ Stable compatibility check failed for canister 'backend'", + "stdout": "✓ backend", +} +`; diff --git a/cli/tests/migrate.test.ts b/cli/tests/migrate.test.ts new file mode 100644 index 00000000..ea161336 --- /dev/null +++ b/cli/tests/migrate.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, test, afterEach } from "@jest/globals"; +import { readdirSync, readFileSync } from "node:fs"; +import { cp, rm, writeFile } from "node:fs/promises"; +import path from "path"; +import { cli, cliSnapshot, normalizePaths } from "./helpers"; + +const normalizeTimestamp = (text: string) => + text.replace(/\d{8}_\d{6}/g, ""); + +const fixturesDir = path.join(import.meta.dirname, "migrate"); + +describe("migrate", () => { + const tempDirs: string[] = []; + + async function makeTempFixture(fixture: string): Promise { + const src = path.join(fixturesDir, fixture); + const dest = path.join(fixturesDir, `_tmp_${fixture}_${Date.now()}`); + await cp(src, dest, { recursive: true }); + tempDirs.push(dest); + return dest; + } + + async function patchMigrations(cwd: string, extra: string): Promise { + const tomlPath = path.join(cwd, "mops.toml"); + const toml = readFileSync(tomlPath, "utf-8"); + await writeFile( + tomlPath, + toml.replace( + 'next = "next-migration"', + `next = "next-migration"\n${extra}`, + ), + ); + } + + afterEach(async () => { + for (const dir of tempDirs) { + await rm(dir, { recursive: true, force: true }); + } + tempDirs.length = 0; + }); + + describe("migrate new", () => { + test("creates a migration file with timestamp and template", async () => { + const cwd = await makeTempFixture("basic"); + const result = await cli(["migrate", "new", "AddPhone"], { cwd }); + expect(result.exitCode).toBe(0); + + const nextDir = path.join(cwd, "next-migration"); + const files = readdirSync(nextDir).filter((f) => f.endsWith(".mo")); + expect(files).toHaveLength(1); + expect(files[0]).toMatch(/^\d{8}_\d{6}_AddPhone\.mo$/); + + const content = readFileSync(path.join(nextDir, files[0]!), "utf-8"); + + expect({ + exitCode: result.exitCode, + stdout: normalizeTimestamp(normalizePaths(result.stdout)), + stderr: normalizePaths(result.stderr), + template: content, + }).toMatchSnapshot(); + }); + + test("errors when next already has a file", async () => { + const cwd = await makeTempFixture("basic"); + await cli(["migrate", "new", "First"], { cwd }); + const result = await cli(["migrate", "new", "Second"], { cwd }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toMatch(/next migration already exists/i); + }); + + test("errors on invalid migration name", async () => { + const cwd = await makeTempFixture("basic"); + for (const name of ["../evil", "has space", "123start", "foo/bar"]) { + const result = await cli(["migrate", "new", name], { cwd }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toMatch(/invalid migration name/i); + } + }); + + test("errors when [migrations] not configured", async () => { + const cwd = await makeTempFixture("basic"); + const tomlPath = path.join(cwd, "mops.toml"); + const toml = readFileSync(tomlPath, "utf-8"); + await writeFile( + tomlPath, + toml.replace(/\[canisters\.backend\.migrations\][\s\S]*$/, ""), + ); + const result = await cli(["migrate", "new", "Test"], { cwd }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toMatch(/\[migrations\]/i); + }); + }); + + describe("migrate freeze", () => { + test("moves the file from next to chain", async () => { + const cwd = await makeTempFixture("with-next"); + const result = await cli(["migrate", "freeze"], { cwd }); + expect(result.exitCode).toBe(0); + + const nextFiles = readdirSync(path.join(cwd, "next-migration")).filter( + (f) => f.endsWith(".mo"), + ); + expect(nextFiles).toHaveLength(0); + + const chainFiles = readdirSync(path.join(cwd, "migrations")).filter((f) => + f.endsWith(".mo"), + ); + expect(chainFiles).toContain("20250401_000000_RenameId.mo"); + + expect({ + exitCode: result.exitCode, + stdout: normalizePaths(result.stdout), + stderr: normalizePaths(result.stderr), + }).toMatchSnapshot(); + }); + + test("errors when next is empty", async () => { + const cwd = await makeTempFixture("basic"); + const result = await cli(["migrate", "freeze"], { cwd }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toMatch(/no next migration/i); + }); + + test("errors when next-migration does not sort last", async () => { + const cwd = await makeTempFixture("basic"); + await writeFile( + path.join(cwd, "next-migration", "00000000_000000_Early.mo"), + "module {\n public func migration(_ : {}) : {} {\n {}\n }\n}\n", + ); + const result = await cli(["migrate", "freeze"], { cwd }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toMatch(/must sort after/i); + }); + }); + + describe("check", () => { + test("check fails without next migration, passes with it", async () => { + const cwd = await makeTempFixture("with-next"); + const nextFile = readdirSync(path.join(cwd, "next-migration")).find((f) => + f.endsWith(".mo"), + )!; + const nextPath = path.join(cwd, "next-migration", nextFile); + const nextContent = readFileSync(nextPath, "utf-8"); + await rm(nextPath); + + await cliSnapshot(["check"], { cwd }, 1); + + await writeFile(nextPath, nextContent); + await cliSnapshot(["check"], { cwd }, 0); + }); + + test("check with trimming shows reduced chain", async () => { + const cwd = await makeTempFixture("basic"); + await patchMigrations(cwd, "check-limit = 2"); + await cliSnapshot(["check", "--verbose"], { cwd }, 0); + }); + }); + + describe("build", () => { + test("build produces .most with full migration chain", async () => { + const cwd = await makeTempFixture("basic"); + const result = await cli(["build"], { cwd }); + expect(result.exitCode).toBe(0); + + const most = readFileSync( + path.join(cwd, ".mops", ".build", "backend.most"), + "utf-8", + ); + expect(most).toMatchSnapshot(); + }); + + test("build with build-limit produces trimmed .most", async () => { + const cwd = await makeTempFixture("basic"); + await patchMigrations(cwd, "build-limit = 2"); + const result = await cli(["build"], { cwd }); + expect(result.exitCode).toBe(0); + + const most = readFileSync( + path.join(cwd, ".mops", ".build", "backend.most"), + "utf-8", + ); + expect(most).toMatchSnapshot(); + }); + + test("build-limit counts next migration as part of the chain", async () => { + const cwd = await makeTempFixture("with-next"); + await patchMigrations(cwd, "build-limit = 2"); + const result = await cli(["build"], { cwd }); + expect(result.exitCode).toBe(0); + + const most = readFileSync( + path.join(cwd, ".mops", ".build", "backend.most"), + "utf-8", + ); + expect(most).toMatchSnapshot(); + }); + }); + + describe("stable check hint", () => { + test("stable check fails with hint when deployed.most is incompatible", async () => { + const cwd = await makeTempFixture("basic"); + await writeFile( + path.join(cwd, "deployed.most"), + "// Version: 1.0.0\nactor {\n stable var a : Nat;\n stable var name : Int\n};\n", + ); + await cliSnapshot(["check"], { cwd }, 1); + }); + }); + + describe("conflict detection", () => { + test("errors when both [migrations] and --enhanced-migration in args", async () => { + const cwd = await makeTempFixture("basic"); + const tomlPath = path.join(cwd, "mops.toml"); + const toml = readFileSync(tomlPath, "utf-8"); + await writeFile( + tomlPath, + toml.replace( + "[canisters.backend.migrations]", + 'args = ["--enhanced-migration=migrations"]\n\n[canisters.backend.migrations]', + ), + ); + const result = await cli(["check"], { cwd }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toMatch(/--enhanced-migration/); + expect(result.stderr).toMatch(/managed automatically/i); + }); + }); +}); diff --git a/cli/tests/migrate/.gitignore b/cli/tests/migrate/.gitignore new file mode 100644 index 00000000..fc5f6a64 --- /dev/null +++ b/cli/tests/migrate/.gitignore @@ -0,0 +1 @@ +_tmp_*/ diff --git a/cli/tests/migrate/basic/deployed.most b/cli/tests/migrate/basic/deployed.most new file mode 100644 index 00000000..80de0313 --- /dev/null +++ b/cli/tests/migrate/basic/deployed.most @@ -0,0 +1,12 @@ +// Version: 4.0.0 +{ + "20250101_000000_Init" : {} -> {a : Nat}; + "20250201_000000_AddName" : (old : {a : Nat}) -> {a : Nat; name : Text}; + "20250301_000000_AddEmail" : + (old : {a : Nat; name : Text}) -> {a : Nat; email : Text; name : Text} +} +actor { + stable a : Nat; + stable email : Text; + stable name : Text +}; diff --git a/cli/tests/migrate/basic/migrations/20250101_000000_Init.mo b/cli/tests/migrate/basic/migrations/20250101_000000_Init.mo new file mode 100644 index 00000000..192370fd --- /dev/null +++ b/cli/tests/migrate/basic/migrations/20250101_000000_Init.mo @@ -0,0 +1,5 @@ +module { + public func migration(_ : {}) : { a : Nat } { + { a = 0 }; + }; +}; diff --git a/cli/tests/migrate/basic/migrations/20250201_000000_AddName.mo b/cli/tests/migrate/basic/migrations/20250201_000000_AddName.mo new file mode 100644 index 00000000..6a0b42e4 --- /dev/null +++ b/cli/tests/migrate/basic/migrations/20250201_000000_AddName.mo @@ -0,0 +1,5 @@ +module { + public func migration(old : { a : Nat }) : { a : Nat; name : Text } { + { old with name = "" }; + }; +}; diff --git a/cli/tests/migrate/basic/migrations/20250301_000000_AddEmail.mo b/cli/tests/migrate/basic/migrations/20250301_000000_AddEmail.mo new file mode 100644 index 00000000..50f28496 --- /dev/null +++ b/cli/tests/migrate/basic/migrations/20250301_000000_AddEmail.mo @@ -0,0 +1,9 @@ +module { + public func migration(old : { a : Nat; name : Text }) : { + a : Nat; + name : Text; + email : Text; + } { + { old with email = "" }; + }; +}; diff --git a/cli/tests/migrate/basic/mops.toml b/cli/tests/migrate/basic/mops.toml new file mode 100644 index 00000000..8b3819dc --- /dev/null +++ b/cli/tests/migrate/basic/mops.toml @@ -0,0 +1,15 @@ +[toolchain] +moc = "1.5.0" + +[moc] +args = ["--default-persistent-actors"] + +[canisters.backend] +main = "src/main.mo" + +[canisters.backend.migrations] +chain = "migrations" +next = "next-migration" + +[canisters.backend.check-stable] +path = "deployed.most" diff --git a/cli/tests/migrate/basic/next-migration/.gitkeep b/cli/tests/migrate/basic/next-migration/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/cli/tests/migrate/basic/src/main.mo b/cli/tests/migrate/basic/src/main.mo new file mode 100644 index 00000000..a66e678c --- /dev/null +++ b/cli/tests/migrate/basic/src/main.mo @@ -0,0 +1,11 @@ +import Prim "mo:prim"; + +actor { + let a : Nat; + let name : Text; + let email : Text; + + public func check() : async () { + Prim.debugPrint(debug_show { a; name; email }); + }; +}; diff --git a/cli/tests/migrate/with-next/deployed.most b/cli/tests/migrate/with-next/deployed.most new file mode 100644 index 00000000..80de0313 --- /dev/null +++ b/cli/tests/migrate/with-next/deployed.most @@ -0,0 +1,12 @@ +// Version: 4.0.0 +{ + "20250101_000000_Init" : {} -> {a : Nat}; + "20250201_000000_AddName" : (old : {a : Nat}) -> {a : Nat; name : Text}; + "20250301_000000_AddEmail" : + (old : {a : Nat; name : Text}) -> {a : Nat; email : Text; name : Text} +} +actor { + stable a : Nat; + stable email : Text; + stable name : Text +}; diff --git a/cli/tests/migrate/with-next/migrations/20250101_000000_Init.mo b/cli/tests/migrate/with-next/migrations/20250101_000000_Init.mo new file mode 100644 index 00000000..192370fd --- /dev/null +++ b/cli/tests/migrate/with-next/migrations/20250101_000000_Init.mo @@ -0,0 +1,5 @@ +module { + public func migration(_ : {}) : { a : Nat } { + { a = 0 }; + }; +}; diff --git a/cli/tests/migrate/with-next/migrations/20250201_000000_AddName.mo b/cli/tests/migrate/with-next/migrations/20250201_000000_AddName.mo new file mode 100644 index 00000000..6a0b42e4 --- /dev/null +++ b/cli/tests/migrate/with-next/migrations/20250201_000000_AddName.mo @@ -0,0 +1,5 @@ +module { + public func migration(old : { a : Nat }) : { a : Nat; name : Text } { + { old with name = "" }; + }; +}; diff --git a/cli/tests/migrate/with-next/migrations/20250301_000000_AddEmail.mo b/cli/tests/migrate/with-next/migrations/20250301_000000_AddEmail.mo new file mode 100644 index 00000000..50f28496 --- /dev/null +++ b/cli/tests/migrate/with-next/migrations/20250301_000000_AddEmail.mo @@ -0,0 +1,9 @@ +module { + public func migration(old : { a : Nat; name : Text }) : { + a : Nat; + name : Text; + email : Text; + } { + { old with email = "" }; + }; +}; diff --git a/cli/tests/migrate/with-next/mops.toml b/cli/tests/migrate/with-next/mops.toml new file mode 100644 index 00000000..8b3819dc --- /dev/null +++ b/cli/tests/migrate/with-next/mops.toml @@ -0,0 +1,15 @@ +[toolchain] +moc = "1.5.0" + +[moc] +args = ["--default-persistent-actors"] + +[canisters.backend] +main = "src/main.mo" + +[canisters.backend.migrations] +chain = "migrations" +next = "next-migration" + +[canisters.backend.check-stable] +path = "deployed.most" diff --git a/cli/tests/migrate/with-next/next-migration/20250401_000000_RenameId.mo b/cli/tests/migrate/with-next/next-migration/20250401_000000_RenameId.mo new file mode 100644 index 00000000..8d0b5eb1 --- /dev/null +++ b/cli/tests/migrate/with-next/next-migration/20250401_000000_RenameId.mo @@ -0,0 +1,9 @@ +module { + public func migration(old : { a : Nat; name : Text; email : Text }) : { + id : Nat; + name : Text; + email : Text; + } { + { id = old.a; name = old.name; email = old.email }; + }; +}; diff --git a/cli/tests/migrate/with-next/src/main.mo b/cli/tests/migrate/with-next/src/main.mo new file mode 100644 index 00000000..2ffb4dea --- /dev/null +++ b/cli/tests/migrate/with-next/src/main.mo @@ -0,0 +1,11 @@ +import Prim "mo:prim"; + +actor { + let id : Nat; + let name : Text; + let email : Text; + + public func check() : async () { + Prim.debugPrint(debug_show { id; name; email }); + }; +}; diff --git a/cli/types.ts b/cli/types.ts index cbbf92aa..f82ca3a8 100644 --- a/cli/types.ts +++ b/cli/types.ts @@ -35,6 +35,13 @@ export type Config = { }; }; +export type MigrationsConfig = { + chain: string; + next?: string; + "check-limit"?: number; + "build-limit"?: number; +}; + export type CanisterConfig = { main?: string; args?: string[]; @@ -44,6 +51,7 @@ export type CanisterConfig = { path: string; skipIfMissing?: boolean; }; + migrations?: MigrationsConfig; }; export type Dependencies = Record; diff --git a/docs/docs/09-mops.toml.md b/docs/docs/09-mops.toml.md index 045d1d10..98907446 100644 --- a/docs/docs/09-mops.toml.md +++ b/docs/docs/09-mops.toml.md @@ -114,7 +114,10 @@ Multi-canister example with per-canister flags: ```toml [canisters.backend] main = "src/backend/main.mo" -args = ["--enhanced-migration=migrations/backend"] + +[canisters.backend.migrations] +chain = "migrations/backend" +next = "next-migration/backend" [canisters.frontend] main = "src/frontend/main.mo" @@ -136,6 +139,32 @@ path = ".old/src/main.most" skipIfMissing = true ``` +### `[canisters..migrations]` + +Configure managed enhanced migration chains for a canister. When set, `mops check`, `mops build`, and `mops check-stable` auto-inject `--enhanced-migration` and you can use [`mops migrate`](/cli/mops-migrate) commands to manage the migration chain. + +| Field | Description | +| ----------- | --------------------------------------------------------------- | +| chain | Path to the directory containing frozen migration files (required) | +| next | Path to the directory for the next pending migration (optional). Required for `mops migrate new/freeze`. Must contain 0 or 1 `.mo` files | +| check-limit | Max number of migrations to pass to `moc` during `mops check` and `mops check-stable` (optional). Counts the full chain including any pending next migration | +| build-limit | Max number of migrations to pass to `moc` during `mops build` (optional). Counts the full chain including any pending next migration | + +Example: +```toml +[canisters.backend.migrations] +chain = "migrations" +next = "next-migration" +check-limit = 1 +build-limit = 100 +``` + +Migration files must be named so they sort lexicographically in the correct order. The recommended naming convention is `YYYYMMDD_HHMMSS_Name.mo` (e.g. `20250415_120000_AddEmail.mo`). + +:::note +When `[migrations]` is configured, do not add `--enhanced-migration` to `[canisters.].args` — mops manages this flag automatically. +::: + Shorthand — when only the entrypoint is needed: ```toml [canisters] diff --git a/docs/docs/cli/4-dev/03-mops-build.md b/docs/docs/cli/4-dev/03-mops-build.md index 21bc10bc..2eec86cb 100644 --- a/docs/docs/cli/4-dev/03-mops-build.md +++ b/docs/docs/cli/4-dev/03-mops-build.md @@ -94,6 +94,12 @@ Default `.mops/.build` The `--output` CLI flag takes precedence over this config value. +## Enhanced Migration Support + +When a canister has a `[canisters..migrations]` section in `mops.toml`, `mops build` automatically injects the `--enhanced-migration` flag. The frozen migration chain and any pending next migration are included in the compiled WASM. + +See [`mops migrate`](/cli/mops-migrate) for the full migration workflow and chain trimming configuration. + ## Candid Compatibility If a `candid` field is specified in the canister configuration, the build command will automatically check that the generated Candid interface is compatible with the specified interface. diff --git a/docs/docs/cli/4-dev/04-mops-check.md b/docs/docs/cli/4-dev/04-mops-check.md index 3e4bc414..9a163d53 100644 --- a/docs/docs/cli/4-dev/04-mops-check.md +++ b/docs/docs/cli/4-dev/04-mops-check.md @@ -113,6 +113,14 @@ skipIfMissing = true For more details, see [`mops check-stable`](/cli/mops-check-stable). +## Enhanced migration support + +When a canister has a `[canisters..migrations]` section in `mops.toml`, `mops check` automatically injects the `--enhanced-migration` flag. The frozen migration chain and any pending next migration are assembled into a temporary directory and passed to `moc`. + +If a stable compatibility check fails and `[migrations]` is configured, a hint is shown suggesting to create a new migration with `mops migrate new `. + +See [`mops migrate`](/cli/mops-migrate) for the full migration workflow. + ## Lint integration After type-checking succeeds, `mops check` automatically runs [`mops lint`](/cli/mops-lint) when `lintoko` is pinned in `[toolchain]`. diff --git a/docs/docs/cli/4-dev/05-mops-check-stable.md b/docs/docs/cli/4-dev/05-mops-check-stable.md index ab205282..ab263a18 100644 --- a/docs/docs/cli/4-dev/05-mops-check-stable.md +++ b/docs/docs/cli/4-dev/05-mops-check-stable.md @@ -85,6 +85,12 @@ mops check-stable [canister] Show detailed output including the `moc` commands being run and the intermediate file paths. +## Enhanced migration support + +When a canister has a `[canisters..migrations]` section in `mops.toml`, `mops check-stable` automatically injects the `--enhanced-migration` flag when generating stable type signatures. If the stable check fails and `[migrations]` is configured, a hint is shown suggesting to create a new migration. + +See [`mops migrate`](/cli/mops-migrate) for the full migration workflow. + ## Passing flags to the Motoko compiler Any arguments after `--` are forwarded to `moc` when generating stable type signatures. diff --git a/docs/docs/cli/4-dev/08-mops-migrate.md b/docs/docs/cli/4-dev/08-mops-migrate.md new file mode 100644 index 00000000..1f24adae --- /dev/null +++ b/docs/docs/cli/4-dev/08-mops-migrate.md @@ -0,0 +1,84 @@ +--- +slug: /cli/mops-migrate +sidebar_label: mops migrate +--- + +# `mops migrate` + +Manage enhanced migration chains. + +Migration files define how canister state transforms from one version to the next. They let you batch multiple incompatible stable state changes and deploy them together in a single upgrade. Each migration is a Motoko module with a `migration` function that takes the old state shape and returns the new one. + +## `mops migrate new` + +``` +mops migrate new [canister] +``` + +Create a new migration file in the `next` directory configured by `[canisters..migrations].next`. + +- **``** — descriptive name for the migration (e.g. `AddEmail`, `RemoveCounter`) +- **`[canister]`** — canister name. Auto-detected if exactly one canister has `[migrations]` configured + +The file is created with a timestamp prefix for correct ordering: `YYYYMMDD_HHMMSS_.mo`. + +### Examples + +``` +mops migrate new AddEmail +mops migrate new RemoveCounter backend +``` + +## `mops migrate freeze` + +``` +mops migrate freeze [canister] +``` + +Move the migration file from the `next` directory into the `chain` directory, making it part of the permanent migration chain. + +- **`[canister]`** — canister name. Auto-detected if exactly one canister has `[migrations]` configured + +Call `freeze` after verifying the migration with `mops check` and `mops build`. Once frozen, the migration becomes part of the permanent chain. + +### Example + +``` +mops migrate freeze +``` + +## Configuration + +Migrations are configured per-canister in `mops.toml`: + +```toml +[canisters.backend.migrations] +chain = "migrations" +next = "next-migration" +check-limit = 1 +build-limit = 100 +``` + +See [`mops.toml` reference](/mops.toml#canistersnamemigrations) for all fields. + +## Typical workflow + +1. Make a breaking change to your canister's stable state +2. Run `mops check` — the stable compatibility check fails, with a hint to create a migration +3. Run `mops migrate new AddEmail` — creates a migration file in `next-migration/` +4. Edit the migration file to define the state transformation +5. Run `mops check` — verifies the migration makes the upgrade compatible +6. Run `mops build` — builds the WASM with the migration included +7. Deploy the canister +8. Run `mops migrate freeze` — moves the migration into the permanent chain + +## Chain trimming + +Large migration chains increase WASM size and compilation time. Use `check-limit` and `build-limit` to trim the chain: + +- **`check-limit`** — only the last N migrations are included during `mops check` and `mops check-stable`. Set to `1` for fastest type-checking. +- **`build-limit`** — only the last N migrations are included during `mops build`. Set higher (e.g. `100`) so the deployed WASM can apply multiple pending migrations. + +The limits count the full virtual chain (frozen + pending next migration). This means `mops build` produces identical results whether a migration is still pending or already frozen. + +Already-applied migrations are skipped at runtime by the Motoko RTS, so trimming is safe. When trimming is active, M0254 warnings are automatically suppressed.