chore: set up scoped schema migrations

This commit is contained in:
Ryan Di
2026-03-20 18:03:22 +11:00
parent b3b9b26979
commit ec458d92e3
2 changed files with 177 additions and 37 deletions
+52 -17
View File
@@ -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");
});
});
+125 -20
View File
@@ -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<number>();
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<readonly ExcalidrawElement[]>(
(acc, migration) => {
if (migration.version <= opts.schemaVersion) {
return acc;
}
if (!migration.scope.includes(opts.scope)) {
return acc;
}
return migration.apply(acc);
},
elements,
);
};