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 { API } from "../tests/helpers/api";
|
||||||
import {
|
import {
|
||||||
|
ALL_SCOPES,
|
||||||
|
type SchemaMigration,
|
||||||
migrateElementsBySchema,
|
migrateElementsBySchema,
|
||||||
resolveSchemaVersion,
|
resolveSchemaVersion,
|
||||||
|
SCHEMA_MIGRATIONS,
|
||||||
SCHEMA_VERSIONS,
|
SCHEMA_VERSIONS,
|
||||||
|
validateSchemaMigrations,
|
||||||
} from "./schema";
|
} from "./schema";
|
||||||
|
|
||||||
describe("schema migration", () => {
|
describe("schema migration", () => {
|
||||||
@@ -13,14 +17,15 @@ describe("schema migration", () => {
|
|||||||
type: "frame",
|
type: "frame",
|
||||||
backgroundColor: "#ffc9c9",
|
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(
|
expect(migrated[0].backgroundColor).toBe(
|
||||||
DEFAULT_ELEMENT_PROPS.backgroundColor,
|
DEFAULT_ELEMENT_PROPS.backgroundColor,
|
||||||
);
|
);
|
||||||
expect((migrated[0] as any).backgroundEnabled).toBeUndefined();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should keep latest-schema frame backgrounds unchanged", () => {
|
it("should keep latest-schema frame backgrounds unchanged", () => {
|
||||||
@@ -28,30 +33,30 @@ describe("schema migration", () => {
|
|||||||
type: "frame",
|
type: "frame",
|
||||||
backgroundColor: "#ffc9c9",
|
backgroundColor: "#ffc9c9",
|
||||||
});
|
});
|
||||||
(frame as any).backgroundEnabled = true;
|
|
||||||
|
|
||||||
const migrated = migrateElementsBySchema(
|
const migrated = migrateElementsBySchema([frame], {
|
||||||
[frame],
|
schemaVersion: SCHEMA_VERSIONS.latest,
|
||||||
SCHEMA_VERSIONS.latest,
|
scope: "scene",
|
||||||
)!;
|
})!;
|
||||||
|
|
||||||
expect(migrated[0].backgroundColor).toBe("#ffc9c9");
|
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({
|
const frame = API.createElement({
|
||||||
type: "frame",
|
type: "frame",
|
||||||
backgroundColor: "#a5d8ff",
|
backgroundColor: "#a5d8ff",
|
||||||
});
|
});
|
||||||
(frame as any).backgroundEnabled = false;
|
|
||||||
|
|
||||||
const migrated = migrateElementsBySchema([frame], SCHEMA_VERSIONS.initial)!;
|
for (const scope of ALL_SCOPES) {
|
||||||
|
const migrated = migrateElementsBySchema([frame], {
|
||||||
expect(migrated[0].backgroundColor).toBe(
|
schemaVersion: SCHEMA_VERSIONS.initial,
|
||||||
DEFAULT_ELEMENT_PROPS.backgroundColor,
|
scope,
|
||||||
);
|
})!;
|
||||||
expect((migrated[0] as any).backgroundEnabled).toBeUndefined();
|
expect(migrated[0].backgroundColor).toBe(
|
||||||
|
DEFAULT_ELEMENT_PROPS.backgroundColor,
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should resolve invalid schema versions using fallback", () => {
|
it("should resolve invalid schema versions using fallback", () => {
|
||||||
@@ -63,4 +68,34 @@ describe("schema migration", () => {
|
|||||||
);
|
);
|
||||||
expect(resolveSchemaVersion(2, SCHEMA_VERSIONS.initial)).toBe(2);
|
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,
|
latest: 2,
|
||||||
} as const;
|
} 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 = (
|
export const resolveSchemaVersion = (
|
||||||
schemaVersion: number | undefined,
|
schemaVersion: number | undefined,
|
||||||
fallbackVersion: number,
|
fallbackVersion: number,
|
||||||
@@ -21,31 +63,94 @@ export const resolveSchemaVersion = (
|
|||||||
return fallbackVersion;
|
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 = (
|
export const migrateElementsBySchema = (
|
||||||
elements: readonly ExcalidrawElement[] | null | undefined,
|
elements: readonly ExcalidrawElement[] | null | undefined,
|
||||||
schemaVersion: number,
|
opts: {
|
||||||
|
schemaVersion: number;
|
||||||
|
scope: SchemaMigrationScope;
|
||||||
|
},
|
||||||
) => {
|
) => {
|
||||||
if (!elements) {
|
if (!elements) {
|
||||||
return elements;
|
return elements;
|
||||||
}
|
}
|
||||||
|
|
||||||
return elements.map((element) => {
|
return SCHEMA_MIGRATIONS.reduce<readonly ExcalidrawElement[]>(
|
||||||
if (element.type !== "frame") {
|
(acc, migration) => {
|
||||||
return element;
|
if (migration.version <= opts.schemaVersion) {
|
||||||
}
|
return acc;
|
||||||
|
}
|
||||||
const { backgroundEnabled: _, ...frameWithoutBackgroundEnabled } =
|
if (!migration.scope.includes(opts.scope)) {
|
||||||
element as ExcalidrawElement & {
|
return acc;
|
||||||
backgroundEnabled?: boolean;
|
}
|
||||||
};
|
return migration.apply(acc);
|
||||||
|
},
|
||||||
if (schemaVersion >= SCHEMA_VERSIONS.frameBackgrounds) {
|
elements,
|
||||||
return frameWithoutBackgroundEnabled;
|
);
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...frameWithoutBackgroundEnabled,
|
|
||||||
backgroundColor: DEFAULT_ELEMENT_PROPS.backgroundColor,
|
|
||||||
} as ExcalidrawElement;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user