diff --git a/.agents/skills/mops-cli/SKILL.md b/.agents/skills/mops-cli/SKILL.md index d1d443ce..230f4703 100644 --- a/.agents/skills/mops-cli/SKILL.md +++ b/.agents/skills/mops-cli/SKILL.md @@ -144,6 +144,8 @@ mops migrate freeze backend # specify canister explicitly When `[canisters..migrations]` is configured, `mops check`, `mops build`, and `mops check-stable` automatically inject `--enhanced-migration`. Do not add `--enhanced-migration` to `[canisters.].args` when using managed migrations — mops will error. +`chain` and `next` must live in the same parent directory. Migration files can import from sibling folders via relative paths (e.g. shared types in `src/backend/types/`); mops stages the active chain into `/.migrations-/`, preserving depth so relative imports resolve identically. The staged dir self-stamps a `.gitignore`; `mops init` also adds `.migrations-*/` to the project `.gitignore`. + 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 ` diff --git a/.gitignore b/.gitignore index 4c9b67fc..f7764cfa 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ target/ dist/ .dfx/ .mops/ +.migrations-*/ mops.lock .DS_Store diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index ea0cfab2..84916a4e 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -1,6 +1,8 @@ # Mops CLI Changelog ## Next +- Migration staging directory moved from `.mops/.migrations//` to `/.migrations-/`, so migration files can import shared modules from sibling folders (e.g. a `types/` folder next to `migrations/`) — relative imports now resolve to the same target whether moc reads the original chain dir or the staged one. The staged dir self-stamps a `.gitignore` so it doesn't pollute `git status`; `mops init` now also adds `.migrations-*/` to the project `.gitignore` +- `[canisters..migrations]` now requires `chain` and `next` to share the same parent directory (any layout where the parents differed is rejected with a clear error). The default layout `chain = "migrations"` + `next = "next-migration"` already satisfies this. For per-canister setups, use sibling subdirectories, e.g. `chain = "src/backend/migrations"` + `next = "src/backend/next-migration"` ## 2.11.0 - Add `mops migrate new ` and `mops migrate freeze` commands for managing enhanced migration chains diff --git a/cli/commands/init.ts b/cli/commands/init.ts index 43cf7e5a..107fa815 100644 --- a/cli/commands/init.ts +++ b/cli/commands/init.ts @@ -282,16 +282,29 @@ async function applyInit({ await template("github-workflow:mops-test"); } - // add .mops to .gitignore + // add mops-managed paths to .gitignore { let gitignore = path.join(process.cwd(), ".gitignore"); let gitignoreData = existsSync(gitignore) ? readFileSync(gitignore).toString() : ""; - let lf = gitignoreData.endsWith("\n") ? "\n" : ""; + const additions: string[] = []; if (!gitignoreData.includes(".mops")) { - writeFileSync(gitignore, `${gitignoreData}\n.mops${lf}`.trimStart()); - console.log(chalk.green("Added"), ".mops to .gitignore"); + additions.push(".mops"); + } + if (!gitignoreData.includes(".migrations-")) { + additions.push(".migrations-*/"); + } + if (additions.length > 0) { + let lf = gitignoreData.endsWith("\n") ? "\n" : ""; + writeFileSync( + gitignore, + `${gitignoreData}\n${additions.join("\n")}${lf}`.trimStart(), + ); + console.log( + chalk.green("Added"), + `${additions.join(", ")} to .gitignore`, + ); } } diff --git a/cli/helpers/migrations.ts b/cli/helpers/migrations.ts index 43792c8b..cb7af07c 100644 --- a/cli/helpers/migrations.ts +++ b/cli/helpers/migrations.ts @@ -1,12 +1,20 @@ -import { existsSync, mkdirSync, readdirSync, symlinkSync } from "node:fs"; -import { join, resolve } from "node:path"; +import { + existsSync, + mkdirSync, + readdirSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { dirname, 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 { getRootDir, resolveConfigPath } from "../mops.js"; import { MigrationsConfig } from "../types.js"; -const MIGRATIONS_TEMP_DIR = ".mops/.migrations"; +function stagedMigrationsDir(chainDir: string, canisterName: string): string { + return join(dirname(chainDir), `.migrations-${canisterName}`); +} export interface MigrationArgsResult { migrationArgs: string[]; @@ -70,6 +78,21 @@ export function validateMigrationsConfig( ); } } + if (migrations.next) { + const parentOf = (p: string) => dirname(resolve(getRootDir(), p)); + const chainParent = parentOf(migrations.chain); + const nextParent = parentOf(migrations.next); + if (chainParent !== nextParent) { + cliError( + `[canisters.${canisterName}.migrations] "chain" and "next" must live in the same parent directory.\n` + + ` chain = "${migrations.chain}" (parent: ${chainParent})\n` + + ` next = "${migrations.next}" (parent: ${nextParent})\n` + + "Place them in the same parent directory, e.g.:\n" + + ' chain = "migrations"\n' + + ' next = "next-migration"', + ); + } + } } export async function prepareMigrationArgs( @@ -130,9 +153,10 @@ export async function prepareMigrationArgs( }; } - const tempDir = join(MIGRATIONS_TEMP_DIR, canisterName); + const tempDir = stagedMigrationsDir(chainDir, canisterName); await rm(tempDir, { recursive: true, force: true }); mkdirSync(tempDir, { recursive: true }); + writeFileSync(join(tempDir, ".gitignore"), "*\n"); const filesToInclude = isTrimming ? allMigrations.slice(-limit) diff --git a/cli/tests/__snapshots__/migrate.test.ts.snap b/cli/tests/__snapshots__/migrate.test.ts.snap index 314d4683..e9cddc77 100644 --- a/cli/tests/__snapshots__/migrate.test.ts.snap +++ b/cli/tests/__snapshots__/migrate.test.ts.snap @@ -33,12 +33,12 @@ actor { exports[`migrate build build-limit counts next migration as part of the chain 1`] = ` "// Version: 4.0.0 +type V2__933402648 = {a : Nat; name : Text}; +type V3__40989327 = {a : Nat; email : Text; name : Text}; +type V4__364034734 = {email : Text; id : Nat; name : Text}; { - "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} + "20250301_000000_AddEmail" : (old : V2__933402648) -> V3__40989327; + "20250401_000000_RenameId" : (old : V3__40989327) -> V4__364034734 } actor { stable email : Text; @@ -75,10 +75,10 @@ exports[`migrate check check with trimming shows reduced chain 1`] = ` "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"] +moc-wrapper ["src/main.mo","--check","--all-libs","--default-persistent-actors","--enhanced-migration=.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"] +moc-wrapper ["--stable-types","-o",".mops/.check-stable/new.wasm","src/main.mo","--default-persistent-actors","--enhanced-migration=.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'", diff --git a/cli/tests/migrate.test.ts b/cli/tests/migrate.test.ts index ea161336..a53171d4 100644 --- a/cli/tests/migrate.test.ts +++ b/cli/tests/migrate.test.ts @@ -207,6 +207,33 @@ describe("migrate", () => { }); }); + describe("sibling validation", () => { + async function patchNextDir(cwd: string, nextValue: string): Promise { + const tomlPath = path.join(cwd, "mops.toml"); + const toml = readFileSync(tomlPath, "utf-8"); + await writeFile( + tomlPath, + toml.replace('next = "next-migration"', `next = "${nextValue}"`), + ); + } + + test("errors from `mops check` when chain and next have different parents", async () => { + const cwd = await makeTempFixture("with-next"); + await patchNextDir(cwd, "other/next-migration"); + const result = await cli(["check"], { cwd }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toMatch(/same parent directory/i); + }); + + test("errors from `mops migrate new` too — validation runs in every entry point", async () => { + const cwd = await makeTempFixture("basic"); + await patchNextDir(cwd, "other/next-migration"); + const result = await cli(["migrate", "new", "Test"], { cwd }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toMatch(/same parent directory/i); + }); + }); + describe("conflict detection", () => { test("errors when both [migrations] and --enhanced-migration in args", async () => { const cwd = await makeTempFixture("basic"); 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 192370fd..f3a52109 100644 --- a/cli/tests/migrate/with-next/migrations/20250101_000000_Init.mo +++ b/cli/tests/migrate/with-next/migrations/20250101_000000_Init.mo @@ -1,5 +1,7 @@ +import State "../types/State"; + module { - public func migration(_ : {}) : { a : Nat } { + public func migration(_ : State.V0) : State.V1 { { 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 index 6a0b42e4..98b22af7 100644 --- a/cli/tests/migrate/with-next/migrations/20250201_000000_AddName.mo +++ b/cli/tests/migrate/with-next/migrations/20250201_000000_AddName.mo @@ -1,5 +1,7 @@ +import State "../types/State"; + module { - public func migration(old : { a : Nat }) : { a : Nat; name : Text } { + public func migration(old : State.V1) : State.V2 { { 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 index 50f28496..a4563367 100644 --- a/cli/tests/migrate/with-next/migrations/20250301_000000_AddEmail.mo +++ b/cli/tests/migrate/with-next/migrations/20250301_000000_AddEmail.mo @@ -1,9 +1,7 @@ +import State "../types/State"; + module { - public func migration(old : { a : Nat; name : Text }) : { - a : Nat; - name : Text; - email : Text; - } { + public func migration(old : State.V2) : State.V3 { { old with email = "" }; }; }; 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 index 8d0b5eb1..0bde56b6 100644 --- a/cli/tests/migrate/with-next/next-migration/20250401_000000_RenameId.mo +++ b/cli/tests/migrate/with-next/next-migration/20250401_000000_RenameId.mo @@ -1,9 +1,7 @@ +import State "../types/State"; + module { - public func migration(old : { a : Nat; name : Text; email : Text }) : { - id : Nat; - name : Text; - email : Text; - } { + public func migration(old : State.V3) : State.V4 { { id = old.a; name = old.name; email = old.email }; }; }; diff --git a/cli/tests/migrate/with-next/types/State.mo b/cli/tests/migrate/with-next/types/State.mo new file mode 100644 index 00000000..2a144e74 --- /dev/null +++ b/cli/tests/migrate/with-next/types/State.mo @@ -0,0 +1,7 @@ +module { + public type V0 = {}; + public type V1 = { a : Nat }; + public type V2 = { a : Nat; name : Text }; + public type V3 = { a : Nat; name : Text; email : Text }; + public type V4 = { id : Nat; name : Text; email : Text }; +}; diff --git a/docs/docs/09-mops.toml.md b/docs/docs/09-mops.toml.md index 98907446..951c17ff 100644 --- a/docs/docs/09-mops.toml.md +++ b/docs/docs/09-mops.toml.md @@ -116,8 +116,8 @@ Multi-canister example with per-canister flags: main = "src/backend/main.mo" [canisters.backend.migrations] -chain = "migrations/backend" -next = "next-migration/backend" +chain = "migrations/backend/chain" +next = "migrations/backend/next" [canisters.frontend] main = "src/frontend/main.mo" @@ -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 (optional). Required for `mops migrate new/freeze`. 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. Must share the same parent directory as `chain` | | 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 | @@ -165,6 +165,10 @@ Migration files must be named so they sort lexicographically in the correct orde When `[migrations]` is configured, do not add `--enhanced-migration` to `[canisters.].args` — mops manages this flag automatically. ::: +:::note +When a `next` migration exists or chain trimming is active, mops stages the active chain into `/.migrations-/` for compilation. This keeps the staged files at the same depth as the originals so relative imports (e.g. a shared `types/` folder next to `chain` and `next`) resolve identically. The staged dir self-stamps a `.gitignore`, and `mops init` adds `.migrations-*/` to the project `.gitignore`. +::: + Shorthand — when only the entrypoint is needed: ```toml [canisters] diff --git a/docs/docs/cli/00-mops-init.md b/docs/docs/cli/00-mops-init.md index 52c92c5d..aa6a1b7a 100644 --- a/docs/docs/cli/00-mops-init.md +++ b/docs/docs/cli/00-mops-init.md @@ -55,7 +55,7 @@ When accepted, adds `.github/workflows/mops-test.yml` that runs `mops test` on p 5. **`LICENSE`** (and `NOTICE` for Apache-2.0) — package only, filled with the current year and copyright owner. 6. **`README.md`** — package only, with placeholders replaced by the package name. 7. **`.github/workflows/mops-test.yml`** — when the workflow prompt was accepted. -8. **`.mops`** appended to `.gitignore` (created if missing). +8. **`.mops`** and **`.migrations-*/`** appended to `.gitignore` (created if missing). Existing `LICENSE`, `README.md`, and workflow files are not overwritten. diff --git a/docs/docs/cli/4-dev/08-mops-migrate.md b/docs/docs/cli/4-dev/08-mops-migrate.md index 1f24adae..52266502 100644 --- a/docs/docs/cli/4-dev/08-mops-migrate.md +++ b/docs/docs/cli/4-dev/08-mops-migrate.md @@ -59,6 +59,8 @@ check-limit = 1 build-limit = 100 ``` +`chain` and `next` must live in the same parent directory. Migration files can import from sibling folders (e.g. a shared `types/` folder) using relative paths — mops stages the active chain into `/.migrations-/` for compilation, preserving the depth of the originals so relative imports resolve identically. The staged dir self-stamps a `.gitignore`, and `mops init` adds `.migrations-*/` to the project `.gitignore`. + See [`mops.toml` reference](/mops.toml#canistersnamemigrations) for all fields. ## Typical workflow