ic-mops 2.13.1 → 2.13.2

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,12 @@
2
2
 
3
3
  ## Next
4
4
 
5
+ ## 2.13.2
6
+ - Fix race conditions when two `mops` processes run on the same project (e.g. an editor watcher and `caffeine check --fix`, or back-to-back invocations). `mops check-stable` used a shared `.mops/.check-stable/` scratch dir and `mops check`/`build`/`check-stable` used a shared `<parent>/.migrations-<canister>/` staging dir; concurrent runs would clobber each other and surface as misleading errors like `.mops/.check-stable/new.most: No such file or directory` or `EEXIST: file already exists, symlink ...`. Both directories are now per-invocation (created via `mkdtemp` and removed when the command finishes).
7
+ - Deprecate `skipIfMissing` in `[canisters.<name>.check-stable]`. Behavior is unchanged for now, but `mops check`/`check-stable` print a warning when it is set. For initial deployments, commit a `.most` file at the configured `path` containing an empty actor (`// Version: 1.0.0\nactor { };`) instead — the stable check then runs against an empty baseline.
8
+ - Drop the "you may need a migration" hint after a failed stable compatibility check in `mops check`/`check-stable`. The hint guessed at whether the user needed a new migration or a fix to an existing one, and `moc`'s underlying compatibility error already links to the migration docs.
9
+ - The missing-chain-directory error from `mops check`/`build`/`check-stable` now points at adding a `.mo` file to the `chain` directory instead of running the experimental `mops migrate new <Name>` command.
10
+
5
11
  ## 2.13.1
6
12
  - `mops lint` now honors `[canisters.<name>.migrations].check-limit`, skipping trimmed chain migrations so projects with large migration histories lint as fast as they type-check. Pass an explicit filter (`mops lint <name>`) to opt back in for a one-off lint of a trimmed file.
7
13
 
package/bundle/cli.tgz CHANGED
Binary file
@@ -1,5 +1,5 @@
1
- import { basename, join } from "node:path";
2
- import { existsSync, mkdirSync } from "node:fs";
1
+ import { join } from "node:path";
2
+ import { existsSync, mkdirSync, mkdtempSync } from "node:fs";
3
3
  import { rm } from "node:fs/promises";
4
4
  import chalk from "chalk";
5
5
  import { execa } from "execa";
@@ -17,7 +17,10 @@ import {
17
17
  import { sourcesArgs } from "./sources.js";
18
18
  import { toolchain } from "./toolchain/index.js";
19
19
 
20
- const CHECK_STABLE_DIR = ".mops/.check-stable";
20
+ // Per-invocation scratch dir lives under `.mops/`; `mkdtempSync` makes it unique so
21
+ // concurrent `mops` processes don't clobber each other's `old.most`/`new.most`.
22
+ const CHECK_STABLE_PARENT = ".mops";
23
+ const CHECK_STABLE_PREFIX = ".check-stable-";
21
24
 
22
25
  export interface CheckStableOptions {
23
26
  verbose: boolean;
@@ -39,15 +42,25 @@ export function resolveStablePath(
39
42
  return null;
40
43
  }
41
44
  const stablePath = resolveConfigPath(stableConfig.path);
45
+ if (stableConfig.skipIfMissing) {
46
+ console.warn(
47
+ chalk.yellow(
48
+ `WARN: \`skipIfMissing\` in [canisters.${canisterName}.check-stable] is deprecated. ` +
49
+ `Instead, create ${stableConfig.path} with an empty actor:\n` +
50
+ " // Version: 1.0.0\n" +
51
+ " actor { };",
52
+ ),
53
+ );
54
+ }
42
55
  if (!existsSync(stablePath)) {
43
56
  if (stableConfig.skipIfMissing) {
44
57
  return null;
45
58
  }
46
59
  cliError(
47
60
  `Deployed file not found: ${stablePath} (canister '${canisterName}')\n` +
48
- "Set skipIfMissing = true in [canisters." +
49
- canisterName +
50
- ".check-stable] to skip this check when the file is missing.",
61
+ `Create ${stableConfig.path} with an empty actor to enable the check:\n` +
62
+ " // Version: 1.0.0\n" +
63
+ " actor { };",
51
64
  );
52
65
  }
53
66
  return stablePath;
@@ -88,7 +101,6 @@ export async function checkStable(
88
101
  globalMocArgs,
89
102
  canisterArgs: [...migration.migrationArgs, ...(canister.args ?? [])],
90
103
  options,
91
- hasMigrations: !!canister.migrations,
92
104
  });
93
105
  } finally {
94
106
  await migration.cleanup();
@@ -131,7 +143,6 @@ export async function checkStable(
131
143
  canisterArgs: [...migration.migrationArgs, ...(canister.args ?? [])],
132
144
  sources,
133
145
  options,
134
- hasMigrations: !!canister.migrations,
135
146
  });
136
147
  } finally {
137
148
  await migration.cleanup();
@@ -159,7 +170,6 @@ export interface RunStableCheckParams {
159
170
  canisterArgs: string[];
160
171
  sources?: string[];
161
172
  options?: Partial<CheckStableOptions>;
162
- hasMigrations?: boolean;
163
173
  }
164
174
 
165
175
  export async function runStableCheck(
@@ -182,15 +192,17 @@ export async function runStableCheck(
182
192
  cliError(`File not found: ${oldFile}`);
183
193
  }
184
194
 
185
- await rm(CHECK_STABLE_DIR, { recursive: true, force: true });
186
- mkdirSync(CHECK_STABLE_DIR, { recursive: true });
195
+ mkdirSync(CHECK_STABLE_PARENT, { recursive: true });
196
+ const scratchDir = mkdtempSync(
197
+ join(CHECK_STABLE_PARENT, CHECK_STABLE_PREFIX),
198
+ );
187
199
  try {
188
200
  const oldMostPath = isOldMostFile
189
201
  ? oldFile
190
202
  : await generateStableTypes(
191
203
  mocPath,
192
204
  oldFile,
193
- join(CHECK_STABLE_DIR, "old.most"),
205
+ join(scratchDir, "old.most"),
194
206
  sources,
195
207
  globalMocArgs,
196
208
  canisterArgs,
@@ -200,7 +212,7 @@ export async function runStableCheck(
200
212
  const newMostPath = await generateStableTypes(
201
213
  mocPath,
202
214
  canisterMain,
203
- join(CHECK_STABLE_DIR, "new.most"),
215
+ join(scratchDir, "new.most"),
204
216
  sources,
205
217
  globalMocArgs,
206
218
  canisterArgs,
@@ -228,13 +240,6 @@ export async function runStableCheck(
228
240
  if (result.stderr) {
229
241
  console.error(result.stderr);
230
242
  }
231
- if (params.hasMigrations) {
232
- console.error(
233
- chalk.yellow(
234
- "Hint: You may need a migration. Run `mops migrate new <Name>` to create one.",
235
- ),
236
- );
237
- }
238
243
  cliError(
239
244
  `✗ Stable compatibility check failed for canister '${canisterName}'`,
240
245
  );
@@ -246,7 +251,7 @@ export async function runStableCheck(
246
251
  ),
247
252
  );
248
253
  } finally {
249
- await rm(CHECK_STABLE_DIR, { recursive: true, force: true });
254
+ await rm(scratchDir, { recursive: true, force: true });
250
255
  }
251
256
  }
252
257
 
@@ -259,8 +264,7 @@ async function generateStableTypes(
259
264
  canisterArgs: string[],
260
265
  options: Partial<CheckStableOptions>,
261
266
  ): Promise<string> {
262
- const base = basename(outputPath, ".most");
263
- const wasmPath = join(CHECK_STABLE_DIR, base + ".wasm");
267
+ const wasmPath = outputPath.replace(/\.most$/, ".wasm");
264
268
  const args = [
265
269
  "--stable-types",
266
270
  "-o",
package/commands/check.ts CHANGED
@@ -216,7 +216,6 @@ async function checkCanisters(
216
216
  canisterArgs: [...migration.migrationArgs, ...(canister.args ?? [])],
217
217
  sources,
218
218
  options: { verbose: options.verbose, extraArgs: options.extraArgs },
219
- hasMigrations: !!canister.migrations,
220
219
  });
221
220
  }
222
221
  } finally {
@@ -16,6 +16,5 @@ export interface RunStableCheckParams {
16
16
  canisterArgs: string[];
17
17
  sources?: string[];
18
18
  options?: Partial<CheckStableOptions>;
19
- hasMigrations?: boolean;
20
19
  }
21
20
  export declare function runStableCheck(params: RunStableCheckParams): Promise<void>;
@@ -1,5 +1,5 @@
1
- import { basename, join } from "node:path";
2
- import { existsSync, mkdirSync } from "node:fs";
1
+ import { join } from "node:path";
2
+ import { existsSync, mkdirSync, mkdtempSync } from "node:fs";
3
3
  import { rm } from "node:fs/promises";
4
4
  import chalk from "chalk";
5
5
  import { execa } from "execa";
@@ -9,7 +9,10 @@ import { getGlobalMocArgs, readConfig, resolveConfigPath } from "../mops.js";
9
9
  import { filterCanisters, looksLikeFile, resolveCanisterConfigs, resolveSingleCanister, validateCanisterArgs, } from "../helpers/resolve-canisters.js";
10
10
  import { sourcesArgs } from "./sources.js";
11
11
  import { toolchain } from "./toolchain/index.js";
12
- const CHECK_STABLE_DIR = ".mops/.check-stable";
12
+ // Per-invocation scratch dir lives under `.mops/`; `mkdtempSync` makes it unique so
13
+ // concurrent `mops` processes don't clobber each other's `old.most`/`new.most`.
14
+ const CHECK_STABLE_PARENT = ".mops";
15
+ const CHECK_STABLE_PREFIX = ".check-stable-";
13
16
  export function resolveStablePath(canister, canisterName, options) {
14
17
  const stableConfig = canister["check-stable"];
15
18
  if (!stableConfig) {
@@ -19,14 +22,20 @@ export function resolveStablePath(canister, canisterName, options) {
19
22
  return null;
20
23
  }
21
24
  const stablePath = resolveConfigPath(stableConfig.path);
25
+ if (stableConfig.skipIfMissing) {
26
+ console.warn(chalk.yellow(`WARN: \`skipIfMissing\` in [canisters.${canisterName}.check-stable] is deprecated. ` +
27
+ `Instead, create ${stableConfig.path} with an empty actor:\n` +
28
+ " // Version: 1.0.0\n" +
29
+ " actor { };"));
30
+ }
22
31
  if (!existsSync(stablePath)) {
23
32
  if (stableConfig.skipIfMissing) {
24
33
  return null;
25
34
  }
26
35
  cliError(`Deployed file not found: ${stablePath} (canister '${canisterName}')\n` +
27
- "Set skipIfMissing = true in [canisters." +
28
- canisterName +
29
- ".check-stable] to skip this check when the file is missing.");
36
+ `Create ${stableConfig.path} with an empty actor to enable the check:\n` +
37
+ " // Version: 1.0.0\n" +
38
+ " actor { };");
30
39
  }
31
40
  return stablePath;
32
41
  }
@@ -53,7 +62,6 @@ export async function checkStable(args, options = {}) {
53
62
  globalMocArgs,
54
63
  canisterArgs: [...migration.migrationArgs, ...(canister.args ?? [])],
55
64
  options,
56
- hasMigrations: !!canister.migrations,
57
65
  });
58
66
  }
59
67
  finally {
@@ -88,7 +96,6 @@ export async function checkStable(args, options = {}) {
88
96
  canisterArgs: [...migration.migrationArgs, ...(canister.args ?? [])],
89
97
  sources,
90
98
  options,
91
- hasMigrations: !!canister.migrations,
92
99
  });
93
100
  }
94
101
  finally {
@@ -111,13 +118,13 @@ export async function runStableCheck(params) {
111
118
  if (!existsSync(oldFile)) {
112
119
  cliError(`File not found: ${oldFile}`);
113
120
  }
114
- await rm(CHECK_STABLE_DIR, { recursive: true, force: true });
115
- mkdirSync(CHECK_STABLE_DIR, { recursive: true });
121
+ mkdirSync(CHECK_STABLE_PARENT, { recursive: true });
122
+ const scratchDir = mkdtempSync(join(CHECK_STABLE_PARENT, CHECK_STABLE_PREFIX));
116
123
  try {
117
124
  const oldMostPath = isOldMostFile
118
125
  ? oldFile
119
- : await generateStableTypes(mocPath, oldFile, join(CHECK_STABLE_DIR, "old.most"), sources, globalMocArgs, canisterArgs, options);
120
- const newMostPath = await generateStableTypes(mocPath, canisterMain, join(CHECK_STABLE_DIR, "new.most"), sources, globalMocArgs, canisterArgs, options);
126
+ : await generateStableTypes(mocPath, oldFile, join(scratchDir, "old.most"), sources, globalMocArgs, canisterArgs, options);
127
+ const newMostPath = await generateStableTypes(mocPath, canisterMain, join(scratchDir, "new.most"), sources, globalMocArgs, canisterArgs, options);
121
128
  if (options.verbose) {
122
129
  console.log(chalk.blue("check-stable"), chalk.gray(`Comparing ${oldMostPath} ↔ ${newMostPath}`));
123
130
  }
@@ -133,20 +140,16 @@ export async function runStableCheck(params) {
133
140
  if (result.stderr) {
134
141
  console.error(result.stderr);
135
142
  }
136
- if (params.hasMigrations) {
137
- console.error(chalk.yellow("Hint: You may need a migration. Run `mops migrate new <Name>` to create one."));
138
- }
139
143
  cliError(`✗ Stable compatibility check failed for canister '${canisterName}'`);
140
144
  }
141
145
  console.log(chalk.green(`✓ Stable compatibility check passed for canister '${canisterName}'`));
142
146
  }
143
147
  finally {
144
- await rm(CHECK_STABLE_DIR, { recursive: true, force: true });
148
+ await rm(scratchDir, { recursive: true, force: true });
145
149
  }
146
150
  }
147
151
  async function generateStableTypes(mocPath, moFile, outputPath, sources, globalMocArgs, canisterArgs, options) {
148
- const base = basename(outputPath, ".most");
149
- const wasmPath = join(CHECK_STABLE_DIR, base + ".wasm");
152
+ const wasmPath = outputPath.replace(/\.most$/, ".wasm");
150
153
  const args = [
151
154
  "--stable-types",
152
155
  "-o",
@@ -137,7 +137,6 @@ async function checkCanisters(config, canisters, options) {
137
137
  canisterArgs: [...migration.migrationArgs, ...(canister.args ?? [])],
138
138
  sources,
139
139
  options: { verbose: options.verbose, extraArgs: options.extraArgs },
140
- hasMigrations: !!canister.migrations,
141
140
  });
142
141
  }
143
142
  }
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, readdirSync, symlinkSync, writeFileSync, } from "node:fs";
1
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, symlinkSync, writeFileSync, } from "node:fs";
2
2
  import { dirname, join, resolve } from "node:path";
3
3
  import { rm } from "node:fs/promises";
4
4
  import chalk from "chalk";
@@ -77,7 +77,7 @@ function resolveMigrationChain(migrations, canisterName, mode) {
77
77
  const nextFile = nextDir ? getNextMigrationFile(nextDir) : null;
78
78
  if (!existsSync(chainDir) && !nextFile) {
79
79
  cliError(`Migration chain directory not found: ${chainDir}\n` +
80
- "Run `mops migrate new <Name>` to initialize the migration chain.");
80
+ "Create the directory and add a `.mo` migration file to initialize the chain.");
81
81
  }
82
82
  const chainFiles = getMigrationFiles(chainDir);
83
83
  if (nextFile) {
@@ -122,9 +122,11 @@ export async function prepareMigrationArgs(migrations, canisterName, mode, verbo
122
122
  }
123
123
  return { migrationArgs, cleanup: async () => { } };
124
124
  }
125
- const tempDir = stagedMigrationsDir(chainDir, canisterName);
126
- await rm(tempDir, { recursive: true, force: true });
127
- mkdirSync(tempDir, { recursive: true });
125
+ // Per-invocation staging dir; `mkdtempSync` makes it unique so concurrent `mops`
126
+ // processes don't clobber each other's symlinks. Cleaned up below in `cleanup()`.
127
+ const baseDir = stagedMigrationsDir(chainDir, canisterName);
128
+ mkdirSync(dirname(baseDir), { recursive: true });
129
+ const tempDir = mkdtempSync(`${baseDir}-`);
128
130
  writeFileSync(join(tempDir, ".gitignore"), "*\n");
129
131
  for (const { file, dir } of included) {
130
132
  symlinkSync(resolve(dir, file), join(tempDir, file));
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ic-mops",
3
- "version": "2.13.1",
3
+ "version": "2.13.2",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mops": "bin/mops.js",
@@ -77,4 +77,21 @@ describe("check-stable", () => {
77
77
  expect(result.exitCode).toBe(1);
78
78
  expect(result.stderr).toMatch(/File not found/);
79
79
  });
80
+ // Regression: two concurrent `mops check-stable` runs on the same project used to clobber
81
+ // each other's `.mops/.check-stable/new.most` and the staged migration symlinks, surfacing
82
+ // as a misleading `new.most: No such file or directory` or an `EEXIST: symlink` crash.
83
+ test("concurrent runs do not clobber each other's scratch state", async () => {
84
+ const cwd = path.join(import.meta.dirname, "check-stable/migrations-chain");
85
+ const results = await Promise.all(Array.from({ length: 10 }, () => cli(["check-stable"], { cwd })));
86
+ for (const result of results) {
87
+ expect({
88
+ exitCode: result.exitCode,
89
+ stderr: result.stderr,
90
+ }).toEqual({
91
+ exitCode: 0,
92
+ stderr: "",
93
+ });
94
+ expect(result.stdout).toMatch(/Stable compatibility check passed/);
95
+ }
96
+ }, 60_000);
80
97
  });
@@ -86,18 +86,19 @@ describe("check", () => {
86
86
  expect(result.exitCode).toBe(0);
87
87
  expect(result.stdout).toMatch(/Stable compatibility check passed/);
88
88
  });
89
- test("deployed: silently skips when file missing and skipIfMissing", async () => {
89
+ test("deployed: skips when file missing and skipIfMissing, with deprecation warning", async () => {
90
90
  const cwd = path.join(import.meta.dirname, "check/deployed-missing-skip");
91
91
  const result = await cli(["check"], { cwd });
92
92
  expect(result.exitCode).toBe(0);
93
93
  expect(result.stdout).not.toMatch(/stable/i);
94
+ expect(result.stderr).toMatch(/skipIfMissing.*deprecated/);
94
95
  });
95
- test("deployed: errors when file missing without deployedSkipIfFileMissing", async () => {
96
+ test("deployed: errors when file missing", async () => {
96
97
  const cwd = path.join(import.meta.dirname, "check/deployed-missing-error");
97
98
  const result = await cli(["check"], { cwd });
98
99
  expect(result.exitCode).toBe(1);
99
100
  expect(result.stderr).toMatch(/Deployed file not found/);
100
- expect(result.stderr).toMatch(/skipIfMissing/);
101
+ expect(result.stderr).toMatch(/empty actor/);
101
102
  });
102
103
  test("--fix runs stable check after fixing", async () => {
103
104
  const cwd = path.join(import.meta.dirname, "check/deployed-compatible");
@@ -26,7 +26,11 @@ export const normalizePaths = (text) => {
26
26
  .replace(/\/[^\s"]+\/\.cache\/mops/g, "<CACHE>")
27
27
  .replace(/\/[^\s"]+\/Library\/Caches\/mops/g, "<CACHE>")
28
28
  .replace(/\/[^\s"[\]]+\/moc(?:-wrapper)?(?=\s|$)/g, "moc-wrapper")
29
- .replace(/\/[^\s"[\]]+\.motoko\/bin\/moc/g, "moc-wrapper"));
29
+ .replace(/\/[^\s"[\]]+\.motoko\/bin\/moc/g, "moc-wrapper")
30
+ // Per-invocation scratch / staging dirs use mkdtemp; redact the random suffix
31
+ // (Node's exact suffix format isn't a stable contract) so snapshots stay stable.
32
+ .replace(/\.mops\/\.check-stable-\w+/g, ".mops/.check-stable")
33
+ .replace(/(\.migrations-[\w.-]+?)-\w+(?=[/\s"]|$)/g, "$1"));
30
34
  };
31
35
  export const cliSnapshot = async (args, options, exitCode) => {
32
36
  const result = await cli(args, options);
@@ -173,8 +173,8 @@ describe("migrate", () => {
173
173
  expect(most).toMatchSnapshot();
174
174
  });
175
175
  });
176
- describe("stable check hint", () => {
177
- test("stable check fails with hint when deployed.most is incompatible", async () => {
176
+ describe("stable check", () => {
177
+ test("stable check fails when deployed.most is incompatible", async () => {
178
178
  const cwd = await makeTempFixture("basic");
179
179
  await writeFile(path.join(cwd, "deployed.most"), "// Version: 1.0.0\nactor {\n stable var a : Nat;\n stable var name : Int\n};\n");
180
180
  await cliSnapshot(["check"], { cwd }, 1);
package/dist/types.d.ts CHANGED
@@ -47,6 +47,7 @@ export type CanisterConfig = {
47
47
  initArg?: string;
48
48
  "check-stable"?: {
49
49
  path: string;
50
+ /** @deprecated Create the file with an empty actor instead. */
50
51
  skipIfMissing?: boolean;
51
52
  };
52
53
  migrations?: MigrationsConfig;
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  existsSync,
3
3
  mkdirSync,
4
+ mkdtempSync,
4
5
  readdirSync,
5
6
  symlinkSync,
6
7
  writeFileSync,
@@ -130,7 +131,7 @@ function resolveMigrationChain(
130
131
  if (!existsSync(chainDir) && !nextFile) {
131
132
  cliError(
132
133
  `Migration chain directory not found: ${chainDir}\n` +
133
- "Run `mops migrate new <Name>` to initialize the migration chain.",
134
+ "Create the directory and add a `.mo` migration file to initialize the chain.",
134
135
  );
135
136
  }
136
137
 
@@ -193,9 +194,11 @@ export async function prepareMigrationArgs(
193
194
  return { migrationArgs, cleanup: async () => {} };
194
195
  }
195
196
 
196
- const tempDir = stagedMigrationsDir(chainDir, canisterName);
197
- await rm(tempDir, { recursive: true, force: true });
198
- mkdirSync(tempDir, { recursive: true });
197
+ // Per-invocation staging dir; `mkdtempSync` makes it unique so concurrent `mops`
198
+ // processes don't clobber each other's symlinks. Cleaned up below in `cleanup()`.
199
+ const baseDir = stagedMigrationsDir(chainDir, canisterName);
200
+ mkdirSync(dirname(baseDir), { recursive: true });
201
+ const tempDir = mkdtempSync(`${baseDir}-`);
199
202
  writeFileSync(join(tempDir, ".gitignore"), "*\n");
200
203
 
201
204
  for (const { file, dir } of included) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ic-mops",
3
- "version": "2.13.1",
3
+ "version": "2.13.2",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mops": "dist/bin/mops.js",
@@ -143,12 +143,11 @@ exports[`migrate migrate new creates a migration file with timestamp and templat
143
143
  }
144
144
  `;
145
145
 
146
- exports[`migrate stable check hint stable check fails with hint when deployed.most is incompatible 1`] = `
146
+ exports[`migrate stable check stable check fails when deployed.most is incompatible 1`] = `
147
147
  {
148
148
  "exitCode": 1,
149
149
  "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
150
150
  (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
151
- Hint: You may need a migration. Run \`mops migrate new <Name>\` to create one.
152
151
  ✗ Stable compatibility check failed for canister 'backend'",
153
152
  "stdout": "✓ backend",
154
153
  }
@@ -0,0 +1,14 @@
1
+ // Version: 4.0.0
2
+ {
3
+ "20250101_000000_Init" : {} -> {a : Nat; b : Text};
4
+ "20250201_000000_AddField" : (old : {a : Nat; b : Text}) -> {a : Nat; b : Text; c : Bool};
5
+ "20250301_000000_AddD" : (old : {a : Nat; b : Text; c : Bool}) -> {a : Nat; b : Text; c : Bool; d : Int};
6
+ "20250401_000000_AddE" : (old : {a : Nat; b : Text; c : Bool; d : Int}) -> {a : Nat; b : Text; c : Bool; d : Int; e : Text}
7
+ }
8
+ actor {
9
+ stable a : Nat;
10
+ stable b : Text;
11
+ stable c : Bool;
12
+ stable d : Int;
13
+ stable e : Text
14
+ };
@@ -0,0 +1,8 @@
1
+ module {
2
+ public func migration(_ : {}) : { a : Nat; b : Text } {
3
+ {
4
+ a = 42;
5
+ b = "hello";
6
+ };
7
+ };
8
+ };
@@ -0,0 +1,9 @@
1
+ module {
2
+ public func migration(old : { a : Nat; b : Text }) : {
3
+ a : Nat;
4
+ b : Text;
5
+ c : Bool;
6
+ } {
7
+ { old with c = true };
8
+ };
9
+ };
@@ -0,0 +1,10 @@
1
+ module {
2
+ public func migration(old : { a : Nat; b : Text; c : Bool }) : {
3
+ a : Nat;
4
+ b : Text;
5
+ c : Bool;
6
+ d : Int;
7
+ } {
8
+ { old with d = 0 };
9
+ };
10
+ };
@@ -0,0 +1,11 @@
1
+ module {
2
+ public func migration(old : { a : Nat; b : Text; c : Bool; d : Int }) : {
3
+ a : Nat;
4
+ b : Text;
5
+ c : Bool;
6
+ d : Int;
7
+ e : Text;
8
+ } {
9
+ { old with e = "" };
10
+ };
11
+ };
@@ -0,0 +1,15 @@
1
+ [toolchain]
2
+ moc = "1.5.0"
3
+
4
+ [moc]
5
+ args = ["--default-persistent-actors"]
6
+
7
+ [canisters.backend]
8
+ main = "src/main.mo"
9
+
10
+ [canisters.backend.migrations]
11
+ chain = "migrations"
12
+ check-limit = 3
13
+
14
+ [canisters.backend.check-stable]
15
+ path = "deployed.most"
@@ -0,0 +1,13 @@
1
+ import Prim "mo:prim";
2
+
3
+ actor {
4
+ let a : Nat;
5
+ let b : Text;
6
+ let c : Bool;
7
+ let d : Int;
8
+ let e : Text;
9
+
10
+ public func check() : async () {
11
+ Prim.debugPrint(debug_show { a; b; c; d; e });
12
+ };
13
+ };
@@ -86,4 +86,24 @@ describe("check-stable", () => {
86
86
  expect(result.exitCode).toBe(1);
87
87
  expect(result.stderr).toMatch(/File not found/);
88
88
  });
89
+
90
+ // Regression: two concurrent `mops check-stable` runs on the same project used to clobber
91
+ // each other's `.mops/.check-stable/new.most` and the staged migration symlinks, surfacing
92
+ // as a misleading `new.most: No such file or directory` or an `EEXIST: symlink` crash.
93
+ test("concurrent runs do not clobber each other's scratch state", async () => {
94
+ const cwd = path.join(import.meta.dirname, "check-stable/migrations-chain");
95
+ const results = await Promise.all(
96
+ Array.from({ length: 10 }, () => cli(["check-stable"], { cwd })),
97
+ );
98
+ for (const result of results) {
99
+ expect({
100
+ exitCode: result.exitCode,
101
+ stderr: result.stderr,
102
+ }).toEqual({
103
+ exitCode: 0,
104
+ stderr: "",
105
+ });
106
+ expect(result.stdout).toMatch(/Stable compatibility check passed/);
107
+ }
108
+ }, 60_000);
89
109
  });
@@ -113,19 +113,20 @@ describe("check", () => {
113
113
  expect(result.stdout).toMatch(/Stable compatibility check passed/);
114
114
  });
115
115
 
116
- test("deployed: silently skips when file missing and skipIfMissing", async () => {
116
+ test("deployed: skips when file missing and skipIfMissing, with deprecation warning", async () => {
117
117
  const cwd = path.join(import.meta.dirname, "check/deployed-missing-skip");
118
118
  const result = await cli(["check"], { cwd });
119
119
  expect(result.exitCode).toBe(0);
120
120
  expect(result.stdout).not.toMatch(/stable/i);
121
+ expect(result.stderr).toMatch(/skipIfMissing.*deprecated/);
121
122
  });
122
123
 
123
- test("deployed: errors when file missing without deployedSkipIfFileMissing", async () => {
124
+ test("deployed: errors when file missing", async () => {
124
125
  const cwd = path.join(import.meta.dirname, "check/deployed-missing-error");
125
126
  const result = await cli(["check"], { cwd });
126
127
  expect(result.exitCode).toBe(1);
127
128
  expect(result.stderr).toMatch(/Deployed file not found/);
128
- expect(result.stderr).toMatch(/skipIfMissing/);
129
+ expect(result.stderr).toMatch(/empty actor/);
129
130
  });
130
131
 
131
132
  test("--fix runs stable check after fixing", async () => {
package/tests/helpers.ts CHANGED
@@ -37,7 +37,11 @@ export const normalizePaths = (text: string): string => {
37
37
  .replace(/\/[^\s"]+\/\.cache\/mops/g, "<CACHE>")
38
38
  .replace(/\/[^\s"]+\/Library\/Caches\/mops/g, "<CACHE>")
39
39
  .replace(/\/[^\s"[\]]+\/moc(?:-wrapper)?(?=\s|$)/g, "moc-wrapper")
40
- .replace(/\/[^\s"[\]]+\.motoko\/bin\/moc/g, "moc-wrapper"),
40
+ .replace(/\/[^\s"[\]]+\.motoko\/bin\/moc/g, "moc-wrapper")
41
+ // Per-invocation scratch / staging dirs use mkdtemp; redact the random suffix
42
+ // (Node's exact suffix format isn't a stable contract) so snapshots stay stable.
43
+ .replace(/\.mops\/\.check-stable-\w+/g, ".mops/.check-stable")
44
+ .replace(/(\.migrations-[\w.-]+?)-\w+(?=[/\s"]|$)/g, "$1"),
41
45
  );
42
46
  };
43
47
 
@@ -241,8 +241,8 @@ describe("migrate", () => {
241
241
  });
242
242
  });
243
243
 
244
- describe("stable check hint", () => {
245
- test("stable check fails with hint when deployed.most is incompatible", async () => {
244
+ describe("stable check", () => {
245
+ test("stable check fails when deployed.most is incompatible", async () => {
246
246
  const cwd = await makeTempFixture("basic");
247
247
  await writeFile(
248
248
  path.join(cwd, "deployed.most"),
package/types.ts CHANGED
@@ -49,6 +49,7 @@ export type CanisterConfig = {
49
49
  initArg?: string;
50
50
  "check-stable"?: {
51
51
  path: string;
52
+ /** @deprecated Create the file with an empty actor instead. */
52
53
  skipIfMissing?: boolean;
53
54
  };
54
55
  migrations?: MigrationsConfig;