From 16cf593978850e86e9f4e2bfb9ea3ffacda18144 Mon Sep 17 00:00:00 2001 From: Ryan Di Date: Wed, 25 Mar 2026 18:04:13 +1100 Subject: [PATCH] refactor: replace global schema version with per-track migration --- packages/excalidraw/clipboard.ts | 4 +- packages/excalidraw/data/restore.ts | 7 +- packages/excalidraw/data/schema.ts | 359 ++++++++++++++++++++-------- packages/excalidraw/history.ts | 2 +- 4 files changed, 269 insertions(+), 103 deletions(-) diff --git a/packages/excalidraw/clipboard.ts b/packages/excalidraw/clipboard.ts index 2cbca45cba..9e45b0182b 100644 --- a/packages/excalidraw/clipboard.ts +++ b/packages/excalidraw/clipboard.ts @@ -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; }; diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index 8231642adb..4508767620 100644 --- a/packages/excalidraw/data/restore.ts +++ b/packages/excalidraw/data/restore.ts @@ -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 diff --git a/packages/excalidraw/data/schema.ts b/packages/excalidraw/data/schema.ts index 50ec2a3fd1..68d6dfedda 100644 --- a/packages/excalidraw/data/schema.ts +++ b/packages/excalidraw/data/schema.ts @@ -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; + } + + return Object.entries(tracks as Record).reduce< + Record + >((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(); - let previousVersion: number = SCHEMA_VERSIONS.initial; + const seenIds = new Set(); + const previousVersionByTrack = new Map(); 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( - (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)); }; diff --git a/packages/excalidraw/history.ts b/packages/excalidraw/history.ts index 482065be45..a3c3ba1cbf 100644 --- a/packages/excalidraw/history.ts +++ b/packages/excalidraw/history.ts @@ -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"]), }, );