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[] = [
|
const invalidMigrations: SchemaMigration[] = [
|
||||||
{
|
{
|
||||||
version: 2.1,
|
version: 2.1,
|
||||||
title: "bad migration",
|
title: "",
|
||||||
description: " ",
|
description: " ",
|
||||||
scope: [],
|
scope: [],
|
||||||
apply: (elements) => elements,
|
apply: (elements) => elements,
|
||||||
@@ -99,7 +99,65 @@ describe("schema migration", () => {
|
|||||||
|
|
||||||
expect(errors.length).toBeGreaterThan(0);
|
expect(errors.length).toBeGreaterThan(0);
|
||||||
expect(errors.join("\n")).toContain("integer version");
|
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("non-empty description");
|
||||||
expect(errors.join("\n")).toContain("Duplicate schema migration version");
|
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[] =
|
export const ALL_SCOPES: readonly SchemaMigrationScope[] =
|
||||||
SCHEMA_MIGRATION_SCOPES;
|
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 = {
|
export type SchemaMigration = {
|
||||||
version: number;
|
version: number;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -95,6 +108,9 @@ export const validateSchemaMigrations = (
|
|||||||
}
|
}
|
||||||
previousVersion = migration.version;
|
previousVersion = migration.version;
|
||||||
|
|
||||||
|
if (!migration.title.trim()) {
|
||||||
|
errors.push("Migration title must be non-empty.");
|
||||||
|
}
|
||||||
if (!migration.description.trim()) {
|
if (!migration.description.trim()) {
|
||||||
errors.push(
|
errors.push(
|
||||||
`Migration "${migration.title}" must include a non-empty description.`,
|
`Migration "${migration.title}" must include a non-empty description.`,
|
||||||
|
|||||||
Reference in New Issue
Block a user