@crewhaus/migration-runner 0.5.8 → 0.6.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/dist/index.d.ts +72 -2
- package/dist/index.js +139 -8
- package/package.json +4 -4
package/dist/index.d.ts
CHANGED
|
@@ -6,10 +6,26 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Re-running with the same `(fromVersion, toVersion)` is a no-op once the
|
|
8
8
|
* target version exists in the registry — the runner skips specs whose
|
|
9
|
-
* latest version is already at
|
|
9
|
+
* latest version is already at `toVersion`.
|
|
10
|
+
*
|
|
11
|
+
* 0.6.0 (§9.2):
|
|
12
|
+
* - {@link migrateSpecYaml} is the ONE comment-preserving migration writer,
|
|
13
|
+
* shared with `crewhaus upgrade`: steps that declare `edits()` are
|
|
14
|
+
* applied on the `yaml` document CST ({@link applyMigrationEdits} — the
|
|
15
|
+
* same setIn / deleteIn technique as spec-patch's `applySpecEdits`, minus
|
|
16
|
+
* its unconditional `parseSpec`, because validation is this module's
|
|
17
|
+
* INJECTED `validate` seam and the CLI wires `parseSpec` there for both
|
|
18
|
+
* verbs); a step without edits falls back to re-serialising the object,
|
|
19
|
+
* and the result says which happened.
|
|
20
|
+
* - the skip branch is split by DIRECTION. An upward run (`fromVersion <=
|
|
21
|
+
* toVersion`, today's only real usage) keeps skipping specs already at or
|
|
22
|
+
* above the target. A downward run (`fromVersion > toVersion`) walks specs
|
|
23
|
+
* above the target DOWN, which is where `Migration.irreversible` becomes
|
|
24
|
+
* reachable: the engine's `MigrationIrreversibleError` lands on the plan
|
|
25
|
+
* as a `validate-fail` item instead of a silent skip.
|
|
10
26
|
*/
|
|
11
27
|
import { CrewhausError } from "@crewhaus/errors";
|
|
12
|
-
import type { MigrationEngine, SpecObject } from "@crewhaus/migration-engine";
|
|
28
|
+
import type { MigrationEdit, MigrationEngine, SpecObject } from "@crewhaus/migration-engine";
|
|
13
29
|
import type { RegistryAdapter } from "@crewhaus/spec-registry";
|
|
14
30
|
export declare class MigrationRunnerError extends CrewhausError {
|
|
15
31
|
readonly name = "MigrationRunnerError";
|
|
@@ -25,10 +41,23 @@ export type MigrationPlanItem = {
|
|
|
25
41
|
toVersion: number;
|
|
26
42
|
};
|
|
27
43
|
readonly error?: string;
|
|
44
|
+
/**
|
|
45
|
+
* 0.6.0 — true when the written text kept the source's comments and key
|
|
46
|
+
* order (every step supplied `edits()`); false when at least one step was
|
|
47
|
+
* re-serialised object-level. Present for `action: "migrate"` only.
|
|
48
|
+
*/
|
|
49
|
+
readonly commentsPreserved?: boolean;
|
|
28
50
|
};
|
|
29
51
|
export type MigrateAllOptions = {
|
|
30
52
|
readonly registry: RegistryAdapter;
|
|
31
53
|
readonly engine: MigrationEngine;
|
|
54
|
+
/**
|
|
55
|
+
* The version the run starts from. It decides the run's DIRECTION:
|
|
56
|
+
* `fromVersion <= toVersion` is an upward run (specs at or above the
|
|
57
|
+
* target skip); `fromVersion > toVersion` is a downward run (specs above
|
|
58
|
+
* the target are walked down through `Migration.down`, and an
|
|
59
|
+
* `irreversible` step surfaces as `validate-fail`).
|
|
60
|
+
*/
|
|
32
61
|
readonly fromVersion: number;
|
|
33
62
|
readonly toVersion: number;
|
|
34
63
|
/** When true, no writes happen — only the plan is returned. */
|
|
@@ -47,4 +76,45 @@ export type MigrateAllResult = {
|
|
|
47
76
|
readonly skipped: number;
|
|
48
77
|
readonly failed: number;
|
|
49
78
|
};
|
|
79
|
+
export type MigratedSpecYaml = {
|
|
80
|
+
/** The migrated YAML text. */
|
|
81
|
+
readonly yaml: string;
|
|
82
|
+
/** The migrated spec object (the engine's `up()`/`down()` result). */
|
|
83
|
+
readonly spec: SpecObject;
|
|
84
|
+
readonly fromVersion: number;
|
|
85
|
+
readonly toVersion: number;
|
|
86
|
+
/**
|
|
87
|
+
* True when the text was produced ONLY through CST edits (every up-step
|
|
88
|
+
* declared `edits()`), so the source's comments and key order survive.
|
|
89
|
+
* False when any step was re-serialised — including every down-walk,
|
|
90
|
+
* which has no edit seam.
|
|
91
|
+
*/
|
|
92
|
+
readonly commentsPreserved: boolean;
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* Apply `MigrationEdit`s to YAML text on the document CST so comments, key
|
|
96
|
+
* order and blank lines survive: `value` present → `setIn` (upsert, creating
|
|
97
|
+
* missing intermediate maps); `value` absent → `deleteIn` (idempotent). A
|
|
98
|
+
* numeric segment may only address an EXISTING sequence item or append at
|
|
99
|
+
* `length` — the CST would otherwise null-pad, which no migration means.
|
|
100
|
+
* Pure text → text; the caller validates the result.
|
|
101
|
+
*/
|
|
102
|
+
export declare function applyMigrationEdits(yamlText: string, edits: ReadonlyArray<MigrationEdit>): string;
|
|
103
|
+
/**
|
|
104
|
+
* 0.6.0 §9.2 — migrate a spec's YAML TEXT to `toVersion`, preserving
|
|
105
|
+
* comments and key order wherever the chain allows.
|
|
106
|
+
*
|
|
107
|
+
* Up-walk: each step that declares `edits()` is applied through
|
|
108
|
+
* {@link applyMigrationEdits} on the document CST; a step without edits
|
|
109
|
+
* re-serialises its `up()` object. After a CST step the text is re-parsed and checked
|
|
110
|
+
* against the step's `up()` object, so a migration whose two descriptions
|
|
111
|
+
* disagree fails loudly here instead of writing one thing and reporting
|
|
112
|
+
* another. Down-walk: object-level through the engine (which throws
|
|
113
|
+
* `MigrationIrreversibleError` across a lossy step), then re-serialised.
|
|
114
|
+
*
|
|
115
|
+
* Throws (`MigrationError` / `MigrationRunnerError`)
|
|
116
|
+
* rather than returning a failure: callers turn the message into their own
|
|
117
|
+
* `validate-fail` item.
|
|
118
|
+
*/
|
|
119
|
+
export declare function migrateSpecYaml(yamlText: string, engine: MigrationEngine, toVersion: number): MigratedSpecYaml;
|
|
50
120
|
export declare function migrateAll(opts: MigrateAllOptions): Promise<MigrateAllResult>;
|
package/dist/index.js
CHANGED
|
@@ -6,10 +6,26 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Re-running with the same `(fromVersion, toVersion)` is a no-op once the
|
|
8
8
|
* target version exists in the registry — the runner skips specs whose
|
|
9
|
-
* latest version is already at
|
|
9
|
+
* latest version is already at `toVersion`.
|
|
10
|
+
*
|
|
11
|
+
* 0.6.0 (§9.2):
|
|
12
|
+
* - {@link migrateSpecYaml} is the ONE comment-preserving migration writer,
|
|
13
|
+
* shared with `crewhaus upgrade`: steps that declare `edits()` are
|
|
14
|
+
* applied on the `yaml` document CST ({@link applyMigrationEdits} — the
|
|
15
|
+
* same setIn / deleteIn technique as spec-patch's `applySpecEdits`, minus
|
|
16
|
+
* its unconditional `parseSpec`, because validation is this module's
|
|
17
|
+
* INJECTED `validate` seam and the CLI wires `parseSpec` there for both
|
|
18
|
+
* verbs); a step without edits falls back to re-serialising the object,
|
|
19
|
+
* and the result says which happened.
|
|
20
|
+
* - the skip branch is split by DIRECTION. An upward run (`fromVersion <=
|
|
21
|
+
* toVersion`, today's only real usage) keeps skipping specs already at or
|
|
22
|
+
* above the target. A downward run (`fromVersion > toVersion`) walks specs
|
|
23
|
+
* above the target DOWN, which is where `Migration.irreversible` becomes
|
|
24
|
+
* reachable: the engine's `MigrationIrreversibleError` lands on the plan
|
|
25
|
+
* as a `validate-fail` item instead of a silent skip.
|
|
10
26
|
*/
|
|
11
27
|
import { CrewhausError } from "@crewhaus/errors";
|
|
12
|
-
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
28
|
+
import { isSeq, parseDocument, parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
13
29
|
export class MigrationRunnerError extends CrewhausError {
|
|
14
30
|
name = "MigrationRunnerError";
|
|
15
31
|
constructor(message, cause) {
|
|
@@ -22,8 +38,119 @@ const DEFAULT_NEW_VERSION = (latest, toVersion) => {
|
|
|
22
38
|
return `v${(Number.parseInt(m[1] ?? "0", 10) || 0) + 1}`;
|
|
23
39
|
return `${latest}-v${toVersion}`;
|
|
24
40
|
};
|
|
41
|
+
/**
|
|
42
|
+
* Apply `MigrationEdit`s to YAML text on the document CST so comments, key
|
|
43
|
+
* order and blank lines survive: `value` present → `setIn` (upsert, creating
|
|
44
|
+
* missing intermediate maps); `value` absent → `deleteIn` (idempotent). A
|
|
45
|
+
* numeric segment may only address an EXISTING sequence item or append at
|
|
46
|
+
* `length` — the CST would otherwise null-pad, which no migration means.
|
|
47
|
+
* Pure text → text; the caller validates the result.
|
|
48
|
+
*/
|
|
49
|
+
export function applyMigrationEdits(yamlText, edits) {
|
|
50
|
+
if (edits.length === 0)
|
|
51
|
+
return yamlText;
|
|
52
|
+
const doc = parseDocument(yamlText);
|
|
53
|
+
if (doc.errors.length > 0) {
|
|
54
|
+
throw new MigrationRunnerError(`spec YAML is not parseable: ${doc.errors[0]?.message ?? "unknown error"}`);
|
|
55
|
+
}
|
|
56
|
+
edits.forEach((edit, i) => {
|
|
57
|
+
const path = [...edit.path];
|
|
58
|
+
if (path.length === 0)
|
|
59
|
+
throw new MigrationRunnerError(`edit #${i} has an empty path`);
|
|
60
|
+
if (edit.value === undefined) {
|
|
61
|
+
if (doc.hasIn(path))
|
|
62
|
+
doc.deleteIn(path);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
for (let d = 0; d < path.length; d++) {
|
|
66
|
+
const seg = path[d];
|
|
67
|
+
if (typeof seg !== "number")
|
|
68
|
+
continue;
|
|
69
|
+
const parent = d === 0 ? doc.contents : doc.getIn(path.slice(0, d), true);
|
|
70
|
+
const length = isSeq(parent) ? parent.items.length : parent === undefined ? 0 : undefined;
|
|
71
|
+
if (length === undefined || seg > length) {
|
|
72
|
+
throw new MigrationRunnerError(`edit #${i}: index ${seg} at ${path.slice(0, d).join(".") || "(root)"} is out of bounds`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
doc.setIn(path, edit.value);
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
throw new MigrationRunnerError(`edit #${i} (${path.join(".")}) failed: ${err.message}`, err);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
return doc.toString();
|
|
83
|
+
}
|
|
84
|
+
/** Order-insensitive structural equality (JSON-comparable values only). */
|
|
85
|
+
function structurallyEqual(a, b) {
|
|
86
|
+
if (a === b)
|
|
87
|
+
return true;
|
|
88
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
89
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length)
|
|
90
|
+
return false;
|
|
91
|
+
return a.every((v, i) => structurallyEqual(v, b[i]));
|
|
92
|
+
}
|
|
93
|
+
if (typeof a === "object" && a !== null && typeof b === "object" && b !== null) {
|
|
94
|
+
const ka = Object.keys(a).sort();
|
|
95
|
+
const kb = Object.keys(b).sort();
|
|
96
|
+
if (ka.length !== kb.length || ka.some((k, i) => k !== kb[i]))
|
|
97
|
+
return false;
|
|
98
|
+
return ka.every((k) => structurallyEqual(a[k], b[k]));
|
|
99
|
+
}
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* 0.6.0 §9.2 — migrate a spec's YAML TEXT to `toVersion`, preserving
|
|
104
|
+
* comments and key order wherever the chain allows.
|
|
105
|
+
*
|
|
106
|
+
* Up-walk: each step that declares `edits()` is applied through
|
|
107
|
+
* {@link applyMigrationEdits} on the document CST; a step without edits
|
|
108
|
+
* re-serialises its `up()` object. After a CST step the text is re-parsed and checked
|
|
109
|
+
* against the step's `up()` object, so a migration whose two descriptions
|
|
110
|
+
* disagree fails loudly here instead of writing one thing and reporting
|
|
111
|
+
* another. Down-walk: object-level through the engine (which throws
|
|
112
|
+
* `MigrationIrreversibleError` across a lossy step), then re-serialised.
|
|
113
|
+
*
|
|
114
|
+
* Throws (`MigrationError` / `MigrationRunnerError`)
|
|
115
|
+
* rather than returning a failure: callers turn the message into their own
|
|
116
|
+
* `validate-fail` item.
|
|
117
|
+
*/
|
|
118
|
+
export function migrateSpecYaml(yamlText, engine, toVersion) {
|
|
119
|
+
const parsed = parseYaml(yamlText);
|
|
120
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
121
|
+
throw new MigrationRunnerError("spec YAML must be a mapping at the root");
|
|
122
|
+
}
|
|
123
|
+
const fromVersion = (parsed.version ?? 0) | 0;
|
|
124
|
+
if (fromVersion === toVersion) {
|
|
125
|
+
return { yaml: yamlText, spec: parsed, fromVersion, toVersion, commentsPreserved: true };
|
|
126
|
+
}
|
|
127
|
+
if (fromVersion > toVersion) {
|
|
128
|
+
const spec = engine.migrate(parsed, toVersion);
|
|
129
|
+
return { yaml: stringifyYaml(spec), spec, fromVersion, toVersion, commentsPreserved: false };
|
|
130
|
+
}
|
|
131
|
+
const plan = engine.planUp(parsed, toVersion);
|
|
132
|
+
let yaml = yamlText;
|
|
133
|
+
let commentsPreserved = true;
|
|
134
|
+
for (const step of plan.steps) {
|
|
135
|
+
if (step.edits === undefined) {
|
|
136
|
+
yaml = stringifyYaml(step.spec);
|
|
137
|
+
commentsPreserved = false;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
yaml = applyMigrationEdits(yaml, step.edits);
|
|
141
|
+
const reparsed = parseYaml(yaml);
|
|
142
|
+
if (!structurallyEqual(reparsed, step.spec)) {
|
|
143
|
+
throw new MigrationRunnerError(`migration ${step.from} → ${step.to}: its edits() and up() disagree — the CST-edited text does not parse to the object up() returns`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { yaml, spec: plan.spec, fromVersion, toVersion, commentsPreserved };
|
|
147
|
+
}
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// The fleet walk
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
25
151
|
export async function migrateAll(opts) {
|
|
26
152
|
const newVersionName = opts.newVersionName ?? DEFAULT_NEW_VERSION;
|
|
153
|
+
const downward = opts.fromVersion > opts.toVersion;
|
|
27
154
|
const specs = await opts.registry.listSpecs();
|
|
28
155
|
const plan = [];
|
|
29
156
|
for (const name of specs) {
|
|
@@ -47,14 +174,18 @@ export async function migrateAll(opts) {
|
|
|
47
174
|
});
|
|
48
175
|
continue;
|
|
49
176
|
}
|
|
50
|
-
const currentVersion = (parsed
|
|
51
|
-
|
|
177
|
+
const currentVersion = (parsed?.version ?? 0) | 0;
|
|
178
|
+
// Direction gate (0.6.0 §9.2). Upward run: at-or-above the target skips,
|
|
179
|
+
// exactly as before. Downward run: only specs ABOVE the target move, and
|
|
180
|
+
// they walk DOWN — the branch that makes `irreversible` reachable.
|
|
181
|
+
const needsMove = downward ? currentVersion > opts.toVersion : currentVersion < opts.toVersion;
|
|
182
|
+
if (!needsMove) {
|
|
52
183
|
plan.push({ name, latestVersion: latest, action: "skip" });
|
|
53
184
|
continue;
|
|
54
185
|
}
|
|
55
186
|
let migrated;
|
|
56
187
|
try {
|
|
57
|
-
migrated = opts.engine
|
|
188
|
+
migrated = migrateSpecYaml(yaml, opts.engine, opts.toVersion);
|
|
58
189
|
}
|
|
59
190
|
catch (err) {
|
|
60
191
|
plan.push({
|
|
@@ -67,7 +198,7 @@ export async function migrateAll(opts) {
|
|
|
67
198
|
}
|
|
68
199
|
if (opts.validate) {
|
|
69
200
|
try {
|
|
70
|
-
opts.validate(migrated, name);
|
|
201
|
+
opts.validate(migrated.spec, name);
|
|
71
202
|
}
|
|
72
203
|
catch (err) {
|
|
73
204
|
plan.push({
|
|
@@ -86,10 +217,10 @@ export async function migrateAll(opts) {
|
|
|
86
217
|
action: "migrate",
|
|
87
218
|
newVersion,
|
|
88
219
|
diff: { fromVersion: currentVersion, toVersion: opts.toVersion },
|
|
220
|
+
commentsPreserved: migrated.commentsPreserved,
|
|
89
221
|
});
|
|
90
222
|
if (!opts.dryRun) {
|
|
91
|
-
|
|
92
|
-
await opts.registry.put(name, newVersion, migratedYaml);
|
|
223
|
+
await opts.registry.put(name, newVersion, migrated.yaml);
|
|
93
224
|
}
|
|
94
225
|
}
|
|
95
226
|
// Refuse to apply if any spec failed validation in non-dry-run mode.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crewhaus/migration-runner",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Batch-migrate every spec in a registry: dry-run + write, idempotent re-runs",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -15,9 +15,9 @@
|
|
|
15
15
|
"test": "bun test src"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@crewhaus/errors": "0.
|
|
19
|
-
"@crewhaus/migration-engine": "0.
|
|
20
|
-
"@crewhaus/spec-registry": "0.
|
|
18
|
+
"@crewhaus/errors": "0.6.0",
|
|
19
|
+
"@crewhaus/migration-engine": "0.6.0",
|
|
20
|
+
"@crewhaus/spec-registry": "0.6.0",
|
|
21
21
|
"yaml": "^2.6.0"
|
|
22
22
|
},
|
|
23
23
|
"license": "Apache-2.0",
|