@crewhaus/migration-runner 0.1.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/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@crewhaus/migration-runner",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Batch-migrate every spec in a registry: dry-run + write, idempotent re-runs",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts"
10
+ },
11
+ "scripts": {
12
+ "test": "bun test src"
13
+ },
14
+ "dependencies": {
15
+ "@crewhaus/errors": "0.0.0",
16
+ "@crewhaus/migration-engine": "0.0.0",
17
+ "@crewhaus/spec-registry": "0.0.0",
18
+ "yaml": "^2.6.0"
19
+ },
20
+ "license": "Apache-2.0",
21
+ "author": {
22
+ "name": "Max Meier",
23
+ "email": "max@studiomax.io",
24
+ "url": "https://studiomax.io"
25
+ },
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/crewhaus/factory.git",
29
+ "directory": "packages/migration-runner"
30
+ },
31
+ "homepage": "https://github.com/crewhaus/factory/tree/main/packages/migration-runner#readme",
32
+ "bugs": {
33
+ "url": "https://github.com/crewhaus/factory/issues"
34
+ },
35
+ "publishConfig": {
36
+ "access": "restricted"
37
+ },
38
+ "files": [
39
+ "src",
40
+ "README.md",
41
+ "LICENSE",
42
+ "NOTICE"
43
+ ]
44
+ }
@@ -0,0 +1,118 @@
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 ADDED
@@ -0,0 +1,142 @@
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
+ }