chore: set up scoped schema migrations
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user