diff --git a/packages/excalidraw/data/SCHEMA_MIGRATIONS.md b/packages/excalidraw/data/SCHEMA_MIGRATIONS.md deleted file mode 100644 index 91eadf3350..0000000000 --- a/packages/excalidraw/data/SCHEMA_MIGRATIONS.md +++ /dev/null @@ -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. diff --git a/packages/excalidraw/data/schema.test.ts b/packages/excalidraw/data/schema.test.ts index 1a41887ddb..f3e56d51b5 100644 --- a/packages/excalidraw/data/schema.test.ts +++ b/packages/excalidraw/data/schema.test.ts @@ -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, + ); + }); }); diff --git a/packages/excalidraw/data/schema.ts b/packages/excalidraw/data/schema.ts index e1e3b830a4..f53f7be51f 100644 --- a/packages/excalidraw/data/schema.ts +++ b/packages/excalidraw/data/schema.ts @@ -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.`,