From 2510084aa2ac51dbb61a22c24c30d2e8df6bf252 Mon Sep 17 00:00:00 2001 From: Kamil Listopad Date: Wed, 15 Apr 2026 16:24:41 +0200 Subject: [PATCH 01/12] feat: add managed migrations for enhanced orthogonal persistence Add `mops migrate new/freeze` commands and `[canisters..migrations]` config to automate Motoko enhanced migration chain management. When `[migrations]` is configured, `mops check` and `mops build` auto-inject `--enhanced-migration` with optional chain trimming via `check-limit` and `build-limit`. A hint is shown on stable check failure suggesting migration creation. Spec: cli/specs/managed-migrations.md Made-with: Cursor --- .agents/skills/mops-cli/SKILL.md | 27 ++- cli/CHANGELOG.md | 5 + cli/cli.ts | 24 +++ cli/commands/build.ts | 9 + cli/commands/check-stable.ts | 67 ++++-- cli/commands/check.ts | 115 +++++----- cli/commands/migrate.ts | 142 +++++++++++++ cli/helpers/migrations.ts | 167 +++++++++++++++ cli/helpers/resolve-canisters.ts | 11 + cli/specs/managed-migrations.md | 199 ++++++++++++++++++ cli/tests/migrate.test.ts | 154 ++++++++++++++ cli/tests/migrate/.gitignore | 1 + .../basic/migrations/20250101_000000_Init.mo | 8 + cli/tests/migrate/basic/mops.toml | 12 ++ cli/tests/migrate/basic/src/main.mo | 10 + .../migrations/20250101_000000_Init.mo | 5 + .../migrations/20250201_000000_AddField.mo | 5 + cli/tests/migrate/trimmed/mops.toml | 13 ++ cli/tests/migrate/trimmed/src/main.mo | 10 + .../migrations/20250101_000000_Init.mo | 8 + cli/tests/migrate/with-next/mops.toml | 12 ++ .../20250201_000000_AddField.mo | 9 + cli/tests/migrate/with-next/src/main.mo | 11 + cli/types.ts | 8 + docs/docs/09-mops.toml.md | 31 ++- docs/docs/cli/4-dev/03-mops-build.md | 6 + docs/docs/cli/4-dev/04-mops-check.md | 8 + docs/docs/cli/4-dev/08-mops-migrate.md | 82 ++++++++ 28 files changed, 1085 insertions(+), 74 deletions(-) create mode 100644 cli/commands/migrate.ts create mode 100644 cli/helpers/migrations.ts create mode 100644 cli/specs/managed-migrations.md create mode 100644 cli/tests/migrate.test.ts create mode 100644 cli/tests/migrate/.gitignore create mode 100644 cli/tests/migrate/basic/migrations/20250101_000000_Init.mo create mode 100644 cli/tests/migrate/basic/mops.toml create mode 100644 cli/tests/migrate/basic/src/main.mo create mode 100644 cli/tests/migrate/trimmed/migrations/20250101_000000_Init.mo create mode 100644 cli/tests/migrate/trimmed/migrations/20250201_000000_AddField.mo create mode 100644 cli/tests/migrate/trimmed/mops.toml create mode 100644 cli/tests/migrate/trimmed/src/main.mo create mode 100644 cli/tests/migrate/with-next/migrations/20250101_000000_Init.mo create mode 100644 cli/tests/migrate/with-next/mops.toml create mode 100644 cli/tests/migrate/with-next/next-migration/20250201_000000_AddField.mo create mode 100644 cli/tests/migrate/with-next/src/main.mo create mode 100644 docs/docs/cli/4-dev/08-mops-migrate.md diff --git a/.agents/skills/mops-cli/SKILL.md b/.agents/skills/mops-cli/SKILL.md index 7c91c1c7..829e6635 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" +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 orthogonal persistence 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` and `mops build` 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/cli/CHANGELOG.md b/cli/CHANGELOG.md index be23f2fd..0c8e053c 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 orthogonal persistence migration chains +- Add `[canisters..migrations]` config section with `chain`, `next`, `check-limit`, and `build-limit` fields +- `mops check` and `mops build` now auto-inject `--enhanced-migration` when `[migrations]` is configured +- `mops check` emits 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..5fbf7fc9 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( + "Migration management for enhanced orthogonal persistence", +); + +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..13db4fd4 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?.(); diff --git a/cli/commands/check-stable.ts b/cli/commands/check-stable.ts index 8056be32..766289c4 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 { @@ -72,15 +73,25 @@ export async function checkStable( validateCanisterArgs(canister, name); - await runStableCheck({ - oldFile, - canisterMain: resolveConfigPath(canister.main), - canisterName: name, - mocPath, - globalMocArgs, - canisterArgs: canister.args ?? [], - options, - }); + const migration = await prepareMigrationArgs( + canister.migrations, + name, + "check", + ); + 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; } @@ -103,16 +114,26 @@ 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", + ); + 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 +157,7 @@ export interface RunStableCheckParams { canisterArgs: string[]; sources?: string[]; options?: Partial; + hasMigrations?: boolean; } export async function runStableCheck( @@ -204,6 +226,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..741236c6 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"; @@ -147,67 +148,79 @@ async function checkCanisters( validateCanisterArgs(canister, canisterName); 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..f61beec8 --- /dev/null +++ b/cli/commands/migrate.ts @@ -0,0 +1,142 @@ +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"', + ); + } + + 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] }; +} + +function generateTimestamp(): string { + const now = new Date(); + const pad = (n: number) => String(n).padStart(2, "0"); + return ( + `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}` + + `_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}` + ); +} + +const MIGRATION_TEMPLATE = `module { + public func migration(old : {}) : {} { + {} + } +} +`; + +export async function migrateNew( + name: string, + canisterName?: string, +): Promise { + const { name: resolvedName, canister } = + resolveMigrationCanister(canisterName); + const migrations = canister.migrations!; + validateMigrationsConfig(migrations, resolvedName); + + 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); + + 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..1507af20 --- /dev/null +++ b/cli/helpers/migrations.ts @@ -0,0 +1,167 @@ +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( + chainDir: string, + nextFile: string, +): void { + const chainFiles = getMigrationFiles(chainDir); + 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"`, + ); + } + if (!migrations.next) { + cliError( + `[canisters.${canisterName}.migrations] is missing required field "next"`, + ); + } + if ( + migrations["check-limit"] !== undefined && + migrations["check-limit"] <= 0 + ) { + cliError(`[canisters.${canisterName}.migrations] check-limit must be > 0`); + } + if ( + migrations["build-limit"] !== undefined && + migrations["build-limit"] <= 0 + ) { + cliError(`[canisters.${canisterName}.migrations] build-limit must be > 0`); + } +} + +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 = resolveConfigPath(migrations.next); + const nextFile = getNextMigrationFile(nextDir); + + 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(chainDir, nextFile); + } + + const limit = + mode === "check" ? migrations["check-limit"] : migrations["build-limit"]; + const isTrimming = limit !== undefined && limit < chainFiles.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 + ? chainFiles.slice(-limit) + : [...chainFiles]; + + for (const file of filesToInclude) { + const target = resolve(chainDir, file); + symlinkSync(target, join(tempDir, file)); + } + + if (nextFile) { + const target = resolve(nextDir, nextFile); + symlinkSync(target, join(tempDir, nextFile)); + } + + if (verbose) { + const total = filesToInclude.length + (nextFile ? 1 : 0); + console.log( + chalk.blue("migrations"), + chalk.gray( + `Prepared ${total} migration(s) for ${canisterName}` + + (isTrimming ? ` (trimmed from ${chainFiles.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..0f1a7203 100644 --- a/cli/helpers/resolve-canisters.ts +++ b/cli/helpers/resolve-canisters.ts @@ -80,4 +80,15 @@ export function validateCanisterArgs( `Canister config 'args' should be an array of strings for canister ${canisterName}`, ); } + if ( + canister.migrations && + canister.args?.some((a) => a.startsWith("--enhanced-migration")) + ) { + cliError( + `Canister '${canisterName}' has both [migrations] config and --enhanced-migration in args.\n` + + "Remove --enhanced-migration from [canisters." + + canisterName + + "].args — it is managed automatically when [migrations] is configured.", + ); + } } diff --git a/cli/specs/managed-migrations.md b/cli/specs/managed-migrations.md new file mode 100644 index 00000000..c8eea37d --- /dev/null +++ b/cli/specs/managed-migrations.md @@ -0,0 +1,199 @@ +# Managed Migrations + +**Status**: Draft specification + +## Problem + +Users of `--enhanced-migration` must manually: +1. Name migration files with timestamp prefixes and place them in the correct directory +2. Keep `.most` files around for stability checks +3. Pass `--enhanced-migration=` in canister args + +Mops should manage the migration lifecycle so users can focus on writing migration logic. + +## Overview + +Mops introduces a `[canisters..migrations]` config section that manages enhanced migrations as a first-class concept. The key ideas: + +- A **chain directory** holds the frozen (committed) migration files +- A **next-migration directory** holds 0 or 1 migration file currently being developed +- Mops merges both directories during `check` / `build` and auto-adds the `--enhanced-migration` flag to `moc` +- A `mops migrate freeze` command moves the next migration into the chain +- Configurable **chain trimming** limits the number of migrations compiled into the wasm + +## Config + +```toml +[canisters.backend] +main = "src/main.mo" + +[canisters.backend.migrations] +chain = "migrations" # path to frozen migration chain directory +next = "next-migration" # path to next-migration directory (0 or 1 files) +check-limit = 1 # max migrations in chain suffix for mops check (optional) +build-limit = 100 # max migrations in chain suffix for mops build (optional) +``` + +All paths are relative to `mops.toml`. + +### Fields + +| Field | Required | Description | +|-------|----------|-------------| +| `chain` | yes | Path to the directory containing frozen migration `.mo` files. Mops auto-adds `--enhanced-migration=` to `moc` args. Users must NOT also put `--enhanced-migration` in `[canisters.].args`. | +| `next` | yes | Path to the directory holding the next migration being developed. Must contain 0 or 1 `.mo` files. Required for `mops migrate` commands. | +| `check-limit` | no | Maximum number of migration files (from the end of the chain) to include when running `mops check`. When omitted, the full chain is used. | +| `build-limit` | no | Maximum number of migration files (from the end of the chain) to include when running `mops build`. When omitted, the full chain is used. | + +### Why separate limits + +- `check-limit = 1` gives fast iteration during development — only the latest migration is type-checked against the actor. +- `build-limit` controls the wasm size. Large migration chains produce large wasms that may exceed Internet Computer deployment limits (~2 MB). A `build-limit` of 100 means the wasm can handle up to 100 pending migrations during a single upgrade. + +## Directory Layout + +``` +backend/ +├── main.mo +├── migrations/ # chain: frozen migrations (committed to git) +│ ├── 20250101_000000_Init.mo +│ └── 20250201_000000_AddField.mo +└── next-migration/ # next: 0 or 1 file (committed to git) + └── 20260415_120000_AddEmail.mo # user picks the final name upfront +``` + +The file in `next-migration/` already has its permanent name. When frozen, it moves to `migrations/` unchanged. No renaming occurs. + +## Commands + +### `mops migrate new [canister]` + +Creates a new migration file in the `next` directory. + +- Generates a timestamp prefix: `YYYYMMDD_HHMMSS` +- Creates `/_.mo` with a migration module template +- If `[canister]` is omitted, auto-selects when exactly one canister has `[migrations]` configured; errors if multiple + +**Template content:** +```motoko +module { + public func migration(old : {}) : {} { + {} + } +} +``` + +**Errors:** +- `[migrations]` not configured in `mops.toml` +- `next` directory already contains a `.mo` file +- `chain` directory already contains a file that sorts after the generated name (should not happen with timestamps, but validated as a safety check) + +### `mops migrate freeze [canister]` + +Moves the next migration file into the frozen chain directory. + +- Moves the single `.mo` file from `next` to `chain` +- If `[canister]` is omitted, auto-selects when exactly one canister has `[migrations]` configured + +**Errors:** +- `[migrations]` not configured in `mops.toml` +- `next` directory is empty (no file to freeze) + +### Modified: `mops check` / `mops build` + +When `[canisters..migrations]` is configured: + +1. List `.mo` files in `chain` directory (sorted lexicographically) +2. If a `check-limit` or `build-limit` is set, take only the last N files (suffix of chain) +3. If `next` directory has a file, include it (so the temp dir has at most limit + 1 files) +4. Create a temp directory (inside `.mops/`) with symlinks or copies of these files +5. Auto-add `--enhanced-migration=` to `moc` args +6. If trimming is active, suppress M0254 warnings (see [Chain Trimming](#chain-trimming)) +7. Run `moc` +8. Clean up the temp directory + +When `next` is empty and no trimming is needed, pass `--enhanced-migration=` directly (no temp dir). + +## Chain Trimming + +### Mechanism + +Trimming removes a prefix of the migration chain so that `moc` only processes the last N migrations. This is done by creating a temp directory with only the relevant files. + +Example with `check-limit = 1` and a chain of `[Init, AddField, RenameField]` + next migration `AddEmail`: +- Temp dir contains: `RenameField.mo`, `AddEmail.mo` (1 from chain + 1 next) +- `Init.mo` and `AddField.mo` are excluded + +### M0254 Warning Suppression + +When the chain is trimmed, the first migration in the temp dir has a non-empty input type. The `moc` compiler emits M0254 warnings ("initial actor requires field X") for each field in that input. These warnings are expected and harmless — mops suppresses them automatically when trimming is active. + +### Runtime Safety + +The deployed canister's RTS tracks which migrations have been applied via `rts_was_migration_performed`. During an upgrade: +- Already-applied migrations are skipped +- Only new (unapplied) migrations execute + +A wasm built with `build-limit = 100` containing migrations 50–150 will correctly skip migrations 50–149 (already applied) and only execute migration 150. + +### Limits and the Next Migration + +The limit applies to **chain** files only. The next migration is always appended on top of the limited suffix, so the effective count in the temp dir is `min(chain_length, limit) + (1 if next exists)`. + +## Validation Rules + +| Rule | When checked | +|------|-------------| +| `next` dir must contain 0 or 1 `.mo` files | `check`, `build`, `migrate new`, `migrate freeze` | +| Next-migration filename must sort lexicographically after all files in `chain` dir | `check`, `build`, `migrate freeze` | +| `chain` path must exist (or be creatable) | `migrate new` (creates if missing) | +| `next` path must exist (or be creatable) | `migrate new` (creates if missing) | +| `[migrations]` config must be present | `migrate new`, `migrate freeze` (error if missing) | +| `check-limit` and `build-limit` must be > 0 | config validation | + +### Ordering Validation + +The next-migration filename must sort after every file in the chain directory using standard string comparison. No specific naming format is enforced — timestamps (`YYYYMMDD_HHMMSS_Name.mo`) are a convention that produces good lexicographic ordering, but any scheme that sorts correctly is valid. + +## Edge Cases + +| Scenario | Behavior | +|----------|----------| +| No `[migrations]` config on canister | Feature disabled. Everything works as before. Users can still use `--enhanced-migration` in `args` manually. | +| `[migrations]` configured, `next` dir empty | No pending migration. Use `chain` dir directly (with trimming if limits set, no temp dir needed otherwise). | +| `chain` dir empty + file in `next` | Valid. The next migration is the init migration (its input should be `{}`). | +| Neither `chain` nor `next` dir exists | `mops migrate new` creates both directories. `mops check` / `mops build` error if `chain` doesn't exist. | +| Limit larger than chain length | Use full chain, no trimming. | +| Limit = 0 | Invalid config, error at validation time. | +| `mops migrate new` when `next` has a file | Error: "A next migration already exists. Freeze it first with `mops migrate freeze`." | +| `mops migrate freeze` when `next` is empty | Error: "No next migration to freeze. Create one with `mops migrate new `." | +| Stable check fails and `[migrations]` is configured | Emit hint: "You may need a migration. Run `mops migrate new ` to create one." | + +## Interaction with Existing Features + +### `check-stable` + +The `[canisters..check-stable]` config continues to work independently. When both `[migrations]` and `[check-stable]` are configured: +1. `mops check` compiles with the merged migration chain (including next migration) +2. Then runs the stable compatibility check using the configured `check-stable.path` +3. If the stable check fails and `[migrations]` is configured, an extra hint is emitted + +### Canister `args` + +When `[migrations]` is configured, mops auto-adds `--enhanced-migration=` to `moc` invocations. Users must NOT also include `--enhanced-migration` in `[canisters.].args` — mops should detect this and emit an error to prevent duplicate/conflicting flags. + +## Scope + +### In scope (this feature) +- Next-migration lifecycle: `mops migrate new`, `mops migrate freeze` +- Chain trimming with configurable limits per command +- Auto `--enhanced-migration` flag management +- M0254 suppression during trimming +- Migration hint on stable check failure +- Validation of next-migration ordering and directory contents + +### Out of scope (future work) +- `.most` file management (auto-save deployed state, track deployments) +- Deployed state tracking (knowing what's actually on the canister) +- Auto-detecting whether a migration is needed (analyzing field changes) +- `mops migrate status` command (showing chain state, pending migrations) diff --git a/cli/tests/migrate.test.ts b/cli/tests/migrate.test.ts new file mode 100644 index 00000000..a8f7136a --- /dev/null +++ b/cli/tests/migrate.test.ts @@ -0,0 +1,154 @@ +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 } from "./helpers"; + +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; + } + + 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", "AddEmail"], { cwd }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toMatch(/Created migration/); + + 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}_AddEmail\.mo$/); + + const content = readFileSync(path.join(nextDir, files[0]!), "utf-8"); + expect(content).toContain("public func migration"); + }); + + 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 when [migrations] not configured", async () => { + const cwd = await makeTempFixture("basic"); + await writeFile( + path.join(cwd, "mops.toml"), + '[toolchain]\nmoc = "1.5.0"\n\n[canisters.backend]\nmain = "src/main.mo"\n', + ); + 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); + expect(result.stdout).toMatch(/Frozen migration/); + + 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("20250201_000000_AddField.mo"); + }); + + 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); + }); + }); + + describe("check with [migrations]", () => { + test("check passes with migrations config and no next migration", async () => { + const cwd = path.join(fixturesDir, "basic"); + const result = await cli(["check"], { cwd }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toMatch(/✓ backend/); + }); + + test("check passes with next migration included", async () => { + const cwd = path.join(fixturesDir, "with-next"); + const result = await cli(["check"], { cwd }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toMatch(/✓ backend/); + }); + }); + + describe("chain trimming", () => { + test("check passes with check-limit trimming the chain", async () => { + const cwd = path.join(fixturesDir, "trimmed"); + const result = await cli(["check", "--verbose"], { cwd }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toMatch(/✓ backend/); + expect(result.stdout).toMatch(/trimmed from 2/); + }); + }); + + describe("ordering validation", () => { + test("freeze 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("conflict detection", () => { + test("errors when both [migrations] and --enhanced-migration in args", async () => { + const cwd = await makeTempFixture("basic"); + await writeFile( + path.join(cwd, "mops.toml"), + `[toolchain] +moc = "1.5.0" + +[moc] +args = ["--default-persistent-actors"] + +[canisters.backend] +main = "src/main.mo" +args = ["--enhanced-migration=migrations"] + +[canisters.backend.migrations] +chain = "migrations" +next = "next-migration" +`, + ); + 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/migrations/20250101_000000_Init.mo b/cli/tests/migrate/basic/migrations/20250101_000000_Init.mo new file mode 100644 index 00000000..d706b262 --- /dev/null +++ b/cli/tests/migrate/basic/migrations/20250101_000000_Init.mo @@ -0,0 +1,8 @@ +module { + public func migration(_ : {}) : { a : Nat; b : Text } { + { + a = 42; + b = "hello"; + }; + }; +}; diff --git a/cli/tests/migrate/basic/mops.toml b/cli/tests/migrate/basic/mops.toml new file mode 100644 index 00000000..4c5bf870 --- /dev/null +++ b/cli/tests/migrate/basic/mops.toml @@ -0,0 +1,12 @@ +[toolchain] +moc = "1.5.0" + +[moc] +args = ["--default-persistent-actors"] + +[canisters.backend] +main = "src/main.mo" + +[canisters.backend.migrations] +chain = "migrations" +next = "next-migration" diff --git a/cli/tests/migrate/basic/src/main.mo b/cli/tests/migrate/basic/src/main.mo new file mode 100644 index 00000000..34aea994 --- /dev/null +++ b/cli/tests/migrate/basic/src/main.mo @@ -0,0 +1,10 @@ +import Prim "mo:prim"; + +actor { + let a : Nat; + let b : Text; + + public func check() : async () { + Prim.debugPrint(debug_show { a; b }); + }; +}; diff --git a/cli/tests/migrate/trimmed/migrations/20250101_000000_Init.mo b/cli/tests/migrate/trimmed/migrations/20250101_000000_Init.mo new file mode 100644 index 00000000..18ee1cef --- /dev/null +++ b/cli/tests/migrate/trimmed/migrations/20250101_000000_Init.mo @@ -0,0 +1,5 @@ +module { + public func migration(_ : {}) : { a : Nat } { + { a = 42 }; + }; +}; diff --git a/cli/tests/migrate/trimmed/migrations/20250201_000000_AddField.mo b/cli/tests/migrate/trimmed/migrations/20250201_000000_AddField.mo new file mode 100644 index 00000000..2563aca9 --- /dev/null +++ b/cli/tests/migrate/trimmed/migrations/20250201_000000_AddField.mo @@ -0,0 +1,5 @@ +module { + public func migration(old : { a : Nat }) : { a : Nat; b : Text } { + { old with b = "hello" }; + }; +}; diff --git a/cli/tests/migrate/trimmed/mops.toml b/cli/tests/migrate/trimmed/mops.toml new file mode 100644 index 00000000..c76bd4d3 --- /dev/null +++ b/cli/tests/migrate/trimmed/mops.toml @@ -0,0 +1,13 @@ +[toolchain] +moc = "1.5.0" + +[moc] +args = ["--default-persistent-actors"] + +[canisters.backend] +main = "src/main.mo" + +[canisters.backend.migrations] +chain = "migrations" +next = "next-migration" +check-limit = 1 diff --git a/cli/tests/migrate/trimmed/src/main.mo b/cli/tests/migrate/trimmed/src/main.mo new file mode 100644 index 00000000..34aea994 --- /dev/null +++ b/cli/tests/migrate/trimmed/src/main.mo @@ -0,0 +1,10 @@ +import Prim "mo:prim"; + +actor { + let a : Nat; + let b : Text; + + public func check() : async () { + Prim.debugPrint(debug_show { a; b }); + }; +}; 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..d706b262 --- /dev/null +++ b/cli/tests/migrate/with-next/migrations/20250101_000000_Init.mo @@ -0,0 +1,8 @@ +module { + public func migration(_ : {}) : { a : Nat; b : Text } { + { + a = 42; + b = "hello"; + }; + }; +}; diff --git a/cli/tests/migrate/with-next/mops.toml b/cli/tests/migrate/with-next/mops.toml new file mode 100644 index 00000000..4c5bf870 --- /dev/null +++ b/cli/tests/migrate/with-next/mops.toml @@ -0,0 +1,12 @@ +[toolchain] +moc = "1.5.0" + +[moc] +args = ["--default-persistent-actors"] + +[canisters.backend] +main = "src/main.mo" + +[canisters.backend.migrations] +chain = "migrations" +next = "next-migration" diff --git a/cli/tests/migrate/with-next/next-migration/20250201_000000_AddField.mo b/cli/tests/migrate/with-next/next-migration/20250201_000000_AddField.mo new file mode 100644 index 00000000..432414c9 --- /dev/null +++ b/cli/tests/migrate/with-next/next-migration/20250201_000000_AddField.mo @@ -0,0 +1,9 @@ +module { + public func migration(old : { a : Nat; b : Text }) : { + a : Nat; + b : Text; + c : Bool; + } { + { old with c = true }; + }; +}; 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..6efaf107 --- /dev/null +++ b/cli/tests/migrate/with-next/src/main.mo @@ -0,0 +1,11 @@ +import Prim "mo:prim"; + +actor { + let a : Nat; + let b : Text; + let c : Bool; + + public func check() : async () { + Prim.debugPrint(debug_show { a; b; c }); + }; +}; diff --git a/cli/types.ts b/cli/types.ts index cbbf92aa..a9a93b8d 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..78ea6966 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 orthogonal persistence migrations for a canister. When set, `mops check` and `mops build` 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 staging directory for the next migration (required). Must contain 0 or 1 `.mo` files | +| check-limit | Max number of migrations to include when running `mops check` (optional). When set, only the last N migrations from the chain are used | +| build-limit | Max number of migrations to include when running `mops build` (optional). When set, only the last N migrations from the chain are used | + +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..ae5552ae 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 migration chain (and any staged next-migration) is 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..02445ceb 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 migration chain (and any staged next-migration) is 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/08-mops-migrate.md b/docs/docs/cli/4-dev/08-mops-migrate.md new file mode 100644 index 00000000..82efa22e --- /dev/null +++ b/docs/docs/cli/4-dev/08-mops-migrate.md @@ -0,0 +1,82 @@ +--- +slug: /cli/mops-migrate +sidebar_label: mops migrate +--- + +# `mops migrate` + +Manage enhanced orthogonal persistence migration chains. + +Migration files define how canister state transforms from one version to the next. 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 staging 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` staging 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`. 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. + +Already-applied migrations are skipped at runtime by the Motoko RTS, so trimming is safe. When trimming is active, M0254 warnings are automatically suppressed. From ee666f886c6f162b58bea4f8b86d2eba26a93a98 Mon Sep 17 00:00:00 2001 From: Kamil Listopad Date: Wed, 15 Apr 2026 16:43:09 +0200 Subject: [PATCH 02/12] =?UTF-8?q?fix:=20address=20review=20findings=20?= =?UTF-8?q?=E2=80=94=20input=20validation,=20conflict=20checks,=20perf?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sanitize migration name (reject path traversal, spaces, invalid chars) - Extend --enhanced-migration conflict check to [moc].args and [build].args - Pass verbose to prepareMigrationArgs in check-stable for consistency - Validate check-limit/build-limit as positive integers (reject floats) - Use UTC in generateTimestamp for cross-timezone consistency - Eliminate redundant readdirSync in prepareMigrationArgs (pass chainFiles) - Add test for invalid migration name rejection Made-with: Cursor --- cli/commands/build.ts | 2 +- cli/commands/check-stable.ts | 6 ++++-- cli/commands/check.ts | 2 +- cli/commands/migrate.ts | 13 +++++++++++-- cli/helpers/migrations.ts | 27 +++++++++++++-------------- cli/helpers/resolve-canisters.ts | 26 ++++++++++++++++---------- cli/tests/migrate.test.ts | 9 +++++++++ 7 files changed, 55 insertions(+), 30 deletions(-) diff --git a/cli/commands/build.ts b/cli/commands/build.ts index 13db4fd4..249c95f7 100644 --- a/cli/commands/build.ts +++ b/cli/commands/build.ts @@ -247,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 766289c4..04e7c707 100644 --- a/cli/commands/check-stable.ts +++ b/cli/commands/check-stable.ts @@ -71,12 +71,13 @@ export async function checkStable( cliError(`No main file specified for canister '${name}' in mops.toml`); } - validateCanisterArgs(canister, name); + validateCanisterArgs(canister, name, config); const migration = await prepareMigrationArgs( canister.migrations, name, "check", + options.verbose, ); try { await runStableCheck({ @@ -106,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, }); @@ -118,6 +119,7 @@ export async function checkStable( canister.migrations, name, "check", + options.verbose, ); try { await runStableCheck({ diff --git a/cli/commands/check.ts b/cli/commands/check.ts index 741236c6..11652b56 100644 --- a/cli/commands/check.ts +++ b/cli/commands/check.ts @@ -145,7 +145,7 @@ async function checkCanisters( ); } - validateCanisterArgs(canister, canisterName); + validateCanisterArgs(canister, canisterName, config); const motokoPath = resolveConfigPath(canister.main); const migration = await prepareMigrationArgs( diff --git a/cli/commands/migrate.ts b/cli/commands/migrate.ts index f61beec8..c6889b96 100644 --- a/cli/commands/migrate.ts +++ b/cli/commands/migrate.ts @@ -56,12 +56,14 @@ function resolveMigrationCanister(canisterName?: string): { 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.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}` + - `_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}` + `${now.getUTCFullYear()}${pad(now.getUTCMonth() + 1)}${pad(now.getUTCDate())}` + + `_${pad(now.getUTCHours())}${pad(now.getUTCMinutes())}${pad(now.getUTCSeconds())}` ); } @@ -76,6 +78,13 @@ 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!; diff --git a/cli/helpers/migrations.ts b/cli/helpers/migrations.ts index 1507af20..7231cdd4 100644 --- a/cli/helpers/migrations.ts +++ b/cli/helpers/migrations.ts @@ -36,10 +36,13 @@ export function getNextMigrationFile(nextDir: string): string | null { } export function validateNextMigrationOrder( - chainDir: string, + chainDirOrFiles: string | string[], nextFile: string, ): void { - const chainFiles = getMigrationFiles(chainDir); + const chainFiles = + typeof chainDirOrFiles === "string" + ? getMigrationFiles(chainDirOrFiles) + : chainDirOrFiles; const lastChainFile = chainFiles[chainFiles.length - 1]; if (lastChainFile && nextFile <= lastChainFile) { cliError( @@ -64,17 +67,13 @@ export function validateMigrationsConfig( `[canisters.${canisterName}.migrations] is missing required field "next"`, ); } - if ( - migrations["check-limit"] !== undefined && - migrations["check-limit"] <= 0 - ) { - cliError(`[canisters.${canisterName}.migrations] check-limit must be > 0`); - } - if ( - migrations["build-limit"] !== undefined && - migrations["build-limit"] <= 0 - ) { - cliError(`[canisters.${canisterName}.migrations] build-limit must be > 0`); + 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`, + ); + } } } @@ -109,7 +108,7 @@ export async function prepareMigrationArgs( const chainFiles = getMigrationFiles(chainDir); if (nextFile) { - validateNextMigrationOrder(chainDir, nextFile); + validateNextMigrationOrder(chainFiles, nextFile); } const limit = diff --git a/cli/helpers/resolve-canisters.ts b/cli/helpers/resolve-canisters.ts index 0f1a7203..03e6bb7d 100644 --- a/cli/helpers/resolve-canisters.ts +++ b/cli/helpers/resolve-canisters.ts @@ -74,21 +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 && - canister.args?.some((a) => a.startsWith("--enhanced-migration")) - ) { - cliError( - `Canister '${canisterName}' has both [migrations] config and --enhanced-migration in args.\n` + - "Remove --enhanced-migration from [canisters." + - canisterName + - "].args — it is managed automatically when [migrations] is configured.", - ); + 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/migrate.test.ts b/cli/tests/migrate.test.ts index a8f7136a..90141657 100644 --- a/cli/tests/migrate.test.ts +++ b/cli/tests/migrate.test.ts @@ -48,6 +48,15 @@ describe("migrate", () => { 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"); await writeFile( From e3b110cf66b6e86d03221f96d5a3499d14e1feb0 Mon Sep 17 00:00:00 2001 From: Kamil Listopad Date: Wed, 15 Apr 2026 16:46:17 +0200 Subject: [PATCH 03/12] docs: mention check-stable in migration docs, changelog, and skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check-stable also auto-injects --enhanced-migration when [migrations] is configured — align all doc surfaces for consistency. Made-with: Cursor --- .agents/skills/mops-cli/SKILL.md | 2 +- cli/CHANGELOG.md | 4 ++-- docs/docs/09-mops.toml.md | 2 +- docs/docs/cli/4-dev/05-mops-check-stable.md | 6 ++++++ 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/.agents/skills/mops-cli/SKILL.md b/.agents/skills/mops-cli/SKILL.md index 829e6635..87263035 100644 --- a/.agents/skills/mops-cli/SKILL.md +++ b/.agents/skills/mops-cli/SKILL.md @@ -142,7 +142,7 @@ mops migrate freeze # move next-migration to the permanent chain mops migrate freeze backend # specify canister explicitly ``` -When `[canisters..migrations]` is configured, `mops check` and `mops build` automatically inject `--enhanced-migration`. Do not add `--enhanced-migration` to `[canisters.].args` when using managed migrations — mops will error. +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`. diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 0c8e053c..31285467 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -3,8 +3,8 @@ ## Next - Add `mops migrate new ` and `mops migrate freeze` commands for managing enhanced orthogonal persistence migration chains - Add `[canisters..migrations]` config section with `chain`, `next`, `check-limit`, and `build-limit` fields -- `mops check` and `mops build` now auto-inject `--enhanced-migration` when `[migrations]` is configured -- `mops check` emits a hint to create a migration when a stable compatibility check fails and `[migrations]` is configured +- `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 diff --git a/docs/docs/09-mops.toml.md b/docs/docs/09-mops.toml.md index 78ea6966..3ad32be6 100644 --- a/docs/docs/09-mops.toml.md +++ b/docs/docs/09-mops.toml.md @@ -141,7 +141,7 @@ skipIfMissing = true ### `[canisters..migrations]` -Configure managed enhanced orthogonal persistence migrations for a canister. When set, `mops check` and `mops build` auto-inject `--enhanced-migration` and you can use [`mops migrate`](/cli/mops-migrate) commands to manage the migration chain. +Configure managed enhanced orthogonal persistence migrations 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 | | ----------- | --------------------------------------------------------------- | 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. From b84c46dc9d9240f8f8d06a3019a24df4ea9ca6f6 Mon Sep 17 00:00:00 2001 From: Kamil Listopad Date: Wed, 15 Apr 2026 16:55:51 +0200 Subject: [PATCH 04/12] =?UTF-8?q?fix:=20correct=20wording=20=E2=80=94=20"e?= =?UTF-8?q?nhanced=20migrations"=20not=20"enhanced=20orthogonal=20persiste?= =?UTF-8?q?nce"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Made-with: Cursor --- .agents/skills/mops-cli/SKILL.md | 2 +- cli/CHANGELOG.md | 2 +- cli/cli.ts | 2 +- docs/docs/09-mops.toml.md | 2 +- docs/docs/cli/4-dev/08-mops-migrate.md | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.agents/skills/mops-cli/SKILL.md b/.agents/skills/mops-cli/SKILL.md index 87263035..3fca2edd 100644 --- a/.agents/skills/mops-cli/SKILL.md +++ b/.agents/skills/mops-cli/SKILL.md @@ -133,7 +133,7 @@ mops toolchain bin moc # print path to binary ### `mops migrate` -Manage enhanced orthogonal persistence migration chains: +Manage enhanced migration chains: ```bash mops migrate new AddEmail # create a new migration file in next-migration/ diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 31285467..48276719 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -1,7 +1,7 @@ # Mops CLI Changelog ## Next -- Add `mops migrate new ` and `mops migrate freeze` commands for managing enhanced orthogonal persistence migration chains +- 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 diff --git a/cli/cli.ts b/cli/cli.ts index 5fbf7fc9..3b7ddba3 100755 --- a/cli/cli.ts +++ b/cli/cli.ts @@ -710,7 +710,7 @@ program.addCommand(toolchainCommand); // migrate const migrateCommand = new Command("migrate").description( - "Migration management for enhanced orthogonal persistence", + "Migration management for enhanced migrations", ); migrateCommand diff --git a/docs/docs/09-mops.toml.md b/docs/docs/09-mops.toml.md index 3ad32be6..731c6484 100644 --- a/docs/docs/09-mops.toml.md +++ b/docs/docs/09-mops.toml.md @@ -141,7 +141,7 @@ skipIfMissing = true ### `[canisters..migrations]` -Configure managed enhanced orthogonal persistence migrations 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. +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 | | ----------- | --------------------------------------------------------------- | diff --git a/docs/docs/cli/4-dev/08-mops-migrate.md b/docs/docs/cli/4-dev/08-mops-migrate.md index 82efa22e..e750b8c3 100644 --- a/docs/docs/cli/4-dev/08-mops-migrate.md +++ b/docs/docs/cli/4-dev/08-mops-migrate.md @@ -5,7 +5,7 @@ sidebar_label: mops migrate # `mops migrate` -Manage enhanced orthogonal persistence migration chains. +Manage enhanced migration chains. Migration files define how canister state transforms from one version to the next. Each migration is a Motoko module with a `migration` function that takes the old state shape and returns the new one. From 65a578b17960ff9388b3c5f62315c61c4e45cad4 Mon Sep 17 00:00:00 2001 From: Kamil Listopad Date: Wed, 15 Apr 2026 16:57:03 +0200 Subject: [PATCH 05/12] fix: add .gitkeep to empty next-migration fixture dirs Git doesn't track empty directories, so cp on CI skips them and tests fail with ENOENT when writing into the missing dir. Made-with: Cursor --- cli/tests/migrate/basic/next-migration/.gitkeep | 0 cli/tests/migrate/trimmed/next-migration/.gitkeep | 0 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 cli/tests/migrate/basic/next-migration/.gitkeep create mode 100644 cli/tests/migrate/trimmed/next-migration/.gitkeep 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/trimmed/next-migration/.gitkeep b/cli/tests/migrate/trimmed/next-migration/.gitkeep new file mode 100644 index 00000000..e69de29b From e4b1d5eaa38585cd4c0e41ccce945a66546171df Mon Sep 17 00:00:00 2001 From: Kamil Listopad Date: Thu, 16 Apr 2026 13:37:34 +0200 Subject: [PATCH 06/12] agents uopdate --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) 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 From fac32485cebeb52e84d219d507db55223cecaf3d Mon Sep 17 00:00:00 2001 From: Kamil Listopad Date: Thu, 16 Apr 2026 13:38:01 +0200 Subject: [PATCH 07/12] docs updates --- docs/docs/09-mops.toml.md | 2 +- docs/docs/cli/4-dev/03-mops-build.md | 2 +- docs/docs/cli/4-dev/04-mops-check.md | 2 +- docs/docs/cli/4-dev/08-mops-migrate.md | 6 +++--- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/docs/09-mops.toml.md b/docs/docs/09-mops.toml.md index 731c6484..732f4557 100644 --- a/docs/docs/09-mops.toml.md +++ b/docs/docs/09-mops.toml.md @@ -146,7 +146,7 @@ Configure managed enhanced migration chains for a canister. When set, `mops chec | Field | Description | | ----------- | --------------------------------------------------------------- | | chain | Path to the directory containing frozen migration files (required) | -| next | Path to the staging directory for the next migration (required). Must contain 0 or 1 `.mo` files | +| next | Path to the directory for the next pending migration (required). Must contain 0 or 1 `.mo` files | | check-limit | Max number of migrations to include when running `mops check` (optional). When set, only the last N migrations from the chain are used | | build-limit | Max number of migrations to include when running `mops build` (optional). When set, only the last N migrations from the chain are used | diff --git a/docs/docs/cli/4-dev/03-mops-build.md b/docs/docs/cli/4-dev/03-mops-build.md index ae5552ae..2eec86cb 100644 --- a/docs/docs/cli/4-dev/03-mops-build.md +++ b/docs/docs/cli/4-dev/03-mops-build.md @@ -96,7 +96,7 @@ 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 migration chain (and any staged next-migration) is included in the compiled WASM. +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. diff --git a/docs/docs/cli/4-dev/04-mops-check.md b/docs/docs/cli/4-dev/04-mops-check.md index 02445ceb..9a163d53 100644 --- a/docs/docs/cli/4-dev/04-mops-check.md +++ b/docs/docs/cli/4-dev/04-mops-check.md @@ -115,7 +115,7 @@ 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 migration chain (and any staged next-migration) is assembled into a temporary directory and passed to `moc`. +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 `. diff --git a/docs/docs/cli/4-dev/08-mops-migrate.md b/docs/docs/cli/4-dev/08-mops-migrate.md index e750b8c3..6b4011aa 100644 --- a/docs/docs/cli/4-dev/08-mops-migrate.md +++ b/docs/docs/cli/4-dev/08-mops-migrate.md @@ -7,7 +7,7 @@ sidebar_label: mops migrate Manage enhanced migration chains. -Migration files define how canister state transforms from one version to the next. Each migration is a Motoko module with a `migration` function that takes the old state shape and returns the new one. +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` @@ -15,7 +15,7 @@ Migration files define how canister state transforms from one version to the nex mops migrate new [canister] ``` -Create a new migration file in the staging directory configured by `[canisters..migrations].next`. +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 @@ -35,7 +35,7 @@ mops migrate new RemoveCounter backend mops migrate freeze [canister] ``` -Move the migration file from the `next` staging directory into the `chain` directory, making it part of the permanent migration chain. +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 From 44dd2bcf3e60ba293aa126976e6287145e869307 Mon Sep 17 00:00:00 2001 From: Kamil Listopad Date: Thu, 16 Apr 2026 13:55:52 +0200 Subject: [PATCH 08/12] test: redesign migration test fixtures and add snapshot coverage Consolidate 4 fixtures (basic, with-next, trimmed, incompatible) into 2 richer fixtures with 3-migration chains. All tests copy a fixture to a temp dir and apply targeted modifications (remove next-migration, patch mops.toml, overwrite deployed.most) to exercise different scenarios. New snapshot-tested use cases: - migrate new / freeze output - check fails without next migration, passes with it - check with chain trimming (verbose) - build produces .most with full and trimmed chain - stable check failure with migration hint Made-with: Cursor --- cli/tests/__snapshots__/migrate.test.ts.snap | 102 ++++++++++++ cli/tests/migrate.test.ts | 148 ++++++++++++------ cli/tests/migrate/basic/deployed.most | 12 ++ .../basic/migrations/20250101_000000_Init.mo | 7 +- .../migrations/20250201_000000_AddName.mo | 5 + .../migrations/20250301_000000_AddEmail.mo | 9 ++ cli/tests/migrate/basic/mops.toml | 3 + cli/tests/migrate/basic/src/main.mo | 5 +- .../migrations/20250101_000000_Init.mo | 5 - .../migrations/20250201_000000_AddField.mo | 5 - cli/tests/migrate/trimmed/mops.toml | 13 -- .../migrate/trimmed/next-migration/.gitkeep | 0 cli/tests/migrate/trimmed/src/main.mo | 10 -- cli/tests/migrate/with-next/deployed.most | 12 ++ .../migrations/20250101_000000_Init.mo | 7 +- .../migrations/20250201_000000_AddName.mo | 5 + .../migrations/20250301_000000_AddEmail.mo | 9 ++ cli/tests/migrate/with-next/mops.toml | 3 + .../20250201_000000_AddField.mo | 9 -- .../20250401_000000_RenameId.mo | 9 ++ cli/tests/migrate/with-next/src/main.mo | 8 +- 21 files changed, 280 insertions(+), 106 deletions(-) create mode 100644 cli/tests/__snapshots__/migrate.test.ts.snap create mode 100644 cli/tests/migrate/basic/deployed.most create mode 100644 cli/tests/migrate/basic/migrations/20250201_000000_AddName.mo create mode 100644 cli/tests/migrate/basic/migrations/20250301_000000_AddEmail.mo delete mode 100644 cli/tests/migrate/trimmed/migrations/20250101_000000_Init.mo delete mode 100644 cli/tests/migrate/trimmed/migrations/20250201_000000_AddField.mo delete mode 100644 cli/tests/migrate/trimmed/mops.toml delete mode 100644 cli/tests/migrate/trimmed/next-migration/.gitkeep delete mode 100644 cli/tests/migrate/trimmed/src/main.mo create mode 100644 cli/tests/migrate/with-next/deployed.most create mode 100644 cli/tests/migrate/with-next/migrations/20250201_000000_AddName.mo create mode 100644 cli/tests/migrate/with-next/migrations/20250301_000000_AddEmail.mo delete mode 100644 cli/tests/migrate/with-next/next-migration/20250201_000000_AddField.mo create mode 100644 cli/tests/migrate/with-next/next-migration/20250401_000000_RenameId.mo diff --git a/cli/tests/__snapshots__/migrate.test.ts.snap b/cli/tests/__snapshots__/migrate.test.ts.snap new file mode 100644 index 00000000..8bc8f796 --- /dev/null +++ b/cli/tests/__snapshots__/migrate.test.ts.snap @@ -0,0 +1,102 @@ +// 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 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 index 90141657..31839aa8 100644 --- a/cli/tests/migrate.test.ts +++ b/cli/tests/migrate.test.ts @@ -2,7 +2,10 @@ 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 } from "./helpers"; +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"); @@ -17,6 +20,18 @@ describe("migrate", () => { 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 }); @@ -27,17 +42,22 @@ describe("migrate", () => { describe("migrate new", () => { test("creates a migration file with timestamp and template", async () => { const cwd = await makeTempFixture("basic"); - const result = await cli(["migrate", "new", "AddEmail"], { cwd }); + const result = await cli(["migrate", "new", "AddPhone"], { cwd }); expect(result.exitCode).toBe(0); - expect(result.stdout).toMatch(/Created migration/); 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}_AddEmail\.mo$/); + expect(files[0]).toMatch(/^\d{8}_\d{6}_AddPhone\.mo$/); const content = readFileSync(path.join(nextDir, files[0]!), "utf-8"); - expect(content).toContain("public func migration"); + + 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 () => { @@ -59,9 +79,11 @@ describe("migrate", () => { 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( - path.join(cwd, "mops.toml"), - '[toolchain]\nmoc = "1.5.0"\n\n[canisters.backend]\nmain = "src/main.mo"\n', + tomlPath, + toml.replace(/\[canisters\.backend\.migrations\][\s\S]*$/, ""), ); const result = await cli(["migrate", "new", "Test"], { cwd }); expect(result.exitCode).toBe(1); @@ -74,7 +96,6 @@ describe("migrate", () => { const cwd = await makeTempFixture("with-next"); const result = await cli(["migrate", "freeze"], { cwd }); expect(result.exitCode).toBe(0); - expect(result.stdout).toMatch(/Frozen migration/); const nextFiles = readdirSync(path.join(cwd, "next-migration")).filter( (f) => f.endsWith(".mo"), @@ -84,7 +105,13 @@ describe("migrate", () => { const chainFiles = readdirSync(path.join(cwd, "migrations")).filter((f) => f.endsWith(".mo"), ); - expect(chainFiles).toContain("20250201_000000_AddField.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 () => { @@ -93,66 +120,91 @@ describe("migrate", () => { 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 with [migrations]", () => { - test("check passes with migrations config and no next migration", async () => { - const cwd = path.join(fixturesDir, "basic"); - const result = await cli(["check"], { cwd }); - expect(result.exitCode).toBe(0); - expect(result.stdout).toMatch(/✓ backend/); + 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 passes with next migration included", async () => { - const cwd = path.join(fixturesDir, "with-next"); - const result = await cli(["check"], { cwd }); - expect(result.exitCode).toBe(0); - expect(result.stdout).toMatch(/✓ backend/); + 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("chain trimming", () => { - test("check passes with check-limit trimming the chain", async () => { - const cwd = path.join(fixturesDir, "trimmed"); - const result = await cli(["check", "--verbose"], { cwd }); + 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); - expect(result.stdout).toMatch(/✓ backend/); - expect(result.stdout).toMatch(/trimmed from 2/); + + const most = readFileSync( + path.join(cwd, ".mops", ".build", "backend.most"), + "utf-8", + ); + expect(most).toMatchSnapshot(); }); }); - describe("ordering validation", () => { - test("freeze errors when next-migration does not sort last", async () => { + 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, "next-migration", "00000000_000000_Early.mo"), - "module {\n public func migration(_ : {}) : {} {\n {}\n }\n}\n", + path.join(cwd, "deployed.most"), + "// Version: 1.0.0\nactor {\n stable var a : Nat;\n stable var name : Int\n};\n", ); - const result = await cli(["migrate", "freeze"], { cwd }); - expect(result.exitCode).toBe(1); - expect(result.stderr).toMatch(/must sort after/i); + 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( - path.join(cwd, "mops.toml"), - `[toolchain] -moc = "1.5.0" - -[moc] -args = ["--default-persistent-actors"] - -[canisters.backend] -main = "src/main.mo" -args = ["--enhanced-migration=migrations"] - -[canisters.backend.migrations] -chain = "migrations" -next = "next-migration" -`, + 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); 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 index d706b262..192370fd 100644 --- a/cli/tests/migrate/basic/migrations/20250101_000000_Init.mo +++ b/cli/tests/migrate/basic/migrations/20250101_000000_Init.mo @@ -1,8 +1,5 @@ module { - public func migration(_ : {}) : { a : Nat; b : Text } { - { - a = 42; - b = "hello"; - }; + 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 index 4c5bf870..8b3819dc 100644 --- a/cli/tests/migrate/basic/mops.toml +++ b/cli/tests/migrate/basic/mops.toml @@ -10,3 +10,6 @@ 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/src/main.mo b/cli/tests/migrate/basic/src/main.mo index 34aea994..a66e678c 100644 --- a/cli/tests/migrate/basic/src/main.mo +++ b/cli/tests/migrate/basic/src/main.mo @@ -2,9 +2,10 @@ import Prim "mo:prim"; actor { let a : Nat; - let b : Text; + let name : Text; + let email : Text; public func check() : async () { - Prim.debugPrint(debug_show { a; b }); + Prim.debugPrint(debug_show { a; name; email }); }; }; diff --git a/cli/tests/migrate/trimmed/migrations/20250101_000000_Init.mo b/cli/tests/migrate/trimmed/migrations/20250101_000000_Init.mo deleted file mode 100644 index 18ee1cef..00000000 --- a/cli/tests/migrate/trimmed/migrations/20250101_000000_Init.mo +++ /dev/null @@ -1,5 +0,0 @@ -module { - public func migration(_ : {}) : { a : Nat } { - { a = 42 }; - }; -}; diff --git a/cli/tests/migrate/trimmed/migrations/20250201_000000_AddField.mo b/cli/tests/migrate/trimmed/migrations/20250201_000000_AddField.mo deleted file mode 100644 index 2563aca9..00000000 --- a/cli/tests/migrate/trimmed/migrations/20250201_000000_AddField.mo +++ /dev/null @@ -1,5 +0,0 @@ -module { - public func migration(old : { a : Nat }) : { a : Nat; b : Text } { - { old with b = "hello" }; - }; -}; diff --git a/cli/tests/migrate/trimmed/mops.toml b/cli/tests/migrate/trimmed/mops.toml deleted file mode 100644 index c76bd4d3..00000000 --- a/cli/tests/migrate/trimmed/mops.toml +++ /dev/null @@ -1,13 +0,0 @@ -[toolchain] -moc = "1.5.0" - -[moc] -args = ["--default-persistent-actors"] - -[canisters.backend] -main = "src/main.mo" - -[canisters.backend.migrations] -chain = "migrations" -next = "next-migration" -check-limit = 1 diff --git a/cli/tests/migrate/trimmed/next-migration/.gitkeep b/cli/tests/migrate/trimmed/next-migration/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/cli/tests/migrate/trimmed/src/main.mo b/cli/tests/migrate/trimmed/src/main.mo deleted file mode 100644 index 34aea994..00000000 --- a/cli/tests/migrate/trimmed/src/main.mo +++ /dev/null @@ -1,10 +0,0 @@ -import Prim "mo:prim"; - -actor { - let a : Nat; - let b : Text; - - public func check() : async () { - Prim.debugPrint(debug_show { a; b }); - }; -}; 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 index d706b262..192370fd 100644 --- a/cli/tests/migrate/with-next/migrations/20250101_000000_Init.mo +++ b/cli/tests/migrate/with-next/migrations/20250101_000000_Init.mo @@ -1,8 +1,5 @@ module { - public func migration(_ : {}) : { a : Nat; b : Text } { - { - a = 42; - b = "hello"; - }; + 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 index 4c5bf870..8b3819dc 100644 --- a/cli/tests/migrate/with-next/mops.toml +++ b/cli/tests/migrate/with-next/mops.toml @@ -10,3 +10,6 @@ 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/20250201_000000_AddField.mo b/cli/tests/migrate/with-next/next-migration/20250201_000000_AddField.mo deleted file mode 100644 index 432414c9..00000000 --- a/cli/tests/migrate/with-next/next-migration/20250201_000000_AddField.mo +++ /dev/null @@ -1,9 +0,0 @@ -module { - public func migration(old : { a : Nat; b : Text }) : { - a : Nat; - b : Text; - c : Bool; - } { - { old with c = true }; - }; -}; 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 index 6efaf107..2ffb4dea 100644 --- a/cli/tests/migrate/with-next/src/main.mo +++ b/cli/tests/migrate/with-next/src/main.mo @@ -1,11 +1,11 @@ import Prim "mo:prim"; actor { - let a : Nat; - let b : Text; - let c : Bool; + let id : Nat; + let name : Text; + let email : Text; public func check() : async () { - Prim.debugPrint(debug_show { a; b; c }); + Prim.debugPrint(debug_show { id; name; email }); }; }; From 114a4f28f9702486581a322fcc64f39fbbc97322 Mon Sep 17 00:00:00 2001 From: Kamil Listopad Date: Thu, 16 Apr 2026 14:06:02 +0200 Subject: [PATCH 09/12] polish: improve CLI description wording and clarify check-limit scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "Migration management for enhanced migrations" → "Manage enhanced migration chains" - Clarify in docs that check-limit applies to mops check-stable too Made-with: Cursor --- cli/cli.ts | 2 +- docs/docs/09-mops.toml.md | 2 +- docs/docs/cli/4-dev/08-mops-migrate.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cli/cli.ts b/cli/cli.ts index 3b7ddba3..c02f5c1b 100755 --- a/cli/cli.ts +++ b/cli/cli.ts @@ -710,7 +710,7 @@ program.addCommand(toolchainCommand); // migrate const migrateCommand = new Command("migrate").description( - "Migration management for enhanced migrations", + "Manage enhanced migration chains", ); migrateCommand diff --git a/docs/docs/09-mops.toml.md b/docs/docs/09-mops.toml.md index 732f4557..86c24fb6 100644 --- a/docs/docs/09-mops.toml.md +++ b/docs/docs/09-mops.toml.md @@ -147,7 +147,7 @@ Configure managed enhanced migration chains for a canister. When set, `mops chec | ----------- | --------------------------------------------------------------- | | chain | Path to the directory containing frozen migration files (required) | | next | Path to the directory for the next pending migration (required). Must contain 0 or 1 `.mo` files | -| check-limit | Max number of migrations to include when running `mops check` (optional). When set, only the last N migrations from the chain are used | +| check-limit | Max number of migrations to include when running `mops check` and `mops check-stable` (optional). When set, only the last N migrations from the chain are used | | build-limit | Max number of migrations to include when running `mops build` (optional). When set, only the last N migrations from the chain are used | Example: diff --git a/docs/docs/cli/4-dev/08-mops-migrate.md b/docs/docs/cli/4-dev/08-mops-migrate.md index 6b4011aa..e9d201de 100644 --- a/docs/docs/cli/4-dev/08-mops-migrate.md +++ b/docs/docs/cli/4-dev/08-mops-migrate.md @@ -76,7 +76,7 @@ See [`mops.toml` reference](/mops.toml#canistersnamemigrations) for all fields. 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`. Set to `1` for fastest type-checking. +- **`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. Already-applied migrations are skipped at runtime by the Motoko RTS, so trimming is safe. When trimming is active, M0254 warnings are automatically suppressed. From 71d7b1c1a06a834dac0dcb6c5c74c1020cbf54b1 Mon Sep 17 00:00:00 2001 From: Kamil Listopad Date: Thu, 16 Apr 2026 14:09:09 +0200 Subject: [PATCH 10/12] chore: remove managed-migrations spec file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No longer needed — the feature is implemented and documented. Made-with: Cursor --- cli/specs/managed-migrations.md | 199 -------------------------------- 1 file changed, 199 deletions(-) delete mode 100644 cli/specs/managed-migrations.md diff --git a/cli/specs/managed-migrations.md b/cli/specs/managed-migrations.md deleted file mode 100644 index c8eea37d..00000000 --- a/cli/specs/managed-migrations.md +++ /dev/null @@ -1,199 +0,0 @@ -# Managed Migrations - -**Status**: Draft specification - -## Problem - -Users of `--enhanced-migration` must manually: -1. Name migration files with timestamp prefixes and place them in the correct directory -2. Keep `.most` files around for stability checks -3. Pass `--enhanced-migration=` in canister args - -Mops should manage the migration lifecycle so users can focus on writing migration logic. - -## Overview - -Mops introduces a `[canisters..migrations]` config section that manages enhanced migrations as a first-class concept. The key ideas: - -- A **chain directory** holds the frozen (committed) migration files -- A **next-migration directory** holds 0 or 1 migration file currently being developed -- Mops merges both directories during `check` / `build` and auto-adds the `--enhanced-migration` flag to `moc` -- A `mops migrate freeze` command moves the next migration into the chain -- Configurable **chain trimming** limits the number of migrations compiled into the wasm - -## Config - -```toml -[canisters.backend] -main = "src/main.mo" - -[canisters.backend.migrations] -chain = "migrations" # path to frozen migration chain directory -next = "next-migration" # path to next-migration directory (0 or 1 files) -check-limit = 1 # max migrations in chain suffix for mops check (optional) -build-limit = 100 # max migrations in chain suffix for mops build (optional) -``` - -All paths are relative to `mops.toml`. - -### Fields - -| Field | Required | Description | -|-------|----------|-------------| -| `chain` | yes | Path to the directory containing frozen migration `.mo` files. Mops auto-adds `--enhanced-migration=` to `moc` args. Users must NOT also put `--enhanced-migration` in `[canisters.].args`. | -| `next` | yes | Path to the directory holding the next migration being developed. Must contain 0 or 1 `.mo` files. Required for `mops migrate` commands. | -| `check-limit` | no | Maximum number of migration files (from the end of the chain) to include when running `mops check`. When omitted, the full chain is used. | -| `build-limit` | no | Maximum number of migration files (from the end of the chain) to include when running `mops build`. When omitted, the full chain is used. | - -### Why separate limits - -- `check-limit = 1` gives fast iteration during development — only the latest migration is type-checked against the actor. -- `build-limit` controls the wasm size. Large migration chains produce large wasms that may exceed Internet Computer deployment limits (~2 MB). A `build-limit` of 100 means the wasm can handle up to 100 pending migrations during a single upgrade. - -## Directory Layout - -``` -backend/ -├── main.mo -├── migrations/ # chain: frozen migrations (committed to git) -│ ├── 20250101_000000_Init.mo -│ └── 20250201_000000_AddField.mo -└── next-migration/ # next: 0 or 1 file (committed to git) - └── 20260415_120000_AddEmail.mo # user picks the final name upfront -``` - -The file in `next-migration/` already has its permanent name. When frozen, it moves to `migrations/` unchanged. No renaming occurs. - -## Commands - -### `mops migrate new [canister]` - -Creates a new migration file in the `next` directory. - -- Generates a timestamp prefix: `YYYYMMDD_HHMMSS` -- Creates `/_.mo` with a migration module template -- If `[canister]` is omitted, auto-selects when exactly one canister has `[migrations]` configured; errors if multiple - -**Template content:** -```motoko -module { - public func migration(old : {}) : {} { - {} - } -} -``` - -**Errors:** -- `[migrations]` not configured in `mops.toml` -- `next` directory already contains a `.mo` file -- `chain` directory already contains a file that sorts after the generated name (should not happen with timestamps, but validated as a safety check) - -### `mops migrate freeze [canister]` - -Moves the next migration file into the frozen chain directory. - -- Moves the single `.mo` file from `next` to `chain` -- If `[canister]` is omitted, auto-selects when exactly one canister has `[migrations]` configured - -**Errors:** -- `[migrations]` not configured in `mops.toml` -- `next` directory is empty (no file to freeze) - -### Modified: `mops check` / `mops build` - -When `[canisters..migrations]` is configured: - -1. List `.mo` files in `chain` directory (sorted lexicographically) -2. If a `check-limit` or `build-limit` is set, take only the last N files (suffix of chain) -3. If `next` directory has a file, include it (so the temp dir has at most limit + 1 files) -4. Create a temp directory (inside `.mops/`) with symlinks or copies of these files -5. Auto-add `--enhanced-migration=` to `moc` args -6. If trimming is active, suppress M0254 warnings (see [Chain Trimming](#chain-trimming)) -7. Run `moc` -8. Clean up the temp directory - -When `next` is empty and no trimming is needed, pass `--enhanced-migration=` directly (no temp dir). - -## Chain Trimming - -### Mechanism - -Trimming removes a prefix of the migration chain so that `moc` only processes the last N migrations. This is done by creating a temp directory with only the relevant files. - -Example with `check-limit = 1` and a chain of `[Init, AddField, RenameField]` + next migration `AddEmail`: -- Temp dir contains: `RenameField.mo`, `AddEmail.mo` (1 from chain + 1 next) -- `Init.mo` and `AddField.mo` are excluded - -### M0254 Warning Suppression - -When the chain is trimmed, the first migration in the temp dir has a non-empty input type. The `moc` compiler emits M0254 warnings ("initial actor requires field X") for each field in that input. These warnings are expected and harmless — mops suppresses them automatically when trimming is active. - -### Runtime Safety - -The deployed canister's RTS tracks which migrations have been applied via `rts_was_migration_performed`. During an upgrade: -- Already-applied migrations are skipped -- Only new (unapplied) migrations execute - -A wasm built with `build-limit = 100` containing migrations 50–150 will correctly skip migrations 50–149 (already applied) and only execute migration 150. - -### Limits and the Next Migration - -The limit applies to **chain** files only. The next migration is always appended on top of the limited suffix, so the effective count in the temp dir is `min(chain_length, limit) + (1 if next exists)`. - -## Validation Rules - -| Rule | When checked | -|------|-------------| -| `next` dir must contain 0 or 1 `.mo` files | `check`, `build`, `migrate new`, `migrate freeze` | -| Next-migration filename must sort lexicographically after all files in `chain` dir | `check`, `build`, `migrate freeze` | -| `chain` path must exist (or be creatable) | `migrate new` (creates if missing) | -| `next` path must exist (or be creatable) | `migrate new` (creates if missing) | -| `[migrations]` config must be present | `migrate new`, `migrate freeze` (error if missing) | -| `check-limit` and `build-limit` must be > 0 | config validation | - -### Ordering Validation - -The next-migration filename must sort after every file in the chain directory using standard string comparison. No specific naming format is enforced — timestamps (`YYYYMMDD_HHMMSS_Name.mo`) are a convention that produces good lexicographic ordering, but any scheme that sorts correctly is valid. - -## Edge Cases - -| Scenario | Behavior | -|----------|----------| -| No `[migrations]` config on canister | Feature disabled. Everything works as before. Users can still use `--enhanced-migration` in `args` manually. | -| `[migrations]` configured, `next` dir empty | No pending migration. Use `chain` dir directly (with trimming if limits set, no temp dir needed otherwise). | -| `chain` dir empty + file in `next` | Valid. The next migration is the init migration (its input should be `{}`). | -| Neither `chain` nor `next` dir exists | `mops migrate new` creates both directories. `mops check` / `mops build` error if `chain` doesn't exist. | -| Limit larger than chain length | Use full chain, no trimming. | -| Limit = 0 | Invalid config, error at validation time. | -| `mops migrate new` when `next` has a file | Error: "A next migration already exists. Freeze it first with `mops migrate freeze`." | -| `mops migrate freeze` when `next` is empty | Error: "No next migration to freeze. Create one with `mops migrate new `." | -| Stable check fails and `[migrations]` is configured | Emit hint: "You may need a migration. Run `mops migrate new ` to create one." | - -## Interaction with Existing Features - -### `check-stable` - -The `[canisters..check-stable]` config continues to work independently. When both `[migrations]` and `[check-stable]` are configured: -1. `mops check` compiles with the merged migration chain (including next migration) -2. Then runs the stable compatibility check using the configured `check-stable.path` -3. If the stable check fails and `[migrations]` is configured, an extra hint is emitted - -### Canister `args` - -When `[migrations]` is configured, mops auto-adds `--enhanced-migration=` to `moc` invocations. Users must NOT also include `--enhanced-migration` in `[canisters.].args` — mops should detect this and emit an error to prevent duplicate/conflicting flags. - -## Scope - -### In scope (this feature) -- Next-migration lifecycle: `mops migrate new`, `mops migrate freeze` -- Chain trimming with configurable limits per command -- Auto `--enhanced-migration` flag management -- M0254 suppression during trimming -- Migration hint on stable check failure -- Validation of next-migration ordering and directory contents - -### Out of scope (future work) -- `.most` file management (auto-save deployed state, track deployments) -- Deployed state tracking (knowing what's actually on the canister) -- Auto-detecting whether a migration is needed (analyzing field changes) -- `mops migrate status` command (showing chain state, pending migrations) From ab8c2b1534a82d8fa5eb2d65213a79cd42bb1866 Mon Sep 17 00:00:00 2001 From: Kamil Listopad Date: Thu, 16 Apr 2026 14:16:28 +0200 Subject: [PATCH 11/12] refactor: make migrations.next optional Users can add migrations directly to the chain directory without needing a separate next-migration staging workflow. The `next` field is now only required when using `mops migrate new/freeze`. Made-with: Cursor --- .agents/skills/mops-cli/SKILL.md | 2 +- cli/commands/migrate.ts | 16 +++++++++++++++- cli/helpers/migrations.ts | 13 +++++-------- cli/types.ts | 2 +- docs/docs/09-mops.toml.md | 2 +- 5 files changed, 23 insertions(+), 12 deletions(-) diff --git a/.agents/skills/mops-cli/SKILL.md b/.agents/skills/mops-cli/SKILL.md index 3fca2edd..d1d443ce 100644 --- a/.agents/skills/mops-cli/SKILL.md +++ b/.agents/skills/mops-cli/SKILL.md @@ -34,7 +34,7 @@ main = "src/backend/main.mo" [canisters.backend.migrations] chain = "src/backend/migrations" -next = "src/backend/next-migration" +next = "src/backend/next-migration" # optional — needed for `mops migrate new/freeze` check-limit = 1 build-limit = 100 diff --git a/cli/commands/migrate.ts b/cli/commands/migrate.ts index c6889b96..4a521e86 100644 --- a/cli/commands/migrate.ts +++ b/cli/commands/migrate.ts @@ -28,7 +28,7 @@ function resolveMigrationCanister(canisterName?: string): { "Add a [canisters..migrations] section first:\n\n" + " [canisters.backend.migrations]\n" + ' chain = "migrations"\n' + - ' next = "next-migration"', + ' next = "next-migration" # required for migrate new/freeze', ); } @@ -90,6 +90,13 @@ export async function migrateNew( 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); @@ -127,6 +134,13 @@ export async function migrateFreeze(canisterName?: string): Promise { 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); diff --git a/cli/helpers/migrations.ts b/cli/helpers/migrations.ts index 7231cdd4..72d3acc0 100644 --- a/cli/helpers/migrations.ts +++ b/cli/helpers/migrations.ts @@ -62,11 +62,6 @@ export function validateMigrationsConfig( `[canisters.${canisterName}.migrations] is missing required field "chain"`, ); } - if (!migrations.next) { - cliError( - `[canisters.${canisterName}.migrations] is missing required field "next"`, - ); - } for (const field of ["check-limit", "build-limit"] as const) { const value = migrations[field]; if (value !== undefined && (!Number.isInteger(value) || value <= 0)) { @@ -95,8 +90,10 @@ export async function prepareMigrationArgs( validateMigrationsConfig(migrations, canisterName); const chainDir = resolveConfigPath(migrations.chain); - const nextDir = resolveConfigPath(migrations.next); - const nextFile = getNextMigrationFile(nextDir); + const nextDir = migrations.next + ? resolveConfigPath(migrations.next) + : undefined; + const nextFile = nextDir ? getNextMigrationFile(nextDir) : null; if (!existsSync(chainDir) && !nextFile) { cliError( @@ -136,7 +133,7 @@ export async function prepareMigrationArgs( symlinkSync(target, join(tempDir, file)); } - if (nextFile) { + if (nextFile && nextDir) { const target = resolve(nextDir, nextFile); symlinkSync(target, join(tempDir, nextFile)); } diff --git a/cli/types.ts b/cli/types.ts index a9a93b8d..f82ca3a8 100644 --- a/cli/types.ts +++ b/cli/types.ts @@ -37,7 +37,7 @@ export type Config = { export type MigrationsConfig = { chain: string; - next: string; + next?: string; "check-limit"?: number; "build-limit"?: number; }; diff --git a/docs/docs/09-mops.toml.md b/docs/docs/09-mops.toml.md index 86c24fb6..6ede3964 100644 --- a/docs/docs/09-mops.toml.md +++ b/docs/docs/09-mops.toml.md @@ -146,7 +146,7 @@ Configure managed enhanced migration chains for a canister. When set, `mops chec | Field | Description | | ----------- | --------------------------------------------------------------- | | chain | Path to the directory containing frozen migration files (required) | -| next | Path to the directory for the next pending migration (required). Must contain 0 or 1 `.mo` files | +| 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 include when running `mops check` and `mops check-stable` (optional). When set, only the last N migrations from the chain are used | | build-limit | Max number of migrations to include when running `mops build` (optional). When set, only the last N migrations from the chain are used | From 4889a8586ce99596d47ca0c762467f20f8ffd2fe Mon Sep 17 00:00:00 2001 From: Kamil Listopad Date: Thu, 16 Apr 2026 14:55:34 +0200 Subject: [PATCH 12/12] fix: apply trimming limits to the full virtual chain (frozen + next) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously check-limit/build-limit only trimmed the frozen chain and the next migration was always appended on top. This meant build-limit=2 with 3 chain + 1 next produced 3 files, but after freezing the same limit produced 2 files — inconsistent output for the same logical state. Now chain + next are merged into one list before applying the limit, so the result is identical regardless of whether the last migration is pending or already frozen. Made-with: Cursor --- cli/helpers/migrations.ts | 31 +++++++++++--------- cli/tests/__snapshots__/migrate.test.ts.snap | 17 +++++++++++ cli/tests/migrate.test.ts | 13 ++++++++ docs/docs/09-mops.toml.md | 4 +-- docs/docs/cli/4-dev/08-mops-migrate.md | 2 ++ 5 files changed, 51 insertions(+), 16 deletions(-) diff --git a/cli/helpers/migrations.ts b/cli/helpers/migrations.ts index 72d3acc0..43792c8b 100644 --- a/cli/helpers/migrations.ts +++ b/cli/helpers/migrations.ts @@ -108,9 +108,19 @@ export async function prepareMigrationArgs( 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 < chainFiles.length; + const isTrimming = limit !== undefined && limit < allMigrations.length; const needsTempDir = nextFile !== null || isTrimming; if (!needsTempDir) { @@ -125,26 +135,19 @@ export async function prepareMigrationArgs( mkdirSync(tempDir, { recursive: true }); const filesToInclude = isTrimming - ? chainFiles.slice(-limit) - : [...chainFiles]; + ? allMigrations.slice(-limit) + : allMigrations; - for (const file of filesToInclude) { - const target = resolve(chainDir, file); - symlinkSync(target, join(tempDir, file)); - } - - if (nextFile && nextDir) { - const target = resolve(nextDir, nextFile); - symlinkSync(target, join(tempDir, nextFile)); + for (const { file, dir } of filesToInclude) { + symlinkSync(resolve(dir, file), join(tempDir, file)); } if (verbose) { - const total = filesToInclude.length + (nextFile ? 1 : 0); console.log( chalk.blue("migrations"), chalk.gray( - `Prepared ${total} migration(s) for ${canisterName}` + - (isTrimming ? ` (trimmed from ${chainFiles.length})` : ""), + `Prepared ${filesToInclude.length} migration(s) for ${canisterName}` + + (isTrimming ? ` (trimmed from ${allMigrations.length})` : ""), ), ); } diff --git a/cli/tests/__snapshots__/migrate.test.ts.snap b/cli/tests/__snapshots__/migrate.test.ts.snap index 8bc8f796..314d4683 100644 --- a/cli/tests/__snapshots__/migrate.test.ts.snap +++ b/cli/tests/__snapshots__/migrate.test.ts.snap @@ -31,6 +31,23 @@ actor { " `; +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, diff --git a/cli/tests/migrate.test.ts b/cli/tests/migrate.test.ts index 31839aa8..ea161336 100644 --- a/cli/tests/migrate.test.ts +++ b/cli/tests/migrate.test.ts @@ -181,6 +181,19 @@ describe("migrate", () => { ); 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", () => { diff --git a/docs/docs/09-mops.toml.md b/docs/docs/09-mops.toml.md index 6ede3964..98907446 100644 --- a/docs/docs/09-mops.toml.md +++ b/docs/docs/09-mops.toml.md @@ -147,8 +147,8 @@ Configure managed enhanced migration chains for a canister. When set, `mops chec | ----------- | --------------------------------------------------------------- | | 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 include when running `mops check` and `mops check-stable` (optional). When set, only the last N migrations from the chain are used | -| build-limit | Max number of migrations to include when running `mops build` (optional). When set, only the last N migrations from the chain are used | +| 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 diff --git a/docs/docs/cli/4-dev/08-mops-migrate.md b/docs/docs/cli/4-dev/08-mops-migrate.md index e9d201de..1f24adae 100644 --- a/docs/docs/cli/4-dev/08-mops-migrate.md +++ b/docs/docs/cli/4-dev/08-mops-migrate.md @@ -79,4 +79,6 @@ Large migration chains increase WASM size and compilation time. Use `check-limit - **`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.