diff --git a/packages/excalidraw/data/schema.test.ts b/packages/excalidraw/data/schema.test.ts index fef2495ed3..f5428018dc 100644 --- a/packages/excalidraw/data/schema.test.ts +++ b/packages/excalidraw/data/schema.test.ts @@ -2,9 +2,13 @@ import { DEFAULT_ELEMENT_PROPS } from "@excalidraw/common"; import { API } from "../tests/helpers/api"; import { + ALL_SCOPES, + type SchemaMigration, migrateElementsBySchema, resolveSchemaVersion, + SCHEMA_MIGRATIONS, SCHEMA_VERSIONS, + validateSchemaMigrations, } from "./schema"; describe("schema migration", () => { @@ -13,14 +17,15 @@ describe("schema migration", () => { type: "frame", backgroundColor: "#ffc9c9", }); - (frame as any).backgroundEnabled = true; - const migrated = migrateElementsBySchema([frame], SCHEMA_VERSIONS.initial)!; + const migrated = migrateElementsBySchema([frame], { + schemaVersion: SCHEMA_VERSIONS.initial, + scope: "scene", + })!; expect(migrated[0].backgroundColor).toBe( DEFAULT_ELEMENT_PROPS.backgroundColor, ); - expect((migrated[0] as any).backgroundEnabled).toBeUndefined(); }); it("should keep latest-schema frame backgrounds unchanged", () => { @@ -28,30 +33,30 @@ describe("schema migration", () => { type: "frame", backgroundColor: "#ffc9c9", }); - (frame as any).backgroundEnabled = true; - const migrated = migrateElementsBySchema( - [frame], - SCHEMA_VERSIONS.latest, - )!; + const migrated = migrateElementsBySchema([frame], { + schemaVersion: SCHEMA_VERSIONS.latest, + scope: "scene", + })!; expect(migrated[0].backgroundColor).toBe("#ffc9c9"); - expect((migrated[0] as any).backgroundEnabled).toBeUndefined(); }); - it("should normalize legacy false-flag frame backgrounds", () => { + it("should normalize legacy frame backgrounds across all scopes", () => { const frame = API.createElement({ type: "frame", backgroundColor: "#a5d8ff", }); - (frame as any).backgroundEnabled = false; - const migrated = migrateElementsBySchema([frame], SCHEMA_VERSIONS.initial)!; - - expect(migrated[0].backgroundColor).toBe( - DEFAULT_ELEMENT_PROPS.backgroundColor, - ); - expect((migrated[0] as any).backgroundEnabled).toBeUndefined(); + for (const scope of ALL_SCOPES) { + const migrated = migrateElementsBySchema([frame], { + schemaVersion: SCHEMA_VERSIONS.initial, + scope, + })!; + expect(migrated[0].backgroundColor).toBe( + DEFAULT_ELEMENT_PROPS.backgroundColor, + ); + } }); it("should resolve invalid schema versions using fallback", () => { @@ -63,4 +68,34 @@ describe("schema migration", () => { ); expect(resolveSchemaVersion(2, SCHEMA_VERSIONS.initial)).toBe(2); }); + + it("should have a valid migration registry configuration", () => { + expect(validateSchemaMigrations(SCHEMA_MIGRATIONS)).toEqual([]); + }); + + it("should reject invalid migration metadata", () => { + const invalidMigrations: SchemaMigration[] = [ + { + version: 2.1, + title: "bad migration", + description: " ", + scope: [], + apply: (elements) => elements, + }, + { + version: 2.1, + title: "duplicate", + description: "duplicate version", + scope: ["scene"], + apply: (elements) => elements, + }, + ]; + + const errors = validateSchemaMigrations(invalidMigrations); + + expect(errors.length).toBeGreaterThan(0); + expect(errors.join("\n")).toContain("integer version"); + expect(errors.join("\n")).toContain("non-empty description"); + expect(errors.join("\n")).toContain("Duplicate schema migration version"); + }); }); diff --git a/packages/excalidraw/data/schema.ts b/packages/excalidraw/data/schema.ts index e352a6fb7f..732ec9694d 100644 --- a/packages/excalidraw/data/schema.ts +++ b/packages/excalidraw/data/schema.ts @@ -8,6 +8,48 @@ export const SCHEMA_VERSIONS = { latest: 2, } as const; +export const SCHEMA_MIGRATION_SCOPES = [ + "scene", + "library", + "clipboard", + "api", +] as const; + +export type SchemaMigrationScope = (typeof SCHEMA_MIGRATION_SCOPES)[number]; + +export const ALL_SCOPES: readonly SchemaMigrationScope[] = + SCHEMA_MIGRATION_SCOPES; + +export type SchemaMigration = { + version: number; + title: string; + description: string; + scope: readonly SchemaMigrationScope[]; + apply: ( + elements: readonly ExcalidrawElement[], + ) => readonly ExcalidrawElement[]; +}; + +export const SCHEMA_MIGRATIONS: readonly SchemaMigration[] = [ + { + version: SCHEMA_VERSIONS.frameBackgrounds, + title: "Normalize legacy frame backgrounds", + description: + "Frames saved before schema v2 must render without visible fill, so normalize their backgroundColor to transparent on restore.", + scope: ALL_SCOPES, + apply: (elements) => + elements.map((element) => { + if (element.type !== "frame") { + return element; + } + return { + ...element, + backgroundColor: DEFAULT_ELEMENT_PROPS.backgroundColor, + }; + }), + }, +]; + export const resolveSchemaVersion = ( schemaVersion: number | undefined, fallbackVersion: number, @@ -21,31 +63,94 @@ export const resolveSchemaVersion = ( return fallbackVersion; }; +export const validateSchemaMigrations = ( + migrations: readonly SchemaMigration[], +) => { + const errors: string[] = []; + const seenVersions = new Set(); + let previousVersion: number = SCHEMA_VERSIONS.initial; + + for (const migration of migrations) { + if (!Number.isInteger(migration.version)) { + errors.push( + `Migration "${migration.title}" must use an integer version.`, + ); + } + if (migration.version <= SCHEMA_VERSIONS.initial) { + errors.push( + `Migration "${migration.title}" version must be greater than schema initial version.`, + ); + } + if (seenVersions.has(migration.version)) { + errors.push( + `Duplicate schema migration version found: ${migration.version}.`, + ); + } + seenVersions.add(migration.version); + + if (migration.version <= previousVersion) { + errors.push( + `Migration "${migration.title}" must be ordered by increasing version.`, + ); + } + previousVersion = migration.version; + + if (!migration.description.trim()) { + errors.push( + `Migration "${migration.title}" must include a non-empty description.`, + ); + } + if (!migration.scope.length) { + errors.push(`Migration "${migration.title}" must declare at least one scope.`); + } + for (const scope of migration.scope) { + if (!SCHEMA_MIGRATION_SCOPES.includes(scope)) { + errors.push( + `Migration "${migration.title}" contains unsupported scope "${scope}".`, + ); + } + } + } + + if (migrations.length > 0 && previousVersion !== SCHEMA_VERSIONS.latest) { + errors.push( + `SCHEMA_VERSIONS.latest (${SCHEMA_VERSIONS.latest}) must match last migration version (${previousVersion}).`, + ); + } + + return errors; +}; + +const schemaMigrationValidationErrors = validateSchemaMigrations( + SCHEMA_MIGRATIONS, +); +if (schemaMigrationValidationErrors.length) { + throw new Error( + `Invalid schema migration configuration:\n${schemaMigrationValidationErrors.join("\n")}`, + ); +} + export const migrateElementsBySchema = ( elements: readonly ExcalidrawElement[] | null | undefined, - schemaVersion: number, + opts: { + schemaVersion: number; + scope: SchemaMigrationScope; + }, ) => { if (!elements) { return elements; } - return elements.map((element) => { - if (element.type !== "frame") { - return element; - } - - const { backgroundEnabled: _, ...frameWithoutBackgroundEnabled } = - element as ExcalidrawElement & { - backgroundEnabled?: boolean; - }; - - if (schemaVersion >= SCHEMA_VERSIONS.frameBackgrounds) { - return frameWithoutBackgroundEnabled; - } - - return { - ...frameWithoutBackgroundEnabled, - backgroundColor: DEFAULT_ELEMENT_PROPS.backgroundColor, - } as ExcalidrawElement; - }); + return SCHEMA_MIGRATIONS.reduce( + (acc, migration) => { + if (migration.version <= opts.schemaVersion) { + return acc; + } + if (!migration.scope.includes(opts.scope)) { + return acc; + } + return migration.apply(acc); + }, + elements, + ); };