chore: keep schema migration rules in code

This commit is contained in:
Ryan Di
2026-03-20 18:17:00 +11:00
parent 67967c05fa
commit b818b3fe04
3 changed files with 75 additions and 28 deletions
@@ -1,27 +0,0 @@
# Schema Migration Guidelines
## Contract
Each migration entry must declare:
- `version`: integer schema version (monotonic)
- `title`: short label
- `description`: required plain-language explanation of what changed
- `scope`: one or more scopes (`scene`, `library`, `clipboard`, `api`)
- `apply`: pure element transform
## Versioning Rules
- Use integer versions only (`2`, `3`, `4`, ...).
- `SCHEMA_VERSIONS.latest` must match the latest migration version.
- Add a new migration when behavior changes.
## Scope Rules
- Prefer explicit scopes.
- Use `ALL_SCOPES` only when the same transform is required for every boundary.
- If a migration was missing a scope but transform is unchanged, widening scope is allowed.
## Field Stability
Migrations must only rely on stable, persisted schema fields.
Do not read or write temporary/PR-only fields.
+59 -1
View File
@@ -81,7 +81,7 @@ describe("schema migration", () => {
const invalidMigrations: SchemaMigration[] = [
{
version: 2.1,
title: "bad migration",
title: "",
description: " ",
scope: [],
apply: (elements) => elements,
@@ -99,7 +99,65 @@ describe("schema migration", () => {
expect(errors.length).toBeGreaterThan(0);
expect(errors.join("\n")).toContain("integer version");
expect(errors.join("\n")).toContain("title must be non-empty");
expect(errors.join("\n")).toContain("non-empty description");
expect(errors.join("\n")).toContain("Duplicate schema migration version");
});
it("should reject versions at or below initial", () => {
const errors = validateSchemaMigrations([
{
version: SCHEMA_VERSIONS.initial,
title: "invalid start",
description: "bad version",
scope: ["scene"],
apply: (elements) => elements,
},
]);
expect(errors.join("\n")).toContain("greater than schema initial version");
});
it("should reject latest-version mismatch", () => {
const errors = validateSchemaMigrations([
{
version: SCHEMA_VERSIONS.latest + 1,
title: "future migration",
description: "future migration for test",
scope: ["scene"],
apply: (elements) => elements,
},
]);
expect(errors.join("\n")).toContain(
"SCHEMA_VERSIONS.latest (2) must match last migration version",
);
});
it("should not depend on temporary fields during migration", () => {
const frame = API.createElement({
type: "frame",
backgroundColor: "#a5d8ff",
});
const withTempField = {
...frame,
backgroundEnabled: false,
} as typeof frame & { backgroundEnabled: boolean };
const migratedBase = migrateSceneElements(
[frame],
SCHEMA_VERSIONS.initial,
)!;
const migratedWithTempField = migrateSceneElements(
[withTempField],
SCHEMA_VERSIONS.initial,
)!;
expect(migratedBase[0].backgroundColor).toBe(
DEFAULT_ELEMENT_PROPS.backgroundColor,
);
expect(migratedWithTempField[0].backgroundColor).toBe(
DEFAULT_ELEMENT_PROPS.backgroundColor,
);
});
});
+16
View File
@@ -20,6 +20,19 @@ export type SchemaMigrationScope = (typeof SCHEMA_MIGRATION_SCOPES)[number];
export const ALL_SCOPES: readonly SchemaMigrationScope[] =
SCHEMA_MIGRATION_SCOPES;
/**
* Schema migration contract:
* - `version`: integer schema version, strictly increasing.
* - `title`: short human-readable label.
* - `description`: required plain-language explanation of the change.
* - `scope`: one or more ingestion boundaries (`scene`, `library`, `clipboard`, `api`).
* - `apply`: pure element transform.
*
* Rules:
* - Use integer versions only. `SCHEMA_VERSIONS.latest` must match the last migration version.
* - Prefer explicit scopes; use `ALL_SCOPES` only when the same transform is required everywhere.
* - Migrations should depend only on stable persisted schema fields (not temporary/PR-only fields).
*/
export type SchemaMigration = {
version: number;
title: string;
@@ -95,6 +108,9 @@ export const validateSchemaMigrations = (
}
previousVersion = migration.version;
if (!migration.title.trim()) {
errors.push("Migration title must be non-empty.");
}
if (!migration.description.trim()) {
errors.push(
`Migration "${migration.title}" must include a non-empty description.`,