@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 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 or beyond `toVersion`.
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 or beyond `toVersion`.
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.version ?? 0) | 0;
51
- if (currentVersion >= opts.toVersion) {
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.migrate(parsed, opts.toVersion);
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
- const migratedYaml = stringifyYaml(migrated);
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.5.8",
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.5.8",
19
- "@crewhaus/migration-engine": "0.5.8",
20
- "@crewhaus/spec-registry": "0.5.8",
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",