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 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;
}; };
+5 -2
View File
@@ -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
+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"; 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;
});
}; };
+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, // 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"]),
}, },
); );