ic-mops 2.11.0 → 2.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  ## Next
4
4
 
5
+ ## 2.12.1
6
+ - `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.
7
+
8
+ ## 2.12.0
9
+ - 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`
10
+ - `[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"`
11
+
5
12
  ## 2.11.0
6
13
  - Add `mops migrate new <Name>` and `mops migrate freeze` commands for managing enhanced migration chains
7
14
  - Add `[canisters.<name>.migrations]` config section with `chain`, `next`, `check-limit`, and `build-limit` fields
package/bundle/cli.tgz CHANGED
Binary file
package/commands/init.ts CHANGED
@@ -282,16 +282,29 @@ async function applyInit({
282
282
  await template("github-workflow:mops-test");
283
283
  }
284
284
 
285
- // add .mops to .gitignore
285
+ // add mops-managed paths to .gitignore
286
286
  {
287
287
  let gitignore = path.join(process.cwd(), ".gitignore");
288
288
  let gitignoreData = existsSync(gitignore)
289
289
  ? readFileSync(gitignore).toString()
290
290
  : "";
291
- let lf = gitignoreData.endsWith("\n") ? "\n" : "";
291
+ const additions: string[] = [];
292
292
  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
+ );
295
308
  }
296
309
  }
297
310
 
@@ -226,16 +226,23 @@ async function applyInit({ type, config, setupWorkflow, addTest, copyrightOwner,
226
226
  if (setupWorkflow) {
227
227
  await template("github-workflow:mops-test");
228
228
  }
229
- // add .mops to .gitignore
229
+ // add mops-managed paths to .gitignore
230
230
  {
231
231
  let gitignore = path.join(process.cwd(), ".gitignore");
232
232
  let gitignoreData = existsSync(gitignore)
233
233
  ? readFileSync(gitignore).toString()
234
234
  : "";
235
- let lf = gitignoreData.endsWith("\n") ? "\n" : "";
235
+ const additions = [];
236
236
  if (!gitignoreData.includes(".mops")) {
237
- writeFileSync(gitignore, `${gitignoreData}\n.mops${lf}`.trimStart());
238
- console.log(chalk.green("Added"), ".mops to .gitignore");
237
+ additions.push(".mops");
238
+ }
239
+ if (!gitignoreData.includes(".migrations-")) {
240
+ additions.push(".migrations-*/");
241
+ }
242
+ if (additions.length > 0) {
243
+ let lf = gitignoreData.endsWith("\n") ? "\n" : "";
244
+ writeFileSync(gitignore, `${gitignoreData}\n${additions.join("\n")}${lf}`.trimStart());
245
+ console.log(chalk.green("Added"), `${additions.join(", ")} to .gitignore`);
239
246
  }
240
247
  }
241
248
  // install deps
@@ -1,10 +1,12 @@
1
- import { existsSync, mkdirSync, readdirSync, symlinkSync } from "node:fs";
2
- import { join, resolve } from "node:path";
1
+ import { existsSync, mkdirSync, readdirSync, symlinkSync, writeFileSync, } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
3
  import { rm } from "node:fs/promises";
4
4
  import chalk from "chalk";
5
5
  import { cliError } from "../error.js";
6
- import { resolveConfigPath } from "../mops.js";
7
- const MIGRATIONS_TEMP_DIR = ".mops/.migrations";
6
+ import { getRootDir, resolveConfigPath } from "../mops.js";
7
+ function stagedMigrationsDir(chainDir, canisterName) {
8
+ return join(dirname(chainDir), `.migrations-${canisterName}`);
9
+ }
8
10
  export function getMigrationFiles(dir) {
9
11
  if (!existsSync(dir)) {
10
12
  return [];
@@ -44,6 +46,19 @@ export function validateMigrationsConfig(migrations, canisterName) {
44
46
  cliError(`[canisters.${canisterName}.migrations] ${field} must be a positive integer`);
45
47
  }
46
48
  }
49
+ if (migrations.next) {
50
+ const parentOf = (p) => dirname(resolve(getRootDir(), p));
51
+ const chainParent = parentOf(migrations.chain);
52
+ const nextParent = parentOf(migrations.next);
53
+ if (chainParent !== nextParent) {
54
+ cliError(`[canisters.${canisterName}.migrations] "chain" and "next" must live in the same parent directory.\n` +
55
+ ` chain = "${migrations.chain}" (parent: ${chainParent})\n` +
56
+ ` next = "${migrations.next}" (parent: ${nextParent})\n` +
57
+ "Place them in the same parent directory, e.g.:\n" +
58
+ ' chain = "migrations"\n' +
59
+ ' next = "next-migration"');
60
+ }
61
+ }
47
62
  }
48
63
  export async function prepareMigrationArgs(migrations, canisterName, mode, verbose) {
49
64
  const noOp = {
@@ -83,9 +98,19 @@ export async function prepareMigrationArgs(migrations, canisterName, mode, verbo
83
98
  cleanup: async () => { },
84
99
  };
85
100
  }
86
- const tempDir = join(MIGRATIONS_TEMP_DIR, canisterName);
101
+ // Shortcut: when only the pending next migration is needed (empty chain or
102
+ // trimmed to 1), point moc at next-migration/ so diagnostics use the real path.
103
+ if (nextFile && nextDir && (chainFiles.length === 0 || limit === 1)) {
104
+ const migrationArgs = [`--enhanced-migration=${nextDir}`];
105
+ if (isTrimming) {
106
+ migrationArgs.push("-A=M0254");
107
+ }
108
+ return { migrationArgs, cleanup: async () => { } };
109
+ }
110
+ const tempDir = stagedMigrationsDir(chainDir, canisterName);
87
111
  await rm(tempDir, { recursive: true, force: true });
88
112
  mkdirSync(tempDir, { recursive: true });
113
+ writeFileSync(join(tempDir, ".gitignore"), "*\n");
89
114
  const filesToInclude = isTrimming
90
115
  ? allMigrations.slice(-limit)
91
116
  : allMigrations;
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ic-mops",
3
- "version": "2.11.0",
3
+ "version": "2.12.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mops": "bin/mops.js",
@@ -112,6 +112,41 @@ describe("migrate", () => {
112
112
  await patchMigrations(cwd, "check-limit = 2");
113
113
  await cliSnapshot(["check", "--verbose"], { cwd }, 0);
114
114
  });
115
+ test("error inside a chain migration reports its file location", async () => {
116
+ const cwd = await makeTempFixture("with-next");
117
+ await writeFile(path.join(cwd, "migrations", "20250301_000000_AddEmail.mo"), 'import State "../types/State";\n' +
118
+ "module {\n" +
119
+ " public func migration(old : State.V2) : State.V3 {\n" +
120
+ " { old with email = 42 };\n" +
121
+ " };\n" +
122
+ "};\n");
123
+ await cliSnapshot(["check"], { cwd }, 1);
124
+ });
125
+ async function corruptNextMigration(cwd) {
126
+ const nextDir = path.join(cwd, "next-migration");
127
+ const nextFile = readdirSync(nextDir).find((f) => f.endsWith(".mo"));
128
+ await writeFile(path.join(nextDir, nextFile), 'import State "../types/State";\n' +
129
+ "module {\n" +
130
+ " public func migration(old : State.V3) : State.V4 {\n" +
131
+ ' { id = "wrong"; name = old.name; email = old.email };\n' +
132
+ " };\n" +
133
+ "};\n");
134
+ }
135
+ test("check-limit=1 with pending next reports real next-migration path on error", async () => {
136
+ const cwd = await makeTempFixture("with-next");
137
+ await patchMigrations(cwd, "check-limit = 1");
138
+ await corruptNextMigration(cwd);
139
+ await cliSnapshot(["check"], { cwd }, 1);
140
+ });
141
+ test("empty chain with pending next reports real next-migration path on error", async () => {
142
+ const cwd = await makeTempFixture("with-next");
143
+ const chainDir = path.join(cwd, "migrations");
144
+ for (const f of readdirSync(chainDir).filter((f) => f.endsWith(".mo"))) {
145
+ await rm(path.join(chainDir, f));
146
+ }
147
+ await corruptNextMigration(cwd);
148
+ await cliSnapshot(["check"], { cwd }, 1);
149
+ });
115
150
  });
116
151
  describe("build", () => {
117
152
  test("build produces .most with full migration chain", async () => {
@@ -145,6 +180,27 @@ describe("migrate", () => {
145
180
  await cliSnapshot(["check"], { cwd }, 1);
146
181
  });
147
182
  });
183
+ describe("sibling validation", () => {
184
+ async function patchNextDir(cwd, nextValue) {
185
+ const tomlPath = path.join(cwd, "mops.toml");
186
+ const toml = readFileSync(tomlPath, "utf-8");
187
+ await writeFile(tomlPath, toml.replace('next = "next-migration"', `next = "${nextValue}"`));
188
+ }
189
+ test("errors from `mops check` when chain and next have different parents", async () => {
190
+ const cwd = await makeTempFixture("with-next");
191
+ await patchNextDir(cwd, "other/next-migration");
192
+ const result = await cli(["check"], { cwd });
193
+ expect(result.exitCode).toBe(1);
194
+ expect(result.stderr).toMatch(/same parent directory/i);
195
+ });
196
+ test("errors from `mops migrate new` too — validation runs in every entry point", async () => {
197
+ const cwd = await makeTempFixture("basic");
198
+ await patchNextDir(cwd, "other/next-migration");
199
+ const result = await cli(["migrate", "new", "Test"], { cwd });
200
+ expect(result.exitCode).toBe(1);
201
+ expect(result.stderr).toMatch(/same parent directory/i);
202
+ });
203
+ });
148
204
  describe("conflict detection", () => {
149
205
  test("errors when both [migrations] and --enhanced-migration in args", async () => {
150
206
  const cwd = await makeTempFixture("basic");
@@ -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";
3
9
  import { rm } from "node:fs/promises";
4
10
  import chalk from "chalk";
5
11
  import { cliError } from "../error.js";
6
- import { resolveConfigPath } from "../mops.js";
12
+ import { getRootDir, resolveConfigPath } from "../mops.js";
7
13
  import { MigrationsConfig } from "../types.js";
8
14
 
9
- const MIGRATIONS_TEMP_DIR = ".mops/.migrations";
15
+ function stagedMigrationsDir(chainDir: string, canisterName: string): string {
16
+ return join(dirname(chainDir), `.migrations-${canisterName}`);
17
+ }
10
18
 
11
19
  export interface MigrationArgsResult {
12
20
  migrationArgs: string[];
@@ -70,6 +78,21 @@ export function validateMigrationsConfig(
70
78
  );
71
79
  }
72
80
  }
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
+ }
73
96
  }
74
97
 
75
98
  export async function prepareMigrationArgs(
@@ -130,9 +153,20 @@ export async function prepareMigrationArgs(
130
153
  };
131
154
  }
132
155
 
133
- const tempDir = join(MIGRATIONS_TEMP_DIR, canisterName);
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
+
166
+ const tempDir = stagedMigrationsDir(chainDir, canisterName);
134
167
  await rm(tempDir, { recursive: true, force: true });
135
168
  mkdirSync(tempDir, { recursive: true });
169
+ writeFileSync(join(tempDir, ".gitignore"), "*\n");
136
170
 
137
171
  const filesToInclude = isTrimming
138
172
  ? allMigrations.slice(-limit)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ic-mops",
3
- "version": "2.11.0",
3
+ "version": "2.12.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mops": "dist/bin/mops.js",
@@ -33,12 +33,12 @@ actor {
33
33
 
34
34
  exports[`migrate build build-limit counts next migration as part of the chain 1`] = `
35
35
  "// 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};
36
39
  {
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
42
42
  }
43
43
  actor {
44
44
  stable email : Text;
@@ -75,16 +75,52 @@ exports[`migrate check check with trimming shows reduced chain 1`] = `
75
75
  "stdout": "check Using --all-libs for richer diagnostics
76
76
  migrations Prepared 2 migration(s) for backend (trimmed from 3)
77
77
  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"]
79
79
  ✓ backend
80
80
  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"]
82
82
  check-stable Comparing deployed.most ↔ .mops/.check-stable/new.most
83
83
  <CACHE>moc-wrapper ["--stable-compatible","deployed.most",".mops/.check-stable/new.most"]
84
84
  ✓ Stable compatibility check passed for canister 'backend'",
85
85
  }
86
86
  `;
87
87
 
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
+
88
124
  exports[`migrate migrate freeze moves the file from next to chain 1`] = `
89
125
  {
90
126
  "exitCode": 0,
@@ -1,5 +1,7 @@
1
+ import State "../types/State";
2
+
1
3
  module {
2
- public func migration(_ : {}) : { a : Nat } {
4
+ public func migration(_ : State.V0) : State.V1 {
3
5
  { a = 0 };
4
6
  };
5
7
  };
@@ -1,5 +1,7 @@
1
+ import State "../types/State";
2
+
1
3
  module {
2
- public func migration(old : { a : Nat }) : { a : Nat; name : Text } {
4
+ public func migration(old : State.V1) : State.V2 {
3
5
  { old with name = "" };
4
6
  };
5
7
  };
@@ -1,9 +1,7 @@
1
+ import State "../types/State";
2
+
1
3
  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 {
7
5
  { old with email = "" };
8
6
  };
9
7
  };
@@ -1,9 +1,7 @@
1
+ import State "../types/State";
2
+
1
3
  module {
2
- public func migration(old : { a : Nat; name : Text; email : Text }) : {
3
- id : Nat;
4
- name : Text;
5
- email : Text;
6
- } {
4
+ public func migration(old : State.V3) : State.V4 {
7
5
  { id = old.a; name = old.name; email = old.email };
8
6
  };
9
7
  };
@@ -0,0 +1,7 @@
1
+ module {
2
+ public type V0 = {};
3
+ public type V1 = { a : Nat };
4
+ public type V2 = { a : Nat; name : Text };
5
+ public type V3 = { a : Nat; name : Text; email : Text };
6
+ public type V4 = { id : Nat; name : Text; email : Text };
7
+ };
@@ -154,6 +154,51 @@ describe("migrate", () => {
154
154
  await patchMigrations(cwd, "check-limit = 2");
155
155
  await cliSnapshot(["check", "--verbose"], { cwd }, 0);
156
156
  });
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
+ });
157
202
  });
158
203
 
159
204
  describe("build", () => {
@@ -207,6 +252,33 @@ describe("migrate", () => {
207
252
  });
208
253
  });
209
254
 
255
+ describe("sibling validation", () => {
256
+ async function patchNextDir(cwd: string, nextValue: string): Promise<void> {
257
+ const tomlPath = path.join(cwd, "mops.toml");
258
+ const toml = readFileSync(tomlPath, "utf-8");
259
+ await writeFile(
260
+ tomlPath,
261
+ toml.replace('next = "next-migration"', `next = "${nextValue}"`),
262
+ );
263
+ }
264
+
265
+ test("errors from `mops check` when chain and next have different parents", async () => {
266
+ const cwd = await makeTempFixture("with-next");
267
+ await patchNextDir(cwd, "other/next-migration");
268
+ const result = await cli(["check"], { cwd });
269
+ expect(result.exitCode).toBe(1);
270
+ expect(result.stderr).toMatch(/same parent directory/i);
271
+ });
272
+
273
+ test("errors from `mops migrate new` too — validation runs in every entry point", async () => {
274
+ const cwd = await makeTempFixture("basic");
275
+ await patchNextDir(cwd, "other/next-migration");
276
+ const result = await cli(["migrate", "new", "Test"], { cwd });
277
+ expect(result.exitCode).toBe(1);
278
+ expect(result.stderr).toMatch(/same parent directory/i);
279
+ });
280
+ });
281
+
210
282
  describe("conflict detection", () => {
211
283
  test("errors when both [migrations] and --enhanced-migration in args", async () => {
212
284
  const cwd = await makeTempFixture("basic");