Skip to content

Commit 256e0c7

Browse files
authored
cli: stage migrations next to chain dir so shared imports resolve (#500)
## Why Migration files often want to share state types from a sibling folder: ``` migrations/20250101_000000_Init.mo ─ import State \"../types/State\" types/State.mo ─ shared type definitions ``` This fails as soon as a `next-migration` exists or chain trimming kicks in. mops symlinks the chain into `.mops/.migrations/<canister>/` and points moc at it, but moc resolves relative imports from the symlink's parent — so `../types/State` becomes `.mops/types/State.mo` and errors. ## Fix Stage into `<parent-of-chain>/.migrations-<canister>/` instead. The staged files now sit at the same depth as the originals, so `../types/State` resolves to the same target either way. This requires `chain` and `next` to share the same parent — enforced with a clear validation error at every entry point (`check`, `build`, `migrate new/freeze`, `check-stable`). The staged dir self-stamps a `.gitignore` (`*`) so it doesn't show up in `git status`. ## Breaking Any `[canisters.<name>.migrations]` whose `chain` and `next` had different parents is rejected. The default layout (`chain = \"migrations\"` + `next = \"next-migration\"`) is unaffected. `2.11.0` introduced the migration feature one release ago, so adoption is small. ## Test plan - [x] All 16 `migrate.test.ts` tests pass, including two new tests that the sibling validation fires from both `mops check` and `mops migrate new`. - [x] `with-next` fixture updated to use a shared `types/State.mo` — exercises the imports-resolve-correctly behavior end-to-end. - [x] `npm run check` and `npm run lint` pass.
1 parent 27214dd commit 256e0c7

15 files changed

Lines changed: 114 additions & 32 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,8 @@ mops migrate freeze backend # specify canister explicitly
144144

145145
When `[canisters.<name>.migrations]` is configured, `mops check`, `mops build`, and `mops check-stable` automatically inject `--enhanced-migration`. Do not add `--enhanced-migration` to `[canisters.<name>].args` when using managed migrations — mops will error.
146146

147+
`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`.
148+
147149
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`.
148150

149151
### `mops remove <package>`

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ target/
33
dist/
44
.dfx/
55
.mops/
6+
.migrations-*/
67
mops.lock
78

89
.DS_Store

cli/CHANGELOG.md

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

33
## Next
4+
- 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`
5+
- `[canisters.<name>.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"`
46

57
## 2.11.0
68
- Add `mops migrate new <Name>` and `mops migrate freeze` commands for managing enhanced migration chains

cli/commands/init.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -282,16 +282,29 @@ async function applyInit({
282282
await template("github-workflow:mops-test");
283283
}
284284

285-
// add .mops to .gitignore
285+
// add mops-managed paths to .gitignore
286286
{
287287
let gitignore = path.join(process.cwd(), ".gitignore");
288288
let gitignoreData = existsSync(gitignore)
289289
? readFileSync(gitignore).toString()
290290
: "";
291-
let lf = gitignoreData.endsWith("\n") ? "\n" : "";
291+
const additions: string[] = [];
292292
if (!gitignoreData.includes(".mops")) {
293-
writeFileSync(gitignore, `${gitignoreData}\n.mops${lf}`.trimStart());
294-
console.log(chalk.green("Added"), ".mops to .gitignore");
293+
additions.push(".mops");
294+
}
295+
if (!gitignoreData.includes(".migrations-")) {
296+
additions.push(".migrations-*/");
297+
}
298+
if (additions.length > 0) {
299+
let lf = gitignoreData.endsWith("\n") ? "\n" : "";
300+
writeFileSync(
301+
gitignore,
302+
`${gitignoreData}\n${additions.join("\n")}${lf}`.trimStart(),
303+
);
304+
console.log(
305+
chalk.green("Added"),
306+
`${additions.join(", ")} to .gitignore`,
307+
);
295308
}
296309
}
297310

cli/helpers/migrations.ts

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
1-
import { existsSync, mkdirSync, readdirSync, symlinkSync } from "node:fs";
2-
import { join, resolve } from "node:path";
1+
import {
2+
existsSync,
3+
mkdirSync,
4+
readdirSync,
5+
symlinkSync,
6+
writeFileSync,
7+
} from "node:fs";
8+
import { dirname, join, resolve } from "node:path";
39
import { rm } from "node:fs/promises";
410
import chalk from "chalk";
511
import { cliError } from "../error.js";
6-
import { resolveConfigPath } from "../mops.js";
12+
import { getRootDir, resolveConfigPath } from "../mops.js";
713
import { MigrationsConfig } from "../types.js";
814

9-
const MIGRATIONS_TEMP_DIR = ".mops/.migrations";
15+
function stagedMigrationsDir(chainDir: string, canisterName: string): string {
16+
return join(dirname(chainDir), `.migrations-${canisterName}`);
17+
}
1018

1119
export interface MigrationArgsResult {
1220
migrationArgs: string[];
@@ -70,6 +78,21 @@ export function validateMigrationsConfig(
7078
);
7179
}
7280
}
81+
if (migrations.next) {
82+
const parentOf = (p: string) => dirname(resolve(getRootDir(), p));
83+
const chainParent = parentOf(migrations.chain);
84+
const nextParent = parentOf(migrations.next);
85+
if (chainParent !== nextParent) {
86+
cliError(
87+
`[canisters.${canisterName}.migrations] "chain" and "next" must live in the same parent directory.\n` +
88+
` chain = "${migrations.chain}" (parent: ${chainParent})\n` +
89+
` next = "${migrations.next}" (parent: ${nextParent})\n` +
90+
"Place them in the same parent directory, e.g.:\n" +
91+
' chain = "migrations"\n' +
92+
' next = "next-migration"',
93+
);
94+
}
95+
}
7396
}
7497

7598
export async function prepareMigrationArgs(
@@ -130,9 +153,10 @@ export async function prepareMigrationArgs(
130153
};
131154
}
132155

133-
const tempDir = join(MIGRATIONS_TEMP_DIR, canisterName);
156+
const tempDir = stagedMigrationsDir(chainDir, canisterName);
134157
await rm(tempDir, { recursive: true, force: true });
135158
mkdirSync(tempDir, { recursive: true });
159+
writeFileSync(join(tempDir, ".gitignore"), "*\n");
136160

137161
const filesToInclude = isTrimming
138162
? allMigrations.slice(-limit)

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

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,12 @@ actor {
3333

3434
exports[`migrate build build-limit counts next migration as part of the chain 1`] = `
3535
"// Version: 4.0.0
36+
type V2__933402648 = {a : Nat; name : Text};
37+
type V3__40989327 = {a : Nat; email : Text; name : Text};
38+
type V4__364034734 = {email : Text; id : Nat; name : Text};
3639
{
37-
"20250301_000000_AddEmail" :
38-
(old : {a : Nat; name : Text}) -> {a : Nat; email : Text; name : Text};
39-
"20250401_000000_RenameId" :
40-
(old : {a : Nat; email : Text; name : Text}) ->
41-
{email : Text; id : Nat; name : Text}
40+
"20250301_000000_AddEmail" : (old : V2__933402648) -> V3__40989327;
41+
"20250401_000000_RenameId" : (old : V3__40989327) -> V4__364034734
4242
}
4343
actor {
4444
stable email : Text;
@@ -75,10 +75,10 @@ exports[`migrate check check with trimming shows reduced chain 1`] = `
7575
"stdout": "check Using --all-libs for richer diagnostics
7676
migrations Prepared 2 migration(s) for backend (trimmed from 3)
7777
check Checking canister backend:
78-
<CACHE>moc-wrapper ["src/main.mo","--check","--all-libs","--default-persistent-actors","--enhanced-migration=.mops/.migrations/backend","-A=M0254"]
78+
<CACHE>moc-wrapper ["src/main.mo","--check","--all-libs","--default-persistent-actors","--enhanced-migration=.migrations-backend","-A=M0254"]
7979
✓ backend
8080
check-stable Generating stable types for src/main.mo
81-
<CACHE>moc-wrapper ["--stable-types","-o",".mops/.check-stable/new.wasm","src/main.mo","--default-persistent-actors","--enhanced-migration=.mops/.migrations/backend","-A=M0254"]
81+
<CACHE>moc-wrapper ["--stable-types","-o",".mops/.check-stable/new.wasm","src/main.mo","--default-persistent-actors","--enhanced-migration=.migrations-backend","-A=M0254"]
8282
check-stable Comparing deployed.most ↔ .mops/.check-stable/new.most
8383
<CACHE>moc-wrapper ["--stable-compatible","deployed.most",".mops/.check-stable/new.most"]
8484
✓ Stable compatibility check passed for canister 'backend'",

cli/tests/migrate.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,33 @@ describe("migrate", () => {
207207
});
208208
});
209209

210+
describe("sibling validation", () => {
211+
async function patchNextDir(cwd: string, nextValue: string): Promise<void> {
212+
const tomlPath = path.join(cwd, "mops.toml");
213+
const toml = readFileSync(tomlPath, "utf-8");
214+
await writeFile(
215+
tomlPath,
216+
toml.replace('next = "next-migration"', `next = "${nextValue}"`),
217+
);
218+
}
219+
220+
test("errors from `mops check` when chain and next have different parents", async () => {
221+
const cwd = await makeTempFixture("with-next");
222+
await patchNextDir(cwd, "other/next-migration");
223+
const result = await cli(["check"], { cwd });
224+
expect(result.exitCode).toBe(1);
225+
expect(result.stderr).toMatch(/same parent directory/i);
226+
});
227+
228+
test("errors from `mops migrate new` too — validation runs in every entry point", async () => {
229+
const cwd = await makeTempFixture("basic");
230+
await patchNextDir(cwd, "other/next-migration");
231+
const result = await cli(["migrate", "new", "Test"], { cwd });
232+
expect(result.exitCode).toBe(1);
233+
expect(result.stderr).toMatch(/same parent directory/i);
234+
});
235+
});
236+
210237
describe("conflict detection", () => {
211238
test("errors when both [migrations] and --enhanced-migration in args", async () => {
212239
const cwd = await makeTempFixture("basic");
Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import State "../types/State";
2+
13
module {
2-
public func migration(_ : {}) : { a : Nat } {
4+
public func migration(_ : State.V0) : State.V1 {
35
{ a = 0 };
46
};
57
};
Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import State "../types/State";
2+
13
module {
2-
public func migration(old : { a : Nat }) : { a : Nat; name : Text } {
4+
public func migration(old : State.V1) : State.V2 {
35
{ old with name = "" };
46
};
57
};
Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
1+
import State "../types/State";
2+
13
module {
2-
public func migration(old : { a : Nat; name : Text }) : {
3-
a : Nat;
4-
name : Text;
5-
email : Text;
6-
} {
4+
public func migration(old : State.V2) : State.V3 {
75
{ old with email = "" };
86
};
97
};

0 commit comments

Comments
 (0)