refactor: replace global schema version with per-track migration

This commit is contained in:
Ryan Di
2026-03-25 18:04:13 +11:00
parent 1d35cb406b
commit 16cf593978
4 changed files with 269 additions and 103 deletions
+1 -3
View File
@@ -43,9 +43,7 @@ import type { BinaryFiles } from "./types";
type ElementsClipboard = {
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
elements: readonly (NonDeletedExcalidrawElement & {
schemaVersion?: number;
})[];
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles | undefined;
};
+5 -2
View File
@@ -82,7 +82,7 @@ import {
getNormalizedZoom,
} from "../scene";
import { migrateElements, SCHEMA_VERSIONS } from "./schema";
import { migrateElements } from "./schema";
import type {
AppState,
@@ -287,7 +287,10 @@ const restoreElementWithProperties = <
width: element.width || 0,
height: element.height || 0,
seed: element.seed ?? 1,
schemaVersion: element.schemaVersion ?? SCHEMA_VERSIONS.latest,
schemaState:
element.schemaState && typeof element.schemaState === "object"
? element.schemaState
: { tracks: {} },
groupIds: element.groupIds ?? [],
frameId: element.frameId ?? null,
roundness: element.roundness
+262 -97
View File
@@ -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";
export const SCHEMA_VERSIONS = {
initial: 1,
frameBackgrounds: 2,
latest: 2,
} as const;
export {
CORE_FRAME_SCHEMA_TRACK,
CORE_SUPPORTED_TRACKS,
SCHEMA_CORE_NAMESPACE,
SCHEMA_INITIAL_TRACK_VERSION,
};
export type { SchemaNamespace, SchemaTrack };
/**
* Schema migration contract:
* - `version`: integer schema version, strictly increasing.
* - `title`: short human-readable label.
* - `description`: required plain-language explanation of the change.
* - `apply`: pure element transform.
*
* 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).
* Schema migration flow (per element):
* 1) Normalize element.schemaState.tracks (invalid/missing -> initial track version).
* 2) Iterate declared migrations in order.
* 3) For matching element types, apply only forward migrations supported by current app.
* 4) Persist migrated track versions back onto the element.
*/
/** One migration step for a single track version bump. */
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;
description: string;
apply: (
elements: readonly ExcalidrawElement[],
) => readonly ExcalidrawElement[];
/** Which element types this migration may transform ("*" = all). */
targetTypes: readonly ExcalidrawElement["type"][] | "*";
/** Pure transform for a single element. */
apply: (element: ExcalidrawElement) => ExcalidrawElement;
};
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",
description:
"Frames saved before schema v2 must render without visible fill, so normalize their backgroundColor to transparent on restore.",
apply: (elements) =>
elements.map((element) => {
if (element.type !== "frame") {
return element;
}
return {
...element,
backgroundColor: DEFAULT_ELEMENT_PROPS.backgroundColor,
};
}),
"Frames saved before frame track v2 must render without visible fill, so normalize backgroundColor to transparent on restore.",
targetTypes: ["frame"],
apply: (element) => {
if (element.type !== "frame") {
return element;
}
return {
...element,
backgroundColor: "transparent",
};
},
},
];
export const resolveSchemaVersion = (schemaVersion: number | undefined) => {
export const resolveTrackVersion = (trackVersion: unknown) => {
if (
Number.isInteger(schemaVersion) &&
(schemaVersion as number) >= SCHEMA_VERSIONS.initial
Number.isInteger(trackVersion) &&
(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 = (
migrations: readonly SchemaMigration[],
) => {
const errors: string[] = [];
const seenVersions = new Set<number>();
let previousVersion: number = SCHEMA_VERSIONS.initial;
const seenIds = new Set<string>();
const previousVersionByTrack = new Map<string, number>();
for (const migration of migrations) {
if (!Number.isInteger(migration.version)) {
errors.push(
`Migration "${migration.title}" must use an integer version.`,
);
if (!migration.id.trim()) {
errors.push("Migration id must be non-empty.");
}
if (migration.version <= SCHEMA_VERSIONS.initial) {
errors.push(
`Migration "${migration.title}" version must be greater than schema initial version.`,
);
if (seenIds.has(migration.id)) {
errors.push(`Duplicate schema migration id found: ${migration.id}.`);
}
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;
seenIds.add(migration.id);
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()) {
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) {
errors.push(
`SCHEMA_VERSIONS.latest (${SCHEMA_VERSIONS.latest}) must match last migration version (${previousVersion}).`,
);
for (const [track, supportedVersion] of Object.entries(
CORE_SUPPORTED_TRACKS,
)) {
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;
@@ -118,25 +275,47 @@ if (schemaMigrationValidationErrors.length) {
);
}
export const migrateElementsBySchema = (
elements: readonly ExcalidrawElement[] | null | undefined,
opts: {
schemaVersion: number;
},
) => {
if (!elements) {
return elements;
const migrateElement = (element: ExcalidrawElement) => {
// Always migrate from a normalized per-element schema state.
let migratedElement = ensureElementSchemaState(element);
for (const migration of SCHEMA_MIGRATIONS) {
if (migration.namespace !== SCHEMA_CORE_NAMESPACE) {
continue;
}
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[]>(
(acc, migration) => {
if (migration.version <= opts.schemaVersion) {
return acc;
}
return migration.apply(acc);
},
elements,
);
return migratedElement;
};
export const migrateElements = (
@@ -146,19 +325,5 @@ export const migrateElements = (
return elements;
}
return elements.map((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;
});
return elements.map((element) => migrateElement(element));
};
+1 -1
View File
@@ -30,7 +30,7 @@ export class HistoryDelta extends StoreDelta {
// as we always need to end up with a new version due to collaboration,
// approaching each undo / redo as a new user action
{
excludedProperties: new Set(["version", "versionNonce"]),
excludedProperties: new Set(["version", "versionNonce", "schemaState"]),
},
);