@crewhaus/migration-engine 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 +41 -0
- package/src/index.test.ts +118 -0
- package/src/index.ts +118 -0
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@crewhaus/migration-engine",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "IR version migrations: register up/down chains; migrate(spec, fromVersion, toVersion) walks the chain",
|
|
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
|
+
},
|
|
17
|
+
"license": "Apache-2.0",
|
|
18
|
+
"author": {
|
|
19
|
+
"name": "Max Meier",
|
|
20
|
+
"email": "max@studiomax.io",
|
|
21
|
+
"url": "https://studiomax.io"
|
|
22
|
+
},
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/crewhaus/factory.git",
|
|
26
|
+
"directory": "packages/migration-engine"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/crewhaus/factory/tree/main/packages/migration-engine#readme",
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/crewhaus/factory/issues"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "restricted"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"src",
|
|
37
|
+
"README.md",
|
|
38
|
+
"LICENSE",
|
|
39
|
+
"NOTICE"
|
|
40
|
+
]
|
|
41
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Section 28 — `migration-engine` tests:
|
|
3
|
+
* - T1 round-trip per registered migration
|
|
4
|
+
* - T4 fixture corpus replay
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import { MigrationEngine, MigrationError, NOOP_0_TO_1, createDefaultEngine } from "./index";
|
|
8
|
+
|
|
9
|
+
describe("migration-engine — T1 single-step migrations", () => {
|
|
10
|
+
test("0 → 1 NOOP migrates and stamps version", () => {
|
|
11
|
+
const e = createDefaultEngine();
|
|
12
|
+
const out = e.migrate({ name: "x", target: "cli" }, 1);
|
|
13
|
+
expect(out.version).toBe(1);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("up + down round-trip", () => {
|
|
17
|
+
const e = createDefaultEngine();
|
|
18
|
+
const original = { name: "x", target: "cli", version: 0 };
|
|
19
|
+
const up = e.migrate(original, 1);
|
|
20
|
+
expect(up.version).toBe(1);
|
|
21
|
+
const back = e.migrate(up, 0);
|
|
22
|
+
expect(back.version).toBe(0);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("from === to is a no-op", () => {
|
|
26
|
+
const e = createDefaultEngine();
|
|
27
|
+
const spec = { version: 0 };
|
|
28
|
+
expect(e.migrate(spec, 0)).toBe(spec);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("missing version step throws", () => {
|
|
32
|
+
const e = new MigrationEngine();
|
|
33
|
+
expect(() => e.migrate({ version: 0 }, 1)).toThrow(MigrationError);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("registering same key twice throws", () => {
|
|
37
|
+
const e = new MigrationEngine();
|
|
38
|
+
e.register(NOOP_0_TO_1);
|
|
39
|
+
expect(() => e.register(NOOP_0_TO_1)).toThrow(MigrationError);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("registering same-version migration throws", () => {
|
|
43
|
+
const e = new MigrationEngine();
|
|
44
|
+
expect(() =>
|
|
45
|
+
e.register({
|
|
46
|
+
from: 0,
|
|
47
|
+
to: 0,
|
|
48
|
+
up: (s) => s,
|
|
49
|
+
down: (s) => s,
|
|
50
|
+
}),
|
|
51
|
+
).toThrow(MigrationError);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("registering >1-step migration throws", () => {
|
|
55
|
+
const e = new MigrationEngine();
|
|
56
|
+
expect(() =>
|
|
57
|
+
e.register({
|
|
58
|
+
from: 0,
|
|
59
|
+
to: 2,
|
|
60
|
+
up: (s) => s,
|
|
61
|
+
down: (s) => s,
|
|
62
|
+
}),
|
|
63
|
+
).toThrow(MigrationError);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("migration-engine — T4 multi-step chain replay", () => {
|
|
68
|
+
test("0 → 2 walks 0→1 then 1→2", () => {
|
|
69
|
+
const e = new MigrationEngine();
|
|
70
|
+
e.register({
|
|
71
|
+
from: 0,
|
|
72
|
+
to: 1,
|
|
73
|
+
up: (s) => ({ ...s, version: 1, addedAt1: true }),
|
|
74
|
+
down: (s) => ({ ...s, version: 0 }),
|
|
75
|
+
});
|
|
76
|
+
e.register({
|
|
77
|
+
from: 1,
|
|
78
|
+
to: 2,
|
|
79
|
+
up: (s) => ({ ...s, version: 2, addedAt2: true }),
|
|
80
|
+
down: (s) => ({ ...s, version: 1 }),
|
|
81
|
+
});
|
|
82
|
+
const out = e.migrate({ version: 0 }, 2);
|
|
83
|
+
expect(out.version).toBe(2);
|
|
84
|
+
expect((out as { addedAt1?: boolean }).addedAt1).toBe(true);
|
|
85
|
+
expect((out as { addedAt2?: boolean }).addedAt2).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("2 → 0 walks 1→2.down then 0→1.down", () => {
|
|
89
|
+
const e = new MigrationEngine();
|
|
90
|
+
e.register({
|
|
91
|
+
from: 0,
|
|
92
|
+
to: 1,
|
|
93
|
+
up: (s) => ({ ...s, version: 1 }),
|
|
94
|
+
down: (s) => ({ ...s, version: 0, removedAt0: true }),
|
|
95
|
+
});
|
|
96
|
+
e.register({
|
|
97
|
+
from: 1,
|
|
98
|
+
to: 2,
|
|
99
|
+
up: (s) => ({ ...s, version: 2 }),
|
|
100
|
+
down: (s) => ({ ...s, version: 1, removedAt1: true }),
|
|
101
|
+
});
|
|
102
|
+
const out = e.migrate({ version: 2 }, 0);
|
|
103
|
+
expect(out.version).toBe(0);
|
|
104
|
+
expect((out as { removedAt0?: boolean }).removedAt0).toBe(true);
|
|
105
|
+
expect((out as { removedAt1?: boolean }).removedAt1).toBe(true);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("list returns sorted migration keys", () => {
|
|
109
|
+
const e = createDefaultEngine();
|
|
110
|
+
e.register({
|
|
111
|
+
from: 1,
|
|
112
|
+
to: 2,
|
|
113
|
+
up: (s) => s,
|
|
114
|
+
down: (s) => s,
|
|
115
|
+
});
|
|
116
|
+
expect(e.list()).toEqual(["0→1", "1→2"]);
|
|
117
|
+
});
|
|
118
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Section 28 — `migration-engine`. Versioned spec migrations registered as
|
|
3
|
+
* `{ from, to, up, down }` so rollbacks work both directions. The engine
|
|
4
|
+
* walks the registered chain to bridge any pair of source/target versions
|
|
5
|
+
* (forward when `from < to`, reverse when `from > to`).
|
|
6
|
+
*
|
|
7
|
+
* Spec versions are integer ints (today every spec is `version: 0`). The
|
|
8
|
+
* first migration registered is the no-op skeleton 0 → 1.
|
|
9
|
+
*
|
|
10
|
+
* Specs are passed in as parsed YAML (raw object); migration steps are
|
|
11
|
+
* pure transforms `(spec) → spec` returning the next-version shape. The
|
|
12
|
+
* engine is unaware of YAML serialisation — callers use yaml-js or the
|
|
13
|
+
* spec parser to round-trip.
|
|
14
|
+
*/
|
|
15
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
16
|
+
|
|
17
|
+
export type SpecObject = Record<string, unknown> & {
|
|
18
|
+
readonly version?: number;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type Migration = {
|
|
22
|
+
readonly from: number;
|
|
23
|
+
readonly to: number;
|
|
24
|
+
up(spec: SpecObject): SpecObject;
|
|
25
|
+
down(spec: SpecObject): SpecObject;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export class MigrationError extends CrewhausError {
|
|
29
|
+
override readonly name = "MigrationError";
|
|
30
|
+
constructor(message: string, cause?: unknown) {
|
|
31
|
+
super("config", message, cause);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class MigrationEngine {
|
|
36
|
+
private readonly migrations = new Map<string, Migration>();
|
|
37
|
+
|
|
38
|
+
register(m: Migration): void {
|
|
39
|
+
if (m.from === m.to) {
|
|
40
|
+
throw new MigrationError(
|
|
41
|
+
`migration ${m.from} → ${m.to} is a no-op (from === to); refusing to register`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
if (Math.abs(m.to - m.from) !== 1) {
|
|
45
|
+
throw new MigrationError(`migrations must be single-version steps; got ${m.from} → ${m.to}`);
|
|
46
|
+
}
|
|
47
|
+
const key = `${m.from}→${m.to}`;
|
|
48
|
+
if (this.migrations.has(key)) {
|
|
49
|
+
throw new MigrationError(`duplicate migration ${key}`);
|
|
50
|
+
}
|
|
51
|
+
this.migrations.set(key, m);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Walk the registered chain to migrate `spec` from its current version
|
|
56
|
+
* to `toVersion`. Throws MigrationError when a step is missing.
|
|
57
|
+
*/
|
|
58
|
+
migrate(spec: SpecObject, toVersion: number): SpecObject {
|
|
59
|
+
const fromVersion = (spec.version ?? 0) | 0;
|
|
60
|
+
if (fromVersion === toVersion) return spec;
|
|
61
|
+
|
|
62
|
+
let current = spec;
|
|
63
|
+
if (fromVersion < toVersion) {
|
|
64
|
+
// Walk up
|
|
65
|
+
for (let v = fromVersion; v < toVersion; v++) {
|
|
66
|
+
const key = `${v}→${v + 1}`;
|
|
67
|
+
const step = this.migrations.get(key);
|
|
68
|
+
if (!step) {
|
|
69
|
+
throw new MigrationError(`no migration registered for ${key}`);
|
|
70
|
+
}
|
|
71
|
+
current = step.up(current);
|
|
72
|
+
}
|
|
73
|
+
} else {
|
|
74
|
+
// Walk down
|
|
75
|
+
for (let v = fromVersion; v > toVersion; v--) {
|
|
76
|
+
const key = `${v - 1}→${v}`;
|
|
77
|
+
const step = this.migrations.get(key);
|
|
78
|
+
if (!step) {
|
|
79
|
+
throw new MigrationError(`no migration registered for ${key} (needed for downgrade)`);
|
|
80
|
+
}
|
|
81
|
+
current = step.down(current);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return current;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Diagnostic: list registered migration keys. */
|
|
88
|
+
list(): ReadonlyArray<string> {
|
|
89
|
+
return [...this.migrations.keys()].sort();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Reset the registry (tests). */
|
|
93
|
+
clear(): void {
|
|
94
|
+
this.migrations.clear();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Skeleton migration 0 → 1 that today is a no-op. Future schema changes
|
|
100
|
+
* land here and bump the IR version on the up() side.
|
|
101
|
+
*/
|
|
102
|
+
export const NOOP_0_TO_1: Migration = Object.freeze({
|
|
103
|
+
from: 0,
|
|
104
|
+
to: 1,
|
|
105
|
+
up(spec) {
|
|
106
|
+
return { ...spec, version: 1 };
|
|
107
|
+
},
|
|
108
|
+
down(spec) {
|
|
109
|
+
return { ...spec, version: 0 };
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
/** A pre-built engine with the v0 → v1 skeleton registered. */
|
|
114
|
+
export function createDefaultEngine(): MigrationEngine {
|
|
115
|
+
const e = new MigrationEngine();
|
|
116
|
+
e.register(NOOP_0_TO_1);
|
|
117
|
+
return e;
|
|
118
|
+
}
|