@crewhaus/migration-runner 0.1.1 → 0.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/migration-runner",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "description": "Batch-migrate every spec in a registry: dry-run + write, idempotent re-runs",
6
6
  "main": "src/index.ts",
@@ -12,16 +12,16 @@
12
12
  "test": "bun test src"
13
13
  },
14
14
  "dependencies": {
15
- "@crewhaus/errors": "0.1.1",
16
- "@crewhaus/migration-engine": "0.1.1",
17
- "@crewhaus/spec-registry": "0.1.1",
15
+ "@crewhaus/errors": "0.1.2",
16
+ "@crewhaus/migration-engine": "0.1.2",
17
+ "@crewhaus/spec-registry": "0.1.2",
18
18
  "yaml": "^2.6.0"
19
19
  },
20
20
  "license": "Apache-2.0",
21
21
  "author": {
22
22
  "name": "Max Meier",
23
- "email": "max@studiomax.io",
24
- "url": "https://studiomax.io"
23
+ "email": "max@crewhaus.ai",
24
+ "url": "https://crewhaus.ai"
25
25
  },
26
26
  "repository": {
27
27
  "type": "git",
@@ -33,12 +33,7 @@
33
33
  "url": "https://github.com/crewhaus/factory/issues"
34
34
  },
35
35
  "publishConfig": {
36
- "access": "restricted"
36
+ "access": "public"
37
37
  },
38
- "files": [
39
- "src",
40
- "README.md",
41
- "LICENSE",
42
- "NOTICE"
43
- ]
38
+ "files": ["src", "README.md", "LICENSE", "NOTICE"]
44
39
  }
@@ -0,0 +1,204 @@
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;