ic-mops 2.10.0 → 2.12.0
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 +11 -0
- package/bundle/cli.tgz +0 -0
- package/cli.ts +24 -0
- package/commands/build.ts +10 -1
- package/commands/check-stable.ts +52 -21
- package/commands/check.ts +65 -52
- package/commands/init.ts +17 -4
- package/commands/migrate.ts +165 -0
- package/declarations/main/main.did +38 -0
- package/declarations/main/main.did.d.ts +36 -0
- package/declarations/main/main.did.js +36 -0
- package/dist/cli.js +18 -0
- package/dist/commands/build.js +5 -1
- package/dist/commands/check-stable.d.ts +1 -0
- package/dist/commands/check-stable.js +39 -21
- package/dist/commands/check.js +51 -42
- package/dist/commands/init.js +11 -4
- package/dist/commands/migrate.d.ts +2 -0
- package/dist/commands/migrate.js +104 -0
- package/dist/declarations/main/main.did +38 -0
- package/dist/declarations/main/main.did.d.ts +36 -0
- package/dist/declarations/main/main.did.js +36 -0
- package/dist/helpers/migrations.d.ts +10 -0
- package/dist/helpers/migrations.js +125 -0
- package/dist/helpers/resolve-canisters.d.ts +1 -1
- package/dist/helpers/resolve-canisters.js +15 -1
- package/dist/package.json +1 -1
- package/dist/tests/migrate.test.d.ts +1 -0
- package/dist/tests/migrate.test.js +181 -0
- package/dist/types.d.ts +7 -0
- package/helpers/migrations.ts +190 -0
- package/helpers/resolve-canisters.ts +17 -0
- package/package.json +1 -1
- package/tests/__snapshots__/migrate.test.ts.snap +119 -0
- package/tests/migrate/basic/deployed.most +12 -0
- package/tests/migrate/basic/migrations/20250101_000000_Init.mo +5 -0
- package/tests/migrate/basic/migrations/20250201_000000_AddName.mo +5 -0
- package/tests/migrate/basic/migrations/20250301_000000_AddEmail.mo +9 -0
- package/tests/migrate/basic/mops.toml +15 -0
- package/tests/migrate/basic/next-migration/.gitkeep +0 -0
- package/tests/migrate/basic/src/main.mo +11 -0
- package/tests/migrate/with-next/deployed.most +12 -0
- package/tests/migrate/with-next/migrations/20250101_000000_Init.mo +7 -0
- package/tests/migrate/with-next/migrations/20250201_000000_AddName.mo +7 -0
- package/tests/migrate/with-next/migrations/20250301_000000_AddEmail.mo +7 -0
- package/tests/migrate/with-next/mops.toml +15 -0
- package/tests/migrate/with-next/next-migration/20250401_000000_RenameId.mo +7 -0
- package/tests/migrate/with-next/src/main.mo +11 -0
- package/tests/migrate/with-next/types/State.mo +7 -0
- package/tests/migrate.test.ts +255 -0
- package/types.ts +8 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, symlinkSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { rm } from "node:fs/promises";
|
|
4
|
+
import chalk from "chalk";
|
|
5
|
+
import { cliError } from "../error.js";
|
|
6
|
+
import { getRootDir, resolveConfigPath } from "../mops.js";
|
|
7
|
+
function stagedMigrationsDir(chainDir, canisterName) {
|
|
8
|
+
return join(dirname(chainDir), `.migrations-${canisterName}`);
|
|
9
|
+
}
|
|
10
|
+
export function getMigrationFiles(dir) {
|
|
11
|
+
if (!existsSync(dir)) {
|
|
12
|
+
return [];
|
|
13
|
+
}
|
|
14
|
+
return readdirSync(dir)
|
|
15
|
+
.filter((f) => f.endsWith(".mo"))
|
|
16
|
+
.sort();
|
|
17
|
+
}
|
|
18
|
+
export function getNextMigrationFile(nextDir) {
|
|
19
|
+
if (!existsSync(nextDir)) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
const files = readdirSync(nextDir).filter((f) => f.endsWith(".mo"));
|
|
23
|
+
if (files.length > 1) {
|
|
24
|
+
cliError(`next-migration directory must contain at most 1 .mo file, found ${files.length} in ${nextDir}`);
|
|
25
|
+
}
|
|
26
|
+
return files[0] ?? null;
|
|
27
|
+
}
|
|
28
|
+
export function validateNextMigrationOrder(chainDirOrFiles, nextFile) {
|
|
29
|
+
const chainFiles = typeof chainDirOrFiles === "string"
|
|
30
|
+
? getMigrationFiles(chainDirOrFiles)
|
|
31
|
+
: chainDirOrFiles;
|
|
32
|
+
const lastChainFile = chainFiles[chainFiles.length - 1];
|
|
33
|
+
if (lastChainFile && nextFile <= lastChainFile) {
|
|
34
|
+
cliError(`Next migration "${nextFile}" must sort after all files in the chain.\n` +
|
|
35
|
+
`Last chain file: "${lastChainFile}".\n` +
|
|
36
|
+
"Use a timestamp prefix (e.g. YYYYMMDD_HHMMSS_Name.mo) to ensure correct ordering.");
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export function validateMigrationsConfig(migrations, canisterName) {
|
|
40
|
+
if (!migrations.chain) {
|
|
41
|
+
cliError(`[canisters.${canisterName}.migrations] is missing required field "chain"`);
|
|
42
|
+
}
|
|
43
|
+
for (const field of ["check-limit", "build-limit"]) {
|
|
44
|
+
const value = migrations[field];
|
|
45
|
+
if (value !== undefined && (!Number.isInteger(value) || value <= 0)) {
|
|
46
|
+
cliError(`[canisters.${canisterName}.migrations] ${field} must be a positive integer`);
|
|
47
|
+
}
|
|
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
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export async function prepareMigrationArgs(migrations, canisterName, mode, verbose) {
|
|
64
|
+
const noOp = {
|
|
65
|
+
migrationArgs: [],
|
|
66
|
+
cleanup: async () => { },
|
|
67
|
+
};
|
|
68
|
+
if (!migrations) {
|
|
69
|
+
return noOp;
|
|
70
|
+
}
|
|
71
|
+
validateMigrationsConfig(migrations, canisterName);
|
|
72
|
+
const chainDir = resolveConfigPath(migrations.chain);
|
|
73
|
+
const nextDir = migrations.next
|
|
74
|
+
? resolveConfigPath(migrations.next)
|
|
75
|
+
: undefined;
|
|
76
|
+
const nextFile = nextDir ? getNextMigrationFile(nextDir) : null;
|
|
77
|
+
if (!existsSync(chainDir) && !nextFile) {
|
|
78
|
+
cliError(`Migration chain directory not found: ${chainDir}\n` +
|
|
79
|
+
"Run `mops migrate new <Name>` to initialize the migration chain.");
|
|
80
|
+
}
|
|
81
|
+
const chainFiles = getMigrationFiles(chainDir);
|
|
82
|
+
if (nextFile) {
|
|
83
|
+
validateNextMigrationOrder(chainFiles, nextFile);
|
|
84
|
+
}
|
|
85
|
+
const allMigrations = chainFiles.map((f) => ({
|
|
86
|
+
file: f,
|
|
87
|
+
dir: chainDir,
|
|
88
|
+
}));
|
|
89
|
+
if (nextFile && nextDir) {
|
|
90
|
+
allMigrations.push({ file: nextFile, dir: nextDir });
|
|
91
|
+
}
|
|
92
|
+
const limit = mode === "check" ? migrations["check-limit"] : migrations["build-limit"];
|
|
93
|
+
const isTrimming = limit !== undefined && limit < allMigrations.length;
|
|
94
|
+
const needsTempDir = nextFile !== null || isTrimming;
|
|
95
|
+
if (!needsTempDir) {
|
|
96
|
+
return {
|
|
97
|
+
migrationArgs: [`--enhanced-migration=${chainDir}`],
|
|
98
|
+
cleanup: async () => { },
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const tempDir = stagedMigrationsDir(chainDir, canisterName);
|
|
102
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
103
|
+
mkdirSync(tempDir, { recursive: true });
|
|
104
|
+
writeFileSync(join(tempDir, ".gitignore"), "*\n");
|
|
105
|
+
const filesToInclude = isTrimming
|
|
106
|
+
? allMigrations.slice(-limit)
|
|
107
|
+
: allMigrations;
|
|
108
|
+
for (const { file, dir } of filesToInclude) {
|
|
109
|
+
symlinkSync(resolve(dir, file), join(tempDir, file));
|
|
110
|
+
}
|
|
111
|
+
if (verbose) {
|
|
112
|
+
console.log(chalk.blue("migrations"), chalk.gray(`Prepared ${filesToInclude.length} migration(s) for ${canisterName}` +
|
|
113
|
+
(isTrimming ? ` (trimmed from ${allMigrations.length})` : "")));
|
|
114
|
+
}
|
|
115
|
+
const migrationArgs = [`--enhanced-migration=${tempDir}`];
|
|
116
|
+
if (isTrimming) {
|
|
117
|
+
migrationArgs.push("-A=M0254");
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
migrationArgs,
|
|
121
|
+
cleanup: async () => {
|
|
122
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -6,4 +6,4 @@ export declare function resolveSingleCanister(config: Config, canisterName?: str
|
|
|
6
6
|
canister: CanisterConfig;
|
|
7
7
|
};
|
|
8
8
|
export declare function looksLikeFile(arg: string): boolean;
|
|
9
|
-
export declare function validateCanisterArgs(canister: CanisterConfig, canisterName: string): void;
|
|
9
|
+
export declare function validateCanisterArgs(canister: CanisterConfig, canisterName: string, config?: Config): void;
|
|
@@ -39,8 +39,22 @@ export function looksLikeFile(arg) {
|
|
|
39
39
|
arg.includes("/") ||
|
|
40
40
|
arg.includes("\\"));
|
|
41
41
|
}
|
|
42
|
-
export function validateCanisterArgs(canister, canisterName) {
|
|
42
|
+
export function validateCanisterArgs(canister, canisterName, config) {
|
|
43
43
|
if (canister.args && typeof canister.args === "string") {
|
|
44
44
|
cliError(`Canister config 'args' should be an array of strings for canister ${canisterName}`);
|
|
45
45
|
}
|
|
46
|
+
if (!canister.migrations) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const flagSources = [
|
|
50
|
+
[`[canisters.${canisterName}].args`, canister.args],
|
|
51
|
+
["[moc].args", config?.moc?.args],
|
|
52
|
+
["[build].args", config?.build?.args],
|
|
53
|
+
];
|
|
54
|
+
for (const [section, args] of flagSources) {
|
|
55
|
+
if (args?.some((a) => a.startsWith("--enhanced-migration"))) {
|
|
56
|
+
cliError(`Canister '${canisterName}' has [migrations] config but --enhanced-migration in ${section}.\n` +
|
|
57
|
+
"Remove --enhanced-migration — it is managed automatically when [migrations] is configured.");
|
|
58
|
+
}
|
|
59
|
+
}
|
|
46
60
|
}
|
package/dist/package.json
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { describe, expect, test, afterEach } from "@jest/globals";
|
|
2
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { cp, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import { cli, cliSnapshot, normalizePaths } from "./helpers";
|
|
6
|
+
const normalizeTimestamp = (text) => text.replace(/\d{8}_\d{6}/g, "<TIMESTAMP>");
|
|
7
|
+
const fixturesDir = path.join(import.meta.dirname, "migrate");
|
|
8
|
+
describe("migrate", () => {
|
|
9
|
+
const tempDirs = [];
|
|
10
|
+
async function makeTempFixture(fixture) {
|
|
11
|
+
const src = path.join(fixturesDir, fixture);
|
|
12
|
+
const dest = path.join(fixturesDir, `_tmp_${fixture}_${Date.now()}`);
|
|
13
|
+
await cp(src, dest, { recursive: true });
|
|
14
|
+
tempDirs.push(dest);
|
|
15
|
+
return dest;
|
|
16
|
+
}
|
|
17
|
+
async function patchMigrations(cwd, extra) {
|
|
18
|
+
const tomlPath = path.join(cwd, "mops.toml");
|
|
19
|
+
const toml = readFileSync(tomlPath, "utf-8");
|
|
20
|
+
await writeFile(tomlPath, toml.replace('next = "next-migration"', `next = "next-migration"\n${extra}`));
|
|
21
|
+
}
|
|
22
|
+
afterEach(async () => {
|
|
23
|
+
for (const dir of tempDirs) {
|
|
24
|
+
await rm(dir, { recursive: true, force: true });
|
|
25
|
+
}
|
|
26
|
+
tempDirs.length = 0;
|
|
27
|
+
});
|
|
28
|
+
describe("migrate new", () => {
|
|
29
|
+
test("creates a migration file with timestamp and template", async () => {
|
|
30
|
+
const cwd = await makeTempFixture("basic");
|
|
31
|
+
const result = await cli(["migrate", "new", "AddPhone"], { cwd });
|
|
32
|
+
expect(result.exitCode).toBe(0);
|
|
33
|
+
const nextDir = path.join(cwd, "next-migration");
|
|
34
|
+
const files = readdirSync(nextDir).filter((f) => f.endsWith(".mo"));
|
|
35
|
+
expect(files).toHaveLength(1);
|
|
36
|
+
expect(files[0]).toMatch(/^\d{8}_\d{6}_AddPhone\.mo$/);
|
|
37
|
+
const content = readFileSync(path.join(nextDir, files[0]), "utf-8");
|
|
38
|
+
expect({
|
|
39
|
+
exitCode: result.exitCode,
|
|
40
|
+
stdout: normalizeTimestamp(normalizePaths(result.stdout)),
|
|
41
|
+
stderr: normalizePaths(result.stderr),
|
|
42
|
+
template: content,
|
|
43
|
+
}).toMatchSnapshot();
|
|
44
|
+
});
|
|
45
|
+
test("errors when next already has a file", async () => {
|
|
46
|
+
const cwd = await makeTempFixture("basic");
|
|
47
|
+
await cli(["migrate", "new", "First"], { cwd });
|
|
48
|
+
const result = await cli(["migrate", "new", "Second"], { cwd });
|
|
49
|
+
expect(result.exitCode).toBe(1);
|
|
50
|
+
expect(result.stderr).toMatch(/next migration already exists/i);
|
|
51
|
+
});
|
|
52
|
+
test("errors on invalid migration name", async () => {
|
|
53
|
+
const cwd = await makeTempFixture("basic");
|
|
54
|
+
for (const name of ["../evil", "has space", "123start", "foo/bar"]) {
|
|
55
|
+
const result = await cli(["migrate", "new", name], { cwd });
|
|
56
|
+
expect(result.exitCode).toBe(1);
|
|
57
|
+
expect(result.stderr).toMatch(/invalid migration name/i);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
test("errors when [migrations] not configured", async () => {
|
|
61
|
+
const cwd = await makeTempFixture("basic");
|
|
62
|
+
const tomlPath = path.join(cwd, "mops.toml");
|
|
63
|
+
const toml = readFileSync(tomlPath, "utf-8");
|
|
64
|
+
await writeFile(tomlPath, toml.replace(/\[canisters\.backend\.migrations\][\s\S]*$/, ""));
|
|
65
|
+
const result = await cli(["migrate", "new", "Test"], { cwd });
|
|
66
|
+
expect(result.exitCode).toBe(1);
|
|
67
|
+
expect(result.stderr).toMatch(/\[migrations\]/i);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
describe("migrate freeze", () => {
|
|
71
|
+
test("moves the file from next to chain", async () => {
|
|
72
|
+
const cwd = await makeTempFixture("with-next");
|
|
73
|
+
const result = await cli(["migrate", "freeze"], { cwd });
|
|
74
|
+
expect(result.exitCode).toBe(0);
|
|
75
|
+
const nextFiles = readdirSync(path.join(cwd, "next-migration")).filter((f) => f.endsWith(".mo"));
|
|
76
|
+
expect(nextFiles).toHaveLength(0);
|
|
77
|
+
const chainFiles = readdirSync(path.join(cwd, "migrations")).filter((f) => f.endsWith(".mo"));
|
|
78
|
+
expect(chainFiles).toContain("20250401_000000_RenameId.mo");
|
|
79
|
+
expect({
|
|
80
|
+
exitCode: result.exitCode,
|
|
81
|
+
stdout: normalizePaths(result.stdout),
|
|
82
|
+
stderr: normalizePaths(result.stderr),
|
|
83
|
+
}).toMatchSnapshot();
|
|
84
|
+
});
|
|
85
|
+
test("errors when next is empty", async () => {
|
|
86
|
+
const cwd = await makeTempFixture("basic");
|
|
87
|
+
const result = await cli(["migrate", "freeze"], { cwd });
|
|
88
|
+
expect(result.exitCode).toBe(1);
|
|
89
|
+
expect(result.stderr).toMatch(/no next migration/i);
|
|
90
|
+
});
|
|
91
|
+
test("errors when next-migration does not sort last", async () => {
|
|
92
|
+
const cwd = await makeTempFixture("basic");
|
|
93
|
+
await writeFile(path.join(cwd, "next-migration", "00000000_000000_Early.mo"), "module {\n public func migration(_ : {}) : {} {\n {}\n }\n}\n");
|
|
94
|
+
const result = await cli(["migrate", "freeze"], { cwd });
|
|
95
|
+
expect(result.exitCode).toBe(1);
|
|
96
|
+
expect(result.stderr).toMatch(/must sort after/i);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
describe("check", () => {
|
|
100
|
+
test("check fails without next migration, passes with it", async () => {
|
|
101
|
+
const cwd = await makeTempFixture("with-next");
|
|
102
|
+
const nextFile = readdirSync(path.join(cwd, "next-migration")).find((f) => f.endsWith(".mo"));
|
|
103
|
+
const nextPath = path.join(cwd, "next-migration", nextFile);
|
|
104
|
+
const nextContent = readFileSync(nextPath, "utf-8");
|
|
105
|
+
await rm(nextPath);
|
|
106
|
+
await cliSnapshot(["check"], { cwd }, 1);
|
|
107
|
+
await writeFile(nextPath, nextContent);
|
|
108
|
+
await cliSnapshot(["check"], { cwd }, 0);
|
|
109
|
+
});
|
|
110
|
+
test("check with trimming shows reduced chain", async () => {
|
|
111
|
+
const cwd = await makeTempFixture("basic");
|
|
112
|
+
await patchMigrations(cwd, "check-limit = 2");
|
|
113
|
+
await cliSnapshot(["check", "--verbose"], { cwd }, 0);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
describe("build", () => {
|
|
117
|
+
test("build produces .most with full migration chain", async () => {
|
|
118
|
+
const cwd = await makeTempFixture("basic");
|
|
119
|
+
const result = await cli(["build"], { cwd });
|
|
120
|
+
expect(result.exitCode).toBe(0);
|
|
121
|
+
const most = readFileSync(path.join(cwd, ".mops", ".build", "backend.most"), "utf-8");
|
|
122
|
+
expect(most).toMatchSnapshot();
|
|
123
|
+
});
|
|
124
|
+
test("build with build-limit produces trimmed .most", async () => {
|
|
125
|
+
const cwd = await makeTempFixture("basic");
|
|
126
|
+
await patchMigrations(cwd, "build-limit = 2");
|
|
127
|
+
const result = await cli(["build"], { cwd });
|
|
128
|
+
expect(result.exitCode).toBe(0);
|
|
129
|
+
const most = readFileSync(path.join(cwd, ".mops", ".build", "backend.most"), "utf-8");
|
|
130
|
+
expect(most).toMatchSnapshot();
|
|
131
|
+
});
|
|
132
|
+
test("build-limit counts next migration as part of the chain", async () => {
|
|
133
|
+
const cwd = await makeTempFixture("with-next");
|
|
134
|
+
await patchMigrations(cwd, "build-limit = 2");
|
|
135
|
+
const result = await cli(["build"], { cwd });
|
|
136
|
+
expect(result.exitCode).toBe(0);
|
|
137
|
+
const most = readFileSync(path.join(cwd, ".mops", ".build", "backend.most"), "utf-8");
|
|
138
|
+
expect(most).toMatchSnapshot();
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
describe("stable check hint", () => {
|
|
142
|
+
test("stable check fails with hint when deployed.most is incompatible", async () => {
|
|
143
|
+
const cwd = await makeTempFixture("basic");
|
|
144
|
+
await writeFile(path.join(cwd, "deployed.most"), "// Version: 1.0.0\nactor {\n stable var a : Nat;\n stable var name : Int\n};\n");
|
|
145
|
+
await cliSnapshot(["check"], { cwd }, 1);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
describe("sibling validation", () => {
|
|
149
|
+
async function patchNextDir(cwd, nextValue) {
|
|
150
|
+
const tomlPath = path.join(cwd, "mops.toml");
|
|
151
|
+
const toml = readFileSync(tomlPath, "utf-8");
|
|
152
|
+
await writeFile(tomlPath, toml.replace('next = "next-migration"', `next = "${nextValue}"`));
|
|
153
|
+
}
|
|
154
|
+
test("errors from `mops check` when chain and next have different parents", async () => {
|
|
155
|
+
const cwd = await makeTempFixture("with-next");
|
|
156
|
+
await patchNextDir(cwd, "other/next-migration");
|
|
157
|
+
const result = await cli(["check"], { cwd });
|
|
158
|
+
expect(result.exitCode).toBe(1);
|
|
159
|
+
expect(result.stderr).toMatch(/same parent directory/i);
|
|
160
|
+
});
|
|
161
|
+
test("errors from `mops migrate new` too — validation runs in every entry point", async () => {
|
|
162
|
+
const cwd = await makeTempFixture("basic");
|
|
163
|
+
await patchNextDir(cwd, "other/next-migration");
|
|
164
|
+
const result = await cli(["migrate", "new", "Test"], { cwd });
|
|
165
|
+
expect(result.exitCode).toBe(1);
|
|
166
|
+
expect(result.stderr).toMatch(/same parent directory/i);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
describe("conflict detection", () => {
|
|
170
|
+
test("errors when both [migrations] and --enhanced-migration in args", async () => {
|
|
171
|
+
const cwd = await makeTempFixture("basic");
|
|
172
|
+
const tomlPath = path.join(cwd, "mops.toml");
|
|
173
|
+
const toml = readFileSync(tomlPath, "utf-8");
|
|
174
|
+
await writeFile(tomlPath, toml.replace("[canisters.backend.migrations]", 'args = ["--enhanced-migration=migrations"]\n\n[canisters.backend.migrations]'));
|
|
175
|
+
const result = await cli(["check"], { cwd });
|
|
176
|
+
expect(result.exitCode).toBe(1);
|
|
177
|
+
expect(result.stderr).toMatch(/--enhanced-migration/);
|
|
178
|
+
expect(result.stderr).toMatch(/managed automatically/i);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
});
|
package/dist/types.d.ts
CHANGED
|
@@ -34,6 +34,12 @@ export type Config = {
|
|
|
34
34
|
extra?: Record<string, string[]>;
|
|
35
35
|
};
|
|
36
36
|
};
|
|
37
|
+
export type MigrationsConfig = {
|
|
38
|
+
chain: string;
|
|
39
|
+
next?: string;
|
|
40
|
+
"check-limit"?: number;
|
|
41
|
+
"build-limit"?: number;
|
|
42
|
+
};
|
|
37
43
|
export type CanisterConfig = {
|
|
38
44
|
main?: string;
|
|
39
45
|
args?: string[];
|
|
@@ -43,6 +49,7 @@ export type CanisterConfig = {
|
|
|
43
49
|
path: string;
|
|
44
50
|
skipIfMissing?: boolean;
|
|
45
51
|
};
|
|
52
|
+
migrations?: MigrationsConfig;
|
|
46
53
|
};
|
|
47
54
|
export type Dependencies = Record<string, Dependency>;
|
|
48
55
|
export type Dependency = {
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readdirSync,
|
|
5
|
+
symlinkSync,
|
|
6
|
+
writeFileSync,
|
|
7
|
+
} from "node:fs";
|
|
8
|
+
import { dirname, join, resolve } from "node:path";
|
|
9
|
+
import { rm } from "node:fs/promises";
|
|
10
|
+
import chalk from "chalk";
|
|
11
|
+
import { cliError } from "../error.js";
|
|
12
|
+
import { getRootDir, resolveConfigPath } from "../mops.js";
|
|
13
|
+
import { MigrationsConfig } from "../types.js";
|
|
14
|
+
|
|
15
|
+
function stagedMigrationsDir(chainDir: string, canisterName: string): string {
|
|
16
|
+
return join(dirname(chainDir), `.migrations-${canisterName}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface MigrationArgsResult {
|
|
20
|
+
migrationArgs: string[];
|
|
21
|
+
cleanup: () => Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function getMigrationFiles(dir: string): string[] {
|
|
25
|
+
if (!existsSync(dir)) {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
return readdirSync(dir)
|
|
29
|
+
.filter((f) => f.endsWith(".mo"))
|
|
30
|
+
.sort();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function getNextMigrationFile(nextDir: string): string | null {
|
|
34
|
+
if (!existsSync(nextDir)) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
const files = readdirSync(nextDir).filter((f) => f.endsWith(".mo"));
|
|
38
|
+
if (files.length > 1) {
|
|
39
|
+
cliError(
|
|
40
|
+
`next-migration directory must contain at most 1 .mo file, found ${files.length} in ${nextDir}`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
return files[0] ?? null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function validateNextMigrationOrder(
|
|
47
|
+
chainDirOrFiles: string | string[],
|
|
48
|
+
nextFile: string,
|
|
49
|
+
): void {
|
|
50
|
+
const chainFiles =
|
|
51
|
+
typeof chainDirOrFiles === "string"
|
|
52
|
+
? getMigrationFiles(chainDirOrFiles)
|
|
53
|
+
: chainDirOrFiles;
|
|
54
|
+
const lastChainFile = chainFiles[chainFiles.length - 1];
|
|
55
|
+
if (lastChainFile && nextFile <= lastChainFile) {
|
|
56
|
+
cliError(
|
|
57
|
+
`Next migration "${nextFile}" must sort after all files in the chain.\n` +
|
|
58
|
+
`Last chain file: "${lastChainFile}".\n` +
|
|
59
|
+
"Use a timestamp prefix (e.g. YYYYMMDD_HHMMSS_Name.mo) to ensure correct ordering.",
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function validateMigrationsConfig(
|
|
65
|
+
migrations: MigrationsConfig,
|
|
66
|
+
canisterName: string,
|
|
67
|
+
): void {
|
|
68
|
+
if (!migrations.chain) {
|
|
69
|
+
cliError(
|
|
70
|
+
`[canisters.${canisterName}.migrations] is missing required field "chain"`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
for (const field of ["check-limit", "build-limit"] as const) {
|
|
74
|
+
const value = migrations[field];
|
|
75
|
+
if (value !== undefined && (!Number.isInteger(value) || value <= 0)) {
|
|
76
|
+
cliError(
|
|
77
|
+
`[canisters.${canisterName}.migrations] ${field} must be a positive integer`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
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
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function prepareMigrationArgs(
|
|
99
|
+
migrations: MigrationsConfig | undefined,
|
|
100
|
+
canisterName: string,
|
|
101
|
+
mode: "check" | "build",
|
|
102
|
+
verbose?: boolean,
|
|
103
|
+
): Promise<MigrationArgsResult> {
|
|
104
|
+
const noOp: MigrationArgsResult = {
|
|
105
|
+
migrationArgs: [],
|
|
106
|
+
cleanup: async () => {},
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
if (!migrations) {
|
|
110
|
+
return noOp;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
validateMigrationsConfig(migrations, canisterName);
|
|
114
|
+
|
|
115
|
+
const chainDir = resolveConfigPath(migrations.chain);
|
|
116
|
+
const nextDir = migrations.next
|
|
117
|
+
? resolveConfigPath(migrations.next)
|
|
118
|
+
: undefined;
|
|
119
|
+
const nextFile = nextDir ? getNextMigrationFile(nextDir) : null;
|
|
120
|
+
|
|
121
|
+
if (!existsSync(chainDir) && !nextFile) {
|
|
122
|
+
cliError(
|
|
123
|
+
`Migration chain directory not found: ${chainDir}\n` +
|
|
124
|
+
"Run `mops migrate new <Name>` to initialize the migration chain.",
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const chainFiles = getMigrationFiles(chainDir);
|
|
129
|
+
|
|
130
|
+
if (nextFile) {
|
|
131
|
+
validateNextMigrationOrder(chainFiles, nextFile);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Treat chain + next as one virtual merged list
|
|
135
|
+
type MigrationEntry = { file: string; dir: string };
|
|
136
|
+
const allMigrations: MigrationEntry[] = chainFiles.map((f) => ({
|
|
137
|
+
file: f,
|
|
138
|
+
dir: chainDir,
|
|
139
|
+
}));
|
|
140
|
+
if (nextFile && nextDir) {
|
|
141
|
+
allMigrations.push({ file: nextFile, dir: nextDir });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const limit =
|
|
145
|
+
mode === "check" ? migrations["check-limit"] : migrations["build-limit"];
|
|
146
|
+
const isTrimming = limit !== undefined && limit < allMigrations.length;
|
|
147
|
+
const needsTempDir = nextFile !== null || isTrimming;
|
|
148
|
+
|
|
149
|
+
if (!needsTempDir) {
|
|
150
|
+
return {
|
|
151
|
+
migrationArgs: [`--enhanced-migration=${chainDir}`],
|
|
152
|
+
cleanup: async () => {},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const tempDir = stagedMigrationsDir(chainDir, canisterName);
|
|
157
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
158
|
+
mkdirSync(tempDir, { recursive: true });
|
|
159
|
+
writeFileSync(join(tempDir, ".gitignore"), "*\n");
|
|
160
|
+
|
|
161
|
+
const filesToInclude = isTrimming
|
|
162
|
+
? allMigrations.slice(-limit)
|
|
163
|
+
: allMigrations;
|
|
164
|
+
|
|
165
|
+
for (const { file, dir } of filesToInclude) {
|
|
166
|
+
symlinkSync(resolve(dir, file), join(tempDir, file));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (verbose) {
|
|
170
|
+
console.log(
|
|
171
|
+
chalk.blue("migrations"),
|
|
172
|
+
chalk.gray(
|
|
173
|
+
`Prepared ${filesToInclude.length} migration(s) for ${canisterName}` +
|
|
174
|
+
(isTrimming ? ` (trimmed from ${allMigrations.length})` : ""),
|
|
175
|
+
),
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const migrationArgs = [`--enhanced-migration=${tempDir}`];
|
|
180
|
+
if (isTrimming) {
|
|
181
|
+
migrationArgs.push("-A=M0254");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
migrationArgs,
|
|
186
|
+
cleanup: async () => {
|
|
187
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|
|
@@ -74,10 +74,27 @@ export function looksLikeFile(arg: string): boolean {
|
|
|
74
74
|
export function validateCanisterArgs(
|
|
75
75
|
canister: CanisterConfig,
|
|
76
76
|
canisterName: string,
|
|
77
|
+
config?: Config,
|
|
77
78
|
): void {
|
|
78
79
|
if (canister.args && typeof canister.args === "string") {
|
|
79
80
|
cliError(
|
|
80
81
|
`Canister config 'args' should be an array of strings for canister ${canisterName}`,
|
|
81
82
|
);
|
|
82
83
|
}
|
|
84
|
+
if (!canister.migrations) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const flagSources: [string, string[] | undefined][] = [
|
|
88
|
+
[`[canisters.${canisterName}].args`, canister.args],
|
|
89
|
+
["[moc].args", config?.moc?.args],
|
|
90
|
+
["[build].args", config?.build?.args],
|
|
91
|
+
];
|
|
92
|
+
for (const [section, args] of flagSources) {
|
|
93
|
+
if (args?.some((a) => a.startsWith("--enhanced-migration"))) {
|
|
94
|
+
cliError(
|
|
95
|
+
`Canister '${canisterName}' has [migrations] config but --enhanced-migration in ${section}.\n` +
|
|
96
|
+
"Remove --enhanced-migration — it is managed automatically when [migrations] is configured.",
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
83
100
|
}
|