Skip to content

Commit 82d400d

Browse files
authored
fix(migrate): skip staging when window is exactly the pending next migration (#503)
## Problem When `[canisters.<name>.migrations]` triggers staging (a pending `next` migration, or `check-limit`/`build-limit` trimming the chain), mops creates a temp `.migrations-<canister>/` directory of symlinks and points moc at it. Errors look like: ``` .migrations-backend/20250401_000000_RenameId.mo:4.12-4.19: type error [M0050] ... ``` The staged dir is `rm -rf`-ed in a `finally` before the process exits, so by the time the user reads the error the path no longer resolves — editor jump-to-error fails, and CI logs contain a path that looks like an internal artifact. ## Why staging exists `moc --enhanced-migration` takes a single directory. When the active window straddles `chain/` and `next-migration/`, or when trimming hides files inside `chain/`, mops needs a synthesized directory holding exactly the files moc should see. ## Fix When moc only needs the pending `next` migration — empty chain, or `check-limit = 1` with a pending next — there's nothing to merge. Point moc at `next-migration/` directly: Before: ``` .migrations-backend/20250401_000000_RenameId.mo:4.12-4.19: type error [M0050] ... ``` After: ``` next-migration/20250401_000000_RenameId.mo:4.12-4.19: type error [M0050] ... ``` This covers the common dev-loop case (writing a new migration with `check-limit = 1`) and the first-migration case (no frozen chain yet). Every other trimming / merge case still stages — diagnostics there continue to print the `.migrations-backend/...` path. ## Test plan - [x] `check-limit=1 with pending next reports real next-migration path on error` — pins the trimmed-to-1 branch - [x] `empty chain with pending next reports real next-migration path on error` — pins the empty-chain branch - [x] `error inside a chain migration reports its file location` — pins the staged-path behavior for the cases that still stage - [x] `npm test -- --testPathPatterns=migrate.test.ts` — 19/19 pass - [x] `npm run check` — clean - [x] Pre-existing `build.test.ts` failures verified unrelated (stash + retest on `main`)
1 parent e88df80 commit 82d400d

7 files changed

Lines changed: 98 additions & 0 deletions

File tree

.agents/skills/mops-cli/SKILL.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,8 @@ When `[canisters.<name>.migrations]` is configured, `mops check`, `mops build`,
146146

147147
`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 `<parent-of-chain>/.migrations-<canister>/`, preserving depth so relative imports resolve identically. The staged dir self-stamps a `.gitignore`; `mops init` also adds `.migrations-*/` to the project `.gitignore`.
148148

149+
`moc` diagnostics may print paths under `.migrations-<canister>/` — a staging dir that mops removes when the command finishes. The real file has the same name under `chain/` or `next/`.
150+
149151
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`.
150152

151153
### `mops remove <package>`

cli/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Mops CLI Changelog
22

33
## Next
4+
- `mops check`/`build`/`check-stable` skip migration staging when only the pending `next` migration is needed, so `moc` diagnostics reference the real `next-migration/<file>` path.
45

56
## 2.12.0
67
- Migration staging directory moved from `.mops/.migrations/<canister>/` to `<parent-of-chain>/.migrations-<canister>/`, 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`

cli/helpers/migrations.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,16 @@ export async function prepareMigrationArgs(
153153
};
154154
}
155155

156+
// Shortcut: when only the pending next migration is needed (empty chain or
157+
// trimmed to 1), point moc at next-migration/ so diagnostics use the real path.
158+
if (nextFile && nextDir && (chainFiles.length === 0 || limit === 1)) {
159+
const migrationArgs = [`--enhanced-migration=${nextDir}`];
160+
if (isTrimming) {
161+
migrationArgs.push("-A=M0254");
162+
}
163+
return { migrationArgs, cleanup: async () => {} };
164+
}
165+
156166
const tempDir = stagedMigrationsDir(chainDir, canisterName);
157167
await rm(tempDir, { recursive: true, force: true });
158168
mkdirSync(tempDir, { recursive: true });

cli/tests/__snapshots__/migrate.test.ts.snap

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,42 @@ check-stable Comparing deployed.most ↔ .mops/.check-stable/new.most
8585
}
8686
`;
8787
88+
exports[`migrate check check-limit=1 with pending next reports real next-migration path on error 1`] = `
89+
{
90+
"exitCode": 1,
91+
"stderr": "next-migration/20250401_000000_RenameId.mo:4.12-4.19: type error [M0050], literal of type
92+
Text
93+
does not have expected type
94+
Nat
95+
Check failed for canister backend (exit code: 1)",
96+
"stdout": "",
97+
}
98+
`;
99+
100+
exports[`migrate check empty chain with pending next reports real next-migration path on error 1`] = `
101+
{
102+
"exitCode": 1,
103+
"stderr": "next-migration/20250401_000000_RenameId.mo:4.12-4.19: type error [M0050], literal of type
104+
Text
105+
does not have expected type
106+
Nat
107+
Check failed for canister backend (exit code: 1)",
108+
"stdout": "",
109+
}
110+
`;
111+
112+
exports[`migrate check error inside a chain migration reports its file location 1`] = `
113+
{
114+
"exitCode": 1,
115+
"stderr": ".migrations-backend/20250301_000000_AddEmail.mo:4.24-4.26: type error [M0050], literal of type
116+
Nat
117+
does not have expected type
118+
Text
119+
Check failed for canister backend (exit code: 1)",
120+
"stdout": "",
121+
}
122+
`;
123+
88124
exports[`migrate migrate freeze moves the file from next to chain 1`] = `
89125
{
90126
"exitCode": 0,

cli/tests/migrate.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,51 @@ describe("migrate", () => {
154154
await patchMigrations(cwd, "check-limit = 2");
155155
await cliSnapshot(["check", "--verbose"], { cwd }, 0);
156156
});
157+
158+
test("error inside a chain migration reports its file location", async () => {
159+
const cwd = await makeTempFixture("with-next");
160+
await writeFile(
161+
path.join(cwd, "migrations", "20250301_000000_AddEmail.mo"),
162+
'import State "../types/State";\n' +
163+
"module {\n" +
164+
" public func migration(old : State.V2) : State.V3 {\n" +
165+
" { old with email = 42 };\n" +
166+
" };\n" +
167+
"};\n",
168+
);
169+
await cliSnapshot(["check"], { cwd }, 1);
170+
});
171+
172+
async function corruptNextMigration(cwd: string): Promise<void> {
173+
const nextDir = path.join(cwd, "next-migration");
174+
const nextFile = readdirSync(nextDir).find((f) => f.endsWith(".mo"))!;
175+
await writeFile(
176+
path.join(nextDir, nextFile),
177+
'import State "../types/State";\n' +
178+
"module {\n" +
179+
" public func migration(old : State.V3) : State.V4 {\n" +
180+
' { id = "wrong"; name = old.name; email = old.email };\n' +
181+
" };\n" +
182+
"};\n",
183+
);
184+
}
185+
186+
test("check-limit=1 with pending next reports real next-migration path on error", async () => {
187+
const cwd = await makeTempFixture("with-next");
188+
await patchMigrations(cwd, "check-limit = 1");
189+
await corruptNextMigration(cwd);
190+
await cliSnapshot(["check"], { cwd }, 1);
191+
});
192+
193+
test("empty chain with pending next reports real next-migration path on error", async () => {
194+
const cwd = await makeTempFixture("with-next");
195+
const chainDir = path.join(cwd, "migrations");
196+
for (const f of readdirSync(chainDir).filter((f) => f.endsWith(".mo"))) {
197+
await rm(path.join(chainDir, f));
198+
}
199+
await corruptNextMigration(cwd);
200+
await cliSnapshot(["check"], { cwd }, 1);
201+
});
157202
});
158203

159204
describe("build", () => {

docs/docs/09-mops.toml.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,8 @@ When `[migrations]` is configured, do not add `--enhanced-migration` to `[canist
167167

168168
:::note
169169
When a `next` migration exists or chain trimming is active, mops stages the active chain into `<parent-of-chain>/.migrations-<canister>/` 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`.
170+
171+
`moc` diagnostics may point to a staged path under `.migrations-<canister>/`, which mops removes when the command finishes.
170172
:::
171173

172174
Shorthand — when only the entrypoint is needed:

docs/docs/cli/4-dev/08-mops-migrate.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ build-limit = 100
6161

6262
`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 `<parent-of-chain>/.migrations-<canister>/` 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`.
6363

64+
`moc` diagnostics may point to a staged path under `.migrations-<canister>/`, which mops removes when the command finishes.
65+
6466
See [`mops.toml` reference](/mops.toml#canistersnamemigrations) for all fields.
6567

6668
## Typical workflow

0 commit comments

Comments
 (0)