chore: keep schema migration rules in code
This commit is contained in:
@@ -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.
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
Reference in New Issue
Block a user