refactor: replace global schema version with per-track migration
This commit is contained in:
@@ -43,9 +43,7 @@ import type { BinaryFiles } from "./types";
|
|||||||
|
|
||||||
type ElementsClipboard = {
|
type ElementsClipboard = {
|
||||||
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
|
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
|
||||||
elements: readonly (NonDeletedExcalidrawElement & {
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
schemaVersion?: number;
|
|
||||||
})[];
|
|
||||||
files: BinaryFiles | undefined;
|
files: BinaryFiles | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ import {
|
|||||||
getNormalizedZoom,
|
getNormalizedZoom,
|
||||||
} from "../scene";
|
} from "../scene";
|
||||||
|
|
||||||
import { migrateElements, SCHEMA_VERSIONS } from "./schema";
|
import { migrateElements } from "./schema";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AppState,
|
AppState,
|
||||||
@@ -287,7 +287,10 @@ const restoreElementWithProperties = <
|
|||||||
width: element.width || 0,
|
width: element.width || 0,
|
||||||
height: element.height || 0,
|
height: element.height || 0,
|
||||||
seed: element.seed ?? 1,
|
seed: element.seed ?? 1,
|
||||||
schemaVersion: element.schemaVersion ?? SCHEMA_VERSIONS.latest,
|
schemaState:
|
||||||
|
element.schemaState && typeof element.schemaState === "object"
|
||||||
|
? element.schemaState
|
||||||
|
: { tracks: {} },
|
||||||
groupIds: element.groupIds ?? [],
|
groupIds: element.groupIds ?? [],
|
||||||
frameId: element.frameId ?? null,
|
frameId: element.frameId ?? null,
|
||||||
roundness: element.roundness
|
roundness: element.roundness
|
||||||
|
|||||||
@@ -1,108 +1,265 @@
|
|||||||
import { DEFAULT_ELEMENT_PROPS } from "@excalidraw/common";
|
import {
|
||||||
|
CORE_FRAME_SCHEMA_TRACK,
|
||||||
|
SCHEMA_CORE_NAMESPACE,
|
||||||
|
SCHEMA_INITIAL_TRACK_VERSION,
|
||||||
|
CORE_SUPPORTED_TRACKS,
|
||||||
|
} from "@excalidraw/element/schema";
|
||||||
|
|
||||||
|
import type { SchemaNamespace, SchemaTrack } from "@excalidraw/element/schema";
|
||||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
export const SCHEMA_VERSIONS = {
|
export {
|
||||||
initial: 1,
|
CORE_FRAME_SCHEMA_TRACK,
|
||||||
frameBackgrounds: 2,
|
CORE_SUPPORTED_TRACKS,
|
||||||
latest: 2,
|
SCHEMA_CORE_NAMESPACE,
|
||||||
} as const;
|
SCHEMA_INITIAL_TRACK_VERSION,
|
||||||
|
};
|
||||||
|
export type { SchemaNamespace, SchemaTrack };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schema migration contract:
|
* Schema migration flow (per element):
|
||||||
* - `version`: integer schema version, strictly increasing.
|
* 1) Normalize element.schemaState.tracks (invalid/missing -> initial track version).
|
||||||
* - `title`: short human-readable label.
|
* 2) Iterate declared migrations in order.
|
||||||
* - `description`: required plain-language explanation of the change.
|
* 3) For matching element types, apply only forward migrations supported by current app.
|
||||||
* - `apply`: pure element transform.
|
* 4) Persist migrated track versions back onto the element.
|
||||||
*
|
|
||||||
* Rules:
|
|
||||||
* - Use integer versions only. `SCHEMA_VERSIONS.latest` must match the last migration version.
|
|
||||||
* - Migrations should depend only on stable persisted schema fields (not temporary/PR-only fields).
|
|
||||||
*/
|
*/
|
||||||
|
/** One migration step for a single track version bump. */
|
||||||
export type SchemaMigration = {
|
export type SchemaMigration = {
|
||||||
version: number;
|
/** Stable unique id for validation and debugging. */
|
||||||
|
id: string;
|
||||||
|
/** Owner of the migration: core or a host namespace. */
|
||||||
|
namespace: SchemaNamespace;
|
||||||
|
/** Version line this migration belongs to. */
|
||||||
|
track: SchemaTrack;
|
||||||
|
/** Target version reached after applying this migration. */
|
||||||
|
toVersion: number;
|
||||||
|
/** Human-readable metadata for maintainers/reviewers. */
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
apply: (
|
/** Which element types this migration may transform ("*" = all). */
|
||||||
elements: readonly ExcalidrawElement[],
|
targetTypes: readonly ExcalidrawElement["type"][] | "*";
|
||||||
) => readonly ExcalidrawElement[];
|
/** Pure transform for a single element. */
|
||||||
|
apply: (element: ExcalidrawElement) => ExcalidrawElement;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SCHEMA_MIGRATIONS: readonly SchemaMigration[] = [
|
export const SCHEMA_MIGRATIONS: readonly SchemaMigration[] = [
|
||||||
{
|
{
|
||||||
version: SCHEMA_VERSIONS.frameBackgrounds,
|
id: "core.frame.background.normalize.v2",
|
||||||
|
namespace: SCHEMA_CORE_NAMESPACE,
|
||||||
|
track: CORE_FRAME_SCHEMA_TRACK,
|
||||||
|
toVersion: CORE_SUPPORTED_TRACKS[CORE_FRAME_SCHEMA_TRACK],
|
||||||
title: "Normalize legacy frame backgrounds",
|
title: "Normalize legacy frame backgrounds",
|
||||||
description:
|
description:
|
||||||
"Frames saved before schema v2 must render without visible fill, so normalize their backgroundColor to transparent on restore.",
|
"Frames saved before frame track v2 must render without visible fill, so normalize backgroundColor to transparent on restore.",
|
||||||
apply: (elements) =>
|
targetTypes: ["frame"],
|
||||||
elements.map((element) => {
|
apply: (element) => {
|
||||||
if (element.type !== "frame") {
|
if (element.type !== "frame") {
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...element,
|
...element,
|
||||||
backgroundColor: DEFAULT_ELEMENT_PROPS.backgroundColor,
|
backgroundColor: "transparent",
|
||||||
};
|
};
|
||||||
}),
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const resolveSchemaVersion = (schemaVersion: number | undefined) => {
|
export const resolveTrackVersion = (trackVersion: unknown) => {
|
||||||
if (
|
if (
|
||||||
Number.isInteger(schemaVersion) &&
|
Number.isInteger(trackVersion) &&
|
||||||
(schemaVersion as number) >= SCHEMA_VERSIONS.initial
|
(trackVersion as number) >= SCHEMA_INITIAL_TRACK_VERSION
|
||||||
) {
|
) {
|
||||||
return schemaVersion as number;
|
return trackVersion as number;
|
||||||
}
|
}
|
||||||
return SCHEMA_VERSIONS.initial;
|
return SCHEMA_INITIAL_TRACK_VERSION;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeSchemaTracks = (tracks: unknown) => {
|
||||||
|
if (!tracks || typeof tracks !== "object") {
|
||||||
|
return {} as Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.entries(tracks as Record<string, unknown>).reduce<
|
||||||
|
Record<string, number>
|
||||||
|
>((acc, [track, version]) => {
|
||||||
|
const normalizedVersion = resolveTrackVersion(version);
|
||||||
|
if (normalizedVersion >= SCHEMA_INITIAL_TRACK_VERSION) {
|
||||||
|
acc[track] = normalizedVersion;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeElementSchemaState = (
|
||||||
|
element: ExcalidrawElement,
|
||||||
|
): ExcalidrawElement["schemaState"] => {
|
||||||
|
const tracks = normalizeSchemaTracks(
|
||||||
|
(
|
||||||
|
element as ExcalidrawElement & {
|
||||||
|
schemaState?: ExcalidrawElement["schemaState"];
|
||||||
|
}
|
||||||
|
).schemaState?.tracks,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tracks,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureElementSchemaState = (element: ExcalidrawElement) => {
|
||||||
|
const normalizedSchemaState = normalizeElementSchemaState(element);
|
||||||
|
|
||||||
|
// Fast path: avoid reallocating when element already has normalized state.
|
||||||
|
if (element.schemaState === normalizedSchemaState) {
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
Object.keys(element.schemaState?.tracks || {}).length ===
|
||||||
|
Object.keys(normalizedSchemaState.tracks).length &&
|
||||||
|
Object.entries(normalizedSchemaState.tracks).every(
|
||||||
|
([track, version]) => element.schemaState?.tracks?.[track] === version,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...element,
|
||||||
|
schemaState: normalizedSchemaState,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTrackVersion = (element: ExcalidrawElement, track: SchemaTrack) => {
|
||||||
|
return resolveTrackVersion(element.schemaState.tracks[track]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const withTrackVersion = (
|
||||||
|
element: ExcalidrawElement,
|
||||||
|
track: SchemaTrack,
|
||||||
|
version: number,
|
||||||
|
) => {
|
||||||
|
if (element.schemaState.tracks[track] === version) {
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...element,
|
||||||
|
schemaState: {
|
||||||
|
...element.schemaState,
|
||||||
|
tracks: {
|
||||||
|
...element.schemaState.tracks,
|
||||||
|
[track]: version,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const migrationMatchesElementType = (
|
||||||
|
migration: SchemaMigration,
|
||||||
|
element: ExcalidrawElement,
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
migration.targetTypes === "*" ||
|
||||||
|
migration.targetTypes.includes(element.type)
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const validateSchemaMigrations = (
|
export const validateSchemaMigrations = (
|
||||||
migrations: readonly SchemaMigration[],
|
migrations: readonly SchemaMigration[],
|
||||||
) => {
|
) => {
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
const seenVersions = new Set<number>();
|
const seenIds = new Set<string>();
|
||||||
let previousVersion: number = SCHEMA_VERSIONS.initial;
|
const previousVersionByTrack = new Map<string, number>();
|
||||||
|
|
||||||
for (const migration of migrations) {
|
for (const migration of migrations) {
|
||||||
if (!Number.isInteger(migration.version)) {
|
if (!migration.id.trim()) {
|
||||||
errors.push(
|
errors.push("Migration id must be non-empty.");
|
||||||
`Migration "${migration.title}" must use an integer version.`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (migration.version <= SCHEMA_VERSIONS.initial) {
|
if (seenIds.has(migration.id)) {
|
||||||
errors.push(
|
errors.push(`Duplicate schema migration id found: ${migration.id}.`);
|
||||||
`Migration "${migration.title}" version must be greater than schema initial version.`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (seenVersions.has(migration.version)) {
|
seenIds.add(migration.id);
|
||||||
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.title.trim()) {
|
if (!migration.title.trim()) {
|
||||||
errors.push("Migration title must be non-empty.");
|
errors.push(`Migration "${migration.id}" title must be non-empty.`);
|
||||||
}
|
}
|
||||||
if (!migration.description.trim()) {
|
if (!migration.description.trim()) {
|
||||||
errors.push(
|
errors.push(
|
||||||
`Migration "${migration.title}" must include a non-empty description.`,
|
`Migration "${migration.id}" must include a non-empty description.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isInteger(migration.toVersion)) {
|
||||||
|
errors.push(`Migration "${migration.id}" must use an integer version.`);
|
||||||
|
}
|
||||||
|
if (migration.toVersion <= SCHEMA_INITIAL_TRACK_VERSION) {
|
||||||
|
errors.push(
|
||||||
|
`Migration "${migration.id}" version must be greater than ${SCHEMA_INITIAL_TRACK_VERSION}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
migration.targetTypes !== "*" &&
|
||||||
|
(!migration.targetTypes.length ||
|
||||||
|
migration.targetTypes.some((type) => !type))
|
||||||
|
) {
|
||||||
|
errors.push(
|
||||||
|
`Migration "${migration.id}" must declare at least one target type.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackKey = `${migration.namespace}|${migration.track}`;
|
||||||
|
const previousVersion =
|
||||||
|
previousVersionByTrack.get(trackKey) ?? SCHEMA_INITIAL_TRACK_VERSION;
|
||||||
|
if (migration.toVersion <= previousVersion) {
|
||||||
|
errors.push(
|
||||||
|
`Migration "${migration.id}" must be ordered by increasing version within ${trackKey}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
previousVersionByTrack.set(trackKey, migration.toVersion);
|
||||||
|
|
||||||
|
if (
|
||||||
|
migration.namespace === SCHEMA_CORE_NAMESPACE &&
|
||||||
|
!migration.track.startsWith("excalidraw.")
|
||||||
|
) {
|
||||||
|
errors.push(
|
||||||
|
`Core migration "${migration.id}" must use an excalidraw.* track.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
migration.namespace === SCHEMA_CORE_NAMESPACE &&
|
||||||
|
!(migration.track in CORE_SUPPORTED_TRACKS)
|
||||||
|
) {
|
||||||
|
errors.push(
|
||||||
|
`Core migration "${migration.id}" track "${migration.track}" must be declared in CORE_SUPPORTED_TRACKS.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
migration.namespace !== SCHEMA_CORE_NAMESPACE &&
|
||||||
|
!migration.track.startsWith(`${migration.namespace}.`)
|
||||||
|
) {
|
||||||
|
errors.push(
|
||||||
|
`Host migration "${migration.id}" track must use namespace prefix ${migration.namespace}.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (migrations.length > 0 && previousVersion !== SCHEMA_VERSIONS.latest) {
|
for (const [track, supportedVersion] of Object.entries(
|
||||||
errors.push(
|
CORE_SUPPORTED_TRACKS,
|
||||||
`SCHEMA_VERSIONS.latest (${SCHEMA_VERSIONS.latest}) must match last migration version (${previousVersion}).`,
|
)) {
|
||||||
);
|
const migrationTrackKey = `${SCHEMA_CORE_NAMESPACE}|${track}`;
|
||||||
|
const lastDeclaredVersion =
|
||||||
|
previousVersionByTrack.get(migrationTrackKey) ??
|
||||||
|
SCHEMA_INITIAL_TRACK_VERSION;
|
||||||
|
|
||||||
|
if (lastDeclaredVersion !== supportedVersion) {
|
||||||
|
errors.push(
|
||||||
|
`Core supported track "${track}" (${supportedVersion}) must match last migration version (${lastDeclaredVersion}).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return errors;
|
return errors;
|
||||||
@@ -118,25 +275,47 @@ if (schemaMigrationValidationErrors.length) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const migrateElementsBySchema = (
|
const migrateElement = (element: ExcalidrawElement) => {
|
||||||
elements: readonly ExcalidrawElement[] | null | undefined,
|
// Always migrate from a normalized per-element schema state.
|
||||||
opts: {
|
let migratedElement = ensureElementSchemaState(element);
|
||||||
schemaVersion: number;
|
|
||||||
},
|
for (const migration of SCHEMA_MIGRATIONS) {
|
||||||
) => {
|
if (migration.namespace !== SCHEMA_CORE_NAMESPACE) {
|
||||||
if (!elements) {
|
continue;
|
||||||
return elements;
|
}
|
||||||
|
|
||||||
|
if (!migrationMatchesElementType(migration, migratedElement)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentTrackVersion = getTrackVersion(
|
||||||
|
migratedElement,
|
||||||
|
migration.track,
|
||||||
|
);
|
||||||
|
const supportedTrackVersion =
|
||||||
|
CORE_SUPPORTED_TRACKS[
|
||||||
|
migration.track as keyof typeof CORE_SUPPORTED_TRACKS
|
||||||
|
] ?? currentTrackVersion;
|
||||||
|
|
||||||
|
// Never re-run or downgrade.
|
||||||
|
if (currentTrackVersion >= migration.toVersion) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve future data: ignore migrations newer than what this app supports.
|
||||||
|
if (migration.toVersion > supportedTrackVersion) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply transform, then stamp the element's track version.
|
||||||
|
migratedElement = withTrackVersion(
|
||||||
|
migration.apply(migratedElement),
|
||||||
|
migration.track,
|
||||||
|
migration.toVersion,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return SCHEMA_MIGRATIONS.reduce<readonly ExcalidrawElement[]>(
|
return migratedElement;
|
||||||
(acc, migration) => {
|
|
||||||
if (migration.version <= opts.schemaVersion) {
|
|
||||||
return acc;
|
|
||||||
}
|
|
||||||
return migration.apply(acc);
|
|
||||||
},
|
|
||||||
elements,
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const migrateElements = (
|
export const migrateElements = (
|
||||||
@@ -146,19 +325,5 @@ export const migrateElements = (
|
|||||||
return elements;
|
return elements;
|
||||||
}
|
}
|
||||||
|
|
||||||
return elements.map((element) => {
|
return elements.map((element) => migrateElement(element));
|
||||||
const schemaVersion = resolveSchemaVersion(element.schemaVersion);
|
|
||||||
const migratedElement =
|
|
||||||
migrateElementsBySchema([element], {
|
|
||||||
schemaVersion,
|
|
||||||
})?.[0] || element;
|
|
||||||
|
|
||||||
if (
|
|
||||||
resolveSchemaVersion(migratedElement.schemaVersion) <
|
|
||||||
SCHEMA_VERSIONS.latest
|
|
||||||
) {
|
|
||||||
(migratedElement as any).schemaVersion = SCHEMA_VERSIONS.latest;
|
|
||||||
}
|
|
||||||
return migratedElement;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export class HistoryDelta extends StoreDelta {
|
|||||||
// as we always need to end up with a new version due to collaboration,
|
// as we always need to end up with a new version due to collaboration,
|
||||||
// approaching each undo / redo as a new user action
|
// approaching each undo / redo as a new user action
|
||||||
{
|
{
|
||||||
excludedProperties: new Set(["version", "versionNonce"]),
|
excludedProperties: new Set(["version", "versionNonce", "schemaState"]),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user