@crewhaus/migration-engine 0.1.3 → 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.
@@ -0,0 +1,49 @@
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
+ export type SpecObject = Record<string, unknown> & {
17
+ readonly version?: number;
18
+ };
19
+ export type Migration = {
20
+ readonly from: number;
21
+ readonly to: number;
22
+ up(spec: SpecObject): SpecObject;
23
+ down(spec: SpecObject): SpecObject;
24
+ };
25
+ export declare class MigrationError extends CrewhausError {
26
+ readonly name = "MigrationError";
27
+ constructor(message: string, cause?: unknown);
28
+ }
29
+ export declare class MigrationEngine {
30
+ private readonly migrations;
31
+ constructor();
32
+ register(m: Migration): void;
33
+ /**
34
+ * Walk the registered chain to migrate `spec` from its current version
35
+ * to `toVersion`. Throws MigrationError when a step is missing.
36
+ */
37
+ migrate(spec: SpecObject, toVersion: number): SpecObject;
38
+ /** Diagnostic: list registered migration keys. */
39
+ list(): ReadonlyArray<string>;
40
+ /** Reset the registry (tests). */
41
+ clear(): void;
42
+ }
43
+ /**
44
+ * Skeleton migration 0 → 1 that today is a no-op. Future schema changes
45
+ * land here and bump the IR version on the up() side.
46
+ */
47
+ export declare const NOOP_0_TO_1: Migration;
48
+ /** A pre-built engine with the v0 → v1 skeleton registered. */
49
+ export declare function createDefaultEngine(): MigrationEngine;
package/dist/index.js ADDED
@@ -0,0 +1,101 @@
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
+ export class MigrationError extends CrewhausError {
17
+ name = "MigrationError";
18
+ constructor(message, cause) {
19
+ super("config", message, cause);
20
+ }
21
+ }
22
+ export class MigrationEngine {
23
+ migrations;
24
+ constructor() {
25
+ this.migrations = new Map();
26
+ }
27
+ register(m) {
28
+ if (m.from === m.to) {
29
+ throw new MigrationError(`migration ${m.from} → ${m.to} is a no-op (from === to); refusing to register`);
30
+ }
31
+ if (Math.abs(m.to - m.from) !== 1) {
32
+ throw new MigrationError(`migrations must be single-version steps; got ${m.from} → ${m.to}`);
33
+ }
34
+ const key = `${m.from}→${m.to}`;
35
+ if (this.migrations.has(key)) {
36
+ throw new MigrationError(`duplicate migration ${key}`);
37
+ }
38
+ this.migrations.set(key, m);
39
+ }
40
+ /**
41
+ * Walk the registered chain to migrate `spec` from its current version
42
+ * to `toVersion`. Throws MigrationError when a step is missing.
43
+ */
44
+ migrate(spec, toVersion) {
45
+ const fromVersion = (spec.version ?? 0) | 0;
46
+ if (fromVersion === toVersion)
47
+ return spec;
48
+ let current = spec;
49
+ if (fromVersion < toVersion) {
50
+ // Walk up
51
+ for (let v = fromVersion; v < toVersion; v++) {
52
+ const key = `${v}→${v + 1}`;
53
+ const step = this.migrations.get(key);
54
+ if (!step) {
55
+ throw new MigrationError(`no migration registered for ${key}`);
56
+ }
57
+ current = step.up(current);
58
+ }
59
+ }
60
+ else {
61
+ // Walk down
62
+ for (let v = fromVersion; v > toVersion; v--) {
63
+ const key = `${v - 1}→${v}`;
64
+ const step = this.migrations.get(key);
65
+ if (!step) {
66
+ throw new MigrationError(`no migration registered for ${key} (needed for downgrade)`);
67
+ }
68
+ current = step.down(current);
69
+ }
70
+ }
71
+ return current;
72
+ }
73
+ /** Diagnostic: list registered migration keys. */
74
+ list() {
75
+ return [...this.migrations.keys()].sort();
76
+ }
77
+ /** Reset the registry (tests). */
78
+ clear() {
79
+ this.migrations.clear();
80
+ }
81
+ }
82
+ /**
83
+ * Skeleton migration 0 → 1 that today is a no-op. Future schema changes
84
+ * land here and bump the IR version on the up() side.
85
+ */
86
+ export const NOOP_0_TO_1 = Object.freeze({
87
+ from: 0,
88
+ to: 1,
89
+ up(spec) {
90
+ return { ...spec, version: 1 };
91
+ },
92
+ down(spec) {
93
+ return { ...spec, version: 0 };
94
+ },
95
+ });
96
+ /** A pre-built engine with the v0 → v1 skeleton registered. */
97
+ export function createDefaultEngine() {
98
+ const e = new MigrationEngine();
99
+ e.register(NOOP_0_TO_1);
100
+ return e;
101
+ }
package/package.json CHANGED
@@ -1,18 +1,21 @@
1
1
  {
2
2
  "name": "@crewhaus/migration-engine",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
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",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
8
  "exports": {
9
- ".": "./src/index.ts"
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.3"
18
+ "@crewhaus/errors": "0.1.5"
16
19
  },
17
20
  "license": "Apache-2.0",
18
21
  "author": {
@@ -32,5 +35,5 @@
32
35
  "publishConfig": {
33
36
  "access": "public"
34
37
  },
35
- "files": ["src", "README.md", "LICENSE", "NOTICE"]
38
+ "files": ["dist", "README.md", "LICENSE", "NOTICE"]
36
39
  }
package/src/index.test.ts DELETED
@@ -1,160 +0,0 @@
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
-
119
- test("clear empties the registry", () => {
120
- const e = createDefaultEngine();
121
- expect(e.list()).toEqual(["0→1"]);
122
- e.clear();
123
- expect(e.list()).toEqual([]);
124
- // After clearing, the previously-registered step is gone.
125
- expect(() => e.migrate({ version: 0 }, 1)).toThrow(MigrationError);
126
- });
127
- });
128
-
129
- describe("migration-engine — additional coverage", () => {
130
- test("NOOP_0_TO_1.up stamps version 1 and preserves other keys", () => {
131
- const out = NOOP_0_TO_1.up({ name: "x", version: 0 });
132
- expect(out).toEqual({ name: "x", version: 1 });
133
- });
134
-
135
- test("NOOP_0_TO_1.down stamps version 0 and preserves other keys", () => {
136
- const out = NOOP_0_TO_1.down({ name: "x", version: 1 });
137
- expect(out).toEqual({ name: "x", version: 0 });
138
- });
139
-
140
- test("migrate treats a spec with no version field as version 0", () => {
141
- const e = createDefaultEngine();
142
- // No `version` key at all -> fromVersion defaults to 0, so 0 -> 1 runs.
143
- const out = e.migrate({ name: "no-version" }, 1);
144
- expect(out.version).toBe(1);
145
- });
146
-
147
- test("downgrade throws when the needed step is not registered", () => {
148
- const e = new MigrationEngine();
149
- // Walking down from 1 to 0 needs the 0→1 step's down(); none registered.
150
- expect(() => e.migrate({ version: 1 }, 0)).toThrow(MigrationError);
151
- });
152
-
153
- test("MigrationError carries the config code and an optional cause", () => {
154
- const cause = new Error("root");
155
- const err = new MigrationError("boom", cause);
156
- expect(err).toBeInstanceOf(MigrationError);
157
- expect(err.name).toBe("MigrationError");
158
- expect(err.cause).toBe(cause);
159
- });
160
- });
package/src/index.ts DELETED
@@ -1,122 +0,0 @@
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: Map<string, Migration>;
37
-
38
- constructor() {
39
- this.migrations = new Map<string, Migration>();
40
- }
41
-
42
- register(m: Migration): void {
43
- if (m.from === m.to) {
44
- throw new MigrationError(
45
- `migration ${m.from} → ${m.to} is a no-op (from === to); refusing to register`,
46
- );
47
- }
48
- if (Math.abs(m.to - m.from) !== 1) {
49
- throw new MigrationError(`migrations must be single-version steps; got ${m.from} → ${m.to}`);
50
- }
51
- const key = `${m.from}→${m.to}`;
52
- if (this.migrations.has(key)) {
53
- throw new MigrationError(`duplicate migration ${key}`);
54
- }
55
- this.migrations.set(key, m);
56
- }
57
-
58
- /**
59
- * Walk the registered chain to migrate `spec` from its current version
60
- * to `toVersion`. Throws MigrationError when a step is missing.
61
- */
62
- migrate(spec: SpecObject, toVersion: number): SpecObject {
63
- const fromVersion = (spec.version ?? 0) | 0;
64
- if (fromVersion === toVersion) return spec;
65
-
66
- let current = spec;
67
- if (fromVersion < toVersion) {
68
- // Walk up
69
- for (let v = fromVersion; v < toVersion; v++) {
70
- const key = `${v}→${v + 1}`;
71
- const step = this.migrations.get(key);
72
- if (!step) {
73
- throw new MigrationError(`no migration registered for ${key}`);
74
- }
75
- current = step.up(current);
76
- }
77
- } else {
78
- // Walk down
79
- for (let v = fromVersion; v > toVersion; v--) {
80
- const key = `${v - 1}→${v}`;
81
- const step = this.migrations.get(key);
82
- if (!step) {
83
- throw new MigrationError(`no migration registered for ${key} (needed for downgrade)`);
84
- }
85
- current = step.down(current);
86
- }
87
- }
88
- return current;
89
- }
90
-
91
- /** Diagnostic: list registered migration keys. */
92
- list(): ReadonlyArray<string> {
93
- return [...this.migrations.keys()].sort();
94
- }
95
-
96
- /** Reset the registry (tests). */
97
- clear(): void {
98
- this.migrations.clear();
99
- }
100
- }
101
-
102
- /**
103
- * Skeleton migration 0 → 1 that today is a no-op. Future schema changes
104
- * land here and bump the IR version on the up() side.
105
- */
106
- export const NOOP_0_TO_1: Migration = Object.freeze({
107
- from: 0,
108
- to: 1,
109
- up(spec) {
110
- return { ...spec, version: 1 };
111
- },
112
- down(spec) {
113
- return { ...spec, version: 0 };
114
- },
115
- });
116
-
117
- /** A pre-built engine with the v0 → v1 skeleton registered. */
118
- export function createDefaultEngine(): MigrationEngine {
119
- const e = new MigrationEngine();
120
- e.register(NOOP_0_TO_1);
121
- return e;
122
- }