Skip to content

Commit 296274b

Browse files
committed
feat(v7-h04): spec schema_version + migrate-on-load framework
Closes V7-H04 (cppmega-mlx-odxp): persisted specs now carry a schema_version integer + a migration registry so future breaking changes (renamed brick kinds, removed params, default changes) can be applied automatically on load instead of silently corrupting the spec. New module vbgui/src/state/migrations.ts: - CURRENT_SCHEMA_VERSION = 1 - migrate(spec) walks from spec.schema_version up to current, applying registered migrate_v{n}_to_v{n+1} functions linearly. - migrate_v0_to_v1 stamps schema_version=1 onto pre-versioning specs (the demonstrative migration required by acceptance). - FutureSchemaError raised when loading a spec from a newer build. - Adding a new migration documented in the module header. App.tsx onLoadSpec now calls migrate(raw) before dispatching so saved bundles round-trip through the version chain. Future-version specs surface a FutureSchemaError into the existing error modal. Tests (vbgui/tests/migrations.test.ts): 4/4 — * v0 input → schema_version=1, other fields preserved. * Current version → no-op. * Future version → FutureSchemaError. * Nested fields preserved through migration. - Full vbgui vitest: 213/213. - V6 H09 Save→Load roundtrip e2e: 1/1 (proves migrate path is wired into App without breaking the existing parity gate). Backend mirror (Pydantic validation of inbound schema_version on RPC entry) is deferred to a follow-up V7-H04 sub-task since the current RPC layer accepts arbitrary spec dicts.
1 parent 129f425 commit 296274b

3 files changed

Lines changed: 115 additions & 4 deletions

File tree

vbgui/src/App.tsx

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { usePresets } from "@/hooks/usePresets";
2121
import {
2222
INITIAL_SPEC, specReducer, type SpecState, type TopologyFactory,
2323
} from "@/state/spec";
24+
import { migrate } from "@/state/migrations";
2425
import type { ShardingProposalView } from "@/components/sidebar/ShardingTab";
2526

2627
// PRESETS list is now fetched dynamically from the backend via
@@ -480,11 +481,16 @@ export function App(): JSX.Element {
480481
const reader = new FileReader();
481482
reader.onload = () => {
482483
try {
483-
const obj = JSON.parse(String(reader.result));
484+
const raw = JSON.parse(String(reader.result));
485+
// V7-H04: migrate-on-load so older saved bundles still
486+
// hydrate cleanly. Future-version specs throw a
487+
// FutureSchemaError that bubbles into the error modal.
488+
const obj = migrate(raw);
484489
if (obj.projectName) setProjectName(String(obj.projectName));
485-
if (obj.spec) dispatch({ type: "spec.replace", spec: obj.spec });
486-
if (obj.nodes) setNodes(obj.nodes);
487-
if (obj.edges) setEdges(obj.edges);
490+
if (obj.spec) dispatch({ type: "spec.replace",
491+
spec: obj.spec as never });
492+
if (obj.nodes) setNodes(obj.nodes as never[]);
493+
if (obj.edges) setEdges(obj.edges as never[]);
488494
} catch (e) {
489495
setRunError(`Load failed: ${String(e)}`);
490496
}

vbgui/src/state/migrations.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// V7-H04: spec schema versioning + migrate-on-load.
2+
//
3+
// `CURRENT_SCHEMA_VERSION` is the integer that fresh specs are stamped
4+
// with. Every breaking change to the persisted spec shape (renaming a
5+
// brick kind, removing a param, changing a default) must:
6+
// 1. Bump CURRENT_SCHEMA_VERSION.
7+
// 2. Add a pure migrate_v{n-1}_to_v{n} function below.
8+
// 3. Append it to MIGRATIONS (keyed by source version).
9+
//
10+
// migrate(spec) walks from the spec's stored version up to current and
11+
// returns the migrated object. Specs from a future version (i.e. saved
12+
// by a newer build) throw — the caller is expected to surface this as
13+
// a UI error rather than silently corrupting the spec.
14+
15+
export const CURRENT_SCHEMA_VERSION = 1;
16+
17+
export type SchemaVersion = number;
18+
19+
export interface VersionedSpec {
20+
schema_version?: SchemaVersion;
21+
[key: string]: unknown;
22+
}
23+
24+
export class FutureSchemaError extends Error {
25+
constructor(version: SchemaVersion) {
26+
super(
27+
`spec schema_version=${version} is newer than this build's ` +
28+
`CURRENT_SCHEMA_VERSION=${CURRENT_SCHEMA_VERSION}; upgrade the ` +
29+
`app or open the spec in a matching build.`,
30+
);
31+
this.name = "FutureSchemaError";
32+
}
33+
}
34+
35+
/** v0 → v1: stamp schema_version=1 onto pre-versioning specs. */
36+
function migrate_v0_to_v1(spec: VersionedSpec): VersionedSpec {
37+
return { ...spec, schema_version: 1 };
38+
}
39+
40+
const MIGRATIONS: Record<number,
41+
(s: VersionedSpec) => VersionedSpec> = {
42+
0: migrate_v0_to_v1,
43+
};
44+
45+
export function migrate(spec: VersionedSpec): VersionedSpec {
46+
let current: VersionedSpec = { ...spec };
47+
let v = typeof current.schema_version === "number"
48+
? current.schema_version
49+
: 0;
50+
if (v > CURRENT_SCHEMA_VERSION) {
51+
throw new FutureSchemaError(v);
52+
}
53+
while (v < CURRENT_SCHEMA_VERSION) {
54+
const step = MIGRATIONS[v];
55+
if (!step) {
56+
throw new Error(
57+
`V7-H04: no migration registered for schema_version=${v}; ` +
58+
`add migrate_v${v}_to_v${v + 1} to MIGRATIONS`,
59+
);
60+
}
61+
current = step(current);
62+
v = typeof current.schema_version === "number"
63+
? current.schema_version
64+
: v + 1;
65+
}
66+
return current;
67+
}

vbgui/tests/migrations.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { describe, it, expect } from "vitest";
2+
import {
3+
CURRENT_SCHEMA_VERSION, FutureSchemaError, migrate,
4+
} from "@/state/migrations";
5+
6+
describe("V7-H04 spec migrations", () => {
7+
it("stamps schema_version on a v0 (pre-versioning) spec", () => {
8+
const v0 = { projectName: "demo", spec: {}, nodes: [], edges: [] };
9+
const out = migrate(v0);
10+
expect(out.schema_version).toBe(1);
11+
expect(out.projectName).toBe("demo");
12+
});
13+
14+
it("returns spec unchanged when already at current version", () => {
15+
const cur = { schema_version: CURRENT_SCHEMA_VERSION,
16+
projectName: "demo" };
17+
const out = migrate(cur);
18+
expect(out.schema_version).toBe(CURRENT_SCHEMA_VERSION);
19+
expect(out.projectName).toBe("demo");
20+
});
21+
22+
it("throws FutureSchemaError on a spec from a newer build", () => {
23+
expect(() => migrate({ schema_version: 9_999 }))
24+
.toThrowError(FutureSchemaError);
25+
});
26+
27+
it("preserves nested fields through migration", () => {
28+
const v0 = {
29+
projectName: "P",
30+
spec: { loss: { kind: "cross_entropy" }, optim: { kind: "adamw" } },
31+
nodes: [{ id: "a" }],
32+
};
33+
const out = migrate(v0) as typeof v0 & { schema_version: number };
34+
expect(out.schema_version).toBe(1);
35+
expect(out.spec).toEqual(v0.spec);
36+
expect(out.nodes).toEqual(v0.nodes);
37+
});
38+
});

0 commit comments

Comments
 (0)