@crewhaus/migration-runner 0.1.4 → 0.1.5
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/dist/index.d.ts +50 -0
- package/dist/index.js +106 -0
- package/package.json +11 -8
- package/src/index.coverage.test.ts +0 -204
- package/src/index.test.ts +0 -118
- package/src/index.ts +0 -142
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Section 28 — `migration-runner`. Walks every spec in a `spec-registry`,
|
|
3
|
+
* applies the registered migration chain, and writes new versions while
|
|
4
|
+
* leaving old versions intact for rollback. Dry-run mode shows the diff
|
|
5
|
+
* per spec before any write happens.
|
|
6
|
+
*
|
|
7
|
+
* Re-running with the same `(fromVersion, toVersion)` is a no-op once the
|
|
8
|
+
* target version exists in the registry — the runner skips specs whose
|
|
9
|
+
* latest version is already at or beyond `toVersion`.
|
|
10
|
+
*/
|
|
11
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
12
|
+
import type { MigrationEngine, SpecObject } from "@crewhaus/migration-engine";
|
|
13
|
+
import type { RegistryAdapter } from "@crewhaus/spec-registry";
|
|
14
|
+
export declare class MigrationRunnerError extends CrewhausError {
|
|
15
|
+
readonly name = "MigrationRunnerError";
|
|
16
|
+
constructor(message: string, cause?: unknown);
|
|
17
|
+
}
|
|
18
|
+
export type MigrationPlanItem = {
|
|
19
|
+
readonly name: string;
|
|
20
|
+
readonly latestVersion: string;
|
|
21
|
+
readonly action: "skip" | "migrate" | "validate-fail";
|
|
22
|
+
readonly newVersion?: string;
|
|
23
|
+
readonly diff?: {
|
|
24
|
+
fromVersion: number;
|
|
25
|
+
toVersion: number;
|
|
26
|
+
};
|
|
27
|
+
readonly error?: string;
|
|
28
|
+
};
|
|
29
|
+
export type MigrateAllOptions = {
|
|
30
|
+
readonly registry: RegistryAdapter;
|
|
31
|
+
readonly engine: MigrationEngine;
|
|
32
|
+
readonly fromVersion: number;
|
|
33
|
+
readonly toVersion: number;
|
|
34
|
+
/** When true, no writes happen — only the plan is returned. */
|
|
35
|
+
readonly dryRun?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Optional post-migration validator. If provided, the runner refuses to
|
|
38
|
+
* write a spec that fails validation; the plan item carries the error.
|
|
39
|
+
*/
|
|
40
|
+
readonly validate?: (spec: SpecObject, name: string) => void;
|
|
41
|
+
/** Custom new-version naming. Default: append `-vN`. */
|
|
42
|
+
readonly newVersionName?: (latest: string, toVersion: number) => string;
|
|
43
|
+
};
|
|
44
|
+
export type MigrateAllResult = {
|
|
45
|
+
readonly plan: ReadonlyArray<MigrationPlanItem>;
|
|
46
|
+
readonly migrated: number;
|
|
47
|
+
readonly skipped: number;
|
|
48
|
+
readonly failed: number;
|
|
49
|
+
};
|
|
50
|
+
export declare function migrateAll(opts: MigrateAllOptions): Promise<MigrateAllResult>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Section 28 — `migration-runner`. Walks every spec in a `spec-registry`,
|
|
3
|
+
* applies the registered migration chain, and writes new versions while
|
|
4
|
+
* leaving old versions intact for rollback. Dry-run mode shows the diff
|
|
5
|
+
* per spec before any write happens.
|
|
6
|
+
*
|
|
7
|
+
* Re-running with the same `(fromVersion, toVersion)` is a no-op once the
|
|
8
|
+
* target version exists in the registry — the runner skips specs whose
|
|
9
|
+
* latest version is already at or beyond `toVersion`.
|
|
10
|
+
*/
|
|
11
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
12
|
+
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
13
|
+
export class MigrationRunnerError extends CrewhausError {
|
|
14
|
+
name = "MigrationRunnerError";
|
|
15
|
+
constructor(message, cause) {
|
|
16
|
+
super("config", message, cause);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const DEFAULT_NEW_VERSION = (latest, toVersion) => {
|
|
20
|
+
const m = latest.match(/^v(\d+)$/);
|
|
21
|
+
if (m)
|
|
22
|
+
return `v${(Number.parseInt(m[1] ?? "0", 10) || 0) + 1}`;
|
|
23
|
+
return `${latest}-v${toVersion}`;
|
|
24
|
+
};
|
|
25
|
+
export async function migrateAll(opts) {
|
|
26
|
+
const newVersionName = opts.newVersionName ?? DEFAULT_NEW_VERSION;
|
|
27
|
+
const specs = await opts.registry.listSpecs();
|
|
28
|
+
const plan = [];
|
|
29
|
+
for (const name of specs) {
|
|
30
|
+
const versions = [...(await opts.registry.list(name))].sort();
|
|
31
|
+
if (versions.length === 0)
|
|
32
|
+
continue;
|
|
33
|
+
const latest = versions[versions.length - 1];
|
|
34
|
+
if (!latest)
|
|
35
|
+
continue;
|
|
36
|
+
const yaml = await opts.registry.get(name, latest);
|
|
37
|
+
let parsed;
|
|
38
|
+
try {
|
|
39
|
+
parsed = parseYaml(yaml);
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
plan.push({
|
|
43
|
+
name,
|
|
44
|
+
latestVersion: latest,
|
|
45
|
+
action: "validate-fail",
|
|
46
|
+
error: `parse error: ${err.message}`,
|
|
47
|
+
});
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const currentVersion = (parsed.version ?? 0) | 0;
|
|
51
|
+
if (currentVersion >= opts.toVersion) {
|
|
52
|
+
plan.push({ name, latestVersion: latest, action: "skip" });
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
let migrated;
|
|
56
|
+
try {
|
|
57
|
+
migrated = opts.engine.migrate(parsed, opts.toVersion);
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
plan.push({
|
|
61
|
+
name,
|
|
62
|
+
latestVersion: latest,
|
|
63
|
+
action: "validate-fail",
|
|
64
|
+
error: `migration error: ${err.message}`,
|
|
65
|
+
});
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (opts.validate) {
|
|
69
|
+
try {
|
|
70
|
+
opts.validate(migrated, name);
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
plan.push({
|
|
74
|
+
name,
|
|
75
|
+
latestVersion: latest,
|
|
76
|
+
action: "validate-fail",
|
|
77
|
+
error: `validation: ${err.message}`,
|
|
78
|
+
});
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const newVersion = newVersionName(latest, opts.toVersion);
|
|
83
|
+
plan.push({
|
|
84
|
+
name,
|
|
85
|
+
latestVersion: latest,
|
|
86
|
+
action: "migrate",
|
|
87
|
+
newVersion,
|
|
88
|
+
diff: { fromVersion: currentVersion, toVersion: opts.toVersion },
|
|
89
|
+
});
|
|
90
|
+
if (!opts.dryRun) {
|
|
91
|
+
const migratedYaml = stringifyYaml(migrated);
|
|
92
|
+
await opts.registry.put(name, newVersion, migratedYaml);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// Refuse to apply if any spec failed validation in non-dry-run mode.
|
|
96
|
+
const failed = plan.filter((p) => p.action === "validate-fail").length;
|
|
97
|
+
if (failed > 0 && !opts.dryRun) {
|
|
98
|
+
throw new MigrationRunnerError(`${failed} spec(s) failed migration; run with dryRun: true to inspect`);
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
plan,
|
|
102
|
+
migrated: plan.filter((p) => p.action === "migrate").length,
|
|
103
|
+
skipped: plan.filter((p) => p.action === "skip").length,
|
|
104
|
+
failed,
|
|
105
|
+
};
|
|
106
|
+
}
|
package/package.json
CHANGED
|
@@ -1,20 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crewhaus/migration-runner",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Batch-migrate every spec in a registry: dry-run + write, idempotent re-runs",
|
|
6
|
-
"main": "
|
|
7
|
-
"types": "
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
8
|
"exports": {
|
|
9
|
-
".":
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
10
13
|
},
|
|
11
14
|
"scripts": {
|
|
12
15
|
"test": "bun test src"
|
|
13
16
|
},
|
|
14
17
|
"dependencies": {
|
|
15
|
-
"@crewhaus/errors": "0.1.
|
|
16
|
-
"@crewhaus/migration-engine": "0.1.
|
|
17
|
-
"@crewhaus/spec-registry": "0.1.
|
|
18
|
+
"@crewhaus/errors": "0.1.5",
|
|
19
|
+
"@crewhaus/migration-engine": "0.1.5",
|
|
20
|
+
"@crewhaus/spec-registry": "0.1.5",
|
|
18
21
|
"yaml": "^2.6.0"
|
|
19
22
|
},
|
|
20
23
|
"license": "Apache-2.0",
|
|
@@ -35,5 +38,5 @@
|
|
|
35
38
|
"publishConfig": {
|
|
36
39
|
"access": "public"
|
|
37
40
|
},
|
|
38
|
-
"files": ["
|
|
41
|
+
"files": ["dist", "README.md", "LICENSE", "NOTICE"]
|
|
39
42
|
}
|
|
@@ -1,204 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Supplementary coverage for `migration-runner` exercising the branches the
|
|
3
|
-
* happy-path file-backed suite (`index.test.ts`) does not reach:
|
|
4
|
-
* - the default `-vN` naming fallback for a non-`vN` latest version,
|
|
5
|
-
* - a custom `newVersionName`,
|
|
6
|
-
* - the YAML parse-error plan item,
|
|
7
|
-
* - the engine-migration-error plan item,
|
|
8
|
-
* - the non-dry-run refusal that throws `MigrationRunnerError`,
|
|
9
|
-
* - specs with no versions / empty latest being skipped.
|
|
10
|
-
*
|
|
11
|
-
* Everything runs against a pure in-memory `RegistryAdapter` — no filesystem,
|
|
12
|
-
* no network, no timers, no real clock — so it is fully deterministic and
|
|
13
|
-
* leaks no handles.
|
|
14
|
-
*/
|
|
15
|
-
import { describe, expect, test } from "bun:test";
|
|
16
|
-
import { createDefaultEngine } from "@crewhaus/migration-engine";
|
|
17
|
-
import type { MigrationEngine, SpecObject } from "@crewhaus/migration-engine";
|
|
18
|
-
import type { RegistryAdapter } from "@crewhaus/spec-registry";
|
|
19
|
-
import { MigrationRunnerError, migrateAll } from "./index";
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Minimal in-memory registry. `data` maps spec name -> (version -> yaml).
|
|
23
|
-
* Versions returned by `list` preserve insertion order; `migrateAll` sorts
|
|
24
|
-
* them itself.
|
|
25
|
-
*/
|
|
26
|
-
function memRegistry(initial: Record<string, Record<string, string>> = {}): RegistryAdapter & {
|
|
27
|
-
data: Map<string, Map<string, string>>;
|
|
28
|
-
} {
|
|
29
|
-
const data = new Map<string, Map<string, string>>();
|
|
30
|
-
for (const [name, versions] of Object.entries(initial)) {
|
|
31
|
-
data.set(name, new Map(Object.entries(versions)));
|
|
32
|
-
}
|
|
33
|
-
// `migrateAll` only ever touches listSpecs/list/get/put; the remaining
|
|
34
|
-
// RegistryAdapter members are intentionally absent (never called here).
|
|
35
|
-
return {
|
|
36
|
-
data,
|
|
37
|
-
async listSpecs() {
|
|
38
|
-
return [...data.keys()];
|
|
39
|
-
},
|
|
40
|
-
async list(name: string) {
|
|
41
|
-
return [...(data.get(name)?.keys() ?? [])];
|
|
42
|
-
},
|
|
43
|
-
async get(name: string, version: string) {
|
|
44
|
-
const v = data.get(name)?.get(version);
|
|
45
|
-
if (v === undefined) throw new Error(`ENOENT ${name}@${version}`);
|
|
46
|
-
return v;
|
|
47
|
-
},
|
|
48
|
-
async put(name: string, version: string, yaml: string) {
|
|
49
|
-
const m = data.get(name) ?? new Map<string, string>();
|
|
50
|
-
m.set(version, yaml);
|
|
51
|
-
data.set(name, m);
|
|
52
|
-
},
|
|
53
|
-
} as unknown as RegistryAdapter & { data: Map<string, Map<string, string>> };
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
describe("migration-runner — default new-version naming fallback", () => {
|
|
57
|
-
test("non-vN latest version names the new version `<latest>-v<toVersion>`", async () => {
|
|
58
|
-
// latest is "release-2024" which does NOT match /^v(\d+)$/, so the
|
|
59
|
-
// DEFAULT_NEW_VERSION fallback branch runs.
|
|
60
|
-
const reg = memRegistry({
|
|
61
|
-
a: { "release-2024": "name: a\ntarget: cli\nversion: 0\n" },
|
|
62
|
-
});
|
|
63
|
-
const engine = createDefaultEngine();
|
|
64
|
-
const result = await migrateAll({ registry: reg, engine, fromVersion: 0, toVersion: 1 });
|
|
65
|
-
expect(result.migrated).toBe(1);
|
|
66
|
-
expect(result.plan[0]?.newVersion).toBe("release-2024-v1");
|
|
67
|
-
// The write landed under the fallback name, preserving the original.
|
|
68
|
-
expect([...(reg.data.get("a")?.keys() ?? [])].sort()).toEqual([
|
|
69
|
-
"release-2024",
|
|
70
|
-
"release-2024-v1",
|
|
71
|
-
]);
|
|
72
|
-
});
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
describe("migration-runner — custom newVersionName", () => {
|
|
76
|
-
test("a caller-supplied namer overrides the default", async () => {
|
|
77
|
-
const reg = memRegistry({
|
|
78
|
-
a: { v1: "name: a\ntarget: cli\nversion: 0\n" },
|
|
79
|
-
});
|
|
80
|
-
const engine = createDefaultEngine();
|
|
81
|
-
const seen: Array<{ latest: string; to: number }> = [];
|
|
82
|
-
const result = await migrateAll({
|
|
83
|
-
registry: reg,
|
|
84
|
-
engine,
|
|
85
|
-
fromVersion: 0,
|
|
86
|
-
toVersion: 1,
|
|
87
|
-
newVersionName: (latest, to) => {
|
|
88
|
-
seen.push({ latest, to });
|
|
89
|
-
return `${latest}__migrated`;
|
|
90
|
-
},
|
|
91
|
-
});
|
|
92
|
-
expect(result.migrated).toBe(1);
|
|
93
|
-
expect(result.plan[0]?.newVersion).toBe("v1__migrated");
|
|
94
|
-
expect(seen).toEqual([{ latest: "v1", to: 1 }]);
|
|
95
|
-
});
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
describe("migration-runner — parse-error plan item", () => {
|
|
99
|
-
test("a spec whose latest YAML does not parse becomes a validate-fail (dry-run)", async () => {
|
|
100
|
-
const reg = memRegistry({
|
|
101
|
-
broken: { v1: "{ this is not valid yaml" },
|
|
102
|
-
});
|
|
103
|
-
const engine = createDefaultEngine();
|
|
104
|
-
const result = await migrateAll({
|
|
105
|
-
registry: reg,
|
|
106
|
-
engine,
|
|
107
|
-
fromVersion: 0,
|
|
108
|
-
toVersion: 1,
|
|
109
|
-
dryRun: true,
|
|
110
|
-
});
|
|
111
|
-
expect(result.failed).toBe(1);
|
|
112
|
-
expect(result.plan[0]?.action).toBe("validate-fail");
|
|
113
|
-
expect(result.plan[0]?.error).toContain("parse error:");
|
|
114
|
-
});
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
describe("migration-runner — engine migration error plan item", () => {
|
|
118
|
-
test("an unregistered migration step becomes a validate-fail (dry-run)", async () => {
|
|
119
|
-
const reg = memRegistry({
|
|
120
|
-
a: { v1: "name: a\ntarget: cli\nversion: 0\n" },
|
|
121
|
-
});
|
|
122
|
-
// Engine with NO steps registered -> migrate(0 -> 2) throws inside migrateAll.
|
|
123
|
-
const emptyEngine = createDefaultEngine();
|
|
124
|
-
emptyEngine.clear();
|
|
125
|
-
const result = await migrateAll({
|
|
126
|
-
registry: reg,
|
|
127
|
-
engine: emptyEngine,
|
|
128
|
-
fromVersion: 0,
|
|
129
|
-
toVersion: 2,
|
|
130
|
-
dryRun: true,
|
|
131
|
-
});
|
|
132
|
-
expect(result.failed).toBe(1);
|
|
133
|
-
expect(result.plan[0]?.action).toBe("validate-fail");
|
|
134
|
-
expect(result.plan[0]?.error).toContain("migration error:");
|
|
135
|
-
});
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
describe("migration-runner — non-dry-run refusal", () => {
|
|
139
|
-
test("write mode throws MigrationRunnerError when any spec fails", async () => {
|
|
140
|
-
const reg = memRegistry({
|
|
141
|
-
good: { v1: "name: good\ntarget: cli\nversion: 0\n" },
|
|
142
|
-
bad: { v1: "{ broken yaml" },
|
|
143
|
-
});
|
|
144
|
-
const engine = createDefaultEngine();
|
|
145
|
-
await expect(
|
|
146
|
-
migrateAll({ registry: reg, engine, fromVersion: 0, toVersion: 1 }),
|
|
147
|
-
).rejects.toBeInstanceOf(MigrationRunnerError);
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
test("the thrown error names how many specs failed", async () => {
|
|
151
|
-
const reg = memRegistry({
|
|
152
|
-
bad: { v1: "{ broken yaml" },
|
|
153
|
-
});
|
|
154
|
-
const engine = createDefaultEngine();
|
|
155
|
-
let caught: Error | undefined;
|
|
156
|
-
try {
|
|
157
|
-
await migrateAll({ registry: reg, engine, fromVersion: 0, toVersion: 1 });
|
|
158
|
-
} catch (err) {
|
|
159
|
-
caught = err as Error;
|
|
160
|
-
}
|
|
161
|
-
expect(caught).toBeInstanceOf(MigrationRunnerError);
|
|
162
|
-
expect(caught?.message).toContain("1 spec(s) failed migration");
|
|
163
|
-
});
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
describe("migration-runner — skip already-migrated and empty specs", () => {
|
|
167
|
-
test("a spec already at/above the target version is skipped", async () => {
|
|
168
|
-
const reg = memRegistry({
|
|
169
|
-
a: { v1: "name: a\ntarget: cli\nversion: 5\n" },
|
|
170
|
-
});
|
|
171
|
-
const engine = createDefaultEngine();
|
|
172
|
-
const result = await migrateAll({ registry: reg, engine, fromVersion: 0, toVersion: 1 });
|
|
173
|
-
expect(result.skipped).toBe(1);
|
|
174
|
-
expect(result.migrated).toBe(0);
|
|
175
|
-
expect(result.plan[0]?.action).toBe("skip");
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
test("a spec with no versions at all is skipped silently (no plan item)", async () => {
|
|
179
|
-
const reg = memRegistry();
|
|
180
|
-
// Register the spec name but give it zero versions.
|
|
181
|
-
reg.data.set("empty", new Map());
|
|
182
|
-
const engine = createDefaultEngine();
|
|
183
|
-
const result = await migrateAll({ registry: reg, engine, fromVersion: 0, toVersion: 1 });
|
|
184
|
-
expect(result.plan.length).toBe(0);
|
|
185
|
-
expect(result.migrated).toBe(0);
|
|
186
|
-
expect(result.skipped).toBe(0);
|
|
187
|
-
expect(result.failed).toBe(0);
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
test("a spec whose sorted latest is an empty-string version is skipped", async () => {
|
|
191
|
-
// versions === [""] -> latest === "" which is falsy, so the `!latest`
|
|
192
|
-
// guard `continue`s before any get/parse.
|
|
193
|
-
const reg = memRegistry();
|
|
194
|
-
reg.data.set("blank", new Map([["", "name: blank\n"]]));
|
|
195
|
-
const engine = createDefaultEngine();
|
|
196
|
-
const result = await migrateAll({ registry: reg, engine, fromVersion: 0, toVersion: 1 });
|
|
197
|
-
expect(result.plan.length).toBe(0);
|
|
198
|
-
});
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
// Keep a no-op reference to the imported types so the file documents intent
|
|
202
|
-
// without unused-import noise.
|
|
203
|
-
const _typeRefs: { e?: MigrationEngine; s?: SpecObject } = {};
|
|
204
|
-
void _typeRefs;
|
package/src/index.test.ts
DELETED
|
@@ -1,118 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Section 28 — `migration-runner` tests:
|
|
3
|
-
* - T3 dry-run + write cycle on fixture registry
|
|
4
|
-
* - T9 idempotence (re-running yields zero changes)
|
|
5
|
-
*/
|
|
6
|
-
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
7
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
8
|
-
import { tmpdir } from "node:os";
|
|
9
|
-
import { join } from "node:path";
|
|
10
|
-
import { createDefaultEngine } from "@crewhaus/migration-engine";
|
|
11
|
-
import { createFileBackedRegistry } from "@crewhaus/spec-registry";
|
|
12
|
-
import { migrateAll } from "./index";
|
|
13
|
-
|
|
14
|
-
let tmpRoot = "";
|
|
15
|
-
|
|
16
|
-
beforeEach(() => {
|
|
17
|
-
tmpRoot = mkdtempSync(join(tmpdir(), "migration-runner-test-"));
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
afterEach(() => {
|
|
21
|
-
rmSync(tmpRoot, { recursive: true, force: true });
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
describe("migration-runner — T3 dry-run + write cycle", () => {
|
|
25
|
-
test("plan includes every spec at the source version", async () => {
|
|
26
|
-
const reg = createFileBackedRegistry({ rootDir: tmpRoot });
|
|
27
|
-
const engine = createDefaultEngine();
|
|
28
|
-
await reg.put(
|
|
29
|
-
"a",
|
|
30
|
-
"v1",
|
|
31
|
-
"name: a\ntarget: cli\nversion: 0\nagent:\n model: x\n instructions: y\n",
|
|
32
|
-
);
|
|
33
|
-
await reg.put(
|
|
34
|
-
"b",
|
|
35
|
-
"v1",
|
|
36
|
-
"name: b\ntarget: cli\nversion: 0\nagent:\n model: x\n instructions: y\n",
|
|
37
|
-
);
|
|
38
|
-
const result = await migrateAll({
|
|
39
|
-
registry: reg,
|
|
40
|
-
engine,
|
|
41
|
-
fromVersion: 0,
|
|
42
|
-
toVersion: 1,
|
|
43
|
-
dryRun: true,
|
|
44
|
-
});
|
|
45
|
-
expect(result.migrated).toBe(2);
|
|
46
|
-
expect(result.plan.every((p) => p.action === "migrate")).toBe(true);
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
test("dry-run does not write new versions", async () => {
|
|
50
|
-
const reg = createFileBackedRegistry({ rootDir: tmpRoot });
|
|
51
|
-
const engine = createDefaultEngine();
|
|
52
|
-
await reg.put(
|
|
53
|
-
"a",
|
|
54
|
-
"v1",
|
|
55
|
-
"name: a\ntarget: cli\nversion: 0\nagent:\n model: x\n instructions: y\n",
|
|
56
|
-
);
|
|
57
|
-
await migrateAll({ registry: reg, engine, fromVersion: 0, toVersion: 1, dryRun: true });
|
|
58
|
-
expect(await reg.list("a")).toEqual(["v1"]);
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
test("write mode adds a new version while preserving old", async () => {
|
|
62
|
-
const reg = createFileBackedRegistry({ rootDir: tmpRoot });
|
|
63
|
-
const engine = createDefaultEngine();
|
|
64
|
-
await reg.put(
|
|
65
|
-
"a",
|
|
66
|
-
"v1",
|
|
67
|
-
"name: a\ntarget: cli\nversion: 0\nagent:\n model: x\n instructions: y\n",
|
|
68
|
-
);
|
|
69
|
-
await migrateAll({ registry: reg, engine, fromVersion: 0, toVersion: 1 });
|
|
70
|
-
const versions = [...(await reg.list("a"))].sort();
|
|
71
|
-
expect(versions).toEqual(["v1", "v2"]);
|
|
72
|
-
const v2 = await reg.get("a", "v2");
|
|
73
|
-
expect(v2).toContain("version: 1");
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
test("validate hook can reject migrated spec", async () => {
|
|
77
|
-
const reg = createFileBackedRegistry({ rootDir: tmpRoot });
|
|
78
|
-
const engine = createDefaultEngine();
|
|
79
|
-
await reg.put(
|
|
80
|
-
"good",
|
|
81
|
-
"v1",
|
|
82
|
-
"name: good\ntarget: cli\nversion: 0\nagent:\n model: x\n instructions: y\n",
|
|
83
|
-
);
|
|
84
|
-
await reg.put(
|
|
85
|
-
"bad",
|
|
86
|
-
"v1",
|
|
87
|
-
"name: bad\ntarget: cli\nversion: 0\nagent:\n model: x\n instructions: y\n",
|
|
88
|
-
);
|
|
89
|
-
const result = await migrateAll({
|
|
90
|
-
registry: reg,
|
|
91
|
-
engine,
|
|
92
|
-
fromVersion: 0,
|
|
93
|
-
toVersion: 1,
|
|
94
|
-
dryRun: true,
|
|
95
|
-
validate: (_spec, name) => {
|
|
96
|
-
if (name === "bad") throw new Error("forbidden");
|
|
97
|
-
},
|
|
98
|
-
});
|
|
99
|
-
expect(result.failed).toBe(1);
|
|
100
|
-
expect(result.migrated).toBe(1);
|
|
101
|
-
});
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
describe("migration-runner — T9 idempotence", () => {
|
|
105
|
-
test("re-running with same target version skips already-migrated specs", async () => {
|
|
106
|
-
const reg = createFileBackedRegistry({ rootDir: tmpRoot });
|
|
107
|
-
const engine = createDefaultEngine();
|
|
108
|
-
await reg.put(
|
|
109
|
-
"a",
|
|
110
|
-
"v1",
|
|
111
|
-
"name: a\ntarget: cli\nversion: 0\nagent:\n model: x\n instructions: y\n",
|
|
112
|
-
);
|
|
113
|
-
await migrateAll({ registry: reg, engine, fromVersion: 0, toVersion: 1 });
|
|
114
|
-
const second = await migrateAll({ registry: reg, engine, fromVersion: 0, toVersion: 1 });
|
|
115
|
-
expect(second.migrated).toBe(0);
|
|
116
|
-
expect(second.skipped).toBe(1);
|
|
117
|
-
});
|
|
118
|
-
});
|
package/src/index.ts
DELETED
|
@@ -1,142 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Section 28 — `migration-runner`. Walks every spec in a `spec-registry`,
|
|
3
|
-
* applies the registered migration chain, and writes new versions while
|
|
4
|
-
* leaving old versions intact for rollback. Dry-run mode shows the diff
|
|
5
|
-
* per spec before any write happens.
|
|
6
|
-
*
|
|
7
|
-
* Re-running with the same `(fromVersion, toVersion)` is a no-op once the
|
|
8
|
-
* target version exists in the registry — the runner skips specs whose
|
|
9
|
-
* latest version is already at or beyond `toVersion`.
|
|
10
|
-
*/
|
|
11
|
-
import { CrewhausError } from "@crewhaus/errors";
|
|
12
|
-
import type { MigrationEngine, SpecObject } from "@crewhaus/migration-engine";
|
|
13
|
-
import type { RegistryAdapter } from "@crewhaus/spec-registry";
|
|
14
|
-
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
15
|
-
|
|
16
|
-
export class MigrationRunnerError extends CrewhausError {
|
|
17
|
-
override readonly name = "MigrationRunnerError";
|
|
18
|
-
constructor(message: string, cause?: unknown) {
|
|
19
|
-
super("config", message, cause);
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export type MigrationPlanItem = {
|
|
24
|
-
readonly name: string;
|
|
25
|
-
readonly latestVersion: string;
|
|
26
|
-
readonly action: "skip" | "migrate" | "validate-fail";
|
|
27
|
-
readonly newVersion?: string;
|
|
28
|
-
readonly diff?: { fromVersion: number; toVersion: number };
|
|
29
|
-
readonly error?: string;
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
export type MigrateAllOptions = {
|
|
33
|
-
readonly registry: RegistryAdapter;
|
|
34
|
-
readonly engine: MigrationEngine;
|
|
35
|
-
readonly fromVersion: number;
|
|
36
|
-
readonly toVersion: number;
|
|
37
|
-
/** When true, no writes happen — only the plan is returned. */
|
|
38
|
-
readonly dryRun?: boolean;
|
|
39
|
-
/**
|
|
40
|
-
* Optional post-migration validator. If provided, the runner refuses to
|
|
41
|
-
* write a spec that fails validation; the plan item carries the error.
|
|
42
|
-
*/
|
|
43
|
-
readonly validate?: (spec: SpecObject, name: string) => void;
|
|
44
|
-
/** Custom new-version naming. Default: append `-vN`. */
|
|
45
|
-
readonly newVersionName?: (latest: string, toVersion: number) => string;
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
export type MigrateAllResult = {
|
|
49
|
-
readonly plan: ReadonlyArray<MigrationPlanItem>;
|
|
50
|
-
readonly migrated: number;
|
|
51
|
-
readonly skipped: number;
|
|
52
|
-
readonly failed: number;
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
const DEFAULT_NEW_VERSION = (latest: string, toVersion: number): string => {
|
|
56
|
-
const m = latest.match(/^v(\d+)$/);
|
|
57
|
-
if (m) return `v${(Number.parseInt(m[1] ?? "0", 10) || 0) + 1}`;
|
|
58
|
-
return `${latest}-v${toVersion}`;
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
export async function migrateAll(opts: MigrateAllOptions): Promise<MigrateAllResult> {
|
|
62
|
-
const newVersionName = opts.newVersionName ?? DEFAULT_NEW_VERSION;
|
|
63
|
-
const specs = await opts.registry.listSpecs();
|
|
64
|
-
const plan: MigrationPlanItem[] = [];
|
|
65
|
-
|
|
66
|
-
for (const name of specs) {
|
|
67
|
-
const versions = [...(await opts.registry.list(name))].sort();
|
|
68
|
-
if (versions.length === 0) continue;
|
|
69
|
-
const latest = versions[versions.length - 1];
|
|
70
|
-
if (!latest) continue;
|
|
71
|
-
const yaml = await opts.registry.get(name, latest);
|
|
72
|
-
let parsed: SpecObject;
|
|
73
|
-
try {
|
|
74
|
-
parsed = parseYaml(yaml) as SpecObject;
|
|
75
|
-
} catch (err) {
|
|
76
|
-
plan.push({
|
|
77
|
-
name,
|
|
78
|
-
latestVersion: latest,
|
|
79
|
-
action: "validate-fail",
|
|
80
|
-
error: `parse error: ${(err as Error).message}`,
|
|
81
|
-
});
|
|
82
|
-
continue;
|
|
83
|
-
}
|
|
84
|
-
const currentVersion = (parsed.version ?? 0) | 0;
|
|
85
|
-
if (currentVersion >= opts.toVersion) {
|
|
86
|
-
plan.push({ name, latestVersion: latest, action: "skip" });
|
|
87
|
-
continue;
|
|
88
|
-
}
|
|
89
|
-
let migrated: SpecObject;
|
|
90
|
-
try {
|
|
91
|
-
migrated = opts.engine.migrate(parsed, opts.toVersion);
|
|
92
|
-
} catch (err) {
|
|
93
|
-
plan.push({
|
|
94
|
-
name,
|
|
95
|
-
latestVersion: latest,
|
|
96
|
-
action: "validate-fail",
|
|
97
|
-
error: `migration error: ${(err as Error).message}`,
|
|
98
|
-
});
|
|
99
|
-
continue;
|
|
100
|
-
}
|
|
101
|
-
if (opts.validate) {
|
|
102
|
-
try {
|
|
103
|
-
opts.validate(migrated, name);
|
|
104
|
-
} catch (err) {
|
|
105
|
-
plan.push({
|
|
106
|
-
name,
|
|
107
|
-
latestVersion: latest,
|
|
108
|
-
action: "validate-fail",
|
|
109
|
-
error: `validation: ${(err as Error).message}`,
|
|
110
|
-
});
|
|
111
|
-
continue;
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
const newVersion = newVersionName(latest, opts.toVersion);
|
|
115
|
-
plan.push({
|
|
116
|
-
name,
|
|
117
|
-
latestVersion: latest,
|
|
118
|
-
action: "migrate",
|
|
119
|
-
newVersion,
|
|
120
|
-
diff: { fromVersion: currentVersion, toVersion: opts.toVersion },
|
|
121
|
-
});
|
|
122
|
-
if (!opts.dryRun) {
|
|
123
|
-
const migratedYaml = stringifyYaml(migrated);
|
|
124
|
-
await opts.registry.put(name, newVersion, migratedYaml);
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// Refuse to apply if any spec failed validation in non-dry-run mode.
|
|
129
|
-
const failed = plan.filter((p) => p.action === "validate-fail").length;
|
|
130
|
-
if (failed > 0 && !opts.dryRun) {
|
|
131
|
-
throw new MigrationRunnerError(
|
|
132
|
-
`${failed} spec(s) failed migration; run with dryRun: true to inspect`,
|
|
133
|
-
);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
return {
|
|
137
|
-
plan,
|
|
138
|
-
migrated: plan.filter((p) => p.action === "migrate").length,
|
|
139
|
-
skipped: plan.filter((p) => p.action === "skip").length,
|
|
140
|
-
failed,
|
|
141
|
-
};
|
|
142
|
-
}
|