@crewhaus/migration-engine 0.5.7 → 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
@@ -4,53 +4,158 @@
4
4
  * walks the registered chain to bridge any pair of source/target versions
5
5
  * (forward when `from < to`, reverse when `from > to`).
6
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.
7
+ * Spec versions are integer ints. The first migration registered is the
8
+ * no-op skeleton 0 → 1; 0.6.0 adds the first REAL migration, 1 → 2
9
+ * (`MIGRATION_1_TO_2`, design §9.2).
9
10
  *
10
11
  * Specs are passed in as parsed YAML (raw object); migration steps are
11
12
  * pure transforms `(spec) → spec` returning the next-version shape. The
12
13
  * engine is unaware of YAML serialisation — callers use yaml-js or the
13
14
  * spec parser to round-trip.
15
+ *
16
+ * 0.6.0 (§9.2) — two additive seams on `Migration`:
17
+ *
18
+ * - `edits?(spec)` declares the SAME change `up()` performs as a list of
19
+ * `SpecEdit`-shaped path/value edits. A caller that owns the YAML TEXT
20
+ * (`crewhaus upgrade`, `migrate-all`) applies those through the CST
21
+ * writer (`applySpecEdits` in `@crewhaus/spec-patch`) so comments and
22
+ * key order survive; `up()` stays the object-level truth for callers
23
+ * that only hold a parsed object. {@link MigrationEngine.planUp} walks
24
+ * the chain and hands both back per step.
25
+ * - `irreversible?: true` marks a lossy step. The DOWN-walk throws
26
+ * {@link MigrationIrreversibleError} across such a step instead of
27
+ * calling its `down()` — the marker alone would be decoration.
14
28
  */
15
29
  import { CrewhausError } from "@crewhaus/errors";
16
30
  export type SpecObject = Record<string, unknown> & {
17
31
  readonly version?: number;
18
32
  };
33
+ export type MigrationEditPathSegment = string | number;
34
+ /**
35
+ * One comment-preserving edit a migration declares: "make `path` carry
36
+ * `value`" (upsert) or, when `value` is `undefined`, "make `path` absent".
37
+ * Structurally identical to `SpecEdit` in `@crewhaus/spec-patch` — declared
38
+ * here so the engine keeps its one-dependency footprint and stays
39
+ * serialisation-free; callers pass these straight to `applySpecEdits`.
40
+ */
41
+ export type MigrationEdit = {
42
+ readonly path: ReadonlyArray<MigrationEditPathSegment>;
43
+ readonly value?: unknown;
44
+ readonly rationale?: string;
45
+ };
19
46
  export type Migration = {
20
47
  readonly from: number;
21
48
  readonly to: number;
22
49
  up(spec: SpecObject): SpecObject;
23
50
  down(spec: SpecObject): SpecObject;
51
+ /**
52
+ * 0.6.0 §9.2 — the edit list equivalent to `up(spec)`, for callers that
53
+ * hold the YAML text and want comments + key order preserved. Optional: a
54
+ * step without it is applied object-level and re-serialised (the pre-0.6.0
55
+ * comment-flattening path), which `planUp` reports per step.
56
+ */
57
+ edits?(spec: SpecObject): ReadonlyArray<MigrationEdit>;
58
+ /**
59
+ * 0.6.0 §9.2 — declares the step lossy. The engine refuses to walk DOWN
60
+ * across it (`MigrationIrreversibleError`); `down()` is never called.
61
+ * Reserved for a genuinely lossy future step — `MIGRATION_1_TO_2` is a
62
+ * version stamp plus one explicit default and is NOT marked.
63
+ */
64
+ readonly irreversible?: true;
24
65
  };
25
66
  export declare class MigrationError extends CrewhausError {
26
- readonly name = "MigrationError";
67
+ readonly name: string;
27
68
  constructor(message: string, cause?: unknown);
28
69
  }
70
+ /** Thrown by the down-walk when it would have to cross an `irreversible` step. */
71
+ export declare class MigrationIrreversibleError extends MigrationError {
72
+ readonly name = "MigrationIrreversibleError";
73
+ readonly from: number;
74
+ readonly to: number;
75
+ constructor(from: number, to: number);
76
+ }
77
+ /** One step of an up-walk as {@link MigrationEngine.planUp} reports it. */
78
+ export type MigrationStepPlan = {
79
+ readonly from: number;
80
+ readonly to: number;
81
+ /** The step's declared edits — `undefined` when the migration supplies none. */
82
+ readonly edits?: ReadonlyArray<MigrationEdit>;
83
+ /** The spec object AFTER this step's `up()`. */
84
+ readonly spec: SpecObject;
85
+ };
86
+ export type MigrationUpPlan = {
87
+ /** The fully migrated spec object (equal to `steps.at(-1).spec`, or the input when no step ran). */
88
+ readonly spec: SpecObject;
89
+ readonly steps: ReadonlyArray<MigrationStepPlan>;
90
+ /** True when EVERY step supplied `edits` — the whole walk can be applied comment-preserving. */
91
+ readonly editsComplete: boolean;
92
+ };
29
93
  export declare class MigrationEngine {
30
94
  private readonly migrations;
31
95
  constructor();
32
96
  register(m: Migration): void;
33
97
  /**
34
98
  * Walk the registered chain to migrate `spec` from its current version
35
- * to `toVersion`. Throws MigrationError when a step is missing.
99
+ * to `toVersion`. Throws MigrationError when a step is missing, and
100
+ * MigrationIrreversibleError when a DOWN-walk would cross an
101
+ * `irreversible` step (checked before any `down()` runs, so a refused
102
+ * downgrade leaves nothing half-applied).
36
103
  */
37
104
  migrate(spec: SpecObject, toVersion: number): SpecObject;
105
+ /**
106
+ * 0.6.0 §9.2 — the up-walk with the edit seam exposed: for each step from
107
+ * the spec's version to `toVersion`, the step's `edits(specBefore)` (when
108
+ * declared) and the object after `up()`. Callers holding YAML text apply
109
+ * `edits` through the CST writer and fall back to re-serialising `spec`
110
+ * only for steps that supply none. Throws MigrationError when a step is
111
+ * missing or `toVersion` is below the spec's version (use `migrate`).
112
+ */
113
+ planUp(spec: SpecObject, toVersion: number): MigrationUpPlan;
38
114
  /** Diagnostic: list registered migration keys. */
39
115
  list(): ReadonlyArray<string>;
40
116
  /**
41
117
  * #43 — the highest version this engine can migrate TO, i.e. the current
42
118
  * spec-schema version. It is the max `to` across every registered migration
43
119
  * (`0` when none are registered — nothing to upgrade to). `crewhaus upgrade`
44
- * uses this as the drift target so the CLI never hardcodes "1".
120
+ * uses this as the drift target so the CLI never hardcodes a number.
45
121
  */
46
122
  latestVersion(): number;
47
123
  /** Reset the registry (tests). */
48
124
  clear(): void;
49
125
  }
50
126
  /**
51
- * Skeleton migration 0 → 1 that today is a no-op. Future schema changes
52
- * land here and bump the IR version on the up() side.
127
+ * Skeleton migration 0 → 1 (a version stamp only). Declares its edit list
128
+ * too, so a 0 2 walk is comment-preserving end to end.
53
129
  */
54
130
  export declare const NOOP_0_TO_1: Migration;
55
- /** A pre-built engine with the v0 v1 skeleton registered. */
131
+ /** One `model_pool` block found in a spec object, with the path to it. */
132
+ export type ModelPoolSite = {
133
+ /** Path from the spec root to the `model_pool` object (string keys / array indices). */
134
+ readonly path: ReadonlyArray<MigrationEditPathSegment>;
135
+ readonly pool: Record<string, unknown>;
136
+ };
137
+ /**
138
+ * Every place the spec schema hangs a `model_pool` block: the agent (cli /
139
+ * channel / managed / pipeline / research / batch / browser / voice-less
140
+ * pooled shapes), `agent.sub_agents.<name>`, workflow `steps[i]`, graph
141
+ * `nodes.<name>`, crew `roles.<name>`. Pure structural walk over the parsed
142
+ * object — no schema knowledge beyond the key names, so a spec of any
143
+ * target (or an unparseable-by-zod one) still enumerates.
144
+ */
145
+ export declare function findModelPools(spec: SpecObject): ReadonlyArray<ModelPoolSite>;
146
+ /**
147
+ * 0.6.0 §9.2 — the first REAL schema migration. Stamps `version: 2`, and
148
+ * adds `reward: { quality_source: none }` explicitly — but ONLY on a
149
+ * `model_pool` with `policy: learned` that does not already say (so the
150
+ * default that now steers a learned reward is visible in the file). Nothing
151
+ * else changes: every other 0.6.0 key is optional and absent-is-byte-identical.
152
+ *
153
+ * `edits()` and `up()` describe the same change; `planUp` consumers apply
154
+ * `edits` through the CST writer so the file's comments and key order
155
+ * survive. `down()` un-stamps the version and leaves the explicit default in
156
+ * place (it is valid at v1 and harmless) — the step is trivially reversible,
157
+ * so it is NOT marked `irreversible`.
158
+ */
159
+ export declare const MIGRATION_1_TO_2: Migration;
160
+ /** A pre-built engine with the full chain registered (0 → 1 → 2). */
56
161
  export declare function createDefaultEngine(): MigrationEngine;
package/dist/index.js CHANGED
@@ -4,21 +4,47 @@
4
4
  * walks the registered chain to bridge any pair of source/target versions
5
5
  * (forward when `from < to`, reverse when `from > to`).
6
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.
7
+ * Spec versions are integer ints. The first migration registered is the
8
+ * no-op skeleton 0 → 1; 0.6.0 adds the first REAL migration, 1 → 2
9
+ * (`MIGRATION_1_TO_2`, design §9.2).
9
10
  *
10
11
  * Specs are passed in as parsed YAML (raw object); migration steps are
11
12
  * pure transforms `(spec) → spec` returning the next-version shape. The
12
13
  * engine is unaware of YAML serialisation — callers use yaml-js or the
13
14
  * spec parser to round-trip.
15
+ *
16
+ * 0.6.0 (§9.2) — two additive seams on `Migration`:
17
+ *
18
+ * - `edits?(spec)` declares the SAME change `up()` performs as a list of
19
+ * `SpecEdit`-shaped path/value edits. A caller that owns the YAML TEXT
20
+ * (`crewhaus upgrade`, `migrate-all`) applies those through the CST
21
+ * writer (`applySpecEdits` in `@crewhaus/spec-patch`) so comments and
22
+ * key order survive; `up()` stays the object-level truth for callers
23
+ * that only hold a parsed object. {@link MigrationEngine.planUp} walks
24
+ * the chain and hands both back per step.
25
+ * - `irreversible?: true` marks a lossy step. The DOWN-walk throws
26
+ * {@link MigrationIrreversibleError} across such a step instead of
27
+ * calling its `down()` — the marker alone would be decoration.
14
28
  */
15
29
  import { CrewhausError } from "@crewhaus/errors";
16
30
  export class MigrationError extends CrewhausError {
31
+ // Widened to `string` so the irreversible subclass can carry its own name.
17
32
  name = "MigrationError";
18
33
  constructor(message, cause) {
19
34
  super("config", message, cause);
20
35
  }
21
36
  }
37
+ /** Thrown by the down-walk when it would have to cross an `irreversible` step. */
38
+ export class MigrationIrreversibleError extends MigrationError {
39
+ name = "MigrationIrreversibleError";
40
+ from;
41
+ to;
42
+ constructor(from, to) {
43
+ super(`migration ${from} → ${to} is irreversible: a spec at v${to} cannot be walked back to v${from} (the step is lossy — restore the pre-migration file from version control or the registry's previous version instead)`);
44
+ this.from = from;
45
+ this.to = to;
46
+ }
47
+ }
22
48
  export class MigrationEngine {
23
49
  migrations;
24
50
  constructor() {
@@ -39,37 +65,72 @@ export class MigrationEngine {
39
65
  }
40
66
  /**
41
67
  * Walk the registered chain to migrate `spec` from its current version
42
- * to `toVersion`. Throws MigrationError when a step is missing.
68
+ * to `toVersion`. Throws MigrationError when a step is missing, and
69
+ * MigrationIrreversibleError when a DOWN-walk would cross an
70
+ * `irreversible` step (checked before any `down()` runs, so a refused
71
+ * downgrade leaves nothing half-applied).
43
72
  */
44
73
  migrate(spec, toVersion) {
45
74
  const fromVersion = (spec.version ?? 0) | 0;
46
75
  if (fromVersion === toVersion)
47
76
  return spec;
48
- let current = spec;
49
77
  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
- }
78
+ return this.planUp(spec, toVersion).spec;
59
79
  }
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);
80
+ // Walk down. Resolve every step first so an irreversible or missing step
81
+ // is reported before any down() mutates anything.
82
+ const steps = [];
83
+ for (let v = fromVersion; v > toVersion; v--) {
84
+ const key = `${v - 1}→${v}`;
85
+ const step = this.migrations.get(key);
86
+ if (!step) {
87
+ throw new MigrationError(`no migration registered for ${key} (needed for downgrade)`);
69
88
  }
89
+ if (step.irreversible === true) {
90
+ throw new MigrationIrreversibleError(step.from, step.to);
91
+ }
92
+ steps.push(step);
70
93
  }
94
+ let current = spec;
95
+ for (const step of steps)
96
+ current = step.down(current);
71
97
  return current;
72
98
  }
99
+ /**
100
+ * 0.6.0 §9.2 — the up-walk with the edit seam exposed: for each step from
101
+ * the spec's version to `toVersion`, the step's `edits(specBefore)` (when
102
+ * declared) and the object after `up()`. Callers holding YAML text apply
103
+ * `edits` through the CST writer and fall back to re-serialising `spec`
104
+ * only for steps that supply none. Throws MigrationError when a step is
105
+ * missing or `toVersion` is below the spec's version (use `migrate`).
106
+ */
107
+ planUp(spec, toVersion) {
108
+ const fromVersion = (spec.version ?? 0) | 0;
109
+ if (toVersion < fromVersion) {
110
+ throw new MigrationError(`planUp: target v${toVersion} is below the spec's v${fromVersion} — downgrades go through migrate()`);
111
+ }
112
+ const steps = [];
113
+ let current = spec;
114
+ let editsComplete = true;
115
+ for (let v = fromVersion; v < toVersion; v++) {
116
+ const key = `${v}→${v + 1}`;
117
+ const step = this.migrations.get(key);
118
+ if (!step) {
119
+ throw new MigrationError(`no migration registered for ${key}`);
120
+ }
121
+ const edits = step.edits !== undefined ? step.edits(current) : undefined;
122
+ if (edits === undefined)
123
+ editsComplete = false;
124
+ current = step.up(current);
125
+ steps.push({
126
+ from: step.from,
127
+ to: step.to,
128
+ ...(edits !== undefined ? { edits } : {}),
129
+ spec: current,
130
+ });
131
+ }
132
+ return { spec: current, steps, editsComplete };
133
+ }
73
134
  /** Diagnostic: list registered migration keys. */
74
135
  list() {
75
136
  return [...this.migrations.keys()].sort();
@@ -78,7 +139,7 @@ export class MigrationEngine {
78
139
  * #43 — the highest version this engine can migrate TO, i.e. the current
79
140
  * spec-schema version. It is the max `to` across every registered migration
80
141
  * (`0` when none are registered — nothing to upgrade to). `crewhaus upgrade`
81
- * uses this as the drift target so the CLI never hardcodes "1".
142
+ * uses this as the drift target so the CLI never hardcodes a number.
82
143
  */
83
144
  latestVersion() {
84
145
  let latest = 0;
@@ -94,8 +155,8 @@ export class MigrationEngine {
94
155
  }
95
156
  }
96
157
  /**
97
- * Skeleton migration 0 → 1 that today is a no-op. Future schema changes
98
- * land here and bump the IR version on the up() side.
158
+ * Skeleton migration 0 → 1 (a version stamp only). Declares its edit list
159
+ * too, so a 0 2 walk is comment-preserving end to end.
99
160
  */
100
161
  export const NOOP_0_TO_1 = Object.freeze({
101
162
  from: 0,
@@ -106,10 +167,131 @@ export const NOOP_0_TO_1 = Object.freeze({
106
167
  down(spec) {
107
168
  return { ...spec, version: 0 };
108
169
  },
170
+ edits() {
171
+ return [{ path: ["version"], value: 1, rationale: "schema version stamp (0 → 1)" }];
172
+ },
173
+ });
174
+ function isRecord(v) {
175
+ return typeof v === "object" && v !== null && !Array.isArray(v);
176
+ }
177
+ /**
178
+ * Every place the spec schema hangs a `model_pool` block: the agent (cli /
179
+ * channel / managed / pipeline / research / batch / browser / voice-less
180
+ * pooled shapes), `agent.sub_agents.<name>`, workflow `steps[i]`, graph
181
+ * `nodes.<name>`, crew `roles.<name>`. Pure structural walk over the parsed
182
+ * object — no schema knowledge beyond the key names, so a spec of any
183
+ * target (or an unparseable-by-zod one) still enumerates.
184
+ */
185
+ export function findModelPools(spec) {
186
+ const out = [];
187
+ const consider = (path, block) => {
188
+ if (!isRecord(block))
189
+ return;
190
+ const pool = block["model_pool"];
191
+ if (isRecord(pool))
192
+ out.push({ path: [...path, "model_pool"], pool });
193
+ };
194
+ const agent = spec["agent"];
195
+ consider(["agent"], agent);
196
+ if (isRecord(agent) && isRecord(agent["sub_agents"])) {
197
+ for (const [name, def] of Object.entries(agent["sub_agents"])) {
198
+ consider(["agent", "sub_agents", name], def);
199
+ }
200
+ }
201
+ if (Array.isArray(spec["steps"])) {
202
+ spec["steps"].forEach((step, i) => consider(["steps", i], step));
203
+ }
204
+ for (const mapKey of ["nodes", "roles"]) {
205
+ const map = spec[mapKey];
206
+ if (!isRecord(map))
207
+ continue;
208
+ for (const [name, block] of Object.entries(map))
209
+ consider([mapKey, name], block);
210
+ }
211
+ return out;
212
+ }
213
+ /** A learned pool whose `reward.quality_source` is not yet explicit. */
214
+ function poolNeedsExplicitQualitySource(pool) {
215
+ if (pool["policy"] !== "learned")
216
+ return false;
217
+ const reward = pool["reward"];
218
+ if (reward === undefined || reward === null)
219
+ return true;
220
+ if (!isRecord(reward))
221
+ return false; // malformed — leave it for the validator to reject
222
+ return reward["quality_source"] === undefined;
223
+ }
224
+ /** Immutable deep-set along a string/index path (maps only — pool paths never cross arrays past `steps[i]`). */
225
+ function setAtPath(root, path, value) {
226
+ const step = (node, i) => {
227
+ const seg = path[i];
228
+ if (i === path.length - 1) {
229
+ if (Array.isArray(node)) {
230
+ const copy = [...node];
231
+ copy[Number(seg)] = value;
232
+ return copy;
233
+ }
234
+ return { ...(isRecord(node) ? node : {}), [seg]: value };
235
+ }
236
+ if (Array.isArray(node)) {
237
+ const copy = [...node];
238
+ copy[Number(seg)] = step(node[Number(seg)], i + 1);
239
+ return copy;
240
+ }
241
+ const rec = isRecord(node) ? node : {};
242
+ return { ...rec, [seg]: step(rec[seg], i + 1) };
243
+ };
244
+ return step(root, 0);
245
+ }
246
+ const QUALITY_SOURCE_RATIONALE = "0.6.0: a learned pool's reward quality source is stated explicitly (runtime default `none`)";
247
+ /**
248
+ * 0.6.0 §9.2 — the first REAL schema migration. Stamps `version: 2`, and
249
+ * adds `reward: { quality_source: none }` explicitly — but ONLY on a
250
+ * `model_pool` with `policy: learned` that does not already say (so the
251
+ * default that now steers a learned reward is visible in the file). Nothing
252
+ * else changes: every other 0.6.0 key is optional and absent-is-byte-identical.
253
+ *
254
+ * `edits()` and `up()` describe the same change; `planUp` consumers apply
255
+ * `edits` through the CST writer so the file's comments and key order
256
+ * survive. `down()` un-stamps the version and leaves the explicit default in
257
+ * place (it is valid at v1 and harmless) — the step is trivially reversible,
258
+ * so it is NOT marked `irreversible`.
259
+ */
260
+ export const MIGRATION_1_TO_2 = Object.freeze({
261
+ from: 1,
262
+ to: 2,
263
+ up(spec) {
264
+ let out = { ...spec, version: 2 };
265
+ for (const site of findModelPools(spec)) {
266
+ if (!poolNeedsExplicitQualitySource(site.pool))
267
+ continue;
268
+ out = setAtPath(out, [...site.path, "reward", "quality_source"], "none");
269
+ }
270
+ return out;
271
+ },
272
+ down(spec) {
273
+ return { ...spec, version: 1 };
274
+ },
275
+ edits(spec) {
276
+ const edits = [
277
+ { path: ["version"], value: 2, rationale: "schema version stamp (1 → 2)" },
278
+ ];
279
+ for (const site of findModelPools(spec)) {
280
+ if (!poolNeedsExplicitQualitySource(site.pool))
281
+ continue;
282
+ edits.push({
283
+ path: [...site.path, "reward", "quality_source"],
284
+ value: "none",
285
+ rationale: QUALITY_SOURCE_RATIONALE,
286
+ });
287
+ }
288
+ return edits;
289
+ },
109
290
  });
110
- /** A pre-built engine with the v0v1 skeleton registered. */
291
+ /** A pre-built engine with the full chain registered (0 1 2). */
111
292
  export function createDefaultEngine() {
112
293
  const e = new MigrationEngine();
113
294
  e.register(NOOP_0_TO_1);
295
+ e.register(MIGRATION_1_TO_2);
114
296
  return e;
115
297
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/migration-engine",
3
- "version": "0.5.7",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "IR version migrations: register up/down chains; migrate(spec, fromVersion, toVersion) walks the chain",
6
6
  "main": "dist/index.js",
@@ -15,7 +15,7 @@
15
15
  "test": "bun test src"
16
16
  },
17
17
  "dependencies": {
18
- "@crewhaus/errors": "0.5.7"
18
+ "@crewhaus/errors": "0.6.0"
19
19
  },
20
20
  "license": "Apache-2.0",
21
21
  "author": {